@prisma-next/cli 0.5.0-dev.73 → 0.5.0-dev.75

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 (105) hide show
  1. package/dist/cli.mjs +8 -8
  2. package/dist/{client-0ZX24FXF.mjs → client-qVH-rEgd.mjs} +433 -236
  3. package/dist/client-qVH-rEgd.mjs.map +1 -0
  4. package/dist/{result-handler-DWb1rFS-.mjs → command-helpers-BeZHkxV8.mjs} +22 -24
  5. package/dist/command-helpers-BeZHkxV8.mjs.map +1 -0
  6. package/dist/commands/contract-emit.mjs +1 -1
  7. package/dist/commands/contract-infer.mjs +1 -1
  8. package/dist/commands/db-init.d.mts.map +1 -1
  9. package/dist/commands/db-init.mjs +7 -5
  10. package/dist/commands/db-init.mjs.map +1 -1
  11. package/dist/commands/db-schema.mjs +5 -4
  12. package/dist/commands/db-schema.mjs.map +1 -1
  13. package/dist/commands/db-sign.mjs +6 -5
  14. package/dist/commands/db-sign.mjs.map +1 -1
  15. package/dist/commands/db-update.d.mts.map +1 -1
  16. package/dist/commands/db-update.mjs +7 -5
  17. package/dist/commands/db-update.mjs.map +1 -1
  18. package/dist/commands/db-verify.mjs +1 -1
  19. package/dist/commands/migration-apply.d.mts +29 -17
  20. package/dist/commands/migration-apply.d.mts.map +1 -1
  21. package/dist/commands/migration-apply.mjs +35 -129
  22. package/dist/commands/migration-apply.mjs.map +1 -1
  23. package/dist/commands/migration-new.mjs +4 -3
  24. package/dist/commands/migration-new.mjs.map +1 -1
  25. package/dist/commands/migration-plan.d.mts +19 -1
  26. package/dist/commands/migration-plan.d.mts.map +1 -1
  27. package/dist/commands/migration-plan.mjs +2 -2
  28. package/dist/commands/migration-ref.d.mts +1 -1
  29. package/dist/commands/migration-ref.mjs +3 -2
  30. package/dist/commands/migration-ref.mjs.map +1 -1
  31. package/dist/commands/migration-show.d.mts +1 -1
  32. package/dist/commands/migration-show.mjs +5 -4
  33. package/dist/commands/migration-show.mjs.map +1 -1
  34. package/dist/commands/migration-status.d.mts +104 -1
  35. package/dist/commands/migration-status.d.mts.map +1 -1
  36. package/dist/commands/migration-status.mjs +2 -2
  37. package/dist/{contract-emit-DkMqO7f2.mjs → contract-emit-9DBda5Ou.mjs} +7 -5
  38. package/dist/{contract-emit-DkMqO7f2.mjs.map → contract-emit-9DBda5Ou.mjs.map} +1 -1
  39. package/dist/{contract-emit-B3ChISB_.mjs → contract-emit-B77TsJqf.mjs} +4 -15
  40. package/dist/{contract-emit-B3ChISB_.mjs.map → contract-emit-B77TsJqf.mjs.map} +1 -1
  41. package/dist/{contract-enrichment-CF6ogEJ_.mjs → contract-enrichment-Dani0mMW.mjs} +1 -1
  42. package/dist/{contract-enrichment-CF6ogEJ_.mjs.map → contract-enrichment-Dani0mMW.mjs.map} +1 -1
  43. package/dist/{contract-infer-BDKAE0B0.mjs → contract-infer-BK9YFGEG.mjs} +5 -4
  44. package/dist/{contract-infer-BDKAE0B0.mjs.map → contract-infer-BK9YFGEG.mjs.map} +1 -1
  45. package/dist/{db-verify-B4TdDKOI.mjs → db-verify-C0y1PCO2.mjs} +7 -6
  46. package/dist/{db-verify-B4TdDKOI.mjs.map → db-verify-C0y1PCO2.mjs.map} +1 -1
  47. package/dist/exports/control-api.d.mts +3 -746
  48. package/dist/exports/control-api.d.mts.map +1 -1
  49. package/dist/exports/control-api.mjs +3 -3
  50. package/dist/exports/index.mjs +1 -1
  51. package/dist/exports/init-output.mjs +1 -1
  52. package/dist/extension-pack-inputs-C7xgE-vv.mjs +74 -0
  53. package/dist/extension-pack-inputs-C7xgE-vv.mjs.map +1 -0
  54. package/dist/{framework-components-gwAHl7ml.mjs → framework-components-ChqVUxR-.mjs} +1 -1
  55. package/dist/{framework-components-gwAHl7ml.mjs.map → framework-components-ChqVUxR-.mjs.map} +1 -1
  56. package/dist/global-flags-Icqpxk23.d.mts +12 -0
  57. package/dist/global-flags-Icqpxk23.d.mts.map +1 -0
  58. package/dist/helpers-eqdN8tH6.mjs +25 -0
  59. package/dist/helpers-eqdN8tH6.mjs.map +1 -0
  60. package/dist/{init-Deo7U8_U.mjs → init-CoDVPvQ4.mjs} +4 -4
  61. package/dist/{init-Deo7U8_U.mjs.map → init-CoDVPvQ4.mjs.map} +1 -1
  62. package/dist/{inspect-live-schema-BAgQMYpD.mjs → inspect-live-schema-CWYxGKlb.mjs} +4 -4
  63. package/dist/{inspect-live-schema-BAgQMYpD.mjs.map → inspect-live-schema-CWYxGKlb.mjs.map} +1 -1
  64. package/dist/{migration-command-scaffold-B8J702Uh.mjs → migration-command-scaffold-B5dORFEv.mjs} +4 -4
  65. package/dist/{migration-command-scaffold-B8J702Uh.mjs.map → migration-command-scaffold-B5dORFEv.mjs.map} +1 -1
  66. package/dist/{migration-plan-BcKNnTM7.mjs → migration-plan-C6lVaHsO.mjs} +47 -23
  67. package/dist/migration-plan-C6lVaHsO.mjs.map +1 -0
  68. package/dist/{migration-status-CjwB2of-.mjs → migration-status-CZ-D5k7k.mjs} +161 -7
  69. package/dist/migration-status-CZ-D5k7k.mjs.map +1 -0
  70. package/dist/{migrations-CIK94AJf.mjs → migrations-D_UJnpuW.mjs} +67 -24
  71. package/dist/migrations-D_UJnpuW.mjs.map +1 -0
  72. package/dist/{output-DnjfCC_u.mjs → output-B16Kefzx.mjs} +1 -1
  73. package/dist/{output-DnjfCC_u.mjs.map → output-B16Kefzx.mjs.map} +1 -1
  74. package/dist/{progress-adapter-xASh41wr.mjs → progress-adapter-DFfvZcYL.mjs} +1 -1
  75. package/dist/{progress-adapter-xASh41wr.mjs.map → progress-adapter-DFfvZcYL.mjs.map} +1 -1
  76. package/dist/result-handler-rmPVKIP2.mjs +25 -0
  77. package/dist/result-handler-rmPVKIP2.mjs.map +1 -0
  78. package/dist/rolldown-runtime-twds-ZHy.mjs +14 -0
  79. package/dist/{terminal-ui-zaRDhJnP.mjs → terminal-ui-C_hFNbAn.mjs} +3 -23
  80. package/dist/terminal-ui-C_hFNbAn.mjs.map +1 -0
  81. package/dist/types-D7x-IFLO.d.mts +858 -0
  82. package/dist/types-D7x-IFLO.d.mts.map +1 -0
  83. package/dist/{verify-BEIa9638.mjs → verify-CiwNWM9N.mjs} +2 -2
  84. package/dist/{verify-BEIa9638.mjs.map → verify-CiwNWM9N.mjs.map} +1 -1
  85. package/package.json +14 -14
  86. package/src/commands/db-init.ts +1 -0
  87. package/src/commands/db-update.ts +1 -0
  88. package/src/commands/migration-apply.ts +94 -213
  89. package/src/commands/migration-plan.ts +89 -32
  90. package/src/commands/migration-status.ts +288 -5
  91. package/src/control-api/client.ts +16 -4
  92. package/src/control-api/operations/apply-aggregate.ts +290 -0
  93. package/src/control-api/operations/db-apply-aggregate.ts +42 -91
  94. package/src/control-api/operations/migration-apply.ts +420 -155
  95. package/src/control-api/types.ts +165 -32
  96. package/src/utils/contract-space-aggregate-loader.ts +24 -56
  97. package/src/utils/extension-pack-inputs.ts +170 -0
  98. package/src/utils/formatters/migrations.ts +135 -35
  99. package/dist/client-0ZX24FXF.mjs.map +0 -1
  100. package/dist/migration-plan-BcKNnTM7.mjs.map +0 -1
  101. package/dist/migration-status-CjwB2of-.mjs.map +0 -1
  102. package/dist/migrations-CIK94AJf.mjs.map +0 -1
  103. package/dist/result-handler-DWb1rFS-.mjs.map +0 -1
  104. package/dist/terminal-ui-zaRDhJnP.mjs.map +0 -1
  105. /package/dist/{cli-errors-QH8kf-C2.d.mts → cli-errors-B9OBbled.d.mts} +0 -0
@@ -1,11 +1,6 @@
1
- import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
2
- import {
3
- errorNoInvariantPath,
4
- errorUnknownInvariant,
5
- MigrationToolsError,
6
- } from '@prisma-next/migration-tools/errors';
7
- import { findPathWithDecision } from '@prisma-next/migration-tools/migration-graph';
8
- import type { OnDiskMigrationPackage } from '@prisma-next/migration-tools/package';
1
+ import { readFile } from 'node:fs/promises';
2
+ import type { Contract } from '@prisma-next/contract/types';
3
+ import { errorUnknownInvariant, MigrationToolsError } from '@prisma-next/migration-tools/errors';
9
4
  import type { RefEntry } from '@prisma-next/migration-tools/refs';
10
5
  import { readRefs, resolveRef } from '@prisma-next/migration-tools/refs';
11
6
  import { ifDefined } from '@prisma-next/utils/defined';
@@ -14,12 +9,18 @@ import { Command } from 'commander';
14
9
 
15
10
  import { loadConfig } from '../config-loader';
16
11
  import { createControlClient } from '../control-api/client';
17
- import type { MigrationApplyFailure, MigrationApplyStep } from '../control-api/types';
12
+ import type {
13
+ AggregatePerSpaceExecutionEntry,
14
+ MigrationApplyFailure,
15
+ MigrationApplyPathDecision,
16
+ } from '../control-api/types';
18
17
  import {
19
18
  CliStructuredError,
20
19
  type CliStructuredError as CliStructuredErrorType,
20
+ errorContractValidationFailed,
21
21
  errorDatabaseConnectionRequired,
22
22
  errorDriverRequired,
23
+ errorFileNotFound,
23
24
  errorRuntime,
24
25
  errorTargetMigrationNotSupported,
25
26
  errorUnexpected,
@@ -30,13 +31,11 @@ import {
30
31
  collectDeclaredInvariants,
31
32
  loadMigrationPackages,
32
33
  maskConnectionUrl,
33
- readContractEnvelope,
34
+ resolveContractPath,
34
35
  resolveMigrationPaths,
35
36
  setCommandDescriptions,
36
37
  setCommandExamples,
37
38
  targetSupportsMigrations,
38
- toPathDecisionResult,
39
- toStructuralEdge,
40
39
  } from '../utils/command-helpers';
41
40
  import { formatMigrationApplyCommandOutput } from '../utils/formatters/migrations';
42
41
  import { formatStyledHeader } from '../utils/formatters/styled';
@@ -51,34 +50,45 @@ interface MigrationApplyCommandOptions extends CommonCommandOptions {
51
50
  readonly ref?: string;
52
51
  }
53
52
 
53
+ /**
54
+ * Per-space breakdown of an apply run. The CLI command surfaces these
55
+ * for both the JSON shape (`appliedSpaces[]`) and the human-readable
56
+ * formatter (per-space block — same shape `db init` / `db update`
57
+ * use, M6 sub-spec § Output shape contract).
58
+ */
54
59
  export interface MigrationApplyResult {
55
60
  readonly ok: boolean;
61
+ /** Number of contract spaces that had non-zero pending operations applied. */
56
62
  readonly migrationsApplied: number;
63
+ /** Total contract spaces visible in the aggregate (pending + already-up-to-date). */
57
64
  readonly migrationsTotal: number;
65
+ /**
66
+ * Marker hash for the **app member** post-apply. Surfaced for
67
+ * back-compat with single-space callers; per-space markers live on
68
+ * `perSpace[].marker.storageHash`.
69
+ */
58
70
  readonly markerHash: string;
59
71
  readonly applied: readonly {
72
+ readonly spaceId: string;
60
73
  readonly dirName: string;
61
- readonly from: string | null;
74
+ readonly migrationHash: string;
75
+ readonly from: string;
62
76
  readonly to: string;
63
77
  readonly operationsExecuted: number;
64
78
  }[];
65
79
  readonly summary: string;
66
- readonly pathDecision?: {
67
- readonly fromHash: string;
68
- readonly toHash: string;
69
- readonly alternativeCount: number;
70
- readonly tieBreakReasons: readonly string[];
71
- readonly refName?: string;
72
- readonly requiredInvariants: readonly string[];
73
- readonly satisfiedInvariants: readonly string[];
74
- readonly selectedPath: readonly {
75
- readonly dirName: string;
76
- readonly migrationHash: string;
77
- readonly from: string;
78
- readonly to: string;
79
- readonly invariants: readonly string[];
80
- }[];
81
- };
80
+ /**
81
+ * Per-space breakdown in canonical schedule order (extensions
82
+ * alphabetically, then app). Always present for the aggregate-walking
83
+ * apply path.
84
+ */
85
+ readonly perSpace: readonly AggregatePerSpaceExecutionEntry[];
86
+ /**
87
+ * Path-decision data for the app member. Surfaced for back-compat
88
+ * with single-space callers (cli-journeys invariant tests).
89
+ * Absent for no-op applies where the app had nothing to do.
90
+ */
91
+ readonly pathDecision?: MigrationApplyPathDecision;
82
92
  readonly timings: {
83
93
  readonly total: number;
84
94
  };
@@ -92,17 +102,6 @@ function mapApplyFailure(failure: MigrationApplyFailure): CliStructuredErrorType
92
102
  });
93
103
  }
94
104
 
95
- function packageToStep(pkg: OnDiskMigrationPackage): MigrationApplyStep {
96
- return {
97
- dirName: pkg.dirName,
98
- from: pkg.metadata.from,
99
- to: pkg.metadata.to,
100
- toContract: pkg.metadata.toContract,
101
- operations: pkg.ops,
102
- providedInvariants: pkg.metadata.providedInvariants,
103
- };
104
- }
105
-
106
105
  async function executeMigrationApplyCommand(
107
106
  options: MigrationApplyCommandOptions,
108
107
  flags: GlobalFlags,
@@ -110,10 +109,8 @@ async function executeMigrationApplyCommand(
110
109
  startTime: number,
111
110
  ): Promise<Result<MigrationApplyResult, CliStructuredErrorType>> {
112
111
  const config = await loadConfig(options.config);
113
- const { configPath, appMigrationsDir, appMigrationsRelative, refsDir } = resolveMigrationPaths(
114
- options.config,
115
- config,
116
- );
112
+ const { configPath, migrationsDir, appMigrationsDir, appMigrationsRelative, refsDir } =
113
+ resolveMigrationPaths(options.config, config);
117
114
 
118
115
  const dbConnection = options.db ?? config.db?.connection;
119
116
  if (!dbConnection) {
@@ -142,7 +139,6 @@ async function executeMigrationApplyCommand(
142
139
  }
143
140
 
144
141
  let refEntry: RefEntry | undefined;
145
- let envelopeHash: string | undefined;
146
142
  const refName = options.ref;
147
143
 
148
144
  if (refName) {
@@ -155,20 +151,31 @@ async function executeMigrationApplyCommand(
155
151
  }
156
152
  throw error;
157
153
  }
158
- } else {
159
- try {
160
- const envelope = await readContractEnvelope(config);
161
- envelopeHash = envelope.storageHash;
162
- } catch (error) {
154
+ }
155
+
156
+ // Resolve and parse the contract envelope. The aggregate-walking
157
+ // operation needs the validated app contract to load the aggregate.
158
+ const contractPathAbsolute = resolveContractPath(config);
159
+ let contractRaw: Contract;
160
+ try {
161
+ const contractContent = await readFile(contractPathAbsolute, 'utf-8');
162
+ contractRaw = JSON.parse(contractContent) as Contract;
163
+ } catch (error) {
164
+ if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
163
165
  return notOk(
164
- errorRuntime('Current contract is unavailable', {
165
- why: `Failed to read contract: ${error instanceof Error ? error.message : String(error)}`,
166
+ errorFileNotFound(contractPathAbsolute, {
167
+ why: `Contract file not found at ${contractPathAbsolute}`,
166
168
  fix: 'Run `prisma-next contract emit` to generate a valid contract.json, then retry apply.',
167
169
  }),
168
170
  );
169
171
  }
172
+ return notOk(
173
+ errorContractValidationFailed(
174
+ `Contract JSON is invalid: ${error instanceof Error ? error.message : String(error)}`,
175
+ { where: { path: contractPathAbsolute } },
176
+ ),
177
+ );
170
178
  }
171
- const destinationHash = refEntry?.hash ?? envelopeHash!;
172
179
 
173
180
  if (!flags.json && !flags.quiet) {
174
181
  const details: Array<{ label: string; value: string }> = [
@@ -194,10 +201,11 @@ async function executeMigrationApplyCommand(
194
201
  ui.stderr(header);
195
202
  }
196
203
 
197
- // Read migrations and build migration chain model (offline no DB needed)
198
- let migrations: Awaited<ReturnType<typeof loadMigrationPackages>>;
204
+ // Load app-space migration packagesthe aggregate operation
205
+ // needs them to hydrate the app member's graph for graph-walk.
206
+ let appPackages: Awaited<ReturnType<typeof loadMigrationPackages>>;
199
207
  try {
200
- migrations = await loadMigrationPackages(appMigrationsDir);
208
+ appPackages = await loadMigrationPackages(appMigrationsDir);
201
209
  } catch (error) {
202
210
  if (MigrationToolsError.is(error)) {
203
211
  return notOk(mapMigrationToolsError(error));
@@ -215,20 +223,20 @@ async function executeMigrationApplyCommand(
215
223
 
216
224
  try {
217
225
  await client.connect(dbConnection);
218
- const marker = await client.readMarker();
219
226
 
220
- // Pre-check unknown invariants against `(declared by graph) ∪
221
- // (already on the marker)`. The union catches the edge case where the
222
- // ref carries an invariant whose declaring migration was retired (e.g.
223
- // history rewritten) but whose id is recorded on the marker —
224
- // surfacing that as MIGRATION.UNKNOWN_INVARIANT would be misleading
225
- // because the database has already satisfied the requirement, so the
226
- // marker-subtraction below empties `effectiveRequired` and apply
227
- // short-circuits to "Already up to date".
227
+ // Pre-check unknown invariants against `(declared by app graph) ∪
228
+ // (already on the app marker)`. The marker side of the union
229
+ // catches the case where the ref carries an invariant whose
230
+ // declaring migration was retired (history rewritten) but whose
231
+ // id is recorded on the marker — surfacing UNKNOWN_INVARIANT
232
+ // there would be misleading because the database has already
233
+ // satisfied the requirement.
228
234
  if (refEntry && refEntry.invariants.length > 0) {
229
- const declared = collectDeclaredInvariants(migrations.graph);
235
+ const allMarkers = await client.readAllMarkers();
236
+ const appMarker = allMarkers.get('app') ?? null;
237
+ const declared = collectDeclaredInvariants(appPackages.graph);
230
238
  const known = new Set<string>(declared);
231
- for (const id of marker?.invariants ?? []) known.add(id);
239
+ for (const id of appMarker?.invariants ?? []) known.add(id);
232
240
  const unknown = refEntry.invariants.filter((id) => !known.has(id));
233
241
  if (unknown.length > 0) {
234
242
  return notOk(
@@ -243,149 +251,17 @@ async function executeMigrationApplyCommand(
243
251
  }
244
252
  }
245
253
 
246
- // --- No migrations on disk ---
247
- if (migrations.bundles.length === 0) {
248
- if (marker?.storageHash) {
249
- return notOk(
250
- errorRuntime('Database has state but no migrations exist', {
251
- why: `The database marker hash "${marker.storageHash}" exists but no migrations were found in ${appMigrationsRelative}`,
252
- fix: 'Ensure the migrations directory is correct. If the database was managed with `db init` or `db update`, run `prisma-next db sign` to update the marker.',
253
- meta: { markerHash: marker.storageHash, migrationsDir: appMigrationsRelative },
254
- }),
255
- );
256
- }
257
- // Non-empty contract + no migrations = user needs to plan first.
258
- if (destinationHash !== EMPTY_CONTRACT_HASH) {
259
- return notOk(
260
- errorRuntime('Current contract has no planned migrations', {
261
- why: `No migrations were found in ${appMigrationsRelative}, but current contract hash is "${destinationHash}"`,
262
- fix: 'Run `prisma-next migration plan` to create a migration for the current contract.',
263
- meta: { destinationHash, migrationsDir: appMigrationsRelative },
264
- }),
265
- );
266
- }
267
- // Empty contract + no migrations = nothing to do.
268
- return ok({
269
- ok: true,
270
- migrationsApplied: 0,
271
- migrationsTotal: 0,
272
- markerHash: EMPTY_CONTRACT_HASH,
273
- applied: [],
274
- summary: 'No migrations found',
275
- timings: { total: Date.now() - startTime },
276
- });
277
- }
278
-
279
- // --- Validate marker state ---
280
-
281
- // The empty sentinel should never appear in a real marker row — if it does,
282
- // the marker was corrupted and replaying all migrations would be dangerous.
283
- if (marker?.storageHash === EMPTY_CONTRACT_HASH) {
284
- return notOk(
285
- errorRuntime('Database marker contains the empty sentinel hash', {
286
- why: `The marker row exists but contains the empty sentinel value "${EMPTY_CONTRACT_HASH}". This should never happen — the marker should contain the hash of the last applied contract.`,
287
- fix: 'The marker is corrupted. Run `prisma-next db sign` to overwrite it with the correct contract hash, or drop and recreate the database.',
288
- meta: { markerHash: EMPTY_CONTRACT_HASH },
289
- }),
290
- );
291
- }
292
-
293
- const markerHash = marker?.storageHash;
294
-
295
- if (markerHash !== undefined && !migrations.graph.nodes.has(markerHash)) {
296
- return notOk(
297
- errorRuntime('Database marker does not match any known migration', {
298
- why: `The database marker hash "${markerHash}" is not found in the migration history at ${appMigrationsRelative}`,
299
- fix: 'Ensure the migrations directory matches this database. If the database was managed with `db init` or `db update`, run `prisma-next db sign` to update the marker.',
300
- meta: { markerHash, knownNodes: [...migrations.graph.nodes] },
301
- }),
302
- );
303
- }
304
-
305
- if (!migrations.graph.nodes.has(destinationHash)) {
306
- return notOk(
307
- errorRuntime('Current contract has no planned migration path', {
308
- why: `Current contract hash "${destinationHash}" is not present in the migration history at ${appMigrationsRelative}`,
309
- fix: 'Run `prisma-next migration plan` to create a migration for the current contract, then re-run apply.',
310
- meta: { destinationHash, knownNodes: [...migrations.graph.nodes] },
311
- }),
312
- );
313
- }
314
-
315
- // "No marker" means the database is fresh — start from the empty contract hash.
316
- const originHash = markerHash ?? EMPTY_CONTRACT_HASH;
317
-
318
- const appliedInvariants = new Set(marker?.invariants ?? []);
319
- const effectiveRequired = new Set(
320
- (refEntry?.invariants ?? []).filter((id) => !appliedInvariants.has(id)),
321
- );
322
-
323
- const outcome = findPathWithDecision(migrations.graph, originHash, destinationHash, {
324
- ...ifDefined('refName', refName),
325
- required: effectiveRequired,
326
- });
327
- if (outcome.kind === 'unsatisfiable') {
328
- return notOk(
329
- mapMigrationToolsError(
330
- errorNoInvariantPath({
331
- ...ifDefined('refName', refName),
332
- required: [...effectiveRequired].sort(),
333
- missing: outcome.missing,
334
- structuralPath: outcome.structuralPath.map(toStructuralEdge),
335
- }),
336
- ),
337
- );
338
- }
339
- if (outcome.kind === 'unreachable') {
340
- return notOk(
341
- errorRuntime('No migration path from current state to target', {
342
- why: `Cannot find a path from "${originHash}" to target "${destinationHash}"`,
343
- fix: 'Check the migration history for gaps or inconsistencies.',
344
- meta: { markerHash: originHash, destinationHash },
345
- }),
346
- );
347
- }
348
-
349
- const pathDecision = toPathDecisionResult(outcome.decision);
350
-
351
- if (outcome.decision.selectedPath.length === 0) {
352
- return ok({
353
- ok: true,
354
- migrationsApplied: 0,
355
- migrationsTotal: 0,
356
- markerHash: originHash,
357
- applied: [],
358
- summary: 'Already up to date',
359
- pathDecision,
360
- timings: { total: Date.now() - startTime },
361
- });
362
- }
363
-
364
- const bundleByDir = new Map(migrations.bundles.map((b) => [b.dirName, b]));
365
- const pendingMigrations: MigrationApplyStep[] = [];
366
- for (const migration of outcome.decision.selectedPath) {
367
- const pkg = bundleByDir.get(migration.dirName);
368
- if (!pkg) {
369
- return notOk(
370
- errorRuntime(`Migration package not found: ${migration.dirName}`, {
371
- why: `The migration directory for path segment ${migration.from} → ${migration.to} was not found`,
372
- fix: 'Ensure all migration directories are present and intact.',
373
- }),
374
- );
375
- }
376
- pendingMigrations.push(packageToStep(pkg));
377
- }
378
-
379
254
  if (!flags.quiet && !flags.json) {
380
- for (const migration of pendingMigrations) {
381
- ui.step(`Pending ${migration.dirName}`);
382
- }
255
+ ui.step('Loading contract spaces…');
383
256
  }
384
257
 
385
258
  const applyResult = await client.migrationApply({
386
- originHash,
387
- destinationHash,
388
- pendingMigrations,
259
+ contract: contractRaw,
260
+ migrationsDir,
261
+ appMigrationPackages: appPackages.bundles,
262
+ ...ifDefined('refHash', refEntry?.hash),
263
+ ...(refEntry?.invariants ? { refInvariants: refEntry.invariants } : {}),
264
+ ...(refEntry !== undefined ? ifDefined('refName', refName) : {}),
389
265
  });
390
266
 
391
267
  if (!applyResult.ok) {
@@ -397,17 +273,21 @@ async function executeMigrationApplyCommand(
397
273
  return ok({
398
274
  ok: true,
399
275
  migrationsApplied: value.migrationsApplied,
400
- migrationsTotal: outcome.decision.selectedPath.length,
276
+ migrationsTotal: value.perSpace.length,
401
277
  markerHash: value.markerHash,
402
278
  applied: value.applied,
403
279
  summary: value.summary,
404
- pathDecision,
280
+ perSpace: value.perSpace,
281
+ ...ifDefined('pathDecision', value.pathDecision),
405
282
  timings: { total: Date.now() - startTime },
406
283
  });
407
284
  } catch (error) {
408
285
  if (CliStructuredError.is(error)) {
409
286
  return notOk(error);
410
287
  }
288
+ if (MigrationToolsError.is(error)) {
289
+ return notOk(mapMigrationToolsError(error));
290
+ }
411
291
  return notOk(
412
292
  errorUnexpected(error instanceof Error ? error.message : String(error), {
413
293
  why: `Unexpected error during migration apply: ${error instanceof Error ? error.message : String(error)}`,
@@ -423,16 +303,17 @@ export function createMigrationApplyCommand(): Command {
423
303
  setCommandDescriptions(
424
304
  command,
425
305
  'Apply planned migrations to the database',
426
- 'Applies previously planned migrations (created by `migration plan`) to a live database.\n' +
427
- 'Compares the database marker against the migration history to determine which\n' +
428
- 'migrations are pending, then executes them sequentially. Each migration runs\n' +
429
- 'in its own transaction. Does not plan new migrations run `migration plan` first.',
306
+ 'Walks every contract space (app + extensions) and applies pending\n' +
307
+ 'on-disk migrations in canonical order (extensions alphabetically,\n' +
308
+ 'then app). Graph-walks the on-disk migration graph for every space —\n' +
309
+ "no introspection, no synth. Each space's marker advances inside its\n" +
310
+ "transaction; per-space failure rolls back every space's writes.",
430
311
  );
431
312
  setCommandExamples(command, ['prisma-next migration apply --db $DATABASE_URL']);
432
313
  addGlobalOptions(command)
433
314
  .option('--db <url>', 'Database connection string')
434
315
  .option('--config <path>', 'Path to prisma-next.config.ts')
435
- .option('--ref <name>', 'Target ref name from migrations/refs/')
316
+ .option('--ref <name>', 'App-space target ref name from migrations/app/refs/')
436
317
  .action(async (options: MigrationApplyCommandOptions) => {
437
318
  const flags = parseGlobalFlags(options);
438
319
  const startTime = Date.now();
@@ -43,15 +43,16 @@ import {
43
43
  setCommandDescriptions,
44
44
  setCommandExamples,
45
45
  } from '../utils/command-helpers';
46
- import {
47
- type ExtensionMigrationsExtensionInput,
48
- runContractSpaceExtensionMigrationsPass,
49
- } from '../utils/contract-space-extension-migrations-pass';
46
+ import { runContractSpaceExtensionMigrationsPass } from '../utils/contract-space-extension-migrations-pass';
50
47
  import {
51
48
  formatContractSpaceDriftWarning,
52
- type MigrateExtensionInput,
53
49
  runContractSpaceMigratePass,
54
50
  } from '../utils/contract-space-migrate-pass';
51
+ import {
52
+ toExtensionInputs,
53
+ toExtensionMigrationsInputs,
54
+ toMigratePassInputs,
55
+ } from '../utils/extension-pack-inputs';
55
56
  import { formatStyledHeader } from '../utils/formatters/styled';
56
57
  import { assertFrameworkComponentsCompatible } from '../utils/framework-components';
57
58
  import type { CommonCommandOptions } from '../utils/global-flags';
@@ -71,6 +72,19 @@ export interface MigrationPlanResult {
71
72
  readonly from: string | null;
72
73
  readonly to: string;
73
74
  readonly dir?: string;
75
+ /**
76
+ * Extension-space migration packages materialised onto disk during this
77
+ * `plan` run. Each entry names a `migrations/<spaceId>/<dirName>/`
78
+ * tree the framework wrote alongside the app-space migration directory.
79
+ * Empty when the project has no extension packs declaring a contract
80
+ * space, or when every extension-space package is already on disk.
81
+ *
82
+ * Surfacing these in the result (rather than only via `ui.step` log
83
+ * lines) makes the cross-space side effect explicit to JSON consumers
84
+ * and the success-summary renderer — the same multi-space side effect
85
+ * that `migration apply` will replay.
86
+ */
87
+ readonly emittedExtensionDirs: readonly { readonly spaceId: string; readonly dirName: string }[];
74
88
  readonly operations: readonly {
75
89
  readonly id: string;
76
90
  readonly label: string;
@@ -233,16 +247,12 @@ async function executeMigrationPlanCommand(
233
247
  // structural app-space change) still re-pins extension artefacts on
234
248
  // disk. Drift warnings are non-fatal — the on-disk artefacts are refreshed
235
249
  // and the user is notified that the bump is being captured.
236
- const extensionInputs: readonly MigrateExtensionInput[] = (config.extensionPacks ?? []).map(
237
- (pack) => {
238
- const cs = (pack as { readonly contractSpace?: MigrateExtensionInput['contractSpace'] })
239
- .contractSpace;
240
- return cs !== undefined ? { id: pack.id, contractSpace: cs } : { id: pack.id };
241
- },
242
- );
250
+ // Single descriptor-import boundary: every consumer of `extensionPacks`
251
+ // goes through `toExtensionInputs` + a per-consumer adapter. AC11.
252
+ const canonicalExtensionInputs = toExtensionInputs(config.extensionPacks ?? []);
243
253
  const migratePass = await runContractSpaceMigratePass({
244
254
  migrationsDir,
245
- extensionPacks: extensionInputs,
255
+ extensionPacks: toMigratePassInputs(canonicalExtensionInputs),
246
256
  });
247
257
  if (!flags.json && !flags.quiet) {
248
258
  for (const drift of migratePass.drifts) {
@@ -257,19 +267,9 @@ async function executeMigrationPlanCommand(
257
267
  // Idempotent (existing dirs are left untouched).
258
268
  // Uses `planAllSpaces` for deterministic ordering + duplicate-spaceId
259
269
  // detection.
260
- const extensionMigrationsInputs: readonly ExtensionMigrationsExtensionInput[] = (
261
- config.extensionPacks ?? []
262
- ).map((pack) => {
263
- const cs = (
264
- pack as {
265
- readonly contractSpace?: ExtensionMigrationsExtensionInput['contractSpace'];
266
- }
267
- ).contractSpace;
268
- return cs !== undefined ? { id: pack.id, contractSpace: cs } : { id: pack.id };
269
- });
270
270
  const extensionMigrationsResult = await runContractSpaceExtensionMigrationsPass({
271
271
  migrationsDir,
272
- extensionPacks: extensionMigrationsInputs,
272
+ extensionPacks: toExtensionMigrationsInputs(canonicalExtensionInputs),
273
273
  });
274
274
  if (!flags.json && !flags.quiet) {
275
275
  for (const entry of extensionMigrationsResult.emitted) {
@@ -285,6 +285,7 @@ async function executeMigrationPlanCommand(
285
285
  from: fromHash,
286
286
  to: toStorageHash,
287
287
  operations: [],
288
+ emittedExtensionDirs: extensionMigrationsResult.emitted,
288
289
  summary: 'No changes detected between contracts',
289
290
  timings: { total: Date.now() - startTime },
290
291
  };
@@ -420,6 +421,7 @@ async function executeMigrationPlanCommand(
420
421
  to: toStorageHash,
421
422
  dir: relative(process.cwd(), packageDir),
422
423
  operations: [],
424
+ emittedExtensionDirs: extensionMigrationsResult.emitted,
423
425
  pendingPlaceholders: true,
424
426
  summary:
425
427
  'Planned migration with placeholder(s) — edit migration.ts then run `node migration.ts` to self-emit',
@@ -442,8 +444,9 @@ async function executeMigrationPlanCommand(
442
444
  label: op.label,
443
445
  operationClass: op.operationClass,
444
446
  })),
447
+ emittedExtensionDirs: extensionMigrationsResult.emitted,
445
448
  ...(preview !== undefined ? { preview } : {}),
446
- summary: `Planned ${plannedOps.length} operation(s)`,
449
+ summary: buildPlanSummary(plannedOps.length, extensionMigrationsResult.emitted.length),
447
450
  timings: { total: Date.now() - startTime },
448
451
  };
449
452
  return ok(result);
@@ -501,7 +504,29 @@ export function createMigrationPlanCommand(): Command {
501
504
  return command;
502
505
  }
503
506
 
504
- function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFlags): string {
507
+ /**
508
+ * Compose the success-line summary so the cross-space side effect
509
+ * (extension-space migration packages materialised on disk during
510
+ * this `plan` run) is visible in the top line — not just in the
511
+ * step log above it.
512
+ *
513
+ * Example outputs:
514
+ * - `Planned 3 operation(s)` (app-space-only project)
515
+ * - `Planned 3 operation(s); materialised 1 extension-space migration` (one extension)
516
+ * - `Planned 3 operation(s); materialised 2 extension-space migrations` (two extensions)
517
+ *
518
+ * Locks AC3 at the summary-line level: a reader of the success line
519
+ * can tell that something happened beyond the app space.
520
+ */
521
+ function buildPlanSummary(plannedOpsCount: number, emittedExtensionDirsCount: number): string {
522
+ const base = `Planned ${plannedOpsCount} operation(s)`;
523
+ if (emittedExtensionDirsCount === 0) return base;
524
+ const noun =
525
+ emittedExtensionDirsCount === 1 ? 'extension-space migration' : 'extension-space migrations';
526
+ return `${base}; materialised ${emittedExtensionDirsCount} ${noun}`;
527
+ }
528
+
529
+ export function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFlags): string {
505
530
  const lines: string[] = [];
506
531
  const useColor = flags.color !== false;
507
532
 
@@ -509,10 +534,29 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
509
534
  const yellow_ = useColor ? (s: string) => `\x1b[33m${s}\x1b[0m` : (s: string) => s;
510
535
  const dim_ = useColor ? (s: string) => `\x1b[2m${s}\x1b[0m` : (s: string) => s;
511
536
 
537
+ // Renders the extension-space materialisation block + canonical apply-step
538
+ // hint shared by the no-op, placeholder, and full-plan branches. The app
539
+ // space short-circuits do not skip it: an extension-only bump emits new
540
+ // `migrations/<spaceId>/<dirName>/` directories on disk that the user
541
+ // still has to apply, so the success line must surface them.
542
+ function appendEmittedExtensions(): void {
543
+ if (result.emittedExtensionDirs.length === 0) return;
544
+ lines.push('');
545
+ lines.push(dim_('Emitted extension migrations:'));
546
+ for (const entry of result.emittedExtensionDirs) {
547
+ lines.push(dim_(` ${entry.spaceId} → migrations/${entry.spaceId}/${entry.dirName}`));
548
+ }
549
+ lines.push('');
550
+ lines.push(
551
+ `Next: review the extension migrations above, then run ${green_('prisma-next migration apply')}.`,
552
+ );
553
+ }
554
+
512
555
  if (result.noOp) {
513
556
  lines.push(`${green_('✔')} No changes detected`);
514
557
  lines.push(dim_(` from: ${result.from}`));
515
558
  lines.push(dim_(` to: ${result.to}`));
559
+ appendEmittedExtensions();
516
560
  return lines.join('\n');
517
561
  }
518
562
 
@@ -529,6 +573,7 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
529
573
  'Open migration.ts and replace each `placeholder(...)` call with your actual query.',
530
574
  );
531
575
  lines.push(`Then run: ${green_(`node ${result.dir ?? '<dir>'}/migration.ts`)}`);
576
+ appendEmittedExtensions();
532
577
  return lines.join('\n');
533
578
  }
534
579
 
@@ -541,11 +586,11 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
541
586
  const op = result.operations[i]!;
542
587
  const isLast = i === result.operations.length - 1;
543
588
  const treeChar = isLast ? '└' : '├';
544
- const opClassLabel =
545
- op.operationClass === 'destructive'
546
- ? yellow_(`[${op.operationClass}]`)
547
- : dim_(`[${op.operationClass}]`);
548
- lines.push(`${dim_(treeChar)}─ ${op.label} ${opClassLabel}`);
589
+ // operationClass tag is intentionally NOT inlined per spec:
590
+ // a destructive footer warning still surfaces below this list.
591
+ const destructiveMarker =
592
+ op.operationClass === 'destructive' ? ` ${yellow_('(destructive)')}` : '';
593
+ lines.push(`${dim_(treeChar)}─ ${op.label}${destructiveMarker}`);
549
594
  }
550
595
 
551
596
  const hasDestructive = result.operations.some((op) => op.operationClass === 'destructive');
@@ -561,10 +606,22 @@ function formatMigrationPlanOutput(result: MigrationPlanResult, flags: GlobalFla
561
606
  lines.push(dim_(`from: ${result.from}`));
562
607
  lines.push(dim_(`to: ${result.to}`));
563
608
  if (result.dir) {
564
- lines.push(dim_(`dir: ${result.dir}`));
609
+ lines.push(dim_(`App space → ${result.dir}`));
610
+ }
611
+ // Per-space block: surface the extension-space directories materialised
612
+ // alongside the app-space migration. Without this block the cross-space
613
+ // side effect is invisible in the success summary (e2e finding F1).
614
+ for (const entry of result.emittedExtensionDirs) {
615
+ lines.push(
616
+ dim_(`Extension space ${entry.spaceId} → migrations/${entry.spaceId}/${entry.dirName}`),
617
+ );
565
618
  }
566
619
 
567
620
  lines.push('');
621
+ // The "Next:" hint always points at the canonical apply path
622
+ // (`prisma-next migration apply`) regardless of how many spaces
623
+ // were materialised — `db update` is a dev-time convenience, not
624
+ // the canonical replay step.
568
625
  lines.push(
569
626
  `Next: review ${green_(result.dir ?? '<dir>')} if needed, then run ${green_('prisma-next migration apply')}.`,
570
627
  );