Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5f26680
feat(cli): introduce phase-1 contract-space seed component
wmadden May 11, 2026
22a29e1
refactor(migration-tools): retire descriptor-vs-on-disk drift surface…
wmadden May 11, 2026
9422744
refactor(cli): replace two migrate passes with seed phase + aggregate…
wmadden May 11, 2026
f3a3f17
feat(cli): migrate show to aggregate enumeration (T6.5.4)
wmadden May 11, 2026
82e6ecf
fix(cli): stop reading contractSpace.contractJson in aggregate loader…
wmadden May 11, 2026
9775c1e
refactor(cli): use real control stack for aggregate validation; surfa…
wmadden May 12, 2026
98c2068
fix(cli): migration status counts pending migrations, not lowered ops…
wmadden May 12, 2026
35b1a19
fix(cli): use empty-hash sentinel for no-marker structural fallback
wmadden May 12, 2026
3c15243
refactor: tighten aggregate plumbing — dry-run sees orphan markers, d…
wmadden May 12, 2026
1e66ec7
docs: describe behavior directly in comments, pin perSpace ordering i…
wmadden May 12, 2026
99db200
refactor(cli): use ifDefined for conditional driver spread in migrati…
wmadden May 12, 2026
6ddb081
chore(cli): apply biome formatting to extension-pack-inputs test imports
wmadden May 12, 2026
feea440
docs(cli): describe SQL byte-identity behaviour directly in migration…
wmadden May 12, 2026
09b24b6
fix(cli): surface migration-show edge cases instead of mislabeling them
wmadden May 12, 2026
4147a79
fix(cli): reject cross-drive migration-show targets in containment guard
wmadden May 12, 2026
efb161f
test(cli): clean up migration-show temp dirs in afterEach
wmadden May 12, 2026
31bcc8f
fix(cli): align raw extension-pack filtering with canonical contractS…
wmadden May 12, 2026
7a8abe4
docs(cli): strip milestone IDs from migration-show test comment
wmadden May 12, 2026
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

92 changes: 45 additions & 47 deletions packages/1-framework/3-tooling/cli/src/commands/migration-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { readFile } from 'node:fs/promises';
import type { Contract } from '@prisma-next/contract/types';
import { getEmittedArtifactPaths } from '@prisma-next/emitter';
import {
APP_SPACE_ID,
createControlStack,
hasOperationPreview,
type MigrationPlanOperation,
Expand Down Expand Up @@ -43,16 +42,9 @@ import {
setCommandDescriptions,
setCommandExamples,
} from '../utils/command-helpers';
import { runContractSpaceExtensionMigrationsPass } from '../utils/contract-space-extension-migrations-pass';
import {
formatContractSpaceDriftWarning,
runContractSpaceMigratePass,
} from '../utils/contract-space-migrate-pass';
import {
toExtensionInputs,
toExtensionMigrationsInputs,
toMigratePassInputs,
} from '../utils/extension-pack-inputs';
import { buildContractSpaceAggregate } from '../utils/contract-space-aggregate-loader';
import { runContractSpaceSeedPhase } from '../utils/contract-space-seed-phase';
import { toExtensionInputs } from '../utils/extension-pack-inputs';
import { formatStyledHeader } from '../utils/formatters/styled';
import { assertFrameworkComponentsCompatible } from '../utils/framework-components';
import type { CommonCommandOptions } from '../utils/global-flags';
Expand Down Expand Up @@ -241,41 +233,30 @@ async function executeMigrationPlanCommand(
);
}

// Per-space migrate pass: drift detection + on-disk artefact emission for
// every loaded extension that exposes a `contractSpace`. Runs *before*
// the app-space no-op check so that an extension bump alone (with no
// structural app-space change) still re-pins extension artefacts on
// disk. Drift warnings are non-fatal — the on-disk artefacts are refreshed
// and the user is notified that the bump is being captured.
// Single descriptor-import boundary: every consumer of `extensionPacks`
// goes through `toExtensionInputs` + a per-consumer adapter. AC11.
// Phase 1 — seed: unconditionally re-emit per-space pinned artefacts
// (contract.json / contract.d.ts / refs/head.json) and materialise any
// descriptor-shipped migration packages not yet on disk. Runs before
// the no-op check so that an extension bump alone (with no structural
// app-space change) still re-pins extension artefacts on disk.
const canonicalExtensionInputs = toExtensionInputs(config.extensionPacks ?? []);
const migratePass = await runContractSpaceMigratePass({
const seedResult = await runContractSpaceSeedPhase({
migrationsDir,
extensionPacks: toMigratePassInputs(canonicalExtensionInputs),
extensionPacks: canonicalExtensionInputs,
});
if (!flags.json && !flags.quiet) {
for (const drift of migratePass.drifts) {
if (drift.kind === 'drift') {
ui.stderr(formatContractSpaceDriftWarning(drift));
for (const record of seedResult.seeded) {
if (record.action === 'updated') {
const pkgSuffix =
record.newMigrationDirs.length > 0
? `; ${record.newMigrationDirs.length} new migration package(s) materialised`
: '';
ui.step(`Updated ${record.spaceId} to ${record.newHash}${pkgSuffix}`);
}
}
}

// Materialise descriptor-shipped migration packages onto disk under
// `migrations/<spaceId>/<dirName>/` for any package not yet present.
// Idempotent (existing dirs are left untouched).
// Uses `planAllSpaces` for deterministic ordering + duplicate-spaceId
// detection.
const extensionMigrationsResult = await runContractSpaceExtensionMigrationsPass({
migrationsDir,
extensionPacks: toExtensionMigrationsInputs(canonicalExtensionInputs),
});
if (!flags.json && !flags.quiet) {
for (const entry of extensionMigrationsResult.emitted) {
ui.step(`Emitted ${entry.spaceId}/${entry.dirName}`);
}
}
const emittedExtensionDirs = seedResult.seeded.flatMap((r) =>
r.newMigrationDirs.map((dirName) => ({ spaceId: r.spaceId, dirName })),
);

// Check for no-op (same hash means no changes)
if (fromHash === toStorageHash) {
Expand All @@ -285,7 +266,7 @@ async function executeMigrationPlanCommand(
from: fromHash,
to: toStorageHash,
operations: [],
emittedExtensionDirs: extensionMigrationsResult.emitted,
emittedExtensionDirs,
summary: 'No changes detected between contracts',
timings: { total: Date.now() - startTime },
};
Expand All @@ -301,6 +282,25 @@ async function executeMigrationPlanCommand(
}),
);
}

// Phase 2 — load: build the aggregate against the now-consistent disk
// state that phase 1 just seeded. The seed phase guarantees every
// declared extension has its head ref pinned, so the loader's
// declaredButUnmigrated precheck always passes here.
const stack = createControlStack(config);
const familyInstance = config.family.create(stack);
const aggregateResult = await buildContractSpaceAggregate({
targetId: config.target.targetId,
migrationsDir,
appContract: toContractJson,
extensionPacks: config.extensionPacks ?? [],
validateContract: (json: unknown) => familyInstance.validateContract(json),
});
if (!aggregateResult.ok) {
return notOk(aggregateResult.failure);
}
const aggregate = aggregateResult.value;

const frameworkComponents = assertFrameworkComponentsCompatible(
config.family.familyId,
config.target.targetId,
Expand Down Expand Up @@ -328,17 +328,15 @@ async function executeMigrationPlanCommand(
};

try {
const stack = createControlStack(config);
const familyInstance = config.family.create(stack);
const planner = migrations.createPlanner(familyInstance);
const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
const plannerResult = planner.plan({
contract: toContractJson,
contract: aggregate.app.contract,
schema: fromSchema,
policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
fromContract,
frameworkComponents,
spaceId: APP_SPACE_ID,
spaceId: aggregate.app.spaceId,
});
if (plannerResult.kind === 'failure') {
return notOk(
Expand Down Expand Up @@ -421,7 +419,7 @@ async function executeMigrationPlanCommand(
to: toStorageHash,
dir: relative(process.cwd(), packageDir),
operations: [],
emittedExtensionDirs: extensionMigrationsResult.emitted,
emittedExtensionDirs,
pendingPlaceholders: true,
summary:
'Planned migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit',
Expand All @@ -444,9 +442,9 @@ async function executeMigrationPlanCommand(
label: op.label,
operationClass: op.operationClass,
})),
emittedExtensionDirs: extensionMigrationsResult.emitted,
emittedExtensionDirs,
...(preview !== undefined ? { preview } : {}),
summary: buildPlanSummary(plannedOps.length, extensionMigrationsResult.emitted.length),
summary: buildPlanSummary(plannedOps.length, emittedExtensionDirs.length),
timings: { total: Date.now() - startTime },
};
return ok(result);
Expand Down
Loading
Loading