@prisma-next/cli 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +4 -4
  3. package/dist/{client-qVH-rEgd.mjs → client-BCnP7cHo.mjs} +9 -119
  4. package/dist/client-BCnP7cHo.mjs.map +1 -0
  5. package/dist/commands/contract-infer.mjs +1 -1
  6. package/dist/commands/db-init.mjs +3 -3
  7. package/dist/commands/db-schema.mjs +1 -1
  8. package/dist/commands/db-sign.mjs +1 -1
  9. package/dist/commands/db-update.mjs +3 -3
  10. package/dist/commands/db-verify.mjs +1 -1
  11. package/dist/commands/migration-apply.d.mts +1 -1
  12. package/dist/commands/migration-apply.mjs +2 -2
  13. package/dist/commands/migration-plan.d.mts.map +1 -1
  14. package/dist/commands/migration-plan.mjs +1 -1
  15. package/dist/commands/migration-show.d.mts +55 -7
  16. package/dist/commands/migration-show.d.mts.map +1 -1
  17. package/dist/commands/migration-show.mjs +153 -46
  18. package/dist/commands/migration-show.mjs.map +1 -1
  19. package/dist/commands/migration-status.d.mts.map +1 -1
  20. package/dist/commands/migration-status.mjs +1 -1
  21. package/dist/{contract-infer-BK9YFGEG.mjs → contract-infer-ByxhPjpW.mjs} +2 -2
  22. package/dist/{contract-infer-BK9YFGEG.mjs.map → contract-infer-ByxhPjpW.mjs.map} +1 -1
  23. package/dist/contract-space-aggregate-loader-BrwKK6Q6.mjs +160 -0
  24. package/dist/contract-space-aggregate-loader-BrwKK6Q6.mjs.map +1 -0
  25. package/dist/{db-verify-C0y1PCO2.mjs → db-verify-Czm5T-J4.mjs} +2 -2
  26. package/dist/{db-verify-C0y1PCO2.mjs.map → db-verify-Czm5T-J4.mjs.map} +1 -1
  27. package/dist/exports/control-api.d.mts +1 -1
  28. package/dist/exports/control-api.mjs +1 -1
  29. package/dist/{inspect-live-schema-CWYxGKlb.mjs → inspect-live-schema-DxdBd4Er.mjs} +2 -2
  30. package/dist/{inspect-live-schema-CWYxGKlb.mjs.map → inspect-live-schema-DxdBd4Er.mjs.map} +1 -1
  31. package/dist/{migration-command-scaffold-B5dORFEv.mjs → migration-command-scaffold-BdV8JYXV.mjs} +2 -2
  32. package/dist/{migration-command-scaffold-B5dORFEv.mjs.map → migration-command-scaffold-BdV8JYXV.mjs.map} +1 -1
  33. package/dist/{migration-plan-C6lVaHsO.mjs → migration-plan-mRu5K81L.mjs} +89 -149
  34. package/dist/migration-plan-mRu5K81L.mjs.map +1 -0
  35. package/dist/{migration-status-CZ-D5k7k.mjs → migration-status-By9G5p2H.mjs} +6 -8
  36. package/dist/{migration-status-CZ-D5k7k.mjs.map → migration-status-By9G5p2H.mjs.map} +1 -1
  37. package/dist/{migrations-D_UJnpuW.mjs → migrations-CTsyBXCA.mjs} +42 -29
  38. package/dist/migrations-CTsyBXCA.mjs.map +1 -0
  39. package/dist/{types-D7x-IFLO.d.mts → types-LItU7E4l.d.mts} +7 -9
  40. package/dist/{types-D7x-IFLO.d.mts.map → types-LItU7E4l.d.mts.map} +1 -1
  41. package/package.json +14 -14
  42. package/src/commands/migration-plan.ts +45 -47
  43. package/src/commands/migration-show.ts +245 -60
  44. package/src/commands/migration-status.ts +17 -9
  45. package/src/control-api/operations/db-apply-aggregate.ts +12 -10
  46. package/src/control-api/operations/migration-apply.ts +7 -1
  47. package/src/control-api/types.ts +6 -8
  48. package/src/utils/contract-space-aggregate-loader.ts +7 -34
  49. package/src/utils/contract-space-seed-phase.ts +201 -0
  50. package/src/utils/extension-pack-inputs.ts +47 -55
  51. package/src/utils/formatters/migrations.ts +80 -38
  52. package/dist/client-qVH-rEgd.mjs.map +0 -1
  53. package/dist/extension-pack-inputs-C7xgE-vv.mjs +0 -74
  54. package/dist/extension-pack-inputs-C7xgE-vv.mjs.map +0 -1
  55. package/dist/migration-plan-C6lVaHsO.mjs.map +0 -1
  56. package/dist/migrations-D_UJnpuW.mjs.map +0 -1
  57. package/src/utils/contract-space-extension-migrations-pass.ts +0 -120
  58. package/src/utils/contract-space-migrate-pass.ts +0 -156
@@ -1,6 +1,9 @@
1
- import type {
2
- MigrationPlanOperation,
3
- OperationPreview,
1
+ import { readFile } from 'node:fs/promises';
2
+ import type { Contract } from '@prisma-next/contract/types';
3
+ import {
4
+ createControlStack,
5
+ type MigrationPlanOperation,
6
+ type OperationPreview,
4
7
  } from '@prisma-next/framework-components/control';
5
8
  import { MigrationToolsError } from '@prisma-next/migration-tools/errors';
6
9
  import { readMigrationPackage, readMigrationsDir } from '@prisma-next/migration-tools/io';
@@ -9,23 +12,29 @@ import {
9
12
  reconstructGraph,
10
13
  } from '@prisma-next/migration-tools/migration-graph';
11
14
  import type { OnDiskMigrationPackage } from '@prisma-next/migration-tools/package';
12
- import { APP_SPACE_ID, spaceMigrationDirectory } from '@prisma-next/migration-tools/spaces';
15
+ import { spaceMigrationDirectory } from '@prisma-next/migration-tools/spaces';
16
+ import { ifDefined } from '@prisma-next/utils/defined';
13
17
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
14
18
  import { Command } from 'commander';
15
- import { relative, resolve } from 'pathe';
19
+ import { isAbsolute, relative, resolve } from 'pathe';
16
20
  import { loadConfig } from '../config-loader';
17
21
  import { createControlClient } from '../control-api/client';
18
22
  import {
19
23
  type CliStructuredError,
24
+ errorContractValidationFailed,
25
+ errorFileNotFound,
20
26
  errorRuntime,
21
27
  errorUnexpected,
22
28
  mapMigrationToolsError,
23
29
  } from '../utils/cli-errors';
24
30
  import {
25
31
  addGlobalOptions,
32
+ resolveContractPath,
33
+ resolveMigrationPaths,
26
34
  setCommandDescriptions,
27
35
  setCommandExamples,
28
36
  } from '../utils/command-helpers';
37
+ import { buildContractSpaceAggregate } from '../utils/contract-space-aggregate-loader';
29
38
  import { formatMigrationShowOutput } from '../utils/formatters/migrations';
30
39
  import { formatStyledHeader } from '../utils/formatters/styled';
31
40
  import type { CommonCommandOptions } from '../utils/global-flags';
@@ -37,8 +46,12 @@ interface MigrationShowOptions extends CommonCommandOptions {
37
46
  readonly config?: string;
38
47
  }
39
48
 
40
- export interface MigrationShowResult {
41
- readonly ok: true;
49
+ /**
50
+ * Details of one space's latest (or targeted) migration package.
51
+ */
52
+ export interface MigrationShowSpacePresent {
53
+ readonly kind: 'present';
54
+ readonly spaceId: string;
42
55
  readonly dirName: string;
43
56
  readonly dirPath: string;
44
57
  readonly from: string | null;
@@ -51,19 +64,78 @@ export interface MigrationShowResult {
51
64
  readonly operationClass: string;
52
65
  }[];
53
66
  /**
54
- * Family-agnostic textual preview of the migration's operations. Replaces
55
- * the previous string-array DDL field. Always defined; statements is empty
56
- * for a no-op migration or a family that does not implement the
57
- * `OperationPreviewCapable` capability.
67
+ * Family-agnostic textual preview of the migration's operations. Always
68
+ * defined; statements is empty for a no-op migration or a family that does
69
+ * not implement the `OperationPreviewCapable` capability.
58
70
  */
59
71
  readonly preview: OperationPreview;
60
72
  readonly summary: string;
61
73
  }
62
74
 
75
+ /**
76
+ * Placeholder for a loaded contract space that has no on-disk migration
77
+ * package — the extension descriptor declared the space but no migrations
78
+ * directory has been materialised for it yet. Surfaces the space in the
79
+ * response so JSON consumers see every loaded extension instead of having
80
+ * silently-skipped entries.
81
+ */
82
+ export interface MigrationShowSpaceMissing {
83
+ readonly kind: 'missing';
84
+ readonly spaceId: string;
85
+ readonly summary: string;
86
+ }
87
+
88
+ export type MigrationShowSpaceResult = MigrationShowSpacePresent | MigrationShowSpaceMissing;
89
+
90
+ export interface MigrationShowResult {
91
+ readonly ok: true;
92
+ /**
93
+ * Per-space results, ordered: app first, then extensions alphabetically
94
+ * (matching the aggregate's canonical ordering).
95
+ */
96
+ readonly spaces: readonly MigrationShowSpaceResult[];
97
+ }
98
+
63
99
  function looksLikePath(target: string): boolean {
64
100
  return target.includes('/') || target.includes('\\');
65
101
  }
66
102
 
103
+ /**
104
+ * Validate that a path-like `migration show` target resolves inside the app
105
+ * migrations directory. The returned result is always emitted under
106
+ * `aggregate.app.spaceId`, so accepting an extension-space (or otherwise
107
+ * external) path here would silently mislabel the result. Returns the
108
+ * resolved absolute path on success.
109
+ *
110
+ * `pathe.relative` can return an absolute path when the target cannot be
111
+ * expressed relative to the base (e.g. on Windows when `target` is on a
112
+ * different drive than `appMigrationsDir`). That case does not start with
113
+ * `..`, so the absolute-check below is required to reject cross-drive
114
+ * targets rather than mislabeling them as app-space.
115
+ */
116
+ export function resolveAppTargetPath(
117
+ target: string,
118
+ appMigrationsDir: string,
119
+ appMigrationsRelative: string,
120
+ ): Result<string, CliStructuredError> {
121
+ const targetPath = resolve(target);
122
+ const relativeToApp = relative(appMigrationsDir, targetPath);
123
+ const isOutsideAppDir =
124
+ relativeToApp === '' ||
125
+ relativeToApp === '.' ||
126
+ relativeToApp.startsWith('..') ||
127
+ isAbsolute(relativeToApp);
128
+ if (isOutsideAppDir) {
129
+ return notOk(
130
+ errorRuntime('Target must point to an app-space migration', {
131
+ why: `Expected a path under ${appMigrationsRelative}, got ${target}`,
132
+ fix: 'Pass an app-space migration directory or use a hash prefix.',
133
+ }),
134
+ );
135
+ }
136
+ return ok(targetPath);
137
+ }
138
+
67
139
  export function resolveByHashPrefix(
68
140
  packages: readonly OnDiskMigrationPackage[],
69
141
  prefix: string,
@@ -93,6 +165,71 @@ export function resolveByHashPrefix(
93
165
  );
94
166
  }
95
167
 
168
+ /**
169
+ * Resolve the latest migration from a space directory.
170
+ *
171
+ * Returns `ok(null)` only when the directory is empty or absent (ENOENT is
172
+ * absorbed by `readMigrationsDir`). If `readMigrationsDir` returned packages
173
+ * but `findLatestMigration` cannot pick a leaf, the on-disk history is
174
+ * corrupt — return a runtime error rather than collapsing it to a `missing`
175
+ * placeholder, which would hide the corruption from the caller.
176
+ */
177
+ export async function resolveLatestFromDir(
178
+ spaceDir: string,
179
+ ): Promise<Result<OnDiskMigrationPackage | null, CliStructuredError>> {
180
+ try {
181
+ const allPackages = await readMigrationsDir(spaceDir);
182
+ if (allPackages.length === 0) return ok(null);
183
+ const graph = reconstructGraph(allPackages);
184
+ const latestMigration = findLatestMigration(graph);
185
+ if (!latestMigration) {
186
+ return notOk(
187
+ errorRuntime('Could not resolve latest migration', {
188
+ why: `No latest migration found in ${relative(process.cwd(), spaceDir)}`,
189
+ fix: 'The migrations directory may be corrupted. Inspect the migration.json files.',
190
+ }),
191
+ );
192
+ }
193
+ const leafPkg = allPackages.find(
194
+ (p) => p.metadata.migrationHash === latestMigration.migrationHash,
195
+ );
196
+ return ok(leafPkg ?? null);
197
+ } catch (error) {
198
+ if (MigrationToolsError.is(error)) return notOk(mapMigrationToolsError(error));
199
+ return notOk(
200
+ errorUnexpected(error instanceof Error ? error.message : String(error), {
201
+ why: `Failed to read migrations: ${error instanceof Error ? error.message : String(error)}`,
202
+ }),
203
+ );
204
+ }
205
+ }
206
+
207
+ function pkgToSpaceResult(
208
+ spaceId: string,
209
+ pkg: OnDiskMigrationPackage,
210
+ client: ReturnType<typeof createControlClient>,
211
+ ): MigrationShowSpacePresent {
212
+ const ops = pkg.ops as readonly MigrationPlanOperation[];
213
+ const preview: OperationPreview = client.toOperationPreview(ops) ?? { statements: [] };
214
+ return {
215
+ kind: 'present',
216
+ spaceId,
217
+ dirName: pkg.dirName,
218
+ dirPath: relative(process.cwd(), pkg.dirPath),
219
+ from: pkg.metadata.from,
220
+ to: pkg.metadata.to,
221
+ migrationHash: pkg.metadata.migrationHash,
222
+ createdAt: pkg.metadata.createdAt,
223
+ operations: ops.map((op) => ({
224
+ id: op.id,
225
+ label: op.label,
226
+ operationClass: op.operationClass,
227
+ })),
228
+ preview,
229
+ summary: `${ops.length} operation(s)`,
230
+ };
231
+ }
232
+
96
233
  async function executeMigrationShowCommand(
97
234
  target: string | undefined,
98
235
  options: MigrationShowOptions,
@@ -100,20 +237,16 @@ async function executeMigrationShowCommand(
100
237
  ui: TerminalUI,
101
238
  ): Promise<Result<MigrationShowResult, CliStructuredError>> {
102
239
  const config = await loadConfig(options.config);
103
- const configPath = options.config
104
- ? relative(process.cwd(), resolve(options.config))
105
- : 'prisma-next.config.ts';
240
+ const { configPath, migrationsDir, appMigrationsDir, appMigrationsRelative } =
241
+ resolveMigrationPaths(options.config, config);
106
242
 
107
- const migrationsDirRoot = resolve(
108
- options.config ? resolve(options.config, '..') : process.cwd(),
109
- config.migrations?.dir ?? 'migrations',
110
- );
111
- const appMigrationsDir = spaceMigrationDirectory(migrationsDirRoot, APP_SPACE_ID);
112
- const appMigrationsRelative = relative(process.cwd(), appMigrationsDir);
243
+ const contractPathAbsolute = resolveContractPath(config);
244
+ const contractPath = relative(process.cwd(), contractPathAbsolute);
113
245
 
114
246
  if (!flags.json && !flags.quiet) {
115
247
  const details: Array<{ label: string; value: string }> = [
116
248
  { label: 'config', value: configPath },
249
+ { label: 'contract', value: contractPath },
117
250
  { label: 'migrations', value: appMigrationsRelative },
118
251
  ];
119
252
  if (target) {
@@ -128,11 +261,73 @@ async function executeMigrationShowCommand(
128
261
  ui.stderr(header);
129
262
  }
130
263
 
131
- let pkg: OnDiskMigrationPackage;
264
+ // Load the app contract so the aggregate loader can validate it.
265
+ let contractJsonContent: string;
266
+ try {
267
+ contractJsonContent = await readFile(contractPathAbsolute, 'utf-8');
268
+ } catch (error) {
269
+ if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
270
+ return notOk(
271
+ errorFileNotFound(contractPathAbsolute, {
272
+ why: `Contract file not found at ${contractPathAbsolute}`,
273
+ fix: `Run \`prisma-next contract emit\` to generate ${contractPath}`,
274
+ }),
275
+ );
276
+ }
277
+ return notOk(
278
+ errorUnexpected(error instanceof Error ? error.message : String(error), {
279
+ why: 'Failed to read contract file',
280
+ }),
281
+ );
282
+ }
132
283
 
284
+ let appContract: Contract;
133
285
  try {
286
+ appContract = JSON.parse(contractJsonContent) as Contract;
287
+ } catch (error) {
288
+ return notOk(
289
+ errorContractValidationFailed(
290
+ `Contract JSON is invalid: ${error instanceof Error ? error.message : String(error)}`,
291
+ { where: { path: contractPathAbsolute } },
292
+ ),
293
+ );
294
+ }
295
+
296
+ // Build the aggregate against current disk state to enumerate all spaces.
297
+ const stack = createControlStack(config);
298
+ const familyInstance = config.family.create(stack);
299
+ const aggregateResult = await buildContractSpaceAggregate({
300
+ targetId: config.target.targetId,
301
+ migrationsDir,
302
+ appContract,
303
+ extensionPacks: config.extensionPacks ?? [],
304
+ validateContract: (json: unknown) => familyInstance.validateContract(json),
305
+ });
306
+ if (!aggregateResult.ok) {
307
+ return notOk(aggregateResult.failure);
308
+ }
309
+ const aggregate = aggregateResult.value;
310
+
311
+ // `migration show` is an offline command; the control client is constructed
312
+ // purely to dispatch the family-specific `toOperationPreview` capability and
313
+ // is not connected to a database.
314
+ const client = createControlClient({
315
+ family: config.family,
316
+ target: config.target,
317
+ adapter: config.adapter,
318
+ ...ifDefined('driver', config.driver),
319
+ extensionPacks: config.extensionPacks ?? [],
320
+ });
321
+
322
+ const spaces: MigrationShowSpaceResult[] = [];
323
+
324
+ // App space: honour the `target` argument (path or hash prefix) when provided.
325
+ try {
326
+ let appPkg: OnDiskMigrationPackage;
134
327
  if (target && looksLikePath(target)) {
135
- pkg = await readMigrationPackage(resolve(target));
328
+ const resolved = resolveAppTargetPath(target, appMigrationsDir, appMigrationsRelative);
329
+ if (!resolved.ok) return resolved;
330
+ appPkg = await readMigrationPackage(resolved.value);
136
331
  } else {
137
332
  const allPackages = await readMigrationsDir(appMigrationsDir);
138
333
  if (allPackages.length === 0) {
@@ -143,11 +338,10 @@ async function executeMigrationShowCommand(
143
338
  }),
144
339
  );
145
340
  }
146
-
147
341
  if (target) {
148
342
  const resolved = resolveByHashPrefix(allPackages, target);
149
343
  if (!resolved.ok) return resolved;
150
- pkg = resolved.value;
344
+ appPkg = resolved.value;
151
345
  } else {
152
346
  const graph = reconstructGraph(allPackages);
153
347
  const latestMigration = findLatestMigration(graph);
@@ -170,51 +364,42 @@ async function executeMigrationShowCommand(
170
364
  }),
171
365
  );
172
366
  }
173
- pkg = leafPkg;
367
+ appPkg = leafPkg;
174
368
  }
175
369
  }
370
+ spaces.push(pkgToSpaceResult(aggregate.app.spaceId, appPkg, client));
176
371
  } catch (error) {
177
372
  if (MigrationToolsError.is(error)) {
178
373
  return notOk(mapMigrationToolsError(error));
179
374
  }
180
375
  return notOk(
181
376
  errorUnexpected(error instanceof Error ? error.message : String(error), {
182
- why: `Failed to read migration: ${error instanceof Error ? error.message : String(error)}`,
377
+ why: `Failed to read app-space migration: ${error instanceof Error ? error.message : String(error)}`,
183
378
  }),
184
379
  );
185
380
  }
186
381
 
187
- const ops = pkg.ops as readonly MigrationPlanOperation[];
188
-
189
- // `migration show` is an offline command; the control client is constructed
190
- // purely to dispatch the family-specific `toOperationPreview` capability and
191
- // is not connected to a database.
192
- const client = createControlClient({
193
- family: config.family,
194
- target: config.target,
195
- adapter: config.adapter,
196
- ...(config.driver ? { driver: config.driver } : {}),
197
- extensionPacks: config.extensionPacks ?? [],
198
- });
199
- const preview: OperationPreview = client.toOperationPreview(ops) ?? { statements: [] };
382
+ // Extension spaces: always emit one entry per loaded extension so the
383
+ // response enumerates every space the aggregate knows about. Spaces
384
+ // with no on-disk migration package yet (e.g. an extension was declared
385
+ // but never `migrate`d) become `kind: 'missing'` placeholders instead
386
+ // of being silently skipped.
387
+ for (const ext of aggregate.extensions) {
388
+ const extSpaceDir = spaceMigrationDirectory(migrationsDir, ext.spaceId);
389
+ const extPkgResult = await resolveLatestFromDir(extSpaceDir);
390
+ if (!extPkgResult.ok) return extPkgResult;
391
+ if (extPkgResult.value !== null) {
392
+ spaces.push(pkgToSpaceResult(ext.spaceId, extPkgResult.value, client));
393
+ } else {
394
+ spaces.push({
395
+ kind: 'missing',
396
+ spaceId: ext.spaceId,
397
+ summary: 'No on-disk migration package for this space',
398
+ });
399
+ }
400
+ }
200
401
 
201
- const result: MigrationShowResult = {
202
- ok: true,
203
- dirName: pkg.dirName,
204
- dirPath: relative(process.cwd(), pkg.dirPath),
205
- from: pkg.metadata.from,
206
- to: pkg.metadata.to,
207
- migrationHash: pkg.metadata.migrationHash,
208
- createdAt: pkg.metadata.createdAt,
209
- operations: ops.map((op) => ({
210
- id: op.id,
211
- label: op.label,
212
- operationClass: op.operationClass,
213
- })),
214
- preview,
215
- summary: `${ops.length} operation(s)`,
216
- };
217
- return ok(result);
402
+ return ok({ ok: true, spaces });
218
403
  }
219
404
 
220
405
  export function createMigrationShowCommand(): Command {
@@ -222,16 +407,16 @@ export function createMigrationShowCommand(): Command {
222
407
  setCommandDescriptions(
223
408
  command,
224
409
  'Display migration package contents',
225
- 'Shows the operations, statement preview, and metadata for a migration package.\n' +
226
- 'Accepts a directory path, a hash prefix (git-style), or defaults to the\n' +
227
- 'latest migration.',
410
+ 'Shows the operations, statement preview, and metadata for every loaded contract\n' +
411
+ 'space (app + extensions). Accepts a directory path or hash prefix to target a\n' +
412
+ 'specific app-space migration; defaults to the latest per space.',
228
413
  );
229
414
  setCommandExamples(command, [
230
415
  'prisma-next migration show',
231
416
  'prisma-next migration show sha256:a1b2c3',
232
417
  ]);
233
418
  addGlobalOptions(command)
234
- .argument('[target]', 'Migration directory path or migrationHash prefix (defaults to latest)')
419
+ .argument('[target]', 'App-space migration path or migrationHash prefix (defaults to latest)')
235
420
  .option('--config <path>', 'Path to prisma-next.config.ts')
236
421
  .action(async (target: string | undefined, options: MigrationShowOptions) => {
237
422
  const flags = parseGlobalFlags(options);
@@ -513,7 +513,10 @@ export async function loadAggregateStatusSpaces(args: {
513
513
  let pendingCount = 0;
514
514
  let status: MigrationStatusSpaceEntry['status'];
515
515
  if (walked.kind === 'ok') {
516
- pendingCount = walked.result.plan.operations.length;
516
+ // Count pending *migrations* (graph edges), not operations: a
517
+ // single authored migration that lowers to N ops or zero ops
518
+ // both count as exactly one pending unit of work for the user.
519
+ pendingCount = walked.result.migrationEdges?.length ?? 0;
517
520
  if (liveMarker === null) {
518
521
  status = pendingCount === 0 ? 'no-marker' : 'pending';
519
522
  } else {
@@ -723,15 +726,20 @@ async function executeMigrationStatusCommand(
723
726
  // surface per-space marker state. `readAllMarkers` mirrors what
724
727
  // `db init` / `db update` already use to drive the multi-space
725
728
  // planner; here it powers the aggregate status output.
726
- try {
729
+ //
730
+ // Probe for the method first so we only swallow the
731
+ // unsupported-method case: older family instances may not
732
+ // implement `readAllMarkers` (per-space enumeration then falls
733
+ // back to "marker unknown"). Real query / runtime errors from
734
+ // an instance that *does* expose the method must propagate up
735
+ // — otherwise transient DB failures would silently degrade
736
+ // status to "markers unknown".
737
+ if (typeof client.readAllMarkers === 'function') {
727
738
  allMarkers = await client.readAllMarkers();
728
- } catch {
729
- // Older family instances may not implement `readAllMarkers`.
730
- // Per-space enumeration falls back to "marker unknown" rather
731
- // than failing the whole status command leaving
732
- // `allMarkers` as `null` signals "unknown" to the aggregate
733
- // loader (an empty `Map` would instead mean "every space has
734
- // no marker", which is a different condition).
739
+ } else {
740
+ // Leaving `allMarkers` as `null` signals "unknown" to the
741
+ // aggregate loader (an empty `Map` would instead mean "every
742
+ // space has no marker", which is a different condition).
735
743
  allMarkers = null;
736
744
  }
737
745
  } catch {
@@ -127,16 +127,18 @@ export async function executeAggregateApply<TFamilyId extends string, TTargetId
127
127
  // 2. Read live DB state (markers + schema).
128
128
  const markerRows = await familyInstance.readAllMarkers({ driver });
129
129
 
130
- // 2a. Orphan-marker pre-flight: refuse to apply when a marker row
131
- // exists for a space that is not declared in the aggregate.
132
- // Mirrors the M2 marker-check that `db init` / `db update` ran via
133
- // `runContractSpaceVerifierMarkerCheck`. Runs before planning so a
134
- // user with an orphaned marker (e.g. a retired extension whose
135
- // migrations directory has been removed) is told to clean it up
136
- // rather than silently advancing the app's marker.
137
- const orphanMarkerError = detectOrphanMarkers(aggregate, markerRows);
138
- if (orphanMarkerError !== null) {
139
- throw orphanMarkerError;
130
+ // 2a. Orphan-marker pre-flight: refuse to *apply* when a marker row
131
+ // exists for a space that is not declared in the aggregate. Plan mode
132
+ // (`db init/update --dry-run`) must still be able to introspect the
133
+ // aggregate plan in this state a retired extension whose marker
134
+ // happens to linger should not block the user from inspecting what a
135
+ // run would do. Apply mode tells the user to clean up the orphan
136
+ // before silently advancing the app's marker.
137
+ if (mode === 'apply') {
138
+ const orphanMarkerError = detectOrphanMarkers(aggregate, markerRows);
139
+ if (orphanMarkerError !== null) {
140
+ throw orphanMarkerError;
141
+ }
140
142
  }
141
143
 
142
144
  onProgress?.({
@@ -216,7 +216,13 @@ export async function executeMigrationApply<TFamilyId extends string, TTargetId
216
216
  // (the error rendering pipeline maps it to meta.code +
217
217
  // meta.required + meta.missing + meta.structuralPath that the
218
218
  // cli-journeys invariant suite asserts on).
219
- const fromHash = liveMarker?.storageHash ?? '';
219
+ // Greenfield runs (no marker yet) use the canonical empty-hash
220
+ // sentinel so the structural path stays attached to the
221
+ // `MIGRATION.NO_INVARIANT_PATH` error envelope. Using an empty
222
+ // string here would leave the structural lookup with a hash that
223
+ // is never a graph node, producing an empty `structuralPath` and
224
+ // a less actionable diagnostic.
225
+ const fromHash = liveMarker?.storageHash ?? EMPTY_CONTRACT_HASH;
220
226
  const structural = findPathWithDecision(targetMember.migrations.graph, fromHash, targetHash, {
221
227
  required: new Set<string>(),
222
228
  });
@@ -316,10 +316,10 @@ export interface EmitOptions {
316
316
  * then app — together with the operations attributed to each space and,
317
317
  * when the run was applied, the resulting per-space marker hash.
318
318
  *
319
- * M6 sub-spec § Output shape contract every space involved in a run
320
- * is observable in the success summary, including its post-apply
321
- * marker, so the per-space invariant is visible to the user (closing
322
- * F4 / F7 from `e2e-verification.md`).
319
+ * Every space involved in a run is observable in the success summary,
320
+ * including its post-apply marker — the per-space marker is visible
321
+ * to the user instead of being collapsed into a single ambiguous
322
+ * top-level hash.
323
323
  */
324
324
  export interface AggregatePerSpaceExecutionEntry {
325
325
  readonly spaceId: string;
@@ -536,8 +536,6 @@ export type EmitResult = Result<EmitSuccess, EmitFailure>;
536
536
  * through the shared `applyAggregate` primitive. The CLI command
537
537
  * just resolves the descriptor surface (config, refs, contract
538
538
  * envelope, app-space migration packages) and hands the inputs in.
539
- *
540
- * Sub-spec § `migration apply` semantics + § Required changes 1.
541
539
  */
542
540
  export interface MigrationApplyOptions {
543
541
  /** Already-validated app contract (the canonical "where we are heading" hash). */
@@ -628,8 +626,8 @@ export interface MigrationApplyAppliedEntry {
628
626
  * Successful migrationApply result. Carries both the legacy
629
627
  * single-space fields (`markerHash` is the **app member's** post-apply
630
628
  * marker, surfaced for back-compat with single-space callers) and the
631
- * per-space breakdown (`perSpace` — markers / operations / canonical
632
- * order, per M6 sub-spec § Output shape).
629
+ * per-space breakdown (`perSpace` — markers / operations in canonical
630
+ * schedule order).
633
631
  */
634
632
  /**
635
633
  * Path-decision summary for the **app member** post-apply. Surfaced
@@ -10,14 +10,14 @@ import { loadContractSpaceAggregate } from '@prisma-next/migration-tools/aggrega
10
10
  import type { OnDiskMigrationPackage } from '@prisma-next/migration-tools/package';
11
11
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
12
12
  import { CliStructuredError } from './cli-errors';
13
- import { toDeclaredExtensions, toExtensionInputs } from './extension-pack-inputs';
13
+ import { toDeclaredExtensionsFromRaw } from './extension-pack-inputs';
14
14
 
15
15
  /**
16
16
  * Render a {@link LoadAggregateError} into a CLI structured-error
17
17
  * envelope. Preserves error codes `5001` (layout) and `5002` (marker /
18
- * drift / disjointness / etc.) so existing integration tests and
19
- * downstream tooling continue to assert on the same `meta.violations[]`
20
- * shape they did under the old precheck/marker-check helpers.
18
+ * disjointness / etc.) so existing integration tests and downstream
19
+ * tooling continue to assert on the same `meta.violations[]` shape
20
+ * they did under the old precheck/marker-check helpers.
21
21
  */
22
22
  export function mapLoadAggregateError(error: LoadAggregateError): CliStructuredError {
23
23
  if (error.kind === 'layoutViolation') {
@@ -39,24 +39,6 @@ export function mapLoadAggregateError(error: LoadAggregateError): CliStructuredE
39
39
  },
40
40
  });
41
41
  }
42
- if (error.kind === 'driftViolation') {
43
- return new CliStructuredError('5002', `Contract-space drift detected for "${error.spaceId}"`, {
44
- domain: 'MIG',
45
- why: `The on-disk contract for space "${error.spaceId}" (hash ${error.priorHeadHash}) does not match the live extension descriptor (hash ${error.liveHash}).`,
46
- fix: 'Run `prisma-next migrate` to refresh the on-disk artefacts to match the live descriptor.',
47
- docsUrl: 'https://pris.ly/contract-spaces',
48
- meta: {
49
- violations: [
50
- {
51
- kind: 'drift',
52
- spaceId: error.spaceId,
53
- priorHeadHash: error.priorHeadHash,
54
- liveHash: error.liveHash,
55
- },
56
- ],
57
- },
58
- });
59
- }
60
42
  if (error.kind === 'disjointnessViolation') {
61
43
  return new CliStructuredError(
62
44
  '5002',
@@ -174,25 +156,16 @@ export async function buildContractSpaceAggregate<
174
156
  >(
175
157
  inputs: BuildAggregateInputs<TFamilyId, TTargetId>,
176
158
  ): Promise<Result<ContractSpaceAggregate, CliStructuredError>> {
177
- const { entries, hashByContractJson } = toDeclaredExtensions(
178
- toExtensionInputs(inputs.extensionPacks),
159
+ const declaredExtensions = toDeclaredExtensionsFromRaw(
160
+ inputs.extensionPacks as ReadonlyArray<unknown>,
179
161
  );
180
162
 
181
163
  const loadInput: LoadAggregateInput = {
182
164
  targetId: inputs.targetId,
183
165
  migrationsDir: inputs.migrationsDir,
184
166
  appContract: inputs.appContract,
185
- declaredExtensions: entries,
167
+ declaredExtensions,
186
168
  validateContract: inputs.validateContract,
187
- hashContract: (contractJson: unknown) => {
188
- const precomputed = hashByContractJson.get(contractJson);
189
- if (precomputed === undefined) {
190
- throw new Error(
191
- 'CLI aggregate loader: encountered an extension contract without a pre-computed descriptor hash. This is a wiring bug.',
192
- );
193
- }
194
- return precomputed;
195
- },
196
169
  appMigrationPackages: inputs.appMigrationPackages ?? [],
197
170
  };
198
171