@prisma-next/migration-tools 0.4.0-dev.9 → 0.5.0-dev.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 (51) hide show
  1. package/README.md +1 -1
  2. package/dist/{attestation-DnebS4XZ.mjs → attestation-DtF8tEOM.mjs} +24 -23
  3. package/dist/attestation-DtF8tEOM.mjs.map +1 -0
  4. package/dist/{errors-C_XuSbX7.mjs → errors-BKbRGCJM.mjs} +9 -2
  5. package/dist/errors-BKbRGCJM.mjs.map +1 -0
  6. package/dist/exports/attestation.d.mts +20 -6
  7. package/dist/exports/attestation.d.mts.map +1 -1
  8. package/dist/exports/attestation.mjs +3 -3
  9. package/dist/exports/dag.d.mts +8 -6
  10. package/dist/exports/dag.d.mts.map +1 -1
  11. package/dist/exports/dag.mjs +181 -107
  12. package/dist/exports/dag.mjs.map +1 -1
  13. package/dist/exports/io.d.mts +16 -13
  14. package/dist/exports/io.d.mts.map +1 -1
  15. package/dist/exports/io.mjs +2 -2
  16. package/dist/exports/migration-ts.d.mts +15 -21
  17. package/dist/exports/migration-ts.d.mts.map +1 -1
  18. package/dist/exports/migration-ts.mjs +28 -36
  19. package/dist/exports/migration-ts.mjs.map +1 -1
  20. package/dist/exports/migration.d.mts +48 -18
  21. package/dist/exports/migration.d.mts.map +1 -1
  22. package/dist/exports/migration.mjs +75 -85
  23. package/dist/exports/migration.mjs.map +1 -1
  24. package/dist/exports/refs.mjs +1 -1
  25. package/dist/exports/types.d.mts +2 -2
  26. package/dist/exports/types.mjs +2 -16
  27. package/dist/{io-Cun81AIZ.mjs → io-CCnYsUHU.mjs} +18 -22
  28. package/dist/io-CCnYsUHU.mjs.map +1 -0
  29. package/dist/types-DyGXcWWp.d.mts +71 -0
  30. package/dist/types-DyGXcWWp.d.mts.map +1 -0
  31. package/package.json +5 -4
  32. package/src/attestation.ts +34 -26
  33. package/src/dag.ts +140 -154
  34. package/src/errors.ts +8 -0
  35. package/src/exports/attestation.ts +2 -1
  36. package/src/exports/io.ts +1 -1
  37. package/src/exports/migration-ts.ts +1 -1
  38. package/src/exports/migration.ts +8 -1
  39. package/src/exports/types.ts +2 -8
  40. package/src/graph-ops.ts +65 -0
  41. package/src/io.ts +23 -24
  42. package/src/migration-base.ts +99 -101
  43. package/src/migration-ts.ts +28 -50
  44. package/src/queue.ts +37 -0
  45. package/src/types.ts +15 -55
  46. package/dist/attestation-DnebS4XZ.mjs.map +0 -1
  47. package/dist/errors-C_XuSbX7.mjs.map +0 -1
  48. package/dist/exports/types.mjs.map +0 -1
  49. package/dist/io-Cun81AIZ.mjs.map +0 -1
  50. package/dist/types-D2uX4ql7.d.mts +0 -100
  51. package/dist/types-D2uX4ql7.d.mts.map +0 -1
package/src/dag.ts CHANGED
@@ -7,9 +7,30 @@ import {
7
7
  errorNoTarget,
8
8
  errorSameSourceAndTarget,
9
9
  } from './errors';
10
- import type { AttestedMigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
10
+ import { bfs } from './graph-ops';
11
+ import type { MigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
11
12
 
12
- export function reconstructGraph(packages: readonly AttestedMigrationBundle[]): MigrationGraph {
13
+ /** Forward-edge neighbours for BFS: edge `e` from `n` visits `e.to` next. */
14
+ function forwardNeighbours(graph: MigrationGraph, node: string) {
15
+ return (graph.forwardChain.get(node) ?? []).map((edge) => ({ next: edge.to, edge }));
16
+ }
17
+
18
+ /** Reverse-edge neighbours for BFS: edge `e` from `n` visits `e.from` next. */
19
+ function reverseNeighbours(graph: MigrationGraph, node: string) {
20
+ return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
21
+ }
22
+
23
+ function appendEdge(
24
+ map: Map<string, MigrationChainEntry[]>,
25
+ key: string,
26
+ entry: MigrationChainEntry,
27
+ ): void {
28
+ const bucket = map.get(key);
29
+ if (bucket) bucket.push(entry);
30
+ else map.set(key, [entry]);
31
+ }
32
+
33
+ export function reconstructGraph(packages: readonly MigrationBundle[]): MigrationGraph {
13
34
  const nodes = new Set<string>();
14
35
  const forwardChain = new Map<string, MigrationChainEntry[]>();
15
36
  const reverseChain = new Map<string, MigrationChainEntry[]>();
@@ -34,31 +55,24 @@ export function reconstructGraph(packages: readonly AttestedMigrationBundle[]):
34
55
  labels: pkg.manifest.labels,
35
56
  };
36
57
 
37
- if (migration.migrationId !== null) {
38
- if (migrationById.has(migration.migrationId)) {
39
- throw errorDuplicateMigrationId(migration.migrationId);
40
- }
41
- migrationById.set(migration.migrationId, migration);
42
- }
43
-
44
- const fwd = forwardChain.get(from);
45
- if (fwd) {
46
- fwd.push(migration);
47
- } else {
48
- forwardChain.set(from, [migration]);
58
+ if (migrationById.has(migration.migrationId)) {
59
+ throw errorDuplicateMigrationId(migration.migrationId);
49
60
  }
61
+ migrationById.set(migration.migrationId, migration);
50
62
 
51
- const rev = reverseChain.get(to);
52
- if (rev) {
53
- rev.push(migration);
54
- } else {
55
- reverseChain.set(to, [migration]);
56
- }
63
+ appendEdge(forwardChain, from, migration);
64
+ appendEdge(reverseChain, to, migration);
57
65
  }
58
66
 
59
67
  return { nodes, forwardChain, reverseChain, migrationById };
60
68
  }
61
69
 
70
+ // ---------------------------------------------------------------------------
71
+ // Deterministic tie-breaking for BFS neighbour order.
72
+ // Used by `findPath` and `findPathWithDecision` only; not a general-purpose
73
+ // utility. Ordering: label priority → createdAt → to → migrationId.
74
+ // ---------------------------------------------------------------------------
75
+
62
76
  const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
63
77
 
64
78
  function labelPriority(labels: readonly string[]): number {
@@ -70,16 +84,25 @@ function labelPriority(labels: readonly string[]): number {
70
84
  return best;
71
85
  }
72
86
 
87
+ function compareTieBreak(a: MigrationChainEntry, b: MigrationChainEntry): number {
88
+ const lp = labelPriority(a.labels) - labelPriority(b.labels);
89
+ if (lp !== 0) return lp;
90
+ const ca = a.createdAt.localeCompare(b.createdAt);
91
+ if (ca !== 0) return ca;
92
+ const tc = a.to.localeCompare(b.to);
93
+ if (tc !== 0) return tc;
94
+ return a.migrationId.localeCompare(b.migrationId);
95
+ }
96
+
73
97
  function sortedNeighbors(edges: readonly MigrationChainEntry[]): readonly MigrationChainEntry[] {
74
- return [...edges].sort((a, b) => {
75
- const lp = labelPriority(a.labels) - labelPriority(b.labels);
76
- if (lp !== 0) return lp;
77
- const ca = a.createdAt.localeCompare(b.createdAt);
78
- if (ca !== 0) return ca;
79
- const tc = a.to.localeCompare(b.to);
80
- if (tc !== 0) return tc;
81
- return (a.migrationId ?? '').localeCompare(b.migrationId ?? '');
82
- });
98
+ return [...edges].sort(compareTieBreak);
99
+ }
100
+
101
+ /** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
102
+ function bfsOrdering(
103
+ items: readonly { next: string; edge: MigrationChainEntry }[],
104
+ ): readonly { next: string; edge: MigrationChainEntry }[] {
105
+ return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
83
106
  }
84
107
 
85
108
  /**
@@ -97,44 +120,40 @@ export function findPath(
97
120
  ): readonly MigrationChainEntry[] | null {
98
121
  if (fromHash === toHash) return [];
99
122
 
100
- const visited = new Set<string>();
101
- const parent = new Map<string, { node: string; edge: MigrationChainEntry }>();
102
- const queue: string[] = [fromHash];
103
- visited.add(fromHash);
104
-
105
- while (queue.length > 0) {
106
- const current = queue.shift();
107
- if (current === undefined) break;
108
-
109
- if (current === toHash) {
123
+ const parents = new Map<string, { parent: string; edge: MigrationChainEntry }>();
124
+ for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n), bfsOrdering)) {
125
+ if (step.parent !== null && step.incomingEdge !== null) {
126
+ parents.set(step.node, { parent: step.parent, edge: step.incomingEdge });
127
+ }
128
+ if (step.node === toHash) {
110
129
  const path: MigrationChainEntry[] = [];
111
- let node = toHash;
112
- let entry = parent.get(node);
113
- while (entry) {
114
- const { node: prev, edge } = entry;
115
- path.push(edge);
116
- node = prev;
117
- entry = parent.get(node);
130
+ let cur = toHash;
131
+ let p = parents.get(cur);
132
+ while (p) {
133
+ path.push(p.edge);
134
+ cur = p.parent;
135
+ p = parents.get(cur);
118
136
  }
119
137
  path.reverse();
120
138
  return path;
121
139
  }
122
-
123
- const outgoing = graph.forwardChain.get(current);
124
- if (!outgoing) continue;
125
-
126
- for (const edge of sortedNeighbors(outgoing)) {
127
- if (!visited.has(edge.to)) {
128
- visited.add(edge.to);
129
- parent.set(edge.to, { node: current, edge });
130
- queue.push(edge.to);
131
- }
132
- }
133
140
  }
134
141
 
135
142
  return null;
136
143
  }
137
144
 
145
+ /**
146
+ * Reverse-BFS from `toHash` over `reverseChain` to collect every node from
147
+ * which `toHash` is reachable (inclusive of `toHash` itself).
148
+ */
149
+ function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<string> {
150
+ const reached = new Set<string>();
151
+ for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) {
152
+ reached.add(step.node);
153
+ }
154
+ return reached;
155
+ }
156
+
138
157
  export interface PathDecision {
139
158
  readonly selectedPath: readonly MigrationChainEntry[];
140
159
  readonly fromHash: string;
@@ -168,16 +187,18 @@ export function findPathWithDecision(
168
187
  const path = findPath(graph, fromHash, toHash);
169
188
  if (!path) return null;
170
189
 
190
+ // Single reverse BFS marks every node from which `toHash` is reachable.
191
+ // Replaces a per-edge `findPath(e.to, toHash)` call inside the loop below,
192
+ // which made the whole function O(|path| · (V + E)) instead of O(V + E).
193
+ const reachesTarget = collectNodesReachingTarget(graph, toHash);
194
+
171
195
  const tieBreakReasons: string[] = [];
172
196
  let alternativeCount = 0;
173
197
 
174
198
  for (const edge of path) {
175
199
  const outgoing = graph.forwardChain.get(edge.from);
176
200
  if (outgoing && outgoing.length > 1) {
177
- const reachable = outgoing.filter((e) => {
178
- const pathFromE = findPath(graph, e.to, toHash);
179
- return pathFromE !== null || e.to === toHash;
180
- });
201
+ const reachable = outgoing.filter((e) => reachesTarget.has(e.to));
181
202
  if (reachable.length > 1) {
182
203
  alternativeCount += reachable.length - 1;
183
204
  const sorted = sortedNeighbors(reachable);
@@ -213,17 +234,8 @@ function findDivergencePoint(
213
234
  ): string {
214
235
  const ancestorSets = leaves.map((leaf) => {
215
236
  const ancestors = new Set<string>();
216
- const queue = [leaf];
217
- while (queue.length > 0) {
218
- const current = queue.shift() as string;
219
- if (ancestors.has(current)) continue;
220
- ancestors.add(current);
221
- const incoming = graph.reverseChain.get(current);
222
- if (incoming) {
223
- for (const edge of incoming) {
224
- queue.push(edge.from);
225
- }
226
- }
237
+ for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) {
238
+ ancestors.add(step.node);
227
239
  }
228
240
  return ancestors;
229
241
  });
@@ -250,40 +262,26 @@ function findDivergencePoint(
250
262
  * `fromHash` via forward edges.
251
263
  */
252
264
  export function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {
253
- const visited = new Set<string>();
254
- const queue: string[] = [fromHash];
255
- visited.add(fromHash);
256
265
  const leaves: string[] = [];
257
-
258
- while (queue.length > 0) {
259
- const current = queue.shift();
260
- if (current === undefined) break;
261
- const outgoing = graph.forwardChain.get(current);
262
-
263
- if (!outgoing || outgoing.length === 0) {
264
- leaves.push(current);
265
- } else {
266
- for (const edge of outgoing) {
267
- if (!visited.has(edge.to)) {
268
- visited.add(edge.to);
269
- queue.push(edge.to);
270
- }
271
- }
266
+ for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) {
267
+ if (!graph.forwardChain.get(step.node)?.length) {
268
+ leaves.push(step.node);
272
269
  }
273
270
  }
274
-
275
271
  return leaves;
276
272
  }
277
273
 
278
274
  /**
279
275
  * Find the target contract hash of the migration graph reachable from
280
- * EMPTY_CONTRACT_HASH. Throws NO_INITIAL_MIGRATION if the graph has
281
- * nodes but none originate from the empty hash.
282
- * Throws AMBIGUOUS_TARGET if multiple branch tips exist.
276
+ * EMPTY_CONTRACT_HASH. Returns `null` for a graph that has no target
277
+ * state (either empty, or containing only the root with no outgoing
278
+ * edges). Throws NO_INITIAL_MIGRATION if the graph has nodes but none
279
+ * originate from the empty hash, and AMBIGUOUS_TARGET if multiple
280
+ * branch tips exist.
283
281
  */
284
- export function findLeaf(graph: MigrationGraph): string {
282
+ export function findLeaf(graph: MigrationGraph): string | null {
285
283
  if (graph.nodes.size === 0) {
286
- return EMPTY_CONTRACT_HASH;
284
+ return null;
287
285
  }
288
286
 
289
287
  if (!graph.nodes.has(EMPTY_CONTRACT_HASH)) {
@@ -297,7 +295,7 @@ export function findLeaf(graph: MigrationGraph): string {
297
295
  if (reachable.length > 0) {
298
296
  throw errorNoTarget(reachable);
299
297
  }
300
- return EMPTY_CONTRACT_HASH;
298
+ return null;
301
299
  }
302
300
 
303
301
  if (leaves.length > 1) {
@@ -312,8 +310,8 @@ export function findLeaf(graph: MigrationGraph): string {
312
310
  throw errorAmbiguousTarget(leaves, { divergencePoint, branches });
313
311
  }
314
312
 
315
- const leaf = leaves[0];
316
- return leaf !== undefined ? leaf : EMPTY_CONTRACT_HASH;
313
+ // biome-ignore lint/style/noNonNullAssertion: leaves.length is neither 0 nor >1 per the branches above, so exactly one leaf remains
314
+ return leaves[0]!;
317
315
  }
318
316
 
319
317
  /**
@@ -322,21 +320,11 @@ export function findLeaf(graph: MigrationGraph): string {
322
320
  * Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
323
321
  */
324
322
  export function findLatestMigration(graph: MigrationGraph): MigrationChainEntry | null {
325
- if (graph.nodes.size === 0) {
326
- return null;
327
- }
328
-
329
323
  const leafHash = findLeaf(graph);
330
- if (leafHash === EMPTY_CONTRACT_HASH) {
331
- return null;
332
- }
324
+ if (leafHash === null) return null;
333
325
 
334
326
  const path = findPath(graph, EMPTY_CONTRACT_HASH, leafHash);
335
- if (!path || path.length === 0) {
336
- return null;
337
- }
338
-
339
- return path[path.length - 1] ?? null;
327
+ return path?.at(-1) ?? null;
340
328
  }
341
329
 
342
330
  export function detectCycles(graph: MigrationGraph): readonly string[][] {
@@ -352,37 +340,50 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
352
340
  color.set(node, WHITE);
353
341
  }
354
342
 
355
- function dfs(u: string): void {
343
+ // Iterative three-color DFS. A frame is (node, outgoing edges, next-index).
344
+ interface Frame {
345
+ node: string;
346
+ outgoing: readonly MigrationChainEntry[];
347
+ index: number;
348
+ }
349
+ const stack: Frame[] = [];
350
+
351
+ function pushFrame(u: string): void {
356
352
  color.set(u, GRAY);
353
+ stack.push({ node: u, outgoing: graph.forwardChain.get(u) ?? [], index: 0 });
354
+ }
357
355
 
358
- const outgoing = graph.forwardChain.get(u);
359
- if (outgoing) {
360
- for (const edge of outgoing) {
361
- const v = edge.to;
362
- if (color.get(v) === GRAY) {
363
- const cycle: string[] = [v];
364
- let cur = u;
365
- while (cur !== v) {
366
- cycle.push(cur);
367
- cur = parentMap.get(cur) ?? v;
368
- }
369
- cycle.reverse();
370
- cycles.push(cycle);
371
- } else if (color.get(v) === WHITE) {
372
- parentMap.set(v, u);
373
- dfs(v);
356
+ for (const root of graph.nodes) {
357
+ if (color.get(root) !== WHITE) continue;
358
+ parentMap.set(root, null);
359
+ pushFrame(root);
360
+
361
+ while (stack.length > 0) {
362
+ // biome-ignore lint/style/noNonNullAssertion: stack.length > 0 should guarantee that this cannot be undefined
363
+ const frame = stack[stack.length - 1]!;
364
+ if (frame.index >= frame.outgoing.length) {
365
+ color.set(frame.node, BLACK);
366
+ stack.pop();
367
+ continue;
368
+ }
369
+ // biome-ignore lint/style/noNonNullAssertion: the early-continue above guarantees frame.index < frame.outgoing.length here, so this is defined
370
+ const edge = frame.outgoing[frame.index++]!;
371
+ const v = edge.to;
372
+ const vColor = color.get(v);
373
+ if (vColor === GRAY) {
374
+ const cycle: string[] = [v];
375
+ let cur = frame.node;
376
+ while (cur !== v) {
377
+ cycle.push(cur);
378
+ cur = parentMap.get(cur) ?? v;
374
379
  }
380
+ cycle.reverse();
381
+ cycles.push(cycle);
382
+ } else if (vColor === WHITE) {
383
+ parentMap.set(v, frame.node);
384
+ pushFrame(v);
375
385
  }
376
386
  }
377
-
378
- color.set(u, BLACK);
379
- }
380
-
381
- for (const node of graph.nodes) {
382
- if (color.get(node) === WHITE) {
383
- parentMap.set(node, null);
384
- dfs(node);
385
- }
386
387
  }
387
388
 
388
389
  return cycles;
@@ -410,23 +411,8 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEnt
410
411
  }
411
412
  }
412
413
 
413
- const queue = [...startNodes];
414
- for (const hash of queue) {
415
- reachable.add(hash);
416
- }
417
-
418
- while (queue.length > 0) {
419
- const node = queue.shift();
420
- if (node === undefined) break;
421
- const outgoing = graph.forwardChain.get(node);
422
- if (!outgoing) continue;
423
-
424
- for (const migration of outgoing) {
425
- if (!reachable.has(migration.to)) {
426
- reachable.add(migration.to);
427
- queue.push(migration.to);
428
- }
429
- }
414
+ for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) {
415
+ reachable.add(step.node);
430
416
  }
431
417
 
432
418
  const orphans: MigrationChainEntry[] = [];
package/src/errors.ts CHANGED
@@ -84,6 +84,14 @@ export function errorInvalidSlug(slug: string): MigrationToolsError {
84
84
  });
85
85
  }
86
86
 
87
+ export function errorInvalidDestName(destName: string): MigrationToolsError {
88
+ return new MigrationToolsError('MIGRATION.INVALID_DEST_NAME', 'Invalid copy destination name', {
89
+ why: `The destination name "${destName}" must be a single path segment (no ".." or directory separators).`,
90
+ fix: 'Use a simple file name such as "contract.json" for each destination in the copy list.',
91
+ details: { destName },
92
+ });
93
+ }
94
+
87
95
  export function errorSameSourceAndTarget(dirName: string, hash: string): MigrationToolsError {
88
96
  return new MigrationToolsError(
89
97
  'MIGRATION.SAME_SOURCE_AND_TARGET',
@@ -1 +1,2 @@
1
- export { attestMigration, computeMigrationId, verifyMigration } from '../attestation';
1
+ export type { VerifyResult } from '../attestation';
2
+ export { computeMigrationId, verifyMigration, verifyMigrationBundle } from '../attestation';
package/src/exports/io.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export {
2
- copyContractToMigrationDir,
2
+ copyFilesWithRename,
3
3
  formatMigrationDirName,
4
4
  readMigrationPackage,
5
5
  readMigrationsDir,
@@ -1,3 +1,3 @@
1
- export { evaluateMigrationTs, hasMigrationTs, writeMigrationTs } from '../migration-ts';
1
+ export { hasMigrationTs, writeMigrationTs } from '../migration-ts';
2
2
  export type { ScaffoldRuntime } from '../runtime-detection';
3
3
  export { detectScaffoldRuntime, shebangLineFor } from '../runtime-detection';
@@ -1 +1,8 @@
1
- export { Migration, type MigrationMeta } from '../migration-base';
1
+ export {
2
+ buildMigrationArtifacts,
3
+ isDirectEntrypoint,
4
+ Migration,
5
+ type MigrationArtifacts,
6
+ type MigrationMeta,
7
+ printMigrationHelp,
8
+ } from '../migration-base';
@@ -1,16 +1,10 @@
1
1
  export { MigrationToolsError } from '../errors';
2
2
  export type {
3
- AttestedMigrationBundle,
4
- AttestedMigrationManifest,
5
- BaseMigrationBundle,
6
- BaseMigrationBundle as MigrationBundle,
7
- BaseMigrationBundle as MigrationPackage,
8
- DraftMigrationBundle,
9
- DraftMigrationManifest,
3
+ MigrationBundle,
4
+ MigrationBundle as MigrationPackage,
10
5
  MigrationChainEntry,
11
6
  MigrationGraph,
12
7
  MigrationHints,
13
8
  MigrationManifest,
14
9
  MigrationOps,
15
10
  } from '../types';
16
- export { isAttested, isDraft } from '../types';
@@ -0,0 +1,65 @@
1
+ import { Queue } from './queue';
2
+
3
+ /**
4
+ * One step of a BFS traversal.
5
+ *
6
+ * `parent` and `incomingEdge` are `null` for start nodes — they were not
7
+ * reached via any edge. For every other node they record the node and edge
8
+ * by which this node was first reached.
9
+ */
10
+ export interface BfsStep<E> {
11
+ readonly node: string;
12
+ readonly parent: string | null;
13
+ readonly incomingEdge: E | null;
14
+ }
15
+
16
+ /**
17
+ * Generic breadth-first traversal.
18
+ *
19
+ * Direction (forward/reverse) is expressed by the caller's `neighbours`
20
+ * closure: return `{ next, edge }` pairs where `next` is the node to visit
21
+ * next and `edge` is the edge that connects them. Callers that don't need
22
+ * path reconstruction can ignore the `parent`/`incomingEdge` fields of each
23
+ * yielded step.
24
+ *
25
+ * Stops are intrinsic — callers `break` out of the `for..of` loop when
26
+ * 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
+ */
32
+ export function* bfs<E>(
33
+ starts: Iterable<string>,
34
+ neighbours: (node: string) => Iterable<{ next: string; edge: E }>,
35
+ ordering?: (items: readonly { next: string; edge: E }[]) => readonly { next: string; edge: E }[],
36
+ ): Generator<BfsStep<E>> {
37
+ const visited = new Set<string>();
38
+ const parentMap = new Map<string, { parent: string; edge: E }>();
39
+ const queue = new Queue<string>();
40
+ for (const start of starts) {
41
+ if (!visited.has(start)) {
42
+ visited.add(start);
43
+ queue.push(start);
44
+ }
45
+ }
46
+ while (!queue.isEmpty) {
47
+ const current = queue.shift();
48
+ const parentInfo = parentMap.get(current);
49
+ yield {
50
+ node: current,
51
+ parent: parentInfo?.parent ?? null,
52
+ incomingEdge: parentInfo?.edge ?? null,
53
+ };
54
+
55
+ const items = neighbours(current);
56
+ const toVisit = ordering ? ordering([...items]) : items;
57
+ for (const { next, edge } of toVisit) {
58
+ if (!visited.has(next)) {
59
+ visited.add(next);
60
+ parentMap.set(next, { parent: current, edge });
61
+ queue.push(next);
62
+ }
63
+ }
64
+ }
65
+ }
package/src/io.ts CHANGED
@@ -3,12 +3,13 @@ import { type } from 'arktype';
3
3
  import { basename, dirname, join } from 'pathe';
4
4
  import {
5
5
  errorDirectoryExists,
6
+ errorInvalidDestName,
6
7
  errorInvalidJson,
7
8
  errorInvalidManifest,
8
9
  errorInvalidSlug,
9
10
  errorMissingFile,
10
11
  } from './errors';
11
- import type { BaseMigrationBundle, MigrationManifest, MigrationOps } from './types';
12
+ import type { MigrationBundle, MigrationManifest, MigrationOps } from './types';
12
13
 
13
14
  const MANIFEST_FILE = 'migration.json';
14
15
  const OPS_FILE = 'ops.json';
@@ -22,13 +23,12 @@ const MigrationHintsSchema = type({
22
23
  used: 'string[]',
23
24
  applied: 'string[]',
24
25
  plannerVersion: 'string',
25
- planningStrategy: 'string',
26
26
  });
27
27
 
28
28
  const MigrationManifestSchema = type({
29
29
  from: 'string',
30
30
  to: 'string',
31
- migrationId: 'string | null',
31
+ migrationId: 'string',
32
32
  kind: "'regular' | 'baseline'",
33
33
  fromContract: 'object | null',
34
34
  toContract: 'object',
@@ -75,27 +75,26 @@ export async function writeMigrationPackage(
75
75
  }
76
76
 
77
77
  /**
78
- * Copy the destination contract artifacts (`contract.json` and the
79
- * colocated `contract.d.ts`) into the migration package directory so
80
- * authors of the scaffolded `migration.ts` can import the typed
81
- * contract relative to the migration directory
82
- * (`import type { Contract } from './contract'`).
78
+ * Copy a list of files into `destDir`, optionally renaming each one.
83
79
  *
84
- * A missing `.d.ts` is tolerated (only the `.json` is required) so the
85
- * helper stays usable in tests that hand-roll a bare `contract.json`.
86
- * A missing `contract.json` or any other I/O failure — throws.
80
+ * The destination directory is created (with `recursive: true`) if it
81
+ * does not already exist. Each source path is copied byte-for-byte into
82
+ * `destDir/<destName>`; missing sources throw `ENOENT`. The helper is
83
+ * intentionally generic: callers own the list of files (e.g. a contract
84
+ * emitter's emitted output) and the naming convention (e.g. renaming
85
+ * the destination contract to `end-contract.*` and the source contract
86
+ * to `start-contract.*`).
87
87
  */
88
- export async function copyContractToMigrationDir(
89
- packageDir: string,
90
- contractJsonPath: string,
88
+ export async function copyFilesWithRename(
89
+ destDir: string,
90
+ files: readonly { readonly sourcePath: string; readonly destName: string }[],
91
91
  ): Promise<void> {
92
- await copyFile(contractJsonPath, join(packageDir, 'contract.json'));
93
- const dtsPath = `${contractJsonPath.slice(0, -'.json'.length)}.d.ts`;
94
- try {
95
- await copyFile(dtsPath, join(packageDir, 'contract.d.ts'));
96
- } catch (error) {
97
- if (hasErrnoCode(error, 'ENOENT')) return;
98
- throw error;
92
+ await mkdir(destDir, { recursive: true });
93
+ for (const file of files) {
94
+ if (basename(file.destName) !== file.destName) {
95
+ throw errorInvalidDestName(file.destName);
96
+ }
97
+ await copyFile(file.sourcePath, join(destDir, file.destName));
99
98
  }
100
99
  }
101
100
 
@@ -110,7 +109,7 @@ export async function writeMigrationOps(dir: string, ops: MigrationOps): Promise
110
109
  await writeFile(join(dir, OPS_FILE), `${JSON.stringify(ops, null, 2)}\n`);
111
110
  }
112
111
 
113
- export async function readMigrationPackage(dir: string): Promise<BaseMigrationBundle> {
112
+ export async function readMigrationPackage(dir: string): Promise<MigrationBundle> {
114
113
  const manifestPath = join(dir, MANIFEST_FILE);
115
114
  const opsPath = join(dir, OPS_FILE);
116
115
 
@@ -178,7 +177,7 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
178
177
 
179
178
  export async function readMigrationsDir(
180
179
  migrationsRoot: string,
181
- ): Promise<readonly BaseMigrationBundle[]> {
180
+ ): Promise<readonly MigrationBundle[]> {
182
181
  let entries: string[];
183
182
  try {
184
183
  entries = await readdir(migrationsRoot);
@@ -189,7 +188,7 @@ export async function readMigrationsDir(
189
188
  throw error;
190
189
  }
191
190
 
192
- const packages: BaseMigrationBundle[] = [];
191
+ const packages: MigrationBundle[] = [];
193
192
 
194
193
  for (const entry of entries.sort()) {
195
194
  const entryPath = join(migrationsRoot, entry);