@prisma-next/cli 0.12.0-dev.7 → 0.12.0-dev.71

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 (214) 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-CJzuo5wX.mjs} +222 -107
  5. package/dist/client-CJzuo5wX.mjs.map +1 -0
  6. package/dist/{command-helpers-Bbw1GbwL.mjs → command-helpers-DGMvGBeX.mjs} +318 -25
  7. package/dist/command-helpers-DGMvGBeX.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 +4 -5
  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 -3
  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 +6 -6
  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 +10 -7
  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 +37 -3
  27. package/dist/commands/migrate.d.mts.map +1 -1
  28. package/dist/commands/migrate.mjs +298 -12
  29. package/dist/commands/migrate.mjs.map +1 -1
  30. package/dist/commands/migration-check.d.mts +55 -13
  31. package/dist/commands/migration-check.d.mts.map +1 -1
  32. package/dist/commands/migration-check.mjs +3 -2
  33. package/dist/commands/migration-graph.d.mts +16 -25
  34. package/dist/commands/migration-graph.d.mts.map +1 -1
  35. package/dist/commands/migration-graph.mjs +185 -2
  36. package/dist/commands/migration-graph.mjs.map +1 -0
  37. package/dist/commands/migration-list.d.mts +26 -27
  38. package/dist/commands/migration-list.d.mts.map +1 -1
  39. package/dist/commands/migration-list.mjs +2 -190
  40. package/dist/commands/migration-log.d.mts +9 -19
  41. package/dist/commands/migration-log.d.mts.map +1 -1
  42. package/dist/commands/migration-log.mjs +1 -137
  43. package/dist/commands/migration-new.d.mts.map +1 -1
  44. package/dist/commands/migration-new.mjs +6 -5
  45. package/dist/commands/migration-new.mjs.map +1 -1
  46. package/dist/commands/migration-plan.d.mts +1 -1
  47. package/dist/commands/migration-plan.d.mts.map +1 -1
  48. package/dist/commands/migration-plan.mjs +1 -1
  49. package/dist/commands/migration-show.d.mts +17 -21
  50. package/dist/commands/migration-show.d.mts.map +1 -1
  51. package/dist/commands/migration-show.mjs +24 -36
  52. package/dist/commands/migration-show.mjs.map +1 -1
  53. package/dist/commands/migration-status.d.mts +42 -144
  54. package/dist/commands/migration-status.d.mts.map +1 -1
  55. package/dist/commands/migration-status.mjs +3 -759
  56. package/dist/commands/ref.d.mts +1 -1
  57. package/dist/commands/ref.d.mts.map +1 -1
  58. package/dist/commands/ref.mjs +4 -4
  59. package/dist/commands/ref.mjs.map +1 -1
  60. package/dist/commands/telemetry/index.d.mts +7 -0
  61. package/dist/commands/telemetry/index.d.mts.map +1 -0
  62. package/dist/commands/telemetry/index.mjs +2 -0
  63. package/dist/{config-loader-B6sJjXTv.mjs → config-loader-p9JMrekQ.mjs} +1 -1
  64. package/dist/{config-loader-B6sJjXTv.mjs.map → config-loader-p9JMrekQ.mjs.map} +1 -1
  65. package/dist/config-loader.mjs +1 -1
  66. package/dist/{contract-at-errors-BxP-TOMl.mjs → contract-at-errors-CFXsstzm.mjs} +2 -2
  67. package/dist/{contract-at-errors-BxP-TOMl.mjs.map → contract-at-errors-CFXsstzm.mjs.map} +1 -1
  68. package/dist/{contract-emit-DxcGl4Uq.mjs → contract-emit-B_qriF8B.mjs} +5 -5
  69. package/dist/{contract-emit-DxcGl4Uq.mjs.map → contract-emit-B_qriF8B.mjs.map} +1 -1
  70. package/dist/{contract-emit-D-4jrNve.mjs → contract-emit-C8HmtboH.mjs} +12 -7
  71. package/dist/contract-emit-C8HmtboH.mjs.map +1 -0
  72. package/dist/{contract-enrichment-a0V5Y_mL.mjs → contract-enrichment-gn9sWbPw.mjs} +1 -1
  73. package/dist/{contract-enrichment-a0V5Y_mL.mjs.map → contract-enrichment-gn9sWbPw.mjs.map} +1 -1
  74. package/dist/{contract-infer-D8uEbJuu.mjs → contract-infer-BYT_ra_U.mjs} +5 -5
  75. package/dist/contract-infer-BYT_ra_U.mjs.map +1 -0
  76. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs → contract-space-aggregate-loader-ClI1KN6d.mjs} +5 -5
  77. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs.map → contract-space-aggregate-loader-ClI1KN6d.mjs.map} +1 -1
  78. package/dist/{db-verify-v_vUKXTU.mjs → db-verify-C24FKhb7.mjs} +6 -6
  79. package/dist/{db-verify-v_vUKXTU.mjs.map → db-verify-C24FKhb7.mjs.map} +1 -1
  80. package/dist/exports/control-api.d.mts +5 -3
  81. package/dist/exports/control-api.d.mts.map +1 -1
  82. package/dist/exports/control-api.mjs +3 -3
  83. package/dist/exports/index.mjs +1 -1
  84. package/dist/exports/index.mjs.map +1 -1
  85. package/dist/exports/init-output.d.mts +1 -3
  86. package/dist/exports/init-output.d.mts.map +1 -1
  87. package/dist/exports/init-output.mjs +1 -1
  88. package/dist/{extension-pack-inputs-IDvjRCi3.mjs → extension-pack-inputs-1ySHqxKG.mjs} +1 -1
  89. package/dist/{extension-pack-inputs-IDvjRCi3.mjs.map → extension-pack-inputs-1ySHqxKG.mjs.map} +1 -1
  90. package/dist/{framework-components-fYXjz_in.mjs → framework-components-YVQHhPH7.mjs} +2 -2
  91. package/dist/{framework-components-fYXjz_in.mjs.map → framework-components-YVQHhPH7.mjs.map} +1 -1
  92. package/dist/{global-flags-DEHjV8_s.d.mts → global-flags-BpoOYtNZ.d.mts} +1 -1
  93. package/dist/{global-flags-DEHjV8_s.d.mts.map → global-flags-BpoOYtNZ.d.mts.map} +1 -1
  94. package/dist/{init-Cv9UzWL5.mjs → init-0HwB-Vh8.mjs} +5 -58
  95. package/dist/init-0HwB-Vh8.mjs.map +1 -0
  96. package/dist/{inspect-live-schema-C6ohV_oQ.mjs → inspect-live-schema-DF6IwcDl.mjs} +7 -5
  97. package/dist/inspect-live-schema-DF6IwcDl.mjs.map +1 -0
  98. package/dist/migration-check-soB5uZEQ.mjs +573 -0
  99. package/dist/migration-check-soB5uZEQ.mjs.map +1 -0
  100. package/dist/migration-cli.mjs +1 -1
  101. package/dist/migration-cli.mjs.map +1 -1
  102. package/dist/{migration-command-scaffold-CjvwO6at.mjs → migration-command-scaffold-DA-Lhx6o.mjs} +5 -5
  103. package/dist/{migration-command-scaffold-CjvwO6at.mjs.map → migration-command-scaffold-DA-Lhx6o.mjs.map} +1 -1
  104. package/dist/migration-graph-command-render-BAOzyYF6.mjs +1822 -0
  105. package/dist/migration-graph-command-render-BAOzyYF6.mjs.map +1 -0
  106. package/dist/migration-list-CihF6w5z.mjs +230 -0
  107. package/dist/migration-list-CihF6w5z.mjs.map +1 -0
  108. package/dist/migration-log-B75IArji.mjs +222 -0
  109. package/dist/migration-log-B75IArji.mjs.map +1 -0
  110. package/dist/migration-path-target-Ce6OZImp.mjs +38 -0
  111. package/dist/migration-path-target-Ce6OZImp.mjs.map +1 -0
  112. package/dist/{migration-plan-9DJ7q7_z.mjs → migration-plan-z5Ing-TD.mjs} +9 -8
  113. package/dist/migration-plan-z5Ing-TD.mjs.map +1 -0
  114. package/dist/migration-status-Di82DGvo.mjs +446 -0
  115. package/dist/migration-status-Di82DGvo.mjs.map +1 -0
  116. package/dist/{output-B60Gw5fu.mjs → output-mEQ74_nd.mjs} +1 -1
  117. package/dist/{output-B60Gw5fu.mjs.map → output-mEQ74_nd.mjs.map} +1 -1
  118. package/dist/{progress-adapter-C644QK8l.mjs → progress-adapter-CjAeTxY_.mjs} +1 -1
  119. package/dist/{progress-adapter-C644QK8l.mjs.map → progress-adapter-CjAeTxY_.mjs.map} +1 -1
  120. package/dist/{ref-advancement-DUZqsue6.mjs → ref-advancement-BkXlikCA.mjs} +1 -1
  121. package/dist/{ref-advancement-DUZqsue6.mjs.map → ref-advancement-BkXlikCA.mjs.map} +1 -1
  122. package/dist/schemas-CeGMYFYX.d.mts +191 -0
  123. package/dist/schemas-CeGMYFYX.d.mts.map +1 -0
  124. package/dist/schemas-KhXMzNA_.mjs +112 -0
  125. package/dist/schemas-KhXMzNA_.mjs.map +1 -0
  126. package/dist/telemetry-BIM4beEO.mjs +122 -0
  127. package/dist/telemetry-BIM4beEO.mjs.map +1 -0
  128. package/dist/{terminal-ui-5Y6mrg93.d.mts → terminal-ui-DGRNFWna.d.mts} +1 -1
  129. package/dist/terminal-ui-DGRNFWna.d.mts.map +1 -0
  130. package/dist/{types-Dt_SfqFm.d.mts → types-C_tYiJYx.d.mts} +53 -31
  131. package/dist/types-C_tYiJYx.d.mts.map +1 -0
  132. package/dist/{verify-DCA9Sldu.mjs → verify-DcOYZ1tH.mjs} +2 -2
  133. package/dist/{verify-DCA9Sldu.mjs.map → verify-DcOYZ1tH.mjs.map} +1 -1
  134. package/package.json +26 -22
  135. package/src/cli.ts +5 -0
  136. package/src/commands/contract-infer.ts +2 -2
  137. package/src/commands/db-update.ts +7 -1
  138. package/src/commands/init/index.ts +6 -35
  139. package/src/commands/init/init.ts +1 -14
  140. package/src/commands/init/inputs.ts +0 -75
  141. package/src/commands/inspect-live-schema.ts +10 -0
  142. package/src/commands/json/schemas.ts +195 -0
  143. package/src/commands/migrate.ts +527 -8
  144. package/src/commands/migration-check.ts +469 -134
  145. package/src/commands/migration-graph.ts +151 -119
  146. package/src/commands/migration-list.ts +72 -39
  147. package/src/commands/migration-log.ts +52 -102
  148. package/src/commands/migration-new.ts +2 -1
  149. package/src/commands/migration-plan.ts +2 -1
  150. package/src/commands/migration-show.ts +31 -66
  151. package/src/commands/migration-status-overlay.ts +61 -0
  152. package/src/commands/migration-status.ts +458 -1066
  153. package/src/commands/telemetry/index.ts +107 -0
  154. package/src/commands/telemetry/status.ts +67 -0
  155. package/src/control-api/client.ts +70 -9
  156. package/src/control-api/operations/contract-emit.ts +22 -2
  157. package/src/control-api/operations/db-init.ts +6 -3
  158. package/src/control-api/operations/{db-apply.ts → db-run.ts} +55 -14
  159. package/src/control-api/operations/db-update.ts +7 -4
  160. package/src/control-api/operations/db-verify.ts +15 -5
  161. package/src/control-api/operations/{migration-apply.ts → migrate.ts} +181 -80
  162. package/src/control-api/operations/{apply.ts → run-migration.ts} +33 -27
  163. package/src/control-api/types.ts +56 -29
  164. package/src/utils/cli-errors.ts +70 -2
  165. package/src/utils/formatters/errors.ts +11 -0
  166. package/src/utils/formatters/migration-graph-command-render.ts +239 -0
  167. package/src/utils/formatters/migration-graph-grid-layout.ts +857 -0
  168. package/src/utils/formatters/migration-graph-labels.ts +406 -0
  169. package/src/utils/formatters/migration-graph-model.ts +94 -0
  170. package/src/utils/formatters/migration-graph-occlusion-render.ts +245 -0
  171. package/src/utils/formatters/migration-graph-rows.ts +128 -15
  172. package/src/utils/formatters/migration-graph-space-render.ts +188 -0
  173. package/src/utils/formatters/migration-list-data-column.ts +4 -91
  174. package/src/utils/formatters/migration-list-graph-topology.ts +72 -94
  175. package/src/utils/formatters/migration-list-render.ts +135 -71
  176. package/src/utils/formatters/migration-list-styler.ts +46 -5
  177. package/src/utils/formatters/migration-list-types.ts +5 -21
  178. package/src/utils/formatters/migration-log-table.ts +205 -0
  179. package/src/utils/formatters/migrations.ts +33 -11
  180. package/src/utils/global-flags.ts +35 -0
  181. package/src/utils/integrity-violation-to-check-failure.ts +28 -19
  182. package/src/utils/legend.ts +38 -0
  183. package/src/utils/migration-path-target.ts +60 -0
  184. package/src/utils/telemetry.ts +68 -32
  185. package/dist/client-KgJorIvG.mjs.map +0 -1
  186. package/dist/command-helpers-Bbw1GbwL.mjs.map +0 -1
  187. package/dist/commands/migration-list.mjs.map +0 -1
  188. package/dist/commands/migration-log.mjs.map +0 -1
  189. package/dist/commands/migration-status.mjs.map +0 -1
  190. package/dist/contract-emit-D-4jrNve.mjs.map +0 -1
  191. package/dist/contract-infer-D8uEbJuu.mjs.map +0 -1
  192. package/dist/graph-render-rFAqZujX.mjs +0 -1081
  193. package/dist/graph-render-rFAqZujX.mjs.map +0 -1
  194. package/dist/init-Cv9UzWL5.mjs.map +0 -1
  195. package/dist/inspect-live-schema-C6ohV_oQ.mjs.map +0 -1
  196. package/dist/migration-check-BiBJoYYW.mjs +0 -341
  197. package/dist/migration-check-BiBJoYYW.mjs.map +0 -1
  198. package/dist/migration-graph-BzxEsMZg.mjs +0 -1463
  199. package/dist/migration-graph-BzxEsMZg.mjs.map +0 -1
  200. package/dist/migration-list-styler-BRwF4-gy.mjs +0 -399
  201. package/dist/migration-list-styler-BRwF4-gy.mjs.map +0 -1
  202. package/dist/migration-plan-9DJ7q7_z.mjs.map +0 -1
  203. package/dist/migration-types-D2FW63pr.d.mts +0 -15
  204. package/dist/migration-types-D2FW63pr.d.mts.map +0 -1
  205. package/dist/migrations-Cv2jxNNK.mjs +0 -228
  206. package/dist/migrations-Cv2jxNNK.mjs.map +0 -1
  207. package/dist/terminal-ui-5Y6mrg93.d.mts.map +0 -1
  208. package/dist/types-Dt_SfqFm.d.mts.map +0 -1
  209. package/src/utils/formatters/graph-migration-mapper.ts +0 -235
  210. package/src/utils/formatters/graph-render.ts +0 -1323
  211. package/src/utils/formatters/graph-types.ts +0 -120
  212. package/src/utils/formatters/migration-graph-lane-colors.ts +0 -31
  213. package/src/utils/formatters/migration-graph-layout.ts +0 -1119
  214. package/src/utils/formatters/migration-graph-tree-render.ts +0 -755
@@ -0,0 +1,857 @@
1
+ /**
2
+ * Grid layout for the line/plane/occlusion migration-graph renderer.
3
+ *
4
+ * Produces a Grid (rows × cells) from a MigrationGraphRowModel. Each node
5
+ * emits: fork connector, self-loop rows, node row, merge connector, and
6
+ * inbound migration rows — in display order (tips first, then roots).
7
+ */
8
+
9
+ import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
10
+ import type {
11
+ Cell,
12
+ CellLine,
13
+ Direction,
14
+ Grid,
15
+ GridOptions,
16
+ Highlight,
17
+ LineRef,
18
+ NodeRef,
19
+ PathRole,
20
+ } from './migration-graph-model';
21
+ import type { ClassifiedEdge, MigrationGraphRowModel } from './migration-graph-rows';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Internal: lane + rank assignment
25
+ // ---------------------------------------------------------------------------
26
+
27
+ interface LaneAssignment {
28
+ nodeLane: Map<string, number>;
29
+ nodeRank: Map<string, number>;
30
+ /** Total number of lanes allocated. */
31
+ numLanes: number;
32
+ }
33
+
34
+ function buildLaneAssignment(
35
+ nodes: readonly (string | null)[],
36
+ edges: readonly ClassifiedEdge[],
37
+ ): LaneAssignment {
38
+ const allNodes = new Set<string>();
39
+ for (const n of nodes) {
40
+ if (n !== null) allNodes.add(n);
41
+ }
42
+
43
+ // Separate forward (non-self) edges
44
+ const fwdEdges = edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
45
+
46
+ // Build adjacency: outbound forward edges per node, sorted lex by migrationHash
47
+ const outbound = new Map<string, ClassifiedEdge[]>();
48
+ const inbound = new Map<string, ClassifiedEdge[]>();
49
+ for (const edge of fwdEdges) {
50
+ const ob = outbound.get(edge.from);
51
+ if (ob) ob.push(edge);
52
+ else outbound.set(edge.from, [edge]);
53
+
54
+ const ib = inbound.get(edge.to);
55
+ if (ib) ib.push(edge);
56
+ else inbound.set(edge.to, [edge]);
57
+ }
58
+ for (const list of outbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
59
+ for (const list of inbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
60
+
61
+ // Compute longest-forward-path rank from roots (tips get highest rank)
62
+ const nodeRank = new Map<string, number>();
63
+ for (const n of allNodes) nodeRank.set(n, 0);
64
+ for (let pass = 0; pass < allNodes.size; pass++) {
65
+ let changed = false;
66
+ for (const [from, edges] of outbound) {
67
+ const base = nodeRank.get(from) ?? 0;
68
+ for (const e of edges) {
69
+ const next = base + 1;
70
+ if (next > (nodeRank.get(e.to) ?? 0)) {
71
+ nodeRank.set(e.to, next);
72
+ changed = true;
73
+ }
74
+ }
75
+ }
76
+ if (!changed) break;
77
+ }
78
+
79
+ // Lane assignment: BFS from roots, trunk keeps parent's lane
80
+ const nodeLane = new Map<string, number>();
81
+ let nextLane = 0;
82
+
83
+ // Roots: nodes with no inbound forward edges
84
+ const roots: string[] = [];
85
+ for (const n of allNodes) {
86
+ if ((inbound.get(n) ?? []).length === 0) roots.push(n);
87
+ }
88
+ roots.sort((a, b) => {
89
+ if (a === EMPTY_CONTRACT_HASH) return -1;
90
+ if (b === EMPTY_CONTRACT_HASH) return 1;
91
+ return a.localeCompare(b);
92
+ });
93
+
94
+ const bfsQueue: Array<{ node: string; lane: number }> = [];
95
+ for (const root of roots) {
96
+ if (!nodeLane.has(root)) {
97
+ nodeLane.set(root, nextLane++);
98
+ bfsQueue.push({ node: root, lane: nodeLane.get(root)! });
99
+ }
100
+ }
101
+
102
+ // BFS expansion
103
+ let head = 0;
104
+ while (head < bfsQueue.length) {
105
+ const item = bfsQueue[head++]!;
106
+ const { node, lane } = item;
107
+ const children = outbound.get(node) ?? [];
108
+ let first = true;
109
+ for (const childEdge of children) {
110
+ const child = childEdge.to;
111
+ if (!nodeLane.has(child)) {
112
+ const childLane = first ? lane : nextLane++;
113
+ nodeLane.set(child, childLane);
114
+ bfsQueue.push({ node: child, lane: childLane });
115
+ }
116
+ first = false;
117
+ }
118
+ }
119
+
120
+ // Isolated nodes (no edges) get their own lane
121
+ for (const n of allNodes) {
122
+ if (!nodeLane.has(n)) nodeLane.set(n, nextLane++);
123
+ }
124
+
125
+ return { nodeLane, nodeRank, numLanes: nextLane };
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Internal: display order
130
+ // ---------------------------------------------------------------------------
131
+
132
+ interface NodeDisplay {
133
+ hash: string;
134
+ lane: number;
135
+ rank: number;
136
+ }
137
+
138
+ function computeDisplayOrder(
139
+ nodes: readonly (string | null)[],
140
+ nodeLane: Map<string, number>,
141
+ nodeRank: Map<string, number>,
142
+ ): NodeDisplay[] {
143
+ const seen = new Set<string>();
144
+ const result: NodeDisplay[] = [];
145
+ for (const n of nodes) {
146
+ if (n === null || seen.has(n)) continue;
147
+ seen.add(n);
148
+ result.push({ hash: n, lane: nodeLane.get(n) ?? 0, rank: nodeRank.get(n) ?? 0 });
149
+ }
150
+ // Tips first (rank desc), within same rank lane asc
151
+ result.sort((a, b) => b.rank - a.rank || a.lane - b.lane);
152
+ return result;
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Internal: grid row builder
157
+ // ---------------------------------------------------------------------------
158
+
159
+ type CellsRow = Cell[];
160
+
161
+ /** Create an empty cell. */
162
+ function emptyCell(): Cell {
163
+ return { lines: [] };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // buildGrid — main entry point
168
+ // ---------------------------------------------------------------------------
169
+
170
+ export function buildGrid(
171
+ rowModel: MigrationGraphRowModel,
172
+ opts: GridOptions = {},
173
+ highlight: Highlight = { mode: 'flat', onPath: new Set() },
174
+ ): Grid {
175
+ const colsPerLane = opts.colsPerLane ?? 2;
176
+ const isFocus = highlight.mode === 'focus';
177
+
178
+ const { nodeLane, nodeRank, numLanes } = buildLaneAssignment(rowModel.nodes, rowModel.edges);
179
+
180
+ const displayOrder = computeDisplayOrder(rowModel.nodes, nodeLane, nodeRank);
181
+
182
+ // Display index per node (0 = topmost row).
183
+ const displayIndex = new Map<string, number>();
184
+ displayOrder.forEach((d, i) => {
185
+ displayIndex.set(d.hash, i);
186
+ });
187
+
188
+ // ── Back-arc planning ────────────────────────────────────────────────────
189
+ // Each rollback edge runs against the forward grain. An *adjacent* rollback
190
+ // (target is the display-neighbour directly below the source) is a plain ↓ in
191
+ // the source's own lane. A *node-skipping* rollback is routed on its own
192
+ // back-lane to the right: it tees off the source node row (○─╮), runs a
193
+ // vertical │ down its back-lane, and lands into the target node (◂╯).
194
+ //
195
+ // Two independent numbers per routed back-arc:
196
+ // geomLane — the column its rail occupies. Outermost (largest) goes to the
197
+ // arc reaching the lowest target (ties: higher source first), so
198
+ // interleaving spans cross and nested spans nest cleanly.
199
+ // colourLane — the lane index used purely for colour. Assigned by
200
+ // migrationHash order, continuing after the forward lanes, so the
201
+ // first rollback is lane numLanes, the next numLanes+1, etc.
202
+ // These differ whenever two arcs interleave (rollback-cross): the inner column
203
+ // may carry the higher colour. Colour is read off LineRef.lane; the column is
204
+ // where the cell is placed.
205
+ interface RoutedBackArc {
206
+ readonly edge: ClassifiedEdge;
207
+ readonly sourceIndex: number;
208
+ readonly targetIndex: number;
209
+ readonly geomLane: number;
210
+ readonly colourLane: number;
211
+ }
212
+
213
+ const rollbackEdges = rowModel.edges.filter((e) => e.kind === 'rollback' && e.from !== e.to);
214
+
215
+ const adjacentRollbacks: ClassifiedEdge[] = [];
216
+ const skippingRollbacks: ClassifiedEdge[] = [];
217
+ for (const e of rollbackEdges) {
218
+ const si = displayIndex.get(e.from);
219
+ const ti = displayIndex.get(e.to);
220
+ if (si === undefined || ti === undefined) continue;
221
+ // Adjacent: target sits directly below the source in display order.
222
+ if (ti === si + 1) adjacentRollbacks.push(e);
223
+ else skippingRollbacks.push(e);
224
+ }
225
+
226
+ // colourLane by migration NAME (dirName) order — chronological, not hash.
227
+ const colourLaneOf = new Map<string, number>();
228
+ [...skippingRollbacks]
229
+ .sort((a, b) => a.dirName.localeCompare(b.dirName))
230
+ .forEach((e, i) => {
231
+ colourLaneOf.set(e.migrationHash, numLanes + i);
232
+ });
233
+
234
+ // geomLane: outermost rail to the arc with the lowest target (largest target
235
+ // index); ties broken by the highest source (smallest source index). The first
236
+ // in this order gets the outermost (largest) geometric lane.
237
+ const geomOrder = [...skippingRollbacks].sort((a, b) => {
238
+ const ta = displayIndex.get(a.to) ?? 0;
239
+ const tb = displayIndex.get(b.to) ?? 0;
240
+ if (ta !== tb) return tb - ta; // lower target (larger index) first
241
+ const sa = displayIndex.get(a.from) ?? 0;
242
+ const sb = displayIndex.get(b.from) ?? 0;
243
+ return sa - sb; // higher source (smaller index) first
244
+ });
245
+ const geomLaneOf = new Map<string, number>();
246
+ const outermost = numLanes + skippingRollbacks.length - 1;
247
+ geomOrder.forEach((e, i) => {
248
+ geomLaneOf.set(e.migrationHash, outermost - i);
249
+ });
250
+
251
+ const routedBackArcs: RoutedBackArc[] = skippingRollbacks.map((e) => ({
252
+ edge: e,
253
+ sourceIndex: displayIndex.get(e.from) ?? 0,
254
+ targetIndex: displayIndex.get(e.to) ?? 0,
255
+ geomLane: geomLaneOf.get(e.migrationHash) ?? numLanes,
256
+ colourLane: colourLaneOf.get(e.migrationHash) ?? numLanes,
257
+ }));
258
+
259
+ const backArcsBySource = new Map<string, RoutedBackArc[]>();
260
+ const backArcsByTarget = new Map<string, RoutedBackArc[]>();
261
+ for (const arc of routedBackArcs) {
262
+ const sb = backArcsBySource.get(arc.edge.from);
263
+ if (sb) sb.push(arc);
264
+ else backArcsBySource.set(arc.edge.from, [arc]);
265
+ const tb = backArcsByTarget.get(arc.edge.to);
266
+ if (tb) tb.push(arc);
267
+ else backArcsByTarget.set(arc.edge.to, [arc]);
268
+ }
269
+
270
+ const adjacentBySource = new Map<string, ClassifiedEdge[]>();
271
+ const adjacentByTarget = new Map<string, ClassifiedEdge[]>();
272
+ for (const e of adjacentRollbacks) {
273
+ const b = adjacentBySource.get(e.from);
274
+ if (b) b.push(e);
275
+ else adjacentBySource.set(e.from, [e]);
276
+ const t = adjacentByTarget.get(e.to);
277
+ if (t) t.push(e);
278
+ else adjacentByTarget.set(e.to, [e]);
279
+ }
280
+ for (const list of adjacentBySource.values())
281
+ list.sort((a, b) => a.dirName.localeCompare(b.dirName));
282
+
283
+ const numBackLanes = skippingRollbacks.length;
284
+ const totalCols = (numLanes + numBackLanes) * colsPerLane;
285
+
286
+ // Build edge lookup maps (classified)
287
+ const fwdEdges = rowModel.edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
288
+ const selfEdges = rowModel.edges.filter((e) => e.kind === 'self');
289
+
290
+ // outbound sorted by migrationHash
291
+ const outboundFwd = new Map<string, ClassifiedEdge[]>();
292
+ const inboundFwd = new Map<string, ClassifiedEdge[]>();
293
+ for (const e of fwdEdges) {
294
+ const ob = outboundFwd.get(e.from);
295
+ if (ob) ob.push(e);
296
+ else outboundFwd.set(e.from, [e]);
297
+ const ib = inboundFwd.get(e.to);
298
+ if (ib) ib.push(e);
299
+ else inboundFwd.set(e.to, [e]);
300
+ }
301
+ for (const list of outboundFwd.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
302
+ for (const list of inboundFwd.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
303
+
304
+ const selfEdgesByNode = new Map<string, ClassifiedEdge[]>();
305
+ for (const e of selfEdges) {
306
+ const bucket = selfEdgesByNode.get(e.from);
307
+ if (bucket) bucket.push(e);
308
+ else selfEdgesByNode.set(e.from, [e]);
309
+ }
310
+ for (const list of selfEdgesByNode.values())
311
+ list.sort((a, b) => a.dirName.localeCompare(b.dirName));
312
+
313
+ // ── Role + plane: mode/z-order seam ──────────────────────────────────────
314
+ // role(migrationHash): focus → on-path/off-path from highlight.onPath; flat → undefined.
315
+ function roleOf(migrationHash: string): PathRole | undefined {
316
+ if (!isFocus) return undefined;
317
+ return highlight.onPath.has(migrationHash) ? 'on-path' : 'off-path';
318
+ }
319
+
320
+ // On-path node set: a node is on-path iff an on-path edge touches it (from or
321
+ // to) — forward, self, OR rollback (a back-arc's endpoints are on its route).
322
+ const onPathNodes = new Set<string>();
323
+ if (isFocus) {
324
+ for (const e of [...fwdEdges, ...selfEdges, ...rollbackEdges]) {
325
+ if (highlight.onPath.has(e.migrationHash)) {
326
+ onPathNodes.add(e.from);
327
+ onPathNodes.add(e.to);
328
+ }
329
+ }
330
+ }
331
+ function nodeRoleOf(hash: string): PathRole | undefined {
332
+ if (!isFocus) return undefined;
333
+ return onPathNodes.has(hash) ? 'on-path' : 'off-path';
334
+ }
335
+
336
+ // planeOf — z-order. Lower number = drawn on top.
337
+ // flat: trunk on top → plane = lane (lane 0 topmost).
338
+ // focus: on-path on top → on-path = plane 0; off-path sits beneath it,
339
+ // ordered by lane so a deterministic owner survives among off-path lines.
340
+ function planeOf(lane: number, role: PathRole | undefined): number {
341
+ if (!isFocus) return lane;
342
+ return role === 'on-path' ? 0 : lane + 1;
343
+ }
344
+
345
+ // ── LineRef + cell builders (role-aware) ─────────────────────────────────
346
+ function lineRefFor(edge: ClassifiedEdge, lane: number): LineRef {
347
+ return {
348
+ migrationHash: edge.migrationHash,
349
+ dirName: edge.dirName,
350
+ lane,
351
+ role: roleOf(edge.migrationHash),
352
+ };
353
+ }
354
+
355
+ /** Synthetic LineRef for a lane carrying a representative edge's role (pass-through). */
356
+ function passLineRef(lane: number, dirName: string, migHash: string): LineRef {
357
+ return { migrationHash: migHash, dirName, lane, role: roleOf(migHash) };
358
+ }
359
+
360
+ function vertCell(line: LineRef): Cell {
361
+ return {
362
+ lines: [
363
+ {
364
+ line,
365
+ directions: new Set<Direction>(['up', 'down']),
366
+ plane: planeOf(line.lane, line.role),
367
+ },
368
+ ],
369
+ };
370
+ }
371
+
372
+ function dirCell(line: LineRef, dirs: ReadonlySet<Direction>): Cell {
373
+ return { lines: [{ line, directions: dirs, plane: planeOf(line.lane, line.role) }] };
374
+ }
375
+
376
+ function nodeCell(nodeRef: NodeRef): Cell {
377
+ return { node: nodeRef, lines: [] };
378
+ }
379
+
380
+ // Pass-through colour follows the edge CURRENTLY occupying a lane at this row,
381
+ // not a lane-wide average. A single lane carries different edges (with different
382
+ // roles) over its vertical extent — e.g. lane 0 below a fork carries the trunk
383
+ // branch (off-path) above the fork node and the trunk's parent edge (on-path)
384
+ // below it. We track the active edge per lane as we descend top-to-bottom and
385
+ // colour pass-through verticals from it. `laneCurrentEdge[L]` = the edge whose
386
+ // vertical body currently runs through lane L at the row being emitted.
387
+ const laneCurrentEdge = new Map<number, ClassifiedEdge>();
388
+
389
+ function getRepLine(lane: number): LineRef {
390
+ const e = laneCurrentEdge.get(lane);
391
+ if (e) return lineRefFor(e, lane);
392
+ return passLineRef(lane, `lane${lane}`, `lane${lane}`);
393
+ }
394
+
395
+ // Active lanes: set of lane indices currently visible (vertical passes through them)
396
+ const activeLanes = new Set<number>();
397
+
398
+ const grid: Cell[][] = [];
399
+
400
+ function makeRow(): CellsRow {
401
+ return Array.from({ length: totalCols }, () => emptyCell());
402
+ }
403
+
404
+ // Place vertical pass-throughs for all active lanes in a row, skipping specified lanes.
405
+ function placeVerticals(row: CellsRow, skip: Set<number>): void {
406
+ for (const lane of activeLanes) {
407
+ if (skip.has(lane)) continue;
408
+ const railCol = lane * colsPerLane;
409
+ const cell = row[railCol];
410
+ if (cell !== undefined && cell.lines.length === 0 && !cell.node) {
411
+ row[railCol] = vertCell(getRepLine(lane));
412
+ }
413
+ }
414
+ }
415
+
416
+ // ── Back-arc helpers ──────────────────────────────────────────────────────
417
+ // Active routed back-arcs whose vertical currently runs through their geomLane.
418
+ const activeBackArcs = new Set<RoutedBackArc>();
419
+
420
+ // A back-arc's LineRef carries its colourLane (not its geomLane) so colour is
421
+ // read off the lane that drives the rotation, independent of column placement.
422
+ function backArcLine(arc: RoutedBackArc): LineRef {
423
+ return {
424
+ migrationHash: arc.edge.migrationHash,
425
+ dirName: arc.edge.dirName,
426
+ lane: arc.colourLane,
427
+ role: roleOf(arc.edge.migrationHash),
428
+ };
429
+ }
430
+
431
+ function backArcPlane(arc: RoutedBackArc): number {
432
+ const role = roleOf(arc.edge.migrationHash);
433
+ if (!isFocus) return arc.colourLane;
434
+ return role === 'on-path' ? 0 : arc.colourLane + 1;
435
+ }
436
+
437
+ // Compose a CellLine into a row cell (never overwrite — occlusion arbitrates).
438
+ function composeLine(
439
+ row: CellsRow,
440
+ col: number,
441
+ line: LineRef,
442
+ dirs: ReadonlySet<Direction>,
443
+ plane: number,
444
+ extra?: { landingArrow?: boolean },
445
+ ): void {
446
+ const existing = row[col];
447
+ const cellLine: CellLine = {
448
+ line,
449
+ directions: dirs,
450
+ plane,
451
+ ...(extra?.landingArrow ? { landingArrow: true } : {}),
452
+ };
453
+ if (existing && (existing.lines.length > 0 || existing.node)) {
454
+ row[col] = { ...existing, lines: [...existing.lines, cellLine] };
455
+ } else {
456
+ row[col] = { lines: [cellLine] };
457
+ }
458
+ }
459
+
460
+ // Place verticals for every active back-arc on this row (in its geomLane rail).
461
+ function placeBackVerticals(row: CellsRow): void {
462
+ for (const arc of activeBackArcs) {
463
+ const railCol = arc.geomLane * colsPerLane;
464
+ composeLine(
465
+ row,
466
+ railCol,
467
+ backArcLine(arc),
468
+ new Set<Direction>(['up', 'down']),
469
+ backArcPlane(arc),
470
+ );
471
+ }
472
+ placeAdjacentOverlays(row);
473
+ }
474
+
475
+ // Adjacent rollbacks share the source's own lane: their vertical body overlays
476
+ // the forward trunk between source and target. In focus, an on-path adjacent
477
+ // rollback lifts that segment of the trunk to the top plane (drawn green); in
478
+ // flat it sits at the same plane/colour as the trunk, so it is a no-op there.
479
+ interface ActiveAdjacent {
480
+ readonly lane: number;
481
+ readonly edge: ClassifiedEdge;
482
+ }
483
+ const activeAdjacent = new Set<ActiveAdjacent>();
484
+
485
+ function placeAdjacentOverlays(row: CellsRow): void {
486
+ for (const adj of activeAdjacent) {
487
+ const railCol = adj.lane * colsPerLane;
488
+ const cell = row[railCol];
489
+ if (cell?.node) continue; // never overlay a node marker
490
+ const line = lineRefFor(adj.edge, adj.lane);
491
+ composeLine(
492
+ row,
493
+ railCol,
494
+ line,
495
+ new Set<Direction>(['up', 'down']),
496
+ planeOf(adj.lane, line.role),
497
+ );
498
+ }
499
+ }
500
+
501
+ // Tee a routed back-arc off its source node row: a horizontal bridge from the
502
+ // node's connector column across to the back-lane rail, ending in a ╮ corner
503
+ // (down+left). Composed (not overwritten) so it occludes / is occluded by any
504
+ // back-arc vertical it crosses.
505
+ function emitBackArcTee(row: CellsRow, nodeLaneNum: number, arc: RoutedBackArc): void {
506
+ const nodeRail = nodeLaneNum * colsPerLane;
507
+ const geomRail = arc.geomLane * colsPerLane;
508
+ const line = backArcLine(arc);
509
+ const plane = backArcPlane(arc);
510
+ for (let col = nodeRail + 1; col < geomRail; col++) {
511
+ composeLine(row, col, line, new Set<Direction>(['left', 'right']), plane);
512
+ }
513
+ composeLine(row, geomRail, line, new Set<Direction>(['down', 'left']), plane);
514
+ }
515
+
516
+ // Land a routed back-arc into its target node row: a ◂ arrowhead in the node's
517
+ // connector column, a horizontal bridge across to the back-lane rail, ending in
518
+ // a ╯ corner (up+left). Composed so the on-top arc draws the anchor and the
519
+ // others yield their corners beneath it (occlusion arbitrates).
520
+ function emitBackArcLanding(row: CellsRow, nodeLaneNum: number, arc: RoutedBackArc): void {
521
+ const nodeRail = nodeLaneNum * colsPerLane;
522
+ const geomRail = arc.geomLane * colsPerLane;
523
+ const line = backArcLine(arc);
524
+ const plane = backArcPlane(arc);
525
+ composeLine(row, nodeRail + 1, line, new Set<Direction>(['left', 'right']), plane, {
526
+ landingArrow: true,
527
+ });
528
+ for (let col = nodeRail + 2; col < geomRail; col++) {
529
+ composeLine(row, col, line, new Set<Direction>(['left', 'right']), plane);
530
+ }
531
+ composeLine(row, geomRail, line, new Set<Direction>(['up', 'left']), plane);
532
+ }
533
+
534
+ // Emit a connector row (fork or merge).
535
+ //
536
+ // The CONTINUOUS lane gets the unbroken vertical/sweep; every other
537
+ // participating lane yields into its own corner. In flat mode the continuous
538
+ // lane is the trunk (lane of the node); in focus mode it is the on-path lane
539
+ // (the inbound/outbound edge whose migration is on-path), so the chosen route
540
+ // is drawn as one continuous green line sweeping the merge/fork.
541
+ //
542
+ // Geometry is identical regardless of which lane is continuous; only the
543
+ // NODE-ANCHOR glyph at the trunk rail changes:
544
+ // continuous == trunk → │ (vertical, the trunk passes straight through)
545
+ // continuous == a branch → corner toward that branch
546
+ // merge: ╰ (up+right) fork: ╭ (down+right)
547
+ // The branch's own rail always carries its yield corner (merge ╮ / fork ╯), and
548
+ // the cells between carry horizontals. The continuous (on-path) sweep is placed
549
+ // on the top plane so it occludes the trunk's vertical at the node anchor.
550
+ function emitConnectorRow(
551
+ trunkLane: number,
552
+ branchEntries: readonly { lane: number; edge: ClassifiedEdge }[],
553
+ connectorType: 'fork' | 'merge',
554
+ trunkEdge: ClassifiedEdge | undefined,
555
+ ): CellsRow {
556
+ const row = makeRow();
557
+ const sorted = [...branchEntries].sort((a, b) => a.lane - b.lane);
558
+ if (sorted.length === 0) return row;
559
+
560
+ const branchByLane = new Map<number, ClassifiedEdge>();
561
+ for (const b of sorted) branchByLane.set(b.lane, b.edge);
562
+
563
+ // Continuous lane: the on-path participant in focus, else the trunk.
564
+ let continuousLane = trunkLane;
565
+ if (isFocus) {
566
+ if (trunkEdge && highlight.onPath.has(trunkEdge.migrationHash)) {
567
+ continuousLane = trunkLane;
568
+ } else {
569
+ const onPathBranch = sorted.find((b) => highlight.onPath.has(b.edge.migrationHash));
570
+ if (onPathBranch) continuousLane = onPathBranch.lane;
571
+ }
572
+ }
573
+
574
+ const trunkRailCol = trunkLane * colsPerLane;
575
+ const continuousRailCol = continuousLane * colsPerLane;
576
+
577
+ // Add a CellLine to a cell (compose, don't overwrite) so occlusion arbitrates.
578
+ function addLine(col: number, line: LineRef, dirs: ReadonlySet<Direction>): void {
579
+ const existing = row[col];
580
+ const cellLine: CellLine = { line, directions: dirs, plane: planeOf(line.lane, line.role) };
581
+ row[col] =
582
+ existing && existing.lines.length > 0
583
+ ? { ...existing, lines: [...existing.lines, cellLine] }
584
+ : { lines: [cellLine] };
585
+ }
586
+
587
+ const cornerLeftDown: ReadonlySet<Direction> =
588
+ connectorType === 'merge'
589
+ ? new Set<Direction>(['left', 'down'])
590
+ : new Set<Direction>(['left', 'up']);
591
+
592
+ // ── Base plane: every yielding branch lays its own corner + the horizontal
593
+ // segment to its left (up to the previous branch's rail). These sit on the
594
+ // branch's lane plane; where the continuous sweep crosses them it occludes.
595
+ for (let i = 0; i < sorted.length; i++) {
596
+ const b = sorted[i]!;
597
+ if (b.lane === continuousLane) continue; // continuous drawn separately, on top
598
+ const branchLine = lineRefFor(b.edge, b.lane);
599
+ const railCol = b.lane * colsPerLane;
600
+ addLine(railCol, branchLine, cornerLeftDown);
601
+ const leftBound = i === 0 ? trunkRailCol + 1 : sorted[i - 1]!.lane * colsPerLane + 1;
602
+ for (let col = leftBound; col < railCol; col++) {
603
+ addLine(col, branchLine, new Set<Direction>(['left', 'right']));
604
+ }
605
+ }
606
+
607
+ // ── The continuous line ──────────────────────────────────────────────────
608
+ const continuousLine: LineRef =
609
+ continuousLane === trunkLane
610
+ ? trunkEdge
611
+ ? lineRefFor(trunkEdge, trunkLane)
612
+ : getRepLine(trunkLane)
613
+ : lineRefFor(branchByLane.get(continuousLane)!, continuousLane);
614
+
615
+ if (continuousLane === trunkLane) {
616
+ // Trunk passes straight through the node anchor (│), branches yield to it.
617
+ addLine(trunkRailCol, continuousLine, new Set<Direction>(['up', 'down']));
618
+ } else {
619
+ // A branch is continuous: it sweeps from the node anchor across to its own
620
+ // rail, on the TOP plane, occluding the trunk vertical and any intermediate
621
+ // yielding branch corners it passes over.
622
+ const anchorDirs: ReadonlySet<Direction> =
623
+ connectorType === 'merge'
624
+ ? new Set<Direction>(['up', 'right'])
625
+ : new Set<Direction>(['down', 'right']);
626
+ addLine(trunkRailCol, continuousLine, anchorDirs);
627
+ for (let col = trunkRailCol + 1; col < continuousRailCol; col++) {
628
+ addLine(col, continuousLine, new Set<Direction>(['left', 'right']));
629
+ }
630
+ addLine(continuousRailCol, continuousLine, cornerLeftDown);
631
+ }
632
+
633
+ // Other active lanes (not trunk, not branch): vertical pass-through.
634
+ const skipSet = new Set<number>([trunkLane, ...sorted.map((b) => b.lane)]);
635
+ placeVerticals(row, skipSet);
636
+ placeBackVerticals(row);
637
+
638
+ return row;
639
+ }
640
+
641
+ // Process each node in display order
642
+ for (const nodeDisplay of displayOrder) {
643
+ const { hash: nodeHash } = nodeDisplay;
644
+ const nodeLaneNum = nodeLane.get(nodeHash) ?? 0;
645
+
646
+ activeLanes.add(nodeLaneNum);
647
+
648
+ // ── 1. Fork connector (BEFORE the node row) ──────────────────────────
649
+ const outEdges = outboundFwd.get(nodeHash) ?? [];
650
+ if (outEdges.length > 1) {
651
+ const trunkChildLane = nodeLane.get(outEdges[0]!.to) ?? nodeLaneNum;
652
+ const branchEntries = outEdges
653
+ .slice(1)
654
+ .map((e) => ({ lane: nodeLane.get(e.to) ?? 0, edge: e }))
655
+ .filter((b) => b.lane !== trunkChildLane && activeLanes.has(b.lane));
656
+
657
+ if (branchEntries.length > 0) {
658
+ const trunkEdge = outEdges[0];
659
+ const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'fork', trunkEdge);
660
+ grid.push(connRow);
661
+ assertSingleOwner(connRow, isFocus);
662
+
663
+ for (const b of branchEntries) activeLanes.delete(b.lane);
664
+ }
665
+ }
666
+
667
+ // ── 2. Self-loop rows (BEFORE the node row) ───────────────────────────
668
+ const selfMigrations = selfEdgesByNode.get(nodeHash) ?? [];
669
+ for (const selfEdge of selfMigrations) {
670
+ const row = makeRow();
671
+ const railCol = nodeLaneNum * colsPerLane;
672
+ const connCol = nodeLaneNum * colsPerLane + 1;
673
+ const line = lineRefFor(selfEdge, nodeLaneNum);
674
+ row[railCol] = vertCell(line);
675
+ row[connCol] = {
676
+ lines: [
677
+ {
678
+ line,
679
+ directions: new Set<Direction>(),
680
+ plane: planeOf(nodeLaneNum, line.role),
681
+ selfLoop: true,
682
+ },
683
+ ],
684
+ };
685
+ placeVerticals(row, new Set([nodeLaneNum]));
686
+ placeBackVerticals(row);
687
+ grid.push(row);
688
+ }
689
+
690
+ // ── 3. Node row ────────────────────────────────────────────────────────
691
+ {
692
+ const row = makeRow();
693
+ const railCol = nodeLaneNum * colsPerLane;
694
+ const nodeRef: NodeRef = {
695
+ contractHash: nodeHash,
696
+ isEmpty: nodeHash === EMPTY_CONTRACT_HASH,
697
+ lane: nodeLaneNum,
698
+ role: nodeRoleOf(nodeHash),
699
+ };
700
+ row[railCol] = nodeCell(nodeRef);
701
+ placeVerticals(row, new Set([nodeLaneNum]));
702
+
703
+ // A back-arc landing ends its vertical at this row, replacing it with a ╯
704
+ // corner — so deactivate landing arcs BEFORE placing back verticals. An
705
+ // adjacent rollback's overlay likewise ends at its target node.
706
+ const landingArcs = backArcsByTarget.get(nodeHash) ?? [];
707
+ for (const arc of landingArcs) activeBackArcs.delete(arc);
708
+ for (const adj of [...activeAdjacent]) {
709
+ if (adj.edge.to === nodeHash) activeAdjacent.delete(adj);
710
+ }
711
+
712
+ placeBackVerticals(row);
713
+
714
+ // Back-arc landing: arcs targeting this node sweep from the node anchor
715
+ // (◂ arrowhead) across to their own rail corner (╯). The on-top arc draws
716
+ // the anchor; others yield their corners beneath (occlusion arbitrates).
717
+ for (const arc of landingArcs) {
718
+ emitBackArcLanding(row, nodeLaneNum, arc);
719
+ }
720
+
721
+ // Back-arc tee: arcs sourced at this node tee off the node row into their
722
+ // back-lane (─ bridge + ╮ corner). The vertical begins on the next row.
723
+ const teeArcs = backArcsBySource.get(nodeHash) ?? [];
724
+ for (const arc of teeArcs) {
725
+ emitBackArcTee(row, nodeLaneNum, arc);
726
+ }
727
+
728
+ grid.push(row);
729
+
730
+ // Activate the back-arc verticals AFTER the node row so the rail runs from
731
+ // the next row down to (but not including) the target landing row.
732
+ for (const arc of teeArcs) activeBackArcs.add(arc);
733
+
734
+ // Activate adjacent-rollback overlays sourced here (their trunk overlay
735
+ // runs from the next row down to the target node).
736
+ for (const adj of adjacentBySource.get(nodeHash) ?? []) {
737
+ activeAdjacent.add({ lane: nodeLaneNum, edge: adj });
738
+ }
739
+ }
740
+
741
+ // Inbound forward edges run down their lanes below this node. Record each as
742
+ // its lane's current edge NOW (before emitting the back-arc arrow rows, merge
743
+ // connector, and migration rows) so pass-through verticals colour from the
744
+ // forward edge actually occupying the trunk below this node.
745
+ const inEdges = inboundFwd.get(nodeHash) ?? [];
746
+ inEdges.sort((a, b) => a.dirName.localeCompare(b.dirName));
747
+ for (const edge of inEdges) {
748
+ const edgeLane = Math.max(nodeLane.get(edge.from) ?? 0, nodeLane.get(edge.to) ?? 0);
749
+ laneCurrentEdge.set(edgeLane, edge);
750
+ }
751
+
752
+ // ── 3b. Back-arc arrow rows ──────────────────────────────────────────────
753
+ // For each routed arc sourced here, a │↓ arrow row in its back-lane sits
754
+ // directly below the source node (before the source node's forward inbound
755
+ // migration rows).
756
+ {
757
+ const teeArcs = backArcsBySource.get(nodeHash) ?? [];
758
+ for (const arc of teeArcs) {
759
+ const row = makeRow();
760
+ const railCol = arc.geomLane * colsPerLane;
761
+ const connCol = railCol + 1;
762
+ const line = backArcLine(arc);
763
+ const plane = backArcPlane(arc);
764
+ composeLine(row, railCol, line, new Set<Direction>(['up', 'down']), plane);
765
+ composeLine(row, connCol, line, new Set<Direction>(['down']), plane);
766
+ placeVerticals(row, new Set<number>());
767
+ placeBackVerticals(row);
768
+ grid.push(row);
769
+ }
770
+ }
771
+
772
+ // ── 4. Merge connector (AFTER the node row) ────────────────────────────
773
+ if (inEdges.length > 1) {
774
+ const branchEntries = inEdges
775
+ .slice(1)
776
+ .map((e) => ({ lane: nodeLane.get(e.from) ?? 0, edge: e }));
777
+
778
+ const trunkEdge = inEdges[0];
779
+ const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'merge', trunkEdge);
780
+ grid.push(connRow);
781
+ assertSingleOwner(connRow, isFocus);
782
+
783
+ for (const b of branchEntries) activeLanes.add(b.lane);
784
+ }
785
+
786
+ // ── 5. Migration rows (one per inbound edge, ordered by migration hash) ─
787
+ for (const edge of inEdges) {
788
+ const fromLane = nodeLane.get(edge.from) ?? 0;
789
+ const toLane = nodeLane.get(edge.to) ?? 0;
790
+ const edgeLane = Math.max(fromLane, toLane);
791
+ const row = makeRow();
792
+ const railCol = edgeLane * colsPerLane;
793
+ const connCol = edgeLane * colsPerLane + 1;
794
+ const line = lineRefFor(edge, edgeLane);
795
+
796
+ row[railCol] = vertCell(line);
797
+ row[connCol] = dirCell(line, new Set<Direction>(['up']));
798
+
799
+ placeVerticals(row, new Set([edgeLane]));
800
+ placeBackVerticals(row);
801
+ grid.push(row);
802
+ }
803
+
804
+ // ── 5b. Adjacent rollback ↓ rows ─────────────────────────────────────────
805
+ // An adjacent rollback (target is the display-neighbour directly below) is a
806
+ // plain ↓ in the source's own lane — mirror of the forward ↑ — emitted after
807
+ // the source node's forward inbound rows, directly above the target node.
808
+ {
809
+ const adjacents = adjacentBySource.get(nodeHash) ?? [];
810
+ for (const adj of adjacents) {
811
+ const row = makeRow();
812
+ const connCol = nodeLaneNum * colsPerLane + 1;
813
+ const line = lineRefFor(adj, nodeLaneNum);
814
+ const plane = planeOf(nodeLaneNum, line.role);
815
+ // The rail │ belongs to the trunk passing through (drawn by placeVerticals
816
+ // from the lane's current forward edge); only the ↓ arrow is the rollback.
817
+ composeLine(row, connCol, line, new Set<Direction>(['down']), plane);
818
+ placeVerticals(row, new Set<number>());
819
+ placeBackVerticals(row);
820
+ grid.push(row);
821
+ }
822
+ }
823
+
824
+ // ── 6. Root lane deactivation ─────────────────────────────────────────
825
+ if (inEdges.length === 0) {
826
+ activeLanes.delete(nodeLaneNum);
827
+ }
828
+ }
829
+
830
+ return grid;
831
+ }
832
+
833
+ // ---------------------------------------------------------------------------
834
+ // Single-owner invariant — after building a connector row, assert that every
835
+ // cell has at most one DRAWABLE owner once occlusion (topmost plane) is applied.
836
+ // In focus mode a tie at the same plane between an on-path and an off-path line
837
+ // would be a colour ambiguity, so we additionally assert that at the top plane
838
+ // of each cell exactly one role survives.
839
+ // ---------------------------------------------------------------------------
840
+ function assertSingleOwner(row: CellsRow, isFocus: boolean): void {
841
+ for (const cell of row) {
842
+ if (cell.lines.length <= 1) continue;
843
+ let topPlane = Number.POSITIVE_INFINITY;
844
+ for (const cl of cell.lines) if (cl.plane < topPlane) topPlane = cl.plane;
845
+ const top = cell.lines.filter((cl: CellLine) => cl.plane === topPlane);
846
+ if (top.length > 1) {
847
+ if (isFocus) {
848
+ const roles = new Set(top.map((cl) => cl.line.role));
849
+ if (roles.size > 1) {
850
+ throw new Error(
851
+ 'migration-graph layout: single-owner invariant violated — two differently-roled lines share the top plane in one cell',
852
+ );
853
+ }
854
+ }
855
+ }
856
+ }
857
+ }