@prisma-next/migration-tools 0.5.0-dev.9 → 0.5.1

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 (130) hide show
  1. package/README.md +34 -22
  2. package/dist/{constants-BRi0X7B_.mjs → constants-DWV9_o2Z.mjs} +2 -2
  3. package/dist/{constants-BRi0X7B_.mjs.map → constants-DWV9_o2Z.mjs.map} +1 -1
  4. package/dist/errors-EPL_9p9f.mjs +297 -0
  5. package/dist/errors-EPL_9p9f.mjs.map +1 -0
  6. package/dist/exports/aggregate.d.mts +599 -0
  7. package/dist/exports/aggregate.d.mts.map +1 -0
  8. package/dist/exports/aggregate.mjs +599 -0
  9. package/dist/exports/aggregate.mjs.map +1 -0
  10. package/dist/exports/constants.d.mts.map +1 -1
  11. package/dist/exports/constants.mjs +2 -3
  12. package/dist/exports/errors.d.mts +68 -0
  13. package/dist/exports/errors.d.mts.map +1 -0
  14. package/dist/exports/errors.mjs +2 -0
  15. package/dist/exports/graph.d.mts +2 -0
  16. package/dist/exports/graph.mjs +1 -0
  17. package/dist/exports/hash.d.mts +52 -0
  18. package/dist/exports/hash.d.mts.map +1 -0
  19. package/dist/exports/hash.mjs +2 -0
  20. package/dist/exports/invariants.d.mts +39 -0
  21. package/dist/exports/invariants.d.mts.map +1 -0
  22. package/dist/exports/invariants.mjs +2 -0
  23. package/dist/exports/io.d.mts +66 -6
  24. package/dist/exports/io.d.mts.map +1 -1
  25. package/dist/exports/io.mjs +2 -3
  26. package/dist/exports/metadata.d.mts +2 -0
  27. package/dist/exports/metadata.mjs +1 -0
  28. package/dist/exports/migration-graph.d.mts +2 -0
  29. package/dist/exports/migration-graph.mjs +2 -0
  30. package/dist/exports/migration-ts.d.mts.map +1 -1
  31. package/dist/exports/migration-ts.mjs +2 -4
  32. package/dist/exports/migration-ts.mjs.map +1 -1
  33. package/dist/exports/migration.d.mts +15 -14
  34. package/dist/exports/migration.d.mts.map +1 -1
  35. package/dist/exports/migration.mjs +70 -43
  36. package/dist/exports/migration.mjs.map +1 -1
  37. package/dist/exports/package.d.mts +3 -0
  38. package/dist/exports/package.mjs +1 -0
  39. package/dist/exports/refs.d.mts.map +1 -1
  40. package/dist/exports/refs.mjs +3 -4
  41. package/dist/exports/refs.mjs.map +1 -1
  42. package/dist/exports/spaces.d.mts +526 -0
  43. package/dist/exports/spaces.d.mts.map +1 -0
  44. package/dist/exports/spaces.mjs +266 -0
  45. package/dist/exports/spaces.mjs.map +1 -0
  46. package/dist/graph-HMWAldoR.d.mts +28 -0
  47. package/dist/graph-HMWAldoR.d.mts.map +1 -0
  48. package/dist/hash-By50zM_E.mjs +74 -0
  49. package/dist/hash-By50zM_E.mjs.map +1 -0
  50. package/dist/invariants-qgQGlsrV.mjs +57 -0
  51. package/dist/invariants-qgQGlsrV.mjs.map +1 -0
  52. package/dist/io-D5YYptRO.mjs +239 -0
  53. package/dist/io-D5YYptRO.mjs.map +1 -0
  54. package/dist/metadata-CFvm3ayn.d.mts +2 -0
  55. package/dist/migration-graph-DGNnKDY5.mjs +523 -0
  56. package/dist/migration-graph-DGNnKDY5.mjs.map +1 -0
  57. package/dist/migration-graph-DulOITvG.d.mts +124 -0
  58. package/dist/migration-graph-DulOITvG.d.mts.map +1 -0
  59. package/dist/op-schema-D5qkXfEf.mjs +13 -0
  60. package/dist/op-schema-D5qkXfEf.mjs.map +1 -0
  61. package/dist/package-BjiZ7KDy.d.mts +21 -0
  62. package/dist/package-BjiZ7KDy.d.mts.map +1 -0
  63. package/dist/read-contract-space-contract-Cme8KZk_.mjs +259 -0
  64. package/dist/read-contract-space-contract-Cme8KZk_.mjs.map +1 -0
  65. package/package.json +42 -17
  66. package/src/aggregate/loader.ts +379 -0
  67. package/src/aggregate/marker-types.ts +16 -0
  68. package/src/aggregate/planner-types.ts +171 -0
  69. package/src/aggregate/planner.ts +159 -0
  70. package/src/aggregate/project-schema-to-space.ts +64 -0
  71. package/src/aggregate/strategies/graph-walk.ts +118 -0
  72. package/src/aggregate/strategies/synth.ts +122 -0
  73. package/src/aggregate/types.ts +89 -0
  74. package/src/aggregate/verifier.ts +230 -0
  75. package/src/assert-descriptor-self-consistency.ts +70 -0
  76. package/src/compute-extension-space-apply-path.ts +152 -0
  77. package/src/concatenate-space-apply-inputs.ts +90 -0
  78. package/src/contract-space-from-json.ts +63 -0
  79. package/src/emit-contract-space-artefacts.ts +70 -0
  80. package/src/errors.ts +251 -17
  81. package/src/exports/aggregate.ts +42 -0
  82. package/src/exports/errors.ts +8 -0
  83. package/src/exports/graph.ts +1 -0
  84. package/src/exports/hash.ts +2 -0
  85. package/src/exports/invariants.ts +1 -0
  86. package/src/exports/io.ts +3 -1
  87. package/src/exports/metadata.ts +1 -0
  88. package/src/exports/{dag.ts → migration-graph.ts} +3 -2
  89. package/src/exports/migration.ts +0 -1
  90. package/src/exports/package.ts +2 -0
  91. package/src/exports/spaces.ts +45 -0
  92. package/src/gather-disk-contract-space-state.ts +62 -0
  93. package/src/graph-ops.ts +57 -30
  94. package/src/graph.ts +25 -0
  95. package/src/hash.ts +91 -0
  96. package/src/invariants.ts +61 -0
  97. package/src/io.ts +163 -40
  98. package/src/metadata.ts +1 -0
  99. package/src/migration-base.ts +97 -56
  100. package/src/migration-graph.ts +676 -0
  101. package/src/op-schema.ts +11 -0
  102. package/src/package.ts +21 -0
  103. package/src/plan-all-spaces.ts +76 -0
  104. package/src/read-contract-space-contract.ts +44 -0
  105. package/src/read-contract-space-head-ref.ts +63 -0
  106. package/src/space-layout.ts +48 -0
  107. package/src/verify-contract-spaces.ts +272 -0
  108. package/dist/attestation-BnzTb0Qp.mjs +0 -65
  109. package/dist/attestation-BnzTb0Qp.mjs.map +0 -1
  110. package/dist/errors-BmiSgz1j.mjs +0 -160
  111. package/dist/errors-BmiSgz1j.mjs.map +0 -1
  112. package/dist/exports/attestation.d.mts +0 -37
  113. package/dist/exports/attestation.d.mts.map +0 -1
  114. package/dist/exports/attestation.mjs +0 -4
  115. package/dist/exports/dag.d.mts +0 -51
  116. package/dist/exports/dag.d.mts.map +0 -1
  117. package/dist/exports/dag.mjs +0 -386
  118. package/dist/exports/dag.mjs.map +0 -1
  119. package/dist/exports/types.d.mts +0 -35
  120. package/dist/exports/types.d.mts.map +0 -1
  121. package/dist/exports/types.mjs +0 -3
  122. package/dist/io-Cd6GLyjK.mjs +0 -153
  123. package/dist/io-Cd6GLyjK.mjs.map +0 -1
  124. package/dist/types-DyGXcWWp.d.mts +0 -71
  125. package/dist/types-DyGXcWWp.d.mts.map +0 -1
  126. package/src/attestation.ts +0 -81
  127. package/src/dag.ts +0 -426
  128. package/src/exports/attestation.ts +0 -2
  129. package/src/exports/types.ts +0 -10
  130. package/src/types.ts +0 -66
@@ -0,0 +1,676 @@
1
+ import { ifDefined } from '@prisma-next/utils/defined';
2
+ import { EMPTY_CONTRACT_HASH } from './constants';
3
+ import {
4
+ errorAmbiguousTarget,
5
+ errorDuplicateMigrationHash,
6
+ errorNoInitialMigration,
7
+ errorNoTarget,
8
+ errorSameSourceAndTarget,
9
+ } from './errors';
10
+ import type { MigrationEdge, MigrationGraph } from './graph';
11
+ import { bfs } from './graph-ops';
12
+ import type { OnDiskMigrationPackage } from './package';
13
+
14
+ /** Forward-edge neighbours: edge `e` from `n` visits `e.to` next. */
15
+ function forwardNeighbours(graph: MigrationGraph, node: string) {
16
+ return (graph.forwardChain.get(node) ?? []).map((edge) => ({ next: edge.to, edge }));
17
+ }
18
+
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. */
29
+ function reverseNeighbours(graph: MigrationGraph, node: string) {
30
+ return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
31
+ }
32
+
33
+ function appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: MigrationEdge): void {
34
+ const bucket = map.get(key);
35
+ if (bucket) bucket.push(entry);
36
+ else map.set(key, [entry]);
37
+ }
38
+
39
+ export function reconstructGraph(packages: readonly OnDiskMigrationPackage[]): MigrationGraph {
40
+ const nodes = new Set<string>();
41
+ const forwardChain = new Map<string, MigrationEdge[]>();
42
+ const reverseChain = new Map<string, MigrationEdge[]>();
43
+ const migrationByHash = new Map<string, MigrationEdge>();
44
+
45
+ for (const pkg of packages) {
46
+ // Manifest `from` is `string | null` (null = baseline). The graph layer
47
+ // is the marker/path layer where "no prior state" is encoded as the
48
+ // EMPTY_CONTRACT_HASH sentinel; bridge here so pathfinding stays string-
49
+ // keyed.
50
+ const from = pkg.metadata.from ?? EMPTY_CONTRACT_HASH;
51
+ const { to } = pkg.metadata;
52
+
53
+ if (from === to) {
54
+ const hasDataOp = pkg.ops.some((op) => op.operationClass === 'data');
55
+ if (!hasDataOp) {
56
+ throw errorSameSourceAndTarget(pkg.dirPath, from);
57
+ }
58
+ }
59
+
60
+ nodes.add(from);
61
+ nodes.add(to);
62
+
63
+ const migration: MigrationEdge = {
64
+ from,
65
+ to,
66
+ migrationHash: pkg.metadata.migrationHash,
67
+ dirName: pkg.dirName,
68
+ createdAt: pkg.metadata.createdAt,
69
+ labels: pkg.metadata.labels,
70
+ invariants: pkg.metadata.providedInvariants,
71
+ };
72
+
73
+ if (migrationByHash.has(migration.migrationHash)) {
74
+ throw errorDuplicateMigrationHash(migration.migrationHash);
75
+ }
76
+ migrationByHash.set(migration.migrationHash, migration);
77
+
78
+ appendEdge(forwardChain, from, migration);
79
+ appendEdge(reverseChain, to, migration);
80
+ }
81
+
82
+ return { nodes, forwardChain, reverseChain, migrationByHash };
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Deterministic tie-breaking for BFS neighbour order.
87
+ // Used by path-finders only; not a general-purpose utility.
88
+ // Ordering: label priority → createdAt → to → migrationHash.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
92
+
93
+ function labelPriority(labels: readonly string[]): number {
94
+ let best = 3;
95
+ for (const l of labels) {
96
+ const p = LABEL_PRIORITY[l];
97
+ if (p !== undefined && p < best) best = p;
98
+ }
99
+ return best;
100
+ }
101
+
102
+ function compareTieBreak(a: MigrationEdge, b: MigrationEdge): number {
103
+ const lp = labelPriority(a.labels) - labelPriority(b.labels);
104
+ if (lp !== 0) return lp;
105
+ const ca = a.createdAt.localeCompare(b.createdAt);
106
+ if (ca !== 0) return ca;
107
+ const tc = a.to.localeCompare(b.to);
108
+ if (tc !== 0) return tc;
109
+ return a.migrationHash.localeCompare(b.migrationHash);
110
+ }
111
+
112
+ function sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdge[] {
113
+ return [...edges].sort(compareTieBreak);
114
+ }
115
+
116
+ /**
117
+ * Find the shortest path from `fromHash` to `toHash` using BFS over the
118
+ * contract-hash graph. Returns the ordered list of edges, or null if no path
119
+ * exists. Returns an empty array when `fromHash === toHash` (no-op).
120
+ *
121
+ * Neighbor ordering is deterministic via the tie-break sort key:
122
+ * label priority → createdAt → to → migrationHash.
123
+ */
124
+ export function findPath(
125
+ graph: MigrationGraph,
126
+ fromHash: string,
127
+ toHash: string,
128
+ ): readonly MigrationEdge[] | null {
129
+ if (fromHash === toHash) return [];
130
+
131
+ const parents = new Map<string, { parent: string; edge: MigrationEdge }>();
132
+ for (const step of bfs([fromHash], (n) => sortedForwardNeighbours(graph, n))) {
133
+ if (step.parent !== null && step.incomingEdge !== null) {
134
+ parents.set(step.state, { parent: step.parent, edge: step.incomingEdge });
135
+ }
136
+ if (step.state === toHash) {
137
+ const path: MigrationEdge[] = [];
138
+ let cur = toHash;
139
+ let p = parents.get(cur);
140
+ while (p) {
141
+ path.push(p.edge);
142
+ cur = p.parent;
143
+ p = parents.get(cur);
144
+ }
145
+ path.reverse();
146
+ return path;
147
+ }
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * Find the shortest path from `fromHash` to `toHash` whose edges collectively
155
+ * cover every invariant in `required`. Returns `null` when no such path exists
156
+ * (either `fromHash`→`toHash` is structurally unreachable, or every reachable
157
+ * path leaves at least one required invariant uncovered). When `required` is
158
+ * empty, delegates to `findPath` so the result is byte-identical for that case.
159
+ *
160
+ * Algorithm: BFS over `(node, coveredSubset)` states with state-level dedup.
161
+ * The covered subset is a `Set<string>` of invariant ids; the state's dedup
162
+ * key is `${node}\0${[...covered].sort().join('\0')}`. State keys distinguish
163
+ * distinct `(node, covered)` tuples regardless of node-name length because
164
+ * `\0` cannot appear in any invariant id (validation rejects whitespace and
165
+ * control chars at authoring time).
166
+ *
167
+ * Neighbour ordering when `required ≠ ∅`: edges covering ≥1 still-needed
168
+ * invariant come first, with `labelPriority → createdAt → to → migrationHash`
169
+ * as the secondary key. The heuristic steers BFS toward the satisfying path;
170
+ * correctness (shortest, deterministic) does not depend on it.
171
+ */
172
+ export function findPathWithInvariants(
173
+ graph: MigrationGraph,
174
+ fromHash: string,
175
+ toHash: string,
176
+ required: ReadonlySet<string>,
177
+ ): readonly MigrationEdge[] | null {
178
+ if (required.size === 0) {
179
+ return findPath(graph, fromHash, toHash);
180
+ }
181
+
182
+ interface InvState {
183
+ readonly node: string;
184
+ readonly covered: ReadonlySet<string>;
185
+ }
186
+ // `\0` is a safe segment separator: `validateInvariantId` rejects any id
187
+ // containing whitespace or control characters (NUL is U+0000), and node
188
+ // hashes are hex strings. Distinct `(node, covered)` tuples therefore
189
+ // map to distinct strings. If `validateInvariantId` is ever relaxed,
190
+ // re-confirm dedup correctness here.
191
+ const stateKey = (s: InvState): string => {
192
+ if (s.covered.size === 0) return `${s.node}\0`;
193
+ return `${s.node}\0${[...s.covered].sort().join('\0')}`;
194
+ };
195
+
196
+ const neighbours = (s: InvState): Iterable<{ next: InvState; edge: MigrationEdge }> => {
197
+ const outgoing = graph.forwardChain.get(s.node) ?? [];
198
+ if (outgoing.length === 0) return [];
199
+ return [...outgoing]
200
+ .map((edge) => {
201
+ let useful = false;
202
+ let next: Set<string> | null = null;
203
+ for (const inv of edge.invariants) {
204
+ if (required.has(inv) && !s.covered.has(inv)) {
205
+ if (next === null) next = new Set(s.covered);
206
+ next.add(inv);
207
+ useful = true;
208
+ }
209
+ }
210
+ return { edge, useful, nextCovered: next ?? s.covered };
211
+ })
212
+ .sort((a, b) => {
213
+ if (a.useful !== b.useful) return a.useful ? -1 : 1;
214
+ return compareTieBreak(a.edge, b.edge);
215
+ })
216
+ .map(({ edge, nextCovered }) => ({
217
+ next: { node: edge.to, covered: nextCovered },
218
+ edge,
219
+ }));
220
+ };
221
+
222
+ // Path reconstruction is consumer-side, keyed on stateKey, same shape as
223
+ // findPath's parents map.
224
+ const parents = new Map<string, { parentKey: string; edge: MigrationEdge }>();
225
+ for (const step of bfs<InvState, MigrationEdge>(
226
+ [{ node: fromHash, covered: new Set() }],
227
+ neighbours,
228
+ stateKey,
229
+ )) {
230
+ const curKey = stateKey(step.state);
231
+ if (step.parent !== null && step.incomingEdge !== null) {
232
+ parents.set(curKey, { parentKey: stateKey(step.parent), edge: step.incomingEdge });
233
+ }
234
+ if (step.state.node === toHash && step.state.covered.size === required.size) {
235
+ const path: MigrationEdge[] = [];
236
+ let cur: string | undefined = curKey;
237
+ while (cur !== undefined) {
238
+ const p = parents.get(cur);
239
+ if (!p) break;
240
+ path.push(p.edge);
241
+ cur = p.parentKey;
242
+ }
243
+ path.reverse();
244
+ return path;
245
+ }
246
+ }
247
+
248
+ return null;
249
+ }
250
+
251
+ /**
252
+ * Reverse-BFS from `toHash` over `reverseChain` to collect every node from
253
+ * which `toHash` is reachable (inclusive of `toHash` itself).
254
+ */
255
+ function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<string> {
256
+ const reached = new Set<string>();
257
+ for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) {
258
+ reached.add(step.state);
259
+ }
260
+ return reached;
261
+ }
262
+
263
+ export interface PathDecision {
264
+ readonly selectedPath: readonly MigrationEdge[];
265
+ readonly fromHash: string;
266
+ readonly toHash: string;
267
+ readonly alternativeCount: number;
268
+ readonly tieBreakReasons: readonly string[];
269
+ readonly refName?: string;
270
+ /** The caller-supplied required invariant set, sorted ascending. */
271
+ readonly requiredInvariants: readonly string[];
272
+ /**
273
+ * The subset of `requiredInvariants` actually covered by edges on
274
+ * `selectedPath`. Always a subset of `requiredInvariants` (when the path
275
+ * is satisfying, equal to it); always derived from `selectedPath`.
276
+ */
277
+ readonly satisfiedInvariants: readonly string[];
278
+ }
279
+
280
+ /**
281
+ * Outcome of {@link findPathWithDecision}. The pathfinder distinguishes
282
+ * three cases up front so callers don't re-derive structural reachability:
283
+ *
284
+ * - `ok` — a path covering `required` exists; `decision` carries the
285
+ * selection metadata and per-edge invariants.
286
+ * - `unreachable` — `from`→`to` has no structural path. Mapped by callers
287
+ * to the existing no-path / `NO_TARGET` diagnostic.
288
+ * - `unsatisfiable` — `from`→`to` is structurally reachable but no path
289
+ * covers every required invariant. `structuralPath` is the
290
+ * `findPath(graph, from, to)` result, included so callers don't have to
291
+ * recompute it when raising `MIGRATION.NO_INVARIANT_PATH`. `missing` is
292
+ * the subset of `required` that the structural path does *not* cover —
293
+ * correctly accounts for partial coverage when some required invariants
294
+ * are met by the fallback path. Only emitted when `required` is
295
+ * non-empty.
296
+ */
297
+ export type FindPathOutcome =
298
+ | { readonly kind: 'ok'; readonly decision: PathDecision }
299
+ | { readonly kind: 'unreachable' }
300
+ | {
301
+ readonly kind: 'unsatisfiable';
302
+ readonly structuralPath: readonly MigrationEdge[];
303
+ readonly missing: readonly string[];
304
+ };
305
+
306
+ /**
307
+ * Routing context for {@link findPathWithDecision}. Both fields are optional;
308
+ * `refName` is only used to decorate the resulting `PathDecision` for the
309
+ * JSON envelope, and `required` defaults to an empty set (purely structural
310
+ * routing). They are passed via a single options object so the call sites
311
+ * cannot silently swap two adjacent string parameters.
312
+ */
313
+ export interface FindPathWithDecisionOptions {
314
+ readonly refName?: string;
315
+ readonly required?: ReadonlySet<string>;
316
+ }
317
+
318
+ /**
319
+ * Find the shortest path from `fromHash` to `toHash` and return structured
320
+ * path-decision metadata for machine-readable output. When `required` is
321
+ * non-empty, the returned path is the shortest one whose edges collectively
322
+ * cover every required invariant.
323
+ *
324
+ * The discriminated return type tells the caller *why* a path could not be
325
+ * found, so the CLI can pick the right structured error without re-running
326
+ * a structural BFS.
327
+ */
328
+ export function findPathWithDecision(
329
+ graph: MigrationGraph,
330
+ fromHash: string,
331
+ toHash: string,
332
+ options: FindPathWithDecisionOptions = {},
333
+ ): FindPathOutcome {
334
+ const { refName, required = new Set<string>() } = options;
335
+ const requiredInvariants = [...required].sort();
336
+
337
+ if (fromHash === toHash && required.size === 0) {
338
+ return {
339
+ kind: 'ok',
340
+ decision: {
341
+ selectedPath: [],
342
+ fromHash,
343
+ toHash,
344
+ alternativeCount: 0,
345
+ tieBreakReasons: [],
346
+ requiredInvariants,
347
+ satisfiedInvariants: [],
348
+ ...ifDefined('refName', refName),
349
+ },
350
+ };
351
+ }
352
+
353
+ const path = findPathWithInvariants(graph, fromHash, toHash, required);
354
+ if (!path) {
355
+ if (required.size === 0) {
356
+ return { kind: 'unreachable' };
357
+ }
358
+ const structural = findPath(graph, fromHash, toHash);
359
+ if (structural === null) {
360
+ return { kind: 'unreachable' };
361
+ }
362
+ const coveredByStructural = new Set<string>();
363
+ for (const edge of structural) {
364
+ for (const inv of edge.invariants) {
365
+ if (required.has(inv)) coveredByStructural.add(inv);
366
+ }
367
+ }
368
+ const missing = requiredInvariants.filter((id) => !coveredByStructural.has(id));
369
+ return { kind: 'unsatisfiable', structuralPath: structural, missing };
370
+ }
371
+
372
+ const satisfiedInvariants = computeSatisfiedInvariants(required, path);
373
+
374
+ // Single reverse BFS marks every node from which `toHash` is reachable.
375
+ // Replaces a per-edge `findPath(e.to, toHash)` call inside the loop below,
376
+ // which made the whole function O(|path| · (V + E)) instead of O(V + E).
377
+ const reachesTarget = collectNodesReachingTarget(graph, toHash);
378
+ const coveragePrefixes = requiredCoveragePrefixes(required, path);
379
+
380
+ const tieBreakReasons: string[] = [];
381
+ let alternativeCount = 0;
382
+
383
+ for (const [i, edge] of path.entries()) {
384
+ const outgoing = graph.forwardChain.get(edge.from);
385
+ if (!outgoing || outgoing.length <= 1) continue;
386
+ const reachable = outgoing.filter((e) => reachesTarget.has(e.to));
387
+ if (reachable.length <= 1) continue;
388
+
389
+ let comparisonPool: readonly MigrationEdge[] = reachable;
390
+ if (required.size > 0) {
391
+ // coveragePrefixes is built one-per-edge from path, so the index is
392
+ // always in range here; the explicit guard keeps the type narrowed
393
+ // without a non-null assertion.
394
+ const prefixSet = coveragePrefixes[i];
395
+ if (prefixSet === undefined) continue;
396
+ comparisonPool = invariantViableAlternativesAtStep(required, prefixSet, reachable);
397
+ }
398
+
399
+ alternativeCount += reachable.length - 1;
400
+ const sorted = sortedNeighbors(reachable);
401
+ if (sorted[0]?.migrationHash !== edge.migrationHash) continue;
402
+ if (!reachable.some((e) => e.migrationHash !== edge.migrationHash)) continue;
403
+
404
+ const sortedViable = sortedNeighbors(comparisonPool);
405
+ if (
406
+ sortedViable.length > 1 &&
407
+ sortedViable[0]?.migrationHash === edge.migrationHash &&
408
+ sortedViable.some((e) => e.migrationHash !== edge.migrationHash)
409
+ ) {
410
+ tieBreakReasons.push(
411
+ `at ${edge.from}: ${comparisonPool.length} candidates, selected by tie-break`,
412
+ );
413
+ }
414
+ }
415
+
416
+ return {
417
+ kind: 'ok',
418
+ decision: {
419
+ selectedPath: path,
420
+ fromHash,
421
+ toHash,
422
+ alternativeCount,
423
+ tieBreakReasons,
424
+ requiredInvariants,
425
+ satisfiedInvariants,
426
+ ...ifDefined('refName', refName),
427
+ },
428
+ };
429
+ }
430
+
431
+ function computeSatisfiedInvariants(
432
+ required: ReadonlySet<string>,
433
+ path: readonly MigrationEdge[],
434
+ ): readonly string[] {
435
+ if (required.size === 0) return [];
436
+ const covered = new Set<string>();
437
+ for (const edge of path) {
438
+ for (const inv of edge.invariants) {
439
+ if (required.has(inv)) covered.add(inv);
440
+ }
441
+ }
442
+ return [...covered].sort();
443
+ }
444
+
445
+ /**
446
+ * For each edge on path, invariant coverage accumulated from earlier edges only —
447
+ * `(required ∩ ∪_{j<i} path[j].invariants)` represented as cumulative set along `required`,
448
+ * keyed as "full set of required ids satisfied before taking path[i]".
449
+ */
450
+ function requiredCoveragePrefixes(
451
+ required: ReadonlySet<string>,
452
+ path: readonly MigrationEdge[],
453
+ ): readonly ReadonlySet<string>[] {
454
+ const prefixes: ReadonlySet<string>[] = [];
455
+ const acc = new Set<string>();
456
+ for (const edge of path) {
457
+ prefixes.push(new Set(acc));
458
+ for (const inv of edge.invariants) {
459
+ if (required.has(inv)) acc.add(inv);
460
+ }
461
+ }
462
+ return prefixes;
463
+ }
464
+
465
+ function invariantViableAlternativesAtStep(
466
+ required: ReadonlySet<string>,
467
+ coverageBeforeTakingEdge: ReadonlySet<string>,
468
+ outgoing: readonly MigrationEdge[],
469
+ ): readonly MigrationEdge[] {
470
+ if (required.size === 0) return [...outgoing];
471
+ return outgoing.filter((e) =>
472
+ [...required].every((id) => coverageBeforeTakingEdge.has(id) || e.invariants.includes(id)),
473
+ );
474
+ }
475
+
476
+ /**
477
+ * Walk ancestors of each branch tip back to find the last node
478
+ * that appears on all paths. Returns `fromHash` if no shared ancestor is found.
479
+ */
480
+ function findDivergencePoint(
481
+ graph: MigrationGraph,
482
+ fromHash: string,
483
+ leaves: readonly string[],
484
+ ): string {
485
+ const ancestorSets = leaves.map((leaf) => {
486
+ const ancestors = new Set<string>();
487
+ for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) {
488
+ ancestors.add(step.state);
489
+ }
490
+ return ancestors;
491
+ });
492
+
493
+ const commonAncestors = [...(ancestorSets[0] ?? [])].filter((node) =>
494
+ ancestorSets.every((s) => s.has(node)),
495
+ );
496
+
497
+ let deepest = fromHash;
498
+ let deepestDepth = -1;
499
+ for (const ancestor of commonAncestors) {
500
+ const path = findPath(graph, fromHash, ancestor);
501
+ const depth = path ? path.length : 0;
502
+ if (depth > deepestDepth) {
503
+ deepestDepth = depth;
504
+ deepest = ancestor;
505
+ }
506
+ }
507
+ return deepest;
508
+ }
509
+
510
+ /**
511
+ * Find all branch tips (nodes with no outgoing edges) reachable from
512
+ * `fromHash` via forward edges.
513
+ */
514
+ export function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {
515
+ const leaves: string[] = [];
516
+ for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) {
517
+ if (!graph.forwardChain.get(step.state)?.length) {
518
+ leaves.push(step.state);
519
+ }
520
+ }
521
+ return leaves;
522
+ }
523
+
524
+ /**
525
+ * Find the target contract hash of the migration graph reachable from
526
+ * EMPTY_CONTRACT_HASH. Returns `null` for a graph that has no target
527
+ * state (either empty, or containing only the root with no outgoing
528
+ * edges). Throws NO_INITIAL_MIGRATION if the graph has nodes but none
529
+ * originate from the empty hash, and AMBIGUOUS_TARGET if multiple
530
+ * branch tips exist.
531
+ */
532
+ export function findLeaf(graph: MigrationGraph): string | null {
533
+ if (graph.nodes.size === 0) {
534
+ return null;
535
+ }
536
+
537
+ if (!graph.nodes.has(EMPTY_CONTRACT_HASH)) {
538
+ throw errorNoInitialMigration([...graph.nodes]);
539
+ }
540
+
541
+ const leaves = findReachableLeaves(graph, EMPTY_CONTRACT_HASH);
542
+
543
+ if (leaves.length === 0) {
544
+ const reachable = [...graph.nodes].filter((n) => n !== EMPTY_CONTRACT_HASH);
545
+ if (reachable.length > 0) {
546
+ throw errorNoTarget(reachable);
547
+ }
548
+ return null;
549
+ }
550
+
551
+ if (leaves.length > 1) {
552
+ const divergencePoint = findDivergencePoint(graph, EMPTY_CONTRACT_HASH, leaves);
553
+ const branches = leaves.map((tip) => {
554
+ const path = findPath(graph, divergencePoint, tip);
555
+ return {
556
+ tip,
557
+ edges: (path ?? []).map((e) => ({ dirName: e.dirName, from: e.from, to: e.to })),
558
+ };
559
+ });
560
+ throw errorAmbiguousTarget(leaves, { divergencePoint, branches });
561
+ }
562
+
563
+ // biome-ignore lint/style/noNonNullAssertion: leaves.length is neither 0 nor >1 per the branches above, so exactly one leaf remains
564
+ return leaves[0]!;
565
+ }
566
+
567
+ /**
568
+ * Find the latest migration entry by traversing from EMPTY_CONTRACT_HASH
569
+ * to the single target. Returns null for an empty graph.
570
+ * Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
571
+ */
572
+ export function findLatestMigration(graph: MigrationGraph): MigrationEdge | null {
573
+ const leafHash = findLeaf(graph);
574
+ if (leafHash === null) return null;
575
+
576
+ const path = findPath(graph, EMPTY_CONTRACT_HASH, leafHash);
577
+ return path?.at(-1) ?? null;
578
+ }
579
+
580
+ export function detectCycles(graph: MigrationGraph): readonly string[][] {
581
+ const WHITE = 0;
582
+ const GRAY = 1;
583
+ const BLACK = 2;
584
+
585
+ const color = new Map<string, number>();
586
+ const parentMap = new Map<string, string | null>();
587
+ const cycles: string[][] = [];
588
+
589
+ for (const node of graph.nodes) {
590
+ color.set(node, WHITE);
591
+ }
592
+
593
+ // Iterative three-color DFS. A frame is (node, outgoing edges, next-index).
594
+ interface Frame {
595
+ node: string;
596
+ outgoing: readonly MigrationEdge[];
597
+ index: number;
598
+ }
599
+ const stack: Frame[] = [];
600
+
601
+ function pushFrame(u: string): void {
602
+ color.set(u, GRAY);
603
+ stack.push({ node: u, outgoing: graph.forwardChain.get(u) ?? [], index: 0 });
604
+ }
605
+
606
+ for (const root of graph.nodes) {
607
+ if (color.get(root) !== WHITE) continue;
608
+ parentMap.set(root, null);
609
+ pushFrame(root);
610
+
611
+ while (stack.length > 0) {
612
+ // biome-ignore lint/style/noNonNullAssertion: stack.length > 0 should guarantee that this cannot be undefined
613
+ const frame = stack[stack.length - 1]!;
614
+ if (frame.index >= frame.outgoing.length) {
615
+ color.set(frame.node, BLACK);
616
+ stack.pop();
617
+ continue;
618
+ }
619
+ // biome-ignore lint/style/noNonNullAssertion: the early-continue above guarantees frame.index < frame.outgoing.length here, so this is defined
620
+ const edge = frame.outgoing[frame.index++]!;
621
+ const v = edge.to;
622
+ const vColor = color.get(v);
623
+ if (vColor === GRAY) {
624
+ const cycle: string[] = [v];
625
+ let cur = frame.node;
626
+ while (cur !== v) {
627
+ cycle.push(cur);
628
+ cur = parentMap.get(cur) ?? v;
629
+ }
630
+ cycle.reverse();
631
+ cycles.push(cycle);
632
+ } else if (vColor === WHITE) {
633
+ parentMap.set(v, frame.node);
634
+ pushFrame(v);
635
+ }
636
+ }
637
+ }
638
+
639
+ return cycles;
640
+ }
641
+
642
+ export function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {
643
+ if (graph.nodes.size === 0) return [];
644
+
645
+ const reachable = new Set<string>();
646
+ const startNodes: string[] = [];
647
+
648
+ if (graph.forwardChain.has(EMPTY_CONTRACT_HASH)) {
649
+ startNodes.push(EMPTY_CONTRACT_HASH);
650
+ } else {
651
+ const allTargets = new Set<string>();
652
+ for (const edges of graph.forwardChain.values()) {
653
+ for (const edge of edges) {
654
+ allTargets.add(edge.to);
655
+ }
656
+ }
657
+ for (const node of graph.nodes) {
658
+ if (!allTargets.has(node)) {
659
+ startNodes.push(node);
660
+ }
661
+ }
662
+ }
663
+
664
+ for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) {
665
+ reachable.add(step.state);
666
+ }
667
+
668
+ const orphans: MigrationEdge[] = [];
669
+ for (const [from, migrations] of graph.forwardChain) {
670
+ if (!reachable.has(from)) {
671
+ orphans.push(...migrations);
672
+ }
673
+ }
674
+
675
+ return orphans;
676
+ }
@@ -0,0 +1,11 @@
1
+ import { type } from 'arktype';
2
+
3
+ export const MigrationOpSchema = type({
4
+ id: 'string',
5
+ label: 'string',
6
+ operationClass: "'additive' | 'widening' | 'destructive' | 'data'",
7
+ 'invariantId?': 'string',
8
+ });
9
+
10
+ // Intentionally shallow: operation-specific payload validation is owned by planner/runner layers.
11
+ export const MigrationOpsSchema = MigrationOpSchema.array();
package/src/package.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type {
2
+ MigrationPackage,
3
+ MigrationPlanOperation,
4
+ } from '@prisma-next/framework-components/control';
5
+
6
+ export type MigrationOps = readonly MigrationPlanOperation[];
7
+
8
+ /**
9
+ * Augmented form of the canonical {@link MigrationPackage} returned by
10
+ * the on-disk readers (`readMigrationPackage`, `readMigrationsDir`).
11
+ * Adds `dirPath` — the absolute path the package was loaded from — so
12
+ * downstream diagnostics can point operators at a concrete directory.
13
+ *
14
+ * Holding an `OnDiskMigrationPackage` value implies the loader verified
15
+ * the package's integrity (hash recomputation against the stored
16
+ * `migrationHash`); the canonical structural shape carries no such
17
+ * guarantee on its own.
18
+ */
19
+ export interface OnDiskMigrationPackage extends MigrationPackage {
20
+ readonly dirPath: string;
21
+ }