exercise

package
v1.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 10, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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).

View Source
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:

  1. hit = 0 SQL — una 2ª query idéntica con .Cache() no ejecuta SQL (el middleware del recorder no se dispara → Count() no cambia).
  2. 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.
  3. 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.
View Source
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.

View Source
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:

  1. 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í).
  2. 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.

View Source
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).

View Source
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).

View Source
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.

View Source
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:

  1. Aislamiento por schema — cada tenant ve sólo las filas de SU schema (tablas físicamente distintas dentro de la misma base).
  2. 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).

View Source
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.

View Source
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).

View Source
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 CM

func CM(method string) string

CM es la key de un método de *Client.

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

func MIG(name string) string

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

func QF(name string) string

QF es la key de una func/tipo/const a nivel paquete (For, ForTx, New, NewTenantRouter, RowLevelSecurityClient…).

func QM

func QM(method string) string

QM es la key de manifiesto de un método de *Query[T]: QM("Create") → "github.com/jcsvwinston/quark.(*Query[T]).Create".

func Run

func Run(conns map[control.Engine]engine.Conn, tol int, exercisers []Exerciser) map[control.Engine]EngineResult

Run corre los exercisers contra cada motor vía engine.Run (lifecycle + anti-fugas), instalando un recorder por motor y migrando el dominio primero.

func TRM

func TRM(method string) string

TRM es la key de un método de *TenantRouter: TRM("Tx") → "github.com/jcsvwinston/quark.(*TenantRouter).Tx".

Types

type Conn

type Conn = engine.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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL