diff --git a/.github/workflows/integration-nightly.yml b/.github/workflows/integration-nightly.yml index e9340ad76445..1b0dc9bafb34 100644 --- a/.github/workflows/integration-nightly.yml +++ b/.github/workflows/integration-nightly.yml @@ -45,5 +45,7 @@ jobs: run: pnpm build - name: Run Integration tests run: pnpm test:integration + - name: Run init-journey matrix (nightly-only; excluded from PR runs) + run: pnpm --filter integration-tests test:init-journey - name: Check working tree is clean run: pnpm check:clean-tree diff --git a/docs/design/10-domains/migration/user-journeys.md b/docs/design/10-domains/migration/user-journeys.md index 9a71d162fc79..f48f80d879e9 100644 --- a/docs/design/10-domains/migration/user-journeys.md +++ b/docs/design/10-domains/migration/user-journeys.md @@ -242,7 +242,7 @@ Three shapes the merge can take, all valid: The graph is content-addressed: migration hashes change when ancestry changes, but the rules for "does this migration make sense from this contract?" are encoded in the verb taxonomy (`migration check`'s PN codes), not in branch ordering. -**Exercised by:** `converging-paths.e2e.test.ts`, `diamond-convergence.e2e.test.ts`, `divergence-and-refs.e2e.test.ts`. +**Exercised by:** `diamond-convergence.e2e.test.ts`, `divergence-and-refs.e2e.test.ts`. --- diff --git a/test/integration/package.json b/test/integration/package.json index ff6995770a0b..1334a6b763ac 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -11,6 +11,7 @@ "pretest:vite-plugin": "pnpm -C ../.. --filter @repo/test-utils --filter @internal/vite-plugin-contract-emit... build", "test": "vitest run", "test:journeys": "vitest run --config vitest.journeys.config.ts", + "test:init-journey": "RUN_INIT_JOURNEY=1 vitest run --config vitest.journeys.config.ts test/cli-journeys/init-journey.e2e.test.ts", "test:vite-plugin": "vitest run test/vite-plugin.hmr.e2e.test.ts", "test:watch": "vitest", "typecheck": "tsc --project tsconfig.json --noEmit", diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index a6e72d693219..a390703b0f5e 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -11,12 +11,15 @@ These tests are the primary regression suite for the Prisma Next CLI's database pnpm test:journeys ``` +`init-journey.e2e.test.ts` (the 4-cell target × authoring pack+install matrix) is excluded from `pnpm test:journeys` and `pnpm test` unless `RUN_INIT_JOURNEY=1` is set; it runs nightly via `pnpm test:init-journey` (see `.github/workflows/integration-nightly.yml`). + ## Test files ### Happy paths | File | What it covers | |---|---| +| `init-journey.e2e.test.ts` | **`prisma orm init` inner loop** across all four (target × authoring) cells: scaffold, pack + real install against workspace tarballs, emit, plan, self-emit, apply, run user code. Nightly-only — excluded from PR runs unless `RUN_INIT_JOURNEY=1` | | `greenfield-setup.e2e.test.ts` | New project with empty database: emit a contract, dry-run init to preview operations, apply init, confirm idempotency on re-run, verify marker and schema (`db verify`, `db verify --schema-only`, `db verify --strict`), inspect the live schema with `db schema`, and check JSON output variants of full and schema-only verify | | `composite-pk-greenfield.e2e.test.ts` | **Composite primary key greenfield**: emit a PSL contract for a junction table, dry-run and apply `db init`, inspect the live Postgres primary-key constraint order, verify duplicate inserts fail on that constraint, then round-trip through `contract infer` and schema verification | | `db-schema-discovery.e2e.test.ts` | **Live schema discovery**: inspect an unmanaged database with `db schema`, apply manual DDL, inspect again with `db schema --json`, and confirm the command stays read-only throughout | @@ -33,10 +36,9 @@ pnpm test:journeys | File | What it covers | |---|---| | `rollback-cycle.e2e.test.ts` | **Rollback cycle (P-2)**: C1→C2→C1 creates a cycle. `findLeaf` fails with `NO_TARGET`. Plan with `--from` bypasses cycle, apply recovers | -| `converging-paths.e2e.test.ts` | **Converging paths (P-3)**: two paths to the same target (C1→C2→C3 and C1→C3 direct). Pathfinder selects shortest path (2 steps not 3) | | `divergence-and-refs.e2e.test.ts` | **Same-base divergence (P-4)**: two edges from C1 (C1→C2, C1→C3). Status without `--ref` fails with `AMBIGUOUS_TARGET`. Ref-based resolution routes apply to the correct branch | -| `ref-routing.e2e.test.ts` | **Staging ahead via refs (P-5)**: production=C1, staging=C2 on same DB. Apply `--ref staging` advances staging; production unaffected. **Marker ahead of ref (P-6)**: after staging apply, DB at C2 but production ref at C1 — apply fails, status reports ahead-of-ref | | `adopt-migrations.e2e.test.ts` | **Adopting migrations (P-9)**: DB managed via `db update` (at C2). Baseline migration EMPTY→C2 is no-op. Incremental C2→C3 applies normally. Status shows both migrations applied | +| `data-transform-strategies.e2e.test.ts` | **Planner-assisted dataTransform strategies**: one scenario per Postgres call strategy (NOT NULL backfill, nullable tightening, text→int4 type change). Planner emits placeholder stubs, the test fills them in, re-emits in-process, applies, and asserts data + column shape | | `diamond-convergence.e2e.test.ts` | **Diamond convergence**: Two environments (staging, production) diverge from C1 via independent branches (C1→C2→C3 and C1→C4), then converge to C5. Uses two PGlite instances with separate configs sharing the same migration graph on disk. Verifies both DBs reach C5 via their respective merge migrations and status shows 0 pending for both refs | | `interleaved-db-update.e2e.test.ts` | **Interleaved db update + migrations**: User on migrations (∅→C1→C2) runs `db update` to C3 instead of `migration plan`. Retroactive `migration plan` creates the C2→C3 edge, `migration apply` is a noop (DB already at C3). Future migrations (C3→C4) resume normally. Documents that `migration plan` is offline (uses latest migration target, not DB marker) | diff --git a/test/integration/test/cli-journeys/adopt-migrations.e2e.test.ts b/test/integration/test/cli-journeys/adopt-migrations.e2e.test.ts index 9df5c25070c2..ed9f4f2317d7 100644 --- a/test/integration/test/cli-journeys/adopt-migrations.e2e.test.ts +++ b/test/integration/test/cli-journeys/adopt-migrations.e2e.test.ts @@ -14,10 +14,10 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planMigrationAndSelfEmit, runContractEmit, runDbUpdate, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -51,7 +51,7 @@ withTempDir(({ createTempDir }) => { expect(update1.exitCode, 'O.02: db update C2').toBe(0); // O.03: plan baseline migration EMPTY→C2 (current contract) - const planBaseline = await runMigrationPlanAndEmit(ctx, ['--name', 'baseline', '--json']); + const planBaseline = await planMigrationAndSelfEmit(ctx, ['--name', 'baseline', '--json']); expect(planBaseline.exitCode, 'O.03: plan baseline').toBe(0); const baselineResult = parseJsonOutput<{ to: string; noOp: boolean }>(planBaseline); expect(baselineResult.noOp, 'O.03: baseline is not a plan-noop').toBe(false); @@ -73,7 +73,11 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone-bio'); const emit2 = await runContractEmit(ctx); expect(emit2.exitCode, 'O.05: emit C3').toBe(0); - const planIncremental = await runMigrationPlanAndEmit(ctx, ['--name', 'add-bio', '--json']); + const planIncremental = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-bio', + '--json', + ]); expect(planIncremental.exitCode, 'O.05: plan C2→C3').toBe(0); const incrementalResult = parseJsonOutput<{ from: string; to: string }>(planIncremental); expect(incrementalResult.from, 'O.05: from C2').toBe(c2Hash); diff --git a/test/integration/test/cli-journeys/converging-paths.e2e.test.ts b/test/integration/test/cli-journeys/converging-paths.e2e.test.ts deleted file mode 100644 index f8ec780b1b5d..000000000000 --- a/test/integration/test/cli-journeys/converging-paths.e2e.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Converging Paths (Journey K — spec scenario P-3/S-3) - * - * Tests that when multiple migration paths lead to the same target, - * the pathfinder selects the shortest path. Creates a graph with: - * C1 → C2 (add-phone) - * C2 → C3 (add-phone-bio, via C2) - * C1 → C3 (direct shortcut, via --from C1) - * Applies from empty — shortest path (∅→C1→C3) is selected over ∅→C1→C2→C3. - */ - -import { describe, expect, it } from 'vitest'; -import { withTempDir } from '../utils/cli-test-helpers'; -import { - type JourneyContext, - parseJsonOutput, - runContractEmit, - runMigrate, - runMigrationPlanAndEmit, - setupJourney, - swapContract, - timeouts, - useDevDatabase, -} from '../utils/journey-test-helpers'; - -withTempDir(({ createTempDir }) => { - describe('Journey K: Converging Paths (P-3/S-3)', () => { - const db = useDevDatabase(); - - it( - 'shortest path selected over longer alternative when graph converges', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - }); - - // K.01: emit base contract (C1) → plan init - const emit0 = await runContractEmit(ctx); - expect(emit0.exitCode, 'K.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); - expect(plan0.exitCode, 'K.01: plan init').toBe(0); - const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; - - // K.02: swap to contract-phone (C2) → emit → plan add-phone (C1→C2) - swapContract(ctx, 'contract-phone'); - const emit1 = await runContractEmit(ctx); - expect(emit1.exitCode, 'K.02: emit C2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-phone', '--json']); - expect(plan1.exitCode, 'K.02: plan C1→C2').toBe(0); - parseJsonOutput<{ to: string }>(plan1); - - // K.03: swap to contract-phone-bio (C3) → emit → plan from C2 (C2→C3) - swapContract(ctx, 'contract-phone-bio'); - const emit2 = await runContractEmit(ctx); - expect(emit2.exitCode, 'K.03: emit C3').toBe(0); - const plan2 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-bio-via-c2', '--json']); - expect(plan2.exitCode, 'K.03: plan C2→C3').toBe(0); - const c3Hash = parseJsonOutput<{ to: string }>(plan2).to; - - // K.04: plan direct shortcut from C1→C3 (creates a shorter alternative) - const planDirect = await runMigrationPlanAndEmit(ctx, [ - '--name', - 'direct-to-c3', - '--from', - c1Hash, - '--json', - ]); - expect(planDirect.exitCode, 'K.04: plan C1→C3 direct').toBe(0); - const directResult = parseJsonOutput<{ from: string; to: string }>(planDirect); - expect(directResult.from, 'K.04: from C1').toBe(c1Hash); - expect(directResult.to, 'K.04: to C3').toBe(c3Hash); - - // K.05: apply from empty DB — pathfinder picks shortest path (∅→C1→C3) - const apply = await runMigrate(ctx, ['--json']); - expect(apply.exitCode, 'K.05: apply converging graph').toBe(0); - - const applyResult = parseJsonOutput<{ - ok: boolean; - migrationsApplied: number; - markerHash: string; - }>(apply); - expect(applyResult.ok, 'K.05: ok').toBe(true); - expect(applyResult.markerHash, 'K.05: marker at C3').toBe(c3Hash); - // Shortest path is ∅→C1→C3 (2 migrations), not ∅→C1→C2→C3 (3 migrations) - expect(applyResult.migrationsApplied, 'K.05: shortest path = 2 steps').toBe(2); - }, - timeouts.spinUpPpgDev, - ); - }); -}); diff --git a/test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts deleted file mode 100644 index 95cb3d58b28e..000000000000 --- a/test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * NOT-NULL backfill — `notNullBackfillCallStrategy` end-to-end. - * - * Drives a contract change that adds a non-nullable column with no - * default. The Postgres planner's `notNullBackfillCallStrategy` - * matches this and emits - * `addColumn(nullable) → DataTransformCall(placeholder slots) → - * setNotNull`. The planner-emitted `migration.ts` therefore has two - * `placeholder("…")` stubs the user must fill in. This test simulates - * the user editing the file (string-patching the stubs and injecting a - * `db = sql({ context, rawCodecInferer: { inferCodec: () => 'pg/text' } })` setup), then runs `migration emit` + - * `migration apply` and asserts the post-apply data has been - * backfilled and the column is NOT NULL. - * - * Phase 2 acceptance: covers `postgresPlannerStrategies` (data-safe path) end-to-end - * for the NOT-NULL backfill case (plan.md AC R2.2 #1). - */ - -import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { withTempDir } from '../utils/cli-test-helpers'; -import { - injectMigrationSqlDbSetup, - type JourneyContext, - runContractEmit, - runMigrate, - runMigrationEmit, - runMigrationPlan, - runMigrationPlanAndEmit, - setupJourney, - sql, - swapContract, - timeouts, - useDevDatabase, -} from '../utils/journey-test-helpers'; - -const BACKFILLED_NAME = 'unknown'; - -withTempDir(({ createTempDir }) => { - describe('Journey: dataTransform — NOT NULL backfill (planner-assisted)', () => { - const db = useDevDatabase(); - - it( - 'planner emits placeholder() stubs the user fills in; apply backfills + sets NOT NULL', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - }); - - const emit0 = await runContractEmit(ctx); - expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); - expect(plan0.exitCode, `plan initial: ${plan0.stderr}`).toBe(0); - const apply0 = await runMigrate(ctx); - expect(apply0.exitCode, `apply initial: ${apply0.stderr}`).toBe(0); - - await sql( - db.connectionString, - `INSERT INTO "public"."user" (id, email) VALUES (1, 'alice@example.com'), (2, 'bob@test.org')`, - ); - - // The contract swap is the input to `notNullBackfillCallStrategy`: - // an existing table gains a NOT NULL column with no default. - swapContract(ctx, 'contract-additive-required-name'); - const emit1 = await runContractEmit(ctx); - expect(emit1.exitCode, `emit required-name: ${emit1.stderr}`).toBe(0); - - const planResult = await runMigrationPlan(ctx, ['--name', 'add-required-name']); - expect(planResult.exitCode, `plan: ${planResult.stderr}\n${planResult.stderr}`).toBe(0); - - const migrationsDir = join(ctx.testDir, 'migrations', 'app'); - const migrationDirs = readdirSync(migrationsDir) - .filter((d) => d.includes('add_required_name')) - .sort(); - expect(migrationDirs.length, 'planned migration dir exists').toBe(1); - const migrationDir = join(migrationsDir, migrationDirs[0]!); - const migrationTsPath = join(migrationDir, 'migration.ts'); - - const scaffold = readFileSync(migrationTsPath, 'utf-8'); - expect(scaffold).toContain("placeholder('backfill-user-name:check')"); - expect(scaffold).toContain("placeholder('backfill-user-name:run')"); - const manifestBefore = JSON.parse( - readFileSync(join(migrationDir, 'migration.json'), 'utf-8'), - ); - // The package is fully attested even when the planner could not - // lower any calls because of placeholders: `ops.json` is `[]` and - // `migrationHash` is the content-address over `(manifest, [])`. - // The author re-emits after filling in placeholders to rewrite - // both `ops.json` and `migrationHash`. - expect(manifestBefore.migrationHash).toMatch(/^[a-f0-9]{64}$/); - expect(JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8'))).toEqual([]); - - const filled = injectMigrationSqlDbSetup(scaffold) - .replace( - "() => placeholder('backfill-user-name:check')", - "() => db.public.user.select('id').where((f, fns) => fns.eq(f.name, null)).limit(1)", - ) - .replace( - "() => placeholder('backfill-user-name:run')", - `() => db.public.user.update({ name: '${BACKFILLED_NAME}' }).where((f, fns) => fns.eq(f.name, null))`, - ); - expect(filled).not.toContain('placeholder('); - expect(filled).toContain('const db = sql('); - writeFileSync(migrationTsPath, filled); - - const emitResult = await runMigrationEmit(ctx, [ - '--dir', - migrationDir, - '--config', - ctx.configPath, - ]); - expect(emitResult.exitCode, `emit: ${emitResult.stdout}\n${emitResult.stderr}`).toBe(0); - - const opsAfterEmit = JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8')); - const dataTransformOp = opsAfterEmit.find( - (op: { id: string }) => op.id === 'data_migration.backfill-user-name', - ); - expect(dataTransformOp, 'dataTransform op exists').toBeDefined(); - expect(dataTransformOp.operationClass).toBe('data'); - expect(dataTransformOp.precheck).toHaveLength(1); - expect(dataTransformOp.execute).toHaveLength(1); - expect(dataTransformOp.postcheck).toHaveLength(1); - - const manifestAfter = JSON.parse( - readFileSync(join(migrationDir, 'migration.json'), 'utf-8'), - ); - expect(manifestAfter.migrationHash).toMatch(/^[a-f0-9]{64}$/); - - const apply1 = await runMigrate(ctx); - expect(apply1.exitCode, `apply: ${apply1.stdout}\n${apply1.stderr}`).toBe(0); - - const result = await sql( - db.connectionString, - `SELECT id, email, "name" FROM "public"."user" ORDER BY id`, - ); - expect(result.rows).toEqual([ - { id: 1, email: 'alice@example.com', name: BACKFILLED_NAME }, - { id: 2, email: 'bob@test.org', name: BACKFILLED_NAME }, - ]); - - // Verify the column is now NOT NULL — strategy ends in - // setNotNull and apply must have executed it. - const colInfo = await sql( - db.connectionString, - `SELECT is_nullable FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'user' AND column_name = 'name'`, - ); - expect(colInfo.rows).toEqual([{ is_nullable: 'NO' }]); - - // Re-apply must be a no-op: the marker advanced past this - // migration and the dataTransform op is idempotency-skipped - // because its `check` query now returns 0 rows (all NULLs - // were backfilled by the first apply). Pins both the - // runner's marker-CAS ledger advance and the data-transform - // check-driven skip path (spec AC4.2 idempotency half). - const reapply = await runMigrate(ctx); - expect(reapply.exitCode, `reapply: ${reapply.stdout}\n${reapply.stderr}`).toBe(0); - expect(reapply.stderr).toContain('Already up to date'); - }, - timeouts.spinUpPpgDev, - ); - }); -}); diff --git a/test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts deleted file mode 100644 index e9dd379b7626..000000000000 --- a/test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Nullable tightening — `nullableTighteningCallStrategy` end-to-end. - * - * Drives a contract change that flips an existing column from - * nullable to NOT NULL (no `addColumn`, no type change). The Postgres - * planner's `nullableTighteningCallStrategy` matches this - * case and emits `DataTransformCall(placeholder slots) → setNotNull`, - * so the planner-emitted `migration.ts` has two `placeholder("…")` - * stubs the user must fill in to backfill any existing NULL rows - * before the constraint is tightened. This test simulates the user - * editing the file (string-patching the stubs and injecting a - * `db = sql({ context, rawCodecInferer: { inferCodec: () => 'pg/text' } })` setup), then runs `migration emit` + - * `migration apply` and asserts the post-apply NULL row has been - * backfilled and the column is NOT NULL. - * - * Phase 2 acceptance: covers `postgresPlannerStrategies` (data-safe path) end-to-end - * for the nullable-tightening case (plan.md AC R2.2 #3). - */ - -import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { withTempDir } from '../utils/cli-test-helpers'; -import { - injectMigrationSqlDbSetup, - type JourneyContext, - runContractEmit, - runMigrate, - runMigrationEmit, - runMigrationPlan, - runMigrationPlanAndEmit, - setupJourney, - sql, - swapContract, - timeouts, - useDevDatabase, -} from '../utils/journey-test-helpers'; - -const BACKFILLED_NAME = 'unknown'; - -withTempDir(({ createTempDir }) => { - describe('Journey: dataTransform — nullable → NOT NULL tightening (planner-assisted)', () => { - const db = useDevDatabase(); - - it( - 'planner emits placeholder() stubs the user fills in; apply backfills NULLs + tightens to NOT NULL', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - }); - - // Initial contract: User.name is nullable. Apply, then seed - // both a row with a name and a row with NULL — the latter is - // what the user-filled `:run` query has to backfill before - // setNotNull can succeed. - swapContract(ctx, 'contract-nullable-name'); - const emit0 = await runContractEmit(ctx); - expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); - expect(plan0.exitCode, `plan initial: ${plan0.stderr}`).toBe(0); - const apply0 = await runMigrate(ctx); - expect(apply0.exitCode, `apply initial: ${apply0.stderr}`).toBe(0); - - await sql( - db.connectionString, - `INSERT INTO "public"."user" (id, email, "name") VALUES (1, 'alice@example.com', 'Alice'), (2, 'bob@test.org', NULL)`, - ); - - // Swap to the NOT NULL contract: this is the input to - // `nullableTighteningCallStrategy`. - swapContract(ctx, 'contract-nullable-name-required'); - const emit1 = await runContractEmit(ctx); - expect(emit1.exitCode, `emit required: ${emit1.stderr}`).toBe(0); - - const planResult = await runMigrationPlan(ctx, ['--name', 'tighten-name-not-null']); - expect(planResult.exitCode, `plan: ${planResult.stderr}\n${planResult.stderr}`).toBe(0); - - const migrationsDir = join(ctx.testDir, 'migrations', 'app'); - const migrationDirs = readdirSync(migrationsDir) - .filter((d) => d.includes('tighten_name_not_null')) - .sort(); - expect(migrationDirs.length, 'planned migration dir exists').toBe(1); - const migrationDir = join(migrationsDir, migrationDirs[0]!); - const migrationTsPath = join(migrationDir, 'migration.ts'); - - const scaffold = readFileSync(migrationTsPath, 'utf-8'); - expect(scaffold).toContain("placeholder('handle-nulls-user-name:check')"); - expect(scaffold).toContain("placeholder('handle-nulls-user-name:run')"); - expect(scaffold).toContain('setNotNull'); - // The planner *must not* emit an addColumn for `name` here: - // this is the tightening case, the column already exists. - expect(scaffold).not.toContain('addColumn'); - const manifestBefore = JSON.parse( - readFileSync(join(migrationDir, 'migration.json'), 'utf-8'), - ); - // The package is fully attested even when the planner could not - // lower any calls because of placeholders: `ops.json` is `[]` and - // `migrationHash` is the content-address over `(manifest, [])`. - // The author re-emits after filling in placeholders to rewrite - // both `ops.json` and `migrationHash`. - expect(manifestBefore.migrationHash).toMatch(/^[a-f0-9]{64}$/); - expect(JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8'))).toEqual([]); - - const filled = injectMigrationSqlDbSetup(scaffold) - .replace( - "() => placeholder('handle-nulls-user-name:check')", - "() => db.public.user.select('id').where((f, fns) => fns.eq(f.name, null)).limit(1)", - ) - .replace( - "() => placeholder('handle-nulls-user-name:run')", - `() => db.public.user.update({ name: '${BACKFILLED_NAME}' }).where((f, fns) => fns.eq(f.name, null))`, - ); - expect(filled).not.toContain('placeholder('); - expect(filled).toContain('const db = sql('); - writeFileSync(migrationTsPath, filled); - - const emitResult = await runMigrationEmit(ctx, [ - '--dir', - migrationDir, - '--config', - ctx.configPath, - ]); - expect(emitResult.exitCode, `emit: ${emitResult.stdout}\n${emitResult.stderr}`).toBe(0); - - const opsAfterEmit = JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8')); - const dataTransformOp = opsAfterEmit.find( - (op: { id: string }) => op.id === 'data_migration.handle-nulls-user-name', - ); - expect(dataTransformOp, 'dataTransform op exists').toBeDefined(); - expect(dataTransformOp.operationClass).toBe('data'); - expect(dataTransformOp.precheck).toHaveLength(1); - expect(dataTransformOp.execute).toHaveLength(1); - expect(dataTransformOp.postcheck).toHaveLength(1); - - const setNotNullOp = opsAfterEmit.find((op: { id: string }) => - op.id.includes('setNotNull.user.name'), - ); - expect(setNotNullOp, 'setNotNull op exists').toBeDefined(); - - const apply1 = await runMigrate(ctx); - expect(apply1.exitCode, `apply: ${apply1.stdout}\n${apply1.stderr}`).toBe(0); - - const result = await sql( - db.connectionString, - `SELECT id, email, "name" FROM "public"."user" ORDER BY id`, - ); - expect(result.rows).toEqual([ - { id: 1, email: 'alice@example.com', name: 'Alice' }, - { id: 2, email: 'bob@test.org', name: BACKFILLED_NAME }, - ]); - - const colInfo = await sql( - db.connectionString, - `SELECT is_nullable FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'user' AND column_name = 'name'`, - ); - expect(colInfo.rows).toEqual([{ is_nullable: 'NO' }]); - }, - timeouts.spinUpPpgDev, - ); - }); -}); diff --git a/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts new file mode 100644 index 000000000000..13338cb41803 --- /dev/null +++ b/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts @@ -0,0 +1,289 @@ +/** + * Planner-assisted dataTransform strategies, end-to-end — one scenario per + * Postgres planner call strategy (plan.md AC R2.2): + * + * - `notNullBackfillCallStrategy` (#1): an existing table gains a NOT NULL + * column with no default. The planner emits `addColumn(nullable) → + * DataTransformCall(placeholder slots) → setNotNull`. + * - `nullableTighteningCallStrategy` (#3): an existing column flips from + * nullable to NOT NULL (no addColumn, no type change). The planner emits + * `DataTransformCall(placeholder slots) → setNotNull`. + * - `typeChangeCallStrategy` (#2): a column's type changes (text → int4). + * The planner emits `DataTransformCall(placeholder slots) → + * alterColumnType`. + * + * Each scenario simulates the user editing the planner-emitted + * `migration.ts` (string-patching the placeholder stubs and injecting a + * `db = sql(...)` setup), re-emits the package in-process, applies it, and + * asserts the post-apply data and column shape. + */ + +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { withTempDir } from '../utils/cli-test-helpers'; +import { + injectMigrationSqlDbSetup, + type JourneyContext, + latestMigrationDirName, + planMigrationAndSelfEmit, + runContractEmit, + runMigrate, + runMigrationPlan, + selfEmitMigration, + setupJourney, + sql, + swapContract, + timeouts, + useDevDatabase, +} from '../utils/journey-test-helpers'; + +const BACKFILLED_NAME = 'unknown'; + +type ContractVariant = Parameters[1]; + +interface Scenario { + readonly strategy: string; + readonly title: string; + /** Contract to swap to (and apply) before seeding; absent = the base contract. */ + readonly initialContract: ContractVariant | undefined; + readonly seedSql: string; + readonly targetContract: ContractVariant; + readonly migrationName: string; + readonly dirToken: string; + readonly placeholderId: string; + readonly assertScaffold: (scaffold: string) => void; + readonly checkReplacement: string; + readonly runReplacement: string; + readonly assertOps: (ops: readonly { id: string; operationClass?: string }[]) => void; + readonly postApplySelect: string; + readonly expectedRows: readonly Record[]; + readonly columnInfoSql: string; + readonly expectedColumnInfo: readonly Record[]; + /** The backfill scenario also pins the re-apply no-op path (spec AC4.2). */ + readonly assertReapplyNoop: boolean; +} + +const scenarios: readonly Scenario[] = [ + { + strategy: 'notNullBackfillCallStrategy', + title: 'NOT NULL backfill: planner emits placeholder() stubs; apply backfills + sets NOT NULL', + initialContract: undefined, + seedSql: `INSERT INTO "public"."user" (id, email) VALUES (1, 'alice@example.com'), (2, 'bob@test.org')`, + targetContract: 'contract-additive-required-name', + migrationName: 'add-required-name', + dirToken: 'add_required_name', + placeholderId: 'backfill-user-name', + assertScaffold: () => {}, + checkReplacement: + "() => db.public.user.select('id').where((f, fns) => fns.eq(f.name, null)).limit(1)", + runReplacement: `() => db.public.user.update({ name: '${BACKFILLED_NAME}' }).where((f, fns) => fns.eq(f.name, null))`, + assertOps: () => {}, + postApplySelect: `SELECT id, email, "name" FROM "public"."user" ORDER BY id`, + expectedRows: [ + { id: 1, email: 'alice@example.com', name: BACKFILLED_NAME }, + { id: 2, email: 'bob@test.org', name: BACKFILLED_NAME }, + ], + columnInfoSql: `SELECT is_nullable FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'user' AND column_name = 'name'`, + expectedColumnInfo: [{ is_nullable: 'NO' }], + assertReapplyNoop: true, + }, + { + strategy: 'nullableTighteningCallStrategy', + title: + 'nullable → NOT NULL tightening: planner emits placeholder() stubs; apply backfills NULLs + tightens', + initialContract: 'contract-nullable-name', + seedSql: `INSERT INTO "public"."user" (id, email, "name") VALUES (1, 'alice@example.com', 'Alice'), (2, 'bob@test.org', NULL)`, + targetContract: 'contract-nullable-name-required', + migrationName: 'tighten-name-not-null', + dirToken: 'tighten_name_not_null', + placeholderId: 'handle-nulls-user-name', + assertScaffold: (scaffold) => { + expect(scaffold).toContain('setNotNull'); + // The planner *must not* emit an addColumn here: this is the + // tightening case, the column already exists. + expect(scaffold).not.toContain('addColumn'); + }, + checkReplacement: + "() => db.public.user.select('id').where((f, fns) => fns.eq(f.name, null)).limit(1)", + runReplacement: `() => db.public.user.update({ name: '${BACKFILLED_NAME}' }).where((f, fns) => fns.eq(f.name, null))`, + assertOps: (ops) => { + const setNotNullOp = ops.find((op) => op.id.includes('setNotNull.user.name')); + expect(setNotNullOp, 'setNotNull op exists').toBeDefined(); + }, + postApplySelect: `SELECT id, email, "name" FROM "public"."user" ORDER BY id`, + expectedRows: [ + { id: 1, email: 'alice@example.com', name: 'Alice' }, + { id: 2, email: 'bob@test.org', name: BACKFILLED_NAME }, + ], + columnInfoSql: `SELECT is_nullable FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'user' AND column_name = 'name'`, + expectedColumnInfo: [{ is_nullable: 'NO' }], + assertReapplyNoop: false, + }, + { + strategy: 'typeChangeCallStrategy', + title: 'text → int4 type change: planner emits placeholder() stubs; apply alters column type', + // The user-filled queries here are intentionally no-ops (guarded by + // `id = -1`): the goal is the dataTransform → alterColumnType pipeline + // end-to-end, with the `USING score::int4` cast doing the conversion. + // A real "score is not castable" check against a text column through + // the int4-typed ORM surface isn't currently expressible without an + // escape hatch. + initialContract: 'contract-typechange-text', + seedSql: `INSERT INTO "public"."user" (id, email, score) VALUES (1, 'alice@example.com', '10'), (2, 'bob@test.org', '20')`, + targetContract: 'contract-typechange-int', + migrationName: 'retype-score-to-int', + dirToken: 'retype_score_to_int', + placeholderId: 'typechange-user-score', + assertScaffold: (scaffold) => { + expect(scaffold).toContain('alterColumnType'); + }, + checkReplacement: + "() => db.public.user.select('id').where((f, fns) => fns.eq(f.id, -1)).limit(1)", + runReplacement: '() => db.public.user.update({ score: 0 }).where((f, fns) => fns.eq(f.id, -1))', + assertOps: (ops) => { + const alterOp = ops.find((op) => op.id.startsWith('alterType.user.score')); + expect(alterOp, 'alterColumnType op exists').toBeDefined(); + expect(alterOp?.operationClass).toBe('destructive'); + }, + postApplySelect: `SELECT id, email, score FROM "public"."user" ORDER BY id`, + expectedRows: [ + { id: 1, email: 'alice@example.com', score: 10 }, + { id: 2, email: 'bob@test.org', score: 20 }, + ], + columnInfoSql: `SELECT data_type FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'user' AND column_name = 'score'`, + expectedColumnInfo: [{ data_type: 'integer' }], + assertReapplyNoop: false, + }, +]; + +withTempDir(({ createTempDir }) => { + describe.each(scenarios)('Journey: dataTransform — $strategy (planner-assisted)', (scenario) => { + const db = useDevDatabase(); + + it( + scenario.title, + async () => { + const ctx: JourneyContext = setupJourney({ + connectionString: db.connectionString, + createTempDir, + }); + + if (scenario.initialContract !== undefined) { + swapContract(ctx, scenario.initialContract); + } + const emit0 = await runContractEmit(ctx); + expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); + expect(plan0.exitCode, `plan initial: ${plan0.stderr}`).toBe(0); + const apply0 = await runMigrate(ctx); + expect(apply0.exitCode, `apply initial: ${apply0.stderr}`).toBe(0); + + await sql(db.connectionString, scenario.seedSql); + + // The contract swap is the input the strategy matches on. + swapContract(ctx, scenario.targetContract); + const emit1 = await runContractEmit(ctx); + expect(emit1.exitCode, `emit target: ${emit1.stderr}`).toBe(0); + + const planResult = await runMigrationPlan(ctx, [ + '--name', + scenario.migrationName, + '--from', + latestMigrationDirName(ctx), + ]); + expect(planResult.exitCode, `plan: ${planResult.stderr}`).toBe(0); + + const migrationsDir = join(ctx.testDir, 'migrations', 'app'); + const migrationDirs = readdirSync(migrationsDir) + .filter((d) => d.includes(scenario.dirToken)) + .sort(); + expect(migrationDirs.length, 'planned migration dir exists').toBe(1); + const migrationDir = join(migrationsDir, migrationDirs[0]!); + const migrationTsPath = join(migrationDir, 'migration.ts'); + + const scaffold = readFileSync(migrationTsPath, 'utf-8'); + expect(scaffold).toContain(`placeholder('${scenario.placeholderId}:check')`); + expect(scaffold).toContain(`placeholder('${scenario.placeholderId}:run')`); + scenario.assertScaffold(scaffold); + const manifestBefore = JSON.parse( + readFileSync(join(migrationDir, 'migration.json'), 'utf-8'), + ); + // The package is fully attested even when the planner could not + // lower any calls because of placeholders: `ops.json` is `[]` and + // `migrationHash` is the content-address over `(manifest, [])`. + // The author re-emits after filling in placeholders to rewrite + // both `ops.json` and `migrationHash`. + expect(manifestBefore.migrationHash).toMatch(/^[a-f0-9]{64}$/); + expect(JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8'))).toEqual([]); + + const filled = injectMigrationSqlDbSetup(scaffold) + .replace( + `() => placeholder('${scenario.placeholderId}:check')`, + scenario.checkReplacement, + ) + .replace(`() => placeholder('${scenario.placeholderId}:run')`, scenario.runReplacement); + expect(filled).not.toContain('placeholder('); + expect(filled).toContain('const db = sql('); + writeFileSync(migrationTsPath, filled); + + const emitResult = await selfEmitMigration(ctx, [ + '--dir', + migrationDir, + '--config', + ctx.configPath, + ]); + expect(emitResult.exitCode, `emit: ${emitResult.stdout}\n${emitResult.stderr}`).toBe(0); + + const opsAfterEmit = JSON.parse( + readFileSync(join(migrationDir, 'ops.json'), 'utf-8'), + ) as readonly { + id: string; + operationClass?: string; + precheck?: readonly unknown[]; + execute?: readonly unknown[]; + postcheck?: readonly unknown[]; + }[]; + const dataTransformOp = opsAfterEmit.find( + (op) => op.id === `data_migration.${scenario.placeholderId}`, + ); + expect(dataTransformOp, 'dataTransform op exists').toBeDefined(); + expect(dataTransformOp?.operationClass).toBe('data'); + expect(dataTransformOp?.precheck).toHaveLength(1); + expect(dataTransformOp?.execute).toHaveLength(1); + expect(dataTransformOp?.postcheck).toHaveLength(1); + scenario.assertOps(opsAfterEmit); + + const manifestAfter = JSON.parse( + readFileSync(join(migrationDir, 'migration.json'), 'utf-8'), + ); + expect(manifestAfter.migrationHash).toMatch(/^[a-f0-9]{64}$/); + + const apply1 = await runMigrate(ctx); + expect(apply1.exitCode, `apply: ${apply1.stdout}\n${apply1.stderr}`).toBe(0); + + const result = await sql(db.connectionString, scenario.postApplySelect); + expect(result.rows).toEqual(scenario.expectedRows); + + const colInfo = await sql(db.connectionString, scenario.columnInfoSql); + expect(colInfo.rows).toEqual(scenario.expectedColumnInfo); + + if (scenario.assertReapplyNoop) { + // Re-apply must be a no-op: the marker advanced past this + // migration and the dataTransform op is idempotency-skipped + // because its `check` query now returns 0 rows (all NULLs + // were backfilled by the first apply). Pins both the + // runner's marker-CAS ledger advance and the data-transform + // check-driven skip path (spec AC4.2 idempotency half). + const reapply = await runMigrate(ctx); + expect(reapply.exitCode, `reapply: ${reapply.stdout}\n${reapply.stderr}`).toBe(0); + expect(reapply.stderr).toContain('Already up to date'); + } + }, + timeouts.spinUpPpgDev, + ); + }); +}); diff --git a/test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts deleted file mode 100644 index 366b7bb0cd1c..000000000000 --- a/test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Type change — `typeChangeCallStrategy` end-to-end. - * - * Drives a contract change that retypes an existing column from - * `text` to `int4`. That transition is unsafe (not in - * `SAFE_WIDENINGS`), so the Postgres planner's - * `typeChangeCallStrategy` matches it and emits - * `DataTransformCall(placeholder slots) → alterColumnType`. The - * planner-emitted `migration.ts` therefore has two `placeholder("…")` - * stubs the user must fill in. This test simulates the user editing - * the file (string-patching the stubs and injecting a - * `db = sql({ context, rawCodecInferer: { inferCodec: () => 'pg/text' } })` setup), then runs `migration emit` + - * `migration apply` and asserts the post-apply column has switched - * to `int4` with the expected integer values. - * - * Phase 2 acceptance: covers `postgresPlannerStrategies` (data-safe path) end-to-end - * for the unsafe type-change case (plan.md AC R2.2 #2). - */ - -import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { withTempDir } from '../utils/cli-test-helpers'; -import { - injectMigrationSqlDbSetup, - type JourneyContext, - runContractEmit, - runMigrate, - runMigrationEmit, - runMigrationPlan, - runMigrationPlanAndEmit, - setupJourney, - sql, - swapContract, - timeouts, - useDevDatabase, -} from '../utils/journey-test-helpers'; - -withTempDir(({ createTempDir }) => { - describe('Journey: dataTransform — text → int4 type change (planner-assisted)', () => { - const db = useDevDatabase(); - - it( - 'planner emits placeholder() stubs the user fills in; apply normalises data + alters column type', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - }); - - // Initial contract: User.score is text. Apply, then seed two - // rows whose `score` happens to be parseable as an int. The - // user-filled `:run` query in this scenario is intentionally - // a no-op — the goal of the test is to exercise the - // `dataTransform → alterColumnType` pipeline end-to-end, not - // to do any real normalisation work. (Expressing a proper - // "score is not castable to int4" check against a text column - // through the int4-typed ORM surface isn't currently possible - // without an escape hatch.) - swapContract(ctx, 'contract-typechange-text'); - const emit0 = await runContractEmit(ctx); - expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); - expect(plan0.exitCode, `plan initial: ${plan0.stderr}`).toBe(0); - const apply0 = await runMigrate(ctx); - expect(apply0.exitCode, `apply initial: ${apply0.stderr}`).toBe(0); - - await sql( - db.connectionString, - `INSERT INTO "public"."user" (id, email, score) VALUES (1, 'alice@example.com', '10'), (2, 'bob@test.org', '20')`, - ); - - // Swap to the int4 contract: this is the input to - // `typeChangeCallStrategy`. - swapContract(ctx, 'contract-typechange-int'); - const emit1 = await runContractEmit(ctx); - expect(emit1.exitCode, `emit int: ${emit1.stderr}`).toBe(0); - - const planResult = await runMigrationPlan(ctx, ['--name', 'retype-score-to-int']); - expect(planResult.exitCode, `plan: ${planResult.stderr}\n${planResult.stderr}`).toBe(0); - - const migrationsDir = join(ctx.testDir, 'migrations', 'app'); - const migrationDirs = readdirSync(migrationsDir) - .filter((d) => d.includes('retype_score_to_int')) - .sort(); - expect(migrationDirs.length, 'planned migration dir exists').toBe(1); - const migrationDir = join(migrationsDir, migrationDirs[0]!); - const migrationTsPath = join(migrationDir, 'migration.ts'); - - const scaffold = readFileSync(migrationTsPath, 'utf-8'); - expect(scaffold).toContain("placeholder('typechange-user-score:check')"); - expect(scaffold).toContain("placeholder('typechange-user-score:run')"); - expect(scaffold).toContain('alterColumnType'); - const manifestBefore = JSON.parse( - readFileSync(join(migrationDir, 'migration.json'), 'utf-8'), - ); - // The package is fully attested even when the planner could not - // lower any calls because of placeholders: `ops.json` is `[]` and - // `migrationHash` is the content-address over `(manifest, [])`. - // The author re-emits after filling in placeholders to rewrite - // both `ops.json` and `migrationHash`. - expect(manifestBefore.migrationHash).toMatch(/^[a-f0-9]{64}$/); - expect(JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8'))).toEqual([]); - - // Both queries are guarded by `id = -1` so the test's - // pre-cleaned seed data flows through unchanged and the - // `alterColumnType USING score::int4` cast handles the actual - // conversion. The point of patching the stubs is to prove the - // planner-emitted migration is well-typed against the *new* - // contract (where `score` is int4) and that the - // `dataTransform → alterColumnType` pipeline runs end-to-end. - const filled = injectMigrationSqlDbSetup(scaffold) - .replace( - "() => placeholder('typechange-user-score:check')", - "() => db.public.user.select('id').where((f, fns) => fns.eq(f.id, -1)).limit(1)", - ) - .replace( - "() => placeholder('typechange-user-score:run')", - '() => db.public.user.update({ score: 0 }).where((f, fns) => fns.eq(f.id, -1))', - ); - expect(filled).not.toContain('placeholder('); - expect(filled).toContain('const db = sql('); - writeFileSync(migrationTsPath, filled); - - const emitResult = await runMigrationEmit(ctx, [ - '--dir', - migrationDir, - '--config', - ctx.configPath, - ]); - expect(emitResult.exitCode, `emit: ${emitResult.stdout}\n${emitResult.stderr}`).toBe(0); - - const opsAfterEmit = JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8')); - const dataTransformOp = opsAfterEmit.find( - (op: { id: string }) => op.id === 'data_migration.typechange-user-score', - ); - expect(dataTransformOp, 'dataTransform op exists').toBeDefined(); - expect(dataTransformOp.operationClass).toBe('data'); - expect(dataTransformOp.precheck).toHaveLength(1); - expect(dataTransformOp.execute).toHaveLength(1); - expect(dataTransformOp.postcheck).toHaveLength(1); - - const alterOp = opsAfterEmit.find((op: { id: string }) => - op.id.startsWith('alterType.user.score'), - ); - expect(alterOp, 'alterColumnType op exists').toBeDefined(); - expect(alterOp.operationClass).toBe('destructive'); - - const apply1 = await runMigrate(ctx); - expect(apply1.exitCode, `apply: ${apply1.stdout}\n${apply1.stderr}`).toBe(0); - - const result = await sql( - db.connectionString, - `SELECT id, email, score FROM "public"."user" ORDER BY id`, - ); - expect(result.rows).toEqual([ - { id: 1, email: 'alice@example.com', score: 10 }, - { id: 2, email: 'bob@test.org', score: 20 }, - ]); - - // The column must now have integer storage type — the alter - // ran and the USING cast converted text → int4. - const colInfo = await sql( - db.connectionString, - `SELECT data_type FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = 'user' AND column_name = 'score'`, - ); - expect(colInfo.rows).toEqual([{ data_type: 'integer' }]); - }, - timeouts.spinUpPpgDev, - ); - }); -}); diff --git a/test/integration/test/cli-journeys/db-sign-contract-arg.e2e.test.ts b/test/integration/test/cli-journeys/db-sign-contract-arg.e2e.test.ts index d5f98d414a24..7d25ac44639f 100644 --- a/test/integration/test/cli-journeys/db-sign-contract-arg.e2e.test.ts +++ b/test/integration/test/cli-journeys/db-sign-contract-arg.e2e.test.ts @@ -17,10 +17,10 @@ import { engineDocument, type JourneyContext, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runDbInit, runDbSign, - runMigrationPlanAndEmit, runRef, setupJourney, timeouts, @@ -42,7 +42,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan.exitCode, 'plan').toBe(0); const planJson = parseJsonOutput(plan); diff --git a/test/integration/test/cli-journeys/db-update-workflows.e2e.test.ts b/test/integration/test/cli-journeys/db-update-workflows.e2e.test.ts index ab1544290a12..2fe703e0896d 100644 --- a/test/integration/test/cli-journeys/db-update-workflows.e2e.test.ts +++ b/test/integration/test/cli-journeys/db-update-workflows.e2e.test.ts @@ -8,14 +8,9 @@ * column, test that --no-interactive blocks destructive changes, --json * returns an error envelope, and --json -y auto-accepts and succeeds. * - * Note: Journey O ("re-init conflict") was removed when the dual `db init` - * path was collapsed onto the per-space flow. The `MARKER_ORIGIN_MISMATCH` - * gate that previously failed `db init` whenever the marker did not match the - * destination contract no longer exists; the per-space planner reconciles - * schema drift and the marker is treated as bookkeeping rather than an - * authoritative source. Marker-aware violations (orphan markers, - * declared-but-unmigrated extension spaces) are caught by the contract-space - * verifier instead — see `cli.db-init.contract-space-verifier.test.ts` and + * Marker-aware violations (orphan markers, declared-but-unmigrated + * extension spaces) are caught by the contract-space verifier — see + * `cli.db-init.contract-space-verifier.test.ts` and * `cli.db-update.contract-space-verifier.test.ts`. */ diff --git a/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts b/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts index 0ebf2613fc74..7264f7f2d03a 100644 --- a/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts +++ b/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts @@ -22,12 +22,13 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planMigrationAndSelfEmit, runContractEmit, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, runRef, setupJourney, @@ -48,6 +49,7 @@ withTempDir(({ createTempDir }) => { describe('Journey D: Diamond Convergence', () => { const stagingDb = useDevDatabase(); const prodDb = useDevDatabase(); + const freshDb = useDevDatabase(); it( 'two environments diverge from C1, converge to C5 via distinct paths', @@ -61,7 +63,7 @@ withTempDir(({ createTempDir }) => { // D.01: emit base (C1) → plan init (∅→C1) const emit0 = await runContractEmit(staging); expect(emit0.exitCode, 'D.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(staging, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(staging, ['--name', 'init', '--json']); expect(plan0.exitCode, 'D.01: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; @@ -83,7 +85,13 @@ withTempDir(({ createTempDir }) => { swapContract(staging, 'contract-phone'); const emit1 = await runContractEmit(staging); expect(emit1.exitCode, 'D.04: emit C2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(staging, ['--name', 'add-phone', '--json']); + const plan1 = await planMigrationAndSelfEmit(staging, [ + '--name', + 'add-phone', + '--from', + latestMigrationDirName(staging), + '--json', + ]); expect(plan1.exitCode, 'D.04: plan C1→C2').toBe(0); const applyStaging1 = await runMigrate(staging); expect(applyStaging1.exitCode, 'D.04: apply C2 to staging').toBe(0); @@ -92,7 +100,13 @@ withTempDir(({ createTempDir }) => { swapContract(staging, 'contract-phone-bio'); const emit2 = await runContractEmit(staging); expect(emit2.exitCode, 'D.05: emit C3').toBe(0); - const plan2 = await runMigrationPlanAndEmit(staging, ['--name', 'add-bio', '--json']); + const plan2 = await planMigrationAndSelfEmit(staging, [ + '--name', + 'add-bio', + '--from', + latestMigrationDirName(staging), + '--json', + ]); expect(plan2.exitCode, 'D.05: plan C2→C3').toBe(0); const c3Hash = parseJsonOutput<{ to: string }>(plan2).to; const applyStaging2 = await runMigrate(staging); @@ -108,7 +122,7 @@ withTempDir(({ createTempDir }) => { swapContract(staging, 'contract-avatar'); const emit3 = await runContractEmit(staging); expect(emit3.exitCode, 'D.06: emit C4').toBe(0); - const plan3 = await runMigrationPlanAndEmit(staging, [ + const plan3 = await planMigrationAndSelfEmit(staging, [ '--name', 'add-avatar', '--from', @@ -136,7 +150,7 @@ withTempDir(({ createTempDir }) => { expect(emit4.exitCode, 'D.07: emit C5').toBe(0); // Plan merge from staging branch: C3→C5 - const planMergeStaging = await runMigrationPlanAndEmit(staging, [ + const planMergeStaging = await planMigrationAndSelfEmit(staging, [ '--name', 'merge-staging', '--from', @@ -147,7 +161,7 @@ withTempDir(({ createTempDir }) => { const c5Hash = parseJsonOutput<{ to: string }>(planMergeStaging).to; // Plan merge from production branch: C4→C5 - const planMergeProd = await runMigrationPlanAndEmit(staging, [ + const planMergeProd = await planMigrationAndSelfEmit(staging, [ '--name', 'merge-prod', '--from', @@ -212,6 +226,23 @@ withTempDir(({ createTempDir }) => { prodStatusData.migrations.length, 'D.10: production lists migrations', ).toBeGreaterThan(3); + + // Shortest-path selection: apply the whole graph to an empty database — + // the pathfinder + // picks the shortest route to C5 (∅→C1→C4→C5, 3 steps) over the + // longer staging branch (∅→C1→C2→C3→C5, 4 steps). Folded in from the + // deleted converging-paths journey. + const fresh = createSecondDbContext(staging, freshDb.connectionString); + const applyFresh = await runMigrate(fresh, ['--json']); + expect(applyFresh.exitCode, 'apply to empty database').toBe(0); + const freshResult = parseJsonOutput<{ + ok: boolean; + migrationsApplied: number; + markerHash: string; + }>(applyFresh); + expect(freshResult.ok, 'apply to empty database ok').toBe(true); + expect(freshResult.markerHash, 'empty-database marker at C5').toBe(c5Hash); + expect(freshResult.migrationsApplied, 'shortest path = 3 steps').toBe(3); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/cli-journeys/divergence-and-refs.e2e.test.ts b/test/integration/test/cli-journeys/divergence-and-refs.e2e.test.ts index f77aeca17134..dac62eb82573 100644 --- a/test/integration/test/cli-journeys/divergence-and-refs.e2e.test.ts +++ b/test/integration/test/cli-journeys/divergence-and-refs.e2e.test.ts @@ -5,7 +5,7 @@ * are handled gracefully. Creates: * C1 → C2 (add-phone, on disk but not applied) * C1 → C3 (add-bio, via --from C1) - * Without --ref, status auto-resolves to the contract hash if it matches + * Without --to, status auto-resolves to the contract hash if it matches * a graph node. With ref production=C3, apply routes via C1→C3. */ @@ -16,9 +16,9 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planMigrationAndSelfEmit, runContractEmit, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, runRef, setupJourney, @@ -42,7 +42,7 @@ withTempDir(({ createTempDir }) => { // L.01: emit base (C1) → plan + apply init const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'L.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'L.01: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; const apply0 = await runMigrate(ctx); @@ -52,7 +52,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'L.02: emit C2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-phone', '--json']); + const plan1 = await planMigrationAndSelfEmit(ctx, ['--name', 'add-phone', '--json']); expect(plan1.exitCode, 'L.02: plan C1→C2').toBe(0); const c2Hash = parseJsonOutput<{ to: string }>(plan1).to; @@ -60,7 +60,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-bio'); const emit2 = await runContractEmit(ctx); expect(emit2.exitCode, 'L.03: emit C3').toBe(0); - const plan2 = await runMigrationPlanAndEmit(ctx, [ + const plan2 = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-bio', '--from', @@ -71,7 +71,7 @@ withTempDir(({ createTempDir }) => { const c3Hash = parseJsonOutput<{ to: string }>(plan2).to; expect(c3Hash, 'L.03: C3 differs from C2').not.toBe(c2Hash); - // L.04: status without --ref succeeds — auto-resolves to contract hash (C3) + // status without --to succeeds — auto-resolves to contract hash (C3) const statusAuto = await runMigrationStatus(ctx, ['--json']); expect(statusAuto.exitCode, 'L.04: status succeeds').toBe(0); const statusData = parseMigrationStatusJson(statusAuto); @@ -85,9 +85,18 @@ withTempDir(({ createTempDir }) => { const refSet = await runRef(ctx, ['set', 'production', c3Hash]); expect(refSet.exitCode, 'L.05: ref set production').toBe(0); - // L.06: apply with --ref production → routes via C1→C3 + // A ref ahead of the DB marker shows exactly its pending edge + // (folded in from the deleted ref-routing journey). + const statusAhead = await runMigrationStatus(ctx, ['--to', 'production', '--json']); + expect(statusAhead.exitCode, 'status --to production with ref ahead').toBe(0); + const aheadPending = migrationStatusAppSpace( + parseMigrationStatusJson(statusAhead), + ).migrations.filter((m) => m.status === 'pending').length; + expect(aheadPending, 'production has 1 pending').toBe(1); + + // apply with --to production → routes via C1→C3 const applyRef = await runMigrate(ctx, ['--to', 'production', '--json']); - expect(applyRef.exitCode, 'L.06: apply --ref production').toBe(0); + expect(applyRef.exitCode, 'apply --to production').toBe(0); const applyResult = parseJsonOutput<{ ok: boolean; migrationsApplied: number; @@ -97,9 +106,32 @@ withTempDir(({ createTempDir }) => { expect(applyResult.migrationsApplied, 'L.06: applied 1').toBe(1); expect(applyResult.markerHash, 'L.06: marker at C3').toBe(c3Hash); - // L.07: status with --ref production + // status with --to production resolves the target via the ref + // even on a divergent graph (also covers the deleted + // migration-status-diagnostics case "divergent graph with ref"). const statusRef = await runMigrationStatus(ctx, ['--to', 'production', '--json']); - expect(statusRef.exitCode, 'L.07: status --ref production').toBe(0); + expect(statusRef.exitCode, 'status --to production after apply').toBe(0); + expect( + migrationStatusAppSpace(parseMigrationStatusJson(statusRef)).targetContract, + 'target resolved via ref to C3', + ).toBe(c3Hash); + + // Marker ahead of ref (folded in from the deleted ref-routing + // journey's marker-ahead cases, spec P-6): point production back at C1, which + // is now behind the DB marker C3. Apply fails (no backward edge) and + // status names the ahead-of-ref condition. + const refBack = await runRef(ctx, ['set', 'production', c1Hash]); + expect(refBack.exitCode, 'ref set production=C1').toBe(0); + const applyBehind = await runMigrate(ctx, ['--to', 'production', '--json']); + expect(applyBehind.exitCode, 'apply --to production fails when the marker is ahead').toBe( + 2, + ); + const statusBehind = await runMigrationStatus(ctx, ['--to', 'production', '--json']); + expect(statusBehind.exitCode, 'status --to production settles').toBe(0); + expect( + parseMigrationStatusJson(statusBehind).summary, + 'status indicates ahead-of-ref condition', + ).toMatch(/ahead|no.*path|mismatch|cannot reach/i); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts b/test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts deleted file mode 100644 index a7acc61b55ef..000000000000 --- a/test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Migration DAG Drift — Deleted Root Migration (Journey P4) - * - * After building a 2-step migration chain (initial → add-name), the - * initial migration directory is deleted from disk. This leaves an - * orphaned migration (add-name) whose origin hash has no incoming edge - * from EMPTY_CONTRACT_HASH. The system must detect this and report an - * error rather than silently treating the graph as empty. - */ - -import { readdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { timeouts } from '@repo/test-utils'; -import stripAnsi from 'strip-ansi'; -import { describe, expect, it } from 'vitest'; -import { withTempDir } from '../utils/cli-test-helpers'; -import { - type JourneyContext, - parseJsonOutput, - runContractEmit, - runMigrate, - runMigrationPlan, - runMigrationPlanAndEmit, - runMigrationStatus, - setupJourney, - swapContract, - useDevDatabase, -} from '../utils/journey-test-helpers'; - -withTempDir(({ createTempDir }) => { - describe('Journey P4: Deleted Root Migration', () => { - const db = useDevDatabase(); - - it( - 'deleting root migration is detected as broken graph, not silently ignored', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - }); - - // Build a 2-migration chain: base → additive - const emit0 = await runContractEmit(ctx); - expect(emit0.exitCode, 'P4.pre: emit base').toBe(0); - const planInit = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); - expect(planInit.exitCode, 'P4.pre: plan initial').toBe(0); - const applyInit = await runMigrate(ctx); - expect(applyInit.exitCode, 'P4.pre: apply initial').toBe(0); - - swapContract(ctx, 'contract-additive'); - const emit1 = await runContractEmit(ctx); - expect(emit1.exitCode, 'P4.pre: emit v2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-name']); - expect(plan1.exitCode, 'P4.pre: plan add-name').toBe(0); - const apply1 = await runMigrate(ctx); - expect(apply1.exitCode, 'P4.pre: apply add-name').toBe(0); - - // Delete the FIRST migration (root edge: empty → base) - const migrationsDir = join(ctx.testDir, 'migrations', 'app'); - const migrationDirs = readdirSync(migrationsDir).sort(); - const initDir = migrationDirs.find((d) => d.endsWith('_initial')); - expect(initDir, 'P4.pre: initial dir exists').toBeDefined(); - rmSync(join(migrationsDir, initDir!), { recursive: true, force: true }); - - // Verify only add-name remains on disk - const remaining = readdirSync(migrationsDir).filter((d) => !d.startsWith('.')); - expect(remaining, 'P4.pre: only add-name remains').toHaveLength(1); - expect(remaining[0], 'P4.pre: remaining is add-name').toMatch(/_add_name$/); - - // P4.01: migration status still lists the orphaned on-disk migration - const status = await runMigrationStatus(ctx); - expect(status.exitCode, 'P4.01: status succeeds').toBe(0); - const statusOutput = stripAnsi(status.stderr); - expect(statusOutput, 'P4.01: surviving migration visible').toMatch(/add_name/); - expect(statusOutput, 'P4.01: not treated as empty').not.toContain('No migrations found'); - - // P4.02: migration plan uses the db ref even when the graph chain is - // broken — it must not silently greenfield-plan a duplicate init - const planAgain = await runMigrationPlan(ctx, ['--name', 'catch-up', '--json']); - expect(planAgain.exitCode, 'P4.02: plan from db ref').toBe(0); - const planResult = parseJsonOutput<{ from: string }>(planAgain); - expect(planResult.from, 'P4.02: from is db ref not empty sentinel').not.toBe('empty'); - const dirsAfterPlan = readdirSync(migrationsDir).filter((d) => !d.startsWith('.')); - expect(dirsAfterPlan, 'P4.02: orphaned add-name only — no greenfield init').toHaveLength(1); - expect(dirsAfterPlan[0], 'P4.02: surviving migration is add-name').toMatch(/_add_name$/); - }, - timeouts.spinUpPpgDev, - ); - }); -}); diff --git a/test/integration/test/cli-journeys/drift-marker.e2e.test.ts b/test/integration/test/cli-journeys/drift-marker.e2e.test.ts index 2ce013b6ae07..830121243304 100644 --- a/test/integration/test/cli-journeys/drift-marker.e2e.test.ts +++ b/test/integration/test/cli-journeys/drift-marker.e2e.test.ts @@ -170,7 +170,7 @@ withTempDir(({ createTempDir }) => { const emitV3 = await runContractEmit(ctx); expect(emitV3.exitCode, 'P.02.pre: emit v3').toBe(0); - // P.02: db update to v3 (recovery via db update instead of migration apply) + // db update to v3 (recovery via db update instead of migrate) const updateV3 = await runDbUpdate(ctx); expect(updateV3.exitCode, 'P.02: db update to v3').toBe(0); diff --git a/test/integration/test/cli-journeys/drift-migration-dag.e2e.test.ts b/test/integration/test/cli-journeys/drift-migration-dag.e2e.test.ts index b7f0c3b8d76d..59e346355dc5 100644 --- a/test/integration/test/cli-journeys/drift-migration-dag.e2e.test.ts +++ b/test/integration/test/cli-journeys/drift-migration-dag.e2e.test.ts @@ -3,7 +3,7 @@ * * After building a migration chain (initial → add-name → add-posts), the * add-posts directory is deleted from disk. migration status reports the - * broken chain, migration apply fails (no path to destination), and recovery + * broken chain, migrate fails (no path to destination), and recovery * is achieved by re-planning the missing edge and applying it. */ @@ -13,10 +13,11 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -42,7 +43,7 @@ withTempDir(({ createTempDir }) => { // Precondition: emit base, plan+apply initial, then plan and apply first migration const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'P3.pre: emit base').toBe(0); - const planInit = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const planInit = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(planInit.exitCode, 'P3.pre: plan initial').toBe(0); const applyInit = await runMigrate(ctx); expect(applyInit.exitCode, 'P3.pre: apply initial').toBe(0); @@ -50,7 +51,12 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'P3.pre: emit v2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-name']); + const plan1 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-name', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan1.exitCode, 'P3.pre: plan v2').toBe(0); const apply1 = await runMigrate(ctx); expect(apply1.exitCode, 'P3.pre: apply v2').toBe(0); @@ -59,7 +65,12 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-v3'); const emit2 = await runContractEmit(ctx); expect(emit2.exitCode, 'P3.pre: emit v3').toBe(0); - const plan2 = await runMigrationPlan(ctx, ['--name', 'add-posts']); + const plan2 = await runMigrationPlan(ctx, [ + '--name', + 'add-posts', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan2.exitCode, 'P3.pre: plan v3').toBe(0); // Delete the add-posts migration directory (additive→v3 edge) @@ -71,21 +82,42 @@ withTempDir(({ createTempDir }) => { expect(addPostsDir, 'P3.pre: add-posts dir exists').toBeDefined(); rmSync(join(migrationsDir, addPostsDir!), { recursive: true, force: true }); - // P3.01: migration status (reports broken chain — contract has no matching leaf) + // migration status reports the broken chain (contract has no + // matching leaf) and still lists the surviving on-disk migrations + // rather than treating the space as empty (folded in from the + // deleted drift-deleted-root journey). const statusBroken = await runMigrationStatus(ctx); - expect([0, 1], 'P3.01: status exits 0 or 1').toContain(statusBroken.exitCode); + expect([0, 1], 'status exits 0 or 1').toContain(statusBroken.exitCode); + expect(statusBroken.stderr, 'surviving migrations visible').toMatch(/add_name/); + expect(statusBroken.stderr, 'not treated as empty').not.toContain('No migrations found'); - // P3.02: migration apply (fails — no path from marker to destination contract) + // migrate fails — no path from marker to destination contract const applyFail = await runMigrate(ctx); - expect(applyFail.exitCode, 'P3.02: migration apply fails').not.toBe(0); + expect(applyFail.exitCode, 'migrate fails on the broken chain').not.toBe(0); // P3.03: re-plan the missing edge (chain leaf is additive, contract is v3) - const rePlan = await runMigrationPlanAndEmit(ctx, ['--name', 're-add-posts']); + const rePlan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 're-add-posts', + '--from', + latestMigrationDirName(ctx), + ]); expect(rePlan.exitCode, 'P3.03: migration plan recovery').toBe(0); - // P3.04: migration apply (applies the re-planned additive→v3 migration) + // The recovery plan adds exactly the missing edge — it must not + // greenfield-plan a duplicate init (folded in from the deleted + // drift-deleted-root journey). + const dirsAfterRePlan = readdirSync(migrationsDir).filter( + (d) => !d.startsWith('.') && d !== 'refs', + ); + expect( + dirsAfterRePlan.filter((d) => d.endsWith('_initial')), + 'exactly one init migration', + ).toHaveLength(1); + + // migrate applies the re-planned additive→v3 migration const applyRecovery = await runMigrate(ctx); - expect(applyRecovery.exitCode, 'P3.04: migration apply recovery').toBe(0); + expect(applyRecovery.exitCode, 'migrate applies the recovery plan').toBe(0); }, timeouts.spinUpPpgDev, ); diff --git a/test/integration/test/cli-journeys/expression-index-migration.e2e.test.ts b/test/integration/test/cli-journeys/expression-index-migration.e2e.test.ts index 3919a105ec6e..188ab2a56cad 100644 --- a/test/integration/test/cli-journeys/expression-index-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/expression-index-migration.e2e.test.ts @@ -23,10 +23,11 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, + planMigrationAndSelfEmit, runContractEmit, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, swapContract, swapPslContract, @@ -69,14 +70,14 @@ async function runInitialFlow(ctx: JourneyContext, connectionString: string): Pr const emit = await runContractEmit(ctx); expect(emit.exitCode, `contract emit\n${stripAnsi(emit.stderr)}`).toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(plan.exitCode, `migration plan\n${stripAnsi(plan.stderr)}`).toBe(0); expect(indexSqlOf(readPlannedOps(ctx)).sort(), 'byte-exact index DDL').toEqual( EXPECTED_INDEX_DDL, ); const apply = await runMigrate(ctx); - expect(apply.exitCode, `migration apply\n${stripAnsi(apply.stderr)}`).toBe(0); + expect(apply.exitCode, `migrate\n${stripAnsi(apply.stderr)}`).toBe(0); const verify = await runDbVerify(ctx); expect(verify.exitCode, `db verify clean\n${stripAnsi(verify.stderr)}`).toBe(0); @@ -122,7 +123,12 @@ withTempDir(({ createTempDir }) => { swapPslContract(ctx, 'contract-expression-authored-renamed'); const emitRenamed = await runContractEmit(ctx); expect(emitRenamed.exitCode, `rename: emit\n${stripAnsi(emitRenamed.stderr)}`).toBe(0); - const planRename = await runMigrationPlanAndEmit(ctx, ['--name', 'rename-search-index']); + const planRename = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'rename-search-index', + '--from', + latestMigrationDirName(ctx), + ]); expect(planRename.exitCode, `rename: plan\n${stripAnsi(planRename.stderr)}`).toBe(0); const renameOps = readPlannedOps(ctx); expect( @@ -147,7 +153,12 @@ withTempDir(({ createTempDir }) => { swapPslContract(ctx, 'contract-expression-authored-editedbody'); const emitEdited = await runContractEmit(ctx); expect(emitEdited.exitCode, `body-edit: emit\n${stripAnsi(emitEdited.stderr)}`).toBe(0); - const planEdit = await runMigrationPlanAndEmit(ctx, ['--name', 'edit-search-index-body']); + const planEdit = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'edit-search-index-body', + '--from', + latestMigrationDirName(ctx), + ]); expect(planEdit.exitCode, `body-edit: plan\n${stripAnsi(planEdit.stderr)}`).toBe(0); expect( indexSqlOf(readPlannedOps(ctx)).sort(), diff --git a/test/integration/test/cli-journeys/help-and-flags.e2e.test.ts b/test/integration/test/cli-journeys/help-and-flags.e2e.test.ts index b361c985a093..831284f97870 100644 --- a/test/integration/test/cli-journeys/help-and-flags.e2e.test.ts +++ b/test/integration/test/cli-journeys/help-and-flags.e2e.test.ts @@ -13,62 +13,62 @@ import { parseJsonOutput, runContractEmit, setupJourney } from '../utils/journey withTempDir(({ createTempDir }) => { describe('Journey Y: Global Flags', () => { - // Y.01: --no-color (already used by default in our helpers) + // --no-color it( - 'Y.01: --no-color suppresses ANSI codes in stdout', + '--no-color strips the ANSI codes a TTY run carries', async () => { const ctx = setupJourney({ createTempDir }); - const result = await runContractEmit(ctx); - expect(result.exitCode, 'Y.01: emit succeeds').toBe(0); - // Verify that stdout (the primary output channel) has no ANSI codes. - // Note: stderr may still contain decoration characters from TerminalUI - // even with --no-color due to how the mock captures output. - // The key assertion is that the meaningful output is ANSI-free. - expect( - result.stdout.length + result.stderr.length, - 'Y.01: produces output', - ).toBeGreaterThan(0); + const colored = await runContractEmit(ctx); + expect(colored.exitCode, 'colored emit succeeds').toBe(0); + const plain = await runContractEmit(ctx, ['--no-color']); + expect(plain.exitCode, '--no-color emit succeeds').toBe(0); + + // The harness reports a TTY, so the default run colorizes its + // progress commentary; --no-color must strip every escape code. + expect(colored.stderr, 'TTY run carries ANSI codes').toContain('\u001b['); + expect(plain.stdout + plain.stderr, '--no-color output is ANSI-free').not.toContain( + '\u001b[', + ); }, timeouts.typeScriptCompilation, ); - // Y.02: -q (quiet) + // -q (quiet) it( - 'Y.02: quiet mode reduces output', + 'quiet mode drops the progress commentary the default run prints', async () => { const ctx = setupJourney({ createTempDir }); const normal = await runContractEmit(ctx); - expect(normal.exitCode, 'Y.02: normal emit').toBe(0); + expect(normal.exitCode, 'normal emit').toBe(0); const quiet = await runContractEmit(ctx, ['-q']); - expect(quiet.exitCode, 'Y.02: quiet emit').toBe(0); + expect(quiet.exitCode, 'quiet emit').toBe(0); - // Quiet output should be shorter than or equal to normal output - const normalLen = normal.stdout.length + normal.stderr.length; - const quietLen = quiet.stdout.length + quiet.stderr.length; - expect(quietLen, 'Y.02: quiet output is shorter').toBeLessThanOrEqual(normalLen); + expect(normal.stderr, 'default run narrates progress').toContain('Emitting contract'); + expect(quiet.stderr, 'quiet run does not').not.toContain('Emitting contract'); + expect(quiet.stderr.length, 'quiet output is strictly shorter').toBeLessThan( + normal.stderr.length, + ); }, timeouts.typeScriptCompilation, ); - // Y.03: -v (verbose) + // -v (verbose) it( - 'Y.03: verbose mode increases output', + 'verbose mode adds timings the default run does not print', async () => { const ctx = setupJourney({ createTempDir }); const normal = await runContractEmit(ctx); - expect(normal.exitCode, 'Y.03: normal emit').toBe(0); + expect(normal.exitCode, 'normal emit for the verbose comparison').toBe(0); const verbose = await runContractEmit(ctx, ['-v']); - expect(verbose.exitCode, 'Y.03: verbose emit').toBe(0); + expect(verbose.exitCode, 'verbose emit').toBe(0); - // Verbose output should be longer than normal - const normalLen = normal.stdout.length + normal.stderr.length; - const verboseLen = verbose.stdout.length + verbose.stderr.length; - expect(verboseLen, 'Y.03: verbose output is longer').toBeGreaterThanOrEqual(normalLen); + expect(verbose.stderr, 'verbose run reports timings').toContain('Total time'); + expect(normal.stderr, 'default run reports no timings').not.toContain('Total time'); }, timeouts.typeScriptCompilation, ); diff --git a/test/integration/test/cli-journeys/index-name-convergence.e2e.test.ts b/test/integration/test/cli-journeys/index-name-convergence.e2e.test.ts index a69310df44ee..286e4333f329 100644 --- a/test/integration/test/cli-journeys/index-name-convergence.e2e.test.ts +++ b/test/integration/test/cli-journeys/index-name-convergence.e2e.test.ts @@ -22,17 +22,16 @@ import stripAnsi from 'strip-ansi'; import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { - engineDocument, getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runContractInfer, runDbSign, - runDbUpdate, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, swapPslContract, timeouts, @@ -90,7 +89,7 @@ withTempDir(({ createTempDir }) => { expect(sign.exitCode, `db sign\n${stripAnsi(sign.stderr)}`).toBe(0); // baseline migration (EMPTY → adopted contract); no-op on apply. - const planBaseline = await runMigrationPlanAndEmit(ctx, ['--name', 'baseline']); + const planBaseline = await planMigrationAndSelfEmit(ctx, ['--name', 'baseline']); expect(planBaseline.exitCode, 'plan baseline').toBe(0); const applyBaseline = await runMigrate(ctx, ['--json']); expect(applyBaseline.exitCode, 'apply baseline').toBe(0); @@ -105,7 +104,12 @@ withTempDir(({ createTempDir }) => { expect(emit2.exitCode, `contract emit wire\n${stripAnsi(emit2.stderr)}`).toBe(0); // the first widening plan is renames only, byte-asserted. - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'converge-index-names']); + const plan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'converge-index-names', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan.exitCode, `migration plan\n${stripAnsi(plan.stderr)}`).toBe(0); const ops = readPlannedOps(ctx); expect( @@ -130,7 +134,7 @@ withTempDir(({ createTempDir }) => { // apply the renames. const apply = await runMigrate(ctx, ['--json']); - expect(apply.exitCode, `migration apply\n${stripAnsi(apply.stderr)}`).toBe(0); + expect(apply.exitCode, `migrate\n${stripAnsi(apply.stderr)}`).toBe(0); expect(parseJsonOutput(apply), 'one migration applied').toMatchObject({ migrationsApplied: 1, }); @@ -152,66 +156,4 @@ withTempDir(({ createTempDir }) => { timeouts.spinUpPpgDev, ); }); - - describe('exact-mode adoption round-trip on fields-only indexes', () => { - const db = useDevDatabase({ - onReady: (cs) => - withClient(cs, (client) => - client.query(` - CREATE TABLE "account" ( - id int4 PRIMARY KEY, - email text NOT NULL, - name text NOT NULL - ); - CREATE INDEX "account_email_idx" ON "account" (email); - CREATE INDEX "email_lookup" ON "account" (name, email); - `), - ), - }); - - it( - 'infer → emit → verify zero issues → sign → db update dry-run plans zero ops', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - contractMode: 'psl', - }); - - const infer = await runContractInfer(ctx); - expect(infer.exitCode, `contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); - const inferredPsl = readFileSync(join(ctx.testDir, 'contract.prisma'), 'utf-8'); - expect(inferredPsl, 'default-named index adopted exactly').toContain( - '@@index([email], map: "account_email_idx")', - ); - expect(inferredPsl, 'custom-named index adopted exactly').toContain( - '@@index([name, email], map: "email_lookup")', - ); - - const emit = await runContractEmit(ctx); - expect(emit.exitCode, `contract emit\n${stripAnsi(emit.stderr)}`).toBe(0); - - const schemaVerify = await runDbVerify(ctx, ['--schema-only', '--json']); - expect(schemaVerify.exitCode, 'schema verify zero issues').toBe(0); - expect(engineDocument(schemaVerify), 'no issues').toMatchObject({ - ok: true, - schema: { issues: [] }, - }); - - const sign = await runDbSign(ctx); - expect(sign.exitCode, `db sign\n${stripAnsi(sign.stderr)}`).toBe(0); - const verify = await runDbVerify(ctx); - expect(verify.exitCode, 'db verify').toBe(0); - - // Zero drift ⇒ a dry-run update plans nothing. - const dryRun = await runDbUpdate(ctx, ['--dry-run', '--json']); - expect(dryRun.exitCode, `db update dry-run\n${stripAnsi(dryRun.stderr)}`).toBe(0); - expect(parseJsonOutput(dryRun), 'zero operations').toMatchObject({ - ok: true, - plan: { operations: [] }, - }); - }, - timeouts.spinUpPpgDev, - ); - }); }); diff --git a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts index 66c1e3905ba9..ae864d43528e 100644 --- a/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts +++ b/test/integration/test/cli-journeys/infer-roundtrip-fidelity.e2e.test.ts @@ -517,29 +517,6 @@ withTempDir(({ createTempDir }) => { }, timeouts.spinUpPpgDev, ); - - it( - 'full round trip — infer -> emit -> db verify --schema-only, no hand-editing', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - contractMode: 'psl', - }); - - const infer = await runContractInfer(ctx); - expect(infer.exitCode, `contract infer\n${stripAnsi(infer.stderr)}`).toBe(0); - - const emit = await runContractEmit(ctx); - expect( - emit.exitCode, - `contract emit (unmodified inferred PSL)\n${stripAnsi(emit.stderr)}\n${stripAnsi(emit.stdout)}`, - ).toBe(0); - - await expectVerifiesCleanAfterPull(ctx, 'unmodified inferred PSL'); - }, - timeouts.spinUpPpgDev, - ); }); describe('Journey: 1:1 detection for FKs enforced by a bare CREATE UNIQUE INDEX', () => { diff --git a/test/integration/test/cli-journeys/init-journey.e2e.test.ts b/test/integration/test/cli-journeys/init-journey.e2e.test.ts index 96fe131702d7..a360bb1501b0 100644 --- a/test/integration/test/cli-journeys/init-journey.e2e.test.ts +++ b/test/integration/test/cli-journeys/init-journey.e2e.test.ts @@ -5,14 +5,8 @@ * query against a real DB, across all four `(target × authoring)` cells. * Asserts the contract one subsystem hands to the next at every seam. * - * Each known seam bug (TML-2461, TML-2486, TML-2487, TML-2314) is expressed - * as a `seamExpectation` whose `status` records whether the seam is still - * `'broken'` or already `'fixed'`. While a seam is `'broken'` the test - * passes precisely *because* the bug is still present (the - * `whenBroken` assertion holds); when the fix lands, the maintainer flips - * the status to `'fixed'` and the `whenFixed` assertion takes over. This - * keeps the test honest as a regression backstop without forcing the - * journey to be temporarily disabled around an in-flight fix. + * The seams that were once tracked as known bugs are all fixed; each step + * now asserts the working behavior directly. */ import { existsSync, readFileSync } from 'node:fs'; @@ -33,7 +27,6 @@ import { migrationPlan, runUserCode, type StepResult, - seamExpectation, selfEmitLatestMigration, } from './init-journey/harness'; @@ -112,18 +105,21 @@ describe.each(ALL_CELLS.map((cell) => ({ cell, label: cellLabel(cell) })))( ).toBe(true); }); - it('step 4b (migration emit): self-emits ops.json next to the draft migration.ts', () => { + it('step 4b (migration.ts self-emit): self-emits ops.json next to the draft migration.ts', () => { const result = ctx.migrationEmit; expect(result, 'migration self-emit was not run (precondition failure)').not.toBeNull(); if (result === null) return; - expect(result.exitCode, formatStepDiagnostic('migration emit', ctx.project, result)).toBe(0); + expect( + result.exitCode, + formatStepDiagnostic('migration.ts self-emit', ctx.project, result), + ).toBe(0); }); - it('step 4c (migration apply): applies the planned migration (TML-2486 seam)', () => { + it('step 4c (migrate): applies the planned migration', () => { const result = ctx.migrationApply; - expect(result, 'migration apply was not run (precondition failure)').not.toBeNull(); + expect(result, 'migrate was not run (precondition failure)').not.toBeNull(); if (result === null) return; - TML_2486_seam(cell, ctx.project, result); + expectMigrationApplied(ctx.project, result); }); it( @@ -140,7 +136,7 @@ describe.each(ALL_CELLS.map((cell) => ({ cell, label: cellLabel(cell) })))( '', ].join('\n'), ); - TML_2487_seam(run); + expectObjectIdImportWorks(run); }, timeouts.coldTransformImport, ); @@ -199,7 +195,7 @@ describe.each(ALL_CELLS.map((cell) => ({ cell, label: cellLabel(cell) })))( ' await control.connect();', ' const marker = await control.readMarker();', ' if (marker === null) {', - " console.error('control readMarker returned null after migration apply');", + " console.error('control readMarker returned null after migrate');", ' process.exit(3);', ' }', '} finally {', @@ -210,7 +206,7 @@ describe.each(ALL_CELLS.map((cell) => ({ cell, label: cellLabel(cell) })))( '', ].join('\n'), ); - TML_2314_seam(run); + expectPostgresUserCodeRoundTrip(run); }, timeouts.coldTransformImport, ); @@ -275,71 +271,24 @@ async function runFullJourney(cell: CellId): Promise { } } -// --- Seam expectations ----------------------------------------------------- -// -// One per known seam bug. Each is a `seamExpectation` with `status: -// 'broken'`. When the matching fix commit lands, the maintainer flips -// `'broken'` to `'fixed'` here and the assertion follows. +// --- Per-seam step assertions ------------------------------------------ -const TML_2486_seam = (cell: CellId, project: JourneyProject, result: StepResult): void => { - if (cell.target !== 'mongo') { - expect(result.exitCode, formatStepDiagnostic('migration apply', project, result)).toBe(0); - return; - } - seamExpectation({ - ticket: 'TML-2486', - description: - 'mongo migration apply successfully creates the contract collections (planner emits createCollection for plain collections; serializer accepts in-memory ops with undefined optionals)', - status: 'fixed', - whenBroken: (r) => { - expect( - r.exitCode, - 'TML-2486 still broken: mongo migration apply must currently fail', - ).not.toBe(0); - // Prisma-Next CLI journey tests treat stdout as the - // machine-readable channel — assert the diagnostic regex against - // stdout only so a regression that quietly moves the message to - // stderr would still flip the test red. - expect( - r.stdout, - 'TML-2486 still broken: mongo error must mention undefined fields or missing collections', - ).toMatch(/undefined|CLI.UNEXPECTED|createCollection|MIGRATION.RUNNER_FAILED|missing_table/); - }, - whenFixed: (r) => { - expect(r.exitCode, formatStepDiagnostic('migration apply', project, r)).toBe(0); - }, - })(result); +const expectMigrationApplied = (project: JourneyProject, result: StepResult): void => { + expect(result.exitCode, formatStepDiagnostic('migrate', project, result)).toBe(0); }; -const TML_2487_seam = seamExpectation({ - ticket: 'TML-2487', - description: '@prisma/orm-mongo/bson re-exports ObjectId', - status: 'fixed', - whenBroken: (r) => { - expect(r.exitCode, 'TML-2487 still broken: ObjectId import must currently fail').not.toBe(0); - }, - whenFixed: (r) => { - expect(r.exitCode, formatStepDiagnostic('ObjectId import', null, r)).toBe(0); - expect(r.stdout.trim(), 'ObjectId.toHexString() should yield 24 hex chars').toBe('24'); - }, -}); - -const TML_2314_seam = seamExpectation({ - ticket: 'TML-2314', - description: - 'user can write/read an entity via @internal/postgres/runtime and the /control facade composes a working stack', - status: 'fixed', - whenBroken: (r) => { - expect(r.exitCode, 'TML-2314 still broken: control import must currently fail').not.toBe(0); - }, - whenFixed: (r) => { - expect(r.exitCode, formatStepDiagnostic('postgres journey user-code', null, r)).toBe(0); - expect( - r.stdout.trim(), - 'postgres journey must complete a runtime CRUD round-trip and a control readMarker', - ).toBe('ok'); - }, -}); +const expectObjectIdImportWorks = (r: StepResult): void => { + expect(r.exitCode, formatStepDiagnostic('ObjectId import', null, r)).toBe(0); + expect(r.stdout.trim(), 'ObjectId.toHexString() should yield 24 hex chars').toBe('24'); +}; + +const expectPostgresUserCodeRoundTrip = (r: StepResult): void => { + expect(r.exitCode, formatStepDiagnostic('postgres journey user-code', null, r)).toBe(0); + expect( + r.stdout.trim(), + 'postgres journey must complete a runtime CRUD round-trip and a control readMarker', + ).toBe('ok'); +}; function expectScaffoldedFiles(project: JourneyProject): void { const required = [ diff --git a/test/integration/test/cli-journeys/init-journey/harness.ts b/test/integration/test/cli-journeys/init-journey/harness.ts index 2f1453746e01..88ca8f5f9898 100644 --- a/test/integration/test/cli-journeys/init-journey/harness.ts +++ b/test/integration/test/cli-journeys/init-journey/harness.ts @@ -695,28 +695,3 @@ async function runStep(project: JourneyProject, args: readonly string[]): Promis const result = await runExec(bin, rest, project.dir); return { ...result, command: args.join(' ') }; } - -/** - * Helper that flips one assertion based on whether a bug is currently - * `'broken'` or `'fixed'`. The journey test encodes one of these per known - * seam bug (TML-2486, TML-2487, TML-2314, TML-2461); flipping the status - * is how individual bug-fix commits land in the same PR without rewriting - * the journey test itself. - */ -export interface SeamExpectation { - readonly ticket: string; - readonly description: string; - readonly status: 'broken' | 'fixed'; - readonly whenBroken: (result: T) => void; - readonly whenFixed: (result: T) => void; -} - -export function seamExpectation(spec: SeamExpectation): (result: T) => void { - return (result) => { - if (spec.status === 'broken') { - spec.whenBroken(result); - } else { - spec.whenFixed(result); - } - }; -} diff --git a/test/integration/test/cli-journeys/interleaved-db-update.e2e.test.ts b/test/integration/test/cli-journeys/interleaved-db-update.e2e.test.ts index d130654e4eb0..b3c0f3017df9 100644 --- a/test/integration/test/cli-journeys/interleaved-db-update.e2e.test.ts +++ b/test/integration/test/cli-journeys/interleaved-db-update.e2e.test.ts @@ -16,14 +16,15 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planMigrationAndSelfEmit, runContractEmit, runDbUpdate, runDbVerify, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -45,7 +46,7 @@ withTempDir(({ createTempDir }) => { // 1. Establish migration workflow: emit C1 → plan init → apply const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, '1: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, '1: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; const apply0 = await runMigrate(ctx, ['--json']); @@ -58,7 +59,13 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, '2: emit C2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-phone', '--json']); + const plan1 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-phone', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(plan1.exitCode, '2: plan C1→C2').toBe(0); const c2Hash = parseJsonOutput<{ to: string }>(plan1).to; const apply1 = await runMigrate(ctx, ['--json']); @@ -84,7 +91,7 @@ withTempDir(({ createTempDir }) => { // 4. Retroactive migration plan: user realizes they should have used migrations. // `migration plan` plans from graph leaf (C2) to current contract (C3). // This is the same edge that db update already applied to the DB. - const plan2 = await runMigrationPlanAndEmit(ctx, [ + const plan2 = await planMigrationAndSelfEmit(ctx, [ '--from', c2Hash, '--name', @@ -111,7 +118,13 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-all'); const emit3 = await runContractEmit(ctx); expect(emit3.exitCode, '6: emit C4').toBe(0); - const plan3 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-avatar', '--json']); + const plan3 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-avatar', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(plan3.exitCode, '6: plan C3→C4').toBe(0); const plan3Result = parseJsonOutput<{ from: string; to: string }>(plan3); expect(plan3Result.from, '6: from is C3 (new graph leaf)').toBe(c3Hash); diff --git a/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts b/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts index cff7199406ca..cf63c6c49155 100644 --- a/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts +++ b/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts @@ -3,7 +3,7 @@ * * The happy path: a dataTransform declares an `invariantId`, the resulting * `migration.json` carries `providedInvariants`, a ref declares the same - * id, and `migration apply --ref` routes through the data-bearing path. + * id, and `migrate --to` routes through the data-bearing path. * The marker write unions the applied id, so re-applying the same ref * subtracts already-covered invariants from the required set and the * second apply is a no-op. @@ -25,16 +25,17 @@ import { engineError, injectMigrationSqlDbSetup, type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planMigrationAndSelfEmit, runContractEmit, runMigrate, - runMigrationEmit, runMigrationNew, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationStatus, + selfEmitMigration, setupJourney, sql, swapContract, @@ -116,7 +117,7 @@ withTempDir(({ createTempDir }) => { // O.01: emit base contract (C1) → plan + apply init (creates user table) expect((await runContractEmit(ctx)).exitCode, 'O.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'O.01: plan init').toBe(0); expect((await runMigrate(ctx)).exitCode, 'O.01: apply init').toBe(0); @@ -130,7 +131,12 @@ withTempDir(({ createTempDir }) => { // the planner emits a placeholder dataTransform. swapContract(ctx, 'contract-additive-required-name'); expect((await runContractEmit(ctx)).exitCode, 'O.03: emit C2').toBe(0); - const planResult = await runMigrationPlan(ctx, ['--name', 'add-required-name']); + const planResult = await runMigrationPlan(ctx, [ + '--name', + 'add-required-name', + '--from', + latestMigrationDirName(ctx), + ]); expect(planResult.exitCode, 'O.03: plan add-required-name').toBe(0); const migrationsDir = join(ctx.testDir, 'migrations', 'app'); @@ -146,7 +152,7 @@ withTempDir(({ createTempDir }) => { // O.05: re-emit and confirm the manifest carries `providedInvariants`. expect( - (await runMigrationEmit(ctx, ['--dir', migrationDir])).exitCode, + (await selfEmitMigration(ctx, ['--dir', migrationDir])).exitCode, 'O.05: re-emit', ).toBe(0); const manifestAfter = JSON.parse( @@ -161,10 +167,10 @@ withTempDir(({ createTempDir }) => { // O.06: declare a ref `prod` that points at C2 and requires the invariant. writeRefFile(ctx, 'prod', c2Hash, [INVARIANT_ID]); - // O.07: apply --ref prod — routes through the invariant-bearing path, + // apply --to prod — routes through the invariant-bearing path, // backfills the data, advances the marker. const applyRef = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect(applyRef.exitCode, 'O.07: apply --ref prod').toBe(0); + expect(applyRef.exitCode, 'apply --to prod').toBe(0); const applyResult = parseJsonOutput<{ ok: boolean; markerHash: string; @@ -198,10 +204,10 @@ withTempDir(({ createTempDir }) => { { id: 2, email: 'bob@test.org', name: BACKFILLED_NAME }, ]); - // O.09: status --ref prod surfaces the three invariant sets and the per-edge + // status --to prod surfaces the three invariant sets and the per-edge // invariants on the selected path. const statusRef = await runMigrationStatus(ctx, ['--to', 'prod', '--json']); - expect(statusRef.exitCode, 'O.09: status --ref prod').toBe(0); + expect(statusRef.exitCode, 'status --to prod').toBe(0); const statusResult = parseMigrationStatusJson(statusRef); expect( statusResult.diagnostics?.some((d) => d.code === 'MIGRATION.MISSING_INVARIANTS'), @@ -213,12 +219,12 @@ withTempDir(({ createTempDir }) => { 'O.09: path migrations applied', ).toBe(true); - // O.10: re-apply --ref prod is a no-op. The marker subtraction in + // re-apply --to prod is a no-op. The marker subtraction in // the apply command (`effectiveRequired = ref.invariants − marker.invariants`) // empties the required set, so routing falls through to the trivial // marker===target case (no path selected). const reapply = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect(reapply.exitCode, 'O.10: re-apply --ref prod').toBe(0); + expect(reapply.exitCode, 're-apply --to prod').toBe(0); const reapplyResult = parseJsonOutput<{ ok: boolean; markerHash: string; @@ -245,7 +251,7 @@ withTempDir(({ createTempDir }) => { // P.01: emit base + plan + apply a single migration that declares a real invariant. expect((await runContractEmit(ctx)).exitCode, 'P.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'P.01: plan init').toBe(0); expect((await runMigrate(ctx)).exitCode, 'P.01: apply init').toBe(0); @@ -257,7 +263,14 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive-required-name'); expect((await runContractEmit(ctx)).exitCode, 'P.02: emit C2').toBe(0); expect( - (await runMigrationPlan(ctx, ['--name', 'add-required-name'])).exitCode, + ( + await runMigrationPlan(ctx, [ + '--name', + 'add-required-name', + '--from', + latestMigrationDirName(ctx), + ]) + ).exitCode, 'P.02: plan', ).toBe(0); @@ -271,7 +284,7 @@ withTempDir(({ createTempDir }) => { ); patchBackfillMigrationTs(migrationDir, { addInvariantId: true }); expect( - (await runMigrationEmit(ctx, ['--dir', migrationDir])).exitCode, + (await selfEmitMigration(ctx, ['--dir', migrationDir])).exitCode, 'P.02: re-emit', ).toBe(0); @@ -281,7 +294,7 @@ withTempDir(({ createTempDir }) => { // P.03: declare a ref requiring an id no migration provides. writeRefFile(ctx, 'prod', c2Hash, ['typo-no-migration-declares-this']); - // P.04: apply --ref prod fails fast with UNKNOWN_INVARIANT, marker untouched. + // apply --to prod fails fast with UNKNOWN_INVARIANT, marker untouched. const applyFail = await runMigrate(ctx, ['--to', 'prod', '--json']); expect(applyFail.exitCode, 'P.04: apply exits 2').toBe(2); const applyEnvelope = parseJsonOutput<{ @@ -297,14 +310,14 @@ withTempDir(({ createTempDir }) => { ]); // P.05: marker still at C1 — UNKNOWN_INVARIANT fired before any DB write. - // Querying via the CLI status path (without --ref so the pre-check doesn't + // Querying via the CLI status path (without --to so the pre-check doesn't // fire) is the cleanest cross-DB-family way to read the marker. const statusOffline = await runMigrationStatus(ctx, ['--json']); expect(statusOffline.exitCode, 'P.05: status exit').toBe(0); const offlineState = migrationStatusAppSpace(parseMigrationStatusJson(statusOffline)); expect(offlineState.currentContract, 'P.05: marker did not advance to C2').not.toBe(c2Hash); - // P.06: status --ref prod is fatal too (parity with apply). + // status --to prod is fatal too (parity with apply). const statusFail = await runMigrationStatus(ctx, ['--to', 'prod', '--json']); expect(statusFail.exitCode, 'P.06: status exits 2').toBe(2); expect(engineError(statusFail)?.code, 'P.06: status error code').toBe( @@ -328,7 +341,7 @@ withTempDir(({ createTempDir }) => { // Q.01: emit base (C1), plan + apply init (no invariants on this edge). expect((await runContractEmit(ctx)).exitCode, 'Q.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'Q.01: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; expect((await runMigrate(ctx)).exitCode, 'Q.01: apply init').toBe(0); @@ -342,7 +355,12 @@ withTempDir(({ createTempDir }) => { // This edge declares invariantId=INVARIANT_ID and goes C1 → CA. swapContract(ctx, 'contract-additive-required-name'); expect((await runContractEmit(ctx)).exitCode, 'Q.02: emit CA').toBe(0); - const planA = await runMigrationPlan(ctx, ['--name', 'branch-a-with-invariant']); + const planA = await runMigrationPlan(ctx, [ + '--name', + 'branch-a-with-invariant', + '--from', + latestMigrationDirName(ctx), + ]); expect(planA.exitCode, 'Q.02: plan branch A').toBe(0); const migrationsDir = join(ctx.testDir, 'migrations', 'app'); const branchADir = join( @@ -354,7 +372,7 @@ withTempDir(({ createTempDir }) => { ); patchBackfillMigrationTs(branchADir, { addInvariantId: true }); expect( - (await runMigrationEmit(ctx, ['--dir', branchADir])).exitCode, + (await selfEmitMigration(ctx, ['--dir', branchADir])).exitCode, 'Q.02: re-emit branch A', ).toBe(0); @@ -362,7 +380,7 @@ withTempDir(({ createTempDir }) => { // plan with --from C1 to create a divergent edge C1 → CB. No invariants. swapContract(ctx, 'contract-phone'); expect((await runContractEmit(ctx)).exitCode, 'Q.03: emit CB').toBe(0); - const planB = await runMigrationPlanAndEmit(ctx, [ + const planB = await planMigrationAndSelfEmit(ctx, [ '--name', 'branch-b-no-invariant', '--from', @@ -377,7 +395,7 @@ withTempDir(({ createTempDir }) => { // The structural path C1 → CB exists; it just doesn't cover the required id. writeRefFile(ctx, 'prod', cbHash, [INVARIANT_ID]); - // Q.05: apply --ref prod fails with NO_INVARIANT_PATH (not UNKNOWN_INVARIANT, + // apply --to prod fails with NO_INVARIANT_PATH (not UNKNOWN_INVARIANT, // because the id IS declared somewhere in the graph). The structural path // points at the CB-branch edge that doesn't cover it. const applyFail = await runMigrate(ctx, ['--to', 'prod', '--json']); @@ -412,7 +430,7 @@ withTempDir(({ createTempDir }) => { // The pinned behavior: `marker.invariants` is set-semantic. Once an // invariant id has been written by a successful apply, it stays in the // set forever — no rollback path removes it. A second forward apply via - // `--ref` after an out-of-band marker reset routes through the same + // `--to` after an out-of-band marker reset routes through the same // edge, the data transform is re-evaluated, and the set is unchanged // (already-present id is a no-op union). // @@ -424,7 +442,7 @@ withTempDir(({ createTempDir }) => { // honest outcome — the test does not pretend the data transform's // body re-fires when it doesn't. it( - 'rollback marker.storageHash to A → re-apply via --ref selects M1 → marker advances back to B with invariants unchanged', + 'rollback marker.storageHash to A → re-apply via --to selects M1 → marker advances back to B with invariants unchanged', async () => { const ctx: JourneyContext = setupJourney({ connectionString: db.connectionString, @@ -432,7 +450,7 @@ withTempDir(({ createTempDir }) => { }); expect((await runContractEmit(ctx)).exitCode, 'R.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'R.01: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; expect((await runMigrate(ctx)).exitCode, 'R.01: apply init').toBe(0); @@ -445,7 +463,14 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive-required-name'); expect((await runContractEmit(ctx)).exitCode, 'R.02: emit C2').toBe(0); expect( - (await runMigrationPlan(ctx, ['--name', 'add-required-name'])).exitCode, + ( + await runMigrationPlan(ctx, [ + '--name', + 'add-required-name', + '--from', + latestMigrationDirName(ctx), + ]) + ).exitCode, 'R.02: plan', ).toBe(0); @@ -458,9 +483,10 @@ withTempDir(({ createTempDir }) => { .at(-1)!, ); patchBackfillMigrationTs(migrationDir, { addInvariantId: true }); - expect((await runMigrationEmit(ctx, ['--dir', migrationDir])).exitCode, 'R.02: emit').toBe( - 0, - ); + expect( + (await selfEmitMigration(ctx, ['--dir', migrationDir])).exitCode, + 're-emit with invariant', + ).toBe(0); const manifest = JSON.parse(readFileSync(join(migrationDir, 'migration.json'), 'utf-8')); const c2Hash = manifest.to as string; @@ -468,7 +494,7 @@ withTempDir(({ createTempDir }) => { writeRefFile(ctx, 'prod', c2Hash, [INVARIANT_ID]); const apply1 = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect(apply1.exitCode, 'R.02: apply --ref prod').toBe(0); + expect(apply1.exitCode, 'apply --to prod after rollback').toBe(0); expect( parseJsonOutput<{ markerHash: string }>(apply1).markerHash, 'R.02: marker at C2', @@ -496,7 +522,7 @@ withTempDir(({ createTempDir }) => { ).toEqual([INVARIANT_ID]); const apply2 = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect(apply2.exitCode, 'R.04: re-apply --ref prod').toBe(0); + expect(apply2.exitCode, 're-apply --to prod after rollback').toBe(0); const apply2Result = parseJsonOutput<{ markerHash: string; pathDecision?: { @@ -535,7 +561,7 @@ withTempDir(({ createTempDir }) => { // Self-edges (from === to) carry only data ops. The pathfinder treats // them as routing-visible when they declare an invariantId, and the - // runner runs them on `migration apply --ref` even though the marker's + // runner runs them on `migrate --to` even though the marker's // storage hash never changes. it( 'migration new --from scaffolds self-edge → fill in dataTransform → apply normalizes data and accumulates marker.invariants', @@ -546,7 +572,7 @@ withTempDir(({ createTempDir }) => { }); expect((await runContractEmit(ctx)).exitCode, 'S.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'S.01: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; expect((await runMigrate(ctx)).exitCode, 'S.01: apply init').toBe(0); @@ -616,7 +642,7 @@ export default class M extends Migration { MigrationCLI.run(import.meta.url, M); `; writeFileSync(join(migrationDir, 'migration.ts'), handAuthored); - const emitResult = await runMigrationEmit(ctx, ['--dir', migrationDir]); + const emitResult = await selfEmitMigration(ctx, ['--dir', migrationDir]); expect(emitResult.exitCode, `S.04: emit: ${emitResult.stdout}\n${emitResult.stderr}`).toBe( 0, ); @@ -634,7 +660,7 @@ MigrationCLI.run(import.meta.url, M); const applyRef = await runMigrate(ctx, ['--to', 'prod', '--json']); expect( applyRef.exitCode, - `S.05: apply --ref prod: ${applyRef.stdout}\n${applyRef.stderr}`, + `apply --to prod (subset ref): ${applyRef.stdout}\n${applyRef.stderr}`, ).toBe(0); const applyResult = parseJsonOutput<{ markerHash: string; @@ -682,7 +708,7 @@ MigrationCLI.run(import.meta.url, M); ); }); - describe('Journey T: status --ref reports INVARIANTS_PENDING when marker is at target hash but missing required invariants', () => { + describe('Journey T: status --to reports INVARIANTS_PENDING when marker is at target hash but missing required invariants', () => { const db = useDevDatabase(); const NORMALIZED_EMAIL = 'normalized@example.com'; @@ -692,12 +718,12 @@ MigrationCLI.run(import.meta.url, M); // (`pendingCount === 0`) but is missing required invariants the active // ref declares, status must NOT say "up to date". A self-edge migration // exists in the graph that provides the invariant, so the routing path - // is satisfiable — but `apply --ref` hasn't been run since marker.invariants + // is satisfiable — but `apply --to` hasn't been run since marker.invariants // got out of sync (modeled here by an out-of-band UPDATE). // // Mirrors Journey R's manual-marker-UPDATE pattern. it( - 'manually clear marker.invariants → status --ref surfaces MIGRATION.INVARIANTS_PENDING and summary names the missing id', + 'manually clear marker.invariants → status --to surfaces MIGRATION.INVARIANTS_PENDING and summary names the missing id', async () => { const ctx: JourneyContext = setupJourney({ connectionString: db.connectionString, @@ -705,7 +731,7 @@ MigrationCLI.run(import.meta.url, M); }); expect((await runContractEmit(ctx)).exitCode, 'T.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'T.01: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; expect((await runMigrate(ctx)).exitCode, 'T.01: apply init').toBe(0); @@ -770,7 +796,7 @@ MigrationCLI.run(import.meta.url, M); `; writeFileSync(join(migrationDir, 'migration.ts'), handAuthored); expect( - (await runMigrationEmit(ctx, ['--dir', migrationDir])).exitCode, + (await selfEmitMigration(ctx, ['--dir', migrationDir])).exitCode, 'T.02: emit self-edge', ).toBe(0); @@ -805,7 +831,7 @@ MigrationCLI.run(import.meta.url, M); [], ); - // T.05: status --ref must report INVARIANTS_PENDING, NOT UP_TO_DATE. + // status --to must report INVARIANTS_PENDING, NOT UP_TO_DATE. const statusResult = await runMigrationStatus(ctx, ['--to', 'prod', '--json']); expect(statusResult.exitCode, 'T.05: status exits 0').toBe(0); const envelope = parseMigrationStatusJson(statusResult); diff --git a/test/integration/test/cli-journeys/invariant-routing.mongo.e2e.test.ts b/test/integration/test/cli-journeys/invariant-routing.mongo.e2e.test.ts deleted file mode 100644 index d12c9de05e8b..000000000000 --- a/test/integration/test/cli-journeys/invariant-routing.mongo.e2e.test.ts +++ /dev/null @@ -1,573 +0,0 @@ -/** - * Invariant-aware ref routing — end-to-end against MongoDB. - * - * Mirrors the Postgres-backed `invariant-routing.e2e.test.ts` to confirm - * the routing surface is family-neutral. The CLI commands and the - * migration-tools pathfinder are target-agnostic; this file is a smoke - * test that the full apply / status flow works against a live Mongo - * runner with marker.invariants accumulating server-side via the - * aggregation-pipeline merge. - * - * Three journeys: happy path with marker accumulation, UNKNOWN_INVARIANT - * pre-check, and NO_INVARIANT_PATH on a divergent graph. - */ - -import { - copyFileSync, - mkdirSync, - readdirSync, - readFileSync, - statSync, - writeFileSync, -} from 'node:fs'; -import { rm } from 'node:fs/promises'; -import { basename, join } from 'node:path'; -import { timeouts } from '@repo/test-utils'; -import { MongoClient } from 'mongodb'; -import { MongoMemoryReplSet } from 'mongodb-memory-server'; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { fixtureAppDir } from '../utils/cli-test-helpers'; -import { - engineError, - type JourneyContext, - migrationStatusAppSpace, - parseJsonOutput, - parseMigrationStatusJson, - runContractEmit, - runMigrate, - runMigrationEmit, - runMigrationNew, - runMigrationPlan, - runMigrationStatus, -} from '../utils/journey-test-helpers'; - -const FIXTURES_DIR = join(fixtureAppDir, 'fixtures/mongo-cli-journeys'); -const INVARIANT_ID = 'lowercase-user-name'; - -function setupMongoJourney(connectionString: string): JourneyContext { - const testDir = join( - fixtureAppDir, - `test-mongo-invariants-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - mkdirSync(testDir, { recursive: true }); - const outputDir = join(testDir, 'output'); - mkdirSync(outputDir, { recursive: true }); - mkdirSync(join(testDir, 'migrations'), { recursive: true }); - // Says which database this project is for. Without it the project inherits - // the fixture app's manifest, which carries every database these suites - // exercise and so answers no single import root. - writeFileSync( - join(testDir, 'package.json'), - `${JSON.stringify( - { - name: 'mongo-invariants-app', - private: true, - type: 'module', - dependencies: { '@prisma/orm-mongo': 'workspace:0.16.0' }, - }, - null, - 2, - )}\n`, - 'utf-8', - ); - - copyFileSync(join(FIXTURES_DIR, 'contract-base.ts'), join(testDir, 'contract.ts')); - - let configContent = readFileSync(join(FIXTURES_DIR, 'prisma.config.with-db.ts'), 'utf-8'); - configContent = configContent.replace(/\{\{DB_URL\}\}/g, () => connectionString); - const configPath = join(testDir, 'prisma.config.ts'); - writeFileSync(configPath, configContent, 'utf-8'); - - return { testDir, configPath, outputDir }; -} - -function swapToAdditive(ctx: JourneyContext): void { - copyFileSync(join(FIXTURES_DIR, 'contract-additive.ts'), join(ctx.testDir, 'contract.ts')); -} - -function swapToBranchB(ctx: JourneyContext): void { - copyFileSync(join(FIXTURES_DIR, 'contract-branch-b.ts'), join(ctx.testDir, 'contract.ts')); -} - -function getLatestMigrationDir(ctx: JourneyContext): string { - const migrationsDir = join(ctx.testDir, 'migrations', 'app'); - const dirs = readdirSync(migrationsDir).filter((d) => { - if (d.startsWith('.')) return false; - if (d === 'refs') return false; - return statSync(join(migrationsDir, d)).isDirectory(); - }); - if (dirs.length === 0) throw new Error('No migration directory found'); - let newest = dirs[0]!; - let newestMtime = statSync(join(migrationsDir, newest)).mtimeMs; - for (let i = 1; i < dirs.length; i++) { - const dir = dirs[i]!; - const mtime = statSync(join(migrationsDir, dir)).mtimeMs; - if (mtime > newestMtime) { - newestMtime = mtime; - newest = dir; - } - } - return join(migrationsDir, newest); -} - -function findMigrationDirBySlug(ctx: JourneyContext, slugFragment: string): string { - const migrationsDir = join(ctx.testDir, 'migrations', 'app'); - const dirs = readdirSync(migrationsDir) - .filter((d) => !d.startsWith('.') && d.includes(slugFragment)) - .sort(); - const match = dirs[dirs.length - 1]; - if (!match) { - throw new Error(`No migration directory found containing '${slugFragment}'`); - } - return join(migrationsDir, match); -} - -function buildMongoUri(baseUri: string, dbName: string): string { - const [hostPart, query] = baseUri.split('?'); - const trimmedHost = (hostPart ?? '').replace(/\/?$/, '/'); - return query ? `${trimmedHost}${dbName}?${query}` : `${trimmedHost}${dbName}`; -} - -function writeRefFile( - ctx: JourneyContext, - name: string, - hash: string, - invariants: readonly string[], -): void { - const refsDir = join(ctx.testDir, 'migrations', 'app', 'refs'); - mkdirSync(refsDir, { recursive: true }); - const file = join(refsDir, `${name}.json`); - writeFileSync(file, `${JSON.stringify({ hash, invariants }, null, 2)}\n`, 'utf-8'); -} - -/** - * Renders a hand-authored Mongo migration.ts that adds a `name` index and - * runs a `dataTransform` lowercasing user names. The transform optionally - * declares an `invariantId` so refs can route on it. - */ -function renderInvariantMigrationTs( - draftFrom: string, - draftTo: string, - opts: { invariantId?: string }, -): string { - const invariantField = opts.invariantId - ? ` invariantId: ${JSON.stringify(opts.invariantId)},\n` - : ''; - return `import { createIndex, dataTransform, Migration, MigrationCLI } from '@prisma/orm-mongo/target/migration'; -import { RawUpdateManyCommand, RawAggregateCommand } from '@prisma/orm-mongo/query-ast/execution'; - -const planMeta = { - target: 'mongo', - storageHash: 'hand-authored', - lane: 'mongo-raw', - paramDescriptors: [], -}; - -class M extends Migration { - override describe() { - return { - from: ${JSON.stringify(draftFrom)}, - to: ${JSON.stringify(draftTo)}, - }; - } - - override get operations() { - return [ - createIndex('users', [{ field: 'name', direction: 1 }]), - dataTransform('lowercase-user-name', { -${invariantField} check: { - source: () => ({ - collection: 'users', - command: new RawAggregateCommand( - 'users', - [{ $match: { name: { $regex: '[A-Z]' } } }, { $limit: 1 }], - ), - meta: { ...planMeta, lane: 'mongo-pipeline' }, - }), - }, - run: () => ({ - collection: 'users', - command: new RawUpdateManyCommand( - 'users', - { name: { $exists: true } }, - [{ $set: { name: { $toLower: '$name' } } }], - ), - meta: planMeta, - }), - }), - ]; - } -} - -export default M; -MigrationCLI.run(import.meta.url, M); -`; -} - -/** - * Renders a hand-authored Mongo migration.ts that only adds an index — no - * dataTransform, so the migration declares no invariants. Used by the - * NO_INVARIANT_PATH journey to build a divergent edge that doesn't cover - * the ref-required invariant. - */ -function renderIndexOnlyMigrationTs(draftFrom: string, draftTo: string): string { - return `import { createIndex, Migration, MigrationCLI } from '@prisma/orm-mongo/target/migration'; - -class M extends Migration { - override describe() { - return { - from: ${JSON.stringify(draftFrom)}, - to: ${JSON.stringify(draftTo)}, - }; - } - - override get operations() { - return [ - createIndex('users', [{ field: 'email', direction: -1 }]), - ]; - } -} - -export default M; -MigrationCLI.run(import.meta.url, M); -`; -} - -describe('Journey: Mongo invariant-aware ref routing (live database)', { - timeout: timeouts.spinUpMongoMemoryServer, -}, () => { - let replSet: MongoMemoryReplSet; - let client: MongoClient; - const created = new Set(); - - beforeAll(async () => { - replSet = await MongoMemoryReplSet.create({ - instanceOpts: [ - { launchTimeout: timeouts.spinUpMongoMemoryServer, storageEngine: 'wiredTiger' }, - ], - replSet: { count: 1, storageEngine: 'wiredTiger' }, - }); - client = new MongoClient(replSet.getUri()); - await client.connect(); - }, timeouts.spinUpMongoMemoryServer); - - let dbName: string; - beforeEach(async () => { - dbName = `mongo_inv_${Date.now()}_${Math.random().toString(36).slice(2)}`; - }); - - afterEach(async () => { - await client - ?.db(dbName) - .dropDatabase() - .catch(() => {}); - for (const dir of created) { - await rm(dir, { recursive: true, force: true }).catch(() => {}); - } - created.clear(); - }); - - afterAll(async () => { - await client?.close().catch(() => {}); - await replSet?.stop().catch(() => {}); - }, timeouts.spinUpMongoMemoryServer); - - it('Mongo O: invariantId on dataTransform → ref requires it → apply lowercases names + accumulates marker → re-apply is noop', async () => { - const ctx = setupMongoJourney(buildMongoUri(replSet.getUri(), dbName)); - created.add(ctx.testDir); - - // Mongo-O.01: emit base + plan + apply init (creates `users` collection + email index). - expect((await runContractEmit(ctx)).exitCode, 'Mongo-O.01: emit base').toBe(0); - expect((await runMigrationPlan(ctx, ['--name', 'initial'])).exitCode, 'Mongo-O.01: plan').toBe( - 0, - ); - expect( - ( - await runMigrationEmit(ctx, [ - '--dir', - `migrations/app/${basename(getLatestMigrationDir(ctx))}`, - ]) - ).exitCode, - 'Mongo-O.01: emit init', - ).toBe(0); - expect((await runMigrate(ctx)).exitCode, 'Mongo-O.01: apply init').toBe(0); - - // Mongo-O.02: seed a row whose `name` needs lower-casing. - await client - .db(dbName) - .collection('users') - .insertMany([ - { email: 'alice@example.com', name: 'Alice' }, - { email: 'bob@example.com', name: 'BOB' }, - ]); - - // Mongo-O.03: swap to additive (adds `name` index), emit, scaffold a hand-authored migration. - swapToAdditive(ctx); - expect((await runContractEmit(ctx)).exitCode, 'Mongo-O.03: emit additive').toBe(0); - expect( - (await runMigrationNew(ctx, ['--name', 'normalize-names'])).exitCode, - 'Mongo-O.03: migration new', - ).toBe(0); - - const migrationDir = findMigrationDirBySlug(ctx, 'normalize_names'); - const migrationTsPath = join(migrationDir, 'migration.ts'); - const draftManifest = JSON.parse( - readFileSync(join(migrationDir, 'migration.json'), 'utf-8'), - ) as { from: string; to: string }; - - // Mongo-O.04: write the migration with invariantId baked in. - writeFileSync( - migrationTsPath, - renderInvariantMigrationTs(draftManifest.from, draftManifest.to, { - invariantId: INVARIANT_ID, - }), - ); - expect( - (await runMigrationEmit(ctx, ['--dir', migrationDir])).exitCode, - 'Mongo-O.04: emit', - ).toBe(0); - - // Mongo-O.05: confirm migration.json carries providedInvariants. - const manifestAfter = JSON.parse(readFileSync(join(migrationDir, 'migration.json'), 'utf-8')); - expect( - manifestAfter.providedInvariants, - 'Mongo-O.05: manifest carries providedInvariants', - ).toEqual([INVARIANT_ID]); - const c2Hash = manifestAfter.to as string; - - // Mongo-O.06: declare a ref that requires the invariant. - writeRefFile(ctx, 'prod', c2Hash, [INVARIANT_ID]); - - // Mongo-O.07: apply --ref prod — routes through the invariant edge. - const applyRef = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect( - applyRef.exitCode, - `Mongo-O.07: apply --ref prod: ${applyRef.stdout}\n${applyRef.stderr}`, - ).toBe(0); - const applyResult = parseJsonOutput<{ - ok: boolean; - markerHash: string; - pathDecision?: { - requiredInvariants: readonly string[]; - satisfiedInvariants: readonly string[]; - selectedPath: readonly { dirName: string; invariants: readonly string[] }[]; - }; - }>(applyRef); - expect(applyResult.ok, 'Mongo-O.07: ok').toBe(true); - expect(applyResult.markerHash, 'Mongo-O.07: marker advanced').toBe(c2Hash); - expect( - applyResult.pathDecision?.requiredInvariants, - 'Mongo-O.07: required reflects ref', - ).toEqual([INVARIANT_ID]); - expect( - applyResult.pathDecision?.satisfiedInvariants, - 'Mongo-O.07: satisfied = required', - ).toEqual([INVARIANT_ID]); - expect( - applyResult.pathDecision?.selectedPath.at(-1)?.invariants, - 'Mongo-O.07: selectedPath edge carries the invariant', - ).toEqual([INVARIANT_ID]); - - // Mongo-O.08: data was actually lowercased. - const users = await client - .db(dbName) - .collection('users') - .aggregate([{ $project: { _id: 0, email: 1, name: 1 } }, { $sort: { email: 1 } }]) - .toArray(); - expect(users, 'Mongo-O.08: names lowercased').toEqual([ - { email: 'alice@example.com', name: 'alice' }, - { email: 'bob@example.com', name: 'bob' }, - ]); - - // Mongo-O.09: status --ref prod surfaces the three invariant sets and - // proves the marker doc accumulated the invariant via $setUnion. - const statusRef = await runMigrationStatus(ctx, ['--to', 'prod', '--json']); - expect(statusRef.exitCode, 'Mongo-O.09: status --ref prod').toBe(0); - const statusResult = parseMigrationStatusJson(statusRef); - expect( - statusResult.diagnostics?.some((d) => d.code === 'MIGRATION.MISSING_INVARIANTS'), - 'Mongo-O.09: missing empty', - ).toBeFalsy(); - expect(statusResult.summary, 'Mongo-O.09: up to date').toMatch(/up to date/i); - expect( - migrationStatusAppSpace(statusResult).migrations.every((m) => m.status === 'applied'), - 'Mongo-O.09: path migrations applied', - ).toBe(true); - - // Mongo-O.10: re-apply is a noop. The CLI's marker subtraction empties - // the required set; the Mongo runner additionally short-circuits via - // its own `incomingIsSubsetOfExisting` guard. - const reapply = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect(reapply.exitCode, 'Mongo-O.10: re-apply').toBe(0); - const reapplyResult = parseJsonOutput<{ - ok: boolean; - markerHash: string; - summary: string; - }>(reapply); - expect(reapplyResult.ok, 'Mongo-O.10: ok').toBe(true); - expect(reapplyResult.markerHash, 'Mongo-O.10: marker unchanged').toBe(c2Hash); - expect(reapplyResult.summary, 'Mongo-O.10: noop summary').toMatch(/up to date/i); - }); - - it('Mongo P: apply and status both exit with MIGRATION.UNKNOWN_INVARIANT before any DB activity', async () => { - const ctx = setupMongoJourney(buildMongoUri(replSet.getUri(), dbName)); - created.add(ctx.testDir); - - // Mongo-P.01: stand up an init migration on disk; no invariant declared. - expect((await runContractEmit(ctx)).exitCode, 'Mongo-P.01: emit base').toBe(0); - expect((await runMigrationPlan(ctx, ['--name', 'initial'])).exitCode, 'Mongo-P.01: plan').toBe( - 0, - ); - const initDir = getLatestMigrationDir(ctx); - expect( - (await runMigrationEmit(ctx, ['--dir', `migrations/app/${basename(initDir)}`])).exitCode, - 'Mongo-P.01: emit init', - ).toBe(0); - expect((await runMigrate(ctx)).exitCode, 'Mongo-P.01: apply init').toBe(0); - - // Mongo-P.02: hand-author an additive migration with INVARIANT_ID. - swapToAdditive(ctx); - expect((await runContractEmit(ctx)).exitCode, 'Mongo-P.02: emit additive').toBe(0); - expect( - (await runMigrationNew(ctx, ['--name', 'normalize-names'])).exitCode, - 'Mongo-P.02: new', - ).toBe(0); - const dir2 = findMigrationDirBySlug(ctx, 'normalize_names'); - const draft = JSON.parse(readFileSync(join(dir2, 'migration.json'), 'utf-8')) as { - from: string; - to: string; - }; - writeFileSync( - join(dir2, 'migration.ts'), - renderInvariantMigrationTs(draft.from, draft.to, { invariantId: INVARIANT_ID }), - ); - expect((await runMigrationEmit(ctx, ['--dir', dir2])).exitCode, 'Mongo-P.02: emit').toBe(0); - - const manifest = JSON.parse(readFileSync(join(dir2, 'migration.json'), 'utf-8')); - const c2Hash = manifest.to as string; - - // Mongo-P.03: ref names an id no migration declares. - writeRefFile(ctx, 'prod', c2Hash, ['typo-no-migration-declares-this']); - - // Mongo-P.04: apply fails with UNKNOWN_INVARIANT. - const applyFail = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect(applyFail.exitCode, 'Mongo-P.04: apply exits 2').toBe(2); - const applyEnvelope = parseJsonOutput<{ - code?: string; - meta?: { unknown?: readonly string[]; declared?: readonly string[] }; - }>(applyFail); - expect(applyEnvelope.code, 'Mongo-P.04: error code').toBe('MIGRATION.UNKNOWN_INVARIANT'); - expect(applyEnvelope.meta?.unknown, 'Mongo-P.04: unknown listed').toEqual([ - 'typo-no-migration-declares-this', - ]); - expect(applyEnvelope.meta?.declared, 'Mongo-P.04: declared listed').toEqual([INVARIANT_ID]); - - // Mongo-P.05: marker untouched (still at C1, not C2). Read via status - // without --ref so the pre-check doesn't fire. - const statusOffline = await runMigrationStatus(ctx, ['--json']); - expect(statusOffline.exitCode, 'Mongo-P.05: status').toBe(0); - const offlineState = migrationStatusAppSpace(parseMigrationStatusJson(statusOffline)); - expect(offlineState.currentContract, 'Mongo-P.05: marker did not advance to C2').not.toBe( - c2Hash, - ); - - // Mongo-P.06: status --ref also fatal (parity with apply). - const statusFail = await runMigrationStatus(ctx, ['--to', 'prod', '--json']); - expect(statusFail.exitCode, 'Mongo-P.06: status exits 2').toBe(2); - expect(engineError(statusFail)?.code, 'Mongo-P.06: status error code').toBe( - 'MIGRATION.UNKNOWN_INVARIANT', - ); - }); - - it('Mongo Q: divergent graph — ref points at the no-invariant branch, apply fails with NO_INVARIANT_PATH', async () => { - const ctx = setupMongoJourney(buildMongoUri(replSet.getUri(), dbName)); - created.add(ctx.testDir); - - // Mongo-Q.01: emit base, plan + apply init. - expect((await runContractEmit(ctx)).exitCode, 'Mongo-Q.01: emit base').toBe(0); - expect((await runMigrationPlan(ctx, ['--name', 'initial'])).exitCode, 'Mongo-Q.01: plan').toBe( - 0, - ); - const initDir = getLatestMigrationDir(ctx); - expect( - (await runMigrationEmit(ctx, ['--dir', `migrations/app/${basename(initDir)}`])).exitCode, - 'Mongo-Q.01: emit init', - ).toBe(0); - expect((await runMigrate(ctx)).exitCode, 'Mongo-Q.01: apply init').toBe(0); - const initManifest = JSON.parse(readFileSync(join(initDir, 'migration.json'), 'utf-8')) as { - to: string; - }; - const c1Hash = initManifest.to; - - // Mongo-Q.02: branch A — additive contract, hand-authored migration WITH invariantId. - swapToAdditive(ctx); - expect((await runContractEmit(ctx)).exitCode, 'Mongo-Q.02: emit CA').toBe(0); - expect( - (await runMigrationNew(ctx, ['--name', 'branch-a-with-invariant'])).exitCode, - 'Mongo-Q.02: new branch A', - ).toBe(0); - const branchADir = findMigrationDirBySlug(ctx, 'branch_a_with_invariant'); - const draftA = JSON.parse(readFileSync(join(branchADir, 'migration.json'), 'utf-8')) as { - from: string; - to: string; - }; - writeFileSync( - join(branchADir, 'migration.ts'), - renderInvariantMigrationTs(draftA.from, draftA.to, { invariantId: INVARIANT_ID }), - ); - expect( - (await runMigrationEmit(ctx, ['--dir', branchADir])).exitCode, - 'Mongo-Q.02: emit branch A', - ).toBe(0); - - // Mongo-Q.03: branch B — index-only migration, no invariantId, planned --from C1. - // The destination contract snapshot store is content-addressed (keyed by - // the contract's real storage hash), so branch B needs a genuinely - // distinct contract to land at a distinct destination — swap to a third - // fixture (a different additive index) and emit it before scaffolding, - // then hand-author an index-only migration.ts against the real hash. - swapToBranchB(ctx); - expect((await runContractEmit(ctx)).exitCode, 'Mongo-Q.03: emit CB').toBe(0); - expect( - (await runMigrationNew(ctx, ['--name', 'branch-b-no-invariant', '--from', c1Hash])).exitCode, - 'Mongo-Q.03: new branch B', - ).toBe(0); - const branchBDir = findMigrationDirBySlug(ctx, 'branch_b_no_invariant'); - const branchBManifest = JSON.parse( - readFileSync(join(branchBDir, 'migration.json'), 'utf-8'), - ) as { from: string; to: string }; - const cbHash = branchBManifest.to; - writeFileSync( - join(branchBDir, 'migration.ts'), - renderIndexOnlyMigrationTs(branchBManifest.from, cbHash), - ); - expect( - (await runMigrationEmit(ctx, ['--dir', branchBDir])).exitCode, - 'Mongo-Q.03: emit branch B', - ).toBe(0); - - // Mongo-Q.04: ref points at CB but requires INVARIANT_ID — declared on - // branch A, not on the path C1 → CB. - writeRefFile(ctx, 'prod', cbHash, [INVARIANT_ID]); - - // Mongo-Q.05: apply --ref prod fails with NO_INVARIANT_PATH. - const applyFail = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect(applyFail.exitCode, 'Mongo-Q.05: apply exits 2').toBe(2); - const envelope = parseJsonOutput<{ - code?: string; - meta?: { - required?: readonly string[]; - missing?: readonly string[]; - structuralPath?: readonly { dirName: string; invariants: readonly string[] }[]; - }; - }>(applyFail); - expect(envelope.code, 'Mongo-Q.05: error code').toBe('MIGRATION.NO_INVARIANT_PATH'); - expect(envelope.meta?.required, 'Mongo-Q.05: required').toEqual([INVARIANT_ID]); - expect(envelope.meta?.missing, 'Mongo-Q.05: missing').toEqual([INVARIANT_ID]); - expect(envelope.meta?.structuralPath, 'Mongo-Q.05: structuralPath populated').toBeDefined(); - expect( - envelope.meta?.structuralPath?.at(-1)?.invariants, - 'Mongo-Q.05: CB-branch edge has no invariants', - ).toEqual([]); - }); -}); diff --git a/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts b/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts index 773556bdea54..836210e8732e 100644 --- a/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts @@ -14,11 +14,12 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, sql, swapContract, @@ -43,7 +44,7 @@ withTempDir(({ createTempDir }) => { // Setup: emit → plan → apply initial migration const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, 'plan initial').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply initial').toBe(0); @@ -82,7 +83,7 @@ withTempDir(({ createTempDir }) => { // Plan and apply initial migration (creates user table with id + email) const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, 'plan initial').toBe(0); const apply0 = await runMigrate(ctx, ['--json']); expect(apply0.exitCode, 'apply initial').toBe(0); @@ -102,7 +103,12 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-unique-email'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit unique-email').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-unique-email']); + const plan1 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-unique-email', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan1.exitCode, 'plan add-unique-email').toBe(0); // Apply fails because duplicate emails violate the unique constraint @@ -156,7 +162,7 @@ withTempDir(({ createTempDir }) => { // Plan and apply initial migration const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, 'plan initial').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply initial').toBe(0); @@ -173,7 +179,12 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-destructive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit destructive').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'drop-email']); + const plan1 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'drop-email', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan1.exitCode, 'plan drop-email').toBe(0); // Apply destructive migration @@ -229,21 +240,31 @@ withTempDir(({ createTempDir }) => { // Migration 1: create user table (id + email) const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, 'plan initial').toBe(0); // Migration 2: add name column swapContract(ctx, 'contract-additive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit additive').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-name']); + const plan1 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-name', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan1.exitCode, 'plan add-name').toBe(0); // Migration 3: drop email column (destructive) swapContract(ctx, 'contract-destructive'); const emit2 = await runContractEmit(ctx); expect(emit2.exitCode, 'emit destructive').toBe(0); - const plan2 = await runMigrationPlanAndEmit(ctx, ['--name', 'drop-email']); + const plan2 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'drop-email', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan2.exitCode, 'plan drop-email').toBe(0); // Batch apply all three from empty DB diff --git a/test/integration/test/cli-journeys/migration-check.e2e.test.ts b/test/integration/test/cli-journeys/migration-check.e2e.test.ts index 869557572e36..98d1782e9b1e 100644 --- a/test/integration/test/cli-journeys/migration-check.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-check.e2e.test.ts @@ -17,9 +17,9 @@ import { engineDiagnosticCodes, engineDocument, type JourneyContext, + planMigrationAndSelfEmit, runContractEmit, runMigrationCheck, - runMigrationPlanAndEmit, setupJourney, timeouts, } from '../utils/journey-test-helpers'; @@ -53,7 +53,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const check = await runMigrationCheck(ctx, ['--json']); @@ -71,7 +71,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const migDir = findLatestMigrationDir(ctx); @@ -99,7 +99,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const appDir = join(ctx.testDir, 'migrations', 'app'); @@ -121,7 +121,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const migDir = findLatestMigrationDir(ctx); @@ -160,7 +160,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const danglingHash = `${'f'.repeat(64)}`; @@ -186,7 +186,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const migDir = findLatestMigrationDir(ctx); @@ -217,7 +217,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const migDir = findLatestMigrationDir(ctx); @@ -246,7 +246,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const check = await runMigrationCheck(ctx, ['nonexistent-migration', '--json']); diff --git a/test/integration/test/cli-journeys/migration-graph-dot.e2e.test.ts b/test/integration/test/cli-journeys/migration-graph-dot.e2e.test.ts index 795ac456285a..7a5f2cec8913 100644 --- a/test/integration/test/cli-journeys/migration-graph-dot.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-graph-dot.e2e.test.ts @@ -14,9 +14,9 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + planMigrationAndSelfEmit, runContractEmit, runMigrationGraph, - runMigrationPlanAndEmit, setupJourney, timeouts, } from '../utils/journey-test-helpers'; @@ -30,7 +30,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const human = await runMigrationGraph(ctx, ['--dot']); @@ -64,7 +64,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const graph = await runMigrationGraph(ctx, [], { isTTY: false }); diff --git a/test/integration/test/cli-journeys/migration-list.e2e.test.ts b/test/integration/test/cli-journeys/migration-list.e2e.test.ts index 0e0937d2e027..0f1bee0a027c 100644 --- a/test/integration/test/cli-journeys/migration-list.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-list.e2e.test.ts @@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, + planMigrationAndSelfEmit, runContractEmit, runMigrationList, - runMigrationPlanAndEmit, setupJourney, swapContract, } from '../utils/journey-test-helpers'; @@ -21,10 +22,15 @@ withTempDir(({ createTempDir }) => { async function projectWithTwoMigrations(): Promise { const ctx = setupJourney({ createTempDir }); await runContractEmit(ctx); - await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); swapContract(ctx, 'contract-additive'); await runContractEmit(ctx); - await runMigrationPlanAndEmit(ctx, ['--name', 'add-name']); + await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-name', + '--from', + latestMigrationDirName(ctx), + ]); return ctx; } diff --git a/test/integration/test/cli-journeys/migration-log.e2e.test.ts b/test/integration/test/cli-journeys/migration-log.e2e.test.ts index 63d09a2192bf..5a41d53c4ed6 100644 --- a/test/integration/test/cli-journeys/migration-log.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-log.e2e.test.ts @@ -9,10 +9,11 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationLog, - runMigrationPlanAndEmit, setupJourney, swapContract, timeouts, @@ -40,7 +41,7 @@ withTempDir(({ createTempDir }) => { }); expect((await runContractEmit(ctx)).exitCode, 'emit base').toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'initial'])).exitCode, 'plan').toBe( + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'initial'])).exitCode, 'plan').toBe( 0, ); expect((await runMigrate(ctx)).exitCode, 'apply initial').toBe(0); @@ -48,7 +49,14 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive'); expect((await runContractEmit(ctx)).exitCode, 'emit v2').toBe(0); expect( - (await runMigrationPlanAndEmit(ctx, ['--name', 'add-name-column'])).exitCode, + ( + await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-name-column', + '--from', + latestMigrationDirName(ctx), + ]) + ).exitCode, 'plan v2', ).toBe(0); expect((await runMigrate(ctx)).exitCode, 'apply v2').toBe(0); diff --git a/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts b/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts index 281490676ae5..e024bf4ff397 100644 --- a/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts @@ -19,11 +19,12 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, - runMigrationEmit, runMigrationPlan, - runMigrationPlanAndEmit, + selfEmitMigration, setupJourney, swapContract, useDevDatabase, @@ -50,11 +51,7 @@ withTempDir(({ createTempDir }) => { // H.02: migration plan --json (plan+self-emit so the migration is // attested on disk for H.03's verifyMigration check). - // - // `migrationHash` was removed from `MigrationPlanResult` in PR 3 — it - // was tied to the old `migration emit` path — so we no longer assert - // on it here. - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'initial', '--json']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'initial', '--json']); expect(plan.exitCode, 'H.02: migration plan --json').toBe(0); const result = parseJsonOutput<{ @@ -71,9 +68,6 @@ withTempDir(({ createTempDir }) => { expect(result.ok, 'H.02: ok flag').toBe(true); expect(result.noOp, 'H.02: not a noop').toBe(false); - // Baseline migrations are encoded as `from: null` end-to-end; the live- - // marker layer still uses `EMPTY_CONTRACT_HASH` for "no marker present" - // but the manifest / plan-result surface no longer carries the sentinel. expect(result.from, 'H.02: from is null (baseline)').toBeNull(); expect(result.to, 'H.02: to is defined').toBeDefined(); expect(result.dir, 'H.02: dir is defined').toBeDefined(); @@ -127,7 +121,7 @@ withTempDir(({ createTempDir }) => { // Self-emit the initial migration so it's attested and becomes a // leaf in the migration graph — otherwise I.03's planner computes // from the empty contract and mis-classifies the change. - const planInit = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const planInit = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(planInit.exitCode, 'I.01: plan initial').toBe(0); // I.02: swap to destructive contract (removes email column) @@ -136,7 +130,13 @@ withTempDir(({ createTempDir }) => { expect(emit1.exitCode, 'I.02: contract emit destructive').toBe(0); // I.03: plan drop-column migration - const planDrop = await runMigrationPlan(ctx, ['--name', 'drop-email', '--json']); + const planDrop = await runMigrationPlan(ctx, [ + '--name', + 'drop-email', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(planDrop.exitCode, 'I.03: plan drop-email').toBe(0); const result = parseJsonOutput<{ @@ -160,7 +160,7 @@ withTempDir(({ createTempDir }) => { // run the scaffolded `migration.ts` explicitly to produce ops.json. const dropDir = getLatestMigrationDir(ctx); expect(dropDir, 'I.03: drop-email migration dir').toBeTruthy(); - const dropEmitResult = await runMigrationEmit(ctx, ['--dir', `migrations/app/${dropDir}`]); + const dropEmitResult = await selfEmitMigration(ctx, ['--dir', `migrations/app/${dropDir}`]); expect(dropEmitResult.exitCode, `I.03: emit drop-email: ${dropEmitResult.stderr}`).toBe(0); // I.04: verify destructive operation class on disk diff --git a/test/integration/test/cli-journeys/migration-round-trip.e2e.test.ts b/test/integration/test/cli-journeys/migration-round-trip.e2e.test.ts index d7756fc0c800..477dc21f02a3 100644 --- a/test/integration/test/cli-journeys/migration-round-trip.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-round-trip.e2e.test.ts @@ -4,25 +4,25 @@ * Drives the full migration lifecycle end-to-end against a live * Postgres: * - * 1. `migration plan` → `migration apply` against an empty + * 1. `migration plan` → `migrate` against an empty * database creates the initial table (`createTable` only — no * placeholders, no data ops). - * 2. Re-run `migration apply` is a no-op — `migrationsApplied: 0` + * 2. Re-run `migrate` is a no-op — `migrationsApplied: 0` * and the formatted output reports "Already up to date" (per * `plan.md` lines 318-323). * 3. Swap to a contract that both adds a nullable column and * requires a data backfill, hand-author a `migration.ts` that * combines `addColumn` + `dataTransform` + `setNotNull`, run - * it to emit `ops.json`, then `migration apply` succeeds and + * it to emit `ops.json`, then `migrate` succeeds and * the data is correct. - * 4. Re-running `migration apply` after the second migration is + * 4. Re-running `migrate` after the second migration is * again a no-op. * * This is the broader companion to the per-strategy planner-assisted - * e2es (`data-transform-not-null-backfill.e2e.test.ts` and - * friends): those isolate one strategy each, this one proves the - * whole pipeline (createTable → addColumn → dataTransform → - * setNotNull) round-trips and is idempotent. + * e2es (`data-transform-strategies.e2e.test.ts`): those isolate one + * strategy each, this one proves the whole pipeline (createTable → + * addColumn → dataTransform → setNotNull) round-trips and is + * idempotent. */ import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; @@ -32,11 +32,11 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + planMigrationAndSelfEmit, runContractEmit, runMigrate, - runMigrationEmit, runMigrationNew, - runMigrationPlanAndEmit, + selfEmitMigration, setupJourney, sql, swapContract, @@ -62,13 +62,13 @@ withTempDir(({ createTempDir }) => { // Step 1: emit base contract → plan → apply (createTable // only). The base contract is `id + email`; nothing data- // safety touches it, so the planner emits a pure - // `createTable` and `migration apply` runs all of it without + // `createTable` and `migrate` runs all of it without // any user intervention. // ----------------------------------------------------------- const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, `plan initial: ${plan0.stderr}`).toBe(0); const apply0 = await runMigrate(ctx); @@ -81,7 +81,7 @@ withTempDir(({ createTempDir }) => { ); // ----------------------------------------------------------- - // Step 2: re-running `migration apply` against an + // Step 2: re-running `migrate` against an // already-up-to-date database must be a no-op (Phase 3 AC, // plan.md lines 318-323). // ----------------------------------------------------------- @@ -156,7 +156,7 @@ MigrationCLI.run(import.meta.url, M); `; writeFileSync(migrationTsPath, migrationTs); - const emitResult = await runMigrationEmit(ctx, [ + const emitResult = await selfEmitMigration(ctx, [ '--dir', migrationDir, '--config', diff --git a/test/integration/test/cli-journeys/migration-show-reachability.e2e.test.ts b/test/integration/test/cli-journeys/migration-show-reachability.e2e.test.ts index 220c875e5a9d..1ae612c33a4e 100644 --- a/test/integration/test/cli-journeys/migration-show-reachability.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-show-reachability.e2e.test.ts @@ -18,8 +18,8 @@ import { declarePgvectorExtension, type EngineCommandResult, type JourneyContext, + planMigrationAndSelfEmit, runContractEmit, - runMigrationPlanAndEmit, runMigrationShow, setupJourney, timeouts, @@ -57,7 +57,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); setupUnmigratedExtensionsState(ctx); @@ -75,7 +75,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); setupUnmigratedExtensionsState(ctx); @@ -106,7 +106,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); setupUnmigratedExtensionsState(ctx); diff --git a/test/integration/test/cli-journeys/migration-status-diagnostics.e2e.test.ts b/test/integration/test/cli-journeys/migration-status-diagnostics.e2e.test.ts index a427892af896..8723bc9ae89b 100644 --- a/test/integration/test/cli-journeys/migration-status-diagnostics.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-status-diagnostics.e2e.test.ts @@ -19,13 +19,14 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { EMPTY_CONTRACT_HASH, type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planMigrationAndSelfEmit, runContractEmit, runDbUpdate, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, runRef, setupJourney, @@ -72,7 +73,7 @@ withTempDir(({ createTempDir }) => { const statusContractOnly = await runMigrationStatus(ctx); expect(statusContractOnly.exitCode, 'still requires --db or --from after emit').not.toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan.exitCode, 'plan').toBe(0); const planFrom = parseJsonOutput<{ from: string | null }>(plan).from; @@ -115,7 +116,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const status = await runMigrationStatus(ctx); @@ -148,7 +149,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const apply = await runMigrate(ctx); expect(apply.exitCode, 'apply').toBe(0); @@ -185,7 +186,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan init').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply init').toBe(0); @@ -193,7 +194,12 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit v2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-field']); + const plan1 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-field', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan1.exitCode, 'plan v2').toBe(0); const status = await runMigrationStatus(ctx); @@ -228,7 +234,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); swapContract(ctx, 'contract-additive'); @@ -260,7 +266,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply').toBe(0); @@ -303,7 +309,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply').toBe(0); @@ -351,7 +357,7 @@ withTempDir(({ createTempDir }) => { // Base: emit → plan → apply const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply').toBe(0); @@ -408,7 +414,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const apply = await runMigrate(ctx); expect(apply.exitCode, 'apply').toBe(0); @@ -457,7 +463,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'plan init').toBe(0); const baseHash = parseJsonOutput<{ to: string }>(plan0).to; const apply0 = await runMigrate(ctx); @@ -466,7 +472,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emitA = await runContractEmit(ctx); expect(emitA.exitCode, 'emit branch A').toBe(0); - const planA = await runMigrationPlanAndEmit(ctx, [ + const planA = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-phone', '--from', @@ -477,7 +483,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-bio'); const emitB = await runContractEmit(ctx); expect(emitB.exitCode, 'emit branch B').toBe(0); - const planB = await runMigrationPlanAndEmit(ctx, [ + const planB = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-bio', '--from', @@ -525,7 +531,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'plan init').toBe(0); const baseHash = parseJsonOutput<{ to: string }>(plan0).to; const apply0 = await runMigrate(ctx); @@ -535,7 +541,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emitA = await runContractEmit(ctx); expect(emitA.exitCode, 'emit A').toBe(0); - const planA = await runMigrationPlanAndEmit(ctx, [ + const planA = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-phone', '--from', @@ -549,7 +555,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-bio'); const emitB = await runContractEmit(ctx); expect(emitB.exitCode, 'emit B').toBe(0); - const planB = await runMigrationPlanAndEmit(ctx, [ + const planB = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-bio', '--from', @@ -574,73 +580,6 @@ withTempDir(({ createTempDir }) => { ); }); - /** - * Scenario: same divergent graph as above, but the user has set a ref - * pointing at one of the branches. - * - * With a ref, the system knows which path to follow. The divergence - * warning should disappear and status should report normally — either - * up to date or pending depending on what's been applied. This - * validates that --ref is the correct escape hatch for ambiguous graphs. - */ - describe('divergent graph with ref — resolves target', () => { - const db = useDevDatabase(); - - it( - 'two branches + ref set → status resolves via ref', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - }); - - const emit0 = await runContractEmit(ctx); - expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); - expect(plan0.exitCode, 'plan init').toBe(0); - const baseHash = parseJsonOutput<{ to: string }>(plan0).to; - const apply0 = await runMigrate(ctx); - expect(apply0.exitCode, 'apply init').toBe(0); - - swapContract(ctx, 'contract-phone'); - const emitA = await runContractEmit(ctx); - expect(emitA.exitCode, 'emit A').toBe(0); - const planA = await runMigrationPlanAndEmit(ctx, [ - '--name', - 'add-phone', - '--from', - baseHash, - '--json', - ]); - expect(planA.exitCode, 'plan A').toBe(0); - const hashA = parseJsonOutput<{ to: string }>(planA).to; - - swapContract(ctx, 'contract-bio'); - const emitB = await runContractEmit(ctx); - expect(emitB.exitCode, 'emit B').toBe(0); - const planB = await runMigrationPlanAndEmit(ctx, [ - '--name', - 'add-bio', - '--from', - baseHash, - ]); - expect(planB.exitCode, 'plan B').toBe(0); - - const setRef = await runRef(ctx, ['set', 'production', hashA]); - expect(setRef.exitCode, 'ref set').toBe(0); - - const status = await runMigrationStatus(ctx, ['--to', 'production']); - const out = stripAnsi(status.stderr); - - expect(status.exitCode).toBe(0); - expect(out).not.toContain('multiple valid migration paths'); - expect(out).toMatch(/1 pending/); - expect(out).toContain('prisma-cli migrate'); - }, - timeouts.spinUpPpgDev, - ); - }); - describe('--from constrains the path origin', () => { const db = useDevDatabase(); @@ -654,13 +593,13 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit0').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'plan0').toBe(0); await swapContract(ctx, 'contract-additive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit1').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'additive']); + const plan1 = await planMigrationAndSelfEmit(ctx, ['--name', 'additive']); expect(plan1.exitCode, 'plan1').toBe(0); const hashA = parseJsonOutput(plan0)?.['to'] as string; diff --git a/test/integration/test/cli-journeys/mongo-migration.e2e.test.ts b/test/integration/test/cli-journeys/mongo-migration.e2e.test.ts index 3c78278f36d7..4fca3468ddd5 100644 --- a/test/integration/test/cli-journeys/mongo-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/mongo-migration.e2e.test.ts @@ -8,7 +8,7 @@ * content-addressed `migrations/snapshots//contract.{json,d.ts}` * store entry for the destination contract, and emits attested * `ops.json` with the expected `createIndex` operation(s). Asserts the - * rendered `migration.ts` is round-trip executable: running it via `tsx` + * rendered `migration.ts` is round-trip executable: running its class-flow * instantiates the migration class, reads its `operations` getter, and * self-emits `ops.json` + attested `migration.json`. * @@ -43,13 +43,35 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { fixtureAppDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + migrationStatusAppSpace, + parseJsonOutput, + parseMigrationStatusJson, runContractEmit, runMigrate, - runMigrationEmit, runMigrationNew, runMigrationPlan, + runMigrationStatus, + selfEmitMigration, } from '../utils/journey-test-helpers'; +const INVARIANT_ID = 'lowercase-user-name'; + +/** Writes a ref pointing at `hash` and requiring `invariants` (moved from the deleted invariant-routing.mongo mirror). */ +function writeRefFile( + ctx: JourneyContext, + name: string, + hash: string, + invariants: readonly string[], +): void { + const refsDir = join(ctx.testDir, 'migrations', 'app', 'refs'); + mkdirSync(refsDir, { recursive: true }); + writeFileSync( + join(refsDir, `${name}.json`), + `${JSON.stringify({ hash, invariants }, null, 2)}\n`, + 'utf-8', + ); +} + const FIXTURES_DIR = join(fixtureAppDir, 'fixtures/mongo-cli-journeys'); function setupMongoJourney(connectionString: string | undefined): JourneyContext { @@ -195,10 +217,13 @@ describe('Journey: Mongo migration authoring (offline)', { timeout: timeouts.spi readFileSync(contractSnapshotPath(ctx, draftManifest.to, 'contract.d.ts'), 'utf-8'), ).toBe(readFileSync(join(ctx.outputDir, 'contract.d.ts'), 'utf-8')); - // Plan leaves a draft migration; self-emit via `tsx migration.ts` to + // Plan leaves a draft migration; self-emit by running migration.ts in-process to // produce `ops.json` and the attested `migration.json`. - const emit = await runMigrationEmit(ctx, ['--dir', `migrations/app/${basename(migrationDir)}`]); - expect(emit.exitCode, `migration emit: ${emit.stdout}\n${emit.stderr}`).toBe(0); + const emit = await selfEmitMigration(ctx, [ + '--dir', + `migrations/app/${basename(migrationDir)}`, + ]); + expect(emit.exitCode, `migration.ts self-emit: ${emit.stdout}\n${emit.stderr}`).toBe(0); const ops = JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8')) as ReadonlyArray<{ id: string; @@ -323,17 +348,17 @@ describe('Journey: Mongo migration authoring (live database)', { const plan0 = await runMigrationPlan(ctx, ['--name', 'initial']); expect(plan0.exitCode, `migration plan initial: ${plan0.stdout}\n${plan0.stderr}`).toBe(0); - const emitInit = await runMigrationEmit(ctx, [ + const emitInit = await selfEmitMigration(ctx, [ '--dir', `migrations/app/${basename(getLatestMigrationDir(ctx))}`, ]); expect( emitInit.exitCode, - `migration emit initial: ${emitInit.stdout}\n${emitInit.stderr}`, + `migration.ts self-emit initial: ${emitInit.stdout}\n${emitInit.stderr}`, ).toBe(0); const apply0 = await runMigrate(ctx); - expect(apply0.exitCode, `migration apply initial: ${apply0.stdout}\n${apply0.stderr}`).toBe(0); + expect(apply0.exitCode, `migrate initial: ${apply0.stdout}\n${apply0.stderr}`).toBe(0); const collections = await client.db(dbName).listCollections({ name: 'users' }).toArray(); expect(collections.map((c) => c.name)).toContain('users'); @@ -387,6 +412,7 @@ class M extends Migration { return [ createIndex('users', [{ field: 'name', direction: 1 }]), dataTransform('lowercase-user-name', { + invariantId: ${JSON.stringify(INVARIANT_ID)}, check: { source: () => ({ collection: 'users', @@ -416,10 +442,11 @@ MigrationCLI.run(import.meta.url, M); `; writeFileSync(migrationTsPath, handAuthored); - const emitResult = await runMigrationEmit(ctx, ['--dir', migrationDir]); - expect(emitResult.exitCode, `migration emit: ${emitResult.stdout}\n${emitResult.stderr}`).toBe( - 0, - ); + const emitResult = await selfEmitMigration(ctx, ['--dir', migrationDir]); + expect( + emitResult.exitCode, + `migration.ts self-emit: ${emitResult.stdout}\n${emitResult.stderr}`, + ).toBe(0); const ops = JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8')) as ReadonlyArray<{ id: string; @@ -435,8 +462,29 @@ MigrationCLI.run(import.meta.url, M); }; expect(manifest.migrationHash).toMatch(/^[a-f0-9]{64}$/); - const apply1 = await runMigrate(ctx); - expect(apply1.exitCode, `migration apply additive: ${apply1.stdout}\n${apply1.stderr}`).toBe(0); + // The transform declares an invariantId and the ref requires it — apply + // routes on the invariant and the Mongo runner accumulates it onto the + // marker doc via its aggregation-pipeline $setUnion merge (moved from + // the deleted invariant-routing.mongo mirror). + writeRefFile(ctx, 'prod', draftManifest.to, [INVARIANT_ID]); + const apply1 = await runMigrate(ctx, ['--to', 'prod', '--json']); + expect(apply1.exitCode, `migrate additive: ${apply1.stdout}\n${apply1.stderr}`).toBe(0); + const apply1Result = parseJsonOutput<{ + ok: boolean; + markerHash: string; + pathDecision?: { + requiredInvariants: readonly string[]; + satisfiedInvariants: readonly string[]; + }; + }>(apply1); + expect(apply1Result.ok, 'apply ok').toBe(true); + expect(apply1Result.pathDecision?.requiredInvariants, 'required reflects the ref').toEqual([ + INVARIANT_ID, + ]); + expect( + apply1Result.pathDecision?.satisfiedInvariants, + 'the selected path satisfies the invariant', + ).toEqual([INVARIANT_ID]); const users = await client .db(dbName) @@ -455,10 +503,36 @@ MigrationCLI.run(import.meta.url, M); true, ); - // Re-apply: the runner postcheck sees all names are already lower-case, - // so the data transform is skipped. Data must be byte-identical. - const apply2 = await runMigrate(ctx); + // Status reads the marker document back and proves the invariant + // accumulated onto it via the Mongo runner's server-side $setUnion + // merge: nothing missing, path applied, up to date. + const statusRef = await runMigrationStatus(ctx, ['--to', 'prod', '--json']); + expect(statusRef.exitCode, `status --to prod: ${statusRef.stdout}\n${statusRef.stderr}`).toBe( + 0, + ); + const statusResult = parseMigrationStatusJson(statusRef); + expect( + statusResult.diagnostics?.some((d) => d.code === 'MIGRATION.MISSING_INVARIANTS'), + 'no missing-invariants diagnostic', + ).toBeFalsy(); + expect(statusResult.summary, 'status up to date').toMatch(/up to date/i); + expect( + migrationStatusAppSpace(statusResult).migrations.every((m) => m.status === 'applied'), + 'path migrations applied', + ).toBe(true); + + // Re-apply is a true no-op: the CLI's marker subtraction empties the + // required set and the runner short-circuits via its + // incomingIsSubsetOfExisting guard — marker unchanged, and the + // postcheck sees all names already lower-case so data is byte-identical. + const apply2 = await runMigrate(ctx, ['--to', 'prod', '--json']); expect(apply2.exitCode, `re-apply: ${apply2.stdout}\n${apply2.stderr}`).toBe(0); + const apply2Result = parseJsonOutput<{ ok: boolean; markerHash: string; summary: string }>( + apply2, + ); + expect(apply2Result.ok, 're-apply ok').toBe(true); + expect(apply2Result.markerHash, 'marker unchanged').toBe(apply1Result.markerHash); + expect(apply2Result.summary, 'noop summary').toMatch(/up to date/i); const usersAfterReApply = await client .db(dbName) diff --git a/test/integration/test/cli-journeys/multi-step-migration.e2e.test.ts b/test/integration/test/cli-journeys/multi-step-migration.e2e.test.ts index f88430269e33..6df6ee12d27b 100644 --- a/test/integration/test/cli-journeys/multi-step-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/multi-step-migration.e2e.test.ts @@ -12,10 +12,10 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + planMigrationAndSelfEmit, runContractEmit, runDbVerify, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -38,7 +38,7 @@ withTempDir(({ createTempDir }) => { // Precondition: plan initial migration (∅ → base) const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'C.pre: emit base').toBe(0); - const planInit = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const planInit = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(planInit.exitCode, 'C.pre: plan initial').toBe(0); // C.01: Swap to contract-additive, contract emit @@ -47,7 +47,7 @@ withTempDir(({ createTempDir }) => { expect(emit1.exitCode, 'C.01: contract emit v2').toBe(0); // C.02: migration plan --name add-name - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-name']); + const plan1 = await planMigrationAndSelfEmit(ctx, ['--name', 'add-name']); expect(plan1.exitCode, 'C.02: migration plan v2').toBe(0); // C.03: Swap to contract-v3, contract emit @@ -56,7 +56,7 @@ withTempDir(({ createTempDir }) => { expect(emit2.exitCode, 'C.03: contract emit v3').toBe(0); // C.04: migration plan --name add-posts - const plan2 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-posts']); + const plan2 = await planMigrationAndSelfEmit(ctx, ['--name', 'add-posts']); expect(plan2.exitCode, 'C.04: migration plan v3').toBe(0); // C.05: migration status --db (2 pending) @@ -66,9 +66,9 @@ withTempDir(({ createTempDir }) => { // Should show at least 2 pending expect(pendingOutput, 'C.05: shows pending migrations').toContain('pending'); - // C.06: migration apply --db (applies both) + // migrate --db (applies both) const apply = await runMigrate(ctx); - expect(apply.exitCode, 'C.06: migration apply all').toBe(0); + expect(apply.exitCode, 'migrate applies both steps').toBe(0); // C.07: migration status --db (all applied) const statusApplied = await runMigrationStatus(ctx); diff --git a/test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts b/test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts deleted file mode 100644 index 4efd66bde5c3..000000000000 --- a/test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Plannable rollback edge (TML-2690) - * - * Reproduces the failing case end-to-end: from a two-migration applied state, - * `migration plan --to ^` plans the reverse edge toward the predecessor - * contract (a DROP, flagged destructive), and `migrate --to ^` then applies - * it and moves the marker back — all WITHOUT editing the contract source. - * - * This is the one-command recovery the `migrate` path-unreachable diagnostic now - * advertises: previously `migrate --to ^` was advertised in `--help` but - * dead-ended, forcing a contract-surgery workaround. - */ - -import { readFileSync } from 'node:fs'; -import { join } from 'pathe'; -import { describe, expect, it } from 'vitest'; -import { withTempDir } from '../utils/cli-test-helpers'; -import { - getLatestMigrationDir, - type JourneyContext, - migrationStatusAppSpace, - parseJsonOutput, - parseMigrationStatusJson, - runContractEmit, - runMigrate, - runMigrationPlanAndEmit, - runMigrationStatus, - setupJourney, - swapContract, - timeouts, - useDevDatabase, -} from '../utils/journey-test-helpers'; - -interface PlanJson { - readonly from: string; - readonly to: string; - readonly operations: ReadonlyArray<{ readonly operationClass: string }>; -} - -withTempDir(({ createTempDir }) => { - describe('migration plan --to ^ enables a one-command rollback (TML-2690)', () => { - const db = useDevDatabase(); - - it( - 'plans and applies a reverse edge with no contract-source edit, moving the marker back', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - }); - - // Base (C1): emit → plan + apply init. Marker lands at C1. - expect((await runContractEmit(ctx)).exitCode, 'emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); - expect(plan0.exitCode, 'plan init').toBe(0); - const c1Hash = parseJsonOutput(plan0).to; - expect((await runMigrate(ctx)).exitCode, 'apply init').toBe(0); - - // Add phone (C2): swap source → emit → plan + apply add-phone. Marker at C2. - swapContract(ctx, 'contract-phone'); - expect((await runContractEmit(ctx)).exitCode, 'emit C2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-phone', '--json']); - expect(plan1.exitCode, 'plan add-phone').toBe(0); - const c2Hash = parseJsonOutput(plan1).to; - expect(c2Hash, 'C2 differs from C1').not.toBe(c1Hash); - const addPhoneDir = getLatestMigrationDir(ctx); - expect(addPhoneDir, 'captured add-phone dir').toBeTruthy(); - expect((await runMigrate(ctx)).exitCode, 'apply add-phone').toBe(0); - - // Rollback WITHOUT touching the contract source: plan toward the - // add-phone migration's predecessor (`^` == C1). The emitted - // contract.ts still holds the phone variant throughout. - const rollbackTarget = `${addPhoneDir}^`; - const planRollback = await runMigrationPlanAndEmit(ctx, [ - '--to', - rollbackTarget, - '--name', - 'rollback-phone', - '--json', - ]); - expect(planRollback.exitCode, 'plan rollback --to ^').toBe(0); - const rollback = parseJsonOutput(planRollback); - expect(rollback.from, 'rollback from = current marker C2').toBe(c2Hash); - expect(rollback.to, 'rollback to = predecessor C1').toBe(c1Hash); - expect( - rollback.operations.some((op) => op.operationClass === 'destructive'), - 'reverse delta drops the added column (destructive), no refusal', - ).toBe(true); - - // Prove the recovery needed no contract-source edit: contract.ts is - // still the phone (C2) variant, not reverted to base. - const contractSource = readFileSync(join(ctx.testDir, 'contract.ts'), 'utf-8'); - expect(contractSource, 'contract source untouched (still phone variant)').toContain( - 'phone', - ); - - // Apply the reverse edge; the marker moves back to C1. The reverse - // delta drops a column, so the user accepts the data loss with `-y`. - const applyRollback = await runMigrate(ctx, ['--to', rollbackTarget, '-y', '--json']); - expect( - applyRollback.exitCode, - `apply rollback --to ^:\n${applyRollback.stdout}\n${applyRollback.stderr}`, - ).toBe(0); - const applied = parseJsonOutput<{ ok: boolean; markerHash: string }>(applyRollback); - expect(applied.ok, 'rollback applied ok').toBe(true); - expect(applied.markerHash, 'marker moved back to C1').toBe(c1Hash); - - // Status confirms the live marker is back at the baseline. - const status = await runMigrationStatus(ctx, ['--json']); - expect(status.exitCode, 'status after rollback').toBe(0); - const statusJson = migrationStatusAppSpace(parseMigrationStatusJson(status)); - expect(statusJson.currentContract, 'status marker = C1').toBe(c1Hash); - }, - timeouts.spinUpPpgDev, - ); - }); -}); diff --git a/test/integration/test/cli-journeys/ref-routing.e2e.test.ts b/test/integration/test/cli-journeys/ref-routing.e2e.test.ts deleted file mode 100644 index 004c51aadd80..000000000000 --- a/test/integration/test/cli-journeys/ref-routing.e2e.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Staging Ahead via Refs + Marker Ahead of Ref (Journeys M + N — spec P-5/P-6) - * - * M — Refs route apply and status to different targets on the same DB: - * - production=C1, staging=C2 - * - apply --ref staging advances staging; production unaffected - * - * N — Marker-ahead-of-ref scenario (continuation of M): - * - After staging apply, DB marker is at C2 - * - Set production ref to C1 (behind DB) - * - apply --ref production fails (no backward edge from C2 to C1) - */ - -import { describe, expect, it } from 'vitest'; -import { withTempDir } from '../utils/cli-test-helpers'; -import { - type JourneyContext, - migrationStatusAppSpace, - parseJsonOutput, - parseMigrationStatusJson, - runContractEmit, - runMigrate, - runMigrationPlanAndEmit, - runMigrationStatus, - runRef, - setupJourney, - swapContract, - timeouts, - useDevDatabase, -} from '../utils/journey-test-helpers'; - -withTempDir(({ createTempDir }) => { - describe('Journey M+N: Ref Routing and Marker Ahead (P-5/P-6)', () => { - const db = useDevDatabase(); - - it( - 'staging ref ahead of production → apply --ref staging → marker ahead of production ref', - async () => { - const ctx: JourneyContext = setupJourney({ - connectionString: db.connectionString, - createTempDir, - }); - - // M.01: emit base (C1) → plan + apply init - const emit0 = await runContractEmit(ctx); - expect(emit0.exitCode, 'M.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); - expect(plan0.exitCode, 'M.01: plan init').toBe(0); - const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; - const apply0 = await runMigrate(ctx); - expect(apply0.exitCode, 'M.01: apply init').toBe(0); - - // M.02: swap to contract-phone (C2) → emit → plan add-phone (C1→C2) - swapContract(ctx, 'contract-phone'); - const emit1 = await runContractEmit(ctx); - expect(emit1.exitCode, 'M.02: emit C2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-phone', '--json']); - expect(plan1.exitCode, 'M.02: plan C1→C2').toBe(0); - const c2Hash = parseJsonOutput<{ to: string }>(plan1).to; - - // M.03: set refs — production=C1, staging=C2 - const refProd = await runRef(ctx, ['set', 'production', c1Hash]); - expect(refProd.exitCode, 'M.03: ref set production=C1').toBe(0); - const refStaging = await runRef(ctx, ['set', 'staging', c2Hash]); - expect(refStaging.exitCode, 'M.03: ref set staging=C2').toBe(0); - - // M.04: status --ref production → at-target (DB marker = C1, ref = C1) - const statusProd = await runMigrationStatus(ctx, ['--to', 'production', '--json']); - expect(statusProd.exitCode, 'M.04: status --ref production').toBe(0); - const prodStatus = migrationStatusAppSpace(parseMigrationStatusJson(statusProd)); - const prodPending = prodStatus.migrations.filter((m) => m.status === 'pending').length; - expect(prodPending, 'M.04: production has 0 pending').toBe(0); - - // M.05: status --ref staging → 1 pending (DB marker = C1, ref = C2) - const statusStaging = await runMigrationStatus(ctx, ['--to', 'staging', '--json']); - expect(statusStaging.exitCode, 'M.05: status --ref staging').toBe(0); - const stagingStatus = migrationStatusAppSpace(parseMigrationStatusJson(statusStaging)); - const stagingPending = stagingStatus.migrations.filter( - (m) => m.status === 'pending', - ).length; - expect(stagingPending, 'M.05: staging has 1 pending').toBe(1); - - // M.06: apply --ref staging → advances DB to C2 - const applyStaging = await runMigrate(ctx, ['--to', 'staging', '--json']); - expect(applyStaging.exitCode, 'M.06: apply --ref staging').toBe(0); - const applyStagingResult = parseJsonOutput<{ - ok: boolean; - migrationsApplied: number; - markerHash: string; - }>(applyStaging); - expect(applyStagingResult.ok, 'M.06: ok').toBe(true); - expect(applyStagingResult.migrationsApplied, 'M.06: applied 1').toBe(1); - expect(applyStagingResult.markerHash, 'M.06: marker at C2').toBe(c2Hash); - - // M.07: status --ref production unchanged (still 0 pending, but DB is now at C2) - // The production ref points to C1 which is behind the DB marker C2. - // This transitions into the P-6 scenario (marker ahead of ref). - - // N.01: apply --ref production fails (DB at C2, ref at C1, no backward edge) - const applyProdFail = await runMigrate(ctx, ['--to', 'production', '--json']); - expect(applyProdFail.exitCode, 'N.01: apply --ref production fails').toBe(2); - - // N.02: status --ref production reports ahead-of-ref condition - const statusProdAfter = await runMigrationStatus(ctx, ['--to', 'production', '--json']); - expect( - parseMigrationStatusJson(statusProdAfter).summary, - 'N.02: production status indicates ahead-of-ref condition', - ).toMatch(/ahead|no.*path|mismatch|cannot reach/i); - }, - timeouts.spinUpPpgDev, - ); - }); -}); diff --git a/test/integration/test/cli-journeys/rls-exact-name-adoption.e2e.test.ts b/test/integration/test/cli-journeys/rls-exact-name-adoption.e2e.test.ts index 08f1063d0fae..551a241a3b9d 100644 --- a/test/integration/test/cli-journeys/rls-exact-name-adoption.e2e.test.ts +++ b/test/integration/test/cli-journeys/rls-exact-name-adoption.e2e.test.ts @@ -15,12 +15,13 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runDbSign, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, swapPslContract, timeouts, @@ -88,7 +89,7 @@ withTempDir(({ createTempDir }) => { ).toBe(0); // baseline: EMPTY → adopted contract; no-op on apply. - const planBaseline = await runMigrationPlanAndEmit(ctx, ['--name', 'baseline']); + const planBaseline = await planMigrationAndSelfEmit(ctx, ['--name', 'baseline']); expect(planBaseline.exitCode, `baseline: plan\n${stripAnsi(planBaseline.stderr)}`).toBe(0); const applyBaseline = await runMigrate(ctx, ['--json']); expect(applyBaseline.exitCode, `baseline: apply\n${stripAnsi(applyBaseline.stderr)}`).toBe( @@ -106,7 +107,12 @@ withTempDir(({ createTempDir }) => { ); // plan rename: the widening plan is exactly one ALTER POLICY … RENAME. - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'adopt-wire-name']); + const plan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'adopt-wire-name', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan.exitCode, `plan rename: migration plan\n${stripAnsi(plan.stderr)}`).toBe(0); const ops = readPlannedOps(ctx); expect( @@ -126,7 +132,7 @@ withTempDir(({ createTempDir }) => { // apply: run the rename and verify clean under the wire name. const apply = await runMigrate(ctx); - expect(apply.exitCode, `apply: migration apply\n${stripAnsi(apply.stderr)}`).toBe(0); + expect(apply.exitCode, `apply: migrate\n${stripAnsi(apply.stderr)}`).toBe(0); const verifyWire = await runDbVerify(ctx); expect(verifyWire.exitCode, `apply: verify clean\n${stripAnsi(verifyWire.stderr)}`).toBe(0); }, diff --git a/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts b/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts index d97993516288..41d3c464e762 100644 --- a/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts +++ b/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts @@ -2,20 +2,23 @@ * Rollback Cycle (Journey J — spec scenario P-2/S-2) * * Tests cycle-safe shortest-path resolution after a rollback migration - * creates a cycle in the migration graph (C1 → C2 → C1). The default db - * ref supplies --from implicitly; an explicit --from can still target an - * older graph node when the implicit path is not desired. + * creates a cycle in the migration graph (C1 → C2 → C1). The rollback is + * the one-command flow: `--to ^` with no contract-source + * edit. Every plan names its base explicitly (`--from `). */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -38,7 +41,7 @@ withTempDir(({ createTempDir }) => { // J.01: emit base contract (C1) → plan + apply init const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'J.01: emit C1').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'J.01: plan init').toBe(0); const planResult0 = parseJsonOutput<{ to: string }>(plan0); const c1Hash = planResult0.to; @@ -49,7 +52,13 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'J.02: emit C2').toBe(0); - const plan1 = await runMigrationPlanAndEmit(ctx, ['--name', 'add-phone', '--json']); + const plan1 = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-phone', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(plan1.exitCode, 'J.02: plan add-phone').toBe(0); const planResult1 = parseJsonOutput<{ to: string }>(plan1); const c2Hash = planResult1.to; @@ -57,30 +66,62 @@ withTempDir(({ createTempDir }) => { const apply1 = await runMigrate(ctx); expect(apply1.exitCode, 'J.02: apply add-phone').toBe(0); - // J.03: swap back to base contract (C1) → emit → plan rollback (C2→C1 cycle edge) - swapContract(ctx, 'contract-base'); - const emit2 = await runContractEmit(ctx); - expect(emit2.exitCode, 'J.03: emit C1 again').toBe(0); - const planRollback = await runMigrationPlanAndEmit(ctx, [ + // One-command rollback (folded in from the deleted plan-to-rollback + // journey): plan toward the add-phone migration's + // predecessor via `--to ^` — no contract-source edit. The + // reverse delta drops the added column, so applying needs `-y`. + const addPhoneDir = latestMigrationDirName(ctx); + const rollbackTarget = `${addPhoneDir}^`; + const planRollback = await planMigrationAndSelfEmit(ctx, [ '--name', 'rollback-phone', + '--from', + addPhoneDir, + '--to', + rollbackTarget, '--json', ]); - expect(planRollback.exitCode, 'J.03: plan rollback').toBe(0); - const apply2 = await runMigrate(ctx); - expect(apply2.exitCode, 'J.03: apply rollback').toBe(0); + expect(planRollback.exitCode, 'plan rollback --to ^').toBe(0); + const rollback = parseJsonOutput<{ + from: string; + to: string; + operations: readonly { operationClass: string }[]; + }>(planRollback); + expect(rollback.from, 'rollback from C2').toBe(c2Hash); + expect(rollback.to, 'rollback to predecessor C1').toBe(c1Hash); + expect( + rollback.operations.some((op) => op.operationClass === 'destructive'), + 'reverse delta drops the added column (destructive), no refusal', + ).toBe(true); + const contractSource = readFileSync(join(ctx.testDir, 'contract.ts'), 'utf-8'); + expect(contractSource, 'contract source untouched (still phone variant)').toContain( + 'phone', + ); + const apply2 = await runMigrate(ctx, ['--to', rollbackTarget, '-y', '--json']); + expect(apply2.exitCode, 'apply rollback').toBe(0); + const applied2 = parseJsonOutput<{ ok: boolean; markerHash: string }>(apply2); + expect(applied2.ok, 'rollback applied ok').toBe(true); + expect(applied2.markerHash, 'marker moved back to C1').toBe(c1Hash); - // J.04: graph has cycle (C1→C2→C1); implicit db ref still plans forward + // Graph has cycle (C1→C2→C1); planning from the rollback tip + // (named explicitly — with no db ref, an unflagged plan would be + // greenfield) still plans forward out of the cycle. swapContract(ctx, 'contract-bio'); const emit3 = await runContractEmit(ctx); - expect(emit3.exitCode, 'J.04: emit C3 (bio)').toBe(0); - const planImplicit = await runMigrationPlan(ctx, ['--name', 'add-bio-implicit', '--json']); - expect(planImplicit.exitCode, 'J.04: plan without explicit --from').toBe(0); + expect(emit3.exitCode, 'emit C3 (bio)').toBe(0); + const planImplicit = await runMigrationPlan(ctx, [ + '--name', + 'add-bio-implicit', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(planImplicit.exitCode, 'plan from the rollback tip').toBe(0); const implicitResult = parseJsonOutput<{ from: string; to: string }>(planImplicit); - expect(implicitResult.from, 'J.04: from resolved via db ref').toBeTruthy(); + expect(implicitResult.from, 'plan base resolved').toBeTruthy(); // J.05: plan with --from C1 recovers - const planFrom = await runMigrationPlanAndEmit(ctx, [ + const planFrom = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-bio', '--from', diff --git a/test/integration/test/cli-journeys/schema-evolution-migrations.e2e.test.ts b/test/integration/test/cli-journeys/schema-evolution-migrations.e2e.test.ts index 630583a52516..93d1f7b7f69f 100644 --- a/test/integration/test/cli-journeys/schema-evolution-migrations.e2e.test.ts +++ b/test/integration/test/cli-journeys/schema-evolution-migrations.e2e.test.ts @@ -20,17 +20,18 @@ import { clearDbRefForGreenfieldPlan, withTempDir } from '../utils/cli-test-help import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runDbInit, runDbUpdate, runDbVerify, runMigrate, - runMigrationEmit, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationShow, runMigrationStatus, + selfEmitMigration, setupJourney, swapContract, timeouts, @@ -55,7 +56,7 @@ withTempDir(({ createTempDir }) => { // Precondition: emit base contract and plan initial migration (∅ → base) const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'B.pre: emit base').toBe(0); - const planInit = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const planInit = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(planInit.exitCode, 'B.pre: plan initial').toBe(0); const applyInit = await runMigrate(ctx); expect(applyInit.exitCode, 'B.pre: apply initial').toBe(0); @@ -66,7 +67,12 @@ withTempDir(({ createTempDir }) => { expect(emit.exitCode, 'B.01: contract emit v2').toBe(0); // B.02: migration plan --name add-name-column - const plan = await runMigrationPlan(ctx, ['--name', 'add-name-column']); + const plan = await runMigrationPlan(ctx, [ + '--name', + 'add-name-column', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan.exitCode, 'B.02: migration plan').toBe(0); expect(stripAnsi(plan.stderr), 'B.02: shows migration').toContain('add-name-column'); @@ -76,20 +82,20 @@ withTempDir(({ createTempDir }) => { const show = await runMigrationShow(ctx, [showTarget!]); expect(show.exitCode, 'B.03: migration show').toBe(0); - // B.04: migration emit --dir + // self-emit the planned migration.ts const migDir = getLatestMigrationDir(ctx); expect(migDir, 'B.04: migration dir exists').toBeDefined(); - const emitMig = await runMigrationEmit(ctx, ['--dir', `migrations/app/${migDir}`]); - expect(emitMig.exitCode, 'B.04: migration emit').toBe(0); + const emitMig = await selfEmitMigration(ctx, ['--dir', `migrations/app/${migDir}`]); + expect(emitMig.exitCode, 'migration.ts self-emit').toBe(0); // B.05: migration status (pre-apply — shows pending migration) const statusPreApply = await runMigrationStatus(ctx); expect(statusPreApply.exitCode, 'B.05: migration status pre-apply').toBe(0); expect(stripAnsi(statusPreApply.stderr), 'B.05: shows pending').toContain('pending'); - // B.06: migration apply + // migrate applies the planned migration const apply = await runMigrate(ctx); - expect(apply.exitCode, 'B.06: migration apply').toBe(0); + expect(apply.exitCode, 'migrate applies the plan').toBe(0); // B.07: migration status (all applied) const statusApplied = await runMigrationStatus(ctx); @@ -118,11 +124,11 @@ withTempDir(({ createTempDir }) => { ], }); - // --- Merged from Journey Q: migration apply noop (already up-to-date) --- + // --- Merged from Journey Q: migrate noop (already up-to-date) --- - // Q.01: migration apply --json (already up-to-date) + // migrate --json (already up-to-date) const applyNoop = await runMigrate(ctx, ['--json']); - expect(applyNoop.exitCode, 'Q.01: migration apply noop').toBe(0); + expect(applyNoop.exitCode, 'migrate noop').toBe(0); const noopApplyData = parseJsonOutput(applyNoop); expect(noopApplyData, 'Q.01: 0 applied').toMatchObject({ ok: true, @@ -131,11 +137,15 @@ withTempDir(({ createTempDir }) => { // --- Merged from Journey R: migration plan noop (contract unchanged) --- - // R.01: migration plan --json (no changes — contract matches leaf) - const planNoop = await runMigrationPlan(ctx, ['--json']); - expect(planNoop.exitCode, 'R.01: migration plan noop').toBe(0); + // migration plan from the leaf (no changes — contract matches it) + const planNoop = await runMigrationPlan(ctx, [ + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(planNoop.exitCode, 'migration plan noop').toBe(0); const noopPlanData = parseJsonOutput(planNoop); - expect(noopPlanData, 'R.01: noop flag').toMatchObject({ noOp: true }); + expect(noopPlanData, 'noop flag').toMatchObject({ noOp: true }); // --- Merged from Journey X: migration show variants --- @@ -196,11 +206,11 @@ withTempDir(({ createTempDir }) => { const plan = await runMigrationPlan(ctx, ['--name', 'initial-evolution']); expect(plan.exitCode, 'Z.02: migration plan').toBe(0); - // Z.03: migration apply fails because the db init marker doesn't match + // migrate fails because the db init marker doesn't match // the migration chain root (planned from ∅→additive, but marker is at base). // Then db update recovers by applying the schema directly. const apply = await runMigrate(ctx); - expect(apply.exitCode, 'Z.03: migration apply rejects marker mismatch').toBe(2); + expect(apply.exitCode, 'migrate rejects marker mismatch').toBe(2); const update = await runDbUpdate(ctx); expect(update.exitCode, 'Z.03: db update recovery').toBe(0); diff --git a/test/integration/test/cli-journeys/sign-the-database.e2e.test.ts b/test/integration/test/cli-journeys/sign-the-database.e2e.test.ts index 671b6d5ec37d..21bb513f559e 100644 --- a/test/integration/test/cli-journeys/sign-the-database.e2e.test.ts +++ b/test/integration/test/cli-journeys/sign-the-database.e2e.test.ts @@ -18,24 +18,21 @@ import { join } from 'node:path'; import { withClient } from '@repo/test-utils'; import stripAnsi from 'strip-ansi'; import { afterAll, describe, expect, it } from 'vitest'; -import { - computeContentHash, - computeIndexContentHash, - normalizeSqlBody, -} from '../utils/cli-commands'; +import { computeIndexContentHash } from '../utils/cli-commands'; import { fixtureAppDir } from '../utils/cli-test-helpers'; import { engineDocument, getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runContractInfer, runDbSign, runDbUpdate, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, timeouts, useDevDatabase, @@ -52,6 +49,8 @@ const FOREIGN_TOOL_SCHEMA = ` CREATE INDEX documents_email_lower_idx ON documents (lower(email)); CREATE INDEX documents_active_idx ON documents (tenant_id) WHERE (archived_at IS NULL); CREATE UNIQUE INDEX documents_email_ci_key ON documents (lower(email)); + CREATE INDEX documents_email_idx ON documents (email); + CREATE INDEX email_lookup ON documents (tenant_id, email); ALTER TABLE documents ENABLE ROW LEVEL SECURITY; CREATE POLICY "Tenant members can read" ON documents AS PERMISSIVE FOR SELECT TO tenant_app_user @@ -66,13 +65,6 @@ const WIRE_INDEX_NAME = `documents_email_lower_${computeIndexContentHash({ unique: false, })}`; -const WIRE_POLICY_NAME = `Tenant_members_can_read_${computeContentHash({ - using: normalizeSqlBody('(tenant_id = 1)'), - roles: ['tenant_app_user'], - operation: 'select', - permissive: true, -})}`; - interface PlannedOp { readonly id: string; readonly operationClass: string; @@ -136,6 +128,15 @@ describe('sign a database this toolchain has never seen, then transition to wire expect(inferredPsl).toContain( '@@index(expression: "lower(email)", map: "documents_email_ci_key", unique: true)', ); + // Fields-only indexes adopt exactly too — default-named and + // custom-named (folded in from the deleted index-name-convergence + // exact-mode adoption case). + expect(inferredPsl, 'default-named index adopted exactly').toContain( + '@@index([email], map: "documents_email_idx")', + ); + expect(inferredPsl, 'custom-named index adopted exactly').toContain( + '@@index([tenantId, email], map: "email_lookup")', + ); expect(inferredPsl).toContain('policy_select Tenant_members_can_read {'); expect(inferredPsl).toContain('@@map("Tenant members can read")'); expect(inferredPsl).toContain('policy_update Deny_cross_tenant_writes {'); @@ -168,13 +169,13 @@ describe('sign a database this toolchain has never seen, then transition to wire ); it( - 'map:-to-wire transition plans exactly two renames, applies, verifies clean', + 'map:-to-wire index transition plans exactly one rename, applies, verifies clean', async () => { expect(ctx, 'the signing step must have completed').toBeDefined(); // Baseline migration so migration plan diffs from the // adopted contract; a fresh migrate is a no-op against the live DB. - const planBaseline = await runMigrationPlanAndEmit(ctx, ['--name', 'baseline']); + const planBaseline = await planMigrationAndSelfEmit(ctx, ['--name', 'baseline']); expect(planBaseline.exitCode, `3.1: plan baseline\n${stripAnsi(planBaseline.stderr)}`).toBe( 0, ); @@ -187,50 +188,47 @@ describe('sign a database this toolchain has never seen, then transition to wire migrationsApplied: 0, }); - // Transition ONE index and ONE policy to wire spellings, - // bodies verbatim. - const transitioned = inferredPsl - .replace( - '@@index(expression: "lower(email)", map: "documents_email_lower_idx")', - '@@index(expression: "lower(email)", name: "documents_email_lower")', - ) - .replace(/^\s*@@map\("Tenant members can read"\)\n/m, ''); - // The index replacement above cannot mask a no-op here: prove the - // @@map line itself is gone, not merely that something changed. - expect(transitioned).not.toContain('@@map("Tenant members can read")'); + // Transition ONE index to its wire spelling, body verbatim. (The + // matching policy transition is owned by the rls-exact-name-adoption + // journey.) + const transitioned = inferredPsl.replace( + '@@index(expression: "lower(email)", map: "documents_email_lower_idx")', + '@@index(expression: "lower(email)", name: "documents_email_lower")', + ); + expect(transitioned, '3.2: the map: spelling is gone').not.toContain( + 'map: "documents_email_lower_idx"', + ); writeFileSync(join(ctx.testDir, 'contract.prisma'), transitioned, 'utf-8'); const emitWire = await runContractEmit(ctx); expect(emitWire.exitCode, `3.2: emit wire\n${stripAnsi(emitWire.stderr)}`).toBe(0); - // The widening plan is EXACTLY the two renames. - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'adopt-wire-names']); + // The widening plan is EXACTLY the one rename. + const plan = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'adopt-wire-names', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan.exitCode, `3.3: migration plan\n${stripAnsi(plan.stderr)}`).toBe(0); const ops = readPlannedOps(ctx); expect( - ops - .map((op) => ({ - id: op.id, - operationClass: op.operationClass, - sql: op.execute[0]?.sql, - })) - .sort((a, b) => (a.id < b.id ? -1 : 1)), - '3.3: exactly two renames', + ops.map((op) => ({ + id: op.id, + operationClass: op.operationClass, + sql: op.execute[0]?.sql, + })), + '3.3: exactly one rename', ).toEqual([ { id: 'index.public.documents.documents_email_lower_idx.rename', operationClass: 'widening', sql: `ALTER INDEX "public"."documents_email_lower_idx" RENAME TO "${WIRE_INDEX_NAME}"`, }, - { - id: 'rlsPolicy.public.documents.Tenant members can read.rename', - operationClass: 'widening', - sql: `ALTER POLICY "Tenant members can read" ON "public"."documents" RENAME TO "${WIRE_POLICY_NAME}"`, - }, ]); // Apply; verify clean under the wire names. const apply = await runMigrate(ctx); - expect(apply.exitCode, `3.4: migration apply\n${stripAnsi(apply.stderr)}`).toBe(0); + expect(apply.exitCode, `3.4: migrate\n${stripAnsi(apply.stderr)}`).toBe(0); const verifyWire = await runDbVerify(ctx); expect(verifyWire.exitCode, `3.4: verify clean\n${stripAnsi(verifyWire.stderr)}`).toBe(0); }, diff --git a/test/integration/test/cli.config-section-requirements.test.ts b/test/integration/test/cli.config-section-requirements.test.ts index 255ceffce62b..95bb5879c603 100644 --- a/test/integration/test/cli.config-section-requirements.test.ts +++ b/test/integration/test/cli.config-section-requirements.test.ts @@ -65,11 +65,10 @@ describe('commands declare the config sections they read', () => { it.each(readsContract)( '%s reports a malformed contract section as a config error', async (_name, argv) => { - const run = await runOnEngine( - { testDir, configPath: brokenContractConfig }, - [...argv, '--json'], - { settleConfigFailures: true }, - ); + const run = await runOnEngine({ testDir, configPath: brokenContractConfig }, [ + ...argv, + '--json', + ]); expect(run.exitCode).toBe(2); expect(run.json.at(-1)).toMatchObject({ @@ -91,11 +90,10 @@ describe('commands declare the config sections they read', () => { it.each(readsMigrations)( '%s reports a malformed migrations section as a config error', async (_name, argv) => { - const run = await runOnEngine( - { testDir, configPath: brokenMigrationsConfig }, - [...argv, '--json'], - { settleConfigFailures: true }, - ); + const run = await runOnEngine({ testDir, configPath: brokenMigrationsConfig }, [ + ...argv, + '--json', + ]); expect(run.exitCode).toBe(2); expect(run.json.at(-1)).toMatchObject({ diff --git a/test/integration/test/cli.db-verify.aggregate-schema.test.ts b/test/integration/test/cli.db-verify.aggregate-schema.test.ts index a8048c29c8aa..029c3f40197b 100644 --- a/test/integration/test/cli.db-verify.aggregate-schema.test.ts +++ b/test/integration/test/cli.db-verify.aggregate-schema.test.ts @@ -22,12 +22,10 @@ import { * F23 lock — `db verify` against a multi-member aggregate (app + * extension, both claiming live tables) returns zero schema issues. * - * Pre-aggregate (M2 R6 R1), `db verify` projected the live schema only - * through the app contract. Tables claimed by extensions surfaced as - * `extras` and tripped lenient/strict schema diffs, polluting the - * verify output. The aggregate verifier (M2.5) pre-projects the live - * schema per member before running the family's schema-verify, so each - * member only sees the elements it owns. + * The aggregate verifier pre-projects the live schema per contract-space + * member before running the family's schema-verify, so each member only + * sees the elements it owns — extension-claimed tables never surface as + * `extras` in the app contract's diff. * * Setup mirrors the spec's intent (sub-spec § "Commit 6"): * - app contract claims `user` diff --git a/test/integration/test/cli.emit-command.additional.test.ts b/test/integration/test/cli.emit-command.additional.test.ts deleted file mode 100644 index a65f3cd29489..000000000000 --- a/test/integration/test/cli.emit-command.additional.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { loadConfig } from '@internal/config-loader'; -import { createControlStack } from '@internal/framework-components/control'; -import { timeouts } from '@repo/test-utils'; -import { describe, expect, it } from 'vitest'; -import { - integrationFixtureAppDir, - runOnEngine, - setupIntegrationTestDirectoryFromFixtures, -} from './utils/cli-test-helpers'; - -const fixtureSubdir = 'emit-command'; - -describe('emit command: additional fixtures', () => { - it('emits equivalent hashes from psl and ts providers', { - timeout: timeouts.typeScriptCompilation, - }, async () => { - const tsSetup = setupIntegrationTestDirectoryFromFixtures( - fixtureSubdir, - 'prisma.config.parity-ts.ts', - ); - const pslSetup = setupIntegrationTestDirectoryFromFixtures( - fixtureSubdir, - 'prisma.config.parity-psl.ts', - ); - - try { - const tsRun = await runOnEngine(tsSetup, ['contract', 'emit', '--json']); - expect(tsRun.exitCode).toBe(0); - const tsContract = JSON.parse( - readFileSync(join(tsSetup.outputDir, 'contract.json'), 'utf-8'), - ) as Record; - const storage = tsContract['storage'] as Record; - const storageHash = storage['storageHash']; - const profileHash = tsContract['profileHash']; - expect(storageHash).toMatch(/^[a-f0-9]{64}$/); - expect(profileHash).toMatch(/^[a-f0-9]{64}$/); - const tsProviderStorageHash = storageHash as string; - const tsProviderProfileHash = profileHash as string; - - writeFileSync( - join(pslSetup.testDir, 'schema.prisma'), - readFileSync( - join(integrationFixtureAppDir, 'fixtures', fixtureSubdir, 'schema.parity.psl'), - 'utf-8', - ), - 'utf-8', - ); - - const pslRun = await runOnEngine(pslSetup, ['contract', 'emit', '--json']); - expect(pslRun.exitCode).toBe(0); - - const contractJsonPath = join(pslSetup.testDir, 'output/contract.json'); - const contractDtsPath = join(pslSetup.testDir, 'output/contract.d.ts'); - expect(existsSync(contractJsonPath)).toBe(true); - expect(existsSync(contractDtsPath)).toBe(true); - - const emitted = JSON.parse(readFileSync(contractJsonPath, 'utf-8')); - const emittedStorage = emitted['storage'] as Record; - const emittedStorageHash = emittedStorage['storageHash']; - const emittedProfileHash = emitted['profileHash']; - - expect(emitted).toMatchObject({ - targetFamily: 'sql', - }); - expect(emittedStorageHash).toMatch(/^[a-f0-9]{64}$/); - expect(emittedProfileHash).toMatch(/^[a-f0-9]{64}$/); - expect(emittedStorageHash).toBe(tsProviderStorageHash); - expect(emittedProfileHash).toBe(tsProviderProfileHash); - expect(emitted).not.toHaveProperty('sources'); - expect(emitted).toMatchObject({ - meta: expect.not.objectContaining({ - source: expect.anything(), - sourceId: expect.anything(), - schemaPath: expect.anything(), - }), - }); - } finally { - tsSetup.cleanup(); - pslSetup.cleanup(); - } - }); - - it('renders provider diagnostics when psl provider fails', { - timeout: timeouts.typeScriptCompilation, - }, async () => { - const testSetup = setupIntegrationTestDirectoryFromFixtures( - fixtureSubdir, - 'prisma.config.parity-psl.ts', - ); - - try { - writeFileSync( - join(testSetup.testDir, 'schema.prisma'), - `model Post { - id Int @id - data Unsupported -} -`, - 'utf-8', - ); - - const providerConfig = ( - await loadConfig(join(testSetup.testDir, 'prisma.config.ts')) - ).assertOk().config; - const contractConfig = providerConfig.contract; - expect(contractConfig).toBeDefined(); - - const stack = createControlStack({ - family: providerConfig.family, - target: providerConfig.target, - adapter: providerConfig.adapter, - extensions: providerConfig.extensions ?? [], - }); - const sourceResult = await contractConfig!.source.load({ - composedExtensions: stack.extensions.map((p) => p.id), - composedExtensionContracts: new Map(), - authoringContributions: stack.authoringContributions, - codecLookup: stack.codecLookup, - controlMutationDefaults: stack.controlMutationDefaults, - resolvedInputs: contractConfig!.source.inputs ?? [], - capabilities: stack.capabilities, - }); - - expect(sourceResult.ok).toBe(false); - if (sourceResult.ok) { - throw new Error('Expected source provider to fail for unsupported field type'); - } - expect(sourceResult.failure.summary).toBe('PSL to SQL contract interpretation failed'); - expect(sourceResult.failure.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'PSL_UNSUPPORTED_FIELD_TYPE', - sourceId: './schema.prisma', - span: expect.objectContaining({ - start: expect.objectContaining({ line: 3 }), - }), - }), - ]), - ); - - const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); - expect(run.exitCode).toBe(2); - - const terminal = run.json.at(-1); - const envelope = - terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; - expect(envelope).toMatchObject({ - ok: false, - error: { - code: 'CONTRACT.SOURCE_LOAD_FAILED', - why: 'PSL to SQL contract interpretation failed', - }, - }); - - const reported = JSON.stringify(envelope); - expect(reported).toContain('PSL_UNSUPPORTED_FIELD_TYPE'); - expect(reported).toContain('schema.prisma'); - } finally { - testSetup.cleanup(); - } - }); - - it('rejects plain-object configs that were not created by defineConfig', { - timeout: timeouts.typeScriptCompilation, - }, async () => { - const testSetup = setupIntegrationTestDirectoryFromFixtures( - fixtureSubdir, - 'prisma.config.missing-output.ts', - ); - - try { - const run = await runOnEngine(testSetup, ['contract', 'emit', '--json'], { - settleConfigFailures: true, - }); - expect(run.exitCode).toBe(2); - - const terminal = run.json.at(-1); - const envelope = - terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; - expect(envelope).toMatchObject({ - ok: false, - error: { code: 'CONFIG.VERSION_MARKER_MISSING' }, - }); - expect(existsSync(join(testSetup.testDir, 'src/prisma/contract.json'))).toBe(false); - } finally { - testSetup.cleanup(); - } - }); - - it('emits contract.json and contract.d.ts with Mongo config', { - timeout: timeouts.typeScriptCompilation, - }, async () => { - const testSetup = setupIntegrationTestDirectoryFromFixtures( - fixtureSubdir, - 'prisma.config.mongo.ts', - ); - - try { - writeFileSync( - join(testSetup.testDir, 'contract.prisma'), - `model User { - id ObjectId @id @map("_id") - name String - email String - posts Post[] - @@map("users") -} - -model Post { - id ObjectId @id @map("_id") - title String - authorId ObjectId - author User @relation(fields: [authorId], references: [id]) - @@map("posts") -} -`, - 'utf-8', - ); - - const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); - expect(run.exitCode).toBe(0); - - const contractJsonPath = join(testSetup.outputDir, 'contract.json'); - const contractDtsPath = join(testSetup.outputDir, 'contract.d.ts'); - - expect(existsSync(contractJsonPath)).toBe(true); - expect(existsSync(contractDtsPath)).toBe(true); - - const contractJson = JSON.parse(readFileSync(contractJsonPath, 'utf-8')); - expect(contractJson).toMatchObject({ - targetFamily: 'mongo', - target: 'mongo', - domain: { - namespaces: { - __unbound__: { - models: { - User: expect.objectContaining({ - fields: expect.objectContaining({ - _id: { - type: { kind: 'scalar', codecId: 'mongo/objectId@1' }, - nullable: false, - }, - name: { - type: { kind: 'scalar', codecId: 'mongo/string@1' }, - nullable: false, - }, - }), - }), - Post: expect.objectContaining({ - relations: expect.objectContaining({ - author: expect.objectContaining({ - to: { namespace: '__unbound__', model: 'User' }, - cardinality: 'N:1', - }), - }), - }), - }, - }, - }, - }, - }); - - const contractDts = readFileSync(contractDtsPath, 'utf-8'); - expect(contractDts).toContain('export type Contract'); - expect(contractDts).toContain('CodecTypes'); - - expect(run.presented?.data).toMatchObject({ - ok: true, - storageHash: expect.stringMatching(/^[a-f0-9]{64}$/), - files: { - json: expect.stringContaining('contract.json'), - dts: expect.stringContaining('contract.d.ts'), - }, - }); - } finally { - testSetup.cleanup(); - } - }); - - it('emits contract.json and contract.d.ts with Mongo contract.ts config', { - timeout: timeouts.typeScriptCompilation, - }, async () => { - const testSetup = setupIntegrationTestDirectoryFromFixtures( - fixtureSubdir, - 'prisma.config.mongo-contract-ts.ts', - ); - - try { - const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); - expect(run.exitCode).toBe(0); - - const contractJsonPath = join(testSetup.outputDir, 'contract.json'); - const contractDtsPath = join(testSetup.outputDir, 'contract.d.ts'); - - expect(existsSync(contractJsonPath)).toBe(true); - expect(existsSync(contractDtsPath)).toBe(true); - - const contractJson = JSON.parse(readFileSync(contractJsonPath, 'utf-8')); - expect(contractJson).toMatchObject({ - targetFamily: 'mongo', - target: 'mongo', - storage: { - namespaces: { - __unbound__: { - entries: { - collection: { - users: { - indexes: [{ keys: [{ field: 'email', direction: 1 }], unique: true }], - options: { - collation: { locale: 'en', strength: 2 }, - }, - }, - }, - }, - }, - }, - }, - domain: { - namespaces: { - __unbound__: { - models: { - Task: expect.objectContaining({ - storage: expect.objectContaining({ - collection: 'tasks', - relations: { - comments: { field: 'comments' }, - }, - }), - discriminator: { field: 'type' }, - variants: { - Bug: { value: 'bug' }, - }, - }), - Bug: expect.objectContaining({ - base: { namespace: '__unbound__', model: 'Task' }, - }), - Comment: expect.objectContaining({ - owner: 'Task', - }), - }, - }, - }, - }, - }); - - const contractDts = readFileSync(contractDtsPath, 'utf-8'); - expect(contractDts).toContain("readonly owner: 'Task'"); - expect(contractDts).toMatch(/readonly base:\s*{\s*readonly namespace:/); - expect(contractDts).toContain("readonly discriminator: { readonly field: 'type' }"); - expect(contractDts).toContain('readonly users: {'); - expect(contractDts).toContain('readonly indexes:'); - expect(contractDts).toContain("readonly kind: 'mongo-index'"); - expect(contractDts).toContain("readonly field: 'email'"); - expect(contractDts).toContain('readonly direction: 1'); - expect(contractDts).toContain('readonly unique: true'); - expect(contractDts).toContain('readonly options:'); - expect(contractDts).toContain("readonly kind: 'mongo-collection-options'"); - expect(contractDts).toContain("readonly kind: 'mongo-collation-options'"); - expect(contractDts).toContain("readonly locale: 'en'"); - expect(contractDts).toContain('readonly strength: 2'); - - expect(run.presented?.data).toMatchObject({ - ok: true, - storageHash: expect.stringMatching(/^[a-f0-9]{64}$/), - files: { - json: expect.stringContaining('contract.json'), - dts: expect.stringContaining('contract.d.ts'), - }, - }); - } finally { - testSetup.cleanup(); - } - }); -}); diff --git a/test/integration/test/cli.emit-command.e2e.test.ts b/test/integration/test/cli.emit-command.e2e.test.ts deleted file mode 100644 index 179c5697c418..000000000000 --- a/test/integration/test/cli.emit-command.e2e.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import type { CompletedEnvelope, ErroredEnvelope } from '@prisma/cli-engine'; -import { timeouts } from '@repo/test-utils'; -import { describe, expect, it } from 'vitest'; -import { - type EngineRunResult, - runOnEngine, - setupTestDirectoryFromFixtures, - withTempDir, -} from './utils/cli-test-helpers'; - -// Fixture subdirectory for emit tests -const fixtureSubdir = 'emit'; - -/** What the run settled with, read off the terminal frame of the json stream. */ -function settledEnvelope(run: EngineRunResult): CompletedEnvelope | ErroredEnvelope | undefined { - const terminal = run.json.at(-1); - return terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; -} - -withTempDir(({ createTempDir }) => { - describe('contract emit command (e2e)', () => { - it( - 'emits contract.json and contract.d.ts with canonical command', - async () => { - const testSetup = setupTestDirectoryFromFixtures( - createTempDir, - fixtureSubdir, - 'prisma.config.emit.ts', - ); - const outputDir = testSetup.outputDir; - - const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); - expect(run.exitCode).toBe(0); - - expect(run.presented?.data).toMatchObject({ - ok: true, - storageHash: expect.any(String), - outDir: expect.any(String), - files: { - json: expect.any(String), - dts: expect.any(String), - }, - timings: { - total: expect.any(Number), - }, - }); - - // Verify files were actually created - const contractJsonPath = join(outputDir, 'contract.json'); - const contractDtsPath = join(outputDir, 'contract.d.ts'); - - expect(existsSync(contractJsonPath)).toBe(true); - expect(existsSync(contractDtsPath)).toBe(true); - - // Verify contract.json content - const contractJson = JSON.parse(readFileSync(contractJsonPath, 'utf-8')); - expect(contractJson).toMatchObject({ - targetFamily: 'sql', - _generated: expect.anything(), - }); - - // Verify contract.d.ts content - const contractDts = readFileSync(contractDtsPath, 'utf-8'); - expect(contractDts).toContain('export type Contract'); - expect(contractDts).toContain('CodecTypes'); - - // Verify temporary publication artifacts were cleaned up - expect(readdirSync(outputDir).filter((entry) => entry.endsWith('.tmp'))).toEqual([]); - - // Verify the result document matches the actual files - expect(run.presented?.data).toMatchObject({ - storageHash: contractJson.storage.storageHash, - files: { - json: contractJsonPath, - dts: contractDtsPath, - }, - }); - }, - timeouts.typeScriptCompilation, - ); - - it( - 'outputs JSON when --json flag is provided', - async () => { - const testSetup = setupTestDirectoryFromFixtures( - createTempDir, - fixtureSubdir, - 'prisma.config.emit.ts', - ); - - const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); - expect(run.exitCode).toBe(0); - - expect(run.presented?.data).toMatchObject({ - ok: true, - storageHash: expect.any(String), - outDir: expect.any(String), - files: { - json: expect.any(String), - dts: expect.any(String), - }, - timings: { - total: expect.any(Number), - }, - }); - }, - timeouts.typeScriptCompilation, - ); - - it( - 'throws error with CONFIG.FILE_NOT_FOUND code when config file is missing', - async () => { - // Set up test directory from fixtures (but we'll use a non-existent config) - const testSetup = setupTestDirectoryFromFixtures( - createTempDir, - fixtureSubdir, - 'prisma.config.emit.ts', - ); - - const run = await runOnEngine( - testSetup, - ['contract', 'emit', '--config', 'nonexistent.config.ts', '--json'], - { settleConfigFailures: true }, - ); - - // Config errors should have exit code 2 - expect(run.exitCode).toBe(2); - - const envelope = settledEnvelope(run); - expect(envelope).toMatchObject({ - ok: false, - error: { - code: 'CONFIG.FILE_NOT_FOUND', - summary: expect.any(String), - why: expect.any(String), - }, - }); - expect(envelope?.nextActions.length).toBeGreaterThan(0); - }, - timeouts.typeScriptCompilation, - ); - - it( - 'throws error with CONFIG.CONTRACT_MISSING code when contract config is missing', - async () => { - const testSetup = setupTestDirectoryFromFixtures( - createTempDir, - fixtureSubdir, - 'prisma.config.no-contract.ts', - ); - - const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); - expect(run.exitCode).toBe(2); - - const envelope = settledEnvelope(run); - expect(envelope).toMatchObject({ - ok: false, - error: { - code: 'CONFIG.CONTRACT_MISSING', - summary: expect.any(String), - why: expect.any(String), - }, - }); - expect(envelope?.nextActions.length).toBeGreaterThan(0); - }, - timeouts.spinUpPpgDev, - ); - - it( - 'outputs timings in verbose mode', - async () => { - const testSetup = setupTestDirectoryFromFixtures( - createTempDir, - fixtureSubdir, - 'prisma.config.emit.ts', - ); - - const run = await runOnEngine(testSetup, ['contract', 'emit', '--verbose']); - expect(run.exitCode).toBe(0); - - expect(run.stderr).toContain('Total time'); - }, - timeouts.typeScriptCompilation, - ); - - it( - 'suppresses output in quiet mode', - async () => { - const testSetup = setupTestDirectoryFromFixtures( - createTempDir, - fixtureSubdir, - 'prisma.config.emit.ts', - ); - - const quiet = await runOnEngine(testSetup, ['contract', 'emit', '--quiet']); - expect(quiet.exitCode).toBe(0); - - const normal = await runOnEngine(testSetup, ['contract', 'emit']); - expect(normal.exitCode).toBe(0); - - // The engine's --quiet is a log-level shorthand: it drops the progress - // commentary but still presents the result. - expect(quiet.stderr).not.toContain('Resolving contract source'); - expect(quiet.stderr).not.toContain('Emitting contract...'); - expect(quiet.stderr.length).toBeLessThan(normal.stderr.length); - }, - timeouts.typeScriptCompilation, - ); - }); -}); diff --git a/test/integration/test/cli.emit-command.test.ts b/test/integration/test/cli.emit-command.test.ts index f3ac1cc9cc1e..501976d58582 100644 --- a/test/integration/test/cli.emit-command.test.ts +++ b/test/integration/test/cli.emit-command.test.ts @@ -1,16 +1,23 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { loadConfig } from '@internal/config-loader'; +import { createControlStack } from '@internal/framework-components/control'; import type { CompletedEnvelope, ErroredEnvelope } from '@prisma/cli-engine'; import { timeouts } from '@repo/test-utils'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { type EngineRunResult, + integrationFixtureAppDir, runOnEngine, setupIntegrationTestDirectoryFromFixtures, + setupTestDirectoryFromFixtures, + withTempDir, } from './utils/cli-test-helpers'; -// Fixture subdirectory for emit-command tests +// The 'emit-command' fixtures drive the config-shape and provider cases; +// the 'emit' fixtures drive the canonical end-to-end command runs. const fixtureSubdir = 'emit-command'; +const emitFixtureSubdir = 'emit'; /** What the run settled with, read off the terminal frame of the json stream. */ function settledEnvelope(run: EngineRunResult): CompletedEnvelope | ErroredEnvelope | undefined { @@ -268,3 +275,558 @@ describe('emit command', () => { } }); }); + +withTempDir(({ createTempDir }) => { + describe('contract emit command (e2e)', () => { + it( + 'emits contract.json and contract.d.ts with canonical command', + async () => { + const testSetup = setupTestDirectoryFromFixtures( + createTempDir, + emitFixtureSubdir, + 'prisma.config.emit.ts', + ); + const outputDir = testSetup.outputDir; + + const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); + expect(run.exitCode).toBe(0); + + expect(run.presented?.data).toMatchObject({ + ok: true, + storageHash: expect.any(String), + outDir: expect.any(String), + files: { + json: expect.any(String), + dts: expect.any(String), + }, + timings: { + total: expect.any(Number), + }, + }); + + // Verify files were actually created + const contractJsonPath = join(outputDir, 'contract.json'); + const contractDtsPath = join(outputDir, 'contract.d.ts'); + + expect(existsSync(contractJsonPath)).toBe(true); + expect(existsSync(contractDtsPath)).toBe(true); + + // Verify contract.json content + const contractJson = JSON.parse(readFileSync(contractJsonPath, 'utf-8')); + expect(contractJson).toMatchObject({ + targetFamily: 'sql', + _generated: expect.anything(), + }); + + // Verify contract.d.ts content + const contractDts = readFileSync(contractDtsPath, 'utf-8'); + expect(contractDts).toContain('export type Contract'); + expect(contractDts).toContain('CodecTypes'); + + // Verify temporary publication artifacts were cleaned up + expect(readdirSync(outputDir).filter((entry) => entry.endsWith('.tmp'))).toEqual([]); + + // Verify the result document matches the actual files + expect(run.presented?.data).toMatchObject({ + storageHash: contractJson.storage.storageHash, + files: { + json: contractJsonPath, + dts: contractDtsPath, + }, + }); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'outputs JSON when --json flag is provided', + async () => { + const testSetup = setupTestDirectoryFromFixtures( + createTempDir, + emitFixtureSubdir, + 'prisma.config.emit.ts', + ); + + const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); + expect(run.exitCode).toBe(0); + + expect(run.presented?.data).toMatchObject({ + ok: true, + storageHash: expect.any(String), + outDir: expect.any(String), + files: { + json: expect.any(String), + dts: expect.any(String), + }, + timings: { + total: expect.any(Number), + }, + }); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'throws error with CONFIG.FILE_NOT_FOUND code when config file is missing', + async () => { + // Set up test directory from fixtures (but we'll use a non-existent config) + const testSetup = setupTestDirectoryFromFixtures( + createTempDir, + emitFixtureSubdir, + 'prisma.config.emit.ts', + ); + + const run = await runOnEngine(testSetup, [ + 'contract', + 'emit', + '--config', + 'nonexistent.config.ts', + '--json', + ]); + + // Config errors should have exit code 2 + expect(run.exitCode).toBe(2); + + const envelope = settledEnvelope(run); + expect(envelope).toMatchObject({ + ok: false, + error: { + code: 'CONFIG.FILE_NOT_FOUND', + summary: expect.any(String), + why: expect.any(String), + }, + }); + expect(envelope?.nextActions.length).toBeGreaterThan(0); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'throws error with CONFIG.CONTRACT_MISSING code when contract config is missing', + async () => { + const testSetup = setupTestDirectoryFromFixtures( + createTempDir, + emitFixtureSubdir, + 'prisma.config.no-contract.ts', + ); + + const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); + expect(run.exitCode).toBe(2); + + const envelope = settledEnvelope(run); + expect(envelope).toMatchObject({ + ok: false, + error: { + code: 'CONFIG.CONTRACT_MISSING', + summary: expect.any(String), + why: expect.any(String), + }, + }); + expect(envelope?.nextActions.length).toBeGreaterThan(0); + }, + timeouts.spinUpPpgDev, + ); + + it( + 'outputs timings in verbose mode', + async () => { + const testSetup = setupTestDirectoryFromFixtures( + createTempDir, + emitFixtureSubdir, + 'prisma.config.emit.ts', + ); + + const run = await runOnEngine(testSetup, ['contract', 'emit', '--verbose']); + expect(run.exitCode).toBe(0); + + expect(run.stderr).toContain('Total time'); + }, + timeouts.typeScriptCompilation, + ); + + it( + 'suppresses output in quiet mode', + async () => { + const testSetup = setupTestDirectoryFromFixtures( + createTempDir, + emitFixtureSubdir, + 'prisma.config.emit.ts', + ); + + const quiet = await runOnEngine(testSetup, ['contract', 'emit', '--quiet']); + expect(quiet.exitCode).toBe(0); + + const normal = await runOnEngine(testSetup, ['contract', 'emit']); + expect(normal.exitCode).toBe(0); + + // The engine's --quiet is a log-level shorthand: it drops the progress + // commentary but still presents the result. + expect(quiet.stderr).not.toContain('Resolving contract source'); + expect(quiet.stderr).not.toContain('Emitting contract...'); + expect(quiet.stderr.length).toBeLessThan(normal.stderr.length); + }, + timeouts.typeScriptCompilation, + ); + }); +}); + +describe('emit command: additional fixtures', () => { + it('emits equivalent hashes from psl and ts providers', { + timeout: timeouts.typeScriptCompilation, + }, async () => { + const tsSetup = setupIntegrationTestDirectoryFromFixtures( + fixtureSubdir, + 'prisma.config.parity-ts.ts', + ); + const pslSetup = setupIntegrationTestDirectoryFromFixtures( + fixtureSubdir, + 'prisma.config.parity-psl.ts', + ); + + try { + const tsRun = await runOnEngine(tsSetup, ['contract', 'emit', '--json']); + expect(tsRun.exitCode).toBe(0); + const tsContract = JSON.parse( + readFileSync(join(tsSetup.outputDir, 'contract.json'), 'utf-8'), + ) as Record; + const storage = tsContract['storage'] as Record; + const storageHash = storage['storageHash']; + const profileHash = tsContract['profileHash']; + expect(storageHash).toMatch(/^[a-f0-9]{64}$/); + expect(profileHash).toMatch(/^[a-f0-9]{64}$/); + const tsProviderStorageHash = storageHash as string; + const tsProviderProfileHash = profileHash as string; + + writeFileSync( + join(pslSetup.testDir, 'schema.prisma'), + readFileSync( + join(integrationFixtureAppDir, 'fixtures', fixtureSubdir, 'schema.parity.psl'), + 'utf-8', + ), + 'utf-8', + ); + + const pslRun = await runOnEngine(pslSetup, ['contract', 'emit', '--json']); + expect(pslRun.exitCode).toBe(0); + + const contractJsonPath = join(pslSetup.testDir, 'output/contract.json'); + const contractDtsPath = join(pslSetup.testDir, 'output/contract.d.ts'); + expect(existsSync(contractJsonPath)).toBe(true); + expect(existsSync(contractDtsPath)).toBe(true); + + const emitted = JSON.parse(readFileSync(contractJsonPath, 'utf-8')); + const emittedStorage = emitted['storage'] as Record; + const emittedStorageHash = emittedStorage['storageHash']; + const emittedProfileHash = emitted['profileHash']; + + expect(emitted).toMatchObject({ + targetFamily: 'sql', + }); + expect(emittedStorageHash).toMatch(/^[a-f0-9]{64}$/); + expect(emittedProfileHash).toMatch(/^[a-f0-9]{64}$/); + expect(emittedStorageHash).toBe(tsProviderStorageHash); + expect(emittedProfileHash).toBe(tsProviderProfileHash); + expect(emitted).not.toHaveProperty('sources'); + expect(emitted).toMatchObject({ + meta: expect.not.objectContaining({ + source: expect.anything(), + sourceId: expect.anything(), + schemaPath: expect.anything(), + }), + }); + } finally { + tsSetup.cleanup(); + pslSetup.cleanup(); + } + }); + + it('renders provider diagnostics when psl provider fails', { + timeout: timeouts.typeScriptCompilation, + }, async () => { + const testSetup = setupIntegrationTestDirectoryFromFixtures( + fixtureSubdir, + 'prisma.config.parity-psl.ts', + ); + + try { + writeFileSync( + join(testSetup.testDir, 'schema.prisma'), + `model Post { + id Int @id + data Unsupported +} +`, + 'utf-8', + ); + + const providerConfig = ( + await loadConfig(join(testSetup.testDir, 'prisma.config.ts')) + ).assertOk().config; + const contractConfig = providerConfig.contract; + expect(contractConfig).toBeDefined(); + + const stack = createControlStack({ + family: providerConfig.family, + target: providerConfig.target, + adapter: providerConfig.adapter, + extensions: providerConfig.extensions ?? [], + }); + const sourceResult = await contractConfig!.source.load({ + composedExtensions: stack.extensions.map((p) => p.id), + composedExtensionContracts: new Map(), + authoringContributions: stack.authoringContributions, + codecLookup: stack.codecLookup, + controlMutationDefaults: stack.controlMutationDefaults, + resolvedInputs: contractConfig!.source.inputs ?? [], + capabilities: stack.capabilities, + }); + + expect(sourceResult.ok).toBe(false); + if (sourceResult.ok) { + throw new Error('Expected source provider to fail for unsupported field type'); + } + expect(sourceResult.failure.summary).toBe('PSL to SQL contract interpretation failed'); + expect(sourceResult.failure.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'PSL_UNSUPPORTED_FIELD_TYPE', + sourceId: './schema.prisma', + span: expect.objectContaining({ + start: expect.objectContaining({ line: 3 }), + }), + }), + ]), + ); + + const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); + expect(run.exitCode).toBe(2); + + const terminal = run.json.at(-1); + const envelope = + terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; + expect(envelope).toMatchObject({ + ok: false, + error: { + code: 'CONTRACT.SOURCE_LOAD_FAILED', + why: 'PSL to SQL contract interpretation failed', + }, + }); + + const reported = JSON.stringify(envelope); + expect(reported).toContain('PSL_UNSUPPORTED_FIELD_TYPE'); + expect(reported).toContain('schema.prisma'); + } finally { + testSetup.cleanup(); + } + }); + + it('rejects plain-object configs that were not created by defineConfig', { + timeout: timeouts.typeScriptCompilation, + }, async () => { + const testSetup = setupIntegrationTestDirectoryFromFixtures( + fixtureSubdir, + 'prisma.config.missing-output.ts', + ); + + try { + const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); + expect(run.exitCode).toBe(2); + + const terminal = run.json.at(-1); + const envelope = + terminal !== undefined && terminal.kind === 'result' ? terminal.envelope : undefined; + expect(envelope).toMatchObject({ + ok: false, + error: { code: 'CONFIG.VERSION_MARKER_MISSING' }, + }); + expect(existsSync(join(testSetup.testDir, 'src/prisma/contract.json'))).toBe(false); + } finally { + testSetup.cleanup(); + } + }); + + it('emits contract.json and contract.d.ts with Mongo config', { + timeout: timeouts.typeScriptCompilation, + }, async () => { + const testSetup = setupIntegrationTestDirectoryFromFixtures( + fixtureSubdir, + 'prisma.config.mongo.ts', + ); + + try { + writeFileSync( + join(testSetup.testDir, 'contract.prisma'), + `model User { + id ObjectId @id @map("_id") + name String + email String + posts Post[] + @@map("users") +} + +model Post { + id ObjectId @id @map("_id") + title String + authorId ObjectId + author User @relation(fields: [authorId], references: [id]) + @@map("posts") +} +`, + 'utf-8', + ); + + const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); + expect(run.exitCode).toBe(0); + + const contractJsonPath = join(testSetup.outputDir, 'contract.json'); + const contractDtsPath = join(testSetup.outputDir, 'contract.d.ts'); + + expect(existsSync(contractJsonPath)).toBe(true); + expect(existsSync(contractDtsPath)).toBe(true); + + const contractJson = JSON.parse(readFileSync(contractJsonPath, 'utf-8')); + expect(contractJson).toMatchObject({ + targetFamily: 'mongo', + target: 'mongo', + domain: { + namespaces: { + __unbound__: { + models: { + User: expect.objectContaining({ + fields: expect.objectContaining({ + _id: { + type: { kind: 'scalar', codecId: 'mongo/objectId@1' }, + nullable: false, + }, + name: { + type: { kind: 'scalar', codecId: 'mongo/string@1' }, + nullable: false, + }, + }), + }), + Post: expect.objectContaining({ + relations: expect.objectContaining({ + author: expect.objectContaining({ + to: { namespace: '__unbound__', model: 'User' }, + cardinality: 'N:1', + }), + }), + }), + }, + }, + }, + }, + }); + + const contractDts = readFileSync(contractDtsPath, 'utf-8'); + expect(contractDts).toContain('export type Contract'); + expect(contractDts).toContain('CodecTypes'); + + expect(run.presented?.data).toMatchObject({ + ok: true, + storageHash: expect.stringMatching(/^[a-f0-9]{64}$/), + files: { + json: expect.stringContaining('contract.json'), + dts: expect.stringContaining('contract.d.ts'), + }, + }); + } finally { + testSetup.cleanup(); + } + }); + + it('emits contract.json and contract.d.ts with Mongo contract.ts config', { + timeout: timeouts.typeScriptCompilation, + }, async () => { + const testSetup = setupIntegrationTestDirectoryFromFixtures( + fixtureSubdir, + 'prisma.config.mongo-contract-ts.ts', + ); + + try { + const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); + expect(run.exitCode).toBe(0); + + const contractJsonPath = join(testSetup.outputDir, 'contract.json'); + const contractDtsPath = join(testSetup.outputDir, 'contract.d.ts'); + + expect(existsSync(contractJsonPath)).toBe(true); + expect(existsSync(contractDtsPath)).toBe(true); + + const contractJson = JSON.parse(readFileSync(contractJsonPath, 'utf-8')); + expect(contractJson).toMatchObject({ + targetFamily: 'mongo', + target: 'mongo', + storage: { + namespaces: { + __unbound__: { + entries: { + collection: { + users: { + indexes: [{ keys: [{ field: 'email', direction: 1 }], unique: true }], + options: { + collation: { locale: 'en', strength: 2 }, + }, + }, + }, + }, + }, + }, + }, + domain: { + namespaces: { + __unbound__: { + models: { + Task: expect.objectContaining({ + storage: expect.objectContaining({ + collection: 'tasks', + relations: { + comments: { field: 'comments' }, + }, + }), + discriminator: { field: 'type' }, + variants: { + Bug: { value: 'bug' }, + }, + }), + Bug: expect.objectContaining({ + base: { namespace: '__unbound__', model: 'Task' }, + }), + Comment: expect.objectContaining({ + owner: 'Task', + }), + }, + }, + }, + }, + }); + + const contractDts = readFileSync(contractDtsPath, 'utf-8'); + expect(contractDts).toContain("readonly owner: 'Task'"); + expect(contractDts).toMatch(/readonly base:\s*{\s*readonly namespace:/); + expect(contractDts).toContain("readonly discriminator: { readonly field: 'type' }"); + expect(contractDts).toContain('readonly users: {'); + expect(contractDts).toContain('readonly indexes:'); + expect(contractDts).toContain("readonly kind: 'mongo-index'"); + expect(contractDts).toContain("readonly field: 'email'"); + expect(contractDts).toContain('readonly direction: 1'); + expect(contractDts).toContain('readonly unique: true'); + expect(contractDts).toContain('readonly options:'); + expect(contractDts).toContain("readonly kind: 'mongo-collection-options'"); + expect(contractDts).toContain("readonly kind: 'mongo-collation-options'"); + expect(contractDts).toContain("readonly locale: 'en'"); + expect(contractDts).toContain('readonly strength: 2'); + + expect(run.presented?.data).toMatchObject({ + ok: true, + storageHash: expect.stringMatching(/^[a-f0-9]{64}$/), + files: { + json: expect.stringContaining('contract.json'), + dts: expect.stringContaining('contract.d.ts'), + }, + }); + } finally { + testSetup.cleanup(); + } + }); +}); diff --git a/test/integration/test/cli.migrate-drift-check.e2e.test.ts b/test/integration/test/cli.migrate-drift-check.e2e.test.ts index 8864352f46be..b35b2451d1e6 100644 --- a/test/integration/test/cli.migrate-drift-check.e2e.test.ts +++ b/test/integration/test/cli.migrate-drift-check.e2e.test.ts @@ -6,10 +6,10 @@ import { withTempDir } from './utils/cli-test-helpers'; import { type JourneyContext, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runDbInit, runMigrate, - runMigrationPlanAndEmit, setupJourney, swapContract, } from './utils/journey-test-helpers'; @@ -65,7 +65,7 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); const firstApply = await runMigrate(ctx, ['--json']); expect(firstApply.exitCode).toBe(0); const firstJson = parseJsonOutput<{ markerHash: string }>(firstApply); @@ -74,7 +74,7 @@ withTempDir(({ createTempDir }) => { removeAppMigrationBundles(ctx); swapContract(ctx, 'contract-additive'); expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'replacement'])).exitCode).toBe( + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'replacement'])).exitCode).toBe( 0, ); @@ -99,7 +99,7 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); expect((await runMigrate(ctx, ['--json'])).exitCode).toBe(0); const second = await runMigrate(ctx, ['--json']); expect(second.exitCode).toBe(0); @@ -117,7 +117,7 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); const apply = await runMigrate(ctx, ['--json']); expect(apply.exitCode).toBe(0); }); @@ -152,13 +152,13 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); expect((await runMigrate(ctx)).exitCode).toBe(0); removeAppMigrationBundles(ctx); swapContract(ctx, 'contract-additive'); expect((await runContractEmit(ctx)).exitCode).toBe(0); - const replacementPlan = await runMigrationPlanAndEmit(ctx, ['--name', 'replacement']); + const replacementPlan = await planMigrationAndSelfEmit(ctx, ['--name', 'replacement']); expect(replacementPlan.exitCode).toBe(0); const bundleDir = readdirSync(appMigrationsDir(ctx)) .filter((d) => d !== 'refs' && !d.startsWith('.')) @@ -181,12 +181,12 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); expect((await runMigrate(ctx)).exitCode).toBe(0); swapContract(ctx, 'contract-additive'); expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'add-name'])).exitCode).toBe(0); + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'add-name'])).exitCode).toBe(0); swapContract(ctx, 'contract-phone'); expect((await runContractEmit(ctx)).exitCode).toBe(0); diff --git a/test/integration/test/cli.migrate-external-space.e2e.test.ts b/test/integration/test/cli.migrate-external-space.e2e.test.ts index 4bb94599c7c1..9c3434a3d2e9 100644 --- a/test/integration/test/cli.migrate-external-space.e2e.test.ts +++ b/test/integration/test/cli.migrate-external-space.e2e.test.ts @@ -20,28 +20,23 @@ * them and expects migrate to succeed afterwards. */ -import { execFile } from 'node:child_process'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { promisify } from 'node:util'; import { storageHashHex } from '@internal/framework-components/control'; import { timeouts, withClient, withDevDatabase } from '@repo/test-utils'; -import { join, resolve } from 'pathe'; +import { join } from 'pathe'; import { describe, expect, it } from 'vitest'; import { TEST_EXTERNAL_HEAD_HASH, TEST_EXTERNAL_SPACE_ID, } from './contract-space-fixture/external-space'; import { - appendImplicitMigrationPlanFrom, type EngineRunResult, + runMigrationFile, runOnEngine, setupTestDirectoryFromFixtures, withTempDir, } from './utils/cli-test-helpers'; -const execFileAsync = promisify(execFile); -const TSX_BIN = resolve(__dirname, '../../../node_modules/.bin/tsx'); - interface Project { readonly testDir: string; readonly configPath: string; @@ -77,15 +72,17 @@ async function selfEmitLatestMigration(testDir: string): Promise { const latest = getLatestMigrationDir(testDir); if (!latest) return; const migrationTs = join(testDir, 'migrations', 'app', latest, 'migration.ts'); - await execFileAsync(TSX_BIN, [migrationTs], { cwd: testDir }); + const emitted = await runMigrationFile(migrationTs, [], testDir); + if (emitted.exitCode !== 0) { + throw new Error(`migration.ts self-emit failed (exit ${emitted.exitCode}): ${emitted.stderr}`); + } } async function runMigrationPlan( project: Project, args: readonly string[], ): Promise { - const planArgs = appendImplicitMigrationPlanFrom(project.testDir, args); - const run = await runOnEngine(project, ['migration', 'plan', ...planArgs]); + const run = await runOnEngine(project, ['migration', 'plan', ...args]); if (run.exitCode === 0) { await selfEmitLatestMigration(project.testDir); } diff --git a/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts b/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts index 070bc22399d7..b8ba27bbd5b9 100644 --- a/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts +++ b/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts @@ -1,21 +1,17 @@ -import { execFile } from 'node:child_process'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { promisify } from 'node:util'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { timeouts, withDevDatabase } from '@repo/test-utils'; -import { dirname, join, resolve } from 'pathe'; +import { dirname, join } from 'pathe'; import { describe, expect, it } from 'vitest'; import { - appendImplicitMigrationPlanFrom, type EngineRunResult, + runMigrationFile, runOnEngine, setupTestDirectoryFromFixtures, withTempDir, } from './utils/cli-test-helpers'; import { replaceInFileOrThrow } from './utils/contract-fixture-editing'; -const execFileAsync = promisify(execFile); -const TSX_BIN = resolve(__dirname, '../../../node_modules/.bin/tsx'); const fixtureSubdir = 'migration-apply'; interface Project { @@ -53,12 +49,14 @@ async function selfEmitLatestMigration(testDir: string): Promise { const latest = getLatestMigrationDir(testDir); if (!latest) return; const migrationTs = join(testDir, 'migrations', 'app', latest, 'migration.ts'); - await execFileAsync(TSX_BIN, [migrationTs], { cwd: testDir }); + const emitted = await runMigrationFile(migrationTs, [], testDir); + if (emitted.exitCode !== 0) { + throw new Error(`migration.ts self-emit failed (exit ${emitted.exitCode}): ${emitted.stderr}`); + } } async function runMigrationPlan(project: Project, args: readonly string[]): Promise { - const planArgs = appendImplicitMigrationPlanFrom(project.testDir, args); - const run = await runOnEngine(project, ['migration', 'plan', ...planArgs]); + const run = await runOnEngine(project, ['migration', 'plan', ...args]); expect(run.exitCode, `migration plan failed:\n${run.stderr}`).toBe(0); await selfEmitLatestMigration(project.testDir); } diff --git a/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts index 623beb7566f4..916f749ed3e1 100644 --- a/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts +++ b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts @@ -9,13 +9,13 @@ import { getMigrationDirs, type JourneyContext, parseJsonOutput, + planMigrationAndSelfEmit, runContractEmit, runDbInit, runDbUpdate, runMigrate, - runMigrationEmit, runMigrationPlan, - runMigrationPlanAndEmit, + selfEmitMigration, setupJourney, swapContract, } from './utils/journey-test-helpers'; @@ -103,7 +103,7 @@ function listAppMigrationBundleDirs(ctx: JourneyContext): string[] { async function emitAllAppMigrations(ctx: JourneyContext): Promise { for (const dir of listAppMigrationBundleDirs(ctx)) { - const result = await runMigrationEmit(ctx, ['--dir', `migrations/app/${dir}`]); + const result = await selfEmitMigration(ctx, ['--dir', `migrations/app/${dir}`]); expect(result.exitCode, `emit ${dir}`).toBe(0); } } @@ -325,7 +325,7 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode).toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; expect((await runMigrate(ctx)).exitCode).toBe(0); @@ -356,7 +356,7 @@ withTempDir(({ createTempDir }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); expect( - (await runMigrationPlanAndEmit(ctx, ['--name', 'init', '--json'])).exitCode, + (await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json'])).exitCode, ).toBe(0); expect((await runMigrate(ctx)).exitCode).toBe(0); diff --git a/test/integration/test/cli.ref-pointer-integration.e2e.test.ts b/test/integration/test/cli.ref-pointer-integration.e2e.test.ts index b2f460780350..b3bae9662ea4 100644 --- a/test/integration/test/cli.ref-pointer-integration.e2e.test.ts +++ b/test/integration/test/cli.ref-pointer-integration.e2e.test.ts @@ -10,8 +10,8 @@ import { type EngineCommandResult, getLatestMigrationDir, type JourneyContext, + planMigrationAndSelfEmit, runContractEmit, - runMigrationPlanAndEmit, runOnEngine, } from './utils/journey-test-helpers'; @@ -66,7 +66,7 @@ async function seedPlannedMigration( if (emit.exitCode !== 0) { throw new Error(`seedPlannedMigration: contract emit exited ${emit.exitCode}\n${emit.stderr}`); } - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'initial', '--no-color']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'initial', '--no-color']); if (plan.exitCode !== 0) { throw new Error(`seedPlannedMigration: migration plan exited ${plan.exitCode}\n${plan.stderr}`); } diff --git a/test/integration/test/utils/cli-test-helpers.ts b/test/integration/test/utils/cli-test-helpers.ts index 3efd97f27814..dc5a29e08752 100644 --- a/test/integration/test/utils/cli-test-helpers.ts +++ b/test/integration/test/utils/cli-test-helpers.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { copyFileSync, existsSync, @@ -5,18 +6,18 @@ import { readdirSync, readFileSync, rmSync, - statSync, writeFileSync, } from 'node:fs'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { Writable } from 'node:stream'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { loadOrmConfig, ormCommandFamily } from '@internal/cli'; +import { MigrationCLI } from '@internal/cli/migration-cli'; import type { Contract } from '@internal/contract/types'; -import type { MigrationMetadata } from '@internal/migration-tools/metadata'; import type { SqlStorage } from '@internal/sql-contract/types'; import { PostgresContractSerializer } from '@internal/target-postgres/runtime'; import type { EngineEvent, MountedTree, PresentedResult, StreamEvent } from '@prisma/cli-engine'; -import { createTestCli } from '@prisma/cli-engine/testing'; +import { createTestCli, type TestCli } from '@prisma/cli-engine/testing'; import { afterEach, beforeEach } from 'vitest'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -39,12 +40,6 @@ export interface EngineRunResult { export interface RunOnEngineOptions { /** Simulate piped stdout (isTTY=false) to exercise the engine's json auto-selection. */ readonly isTTY?: boolean; - /** - * Run the config file through the engine's loader rather than pre-evaluating - * it, so a file that does not evaluate settles as the run's error instead of - * throwing here. - */ - readonly settleConfigFailures?: boolean; } /** @@ -72,47 +67,75 @@ export function ormEngineMount(): { readonly commands: MountedTree; readonly groups: Record; } { - const commands = Object.fromEntries( - Object.entries(ormCommandFamily.commands).map(([path, command]) => [ - path === 'init' ? 'orm init' : path, - command, - ]), - ); - return { commands, groups: groupsFor(commands) }; + if (cachedMount === undefined) { + const commands = Object.fromEntries( + Object.entries(ormCommandFamily.commands).map(([path, command]) => [ + path === 'init' ? 'orm init' : path, + command, + ]), + ); + cachedMount = { commands, groups: groupsFor(commands) }; + } + return cachedMount; } +let cachedMount: + | { + readonly commands: MountedTree; + readonly groups: Record; + } + | undefined; + /** - * Runs one CLI invocation through the engine's own harness. - * - * The harness takes config as an already-evaluated record and has no config - * option on `run()`, so the project's `prisma.config.ts` is evaluated here - * — through the same adapter the binary uses — and a fresh `TestCli` is built - * per run. That is what lets a step which writes or rewrites the config be - * picked up by the next one. The project directory is passed as `cwd` rather - * than chdir'ed into, so nothing about the run is process-global. + * One `TestCli` per project, keyed by `testDir` + `configPath`. The engine's + * `loadConfig` hook runs on every invocation that needs config (the same + * adapter the binary uses), so a step that writes or rewrites the config file + * is picked up by the next command without rebuilding the harness — and a + * config that does not evaluate settles as the run's error, exactly as it + * would for a user. A test that swaps to a whole new project directory gets a + * fresh harness through the key. + */ +const engineCliCache = new Map(); + +/** + * Drops every cached harness for a project directory. Called from the + * temp-dir cleanup paths so a worker does not retain a `TestCli` (and its + * `loadConfig` closure) for every deleted directory it ever ran against. + */ +export function evictEngineCli(testDir: string): void { + for (const key of engineCliCache.keys()) { + if (key.startsWith(`${testDir}\u0000`)) { + engineCliCache.delete(key); + } + } +} + +/** + * Runs one CLI invocation through the engine's own harness. The project + * directory is passed as `cwd` rather than chdir'ed into, so nothing about + * the run is process-global. */ export async function runOnEngine( project: { readonly testDir: string; readonly configPath: string }, argv: readonly string[], options?: RunOnEngineOptions, ): Promise { - const { commands, groups } = ormEngineMount(); - const spec = { - commandFamilies: [ormCommandFamily], - commands, - groups, - }; - - const cli = options?.settleConfigFailures - ? createTestCli({ - ...spec, - loadConfig: (configPath) => - loadOrmConfig({ - cwd: project.testDir, - configPath: configPath ?? project.configPath, - }), - }) - : createTestCli({ ...spec, config: await evaluatedSections(project) }); + const key = `${project.testDir}\u0000${project.configPath}`; + let cli = engineCliCache.get(key); + if (cli === undefined) { + const { commands, groups } = ormEngineMount(); + cli = createTestCli({ + commandFamilies: [ormCommandFamily], + commands, + groups, + loadConfig: (configPath) => + loadOrmConfig({ + cwd: project.testDir, + configPath: configPath ?? project.configPath, + }), + }); + engineCliCache.set(key, cli); + } const run = await cli.run([...argv], { cwd: project.testDir, @@ -129,20 +152,6 @@ export async function runOnEngine( }; } -async function evaluatedSections(project: { - readonly testDir: string; - readonly configPath: string; -}): Promise>> { - const loaded = await loadOrmConfig({ cwd: project.testDir, configPath: project.configPath }); - const fileLevel = loaded.diagnostics.find((entry) => entry.section === null); - if (fileLevel !== undefined) { - throw new Error( - `runOnEngine: ${project.configPath} did not evaluate: ${fileLevel.diagnostic.code} — ${fileLevel.diagnostic.summary}`, - ); - } - return loaded.sections; -} - /** * Pins a generated test project to the workspace import root. * @@ -332,6 +341,7 @@ export function setupIntegrationTestDirectoryFromFixtures( } const cleanup = () => { + evictEngineCli(testDir); if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } @@ -388,6 +398,7 @@ export function setupTestDirectory(): { const configPath = join(testDir, 'prisma.config.ts'); const cleanup = () => { + evictEngineCli(testDir); if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } @@ -437,65 +448,6 @@ export async function setupDbTestFixture( return { testSetup, configPath }; } -function readMigrationGraphTipHash(testDir: string): string | null { - const appDir = join(testDir, 'migrations', 'app'); - if (!existsSync(appDir)) { - return null; - } - let newestDir: string | null = null; - let newestMtime = 0; - for (const dir of readdirSync(appDir)) { - if (dir.startsWith('.') || dir === 'refs') { - continue; - } - const dirPath = join(appDir, dir); - if (!statSync(dirPath).isDirectory()) { - continue; - } - const manifestPath = join(dirPath, 'migration.json'); - if (!existsSync(manifestPath)) { - continue; - } - const mtime = statSync(dirPath).mtimeMs; - if (mtime > newestMtime) { - newestMtime = mtime; - newestDir = dir; - } - } - if (newestDir === null) { - return null; - } - const manifest = JSON.parse( - readFileSync(join(appDir, newestDir, 'migration.json'), 'utf-8'), - ) as MigrationMetadata; - return manifest.to; -} - -/** - * Supplies an implicit `--from` for integration tests that predate the db-ref - * default: when the db ref is absent but the on-disk graph is not, plan from - * the graph tip (matching pre-change CLI behaviour). Callers that exercise the - * implicit db default leave the db ref in place; greenfield scenarios clear it - * with {@link clearDbRefForGreenfieldPlan}. - */ -export function appendImplicitMigrationPlanFrom( - testDir: string, - extraArgs: readonly string[], -): readonly string[] { - if (extraArgs.some((arg) => arg === '--from' || arg.startsWith('--from='))) { - return extraArgs; - } - const dbRefPath = join(testDir, 'migrations', 'app', 'refs', 'db.json'); - if (existsSync(dbRefPath)) { - return extraArgs; - } - const tipHash = readMigrationGraphTipHash(testDir); - if (tipHash !== null) { - return [...extraArgs, '--from', tipHash]; - } - return extraArgs; -} - export function clearDbRefForGreenfieldPlan(testDir: string): void { const refsDir = join(testDir, 'migrations', 'app', 'refs'); if (!existsSync(refsDir)) { @@ -508,6 +460,83 @@ export function clearDbRefForGreenfieldPlan(testDir: string): void { } } +/** What running a `migration.ts` file reports back. */ +export interface MigrationFileRunResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +class CapturingWritable extends Writable { + private readonly chunks: Buffer[] = []; + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.chunks.push(Buffer.from(chunk)); + callback(); + } + + get text(): string { + return Buffer.concat(this.chunks).toString('utf-8'); + } +} + +/** + * Runs a scaffolded `migration.ts` in-process, replacing the old + * `execFile(tsx, [migration.ts])` pattern. Each spawn paid a node boot, an + * esbuild transform, and a cold import of the workspace packages — one to + * three seconds per migration step on CI, multiplied across every journey. + * + * Vitest's own transformer imports the file (the `?v=` query + * defeats the ESM module cache when a test rewrites the same migration.ts), + * and the module-scope `MigrationCLI.run(import.meta.url, M)` call inside the + * file no-ops because the file is not the process entrypoint. The helper then + * invokes `MigrationCLI.run` itself with an argv whose second element is the + * migration path, which satisfies the entrypoint guard, and with injected + * capture streams — the same in-process testability surface the CLI package's + * own tests use. + * + * Two process globals are saved and restored around the run, because the + * migration-file CLI is written for a process it owns: config discovery walks + * up from `process.cwd()` (so the helper chdirs to `cwd`, exactly where the + * old spawn pointed the child), and a failing run sets `process.exitCode` + * (which must not leak into the vitest worker's exit status when a test + * asserts on a migration failure). Tests within a worker run sequentially + * under the forks pool, so the temporary chdir cannot interleave with another + * test. + */ +export async function runMigrationFile( + migrationTs: string, + args: readonly string[] = [], + cwd?: string, +): Promise { + const content = readFileSync(migrationTs, 'utf-8'); + const version = createHash('sha1').update(content).digest('hex').slice(0, 12); + const migrationUrl = pathToFileURL(migrationTs).href; + const module = (await import(`${migrationUrl}?v=${version}`)) as { + default: Parameters[1]; + }; + const stdout = new CapturingWritable(); + const stderr = new CapturingWritable(); + const previousExitCode = process.exitCode; + const previousCwd = process.cwd(); + try { + process.chdir(cwd ?? dirname(migrationTs)); + const exitCode = await MigrationCLI.run(migrationUrl, module.default, { + argv: [process.execPath, migrationTs, ...args], + stdout, + stderr, + }); + return { exitCode, stdout: stdout.text, stderr: stderr.text }; + } finally { + process.chdir(previousCwd); + process.exitCode = previousExitCode; + } +} + /** * Decorator that wraps test suites to automatically manage temporary directory cleanup. * Creates directories within the fixture app directory so jiti can resolve workspace packages. @@ -538,6 +567,7 @@ export function withTempDir(callback: (context: { createTempDir: () => string }) // Clean up all directories created during this test for (const dir of tempDirs) { try { + evictEngineCli(dir); if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); } diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index e4d9eb0ed1ab..ff97eb914389 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -6,7 +6,6 @@ * so journey tests stay concise and readable. */ -import { execFile } from 'node:child_process'; import { copyFileSync, existsSync, @@ -16,20 +15,16 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { promisify } from 'node:util'; import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import type { EngineEvent, PresentedResult, StreamEvent } from '@prisma/cli-engine'; import type { Diagnostic } from '@prisma/cli-engine/protocol'; import { createDevDatabase, timeouts, withClient } from '@repo/test-utils'; -import { isAbsolute, join, resolve } from 'pathe'; +import { isAbsolute, join } from 'pathe'; import { afterAll, beforeAll } from 'vitest'; -const execFileAsync = promisify(execFile); -const TSX_BIN = resolve(import.meta.dirname, '../../../../node_modules/.bin/tsx'); - import { - appendImplicitMigrationPlanFrom, runOnEngine as runCommandOnEngine, + runMigrationFile, writeProjectManifest, } from './cli-test-helpers'; @@ -357,11 +352,7 @@ export async function runMigrationPlan( extraArgs: readonly string[] = [], options?: RunCommandOptions, ): Promise { - return runOnEngine( - ctx, - ['migration', 'plan', ...appendImplicitMigrationPlanFrom(ctx.testDir, extraArgs)], - options, - ); + return runOnEngine(ctx, ['migration', 'plan', ...extraArgs], options); } export async function runMigrationNew( @@ -486,17 +477,16 @@ export function injectMigrationSqlDbSetup(scaffold: string): string { } /** - * Self-emits a migration package by running its `migration.ts` directly with - * `tsx`. The migration.ts invokes `MigrationCLI.run(import.meta.url, …)`, - * which serializes the class's `operations` to `ops.json` and attests - * `migration.json` in the package directory. + * Runs a migration package's `migration.ts` in-process so it writes its own + * `ops.json` and attested `migration.json` into the package directory + * (self-emission; see {@link runMigrationFile}). * - * Accepts a trailing `--dir ` pair (relative to `ctx.testDir`) to stay - * source-compatible with the old `migration emit --dir` callsites. Any other - * arguments are forwarded to the spawned process so tests can pass flags like + * Accepts a trailing `--dir ` pair (relative to `ctx.testDir`) naming + * the migration package whose `migration.ts` to run. Any other arguments are + * forwarded to the migration-file CLI so tests can pass flags like * `--dry-run`. */ -export async function runMigrationEmit( +export async function selfEmitMigration( ctx: JourneyContext, extraArgs: readonly string[] = [], ): Promise { @@ -504,7 +494,7 @@ export async function runMigrationEmit( const dirIdx = args.indexOf('--dir'); if (dirIdx < 0 || dirIdx === args.length - 1) { throw new Error( - 'runMigrationEmit requires `--dir ` so we know which migration.ts to execute', + 'selfEmitMigration requires `--dir ` so we know which migration.ts to execute', ); } const dirArg = args[dirIdx + 1]!; @@ -513,40 +503,32 @@ export async function runMigrationEmit( const migrationTs = isAbsolute(dirArg) ? join(dirArg, 'migration.ts') : join(ctx.testDir, dirArg, 'migration.ts'); - try { - const { stdout, stderr } = await execFileAsync(TSX_BIN, [migrationTs, ...args], { - cwd: ctx.testDir, - }); - return { exitCode: 0, stdout, stderr }; - } catch (error) { - const e = error as { stdout?: string; stderr?: string; code?: number }; - return { exitCode: e.code ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' }; - } + return runMigrationFile(migrationTs, args, ctx.testDir); } /** - * Runs `migration plan` and then self-emits the resulting draft `migration.ts` - * via `tsx`. Mirrors the old `migration plan`-auto-emits behaviour that journey - * tests relied on before the `migration emit` command was removed. + * Runs `migration plan` (which scaffolds a draft migration.ts), then runs + * that migration.ts in-process so it writes its own ops.json and + * migration.json (self-emission; see {@link runMigrationFile}). * - * Returns the original plan result (so JSON callers still see the plan's - * stdout). If plan fails, emit is skipped. If emit fails, the returned result - * carries the emit failure via `exitCode`/`stderr`. + * Returns the plan result (so JSON callers still see the plan's stdout). If + * plan fails, the self-emit is skipped. If the self-emit fails, the returned + * result carries that failure via `exitCode`/`stderr`. */ -export async function runMigrationPlanAndEmit( +export async function planMigrationAndSelfEmit( ctx: JourneyContext, extraArgs: readonly string[] = [], -): Promise { +): Promise { const planResult = await runMigrationPlan(ctx, extraArgs); if (planResult.exitCode !== 0) return planResult; const latest = getLatestMigrationDir(ctx); if (!latest) return planResult; - const emitResult = await runMigrationEmit(ctx, ['--dir', `migrations/app/${latest}`]); + const emitResult = await selfEmitMigration(ctx, ['--dir', `migrations/app/${latest}`]); if (emitResult.exitCode !== 0) { return { ...planResult, exitCode: emitResult.exitCode, - stderr: `${planResult.stderr}\n[runMigrationPlanAndEmit] migration emit failed (exit ${emitResult.exitCode}):\n${emitResult.stderr}`, + stderr: `${planResult.stderr}\n[planMigrationAndSelfEmit] migration.ts self-emit failed (exit ${emitResult.exitCode}):\n${emitResult.stderr}`, }; } return planResult; @@ -570,9 +552,7 @@ export async function runContractEmitWithConfig( configPath: string, extraArgs: readonly string[] = [], ): Promise { - return runCommandOnEngine({ testDir, configPath }, ['contract', 'emit', ...extraArgs], { - settleConfigFailures: true, - }); + return runCommandOnEngine({ testDir, configPath }, ['contract', 'emit', ...extraArgs]); } export async function runFormat( @@ -599,49 +579,23 @@ export async function runDbVerifyWithDb( // --------------------------------------------------------------------------- /** - * The document a step's `--json` run produced. - * - * The two shells frame it differently and this unwraps both. The commander - * writes the document itself; the engine writes one `StreamEvent` per line and - * carries the document inside the terminal `result` frame's envelope — under - * `result` when the command completed and under `error` when it did not, which - * is where the commander put its error envelope too. - */ -/** - * The `--json` document a step produced. A step run through the engine already - * carries it as the presented result — the engine's json mode writes NDJSON - * frames rather than the bare document, so the document is read from the run - * rather than parsed back out of stdout. + * The `--json` document a step produced. A step run through the engine + * carries it as the presented result when the handler presented; a step that + * settled without presenting (a config or orchestration error) carries the + * document in the terminal `result` frame's envelope — under `result` when + * the command completed and under `error` when it did not. */ -export function parseJsonOutput>( - result: CommandResult | EngineCommandResult, -): T { - const presented = 'presented' in result ? result.presented : undefined; - if (presented !== undefined) { - return (presented.presentation.json ?? presented.data) as T; - } - const output = result.stdout.trim(); - const parsed = lastJsonValue(output); - if (parsed === undefined) { - throw new Error(`Failed to parse JSON from command output:\n${output}`); +export function parseJsonOutput>(result: EngineCommandResult): T { + if (result.presented !== undefined) { + return (result.presented.presentation.json ?? result.presented.data) as T; } - const document = frameDocument(parsed); - return (document === undefined ? parsed : document) as T; -} - -function lastJsonValue(output: string): unknown { - try { - return JSON.parse(output); - } catch { - const lines = output.split('\n'); - for (let i = lines.length - 1; i >= 0; i--) { - const candidate = lines.slice(i).join('\n').trim(); - try { - return JSON.parse(candidate); - } catch {} - } - return undefined; + const terminal = result.json.at(-1); + if (terminal === undefined || terminal.kind !== 'result') { + throw new Error( + `Step produced no terminal result frame (exit ${result.exitCode}):\n${result.stderr}`, + ); } + return (terminal.envelope.ok ? terminal.envelope.result : terminal.envelope.error) as T; } /** @@ -670,18 +624,6 @@ export function engineDiagnosticCodes(run: EngineCommandResult): readonly string return presented.diagnostics.map((diagnostic) => diagnostic.code); } -function frameDocument(parsed: unknown): unknown { - if (typeof parsed !== 'object' || parsed === null) { - return undefined; - } - const frame = parsed as { kind?: unknown; envelope?: unknown }; - if (frame.kind !== 'result' || typeof frame.envelope !== 'object' || frame.envelope === null) { - return undefined; - } - const envelope = frame.envelope as { ok?: unknown; result?: unknown; error?: unknown }; - return envelope.ok === true ? envelope.result : envelope.error; -} - export { EMPTY_CONTRACT_HASH }; export interface MigrationStatusMigrationJson { @@ -735,7 +677,7 @@ export function engineError(result: EngineCommandResult): Diagnostic | undefined return terminal.envelope.error; } -export function parseMigrationStatusJson(result: CommandResult): MigrationStatusJson { +export function parseMigrationStatusJson(result: EngineCommandResult): MigrationStatusJson { return parseJsonOutput(result); } @@ -770,7 +712,8 @@ export function getMigrationDirs(ctx: JourneyContext): string[] { const migrationsDir = appMigrationsDir(ctx); if (!existsSync(migrationsDir)) return []; return readdirSync(migrationsDir) - .filter((d) => !d.startsWith('.')) + .filter((d) => !d.startsWith('.') && d !== 'refs') + .filter((d) => statSync(join(migrationsDir, d)).isDirectory()) .sort(); } @@ -784,17 +727,42 @@ export function getMigrationDirs(ctx: JourneyContext): string[] { * mis-identified as "not the latest" if we just used `sort().at(-1)`. The * on-disk mtime always reflects the actual creation order. */ +/** + * The newest migration directory's name — what a user reads out of + * `migrations/app/` to continue planning from the tip (`--from ` + * resolves to that migration's destination contract). Throws when the + * journey has no migrations yet, so call sites can pass it straight into + * argv. + */ +export function latestMigrationDirName(ctx: JourneyContext): string { + const latest = getLatestMigrationDir(ctx); + if (latest === undefined) { + throw new Error('latestMigrationDirName: the journey has no migration directories yet'); + } + return latest; +} + export function getLatestMigrationDir(ctx: JourneyContext): string | undefined { const dirs = getMigrationDirs(ctx); if (dirs.length === 0) return undefined; const migrationsDir = appMigrationsDir(ctx); + const createdAtOf = (dir: string): string => { + const manifestPath = join(migrationsDir, dir, 'migration.json'); + if (!existsSync(manifestPath)) return ''; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { createdAt?: string }; + return manifest.createdAt ?? ''; + }; + // Newest by the manifest's own createdAt (directory mtime ties on coarse + // filesystems, and the minute-precision dir-name prefix cannot break a + // same-minute tie); equal timestamps fall back to the lexicographically + // last dir name so the choice is deterministic either way. let newest = dirs[0]!; - let newestMtime = statSync(join(migrationsDir, newest)).mtimeMs; + let newestCreatedAt = createdAtOf(newest); for (let i = 1; i < dirs.length; i++) { const dir = dirs[i]!; - const mtime = statSync(join(migrationsDir, dir)).mtimeMs; - if (mtime > newestMtime) { - newestMtime = mtime; + const createdAt = createdAtOf(dir); + if (createdAt > newestCreatedAt || (createdAt === newestCreatedAt && dir > newest)) { + newestCreatedAt = createdAt; newest = dir; } } diff --git a/test/integration/test/utils/parse-json-output.test.ts b/test/integration/test/utils/parse-json-output.test.ts index adaf40c7c292..88b46a0a2d00 100644 --- a/test/integration/test/utils/parse-json-output.test.ts +++ b/test/integration/test/utils/parse-json-output.test.ts @@ -1,28 +1,76 @@ import { describe, expect, it } from 'vitest'; +import type { EngineCommandResult } from './journey-test-helpers'; import { parseJsonOutput } from './journey-test-helpers'; -function result(stdout: string) { - return { exitCode: 0, stdout, stderr: '' }; +function engineResult(overrides: Partial): EngineCommandResult { + return { + exitCode: 0, + stdout: '', + stderr: '', + events: [], + json: [], + presented: undefined, + ...overrides, + }; } +const meta = { commandId: 'test', timestamp: '2026-01-01T00:00:00.000Z' }; + describe('parseJsonOutput', () => { - it('unwraps the document from an engine result frame', () => { - const frame = { kind: 'result', envelope: { ok: true, result: { ok: true, summary: 'done' } } }; - expect(parseJsonOutput(result(JSON.stringify(frame)))).toEqual({ ok: true, summary: 'done' }); + it('reads the presented document when the handler presented', () => { + const run = engineResult({ + presented: { + data: { ok: true, summary: 'done' }, + diagnostics: [], + presentation: {}, + } as unknown as EngineCommandResult['presented'], + }); + expect(parseJsonOutput(run)).toEqual({ ok: true, summary: 'done' }); }); - it('unwraps the error from a failed engine result frame', () => { - const frame = { kind: 'result', envelope: { ok: false, error: { code: 'CLI.UNEXPECTED' } } }; - expect(parseJsonOutput(result(JSON.stringify(frame)))).toEqual({ code: 'CLI.UNEXPECTED' }); + it('unwraps the document from the terminal result frame when nothing was presented', () => { + const run = engineResult({ + json: [ + { + kind: 'result', + envelope: { + ok: true, + commandId: 'test', + exitCode: 0, + result: { summary: 'done' }, + diagnostics: [], + nextActions: [], + }, + ...meta, + }, + ], + }); + expect(parseJsonOutput(run)).toEqual({ summary: 'done' }); }); - it('preserves a null terminal document instead of returning the frame', () => { - const frame = { kind: 'result', envelope: { ok: true, result: null } }; - expect(parseJsonOutput(result(JSON.stringify(frame)))).toBeNull(); + it('unwraps the error from a failed terminal result frame', () => { + const error = { + code: 'CLI.UNEXPECTED' as const, + severity: 'error' as const, + summary: 'boom', + nextActions: [], + }; + const run = engineResult({ + exitCode: 2, + json: [ + { + kind: 'result', + envelope: { ok: false, commandId: 'test', error, diagnostics: [], nextActions: [] }, + ...meta, + }, + ], + }); + expect(parseJsonOutput(run)).toEqual(error); }); - it('returns a commander-written document as-is', () => { - const document = { ok: true, migrations: [] }; - expect(parseJsonOutput(result(JSON.stringify(document)))).toEqual(document); + it('throws when the run produced neither a presented result nor a terminal frame', () => { + expect(() => parseJsonOutput(engineResult({ exitCode: 1, stderr: 'boom' }))).toThrow( + /no terminal result frame/, + ); }); }); diff --git a/test/integration/vitest.config.ts b/test/integration/vitest.config.ts index a1911342b9ff..12106a9b35ba 100644 --- a/test/integration/vitest.config.ts +++ b/test/integration/vitest.config.ts @@ -1,5 +1,11 @@ import { timeouts } from '@repo/test-utils'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; + +// init-journey's 4-cell pack+install matrix runs nightly only +// (.github/workflows/integration-nightly.yml); RUN_INIT_JOURNEY=1 opts it in. +export const initJourneyExclude = process.env['RUN_INIT_JOURNEY'] + ? [] + : ['test/cli-journeys/init-journey.e2e.test.ts']; export default defineConfig({ test: { @@ -45,6 +51,7 @@ export default defineConfig({ globals: true, environment: 'node', include: ['test/**/*.test.ts'], + exclude: [...configDefaults.exclude, ...initJourneyExclude], typecheck: { enabled: true, include: ['test/**/*.test-d.ts'], diff --git a/test/integration/vitest.journeys.config.ts b/test/integration/vitest.journeys.config.ts index 3cbaa03cc71c..8801535f2dee 100644 --- a/test/integration/vitest.journeys.config.ts +++ b/test/integration/vitest.journeys.config.ts @@ -1,11 +1,13 @@ import { timeouts } from '@repo/test-utils'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; +import { initJourneyExclude } from './vitest.config'; export default defineConfig({ test: { globals: true, environment: 'node', include: ['test/cli-journeys/**/*.e2e.test.ts'], + exclude: [...configDefaults.exclude, ...initJourneyExclude], testTimeout: timeouts.spinUpPpgDev, hookTimeout: timeouts.spinUpPpgDev, // Required (not a preference): journey helpers use process.chdir() and mock