@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
@@ -1,2 +1,10 @@
1
- export type { Refs } from '../refs';
2
- export { readRefs, resolveRef, validateRefName, validateRefValue, writeRefs } from '../refs';
1
+ export type { RefEntry, Refs } from '../refs';
2
+ export {
3
+ deleteRef,
4
+ readRef,
5
+ readRefs,
6
+ resolveRef,
7
+ validateRefName,
8
+ validateRefValue,
9
+ writeRef,
10
+ } from '../refs';
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
+ }
@@ -0,0 +1,45 @@
1
+ import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
2
+ import { errorDuplicateInvariantInEdge, errorInvalidInvariantId } from './errors';
3
+ import type { MigrationOps } from './package';
4
+
5
+ /**
6
+ * Hygiene check for `invariantId`. Rejects empty values plus any
7
+ * whitespace or control character (including Unicode whitespace like
8
+ * NBSP and em space, which are visually identical to ASCII space and
9
+ * routinely sneak in via paste).
10
+ */
11
+ export function validateInvariantId(invariantId: string): boolean {
12
+ if (invariantId.length === 0) return false;
13
+ return !/[\p{Cc}\p{White_Space}]/u.test(invariantId);
14
+ }
15
+
16
+ /**
17
+ * Walk a migration's operations and produce its `providedInvariants`
18
+ * aggregate: the sorted, deduplicated list of `invariantId`s declared
19
+ * by data-transform ops. Ops without `operationClass === 'data'` are
20
+ * skipped; data ops without an `invariantId` are skipped.
21
+ *
22
+ * Throws `MIGRATION.INVALID_INVARIANT_ID` on a malformed id and
23
+ * `MIGRATION.DUPLICATE_INVARIANT_IN_EDGE` on duplicates.
24
+ */
25
+ export function deriveProvidedInvariants(ops: MigrationOps): readonly string[] {
26
+ const seen = new Set<string>();
27
+ for (const op of ops) {
28
+ const invariantId = readInvariantId(op);
29
+ if (invariantId === undefined) continue;
30
+ if (!validateInvariantId(invariantId)) {
31
+ throw errorInvalidInvariantId(invariantId);
32
+ }
33
+ if (seen.has(invariantId)) {
34
+ throw errorDuplicateInvariantInEdge(invariantId);
35
+ }
36
+ seen.add(invariantId);
37
+ }
38
+ return [...seen].sort();
39
+ }
40
+
41
+ function readInvariantId(op: MigrationPlanOperation): string | undefined {
42
+ if (op.operationClass !== 'data') return undefined;
43
+ const candidate = (op as { invariantId?: unknown }).invariantId;
44
+ return typeof candidate === 'string' ? candidate : undefined;
45
+ }
package/src/io.ts CHANGED
@@ -7,9 +7,15 @@ import {
7
7
  errorInvalidJson,
8
8
  errorInvalidManifest,
9
9
  errorInvalidSlug,
10
+ errorMigrationHashMismatch,
10
11
  errorMissingFile,
12
+ errorProvidedInvariantsMismatch,
11
13
  } from './errors';
12
- import type { MigrationBundle, MigrationManifest, MigrationOps } from './types';
14
+ import { verifyMigrationHash } from './hash';
15
+ import { deriveProvidedInvariants } from './invariants';
16
+ import type { MigrationMetadata } from './metadata';
17
+ import { MigrationOpsSchema } from './op-schema';
18
+ import type { MigrationOps, MigrationPackage } from './package';
13
19
 
14
20
  const MANIFEST_FILE = 'migration.json';
15
21
  const OPS_FILE = 'ops.json';
@@ -25,15 +31,16 @@ const MigrationHintsSchema = type({
25
31
  plannerVersion: 'string',
26
32
  });
27
33
 
28
- const MigrationManifestSchema = type({
29
- from: 'string',
34
+ const MigrationMetadataSchema = type({
35
+ '+': 'reject',
36
+ from: 'string > 0 | null',
30
37
  to: 'string',
31
- migrationId: 'string',
32
- kind: "'regular' | 'baseline'",
38
+ migrationHash: 'string',
33
39
  fromContract: 'object | null',
34
40
  toContract: 'object',
35
41
  hints: MigrationHintsSchema,
36
42
  labels: 'string[]',
43
+ providedInvariants: 'string[]',
37
44
  'authorship?': type({
38
45
  'author?': 'string',
39
46
  'email?': 'string',
@@ -45,18 +52,9 @@ const MigrationManifestSchema = type({
45
52
  createdAt: 'string',
46
53
  });
47
54
 
48
- const MigrationOpSchema = type({
49
- id: 'string',
50
- label: 'string',
51
- operationClass: "'additive' | 'widening' | 'destructive' | 'data'",
52
- });
53
-
54
- // Intentionally shallow: operation-specific payload validation is owned by planner/runner layers.
55
- const MigrationOpsSchema = MigrationOpSchema.array();
56
-
57
55
  export async function writeMigrationPackage(
58
56
  dir: string,
59
- manifest: MigrationManifest,
57
+ metadata: MigrationMetadata,
60
58
  ops: MigrationOps,
61
59
  ): Promise<void> {
62
60
  await mkdir(dirname(dir), { recursive: true });
@@ -70,7 +68,9 @@ export async function writeMigrationPackage(
70
68
  throw error;
71
69
  }
72
70
 
73
- await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(manifest, null, 2), { flag: 'wx' });
71
+ await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(metadata, null, 2), {
72
+ flag: 'wx',
73
+ });
74
74
  await writeFile(join(dir, OPS_FILE), JSON.stringify(ops, null, 2), { flag: 'wx' });
75
75
  }
76
76
 
@@ -98,18 +98,18 @@ export async function copyFilesWithRename(
98
98
  }
99
99
  }
100
100
 
101
- export async function writeMigrationManifest(
101
+ export async function writeMigrationMetadata(
102
102
  dir: string,
103
- manifest: MigrationManifest,
103
+ metadata: MigrationMetadata,
104
104
  ): Promise<void> {
105
- await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
105
+ await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(metadata, null, 2)}\n`);
106
106
  }
107
107
 
108
108
  export async function writeMigrationOps(dir: string, ops: MigrationOps): Promise<void> {
109
109
  await writeFile(join(dir, OPS_FILE), `${JSON.stringify(ops, null, 2)}\n`);
110
110
  }
111
111
 
112
- export async function readMigrationPackage(dir: string): Promise<MigrationBundle> {
112
+ export async function readMigrationPackage(dir: string): Promise<MigrationPackage> {
113
113
  const manifestPath = join(dir, MANIFEST_FILE);
114
114
  const opsPath = join(dir, OPS_FILE);
115
115
 
@@ -133,9 +133,9 @@ export async function readMigrationPackage(dir: string): Promise<MigrationBundle
133
133
  throw error;
134
134
  }
135
135
 
136
- let manifest: MigrationManifest;
136
+ let metadata: MigrationMetadata;
137
137
  try {
138
- manifest = JSON.parse(manifestRaw);
138
+ metadata = JSON.parse(manifestRaw);
139
139
  } catch (e) {
140
140
  throw errorInvalidJson(manifestPath, e instanceof Error ? e.message : String(e));
141
141
  }
@@ -147,22 +147,48 @@ export async function readMigrationPackage(dir: string): Promise<MigrationBundle
147
147
  throw errorInvalidJson(opsPath, e instanceof Error ? e.message : String(e));
148
148
  }
149
149
 
150
- validateManifest(manifest, manifestPath);
150
+ validateMetadata(metadata, manifestPath);
151
151
  validateOps(ops, opsPath);
152
152
 
153
- return {
153
+ // Re-derive before the hash check so format/duplicate diagnostics
154
+ // fire with their dedicated codes rather than as a generic hash mismatch.
155
+ const derivedInvariants = deriveProvidedInvariants(ops);
156
+ if (!arraysEqual(metadata.providedInvariants, derivedInvariants)) {
157
+ throw errorProvidedInvariantsMismatch(
158
+ manifestPath,
159
+ metadata.providedInvariants,
160
+ derivedInvariants,
161
+ );
162
+ }
163
+
164
+ const pkg: MigrationPackage = {
154
165
  dirName: basename(dir),
155
166
  dirPath: dir,
156
- manifest,
167
+ metadata,
157
168
  ops,
158
169
  };
170
+
171
+ const verification = verifyMigrationHash(pkg);
172
+ if (!verification.ok) {
173
+ throw errorMigrationHashMismatch(dir, verification.storedHash, verification.computedHash);
174
+ }
175
+
176
+ return pkg;
177
+ }
178
+
179
+ function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
180
+ if (a.length !== b.length) return false;
181
+ for (let i = 0; i < a.length; i++) {
182
+ if (a[i] !== b[i]) return false;
183
+ }
184
+ return true;
159
185
  }
160
186
 
161
- function validateManifest(
162
- manifest: unknown,
187
+ function validateMetadata(
188
+ metadata: unknown,
163
189
  filePath: string,
164
- ): asserts manifest is MigrationManifest {
165
- const result = MigrationManifestSchema(manifest);
190
+ ): asserts metadata is MigrationMetadata {
191
+ const result = MigrationMetadataSchema(metadata);
166
192
  if (result instanceof type.errors) {
167
193
  throw errorInvalidManifest(filePath, result.summary);
168
194
  }
@@ -177,7 +203,7 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
177
203
 
178
204
  export async function readMigrationsDir(
179
205
  migrationsRoot: string,
180
- ): Promise<readonly MigrationBundle[]> {
206
+ ): Promise<readonly MigrationPackage[]> {
181
207
  let entries: string[];
182
208
  try {
183
209
  entries = await readdir(migrationsRoot);
@@ -188,7 +214,7 @@ export async function readMigrationsDir(
188
214
  throw error;
189
215
  }
190
216
 
191
- const packages: MigrationBundle[] = [];
217
+ const packages: MigrationPackage[] = [];
192
218
 
193
219
  for (const entry of entries.sort()) {
194
220
  const entryPath = join(migrationsRoot, entry);
@@ -0,0 +1,41 @@
1
+ import type { Contract } from '@prisma-next/contract/types';
2
+
3
+ export interface MigrationHints {
4
+ readonly used: readonly string[];
5
+ readonly applied: readonly string[];
6
+ readonly plannerVersion: string;
7
+ }
8
+
9
+ /**
10
+ * In-memory migration metadata envelope. Every migration is content-addressed:
11
+ * the `migrationHash` is a hash over the metadata envelope plus the operations
12
+ * list, computed at write time. There is no draft state — a migration
13
+ * directory either exists with fully attested metadata or it does not.
14
+ *
15
+ * When the planner cannot lower an operation because of an unfilled
16
+ * `placeholder(...)` slot, the migration is still written with `migrationHash`
17
+ * hashed over `ops: []`. Re-running self-emit after the user fills the
18
+ * placeholder produces a *different* `migrationHash` (committed to the real
19
+ * ops); this is intentional.
20
+ *
21
+ * The on-disk JSON shape in `migration.json` matches this type field-for-field
22
+ * — `JSON.stringify(metadata, null, 2)` is the canonical writer output.
23
+ */
24
+ export interface MigrationMetadata {
25
+ readonly migrationHash: string;
26
+ readonly from: string | null;
27
+ readonly to: string;
28
+ readonly fromContract: Contract | null;
29
+ readonly toContract: Contract;
30
+ readonly hints: MigrationHints;
31
+ readonly labels: readonly string[];
32
+ /**
33
+ * Sorted, deduplicated list of `invariantId`s declared by the
34
+ * migration's data-transform ops. Always present; an empty array
35
+ * means the migration has no routing-visible data transforms.
36
+ */
37
+ readonly providedInvariants: readonly string[];
38
+ readonly authorship?: { readonly author?: string; readonly email?: string };
39
+ readonly signature?: { readonly keyId: string; readonly value: string } | null;
40
+ readonly createdAt: string;
41
+ }