@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,4 +1,11 @@
1
- import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
1
+ import {
2
+ createControlStack,
3
+ type MigrationPlanOperation,
4
+ } from '@prisma-next/framework-components/control';
5
+ import {
6
+ type ContractMarkerRecordLike,
7
+ graphWalkStrategy,
8
+ } from '@prisma-next/migration-tools/aggregate';
2
9
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
3
10
  import {
4
11
  errorNoInvariantPath,
@@ -39,6 +46,10 @@ import {
39
46
  toPathDecisionResult,
40
47
  toStructuralEdge,
41
48
  } from '../utils/command-helpers';
49
+ import {
50
+ type BuildAggregateInputs,
51
+ buildContractSpaceAggregate,
52
+ } from '../utils/contract-space-aggregate-loader';
42
53
  import {
43
54
  type EdgeStatus,
44
55
  type EdgeStatusKind,
@@ -76,6 +87,53 @@ export interface MigrationStatusEntry {
76
87
  readonly status: EdgeStatusKind | 'unknown';
77
88
  }
78
89
 
90
+ /**
91
+ * Per-space status row in the aggregate-shaped status output.
92
+ *
93
+ * Surfaces, for each contract space:
94
+ *
95
+ * - `headHash`: the on-disk head ref's hash (where the space is going).
96
+ * - `markerHash`: the live marker hash for the space, or null if no
97
+ * marker has been written yet (greenfield, or pre-`migration apply`).
98
+ * - `pendingCount`: number of migration edges between marker and head.
99
+ * Computed via {@link graphWalkStrategy}; 0 means the space is
100
+ * already at head.
101
+ * - `status`: convenience tag the formatter uses to pick a glyph.
102
+ * `'never-planned'` is reserved for spaces with non-empty head but
103
+ * no on-disk migrations — which shouldn't happen if the loader's
104
+ * integrity check passes.
105
+ *
106
+ * Online-only fields (`markerHash`, `status`) are absent when the
107
+ * command runs without a database connection.
108
+ */
109
+ export interface MigrationStatusSpaceEntry {
110
+ readonly spaceId: string;
111
+ readonly kind: 'app' | 'extension';
112
+ readonly headHash: string;
113
+ readonly markerHash?: string | null;
114
+ readonly pendingCount?: number;
115
+ readonly status?: 'up-to-date' | 'pending' | 'no-marker' | 'never-planned' | 'unreachable';
116
+ }
117
+
118
+ /**
119
+ * Sum per-space `pendingCount` into a cross-space total, but only when
120
+ * every loaded space reports a defined `pendingCount`. Returns
121
+ * `undefined` if any space is on the marker-unknown / offline path
122
+ * (where `pendingCount` is intentionally absent), so JSON consumers can
123
+ * distinguish "no pending" from "unknown".
124
+ */
125
+ export function computeTotalPendingAcrossSpaces(
126
+ spaces: readonly MigrationStatusSpaceEntry[],
127
+ ): number | undefined {
128
+ if (spaces.length === 0) return undefined;
129
+ let total = 0;
130
+ for (const s of spaces) {
131
+ if (s.pendingCount === undefined) return undefined;
132
+ total += s.pendingCount;
133
+ }
134
+ return total;
135
+ }
136
+
79
137
  export type { StatusDiagnostic, StatusRef } from '../utils/migration-types';
80
138
 
81
139
  export interface MigrationStatusResult {
@@ -117,6 +175,22 @@ export interface MigrationStatusResult {
117
175
  };
118
176
  readonly summary: string;
119
177
  readonly diagnostics: readonly StatusDiagnostic[];
178
+ /**
179
+ * Aggregate enumeration of every on-disk contract space (app +
180
+ * extensions), in canonical schedule order (extensions
181
+ * alphabetically, then app). Present whenever the aggregate loader
182
+ * succeeded; absent in early-error returns (e.g. unreadable
183
+ * migrations directory) where the existing diagnostics already
184
+ * surface the failure.
185
+ *
186
+ * The legacy top-level fields (`migrations`, `markerHash`,
187
+ * `targetHash`, `pathDecision`, …) describe the **app member**
188
+ * specifically — back-compat with single-space callers. Per-space
189
+ * detail for extension members lives only on this list.
190
+ */
191
+ readonly spaces?: readonly MigrationStatusSpaceEntry[];
192
+ /** Cross-space pending-migration total (sum of `spaces[].pendingCount`). Present when `spaces` is. */
193
+ readonly totalPendingAcrossSpaces?: number;
120
194
  readonly graph?: MigrationGraph;
121
195
  readonly bundles?: readonly OnDiskMigrationPackage[];
122
196
  readonly edgeStatuses?: readonly EdgeStatus[];
@@ -363,16 +437,130 @@ function determineLimit(opts: MigrationStatusOptions) {
363
437
  return parsed;
364
438
  }
365
439
 
440
+ /**
441
+ * Build the aggregate enumeration of contract spaces for the status
442
+ * output. Loads the aggregate from disk (lossy on failure — extension
443
+ * spaces are simply omitted, the existing single-space app behaviour
444
+ * keeps working), reads per-space marker rows when online, and uses
445
+ * {@link graphWalkStrategy} to compute each space's pending count.
446
+ *
447
+ * Sub-spec § `migration status` semantics — the aggregate-walking
448
+ * version reports per-space marker + pending state alongside the
449
+ * cross-space totals.
450
+ */
451
+ export async function loadAggregateStatusSpaces(args: {
452
+ readonly targetId: string;
453
+ readonly migrationsDir: string;
454
+ readonly appContractRaw: unknown;
455
+ readonly extensionPacks: BuildAggregateInputs<string, string>['extensionPacks'];
456
+ readonly validateContract: BuildAggregateInputs<string, string>['validateContract'];
457
+ readonly markersBySpace: ReadonlyMap<string, ContractMarkerRecordLike> | null;
458
+ }): Promise<readonly MigrationStatusSpaceEntry[]> {
459
+ const loadInputs: BuildAggregateInputs<string, string> = {
460
+ targetId: args.targetId,
461
+ migrationsDir: args.migrationsDir,
462
+ appContract: args.validateContract(args.appContractRaw),
463
+ extensionPacks: args.extensionPacks,
464
+ validateContract: args.validateContract,
465
+ };
466
+
467
+ const loaded = await buildContractSpaceAggregate(loadInputs);
468
+ if (!loaded.ok) {
469
+ // Loader failure (drift, layout violation, etc.) — surfacing it
470
+ // as a status diagnostic would duplicate `migration plan`'s job.
471
+ // The single-space app pipeline still runs; extensions are simply
472
+ // not enumerated.
473
+ return [];
474
+ }
475
+ const aggregate = loaded.value;
476
+
477
+ const orderedMembers = [...aggregate.extensions, aggregate.app];
478
+ const rows: MigrationStatusSpaceEntry[] = [];
479
+ for (const member of orderedMembers) {
480
+ const liveMarker = args.markersBySpace?.get(member.spaceId) ?? null;
481
+ const isApp = member.spaceId === aggregate.app.spaceId;
482
+
483
+ if (member.migrations.graph.nodes.size === 0) {
484
+ rows.push({
485
+ spaceId: member.spaceId,
486
+ kind: isApp ? 'app' : 'extension',
487
+ headHash: member.headRef.hash,
488
+ ...(args.markersBySpace !== null
489
+ ? {
490
+ markerHash: liveMarker?.storageHash ?? null,
491
+ status: member.headRef.hash === EMPTY_CONTRACT_HASH ? 'up-to-date' : 'never-planned',
492
+ pendingCount: 0,
493
+ }
494
+ : {}),
495
+ });
496
+ continue;
497
+ }
498
+
499
+ if (args.markersBySpace === null) {
500
+ rows.push({
501
+ spaceId: member.spaceId,
502
+ kind: isApp ? 'app' : 'extension',
503
+ headHash: member.headRef.hash,
504
+ });
505
+ continue;
506
+ }
507
+
508
+ const walked = graphWalkStrategy({
509
+ aggregateTargetId: aggregate.targetId,
510
+ member,
511
+ currentMarker: liveMarker,
512
+ });
513
+ let pendingCount = 0;
514
+ let status: MigrationStatusSpaceEntry['status'];
515
+ if (walked.kind === 'ok') {
516
+ pendingCount = walked.result.plan.operations.length;
517
+ if (liveMarker === null) {
518
+ status = pendingCount === 0 ? 'no-marker' : 'pending';
519
+ } else {
520
+ status = pendingCount === 0 ? 'up-to-date' : 'pending';
521
+ }
522
+ } else {
523
+ status = 'unreachable';
524
+ }
525
+
526
+ rows.push({
527
+ spaceId: member.spaceId,
528
+ kind: isApp ? 'app' : 'extension',
529
+ headHash: member.headRef.hash,
530
+ markerHash: liveMarker?.storageHash ?? null,
531
+ pendingCount,
532
+ ...(status ? { status } : {}),
533
+ });
534
+ }
535
+ return rows;
536
+ }
537
+
538
+ /**
539
+ * Read the raw contract.json bytes from disk for the aggregate
540
+ * loader. Returns `null` if the file is missing or unparseable —
541
+ * the existing `readContractEnvelope` path will report the same
542
+ * problem via a status diagnostic, no need to double-surface.
543
+ */
544
+ async function loadContractRawSafely(config: {
545
+ contract?: { output?: string };
546
+ }): Promise<unknown | null> {
547
+ try {
548
+ const path = (await import('../utils/command-helpers')).resolveContractPath(config);
549
+ const raw = await (await import('node:fs/promises')).readFile(path, 'utf-8');
550
+ return JSON.parse(raw) as unknown;
551
+ } catch {
552
+ return null;
553
+ }
554
+ }
555
+
366
556
  async function executeMigrationStatusCommand(
367
557
  options: MigrationStatusOptions,
368
558
  flags: GlobalFlags,
369
559
  ui: TerminalUI,
370
560
  ): Promise<Result<MigrationStatusResult, CliStructuredError>> {
371
561
  const config = await loadConfig(options.config);
372
- const { configPath, appMigrationsDir, appMigrationsRelative, refsDir } = resolveMigrationPaths(
373
- options.config,
374
- config,
375
- );
562
+ const { configPath, appMigrationsDir, appMigrationsRelative, migrationsDir, refsDir } =
563
+ resolveMigrationPaths(options.config, config);
376
564
 
377
565
  const dbConnection = options.db ?? config.db?.connection;
378
566
  const hasDriver = !!config.driver;
@@ -515,6 +703,7 @@ async function executeMigrationStatusCommand(
515
703
  let markerHash: string | undefined;
516
704
  let markerInvariants: readonly string[] = [];
517
705
  let mode: 'online' | 'offline' = 'offline';
706
+ let allMarkers: ReadonlyMap<string, ContractMarkerRecordLike> | null = null;
518
707
 
519
708
  if (dbConnection && hasDriver) {
520
709
  const client = createControlClient({
@@ -530,6 +719,21 @@ async function executeMigrationStatusCommand(
530
719
  markerHash = marker?.storageHash;
531
720
  markerInvariants = marker?.invariants ?? [];
532
721
  mode = 'online';
722
+ // Read every space's marker so the aggregate enumeration can
723
+ // surface per-space marker state. `readAllMarkers` mirrors what
724
+ // `db init` / `db update` already use to drive the multi-space
725
+ // planner; here it powers the aggregate status output.
726
+ try {
727
+ 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).
735
+ allMarkers = null;
736
+ }
533
737
  } catch {
534
738
  if (!flags.json && !flags.quiet) {
535
739
  ui.warn('Could not connect to database — showing offline status');
@@ -539,6 +743,37 @@ async function executeMigrationStatusCommand(
539
743
  }
540
744
  }
541
745
 
746
+ // Build the aggregate enumeration of contract spaces. Lossy on
747
+ // failure (extensions are simply omitted) so the existing
748
+ // single-space app pipeline below still runs even if extensions
749
+ // can't be loaded — a strict failure here would degrade the
750
+ // load-bearing app-space output for unrelated reasons.
751
+ const contractRawForAggregate = await loadContractRawSafely(config);
752
+ let aggregateSpaces: readonly MigrationStatusSpaceEntry[] = [];
753
+ if (contractRawForAggregate !== null) {
754
+ // The aggregate loader needs a typed-Contract producer. Build a
755
+ // real control stack so `validateContract` runs against a fully
756
+ // composed family instance — descriptors that read stack members
757
+ // during construction (e.g. codec lookups) get a consistent view.
758
+ const stack = createControlStack(config);
759
+ const familyInstance = config.family.create(stack);
760
+ try {
761
+ aggregateSpaces = await loadAggregateStatusSpaces({
762
+ targetId: config.target.targetId,
763
+ migrationsDir,
764
+ appContractRaw: contractRawForAggregate,
765
+ extensionPacks: config.extensionPacks ?? [],
766
+ validateContract: (json: unknown) => familyInstance.validateContract(json),
767
+ markersBySpace: allMarkers,
768
+ });
769
+ } catch {
770
+ // Loader failure short-circuits silently — the existing
771
+ // single-space app pipeline below still runs.
772
+ aggregateSpaces = [];
773
+ }
774
+ }
775
+ const totalPendingAcrossSpaces = computeTotalPendingAcrossSpaces(aggregateSpaces);
776
+
542
777
  // Pre-check unknown invariants. Online: union the graph's declared
543
778
  // invariants with the marker's recorded set so a retired-but-applied
544
779
  // invariant doesn't surface as MIGRATION.UNKNOWN_INVARIANT — apply would
@@ -803,6 +1038,8 @@ async function executeMigrationStatusCommand(
803
1038
  edgeStatuses,
804
1039
  ...ifDefined('activeRefHash', activeRefHash),
805
1040
  ...ifDefined('activeRefName', activeRefName),
1041
+ spaces: aggregateSpaces,
1042
+ ...ifDefined('totalPendingAcrossSpaces', totalPendingAcrossSpaces),
806
1043
  };
807
1044
  return ok(result);
808
1045
  }
@@ -950,9 +1187,55 @@ export function formatStatusSummary(result: MigrationStatusResult, colorize: boo
950
1187
  }
951
1188
  }
952
1189
 
1190
+ // Per-space section. Suppressed when there's no extension space —
1191
+ // the legacy single-space output already covers the app member.
1192
+ // When extensions exist, render every space (including the app)
1193
+ // for consistency, plus a cross-space pending total + apply hint.
1194
+ if (result.spaces?.some((s) => s.kind === 'extension')) {
1195
+ const total = result.totalPendingAcrossSpaces ?? 0;
1196
+ lines.push('');
1197
+ lines.push(c(dim, 'spaces'));
1198
+ for (const space of result.spaces) {
1199
+ lines.push(formatSpaceLine(space, c));
1200
+ }
1201
+ if (total > 0) {
1202
+ lines.push('');
1203
+ lines.push(
1204
+ `${c(yellow, '⧗')} ${total} pending migration(s) across ${result.spaces.length} space(s) — run 'prisma-next migration apply' to apply`,
1205
+ );
1206
+ }
1207
+ }
1208
+
953
1209
  return lines.join('\n');
954
1210
  }
955
1211
 
1212
+ function formatSpaceLine(
1213
+ space: MigrationStatusSpaceEntry,
1214
+ c: (fn: (s: string) => string, s: string) => string,
1215
+ ): string {
1216
+ const glyph = (() => {
1217
+ if (space.status === 'up-to-date' || space.status === 'no-marker') return c(cyan, '✓');
1218
+ if (space.status === 'pending') return c(yellow, '⧗');
1219
+ if (space.status === 'unreachable' || space.status === 'never-planned') return c(magenta, '✗');
1220
+ return ' ';
1221
+ })();
1222
+ const tag = space.kind === 'app' ? '[app]' : '[ext]';
1223
+ const head = space.headHash.slice(0, 8);
1224
+ const marker =
1225
+ space.markerHash === undefined
1226
+ ? '(unknown)'
1227
+ : space.markerHash === null
1228
+ ? '(no marker)'
1229
+ : space.markerHash.slice(0, 8);
1230
+ const pending =
1231
+ space.pendingCount === undefined
1232
+ ? ''
1233
+ : space.pendingCount === 0
1234
+ ? c(dim, ' (up to date)')
1235
+ : c(yellow, ` (${space.pendingCount} pending)`);
1236
+ return ` ${glyph} ${c(dim, tag)} ${space.spaceId} → head ${c(dim, head)}, marker ${c(dim, marker)}${pending}`;
1237
+ }
1238
+
956
1239
  function formatInvariantList(ids: readonly string[]): string {
957
1240
  return ids.length === 0 ? '(none)' : ids.join(', ');
958
1241
  }
@@ -461,16 +461,28 @@ class ControlClientImpl implements ControlClient {
461
461
  throw new Error(`Target "${this.options.target.targetId}" does not support migrations`);
462
462
  }
463
463
 
464
+ let contract: Contract;
465
+ try {
466
+ contract = familyInstance.validateContract(options.contract);
467
+ } catch (error) {
468
+ const message = error instanceof Error ? error.message : String(error);
469
+ throw new ContractValidationError(message, error);
470
+ }
471
+
464
472
  return executeMigrationApply({
465
473
  driver,
466
474
  familyInstance,
467
- originHash: options.originHash,
468
- destinationHash: options.destinationHash,
469
- pendingMigrations: options.pendingMigrations,
475
+ contract,
470
476
  migrations: this.options.target.migrations,
471
477
  frameworkComponents,
478
+ migrationsDir: options.migrationsDir,
479
+ extensionPacks: this.options.extensionPacks ?? [],
472
480
  targetId: this.options.target.targetId,
473
- ...(onProgress ? { onProgress } : {}),
481
+ appMigrationPackages: options.appMigrationPackages,
482
+ ...ifDefined('refHash', options.refHash),
483
+ ...ifDefined('refInvariants', options.refInvariants),
484
+ ...ifDefined('refName', options.refName),
485
+ ...ifDefined('onProgress', onProgress),
474
486
  });
475
487
  }
476
488
 
@@ -0,0 +1,290 @@
1
+ import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
2
+ import type {
3
+ ControlDriverInstance,
4
+ ControlFamilyInstance,
5
+ MigrationOperationPolicy,
6
+ MultiSpaceCapableRunner,
7
+ MultiSpaceRunnerPerSpaceOptions,
8
+ TargetMigrationsCapability,
9
+ } from '@prisma-next/framework-components/control';
10
+ import { hasMultiSpaceRunner } from '@prisma-next/framework-components/control';
11
+ import type {
12
+ AggregatePerSpacePlan,
13
+ ContractSpaceAggregate,
14
+ } from '@prisma-next/migration-tools/aggregate';
15
+ import { ifDefined } from '@prisma-next/utils/defined';
16
+ import { notOk, ok, type Result } from '@prisma-next/utils/result';
17
+ import { errorRunnerFailed } from '../../utils/cli-errors';
18
+ import type { AggregatePerSpaceExecutionEntry, OnControlProgress } from '../types';
19
+
20
+ /**
21
+ * Span id emitted via `onProgress` for the apply phase. Stable
22
+ * identifier consumed by the structured-output renderer and by tests.
23
+ */
24
+ const APPLY_SPAN_ID = 'apply' as const;
25
+
26
+ /**
27
+ * Action that originated this apply call. Threaded into `OnControlProgress`
28
+ * events so the parent CLI command can attribute the span correctly,
29
+ * and used to compose action-specific summary phrasing.
30
+ */
31
+ export type AggregateApplyAction = 'dbInit' | 'dbUpdate' | 'migrationApply';
32
+
33
+ /**
34
+ * Failure variant emitted by {@link applyAggregate} when the multi-space
35
+ * runner itself rejects the apply. Mirrors the failure shape callers
36
+ * already wrap into their own action-specific failure envelopes
37
+ * (`DbInitFailure`, `DbUpdateFailure`, `MigrationApplyFailure`) so each
38
+ * caller keeps owning its own discriminated failure code.
39
+ */
40
+ export interface AggregateApplyRunnerFailure {
41
+ readonly summary: string;
42
+ readonly why?: string;
43
+ readonly meta: Record<string, unknown>;
44
+ }
45
+
46
+ export interface ApplyAggregateInputs<TFamilyId extends string, TTargetId extends string> {
47
+ readonly aggregate: ContractSpaceAggregate;
48
+ /**
49
+ * Per-space plans, keyed by `spaceId`. Produced by either the full
50
+ * {@link planAggregate} pipeline (`db init` / `db update` — synth
51
+ * for the app, graph-walk for extensions) or by direct
52
+ * {@link graphWalkStrategy} calls (`migration apply` — graph-walk
53
+ * for every member). Either way, the runner consumes the same shape.
54
+ */
55
+ readonly perSpacePlans: ReadonlyMap<string, AggregatePerSpacePlan>;
56
+ /**
57
+ * Canonical schedule order — extensions alphabetically by `spaceId`,
58
+ * then app. Mirrors {@link import('@prisma-next/migration-tools/concatenate-space-apply-inputs').concatenateSpaceApplyInputs}'s
59
+ * convention so `MultiSpaceRunnerFailure.failingSpace` attribution
60
+ * stays byte-for-byte stable across callers.
61
+ */
62
+ readonly applyOrder: readonly string[];
63
+ readonly driver: ControlDriverInstance<TFamilyId, TTargetId>;
64
+ readonly familyInstance: ControlFamilyInstance<TFamilyId, unknown>;
65
+ readonly migrations: TargetMigrationsCapability<
66
+ TFamilyId,
67
+ TTargetId,
68
+ ControlFamilyInstance<TFamilyId, unknown>
69
+ >;
70
+ readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<TFamilyId, TTargetId>>;
71
+ readonly policy: MigrationOperationPolicy;
72
+ readonly action: AggregateApplyAction;
73
+ readonly onProgress?: OnControlProgress;
74
+ }
75
+
76
+ /**
77
+ * Resolved per-space plan in canonical schedule order. Surfaced from
78
+ * {@link applyAggregate} to callers so each one can build its own
79
+ * action-specific success envelope (e.g. `DbInitSuccess` vs
80
+ * `MigrationApplySuccess`) without re-deriving the ordering.
81
+ */
82
+ export interface OrderedResolution {
83
+ readonly spaceId: string;
84
+ readonly entry: AggregatePerSpacePlan;
85
+ }
86
+
87
+ export interface ApplyAggregateValue {
88
+ readonly orderedResolutions: readonly OrderedResolution[];
89
+ readonly totalOpsPlanned: number;
90
+ readonly totalOpsExecuted: number;
91
+ /**
92
+ * Per-space breakdown ready to thread into action-specific success
93
+ * envelopes. Each entry carries the post-apply marker (live storage hash
94
+ * plus invariants) so callers can render it directly without re-reading.
95
+ */
96
+ readonly perSpace: readonly AggregatePerSpaceExecutionEntry[];
97
+ }
98
+
99
+ export type ApplyAggregateResult = Result<ApplyAggregateValue, AggregateApplyRunnerFailure>;
100
+
101
+ /**
102
+ * Runner-driving tail shared by every aggregate apply caller — `db init`,
103
+ * `db update`, and `migration apply`. Consumes already-resolved per-space
104
+ * plans (the planner-vs-replay distinction is owned by the caller) and
105
+ * dispatches them to the multi-space runner in canonical order.
106
+ *
107
+ * Marker advancement is part of the runner's per-space transaction
108
+ * (the SQL family runner writes the marker as the last step of each
109
+ * space's transaction), so this primitive does not advance markers
110
+ * separately — by the time `executeAcrossSpaces` returns ok, every
111
+ * space's marker has been advanced to its plan's destination.
112
+ *
113
+ * Span emission (`spanStart 'apply'` / `spanEnd 'apply'`) is owned here
114
+ * so callers don't have to duplicate it; the `action` field on each
115
+ * progress event is taken from the caller's `action` argument.
116
+ */
117
+ export async function applyAggregate<TFamilyId extends string, TTargetId extends string>(
118
+ inputs: ApplyAggregateInputs<TFamilyId, TTargetId>,
119
+ ): Promise<ApplyAggregateResult> {
120
+ const {
121
+ aggregate,
122
+ perSpacePlans,
123
+ applyOrder,
124
+ driver,
125
+ familyInstance,
126
+ migrations,
127
+ frameworkComponents,
128
+ policy,
129
+ action,
130
+ onProgress,
131
+ } = inputs;
132
+
133
+ const orderedResolutions = collectOrdered(applyOrder, perSpacePlans);
134
+
135
+ const runner = migrations.createRunner(familyInstance);
136
+ if (!hasMultiSpaceRunner(runner)) {
137
+ throw errorRunnerFailed(
138
+ `Runner for target "${aggregate.targetId}" does not implement \`executeAcrossSpaces\``,
139
+ {
140
+ why: `${labelForAction(action)} requires multi-space-capable runners (today: every SQL family runner).`,
141
+ },
142
+ );
143
+ }
144
+
145
+ onProgress?.({
146
+ action,
147
+ kind: 'spanStart',
148
+ spanId: APPLY_SPAN_ID,
149
+ label: progressLabelForAction(action),
150
+ });
151
+
152
+ const perSpaceOptions: MultiSpaceRunnerPerSpaceOptions<TFamilyId, TTargetId>[] =
153
+ orderedResolutions.map((r) => ({
154
+ space: r.spaceId,
155
+ plan: r.entry.plan,
156
+ driver,
157
+ destinationContract: r.entry.destinationContract,
158
+ policy,
159
+ frameworkComponents,
160
+ // Per-space post-apply schema verification is non-strict: each
161
+ // space's `destinationContract` describes only its own slice; a
162
+ // strict verifier would treat every other space's tables as
163
+ // `extras`. Tolerant mode still catches missing tables / columns.
164
+ // SQL family runners read `strictVerification` via structural
165
+ // typing.
166
+ strictVerification: false,
167
+ })) as MultiSpaceRunnerPerSpaceOptions<TFamilyId, TTargetId>[];
168
+
169
+ const runnerResult = await (
170
+ runner as MultiSpaceCapableRunner<TFamilyId, TTargetId>
171
+ ).executeAcrossSpaces({ driver, perSpaceOptions });
172
+
173
+ if (!runnerResult.ok) {
174
+ onProgress?.({ action, kind: 'spanEnd', spanId: APPLY_SPAN_ID, outcome: 'error' });
175
+ return notOk({
176
+ summary: runnerResult.failure.summary,
177
+ ...ifDefined('why', runnerResult.failure.why),
178
+ meta: {
179
+ ...(runnerResult.failure.meta ?? {}),
180
+ failingSpace: runnerResult.failure.failingSpace,
181
+ },
182
+ });
183
+ }
184
+ onProgress?.({ action, kind: 'spanEnd', spanId: APPLY_SPAN_ID, outcome: 'ok' });
185
+
186
+ const totalOpsPlanned = runnerResult.value.perSpaceResults.reduce(
187
+ (sum, r) => sum + r.value.operationsPlanned,
188
+ 0,
189
+ );
190
+ const totalOpsExecuted = runnerResult.value.perSpaceResults.reduce(
191
+ (sum, r) => sum + r.value.operationsExecuted,
192
+ 0,
193
+ );
194
+
195
+ const perSpace = buildPerSpaceBreakdown(orderedResolutions, aggregate.app.spaceId, {
196
+ includeMarkers: true,
197
+ });
198
+
199
+ return ok({
200
+ orderedResolutions,
201
+ totalOpsPlanned,
202
+ totalOpsExecuted,
203
+ perSpace,
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Project the planner's per-space resolutions into the
209
+ * `AggregatePerSpaceExecutionEntry[]` shape the CLI surfaces.
210
+ *
211
+ * `includeMarkers` is `true` for apply-mode (each space's marker is
212
+ * the `destination.storageHash` of its plan, which the runner
213
+ * advances as the last step of each space's transaction) and `false`
214
+ * for plan-mode (no marker has been written yet).
215
+ *
216
+ * Exported alongside {@link applyAggregate} so plan-mode callers can
217
+ * assemble the same per-space block without going through the runner.
218
+ */
219
+ export function buildPerSpaceBreakdown(
220
+ orderedResolutions: readonly OrderedResolution[],
221
+ appSpaceId: string,
222
+ options: { readonly includeMarkers: boolean },
223
+ ): readonly AggregatePerSpaceExecutionEntry[] {
224
+ return orderedResolutions.map((r) => {
225
+ const operations = r.entry.displayOps.map((op) => ({
226
+ id: op.id,
227
+ label: op.label,
228
+ operationClass: op.operationClass,
229
+ }));
230
+ const base: AggregatePerSpaceExecutionEntry = {
231
+ spaceId: r.spaceId,
232
+ kind: r.spaceId === appSpaceId ? 'app' : 'extension',
233
+ operations,
234
+ };
235
+ if (!options.includeMarkers) return base;
236
+ return {
237
+ ...base,
238
+ marker: { storageHash: r.entry.plan.destination.storageHash },
239
+ };
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Materialise the `applyOrder` ordering into resolved per-space
245
+ * entries. Throws if the planner output is missing a member listed
246
+ * in `applyOrder` — a wiring bug that should never reach runtime.
247
+ *
248
+ * Exported so callers building their own success envelopes after a
249
+ * plan-mode dispatch can replay the same ordering.
250
+ */
251
+ export function collectOrdered(
252
+ applyOrder: readonly string[],
253
+ perSpace: ReadonlyMap<string, AggregatePerSpacePlan>,
254
+ ): readonly OrderedResolution[] {
255
+ return applyOrder.map((spaceId) => {
256
+ const entry = perSpace.get(spaceId);
257
+ if (!entry) {
258
+ throw new Error(`Aggregate planner output missing per-space plan for "${spaceId}"`);
259
+ }
260
+ return { spaceId, entry };
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Action-appropriate label for the `spanStart` event the apply
266
+ * primitive emits. `applyAggregate` is shared by `db init`, `db update`,
267
+ * and `migration apply`; the span label tracks the user-visible action
268
+ * so structured-progress output reads naturally for each surface.
269
+ */
270
+ export function progressLabelForAction(action: AggregateApplyAction): string {
271
+ switch (action) {
272
+ case 'dbInit':
273
+ return 'Initialising database across spaces';
274
+ case 'dbUpdate':
275
+ return 'Updating database across spaces';
276
+ case 'migrationApply':
277
+ return 'Applying migration plan across spaces';
278
+ }
279
+ }
280
+
281
+ function labelForAction(action: AggregateApplyAction): string {
282
+ switch (action) {
283
+ case 'dbInit':
284
+ return 'db init';
285
+ case 'dbUpdate':
286
+ return 'db update';
287
+ case 'migrationApply':
288
+ return 'migration apply';
289
+ }
290
+ }