@prisma-next/migration-tools 0.5.0-dev.6 → 0.5.0-dev.60

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 (91) 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-CfmjBeK0.mjs +272 -0
  5. package/dist/errors-CfmjBeK0.mjs.map +1 -0
  6. package/dist/exports/constants.mjs +1 -1
  7. package/dist/exports/errors.d.mts +63 -0
  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/migration-graph.d.mts +124 -0
  25. package/dist/exports/migration-graph.d.mts.map +1 -0
  26. package/dist/exports/migration-graph.mjs +526 -0
  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 +69 -41
  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.mjs +2 -2
  36. package/dist/graph-BHPv-9Gl.d.mts +28 -0
  37. package/dist/graph-BHPv-9Gl.d.mts.map +1 -0
  38. package/dist/hash-BARZdVgW.mjs +76 -0
  39. package/dist/hash-BARZdVgW.mjs.map +1 -0
  40. package/dist/invariants-30VA65sB.mjs +42 -0
  41. package/dist/invariants-30VA65sB.mjs.map +1 -0
  42. package/dist/metadata-BP1cmU7Z.d.mts +50 -0
  43. package/dist/metadata-BP1cmU7Z.d.mts.map +1 -0
  44. package/dist/op-schema-DZKFua46.mjs +14 -0
  45. package/dist/op-schema-DZKFua46.mjs.map +1 -0
  46. package/dist/package-5HCCg0z-.d.mts +21 -0
  47. package/dist/package-5HCCg0z-.d.mts.map +1 -0
  48. package/package.json +31 -14
  49. package/src/errors.ts +210 -17
  50. package/src/exports/errors.ts +7 -0
  51. package/src/exports/graph.ts +1 -0
  52. package/src/exports/hash.ts +2 -0
  53. package/src/exports/invariants.ts +1 -0
  54. package/src/exports/io.ts +1 -1
  55. package/src/exports/metadata.ts +1 -0
  56. package/src/exports/{dag.ts → migration-graph.ts} +3 -2
  57. package/src/exports/migration.ts +0 -1
  58. package/src/exports/package.ts +1 -0
  59. package/src/graph-ops.ts +57 -30
  60. package/src/graph.ts +25 -0
  61. package/src/hash.ts +91 -0
  62. package/src/invariants.ts +45 -0
  63. package/src/io.ts +57 -31
  64. package/src/metadata.ts +41 -0
  65. package/src/migration-base.ts +97 -56
  66. package/src/migration-graph.ts +676 -0
  67. package/src/op-schema.ts +11 -0
  68. package/src/package.ts +18 -0
  69. package/dist/attestation-BnzTb0Qp.mjs +0 -65
  70. package/dist/attestation-BnzTb0Qp.mjs.map +0 -1
  71. package/dist/errors-BmiSgz1j.mjs +0 -160
  72. package/dist/errors-BmiSgz1j.mjs.map +0 -1
  73. package/dist/exports/attestation.d.mts +0 -37
  74. package/dist/exports/attestation.d.mts.map +0 -1
  75. package/dist/exports/attestation.mjs +0 -4
  76. package/dist/exports/dag.d.mts +0 -51
  77. package/dist/exports/dag.d.mts.map +0 -1
  78. package/dist/exports/dag.mjs +0 -386
  79. package/dist/exports/dag.mjs.map +0 -1
  80. package/dist/exports/types.d.mts +0 -35
  81. package/dist/exports/types.d.mts.map +0 -1
  82. package/dist/exports/types.mjs +0 -3
  83. package/dist/io-Cd6GLyjK.mjs +0 -153
  84. package/dist/io-Cd6GLyjK.mjs.map +0 -1
  85. package/dist/types-DyGXcWWp.d.mts +0 -71
  86. package/dist/types-DyGXcWWp.d.mts.map +0 -1
  87. package/src/attestation.ts +0 -81
  88. package/src/dag.ts +0 -426
  89. package/src/exports/attestation.ts +0 -2
  90. package/src/exports/types.ts +0 -10
  91. package/src/types.ts +0 -66
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "@prisma-next/migration-tools",
3
- "version": "0.5.0-dev.6",
3
+ "version": "0.5.0-dev.60",
4
+ "license": "Apache-2.0",
4
5
  "type": "module",
5
6
  "sideEffects": false,
6
- "description": "On-disk migration persistence, attestation, and chain reconstruction for Prisma Next",
7
+ "description": "On-disk migration persistence, hash verification, and chain reconstruction for Prisma Next",
7
8
  "dependencies": {
8
9
  "arktype": "^2.1.29",
9
10
  "pathe": "^2.0.3",
10
11
  "prettier": "^3.6.2",
11
- "@prisma-next/contract": "0.5.0-dev.6",
12
- "@prisma-next/utils": "0.5.0-dev.6",
13
- "@prisma-next/framework-components": "0.5.0-dev.6"
12
+ "@prisma-next/contract": "0.5.0-dev.60",
13
+ "@prisma-next/framework-components": "0.5.0-dev.60",
14
+ "@prisma-next/utils": "0.5.0-dev.60"
14
15
  },
15
16
  "devDependencies": {
16
17
  "tsdown": "0.18.4",
@@ -27,21 +28,37 @@
27
28
  "node": ">=20"
28
29
  },
29
30
  "exports": {
30
- "./types": {
31
- "types": "./dist/exports/types.d.mts",
32
- "import": "./dist/exports/types.mjs"
31
+ "./metadata": {
32
+ "types": "./dist/exports/metadata.d.mts",
33
+ "import": "./dist/exports/metadata.mjs"
34
+ },
35
+ "./package": {
36
+ "types": "./dist/exports/package.d.mts",
37
+ "import": "./dist/exports/package.mjs"
38
+ },
39
+ "./graph": {
40
+ "types": "./dist/exports/graph.d.mts",
41
+ "import": "./dist/exports/graph.mjs"
42
+ },
43
+ "./errors": {
44
+ "types": "./dist/exports/errors.d.mts",
45
+ "import": "./dist/exports/errors.mjs"
33
46
  },
34
47
  "./io": {
35
48
  "types": "./dist/exports/io.d.mts",
36
49
  "import": "./dist/exports/io.mjs"
37
50
  },
38
- "./attestation": {
39
- "types": "./dist/exports/attestation.d.mts",
40
- "import": "./dist/exports/attestation.mjs"
51
+ "./hash": {
52
+ "types": "./dist/exports/hash.d.mts",
53
+ "import": "./dist/exports/hash.mjs"
54
+ },
55
+ "./invariants": {
56
+ "types": "./dist/exports/invariants.d.mts",
57
+ "import": "./dist/exports/invariants.mjs"
41
58
  },
42
- "./dag": {
43
- "types": "./dist/exports/dag.d.mts",
44
- "import": "./dist/exports/dag.mjs"
59
+ "./migration-graph": {
60
+ "types": "./dist/exports/migration-graph.d.mts",
61
+ "import": "./dist/exports/migration-graph.mjs"
45
62
  },
46
63
  "./refs": {
47
64
  "types": "./dist/exports/refs.d.mts",
package/src/errors.ts CHANGED
@@ -1,10 +1,30 @@
1
+ import { ifDefined } from '@prisma-next/utils/defined';
2
+ import { basename, dirname, relative } from 'pathe';
3
+
4
+ /**
5
+ * Build the canonical "re-emit this package" remediation hint.
6
+ *
7
+ * Every on-disk migration package ships its own `migration.ts` author-time
8
+ * file. Running it regenerates `migration.json` and `ops.json` with the
9
+ * correct hash + metadata, so it is the right primitive whenever a single
10
+ * package's on-disk artifacts are missing, malformed, or otherwise corrupt.
11
+ * Pointing users at `migration plan` would emit a *new* package rather than
12
+ * heal the broken one.
13
+ */
14
+ function reemitHint(dir: string, fallback?: string): string {
15
+ const relativeDir = relative(process.cwd(), dir);
16
+ const reemit = `Re-emit the package by running \`node "${relativeDir}/migration.ts"\``;
17
+ return fallback ? `${reemit}, ${fallback}` : `${reemit}.`;
18
+ }
19
+
1
20
  /**
2
21
  * Structured error for migration tooling operations.
3
22
  *
4
23
  * Follows the NAMESPACE.SUBCODE convention from ADR 027. All codes live under
5
- * the MIGRATION namespace. These are tooling-time errors (file I/O, attestation,
6
- * migration history reconstruction), distinct from the runtime MIGRATION.* codes for apply-time
7
- * failures (PRECHECK_FAILED, POSTCHECK_FAILED, etc.).
24
+ * the MIGRATION namespace. These are tooling-time errors (file I/O, hash
25
+ * verification, migration history reconstruction), distinct from the runtime
26
+ * MIGRATION.* codes for apply-time failures (PRECHECK_FAILED, POSTCHECK_FAILED,
27
+ * etc.).
8
28
  *
9
29
  * Fields:
10
30
  * - code: Stable machine-readable code (MIGRATION.SUBCODE)
@@ -55,7 +75,10 @@ export function errorDirectoryExists(dir: string): MigrationToolsError {
55
75
  export function errorMissingFile(file: string, dir: string): MigrationToolsError {
56
76
  return new MigrationToolsError('MIGRATION.FILE_MISSING', `Missing ${file}`, {
57
77
  why: `Expected "${file}" in "${dir}" but the file does not exist.`,
58
- fix: 'Ensure the migration directory contains both migration.json and ops.json. If the directory is corrupt, delete it and re-run migration plan.',
78
+ fix: reemitHint(
79
+ dir,
80
+ 'or delete the directory if the migration is unwanted and the source TypeScript is gone.',
81
+ ),
59
82
  details: { file, dir },
60
83
  });
61
84
  }
@@ -63,19 +86,52 @@ export function errorMissingFile(file: string, dir: string): MigrationToolsError
63
86
  export function errorInvalidJson(filePath: string, parseError: string): MigrationToolsError {
64
87
  return new MigrationToolsError('MIGRATION.INVALID_JSON', 'Invalid JSON in migration file', {
65
88
  why: `Failed to parse "${filePath}": ${parseError}`,
66
- fix: 'Fix the JSON syntax error, or delete the migration directory and re-run migration plan.',
89
+ fix: reemitHint(dirname(filePath), 'or restore the directory from version control.'),
67
90
  details: { filePath, parseError },
68
91
  });
69
92
  }
70
93
 
71
94
  export function errorInvalidManifest(filePath: string, reason: string): MigrationToolsError {
72
95
  return new MigrationToolsError('MIGRATION.INVALID_MANIFEST', 'Invalid migration manifest', {
73
- why: `Manifest at "${filePath}" is invalid: ${reason}`,
74
- fix: 'Ensure the manifest has all required fields (from, to, kind, toContract). If corrupt, delete and re-plan.',
96
+ why: `Migration manifest at "${filePath}" is invalid: ${reason}`,
97
+ fix: reemitHint(dirname(filePath), 'or restore the directory from version control.'),
75
98
  details: { filePath, reason },
76
99
  });
77
100
  }
78
101
 
102
+ export function errorInvalidOperationEntry(index: number, reason: string): MigrationToolsError {
103
+ return new MigrationToolsError(
104
+ 'MIGRATION.INVALID_OPERATION_ENTRY',
105
+ 'Migration operation entry is malformed',
106
+ {
107
+ why: `Operation at index ${index} returned by the migration class failed schema validation: ${reason}.`,
108
+ fix: "Update the migration class so each entry of `operations` carries `id` (string), `label` (string), and `operationClass` (one of 'additive' | 'widening' | 'destructive' | 'data').",
109
+ details: { index, reason },
110
+ },
111
+ );
112
+ }
113
+
114
+ export function errorStaleContractBookends(args: {
115
+ readonly side: 'from' | 'to';
116
+ readonly metaHash: string | null;
117
+ readonly contractHash: string;
118
+ }): MigrationToolsError {
119
+ const { side, metaHash, contractHash } = args;
120
+ // `meta.from` is `string | null` (null = baseline). Render `null` as a
121
+ // human-readable token in the diagnostic so the message stays clear when
122
+ // the mismatch is a baseline-vs-non-baseline disagreement.
123
+ const renderedMetaHash = metaHash === null ? 'null (baseline)' : `"${metaHash}"`;
124
+ return new MigrationToolsError(
125
+ 'MIGRATION.STALE_CONTRACT_BOOKENDS',
126
+ 'Migration manifest contract bookends disagree with describe()',
127
+ {
128
+ why: `migration.json stores ${side}Contract.storage.storageHash "${contractHash}", but describe() returned meta.${side} = ${renderedMetaHash}. The bookend is stale — most likely the migration's describe() was edited after the package was scaffolded by \`migration plan\`.`,
129
+ fix: 'Re-run `migration plan` to regenerate the package with fresh contract bookends, or restore the directory from version control.',
130
+ details: { side, metaHash, contractHash },
131
+ },
132
+ );
133
+ }
134
+
79
135
  export function errorInvalidSlug(slug: string): MigrationToolsError {
80
136
  return new MigrationToolsError('MIGRATION.INVALID_NAME', 'Invalid migration name', {
81
137
  why: `The slug "${slug}" contains no valid characters after sanitization (only a-z, 0-9 are kept).`,
@@ -92,13 +148,17 @@ export function errorInvalidDestName(destName: string): MigrationToolsError {
92
148
  });
93
149
  }
94
150
 
95
- export function errorSameSourceAndTarget(dirName: string, hash: string): MigrationToolsError {
151
+ export function errorSameSourceAndTarget(dir: string, hash: string): MigrationToolsError {
152
+ const dirName = basename(dir);
96
153
  return new MigrationToolsError(
97
154
  'MIGRATION.SAME_SOURCE_AND_TARGET',
98
- 'Migration has same source and target',
155
+ 'Migration without data-transform operations has same source and target',
99
156
  {
100
- why: `Migration "${dirName}" has from === to === "${hash}". A migration must transition between two different contract states.`,
101
- fix: 'Delete the invalid migration directory and re-run migration plan.',
157
+ why: `Migration "${dirName}" has from === to === "${hash}" and declares no data-transform operations. Self-edges are only allowed when the migration runs at least one dataTransform otherwise the migration is a no-op.`,
158
+ fix: reemitHint(
159
+ dir,
160
+ 'and either change the contract so from ≠ to, add a dataTransform op, or delete the directory if the migration is unwanted.',
161
+ ),
102
162
  details: { dirName, hash },
103
163
  },
104
164
  );
@@ -175,14 +235,147 @@ export function errorInvalidRefValue(value: string): MigrationToolsError {
175
235
  });
176
236
  }
177
237
 
178
- export function errorDuplicateMigrationId(migrationId: string): MigrationToolsError {
238
+ export function errorDuplicateMigrationHash(migrationHash: string): MigrationToolsError {
239
+ return new MigrationToolsError(
240
+ 'MIGRATION.DUPLICATE_MIGRATION_HASH',
241
+ 'Duplicate migrationHash in migration graph',
242
+ {
243
+ why: `Multiple migrations share migrationHash "${migrationHash}". Each migration must have a unique content-addressed identity.`,
244
+ fix: 'Regenerate one of the conflicting migrations so each migrationHash is unique, then re-run migration commands.',
245
+ details: { migrationHash },
246
+ },
247
+ );
248
+ }
249
+
250
+ export function errorInvalidInvariantId(invariantId: string): MigrationToolsError {
251
+ return new MigrationToolsError('MIGRATION.INVALID_INVARIANT_ID', 'Invalid invariantId', {
252
+ why: `invariantId ${JSON.stringify(invariantId)} is invalid. Ids must be non-empty and contain no whitespace or control characters (including Unicode whitespace like NBSP); other content (kebab-case, camelCase, namespaced, Unicode letters) is allowed.`,
253
+ fix: 'Pick an invariantId without spaces, tabs, newlines, or control characters — e.g. "backfill-user-phone", "users/backfill-phone", or "BackfillUserPhone".',
254
+ details: { invariantId },
255
+ });
256
+ }
257
+
258
+ export function errorDuplicateInvariantInEdge(invariantId: string): MigrationToolsError {
179
259
  return new MigrationToolsError(
180
- 'MIGRATION.DUPLICATE_MIGRATION_ID',
181
- 'Duplicate migrationId in migration graph',
260
+ 'MIGRATION.DUPLICATE_INVARIANT_IN_EDGE',
261
+ 'Duplicate invariantId on a single migration',
182
262
  {
183
- why: `Multiple migrations share migrationId "${migrationId}". Each migration must have a unique content-addressed identity.`,
184
- fix: 'Regenerate one of the conflicting migrations so each migrationId is unique, then re-run migration commands.',
185
- details: { migrationId },
263
+ why: `invariantId "${invariantId}" is declared by more than one dataTransform on the same migration. The marker stores invariants as a set and the routing layer treats them as edge-level, so two ops cannot share a routing identity.`,
264
+ fix: 'Rename one of the conflicting dataTransform invariantIds, or drop invariantId on the op that does not need to be routing-visible.',
265
+ details: { invariantId },
186
266
  },
187
267
  );
188
268
  }
269
+
270
+ export function errorProvidedInvariantsMismatch(
271
+ filePath: string,
272
+ stored: readonly string[],
273
+ derived: readonly string[],
274
+ ): MigrationToolsError {
275
+ const storedSet = new Set(stored);
276
+ const derivedSet = new Set(derived);
277
+ const missing = [...derivedSet].filter((id) => !storedSet.has(id));
278
+ const extra = [...storedSet].filter((id) => !derivedSet.has(id));
279
+ // When sets agree but arrays don't, the only difference is ordering — call
280
+ // it out so the reader doesn't stare at two visually-identical arrays.
281
+ // Canonical providedInvariants is sorted ascending; a manifest with the
282
+ // same ids in a different order is still a mismatch (the hash check would
283
+ // also fail), but the human-readable diagnostic is otherwise unhelpful.
284
+ const orderingOnly = missing.length === 0 && extra.length === 0;
285
+ const why = orderingOnly
286
+ ? `migration.json at "${filePath}" stores providedInvariants ${JSON.stringify(stored)}, but the canonical value derived from ops.json is ${JSON.stringify(derived)} — same ids, different order. Canonical providedInvariants is sorted ascending.`
287
+ : `migration.json at "${filePath}" stores providedInvariants ${JSON.stringify(stored)}, but the value derived from ops.json is ${JSON.stringify(derived)}. The manifest copy was likely hand-edited without re-emitting.`;
288
+ return new MigrationToolsError(
289
+ 'MIGRATION.PROVIDED_INVARIANTS_MISMATCH',
290
+ 'providedInvariants on migration.json disagrees with ops.json',
291
+ {
292
+ why,
293
+ fix: reemitHint(dirname(filePath), 'or restore the directory from version control.'),
294
+ details: { filePath, stored, derived, difference: { missing, extra } },
295
+ },
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Wire-shape edge surfaced through the JSON envelope's
301
+ * `meta.structuralPath` of `MIGRATION.NO_INVARIANT_PATH`. Slim by design —
302
+ * authoring metadata (`createdAt`, `labels`) lives on `MigrationEdge` but
303
+ * is intentionally dropped here so the envelope stays stable across
304
+ * graph-internal refactors.
305
+ *
306
+ * Stability: any field added here is part of the public CLI JSON contract.
307
+ * Callers (CLI consumers, agents) must be able to treat
308
+ * `(dirName, migrationHash, from, to, invariants)` as the canonical shape.
309
+ */
310
+ export interface NoInvariantPathStructuralEdge {
311
+ readonly dirName: string;
312
+ readonly migrationHash: string;
313
+ readonly from: string;
314
+ readonly to: string;
315
+ readonly invariants: readonly string[];
316
+ }
317
+
318
+ export function errorNoInvariantPath(args: {
319
+ readonly refName?: string;
320
+ readonly required: readonly string[];
321
+ readonly missing: readonly string[];
322
+ readonly structuralPath: readonly NoInvariantPathStructuralEdge[];
323
+ }): MigrationToolsError {
324
+ const { refName, required, missing, structuralPath } = args;
325
+ const refClause = refName ? `Ref "${refName}"` : 'Target';
326
+ const missingList = missing.map((id) => JSON.stringify(id)).join(', ');
327
+ const requiredList = required.map((id) => JSON.stringify(id)).join(', ');
328
+ return new MigrationToolsError(
329
+ 'MIGRATION.NO_INVARIANT_PATH',
330
+ 'No path covers the required invariants',
331
+ {
332
+ why: `${refClause} requires invariants the reachable path doesn't cover. required=[${requiredList}], missing=[${missingList}].`,
333
+ fix: 'Add a migration on the path that runs `dataTransform({ invariantId: "<id>", … })` for each missing invariant, or retarget the ref to a hash whose path already provides them.',
334
+ details: {
335
+ required,
336
+ missing,
337
+ structuralPath,
338
+ ...ifDefined('refName', refName),
339
+ },
340
+ },
341
+ );
342
+ }
343
+
344
+ export function errorUnknownInvariant(args: {
345
+ readonly refName?: string;
346
+ readonly unknown: readonly string[];
347
+ readonly declared: readonly string[];
348
+ }): MigrationToolsError {
349
+ const { refName, unknown, declared } = args;
350
+ const refClause = refName ? `Ref "${refName}" declares` : 'Declares';
351
+ const unknownList = unknown.map((id) => JSON.stringify(id)).join(', ');
352
+ return new MigrationToolsError(
353
+ 'MIGRATION.UNKNOWN_INVARIANT',
354
+ 'Ref declares invariants no migration in the graph provides',
355
+ {
356
+ why: `${refClause} invariants no migration in the graph provides. unknown=[${unknownList}].`,
357
+ fix: 'Either the ref has a typo, or the declaring migration has not been authored/attested yet. Re-check the ref file and the migrations directory.',
358
+ details: {
359
+ unknown,
360
+ declared,
361
+ ...ifDefined('refName', refName),
362
+ },
363
+ },
364
+ );
365
+ }
366
+
367
+ export function errorMigrationHashMismatch(
368
+ dir: string,
369
+ storedHash: string,
370
+ computedHash: string,
371
+ ): MigrationToolsError {
372
+ // Render a cwd-relative path in the human-readable diagnostic so users
373
+ // running CLI commands from the project root see a familiar short path.
374
+ // Keep the absolute path in `details.dir` for machine consumers.
375
+ const relativeDir = relative(process.cwd(), dir);
376
+ return new MigrationToolsError('MIGRATION.HASH_MISMATCH', 'Migration package is corrupt', {
377
+ why: `Stored migrationHash "${storedHash}" does not match the recomputed hash "${computedHash}" for "${relativeDir}". The migration.json or ops.json has been edited or partially written since emit.`,
378
+ fix: reemitHint(dir, 'or restore the directory from version control.'),
379
+ details: { dir, storedHash, computedHash },
380
+ });
381
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ errorInvalidJson,
3
+ errorNoInvariantPath,
4
+ errorUnknownInvariant,
5
+ MigrationToolsError,
6
+ type NoInvariantPathStructuralEdge,
7
+ } from '../errors';
@@ -0,0 +1 @@
1
+ export type { MigrationEdge, MigrationGraph } from '../graph';
@@ -0,0 +1,2 @@
1
+ export type { VerifyResult } from '../hash';
2
+ export { computeMigrationHash, verifyMigrationHash } from '../hash';
@@ -0,0 +1 @@
1
+ export { deriveProvidedInvariants, validateInvariantId } from '../invariants';
package/src/exports/io.ts CHANGED
@@ -3,7 +3,7 @@ export {
3
3
  formatMigrationDirName,
4
4
  readMigrationPackage,
5
5
  readMigrationsDir,
6
- writeMigrationManifest,
6
+ writeMigrationMetadata,
7
7
  writeMigrationOps,
8
8
  writeMigrationPackage,
9
9
  } from '../io';
@@ -0,0 +1 @@
1
+ export type { MigrationHints, MigrationMetadata } from '../metadata';
@@ -1,4 +1,4 @@
1
- export type { PathDecision } from '../dag';
1
+ export type { PathDecision } from '../migration-graph';
2
2
  export {
3
3
  detectCycles,
4
4
  detectOrphans,
@@ -6,6 +6,7 @@ export {
6
6
  findLeaf,
7
7
  findPath,
8
8
  findPathWithDecision,
9
+ findPathWithInvariants,
9
10
  findReachableLeaves,
10
11
  reconstructGraph,
11
- } from '../dag';
12
+ } from '../migration-graph';
@@ -4,5 +4,4 @@ export {
4
4
  Migration,
5
5
  type MigrationArtifacts,
6
6
  type MigrationMeta,
7
- printMigrationHelp,
8
7
  } from '../migration-base';
@@ -0,0 +1 @@
1
+ export type { MigrationOps, MigrationPackage } from '../package';
package/src/graph-ops.ts CHANGED
@@ -3,13 +3,18 @@ import { Queue } from './queue';
3
3
  /**
4
4
  * One step of a BFS traversal.
5
5
  *
6
- * `parent` and `incomingEdge` are `null` for start 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.
6
+ * `parent` and `incomingEdge` are `null` for start states — they were not
7
+ * reached via any edge. For every other state they record the predecessor
8
+ * state and the edge by which this state was first reached.
9
+ *
10
+ * `state` is the BFS state, most often a string (graph node identifier) but
11
+ * can be a composite object. The string overload keeps the common case
12
+ * ergonomic; the generic overload accepts a caller-supplied `key` function
13
+ * that produces a stable equality key for dedup.
9
14
  */
10
- export interface BfsStep<E> {
11
- readonly node: string;
12
- readonly parent: string | null;
15
+ export interface BfsStep<S, E> {
16
+ readonly state: S;
17
+ readonly parent: S | null;
13
18
  readonly incomingEdge: E | null;
14
19
  }
15
20
 
@@ -17,48 +22,70 @@ export interface BfsStep<E> {
17
22
  * Generic breadth-first traversal.
18
23
  *
19
24
  * Direction (forward/reverse) is expressed by the caller's `neighbours`
20
- * closure: return `{ next, edge }` pairs where `next` is the node to visit
25
+ * closure: return `{ next, edge }` pairs where `next` is the state to visit
21
26
  * next and `edge` is the edge that connects them. Callers that don't need
22
27
  * path reconstruction can ignore the `parent`/`incomingEdge` fields of each
23
28
  * yielded step.
24
29
  *
30
+ * Ordering — when the result needs to be deterministic (path-finding) the
31
+ * caller is responsible for sorting inside `neighbours`; this generator
32
+ * does not impose an ordering hook of its own. State-dependent orderings
33
+ * have full access to the source state inside the closure.
34
+ *
25
35
  * Stops are intrinsic — callers `break` out of the `for..of` loop when
26
36
  * they've found what they're looking for.
27
- *
28
- * `ordering`, if provided, controls the order in which neighbours of each
29
- * node are enqueued. Only matters for path-finding: a deterministic ordering
30
- * makes BFS return a deterministic shortest path when multiple exist.
31
37
  */
32
- export function* bfs<E>(
38
+ export function bfs<E>(
33
39
  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>> {
40
+ neighbours: (state: string) => Iterable<{ next: string; edge: E }>,
41
+ ): Generator<BfsStep<string, E>>;
42
+ export function bfs<S, E>(
43
+ starts: Iterable<S>,
44
+ neighbours: (state: S) => Iterable<{ next: S; edge: E }>,
45
+ key: (state: S) => string,
46
+ ): Generator<BfsStep<S, E>>;
47
+ export function* bfs<S, E>(
48
+ starts: Iterable<S>,
49
+ neighbours: (state: S) => Iterable<{ next: S; edge: E }>,
50
+ // Identity default for the string overload. TypeScript can't express
51
+ // "default applies only when S = string", so this cast bridges the
52
+ // generic implementation signature to the public overloads — which
53
+ // guarantee `key` is omitted only when S = string at the call site.
54
+ key: (state: S) => string = (state) => state as unknown as string,
55
+ ): Generator<BfsStep<S, E>> {
56
+ // Queue entries carry the state alongside its key so we don't recompute
57
+ // key() twice per visit (once on dedup, once on parent lookup). Composite
58
+ // keys can be non-trivial to compute; string-overload callers pay nothing
59
+ // since key() is identity there.
60
+ interface Entry {
61
+ readonly state: S;
62
+ readonly key: string;
63
+ }
37
64
  const visited = new Set<string>();
38
- const parentMap = new Map<string, { parent: string; edge: E }>();
39
- const queue = new Queue<string>();
65
+ const parentMap = new Map<string, { parent: S; edge: E }>();
66
+ const queue = new Queue<Entry>();
40
67
  for (const start of starts) {
41
- if (!visited.has(start)) {
42
- visited.add(start);
43
- queue.push(start);
68
+ const k = key(start);
69
+ if (!visited.has(k)) {
70
+ visited.add(k);
71
+ queue.push({ state: start, key: k });
44
72
  }
45
73
  }
46
74
  while (!queue.isEmpty) {
47
- const current = queue.shift();
48
- const parentInfo = parentMap.get(current);
75
+ const { state: current, key: curKey } = queue.shift();
76
+ const parentInfo = parentMap.get(curKey);
49
77
  yield {
50
- node: current,
78
+ state: current,
51
79
  parent: parentInfo?.parent ?? null,
52
80
  incomingEdge: parentInfo?.edge ?? null,
53
81
  };
54
82
 
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);
83
+ for (const { next, edge } of neighbours(current)) {
84
+ const k = key(next);
85
+ if (!visited.has(k)) {
86
+ visited.add(k);
87
+ parentMap.set(k, { parent: current, edge });
88
+ queue.push({ state: next, key: k });
62
89
  }
63
90
  }
64
91
  }
package/src/graph.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * An entry in the migration graph. All on-disk migrations are attested,
3
+ * so `migrationHash` is always a string.
4
+ */
5
+ export interface MigrationEdge {
6
+ readonly from: string;
7
+ readonly to: string;
8
+ readonly migrationHash: string;
9
+ readonly dirName: string;
10
+ readonly createdAt: string;
11
+ readonly labels: readonly string[];
12
+ /**
13
+ * Sorted, deduplicated list of `invariantId`s this edge provides.
14
+ * An empty array means the migration declares no routing-visible
15
+ * data transforms.
16
+ */
17
+ readonly invariants: readonly string[];
18
+ }
19
+
20
+ export interface MigrationGraph {
21
+ readonly nodes: ReadonlySet<string>;
22
+ readonly forwardChain: ReadonlyMap<string, readonly MigrationEdge[]>;
23
+ readonly reverseChain: ReadonlyMap<string, readonly MigrationEdge[]>;
24
+ readonly migrationByHash: ReadonlyMap<string, MigrationEdge>;
25
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalizeJson } from './canonicalize-json';
3
+ import type { MigrationMetadata } from './metadata';
4
+ import type { MigrationOps, MigrationPackage } from './package';
5
+
6
+ export interface VerifyResult {
7
+ readonly ok: boolean;
8
+ readonly reason?: 'mismatch';
9
+ readonly storedHash: string;
10
+ readonly computedHash: string;
11
+ }
12
+
13
+ function sha256Hex(input: string): string {
14
+ return createHash('sha256').update(input).digest('hex');
15
+ }
16
+
17
+ /**
18
+ * Content-addressed migration hash over (metadata envelope sans
19
+ * contracts/hints/signature, ops). See ADR 199 — Storage-only migration
20
+ * identity for the rationale: contracts are anchored separately by the
21
+ * storage-hash bookends inside the envelope; planner hints are advisory
22
+ * and must not affect identity.
23
+ *
24
+ * The integrity check is purely structural, not semantic. The function
25
+ * canonicalizes its inputs via `sortKeys` (recursive) + `JSON.stringify`
26
+ * and hashes the result. Target-specific operation payloads (`step.sql`,
27
+ * Mongo's pipeline AST, …) are hashed verbatim — no per-target
28
+ * normalization is required, because what's being verified is "do the
29
+ * on-disk bytes still produce their recorded hash", not "do two
30
+ * semantically-equivalent migrations hash the same". The latter is an
31
+ * emit-drift concern (ADR 192 step 2).
32
+ *
33
+ * The symmetry across write and read holds because `JSON.parse(
34
+ * JSON.stringify(x))` round-trips JSON-safe values losslessly and
35
+ * `sortKeys` is idempotent and deterministic — write-time and read-time
36
+ * canonicalization produce the same canonical bytes regardless of
37
+ * source-side key ordering or whitespace.
38
+ *
39
+ * The `migrationHash` field on the metadata is stripped before hashing
40
+ * so the function can be used both at write time (when no hash exists
41
+ * yet) and at verify time (rehashing an already-attested record).
42
+ */
43
+ export function computeMigrationHash(
44
+ metadata: Omit<MigrationMetadata, 'migrationHash'> & { readonly migrationHash?: string },
45
+ ops: MigrationOps,
46
+ ): string {
47
+ const {
48
+ migrationHash: _migrationHash,
49
+ signature: _signature,
50
+ fromContract: _fromContract,
51
+ toContract: _toContract,
52
+ hints: _hints,
53
+ ...strippedMeta
54
+ } = metadata;
55
+
56
+ const canonicalMetadata = canonicalizeJson(strippedMeta);
57
+ const canonicalOps = canonicalizeJson(ops);
58
+
59
+ const partHashes = [canonicalMetadata, canonicalOps].map(sha256Hex);
60
+ const hash = sha256Hex(canonicalizeJson(partHashes));
61
+
62
+ return `sha256:${hash}`;
63
+ }
64
+
65
+ /**
66
+ * Re-hash an in-memory migration package and compare against the stored
67
+ * `migrationHash`. See `computeMigrationHash` for the canonicalization rules.
68
+ *
69
+ * Returns `{ ok: true }` when the package is internally consistent, or
70
+ * `{ ok: false, reason: 'mismatch', storedHash, computedHash }` when it is
71
+ * not — typically a sign of FS corruption, partial writes, or a post-emit
72
+ * hand edit.
73
+ */
74
+ export function verifyMigrationHash(pkg: MigrationPackage): VerifyResult {
75
+ const computed = computeMigrationHash(pkg.metadata, pkg.ops);
76
+
77
+ if (pkg.metadata.migrationHash === computed) {
78
+ return {
79
+ ok: true,
80
+ storedHash: pkg.metadata.migrationHash,
81
+ computedHash: computed,
82
+ };
83
+ }
84
+
85
+ return {
86
+ ok: false,
87
+ reason: 'mismatch',
88
+ storedHash: pkg.metadata.migrationHash,
89
+ computedHash: computed,
90
+ };
91
+ }