@prisma-next/cli 0.12.0-dev.5 → 0.12.0-dev.50

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 (174) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +180 -163
  3. package/dist/cli.mjs.map +1 -1
  4. package/dist/{client-KgJorIvG.mjs → client-DC-UlBLy.mjs} +83 -58
  5. package/dist/client-DC-UlBLy.mjs.map +1 -0
  6. package/dist/{command-helpers-Bbw1GbwL.mjs → command-helpers-esJGBD4W.mjs} +317 -23
  7. package/dist/command-helpers-esJGBD4W.mjs.map +1 -0
  8. package/dist/commands/contract-emit.mjs +1 -1
  9. package/dist/commands/contract-infer.mjs +1 -1
  10. package/dist/commands/db-init.mjs +4 -5
  11. package/dist/commands/db-init.mjs.map +1 -1
  12. package/dist/commands/db-schema.mjs +3 -3
  13. package/dist/commands/db-sign.mjs +4 -4
  14. package/dist/commands/db-update.d.mts.map +1 -1
  15. package/dist/commands/db-update.mjs +10 -7
  16. package/dist/commands/db-update.mjs.map +1 -1
  17. package/dist/commands/db-verify.mjs +1 -1
  18. package/dist/commands/migrate.d.mts +2 -2
  19. package/dist/commands/migrate.d.mts.map +1 -1
  20. package/dist/commands/migrate.mjs +6 -8
  21. package/dist/commands/migrate.mjs.map +1 -1
  22. package/dist/commands/migration-check.d.mts +55 -13
  23. package/dist/commands/migration-check.d.mts.map +1 -1
  24. package/dist/commands/migration-check.mjs +3 -2
  25. package/dist/commands/migration-graph.d.mts +17 -8
  26. package/dist/commands/migration-graph.d.mts.map +1 -1
  27. package/dist/commands/migration-graph.mjs +183 -2
  28. package/dist/commands/migration-graph.mjs.map +1 -0
  29. package/dist/commands/migration-list.d.mts +25 -27
  30. package/dist/commands/migration-list.d.mts.map +1 -1
  31. package/dist/commands/migration-list.mjs +2 -190
  32. package/dist/commands/migration-log.d.mts +9 -19
  33. package/dist/commands/migration-log.d.mts.map +1 -1
  34. package/dist/commands/migration-log.mjs +1 -137
  35. package/dist/commands/migration-new.mjs +3 -3
  36. package/dist/commands/migration-plan.d.mts +1 -1
  37. package/dist/commands/migration-plan.mjs +1 -1
  38. package/dist/commands/migration-show.d.mts +17 -21
  39. package/dist/commands/migration-show.d.mts.map +1 -1
  40. package/dist/commands/migration-show.mjs +23 -35
  41. package/dist/commands/migration-show.mjs.map +1 -1
  42. package/dist/commands/migration-status.d.mts +42 -144
  43. package/dist/commands/migration-status.d.mts.map +1 -1
  44. package/dist/commands/migration-status.mjs +3 -759
  45. package/dist/commands/ref.d.mts +1 -1
  46. package/dist/commands/ref.mjs +3 -3
  47. package/dist/commands/telemetry/index.d.mts +7 -0
  48. package/dist/commands/telemetry/index.d.mts.map +1 -0
  49. package/dist/commands/telemetry/index.mjs +2 -0
  50. package/dist/{contract-at-errors-BxP-TOMl.mjs → contract-at-errors-COZAemUl.mjs} +2 -2
  51. package/dist/{contract-at-errors-BxP-TOMl.mjs.map → contract-at-errors-COZAemUl.mjs.map} +1 -1
  52. package/dist/{contract-emit-DxcGl4Uq.mjs → contract-emit-Bv46RAIO.mjs} +3 -3
  53. package/dist/{contract-emit-DxcGl4Uq.mjs.map → contract-emit-Bv46RAIO.mjs.map} +1 -1
  54. package/dist/{contract-emit-D-4jrNve.mjs → contract-emit-DIWImLqS.mjs} +5 -5
  55. package/dist/{contract-emit-D-4jrNve.mjs.map → contract-emit-DIWImLqS.mjs.map} +1 -1
  56. package/dist/{contract-infer-D8uEbJuu.mjs → contract-infer-DpGN9SAj.mjs} +3 -3
  57. package/dist/{contract-infer-D8uEbJuu.mjs.map → contract-infer-DpGN9SAj.mjs.map} +1 -1
  58. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs → contract-space-aggregate-loader-CpNVrBqW.mjs} +63 -5
  59. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs.map → contract-space-aggregate-loader-CpNVrBqW.mjs.map} +1 -1
  60. package/dist/{db-verify-v_vUKXTU.mjs → db-verify-Cq16Obsw.mjs} +4 -4
  61. package/dist/{db-verify-v_vUKXTU.mjs.map → db-verify-Cq16Obsw.mjs.map} +1 -1
  62. package/dist/exports/control-api.d.mts +2 -2
  63. package/dist/exports/control-api.d.mts.map +1 -1
  64. package/dist/exports/control-api.mjs +2 -2
  65. package/dist/exports/index.mjs +1 -1
  66. package/dist/exports/init-output.mjs +1 -1
  67. package/dist/{framework-components-fYXjz_in.mjs → framework-components-BO9VO43s.mjs} +2 -2
  68. package/dist/{framework-components-fYXjz_in.mjs.map → framework-components-BO9VO43s.mjs.map} +1 -1
  69. package/dist/{global-flags-DEHjV8_s.d.mts → global-flags-CV5LhrFg.d.mts} +1 -1
  70. package/dist/{global-flags-DEHjV8_s.d.mts.map → global-flags-CV5LhrFg.d.mts.map} +1 -1
  71. package/dist/{init-Cv9UzWL5.mjs → init-C0rjiQ9I.mjs} +5 -58
  72. package/dist/init-C0rjiQ9I.mjs.map +1 -0
  73. package/dist/{inspect-live-schema-C6ohV_oQ.mjs → inspect-live-schema-CRDKTNcf.mjs} +3 -3
  74. package/dist/{inspect-live-schema-C6ohV_oQ.mjs.map → inspect-live-schema-CRDKTNcf.mjs.map} +1 -1
  75. package/dist/migration-check-BxWlQBOs.mjs +573 -0
  76. package/dist/migration-check-BxWlQBOs.mjs.map +1 -0
  77. package/dist/{migration-command-scaffold-CjvwO6at.mjs → migration-command-scaffold-BDd9abqW.mjs} +3 -3
  78. package/dist/{migration-command-scaffold-CjvwO6at.mjs.map → migration-command-scaffold-BDd9abqW.mjs.map} +1 -1
  79. package/dist/migration-graph-space-render-CeNXh_Wy.mjs +1966 -0
  80. package/dist/migration-graph-space-render-CeNXh_Wy.mjs.map +1 -0
  81. package/dist/migration-list-vJWFuXca.mjs +228 -0
  82. package/dist/migration-list-vJWFuXca.mjs.map +1 -0
  83. package/dist/migration-log-6rcHQSI4.mjs +222 -0
  84. package/dist/migration-log-6rcHQSI4.mjs.map +1 -0
  85. package/dist/migration-path-target-UkxkgXnv.mjs +38 -0
  86. package/dist/migration-path-target-UkxkgXnv.mjs.map +1 -0
  87. package/dist/{migration-plan-9DJ7q7_z.mjs → migration-plan-CHu_erQ5.mjs} +5 -6
  88. package/dist/{migration-plan-9DJ7q7_z.mjs.map → migration-plan-CHu_erQ5.mjs.map} +1 -1
  89. package/dist/migration-status-Bjv91dE7.mjs +444 -0
  90. package/dist/migration-status-Bjv91dE7.mjs.map +1 -0
  91. package/dist/{output-B60Gw5fu.mjs → output-BD61elic.mjs} +1 -1
  92. package/dist/{output-B60Gw5fu.mjs.map → output-BD61elic.mjs.map} +1 -1
  93. package/dist/{ref-advancement-DUZqsue6.mjs → ref-advancement-CJY9zOv7.mjs} +1 -1
  94. package/dist/{ref-advancement-DUZqsue6.mjs.map → ref-advancement-CJY9zOv7.mjs.map} +1 -1
  95. package/dist/schemas-BL33A3i-.d.mts +193 -0
  96. package/dist/schemas-BL33A3i-.d.mts.map +1 -0
  97. package/dist/schemas-DJY2O09F.mjs +112 -0
  98. package/dist/schemas-DJY2O09F.mjs.map +1 -0
  99. package/dist/telemetry-CZkgkR_O.mjs +122 -0
  100. package/dist/telemetry-CZkgkR_O.mjs.map +1 -0
  101. package/dist/{terminal-ui-5Y6mrg93.d.mts → terminal-ui-BgLiAOYi.d.mts} +1 -1
  102. package/dist/{terminal-ui-5Y6mrg93.d.mts.map → terminal-ui-BgLiAOYi.d.mts.map} +1 -1
  103. package/dist/{types-Dt_SfqFm.d.mts → types-qV41eEXH.d.mts} +44 -31
  104. package/dist/types-qV41eEXH.d.mts.map +1 -0
  105. package/dist/{verify-DCA9Sldu.mjs → verify-IilvIk_E.mjs} +2 -2
  106. package/dist/{verify-DCA9Sldu.mjs.map → verify-IilvIk_E.mjs.map} +1 -1
  107. package/package.json +22 -19
  108. package/src/cli.ts +5 -0
  109. package/src/commands/db-update.ts +7 -1
  110. package/src/commands/init/index.ts +6 -35
  111. package/src/commands/init/init.ts +1 -14
  112. package/src/commands/init/inputs.ts +0 -75
  113. package/src/commands/json/schemas.ts +195 -0
  114. package/src/commands/migrate.ts +6 -6
  115. package/src/commands/migration-check.ts +469 -134
  116. package/src/commands/migration-graph.ts +162 -91
  117. package/src/commands/migration-list.ts +69 -39
  118. package/src/commands/migration-log.ts +52 -102
  119. package/src/commands/migration-show.ts +31 -66
  120. package/src/commands/migration-status-overlay.ts +61 -0
  121. package/src/commands/migration-status.ts +453 -1067
  122. package/src/commands/telemetry/index.ts +107 -0
  123. package/src/commands/telemetry/status.ts +67 -0
  124. package/src/control-api/client.ts +20 -9
  125. package/src/control-api/operations/contract-emit.ts +2 -2
  126. package/src/control-api/operations/db-init.ts +3 -3
  127. package/src/control-api/operations/{db-apply.ts → db-run.ts} +37 -10
  128. package/src/control-api/operations/db-update.ts +4 -4
  129. package/src/control-api/operations/{migration-apply.ts → migrate.ts} +32 -24
  130. package/src/control-api/operations/{apply.ts → run-migration.ts} +33 -27
  131. package/src/control-api/types.ts +46 -29
  132. package/src/utils/cli-errors.ts +70 -2
  133. package/src/utils/formatters/errors.ts +11 -0
  134. package/src/utils/formatters/migration-graph-lane-colors.ts +194 -0
  135. package/src/utils/formatters/migration-graph-layout.ts +51 -7
  136. package/src/utils/formatters/migration-graph-rows.ts +128 -15
  137. package/src/utils/formatters/migration-graph-space-render.ts +138 -0
  138. package/src/utils/formatters/migration-graph-tree-render.ts +405 -77
  139. package/src/utils/formatters/migration-list-data-column.ts +4 -91
  140. package/src/utils/formatters/migration-list-graph-topology.ts +72 -94
  141. package/src/utils/formatters/migration-list-render.ts +123 -71
  142. package/src/utils/formatters/migration-list-styler.ts +48 -5
  143. package/src/utils/formatters/migration-list-types.ts +5 -21
  144. package/src/utils/formatters/migration-log-table.ts +205 -0
  145. package/src/utils/formatters/migrations.ts +33 -11
  146. package/src/utils/global-flags.ts +35 -0
  147. package/src/utils/integrity-violation-to-check-failure.ts +28 -19
  148. package/src/utils/legend.ts +38 -0
  149. package/src/utils/migration-path-target.ts +60 -0
  150. package/src/utils/telemetry.ts +68 -32
  151. package/dist/client-KgJorIvG.mjs.map +0 -1
  152. package/dist/command-helpers-Bbw1GbwL.mjs.map +0 -1
  153. package/dist/commands/migration-list.mjs.map +0 -1
  154. package/dist/commands/migration-log.mjs.map +0 -1
  155. package/dist/commands/migration-status.mjs.map +0 -1
  156. package/dist/extension-pack-inputs-IDvjRCi3.mjs +0 -62
  157. package/dist/extension-pack-inputs-IDvjRCi3.mjs.map +0 -1
  158. package/dist/graph-render-rFAqZujX.mjs +0 -1081
  159. package/dist/graph-render-rFAqZujX.mjs.map +0 -1
  160. package/dist/init-Cv9UzWL5.mjs.map +0 -1
  161. package/dist/migration-check-BiBJoYYW.mjs +0 -341
  162. package/dist/migration-check-BiBJoYYW.mjs.map +0 -1
  163. package/dist/migration-graph-D7DVUElV.mjs +0 -1232
  164. package/dist/migration-graph-D7DVUElV.mjs.map +0 -1
  165. package/dist/migration-list-styler-BRwF4-gy.mjs +0 -399
  166. package/dist/migration-list-styler-BRwF4-gy.mjs.map +0 -1
  167. package/dist/migration-types-D2FW63pr.d.mts +0 -15
  168. package/dist/migration-types-D2FW63pr.d.mts.map +0 -1
  169. package/dist/migrations-Cv2jxNNK.mjs +0 -228
  170. package/dist/migrations-Cv2jxNNK.mjs.map +0 -1
  171. package/dist/types-Dt_SfqFm.d.mts.map +0 -1
  172. package/src/utils/formatters/graph-migration-mapper.ts +0 -235
  173. package/src/utils/formatters/graph-render.ts +0 -1323
  174. package/src/utils/formatters/graph-types.ts +0 -120
@@ -1,14 +1,7 @@
1
- import type { Contract } from '@prisma-next/contract/types';
2
- import {
3
- createControlStack,
4
- type MigrationPlanOperation,
5
- } from '@prisma-next/framework-components/control';
6
- import {
7
- type ContractMarkerRecordLike,
8
- type ContractSpaceAggregate,
9
- graphWalkStrategy,
10
- loadContractSpaceAggregate,
11
- requireHeadRef,
1
+ import type { LedgerEntryRecord } from '@prisma-next/contract/types';
2
+ import type {
3
+ ContractMarkerRecordLike,
4
+ ContractSpaceMember,
12
5
  } from '@prisma-next/migration-tools/aggregate';
13
6
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
14
7
  import {
@@ -16,29 +9,22 @@ import {
16
9
  errorUnknownInvariant,
17
10
  MigrationToolsError,
18
11
  } from '@prisma-next/migration-tools/errors';
19
- import type { MigrationEdge, MigrationGraph } from '@prisma-next/migration-tools/graph';
20
- import {
21
- findPath,
22
- findPathWithDecision,
23
- findReachableLeaves,
24
- } from '@prisma-next/migration-tools/migration-graph';
25
- import type { OnDiskMigrationPackage } from '@prisma-next/migration-tools/package';
12
+ import { findPath, findPathWithDecision } from '@prisma-next/migration-tools/migration-graph';
26
13
  import { parseContractRef } from '@prisma-next/migration-tools/ref-resolution';
27
14
  import type { RefEntry, Refs } from '@prisma-next/migration-tools/refs';
28
15
  import { readRefs } from '@prisma-next/migration-tools/refs';
29
16
  import { ifDefined } from '@prisma-next/utils/defined';
30
17
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
31
- import { cyan, dim, magenta, yellow } from 'colorette';
18
+ import { dim, yellow } from 'colorette';
32
19
  import { Command } from 'commander';
33
-
34
20
  import { loadConfig } from '../config-loader';
35
21
  import { createControlClient } from '../control-api/client';
36
22
  import {
37
23
  CliStructuredError,
38
- errorRuntime,
39
24
  errorUnexpected,
40
25
  mapMigrationToolsError,
41
26
  mapRefResolutionError,
27
+ requireLiveDatabase,
42
28
  } from '../utils/cli-errors';
43
29
  import {
44
30
  addGlobalOptions,
@@ -49,537 +35,265 @@ import {
49
35
  setCommandDescriptions,
50
36
  setCommandExamples,
51
37
  setCommandSeeAlso,
52
- toPathDecisionResult,
53
38
  toStructuralEdge,
54
39
  } from '../utils/command-helpers';
55
40
  import {
56
- appContractStandInFromIdentity,
41
+ buildReadAggregate,
57
42
  loadContractRawSafely,
58
- refuseContractSpaceIntegrity,
59
43
  refusePackageCorruptionOnAggregate,
60
44
  } from '../utils/contract-space-aggregate-loader';
61
- import { toDeclaredExtensionsFromRaw } from '../utils/extension-pack-inputs';
62
- import {
63
- type EdgeStatus,
64
- type EdgeStatusKind,
65
- migrationGraphToRenderInput,
66
- } from '../utils/formatters/graph-migration-mapper';
67
45
  import {
68
- extractRelevantSubgraph,
69
- graphRenderer,
70
- isLinearGraph,
71
- } from '../utils/formatters/graph-render';
46
+ computeGlobalMaxDirNameWidth,
47
+ computeGlobalMaxEdgeTreePrefixWidth,
48
+ indentMigrationGraphTreeBlock,
49
+ renderMigrationGraphSpaceTree,
50
+ } from '../utils/formatters/migration-graph-space-render';
51
+ import type { MigrationEdgeAnnotation } from '../utils/formatters/migration-graph-tree-render';
52
+ import { renderMigrationGraphLegend } from '../utils/formatters/migration-graph-tree-render';
53
+ import type { MigrationListEntry } from '../utils/formatters/migration-list-types';
72
54
  import { formatStyledHeader } from '../utils/formatters/styled';
73
55
  import type { CommonCommandOptions } from '../utils/global-flags';
74
56
  import { type GlobalFlags, parseGlobalFlagsOrExit } from '../utils/global-flags';
75
- import type { StatusDiagnostic, StatusRef } from '../utils/migration-types';
57
+ import { shouldShowLegend, validateLegendOptions } from '../utils/legend';
76
58
  import { handleResult } from '../utils/result-handler';
77
59
  import { createTerminalUI, type TerminalUI } from '../utils/terminal-ui';
78
-
79
- interface MigrationStatusOptions extends CommonCommandOptions {
60
+ import type {
61
+ MigrationStatusEntry,
62
+ MigrationStatusResult,
63
+ MigrationStatusSpace,
64
+ StatusDiagnosticJson,
65
+ } from './json/schemas';
66
+ import { migrationStatusJsonResultSchema } from './json/schemas';
67
+ import {
68
+ listRefsByContractHash,
69
+ migrationSpaceListEntriesFromAggregate,
70
+ runMigrationList,
71
+ } from './migration-list';
72
+ import {
73
+ appliedHashesFromLedger,
74
+ deriveStatusEdgeAnnotations,
75
+ statusForMigrationHash,
76
+ } from './migration-status-overlay';
77
+
78
+ export type { StatusRef } from '../utils/migration-types';
79
+ export type {
80
+ MigrationStatusEntry,
81
+ MigrationStatusResult,
82
+ MigrationStatusSpace,
83
+ StatusDiagnosticJson,
84
+ };
85
+ export { migrationStatusJsonResultSchema };
86
+
87
+ export interface MigrationStatusOptions extends CommonCommandOptions {
80
88
  readonly db?: string;
81
89
  readonly config?: string;
82
90
  readonly to?: string;
83
91
  readonly from?: string;
92
+ readonly space?: string;
93
+ readonly legend?: boolean;
94
+ readonly ascii?: boolean;
84
95
  }
85
96
 
86
- export interface MigrationStatusEntry {
87
- readonly dirName: string;
88
- readonly from: string;
89
- readonly to: string;
90
- readonly migrationHash: string;
91
- readonly operationCount: number;
92
- readonly operationSummary: string;
93
- readonly hasDestructive: boolean;
94
- readonly status: EdgeStatusKind | 'unknown';
95
- }
96
-
97
- /**
98
- * Per-space status row in the aggregate-shaped status output.
99
- *
100
- * Surfaces, for each contract space:
101
- *
102
- * - `headHash`: the on-disk head ref's hash (where the space is going).
103
- * - `markerHash`: the live marker hash for the space, or null if no
104
- * marker has been written yet (greenfield, or pre-`migrate`).
105
- * - `pendingCount`: number of migration edges between marker and head.
106
- * Computed via {@link graphWalkStrategy}; 0 means the space is
107
- * already at head.
108
- * - `status`: convenience tag the formatter uses to pick a glyph.
109
- * `'never-planned'` is reserved for spaces with non-empty head but
110
- * no on-disk migrations — which shouldn't happen if the loader's
111
- * integrity check passes.
112
- *
113
- * Online-only fields (`markerHash`, `status`) are absent when the
114
- * command runs without a database connection.
115
- */
116
- export interface MigrationStatusSpaceEntry {
117
- readonly spaceId: string;
118
- readonly kind: 'app' | 'extension';
119
- readonly headHash: string;
120
- readonly markerHash?: string | null;
121
- readonly pendingCount?: number;
122
- readonly status?: 'up-to-date' | 'pending' | 'no-marker' | 'never-planned' | 'unreachable';
97
+ export interface MigrationStatusTreeSection {
98
+ readonly space: string;
99
+ readonly tree: string;
100
+ readonly showHeading: boolean;
123
101
  }
124
102
 
125
- /**
126
- * Sum per-space `pendingCount` into a cross-space total, but only when
127
- * every loaded space reports a defined `pendingCount`. Returns
128
- * `undefined` if any space is on the marker-unknown / offline path
129
- * (where `pendingCount` is intentionally absent), so JSON consumers can
130
- * distinguish "no pending" from "unknown".
131
- */
132
- export function computeTotalPendingAcrossSpaces(
133
- spaces: readonly MigrationStatusSpaceEntry[],
134
- ): number | undefined {
135
- if (spaces.length === 0) return undefined;
136
- let total = 0;
137
- for (const s of spaces) {
138
- if (s.pendingCount === undefined) return undefined;
139
- total += s.pendingCount;
140
- }
141
- return total;
142
- }
143
-
144
- export type { StatusDiagnostic, StatusRef } from '../utils/migration-types';
145
-
146
- export interface MigrationStatusResult {
103
+ interface MigrationStatusCommandResult {
147
104
  readonly ok: true;
148
- readonly mode: 'online' | 'offline';
149
- readonly migrations: readonly MigrationStatusEntry[];
150
- readonly markerHash?: string;
151
- readonly targetHash: string;
152
- readonly contractHash: string;
153
- readonly refs?: readonly StatusRef[];
154
- /** Required invariants from the active ref, sorted ascending. Always present (`[]` when no `--ref` or the ref declares none) — knowable offline. */
155
- readonly requiredInvariants: readonly string[];
156
- /**
157
- * Invariants the marker has applied at least once, intersected with
158
- * `requiredInvariants` for display relevance. JSON consumers see only the
159
- * subset overlapping the active ref's required set — the full unfiltered
160
- * marker invariant list lives on `marker.invariants` (control plane) and
161
- * is not surfaced here. Present only in `mode === 'online'`; absent when
162
- * offline (the marker is unknown, not empty).
163
- */
164
- readonly appliedInvariants?: readonly string[];
165
- /** required − applied. Present only in `mode === 'online'`; absent when offline. */
166
- readonly missingInvariants?: readonly string[];
167
- readonly pathDecision?: {
168
- readonly fromHash: string;
169
- readonly toHash: string;
170
- readonly alternativeCount: number;
171
- readonly tieBreakReasons: readonly string[];
172
- readonly refName?: string;
173
- readonly requiredInvariants: readonly string[];
174
- readonly satisfiedInvariants: readonly string[];
175
- readonly selectedPath: readonly {
176
- readonly dirName: string;
177
- readonly migrationHash: string;
178
- readonly from: string;
179
- readonly to: string;
180
- readonly invariants: readonly string[];
181
- }[];
182
- };
105
+ readonly spaces: readonly MigrationStatusSpace[];
183
106
  readonly summary: string;
184
- readonly diagnostics: readonly StatusDiagnostic[];
185
- /**
186
- * Aggregate enumeration of every on-disk contract space (app +
187
- * extensions), in canonical schedule order (extensions
188
- * alphabetically, then app). Present whenever the aggregate loader
189
- * succeeded; absent in early-error returns (e.g. unreadable
190
- * migrations directory) where the existing diagnostics already
191
- * surface the failure.
192
- *
193
- * The top-level fields (`migrations`, `markerHash`, `targetHash`,
194
- * `pathDecision`, …) describe the **app member** specifically.
195
- * Per-space detail for extension members lives only on this list.
196
- */
197
- readonly spaces?: readonly MigrationStatusSpaceEntry[];
198
- /** Cross-space pending-migration total (sum of `spaces[].pendingCount`). Present when `spaces` is. */
199
- readonly totalPendingAcrossSpaces?: number;
200
- readonly graph?: MigrationGraph;
201
- readonly bundles?: readonly OnDiskMigrationPackage[];
202
- readonly edgeStatuses?: readonly EdgeStatus[];
203
- readonly activeRefHash?: string;
204
- readonly activeRefName?: string;
205
- readonly diverged?: boolean;
107
+ readonly diagnostics: readonly StatusDiagnosticJson[];
108
+ readonly treeSections: readonly MigrationStatusTreeSection[];
206
109
  }
207
110
 
208
- function summarizeOps(ops: readonly MigrationPlanOperation[]): {
209
- summary: string;
210
- hasDestructive: boolean;
211
- } {
212
- if (ops.length === 0) return { summary: '0 ops', hasDestructive: false };
213
-
214
- const classes = new Map<string, number>();
215
- for (const op of ops) {
216
- classes.set(op.operationClass, (classes.get(op.operationClass) ?? 0) + 1);
217
- }
218
-
219
- const hasDestructive = classes.has('destructive');
220
- const count = ops.length;
221
- const noun = count === 1 ? 'op' : 'ops';
222
-
223
- if (classes.size === 1) {
224
- const cls = [...classes.keys()][0]!;
225
- return { summary: `${count} ${noun} (all ${cls})`, hasDestructive };
226
- }
227
-
228
- const destructiveCount = classes.get('destructive');
229
- if (destructiveCount) {
230
- return { summary: `${count} ${noun} (${destructiveCount} destructive)`, hasDestructive };
231
- }
232
-
233
- const parts = [...classes.entries()].map(([cls, n]) => `${n} ${cls}`);
234
- return { summary: `${count} ${noun} (${parts.join(', ')})`, hasDestructive };
111
+ function shortDisplayHash(hash: string): string {
112
+ const stripped = hash.startsWith('sha256:') ? hash.slice(7) : hash;
113
+ return stripped.slice(0, 12);
235
114
  }
236
115
 
237
- /**
238
- * Derive per-edge status across the full graph using path analysis.
239
- *
240
- * - **applied**: edge is on the path from root to the DB marker
241
- * - **pending**: edge is on the path from the DB marker to the target
242
- * (and the marker is reachable from root, i.e. it's on the same branch)
243
- * - **unreachable**: edge is on the path from root to the target but the DB
244
- * marker is on a different branch — `apply` can't reach these edges
245
- * without the DB first moving to this branch
246
- *
247
- * Returns statuses only for edges that have a known status (skips offline
248
- * and edges not on any relevant path).
249
- *
250
- * @internal Exported for testing only.
251
- */
252
- export function deriveEdgeStatuses(
253
- graph: MigrationGraph,
254
- targetHash: string,
255
- contractHash: string,
256
- markerHash: string | undefined,
257
- mode: 'online' | 'offline',
258
- ): EdgeStatus[] {
259
- if (mode === 'offline') return [];
260
-
261
- const edgeKey = (e: MigrationEdge) => `${e.from}\0${e.to}`;
262
-
263
- // No marker = empty DB — treat root as the marker (nothing applied, everything pending)
264
- const effectiveMarker = markerHash ?? EMPTY_CONTRACT_HASH;
265
-
266
- const appliedPath =
267
- markerHash !== undefined ? findPath(graph, EMPTY_CONTRACT_HASH, markerHash) : null;
116
+ function resolveTarget(contractHash: string, activeRefHash: string | undefined): string {
117
+ return activeRefHash ?? contractHash;
118
+ }
268
119
 
269
- const pendingPath = findPath(graph, effectiveMarker, targetHash);
270
- const targetPath = findPath(graph, EMPTY_CONTRACT_HASH, targetHash);
120
+ function buildStatusMigrations(
121
+ listMigrations: readonly MigrationListEntry[],
122
+ annotations: ReadonlyMap<string, MigrationEdgeAnnotation>,
123
+ ): readonly MigrationStatusEntry[] {
124
+ return listMigrations.map((migration) => ({
125
+ ...migration,
126
+ status: statusForMigrationHash(migration.hash, annotations),
127
+ }));
128
+ }
271
129
 
272
- const statuses: EdgeStatus[] = [];
273
- const assignedKeys = new Set<string>();
130
+ function renderSpaceTree(args: {
131
+ readonly member: ContractSpaceMember;
132
+ readonly liveContractHash: string;
133
+ readonly migrations: readonly MigrationListEntry[];
134
+ readonly markerHash: string | undefined;
135
+ readonly showDbMarker: boolean;
136
+ readonly statusOverlay: ReadonlyMap<string, MigrationEdgeAnnotation>;
137
+ readonly colorize: boolean;
138
+ readonly glyphMode: 'unicode' | 'ascii';
139
+ readonly globalMaxEdgeTreePrefixWidth?: number;
140
+ readonly globalMaxDirNameWidth?: number;
141
+ }): string {
142
+ const graph = args.member.graph();
143
+ if (graph.nodes.size === 0) {
144
+ return '';
145
+ }
146
+ return renderMigrationGraphSpaceTree({
147
+ graph,
148
+ migrations: args.migrations,
149
+ liveContractHash: args.liveContractHash,
150
+ refsByHash: listRefsByContractHash(args.member),
151
+ statusOverlayByHash: args.statusOverlay,
152
+ colorize: args.colorize,
153
+ glyphMode: args.glyphMode,
154
+ ...(args.showDbMarker && args.markerHash !== undefined ? { dbHash: args.markerHash } : {}),
155
+ ...(args.globalMaxEdgeTreePrefixWidth !== undefined
156
+ ? { globalMaxEdgeTreePrefixWidth: args.globalMaxEdgeTreePrefixWidth }
157
+ : {}),
158
+ ...(args.globalMaxDirNameWidth !== undefined
159
+ ? { globalMaxDirNameWidth: args.globalMaxDirNameWidth }
160
+ : {}),
161
+ });
162
+ }
274
163
 
275
- // Applied edges (root marker)
276
- if (appliedPath) {
277
- for (const e of appliedPath) {
278
- assignedKeys.add(edgeKey(e));
279
- statuses.push({ dirName: e.dirName, status: 'applied' });
280
- }
281
- }
164
+ function countPending(migrations: readonly MigrationStatusEntry[]): number {
165
+ return migrations.filter((m) => m.status === 'pending').length;
166
+ }
282
167
 
283
- // Pending edges (marker → target)
284
- if (pendingPath) {
285
- for (const e of pendingPath) {
286
- assignedKeys.add(edgeKey(e));
287
- statuses.push({ dirName: e.dirName, status: 'pending' });
288
- }
289
- }
168
+ export function buildNoPathSummary(args: {
169
+ readonly markerHash: string | undefined;
170
+ readonly targetHash: string;
171
+ readonly explicitTarget: boolean;
172
+ readonly refName: string | undefined;
173
+ }): string {
174
+ const markerPart =
175
+ args.markerHash !== undefined
176
+ ? `the database state (${shortDisplayHash(args.markerHash)})`
177
+ : 'the database state';
178
+ const targetShort = shortDisplayHash(args.targetHash);
179
+ if (!args.explicitTarget) {
180
+ return `No migration path from ${markerPart} to the application's contract (${targetShort}). Run \`prisma-next migration plan --name <name>\` to author one.`;
181
+ }
182
+ const targetLabel =
183
+ args.refName !== undefined
184
+ ? `the target (${targetShort} via \`${args.refName}\`)`
185
+ : `the target (${targetShort})`;
186
+ return `No migration path from ${markerPart} to ${targetLabel}. Run \`prisma-next migration plan --name <name>\` to author one, or pass \`--to <contract>\` to pick a reachable target.`;
187
+ }
290
188
 
291
- // Pending edges beyond the target: target → contract (when target is a ref
292
- // and the contract is reachable from it)
293
- if (
294
- contractHash !== EMPTY_CONTRACT_HASH &&
295
- contractHash !== targetHash &&
296
- graph.nodes.has(contractHash)
297
- ) {
298
- const beyondTarget = findPath(graph, targetHash, contractHash);
299
- if (beyondTarget) {
300
- for (const e of beyondTarget) {
301
- if (!assignedKeys.has(edgeKey(e))) {
302
- assignedKeys.add(edgeKey(e));
303
- statuses.push({ dirName: e.dirName, status: 'pending' });
304
- }
305
- }
306
- }
189
+ export function buildStatusHeadline(args: {
190
+ readonly pendingCount: number;
191
+ readonly targetHash: string;
192
+ readonly markerDiverged: boolean;
193
+ readonly markerHash: string | undefined;
194
+ }): string {
195
+ if (args.markerDiverged && args.markerHash !== undefined) {
196
+ return `Database marker ${shortDisplayHash(args.markerHash)} is not in the on-disk migration graph`;
307
197
  }
308
-
309
- // Unreachable edges: on the path from root to the target but neither applied
310
- // nor pending. This covers two cases:
311
- // 1. Marker can't reach target at all (different branch entirely)
312
- // 2. Marker reaches target via a different route, leaving some root→target
313
- // edges orphaned (e.g. a fork where one branch was applied and apply
314
- // will continue through the other)
315
- if (targetPath) {
316
- for (const e of targetPath) {
317
- if (!assignedKeys.has(edgeKey(e))) {
318
- statuses.push({ dirName: e.dirName, status: 'unreachable' });
319
- }
320
- }
198
+ if (args.pendingCount === 0) {
199
+ return 'Up to date';
321
200
  }
322
-
323
- return statuses;
201
+ return `${args.pendingCount} pending — run \`prisma-next migrate --to ${shortDisplayHash(args.targetHash)}\``;
324
202
  }
325
203
 
326
- /**
327
- * @param mode — 'online' if we connected to the database, 'offline' otherwise
328
- * @param markerHash — the marker hash from the database, or undefined if no marker row / offline
329
- */
330
- function buildMigrationEntries(
331
- chain: readonly MigrationEdge[],
332
- packages: readonly OnDiskMigrationPackage[],
333
- mode: 'online' | 'offline',
334
- markerHash: string | undefined,
335
- edgeStatuses?: readonly EdgeStatus[],
336
- ): MigrationStatusEntry[] {
337
- const pkgByDirName = new Map(packages.map((p) => [p.dirName, p]));
338
- const statusByDirName = edgeStatuses
339
- ? new Map(edgeStatuses.map((e) => [e.dirName, e.status]))
340
- : undefined;
341
-
342
- const markerInChain = markerHash === undefined || chain.some((e) => e.to === markerHash);
343
-
344
- const entries: MigrationStatusEntry[] = [];
345
- let reachedMarker = mode === 'online' && markerHash === undefined;
346
-
347
- for (const migration of chain) {
348
- const pkg = pkgByDirName.get(migration.dirName);
349
- const ops = (pkg?.ops ?? []) as readonly MigrationPlanOperation[];
350
- const { summary, hasDestructive } = summarizeOps(ops);
351
-
352
- let status: EdgeStatusKind | 'unknown';
353
- const edgeStatus = statusByDirName?.get(migration.dirName);
354
- if (edgeStatus) {
355
- status = edgeStatus;
356
- } else if (mode === 'offline' || !markerInChain) {
357
- status = 'unknown';
358
- } else if (reachedMarker) {
359
- status = 'pending';
360
- } else {
361
- status = 'applied';
362
- }
363
-
364
- entries.push({
365
- dirName: migration.dirName,
366
- from: migration.from,
367
- to: migration.to,
368
- migrationHash: migration.migrationHash,
369
- operationCount: ops.length,
370
- operationSummary: summary,
371
- hasDestructive,
372
- status,
373
- });
374
-
375
- if (!reachedMarker && migration.to === markerHash) {
376
- reachedMarker = true;
377
- }
204
+ export function formatStatusSummary(
205
+ result: MigrationStatusCommandResult,
206
+ colorize: boolean,
207
+ ): string {
208
+ const c = (fn: (s: string) => string, s: string) => (colorize ? fn(s) : s);
209
+ const lines: string[] = [];
210
+ const pendingTotal = result.spaces.reduce(
211
+ (sum, space) => sum + countPending(space.migrations),
212
+ 0,
213
+ );
214
+ const hasDivergence = result.diagnostics.some(
215
+ (d) => d.code === 'MIGRATION.MARKER_NOT_IN_HISTORY',
216
+ );
217
+ if (hasDivergence || pendingTotal > 0) {
218
+ lines.push(c(yellow, result.summary));
219
+ } else {
220
+ lines.push(result.summary);
378
221
  }
379
-
380
- return entries;
381
- }
382
-
383
- /**
384
- * Resolve the migration chain to display in status output.
385
- *
386
- * When offline or the marker is at EMPTY, the chain is simply the shortest
387
- * path from EMPTY to the target — all structural paths are equivalent per
388
- * the spec, so the deterministic shortest path is the canonical display.
389
- *
390
- * When online with a non-empty marker, the chain routes *through* the marker:
391
- * EMPTY→marker (applied history) + marker→target (pending edges). This ensures
392
- * the displayed chain includes the marker node so applied/pending status is
393
- * correct. Without this, BFS from EMPTY to target could pick a shortest path
394
- * that bypasses the marker entirely (e.g. in a diamond graph), causing the
395
- * marker to appear "diverged" when it isn't.
396
- */
397
- function resolveDisplayChain(
398
- graph: MigrationGraph,
399
- targetHash: string,
400
- markerHash: string | undefined,
401
- ): readonly MigrationEdge[] | null {
402
- if (markerHash === undefined) {
403
- return findPath(graph, EMPTY_CONTRACT_HASH, targetHash);
222
+ const missingInvariantsDiagnostic = result.diagnostics.find(
223
+ (d) => d.code === 'MIGRATION.MISSING_INVARIANTS',
224
+ );
225
+ if (missingInvariantsDiagnostic !== undefined) {
226
+ lines.push(c(dim, missingInvariantsDiagnostic.message));
404
227
  }
405
-
406
- const toMarker = findPath(graph, EMPTY_CONTRACT_HASH, markerHash);
407
- // Marker unreachable from EMPTY — show the target chain anyway.
408
- // The caller detects this via markerInChain and emits a divergence diagnostic.
409
- if (!toMarker) return findPath(graph, EMPTY_CONTRACT_HASH, targetHash);
410
-
411
- if (markerHash === targetHash) return toMarker;
412
-
413
- const fromMarker = findPath(graph, markerHash, targetHash);
414
- if (fromMarker) return [...toMarker, ...fromMarker];
415
-
416
- // Marker is ahead of target (or on a disconnected branch).
417
- // Try the inverse: target→marker. If it succeeds, the marker is ahead —
418
- // show the full chain from EMPTY through the target and on to the marker.
419
- const toTarget = findPath(graph, EMPTY_CONTRACT_HASH, targetHash);
420
- if (!toTarget) return null;
421
-
422
- const targetToMarker = findPath(graph, targetHash, markerHash);
423
- if (targetToMarker) return [...toTarget, ...targetToMarker];
424
-
425
- // Genuinely disconnected — show EMPTY→target; caller handles divergence diagnostic.
426
- return toTarget;
228
+ return lines.join('\n');
427
229
  }
428
230
 
429
- /**
430
- * Build the aggregate enumeration of contract spaces for the status
431
- * output. Loads the aggregate from disk (lossy on failure — extension
432
- * spaces are simply omitted, the app member's output keeps working),
433
- * reads per-space marker rows when online, and uses
434
- * {@link graphWalkStrategy} to compute each space's pending count.
435
- *
436
- * The aggregate-walking status reports per-space marker + pending
437
- * state alongside the cross-space totals.
438
- */
439
- export async function loadAggregateStatusSpaces(args: {
440
- readonly aggregate: ContractSpaceAggregate;
441
- readonly extensionPacks: ReadonlyArray<unknown>;
442
- readonly markersBySpace: ReadonlyMap<string, ContractMarkerRecordLike> | null;
443
- }): Promise<readonly MigrationStatusSpaceEntry[]> {
444
- const declaredExtensions = toDeclaredExtensionsFromRaw(args.extensionPacks);
445
- if (
446
- refuseContractSpaceIntegrity(args.aggregate, {
447
- declaredExtensions,
448
- checkContracts: true,
449
- })
450
- ) {
451
- // Full integrity refusal (drift, layout violation, etc.) — surfacing
452
- // it as a status diagnostic would duplicate `migration plan`'s job.
453
- // The app pipeline still runs; extensions are simply not enumerated.
454
- return [];
455
- }
456
- const aggregate = args.aggregate;
457
-
458
- const orderedMembers = [...aggregate.extensions, aggregate.app];
459
- const rows: MigrationStatusSpaceEntry[] = [];
460
- for (const member of orderedMembers) {
461
- const liveMarker = args.markersBySpace?.get(member.spaceId) ?? null;
462
- const isApp = member.spaceId === aggregate.app.spaceId;
463
- // The aggregate passed the integrity gate above, so every member has
464
- // a resolved head ref (a missing one would have refused the load).
465
- const headRef = requireHeadRef(member);
466
-
467
- if (member.graph().nodes.size === 0) {
468
- rows.push({
469
- spaceId: member.spaceId,
470
- kind: isApp ? 'app' : 'extension',
471
- headHash: headRef.hash,
472
- ...(args.markersBySpace !== null
473
- ? {
474
- markerHash: liveMarker?.storageHash ?? null,
475
- status: headRef.hash === EMPTY_CONTRACT_HASH ? 'up-to-date' : 'never-planned',
476
- pendingCount: 0,
477
- }
478
- : {}),
479
- });
480
- continue;
481
- }
482
-
483
- if (args.markersBySpace === null) {
484
- rows.push({
485
- spaceId: member.spaceId,
486
- kind: isApp ? 'app' : 'extension',
487
- headHash: headRef.hash,
488
- });
489
- continue;
231
+ export function formatStatusHumanOutput(
232
+ result: MigrationStatusCommandResult,
233
+ colorize: boolean,
234
+ ): string {
235
+ const sections: string[] = [];
236
+ for (const section of result.treeSections) {
237
+ if (section.showHeading) {
238
+ sections.push(`${section.space}:`);
490
239
  }
491
-
492
- const walked = graphWalkStrategy({
493
- aggregateTargetId: aggregate.targetId,
494
- member,
495
- currentMarker: liveMarker,
496
- });
497
- let pendingCount = 0;
498
- let status: MigrationStatusSpaceEntry['status'];
499
- if (walked.kind === 'ok') {
500
- // Count pending *migrations* (graph edges), not operations: a
501
- // single authored migration that lowers to N ops or zero ops
502
- // both count as exactly one pending unit of work for the user.
503
- pendingCount = walked.result.migrationEdges?.length ?? 0;
504
- if (liveMarker === null) {
505
- status = pendingCount === 0 ? 'no-marker' : 'pending';
506
- } else {
507
- status = pendingCount === 0 ? 'up-to-date' : 'pending';
508
- }
240
+ if (section.tree.length > 0) {
241
+ sections.push(section.tree);
509
242
  } else {
510
- status = 'unreachable';
243
+ sections.push('(no migrations)');
511
244
  }
512
-
513
- rows.push({
514
- spaceId: member.spaceId,
515
- kind: isApp ? 'app' : 'extension',
516
- headHash: headRef.hash,
517
- markerHash: liveMarker?.storageHash ?? null,
518
- pendingCount,
519
- ...(status ? { status } : {}),
520
- });
245
+ sections.push('');
521
246
  }
522
- return rows;
247
+ sections.push(formatStatusSummary(result, colorize));
248
+ return sections.join('\n').trimEnd();
523
249
  }
524
250
 
525
- /**
526
- * Read the raw contract.json bytes from disk for the aggregate
527
- * loader. Returns `null` if the file is missing or unparseable —
528
- * the existing `readContractEnvelope` path will report the same
529
- * problem via a status diagnostic, no need to double-surface.
530
- */
531
-
532
- async function validateOnlineMarkerRead(
533
- config: Awaited<ReturnType<typeof loadConfig>>,
534
- dbConnection: unknown,
535
- ): Promise<Result<void, CliStructuredError>> {
536
- const driver = config.driver;
537
- if (!driver) {
538
- return ok(undefined);
539
- }
540
-
541
- const client = createControlClient({
542
- family: config.family,
543
- target: config.target,
544
- adapter: config.adapter,
545
- driver,
546
- extensionPacks: config.extensionPacks ?? [],
547
- });
548
- try {
549
- await client.connect(dbConnection);
550
- await client.readMarker();
551
- return ok(undefined);
552
- } catch (error) {
553
- if (CliStructuredError.is(error)) {
554
- return notOk(error);
555
- }
556
- return notOk(
557
- errorUnexpected(error instanceof Error ? error.message : String(error), {
558
- why: `Failed to read database marker: ${error instanceof Error ? error.message : String(error)}`,
559
- }),
560
- );
561
- } finally {
562
- await client.close();
563
- }
251
+ async function readMarkersAndLedgers(args: {
252
+ readonly client: ReturnType<typeof createControlClient>;
253
+ readonly spaceIds: readonly string[];
254
+ }): Promise<{
255
+ readonly markersBySpace: ReadonlyMap<string, ContractMarkerRecordLike>;
256
+ readonly ledgersBySpace: ReadonlyMap<string, readonly LedgerEntryRecord[]>;
257
+ }> {
258
+ const markersBySpace = new Map<string, ContractMarkerRecordLike>();
259
+ const all = await args.client.readAllMarkers();
260
+ for (const [spaceId, marker] of all) {
261
+ markersBySpace.set(spaceId, marker);
262
+ }
263
+ const ledgersBySpace = new Map<string, readonly LedgerEntryRecord[]>();
264
+ for (const spaceId of args.spaceIds) {
265
+ ledgersBySpace.set(spaceId, await args.client.readLedger(spaceId));
266
+ }
267
+ return { markersBySpace, ledgersBySpace };
564
268
  }
565
269
 
566
- async function executeMigrationStatusCommand(
270
+ export async function executeMigrationStatusCommand(
567
271
  options: MigrationStatusOptions,
568
272
  flags: GlobalFlags,
569
273
  ui: TerminalUI,
570
- ): Promise<Result<MigrationStatusResult, CliStructuredError>> {
274
+ ): Promise<Result<MigrationStatusCommandResult, CliStructuredError>> {
571
275
  const config = await loadConfig(options.config);
572
- const { configPath, appMigrationsRelative, migrationsDir, refsDir } = resolveMigrationPaths(
276
+ const { configPath, migrationsDir, migrationsRelative, refsDir } = resolveMigrationPaths(
573
277
  options.config,
574
278
  config,
575
279
  );
576
280
 
577
281
  const dbConnection = options.db ?? config.db?.connection;
578
282
  const hasDriver = !!config.driver;
283
+ const usingFromOverride = options.from !== undefined;
284
+
285
+ if (!usingFromOverride) {
286
+ const missingDb = requireLiveDatabase({
287
+ dbConnection,
288
+ hasDriver,
289
+ why: 'migration status needs a database connection to read the marker and ledger (or pass --from for offline path preview)',
290
+ retryCommand: 'prisma-next migration status --from <contract>',
291
+ });
292
+ if (missingDb) {
293
+ return notOk(missingDb);
294
+ }
295
+ }
579
296
 
580
- let activeRefName: string | undefined;
581
- let activeRefHash: string | undefined;
582
- let activeRefEntry: RefEntry | undefined;
583
297
  let allRefs: Refs = {};
584
298
  try {
585
299
  allRefs = await readRefs(refsDir);
@@ -590,7 +304,7 @@ async function executeMigrationStatusCommand(
590
304
  throw error;
591
305
  }
592
306
 
593
- const diagnostics: StatusDiagnostic[] = [];
307
+ const diagnostics: StatusDiagnosticJson[] = [];
594
308
  let contractHash: string = EMPTY_CONTRACT_HASH;
595
309
  try {
596
310
  const envelope = await readContractEnvelope(config);
@@ -604,93 +318,52 @@ async function executeMigrationStatusCommand(
604
318
  });
605
319
  }
606
320
 
607
- const contractRawForAggregate = await loadContractRawSafely(config);
608
- const stack = createControlStack(config);
609
- const familyInstance = config.family.create(stack);
610
- const deserializeContract = (json: unknown): Contract => familyInstance.deserializeContract(json);
611
- const appContractStandIn = appContractStandInFromIdentity({
612
- contractHash,
613
- targetId: config.target.id,
614
- targetFamily: config.target.familyId,
615
- });
616
- let appContractForLoad: Contract = appContractStandIn;
617
- if (contractRawForAggregate !== null) {
618
- try {
619
- appContractForLoad = deserializeContract(contractRawForAggregate);
620
- } catch (error) {
621
- diagnostics.push({
622
- code: 'CONTRACT.UNREADABLE',
623
- severity: 'warn',
624
- message: `Could not deserialize contract: ${error instanceof Error ? error.message : 'unknown error'}`,
625
- hints: ["Run 'prisma-next contract emit' to generate a valid contract"],
626
- });
627
- }
628
- }
629
-
630
- let aggregate: ContractSpaceAggregate;
631
- try {
632
- aggregate = await loadContractSpaceAggregate({
633
- migrationsDir,
634
- deserializeContract,
635
- appContract: appContractForLoad,
636
- });
637
- } catch (error) {
638
- if (MigrationToolsError.is(error)) {
639
- return notOk(mapMigrationToolsError(error));
640
- }
641
- return notOk(
642
- errorUnexpected(error instanceof Error ? error.message : String(error), {
643
- why: `Failed to read migrations directory: ${error instanceof Error ? error.message : String(error)}`,
644
- }),
645
- );
321
+ const loaded = await buildReadAggregate(config, { migrationsDir });
322
+ if (!loaded.ok) {
323
+ return notOk(loaded.failure);
646
324
  }
647
325
 
326
+ const { aggregate } = loaded.value;
327
+ const contractRawForAggregate = await loadContractRawSafely(config);
648
328
  if (contractRawForAggregate !== null) {
649
329
  const corruptionFailure = refusePackageCorruptionOnAggregate(aggregate);
650
330
  if (corruptionFailure) {
651
331
  return notOk(corruptionFailure);
652
332
  }
653
333
  }
654
-
655
334
  const appGraph = aggregate.app.graph();
656
335
 
336
+ let activeRefHash: string | undefined;
337
+ let activeRefName: string | undefined;
338
+ let activeRefEntry: RefEntry | undefined;
657
339
  let fromOverrideHash: string | undefined;
658
340
 
659
- if (options.to || options.from) {
660
- if (options.to) {
661
- const refResult = parseContractRef(options.to, { graph: appGraph, refs: allRefs });
662
- if (!refResult.ok) {
663
- return notOk(mapRefResolutionError(refResult.failure));
664
- }
665
- activeRefHash = refResult.value.hash;
666
- if (refResult.value.provenance.kind === 'ref') {
667
- const resolvedRefName = refResult.value.provenance.refName;
668
- activeRefName = resolvedRefName;
669
- activeRefEntry = allRefs[resolvedRefName];
670
- }
341
+ if (options.to) {
342
+ const refResult = parseContractRef(options.to, { graph: appGraph, refs: allRefs });
343
+ if (!refResult.ok) {
344
+ return notOk(mapRefResolutionError(refResult.failure));
345
+ }
346
+ activeRefHash = refResult.value.hash;
347
+ if (refResult.value.provenance.kind === 'ref') {
348
+ activeRefName = refResult.value.provenance.refName;
349
+ activeRefEntry = allRefs[activeRefName];
671
350
  }
351
+ }
672
352
 
673
- if (options.from) {
674
- const fromResult = parseContractRef(options.from, { graph: appGraph, refs: allRefs });
675
- if (!fromResult.ok) {
676
- return notOk(mapRefResolutionError(fromResult.failure));
677
- }
678
- fromOverrideHash = fromResult.value.hash;
353
+ if (options.from) {
354
+ const fromResult = parseContractRef(options.from, { graph: appGraph, refs: allRefs });
355
+ if (!fromResult.ok) {
356
+ return notOk(mapRefResolutionError(fromResult.failure));
679
357
  }
358
+ fromOverrideHash = fromResult.value.hash;
680
359
  }
681
360
 
682
361
  const requiredInvariants: readonly string[] = [...(activeRefEntry?.invariants ?? [])].sort();
683
362
 
684
- const statusRefs: StatusRef[] = Object.entries(allRefs).map(([name, entry]) => ({
685
- name,
686
- hash: entry.hash,
687
- active: name === activeRefName,
688
- }));
689
-
690
363
  if (!flags.json && !flags.quiet) {
691
364
  const details: Array<{ label: string; value: string }> = [
692
365
  { label: 'config', value: configPath },
693
- { label: 'migrations', value: appMigrationsRelative },
366
+ { label: 'migrations', value: migrationsRelative },
694
367
  ];
695
368
  if (dbConnection && hasDriver) {
696
369
  details.push({ label: 'database', value: maskConnectionUrl(String(dbConnection)) });
@@ -701,11 +374,8 @@ async function executeMigrationStatusCommand(
701
374
  if (options.from) {
702
375
  details.push({ label: 'from', value: options.from });
703
376
  }
704
- if (activeRefEntry && activeRefEntry.invariants.length > 0) {
705
- details.push({
706
- label: 'required',
707
- value: formatInvariantList(activeRefEntry.invariants),
708
- });
377
+ if (options.space) {
378
+ details.push({ label: 'space', value: options.space });
709
379
  }
710
380
  const header = formatStyledHeader({
711
381
  command: 'migration status',
@@ -714,69 +384,34 @@ async function executeMigrationStatusCommand(
714
384
  flags,
715
385
  });
716
386
  ui.stderr(header);
717
- }
718
-
719
- const bundles = aggregate.app.packages;
720
- const graph = appGraph;
721
-
722
- if (bundles.length === 0) {
723
- if (dbConnection && hasDriver) {
724
- const markerProbe = await validateOnlineMarkerRead(config, dbConnection);
725
- if (!markerProbe.ok) {
726
- return markerProbe;
727
- }
728
- }
729
- if (contractHash !== EMPTY_CONTRACT_HASH) {
730
- diagnostics.push({
731
- code: 'CONTRACT.AHEAD',
732
- severity: 'warn',
733
- message: 'No migration exists for the current contract',
734
- hints: [
735
- "Run 'prisma-next migration plan' to generate a migration for the current contract",
736
- ],
737
- });
387
+ if (shouldShowLegend(options, flags)) {
388
+ ui.stderr(
389
+ renderMigrationGraphLegend({
390
+ colorize: flags.color !== false,
391
+ glyphMode: ui.resolveGlyphMode(options.ascii === true),
392
+ }),
393
+ );
394
+ ui.stderr('');
738
395
  }
739
- return ok({
740
- ok: true,
741
- mode: dbConnection && hasDriver ? 'online' : 'offline',
742
- migrations: [],
743
- targetHash: EMPTY_CONTRACT_HASH,
744
- contractHash,
745
- summary: 'No migrations found',
746
- diagnostics,
747
- requiredInvariants,
748
- });
749
396
  }
750
397
 
751
- let targetHash: string | undefined;
752
-
753
- if (activeRefHash) {
754
- targetHash = activeRefHash;
755
- } else if (graph.nodes.has(contractHash)) {
756
- targetHash = contractHash;
757
- } else {
758
- const leaves = findReachableLeaves(graph, EMPTY_CONTRACT_HASH);
759
- if (leaves.length === 1) {
760
- targetHash = leaves[0];
761
- } else {
762
- diagnostics.push({
763
- code: 'MIGRATION.DIVERGED',
764
- severity: 'warn',
765
- message: 'There are multiple valid migration paths — you must select a target',
766
- hints: [
767
- "Use '--to <contract>' to select a target",
768
- "Or 'prisma-next ref set <name> <hash>' to create one",
769
- ],
770
- });
771
- }
398
+ const listSpaces = await migrationSpaceListEntriesFromAggregate(aggregate, migrationsDir);
399
+ const listResult = runMigrationList({
400
+ spaces: listSpaces,
401
+ ...ifDefined('spaceFilter', options.space),
402
+ });
403
+ if (!listResult.ok) {
404
+ return listResult;
772
405
  }
773
406
 
774
- let markerHash: string | undefined;
775
- let markerInvariants: readonly string[] = [];
776
- let mode: 'online' | 'offline' = 'offline';
777
- let allMarkers: ReadonlyMap<string, ContractMarkerRecordLike> | null = null;
407
+ const scopedSpaces = listResult.value.spaces;
408
+ const showSpaceHeadings = scopedSpaces.length > 1;
778
409
 
779
- if (dbConnection && hasDriver) {
410
+ let markersBySpace = new Map<string, ContractMarkerRecordLike>();
411
+ let ledgersBySpace = new Map<string, readonly LedgerEntryRecord[]>();
412
+ let connected = false;
413
+
414
+ if (dbConnection && hasDriver && !usingFromOverride) {
780
415
  const client = createControlClient({
781
416
  family: config.family,
782
417
  target: config.target,
@@ -786,74 +421,32 @@ async function executeMigrationStatusCommand(
786
421
  });
787
422
  try {
788
423
  await client.connect(dbConnection);
789
- const marker = await client.readMarker();
790
- markerHash = marker?.storageHash;
791
- markerInvariants = marker?.invariants ?? [];
792
- mode = 'online';
793
- // Read every space's marker so the aggregate enumeration can
794
- // surface per-space marker state. `readAllMarkers` mirrors what
795
- // `db init` / `db update` already use to drive the planner;
796
- // here it powers the aggregate status output.
797
- //
798
- // Probe for the method first so we only swallow the
799
- // unsupported-method case: older family instances may not
800
- // implement `readAllMarkers` (per-space enumeration then falls
801
- // back to "marker unknown"). Real query / runtime errors from
802
- // an instance that *does* expose the method must propagate up
803
- // — otherwise transient DB failures would silently degrade
804
- // status to "markers unknown".
805
- if (typeof client.readAllMarkers === 'function') {
806
- allMarkers = await client.readAllMarkers();
807
- } else {
808
- // Leaving `allMarkers` as `null` signals "unknown" to the
809
- // aggregate loader (an empty `Map` would instead mean "every
810
- // space has no marker", which is a different condition).
811
- allMarkers = null;
812
- }
424
+ connected = true;
425
+ const read = await readMarkersAndLedgers({
426
+ client,
427
+ spaceIds: scopedSpaces.map((s) => s.space),
428
+ });
429
+ markersBySpace = new Map(read.markersBySpace);
430
+ ledgersBySpace = new Map(read.ledgersBySpace);
813
431
  } catch (error) {
814
432
  if (CliStructuredError.is(error)) {
815
433
  return notOk(error);
816
434
  }
817
- if (!flags.json && !flags.quiet) {
818
- ui.warn('Could not connect to database showing offline status');
819
- }
435
+ return notOk(
436
+ errorUnexpected(error instanceof Error ? error.message : String(error), {
437
+ why: `Failed to read database state: ${error instanceof Error ? error.message : String(error)}`,
438
+ }),
439
+ );
820
440
  } finally {
821
441
  await client.close();
822
442
  }
823
443
  }
824
444
 
825
- if (fromOverrideHash !== undefined) {
826
- markerHash = fromOverrideHash;
827
- mode = 'offline';
828
- allMarkers = null;
829
- }
830
-
831
- let aggregateSpaces: readonly MigrationStatusSpaceEntry[] = [];
832
- if (contractRawForAggregate !== null) {
833
- try {
834
- aggregateSpaces = await loadAggregateStatusSpaces({
835
- aggregate,
836
- extensionPacks: config.extensionPacks ?? [],
837
- markersBySpace: allMarkers,
838
- });
839
- } catch {
840
- aggregateSpaces = [];
841
- }
842
- }
843
- const totalPendingAcrossSpaces = computeTotalPendingAcrossSpaces(aggregateSpaces);
844
-
845
- // Pre-check unknown invariants. Online: union the graph's declared
846
- // invariants with the marker's recorded set so a retired-but-applied
847
- // invariant doesn't surface as MIGRATION.UNKNOWN_INVARIANT — apply would
848
- // route fine because marker subtraction empties `effectiveRequired`.
849
- // Offline: keep the check graph-strict (the marker is unknown, and a
850
- // missing declarer is the dominant signal we can offer).
851
- if (activeRefEntry && activeRefEntry.invariants.length > 0) {
852
- const declared = collectDeclaredInvariants(graph);
445
+ if (activeRefEntry && activeRefEntry.invariants.length > 0 && connected) {
446
+ const declared = collectDeclaredInvariants(appGraph);
447
+ const markerInvariants = markersBySpace.get(aggregate.app.spaceId)?.invariants ?? [];
853
448
  const known = new Set<string>(declared);
854
- if (mode === 'online') {
855
- for (const id of markerInvariants) known.add(id);
856
- }
449
+ for (const id of markerInvariants) known.add(id);
857
450
  const unknown = activeRefEntry.invariants.filter((id) => !known.has(id));
858
451
  if (unknown.length > 0) {
859
452
  return notOk(
@@ -868,247 +461,183 @@ async function executeMigrationStatusCommand(
868
461
  }
869
462
  }
870
463
 
871
- // Marker exists but is not in the migration graph and doesn't match the
872
- // contract hash. The DB is at an unknown state relative to the graph.
873
- // Bail out early with a clear diagnostic instead of rendering a confusing
874
- // graph with no statuses.
875
- //
876
- // When marker === contract (both off-graph), the DB matches the current
877
- // contract proceed normally; the detached contract node will carry both
878
- // the db and contract markers.
879
- if (
880
- mode === 'online' &&
881
- markerHash !== undefined &&
882
- !graph.nodes.has(markerHash) &&
883
- markerHash !== contractHash
884
- ) {
885
- const hints: string[] = [];
886
- if (graph.nodes.has(contractHash)) {
887
- hints.push(
888
- "Run 'prisma-next db sign' to overwrite the marker if the database already matches the contract",
889
- "Run 'prisma-next db update' to push the current contract to the database",
890
- "Run 'prisma-next contract infer' to make your contract match the database",
891
- "Run 'prisma-next db verify' to inspect the database state",
892
- );
893
- } else {
894
- hints.push(
895
- "Run 'prisma-next db update' to push the current contract to the database",
896
- "Run 'prisma-next contract infer' to make your contract match the database",
897
- "Run 'prisma-next db verify' to inspect the database state",
898
- );
464
+ const showAppliedOverlay = connected && !usingFromOverride;
465
+ const showDbMarker = connected && !usingFromOverride;
466
+ const glyphMode = ui.resolveGlyphMode(options.ascii === true);
467
+ const colorize = flags.color !== false;
468
+
469
+ const statusSpaces: MigrationStatusSpace[] = [];
470
+ const treeSections: MigrationStatusTreeSection[] = [];
471
+ let markerDiverged = false;
472
+ let markerCannotReachTarget = false;
473
+ let headlineTargetHash = activeRefHash ?? contractHash;
474
+ let totalPending = 0;
475
+
476
+ const globalLayoutInputs = showSpaceHeadings
477
+ ? scopedSpaces
478
+ .filter((spaceEntry) => spaceEntry.migrations.length > 0)
479
+ .map((spaceEntry) => ({
480
+ graph: aggregate.space(spaceEntry.space)!.graph(),
481
+ liveContractHash: contractHash,
482
+ }))
483
+ : [];
484
+ const globalMaxEdgeTreePrefixWidth =
485
+ globalLayoutInputs.length > 0
486
+ ? computeGlobalMaxEdgeTreePrefixWidth(globalLayoutInputs)
487
+ : undefined;
488
+ const globalMaxDirNameWidth =
489
+ globalLayoutInputs.length > 0 ? computeGlobalMaxDirNameWidth(globalLayoutInputs) : undefined;
490
+
491
+ for (const spaceEntry of scopedSpaces) {
492
+ const member = aggregate.space(spaceEntry.space);
493
+ if (member === undefined) {
494
+ continue;
899
495
  }
900
- diagnostics.push({
901
- code: 'MIGRATION.MARKER_NOT_IN_HISTORY',
902
- severity: 'warn',
903
- message:
904
- 'Database was updated outside the migration system (marker does not match any migration)',
905
- hints,
906
- });
907
- return ok({
908
- ok: true,
909
- mode,
910
- migrations: [],
911
- targetHash: EMPTY_CONTRACT_HASH,
912
- contractHash,
913
- summary: `${bundles.length} migration(s) on disk`,
914
- diagnostics,
915
- markerHash,
916
- requiredInvariants,
917
- ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
918
- });
919
- }
920
-
921
- if (mode === 'online' && markerHash === undefined) {
922
- diagnostics.push({
923
- code: 'MIGRATION.NO_MARKER',
924
- severity: 'warn',
925
- message: 'Database has not been initialized — no migration marker found',
926
- hints: ["Run 'prisma-next migrate' to apply pending migrations"],
927
- });
928
- }
929
-
930
- // Contract diagnostic — fires when no migration produces the current contract hash.
931
- // Suppressed when: (a) graph is diverged (MIGRATION.DIVERGED already guides the user),
932
- // (b) marker === contract and both off-graph (marker-not-in-graph diagnostic covers it).
933
- if (
934
- targetHash &&
935
- contractHash !== EMPTY_CONTRACT_HASH &&
936
- !graph.nodes.has(contractHash) &&
937
- markerHash !== contractHash
938
- ) {
939
- diagnostics.push({
940
- code: 'CONTRACT.AHEAD',
941
- severity: 'warn',
942
- message: 'Contract has changed since the last migration was planned',
943
- hints: ["Run 'prisma-next migration plan' to generate a migration for the current contract"],
944
- });
945
- }
946
-
947
- if (!targetHash) {
948
- return ok({
949
- ok: true,
950
- mode,
951
- migrations: [],
952
- targetHash: EMPTY_CONTRACT_HASH,
953
- contractHash,
954
- summary: `${bundles.length} migration(s) on disk`,
955
- diagnostics,
956
- ...ifDefined('markerHash', markerHash),
957
- requiredInvariants,
958
- ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
959
- graph,
960
- bundles,
961
- diverged: true,
962
- });
963
- }
964
-
965
- const chain = resolveDisplayChain(graph, targetHash, markerHash);
966
-
967
- if (!chain) {
968
- return notOk(
969
- errorRuntime('Cannot reconstruct migration history', {
970
- why: `No path from ${EMPTY_CONTRACT_HASH} to target ${targetHash}`,
971
- fix: 'The migration history may have gaps. Check the migrations directory for missing or corrupted packages.',
972
- }),
973
- );
974
- }
975
-
976
- const edgeStatuses = deriveEdgeStatuses(graph, targetHash, contractHash, markerHash, mode);
977
- const entries = buildMigrationEntries(chain, bundles, mode, markerHash, edgeStatuses);
978
-
979
- const pendingCount = edgeStatuses.filter((e) => e.status === 'pending').length;
980
- const appliedCount = edgeStatuses.filter((e) => e.status === 'applied').length;
981
-
982
- let appliedInvariants: readonly string[] | undefined;
983
- let missingInvariants: readonly string[] | undefined;
984
- let effectiveRequired = new Set<string>();
985
- if (mode === 'online') {
986
- // Mirrors `migrate.ts`: compute `effectiveRequired = required −
987
- // marker.invariants` directly, then derive the display fields from it.
988
- // `appliedInvariants` is the intersection (`required ∩ marker`), which
989
- // is what JSON consumers see for the active ref; the unfiltered set
990
- // lives on `marker.invariants`.
991
- const markerSet = new Set(markerInvariants);
992
- effectiveRequired = new Set(requiredInvariants.filter((id) => !markerSet.has(id)));
993
- appliedInvariants = requiredInvariants.filter((id) => markerSet.has(id));
994
- missingInvariants = [...effectiveRequired].sort();
995
- }
996
-
997
- // The marker can match the structural target while still missing required
998
- // invariants — for example, a self-edge that provides X, applied via a ref
999
- // declaring X. `pendingCount` (structural) says zero in that case but
1000
- // `effectiveRequired` is non-empty, so up-to-date messaging would mislead.
1001
- const hasInvariantWork = effectiveRequired.size > 0;
1002
- const missingList = [...effectiveRequired].sort().join(', ');
1003
-
1004
- let summary: string;
1005
- if (mode === 'online') {
1006
- if (markerHash !== undefined && !graph.nodes.has(markerHash) && markerHash === contractHash) {
1007
- summary = `${bundles.length} migration(s) on disk`;
1008
- } else if (activeRefHash && activeRefName && markerHash !== undefined) {
1009
- const distance = summarizeRefDistance(graph, markerHash, activeRefHash, activeRefName);
1010
- summary = hasInvariantWork ? `${distance} — missing invariant(s): ${missingList}` : distance;
1011
- } else if (pendingCount === 0 && !hasInvariantWork) {
1012
- summary = `Database is up to date (${appliedCount} migration${appliedCount !== 1 ? 's' : ''} applied)`;
1013
- } else if (pendingCount === 0 && hasInvariantWork) {
1014
- summary = `Missing invariant(s): ${missingList} — run 'prisma-next migrate --to ${activeRefName ?? '<ref>'}' to apply`;
1015
- } else if (markerHash === undefined) {
1016
- summary = `${pendingCount} pending migration(s) — database has no marker`;
1017
- } else {
1018
- summary = `${pendingCount} pending migration(s) — run 'prisma-next migrate' to apply`;
496
+ const graph = member.graph();
497
+ const spaceContractHash = member.contract().storage.storageHash;
498
+ const targetHash = resolveTarget(spaceContractHash, activeRefHash);
499
+ if (spaceEntry.space === aggregate.app.spaceId) {
500
+ headlineTargetHash = targetHash;
1019
501
  }
1020
- } else {
1021
- summary = `${entries.length} migration(s) on disk`;
1022
- }
1023
502
 
1024
- let pathDecision: MigrationStatusResult['pathDecision'];
1025
- let routingUnreachable = false;
1026
- if (mode === 'online') {
503
+ const markerRecord = markersBySpace.get(spaceEntry.space);
504
+ const markerHash = usingFromOverride
505
+ ? fromOverrideHash
506
+ : (markerRecord?.storageHash ?? undefined);
1027
507
  const originHash = markerHash ?? EMPTY_CONTRACT_HASH;
1028
- const outcome = findPathWithDecision(graph, originHash, targetHash, {
1029
- ...ifDefined('refName', activeRefName),
1030
- required: effectiveRequired,
1031
- });
1032
- if (outcome.kind === 'ok') {
1033
- pathDecision = toPathDecisionResult(outcome.decision);
1034
- } else if (outcome.kind === 'unsatisfiable') {
1035
- return notOk(
1036
- mapMigrationToolsError(
1037
- errorNoInvariantPath({
1038
- ...ifDefined('refName', activeRefName),
1039
- required: [...effectiveRequired].sort(),
1040
- missing: outcome.missing,
1041
- structuralPath: outcome.structuralPath.map(toStructuralEdge),
1042
- }),
1043
- ),
1044
- );
1045
- } else {
1046
- // outcome.kind === 'unreachable' — origin (marker) has no structural
1047
- // path to the active target. `pendingCount` and `hasInvariantWork`
1048
- // both report zero in this case, but emitting MIGRATION.UP_TO_DATE
1049
- // would be wrong: the database simply cannot reach the requested
1050
- // ref/contract from its current state. Suppress UP_TO_DATE below.
1051
- routingUnreachable = true;
1052
- }
1053
- }
1054
-
1055
- if (mode === 'online') {
1056
- if (markerHash !== undefined && !graph.nodes.has(markerHash) && markerHash === contractHash) {
508
+ const markerInGraph =
509
+ markerHash === undefined || graph.nodes.has(markerHash) || markerHash === spaceContractHash;
510
+ if (
511
+ connected &&
512
+ !usingFromOverride &&
513
+ markerInGraph &&
514
+ originHash !== targetHash &&
515
+ findPath(graph, originHash, targetHash) === null
516
+ ) {
517
+ markerCannotReachTarget = true;
518
+ }
519
+
520
+ if (connected && !usingFromOverride && markerHash !== undefined && !markerInGraph) {
521
+ markerDiverged = true;
1057
522
  diagnostics.push({
1058
523
  code: 'MIGRATION.MARKER_NOT_IN_HISTORY',
1059
524
  severity: 'warn',
1060
- message: 'Database matches the current contract but was updated directly (not via migrate)',
1061
- hints: ["Run 'prisma-next migration plan' to plan a migration to your current contract"],
1062
- });
1063
- } else if (pendingCount > 0) {
1064
- diagnostics.push({
1065
- code: 'MIGRATION.DATABASE_BEHIND',
1066
- severity: 'info',
1067
- message: `${pendingCount} migration(s) pending`,
1068
- hints: ["Run 'prisma-next migrate' to apply pending migrations"],
1069
- });
1070
- } else if (hasInvariantWork) {
1071
- diagnostics.push({
1072
- code: 'MIGRATION.INVARIANTS_PENDING',
1073
- severity: 'info',
1074
- message: `Missing required invariant(s): ${missingList}`,
525
+ message:
526
+ 'Database was updated outside the migration system (marker does not match any migration)',
1075
527
  hints: [
1076
- `Run 'prisma-next migrate --to ${activeRefName ?? '<ref>'}' to apply a path that covers the required invariants`,
528
+ "Run 'prisma-next db sign' to overwrite the marker if the database already matches the contract",
529
+ "Run 'prisma-next db update' to push the current contract to the database",
1077
530
  ],
1078
531
  });
1079
- } else if (!routingUnreachable) {
532
+ }
533
+
534
+ const ledger = ledgersBySpace.get(spaceEntry.space) ?? [];
535
+ const appliedHashes = showAppliedOverlay ? appliedHashesFromLedger(ledger) : new Set<string>();
536
+
537
+ const annotations = deriveStatusEdgeAnnotations({
538
+ graph,
539
+ targetHash,
540
+ originHash,
541
+ appliedMigrationHashes: appliedHashes,
542
+ showAppliedOverlay,
543
+ });
544
+ const tree = renderSpaceTree({
545
+ member,
546
+ liveContractHash: contractHash,
547
+ migrations: spaceEntry.migrations,
548
+ markerHash,
549
+ showDbMarker,
550
+ statusOverlay: annotations,
551
+ colorize,
552
+ glyphMode,
553
+ ...(globalMaxEdgeTreePrefixWidth !== undefined ? { globalMaxEdgeTreePrefixWidth } : {}),
554
+ ...(globalMaxDirNameWidth !== undefined ? { globalMaxDirNameWidth } : {}),
555
+ });
556
+ const migrations = buildStatusMigrations(spaceEntry.migrations, annotations);
557
+ const pending = countPending(migrations);
558
+ totalPending += pending;
559
+
560
+ statusSpaces.push({
561
+ space: spaceEntry.space,
562
+ currentContract: markerHash ?? null,
563
+ targetContract: targetHash,
564
+ migrations: [...migrations],
565
+ });
566
+ const displayTree =
567
+ showSpaceHeadings && tree.length > 0 ? indentMigrationGraphTreeBlock(tree, ' ') : tree;
568
+ treeSections.push({
569
+ space: spaceEntry.space,
570
+ tree: displayTree,
571
+ showHeading: showSpaceHeadings,
572
+ });
573
+ }
574
+
575
+ if (connected && requiredInvariants.length > 0) {
576
+ const markerInvariants = markersBySpace.get(aggregate.app.spaceId)?.invariants ?? [];
577
+ const markerSet = new Set(markerInvariants);
578
+ const missing = requiredInvariants.filter((id) => !markerSet.has(id));
579
+ if (missing.length > 0) {
1080
580
  diagnostics.push({
1081
- code: 'MIGRATION.UP_TO_DATE',
1082
- severity: 'info',
1083
- message: 'Database is up to date',
1084
- hints: [],
581
+ code: 'MIGRATION.MISSING_INVARIANTS',
582
+ ...ifDefined('ref', activeRefName),
583
+ invariants: missing,
584
+ message: `missing invariant(s): ${missing.join(', ')}`,
1085
585
  });
586
+ if (activeRefHash !== undefined) {
587
+ const originHash =
588
+ markersBySpace.get(aggregate.app.spaceId)?.storageHash ?? EMPTY_CONTRACT_HASH;
589
+ const outcome = findPathWithDecision(appGraph, originHash, activeRefHash, {
590
+ ...ifDefined('refName', activeRefName),
591
+ required: new Set(missing),
592
+ });
593
+ if (outcome.kind === 'unsatisfiable') {
594
+ return notOk(
595
+ mapMigrationToolsError(
596
+ errorNoInvariantPath({
597
+ ...ifDefined('refName', activeRefName),
598
+ required: [...missing].sort(),
599
+ missing: outcome.missing,
600
+ structuralPath: outcome.structuralPath.map(toStructuralEdge),
601
+ }),
602
+ ),
603
+ );
604
+ }
605
+ }
1086
606
  }
1087
607
  }
1088
608
 
1089
- const result: MigrationStatusResult = {
609
+ const appMarkerHash = markersBySpace.get(aggregate.app.spaceId)?.storageHash;
610
+ const summary = markerCannotReachTarget
611
+ ? buildNoPathSummary({
612
+ markerHash: appMarkerHash,
613
+ targetHash: headlineTargetHash,
614
+ explicitTarget: options.to !== undefined,
615
+ refName: activeRefName,
616
+ })
617
+ : buildStatusHeadline({
618
+ pendingCount: totalPending,
619
+ targetHash: headlineTargetHash,
620
+ markerDiverged,
621
+ markerHash: appMarkerHash,
622
+ });
623
+
624
+ if (scopedSpaces.every((s) => s.migrations.length === 0)) {
625
+ return ok({
626
+ ok: true,
627
+ spaces: statusSpaces,
628
+ summary: 'No migrations found',
629
+ diagnostics,
630
+ treeSections,
631
+ });
632
+ }
633
+
634
+ return ok({
1090
635
  ok: true,
1091
- mode,
1092
- migrations: entries,
1093
- targetHash,
1094
- contractHash,
636
+ spaces: statusSpaces,
1095
637
  summary,
1096
638
  diagnostics,
1097
- ...ifDefined('markerHash', markerHash),
1098
- requiredInvariants,
1099
- ...ifDefined('appliedInvariants', appliedInvariants),
1100
- ...ifDefined('missingInvariants', missingInvariants),
1101
- ...(statusRefs.length > 0 ? { refs: statusRefs } : {}),
1102
- ...ifDefined('pathDecision', pathDecision),
1103
- graph,
1104
- bundles,
1105
- edgeStatuses,
1106
- ...ifDefined('activeRefHash', activeRefHash),
1107
- ...ifDefined('activeRefName', activeRefName),
1108
- spaces: aggregateSpaces,
1109
- ...ifDefined('totalPendingAcrossSpaces', totalPendingAcrossSpaces),
1110
- };
1111
- return ok(result);
639
+ treeSections,
640
+ });
1112
641
  }
1113
642
 
1114
643
  export function createMigrationStatusCommand(): Command {
@@ -1117,13 +646,18 @@ export function createMigrationStatusCommand(): Command {
1117
646
  command,
1118
647
  'Show migration path and pending status',
1119
648
  'Shows which migrations are pending between the database marker and\n' +
1120
- 'the target contract. Requires a database connection for live status.\n' +
649
+ 'the target contract. Requires a database connection.\n' +
650
+ 'Pass --from for an offline path preview without a database.\n' +
1121
651
  'Use `migration graph` for topology, `migration log` for history,\n' +
1122
652
  'and `migration list` for on-disk enumeration.',
1123
653
  );
1124
654
  setCommandExamples(command, [
1125
655
  'prisma-next migration status --db $DATABASE_URL',
1126
656
  'prisma-next migration status --to production --db $DATABASE_URL',
657
+ 'prisma-next migration status --from sha256:abc --to production',
658
+ 'prisma-next migration status --from sha256:abc --to production --json',
659
+ 'prisma-next migration status --ascii --from sha256:abc --to production',
660
+ 'prisma-next migration status --legend --from sha256:abc --to production',
1127
661
  ]);
1128
662
  setCommandSeeAlso(command, [
1129
663
  { verb: 'migration log', oneLiner: 'Show executed migration history' },
@@ -1134,6 +668,7 @@ export function createMigrationStatusCommand(): Command {
1134
668
  addGlobalOptions(command)
1135
669
  .option('--db <url>', 'Database connection string')
1136
670
  .option('--config <path>', 'Path to prisma-next.config.ts')
671
+ .option('--space <id>', 'Narrow output to a single contract space')
1137
672
  .option(
1138
673
  '--to <contract>',
1139
674
  'Target contract reference (hash, prefix, ref name, migration dir name, <dir>^, or ./path)',
@@ -1142,56 +677,30 @@ export function createMigrationStatusCommand(): Command {
1142
677
  '--from <contract>',
1143
678
  'Origin contract reference; same grammar as --to. Supplying --from switches to offline path computation.',
1144
679
  )
680
+ .option('--legend', 'Print a key for the tree glyphs and lane colors')
681
+ .option('--ascii', 'Use ASCII glyphs (pipe-friendly)')
1145
682
  .action(async (options: MigrationStatusOptions) => {
1146
683
  const flags = parseGlobalFlagsOrExit(options);
1147
684
  const ui = createTerminalUI(flags);
1148
685
 
686
+ const legendValidation = validateLegendOptions(options, flags);
687
+ if (!legendValidation.ok) {
688
+ process.exit(handleResult(legendValidation, flags, ui));
689
+ }
690
+
1149
691
  const result = await executeMigrationStatusCommand(options, flags, ui);
1150
692
 
1151
693
  const exitCode = handleResult(result, flags, ui, (statusResult) => {
1152
694
  if (flags.json) {
1153
- const {
1154
- graph: _graph,
1155
- bundles: _bundles,
1156
- edgeStatuses: _edgeStatuses,
1157
- activeRefHash: _activeRefHash,
1158
- activeRefName: _activeRefName,
1159
- diverged: _diverged,
1160
- ...jsonResult
1161
- } = statusResult;
695
+ const jsonResult: MigrationStatusResult = {
696
+ ok: true,
697
+ spaces: [...statusResult.spaces],
698
+ summary: statusResult.summary,
699
+ diagnostics: [...statusResult.diagnostics],
700
+ };
1162
701
  ui.output(JSON.stringify(jsonResult, null, 2));
1163
702
  } else if (!flags.quiet) {
1164
- const colorize = flags.color !== false;
1165
-
1166
- if (statusResult.graph) {
1167
- const renderInput = migrationGraphToRenderInput({
1168
- graph: statusResult.graph,
1169
- mode: statusResult.mode,
1170
- markerHash: statusResult.markerHash,
1171
- contractHash: statusResult.contractHash,
1172
- refs: statusResult.refs,
1173
- activeRefHash: statusResult.activeRefHash,
1174
- activeRefName: statusResult.activeRefName,
1175
- edgeStatuses: statusResult.edgeStatuses,
1176
- });
1177
-
1178
- const graphToRender = statusResult.diverged
1179
- ? renderInput.graph
1180
- : extractRelevantSubgraph(renderInput.graph, renderInput.relevantPaths);
1181
- const dagreOptions = isLinearGraph(graphToRender) ? { ranksep: 1 } : undefined;
1182
- const renderOptions = {
1183
- ...renderInput.options,
1184
- colorize,
1185
- ...ifDefined('dagreOptions', dagreOptions),
1186
- };
1187
- const graphOutput = graphRenderer.render(graphToRender, renderOptions);
1188
- ui.log(graphOutput);
1189
- if (statusResult.mode === 'online') {
1190
- ui.log(formatLegend(colorize));
1191
- }
1192
- }
1193
- ui.log('');
1194
- ui.log(formatStatusSummary(statusResult, colorize));
703
+ ui.output(formatStatusHumanOutput(statusResult, flags.color !== false));
1195
704
  }
1196
705
  });
1197
706
 
@@ -1200,126 +709,3 @@ export function createMigrationStatusCommand(): Command {
1200
709
 
1201
710
  return command;
1202
711
  }
1203
-
1204
- function formatLegend(colorize: boolean): string {
1205
- const c = (fn: (s: string) => string, s: string) => (colorize ? fn(s) : s);
1206
- const parts = [
1207
- `${c(cyan, '✓')} applied`,
1208
- `${c(yellow, '⧗')} pending`,
1209
- `${c(magenta, '✗')} unreachable`,
1210
- ];
1211
- return c(dim, parts.join(' '));
1212
- }
1213
-
1214
- export function formatStatusSummary(result: MigrationStatusResult, colorize: boolean): string {
1215
- const c = (fn: (s: string) => string, s: string) => (colorize ? fn(s) : s);
1216
- const lines: string[] = [];
1217
-
1218
- const hasUnknown = result.migrations.some((e) => e.status === 'unknown');
1219
- const pendingCount = result.migrations.filter((e) => e.status === 'pending').length;
1220
-
1221
- const hasWarnings = result.diagnostics?.some((d) => d.severity === 'warn') ?? false;
1222
- // INVARIANTS_PENDING is filed at severity 'info' (per ADR 208) so the
1223
- // warn-severity check above doesn't see it. It still represents pending
1224
- // work, so it must promote the summary off the success icon.
1225
- const hasInvariantPending =
1226
- result.diagnostics?.some((d) => d.code === 'MIGRATION.INVARIANTS_PENDING') ?? false;
1227
-
1228
- if (result.mode === 'online') {
1229
- if (hasUnknown || hasWarnings) {
1230
- lines.push(`${c(yellow, '⚠')} ${result.summary}`);
1231
- } else if (pendingCount === 0 && !hasInvariantPending) {
1232
- lines.push(`${c(cyan, '✔')} ${result.summary}`);
1233
- } else {
1234
- lines.push(`${c(yellow, '⧗')} ${result.summary}`);
1235
- }
1236
- } else {
1237
- lines.push(result.summary);
1238
- }
1239
-
1240
- if (result.requiredInvariants.length > 0) {
1241
- if (result.appliedInvariants !== undefined && result.missingInvariants !== undefined) {
1242
- lines.push(`${c(dim, 'applied ')}${formatInvariantList(result.appliedInvariants)}`);
1243
- lines.push(`${c(dim, 'missing ')}${formatInvariantList(result.missingInvariants)}`);
1244
- } else {
1245
- lines.push(`${c(dim, 'applied ')}(unknown — connect a database to evaluate)`);
1246
- }
1247
- }
1248
-
1249
- const warnings = result.diagnostics?.filter((d) => d.severity === 'warn') ?? [];
1250
- for (const diag of warnings) {
1251
- lines.push(`${c(yellow, '⚠')} ${diag.message}`);
1252
- for (const hint of diag.hints) {
1253
- lines.push(` ${c(dim, hint)}`);
1254
- }
1255
- }
1256
-
1257
- // Per-space section. Suppressed when there's no extension space —
1258
- // the top-level output already covers the app member.
1259
- // When extensions exist, render every space (including the app)
1260
- // for consistency, plus a cross-space pending total + apply hint.
1261
- if (result.spaces?.some((s) => s.kind === 'extension')) {
1262
- const total = result.totalPendingAcrossSpaces ?? 0;
1263
- lines.push('');
1264
- lines.push(c(dim, 'spaces'));
1265
- for (const space of result.spaces) {
1266
- lines.push(formatSpaceLine(space, c));
1267
- }
1268
- if (total > 0) {
1269
- lines.push('');
1270
- lines.push(
1271
- `${c(yellow, '⧗')} ${total} pending migration(s) across ${result.spaces.length} space(s) — run 'prisma-next migrate' to apply`,
1272
- );
1273
- }
1274
- }
1275
-
1276
- return lines.join('\n');
1277
- }
1278
-
1279
- function formatSpaceLine(
1280
- space: MigrationStatusSpaceEntry,
1281
- c: (fn: (s: string) => string, s: string) => string,
1282
- ): string {
1283
- const glyph = (() => {
1284
- if (space.status === 'up-to-date' || space.status === 'no-marker') return c(cyan, '✓');
1285
- if (space.status === 'pending') return c(yellow, '⧗');
1286
- if (space.status === 'unreachable' || space.status === 'never-planned') return c(magenta, '✗');
1287
- return ' ';
1288
- })();
1289
- const tag = space.kind === 'app' ? '[app]' : '[ext]';
1290
- const head = space.headHash.slice(0, 8);
1291
- const marker =
1292
- space.markerHash === undefined
1293
- ? '(unknown)'
1294
- : space.markerHash === null
1295
- ? '(no marker)'
1296
- : space.markerHash.slice(0, 8);
1297
- const pending =
1298
- space.pendingCount === undefined
1299
- ? ''
1300
- : space.pendingCount === 0
1301
- ? c(dim, ' (up to date)')
1302
- : c(yellow, ` (${space.pendingCount} pending)`);
1303
- return ` ${glyph} ${c(dim, tag)} ${space.spaceId} → head ${c(dim, head)}, marker ${c(dim, marker)}${pending}`;
1304
- }
1305
-
1306
- function formatInvariantList(ids: readonly string[]): string {
1307
- return ids.length === 0 ? '(none)' : ids.join(', ');
1308
- }
1309
-
1310
- function summarizeRefDistance(
1311
- graph: MigrationGraph,
1312
- markerHash: string,
1313
- refHash: string,
1314
- refName: string,
1315
- ): string {
1316
- if (markerHash === refHash) return `At ref "${refName}" target`;
1317
-
1318
- const pathToRef = findPath(graph, markerHash, refHash);
1319
- if (pathToRef) return `${pathToRef.length} migration(s) behind ref "${refName}"`;
1320
-
1321
- const pathFromRef = findPath(graph, refHash, markerHash);
1322
- if (pathFromRef) return `${pathFromRef.length} migration(s) ahead of ref "${refName}"`;
1323
-
1324
- return `No path between database marker and ref "${refName}" target`;
1325
- }