@prisma-next/cli 0.6.0-dev.1 → 0.6.0-dev.10

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 (65) hide show
  1. package/dist/cli.mjs +5 -5
  2. package/dist/{client-qVH-rEgd.mjs → client-BCnP7cHo.mjs} +9 -119
  3. package/dist/client-BCnP7cHo.mjs.map +1 -0
  4. package/dist/commands/contract-infer.mjs +1 -1
  5. package/dist/commands/db-init.mjs +3 -3
  6. package/dist/commands/db-schema.mjs +1 -1
  7. package/dist/commands/db-sign.mjs +1 -1
  8. package/dist/commands/db-update.mjs +3 -3
  9. package/dist/commands/db-verify.mjs +1 -1
  10. package/dist/commands/migration-apply.d.mts +1 -1
  11. package/dist/commands/migration-apply.mjs +2 -2
  12. package/dist/commands/migration-plan.d.mts.map +1 -1
  13. package/dist/commands/migration-plan.mjs +1 -1
  14. package/dist/commands/migration-show.d.mts +55 -7
  15. package/dist/commands/migration-show.d.mts.map +1 -1
  16. package/dist/commands/migration-show.mjs +153 -46
  17. package/dist/commands/migration-show.mjs.map +1 -1
  18. package/dist/commands/migration-status.d.mts.map +1 -1
  19. package/dist/commands/migration-status.mjs +1 -1
  20. package/dist/{contract-infer-BK9YFGEG.mjs → contract-infer-ByxhPjpW.mjs} +2 -2
  21. package/dist/{contract-infer-BK9YFGEG.mjs.map → contract-infer-ByxhPjpW.mjs.map} +1 -1
  22. package/dist/contract-space-aggregate-loader-BrwKK6Q6.mjs +160 -0
  23. package/dist/contract-space-aggregate-loader-BrwKK6Q6.mjs.map +1 -0
  24. package/dist/{db-verify-C0y1PCO2.mjs → db-verify-Czm5T-J4.mjs} +2 -2
  25. package/dist/{db-verify-C0y1PCO2.mjs.map → db-verify-Czm5T-J4.mjs.map} +1 -1
  26. package/dist/exports/control-api.d.mts +1 -1
  27. package/dist/exports/control-api.mjs +1 -1
  28. package/dist/{init-DETSgw3h.mjs → init-BRKnARU6.mjs} +125 -25
  29. package/dist/init-BRKnARU6.mjs.map +1 -0
  30. package/dist/{inspect-live-schema-CWYxGKlb.mjs → inspect-live-schema-DxdBd4Er.mjs} +2 -2
  31. package/dist/{inspect-live-schema-CWYxGKlb.mjs.map → inspect-live-schema-DxdBd4Er.mjs.map} +1 -1
  32. package/dist/{migration-command-scaffold-B5dORFEv.mjs → migration-command-scaffold-BdV8JYXV.mjs} +2 -2
  33. package/dist/{migration-command-scaffold-B5dORFEv.mjs.map → migration-command-scaffold-BdV8JYXV.mjs.map} +1 -1
  34. package/dist/{migration-plan-C6lVaHsO.mjs → migration-plan-mRu5K81L.mjs} +89 -149
  35. package/dist/migration-plan-mRu5K81L.mjs.map +1 -0
  36. package/dist/{migration-status-CZ-D5k7k.mjs → migration-status-By9G5p2H.mjs} +6 -8
  37. package/dist/{migration-status-CZ-D5k7k.mjs.map → migration-status-By9G5p2H.mjs.map} +1 -1
  38. package/dist/{migrations-D_UJnpuW.mjs → migrations-CTsyBXCA.mjs} +42 -29
  39. package/dist/migrations-CTsyBXCA.mjs.map +1 -0
  40. package/dist/{types-D7x-IFLO.d.mts → types-LItU7E4l.d.mts} +7 -9
  41. package/dist/{types-D7x-IFLO.d.mts.map → types-LItU7E4l.d.mts.map} +1 -1
  42. package/package.json +14 -14
  43. package/src/commands/init/detect-package-manager.ts +28 -4
  44. package/src/commands/init/errors.ts +0 -16
  45. package/src/commands/init/exit-codes.ts +1 -1
  46. package/src/commands/init/hygiene-package-scripts.ts +77 -0
  47. package/src/commands/init/init.ts +81 -13
  48. package/src/commands/migration-plan.ts +45 -47
  49. package/src/commands/migration-show.ts +245 -60
  50. package/src/commands/migration-status.ts +17 -9
  51. package/src/control-api/operations/db-apply-aggregate.ts +12 -10
  52. package/src/control-api/operations/migration-apply.ts +7 -1
  53. package/src/control-api/types.ts +6 -8
  54. package/src/utils/contract-space-aggregate-loader.ts +7 -34
  55. package/src/utils/contract-space-seed-phase.ts +201 -0
  56. package/src/utils/extension-pack-inputs.ts +47 -55
  57. package/src/utils/formatters/migrations.ts +80 -38
  58. package/dist/client-qVH-rEgd.mjs.map +0 -1
  59. package/dist/extension-pack-inputs-C7xgE-vv.mjs +0 -74
  60. package/dist/extension-pack-inputs-C7xgE-vv.mjs.map +0 -1
  61. package/dist/init-DETSgw3h.mjs.map +0 -1
  62. package/dist/migration-plan-C6lVaHsO.mjs.map +0 -1
  63. package/dist/migrations-D_UJnpuW.mjs.map +0 -1
  64. package/src/utils/contract-space-extension-migrations-pass.ts +0 -120
  65. package/src/utils/contract-space-migrate-pass.ts +0 -156
@@ -2,7 +2,6 @@ import { readFile } from 'node:fs/promises';
2
2
  import type { Contract } from '@prisma-next/contract/types';
3
3
  import { getEmittedArtifactPaths } from '@prisma-next/emitter';
4
4
  import {
5
- APP_SPACE_ID,
6
5
  createControlStack,
7
6
  hasOperationPreview,
8
7
  type MigrationPlanOperation,
@@ -43,16 +42,9 @@ import {
43
42
  setCommandDescriptions,
44
43
  setCommandExamples,
45
44
  } from '../utils/command-helpers';
46
- import { runContractSpaceExtensionMigrationsPass } from '../utils/contract-space-extension-migrations-pass';
47
- import {
48
- formatContractSpaceDriftWarning,
49
- runContractSpaceMigratePass,
50
- } from '../utils/contract-space-migrate-pass';
51
- import {
52
- toExtensionInputs,
53
- toExtensionMigrationsInputs,
54
- toMigratePassInputs,
55
- } from '../utils/extension-pack-inputs';
45
+ import { buildContractSpaceAggregate } from '../utils/contract-space-aggregate-loader';
46
+ import { runContractSpaceSeedPhase } from '../utils/contract-space-seed-phase';
47
+ import { toExtensionInputs } from '../utils/extension-pack-inputs';
56
48
  import { formatStyledHeader } from '../utils/formatters/styled';
57
49
  import { assertFrameworkComponentsCompatible } from '../utils/framework-components';
58
50
  import type { CommonCommandOptions } from '../utils/global-flags';
@@ -241,41 +233,30 @@ async function executeMigrationPlanCommand(
241
233
  );
242
234
  }
243
235
 
244
- // Per-space migrate pass: drift detection + on-disk artefact emission for
245
- // every loaded extension that exposes a `contractSpace`. Runs *before*
246
- // the app-space no-op check so that an extension bump alone (with no
247
- // structural app-space change) still re-pins extension artefacts on
248
- // disk. Drift warnings are non-fatal the on-disk artefacts are refreshed
249
- // and the user is notified that the bump is being captured.
250
- // Single descriptor-import boundary: every consumer of `extensionPacks`
251
- // goes through `toExtensionInputs` + a per-consumer adapter. AC11.
236
+ // Phase 1 — seed: unconditionally re-emit per-space pinned artefacts
237
+ // (contract.json / contract.d.ts / refs/head.json) and materialise any
238
+ // descriptor-shipped migration packages not yet on disk. Runs before
239
+ // the no-op check so that an extension bump alone (with no structural
240
+ // app-space change) still re-pins extension artefacts on disk.
252
241
  const canonicalExtensionInputs = toExtensionInputs(config.extensionPacks ?? []);
253
- const migratePass = await runContractSpaceMigratePass({
242
+ const seedResult = await runContractSpaceSeedPhase({
254
243
  migrationsDir,
255
- extensionPacks: toMigratePassInputs(canonicalExtensionInputs),
244
+ extensionPacks: canonicalExtensionInputs,
256
245
  });
257
246
  if (!flags.json && !flags.quiet) {
258
- for (const drift of migratePass.drifts) {
259
- if (drift.kind === 'drift') {
260
- ui.stderr(formatContractSpaceDriftWarning(drift));
247
+ for (const record of seedResult.seeded) {
248
+ if (record.action === 'updated') {
249
+ const pkgSuffix =
250
+ record.newMigrationDirs.length > 0
251
+ ? `; ${record.newMigrationDirs.length} new migration package(s) materialised`
252
+ : '';
253
+ ui.step(`Updated ${record.spaceId} to ${record.newHash}${pkgSuffix}`);
261
254
  }
262
255
  }
263
256
  }
264
-
265
- // Materialise descriptor-shipped migration packages onto disk under
266
- // `migrations/<spaceId>/<dirName>/` for any package not yet present.
267
- // Idempotent (existing dirs are left untouched).
268
- // Uses `planAllSpaces` for deterministic ordering + duplicate-spaceId
269
- // detection.
270
- const extensionMigrationsResult = await runContractSpaceExtensionMigrationsPass({
271
- migrationsDir,
272
- extensionPacks: toExtensionMigrationsInputs(canonicalExtensionInputs),
273
- });
274
- if (!flags.json && !flags.quiet) {
275
- for (const entry of extensionMigrationsResult.emitted) {
276
- ui.step(`Emitted ${entry.spaceId}/${entry.dirName}`);
277
- }
278
- }
257
+ const emittedExtensionDirs = seedResult.seeded.flatMap((r) =>
258
+ r.newMigrationDirs.map((dirName) => ({ spaceId: r.spaceId, dirName })),
259
+ );
279
260
 
280
261
  // Check for no-op (same hash means no changes)
281
262
  if (fromHash === toStorageHash) {
@@ -285,7 +266,7 @@ async function executeMigrationPlanCommand(
285
266
  from: fromHash,
286
267
  to: toStorageHash,
287
268
  operations: [],
288
- emittedExtensionDirs: extensionMigrationsResult.emitted,
269
+ emittedExtensionDirs,
289
270
  summary: 'No changes detected between contracts',
290
271
  timings: { total: Date.now() - startTime },
291
272
  };
@@ -301,6 +282,25 @@ async function executeMigrationPlanCommand(
301
282
  }),
302
283
  );
303
284
  }
285
+
286
+ // Phase 2 — load: build the aggregate against the now-consistent disk
287
+ // state that phase 1 just seeded. The seed phase guarantees every
288
+ // declared extension has its head ref pinned, so the loader's
289
+ // declaredButUnmigrated precheck always passes here.
290
+ const stack = createControlStack(config);
291
+ const familyInstance = config.family.create(stack);
292
+ const aggregateResult = await buildContractSpaceAggregate({
293
+ targetId: config.target.targetId,
294
+ migrationsDir,
295
+ appContract: toContractJson,
296
+ extensionPacks: config.extensionPacks ?? [],
297
+ validateContract: (json: unknown) => familyInstance.validateContract(json),
298
+ });
299
+ if (!aggregateResult.ok) {
300
+ return notOk(aggregateResult.failure);
301
+ }
302
+ const aggregate = aggregateResult.value;
303
+
304
304
  const frameworkComponents = assertFrameworkComponentsCompatible(
305
305
  config.family.familyId,
306
306
  config.target.targetId,
@@ -328,17 +328,15 @@ async function executeMigrationPlanCommand(
328
328
  };
329
329
 
330
330
  try {
331
- const stack = createControlStack(config);
332
- const familyInstance = config.family.create(stack);
333
331
  const planner = migrations.createPlanner(familyInstance);
334
332
  const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
335
333
  const plannerResult = planner.plan({
336
- contract: toContractJson,
334
+ contract: aggregate.app.contract,
337
335
  schema: fromSchema,
338
336
  policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
339
337
  fromContract,
340
338
  frameworkComponents,
341
- spaceId: APP_SPACE_ID,
339
+ spaceId: aggregate.app.spaceId,
342
340
  });
343
341
  if (plannerResult.kind === 'failure') {
344
342
  return notOk(
@@ -421,7 +419,7 @@ async function executeMigrationPlanCommand(
421
419
  to: toStorageHash,
422
420
  dir: relative(process.cwd(), packageDir),
423
421
  operations: [],
424
- emittedExtensionDirs: extensionMigrationsResult.emitted,
422
+ emittedExtensionDirs,
425
423
  pendingPlaceholders: true,
426
424
  summary:
427
425
  'Planned migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit',
@@ -444,9 +442,9 @@ async function executeMigrationPlanCommand(
444
442
  label: op.label,
445
443
  operationClass: op.operationClass,
446
444
  })),
447
- emittedExtensionDirs: extensionMigrationsResult.emitted,
445
+ emittedExtensionDirs,
448
446
  ...(preview !== undefined ? { preview } : {}),
449
- summary: buildPlanSummary(plannedOps.length, extensionMigrationsResult.emitted.length),
447
+ summary: buildPlanSummary(plannedOps.length, emittedExtensionDirs.length),
450
448
  timings: { total: Date.now() - startTime },
451
449
  };
452
450
  return ok(result);
@@ -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?.({