Documentation
¶
Overview ¶
Package exercise ejerce la superficie pública de Quark contra cada motor, marcando los símbolos que invoca (para el gate de cobertura del manifiesto) y asertando el resultado funcional. Reusa engine.Run (S4) para el lifecycle por motor + el chequeo de fugas; añade el recorder por motor y la cobertura.
Un Exerciser cubre un área (crud, builder, relations, tx, cache, tenant, migrate, security, ha, observability). Cada uno es un `fn` que engine.Run corre por motor; los asserts funcionales viven dentro de cada Exerciser.
Index ¶
- Variables
- func CM(method string) string
- func Coverage(results map[control.Engine]EngineResult) control.Invoked
- func MIG(name string) string
- func QF(name string) string
- func QM(method string) string
- func Run(conns map[control.Engine]engine.Conn, tol int, exercisers []Exerciser) map[control.Engine]EngineResult
- func TRM(method string) string
- type Conn
- type EngineResult
- type Exerciser
Constants ¶
This section is empty.
Variables ¶
var BUILDER = Exerciser{Name: "builder", Fn: func(ctx context.Context, client *quark.Client, rec *recorder.Recorder, _ Conn) error { rec.Note(QF("For")) n := atomic.AddInt64(&builderSeq, 1) owner := &domain.Account{Email: fmt.Sprintf("builder%d@superapp.test", n), Name: "b", Role: "member", Active: true} if err := quark.For[domain.Account](ctx, client).Create(owner); err != nil { return fmt.Errorf("seed owner: %w", err) } proj := &domain.Project{OwnerID: owner.ID, Name: "builder-proj", Status: "active"} if err := quark.For[domain.Project](ctx, client).Create(proj); err != nil { return fmt.Errorf("seed project: %w", err) } for p := 1; p <= 5; p++ { t := &domain.Task{ProjectID: proj.ID, Title: fmt.Sprintf("t%d", p), Priority: p, Done: p%2 == 0} if err := quark.For[domain.Task](ctx, client).Create(t); err != nil { return fmt.Errorf("seed task: %w", err) } } scope := func() *quark.Query[domain.Task] { return quark.For[domain.Task](ctx, client).Where("project_id", "=", proj.ID) } if sum, err := quark.For[domain.Task](rec.Mark(ctx, QM("Sum")), client).Where("project_id", "=", proj.ID).Sum("priority"); err != nil || sum != 15 { return fmt.Errorf("Sum(priority)=%v err=%v, esperaba 15", sum, err) } if avg, err := quark.For[domain.Task](rec.Mark(ctx, QM("Avg")), client).Where("project_id", "=", proj.ID).Avg("priority"); err != nil || avg != 3 { return fmt.Errorf("Avg=%v err=%v, esperaba 3", avg, err) } if mn, err := quark.For[domain.Task](rec.Mark(ctx, QM("Min")), client).Where("project_id", "=", proj.ID).Min("priority"); err != nil || mn != 1 { return fmt.Errorf("Min=%v err=%v, esperaba 1", mn, err) } if mx, err := quark.For[domain.Task](rec.Mark(ctx, QM("Max")), client).Where("project_id", "=", proj.ID).Max("priority"); err != nil || mx != 5 { return fmt.Errorf("Max=%v err=%v, esperaba 5", mx, err) } rec.Note(QM("Select"), QM("GroupBy"), QM("Having")) groups, err := scope().Select("done").GroupBy("done").Having("done", "=", true).List() if err != nil { return fmt.Errorf("Select/GroupBy/Having: %w", err) } if len(groups) == 0 { return fmt.Errorf("GroupBy no devolvió grupos") } if c, err := quark.For[domain.Task](rec.Mark(ctx, QM("WhereIn")), client).Where("project_id", "=", proj.ID).WhereIn("priority", []any{1, 2, 3}).Count(); err != nil || c != 3 { return fmt.Errorf("WhereIn count=%d err=%v, esperaba 3", c, err) } if c, err := scope().Where("priority", "=", 5).Or(func(q *quark.Query[domain.Task]) *quark.Query[domain.Task] { return q.Where("project_id", "=", proj.ID).Where("priority", "=", 1) }).Count(); err != nil || c < 1 { return fmt.Errorf("Or count=%d err=%v, esperaba >=1", c, err) } rec.Note(QM("Or")) rec.Note(QM("OrderBy"), QM("Offset")) top, err := scope().OrderBy("priority", "DESC").Offset(1).Limit(1).List() if err != nil || len(top) != 1 || top[0].Priority != 4 { return fmt.Errorf("OrderBy/Offset: %+v err=%v, esperaba priority=4", top, err) } rec.Note(QM("Distinct")) if _, err := scope().Distinct().List(); err != nil { return fmt.Errorf("Distinct: %w", err) } want := top[0].ID found, err := quark.For[domain.Task](rec.Mark(ctx, QM("Find")), client).Find(want) if err != nil || found.ID != want { return fmt.Errorf("Find(%d)=%+v err=%v", want, found, err) } count := 0 if err := scope().Iter(func(domain.Task) error { count++; return nil }); err != nil { return fmt.Errorf("Iter: %w", err) } if count != 5 { return fmt.Errorf("Iter contó %d, esperaba 5", count) } rec.Note(QM("Iter")) cur, err := scope().Cursor() if err != nil { return fmt.Errorf("Cursor: %w", err) } cn := 0 for cur.Next() { var t domain.Task if err := cur.Scan(&t); err != nil { _ = cur.Close() return fmt.Errorf("Cursor.Scan: %w", err) } cn++ } if err := cur.Err(); err != nil { _ = cur.Close() return fmt.Errorf("Cursor.Err: %w", err) } if err := cur.Close(); err != nil { return fmt.Errorf("Cursor.Close: %w", err) } if cn != 5 { return fmt.Errorf("Cursor contó %d, esperaba 5", cn) } rec.Note(QM("Cursor")) page, err := scope().Paginate(2, 0) if err != nil { return fmt.Errorf("Paginate: %w", err) } if page.Total != 5 || len(page.Items) != 2 { return fmt.Errorf("Paginate total=%d items=%d, esperaba 5/2", page.Total, len(page.Items)) } rec.Note(QM("Paginate")) return nil }}
BUILDER ejerce la superficie de construcción de queries sobre datos propios y deterministas: agregados, group/having, filtrado (WhereIn/Or), orden/paginado, streaming (Iter/Cursor), Find. Setops/locking/CTE quedan para exercisers posteriores (necesitan matriz de capacidad por motor).
var CACHE = Exerciser{Name: "cache", Fn: runCache}
CacheExerciser ejerce las garantías de la caché L2 integrada (ADR-0004) por CONTEO de statements (diff de rec.Count()), no por inspección del store — reusa el patrón de recorder/infra_test.go. La caché la instala el suite en newClient (WithCacheStore(memory.New())); el store dormita para los exercisers que no llaman .Cache() y se cierra en el fn del suite antes del leak-check (cleanupLoop es una goroutine).
Tres garantías observables sin tocar el store:
- hit = 0 SQL — una 2ª query idéntica con .Cache() no ejecuta SQL (el middleware del recorder no se dispara → Count() no cambia).
- invalidación por mutación — un Create sobre la tabla cacheada llama InvalidateTags(tabla) (la misma tag que .Cache() auto-añade), así que la siguiente .Cache() vuelve a ejecutar.
- N+1 acotado — un Preload de M padres suma 1 statement (hijos vía IN ...), no M: el delta de Count() queda en 2 (padres + IN), no en 1+M.
var CRUD = Exerciser{Name: "crud", Fn: func(ctx context.Context, client *quark.Client, rec *recorder.Recorder, _ Conn) error { rec.Note(QF("For")) email := fmt.Sprintf("crud%d@superapp.test", atomic.AddInt64(&crudSeq, 1)) a := &domain.Account{Email: email, Name: "crud", Role: "member", Active: true} if err := quark.For[domain.Account](rec.Mark(ctx, QM("Create")), client).Create(a); err != nil { return fmt.Errorf("create: %w", err) } if a.ID == 0 { return fmt.Errorf("create no asignó ID") } rec.Note(QM("Where")) got, err := quark.For[domain.Account](rec.Mark(ctx, QM("First")), client).Where("email", "=", email).First() if err != nil { return fmt.Errorf("first: %w", err) } if got.ID != a.ID || got.Email != email { return fmt.Errorf("first round-trip roto: got id=%d email=%q", got.ID, got.Email) } n, err := quark.For[domain.Account](rec.Mark(ctx, QM("Count")), client).Where("email", "=", email).Count() if err != nil { return fmt.Errorf("count: %w", err) } if n != 1 { return fmt.Errorf("count=%d, esperaba 1", n) } got.Name = "crud-updated" rows, err := quark.For[domain.Account](rec.Mark(ctx, QM("Update")), client).Update(&got) if err != nil { return fmt.Errorf("update: %w", err) } if rows != 1 { return fmt.Errorf("update afectó %d filas, esperaba 1", rows) } fresh, err := quark.For[domain.Account](ctx, client).Where("id", "=", a.ID).First() if err != nil { return fmt.Errorf("reread: %w", err) } if fresh.Name != "crud-updated" { return fmt.Errorf("update no persistió: name=%q", fresh.Name) } if _, err := quark.For[domain.Account](rec.Mark(ctx, QM("Delete")), client).Delete(&fresh); err != nil { return fmt.Errorf("delete: %w", err) } after, err := quark.For[domain.Account](ctx, client).Where("id", "=", a.ID).Count() if err != nil { return fmt.Errorf("count post-delete: %w", err) } if after != 0 { return fmt.Errorf("soft-delete no excluyó la fila: count=%d", after) } rec.Note(QM("Limit")) if _, err := quark.For[domain.Account](rec.Mark(ctx, QM("List")), client).Limit(10).List(); err != nil { return fmt.Errorf("list: %w", err) } return nil }}
CRUD es el patrón canónico: Create → First → Count → Update → Delete(soft) → List, con un assert funcional por paso y marcando cada símbolo invocado.
var DBPERTENANT = Exerciser{Name: "tenant-db-per", Fn: runDBPerTenant}
DBPERTENANT ejerce la estrategia DatabasePerTenant (ADR-0007): el router abre un *Client por tenant vía factory y los cachea en un LRU. Se asertan las dos garantías del contrato:
- Aislamiento físico — cada tenant ve sólo su base (counts exactos, sin columna tenant_id de por medio), y los datos sobreviven al ciclo del pool (evicción → re-open → siguen ahí).
- El LRU evicta — con MaxCachedPools=1 y 2 tenants alternados, el factory se invoca en cada cambio de tenant (4 veces, no 2) y ActiveTenants() refleja sólo el pool vivo. La evicción cierra el client evictado (async); el exerciser cierra además todos los que abrió antes del leak-check.
Aprovisionamiento por motor (FeatDBPerTenantProvision): SQLite = un fichero por tenant; PG/MySQL/MariaDB/MSSQL = CREATE DATABASE vía un admin client (client.Exec va directo a db.ExecContext, sin tx — PG exige CREATE DATABASE fuera de tx) + rewrite del DSN (tenant_dsn.go). Oracle se salta documentado: una database por tenant ahí es un PDB, fuera del alcance del harness.
var MIGRATE = Exerciser{Name: "migrate", Fn: func(ctx context.Context, client *quark.Client, rec *recorder.Recorder, conn Conn) error { eng := rec.Engine() p0, err := client.PlanMigration(rec.Mark(ctx, CM("PlanMigration")), domain.AllModels()...) if err != nil { return fmt.Errorf("plan converge: %w", err) } if !p0.IsEmpty() { if err := client.ApplyPlan(rec.Mark(ctx, CM("ApplyPlan")), p0); err != nil { return fmt.Errorf("converge (restos de un run anterior): %w", err) } } rec.Note(CM("Dialect"), CM("Raw")) _, _ = client.Raw().ExecContext(ctx, "DELETE FROM quark_backfill_state WHERE name = "+client.Dialect().Placeholder(1), backfillName) _, _ = client.Raw().ExecContext(ctx, fmt.Sprintf( "DELETE FROM quark_migrations WHERE id IN (%s, %s)", client.Dialect().Placeholder(1), client.Dialect().Placeholder(2)), migVNotesID, migVSeedID) p1, err := client.PlanMigration(ctx, domain.AllModels()...) if err != nil { return fmt.Errorf("plan round-trip: %w", err) } rec.Note(QF("(Plan).IsEmpty"), QF("(Plan).String"), QF("Plan")) if !p1.IsEmpty() { return fmt.Errorf("round-trip roto: el plan post-Migrate no es vacío:\n%s", p1.String()) } if got := p1.String(); got != "(no changes)" { return fmt.Errorf("Plan.String() de plan vacío = %q, esperaba \"(no changes)\"", got) } models2 := append(domain.AllModels(), &migrateLedger{}) p2, err := client.PlanMigration(ctx, models2...) if err != nil { return fmt.Errorf("plan con ledger: %w", err) } if p2.IsEmpty() { return fmt.Errorf("el plan con el modelo nuevo debería contener su CREATE TABLE") } if !strings.Contains(p2.String(), ledgerTable) { return fmt.Errorf("el plan no menciona %s:\n%s", ledgerTable, p2.String()) } rec.Note(QF("(Plan).Hash")) if h := p2.Hash(); h == "" || h != p2.Hash() { return fmt.Errorf("Plan.Hash() debe ser determinista y no-vacío (got %q)", h) } if err := client.ApplyPlan(rec.Mark(ctx, CM("ApplyPlan")), p2); err != nil { return fmt.Errorf("apply ledger: %w", err) } p3, err := client.PlanMigration(ctx, models2...) if err != nil { return fmt.Errorf("plan post-apply: %w", err) } if !p3.IsEmpty() { return fmt.Errorf("round-trip post-ApplyPlan del ledger roto:\n%s", p3.String()) } if err := quark.For[migrateLedger](rec.Mark(ctx, QM("Create")), client).Create(&migrateLedger{Ref: "r-1", Amount: 10}); err != nil { return fmt.Errorf("create en tabla creada por plan: %w", err) } if n, err := quark.For[migrateLedger](ctx, client).Count(); err != nil || n != 1 { return fmt.Errorf("count en ledger: n=%d err=%v", n, err) } if err := client.CreateIndex(rec.Mark(ctx, CM("CreateIndex")), ledgerTable, "idx_superapp_migrate_ref", []string{"ref"}, false); err != nil { return fmt.Errorf("create index: %w", err) } p4, err := client.PlanMigration(ctx, models2...) if err != nil { return fmt.Errorf("plan post-índice: %w", err) } if !p4.IsEmpty() { return fmt.Errorf("el índice manual generó drift en el plan (mergeNonColumnSurface roto):\n%s", p4.String()) } rec.Note(CM("RegisteredModels")) if len(client.RegisteredModels()) == 0 { if err := client.MigrateRegistered(rec.Mark(ctx, CM("MigrateRegistered"))); err != nil { return fmt.Errorf("MigrateRegistered sin modelos debía ser no-op nil, got %v", err) } } rec.Note(CM("RegisterModel")) if err := client.RegisterModel(models2...); err != nil { return fmt.Errorf("RegisterModel: %w", err) } if got := len(client.RegisteredModels()); got != len(models2) { return fmt.Errorf("RegisteredModels()=%d, esperaba %d", got, len(models2)) } if err := client.MigrateRegistered(rec.Mark(ctx, CM("MigrateRegistered"))); err != nil { return fmt.Errorf("MigrateRegistered (todo existente): %w", err) } pr, err := client.PlanMigrationRegistered(rec.Mark(ctx, CM("PlanMigrationRegistered"))) if err != nil { return fmt.Errorf("PlanMigrationRegistered: %w", err) } if !pr.IsEmpty() { return fmt.Errorf("PlanMigrationRegistered no-vacío con todo migrado:\n%s", pr.String()) } rec.Note(QF("SyncOptions")) if has, err := hasColumn(rec.Mark(ctx, CM("IntrospectSchema")), client, ledgerTable, "note"); err != nil { return fmt.Errorf("introspect pre-sync: %w", err) } else if has { return fmt.Errorf("la columna note no debería existir antes del Sync") } if err := client.Sync(rec.Mark(ctx, CM("Sync")), quark.SyncOptions{DryRun: true}, &migrateLedgerV2{}); err != nil { return fmt.Errorf("sync dry-run: %w", err) } if has, err := hasColumn(ctx, client, ledgerTable, "note"); err != nil { return fmt.Errorf("introspect post-dry-run: %w", err) } else if has { return fmt.Errorf("Sync con DryRun ejecutó DDL (la columna note existe)") } if err := client.Sync(ctx, quark.SyncOptions{}, &migrateLedgerV2{}); err != nil { return fmt.Errorf("sync add column: %w", err) } if has, err := hasColumn(ctx, client, ledgerTable, "note"); err != nil { return fmt.Errorf("introspect post-sync: %w", err) } else if !has { return fmt.Errorf("Sync no añadió la columna note") } got, err := quark.For[migrateLedgerV2](ctx, client).Where("ref", "=", "r-1").First() if err != nil { return fmt.Errorf("first V2: %w", err) } got.Note = quark.Nullable[string]{V: syncedNoteVal, Valid: true} if rows, err := quark.For[migrateLedgerV2](rec.Mark(ctx, QM("Update")), client).Update(&got); err != nil || rows != 1 { return fmt.Errorf("update V2: rows=%d err=%v", rows, err) } if re, err := quark.For[migrateLedgerV2](ctx, client).Where("ref", "=", "r-1").First(); err != nil || !re.Note.Valid || re.Note.V != syncedNoteVal { return fmt.Errorf("la columna añadida por Sync no hizo round-trip: note=%+v err=%v", re.Note, err) } if err := client.Sync(ctx, quark.SyncOptions{NoTransaction: true}, &migrateLedger{}); err != nil { return fmt.Errorf("sync drop column: %w", err) } if has, err := hasColumn(ctx, client, ledgerTable, "note"); err != nil { return fmt.Errorf("introspect post-drop: %w", err) } else if has { return fmt.Errorf("Sync de vuelta a V1 no dropeó la columna note (SafeMigrations=false)") } modelsV2 := append(domain.AllModels(), &migrateLedgerV2{}) pAdd, err := client.PlanMigration(ctx, modelsV2...) if err != nil { return fmt.Errorf("plan add-column: %w", err) } if pAdd.IsEmpty() || !strings.Contains(pAdd.String(), "note") { return fmt.Errorf("el plan V2 debía proponer la columna note:\n%s", pAdd.String()) } if err := client.ApplyPlan(rec.Mark(ctx, CM("ApplyPlan")), pAdd); err != nil { return fmt.Errorf("apply add-column: %w", err) } if has, err := hasColumn(ctx, client, ledgerTable, "note"); err != nil { return fmt.Errorf("introspect post-apply-add: %w", err) } else if !has { return fmt.Errorf("ApplyPlan no añadió la columna note") } pDropCol, err := client.PlanMigration(ctx, models2...) if err != nil { return fmt.Errorf("plan drop-column: %w", err) } if pDropCol.IsEmpty() { return fmt.Errorf("el plan de vuelta a V1 debía proponer el drop de note") } if err := client.ApplyPlan(rec.Mark(ctx, CM("ApplyPlan")), pDropCol); err != nil { return fmt.Errorf("apply drop-column: %w", err) } if has, err := hasColumn(ctx, client, ledgerTable, "note"); err != nil { return fmt.Errorf("introspect post-apply-drop: %w", err) } else if has { return fmt.Errorf("ApplyPlan no dropeó la columna note") } seed := make([]*migrateLedger, 0, 24) for i := 0; i < 24; i++ { seed = append(seed, &migrateLedger{Ref: fmt.Sprintf("bf-%02d", i), Amount: int64(i)}) } if err := quark.For[migrateLedger](rec.Mark(ctx, QM("CreateBatch")), client).CreateBatch(seed); err != nil { return fmt.Errorf("seed backfill: %w", err) } rec.Note(QF("BackfillSpec")) sentinel := errors.New("fallo inyectado en el lote 2") var run1 [][]int64 err = client.Backfill(rec.Mark(ctx, CM("Backfill")), quark.BackfillSpec{ Name: backfillName, Table: ledgerTable, BatchSize: 10, Process: func(_ context.Context, pks []int64) error { if len(run1) == 1 { return sentinel } run1 = append(run1, pks) return nil }, }) if !errors.Is(err, sentinel) { return fmt.Errorf("backfill run1: esperaba el sentinel del lote 2, got %v", err) } if len(run1) != 1 { return fmt.Errorf("backfill run1 procesó %d lotes antes del fallo, esperaba exactamente 1", len(run1)) } if len(run1[0]) != 10 { return fmt.Errorf("backfill run1: el lote 1 trajo %d PKs, esperaba 10", len(run1[0])) } maxSeen := run1[0][len(run1[0])-1] // Re-invocación con el mismo Name: resume DESPUÉS del último PK persistido, // sin reprocesar el lote 1. var run2 []int64 err = client.Backfill(rec.Mark(ctx, CM("Backfill")), quark.BackfillSpec{ Name: backfillName, Table: ledgerTable, BatchSize: 10, Process: func(_ context.Context, pks []int64) error { run2 = append(run2, pks...) return nil }, }) if err != nil { return fmt.Errorf("backfill run2 (resume): %w", err) } if len(run2) != 15 { return fmt.Errorf("backfill run2 procesó %d PKs, esperaba los 15 restantes", len(run2)) } if run2[0] <= maxSeen { return fmt.Errorf("backfill run2 reprocesó PKs del lote 1 (primero=%d, estado=%d)", run2[0], maxSeen) } calls := 0 err = client.Backfill(rec.Mark(ctx, CM("Backfill")), quark.BackfillSpec{ Name: backfillName, Table: ledgerTable, BatchSize: 10, Process: func(_ context.Context, _ []int64) error { calls++; return nil }, }) if err != nil || calls != 0 { return fmt.Errorf("backfill run3 (completo): calls=%d err=%v, esperaba 0 y nil", calls, err) } rec.Note(QF("MigrationLock")) if control.Supports(control.FeatMigrationLock, eng) { lock1, err := client.AcquireMigrationLock(rec.Mark(ctx, CM("AcquireMigrationLock")), migLockName, 5*time.Second) if err != nil { return fmt.Errorf("acquire lock: %w", err) } if _, err := client.AcquireMigrationLock(ctx, migLockName, time.Second); !errors.Is(err, quark.ErrLockTimeout) { _ = lock1.Release(ctx) return fmt.Errorf("acquire concurrente: esperaba ErrLockTimeout, got %v", err) } rec.Note(QF("ErrLockTimeout")) if err := lock1.Release(ctx); err != nil { return fmt.Errorf("release: %w", err) } rec.Note(QF("(MigrationLock).Release")) lock2, err := client.AcquireMigrationLock(ctx, migLockName, 5*time.Second) if err != nil { return fmt.Errorf("re-acquire tras release: %w", err) } if err := lock2.Release(ctx); err != nil { return fmt.Errorf("release 2: %w", err) } } else { _, err := client.AcquireMigrationLock(rec.Mark(ctx, CM("AcquireMigrationLock")), migLockName, time.Second) if !errors.Is(err, quark.ErrUnsupportedFeature) { return fmt.Errorf("lock en %s: esperaba ErrUnsupportedFeature, got %v", eng, err) } rec.Note(QF("ErrUnsupportedFeature")) } migLimits := quark.DefaultLimits() migLimits.AllowRawQueries = true admin, err := quark.New(conn.Driver, conn.DSN, append(rec.Options(), quark.WithLimits(migLimits))...) if err != nil { return fmt.Errorf("client de migración (AllowRawQueries): %w", err) } defer admin.Close() migrate.Reset() defer migrate.Reset() rec.Note(MIG("Reset"), MIG("Register"), MIG("Migration"), MIG("Migrator"), MIG("NewMigrator")) migrate.Register(&migrate.Migration{ ID: migVNotesID, Name: "create v_notes", Up: func(ctx context.Context, c *quark.Client) error { return c.Migrate(ctx, &migrateVNote{}) }, Down: func(ctx context.Context, c *quark.Client) error { return execRaw(ctx, c, "DROP TABLE "+vNotesTable) }, }) migrate.Register(&migrate.Migration{ ID: migVSeedID, Name: "seed v_notes", Up: func(ctx context.Context, c *quark.Client) error { return quark.For[migrateVNote](ctx, c).Create(&migrateVNote{Body: "seeded"}) }, Down: func(ctx context.Context, c *quark.Client) error { return execRaw(ctx, c, "DELETE FROM "+vNotesTable) }, }) m := migrate.NewMigrator(admin) if err := m.Init(rec.Mark(ctx, MIG("(*Migrator).Init"))); err != nil { return fmt.Errorf("migrator init: %w", err) } if err := m.UpDryRun(rec.Mark(ctx, MIG("(*Migrator).UpDryRun")), 0); err != nil { return fmt.Errorf("up dry-run: %w", err) } if has, err := hasTable(ctx, client, vNotesTable); err != nil { return fmt.Errorf("introspect post-dry-run versionado: %w", err) } else if has { return fmt.Errorf("UpDryRun ejecutó la migración (existe %s)", vNotesTable) } if err := m.Up(rec.Mark(ctx, MIG("(*Migrator).Up")), 0); err != nil { return fmt.Errorf("up: %w", err) } if n, err := quark.For[migrateVNote](ctx, admin).Count(); err != nil || n != 1 { return fmt.Errorf("v_notes tras Up: n=%d err=%v, esperaba la fila seeded", n, err) } applied, err := m.GetApplied(rec.Mark(ctx, MIG("(*Migrator).GetApplied"))) if err != nil { return fmt.Errorf("get applied: %w", err) } if !applied[migVNotesID] || !applied[migVSeedID] { return fmt.Errorf("GetApplied no registra las dos migraciones: %v", applied) } if err := m.Down(rec.Mark(ctx, MIG("(*Migrator).Down")), 1); err != nil { return fmt.Errorf("down(1): %w", err) } if n, err := quark.For[migrateVNote](ctx, admin).Count(); err != nil || n != 0 { return fmt.Errorf("v_notes tras Down(1): n=%d err=%v, esperaba 0 (seed revertido)", n, err) } applied, err = m.GetApplied(ctx) if err != nil { return fmt.Errorf("get applied post-down: %w", err) } if !applied[migVNotesID] || applied[migVSeedID] { return fmt.Errorf("Down(1) debía revertir sólo %s: %v", migVSeedID, applied) } if err := m.Up(ctx, 1); err != nil { return fmt.Errorf("re-up: %w", err) } if n, err := quark.For[migrateVNote](ctx, admin).Count(); err != nil || n != 1 { return fmt.Errorf("v_notes tras re-Up: n=%d err=%v", n, err) } if err := m.Down(ctx, 0); err != nil { return fmt.Errorf("down(0): %w", err) } if has, err := hasTable(ctx, client, vNotesTable); err != nil { return fmt.Errorf("introspect post-down-all: %w", err) } else if has { return fmt.Errorf("Down(0) no dropeó %s", vNotesTable) } pDrop, err := client.PlanMigration(ctx, domain.AllModels()...) if err != nil { return fmt.Errorf("plan drop: %w", err) } if pDrop.IsEmpty() || !strings.Contains(pDrop.String(), ledgerTable) { return fmt.Errorf("el plan de cleanup debía proponer el drop de %s:\n%s", ledgerTable, pDrop.String()) } if err := client.ApplyPlan(ctx, pDrop); err != nil { return fmt.Errorf("apply drop: %w", err) } pFinal, err := client.PlanMigration(ctx, domain.AllModels()...) if err != nil { return fmt.Errorf("plan final: %w", err) } if !pFinal.IsEmpty() { return fmt.Errorf("la BD no quedó canónica tras el cleanup:\n%s", pFinal.String()) } _, _ = client.Raw().ExecContext(ctx, "DELETE FROM quark_backfill_state WHERE name = "+client.Dialect().Placeholder(1), backfillName) return nil }}
MIGRATE ejerce el área de migraciones: el round-trip Migrate→PlanMigration vacío (el invariante que BB-11 rompía), el ciclo schema-as-code completo (diff detecta la tabla faltante → ApplyPlan la CREA con su PK [regresión F3-2-pk] → ops de columna → drop de tabla), el contrato "índice manual no genera drops" de mergeNonColumnSurface, el registry per-Client (F3-7), Sync (dry-run, add y drop de columna), Backfill con resume tras fallo (F3-6), el lock de migración distribuido por capability (F3-1/ADR-0018), y el ciclo completo de migraciones versionadas (paquete migrate: Init/UpDryRun/Up/GetApplied/Down). Deja la BD como la encontró (cleanup vía OpDropTable real) y es idempotente entre runs (converge + limpieza de estado al entrar).
var RELATIONS = Exerciser{Name: "relations", Fn: func(ctx context.Context, client *quark.Client, rec *recorder.Recorder, _ Conn) error { rec.Note(QF("For"), QM("Preload"), QM("Where")) n := atomic.AddInt64(&relSeq, 1) owner := &domain.Account{Email: fmt.Sprintf("rel%d@superapp.test", n), Name: "rel", Role: "member", Active: true} if err := quark.For[domain.Account](ctx, client).Create(owner); err != nil { return fmt.Errorf("seed owner: %w", err) } proj := &domain.Project{OwnerID: owner.ID, Name: "rel-proj", Status: "active"} if err := quark.For[domain.Project](ctx, client).Create(proj); err != nil { return fmt.Errorf("seed project: %w", err) } aid := owner.ID assigned := &domain.Task{ProjectID: proj.ID, Title: "assigned", AssigneeID: &aid} if err := quark.For[domain.Task](ctx, client).Create(assigned); err != nil { return fmt.Errorf("seed assigned task: %w", err) } unassigned := &domain.Task{ProjectID: proj.ID, Title: "unassigned"} if err := quark.For[domain.Task](ctx, client).Create(unassigned); err != nil { return fmt.Errorf("seed unassigned task: %w", err) } t1, err := quark.For[domain.Task](rec.Mark(ctx, QM("First")), client). Preload("Project").Preload("Assignee").Where("id", "=", assigned.ID).First() if err != nil { return fmt.Errorf("preload belongs_to: %w", err) } if t1.Project == nil || t1.Project.ID != proj.ID { return fmt.Errorf("belongs_to Project no cargado: %+v", t1.Project) } if t1.Assignee == nil || t1.Assignee.ID != owner.ID { return fmt.Errorf("belongs_to Assignee no cargado: %+v", t1.Assignee) } t2, err := quark.For[domain.Task](ctx, client).Preload("Assignee").Where("id", "=", unassigned.ID).First() if err != nil { return fmt.Errorf("preload nullable FK: %w", err) } if t2.Assignee != nil { return fmt.Errorf("BB-5: Assignee debía quedar nil, cargó %+v", t2.Assignee) } acc, err := quark.For[domain.Account](ctx, client).Preload("Projects").Where("id", "=", owner.ID).First() if err != nil { return fmt.Errorf("preload has_many Projects: %w", err) } if len(acc.Projects) < 1 { return fmt.Errorf("has_many Projects vacío") } p, err := quark.For[domain.Project](ctx, client).Preload("Tasks").Where("id", "=", proj.ID).First() if err != nil { return fmt.Errorf("preload has_many Tasks: %w", err) } if len(p.Tasks) != 2 { return fmt.Errorf("has_many Tasks=%d, esperaba 2", len(p.Tasks)) } mProj := &domain.Project{ OwnerID: owner.ID, Name: "m2m-proj", Status: "active", Tags: []domain.Tag{ {Slug: fmt.Sprintf("rel-tag-a-%d", n)}, {Slug: fmt.Sprintf("rel-tag-b-%d", n)}, }, } if err := quark.For[domain.Project](rec.Mark(ctx, QM("Create")), client).Create(mProj); err != nil { return fmt.Errorf("m2m create con tags: %w", err) } mp, err := quark.For[domain.Project](ctx, client).Preload("Tags").Where("id", "=", mProj.ID).First() if err != nil { return fmt.Errorf("preload m2m Tags: %w", err) } if len(mp.Tags) != 2 { return fmt.Errorf("m2m Tags=%d, esperaba 2", len(mp.Tags)) } return nil }}
RELATIONS ejerce Preload en las tres formas: belongs_to (con el caso BB-5 de FK nullable que NO debe cargar basura), has_many, y many_to_many con persistencia de asociación (Create de un Project con Tags inserta en la tabla join).
var RLSNATIVE = Exerciser{Name: "tenant-rls-native", Fn: runRLSNative}
RLSNATIVE ejerce la estrategia RowLevelSecurityNative (ADR-0012, F5-2): el aislamiento lo FUERZA el motor vía CREATE POLICY + set_config('app.tenant_id'), no la WHERE-injection del builder (esa es RowLevelSecurityClient, exerciser TENANT). La distinción es observable: bajo Native el builder NO inyecta `WHERE tenant_id = ?` — un `SELECT * FROM rls_native_orders` plano devuelve sólo las filas del tenant porque la policy del motor las filtra. Es PG-only:
- En Postgres instala un rol no-superuser + policy y aserta, vía router.Tx (el camino recomendado bajo Native), que cada tenant ve sólo sus filas y que un INSERT respeta el WITH CHECK de la policy.
- En los otros 5 motores aserta que la estrategia se rechaza con quark.ErrUnsupportedFeature (capacidad desigual ≠ fallo, premisa #4 del HANDOFF) — mirror de rls_native_test.go.
A diferencia del exerciser RLSClient (builder-only sobre el client del harness), éste necesita el DSN del motor (conn): el client del harness corre como superuser y los superusers se saltan RLS incondicionalmente, así que el sujeto del aislamiento debe ser un rol no-superuser distinto, y el DDL de policy exige un admin client con AllowRawQueries. Por eso S5 cambió la firma de Exerciser.Fn para recibir el Conn.
Usa SÓLO router.Tx (no el path implicit-tx de For[T] bajo Native): router.Tx commitea de forma síncrona y libera la conexión y la goroutine awaitDone de la tx, así que es determinista y no deja fugas para el leak-check del harness. El path implicit-tx de For[T] (nativeRLSExecutor con context.AfterFunc) queda cubierto por rls_native_postgres_test.go; aquí seguimos el camino que el propio rls_native.go marca como recomendado para cualquier operación no trivial.
var SCHEMAPERTENANT = Exerciser{Name: "tenant-schema-per", Fn: runSchemaPerTenant}
SCHEMAPERTENANT ejerce la estrategia SchemaPerTenant (ADR-0007): una base, un schema por tenant; For[T] bajo el router fija q.schema = tenantID y todo el SQL sale schema-qualified. Se asertan las dos garantías:
- Aislamiento por schema — cada tenant ve sólo las filas de SU schema (tablas físicamente distintas dentro de la misma base).
- La qualificación llega al SQL EMITIDO, incluida la regresión BB-8 (los write-paths construían BaseQuery internos que perdían q.schema y los INSERT caían al schema default): se inspecciona rec.Statements() y se exige que el INSERT mencione el schema del tenant.
Sólo corre el path funcional donde el motor tiene schemas reales (FeatSchemaPerTenant: PG y MSSQL — fuente docs/playbooks/tenant.md); en el resto salta limpio (Quark NO gatea esta estrategia con ErrUnsupportedFeature, así que no hay error que asertar — ver el comment en capability.go). El onboarding (CREATE SCHEMA + migrar la tabla al schema) es responsabilidad del caller per el playbook ("SchemaPerTenant no auto-crea schema"): aquí el admin crea los schemas y un client efímero con search_path=<schema> migra dentro de cada uno — en MSSQL no existe el equivalente de search_path en DSN, así que su mecanismo de migrate-into-schema queda TODO (error ruidoso, no skip silencioso).
var SECURITY = Exerciser{Name: "security", Fn: func(ctx context.Context, client *quark.Client, rec *recorder.Recorder, _ Conn) error { rec.Note(QF("For"), QM("Where"), QM("WhereJSON"), QM("Join")) hostile := []string{ `id; DROP TABLE accounts;--`, `id) OR 1=1 --`, `name'`, `1=1`, `id'); DELETE FROM accounts;--`, } for _, h := range hostile { if _, err := quark.For[domain.Account](ctx, client).Where(h, "=", 1).List(); err == nil { return fmt.Errorf("identificador hostil %q NO fue rechazado", h) } else if !strings.Contains(strings.ToLower(err.Error()), "identifier") { return fmt.Errorf("identificador hostil %q: error inesperado: %v", h, err) } } if _, err := quark.For[domain.Account](ctx, client).OrderBy("name; DROP TABLE--", "ASC").Limit(1).List(); err == nil { return fmt.Errorf("OrderBy con columna hostil NO fue rechazado") } if _, err := quark.For[domain.Account](ctx, client).WhereJSON("settings", `theme'; DROP--`, "=", "x").List(); !errors.Is(err, quark.ErrInvalidJSONPath) { return fmt.Errorf("JSON-path hostil: esperaba ErrInvalidJSONPath, got %v", err) } if _, err := quark.For[domain.Task](ctx, client).Join("projects").On(`id; DROP TABLE projects`, "=", "x").List(); !errors.Is(err, quark.ErrInvalidJoin) { return fmt.Errorf("JOIN ON hostil: esperaba ErrInvalidJoin, got %v", err) } return nil }}
SECURITY ejerce el SQLGuard: identificadores, JSON-path y JOIN-ON hostiles deben rechazarse ANTES de tocar la BD. Verifica que la inyección se ataja (err != nil, query no ejecutada) y, donde Quark envuelve el sentinel con %w, que errors.Is lo alcanza.
var TENANT = Exerciser{Name: "tenant", Fn: runTenant}
TENANT ejerce la modalidad RowLevelSecurityClient de multi-tenancy (ADR-0007): inyección de `WHERE tenant_id = ?` en el builder, disponible en los 6 motores. Asierta la garantía de seguridad crítica — aislamiento cross-tenant — y las trampas que el playbook marca: la propagación del predicado a los Or-groups (regresión del P0-1) y que el aislamiento sólo aplica A TRAVÉS del router (una query con el client base, igual que `client.Raw()`/`Exec()`, lo evita).
Es builder-only a propósito (sin SQL raw): así corre portable en los 6 motores sin tropezar con el case de identificadores de Oracle.
Las otras 3 estrategias necesitan fixtures más pesados y llegan en PRs propios (ver examples/superapp/HANDOFF.md): RowLevelSecurityNative (PG-only, requiere un rol no-superuser + CREATE POLICY), SchemaPerTenant (PG/MSSQL, CREATE SCHEMA) y DatabasePerTenant (factory de *Client por tenant con DSN propio).
var TX = Exerciser{Name: "tx", Fn: func(ctx context.Context, client *quark.Client, rec *recorder.Recorder, _ Conn) error { rec.Note(CM("Tx"), QF("ForTx")) n := atomic.AddInt64(&txSeq, 1) email := fmt.Sprintf("tx%d@superapp.test", n) var accID int64 err := client.Tx(rec.Mark(ctx, CM("Tx")), func(tx *quark.Tx) error { a := &domain.Account{Email: email, Name: "tx", Role: "member", Active: true} if err := quark.ForTx[domain.Account](ctx, tx).Create(a); err != nil { return err } accID = a.ID p := &domain.Project{OwnerID: a.ID, Name: "tx-proj", Status: "active"} return quark.ForTx[domain.Project](ctx, tx).Create(p) }) if err != nil { return fmt.Errorf("commit: %w", err) } if c, err := quark.For[domain.Account](ctx, client).Where("email", "=", email).Count(); err != nil || c != 1 { return fmt.Errorf("commit no persistió account (count=%d err=%v)", c, err) } if c, err := quark.For[domain.Project](ctx, client).Where("owner_id", "=", accID).Count(); err != nil || c != 1 { return fmt.Errorf("commit no persistió project (count=%d err=%v)", c, err) } rbEmail := fmt.Sprintf("txrb%d@superapp.test", n) sentinel := errors.New("rollback intencional") err = client.Tx(ctx, func(tx *quark.Tx) error { a := &domain.Account{Email: rbEmail, Name: "rb", Role: "member", Active: true} if cerr := quark.ForTx[domain.Account](ctx, tx).Create(a); cerr != nil { return cerr } return sentinel }) if !errors.Is(err, sentinel) { return fmt.Errorf("tx con error no propagó el sentinel: %v", err) } if c, err := quark.For[domain.Account](ctx, client).Where("email", "=", rbEmail).Count(); err != nil || c != 0 { return fmt.Errorf("rollback no revirtió (count=%d err=%v)", c, err) } return nil }}
TX ejerce transacciones: un commit multi-entidad atómico (account + project) y un rollback (la closure devuelve error → nada persiste). Marca Client.Tx y ForTx.
Functions ¶
func Coverage ¶
func Coverage(results map[control.Engine]EngineResult) control.Invoked
Coverage pliega la cobertura de todos los motores en un control.Invoked, listo para control.Manifest.Reconcile.
func MIG ¶ added in v1.1.2
MIG es la key de un símbolo del paquete de migraciones versionadas: MIG("(*Migrator).Up") → "github.com/jcsvwinston/quark/migrate.(*Migrator).Up".
func QF ¶
QF es la key de una func/tipo/const a nivel paquete (For, ForTx, New, NewTenantRouter, RowLevelSecurityClient…).
func QM ¶
QM es la key de manifiesto de un método de *Query[T]: QM("Create") → "github.com/jcsvwinston/quark.(*Query[T]).Create".
Types ¶
type Conn ¶
Conn re-exporta engine.Conn para que los exercisers que necesitan el driver/DSN del motor lo reciban sin importar el paquete engine. Lo usan los que abren clientes propios además del client del harness: RLSNative deriva un rol no-superuser y aplica CREATE POLICY; DBPerTenant abre un *Client por tenant. El resto lo ignora (_ Conn).
type EngineResult ¶
type EngineResult struct {
Err error // primer error funcional (o de open/migrate)
Leak engine.LeakReport // del chequeo de fugas de engine.Run
Rec *recorder.Recorder // recorder del motor (cobertura + SQL capturado)
}
EngineResult reúne el resultado por motor.
type Exerciser ¶
type Exerciser struct {
Name string
Fn func(ctx context.Context, client *quark.Client, rec *recorder.Recorder, conn Conn) error
}
Exerciser ejerce un área de la API: marca los símbolos que toca (rec.Mark / rec.Note) y aserta el resultado funcional, devolviendo error al primer fallo. El Conn da acceso al driver/DSN del motor a los exercisers que abren sus propios clientes; los que sólo usan el client del harness lo ignoran.