diff --git a/packages/1-framework/0-foundation/contract/src/exports/types.ts b/packages/1-framework/0-foundation/contract/src/exports/types.ts index 90c9817388..942cbe801c 100644 --- a/packages/1-framework/0-foundation/contract/src/exports/types.ts +++ b/packages/1-framework/0-foundation/contract/src/exports/types.ts @@ -58,6 +58,7 @@ export type { GeneratedValueSpec, JsonPrimitive, JsonValue, + LedgerEntryRecord, PlanMeta, ProfileHashBase, Source, diff --git a/packages/1-framework/0-foundation/contract/src/types.ts b/packages/1-framework/0-foundation/contract/src/types.ts index f16eb4d93c..c077215a60 100644 --- a/packages/1-framework/0-foundation/contract/src/types.ts +++ b/packages/1-framework/0-foundation/contract/src/types.ts @@ -243,3 +243,17 @@ export interface ContractMarkerRecord { readonly meta: Record; readonly invariants: readonly string[]; } + +/** + * One applied migration edge from the per-space ledger journal. + * Returned by `readLedger` in append (apply) order. + */ +export interface LedgerEntryRecord { + readonly space: string; + readonly migrationName: string; + readonly migrationHash: string; + readonly from: string | null; + readonly to: string; + readonly appliedAt: Date; + readonly operationCount: number; +} diff --git a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts index 53d68e5a43..b4480b8fdc 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-instances.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-instances.ts @@ -1,4 +1,8 @@ -import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { + Contract, + ContractMarkerRecord, + LedgerEntryRecord, +} from '@prisma-next/contract/types'; import type { AdapterInstance, DriverInstance, @@ -91,6 +95,16 @@ export interface ControlFamilyInstance readonly driver: ControlDriverInstance; }): Promise>; + /** + * Reads the per-migration ledger journal for `space` in apply order. + * Returns an empty array when the ledger table/collection has no rows + * for that space (or when the ledger store does not yet exist). + */ + readLedger(options: { + readonly driver: ControlDriverInstance; + readonly space: string; + }): Promise; + introspect(options: { readonly driver: ControlDriverInstance; readonly contract?: unknown; diff --git a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts index a472304914..7afbf0efb1 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-migration-types.ts @@ -479,6 +479,20 @@ export interface MigrationRunnerPerSpaceOptions< * Paths and metadata forwarded to schema verification diagnostics. */ readonly context?: OperationContext; + /** + * Per-edge breakdown from graph-walk planning. When present, runners that + * support a per-migration ledger write one row per edge instead of one + * collapsed row per apply. + */ + readonly migrationEdges?: + | ReadonlyArray<{ + readonly migrationHash: string; + readonly dirName: string; + readonly from: string; + readonly to: string; + readonly operationCount: number; + }> + | undefined; } export interface MigrationRunner< diff --git a/packages/1-framework/3-tooling/cli/src/control-api/client.ts b/packages/1-framework/3-tooling/cli/src/control-api/client.ts index d6ae2d155d..fd9c98f0e5 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/client.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/client.ts @@ -1,4 +1,8 @@ -import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { + Contract, + ContractMarkerRecord, + LedgerEntryRecord, +} from '@prisma-next/contract/types'; import { emit as emitContractArtifacts } from '@prisma-next/emitter'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { @@ -447,6 +451,11 @@ class ControlClientImpl implements ControlClient { return familyInstance.readAllMarkers({ driver }); } + async readLedger(space = APP_SPACE_ID): Promise { + const { driver, familyInstance } = await this.ensureConnected(); + return familyInstance.readLedger({ driver, space }); + } + async migrationApply(options: MigrationApplyOptions): Promise { const { onProgress } = options; await this.connectWithProgress(options.connection, 'migrationApply', onProgress); diff --git a/packages/1-framework/3-tooling/cli/src/control-api/operations/apply.ts b/packages/1-framework/3-tooling/cli/src/control-api/operations/apply.ts index f3c99d9a87..040b2482a3 100644 --- a/packages/1-framework/3-tooling/cli/src/control-api/operations/apply.ts +++ b/packages/1-framework/3-tooling/cli/src/control-api/operations/apply.ts @@ -141,6 +141,7 @@ export async function applyMigration>; + /** + * Reads the per-migration ledger journal for `space` in apply order. + * Returns an empty array when the ledger store does not yet exist or + * has no rows for that space. + */ + readLedger(space?: string): Promise; + /** * Applies pre-planned on-disk migrations to the database. * Each migration runs in its own transaction with full execution checks. diff --git a/packages/1-framework/3-tooling/cli/test/config-types.test.ts b/packages/1-framework/3-tooling/cli/test/config-types.test.ts index fcb4490ac5..fc977bbf0e 100644 --- a/packages/1-framework/3-tooling/cli/test/config-types.test.ts +++ b/packages/1-framework/3-tooling/cli/test/config-types.test.ts @@ -71,6 +71,7 @@ describe('defineConfig', () => { }), readMarker: async () => null, readAllMarkers: async () => new Map(), + readLedger: async () => [], introspect: async () => ({ tables: {}, extensionPacks: [] }), }), }, diff --git a/packages/2-mongo-family/9-family/src/core/control-adapter.ts b/packages/2-mongo-family/9-family/src/core/control-adapter.ts index 0d5f7abfaf..8181ffce14 100644 --- a/packages/2-mongo-family/9-family/src/core/control-adapter.ts +++ b/packages/2-mongo-family/9-family/src/core/control-adapter.ts @@ -1,4 +1,4 @@ -import type { ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { ContractMarkerRecord, LedgerEntryRecord } from '@prisma-next/contract/types'; import type { ControlAdapterDescriptor, ControlAdapterInstance, @@ -79,9 +79,25 @@ export interface MongoControlAdapter writeLedgerEntry( driver: ControlDriverInstance<'mongo', TTarget>, space: string, - entry: { readonly edgeId: string; readonly from: string; readonly to: string }, + entry: { + readonly edgeId: string; + readonly from: string; + readonly to: string; + readonly migrationName: string; + readonly migrationHash: string; + readonly operations: readonly unknown[]; + }, ): Promise; + /** + * Reads the per-migration ledger journal for `space` in apply order. + * Returns an empty array when no ledger entries exist for that space. + */ + readLedger( + driver: ControlDriverInstance<'mongo', TTarget>, + space: string, + ): Promise; + /** * Introspects the live database and returns a `MongoSchemaIR`. */ diff --git a/packages/2-mongo-family/9-family/src/core/control-instance.ts b/packages/2-mongo-family/9-family/src/core/control-instance.ts index 2db807af1f..1751d5f14d 100644 --- a/packages/2-mongo-family/9-family/src/core/control-instance.ts +++ b/packages/2-mongo-family/9-family/src/core/control-instance.ts @@ -1,4 +1,8 @@ -import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { + Contract, + ContractMarkerRecord, + LedgerEntryRecord, +} from '@prisma-next/contract/types'; import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components'; import type { ControlDriverInstance, @@ -23,6 +27,7 @@ import { assertDescriptorSelfConsistency } from '@prisma-next/migration-tools/sp import type { MongoContract } from '@prisma-next/mongo-contract'; import { mongoContractCanonicalizationHooks } from '@prisma-next/mongo-contract/canonicalization-hooks'; import type { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; +import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { MongoControlAdapter, MongoControlAdapterDescriptor } from './control-adapter'; import type { MongoControlExtensionDescriptor } from './control-types'; @@ -84,6 +89,9 @@ function isMongoControlAdapter(value: unknown): value is MongoControlAdapter<'mo typeof (value as { readMarker: unknown }).readMarker === 'function' && 'readAllMarkers' in value && typeof (value as { readAllMarkers: unknown }).readAllMarkers === 'function' && + 'readLedger' in value && + typeof blindCast<{ readLedger: unknown }, 'MongoControlAdapter duck-type probe'>(value) + .readLedger === 'function' && 'introspectSchema' in value && typeof (value as { introspectSchema: unknown }).introspectSchema === 'function' ); @@ -167,7 +175,7 @@ export function createMongoFamilyInstance(controlStack: ControlStack): MongoCont const controlAdapter = adapter.create(controlStack as ControlStack<'mongo', 'mongo'>); if (!isMongoControlAdapter(controlAdapter)) { throw new Error( - 'Adapter does not implement MongoControlAdapter (missing readMarker, readAllMarkers, or introspectSchema)', + 'Adapter does not implement MongoControlAdapter (missing readMarker, readAllMarkers, readLedger, or introspectSchema)', ); } return controlAdapter; @@ -371,6 +379,10 @@ export function createMongoFamilyInstance(controlStack: ControlStack): MongoCont return getControlAdapter().readAllMarkers(asMongoDriver(options.driver)); }, + async readLedger(options): Promise { + return getControlAdapter().readLedger(asMongoDriver(options.driver), options.space); + }, + async introspect(options): Promise { return getControlAdapter().introspectSchema(asMongoDriver(options.driver)); }, diff --git a/packages/2-sql/9-family/package.json b/packages/2-sql/9-family/package.json index 449559e24d..41e4ef87e1 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -57,6 +57,7 @@ "./control": "./dist/control.mjs", "./control-adapter": "./dist/control-adapter.mjs", "./ir": "./dist/ir.mjs", + "./ledger-read": "./dist/ledger-read.mjs", "./migration": "./dist/migration.mjs", "./pack": "./dist/pack.mjs", "./runtime": "./dist/runtime.mjs", diff --git a/packages/2-sql/9-family/src/core/control-adapter.ts b/packages/2-sql/9-family/src/core/control-adapter.ts index 9d3bd41db2..3930f69835 100644 --- a/packages/2-sql/9-family/src/core/control-adapter.ts +++ b/packages/2-sql/9-family/src/core/control-adapter.ts @@ -1,4 +1,8 @@ -import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { + Contract, + ContractMarkerRecord, + LedgerEntryRecord, +} from '@prisma-next/contract/types'; import type { ControlAdapterInstance, ControlDriverInstance, @@ -52,6 +56,16 @@ export interface SqlControlAdapter driver: ControlDriverInstance<'sql', TTarget>, ): Promise>; + /** + * Reads the per-migration ledger journal for `space` in apply order. + * Returns an empty array when the ledger store does not yet exist or + * has no rows for that space. + */ + readLedger( + driver: ControlDriverInstance<'sql', TTarget>, + space: string, + ): Promise; + /** * Introspects a database schema and returns a raw SqlSchemaIR. * diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index fd490b928b..801120c624 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -1,4 +1,8 @@ -import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { + Contract, + ContractMarkerRecord, + LedgerEntryRecord, +} from '@prisma-next/contract/types'; import type { TargetBoundComponentDescriptor, TargetDescriptor, @@ -41,6 +45,7 @@ import { } from '@prisma-next/sql-runtime'; import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming'; import type { SqlSchemaIR, SqlTableIR } from '@prisma-next/sql-schema-ir/types'; +import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { SqlControlAdapter } from './control-adapter'; import { SqlContractSerializer } from './ir/sql-contract-serializer'; @@ -260,6 +265,9 @@ function isSqlControlAdapter( typeof (value as { readMarker: unknown }).readMarker === 'function' && 'readAllMarkers' in value && typeof (value as { readAllMarkers: unknown }).readAllMarkers === 'function' && + 'readLedger' in value && + typeof blindCast<{ readLedger: unknown }, 'SqlControlAdapter duck-type probe'>(value) + .readLedger === 'function' && 'lower' in value && typeof (value as { lower: unknown }).lower === 'function' ); @@ -369,7 +377,7 @@ export function createSqlFamilyInstance( const controlAdapter = adapter.create(stack); if (!isSqlControlAdapter(controlAdapter)) { throw new Error( - 'Adapter does not implement SqlControlAdapter (missing introspect, readMarker, or readAllMarkers)', + 'Adapter does not implement SqlControlAdapter (missing introspect, readMarker, readAllMarkers, readLedger, or lower)', ); } return controlAdapter; @@ -651,6 +659,12 @@ export function createSqlFamilyInstance( }): Promise> { return getControlAdapter().readAllMarkers(options.driver); }, + async readLedger(options: { + readonly driver: ControlDriverInstance<'sql', string>; + readonly space: string; + }): Promise { + return getControlAdapter().readLedger(options.driver, options.space); + }, async introspect(options: { readonly driver: ControlDriverInstance<'sql', string>; readonly contract?: unknown; diff --git a/packages/2-sql/9-family/src/core/ledger-read.ts b/packages/2-sql/9-family/src/core/ledger-read.ts new file mode 100644 index 0000000000..03d5b9157e --- /dev/null +++ b/packages/2-sql/9-family/src/core/ledger-read.ts @@ -0,0 +1,35 @@ +import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; + +const DESIGNATOR_LESS_UTC_DATETIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?$/; + +export function ledgerOriginFromStored(originCoreHash: string | null): string | null { + if (originCoreHash === null || originCoreHash === '' || originCoreHash === EMPTY_CONTRACT_HASH) { + return null; + } + return originCoreHash; +} + +export function coerceLedgerAppliedAt(value: Date | string): Date { + if (value instanceof Date) { + return value; + } + if (DESIGNATOR_LESS_UTC_DATETIME.test(value)) { + return new Date(`${value.replace(' ', 'T')}Z`); + } + return new Date(value); +} + +export function operationCountFromStored(operations: unknown): number { + if (Array.isArray(operations)) { + return operations.length; + } + if (typeof operations === 'string') { + try { + const parsed: unknown = JSON.parse(operations); + return Array.isArray(parsed) ? parsed.length : 0; + } catch { + return 0; + } + } + return 0; +} diff --git a/packages/2-sql/9-family/src/core/migrations/types.ts b/packages/2-sql/9-family/src/core/migrations/types.ts index add4a95f1e..39469f9e7a 100644 --- a/packages/2-sql/9-family/src/core/migrations/types.ts +++ b/packages/2-sql/9-family/src/core/migrations/types.ts @@ -22,6 +22,7 @@ import type { SchemaIssue, SchemaVerifier, } from '@prisma-next/framework-components/control'; +import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; import type { SqlStorage, StorageColumn, @@ -384,6 +385,11 @@ export interface SqlMigrationRunnerExecuteOptions { * All components must have matching familyId ('sql') and targetId. */ readonly frameworkComponents: ReadonlyArray>; + /** + * Per-edge breakdown from graph-walk planning. When present, the runner + * writes one ledger row per edge instead of one collapsed row per apply. + */ + readonly migrationEdges?: readonly AggregateMigrationEdgeRef[] | undefined; } export type SqlMigrationRunnerErrorCode = diff --git a/packages/2-sql/9-family/src/exports/ledger-read.ts b/packages/2-sql/9-family/src/exports/ledger-read.ts new file mode 100644 index 0000000000..e3a3d30f66 --- /dev/null +++ b/packages/2-sql/9-family/src/exports/ledger-read.ts @@ -0,0 +1,5 @@ +export { + coerceLedgerAppliedAt, + ledgerOriginFromStored, + operationCountFromStored, +} from '../core/ledger-read'; diff --git a/packages/2-sql/9-family/test/ledger-read.test.ts b/packages/2-sql/9-family/test/ledger-read.test.ts new file mode 100644 index 0000000000..421f5fb1af --- /dev/null +++ b/packages/2-sql/9-family/test/ledger-read.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + coerceLedgerAppliedAt, + ledgerOriginFromStored, + operationCountFromStored, +} from '../src/core/ledger-read'; + +describe('ledgerOriginFromStored', () => { + it('maps empty origin sentinels to null', () => { + expect(ledgerOriginFromStored(null)).toBeNull(); + expect(ledgerOriginFromStored('')).toBeNull(); + expect(ledgerOriginFromStored('sha256:empty')).toBeNull(); + }); + + it('preserves a non-empty origin hash', () => { + expect(ledgerOriginFromStored('sha256:abc')).toBe('sha256:abc'); + }); +}); + +describe('coerceLedgerAppliedAt', () => { + it('returns Date instances unchanged', () => { + const date = new Date('2024-01-15T10:30:00.000Z'); + expect(coerceLedgerAppliedAt(date)).toBe(date); + }); + + it('parses Z-suffixed ISO-8601 as UTC', () => { + const parsed = coerceLedgerAppliedAt('2024-06-01T12:00:00.000Z'); + expect(parsed.toISOString()).toBe('2024-06-01T12:00:00.000Z'); + }); + + it('parses designator-less SQLite datetime as UTC', () => { + const parsed = coerceLedgerAppliedAt('2024-06-01 12:00:00'); + expect(parsed.toISOString()).toBe('2024-06-01T12:00:00.000Z'); + }); +}); + +describe('operationCountFromStored', () => { + it('counts array operations', () => { + expect(operationCountFromStored([{ id: 'a' }, { id: 'b' }])).toBe(2); + }); + + it('parses JSON string operations', () => { + expect(operationCountFromStored('[{"id":"a"}]')).toBe(1); + }); + + it('returns zero for invalid JSON', () => { + expect(operationCountFromStored('not-json')).toBe(0); + }); +}); diff --git a/packages/2-sql/9-family/tsdown.config.ts b/packages/2-sql/9-family/tsdown.config.ts index 018eff4957..ab5733cdcb 100644 --- a/packages/2-sql/9-family/tsdown.config.ts +++ b/packages/2-sql/9-family/tsdown.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ 'src/exports/pack.ts', 'src/exports/runtime.ts', 'src/exports/verify.ts', + 'src/exports/ledger-read.ts', 'src/exports/test-utils.ts', 'src/exports/schema-verify.ts', ], diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-runner.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-runner.ts index 4cd5d0c43b..af92d972ab 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-runner.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-runner.ts @@ -13,6 +13,7 @@ import { type MigrationRunnerPerSpaceSuccessValue, type OperationContext, } from '@prisma-next/framework-components/control'; +import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; import type { MongoContract } from '@prisma-next/mongo-contract'; import type { MongoAdapter, MongoDriver } from '@prisma-next/mongo-lowering'; import type { @@ -55,6 +56,8 @@ export interface MongoMigrationRunnerExecuteOptions { * leave it unset and verify against the whole introspected schema. */ readonly projectSchema?: (schema: MongoSchemaIR) => MongoSchemaIR; + /** Per-edge breakdown from graph-walk planning; drives per-edge ledger writes. */ + readonly migrationEdges?: readonly AggregateMigrationEdgeRef[] | undefined; } export type MongoMigrationRunnerResult = Result< @@ -101,8 +104,9 @@ export class MongoMigrationRunner { const filterEvaluator = new FilterEvaluator(); let operationsExecuted = 0; + const executedPlanOps: unknown[] = []; - for (const operation of operations) { + for (const [i, operation] of operations.entries()) { options.callbacks?.onOperationStart?.(operation); try { if (operation.operationClass === 'data') { @@ -116,7 +120,10 @@ export class MongoMigrationRunner { runPostchecks, ); if (result.failure) return result.failure; - if (result.executed) operationsExecuted += 1; + if (result.executed) { + operationsExecuted += 1; + executedPlanOps.push(options.plan.operations[i]); + } continue; } @@ -166,6 +173,7 @@ export class MongoMigrationRunner { } operationsExecuted += 1; + executedPlanOps.push(options.plan.operations[i]); } finally { options.callbacks?.onOperationComplete?.(operation); } @@ -249,17 +257,57 @@ export class MongoMigrationRunner { }); } - const originHash = existingMarker?.storageHash ?? ''; - await markerOps.writeLedgerEntry(space, { - edgeId: `${originHash}->${destination.storageHash}`, - from: originHash, - to: destination.storageHash, - }); + await this.recordLedgerEntries(markerOps, space, options, existingMarker, executedPlanOps); } return ok({ operationsPlanned: operations.length, operationsExecuted }); } + private async recordLedgerEntries( + markerOps: MarkerOperations, + space: string, + options: MongoMigrationRunnerExecuteOptions, + existingMarker: ContractMarkerRecord | null, + executedPlanOps: readonly unknown[], + ): Promise { + const plan = options.plan; + const destination = plan.destination; + const edges = options.migrationEdges; + + if (edges !== undefined && edges.length > 0) { + const totalEdgeOps = edges.reduce((sum, edge) => sum + edge.operationCount, 0); + if (totalEdgeOps !== plan.operations.length) { + throw new Error( + `Ledger write: plan.operations length (${plan.operations.length}) does not match sum of migrationEdges operationCount (${totalEdgeOps})`, + ); + } + let offset = 0; + for (const edge of edges) { + const edgeOps = plan.operations.slice(offset, offset + edge.operationCount); + offset += edge.operationCount; + await markerOps.writeLedgerEntry(space, { + edgeId: `${edge.from}->${edge.to}`, + from: edge.from, + to: edge.to, + migrationName: edge.dirName, + migrationHash: edge.migrationHash, + operations: edgeOps, + }); + } + return; + } + + const originHash = existingMarker?.storageHash ?? ''; + await markerOps.writeLedgerEntry(space, { + edgeId: `${originHash}->${destination.storageHash}`, + from: originHash, + to: destination.storageHash, + migrationName: '', + migrationHash: destination.storageHash, + operations: executedPlanOps, + }); + } + private async executeDataTransform( op: MongoDataTransformOperation, adapter: MongoAdapter, diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-runner-integration.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-runner-integration.test.ts index 1535bca947..bb47099916 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-runner-integration.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-runner-integration.test.ts @@ -3,14 +3,18 @@ import { createMongoRunnerDeps, initMarker, introspectSchema, + readLedger, readMarker, } from '@prisma-next/adapter-mongo/control'; import { MongoDriverImpl } from '@prisma-next/driver-mongo'; +import type { MongoControlFamilyInstance } from '@prisma-next/family-mongo/control'; import type { ControlFamilyInstance, MigrationPlan, MigrationPlanOperation, } from '@prisma-next/framework-components/control'; +import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; +import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; import type { MongoContract } from '@prisma-next/mongo-contract'; import type { AnyMongoMigrationOperation } from '@prisma-next/mongo-query-ast/control'; import { @@ -21,6 +25,8 @@ import { import { type Db, MongoClient } from 'mongodb'; import { MongoMemoryReplSet } from 'mongodb-memory-server'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mongoTargetDescriptor } from '../src/core/control-target'; +import { createCollection } from '../src/core/migration-factories'; import { serializeMongoOps } from '../src/core/mongo-ops-serializer'; import { MongoMigrationPlanner } from '../src/core/mongo-planner'; import { MongoMigrationRunner } from '../src/core/mongo-runner'; @@ -877,6 +883,67 @@ describe('MongoMigrationRunner - E2E round-trip', () => { }); }); +describe('mongoTargetDescriptor migrations.createRunner — per-edge ledger', () => { + it('threads migrationEdges through createRunner().execute() into per-edge ledger docs', async () => { + const runner = mongoTargetDescriptor.migrations.createRunner( + fakeFamily() as MongoControlFamilyInstance, + ); + const driver = createMongoControlDriver(db, client); + const space = 'ledger-wrapper-test'; + const destHash = 'sha256:wrapper-dest'; + const midHash = 'sha256:wrapper-mid'; + const contract = bareContract(destHash); + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-a', + dirName: '001_a', + from: EMPTY_CONTRACT_HASH, + to: midHash, + operationCount: 1, + }, + { + migrationHash: 'sha256:mig-b', + dirName: '002_b', + from: midHash, + to: destHash, + operationCount: 1, + }, + ]; + const plan: MigrationPlan = { + targetId: 'mongo', + spaceId: space, + origin: null, + destination: { storageHash: destHash }, + operations: JSON.parse( + serializeMongoOps([createCollection('wrapper_a'), createCollection('wrapper_b')]), + ) as MigrationPlan['operations'], + }; + + const result = await runner.execute({ + driver, + perSpaceOptions: [ + { + space, + plan, + driver, + destinationContract: contract, + policy: { allowedOperationClasses: ['additive', 'widening', 'destructive'] }, + frameworkComponents: [], + strictVerification: false, + executionChecks: { prechecks: false, postchecks: false, idempotencyChecks: false }, + migrationEdges: edges, + }, + ], + }); + + expect(result.ok).toBe(true); + const ledger = await readLedger(db, space); + expect(ledger).toHaveLength(2); + expect(ledger.map((entry) => entry.migrationName)).toEqual(['001_a', '002_b']); + expect(ledger.map((entry) => entry.migrationHash)).toEqual(['sha256:mig-a', 'sha256:mig-b']); + }); +}); + describe('MongoControlDriver', () => { it('query() throws because MongoDB does not support SQL', () => { const driver = createMongoControlDriver(db, client); diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-runner.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-runner.test.ts index 78f00797e4..5cd26d48ff 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-runner.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-runner.test.ts @@ -4,6 +4,8 @@ import type { MigrationPlan, MigrationRunnerExecutionChecks, } from '@prisma-next/framework-components/control'; +import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; +import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; import type { MongoContract } from '@prisma-next/mongo-contract'; import type { MongoAdapter, MongoDriver, MongoLoweredDraft } from '@prisma-next/mongo-lowering'; import type { @@ -541,3 +543,219 @@ describe('MongoMigrationRunner schema verification', () => { expect(calls.writeLedgerEntry).toBe(1); }); }); + +const LEDGER_TEST_SPACE_ID = 'ledger-test'; + +type LedgerEntryPayload = Parameters[1]; + +function makeLedgerHarness(): { + runner: MongoMigrationRunner; + ledgerEntries: LedgerEntryPayload[]; +} { + const ledgerEntries: LedgerEntryPayload[] = []; + const markerOps: MarkerOperations = { + readMarker: async () => null, + initMarker: async () => {}, + updateMarker: async () => true, + writeLedgerEntry: async (_space, entry) => { + ledgerEntries.push(entry); + }, + }; + const deps: MongoRunnerDependencies = { + commandExecutor: new StubCommandExecutor(), + inspectionExecutor: new StubInspectionExecutor(), + adapter: new StubMongoAdapter(), + driver: new StubMongoDriver(), + markerOps, + introspectSchema: async () => new MongoSchemaIR([]), + }; + return { runner: new MongoMigrationRunner(deps), ledgerEntries }; +} + +function makeLedgerPlan( + ops: readonly AnyMongoMigrationOperation[], + options: { + readonly destinationHash?: string; + readonly migrationEdges?: readonly AggregateMigrationEdgeRef[]; + } = {}, +): MigrationPlan { + return { + targetId: 'mongo', + spaceId: LEDGER_TEST_SPACE_ID, + origin: null, + destination: { storageHash: options.destinationHash ?? 'sha256:dest' }, + operations: serializedOperations(ops) as unknown as MigrationPlan['operations'], + }; +} + +const LEDGER_EXECUTION_CHECKS: MigrationRunnerExecutionChecks = { + prechecks: false, + postchecks: false, + idempotencyChecks: false, +}; + +describe('MongoMigrationRunner - per-edge ledger', () => { + it('writes one ledger entry for a single-edge apply with space, name, hash, from/to, and that edge ops', async () => { + const { runner, ledgerEntries } = makeLedgerHarness(); + const destHash = 'sha256:dest'; + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-single', + dirName: '001_single', + from: EMPTY_CONTRACT_HASH, + to: destHash, + operationCount: 1, + }, + ]; + const planOps = serializedOperations([createCollection('ledger_single')]); + const result = await runner.execute({ + plan: makeLedgerPlan([createCollection('ledger_single')], { destinationHash: destHash }), + destinationContract: makeContract(destHash), + policy: ALL_POLICY, + frameworkComponents: [], + strictVerification: false, + executionChecks: LEDGER_EXECUTION_CHECKS, + migrationEdges: edges, + }); + + expect(result.assertOk()).toEqual({ operationsPlanned: 1, operationsExecuted: 1 }); + expect(ledgerEntries).toHaveLength(1); + expect(ledgerEntries[0]).toMatchObject({ + edgeId: `${EMPTY_CONTRACT_HASH}->${destHash}`, + from: EMPTY_CONTRACT_HASH, + to: destHash, + migrationName: '001_single', + migrationHash: 'sha256:mig-single', + }); + const storedOps = ledgerEntries[0]?.operations as Array<{ id: string }>; + expect(storedOps).toHaveLength(1); + expect(storedOps[0]?.id).toBe((planOps[0] as { id: string }).id); + }); + + it('writes N ledger entries in walk order for multi-edge apply with ops attributed per edge', async () => { + const { runner, ledgerEntries } = makeLedgerHarness(); + const hashA = 'sha256:ledger-mid-a'; + const hashB = 'sha256:ledger-mid-b'; + const destHash = 'sha256:dest'; + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-a', + dirName: '001_a', + from: EMPTY_CONTRACT_HASH, + to: hashA, + operationCount: 1, + }, + { + migrationHash: 'sha256:mig-b', + dirName: '002_b', + from: hashA, + to: hashB, + operationCount: 2, + }, + { + migrationHash: 'sha256:mig-c', + dirName: '003_c', + from: hashB, + to: destHash, + operationCount: 1, + }, + ]; + const ops = [ + createCollection('ledger_a'), + createCollection('ledger_b1'), + createCollection('ledger_b2'), + createCollection('ledger_c'), + ]; + const planOps = serializedOperations(ops) as Array<{ id: string }>; + + const result = await runner.execute({ + plan: makeLedgerPlan(ops, { destinationHash: destHash }), + destinationContract: makeContract(destHash), + policy: ALL_POLICY, + frameworkComponents: [], + strictVerification: false, + executionChecks: LEDGER_EXECUTION_CHECKS, + migrationEdges: edges, + }); + + expect(result.assertOk()).toEqual({ operationsPlanned: 4, operationsExecuted: 4 }); + expect(ledgerEntries).toHaveLength(3); + expect(ledgerEntries.map((e) => e.migrationName)).toEqual(['001_a', '002_b', '003_c']); + expect(ledgerEntries[0]).toMatchObject({ + edgeId: `${EMPTY_CONTRACT_HASH}->${hashA}`, + from: EMPTY_CONTRACT_HASH, + to: hashA, + migrationHash: 'sha256:mig-a', + }); + expect(ledgerEntries[1]).toMatchObject({ + edgeId: `${hashA}->${hashB}`, + from: hashA, + to: hashB, + migrationHash: 'sha256:mig-b', + }); + expect(ledgerEntries[2]).toMatchObject({ + edgeId: `${hashB}->${destHash}`, + from: hashB, + to: destHash, + migrationHash: 'sha256:mig-c', + }); + + const opCounts = ledgerEntries.map((e) => (e.operations as unknown[]).length); + expect(opCounts).toEqual([1, 2, 1]); + const opIds = ledgerEntries.flatMap((e) => + (e.operations as Array<{ id: string }>).map((o) => o.id), + ); + expect(opIds).toEqual(planOps.map((o) => o.id)); + }); + + it('throws when migrationEdges operationCount sum does not match plan.operations length', async () => { + const { runner } = makeLedgerHarness(); + const destHash = 'sha256:dest'; + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-single', + dirName: '001_single', + from: EMPTY_CONTRACT_HASH, + to: destHash, + operationCount: 2, + }, + ]; + + await expect( + runner.execute({ + plan: makeLedgerPlan([createCollection('ledger_single')], { destinationHash: destHash }), + destinationContract: makeContract(destHash), + policy: ALL_POLICY, + frameworkComponents: [], + strictVerification: false, + executionChecks: LEDGER_EXECUTION_CHECKS, + migrationEdges: edges, + }), + ).rejects.toThrow(/does not match sum of migrationEdges operationCount/); + }); + + it('writes one synthesised ledger entry with empty migration name for synth apply without migrationEdges', async () => { + const { runner, ledgerEntries } = makeLedgerHarness(); + const destHash = 'sha256:dest'; + + const result = await runner.execute({ + plan: makeLedgerPlan([createCollection('ledger_synth')], { destinationHash: destHash }), + destinationContract: makeContract(destHash), + policy: ALL_POLICY, + frameworkComponents: [], + strictVerification: false, + executionChecks: LEDGER_EXECUTION_CHECKS, + }); + + expect(result.assertOk()).toEqual({ operationsPlanned: 1, operationsExecuted: 1 }); + expect(ledgerEntries).toHaveLength(1); + expect(ledgerEntries[0]).toMatchObject({ + edgeId: `->${destHash}`, + from: '', + to: destHash, + migrationName: '', + migrationHash: destHash, + }); + expect((ledgerEntries[0]?.operations as unknown[]).length).toBe(1); + }); +}); diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/marker-ledger.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/marker-ledger.ts index 24e600a4f5..43a0d94613 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/core/marker-ledger.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/marker-ledger.ts @@ -1,4 +1,4 @@ -import type { ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { ContractMarkerRecord, LedgerEntryRecord } from '@prisma-next/contract/types'; import { parseMarkerRowSafely, withMarkerReadErrorHandling } from '@prisma-next/errors/execution'; import { RawAggregateCommand, @@ -10,6 +10,15 @@ import type { Db, Document, UpdateFilter } from 'mongodb'; const COLLECTION = '_prisma_migrations'; const MONGO_MARKER_COLLECTION = `_prisma_migrations marker documents in ${COLLECTION}`; +const MONGO_LEDGER_COLLECTION = `_prisma_migrations ledger documents in ${COLLECTION}`; +const EMPTY_ORIGIN_HASH = 'sha256:empty'; + +function ledgerOriginFromStored(from: string): string | null { + if (from === '' || from === EMPTY_ORIGIN_HASH) { + return null; + } + return from; +} /** * Marker doc shape. @@ -235,10 +244,69 @@ export async function updateMarker( * a synthetic ∅→head edge on first apply), so the ledger key is * `(space, edgeId)` — the doc carries `space` for partitioned reads. */ +/** + * Reads per-migration ledger entries for `space` in apply order. Returns + * `[]` when no ledger documents exist for that space yet. + */ +export async function readLedger(db: Db, space: string): Promise { + const ledgerContext = { space, markerLocation: MONGO_LEDGER_COLLECTION }; + const docs = await withMarkerReadErrorHandling( + () => + executeAggregate( + db, + new RawAggregateCommand(COLLECTION, [ + { $match: { type: 'ledger', space } }, + { $sort: { _id: 1 } }, + ]), + ), + ledgerContext, + ); + + const entries: LedgerEntryRecord[] = []; + for (const doc of docs) { + const migrationName = doc['migrationName']; + const migrationHash = doc['migrationHash']; + const from = doc['from']; + const to = doc['to']; + if (typeof migrationName !== 'string' || typeof migrationHash !== 'string') { + continue; + } + if (typeof from !== 'string' || typeof to !== 'string') { + continue; + } + const appliedAt = doc['appliedAt']; + const appliedAtDate = + appliedAt instanceof Date + ? appliedAt + : appliedAt !== undefined + ? new Date(String(appliedAt)) + : new Date(); + const operations = doc['operations']; + const opList = Array.isArray(operations) ? operations : []; + entries.push({ + space, + migrationName, + migrationHash, + from: ledgerOriginFromStored(from), + to, + appliedAt: appliedAtDate, + operationCount: opList.length, + }); + } + return entries; +} + export async function writeLedgerEntry( db: Db, space: string, - entry: { readonly edgeId: string; readonly from: string; readonly to: string }, + entry: { + readonly edgeId: string; + readonly from: string; + readonly to: string; + readonly migrationName: string; + readonly migrationHash: string; + readonly operations: readonly unknown[]; + }, ): Promise { const cmd = new RawInsertOneCommand(COLLECTION, { type: 'ledger', @@ -246,6 +314,9 @@ export async function writeLedgerEntry( edgeId: entry.edgeId, from: entry.from, to: entry.to, + migrationName: entry.migrationName, + migrationHash: entry.migrationHash, + operations: entry.operations, appliedAt: new Date(), }); await executeInsertOne(db, cmd); diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/mongo-control-adapter.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/mongo-control-adapter.ts index 83d80ee23e..7fecb070e9 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/core/mongo-control-adapter.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/mongo-control-adapter.ts @@ -1,4 +1,4 @@ -import type { ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { ContractMarkerRecord, LedgerEntryRecord } from '@prisma-next/contract/types'; import type { MongoControlAdapter } from '@prisma-next/family-mongo/control-adapter'; import type { ControlDriverInstance } from '@prisma-next/framework-components/control'; import type { MongoSchemaIR } from '@prisma-next/mongo-schema-ir'; @@ -6,6 +6,7 @@ import { introspectSchema } from './introspect-schema'; import { initMarker, readAllMarkers, + readLedger, readMarker, updateMarker, writeLedgerEntry, @@ -63,11 +64,25 @@ export class MongoControlAdapterImpl implements MongoControlAdapter<'mongo'> { async writeLedgerEntry( driver: ControlDriverInstance<'mongo', 'mongo'>, space: string, - entry: { readonly edgeId: string; readonly from: string; readonly to: string }, + entry: { + readonly edgeId: string; + readonly from: string; + readonly to: string; + readonly migrationName: string; + readonly migrationHash: string; + readonly operations: readonly unknown[]; + }, ): Promise { await writeLedgerEntry(extractDb(driver), space, entry); } + async readLedger( + driver: ControlDriverInstance<'mongo', 'mongo'>, + space: string, + ): Promise { + return readLedger(extractDb(driver), space); + } + async introspectSchema(driver: ControlDriverInstance<'mongo', 'mongo'>): Promise { return introspectSchema(extractDb(driver)); } diff --git a/packages/3-mongo-target/2-mongo-adapter/src/core/runner-deps.ts b/packages/3-mongo-target/2-mongo-adapter/src/core/runner-deps.ts index 87fe7d9353..5d059d1a7e 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/core/runner-deps.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/core/runner-deps.ts @@ -57,6 +57,9 @@ export interface MarkerOperations { readonly edgeId: string; readonly from: string; readonly to: string; + readonly migrationName: string; + readonly migrationHash: string; + readonly operations: readonly unknown[]; }, ): Promise; } @@ -82,11 +85,11 @@ export interface MongoRunnerDependencies { export function createMongoRunnerDeps( controlDriver: ControlDriverInstance<'mongo', 'mongo'>, driver: MongoDriver, - // Vestigial after the M2.5 family→adapter SPI dispatch refactor: the runner - // dependencies now route every wire-level call through `controlAdapter`, so - // the `family` instance is no longer consulted. Kept on the signature to - // avoid rippling through ~14 call sites mid-orchestration; a follow-up that - // already touches this factory should drop the parameter outright. + // Vestigial after the family→adapter SPI refactor: the runner dependencies + // now route every wire-level call through `controlAdapter`, so the `family` + // instance is no longer consulted. Kept on the signature to avoid rippling + // through ~14 call sites; a follow-up that already touches this factory + // should drop the parameter outright. _family: ControlFamilyInstance<'mongo', MongoSchemaIR>, controlAdapter: MongoControlAdapter<'mongo'> = new MongoControlAdapterImpl(), ): MongoRunnerDependencies { diff --git a/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts b/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts index 03e13a2290..c23a706697 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts @@ -5,6 +5,7 @@ export { introspectSchema } from '../core/introspect-schema'; export { initMarker, readAllMarkers, + readLedger, readMarker, updateMarker, writeLedgerEntry, diff --git a/packages/3-mongo-target/2-mongo-adapter/test/marker-ledger.test.ts b/packages/3-mongo-target/2-mongo-adapter/test/marker-ledger.test.ts index 5e8c1bee1c..bdf20b59e8 100644 --- a/packages/3-mongo-target/2-mongo-adapter/test/marker-ledger.test.ts +++ b/packages/3-mongo-target/2-mongo-adapter/test/marker-ledger.test.ts @@ -1,10 +1,13 @@ +import type { LedgerEntryRecord } from '@prisma-next/contract/types'; import { CliStructuredError } from '@prisma-next/errors/control'; +import { timeouts } from '@prisma-next/test-utils'; import { type Db, MongoClient } from 'mongodb'; import { MongoMemoryReplSet } from 'mongodb-memory-server'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { initMarker, readAllMarkers, + readLedger, readMarker, updateMarker, writeLedgerEntry, @@ -25,12 +28,12 @@ beforeAll(async () => { client = new MongoClient(replSet.getUri()); await client.connect(); db = client.db(dbName); -}); +}, timeouts.spinUpMongoMemoryServer); afterAll(async () => { await client?.close(); await replSet?.stop(); -}); +}, timeouts.spinUpMongoMemoryServer); beforeEach(async () => { await db.collection('_prisma_migrations').deleteMany({}); @@ -331,12 +334,147 @@ describe('updateMarker', () => { }); }); -describe('writeLedgerEntry', () => { +type ExpectedLedgerEntry = Omit; + +function expectReadLedger( + entries: readonly LedgerEntryRecord[], + expected: readonly ExpectedLedgerEntry[], +): void { + expect(entries).toHaveLength(expected.length); + for (const entry of entries) { + expect(entry.appliedAt).toBeInstanceOf(Date); + } + expect(entries.map(({ appliedAt: _appliedAt, ...rest }) => rest)).toEqual(expected); +} + +describe('readLedger', { timeout: timeouts.databaseOperation }, () => { + it('returns an empty array when no ledger entries exist for the space', async () => { + expect(await readLedger(db, APP)).toEqual([]); + }); + + it('returns entries in insertion order with cross-target LedgerEntryRecord shape', async () => { + const hashA = 'sha256:ledger-mid-a'; + const hashB = 'sha256:ledger-mid-b'; + const destHash = 'sha256:ledger-dest'; + await writeLedgerEntry(db, APP, { + edgeId: 'sha256:empty->sha256:ledger-mid-a', + from: 'sha256:empty', + to: hashA, + migrationName: '001_a', + migrationHash: 'sha256:mig-a', + operations: [{ id: 'edge.a' }], + }); + await writeLedgerEntry(db, APP, { + edgeId: `${hashA}->${hashB}`, + from: hashA, + to: hashB, + migrationName: '002_b', + migrationHash: 'sha256:mig-b', + operations: [{ id: 'edge.b1' }, { id: 'edge.b2' }], + }); + await writeLedgerEntry(db, APP, { + edgeId: `${hashB}->${destHash}`, + from: hashB, + to: destHash, + migrationName: '003_c', + migrationHash: 'sha256:mig-c', + operations: [{ id: 'edge.c' }], + }); + + const ledger = await readLedger(db, APP); + expectReadLedger(ledger, [ + { + space: APP, + migrationName: '001_a', + migrationHash: 'sha256:mig-a', + from: null, + to: hashA, + operationCount: 1, + }, + { + space: APP, + migrationName: '002_b', + migrationHash: 'sha256:mig-b', + from: hashA, + to: hashB, + operationCount: 2, + }, + { + space: APP, + migrationName: '003_c', + migrationHash: 'sha256:mig-c', + from: hashB, + to: destHash, + operationCount: 1, + }, + ]); + }); + + it('skips legacy ledger docs missing migrationName or migrationHash', async () => { + await db.collection('_prisma_migrations').insertOne({ + type: 'ledger', + space: APP, + edgeId: 'legacy-edge', + from: 'sha256:v1', + to: 'sha256:v2', + appliedAt: new Date('2024-01-01T00:00:00.000Z'), + operations: [], + }); + await writeLedgerEntry(db, APP, { + edgeId: 'edge-1', + from: 'sha256:v1', + to: 'sha256:v2', + migrationName: '001_ok', + migrationHash: 'sha256:ok', + operations: [{ id: 'op.one' }], + }); + + const ledger = await readLedger(db, APP); + expectReadLedger(ledger, [ + { + space: APP, + migrationName: '001_ok', + migrationHash: 'sha256:ok', + from: 'sha256:v1', + to: 'sha256:v2', + operationCount: 1, + }, + ]); + }); + + it('maps synth empty-string from to null', async () => { + await writeLedgerEntry(db, APP, { + edgeId: '->sha256:v1', + from: '', + to: 'sha256:v1', + migrationName: '', + migrationHash: 'sha256:v1', + operations: [], + }); + + const ledger = await readLedger(db, APP); + expectReadLedger(ledger, [ + { + space: APP, + migrationName: '', + migrationHash: 'sha256:v1', + from: null, + to: 'sha256:v1', + operationCount: 0, + }, + ]); + }); +}); + +describe('writeLedgerEntry', { timeout: timeouts.databaseOperation }, () => { it('writes a ledger entry that exists in collection, tagged with space', async () => { await writeLedgerEntry(db, APP, { edgeId: 'edge-1', from: 'sha256:v1', to: 'sha256:v2', + migrationName: '001_init', + migrationHash: 'sha256:mig-1', + operations: [{ id: 'op.one' }], }); const entries = await db.collection('_prisma_migrations').find({ type: 'ledger' }).toArray(); @@ -347,6 +485,9 @@ describe('writeLedgerEntry', () => { edgeId: 'edge-1', from: 'sha256:v1', to: 'sha256:v2', + migrationName: '001_init', + migrationHash: 'sha256:mig-1', + operations: [{ id: 'op.one' }], }); expect(entries[0]?.['appliedAt']).toBeInstanceOf(Date); }); @@ -356,11 +497,17 @@ describe('writeLedgerEntry', () => { edgeId: 'edge-1', from: 'sha256:v1', to: 'sha256:v2', + migrationName: '001_a', + migrationHash: 'sha256:a', + operations: [{ id: 'a' }], }); await writeLedgerEntry(db, APP, { edgeId: 'edge-2', from: 'sha256:v2', to: 'sha256:v3', + migrationName: '002_b', + migrationHash: 'sha256:b', + operations: [{ id: 'b' }], }); const entries = await db @@ -374,8 +521,22 @@ describe('writeLedgerEntry', () => { }); it('records the same edgeId across different spaces without collision (key is (space, edgeId))', async () => { - await writeLedgerEntry(db, APP, { edgeId: 'edge-1', from: '', to: 'sha256:v1' }); - await writeLedgerEntry(db, EXT, { edgeId: 'edge-1', from: '', to: 'sha256:v1' }); + await writeLedgerEntry(db, APP, { + edgeId: 'edge-1', + from: '', + to: 'sha256:v1', + migrationName: '', + migrationHash: 'sha256:v1', + operations: [], + }); + await writeLedgerEntry(db, EXT, { + edgeId: 'edge-1', + from: '', + to: 'sha256:v1', + migrationName: '', + migrationHash: 'sha256:v1', + operations: [], + }); const entries = await db .collection('_prisma_migrations') @@ -447,7 +608,14 @@ describe('readAllMarkers', () => { it('excludes ledger entries (filter keys on string _id with a space field)', async () => { await initMarker(db, APP, { storageHash: 'sha256:app1', profileHash: 'sha256:p1' }); - await writeLedgerEntry(db, APP, { edgeId: 'edge-1', from: '', to: 'sha256:app1' }); + await writeLedgerEntry(db, APP, { + edgeId: 'edge-1', + from: '', + to: 'sha256:app1', + migrationName: '', + migrationHash: 'sha256:app1', + operations: [], + }); const markers = await readAllMarkers(db); expect(markers.size).toBe(1); diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts index 7325e08d78..888cb92114 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/runner.ts @@ -166,7 +166,12 @@ class PostgresMigrationRunner implements SqlMigrationRunner['driver'], options: SqlMigrationRunnerExecuteOptions, existingMarker: ContractMarkerRecord | null, executedOperations: readonly SqlMigrationPlanOperation[], ): Promise { - const ledgerStatement = buildLedgerInsertStatement({ - originStorageHash: existingMarker?.storageHash ?? null, - originProfileHash: existingMarker?.profileHash ?? null, - destinationStorageHash: options.plan.destination.storageHash, - destinationProfileHash: - options.plan.destination.profileHash ?? - options.destinationContract.profileHash ?? - options.plan.destination.storageHash, - contractJsonBefore: existingMarker?.contractJson ?? null, - contractJsonAfter: options.destinationContract, - operations: executedOperations, - }); - await this.executeStatement(driver, ledgerStatement); + const plan = options.plan; + const space = plan.spaceId; + const destinationProfileHash = + plan.destination.profileHash ?? + options.destinationContract.profileHash ?? + plan.destination.storageHash; + const edges = options.migrationEdges; + + if (edges !== undefined && edges.length > 0) { + const totalEdgeOps = edges.reduce((sum, edge) => sum + edge.operationCount, 0); + if (totalEdgeOps !== plan.operations.length) { + throw new Error( + `Ledger write: plan.operations length (${plan.operations.length}) does not match sum of migrationEdges operationCount (${totalEdgeOps})`, + ); + } + let offset = 0; + const lastIndex = edges.length - 1; + for (const [i, edge] of edges.entries()) { + const edgeOps = plan.operations.slice(offset, offset + edge.operationCount); + offset += edge.operationCount; + const isFirst = i === 0; + const isLast = i === lastIndex; + await this.executeStatement( + driver, + buildLedgerInsertStatement({ + space, + migrationName: edge.dirName, + migrationHash: edge.migrationHash, + originStorageHash: edge.from, + originProfileHash: + isFirst && existingMarker?.storageHash === edge.from + ? (existingMarker.profileHash ?? null) + : null, + destinationStorageHash: edge.to, + destinationProfileHash: + isLast && edge.to === plan.destination.storageHash ? destinationProfileHash : null, + contractJsonBefore: isFirst ? (existingMarker?.contractJson ?? null) : null, + contractJsonAfter: isLast ? options.destinationContract : null, + operations: edgeOps, + }), + ); + } + return; + } + + // Synth plans have no authored edges — one row keyed by the plan destination. + await this.executeStatement( + driver, + buildLedgerInsertStatement({ + space, + migrationName: '', + migrationHash: plan.destination.storageHash, + originStorageHash: existingMarker?.storageHash ?? null, + originProfileHash: existingMarker?.profileHash ?? null, + destinationStorageHash: plan.destination.storageHash, + destinationProfileHash, + contractJsonBefore: existingMarker?.contractJson ?? null, + contractJsonAfter: options.destinationContract, + operations: executedOperations, + }), + ); } private async acquireLock( diff --git a/packages/3-targets/3-targets/postgres/src/core/migrations/statement-builders.ts b/packages/3-targets/3-targets/postgres/src/core/migrations/statement-builders.ts index 916a9bd3b9..8d27b87e30 100644 --- a/packages/3-targets/3-targets/postgres/src/core/migrations/statement-builders.ts +++ b/packages/3-targets/3-targets/postgres/src/core/migrations/statement-builders.ts @@ -40,6 +40,9 @@ export const ensureLedgerTableStatement: SqlStatement = { sql: `create table if not exists prisma_contract.ledger ( id bigserial primary key, created_at timestamptz not null default now(), + space text not null, + migration_name text not null, + migration_hash text not null, origin_core_hash text, origin_profile_hash text, destination_core_hash text not null, @@ -138,6 +141,9 @@ export function buildMergeMarkerStatements(input: MergeMarkerInput): { } export interface LedgerInsertInput { + readonly space: string; + readonly migrationName: string; + readonly migrationHash: string; readonly originStorageHash?: string | null; readonly originProfileHash?: string | null; readonly destinationStorageHash: string; @@ -150,6 +156,9 @@ export interface LedgerInsertInput { export function buildLedgerInsertStatement(input: LedgerInsertInput): SqlStatement { return { sql: `insert into prisma_contract.ledger ( + space, + migration_name, + migration_hash, origin_core_hash, origin_profile_hash, destination_core_hash, @@ -162,11 +171,17 @@ export function buildLedgerInsertStatement(input: LedgerInsertInput): SqlStateme $2, $3, $4, - $5::jsonb, - $6::jsonb, - $7::jsonb + $5, + $6, + $7, + $8::jsonb, + $9::jsonb, + $10::jsonb )`, params: [ + input.space, + input.migrationName, + input.migrationHash, input.originStorageHash ?? null, input.originProfileHash ?? null, input.destinationStorageHash, diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts index 43253070f1..9a72667232 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/runner.ts @@ -127,7 +127,7 @@ class SqliteMigrationRunner implements SqlMigrationRunner['driver'], options: SqlMigrationRunnerExecuteOptions, existingMarker: ContractMarkerRecord | null, executedOperations: readonly SqlMigrationPlanOperation[], ): Promise { - const ledgerStatement = buildLedgerInsertStatement({ - originStorageHash: existingMarker?.storageHash ?? null, - originProfileHash: existingMarker?.profileHash ?? null, - destinationStorageHash: options.plan.destination.storageHash, - destinationProfileHash: - options.plan.destination.profileHash ?? - options.destinationContract.profileHash ?? - options.plan.destination.storageHash, - contractJsonBefore: existingMarker?.contractJson ?? null, - contractJsonAfter: options.destinationContract, - operations: executedOperations, - }); - await this.executeStatement(driver, ledgerStatement); + const plan = options.plan; + const space = plan.spaceId; + const destinationProfileHash = + plan.destination.profileHash ?? + options.destinationContract.profileHash ?? + plan.destination.storageHash; + const edges = options.migrationEdges; + + if (edges !== undefined && edges.length > 0) { + const totalEdgeOps = edges.reduce((sum, edge) => sum + edge.operationCount, 0); + if (totalEdgeOps !== plan.operations.length) { + throw new Error( + `Ledger write: plan.operations length (${plan.operations.length}) does not match sum of migrationEdges operationCount (${totalEdgeOps})`, + ); + } + let offset = 0; + const lastIndex = edges.length - 1; + for (const [i, edge] of edges.entries()) { + const edgeOps = plan.operations.slice(offset, offset + edge.operationCount); + offset += edge.operationCount; + const isFirst = i === 0; + const isLast = i === lastIndex; + await this.executeStatement( + driver, + buildLedgerInsertStatement({ + space, + migrationName: edge.dirName, + migrationHash: edge.migrationHash, + originStorageHash: edge.from, + originProfileHash: + isFirst && existingMarker?.storageHash === edge.from + ? (existingMarker.profileHash ?? null) + : null, + destinationStorageHash: edge.to, + destinationProfileHash: + isLast && edge.to === plan.destination.storageHash ? destinationProfileHash : null, + contractJsonBefore: isFirst ? (existingMarker?.contractJson ?? null) : null, + contractJsonAfter: isLast ? options.destinationContract : null, + operations: edgeOps, + }), + ); + } + return; + } + + // Synth plans have no authored edges — one row keyed by the plan destination. + await this.executeStatement( + driver, + buildLedgerInsertStatement({ + space, + migrationName: '', + migrationHash: plan.destination.storageHash, + originStorageHash: existingMarker?.storageHash ?? null, + originProfileHash: existingMarker?.profileHash ?? null, + destinationStorageHash: plan.destination.storageHash, + destinationProfileHash, + contractJsonBefore: existingMarker?.contractJson ?? null, + contractJsonAfter: options.destinationContract, + operations: executedOperations, + }), + ); } private async beginExclusiveTransaction( diff --git a/packages/3-targets/3-targets/sqlite/src/core/migrations/statement-builders.ts b/packages/3-targets/3-targets/sqlite/src/core/migrations/statement-builders.ts index 4d8bae8087..ead14ba13f 100644 --- a/packages/3-targets/3-targets/sqlite/src/core/migrations/statement-builders.ts +++ b/packages/3-targets/3-targets/sqlite/src/core/migrations/statement-builders.ts @@ -46,7 +46,10 @@ export const ensureMarkerTableStatement: SqlStatement = { export const ensureLedgerTableStatement: SqlStatement = { sql: `CREATE TABLE IF NOT EXISTS _prisma_ledger ( id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + space TEXT NOT NULL, + migration_name TEXT NOT NULL, + migration_hash TEXT NOT NULL, origin_core_hash TEXT, origin_profile_hash TEXT, destination_core_hash TEXT NOT NULL, @@ -167,6 +170,9 @@ export function buildWriteMarkerStatements(input: WriteMarkerInput): { } export interface LedgerInsertInput { + readonly space: string; + readonly migrationName: string; + readonly migrationHash: string; readonly originStorageHash?: string | null; readonly originProfileHash?: string | null; readonly destinationStorageHash: string; @@ -179,6 +185,9 @@ export interface LedgerInsertInput { export function buildLedgerInsertStatement(input: LedgerInsertInput): SqlStatement { return { sql: `INSERT INTO _prisma_ledger ( + space, + migration_name, + migration_hash, origin_core_hash, origin_profile_hash, destination_core_hash, @@ -193,9 +202,15 @@ export function buildLedgerInsertStatement(input: LedgerInsertInput): SqlStateme ?, ?, ?, + ?, + ?, + ?, ? )`, params: [ + input.space, + input.migrationName, + input.migrationHash, input.originStorageHash ?? null, input.originProfileHash ?? null, input.destinationStorageHash, diff --git a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts index 43e2f30629..191976862a 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/control-adapter.ts @@ -1,6 +1,15 @@ -import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { + Contract, + ContractMarkerRecord, + LedgerEntryRecord, +} from '@prisma-next/contract/types'; import { parseMarkerRowSafely, withMarkerReadErrorHandling } from '@prisma-next/errors/execution'; import type { SqlControlAdapter } from '@prisma-next/family-sql/control-adapter'; +import { + coerceLedgerAppliedAt, + ledgerOriginFromStored, + operationCountFromStored, +} from '@prisma-next/family-sql/ledger-read'; import { parseContractMarkerRow } from '@prisma-next/family-sql/verify'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; import { @@ -43,6 +52,7 @@ import { renderLoweredSql } from './sql-renderer'; import type { PostgresContract } from './types'; const POSTGRES_MARKER_TABLE = 'prisma_contract.marker'; +const POSTGRES_LEDGER_TABLE = 'prisma_contract.ledger'; /** * Postgres control plane adapter for control-plane operations like introspection. @@ -236,6 +246,69 @@ export class PostgresControlAdapter implements SqlControlAdapter<'postgres'> { return rows; } + /** + * Reads per-migration ledger rows for `space` from `prisma_contract.ledger` + * in apply order. Probes `information_schema.tables` first so a fresh + * database without the ledger table returns `[]` instead of raising + * "relation does not exist". + */ + async readLedger( + driver: ControlDriverInstance<'sql', 'postgres'>, + space: string, + ): Promise { + const ledgerContext = { space, markerLocation: POSTGRES_LEDGER_TABLE }; + const exists = await withMarkerReadErrorHandling( + () => + driver.query( + `select 1 + from information_schema.tables + where table_schema = $1 and table_name = $2`, + ['prisma_contract', 'ledger'], + ), + ledgerContext, + ); + if (exists.rows.length === 0) { + return []; + } + + const result = await withMarkerReadErrorHandling( + () => + driver.query<{ + space: string; + migration_name: string; + migration_hash: string; + origin_core_hash: string | null; + destination_core_hash: string; + operations: unknown; + created_at: Date | string; + }>( + `select + space, + migration_name, + migration_hash, + origin_core_hash, + destination_core_hash, + operations, + created_at + from prisma_contract.ledger + where space = $1 + order by id`, + [space], + ), + ledgerContext, + ); + + return result.rows.map((row) => ({ + space: row.space, + migrationName: row.migration_name, + migrationHash: row.migration_hash, + from: ledgerOriginFromStored(row.origin_core_hash), + to: row.destination_core_hash, + appliedAt: coerceLedgerAppliedAt(row.created_at), + operationCount: operationCountFromStored(row.operations), + })); + } + /** * Introspects a Postgres database schema and returns a raw SqlSchemaIR. * diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/fixtures/runner-fixtures.ts b/packages/3-targets/6-adapters/postgres/test/migrations/fixtures/runner-fixtures.ts index e89d42cded..016983d959 100644 --- a/packages/3-targets/6-adapters/postgres/test/migrations/fixtures/runner-fixtures.ts +++ b/packages/3-targets/6-adapters/postgres/test/migrations/fixtures/runner-fixtures.ts @@ -7,6 +7,7 @@ import { type MigrationRunnerFailure, } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; import { buildSqlNamespace, SqlStorage } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import postgresTargetDescriptor from '@prisma-next/target-postgres/control'; @@ -114,6 +115,23 @@ export function toPlanContractInfo(c: Contract) { return { storageHash: c.storage.storageHash, profileHash: c.profileHash }; } +export const LEDGER_TEST_SPACE_ID = 'ledger-test'; + +export function createLedgerTestPlan(options: { + readonly destinationHash: string; + readonly operations: ReturnType>['operations']; + readonly migrationEdges: readonly AggregateMigrationEdgeRef[]; +}) { + return createMigrationPlan({ + targetId: 'postgres', + spaceId: LEDGER_TEST_SPACE_ID, + origin: null, + destination: { storageHash: options.destinationHash, profileHash: contract.profileHash }, + operations: options.operations, + providedInvariants: [], + }); +} + export async function executeStatement( driver: PostgresControlDriver, statement: SqlStatement, diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/runner.ledger.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/runner.ledger.integration.test.ts new file mode 100644 index 0000000000..03aae2fa96 --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/runner.ledger.integration.test.ts @@ -0,0 +1,395 @@ +import type { LedgerEntryRecord } from '@prisma-next/contract/types'; +import { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; +import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; +import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; +import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; +import type { PostgresPlanTargetDetails } from '@prisma-next/target-postgres/planner-target-details'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { PostgresControlAdapter } from '../../src/core/control-adapter'; +import { + contract, + createDriver, + createLedgerTestPlan, + createTestDatabase, + emptySchema, + familyInstance, + formatRunnerFailure, + frameworkComponents, + LEDGER_TEST_SPACE_ID, + type PostgresControlDriver, + postgresTargetDescriptor, + resetDatabase, + testTimeout, +} from './fixtures/runner-fixtures'; + +interface LedgerRow { + readonly space: string; + readonly migration_name: string; + readonly migration_hash: string; + readonly origin_core_hash: string | null; + readonly destination_core_hash: string; + readonly contract_json_before: unknown; + readonly contract_json_after: unknown; + readonly operations: unknown; +} + +const ledgerAdapter = new PostgresControlAdapter(); + +type ExpectedLedgerEntry = Omit; + +function expectReadLedger( + entries: readonly LedgerEntryRecord[], + expected: readonly ExpectedLedgerEntry[], +): void { + expect(entries).toHaveLength(expected.length); + for (const entry of entries) { + expect(entry.appliedAt).toBeInstanceOf(Date); + } + expect(entries.map(({ appliedAt: _appliedAt, ...rest }) => rest)).toEqual(expected); +} + +async function readLedgerRows(driver: PostgresControlDriver): Promise { + const result = await driver.query( + `select space, migration_name, migration_hash, origin_core_hash, destination_core_hash, + contract_json_before, contract_json_after, operations + from prisma_contract.ledger order by id`, + ); + return result.rows; +} + +describe.sequential('PostgresMigrationRunner - per-edge ledger', () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, testTimeout); + + afterAll(async () => { + if (database) { + await database.close(); + } + }, testTimeout); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + }, testTimeout); + + afterEach(async () => { + if (driver) { + await driver.close(); + driver = undefined; + } + }); + + it('readLedger returns an empty array when the ledger table does not exist', { + timeout: testTimeout, + }, async () => { + const freshDriver = await createDriver(database.connectionString); + const ledger = await ledgerAdapter.readLedger(freshDriver, LEDGER_TEST_SPACE_ID); + expect(ledger).toEqual([]); + await freshDriver.close(); + }); + + it('writes one ledger row for a single-edge apply with space, name, hash, from/to, and that edge ops', { + timeout: testTimeout, + }, async () => { + const runner = postgresTargetDescriptor.createRunner(familyInstance); + const destHash = contract.storage.storageHash; + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-single', + dirName: '001_single', + from: EMPTY_CONTRACT_HASH, + to: destHash, + operationCount: 1, + }, + ]; + const plan = createLedgerTestPlan({ + destinationHash: destHash, + operations: [ + { + id: 'edge.single.op', + label: 'single edge op', + operationClass: 'additive', + target: { + id: 'postgres', + details: { schema: 'public', objectType: 'table', name: 'user' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT TRUE' }], + }, + ], + migrationEdges: edges, + }); + + const result = await runner.execute({ + driver: driver!, + perSpaceOptions: [ + { + space: LEDGER_TEST_SPACE_ID, + plan, + driver: driver!, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + strictVerification: false, + migrationEdges: edges, + }, + ], + }); + if (!result.ok) throw new Error(formatRunnerFailure(result.failure)); + + const rows = await readLedgerRows(driver!); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + space: LEDGER_TEST_SPACE_ID, + migration_name: '001_single', + migration_hash: 'sha256:mig-single', + origin_core_hash: EMPTY_CONTRACT_HASH, + destination_core_hash: destHash, + }); + const ops = rows[0]!.operations as Array<{ id: string }>; + expect(ops).toHaveLength(1); + expect(ops[0]?.id).toBe('edge.single.op'); + expect(rows[0]!.contract_json_before).toBeNull(); + expect(rows[0]!.contract_json_after).toMatchObject({ + storage: { storageHash: destHash }, + }); + + const ledger = await ledgerAdapter.readLedger(driver!, LEDGER_TEST_SPACE_ID); + expectReadLedger(ledger, [ + { + space: LEDGER_TEST_SPACE_ID, + migrationName: '001_single', + migrationHash: 'sha256:mig-single', + from: null, + to: destHash, + operationCount: 1, + }, + ]); + }); + + it('writes N ledger rows in walk order for multi-edge apply with ops and contract_json on endpoints only', { + timeout: testTimeout, + }, async () => { + const runner = postgresTargetDescriptor.createRunner(familyInstance); + const hashA = 'sha256:ledger-mid-a'; + const hashB = 'sha256:ledger-mid-b'; + const destHash = contract.storage.storageHash; + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-a', + dirName: '001_a', + from: EMPTY_CONTRACT_HASH, + to: hashA, + operationCount: 1, + }, + { + migrationHash: 'sha256:mig-b', + dirName: '002_b', + from: hashA, + to: hashB, + operationCount: 2, + }, + { + migrationHash: 'sha256:mig-c', + dirName: '003_c', + from: hashB, + to: destHash, + operationCount: 1, + }, + ]; + const plan = createLedgerTestPlan({ + destinationHash: destHash, + operations: [ + { + id: 'edge.a', + label: 'a', + operationClass: 'additive', + target: { + id: 'postgres', + details: { schema: 'public', objectType: 'table', name: 'a' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT TRUE' }], + }, + { + id: 'edge.b1', + label: 'b1', + operationClass: 'additive', + target: { + id: 'postgres', + details: { schema: 'public', objectType: 'table', name: 'b1' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT TRUE' }], + }, + { + id: 'edge.b2', + label: 'b2', + operationClass: 'additive', + target: { + id: 'postgres', + details: { schema: 'public', objectType: 'table', name: 'b2' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT TRUE' }], + }, + { + id: 'edge.c', + label: 'c', + operationClass: 'additive', + target: { + id: 'postgres', + details: { schema: 'public', objectType: 'table', name: 'c' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT TRUE' }], + }, + ], + migrationEdges: edges, + }); + + const result = await runner.execute({ + driver: driver!, + perSpaceOptions: [ + { + space: LEDGER_TEST_SPACE_ID, + plan, + driver: driver!, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + strictVerification: false, + migrationEdges: edges, + }, + ], + }); + if (!result.ok) throw new Error(formatRunnerFailure(result.failure)); + + const rows = await readLedgerRows(driver!); + expect(rows).toHaveLength(3); + expect(rows.map((r) => r.migration_name)).toEqual(['001_a', '002_b', '003_c']); + expect(rows.map((r) => r.space)).toEqual([ + LEDGER_TEST_SPACE_ID, + LEDGER_TEST_SPACE_ID, + LEDGER_TEST_SPACE_ID, + ]); + expect(rows[0]).toMatchObject({ + origin_core_hash: EMPTY_CONTRACT_HASH, + destination_core_hash: hashA, + }); + expect(rows[1]).toMatchObject({ + origin_core_hash: hashA, + destination_core_hash: hashB, + }); + expect(rows[2]).toMatchObject({ + origin_core_hash: hashB, + destination_core_hash: destHash, + }); + + const opCounts = rows.map((r) => (r.operations as unknown[]).length); + expect(opCounts).toEqual([1, 2, 1]); + const opIds = rows.flatMap((r) => (r.operations as Array<{ id: string }>).map((o) => o.id)); + expect(opIds).toEqual(['edge.a', 'edge.b1', 'edge.b2', 'edge.c']); + + expect(rows[0]!.contract_json_before).toBeNull(); + expect(rows[0]!.contract_json_after).toBeNull(); + expect(rows[1]!.contract_json_before).toBeNull(); + expect(rows[1]!.contract_json_after).toBeNull(); + expect(rows[2]!.contract_json_before).toBeNull(); + expect(rows[2]!.contract_json_after).toMatchObject({ + storage: { storageHash: destHash }, + }); + + const ledger = await ledgerAdapter.readLedger(driver!, LEDGER_TEST_SPACE_ID); + expectReadLedger(ledger, [ + { + space: LEDGER_TEST_SPACE_ID, + migrationName: '001_a', + migrationHash: 'sha256:mig-a', + from: null, + to: hashA, + operationCount: 1, + }, + { + space: LEDGER_TEST_SPACE_ID, + migrationName: '002_b', + migrationHash: 'sha256:mig-b', + from: hashA, + to: hashB, + operationCount: 2, + }, + { + space: LEDGER_TEST_SPACE_ID, + migrationName: '003_c', + migrationHash: 'sha256:mig-c', + from: hashB, + to: destHash, + operationCount: 1, + }, + ]); + }); + + it('writes one synthesised ledger row with space for synth apply without migrationEdges', { + timeout: testTimeout, + }, async () => { + const planner = postgresTargetDescriptor.createPlanner(familyInstance); + const runner = postgresTargetDescriptor.createRunner(familyInstance); + + const planResult = planner.plan({ + contract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents, + spaceId: APP_SPACE_ID, + }); + if (planResult.kind !== 'success') throw new Error('expected planner success'); + + const executeResult = await runner.execute({ + driver: driver!, + perSpaceOptions: [ + { + plan: planResult.plan, + driver: driver!, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + strictVerification: false, + }, + ], + }); + if (!executeResult.ok) throw new Error(formatRunnerFailure(executeResult.failure)); + + const rows = await readLedgerRows(driver!); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + space: APP_SPACE_ID, + migration_name: '', + migration_hash: contract.storage.storageHash, + destination_core_hash: contract.storage.storageHash, + }); + + const ledger = await ledgerAdapter.readLedger(driver!, APP_SPACE_ID); + expect(ledger).toHaveLength(1); + expect(ledger[0]).toMatchObject({ + space: APP_SPACE_ID, + migrationName: '', + migrationHash: contract.storage.storageHash, + from: null, + to: contract.storage.storageHash, + }); + const storedSynthOps = rows[0]!.operations; + expect(ledger[0]!.operationCount).toBe( + Array.isArray(storedSynthOps) ? storedSynthOps.length : 0, + ); + }); +}); diff --git a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts index 1cbec0299e..0acbe5fcc9 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/control-adapter.ts @@ -1,6 +1,11 @@ -import type { ContractMarkerRecord } from '@prisma-next/contract/types'; +import type { ContractMarkerRecord, LedgerEntryRecord } from '@prisma-next/contract/types'; import { parseMarkerRowSafely, withMarkerReadErrorHandling } from '@prisma-next/errors/execution'; import type { SqlControlAdapter } from '@prisma-next/family-sql/control-adapter'; +import { + coerceLedgerAppliedAt, + ledgerOriginFromStored, + operationCountFromStored, +} from '@prisma-next/family-sql/ledger-read'; import { parseContractMarkerRow } from '@prisma-next/family-sql/verify'; import { APP_SPACE_ID, @@ -28,6 +33,7 @@ import { renderLoweredSql } from './adapter'; import type { SqliteContract } from './types'; const SQLITE_MARKER_TABLE = '_prisma_marker'; +const SQLITE_LEDGER_TABLE = '_prisma_ledger'; /** * SQLite stores arrays as JSON-encoded TEXT (no native array type), so the @@ -232,6 +238,65 @@ export class SqliteControlAdapter implements SqlControlAdapter<'sqlite'> { return rows; } + /** + * Reads per-migration ledger rows for `space` from `_prisma_ledger` in + * apply order. Probes `sqlite_master` first so a fresh database without + * the ledger table returns `[]` instead of raising "no such table". + */ + async readLedger( + driver: ControlDriverInstance<'sql', 'sqlite'>, + space: string, + ): Promise { + const ledgerContext = { space, markerLocation: SQLITE_LEDGER_TABLE }; + const exists = await withMarkerReadErrorHandling( + () => + driver.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, [ + '_prisma_ledger', + ]), + ledgerContext, + ); + if (exists.rows.length === 0) { + return []; + } + + const result = await withMarkerReadErrorHandling( + () => + driver.query<{ + space: string; + migration_name: string; + migration_hash: string; + origin_core_hash: string | null; + destination_core_hash: string; + operations: unknown; + created_at: Date | string; + }>( + `SELECT + space, + migration_name, + migration_hash, + origin_core_hash, + destination_core_hash, + operations, + created_at + FROM _prisma_ledger + WHERE space = ? + ORDER BY id`, + [space], + ), + ledgerContext, + ); + + return result.rows.map((row) => ({ + space: row.space, + migrationName: row.migration_name, + migrationHash: row.migration_hash, + from: ledgerOriginFromStored(row.origin_core_hash), + to: row.destination_core_hash, + appliedAt: coerceLedgerAppliedAt(row.created_at), + operationCount: operationCountFromStored(row.operations), + })); + } + async introspect( driver: ControlDriverInstance<'sql', 'sqlite'>, _contract?: unknown, diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/fixtures/runner-fixtures.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/fixtures/runner-fixtures.ts index cdace93e3a..ca79704069 100644 --- a/packages/3-targets/6-adapters/sqlite/test/migrations/fixtures/runner-fixtures.ts +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/fixtures/runner-fixtures.ts @@ -11,6 +11,7 @@ import { type MigrationRunnerFailure, } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; import { buildSqlNamespace, SqlStorage } from '@prisma-next/sql-contract/types'; import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; import sqliteTargetDescriptor from '@prisma-next/target-sqlite/control'; @@ -149,6 +150,25 @@ export function toPlanContractInfo(c: Contract) { return { storageHash: c.storage.storageHash, profileHash: c.profileHash }; } +export const LEDGER_TEST_SPACE_ID = 'ledger-test'; + +export function createLedgerTestPlan(options: { + readonly destinationHash: string; + readonly operations: ReturnType< + typeof createMigrationPlan + >['operations']; + readonly migrationEdges: readonly AggregateMigrationEdgeRef[]; +}) { + return createMigrationPlan({ + targetId: 'sqlite', + spaceId: LEDGER_TEST_SPACE_ID, + origin: null, + destination: { storageHash: options.destinationHash, profileHash: contract.profileHash }, + operations: options.operations, + providedInvariants: [], + }); +} + export async function executeStatement( driver: SqliteControlDriver, statement: SqlStatement, diff --git a/packages/3-targets/6-adapters/sqlite/test/migrations/runner.ledger.test.ts b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.ledger.test.ts new file mode 100644 index 0000000000..addb714044 --- /dev/null +++ b/packages/3-targets/6-adapters/sqlite/test/migrations/runner.ledger.test.ts @@ -0,0 +1,434 @@ +import type { LedgerEntryRecord } from '@prisma-next/contract/types'; +import { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control'; +import { APP_SPACE_ID } from '@prisma-next/framework-components/control'; +import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate'; +import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants'; +import { timeouts } from '@prisma-next/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SqliteControlAdapter } from '../../src/core/control-adapter'; +import { + contract, + createLedgerTestPlan, + createTestDatabase, + emptySchema, + familyInstance, + formatRunnerFailure, + frameworkComponents, + LEDGER_TEST_SPACE_ID, + sqliteTargetDescriptor, + type TestDatabase, +} from './fixtures/runner-fixtures'; + +interface LedgerRow { + readonly space: string; + readonly migration_name: string; + readonly migration_hash: string; + readonly origin_core_hash: string | null; + readonly destination_core_hash: string; + readonly contract_json_before: string | null; + readonly contract_json_after: string | null; + readonly operations: string; + readonly created_at: string; +} + +const ledgerAdapter = new SqliteControlAdapter(); + +type ExpectedLedgerEntry = Omit; + +function expectReadLedger( + entries: readonly LedgerEntryRecord[], + expected: readonly ExpectedLedgerEntry[], +): void { + expect(entries).toHaveLength(expected.length); + for (const entry of entries) { + expect(entry.appliedAt).toBeInstanceOf(Date); + } + expect(entries.map(({ appliedAt: _appliedAt, ...rest }) => rest)).toEqual(expected); +} + +async function readLedgerRows(driver: TestDatabase['driver']): Promise { + return ( + await driver.query( + `SELECT space, migration_name, migration_hash, origin_core_hash, destination_core_hash, + contract_json_before, contract_json_after, operations, created_at + FROM _prisma_ledger ORDER BY id`, + ) + ).rows; +} + +function parseNullableJsonColumn(value: string | null): unknown { + if (value === null) { + return null; + } + return JSON.parse(value) as unknown; +} + +describe('SqliteMigrationRunner - per-edge ledger', { timeout: timeouts.databaseOperation }, () => { + let testDb: TestDatabase; + + afterEach(() => { + testDb?.cleanup(); + }); + + it('readLedger returns an empty array when the ledger table does not exist', async () => { + testDb = createTestDatabase(); + const ledger = await ledgerAdapter.readLedger(testDb.driver, LEDGER_TEST_SPACE_ID); + expect(ledger).toEqual([]); + }); + + it('writes one ledger row for a single-edge apply with space, name, hash, from/to, and that edge ops', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + const runner = sqliteTargetDescriptor.createRunner(familyInstance); + const destHash = contract.storage.storageHash; + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-single', + dirName: '001_single', + from: EMPTY_CONTRACT_HASH, + to: destHash, + operationCount: 1, + }, + ]; + const plan = createLedgerTestPlan({ + destinationHash: destHash, + operations: [ + { + id: 'edge.single.op', + label: 'single edge op', + operationClass: 'additive', + target: { + id: 'sqlite', + details: { schema: 'main', objectType: 'table', name: 'user' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT 1' }], + }, + ], + migrationEdges: edges, + }); + + const result = await runner.execute({ + driver, + perSpaceOptions: [ + { + space: LEDGER_TEST_SPACE_ID, + plan, + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + strictVerification: false, + migrationEdges: edges, + }, + ], + }); + if (!result.ok) throw new Error(formatRunnerFailure(result.failure)); + + const rows = await readLedgerRows(driver); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + space: LEDGER_TEST_SPACE_ID, + migration_name: '001_single', + migration_hash: 'sha256:mig-single', + origin_core_hash: EMPTY_CONTRACT_HASH, + destination_core_hash: destHash, + }); + const ops = JSON.parse(rows[0]!.operations) as Array<{ id: string }>; + expect(ops).toHaveLength(1); + expect(ops[0]?.id).toBe('edge.single.op'); + expect(parseNullableJsonColumn(rows[0]!.contract_json_before)).toBeNull(); + expect(parseNullableJsonColumn(rows[0]!.contract_json_after)).toMatchObject({ + storage: { storageHash: destHash }, + }); + + const ledger = await ledgerAdapter.readLedger(driver, LEDGER_TEST_SPACE_ID); + expectReadLedger(ledger, [ + { + space: LEDGER_TEST_SPACE_ID, + migrationName: '001_single', + migrationHash: 'sha256:mig-single', + from: null, + to: destHash, + operationCount: 1, + }, + ]); + const storedCreatedAt = rows[0]!.created_at; + expect(storedCreatedAt.endsWith('Z')).toBe(true); + expect(ledger[0]!.appliedAt.getTime()).toBe(Date.parse(storedCreatedAt)); + }); + + it('throws when migrationEdges operationCount sum does not match plan.operations length', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + const runner = sqliteTargetDescriptor.createRunner(familyInstance); + const destHash = contract.storage.storageHash; + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-single', + dirName: '001_single', + from: EMPTY_CONTRACT_HASH, + to: destHash, + operationCount: 2, + }, + ]; + const plan = createLedgerTestPlan({ + destinationHash: destHash, + operations: [ + { + id: 'edge.single.op', + label: 'single edge op', + operationClass: 'additive', + target: { + id: 'sqlite', + details: { schema: 'main', objectType: 'table', name: 'user' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT 1' }], + }, + ], + migrationEdges: edges, + }); + + await expect( + runner.execute({ + driver, + perSpaceOptions: [ + { + space: LEDGER_TEST_SPACE_ID, + plan, + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + strictVerification: false, + migrationEdges: edges, + }, + ], + }), + ).rejects.toThrow(/does not match sum of migrationEdges operationCount/); + }); + + it('writes N ledger rows in walk order for multi-edge apply with ops and contract_json on endpoints only', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + const runner = sqliteTargetDescriptor.createRunner(familyInstance); + const hashA = 'sha256:ledger-mid-a'; + const hashB = 'sha256:ledger-mid-b'; + const destHash = contract.storage.storageHash; + const edges: readonly AggregateMigrationEdgeRef[] = [ + { + migrationHash: 'sha256:mig-a', + dirName: '001_a', + from: EMPTY_CONTRACT_HASH, + to: hashA, + operationCount: 1, + }, + { + migrationHash: 'sha256:mig-b', + dirName: '002_b', + from: hashA, + to: hashB, + operationCount: 2, + }, + { + migrationHash: 'sha256:mig-c', + dirName: '003_c', + from: hashB, + to: destHash, + operationCount: 1, + }, + ]; + const plan = createLedgerTestPlan({ + destinationHash: destHash, + operations: [ + { + id: 'edge.a', + label: 'a', + operationClass: 'additive', + target: { + id: 'sqlite', + details: { schema: 'main', objectType: 'table', name: 'a' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT 1' }], + }, + { + id: 'edge.b1', + label: 'b1', + operationClass: 'additive', + target: { + id: 'sqlite', + details: { schema: 'main', objectType: 'table', name: 'b1' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT 1' }], + }, + { + id: 'edge.b2', + label: 'b2', + operationClass: 'additive', + target: { + id: 'sqlite', + details: { schema: 'main', objectType: 'table', name: 'b2' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT 1' }], + }, + { + id: 'edge.c', + label: 'c', + operationClass: 'additive', + target: { + id: 'sqlite', + details: { schema: 'main', objectType: 'table', name: 'c' }, + }, + precheck: [], + execute: [], + postcheck: [{ description: 'ok', sql: 'SELECT 1' }], + }, + ], + migrationEdges: edges, + }); + + const result = await runner.execute({ + driver, + perSpaceOptions: [ + { + space: LEDGER_TEST_SPACE_ID, + plan, + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + strictVerification: false, + migrationEdges: edges, + }, + ], + }); + if (!result.ok) throw new Error(formatRunnerFailure(result.failure)); + + const rows = await readLedgerRows(driver); + expect(rows).toHaveLength(3); + expect(rows.map((r) => r.migration_name)).toEqual(['001_a', '002_b', '003_c']); + expect(rows.map((r) => r.space)).toEqual([ + LEDGER_TEST_SPACE_ID, + LEDGER_TEST_SPACE_ID, + LEDGER_TEST_SPACE_ID, + ]); + expect(rows[0]).toMatchObject({ + origin_core_hash: EMPTY_CONTRACT_HASH, + destination_core_hash: hashA, + }); + expect(rows[1]).toMatchObject({ + origin_core_hash: hashA, + destination_core_hash: hashB, + }); + expect(rows[2]).toMatchObject({ + origin_core_hash: hashB, + destination_core_hash: destHash, + }); + + const opCounts = rows.map((r) => (JSON.parse(r.operations) as unknown[]).length); + expect(opCounts).toEqual([1, 2, 1]); + const opIds = rows.flatMap((r) => + (JSON.parse(r.operations) as Array<{ id: string }>).map((o) => o.id), + ); + expect(opIds).toEqual(['edge.a', 'edge.b1', 'edge.b2', 'edge.c']); + + expect(parseNullableJsonColumn(rows[0]!.contract_json_before)).toBeNull(); + expect(parseNullableJsonColumn(rows[0]!.contract_json_after)).toBeNull(); + expect(parseNullableJsonColumn(rows[1]!.contract_json_before)).toBeNull(); + expect(parseNullableJsonColumn(rows[1]!.contract_json_after)).toBeNull(); + expect(parseNullableJsonColumn(rows[2]!.contract_json_before)).toBeNull(); + expect(parseNullableJsonColumn(rows[2]!.contract_json_after)).toMatchObject({ + storage: { storageHash: destHash }, + }); + + const ledger = await ledgerAdapter.readLedger(driver, LEDGER_TEST_SPACE_ID); + expectReadLedger(ledger, [ + { + space: LEDGER_TEST_SPACE_ID, + migrationName: '001_a', + migrationHash: 'sha256:mig-a', + from: null, + to: hashA, + operationCount: 1, + }, + { + space: LEDGER_TEST_SPACE_ID, + migrationName: '002_b', + migrationHash: 'sha256:mig-b', + from: hashA, + to: hashB, + operationCount: 2, + }, + { + space: LEDGER_TEST_SPACE_ID, + migrationName: '003_c', + migrationHash: 'sha256:mig-c', + from: hashB, + to: destHash, + operationCount: 1, + }, + ]); + }); + + it('writes one synthesised ledger row with space for synth apply without migrationEdges', async () => { + testDb = createTestDatabase(); + const { driver } = testDb; + const planner = sqliteTargetDescriptor.createPlanner(familyInstance); + const runner = sqliteTargetDescriptor.createRunner(familyInstance); + + const result = planner.plan({ + contract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents, + spaceId: APP_SPACE_ID, + }); + if (result.kind !== 'success') throw new Error('expected planner success'); + + const executeResult = await runner.execute({ + driver, + perSpaceOptions: [ + { + plan: result.plan, + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + strictVerification: false, + }, + ], + }); + if (!executeResult.ok) throw new Error(formatRunnerFailure(executeResult.failure)); + + const rows = await readLedgerRows(driver); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + space: APP_SPACE_ID, + migration_name: '', + migration_hash: contract.storage.storageHash, + destination_core_hash: contract.storage.storageHash, + }); + + const ledger = await ledgerAdapter.readLedger(driver, APP_SPACE_ID); + expect(ledger).toHaveLength(1); + expect(ledger[0]).toMatchObject({ + space: APP_SPACE_ID, + migrationName: '', + migrationHash: contract.storage.storageHash, + from: null, + to: contract.storage.storageHash, + }); + const storedSynthOps = JSON.parse(rows[0]!.operations) as unknown[]; + expect(ledger[0]!.operationCount).toBe(storedSynthOps.length); + }); +}); diff --git a/projects/migration-graph-rendering/README.md b/projects/migration-graph-rendering/README.md index bdab3aa73e..03d3d33e56 100644 --- a/projects/migration-graph-rendering/README.md +++ b/projects/migration-graph-rendering/README.md @@ -17,9 +17,42 @@ but not blocked by, the consolidation project (TML-2739). 1. **Redesign the Tier-3 renderer** — [`spec.md`](./spec.md). The condensed annotated node-link diagram (shipped in PR #658). 2. **Retire `migration list --graph`** — - [`slices/remove-list-graph-renderer/spec.md`](./slices/remove-list-graph-renderer/spec.md). - Now that the Tier-3 tree is compact and correct, the Tier-2 list-graph gutter - is the redundant middle; this slice removes it, leaving one graph renderer. + [`slices/remove-list-graph-renderer/spec.md`](./slices/remove-list-graph-renderer/spec.md) + ([TML-2765](https://linear.app/prisma-company/issue/TML-2765)). Now that the + Tier-3 tree is compact and correct, the Tier-2 list-graph gutter is the + redundant middle; this slice removes it, leaving one graph renderer. +3. **`migration graph` multi-space** — + [`slices/migration-graph-space-flag/spec.md`](./slices/migration-graph-space-flag/spec.md) + ([TML-2767](https://linear.app/prisma-company/issue/TML-2767)). Makes the read + commands consistent: `graph` draws every on-disk space as a disconnected + per-space tree by default, with `--space ` to narrow — matching + `migration list`. Deferred — land after `--tree` becomes the default + (TML-2748). +4. **`migration list` renders the tree (human output)** — + [`slices/list-renders-tree/spec.md`](./slices/list-renders-tree/spec.md) + ([TML-2768](https://linear.app/prisma-company/issue/TML-2768)). `list`'s + pretty/TTY output adopts the shared tree renderer (package-annotated); its + `--json` and future text-only formats stay flat for tooling. Completes the + intent of TML-2697. + +The project has broadened from the `graph` renderer into the whole **migration +read-command family** (`list` / `graph` / `status` / `log`). Cross-cutting design +decisions (the command-family model, shared renderer, space policy, `list`/`graph` +split, the ledger foundation) live in [`decisions.md`](./decisions.md). + +Further slices, all sequenced after TML-2748 unless noted: + +5. **Ledger foundation** ([TML-2769](https://linear.app/prisma-company/issue/TML-2769), blocks 6–7) — + make the on-apply ledger readable; store migration hash + name; add + `readLedger`. Control-plane (all targets). +6. **`status` = `list` + DB-state overlay** ([TML-2748](https://linear.app/prisma-company/issue/TML-2748)) — + applied/pending overlay, `--from`/`--to`, delete dagre, `--tree` becomes default. +7. **`log` reads the ledger** ([TML-2770](https://linear.app/prisma-company/issue/TML-2770)) — + flat executed history (no tree), real order + timestamps + rollbacks. + +Future siblings (not core): `migration path --from X --to Y` +([TML-2771](https://linear.app/prisma-company/issue/TML-2771)) and `ref show` +invariants ([TML-2772](https://linear.app/prisma-company/issue/TML-2772)). ## Contents diff --git a/projects/migration-graph-rendering/decisions.md b/projects/migration-graph-rendering/decisions.md new file mode 100644 index 0000000000..09fd1a30fa --- /dev/null +++ b/projects/migration-graph-rendering/decisions.md @@ -0,0 +1,161 @@ +# Design decisions — migration read-command family + +This project began as a redesign of `migration graph`'s renderer (TML-2746) but +has broadened into a coherent design for the whole family of **migration read +commands** — `list`, `graph`, `status`, `log` (and `show`). This file records +the cross-cutting decisions that span more than one slice. Slice-local detail +lives in each slice's `spec.md`. + +## The command family + +| Command | Question it answers | On/offline | Human (TTY) output | Machine output | +|---|---|---|---|---| +| `list` | "what migration packages are on disk?" | offline | shared tree, package-annotated | flat package array (`--json`, future text-only) | +| `graph` | "what contract topology do they describe?" | offline | shared tree, topology/overlay-annotated | `{ nodes, edges }` | +| `status` | "where is my DB relative to all on-disk migrations?" | online (offline with `--from`) | `list` + per-migration applied/pending overlay | `list`'s shape + a `status` field | +| `log` | "what actually ran, and when?" | online | **flat** `list`-format rows from the ledger (no tree) | ledger entries | +| `show` | "what's in this one package?" | offline | package detail | package detail | + +`status`, `list`, and `graph` describe **on-disk state** and are tree-shaped. +`log` describes **what actually happened** (the DB ledger — real apply order and +timestamps, including rollbacks/re-applies) and is the one command that stays +flat, because the same edge can recur and a graph can't represent repetition. + +Tickets: `list`→tree TML-2768, `graph` multi-space TML-2767, `status` TML-2748, +`log` TML-2770, ledger foundation TML-2769, and the future siblings `migration +path` TML-2771 / `ref show` invariants TML-2772. + +## Decisions + +### D1 — One shared graphical renderer, command-specific annotations + +The condensed Tier-3 tree renderer (TML-2746) is the **single** human/graphical +rendering engine. `list`, `graph`, and `status` all draw the same tree; they +diverge only in the **annotations** they overlay: + +- `list` → per-migration package facts (op counts, invariants, refs). +- `graph` → `(refs)` / `(contract)` node overlays. +- `status` → `(db)` marker + per-edge applied/pending/unreachable status glyphs. + +This is the project's thesis: one renderer to maintain, fed different overlay +inputs. The dagre renderer and the Tier-2 list-graph gutter are both retired +(TML-2748, TML-2765). + +### D2 — `list` and `graph` stay distinct commands + +They are **not** merged. They answer different questions and their **machine +output differs significantly**: `list` emits a flat package array (the faithful +on-disk inventory — every package, including parallel / duplicate / disconnected +edges); `graph` emits `{ nodes, edges }` (the deduplicated contract topology). +Their human output happens to share the tree (D1, D3), but the commands' purpose +and tooling contracts are durably separate, and may diverge further. + +### D3 — Graphical output is human-only; machine formats stay flat + +The tree is rendered **only** in the pretty/TTY human path. `--json` and any +future text-only format omit the tree and emit each command's flat data, for +tooling. This is free: piping a read command already auto-switches to JSON +(non-TTY ⇒ `--json`, per `resolveOutputFormat`), so the human renderer never +runs for a pipe/script in the first place. + +Rationale for putting the tree in `list`'s human output: `list`'s flat order is +**lexicographic by directory name**, not chronological (the timestamp prefix is +a naming convention recording *creation*, not application). For a branching +history that order shows no relationships and is close to unreadable, so in +practice you always need `list` + `graph` together — hence combine their +graphical output for humans. + +### D4 — Space policy: all spaces by default, `--space ` to narrow (read commands) + +Contract spaces are **independent histories** (no cross-space topology). All read +commands render **every** on-disk space by default, each as its own disconnected +per-space section/tree, with `--space ` to narrow to one. `migration list` +already does this; `migration graph` is brought into line (TML-2767). This gives +one mental model for `--space` across the family. + +### D5 — `--tree` becomes the default; dagre is deleted + +The condensed tree shipped behind an experimental `migration graph --tree` flag +to avoid disturbing `migration status` (which shared the dagre renderer). Once +`status` moves onto the shared renderer, `--tree` becomes the default (the flag +is dropped) and the dagre renderer + `@dagrejs/dagre` are deleted (TML-2748). + +### D6 — `status` is `migration list` + a DB-state overlay (TML-2748) + +`status` draws the **same** list as `migration list` (shared renderer, per-space +sections, policy B) and overlays, per migration, one of two states; everything +else is shown plain (it's the full list — no subgraph pruning): + +- **applied** — a ledger entry exists for this migration (exact match on + migration hash, D7). KISS: literal "ever ran"; a rolled-back migration still + counts as applied here — the timeline lives in `log` (D8). +- **pending** — on the shortest path from the DB's current contract hash to the + app contract, and not applied (runs next on `migrate`). + +`status --json` is `list`'s shape plus a per-migration `status` field. This +retires dagre (the last consumer) and makes the condensed tree the default +(`--tree` flag dropped). + +### D7 — Restructure the ledger into a readable per-migration journal (TML-2769) + +Every target writes an append-only ledger on apply, but nothing reads it — and +its shape is wrong for `status`/`log`. Investigation found it records **one +collapsed row per space-apply** (origin→destination spanning the whole walked +path), and the three target schemas have diverged (PG/SQLite have no `space` +column; Mongo has no `operations`). Both consumers need **one row per migration +edge**: `status` matches `migration_hash` exactly; `log` shows one row per apply +event. + +So restructure (it's simpler than today's): one row per applied edge, each +carrying `space` + `migration_name` (dirName) + `migration_hash` + per-edge +`from`/`to` + the edge's `operations` (slice of `plan.operations` by +`operationCount`) + `applied_at`. The edge's `operations` are kept on +every row — they make the journal a high-value audit record (exactly what ran). +`contract_json_before/after` stays too (nullable; only the apply's endpoints are +materialised — multi-edge interiors are null; no consumer reads them yet). Both +`operations` and `contract_json` are non-essential to `status`/`log` (which need +only name/hash/from/to/count) — if storage ever bites, drop them or give users +an opt-in/out control for non-essential ledger storage rather than removing the +audit value by default. Writes happen per-edge inside the per-space +transaction, in walk order, by threading `PerSpacePlan.migrationEdges` to the +runner. Add `readLedger({ driver, space })` to `ControlFamilyInstance` (beside +`readMarker`/`readAllMarkers`) returning `LedgerEntryRecord[]` in apply order +with cross-target parity, plumbed through the control client + a control-api +operation. Prototype — no back-compat migration of existing rows. + +### D8 — `log` reads the ledger; flat, no tree (TML-2770) + +`log` reads the ledger (D7) and renders the real apply history in `list` row +format, **flat** — in apply order, with names + `appliedAt`, including rollbacks +and re-applies. It is online-only (the DB is the source) and the only read +command not sourced from on-disk state. Today's `findPath(∅→marker)` +reconstruction is discarded (it can pick the wrong branch and mislabels creation +time as apply time). + +### D9 — `status` origin/target controls (`--from` / `--to`) + +Default origin is the DB marker, default target is the current contract. +`--to X` retargets to a ref/hash ("can I move this DB to X? what path?"). +`--from X` overrides the origin (offline-capable: "what would `migrate --from X +--to Y` do?"). The **applied** overlay shows iff the origin is the real DB — +overriding it makes applied-ness meaningless, so it drops. `status` requires a DB +unless `--from` supplies the origin. + +### D10 — Path-decision and invariants live elsewhere, not in `status` + +To keep `status`'s footer lean (headline + actionable `missing invariant(s)` +line only): + +- **Which-path-and-why** (path selection, tie-break reasons) → a future + `migration path --from X --to Y` command that draws the graph and highlights + the chosen path, and dry-runs alternative pathfinding (TML-2771). +- **A ref's declared required invariants** → `ref show` (ref metadata); `status` + surfaces only the *missing* set relative to the DB (TML-2772). + +## Open / under discussion + +- `status` multi-space rendering detail (N annotated per-space sections vs. a + compact per-space summary) — settle at TML-2748 pickup. +- `log` name-mapping when the on-disk package is gone (show hashes) or ambiguous + (parallel edges) — settle at TML-2770 pickup; the ledger's own migration name + (D7) makes this largely moot. diff --git a/projects/migration-graph-rendering/slices/ledger-foundation/plan.md b/projects/migration-graph-rendering/slices/ledger-foundation/plan.md new file mode 100644 index 0000000000..f06a1f5635 --- /dev/null +++ b/projects/migration-graph-rendering/slices/ledger-foundation/plan.md @@ -0,0 +1,59 @@ +# Plan: ledger foundation (TML-2769) + +One branch / one PR (`tml-2769-make-the-migration-ledger-readable`). Three +dispatches, sequenced; each leaves the workspace green. + +## Dispatch 1 — SQL write restructure + apply-layer threading + +**Outcome:** Postgres + SQLite ledgers record **one row per applied migration +edge** (space + name + hash + per-edge from/to + that edge's ops), written inside +the per-space transaction in walk order. Mongo unchanged (still green). + +- `apply.ts`: add `migrationEdges: r.entry.migrationEdges` to each + `perSpaceOptions` entry. +- `2-sql/9-family/.../migrations/types.ts`: add optional `migrationEdges` to + `SqlMigrationRunnerExecuteOptions`. Also add it (optional) to the **Mongo** + runner execute-options type so `apply.ts` typechecks (Mongo consumes it in + dispatch 2). +- PG + SQLite `statement-builders.ts`: `ensureLedgerTableStatement` gains + `space`, `migration_name`, `migration_hash`; `buildLedgerInsertStatement` + takes the per-edge input (`space`, `migrationName`, `migrationHash`, `from`, + `to`, `operations`, `contractJsonBefore/After`). +- PG + SQLite `runner.ts`: replace the single `recordLedgerEntry` with a per-edge + loop — for each `migrationEdges` entry, slice `plan.operations` by + `operationCount` (walk order) and insert a row. `contract_json` endpoints only + (single-edge: before=prior marker contract, after=destinationContract; + multi-edge interiors null). `synth` plans (no `migrationEdges`) keep a single + synthesised row keyed by plan destination. +- Tests (PG + SQLite adapter): single-edge, multi-edge (N rows, ops attributed + per edge, order), synth (one row), row carries space/name/hash. + +**Builds on:** nothing. **Hands to:** dispatch 2 (Mongo parity), dispatch 3 (reads). + +## Dispatch 2 — Mongo write parity + +**Outcome:** Mongo ledger docs match the per-edge journal shape. + +- `marker-ledger.ts` `writeLedgerEntry`: accept per-edge input; add + `migrationName`, `migrationHash`, `operations` to the doc (already has + `space`/`from`/`to`/`appliedAt`). +- Mongo runner: consume `migrationEdges` from execute options; per-edge loop + inside its write path, walk order. Synth → single doc. +- Tests (mongo adapter): single-edge, multi-edge, synth. + +**Builds on:** dispatch 1's apply-layer threading + execute-options field. + +## Dispatch 3 — `readLedger` SPI + reads + client plumbing + +**Outcome:** `readLedger({ driver, space })` returns a space's entries in apply +order with cross-target parity, reachable from the CLI. + +- `LedgerEntryRecord` in `@prisma-next/contract/types`. +- `readLedger` on `ControlFamilyInstance` (beside `readMarker`/`readAllMarkers`). +- PG/SQLite read statement (`… WHERE space = ? ORDER BY id`); Mongo aggregate + (`$match { type:'ledger', space }`, insertion order); per-family wiring. +- Control client (`cli/src/control-api/client.ts` + `types.ts`) + a + descriptor-free control-api operation, mirroring `readMarker`/`db-verify`. +- Tests: read round-trip per target + cross-target parity on `LedgerEntryRecord`. + +**Builds on:** dispatches 1–2 (rows exist to read). diff --git a/projects/migration-graph-rendering/slices/ledger-foundation/spec.md b/projects/migration-graph-rendering/slices/ledger-foundation/spec.md new file mode 100644 index 0000000000..1e4bd5d80b --- /dev/null +++ b/projects/migration-graph-rendering/slices/ledger-foundation/spec.md @@ -0,0 +1,140 @@ +# Slice: ledger foundation — a readable per-migration journal + +Linear: [TML-2769](https://linear.app/prisma-company/issue/TML-2769). Blocks +`status` ([TML-2748](https://linear.app/prisma-company/issue/TML-2748)) and `log` +([TML-2770](https://linear.app/prisma-company/issue/TML-2770)). Design context: +[`../../decisions.md`](../../decisions.md) (D7). + +## The decision + +Restructure the on-apply ledger into a **per-migration journal** — one row per +applied migration edge — and add a `readLedger` read API. Today the ledger is +write-only, its three target schemas have diverged, and it records **one +collapsed row per space-apply** (origin→destination spanning the whole walked +path). That shape can't answer the two questions `status` and `log` ask: + +- `status`: "is *this migration* applied?" → a ledger row exists whose + `migration_hash` matches the edge. Needs one row **per edge**. +- `log`: "what ran, in what order?" → one row **per apply event**, with the + migration's name, `from→to`, and timestamp. + +So the journal is per-edge, and every row records the **space id** of the +applied migration. + +## Target row shape (all targets, normalised) + +One row per applied migration edge: + +| Field | Source | Notes | +|---|---|---| +| `space` | apply space id | **new** — SQL ledgers have no space column today | +| `migration_name` | `edge.dirName` | **new** | +| `migration_hash` | `edge.migrationHash` | **new** — the exact-match key `status` uses | +| `from` (origin core hash) | `edge.from` | null only for the ∅ origin | +| `to` (destination core hash) | `edge.to` | | +| `operations` | slice of `plan.operations` by `edge.operationCount` (walk order) | the edge's authored ops | +| `contract_json_before` / `_after` | apply endpoints only (see below) | retained, nullable | +| `applied_at` / `created_at` | now() | append order = apply order | + +**`contract_json_before/after`:** kept (per the call to keep it until it's a +problem), but only the apply's **endpoints** are materialised — a single-edge +apply gets `before` = prior marker contract, `after` = `destinationContract`. +Multi-edge applies have no materialised intermediate snapshots, so interior +edges store `null`. No current consumer reads these columns; synthesising +intermediate contracts is out of scope. + +## Schema convergence (away from today's divergence) + +- **Postgres / SQLite** (identical today: `{id, created_at, origin/destination + core+profile hash, contract_json_before/after, operations}`): add `space`, + `migration_name`, `migration_hash`. `origin_core_hash`/`destination_core_hash` + become the per-edge `from`/`to`. +- **Mongo** (today `{type:'ledger', space, edgeId, from, to, appliedAt}`): add + `migrationName`, `migrationHash`, `operations`. Already has `space`/`from`/`to`/ + `appliedAt`. + +This is a prototype — no back-compat / migration of existing ledger rows. + +## Write path: one row per edge, atomic with marker on SQL + +`migrate`/`db update`/`db init` walk a path of edges per space and collapse them +into one `plan` before handing off to the runner (`apply.ts` → +`runner.execute`). The per-edge breakdown (`PerSpacePlan.migrationEdges`: +`{migrationHash, dirName, from, to, operationCount}`) is **not** currently passed +to the runner. Thread it to the runner's per-space execute options, and replace +the single `recordLedgerEntry` call with a loop that inserts **one ledger row per +edge** — attributing ops by slicing `plan.operations` with each edge's +`operationCount` in walk order. + +On **Postgres and SQLite**, those writes stay inside the per-space transaction +(atomic with marker advancement). Apply in walk order so append order is apply +order. + +On **Mongo**, DDL cannot run inside a transaction, so ledger writes and marker +advancement are **resumable**, not atomic — the per-edge journal invariant is a +SQL-only atomicity guarantee. + +`synth`-produced plans (`db init`/`db update` greenfield) have no authored edges +(`migrationEdges` absent). They keep writing a single synthesised row keyed by +the plan's destination (name/hash derived from the plan; `from`=null) — the +journal still records that the space was initialised. + +## Read API: `readLedger` + +Add to `ControlFamilyInstance` (alongside `readMarker`/`readAllMarkers`): + +```ts +readLedger(options: { + readonly driver: ControlDriverInstance; + readonly space: string; +}): Promise; // append (apply) order +``` + +`LedgerEntryRecord` (new, beside `ContractMarkerRecord` in `@prisma-next/contract/types`): +`{ space, migrationName, migrationHash, from: string | null, to, appliedAt: Date, operationCount }`. +Implemented per target (PG/SQLite `SELECT … WHERE space = ? ORDER BY id`; Mongo +`$match: { type:'ledger', space }` sorted by insertion). Plumb through the +control client + a descriptor-free control-api operation, mirroring how +`readMarker`/`db-verify` reach the CLI. + +**Read leniency:** `readLedger` never throws on malformed or legacy rows. Docs +missing per-migration journal fields (`migrationName` / `migrationHash` on Mongo; +unreadable rows on SQL) are **skipped** so a single legacy entry cannot poison +`status` / `log`. Well-formed rows map to `LedgerEntryRecord` as before. + +## Done when + +- Ledger rows carry `space` + `migration_name` + `migration_hash` on Postgres, + SQLite, and Mongo, written **one per applied edge** (inside the per-space SQL + transaction on Postgres/SQLite; resumable on Mongo), in apply order. +- `readLedger({ driver, space })` returns that space's entries in apply order, + with cross-target parity (same `LedgerEntryRecord` shape from all three). +- The control client exposes it; a control-api operation wraps it for the CLI. +- Tests: per-target write (single-edge, multi-edge, synth) + read round-trip, + and cross-target parity on the read shape. + +## Out of scope + +- `status` / `log` command behaviour and rendering (TML-2748 / TML-2770). +- Synthesising intermediate contract snapshots for multi-edge `contract_json`. +- Pruning / compaction of ledger rows; back-compat migration of old rows. + +## Reviewability + +One reviewer holds this in one sitting: it's a single coherent change — "the +ledger becomes a readable per-migration journal." Likely decomposes into a +write/restructure dispatch (schema convergence + per-edge write across the three +targets + apply-layer threading) and a read dispatch (`readLedger` SPI + per- +target reads + client plumbing). + +## References + +- Parent project: [`../../README.md`](../../README.md), [`../../decisions.md`](../../decisions.md). +- Write seam: `cli/src/control-api/operations/apply.ts`, the SQL family runner + (`packages/2-sql/9-family/.../migrations/runner` + `statement-builders.ts` for + PG/SQLite), the Mongo runner + `marker-ledger.ts`. +- Read seam mirror: `ControlFamilyInstance.readMarker`/`readAllMarkers` + (`framework-components/src/control/control-instances.ts`), CLI control client + (`cli/src/control-api/client.ts` + `types.ts`). +- Per-edge breakdown: `migration-tools` `AggregateMigrationEdgeRef` / + `PerSpacePlan` (`aggregate/planner-types.ts`). diff --git a/projects/migration-graph-rendering/slices/list-renders-tree/spec.md b/projects/migration-graph-rendering/slices/list-renders-tree/spec.md new file mode 100644 index 0000000000..9c812d8d45 --- /dev/null +++ b/projects/migration-graph-rendering/slices/list-renders-tree/spec.md @@ -0,0 +1,77 @@ +# Slice: `migration list` renders the graph tree in human output + +_Parent project `projects/migration-graph-rendering/`. Outcome this slice contributes to the project's purpose: `migration list`'s flat, lexicographically-ordered text is unreadable for a branching history. This slice routes `list`'s human (pretty/TTY) output through the shared Tier-3 tree renderer — package-annotated — while keeping its machine formats flat for tooling. Tracking: [TML-2768](https://linear.app/prisma-company/issue/TML-2768)._ + +## At a glance + +Human (TTY) — `list` draws the shared tree, annotated with per-migration package facts: + +``` +$ prisma-next migration list +app: +○ 3b2d98d +│↑ 20260303_add_phone ef9de27 → 3b2d98d 2 ops {phone_present} +○ ef9de27 +│↑ 20260301_init ∅ → ef9de27 5 ops +○ ∅ +``` + +Machine (`--json`, or any pipe) — unchanged flat package array: + +``` +$ prisma-next migration list --json +{ "ok": true, "spaces": [ { "spaceId": "app", "migrations": [ … ] } ], "summary": "…" } +``` + +## Chosen design + +Per project decisions D1/D2/D3: + +- **Pretty/TTY path** → render via the shared Tier-3 renderer (`buildMigrationGraphRows` → `buildMigrationGraphLayout` → `renderMigrationGraphTree`), one disconnected tree per space (D4 — all spaces, `spaceId:` heading when multi-space; `--space` already narrows). Edge rows carry `list`'s package annotations (op count, `{invariants}`, `(refs)`). +- **`--json` and future text-only** → unchanged flat package array (`MigrationListResult`). The tree never appears in a machine format. +- This is free of pipe-safety concerns: `resolveOutputFormat` already returns `json` for non-TTY stdout, so the human renderer only runs interactively. + +`list` and `graph` share the renderer but differ in annotations and JSON (D2): `list` is package-centric (every on-disk package is an edge row, including parallel/duplicate/disconnected edges); `graph` is contract-centric (deduplicated nodes + overlays). + +### Ordering / description fix + +`migration list`'s description claims "latest first"; the sort is `compareDirNamesDescending` — lexicographic by dir name, not chronological, and the timestamp prefix records *creation* not application. In the tree, ordering is topological anyway. Fix the misleading "latest first" wording in the command description; the flat (`--json`/text) path keeps a deterministic order (consider `createdAt` over lexicographic, settled at pickup). + +## Scope + +**In:** + +- Route `migration list`'s pretty/TTY output through the shared tree renderer with package annotations (op count, invariants, refs), per space. +- Keep `--json` flat (unchanged shape). Reserve a future text-only flat format (not built here unless trivial). +- Correct the "latest first" description; settle the flat-path sort key. +- Tests: pretty render across linear + branching + multi-space; `--json` shape unchanged; `--space` narrowing; `--ascii` glyph mode on the tree. + +**Out:** + +- `migration graph` (separate command, separate JSON). +- The `MigrationListResult` JSON shape (stays the flat package array). +- Multi-space policy mechanics (delivered by TML-2767; this slice consumes the same per-space enumeration). + +## Pre-investigated edge cases + +| Edge case | Disposition | +|---|---| +| Parallel / duplicate edges (N packages, same `from → to`) | Each is its own edge row — `list` is package-faithful, unlike `graph`'s deduplicated nodes. | +| Disconnected packages (orphan `from`) | Rendered as a disjoint tree component (the Tier-3 renderer already handles disjoint forests). | +| `--ascii` | Drives the tree's glyph mode (box-drawing → ASCII), same as `graph --ascii`. | +| Empty space | Existing empty-state line per space, unchanged. | + +## Slice-specific done conditions + +- [ ] `migration list` (TTY) renders the package-annotated tree per space; `migration list --json` shape is byte-identical to today; `--space` and `--ascii` behave; description no longer claims "latest first". + +## Sequencing + +Land after TML-2748 (shared renderer becomes the default; no `--tree` flag) so `list` calls the renderer directly, not a flagged variant. + +## References + +- Project decisions: `projects/migration-graph-rendering/decisions.md` (D1–D4). +- Linear: [TML-2768](https://linear.app/prisma-company/issue/TML-2768); lineage [TML-2697](https://linear.app/prisma-company/issue/TML-2697). +- Shared renderer: `cli/src/utils/formatters/migration-graph-{rows,layout,tree-render}.ts`. +- `list` command + flat renderer: `cli/src/commands/migration-list.ts`, `cli/src/utils/formatters/migration-list-render.ts`. diff --git a/projects/migration-graph-rendering/slices/migration-graph-space-flag/spec.md b/projects/migration-graph-rendering/slices/migration-graph-space-flag/spec.md new file mode 100644 index 0000000000..2a2654e95d --- /dev/null +++ b/projects/migration-graph-rendering/slices/migration-graph-space-flag/spec.md @@ -0,0 +1,67 @@ +# Slice: `migration graph` multi-space (all spaces by default, `--space ` to narrow) + +_Parent project `projects/migration-graph-rendering/`. Outcome this slice contributes to the project's purpose: `migration graph` renders only the **app** contract space today, while `migration list` enumerates **all** on-disk spaces. This slice makes the read commands consistent — `graph` draws **every** on-disk contract space as a disconnected per-space tree by default, with `--space ` to narrow to one — matching `migration list`'s existing behaviour._ + +## At a glance + +``` +$ prisma-next migration graph +app: +○ 3b2d98d (contract) +│↑ add_phone ef9de27 → 3b2d98d +○ ef9de27 +│↑ init ∅ → ef9de27 +○ ∅ + +supabase-auth: +○ 9f2a1c0 +│↑ add_session 3bfce91 → 9f2a1c0 +○ 3bfce91 +│↑ init ∅ → 3bfce91 +○ ∅ +``` + +``` +$ prisma-next migration graph --space supabase-auth +○ 9f2a1c0 +│↑ add_session 3bfce91 → 9f2a1c0 +○ 3bfce91 +│↑ init ∅ → 3bfce91 +○ ∅ +``` + +By default `migration graph` draws **all** on-disk contract spaces, each as its own disconnected tree under a `spaceId:` heading (spaces are independent histories — there is no cross-space topology). `--space ` narrows to a single space. This mirrors `migration list`, which already enumerates all spaces with `--space` to narrow. + +## Chosen design + +The space policy is **all-spaces-disconnected by default, `--space ` narrows** (the read-command-consistency decision — same shape `migration list` already implements). `migration graph` loads `aggregate.app.graph()` today — hard-wired to the app space. This slice: + +- **Enumerates every on-disk space** (the same enumeration `migration list` uses — `migrationSpaceListEntriesFromAggregate` / `aggregate.space(id)`), rendering each space's `graph()` as its own tree under a `spaceId:` heading. Headings appear only when more than one space is present (matching the list renderer's `multiSpace` rule). +- **`--space `:** render only the named space's tree, no heading. Unknown space id ⇒ a clear, listing error (enumerate the available space ids); invalid id ⇒ the existing `errorInvalidSpaceId`. +- The renderer itself is space-agnostic — it already consumes a single `MigrationGraph`. The work is per-space iteration + heading composition + `--space` resolution, reusing `migration list`'s space-enumeration and error helpers (`isValidSpaceId`, `errorSpaceNotFound`). + +## Scope + +**In:** + +- All-spaces-by-default rendering on `migration graph` (per-space trees, `spaceId:` headings when multi-space). +- `--space ` flag (human + `--json`/`--dot` route through the selected space; multi-space JSON/DOT keys output by space id). +- Space resolution + unknown/invalid-id errors, reusing the `migration list` helpers. +- Help text / examples; tests across multi-space default, single `--space`, and unknown-space error. + +**Out:** + +- Cross-space edges / a unified multi-space topology (spaces are independent histories — disconnected trees only). +- The Tier-3 renderer's per-tree layout — untouched. + +## Open Questions + +1. **Flag spelling / value.** `--space ` is the established spelling on `migration list`; reuse it verbatim for consistency. +2. **`--space` + the eventual default tree renderer.** This slice should land after `--tree` becomes the default (TML-2748) or be written against `--tree` explicitly; confirm sequencing at pickup. +3. **Multi-space `--json`/`--dot` shape.** Single-space keeps today's shape; multi-space needs a per-space keyed envelope. Settle the exact shape at pickup (e.g. `{ spaces: [{ spaceId, nodes, edges }] }`). + +## References + +- Parent project: `projects/migration-graph-rendering/spec.md`. +- Predecessor slice that drops the old per-space graph view: `slices/remove-list-graph-renderer/spec.md` (TML-2765) — its Open Question #1 defers this work to here. +- Linear issue: _to be filed at pickup (standalone, related to TML-2765)._ diff --git a/projects/migration-graph-rendering/slices/remove-list-graph-renderer/spec.md b/projects/migration-graph-rendering/slices/remove-list-graph-renderer/spec.md index d8b002512b..1a415870bc 100644 --- a/projects/migration-graph-rendering/slices/remove-list-graph-renderer/spec.md +++ b/projects/migration-graph-rendering/slices/remove-list-graph-renderer/spec.md @@ -72,7 +72,7 @@ One reviewer holds this in one sitting: it deletes one renderer and the single f ## References - Parent project: `projects/migration-graph-rendering/spec.md` (Tier-3 redesign — this slice's predecessor). -- Linear issue: _to be filed (standalone, related to TML-2746 and TML-2748)._ +- Linear issue: [TML-2765](https://linear.app/prisma-company/issue/TML-2765) (standalone, related to TML-2746 and TML-2748). - Surfaces removed: `cli/src/utils/formatters/migration-list-graph-{render,layout}.ts` (+ tests/fixtures), `cli/src/commands/migration-list.ts` (`--graph`), `docs/reference/migration-list-graph-rendering.md`. - Surfaces kept: `migration-list-graph-topology.ts` (shared with `migration-graph-rows.ts`), flat `migration list`, the Tier-3 tree renderer.