From a930072e2191b7819c798354caf52880740b097a Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 28 Sep 2026 13:00:53 +0200 Subject: [PATCH 01/37] fix(cli): contract references list only the forms they accept; migration status resolves @contract and @db Every contract reference goes through parseContractRef, which accepts @contract, @db, @empty, a hash, a hash prefix, a ref name, a migration directory name, and ^. The help text advertised ./path, which it never accepted, and two commands used the parser without doing the @contract and @db work. - Remove ./path from every help brief, doc comment, and skill reference that lists contract reference forms; db sign's positional and --contract flag now share one brief listing the forms both accept. - migration status passes the emitted contract's hash so @contract resolves for --to and --from, and resolves @db on either side from the live marker through helpers shared with db migrate --show (isLiveMarkerRef, liveMarkerRefHash, requireLiveDatabaseForLiveMarkerRef). Without a connection, @db fails with CONFIG.DB_CONNECTION_REQUIRED. The empty placeholder hash is never used. - db migrate --show --to @db resolves the target from the live marker instead of refusing with CONFIG.DB_CONNECTION_REQUIRED. - migration status warns MIGRATION.MARKER_NOT_IN_HISTORY whenever the marker is not a graph node, including when it equals the emitted contract, which is the state in which db migrate refuses with MIGRATION.MARKER_MISMATCH. - db update --to refuses @empty, @db, and @contract with MIGRATION.REF_WRONG_GRAMMAR instead of CLI.UNEXPECTED or MIGRATION.REF_NOT_FOUND, and its help text lists exactly the forms it takes. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- docs/reference/error-reference.md | 2 +- .../contract-snapshot-resolution.ts | 2 +- .../control-api/operations/migrate-show.ts | 124 ++++++------ .../control-api/operations/ref-resolution.ts | 51 ++++- .../3-tooling/cli/src/orm/db/sign.ts | 11 +- .../3-tooling/cli/src/orm/db/update.ts | 7 +- .../3-tooling/cli/src/orm/migrate.ts | 2 +- .../3-tooling/cli/src/orm/migration/plan.ts | 2 +- .../3-tooling/cli/src/orm/migration/status.ts | 89 +++++---- .../3-tooling/cli/src/utils/cli-errors.ts | 17 ++ .../test/orm/db-update-to-resolution.test.ts | 27 +++ .../cli/test/orm/migrate-show.test.ts | 51 +++++ .../cli/test/orm/migration-status.test.ts | 181 ++++++++++++++++++ skills/prisma-8/references/migration-model.md | 2 +- 14 files changed, 451 insertions(+), 117 deletions(-) diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 67fcebf183d8..b43a7e304bc7 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1530,7 +1530,7 @@ A ref name resolves to nothing: no pointer file with that name exists, and the f ### MIGRATION.REF_WRONG_GRAMMAR -A reference parsed, but as the wrong kind for the argument position, e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. Payload: `input`, `expectedGrammar`. +A reference parsed, but as the wrong kind for the argument position, e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. Also raised by `db update --to` for the reserved references `@contract`, `@db`, and `@empty`, which name working, live, or empty state rather than a contract on disk; `db update --to` takes a hash, a prefix, a ref name, a migration directory name, or `^`. Payload: `input`, `expectedGrammar`. ### MIGRATION.RUNNER_FAILED diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts index 51e265b28d82..42b9002e9b31 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts @@ -33,7 +33,7 @@ function isEnoent(error: unknown): boolean { interface ResolveContractRefToSnapshotBaseOptions { readonly config: PrismaNextConfig; readonly migrationsDir: string; - /** User-supplied contract reference (hash, prefix, ref name, migration dir name, ^, or ./path). */ + /** User-supplied contract reference (hash, prefix, ref name, migration dir name, or ^). */ readonly refInput: string; /** Absolute path of the emitted contract.json (fallback source + snapshot-path derivation). */ readonly contractPathAbsolute: string; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index b12228102e42..4237b6a5c01e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -28,6 +28,11 @@ import { createControlClient } from '../client'; import type { CreateControlClient } from '../types'; import { buildReadAggregate } from './contract-space-aggregate-loader'; import { planSpacePath } from './migrate'; +import { + isLiveMarkerRef, + liveMarkerRefHash, + requireLiveDatabaseForLiveMarkerRef, +} from './ref-resolution'; /** * One migration that will run in a `migrate --show` preview, in execution order. @@ -95,16 +100,30 @@ export async function executeMigrateShowPlan( const dbConnection = options.db ?? config.db?.connection; const hasDriver = !!config.driver; const hasExplicitFrom = options.from !== undefined; + const fromLiveMarker = isLiveMarkerRef(options.from); + const toLiveMarker = isLiveMarkerRef(options.to); + const liveOrigin = !hasExplicitFrom || fromLiveMarker; + const needsLiveMarker = liveOrigin || toLiveMarker; - // When --from is omitted we read the live DB marker (same as migrate's default). - // When --from is given, we're in offline hypothetical mode — no connection needed. - if (!hasExplicitFrom) { - const missingDb = requireLiveDatabase({ - dbConnection, - hasDriver, - why: 'migrate --show needs a database connection to read the live marker (or pass --from for an offline preview)', - retryCommand: '{bin} db migrate --show --from ', - }); + // The live DB marker is the from-state when --from is omitted or @db (same as + // migrate's default) and the target when --to is @db. Any other --from is an + // offline hypothetical — no connection needed. + if (needsLiveMarker) { + const missingDb = + fromLiveMarker || toLiveMarker + ? requireLiveDatabaseForLiveMarkerRef({ + dbConnection, + hasDriver, + command: '{bin} db migrate --show', + from: options.from, + to: options.to, + }) + : requireLiveDatabase({ + dbConnection, + hasDriver, + why: 'migrate --show needs a database connection to read the live marker (or pass --from for an offline preview)', + retryCommand: '{bin} db migrate --show --from ', + }); if (missingDb) { return notOk(missingDb); } @@ -132,7 +151,7 @@ export async function executeMigrateShowPlan( // same target invariants that real migrate would use (refInvariants ?? headRef.invariants). let targetHash: string = contractHash; let refInvariants: readonly string[] | undefined; - if (options.to) { + if (options.to && !toLiveMarker) { const toResult = parseContractRef(options.to, { graph: appGraph, refs: allRefs, @@ -141,14 +160,6 @@ export async function executeMigrateShowPlan( if (!toResult.ok) { return notOk(mapRefResolutionError(toResult.failure)); } - if (toResult.value.provenance.kind === 'reserved-db') { - return notOk( - errorDatabaseConnectionRequired({ - why: '@db is not valid as a --to target; it names the live database state, not a target contract.', - commandName: 'migrate --show', - }), - ); - } targetHash = toResult.value.hash; if (toResult.value.provenance.kind === 'ref') { const refEntry = allRefs[toResult.value.provenance.refName]; @@ -164,8 +175,8 @@ export async function executeMigrateShowPlan( }); // Resolve the from-state. - // - Explicit --from: parse it offline (no connection). - // - Omitted: read the live DB marker via readAllMarkers() — the same source migrate uses. + // - Explicit --from other than @db: parse it offline (no connection). + // - Omitted or @db: read the live DB marker via readAllMarkers() — the same source migrate uses. // // Full marker records (storageHash + invariants) are preserved so planSpacePath // can feed resolveRecordedPath the complete currentMarker — exactly as executeMigrate @@ -175,53 +186,25 @@ export async function executeMigrateShowPlan( const markerBySpace = new Map(); const allSpaces: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; - if (hasExplicitFrom) { - // @db with explicit --from requires a connection - if (options.from === '@db') { - const missingDb = requireLiveDatabase({ - dbConnection, - hasDriver, - why: '@db resolves to the live database marker and requires a --db connection', - retryCommand: '{bin} db migrate --show --from @db --db $DATABASE_URL', - }); - if (missingDb) { - return notOk(missingDb); - } - // Fall through to the connection path below - } else { - const fromResult = parseContractRef(options.from, { - graph: appGraph, - refs: allRefs, - contractHash, - }); - if (!fromResult.ok) { - return notOk(mapRefResolutionError(fromResult.failure)); - } - if (fromResult.value.provenance.kind === 'reserved-db') { - // Unreachable given the @db branch above, but guard for safety - const missingDb = requireLiveDatabase({ - dbConnection, - hasDriver, - why: '@db resolves to the live database marker and requires a --db connection', - }); - if (missingDb) { - return notOk(missingDb); - } - } else { - // Offline hypothetical: the --from ref only carries a hash (no live invariants). - // Apply the from-hash marker to the APP space only. Extension spaces are left - // absent from markerBySpace (treated as null / greenfield by planSpacePath), - // so they plan from their own marker → own head — exactly as executeMigrate does. - const fromHash = fromResult.value.hash; - const offlineMarker: LiveMarker | null = - fromHash === EMPTY_CONTRACT_HASH ? null : { storageHash: fromHash, invariants: [] }; - markerBySpace.set(aggregate.app.spaceId, offlineMarker); - } + if (options.from !== undefined && !fromLiveMarker) { + const fromResult = parseContractRef(options.from, { + graph: appGraph, + refs: allRefs, + contractHash, + }); + if (!fromResult.ok) { + return notOk(mapRefResolutionError(fromResult.failure)); } + // Offline hypothetical: the --from ref only carries a hash (no live invariants). + // Apply the from-hash marker to the APP space only. Extension spaces are left + // absent from markerBySpace (treated as null / greenfield by planSpacePath), + // so they plan from their own marker → own head — exactly as executeMigrate does. + const fromHash = fromResult.value.hash; + const offlineMarker: LiveMarker | null = + fromHash === EMPTY_CONTRACT_HASH ? null : { storageHash: fromHash, invariants: [] }; + markerBySpace.set(aggregate.app.spaceId, offlineMarker); } - // If we need the live DB marker (no --from, or --from @db), connect and read. - const needsLiveMarker = !hasExplicitFrom || options.from === '@db'; if (needsLiveMarker) { if (!dbConnection || !hasDriver) { return notOk( @@ -243,9 +226,14 @@ export async function executeMigrateShowPlan( const allMarkers = await client.readAllMarkers(); // Store the full marker record (storageHash + invariants) per space. // This is the same data executeMigrate uses via familyInstance.readAllMarkers(). - for (const space of allSpaces) { - const marker = allMarkers.get(space.spaceId); - markerBySpace.set(space.spaceId, marker ?? null); + if (liveOrigin) { + for (const space of allSpaces) { + const marker = allMarkers.get(space.spaceId); + markerBySpace.set(space.spaceId, marker ?? null); + } + } + if (toLiveMarker) { + targetHash = liveMarkerRefHash(allMarkers.get(aggregate.app.spaceId)); } } catch (error) { if (CliStructuredError.is(error)) { @@ -357,6 +345,6 @@ export async function executeMigrateShowPlan( migrations: orderedMigrations, summary, renderMarkerHashBySpace, - usedLiveMarker: needsLiveMarker, + usedLiveMarker: liveOrigin, }); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts index f00ad0bac42f..9bdd8a9876b5 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts @@ -2,12 +2,17 @@ * Client-free contract/migration reference resolution for commands, wrapping migration-tools' parsers with the CLI error mapping. */ +import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import type { MigrationGraph } from '@internal/migration-tools/graph'; import type { ContractRef, MigrationRef } from '@internal/migration-tools/ref-resolution'; import { parseContractRef, parseMigrationRef } from '@internal/migration-tools/ref-resolution'; import type { Refs } from '@internal/migration-tools/refs'; import { notOk, ok, type Result } from '@internal/utils/result'; -import { type CliStructuredError, mapRefResolutionError } from '../../utils/cli-errors'; +import { + type CliStructuredError, + mapRefResolutionError, + requireLiveDatabase, +} from '../../utils/cli-errors'; export interface RefResolutionContext { readonly graph: MigrationGraph; @@ -30,3 +35,47 @@ export function resolveMigrationRef( const result = parseMigrationRef(input, context); return result.ok ? ok(result.value) : notOk(mapRefResolutionError(result.failure)); } + +const LIVE_MARKER_REF = '@db'; + +const RESERVED_CONTRACT_REFS: ReadonlySet = new Set([ + '@contract', + LIVE_MARKER_REF, + '@empty', +]); + +export function isReservedContractRef(input: string): boolean { + return RESERVED_CONTRACT_REFS.has(input); +} + +export function isLiveMarkerRef(input: string | undefined): boolean { + return input === LIVE_MARKER_REF; +} + +/** The hash `@db` names once the marker is read; an unsigned database sits at the empty contract. */ +export function liveMarkerRefHash( + marker: { readonly storageHash: string } | null | undefined, +): string { + return marker?.storageHash ?? EMPTY_CONTRACT_HASH; +} + +export function requireLiveDatabaseForLiveMarkerRef(args: { + readonly dbConnection: unknown; + readonly hasDriver: boolean; + readonly command: string; + readonly from: string | undefined; + readonly to: string | undefined; +}): CliStructuredError | null { + const retryCommand = [ + args.command, + ...(args.from === undefined ? [] : [`--from ${args.from}`]), + ...(args.to === undefined ? [] : [`--to ${args.to}`]), + '--db $DATABASE_URL', + ].join(' '); + return requireLiveDatabase({ + dbConnection: args.dbConnection, + hasDriver: args.hasDriver, + why: `${LIVE_MARKER_REF} resolves to the live database marker and requires a --db connection`, + retryCommand, + }); +} diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts index 8e820c1a4e2f..a854c4ca8f46 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts @@ -59,6 +59,9 @@ type SchemaVerifyDocument = VerifyDatabaseSchemaResult; */ const DEFAULT_ADVANCE_REF = 'db'; +const CONTRACT_REF_BRIEF = + 'Contract reference (hash, prefix, ref name, migration dir name, or ^)'; + interface AdvancedRef { readonly name: string; readonly hash: string; @@ -214,17 +217,13 @@ export function createDbSignCommand( args: { positionals: { contract: positional.optionalString({ - brief: 'Contract reference (hash, prefix, ref name, or migration dir name)', + brief: CONTRACT_REF_BRIEF, placeholder: 'contract', }), }, flags: { db: dbFlag, - contract: flag.string({ - brief: - 'Contract reference (hash, prefix, ref name, migration dir name, ^, or ./path)', - placeholder: 'contract', - }), + contract: flag.string({ brief: CONTRACT_REF_BRIEF, placeholder: 'contract' }), advanceRef: flag.string({ brief: 'Advance the named ref to the post-command contract hash', placeholder: 'name', diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index 954f16aadf00..4a883793a7bc 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -17,11 +17,13 @@ import { NO_REF_ADVANCEMENT, preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; +import { isReservedContractRef } from '../../control-api/operations/ref-resolution'; import type { CreateControlClient, DbUpdateResult, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed, errorUnexpected, + errorUpdateTargetReservedRef, } from '../../utils/cli-errors'; import { closeQuietly, sanitizeErrorMessage } from '../../utils/command-helpers'; import { mapDbUpdateFailure } from '../../utils/db-update-failure'; @@ -139,7 +141,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { db: dbFlag, dryRun: flag.boolean({ brief: 'Preview the planned operations without applying them' }), to: flag.string({ - brief: 'Contract to update to (hash, prefix, ref name, migration dir name, or ./path)', + brief: 'Contract to update to (hash, prefix, ref name, migration dir name, or ^)', placeholder: 'contract', }), advanceRef: flag.string({ @@ -150,6 +152,9 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { + if (args.flags.to !== undefined && isReservedContractRef(args.flags.to)) { + return notOk(normalizeError(errorUpdateTargetReservedRef(args.flags.to))); + } const startedAt = Date.now(); const prepared = await prepareMigrationRun({ config: ctx.config, diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 850b6aaf6991..22382c1171a5 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -226,7 +226,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { db: dbFlag, to: flag.string({ brief: - 'Target contract reference (hash, prefix, ref name, migration dir name, ^, or ./path)', + 'Target contract reference (hash, prefix, ref name, migration dir name, or ^)', placeholder: 'contract', }), advanceRef: flag.string({ diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 3eb007be78fd..36fe5111d8af 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -281,7 +281,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { name: flag.string({ brief: 'Name slug for the migration directory', placeholder: 'slug' }), from: flag.string({ brief: - 'Starting contract reference (hash, prefix, ref name, migration dir name, ^, @empty, or ./path)', + 'Starting contract reference (hash, prefix, ref name, migration dir name, ^, or @empty)', placeholder: 'contract', }), to: flag.string({ diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index e264f5c4ad88..d8f7c6bee467 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -4,6 +4,8 @@ import type { AggregateContractSpace, ContractMarkerRecordLike, } from '@internal/migration-tools/aggregate'; +import { isGraphNode } from '@internal/migration-tools/migration-graph'; +import type { ContractRef } from '@internal/migration-tools/ref-resolution'; import type { RefEntry, Refs } from '@internal/migration-tools/refs'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations, Text } from '@prisma/cli-engine'; @@ -37,7 +39,12 @@ import { originHashForStatus, statusForMigrationHash, } from '../../control-api/operations/migration-status-overlay'; -import { resolveContractRef } from '../../control-api/operations/ref-resolution'; +import { + isLiveMarkerRef, + liveMarkerRefHash, + requireLiveDatabaseForLiveMarkerRef, + resolveContractRef, +} from '../../control-api/operations/ref-resolution'; import { readMigrationRefs } from '../../control-api/operations/refs'; import { errorUnexpected, requireLiveDatabase } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl, readContractEnvelope } from '../../utils/command-helpers'; @@ -230,7 +237,7 @@ export const migrationStatusCommand = defineOrmCommand({ space: flag.string({ brief: 'Narrow output to a single contract space', placeholder: 'id' }), to: flag.string({ brief: - 'Target contract reference (hash, prefix, ref name, migration dir name, ^, or ./path)', + 'Target contract reference (hash, prefix, ref name, migration dir name, ^, @contract, @db, or @empty)', placeholder: 'contract', }), from: flag.string({ @@ -247,15 +254,27 @@ export const migrationStatusCommand = defineOrmCommand({ const migrationsDir = migrationsDirFor(ctx.config); const dbConnection = args.flags.db ?? ctx.config.db?.connection; const hasDriver = ctx.config.driver !== undefined; - const usingFromOverride = args.flags.from !== undefined; - - if (!usingFromOverride) { - const missingDb = requireLiveDatabase({ - dbConnection, - hasDriver, - why: 'migration status needs a database connection to read the marker and ledger (or pass --from for an offline path preview)', - retryCommand: '{bin} migration status --from ', - }); + const fromLiveMarker = isLiveMarkerRef(args.flags.from); + const toLiveMarker = isLiveMarkerRef(args.flags.to); + const liveOrigin = args.flags.from === undefined || fromLiveMarker; + const needsDatabase = liveOrigin || toLiveMarker; + + if (needsDatabase) { + const missingDb = + fromLiveMarker || toLiveMarker + ? requireLiveDatabaseForLiveMarkerRef({ + dbConnection, + hasDriver, + command: '{bin} migration status', + from: args.flags.from, + to: args.flags.to, + }) + : requireLiveDatabase({ + dbConnection, + hasDriver, + why: 'migration status needs a database connection to read the marker and ledger (or pass --from for an offline path preview)', + retryCommand: '{bin} migration status --from ', + }); if (missingDb !== null) { return notOk(normalizeError(missingDb)); } @@ -294,25 +313,20 @@ export const migrationStatusCommand = defineOrmCommand({ } const appGraph = aggregate.app.graph(); + const refContext = { graph: appGraph, refs, contractHash }; - let activeRefHash: string | undefined; - let activeRefName: string | undefined; - let activeRefEntry: RefEntry | undefined; - if (args.flags.to !== undefined) { - const resolved = resolveContractRef(args.flags.to, { graph: appGraph, refs }); + let toRef: ContractRef | undefined; + if (args.flags.to !== undefined && !toLiveMarker) { + const resolved = resolveContractRef(args.flags.to, refContext); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } - activeRefHash = resolved.value.hash; - if (resolved.value.provenance.kind === 'ref') { - activeRefName = resolved.value.provenance.refName; - activeRefEntry = refs[activeRefName]; - } + toRef = resolved.value; } let fromOverrideHash: string | undefined; - if (args.flags.from !== undefined) { - const resolved = resolveContractRef(args.flags.from, { graph: appGraph, refs }); + if (args.flags.from !== undefined && !fromLiveMarker) { + const resolved = resolveContractRef(args.flags.from, refContext); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } @@ -329,7 +343,7 @@ export const migrationStatusCommand = defineOrmCommand({ } const scopedSpaces = listed.value.spaces; - const connects = dbConnection !== undefined && hasDriver && !usingFromOverride; + const connects = needsDatabase && dbConnection !== undefined && hasDriver; let database: DatabaseState = NO_DATABASE_STATE; if (connects) { const read = await readDatabaseState({ @@ -350,7 +364,11 @@ export const migrationStatusCommand = defineOrmCommand({ } const appMarker = database.markersBySpace.get(aggregate.app.spaceId); - if (activeRefEntry !== undefined && activeRefEntry.invariants.length > 0 && connects) { + const activeRefHash = toLiveMarker ? liveMarkerRefHash(appMarker) : toRef?.hash; + const activeRefName = toRef?.provenance.kind === 'ref' ? toRef.provenance.refName : undefined; + const activeRefEntry: RefEntry | undefined = + activeRefName === undefined ? undefined : refs[activeRefName]; + if (activeRefEntry !== undefined && activeRefEntry.invariants.length > 0 && liveOrigin) { const unknown = refuseUnknownInvariants({ graph: appGraph, markerInvariants: appMarker?.invariants ?? [], @@ -389,15 +407,14 @@ export const migrationStatusCommand = defineOrmCommand({ headlineTargetHash = targetHash; } - const markerHash = usingFromOverride - ? fromOverrideHash - : database.markersBySpace.get(entry.space)?.storageHash; + const markerHash = liveOrigin + ? database.markersBySpace.get(entry.space)?.storageHash + : fromOverrideHash; const originHash = originHashForStatus(markerHash); - const markerInGraph = - markerHash === undefined || graph.nodes.has(markerHash) || markerHash === spaceContractHash; + const markerInGraph = markerHash === undefined || isGraphNode(markerHash, graph); if ( - connects && + liveOrigin && markerInGraph && originHash !== targetHash && noPath === undefined && @@ -405,7 +422,7 @@ export const migrationStatusCommand = defineOrmCommand({ ) { noPath = { markerHash, targetHash }; } - if (connects && markerHash !== undefined && !markerInGraph) { + if (liveOrigin && markerHash !== undefined && !markerInGraph) { divergedMarker ??= { space: entry.space, markerHash }; findings.push(markerNotInHistoryFinding(entry.space)); } @@ -415,8 +432,8 @@ export const migrationStatusCommand = defineOrmCommand({ graph, targetHash, originHash, - appliedMigrationHashes: connects ? appliedHashesFromLedger(ledger) : new Set(), - showAppliedOverlay: connects, + appliedMigrationHashes: liveOrigin ? appliedHashesFromLedger(ledger) : new Set(), + showAppliedOverlay: liveOrigin, }); const migrations = entry.migrations.map((migration: MigrationListEntry) => ({ ...migration, @@ -447,12 +464,12 @@ export const migrationStatusCommand = defineOrmCommand({ styler, palette: TONE_MIGRATION_GRAPH_PALETTE, isAppSpace: entry.space === aggregate.app.spaceId, - ...(connects && markerHash !== undefined ? { dbHash: markerHash } : {}), + ...(liveOrigin && markerHash !== undefined ? { dbHash: markerHash } : {}), }); } const requiredInvariants = [...(activeRefEntry?.invariants ?? [])].sort(); - if (connects && requiredInvariants.length > 0) { + if (liveOrigin && requiredInvariants.length > 0) { const held = new Set(appMarker?.invariants ?? []); const missing = requiredInvariants.filter((id) => !held.has(id)); if (missing.length > 0) { diff --git a/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts b/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts index aca5c8a5d4f0..45bc31146992 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts @@ -124,6 +124,23 @@ export function errorContractArgConflict(options: { ); } +const UPDATE_TARGET_FORMS = 'a hash, a prefix, a ref name, a migration directory name, or `^`'; + +/** `db update --to` was given `@contract`, `@db`, or `@empty`, which name state rather than a contract on disk. */ +export function errorUpdateTargetReservedRef(input: string): ActionableCliError { + const fix = `Pass ${UPDATE_TARGET_FORMS}, or omit --to to update to the emitted contract.`; + return new ActionableCliError( + 'MIGRATION.REF_WRONG_GRAMMAR', + `Not a contract \`db update --to\` accepts: "${input}"`, + { + why: `\`db update --to\` takes ${UPDATE_TARGET_FORMS}; without \`--to\` it updates to the emitted contract. "${input}" is a reserved reference for working, live, or empty state, not a contract on disk.`, + fix, + nextActions: [chooseAction(fix)], + meta: { input, expectedGrammar: 'contract' }, + }, + ); +} + /** * A command was told both which ref to advance and not to advance any ref. * Same envelope as the positional/flag contract conflict, so a script sees diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts index 430783c1c6d8..d5344c524a0e 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts @@ -139,6 +139,33 @@ function harness(cwd: string) { } describe('db update --to bundle resolution', () => { + it.each(['@empty', '@contract', '@db'])( + 'refuses the reserved reference %s with a structured envelope', + async (input) => { + const { cwd } = await setupFixture(); + + const run = await harness(cwd).run(['db', 'update', '--to', input, '--dry-run', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'MIGRATION.REF_WRONG_GRAMMAR', + why: expect.stringContaining( + '`db update --to` takes a hash, a prefix, a ref name, a migration directory name, or `^`; without `--to` it updates to the emitted contract', + ), + meta: { input, expectedGrammar: 'contract' }, + }, + }, + }); + expect(mocks.dbUpdate).not.toHaveBeenCalled(); + }, + ); + it('errors on an invalid --advance-ref name with the structured ref envelope', async () => { const { cwd, dirNext } = await setupFixture(); mocks.dbUpdate.mockResolvedValue( diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 548176375c5e..1f2ee3eac67d 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -312,6 +312,57 @@ describe('migrate --show', () => { }); }); + describe('the @db marker', () => { + it('resolves --to @db to the live marker', async () => { + const cwd = await buildProject(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C1, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--from', EMPTY, '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual({ + ok: true, + migrations: [expect.objectContaining({ from: EMPTY, to: C1 })], + summary: '1 migration will run', + }); + }); + + it('shows nothing to run for --to @db when the from-state is the live marker too', async () => { + const cwd = await buildProject(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C1, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ ok: true, migrations: [] }); + }); + + it('errors structurally for --to @db without a connection', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd, { db: undefined })).run( + ['db', 'migrate', '--show', '--from', EMPTY, '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).not.toBe(0); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'CONFIG.DB_CONNECTION_REQUIRED' } }, + }); + }); + }); + describe('extension spaces', () => { it('plans extensions from their own state, never from the app --from hash', async () => { const cwd = await buildProject(); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 82f8c912e0d2..2ec394a5b353 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -105,6 +105,29 @@ async function projectWithOneMigration(): Promise< return { ...project, migrationHash: seeded.migrationHash }; } +const DIR_BASE = '20260101T0000_base'; +const DIR_HEAD = '20260102T0000_head'; + +/** A project whose app space carries ∅ → HASH_BASE → HASH_HEAD, with the contract at HASH_HEAD. */ +async function projectWithTwoMigrations(): Promise< + OfflineProject & { readonly baseMigrationHash: string } +> { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const base = await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: DIR_BASE, + from: null, + to: HASH_BASE, + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: DIR_HEAD, + from: HASH_BASE, + to: HASH_HEAD, + }); + return { ...project, baseMigrationHash: base.migrationHash }; +} + function markersAt(storageHash: string) { return new Map([['app', { storageHash, invariants: [] as readonly string[] }]]); } @@ -181,6 +204,30 @@ describe('migration status', () => { }); }); + it('warns when the marker equals the emitted contract but no migration ends there', async () => { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: DIR_BASE, + from: null, + to: HASH_BASE, + }); + const db = fakeDatabase({ markers: markersAt(HASH_HEAD) }); + + const run = await harness(driverConfig(project, db)).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ + { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, + ]); + expect(run.presented?.data).toMatchObject({ + summary: `Database marker ${HASH_HEAD.slice(0, 12)} is not in the on-disk migration graph`, + spaces: [{ currentContract: HASH_HEAD, targetContract: HASH_HEAD }], + }); + }); + it('records invariants the marker is missing as a warn diagnostic and still exits 0', async () => { const project = await createOfflineProject({ storageHash: HASH_HEAD }); await seedMigrationPackage({ @@ -404,6 +451,140 @@ describe('migration status', () => { expect(migrationLine).not.toContain('→'); }); + describe('reserved contract references', () => { + it('resolves --to @contract to the emitted contract, the same as no --to', async () => { + const project = await projectWithOneMigration(); + const db = fakeDatabase({ + markers: markersAt(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + const config = driverConfig(project, db); + + const implicit = await harness(config).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + const explicit = await harness(config).run( + ['migration', 'status', '--to', '@contract', '--json'], + { cwd: project.dir }, + ); + + expect(explicit.exitCode).toBe(0); + expect(explicit.presented?.data).toEqual(implicit.presented?.data); + expect(explicit.presented?.data).toMatchObject({ + summary: 'Up to date', + spaces: [{ targetContract: HASH_HEAD }], + }); + }); + + it('resolves --from @contract offline', async () => { + const project = await projectWithOneMigration(); + const db = fakeDatabase(); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@contract', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + spaces: [{ currentContract: HASH_HEAD, targetContract: HASH_HEAD }], + }); + }); + + it('resolves --to @db to the live marker and reports up to date', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + diagnostics: [], + spaces: [{ currentContract: HASH_BASE, targetContract: HASH_BASE }], + }); + }); + + it('reports the migration pending between the live marker and --to when --from is @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@db', '--to', DIR_HEAD, '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(1); + expect(run.presented?.data).toMatchObject({ + summary: `1 pending — run \`{bin} db migrate --to ${HASH_HEAD.slice(0, 12)}\``, + spaces: [ + { + currentContract: HASH_BASE, + targetContract: HASH_HEAD, + migrations: expect.arrayContaining([ + expect.objectContaining({ name: DIR_BASE, status: 'applied' }), + expect.objectContaining({ name: DIR_HEAD, status: 'pending' }), + ]), + }, + ], + }); + }); + + it('errors with the connection-required envelope for --to @db without a connection', async () => { + const project = await projectWithOneMigration(); + const config = driverConfig(project); + + const run = await harness({ ...config, db: undefined }).run( + ['migration', 'status', '--from', HASH_HEAD, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + why: expect.stringContaining('@db'), + meta: { missingFlags: ['--db'] }, + }, + }, + }); + }); + + it('errors with the connection-required envelope for --from @db without a connection', async () => { + const project = await projectWithOneMigration(); + const config = driverConfig(project); + + const run = await harness({ ...config, db: undefined }).run( + ['migration', 'status', '--from', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { code: 'CONFIG.DB_CONNECTION_REQUIRED', meta: { missingFlags: ['--db'] } }, + }, + }); + }); + }); + it('closes the connection and keeps the structured error when the marker read fails', async () => { const project = await projectWithOneMigration(); const db = fakeDatabase({ diff --git a/skills/prisma-8/references/migration-model.md b/skills/prisma-8/references/migration-model.md index ad75b7a23155..2db6eb05fe2c 100644 --- a/skills/prisma-8/references/migration-model.md +++ b/skills/prisma-8/references/migration-model.md @@ -65,7 +65,7 @@ pnpm prisma migration ref delete `migration plan` resolves its origin in exactly this order: -1. Explicit `--from ` — `@empty` names the empty database deliberately. The reserved forms `@db` and `@contract` exist in the shared ref grammar but do not resolve here: `migration plan` is offline, so `@db` (the live marker) has nothing to read, and `@contract` needs a contract hash the plan resolver does not pass. Use them with `db migrate --show` / `migration status`, not with `plan`. +1. Explicit `--from ` — `@empty` names the empty database deliberately. The reserved forms `@db` and `@contract` exist in the shared ref grammar but do not resolve here: `migration plan` is offline, so `@db` (the live marker) has nothing to read, and `@contract` needs a contract hash the plan resolver does not pass. Use them with `db migrate --show` / `migration status`, not with `plan`. 2. No `--from` → the `db` ref (`migrations/app/refs/db.json`). 3. No `db` ref → **greenfield: the plan starts from the empty database.** On an empty graph the human output adds a muted notice beneath the summary — `No db ref set — planning from an empty database. Run db init, db update, or db sign if a database already exists.` — and the JSON document carries `fromDefaulted: true`, so this case is distinguishable from an explicit `--from @empty`. From 20615a96f091453b8737d76b0d2a3abe7537895b Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 28 Sep 2026 13:13:09 +0200 Subject: [PATCH 02/37] fix(cli): db migrate --to resolves @contract and @db like --show does Without --show, db migrate resolved --to without the emitted contract's hash, so --to @contract failed with MIGRATION.REF_NOT_FOUND, and it used @db's empty placeholder hash as the target, so --to @db failed with MIGRATION.PATH_UNREACHABLE. db migrate now resolves --to @contract to the emitted contract's hash and --to @db to the live marker once the marker has been read, through the helpers db migrate --show and migration status already use (isLiveMarkerRef, liveMarkerRefHash). Every other --to form still resolves before the connection opens. The --to help text lists exactly the forms the command accepts: hash, prefix, ref name, migration dir name, ^, @contract, @db, or @empty. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- .../3-tooling/cli/src/orm/migrate.ts | 51 +++++--- .../3-tooling/cli/test/orm/migrate.test.ts | 122 +++++++++++++++++- 2 files changed, 153 insertions(+), 20 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 22382c1171a5..d0187d37107d 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -2,8 +2,7 @@ import { ormConfigSection } from '@internal/config-loader'; import type { Contract } from '@internal/contract/types'; import { createControlStack } from '@internal/framework-components/control'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; -import type { MigrationGraph } from '@internal/migration-tools/graph'; -import type { RefEntry, Refs } from '@internal/migration-tools/refs'; +import type { RefEntry } from '@internal/migration-tools/refs'; import { blindCast, castAs } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import type { Block, Presentations } from '@prisma/cli-engine'; @@ -29,7 +28,12 @@ import { type ContractIR, preflightRefAdvancement, } from '../control-api/operations/ref-advancement'; -import { resolveContractRef } from '../control-api/operations/ref-resolution'; +import { + isLiveMarkerRef, + liveMarkerRefHash, + type RefResolutionContext, + resolveContractRef, +} from '../control-api/operations/ref-resolution'; import type { CreateControlClient, MigratePathDecision, @@ -181,18 +185,19 @@ interface RequestedTarget { /** * `--to` as a contract the app graph knows. A ref target keeps the invariants - * the ref declares; a bare hash carries none. Omitting `--to` targets the - * emitted contract, which needs no resolution at all. + * the ref declares; a bare hash, `@contract`, or `@empty` carries none. + * Omitting `--to` targets the emitted contract, which needs no resolution at + * all. `@db` is not resolved here: it needs the live marker, which is read + * only once the connection is open. */ function resolveRequestedTarget( to: string | undefined, - refs: Refs, - graph: MigrationGraph, + context: RefResolutionContext, ): Result { if (to === undefined) { return ok({ entry: undefined, refName: undefined }); } - const resolved = resolveContractRef(to, { graph, refs }); + const resolved = resolveContractRef(to, context); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } @@ -200,7 +205,11 @@ function resolveRequestedTarget( return ok({ entry: { hash: resolved.value.hash, invariants: [] }, refName: undefined }); } const refName = resolved.value.provenance.refName; - return ok({ entry: refs[refName], refName }); + return ok({ entry: context.refs[refName], refName }); +} + +function liveMarkerTarget(appMarker: { readonly storageHash: string } | null): RequestedTarget { + return { entry: { hash: liveMarkerRefHash(appMarker), invariants: [] }, refName: undefined }; } export function createMigrateCommand(createClient: CreateControlClient) { @@ -226,7 +235,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { db: dbFlag, to: flag.string({ brief: - 'Target contract reference (hash, prefix, ref name, migration dir name, or ^)', + 'Target contract reference (hash, prefix, ref name, migration dir name, ^, @contract, @db, or @empty)', placeholder: 'contract', }), advanceRef: flag.string({ @@ -346,13 +355,15 @@ export function createMigrateCommand(createClient: CreateControlClient) { return notOk(normalizeError(integrityFailure)); } - const target = resolveRequestedTarget( - args.flags.to, - aggregate.app.refs, - aggregate.app.graph(), - ); - if (!target.ok) { - return notOk(target.failure); + const offlineTarget = isLiveMarkerRef(args.flags.to) + ? undefined + : resolveRequestedTarget(args.flags.to, { + graph: aggregate.app.graph(), + refs: aggregate.app.refs, + contractHash: aggregate.app.contract().storage.storageHash, + }); + if (offlineTarget !== undefined && !offlineTarget.ok) { + return notOk(offlineTarget.failure); } let document: MigrateDocument; @@ -371,7 +382,9 @@ export function createMigrateCommand(createClient: CreateControlClient) { } } - const refEntry = target.value.entry; + const target: RequestedTarget = + offlineTarget === undefined ? liveMarkerTarget(appMarker) : offlineTarget.value; + const refEntry = target.entry; if (refEntry !== undefined && refEntry.invariants.length > 0) { const invariantRefusal = refuseUnknownInvariants({ graph: appGraph, @@ -395,7 +408,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { try { const at = await aggregate.app.contractAt( refEntry.hash, - target.value.refName === undefined ? undefined : { refName: target.value.refName }, + target.refName === undefined ? undefined : { refName: target.refName }, ); applyContract = at.contract; snapshotContractJson = blindCast< diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts index 6b137fb91f30..61d21b8c7a1f 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts @@ -1,7 +1,10 @@ import { existsSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import type { MigrationPlanOperation } from '@internal/framework-components/control'; -import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; +import { + contractSnapshotDir, + writeContractSnapshot, +} from '@internal/migration-tools/contract-snapshot-store'; import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; @@ -96,6 +99,23 @@ async function buildProject(): Promise { return cwd; } +/** The snapshot store entry a `--to` target's bundle is materialized from. */ +async function writeSnapshot(cwd: string, storageHash: string): Promise { + await writeContractSnapshot(join(cwd, 'migrations'), storageHash, { + contractJson: { + storage: { storageHash, namespaces: {} }, + schemaVersion: '1.0.0', + target: TARGET, + targetFamily: FAMILY, + }, + contractDts: 'export type Contract = unknown;\n', + }); +} + +function markerAt(storageHash: string): Map { + return new Map([['app', { storageHash, invariants: [] }]]); +} + function ormConfig(cwd: string, overrides: Record = {}): Record { return { family: { @@ -417,6 +437,106 @@ describe('migrate', () => { }); }); + describe('--to', () => { + it('resolves @contract to the emitted contract and applies the pending migration', async () => { + const cwd = await buildProject(); + await writeSnapshot(cwd, C2); + mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); + mocks.migrate.mockResolvedValue( + ok({ + ...appliedSuccess(), + migrationsApplied: 1, + applied: [ + { + spaceId: 'app', + dirName: '20260101_100001_222222', + migrationHash: 'h2', + from: C1, + to: C2, + operationsExecuted: 1, + }, + ], + summary: 'Applied 1 migration(s)', + }), + ); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--to', '@contract', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith( + expect.objectContaining({ + refHash: C2, + contract: expect.objectContaining({ + storage: expect.objectContaining({ storageHash: C2 }), + }), + }), + ); + expect(run.presented?.data).toMatchObject({ + ok: true, + migrationsApplied: 1, + markerHash: C2, + summary: 'Applied 1 migration(s)', + }); + }); + + it('resolves @db to the live marker so there is nothing to run', async () => { + const cwd = await buildProject(); + await writeSnapshot(cwd, C1); + mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); + mocks.migrate.mockResolvedValue( + ok({ + ...appliedSuccess(), + migrationsApplied: 0, + markerHash: C1, + applied: [], + summary: 'Already up to date', + perSpace: [], + }), + ); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', '@db', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith( + expect.objectContaining({ + refHash: C1, + refInvariants: [], + contract: expect.objectContaining({ + storage: expect.objectContaining({ storageHash: C1 }), + }), + }), + ); + expect(run.presented?.data).toMatchObject({ + ok: true, + migrationsApplied: 0, + markerHash: C1, + summary: 'Already up to date', + }); + }); + + it('errors with the connection-required envelope for @db without a connection', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd, { db: undefined })).run( + ['db', 'migrate', '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(2); + expect(envelopeOf(run.json)).toMatchObject({ + ok: false, + error: { code: 'CONFIG.DB_CONNECTION_REQUIRED' }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.migrate).not.toHaveBeenCalled(); + }); + }); + describe('a close that fails on the way out', () => { it('reports the connection failure rather than the failure to hang up', async () => { const cwd = await buildProject(); From b2eb5aad3feaab62e472c3aeca9ea73ccd93862c Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 5 Oct 2026 17:27:02 +0200 Subject: [PATCH 03/37] fix(cli): db migrate --to @db and @empty leave an unmarked database alone; help names the forms --from accepts A database with no marker sits at the empty contract. `db migrate --to @db` on such a database resolved the target to the empty contract and then handed a zero-operation plan to the runner, which refused it because the plan's destination (empty) did not match the emitted contract. `db migrate --to @empty` on an empty database failed the same way. The runner is now skipped when a zero-operation plan's origin, with a missing marker read as the empty contract, already equals its destination, so both commands report "Already up to date". The connection-required error for `db migrate --show` named a command that does not exist, `migrate --show`, in its reason and its retry hint. It now says `db migrate --show`. Two help lines were out of date. `migration status --from` said it switches to offline path computation, which `--from @db` does not. `db migrate --from` listed four forms and left out the hash prefix, `^`, and `@empty`, which it accepts. Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- .../control-api/operations/migrate-show.ts | 6 +- .../cli/src/control-api/operations/migrate.ts | 9 ++- .../3-tooling/cli/src/orm/migrate.ts | 3 +- .../3-tooling/cli/src/orm/migration/status.ts | 2 +- .../migrate-plan-requires-execution.test.ts | 57 +++++++++++++++++++ .../cli/test/orm/migrate-show.test.ts | 8 ++- 6 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-requires-execution.test.ts diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index 6d60e8498a60..21f8b826656f 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -121,7 +121,7 @@ export async function executeMigrateShowPlan( : requireLiveDatabase({ dbConnection, hasDriver, - why: 'migrate --show needs a database connection to read the live marker (or pass --from for an offline preview)', + why: 'db migrate --show needs a database connection to read the live marker (or pass --from for an offline preview)', retryCommand: '{bin} db migrate --show --from ', }); if (missingDb) { @@ -209,8 +209,8 @@ export async function executeMigrateShowPlan( if (!dbConnection || !hasDriver) { return notOk( errorDatabaseConnectionRequired({ - why: 'A database connection is required to read the live marker for migrate --show', - commandName: 'migrate --show', + why: 'A database connection is required to read the live marker for db migrate --show', + commandName: 'db migrate --show', }), ); } diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index cc5eb0208243..35e310e99d3c 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -523,11 +523,14 @@ function buildAtHeadResolution(args: { /** * A plan needs the runner when it executes operations or advances the * space's marker (a declared-state resolution has zero operations but a - * destination hash the live marker doesn't carry yet). + * destination hash the live marker doesn't carry yet). A database with no + * marker sits at the empty contract, so a zero-op plan whose destination is + * the empty contract leaves it untouched. */ -function planRequiresExecution(entry: PerSpacePlan): boolean { +export function planRequiresExecution(entry: PerSpacePlan): boolean { if (entry.plan.operations.length > 0) return true; - return entry.plan.origin?.storageHash !== entry.plan.destination.storageHash; + const originHash = entry.plan.origin?.storageHash ?? EMPTY_CONTRACT_HASH; + return originHash !== entry.plan.destination.storageHash; } interface BuildSuccessArgs { diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 094a247b49f7..981f760e35af 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -244,7 +244,8 @@ export function createMigrateCommand(createClient: CreateControlClient) { }), show: flag.boolean({ brief: 'Preview the migration route without applying (read-only)' }), from: flag.string({ - brief: 'From-state for the --show preview (@contract, @db, hash, ref name, or dir)', + brief: + 'From-state for the --show preview (hash, prefix, ref name, migration dir name, ^, @contract, @db, or @empty)', placeholder: 'contract', }), }, diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index bbab1dfaad24..7c229b2fd20d 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -241,7 +241,7 @@ export const migrationStatusCommand = defineOrmCommand({ }), from: flag.string({ brief: - 'Origin contract reference; same grammar as --to. Supplying it switches to offline path computation', + 'Origin contract reference; same grammar as --to. With --from the path is computed offline, unless --from or --to is @db', placeholder: 'contract', }), legend: flag.boolean({ brief: 'Print a key for the tree glyphs and lane colors' }), diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-requires-execution.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-requires-execution.test.ts new file mode 100644 index 000000000000..6bc0d9fba7db --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-requires-execution.test.ts @@ -0,0 +1,57 @@ +import type { PerSpacePlan } from '@internal/migration-tools/aggregate'; +import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import { describe, expect, it } from 'vitest'; +import { planRequiresExecution } from '../../src/control-api/operations/migrate'; + +const HEAD_HASH = 'a'.repeat(64); + +function zeroOpPlan(args: { + readonly origin: string | null; + readonly destination: string; +}): PerSpacePlan { + return { + plan: { + targetId: 'postgres', + spaceId: 'app', + origin: args.origin === null ? null : { storageHash: args.origin }, + destination: { storageHash: args.destination }, + operations: [], + providedInvariants: [], + }, + displayOps: [], + strategy: 'resolve-recorded-path', + migrationEdges: [], + } as unknown as PerSpacePlan; +} + +describe('planRequiresExecution', () => { + it('skips the runner when a database with no marker targets the empty contract', () => { + expect( + planRequiresExecution(zeroOpPlan({ origin: null, destination: EMPTY_CONTRACT_HASH })), + ).toBe(false); + }); + + it('skips the runner when the marker already carries the destination', () => { + expect(planRequiresExecution(zeroOpPlan({ origin: HEAD_HASH, destination: HEAD_HASH }))).toBe( + false, + ); + }); + + it('still runs a declared-state plan that advances a database with no marker to its head', () => { + expect(planRequiresExecution(zeroOpPlan({ origin: null, destination: HEAD_HASH }))).toBe(true); + }); + + it('runs any plan that carries operations', () => { + const plan = zeroOpPlan({ origin: HEAD_HASH, destination: HEAD_HASH }); + const withOps = { + ...plan, + plan: { + ...plan.plan, + operations: [ + { id: 'relation.users', label: 'Create relation users', operationClass: 'additive' }, + ], + }, + } as unknown as PerSpacePlan; + expect(planRequiresExecution(withOps)).toBe(true); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 1f2ee3eac67d..858af70f8495 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -229,7 +229,13 @@ describe('migrate --show', () => { expect(run.exitCode).not.toBe(0); expect(run.json.at(-1)).toMatchObject({ kind: 'result', - envelope: { ok: false, error: { code: 'CONFIG.DB_CONNECTION_REQUIRED' } }, + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + why: expect.stringContaining('db migrate --show'), + }, + }, }); }); From 2c0f06fd9d3de9f23ab2e997bf87f38c80edf358 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 5 Oct 2026 17:37:33 +0200 Subject: [PATCH 04/37] fix(cli): migration status stays quiet for an all-external extension space at its head Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- .../3-tooling/cli/src/orm/migration/status.ts | 5 +- .../cli/test/orm/migration-status.test.ts | 71 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 7c229b2fd20d..86f5cc388255 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -410,7 +410,10 @@ export const migrationStatusCommand = defineOrmCommand({ ? database.markersBySpace.get(entry.space)?.storageHash : fromOverrideHash; const originHash = originHashForStatus(markerHash); - const markerInGraph = markerHash === undefined || isGraphNode(markerHash, graph); + const markerInGraph = + markerHash === undefined || + isGraphNode(markerHash, graph) || + (graph.nodes.size === 0 && markerHash === space.headRef?.hash); if ( liveOrigin && diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 6f1c1e02879f..69a005c64bc9 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -8,11 +8,13 @@ import { afterEach, describe, expect, it } from 'vitest'; import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; import { createOrmTestCli } from '../helpers/orm-test-cli'; import { + contractJson, createOfflineProject, invariantOp, type OfflineProject, offlineConfig, removeOfflineProjects, + seedContractSnapshot, seedMigrationPackage, } from './fixtures/offline-project'; @@ -132,6 +134,48 @@ function markersAt(storageHash: string) { return new Map([['app', { storageHash, invariants: [] as readonly string[] }]]); } +const EXTERNAL_SPACE = 'external'; +const HASH_EXTERNAL_HEAD = `e0e0${'3'.repeat(60)}`; + +/** An all-external extension space: a head ref on disk and no migration packages. */ +async function addAllExternalSpace(project: OfflineProject): Promise { + await writeRef(join(project.migrationsDir, EXTERNAL_SPACE, 'refs'), 'head', { + hash: HASH_EXTERNAL_HEAD, + invariants: [], + }); + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_EXTERNAL_HEAD, + }); +} + +function allExternalExtension(): Record { + return { + kind: 'extension', + id: EXTERNAL_SPACE, + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: () => ({}), + contractSpace: { + contractJson: contractJson(HASH_EXTERNAL_HEAD), + headRef: { hash: HASH_EXTERNAL_HEAD, invariants: [] }, + migrations: [], + }, + }; +} + +function withAllExternalExtension(config: Record): Record { + return { ...config, extensions: [allExternalExtension()] }; +} + +function markersWithExternalAtHead(appHash: string) { + return new Map([ + ['app', { storageHash: appHash, invariants: [] as readonly string[] }], + [EXTERNAL_SPACE, { storageHash: HASH_EXTERNAL_HEAD, invariants: [] as readonly string[] }], + ]); +} + function codesAndSeverities( diagnostics: readonly Diagnostic[], ): ReadonlyArray<{ code: string; severity: string }> { @@ -228,6 +272,33 @@ describe('migration status', () => { }); }); + it('stays quiet about an all-external extension space whose marker is at its head', async () => { + const project = await projectWithOneMigration(); + await addAllExternalSpace(project); + const db = fakeDatabase({ + markers: markersWithExternalAtHead(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + + const run = await harness(withAllExternalExtension(driverConfig(project, db))).run( + ['migration', 'status', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.diagnostics).toEqual([]); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + spaces: expect.arrayContaining([ + expect.objectContaining({ + space: EXTERNAL_SPACE, + currentContract: HASH_EXTERNAL_HEAD, + targetContract: HASH_EXTERNAL_HEAD, + }), + ]), + }); + }); + it('records invariants the marker is missing as a warn diagnostic and still exits 0', async () => { const project = await createOfflineProject({ storageHash: HASH_HEAD }); await seedMigrationPackage({ From e7043a1cac00115cea95d36587a2115004c312a5 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 5 Oct 2026 17:38:07 +0200 Subject: [PATCH 05/37] fix(cli): migration status applies --to and --from to the app space only Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- .../3-tooling/cli/src/orm/migration/status.ts | 11 ++++++---- .../cli/test/orm/migration-status.test.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 86f5cc388255..b9a5149244ea 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -401,14 +401,17 @@ export const migrationStatusCommand = defineOrmCommand({ } const graph = space.graph(); const spaceContractHash = space.contract().storage.storageHash; - const targetHash = activeRefHash ?? spaceContractHash; - if (entry.space === aggregate.app.spaceId) { + const isAppSpace = entry.space === aggregate.app.spaceId; + const targetHash = isAppSpace ? (activeRefHash ?? spaceContractHash) : spaceContractHash; + if (isAppSpace) { headlineTargetHash = targetHash; } const markerHash = liveOrigin ? database.markersBySpace.get(entry.space)?.storageHash - : fromOverrideHash; + : isAppSpace + ? fromOverrideHash + : undefined; const originHash = originHashForStatus(markerHash); const markerInGraph = markerHash === undefined || @@ -465,7 +468,7 @@ export const migrationStatusCommand = defineOrmCommand({ glyphMode, styler, palette: TONE_MIGRATION_GRAPH_PALETTE, - isAppSpace: entry.space === aggregate.app.spaceId, + isAppSpace, ...(liveOrigin && markerHash !== undefined ? { dbHash: markerHash } : {}), }); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 69a005c64bc9..c55472d82ddc 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -547,6 +547,28 @@ describe('migration status', () => { }); }); + it('targets each extension space at its own contract for --to @contract', async () => { + const project = await projectWithOneMigration(); + await addAllExternalSpace(project); + const db = fakeDatabase({ + markers: markersWithExternalAtHead(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + const config = withAllExternalExtension(driverConfig(project, db)); + + const implicit = await harness(config).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + const explicit = await harness(config).run( + ['migration', 'status', '--to', '@contract', '--json'], + { cwd: project.dir }, + ); + + expect(explicit.exitCode).toBe(0); + expect(explicit.presented?.data).toEqual(implicit.presented?.data); + expect(explicit.presented?.data).toMatchObject({ summary: 'Up to date' }); + }); + it('resolves --from @contract offline', async () => { const project = await projectWithOneMigration(); const db = fakeDatabase(); From 3a1736a8fcbc5cc0584ee180d61b6c8df0acdc93 Mon Sep 17 00:00:00 2001 From: willbot Date: Mon, 5 Oct 2026 17:40:14 +0200 Subject: [PATCH 06/37] fix(cli): db migrate keeps each space that needs no change away from the runner Signed-off-by: willbot Signed-off-by: Will Madden Co-Authored-By: Claude Fable 5.1 --- .../cli/src/control-api/operations/migrate.ts | 26 +-- .../migrate-plan-requires-execution.test.ts | 57 ----- .../migrate-runner-schedule.test.ts | 218 ++++++++++++++++++ 3 files changed, 230 insertions(+), 71 deletions(-) delete mode 100644 packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-requires-execution.test.ts create mode 100644 packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index 35e310e99d3c..9abeae459786 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -155,11 +155,11 @@ export async function executeMigrate = [aggregate.app, ...aggregate.extensions]; const perSpacePlans = new Map(); - // Already-at-head empty-graph spaces (typically extensions whose - // head ref is the empty sentinel, or whose live marker already - // matches the target). Kept out of the runner schedule so we don't - // write spurious markers for greenfield extensions, but merged back - // into the success envelope so every loaded space is represented. + // Plans that neither execute operations nor move a marker (at-head + // empty-graph spaces, or a space with no marker whose target is the + // empty contract). Kept out of the runner schedule so we don't write + // spurious markers, but merged back into the success envelope so + // every loaded space is represented. const atHeadResolutions = new Map(); for (const space of allSpaces) { const isAppSpace = space.spaceId === aggregate.app.spaceId; @@ -230,7 +230,11 @@ export async function executeMigrate m.spaceId), aggregate.app.spaceId]; @@ -240,13 +244,7 @@ export async function executeMigrate { - const entry = perSpacePlans.get(spaceId); - return entry !== undefined && planRequiresExecution(entry); - }); - if (!hasPendingWork) { + if (applyOrder.length === 0) { const ordered = canonicalOrder .filter((spaceId) => perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId)) .map((spaceId) => { @@ -527,7 +525,7 @@ function buildAtHeadResolution(args: { * marker sits at the empty contract, so a zero-op plan whose destination is * the empty contract leaves it untouched. */ -export function planRequiresExecution(entry: PerSpacePlan): boolean { +function planRequiresExecution(entry: PerSpacePlan): boolean { if (entry.plan.operations.length > 0) return true; const originHash = entry.plan.origin?.storageHash ?? EMPTY_CONTRACT_HASH; return originHash !== entry.plan.destination.storageHash; diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-requires-execution.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-requires-execution.test.ts deleted file mode 100644 index 6bc0d9fba7db..000000000000 --- a/packages/1-framework/3-tooling/cli/test/control-api/migrate-plan-requires-execution.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { PerSpacePlan } from '@internal/migration-tools/aggregate'; -import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; -import { describe, expect, it } from 'vitest'; -import { planRequiresExecution } from '../../src/control-api/operations/migrate'; - -const HEAD_HASH = 'a'.repeat(64); - -function zeroOpPlan(args: { - readonly origin: string | null; - readonly destination: string; -}): PerSpacePlan { - return { - plan: { - targetId: 'postgres', - spaceId: 'app', - origin: args.origin === null ? null : { storageHash: args.origin }, - destination: { storageHash: args.destination }, - operations: [], - providedInvariants: [], - }, - displayOps: [], - strategy: 'resolve-recorded-path', - migrationEdges: [], - } as unknown as PerSpacePlan; -} - -describe('planRequiresExecution', () => { - it('skips the runner when a database with no marker targets the empty contract', () => { - expect( - planRequiresExecution(zeroOpPlan({ origin: null, destination: EMPTY_CONTRACT_HASH })), - ).toBe(false); - }); - - it('skips the runner when the marker already carries the destination', () => { - expect(planRequiresExecution(zeroOpPlan({ origin: HEAD_HASH, destination: HEAD_HASH }))).toBe( - false, - ); - }); - - it('still runs a declared-state plan that advances a database with no marker to its head', () => { - expect(planRequiresExecution(zeroOpPlan({ origin: null, destination: HEAD_HASH }))).toBe(true); - }); - - it('runs any plan that carries operations', () => { - const plan = zeroOpPlan({ origin: HEAD_HASH, destination: HEAD_HASH }); - const withOps = { - ...plan, - plan: { - ...plan.plan, - operations: [ - { id: 'relation.users', label: 'Create relation users', operationClass: 'additive' }, - ], - }, - } as unknown as PerSpacePlan; - expect(planRequiresExecution(withOps)).toBe(true); - }); -}); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts new file mode 100644 index 000000000000..eeedbbabe18b --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts @@ -0,0 +1,218 @@ +import { rm } from 'node:fs/promises'; +import type { Contract } from '@internal/contract/types'; +import type { + ContractMarkerRecord, + ControlDriverInstance, + ControlExtensionDescriptor, + ControlFamilyInstance, + MigrationPlanOperation, + MigrationRunner, + MigrationRunnerPerSpaceOptions, + TargetMigrationsCapability, +} from '@internal/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import { writeContractSnapshot } from '@internal/migration-tools/contract-snapshot-store'; +import { computeMigrationHash } from '@internal/migration-tools/hash'; +import { writeMigrationPackage } from '@internal/migration-tools/io'; +import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { writeRef } from '@internal/migration-tools/refs'; +import { blindCast } from '@internal/utils/casts'; +import { ok } from '@internal/utils/result'; +import { createSqlContract } from '@repo/test-utils'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vitest'; +import { executeMigrate } from '../../src/control-api/operations/migrate'; +import { createTestProjectDir } from '../utils/test-project-dir'; + +const EXTERNAL_SPACE = 'external'; + +const APP_CONTRACT: Contract = createSqlContract(); +const APP_HEAD = APP_CONTRACT.storage.storageHash; + +const EXTERNAL_CONTRACT: Contract = { + ...createSqlContract({ + storage: { + namespaces: { + [UNBOUND_NAMESPACE_ID]: { id: UNBOUND_NAMESPACE_ID, entries: { table: { users: {} } } }, + }, + }, + }), + defaultControlPolicy: 'external', +}; +const EXTERNAL_HEAD = EXTERNAL_CONTRACT.storage.storageHash; + +const CREATE_TABLE: MigrationPlanOperation = { + id: 'table.post', + label: 'Create table post', + operationClass: 'additive', +}; + +const projectDirs: string[] = []; + +afterEach(async () => { + await Promise.all(projectDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +/** A migrations directory whose app space carries one migration ∅ → APP_HEAD. */ +async function migrationsDirWithOneAppEdge(): Promise { + const projectDir = createTestProjectDir('migrate-runner-schedule'); + projectDirs.push(projectDir); + const migrationsDir = join(projectDir, 'migrations'); + const ops = [CREATE_TABLE]; + const base: Omit = { + from: EMPTY_CONTRACT_HASH, + to: APP_HEAD, + providedInvariants: [], + createdAt: '2026-01-01T00:00:00.000Z', + }; + await writeMigrationPackage( + join(migrationsDir, 'app', '20260101T0000_initial'), + { ...base, migrationHash: computeMigrationHash(base, ops) }, + ops, + ); + return migrationsDir; +} + +/** Adds an all-external extension space: a head ref and a snapshot, no migration packages. */ +async function addAllExternalSpace(migrationsDir: string): Promise { + await writeRef(join(migrationsDir, EXTERNAL_SPACE, 'refs'), 'head', { + hash: EXTERNAL_HEAD, + invariants: [], + }); + await writeContractSnapshot(migrationsDir, EXTERNAL_HEAD, { + contractJson: EXTERNAL_CONTRACT, + contractDts: 'export type Contract = never;\n', + }); +} + +function allExternalExtension(): ControlExtensionDescriptor<'sql', 'postgres'> { + return { + kind: 'extension', + id: EXTERNAL_SPACE, + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + contractSpace: { + contractJson: EXTERNAL_CONTRACT, + headRef: { hash: EXTERNAL_HEAD, invariants: [] }, + migrations: [], + }, + create: () => ({ familyId: 'sql', targetId: 'postgres' }), + }; +} + +function fakeDriver(): ControlDriverInstance<'sql', 'postgres'> { + return blindCast< + ControlDriverInstance<'sql', 'postgres'>, + 'executeMigrate hands the driver to the family and runner fakes, which never touch it' + >({ familyId: 'sql', targetId: 'postgres', close: async () => {} }); +} + +function fakeFamily( + markers: ReadonlyMap, +): ControlFamilyInstance<'sql', unknown> { + const used: Pick< + ControlFamilyInstance<'sql', unknown>, + 'familyId' | 'deserializeContract' | 'readAllMarkers' + > = { + familyId: 'sql', + deserializeContract: (json) => + blindCast(json), + readAllMarkers: async () => markers, + }; + return blindCast< + ControlFamilyInstance<'sql', unknown>, + 'executeMigrate reads only the family members picked above' + >(used); +} + +/** A runner that records which spaces it was handed and reports each as applied. */ +function recordingMigrations() { + const runnerCalls: string[][] = []; + const runner: MigrationRunner<'sql', 'postgres'> = { + execute: async ({ perSpaceOptions }) => { + const spaces = perSpaceOptions.map( + (option: MigrationRunnerPerSpaceOptions<'sql', 'postgres'>) => option.space, + ); + runnerCalls.push(spaces); + return ok({ + perSpaceResults: perSpaceOptions.map((option) => ({ + space: option.space, + value: { + operationsPlanned: option.plan.operations.length, + operationsExecuted: option.plan.operations.length, + }, + })), + }); + }, + }; + const migrations = blindCast< + TargetMigrationsCapability<'sql', 'postgres', ControlFamilyInstance<'sql', unknown>>, + 'executeMigrate only creates a runner' + >({ createRunner: () => runner }); + return { runnerCalls, migrations }; +} + +function migrateOptions(args: { + readonly migrationsDir: string; + readonly markers: ReadonlyMap; + readonly migrations: TargetMigrationsCapability< + 'sql', + 'postgres', + ControlFamilyInstance<'sql', unknown> + >; + readonly extensions?: ReadonlyArray>; + readonly refHash?: string; +}) { + return { + driver: fakeDriver(), + familyInstance: fakeFamily(args.markers), + contract: APP_CONTRACT, + migrations: args.migrations, + frameworkComponents: [], + migrationsDir: args.migrationsDir, + extensions: args.extensions ?? [], + targetId: 'postgres' as const, + ...(args.refHash === undefined ? {} : { refHash: args.refHash }), + }; +} + +describe('executeMigrate runner schedule', () => { + it('leaves a database with no marker alone when the target is the empty contract', async () => { + const migrationsDir = await migrationsDirWithOneAppEdge(); + const { runnerCalls, migrations } = recordingMigrations(); + + const result = await executeMigrate( + migrateOptions({ + migrationsDir, + markers: new Map(), + migrations, + refHash: EMPTY_CONTRACT_HASH, + }), + ); + + expect(result.ok).toBe(true); + expect(result.ok && result.value.summary).toBe('Already up to date'); + expect(runnerCalls).toEqual([]); + }); + + it('runs an all-external extension that needs its marker and keeps the unmarked app space out', async () => { + const migrationsDir = await migrationsDirWithOneAppEdge(); + await addAllExternalSpace(migrationsDir); + const { runnerCalls, migrations } = recordingMigrations(); + + const result = await executeMigrate( + migrateOptions({ + migrationsDir, + markers: new Map(), + migrations, + extensions: [allExternalExtension()], + refHash: EMPTY_CONTRACT_HASH, + }), + ); + + expect(result.ok).toBe(true); + expect(runnerCalls).toEqual([[EXTERNAL_SPACE]]); + }); +}); From f3dea78826b9baf41f64c455dab4706ab7620eca Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:15:27 +0200 Subject: [PATCH 07/37] fix(cli): one source for reserved references and accepted forms; db sign and db update refuse reserved references alike - Reserved tokens (@contract, @db, @empty) and their predicates live in @internal/migration-tools beside parseContractRef. - contractHashAtMarker replaces three copies of "marker hash, or the empty contract". - One helper decides when --from/--to read the live marker, and one check reports a missing connection with a retry command that repeats the user's flags. db migrate --to @db without a connection fails before any work. - db migrate --show prints the database line whenever it reads the database. - db sign and db update --to refuse reserved references through the shared resolver with MIGRATION.REF_WRONG_GRAMMAR. - Help briefs take their form lists from one module. - db migrate passes the resolved ref name, not the raw --to text, to the runner. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/CLI Style Guide.md | 2 +- docs/reference/error-reference.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 4 +- .../contract-snapshot-resolution.ts | 32 ++++++- .../control-api/operations/migrate-show.ts | 89 ++++++------------- .../operations/migration-status-overlay.ts | 6 -- .../control-api/operations/ref-resolution.ts | 60 ++++++++----- .../3-tooling/cli/src/exports/control-api.ts | 1 - .../cli/src/orm/contract-ref-forms.ts | 28 ++++++ .../3-tooling/cli/src/orm/db/sign.ts | 4 +- .../3-tooling/cli/src/orm/db/update.ts | 13 +-- .../3-tooling/cli/src/orm/migrate.ts | 37 +++++--- .../3-tooling/cli/src/orm/migration/plan.ts | 4 +- .../3-tooling/cli/src/orm/migration/status.ts | 75 +++++++--------- .../3-tooling/cli/src/utils/cli-errors.ts | 17 ---- .../migrate-runner-schedule.test.ts | 3 +- .../3-tooling/cli/test/orm/db-sign.test.ts | 21 +++++ .../test/orm/db-update-to-resolution.test.ts | 5 +- .../cli/test/orm/migrate-show.test.ts | 38 +++++++- .../3-tooling/cli/test/orm/migrate.test.ts | 10 ++- .../cli/test/orm/migration-status.test.ts | 60 +++++++++++-- .../3-tooling/migration/src/constants.ts | 7 ++ .../migration/src/exports/constants.ts | 2 +- .../migration/src/exports/ref-resolution.ts | 9 +- .../migration/src/refs/contract-ref.ts | 26 +++++- .../migration/test/constants.test.ts | 12 +++ .../migration/test/refs/contract-ref.test.ts | 25 +++++- 27 files changed, 388 insertions(+), 204 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/src/orm/contract-ref-forms.ts create mode 100644 packages/1-framework/3-tooling/migration/test/constants.test.ts diff --git a/docs/CLI Style Guide.md b/docs/CLI Style Guide.md index 07c33a859707..de2f99b3c9e3 100644 --- a/docs/CLI Style Guide.md +++ b/docs/CLI Style Guide.md @@ -245,7 +245,7 @@ Concrete examples (from the migration CLI verb refactor, TML-2546). Each entry b - On success writes or updates the marker: missing marker → insert; same hash → no‑op; different hash → overwrite, reporting the previous hash. - Then writes the signed contract into the snapshot store and advances the `db` ref to the signed hash (`--advance-ref ` picks another ref). Unlike `db init` / `db update`, `--db` does not suppress this — signing never mutates the schema, and adoption normally runs against the real database via `--db`. `--no-advance-ref` signs without writing any ref or snapshot; combining it with `--advance-ref` is `CLI.ADVANCE_REF_ARG_CONFLICT` (exit code 2). Human output names the advanced ref and, when it existed, the previous hash; JSON carries `advancedRef: { name, hash }` or `null`. - No migration package is written. - - Options: `[contract]` positional or `--contract ` (hash, prefix, ref name, migration dir name, `^`, or `./path`; the positional accepts only the first four; defaults to the emitted `contract.json`; both together is `CLI.CONTRACT_ARG_CONFLICT`), `--db `, `--advance-ref `, `--no-advance-ref`. + - Options: `[contract]` positional or `--contract ` (hash, prefix, ref name, migration dir name, or `^`; both accept the same forms; defaults to the emitted `contract.json`; both together is `CLI.CONTRACT_ARG_CONFLICT`), `--db `, `--advance-ref `, `--no-advance-ref`. - Exit codes: 0 signed; 2 the command could not run (unresolvable contract reference, no emitted contract, unreachable database, conflicting flags); 4 verification refused. ## Init Flow diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 3fda00d79b3f..0c1858c44ed9 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1602,7 +1602,7 @@ A ref name resolves to nothing: no pointer file with that name exists, and the f ### MIGRATION.REF_WRONG_GRAMMAR -A reference parsed, but as the wrong kind for the argument position, e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. Also raised by `db update --to` for the reserved references `@contract`, `@db`, and `@empty`, which name working, live, or empty state rather than a contract on disk; `db update --to` takes a hash, a prefix, a ref name, a migration directory name, or `^`. Payload: `input`, `expectedGrammar`. +A reference parsed, but as the wrong kind for the argument position, e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. `db sign` and `db update --to` raise it for the reserved references `@contract`, `@db`, and `@empty`, which they do not accept. Payload: `input`, `expectedGrammar`. ### MIGRATION.RUNNER_FAILED diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index e40a8981cdf2..310dbbcff129 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1090,7 +1090,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t **Options:** - `--config `: Path to `prisma.config.ts` - `--name `: Name slug for the migration directory (default: `migration`) -- `--from `: Starting contract reference (hash, prefix, ref name, migration directory, `^`, `@empty`, or filesystem path). `@empty` names the empty-database origin deliberately. Defaults to the `db` ref; when the ref is absent, greenfield only on an empty graph — over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed. +- `--from `: Starting contract reference (hash, prefix, ref name, migration directory, `^`, or `@empty`). `@empty` names the empty-database origin deliberately. Defaults to the `db` ref; when the ref is absent, greenfield only on an empty graph — over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed. - `--to `: Destination contract reference (same grammar as `--from`). Defaults to the emitted `contract.json`. Use `--to ^` to plan a rollback toward a predecessor state. - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) @@ -1174,7 +1174,7 @@ prisma db migrate [--db ] [--to ] [--config ] [--json] [-v] **Options:** - `--db `: Database connection string (optional; defaults to `config.db.connection`) -- `--to `: Target contract reference (hash, prefix, ref name, migration directory, `^`, or filesystem path). When omitted, applies toward the emitted `contract.json`. When `--to` resolves to an on-disk graph node, verification and apply use the snapshot store entry for that node's hash — so a planned rollback or other arbitrary-target edge applies without editing contract source. +- `--to `: Target contract reference (hash, prefix, ref name, migration directory, `^`, `@contract`, `@db`, or `@empty`). When omitted, applies toward the emitted `contract.json`. When `--to` resolves to an on-disk graph node, verification and apply use the snapshot store entry for that node's hash — so a planned rollback or other arbitrary-target edge applies without editing contract source. - `--ref `: Target a named ref from `migrations/refs.json` instead of the current contract hash - `--config `: Path to `prisma.config.ts` - `--json`: Output as JSON object diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts index 49682dd1b057..bc574c7a8a1d 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts @@ -12,7 +12,11 @@ import { errorContractDeserializationFailed, MigrationToolsError, } from '@internal/migration-tools/errors'; -import { parseContractRef } from '@internal/migration-tools/ref-resolution'; +import { + isReservedContractRef, + parseContractRef, + type RefResolutionWrongGrammar, +} from '@internal/migration-tools/ref-resolution'; import { blindCast, castAs } from '@internal/utils/casts'; import { notOk, ok, type Result } from '@internal/utils/result'; import { join } from 'pathe'; @@ -62,9 +66,35 @@ export interface ResolveContractRefToSnapshotSuccess { readonly source: 'snapshot' | 'emitted'; } +const ON_DISK_FORMS = 'hash, prefix, ref name, migration directory name, or `^`'; + +function reservedRefRefusal( + options: ResolveContractRefToSnapshotOptions, +): RefResolutionWrongGrammar { + const input = options.refInput; + return options.fallbackToEmitted + ? { + kind: 'wrong-grammar', + input, + expectedGrammar: 'contract', + message: `"${input}" is a reserved reference; \`db sign\` names a contract on disk by ${ON_DISK_FORMS}`, + fix: `Pass a ${ON_DISK_FORMS}, or omit the contract to sign the emitted contract.`, + } + : { + kind: 'wrong-grammar', + input, + expectedGrammar: 'contract', + message: `"${input}" is a reserved reference; \`db update ${options.missingBundleFlag}\` names a migration destination on disk`, + fix: `Pass the ${ON_DISK_FORMS} of a migration destination, or omit ${options.missingBundleFlag} to update to the emitted contract.`, + }; +} + export async function resolveContractRefToSnapshot( options: ResolveContractRefToSnapshotOptions, ): Promise> { + if (isReservedContractRef(options.refInput)) { + return notOk(mapRefResolutionError(reservedRefRefusal(options))); + } try { const loaded = await buildReadAggregate(options.config, { migrationsDir: options.migrationsDir, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index 21f8b826656f..39049098312e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -8,19 +8,15 @@ import { type ContractSpaceAggregate, requireHeadRef, } from '@internal/migration-tools/aggregate'; -import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import { contractHashAtMarker, EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import { MigrationToolsError } from '@internal/migration-tools/errors'; -import { parseContractRef } from '@internal/migration-tools/ref-resolution'; import type { Refs } from '@internal/migration-tools/refs'; import { readRefs } from '@internal/migration-tools/refs'; import { notOk, ok, type Result } from '@internal/utils/result'; import { type CliStructuredError, - errorDatabaseConnectionRequired, errorPathUnreachable, errorRuntime, - mapRefResolutionError, - requireLiveDatabase, } from '../../utils/cli-errors'; import { closeQuietly, resolveMigrationPaths } from '../../utils/command-helpers'; import { createControlClient } from '../client'; @@ -29,9 +25,9 @@ import { errorFromCaught } from './caught-errors'; import { buildReadAggregate } from './contract-space-aggregate-loader'; import { planSpacePath } from './migrate'; import { - isLiveMarkerRef, - liveMarkerRefHash, - requireLiveDatabaseForLiveMarkerRef, + liveMarkerUse, + requireDatabaseForLiveMarkerUse, + resolveContractRef, } from './ref-resolution'; /** @@ -98,35 +94,21 @@ export async function executeMigrateShowPlan( ); const dbConnection = options.db ?? config.db?.connection; - const hasDriver = !!config.driver; + const driver = config.driver; const hasExplicitFrom = options.from !== undefined; - const fromLiveMarker = isLiveMarkerRef(options.from); - const toLiveMarker = isLiveMarkerRef(options.to); - const liveOrigin = !hasExplicitFrom || fromLiveMarker; - const needsLiveMarker = liveOrigin || toLiveMarker; + const use = liveMarkerUse({ from: options.from, to: options.to }); + const { liveOrigin, liveTarget } = use; - // The live DB marker is the from-state when --from is omitted or @db (same as - // migrate's default) and the target when --to is @db. Any other --from is an - // offline hypothetical — no connection needed. - if (needsLiveMarker) { - const missingDb = - fromLiveMarker || toLiveMarker - ? requireLiveDatabaseForLiveMarkerRef({ - dbConnection, - hasDriver, - command: '{bin} db migrate --show', - from: options.from, - to: options.to, - }) - : requireLiveDatabase({ - dbConnection, - hasDriver, - why: 'db migrate --show needs a database connection to read the live marker (or pass --from for an offline preview)', - retryCommand: '{bin} db migrate --show --from ', - }); - if (missingDb) { - return notOk(missingDb); - } + const missingDb = requireDatabaseForLiveMarkerUse({ + use, + dbConnection, + hasDriver: driver !== undefined, + commandName: 'db migrate --show', + from: options.from, + to: options.to, + }); + if (missingDb) { + return notOk(missingDb); } let allRefs: Refs = {}; @@ -151,14 +133,11 @@ export async function executeMigrateShowPlan( // same target invariants that real migrate would use (refInvariants ?? headRef.invariants). let targetHash: string = contractHash; let refInvariants: readonly string[] | undefined; - if (options.to && !toLiveMarker) { - const toResult = parseContractRef(options.to, { - graph: appGraph, - refs: allRefs, - contractHash, - }); + const refContext = { graph: appGraph, refs: allRefs, contractHash }; + if (options.to && !liveTarget) { + const toResult = resolveContractRef(options.to, refContext); if (!toResult.ok) { - return notOk(mapRefResolutionError(toResult.failure)); + return notOk(toResult.failure); } targetHash = toResult.value.hash; if (toResult.value.provenance.kind === 'ref') { @@ -186,14 +165,10 @@ export async function executeMigrateShowPlan( const markerBySpace = new Map(); const allSpaces: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; - if (options.from !== undefined && !fromLiveMarker) { - const fromResult = parseContractRef(options.from, { - graph: appGraph, - refs: allRefs, - contractHash, - }); + if (options.from !== undefined && !liveOrigin) { + const fromResult = resolveContractRef(options.from, refContext); if (!fromResult.ok) { - return notOk(mapRefResolutionError(fromResult.failure)); + return notOk(fromResult.failure); } // Offline hypothetical: the --from ref only carries a hash (no live invariants). // Apply the from-hash marker to the APP space only. Extension spaces are left @@ -205,20 +180,12 @@ export async function executeMigrateShowPlan( markerBySpace.set(aggregate.app.spaceId, offlineMarker); } - if (needsLiveMarker) { - if (!dbConnection || !hasDriver) { - return notOk( - errorDatabaseConnectionRequired({ - why: 'A database connection is required to read the live marker for db migrate --show', - commandName: 'db migrate --show', - }), - ); - } + if (use.needsDatabase && driver !== undefined) { const client = (options.createClient ?? createControlClient)({ family: config.family, target: config.target, adapter: config.adapter, - driver: config.driver!, + driver, extensions: config.extensions ?? [], }); try { @@ -232,8 +199,8 @@ export async function executeMigrateShowPlan( markerBySpace.set(space.spaceId, marker ?? null); } } - if (toLiveMarker) { - targetHash = liveMarkerRefHash(allMarkers.get(aggregate.app.spaceId)); + if (liveTarget) { + targetHash = contractHashAtMarker(allMarkers.get(aggregate.app.spaceId)); } } catch (error) { return notOk( diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-status-overlay.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-status-overlay.ts index c1384381458b..0418a762aafe 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-status-overlay.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migration-status-overlay.ts @@ -1,13 +1,7 @@ -import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import type { MigrationGraph } from '@internal/migration-tools/graph'; import { findPath } from '@internal/migration-tools/migration-graph'; import type { MigrationEdgeAnnotation } from '../../utils/formatters/migration-graph-labels'; -/** Origin hash for status path computation: the live/override marker, or the empty-contract sentinel. */ -export function originHashForStatus(markerHash: string | undefined): string { - return markerHash ?? EMPTY_CONTRACT_HASH; -} - export interface DeriveStatusEdgeAnnotationsInput { readonly graph: MigrationGraph; readonly targetHash: string; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts index 9bdd8a9876b5..df0836ab9ebf 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts @@ -2,10 +2,14 @@ * Client-free contract/migration reference resolution for commands, wrapping migration-tools' parsers with the CLI error mapping. */ -import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import type { MigrationGraph } from '@internal/migration-tools/graph'; import type { ContractRef, MigrationRef } from '@internal/migration-tools/ref-resolution'; -import { parseContractRef, parseMigrationRef } from '@internal/migration-tools/ref-resolution'; +import { + isLiveMarkerRef, + LIVE_MARKER_REF, + parseContractRef, + parseMigrationRef, +} from '@internal/migration-tools/ref-resolution'; import type { Refs } from '@internal/migration-tools/refs'; import { notOk, ok, type Result } from '@internal/utils/result'; import { @@ -36,38 +40,45 @@ export function resolveMigrationRef( return result.ok ? ok(result.value) : notOk(mapRefResolutionError(result.failure)); } -const LIVE_MARKER_REF = '@db'; - -const RESERVED_CONTRACT_REFS: ReadonlySet = new Set([ - '@contract', - LIVE_MARKER_REF, - '@empty', -]); - -export function isReservedContractRef(input: string): boolean { - return RESERVED_CONTRACT_REFS.has(input); +export interface LiveMarkerUse { + readonly liveOrigin: boolean; + readonly liveTarget: boolean; + readonly needsDatabase: boolean; } -export function isLiveMarkerRef(input: string | undefined): boolean { - return input === LIVE_MARKER_REF; -} - -/** The hash `@db` names once the marker is read; an unsigned database sits at the empty contract. */ -export function liveMarkerRefHash( - marker: { readonly storageHash: string } | null | undefined, -): string { - return marker?.storageHash ?? EMPTY_CONTRACT_HASH; +/** Where `--from`/`--to` read the live marker: an omitted or `@db` origin, and an `@db` target. */ +export function liveMarkerUse(refs: { + readonly from: string | undefined; + readonly to: string | undefined; +}): LiveMarkerUse { + const liveOrigin = refs.from === undefined || isLiveMarkerRef(refs.from); + const liveTarget = isLiveMarkerRef(refs.to); + return { liveOrigin, liveTarget, needsDatabase: liveOrigin || liveTarget }; } -export function requireLiveDatabaseForLiveMarkerRef(args: { +export function requireDatabaseForLiveMarkerUse(args: { + readonly use: LiveMarkerUse; readonly dbConnection: unknown; readonly hasDriver: boolean; - readonly command: string; + readonly commandName: string; readonly from: string | undefined; readonly to: string | undefined; }): CliStructuredError | null { + if (!args.use.needsDatabase) { + return null; + } + const namesLiveMarker = isLiveMarkerRef(args.from) || isLiveMarkerRef(args.to); + if (!namesLiveMarker) { + return requireLiveDatabase({ + dbConnection: args.dbConnection, + hasDriver: args.hasDriver, + why: `${args.commandName} needs a database connection to read the live marker (or pass --from for an offline preview)`, + commandName: args.commandName, + retryCommand: `{bin} ${args.commandName} --from `, + }); + } const retryCommand = [ - args.command, + `{bin} ${args.commandName}`, ...(args.from === undefined ? [] : [`--from ${args.from}`]), ...(args.to === undefined ? [] : [`--to ${args.to}`]), '--db $DATABASE_URL', @@ -76,6 +87,7 @@ export function requireLiveDatabaseForLiveMarkerRef(args: { dbConnection: args.dbConnection, hasDriver: args.hasDriver, why: `${LIVE_MARKER_REF} resolves to the live database marker and requires a --db connection`, + commandName: args.commandName, retryCommand, }); } diff --git a/packages/1-framework/3-tooling/cli/src/exports/control-api.ts b/packages/1-framework/3-tooling/cli/src/exports/control-api.ts index 441c5a6750a8..7c6945c581d2 100644 --- a/packages/1-framework/3-tooling/cli/src/exports/control-api.ts +++ b/packages/1-framework/3-tooling/cli/src/exports/control-api.ts @@ -103,7 +103,6 @@ export { export { appliedHashesFromLedger, deriveStatusEdgeAnnotations, - originHashForStatus, statusForMigrationHash, } from '../control-api/operations/migration-status-overlay'; export { diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract-ref-forms.ts b/packages/1-framework/3-tooling/cli/src/orm/contract-ref-forms.ts new file mode 100644 index 000000000000..c9116f02326d --- /dev/null +++ b/packages/1-framework/3-tooling/cli/src/orm/contract-ref-forms.ts @@ -0,0 +1,28 @@ +import { + EMPTY_CONTRACT_REF, + LIVE_MARKER_REF, + WORKING_CONTRACT_REF, +} from '@internal/migration-tools/ref-resolution'; + +const ON_DISK_FORMS = ['hash', 'prefix', 'ref name', 'migration dir name', '^'] as const; + +function listForms(forms: readonly string[]): string { + return `${forms.slice(0, -1).join(', ')}, or ${forms.at(-1)}`; +} + +/** The forms that name a contract on disk. */ +export const ON_DISK_CONTRACT_REF_FORMS = listForms(ON_DISK_FORMS); + +/** The on-disk forms plus `@empty`. */ +export const ON_DISK_OR_EMPTY_CONTRACT_REF_FORMS = listForms([ + ...ON_DISK_FORMS, + EMPTY_CONTRACT_REF, +]); + +/** The on-disk forms plus every reserved token. */ +export const ALL_CONTRACT_REF_FORMS = listForms([ + ...ON_DISK_FORMS, + WORKING_CONTRACT_REF, + LIVE_MARKER_REF, + EMPTY_CONTRACT_REF, +]); diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts index 09132af98a89..d5dddb72f8dd 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts @@ -19,6 +19,7 @@ import { } from '../../control-api/operations/ref-advancement'; import { errorAdvanceRefArgConflict, errorContractArgConflict } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl } from '../../utils/command-helpers'; +import { ON_DISK_CONTRACT_REF_FORMS } from '../contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { appRefsDirFor, baseDirFor, displayPath, migrationsDirFor } from '../migration/paths'; @@ -59,8 +60,7 @@ type SchemaVerifyDocument = VerifyDatabaseSchemaResult; */ const DEFAULT_ADVANCE_REF = 'db'; -const CONTRACT_REF_BRIEF = - 'Contract reference (hash, prefix, ref name, migration dir name, or ^)'; +const CONTRACT_REF_BRIEF = `Contract reference (${ON_DISK_CONTRACT_REF_FORMS})`; interface AdvancedRef { readonly name: string; diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index 77fbc9a04147..b8b4b1301eca 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -18,16 +18,12 @@ import { NO_REF_ADVANCEMENT, preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; -import { isReservedContractRef } from '../../control-api/operations/ref-resolution'; import type { CreateControlClient, DbUpdateResult, DbUpdateSuccess } from '../../control-api/types'; -import { - CliStructuredError, - errorContractValidationFailed, - errorUpdateTargetReservedRef, -} from '../../utils/cli-errors'; +import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; import { mapDbUpdateFailure } from '../../utils/db-update-failure'; import type { MigrationCommandResult } from '../../utils/formatters/migrations'; +import { ON_DISK_CONTRACT_REF_FORMS } from '../contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { baseDirFor } from '../migration/paths'; @@ -141,7 +137,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { db: dbFlag, dryRun: flag.boolean({ brief: 'Preview the planned operations without applying them' }), to: flag.string({ - brief: 'Contract to update to (hash, prefix, ref name, migration dir name, or ^)', + brief: `Contract to update to (${ON_DISK_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), advanceRef: flag.string({ @@ -152,9 +148,6 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { }, needs: { config: ormConfigSection }, handler: async (args, ctx) => { - if (args.flags.to !== undefined && isReservedContractRef(args.flags.to)) { - return notOk(normalizeError(errorUpdateTargetReservedRef(args.flags.to))); - } const startedAt = Date.now(); const prepared = await prepareMigrationRun({ config: ctx.config, diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 981f760e35af..549c23d2e824 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -1,6 +1,7 @@ import { ormConfigSection } from '@internal/config-loader'; import type { Contract } from '@internal/contract/types'; import { createControlStack } from '@internal/framework-components/control'; +import { contractHashAtMarker } from '@internal/migration-tools/constants'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import type { RefEntry } from '@internal/migration-tools/refs'; import { blindCast, castAs } from '@internal/utils/casts'; @@ -29,9 +30,9 @@ import { preflightRefAdvancement, } from '../control-api/operations/ref-advancement'; import { - isLiveMarkerRef, - liveMarkerRefHash, + liveMarkerUse, type RefResolutionContext, + requireDatabaseForLiveMarkerUse, resolveContractRef, } from '../control-api/operations/ref-resolution'; import type { @@ -52,6 +53,7 @@ import { toneDrawing } from '../utils/formatters/tone-markup'; import { mapMigrateFailure } from '../utils/migrate-failure'; import { runCommandAction } from '../utils/next-actions'; import { snapshotVerifierFor } from '../utils/snapshot-content-verification'; +import { ALL_CONTRACT_REF_FORMS } from './contract-ref-forms'; import { perSpaceBlocks } from './db/migration-blocks'; import { prepareMigrationRun } from './db/prepare'; import { defineOrmCommand } from './define-command'; @@ -209,7 +211,7 @@ function resolveRequestedTarget( } function liveMarkerTarget(appMarker: { readonly storageHash: string } | null): RequestedTarget { - return { entry: { hash: liveMarkerRefHash(appMarker), invariants: [] }, refName: undefined }; + return { entry: { hash: contractHashAtMarker(appMarker), invariants: [] }, refName: undefined }; } export function createMigrateCommand(createClient: CreateControlClient) { @@ -234,8 +236,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { flags: { db: dbFlag, to: flag.string({ - brief: - 'Target contract reference (hash, prefix, ref name, migration dir name, ^, @contract, @db, or @empty)', + brief: `Target contract reference (${ALL_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), advanceRef: flag.string({ @@ -244,8 +245,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { }), show: flag.boolean({ brief: 'Preview the migration route without applying (read-only)' }), from: flag.string({ - brief: - 'From-state for the --show preview (hash, prefix, ref name, migration dir name, ^, @contract, @db, or @empty)', + brief: `From-state for the --show preview (${ALL_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), }, @@ -291,7 +291,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { runList: migrateShowRunListRows(plan.migrations, rendering, paint), migrationsDir: migrationsRelative, database: - args.flags.from === undefined && typeof dbConnection === 'string' + liveMarkerUse(args.flags).needsDatabase && typeof dbConnection === 'string' ? maskConnectionUrl(dbConnection) : undefined, from: args.flags.from, @@ -301,6 +301,21 @@ export function createMigrateCommand(createClient: CreateControlClient) { ); } + const use = liveMarkerUse({ from: undefined, to: args.flags.to }); + if (use.liveTarget) { + const missingDb = requireDatabaseForLiveMarkerUse({ + use, + dbConnection: args.flags.db ?? ctx.config.db?.connection, + hasDriver: ctx.config.driver !== undefined, + commandName: 'db migrate', + from: undefined, + to: args.flags.to, + }); + if (missingDb !== null) { + return notOk(normalizeError(missingDb)); + } + } + const startedAt = Date.now(); const prepared = await prepareMigrationRun({ config: ctx.config, @@ -356,7 +371,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { return notOk(normalizeError(integrityFailure)); } - const offlineTarget = isLiveMarkerRef(args.flags.to) + const offlineTarget = use.liveTarget ? undefined : resolveRequestedTarget(args.flags.to, { graph: aggregate.app.graph(), @@ -391,7 +406,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { graph: appGraph, markerInvariants: appMarker?.invariants ?? [], refInvariants: refEntry.invariants, - ...ifDefined('refName', args.flags.to), + ...ifDefined('refName', target.refName), }); if (invariantRefusal) { return notOk(normalizeError(invariantRefusal)); @@ -454,7 +469,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { onProgress: controlProgressReporter(ctx.report), ...ifDefined('refHash', refEntry?.hash), ...(refEntry?.invariants === undefined ? {} : { refInvariants: refEntry.invariants }), - ...(refEntry === undefined ? {} : ifDefined('refName', args.flags.to)), + ...ifDefined('refName', target.refName), }); if (!applied.ok) { return notOk(normalizeError(mapMigrateFailure(applied.failure))); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 36fe5111d8af..47d9fda8551d 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -17,6 +17,7 @@ import type { CreateControlClient, DestructivePlanOperation } from '../../contro import { ERROR_CODE_DESTRUCTIVE_CHANGES } from '../../utils/cli-errors'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; +import { ON_DISK_OR_EMPTY_CONTRACT_REF_FORMS } from '../contract-ref-forms'; import { destructiveOperationList, errorConsentOperationsMissing } from '../db/consent'; import { defineOrmCommand } from '../define-command'; import { consentToken } from '../init-inputs'; @@ -280,8 +281,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { flags: { name: flag.string({ brief: 'Name slug for the migration directory', placeholder: 'slug' }), from: flag.string({ - brief: - 'Starting contract reference (hash, prefix, ref name, migration dir name, ^, or @empty)', + brief: `Starting contract reference (${ON_DISK_OR_EMPTY_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), to: flag.string({ diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index b9a5149244ea..ccbd1d89f233 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -4,6 +4,7 @@ import type { AggregateContractSpace, ContractMarkerRecordLike, } from '@internal/migration-tools/aggregate'; +import { contractHashAtMarker } from '@internal/migration-tools/constants'; import { isGraphNode } from '@internal/migration-tools/migration-graph'; import type { ContractRef } from '@internal/migration-tools/ref-resolution'; import type { RefEntry, Refs } from '@internal/migration-tools/refs'; @@ -37,17 +38,14 @@ import { import { appliedHashesFromLedger, deriveStatusEdgeAnnotations, - originHashForStatus, statusForMigrationHash, } from '../../control-api/operations/migration-status-overlay'; import { - isLiveMarkerRef, - liveMarkerRefHash, - requireLiveDatabaseForLiveMarkerRef, + liveMarkerUse, + requireDatabaseForLiveMarkerUse, resolveContractRef, } from '../../control-api/operations/ref-resolution'; import { readMigrationRefs } from '../../control-api/operations/refs'; -import { requireLiveDatabase } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl, readContractEnvelope } from '../../utils/command-helpers'; import { renderMigrationGraphLegend } from '../../utils/formatters/migration-graph-labels'; import { TONE_MIGRATION_GRAPH_PALETTE } from '../../utils/formatters/migration-graph-palette'; @@ -60,6 +58,7 @@ import { createToneMigrationListStyler } from '../../utils/formatters/migration- import type { MigrationListEntry } from '../../utils/formatters/migration-list-types'; import { toneDrawing } from '../../utils/formatters/tone-markup'; import type { GlyphMode } from '../../utils/glyph-mode'; +import { ALL_CONTRACT_REF_FORMS } from '../contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { normalizeError } from '../normalize-error'; @@ -235,13 +234,11 @@ export const migrationStatusCommand = defineOrmCommand({ db: dbFlag, space: flag.string({ brief: 'Narrow output to a single contract space', placeholder: 'id' }), to: flag.string({ - brief: - 'Target contract reference (hash, prefix, ref name, migration dir name, ^, @contract, @db, or @empty)', + brief: `Target contract reference (${ALL_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), from: flag.string({ - brief: - 'Origin contract reference; same grammar as --to. With --from the path is computed offline, unless --from or --to is @db', + brief: `Origin contract reference (${ALL_CONTRACT_REF_FORMS}). With --from the path is computed offline, unless --from or --to is @db`, placeholder: 'contract', }), legend: flag.boolean({ brief: 'Print a key for the tree glyphs and lane colors' }), @@ -253,30 +250,19 @@ export const migrationStatusCommand = defineOrmCommand({ const migrationsDir = migrationsDirFor(ctx.config); const dbConnection = args.flags.db ?? ctx.config.db?.connection; const hasDriver = ctx.config.driver !== undefined; - const fromLiveMarker = isLiveMarkerRef(args.flags.from); - const toLiveMarker = isLiveMarkerRef(args.flags.to); - const liveOrigin = args.flags.from === undefined || fromLiveMarker; - const needsDatabase = liveOrigin || toLiveMarker; - - if (needsDatabase) { - const missingDb = - fromLiveMarker || toLiveMarker - ? requireLiveDatabaseForLiveMarkerRef({ - dbConnection, - hasDriver, - command: '{bin} migration status', - from: args.flags.from, - to: args.flags.to, - }) - : requireLiveDatabase({ - dbConnection, - hasDriver, - why: 'migration status needs a database connection to read the marker and ledger (or pass --from for an offline path preview)', - retryCommand: '{bin} migration status --from ', - }); - if (missingDb !== null) { - return notOk(normalizeError(missingDb)); - } + const { from, to } = args.flags; + const use = liveMarkerUse({ from, to }); + const { liveOrigin, liveTarget, needsDatabase } = use; + const missingDb = requireDatabaseForLiveMarkerUse({ + use, + dbConnection, + hasDriver, + commandName: 'migration status', + from, + to, + }); + if (missingDb !== null) { + return notOk(normalizeError(missingDb)); } const refsResult = await readMigrationRefs(appRefsDirFor(ctx.config)); @@ -315,8 +301,8 @@ export const migrationStatusCommand = defineOrmCommand({ const refContext = { graph: appGraph, refs, contractHash }; let toRef: ContractRef | undefined; - if (args.flags.to !== undefined && !toLiveMarker) { - const resolved = resolveContractRef(args.flags.to, refContext); + if (to !== undefined && !liveTarget) { + const resolved = resolveContractRef(to, refContext); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } @@ -324,8 +310,8 @@ export const migrationStatusCommand = defineOrmCommand({ } let fromOverrideHash: string | undefined; - if (args.flags.from !== undefined && !fromLiveMarker) { - const resolved = resolveContractRef(args.flags.from, refContext); + if (from !== undefined && !liveOrigin) { + const resolved = resolveContractRef(from, refContext); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } @@ -363,7 +349,7 @@ export const migrationStatusCommand = defineOrmCommand({ } const appMarker = database.markersBySpace.get(aggregate.app.spaceId); - const activeRefHash = toLiveMarker ? liveMarkerRefHash(appMarker) : toRef?.hash; + const activeRefHash = liveTarget ? contractHashAtMarker(appMarker) : toRef?.hash; const activeRefName = toRef?.provenance.kind === 'ref' ? toRef.provenance.refName : undefined; const activeRefEntry: RefEntry | undefined = activeRefName === undefined ? undefined : refs[activeRefName]; @@ -407,12 +393,13 @@ export const migrationStatusCommand = defineOrmCommand({ headlineTargetHash = targetHash; } - const markerHash = liveOrigin - ? database.markersBySpace.get(entry.space)?.storageHash - : isAppSpace - ? fromOverrideHash + const marker = liveOrigin + ? database.markersBySpace.get(entry.space) + : isAppSpace && fromOverrideHash !== undefined + ? { storageHash: fromOverrideHash } : undefined; - const originHash = originHashForStatus(markerHash); + const markerHash = marker?.storageHash; + const originHash = contractHashAtMarker(marker); const markerInGraph = markerHash === undefined || isGraphNode(markerHash, graph) || @@ -482,7 +469,7 @@ export const migrationStatusCommand = defineOrmCommand({ if (activeRefHash !== undefined) { const unreachable = refuseMissingInvariantPath({ graph: appGraph, - originHash: originHashForStatus(appMarker?.storageHash), + originHash: contractHashAtMarker(appMarker), targetHash: activeRefHash, missing, ...ifDefined('refName', activeRefName), diff --git a/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts b/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts index 45bc31146992..aca5c8a5d4f0 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/cli-errors.ts @@ -124,23 +124,6 @@ export function errorContractArgConflict(options: { ); } -const UPDATE_TARGET_FORMS = 'a hash, a prefix, a ref name, a migration directory name, or `^`'; - -/** `db update --to` was given `@contract`, `@db`, or `@empty`, which name state rather than a contract on disk. */ -export function errorUpdateTargetReservedRef(input: string): ActionableCliError { - const fix = `Pass ${UPDATE_TARGET_FORMS}, or omit --to to update to the emitted contract.`; - return new ActionableCliError( - 'MIGRATION.REF_WRONG_GRAMMAR', - `Not a contract \`db update --to\` accepts: "${input}"`, - { - why: `\`db update --to\` takes ${UPDATE_TARGET_FORMS}; without \`--to\` it updates to the emitted contract. "${input}" is a reserved reference for working, live, or empty state, not a contract on disk.`, - fix, - nextActions: [chooseAction(fix)], - meta: { input, expectedGrammar: 'contract' }, - }, - ); -} - /** * A command was told both which ref to advance and not to advance any ref. * Same envelope as the positional/flag contract conflict, so a script sees diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts index eeedbbabe18b..4685a2b7fc6b 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts @@ -1,7 +1,6 @@ import { rm } from 'node:fs/promises'; -import type { Contract } from '@internal/contract/types'; +import type { Contract, ContractMarkerRecord } from '@internal/contract/types'; import type { - ContractMarkerRecord, ControlDriverInstance, ControlExtensionDescriptor, ControlFamilyInstance, diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts index deaaf84a33e9..e577eff031e6 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts @@ -299,6 +299,27 @@ describe('db sign', () => { expect(mocks.schemaVerify).not.toHaveBeenCalled(); }); + it.each(['@empty', '@contract', '@db'])( + 'refuses the reserved reference %s with the wrong-grammar envelope', + async (input) => { + const dir = await projectDir(); + + const run = await harness(ormConfig()).run(['db', 'sign', input, '--json'], { cwd: dir }); + + expect(run.exitCode).toBe(2); + expect(envelopeOf(run)).toMatchObject({ + ok: false, + error: { + code: 'MIGRATION.REF_WRONG_GRAMMAR', + why: `"${input}" is a reserved reference; \`db sign\` names a contract on disk by hash, prefix, ref name, migration directory name, or \`^\``, + meta: { input, expectedGrammar: 'contract' }, + }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.sign).not.toHaveBeenCalled(); + }, + ); + it('reports a refused connection as every command does, with its driver code', async () => { const dir = await projectDir(); mocks.schemaVerify.mockRejectedValue(refusedConnection()); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts index d5344c524a0e..541a403e532a 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts @@ -155,13 +155,12 @@ describe('db update --to bundle resolution', () => { ok: false, error: { code: 'MIGRATION.REF_WRONG_GRAMMAR', - why: expect.stringContaining( - '`db update --to` takes a hash, a prefix, a ref name, a migration directory name, or `^`; without `--to` it updates to the emitted contract', - ), + why: `"${input}" is a reserved reference; \`db update --to\` names a migration destination on disk`, meta: { input, expectedGrammar: 'contract' }, }, }, }); + expect(mocks.connect).not.toHaveBeenCalled(); expect(mocks.dbUpdate).not.toHaveBeenCalled(); }, ); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 858af70f8495..0c4efd090786 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -353,6 +353,29 @@ describe('migrate --show', () => { expect(run.presented?.data).toMatchObject({ ok: true, migrations: [] }); }); + it.each([ + { argv: ['--from', '@db'], named: true }, + { argv: ['--from', EMPTY, '--to', '@db'], named: true }, + { argv: ['--from', EMPTY], named: false }, + ])( + 'names the database in the header only when the preview reads it: $argv', + async ({ argv, named }) => { + const cwd = await buildProject(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C1, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', ...argv], { + cwd, + isTty: { stdout: true }, + }); + const header = run.presented?.presentation.human.find((block) => block.kind === 'fields'); + const labels = header?.kind === 'fields' ? header.rows.map((row) => row.label) : []; + + expect(labels.includes('database')).toBe(named); + }, + ); + it('errors structurally for --to @db without a connection', async () => { const cwd = await buildProject(); @@ -364,7 +387,20 @@ describe('migrate --show', () => { expect(run.exitCode).not.toBe(0); expect(run.json.at(-1)).toMatchObject({ kind: 'result', - envelope: { ok: false, error: { code: 'CONFIG.DB_CONNECTION_REQUIRED' } }, + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `db migrate --show --from ${EMPTY} --to @db --db $DATABASE_URL`, + ), + }), + ], + }, + }, }); }); }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts index 377dc09df168..72bbdc072da2 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts @@ -530,7 +530,15 @@ describe('migrate', () => { expect(run.exitCode).toBe(2); expect(envelopeOf(run.json)).toMatchObject({ ok: false, - error: { code: 'CONFIG.DB_CONNECTION_REQUIRED' }, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining('db migrate --to @db --db $DATABASE_URL'), + }), + ], + }, }); expect(mocks.connect).not.toHaveBeenCalled(); expect(mocks.migrate).not.toHaveBeenCalled(); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index c55472d82ddc..3dd66ff36480 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -554,19 +554,25 @@ describe('migration status', () => { markers: markersWithExternalAtHead(HASH_HEAD), ledger: [{ migrationHash: project.migrationHash }], }); - const config = withAllExternalExtension(driverConfig(project, db)); - const implicit = await harness(config).run(['migration', 'status', '--json'], { - cwd: project.dir, - }); - const explicit = await harness(config).run( + const run = await harness(withAllExternalExtension(driverConfig(project, db))).run( ['migration', 'status', '--to', '@contract', '--json'], { cwd: project.dir }, ); - expect(explicit.exitCode).toBe(0); - expect(explicit.presented?.data).toEqual(implicit.presented?.data); - expect(explicit.presented?.data).toMatchObject({ summary: 'Up to date' }); + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + diagnostics: [], + spaces: expect.arrayContaining([ + expect.objectContaining({ space: 'app', targetContract: HASH_HEAD }), + expect.objectContaining({ + space: EXTERNAL_SPACE, + currentContract: HASH_EXTERNAL_HEAD, + targetContract: HASH_EXTERNAL_HEAD, + }), + ]), + }); }); it('resolves --from @contract offline', async () => { @@ -635,6 +641,37 @@ describe('migration status', () => { }); }); + it('reads the database only for the target when --from is a hash and --to is @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_HEAD), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + const document = run.presented?.data as { + spaces: ReadonlyArray<{ + currentContract: string | null; + targetContract: string; + migrations: ReadonlyArray<{ status: string }>; + }>; + }; + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(1); + expect(document.spaces).toHaveLength(1); + expect(document.spaces[0]).toMatchObject({ + currentContract: HASH_BASE, + targetContract: HASH_HEAD, + }); + expect(document.spaces[0]?.migrations.map((migration) => migration.status)).not.toContain( + 'applied', + ); + }); + it('errors with the connection-required envelope for --to @db without a connection', async () => { const project = await projectWithOneMigration(); const config = driverConfig(project); @@ -653,6 +690,13 @@ describe('migration status', () => { code: 'CONFIG.DB_CONNECTION_REQUIRED', why: expect.stringContaining('@db'), meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `migration status --from ${HASH_HEAD} --to @db --db $DATABASE_URL`, + ), + }), + ], }, }, }); diff --git a/packages/1-framework/3-tooling/migration/src/constants.ts b/packages/1-framework/3-tooling/migration/src/constants.ts index 7fd1621d551a..4724065dddc0 100644 --- a/packages/1-framework/3-tooling/migration/src/constants.ts +++ b/packages/1-framework/3-tooling/migration/src/constants.ts @@ -3,3 +3,10 @@ * This is a human-readable marker, not a real SHA-256 hash. */ export const EMPTY_CONTRACT_HASH = 'empty' as const; + +/** The contract hash a database is at: its marker's storage hash, or the empty contract when it has no marker. */ +export function contractHashAtMarker( + marker: { readonly storageHash: string } | null | undefined, +): string { + return marker?.storageHash ?? EMPTY_CONTRACT_HASH; +} diff --git a/packages/1-framework/3-tooling/migration/src/exports/constants.ts b/packages/1-framework/3-tooling/migration/src/exports/constants.ts index ec6e13ad7b4a..cd4692a80cac 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/constants.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/constants.ts @@ -1 +1 @@ -export { EMPTY_CONTRACT_HASH } from '../constants'; +export { contractHashAtMarker, EMPTY_CONTRACT_HASH } from '../constants'; diff --git a/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts b/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts index 232ffef39554..7fecaac7e806 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts @@ -1,4 +1,11 @@ -export { parseContractRef } from '../refs/contract-ref'; +export { + EMPTY_CONTRACT_REF, + isLiveMarkerRef, + isReservedContractRef, + LIVE_MARKER_REF, + parseContractRef, + WORKING_CONTRACT_REF, +} from '../refs/contract-ref'; export { parseMigrationRef } from '../refs/migration-ref'; export type { ContractRef, diff --git a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts index 7c58fd628638..3eb7e41434fd 100644 --- a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts +++ b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts @@ -10,6 +10,26 @@ import type { } from './types'; import { findEdgeByDirName, isFullHash, isHexPrefix } from './types'; +export const WORKING_CONTRACT_REF = '@contract'; +export const LIVE_MARKER_REF = '@db'; +export const EMPTY_CONTRACT_REF = '@empty'; + +const RESERVED_CONTRACT_REFS: ReadonlySet = new Set([ + WORKING_CONTRACT_REF, + LIVE_MARKER_REF, + EMPTY_CONTRACT_REF, +]); + +/** True for `@contract`, `@db`, and `@empty`, which name a state rather than a contract on disk. */ +export function isReservedContractRef(input: string): boolean { + return RESERVED_CONTRACT_REFS.has(input); +} + +/** True for `@db`, the only reserved token that needs a database read to resolve. */ +export function isLiveMarkerRef(input: string | undefined): boolean { + return input === LIVE_MARKER_REF; +} + /** * Resolve a user-supplied string to a contract hash using the unified * contract-reference grammar. @@ -36,7 +56,7 @@ export function parseContractRef( return notOk({ kind: 'invalid-format', input, reason: 'Reference cannot be empty' }); } - if (input === '@contract') { + if (input === WORKING_CONTRACT_REF) { if (ctx.contractHash === undefined) { return notOk({ kind: 'not-found', @@ -47,7 +67,7 @@ export function parseContractRef( return ok({ hash: ctx.contractHash, provenance: { kind: 'reserved-contract' } }); } - if (input === '@db') { + if (input === LIVE_MARKER_REF) { // The live DB marker is not available offline. Return a sentinel result with // a `reserved-db` provenance; callers must resolve the actual hash via // `readAllMarkers()`. The `hash` placeholder is intentionally empty — it @@ -56,7 +76,7 @@ export function parseContractRef( return ok({ hash: '', provenance: { kind: 'reserved-db' } }); } - if (input === '@empty') { + if (input === EMPTY_CONTRACT_REF) { return ok({ hash: EMPTY_CONTRACT_HASH, provenance: { kind: 'reserved-empty' } }); } diff --git a/packages/1-framework/3-tooling/migration/test/constants.test.ts b/packages/1-framework/3-tooling/migration/test/constants.test.ts new file mode 100644 index 000000000000..826edd35ec81 --- /dev/null +++ b/packages/1-framework/3-tooling/migration/test/constants.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { contractHashAtMarker, EMPTY_CONTRACT_HASH } from '../src/constants'; + +describe('contractHashAtMarker', () => { + it('is the marker storage hash when a marker exists', () => { + expect(contractHashAtMarker({ storageHash: 'a'.repeat(64) })).toBe('a'.repeat(64)); + }); + + it.each([null, undefined])('is the empty contract when the marker is %s', (marker) => { + expect(contractHashAtMarker(marker)).toBe(EMPTY_CONTRACT_HASH); + }); +}); diff --git a/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts b/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts index cd653d98f746..959ecff7df8d 100644 --- a/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts +++ b/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts @@ -3,7 +3,11 @@ import { EMPTY_CONTRACT_HASH } from '../../src/constants'; import { reconstructGraph } from '../../src/migration-graph'; import type { OnDiskMigrationPackage } from '../../src/package'; import type { Refs } from '../../src/refs'; -import { parseContractRef } from '../../src/refs/contract-ref'; +import { + isLiveMarkerRef, + isReservedContractRef, + parseContractRef, +} from '../../src/refs/contract-ref'; import type { RefResolutionContext, RefResolutionError } from '../../src/refs/types'; const HASH_A = `${'a'.repeat(64)}`; @@ -316,3 +320,22 @@ describe('parseContractRef', () => { }); }); }); + +describe('reserved contract references', () => { + it.each(['@contract', '@db', '@empty'])('%s is reserved', (input) => { + expect(isReservedContractRef(input)).toBe(true); + }); + + it.each(['contract', 'db', '@other', HASH_A])('%s is not reserved', (input) => { + expect(isReservedContractRef(input)).toBe(false); + }); + + it('only @db names the live marker', () => { + expect([undefined, '@contract', '@db', '@empty'].map(isLiveMarkerRef)).toEqual([ + false, + false, + true, + false, + ]); + }); +}); From e831fbe4b0836a9281d2225d1aff7440e30c08ff Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:16:14 +0200 Subject: [PATCH 08/37] fix(cli): the reserved-reference refusal takes its form list from the shared module The help briefs and the db sign / db update refusal now read one list. The module moves to src/utils so control-api operations can import it. Signed-off-by: willbot Signed-off-by: Will Madden --- .../operations/contract-snapshot-resolution.ts | 9 ++++----- packages/1-framework/3-tooling/cli/src/orm/db/sign.ts | 2 +- packages/1-framework/3-tooling/cli/src/orm/db/update.ts | 2 +- packages/1-framework/3-tooling/cli/src/orm/migrate.ts | 2 +- .../1-framework/3-tooling/cli/src/orm/migration/plan.ts | 2 +- .../3-tooling/cli/src/orm/migration/status.ts | 2 +- .../cli/src/{orm => utils}/contract-ref-forms.ts | 0 .../1-framework/3-tooling/cli/test/orm/db-sign.test.ts | 2 +- 8 files changed, 10 insertions(+), 11 deletions(-) rename packages/1-framework/3-tooling/cli/src/{orm => utils}/contract-ref-forms.ts (100%) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts index bc574c7a8a1d..e7bf689430a2 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts @@ -27,6 +27,7 @@ import { errorUnexpected, mapRefResolutionError, } from '../../utils/cli-errors'; +import { ON_DISK_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { errorFromCaught } from './caught-errors'; import { buildReadAggregate } from './contract-space-aggregate-loader'; @@ -66,8 +67,6 @@ export interface ResolveContractRefToSnapshotSuccess { readonly source: 'snapshot' | 'emitted'; } -const ON_DISK_FORMS = 'hash, prefix, ref name, migration directory name, or `^`'; - function reservedRefRefusal( options: ResolveContractRefToSnapshotOptions, ): RefResolutionWrongGrammar { @@ -77,15 +76,15 @@ function reservedRefRefusal( kind: 'wrong-grammar', input, expectedGrammar: 'contract', - message: `"${input}" is a reserved reference; \`db sign\` names a contract on disk by ${ON_DISK_FORMS}`, - fix: `Pass a ${ON_DISK_FORMS}, or omit the contract to sign the emitted contract.`, + message: `"${input}" is a reserved reference; \`db sign\` names a contract on disk by ${ON_DISK_CONTRACT_REF_FORMS}`, + fix: `Name a contract on disk (${ON_DISK_CONTRACT_REF_FORMS}), or omit the contract to sign the emitted contract.`, } : { kind: 'wrong-grammar', input, expectedGrammar: 'contract', message: `"${input}" is a reserved reference; \`db update ${options.missingBundleFlag}\` names a migration destination on disk`, - fix: `Pass the ${ON_DISK_FORMS} of a migration destination, or omit ${options.missingBundleFlag} to update to the emitted contract.`, + fix: `Name a migration destination on disk (${ON_DISK_CONTRACT_REF_FORMS}), or omit ${options.missingBundleFlag} to update to the emitted contract.`, }; } diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts index d5dddb72f8dd..40eaf0a36b44 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts @@ -19,7 +19,7 @@ import { } from '../../control-api/operations/ref-advancement'; import { errorAdvanceRefArgConflict, errorContractArgConflict } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl } from '../../utils/command-helpers'; -import { ON_DISK_CONTRACT_REF_FORMS } from '../contract-ref-forms'; +import { ON_DISK_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { appRefsDirFor, baseDirFor, displayPath, migrationsDirFor } from '../migration/paths'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index b8b4b1301eca..14fed374a3bf 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -21,9 +21,9 @@ import { import type { CreateControlClient, DbUpdateResult, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; +import { ON_DISK_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { mapDbUpdateFailure } from '../../utils/db-update-failure'; import type { MigrationCommandResult } from '../../utils/formatters/migrations'; -import { ON_DISK_CONTRACT_REF_FORMS } from '../contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { baseDirFor } from '../migration/paths'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 549c23d2e824..e309c20e65ab 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -42,6 +42,7 @@ import type { } from '../control-api/types'; import { errorContractValidationFailed } from '../utils/cli-errors'; import { closeQuietly, maskConnectionUrl } from '../utils/command-helpers'; +import { ALL_CONTRACT_REF_FORMS } from '../utils/contract-ref-forms'; import { toDeclaredExtensionsFromRaw } from '../utils/extension-pack-inputs'; import { migrateShowRunListRows, @@ -53,7 +54,6 @@ import { toneDrawing } from '../utils/formatters/tone-markup'; import { mapMigrateFailure } from '../utils/migrate-failure'; import { runCommandAction } from '../utils/next-actions'; import { snapshotVerifierFor } from '../utils/snapshot-content-verification'; -import { ALL_CONTRACT_REF_FORMS } from './contract-ref-forms'; import { perSpaceBlocks } from './db/migration-blocks'; import { prepareMigrationRun } from './db/prepare'; import { defineOrmCommand } from './define-command'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 47d9fda8551d..76f85b8b05c9 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -15,9 +15,9 @@ import type { import { executeMigrationPlanCommand } from '../../control-api/operations/migration-plan'; import type { CreateControlClient, DestructivePlanOperation } from '../../control-api/types'; import { ERROR_CODE_DESTRUCTIVE_CHANGES } from '../../utils/cli-errors'; +import { ON_DISK_OR_EMPTY_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; -import { ON_DISK_OR_EMPTY_CONTRACT_REF_FORMS } from '../contract-ref-forms'; import { destructiveOperationList, errorConsentOperationsMissing } from '../db/consent'; import { defineOrmCommand } from '../define-command'; import { consentToken } from '../init-inputs'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index ccbd1d89f233..2ad566c095cb 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -47,6 +47,7 @@ import { } from '../../control-api/operations/ref-resolution'; import { readMigrationRefs } from '../../control-api/operations/refs'; import { closeQuietly, maskConnectionUrl, readContractEnvelope } from '../../utils/command-helpers'; +import { ALL_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { renderMigrationGraphLegend } from '../../utils/formatters/migration-graph-labels'; import { TONE_MIGRATION_GRAPH_PALETTE } from '../../utils/formatters/migration-graph-palette'; import { @@ -58,7 +59,6 @@ import { createToneMigrationListStyler } from '../../utils/formatters/migration- import type { MigrationListEntry } from '../../utils/formatters/migration-list-types'; import { toneDrawing } from '../../utils/formatters/tone-markup'; import type { GlyphMode } from '../../utils/glyph-mode'; -import { ALL_CONTRACT_REF_FORMS } from '../contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { normalizeError } from '../normalize-error'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/contract-ref-forms.ts b/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts similarity index 100% rename from packages/1-framework/3-tooling/cli/src/orm/contract-ref-forms.ts rename to packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts index e577eff031e6..887689916829 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts @@ -311,7 +311,7 @@ describe('db sign', () => { ok: false, error: { code: 'MIGRATION.REF_WRONG_GRAMMAR', - why: `"${input}" is a reserved reference; \`db sign\` names a contract on disk by hash, prefix, ref name, migration directory name, or \`^\``, + why: `"${input}" is a reserved reference; \`db sign\` names a contract on disk by hash, prefix, ref name, migration dir name, or ^`, meta: { input, expectedGrammar: 'contract' }, }, }); From 4b90deb3661b401246e69c88ef26738121ef1f4f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:34:18 +0200 Subject: [PATCH 09/37] test(integration): migration status warns when db update leaves the marker outside the migration graph The journey asserted the old "Up to date" headline. This pull request makes status warn MIGRATION.MARKER_NOT_IN_HISTORY and say so in the headline when the marker equals the emitted contract but no migration ends there. Signed-off-by: willbot Signed-off-by: Will Madden --- .../migration-status-diagnostics.e2e.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 47f44075781d..27e76f01f211 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 @@ -291,16 +291,15 @@ withTempDir(({ createTempDir }) => { * Scenario: the database was updated directly via `db update` instead * of through the migration system. * - * The DB marker matches the live contract after db update, but that - * hash may not be a migration-graph node. Status defaults to the live - * contract as target (same as migrate); when DB and contract align, - * the headline is up to date while MARKER_NOT_IN_HISTORY still warns. + * The DB marker matches the live contract after db update, but no + * migration ends at that hash. Status warns MARKER_NOT_IN_HISTORY and + * says so in the headline, as `db migrate` refuses in the same state. */ describe('DB updated directly — marker ahead of graph', () => { const db = useDevDatabase(); it( - 'emit → plan → apply → swap → emit → db update → up to date with divergence warn', + 'emit → plan → apply → swap → emit → db update → marker-not-in-history warning', async () => { const ctx: JourneyContext = setupJourney({ connectionString: db.connectionString, @@ -325,7 +324,9 @@ withTempDir(({ createTempDir }) => { expect(status.exitCode).toBe(0); expect(out).toContain('@contract @db (db)'); - expect(out).toContain('Up to date'); + expect(out).toContain('is not in the on-disk migration graph'); + expect(out).toContain('MIGRATION.MARKER_NOT_IN_HISTORY'); + expect(out).not.toContain('Up to date'); }, timeouts.spinUpPpgDev, ); From 19561c0032bf0ae52c74b40da3f1376173300c34 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:42:27 +0200 Subject: [PATCH 10/37] fix(cli): db migrate keeps only an unmarked space that needs no change away from the runner An app space whose marker is already at its target goes to the runner again when another space has work, as on main, so the runner still verifies the app schema. Only a space with no marker and nothing to do stays out, because the runner would write a marker the database never had. Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/control-api/operations/migrate.ts | 19 ++++++-- .../migrate-runner-schedule.test.ts | 47 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index 9abeae459786..2f9b68bc264e 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -230,10 +230,10 @@ export async function executeMigrate { + const entry = perSpacePlans.get(spaceId); + return entry !== undefined && planRequiresExecution(entry); + }); + if (!hasPendingWork) { const ordered = canonicalOrder .filter((spaceId) => perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId)) .map((spaceId) => { @@ -518,6 +524,11 @@ function buildAtHeadResolution(args: { }; } +/** Handing this plan to the runner would write a marker the database never had. */ +function leavesUnmarkedSpaceUntouched(entry: PerSpacePlan): boolean { + return !entry.plan.origin && !planRequiresExecution(entry); +} + /** * A plan needs the runner when it executes operations or advances the * space's marker (a declared-state resolution has zero operations but a diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts index 4685a2b7fc6b..eccad7308466 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-runner-schedule.test.ts @@ -101,6 +101,19 @@ function allExternalExtension(): ControlExtensionDescriptor<'sql', 'postgres'> { }; } +function markerAt(storageHash: string): ContractMarkerRecord { + return { + storageHash, + profileHash: '', + contractJson: null, + canonicalVersion: null, + updatedAt: new Date(0), + appTag: null, + meta: {}, + invariants: [], + }; +} + function fakeDriver(): ControlDriverInstance<'sql', 'postgres'> { return blindCast< ControlDriverInstance<'sql', 'postgres'>, @@ -214,4 +227,38 @@ describe('executeMigrate runner schedule', () => { expect(result.ok).toBe(true); expect(runnerCalls).toEqual([[EXTERNAL_SPACE]]); }); + + it('does not call the runner when the app space is already at its head', async () => { + const migrationsDir = await migrationsDirWithOneAppEdge(); + const { runnerCalls, migrations } = recordingMigrations(); + + const result = await executeMigrate( + migrateOptions({ + migrationsDir, + markers: new Map([['app', markerAt(APP_HEAD)]]), + migrations, + }), + ); + + expect(result.ok && result.value.summary).toBe('Already up to date'); + expect(runnerCalls).toEqual([]); + }); + + it('hands an app space already at its head to the runner beside an extension that needs work', async () => { + const migrationsDir = await migrationsDirWithOneAppEdge(); + await addAllExternalSpace(migrationsDir); + const { runnerCalls, migrations } = recordingMigrations(); + + const result = await executeMigrate( + migrateOptions({ + migrationsDir, + markers: new Map([['app', markerAt(APP_HEAD)]]), + migrations, + extensions: [allExternalExtension()], + }), + ); + + expect(result.ok).toBe(true); + expect(runnerCalls).toEqual([[EXTERNAL_SPACE, 'app']]); + }); }); From cbc1b22fe6185d3cb12f299739c9359a126fca64 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:43:35 +0200 Subject: [PATCH 11/37] refactor(cli): name the plans db migrate keeps from the runner; planRequiresExecution reads the origin through contractHashAtMarker Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/control-api/operations/migrate.ts | 51 ++++++++----------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index 2f9b68bc264e..c13bff1a23ea 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -20,7 +20,7 @@ import { requireHeadRef, resolveRecordedPath, } from '@internal/migration-tools/aggregate'; -import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import { contractHashAtMarker, EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { errorNoInvariantPath } from '@internal/migration-tools/errors'; import { findPathWithDecision } from '@internal/migration-tools/migration-graph'; @@ -155,12 +155,10 @@ export async function executeMigrate = [aggregate.app, ...aggregate.extensions]; const perSpacePlans = new Map(); - // Plans that neither execute operations nor move a marker (at-head - // empty-graph spaces, or a space with no marker whose target is the - // empty contract). Kept out of the runner schedule so we don't write - // spurious markers, but merged back into the success envelope so - // every loaded space is represented. - const atHeadResolutions = new Map(); + // An empty-graph space already at its target, or a space with no marker + // whose plan moves nothing. The runner would write a marker for each, so + // they stay out of its schedule; the success envelope still lists them. + const plansKeptFromRunner = new Map(); for (const space of allSpaces) { const isAppSpace = space.spaceId === aggregate.app.spaceId; // The aggregate passed the integrity gate, so every space's head ref @@ -180,11 +178,7 @@ export async function executeMigrate m.spaceId), aggregate.app.spaceId]; const applyOrder = canonicalOrder.filter((spaceId) => perSpacePlans.has(spaceId)); - // Short-circuit: nothing pending across any space (no runner-bound - // plans). Surfaces every loaded space — including at-head empty- - // graph extensions — in `perSpace[]` so the result reflects the - // full aggregate, not just the spaces the runner would have touched. - // A zero-op plan still counts as pending when it advances a marker - // (declared-state resolution for an all-external extension space). + // Short-circuit when no space has work. `perSpace[]` still lists every + // loaded space, including those kept from the runner. A zero-op plan + // counts as work when it advances a marker (declared-state resolution for + // an all-external extension space). const hasPendingWork = applyOrder.some((spaceId) => { const entry = perSpacePlans.get(spaceId); return entry !== undefined && planRequiresExecution(entry); }); if (!hasPendingWork) { const ordered = canonicalOrder - .filter((spaceId) => perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId)) + .filter((spaceId) => perSpacePlans.has(spaceId) || plansKeptFromRunner.has(spaceId)) .map((spaceId) => { - const entry = perSpacePlans.get(spaceId) ?? atHeadResolutions.get(spaceId); + const entry = perSpacePlans.get(spaceId) ?? plansKeptFromRunner.get(spaceId); if (entry === undefined) { throw new InternalError(`Unreachable: missing per-space plan for "${spaceId}"`); } @@ -304,17 +296,17 @@ export async function executeMigrate perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId)) + .filter((spaceId) => perSpacePlans.has(spaceId) || plansKeptFromRunner.has(spaceId)) .map((spaceId) => { if (perSpacePlans.has(spaceId)) { const fromRunner = applied.value.orderedResolutions.find((r) => r.spaceId === spaceId); if (fromRunner !== undefined) return fromRunner; } - const entry = atHeadResolutions.get(spaceId); + const entry = plansKeptFromRunner.get(spaceId); if (entry === undefined) { throw new InternalError(`Unreachable: missing per-space plan for "${spaceId}"`); } @@ -532,14 +524,11 @@ function leavesUnmarkedSpaceUntouched(entry: PerSpacePlan): boolean { /** * A plan needs the runner when it executes operations or advances the * space's marker (a declared-state resolution has zero operations but a - * destination hash the live marker doesn't carry yet). A database with no - * marker sits at the empty contract, so a zero-op plan whose destination is - * the empty contract leaves it untouched. + * destination hash the live marker doesn't carry yet). */ function planRequiresExecution(entry: PerSpacePlan): boolean { if (entry.plan.operations.length > 0) return true; - const originHash = entry.plan.origin?.storageHash ?? EMPTY_CONTRACT_HASH; - return originHash !== entry.plan.destination.storageHash; + return contractHashAtMarker(entry.plan.origin) !== entry.plan.destination.storageHash; } interface BuildSuccessArgs { From c5321ae0af142342d981d0e3f2981e69ad0384c3 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:45:17 +0200 Subject: [PATCH 12/37] refactor(migration-tools): contractHashAtMarker lives beside ContractMarkerRecordLike and is exported from the aggregate entry point Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/src/control-api/operations/migrate-show.ts | 3 ++- .../3-tooling/cli/src/control-api/operations/migrate.ts | 3 ++- packages/1-framework/3-tooling/cli/src/orm/migrate.ts | 2 +- .../3-tooling/cli/src/orm/migration/status.ts | 8 ++++---- .../3-tooling/migration/src/aggregate/marker-types.ts | 9 +++++++++ .../1-framework/3-tooling/migration/src/constants.ts | 7 ------- .../3-tooling/migration/src/exports/aggregate.ts | 2 +- .../3-tooling/migration/src/exports/constants.ts | 2 +- .../marker-types.test.ts} | 3 ++- 9 files changed, 22 insertions(+), 17 deletions(-) rename packages/1-framework/3-tooling/migration/test/{constants.test.ts => aggregate/marker-types.test.ts} (75%) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index 39049098312e..36e511564aa3 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -6,9 +6,10 @@ import type { PrismaNextConfig } from '@internal/config/config-types'; import { type AggregateContractSpace, type ContractSpaceAggregate, + contractHashAtMarker, requireHeadRef, } from '@internal/migration-tools/aggregate'; -import { contractHashAtMarker, EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import { MigrationToolsError } from '@internal/migration-tools/errors'; import type { Refs } from '@internal/migration-tools/refs'; import { readRefs } from '@internal/migration-tools/refs'; diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts index c13bff1a23ea..95b55e997212 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate.ts @@ -16,11 +16,12 @@ import { buildFabricatedMigrationEdge, type ContractMarkerRecordLike, type ContractSpaceAggregate, + contractHashAtMarker, type PerSpacePlan, requireHeadRef, resolveRecordedPath, } from '@internal/migration-tools/aggregate'; -import { contractHashAtMarker, EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; +import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import type { SnapshotContentVerifier } from '@internal/migration-tools/contract-snapshot-store'; import { errorNoInvariantPath } from '@internal/migration-tools/errors'; import { findPathWithDecision } from '@internal/migration-tools/migration-graph'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index e309c20e65ab..b4228e68e70d 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -1,7 +1,7 @@ import { ormConfigSection } from '@internal/config-loader'; import type { Contract } from '@internal/contract/types'; import { createControlStack } from '@internal/framework-components/control'; -import { contractHashAtMarker } from '@internal/migration-tools/constants'; +import { contractHashAtMarker } from '@internal/migration-tools/aggregate'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import type { RefEntry } from '@internal/migration-tools/refs'; import { blindCast, castAs } from '@internal/utils/casts'; diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 2ad566c095cb..c4af9aec9a5a 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -1,10 +1,10 @@ import { ormConfigSection } from '@internal/config-loader'; import type { LedgerEntryRecord } from '@internal/contract/types'; -import type { - AggregateContractSpace, - ContractMarkerRecordLike, +import { + type AggregateContractSpace, + type ContractMarkerRecordLike, + contractHashAtMarker, } from '@internal/migration-tools/aggregate'; -import { contractHashAtMarker } from '@internal/migration-tools/constants'; import { isGraphNode } from '@internal/migration-tools/migration-graph'; import type { ContractRef } from '@internal/migration-tools/ref-resolution'; import type { RefEntry, Refs } from '@internal/migration-tools/refs'; diff --git a/packages/1-framework/3-tooling/migration/src/aggregate/marker-types.ts b/packages/1-framework/3-tooling/migration/src/aggregate/marker-types.ts index 4016fd5ca103..6f73bacfa694 100644 --- a/packages/1-framework/3-tooling/migration/src/aggregate/marker-types.ts +++ b/packages/1-framework/3-tooling/migration/src/aggregate/marker-types.ts @@ -1,3 +1,5 @@ +import { EMPTY_CONTRACT_HASH } from '../constants'; + /** * Structural shape the aggregate planner / verifier accept for marker * rows. Mirrors `family.readAllMarkers(...)` outputs across SQL and @@ -14,3 +16,10 @@ export interface ContractMarkerRecordLike { readonly invariants: readonly string[]; readonly profileHash?: string; } + +/** The contract hash a database is at: its marker's storage hash, or the empty contract when it has no marker. */ +export function contractHashAtMarker( + marker: Pick | null | undefined, +): string { + return marker?.storageHash ?? EMPTY_CONTRACT_HASH; +} diff --git a/packages/1-framework/3-tooling/migration/src/constants.ts b/packages/1-framework/3-tooling/migration/src/constants.ts index 4724065dddc0..7fd1621d551a 100644 --- a/packages/1-framework/3-tooling/migration/src/constants.ts +++ b/packages/1-framework/3-tooling/migration/src/constants.ts @@ -3,10 +3,3 @@ * This is a human-readable marker, not a real SHA-256 hash. */ export const EMPTY_CONTRACT_HASH = 'empty' as const; - -/** The contract hash a database is at: its marker's storage hash, or the empty contract when it has no marker. */ -export function contractHashAtMarker( - marker: { readonly storageHash: string } | null | undefined, -): string { - return marker?.storageHash ?? EMPTY_CONTRACT_HASH; -} diff --git a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts index d15b1b7e9527..214c35af5783 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/aggregate.ts @@ -13,7 +13,7 @@ export { } from '../aggregate/check-integrity'; export { buildFabricatedMigrationEdge } from '../aggregate/fabricated-migration-edge'; export { type LoadAggregateInput, loadContractSpaceAggregate } from '../aggregate/loader'; -export type { ContractMarkerRecordLike } from '../aggregate/marker-types'; +export { type ContractMarkerRecordLike, contractHashAtMarker } from '../aggregate/marker-types'; export { type AggregateCurrentDBState, type AggregateMigrationEdgeRef, diff --git a/packages/1-framework/3-tooling/migration/src/exports/constants.ts b/packages/1-framework/3-tooling/migration/src/exports/constants.ts index cd4692a80cac..ec6e13ad7b4a 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/constants.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/constants.ts @@ -1 +1 @@ -export { contractHashAtMarker, EMPTY_CONTRACT_HASH } from '../constants'; +export { EMPTY_CONTRACT_HASH } from '../constants'; diff --git a/packages/1-framework/3-tooling/migration/test/constants.test.ts b/packages/1-framework/3-tooling/migration/test/aggregate/marker-types.test.ts similarity index 75% rename from packages/1-framework/3-tooling/migration/test/constants.test.ts rename to packages/1-framework/3-tooling/migration/test/aggregate/marker-types.test.ts index 826edd35ec81..23b36c89a94f 100644 --- a/packages/1-framework/3-tooling/migration/test/constants.test.ts +++ b/packages/1-framework/3-tooling/migration/test/aggregate/marker-types.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { contractHashAtMarker, EMPTY_CONTRACT_HASH } from '../src/constants'; +import { contractHashAtMarker } from '../../src/aggregate/marker-types'; +import { EMPTY_CONTRACT_HASH } from '../../src/constants'; describe('contractHashAtMarker', () => { it('is the marker storage hash when a marker exists', () => { From 1bd26f7a3258e77d2b6748cd6951d9c641e2a762 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:46:36 +0200 Subject: [PATCH 13/37] fix(cli): db migrate --to @contract behaves exactly like an omitted --to The command no longer turns @contract into a hash target. It applies contract.json directly, so a missing snapshot no longer fails the run and a contract.json that changed without changing its storage hash is no longer replaced by an older stored copy. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/orm/migrate.ts | 15 ++++++++++----- .../3-tooling/cli/test/orm/migrate.test.ts | 18 ++++++++---------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index b4228e68e70d..027305480110 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -185,24 +185,29 @@ interface RequestedTarget { readonly refName: string | undefined; } +const EMITTED_CONTRACT_TARGET: RequestedTarget = { entry: undefined, refName: undefined }; + /** * `--to` as a contract the app graph knows. A ref target keeps the invariants - * the ref declares; a bare hash, `@contract`, or `@empty` carries none. - * Omitting `--to` targets the emitted contract, which needs no resolution at - * all. `@db` is not resolved here: it needs the live marker, which is read - * only once the connection is open. + * the ref declares; a bare hash or `@empty` carries none. An omitted `--to` + * and `@contract` both target the emitted contract. `@db` is not resolved + * here: it needs the live marker, which is read only once the connection is + * open. */ function resolveRequestedTarget( to: string | undefined, context: RefResolutionContext, ): Result { if (to === undefined) { - return ok({ entry: undefined, refName: undefined }); + return ok(EMITTED_CONTRACT_TARGET); } const resolved = resolveContractRef(to, context); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } + if (resolved.value.provenance.kind === 'reserved-contract') { + return ok(EMITTED_CONTRACT_TARGET); + } if (resolved.value.provenance.kind !== 'ref') { return ok({ entry: { hash: resolved.value.hash, invariants: [] }, refName: undefined }); } diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts index 72bbdc072da2..058fb6451b04 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts @@ -438,9 +438,8 @@ describe('migrate', () => { }); describe('--to', () => { - it('resolves @contract to the emitted contract and applies the pending migration', async () => { + it('treats @contract like an omitted --to and applies the emitted contract', async () => { const cwd = await buildProject(); - await writeSnapshot(cwd, C2); mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); mocks.migrate.mockResolvedValue( ok({ @@ -466,14 +465,13 @@ describe('migrate', () => { ); expect(run.exitCode).toBe(0); - expect(mocks.migrate).toHaveBeenCalledWith( - expect.objectContaining({ - refHash: C2, - contract: expect.objectContaining({ - storage: expect.objectContaining({ storageHash: C2 }), - }), - }), - ); + const migrateOptions = mocks.migrate.mock.calls[0]?.[0]; + expect(migrateOptions).toMatchObject({ + contract: JSON.parse(await readFile(join(cwd, 'contract.json'), 'utf-8')), + }); + expect(migrateOptions).not.toHaveProperty('refHash'); + expect(migrateOptions).not.toHaveProperty('refInvariants'); + expect(migrateOptions).not.toHaveProperty('refName'); expect(run.presented?.data).toMatchObject({ ok: true, migrationsApplied: 1, From 707f28321b1c41310b6ff9bf975060702b83a97d Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:48:01 +0200 Subject: [PATCH 14/37] fix(cli): db migrate --to @db reports a missing driver as a missing driver The separate connection check before prepareMigrationRun is gone. prepareMigrationRun now takes an optional retry command for its missing-connection error, and db migrate passes one that repeats --to @db. A config with a connection and no driver now fails with CONFIG.DRIVER_REQUIRED instead of asking for a --db flag the user already gave. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/orm/db/prepare.ts | 4 ++++ .../3-tooling/cli/src/orm/migrate.ts | 24 ++++++------------- .../3-tooling/cli/test/orm/migrate.test.ts | 16 +++++++++++++ 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts b/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts index ea52ba8e9b28..2dec9e5b6997 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/prepare.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import type { PrismaNextConfig } from '@internal/config/config-types'; import { castAs } from '@internal/utils/casts'; +import { ifDefined } from '@internal/utils/defined'; import type { CliStructuredError, Result } from '@prisma/cli-engine/protocol'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import { errorFromCaught } from '../../control-api/operations/caught-errors'; @@ -73,6 +74,8 @@ export async function prepareMigrationRun(inputs: { readonly db: string | undefined; readonly commandName: string; readonly createClient: CreateControlClient; + /** The command the missing-connection error suggests; defaults to `{bin} --db `. */ + readonly retryCommand?: string; }): Promise> { const { config, cwd, commandName } = inputs; const contractPath = contractPathFor(config); @@ -100,6 +103,7 @@ export async function prepareMigrationRun(inputs: { why: `Database connection is required for ${commandName} (set db.connection in prisma.config.ts, or pass --db )`, commandName, missingFlags: ['--db'], + ...ifDefined('retryCommand', inputs.retryCommand), }), ), ); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 027305480110..751459d5c1cf 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -3,6 +3,7 @@ import type { Contract } from '@internal/contract/types'; import { createControlStack } from '@internal/framework-components/control'; import { contractHashAtMarker } from '@internal/migration-tools/aggregate'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; +import { isLiveMarkerRef, LIVE_MARKER_REF } from '@internal/migration-tools/ref-resolution'; import type { RefEntry } from '@internal/migration-tools/refs'; import { blindCast, castAs } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; @@ -32,7 +33,6 @@ import { import { liveMarkerUse, type RefResolutionContext, - requireDatabaseForLiveMarkerUse, resolveContractRef, } from '../control-api/operations/ref-resolution'; import type { @@ -306,21 +306,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { ); } - const use = liveMarkerUse({ from: undefined, to: args.flags.to }); - if (use.liveTarget) { - const missingDb = requireDatabaseForLiveMarkerUse({ - use, - dbConnection: args.flags.db ?? ctx.config.db?.connection, - hasDriver: ctx.config.driver !== undefined, - commandName: 'db migrate', - from: undefined, - to: args.flags.to, - }); - if (missingDb !== null) { - return notOk(normalizeError(missingDb)); - } - } - + const liveTarget = isLiveMarkerRef(args.flags.to); const startedAt = Date.now(); const prepared = await prepareMigrationRun({ config: ctx.config, @@ -328,6 +314,10 @@ export function createMigrateCommand(createClient: CreateControlClient) { db: args.flags.db, commandName: 'db migrate', createClient, + ...ifDefined( + 'retryCommand', + liveTarget ? `{bin} db migrate --to ${LIVE_MARKER_REF} --db $DATABASE_URL` : undefined, + ), }); if (!prepared.ok) { return notOk(prepared.failure); @@ -376,7 +366,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { return notOk(normalizeError(integrityFailure)); } - const offlineTarget = use.liveTarget + const offlineTarget = liveTarget ? undefined : resolveRequestedTarget(args.flags.to, { graph: aggregate.app.graph(), diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts index 058fb6451b04..b76943d4b0f2 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts @@ -541,6 +541,22 @@ describe('migrate', () => { expect(mocks.connect).not.toHaveBeenCalled(); expect(mocks.migrate).not.toHaveBeenCalled(); }); + + it('reports a missing driver for @db as a missing driver', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd, { driver: undefined })).run( + ['db', 'migrate', '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(2); + expect(envelopeOf(run.json)).toMatchObject({ + ok: false, + error: { code: 'CONFIG.DRIVER_REQUIRED' }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + }); }); describe('a close that fails on the way out', () => { From dba928bef759afce0c858fa16b1c93e74bdca46f Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:52:07 +0200 Subject: [PATCH 15/37] fix(cli): the live-marker connection check keeps the user's --from and --to in every retry command requireDatabaseForLiveMarkerUse now takes --from and --to and works out the live-marker use itself, so a caller cannot pass a decision that disagrees with the flags. The offline alternative (--from ) is an explicit input that migration status and db migrate --show pass. The retry command repeats --from and --to in every case; before, migration status --to production without a connection suggested a retry that dropped --to production. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/migrate-show.ts | 14 ++++--- .../control-api/operations/ref-resolution.ts | 40 +++++++++---------- .../3-tooling/cli/src/orm/migration/status.ts | 9 ++--- .../cli/test/orm/migration-status.test.ts | 29 ++++++++++++++ 4 files changed, 59 insertions(+), 33 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index 36e511564aa3..dfb4d1c517b4 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -97,16 +97,18 @@ export async function executeMigrateShowPlan( const dbConnection = options.db ?? config.db?.connection; const driver = config.driver; const hasExplicitFrom = options.from !== undefined; - const use = liveMarkerUse({ from: options.from, to: options.to }); - const { liveOrigin, liveTarget } = use; + const { liveOrigin, liveTarget, needsDatabase } = liveMarkerUse({ + from: options.from, + to: options.to, + }); const missingDb = requireDatabaseForLiveMarkerUse({ - use, + from: options.from, + to: options.to, dbConnection, hasDriver: driver !== undefined, commandName: 'db migrate --show', - from: options.from, - to: options.to, + offlineRetry: true, }); if (missingDb) { return notOk(missingDb); @@ -181,7 +183,7 @@ export async function executeMigrateShowPlan( markerBySpace.set(aggregate.app.spaceId, offlineMarker); } - if (use.needsDatabase && driver !== undefined) { + if (needsDatabase && driver !== undefined) { const client = (options.createClient ?? createControlClient)({ family: config.family, target: config.target, diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts index df0836ab9ebf..800ad8d031e9 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts @@ -47,47 +47,43 @@ export interface LiveMarkerUse { } /** Where `--from`/`--to` read the live marker: an omitted or `@db` origin, and an `@db` target. */ -export function liveMarkerUse(refs: { +export function liveMarkerUse(flags: { readonly from: string | undefined; readonly to: string | undefined; }): LiveMarkerUse { - const liveOrigin = refs.from === undefined || isLiveMarkerRef(refs.from); - const liveTarget = isLiveMarkerRef(refs.to); + const liveOrigin = flags.from === undefined || isLiveMarkerRef(flags.from); + const liveTarget = isLiveMarkerRef(flags.to); return { liveOrigin, liveTarget, needsDatabase: liveOrigin || liveTarget }; } +/** The missing-connection error for a command whose `--from`/`--to` read the live marker, or `null`. */ export function requireDatabaseForLiveMarkerUse(args: { - readonly use: LiveMarkerUse; + readonly from: string | undefined; + readonly to: string | undefined; readonly dbConnection: unknown; readonly hasDriver: boolean; readonly commandName: string; - readonly from: string | undefined; - readonly to: string | undefined; + /** Offer `--from `, which runs the command without a database, as the retry. */ + readonly offlineRetry?: boolean; }): CliStructuredError | null { - if (!args.use.needsDatabase) { + if (!liveMarkerUse(args).needsDatabase) { return null; } const namesLiveMarker = isLiveMarkerRef(args.from) || isLiveMarkerRef(args.to); - if (!namesLiveMarker) { - return requireLiveDatabase({ - dbConnection: args.dbConnection, - hasDriver: args.hasDriver, - why: `${args.commandName} needs a database connection to read the live marker (or pass --from for an offline preview)`, - commandName: args.commandName, - retryCommand: `{bin} ${args.commandName} --from `, - }); - } - const retryCommand = [ - `{bin} ${args.commandName}`, + const suggestsOffline = args.offlineRetry === true && !namesLiveMarker; + const retryFlags = [ ...(args.from === undefined ? [] : [`--from ${args.from}`]), + ...(suggestsOffline ? ['--from '] : []), ...(args.to === undefined ? [] : [`--to ${args.to}`]), - '--db $DATABASE_URL', - ].join(' '); + ...(suggestsOffline ? [] : ['--db $DATABASE_URL']), + ]; return requireLiveDatabase({ dbConnection: args.dbConnection, hasDriver: args.hasDriver, - why: `${LIVE_MARKER_REF} resolves to the live database marker and requires a --db connection`, + why: namesLiveMarker + ? `${LIVE_MARKER_REF} resolves to the live database marker and requires a --db connection` + : `${args.commandName} needs a database connection to read the live marker${suggestsOffline ? ' (or pass --from to run offline)' : ''}`, commandName: args.commandName, - retryCommand, + retryCommand: [`{bin} ${args.commandName}`, ...retryFlags].join(' '), }); } diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index c4af9aec9a5a..9431446dc32f 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -251,15 +251,14 @@ export const migrationStatusCommand = defineOrmCommand({ const dbConnection = args.flags.db ?? ctx.config.db?.connection; const hasDriver = ctx.config.driver !== undefined; const { from, to } = args.flags; - const use = liveMarkerUse({ from, to }); - const { liveOrigin, liveTarget, needsDatabase } = use; + const { liveOrigin, liveTarget, needsDatabase } = liveMarkerUse({ from, to }); const missingDb = requireDatabaseForLiveMarkerUse({ - use, + from, + to, dbConnection, hasDriver, commandName: 'migration status', - from, - to, + offlineRetry: true, }); if (missingDb !== null) { return notOk(normalizeError(missingDb)); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 3dd66ff36480..a866b0521780 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -453,6 +453,35 @@ describe('migration status', () => { }); }); + it('keeps --to in the retry command it suggests when no connection is configured', async () => { + const project = await projectWithOneMigration(); + const config = driverConfig(project); + + const run = await harness({ ...config, db: undefined }).run( + ['migration', 'status', '--to', HASH_HEAD, '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `migration status --from --to ${HASH_HEAD}`, + ), + }), + ], + }, + }, + }); + }); + it('uses the same envelope with no missing flags when only the driver is absent', async () => { const project = await projectWithOneMigration(); const config = driverConfig(project); From f12e6cdc0c8cc311ae0019144adcef982139d745 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:55:22 +0200 Subject: [PATCH 16/37] fix(cli): db migrate --show refuses a marker outside the graph and labels the database marker whenever it reads it Whenever the preview reads the database, it now refuses an app marker that is not in the migration graph with MIGRATION.MARKER_MISMATCH, as db migrate does. Before, --to @db printed "nothing to run" in that state and plain --show failed with a path error. When the preview reads the database only to resolve --to @db (for example --from @empty --to @db), the tree now labels the database marker @db, as --from @db already did. The planning origin is unchanged. The result field usedLiveMarker is renamed databaseMarkersRead, which is what it now means. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/migrate-show.ts | 28 +++++++++---- .../utils/formatters/migrate-show-render.ts | 4 +- .../control-api/migrate-show-plan.test.ts | 2 +- .../cli/test/orm/migrate-show.test.ts | 42 +++++++++++++++++++ 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index dfb4d1c517b4..4c8097ca6518 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -24,6 +24,7 @@ import { createControlClient } from '../client'; import type { CreateControlClient } from '../types'; import { errorFromCaught } from './caught-errors'; import { buildReadAggregate } from './contract-space-aggregate-loader'; +import { refuseMarkerOutsideGraph } from './graph-queries'; import { planSpacePath } from './migrate'; import { liveMarkerUse, @@ -68,10 +69,10 @@ export interface MigrateShowPlanSuccess { readonly contractHash: string; readonly migrations: readonly MigrateShowMigration[]; readonly summary: string; - /** Per-space render hash: live/override marker storageHash, pre-defaulted to the empty sentinel. */ + /** Each space's database marker hash for the tree's `@db` label; the empty sentinel when the space has no marker or the database was not read. */ readonly renderMarkerHashBySpace: ReadonlyMap; - /** True when the live DB marker was read — gates the ★ db marker in the tree. */ - readonly usedLiveMarker: boolean; + /** True when the preview read the database markers; only then does the tree mark `@db`. */ + readonly databaseMarkersRead: boolean; } /** @@ -166,6 +167,7 @@ export async function executeMigrateShowPlan( // marker would produce a different `required` set and a different (incorrect) path. type LiveMarker = { readonly storageHash: string; readonly invariants: readonly string[] }; const markerBySpace = new Map(); + let databaseMarkers: ReadonlyMap | undefined; const allSpaces: ReadonlyArray = [aggregate.app, ...aggregate.extensions]; if (options.from !== undefined && !liveOrigin) { @@ -194,6 +196,17 @@ export async function executeMigrateShowPlan( try { await client.connect(dbConnection); const allMarkers = await client.readAllMarkers(); + databaseMarkers = allMarkers; + const appMarker = allMarkers.get(aggregate.app.spaceId); + if (appMarker !== undefined) { + const refusal = refuseMarkerOutsideGraph({ + markerHash: appMarker.storageHash, + graph: appGraph, + }); + if (refusal) { + return notOk(refusal); + } + } // Store the full marker record (storageHash + invariants) per space. // This is the same data executeMigrate uses via familyInstance.readAllMarkers(). if (liveOrigin) { @@ -203,7 +216,7 @@ export async function executeMigrateShowPlan( } } if (liveTarget) { - targetHash = contractHashAtMarker(allMarkers.get(aggregate.app.spaceId)); + targetHash = contractHashAtMarker(appMarker); } } catch (error) { return notOk( @@ -300,10 +313,7 @@ export async function executeMigrateShowPlan( : `${count} migration${count === 1 ? '' : 's'} will run`; const renderMarkerHashBySpace = new Map( - allSpaces.map((s) => [ - s.spaceId, - markerBySpace.get(s.spaceId)?.storageHash ?? EMPTY_CONTRACT_HASH, - ]), + allSpaces.map((s) => [s.spaceId, contractHashAtMarker(databaseMarkers?.get(s.spaceId))]), ); return ok({ @@ -312,6 +322,6 @@ export async function executeMigrateShowPlan( migrations: orderedMigrations, summary, renderMarkerHashBySpace, - usedLiveMarker: liveOrigin, + databaseMarkersRead: databaseMarkers !== undefined, }); } diff --git a/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts b/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts index 7ac7971d7aea..71609a458f44 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts @@ -107,7 +107,9 @@ export function renderMigrateShowGraph( rowModel, contractHash, isAppSpace: isApp, - ...(plan.usedLiveMarker && liveMarkerHash !== undefined ? { dbHash: liveMarkerHash } : {}), + ...(plan.databaseMarkersRead && liveMarkerHash !== undefined + ? { dbHash: liveMarkerHash } + : {}), refsByHash: listRefsByContractHash(space), edgeAnnotationsByHash: edgeAnnotations, colorize: options.colorize, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts index 18e7fb9b2329..ec20ed971b16 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts @@ -136,7 +136,7 @@ describe('executeMigrateShowPlan', () => { }, ]); expect(result.value.summary).toBe('1 migration will run'); - expect(result.value.usedLiveMarker).toBe(false); + expect(result.value.databaseMarkersRead).toBe(false); expect(result.value.contractHash).toBe(HASH_B); } expect(mocks.createControlClient).not.toHaveBeenCalled(); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 0c4efd090786..664f4ab6370f 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -34,6 +34,7 @@ const EMPTY = 'empty'; const C1 = '1'.repeat(64); const C2 = '2'.repeat(64); const EXT_C1 = 'e'.repeat(64); +const UNKNOWN = 'd'.repeat(64); const TARGET = 'mock'; const FAMILY = 'mock'; @@ -353,6 +354,47 @@ describe('migrate --show', () => { expect(run.presented?.data).toMatchObject({ ok: true, migrations: [] }); }); + it('labels the target @db for --from @empty --to @db', async () => { + const cwd = await buildProject(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C1, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--from', '@empty', '--to', '@db'], + { cwd, isTty: { stdout: true } }, + ); + const dbLines = drawingLines(run.presented?.presentation.human ?? []).filter((line) => + line.includes('@db'), + ); + + expect(run.exitCode).toBe(0); + expect(dbLines).toHaveLength(1); + expect(dbLines[0]).toContain(C1.slice(0, 7)); + }); + + it.each([ + { argv: [] }, + { argv: ['--to', '@db'] }, + { argv: ['--from', '@empty', '--to', '@db'] }, + ])('refuses a marker outside the migration graph: $argv', async ({ argv }) => { + const cwd = await buildProject(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: UNKNOWN, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', ...argv, '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.MARKER_MISMATCH' } }, + }); + }); + it.each([ { argv: ['--from', '@db'], named: true }, { argv: ['--from', EMPTY, '--to', '@db'], named: true }, From aa7be28a55ab39a2193d70b4e3ff9bacea3ad08b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 08:57:21 +0200 Subject: [PATCH 17/37] fix(cli): migration status checks the path from an offline --from and checks a marker read for --to @db The no-path check now runs when the app origin comes from --from, not only from the database, so --from @contract --to @db on a database behind head no longer reports "Up to date". The summary names the origin it started from: the database state, or the --from contract. When --to @db reads the app marker and that marker is not in the app graph, status now warns MIGRATION.MARKER_NOT_IN_HISTORY, as it does when the origin is the database. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/orm/migration/status.ts | 63 ++++++++++++------- .../cli/test/orm/migration-status.test.ts | 36 +++++++++++ .../cli/test/orm/status-summary.test.ts | 21 +++++-- 3 files changed, 94 insertions(+), 26 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 9431446dc32f..a28491f288c3 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -118,16 +118,27 @@ async function readDatabaseState(inputs: { } } +/** Where a status path starts: the database marker, or the contract `--from` names. */ +export type NoPathOrigin = + | { readonly kind: 'database'; readonly markerHash: string | undefined } + | { readonly kind: 'from'; readonly hash: string }; + +function describeOrigin(origin: NoPathOrigin): string { + if (origin.kind === 'from') { + return `the --from contract (${shortDisplayHash(origin.hash)})`; + } + return origin.markerHash !== undefined + ? `the database state (${shortDisplayHash(origin.markerHash)})` + : 'the database state'; +} + export function buildNoPathSummary(args: { - readonly markerHash: string | undefined; + readonly origin: NoPathOrigin; readonly targetHash: string; readonly explicitTarget: boolean; readonly refName: string | undefined; }): string { - const markerPart = - args.markerHash !== undefined - ? `the database state (${shortDisplayHash(args.markerHash)})` - : 'the database state'; + const markerPart = describeOrigin(args.origin); const targetShort = shortDisplayHash(args.targetHash); if (!args.explicitTarget) { return `No migration path from ${markerPart} to the application's contract (${targetShort}). Run \`{bin} migration plan --name \` to author one.`; @@ -373,9 +384,7 @@ export const migrationStatusCommand = defineOrmCommand({ []; const emptySpaces: string[] = []; let divergedMarker: { readonly space: string; readonly markerHash: string } | undefined; - let noPath: - | { readonly markerHash: string | undefined; readonly targetHash: string } - | undefined; + let noPath: { readonly origin: NoPathOrigin; readonly targetHash: string } | undefined; let headlineTargetHash = activeRefHash ?? contractHash; let totalPending = 0; @@ -392,30 +401,40 @@ export const migrationStatusCommand = defineOrmCommand({ headlineTargetHash = targetHash; } + const offlineOrigin = isAppSpace ? fromOverrideHash : undefined; const marker = liveOrigin ? database.markersBySpace.get(entry.space) - : isAppSpace && fromOverrideHash !== undefined - ? { storageHash: fromOverrideHash } + : offlineOrigin !== undefined + ? { storageHash: offlineOrigin } : undefined; const markerHash = marker?.storageHash; const originHash = contractHashAtMarker(marker); - const markerInGraph = - markerHash === undefined || - isGraphNode(markerHash, graph) || - (graph.nodes.size === 0 && markerHash === space.headRef?.hash); + const readMarker = + liveOrigin || (isAppSpace && liveTarget) + ? database.markersBySpace.get(entry.space) + : undefined; + const markerDiverged = + readMarker !== undefined && + !isGraphNode(readMarker.storageHash, graph) && + !(graph.nodes.size === 0 && readMarker.storageHash === space.headRef?.hash); + if (markerDiverged) { + divergedMarker ??= { space: entry.space, markerHash: readMarker.storageHash }; + findings.push(markerNotInHistoryFinding(entry.space)); + } + const origin: NoPathOrigin | undefined = liveOrigin + ? { kind: 'database', markerHash } + : offlineOrigin !== undefined + ? { kind: 'from', hash: offlineOrigin } + : undefined; if ( - liveOrigin && - markerInGraph && + origin !== undefined && + !markerDiverged && originHash !== targetHash && noPath === undefined && !hasMigrationPath(graph, originHash, targetHash) ) { - noPath = { markerHash, targetHash }; - } - if (liveOrigin && markerHash !== undefined && !markerInGraph) { - divergedMarker ??= { space: entry.space, markerHash }; - findings.push(markerNotInHistoryFinding(entry.space)); + noPath = { origin, targetHash }; } const ledger = database.ledgersBySpace.get(entry.space) ?? []; @@ -499,7 +518,7 @@ export const migrationStatusCommand = defineOrmCommand({ ? 'No migrations found' : noPath !== undefined ? buildNoPathSummary({ - markerHash: noPath.markerHash, + origin: noPath.origin, targetHash: noPath.targetHash, explicitTarget: args.flags.to !== undefined, refName: activeRefName, diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index a866b0521780..5fa8799aee92 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -701,6 +701,42 @@ describe('migration status', () => { ); }); + it('reports no path when --from @contract is ahead of the database named by --to @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@contract', '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: `No migration path from the --from contract (${HASH_HEAD.slice(0, 12)}) to the target (${HASH_BASE.slice(0, 12)}). Run \`{bin} migration plan --name \` to author one, or pass \`--to \` to pick a reachable target.`, + }); + }); + + it('warns when --to @db reads a marker outside the graph and --from is a hash', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ markers: markersAt(HASH_UNKNOWN) }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ + { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, + ]); + expect(run.presented?.data).toMatchObject({ + summary: `Database marker ${HASH_UNKNOWN.slice(0, 12)} is not in the on-disk migration graph`, + }); + }); + it('errors with the connection-required envelope for --to @db without a connection', async () => { const project = await projectWithOneMigration(); const config = driverConfig(project); diff --git a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts index 6fd102a8e1db..3de2927ea80e 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts @@ -5,7 +5,7 @@ describe('buildNoPathSummary', () => { it('names the live contract when no --to was passed', () => { expect( buildNoPathSummary({ - markerHash: 'a'.repeat(64), + origin: { kind: 'database', markerHash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), explicitTarget: false, refName: undefined, @@ -18,7 +18,7 @@ describe('buildNoPathSummary', () => { it('names the ref when --to resolved via ref', () => { expect( buildNoPathSummary({ - markerHash: 'a'.repeat(64), + origin: { kind: 'database', markerHash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), explicitTarget: true, refName: 'prod', @@ -31,7 +31,7 @@ describe('buildNoPathSummary', () => { it('omits via ref when --to was a raw hash', () => { expect( buildNoPathSummary({ - markerHash: 'a'.repeat(64), + origin: { kind: 'database', markerHash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), explicitTarget: true, refName: undefined, @@ -44,7 +44,7 @@ describe('buildNoPathSummary', () => { it('omits the marker parenthetical when the marker hash is unknown', () => { expect( buildNoPathSummary({ - markerHash: undefined, + origin: { kind: 'database', markerHash: undefined }, targetHash: 'b'.repeat(64), explicitTarget: false, refName: undefined, @@ -53,6 +53,19 @@ describe('buildNoPathSummary', () => { "No migration path from the database state to the application's contract (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one.", ); }); + + it('names the --from contract when the origin is offline', () => { + expect( + buildNoPathSummary({ + origin: { kind: 'from', hash: 'a'.repeat(64) }, + targetHash: 'b'.repeat(64), + explicitTarget: true, + refName: undefined, + }), + ).toBe( + 'No migration path from the --from contract (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', + ); + }); }); describe('buildStatusHeadline', () => { From 6e70dbb02610c3083d2f7b5133bd781f029f5ed2 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:00:39 +0200 Subject: [PATCH 18/37] fix(migration-tools): one predicate decides whether a marker is in a space's history; status uses it and warns for an app space with no migrations isInSpaceHistory counts a graph node, or the head of an extension space that ships no migrations. The empty-graph exception now covers extension spaces only, so migration status warns MIGRATION.MARKER_NOT_IN_HISTORY for an app marker when the app space has no migrations, matching db migrate, which refuses in that state. The db sign integration test now expects that warning after signing a project with no migrations. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/orm/migration/status.ts | 9 ++++--- .../cli/test/orm/migration-status.test.ts | 14 +++++++++++ .../migration/src/exports/migration-graph.ts | 2 +- .../migration/src/graph-membership.ts | 15 ++++++++++++ .../migration/test/graph-membership.test.ts | 24 ++++++++++++++++++- .../cli.db-sign-ref-advancement.e2e.test.ts | 6 +++-- 6 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index a28491f288c3..1f3db097ac2f 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -5,7 +5,7 @@ import { type ContractMarkerRecordLike, contractHashAtMarker, } from '@internal/migration-tools/aggregate'; -import { isGraphNode } from '@internal/migration-tools/migration-graph'; +import { isInSpaceHistory } from '@internal/migration-tools/migration-graph'; import type { ContractRef } from '@internal/migration-tools/ref-resolution'; import type { RefEntry, Refs } from '@internal/migration-tools/refs'; import { ifDefined } from '@internal/utils/defined'; @@ -415,8 +415,11 @@ export const migrationStatusCommand = defineOrmCommand({ : undefined; const markerDiverged = readMarker !== undefined && - !isGraphNode(readMarker.storageHash, graph) && - !(graph.nodes.size === 0 && readMarker.storageHash === space.headRef?.hash); + !isInSpaceHistory(readMarker.storageHash, { + graph, + headHash: space.headRef?.hash, + isExtension: !isAppSpace, + }); if (markerDiverged) { divergedMarker ??= { space: entry.space, markerHash: readMarker.storageHash }; diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 5fa8799aee92..cfef4577a1c1 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -272,6 +272,20 @@ describe('migration status', () => { }); }); + it('warns about an app marker when the app space has no migrations', async () => { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const db = fakeDatabase({ markers: markersAt(HASH_HEAD) }); + + const run = await harness(driverConfig(project, db)).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ + { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, + ]); + }); + it('stays quiet about an all-external extension space whose marker is at its head', async () => { const project = await projectWithOneMigration(); await addAllExternalSpace(project); diff --git a/packages/1-framework/3-tooling/migration/src/exports/migration-graph.ts b/packages/1-framework/3-tooling/migration/src/exports/migration-graph.ts index f355de0b1aca..3255d76d9e3c 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/migration-graph.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/migration-graph.ts @@ -1,4 +1,4 @@ -export { assertHashIsGraphNode, isGraphNode } from '../graph-membership'; +export { assertHashIsGraphNode, isGraphNode, isInSpaceHistory } from '../graph-membership'; export type { PathDecision } from '../migration-graph'; export { detectCycles, diff --git a/packages/1-framework/3-tooling/migration/src/graph-membership.ts b/packages/1-framework/3-tooling/migration/src/graph-membership.ts index ed1d1b374899..23bb9b9f12e5 100644 --- a/packages/1-framework/3-tooling/migration/src/graph-membership.ts +++ b/packages/1-framework/3-tooling/migration/src/graph-membership.ts @@ -9,6 +9,21 @@ export function isGraphNode(hash: string, graph: MigrationGraph): boolean { return graph.nodes.has(hash); } +/** True when a marker hash is in a space's history: a graph node, or the head of an extension space that ships no migrations. */ +export function isInSpaceHistory( + hash: string, + space: { + readonly graph: MigrationGraph; + readonly headHash: string | undefined; + readonly isExtension: boolean; + }, +): boolean { + if (isGraphNode(hash, space.graph)) { + return true; + } + return space.isExtension && space.graph.nodes.size === 0 && hash === space.headHash; +} + export function assertHashIsGraphNode(hash: string, graph: MigrationGraph): asserts hash is string { if (isGraphNode(hash, graph)) { return; diff --git a/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts b/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts index 428884534e26..18265b04b7af 100644 --- a/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts +++ b/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { EMPTY_CONTRACT_HASH } from '../src/constants'; import { MigrationToolsError } from '../src/errors'; -import { assertHashIsGraphNode, isGraphNode } from '../src/graph-membership'; +import { assertHashIsGraphNode, isGraphNode, isInSpaceHistory } from '../src/graph-membership'; import { computeMigrationHash } from '../src/hash'; import { reconstructGraph } from '../src/migration-graph'; import type { OnDiskMigrationPackage } from '../src/package'; @@ -55,6 +55,28 @@ describe('isGraphNode', () => { }); }); +describe('isInSpaceHistory', () => { + it('counts a node of the space graph', () => { + const graph = reconstructGraph(chain([E, 'aaa', 'm1'])); + expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa', isExtension: false })).toBe(true); + }); + + it('counts the head of an extension space that ships no migrations', () => { + const graph = reconstructGraph([]); + expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa', isExtension: true })).toBe(true); + }); + + it('does not count the head of an app space that has no migrations', () => { + const graph = reconstructGraph([]); + expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa', isExtension: false })).toBe(false); + }); + + it('does not count a head that is not a node when the extension space has migrations', () => { + const graph = reconstructGraph(chain([E, 'aaa', 'm1'])); + expect(isInSpaceHistory('bbb', { graph, headHash: 'bbb', isExtension: true })).toBe(false); + }); +}); + describe('assertHashIsGraphNode', () => { it('is a no-op for a graph-node hash', () => { const graph = reconstructGraph(chain([E, 'aaa', 'm1'])); diff --git a/test/integration/test/cli.db-sign-ref-advancement.e2e.test.ts b/test/integration/test/cli.db-sign-ref-advancement.e2e.test.ts index 280ddf513a39..ef71302d2911 100644 --- a/test/integration/test/cli.db-sign-ref-advancement.e2e.test.ts +++ b/test/integration/test/cli.db-sign-ref-advancement.e2e.test.ts @@ -144,7 +144,7 @@ withTempDir(({ createTempDir }) => { ); it( - 'migration status reports up to date after signing', + 'migration status warns after signing a project with no migrations, as db migrate refuses', async () => { await withDevDatabase(async ({ connectionString }) => { const ctx = await setupInferredProject(connectionString, createTempDir); @@ -161,7 +161,9 @@ withTempDir(({ createTempDir }) => { targetContract: signedHash, migrations: [], }); - expect(statusJson.diagnostics ?? []).toEqual([]); + expect(statusJson.diagnostics?.map((diagnostic) => diagnostic.code)).toEqual([ + 'MIGRATION.MARKER_NOT_IN_HISTORY', + ]); }); }, timeouts.spinUpPpgDev, From 307fd9b9273d27253830b94f3b668fa92436995a Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:02:38 +0200 Subject: [PATCH 19/37] fix(cli): db update resolves --to before it checks for a connection Whether --to names an accepted contract depends on the command line and the migrations directory alone, so db update now resolves it before prepareMigrationRun. db update --to @db without a connection now fails with MIGRATION.REF_WRONG_GRAMMAR instead of first asking for --db. The resolver takes the emitted contract path only in the db sign branch, the only one that reads it. Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-snapshot-resolution.ts | 9 ++-- .../3-tooling/cli/src/orm/db/update.ts | 42 ++++++++++--------- .../contract-snapshot-resolution.test.ts | 2 - .../test/orm/db-update-to-resolution.test.ts | 16 +++++++ 4 files changed, 44 insertions(+), 25 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts index e7bf689430a2..6e49c4e9e8ff 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts @@ -41,8 +41,6 @@ interface ResolveContractRefToSnapshotBaseOptions { readonly migrationsDir: string; /** User-supplied contract reference (hash, prefix, ref name, migration dir name, or ^). */ readonly refInput: string; - /** Absolute path of the emitted contract.json (fallback source + snapshot-path derivation). */ - readonly contractPathAbsolute: string; } /** @@ -55,7 +53,12 @@ interface ResolveContractRefToSnapshotBaseOptions { */ export type ResolveContractRefToSnapshotOptions = ResolveContractRefToSnapshotBaseOptions & ( - | { readonly fallbackToEmitted: true; readonly missingBundleFlag?: never } + | { + readonly fallbackToEmitted: true; + /** Absolute path of the emitted contract.json, the fallback source. */ + readonly contractPathAbsolute: string; + readonly missingBundleFlag?: never; + } | { readonly fallbackToEmitted: false; readonly missingBundleFlag: '--to' } ); diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index 14fed374a3bf..3ccf18e85cbd 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -10,7 +10,10 @@ import { } from '@prisma/cli-engine/protocol'; import { createControlClient } from '../../control-api/client'; import { errorFromCaught } from '../../control-api/operations/caught-errors'; -import { resolveContractRefToSnapshot } from '../../control-api/operations/contract-snapshot-resolution'; +import { + type ResolveContractRefToSnapshotSuccess, + resolveContractRefToSnapshot, +} from '../../control-api/operations/contract-snapshot-resolution'; import { buildRefAdvancementFields, type ContractIR, @@ -26,7 +29,7 @@ import { mapDbUpdateFailure } from '../../utils/db-update-failure'; import type { MigrationCommandResult } from '../../utils/formatters/migrations'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; -import { baseDirFor } from '../migration/paths'; +import { baseDirFor, migrationsDirFor } from '../migration/paths'; import { normalizeError } from '../normalize-error'; import { controlProgressReporter } from '../progress'; import { @@ -149,36 +152,35 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { needs: { config: ormConfigSection }, handler: async (args, ctx) => { const startedAt = Date.now(); - const prepared = await prepareMigrationRun({ - config: ctx.config, - cwd: ctx.cwd, - db: args.flags.db, - commandName: 'db update', - createClient, - }); - if (!prepared.ok) { - return notOk(prepared.failure); - } - const { client, contractPath, dbConnection, migrationsDir, refsDir } = prepared.value; - - let contractJson = prepared.value.contractJson; - let snapshotContractPath = contractPath; + let destination: ResolveContractRefToSnapshotSuccess | undefined; if (args.flags.to !== undefined) { const resolved = await resolveContractRefToSnapshot({ config: ctx.config, - migrationsDir, + migrationsDir: migrationsDirFor(ctx.config), refInput: args.flags.to, - contractPathAbsolute: contractPath, fallbackToEmitted: false, missingBundleFlag: '--to', }); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); } - contractJson = resolved.value.contractJson; - snapshotContractPath = resolved.value.contractJsonPath; + destination = resolved.value; } + const prepared = await prepareMigrationRun({ + config: ctx.config, + cwd: ctx.cwd, + db: args.flags.db, + commandName: 'db update', + createClient, + }); + if (!prepared.ok) { + return notOk(prepared.failure); + } + const { client, contractPath, dbConnection, migrationsDir, refsDir } = prepared.value; + const contractJson = destination?.contractJson ?? prepared.value.contractJson; + const snapshotContractPath = destination?.contractJsonPath ?? contractPath; + const refName = computeRefAdvancementName({ ...ifDefined('advanceRef', args.flags.advanceRef), ...ifDefined('db', args.flags.db), diff --git a/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts index 5cc639e2fef1..556e2a03433a 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts @@ -164,7 +164,6 @@ describe('resolveContractRefToSnapshot', () => { config, migrationsDir, refInput: 'floating', - contractPathAbsolute, fallbackToEmitted: false, missingBundleFlag: '--to', }); @@ -251,7 +250,6 @@ describe('resolveContractRefToSnapshot', () => { config, migrationsDir, refInput: 'x', - contractPathAbsolute, fallbackToEmitted: false, }); expect(true).toBe(true); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts index 541a403e532a..55c936b2d864 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts @@ -165,6 +165,22 @@ describe('db update --to bundle resolution', () => { }, ); + it('refuses @db before it asks for a connection', async () => { + const { cwd } = await setupFixture(); + + const run = await createOrmTestCli({ + commands, + groups: BIN_GROUPS, + orm: { ...ormConfig(cwd), db: undefined }, + }).run(['db', 'update', '--to', '@db', '--json'], { cwd }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'MIGRATION.REF_WRONG_GRAMMAR' } }, + }); + }); + it('errors on an invalid --advance-ref name with the structured ref envelope', async () => { const { cwd, dirNext } = await setupFixture(); mocks.dbUpdate.mockResolvedValue( From a257ae3661eecedd8e017770a3becde3507e11b9 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:04:20 +0200 Subject: [PATCH 20/37] fix(cli): the reserved-reference refusal names the argument, and the form lists say the forms are recorded in the migrations directory resolveContractRefToSnapshot takes an argument label (the contract argument for db sign, --to for db update) in place of missingBundleFlag. The refusal is built from that label and from fallbackToEmitted, so it no longer names a command, for example: "@db" is a reserved reference; --to takes a migration destination recorded in the migrations directory (hash, prefix, ref name, migration dir name, or ^). The form-list constants are renamed RECORDED_CONTRACT_REF_FORMS and RECORDED_OR_EMPTY_CONTRACT_REF_FORMS: those forms name a contract recorded in the migrations directory, while @contract is also a file on disk. migration-tools exports RESERVED_CONTRACT_REFS, one ordered list of the reserved tokens; isReservedContractRef and ALL_CONTRACT_REF_FORMS are built from it. Signed-off-by: willbot Signed-off-by: Will Madden --- .../contract-snapshot-resolution.ts | 43 ++++++++----------- .../3-tooling/cli/src/orm/db/sign.ts | 5 ++- .../3-tooling/cli/src/orm/db/update.ts | 6 +-- .../3-tooling/cli/src/orm/migration/plan.ts | 4 +- .../cli/src/utils/contract-ref-forms.ts | 24 ++++------- .../contract-snapshot-resolution.test.ts | 16 +++++-- .../3-tooling/cli/test/orm/db-sign.test.ts | 2 +- .../test/orm/db-update-to-resolution.test.ts | 2 +- .../migration/src/exports/ref-resolution.ts | 1 + .../migration/src/refs/contract-ref.ts | 9 ++-- .../migration/test/refs/contract-ref.test.ts | 5 +++ 11 files changed, 59 insertions(+), 58 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts index 6e49c4e9e8ff..b8478b66e725 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/contract-snapshot-resolution.ts @@ -27,7 +27,7 @@ import { errorUnexpected, mapRefResolutionError, } from '../../utils/cli-errors'; -import { ON_DISK_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; +import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { snapshotVerifierFor } from '../../utils/snapshot-content-verification'; import { errorFromCaught } from './caught-errors'; import { buildReadAggregate } from './contract-space-aggregate-loader'; @@ -41,15 +41,14 @@ interface ResolveContractRefToSnapshotBaseOptions { readonly migrationsDir: string; /** User-supplied contract reference (hash, prefix, ref name, migration dir name, or ^). */ readonly refInput: string; + /** How errors name the argument that carried `refInput`, for example `--to`. */ + readonly argument: string; } /** - * `fallbackToEmitted` discriminates the missing-bundle behavior: - * true (db sign): fall back to the emitted contract when no bundle matches and its - * storage.storageHash matches; else the 'No contract file found for hash ""' errorRuntime. - * false (db update --to): missing bundle = the errorUnexpected 'No migration bundle found for - * "" (resolved hash: )' envelope, so `missingBundleFlag` (the flag label - * for that message) is required in this branch. + * `fallbackToEmitted` decides what happens when no migration bundle ends at the resolved hash: + * true (db sign) falls back to the emitted contract when its storage hash matches; false + * (db update --to) fails, because the argument must name a migration destination. */ export type ResolveContractRefToSnapshotOptions = ResolveContractRefToSnapshotBaseOptions & ( @@ -57,9 +56,8 @@ export type ResolveContractRefToSnapshotOptions = ResolveContractRefToSnapshotBa readonly fallbackToEmitted: true; /** Absolute path of the emitted contract.json, the fallback source. */ readonly contractPathAbsolute: string; - readonly missingBundleFlag?: never; } - | { readonly fallbackToEmitted: false; readonly missingBundleFlag: '--to' } + | { readonly fallbackToEmitted: false } ); export interface ResolveContractRefToSnapshotSuccess { @@ -73,22 +71,15 @@ export interface ResolveContractRefToSnapshotSuccess { function reservedRefRefusal( options: ResolveContractRefToSnapshotOptions, ): RefResolutionWrongGrammar { - const input = options.refInput; - return options.fallbackToEmitted - ? { - kind: 'wrong-grammar', - input, - expectedGrammar: 'contract', - message: `"${input}" is a reserved reference; \`db sign\` names a contract on disk by ${ON_DISK_CONTRACT_REF_FORMS}`, - fix: `Name a contract on disk (${ON_DISK_CONTRACT_REF_FORMS}), or omit the contract to sign the emitted contract.`, - } - : { - kind: 'wrong-grammar', - input, - expectedGrammar: 'contract', - message: `"${input}" is a reserved reference; \`db update ${options.missingBundleFlag}\` names a migration destination on disk`, - fix: `Name a migration destination on disk (${ON_DISK_CONTRACT_REF_FORMS}), or omit ${options.missingBundleFlag} to update to the emitted contract.`, - }; + const { refInput: input, argument } = options; + const accepted = `${options.fallbackToEmitted ? 'a contract' : 'a migration destination'} recorded in the migrations directory`; + return { + kind: 'wrong-grammar', + input, + expectedGrammar: 'contract', + message: `"${input}" is a reserved reference; ${argument} takes ${accepted} (${RECORDED_CONTRACT_REF_FORMS})`, + fix: `Name ${accepted}, or omit ${argument} to use the emitted contract.`, + }; } export async function resolveContractRefToSnapshot( @@ -137,7 +128,7 @@ export async function resolveContractRefToSnapshot( if (!options.fallbackToEmitted) { return notOk( errorUnexpected( - `No migration bundle found for ${options.missingBundleFlag} "${options.refInput}" (resolved hash: ${targetHash})`, + `No migration bundle found for ${options.argument} "${options.refInput}" (resolved hash: ${targetHash})`, { why: `The ref resolved successfully but no on-disk migration package has a destination (\`to\`) hash matching ${targetHash}.`, fix: 'Provide a ref or hash that corresponds to an existing migration package, or run `migration list` to see available migrations.', diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts index 40eaf0a36b44..db3d87111237 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/sign.ts @@ -19,7 +19,7 @@ import { } from '../../control-api/operations/ref-advancement'; import { errorAdvanceRefArgConflict, errorContractArgConflict } from '../../utils/cli-errors'; import { closeQuietly, maskConnectionUrl } from '../../utils/command-helpers'; -import { ON_DISK_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; +import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { dbFlag } from '../flags'; import { appRefsDirFor, baseDirFor, displayPath, migrationsDirFor } from '../migration/paths'; @@ -60,7 +60,7 @@ type SchemaVerifyDocument = VerifyDatabaseSchemaResult; */ const DEFAULT_ADVANCE_REF = 'db'; -const CONTRACT_REF_BRIEF = `Contract reference (${ON_DISK_CONTRACT_REF_FORMS})`; +const CONTRACT_REF_BRIEF = `Contract reference (${RECORDED_CONTRACT_REF_FORMS})`; interface AdvancedRef { readonly name: string; @@ -269,6 +269,7 @@ export function createDbSignCommand( config: ctx.config, migrationsDir, refInput: contractRef, + argument: 'the contract argument', contractPathAbsolute: emitted.value.path, fallbackToEmitted: true, }); diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index 3ccf18e85cbd..9e2573a282cf 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -24,7 +24,7 @@ import { import type { CreateControlClient, DbUpdateResult, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; -import { ON_DISK_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; +import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { mapDbUpdateFailure } from '../../utils/db-update-failure'; import type { MigrationCommandResult } from '../../utils/formatters/migrations'; import { defineOrmCommand } from '../define-command'; @@ -140,7 +140,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { db: dbFlag, dryRun: flag.boolean({ brief: 'Preview the planned operations without applying them' }), to: flag.string({ - brief: `Contract to update to (${ON_DISK_CONTRACT_REF_FORMS})`, + brief: `Contract to update to (${RECORDED_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), advanceRef: flag.string({ @@ -158,8 +158,8 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { config: ctx.config, migrationsDir: migrationsDirFor(ctx.config), refInput: args.flags.to, + argument: '--to', fallbackToEmitted: false, - missingBundleFlag: '--to', }); if (!resolved.ok) { return notOk(normalizeError(resolved.failure)); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 76f85b8b05c9..03bb1b00cadf 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -15,7 +15,7 @@ import type { import { executeMigrationPlanCommand } from '../../control-api/operations/migration-plan'; import type { CreateControlClient, DestructivePlanOperation } from '../../control-api/types'; import { ERROR_CODE_DESTRUCTIVE_CHANGES } from '../../utils/cli-errors'; -import { ON_DISK_OR_EMPTY_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; +import { RECORDED_OR_EMPTY_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; import { destructiveOperationList, errorConsentOperationsMissing } from '../db/consent'; @@ -281,7 +281,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { flags: { name: flag.string({ brief: 'Name slug for the migration directory', placeholder: 'slug' }), from: flag.string({ - brief: `Starting contract reference (${ON_DISK_OR_EMPTY_CONTRACT_REF_FORMS})`, + brief: `Starting contract reference (${RECORDED_OR_EMPTY_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), to: flag.string({ diff --git a/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts b/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts index c9116f02326d..006195b0bd18 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts @@ -1,28 +1,22 @@ import { EMPTY_CONTRACT_REF, - LIVE_MARKER_REF, - WORKING_CONTRACT_REF, + RESERVED_CONTRACT_REFS, } from '@internal/migration-tools/ref-resolution'; -const ON_DISK_FORMS = ['hash', 'prefix', 'ref name', 'migration dir name', '^'] as const; +const RECORDED_FORMS = ['hash', 'prefix', 'ref name', 'migration dir name', '^'] as const; function listForms(forms: readonly string[]): string { return `${forms.slice(0, -1).join(', ')}, or ${forms.at(-1)}`; } -/** The forms that name a contract on disk. */ -export const ON_DISK_CONTRACT_REF_FORMS = listForms(ON_DISK_FORMS); +/** The forms that name a contract recorded in the migrations directory. */ +export const RECORDED_CONTRACT_REF_FORMS = listForms(RECORDED_FORMS); -/** The on-disk forms plus `@empty`. */ -export const ON_DISK_OR_EMPTY_CONTRACT_REF_FORMS = listForms([ - ...ON_DISK_FORMS, +/** The recorded forms plus `@empty`. */ +export const RECORDED_OR_EMPTY_CONTRACT_REF_FORMS = listForms([ + ...RECORDED_FORMS, EMPTY_CONTRACT_REF, ]); -/** The on-disk forms plus every reserved token. */ -export const ALL_CONTRACT_REF_FORMS = listForms([ - ...ON_DISK_FORMS, - WORKING_CONTRACT_REF, - LIVE_MARKER_REF, - EMPTY_CONTRACT_REF, -]); +/** The recorded forms plus every reserved token. */ +export const ALL_CONTRACT_REF_FORMS = listForms([...RECORDED_FORMS, ...RESERVED_CONTRACT_REFS]); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts index 556e2a03433a..415c7b104702 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/contract-snapshot-resolution.test.ts @@ -99,6 +99,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: HASH_A, contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(true); @@ -120,6 +121,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(true); @@ -142,6 +144,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); @@ -164,8 +167,8 @@ describe('resolveContractRefToSnapshot', () => { config, migrationsDir, refInput: 'floating', + argument: '--to', fallbackToEmitted: false, - missingBundleFlag: '--to', }); expect(result.ok).toBe(false); if (!result.ok) { @@ -192,6 +195,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); @@ -212,6 +216,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); @@ -232,6 +237,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'floating', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); @@ -243,14 +249,15 @@ describe('resolveContractRefToSnapshot', () => { } }); - it('requires missingBundleFlag when fallbackToEmitted is false (type-level)', () => { + it('requires the emitted contract path when fallbackToEmitted is true (type-level)', () => { const build = (o: ResolveContractRefToSnapshotOptions) => o; - // @ts-expect-error missingBundleFlag is required when fallbackToEmitted is false + // @ts-expect-error contractPathAbsolute is required when fallbackToEmitted is true build({ config, migrationsDir, refInput: 'x', - fallbackToEmitted: false, + argument: 'the contract argument', + fallbackToEmitted: true, }); expect(true).toBe(true); }); @@ -262,6 +269,7 @@ describe('resolveContractRefToSnapshot', () => { migrationsDir, refInput: 'no-such-ref', contractPathAbsolute, + argument: 'the contract argument', fallbackToEmitted: true, }); expect(result.ok).toBe(false); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts index 887689916829..f9c58b07f57f 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts @@ -311,7 +311,7 @@ describe('db sign', () => { ok: false, error: { code: 'MIGRATION.REF_WRONG_GRAMMAR', - why: `"${input}" is a reserved reference; \`db sign\` names a contract on disk by hash, prefix, ref name, migration dir name, or ^`, + why: `"${input}" is a reserved reference; the contract argument takes a contract recorded in the migrations directory (hash, prefix, ref name, migration dir name, or ^)`, meta: { input, expectedGrammar: 'contract' }, }, }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts index 55c936b2d864..a33ebbdeba82 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts @@ -155,7 +155,7 @@ describe('db update --to bundle resolution', () => { ok: false, error: { code: 'MIGRATION.REF_WRONG_GRAMMAR', - why: `"${input}" is a reserved reference; \`db update --to\` names a migration destination on disk`, + why: `"${input}" is a reserved reference; --to takes a migration destination recorded in the migrations directory (hash, prefix, ref name, migration dir name, or ^)`, meta: { input, expectedGrammar: 'contract' }, }, }, diff --git a/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts b/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts index 7fecaac7e806..742fee9ce595 100644 --- a/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts +++ b/packages/1-framework/3-tooling/migration/src/exports/ref-resolution.ts @@ -4,6 +4,7 @@ export { isReservedContractRef, LIVE_MARKER_REF, parseContractRef, + RESERVED_CONTRACT_REFS, WORKING_CONTRACT_REF, } from '../refs/contract-ref'; export { parseMigrationRef } from '../refs/migration-ref'; diff --git a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts index 3eb7e41434fd..44ec91be7bc8 100644 --- a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts +++ b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts @@ -14,15 +14,16 @@ export const WORKING_CONTRACT_REF = '@contract'; export const LIVE_MARKER_REF = '@db'; export const EMPTY_CONTRACT_REF = '@empty'; -const RESERVED_CONTRACT_REFS: ReadonlySet = new Set([ +/** The reserved tokens, in the order help text lists them. */ +export const RESERVED_CONTRACT_REFS: readonly string[] = [ WORKING_CONTRACT_REF, LIVE_MARKER_REF, EMPTY_CONTRACT_REF, -]); +]; -/** True for `@contract`, `@db`, and `@empty`, which name a state rather than a contract on disk. */ +/** True for a reserved token, which resolves from contract.json, the database, or the empty contract rather than from a contract recorded in the migrations directory. */ export function isReservedContractRef(input: string): boolean { - return RESERVED_CONTRACT_REFS.has(input); + return RESERVED_CONTRACT_REFS.includes(input); } /** True for `@db`, the only reserved token that needs a database read to resolve. */ diff --git a/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts b/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts index 959ecff7df8d..4ee2c9a4fba7 100644 --- a/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts +++ b/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts @@ -7,6 +7,7 @@ import { isLiveMarkerRef, isReservedContractRef, parseContractRef, + RESERVED_CONTRACT_REFS, } from '../../src/refs/contract-ref'; import type { RefResolutionContext, RefResolutionError } from '../../src/refs/types'; @@ -322,6 +323,10 @@ describe('parseContractRef', () => { }); describe('reserved contract references', () => { + it('lists the reserved tokens in help order', () => { + expect(RESERVED_CONTRACT_REFS).toEqual(['@contract', '@db', '@empty']); + }); + it.each(['@contract', '@db', '@empty'])('%s is reserved', (input) => { expect(isReservedContractRef(input)).toBe(true); }); From 2d18514be279f089398b129b1ac85696dc666916 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:04:53 +0200 Subject: [PATCH 21/37] fix(cli): migration plan --to and migration ref set take their form lists from the shared module migration plan --to no longer says "same grammar as --from": --from accepts @empty and --to refuses it. Both briefs now list the forms recorded in the migrations directory, and so does the README entry for migration plan --to. Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli/README.md | 2 +- .../1-framework/3-tooling/cli/src/orm/migration/plan.ts | 8 +++++--- packages/1-framework/3-tooling/cli/src/orm/ref/set.ts | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 310dbbcff129..942a33c77b53 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1091,7 +1091,7 @@ prisma migration plan [--config ] [--name ] [--from ] [--t - `--config `: Path to `prisma.config.ts` - `--name `: Name slug for the migration directory (default: `migration`) - `--from `: Starting contract reference (hash, prefix, ref name, migration directory, `^`, or `@empty`). `@empty` names the empty-database origin deliberately. Defaults to the `db` ref; when the ref is absent, greenfield only on an empty graph — over existing migrations the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` is passed. -- `--to `: Destination contract reference (same grammar as `--from`). Defaults to the emitted `contract.json`. Use `--to ^` to plan a rollback toward a predecessor state. +- `--to `: Destination contract reference (hash, prefix, ref name, migration directory, or `^`). Defaults to the emitted `contract.json`. Use `--to ^` to plan a rollback toward a predecessor state. - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output (debug info, timings) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts index 03bb1b00cadf..ec3032f7caa7 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/plan.ts @@ -15,7 +15,10 @@ import type { import { executeMigrationPlanCommand } from '../../control-api/operations/migration-plan'; import type { CreateControlClient, DestructivePlanOperation } from '../../control-api/types'; import { ERROR_CODE_DESTRUCTIVE_CHANGES } from '../../utils/cli-errors'; -import { RECORDED_OR_EMPTY_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; +import { + RECORDED_CONTRACT_REF_FORMS, + RECORDED_OR_EMPTY_CONTRACT_REF_FORMS, +} from '../../utils/contract-ref-forms'; import { previewBlockHeader } from '../../utils/formatters/migrations'; import { runCommandAction } from '../../utils/next-actions'; import { destructiveOperationList, errorConsentOperationsMissing } from '../db/consent'; @@ -285,8 +288,7 @@ export function createMigrationPlanCommand(createClient: CreateControlClient) { placeholder: 'contract', }), to: flag.string({ - brief: - 'Destination contract reference; defaults to the emitted contract. Same grammar as --from', + brief: `Destination contract reference (${RECORDED_CONTRACT_REF_FORMS}); defaults to the emitted contract`, placeholder: 'contract', }), }, diff --git a/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts b/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts index d5974f948caa..0e607c16d1a5 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/ref/set.ts @@ -4,6 +4,7 @@ import { positional } from '@prisma/cli-engine'; import { notOk, ok } from '@prisma/cli-engine/protocol'; import type { RefSetResult } from '../../control-api/operations/ref'; import { executeRefSetCommand } from '../../control-api/operations/ref'; +import { RECORDED_CONTRACT_REF_FORMS } from '../../utils/contract-ref-forms'; import { defineOrmCommand } from '../define-command'; import { normalizeError } from '../normalize-error'; @@ -50,7 +51,7 @@ export function createRefSetCommand(execute: typeof executeRefSetCommand = execu placeholder: 'name', }), contract: positional.string({ - brief: 'Contract reference: hash, prefix, ref name, migration dir name, or ^', + brief: `Contract reference (${RECORDED_CONTRACT_REF_FORMS})`, placeholder: 'contract', }), }, From 6ffd27f7f5c91fd9ac111509b3f235e163a73260 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:06:39 +0200 Subject: [PATCH 22/37] docs: one contract-reference grammar in the migration subsystem doc; no filesystem path form; migration status flags in the CLI README The migration subsystem doc gains a contract-reference grammar section: the five forms recorded in the migrations directory, the three reserved tokens and what each resolves from, which arguments accept which token, and that --from and --to apply to the app space. Its other mentions link there. The filesystem path form and the ./ advice are gone from that doc and the migration domain README, and the line that called migration status --from offline now says @db reads the database. The error reference names migration plan --to @empty as a source of MIGRATION.REF_WRONG_GRAMMAR. The CLI README section for migration status documents --to and --from in place of a --ref flag the command does not have. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 23 ++++++++++---- docs/design/10-domains/migration/README.md | 4 +-- docs/reference/error-reference.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 30 +++++++++---------- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 89e5ae39dae4..e63f444593ff 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -146,13 +146,13 @@ Additive structure is covered by core operations: create table, add nullable col **Default `from` resolution** ([`resolveFromForPlan`](../../../packages/1-framework/3-tooling/cli/src/control-api/operations/plan-resolution.ts)): -1. Explicit `--from ` — ref name, full hash, prefix, migration directory, `^`, or filesystem path. +1. Explicit `--from ` — any [contract reference](#contract-reference-grammar) that names a recorded contract, or `@empty`. 2. No `--from` — resolve the `db` ref via `migrations/app/refs/db.json`. 3. No `db` ref — resolve `from` to the `null` empty-graph sentinel (greenfield). When the graph is also empty, the human output adds a muted notice (`No db ref set — planning from an empty database. Run db init, db update, or db sign if a database already exists.`) and the JSON document carries `fromDefaulted: true`; an explicit `--from @empty` prints neither. The from-contract always materialises by reading the content-addressed snapshot store entry for the resolved hash — the ref-resolved hash comes from the ref's pointer, the hash-resolved `from` on a graph node from the matching bundle; either way the store is keyed by hash, so no bundle lookup is needed. -**Default `to` resolution:** when `--to` is omitted, the destination is the emitted `contract.json`. When `--to ` is supplied, the same [contract-reference grammar](#refs-environment-targets) as `--from` applies (hash / prefix, ref name, migration directory, `^`, or filesystem path); the resolved contract becomes the planner destination and is written into the snapshot store keyed by its storage hash. Use `--to ^` to plan a reverse (rollback) edge toward a predecessor state. +**Default `to` resolution:** when `--to` is omitted, the destination is the emitted `contract.json`. When `--to ` is supplied, it takes the [contract references](#contract-reference-grammar) that name a recorded contract (`@empty` is an origin only); the resolved contract becomes the planner destination and is written into the snapshot store keyed by its storage hash. Use `--to ^` to plan a reverse (rollback) edge toward a predecessor state. **Emission cases:** @@ -329,6 +329,19 @@ Migrations form a directed graph (not necessarily acyclic) via their `from` / `t Refs map logical environment names to contract hashes in `migrations//refs/.json` (e.g., `{ "hash": "...", "invariants": [] }`). They are version-controlled alongside migration artifacts. `db migrate --to production` uses the ref hash as the target instead of the current contract. `migration status --to staging` reports state relative to that ref. Refs are managed via `prisma migration ref set `, `prisma migration ref list`, and `prisma migration ref delete `. See [ADR 169 — On-disk migration persistence](../adrs/ADR%20169%20-%20On-disk%20migration%20persistence.md). +#### Contract-reference grammar + +A contract reference names one contract. Five forms name a contract recorded in the migrations directory: a full storage hash, a unique hash prefix, a ref name, a migration directory name (the migration's `to` contract), and `^` (the migration's `from` contract). Three reserved tokens resolve without the migrations directory: `@contract` is the hash of the emitted `contract.json`, `@db` is the database marker (so it needs a connection), and `@empty` is the empty contract. When an input matches more than one form, the command refuses it as ambiguous; a longer or different form, such as a full hash, resolves it. + +| Argument | `@contract` | `@db` | `@empty` | +|---|---|---|---| +| `migration status --from`, `--to` | yes | yes | yes | +| `db migrate --to`, `db migrate --show --from`, `--to` | yes | yes | yes | +| `migration plan --from` | no | no | yes | +| `migration plan --to`, `migration ref set`, `db update --to`, `db sign` | no | no | no | + +`db update --to` and `db sign` refuse the reserved tokens with `MIGRATION.REF_WRONG_GRAMMAR`, as `migration plan --to` refuses `@empty`. In `migration status`, `db migrate` and `db migrate --show`, `--from` and `--to` apply to the app space; each extension space goes from its own marker to its own head. + #### Contract resolution through the snapshot store A ref is only its pointer file — `{ hash, invariants }`. It carries no contract copy of its own; the contract it names resolves through the shared content-addressed store at `migrations/snapshots//contract.{json,d.ts}` by that hash, the same store every graph node resolves through. See [ADR 218 — Refs with paired contract snapshots and universal graph-node invariant](../adrs/ADR%20218%20-%20Refs%20with%20paired%20contract%20snapshots%20and%20universal%20graph-node%20invariant.md) (its paired-snapshot part is superseded — see the ADR's Status note) and [ADR 240 — Contract snapshots live in a content-addressed store](../adrs/ADR%20240%20-%20Contract%20snapshots%20live%20in%20a%20content-addressed%20store.md). @@ -537,7 +550,7 @@ The remedy in every case is the same: edit the slots in `migration.ts`, run the Top-level verbs: -- `prisma db migrate --db [--to ] [--advance-ref ]` — execute pending migrations against a live database. Ref advancement is **opt-in only** via `--advance-ref`; plain `db migrate` does not advance any ref. The `` argument accepts the full [contract-reference grammar](#refs-environment-targets): hash / prefix, ref name, migration directory name, `^`, or filesystem path. +- `prisma db migrate --db [--to ] [--advance-ref ]` — execute pending migrations against a live database. Ref advancement is **opt-in only** via `--advance-ref`; plain `db migrate` does not advance any ref. The `` argument accepts every [contract reference](#contract-reference-grammar), including `@contract`, `@db` and `@empty`. - `prisma db init --db [--advance-ref ]` — bootstrap a database under contract control. When run against the default `--db` URL (no explicit `--db`), implicitly advances the `db` ref (write-if-absenting its contract into the snapshot store, then writing the pointer). With any explicit `--db` (even one naming the default URL), ref advancement is suppressed unless `--advance-ref` is explicit. - `prisma db update --db [--to ] [--advance-ref ]` — reconcile a live database to the named (or emitted) contract via live introspection. Same implicit `db` ref default and `--db` opt-out as `db init`. Off-graph; dev-only. - `prisma db sign --db [] [--advance-ref ] [--no-advance-ref]` (or `--contract `) — sign the marker with a contract the live DB already satisfies. With no argument, signs with the emitted `contract.json`. After a successful signature, writes the signed contract into the snapshot store and advances the `db` ref (or `--advance-ref `) to the signed hash; an existing ref is overwritten and the previous hash is reported in the human output (the JSON `advancedRef` carries name and hash only). Unlike `db init` / `db update`, `--db` does not suppress this — sign never mutates the schema, and adoption is normally done against the real database via `--db`. `--no-advance-ref` is the opt-out: it signs without writing any ref or snapshot (JSON `advancedRef` is `null`), and combining it with `--advance-ref` is refused with `CLI.ADVANCE_REF_ARG_CONFLICT`. No migration package is written. After `--no-advance-ref` there is no `db` ref, so the next default `migration plan` starts from the empty contract (with the muted `No db ref set` notice) on an empty graph, or refuses with `MIGRATION.PLAN_ORIGIN_UNKNOWN` on a non-empty graph. @@ -546,9 +559,9 @@ Top-level verbs: Migration namespace (artifacts and graph): -- `prisma migration plan [--from ] [--to ] --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags accept the full [contract-reference grammar](#refs-environment-targets). Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. +- `prisma migration plan [--from ] [--to ] --name ` — diff contracts and write a fully attested package (`migration.ts` + `migration.json` + `ops.json`) offline. Defaults `--from` to the `db` ref and `--to` to the emitted contract; when the `db` ref is absent, planning proceeds from greenfield only on an empty graph (with a muted `No db ref set` notice and `fromDefaulted: true` in JSON) — with existing migrations on disk the command refuses (`MIGRATION.PLAN_ORIGIN_UNKNOWN`) unless `--from @empty` names the empty-database origin explicitly. Both flags take [contract references](#contract-reference-grammar); `--from` also accepts `@empty`. Re-run `./migration.ts` after filling any `placeholder(...)` slots to rewrite `ops.json` and the `migrationHash`. - `prisma migration new [--from ] --name ` — scaffold an empty `migration.ts` for hand-authoring. -- `prisma migration status [--db ] [--to ] [--from ]` — path/pending question. Live (uses marker) or offline (uses `--from`). +- `prisma migration status [--db ] [--to ] [--from ]` — path/pending question. Reads the database marker by default; `--from` names the origin instead and runs offline, unless `--from` or `--to` is `@db`, which reads the database. - `prisma migration log --db ` — applied execution history (reads the marker; offline reading of the ledger is also supported). - `prisma migration list` — enumerate migrations on disk in topological order. Offline. - `prisma migration graph` — render the migration graph (ASCII tree by default; `--json` / `--dot` for other formats). Offline. diff --git a/docs/design/10-domains/migration/README.md b/docs/design/10-domains/migration/README.md index 489f3f803a54..8464d5397182 100644 --- a/docs/design/10-domains/migration/README.md +++ b/docs/design/10-domains/migration/README.md @@ -378,8 +378,8 @@ The choices below are the load-bearing ones — the ones that, if reversed, woul - **`db init` vs `db sign`.** Distinct: init lays down structure (live, mutates); sign verifies + writes marker (no structural mutation, refuses if DB doesn't already satisfy the contract). - **"Freeze"** rejected. `migration plan` is the verb for the freeze-and-promise act. - **Dev/deploy split** rejected. The safety semantics belong to the DB URL, not the verb. -- **Contract references and migration references.** Two parallel grammars sharing forms but resolving in different namespaces. `` resolves to a contract storage hash (accepts: hash, ref name, migration directory name → to-contract, `^` → from-contract, filesystem path). `` resolves to a migration (accepts: migration hash or directory name). The command's argument type determines which grammar applies — same hash-shaped input resolves in different namespaces depending on whether the command expects a `` or a ``. **In CLI argument syntax the placeholder is `` or ``** — no umbrella shorthand. A **ref** is a specific kind of contract reference (named, persisted, file-backed); the umbrella is **contract reference**. -- **Directory names are user-controlled.** The default `T_` is convention, not invariant. Ambiguity between a directory name and a hash prefix is an explicit ambiguity error with candidate listing — same Git rule for short SHAs that collide with branch names. Disambiguate with `./` for filesystem paths or with a longer / different form. +- **Contract references and migration references.** Two parallel grammars sharing forms but resolving in different namespaces. `` resolves to a contract storage hash (accepts: hash or hash prefix, ref name, migration directory name → to-contract, `^` → from-contract, and the reserved tokens `@contract`, `@db` and `@empty` where the command allows them; see the [contract-reference grammar](../../../architecture%20docs/subsystems/7.%20Migration%20System.md#contract-reference-grammar)). `` resolves to a migration (accepts: migration hash or directory name). The command's argument type determines which grammar applies — same hash-shaped input resolves in different namespaces depending on whether the command expects a `` or a ``. **In CLI argument syntax the placeholder is `` or ``** — no umbrella shorthand. A **ref** is a specific kind of contract reference (named, persisted, file-backed); the umbrella is **contract reference**. +- **Directory names are user-controlled.** The default `T_` is convention, not invariant. Ambiguity between a directory name and a hash prefix is an explicit ambiguity error with candidate listing — same Git rule for short SHAs that collide with branch names. Disambiguate with a longer or different form, such as a full hash. - **`db sign []` (positional) or `db sign --contract ` (explicit).** The argument names *the thing being signed* — neither `--to` (movement) nor `--at` (position) carries the right meaning. Defaults to the current `contract.json` when omitted. - **`ref set `** is the direct-ref-write verb. `move` was rejected because refs are stored values, not entities that traverse the graph — the spatial-movement vocabulary is reserved for `migrate`. - **`head` ref dropped.** Refs are exclusively environment-named (`production`, `staging`, ...). The emitted `contract.json` already plays the role of "what the repo is working toward"; a `head` ref would have been redundant. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 0c1858c44ed9..86c1b0d1193f 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -1602,7 +1602,7 @@ A ref name resolves to nothing: no pointer file with that name exists, and the f ### MIGRATION.REF_WRONG_GRAMMAR -A reference parsed, but as the wrong kind for the argument position, e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. `db sign` and `db update --to` raise it for the reserved references `@contract`, `@db`, and `@empty`, which they do not accept. Payload: `input`, `expectedGrammar`. +A reference parsed, but as the wrong kind for the argument position, e.g. a migration-only reference where a contract reference is required (raised by the shared ref-resolution mapper). The message and fix come from the resolver's own diagnosis. `db sign` and `db update --to` raise it for the reserved references `@contract`, `@db`, and `@empty`, which they do not accept, and `migration plan --to @empty` raises it because `@empty` is only valid as an origin. Payload: `input`, `expectedGrammar`. ### MIGRATION.RUNNER_FAILED diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 942a33c77b53..f807d83ddb4d 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1137,32 +1137,32 @@ prisma migration show [target] [--config ] [--json] [-v] [-q] [--color/--n ### `prisma migration status` -Show the migration graph and applied status. Adapts based on context: - -- **With DB connection**: Shows applied/pending markers and "you are here" indicators -- **Without DB connection**: Shows the graph structure from disk only -- **With `--ref`**: Targets a specific ref instead of the contract hash; all refs from `refs.json` are rendered on the graph +Shows which migrations are pending between the database marker and the target contract. It needs a database connection unless `--from` names the origin. ```bash -prisma migration status [--db ] [--ref ] [--config ] [--json] [-v] [-q] [--color/--no-color] +prisma migration status [--db ] [--to ] [--from ] [--space ] [--legend] [--ascii] [--config ] [--json] [-v] [-q] [--color/--no-color] ``` **Options:** -- `--db `: Database connection string (enables online mode) -- `--ref `: Target a named ref from `migrations/refs.json` instead of the current contract hash +- `--db `: Database connection string +- `--to `: Target contract reference (hash, prefix, ref name, migration dir name, `^`, `@contract`, `@db`, or `@empty`). Defaults to the emitted contract. +- `--from `: Origin contract reference, with the same forms as `--to`. Defaults to the database marker. With `--from`, the path is computed without reading the database. +- `--space `: Narrow output to a single contract space +- `--legend`: Print a key for the tree glyphs and lane colors +- `--ascii`: Use ASCII glyphs - `--config `: Path to `prisma.config.ts` - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output +`@db` in either `--to` or `--from` resolves to the database marker, so the command reads the database and needs a connection. `--to` and `--from` apply to the app space; each extension space is checked from its own marker to its own head. + **What it does:** -1. Reads migration packages from disk and reconstructs the migration graph -2. Loads all refs from `migrations/refs.json` (if present) and renders them on the graph -3. If `--ref` is provided, uses the ref's hash as the target instead of the contract hash; the active ref is highlighted in bold, other refs are dimmed -4. If a DB connection is available, reads the marker to determine applied/pending status and shows distance from the ref target (e.g., "2 edge(s) behind ref") -5. Displays the graph with `◄ DB`, `◄ Contract`, and `◄ ref:` markers -6. Shows operation summaries with destructive operation highlighting -7. In `--ref` mode, the `CONTRACT.AHEAD` warning is suppressed — contract being ahead of a ref target is expected in multi-environment workflows +1. Reads migration packages from disk and reconstructs each space's migration graph +2. Resolves the origin (the database marker, or `--from`) and the target (the emitted contract, or `--to`) +3. With a database connection, reads each space's marker and ledger to mark migrations applied or pending +4. Draws each space's graph with `@db`, `@contract` and ref labels, and summarises what is pending +5. Warns `MIGRATION.MARKER_NOT_IN_HISTORY` when a marker is not in its space's migration graph ### `prisma db migrate` From 1d3f0176b4ed28987dda52486cdfeb0e983c7af3 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:06:53 +0200 Subject: [PATCH 23/37] docs(migration-tools): the contract-reference parser documents how callers actually handle @db Callers that accept @db test isLiveMarkerRef before parsing and resolve it from the marker. The reserved-db result carries a placeholder hash that must not be used. The parser no longer tells callers to check provenance.kind, which none of them do. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/migration/src/refs/contract-ref.ts | 11 +++-------- .../1-framework/3-tooling/migration/src/refs/types.ts | 7 +++---- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts index 44ec91be7bc8..d230bd6538cd 100644 --- a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts +++ b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts @@ -38,9 +38,9 @@ export function isLiveMarkerRef(input: string | undefined): boolean { * Accepted forms: * - `@contract` — the on-disk working contract hash (offline; requires * `ctx.contractHash` to be set) - * - `@db` — the live database marker (connection-required); callers MUST - * check `result.value.provenance.kind === 'reserved-db'` and resolve the - * actual hash via `readAllMarkers()` before using `result.value.hash` + * - `@db` — the live database marker. Callers that accept `@db` test + * `isLiveMarkerRef(input)` before parsing and resolve it from the marker; + * the `reserved-db` result carries a placeholder hash that must not be used * - `@empty` — the empty contract (offline; resolves to * `EMPTY_CONTRACT_HASH`, the origin with no prior storage state) * - Full storage hash (64 hex chars or `empty`) @@ -69,11 +69,6 @@ export function parseContractRef( } if (input === LIVE_MARKER_REF) { - // The live DB marker is not available offline. Return a sentinel result with - // a `reserved-db` provenance; callers must resolve the actual hash via - // `readAllMarkers()`. The `hash` placeholder is intentionally empty — it - // must NOT be used directly. This is enforced by convention; callers - // should check `provenance.kind` before using the hash. return ok({ hash: '', provenance: { kind: 'reserved-db' } }); } diff --git a/packages/1-framework/3-tooling/migration/src/refs/types.ts b/packages/1-framework/3-tooling/migration/src/refs/types.ts index 33b92119e759..4a464581bc35 100644 --- a/packages/1-framework/3-tooling/migration/src/refs/types.ts +++ b/packages/1-framework/3-tooling/migration/src/refs/types.ts @@ -24,10 +24,9 @@ export type ContractRefProvenance = */ | { readonly kind: 'reserved-contract' } /** - * Resolved from the `@db` reserved token — the live database marker. - * The `hash` field is a placeholder; callers must resolve the actual hash - * via `readAllMarkers()` before using it. Check `provenance.kind === - * 'reserved-db'` to detect this case and perform the DB lookup. + * Resolved from the `@db` reserved token. The `hash` field is a placeholder + * that must not be used: callers that accept `@db` test `isLiveMarkerRef` + * before parsing and resolve it from the live marker. */ | { readonly kind: 'reserved-db' } /** From 2fc4624e65b08b3b7ee93751c3e5960da2ab3162 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:07:38 +0200 Subject: [PATCH 24/37] test(cli): pin the db migrate --to targets, the ref name, and the extension-space status document migration status --to @contract with an extension space now compares the whole document with the run without --to. db migrate --to @db on a database with no marker and --to @empty both hand the runner the empty contract. db migrate --to prod passes refName prod, and --to @db passes none. db migrate --show --from @db without a connection asserts the error code, the missing flag and the retry command. One status test uses a matcher in place of a cast. Signed-off-by: willbot Signed-off-by: Will Madden --- .../cli/test/orm/migrate-show.test.ts | 23 +++++++++---- .../3-tooling/cli/test/orm/migrate.test.ts | 34 +++++++++++++++++++ .../cli/test/orm/migration-status.test.ts | 32 +++++++++-------- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 664f4ab6370f..270c342af8ae 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -240,7 +240,7 @@ describe('migrate --show', () => { }); }); - it('errors structurally for --from @db without a connection', async () => { + it('requires a connection for --from @db and repeats --from @db in the retry', async () => { const cwd = await buildProject(); const run = await harness(ormConfig(cwd, { db: undefined })).run( @@ -248,11 +248,22 @@ describe('migrate --show', () => { { cwd }, ); - expect(run.exitCode).not.toBe(0); - const terminal = run.json.at(-1) as - | { kind: string; envelope?: { ok: boolean; error?: { code: string } } } - | undefined; - expect(terminal?.envelope?.error?.code).toMatch(/^[A-Z]+\.[A-Z_]+$/); + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining('db migrate --show --from @db --db $DATABASE_URL'), + }), + ], + }, + }, + }); }); it('previews a ref target whose invariants ride the ref, not the contract head', async () => { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts index b76943d4b0f2..c356c654f416 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts @@ -8,6 +8,7 @@ import { import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { writeRef } from '@internal/migration-tools/refs'; import { notOk, ok } from '@internal/utils/result'; import type { EngineEvent, StreamEvent } from '@prisma/cli-engine'; import { join } from 'pathe'; @@ -509,6 +510,7 @@ describe('migrate', () => { }), }), ); + expect(mocks.migrate.mock.calls[0]?.[0]).not.toHaveProperty('refName'); expect(run.presented?.data).toMatchObject({ ok: true, migrationsApplied: 0, @@ -517,6 +519,38 @@ describe('migrate', () => { }); }); + it.each([ + { to: '@db', reason: 'a database with no marker' }, + { to: '@empty', reason: 'the empty contract' }, + ])('hands the runner the empty contract for --to $to ($reason)', async ({ to }) => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', to, '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith(expect.objectContaining({ refHash: EMPTY })); + }); + + it('passes the resolved ref name for a ref target', async () => { + const cwd = await buildProject(); + await writeSnapshot(cwd, C1); + await writeRef(join(cwd, 'migrations', 'app', 'refs'), 'prod', { + hash: C1, + invariants: [], + }); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', 'prod', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith( + expect.objectContaining({ refHash: C1, refName: 'prod' }), + ); + }); + it('errors with the connection-required envelope for @db without a connection', async () => { const cwd = await buildProject(); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index cfef4577a1c1..78171483e557 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -598,12 +598,18 @@ describe('migration status', () => { ledger: [{ migrationHash: project.migrationHash }], }); - const run = await harness(withAllExternalExtension(driverConfig(project, db))).run( + const config = withAllExternalExtension(driverConfig(project, db)); + + const implicit = await harness(config).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + const run = await harness(config).run( ['migration', 'status', '--to', '@contract', '--json'], { cwd: project.dir }, ); expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual(implicit.presented?.data); expect(run.presented?.data).toMatchObject({ summary: 'Up to date', diagnostics: [], @@ -695,24 +701,20 @@ describe('migration status', () => { ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], { cwd: project.dir }, ); - const document = run.presented?.data as { - spaces: ReadonlyArray<{ - currentContract: string | null; - targetContract: string; - migrations: ReadonlyArray<{ status: string }>; - }>; - }; expect(run.exitCode).toBe(0); expect(db.counters.connections).toBe(1); - expect(document.spaces).toHaveLength(1); - expect(document.spaces[0]).toMatchObject({ - currentContract: HASH_BASE, - targetContract: HASH_HEAD, + expect(run.presented?.data).toMatchObject({ + spaces: [ + { + currentContract: HASH_BASE, + targetContract: HASH_HEAD, + migrations: expect.not.arrayContaining([ + expect.objectContaining({ status: 'applied' }), + ]), + }, + ], }); - expect(document.spaces[0]?.migrations.map((migration) => migration.status)).not.toContain( - 'applied', - ); }); it('reports no path when --from @contract is ahead of the database named by --to @db', async () => { From f5095b6901d62c54d7fc68a2b87aec438dda34a5 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:12:29 +0200 Subject: [PATCH 25/37] test(cli): split the status and db migrate test files so each stays under 500 lines The migration status harness moves to fixtures/status-database.ts, and the reserved-reference cases move to migration-status-contract-refs.test.ts. The db migrate --to cases move into migrate-to-contract.test.ts, which already tests how db migrate --to resolves its target. The db migrate --show cases in migrate.test.ts move into migrate-show.test.ts, whose harness moves to fixtures/migrate-show-project.ts. Each moved test keeps its assertions; the @contract case now also gives contract.json a field the stored snapshot lacks, so it shows which copy reaches the runner. Signed-off-by: willbot Signed-off-by: Will Madden --- .../test/orm/fixtures/migrate-show-project.ts | 199 ++++++++ .../cli/test/orm/fixtures/status-database.ts | 175 ++++++++ .../cli/test/orm/migrate-show.test.ts | 338 ++++++-------- .../cli/test/orm/migrate-to-contract.test.ts | 138 +++++- .../3-tooling/cli/test/orm/migrate.test.ts | 300 +------------ .../migration-status-contract-refs.test.ts | 258 +++++++++++ .../cli/test/orm/migration-status.test.ts | 424 +----------------- 7 files changed, 932 insertions(+), 900 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts create mode 100644 packages/1-framework/3-tooling/cli/test/orm/fixtures/status-database.ts create mode 100644 packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts new file mode 100644 index 000000000000..ddda3a96701a --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts @@ -0,0 +1,199 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import type { MigrationPlanOperation } from '@internal/framework-components/control'; +import { writeContractSnapshot } from '@internal/migration-tools/contract-snapshot-store'; +import { computeMigrationHash } from '@internal/migration-tools/hash'; +import { writeMigrationPackage } from '@internal/migration-tools/io'; +import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { writeRef } from '@internal/migration-tools/refs'; +import type { Block } from '@prisma/cli-engine'; +import { join } from 'pathe'; +import { type Mock, vi } from 'vitest'; +import type { ControlClient } from '../../../src/control-api/types'; +import { BIN_GROUPS, createBinCommands } from '../../../src/orm/cli'; +import { createOrmTestCli } from '../../helpers/orm-test-cli'; +import { createTestProjectDir } from '../../utils/test-project-dir'; + +/** The control-client double every `db migrate --show` test runs against. */ +export const mocks: Readonly> = { + connect: vi.fn(), + readAllMarkers: vi.fn(), + migrate: vi.fn(), + close: vi.fn(), +}; + +const commands = createBinCommands( + () => + ({ + connect: mocks.connect, + readAllMarkers: mocks.readAllMarkers, + migrate: mocks.migrate, + close: mocks.close, + }) as unknown as ControlClient, +); + +export const EMPTY = 'empty'; +export const C1 = '1'.repeat(64); +export const C2 = '2'.repeat(64); +export const EXT_C1 = 'e'.repeat(64); +export const UNKNOWN = 'd'.repeat(64); +const TARGET = 'mock'; +const FAMILY = 'mock'; + +const OPS: readonly MigrationPlanOperation[] = [ + { id: 'table.users', label: 'Create table users', operationClass: 'additive' }, +]; + +export function contractEnvelope(storageHash: string): Record { + return { + storage: { storageHash, namespaces: {} }, + schemaVersion: '1.0.0', + target: TARGET, + targetFamily: FAMILY, + }; +} + +const tempDirs: string[] = []; + +export async function removeMigrateShowProjects(): Promise { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +} + +export function resetMigrateShowMocks(): void { + mocks.connect.mockReset().mockResolvedValue(undefined); + mocks.close.mockReset().mockResolvedValue(undefined); + mocks.readAllMarkers.mockReset().mockResolvedValue(new Map()); + mocks.migrate.mockReset(); +} + +export async function writePkg( + dir: string, + base: Omit, +): Promise { + const dirName = `20260101_100000_${base.to.slice(7, 13)}`; + const metadata: MigrationMetadata = { + ...base, + migrationHash: computeMigrationHash(base, [...OPS]), + }; + await writeMigrationPackage(join(dir, dirName), metadata, [...OPS]); + return dirName; +} + +/** A linear app history: empty → C1 → C2, with the emitted contract at C2. */ +export async function buildProject(): Promise { + const cwd = createTestProjectDir('orm-migrate-show'); + tempDirs.push(cwd); + const appDir = join(cwd, 'migrations', 'app'); + await mkdir(appDir, { recursive: true }); + await writePkg(appDir, { + from: EMPTY, + to: C1, + providedInvariants: [], + createdAt: '2026-01-01T10:00:00.000Z', + }); + await writePkg(appDir, { + from: C1, + to: C2, + providedInvariants: [], + createdAt: '2026-01-01T10:01:00.000Z', + }); + await writeFile(join(cwd, 'contract.json'), JSON.stringify(contractEnvelope(C2))); + return cwd; +} + +/** Adds a declared pgvector space with its own empty → EXT_C1 graph. */ +export async function addExtensionSpace(cwd: string): Promise { + const extDir = join(cwd, 'migrations', 'pgvector'); + const dirName = await writePkg(extDir, { + from: EMPTY, + to: EXT_C1, + providedInvariants: [], + createdAt: '2026-01-01T09:00:00.000Z', + }); + await writeRef(join(extDir, 'refs'), 'head', { hash: EXT_C1, invariants: [] }); + await writeContractSnapshot(join(cwd, 'migrations'), EXT_C1, { + contractJson: contractEnvelope(EXT_C1), + contractDts: 'export type Contract = unknown;\n', + }); + return dirName; +} + +export function pgvectorExtension(): Record { + return { + kind: 'extension', + id: 'pgvector', + familyId: FAMILY, + targetId: TARGET, + version: '1.0.0', + create: () => ({}), + contractSpace: { + contractJson: contractEnvelope(EXT_C1), + headRef: { hash: EXT_C1, invariants: [] }, + migrations: [], + }, + }; +} + +export function ormConfig( + cwd: string, + overrides: Record = {}, +): Record { + return { + family: { + kind: 'family', + id: FAMILY, + familyId: FAMILY, + version: '1.0.0', + emission: {}, + create: () => ({ deserializeContract: (json: unknown) => json }), + }, + target: { + kind: 'target', + id: TARGET, + familyId: FAMILY, + targetId: TARGET, + version: '1.0.0', + create: () => ({}), + migrations: {}, + }, + adapter: { + kind: 'adapter', + id: 'mock', + familyId: FAMILY, + targetId: TARGET, + version: '1.0.0', + create: () => ({}), + }, + driver: { + kind: 'driver', + id: 'mock', + familyId: FAMILY, + targetId: TARGET, + version: '1.0.0', + create: () => ({}), + }, + db: { connection: 'postgres://user:secret@localhost:5432/appdb' }, + contract: { + source: { format: 'typescript', inputs: [], load: async () => ({}) }, + output: join(cwd, 'contract.json'), + }, + migrations: { dir: 'migrations' }, + ...overrides, + }; +} + +export function harness(config: Record) { + return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); +} + +/** Flattens a drawing block's span lines into plain strings. */ +export function drawingLines(blocks: readonly Block[]): readonly string[] { + return blocks + .filter((block) => block.kind === 'drawing') + .flatMap((block) => + block.lines.map((line) => + typeof line === 'string' + ? line + : line.map((span) => (typeof span === 'string' ? span : span.text)).join(''), + ), + ); +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/status-database.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/status-database.ts new file mode 100644 index 000000000000..10e1e9a2cd92 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/status-database.ts @@ -0,0 +1,175 @@ +import { writeRef } from '@internal/migration-tools/refs'; +import type { Diagnostic } from '@prisma/cli-engine/protocol'; +import { join } from 'pathe'; +import { BIN_COMMANDS, BIN_GROUPS } from '../../../src/orm/cli'; +import { createOrmTestCli } from '../../helpers/orm-test-cli'; +import { + contractJson, + createOfflineProject, + type OfflineProject, + offlineConfig, + seedContractSnapshot, + seedMigrationPackage, +} from './offline-project'; + +export const HASH_HEAD = `c0ffee${'0'.repeat(58)}`; +export const HASH_BASE = `beef${'1'.repeat(60)}`; +export const HASH_UNKNOWN = `dead${'2'.repeat(60)}`; +const CONNECTION = 'postgres://user:secret@localhost:5432/appdb'; + +interface FakeDatabaseScript { + readonly markers?: ReadonlyMap< + string, + { readonly storageHash: string; readonly invariants: readonly string[] } + >; + readonly ledger?: ReadonlyArray<{ readonly migrationHash: string }>; + readonly readMarkersError?: Error; + readonly closeError?: Error; +} + +/** + * The database the real control client talks to: the family instance answers + * marker and ledger reads from the script, and the driver descriptor counts + * connections so tests can assert none was opened. No module mocks — the + * command builds the real client over these descriptors. + */ +export function fakeDatabase(script: FakeDatabaseScript = {}) { + const counters = { connections: 0, closes: 0 }; + const familyInstance = { + deserializeContract: (json: unknown) => json, + readAllMarkers: async () => { + if (script.readMarkersError !== undefined) { + throw script.readMarkersError; + } + return script.markers ?? new Map(); + }, + readLedger: async () => script.ledger ?? [], + }; + const driver = { + close: async () => { + counters.closes += 1; + if (script.closeError !== undefined) { + throw script.closeError; + } + }, + }; + return { counters, familyInstance, driver }; +} + +type FakeDatabase = ReturnType; + +export function driverConfig( + project: OfflineProject, + db: FakeDatabase = fakeDatabase(), +): Record { + const base = offlineConfig({ project }); + return { + ...base, + family: { ...(base['family'] as Record), create: () => db.familyInstance }, + driver: { + kind: 'driver', + id: 'pg', + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: async () => { + db.counters.connections += 1; + return db.driver; + }, + }, + db: { connection: CONNECTION }, + }; +} + +export function harness(config: Record) { + return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); +} + +/** A project whose app space carries one migration ∅ → HASH_HEAD. */ +export async function projectWithOneMigration(): Promise< + OfflineProject & { readonly migrationHash: string } +> { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const seeded = await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: '20260101T0000_initial', + from: null, + to: HASH_HEAD, + }); + return { ...project, migrationHash: seeded.migrationHash }; +} + +export const DIR_BASE = '20260101T0000_base'; +export const DIR_HEAD = '20260102T0000_head'; + +/** A project whose app space carries ∅ → HASH_BASE → HASH_HEAD, with the contract at HASH_HEAD. */ +export async function projectWithTwoMigrations(): Promise< + OfflineProject & { readonly baseMigrationHash: string } +> { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const base = await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: DIR_BASE, + from: null, + to: HASH_BASE, + }); + await seedMigrationPackage({ + appMigrationsDir: project.appMigrationsDir, + dirName: DIR_HEAD, + from: HASH_BASE, + to: HASH_HEAD, + }); + return { ...project, baseMigrationHash: base.migrationHash }; +} + +export function markersAt(storageHash: string) { + return new Map([['app', { storageHash, invariants: [] as readonly string[] }]]); +} + +export const EXTERNAL_SPACE = 'external'; +export const HASH_EXTERNAL_HEAD = `e0e0${'3'.repeat(60)}`; + +/** An all-external extension space: a head ref on disk and no migration packages. */ +export async function addAllExternalSpace(project: OfflineProject): Promise { + await writeRef(join(project.migrationsDir, EXTERNAL_SPACE, 'refs'), 'head', { + hash: HASH_EXTERNAL_HEAD, + invariants: [], + }); + await seedContractSnapshot({ + migrationsDir: project.migrationsDir, + storageHash: HASH_EXTERNAL_HEAD, + }); +} + +function allExternalExtension(): Record { + return { + kind: 'extension', + id: EXTERNAL_SPACE, + familyId: 'sql', + targetId: 'postgres', + version: '1.0.0', + create: () => ({}), + contractSpace: { + contractJson: contractJson(HASH_EXTERNAL_HEAD), + headRef: { hash: HASH_EXTERNAL_HEAD, invariants: [] }, + migrations: [], + }, + }; +} + +export function withAllExternalExtension(config: Record): Record { + return { ...config, extensions: [allExternalExtension()] }; +} + +export function markersWithExternalAtHead(appHash: string) { + return new Map([ + ['app', { storageHash: appHash, invariants: [] as readonly string[] }], + [EXTERNAL_SPACE, { storageHash: HASH_EXTERNAL_HEAD, invariants: [] as readonly string[] }], + ]); +} + +export function codesAndSeverities( + diagnostics: readonly Diagnostic[], +): ReadonlyArray<{ code: string; severity: string }> { + return diagnostics.map(({ code, severity }) => ({ code, severity })); +} diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 270c342af8ae..95e9528d1609 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -1,198 +1,28 @@ -import { mkdir, rm, writeFile } from 'node:fs/promises'; -import type { MigrationPlanOperation } from '@internal/framework-components/control'; -import { writeContractSnapshot } from '@internal/migration-tools/contract-snapshot-store'; -import { computeMigrationHash } from '@internal/migration-tools/hash'; -import { writeMigrationPackage } from '@internal/migration-tools/io'; -import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { rm } from 'node:fs/promises'; import { writeRef } from '@internal/migration-tools/refs'; -import type { Block } from '@prisma/cli-engine'; import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ControlClient } from '../../src/control-api/types'; -import { BIN_GROUPS, createBinCommands } from '../../src/orm/cli'; -import { createOrmTestCli } from '../helpers/orm-test-cli'; -import { createTestProjectDir } from '../utils/test-project-dir'; - -const mocks = { - connect: vi.fn(), - readAllMarkers: vi.fn(), - migrate: vi.fn(), - close: vi.fn(), -}; - -const commands = createBinCommands( - () => - ({ - connect: mocks.connect, - readAllMarkers: mocks.readAllMarkers, - migrate: mocks.migrate, - close: mocks.close, - }) as unknown as ControlClient, -); - -const EMPTY = 'empty'; -const C1 = '1'.repeat(64); -const C2 = '2'.repeat(64); -const EXT_C1 = 'e'.repeat(64); -const UNKNOWN = 'd'.repeat(64); -const TARGET = 'mock'; -const FAMILY = 'mock'; - -const OPS: readonly MigrationPlanOperation[] = [ - { id: 'table.users', label: 'Create table users', operationClass: 'additive' }, -]; - -function contractEnvelope(storageHash: string): Record { - return { - storage: { storageHash, namespaces: {} }, - schemaVersion: '1.0.0', - target: TARGET, - targetFamily: FAMILY, - }; -} - -const tempDirs: string[] = []; - -afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); -}); - -beforeEach(() => { - mocks.connect.mockReset().mockResolvedValue(undefined); - mocks.close.mockReset().mockResolvedValue(undefined); - mocks.readAllMarkers.mockReset().mockResolvedValue(new Map()); - mocks.migrate.mockReset(); -}); - -async function writePkg( - dir: string, - base: Omit, -): Promise { - const dirName = `20260101_100000_${base.to.slice(7, 13)}`; - const metadata: MigrationMetadata = { - ...base, - migrationHash: computeMigrationHash(base, [...OPS]), - }; - await writeMigrationPackage(join(dir, dirName), metadata, [...OPS]); - return dirName; -} - -/** A linear app history: empty → C1 → C2, with the emitted contract at C2. */ -async function buildProject(): Promise { - const cwd = createTestProjectDir('orm-migrate-show'); - tempDirs.push(cwd); - const appDir = join(cwd, 'migrations', 'app'); - await mkdir(appDir, { recursive: true }); - await writePkg(appDir, { - from: EMPTY, - to: C1, - providedInvariants: [], - createdAt: '2026-01-01T10:00:00.000Z', - }); - await writePkg(appDir, { - from: C1, - to: C2, - providedInvariants: [], - createdAt: '2026-01-01T10:01:00.000Z', - }); - await writeFile(join(cwd, 'contract.json'), JSON.stringify(contractEnvelope(C2))); - return cwd; -} - -/** Adds a declared pgvector space with its own empty → EXT_C1 graph. */ -async function addExtensionSpace(cwd: string): Promise { - const extDir = join(cwd, 'migrations', 'pgvector'); - const dirName = await writePkg(extDir, { - from: EMPTY, - to: EXT_C1, - providedInvariants: [], - createdAt: '2026-01-01T09:00:00.000Z', - }); - await writeRef(join(extDir, 'refs'), 'head', { hash: EXT_C1, invariants: [] }); - await writeContractSnapshot(join(cwd, 'migrations'), EXT_C1, { - contractJson: contractEnvelope(EXT_C1), - contractDts: 'export type Contract = unknown;\n', - }); - return dirName; -} - -function pgvectorExtension(): Record { - return { - kind: 'extension', - id: 'pgvector', - familyId: FAMILY, - targetId: TARGET, - version: '1.0.0', - create: () => ({}), - contractSpace: { - contractJson: contractEnvelope(EXT_C1), - headRef: { hash: EXT_C1, invariants: [] }, - migrations: [], - }, - }; -} - -function ormConfig(cwd: string, overrides: Record = {}): Record { - return { - family: { - kind: 'family', - id: FAMILY, - familyId: FAMILY, - version: '1.0.0', - emission: {}, - create: () => ({ deserializeContract: (json: unknown) => json }), - }, - target: { - kind: 'target', - id: TARGET, - familyId: FAMILY, - targetId: TARGET, - version: '1.0.0', - create: () => ({}), - migrations: {}, - }, - adapter: { - kind: 'adapter', - id: 'mock', - familyId: FAMILY, - targetId: TARGET, - version: '1.0.0', - create: () => ({}), - }, - driver: { - kind: 'driver', - id: 'mock', - familyId: FAMILY, - targetId: TARGET, - version: '1.0.0', - create: () => ({}), - }, - db: { connection: 'postgres://user:secret@localhost:5432/appdb' }, - contract: { - source: { format: 'typescript', inputs: [], load: async () => ({}) }, - output: join(cwd, 'contract.json'), - }, - migrations: { dir: 'migrations' }, - ...overrides, - }; -} - -function harness(config: Record) { - return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); -} - -/** Flattens a drawing block's span lines into plain strings. */ -function drawingLines(blocks: readonly Block[]): readonly string[] { - return blocks - .filter((block) => block.kind === 'drawing') - .flatMap((block) => - block.lines.map((line) => - typeof line === 'string' - ? line - : line.map((span) => (typeof span === 'string' ? span : span.text)).join(''), - ), - ); -} +import stripAnsi from 'strip-ansi'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + addExtensionSpace, + buildProject, + C1, + C2, + drawingLines, + EMPTY, + EXT_C1, + harness, + mocks, + ormConfig, + pgvectorExtension, + removeMigrateShowProjects, + resetMigrateShowMocks, + UNKNOWN, + writePkg, +} from './fixtures/migrate-show-project'; + +afterEach(removeMigrateShowProjects); +beforeEach(resetMigrateShowMocks); describe('migrate --show', () => { it('shows nothing to run when the from-state is already the target', async () => { @@ -500,4 +330,126 @@ describe('migrate --show', () => { ]); }); }); + + describe('the preview', () => { + it('previews the route without applying anything', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).not.toHaveBeenCalled(); + expect(run.presented?.data).toMatchObject({ + ok: true, + migrations: [ + expect.objectContaining({ spaceId: 'app', from: EMPTY, to: C1 }), + expect.objectContaining({ spaceId: 'app', from: C1, to: C2 }), + ], + }); + }); + + it('keeps the human-only rendering out of the result document', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { + cwd, + }); + + expect(Object.keys(run.presented?.data ?? {}).sort()).toEqual([ + 'migrations', + 'ok', + 'summary', + ]); + }); + + it('ships the topology as a drawing whose spans carry tone', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { + cwd, + isTty: { stdout: true }, + }); + const blocks = run.presented?.presentation.human ?? []; + const drawings = blocks.filter((block) => block.kind === 'drawing'); + + expect(blocks[0]).toMatchObject({ kind: 'fields', rail: true }); + expect(drawings).toHaveLength(2); + expect(JSON.stringify(drawings)).not.toContain('\\u001b'); + expect(JSON.stringify(drawings)).toContain('"tone"'); + }); + + it('announces how many migrations will run', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { + cwd, + isTty: { stdout: true }, + }); + + expect(run.presented?.presentation.human).toContainEqual({ + kind: 'summary', + status: 'info', + text: 'The following 2 migrations will run:', + }); + }); + + it('keeps every arrow in the run list in one column', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { + cwd, + isTty: { stdout: true, stderr: true }, + }); + const rendered = stripAnsi(run.stderr).split('\n'); + const runList = rendered.slice(rendered.findIndex((line) => line.includes('will run:')) + 1); + const arrowColumns = new Set( + runList.filter((line) => line.includes('\u2192')).map((line) => line.indexOf('\u2192')), + ); + + expect(run.stdout).toBe(''); + expect(runList.filter((line) => line.includes('\u2192'))).toHaveLength(2); + expect(arrowColumns.size).toBe(1); + }); + + it('plans offline when --from names a contract', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--from', C1, '--json'], + { + cwd, + }, + ); + + expect(run.exitCode).toBe(0); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(run.presented?.data).toMatchObject({ + migrations: [expect.objectContaining({ from: C1, to: C2 })], + }); + }); + + it('names the from-state and the target in the header', async () => { + const cwd = await buildProject(); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--show', '--from', C1, '--to', C2], + { + cwd, + isTty: { stdout: true }, + }, + ); + + expect(run.presented?.presentation.human[0]).toEqual({ + kind: 'fields', + rail: true, + rows: [ + { label: 'migrations', value: 'migrations' }, + { label: 'from', value: C1 }, + { label: 'to', value: C2 }, + ], + }); + }); + }); }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts index f28fd7342756..0e656ffec240 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts @@ -7,6 +7,7 @@ import { import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; +import { writeRef } from '@internal/migration-tools/refs'; import { ok } from '@internal/utils/result'; import { join, relative } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -119,7 +120,7 @@ async function buildAppliedProject(): Promise { return cwd; } -function ormConfig(cwd: string): Record { +function ormConfig(cwd: string, overrides: Record = {}): Record { return { family: { kind: 'family', @@ -160,9 +161,14 @@ function ormConfig(cwd: string): Record { output: join(cwd, 'contract.json'), }, migrations: { dir: 'migrations' }, + ...overrides, }; } +function markerAt(storageHash: string): Map { + return new Map([['app', { storageHash, invariants: [] }]]); +} + function harness(config: Record) { return createOrmTestCli({ commands, groups: BIN_GROUPS, orm: config }); } @@ -254,3 +260,133 @@ describe('migrate --to resolves the apply contract', () => { expect(mocks.migrate).not.toHaveBeenCalled(); }); }); + +describe('migrate --to reserved references and refs', () => { + it('treats @contract like an omitted --to and applies the emitted contract', async () => { + const cwd = await buildAppliedProject(); + const emitted = { ...contractEnvelope(C2), models: { onlyInContractJson: {} } }; + await writeFile(join(cwd, 'contract.json'), JSON.stringify(emitted)); + mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); + + const run = await harness(ormConfig(cwd)).run( + ['db', 'migrate', '--to', '@contract', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(0); + const migrateOptions = mocks.migrate.mock.calls[0]?.[0]; + expect(migrateOptions).toMatchObject({ contract: emitted }); + expect(migrateOptions).not.toHaveProperty('refHash'); + expect(migrateOptions).not.toHaveProperty('refInvariants'); + expect(migrateOptions).not.toHaveProperty('refName'); + }); + + it('resolves @db to the live marker so there is nothing to run', async () => { + const cwd = await buildAppliedProject(); + mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); + mocks.migrate.mockResolvedValue( + ok({ + migrationsApplied: 0, + markerHash: C1, + applied: [], + summary: 'Already up to date', + perSpace: [], + }), + ); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', '@db', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith( + expect.objectContaining({ + refHash: C1, + refInvariants: [], + contract: expect.objectContaining({ + storage: expect.objectContaining({ storageHash: C1 }), + }), + }), + ); + expect(mocks.migrate.mock.calls[0]?.[0]).not.toHaveProperty('refName'); + expect(run.presented?.data).toMatchObject({ + ok: true, + migrationsApplied: 0, + markerHash: C1, + summary: 'Already up to date', + }); + }); + + it.each([ + { to: '@db', reason: 'a database with no marker' }, + { to: '@empty', reason: 'the empty contract' }, + ])('hands the runner the empty contract for --to $to ($reason)', async ({ to }) => { + const cwd = await buildAppliedProject(); + mocks.readAllMarkers.mockResolvedValue(new Map()); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', to, '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith(expect.objectContaining({ refHash: EMPTY })); + }); + + it('passes the resolved ref name for a ref target', async () => { + const cwd = await buildAppliedProject(); + await writeRef(join(cwd, 'migrations', 'app', 'refs'), 'prod', { hash: C1, invariants: [] }); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', 'prod', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(0); + expect(mocks.migrate).toHaveBeenCalledWith( + expect.objectContaining({ refHash: C1, refName: 'prod' }), + ); + }); + + it('errors with the connection-required envelope for @db without a connection', async () => { + const cwd = await buildAppliedProject(); + + const run = await harness(ormConfig(cwd, { db: undefined })).run( + ['db', 'migrate', '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining('db migrate --to @db --db $DATABASE_URL'), + }), + ], + }, + }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.migrate).not.toHaveBeenCalled(); + }); + + it('reports a missing driver for @db as a missing driver', async () => { + const cwd = await buildAppliedProject(); + + const run = await harness(ormConfig(cwd, { driver: undefined })).run( + ['db', 'migrate', '--to', '@db', '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { ok: false, error: { code: 'CONFIG.DRIVER_REQUIRED' } }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts index c356c654f416..96d12115ea87 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate.test.ts @@ -1,14 +1,10 @@ import { existsSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import type { MigrationPlanOperation } from '@internal/framework-components/control'; -import { - contractSnapshotDir, - writeContractSnapshot, -} from '@internal/migration-tools/contract-snapshot-store'; +import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; import { computeMigrationHash } from '@internal/migration-tools/hash'; import { writeMigrationPackage } from '@internal/migration-tools/io'; import type { MigrationMetadata } from '@internal/migration-tools/metadata'; -import { writeRef } from '@internal/migration-tools/refs'; import { notOk, ok } from '@internal/utils/result'; import type { EngineEvent, StreamEvent } from '@prisma/cli-engine'; import { join } from 'pathe'; @@ -100,23 +96,6 @@ async function buildProject(): Promise { return cwd; } -/** The snapshot store entry a `--to` target's bundle is materialized from. */ -async function writeSnapshot(cwd: string, storageHash: string): Promise { - await writeContractSnapshot(join(cwd, 'migrations'), storageHash, { - contractJson: { - storage: { storageHash, namespaces: {} }, - schemaVersion: '1.0.0', - target: TARGET, - targetFamily: FAMILY, - }, - contractDts: 'export type Contract = unknown;\n', - }); -} - -function markerAt(storageHash: string): Map { - return new Map([['app', { storageHash, invariants: [] }]]); -} - function ormConfig(cwd: string, overrides: Record = {}): Record { return { family: { @@ -438,161 +417,6 @@ describe('migrate', () => { }); }); - describe('--to', () => { - it('treats @contract like an omitted --to and applies the emitted contract', async () => { - const cwd = await buildProject(); - mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); - mocks.migrate.mockResolvedValue( - ok({ - ...appliedSuccess(), - migrationsApplied: 1, - applied: [ - { - spaceId: 'app', - dirName: '20260101_100001_222222', - migrationHash: 'h2', - from: C1, - to: C2, - operationsExecuted: 1, - }, - ], - summary: 'Applied 1 migration(s)', - }), - ); - - const run = await harness(ormConfig(cwd)).run( - ['db', 'migrate', '--to', '@contract', '--json'], - { cwd }, - ); - - expect(run.exitCode).toBe(0); - const migrateOptions = mocks.migrate.mock.calls[0]?.[0]; - expect(migrateOptions).toMatchObject({ - contract: JSON.parse(await readFile(join(cwd, 'contract.json'), 'utf-8')), - }); - expect(migrateOptions).not.toHaveProperty('refHash'); - expect(migrateOptions).not.toHaveProperty('refInvariants'); - expect(migrateOptions).not.toHaveProperty('refName'); - expect(run.presented?.data).toMatchObject({ - ok: true, - migrationsApplied: 1, - markerHash: C2, - summary: 'Applied 1 migration(s)', - }); - }); - - it('resolves @db to the live marker so there is nothing to run', async () => { - const cwd = await buildProject(); - await writeSnapshot(cwd, C1); - mocks.readAllMarkers.mockResolvedValue(markerAt(C1)); - mocks.migrate.mockResolvedValue( - ok({ - ...appliedSuccess(), - migrationsApplied: 0, - markerHash: C1, - applied: [], - summary: 'Already up to date', - perSpace: [], - }), - ); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', '@db', '--json'], { - cwd, - }); - - expect(run.exitCode).toBe(0); - expect(mocks.migrate).toHaveBeenCalledWith( - expect.objectContaining({ - refHash: C1, - refInvariants: [], - contract: expect.objectContaining({ - storage: expect.objectContaining({ storageHash: C1 }), - }), - }), - ); - expect(mocks.migrate.mock.calls[0]?.[0]).not.toHaveProperty('refName'); - expect(run.presented?.data).toMatchObject({ - ok: true, - migrationsApplied: 0, - markerHash: C1, - summary: 'Already up to date', - }); - }); - - it.each([ - { to: '@db', reason: 'a database with no marker' }, - { to: '@empty', reason: 'the empty contract' }, - ])('hands the runner the empty contract for --to $to ($reason)', async ({ to }) => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', to, '--json'], { - cwd, - }); - - expect(run.exitCode).toBe(0); - expect(mocks.migrate).toHaveBeenCalledWith(expect.objectContaining({ refHash: EMPTY })); - }); - - it('passes the resolved ref name for a ref target', async () => { - const cwd = await buildProject(); - await writeSnapshot(cwd, C1); - await writeRef(join(cwd, 'migrations', 'app', 'refs'), 'prod', { - hash: C1, - invariants: [], - }); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--to', 'prod', '--json'], { - cwd, - }); - - expect(run.exitCode).toBe(0); - expect(mocks.migrate).toHaveBeenCalledWith( - expect.objectContaining({ refHash: C1, refName: 'prod' }), - ); - }); - - it('errors with the connection-required envelope for @db without a connection', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd, { db: undefined })).run( - ['db', 'migrate', '--to', '@db', '--json'], - { cwd }, - ); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { - code: 'CONFIG.DB_CONNECTION_REQUIRED', - meta: { missingFlags: ['--db'] }, - nextActions: [ - expect.objectContaining({ - label: expect.stringContaining('db migrate --to @db --db $DATABASE_URL'), - }), - ], - }, - }); - expect(mocks.connect).not.toHaveBeenCalled(); - expect(mocks.migrate).not.toHaveBeenCalled(); - }); - - it('reports a missing driver for @db as a missing driver', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd, { driver: undefined })).run( - ['db', 'migrate', '--to', '@db', '--json'], - { cwd }, - ); - - expect(run.exitCode).toBe(2); - expect(envelopeOf(run.json)).toMatchObject({ - ok: false, - error: { code: 'CONFIG.DRIVER_REQUIRED' }, - }); - expect(mocks.connect).not.toHaveBeenCalled(); - }); - }); - describe('a close that fails on the way out', () => { it('reports the connection failure rather than the failure to hang up', async () => { const cwd = await buildProject(); @@ -617,126 +441,4 @@ describe('migrate', () => { expect(envelopeOf(run.json)).toMatchObject({ ok: true }); }); }); - - describe('--show', () => { - it('previews the route without applying anything', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { - cwd, - }); - - expect(run.exitCode).toBe(0); - expect(mocks.migrate).not.toHaveBeenCalled(); - expect(run.presented?.data).toMatchObject({ - ok: true, - migrations: [ - expect.objectContaining({ spaceId: 'app', from: EMPTY, to: C1 }), - expect.objectContaining({ spaceId: 'app', from: C1, to: C2 }), - ], - }); - }); - - it('keeps the human-only rendering out of the result document', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { - cwd, - }); - - expect(Object.keys(run.presented?.data ?? {}).sort()).toEqual([ - 'migrations', - 'ok', - 'summary', - ]); - }); - - it('ships the topology as a drawing whose spans carry tone', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { - cwd, - isTty: { stdout: true }, - }); - const blocks = run.presented?.presentation.human ?? []; - const drawings = blocks.filter((block) => block.kind === 'drawing'); - - expect(blocks[0]).toMatchObject({ kind: 'fields', rail: true }); - expect(drawings).toHaveLength(2); - expect(JSON.stringify(drawings)).not.toContain('\\u001b'); - expect(JSON.stringify(drawings)).toContain('"tone"'); - }); - - it('announces how many migrations will run', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { - cwd, - isTty: { stdout: true }, - }); - - expect(run.presented?.presentation.human).toContainEqual({ - kind: 'summary', - status: 'info', - text: 'The following 2 migrations will run:', - }); - }); - - it('keeps every arrow in the run list in one column', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show'], { - cwd, - isTty: { stdout: true, stderr: true }, - }); - const rendered = stripAnsi(run.stderr).split('\n'); - const runList = rendered.slice(rendered.findIndex((line) => line.includes('will run:')) + 1); - const arrowColumns = new Set( - runList.filter((line) => line.includes('\u2192')).map((line) => line.indexOf('\u2192')), - ); - - expect(run.stdout).toBe(''); - expect(runList.filter((line) => line.includes('\u2192'))).toHaveLength(2); - expect(arrowColumns.size).toBe(1); - }); - - it('plans offline when --from names a contract', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run( - ['db', 'migrate', '--show', '--from', C1, '--json'], - { - cwd, - }, - ); - - expect(run.exitCode).toBe(0); - expect(mocks.connect).not.toHaveBeenCalled(); - expect(run.presented?.data).toMatchObject({ - migrations: [expect.objectContaining({ from: C1, to: C2 })], - }); - }); - - it('names the from-state and the target in the header', async () => { - const cwd = await buildProject(); - - const run = await harness(ormConfig(cwd)).run( - ['db', 'migrate', '--show', '--from', C1, '--to', C2], - { - cwd, - isTty: { stdout: true }, - }, - ); - - expect(run.presented?.presentation.human[0]).toEqual({ - kind: 'fields', - rail: true, - rows: [ - { label: 'migrations', value: 'migrations' }, - { label: 'from', value: C1 }, - { label: 'to', value: C2 }, - ], - }); - }); - }); }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts new file mode 100644 index 000000000000..f979a4af8fd2 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts @@ -0,0 +1,258 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { removeOfflineProjects } from './fixtures/offline-project'; +import { + addAllExternalSpace, + codesAndSeverities, + DIR_BASE, + DIR_HEAD, + driverConfig, + EXTERNAL_SPACE, + fakeDatabase, + HASH_BASE, + HASH_EXTERNAL_HEAD, + HASH_HEAD, + HASH_UNKNOWN, + harness, + markersAt, + markersWithExternalAtHead, + projectWithOneMigration, + projectWithTwoMigrations, + withAllExternalExtension, +} from './fixtures/status-database'; + +afterEach(removeOfflineProjects); + +describe('migration status with reserved contract references', () => { + it('resolves --to @contract to the emitted contract, the same as no --to', async () => { + const project = await projectWithOneMigration(); + const db = fakeDatabase({ + markers: markersAt(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + const config = driverConfig(project, db); + + const implicit = await harness(config).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + const explicit = await harness(config).run( + ['migration', 'status', '--to', '@contract', '--json'], + { cwd: project.dir }, + ); + + expect(explicit.exitCode).toBe(0); + expect(explicit.presented?.data).toEqual(implicit.presented?.data); + expect(explicit.presented?.data).toMatchObject({ + summary: 'Up to date', + spaces: [{ targetContract: HASH_HEAD }], + }); + }); + + it('targets each extension space at its own contract for --to @contract', async () => { + const project = await projectWithOneMigration(); + await addAllExternalSpace(project); + const db = fakeDatabase({ + markers: markersWithExternalAtHead(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + + const config = withAllExternalExtension(driverConfig(project, db)); + + const implicit = await harness(config).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + const run = await harness(config).run(['migration', 'status', '--to', '@contract', '--json'], { + cwd: project.dir, + }); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toEqual(implicit.presented?.data); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + diagnostics: [], + spaces: expect.arrayContaining([ + expect.objectContaining({ space: 'app', targetContract: HASH_HEAD }), + expect.objectContaining({ + space: EXTERNAL_SPACE, + currentContract: HASH_EXTERNAL_HEAD, + targetContract: HASH_EXTERNAL_HEAD, + }), + ]), + }); + }); + + it('resolves --from @contract offline', async () => { + const project = await projectWithOneMigration(); + const db = fakeDatabase(); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@contract', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + spaces: [{ currentContract: HASH_HEAD, targetContract: HASH_HEAD }], + }); + }); + + it('resolves --to @db to the live marker and reports up to date', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: 'Up to date', + diagnostics: [], + spaces: [{ currentContract: HASH_BASE, targetContract: HASH_BASE }], + }); + }); + + it('reports the migration pending between the live marker and --to when --from is @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@db', '--to', DIR_HEAD, '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(1); + expect(run.presented?.data).toMatchObject({ + summary: `1 pending — run \`{bin} db migrate --to ${HASH_HEAD.slice(0, 12)}\``, + spaces: [ + { + currentContract: HASH_BASE, + targetContract: HASH_HEAD, + migrations: expect.arrayContaining([ + expect.objectContaining({ name: DIR_BASE, status: 'applied' }), + expect.objectContaining({ name: DIR_HEAD, status: 'pending' }), + ]), + }, + ], + }); + }); + + it('reads the database only for the target when --from is a hash and --to is @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_HEAD), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(db.counters.connections).toBe(1); + expect(run.presented?.data).toMatchObject({ + spaces: [ + { + currentContract: HASH_BASE, + targetContract: HASH_HEAD, + migrations: expect.not.arrayContaining([expect.objectContaining({ status: 'applied' })]), + }, + ], + }); + }); + + it('reports no path when --from @contract is ahead of the database named by --to @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ + markers: markersAt(HASH_BASE), + ledger: [{ migrationHash: project.baseMigrationHash }], + }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', '@contract', '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: `No migration path from the --from contract (${HASH_HEAD.slice(0, 12)}) to the target (${HASH_BASE.slice(0, 12)}). Run \`{bin} migration plan --name \` to author one, or pass \`--to \` to pick a reachable target.`, + }); + }); + + it('warns when --to @db reads a marker outside the graph and --from is a hash', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ markers: markersAt(HASH_UNKNOWN) }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ + { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, + ]); + expect(run.presented?.data).toMatchObject({ + summary: `Database marker ${HASH_UNKNOWN.slice(0, 12)} is not in the on-disk migration graph`, + }); + }); + + it('errors with the connection-required envelope for --to @db without a connection', async () => { + const project = await projectWithOneMigration(); + const config = driverConfig(project); + + const run = await harness({ ...config, db: undefined }).run( + ['migration', 'status', '--from', HASH_HEAD, '--to', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + why: expect.stringContaining('@db'), + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `migration status --from ${HASH_HEAD} --to @db --db $DATABASE_URL`, + ), + }), + ], + }, + }, + }); + }); + + it('errors with the connection-required envelope for --from @db without a connection', async () => { + const project = await projectWithOneMigration(); + const config = driverConfig(project); + + const run = await harness({ ...config, db: undefined }).run( + ['migration', 'status', '--from', '@db', '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { code: 'CONFIG.DB_CONNECTION_REQUIRED', meta: { missingFlags: ['--db'] } }, + }, + }); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 78171483e557..016597e057e1 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -1,187 +1,35 @@ import { rm } from 'node:fs/promises'; import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import { writeRef } from '@internal/migration-tools/refs'; -import type { Diagnostic } from '@prisma/cli-engine/protocol'; import { join } from 'pathe'; import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it } from 'vitest'; -import { BIN_COMMANDS, BIN_GROUPS } from '../../src/orm/cli'; -import { createOrmTestCli } from '../helpers/orm-test-cli'; import { - contractJson, createOfflineProject, invariantOp, - type OfflineProject, - offlineConfig, removeOfflineProjects, - seedContractSnapshot, seedMigrationPackage, } from './fixtures/offline-project'; +import { + addAllExternalSpace, + codesAndSeverities, + DIR_BASE, + driverConfig, + EXTERNAL_SPACE, + fakeDatabase, + HASH_BASE, + HASH_EXTERNAL_HEAD, + HASH_HEAD, + HASH_UNKNOWN, + harness, + markersAt, + markersWithExternalAtHead, + projectWithOneMigration, + withAllExternalExtension, +} from './fixtures/status-database'; afterEach(removeOfflineProjects); -const HASH_HEAD = `c0ffee${'0'.repeat(58)}`; -const HASH_BASE = `beef${'1'.repeat(60)}`; -const HASH_UNKNOWN = `dead${'2'.repeat(60)}`; -const CONNECTION = 'postgres://user:secret@localhost:5432/appdb'; - -interface FakeDatabaseScript { - readonly markers?: ReadonlyMap< - string, - { readonly storageHash: string; readonly invariants: readonly string[] } - >; - readonly ledger?: ReadonlyArray<{ readonly migrationHash: string }>; - readonly readMarkersError?: Error; - readonly closeError?: Error; -} - -/** - * The database the real control client talks to: the family instance answers - * marker and ledger reads from the script, and the driver descriptor counts - * connections so tests can assert none was opened. No module mocks — the - * command builds the real client over these descriptors. - */ -function fakeDatabase(script: FakeDatabaseScript = {}) { - const counters = { connections: 0, closes: 0 }; - const familyInstance = { - deserializeContract: (json: unknown) => json, - readAllMarkers: async () => { - if (script.readMarkersError !== undefined) { - throw script.readMarkersError; - } - return script.markers ?? new Map(); - }, - readLedger: async () => script.ledger ?? [], - }; - const driver = { - close: async () => { - counters.closes += 1; - if (script.closeError !== undefined) { - throw script.closeError; - } - }, - }; - return { counters, familyInstance, driver }; -} - -type FakeDatabase = ReturnType; - -function driverConfig( - project: OfflineProject, - db: FakeDatabase = fakeDatabase(), -): Record { - const base = offlineConfig({ project }); - return { - ...base, - family: { ...(base['family'] as Record), create: () => db.familyInstance }, - driver: { - kind: 'driver', - id: 'pg', - familyId: 'sql', - targetId: 'postgres', - version: '1.0.0', - create: async () => { - db.counters.connections += 1; - return db.driver; - }, - }, - db: { connection: CONNECTION }, - }; -} - -function harness(config: Record) { - return createOrmTestCli({ commands: BIN_COMMANDS, groups: BIN_GROUPS, orm: config }); -} - -/** A project whose app space carries one migration ∅ → HASH_HEAD. */ -async function projectWithOneMigration(): Promise< - OfflineProject & { readonly migrationHash: string } -> { - const project = await createOfflineProject({ storageHash: HASH_HEAD }); - const seeded = await seedMigrationPackage({ - appMigrationsDir: project.appMigrationsDir, - dirName: '20260101T0000_initial', - from: null, - to: HASH_HEAD, - }); - return { ...project, migrationHash: seeded.migrationHash }; -} - -const DIR_BASE = '20260101T0000_base'; -const DIR_HEAD = '20260102T0000_head'; - -/** A project whose app space carries ∅ → HASH_BASE → HASH_HEAD, with the contract at HASH_HEAD. */ -async function projectWithTwoMigrations(): Promise< - OfflineProject & { readonly baseMigrationHash: string } -> { - const project = await createOfflineProject({ storageHash: HASH_HEAD }); - const base = await seedMigrationPackage({ - appMigrationsDir: project.appMigrationsDir, - dirName: DIR_BASE, - from: null, - to: HASH_BASE, - }); - await seedMigrationPackage({ - appMigrationsDir: project.appMigrationsDir, - dirName: DIR_HEAD, - from: HASH_BASE, - to: HASH_HEAD, - }); - return { ...project, baseMigrationHash: base.migrationHash }; -} - -function markersAt(storageHash: string) { - return new Map([['app', { storageHash, invariants: [] as readonly string[] }]]); -} - -const EXTERNAL_SPACE = 'external'; -const HASH_EXTERNAL_HEAD = `e0e0${'3'.repeat(60)}`; - -/** An all-external extension space: a head ref on disk and no migration packages. */ -async function addAllExternalSpace(project: OfflineProject): Promise { - await writeRef(join(project.migrationsDir, EXTERNAL_SPACE, 'refs'), 'head', { - hash: HASH_EXTERNAL_HEAD, - invariants: [], - }); - await seedContractSnapshot({ - migrationsDir: project.migrationsDir, - storageHash: HASH_EXTERNAL_HEAD, - }); -} - -function allExternalExtension(): Record { - return { - kind: 'extension', - id: EXTERNAL_SPACE, - familyId: 'sql', - targetId: 'postgres', - version: '1.0.0', - create: () => ({}), - contractSpace: { - contractJson: contractJson(HASH_EXTERNAL_HEAD), - headRef: { hash: HASH_EXTERNAL_HEAD, invariants: [] }, - migrations: [], - }, - }; -} - -function withAllExternalExtension(config: Record): Record { - return { ...config, extensions: [allExternalExtension()] }; -} - -function markersWithExternalAtHead(appHash: string) { - return new Map([ - ['app', { storageHash: appHash, invariants: [] as readonly string[] }], - [EXTERNAL_SPACE, { storageHash: HASH_EXTERNAL_HEAD, invariants: [] as readonly string[] }], - ]); -} - -function codesAndSeverities( - diagnostics: readonly Diagnostic[], -): ReadonlyArray<{ code: string; severity: string }> { - return diagnostics.map(({ code, severity }) => ({ code, severity })); -} - describe('migration status', () => { it('settles as a completed envelope carrying the status document', async () => { const project = await projectWithOneMigration(); @@ -565,244 +413,6 @@ describe('migration status', () => { expect(migrationLine).not.toContain('→'); }); - describe('reserved contract references', () => { - it('resolves --to @contract to the emitted contract, the same as no --to', async () => { - const project = await projectWithOneMigration(); - const db = fakeDatabase({ - markers: markersAt(HASH_HEAD), - ledger: [{ migrationHash: project.migrationHash }], - }); - const config = driverConfig(project, db); - - const implicit = await harness(config).run(['migration', 'status', '--json'], { - cwd: project.dir, - }); - const explicit = await harness(config).run( - ['migration', 'status', '--to', '@contract', '--json'], - { cwd: project.dir }, - ); - - expect(explicit.exitCode).toBe(0); - expect(explicit.presented?.data).toEqual(implicit.presented?.data); - expect(explicit.presented?.data).toMatchObject({ - summary: 'Up to date', - spaces: [{ targetContract: HASH_HEAD }], - }); - }); - - it('targets each extension space at its own contract for --to @contract', async () => { - const project = await projectWithOneMigration(); - await addAllExternalSpace(project); - const db = fakeDatabase({ - markers: markersWithExternalAtHead(HASH_HEAD), - ledger: [{ migrationHash: project.migrationHash }], - }); - - const config = withAllExternalExtension(driverConfig(project, db)); - - const implicit = await harness(config).run(['migration', 'status', '--json'], { - cwd: project.dir, - }); - const run = await harness(config).run( - ['migration', 'status', '--to', '@contract', '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(0); - expect(run.presented?.data).toEqual(implicit.presented?.data); - expect(run.presented?.data).toMatchObject({ - summary: 'Up to date', - diagnostics: [], - spaces: expect.arrayContaining([ - expect.objectContaining({ space: 'app', targetContract: HASH_HEAD }), - expect.objectContaining({ - space: EXTERNAL_SPACE, - currentContract: HASH_EXTERNAL_HEAD, - targetContract: HASH_EXTERNAL_HEAD, - }), - ]), - }); - }); - - it('resolves --from @contract offline', async () => { - const project = await projectWithOneMigration(); - const db = fakeDatabase(); - - const run = await harness(driverConfig(project, db)).run( - ['migration', 'status', '--from', '@contract', '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(0); - expect(db.counters.connections).toBe(0); - expect(run.presented?.data).toMatchObject({ - summary: 'Up to date', - spaces: [{ currentContract: HASH_HEAD, targetContract: HASH_HEAD }], - }); - }); - - it('resolves --to @db to the live marker and reports up to date', async () => { - const project = await projectWithTwoMigrations(); - const db = fakeDatabase({ - markers: markersAt(HASH_BASE), - ledger: [{ migrationHash: project.baseMigrationHash }], - }); - - const run = await harness(driverConfig(project, db)).run( - ['migration', 'status', '--to', '@db', '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(0); - expect(run.presented?.data).toMatchObject({ - summary: 'Up to date', - diagnostics: [], - spaces: [{ currentContract: HASH_BASE, targetContract: HASH_BASE }], - }); - }); - - it('reports the migration pending between the live marker and --to when --from is @db', async () => { - const project = await projectWithTwoMigrations(); - const db = fakeDatabase({ - markers: markersAt(HASH_BASE), - ledger: [{ migrationHash: project.baseMigrationHash }], - }); - - const run = await harness(driverConfig(project, db)).run( - ['migration', 'status', '--from', '@db', '--to', DIR_HEAD, '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(0); - expect(db.counters.connections).toBe(1); - expect(run.presented?.data).toMatchObject({ - summary: `1 pending — run \`{bin} db migrate --to ${HASH_HEAD.slice(0, 12)}\``, - spaces: [ - { - currentContract: HASH_BASE, - targetContract: HASH_HEAD, - migrations: expect.arrayContaining([ - expect.objectContaining({ name: DIR_BASE, status: 'applied' }), - expect.objectContaining({ name: DIR_HEAD, status: 'pending' }), - ]), - }, - ], - }); - }); - - it('reads the database only for the target when --from is a hash and --to is @db', async () => { - const project = await projectWithTwoMigrations(); - const db = fakeDatabase({ - markers: markersAt(HASH_HEAD), - ledger: [{ migrationHash: project.baseMigrationHash }], - }); - - const run = await harness(driverConfig(project, db)).run( - ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(0); - expect(db.counters.connections).toBe(1); - expect(run.presented?.data).toMatchObject({ - spaces: [ - { - currentContract: HASH_BASE, - targetContract: HASH_HEAD, - migrations: expect.not.arrayContaining([ - expect.objectContaining({ status: 'applied' }), - ]), - }, - ], - }); - }); - - it('reports no path when --from @contract is ahead of the database named by --to @db', async () => { - const project = await projectWithTwoMigrations(); - const db = fakeDatabase({ - markers: markersAt(HASH_BASE), - ledger: [{ migrationHash: project.baseMigrationHash }], - }); - - const run = await harness(driverConfig(project, db)).run( - ['migration', 'status', '--from', '@contract', '--to', '@db', '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(0); - expect(run.presented?.data).toMatchObject({ - summary: `No migration path from the --from contract (${HASH_HEAD.slice(0, 12)}) to the target (${HASH_BASE.slice(0, 12)}). Run \`{bin} migration plan --name \` to author one, or pass \`--to \` to pick a reachable target.`, - }); - }); - - it('warns when --to @db reads a marker outside the graph and --from is a hash', async () => { - const project = await projectWithTwoMigrations(); - const db = fakeDatabase({ markers: markersAt(HASH_UNKNOWN) }); - - const run = await harness(driverConfig(project, db)).run( - ['migration', 'status', '--from', HASH_BASE, '--to', '@db', '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(0); - expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ - { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, - ]); - expect(run.presented?.data).toMatchObject({ - summary: `Database marker ${HASH_UNKNOWN.slice(0, 12)} is not in the on-disk migration graph`, - }); - }); - - it('errors with the connection-required envelope for --to @db without a connection', async () => { - const project = await projectWithOneMigration(); - const config = driverConfig(project); - - const run = await harness({ ...config, db: undefined }).run( - ['migration', 'status', '--from', HASH_HEAD, '--to', '@db', '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(2); - expect(run.json.at(-1)).toMatchObject({ - kind: 'result', - envelope: { - ok: false, - error: { - code: 'CONFIG.DB_CONNECTION_REQUIRED', - why: expect.stringContaining('@db'), - meta: { missingFlags: ['--db'] }, - nextActions: [ - expect.objectContaining({ - label: expect.stringContaining( - `migration status --from ${HASH_HEAD} --to @db --db $DATABASE_URL`, - ), - }), - ], - }, - }, - }); - }); - - it('errors with the connection-required envelope for --from @db without a connection', async () => { - const project = await projectWithOneMigration(); - const config = driverConfig(project); - - const run = await harness({ ...config, db: undefined }).run( - ['migration', 'status', '--from', '@db', '--json'], - { cwd: project.dir }, - ); - - expect(run.exitCode).toBe(2); - expect(run.json.at(-1)).toMatchObject({ - kind: 'result', - envelope: { - ok: false, - error: { code: 'CONFIG.DB_CONNECTION_REQUIRED', meta: { missingFlags: ['--db'] } }, - }, - }); - }); - }); - it('closes the connection and keeps the structured error when the marker read fails', async () => { const project = await projectWithOneMigration(); const db = fakeDatabase({ From c77ea066da629312703b18c6eb0419a01615a692 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:23:27 +0200 Subject: [PATCH 26/37] fix(migration-tools): the head of any space with no migrations counts as part of its history An app space with no migrations whose marker is the emitted contract is the state db update, db sign and contract infer leave behind. migration status stays quiet there, as on main; warning in that state told the user to run db sign or db update, the command they had just run. A marker that is any other contract still warns. The db sign integration test is back to its main version. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/orm/migration/status.ts | 6 +----- .../cli/test/orm/migration-status.test.ts | 14 +++++++++++++- .../3-tooling/migration/src/graph-membership.ts | 10 +++------- .../migration/test/graph-membership.test.ts | 14 +++++++------- .../test/cli.db-sign-ref-advancement.e2e.test.ts | 6 ++---- 5 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 1f3db097ac2f..6dc8d1a0ae97 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -415,11 +415,7 @@ export const migrationStatusCommand = defineOrmCommand({ : undefined; const markerDiverged = readMarker !== undefined && - !isInSpaceHistory(readMarker.storageHash, { - graph, - headHash: space.headRef?.hash, - isExtension: !isAppSpace, - }); + !isInSpaceHistory(readMarker.storageHash, { graph, headHash: space.headRef?.hash }); if (markerDiverged) { divergedMarker ??= { space: entry.space, markerHash: readMarker.storageHash }; diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 016597e057e1..a2051311ca0c 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -120,7 +120,7 @@ describe('migration status', () => { }); }); - it('warns about an app marker when the app space has no migrations', async () => { + it('stays quiet when the app space has no migrations and the marker is the emitted contract', async () => { const project = await createOfflineProject({ storageHash: HASH_HEAD }); const db = fakeDatabase({ markers: markersAt(HASH_HEAD) }); @@ -128,6 +128,18 @@ describe('migration status', () => { cwd: project.dir, }); + expect(run.exitCode).toBe(0); + expect(run.presented?.diagnostics ?? []).toEqual([]); + }); + + it('warns when the app space has no migrations and the marker is another contract', async () => { + const project = await createOfflineProject({ storageHash: HASH_HEAD }); + const db = fakeDatabase({ markers: markersAt(HASH_UNKNOWN) }); + + const run = await harness(driverConfig(project, db)).run(['migration', 'status', '--json'], { + cwd: project.dir, + }); + expect(run.exitCode).toBe(0); expect(codesAndSeverities(run.presented?.diagnostics ?? [])).toEqual([ { code: 'MIGRATION.MARKER_NOT_IN_HISTORY', severity: 'warn' }, diff --git a/packages/1-framework/3-tooling/migration/src/graph-membership.ts b/packages/1-framework/3-tooling/migration/src/graph-membership.ts index 23bb9b9f12e5..6c277dd44396 100644 --- a/packages/1-framework/3-tooling/migration/src/graph-membership.ts +++ b/packages/1-framework/3-tooling/migration/src/graph-membership.ts @@ -9,19 +9,15 @@ export function isGraphNode(hash: string, graph: MigrationGraph): boolean { return graph.nodes.has(hash); } -/** True when a marker hash is in a space's history: a graph node, or the head of an extension space that ships no migrations. */ +/** True when a marker hash is in a space's history: a graph node, or the head of a space that has no migrations. */ export function isInSpaceHistory( hash: string, - space: { - readonly graph: MigrationGraph; - readonly headHash: string | undefined; - readonly isExtension: boolean; - }, + space: { readonly graph: MigrationGraph; readonly headHash: string | undefined }, ): boolean { if (isGraphNode(hash, space.graph)) { return true; } - return space.isExtension && space.graph.nodes.size === 0 && hash === space.headHash; + return space.graph.nodes.size === 0 && hash === space.headHash; } export function assertHashIsGraphNode(hash: string, graph: MigrationGraph): asserts hash is string { diff --git a/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts b/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts index 18265b04b7af..5c521fec28fc 100644 --- a/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts +++ b/packages/1-framework/3-tooling/migration/test/graph-membership.test.ts @@ -58,22 +58,22 @@ describe('isGraphNode', () => { describe('isInSpaceHistory', () => { it('counts a node of the space graph', () => { const graph = reconstructGraph(chain([E, 'aaa', 'm1'])); - expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa', isExtension: false })).toBe(true); + expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa' })).toBe(true); }); - it('counts the head of an extension space that ships no migrations', () => { + it('counts the head of a space that has no migrations', () => { const graph = reconstructGraph([]); - expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa', isExtension: true })).toBe(true); + expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa' })).toBe(true); }); - it('does not count the head of an app space that has no migrations', () => { + it('does not count another hash in a space that has no migrations', () => { const graph = reconstructGraph([]); - expect(isInSpaceHistory('aaa', { graph, headHash: 'aaa', isExtension: false })).toBe(false); + expect(isInSpaceHistory('bbb', { graph, headHash: 'aaa' })).toBe(false); }); - it('does not count a head that is not a node when the extension space has migrations', () => { + it('does not count a head that is not a node when the space has migrations', () => { const graph = reconstructGraph(chain([E, 'aaa', 'm1'])); - expect(isInSpaceHistory('bbb', { graph, headHash: 'bbb', isExtension: true })).toBe(false); + expect(isInSpaceHistory('bbb', { graph, headHash: 'bbb' })).toBe(false); }); }); diff --git a/test/integration/test/cli.db-sign-ref-advancement.e2e.test.ts b/test/integration/test/cli.db-sign-ref-advancement.e2e.test.ts index ef71302d2911..280ddf513a39 100644 --- a/test/integration/test/cli.db-sign-ref-advancement.e2e.test.ts +++ b/test/integration/test/cli.db-sign-ref-advancement.e2e.test.ts @@ -144,7 +144,7 @@ withTempDir(({ createTempDir }) => { ); it( - 'migration status warns after signing a project with no migrations, as db migrate refuses', + 'migration status reports up to date after signing', async () => { await withDevDatabase(async ({ connectionString }) => { const ctx = await setupInferredProject(connectionString, createTempDir); @@ -161,9 +161,7 @@ withTempDir(({ createTempDir }) => { targetContract: signedHash, migrations: [], }); - expect(statusJson.diagnostics?.map((diagnostic) => diagnostic.code)).toEqual([ - 'MIGRATION.MARKER_NOT_IN_HISTORY', - ]); + expect(statusJson.diagnostics ?? []).toEqual([]); }); }, timeouts.spinUpPpgDev, From 3e82929cdd0fad3ece5f87cba0b65e6538d041d2 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:50:20 +0200 Subject: [PATCH 27/37] fix(cli): label @db only in the trees whose plan uses the database marker db migrate --show returned two fields for the @db label: a per-space map that could not say "not read", and a flag saying whether the map meant anything. The handler ignored both and decided the database header line from the flags. One optional field, databaseMarkerHashBySpace, replaces them. It is absent when the preview did not read the database. When present, it holds the marker hash of each space whose plan uses the marker: every space for a live origin, and only the app space for an offline origin with --to @db. The renderer draws @db only for spaces in the map, and the handler prints the database line when the field is present. With --from @empty --to @db, extension trees no longer show @db at a marker the plan ignores. migration status follows the same rule: with an offline --from and --to @db, the app tree now labels the marker @db. The extension-space --show tests move to their own file so migrate-show.test.ts stays under 500 lines. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/migrate-show.ts | 23 +++-- .../3-tooling/cli/src/orm/migrate.ts | 3 +- .../3-tooling/cli/src/orm/migration/status.ts | 2 +- .../utils/formatters/migrate-show-render.ts | 6 +- .../control-api/migrate-show-plan.test.ts | 5 +- .../test/orm/migrate-show-extensions.test.ts | 95 +++++++++++++++++++ .../cli/test/orm/migrate-show.test.ts | 43 --------- .../migration-status-contract-refs.test.ts | 18 ++++ 8 files changed, 133 insertions(+), 62 deletions(-) create mode 100644 packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index 4c8097ca6518..fce7d6abec43 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -13,6 +13,7 @@ import { EMPTY_CONTRACT_HASH } from '@internal/migration-tools/constants'; import { MigrationToolsError } from '@internal/migration-tools/errors'; import type { Refs } from '@internal/migration-tools/refs'; import { readRefs } from '@internal/migration-tools/refs'; +import { ifDefined } from '@internal/utils/defined'; import { notOk, ok, type Result } from '@internal/utils/result'; import { type CliStructuredError, @@ -69,10 +70,8 @@ export interface MigrateShowPlanSuccess { readonly contractHash: string; readonly migrations: readonly MigrateShowMigration[]; readonly summary: string; - /** Each space's database marker hash for the tree's `@db` label; the empty sentinel when the space has no marker or the database was not read. */ - readonly renderMarkerHashBySpace: ReadonlyMap; - /** True when the preview read the database markers; only then does the tree mark `@db`. */ - readonly databaseMarkersRead: boolean; + /** Present when the preview read the database: the marker hash, or the empty contract, of each space whose plan uses the marker. */ + readonly databaseMarkerHashBySpace?: ReadonlyMap; } /** @@ -312,16 +311,22 @@ export async function executeMigrateShowPlan( ? 'Already up to date — nothing to run' : `${count} migration${count === 1 ? '' : 's'} will run`; - const renderMarkerHashBySpace = new Map( - allSpaces.map((s) => [s.spaceId, contractHashAtMarker(databaseMarkers?.get(s.spaceId))]), - ); + const spacesUsingMarker = liveOrigin ? allSpaces : [aggregate.app]; + const databaseMarkerHashBySpace = + databaseMarkers === undefined + ? undefined + : new Map( + spacesUsingMarker.map((space) => [ + space.spaceId, + contractHashAtMarker(databaseMarkers.get(space.spaceId)), + ]), + ); return ok({ aggregate, contractHash, migrations: orderedMigrations, summary, - renderMarkerHashBySpace, - databaseMarkersRead: databaseMarkers !== undefined, + ...ifDefined('databaseMarkerHashBySpace', databaseMarkerHashBySpace), }); } diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 751459d5c1cf..97e326b47bd4 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -31,7 +31,6 @@ import { preflightRefAdvancement, } from '../control-api/operations/ref-advancement'; import { - liveMarkerUse, type RefResolutionContext, resolveContractRef, } from '../control-api/operations/ref-resolution'; @@ -296,7 +295,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { runList: migrateShowRunListRows(plan.migrations, rendering, paint), migrationsDir: migrationsRelative, database: - liveMarkerUse(args.flags).needsDatabase && typeof dbConnection === 'string' + plan.databaseMarkerHashBySpace !== undefined && typeof dbConnection === 'string' ? maskConnectionUrl(dbConnection) : undefined, from: args.flags.from, diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 6dc8d1a0ae97..4390838555d2 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -473,7 +473,7 @@ export const migrationStatusCommand = defineOrmCommand({ styler, palette: TONE_MIGRATION_GRAPH_PALETTE, isAppSpace, - ...(liveOrigin && markerHash !== undefined ? { dbHash: markerHash } : {}), + ...ifDefined('dbHash', readMarker?.storageHash), }); } diff --git a/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts b/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts index 71609a458f44..19127a33dbe8 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/formatters/migrate-show-render.ts @@ -101,15 +101,13 @@ export function renderMigrateShowGraph( const sections: string[] = []; for (const { space, isApp, rowModel, grid, edgeAnnotations } of spaceLayouts) { - const liveMarkerHash = plan.renderMarkerHashBySpace.get(space.spaceId); + const databaseMarkerHash = plan.databaseMarkerHashBySpace?.get(space.spaceId); const tree = renderMigrationGraphCommand({ grid, rowModel, contractHash, isAppSpace: isApp, - ...(plan.databaseMarkersRead && liveMarkerHash !== undefined - ? { dbHash: liveMarkerHash } - : {}), + ...(databaseMarkerHash === undefined ? {} : { dbHash: databaseMarkerHash }), refsByHash: listRefsByContractHash(space), edgeAnnotationsByHash: edgeAnnotations, colorize: options.colorize, diff --git a/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts index ec20ed971b16..fffce8d677b4 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/migrate-show-plan.test.ts @@ -136,13 +136,13 @@ describe('executeMigrateShowPlan', () => { }, ]); expect(result.value.summary).toBe('1 migration will run'); - expect(result.value.databaseMarkersRead).toBe(false); + expect(result.value.databaseMarkerHashBySpace).toBeUndefined(); expect(result.value.contractHash).toBe(HASH_B); } expect(mocks.createControlClient).not.toHaveBeenCalled(); }); - it('defaults the per-space render marker hash to the empty sentinel', async () => { + it('plans every migration from the empty contract offline', async () => { const result = await executeMigrateShowPlan({ config, cwd: tempDir, @@ -150,7 +150,6 @@ describe('executeMigrateShowPlan', () => { }); expect(result.ok).toBe(true); if (result.ok) { - expect(result.value.renderMarkerHashBySpace.get('app')).toBe(EMPTY_CONTRACT_HASH); expect(result.value.migrations.map((m) => m.dirName)).toEqual([firstDirName, secondDirName]); expect(result.value.migrations.map((m) => m.migrationHash)).toEqual([ firstMigrationHash, diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts new file mode 100644 index 000000000000..53f17f14f509 --- /dev/null +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + addExtensionSpace, + buildProject, + C1, + C2, + drawingLines, + EMPTY, + EXT_C1, + harness, + mocks, + ormConfig, + pgvectorExtension, + removeMigrateShowProjects, + resetMigrateShowMocks, +} from './fixtures/migrate-show-project'; + +afterEach(removeMigrateShowProjects); +beforeEach(resetMigrateShowMocks); + +describe('migrate --show with extension spaces', () => { + it('plans extensions from their own state, never from the app --from hash', async () => { + const cwd = await buildProject(); + const extDirName = await addExtensionSpace(cwd); + + const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( + ['db', 'migrate', '--show', '--from', C1.slice(7, 13), '--to', C2.slice(7, 13), '--json'], + { cwd }, + ); + const document = run.presented?.data as { + migrations: ReadonlyArray<{ spaceId: string; dirName: string; from: string }>; + }; + + expect(run.exitCode).toBe(0); + expect(document.migrations).toContainEqual( + expect.objectContaining({ spaceId: 'pgvector', dirName: extDirName, from: EMPTY }), + ); + expect(document.migrations).not.toContainEqual( + expect.objectContaining({ spaceId: 'app', from: EMPTY }), + ); + }); + + it('orders extension migrations before app migrations, matching the runner', async () => { + const cwd = await buildProject(); + await addExtensionSpace(cwd); + + const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( + ['db', 'migrate', '--show', '--from', EMPTY, '--json'], + { cwd }, + ); + const document = run.presented?.data as { + migrations: ReadonlyArray<{ spaceId: string }>; + }; + + expect(run.exitCode).toBe(0); + expect(document.migrations.map((migration) => migration.spaceId)).toEqual([ + 'pgvector', + 'app', + 'app', + ]); + }); + + it.each([ + { argv: [], extensionLabelled: true }, + { argv: ['--from', '@db'], extensionLabelled: true }, + { argv: ['--from', '@empty', '--to', '@db'], extensionLabelled: false }, + ])( + 'labels @db in an extension tree only when the plan starts from its marker: $argv', + async ({ argv, extensionLabelled }) => { + const cwd = await buildProject(); + await addExtensionSpace(cwd); + mocks.readAllMarkers.mockResolvedValue( + new Map([ + ['app', { storageHash: C1, invariants: [] }], + ['pgvector', { storageHash: EXT_C1, invariants: [] }], + ]), + ); + + const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( + ['db', 'migrate', '--show', ...argv], + { cwd, isTty: { stdout: true } }, + ); + const graph = run.presented?.presentation.human.find((block) => block.kind === 'drawing'); + const lines = drawingLines(graph === undefined ? [] : [graph]); + const extensionStart = lines.indexOf('pgvector:'); + const appLines = lines.slice(0, extensionStart); + const extensionLines = lines.slice(extensionStart); + + expect(run.exitCode).toBe(0); + expect(extensionStart).toBeGreaterThan(0); + expect(appLines.filter((line) => line.includes('@db'))).toHaveLength(1); + expect(extensionLines.some((line) => line.includes('@db'))).toBe(extensionLabelled); + }, + ); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 95e9528d1609..404837a47bfe 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -288,49 +288,6 @@ describe('migrate --show', () => { }); }); - describe('extension spaces', () => { - it('plans extensions from their own state, never from the app --from hash', async () => { - const cwd = await buildProject(); - const extDirName = await addExtensionSpace(cwd); - - const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( - ['db', 'migrate', '--show', '--from', C1.slice(7, 13), '--to', C2.slice(7, 13), '--json'], - { cwd }, - ); - const document = run.presented?.data as { - migrations: ReadonlyArray<{ spaceId: string; dirName: string; from: string }>; - }; - - expect(run.exitCode).toBe(0); - expect(document.migrations).toContainEqual( - expect.objectContaining({ spaceId: 'pgvector', dirName: extDirName, from: EMPTY }), - ); - expect(document.migrations).not.toContainEqual( - expect.objectContaining({ spaceId: 'app', from: EMPTY }), - ); - }); - - it('orders extension migrations before app migrations, matching the runner', async () => { - const cwd = await buildProject(); - await addExtensionSpace(cwd); - - const run = await harness(ormConfig(cwd, { extensions: [pgvectorExtension()] })).run( - ['db', 'migrate', '--show', '--from', EMPTY, '--json'], - { cwd }, - ); - const document = run.presented?.data as { - migrations: ReadonlyArray<{ spaceId: string }>; - }; - - expect(run.exitCode).toBe(0); - expect(document.migrations.map((migration) => migration.spaceId)).toEqual([ - 'pgvector', - 'app', - 'app', - ]); - }); - }); - describe('the preview', () => { it('previews the route without applying anything', async () => { const cwd = await buildProject(); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts index f979a4af8fd2..9fd49e8c6ff1 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts @@ -1,3 +1,4 @@ +import stripAnsi from 'strip-ansi'; import { afterEach, describe, expect, it } from 'vitest'; import { removeOfflineProjects } from './fixtures/offline-project'; import { @@ -171,6 +172,23 @@ describe('migration status with reserved contract references', () => { }); }); + it('labels the database marker @db in the tree when --from is a hash and --to is @db', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase({ markers: markersAt(HASH_BASE) }); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--from', HASH_BASE, '--to', '@db'], + { cwd: project.dir, isTty: { stdout: true, stderr: true } }, + ); + const dbLines = stripAnsi(run.stderr) + .split('\n') + .filter((line) => line.includes('@db')); + + expect(run.exitCode).toBe(0); + expect(dbLines).toHaveLength(1); + expect(dbLines[0]).toContain(HASH_BASE.slice(0, 7)); + }); + it('reports no path when --from @contract is ahead of the database named by --to @db', async () => { const project = await projectWithTwoMigrations(); const db = fakeDatabase({ From d619d7c4a065bfd09b737992ba447a1fb69511ad Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:50:58 +0200 Subject: [PATCH 28/37] docs: with an offline --from, extension spaces start from the empty contract The subsystem doc, the CLI README, a comment in migrate-show.ts and a test name said each extension space goes from its own marker. That is true only when the command reads the database for the origin (no --from, or --from @db). When --from names a contract, extension spaces are planned from the empty contract to their own head. The text and the test name now say so. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture docs/subsystems/7. Migration System.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 2 +- .../cli/src/control-api/operations/migrate-show.ts | 7 +++---- .../3-tooling/cli/test/orm/migrate-show-extensions.test.ts | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index e63f444593ff..ce11e76ec789 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -340,7 +340,7 @@ A contract reference names one contract. Five forms name a contract recorded in | `migration plan --from` | no | no | yes | | `migration plan --to`, `migration ref set`, `db update --to`, `db sign` | no | no | no | -`db update --to` and `db sign` refuse the reserved tokens with `MIGRATION.REF_WRONG_GRAMMAR`, as `migration plan --to` refuses `@empty`. In `migration status`, `db migrate` and `db migrate --show`, `--from` and `--to` apply to the app space; each extension space goes from its own marker to its own head. +`db update --to` and `db sign` refuse the reserved tokens with `MIGRATION.REF_WRONG_GRAMMAR`, as `migration plan --to` refuses `@empty`. In `migration status`, `db migrate` and `db migrate --show`, `--from` and `--to` apply to the app space. Each extension space goes to its own head: from its own marker when the command reads the database for the origin (no `--from`, or `--from @db`), and from the empty contract when `--from` names a contract. #### Contract resolution through the snapshot store diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index f807d83ddb4d..0c92f7dbd299 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1155,7 +1155,7 @@ prisma migration status [--db ] [--to ] [--from ] [--sp - `-q, --quiet`: Quiet mode (errors only) - `-v, --verbose`: Verbose output -`@db` in either `--to` or `--from` resolves to the database marker, so the command reads the database and needs a connection. `--to` and `--from` apply to the app space; each extension space is checked from its own marker to its own head. +`@db` in either `--to` or `--from` resolves to the database marker, so the command reads the database and needs a connection. `--from` and `--to` apply to the app space. Each extension space goes to its own head: from its own marker when the command reads the database for the origin (no `--from`, or `--from @db`), and from the empty contract when `--from` names a contract. **What it does:** 1. Reads migration packages from disk and reconstructs each space's migration graph diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index fce7d6abec43..e30fa3de5c77 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -174,10 +174,9 @@ export async function executeMigrateShowPlan( if (!fromResult.ok) { return notOk(fromResult.failure); } - // Offline hypothetical: the --from ref only carries a hash (no live invariants). - // Apply the from-hash marker to the APP space only. Extension spaces are left - // absent from markerBySpace (treated as null / greenfield by planSpacePath), - // so they plan from their own marker → own head — exactly as executeMigrate does. + // The --from contract carries a hash and no invariants, and applies to the app + // space only. Extension spaces stay out of markerBySpace, so they plan from the + // empty contract to their own head. const fromHash = fromResult.value.hash; const offlineMarker: LiveMarker | null = fromHash === EMPTY_CONTRACT_HASH ? null : { storageHash: fromHash, invariants: [] }; diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts index 53f17f14f509..8d0949ce77b4 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show-extensions.test.ts @@ -19,7 +19,7 @@ afterEach(removeMigrateShowProjects); beforeEach(resetMigrateShowMocks); describe('migrate --show with extension spaces', () => { - it('plans extensions from their own state, never from the app --from hash', async () => { + it('plans extensions from the empty contract, never from the app --from hash', async () => { const cwd = await buildProject(); const extDirName = await addExtensionSpace(cwd); From 5089c880a4a3c3783091561f1b72ff691cad5ba7 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:52:36 +0200 Subject: [PATCH 29/37] test(cli): pin db migrate --show refusing a marker on a project with no migrations On a project with no app migrations whose marker is the emitted contract, db migrate --show now refuses with MIGRATION.MARKER_MISMATCH, as db migrate does. A preview predicts what the apply does, so the refusal stays. A test pins it: exit 2, the marker hash, and no reachable hashes. The skill references and the subsystem doc now name db migrate --show wherever they list the commands that raise MIGRATION.MARKER_MISMATCH. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 4 ++-- .../test/orm/fixtures/migrate-show-project.ts | 9 +++++++ .../cli/test/orm/migrate-show.test.ts | 24 +++++++++++++++++++ skills/prisma-8/references/debug.md | 2 +- .../prisma-8/references/migration-review.md | 4 ++-- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index ce11e76ec789..d1ea6858bdfa 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -607,7 +607,7 @@ Structured diagnostics from plan-time and apply-time checks suggest concrete rec |---|---|---| | `MIGRATION.HASH_NOT_IN_GRAPH` | `migration plan` or `migration ref set`: resolved hash not in graph | `migration plan --from ` (e.g. `--from production`) | | `MIGRATION.SNAPSHOT_MISSING` | `migration plan`: a named ref has no pointer file, and the hash being resolved isn't a graph node either | `migration ref set ` to create the ref, `db update --advance-ref ` to advance it, or pass a hash that is a graph node | -| `MIGRATION.MARKER_MISMATCH` | `db migrate`: live marker hash not a graph node (pre-DDL check) | `migration plan --from `, or `migration ref set db ` if on-disk graph is canonical | +| `MIGRATION.MARKER_MISMATCH` | `db migrate` (pre-DDL check) and `db migrate --show`: live marker hash not a graph node | `migration plan --from `, or `migration ref set db ` if on-disk graph is canonical | | `MIGRATION.PATH_UNREACHABLE` | `db migrate`: no path from marker to target in on-disk graph | Plan the missing edge with `migration plan --from --to --name `, then apply with `db migrate --to `. For a rollback, use `--to ^` in both steps — the planned reverse edge applies and moves the marker back without editing contract source. Review destructive (`DROP`) ops in the plan before applying. When the space has no on-disk migrations yet, omit `--from` and use `migration plan --to --name ` first. | After plain `db migrate`, refresh a stale `db` ref with `db update` (no-op on DB when marker matches) or `db migrate --advance-ref db` in the same invocation. @@ -787,7 +787,7 @@ Errors use the stable category/code envelope (see [ADR 027 — Error Envelope & - `MIGRATION.SAME_SOURCE_AND_TARGET` — migration edge has `from === to` (graph invariant violation) - `MIGRATION.HASH_NOT_IN_GRAPH` — resolved hash is not a node in the on-disk graph (plan-time / `migration ref set`) - `MIGRATION.SNAPSHOT_MISSING` — a named ref has no pointer file, and the hash being resolved isn't a graph node either (plan-time) -- `MIGRATION.MARKER_MISMATCH` — live DB marker hash is not a graph node (apply-time, pre-DDL) +- `MIGRATION.MARKER_MISMATCH` — live DB marker hash is not a graph node (`db migrate` before any DDL, and `db migrate --show`) - `MIGRATION.PATH_UNREACHABLE` — no migration path from marker to target (apply-time; improved `fix` payload) **Authoring errors** (`PN-MIG-*`): diff --git a/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts b/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts index ddda3a96701a..e7f93cb12b80 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/fixtures/migrate-show-project.ts @@ -100,6 +100,15 @@ export async function buildProject(): Promise { return cwd; } +/** A project whose app space has no migrations, with the emitted contract at C2. */ +export async function buildProjectWithoutMigrations(): Promise { + const cwd = createTestProjectDir('orm-migrate-show'); + tempDirs.push(cwd); + await mkdir(join(cwd, 'migrations', 'app'), { recursive: true }); + await writeFile(join(cwd, 'contract.json'), JSON.stringify(contractEnvelope(C2))); + return cwd; +} + /** Adds a declared pgvector space with its own empty → EXT_C1 graph. */ export async function addExtensionSpace(cwd: string): Promise { const extDir = join(cwd, 'migrations', 'pgvector'); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts index 404837a47bfe..f9640f12aaec 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-show.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { addExtensionSpace, buildProject, + buildProjectWithoutMigrations, C1, C2, drawingLines, @@ -236,6 +237,29 @@ describe('migrate --show', () => { }); }); + it('refuses a marker at the emitted contract when the app space has no migrations', async () => { + const cwd = await buildProjectWithoutMigrations(); + mocks.readAllMarkers.mockResolvedValue( + new Map([['app', { storageHash: C2, invariants: [] }]]), + ); + + const run = await harness(ormConfig(cwd)).run(['db', 'migrate', '--show', '--json'], { + cwd, + }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'MIGRATION.MARKER_MISMATCH', + meta: { markerHash: C2, reachableHashes: [] }, + }, + }, + }); + }); + it.each([ { argv: ['--from', '@db'], named: true }, { argv: ['--from', EMPTY, '--to', '@db'], named: true }, diff --git a/skills/prisma-8/references/debug.md b/skills/prisma-8/references/debug.md index f5c554f65b0c..8ceccb1cee2d 100644 --- a/skills/prisma-8/references/debug.md +++ b/skills/prisma-8/references/debug.md @@ -100,7 +100,7 @@ The single source of truth: read the envelope, find the row by `code`, follow th | `MIGRATION.NO_INVARIANT_PATH` / `MIGRATION.UNKNOWN_INVARIANT` | `db migrate` | Concurrent-migration and invariant flows — `references/migration-review.md`. | | `DRIVER.NOT_CONNECTED` with message `Runtime is closed` | A query, prepared statement or `db.runtime().connection()` that starts after `db.close()` and after the runtime was idle for one turn of the event loop, or after the `await using` scope that held a serverless connection ended | The work started after the runtime closed. Almost always it is a lazy read (`db.orm...all()`, `db.runtime().query(...)`) returned without `await`, which starts only when the caller awaits it, or work that waited on something other than the database first. Write `return await ...`, or move the call before `close()`. See `references/runtime.md` § *Workflow — Serverless and per-request runtimes*. | | `await db.close()` or the end of an `await using` scope never settles in a test | A test that installed fake timers (`vi.useFakeTimers()`, Jest's modern fake timers) before the Prisma runtime module was imported | `close()` waits one turn of the event loop through a `setTimeout(0)` that the runtime module takes when it loads. Install fake timers after the imports, or advance the fake clock (`await vi.runAllTimersAsync()`) before awaiting the close. | -| `MIGRATION.PATH_UNREACHABLE` / `MIGRATION.MARKER_MISMATCH` | `db migrate` | Run `db migrate --show --db $URL` to inspect the path, then `migration plan --from --to ` or `migration list` to audit the graph — see `references/migration-review.md`. | +| `MIGRATION.PATH_UNREACHABLE` / `MIGRATION.MARKER_MISMATCH` | `db migrate`; `db migrate --show` raises `MARKER_MISMATCH` too | For `PATH_UNREACHABLE`, run `db migrate --show --db $URL` to inspect the path. Then run `migration plan --from --to `, or `migration list` to audit the graph — see `references/migration-review.md`. | | `MIGRATION.PLAN_ORIGIN_UNKNOWN` / `MIGRATION.HASH_NOT_IN_GRAPH` / `MIGRATION.SNAPSHOT_MISSING` | `migration plan`, `migration new` | Origin resolution — `references/migration-model.md` § *The trap* and `references/migrations.md` § *Dev → ship transition*. | | `MIGRATION.MISSING_INVARIANTS` | `migration status` `warn` diagnostic (exit 0) | The live marker reached the destination hash structurally but doesn't carry all invariants the target ref requires. Run `db migrate --to --db $URL` to take a path that covers the missing invariants. See `references/migration-review.md`. | | `MIGRATION.MARKER_NOT_IN_HISTORY` / `CONTRACT.UNREADABLE` | `migration status` `warn` diagnostics (exit 0) | Read `severity` *and* `code`. Up-to-date / pending / no-marker states are not codes — read `spaces[].currentContract` and `migrations[].status` in the `--json` document. `references/migration-review.md` covers the marker-out-of-history flow. | diff --git a/skills/prisma-8/references/migration-review.md b/skills/prisma-8/references/migration-review.md index 35ce48dc0470..5247ad5fc7c4 100644 --- a/skills/prisma-8/references/migration-review.md +++ b/skills/prisma-8/references/migration-review.md @@ -58,7 +58,7 @@ The graph is a static, committed artifact. Several branch tips may coexist, roll | Code | Meaning in the navigation model | Next move | |---|---|---| -| `MIGRATION.MARKER_NOT_IN_HISTORY` | Online; marker hash is not a node in the graph. The database was changed outside the migration system. | Decide which side is truth: `db sign` (accept DB as truth), `db update` (push contract to DB), `contract infer` (re-derive contract from DB), or `db verify` (inspect first). **Not** the same as `MIGRATION.MARKER_MISMATCH`, which `db migrate` raises as an error before any DDL when the marker hash is not a graph node. | +| `MIGRATION.MARKER_NOT_IN_HISTORY` | Online; marker hash is not a node in the graph. The database was changed outside the migration system. | Decide which side is truth: `db sign` (accept DB as truth), `db update` (push contract to DB), `contract infer` (re-derive contract from DB), or `db verify` (inspect first). **Not** the same as `MIGRATION.MARKER_MISMATCH`, which `db migrate` (before any DDL) and `db migrate --show` raise as an error when the marker hash is not a graph node. | | `MIGRATION.MISSING_INVARIANTS` | Marker reached the destination structurally but lacks invariants the target ref declares. | `db migrate --to --db $URL` to take a path that covers them. | | `CONTRACT.UNREADABLE` | `contract.json` couldn't be read. | `contract emit` to regenerate it. | @@ -81,7 +81,7 @@ These codes surface on `migration plan`, `migration ref set`, and `db migrate` |---|---|---|---| | `MIGRATION.HASH_NOT_IN_GRAPH` | `migration plan` (non-empty graph) or `migration ref set` | Resolved hash is not a node in the on-disk migration graph — typical when the default `db` ref points past the graph tip after dev-only `db update` cycles. | `migration plan --from ` (e.g. `--from production`); or realign the ref with `migration ref set db `. | | `MIGRATION.SNAPSHOT_MISSING` | `migration plan` | A named ref has no pointer file (`.json`), and the hash being resolved isn't a node in the migration graph either. | `migration ref set ` to create the ref, `db update --advance-ref ` to advance it, or pass a hash that is a graph node. | -| `MIGRATION.MARKER_MISMATCH` | `db migrate` (pre-DDL, before the runner) | Live DB marker hash is not a graph node — drift the offline planner cannot see. | `migration plan --from ` if the marker is canonical; `migration ref set db ` if the on-disk graph is canonical; investigate out-of-band applies. | +| `MIGRATION.MARKER_MISMATCH` | `db migrate` (pre-DDL, before the runner), `db migrate --show` | Live DB marker hash is not a graph node — drift the offline planner cannot see. | `migration plan --from ` if the marker is canonical; `migration ref set db ` if the on-disk graph is canonical; investigate out-of-band applies. | | `MIGRATION.PATH_UNREACHABLE` | `db migrate` (path resolution) | No migration path from the current marker to the resolved target in the on-disk graph. | Read the improved `fix` payload — it names `fromHash` / `targetHash` and suggests `migration plan --from --to `; run `migration list` to inspect the graph. | ## Workflow — *"What's about to run on deploy?"* From cfb5c4a9fc3a31cd2370e592d3360e8ea4132ce4 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 09:55:59 +0200 Subject: [PATCH 30/37] fix(cli): db migrate and db update repeat the user flags in the missing-connection retry Without a connection, db migrate --to production suggested `db migrate --db `, and db update --to suggested `db update --db `. Running either would go to the emitted contract instead of the target the user asked for. db migrate --to @db --advance-ref x dropped --advance-ref the same way. One function, retryCommandFor, now builds every missing-connection retry. It repeats the user's --from, --to and --advance-ref as given. For a command that can run offline, it adds --from when the user gave no origin and no flag is @db. It adds --db $DATABASE_URL when the retry needs a connection. requireDatabaseForLiveMarkerUse uses it and loses its offlineRetry input, which every caller set. db migrate and db update always pass a retry built from their own flags. db update keeps --dry-run in the retry too, so a preview is never turned into an apply. Signed-off-by: willbot Signed-off-by: Will Madden --- .../control-api/operations/migrate-show.ts | 1 - .../control-api/operations/ref-resolution.ts | 46 +++++++++----- .../3-tooling/cli/src/orm/db/update.ts | 7 +++ .../3-tooling/cli/src/orm/migrate.ts | 13 ++-- .../3-tooling/cli/src/orm/migration/status.ts | 1 - .../test/control-api/ref-resolution.test.ts | 32 ++++++++++ .../test/orm/db-update-to-resolution.test.ts | 37 +++++++++++ .../cli/test/orm/migrate-to-contract.test.ts | 63 +++++++++++-------- 8 files changed, 153 insertions(+), 47 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts index e30fa3de5c77..66b06775358c 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/migrate-show.ts @@ -108,7 +108,6 @@ export async function executeMigrateShowPlan( dbConnection, hasDriver: driver !== undefined, commandName: 'db migrate --show', - offlineRetry: true, }); if (missingDb) { return notOk(missingDb); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts index 800ad8d031e9..9d8983c737de 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts @@ -56,6 +56,28 @@ export function liveMarkerUse(flags: { return { liveOrigin, liveTarget, needsDatabase: liveOrigin || liveTarget }; } +/** The command a missing-connection error suggests: the user's flags as given, plus what the retry needs to run. */ +export function retryCommandFor(args: { + readonly commandName: string; + readonly from?: string | undefined; + readonly to: string | undefined; + readonly advanceRef?: string | undefined; + /** The command runs without a database when `--from` names a contract. */ + readonly offline: boolean; +}): string { + const namesLiveMarker = isLiveMarkerRef(args.from) || isLiveMarkerRef(args.to); + const suggestsOffline = args.offline && args.from === undefined && !namesLiveMarker; + const needsConnection = !args.offline || namesLiveMarker; + return [ + `{bin} ${args.commandName}`, + ...(args.from === undefined ? [] : [`--from ${args.from}`]), + ...(suggestsOffline ? ['--from '] : []), + ...(args.to === undefined ? [] : [`--to ${args.to}`]), + ...(args.advanceRef === undefined ? [] : [`--advance-ref ${args.advanceRef}`]), + ...(needsConnection ? ['--db $DATABASE_URL'] : []), + ].join(' '); +} + /** The missing-connection error for a command whose `--from`/`--to` read the live marker, or `null`. */ export function requireDatabaseForLiveMarkerUse(args: { readonly from: string | undefined; @@ -63,27 +85,23 @@ export function requireDatabaseForLiveMarkerUse(args: { readonly dbConnection: unknown; readonly hasDriver: boolean; readonly commandName: string; - /** Offer `--from `, which runs the command without a database, as the retry. */ - readonly offlineRetry?: boolean; }): CliStructuredError | null { if (!liveMarkerUse(args).needsDatabase) { return null; } - const namesLiveMarker = isLiveMarkerRef(args.from) || isLiveMarkerRef(args.to); - const suggestsOffline = args.offlineRetry === true && !namesLiveMarker; - const retryFlags = [ - ...(args.from === undefined ? [] : [`--from ${args.from}`]), - ...(suggestsOffline ? ['--from '] : []), - ...(args.to === undefined ? [] : [`--to ${args.to}`]), - ...(suggestsOffline ? [] : ['--db $DATABASE_URL']), - ]; return requireLiveDatabase({ dbConnection: args.dbConnection, hasDriver: args.hasDriver, - why: namesLiveMarker - ? `${LIVE_MARKER_REF} resolves to the live database marker and requires a --db connection` - : `${args.commandName} needs a database connection to read the live marker${suggestsOffline ? ' (or pass --from to run offline)' : ''}`, + why: + isLiveMarkerRef(args.from) || isLiveMarkerRef(args.to) + ? `${LIVE_MARKER_REF} resolves to the live database marker and requires a --db connection` + : `${args.commandName} needs a database connection to read the live marker (or pass --from to run offline)`, commandName: args.commandName, - retryCommand: [`{bin} ${args.commandName}`, ...retryFlags].join(' '), + retryCommand: retryCommandFor({ + commandName: args.commandName, + from: args.from, + to: args.to, + offline: true, + }), }); } diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index 9e2573a282cf..bc0dabdf8df3 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -21,6 +21,7 @@ import { NO_REF_ADVANCEMENT, preflightRefAdvancement, } from '../../control-api/operations/ref-advancement'; +import { retryCommandFor } from '../../control-api/operations/ref-resolution'; import type { CreateControlClient, DbUpdateResult, DbUpdateSuccess } from '../../control-api/types'; import { CliStructuredError, errorContractValidationFailed } from '../../utils/cli-errors'; import { closeQuietly } from '../../utils/command-helpers'; @@ -173,6 +174,12 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { db: args.flags.db, commandName: 'db update', createClient, + retryCommand: retryCommandFor({ + commandName: args.flags.dryRun ? 'db update --dry-run' : 'db update', + to: args.flags.to, + advanceRef: args.flags.advanceRef, + offline: false, + }), }); if (!prepared.ok) { return notOk(prepared.failure); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 97e326b47bd4..553bb140b835 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -3,7 +3,7 @@ import type { Contract } from '@internal/contract/types'; import { createControlStack } from '@internal/framework-components/control'; import { contractHashAtMarker } from '@internal/migration-tools/aggregate'; import { contractSnapshotDir } from '@internal/migration-tools/contract-snapshot-store'; -import { isLiveMarkerRef, LIVE_MARKER_REF } from '@internal/migration-tools/ref-resolution'; +import { isLiveMarkerRef } from '@internal/migration-tools/ref-resolution'; import type { RefEntry } from '@internal/migration-tools/refs'; import { blindCast, castAs } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; @@ -33,6 +33,7 @@ import { import { type RefResolutionContext, resolveContractRef, + retryCommandFor, } from '../control-api/operations/ref-resolution'; import type { CreateControlClient, @@ -313,10 +314,12 @@ export function createMigrateCommand(createClient: CreateControlClient) { db: args.flags.db, commandName: 'db migrate', createClient, - ...ifDefined( - 'retryCommand', - liveTarget ? `{bin} db migrate --to ${LIVE_MARKER_REF} --db $DATABASE_URL` : undefined, - ), + retryCommand: retryCommandFor({ + commandName: 'db migrate', + to: args.flags.to, + advanceRef: args.flags.advanceRef, + offline: false, + }), }); if (!prepared.ok) { return notOk(prepared.failure); diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 4390838555d2..5c8cf834bf62 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -269,7 +269,6 @@ export const migrationStatusCommand = defineOrmCommand({ dbConnection, hasDriver, commandName: 'migration status', - offlineRetry: true, }); if (missingDb !== null) { return notOk(normalizeError(missingDb)); diff --git a/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts index 52fa1eca9119..b9002a26f6ad 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { resolveContractRef, resolveMigrationRef, + retryCommandFor, } from '../../src/control-api/operations/ref-resolution'; import { mapRefResolutionError } from '../../src/utils/cli-errors'; import { buildGraph, entry } from '../utils/graph-helpers'; @@ -79,3 +80,34 @@ describe('resolveMigrationRef', () => { } }); }); + +describe('retryCommandFor', () => { + const status = { commandName: 'migration status', offline: true }; + const migrate = { commandName: 'db migrate', from: undefined, offline: false }; + + it.each([ + { + args: { ...status, from: undefined, to: undefined }, + retry: '{bin} migration status --from ', + }, + { + args: { ...status, from: undefined, to: 'prod' }, + retry: '{bin} migration status --from --to prod', + }, + { + args: { ...status, from: '@db', to: 'prod' }, + retry: '{bin} migration status --from @db --to prod --db $DATABASE_URL', + }, + { + args: { ...status, from: HASH_A, to: '@db' }, + retry: `{bin} migration status --from ${HASH_A} --to @db --db $DATABASE_URL`, + }, + { + args: { ...migrate, to: 'prod', advanceRef: 'staging' }, + retry: '{bin} db migrate --to prod --advance-ref staging --db $DATABASE_URL', + }, + { args: { ...migrate, to: undefined }, retry: '{bin} db migrate --db $DATABASE_URL' }, + ])('repeats the flags as given: $retry', ({ args, retry }) => { + expect(retryCommandFor(args)).toBe(retry); + }); +}); diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts index a33ebbdeba82..b850a7ebf7b3 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-update-to-resolution.test.ts @@ -181,6 +181,43 @@ describe('db update --to bundle resolution', () => { }); }); + it.each([ + { flags: [], command: 'db update', after: '' }, + { flags: ['--advance-ref', 'staging'], command: 'db update', after: ' --advance-ref staging' }, + { flags: ['--dry-run'], command: 'db update --dry-run', after: '' }, + ])( + 'keeps --to and $flags in the retry command when no connection is configured', + async ({ flags, command, after }) => { + const { cwd, dirNext } = await setupFixture(); + + const run = await createOrmTestCli({ + commands, + groups: BIN_GROUPS, + orm: { ...ormConfig(cwd), db: undefined }, + }).run(['db', 'update', '--to', dirNext, ...flags, '--json'], { cwd }); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining( + `Run \`prisma-test ${command} --to ${dirNext}${after} --db $DATABASE_URL\``, + ), + }), + ], + }, + }, + }); + expect(mocks.connect).not.toHaveBeenCalled(); + }, + ); + it('errors on an invalid --advance-ref name with the structured ref envelope', async () => { const { cwd, dirNext } = await setupFixture(); mocks.dbUpdate.mockResolvedValue( diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts index 0e656ffec240..3429f4b06fd1 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts @@ -346,33 +346,44 @@ describe('migrate --to reserved references and refs', () => { ); }); - it('errors with the connection-required envelope for @db without a connection', async () => { - const cwd = await buildAppliedProject(); - - const run = await harness(ormConfig(cwd, { db: undefined })).run( - ['db', 'migrate', '--to', '@db', '--json'], - { cwd }, - ); - - expect(run.exitCode).toBe(2); - expect(run.json.at(-1)).toMatchObject({ - kind: 'result', - envelope: { - ok: false, - error: { - code: 'CONFIG.DB_CONNECTION_REQUIRED', - meta: { missingFlags: ['--db'] }, - nextActions: [ - expect.objectContaining({ - label: expect.stringContaining('db migrate --to @db --db $DATABASE_URL'), - }), - ], + it.each([ + { argv: ['--to', '@db'], retry: 'db migrate --to @db --db $DATABASE_URL' }, + { argv: ['--to', 'prod'], retry: 'db migrate --to prod --db $DATABASE_URL' }, + { + argv: ['--to', '@db', '--advance-ref', 'staging'], + retry: 'db migrate --to @db --advance-ref staging --db $DATABASE_URL', + }, + { argv: [], retry: 'db migrate --db $DATABASE_URL' }, + ])( + 'repeats $argv in the retry command when no connection is configured', + async ({ argv, retry }) => { + const cwd = await buildAppliedProject(); + + const run = await harness(ormConfig(cwd, { db: undefined })).run( + ['db', 'migrate', ...argv, '--json'], + { cwd }, + ); + + expect(run.exitCode).toBe(2); + expect(run.json.at(-1)).toMatchObject({ + kind: 'result', + envelope: { + ok: false, + error: { + code: 'CONFIG.DB_CONNECTION_REQUIRED', + meta: { missingFlags: ['--db'] }, + nextActions: [ + expect.objectContaining({ + label: expect.stringContaining(`Run \`prisma-test ${retry}\``), + }), + ], + }, }, - }, - }); - expect(mocks.connect).not.toHaveBeenCalled(); - expect(mocks.migrate).not.toHaveBeenCalled(); - }); + }); + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.migrate).not.toHaveBeenCalled(); + }, + ); it('reports a missing driver for @db as a missing driver', async () => { const cwd = await buildAppliedProject(); From ad5d932ec473d7187be0bfbc20555612c5e17859 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 13:32:29 +0200 Subject: [PATCH 31/37] fix(cli): migration status names the extension head when an extension space has no path The no-path summary took the target label from the raw --to flag and the app ref, even when the space with no path was an extension. migration status --to production then said the extension head was reached "via production" and suggested --to and migration plan, which do not move an extension space. The no-path record now keeps which space it is about. For the app space the summary is unchanged. For an extension space it says "to the head of extension space " and offers no app remedy. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/orm/migration/status.ts | 45 +++++++++++++------ .../cli/test/orm/migration-status.test.ts | 19 ++++++++ .../cli/test/orm/status-summary.test.ts | 27 ++++++----- 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 5c8cf834bf62..4c28cded7775 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -132,20 +132,32 @@ function describeOrigin(origin: NoPathOrigin): string { : 'the database state'; } +/** What a status path aims at: the app space's target, which `--to` may name, or an extension space's head. */ +export type NoPathTarget = + | { + readonly space: 'app'; + readonly explicitTarget: boolean; + readonly refName: string | undefined; + } + | { readonly space: 'extension'; readonly spaceId: string }; + export function buildNoPathSummary(args: { readonly origin: NoPathOrigin; readonly targetHash: string; - readonly explicitTarget: boolean; - readonly refName: string | undefined; + readonly target: NoPathTarget; }): string { const markerPart = describeOrigin(args.origin); const targetShort = shortDisplayHash(args.targetHash); - if (!args.explicitTarget) { + const { target } = args; + if (target.space === 'extension') { + return `No migration path from ${markerPart} to the head of extension space \`${target.spaceId}\` (${targetShort}).`; + } + if (!target.explicitTarget) { return `No migration path from ${markerPart} to the application's contract (${targetShort}). Run \`{bin} migration plan --name \` to author one.`; } const targetLabel = - args.refName !== undefined - ? `the target (${targetShort} via \`${args.refName}\`)` + target.refName !== undefined + ? `the target (${targetShort} via \`${target.refName}\`)` : `the target (${targetShort})`; return `No migration path from ${markerPart} to ${targetLabel}. Run \`{bin} migration plan --name \` to author one, or pass \`--to \` to pick a reachable target.`; } @@ -383,7 +395,13 @@ export const migrationStatusCommand = defineOrmCommand({ []; const emptySpaces: string[] = []; let divergedMarker: { readonly space: string; readonly markerHash: string } | undefined; - let noPath: { readonly origin: NoPathOrigin; readonly targetHash: string } | undefined; + let noPath: + | { + readonly origin: NoPathOrigin; + readonly targetHash: string; + readonly target: NoPathTarget; + } + | undefined; let headlineTargetHash = activeRefHash ?? contractHash; let totalPending = 0; @@ -432,7 +450,13 @@ export const migrationStatusCommand = defineOrmCommand({ noPath === undefined && !hasMigrationPath(graph, originHash, targetHash) ) { - noPath = { origin, targetHash }; + noPath = { + origin, + targetHash, + target: isAppSpace + ? { space: 'app', explicitTarget: to !== undefined, refName: activeRefName } + : { space: 'extension', spaceId: entry.space }, + }; } const ledger = database.ledgersBySpace.get(entry.space) ?? []; @@ -515,12 +539,7 @@ export const migrationStatusCommand = defineOrmCommand({ const summary = everySpaceEmpty ? 'No migrations found' : noPath !== undefined - ? buildNoPathSummary({ - origin: noPath.origin, - targetHash: noPath.targetHash, - explicitTarget: args.flags.to !== undefined, - refName: activeRefName, - }) + ? buildNoPathSummary(noPath) : buildStatusHeadline({ pendingCount: totalPending, targetHash: headlineTargetHash, diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index a2051311ca0c..1b7b622d0400 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -173,6 +173,25 @@ describe('migration status', () => { }); }); + it('names the extension head, not the app --to, when an extension space has no path', async () => { + const project = await projectWithOneMigration(); + await addAllExternalSpace(project); + const db = fakeDatabase({ + markers: markersAt(HASH_HEAD), + ledger: [{ migrationHash: project.migrationHash }], + }); + + const run = await harness(withAllExternalExtension(driverConfig(project, db))).run( + ['migration', 'status', '--to', HASH_HEAD, '--json'], + { cwd: project.dir }, + ); + + expect(run.exitCode).toBe(0); + expect(run.presented?.data).toMatchObject({ + summary: `No migration path from the database state to the head of extension space \`${EXTERNAL_SPACE}\` (${HASH_EXTERNAL_HEAD.slice(0, 12)}).`, + }); + }); + it('records invariants the marker is missing as a warn diagnostic and still exits 0', async () => { const project = await createOfflineProject({ storageHash: HASH_HEAD }); await seedMigrationPackage({ diff --git a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts index 3de2927ea80e..0fea7f47f812 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts @@ -7,8 +7,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', markerHash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), - explicitTarget: false, - refName: undefined, + target: { space: 'app', explicitTarget: false, refName: undefined }, }), ).toBe( "No migration path from the database state (aaaaaaaaaaaa) to the application's contract (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one.", @@ -20,8 +19,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', markerHash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), - explicitTarget: true, - refName: 'prod', + target: { space: 'app', explicitTarget: true, refName: 'prod' }, }), ).toBe( 'No migration path from the database state (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb via `prod`). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', @@ -33,8 +31,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', markerHash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), - explicitTarget: true, - refName: undefined, + target: { space: 'app', explicitTarget: true, refName: undefined }, }), ).toBe( 'No migration path from the database state (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', @@ -46,8 +43,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', markerHash: undefined }, targetHash: 'b'.repeat(64), - explicitTarget: false, - refName: undefined, + target: { space: 'app', explicitTarget: false, refName: undefined }, }), ).toBe( "No migration path from the database state to the application's contract (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one.", @@ -59,13 +55,24 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'from', hash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), - explicitTarget: true, - refName: undefined, + target: { space: 'app', explicitTarget: true, refName: undefined }, }), ).toBe( 'No migration path from the --from contract (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', ); }); + + it('names the head of an extension space and leaves out the app remedies', () => { + expect( + buildNoPathSummary({ + origin: { kind: 'database', markerHash: undefined }, + targetHash: 'b'.repeat(64), + target: { space: 'extension', spaceId: 'pgvector' }, + }), + ).toBe( + 'No migration path from the database state to the head of extension space `pgvector` (bbbbbbbbbbbb).', + ); + }); }); describe('buildStatusHeadline', () => { From 3b18f1fd2268acf805b6c20ff09fbf48aa25d8f3 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 13:33:28 +0200 Subject: [PATCH 32/37] refactor(cli): migration status decides each space's origin once NoPathOrigin was named after one of its uses, and its "from" kind also did not cover --from @db, which reads the database. It is now StatusOrigin: the marker the database holds, or a contract --from names offline. The status loop builds it once per space and derives the origin hash, currentContract, the marker the tree labels @db, and the no-path origin from it. Before, the loop branched on the same three cases twice. The JSON document does not change. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/src/orm/migration/status.ts | 55 ++++++++++--------- .../cli/test/orm/status-summary.test.ts | 12 ++-- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 4c28cded7775..1c95306316ae 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -118,17 +118,25 @@ async function readDatabaseState(inputs: { } } -/** Where a status path starts: the database marker, or the contract `--from` names. */ -export type NoPathOrigin = - | { readonly kind: 'database'; readonly markerHash: string | undefined } - | { readonly kind: 'from'; readonly hash: string }; +/** Where a space's status path starts: the marker the database holds, or a contract `--from` names. */ +export type StatusOrigin = + | { readonly kind: 'database'; readonly marker: ContractMarkerRecordLike | undefined } + | { readonly kind: 'offline'; readonly hash: string }; -function describeOrigin(origin: NoPathOrigin): string { - if (origin.kind === 'from') { +function originHashOf(origin: StatusOrigin | undefined): string { + return origin?.kind === 'offline' ? origin.hash : contractHashAtMarker(origin?.marker); +} + +function currentContractOf(origin: StatusOrigin | undefined): string | null { + return origin?.kind === 'offline' ? origin.hash : (origin?.marker?.storageHash ?? null); +} + +function describeOrigin(origin: StatusOrigin): string { + if (origin.kind === 'offline') { return `the --from contract (${shortDisplayHash(origin.hash)})`; } - return origin.markerHash !== undefined - ? `the database state (${shortDisplayHash(origin.markerHash)})` + return origin.marker !== undefined + ? `the database state (${shortDisplayHash(origin.marker.storageHash)})` : 'the database state'; } @@ -142,7 +150,7 @@ export type NoPathTarget = | { readonly space: 'extension'; readonly spaceId: string }; export function buildNoPathSummary(args: { - readonly origin: NoPathOrigin; + readonly origin: StatusOrigin; readonly targetHash: string; readonly target: NoPathTarget; }): string { @@ -397,7 +405,7 @@ export const migrationStatusCommand = defineOrmCommand({ let divergedMarker: { readonly space: string; readonly markerHash: string } | undefined; let noPath: | { - readonly origin: NoPathOrigin; + readonly origin: StatusOrigin; readonly targetHash: string; readonly target: NoPathTarget; } @@ -418,18 +426,18 @@ export const migrationStatusCommand = defineOrmCommand({ headlineTargetHash = targetHash; } - const offlineOrigin = isAppSpace ? fromOverrideHash : undefined; - const marker = liveOrigin - ? database.markersBySpace.get(entry.space) - : offlineOrigin !== undefined - ? { storageHash: offlineOrigin } + const origin: StatusOrigin | undefined = liveOrigin + ? { kind: 'database', marker: database.markersBySpace.get(entry.space) } + : isAppSpace && fromOverrideHash !== undefined + ? { kind: 'offline', hash: fromOverrideHash } : undefined; - const markerHash = marker?.storageHash; - const originHash = contractHashAtMarker(marker); + const originHash = originHashOf(origin); const readMarker = - liveOrigin || (isAppSpace && liveTarget) - ? database.markersBySpace.get(entry.space) - : undefined; + origin?.kind === 'database' + ? origin.marker + : isAppSpace && liveTarget + ? appMarker + : undefined; const markerDiverged = readMarker !== undefined && !isInSpaceHistory(readMarker.storageHash, { graph, headHash: space.headRef?.hash }); @@ -438,11 +446,6 @@ export const migrationStatusCommand = defineOrmCommand({ divergedMarker ??= { space: entry.space, markerHash: readMarker.storageHash }; findings.push(markerNotInHistoryFinding(entry.space)); } - const origin: NoPathOrigin | undefined = liveOrigin - ? { kind: 'database', markerHash } - : offlineOrigin !== undefined - ? { kind: 'from', hash: offlineOrigin } - : undefined; if ( origin !== undefined && !markerDiverged && @@ -475,7 +478,7 @@ export const migrationStatusCommand = defineOrmCommand({ statusSpaces.push({ space: entry.space, - currentContract: markerHash ?? null, + currentContract: currentContractOf(origin), targetContract: targetHash, migrations, }); diff --git a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts index 0fea7f47f812..ea8ad847c824 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts @@ -5,7 +5,7 @@ describe('buildNoPathSummary', () => { it('names the live contract when no --to was passed', () => { expect( buildNoPathSummary({ - origin: { kind: 'database', markerHash: 'a'.repeat(64) }, + origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), target: { space: 'app', explicitTarget: false, refName: undefined }, }), @@ -17,7 +17,7 @@ describe('buildNoPathSummary', () => { it('names the ref when --to resolved via ref', () => { expect( buildNoPathSummary({ - origin: { kind: 'database', markerHash: 'a'.repeat(64) }, + origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), target: { space: 'app', explicitTarget: true, refName: 'prod' }, }), @@ -29,7 +29,7 @@ describe('buildNoPathSummary', () => { it('omits via ref when --to was a raw hash', () => { expect( buildNoPathSummary({ - origin: { kind: 'database', markerHash: 'a'.repeat(64) }, + origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), target: { space: 'app', explicitTarget: true, refName: undefined }, }), @@ -41,7 +41,7 @@ describe('buildNoPathSummary', () => { it('omits the marker parenthetical when the marker hash is unknown', () => { expect( buildNoPathSummary({ - origin: { kind: 'database', markerHash: undefined }, + origin: { kind: 'database', marker: undefined }, targetHash: 'b'.repeat(64), target: { space: 'app', explicitTarget: false, refName: undefined }, }), @@ -53,7 +53,7 @@ describe('buildNoPathSummary', () => { it('names the --from contract when the origin is offline', () => { expect( buildNoPathSummary({ - origin: { kind: 'from', hash: 'a'.repeat(64) }, + origin: { kind: 'offline', hash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), target: { space: 'app', explicitTarget: true, refName: undefined }, }), @@ -65,7 +65,7 @@ describe('buildNoPathSummary', () => { it('names the head of an extension space and leaves out the app remedies', () => { expect( buildNoPathSummary({ - origin: { kind: 'database', markerHash: undefined }, + origin: { kind: 'database', marker: undefined }, targetHash: 'b'.repeat(64), target: { space: 'extension', spaceId: 'pgvector' }, }), From d1d167d547d68e35a10bf71bd850bf634cb626d0 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 13:34:08 +0200 Subject: [PATCH 33/37] docs: one name for @contract, @db and @empty: reserved reference The identifiers, the refusal users see and the error reference said "reserved reference". The doc comments and the grammar section said "reserved token". Everything now says "reserved reference". The grammar section defines it once, as three reserved references written as tokens. RESERVED_CONTRACT_REFS documents its order as fixed instead of naming the help text that reads it. Signed-off-by: willbot Signed-off-by: Will Madden --- docs/architecture docs/subsystems/7. Migration System.md | 4 ++-- docs/design/10-domains/migration/README.md | 2 +- .../3-tooling/cli/src/utils/contract-ref-forms.ts | 2 +- .../3-tooling/migration/src/refs/contract-ref.ts | 6 +++--- .../1-framework/3-tooling/migration/src/refs/types.ts | 8 ++++---- .../3-tooling/migration/test/refs/contract-ref.test.ts | 8 ++++---- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index d1ea6858bdfa..3a9435034a28 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -331,7 +331,7 @@ Refs map logical environment names to contract hashes in `migrations//ref #### Contract-reference grammar -A contract reference names one contract. Five forms name a contract recorded in the migrations directory: a full storage hash, a unique hash prefix, a ref name, a migration directory name (the migration's `to` contract), and `^` (the migration's `from` contract). Three reserved tokens resolve without the migrations directory: `@contract` is the hash of the emitted `contract.json`, `@db` is the database marker (so it needs a connection), and `@empty` is the empty contract. When an input matches more than one form, the command refuses it as ambiguous; a longer or different form, such as a full hash, resolves it. +A contract reference names one contract. Five forms name a contract recorded in the migrations directory: a full storage hash, a unique hash prefix, a ref name, a migration directory name (the migration's `to` contract), and `^` (the migration's `from` contract). Three reserved references, written as tokens, resolve without the migrations directory: `@contract` is the hash of the emitted `contract.json`, `@db` is the database marker (so it needs a connection), and `@empty` is the empty contract. When an input matches more than one form, the command refuses it as ambiguous; a longer or different form, such as a full hash, resolves it. | Argument | `@contract` | `@db` | `@empty` | |---|---|---|---| @@ -340,7 +340,7 @@ A contract reference names one contract. Five forms name a contract recorded in | `migration plan --from` | no | no | yes | | `migration plan --to`, `migration ref set`, `db update --to`, `db sign` | no | no | no | -`db update --to` and `db sign` refuse the reserved tokens with `MIGRATION.REF_WRONG_GRAMMAR`, as `migration plan --to` refuses `@empty`. In `migration status`, `db migrate` and `db migrate --show`, `--from` and `--to` apply to the app space. Each extension space goes to its own head: from its own marker when the command reads the database for the origin (no `--from`, or `--from @db`), and from the empty contract when `--from` names a contract. +`db update --to` and `db sign` refuse the reserved references with `MIGRATION.REF_WRONG_GRAMMAR`, as `migration plan --to` refuses `@empty`. In `migration status`, `db migrate` and `db migrate --show`, `--from` and `--to` apply to the app space. Each extension space goes to its own head: from its own marker when the command reads the database for the origin (no `--from`, or `--from @db`), and from the empty contract when `--from` names a contract. #### Contract resolution through the snapshot store diff --git a/docs/design/10-domains/migration/README.md b/docs/design/10-domains/migration/README.md index 8464d5397182..b4c2d17f42e9 100644 --- a/docs/design/10-domains/migration/README.md +++ b/docs/design/10-domains/migration/README.md @@ -378,7 +378,7 @@ The choices below are the load-bearing ones — the ones that, if reversed, woul - **`db init` vs `db sign`.** Distinct: init lays down structure (live, mutates); sign verifies + writes marker (no structural mutation, refuses if DB doesn't already satisfy the contract). - **"Freeze"** rejected. `migration plan` is the verb for the freeze-and-promise act. - **Dev/deploy split** rejected. The safety semantics belong to the DB URL, not the verb. -- **Contract references and migration references.** Two parallel grammars sharing forms but resolving in different namespaces. `` resolves to a contract storage hash (accepts: hash or hash prefix, ref name, migration directory name → to-contract, `^` → from-contract, and the reserved tokens `@contract`, `@db` and `@empty` where the command allows them; see the [contract-reference grammar](../../../architecture%20docs/subsystems/7.%20Migration%20System.md#contract-reference-grammar)). `` resolves to a migration (accepts: migration hash or directory name). The command's argument type determines which grammar applies — same hash-shaped input resolves in different namespaces depending on whether the command expects a `` or a ``. **In CLI argument syntax the placeholder is `` or ``** — no umbrella shorthand. A **ref** is a specific kind of contract reference (named, persisted, file-backed); the umbrella is **contract reference**. +- **Contract references and migration references.** Two parallel grammars sharing forms but resolving in different namespaces. `` resolves to a contract storage hash (accepts: hash or hash prefix, ref name, migration directory name → to-contract, `^` → from-contract, and the reserved references `@contract`, `@db` and `@empty` where the command allows them; see the [contract-reference grammar](../../../architecture%20docs/subsystems/7.%20Migration%20System.md#contract-reference-grammar)). `` resolves to a migration (accepts: migration hash or directory name). The command's argument type determines which grammar applies — same hash-shaped input resolves in different namespaces depending on whether the command expects a `` or a ``. **In CLI argument syntax the placeholder is `` or ``** — no umbrella shorthand. A **ref** is a specific kind of contract reference (named, persisted, file-backed); the umbrella is **contract reference**. - **Directory names are user-controlled.** The default `T_` is convention, not invariant. Ambiguity between a directory name and a hash prefix is an explicit ambiguity error with candidate listing — same Git rule for short SHAs that collide with branch names. Disambiguate with a longer or different form, such as a full hash. - **`db sign []` (positional) or `db sign --contract ` (explicit).** The argument names *the thing being signed* — neither `--to` (movement) nor `--at` (position) carries the right meaning. Defaults to the current `contract.json` when omitted. - **`ref set `** is the direct-ref-write verb. `move` was rejected because refs are stored values, not entities that traverse the graph — the spatial-movement vocabulary is reserved for `migrate`. diff --git a/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts b/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts index 006195b0bd18..a65d357aadfd 100644 --- a/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts +++ b/packages/1-framework/3-tooling/cli/src/utils/contract-ref-forms.ts @@ -18,5 +18,5 @@ export const RECORDED_OR_EMPTY_CONTRACT_REF_FORMS = listForms([ EMPTY_CONTRACT_REF, ]); -/** The recorded forms plus every reserved token. */ +/** The recorded forms plus every reserved reference. */ export const ALL_CONTRACT_REF_FORMS = listForms([...RECORDED_FORMS, ...RESERVED_CONTRACT_REFS]); diff --git a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts index d230bd6538cd..1169d84917e8 100644 --- a/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts +++ b/packages/1-framework/3-tooling/migration/src/refs/contract-ref.ts @@ -14,19 +14,19 @@ export const WORKING_CONTRACT_REF = '@contract'; export const LIVE_MARKER_REF = '@db'; export const EMPTY_CONTRACT_REF = '@empty'; -/** The reserved tokens, in the order help text lists them. */ +/** The reserved references, in a fixed order: `@contract`, `@db`, `@empty`. */ export const RESERVED_CONTRACT_REFS: readonly string[] = [ WORKING_CONTRACT_REF, LIVE_MARKER_REF, EMPTY_CONTRACT_REF, ]; -/** True for a reserved token, which resolves from contract.json, the database, or the empty contract rather than from a contract recorded in the migrations directory. */ +/** True for a reserved reference, which resolves from contract.json, the database, or the empty contract rather than from a contract recorded in the migrations directory. */ export function isReservedContractRef(input: string): boolean { return RESERVED_CONTRACT_REFS.includes(input); } -/** True for `@db`, the only reserved token that needs a database read to resolve. */ +/** True for `@db`, the only reserved reference that needs a database read to resolve. */ export function isLiveMarkerRef(input: string | undefined): boolean { return input === LIVE_MARKER_REF; } diff --git a/packages/1-framework/3-tooling/migration/src/refs/types.ts b/packages/1-framework/3-tooling/migration/src/refs/types.ts index 4a464581bc35..190c884d4511 100644 --- a/packages/1-framework/3-tooling/migration/src/refs/types.ts +++ b/packages/1-framework/3-tooling/migration/src/refs/types.ts @@ -7,7 +7,7 @@ export interface RefResolutionContext { readonly refs: Refs; /** * Hash of the on-disk contract (`contract.json`). Required to resolve the - * `@contract` reserved token, which is an offline-resolvable alias for + * `@contract` reserved reference, which is an offline-resolvable alias for * "the working contract the app carries." */ readonly contractHash?: string; @@ -19,18 +19,18 @@ export type ContractRefProvenance = | { readonly kind: 'migration-to'; readonly dirName: string } | { readonly kind: 'migration-from'; readonly dirName: string } /** - * Resolved from the `@contract` reserved token — the hash of the on-disk + * Resolved from the `@contract` reserved reference — the hash of the on-disk * working contract (`contract.json`). Offline-resolvable. */ | { readonly kind: 'reserved-contract' } /** - * Resolved from the `@db` reserved token. The `hash` field is a placeholder + * Resolved from the `@db` reserved reference. The `hash` field is a placeholder * that must not be used: callers that accept `@db` test `isLiveMarkerRef` * before parsing and resolve it from the live marker. */ | { readonly kind: 'reserved-db' } /** - * Resolved from the `@empty` reserved token — the empty contract + * Resolved from the `@empty` reserved reference — the empty contract * (`EMPTY_CONTRACT_HASH`), the origin with no prior storage state. * Offline-resolvable. */ diff --git a/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts b/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts index 4ee2c9a4fba7..cfe7ba70b348 100644 --- a/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts +++ b/packages/1-framework/3-tooling/migration/test/refs/contract-ref.test.ts @@ -229,7 +229,7 @@ describe('parseContractRef', () => { }); }); - describe('@contract reserved token', () => { + describe('@contract reserved reference', () => { it('resolves @contract to the contractHash in context', () => { const ctx = createContext(); const result = parseContractRef('@contract', { ...ctx, contractHash: HASH_B }); @@ -247,7 +247,7 @@ describe('parseContractRef', () => { }); }); - describe('@db reserved token', () => { + describe('@db reserved reference', () => { it('returns a reserved-db provenance that callers must resolve via readAllMarkers', () => { const ctx = createContext(); const result = parseContractRef('@db', ctx); @@ -269,7 +269,7 @@ describe('parseContractRef', () => { }); }); - describe('@empty reserved token', () => { + describe('@empty reserved reference', () => { it('resolves @empty to the empty contract hash offline', () => { const ctx = createContext(); const result = parseContractRef('@empty', ctx); @@ -323,7 +323,7 @@ describe('parseContractRef', () => { }); describe('reserved contract references', () => { - it('lists the reserved tokens in help order', () => { + it('keeps the reserved references in a fixed order', () => { expect(RESERVED_CONTRACT_REFS).toEqual(['@contract', '@db', '@empty']); }); From 723545abe80f060c41c27d60a54717643e5d407b Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 13:35:10 +0200 Subject: [PATCH 34/37] docs(cli): the README and help text describe the db migrate and migration status flags that exist The CLI README documented a --ref flag on db migrate that does not exist, pointed refs at migrations/refs.json, and left out --show, --from and --advance-ref. It now lists the real flags, says a ref name is a --to form stored in migrations//refs/.json, and says --to @contract applies contract.json like an omitted --to. The migration status section and help no longer say --from always runs offline: --from or --to set to @db reads the database. The history warning names the head of a space with no migrations. The db migrate help drops its hand-written form list, and the grammar section says @db is the empty contract when the database has no marker. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 22 +++++++++---------- .../3-tooling/cli/src/orm/migrate.ts | 4 ++-- .../3-tooling/cli/src/orm/migration/status.ts | 8 +++---- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 3a9435034a28..a081e8ec4f43 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -331,7 +331,7 @@ Refs map logical environment names to contract hashes in `migrations//ref #### Contract-reference grammar -A contract reference names one contract. Five forms name a contract recorded in the migrations directory: a full storage hash, a unique hash prefix, a ref name, a migration directory name (the migration's `to` contract), and `^` (the migration's `from` contract). Three reserved references, written as tokens, resolve without the migrations directory: `@contract` is the hash of the emitted `contract.json`, `@db` is the database marker (so it needs a connection), and `@empty` is the empty contract. When an input matches more than one form, the command refuses it as ambiguous; a longer or different form, such as a full hash, resolves it. +A contract reference names one contract. Five forms name a contract recorded in the migrations directory: a full storage hash, a unique hash prefix, a ref name, a migration directory name (the migration's `to` contract), and `^` (the migration's `from` contract). Three reserved references, written as tokens, resolve without the migrations directory: `@contract` is the hash of the emitted `contract.json`, `@db` is the database marker, or the empty contract when there is none (so it needs a connection), and `@empty` is the empty contract. When an input matches more than one form, the command refuses it as ambiguous; a longer or different form, such as a full hash, resolves it. | Argument | `@contract` | `@db` | `@empty` | |---|---|---|---| diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 0c92f7dbd299..b946cf07d7fc 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -1137,7 +1137,7 @@ prisma migration show [target] [--config ] [--json] [-v] [-q] [--color/--n ### `prisma migration status` -Shows which migrations are pending between the database marker and the target contract. It needs a database connection unless `--from` names the origin. +Shows which migrations are pending between the database marker and the target contract. It reads the database marker by default and needs a connection. `--from` names the origin instead and runs offline, unless `--from` or `--to` is `@db`, which reads the database. ```bash prisma migration status [--db ] [--to ] [--from ] [--space ] [--legend] [--ascii] [--config ] [--json] [-v] [-q] [--color/--no-color] @@ -1146,7 +1146,7 @@ prisma migration status [--db ] [--to ] [--from ] [--sp **Options:** - `--db `: Database connection string - `--to `: Target contract reference (hash, prefix, ref name, migration dir name, `^`, `@contract`, `@db`, or `@empty`). Defaults to the emitted contract. -- `--from `: Origin contract reference, with the same forms as `--to`. Defaults to the database marker. With `--from`, the path is computed without reading the database. +- `--from `: Origin contract reference, with the same forms as `--to`. Defaults to the database marker. With `--from`, the path is computed without reading the database, unless `--from` or `--to` is `@db`. - `--space `: Narrow output to a single contract space - `--legend`: Print a key for the tree glyphs and lane colors - `--ascii`: Use ASCII glyphs @@ -1162,20 +1162,22 @@ prisma migration status [--db ] [--to ] [--from ] [--sp 2. Resolves the origin (the database marker, or `--from`) and the target (the emitted contract, or `--to`) 3. With a database connection, reads each space's marker and ledger to mark migrations applied or pending 4. Draws each space's graph with `@db`, `@contract` and ref labels, and summarises what is pending -5. Warns `MIGRATION.MARKER_NOT_IN_HISTORY` when a marker is not in its space's migration graph +5. Warns `MIGRATION.MARKER_NOT_IN_HISTORY` when a marker is not in its space's history (a graph node, or the head of a space with no migrations) ### `prisma db migrate` Apply planned migrations to the database. Executes previously planned migrations (created by `migration plan`). Compares the database marker against the migration graph to determine which migrations are pending, then executes them sequentially. Each migration runs in its own transaction. Does not plan new migrations — run `migration plan` first. ```bash -prisma db migrate [--db ] [--to ] [--config ] [--json] [-v] [-q] [--color/--no-color] +prisma db migrate [--db ] [--to ] [--advance-ref ] [--show] [--from ] [--config ] [--json] [-v] [-q] [--color/--no-color] ``` **Options:** - `--db `: Database connection string (optional; defaults to `config.db.connection`) -- `--to `: Target contract reference (hash, prefix, ref name, migration directory, `^`, `@contract`, `@db`, or `@empty`). When omitted, applies toward the emitted `contract.json`. When `--to` resolves to an on-disk graph node, verification and apply use the snapshot store entry for that node's hash — so a planned rollback or other arbitrary-target edge applies without editing contract source. -- `--ref `: Target a named ref from `migrations/refs.json` instead of the current contract hash +- `--to `: Target contract reference (hash, prefix, ref name, migration directory, `^`, `@contract`, `@db`, or `@empty`). When omitted, applies toward the emitted `contract.json`; `--to @contract` does the same. When `--to` resolves to another on-disk graph node, verification and apply use the snapshot store entry for that node's hash — so a planned rollback or other arbitrary-target edge applies without editing contract source. A ref name is a `--to` form; refs live in `migrations//refs/.json`. +- `--advance-ref `: After a successful apply, advance the named ref to the new marker +- `--show`: Preview the migration route without applying anything (read-only) +- `--from `: The origin for the `--show` preview, with the same forms as `--to`. Defaults to the database marker. A contract other than `@db` makes the preview run offline. - `--config `: Path to `prisma.config.ts` - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) @@ -1184,7 +1186,7 @@ prisma db migrate [--db ] [--to ] [--config ] [--json] [-v] **What it does:** 1. Reads migration packages from `config.migrations.dir`. Every package is attested — there is no on-disk draft state. The loader (`readMigrationPackage` in `@internal/migration-tools/io`) rehashes `(metadata, ops)` for each `MigrationPackage` it returns and confirms the result matches the stored `migrationHash`. If a package has been hand-edited or partially written since emit, the load fails with `MIGRATION.HASH_MISMATCH` pointing at the offending directory and asks the developer to re-run `node migrations//migration.ts` (or restore from version control). 2. Reconstructs the migration graph from all loaded packages -3. Determines the destination hash and apply contract: from `--to` / `--ref`, or from `contract.json` when neither is supplied +3. Determines the destination hash and apply contract: from `--to`, or from `contract.json` when `--to` is omitted or `@contract` 4. Connects to the database and reads the current marker hash 5. Finds the shortest path from the marker hash to the destination using graph pathfinding 6. Executes each pending migration in order using the target's `MigrationRunner` @@ -1197,8 +1199,6 @@ prisma db migrate [--db ] [--to ] [--config ] [--json] [-v] **Resume semantics:** If a migration fails, previously applied migrations are preserved. Re-running `db migrate` resumes from the last successful migration. -**Ref-based routing:** With `--ref`, apply targets the ref's hash instead of the contract hash. This enables multi-environment workflows where staging and production track different points in the migration graph. - ### Emitting `ops.json` and computing `migrationHash` There is no dedicated CLI command for emitting a migration — migrations @@ -1216,7 +1216,7 @@ The scaffolded `migration.ts` calls `MigrationCLI.run(import.meta.url, ...)` fro ### `prisma migration ref` -Manage named refs in `migrations/refs.json`. Refs map logical environment names (e.g., `staging`, `production`) to contract hashes, enabling multi-environment migration workflows where different environments track different points in the migration graph. +Manage named refs, one file per ref at `migrations//refs/.json`. Refs map logical environment names (e.g., `staging`, `production`) to contract hashes, enabling multi-environment migration workflows where different environments track different points in the migration graph. ```bash prisma migration ref set # Set a ref to a contract (hash, ref, dir, ...) @@ -1233,7 +1233,7 @@ prisma migration ref delete # Delete a ref **Ref values:** Must be valid contract hashes (64 lowercase hex chars, or the `empty` sentinel). -**Atomic writes:** `refs.json` is written atomically via temp file + rename to prevent corruption from concurrent writes. +**Atomic writes:** each ref file is written atomically via temp file + rename to prevent corruption from concurrent writes. ## Architecture diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 553bb140b835..1965946a5454 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -227,8 +227,8 @@ export function createMigrateCommand(createClient: CreateControlClient) { 'Walks every contract space (app + extensions) and applies pending on-disk\n' + 'migrations in canonical order (extensions alphabetically, then app). It\n' + 'replays the on-disk migration graph and never invents an edge. Use --to to\n' + - 'target a specific contract (hash, ref name, or migration directory) and\n' + - '--show for a read-only preview of the route it would take.', + 'target a specific contract and --show for a read-only preview of the route\n' + + 'it would take.', examples: [ 'db migrate', 'db migrate --db $DATABASE_URL', diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index 1c95306316ae..b6e860d57542 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -247,10 +247,10 @@ export const migrationStatusCommand = defineOrmCommand({ summary: 'Show migration path and pending status', description: 'Shows which migrations are pending between the database marker and the\n' + - 'target contract. Requires a database connection. Pass --from for an\n' + - 'offline path preview without a database. Use `migration graph` for\n' + - 'topology, `migration log` for history, and `migration list` for on-disk\n' + - 'enumeration.', + 'target contract. Reads the database marker by default. --from names the\n' + + 'origin instead and runs offline, unless --from or --to is @db, which reads\n' + + 'the database. Use `migration graph` for topology, `migration log` for\n' + + 'history, and `migration list` for on-disk enumeration.', examples: [ 'migration status', 'migration status --db $DATABASE_URL', From 2d0613c2166173eb3a6a1ba3420703875d573348 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 13:35:37 +0200 Subject: [PATCH 35/37] test(cli): two tests say and check what they mean The db migrate test that checks the target passed to the control client is now named "passes the empty contract as the target for --to $to": no runner is involved there. The test that migration status stays quiet on a project with no migrations now also checks the summary and that the headline is ok, not a warning. Signed-off-by: willbot Signed-off-by: Will Madden --- .../3-tooling/cli/test/orm/migrate-to-contract.test.ts | 2 +- .../3-tooling/cli/test/orm/migration-status.test.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts index 3429f4b06fd1..476e855ccf1d 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migrate-to-contract.test.ts @@ -320,7 +320,7 @@ describe('migrate --to reserved references and refs', () => { it.each([ { to: '@db', reason: 'a database with no marker' }, { to: '@empty', reason: 'the empty contract' }, - ])('hands the runner the empty contract for --to $to ($reason)', async ({ to }) => { + ])('passes the empty contract as the target for --to $to ($reason)', async ({ to }) => { const cwd = await buildAppliedProject(); mocks.readAllMarkers.mockResolvedValue(new Map()); diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts index 1b7b622d0400..c855a60ace11 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status.test.ts @@ -124,12 +124,19 @@ describe('migration status', () => { const project = await createOfflineProject({ storageHash: HASH_HEAD }); const db = fakeDatabase({ markers: markersAt(HASH_HEAD) }); - const run = await harness(driverConfig(project, db)).run(['migration', 'status', '--json'], { + const run = await harness(driverConfig(project, db)).run(['migration', 'status'], { cwd: project.dir, + isTty: { stdout: true }, }); expect(run.exitCode).toBe(0); expect(run.presented?.diagnostics ?? []).toEqual([]); + expect(run.presented?.data).toMatchObject({ summary: 'No migrations found' }); + expect(run.presented?.presentation.human.at(-1)).toEqual({ + kind: 'summary', + status: 'ok', + text: 'No migrations found', + }); }); it('warns when the app space has no migrations and the marker is another contract', async () => { From f895e56785f00beecf3bda25a8af18030065c8fb Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 13:49:08 +0200 Subject: [PATCH 36/37] test(cli): the db sign reserved-reference test uses the dbSign mock that main renamed Signed-off-by: willbot Signed-off-by: Will Madden --- packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts index 3fe64f727fcb..eb94de0e97f3 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/db-sign.test.ts @@ -382,7 +382,7 @@ describe('db sign', () => { }, }); expect(mocks.connect).not.toHaveBeenCalled(); - expect(mocks.sign).not.toHaveBeenCalled(); + expect(mocks.dbSign).not.toHaveBeenCalled(); }, ); From 858bf5dad885345bc2f8092cf32d89db24c58d62 Mon Sep 17 00:00:00 2001 From: willbot Date: Tue, 6 Oct 2026 14:09:25 +0200 Subject: [PATCH 37/37] fix(cli): migration status labels the empty node @db when --to @db reads an unmarked database; review naming and doc fixes - migration status draws @db at the empty node when it read a database with no marker, as db migrate --show does. - The CLI README no longer lists ./path for db sign (brought back by the merge of main), and says db migrate --show --from still reads the database when --to is @db. - The grammar section attaches "needs a connection" to @db, not to the empty contract. - retryCommandFor takes canRunOffline; the status target type is StatusTarget with a kind discriminator. Signed-off-by: willbot Signed-off-by: Will Madden --- .../subsystems/7. Migration System.md | 2 +- packages/1-framework/3-tooling/cli/README.md | 4 +-- .../control-api/operations/ref-resolution.ts | 8 +++--- .../3-tooling/cli/src/orm/db/update.ts | 2 +- .../3-tooling/cli/src/orm/migrate.ts | 2 +- .../3-tooling/cli/src/orm/migration/status.ts | 25 ++++++++----------- .../test/control-api/ref-resolution.test.ts | 4 +-- .../migration-status-contract-refs.test.ts | 20 +++++++++++++++ .../cli/test/orm/status-summary.test.ts | 12 ++++----- 9 files changed, 48 insertions(+), 31 deletions(-) diff --git a/docs/architecture docs/subsystems/7. Migration System.md b/docs/architecture docs/subsystems/7. Migration System.md index 751d6edc48d4..eada4fb17ba0 100644 --- a/docs/architecture docs/subsystems/7. Migration System.md +++ b/docs/architecture docs/subsystems/7. Migration System.md @@ -333,7 +333,7 @@ Refs map logical environment names to contract hashes in `migrations//ref #### Contract-reference grammar -A contract reference names one contract. Five forms name a contract recorded in the migrations directory: a full storage hash, a unique hash prefix, a ref name, a migration directory name (the migration's `to` contract), and `^` (the migration's `from` contract). Three reserved references, written as tokens, resolve without the migrations directory: `@contract` is the hash of the emitted `contract.json`, `@db` is the database marker, or the empty contract when there is none (so it needs a connection), and `@empty` is the empty contract. When an input matches more than one form, the command refuses it as ambiguous; a longer or different form, such as a full hash, resolves it. +A contract reference names one contract. Five forms name a contract recorded in the migrations directory: a full storage hash, a unique hash prefix, a ref name, a migration directory name (the migration's `to` contract), and `^` (the migration's `from` contract). Three reserved references, written as tokens, resolve without the migrations directory: `@contract` is the hash of the emitted `contract.json`, `@db` is the database marker (so it needs a connection), or the empty contract when the database has none, and `@empty` is the empty contract. When an input matches more than one form, the command refuses it as ambiguous; a longer or different form, such as a full hash, resolves it. | Argument | `@contract` | `@db` | `@empty` | |---|---|---|---| diff --git a/packages/1-framework/3-tooling/cli/README.md b/packages/1-framework/3-tooling/cli/README.md index 8d41acbaca71..89eb370604d9 100644 --- a/packages/1-framework/3-tooling/cli/README.md +++ b/packages/1-framework/3-tooling/cli/README.md @@ -664,7 +664,7 @@ prisma db sign [ | --contract ] [--db ] [--advance-ref ``` Options: -- `` / `--contract `: Optional. The application contract to sign with: a hash, hash prefix, ref name, migration directory name, `^`, or `./path`. Defaults to the emitted `contract.json` +- `` / `--contract `: Optional. The application contract to sign with: a hash, hash prefix, ref name, migration directory name, or `^`. Defaults to the emitted `contract.json` - `--db `: Database connection string (optional; defaults to `config.db.connection` if set) - `--advance-ref `: Advance the named ref of every signed space instead of `db` - `--no-advance-ref`: Sign without writing any ref or snapshot @@ -1093,7 +1093,7 @@ prisma db migrate [--db ] [--to ] [--advance-ref ] [--show] - `--to `: Target contract reference (hash, prefix, ref name, migration directory, `^`, `@contract`, `@db`, or `@empty`). When omitted, applies toward the emitted `contract.json`; `--to @contract` does the same. When `--to` resolves to another on-disk graph node, verification and apply use the snapshot store entry for that node's hash — so a planned rollback or other arbitrary-target edge applies without editing contract source. A ref name is a `--to` form; refs live in `migrations//refs/.json`. - `--advance-ref `: After a successful apply, advance the named ref to the new marker - `--show`: Preview the migration route without applying anything (read-only) -- `--from `: The origin for the `--show` preview, with the same forms as `--to`. Defaults to the database marker. A contract other than `@db` makes the preview run offline. +- `--from `: The origin for the `--show` preview, with the same forms as `--to`. Defaults to the database marker. A contract other than `@db` makes the preview start offline; it still reads the database when `--to` is `@db`. - `--config `: Path to `prisma.config.ts` - `--json`: Output as JSON object - `-q, --quiet`: Quiet mode (errors only) diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts index 9d8983c737de..c2acd022512c 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/ref-resolution.ts @@ -63,11 +63,11 @@ export function retryCommandFor(args: { readonly to: string | undefined; readonly advanceRef?: string | undefined; /** The command runs without a database when `--from` names a contract. */ - readonly offline: boolean; + readonly canRunOffline: boolean; }): string { const namesLiveMarker = isLiveMarkerRef(args.from) || isLiveMarkerRef(args.to); - const suggestsOffline = args.offline && args.from === undefined && !namesLiveMarker; - const needsConnection = !args.offline || namesLiveMarker; + const suggestsOffline = args.canRunOffline && args.from === undefined && !namesLiveMarker; + const needsConnection = !args.canRunOffline || namesLiveMarker; return [ `{bin} ${args.commandName}`, ...(args.from === undefined ? [] : [`--from ${args.from}`]), @@ -101,7 +101,7 @@ export function requireDatabaseForLiveMarkerUse(args: { commandName: args.commandName, from: args.from, to: args.to, - offline: true, + canRunOffline: true, }), }); } diff --git a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts index bc0dabdf8df3..beb98f76e9eb 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/db/update.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/db/update.ts @@ -178,7 +178,7 @@ export function createDbUpdateCommand(createClient: CreateControlClient) { commandName: args.flags.dryRun ? 'db update --dry-run' : 'db update', to: args.flags.to, advanceRef: args.flags.advanceRef, - offline: false, + canRunOffline: false, }), }); if (!prepared.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts index 1965946a5454..638f0284716b 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migrate.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migrate.ts @@ -318,7 +318,7 @@ export function createMigrateCommand(createClient: CreateControlClient) { commandName: 'db migrate', to: args.flags.to, advanceRef: args.flags.advanceRef, - offline: false, + canRunOffline: false, }), }); if (!prepared.ok) { diff --git a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts index b6e860d57542..2001a07da71a 100644 --- a/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts +++ b/packages/1-framework/3-tooling/cli/src/orm/migration/status.ts @@ -141,23 +141,23 @@ function describeOrigin(origin: StatusOrigin): string { } /** What a status path aims at: the app space's target, which `--to` may name, or an extension space's head. */ -export type NoPathTarget = +export type StatusTarget = | { - readonly space: 'app'; + readonly kind: 'app'; readonly explicitTarget: boolean; readonly refName: string | undefined; } - | { readonly space: 'extension'; readonly spaceId: string }; + | { readonly kind: 'extension'; readonly spaceId: string }; export function buildNoPathSummary(args: { readonly origin: StatusOrigin; readonly targetHash: string; - readonly target: NoPathTarget; + readonly target: StatusTarget; }): string { const markerPart = describeOrigin(args.origin); const targetShort = shortDisplayHash(args.targetHash); const { target } = args; - if (target.space === 'extension') { + if (target.kind === 'extension') { return `No migration path from ${markerPart} to the head of extension space \`${target.spaceId}\` (${targetShort}).`; } if (!target.explicitTarget) { @@ -407,7 +407,7 @@ export const migrationStatusCommand = defineOrmCommand({ | { readonly origin: StatusOrigin; readonly targetHash: string; - readonly target: NoPathTarget; + readonly target: StatusTarget; } | undefined; let headlineTargetHash = activeRefHash ?? contractHash; @@ -432,12 +432,9 @@ export const migrationStatusCommand = defineOrmCommand({ ? { kind: 'offline', hash: fromOverrideHash } : undefined; const originHash = originHashOf(origin); + const markerRead = origin?.kind === 'database' || (isAppSpace && liveTarget); const readMarker = - origin?.kind === 'database' - ? origin.marker - : isAppSpace && liveTarget - ? appMarker - : undefined; + origin?.kind === 'database' ? origin.marker : markerRead ? appMarker : undefined; const markerDiverged = readMarker !== undefined && !isInSpaceHistory(readMarker.storageHash, { graph, headHash: space.headRef?.hash }); @@ -457,8 +454,8 @@ export const migrationStatusCommand = defineOrmCommand({ origin, targetHash, target: isAppSpace - ? { space: 'app', explicitTarget: to !== undefined, refName: activeRefName } - : { space: 'extension', spaceId: entry.space }, + ? { kind: 'app', explicitTarget: to !== undefined, refName: activeRefName } + : { kind: 'extension', spaceId: entry.space }, }; } @@ -499,7 +496,7 @@ export const migrationStatusCommand = defineOrmCommand({ styler, palette: TONE_MIGRATION_GRAPH_PALETTE, isAppSpace, - ...ifDefined('dbHash', readMarker?.storageHash), + ...(markerRead ? { dbHash: contractHashAtMarker(readMarker) } : {}), }); } diff --git a/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts b/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts index b9002a26f6ad..4848e5d0f420 100644 --- a/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts +++ b/packages/1-framework/3-tooling/cli/test/control-api/ref-resolution.test.ts @@ -82,8 +82,8 @@ describe('resolveMigrationRef', () => { }); describe('retryCommandFor', () => { - const status = { commandName: 'migration status', offline: true }; - const migrate = { commandName: 'db migrate', from: undefined, offline: false }; + const status = { commandName: 'migration status', canRunOffline: true }; + const migrate = { commandName: 'db migrate', from: undefined, canRunOffline: false }; it.each([ { diff --git a/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts b/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts index 9fd49e8c6ff1..a391722f97f2 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/migration-status-contract-refs.test.ts @@ -189,6 +189,26 @@ describe('migration status with reserved contract references', () => { expect(dbLines[0]).toContain(HASH_BASE.slice(0, 7)); }); + it('labels the empty node @db when --to @db reads a database with no marker', async () => { + const project = await projectWithTwoMigrations(); + const db = fakeDatabase(); + + const run = await harness(driverConfig(project, db)).run( + ['migration', 'status', '--to', '@db'], + { + cwd: project.dir, + isTty: { stdout: true, stderr: true }, + }, + ); + const dbLines = stripAnsi(run.stderr) + .split('\n') + .filter((line) => line.includes('@db')); + + expect(run.exitCode).toBe(0); + expect(dbLines).toHaveLength(1); + expect(dbLines[0]).toContain('∅'); + }); + it('reports no path when --from @contract is ahead of the database named by --to @db', async () => { const project = await projectWithTwoMigrations(); const db = fakeDatabase({ diff --git a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts index ea8ad847c824..4b873d3ded6b 100644 --- a/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts +++ b/packages/1-framework/3-tooling/cli/test/orm/status-summary.test.ts @@ -7,7 +7,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), - target: { space: 'app', explicitTarget: false, refName: undefined }, + target: { kind: 'app', explicitTarget: false, refName: undefined }, }), ).toBe( "No migration path from the database state (aaaaaaaaaaaa) to the application's contract (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one.", @@ -19,7 +19,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), - target: { space: 'app', explicitTarget: true, refName: 'prod' }, + target: { kind: 'app', explicitTarget: true, refName: 'prod' }, }), ).toBe( 'No migration path from the database state (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb via `prod`). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', @@ -31,7 +31,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', marker: { storageHash: 'a'.repeat(64), invariants: [] } }, targetHash: 'b'.repeat(64), - target: { space: 'app', explicitTarget: true, refName: undefined }, + target: { kind: 'app', explicitTarget: true, refName: undefined }, }), ).toBe( 'No migration path from the database state (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', @@ -43,7 +43,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', marker: undefined }, targetHash: 'b'.repeat(64), - target: { space: 'app', explicitTarget: false, refName: undefined }, + target: { kind: 'app', explicitTarget: false, refName: undefined }, }), ).toBe( "No migration path from the database state to the application's contract (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one.", @@ -55,7 +55,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'offline', hash: 'a'.repeat(64) }, targetHash: 'b'.repeat(64), - target: { space: 'app', explicitTarget: true, refName: undefined }, + target: { kind: 'app', explicitTarget: true, refName: undefined }, }), ).toBe( 'No migration path from the --from contract (aaaaaaaaaaaa) to the target (bbbbbbbbbbbb). Run `{bin} migration plan --name ` to author one, or pass `--to ` to pick a reachable target.', @@ -67,7 +67,7 @@ describe('buildNoPathSummary', () => { buildNoPathSummary({ origin: { kind: 'database', marker: undefined }, targetHash: 'b'.repeat(64), - target: { space: 'extension', spaceId: 'pgvector' }, + target: { kind: 'extension', spaceId: 'pgvector' }, }), ).toBe( 'No migration path from the database state to the head of extension space `pgvector` (bbbbbbbbbbbb).',