From 62c4f578f8bc141d1d6e8d6cdfcfdbb47bff0b3a Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 19 Aug 2026 08:47:48 +0200 Subject: [PATCH 1/7] test(integration): run migration.ts self-emit in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every self-emit site spawned tsx per migration step — a node boot, an esbuild transform, and a cold import of the workspace packages each time. The new runMigrationFile helper imports the migration file through vitest's transformer (content-hash query busts the ESM cache when a test rewrites the same migration.ts) and drives MigrationCLI.run through its injectable argv/stdout/stderr surface, saving and restoring process.cwd() and process.exitCode around the run. The journey helpers are renamed to say what they do now that the `migration emit` command no longer exists: runMigrationEmit → selfEmitMigration, runMigrationPlanAndEmit → planThenSelfEmit. CLI scope (83 files, 362 tests): wall 52.68s → 33.94s, test time 674.97s → 418.51s. Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-journeys/adopt-migrations.e2e.test.ts | 6 +- .../cli-journeys/converging-paths.e2e.test.ts | 10 +-- ...ta-transform-not-null-backfill.e2e.test.ts | 8 +- ...-transform-nullable-tightening.e2e.test.ts | 8 +- .../data-transform-type-change.e2e.test.ts | 8 +- .../db-sign-contract-arg.e2e.test.ts | 4 +- .../diamond-convergence.e2e.test.ts | 14 ++-- .../divergence-and-refs.e2e.test.ts | 8 +- .../drift-deleted-root.e2e.test.ts | 6 +- .../drift-migration-dag.e2e.test.ts | 8 +- .../expression-index-migration.e2e.test.ts | 8 +- .../index-name-convergence.e2e.test.ts | 6 +- .../interleaved-db-update.e2e.test.ts | 10 +-- .../invariant-routing.e2e.test.ts | 30 +++---- .../invariant-routing.mongo.e2e.test.ts | 16 ++-- .../migration-apply-edge-cases.e2e.test.ts | 18 ++-- .../cli-journeys/migration-check.e2e.test.ts | 18 ++-- .../migration-graph-dot.e2e.test.ts | 6 +- .../cli-journeys/migration-list.e2e.test.ts | 6 +- .../cli-journeys/migration-log.e2e.test.ts | 8 +- .../migration-plan-details.e2e.test.ts | 10 +-- .../migration-round-trip.e2e.test.ts | 8 +- .../migration-show-reachability.e2e.test.ts | 8 +- .../migration-status-diagnostics.e2e.test.ts | 64 +++++---------- .../cli-journeys/mongo-migration.e2e.test.ts | 15 ++-- .../multi-step-migration.e2e.test.ts | 8 +- .../cli-journeys/plan-to-rollback.e2e.test.ts | 8 +- .../test/cli-journeys/ref-routing.e2e.test.ts | 6 +- .../rls-exact-name-adoption.e2e.test.ts | 6 +- .../cli-journeys/rollback-cycle.e2e.test.ts | 14 ++-- .../schema-evolution-migrations.e2e.test.ts | 8 +- .../sign-the-database.e2e.test.ts | 6 +- .../test/cli.migrate-drift-check.e2e.test.ts | 20 ++--- .../cli.migrate-external-space.e2e.test.ts | 13 ++- .../cli.migrate-ref-advancement.e2e.test.ts | 12 +-- .../cli.migration-plan-ref-aware.e2e.test.ts | 12 ++- .../cli.ref-pointer-integration.e2e.test.ts | 4 +- .../test/utils/cli-test-helpers.ts | 82 ++++++++++++++++++- .../test/utils/journey-test-helpers.ts | 49 ++++------- 39 files changed, 299 insertions(+), 260 deletions(-) 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..8de2549ab4b0 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, + planThenSelfEmit, 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 planThenSelfEmit(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,7 @@ 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 planThenSelfEmit(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 index f8ec780b1b5d..665988856e1b 100644 --- a/test/integration/test/cli-journeys/converging-paths.e2e.test.ts +++ b/test/integration/test/cli-journeys/converging-paths.e2e.test.ts @@ -14,9 +14,9 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationPlanAndEmit, setupJourney, swapContract, timeouts, @@ -38,7 +38,7 @@ withTempDir(({ 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']); + const plan0 = await planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'K.01: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; @@ -46,7 +46,7 @@ withTempDir(({ createTempDir }) => { 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']); + const plan1 = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--json']); expect(plan1.exitCode, 'K.02: plan C1→C2').toBe(0); parseJsonOutput<{ to: string }>(plan1); @@ -54,12 +54,12 @@ withTempDir(({ createTempDir }) => { 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']); + const plan2 = await planThenSelfEmit(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, [ + const planDirect = await planThenSelfEmit(ctx, [ '--name', 'direct-to-c3', '--from', 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 index 95cb3d58b28e..51369bc78742 100644 --- 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 @@ -23,11 +23,11 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { injectMigrationSqlDbSetup, type JourneyContext, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationEmit, runMigrationPlan, - runMigrationPlanAndEmit, + selfEmitMigration, setupJourney, sql, swapContract, @@ -51,7 +51,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const plan0 = await planThenSelfEmit(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); @@ -105,7 +105,7 @@ withTempDir(({ createTempDir }) => { expect(filled).toContain('const db = sql('); writeFileSync(migrationTsPath, filled); - const emitResult = await runMigrationEmit(ctx, [ + const emitResult = await selfEmitMigration(ctx, [ '--dir', migrationDir, '--config', 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 index e9dd379b7626..aab02a782720 100644 --- 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 @@ -24,11 +24,11 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { injectMigrationSqlDbSetup, type JourneyContext, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationEmit, runMigrationPlan, - runMigrationPlanAndEmit, + selfEmitMigration, setupJourney, sql, swapContract, @@ -57,7 +57,7 @@ withTempDir(({ createTempDir }) => { 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']); + const plan0 = await planThenSelfEmit(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); @@ -115,7 +115,7 @@ withTempDir(({ createTempDir }) => { expect(filled).toContain('const db = sql('); writeFileSync(migrationTsPath, filled); - const emitResult = await runMigrationEmit(ctx, [ + const emitResult = await selfEmitMigration(ctx, [ '--dir', migrationDir, '--config', 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 index 366b7bb0cd1c..4b8288d5d13c 100644 --- 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 @@ -24,11 +24,11 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { injectMigrationSqlDbSetup, type JourneyContext, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationEmit, runMigrationPlan, - runMigrationPlanAndEmit, + selfEmitMigration, setupJourney, sql, swapContract, @@ -60,7 +60,7 @@ withTempDir(({ createTempDir }) => { 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']); + const plan0 = await planThenSelfEmit(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); @@ -122,7 +122,7 @@ withTempDir(({ createTempDir }) => { expect(filled).toContain('const db = sql('); writeFileSync(migrationTsPath, filled); - const emitResult = await runMigrationEmit(ctx, [ + const emitResult = await selfEmitMigration(ctx, [ '--dir', migrationDir, '--config', 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..9eff3e2f841f 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, + planThenSelfEmit, 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 planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan.exitCode, 'plan').toBe(0); const planJson = parseJsonOutput(plan); 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..18869dba04e5 100644 --- a/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts +++ b/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts @@ -25,9 +25,9 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, runRef, setupJourney, @@ -61,7 +61,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 planThenSelfEmit(staging, ['--name', 'init', '--json']); expect(plan0.exitCode, 'D.01: plan init').toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; @@ -83,7 +83,7 @@ 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 planThenSelfEmit(staging, ['--name', 'add-phone', '--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 +92,7 @@ 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 planThenSelfEmit(staging, ['--name', 'add-bio', '--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 +108,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 planThenSelfEmit(staging, [ '--name', 'add-avatar', '--from', @@ -136,7 +136,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 planThenSelfEmit(staging, [ '--name', 'merge-staging', '--from', @@ -147,7 +147,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 planThenSelfEmit(staging, [ '--name', 'merge-prod', '--from', 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..4500a08b506e 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 @@ -16,9 +16,9 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planThenSelfEmit, 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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(ctx, [ '--name', 'add-bio', '--from', 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 index a7acc61b55ef..c9c4bed42b1e 100644 --- a/test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts +++ b/test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts @@ -17,10 +17,10 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, runMigrate, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -42,7 +42,7 @@ withTempDir(({ 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']); + const planInit = await planThenSelfEmit(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); @@ -50,7 +50,7 @@ withTempDir(({ createTempDir }) => { 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']); + const plan1 = await planThenSelfEmit(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); 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..a6f0cc691feb 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 @@ -13,10 +13,10 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + planThenSelfEmit, runContractEmit, runMigrate, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -42,7 +42,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 planThenSelfEmit(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 +50,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'add-name']); expect(plan1.exitCode, 'P3.pre: plan v2').toBe(0); const apply1 = await runMigrate(ctx); expect(apply1.exitCode, 'P3.pre: apply v2').toBe(0); @@ -80,7 +80,7 @@ withTempDir(({ createTempDir }) => { expect(applyFail.exitCode, 'P3.02: migration apply fails').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 planThenSelfEmit(ctx, ['--name', 're-add-posts']); expect(rePlan.exitCode, 'P3.03: migration plan recovery').toBe(0); // P3.04: migration apply (applies the re-planned additive→v3 migration) 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..a965d27ada7c 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,10 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { getLatestMigrationDir, type JourneyContext, + planThenSelfEmit, runContractEmit, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, swapContract, swapPslContract, @@ -69,7 +69,7 @@ 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 planThenSelfEmit(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, @@ -122,7 +122,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'rename-search-index']); expect(planRename.exitCode, `rename: plan\n${stripAnsi(planRename.stderr)}`).toBe(0); const renameOps = readPlannedOps(ctx); expect( @@ -147,7 +147,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'edit-search-index-body']); 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/index-name-convergence.e2e.test.ts b/test/integration/test/cli-journeys/index-name-convergence.e2e.test.ts index a69310df44ee..c683eaa6874c 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 @@ -26,13 +26,13 @@ import { getLatestMigrationDir, type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, runContractInfer, runDbSign, runDbUpdate, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, swapPslContract, timeouts, @@ -90,7 +90,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 planThenSelfEmit(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 +105,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'converge-index-names']); expect(plan.exitCode, `migration plan\n${stripAnsi(plan.stderr)}`).toBe(0); const ops = readPlannedOps(ctx); expect( 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..6d6fb8ccb8cc 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 @@ -19,11 +19,11 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planThenSelfEmit, runContractEmit, runDbUpdate, runDbVerify, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -45,7 +45,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 planThenSelfEmit(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 +58,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'add-phone', '--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 +84,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 planThenSelfEmit(ctx, [ '--from', c2Hash, '--name', @@ -111,7 +111,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'add-avatar', '--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..216787bdf67e 100644 --- a/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts +++ b/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts @@ -28,13 +28,13 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationEmit, runMigrationNew, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationStatus, + selfEmitMigration, setupJourney, sql, swapContract, @@ -116,7 +116,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 planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'O.01: plan init').toBe(0); expect((await runMigrate(ctx)).exitCode, 'O.01: apply init').toBe(0); @@ -146,7 +146,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( @@ -245,7 +245,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 planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'P.01: plan init').toBe(0); expect((await runMigrate(ctx)).exitCode, 'P.01: apply init').toBe(0); @@ -271,7 +271,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); @@ -328,7 +328,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 planThenSelfEmit(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); @@ -354,7 +354,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 +362,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 planThenSelfEmit(ctx, [ '--name', 'branch-b-no-invariant', '--from', @@ -432,7 +432,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 planThenSelfEmit(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); @@ -458,7 +458,7 @@ withTempDir(({ createTempDir }) => { .at(-1)!, ); patchBackfillMigrationTs(migrationDir, { addInvariantId: true }); - expect((await runMigrationEmit(ctx, ['--dir', migrationDir])).exitCode, 'R.02: emit').toBe( + expect((await selfEmitMigration(ctx, ['--dir', migrationDir])).exitCode, 'R.02: emit').toBe( 0, ); @@ -546,7 +546,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 planThenSelfEmit(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 +616,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, ); @@ -705,7 +705,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 planThenSelfEmit(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 +770,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); 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 index d12c9de05e8b..220c954b583a 100644 --- a/test/integration/test/cli-journeys/invariant-routing.mongo.e2e.test.ts +++ b/test/integration/test/cli-journeys/invariant-routing.mongo.e2e.test.ts @@ -35,10 +35,10 @@ import { parseMigrationStatusJson, runContractEmit, runMigrate, - runMigrationEmit, runMigrationNew, runMigrationPlan, runMigrationStatus, + selfEmitMigration, } from '../utils/journey-test-helpers'; const FIXTURES_DIR = join(fixtureAppDir, 'fixtures/mongo-cli-journeys'); @@ -283,7 +283,7 @@ describe('Journey: Mongo invariant-aware ref routing (live database)', { ); expect( ( - await runMigrationEmit(ctx, [ + await selfEmitMigration(ctx, [ '--dir', `migrations/app/${basename(getLatestMigrationDir(ctx))}`, ]) @@ -323,7 +323,7 @@ describe('Journey: Mongo invariant-aware ref routing (live database)', { }), ); expect( - (await runMigrationEmit(ctx, ['--dir', migrationDir])).exitCode, + (await selfEmitMigration(ctx, ['--dir', migrationDir])).exitCode, 'Mongo-O.04: emit', ).toBe(0); @@ -420,7 +420,7 @@ describe('Journey: Mongo invariant-aware ref routing (live database)', { ); const initDir = getLatestMigrationDir(ctx); expect( - (await runMigrationEmit(ctx, ['--dir', `migrations/app/${basename(initDir)}`])).exitCode, + (await selfEmitMigration(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); @@ -441,7 +441,7 @@ describe('Journey: Mongo invariant-aware ref routing (live database)', { 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); + expect((await selfEmitMigration(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; @@ -490,7 +490,7 @@ describe('Journey: Mongo invariant-aware ref routing (live database)', { ); const initDir = getLatestMigrationDir(ctx); expect( - (await runMigrationEmit(ctx, ['--dir', `migrations/app/${basename(initDir)}`])).exitCode, + (await selfEmitMigration(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); @@ -516,7 +516,7 @@ describe('Journey: Mongo invariant-aware ref routing (live database)', { renderInvariantMigrationTs(draftA.from, draftA.to, { invariantId: INVARIANT_ID }), ); expect( - (await runMigrationEmit(ctx, ['--dir', branchADir])).exitCode, + (await selfEmitMigration(ctx, ['--dir', branchADir])).exitCode, 'Mongo-Q.02: emit branch A', ).toBe(0); @@ -542,7 +542,7 @@ describe('Journey: Mongo invariant-aware ref routing (live database)', { renderIndexOnlyMigrationTs(branchBManifest.from, cbHash), ); expect( - (await runMigrationEmit(ctx, ['--dir', branchBDir])).exitCode, + (await selfEmitMigration(ctx, ['--dir', branchBDir])).exitCode, 'Mongo-Q.03: emit branch B', ).toBe(0); 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..3703f327a583 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 @@ -15,10 +15,10 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, sql, swapContract, @@ -43,7 +43,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 planThenSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, 'plan initial').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply initial').toBe(0); @@ -82,7 +82,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 planThenSelfEmit(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 +102,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'add-unique-email']); expect(plan1.exitCode, 'plan add-unique-email').toBe(0); // Apply fails because duplicate emails violate the unique constraint @@ -156,7 +156,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 planThenSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, 'plan initial').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply initial').toBe(0); @@ -173,7 +173,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'drop-email']); expect(plan1.exitCode, 'plan drop-email').toBe(0); // Apply destructive migration @@ -229,21 +229,21 @@ 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 planThenSelfEmit(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 planThenSelfEmit(ctx, ['--name', 'add-name']); 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 planThenSelfEmit(ctx, ['--name', 'drop-email']); 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..3693b3a32262 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, + planThenSelfEmit, 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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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..3a2a72450873 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, + planThenSelfEmit, 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 planThenSelfEmit(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 planThenSelfEmit(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..d1d69956a43a 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,9 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + planThenSelfEmit, runContractEmit, runMigrationList, - runMigrationPlanAndEmit, setupJourney, swapContract, } from '../utils/journey-test-helpers'; @@ -21,10 +21,10 @@ withTempDir(({ createTempDir }) => { async function projectWithTwoMigrations(): Promise { const ctx = setupJourney({ createTempDir }); await runContractEmit(ctx); - await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + await planThenSelfEmit(ctx, ['--name', 'initial']); swapContract(ctx, 'contract-additive'); await runContractEmit(ctx); - await runMigrationPlanAndEmit(ctx, ['--name', 'add-name']); + await planThenSelfEmit(ctx, ['--name', 'add-name']); 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..566df37702f3 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,10 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + planThenSelfEmit, runContractEmit, runMigrate, runMigrationLog, - runMigrationPlanAndEmit, setupJourney, swapContract, timeouts, @@ -40,15 +40,13 @@ withTempDir(({ createTempDir }) => { }); expect((await runContractEmit(ctx)).exitCode, 'emit base').toBe(0); - expect((await runMigrationPlanAndEmit(ctx, ['--name', 'initial'])).exitCode, 'plan').toBe( - 0, - ); + expect((await planThenSelfEmit(ctx, ['--name', 'initial'])).exitCode, 'plan').toBe(0); expect((await runMigrate(ctx)).exitCode, 'apply initial').toBe(0); swapContract(ctx, 'contract-additive'); expect((await runContractEmit(ctx)).exitCode, 'emit v2').toBe(0); expect( - (await runMigrationPlanAndEmit(ctx, ['--name', 'add-name-column'])).exitCode, + (await planThenSelfEmit(ctx, ['--name', 'add-name-column'])).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..f63822fc3f41 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 @@ -20,10 +20,10 @@ import { getLatestMigrationDir, type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, - runMigrationEmit, runMigrationPlan, - runMigrationPlanAndEmit, + selfEmitMigration, setupJourney, swapContract, useDevDatabase, @@ -54,7 +54,7 @@ withTempDir(({ createTempDir }) => { // `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 planThenSelfEmit(ctx, ['--name', 'initial', '--json']); expect(plan.exitCode, 'H.02: migration plan --json').toBe(0); const result = parseJsonOutput<{ @@ -127,7 +127,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 planThenSelfEmit(ctx, ['--name', 'initial']); expect(planInit.exitCode, 'I.01: plan initial').toBe(0); // I.02: swap to destructive contract (removes email column) @@ -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..7a54091b5b73 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 @@ -32,11 +32,11 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationEmit, runMigrationNew, - runMigrationPlanAndEmit, + selfEmitMigration, setupJourney, sql, swapContract, @@ -68,7 +68,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'initial']); + const plan0 = await planThenSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, `plan initial: ${plan0.stderr}`).toBe(0); const apply0 = await runMigrate(ctx); @@ -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..6db71bce9ee5 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, + planThenSelfEmit, 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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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..396407b43be2 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 @@ -22,10 +22,10 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planThenSelfEmit, runContractEmit, runDbUpdate, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, runRef, setupJourney, @@ -72,7 +72,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 planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan.exitCode, 'plan').toBe(0); const planFrom = parseJsonOutput<{ from: string | null }>(plan).from; @@ -115,7 +115,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planThenSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const status = await runMigrationStatus(ctx); @@ -148,7 +148,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planThenSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const apply = await runMigrate(ctx); expect(apply.exitCode, 'apply').toBe(0); @@ -185,7 +185,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 planThenSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan init').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply init').toBe(0); @@ -193,7 +193,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'add-field']); expect(plan1.exitCode, 'plan v2').toBe(0); const status = await runMigrationStatus(ctx); @@ -228,7 +228,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan0 = await planThenSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); swapContract(ctx, 'contract-additive'); @@ -260,7 +260,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan0 = await planThenSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply').toBe(0); @@ -303,7 +303,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan0 = await planThenSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply').toBe(0); @@ -351,7 +351,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 planThenSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); const apply0 = await runMigrate(ctx); expect(apply0.exitCode, 'apply').toBe(0); @@ -408,7 +408,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await runMigrationPlanAndEmit(ctx, ['--name', 'init']); + const plan = await planThenSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const apply = await runMigrate(ctx); expect(apply.exitCode, 'apply').toBe(0); @@ -457,7 +457,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 planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'plan init').toBe(0); const baseHash = parseJsonOutput<{ to: string }>(plan0).to; const apply0 = await runMigrate(ctx); @@ -466,23 +466,13 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emitA = await runContractEmit(ctx); expect(emitA.exitCode, 'emit branch A').toBe(0); - const planA = await runMigrationPlanAndEmit(ctx, [ - '--name', - 'add-phone', - '--from', - baseHash, - ]); + const planA = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--from', baseHash]); expect(planA.exitCode, 'plan branch A').toBe(0); swapContract(ctx, 'contract-bio'); const emitB = await runContractEmit(ctx); expect(emitB.exitCode, 'emit branch B').toBe(0); - const planB = await runMigrationPlanAndEmit(ctx, [ - '--name', - 'add-bio', - '--from', - baseHash, - ]); + const planB = await planThenSelfEmit(ctx, ['--name', 'add-bio', '--from', baseHash]); expect(planB.exitCode, 'plan branch B').toBe(0); // Swap to a contract that doesn't match either leaf so the @@ -525,7 +515,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 planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'plan init').toBe(0); const baseHash = parseJsonOutput<{ to: string }>(plan0).to; const apply0 = await runMigrate(ctx); @@ -535,12 +525,7 @@ withTempDir(({ createTempDir }) => { 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, - ]); + const planA = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--from', baseHash]); expect(planA.exitCode, 'plan A').toBe(0); const applyA = await runMigrate(ctx); expect(applyA.exitCode, 'apply A').toBe(0); @@ -549,7 +534,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 planThenSelfEmit(ctx, [ '--name', 'add-bio', '--from', @@ -596,7 +581,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 planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode, 'plan init').toBe(0); const baseHash = parseJsonOutput<{ to: string }>(plan0).to; const apply0 = await runMigrate(ctx); @@ -605,7 +590,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 planThenSelfEmit(ctx, [ '--name', 'add-phone', '--from', @@ -618,12 +603,7 @@ withTempDir(({ createTempDir }) => { 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, - ]); + const planB = await planThenSelfEmit(ctx, ['--name', 'add-bio', '--from', baseHash]); expect(planB.exitCode, 'plan B').toBe(0); const setRef = await runRef(ctx, ['set', 'production', hashA]); @@ -654,13 +634,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 planThenSelfEmit(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 planThenSelfEmit(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..af020a4c7890 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`. * @@ -45,9 +45,9 @@ import { type JourneyContext, runContractEmit, runMigrate, - runMigrationEmit, runMigrationNew, runMigrationPlan, + selfEmitMigration, } from '../utils/journey-test-helpers'; const FIXTURES_DIR = join(fixtureAppDir, 'fixtures/mongo-cli-journeys'); @@ -195,9 +195,12 @@ 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)}`]); + const emit = await selfEmitMigration(ctx, [ + '--dir', + `migrations/app/${basename(migrationDir)}`, + ]); expect(emit.exitCode, `migration emit: ${emit.stdout}\n${emit.stderr}`).toBe(0); const ops = JSON.parse(readFileSync(join(migrationDir, 'ops.json'), 'utf-8')) as ReadonlyArray<{ @@ -323,7 +326,7 @@ 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))}`, ]); @@ -416,7 +419,7 @@ MigrationCLI.run(import.meta.url, M); `; writeFileSync(migrationTsPath, handAuthored); - const emitResult = await runMigrationEmit(ctx, ['--dir', migrationDir]); + const emitResult = await selfEmitMigration(ctx, ['--dir', migrationDir]); expect(emitResult.exitCode, `migration emit: ${emitResult.stdout}\n${emitResult.stderr}`).toBe( 0, ); 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..eff0525602cb 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, + planThenSelfEmit, 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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(ctx, ['--name', 'add-posts']); expect(plan2.exitCode, 'C.04: migration plan v3').toBe(0); // C.05: migration status --db (2 pending) 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 index 4efd66bde5c3..9c3252099e4d 100644 --- a/test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts +++ b/test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts @@ -21,9 +21,9 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -51,7 +51,7 @@ withTempDir(({ 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']); + const plan0 = await planThenSelfEmit(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); @@ -59,7 +59,7 @@ withTempDir(({ createTempDir }) => { // 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']); + const plan1 = await planThenSelfEmit(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); @@ -71,7 +71,7 @@ withTempDir(({ createTempDir }) => { // add-phone migration's predecessor (`^` == C1). The emitted // contract.ts still holds the phone variant throughout. const rollbackTarget = `${addPhoneDir}^`; - const planRollback = await runMigrationPlanAndEmit(ctx, [ + const planRollback = await planThenSelfEmit(ctx, [ '--to', rollbackTarget, '--name', diff --git a/test/integration/test/cli-journeys/ref-routing.e2e.test.ts b/test/integration/test/cli-journeys/ref-routing.e2e.test.ts index 004c51aadd80..3113ea5fee38 100644 --- a/test/integration/test/cli-journeys/ref-routing.e2e.test.ts +++ b/test/integration/test/cli-journeys/ref-routing.e2e.test.ts @@ -18,9 +18,9 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, + planThenSelfEmit, runContractEmit, runMigrate, - runMigrationPlanAndEmit, runMigrationStatus, runRef, setupJourney, @@ -44,7 +44,7 @@ withTempDir(({ 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']); + const plan0 = await planThenSelfEmit(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); @@ -54,7 +54,7 @@ withTempDir(({ createTempDir }) => { 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']); + const plan1 = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--json']); expect(plan1.exitCode, 'M.02: plan C1→C2').toBe(0); const c2Hash = parseJsonOutput<{ to: string }>(plan1).to; 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..d6b5b27ba864 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 @@ -16,11 +16,11 @@ import { getLatestMigrationDir, type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, runDbSign, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, swapPslContract, timeouts, @@ -88,7 +88,7 @@ withTempDir(({ createTempDir }) => { ).toBe(0); // baseline: EMPTY → adopted contract; no-op on apply. - const planBaseline = await runMigrationPlanAndEmit(ctx, ['--name', 'baseline']); + const planBaseline = await planThenSelfEmit(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 +106,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'adopt-wire-name']); expect(plan.exitCode, `plan rename: migration plan\n${stripAnsi(plan.stderr)}`).toBe(0); const ops = readPlannedOps(ctx); expect( 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..8bf7f7573128 100644 --- a/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts +++ b/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts @@ -12,10 +12,10 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, runMigrate, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationStatus, setupJourney, swapContract, @@ -38,7 +38,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 planThenSelfEmit(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 +49,7 @@ 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 planThenSelfEmit(ctx, ['--name', 'add-phone', '--json']); expect(plan1.exitCode, 'J.02: plan add-phone').toBe(0); const planResult1 = parseJsonOutput<{ to: string }>(plan1); const c2Hash = planResult1.to; @@ -61,11 +61,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-base'); const emit2 = await runContractEmit(ctx); expect(emit2.exitCode, 'J.03: emit C1 again').toBe(0); - const planRollback = await runMigrationPlanAndEmit(ctx, [ - '--name', - 'rollback-phone', - '--json', - ]); + const planRollback = await planThenSelfEmit(ctx, ['--name', 'rollback-phone', '--json']); expect(planRollback.exitCode, 'J.03: plan rollback').toBe(0); const apply2 = await runMigrate(ctx); expect(apply2.exitCode, 'J.03: apply rollback').toBe(0); @@ -80,7 +76,7 @@ withTempDir(({ createTempDir }) => { expect(implicitResult.from, 'J.04: from resolved via db ref').toBeTruthy(); // J.05: plan with --from C1 recovers - const planFrom = await runMigrationPlanAndEmit(ctx, [ + const planFrom = await planThenSelfEmit(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..1985cfb6d97e 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 @@ -21,16 +21,16 @@ import { getLatestMigrationDir, type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, runDbInit, runDbUpdate, runDbVerify, runMigrate, - runMigrationEmit, runMigrationPlan, - runMigrationPlanAndEmit, runMigrationShow, runMigrationStatus, + selfEmitMigration, setupJourney, swapContract, timeouts, @@ -55,7 +55,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 planThenSelfEmit(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); @@ -79,7 +79,7 @@ withTempDir(({ createTempDir }) => { // B.04: migration emit --dir const migDir = getLatestMigrationDir(ctx); expect(migDir, 'B.04: migration dir exists').toBeDefined(); - const emitMig = await runMigrationEmit(ctx, ['--dir', `migrations/app/${migDir}`]); + const emitMig = await selfEmitMigration(ctx, ['--dir', `migrations/app/${migDir}`]); expect(emitMig.exitCode, 'B.04: migration emit').toBe(0); // B.05: migration status (pre-apply — shows pending migration) 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..99628c236810 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 @@ -29,13 +29,13 @@ import { getLatestMigrationDir, type JourneyContext, parseJsonOutput, + planThenSelfEmit, runContractEmit, runContractInfer, runDbSign, runDbUpdate, runDbVerify, runMigrate, - runMigrationPlanAndEmit, setupJourney, timeouts, useDevDatabase, @@ -174,7 +174,7 @@ describe('sign a database this toolchain has never seen, then transition to wire // 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 planThenSelfEmit(ctx, ['--name', 'baseline']); expect(planBaseline.exitCode, `3.1: plan baseline\n${stripAnsi(planBaseline.stderr)}`).toBe( 0, ); @@ -203,7 +203,7 @@ describe('sign a database this toolchain has never seen, then transition to wire 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']); + const plan = await planThenSelfEmit(ctx, ['--name', 'adopt-wire-names']); expect(plan.exitCode, `3.3: migration plan\n${stripAnsi(plan.stderr)}`).toBe(0); const ops = readPlannedOps(ctx); expect( 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..81ee5764ebb8 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, + planThenSelfEmit, 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 planThenSelfEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); const firstApply = await runMigrate(ctx, ['--json']); expect(firstApply.exitCode).toBe(0); const firstJson = parseJsonOutput<{ markerHash: string }>(firstApply); @@ -74,9 +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( - 0, - ); + expect((await planThenSelfEmit(ctx, ['--name', 'replacement'])).exitCode).toBe(0); const drift = await runMigrate(ctx, ['--json']); expect(drift.exitCode).not.toBe(0); @@ -99,7 +97,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 planThenSelfEmit(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 +115,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 planThenSelfEmit(ctx, ['--name', 'initial'])).exitCode).toBe(0); const apply = await runMigrate(ctx, ['--json']); expect(apply.exitCode).toBe(0); }); @@ -152,13 +150,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 planThenSelfEmit(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 planThenSelfEmit(ctx, ['--name', 'replacement']); expect(replacementPlan.exitCode).toBe(0); const bundleDir = readdirSync(appMigrationsDir(ctx)) .filter((d) => d !== 'refs' && !d.startsWith('.')) @@ -181,12 +179,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 planThenSelfEmit(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 planThenSelfEmit(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..6d529a4e1d82 100644 --- a/test/integration/test/cli.migrate-external-space.e2e.test.ts +++ b/test/integration/test/cli.migrate-external-space.e2e.test.ts @@ -20,12 +20,10 @@ * 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, @@ -34,14 +32,12 @@ import { 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,7 +73,10 @@ 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( 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..82d6178c8538 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,18 @@ -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,7 +50,10 @@ 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 { 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..c8f491b2a623 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, + planThenSelfEmit, 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 planThenSelfEmit(ctx, ['--name', 'init', '--json']); expect(plan0.exitCode).toBe(0); const c1Hash = parseJsonOutput<{ to: string }>(plan0).to; expect((await runMigrate(ctx)).exitCode).toBe(0); @@ -355,9 +355,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', 'init', '--json'])).exitCode, - ).toBe(0); + expect((await planThenSelfEmit(ctx, ['--name', 'init', '--json'])).exitCode).toBe(0); expect((await runMigrate(ctx)).exitCode).toBe(0); swapContract(ctx, 'contract-additive'); 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..2bce04e91a4d 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, + planThenSelfEmit, 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 planThenSelfEmit(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..04393c86575c 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, @@ -9,8 +10,10 @@ import { 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'; @@ -508,6 +511,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. diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index e4d9eb0ed1ab..65d75d0f7aa7 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,17 @@ 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'; @@ -486,17 +482,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. + * Self-emits a migration package by running its `migration.ts` in-process (see + * {@link runMigrationFile}), which serializes the class's `operations` to + * `ops.json` and attests `migration.json` in the package directory. * - * 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 +499,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,27 +508,19 @@ 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. + * in-process (see {@link runMigrationFile}). Journey steps that just need "a + * planned and emitted migration" use this instead of spelling both steps out. * * 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`. + * 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 planThenSelfEmit( ctx: JourneyContext, extraArgs: readonly string[] = [], ): Promise { @@ -541,12 +528,12 @@ export async function runMigrationPlanAndEmit( 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[planThenSelfEmit] migration.ts self-emit failed (exit ${emitResult.exitCode}):\n${emitResult.stderr}`, }; } return planResult; From 3737e3091987df694732d1a3b97c7195ae1fb8cd Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 19 Aug 2026 09:11:10 +0200 Subject: [PATCH 2/7] test(integration): use the engine runner as designed; journeys type real argv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three helper changes: - runOnEngine keeps one TestCli per project (keyed by testDir + configPath) built over the engine 0.2.0 loadConfig hook. The hook re-reads the config file on every run that needs it, so config rewrites are still picked up; a config that does not evaluate now settles as the run's error for every caller, which is what the settleConfigFailures option used to opt into — the option is gone. The ORM mount and its group index compute once per process. - appendImplicitMigrationPlanFrom is deleted. migrate does not create the db ref, so the shim was silently supplying `--from ` to every follow-up plan in a journey. Journeys now type the argv a user would: `--from ` via the new latestMigrationDirName helper (dir names resolve to that migration's destination contract). Two tests whose subject WAS the implicit behavior are rewritten to name their base explicitly and called out in the PR body (drift-deleted-root P4.02, rollback-cycle J.04). - parseJsonOutput reads the presented result or the terminal result frame; the commander-era bare-document fallback and stdout re-parsing are gone. CLI scope: 83 files, 362 tests, green. Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli-journeys/converging-paths.e2e.test.ts | 17 +- ...ta-transform-not-null-backfill.e2e.test.ts | 8 +- ...-transform-nullable-tightening.e2e.test.ts | 8 +- .../data-transform-type-change.e2e.test.ts | 8 +- .../diamond-convergence.e2e.test.ts | 17 +- .../drift-deleted-root.e2e.test.ts | 22 ++- .../drift-migration-dag.e2e.test.ts | 22 ++- .../expression-index-migration.e2e.test.ts | 15 +- .../index-name-convergence.e2e.test.ts | 8 +- .../interleaved-db-update.e2e.test.ts | 17 +- .../invariant-routing.e2e.test.ts | 33 +++- .../migration-apply-edge-cases.e2e.test.ts | 29 +++- .../cli-journeys/migration-log.e2e.test.ts | 10 +- .../migration-plan-details.e2e.test.ts | 9 +- .../migration-status-diagnostics.e2e.test.ts | 8 +- .../cli-journeys/plan-to-rollback.e2e.test.ts | 11 +- .../test/cli-journeys/ref-routing.e2e.test.ts | 9 +- .../rls-exact-name-adoption.e2e.test.ts | 8 +- .../cli-journeys/rollback-cycle.e2e.test.ts | 33 +++- .../schema-evolution-migrations.e2e.test.ts | 16 +- .../sign-the-database.e2e.test.ts | 8 +- .../cli.config-section-requirements.test.ts | 18 +- .../test/cli.emit-command.additional.test.ts | 4 +- .../test/cli.emit-command.e2e.test.ts | 12 +- .../cli.migrate-external-space.e2e.test.ts | 4 +- .../cli.migrate-ref-advancement.e2e.test.ts | 4 +- .../test/utils/cli-test-helpers.ts | 162 ++++++------------ .../test/utils/journey-test-helpers.ts | 99 ++++------- .../test/utils/parse-json-output.test.ts | 76 ++++++-- 29 files changed, 436 insertions(+), 259 deletions(-) diff --git a/test/integration/test/cli-journeys/converging-paths.e2e.test.ts b/test/integration/test/cli-journeys/converging-paths.e2e.test.ts index 665988856e1b..058bd8b640c3 100644 --- a/test/integration/test/cli-journeys/converging-paths.e2e.test.ts +++ b/test/integration/test/cli-journeys/converging-paths.e2e.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -46,7 +47,13 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'K.02: emit C2').toBe(0); - const plan1 = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--json']); + const plan1 = await planThenSelfEmit(ctx, [ + '--name', + 'add-phone', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(plan1.exitCode, 'K.02: plan C1→C2').toBe(0); parseJsonOutput<{ to: string }>(plan1); @@ -54,7 +61,13 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone-bio'); const emit2 = await runContractEmit(ctx); expect(emit2.exitCode, 'K.03: emit C3').toBe(0); - const plan2 = await planThenSelfEmit(ctx, ['--name', 'add-bio-via-c2', '--json']); + const plan2 = await planThenSelfEmit(ctx, [ + '--name', + 'add-bio-via-c2', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(plan2.exitCode, 'K.03: plan C2→C3').toBe(0); const c3Hash = parseJsonOutput<{ to: string }>(plan2).to; 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 index 51369bc78742..c8be76e7a066 100644 --- 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 @@ -23,6 +23,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { injectMigrationSqlDbSetup, type JourneyContext, + latestMigrationDirName, planThenSelfEmit, runContractEmit, runMigrate, @@ -67,7 +68,12 @@ withTempDir(({ createTempDir }) => { const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, `emit required-name: ${emit1.stderr}`).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, `plan: ${planResult.stderr}\n${planResult.stderr}`).toBe(0); const migrationsDir = join(ctx.testDir, 'migrations', 'app'); 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 index aab02a782720..fb0b9237dd6d 100644 --- 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 @@ -24,6 +24,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { injectMigrationSqlDbSetup, type JourneyContext, + latestMigrationDirName, planThenSelfEmit, runContractEmit, runMigrate, @@ -73,7 +74,12 @@ withTempDir(({ createTempDir }) => { const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, `emit required: ${emit1.stderr}`).toBe(0); - const planResult = await runMigrationPlan(ctx, ['--name', 'tighten-name-not-null']); + const planResult = await runMigrationPlan(ctx, [ + '--name', + 'tighten-name-not-null', + '--from', + latestMigrationDirName(ctx), + ]); expect(planResult.exitCode, `plan: ${planResult.stderr}\n${planResult.stderr}`).toBe(0); const migrationsDir = join(ctx.testDir, 'migrations', 'app'); 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 index 4b8288d5d13c..fd19376e75cb 100644 --- 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 @@ -24,6 +24,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { injectMigrationSqlDbSetup, type JourneyContext, + latestMigrationDirName, planThenSelfEmit, runContractEmit, runMigrate, @@ -76,7 +77,12 @@ withTempDir(({ createTempDir }) => { const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, `emit int: ${emit1.stderr}`).toBe(0); - const planResult = await runMigrationPlan(ctx, ['--name', 'retype-score-to-int']); + const planResult = await runMigrationPlan(ctx, [ + '--name', + 'retype-score-to-int', + '--from', + latestMigrationDirName(ctx), + ]); expect(planResult.exitCode, `plan: ${planResult.stderr}\n${planResult.stderr}`).toBe(0); const migrationsDir = join(ctx.testDir, 'migrations', 'app'); 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 18869dba04e5..70be4cae06c5 100644 --- a/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts +++ b/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts @@ -22,6 +22,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, @@ -83,7 +84,13 @@ withTempDir(({ createTempDir }) => { swapContract(staging, 'contract-phone'); const emit1 = await runContractEmit(staging); expect(emit1.exitCode, 'D.04: emit C2').toBe(0); - const plan1 = await planThenSelfEmit(staging, ['--name', 'add-phone', '--json']); + const plan1 = await planThenSelfEmit(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 +99,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 planThenSelfEmit(staging, ['--name', 'add-bio', '--json']); + const plan2 = await planThenSelfEmit(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); 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 index c9c4bed42b1e..c72947458de5 100644 --- a/test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts +++ b/test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -50,7 +51,12 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'P4.pre: emit v2').toBe(0); - const plan1 = await planThenSelfEmit(ctx, ['--name', 'add-name']); + const plan1 = await planThenSelfEmit(ctx, [ + '--name', + 'add-name', + '--from', + latestMigrationDirName(ctx), + ]); 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); @@ -74,9 +80,17 @@ withTempDir(({ createTempDir }) => { 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']); + // P4.02: planning from the surviving migration works even when the + // graph chain is broken — it must not silently greenfield-plan a + // duplicate init. The user names the surviving directory explicitly; + // without --from (and with no db ref) the CLI would plan greenfield. + const planAgain = await runMigrationPlan(ctx, [ + '--name', + 'catch-up', + '--from', + latestMigrationDirName(ctx), + '--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'); 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 a6f0cc691feb..f371aaa957f4 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 @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, planThenSelfEmit, runContractEmit, runMigrate, @@ -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 planThenSelfEmit(ctx, ['--name', 'add-name']); + const plan1 = await planThenSelfEmit(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) @@ -80,7 +91,12 @@ withTempDir(({ createTempDir }) => { expect(applyFail.exitCode, 'P3.02: migration apply fails').not.toBe(0); // P3.03: re-plan the missing edge (chain leaf is additive, contract is v3) - const rePlan = await planThenSelfEmit(ctx, ['--name', 're-add-posts']); + const rePlan = await planThenSelfEmit(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) 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 a965d27ada7c..5d985c089692 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,6 +23,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, planThenSelfEmit, runContractEmit, runDbVerify, @@ -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 planThenSelfEmit(ctx, ['--name', 'rename-search-index']); + const planRename = await planThenSelfEmit(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 planThenSelfEmit(ctx, ['--name', 'edit-search-index-body']); + const planEdit = await planThenSelfEmit(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/index-name-convergence.e2e.test.ts b/test/integration/test/cli-journeys/index-name-convergence.e2e.test.ts index c683eaa6874c..279910c5b60c 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 @@ -25,6 +25,7 @@ import { engineDocument, getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -105,7 +106,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 planThenSelfEmit(ctx, ['--name', 'converge-index-names']); + const plan = await planThenSelfEmit(ctx, [ + '--name', + 'converge-index-names', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan.exitCode, `migration plan\n${stripAnsi(plan.stderr)}`).toBe(0); const ops = readPlannedOps(ctx); expect( 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 6d6fb8ccb8cc..5bc0319b371a 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,6 +16,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, @@ -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 planThenSelfEmit(ctx, ['--name', 'add-phone', '--json']); + const plan1 = await planThenSelfEmit(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']); @@ -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 planThenSelfEmit(ctx, ['--name', 'add-avatar', '--json']); + const plan3 = await planThenSelfEmit(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 216787bdf67e..4e45948c908a 100644 --- a/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts +++ b/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts @@ -25,6 +25,7 @@ import { engineError, injectMigrationSqlDbSetup, type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, @@ -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'); @@ -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); @@ -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( @@ -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); 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 3703f327a583..68edc57498ed 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,6 +14,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -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 planThenSelfEmit(ctx, ['--name', 'add-unique-email']); + const plan1 = await planThenSelfEmit(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 @@ -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 planThenSelfEmit(ctx, ['--name', 'drop-email']); + const plan1 = await planThenSelfEmit(ctx, [ + '--name', + 'drop-email', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan1.exitCode, 'plan drop-email').toBe(0); // Apply destructive migration @@ -236,14 +247,24 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit additive').toBe(0); - const plan1 = await planThenSelfEmit(ctx, ['--name', 'add-name']); + const plan1 = await planThenSelfEmit(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 planThenSelfEmit(ctx, ['--name', 'drop-email']); + const plan2 = await planThenSelfEmit(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-log.e2e.test.ts b/test/integration/test/cli-journeys/migration-log.e2e.test.ts index 566df37702f3..0c03eade487a 100644 --- a/test/integration/test/cli-journeys/migration-log.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-log.e2e.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, planThenSelfEmit, runContractEmit, runMigrate, @@ -46,7 +47,14 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive'); expect((await runContractEmit(ctx)).exitCode, 'emit v2').toBe(0); expect( - (await planThenSelfEmit(ctx, ['--name', 'add-name-column'])).exitCode, + ( + await planThenSelfEmit(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 f63822fc3f41..a585ca383880 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,6 +19,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -136,7 +137,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<{ 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 396407b43be2..11d6eeca8366 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,6 +19,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { EMPTY_CONTRACT_HASH, type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, @@ -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 planThenSelfEmit(ctx, ['--name', 'add-field']); + const plan1 = await planThenSelfEmit(ctx, [ + '--name', + 'add-field', + '--from', + latestMigrationDirName(ctx), + ]); expect(plan1.exitCode, 'plan v2').toBe(0); const status = 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 index 9c3252099e4d..4a196b2759e7 100644 --- a/test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts +++ b/test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts @@ -18,6 +18,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, @@ -59,7 +60,13 @@ withTempDir(({ createTempDir }) => { // 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 planThenSelfEmit(ctx, ['--name', 'add-phone', '--json']); + const plan1 = await planThenSelfEmit(ctx, [ + '--name', + 'add-phone', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(plan1.exitCode, 'plan add-phone').toBe(0); const c2Hash = parseJsonOutput(plan1).to; expect(c2Hash, 'C2 differs from C1').not.toBe(c1Hash); @@ -72,6 +79,8 @@ withTempDir(({ createTempDir }) => { // contract.ts still holds the phone variant throughout. const rollbackTarget = `${addPhoneDir}^`; const planRollback = await planThenSelfEmit(ctx, [ + '--from', + latestMigrationDirName(ctx), '--to', rollbackTarget, '--name', diff --git a/test/integration/test/cli-journeys/ref-routing.e2e.test.ts b/test/integration/test/cli-journeys/ref-routing.e2e.test.ts index 3113ea5fee38..39494b7d5db3 100644 --- a/test/integration/test/cli-journeys/ref-routing.e2e.test.ts +++ b/test/integration/test/cli-journeys/ref-routing.e2e.test.ts @@ -15,6 +15,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, @@ -54,7 +55,13 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'M.02: emit C2').toBe(0); - const plan1 = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--json']); + const plan1 = await planThenSelfEmit(ctx, [ + '--name', + 'add-phone', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(plan1.exitCode, 'M.02: plan C1→C2').toBe(0); const c2Hash = parseJsonOutput<{ to: string }>(plan1).to; 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 d6b5b27ba864..bb32ad917945 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,6 +15,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -106,7 +107,12 @@ withTempDir(({ createTempDir }) => { ); // plan rename: the widening plan is exactly one ALTER POLICY … RENAME. - const plan = await planThenSelfEmit(ctx, ['--name', 'adopt-wire-name']); + const plan = await planThenSelfEmit(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( 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 8bf7f7573128..2b908a55fe6a 100644 --- a/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts +++ b/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -49,7 +50,13 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'J.02: emit C2').toBe(0); - const plan1 = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--json']); + const plan1 = await planThenSelfEmit(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; @@ -61,19 +68,33 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-base'); const emit2 = await runContractEmit(ctx); expect(emit2.exitCode, 'J.03: emit C1 again').toBe(0); - const planRollback = await planThenSelfEmit(ctx, ['--name', 'rollback-phone', '--json']); + const planRollback = await planThenSelfEmit(ctx, [ + '--name', + 'rollback-phone', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(planRollback.exitCode, 'J.03: plan rollback').toBe(0); const apply2 = await runMigrate(ctx); expect(apply2.exitCode, 'J.03: apply rollback').toBe(0); - // J.04: graph has cycle (C1→C2→C1); implicit db ref still plans forward + // J.04: 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); + const planImplicit = await runMigrationPlan(ctx, [ + '--name', + 'add-bio-implicit', + '--from', + latestMigrationDirName(ctx), + '--json', + ]); + expect(planImplicit.exitCode, 'J.04: 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, 'J.04: from resolved').toBeTruthy(); // J.05: plan with --from C1 recovers const planFrom = await planThenSelfEmit(ctx, [ 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 1985cfb6d97e..86faa2befc03 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,6 +20,7 @@ import { clearDbRefForGreenfieldPlan, withTempDir } from '../utils/cli-test-help import { getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -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'); @@ -131,8 +137,12 @@ 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']); + // R.01: migration plan from the leaf (no changes — contract matches it) + const planNoop = await runMigrationPlan(ctx, [ + '--from', + latestMigrationDirName(ctx), + '--json', + ]); expect(planNoop.exitCode, 'R.01: migration plan noop').toBe(0); const noopPlanData = parseJsonOutput(planNoop); expect(noopPlanData, 'R.01: noop flag').toMatchObject({ noOp: true }); 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 99628c236810..37ec1b6ea4af 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 @@ -28,6 +28,7 @@ import { engineDocument, getLatestMigrationDir, type JourneyContext, + latestMigrationDirName, parseJsonOutput, planThenSelfEmit, runContractEmit, @@ -203,7 +204,12 @@ describe('sign a database this toolchain has never seen, then transition to wire expect(emitWire.exitCode, `3.2: emit wire\n${stripAnsi(emitWire.stderr)}`).toBe(0); // The widening plan is EXACTLY the two renames. - const plan = await planThenSelfEmit(ctx, ['--name', 'adopt-wire-names']); + const plan = await planThenSelfEmit(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( 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.emit-command.additional.test.ts b/test/integration/test/cli.emit-command.additional.test.ts index a65f3cd29489..43e80b180f8a 100644 --- a/test/integration/test/cli.emit-command.additional.test.ts +++ b/test/integration/test/cli.emit-command.additional.test.ts @@ -171,9 +171,7 @@ describe('emit command: additional fixtures', () => { ); try { - const run = await runOnEngine(testSetup, ['contract', 'emit', '--json'], { - settleConfigFailures: true, - }); + const run = await runOnEngine(testSetup, ['contract', 'emit', '--json']); expect(run.exitCode).toBe(2); const terminal = run.json.at(-1); diff --git a/test/integration/test/cli.emit-command.e2e.test.ts b/test/integration/test/cli.emit-command.e2e.test.ts index 179c5697c418..d865fbef7057 100644 --- a/test/integration/test/cli.emit-command.e2e.test.ts +++ b/test/integration/test/cli.emit-command.e2e.test.ts @@ -119,11 +119,13 @@ withTempDir(({ createTempDir }) => { 'prisma.config.emit.ts', ); - const run = await runOnEngine( - testSetup, - ['contract', 'emit', '--config', 'nonexistent.config.ts', '--json'], - { settleConfigFailures: true }, - ); + const run = await runOnEngine(testSetup, [ + 'contract', + 'emit', + '--config', + 'nonexistent.config.ts', + '--json', + ]); // Config errors should have exit code 2 expect(run.exitCode).toBe(2); 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 6d529a4e1d82..9c3434a3d2e9 100644 --- a/test/integration/test/cli.migrate-external-space.e2e.test.ts +++ b/test/integration/test/cli.migrate-external-space.e2e.test.ts @@ -30,7 +30,6 @@ import { TEST_EXTERNAL_SPACE_ID, } from './contract-space-fixture/external-space'; import { - appendImplicitMigrationPlanFrom, type EngineRunResult, runMigrationFile, runOnEngine, @@ -83,8 +82,7 @@ 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 82d6178c8538..b8ba27bbd5b9 100644 --- a/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts +++ b/test/integration/test/cli.migrate-ref-advancement.e2e.test.ts @@ -4,7 +4,6 @@ import { timeouts, withDevDatabase } from '@repo/test-utils'; import { dirname, join } from 'pathe'; import { describe, expect, it } from 'vitest'; import { - appendImplicitMigrationPlanFrom, type EngineRunResult, runMigrationFile, runOnEngine, @@ -57,8 +56,7 @@ async function selfEmitLatestMigration(testDir: string): Promise { } 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/utils/cli-test-helpers.ts b/test/integration/test/utils/cli-test-helpers.ts index 04393c86575c..553e2622121e 100644 --- a/test/integration/test/utils/cli-test-helpers.ts +++ b/test/integration/test/utils/cli-test-helpers.ts @@ -6,7 +6,6 @@ import { readdirSync, readFileSync, rmSync, - statSync, writeFileSync, } from 'node:fs'; import { dirname, join } from 'node:path'; @@ -15,11 +14,10 @@ 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)); @@ -42,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; } /** @@ -75,47 +67,62 @@ 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(); + +/** + * 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, @@ -132,20 +139,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. * @@ -440,65 +433,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)) { diff --git a/test/integration/test/utils/journey-test-helpers.ts b/test/integration/test/utils/journey-test-helpers.ts index 65d75d0f7aa7..9981ca8a6d1b 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -23,7 +23,6 @@ import { isAbsolute, join } from 'pathe'; import { afterAll, beforeAll } from 'vitest'; import { - appendImplicitMigrationPlanFrom, runOnEngine as runCommandOnEngine, runMigrationFile, writeProjectManifest, @@ -353,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( @@ -523,7 +518,7 @@ export async function selfEmitMigration( export async function planThenSelfEmit( ctx: JourneyContext, extraArgs: readonly string[] = [], -): Promise { +): Promise { const planResult = await runMigrationPlan(ctx, extraArgs); if (planResult.exitCode !== 0) return planResult; const latest = getLatestMigrationDir(ctx); @@ -557,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( @@ -586,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; +export function parseJsonOutput>(result: EngineCommandResult): T { + if (result.presented !== undefined) { + return (result.presented.presentation.json ?? result.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}`); - } - 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; } /** @@ -657,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 { @@ -722,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); } @@ -757,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(); } @@ -771,6 +727,21 @@ 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; 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/, + ); }); }); From 08492e738aee521617eb60c0e75fee114799fda3 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 19 Aug 2026 09:34:00 +0200 Subject: [PATCH 3/7] test(integration): collapse duplicate CLI coverage to one survivor per cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedup per the test-estate audit, re-verified against the current tree. Deleted files (coverage folded into the named survivor first): - converging-paths → diamond-convergence (new D.11 shortest-path apply against an empty third database) - drift-deleted-root → drift-migration-dag (orphan-visibility and no-duplicate-greenfield assertions folded into P3.01/P3.03) - plan-to-rollback → rollback-cycle (J.03 is now the one-command `--to ^` rollback, TML-2690, with no contract-source edit) - ref-routing → divergence-and-refs (ahead-ref pending count as L.05, marker-ahead-of-ref failure + status condition as L.08) - data-transform-{not-null-backfill,nullable-tightening,type-change} → one describe.each file, data-transform-strategies, all assertions kept - invariant-routing.mongo → deleted as a case-for-case mirror of the Postgres file; its mongo-only piece (invariant accumulation on the marker doc via $setUnion + ref routing) moved into mongo-migration - cli.emit-command.{e2e,additional} merged into cli.emit-command (all 23 cases preserved) - migration-status-diagnostics loses only its "divergent graph with ref" case (now asserted through divergence-and-refs L.07); infer-roundtrip-fidelity loses only its duplicate full-loop case; sign-the-database hands its RLS half to rls-exact-name-adoption and absorbs index-name-convergence's fields-only adoption assertions Also: help-and-flags Y.01–Y.03 now assert real differences (ANSI with/without --no-color, quiet drops the progress line, verbose adds timings); init-journey's dead seamExpectation scaffolding is gone (all seams were 'fixed'; steps assert the working behavior directly); stale `--ref`/`migration apply`/`migration emit` spellings and tombstone comments swept; journeys README and the migration user-journeys doc updated. CLI scope: 83 files/362 tests → 74 files/352 tests, all green. Full deletion log with per-case dispositions in the PR body. Signed-off-by: willbot Signed-off-by: Will Madden --- .../10-domains/migration/user-journeys.md | 2 +- test/integration/test/cli-journeys/README.md | 3 +- .../cli-journeys/converging-paths.e2e.test.ts | 104 ---- ...ta-transform-not-null-backfill.e2e.test.ts | 171 ------ ...-transform-nullable-tightening.e2e.test.ts | 169 ------ .../data-transform-strategies.e2e.test.ts | 289 +++++++++ .../data-transform-type-change.e2e.test.ts | 179 ------ .../db-update-workflows.e2e.test.ts | 11 +- .../diamond-convergence.e2e.test.ts | 17 + .../divergence-and-refs.e2e.test.ts | 41 +- .../drift-deleted-root.e2e.test.ts | 104 ---- .../cli-journeys/drift-marker.e2e.test.ts | 2 +- .../drift-migration-dag.e2e.test.ts | 30 +- .../expression-index-migration.e2e.test.ts | 2 +- .../cli-journeys/help-and-flags.e2e.test.ts | 44 +- .../index-name-convergence.e2e.test.ts | 66 +- .../infer-roundtrip-fidelity.e2e.test.ts | 23 - .../cli-journeys/init-journey.e2e.test.ts | 108 +--- .../test/cli-journeys/init-journey/harness.ts | 25 - .../invariant-routing.e2e.test.ts | 42 +- .../invariant-routing.mongo.e2e.test.ts | 573 ------------------ .../migration-plan-details.e2e.test.ts | 7 - .../migration-round-trip.e2e.test.ts | 12 +- .../migration-status-diagnostics.e2e.test.ts | 55 +- .../cli-journeys/mongo-migration.e2e.test.ts | 59 +- .../multi-step-migration.e2e.test.ts | 4 +- .../cli-journeys/plan-to-rollback.e2e.test.ts | 126 ---- .../test/cli-journeys/ref-routing.e2e.test.ts | 120 ---- .../rls-exact-name-adoption.e2e.test.ts | 2 +- .../cli-journeys/rollback-cycle.e2e.test.ts | 44 +- .../schema-evolution-migrations.e2e.test.ts | 18 +- .../sign-the-database.e2e.test.ts | 68 +-- .../cli.db-verify.aggregate-schema.test.ts | 10 +- .../test/cli.emit-command.additional.test.ts | 374 ------------ .../test/cli.emit-command.e2e.test.ts | 214 ------- .../integration/test/cli.emit-command.test.ts | 566 ++++++++++++++++- 36 files changed, 1146 insertions(+), 2538 deletions(-) delete mode 100644 test/integration/test/cli-journeys/converging-paths.e2e.test.ts delete mode 100644 test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts delete mode 100644 test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts create mode 100644 test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts delete mode 100644 test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts delete mode 100644 test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts delete mode 100644 test/integration/test/cli-journeys/invariant-routing.mongo.e2e.test.ts delete mode 100644 test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts delete mode 100644 test/integration/test/cli-journeys/ref-routing.e2e.test.ts delete mode 100644 test/integration/test/cli.emit-command.additional.test.ts delete mode 100644 test/integration/test/cli.emit-command.e2e.test.ts 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/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index a6e72d693219..cf66614579fc 100644 --- a/test/integration/test/cli-journeys/README.md +++ b/test/integration/test/cli-journeys/README.md @@ -33,10 +33,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/converging-paths.e2e.test.ts b/test/integration/test/cli-journeys/converging-paths.e2e.test.ts deleted file mode 100644 index 058bd8b640c3..000000000000 --- a/test/integration/test/cli-journeys/converging-paths.e2e.test.ts +++ /dev/null @@ -1,104 +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, - latestMigrationDirName, - parseJsonOutput, - planThenSelfEmit, - runContractEmit, - runMigrate, - 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 planThenSelfEmit(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 planThenSelfEmit(ctx, [ - '--name', - 'add-phone', - '--from', - latestMigrationDirName(ctx), - '--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 planThenSelfEmit(ctx, [ - '--name', - 'add-bio-via-c2', - '--from', - latestMigrationDirName(ctx), - '--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 planThenSelfEmit(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 c8be76e7a066..000000000000 --- a/test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts +++ /dev/null @@ -1,171 +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, - latestMigrationDirName, - planThenSelfEmit, - runContractEmit, - runMigrate, - runMigrationPlan, - selfEmitMigration, - 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 planThenSelfEmit(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', - '--from', - latestMigrationDirName(ctx), - ]); - 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 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')); - 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 fb0b9237dd6d..000000000000 --- a/test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts +++ /dev/null @@ -1,169 +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, - latestMigrationDirName, - planThenSelfEmit, - runContractEmit, - runMigrate, - runMigrationPlan, - selfEmitMigration, - 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 planThenSelfEmit(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', - '--from', - latestMigrationDirName(ctx), - ]); - 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 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')); - 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..6668b2b030a1 --- /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, + planThenSelfEmit, + 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 planThenSelfEmit(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 fd19376e75cb..000000000000 --- a/test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts +++ /dev/null @@ -1,179 +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, - latestMigrationDirName, - planThenSelfEmit, - runContractEmit, - runMigrate, - runMigrationPlan, - selfEmitMigration, - 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 planThenSelfEmit(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', - '--from', - latestMigrationDirName(ctx), - ]); - 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 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')); - 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-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 70be4cae06c5..c94cc1bf09f6 100644 --- a/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts +++ b/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts @@ -49,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', @@ -225,6 +226,22 @@ withTempDir(({ createTempDir }) => { prodStatusData.migrations.length, 'D.10: production lists migrations', ).toBeGreaterThan(3); + + // D.11: 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 (P-3/S-3). + const fresh = createSecondDbContext(staging, freshDb.connectionString); + const applyFresh = await runMigrate(fresh, ['--json']); + expect(applyFresh.exitCode, 'D.11: apply to empty database').toBe(0); + const freshResult = parseJsonOutput<{ + ok: boolean; + migrationsApplied: number; + markerHash: string; + }>(applyFresh); + expect(freshResult.ok, 'D.11: ok').toBe(true); + expect(freshResult.markerHash, 'D.11: marker at C5').toBe(c5Hash); + expect(freshResult.migrationsApplied, 'D.11: 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 4500a08b506e..440daa170b5a 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. */ @@ -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) + // L.04: 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, M.05). + const statusAhead = await runMigrationStatus(ctx, ['--to', 'production', '--json']); + expect(statusAhead.exitCode, 'L.05: status --to production').toBe(0); + const aheadPending = migrationStatusAppSpace( + parseMigrationStatusJson(statusAhead), + ).migrations.filter((m) => m.status === 'pending').length; + expect(aheadPending, 'L.05: production has 1 pending').toBe(1); + + // L.06: 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, 'L.06: apply --to production').toBe(0); const applyResult = parseJsonOutput<{ ok: boolean; migrationsApplied: number; @@ -97,9 +106,29 @@ 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 + // L.07: 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, 'L.07: status --to production').toBe(0); + expect( + migrationStatusAppSpace(parseMigrationStatusJson(statusRef)).targetContract, + 'L.07: target resolved via ref to C3', + ).toBe(c3Hash); + + // L.08: marker ahead of ref (folded in from the deleted ref-routing + // journey, N.01/N.02 — 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, 'L.08: ref set production=C1').toBe(0); + const applyBehind = await runMigrate(ctx, ['--to', 'production', '--json']); + expect(applyBehind.exitCode, 'L.08: apply --to production fails').toBe(2); + const statusBehind = await runMigrationStatus(ctx, ['--to', 'production', '--json']); + expect( + parseMigrationStatusJson(statusBehind).summary, + 'L.08: 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 c72947458de5..000000000000 --- a/test/integration/test/cli-journeys/drift-deleted-root.e2e.test.ts +++ /dev/null @@ -1,104 +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, - latestMigrationDirName, - parseJsonOutput, - planThenSelfEmit, - runContractEmit, - runMigrate, - runMigrationPlan, - 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 planThenSelfEmit(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 planThenSelfEmit(ctx, [ - '--name', - 'add-name', - '--from', - latestMigrationDirName(ctx), - ]); - 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: planning from the surviving migration works even when the - // graph chain is broken — it must not silently greenfield-plan a - // duplicate init. The user names the surviving directory explicitly; - // without --from (and with no db ref) the CLI would plan greenfield. - const planAgain = await runMigrationPlan(ctx, [ - '--name', - 'catch-up', - '--from', - latestMigrationDirName(ctx), - '--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..46670066c0b8 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) + // P.02: 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 f371aaa957f4..22a9f01720b7 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. */ @@ -82,13 +82,20 @@ 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) + // P3.01: migration status (reports 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, P4.01). const statusBroken = await runMigrationStatus(ctx); expect([0, 1], 'P3.01: status exits 0 or 1').toContain(statusBroken.exitCode); + expect(statusBroken.stderr, 'P3.01: surviving migrations visible').toMatch(/add_name/); + expect(statusBroken.stderr, 'P3.01: not treated as empty').not.toContain( + 'No migrations found', + ); - // P3.02: migration apply (fails — no path from marker to destination contract) + // P3.02: 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, 'P3.02: migrate fails').not.toBe(0); // P3.03: re-plan the missing edge (chain leaf is additive, contract is v3) const rePlan = await planThenSelfEmit(ctx, [ @@ -99,9 +106,20 @@ withTempDir(({ createTempDir }) => { ]); 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, P4.02). + const dirsAfterRePlan = readdirSync(migrationsDir).filter( + (d) => !d.startsWith('.') && d !== 'refs', + ); + expect( + dirsAfterRePlan.filter((d) => d.endsWith('_initial')), + 'P3.03: exactly one init migration', + ).toHaveLength(1); + + // P3.04: 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, 'P3.04: migrate recovery').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 5d985c089692..25ca09c87eb1 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 @@ -77,7 +77,7 @@ async function runInitialFlow(ctx: JourneyContext, connectionString: string): Pr ); 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); 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..7a3cc3cc6f6a 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,29 +13,30 @@ 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) + // Y.01: --no-color it( - 'Y.01: --no-color suppresses ANSI codes in stdout', + 'Y.01: --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, 'Y.01: colored emit succeeds').toBe(0); + const plain = await runContractEmit(ctx, ['--no-color']); + expect(plain.exitCode, 'Y.01: --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, 'Y.01: TTY run carries ANSI codes').toContain('\u001b['); + expect(plain.stdout + plain.stderr, 'Y.01: --no-color output is ANSI-free').not.toContain( + '\u001b[', + ); }, timeouts.typeScriptCompilation, ); // Y.02: -q (quiet) it( - 'Y.02: quiet mode reduces output', + 'Y.02: quiet mode drops the progress commentary the default run prints', async () => { const ctx = setupJourney({ createTempDir }); @@ -45,17 +46,18 @@ withTempDir(({ createTempDir }) => { const quiet = await runContractEmit(ctx, ['-q']); expect(quiet.exitCode, 'Y.02: 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, 'Y.02: default run narrates progress').toContain('Emitting contract'); + expect(quiet.stderr, 'Y.02: quiet run does not').not.toContain('Emitting contract'); + expect(quiet.stderr.length, 'Y.02: quiet output is strictly shorter').toBeLessThan( + normal.stderr.length, + ); }, timeouts.typeScriptCompilation, ); // Y.03: -v (verbose) it( - 'Y.03: verbose mode increases output', + 'Y.03: verbose mode adds timings the default run does not print', async () => { const ctx = setupJourney({ createTempDir }); @@ -65,10 +67,8 @@ withTempDir(({ createTempDir }) => { const verbose = await runContractEmit(ctx, ['-v']); expect(verbose.exitCode, 'Y.03: 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, 'Y.03: verbose run reports timings').toContain('Total time'); + expect(normal.stderr, 'Y.03: default run does not').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 279910c5b60c..477e9488c986 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,7 +22,6 @@ import stripAnsi from 'strip-ansi'; import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { - engineDocument, getLatestMigrationDir, type JourneyContext, latestMigrationDirName, @@ -31,7 +30,6 @@ import { runContractEmit, runContractInfer, runDbSign, - runDbUpdate, runDbVerify, runMigrate, setupJourney, @@ -136,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, }); @@ -158,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..78df2405662a 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,9 @@ * 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 (TML-2461, TML-2486, + * TML-2487, TML-2314) are all fixed; each step now asserts the working + * behavior directly. */ import { existsSync, readFileSync } from 'node:fs'; @@ -33,7 +28,6 @@ import { migrationPlan, runUserCode, type StepResult, - seamExpectation, selfEmitLatestMigration, } from './init-journey/harness'; @@ -112,18 +106,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 (TML-2486 seam)', () => { 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 +137,7 @@ describe.each(ALL_CELLS.map((cell) => ({ cell, label: cellLabel(cell) })))( '', ].join('\n'), ); - TML_2487_seam(run); + expectObjectIdImportWorks(run); }, timeouts.coldTransformImport, ); @@ -199,7 +196,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 +207,7 @@ describe.each(ALL_CELLS.map((cell) => ({ cell, label: cellLabel(cell) })))( '', ].join('\n'), ); - TML_2314_seam(run); + expectPostgresUserCodeRoundTrip(run); }, timeouts.coldTransformImport, ); @@ -275,71 +272,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/invariant-routing.e2e.test.ts b/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts index 4e45948c908a..71238d289551 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. @@ -167,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, + // O.07: 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, 'O.07: apply --to prod').toBe(0); const applyResult = parseJsonOutput<{ ok: boolean; markerHash: string; @@ -204,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 + // O.09: 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, 'O.09: status --to prod').toBe(0); const statusResult = parseMigrationStatusJson(statusRef); expect( statusResult.diagnostics?.some((d) => d.code === 'MIGRATION.MISSING_INVARIANTS'), @@ -219,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 + // O.10: 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, 'O.10: re-apply --to prod').toBe(0); const reapplyResult = parseJsonOutput<{ ok: boolean; markerHash: string; @@ -294,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. + // P.04: 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<{ @@ -310,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). + // P.06: 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( @@ -395,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, + // Q.05: 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']); @@ -430,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). // @@ -442,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, @@ -493,7 +493,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, 'R.02: apply --to prod').toBe(0); expect( parseJsonOutput<{ markerHash: string }>(apply1).markerHash, 'R.02: marker at C2', @@ -521,7 +521,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, 'R.04: re-apply --to prod').toBe(0); const apply2Result = parseJsonOutput<{ markerHash: string; pathDecision?: { @@ -560,7 +560,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', @@ -659,7 +659,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}`, + `S.05: apply --to prod: ${applyRef.stdout}\n${applyRef.stderr}`, ).toBe(0); const applyResult = parseJsonOutput<{ markerHash: string; @@ -707,7 +707,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'; @@ -717,12 +717,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, @@ -830,7 +830,7 @@ MigrationCLI.run(import.meta.url, M); [], ); - // T.05: status --ref must report INVARIANTS_PENDING, NOT UP_TO_DATE. + // T.05: 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 220c954b583a..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, - runMigrationNew, - runMigrationPlan, - runMigrationStatus, - selfEmitMigration, -} 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 selfEmitMigration(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 selfEmitMigration(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 selfEmitMigration(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 selfEmitMigration(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 selfEmitMigration(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 selfEmitMigration(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 selfEmitMigration(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-plan-details.e2e.test.ts b/test/integration/test/cli-journeys/migration-plan-details.e2e.test.ts index a585ca383880..0b7a04912d0f 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 @@ -51,10 +51,6 @@ 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 planThenSelfEmit(ctx, ['--name', 'initial', '--json']); expect(plan.exitCode, 'H.02: migration plan --json').toBe(0); @@ -72,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(); 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 7a54091b5b73..603fb4bb8783 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,18 +4,18 @@ * 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 @@ -62,7 +62,7 @@ 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); @@ -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). // ----------------------------------------------------------- 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 11d6eeca8366..744cd2cb2978 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 @@ -572,61 +572,8 @@ withTempDir(({ createTempDir }) => { * 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. + * validates that --to 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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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(); 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 af020a4c7890..09fecaa4482f 100644 --- a/test/integration/test/cli-journeys/mongo-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/mongo-migration.e2e.test.ts @@ -43,6 +43,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { fixtureAppDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + parseJsonOutput, runContractEmit, runMigrate, runMigrationNew, @@ -50,6 +51,24 @@ import { 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 { @@ -201,7 +220,7 @@ describe('Journey: Mongo migration authoring (offline)', { timeout: timeouts.spi '--dir', `migrations/app/${basename(migrationDir)}`, ]); - expect(emit.exitCode, `migration emit: ${emit.stdout}\n${emit.stderr}`).toBe(0); + 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; @@ -332,11 +351,11 @@ describe('Journey: Mongo migration authoring (live database)', { ]); 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'); @@ -390,6 +409,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', @@ -420,9 +440,10 @@ MigrationCLI.run(import.meta.url, M); writeFileSync(migrationTsPath, handAuthored); const emitResult = await selfEmitMigration(ctx, ['--dir', migrationDir]); - expect(emitResult.exitCode, `migration emit: ${emitResult.stdout}\n${emitResult.stderr}`).toBe( - 0, - ); + 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; @@ -438,8 +459,28 @@ 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; + 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) @@ -460,7 +501,7 @@ MigrationCLI.run(import.meta.url, M); // 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); + const apply2 = await runMigrate(ctx, ['--to', 'prod', '--json']); expect(apply2.exitCode, `re-apply: ${apply2.stdout}\n${apply2.stderr}`).toBe(0); const usersAfterReApply = await client 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 eff0525602cb..ea42c92df9fc 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 @@ -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) + // C.06: migrate --db (applies both) const apply = await runMigrate(ctx); - expect(apply.exitCode, 'C.06: migration apply all').toBe(0); + expect(apply.exitCode, 'C.06: migrate all').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 4a196b2759e7..000000000000 --- a/test/integration/test/cli-journeys/plan-to-rollback.e2e.test.ts +++ /dev/null @@ -1,126 +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, - latestMigrationDirName, - migrationStatusAppSpace, - parseJsonOutput, - parseMigrationStatusJson, - planThenSelfEmit, - runContractEmit, - runMigrate, - 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 planThenSelfEmit(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 planThenSelfEmit(ctx, [ - '--name', - 'add-phone', - '--from', - latestMigrationDirName(ctx), - '--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 planThenSelfEmit(ctx, [ - '--from', - latestMigrationDirName(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 39494b7d5db3..000000000000 --- a/test/integration/test/cli-journeys/ref-routing.e2e.test.ts +++ /dev/null @@ -1,120 +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, - latestMigrationDirName, - migrationStatusAppSpace, - parseJsonOutput, - parseMigrationStatusJson, - planThenSelfEmit, - runContractEmit, - runMigrate, - 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 planThenSelfEmit(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 planThenSelfEmit(ctx, [ - '--name', - 'add-phone', - '--from', - latestMigrationDirName(ctx), - '--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 bb32ad917945..7bf6711d70f8 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 @@ -132,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 2b908a55fe6a..d3d447ed9624 100644 --- a/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts +++ b/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts @@ -2,11 +2,13 @@ * 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 (TML-2690): `--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 { @@ -64,20 +66,42 @@ 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); + // J.03: one-command rollback (TML-2690, 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 planThenSelfEmit(ctx, [ '--name', 'rollback-phone', '--from', - latestMigrationDirName(ctx), + addPhoneDir, + '--to', + rollbackTarget, '--json', ]); - expect(planRollback.exitCode, 'J.03: plan rollback').toBe(0); - const apply2 = await runMigrate(ctx); + expect(planRollback.exitCode, 'J.03: plan rollback --to ^').toBe(0); + const rollback = parseJsonOutput<{ + from: string; + to: string; + operations: readonly { operationClass: string }[]; + }>(planRollback); + expect(rollback.from, 'J.03: rollback from C2').toBe(c2Hash); + expect(rollback.to, 'J.03: rollback to predecessor C1').toBe(c1Hash); + expect( + rollback.operations.some((op) => op.operationClass === 'destructive'), + 'J.03: reverse delta drops the added column (destructive), no refusal', + ).toBe(true); + const contractSource = readFileSync(join(ctx.testDir, 'contract.ts'), 'utf-8'); + expect(contractSource, 'J.03: contract source untouched (still phone variant)').toContain( + 'phone', + ); + const apply2 = await runMigrate(ctx, ['--to', rollbackTarget, '-y', '--json']); expect(apply2.exitCode, 'J.03: apply rollback').toBe(0); + const applied2 = parseJsonOutput<{ ok: boolean; markerHash: string }>(apply2); + expect(applied2.ok, 'J.03: rollback applied ok').toBe(true); + expect(applied2.markerHash, 'J.03: marker moved back to C1').toBe(c1Hash); // J.04: graph has cycle (C1→C2→C1); planning from the rollback tip // (named explicitly — with no db ref, an unflagged plan would be 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 86faa2befc03..2d4f7155d512 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 @@ -82,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 + // B.04: self-emit the planned migration.ts const migDir = getLatestMigrationDir(ctx); expect(migDir, 'B.04: migration dir exists').toBeDefined(); const emitMig = await selfEmitMigration(ctx, ['--dir', `migrations/app/${migDir}`]); - expect(emitMig.exitCode, 'B.04: migration emit').toBe(0); + expect(emitMig.exitCode, 'B.04: 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 + // B.06: migrate const apply = await runMigrate(ctx); - expect(apply.exitCode, 'B.06: migration apply').toBe(0); + expect(apply.exitCode, 'B.06: migrate').toBe(0); // B.07: migration status (all applied) const statusApplied = await runMigrationStatus(ctx); @@ -124,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) + // Q.01: 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, 'Q.01: migrate noop').toBe(0); const noopApplyData = parseJsonOutput(applyNoop); expect(noopApplyData, 'Q.01: 0 applied').toMatchObject({ ok: true, @@ -206,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 + // Z.03: 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, 'Z.03: 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 37ec1b6ea4af..fcbb259cc912 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,11 +18,7 @@ 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, @@ -53,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 @@ -67,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; @@ -137,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 {'); @@ -188,22 +188,21 @@ 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. + // The widening plan is EXACTLY the one rename. const plan = await planThenSelfEmit(ctx, [ '--name', 'adopt-wire-names', @@ -213,30 +212,23 @@ describe('sign a database this toolchain has never seen, then transition to wire 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.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 43e80b180f8a..000000000000 --- a/test/integration/test/cli.emit-command.additional.test.ts +++ /dev/null @@ -1,374 +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']); - 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 d865fbef7057..000000000000 --- a/test/integration/test/cli.emit-command.e2e.test.ts +++ /dev/null @@ -1,214 +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', - ]); - - // 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(); + } + }); +}); From fdea225b2efc4fa4bec51504081fc3b44372566c Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 19 Aug 2026 09:41:16 +0200 Subject: [PATCH 4/7] test(integration): run the init-journey matrix nightly instead of on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4-cell (target × authoring) pack+install matrix is the most expensive file in the suite and its subject — the packed-tarball install seam — does not change with typical PRs. Both vitest configs now exclude it unless RUN_INIT_JOURNEY=1; the existing nightly workflow gains a dedicated step (pnpm --filter integration-tests test:init-journey) so the matrix still runs every night on main. PR runs keep the engine-based init coverage. No V8/PGlite execArgv, retry, or pool settings changed. Signed-off-by: willbot Signed-off-by: Will Madden --- .github/workflows/integration-nightly.yml | 2 ++ test/integration/package.json | 1 + test/integration/vitest.config.ts | 9 ++++++++- test/integration/vitest.journeys.config.ts | 4 +++- 4 files changed, 14 insertions(+), 2 deletions(-) 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/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/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 From c2a48d970c060f13aad3debcf506b149a2440d60 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 19 Aug 2026 09:52:35 +0200 Subject: [PATCH 5/7] test(integration): address review round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mongo-migration: the hand-authored dataTransform case now reads the marker document back through `migration status --to prod --json` (no MIGRATION.MISSING_INVARIANTS diagnostic, up-to-date summary, path migrations applied) and pins the re-apply as a true no-op (markerHash unchanged, up-to-date summary) — the $setUnion accumulation coverage the deleted mongo mirror carried - sign-the-database: it#2 renamed to say one rename, matching what it asserts since the RLS half moved out - migration-status-diagnostics: orphaned block comment from the deleted "divergent graph with ref" case removed - migration-round-trip: companion pointer updated to data-transform-strategies Signed-off-by: willbot Signed-off-by: Will Madden --- .../migration-round-trip.e2e.test.ts | 8 ++--- .../migration-status-diagnostics.e2e.test.ts | 9 ----- .../cli-journeys/mongo-migration.e2e.test.ts | 34 +++++++++++++++++-- .../sign-the-database.e2e.test.ts | 2 +- 4 files changed, 37 insertions(+), 16 deletions(-) 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 603fb4bb8783..801d578b890b 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 @@ -19,10 +19,10 @@ * 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'; 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 744cd2cb2978..b97c9ef2c9f5 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 @@ -565,15 +565,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 --to is the correct escape hatch for ambiguous graphs. - */ describe('--from constrains the path origin', () => { const db = useDevDatabase(); 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 09fecaa4482f..4fca3468ddd5 100644 --- a/test/integration/test/cli-journeys/mongo-migration.e2e.test.ts +++ b/test/integration/test/cli-journeys/mongo-migration.e2e.test.ts @@ -43,11 +43,14 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { fixtureAppDir } from '../utils/cli-test-helpers'; import { type JourneyContext, + migrationStatusAppSpace, parseJsonOutput, + parseMigrationStatusJson, runContractEmit, runMigrate, runMigrationNew, runMigrationPlan, + runMigrationStatus, selfEmitMigration, } from '../utils/journey-test-helpers'; @@ -468,6 +471,7 @@ MigrationCLI.run(import.meta.url, M); 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[]; @@ -499,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. + // 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/sign-the-database.e2e.test.ts b/test/integration/test/cli-journeys/sign-the-database.e2e.test.ts index fcbb259cc912..e5ea613bebce 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 @@ -169,7 +169,7 @@ 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(); From 26087b566e8ca08d2173c5bdf2aa877a89b5b85b Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 20 Aug 2026 13:19:52 +0200 Subject: [PATCH 6/7] =?UTF-8?q?test(integration):=20operator=20review=20?= =?UTF-8?q?=E2=80=94=20plain=20naming,=20no=20transient=20case=20IDs,=20Co?= =?UTF-8?q?deRabbit=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename planThenSelfEmit → planMigrationAndSelfEmit across all 28 call-site files, with a plain-English doc comment (plans, then runs the scaffolded migration.ts so it writes its own ops.json and migration.json); selfEmitMigration keeps its name with an equally plain comment - sweep every transient journey-case ID (D.11, L.08, J.03, Y.01, …) and ticket ID our diff added in comments, test names, and assertion labels — 82 instances replaced with labels that describe the behavior; pre-existing ID lines untouched - divergence-and-refs: assert the ahead-of-ref status exit code before reading its summary (CodeRabbit) - migration-list: the second plan names its parent explicitly via latestMigrationDirName, per the real-argv contract (CodeRabbit) - journeys README: document the nightly-only init-journey and the RUN_INIT_JOURNEY=1 guard (CodeRabbit) - cli-test-helpers: evictEngineCli(testDir) drops cached TestCli entries from every temp-dir cleanup path, so a worker no longer retains a harness per deleted directory (CodeRabbit) - journey-test-helpers: getLatestMigrationDir selects the tip by the manifest's createdAt with a deterministic dir-name tie-break instead of directory mtime (CodeRabbit) Signed-off-by: willbot Signed-off-by: Will Madden --- test/integration/test/cli-journeys/README.md | 3 ++ .../cli-journeys/adopt-migrations.e2e.test.ts | 10 ++-- .../data-transform-strategies.e2e.test.ts | 4 +- .../db-sign-contract-arg.e2e.test.ts | 4 +- .../diamond-convergence.e2e.test.ts | 25 ++++----- .../divergence-and-refs.e2e.test.ts | 39 +++++++------- .../cli-journeys/drift-marker.e2e.test.ts | 2 +- .../drift-migration-dag.e2e.test.ts | 32 ++++++----- .../expression-index-migration.e2e.test.ts | 8 +-- .../cli-journeys/help-and-flags.e2e.test.ts | 38 ++++++------- .../index-name-convergence.e2e.test.ts | 6 +-- .../cli-journeys/init-journey.e2e.test.ts | 7 ++- .../interleaved-db-update.e2e.test.ts | 10 ++-- .../invariant-routing.e2e.test.ts | 49 ++++++++--------- .../migration-apply-edge-cases.e2e.test.ts | 18 +++---- .../cli-journeys/migration-check.e2e.test.ts | 18 +++---- .../migration-graph-dot.e2e.test.ts | 6 +-- .../cli-journeys/migration-list.e2e.test.ts | 12 +++-- .../cli-journeys/migration-log.e2e.test.ts | 8 +-- .../migration-plan-details.e2e.test.ts | 6 +-- .../migration-round-trip.e2e.test.ts | 4 +- .../migration-show-reachability.e2e.test.ts | 8 +-- .../migration-status-diagnostics.e2e.test.ts | 53 ++++++++++++------- .../multi-step-migration.e2e.test.ts | 12 ++--- .../rls-exact-name-adoption.e2e.test.ts | 6 +-- .../cli-journeys/rollback-cycle.e2e.test.ts | 40 +++++++------- .../schema-evolution-migrations.e2e.test.ts | 26 ++++----- .../sign-the-database.e2e.test.ts | 6 +-- .../test/cli.migrate-drift-check.e2e.test.ts | 20 +++---- .../cli.migration-plan-ref-aware.e2e.test.ts | 8 +-- .../cli.ref-pointer-integration.e2e.test.ts | 4 +- .../test/utils/cli-test-helpers.ts | 16 ++++++ .../test/utils/journey-test-helpers.ts | 40 ++++++++------ 33 files changed, 305 insertions(+), 243 deletions(-) diff --git a/test/integration/test/cli-journeys/README.md b/test/integration/test/cli-journeys/README.md index cf66614579fc..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 | 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 8de2549ab4b0..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,7 +14,7 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbUpdate, runMigrate, @@ -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 planThenSelfEmit(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 planThenSelfEmit(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/data-transform-strategies.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts index 6668b2b030a1..13338cb41803 100644 --- a/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts +++ b/test/integration/test/cli-journeys/data-transform-strategies.e2e.test.ts @@ -26,7 +26,7 @@ import { injectMigrationSqlDbSetup, type JourneyContext, latestMigrationDirName, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationPlan, @@ -177,7 +177,7 @@ withTempDir(({ createTempDir }) => { } const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); - const plan0 = await planThenSelfEmit(ctx, ['--name', 'initial']); + 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); 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 9eff3e2f841f..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,7 +17,7 @@ import { engineDocument, type JourneyContext, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbInit, runDbSign, @@ -42,7 +42,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await planThenSelfEmit(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/diamond-convergence.e2e.test.ts b/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts index c94cc1bf09f6..be6a67a56bcb 100644 --- a/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts +++ b/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts @@ -26,7 +26,7 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationStatus, @@ -63,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 planThenSelfEmit(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; @@ -85,7 +85,7 @@ withTempDir(({ createTempDir }) => { swapContract(staging, 'contract-phone'); const emit1 = await runContractEmit(staging); expect(emit1.exitCode, 'D.04: emit C2').toBe(0); - const plan1 = await planThenSelfEmit(staging, [ + const plan1 = await planMigrationAndSelfEmit(staging, [ '--name', 'add-phone', '--from', @@ -100,7 +100,7 @@ withTempDir(({ createTempDir }) => { swapContract(staging, 'contract-phone-bio'); const emit2 = await runContractEmit(staging); expect(emit2.exitCode, 'D.05: emit C3').toBe(0); - const plan2 = await planThenSelfEmit(staging, [ + const plan2 = await planMigrationAndSelfEmit(staging, [ '--name', 'add-bio', '--from', @@ -122,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 planThenSelfEmit(staging, [ + const plan3 = await planMigrationAndSelfEmit(staging, [ '--name', 'add-avatar', '--from', @@ -150,7 +150,7 @@ withTempDir(({ createTempDir }) => { expect(emit4.exitCode, 'D.07: emit C5').toBe(0); // Plan merge from staging branch: C3→C5 - const planMergeStaging = await planThenSelfEmit(staging, [ + const planMergeStaging = await planMigrationAndSelfEmit(staging, [ '--name', 'merge-staging', '--from', @@ -161,7 +161,7 @@ withTempDir(({ createTempDir }) => { const c5Hash = parseJsonOutput<{ to: string }>(planMergeStaging).to; // Plan merge from production branch: C4→C5 - const planMergeProd = await planThenSelfEmit(staging, [ + const planMergeProd = await planMigrationAndSelfEmit(staging, [ '--name', 'merge-prod', '--from', @@ -227,21 +227,22 @@ withTempDir(({ createTempDir }) => { 'D.10: production lists migrations', ).toBeGreaterThan(3); - // D.11: apply the whole graph to an empty database — the pathfinder + // 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 (P-3/S-3). const fresh = createSecondDbContext(staging, freshDb.connectionString); const applyFresh = await runMigrate(fresh, ['--json']); - expect(applyFresh.exitCode, 'D.11: apply to empty database').toBe(0); + expect(applyFresh.exitCode, 'apply to empty database').toBe(0); const freshResult = parseJsonOutput<{ ok: boolean; migrationsApplied: number; markerHash: string; }>(applyFresh); - expect(freshResult.ok, 'D.11: ok').toBe(true); - expect(freshResult.markerHash, 'D.11: marker at C5').toBe(c5Hash); - expect(freshResult.migrationsApplied, 'D.11: shortest path = 3 steps').toBe(3); + 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 440daa170b5a..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 @@ -16,7 +16,7 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationStatus, @@ -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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 --to 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); @@ -86,17 +86,17 @@ withTempDir(({ createTempDir }) => { expect(refSet.exitCode, 'L.05: ref set production').toBe(0); // A ref ahead of the DB marker shows exactly its pending edge - // (folded in from the deleted ref-routing journey, M.05). + // (folded in from the deleted ref-routing journey). const statusAhead = await runMigrationStatus(ctx, ['--to', 'production', '--json']); - expect(statusAhead.exitCode, 'L.05: status --to production').toBe(0); + 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, 'L.05: production has 1 pending').toBe(1); + expect(aheadPending, 'production has 1 pending').toBe(1); - // L.06: apply with --to production → routes via C1→C3 + // apply with --to production → routes via C1→C3 const applyRef = await runMigrate(ctx, ['--to', 'production', '--json']); - expect(applyRef.exitCode, 'L.06: apply --to production').toBe(0); + expect(applyRef.exitCode, 'apply --to production').toBe(0); const applyResult = parseJsonOutput<{ ok: boolean; migrationsApplied: number; @@ -106,28 +106,31 @@ 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 --to production resolves the target via the ref + // 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 --to production').toBe(0); + expect(statusRef.exitCode, 'status --to production after apply').toBe(0); expect( migrationStatusAppSpace(parseMigrationStatusJson(statusRef)).targetContract, - 'L.07: target resolved via ref to C3', + 'target resolved via ref to C3', ).toBe(c3Hash); - // L.08: marker ahead of ref (folded in from the deleted ref-routing - // journey, N.01/N.02 — spec P-6): point production back at C1, which + // 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, 'L.08: ref set production=C1').toBe(0); + expect(refBack.exitCode, 'ref set production=C1').toBe(0); const applyBehind = await runMigrate(ctx, ['--to', 'production', '--json']); - expect(applyBehind.exitCode, 'L.08: apply --to production fails').toBe(2); + 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, - 'L.08: status indicates ahead-of-ref condition', + '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-marker.e2e.test.ts b/test/integration/test/cli-journeys/drift-marker.e2e.test.ts index 46670066c0b8..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 migrate) + // 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 22a9f01720b7..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 @@ -14,7 +14,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, latestMigrationDirName, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationPlan, @@ -43,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 planThenSelfEmit(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); @@ -51,7 +51,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'P3.pre: emit v2').toBe(0); - const plan1 = await planThenSelfEmit(ctx, [ + const plan1 = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-name', '--from', @@ -82,23 +82,21 @@ 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 + // 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, P4.01). + // deleted drift-deleted-root journey). const statusBroken = await runMigrationStatus(ctx); - expect([0, 1], 'P3.01: status exits 0 or 1').toContain(statusBroken.exitCode); - expect(statusBroken.stderr, 'P3.01: surviving migrations visible').toMatch(/add_name/); - expect(statusBroken.stderr, 'P3.01: not treated as empty').not.toContain( - 'No migrations found', - ); + 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: migrate (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: migrate 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 planThenSelfEmit(ctx, [ + const rePlan = await planMigrationAndSelfEmit(ctx, [ '--name', 're-add-posts', '--from', @@ -108,18 +106,18 @@ withTempDir(({ createTempDir }) => { // 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, P4.02). + // drift-deleted-root journey). const dirsAfterRePlan = readdirSync(migrationsDir).filter( (d) => !d.startsWith('.') && d !== 'refs', ); expect( dirsAfterRePlan.filter((d) => d.endsWith('_initial')), - 'P3.03: exactly one init migration', + 'exactly one init migration', ).toHaveLength(1); - // P3.04: migrate (applies the re-planned additive→v3 migration) + // migrate applies the re-planned additive→v3 migration const applyRecovery = await runMigrate(ctx); - expect(applyRecovery.exitCode, 'P3.04: migrate 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 25ca09c87eb1..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 @@ -24,7 +24,7 @@ import { getLatestMigrationDir, type JourneyContext, latestMigrationDirName, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbVerify, runMigrate, @@ -70,7 +70,7 @@ 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 planThenSelfEmit(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, @@ -123,7 +123,7 @@ 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 planThenSelfEmit(ctx, [ + const planRename = await planMigrationAndSelfEmit(ctx, [ '--name', 'rename-search-index', '--from', @@ -153,7 +153,7 @@ 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 planThenSelfEmit(ctx, [ + const planEdit = await planMigrationAndSelfEmit(ctx, [ '--name', 'edit-search-index-body', '--from', 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 7a3cc3cc6f6a..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 + // --no-color it( - 'Y.01: --no-color strips the ANSI codes a TTY run carries', + '--no-color strips the ANSI codes a TTY run carries', async () => { const ctx = setupJourney({ createTempDir }); const colored = await runContractEmit(ctx); - expect(colored.exitCode, 'Y.01: colored emit succeeds').toBe(0); + expect(colored.exitCode, 'colored emit succeeds').toBe(0); const plain = await runContractEmit(ctx, ['--no-color']); - expect(plain.exitCode, 'Y.01: --no-color emit succeeds').toBe(0); + 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, 'Y.01: TTY run carries ANSI codes').toContain('\u001b['); - expect(plain.stdout + plain.stderr, 'Y.01: --no-color output is ANSI-free').not.toContain( + 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 drops the progress commentary the default run prints', + '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); - expect(normal.stderr, 'Y.02: default run narrates progress').toContain('Emitting contract'); - expect(quiet.stderr, 'Y.02: quiet run does not').not.toContain('Emitting contract'); - expect(quiet.stderr.length, 'Y.02: quiet output is strictly shorter').toBeLessThan( + 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 adds timings the default run does not print', + '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); - expect(verbose.stderr, 'Y.03: verbose run reports timings').toContain('Total time'); - expect(normal.stderr, 'Y.03: default run does not').not.toContain('Total time'); + 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 477e9488c986..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 @@ -26,7 +26,7 @@ import { type JourneyContext, latestMigrationDirName, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runContractInfer, runDbSign, @@ -89,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 planThenSelfEmit(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); @@ -104,7 +104,7 @@ 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 planThenSelfEmit(ctx, [ + const plan = await planMigrationAndSelfEmit(ctx, [ '--name', 'converge-index-names', '--from', 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 78df2405662a..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,9 +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. * - * The seams that were once tracked as known bugs (TML-2461, TML-2486, - * TML-2487, TML-2314) are all fixed; each step now asserts the working - * behavior directly. + * 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'; @@ -116,7 +115,7 @@ describe.each(ALL_CELLS.map((cell) => ({ cell, label: cellLabel(cell) })))( ).toBe(0); }); - it('step 4c (migrate): applies the planned migration (TML-2486 seam)', () => { + it('step 4c (migrate): applies the planned migration', () => { const result = ctx.migrationApply; expect(result, 'migrate was not run (precondition failure)').not.toBeNull(); if (result === null) return; 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 5bc0319b371a..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 @@ -20,7 +20,7 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbUpdate, runDbVerify, @@ -46,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 planThenSelfEmit(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']); @@ -59,7 +59,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, '2: emit C2').toBe(0); - const plan1 = await planThenSelfEmit(ctx, [ + const plan1 = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-phone', '--from', @@ -91,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 planThenSelfEmit(ctx, [ + const plan2 = await planMigrationAndSelfEmit(ctx, [ '--from', c2Hash, '--name', @@ -118,7 +118,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-all'); const emit3 = await runContractEmit(ctx); expect(emit3.exitCode, '6: emit C4').toBe(0); - const plan3 = await planThenSelfEmit(ctx, [ + const plan3 = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-avatar', '--from', 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 71238d289551..cf63c6c49155 100644 --- a/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts +++ b/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts @@ -29,7 +29,7 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationNew, @@ -117,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 planThenSelfEmit(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); @@ -167,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 --to 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 --to prod').toBe(0); + expect(applyRef.exitCode, 'apply --to prod').toBe(0); const applyResult = parseJsonOutput<{ ok: boolean; markerHash: string; @@ -204,10 +204,10 @@ withTempDir(({ createTempDir }) => { { id: 2, email: 'bob@test.org', name: BACKFILLED_NAME }, ]); - // O.09: status --to 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 --to 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'), @@ -219,12 +219,12 @@ withTempDir(({ createTempDir }) => { 'O.09: path migrations applied', ).toBe(true); - // O.10: re-apply --to 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 --to prod').toBe(0); + expect(reapply.exitCode, 're-apply --to prod').toBe(0); const reapplyResult = parseJsonOutput<{ ok: boolean; markerHash: string; @@ -251,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 planThenSelfEmit(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); @@ -294,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 --to 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<{ @@ -317,7 +317,7 @@ withTempDir(({ createTempDir }) => { const offlineState = migrationStatusAppSpace(parseMigrationStatusJson(statusOffline)); expect(offlineState.currentContract, 'P.05: marker did not advance to C2').not.toBe(c2Hash); - // P.06: status --to 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( @@ -341,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 planThenSelfEmit(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); @@ -380,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 planThenSelfEmit(ctx, [ + const planB = await planMigrationAndSelfEmit(ctx, [ '--name', 'branch-b-no-invariant', '--from', @@ -395,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 --to 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']); @@ -450,7 +450,7 @@ withTempDir(({ createTempDir }) => { }); expect((await runContractEmit(ctx)).exitCode, 'R.01: emit C1').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -483,9 +483,10 @@ withTempDir(({ createTempDir }) => { .at(-1)!, ); patchBackfillMigrationTs(migrationDir, { addInvariantId: true }); - expect((await selfEmitMigration(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; @@ -493,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 --to prod').toBe(0); + expect(apply1.exitCode, 'apply --to prod after rollback').toBe(0); expect( parseJsonOutput<{ markerHash: string }>(apply1).markerHash, 'R.02: marker at C2', @@ -521,7 +522,7 @@ withTempDir(({ createTempDir }) => { ).toEqual([INVARIANT_ID]); const apply2 = await runMigrate(ctx, ['--to', 'prod', '--json']); - expect(apply2.exitCode, 'R.04: re-apply --to prod').toBe(0); + expect(apply2.exitCode, 're-apply --to prod after rollback').toBe(0); const apply2Result = parseJsonOutput<{ markerHash: string; pathDecision?: { @@ -571,7 +572,7 @@ withTempDir(({ createTempDir }) => { }); expect((await runContractEmit(ctx)).exitCode, 'S.01: emit C1').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -659,7 +660,7 @@ MigrationCLI.run(import.meta.url, M); const applyRef = await runMigrate(ctx, ['--to', 'prod', '--json']); expect( applyRef.exitCode, - `S.05: apply --to prod: ${applyRef.stdout}\n${applyRef.stderr}`, + `apply --to prod (subset ref): ${applyRef.stdout}\n${applyRef.stderr}`, ).toBe(0); const applyResult = parseJsonOutput<{ markerHash: string; @@ -730,7 +731,7 @@ MigrationCLI.run(import.meta.url, M); }); expect((await runContractEmit(ctx)).exitCode, 'T.01: emit C1').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -830,7 +831,7 @@ MigrationCLI.run(import.meta.url, M); [], ); - // T.05: status --to 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/migration-apply-edge-cases.e2e.test.ts b/test/integration/test/cli-journeys/migration-apply-edge-cases.e2e.test.ts index 68edc57498ed..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 @@ -16,7 +16,7 @@ import { type JourneyContext, latestMigrationDirName, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbVerify, runMigrate, @@ -44,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 planThenSelfEmit(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); @@ -83,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 planThenSelfEmit(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); @@ -103,7 +103,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-unique-email'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit unique-email').toBe(0); - const plan1 = await planThenSelfEmit(ctx, [ + const plan1 = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-unique-email', '--from', @@ -162,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 planThenSelfEmit(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); @@ -179,7 +179,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-destructive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit destructive').toBe(0); - const plan1 = await planThenSelfEmit(ctx, [ + const plan1 = await planMigrationAndSelfEmit(ctx, [ '--name', 'drop-email', '--from', @@ -240,14 +240,14 @@ withTempDir(({ createTempDir }) => { // Migration 1: create user table (id + email) const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await planThenSelfEmit(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 planThenSelfEmit(ctx, [ + const plan1 = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-name', '--from', @@ -259,7 +259,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-destructive'); const emit2 = await runContractEmit(ctx); expect(emit2.exitCode, 'emit destructive').toBe(0); - const plan2 = await planThenSelfEmit(ctx, [ + const plan2 = await planMigrationAndSelfEmit(ctx, [ '--name', 'drop-email', '--from', 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 3693b3a32262..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,7 +17,7 @@ import { engineDiagnosticCodes, engineDocument, type JourneyContext, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrationCheck, setupJourney, @@ -53,7 +53,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 3a2a72450873..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,7 +14,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrationGraph, setupJourney, @@ -30,7 +30,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await planThenSelfEmit(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 planThenSelfEmit(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 d1d69956a43a..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,7 +3,8 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, - planThenSelfEmit, + latestMigrationDirName, + planMigrationAndSelfEmit, runContractEmit, runMigrationList, setupJourney, @@ -21,10 +22,15 @@ withTempDir(({ createTempDir }) => { async function projectWithTwoMigrations(): Promise { const ctx = setupJourney({ createTempDir }); await runContractEmit(ctx); - await planThenSelfEmit(ctx, ['--name', 'initial']); + await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); swapContract(ctx, 'contract-additive'); await runContractEmit(ctx); - await planThenSelfEmit(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 0c03eade487a..5a41d53c4ed6 100644 --- a/test/integration/test/cli-journeys/migration-log.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-log.e2e.test.ts @@ -10,7 +10,7 @@ import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, latestMigrationDirName, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationLog, @@ -41,14 +41,16 @@ withTempDir(({ createTempDir }) => { }); expect((await runContractEmit(ctx)).exitCode, 'emit base').toBe(0); - expect((await planThenSelfEmit(ctx, ['--name', 'initial'])).exitCode, 'plan').toBe(0); + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'initial'])).exitCode, 'plan').toBe( + 0, + ); expect((await runMigrate(ctx)).exitCode, 'apply initial').toBe(0); swapContract(ctx, 'contract-additive'); expect((await runContractEmit(ctx)).exitCode, 'emit v2').toBe(0); expect( ( - await planThenSelfEmit(ctx, [ + await planMigrationAndSelfEmit(ctx, [ '--name', 'add-name-column', '--from', 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 0b7a04912d0f..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 @@ -21,7 +21,7 @@ import { type JourneyContext, latestMigrationDirName, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrationPlan, selfEmitMigration, @@ -51,7 +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). - const plan = await planThenSelfEmit(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<{ @@ -121,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 planThenSelfEmit(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) 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 801d578b890b..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 @@ -32,7 +32,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationNew, @@ -68,7 +68,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, `emit base: ${emit0.stderr}`).toBe(0); - const plan0 = await planThenSelfEmit(ctx, ['--name', 'initial']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'initial']); expect(plan0.exitCode, `plan initial: ${plan0.stderr}`).toBe(0); const apply0 = await runMigrate(ctx); 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 6db71bce9ee5..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,7 +18,7 @@ import { declarePgvectorExtension, type EngineCommandResult, type JourneyContext, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrationShow, setupJourney, @@ -57,7 +57,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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 b97c9ef2c9f5..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 @@ -23,7 +23,7 @@ import { migrationStatusAppSpace, parseJsonOutput, parseMigrationStatusJson, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbUpdate, runMigrate, @@ -73,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 planThenSelfEmit(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; @@ -116,7 +116,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await planThenSelfEmit(ctx, ['--name', 'init']); + const plan = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan.exitCode, 'plan').toBe(0); const status = await runMigrationStatus(ctx); @@ -149,7 +149,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await planThenSelfEmit(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); @@ -186,7 +186,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -194,7 +194,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-additive'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'emit v2').toBe(0); - const plan1 = await planThenSelfEmit(ctx, [ + const plan1 = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-field', '--from', @@ -234,7 +234,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await planThenSelfEmit(ctx, ['--name', 'init']); + const plan0 = await planMigrationAndSelfEmit(ctx, ['--name', 'init']); expect(plan0.exitCode, 'plan').toBe(0); swapContract(ctx, 'contract-additive'); @@ -266,7 +266,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -309,7 +309,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -357,7 +357,7 @@ withTempDir(({ createTempDir }) => { // Base: emit → plan → apply const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -414,7 +414,7 @@ withTempDir(({ createTempDir }) => { const emit = await runContractEmit(ctx); expect(emit.exitCode, 'emit').toBe(0); - const plan = await planThenSelfEmit(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); @@ -463,7 +463,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -472,13 +472,23 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emitA = await runContractEmit(ctx); expect(emitA.exitCode, 'emit branch A').toBe(0); - const planA = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--from', baseHash]); + const planA = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-phone', + '--from', + baseHash, + ]); expect(planA.exitCode, 'plan branch A').toBe(0); swapContract(ctx, 'contract-bio'); const emitB = await runContractEmit(ctx); expect(emitB.exitCode, 'emit branch B').toBe(0); - const planB = await planThenSelfEmit(ctx, ['--name', 'add-bio', '--from', baseHash]); + const planB = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-bio', + '--from', + baseHash, + ]); expect(planB.exitCode, 'plan branch B').toBe(0); // Swap to a contract that doesn't match either leaf so the @@ -521,7 +531,7 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit base').toBe(0); - const plan0 = await planThenSelfEmit(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); @@ -531,7 +541,12 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emitA = await runContractEmit(ctx); expect(emitA.exitCode, 'emit A').toBe(0); - const planA = await planThenSelfEmit(ctx, ['--name', 'add-phone', '--from', baseHash]); + const planA = await planMigrationAndSelfEmit(ctx, [ + '--name', + 'add-phone', + '--from', + baseHash, + ]); expect(planA.exitCode, 'plan A').toBe(0); const applyA = await runMigrate(ctx); expect(applyA.exitCode, 'apply A').toBe(0); @@ -540,7 +555,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-bio'); const emitB = await runContractEmit(ctx); expect(emitB.exitCode, 'emit B').toBe(0); - const planB = await planThenSelfEmit(ctx, [ + const planB = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-bio', '--from', @@ -578,13 +593,13 @@ withTempDir(({ createTempDir }) => { const emit0 = await runContractEmit(ctx); expect(emit0.exitCode, 'emit0').toBe(0); - const plan0 = await planThenSelfEmit(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 planThenSelfEmit(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/multi-step-migration.e2e.test.ts b/test/integration/test/cli-journeys/multi-step-migration.e2e.test.ts index ea42c92df9fc..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,7 +12,7 @@ import { describe, expect, it } from 'vitest'; import { withTempDir } from '../utils/cli-test-helpers'; import { type JourneyContext, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbVerify, runMigrate, @@ -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 planThenSelfEmit(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 planThenSelfEmit(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 planThenSelfEmit(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: migrate --db (applies both) + // migrate --db (applies both) const apply = await runMigrate(ctx); - expect(apply.exitCode, 'C.06: migrate 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/rls-exact-name-adoption.e2e.test.ts b/test/integration/test/cli-journeys/rls-exact-name-adoption.e2e.test.ts index 7bf6711d70f8..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 @@ -17,7 +17,7 @@ import { type JourneyContext, latestMigrationDirName, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbSign, runDbVerify, @@ -89,7 +89,7 @@ withTempDir(({ createTempDir }) => { ).toBe(0); // baseline: EMPTY → adopted contract; no-op on apply. - const planBaseline = await planThenSelfEmit(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( @@ -107,7 +107,7 @@ withTempDir(({ createTempDir }) => { ); // plan rename: the widening plan is exactly one ALTER POLICY … RENAME. - const plan = await planThenSelfEmit(ctx, [ + const plan = await planMigrationAndSelfEmit(ctx, [ '--name', 'adopt-wire-name', '--from', 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 d3d447ed9624..41d3c464e762 100644 --- a/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts +++ b/test/integration/test/cli-journeys/rollback-cycle.e2e.test.ts @@ -3,7 +3,7 @@ * * Tests cycle-safe shortest-path resolution after a rollback migration * creates a cycle in the migration graph (C1 → C2 → C1). The rollback is - * the one-command flow (TML-2690): `--to ^` with no contract-source + * the one-command flow: `--to ^` with no contract-source * edit. Every plan names its base explicitly (`--from `). */ @@ -15,7 +15,7 @@ import { type JourneyContext, latestMigrationDirName, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runMigrate, runMigrationPlan, @@ -41,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 planThenSelfEmit(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; @@ -52,7 +52,7 @@ withTempDir(({ createTempDir }) => { swapContract(ctx, 'contract-phone'); const emit1 = await runContractEmit(ctx); expect(emit1.exitCode, 'J.02: emit C2').toBe(0); - const plan1 = await planThenSelfEmit(ctx, [ + const plan1 = await planMigrationAndSelfEmit(ctx, [ '--name', 'add-phone', '--from', @@ -66,13 +66,13 @@ withTempDir(({ createTempDir }) => { const apply1 = await runMigrate(ctx); expect(apply1.exitCode, 'J.02: apply add-phone').toBe(0); - // J.03: one-command rollback (TML-2690, folded in from the deleted - // plan-to-rollback journey): plan toward the add-phone migration's + // 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 planThenSelfEmit(ctx, [ + const planRollback = await planMigrationAndSelfEmit(ctx, [ '--name', 'rollback-phone', '--from', @@ -81,34 +81,34 @@ withTempDir(({ createTempDir }) => { rollbackTarget, '--json', ]); - expect(planRollback.exitCode, 'J.03: plan rollback --to ^').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, 'J.03: rollback from C2').toBe(c2Hash); - expect(rollback.to, 'J.03: rollback to predecessor C1').toBe(c1Hash); + 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'), - 'J.03: reverse delta drops the added column (destructive), no refusal', + 'reverse delta drops the added column (destructive), no refusal', ).toBe(true); const contractSource = readFileSync(join(ctx.testDir, 'contract.ts'), 'utf-8'); - expect(contractSource, 'J.03: contract source untouched (still phone variant)').toContain( + expect(contractSource, 'contract source untouched (still phone variant)').toContain( 'phone', ); const apply2 = await runMigrate(ctx, ['--to', rollbackTarget, '-y', '--json']); - expect(apply2.exitCode, 'J.03: apply rollback').toBe(0); + expect(apply2.exitCode, 'apply rollback').toBe(0); const applied2 = parseJsonOutput<{ ok: boolean; markerHash: string }>(apply2); - expect(applied2.ok, 'J.03: rollback applied ok').toBe(true); - expect(applied2.markerHash, 'J.03: marker moved back to C1').toBe(c1Hash); + 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); planning from the rollback tip + // 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); + expect(emit3.exitCode, 'emit C3 (bio)').toBe(0); const planImplicit = await runMigrationPlan(ctx, [ '--name', 'add-bio-implicit', @@ -116,12 +116,12 @@ withTempDir(({ createTempDir }) => { latestMigrationDirName(ctx), '--json', ]); - expect(planImplicit.exitCode, 'J.04: plan from the rollback tip').toBe(0); + 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').toBeTruthy(); + expect(implicitResult.from, 'plan base resolved').toBeTruthy(); // J.05: plan with --from C1 recovers - const planFrom = await planThenSelfEmit(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 2d4f7155d512..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 @@ -22,7 +22,7 @@ import { type JourneyContext, latestMigrationDirName, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbInit, runDbUpdate, @@ -56,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 planThenSelfEmit(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); @@ -82,20 +82,20 @@ withTempDir(({ createTempDir }) => { const show = await runMigrationShow(ctx, [showTarget!]); expect(show.exitCode, 'B.03: migration show').toBe(0); - // B.04: self-emit the planned migration.ts + // self-emit the planned migration.ts const migDir = getLatestMigrationDir(ctx); expect(migDir, 'B.04: migration dir exists').toBeDefined(); const emitMig = await selfEmitMigration(ctx, ['--dir', `migrations/app/${migDir}`]); - expect(emitMig.exitCode, 'B.04: migration.ts self-emit').toBe(0); + 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: migrate + // migrate applies the planned migration const apply = await runMigrate(ctx); - expect(apply.exitCode, 'B.06: migrate').toBe(0); + expect(apply.exitCode, 'migrate applies the plan').toBe(0); // B.07: migration status (all applied) const statusApplied = await runMigrationStatus(ctx); @@ -126,9 +126,9 @@ withTempDir(({ createTempDir }) => { // --- Merged from Journey Q: migrate noop (already up-to-date) --- - // Q.01: migrate --json (already up-to-date) + // migrate --json (already up-to-date) const applyNoop = await runMigrate(ctx, ['--json']); - expect(applyNoop.exitCode, 'Q.01: migrate noop').toBe(0); + expect(applyNoop.exitCode, 'migrate noop').toBe(0); const noopApplyData = parseJsonOutput(applyNoop); expect(noopApplyData, 'Q.01: 0 applied').toMatchObject({ ok: true, @@ -137,15 +137,15 @@ withTempDir(({ createTempDir }) => { // --- Merged from Journey R: migration plan noop (contract unchanged) --- - // R.01: migration plan from the leaf (no changes — contract matches it) + // migration plan from the leaf (no changes — contract matches it) const planNoop = await runMigrationPlan(ctx, [ '--from', latestMigrationDirName(ctx), '--json', ]); - expect(planNoop.exitCode, 'R.01: migration plan noop').toBe(0); + 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 --- @@ -206,11 +206,11 @@ withTempDir(({ createTempDir }) => { const plan = await runMigrationPlan(ctx, ['--name', 'initial-evolution']); expect(plan.exitCode, 'Z.02: migration plan').toBe(0); - // Z.03: migrate 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: migrate 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 e5ea613bebce..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 @@ -26,7 +26,7 @@ import { type JourneyContext, latestMigrationDirName, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runContractInfer, runDbSign, @@ -175,7 +175,7 @@ describe('sign a database this toolchain has never seen, then transition to wire // Baseline migration so migration plan diffs from the // adopted contract; a fresh migrate is a no-op against the live DB. - const planBaseline = await planThenSelfEmit(ctx, ['--name', 'baseline']); + const planBaseline = await planMigrationAndSelfEmit(ctx, ['--name', 'baseline']); expect(planBaseline.exitCode, `3.1: plan baseline\n${stripAnsi(planBaseline.stderr)}`).toBe( 0, ); @@ -203,7 +203,7 @@ describe('sign a database this toolchain has never seen, then transition to wire expect(emitWire.exitCode, `3.2: emit wire\n${stripAnsi(emitWire.stderr)}`).toBe(0); // The widening plan is EXACTLY the one rename. - const plan = await planThenSelfEmit(ctx, [ + const plan = await planMigrationAndSelfEmit(ctx, [ '--name', 'adopt-wire-names', '--from', 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 81ee5764ebb8..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,7 +6,7 @@ import { withTempDir } from './utils/cli-test-helpers'; import { type JourneyContext, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbInit, runMigrate, @@ -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 planThenSelfEmit(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,9 @@ withTempDir(({ createTempDir }) => { removeAppMigrationBundles(ctx); swapContract(ctx, 'contract-additive'); expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await planThenSelfEmit(ctx, ['--name', 'replacement'])).exitCode).toBe(0); + expect((await planMigrationAndSelfEmit(ctx, ['--name', 'replacement'])).exitCode).toBe( + 0, + ); const drift = await runMigrate(ctx, ['--json']); expect(drift.exitCode).not.toBe(0); @@ -97,7 +99,7 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await planThenSelfEmit(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); @@ -115,7 +117,7 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await planThenSelfEmit(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); }); @@ -150,13 +152,13 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await planThenSelfEmit(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 planThenSelfEmit(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('.')) @@ -179,12 +181,12 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await planThenSelfEmit(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 planThenSelfEmit(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.migration-plan-ref-aware.e2e.test.ts b/test/integration/test/cli.migration-plan-ref-aware.e2e.test.ts index c8f491b2a623..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,7 +9,7 @@ import { getMigrationDirs, type JourneyContext, parseJsonOutput, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, runDbInit, runDbUpdate, @@ -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 planThenSelfEmit(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); @@ -355,7 +355,9 @@ withTempDir(({ createTempDir }) => { await withDevDatabase(async ({ connectionString }) => { await withJourney(createTempDir, connectionString, async (ctx) => { expect((await runContractEmit(ctx)).exitCode).toBe(0); - expect((await planThenSelfEmit(ctx, ['--name', 'init', '--json'])).exitCode).toBe(0); + expect( + (await planMigrationAndSelfEmit(ctx, ['--name', 'init', '--json'])).exitCode, + ).toBe(0); expect((await runMigrate(ctx)).exitCode).toBe(0); swapContract(ctx, 'contract-additive'); 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 2bce04e91a4d..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,7 +10,7 @@ import { type EngineCommandResult, getLatestMigrationDir, type JourneyContext, - planThenSelfEmit, + planMigrationAndSelfEmit, runContractEmit, 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 planThenSelfEmit(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 553e2622121e..dc5a29e08752 100644 --- a/test/integration/test/utils/cli-test-helpers.ts +++ b/test/integration/test/utils/cli-test-helpers.ts @@ -97,6 +97,19 @@ let cachedMount: */ 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 @@ -328,6 +341,7 @@ export function setupIntegrationTestDirectoryFromFixtures( } const cleanup = () => { + evictEngineCli(testDir); if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } @@ -384,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 }); } @@ -552,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 9981ca8a6d1b..ff97eb914389 100644 --- a/test/integration/test/utils/journey-test-helpers.ts +++ b/test/integration/test/utils/journey-test-helpers.ts @@ -477,9 +477,9 @@ export function injectMigrationSqlDbSetup(scaffold: string): string { } /** - * Self-emits a migration package by running its `migration.ts` in-process (see - * {@link runMigrationFile}), 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`) naming * the migration package whose `migration.ts` to run. Any other arguments are @@ -507,15 +507,15 @@ export async function selfEmitMigration( } /** - * Runs `migration plan` and then self-emits the resulting draft `migration.ts` - * in-process (see {@link runMigrationFile}). Journey steps that just need "a - * planned and emitted migration" use this instead of spelling both steps out. + * 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, the self-emit is skipped. If the self-emit fails, - * the returned result carries that 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 planThenSelfEmit( +export async function planMigrationAndSelfEmit( ctx: JourneyContext, extraArgs: readonly string[] = [], ): Promise { @@ -528,7 +528,7 @@ export async function planThenSelfEmit( return { ...planResult, exitCode: emitResult.exitCode, - stderr: `${planResult.stderr}\n[planThenSelfEmit] migration.ts self-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; @@ -746,13 +746,23 @@ 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; } } From 3c36feac92d100335781c3528f79597f9bf598d8 Mon Sep 17 00:00:00 2001 From: willbot Date: Thu, 20 Aug 2026 14:23:15 +0200 Subject: [PATCH 7/7] test(integration): drop a journey-ID straggler from a comment Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/cli-journeys/diamond-convergence.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 be6a67a56bcb..7264f7f2d03a 100644 --- a/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts +++ b/test/integration/test/cli-journeys/diamond-convergence.e2e.test.ts @@ -231,7 +231,7 @@ withTempDir(({ createTempDir }) => { // 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 (P-3/S-3). + // 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);