@prisma-next/migration-tools 0.5.0-dev.20 → 0.5.0-dev.22
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.
- package/dist/exports/migration-graph.d.mts +21 -1
- package/dist/exports/migration-graph.d.mts.map +1 -1
- package/dist/exports/migration-graph.mjs +124 -46
- package/dist/exports/migration-graph.mjs.map +1 -1
- package/package.json +6 -6
- package/src/exports/migration-graph.ts +1 -0
- package/src/graph-ops.ts +57 -30
- package/src/migration-graph.ts +118 -19
|
@@ -12,6 +12,26 @@ declare function reconstructGraph(packages: readonly MigrationPackage[]): Migrat
|
|
|
12
12
|
* label priority → createdAt → to → migrationHash.
|
|
13
13
|
*/
|
|
14
14
|
declare function findPath(graph: MigrationGraph, fromHash: string, toHash: string): readonly MigrationEdge[] | null;
|
|
15
|
+
/**
|
|
16
|
+
* Find the shortest path from `fromHash` to `toHash` whose edges collectively
|
|
17
|
+
* cover every invariant in `required`. Returns `null` when no such path exists
|
|
18
|
+
* (either `fromHash`→`toHash` is structurally unreachable, or every reachable
|
|
19
|
+
* path leaves at least one required invariant uncovered). When `required` is
|
|
20
|
+
* empty, delegates to `findPath` so the result is byte-identical for that case.
|
|
21
|
+
*
|
|
22
|
+
* Algorithm: BFS over `(node, coveredSubset)` states with state-level dedup.
|
|
23
|
+
* The covered subset is a `Set<string>` of invariant ids; the state's dedup
|
|
24
|
+
* key is `${node}\0${[...covered].sort().join('\0')}`. State keys distinguish
|
|
25
|
+
* distinct `(node, covered)` tuples regardless of node-name length because
|
|
26
|
+
* `\0` cannot appear in any invariant id (validation rejects whitespace and
|
|
27
|
+
* control chars at authoring time).
|
|
28
|
+
*
|
|
29
|
+
* Neighbour ordering when `required ≠ ∅`: edges covering ≥1 still-needed
|
|
30
|
+
* invariant come first, with `labelPriority → createdAt → to → migrationHash`
|
|
31
|
+
* as the secondary key. The heuristic steers BFS toward the satisfying path;
|
|
32
|
+
* correctness (shortest, deterministic) does not depend on it.
|
|
33
|
+
*/
|
|
34
|
+
declare function findPathWithInvariants(graph: MigrationGraph, fromHash: string, toHash: string, required: ReadonlySet<string>): readonly MigrationEdge[] | null;
|
|
15
35
|
interface PathDecision {
|
|
16
36
|
readonly selectedPath: readonly MigrationEdge[];
|
|
17
37
|
readonly fromHash: string;
|
|
@@ -48,5 +68,5 @@ declare function findLatestMigration(graph: MigrationGraph): MigrationEdge | nul
|
|
|
48
68
|
declare function detectCycles(graph: MigrationGraph): readonly string[][];
|
|
49
69
|
declare function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[];
|
|
50
70
|
//#endregion
|
|
51
|
-
export { type PathDecision, detectCycles, detectOrphans, findLatestMigration, findLeaf, findPath, findPathWithDecision, findReachableLeaves, reconstructGraph };
|
|
71
|
+
export { type PathDecision, detectCycles, detectOrphans, findLatestMigration, findLeaf, findPath, findPathWithDecision, findPathWithInvariants, findReachableLeaves, reconstructGraph };
|
|
52
72
|
//# sourceMappingURL=migration-graph.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migration-graph.d.mts","names":[],"sources":["../../src/migration-graph.ts"],"sourcesContent":[],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"migration-graph.d.mts","names":[],"sources":["../../src/migration-graph.ts"],"sourcesContent":[],"mappings":";;;;iBAsCgB,gBAAA,oBAAoC,qBAAqB;;AAAzE;AA6EA;AAgDA;;;;;AA0FiB,iBA1ID,QAAA,CA2IkB,KAAA,EA1IzB,cA0IsC,EAAA,QAAA,EAAA,MAAA,EAAA,MAAA,EAAA,MAAA,CAAA,EAAA,SAvInC,aAuImC,EAAA,GAAA,IAAA;AAY/C;AA8FA;AAkBA;AAwCA;AAQA;AA8DA;;;;;;;;;;;;;;iBArUgB,sBAAA,QACP,4DAGG,+BACA;UAqFK,YAAA;kCACiB;;;;;;;;;;;iBAYlB,oBAAA,QACP,qEAIN;;;;;iBAyFa,mBAAA,QAA2B;;;;;;;;;iBAkB3B,QAAA,QAAgB;;;;;;iBAwChB,mBAAA,QAA2B,iBAAiB;iBAQ5C,YAAA,QAAoB;iBA8DpB,aAAA,QAAqB,0BAA0B"}
|
|
@@ -36,61 +36,65 @@ var Queue = class {
|
|
|
36
36
|
|
|
37
37
|
//#endregion
|
|
38
38
|
//#region src/graph-ops.ts
|
|
39
|
-
|
|
40
|
-
* Generic breadth-first traversal.
|
|
41
|
-
*
|
|
42
|
-
* Direction (forward/reverse) is expressed by the caller's `neighbours`
|
|
43
|
-
* closure: return `{ next, edge }` pairs where `next` is the node to visit
|
|
44
|
-
* next and `edge` is the edge that connects them. Callers that don't need
|
|
45
|
-
* path reconstruction can ignore the `parent`/`incomingEdge` fields of each
|
|
46
|
-
* yielded step.
|
|
47
|
-
*
|
|
48
|
-
* Stops are intrinsic — callers `break` out of the `for..of` loop when
|
|
49
|
-
* they've found what they're looking for.
|
|
50
|
-
*
|
|
51
|
-
* `ordering`, if provided, controls the order in which neighbours of each
|
|
52
|
-
* node are enqueued. Only matters for path-finding: a deterministic ordering
|
|
53
|
-
* makes BFS return a deterministic shortest path when multiple exist.
|
|
54
|
-
*/
|
|
55
|
-
function* bfs(starts, neighbours, ordering) {
|
|
39
|
+
function* bfs(starts, neighbours, key = (state) => state) {
|
|
56
40
|
const visited = /* @__PURE__ */ new Set();
|
|
57
41
|
const parentMap = /* @__PURE__ */ new Map();
|
|
58
42
|
const queue = new Queue();
|
|
59
|
-
for (const start of starts)
|
|
60
|
-
|
|
61
|
-
|
|
43
|
+
for (const start of starts) {
|
|
44
|
+
const k = key(start);
|
|
45
|
+
if (!visited.has(k)) {
|
|
46
|
+
visited.add(k);
|
|
47
|
+
queue.push({
|
|
48
|
+
state: start,
|
|
49
|
+
key: k
|
|
50
|
+
});
|
|
51
|
+
}
|
|
62
52
|
}
|
|
63
53
|
while (!queue.isEmpty) {
|
|
64
|
-
const current = queue.shift();
|
|
65
|
-
const parentInfo = parentMap.get(
|
|
54
|
+
const { state: current, key: curKey } = queue.shift();
|
|
55
|
+
const parentInfo = parentMap.get(curKey);
|
|
66
56
|
yield {
|
|
67
|
-
|
|
57
|
+
state: current,
|
|
68
58
|
parent: parentInfo?.parent ?? null,
|
|
69
59
|
incomingEdge: parentInfo?.edge ?? null
|
|
70
60
|
};
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
61
|
+
for (const { next, edge } of neighbours(current)) {
|
|
62
|
+
const k = key(next);
|
|
63
|
+
if (!visited.has(k)) {
|
|
64
|
+
visited.add(k);
|
|
65
|
+
parentMap.set(k, {
|
|
66
|
+
parent: current,
|
|
67
|
+
edge
|
|
68
|
+
});
|
|
69
|
+
queue.push({
|
|
70
|
+
state: next,
|
|
71
|
+
key: k
|
|
72
|
+
});
|
|
73
|
+
}
|
|
80
74
|
}
|
|
81
75
|
}
|
|
82
76
|
}
|
|
83
77
|
|
|
84
78
|
//#endregion
|
|
85
79
|
//#region src/migration-graph.ts
|
|
86
|
-
/** Forward-edge neighbours
|
|
80
|
+
/** Forward-edge neighbours: edge `e` from `n` visits `e.to` next. */
|
|
87
81
|
function forwardNeighbours(graph, node) {
|
|
88
82
|
return (graph.forwardChain.get(node) ?? []).map((edge) => ({
|
|
89
83
|
next: edge.to,
|
|
90
84
|
edge
|
|
91
85
|
}));
|
|
92
86
|
}
|
|
93
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Forward-edge neighbours, sorted by the deterministic tie-break.
|
|
89
|
+
* Used by path-finding so the resulting shortest path is stable across runs.
|
|
90
|
+
*/
|
|
91
|
+
function sortedForwardNeighbours(graph, node) {
|
|
92
|
+
return [...graph.forwardChain.get(node) ?? []].sort(compareTieBreak).map((edge) => ({
|
|
93
|
+
next: edge.to,
|
|
94
|
+
edge
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
/** Reverse-edge neighbours: edge `e` from `n` visits `e.from` next. */
|
|
94
98
|
function reverseNeighbours(graph, node) {
|
|
95
99
|
return (graph.reverseChain.get(node) ?? []).map((edge) => ({
|
|
96
100
|
next: edge.from,
|
|
@@ -158,10 +162,6 @@ function compareTieBreak(a, b) {
|
|
|
158
162
|
function sortedNeighbors(edges) {
|
|
159
163
|
return [...edges].sort(compareTieBreak);
|
|
160
164
|
}
|
|
161
|
-
/** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
|
|
162
|
-
function bfsOrdering(items) {
|
|
163
|
-
return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
|
|
164
|
-
}
|
|
165
165
|
/**
|
|
166
166
|
* Find the shortest path from `fromHash` to `toHash` using BFS over the
|
|
167
167
|
* contract-hash graph. Returns the ordered list of edges, or null if no path
|
|
@@ -173,12 +173,12 @@ function bfsOrdering(items) {
|
|
|
173
173
|
function findPath(graph, fromHash, toHash) {
|
|
174
174
|
if (fromHash === toHash) return [];
|
|
175
175
|
const parents = /* @__PURE__ */ new Map();
|
|
176
|
-
for (const step of bfs([fromHash], (n) =>
|
|
177
|
-
if (step.parent !== null && step.incomingEdge !== null) parents.set(step.
|
|
176
|
+
for (const step of bfs([fromHash], (n) => sortedForwardNeighbours(graph, n))) {
|
|
177
|
+
if (step.parent !== null && step.incomingEdge !== null) parents.set(step.state, {
|
|
178
178
|
parent: step.parent,
|
|
179
179
|
edge: step.incomingEdge
|
|
180
180
|
});
|
|
181
|
-
if (step.
|
|
181
|
+
if (step.state === toHash) {
|
|
182
182
|
const path = [];
|
|
183
183
|
let cur = toHash;
|
|
184
184
|
let p = parents.get(cur);
|
|
@@ -194,12 +194,90 @@ function findPath(graph, fromHash, toHash) {
|
|
|
194
194
|
return null;
|
|
195
195
|
}
|
|
196
196
|
/**
|
|
197
|
+
* Find the shortest path from `fromHash` to `toHash` whose edges collectively
|
|
198
|
+
* cover every invariant in `required`. Returns `null` when no such path exists
|
|
199
|
+
* (either `fromHash`→`toHash` is structurally unreachable, or every reachable
|
|
200
|
+
* path leaves at least one required invariant uncovered). When `required` is
|
|
201
|
+
* empty, delegates to `findPath` so the result is byte-identical for that case.
|
|
202
|
+
*
|
|
203
|
+
* Algorithm: BFS over `(node, coveredSubset)` states with state-level dedup.
|
|
204
|
+
* The covered subset is a `Set<string>` of invariant ids; the state's dedup
|
|
205
|
+
* key is `${node}\0${[...covered].sort().join('\0')}`. State keys distinguish
|
|
206
|
+
* distinct `(node, covered)` tuples regardless of node-name length because
|
|
207
|
+
* `\0` cannot appear in any invariant id (validation rejects whitespace and
|
|
208
|
+
* control chars at authoring time).
|
|
209
|
+
*
|
|
210
|
+
* Neighbour ordering when `required ≠ ∅`: edges covering ≥1 still-needed
|
|
211
|
+
* invariant come first, with `labelPriority → createdAt → to → migrationHash`
|
|
212
|
+
* as the secondary key. The heuristic steers BFS toward the satisfying path;
|
|
213
|
+
* correctness (shortest, deterministic) does not depend on it.
|
|
214
|
+
*/
|
|
215
|
+
function findPathWithInvariants(graph, fromHash, toHash, required) {
|
|
216
|
+
if (required.size === 0) return findPath(graph, fromHash, toHash);
|
|
217
|
+
if (fromHash === toHash) return null;
|
|
218
|
+
const stateKey = (s) => {
|
|
219
|
+
if (s.covered.size === 0) return `${s.node}\0`;
|
|
220
|
+
return `${s.node}\0${[...s.covered].sort().join("\0")}`;
|
|
221
|
+
};
|
|
222
|
+
const neighbours = (s) => {
|
|
223
|
+
const outgoing = graph.forwardChain.get(s.node) ?? [];
|
|
224
|
+
if (outgoing.length === 0) return [];
|
|
225
|
+
return [...outgoing].map((edge) => {
|
|
226
|
+
let useful = false;
|
|
227
|
+
let next = null;
|
|
228
|
+
for (const inv of edge.invariants) if (required.has(inv) && !s.covered.has(inv)) {
|
|
229
|
+
if (next === null) next = new Set(s.covered);
|
|
230
|
+
next.add(inv);
|
|
231
|
+
useful = true;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
edge,
|
|
235
|
+
useful,
|
|
236
|
+
nextCovered: next ?? s.covered
|
|
237
|
+
};
|
|
238
|
+
}).sort((a, b) => {
|
|
239
|
+
if (a.useful !== b.useful) return a.useful ? -1 : 1;
|
|
240
|
+
return compareTieBreak(a.edge, b.edge);
|
|
241
|
+
}).map(({ edge, nextCovered }) => ({
|
|
242
|
+
next: {
|
|
243
|
+
node: edge.to,
|
|
244
|
+
covered: nextCovered
|
|
245
|
+
},
|
|
246
|
+
edge
|
|
247
|
+
}));
|
|
248
|
+
};
|
|
249
|
+
const parents = /* @__PURE__ */ new Map();
|
|
250
|
+
for (const step of bfs([{
|
|
251
|
+
node: fromHash,
|
|
252
|
+
covered: /* @__PURE__ */ new Set()
|
|
253
|
+
}], neighbours, stateKey)) {
|
|
254
|
+
const curKey = stateKey(step.state);
|
|
255
|
+
if (step.parent !== null && step.incomingEdge !== null) parents.set(curKey, {
|
|
256
|
+
parentKey: stateKey(step.parent),
|
|
257
|
+
edge: step.incomingEdge
|
|
258
|
+
});
|
|
259
|
+
if (step.state.node === toHash && step.state.covered.size === required.size) {
|
|
260
|
+
const path = [];
|
|
261
|
+
let cur = curKey;
|
|
262
|
+
while (cur !== void 0) {
|
|
263
|
+
const p = parents.get(cur);
|
|
264
|
+
if (!p) break;
|
|
265
|
+
path.push(p.edge);
|
|
266
|
+
cur = p.parentKey;
|
|
267
|
+
}
|
|
268
|
+
path.reverse();
|
|
269
|
+
return path;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
197
275
|
* Reverse-BFS from `toHash` over `reverseChain` to collect every node from
|
|
198
276
|
* which `toHash` is reachable (inclusive of `toHash` itself).
|
|
199
277
|
*/
|
|
200
278
|
function collectNodesReachingTarget(graph, toHash) {
|
|
201
279
|
const reached = /* @__PURE__ */ new Set();
|
|
202
|
-
for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) reached.add(step.
|
|
280
|
+
for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) reached.add(step.state);
|
|
203
281
|
return reached;
|
|
204
282
|
}
|
|
205
283
|
/**
|
|
@@ -249,7 +327,7 @@ function findPathWithDecision(graph, fromHash, toHash, refName) {
|
|
|
249
327
|
function findDivergencePoint(graph, fromHash, leaves) {
|
|
250
328
|
const ancestorSets = leaves.map((leaf) => {
|
|
251
329
|
const ancestors = /* @__PURE__ */ new Set();
|
|
252
|
-
for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) ancestors.add(step.
|
|
330
|
+
for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) ancestors.add(step.state);
|
|
253
331
|
return ancestors;
|
|
254
332
|
});
|
|
255
333
|
const commonAncestors = [...ancestorSets[0] ?? []].filter((node) => ancestorSets.every((s) => s.has(node)));
|
|
@@ -271,7 +349,7 @@ function findDivergencePoint(graph, fromHash, leaves) {
|
|
|
271
349
|
*/
|
|
272
350
|
function findReachableLeaves(graph, fromHash) {
|
|
273
351
|
const leaves = [];
|
|
274
|
-
for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) if (!graph.forwardChain.get(step.
|
|
352
|
+
for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) if (!graph.forwardChain.get(step.state)?.length) leaves.push(step.state);
|
|
275
353
|
return leaves;
|
|
276
354
|
}
|
|
277
355
|
/**
|
|
@@ -376,12 +454,12 @@ function detectOrphans(graph) {
|
|
|
376
454
|
for (const edges of graph.forwardChain.values()) for (const edge of edges) allTargets.add(edge.to);
|
|
377
455
|
for (const node of graph.nodes) if (!allTargets.has(node)) startNodes.push(node);
|
|
378
456
|
}
|
|
379
|
-
for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) reachable.add(step.
|
|
457
|
+
for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) reachable.add(step.state);
|
|
380
458
|
const orphans = [];
|
|
381
459
|
for (const [from, migrations] of graph.forwardChain) if (!reachable.has(from)) orphans.push(...migrations);
|
|
382
460
|
return orphans;
|
|
383
461
|
}
|
|
384
462
|
|
|
385
463
|
//#endregion
|
|
386
|
-
export { detectCycles, detectOrphans, findLatestMigration, findLeaf, findPath, findPathWithDecision, findReachableLeaves, reconstructGraph };
|
|
464
|
+
export { detectCycles, detectOrphans, findLatestMigration, findLeaf, findPath, findPathWithDecision, findPathWithInvariants, findReachableLeaves, reconstructGraph };
|
|
387
465
|
//# sourceMappingURL=migration-graph.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migration-graph.mjs","names":["migration: MigrationEdge","LABEL_PRIORITY: Record<string, number>","path: MigrationEdge[]","tieBreakReasons: string[]","leaves: string[]","cycles: string[][]","stack: Frame[]","cycle: string[]","startNodes: string[]","orphans: MigrationEdge[]"],"sources":["../../src/queue.ts","../../src/graph-ops.ts","../../src/migration-graph.ts"],"sourcesContent":["/**\n * FIFO queue with amortised O(1) push and shift.\n *\n * Uses a head-index cursor over a backing array rather than\n * `Array.prototype.shift()`, which is O(n) on V8. Intended for BFS-shaped\n * traversals where the queue is drained in a single pass — it does not\n * reclaim memory for already-shifted items, so it is not suitable for\n * long-lived queues with many push/shift cycles.\n */\nexport class Queue<T> {\n private readonly items: T[];\n private head = 0;\n\n constructor(initial: Iterable<T> = []) {\n this.items = [...initial];\n }\n\n push(item: T): void {\n this.items.push(item);\n }\n\n /**\n * Remove and return the next item. Caller must check `isEmpty` first —\n * shifting an empty queue throws.\n */\n shift(): T {\n if (this.head >= this.items.length) {\n throw new Error('Queue.shift called on empty queue');\n }\n // biome-ignore lint/style/noNonNullAssertion: bounds-checked on the line above\n return this.items[this.head++]!;\n }\n\n get isEmpty(): boolean {\n return this.head >= this.items.length;\n }\n}\n","import { Queue } from './queue';\n\n/**\n * One step of a BFS traversal.\n *\n * `parent` and `incomingEdge` are `null` for start nodes — they were not\n * reached via any edge. For every other node they record the node and edge\n * by which this node was first reached.\n */\nexport interface BfsStep<E> {\n readonly node: string;\n readonly parent: string | null;\n readonly incomingEdge: E | null;\n}\n\n/**\n * Generic breadth-first traversal.\n *\n * Direction (forward/reverse) is expressed by the caller's `neighbours`\n * closure: return `{ next, edge }` pairs where `next` is the node to visit\n * next and `edge` is the edge that connects them. Callers that don't need\n * path reconstruction can ignore the `parent`/`incomingEdge` fields of each\n * yielded step.\n *\n * Stops are intrinsic — callers `break` out of the `for..of` loop when\n * they've found what they're looking for.\n *\n * `ordering`, if provided, controls the order in which neighbours of each\n * node are enqueued. Only matters for path-finding: a deterministic ordering\n * makes BFS return a deterministic shortest path when multiple exist.\n */\nexport function* bfs<E>(\n starts: Iterable<string>,\n neighbours: (node: string) => Iterable<{ next: string; edge: E }>,\n ordering?: (items: readonly { next: string; edge: E }[]) => readonly { next: string; edge: E }[],\n): Generator<BfsStep<E>> {\n const visited = new Set<string>();\n const parentMap = new Map<string, { parent: string; edge: E }>();\n const queue = new Queue<string>();\n for (const start of starts) {\n if (!visited.has(start)) {\n visited.add(start);\n queue.push(start);\n }\n }\n while (!queue.isEmpty) {\n const current = queue.shift();\n const parentInfo = parentMap.get(current);\n yield {\n node: current,\n parent: parentInfo?.parent ?? null,\n incomingEdge: parentInfo?.edge ?? null,\n };\n\n const items = neighbours(current);\n const toVisit = ordering ? ordering([...items]) : items;\n for (const { next, edge } of toVisit) {\n if (!visited.has(next)) {\n visited.add(next);\n parentMap.set(next, { parent: current, edge });\n queue.push(next);\n }\n }\n }\n}\n","import { ifDefined } from '@prisma-next/utils/defined';\nimport { EMPTY_CONTRACT_HASH } from './constants';\nimport {\n errorAmbiguousTarget,\n errorDuplicateMigrationHash,\n errorNoInitialMigration,\n errorNoTarget,\n errorSameSourceAndTarget,\n} from './errors';\nimport type { MigrationEdge, MigrationGraph } from './graph';\nimport { bfs } from './graph-ops';\nimport type { MigrationPackage } from './package';\n\n/** Forward-edge neighbours for BFS: edge `e` from `n` visits `e.to` next. */\nfunction forwardNeighbours(graph: MigrationGraph, node: string) {\n return (graph.forwardChain.get(node) ?? []).map((edge) => ({ next: edge.to, edge }));\n}\n\n/** Reverse-edge neighbours for BFS: edge `e` from `n` visits `e.from` next. */\nfunction reverseNeighbours(graph: MigrationGraph, node: string) {\n return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));\n}\n\nfunction appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: MigrationEdge): void {\n const bucket = map.get(key);\n if (bucket) bucket.push(entry);\n else map.set(key, [entry]);\n}\n\nexport function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {\n const nodes = new Set<string>();\n const forwardChain = new Map<string, MigrationEdge[]>();\n const reverseChain = new Map<string, MigrationEdge[]>();\n const migrationByHash = new Map<string, MigrationEdge>();\n\n for (const pkg of packages) {\n const { from, to } = pkg.metadata;\n\n if (from === to) {\n throw errorSameSourceAndTarget(pkg.dirPath, from);\n }\n\n nodes.add(from);\n nodes.add(to);\n\n const migration: MigrationEdge = {\n from,\n to,\n migrationHash: pkg.metadata.migrationHash,\n dirName: pkg.dirName,\n createdAt: pkg.metadata.createdAt,\n labels: pkg.metadata.labels,\n invariants: pkg.metadata.providedInvariants,\n };\n\n if (migrationByHash.has(migration.migrationHash)) {\n throw errorDuplicateMigrationHash(migration.migrationHash);\n }\n migrationByHash.set(migration.migrationHash, migration);\n\n appendEdge(forwardChain, from, migration);\n appendEdge(reverseChain, to, migration);\n }\n\n return { nodes, forwardChain, reverseChain, migrationByHash };\n}\n\n// ---------------------------------------------------------------------------\n// Deterministic tie-breaking for BFS neighbour order.\n// Used by `findPath` and `findPathWithDecision` only; not a general-purpose\n// utility. Ordering: label priority → createdAt → to → migrationHash.\n// ---------------------------------------------------------------------------\n\nconst LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };\n\nfunction labelPriority(labels: readonly string[]): number {\n let best = 3;\n for (const l of labels) {\n const p = LABEL_PRIORITY[l];\n if (p !== undefined && p < best) best = p;\n }\n return best;\n}\n\nfunction compareTieBreak(a: MigrationEdge, b: MigrationEdge): number {\n const lp = labelPriority(a.labels) - labelPriority(b.labels);\n if (lp !== 0) return lp;\n const ca = a.createdAt.localeCompare(b.createdAt);\n if (ca !== 0) return ca;\n const tc = a.to.localeCompare(b.to);\n if (tc !== 0) return tc;\n return a.migrationHash.localeCompare(b.migrationHash);\n}\n\nfunction sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdge[] {\n return [...edges].sort(compareTieBreak);\n}\n\n/** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */\nfunction bfsOrdering(\n items: readonly { next: string; edge: MigrationEdge }[],\n): readonly { next: string; edge: MigrationEdge }[] {\n return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));\n}\n\n/**\n * Find the shortest path from `fromHash` to `toHash` using BFS over the\n * contract-hash graph. Returns the ordered list of edges, or null if no path\n * exists. Returns an empty array when `fromHash === toHash` (no-op).\n *\n * Neighbor ordering is deterministic via the tie-break sort key:\n * label priority → createdAt → to → migrationHash.\n */\nexport function findPath(\n graph: MigrationGraph,\n fromHash: string,\n toHash: string,\n): readonly MigrationEdge[] | null {\n if (fromHash === toHash) return [];\n\n const parents = new Map<string, { parent: string; edge: MigrationEdge }>();\n for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n), bfsOrdering)) {\n if (step.parent !== null && step.incomingEdge !== null) {\n parents.set(step.node, { parent: step.parent, edge: step.incomingEdge });\n }\n if (step.node === toHash) {\n const path: MigrationEdge[] = [];\n let cur = toHash;\n let p = parents.get(cur);\n while (p) {\n path.push(p.edge);\n cur = p.parent;\n p = parents.get(cur);\n }\n path.reverse();\n return path;\n }\n }\n\n return null;\n}\n\n/**\n * Reverse-BFS from `toHash` over `reverseChain` to collect every node from\n * which `toHash` is reachable (inclusive of `toHash` itself).\n */\nfunction collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<string> {\n const reached = new Set<string>();\n for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) {\n reached.add(step.node);\n }\n return reached;\n}\n\nexport interface PathDecision {\n readonly selectedPath: readonly MigrationEdge[];\n readonly fromHash: string;\n readonly toHash: string;\n readonly alternativeCount: number;\n readonly tieBreakReasons: readonly string[];\n readonly refName?: string;\n}\n\n/**\n * Find the shortest path from `fromHash` to `toHash` and return structured\n * path-decision metadata for machine-readable output.\n */\nexport function findPathWithDecision(\n graph: MigrationGraph,\n fromHash: string,\n toHash: string,\n refName?: string,\n): PathDecision | null {\n if (fromHash === toHash) {\n return {\n selectedPath: [],\n fromHash,\n toHash,\n alternativeCount: 0,\n tieBreakReasons: [],\n ...ifDefined('refName', refName),\n };\n }\n\n const path = findPath(graph, fromHash, toHash);\n if (!path) return null;\n\n // Single reverse BFS marks every node from which `toHash` is reachable.\n // Replaces a per-edge `findPath(e.to, toHash)` call inside the loop below,\n // which made the whole function O(|path| · (V + E)) instead of O(V + E).\n const reachesTarget = collectNodesReachingTarget(graph, toHash);\n\n const tieBreakReasons: string[] = [];\n let alternativeCount = 0;\n\n for (const edge of path) {\n const outgoing = graph.forwardChain.get(edge.from);\n if (outgoing && outgoing.length > 1) {\n const reachable = outgoing.filter((e) => reachesTarget.has(e.to));\n if (reachable.length > 1) {\n alternativeCount += reachable.length - 1;\n const sorted = sortedNeighbors(reachable);\n if (sorted[0] && sorted[0].migrationHash === edge.migrationHash) {\n if (reachable.some((e) => e.migrationHash !== edge.migrationHash)) {\n tieBreakReasons.push(\n `at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,\n );\n }\n }\n }\n }\n }\n\n return {\n selectedPath: path,\n fromHash,\n toHash,\n alternativeCount,\n tieBreakReasons,\n ...ifDefined('refName', refName),\n };\n}\n\n/**\n * Walk ancestors of each branch tip back to find the last node\n * that appears on all paths. Returns `fromHash` if no shared ancestor is found.\n */\nfunction findDivergencePoint(\n graph: MigrationGraph,\n fromHash: string,\n leaves: readonly string[],\n): string {\n const ancestorSets = leaves.map((leaf) => {\n const ancestors = new Set<string>();\n for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) {\n ancestors.add(step.node);\n }\n return ancestors;\n });\n\n const commonAncestors = [...(ancestorSets[0] ?? [])].filter((node) =>\n ancestorSets.every((s) => s.has(node)),\n );\n\n let deepest = fromHash;\n let deepestDepth = -1;\n for (const ancestor of commonAncestors) {\n const path = findPath(graph, fromHash, ancestor);\n const depth = path ? path.length : 0;\n if (depth > deepestDepth) {\n deepestDepth = depth;\n deepest = ancestor;\n }\n }\n return deepest;\n}\n\n/**\n * Find all branch tips (nodes with no outgoing edges) reachable from\n * `fromHash` via forward edges.\n */\nexport function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {\n const leaves: string[] = [];\n for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) {\n if (!graph.forwardChain.get(step.node)?.length) {\n leaves.push(step.node);\n }\n }\n return leaves;\n}\n\n/**\n * Find the target contract hash of the migration graph reachable from\n * EMPTY_CONTRACT_HASH. Returns `null` for a graph that has no target\n * state (either empty, or containing only the root with no outgoing\n * edges). Throws NO_INITIAL_MIGRATION if the graph has nodes but none\n * originate from the empty hash, and AMBIGUOUS_TARGET if multiple\n * branch tips exist.\n */\nexport function findLeaf(graph: MigrationGraph): string | null {\n if (graph.nodes.size === 0) {\n return null;\n }\n\n if (!graph.nodes.has(EMPTY_CONTRACT_HASH)) {\n throw errorNoInitialMigration([...graph.nodes]);\n }\n\n const leaves = findReachableLeaves(graph, EMPTY_CONTRACT_HASH);\n\n if (leaves.length === 0) {\n const reachable = [...graph.nodes].filter((n) => n !== EMPTY_CONTRACT_HASH);\n if (reachable.length > 0) {\n throw errorNoTarget(reachable);\n }\n return null;\n }\n\n if (leaves.length > 1) {\n const divergencePoint = findDivergencePoint(graph, EMPTY_CONTRACT_HASH, leaves);\n const branches = leaves.map((tip) => {\n const path = findPath(graph, divergencePoint, tip);\n return {\n tip,\n edges: (path ?? []).map((e) => ({ dirName: e.dirName, from: e.from, to: e.to })),\n };\n });\n throw errorAmbiguousTarget(leaves, { divergencePoint, branches });\n }\n\n // biome-ignore lint/style/noNonNullAssertion: leaves.length is neither 0 nor >1 per the branches above, so exactly one leaf remains\n return leaves[0]!;\n}\n\n/**\n * Find the latest migration entry by traversing from EMPTY_CONTRACT_HASH\n * to the single target. Returns null for an empty graph.\n * Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.\n */\nexport function findLatestMigration(graph: MigrationGraph): MigrationEdge | null {\n const leafHash = findLeaf(graph);\n if (leafHash === null) return null;\n\n const path = findPath(graph, EMPTY_CONTRACT_HASH, leafHash);\n return path?.at(-1) ?? null;\n}\n\nexport function detectCycles(graph: MigrationGraph): readonly string[][] {\n const WHITE = 0;\n const GRAY = 1;\n const BLACK = 2;\n\n const color = new Map<string, number>();\n const parentMap = new Map<string, string | null>();\n const cycles: string[][] = [];\n\n for (const node of graph.nodes) {\n color.set(node, WHITE);\n }\n\n // Iterative three-color DFS. A frame is (node, outgoing edges, next-index).\n interface Frame {\n node: string;\n outgoing: readonly MigrationEdge[];\n index: number;\n }\n const stack: Frame[] = [];\n\n function pushFrame(u: string): void {\n color.set(u, GRAY);\n stack.push({ node: u, outgoing: graph.forwardChain.get(u) ?? [], index: 0 });\n }\n\n for (const root of graph.nodes) {\n if (color.get(root) !== WHITE) continue;\n parentMap.set(root, null);\n pushFrame(root);\n\n while (stack.length > 0) {\n // biome-ignore lint/style/noNonNullAssertion: stack.length > 0 should guarantee that this cannot be undefined\n const frame = stack[stack.length - 1]!;\n if (frame.index >= frame.outgoing.length) {\n color.set(frame.node, BLACK);\n stack.pop();\n continue;\n }\n // biome-ignore lint/style/noNonNullAssertion: the early-continue above guarantees frame.index < frame.outgoing.length here, so this is defined\n const edge = frame.outgoing[frame.index++]!;\n const v = edge.to;\n const vColor = color.get(v);\n if (vColor === GRAY) {\n const cycle: string[] = [v];\n let cur = frame.node;\n while (cur !== v) {\n cycle.push(cur);\n cur = parentMap.get(cur) ?? v;\n }\n cycle.reverse();\n cycles.push(cycle);\n } else if (vColor === WHITE) {\n parentMap.set(v, frame.node);\n pushFrame(v);\n }\n }\n }\n\n return cycles;\n}\n\nexport function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {\n if (graph.nodes.size === 0) return [];\n\n const reachable = new Set<string>();\n const startNodes: string[] = [];\n\n if (graph.forwardChain.has(EMPTY_CONTRACT_HASH)) {\n startNodes.push(EMPTY_CONTRACT_HASH);\n } else {\n const allTargets = new Set<string>();\n for (const edges of graph.forwardChain.values()) {\n for (const edge of edges) {\n allTargets.add(edge.to);\n }\n }\n for (const node of graph.nodes) {\n if (!allTargets.has(node)) {\n startNodes.push(node);\n }\n }\n }\n\n for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) {\n reachable.add(step.node);\n }\n\n const orphans: MigrationEdge[] = [];\n for (const [from, migrations] of graph.forwardChain) {\n if (!reachable.has(from)) {\n orphans.push(...migrations);\n }\n }\n\n return orphans;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"migration-graph.mjs","names":["migration: MigrationEdge","LABEL_PRIORITY: Record<string, number>","path: MigrationEdge[]","next: Set<string> | null","cur: string | undefined","tieBreakReasons: string[]","leaves: string[]","cycles: string[][]","stack: Frame[]","cycle: string[]","startNodes: string[]","orphans: MigrationEdge[]"],"sources":["../../src/queue.ts","../../src/graph-ops.ts","../../src/migration-graph.ts"],"sourcesContent":["/**\n * FIFO queue with amortised O(1) push and shift.\n *\n * Uses a head-index cursor over a backing array rather than\n * `Array.prototype.shift()`, which is O(n) on V8. Intended for BFS-shaped\n * traversals where the queue is drained in a single pass — it does not\n * reclaim memory for already-shifted items, so it is not suitable for\n * long-lived queues with many push/shift cycles.\n */\nexport class Queue<T> {\n private readonly items: T[];\n private head = 0;\n\n constructor(initial: Iterable<T> = []) {\n this.items = [...initial];\n }\n\n push(item: T): void {\n this.items.push(item);\n }\n\n /**\n * Remove and return the next item. Caller must check `isEmpty` first —\n * shifting an empty queue throws.\n */\n shift(): T {\n if (this.head >= this.items.length) {\n throw new Error('Queue.shift called on empty queue');\n }\n // biome-ignore lint/style/noNonNullAssertion: bounds-checked on the line above\n return this.items[this.head++]!;\n }\n\n get isEmpty(): boolean {\n return this.head >= this.items.length;\n }\n}\n","import { Queue } from './queue';\n\n/**\n * One step of a BFS traversal.\n *\n * `parent` and `incomingEdge` are `null` for start states — they were not\n * reached via any edge. For every other state they record the predecessor\n * state and the edge by which this state was first reached.\n *\n * `state` is the BFS state, most often a string (graph node identifier) but\n * can be a composite object. The string overload keeps the common case\n * ergonomic; the generic overload accepts a caller-supplied `key` function\n * that produces a stable equality key for dedup.\n */\nexport interface BfsStep<S, E> {\n readonly state: S;\n readonly parent: S | null;\n readonly incomingEdge: E | null;\n}\n\n/**\n * Generic breadth-first traversal.\n *\n * Direction (forward/reverse) is expressed by the caller's `neighbours`\n * closure: return `{ next, edge }` pairs where `next` is the state to visit\n * next and `edge` is the edge that connects them. Callers that don't need\n * path reconstruction can ignore the `parent`/`incomingEdge` fields of each\n * yielded step.\n *\n * Ordering — when the result needs to be deterministic (path-finding) the\n * caller is responsible for sorting inside `neighbours`; this generator\n * does not impose an ordering hook of its own. State-dependent orderings\n * have full access to the source state inside the closure.\n *\n * Stops are intrinsic — callers `break` out of the `for..of` loop when\n * they've found what they're looking for.\n */\nexport function bfs<E>(\n starts: Iterable<string>,\n neighbours: (state: string) => Iterable<{ next: string; edge: E }>,\n): Generator<BfsStep<string, E>>;\nexport function bfs<S, E>(\n starts: Iterable<S>,\n neighbours: (state: S) => Iterable<{ next: S; edge: E }>,\n key: (state: S) => string,\n): Generator<BfsStep<S, E>>;\nexport function* bfs<S, E>(\n starts: Iterable<S>,\n neighbours: (state: S) => Iterable<{ next: S; edge: E }>,\n // Identity default for the string overload. TypeScript can't express\n // \"default applies only when S = string\", so this cast bridges the\n // generic implementation signature to the public overloads — which\n // guarantee `key` is omitted only when S = string at the call site.\n key: (state: S) => string = (state) => state as unknown as string,\n): Generator<BfsStep<S, E>> {\n // Queue entries carry the state alongside its key so we don't recompute\n // key() twice per visit (once on dedup, once on parent lookup). Composite\n // keys can be non-trivial to compute; string-overload callers pay nothing\n // since key() is identity there.\n interface Entry {\n readonly state: S;\n readonly key: string;\n }\n const visited = new Set<string>();\n const parentMap = new Map<string, { parent: S; edge: E }>();\n const queue = new Queue<Entry>();\n for (const start of starts) {\n const k = key(start);\n if (!visited.has(k)) {\n visited.add(k);\n queue.push({ state: start, key: k });\n }\n }\n while (!queue.isEmpty) {\n const { state: current, key: curKey } = queue.shift();\n const parentInfo = parentMap.get(curKey);\n yield {\n state: current,\n parent: parentInfo?.parent ?? null,\n incomingEdge: parentInfo?.edge ?? null,\n };\n\n for (const { next, edge } of neighbours(current)) {\n const k = key(next);\n if (!visited.has(k)) {\n visited.add(k);\n parentMap.set(k, { parent: current, edge });\n queue.push({ state: next, key: k });\n }\n }\n }\n}\n","import { ifDefined } from '@prisma-next/utils/defined';\nimport { EMPTY_CONTRACT_HASH } from './constants';\nimport {\n errorAmbiguousTarget,\n errorDuplicateMigrationHash,\n errorNoInitialMigration,\n errorNoTarget,\n errorSameSourceAndTarget,\n} from './errors';\nimport type { MigrationEdge, MigrationGraph } from './graph';\nimport { bfs } from './graph-ops';\nimport type { MigrationPackage } from './package';\n\n/** Forward-edge neighbours: edge `e` from `n` visits `e.to` next. */\nfunction forwardNeighbours(graph: MigrationGraph, node: string) {\n return (graph.forwardChain.get(node) ?? []).map((edge) => ({ next: edge.to, edge }));\n}\n\n/**\n * Forward-edge neighbours, sorted by the deterministic tie-break.\n * Used by path-finding so the resulting shortest path is stable across runs.\n */\nfunction sortedForwardNeighbours(graph: MigrationGraph, node: string) {\n const edges = graph.forwardChain.get(node) ?? [];\n return [...edges].sort(compareTieBreak).map((edge) => ({ next: edge.to, edge }));\n}\n\n/** Reverse-edge neighbours: edge `e` from `n` visits `e.from` next. */\nfunction reverseNeighbours(graph: MigrationGraph, node: string) {\n return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));\n}\n\nfunction appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: MigrationEdge): void {\n const bucket = map.get(key);\n if (bucket) bucket.push(entry);\n else map.set(key, [entry]);\n}\n\nexport function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {\n const nodes = new Set<string>();\n const forwardChain = new Map<string, MigrationEdge[]>();\n const reverseChain = new Map<string, MigrationEdge[]>();\n const migrationByHash = new Map<string, MigrationEdge>();\n\n for (const pkg of packages) {\n const { from, to } = pkg.metadata;\n\n if (from === to) {\n throw errorSameSourceAndTarget(pkg.dirPath, from);\n }\n\n nodes.add(from);\n nodes.add(to);\n\n const migration: MigrationEdge = {\n from,\n to,\n migrationHash: pkg.metadata.migrationHash,\n dirName: pkg.dirName,\n createdAt: pkg.metadata.createdAt,\n labels: pkg.metadata.labels,\n invariants: pkg.metadata.providedInvariants,\n };\n\n if (migrationByHash.has(migration.migrationHash)) {\n throw errorDuplicateMigrationHash(migration.migrationHash);\n }\n migrationByHash.set(migration.migrationHash, migration);\n\n appendEdge(forwardChain, from, migration);\n appendEdge(reverseChain, to, migration);\n }\n\n return { nodes, forwardChain, reverseChain, migrationByHash };\n}\n\n// ---------------------------------------------------------------------------\n// Deterministic tie-breaking for BFS neighbour order.\n// Used by path-finders only; not a general-purpose utility.\n// Ordering: label priority → createdAt → to → migrationHash.\n// ---------------------------------------------------------------------------\n\nconst LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };\n\nfunction labelPriority(labels: readonly string[]): number {\n let best = 3;\n for (const l of labels) {\n const p = LABEL_PRIORITY[l];\n if (p !== undefined && p < best) best = p;\n }\n return best;\n}\n\nfunction compareTieBreak(a: MigrationEdge, b: MigrationEdge): number {\n const lp = labelPriority(a.labels) - labelPriority(b.labels);\n if (lp !== 0) return lp;\n const ca = a.createdAt.localeCompare(b.createdAt);\n if (ca !== 0) return ca;\n const tc = a.to.localeCompare(b.to);\n if (tc !== 0) return tc;\n return a.migrationHash.localeCompare(b.migrationHash);\n}\n\nfunction sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdge[] {\n return [...edges].sort(compareTieBreak);\n}\n\n/**\n * Find the shortest path from `fromHash` to `toHash` using BFS over the\n * contract-hash graph. Returns the ordered list of edges, or null if no path\n * exists. Returns an empty array when `fromHash === toHash` (no-op).\n *\n * Neighbor ordering is deterministic via the tie-break sort key:\n * label priority → createdAt → to → migrationHash.\n */\nexport function findPath(\n graph: MigrationGraph,\n fromHash: string,\n toHash: string,\n): readonly MigrationEdge[] | null {\n if (fromHash === toHash) return [];\n\n const parents = new Map<string, { parent: string; edge: MigrationEdge }>();\n for (const step of bfs([fromHash], (n) => sortedForwardNeighbours(graph, n))) {\n if (step.parent !== null && step.incomingEdge !== null) {\n parents.set(step.state, { parent: step.parent, edge: step.incomingEdge });\n }\n if (step.state === toHash) {\n const path: MigrationEdge[] = [];\n let cur = toHash;\n let p = parents.get(cur);\n while (p) {\n path.push(p.edge);\n cur = p.parent;\n p = parents.get(cur);\n }\n path.reverse();\n return path;\n }\n }\n\n return null;\n}\n\n/**\n * Find the shortest path from `fromHash` to `toHash` whose edges collectively\n * cover every invariant in `required`. Returns `null` when no such path exists\n * (either `fromHash`→`toHash` is structurally unreachable, or every reachable\n * path leaves at least one required invariant uncovered). When `required` is\n * empty, delegates to `findPath` so the result is byte-identical for that case.\n *\n * Algorithm: BFS over `(node, coveredSubset)` states with state-level dedup.\n * The covered subset is a `Set<string>` of invariant ids; the state's dedup\n * key is `${node}\\0${[...covered].sort().join('\\0')}`. State keys distinguish\n * distinct `(node, covered)` tuples regardless of node-name length because\n * `\\0` cannot appear in any invariant id (validation rejects whitespace and\n * control chars at authoring time).\n *\n * Neighbour ordering when `required ≠ ∅`: edges covering ≥1 still-needed\n * invariant come first, with `labelPriority → createdAt → to → migrationHash`\n * as the secondary key. The heuristic steers BFS toward the satisfying path;\n * correctness (shortest, deterministic) does not depend on it.\n */\nexport function findPathWithInvariants(\n graph: MigrationGraph,\n fromHash: string,\n toHash: string,\n required: ReadonlySet<string>,\n): readonly MigrationEdge[] | null {\n if (required.size === 0) {\n return findPath(graph, fromHash, toHash);\n }\n if (fromHash === toHash) {\n // Empty path covers no invariants; required is non-empty ⇒ unsatisfiable.\n return null;\n }\n\n interface InvState {\n readonly node: string;\n readonly covered: ReadonlySet<string>;\n }\n const stateKey = (s: InvState): string => {\n if (s.covered.size === 0) return `${s.node}\\0`;\n return `${s.node}\\0${[...s.covered].sort().join('\\0')}`;\n };\n\n const neighbours = (s: InvState): Iterable<{ next: InvState; edge: MigrationEdge }> => {\n const outgoing = graph.forwardChain.get(s.node) ?? [];\n if (outgoing.length === 0) return [];\n return [...outgoing]\n .map((edge) => {\n let useful = false;\n let next: Set<string> | null = null;\n for (const inv of edge.invariants) {\n if (required.has(inv) && !s.covered.has(inv)) {\n if (next === null) next = new Set(s.covered);\n next.add(inv);\n useful = true;\n }\n }\n return { edge, useful, nextCovered: next ?? s.covered };\n })\n .sort((a, b) => {\n if (a.useful !== b.useful) return a.useful ? -1 : 1;\n return compareTieBreak(a.edge, b.edge);\n })\n .map(({ edge, nextCovered }) => ({\n next: { node: edge.to, covered: nextCovered },\n edge,\n }));\n };\n\n // Path reconstruction is consumer-side, keyed on stateKey, same shape as\n // findPath's parents map.\n const parents = new Map<string, { parentKey: string; edge: MigrationEdge }>();\n for (const step of bfs<InvState, MigrationEdge>(\n [{ node: fromHash, covered: new Set() }],\n neighbours,\n stateKey,\n )) {\n const curKey = stateKey(step.state);\n if (step.parent !== null && step.incomingEdge !== null) {\n parents.set(curKey, { parentKey: stateKey(step.parent), edge: step.incomingEdge });\n }\n if (step.state.node === toHash && step.state.covered.size === required.size) {\n const path: MigrationEdge[] = [];\n let cur: string | undefined = curKey;\n while (cur !== undefined) {\n const p = parents.get(cur);\n if (!p) break;\n path.push(p.edge);\n cur = p.parentKey;\n }\n path.reverse();\n return path;\n }\n }\n\n return null;\n}\n\n/**\n * Reverse-BFS from `toHash` over `reverseChain` to collect every node from\n * which `toHash` is reachable (inclusive of `toHash` itself).\n */\nfunction collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<string> {\n const reached = new Set<string>();\n for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) {\n reached.add(step.state);\n }\n return reached;\n}\n\nexport interface PathDecision {\n readonly selectedPath: readonly MigrationEdge[];\n readonly fromHash: string;\n readonly toHash: string;\n readonly alternativeCount: number;\n readonly tieBreakReasons: readonly string[];\n readonly refName?: string;\n}\n\n/**\n * Find the shortest path from `fromHash` to `toHash` and return structured\n * path-decision metadata for machine-readable output.\n */\nexport function findPathWithDecision(\n graph: MigrationGraph,\n fromHash: string,\n toHash: string,\n refName?: string,\n): PathDecision | null {\n if (fromHash === toHash) {\n return {\n selectedPath: [],\n fromHash,\n toHash,\n alternativeCount: 0,\n tieBreakReasons: [],\n ...ifDefined('refName', refName),\n };\n }\n\n const path = findPath(graph, fromHash, toHash);\n if (!path) return null;\n\n // Single reverse BFS marks every node from which `toHash` is reachable.\n // Replaces a per-edge `findPath(e.to, toHash)` call inside the loop below,\n // which made the whole function O(|path| · (V + E)) instead of O(V + E).\n const reachesTarget = collectNodesReachingTarget(graph, toHash);\n\n const tieBreakReasons: string[] = [];\n let alternativeCount = 0;\n\n for (const edge of path) {\n const outgoing = graph.forwardChain.get(edge.from);\n if (outgoing && outgoing.length > 1) {\n const reachable = outgoing.filter((e) => reachesTarget.has(e.to));\n if (reachable.length > 1) {\n alternativeCount += reachable.length - 1;\n const sorted = sortedNeighbors(reachable);\n if (sorted[0] && sorted[0].migrationHash === edge.migrationHash) {\n if (reachable.some((e) => e.migrationHash !== edge.migrationHash)) {\n tieBreakReasons.push(\n `at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,\n );\n }\n }\n }\n }\n }\n\n return {\n selectedPath: path,\n fromHash,\n toHash,\n alternativeCount,\n tieBreakReasons,\n ...ifDefined('refName', refName),\n };\n}\n\n/**\n * Walk ancestors of each branch tip back to find the last node\n * that appears on all paths. Returns `fromHash` if no shared ancestor is found.\n */\nfunction findDivergencePoint(\n graph: MigrationGraph,\n fromHash: string,\n leaves: readonly string[],\n): string {\n const ancestorSets = leaves.map((leaf) => {\n const ancestors = new Set<string>();\n for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) {\n ancestors.add(step.state);\n }\n return ancestors;\n });\n\n const commonAncestors = [...(ancestorSets[0] ?? [])].filter((node) =>\n ancestorSets.every((s) => s.has(node)),\n );\n\n let deepest = fromHash;\n let deepestDepth = -1;\n for (const ancestor of commonAncestors) {\n const path = findPath(graph, fromHash, ancestor);\n const depth = path ? path.length : 0;\n if (depth > deepestDepth) {\n deepestDepth = depth;\n deepest = ancestor;\n }\n }\n return deepest;\n}\n\n/**\n * Find all branch tips (nodes with no outgoing edges) reachable from\n * `fromHash` via forward edges.\n */\nexport function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {\n const leaves: string[] = [];\n for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) {\n if (!graph.forwardChain.get(step.state)?.length) {\n leaves.push(step.state);\n }\n }\n return leaves;\n}\n\n/**\n * Find the target contract hash of the migration graph reachable from\n * EMPTY_CONTRACT_HASH. Returns `null` for a graph that has no target\n * state (either empty, or containing only the root with no outgoing\n * edges). Throws NO_INITIAL_MIGRATION if the graph has nodes but none\n * originate from the empty hash, and AMBIGUOUS_TARGET if multiple\n * branch tips exist.\n */\nexport function findLeaf(graph: MigrationGraph): string | null {\n if (graph.nodes.size === 0) {\n return null;\n }\n\n if (!graph.nodes.has(EMPTY_CONTRACT_HASH)) {\n throw errorNoInitialMigration([...graph.nodes]);\n }\n\n const leaves = findReachableLeaves(graph, EMPTY_CONTRACT_HASH);\n\n if (leaves.length === 0) {\n const reachable = [...graph.nodes].filter((n) => n !== EMPTY_CONTRACT_HASH);\n if (reachable.length > 0) {\n throw errorNoTarget(reachable);\n }\n return null;\n }\n\n if (leaves.length > 1) {\n const divergencePoint = findDivergencePoint(graph, EMPTY_CONTRACT_HASH, leaves);\n const branches = leaves.map((tip) => {\n const path = findPath(graph, divergencePoint, tip);\n return {\n tip,\n edges: (path ?? []).map((e) => ({ dirName: e.dirName, from: e.from, to: e.to })),\n };\n });\n throw errorAmbiguousTarget(leaves, { divergencePoint, branches });\n }\n\n // biome-ignore lint/style/noNonNullAssertion: leaves.length is neither 0 nor >1 per the branches above, so exactly one leaf remains\n return leaves[0]!;\n}\n\n/**\n * Find the latest migration entry by traversing from EMPTY_CONTRACT_HASH\n * to the single target. Returns null for an empty graph.\n * Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.\n */\nexport function findLatestMigration(graph: MigrationGraph): MigrationEdge | null {\n const leafHash = findLeaf(graph);\n if (leafHash === null) return null;\n\n const path = findPath(graph, EMPTY_CONTRACT_HASH, leafHash);\n return path?.at(-1) ?? null;\n}\n\nexport function detectCycles(graph: MigrationGraph): readonly string[][] {\n const WHITE = 0;\n const GRAY = 1;\n const BLACK = 2;\n\n const color = new Map<string, number>();\n const parentMap = new Map<string, string | null>();\n const cycles: string[][] = [];\n\n for (const node of graph.nodes) {\n color.set(node, WHITE);\n }\n\n // Iterative three-color DFS. A frame is (node, outgoing edges, next-index).\n interface Frame {\n node: string;\n outgoing: readonly MigrationEdge[];\n index: number;\n }\n const stack: Frame[] = [];\n\n function pushFrame(u: string): void {\n color.set(u, GRAY);\n stack.push({ node: u, outgoing: graph.forwardChain.get(u) ?? [], index: 0 });\n }\n\n for (const root of graph.nodes) {\n if (color.get(root) !== WHITE) continue;\n parentMap.set(root, null);\n pushFrame(root);\n\n while (stack.length > 0) {\n // biome-ignore lint/style/noNonNullAssertion: stack.length > 0 should guarantee that this cannot be undefined\n const frame = stack[stack.length - 1]!;\n if (frame.index >= frame.outgoing.length) {\n color.set(frame.node, BLACK);\n stack.pop();\n continue;\n }\n // biome-ignore lint/style/noNonNullAssertion: the early-continue above guarantees frame.index < frame.outgoing.length here, so this is defined\n const edge = frame.outgoing[frame.index++]!;\n const v = edge.to;\n const vColor = color.get(v);\n if (vColor === GRAY) {\n const cycle: string[] = [v];\n let cur = frame.node;\n while (cur !== v) {\n cycle.push(cur);\n cur = parentMap.get(cur) ?? v;\n }\n cycle.reverse();\n cycles.push(cycle);\n } else if (vColor === WHITE) {\n parentMap.set(v, frame.node);\n pushFrame(v);\n }\n }\n }\n\n return cycles;\n}\n\nexport function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {\n if (graph.nodes.size === 0) return [];\n\n const reachable = new Set<string>();\n const startNodes: string[] = [];\n\n if (graph.forwardChain.has(EMPTY_CONTRACT_HASH)) {\n startNodes.push(EMPTY_CONTRACT_HASH);\n } else {\n const allTargets = new Set<string>();\n for (const edges of graph.forwardChain.values()) {\n for (const edge of edges) {\n allTargets.add(edge.to);\n }\n }\n for (const node of graph.nodes) {\n if (!allTargets.has(node)) {\n startNodes.push(node);\n }\n }\n }\n\n for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) {\n reachable.add(step.state);\n }\n\n const orphans: MigrationEdge[] = [];\n for (const [from, migrations] of graph.forwardChain) {\n if (!reachable.has(from)) {\n orphans.push(...migrations);\n }\n }\n\n return orphans;\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prisma-next/migration-tools",
|
|
3
|
-
"version": "0.5.0-dev.
|
|
3
|
+
"version": "0.5.0-dev.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "On-disk migration persistence, hash verification, and chain reconstruction for Prisma Next",
|
|
@@ -8,16 +8,16 @@
|
|
|
8
8
|
"arktype": "^2.1.29",
|
|
9
9
|
"pathe": "^2.0.3",
|
|
10
10
|
"prettier": "^3.6.2",
|
|
11
|
-
"@prisma-next/contract": "0.5.0-dev.
|
|
12
|
-
"@prisma-next/
|
|
13
|
-
"@prisma-next/
|
|
11
|
+
"@prisma-next/contract": "0.5.0-dev.22",
|
|
12
|
+
"@prisma-next/utils": "0.5.0-dev.22",
|
|
13
|
+
"@prisma-next/framework-components": "0.5.0-dev.22"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"tsdown": "0.18.4",
|
|
17
17
|
"typescript": "5.9.3",
|
|
18
18
|
"vitest": "4.0.17",
|
|
19
|
-
"@prisma-next/
|
|
20
|
-
"@prisma-next/
|
|
19
|
+
"@prisma-next/tsconfig": "0.0.0",
|
|
20
|
+
"@prisma-next/tsdown": "0.0.0"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"dist",
|
package/src/graph-ops.ts
CHANGED
|
@@ -3,13 +3,18 @@ import { Queue } from './queue';
|
|
|
3
3
|
/**
|
|
4
4
|
* One step of a BFS traversal.
|
|
5
5
|
*
|
|
6
|
-
* `parent` and `incomingEdge` are `null` for start
|
|
7
|
-
* reached via any edge. For every other
|
|
8
|
-
* by which this
|
|
6
|
+
* `parent` and `incomingEdge` are `null` for start states — they were not
|
|
7
|
+
* reached via any edge. For every other state they record the predecessor
|
|
8
|
+
* state and the edge by which this state was first reached.
|
|
9
|
+
*
|
|
10
|
+
* `state` is the BFS state, most often a string (graph node identifier) but
|
|
11
|
+
* can be a composite object. The string overload keeps the common case
|
|
12
|
+
* ergonomic; the generic overload accepts a caller-supplied `key` function
|
|
13
|
+
* that produces a stable equality key for dedup.
|
|
9
14
|
*/
|
|
10
|
-
export interface BfsStep<E> {
|
|
11
|
-
readonly
|
|
12
|
-
readonly parent:
|
|
15
|
+
export interface BfsStep<S, E> {
|
|
16
|
+
readonly state: S;
|
|
17
|
+
readonly parent: S | null;
|
|
13
18
|
readonly incomingEdge: E | null;
|
|
14
19
|
}
|
|
15
20
|
|
|
@@ -17,48 +22,70 @@ export interface BfsStep<E> {
|
|
|
17
22
|
* Generic breadth-first traversal.
|
|
18
23
|
*
|
|
19
24
|
* Direction (forward/reverse) is expressed by the caller's `neighbours`
|
|
20
|
-
* closure: return `{ next, edge }` pairs where `next` is the
|
|
25
|
+
* closure: return `{ next, edge }` pairs where `next` is the state to visit
|
|
21
26
|
* next and `edge` is the edge that connects them. Callers that don't need
|
|
22
27
|
* path reconstruction can ignore the `parent`/`incomingEdge` fields of each
|
|
23
28
|
* yielded step.
|
|
24
29
|
*
|
|
30
|
+
* Ordering — when the result needs to be deterministic (path-finding) the
|
|
31
|
+
* caller is responsible for sorting inside `neighbours`; this generator
|
|
32
|
+
* does not impose an ordering hook of its own. State-dependent orderings
|
|
33
|
+
* have full access to the source state inside the closure.
|
|
34
|
+
*
|
|
25
35
|
* Stops are intrinsic — callers `break` out of the `for..of` loop when
|
|
26
36
|
* they've found what they're looking for.
|
|
27
|
-
*
|
|
28
|
-
* `ordering`, if provided, controls the order in which neighbours of each
|
|
29
|
-
* node are enqueued. Only matters for path-finding: a deterministic ordering
|
|
30
|
-
* makes BFS return a deterministic shortest path when multiple exist.
|
|
31
37
|
*/
|
|
32
|
-
export function
|
|
38
|
+
export function bfs<E>(
|
|
33
39
|
starts: Iterable<string>,
|
|
34
|
-
neighbours: (
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
neighbours: (state: string) => Iterable<{ next: string; edge: E }>,
|
|
41
|
+
): Generator<BfsStep<string, E>>;
|
|
42
|
+
export function bfs<S, E>(
|
|
43
|
+
starts: Iterable<S>,
|
|
44
|
+
neighbours: (state: S) => Iterable<{ next: S; edge: E }>,
|
|
45
|
+
key: (state: S) => string,
|
|
46
|
+
): Generator<BfsStep<S, E>>;
|
|
47
|
+
export function* bfs<S, E>(
|
|
48
|
+
starts: Iterable<S>,
|
|
49
|
+
neighbours: (state: S) => Iterable<{ next: S; edge: E }>,
|
|
50
|
+
// Identity default for the string overload. TypeScript can't express
|
|
51
|
+
// "default applies only when S = string", so this cast bridges the
|
|
52
|
+
// generic implementation signature to the public overloads — which
|
|
53
|
+
// guarantee `key` is omitted only when S = string at the call site.
|
|
54
|
+
key: (state: S) => string = (state) => state as unknown as string,
|
|
55
|
+
): Generator<BfsStep<S, E>> {
|
|
56
|
+
// Queue entries carry the state alongside its key so we don't recompute
|
|
57
|
+
// key() twice per visit (once on dedup, once on parent lookup). Composite
|
|
58
|
+
// keys can be non-trivial to compute; string-overload callers pay nothing
|
|
59
|
+
// since key() is identity there.
|
|
60
|
+
interface Entry {
|
|
61
|
+
readonly state: S;
|
|
62
|
+
readonly key: string;
|
|
63
|
+
}
|
|
37
64
|
const visited = new Set<string>();
|
|
38
|
-
const parentMap = new Map<string, { parent:
|
|
39
|
-
const queue = new Queue<
|
|
65
|
+
const parentMap = new Map<string, { parent: S; edge: E }>();
|
|
66
|
+
const queue = new Queue<Entry>();
|
|
40
67
|
for (const start of starts) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
const k = key(start);
|
|
69
|
+
if (!visited.has(k)) {
|
|
70
|
+
visited.add(k);
|
|
71
|
+
queue.push({ state: start, key: k });
|
|
44
72
|
}
|
|
45
73
|
}
|
|
46
74
|
while (!queue.isEmpty) {
|
|
47
|
-
const current = queue.shift();
|
|
48
|
-
const parentInfo = parentMap.get(
|
|
75
|
+
const { state: current, key: curKey } = queue.shift();
|
|
76
|
+
const parentInfo = parentMap.get(curKey);
|
|
49
77
|
yield {
|
|
50
|
-
|
|
78
|
+
state: current,
|
|
51
79
|
parent: parentInfo?.parent ?? null,
|
|
52
80
|
incomingEdge: parentInfo?.edge ?? null,
|
|
53
81
|
};
|
|
54
82
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
queue.push(next);
|
|
83
|
+
for (const { next, edge } of neighbours(current)) {
|
|
84
|
+
const k = key(next);
|
|
85
|
+
if (!visited.has(k)) {
|
|
86
|
+
visited.add(k);
|
|
87
|
+
parentMap.set(k, { parent: current, edge });
|
|
88
|
+
queue.push({ state: next, key: k });
|
|
62
89
|
}
|
|
63
90
|
}
|
|
64
91
|
}
|
package/src/migration-graph.ts
CHANGED
|
@@ -11,12 +11,21 @@ import type { MigrationEdge, MigrationGraph } from './graph';
|
|
|
11
11
|
import { bfs } from './graph-ops';
|
|
12
12
|
import type { MigrationPackage } from './package';
|
|
13
13
|
|
|
14
|
-
/** Forward-edge neighbours
|
|
14
|
+
/** Forward-edge neighbours: edge `e` from `n` visits `e.to` next. */
|
|
15
15
|
function forwardNeighbours(graph: MigrationGraph, node: string) {
|
|
16
16
|
return (graph.forwardChain.get(node) ?? []).map((edge) => ({ next: edge.to, edge }));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Forward-edge neighbours, sorted by the deterministic tie-break.
|
|
21
|
+
* Used by path-finding so the resulting shortest path is stable across runs.
|
|
22
|
+
*/
|
|
23
|
+
function sortedForwardNeighbours(graph: MigrationGraph, node: string) {
|
|
24
|
+
const edges = graph.forwardChain.get(node) ?? [];
|
|
25
|
+
return [...edges].sort(compareTieBreak).map((edge) => ({ next: edge.to, edge }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reverse-edge neighbours: edge `e` from `n` visits `e.from` next. */
|
|
20
29
|
function reverseNeighbours(graph: MigrationGraph, node: string) {
|
|
21
30
|
return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
|
|
22
31
|
}
|
|
@@ -67,8 +76,8 @@ export function reconstructGraph(packages: readonly MigrationPackage[]): Migrati
|
|
|
67
76
|
|
|
68
77
|
// ---------------------------------------------------------------------------
|
|
69
78
|
// Deterministic tie-breaking for BFS neighbour order.
|
|
70
|
-
// Used by
|
|
71
|
-
//
|
|
79
|
+
// Used by path-finders only; not a general-purpose utility.
|
|
80
|
+
// Ordering: label priority → createdAt → to → migrationHash.
|
|
72
81
|
// ---------------------------------------------------------------------------
|
|
73
82
|
|
|
74
83
|
const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
|
|
@@ -96,13 +105,6 @@ function sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdg
|
|
|
96
105
|
return [...edges].sort(compareTieBreak);
|
|
97
106
|
}
|
|
98
107
|
|
|
99
|
-
/** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
|
|
100
|
-
function bfsOrdering(
|
|
101
|
-
items: readonly { next: string; edge: MigrationEdge }[],
|
|
102
|
-
): readonly { next: string; edge: MigrationEdge }[] {
|
|
103
|
-
return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
108
|
/**
|
|
107
109
|
* Find the shortest path from `fromHash` to `toHash` using BFS over the
|
|
108
110
|
* contract-hash graph. Returns the ordered list of edges, or null if no path
|
|
@@ -119,11 +121,11 @@ export function findPath(
|
|
|
119
121
|
if (fromHash === toHash) return [];
|
|
120
122
|
|
|
121
123
|
const parents = new Map<string, { parent: string; edge: MigrationEdge }>();
|
|
122
|
-
for (const step of bfs([fromHash], (n) =>
|
|
124
|
+
for (const step of bfs([fromHash], (n) => sortedForwardNeighbours(graph, n))) {
|
|
123
125
|
if (step.parent !== null && step.incomingEdge !== null) {
|
|
124
|
-
parents.set(step.
|
|
126
|
+
parents.set(step.state, { parent: step.parent, edge: step.incomingEdge });
|
|
125
127
|
}
|
|
126
|
-
if (step.
|
|
128
|
+
if (step.state === toHash) {
|
|
127
129
|
const path: MigrationEdge[] = [];
|
|
128
130
|
let cur = toHash;
|
|
129
131
|
let p = parents.get(cur);
|
|
@@ -140,6 +142,103 @@ export function findPath(
|
|
|
140
142
|
return null;
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Find the shortest path from `fromHash` to `toHash` whose edges collectively
|
|
147
|
+
* cover every invariant in `required`. Returns `null` when no such path exists
|
|
148
|
+
* (either `fromHash`→`toHash` is structurally unreachable, or every reachable
|
|
149
|
+
* path leaves at least one required invariant uncovered). When `required` is
|
|
150
|
+
* empty, delegates to `findPath` so the result is byte-identical for that case.
|
|
151
|
+
*
|
|
152
|
+
* Algorithm: BFS over `(node, coveredSubset)` states with state-level dedup.
|
|
153
|
+
* The covered subset is a `Set<string>` of invariant ids; the state's dedup
|
|
154
|
+
* key is `${node}\0${[...covered].sort().join('\0')}`. State keys distinguish
|
|
155
|
+
* distinct `(node, covered)` tuples regardless of node-name length because
|
|
156
|
+
* `\0` cannot appear in any invariant id (validation rejects whitespace and
|
|
157
|
+
* control chars at authoring time).
|
|
158
|
+
*
|
|
159
|
+
* Neighbour ordering when `required ≠ ∅`: edges covering ≥1 still-needed
|
|
160
|
+
* invariant come first, with `labelPriority → createdAt → to → migrationHash`
|
|
161
|
+
* as the secondary key. The heuristic steers BFS toward the satisfying path;
|
|
162
|
+
* correctness (shortest, deterministic) does not depend on it.
|
|
163
|
+
*/
|
|
164
|
+
export function findPathWithInvariants(
|
|
165
|
+
graph: MigrationGraph,
|
|
166
|
+
fromHash: string,
|
|
167
|
+
toHash: string,
|
|
168
|
+
required: ReadonlySet<string>,
|
|
169
|
+
): readonly MigrationEdge[] | null {
|
|
170
|
+
if (required.size === 0) {
|
|
171
|
+
return findPath(graph, fromHash, toHash);
|
|
172
|
+
}
|
|
173
|
+
if (fromHash === toHash) {
|
|
174
|
+
// Empty path covers no invariants; required is non-empty ⇒ unsatisfiable.
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface InvState {
|
|
179
|
+
readonly node: string;
|
|
180
|
+
readonly covered: ReadonlySet<string>;
|
|
181
|
+
}
|
|
182
|
+
const stateKey = (s: InvState): string => {
|
|
183
|
+
if (s.covered.size === 0) return `${s.node}\0`;
|
|
184
|
+
return `${s.node}\0${[...s.covered].sort().join('\0')}`;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const neighbours = (s: InvState): Iterable<{ next: InvState; edge: MigrationEdge }> => {
|
|
188
|
+
const outgoing = graph.forwardChain.get(s.node) ?? [];
|
|
189
|
+
if (outgoing.length === 0) return [];
|
|
190
|
+
return [...outgoing]
|
|
191
|
+
.map((edge) => {
|
|
192
|
+
let useful = false;
|
|
193
|
+
let next: Set<string> | null = null;
|
|
194
|
+
for (const inv of edge.invariants) {
|
|
195
|
+
if (required.has(inv) && !s.covered.has(inv)) {
|
|
196
|
+
if (next === null) next = new Set(s.covered);
|
|
197
|
+
next.add(inv);
|
|
198
|
+
useful = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { edge, useful, nextCovered: next ?? s.covered };
|
|
202
|
+
})
|
|
203
|
+
.sort((a, b) => {
|
|
204
|
+
if (a.useful !== b.useful) return a.useful ? -1 : 1;
|
|
205
|
+
return compareTieBreak(a.edge, b.edge);
|
|
206
|
+
})
|
|
207
|
+
.map(({ edge, nextCovered }) => ({
|
|
208
|
+
next: { node: edge.to, covered: nextCovered },
|
|
209
|
+
edge,
|
|
210
|
+
}));
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Path reconstruction is consumer-side, keyed on stateKey, same shape as
|
|
214
|
+
// findPath's parents map.
|
|
215
|
+
const parents = new Map<string, { parentKey: string; edge: MigrationEdge }>();
|
|
216
|
+
for (const step of bfs<InvState, MigrationEdge>(
|
|
217
|
+
[{ node: fromHash, covered: new Set() }],
|
|
218
|
+
neighbours,
|
|
219
|
+
stateKey,
|
|
220
|
+
)) {
|
|
221
|
+
const curKey = stateKey(step.state);
|
|
222
|
+
if (step.parent !== null && step.incomingEdge !== null) {
|
|
223
|
+
parents.set(curKey, { parentKey: stateKey(step.parent), edge: step.incomingEdge });
|
|
224
|
+
}
|
|
225
|
+
if (step.state.node === toHash && step.state.covered.size === required.size) {
|
|
226
|
+
const path: MigrationEdge[] = [];
|
|
227
|
+
let cur: string | undefined = curKey;
|
|
228
|
+
while (cur !== undefined) {
|
|
229
|
+
const p = parents.get(cur);
|
|
230
|
+
if (!p) break;
|
|
231
|
+
path.push(p.edge);
|
|
232
|
+
cur = p.parentKey;
|
|
233
|
+
}
|
|
234
|
+
path.reverse();
|
|
235
|
+
return path;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
143
242
|
/**
|
|
144
243
|
* Reverse-BFS from `toHash` over `reverseChain` to collect every node from
|
|
145
244
|
* which `toHash` is reachable (inclusive of `toHash` itself).
|
|
@@ -147,7 +246,7 @@ export function findPath(
|
|
|
147
246
|
function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<string> {
|
|
148
247
|
const reached = new Set<string>();
|
|
149
248
|
for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) {
|
|
150
|
-
reached.add(step.
|
|
249
|
+
reached.add(step.state);
|
|
151
250
|
}
|
|
152
251
|
return reached;
|
|
153
252
|
}
|
|
@@ -233,7 +332,7 @@ function findDivergencePoint(
|
|
|
233
332
|
const ancestorSets = leaves.map((leaf) => {
|
|
234
333
|
const ancestors = new Set<string>();
|
|
235
334
|
for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) {
|
|
236
|
-
ancestors.add(step.
|
|
335
|
+
ancestors.add(step.state);
|
|
237
336
|
}
|
|
238
337
|
return ancestors;
|
|
239
338
|
});
|
|
@@ -262,8 +361,8 @@ function findDivergencePoint(
|
|
|
262
361
|
export function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {
|
|
263
362
|
const leaves: string[] = [];
|
|
264
363
|
for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) {
|
|
265
|
-
if (!graph.forwardChain.get(step.
|
|
266
|
-
leaves.push(step.
|
|
364
|
+
if (!graph.forwardChain.get(step.state)?.length) {
|
|
365
|
+
leaves.push(step.state);
|
|
267
366
|
}
|
|
268
367
|
}
|
|
269
368
|
return leaves;
|
|
@@ -410,7 +509,7 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {
|
|
|
410
509
|
}
|
|
411
510
|
|
|
412
511
|
for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) {
|
|
413
|
-
reachable.add(step.
|
|
512
|
+
reachable.add(step.state);
|
|
414
513
|
}
|
|
415
514
|
|
|
416
515
|
const orphans: MigrationEdge[] = [];
|