@prisma-next/cli 0.12.0-dev.66 → 0.12.0-dev.68

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 (41) hide show
  1. package/dist/cli.mjs +5 -5
  2. package/dist/commands/migrate.d.mts.map +1 -1
  3. package/dist/commands/migrate.mjs +17 -11
  4. package/dist/commands/migrate.mjs.map +1 -1
  5. package/dist/commands/migration-check.mjs +1 -1
  6. package/dist/commands/migration-graph.mjs +2 -2
  7. package/dist/commands/migration-graph.mjs.map +1 -1
  8. package/dist/commands/migration-list.mjs +1 -1
  9. package/dist/commands/migration-log.mjs +1 -1
  10. package/dist/commands/migration-status.d.mts.map +1 -1
  11. package/dist/commands/migration-status.mjs +1 -1
  12. package/dist/{migration-check-VwM8xCZV.mjs → migration-check-soB5uZEQ.mjs} +1 -2
  13. package/dist/{migration-check-VwM8xCZV.mjs.map → migration-check-soB5uZEQ.mjs.map} +1 -1
  14. package/dist/migration-graph-command-render-BAOzyYF6.mjs +1822 -0
  15. package/dist/migration-graph-command-render-BAOzyYF6.mjs.map +1 -0
  16. package/dist/{migration-list-CyLslAtv.mjs → migration-list-CihF6w5z.mjs} +2 -2
  17. package/dist/migration-list-CihF6w5z.mjs.map +1 -0
  18. package/dist/{migration-log-BYt18y2H.mjs → migration-log-B75IArji.mjs} +2 -2
  19. package/dist/{migration-log-BYt18y2H.mjs.map → migration-log-B75IArji.mjs.map} +1 -1
  20. package/dist/{migration-status-B5GzQYe3.mjs → migration-status-Di82DGvo.mjs} +3 -4
  21. package/dist/migration-status-Di82DGvo.mjs.map +1 -0
  22. package/package.json +19 -18
  23. package/src/commands/migrate.ts +35 -26
  24. package/src/commands/migration-graph.ts +1 -1
  25. package/src/commands/migration-list.ts +1 -1
  26. package/src/commands/migration-status-overlay.ts +1 -1
  27. package/src/commands/migration-status.ts +4 -2
  28. package/src/utils/formatters/migration-graph-command-render.ts +239 -0
  29. package/src/utils/formatters/migration-graph-grid-layout.ts +857 -0
  30. package/src/utils/formatters/migration-graph-labels.ts +406 -0
  31. package/src/utils/formatters/migration-graph-model.ts +94 -0
  32. package/src/utils/formatters/migration-graph-occlusion-render.ts +245 -0
  33. package/src/utils/formatters/migration-graph-space-render.ts +73 -33
  34. package/src/utils/formatters/migration-list-render.ts +1 -1
  35. package/dist/migration-graph-space-render-Cpg0ql8v.mjs +0 -2370
  36. package/dist/migration-graph-space-render-Cpg0ql8v.mjs.map +0 -1
  37. package/dist/migration-list-CyLslAtv.mjs.map +0 -1
  38. package/dist/migration-status-B5GzQYe3.mjs.map +0 -1
  39. package/src/utils/formatters/migration-graph-lane-colors.ts +0 -194
  40. package/src/utils/formatters/migration-graph-layout.ts +0 -1308
  41. package/src/utils/formatters/migration-graph-tree-render.ts +0 -1337
@@ -1,1308 +0,0 @@
1
- import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
2
- import type { ClassifiedEdge, MigrationGraphRowModel } from './migration-graph-rows';
3
- import type { MigrationEdgeKind } from './migration-list-graph-topology';
4
-
5
- export type EdgeAdjacency = 'adjacent' | 'node-skipping-forward' | 'node-skipping-rollback';
6
-
7
- export type StructuralCell =
8
- | { readonly kind: 'empty' }
9
- | {
10
- readonly kind: 'node';
11
- readonly contractHash: string;
12
- readonly arcTee?: boolean;
13
- readonly arcLand?: boolean;
14
- }
15
- | { readonly kind: 'vertical-pass'; readonly migrationHash?: string }
16
- | { readonly kind: 'horizontal-pass'; readonly migrationHash?: string }
17
- | { readonly kind: 'branch-tee'; readonly migrationHash?: string }
18
- | { readonly kind: 'branch-corner'; readonly migrationHash?: string }
19
- | { readonly kind: 'merge-tee'; readonly migrationHash?: string }
20
- | { readonly kind: 'merge-corner'; readonly migrationHash?: string }
21
- | { readonly kind: 'arc-branch-corner'; readonly migrationHash?: string }
22
- | { readonly kind: 'arc-branch-tee'; readonly migrationHash?: string }
23
- | { readonly kind: 'arc-land-corner'; readonly migrationHash?: string }
24
- | { readonly kind: 'arc-land-tee'; readonly migrationHash?: string }
25
- | {
26
- readonly kind: 'arc-crossing';
27
- /** Hash of the edge whose vertical lane passes through this cell. */
28
- readonly migrationHash?: string;
29
- /** Hash of the arc edge that crosses over the vertical lane. */
30
- readonly arcMigrationHash?: string;
31
- }
32
- | { readonly kind: 'arc-land-bridge'; readonly migrationHash?: string }
33
- | {
34
- readonly kind: 'edge-lane';
35
- readonly migrationHash: string;
36
- readonly edgeKind: MigrationEdgeKind;
37
- readonly ownsLabel: boolean;
38
- readonly adjacency: EdgeAdjacency;
39
- };
40
-
41
- export type GridRowKind =
42
- | 'node'
43
- | 'edge'
44
- | 'branch-connector'
45
- | 'merge-connector'
46
- | 'component-separator';
47
-
48
- export interface MigrationGraphGridRow {
49
- readonly kind: GridRowKind;
50
- readonly contractHash?: string;
51
- readonly edge?: ClassifiedEdge;
52
- readonly laneIndex?: number;
53
- readonly passThroughLanes?: readonly number[];
54
- readonly startLane?: number;
55
- readonly endLane?: number;
56
- readonly branchCount?: number;
57
- readonly convergenceProducer?: boolean;
58
- readonly cells: readonly StructuralCell[];
59
- }
60
-
61
- export interface MigrationGraphGridModel {
62
- readonly rows: readonly MigrationGraphGridRow[];
63
- readonly nodeColumn: ReadonlyMap<string, number>;
64
- readonly edgeColumn: ReadonlyMap<string, number>;
65
- }
66
-
67
- // ---------------------------------------------------------------------------
68
- // Edge bucketing helpers
69
- // ---------------------------------------------------------------------------
70
-
71
- function forwardEdges(edges: readonly ClassifiedEdge[]): ClassifiedEdge[] {
72
- return edges.filter((e) => e.kind === 'forward');
73
- }
74
-
75
- function buildForwardProducersByTo(
76
- edges: readonly ClassifiedEdge[],
77
- ): Map<string, ClassifiedEdge[]> {
78
- const byTo = new Map<string, ClassifiedEdge[]>();
79
- for (const edge of edges) {
80
- if (edge.kind !== 'forward') continue;
81
- const bucket = byTo.get(edge.to);
82
- if (bucket) bucket.push(edge);
83
- else byTo.set(edge.to, [edge]);
84
- }
85
- return byTo;
86
- }
87
-
88
- function buildForwardOutDegree(edges: readonly ClassifiedEdge[]): Map<string, number> {
89
- const out = new Map<string, number>();
90
- for (const edge of edges) {
91
- if (edge.kind !== 'forward' || edge.from === edge.to) continue;
92
- out.set(edge.from, (out.get(edge.from) ?? 0) + 1);
93
- }
94
- return out;
95
- }
96
-
97
- function buildForwardInDegree(edges: readonly ClassifiedEdge[]): Map<string, number> {
98
- const indeg = new Map<string, number>();
99
- for (const edge of forwardEdges(edges)) {
100
- if (edge.from === edge.to) continue;
101
- indeg.set(edge.to, (indeg.get(edge.to) ?? 0) + 1);
102
- }
103
- return indeg;
104
- }
105
-
106
- /**
107
- * Distinct source contracts among a contract's forward producers. A contract is
108
- * a *convergence* when this count is >= 2. Multiple migrations sharing one
109
- * source (a multi-edge) count once — they stack in a single lane rather than
110
- * fanning into a convergence.
111
- */
112
- function buildDistinctSourceCountByTo(edges: readonly ClassifiedEdge[]): Map<string, number> {
113
- const sources = new Map<string, Set<string>>();
114
- for (const edge of edges) {
115
- if (edge.kind !== 'forward' || edge.from === edge.to) continue;
116
- const set = sources.get(edge.to);
117
- if (set) set.add(edge.from);
118
- else sources.set(edge.to, new Set([edge.from]));
119
- }
120
- const counts = new Map<string, number>();
121
- for (const [to, set] of sources) counts.set(to, set.size);
122
- return counts;
123
- }
124
-
125
- function splitComponents(nodes: readonly (string | null)[]): readonly (readonly string[])[] {
126
- const components: string[][] = [];
127
- let current: string[] = [];
128
- for (const node of nodes) {
129
- if (node === null) {
130
- if (current.length > 0) {
131
- components.push(current);
132
- current = [];
133
- }
134
- continue;
135
- }
136
- current.push(node);
137
- }
138
- if (current.length > 0) components.push(current);
139
- return components;
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // Adjacency refinement (operates on the emitted rows)
144
- // ---------------------------------------------------------------------------
145
-
146
- function classifyForwardShortConvergenceAdjacency(
147
- rows: readonly MigrationGraphGridRow[],
148
- edgeRowIndex: number,
149
- edge: ClassifiedEdge,
150
- laneIndex: number,
151
- ): EdgeAdjacency {
152
- for (let index = edgeRowIndex + 1; index < rows.length; index++) {
153
- const row = rows[index];
154
- if (row === undefined) break;
155
- if (row.kind === 'component-separator' || row.kind === 'branch-connector') continue;
156
- if (row.kind === 'merge-connector') continue;
157
- if (row.kind === 'edge') {
158
- if (row.laneIndex === laneIndex) return 'node-skipping-forward';
159
- continue;
160
- }
161
- if (row.kind === 'node' && row.contractHash === edge.from) {
162
- return 'adjacent';
163
- }
164
- }
165
- return 'node-skipping-forward';
166
- }
167
-
168
- function convergenceProducerUsesShortAdjacency(
169
- edge: ClassifiedEdge,
170
- laneIndex: number,
171
- forwardProducersByTo: ReadonlyMap<string, readonly ClassifiedEdge[]>,
172
- producerLaneByHash: ReadonlyMap<string, number>,
173
- ): boolean {
174
- const producers = (forwardProducersByTo.get(edge.to) ?? []).filter(
175
- (candidate) => candidate.kind === 'forward',
176
- );
177
- if (producers.length < 2) return false;
178
-
179
- const fanLanes = [
180
- ...new Set(
181
- producers
182
- .map((producer) => producerLaneByHash.get(producer.migrationHash))
183
- .filter((candidate): candidate is number => candidate !== undefined),
184
- ),
185
- ].sort((a, b) => a - b);
186
- const fanStart = fanLanes[0];
187
- if (fanStart === undefined) return false;
188
-
189
- return laneIndex === fanStart;
190
- }
191
-
192
- function classifyForwardLayoutAdjacency(
193
- rows: readonly MigrationGraphGridRow[],
194
- edgeRowIndex: number,
195
- edge: ClassifiedEdge,
196
- laneIndex: number,
197
- passThroughLanes: readonly number[],
198
- nodeColumn: ReadonlyMap<string, number>,
199
- convergenceProducer: boolean,
200
- divergenceBranchEdge: boolean,
201
- ): EdgeAdjacency {
202
- let sawObstruction = false;
203
- const passThroughLaneSet = new Set(passThroughLanes);
204
-
205
- for (let index = edgeRowIndex + 1; index < rows.length; index++) {
206
- const row = rows[index];
207
- if (row === undefined) break;
208
- if (row.kind === 'component-separator') continue;
209
- if (row.kind === 'merge-connector') {
210
- if (convergenceProducer) {
211
- if (row.contractHash === edge.from) sawObstruction = true;
212
- } else if (!divergenceBranchEdge && row.contractHash !== edge.from) {
213
- sawObstruction = true;
214
- }
215
- continue;
216
- }
217
- if (row.kind === 'branch-connector') continue;
218
- if (row.kind === 'edge') {
219
- if (row.laneIndex === laneIndex) return 'node-skipping-forward';
220
- if (!divergenceBranchEdge && row.edge !== undefined && row.edge.to !== edge.to) {
221
- sawObstruction = true;
222
- }
223
- continue;
224
- }
225
- if (row.kind === 'node' && row.contractHash !== undefined) {
226
- if (row.contractHash === edge.from) {
227
- return sawObstruction ? 'node-skipping-forward' : 'adjacent';
228
- }
229
- const nodeCol = nodeColumn.get(row.contractHash) ?? 0;
230
- // A divergence-branch lane runs unobstructed to its convergence point;
231
- // sibling-branch nodes sit in parallel lanes and never block it.
232
- if (!divergenceBranchEdge && !passThroughLaneSet.has(nodeCol)) {
233
- sawObstruction = true;
234
- }
235
- }
236
- }
237
-
238
- return 'node-skipping-forward';
239
- }
240
-
241
- function classifyLayoutAdjacency(
242
- rows: readonly MigrationGraphGridRow[],
243
- edgeRowIndex: number,
244
- edge: ClassifiedEdge,
245
- laneIndex: number,
246
- passThroughLanes: readonly number[],
247
- nodeColumn: ReadonlyMap<string, number>,
248
- position: ReadonlyMap<string, number>,
249
- forwardInDegree: ReadonlyMap<string, number>,
250
- convergenceProducer: boolean,
251
- divergenceBranchEdge: boolean,
252
- ): EdgeAdjacency {
253
- if (edge.kind === 'self') return 'adjacent';
254
-
255
- const fromPos = position.get(edge.from);
256
- const toPos = position.get(edge.to);
257
-
258
- if (edge.kind === 'forward') {
259
- const inDegree = forwardInDegree.get(edge.to) ?? 0;
260
- if (inDegree <= 1 && fromPos !== undefined && toPos !== undefined && fromPos === toPos + 1) {
261
- return 'adjacent';
262
- }
263
- return classifyForwardLayoutAdjacency(
264
- rows,
265
- edgeRowIndex,
266
- edge,
267
- laneIndex,
268
- passThroughLanes,
269
- nodeColumn,
270
- convergenceProducer,
271
- divergenceBranchEdge,
272
- );
273
- }
274
-
275
- if (fromPos !== undefined && toPos !== undefined && toPos === fromPos + 1) {
276
- return 'adjacent';
277
- }
278
-
279
- for (let index = edgeRowIndex + 1; index < rows.length; index++) {
280
- const row = rows[index];
281
- if (row === undefined) break;
282
- if (
283
- row.kind === 'component-separator' ||
284
- row.kind === 'branch-connector' ||
285
- row.kind === 'merge-connector'
286
- ) {
287
- continue;
288
- }
289
- if (row.kind === 'edge') continue;
290
- if (row.kind === 'node') {
291
- return row.contractHash === edge.to ? 'adjacent' : 'node-skipping-rollback';
292
- }
293
- }
294
- return 'node-skipping-rollback';
295
- }
296
-
297
- function refineAdjacency(
298
- rows: readonly MigrationGraphGridRow[],
299
- nodeColumn: ReadonlyMap<string, number>,
300
- position: ReadonlyMap<string, number>,
301
- forwardInDegree: ReadonlyMap<string, number>,
302
- forwardOutDegree: ReadonlyMap<string, number>,
303
- edges: readonly ClassifiedEdge[],
304
- producerLaneByHash: ReadonlyMap<string, number>,
305
- ): MigrationGraphGridRow[] {
306
- const forwardProducersByTo = buildForwardProducersByTo(edges);
307
- function branchLaneForEdge(producer: ClassifiedEdge): number | undefined {
308
- const children = edges.filter(
309
- (edge) => edge.from === producer.from && edge.kind === 'forward' && edge.from !== edge.to,
310
- );
311
- if (children.length < 2) return undefined;
312
- const index = children.findIndex((child) => child.migrationHash === producer.migrationHash);
313
- return index >= 0 ? index : undefined;
314
- }
315
-
316
- return rows.map((row, rowIndex) => {
317
- if (row.kind !== 'edge' || row.edge === undefined || row.laneIndex === undefined) {
318
- return row;
319
- }
320
- const divergenceBranchEdge =
321
- row.edge.kind === 'forward' &&
322
- !(row.convergenceProducer ?? false) &&
323
- (forwardOutDegree.get(row.edge.from) ?? 0) >= 2 &&
324
- branchLaneForEdge(row.edge) !== undefined;
325
- const adjacency =
326
- row.convergenceProducer === true &&
327
- convergenceProducerUsesShortAdjacency(
328
- row.edge,
329
- row.laneIndex,
330
- forwardProducersByTo,
331
- producerLaneByHash,
332
- )
333
- ? classifyForwardShortConvergenceAdjacency(rows, rowIndex, row.edge, row.laneIndex)
334
- : classifyLayoutAdjacency(
335
- rows,
336
- rowIndex,
337
- row.edge,
338
- row.laneIndex,
339
- row.passThroughLanes ?? [],
340
- nodeColumn,
341
- position,
342
- forwardInDegree,
343
- row.convergenceProducer ?? false,
344
- divergenceBranchEdge,
345
- );
346
- // Reconstruct lane owners from the existing cells so the refined row
347
- // preserves per-cell identity on its pass-through vertical-pass cells.
348
- const existingLaneEdge = new Map<number, string>();
349
- for (const lane of row.passThroughLanes ?? []) {
350
- const cell = row.cells[lane];
351
- if (cell !== undefined && 'migrationHash' in cell && cell.migrationHash !== undefined) {
352
- existingLaneEdge.set(lane, cell.migrationHash);
353
- }
354
- }
355
- return {
356
- ...row,
357
- cells: buildEdgeCells(
358
- row.edge,
359
- row.laneIndex,
360
- row.passThroughLanes ?? [],
361
- adjacency,
362
- row.cells.length,
363
- existingLaneEdge,
364
- ),
365
- };
366
- });
367
- }
368
-
369
- function classifyEdgeAdjacency(
370
- edge: ClassifiedEdge,
371
- position: ReadonlyMap<string, number>,
372
- ): EdgeAdjacency {
373
- if (edge.kind === 'self') return 'adjacent';
374
-
375
- const fromPos = position.get(edge.from);
376
- const toPos = position.get(edge.to);
377
- if (fromPos === undefined || toPos === undefined) return 'adjacent';
378
-
379
- if (edge.kind === 'forward') {
380
- if (toPos >= fromPos) return 'adjacent';
381
- return fromPos === toPos + 1 ? 'adjacent' : 'node-skipping-forward';
382
- }
383
-
384
- if (toPos <= fromPos) return 'adjacent';
385
- return toPos === fromPos + 1 ? 'adjacent' : 'node-skipping-rollback';
386
- }
387
-
388
- // ---------------------------------------------------------------------------
389
- // Cell builders
390
- // ---------------------------------------------------------------------------
391
-
392
- function emptyCells(width: number): StructuralCell[] {
393
- return Array.from({ length: width }, () => ({ kind: 'empty' as const }));
394
- }
395
-
396
- /** Returns `{ migrationHash: hash }` when hash is defined, otherwise `{}`. */
397
- function hashProp(hash: string | undefined): { readonly migrationHash: string } | object {
398
- return hash !== undefined ? { migrationHash: hash } : {};
399
- }
400
-
401
- /** Returns `{ arcMigrationHash: hash }` when hash is defined, otherwise `{}`. */
402
- function arcHashProp(hash: string | undefined): { readonly arcMigrationHash: string } | object {
403
- return hash !== undefined ? { arcMigrationHash: hash } : {};
404
- }
405
-
406
- function buildBranchConnectorCells(
407
- startLane: number,
408
- endLane: number,
409
- fanTargetLanes: ReadonlySet<number>,
410
- activeLanes: ReadonlySet<number>,
411
- gridWidth: number,
412
- /** Hash of the edge whose lane is at startLane (the source/trunk edge). */
413
- trunkEdgeHash: string | undefined,
414
- /** Hash of the fan edge for each fan-target lane. */
415
- fanEdgeHashByLane: ReadonlyMap<number, string>,
416
- /** Hash of the edge occupying each active pass-through lane. */
417
- laneEdgeByIndex: ReadonlyMap<number, string>,
418
- ): StructuralCell[] {
419
- const cells = emptyCells(gridWidth);
420
- for (let lane = 0; lane < gridWidth; lane++) {
421
- if (activeLanes.has(lane) && (lane < startLane || lane > endLane)) {
422
- cells[lane] = { kind: 'vertical-pass', ...hashProp(laneEdgeByIndex.get(lane)) };
423
- continue;
424
- }
425
- if (lane === startLane) {
426
- cells[lane] = { kind: 'branch-tee', ...hashProp(trunkEdgeHash) };
427
- } else if (lane === endLane) {
428
- cells[lane] = { kind: 'branch-corner', ...hashProp(fanEdgeHashByLane.get(lane)) };
429
- } else if (lane > startLane && lane < endLane) {
430
- if (fanTargetLanes.has(lane)) {
431
- cells[lane] = { kind: 'branch-tee', ...hashProp(fanEdgeHashByLane.get(lane)) };
432
- } else if (activeLanes.has(lane)) {
433
- cells[lane] = {
434
- kind: 'arc-crossing',
435
- ...hashProp(laneEdgeByIndex.get(lane)),
436
- ...arcHashProp(fanEdgeHashByLane.get(endLane)),
437
- };
438
- } else {
439
- cells[lane] = { kind: 'branch-tee', ...hashProp(fanEdgeHashByLane.get(lane)) };
440
- }
441
- }
442
- }
443
- return cells;
444
- }
445
-
446
- function buildMergeConnectorCells(
447
- startLane: number,
448
- endLane: number,
449
- fanTargetLanes: ReadonlySet<number>,
450
- activeLanes: ReadonlySet<number>,
451
- gridWidth: number,
452
- /** Hash of the edge occupying each active lane (fan lanes + pass-throughs). */
453
- laneEdgeByIndex: ReadonlyMap<number, string>,
454
- ): StructuralCell[] {
455
- const cells = emptyCells(gridWidth);
456
- for (let lane = 0; lane < gridWidth; lane++) {
457
- if (activeLanes.has(lane) && (lane < startLane || lane > endLane)) {
458
- cells[lane] = { kind: 'vertical-pass', ...hashProp(laneEdgeByIndex.get(lane)) };
459
- continue;
460
- }
461
- if (lane === startLane) {
462
- cells[lane] = { kind: 'merge-tee', ...hashProp(laneEdgeByIndex.get(lane)) };
463
- } else if (lane === endLane) {
464
- cells[lane] = { kind: 'merge-corner', ...hashProp(laneEdgeByIndex.get(lane)) };
465
- } else if (lane > startLane && lane < endLane) {
466
- if (fanTargetLanes.has(lane)) {
467
- cells[lane] = { kind: 'merge-tee', ...hashProp(laneEdgeByIndex.get(lane)) };
468
- } else if (activeLanes.has(lane)) {
469
- cells[lane] = {
470
- kind: 'arc-crossing',
471
- ...hashProp(laneEdgeByIndex.get(lane)),
472
- ...arcHashProp(laneEdgeByIndex.get(endLane)),
473
- };
474
- } else {
475
- cells[lane] = { kind: 'horizontal-pass', ...hashProp(laneEdgeByIndex.get(startLane)) };
476
- }
477
- }
478
- }
479
- return cells;
480
- }
481
-
482
- function buildNodeCells(
483
- contractHash: string,
484
- nodeColumn: number,
485
- activeLanes: readonly number[],
486
- gridWidth: number,
487
- /** Hash of the edge occupying each active pass-through lane. */
488
- laneEdgeByIndex: ReadonlyMap<number, string>,
489
- ): StructuralCell[] {
490
- const cells = emptyCells(gridWidth);
491
- for (const lane of activeLanes) {
492
- if (lane !== nodeColumn && lane < gridWidth) {
493
- cells[lane] = { kind: 'vertical-pass', ...hashProp(laneEdgeByIndex.get(lane)) };
494
- }
495
- }
496
- if (nodeColumn < gridWidth) {
497
- cells[nodeColumn] = { kind: 'node', contractHash };
498
- }
499
- return cells;
500
- }
501
-
502
- function buildEdgeCells(
503
- edge: ClassifiedEdge,
504
- laneIndex: number,
505
- passThroughLanes: readonly number[],
506
- adjacency: EdgeAdjacency,
507
- gridWidth: number,
508
- /** Hash of the edge occupying each active pass-through lane. */
509
- laneEdgeByIndex: ReadonlyMap<number, string>,
510
- ): StructuralCell[] {
511
- const cells = emptyCells(gridWidth);
512
- for (const lane of passThroughLanes) {
513
- if (lane < gridWidth) {
514
- cells[lane] = { kind: 'vertical-pass', ...hashProp(laneEdgeByIndex.get(lane)) };
515
- }
516
- }
517
- if (laneIndex < gridWidth) {
518
- cells[laneIndex] = {
519
- kind: 'edge-lane',
520
- migrationHash: edge.migrationHash,
521
- edgeKind: edge.kind,
522
- ownsLabel: true,
523
- adjacency,
524
- };
525
- }
526
- return cells;
527
- }
528
-
529
- // ---------------------------------------------------------------------------
530
- // Vertical ordering: tips-first DFS post-order over forward edges
531
- // ---------------------------------------------------------------------------
532
-
533
- /**
534
- * Compute the vertical node order for a component: tips at the top (index 0),
535
- * roots at the bottom. This is a DFS post-order over forward edges starting
536
- * from forward roots, visiting children in their input (insertion) order. A
537
- * node is emitted only after all of its forward children, so convergence nodes
538
- * sit below every branch that feeds them and the longest contiguous chain reads
539
- * top-to-bottom without braiding.
540
- */
541
- function computeVerticalOrder(
542
- componentNodes: readonly string[],
543
- forwardChildren: ReadonlyMap<string, readonly ClassifiedEdge[]>,
544
- forwardInDegree: ReadonlyMap<string, number>,
545
- ): string[] {
546
- const WHITE = 0;
547
- const GRAY = 1;
548
- const BLACK = 2;
549
- const color = new Map<string, number>();
550
- for (const node of componentNodes) color.set(node, WHITE);
551
-
552
- const sortRoots = (roots: readonly string[]): string[] =>
553
- [...roots].sort((a, b) => {
554
- if (a === EMPTY_CONTRACT_HASH) return -1;
555
- if (b === EMPTY_CONTRACT_HASH) return 1;
556
- return a.localeCompare(b);
557
- });
558
-
559
- let roots = sortRoots(componentNodes.filter((n) => (forwardInDegree.get(n) ?? 0) === 0));
560
- if (roots.length === 0) roots = sortRoots(componentNodes);
561
-
562
- const result: string[] = [];
563
-
564
- interface Frame {
565
- node: string;
566
- children: readonly ClassifiedEdge[];
567
- index: number;
568
- }
569
-
570
- function runDfs(root: string): void {
571
- if (color.get(root) !== WHITE) return;
572
- const stack: Frame[] = [{ node: root, children: forwardChildren.get(root) ?? [], index: 0 }];
573
- color.set(root, GRAY);
574
-
575
- while (stack.length > 0) {
576
- const frame = stack[stack.length - 1];
577
- if (frame === undefined) break;
578
- if (frame.index >= frame.children.length) {
579
- color.set(frame.node, BLACK);
580
- result.push(frame.node);
581
- stack.pop();
582
- continue;
583
- }
584
- const child = frame.children[frame.index];
585
- frame.index += 1;
586
- if (child === undefined) continue;
587
- if (color.get(child.to) === WHITE) {
588
- color.set(child.to, GRAY);
589
- stack.push({ node: child.to, children: forwardChildren.get(child.to) ?? [], index: 0 });
590
- }
591
- }
592
- }
593
-
594
- for (const root of roots) runDfs(root);
595
- // Nodes unreachable via forward edges (e.g. rollback-only sources) follow in
596
- // component order.
597
- for (const node of componentNodes) {
598
- if (color.get(node) === WHITE) runDfs(node);
599
- }
600
-
601
- return result;
602
- }
603
-
604
- // ---------------------------------------------------------------------------
605
- // Routed back-arcs for node-skipping rollbacks
606
- // ---------------------------------------------------------------------------
607
-
608
- interface SkipRollbackRoute {
609
- readonly edge: ClassifiedEdge;
610
- readonly backLane: number;
611
- }
612
-
613
- function rollbackSpan(
614
- edge: ClassifiedEdge,
615
- position: ReadonlyMap<string, number>,
616
- ): { readonly top: number; readonly bottom: number } {
617
- const top = position.get(edge.from) ?? 0;
618
- const bottom = position.get(edge.to) ?? top;
619
- return { top, bottom };
620
- }
621
-
622
- function spansOverlap(
623
- a: { readonly top: number; readonly bottom: number },
624
- b: { readonly top: number; readonly bottom: number },
625
- ): boolean {
626
- return a.top <= b.bottom && b.top <= a.bottom;
627
- }
628
-
629
- function forwardMaxLane(
630
- rows: readonly MigrationGraphGridRow[],
631
- skipMigrationHashes: ReadonlySet<string>,
632
- ): number {
633
- let max = 0;
634
- for (const row of rows) {
635
- if (
636
- row.kind === 'edge' &&
637
- row.edge !== undefined &&
638
- skipMigrationHashes.has(row.edge.migrationHash)
639
- ) {
640
- continue;
641
- }
642
- max = Math.max(max, row.laneIndex ?? 0);
643
- for (const lane of row.passThroughLanes ?? []) {
644
- max = Math.max(max, lane);
645
- }
646
- if (row.startLane !== undefined) {
647
- max = Math.max(max, row.startLane, row.endLane ?? row.startLane);
648
- }
649
- }
650
- return max;
651
- }
652
-
653
- function allocateSkipRollbackBackLanes(
654
- skipRollbacks: readonly ClassifiedEdge[],
655
- position: ReadonlyMap<string, number>,
656
- forwardMax: number,
657
- ): Map<string, number> {
658
- const sorted = [...skipRollbacks].sort((a, b) => {
659
- const aTop = position.get(a.from) ?? 0;
660
- const bTop = position.get(b.from) ?? 0;
661
- if (aTop !== bTop) return aTop - bTop;
662
- return b.dirName.localeCompare(a.dirName);
663
- });
664
-
665
- const occupied: { readonly top: number; readonly bottom: number; readonly lane: number }[] = [];
666
- const lanes = new Map<string, number>();
667
- let nextLane = forwardMax + 1;
668
-
669
- for (const edge of sorted) {
670
- const span = rollbackSpan(edge, position);
671
- let lane = nextLane;
672
- while (occupied.some((entry) => entry.lane === lane && spansOverlap(entry, span))) {
673
- lane += 1;
674
- }
675
- occupied.push({ ...span, lane });
676
- lanes.set(edge.migrationHash, lane);
677
- nextLane = Math.max(nextLane, lane + 1);
678
- }
679
-
680
- return lanes;
681
- }
682
-
683
- function findNodeRowIndex(rows: readonly MigrationGraphGridRow[], contractHash: string): number {
684
- return rows.findIndex((row) => row.kind === 'node' && row.contractHash === contractHash);
685
- }
686
-
687
- function findEdgeRowIndex(rows: readonly MigrationGraphGridRow[], migrationHash: string): number {
688
- return rows.findIndex((row) => row.kind === 'edge' && row.edge?.migrationHash === migrationHash);
689
- }
690
-
691
- // A grid row with a mutable `cells` array. The routing pass clones the
692
- // immutable rows into this shape so it can paint arc cells in place without
693
- // stripping `readonly` with a cast.
694
- type MutableGridRow = Omit<MigrationGraphGridRow, 'cells'> & { cells: StructuralCell[] };
695
-
696
- function ensureCellWidth(cells: StructuralCell[], width: number): void {
697
- while (cells.length < width) {
698
- cells.push({ kind: 'empty' });
699
- }
700
- }
701
-
702
- function cloneRow(row: MigrationGraphGridRow): MutableGridRow {
703
- return { ...row, cells: [...row.cells] };
704
- }
705
-
706
- function routeCrossesRow(
707
- route: SkipRollbackRoute,
708
- rowIndex: number,
709
- rows: readonly MigrationGraphGridRow[],
710
- ): boolean {
711
- const sourceRow = findNodeRowIndex(rows, route.edge.from);
712
- const targetRow = findNodeRowIndex(rows, route.edge.to);
713
- if (sourceRow < 0 || targetRow < 0) return false;
714
- return rowIndex > sourceRow && rowIndex <= targetRow;
715
- }
716
-
717
- function applySkipRollbackRouting(
718
- rows: readonly MigrationGraphGridRow[],
719
- skipRollbacks: readonly ClassifiedEdge[],
720
- position: ReadonlyMap<string, number>,
721
- nodeColumn: ReadonlyMap<string, number>,
722
- edgeColumn: Map<string, number>,
723
- ): MigrationGraphGridRow[] {
724
- if (skipRollbacks.length === 0) return [...rows];
725
-
726
- const skipHashes = new Set(skipRollbacks.map((edge) => edge.migrationHash));
727
- const forwardMax = forwardMaxLane(rows, skipHashes);
728
- const backLaneByHash = allocateSkipRollbackBackLanes(skipRollbacks, position, forwardMax);
729
- const routes: SkipRollbackRoute[] = skipRollbacks.map((edge) => ({
730
- edge,
731
- backLane: backLaneByHash.get(edge.migrationHash) ?? forwardMax + 1,
732
- }));
733
-
734
- const result = rows.map(cloneRow);
735
-
736
- for (const route of routes) {
737
- const { edge, backLane } = route;
738
- const nodeCol = nodeColumn.get(edge.from) ?? 0;
739
- const targetCol = nodeColumn.get(edge.to) ?? 0;
740
- const sourceRowIndex = findNodeRowIndex(result, edge.from);
741
- const targetRowIndex = findNodeRowIndex(result, edge.to);
742
- const edgeRowIndex = findEdgeRowIndex(result, edge.migrationHash);
743
- if (sourceRowIndex < 0 || targetRowIndex < 0 || edgeRowIndex < 0) continue;
744
-
745
- edgeColumn.set(edge.migrationHash, backLane);
746
-
747
- // Back-lanes of arcs that tee off this same source node. They share the
748
- // node's tee row, so each inner lane reads as a `┬` junction and only the
749
- // outermost gets the closing `╮`.
750
- const coSourcedLanes = routes
751
- .filter((other) => other.edge.from === edge.from)
752
- .map((other) => other.backLane);
753
- const maxCoSourcedLane = Math.max(...coSourcedLanes);
754
-
755
- // Back-lanes of arcs that converge on this same target node. They share the
756
- // node's landing row, so each inner lane reads as a `┴` junction (the outer
757
- // arcs' horizontal bridge passes through it on the way to the node) and only
758
- // the outermost closes the corner with `╯`.
759
- const coLandingLanes = routes
760
- .filter((other) => other.edge.to === edge.to)
761
- .map((other) => other.backLane);
762
- const maxCoLandingLane = Math.max(...coLandingLanes);
763
-
764
- const { migrationHash: arcHash } = edge;
765
-
766
- const sourceRow = result[sourceRowIndex];
767
- if (sourceRow !== undefined) {
768
- const cells = sourceRow.cells;
769
- ensureCellWidth(cells, backLane + 1);
770
- const contractHash = sourceRow.contractHash ?? EMPTY_CONTRACT_HASH;
771
- cells[nodeCol] = { kind: 'node', contractHash, arcTee: true };
772
- for (let lane = nodeCol + 1; lane < backLane; lane += 1) {
773
- if (coSourcedLanes.includes(lane)) {
774
- // A co-sourced arc tees off at this lane; tag it with that arc's hash.
775
- const coSourcedArc = routes.find((r) => r.backLane === lane && r.edge.from === edge.from);
776
- cells[lane] = {
777
- kind: 'arc-branch-tee',
778
- ...hashProp(coSourcedArc?.edge.migrationHash),
779
- };
780
- continue;
781
- }
782
- const existing = cells[lane];
783
- const occupied =
784
- existing !== undefined &&
785
- existing.kind !== 'empty' &&
786
- existing.kind !== 'horizontal-pass' &&
787
- existing.kind !== 'arc-land-bridge';
788
- const crossed =
789
- occupied ||
790
- routes.some(
791
- (other) =>
792
- other.edge.migrationHash !== arcHash &&
793
- other.backLane === lane &&
794
- routeCrossesRow(other, sourceRowIndex, result),
795
- );
796
- if (crossed) {
797
- // The vertical lane was already occupied; tag the crossing with the
798
- // existing vertical owner's hash and the arc that crosses over it.
799
- const verticalHash =
800
- existing !== undefined && 'migrationHash' in existing
801
- ? existing.migrationHash
802
- : undefined;
803
- cells[lane] = {
804
- kind: 'arc-crossing',
805
- ...hashProp(verticalHash),
806
- arcMigrationHash: arcHash,
807
- };
808
- } else {
809
- cells[lane] = { kind: 'horizontal-pass', migrationHash: arcHash };
810
- }
811
- }
812
- cells[backLane] =
813
- backLane < maxCoSourcedLane
814
- ? { kind: 'arc-branch-tee', migrationHash: arcHash }
815
- : { kind: 'arc-branch-corner', migrationHash: arcHash };
816
- }
817
-
818
- const edgeRow = result[edgeRowIndex];
819
- if (edgeRow !== undefined) {
820
- // Mutate in place rather than rebuild from empty: a co-sourced arc's body
821
- // lane may already cross this row, and rebuilding would clobber it.
822
- const cells = edgeRow.cells;
823
- ensureCellWidth(cells, backLane + 1);
824
- // The forward lane at nodeCol is now interrupted by this rollback; tag the
825
- // vertical-pass with the edge that owns that forward lane.
826
- const forwardLaneCell = cells[nodeCol];
827
- const forwardLaneHash =
828
- forwardLaneCell !== undefined && 'migrationHash' in forwardLaneCell
829
- ? forwardLaneCell.migrationHash
830
- : undefined;
831
- cells[nodeCol] = { kind: 'vertical-pass', ...hashProp(forwardLaneHash) };
832
- cells[backLane] = {
833
- kind: 'edge-lane',
834
- migrationHash: arcHash,
835
- edgeKind: edge.kind,
836
- ownsLabel: true,
837
- adjacency: 'node-skipping-rollback',
838
- };
839
- result[edgeRowIndex] = { ...edgeRow, laneIndex: backLane, passThroughLanes: [nodeCol] };
840
- }
841
-
842
- // Fill the arc body vertically from just below the source tee down to the
843
- // row above the landing, skipping the rollback's own labelled edge row.
844
- // Starting below the source (rather than below the edge row) keeps a
845
- // co-sourced arc's lane connected across an earlier co-sourced edge row.
846
- for (let index = sourceRowIndex + 1; index < targetRowIndex; index += 1) {
847
- if (index === edgeRowIndex) continue;
848
- const row = result[index];
849
- if (row === undefined) continue;
850
- const cells = row.cells;
851
- ensureCellWidth(cells, backLane + 1);
852
- const existing = cells[backLane];
853
- if (
854
- existing?.kind !== 'arc-land-corner' &&
855
- existing?.kind !== 'arc-land-tee' &&
856
- existing?.kind !== 'arc-land-bridge' &&
857
- existing?.kind !== 'arc-branch-corner' &&
858
- existing?.kind !== 'arc-branch-tee' &&
859
- existing?.kind !== 'arc-crossing'
860
- ) {
861
- cells[backLane] = { kind: 'vertical-pass', migrationHash: arcHash };
862
- }
863
- }
864
-
865
- const targetRow = result[targetRowIndex];
866
- if (targetRow !== undefined) {
867
- const cells = targetRow.cells;
868
- ensureCellWidth(cells, backLane + 1);
869
- const contractHash = targetRow.contractHash ?? EMPTY_CONTRACT_HASH;
870
- cells[targetCol] = { kind: 'node', contractHash, arcLand: true };
871
- for (let lane = targetCol + 1; lane < backLane; lane += 1) {
872
- // An inner converging arc's own landing junction: the outer arcs' bridge
873
- // passes through it (`┴`) while its own vertical run closes here.
874
- if (coLandingLanes.includes(lane)) {
875
- // Tag the landing tee with the inner arc that closes here.
876
- const innerArc = routes.find((r) => r.backLane === lane && r.edge.to === edge.to);
877
- cells[lane] = { kind: 'arc-land-tee', ...hashProp(innerArc?.edge.migrationHash) };
878
- continue;
879
- }
880
- // A bridged lane that carries another arc OR a forward vertical still
881
- // active at this row must cross over it (`┼`) rather than overwrite it
882
- // with a bare bridge (`──`).
883
- const existing = cells[lane];
884
- const occupied =
885
- existing !== undefined &&
886
- existing.kind !== 'empty' &&
887
- existing.kind !== 'horizontal-pass' &&
888
- existing.kind !== 'arc-land-bridge' &&
889
- existing.kind !== 'arc-land-tee';
890
- const crossed =
891
- occupied ||
892
- routes.some(
893
- (other) =>
894
- other.edge.migrationHash !== arcHash &&
895
- other.backLane === lane &&
896
- routeCrossesRow(other, targetRowIndex, result),
897
- );
898
- if (crossed) {
899
- const verticalHash =
900
- existing !== undefined && 'migrationHash' in existing
901
- ? existing.migrationHash
902
- : undefined;
903
- cells[lane] = {
904
- kind: 'arc-crossing',
905
- ...hashProp(verticalHash),
906
- arcMigrationHash: arcHash,
907
- };
908
- } else {
909
- cells[lane] = { kind: 'arc-land-bridge', migrationHash: arcHash };
910
- }
911
- }
912
- // Inner converging arcs close as a landing tee so the outermost arc's
913
- // bridge reads through to the node; only the outermost arc draws `╯`.
914
- cells[backLane] =
915
- backLane < maxCoLandingLane
916
- ? { kind: 'arc-land-tee', migrationHash: arcHash }
917
- : { kind: 'arc-land-corner', migrationHash: arcHash };
918
- for (const other of routes) {
919
- if (other.backLane <= backLane) continue;
920
- if (!routeCrossesRow(other, targetRowIndex, result)) continue;
921
- ensureCellWidth(cells, other.backLane + 1);
922
- const existing = cells[other.backLane];
923
- if (
924
- existing?.kind !== 'arc-land-corner' &&
925
- existing?.kind !== 'arc-land-tee' &&
926
- existing?.kind !== 'arc-land-bridge' &&
927
- existing?.kind !== 'node'
928
- ) {
929
- // This is a pass-through from another arc still in flight; tag with
930
- // that arc's hash.
931
- cells[other.backLane] = {
932
- kind: 'vertical-pass',
933
- migrationHash: other.edge.migrationHash,
934
- };
935
- }
936
- }
937
- }
938
- }
939
-
940
- return result;
941
- }
942
-
943
- function collectNodeSkippingRollbacks(
944
- edges: readonly ClassifiedEdge[],
945
- position: ReadonlyMap<string, number>,
946
- ): ClassifiedEdge[] {
947
- return edges.filter(
948
- (edge) =>
949
- edge.kind === 'rollback' &&
950
- classifyEdgeAdjacency(edge, position) === 'node-skipping-rollback',
951
- );
952
- }
953
-
954
- // ---------------------------------------------------------------------------
955
- // Lane allocation: one rule for all topologies
956
- // ---------------------------------------------------------------------------
957
-
958
- interface DownwardGroup {
959
- readonly target: string;
960
- readonly edges: ClassifiedEdge[];
961
- }
962
-
963
- function layoutComponent(
964
- componentNodes: readonly string[],
965
- allEdges: readonly ClassifiedEdge[],
966
- ): {
967
- rows: MigrationGraphGridRow[];
968
- nodeColumn: Map<string, number>;
969
- edgeColumn: Map<string, number>;
970
- } {
971
- const componentSet = new Set(componentNodes);
972
- const edges = allEdges.filter((e) => componentSet.has(e.from) && componentSet.has(e.to));
973
-
974
- const forwardChildren = new Map<string, ClassifiedEdge[]>();
975
- const producersByTo = new Map<string, ClassifiedEdge[]>();
976
- const rollbacksByFrom = new Map<string, ClassifiedEdge[]>();
977
- const selfByFrom = new Map<string, ClassifiedEdge[]>();
978
- for (const edge of edges) {
979
- if (edge.kind === 'self' || edge.from === edge.to) {
980
- const bucket = selfByFrom.get(edge.from);
981
- if (bucket) bucket.push(edge);
982
- else selfByFrom.set(edge.from, [edge]);
983
- continue;
984
- }
985
- if (edge.kind === 'forward') {
986
- const children = forwardChildren.get(edge.from);
987
- if (children) children.push(edge);
988
- else forwardChildren.set(edge.from, [edge]);
989
- const producers = producersByTo.get(edge.to);
990
- if (producers) producers.push(edge);
991
- else producersByTo.set(edge.to, [edge]);
992
- continue;
993
- }
994
- // rollback
995
- const bucket = rollbacksByFrom.get(edge.from);
996
- if (bucket) bucket.push(edge);
997
- else rollbacksByFrom.set(edge.from, [edge]);
998
- }
999
-
1000
- const forwardInDegree = buildForwardInDegree(edges);
1001
- const forwardOutDegree = buildForwardOutDegree(edges);
1002
- const distinctSourceCountByTo = buildDistinctSourceCountByTo(edges);
1003
-
1004
- const order = computeVerticalOrder(componentNodes, forwardChildren, forwardInDegree);
1005
- const position = new Map<string, number>();
1006
- for (let index = 0; index < order.length; index++) {
1007
- const node = order[index];
1008
- if (node !== undefined) position.set(node, index);
1009
- }
1010
-
1011
- const lanes: (string | null)[] = [];
1012
- const rows: MigrationGraphGridRow[] = [];
1013
- const nodeColumn = new Map<string, number>();
1014
- const edgeColumn = new Map<string, number>();
1015
- const producerLaneByHash = new Map<string, number>();
1016
- // Tracks which edge's migrationHash last occupied each lane, so pass-through
1017
- // cells on node/edge/connector rows can carry per-cell identity.
1018
- const laneEdgeByIndex = new Map<number, string>();
1019
- let gridWidth = 1;
1020
-
1021
- function ensureGridWidth(minWidth: number): void {
1022
- if (minWidth > gridWidth) gridWidth = minWidth;
1023
- }
1024
-
1025
- function setLane(index: number, want: string | null): void {
1026
- while (lanes.length <= index) lanes.push(null);
1027
- lanes[index] = want;
1028
- if (want !== null) ensureGridWidth(index + 1);
1029
- }
1030
-
1031
- function activeLaneIndices(): number[] {
1032
- const indices: number[] = [];
1033
- for (let index = 0; index < lanes.length; index++) {
1034
- if (lanes[index] !== null) indices.push(index);
1035
- }
1036
- return indices;
1037
- }
1038
-
1039
- function passThroughExcept(lane: number): number[] {
1040
- return activeLaneIndices().filter((index) => index !== lane);
1041
- }
1042
-
1043
- function leftmostFreeLane(): number {
1044
- for (let index = 0; index < lanes.length; index++) {
1045
- if (lanes[index] === null) return index;
1046
- }
1047
- return lanes.length;
1048
- }
1049
-
1050
- function lanesWanting(contract: string): number[] {
1051
- const indices: number[] = [];
1052
- for (let index = 0; index < lanes.length; index++) {
1053
- if (lanes[index] === contract) indices.push(index);
1054
- }
1055
- return indices;
1056
- }
1057
-
1058
- function emitMergeConnector(contractHash: string, laneIndices: readonly number[]): number {
1059
- const startLane = Math.min(...laneIndices);
1060
- const endLane = Math.max(...laneIndices);
1061
- ensureGridWidth(endLane + 1);
1062
- const activeLanes = new Set(activeLaneIndices());
1063
- const fanTargetLanes = new Set(laneIndices);
1064
- rows.push({
1065
- kind: 'merge-connector',
1066
- contractHash,
1067
- startLane,
1068
- endLane,
1069
- branchCount: laneIndices.length,
1070
- cells: buildMergeConnectorCells(
1071
- startLane,
1072
- endLane,
1073
- fanTargetLanes,
1074
- activeLanes,
1075
- gridWidth,
1076
- laneEdgeByIndex,
1077
- ),
1078
- });
1079
- for (const index of laneIndices) {
1080
- if (index !== startLane) setLane(index, null);
1081
- }
1082
- return startLane;
1083
- }
1084
-
1085
- function emitBranchConnector(
1086
- contractHash: string,
1087
- startLane: number,
1088
- endLane: number,
1089
- branchCount: number,
1090
- fanTargetLanes: readonly number[],
1091
- /** Hash of the first/representative edge for each fan lane (keyed by lane index). */
1092
- fanEdgeHashByLane: ReadonlyMap<number, string>,
1093
- ): void {
1094
- ensureGridWidth(endLane + 1);
1095
- const activeLanes = new Set(activeLaneIndices());
1096
- // Prefer the fanEdgeHashByLane entry for startLane (the downward fanout edge
1097
- // leaving this node) over laneEdgeByIndex, which may still hold the hash of
1098
- // the last skip-rollback emitted into that lane before the branch-connector.
1099
- const trunkEdgeHash = fanEdgeHashByLane.get(startLane) ?? laneEdgeByIndex.get(startLane);
1100
- rows.push({
1101
- kind: 'branch-connector',
1102
- contractHash,
1103
- startLane,
1104
- endLane,
1105
- branchCount,
1106
- cells: buildBranchConnectorCells(
1107
- startLane,
1108
- endLane,
1109
- new Set(fanTargetLanes),
1110
- activeLanes,
1111
- gridWidth,
1112
- trunkEdgeHash,
1113
- fanEdgeHashByLane,
1114
- laneEdgeByIndex,
1115
- ),
1116
- });
1117
- }
1118
-
1119
- function emitEdgeRow(edge: ClassifiedEdge, lane: number, convergenceProducer: boolean): void {
1120
- const passThrough = passThroughExcept(lane);
1121
- const adjacency = classifyEdgeAdjacency(edge, position);
1122
- ensureGridWidth(Math.max(lane, ...passThrough, 0) + 1);
1123
- const row: MigrationGraphGridRow = {
1124
- kind: 'edge',
1125
- edge,
1126
- laneIndex: lane,
1127
- passThroughLanes: passThrough,
1128
- cells: buildEdgeCells(edge, lane, passThrough, adjacency, gridWidth, laneEdgeByIndex),
1129
- };
1130
- rows.push(convergenceProducer ? { ...row, convergenceProducer: true } : row);
1131
- edgeColumn.set(edge.migrationHash, lane);
1132
- if (convergenceProducer) producerLaneByHash.set(edge.migrationHash, lane);
1133
- // Record this edge as the current occupant of its lane so subsequent rows
1134
- // can tag their pass-through cells with the correct owner.
1135
- laneEdgeByIndex.set(lane, edge.migrationHash);
1136
- }
1137
-
1138
- function emitNodeRow(contractHash: string, column: number): void {
1139
- ensureGridWidth(column + 1);
1140
- const passThrough = activeLaneIndices().filter((index) => index !== column);
1141
- rows.push({
1142
- kind: 'node',
1143
- contractHash,
1144
- cells: buildNodeCells(contractHash, column, passThrough, gridWidth, laneEdgeByIndex),
1145
- });
1146
- nodeColumn.set(contractHash, column);
1147
- }
1148
-
1149
- function producerGroups(node: string): DownwardGroup[] {
1150
- const byTarget = new Map<string, DownwardGroup>();
1151
- for (const producer of producersByTo.get(node) ?? []) {
1152
- const group = byTarget.get(producer.from);
1153
- if (group) group.edges.push(producer);
1154
- else byTarget.set(producer.from, { target: producer.from, edges: [producer] });
1155
- }
1156
- const groups = [...byTarget.values()];
1157
- // Lanes are ordered by where their target node lands vertically (soonest →
1158
- // leftmost), which keeps lanes from crossing.
1159
- groups.sort((a, b) => (position.get(a.target) ?? 0) - (position.get(b.target) ?? 0));
1160
- for (const group of groups) {
1161
- group.edges.sort((a, b) => b.dirName.localeCompare(a.dirName));
1162
- }
1163
- return groups;
1164
- }
1165
-
1166
- function processNode(node: string): void {
1167
- const wanting = lanesWanting(node);
1168
- let column: number;
1169
- if (wanting.length >= 2) {
1170
- column = emitMergeConnector(node, wanting);
1171
- } else if (wanting.length === 1) {
1172
- column = wanting[0] ?? 0;
1173
- } else {
1174
- column = leftmostFreeLane();
1175
- }
1176
-
1177
- // Self-edges sit immediately above their node, in its column.
1178
- const selfEdges = [...(selfByFrom.get(node) ?? [])].sort((a, b) =>
1179
- b.dirName.localeCompare(a.dirName),
1180
- );
1181
- for (const selfEdge of selfEdges) emitEdgeRow(selfEdge, column, false);
1182
-
1183
- emitNodeRow(node, column);
1184
-
1185
- const rollbacks = [...(rollbacksByFrom.get(node) ?? [])].sort((a, b) =>
1186
- b.dirName.localeCompare(a.dirName),
1187
- );
1188
- const skipRollbacks: ClassifiedEdge[] = [];
1189
- const adjacentRollbacks: ClassifiedEdge[] = [];
1190
- for (const rollback of rollbacks) {
1191
- if (classifyEdgeAdjacency(rollback, position) === 'node-skipping-rollback') {
1192
- skipRollbacks.push(rollback);
1193
- } else {
1194
- adjacentRollbacks.push(rollback);
1195
- }
1196
- }
1197
- for (const rollback of skipRollbacks) {
1198
- emitEdgeRow(rollback, column, false);
1199
- }
1200
-
1201
- const groups = producerGroups(node);
1202
- const isConvergence = (distinctSourceCountByTo.get(node) ?? 0) >= 2;
1203
- const laneForGroup: number[] = [];
1204
- for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
1205
- const group = groups[groupIndex];
1206
- if (group === undefined) continue;
1207
- const lane = groupIndex === 0 ? column : leftmostFreeLane();
1208
- laneForGroup[groupIndex] = lane;
1209
- setLane(lane, group.target);
1210
- }
1211
-
1212
- if (groups.length >= 2) {
1213
- const endLane = Math.max(...laneForGroup);
1214
- // Map each fan lane to the representative edge (first in the group) so
1215
- // the branch-connector cells can carry per-cell identity.
1216
- const fanEdgeHashByLane = new Map<number, string>();
1217
- for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
1218
- const group = groups[groupIndex];
1219
- const lane = laneForGroup[groupIndex];
1220
- if (group === undefined || lane === undefined) continue;
1221
- const firstEdge = group.edges[0];
1222
- if (firstEdge !== undefined) fanEdgeHashByLane.set(lane, firstEdge.migrationHash);
1223
- }
1224
- emitBranchConnector(node, column, endLane, groups.length, laneForGroup, fanEdgeHashByLane);
1225
-
1226
- // Pre-populate laneEdgeByIndex for every fan lane (including lane 0 / trunk) with the
1227
- // representative edge hash BEFORE emitting any edge rows. Without this, when groupIndex=0's
1228
- // edge rows are emitted first, the pass-through cells for groupIndex≥1 lanes carry no hash
1229
- // (laneEdgeByIndex has no entry yet for those lanes) and fall through to whatever annotation
1230
- // the row's default override is — often the wrong colour.
1231
- for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
1232
- const fanLane = laneForGroup[groupIndex];
1233
- if (fanLane === undefined) continue;
1234
- const fanHash = fanEdgeHashByLane.get(fanLane);
1235
- if (fanHash !== undefined) {
1236
- laneEdgeByIndex.set(fanLane, fanHash);
1237
- }
1238
- }
1239
- }
1240
-
1241
- for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
1242
- const group = groups[groupIndex];
1243
- const lane = laneForGroup[groupIndex];
1244
- if (group === undefined || lane === undefined) continue;
1245
- for (const edge of group.edges) {
1246
- emitEdgeRow(edge, lane, isConvergence);
1247
- }
1248
- }
1249
-
1250
- for (const rollback of adjacentRollbacks) {
1251
- emitEdgeRow(rollback, column, false);
1252
- }
1253
-
1254
- if (groups.length === 0) {
1255
- // A root / leaf: its column lane terminates here.
1256
- setLane(column, null);
1257
- }
1258
- }
1259
-
1260
- for (const node of order) processNode(node);
1261
-
1262
- const refined = refineAdjacency(
1263
- rows,
1264
- nodeColumn,
1265
- position,
1266
- forwardInDegree,
1267
- forwardOutDegree,
1268
- edges,
1269
- producerLaneByHash,
1270
- );
1271
- const skipRollbacks = collectNodeSkippingRollbacks(edges, position);
1272
- const routed = applySkipRollbackRouting(refined, skipRollbacks, position, nodeColumn, edgeColumn);
1273
-
1274
- return {
1275
- rows: routed,
1276
- nodeColumn,
1277
- edgeColumn,
1278
- };
1279
- }
1280
-
1281
- export function buildMigrationGraphLayout(
1282
- rowModel: MigrationGraphRowModel,
1283
- ): MigrationGraphGridModel {
1284
- if (rowModel.nodes.length === 0) {
1285
- return { rows: [], nodeColumn: new Map(), edgeColumn: new Map() };
1286
- }
1287
-
1288
- const components = splitComponents(rowModel.nodes);
1289
- const allRows: MigrationGraphGridRow[] = [];
1290
- const nodeColumn = new Map<string, number>();
1291
- const edgeColumn = new Map<string, number>();
1292
-
1293
- for (let componentIndex = 0; componentIndex < components.length; componentIndex++) {
1294
- if (componentIndex > 0) {
1295
- allRows.push({ kind: 'component-separator', cells: [] });
1296
- }
1297
-
1298
- const component = components[componentIndex];
1299
- if (component === undefined || component.length === 0) continue;
1300
-
1301
- const result = layoutComponent(component, rowModel.edges);
1302
- allRows.push(...result.rows);
1303
- for (const [hash, column] of result.nodeColumn) nodeColumn.set(hash, column);
1304
- for (const [hash, column] of result.edgeColumn) edgeColumn.set(hash, column);
1305
- }
1306
-
1307
- return { rows: allRows, nodeColumn, edgeColumn };
1308
- }