Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions examples/mongo-demo/test/manual-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ describe('hand-authored migration (20260415_add-posts-author-index)', {
policy: ALL_POLICY,
frameworkComponents: [],
strictVerification: false,
migrationEdges: [
{
migrationHash:
'sha256:358522152ebe3ca9db3d573471c656778c1845f4cdd424caf06632352b9772fe',
dirName: 'manual-migration',
from: '',
to: 'sha256:358522152ebe3ca9db3d573471c656778c1845f4cdd424caf06632352b9772fe',
operationCount: ops.length,
},
],
});

expect(result.ok).toBe(true);
Expand Down
9 changes: 9 additions & 0 deletions examples/retail-store/test/manual-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ describe('hand-authored migration (backfill-product-status)', {
policy: ALL_POLICY,
frameworkComponents: [],
strictVerification: false,
migrationEdges: [
{
migrationHash: STORAGE_HASH,
dirName: 'manual-migration',
from: '',
to: STORAGE_HASH,
operationCount: ops.length,
},
],
});

expect(result.ok).toBe(true);
Expand Down
27 changes: 27 additions & 0 deletions examples/retail-store/test/migration-chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ describe('full retail-store migration chain (m1 → m2 → m3)', {
policy: ALL_POLICY,
frameworkComponents: [],
strictVerification: true,
migrationEdges: [
{
migrationHash: m1.endContract.storage.storageHash,
dirName: MIG1_DIR,
from: '',
to: m1.endContract.storage.storageHash,
operationCount: m1.ops.length,
},
],
});
expect(r1.ok, `m1 failed: ${JSON.stringify(r1)}`).toBe(true);

Expand All @@ -153,6 +162,15 @@ describe('full retail-store migration chain (m1 → m2 → m3)', {
policy: ALL_POLICY,
frameworkComponents: [],
strictVerification: true,
migrationEdges: [
{
migrationHash: m2.endContract.storage.storageHash,
dirName: MIG2_DIR,
from: m1.endContract.storage.storageHash,
to: m2.endContract.storage.storageHash,
operationCount: m2.ops.length,
},
],
});
expect(r2.ok, `m2 failed: ${JSON.stringify(r2)}`).toBe(true);

Expand Down Expand Up @@ -197,6 +215,15 @@ describe('full retail-store migration chain (m1 → m2 → m3)', {
policy: ALL_POLICY,
frameworkComponents: [],
strictVerification: true,
migrationEdges: [
{
migrationHash: m3.endContract.storage.storageHash,
dirName: MIG3_DIR,
from: m2.endContract.storage.storageHash,
to: m3.endContract.storage.storageHash,
operationCount: m3.ops.length,
},
],
});
expect(r3.ok, `m3 failed: ${JSON.stringify(r3)}`).toBe(true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type {
GeneratedValueSpec,
JsonPrimitive,
JsonValue,
LedgerEntryRecord,
PlanMeta,
ProfileHashBase,
Source,
Expand Down
14 changes: 14 additions & 0 deletions packages/1-framework/0-foundation/contract/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,17 @@ export interface ContractMarkerRecord {
readonly meta: Record<string, unknown>;
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;
Comment thread
wmadden-electric marked this conversation as resolved.
readonly to: string;
readonly appliedAt: Date;
readonly operationCount: number;
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -91,6 +95,16 @@ export interface ControlFamilyInstance<TFamilyId extends string, TSchemaIR>
readonly driver: ControlDriverInstance<TFamilyId, string>;
}): Promise<ReadonlyMap<string, ContractMarkerRecord>>;

/**
* 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<TFamilyId, string>;
readonly space: string;
}): Promise<readonly LedgerEntryRecord[]>;

introspect(options: {
readonly driver: ControlDriverInstance<TFamilyId, string>;
readonly contract?: unknown;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,17 @@ export interface MigrationRunnerPerSpaceOptions<
* Paths and metadata forwarded to schema verification diagnostics.
*/
readonly context?: OperationContext;
/**
* Per-edge breakdown from aggregate planning. Runners write one ledger row
* per edge in walk order.
*/
readonly migrationEdges: ReadonlyArray<{
readonly migrationHash: string;
readonly dirName: string;
readonly from: string;
readonly to: string;
readonly operationCount: number;
}>;
Comment thread
wmadden-electric marked this conversation as resolved.
}

export interface MigrationRunner<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ export async function loadAggregateStatusSpaces(args: {
// Count pending *migrations* (graph edges), not operations: a
// single authored migration that lowers to N ops or zero ops
// both count as exactly one pending unit of work for the user.
pendingCount = walked.result.migrationEdges?.length ?? 0;
pendingCount = walked.result.migrationEdges.length;
if (liveMarker === null) {
status = pendingCount === 0 ? 'no-marker' : 'pending';
} else {
Expand Down
12 changes: 11 additions & 1 deletion packages/1-framework/3-tooling/cli/src/control-api/client.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -447,6 +451,12 @@ class ControlClientImpl implements ControlClient {
return familyInstance.readAllMarkers({ driver });
}

/** Reads the per-migration journal for `space` (defaults to the app contract space). */
async readLedger(space = APP_SPACE_ID): Promise<readonly LedgerEntryRecord[]> {
const { driver, familyInstance } = await this.ensureConnected();
return familyInstance.readLedger({ driver, space });
}

async migrationApply(options: MigrationApplyOptions): Promise<MigrationApplyResult> {
const { onProgress } = options;
await this.connectWithProgress(options.connection, 'migrationApply', onProgress);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export async function applyMigration<TFamilyId extends string, TTargetId extends
destinationContract: r.entry.destinationContract,
policy,
frameworkComponents,
migrationEdges: r.entry.migrationEdges,
// Per-space post-apply schema verification is non-strict: each
// space's `destinationContract` describes only its own slice; a
// strict verifier would treat every other space's tables as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
TargetMigrationsCapability,
} from '@prisma-next/framework-components/control';
import {
buildSynthMigrationEdge,
type ContractMarkerRecordLike,
type ContractSpaceAggregate,
type ContractSpaceMember,
Expand Down Expand Up @@ -319,7 +320,7 @@ export async function executeMigrationApply<TFamilyId extends string, TTargetId
includeMarkers: true,
});
const totalMigrationsApplied = applied.value.orderedResolutions.reduce(
(sum, r) => sum + (r.entry.migrationEdges?.length ?? 0),
(sum, r) => sum + r.entry.migrationEdges.length,
0,
);
const summary = `Applied ${totalMigrationsApplied} migration(s) (${applied.value.totalOpsExecuted} operation(s)) across ${orderedAll.length} contract space(s)`;
Expand Down Expand Up @@ -361,7 +362,13 @@ function buildAtHeadResolution(args: {
displayOps: [],
destinationContract: member.contract(),
strategy: 'graph-walk',
migrationEdges: [],
migrationEdges: [
buildSynthMigrationEdge({
currentMarkerStorageHash: liveMarker?.storageHash,
destinationStorageHash: targetHash,
operationCount: 0,
}),
],
};
}

Expand Down Expand Up @@ -404,7 +411,7 @@ function buildSuccess(args: BuildSuccessArgs): MigrationApplySuccess {
// JSON-shape consumers (e.g. `parsed.applied.length` in integration
// tests). The aggregate per-space breakdown lives on `perSpace[]`.
const applied = args.orderedResolutions.flatMap((r) => {
const edges = r.entry.migrationEdges ?? [];
const edges = r.entry.migrationEdges;
return edges.map((edge) => ({
spaceId: r.spaceId,
dirName: edge.dirName,
Expand Down
13 changes: 12 additions & 1 deletion packages/1-framework/3-tooling/cli/src/control-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type {
ContractSourceDiagnostics,
ContractSourceProvider,
} from '@prisma-next/config/config-types';
import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types';
import type {
Contract,
ContractMarkerRecord,
LedgerEntryRecord,
} from '@prisma-next/contract/types';
import type {
ControlAdapterDescriptor,
ControlDriverDescriptor,
Expand Down Expand Up @@ -876,6 +880,13 @@ export interface ControlClient {
*/
readAllMarkers(): Promise<ReadonlyMap<string, ContractMarkerRecord>>;

/**
* 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<readonly LedgerEntryRecord[]>;

/**
* Applies pre-planned on-disk migrations to the database.
* Each migration runs in its own transaction with full execution checks.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe('defineConfig', () => {
}),
readMarker: async () => null,
readAllMarkers: async () => new Map(),
readLedger: async () => [],
introspect: async () => ({ tables: {}, extensionPacks: [] }),
}),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type {
ContractSpaceMember,
PerSpacePlan,
} from '@prisma-next/migration-tools/aggregate';
import { createContractSpaceAggregate } from '@prisma-next/migration-tools/aggregate';
import {
buildSynthMigrationEdge,
createContractSpaceAggregate,
} from '@prisma-next/migration-tools/aggregate';
import { ok } from '@prisma-next/utils/result';
import { describe, expect, it, vi } from 'vitest';
import { type ApplyAction, applyMigration } from '../../src/control-api/operations/apply';
Expand Down Expand Up @@ -61,7 +64,13 @@ function makePerSpacePlan(): PerSpacePlan {
displayOps: [],
destinationContract: makeAppMember().contract,
strategy: 'graph-walk',
migrationEdges: [],
migrationEdges: [
buildSynthMigrationEdge({
currentMarkerStorageHash: null,
destinationStorageHash: APP_HASH,
operationCount: 0,
}),
],
pathDecision: undefined,
} as unknown as PerSpacePlan;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ContractSourceProvider } from '@prisma-next/config/config-types';
import type { Contract } from '@prisma-next/contract/types';
import type { Contract, LedgerEntryRecord } from '@prisma-next/contract/types';
import type { EmitResult } from '@prisma-next/emitter';
import type {
ControlAdapterDescriptor,
Expand Down Expand Up @@ -68,6 +68,7 @@ function createMockComponents() {
introspect: async () => ({ tables: [] }),
deserializeContract: (ir: unknown) => ir as Contract,
readMarker: async () => null,
readLedger: async () => [],
verify: async (): Promise<VerifyDatabaseResult> => ({
ok: true,
summary: 'Verification passed',
Expand Down Expand Up @@ -858,6 +859,39 @@ describe('ControlClient progress emission', () => {
});
});

describe('readLedger()', () => {
it('returns journal entries from the family instance', async () => {
const { mockFamily, mockTarget, mockAdapter, mockDriverDescriptor, mockFamilyInstance } =
createMockComponents();

const expectedEntries: LedgerEntryRecord[] = [
{
space: 'app',
migrationName: '001_init',
migrationHash: 'sha256:mig-init',
from: null,
to: 'sha256:dest',
appliedAt: new Date('2024-06-01T12:00:00.000Z'),
operationCount: 1,
},
];
mockFamilyInstance.readLedger = async () => expectedEntries;

const client = createControlClient({
family: mockFamily,
target: mockTarget,
adapter: mockAdapter,
driver: mockDriverDescriptor,
});

await client.connect('postgres://test');
const entries = await client.readLedger();
await client.close();

expect(entries).toEqual(expectedEntries);
});
});

describe('readMarker()', () => {
it('returns null when no marker exists', async () => {
const { mockFamily, mockTarget, mockAdapter, mockDriverDescriptor } = createMockComponents();
Expand Down
4 changes: 4 additions & 0 deletions packages/1-framework/3-tooling/migration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@
"types": "./dist/exports/constants.d.mts",
"import": "./dist/exports/constants.mjs"
},
"./ledger-origin": {
"types": "./dist/exports/ledger-origin.d.mts",
"import": "./dist/exports/ledger-origin.mjs"
},
"./migration-ts": {
"types": "./dist/exports/migration-ts.d.mts",
"import": "./dist/exports/migration-ts.mjs"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@ export interface PerSpacePlan {
readonly destinationContract: Contract;
readonly strategy: 'graph-walk' | 'synth';
/**
* Per-edge breakdown of the chain. Populated by the graph-walk
* strategy; absent for synth-produced plans.
* Per-edge breakdown of the chain. Graph-walk plans carry one entry per
* authored edge; synth and at-head plans carry a single synthesised edge.
*/
readonly migrationEdges?: readonly AggregateMigrationEdgeRef[];
readonly migrationEdges: readonly AggregateMigrationEdgeRef[];
/**
* Path decision data the strategy used to select the chain
* (alternative count, tie-break reasons, required/satisfied
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export async function planMigration<TFamilyId extends string, TTargetId extends
if (ignoreGraph) {
const synthOutcome = await synthStrategy({
aggregateTargetId: aggregate.targetId,
currentMarker,
member,
otherMembers,
schemaIntrospection: currentDBState.schemaIntrospection,
Expand Down Expand Up @@ -131,6 +132,7 @@ export async function planMigration<TFamilyId extends string, TTargetId extends

const synthOutcome = await synthStrategy({
aggregateTargetId: aggregate.targetId,
currentMarker,
member,
otherMembers,
schemaIntrospection: currentDBState.schemaIntrospection,
Expand Down
Loading
Loading