@prisma-next/migration-tools 0.5.0-dev.1 → 0.5.0-dev.10

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 (76) hide show
  1. package/README.md +32 -20
  2. package/dist/{constants-BRi0X7B_.mjs → constants-WVGVMOdu.mjs} +1 -1
  3. package/dist/{constants-BRi0X7B_.mjs.map → constants-WVGVMOdu.mjs.map} +1 -1
  4. package/dist/{errors-BKbRGCJM.mjs → errors-CZ9JD4sd.mjs} +50 -21
  5. package/dist/errors-CZ9JD4sd.mjs.map +1 -0
  6. package/dist/exports/constants.mjs +1 -1
  7. package/dist/exports/dag.d.mts +4 -3
  8. package/dist/exports/dag.d.mts.map +1 -1
  9. package/dist/exports/dag.mjs +15 -15
  10. package/dist/exports/dag.mjs.map +1 -1
  11. package/dist/exports/{types.d.mts → errors.d.mts} +6 -8
  12. package/dist/exports/errors.d.mts.map +1 -0
  13. package/dist/exports/errors.mjs +3 -0
  14. package/dist/exports/graph.d.mts +2 -0
  15. package/dist/exports/graph.mjs +1 -0
  16. package/dist/exports/hash.d.mts +52 -0
  17. package/dist/exports/hash.d.mts.map +1 -0
  18. package/dist/exports/hash.mjs +3 -0
  19. package/dist/exports/io.d.mts +7 -6
  20. package/dist/exports/io.d.mts.map +1 -1
  21. package/dist/exports/io.mjs +156 -2
  22. package/dist/exports/io.mjs.map +1 -0
  23. package/dist/exports/metadata.d.mts +2 -0
  24. package/dist/exports/metadata.mjs +1 -0
  25. package/dist/exports/migration-ts.mjs +1 -1
  26. package/dist/exports/migration.d.mts +13 -10
  27. package/dist/exports/migration.d.mts.map +1 -1
  28. package/dist/exports/migration.mjs +20 -21
  29. package/dist/exports/migration.mjs.map +1 -1
  30. package/dist/exports/package.d.mts +2 -0
  31. package/dist/exports/package.mjs +1 -0
  32. package/dist/exports/refs.d.mts +11 -5
  33. package/dist/exports/refs.d.mts.map +1 -1
  34. package/dist/exports/refs.mjs +106 -30
  35. package/dist/exports/refs.mjs.map +1 -1
  36. package/dist/graph-HiqjZROg.d.mts +22 -0
  37. package/dist/graph-HiqjZROg.d.mts.map +1 -0
  38. package/dist/hash-BNWumjn7.mjs +76 -0
  39. package/dist/hash-BNWumjn7.mjs.map +1 -0
  40. package/dist/metadata-DDa5L-uD.d.mts +45 -0
  41. package/dist/metadata-DDa5L-uD.d.mts.map +1 -0
  42. package/dist/package-BJ5KAEcD.d.mts +21 -0
  43. package/dist/package-BJ5KAEcD.d.mts.map +1 -0
  44. package/package.json +23 -11
  45. package/src/dag.ts +19 -18
  46. package/src/errors.ts +57 -15
  47. package/src/exports/errors.ts +1 -0
  48. package/src/exports/graph.ts +1 -0
  49. package/src/exports/hash.ts +2 -0
  50. package/src/exports/io.ts +1 -1
  51. package/src/exports/metadata.ts +1 -0
  52. package/src/exports/package.ts +1 -0
  53. package/src/exports/refs.ts +10 -2
  54. package/src/graph.ts +19 -0
  55. package/src/hash.ts +91 -0
  56. package/src/io.ts +32 -20
  57. package/src/metadata.ts +36 -0
  58. package/src/migration-base.ts +32 -28
  59. package/src/package.ts +18 -0
  60. package/src/refs.ts +148 -37
  61. package/dist/attestation-DtF8tEOM.mjs +0 -65
  62. package/dist/attestation-DtF8tEOM.mjs.map +0 -1
  63. package/dist/errors-BKbRGCJM.mjs.map +0 -1
  64. package/dist/exports/attestation.d.mts +0 -37
  65. package/dist/exports/attestation.d.mts.map +0 -1
  66. package/dist/exports/attestation.mjs +0 -4
  67. package/dist/exports/types.d.mts.map +0 -1
  68. package/dist/exports/types.mjs +0 -3
  69. package/dist/io-CCnYsUHU.mjs +0 -153
  70. package/dist/io-CCnYsUHU.mjs.map +0 -1
  71. package/dist/types-DyGXcWWp.d.mts +0 -71
  72. package/dist/types-DyGXcWWp.d.mts.map +0 -1
  73. package/src/attestation.ts +0 -81
  74. package/src/exports/attestation.ts +0 -2
  75. package/src/exports/types.ts +0 -10
  76. package/src/types.ts +0 -66
package/src/errors.ts CHANGED
@@ -1,10 +1,29 @@
1
+ import { basename, dirname, relative } from 'pathe';
2
+
3
+ /**
4
+ * Build the canonical "re-emit this package" remediation hint.
5
+ *
6
+ * Every on-disk migration package ships its own `migration.ts` author-time
7
+ * file. Running it regenerates `migration.json` and `ops.json` with the
8
+ * correct hash + metadata, so it is the right primitive whenever a single
9
+ * package's on-disk artifacts are missing, malformed, or otherwise corrupt.
10
+ * Pointing users at `migration plan` would emit a *new* package rather than
11
+ * heal the broken one.
12
+ */
13
+ function reemitHint(dir: string, fallback?: string): string {
14
+ const relativeDir = relative(process.cwd(), dir);
15
+ const reemit = `Re-emit the package by running \`node "${relativeDir}/migration.ts"\``;
16
+ return fallback ? `${reemit}, ${fallback}` : `${reemit}.`;
17
+ }
18
+
1
19
  /**
2
20
  * Structured error for migration tooling operations.
3
21
  *
4
22
  * 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.).
23
+ * the MIGRATION namespace. These are tooling-time errors (file I/O, hash
24
+ * verification, migration history reconstruction), distinct from the runtime
25
+ * MIGRATION.* codes for apply-time failures (PRECHECK_FAILED, POSTCHECK_FAILED,
26
+ * etc.).
8
27
  *
9
28
  * Fields:
10
29
  * - code: Stable machine-readable code (MIGRATION.SUBCODE)
@@ -55,7 +74,10 @@ export function errorDirectoryExists(dir: string): MigrationToolsError {
55
74
  export function errorMissingFile(file: string, dir: string): MigrationToolsError {
56
75
  return new MigrationToolsError('MIGRATION.FILE_MISSING', `Missing ${file}`, {
57
76
  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.',
77
+ fix: reemitHint(
78
+ dir,
79
+ 'or delete the directory if the migration is unwanted and the source TypeScript is gone.',
80
+ ),
59
81
  details: { file, dir },
60
82
  });
61
83
  }
@@ -63,15 +85,15 @@ export function errorMissingFile(file: string, dir: string): MigrationToolsError
63
85
  export function errorInvalidJson(filePath: string, parseError: string): MigrationToolsError {
64
86
  return new MigrationToolsError('MIGRATION.INVALID_JSON', 'Invalid JSON in migration file', {
65
87
  why: `Failed to parse "${filePath}": ${parseError}`,
66
- fix: 'Fix the JSON syntax error, or delete the migration directory and re-run migration plan.',
88
+ fix: reemitHint(dirname(filePath), 'or restore the directory from version control.'),
67
89
  details: { filePath, parseError },
68
90
  });
69
91
  }
70
92
 
71
93
  export function errorInvalidManifest(filePath: string, reason: string): MigrationToolsError {
72
94
  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.',
95
+ why: `Migration manifest at "${filePath}" is invalid: ${reason}`,
96
+ fix: reemitHint(dirname(filePath), 'or restore the directory from version control.'),
75
97
  details: { filePath, reason },
76
98
  });
77
99
  }
@@ -92,13 +114,17 @@ export function errorInvalidDestName(destName: string): MigrationToolsError {
92
114
  });
93
115
  }
94
116
 
95
- export function errorSameSourceAndTarget(dirName: string, hash: string): MigrationToolsError {
117
+ export function errorSameSourceAndTarget(dir: string, hash: string): MigrationToolsError {
118
+ const dirName = basename(dir);
96
119
  return new MigrationToolsError(
97
120
  'MIGRATION.SAME_SOURCE_AND_TARGET',
98
121
  'Migration has same source and target',
99
122
  {
100
123
  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.',
124
+ fix: reemitHint(
125
+ dir,
126
+ 'or delete the directory if the migration is unwanted and the source TypeScript is gone.',
127
+ ),
102
128
  details: { dirName, hash },
103
129
  },
104
130
  );
@@ -175,14 +201,30 @@ export function errorInvalidRefValue(value: string): MigrationToolsError {
175
201
  });
176
202
  }
177
203
 
178
- export function errorDuplicateMigrationId(migrationId: string): MigrationToolsError {
204
+ export function errorDuplicateMigrationHash(migrationHash: string): MigrationToolsError {
179
205
  return new MigrationToolsError(
180
- 'MIGRATION.DUPLICATE_MIGRATION_ID',
181
- 'Duplicate migrationId in migration graph',
206
+ 'MIGRATION.DUPLICATE_MIGRATION_HASH',
207
+ 'Duplicate migrationHash in migration graph',
182
208
  {
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 },
209
+ why: `Multiple migrations share migrationHash "${migrationHash}". Each migration must have a unique content-addressed identity.`,
210
+ fix: 'Regenerate one of the conflicting migrations so each migrationHash is unique, then re-run migration commands.',
211
+ details: { migrationHash },
186
212
  },
187
213
  );
188
214
  }
215
+
216
+ export function errorMigrationHashMismatch(
217
+ dir: string,
218
+ storedHash: string,
219
+ computedHash: string,
220
+ ): MigrationToolsError {
221
+ // Render a cwd-relative path in the human-readable diagnostic so users
222
+ // running CLI commands from the project root see a familiar short path.
223
+ // Keep the absolute path in `details.dir` for machine consumers.
224
+ const relativeDir = relative(process.cwd(), dir);
225
+ return new MigrationToolsError('MIGRATION.HASH_MISMATCH', 'Migration package is corrupt', {
226
+ 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.`,
227
+ fix: reemitHint(dir, 'or restore the directory from version control.'),
228
+ details: { dir, storedHash, computedHash },
229
+ });
230
+ }
@@ -0,0 +1 @@
1
+ export { MigrationToolsError } from '../errors';
@@ -0,0 +1 @@
1
+ export type { MigrationChainEntry, MigrationGraph } from '../graph';
@@ -0,0 +1,2 @@
1
+ export type { VerifyResult } from '../hash';
2
+ export { computeMigrationHash, verifyMigrationHash } from '../hash';
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';
@@ -0,0 +1 @@
1
+ export type { MigrationOps, MigrationPackage } from '../package';
@@ -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.ts ADDED
@@ -0,0 +1,19 @@
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 MigrationChainEntry {
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
+
14
+ export interface MigrationGraph {
15
+ readonly nodes: ReadonlySet<string>;
16
+ readonly forwardChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;
17
+ readonly reverseChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;
18
+ readonly migrationByHash: ReadonlyMap<string, MigrationChainEntry>;
19
+ }
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
+ }
package/src/io.ts CHANGED
@@ -7,9 +7,12 @@ import {
7
7
  errorInvalidJson,
8
8
  errorInvalidManifest,
9
9
  errorInvalidSlug,
10
+ errorMigrationHashMismatch,
10
11
  errorMissingFile,
11
12
  } from './errors';
12
- import type { MigrationBundle, MigrationManifest, MigrationOps } from './types';
13
+ import { verifyMigrationHash } from './hash';
14
+ import type { MigrationMetadata } from './metadata';
15
+ import type { MigrationOps, MigrationPackage } from './package';
13
16
 
14
17
  const MANIFEST_FILE = 'migration.json';
15
18
  const OPS_FILE = 'ops.json';
@@ -25,10 +28,10 @@ const MigrationHintsSchema = type({
25
28
  plannerVersion: 'string',
26
29
  });
27
30
 
28
- const MigrationManifestSchema = type({
31
+ const MigrationMetadataSchema = type({
29
32
  from: 'string',
30
33
  to: 'string',
31
- migrationId: 'string',
34
+ migrationHash: 'string',
32
35
  kind: "'regular' | 'baseline'",
33
36
  fromContract: 'object | null',
34
37
  toContract: 'object',
@@ -56,7 +59,7 @@ const MigrationOpsSchema = MigrationOpSchema.array();
56
59
 
57
60
  export async function writeMigrationPackage(
58
61
  dir: string,
59
- manifest: MigrationManifest,
62
+ metadata: MigrationMetadata,
60
63
  ops: MigrationOps,
61
64
  ): Promise<void> {
62
65
  await mkdir(dirname(dir), { recursive: true });
@@ -70,7 +73,9 @@ export async function writeMigrationPackage(
70
73
  throw error;
71
74
  }
72
75
 
73
- await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(manifest, null, 2), { flag: 'wx' });
76
+ await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(metadata, null, 2), {
77
+ flag: 'wx',
78
+ });
74
79
  await writeFile(join(dir, OPS_FILE), JSON.stringify(ops, null, 2), { flag: 'wx' });
75
80
  }
76
81
 
@@ -98,18 +103,18 @@ export async function copyFilesWithRename(
98
103
  }
99
104
  }
100
105
 
101
- export async function writeMigrationManifest(
106
+ export async function writeMigrationMetadata(
102
107
  dir: string,
103
- manifest: MigrationManifest,
108
+ metadata: MigrationMetadata,
104
109
  ): Promise<void> {
105
- await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
110
+ await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(metadata, null, 2)}\n`);
106
111
  }
107
112
 
108
113
  export async function writeMigrationOps(dir: string, ops: MigrationOps): Promise<void> {
109
114
  await writeFile(join(dir, OPS_FILE), `${JSON.stringify(ops, null, 2)}\n`);
110
115
  }
111
116
 
112
- export async function readMigrationPackage(dir: string): Promise<MigrationBundle> {
117
+ export async function readMigrationPackage(dir: string): Promise<MigrationPackage> {
113
118
  const manifestPath = join(dir, MANIFEST_FILE);
114
119
  const opsPath = join(dir, OPS_FILE);
115
120
 
@@ -133,9 +138,9 @@ export async function readMigrationPackage(dir: string): Promise<MigrationBundle
133
138
  throw error;
134
139
  }
135
140
 
136
- let manifest: MigrationManifest;
141
+ let metadata: MigrationMetadata;
137
142
  try {
138
- manifest = JSON.parse(manifestRaw);
143
+ metadata = JSON.parse(manifestRaw);
139
144
  } catch (e) {
140
145
  throw errorInvalidJson(manifestPath, e instanceof Error ? e.message : String(e));
141
146
  }
@@ -147,22 +152,29 @@ export async function readMigrationPackage(dir: string): Promise<MigrationBundle
147
152
  throw errorInvalidJson(opsPath, e instanceof Error ? e.message : String(e));
148
153
  }
149
154
 
150
- validateManifest(manifest, manifestPath);
155
+ validateMetadata(metadata, manifestPath);
151
156
  validateOps(ops, opsPath);
152
157
 
153
- return {
158
+ const pkg: MigrationPackage = {
154
159
  dirName: basename(dir),
155
160
  dirPath: dir,
156
- manifest,
161
+ metadata,
157
162
  ops,
158
163
  };
164
+
165
+ const verification = verifyMigrationHash(pkg);
166
+ if (!verification.ok) {
167
+ throw errorMigrationHashMismatch(dir, verification.storedHash, verification.computedHash);
168
+ }
169
+
170
+ return pkg;
159
171
  }
160
172
 
161
- function validateManifest(
162
- manifest: unknown,
173
+ function validateMetadata(
174
+ metadata: unknown,
163
175
  filePath: string,
164
- ): asserts manifest is MigrationManifest {
165
- const result = MigrationManifestSchema(manifest);
176
+ ): asserts metadata is MigrationMetadata {
177
+ const result = MigrationMetadataSchema(metadata);
166
178
  if (result instanceof type.errors) {
167
179
  throw errorInvalidManifest(filePath, result.summary);
168
180
  }
@@ -177,7 +189,7 @@ function validateOps(ops: unknown, filePath: string): asserts ops is MigrationOp
177
189
 
178
190
  export async function readMigrationsDir(
179
191
  migrationsRoot: string,
180
- ): Promise<readonly MigrationBundle[]> {
192
+ ): Promise<readonly MigrationPackage[]> {
181
193
  let entries: string[];
182
194
  try {
183
195
  entries = await readdir(migrationsRoot);
@@ -188,7 +200,7 @@ export async function readMigrationsDir(
188
200
  throw error;
189
201
  }
190
202
 
191
- const packages: MigrationBundle[] = [];
203
+ const packages: MigrationPackage[] = [];
192
204
 
193
205
  for (const entry of entries.sort()) {
194
206
  const entryPath = join(migrationsRoot, entry);
@@ -0,0 +1,36 @@
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;
27
+ readonly to: string;
28
+ readonly kind: 'regular' | 'baseline';
29
+ readonly fromContract: Contract | null;
30
+ readonly toContract: Contract;
31
+ readonly hints: MigrationHints;
32
+ readonly labels: readonly string[];
33
+ readonly authorship?: { readonly author?: string; readonly email?: string };
34
+ readonly signature?: { readonly keyId: string; readonly value: string } | null;
35
+ readonly createdAt: string;
36
+ }
@@ -8,8 +8,9 @@ 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 { computeMigrationHash } from './hash';
12
+ import type { MigrationHints, MigrationMetadata } from './metadata';
13
+ import type { MigrationOps } from './package';
13
14
 
14
15
  export interface MigrationMeta {
15
16
  readonly from: string;
@@ -30,7 +31,7 @@ const MigrationMetaSchema = type({
30
31
  *
31
32
  * A `Migration` subclass is itself a `MigrationPlan`: CLI commands and the
32
33
  * runner can consume it directly via `targetId`, `operations`, `origin`, and
33
- * `destination`. The manifest-shaped inputs come from `describe()`, which
34
+ * `destination`. The metadata-shaped inputs come from `describe()`, which
34
35
  * every migration must implement — `migration.json` is required for a
35
36
  * migration to be valid.
36
37
  */
@@ -123,64 +124,67 @@ function printHelp(): void {
123
124
 
124
125
  /**
125
126
  * In-memory artifacts produced from a `Migration` instance: the
126
- * serialized `ops.json` body, the `migration.json` manifest object, and
127
+ * serialized `ops.json` body, the `migration.json` metadata object, and
127
128
  * its serialized form. Returned by `buildMigrationArtifacts` so callers
128
129
  * (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can
129
130
  * decide how to persist them — write to disk, print in dry-run, ship
130
131
  * over the wire — without coupling artifact construction to file I/O.
132
+ *
133
+ * `metadataJson` is `JSON.stringify(metadata, null, 2)` — the canonical
134
+ * on-disk shape that the arktype loader-schema in `./io` validates.
131
135
  */
132
136
  export interface MigrationArtifacts {
133
137
  readonly opsJson: string;
134
- readonly manifest: MigrationManifest;
135
- readonly manifestJson: string;
138
+ readonly metadata: MigrationMetadata;
139
+ readonly metadataJson: string;
136
140
  }
137
141
 
138
142
  /**
139
- * Build the attested manifest from `describe()`-derived metadata, the
140
- * operations list, and the previously-scaffolded manifest (if any).
143
+ * Build the attested metadata from `describe()`-derived metadata, the
144
+ * operations list, and the previously-scaffolded metadata (if any).
141
145
  *
142
146
  * When a `migration.json` already exists for this package (the common
143
147
  * case: it was scaffolded by `migration plan`), preserve the contract
144
148
  * bookends, hints, labels, and `createdAt` set there — those fields are
145
149
  * owned by the CLI scaffolder, not the authored class. Only the
146
150
  * `describe()`-derived fields (`from`, `to`, `kind`) and the operations
147
- * change as the author iterates. When no manifest exists yet (a bare
151
+ * change as the author iterates. When no metadata exists yet (a bare
148
152
  * `migration.ts` run from scratch), synthesize a minimal but
149
- * schema-conformant manifest so the resulting package can still be read,
153
+ * schema-conformant record so the resulting package can still be read,
150
154
  * verified, and applied.
151
155
  *
152
- * The `migrationId` is recomputed against the current manifest + ops so
156
+ * The `migrationHash` is recomputed against the current metadata + ops so
153
157
  * the on-disk artifacts are always fully attested.
154
158
  */
155
- function buildAttestedManifest(
159
+ function buildAttestedMetadata(
156
160
  meta: MigrationMeta,
157
161
  ops: MigrationOps,
158
- existing: Partial<MigrationManifest> | null,
159
- ): MigrationManifest {
160
- const baseManifest: Omit<MigrationManifest, 'migrationId'> = {
162
+ existing: Partial<MigrationMetadata> | null,
163
+ ): MigrationMetadata {
164
+ const baseMetadata: Omit<MigrationMetadata, 'migrationHash'> = {
161
165
  from: meta.from,
162
166
  to: meta.to,
163
167
  kind: meta.kind ?? 'regular',
164
168
  labels: meta.labels ?? existing?.labels ?? [],
165
169
  createdAt: existing?.createdAt ?? new Date().toISOString(),
166
170
  fromContract: existing?.fromContract ?? null,
167
- // When no scaffolded manifest exists we synthesize a minimal contract
171
+ // When no scaffolded metadata exists we synthesize a minimal contract
168
172
  // stub so the package is still readable end-to-end. The cast is
169
173
  // intentional: only the storage bookend matters for hash computation
170
- // (everything else is stripped by `computeMigrationId`), and a real
174
+ // (everything else is stripped by `computeMigrationHash`), and a real
171
175
  // contract bookend would only be available after `migration plan`.
172
176
  toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
173
177
  hints: normalizeHints(existing?.hints),
174
178
  ...ifDefined('authorship', existing?.authorship),
175
179
  };
176
180
 
177
- const migrationId = computeMigrationId(baseManifest, ops);
178
- return { ...baseManifest, migrationId };
181
+ const migrationHash = computeMigrationHash(baseMetadata, ops);
182
+ return { ...baseMetadata, migrationHash };
179
183
  }
180
184
 
181
185
  /**
182
186
  * Project `existing.hints` down to the known `MigrationHints` shape, dropping
183
- * any legacy keys that may linger in manifests scaffolded by older CLI
187
+ * any legacy keys that may linger in metadata scaffolded by older CLI
184
188
  * versions (e.g. `planningStrategy`). Picking fields explicitly instead of
185
189
  * spreading keeps refreshed `migration.json` files schema-clean regardless
186
190
  * of what was on disk before.
@@ -195,16 +199,16 @@ function normalizeHints(existing: MigrationHints | undefined): MigrationHints {
195
199
 
196
200
  /**
197
201
  * Pure conversion from a `Migration` instance (plus the previously
198
- * scaffolded manifest, when one exists on disk) to the in-memory
202
+ * scaffolded metadata, when one exists on disk) to the in-memory
199
203
  * 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
204
+ * metadata synthesis/preservation, hint normalization, and the
205
+ * content-addressed `migrationHash` computation, but performs no file I/O
202
206
  * — callers handle reads (to source `existing`) and writes (to persist
203
- * `opsJson` / `manifestJson`).
207
+ * `opsJson` / `metadataJson`).
204
208
  */
205
209
  export function buildMigrationArtifacts(
206
210
  instance: Migration,
207
- existing: Partial<MigrationManifest> | null,
211
+ existing: Partial<MigrationMetadata> | null,
208
212
  ): MigrationArtifacts {
209
213
  const ops = instance.operations;
210
214
  if (!Array.isArray(ops)) {
@@ -217,11 +221,11 @@ export function buildMigrationArtifacts(
217
221
  throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
218
222
  }
219
223
 
220
- const manifest = buildAttestedManifest(parsed, ops, existing);
224
+ const metadata = buildAttestedMetadata(parsed, ops, existing);
221
225
 
222
226
  return {
223
227
  opsJson: JSON.stringify(ops, null, 2),
224
- manifest,
225
- manifestJson: JSON.stringify(manifest, null, 2),
228
+ metadata,
229
+ metadataJson: JSON.stringify(metadata, null, 2),
226
230
  };
227
231
  }
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
+ }