@prisma-next/migration-tools 0.5.0-dev.3 → 0.5.0-dev.30

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 (92) hide show
  1. package/README.md +34 -22
  2. package/dist/{constants-BRi0X7B_.mjs → constants-BQEHsaEx.mjs} +1 -1
  3. package/dist/{constants-BRi0X7B_.mjs.map → constants-BQEHsaEx.mjs.map} +1 -1
  4. package/dist/errors-Bl3cKiM8.mjs +244 -0
  5. package/dist/errors-Bl3cKiM8.mjs.map +1 -0
  6. package/dist/exports/constants.mjs +1 -1
  7. package/dist/exports/{types.d.mts → errors.d.mts} +7 -8
  8. package/dist/exports/errors.d.mts.map +1 -0
  9. package/dist/exports/errors.mjs +3 -0
  10. package/dist/exports/graph.d.mts +2 -0
  11. package/dist/exports/graph.mjs +1 -0
  12. package/dist/exports/hash.d.mts +52 -0
  13. package/dist/exports/hash.d.mts.map +1 -0
  14. package/dist/exports/hash.mjs +3 -0
  15. package/dist/exports/invariants.d.mts +24 -0
  16. package/dist/exports/invariants.d.mts.map +1 -0
  17. package/dist/exports/invariants.mjs +4 -0
  18. package/dist/exports/io.d.mts +7 -6
  19. package/dist/exports/io.d.mts.map +1 -1
  20. package/dist/exports/io.mjs +162 -2
  21. package/dist/exports/io.mjs.map +1 -0
  22. package/dist/exports/metadata.d.mts +2 -0
  23. package/dist/exports/metadata.mjs +1 -0
  24. package/dist/exports/{dag.d.mts → migration-graph.d.mts} +31 -10
  25. package/dist/exports/migration-graph.d.mts.map +1 -0
  26. package/dist/exports/{dag.mjs → migration-graph.mjs} +143 -63
  27. package/dist/exports/migration-graph.mjs.map +1 -0
  28. package/dist/exports/migration-ts.mjs +1 -1
  29. package/dist/exports/migration.d.mts +15 -14
  30. package/dist/exports/migration.d.mts.map +1 -1
  31. package/dist/exports/migration.mjs +68 -40
  32. package/dist/exports/migration.mjs.map +1 -1
  33. package/dist/exports/package.d.mts +2 -0
  34. package/dist/exports/package.mjs +1 -0
  35. package/dist/exports/refs.d.mts +11 -5
  36. package/dist/exports/refs.d.mts.map +1 -1
  37. package/dist/exports/refs.mjs +106 -30
  38. package/dist/exports/refs.mjs.map +1 -1
  39. package/dist/graph-BHPv-9Gl.d.mts +28 -0
  40. package/dist/graph-BHPv-9Gl.d.mts.map +1 -0
  41. package/dist/hash-BARZdVgW.mjs +76 -0
  42. package/dist/hash-BARZdVgW.mjs.map +1 -0
  43. package/dist/invariants-BmrTBQ0A.mjs +42 -0
  44. package/dist/invariants-BmrTBQ0A.mjs.map +1 -0
  45. package/dist/metadata-BP1cmU7Z.d.mts +50 -0
  46. package/dist/metadata-BP1cmU7Z.d.mts.map +1 -0
  47. package/dist/op-schema-DZKFua46.mjs +14 -0
  48. package/dist/op-schema-DZKFua46.mjs.map +1 -0
  49. package/dist/package-5HCCg0z-.d.mts +21 -0
  50. package/dist/package-5HCCg0z-.d.mts.map +1 -0
  51. package/package.json +30 -14
  52. package/src/errors.ts +139 -15
  53. package/src/exports/errors.ts +1 -0
  54. package/src/exports/graph.ts +1 -0
  55. package/src/exports/hash.ts +2 -0
  56. package/src/exports/invariants.ts +1 -0
  57. package/src/exports/io.ts +1 -1
  58. package/src/exports/metadata.ts +1 -0
  59. package/src/exports/{dag.ts → migration-graph.ts} +3 -2
  60. package/src/exports/migration.ts +0 -1
  61. package/src/exports/package.ts +1 -0
  62. package/src/exports/refs.ts +10 -2
  63. package/src/graph-ops.ts +57 -30
  64. package/src/graph.ts +25 -0
  65. package/src/hash.ts +91 -0
  66. package/src/invariants.ts +45 -0
  67. package/src/io.ts +57 -31
  68. package/src/metadata.ts +41 -0
  69. package/src/migration-base.ts +97 -56
  70. package/src/{dag.ts → migration-graph.ts} +156 -54
  71. package/src/op-schema.ts +11 -0
  72. package/src/package.ts +18 -0
  73. package/src/refs.ts +148 -37
  74. package/dist/attestation-DtF8tEOM.mjs +0 -65
  75. package/dist/attestation-DtF8tEOM.mjs.map +0 -1
  76. package/dist/errors-BKbRGCJM.mjs +0 -160
  77. package/dist/errors-BKbRGCJM.mjs.map +0 -1
  78. package/dist/exports/attestation.d.mts +0 -37
  79. package/dist/exports/attestation.d.mts.map +0 -1
  80. package/dist/exports/attestation.mjs +0 -4
  81. package/dist/exports/dag.d.mts.map +0 -1
  82. package/dist/exports/dag.mjs.map +0 -1
  83. package/dist/exports/types.d.mts.map +0 -1
  84. package/dist/exports/types.mjs +0 -3
  85. package/dist/io-CCnYsUHU.mjs +0 -153
  86. package/dist/io-CCnYsUHU.mjs.map +0 -1
  87. package/dist/types-DyGXcWWp.d.mts +0 -71
  88. package/dist/types-DyGXcWWp.d.mts.map +0 -1
  89. package/src/attestation.ts +0 -81
  90. package/src/exports/attestation.ts +0 -2
  91. package/src/exports/types.ts +0 -10
  92. package/src/types.ts +0 -66
@@ -8,20 +8,27 @@ import type {
8
8
  } from '@prisma-next/framework-components/control';
9
9
  import { ifDefined } from '@prisma-next/utils/defined';
10
10
  import { type } from 'arktype';
11
- import { computeMigrationId } from './attestation';
12
- import type { MigrationHints, MigrationManifest, MigrationOps } from './types';
11
+ import { errorInvalidOperationEntry, errorStaleContractBookends } from './errors';
12
+ import { computeMigrationHash } from './hash';
13
+ import { deriveProvidedInvariants } from './invariants';
14
+ import type { MigrationHints, MigrationMetadata } from './metadata';
15
+ import { MigrationOpSchema } from './op-schema';
16
+ import type { MigrationOps } from './package';
13
17
 
14
18
  export interface MigrationMeta {
15
- readonly from: string;
19
+ readonly from: string | null;
16
20
  readonly to: string;
17
- readonly kind?: 'regular' | 'baseline';
18
21
  readonly labels?: readonly string[];
19
22
  }
20
23
 
24
+ // `from` rejects empty strings to mirror `MigrationMetadataSchema` in
25
+ // `./io.ts`. Without this match, an authored migration could `describe()` with
26
+ // `from: ''` and pass `buildMigrationArtifacts`'s validation, only to have
27
+ // `readMigrationPackage` reject the resulting `migration.json` later — the
28
+ // two validators must agree on the legal value space.
21
29
  const MigrationMetaSchema = type({
22
- from: 'string',
30
+ from: 'string > 0 | null',
23
31
  to: 'string',
24
- 'kind?': "'regular' | 'baseline'",
25
32
  'labels?': type('string').array(),
26
33
  });
27
34
 
@@ -30,7 +37,7 @@ const MigrationMetaSchema = type({
30
37
  *
31
38
  * A `Migration` subclass is itself a `MigrationPlan`: CLI commands and the
32
39
  * runner can consume it directly via `targetId`, `operations`, `origin`, and
33
- * `destination`. The manifest-shaped inputs come from `describe()`, which
40
+ * `destination`. The metadata-shaped inputs come from `describe()`, which
34
41
  * every migration must implement — `migration.json` is required for a
35
42
  * migration to be valid.
36
43
  */
@@ -74,11 +81,7 @@ export abstract class Migration<
74
81
 
75
82
  get origin(): { readonly storageHash: string } | null {
76
83
  const from = this.describe().from;
77
- // An empty `from` represents a migration with no prior origin (e.g.
78
- // initial baseline, or an in-process plan that was never persisted).
79
- // Surface that as a null origin so runners treat the plan as
80
- // origin-less rather than matching against an empty storage hash.
81
- return from === '' ? null : { storageHash: from };
84
+ return from === null ? null : { storageHash: from };
82
85
  }
83
86
 
84
87
  get destination(): { readonly storageHash: string } {
@@ -104,83 +107,114 @@ export function isDirectEntrypoint(importMetaUrl: string): boolean {
104
107
  }
105
108
  }
106
109
 
107
- export function printMigrationHelp(): void {
108
- printHelp();
109
- }
110
-
111
- function printHelp(): void {
112
- process.stdout.write(
113
- [
114
- 'Usage: node <migration-file> [options]',
115
- '',
116
- 'Options:',
117
- ' --dry-run Print operations to stdout without writing files',
118
- ' --help Show this help message',
119
- '',
120
- ].join('\n'),
121
- );
122
- }
123
-
124
110
  /**
125
111
  * In-memory artifacts produced from a `Migration` instance: the
126
- * serialized `ops.json` body, the `migration.json` manifest object, and
112
+ * serialized `ops.json` body, the `migration.json` metadata object, and
127
113
  * its serialized form. Returned by `buildMigrationArtifacts` so callers
128
114
  * (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can
129
115
  * decide how to persist them — write to disk, print in dry-run, ship
130
116
  * over the wire — without coupling artifact construction to file I/O.
117
+ *
118
+ * `metadataJson` is `JSON.stringify(metadata, null, 2)` — the canonical
119
+ * on-disk shape that the arktype loader-schema in `./io` validates.
131
120
  */
132
121
  export interface MigrationArtifacts {
133
122
  readonly opsJson: string;
134
- readonly manifest: MigrationManifest;
135
- readonly manifestJson: string;
123
+ readonly metadata: MigrationMetadata;
124
+ readonly metadataJson: string;
136
125
  }
137
126
 
138
127
  /**
139
- * Build the attested manifest from `describe()`-derived metadata, the
140
- * operations list, and the previously-scaffolded manifest (if any).
128
+ * Build the attested metadata from `describe()`-derived metadata, the
129
+ * operations list, and the previously-scaffolded metadata (if any).
141
130
  *
142
131
  * When a `migration.json` already exists for this package (the common
143
132
  * case: it was scaffolded by `migration plan`), preserve the contract
144
133
  * bookends, hints, labels, and `createdAt` set there — those fields are
145
134
  * owned by the CLI scaffolder, not the authored class. Only the
146
- * `describe()`-derived fields (`from`, `to`, `kind`) and the operations
147
- * change as the author iterates. When no manifest exists yet (a bare
135
+ * `describe()`-derived fields (`from`, `to`) and the operations
136
+ * change as the author iterates. When no metadata exists yet (a bare
148
137
  * `migration.ts` run from scratch), synthesize a minimal but
149
- * schema-conformant manifest so the resulting package can still be read,
138
+ * schema-conformant record so the resulting package can still be read,
150
139
  * verified, and applied.
151
140
  *
152
- * The `migrationId` is recomputed against the current manifest + ops so
141
+ * The `migrationHash` is recomputed against the current metadata + ops so
153
142
  * the on-disk artifacts are always fully attested.
154
143
  */
155
- function buildAttestedManifest(
144
+ function buildAttestedMetadata(
156
145
  meta: MigrationMeta,
157
146
  ops: MigrationOps,
158
- existing: Partial<MigrationManifest> | null,
159
- ): MigrationManifest {
160
- const baseManifest: Omit<MigrationManifest, 'migrationId'> = {
147
+ existing: Partial<MigrationMetadata> | null,
148
+ ): MigrationMetadata {
149
+ assertBookendsMatchMeta(meta, existing);
150
+
151
+ const baseMetadata: Omit<MigrationMetadata, 'migrationHash'> = {
161
152
  from: meta.from,
162
153
  to: meta.to,
163
- kind: meta.kind ?? 'regular',
164
154
  labels: meta.labels ?? existing?.labels ?? [],
155
+ providedInvariants: deriveProvidedInvariants(ops),
165
156
  createdAt: existing?.createdAt ?? new Date().toISOString(),
166
157
  fromContract: existing?.fromContract ?? null,
167
- // When no scaffolded manifest exists we synthesize a minimal contract
158
+ // When no scaffolded metadata exists we synthesize a minimal contract
168
159
  // stub so the package is still readable end-to-end. The cast is
169
160
  // intentional: only the storage bookend matters for hash computation
170
- // (everything else is stripped by `computeMigrationId`), and a real
161
+ // (everything else is stripped by `computeMigrationHash`), and a real
171
162
  // contract bookend would only be available after `migration plan`.
172
163
  toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
173
164
  hints: normalizeHints(existing?.hints),
174
165
  ...ifDefined('authorship', existing?.authorship),
175
166
  };
176
167
 
177
- const migrationId = computeMigrationId(baseManifest, ops);
178
- return { ...baseManifest, migrationId };
168
+ const migrationHash = computeMigrationHash(baseMetadata, ops);
169
+ return { ...baseMetadata, migrationHash };
170
+ }
171
+
172
+ /**
173
+ * Verify each preserved contract bookend in `existing` agrees with the
174
+ * corresponding side of `describe()`'s output. A mismatch indicates the
175
+ * migration's `describe()` was edited after `migration plan` scaffolded
176
+ * the package, leaving a self-inconsistent manifest. Failing fast at
177
+ * write-time turns a silent foot-gun into an actionable diagnostic.
178
+ *
179
+ * Skipped when a side's `existing.<side>Contract` is null/absent (the
180
+ * synthesis path stays open for origin-less initial migrations and for
181
+ * bare `migration.ts` runs from scratch). When a bookend is *present*
182
+ * but its `storage.storageHash` is missing, that's treated as a
183
+ * mismatch — a malformed bookend is not equivalent to "no bookend".
184
+ *
185
+ * This check is paired with TML-2274, which removes `fromContract` /
186
+ * `toContract` from the manifest entirely; once that lands, this
187
+ * function and its error code are deleted.
188
+ */
189
+ function assertBookendsMatchMeta(
190
+ meta: MigrationMeta,
191
+ existing: Partial<MigrationMetadata> | null,
192
+ ): void {
193
+ if (existing?.fromContract != null) {
194
+ const contractHash = existing.fromContract.storage?.storageHash ?? '';
195
+ if (contractHash !== meta.from) {
196
+ throw errorStaleContractBookends({
197
+ side: 'from',
198
+ metaHash: meta.from,
199
+ contractHash,
200
+ });
201
+ }
202
+ }
203
+ if (existing?.toContract != null) {
204
+ const contractHash = existing.toContract.storage?.storageHash ?? '';
205
+ if (contractHash !== meta.to) {
206
+ throw errorStaleContractBookends({
207
+ side: 'to',
208
+ metaHash: meta.to,
209
+ contractHash,
210
+ });
211
+ }
212
+ }
179
213
  }
180
214
 
181
215
  /**
182
216
  * Project `existing.hints` down to the known `MigrationHints` shape, dropping
183
- * any legacy keys that may linger in manifests scaffolded by older CLI
217
+ * any legacy keys that may linger in metadata scaffolded by older CLI
184
218
  * versions (e.g. `planningStrategy`). Picking fields explicitly instead of
185
219
  * spreading keeps refreshed `migration.json` files schema-clean regardless
186
220
  * of what was on disk before.
@@ -195,33 +229,40 @@ function normalizeHints(existing: MigrationHints | undefined): MigrationHints {
195
229
 
196
230
  /**
197
231
  * Pure conversion from a `Migration` instance (plus the previously
198
- * scaffolded manifest, when one exists on disk) to the in-memory
232
+ * scaffolded metadata, when one exists on disk) to the in-memory
199
233
  * artifacts that downstream tooling persists. Owns metadata validation,
200
- * manifest synthesis/preservation, hint normalization, and the
201
- * content-addressed `migrationId` computation, but performs no file I/O
234
+ * metadata synthesis/preservation, hint normalization, and the
235
+ * content-addressed `migrationHash` computation, but performs no file I/O
202
236
  * — callers handle reads (to source `existing`) and writes (to persist
203
- * `opsJson` / `manifestJson`).
237
+ * `opsJson` / `metadataJson`).
204
238
  */
205
239
  export function buildMigrationArtifacts(
206
240
  instance: Migration,
207
- existing: Partial<MigrationManifest> | null,
241
+ existing: Partial<MigrationMetadata> | null,
208
242
  ): MigrationArtifacts {
209
243
  const ops = instance.operations;
210
244
  if (!Array.isArray(ops)) {
211
245
  throw new Error('operations must be an array');
212
246
  }
213
247
 
248
+ for (let index = 0; index < ops.length; index++) {
249
+ const result = MigrationOpSchema(ops[index]);
250
+ if (result instanceof type.errors) {
251
+ throw errorInvalidOperationEntry(index, result.summary);
252
+ }
253
+ }
254
+
214
255
  const rawMeta: unknown = instance.describe();
215
256
  const parsed = MigrationMetaSchema(rawMeta);
216
257
  if (parsed instanceof type.errors) {
217
258
  throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
218
259
  }
219
260
 
220
- const manifest = buildAttestedManifest(parsed, ops, existing);
261
+ const metadata = buildAttestedMetadata(parsed, ops, existing);
221
262
 
222
263
  return {
223
264
  opsJson: JSON.stringify(ops, null, 2),
224
- manifest,
225
- manifestJson: JSON.stringify(manifest, null, 2),
265
+ metadata,
266
+ metadataJson: JSON.stringify(metadata, null, 2),
226
267
  };
227
268
  }
@@ -2,75 +2,87 @@ import { ifDefined } from '@prisma-next/utils/defined';
2
2
  import { EMPTY_CONTRACT_HASH } from './constants';
3
3
  import {
4
4
  errorAmbiguousTarget,
5
- errorDuplicateMigrationId,
5
+ errorDuplicateMigrationHash,
6
6
  errorNoInitialMigration,
7
7
  errorNoTarget,
8
8
  errorSameSourceAndTarget,
9
9
  } from './errors';
10
+ import type { MigrationEdge, MigrationGraph } from './graph';
10
11
  import { bfs } from './graph-ops';
11
- import type { MigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
12
+ import type { MigrationPackage } from './package';
12
13
 
13
- /** Forward-edge neighbours for BFS: edge `e` from `n` visits `e.to` next. */
14
+ /** Forward-edge neighbours: edge `e` from `n` visits `e.to` next. */
14
15
  function forwardNeighbours(graph: MigrationGraph, node: string) {
15
16
  return (graph.forwardChain.get(node) ?? []).map((edge) => ({ next: edge.to, edge }));
16
17
  }
17
18
 
18
- /** Reverse-edge neighbours for BFS: edge `e` from `n` visits `e.from` next. */
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. */
19
29
  function reverseNeighbours(graph: MigrationGraph, node: string) {
20
30
  return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
21
31
  }
22
32
 
23
- function appendEdge(
24
- map: Map<string, MigrationChainEntry[]>,
25
- key: string,
26
- entry: MigrationChainEntry,
27
- ): void {
33
+ function appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: MigrationEdge): void {
28
34
  const bucket = map.get(key);
29
35
  if (bucket) bucket.push(entry);
30
36
  else map.set(key, [entry]);
31
37
  }
32
38
 
33
- export function reconstructGraph(packages: readonly MigrationBundle[]): MigrationGraph {
39
+ export function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {
34
40
  const nodes = new Set<string>();
35
- const forwardChain = new Map<string, MigrationChainEntry[]>();
36
- const reverseChain = new Map<string, MigrationChainEntry[]>();
37
- const migrationById = new Map<string, MigrationChainEntry>();
41
+ const forwardChain = new Map<string, MigrationEdge[]>();
42
+ const reverseChain = new Map<string, MigrationEdge[]>();
43
+ const migrationByHash = new Map<string, MigrationEdge>();
38
44
 
39
45
  for (const pkg of packages) {
40
- const { from, to } = pkg.manifest;
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;
41
52
 
42
53
  if (from === to) {
43
- throw errorSameSourceAndTarget(pkg.dirName, from);
54
+ throw errorSameSourceAndTarget(pkg.dirPath, from);
44
55
  }
45
56
 
46
57
  nodes.add(from);
47
58
  nodes.add(to);
48
59
 
49
- const migration: MigrationChainEntry = {
60
+ const migration: MigrationEdge = {
50
61
  from,
51
62
  to,
52
- migrationId: pkg.manifest.migrationId,
63
+ migrationHash: pkg.metadata.migrationHash,
53
64
  dirName: pkg.dirName,
54
- createdAt: pkg.manifest.createdAt,
55
- labels: pkg.manifest.labels,
65
+ createdAt: pkg.metadata.createdAt,
66
+ labels: pkg.metadata.labels,
67
+ invariants: pkg.metadata.providedInvariants,
56
68
  };
57
69
 
58
- if (migrationById.has(migration.migrationId)) {
59
- throw errorDuplicateMigrationId(migration.migrationId);
70
+ if (migrationByHash.has(migration.migrationHash)) {
71
+ throw errorDuplicateMigrationHash(migration.migrationHash);
60
72
  }
61
- migrationById.set(migration.migrationId, migration);
73
+ migrationByHash.set(migration.migrationHash, migration);
62
74
 
63
75
  appendEdge(forwardChain, from, migration);
64
76
  appendEdge(reverseChain, to, migration);
65
77
  }
66
78
 
67
- return { nodes, forwardChain, reverseChain, migrationById };
79
+ return { nodes, forwardChain, reverseChain, migrationByHash };
68
80
  }
69
81
 
70
82
  // ---------------------------------------------------------------------------
71
83
  // 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.
84
+ // Used by path-finders only; not a general-purpose utility.
85
+ // Ordering: label priority → createdAt → to → migrationHash.
74
86
  // ---------------------------------------------------------------------------
75
87
 
76
88
  const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
@@ -84,49 +96,42 @@ function labelPriority(labels: readonly string[]): number {
84
96
  return best;
85
97
  }
86
98
 
87
- function compareTieBreak(a: MigrationChainEntry, b: MigrationChainEntry): number {
99
+ function compareTieBreak(a: MigrationEdge, b: MigrationEdge): number {
88
100
  const lp = labelPriority(a.labels) - labelPriority(b.labels);
89
101
  if (lp !== 0) return lp;
90
102
  const ca = a.createdAt.localeCompare(b.createdAt);
91
103
  if (ca !== 0) return ca;
92
104
  const tc = a.to.localeCompare(b.to);
93
105
  if (tc !== 0) return tc;
94
- return a.migrationId.localeCompare(b.migrationId);
106
+ return a.migrationHash.localeCompare(b.migrationHash);
95
107
  }
96
108
 
97
- function sortedNeighbors(edges: readonly MigrationChainEntry[]): readonly MigrationChainEntry[] {
109
+ function sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdge[] {
98
110
  return [...edges].sort(compareTieBreak);
99
111
  }
100
112
 
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));
106
- }
107
-
108
113
  /**
109
114
  * Find the shortest path from `fromHash` to `toHash` using BFS over the
110
115
  * contract-hash graph. Returns the ordered list of edges, or null if no path
111
116
  * exists. Returns an empty array when `fromHash === toHash` (no-op).
112
117
  *
113
118
  * Neighbor ordering is deterministic via the tie-break sort key:
114
- * label priority → createdAt → to → migrationId.
119
+ * label priority → createdAt → to → migrationHash.
115
120
  */
116
121
  export function findPath(
117
122
  graph: MigrationGraph,
118
123
  fromHash: string,
119
124
  toHash: string,
120
- ): readonly MigrationChainEntry[] | null {
125
+ ): readonly MigrationEdge[] | null {
121
126
  if (fromHash === toHash) return [];
122
127
 
123
- const parents = new Map<string, { parent: string; edge: MigrationChainEntry }>();
124
- for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n), bfsOrdering)) {
128
+ const parents = new Map<string, { parent: string; edge: MigrationEdge }>();
129
+ for (const step of bfs([fromHash], (n) => sortedForwardNeighbours(graph, n))) {
125
130
  if (step.parent !== null && step.incomingEdge !== null) {
126
- parents.set(step.node, { parent: step.parent, edge: step.incomingEdge });
131
+ parents.set(step.state, { parent: step.parent, edge: step.incomingEdge });
127
132
  }
128
- if (step.node === toHash) {
129
- const path: MigrationChainEntry[] = [];
133
+ if (step.state === toHash) {
134
+ const path: MigrationEdge[] = [];
130
135
  let cur = toHash;
131
136
  let p = parents.get(cur);
132
137
  while (p) {
@@ -142,6 +147,103 @@ export function findPath(
142
147
  return null;
143
148
  }
144
149
 
150
+ /**
151
+ * Find the shortest path from `fromHash` to `toHash` whose edges collectively
152
+ * cover every invariant in `required`. Returns `null` when no such path exists
153
+ * (either `fromHash`→`toHash` is structurally unreachable, or every reachable
154
+ * path leaves at least one required invariant uncovered). When `required` is
155
+ * empty, delegates to `findPath` so the result is byte-identical for that case.
156
+ *
157
+ * Algorithm: BFS over `(node, coveredSubset)` states with state-level dedup.
158
+ * The covered subset is a `Set<string>` of invariant ids; the state's dedup
159
+ * key is `${node}\0${[...covered].sort().join('\0')}`. State keys distinguish
160
+ * distinct `(node, covered)` tuples regardless of node-name length because
161
+ * `\0` cannot appear in any invariant id (validation rejects whitespace and
162
+ * control chars at authoring time).
163
+ *
164
+ * Neighbour ordering when `required ≠ ∅`: edges covering ≥1 still-needed
165
+ * invariant come first, with `labelPriority → createdAt → to → migrationHash`
166
+ * as the secondary key. The heuristic steers BFS toward the satisfying path;
167
+ * correctness (shortest, deterministic) does not depend on it.
168
+ */
169
+ export function findPathWithInvariants(
170
+ graph: MigrationGraph,
171
+ fromHash: string,
172
+ toHash: string,
173
+ required: ReadonlySet<string>,
174
+ ): readonly MigrationEdge[] | null {
175
+ if (required.size === 0) {
176
+ return findPath(graph, fromHash, toHash);
177
+ }
178
+ if (fromHash === toHash) {
179
+ // Empty path covers no invariants; required is non-empty ⇒ unsatisfiable.
180
+ return null;
181
+ }
182
+
183
+ interface InvState {
184
+ readonly node: string;
185
+ readonly covered: ReadonlySet<string>;
186
+ }
187
+ const stateKey = (s: InvState): string => {
188
+ if (s.covered.size === 0) return `${s.node}\0`;
189
+ return `${s.node}\0${[...s.covered].sort().join('\0')}`;
190
+ };
191
+
192
+ const neighbours = (s: InvState): Iterable<{ next: InvState; edge: MigrationEdge }> => {
193
+ const outgoing = graph.forwardChain.get(s.node) ?? [];
194
+ if (outgoing.length === 0) return [];
195
+ return [...outgoing]
196
+ .map((edge) => {
197
+ let useful = false;
198
+ let next: Set<string> | null = null;
199
+ for (const inv of edge.invariants) {
200
+ if (required.has(inv) && !s.covered.has(inv)) {
201
+ if (next === null) next = new Set(s.covered);
202
+ next.add(inv);
203
+ useful = true;
204
+ }
205
+ }
206
+ return { edge, useful, nextCovered: next ?? s.covered };
207
+ })
208
+ .sort((a, b) => {
209
+ if (a.useful !== b.useful) return a.useful ? -1 : 1;
210
+ return compareTieBreak(a.edge, b.edge);
211
+ })
212
+ .map(({ edge, nextCovered }) => ({
213
+ next: { node: edge.to, covered: nextCovered },
214
+ edge,
215
+ }));
216
+ };
217
+
218
+ // Path reconstruction is consumer-side, keyed on stateKey, same shape as
219
+ // findPath's parents map.
220
+ const parents = new Map<string, { parentKey: string; edge: MigrationEdge }>();
221
+ for (const step of bfs<InvState, MigrationEdge>(
222
+ [{ node: fromHash, covered: new Set() }],
223
+ neighbours,
224
+ stateKey,
225
+ )) {
226
+ const curKey = stateKey(step.state);
227
+ if (step.parent !== null && step.incomingEdge !== null) {
228
+ parents.set(curKey, { parentKey: stateKey(step.parent), edge: step.incomingEdge });
229
+ }
230
+ if (step.state.node === toHash && step.state.covered.size === required.size) {
231
+ const path: MigrationEdge[] = [];
232
+ let cur: string | undefined = curKey;
233
+ while (cur !== undefined) {
234
+ const p = parents.get(cur);
235
+ if (!p) break;
236
+ path.push(p.edge);
237
+ cur = p.parentKey;
238
+ }
239
+ path.reverse();
240
+ return path;
241
+ }
242
+ }
243
+
244
+ return null;
245
+ }
246
+
145
247
  /**
146
248
  * Reverse-BFS from `toHash` over `reverseChain` to collect every node from
147
249
  * which `toHash` is reachable (inclusive of `toHash` itself).
@@ -149,13 +251,13 @@ export function findPath(
149
251
  function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<string> {
150
252
  const reached = new Set<string>();
151
253
  for (const step of bfs([toHash], (n) => reverseNeighbours(graph, n))) {
152
- reached.add(step.node);
254
+ reached.add(step.state);
153
255
  }
154
256
  return reached;
155
257
  }
156
258
 
157
259
  export interface PathDecision {
158
- readonly selectedPath: readonly MigrationChainEntry[];
260
+ readonly selectedPath: readonly MigrationEdge[];
159
261
  readonly fromHash: string;
160
262
  readonly toHash: string;
161
263
  readonly alternativeCount: number;
@@ -202,8 +304,8 @@ export function findPathWithDecision(
202
304
  if (reachable.length > 1) {
203
305
  alternativeCount += reachable.length - 1;
204
306
  const sorted = sortedNeighbors(reachable);
205
- if (sorted[0] && sorted[0].migrationId === edge.migrationId) {
206
- if (reachable.some((e) => e.migrationId !== edge.migrationId)) {
307
+ if (sorted[0] && sorted[0].migrationHash === edge.migrationHash) {
308
+ if (reachable.some((e) => e.migrationHash !== edge.migrationHash)) {
207
309
  tieBreakReasons.push(
208
310
  `at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,
209
311
  );
@@ -235,7 +337,7 @@ function findDivergencePoint(
235
337
  const ancestorSets = leaves.map((leaf) => {
236
338
  const ancestors = new Set<string>();
237
339
  for (const step of bfs([leaf], (n) => reverseNeighbours(graph, n))) {
238
- ancestors.add(step.node);
340
+ ancestors.add(step.state);
239
341
  }
240
342
  return ancestors;
241
343
  });
@@ -264,8 +366,8 @@ function findDivergencePoint(
264
366
  export function findReachableLeaves(graph: MigrationGraph, fromHash: string): readonly string[] {
265
367
  const leaves: string[] = [];
266
368
  for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n))) {
267
- if (!graph.forwardChain.get(step.node)?.length) {
268
- leaves.push(step.node);
369
+ if (!graph.forwardChain.get(step.state)?.length) {
370
+ leaves.push(step.state);
269
371
  }
270
372
  }
271
373
  return leaves;
@@ -319,7 +421,7 @@ export function findLeaf(graph: MigrationGraph): string | null {
319
421
  * to the single target. Returns null for an empty graph.
320
422
  * Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
321
423
  */
322
- export function findLatestMigration(graph: MigrationGraph): MigrationChainEntry | null {
424
+ export function findLatestMigration(graph: MigrationGraph): MigrationEdge | null {
323
425
  const leafHash = findLeaf(graph);
324
426
  if (leafHash === null) return null;
325
427
 
@@ -343,7 +445,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
343
445
  // Iterative three-color DFS. A frame is (node, outgoing edges, next-index).
344
446
  interface Frame {
345
447
  node: string;
346
- outgoing: readonly MigrationChainEntry[];
448
+ outgoing: readonly MigrationEdge[];
347
449
  index: number;
348
450
  }
349
451
  const stack: Frame[] = [];
@@ -389,7 +491,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
389
491
  return cycles;
390
492
  }
391
493
 
392
- export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEntry[] {
494
+ export function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {
393
495
  if (graph.nodes.size === 0) return [];
394
496
 
395
497
  const reachable = new Set<string>();
@@ -412,10 +514,10 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEnt
412
514
  }
413
515
 
414
516
  for (const step of bfs(startNodes, (n) => forwardNeighbours(graph, n))) {
415
- reachable.add(step.node);
517
+ reachable.add(step.state);
416
518
  }
417
519
 
418
- const orphans: MigrationChainEntry[] = [];
520
+ const orphans: MigrationEdge[] = [];
419
521
  for (const [from, migrations] of graph.forwardChain) {
420
522
  if (!reachable.has(from)) {
421
523
  orphans.push(...migrations);
@@ -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,18 @@
1
+ import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
2
+ import type { MigrationMetadata } from './metadata';
3
+
4
+ export type MigrationOps = readonly MigrationPlanOperation[];
5
+
6
+ /**
7
+ * An on-disk migration directory (a "package") with its parsed metadata and
8
+ * operations. Returned from `readMigrationPackage` / `readMigrationsDir` only
9
+ * after the loader has verified the package's integrity (hash recomputation
10
+ * against the stored `migrationHash`); holding a `MigrationPackage` value
11
+ * therefore implies the package is internally consistent.
12
+ */
13
+ export interface MigrationPackage {
14
+ readonly dirName: string;
15
+ readonly dirPath: string;
16
+ readonly metadata: MigrationMetadata;
17
+ readonly ops: MigrationOps;
18
+ }