@prisma-next/cli 0.11.0 → 0.12.0-dev.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +13 -9
  2. package/dist/cli.mjs +259 -12
  3. package/dist/cli.mjs.map +1 -1
  4. package/dist/{client-oXO2WCPD.mjs → client-CDr4o07S.mjs} +88 -63
  5. package/dist/client-CDr4o07S.mjs.map +1 -0
  6. package/dist/{command-helpers-BSb0tRC8.mjs → command-helpers-Bbw1GbwL.mjs} +646 -46
  7. package/dist/command-helpers-Bbw1GbwL.mjs.map +1 -0
  8. package/dist/commands/contract-emit.d.mts.map +1 -1
  9. package/dist/commands/contract-emit.mjs +1 -1
  10. package/dist/commands/contract-infer.d.mts.map +1 -1
  11. package/dist/commands/contract-infer.mjs +1 -1
  12. package/dist/commands/db-init.d.mts.map +1 -1
  13. package/dist/commands/db-init.mjs +32 -7
  14. package/dist/commands/db-init.mjs.map +1 -1
  15. package/dist/commands/db-schema.d.mts.map +1 -1
  16. package/dist/commands/db-schema.mjs +3 -4
  17. package/dist/commands/db-schema.mjs.map +1 -1
  18. package/dist/commands/db-sign.d.mts.map +1 -1
  19. package/dist/commands/db-sign.mjs +12 -10
  20. package/dist/commands/db-sign.mjs.map +1 -1
  21. package/dist/commands/db-update.d.mts.map +1 -1
  22. package/dist/commands/db-update.mjs +41 -11
  23. package/dist/commands/db-update.mjs.map +1 -1
  24. package/dist/commands/db-verify.d.mts.map +1 -1
  25. package/dist/commands/db-verify.mjs +1 -1
  26. package/dist/commands/migrate.d.mts +6 -2
  27. package/dist/commands/migrate.d.mts.map +1 -1
  28. package/dist/commands/migrate.mjs +75 -40
  29. package/dist/commands/migrate.mjs.map +1 -1
  30. package/dist/commands/migration-check.d.mts +4 -3
  31. package/dist/commands/migration-check.d.mts.map +1 -1
  32. package/dist/commands/migration-check.mjs +1 -280
  33. package/dist/commands/migration-graph.d.mts +31 -2
  34. package/dist/commands/migration-graph.d.mts.map +1 -1
  35. package/dist/commands/migration-graph.mjs +2 -137
  36. package/dist/commands/migration-list.d.mts +64 -4
  37. package/dist/commands/migration-list.d.mts.map +1 -1
  38. package/dist/commands/migration-list.mjs +143 -56
  39. package/dist/commands/migration-list.mjs.map +1 -1
  40. package/dist/commands/migration-log.d.mts +10 -1
  41. package/dist/commands/migration-log.d.mts.map +1 -1
  42. package/dist/commands/migration-log.mjs +10 -15
  43. package/dist/commands/migration-log.mjs.map +1 -1
  44. package/dist/commands/migration-new.d.mts.map +1 -1
  45. package/dist/commands/migration-new.mjs +32 -38
  46. package/dist/commands/migration-new.mjs.map +1 -1
  47. package/dist/commands/migration-plan.d.mts +3 -2
  48. package/dist/commands/migration-plan.d.mts.map +1 -1
  49. package/dist/commands/migration-plan.mjs +1 -1
  50. package/dist/commands/migration-show.d.mts +4 -55
  51. package/dist/commands/migration-show.d.mts.map +1 -1
  52. package/dist/commands/migration-show.mjs +61 -153
  53. package/dist/commands/migration-show.mjs.map +1 -1
  54. package/dist/commands/migration-status.d.mts +12 -49
  55. package/dist/commands/migration-status.d.mts.map +1 -1
  56. package/dist/commands/migration-status.mjs +86 -82
  57. package/dist/commands/migration-status.mjs.map +1 -1
  58. package/dist/commands/ref.d.mts +1 -1
  59. package/dist/commands/ref.d.mts.map +1 -1
  60. package/dist/commands/ref.mjs +38 -10
  61. package/dist/commands/ref.mjs.map +1 -1
  62. package/dist/config-loader-B6sJjXTv.mjs.map +1 -1
  63. package/dist/config-loader.d.mts.map +1 -1
  64. package/dist/contract-at-errors-BxP-TOMl.mjs +42 -0
  65. package/dist/contract-at-errors-BxP-TOMl.mjs.map +1 -0
  66. package/dist/{contract-emit-bcrpT-wD.mjs → contract-emit-D-4jrNve.mjs} +25 -10
  67. package/dist/contract-emit-D-4jrNve.mjs.map +1 -0
  68. package/dist/{contract-emit-r4y8Zhf1.mjs → contract-emit-DxcGl4Uq.mjs} +19 -14
  69. package/dist/contract-emit-DxcGl4Uq.mjs.map +1 -0
  70. package/dist/{contract-enrichment-Dani0mMW.mjs → contract-enrichment-a0V5Y_mL.mjs} +4 -25
  71. package/dist/contract-enrichment-a0V5Y_mL.mjs.map +1 -0
  72. package/dist/{contract-infer-BmySmqVT.mjs → contract-infer-C8J1WMvO.mjs} +4 -5
  73. package/dist/{contract-infer-BmySmqVT.mjs.map → contract-infer-C8J1WMvO.mjs.map} +1 -1
  74. package/dist/contract-space-aggregate-loader-DvZwdkrr.mjs +247 -0
  75. package/dist/contract-space-aggregate-loader-DvZwdkrr.mjs.map +1 -0
  76. package/dist/{db-verify-BClPs3ph.mjs → db-verify-BeRHwN8M.mjs} +5 -7
  77. package/dist/{db-verify-BClPs3ph.mjs.map → db-verify-BeRHwN8M.mjs.map} +1 -1
  78. package/dist/exports/control-api.d.mts +3 -3
  79. package/dist/exports/control-api.d.mts.map +1 -1
  80. package/dist/exports/control-api.mjs +3 -3
  81. package/dist/exports/index.d.mts.map +1 -1
  82. package/dist/exports/index.mjs +1 -1
  83. package/dist/exports/index.mjs.map +1 -1
  84. package/dist/exports/init-output.d.mts.map +1 -1
  85. package/dist/exports/init-output.mjs +1 -1
  86. package/dist/extension-pack-inputs-IDvjRCi3.mjs +62 -0
  87. package/dist/extension-pack-inputs-IDvjRCi3.mjs.map +1 -0
  88. package/dist/{framework-components-65gOHkHB.mjs → framework-components-fYXjz_in.mjs} +2 -2
  89. package/dist/{framework-components-65gOHkHB.mjs.map → framework-components-fYXjz_in.mjs.map} +1 -1
  90. package/dist/global-flags-DEHjV8_s.d.mts +34 -0
  91. package/dist/global-flags-DEHjV8_s.d.mts.map +1 -0
  92. package/dist/{graph-render-DJVv0_uf.mjs → graph-render-rFAqZujX.mjs} +2 -2
  93. package/dist/{graph-render-DJVv0_uf.mjs.map → graph-render-rFAqZujX.mjs.map} +1 -1
  94. package/dist/{init-BCJZPWE1.mjs → init-Cv9UzWL5.mjs} +20 -269
  95. package/dist/init-Cv9UzWL5.mjs.map +1 -0
  96. package/dist/{inspect-live-schema-DSRbFoOL.mjs → inspect-live-schema-BlKR2Zln.mjs} +4 -5
  97. package/dist/{inspect-live-schema-DSRbFoOL.mjs.map → inspect-live-schema-BlKR2Zln.mjs.map} +1 -1
  98. package/dist/migration-check-BiBJoYYW.mjs +341 -0
  99. package/dist/migration-check-BiBJoYYW.mjs.map +1 -0
  100. package/dist/migration-cli.d.mts.map +1 -1
  101. package/dist/migration-cli.mjs +4 -4
  102. package/dist/migration-cli.mjs.map +1 -1
  103. package/dist/{migration-command-scaffold-Bzd9La5c.mjs → migration-command-scaffold-BAGUiGOK.mjs} +4 -5
  104. package/dist/{migration-command-scaffold-Bzd9La5c.mjs.map → migration-command-scaffold-BAGUiGOK.mjs.map} +1 -1
  105. package/dist/migration-graph-C9WC-7eO.mjs +1478 -0
  106. package/dist/migration-graph-C9WC-7eO.mjs.map +1 -0
  107. package/dist/migration-list-styler-BRwF4-gy.mjs +399 -0
  108. package/dist/migration-list-styler-BRwF4-gy.mjs.map +1 -0
  109. package/dist/{migration-plan-CFwqw3Gk.mjs → migration-plan-9DJ7q7_z.mjs} +372 -133
  110. package/dist/migration-plan-9DJ7q7_z.mjs.map +1 -0
  111. package/dist/{migration-types-BXWvz12q.d.mts → migration-types-D2FW63pr.d.mts} +1 -1
  112. package/dist/{migration-types-BXWvz12q.d.mts.map → migration-types-D2FW63pr.d.mts.map} +1 -1
  113. package/dist/{migrations-CwZMa1Ck.mjs → migrations-Cv2jxNNK.mjs} +12 -13
  114. package/dist/migrations-Cv2jxNNK.mjs.map +1 -0
  115. package/dist/{output-BlsrGMEF.mjs → output-B60Gw5fu.mjs} +1 -1
  116. package/dist/{output-BlsrGMEF.mjs.map → output-B60Gw5fu.mjs.map} +1 -1
  117. package/dist/{progress-adapter-DFfvZcYL.mjs → progress-adapter-C644QK8l.mjs} +1 -1
  118. package/dist/{progress-adapter-DFfvZcYL.mjs.map → progress-adapter-C644QK8l.mjs.map} +1 -1
  119. package/dist/ref-advancement-DUZqsue6.mjs +50 -0
  120. package/dist/ref-advancement-DUZqsue6.mjs.map +1 -0
  121. package/dist/terminal-ui-5Y6mrg93.d.mts +133 -0
  122. package/dist/terminal-ui-5Y6mrg93.d.mts.map +1 -0
  123. package/dist/{types--CqjMdk0.d.mts → types-CeC5ec2Y.d.mts} +35 -29
  124. package/dist/types-CeC5ec2Y.d.mts.map +1 -0
  125. package/dist/{verify-Bom75OYI.mjs → verify-DCA9Sldu.mjs} +2 -2
  126. package/dist/{verify-Bom75OYI.mjs.map → verify-DCA9Sldu.mjs.map} +1 -1
  127. package/package.json +35 -24
  128. package/src/commands/contract-emit.ts +19 -7
  129. package/src/commands/contract-infer.ts +1 -1
  130. package/src/commands/db-init.ts +48 -2
  131. package/src/commands/db-sign.ts +9 -5
  132. package/src/commands/db-update.ts +54 -8
  133. package/src/commands/init/hygiene-gitattributes.ts +2 -2
  134. package/src/commands/init/index.ts +2 -1
  135. package/src/commands/init/templates/code-templates.ts +4 -2
  136. package/src/commands/init/templates/env.ts +13 -14
  137. package/src/commands/migrate.ts +125 -44
  138. package/src/commands/migration-check.ts +43 -83
  139. package/src/commands/migration-graph.ts +116 -60
  140. package/src/commands/migration-list.ts +220 -74
  141. package/src/commands/migration-log.ts +8 -14
  142. package/src/commands/migration-new.ts +44 -48
  143. package/src/commands/migration-plan.ts +412 -197
  144. package/src/commands/migration-show.ts +65 -284
  145. package/src/commands/migration-status.ts +128 -125
  146. package/src/commands/ref.ts +53 -8
  147. package/src/control-api/client.ts +11 -2
  148. package/src/control-api/contract-enrichment.ts +6 -42
  149. package/src/control-api/operations/{apply-aggregate.ts → apply.ts} +45 -75
  150. package/src/control-api/operations/contract-emit.ts +14 -6
  151. package/src/control-api/operations/{db-apply-aggregate.ts → db-apply.ts} +19 -19
  152. package/src/control-api/operations/db-init.ts +4 -4
  153. package/src/control-api/operations/db-update.ts +4 -4
  154. package/src/control-api/operations/db-verify.ts +15 -11
  155. package/src/control-api/operations/migration-apply.ts +66 -50
  156. package/src/control-api/types.ts +38 -28
  157. package/src/migration-cli.ts +4 -4
  158. package/src/utils/cli-errors.ts +234 -0
  159. package/src/utils/command-helpers.ts +9 -24
  160. package/src/utils/contract-at-errors.ts +96 -0
  161. package/src/utils/contract-space-aggregate-loader.ts +336 -117
  162. package/src/utils/formatters/migration-graph-lane-colors.ts +31 -0
  163. package/src/utils/formatters/migration-graph-layout.ts +1141 -0
  164. package/src/utils/formatters/migration-graph-rows.ts +336 -0
  165. package/src/utils/formatters/migration-graph-tree-render.ts +768 -0
  166. package/src/utils/formatters/migration-list-data-column.ts +115 -0
  167. package/src/utils/formatters/migration-list-graph-topology.ts +368 -0
  168. package/src/utils/formatters/migration-list-render.ts +191 -0
  169. package/src/utils/formatters/migration-list-styler.ts +63 -0
  170. package/src/utils/formatters/migration-list-types.ts +21 -0
  171. package/src/utils/formatters/migrations.ts +37 -46
  172. package/src/utils/glyph-mode.ts +22 -0
  173. package/src/utils/integrity-violation-to-check-failure.ts +130 -0
  174. package/src/utils/plan-resolution.ts +258 -0
  175. package/src/utils/ref-advancement.ts +68 -0
  176. package/src/utils/terminal-ui.ts +42 -1
  177. package/dist/cli-errors-Czmx92Zy.d.mts +0 -3
  178. package/dist/cli-errors-Djtz98Vm.mjs +0 -71
  179. package/dist/cli-errors-Djtz98Vm.mjs.map +0 -1
  180. package/dist/client-oXO2WCPD.mjs.map +0 -1
  181. package/dist/command-helpers-BSb0tRC8.mjs.map +0 -1
  182. package/dist/commands/migration-check.mjs.map +0 -1
  183. package/dist/commands/migration-graph.mjs.map +0 -1
  184. package/dist/contract-emit-bcrpT-wD.mjs.map +0 -1
  185. package/dist/contract-emit-r4y8Zhf1.mjs.map +0 -1
  186. package/dist/contract-enrichment-Dani0mMW.mjs.map +0 -1
  187. package/dist/contract-space-aggregate-loader-BmNQwlws.mjs +0 -160
  188. package/dist/contract-space-aggregate-loader-BmNQwlws.mjs.map +0 -1
  189. package/dist/global-flags-CdE7M0d9.d.mts +0 -15
  190. package/dist/global-flags-CdE7M0d9.d.mts.map +0 -1
  191. package/dist/init-BCJZPWE1.mjs.map +0 -1
  192. package/dist/migration-plan-CFwqw3Gk.mjs.map +0 -1
  193. package/dist/migrations-CwZMa1Ck.mjs.map +0 -1
  194. package/dist/rolldown-runtime-twds-ZHy.mjs +0 -14
  195. package/dist/terminal-ui-BiB_8KNo.mjs +0 -379
  196. package/dist/terminal-ui-BiB_8KNo.mjs.map +0 -1
  197. package/dist/types--CqjMdk0.d.mts.map +0 -1
@@ -0,0 +1,115 @@
1
+ import type { GlyphMode } from '../glyph-mode';
2
+ import type { MigrationEdgeKind } from './migration-list-graph-topology';
3
+ import type { MigrationListStyler } from './migration-list-render';
4
+ import type { MigrationListEntry } from './migration-list-types';
5
+
6
+ export const MIGRATION_LIST_HASH_WIDTH = 7;
7
+ export const MIGRATION_LIST_EMPTY_SOURCE = '∅';
8
+ export const MIGRATION_LIST_ASCII_EMPTY_SOURCE = '-';
9
+ export const MIGRATION_LIST_FORWARD_EDGE_GLYPH = '→';
10
+ export const MIGRATION_LIST_ASCII_FORWARD_EDGE_GLYPH = '->';
11
+ export const MIGRATION_LIST_DECORATION_PREFIX = ' ';
12
+
13
+ export const MIGRATION_LIST_UNICODE_KIND_GLYPH: Record<MigrationEdgeKind, string> = {
14
+ forward: '*',
15
+ rollback: '↩',
16
+ self: '⟲',
17
+ };
18
+
19
+ export const MIGRATION_LIST_ASCII_KIND_GLYPH: Record<MigrationEdgeKind, string> = {
20
+ forward: '*',
21
+ rollback: '<',
22
+ self: '~',
23
+ };
24
+
25
+ export function migrationListKindGlyph(glyphMode: GlyphMode, edgeKind: MigrationEdgeKind): string {
26
+ return glyphMode === 'ascii'
27
+ ? MIGRATION_LIST_ASCII_KIND_GLYPH[edgeKind]
28
+ : MIGRATION_LIST_UNICODE_KIND_GLYPH[edgeKind];
29
+ }
30
+
31
+ export function migrationListForwardArrow(glyphMode: GlyphMode): string {
32
+ return glyphMode === 'ascii'
33
+ ? MIGRATION_LIST_ASCII_FORWARD_EDGE_GLYPH
34
+ : MIGRATION_LIST_FORWARD_EDGE_GLYPH;
35
+ }
36
+
37
+ export function migrationListEmptySource(glyphMode: GlyphMode): string {
38
+ return glyphMode === 'ascii' ? MIGRATION_LIST_ASCII_EMPTY_SOURCE : MIGRATION_LIST_EMPTY_SOURCE;
39
+ }
40
+
41
+ export function abbreviateContractHash(hash: string): string {
42
+ const stripped = hash.startsWith('sha256:') ? hash.slice(7) : hash;
43
+ return stripped.slice(0, MIGRATION_LIST_HASH_WIDTH);
44
+ }
45
+
46
+ export function computeMigrationDirNameWidth(migrations: readonly MigrationListEntry[]): number {
47
+ if (migrations.length === 0) return 0;
48
+ return Math.max(...migrations.map((entry) => entry.dirName.length)) + 2;
49
+ }
50
+
51
+ function formatSourceColumn(
52
+ from: string | null,
53
+ style: MigrationListStyler,
54
+ emptySource: string,
55
+ ): string {
56
+ if (from === null) {
57
+ return style.glyph(emptySource) + ' '.repeat(MIGRATION_LIST_HASH_WIDTH - emptySource.length);
58
+ }
59
+ return style.sourceHash(abbreviateContractHash(from));
60
+ }
61
+
62
+ export function formatDecorations(
63
+ providedInvariants: readonly string[],
64
+ refs: readonly string[],
65
+ style: MigrationListStyler,
66
+ ): string {
67
+ const blocks: string[] = [];
68
+ if (providedInvariants.length > 0) {
69
+ blocks.push(style.invariants(providedInvariants));
70
+ }
71
+ if (refs.length > 0) {
72
+ blocks.push(style.refs(refs));
73
+ }
74
+ if (blocks.length === 0) return '';
75
+ return `${MIGRATION_LIST_DECORATION_PREFIX}${blocks.join(' ')}`;
76
+ }
77
+
78
+ export interface MigrationDataColumnOptions {
79
+ readonly dirNameWidth: number;
80
+ readonly edgeKind: MigrationEdgeKind;
81
+ readonly style: MigrationListStyler;
82
+ readonly forwardArrow?: string;
83
+ readonly emptySource?: string;
84
+ }
85
+
86
+ export function formatMigrationDataColumn(
87
+ migration: MigrationListEntry,
88
+ options: MigrationDataColumnOptions,
89
+ ): string {
90
+ const {
91
+ dirNameWidth,
92
+ edgeKind,
93
+ style,
94
+ forwardArrow = MIGRATION_LIST_FORWARD_EDGE_GLYPH,
95
+ emptySource = MIGRATION_LIST_EMPTY_SOURCE,
96
+ } = options;
97
+ const dirNamePadding = ' '.repeat(Math.max(0, dirNameWidth - migration.dirName.length));
98
+ const dirName = `${style.dirName(migration.dirName)}${dirNamePadding}`;
99
+ const decorations = formatDecorations(migration.providedInvariants, migration.refs, style);
100
+
101
+ if (edgeKind === 'self') {
102
+ const contractHash = migration.from ?? migration.to;
103
+ const hash = style.sourceHash(abbreviateContractHash(contractHash));
104
+ return `${dirName}${hash}${decorations}`;
105
+ }
106
+
107
+ const source = formatSourceColumn(migration.from, style, emptySource);
108
+ const arrow = style.glyph(forwardArrow);
109
+ const dest = style.destHash(abbreviateContractHash(migration.to));
110
+ return `${dirName}${source} ${arrow} ${dest}${decorations}`;
111
+ }
112
+
113
+ export function formatNodeLineDataColumn(contractHash: string, style: MigrationListStyler): string {
114
+ return style.sourceHash(abbreviateContractHash(contractHash));
115
+ }
@@ -0,0 +1,368 @@
1
+ import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
2
+ import type { MigrationGraph } from '@prisma-next/migration-tools/graph';
3
+ import type { MigrationListEntry } from './migration-list-types';
4
+
5
+ export type MigrationEdgeKind = 'forward' | 'rollback' | 'self';
6
+
7
+ export interface MigrationListGraphTopology {
8
+ readonly kindByMigrationHash: ReadonlyMap<string, MigrationEdgeKind>;
9
+ readonly forwardInDegree: ReadonlyMap<string, number>;
10
+ readonly forwardOutDegree: ReadonlyMap<string, number>;
11
+ }
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Shared classifier — operates on a normalized edge shape common to both
15
+ // MigrationListEntry (Tier-2) and MigrationEdge / MigrationGraph (Tier-3).
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface NormalizedEdge {
19
+ readonly hash: string;
20
+ readonly from: string;
21
+ readonly to: string;
22
+ readonly dirName: string;
23
+ }
24
+
25
+ function compareDirNameDesc(a: NormalizedEdge, b: NormalizedEdge): number {
26
+ return b.dirName.localeCompare(a.dirName);
27
+ }
28
+
29
+ function bumpDegree(map: Map<string, number>, key: string): void {
30
+ map.set(key, (map.get(key) ?? 0) + 1);
31
+ }
32
+
33
+ function forwardRootsForDepth(
34
+ nodes: ReadonlySet<string>,
35
+ candidates: readonly NormalizedEdge[],
36
+ ): readonly string[] {
37
+ const inDegree = new Map<string, number>();
38
+ for (const node of nodes) {
39
+ inDegree.set(node, 0);
40
+ }
41
+ for (const edge of candidates) {
42
+ bumpDegree(inDegree, edge.to);
43
+ }
44
+
45
+ const roots: string[] = [];
46
+ for (const node of nodes) {
47
+ if ((inDegree.get(node) ?? 0) === 0) {
48
+ roots.push(node);
49
+ }
50
+ }
51
+ roots.sort((a, b) => {
52
+ if (a === EMPTY_CONTRACT_HASH) return -1;
53
+ if (b === EMPTY_CONTRACT_HASH) return 1;
54
+ return a.localeCompare(b);
55
+ });
56
+ if (roots.length > 0) return roots;
57
+
58
+ return [...nodes].sort((a, b) => {
59
+ if (a === EMPTY_CONTRACT_HASH) return -1;
60
+ if (b === EMPTY_CONTRACT_HASH) return 1;
61
+ return a.localeCompare(b);
62
+ });
63
+ }
64
+
65
+ function longestPathDepths(
66
+ nodes: ReadonlySet<string>,
67
+ candidates: readonly NormalizedEdge[],
68
+ ): Map<string, number> {
69
+ const depth = new Map<string, number>();
70
+ for (const root of forwardRootsForDepth(nodes, candidates)) {
71
+ depth.set(root, 0);
72
+ }
73
+
74
+ const maxPasses = nodes.size;
75
+ for (let pass = 0; pass < maxPasses; pass++) {
76
+ let changed = false;
77
+ for (const edge of candidates) {
78
+ const base = depth.get(edge.from);
79
+ if (base === undefined) continue;
80
+ const next = base + 1;
81
+ if (next > (depth.get(edge.to) ?? -1)) {
82
+ depth.set(edge.to, next);
83
+ changed = true;
84
+ }
85
+ }
86
+ if (!changed) break;
87
+ }
88
+
89
+ for (const node of nodes) {
90
+ if (!depth.has(node)) {
91
+ depth.set(node, 0);
92
+ }
93
+ }
94
+
95
+ return depth;
96
+ }
97
+
98
+ function canReachForward(
99
+ start: string,
100
+ goal: string,
101
+ candidates: readonly NormalizedEdge[],
102
+ ): boolean {
103
+ if (start === goal) return true;
104
+
105
+ const outgoing = new Map<string, string[]>();
106
+ for (const edge of candidates) {
107
+ const bucket = outgoing.get(edge.from);
108
+ if (bucket) bucket.push(edge.to);
109
+ else outgoing.set(edge.from, [edge.to]);
110
+ }
111
+
112
+ const visited = new Set<string>([start]);
113
+ const queue = [start];
114
+ while (queue.length > 0) {
115
+ const node = queue.shift();
116
+ if (node === undefined) continue;
117
+ for (const next of outgoing.get(node) ?? []) {
118
+ if (next === goal) return true;
119
+ if (!visited.has(next)) {
120
+ visited.add(next);
121
+ queue.push(next);
122
+ }
123
+ }
124
+ }
125
+
126
+ return false;
127
+ }
128
+
129
+ function isMarginalForwardEdge(
130
+ nodes: ReadonlySet<string>,
131
+ candidates: readonly NormalizedEdge[],
132
+ edge: NormalizedEdge,
133
+ ): boolean {
134
+ const without = candidates.filter((candidate) => candidate !== edge);
135
+ const depthWithout = longestPathDepths(nodes, without);
136
+ const depthWith = longestPathDepths(nodes, candidates);
137
+ const fromDepth = depthWithout.get(edge.from) ?? 0;
138
+ const toWith = depthWith.get(edge.to) ?? 0;
139
+ return toWith > fromDepth;
140
+ }
141
+
142
+ // The first branch is the load-bearing one: a forward edge `from → to` is a
143
+ // disguised node-skipping rollback when, after removing it, `to` can still
144
+ // reach `from` and `from` sits strictly deeper than `to + 1` (a longer path
145
+ // already connects them). This branch fires on every cycle-closing edge, and
146
+ // the caller peels exactly one edge (dirName-max) per iteration before
147
+ // recomputing — so cycles are broken deterministically regardless of edge
148
+ // order. `isMarginalForwardEdge` is only a fallback for the residual case and
149
+ // is reached only while the candidate set is still cyclic.
150
+ function shouldPeelForwardEdge(
151
+ nodes: ReadonlySet<string>,
152
+ candidates: readonly NormalizedEdge[],
153
+ edge: NormalizedEdge,
154
+ ): boolean {
155
+ const without = candidates.filter((candidate) => candidate !== edge);
156
+ const depthWithout = longestPathDepths(nodes, without);
157
+ const fromDepth = depthWithout.get(edge.from) ?? 0;
158
+ const toWithout = depthWithout.get(edge.to) ?? 0;
159
+
160
+ if (canReachForward(edge.to, edge.from, without) && fromDepth > toWithout + 1) {
161
+ return true;
162
+ }
163
+
164
+ return !isMarginalForwardEdge(nodes, candidates, edge);
165
+ }
166
+
167
+ function peelNonMarginalForwardEdges(
168
+ nodes: ReadonlySet<string>,
169
+ kindByMigrationHash: Map<string, MigrationEdgeKind>,
170
+ nonSelf: readonly NormalizedEdge[],
171
+ ): void {
172
+ let candidates = nonSelf.filter((edge) => kindByMigrationHash.get(edge.hash) === 'forward');
173
+
174
+ while (candidates.length > 0) {
175
+ const rollbackCandidates = candidates.filter((edge) =>
176
+ shouldPeelForwardEdge(nodes, candidates, edge),
177
+ );
178
+ if (rollbackCandidates.length === 0) break;
179
+
180
+ rollbackCandidates.sort(compareDirNameDesc);
181
+ const rollback = rollbackCandidates[0];
182
+ if (rollback === undefined) break;
183
+
184
+ kindByMigrationHash.set(rollback.hash, 'rollback');
185
+ candidates = candidates.filter((edge) => edge !== rollback);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * DFS with dirName-descending traversal. A GRAY target is a rollback only when it
191
+ * is the immediate DFS parent of the source — cross-links to other GRAY nodes
192
+ * stay forward. A follow-up peel pass drops node-skipping rollbacks (target can
193
+ * reach the source on the forward subgraph and sits more than one rank below).
194
+ */
195
+ function classifyNormalizedEdges(edges: readonly NormalizedEdge[]): MigrationListGraphTopology {
196
+ const nodes = new Set<string>();
197
+ const kindByMigrationHash = new Map<string, MigrationEdgeKind>();
198
+ const outgoingByFrom = new Map<string, NormalizedEdge[]>();
199
+ const nonSelf: NormalizedEdge[] = [];
200
+
201
+ for (const edge of edges) {
202
+ nodes.add(edge.from);
203
+ nodes.add(edge.to);
204
+
205
+ if (edge.from === edge.to) {
206
+ kindByMigrationHash.set(edge.hash, 'self');
207
+ continue;
208
+ }
209
+
210
+ nonSelf.push(edge);
211
+ const bucket = outgoingByFrom.get(edge.from);
212
+ if (bucket) bucket.push(edge);
213
+ else outgoingByFrom.set(edge.from, [edge]);
214
+ }
215
+
216
+ for (const bucket of outgoingByFrom.values()) {
217
+ bucket.sort(compareDirNameDesc);
218
+ }
219
+
220
+ const nonSelfInDegree = new Map<string, number>();
221
+ for (const node of nodes) {
222
+ nonSelfInDegree.set(node, 0);
223
+ }
224
+ for (const bucket of outgoingByFrom.values()) {
225
+ for (const edge of bucket) {
226
+ bumpDegree(nonSelfInDegree, edge.to);
227
+ }
228
+ }
229
+
230
+ const dfsRoots: string[] = [];
231
+ for (const node of nodes) {
232
+ if ((nonSelfInDegree.get(node) ?? 0) === 0) {
233
+ dfsRoots.push(node);
234
+ }
235
+ }
236
+ dfsRoots.sort((a, b) => {
237
+ if (a === EMPTY_CONTRACT_HASH) return -1;
238
+ if (b === EMPTY_CONTRACT_HASH) return 1;
239
+ return a.localeCompare(b);
240
+ });
241
+ if (dfsRoots.length === 0) {
242
+ dfsRoots.push(...[...nodes].sort((a, b) => a.localeCompare(b)));
243
+ }
244
+
245
+ const WHITE = 0;
246
+ const GRAY = 1;
247
+ const BLACK = 2;
248
+ const color = new Map<string, number>();
249
+ const dfsParent = new Map<string, string | undefined>();
250
+ for (const node of nodes) {
251
+ color.set(node, WHITE);
252
+ }
253
+
254
+ interface Frame {
255
+ node: string;
256
+ outgoing: readonly NormalizedEdge[];
257
+ index: number;
258
+ }
259
+ const stack: Frame[] = [];
260
+
261
+ function isImmediateDfsParent(ancestor: string, node: string): boolean {
262
+ return dfsParent.get(node) === ancestor;
263
+ }
264
+
265
+ function pushFrame(node: string, parent: string | undefined): void {
266
+ color.set(node, GRAY);
267
+ dfsParent.set(node, parent);
268
+ stack.push({ node, outgoing: outgoingByFrom.get(node) ?? [], index: 0 });
269
+ }
270
+
271
+ function runDfsFrom(root: string): void {
272
+ if (color.get(root) !== WHITE) return;
273
+ pushFrame(root, undefined);
274
+
275
+ while (stack.length > 0) {
276
+ const frame = stack[stack.length - 1];
277
+ if (frame === undefined) break;
278
+ if (frame.index >= frame.outgoing.length) {
279
+ color.set(frame.node, BLACK);
280
+ stack.pop();
281
+ continue;
282
+ }
283
+
284
+ const edge = frame.outgoing[frame.index];
285
+ frame.index += 1;
286
+ if (edge === undefined) continue;
287
+
288
+ const v = edge.to;
289
+ const vColor = color.get(v);
290
+ if (vColor === GRAY && isImmediateDfsParent(v, frame.node)) {
291
+ kindByMigrationHash.set(edge.hash, 'rollback');
292
+ } else {
293
+ kindByMigrationHash.set(edge.hash, 'forward');
294
+ if (vColor === WHITE) {
295
+ pushFrame(v, frame.node);
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ for (const root of dfsRoots) {
302
+ runDfsFrom(root);
303
+ }
304
+ const remainingWhite = [...nodes].filter((node) => color.get(node) === WHITE);
305
+ remainingWhite.sort((a, b) => a.localeCompare(b));
306
+ for (const root of remainingWhite) {
307
+ runDfsFrom(root);
308
+ }
309
+
310
+ peelNonMarginalForwardEdges(nodes, kindByMigrationHash, nonSelf);
311
+
312
+ const forwardInDegree = new Map<string, number>();
313
+ const forwardOutDegree = new Map<string, number>();
314
+
315
+ for (const edge of edges) {
316
+ if (kindByMigrationHash.get(edge.hash) !== 'forward') continue;
317
+ bumpDegree(forwardOutDegree, edge.from);
318
+ bumpDegree(forwardInDegree, edge.to);
319
+ }
320
+
321
+ return {
322
+ kindByMigrationHash,
323
+ forwardInDegree,
324
+ forwardOutDegree,
325
+ };
326
+ }
327
+
328
+ function canonicalFrom(from: string | null): string {
329
+ return from ?? EMPTY_CONTRACT_HASH;
330
+ }
331
+
332
+ /**
333
+ * Classify forward/rollback/self for a Tier-2 `MigrationListEntry[]` edge set.
334
+ * Returns the kind of each migration plus the forward in/out degree of each
335
+ * contract node. This is the established Tier-2 surface; its behaviour is
336
+ * unchanged — only its implementation now delegates to the shared classifier.
337
+ */
338
+ export function classifyMigrationListGraphTopology(
339
+ entries: readonly MigrationListEntry[],
340
+ ): MigrationListGraphTopology {
341
+ const normalized: NormalizedEdge[] = entries.map((entry) => ({
342
+ hash: entry.migrationHash,
343
+ from: canonicalFrom(entry.from),
344
+ to: entry.to,
345
+ dirName: entry.dirName,
346
+ }));
347
+ return classifyNormalizedEdges(normalized);
348
+ }
349
+
350
+ /**
351
+ * Classify forward/rollback/self for a `MigrationGraph` edge set (Tier-3).
352
+ * Delegates to the same shared classifier as `classifyMigrationListGraphTopology`
353
+ * so both tiers agree on forward/rollback/self without duplicating logic.
354
+ */
355
+ export function classifyMigrationGraphTopology(graph: MigrationGraph): MigrationListGraphTopology {
356
+ const normalized: NormalizedEdge[] = [];
357
+ for (const edges of graph.forwardChain.values()) {
358
+ for (const edge of edges) {
359
+ normalized.push({
360
+ hash: edge.migrationHash,
361
+ from: edge.from,
362
+ to: edge.to,
363
+ dirName: edge.dirName,
364
+ });
365
+ }
366
+ }
367
+ return classifyNormalizedEdges(normalized);
368
+ }
@@ -0,0 +1,191 @@
1
+ import type { GlyphMode } from '../glyph-mode';
2
+ import {
3
+ computeMigrationDirNameWidth,
4
+ formatMigrationDataColumn,
5
+ migrationListEmptySource,
6
+ migrationListForwardArrow,
7
+ migrationListKindGlyph,
8
+ } from './migration-list-data-column';
9
+ import {
10
+ classifyMigrationListGraphTopology,
11
+ type MigrationEdgeKind,
12
+ type MigrationListGraphTopology,
13
+ } from './migration-list-graph-topology';
14
+ import type { MigrationListEntry, MigrationListResult } from './migration-list-types';
15
+
16
+ export type { GlyphMode } from '../glyph-mode';
17
+ export type { MigrationEdgeKind } from './migration-list-graph-topology';
18
+ export type {
19
+ MigrationListEntry,
20
+ MigrationListResult,
21
+ MigrationSpaceListEntry,
22
+ } from './migration-list-types';
23
+
24
+ /**
25
+ * Semantic styler for `migration list` output tokens. Token-typed so
26
+ * the renderer composes presentation-neutral fragments and the styler
27
+ * decides how each token kind is decorated (ANSI codes, plain text,
28
+ * etc.). The renderer pads with raw spaces *outside* styled tokens so
29
+ * visible column widths stay stable regardless of what the styler
30
+ * emits — adding ANSI escape sequences never disturbs alignment.
31
+ *
32
+ * `invariants` and `refs` receive the underlying string arrays rather
33
+ * than a pre-joined string so per-element styling (e.g. distinguishing
34
+ * the live-DB `db` marker from user-named refs) is possible without
35
+ * having to re-parse a joined block.
36
+ */
37
+ export interface MigrationListStyler {
38
+ kind(text: string): string;
39
+ dirName(text: string): string;
40
+ sourceHash(text: string): string;
41
+ destHash(text: string): string;
42
+ glyph(text: string): string;
43
+ lane(text: string): string;
44
+ invariants(ids: readonly string[]): string;
45
+ refs(names: readonly string[]): string;
46
+ spaceHeading(text: string): string;
47
+ summary(text: string): string;
48
+ emptyState(text: string): string;
49
+ }
50
+
51
+ export const IDENTITY_MIGRATION_LIST_STYLER: MigrationListStyler = {
52
+ kind: (text) => text,
53
+ dirName: (text) => text,
54
+ sourceHash: (text) => text,
55
+ destHash: (text) => text,
56
+ glyph: (text) => text,
57
+ lane: (text) => text,
58
+ invariants: (ids) => `{${ids.join(', ')}}`,
59
+ refs: (names) => `(${names.join(', ')})`,
60
+ spaceHeading: (text) => text,
61
+ summary: (text) => text,
62
+ emptyState: (text) => text,
63
+ };
64
+
65
+ function resolveEdgeKind(
66
+ migrationHash: string,
67
+ kindByMigrationHash: ReadonlyMap<string, MigrationEdgeKind>,
68
+ ): MigrationEdgeKind {
69
+ return kindByMigrationHash.get(migrationHash) ?? 'forward';
70
+ }
71
+
72
+ function formatMigrationRow(
73
+ migration: MigrationListEntry,
74
+ dirNameWidth: number,
75
+ edgeKind: MigrationEdgeKind,
76
+ glyphMode: GlyphMode,
77
+ style: MigrationListStyler,
78
+ ): string {
79
+ const kindColumn = `${style.kind(migrationListKindGlyph(glyphMode, edgeKind))} `;
80
+ const data = formatMigrationDataColumn(migration, {
81
+ dirNameWidth,
82
+ edgeKind,
83
+ style,
84
+ forwardArrow: migrationListForwardArrow(glyphMode),
85
+ emptySource: migrationListEmptySource(glyphMode),
86
+ });
87
+ return `${kindColumn}${data}`;
88
+ }
89
+
90
+ function formatEmptyStateLine(spaceId: string, style: MigrationListStyler): string {
91
+ return style.emptyState(`There are no migrations in migrations/${spaceId}/ yet`);
92
+ }
93
+
94
+ function renderSpaceBlock(
95
+ spaceId: string,
96
+ migrations: readonly MigrationListEntry[],
97
+ multiSpace: boolean,
98
+ glyphMode: GlyphMode,
99
+ kindByMigrationHash: ReadonlyMap<string, MigrationEdgeKind>,
100
+ style: MigrationListStyler,
101
+ ): readonly string[] {
102
+ if (migrations.length === 0) {
103
+ const emptyLine = formatEmptyStateLine(spaceId, style);
104
+ if (!multiSpace) {
105
+ return [emptyLine];
106
+ }
107
+ return [style.spaceHeading(`${spaceId}:`), ` ${emptyLine}`];
108
+ }
109
+
110
+ const dirNameWidth = computeMigrationDirNameWidth(migrations);
111
+ const rows = migrations.map((entry) =>
112
+ formatMigrationRow(
113
+ entry,
114
+ dirNameWidth,
115
+ resolveEdgeKind(entry.migrationHash, kindByMigrationHash),
116
+ glyphMode,
117
+ style,
118
+ ),
119
+ );
120
+ if (!multiSpace) {
121
+ return rows;
122
+ }
123
+ return [style.spaceHeading(`${spaceId}:`), ...rows.map((row) => ` ${row}`)];
124
+ }
125
+
126
+ export function buildMigrationListTopologyBySpace(
127
+ result: MigrationListResult,
128
+ ): ReadonlyMap<string, MigrationListGraphTopology> {
129
+ const topologyBySpaceId = new Map<string, MigrationListGraphTopology>();
130
+ for (const space of result.spaces) {
131
+ topologyBySpaceId.set(space.spaceId, classifyMigrationListGraphTopology(space.migrations));
132
+ }
133
+ return topologyBySpaceId;
134
+ }
135
+
136
+ /**
137
+ * Compose the styled `migration list` output. The renderer is
138
+ * presentation-neutral — every token passes through `style` before
139
+ * landing in the output, so the same composition serves the pure-text
140
+ * path ({@link renderMigrationList} via
141
+ * {@link IDENTITY_MIGRATION_LIST_STYLER}) and the ANSI-styled CLI path
142
+ * (via the ANSI styler the CLI shell wires up).
143
+ */
144
+ export function renderMigrationListWithStyle(
145
+ result: MigrationListResult,
146
+ style: MigrationListStyler,
147
+ glyphMode: GlyphMode = 'unicode',
148
+ topologyBySpaceId: ReadonlyMap<
149
+ string,
150
+ MigrationListGraphTopology
151
+ > = buildMigrationListTopologyBySpace(result),
152
+ ): string {
153
+ const multiSpace = result.spaces.length > 1;
154
+ const lines: string[] = [];
155
+
156
+ for (let index = 0; index < result.spaces.length; index++) {
157
+ const space = result.spaces[index]!;
158
+ if (index > 0) {
159
+ lines.push('');
160
+ }
161
+ const topology = topologyBySpaceId.get(space.spaceId);
162
+ const kindByMigrationHash =
163
+ topology?.kindByMigrationHash ??
164
+ classifyMigrationListGraphTopology(space.migrations).kindByMigrationHash;
165
+ lines.push(
166
+ ...renderSpaceBlock(
167
+ space.spaceId,
168
+ space.migrations,
169
+ multiSpace,
170
+ glyphMode,
171
+ kindByMigrationHash,
172
+ style,
173
+ ),
174
+ );
175
+ }
176
+
177
+ const totalMigrations = result.spaces.reduce(
178
+ (count, space) => count + space.migrations.length,
179
+ 0,
180
+ );
181
+ if (totalMigrations > 0) {
182
+ lines.push('');
183
+ lines.push(style.summary(result.summary));
184
+ }
185
+
186
+ return lines.join('\n');
187
+ }
188
+
189
+ export function renderMigrationList(result: MigrationListResult): string {
190
+ return renderMigrationListWithStyle(result, IDENTITY_MIGRATION_LIST_STYLER);
191
+ }