@prisma-next/cli 0.5.0-dev.74 → 0.5.0-dev.76

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,50 +1,116 @@
1
+ import type { Contract } from '@prisma-next/contract/types';
1
2
  import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
2
3
  import type {
3
4
  ControlDriverInstance,
5
+ ControlExtensionDescriptor,
4
6
  ControlFamilyInstance,
5
- MigrationRunnerResult,
6
7
  TargetMigrationsCapability,
7
8
  } from '@prisma-next/framework-components/control';
8
- import { APP_SPACE_ID } from '@prisma-next/framework-components/control';
9
+ import {
10
+ type AggregatePerSpacePlan,
11
+ type ContractMarkerRecordLike,
12
+ type ContractSpaceAggregate,
13
+ type ContractSpaceMember,
14
+ graphWalkStrategy,
15
+ } from '@prisma-next/migration-tools/aggregate';
9
16
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
17
+ import { errorNoInvariantPath } from '@prisma-next/migration-tools/errors';
18
+ import { findPathWithDecision } from '@prisma-next/migration-tools/migration-graph';
19
+ import type { OnDiskMigrationPackage } from '@prisma-next/migration-tools/package';
20
+ import { ifDefined } from '@prisma-next/utils/defined';
10
21
  import { notOk, ok } from '@prisma-next/utils/result';
22
+ import {
23
+ type BuildAggregateInputs,
24
+ buildContractSpaceAggregate,
25
+ } from '../../utils/contract-space-aggregate-loader';
11
26
  import type {
12
- MigrationApplyAppliedEntry,
27
+ AggregatePerSpaceExecutionEntry,
28
+ MigrationApplyFailure,
29
+ MigrationApplyPathDecision,
13
30
  MigrationApplyResult,
14
- MigrationApplyStep,
31
+ MigrationApplySuccess,
15
32
  OnControlProgress,
16
33
  } from '../types';
34
+ import { applyAggregate, buildPerSpaceBreakdown } from './apply-aggregate';
17
35
 
36
+ /**
37
+ * Inputs for the aggregate-walking `migration apply` control-api
38
+ * operation.
39
+ *
40
+ * The CLI command resolves the descriptor surface (config, refs,
41
+ * contract envelope) and hands a flat input through. The operation
42
+ * is the single descriptor-free seam between the CLI and the
43
+ * aggregate runtime.
44
+ */
18
45
  export interface ExecuteMigrationApplyOptions<TFamilyId extends string, TTargetId extends string> {
19
46
  readonly driver: ControlDriverInstance<TFamilyId, TTargetId>;
20
47
  readonly familyInstance: ControlFamilyInstance<TFamilyId, unknown>;
21
- readonly originHash: string;
22
- readonly destinationHash: string;
23
- readonly pendingMigrations: readonly MigrationApplyStep[];
48
+ /** Already-validated app contract (the canonical "where we are heading" hash). */
49
+ readonly contract: Contract;
24
50
  readonly migrations: TargetMigrationsCapability<
25
51
  TFamilyId,
26
52
  TTargetId,
27
53
  ControlFamilyInstance<TFamilyId, unknown>
28
54
  >;
29
55
  readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<TFamilyId, TTargetId>>;
30
- readonly targetId: string;
56
+ readonly migrationsDir: string;
57
+ readonly extensionPacks: ReadonlyArray<ControlExtensionDescriptor<TFamilyId, TTargetId>>;
58
+ readonly targetId: TTargetId;
59
+ /**
60
+ * Already-loaded app-space migration packages. The CLI command
61
+ * loads these via `loadMigrationPackages(appMigrationsDir)`; the
62
+ * operation hydrates the app member's graph with them. Required
63
+ * because the framework-neutral aggregate loader doesn't know how
64
+ * to read the user's `migrations/` directory layout (it's family-
65
+ * aware: ops.json shape, manifest keys, etc.).
66
+ */
67
+ readonly appMigrationPackages: ReadonlyArray<OnDiskMigrationPackage>;
68
+ /**
69
+ * Optional app-space ref override. When provided, the app member's
70
+ * graph-walk targets this hash instead of `member.headRef.hash`.
71
+ * Extensions are unaffected — they always walk to their own head.
72
+ *
73
+ * Sub-spec § `--ref <hash>` semantics under multi-space.
74
+ */
75
+ readonly refHash?: string;
76
+ /**
77
+ * Required invariants attached to the user-supplied app-space ref.
78
+ * Threaded into the graph-walk's `required` calculation so the
79
+ * planner picks an invariant-bearing path and surfaces the
80
+ * required/satisfied set on the success envelope. When `refHash`
81
+ * is absent the file's `member.headRef.invariants` are used.
82
+ */
83
+ readonly refInvariants?: readonly string[];
84
+ /**
85
+ * Resolved name of the user-supplied app-space ref. Surfaces in
86
+ * `pathDecision.refName` and in `MIGRATION.NO_INVARIANT_PATH`
87
+ * error envelopes so diagnostics name what the user actually
88
+ * passed (`--ref prod`) instead of a synthetic placeholder.
89
+ * Ignored when `refHash` is absent.
90
+ */
91
+ readonly refName?: string;
31
92
  readonly onProgress?: OnControlProgress;
32
93
  }
33
94
 
34
95
  /**
35
- * Apply a sequence of migration packages against the configured driver.
96
+ * Apply pending migrations across every contract space (app +
97
+ * extensions). Replay-only: graph-walk against the on-disk graph for
98
+ * every member; no synth, no introspection.
99
+ *
100
+ * Pipeline:
36
101
  *
37
- * Validates the path's continuity (origin ... → destination, no gaps),
38
- * then drives the family/target's migration runner over each package's
39
- * operations in order, surfacing per-migration progress through `onProgress`.
102
+ * 1. Load aggregate from disk (loader hydrates extension graphs;
103
+ * caller provides app-space packages).
104
+ * 2. Read live marker rows per space (`familyInstance.readAllMarkers`).
105
+ * 3. Per member: `graphWalkStrategy` plots the path from the live
106
+ * marker to `member.headRef.hash` (or `refHash` for the app
107
+ * member when provided). Empty-graph members fail loudly — a
108
+ * "never planned" space is a user-error condition for replay.
109
+ * 4. Hand off to {@link applyAggregate} (the runner-driving tail
110
+ * shared with `db init` / `db update`). Marker advancement is
111
+ * inside the per-space transaction.
40
112
  *
41
- * The `pendingMigrations` parameter is trusted input. Callers are responsible
42
- * for upstream verification of the originating migration packages — typically
43
- * by loading them via `readMigrationPackage` from
44
- * `@prisma-next/migration-tools/io`, which performs hash-integrity checks at
45
- * the load boundary. This operation does not re-verify the packages and
46
- * assumes the `(metadata, ops)` pairs on disk have not been tampered with
47
- * since emit.
113
+ * Sub-spec § `migration apply` semantics + § Required changes 1.
48
114
  */
49
115
  export async function executeMigrationApply<TFamilyId extends string, TTargetId extends string>(
50
116
  options: ExecuteMigrationApplyOptions<TFamilyId, TTargetId>,
@@ -52,168 +118,367 @@ export async function executeMigrationApply<TFamilyId extends string, TTargetId
52
118
  const {
53
119
  driver,
54
120
  familyInstance,
55
- originHash,
56
- destinationHash,
57
- pendingMigrations,
121
+ contract,
58
122
  migrations,
59
123
  frameworkComponents,
124
+ migrationsDir,
125
+ extensionPacks,
60
126
  targetId,
127
+ appMigrationPackages,
128
+ refHash,
129
+ refInvariants,
130
+ refName,
61
131
  onProgress,
62
132
  } = options;
63
133
 
64
- if (pendingMigrations.length === 0) {
65
- if (originHash !== destinationHash) {
66
- return notOk({
67
- code: 'MIGRATION_PATH_NOT_FOUND' as const,
68
- summary: 'No migrations provided for requested origin and destination',
69
- why: `Requested ${originHash} -> ${destinationHash} but pendingMigrations is empty`,
70
- meta: { originHash, destinationHash },
71
- });
72
- }
73
- return ok({
74
- migrationsApplied: 0,
75
- markerHash: originHash,
76
- applied: [],
77
- summary: 'Already up to date',
78
- });
134
+ const loadInputs: BuildAggregateInputs<TFamilyId, TTargetId> = {
135
+ targetId,
136
+ migrationsDir,
137
+ appContract: contract,
138
+ extensionPacks,
139
+ validateContract: (json) => familyInstance.validateContract(json),
140
+ appMigrationPackages,
141
+ };
142
+ const loaded = await buildContractSpaceAggregate(loadInputs);
143
+ if (!loaded.ok) {
144
+ throw loaded.failure;
79
145
  }
146
+ const aggregate = loaded.value;
80
147
 
81
- const firstMigration = pendingMigrations[0]!;
82
- const lastMigration = pendingMigrations[pendingMigrations.length - 1]!;
83
- // Manifest `from` is `string | null` (null = baseline). The live-marker
84
- // layer encodes "no prior state" as EMPTY_CONTRACT_HASH; bridge here so the
85
- // string comparisons below work uniformly.
86
- const firstFromMarker = firstMigration.from ?? EMPTY_CONTRACT_HASH;
87
- if (firstFromMarker !== originHash || lastMigration.to !== destinationHash) {
88
- return notOk({
89
- code: 'MIGRATION_PATH_NOT_FOUND' as const,
90
- summary: 'Migration apply path does not match requested origin and destination',
91
- why: `Path resolved as ${firstFromMarker} -> ${lastMigration.to}, but requested ${originHash} -> ${destinationHash}`,
92
- meta: {
93
- originHash,
94
- destinationHash,
95
- pathOrigin: firstFromMarker,
96
- pathDestination: lastMigration.to,
97
- },
98
- });
99
- }
148
+ const markerRows = await familyInstance.readAllMarkers({ driver });
149
+
150
+ // Plan every member via graph-walk. App member targets `refHash`
151
+ // when provided, otherwise its own head; extensions always walk
152
+ // to their own head ref.
153
+ const allMembers: ReadonlyArray<ContractSpaceMember> = [aggregate.app, ...aggregate.extensions];
154
+ const perSpacePlans = new Map<string, AggregatePerSpacePlan>();
155
+ // Already-at-head empty-graph members (typically extensions whose
156
+ // head ref is the empty sentinel, or whose live marker already
157
+ // matches the target). Kept out of the runner schedule so we don't
158
+ // write spurious markers for greenfield extensions, but merged back
159
+ // into the success envelope so every loaded member is represented.
160
+ const atHeadResolutions = new Map<string, AggregatePerSpacePlan>();
161
+ for (const member of allMembers) {
162
+ const isAppMember = member.spaceId === aggregate.app.spaceId;
163
+ const targetHash = isAppMember && refHash !== undefined ? refHash : member.headRef.hash;
164
+ const liveMarker = markerRows.get(member.spaceId) ?? null;
165
+
166
+ // Empty-graph members fail loudly: replay needs an on-disk path
167
+ // and an empty graph means the user has never planned this space.
168
+ if (member.migrations.graph.nodes.size === 0) {
169
+ // Edge case: target == EMPTY (greenfield, nothing to do) or
170
+ // the live marker already matches the target. Loader integrity
171
+ // allows this for extensions whose head ref is the empty
172
+ // sentinel. Record a zero-op resolution so the aggregate result
173
+ // still surfaces the member in `perSpace[]` as already-at-head;
174
+ // the runner is not invoked for these members because they have
175
+ // no authored ops and (for greenfield extensions) no marker to
176
+ // advance.
177
+ const liveHash = liveMarker?.storageHash;
178
+ if (
179
+ targetHash === liveHash ||
180
+ (liveHash === undefined && targetHash === EMPTY_CONTRACT_HASH)
181
+ ) {
182
+ atHeadResolutions.set(
183
+ member.spaceId,
184
+ buildAtHeadResolution({
185
+ aggregateTargetId: aggregate.targetId,
186
+ member,
187
+ targetHash,
188
+ liveMarker,
189
+ }),
190
+ );
191
+ continue;
192
+ }
193
+ return notOk(buildNeverPlannedFailure(member.spaceId, targetHash));
194
+ }
100
195
 
101
- for (let i = 1; i < pendingMigrations.length; i++) {
102
- const previous = pendingMigrations[i - 1]!;
103
- const current = pendingMigrations[i]!;
104
- const currentFromMarker = current.from ?? EMPTY_CONTRACT_HASH;
105
- if (previous.to !== currentFromMarker) {
106
- return notOk({
107
- code: 'MIGRATION_PATH_NOT_FOUND' as const,
108
- summary: 'Migration apply path contains a discontinuity between adjacent migrations',
109
- why: `Migration "${previous.dirName}" ends at ${previous.to}, but next migration "${current.dirName}" starts at ${currentFromMarker}`,
110
- meta: {
111
- originHash,
112
- destinationHash,
113
- previousDirName: previous.dirName,
114
- previousTo: previous.to,
115
- currentDirName: current.dirName,
116
- currentFrom: currentFromMarker,
117
- discontinuityIndex: i,
118
- },
196
+ const targetInvariants =
197
+ isAppMember && refHash !== undefined && refInvariants !== undefined
198
+ ? refInvariants
199
+ : member.headRef.invariants;
200
+ const targetMember: ContractSpaceMember =
201
+ targetHash === member.headRef.hash && targetInvariants === member.headRef.invariants
202
+ ? member
203
+ : { ...member, headRef: { hash: targetHash, invariants: targetInvariants } };
204
+
205
+ const walked = graphWalkStrategy({
206
+ aggregateTargetId: aggregate.targetId,
207
+ member: targetMember,
208
+ currentMarker: liveMarker,
209
+ ...(isAppMember && refName !== undefined ? { refName } : {}),
210
+ });
211
+ if (walked.kind === 'unreachable') {
212
+ return notOk(buildPathNotFoundFailure(member.spaceId, liveMarker, targetHash));
213
+ }
214
+ if (walked.kind === 'unsatisfiable') {
215
+ // Surface the canonical MIGRATION.NO_INVARIANT_PATH envelope
216
+ // (the error rendering pipeline maps it to meta.code +
217
+ // meta.required + meta.missing + meta.structuralPath that the
218
+ // cli-journeys invariant suite asserts on).
219
+ const fromHash = liveMarker?.storageHash ?? '';
220
+ const structural = findPathWithDecision(targetMember.migrations.graph, fromHash, targetHash, {
221
+ required: new Set<string>(),
222
+ });
223
+ const structuralPath =
224
+ structural.kind === 'ok'
225
+ ? structural.decision.selectedPath.map((edge) => ({
226
+ dirName: edge.dirName,
227
+ migrationHash: edge.migrationHash,
228
+ from: edge.from,
229
+ to: edge.to,
230
+ invariants: edge.invariants,
231
+ }))
232
+ : [];
233
+ throw errorNoInvariantPath({
234
+ ...(isAppMember && refName !== undefined ? { refName } : {}),
235
+ required: targetInvariants,
236
+ missing: walked.missing,
237
+ structuralPath,
119
238
  });
120
239
  }
240
+
241
+ perSpacePlans.set(member.spaceId, walked.result);
121
242
  }
122
243
 
123
- const runner = migrations.createRunner(familyInstance);
124
- const applied: MigrationApplyAppliedEntry[] = [];
244
+ const canonicalOrder = [...aggregate.extensions.map((m) => m.spaceId), aggregate.app.spaceId];
245
+ const applyOrder = canonicalOrder.filter((spaceId) => perSpacePlans.has(spaceId));
125
246
 
126
- for (const migration of pendingMigrations) {
127
- const migrationSpanId = `migration:${migration.dirName}`;
128
- onProgress?.({
129
- action: 'migrationApply',
130
- kind: 'spanStart',
131
- spanId: migrationSpanId,
132
- label: `Applying ${migration.dirName}`,
247
+ // Short-circuit: nothing pending across any space (no runner-bound
248
+ // plans). Surfaces every loaded member — including at-head empty-
249
+ // graph extensions — in `perSpace[]` so the result reflects the
250
+ // full aggregate, not just the spaces the runner would have touched.
251
+ const totalPlannedOps = sumPlannedOps(applyOrder, perSpacePlans);
252
+ if (totalPlannedOps === 0) {
253
+ const ordered = canonicalOrder
254
+ .filter((spaceId) => perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId))
255
+ .map((spaceId) => {
256
+ const entry = perSpacePlans.get(spaceId) ?? atHeadResolutions.get(spaceId);
257
+ if (entry === undefined) {
258
+ throw new Error(`Unreachable: missing per-space plan for "${spaceId}"`);
259
+ }
260
+ return { spaceId, entry };
261
+ });
262
+ const perSpace = buildPerSpaceBreakdown(ordered, aggregate.app.spaceId, {
263
+ includeMarkers: true,
133
264
  });
265
+ const totalSpaces = ordered.length;
266
+ return ok(
267
+ buildSuccess({
268
+ aggregate,
269
+ orderedResolutions: ordered,
270
+ perSpace,
271
+ totalOpsExecuted: 0,
272
+ summary:
273
+ totalSpaces === 0
274
+ ? 'Already up to date — no contract spaces are loaded'
275
+ : totalSpaces === 1
276
+ ? 'Already up to date'
277
+ : `Already up to date across ${totalSpaces} space(s)`,
278
+ }),
279
+ );
280
+ }
134
281
 
135
- const { operations } = migration;
136
-
137
- // Allow all operation classes. The policy gate belongs at plan time, not
138
- // apply time — the planner already decided what to emit. Restricting here
139
- // would be a tautology (the allowed set would just mirror what's in ops).
140
- const policy = {
141
- allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] as const,
142
- };
282
+ const applied = await applyAggregate({
283
+ aggregate,
284
+ perSpacePlans,
285
+ applyOrder,
286
+ driver,
287
+ familyInstance,
288
+ migrations,
289
+ frameworkComponents,
290
+ policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
291
+ action: 'migrationApply',
292
+ ...ifDefined('onProgress', onProgress),
293
+ });
143
294
 
144
- // Manifest `from === null` means "no prior state" — the runner expects
145
- // `origin: null` for a fresh database (no marker present).
146
- //
147
- // M3 will thread the package's owning space id through `MigrationApplyStep`
148
- // and use it here. Until then, all on-disk migration packages live under
149
- // the app's `migrations/` directory, so this path is exclusively app-scope
150
- // and we pass `APP_SPACE_ID` explicitly.
151
- const plan = {
152
- targetId,
153
- spaceId: APP_SPACE_ID,
154
- origin: migration.from === null ? null : { storageHash: migration.from },
155
- destination: { storageHash: migration.to },
156
- operations,
157
- providedInvariants: migration.providedInvariants,
295
+ if (!applied.ok) {
296
+ const failure: MigrationApplyFailure = {
297
+ code: 'RUNNER_FAILED',
298
+ summary: applied.failure.summary,
299
+ why: applied.failure.why,
300
+ meta: applied.failure.meta,
158
301
  };
302
+ return notOk(failure);
303
+ }
159
304
 
160
- const destinationContract = familyInstance.validateContract(migration.toContract);
161
-
162
- const runnerResult: MigrationRunnerResult = await runner.execute({
163
- plan,
164
- driver,
165
- destinationContract,
166
- policy,
167
- executionChecks: {
168
- prechecks: true,
169
- postchecks: true,
170
- idempotencyChecks: true,
171
- },
172
- frameworkComponents,
305
+ // Merge at-head zero-op resolutions back into the canonical order
306
+ // so the success envelope surfaces every loaded member, not just
307
+ // those the runner executed.
308
+ const orderedAll = canonicalOrder
309
+ .filter((spaceId) => perSpacePlans.has(spaceId) || atHeadResolutions.has(spaceId))
310
+ .map((spaceId) => {
311
+ if (perSpacePlans.has(spaceId)) {
312
+ const fromRunner = applied.value.orderedResolutions.find((r) => r.spaceId === spaceId);
313
+ if (fromRunner !== undefined) return fromRunner;
314
+ }
315
+ const entry = atHeadResolutions.get(spaceId);
316
+ if (entry === undefined) {
317
+ throw new Error(`Unreachable: missing per-space plan for "${spaceId}"`);
318
+ }
319
+ return { spaceId, entry };
173
320
  });
321
+ const perSpaceAll = buildPerSpaceBreakdown(orderedAll, aggregate.app.spaceId, {
322
+ includeMarkers: true,
323
+ });
324
+ const totalMigrationsApplied = applied.value.orderedResolutions.reduce(
325
+ (sum, r) => sum + (r.entry.migrationEdges?.length ?? 0),
326
+ 0,
327
+ );
328
+ const summary = `Applied ${totalMigrationsApplied} migration(s) (${applied.value.totalOpsExecuted} operation(s)) across ${orderedAll.length} contract space(s)`;
174
329
 
175
- if (!runnerResult.ok) {
176
- onProgress?.({
177
- action: 'migrationApply',
178
- kind: 'spanEnd',
179
- spanId: migrationSpanId,
180
- outcome: 'error',
181
- });
182
- return notOk({
183
- code: 'RUNNER_FAILED' as const,
184
- summary: runnerResult.failure.summary,
185
- why: runnerResult.failure.why,
186
- meta: {
187
- migration: migration.dirName,
188
- from: migration.from,
189
- to: migration.to,
190
- ...(runnerResult.failure.meta ?? {}),
191
- },
192
- });
193
- }
330
+ return ok(
331
+ buildSuccess({
332
+ aggregate,
333
+ orderedResolutions: orderedAll,
334
+ perSpace: perSpaceAll,
335
+ totalOpsExecuted: applied.value.totalOpsExecuted,
336
+ summary,
337
+ }),
338
+ );
339
+ }
194
340
 
195
- onProgress?.({
196
- action: 'migrationApply',
197
- kind: 'spanEnd',
198
- spanId: migrationSpanId,
199
- outcome: 'ok',
200
- });
341
+ /**
342
+ * Build a zero-op {@link AggregatePerSpacePlan} for an empty-graph
343
+ * member whose live marker already matches the target. Lets the apply
344
+ * pipeline thread the member through `perSpacePlans` -> `applyOrder`
345
+ * -> the success envelope's `perSpace[]` block so the result reflects
346
+ * every loaded space, even when there is nothing to execute.
347
+ */
348
+ function buildAtHeadResolution(args: {
349
+ readonly aggregateTargetId: string;
350
+ readonly member: ContractSpaceMember;
351
+ readonly targetHash: string;
352
+ readonly liveMarker: ContractMarkerRecordLike | null;
353
+ }): AggregatePerSpacePlan {
354
+ const { aggregateTargetId, member, targetHash, liveMarker } = args;
355
+ return {
356
+ plan: {
357
+ targetId: aggregateTargetId,
358
+ spaceId: member.spaceId,
359
+ origin: liveMarker === null ? null : { storageHash: liveMarker.storageHash },
360
+ destination: { storageHash: targetHash },
361
+ operations: [],
362
+ providedInvariants: [],
363
+ },
364
+ displayOps: [],
365
+ destinationContract: member.contract,
366
+ strategy: 'graph-walk',
367
+ migrationEdges: [],
368
+ };
369
+ }
201
370
 
202
- applied.push({
203
- dirName: migration.dirName,
204
- from: migration.from,
205
- to: migration.to,
206
- operationsExecuted: runnerResult.value.operationsExecuted,
207
- });
371
+ function sumPlannedOps(
372
+ applyOrder: readonly string[],
373
+ perSpacePlans: ReadonlyMap<string, AggregatePerSpacePlan>,
374
+ ): number {
375
+ let total = 0;
376
+ for (const spaceId of applyOrder) {
377
+ const entry = perSpacePlans.get(spaceId);
378
+ if (!entry) continue;
379
+ total += entry.plan.operations.length;
208
380
  }
381
+ return total;
382
+ }
383
+
384
+ interface BuildSuccessArgs {
385
+ readonly aggregate: ContractSpaceAggregate;
386
+ readonly orderedResolutions: ReadonlyArray<{
387
+ readonly spaceId: string;
388
+ readonly entry: AggregatePerSpacePlan;
389
+ }>;
390
+ readonly perSpace: ReadonlyArray<AggregatePerSpaceExecutionEntry>;
391
+ readonly totalOpsExecuted: number;
392
+ readonly summary: string;
393
+ }
394
+
395
+ function buildSuccess(args: BuildSuccessArgs): MigrationApplySuccess {
396
+ // The marker hash surfaced at the top level is the **app member's**
397
+ // post-apply marker (today's single-space `markerHash` field).
398
+ // Per-space markers live on `perSpace[].marker.storageHash`.
399
+ const appResolution = args.orderedResolutions.find(
400
+ (r) => r.spaceId === args.aggregate.app.spaceId,
401
+ );
402
+ const appMarkerHash =
403
+ appResolution?.entry.plan.destination.storageHash ?? args.aggregate.app.headRef.hash;
404
+
405
+ // Per-migration entries (one per authored edge) preserve the
406
+ // single-space `migrationsApplied` count semantics for back-compat
407
+ // with existing JSON-shape consumers (e.g. `parsed.applied.length`
408
+ // in integration tests). The aggregate per-space breakdown lives on
409
+ // `perSpace[]`.
410
+ const applied = args.orderedResolutions.flatMap((r) => {
411
+ const edges = r.entry.migrationEdges ?? [];
412
+ return edges.map((edge) => ({
413
+ spaceId: r.spaceId,
414
+ dirName: edge.dirName,
415
+ migrationHash: edge.migrationHash,
416
+ from: edge.from,
417
+ to: edge.to,
418
+ operationsExecuted: edge.operationCount,
419
+ }));
420
+ });
209
421
 
210
- const finalHash = pendingMigrations[pendingMigrations.length - 1]!.to;
211
- const totalOps = applied.reduce((sum, a) => sum + a.operationsExecuted, 0);
422
+ const appPlan = appResolution?.entry;
423
+ const pathDecision: MigrationApplyPathDecision | undefined = appPlan?.pathDecision
424
+ ? {
425
+ fromHash: appPlan.pathDecision.fromHash,
426
+ toHash: appPlan.pathDecision.toHash,
427
+ alternativeCount: appPlan.pathDecision.alternativeCount,
428
+ tieBreakReasons: appPlan.pathDecision.tieBreakReasons,
429
+ ...(appPlan.pathDecision.refName !== undefined
430
+ ? { refName: appPlan.pathDecision.refName }
431
+ : {}),
432
+ requiredInvariants: appPlan.pathDecision.requiredInvariants ?? [],
433
+ satisfiedInvariants: appPlan.pathDecision.satisfiedInvariants ?? [],
434
+ selectedPath: appPlan.pathDecision.selectedPath.map((entry) => ({
435
+ dirName: entry.dirName,
436
+ migrationHash: entry.migrationHash,
437
+ from: entry.from,
438
+ to: entry.to,
439
+ invariants: entry.invariants,
440
+ })),
441
+ }
442
+ : undefined;
212
443
 
213
- return ok({
444
+ return {
214
445
  migrationsApplied: applied.length,
215
- markerHash: finalHash,
446
+ markerHash: appMarkerHash,
216
447
  applied,
217
- summary: `Applied ${applied.length} migration(s) (${totalOps} operation(s)), marker at ${finalHash}`,
218
- });
448
+ summary: args.summary,
449
+ perSpace: args.perSpace,
450
+ ...(pathDecision !== undefined ? { pathDecision } : {}),
451
+ };
452
+ }
453
+
454
+ function buildNeverPlannedFailure(spaceId: string, targetHash: string): MigrationApplyFailure {
455
+ return {
456
+ code: 'MIGRATION_PATH_NOT_FOUND',
457
+ summary: `No on-disk migrations for contract space "${spaceId}"`,
458
+ why: `migration apply is replay-only: every contract space must have an authored migration graph on disk. Space "${spaceId}" has no migrations under \`migrations/${spaceId}/\` but its head ref targets "${targetHash}". Run \`prisma-next migration plan\` first to materialise the path.`,
459
+ meta: { spaceId, target: targetHash, kind: 'neverPlanned' },
460
+ };
461
+ }
462
+
463
+ function buildPathNotFoundFailure(
464
+ spaceId: string,
465
+ marker: ContractMarkerRecordLike | null,
466
+ targetHash: string,
467
+ ): MigrationApplyFailure {
468
+ const fromHash = marker?.storageHash ?? '<empty>';
469
+ // The single-space-degenerate phrasing names the user-visible
470
+ // condition (a contract has been emitted that no on-disk
471
+ // migration reaches) so the error reads naturally for the
472
+ // single-space app case. Multi-space callers see the same
473
+ // condition expressed against the offending space.
474
+ const summary =
475
+ spaceId === 'app'
476
+ ? 'Current contract has no planned migration path'
477
+ : `Current contract has no planned migration path for contract space "${spaceId}"`;
478
+ return {
479
+ code: 'MIGRATION_PATH_NOT_FOUND',
480
+ summary,
481
+ why: `Cannot reach target "${targetHash}" from current marker "${fromHash}" in space "${spaceId}". The on-disk migration graph for this space does not connect the two states. Run \`prisma-next migration plan\` to materialise the path.`,
482
+ meta: { spaceId, fromHash, targetHash, kind: 'pathUnreachable' },
483
+ };
219
484
  }