@prisma-next/migration-tools 0.4.1 → 0.4.3

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 (100) 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.d.mts +5 -1
  29. package/dist/exports/migration-ts.d.mts.map +1 -1
  30. package/dist/exports/migration-ts.mjs +6 -2
  31. package/dist/exports/migration-ts.mjs.map +1 -1
  32. package/dist/exports/migration.d.mts +51 -20
  33. package/dist/exports/migration.d.mts.map +1 -1
  34. package/dist/exports/migration.mjs +110 -99
  35. package/dist/exports/migration.mjs.map +1 -1
  36. package/dist/exports/package.d.mts +2 -0
  37. package/dist/exports/package.mjs +1 -0
  38. package/dist/exports/refs.d.mts +11 -5
  39. package/dist/exports/refs.d.mts.map +1 -1
  40. package/dist/exports/refs.mjs +106 -30
  41. package/dist/exports/refs.mjs.map +1 -1
  42. package/dist/graph-BHPv-9Gl.d.mts +28 -0
  43. package/dist/graph-BHPv-9Gl.d.mts.map +1 -0
  44. package/dist/hash-BARZdVgW.mjs +76 -0
  45. package/dist/hash-BARZdVgW.mjs.map +1 -0
  46. package/dist/invariants-30VA65sB.mjs +42 -0
  47. package/dist/invariants-30VA65sB.mjs.map +1 -0
  48. package/dist/metadata-BP1cmU7Z.d.mts +50 -0
  49. package/dist/metadata-BP1cmU7Z.d.mts.map +1 -0
  50. package/dist/op-schema-DZKFua46.mjs +14 -0
  51. package/dist/op-schema-DZKFua46.mjs.map +1 -0
  52. package/dist/package-5HCCg0z-.d.mts +21 -0
  53. package/dist/package-5HCCg0z-.d.mts.map +1 -0
  54. package/package.json +30 -14
  55. package/src/errors.ts +210 -17
  56. package/src/exports/errors.ts +7 -0
  57. package/src/exports/graph.ts +1 -0
  58. package/src/exports/hash.ts +2 -0
  59. package/src/exports/invariants.ts +1 -0
  60. package/src/exports/io.ts +1 -1
  61. package/src/exports/metadata.ts +1 -0
  62. package/src/exports/{dag.ts → migration-graph.ts} +3 -2
  63. package/src/exports/migration.ts +7 -1
  64. package/src/exports/package.ts +1 -0
  65. package/src/exports/refs.ts +10 -2
  66. package/src/graph-ops.ts +57 -30
  67. package/src/graph.ts +25 -0
  68. package/src/hash.ts +91 -0
  69. package/src/invariants.ts +45 -0
  70. package/src/io.ts +57 -31
  71. package/src/metadata.ts +41 -0
  72. package/src/migration-base.ts +155 -124
  73. package/src/migration-graph.ts +676 -0
  74. package/src/migration-ts.ts +5 -1
  75. package/src/op-schema.ts +11 -0
  76. package/src/package.ts +18 -0
  77. package/src/refs.ts +148 -37
  78. package/dist/attestation-DtF8tEOM.mjs +0 -65
  79. package/dist/attestation-DtF8tEOM.mjs.map +0 -1
  80. package/dist/errors-BKbRGCJM.mjs +0 -160
  81. package/dist/errors-BKbRGCJM.mjs.map +0 -1
  82. package/dist/exports/attestation.d.mts +0 -37
  83. package/dist/exports/attestation.d.mts.map +0 -1
  84. package/dist/exports/attestation.mjs +0 -4
  85. package/dist/exports/dag.d.mts +0 -51
  86. package/dist/exports/dag.d.mts.map +0 -1
  87. package/dist/exports/dag.mjs +0 -386
  88. package/dist/exports/dag.mjs.map +0 -1
  89. package/dist/exports/types.d.mts +0 -35
  90. package/dist/exports/types.d.mts.map +0 -1
  91. package/dist/exports/types.mjs +0 -3
  92. package/dist/io-CCnYsUHU.mjs +0 -153
  93. package/dist/io-CCnYsUHU.mjs.map +0 -1
  94. package/dist/types-DyGXcWWp.d.mts +0 -71
  95. package/dist/types-DyGXcWWp.d.mts.map +0 -1
  96. package/src/attestation.ts +0 -81
  97. package/src/dag.ts +0 -426
  98. package/src/exports/attestation.ts +0 -2
  99. package/src/exports/types.ts +0 -10
  100. package/src/types.ts +0 -66
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
+ }
@@ -1,27 +1,34 @@
1
- import { readFileSync, realpathSync, writeFileSync } from 'node:fs';
1
+ import { realpathSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import type { Contract } from '@prisma-next/contract/types';
4
4
  import type {
5
+ ControlStack,
5
6
  MigrationPlan,
6
7
  MigrationPlanOperation,
7
8
  } from '@prisma-next/framework-components/control';
8
9
  import { ifDefined } from '@prisma-next/utils/defined';
9
10
  import { type } from 'arktype';
10
- import { dirname, join } from 'pathe';
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,15 +37,33 @@ 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
  */
37
- export abstract class Migration<TOperation extends MigrationPlanOperation = MigrationPlanOperation>
38
- implements MigrationPlan
44
+ export abstract class Migration<
45
+ TOperation extends MigrationPlanOperation = MigrationPlanOperation,
46
+ TFamilyId extends string = string,
47
+ TTargetId extends string = string,
48
+ > implements MigrationPlan
39
49
  {
40
50
  abstract readonly targetId: string;
41
51
 
52
+ /**
53
+ * Assembled `ControlStack` injected by the orchestrator (`runMigration`).
54
+ *
55
+ * Subclasses (e.g. `PostgresMigration`) read the stack to materialize their
56
+ * adapter once per instance. Optional at the abstract level so unit tests can
57
+ * construct `Migration` instances purely for `operations` / `describe`
58
+ * assertions without needing a real stack; concrete subclasses that need the
59
+ * stack at runtime should narrow the parameter to required.
60
+ */
61
+ protected readonly stack: ControlStack<TFamilyId, TTargetId> | undefined;
62
+
63
+ constructor(stack?: ControlStack<TFamilyId, TTargetId>) {
64
+ this.stack = stack;
65
+ }
66
+
42
67
  /**
43
68
  * Ordered list of operations this migration performs.
44
69
  *
@@ -56,124 +81,140 @@ export abstract class Migration<TOperation extends MigrationPlanOperation = Migr
56
81
 
57
82
  get origin(): { readonly storageHash: string } | null {
58
83
  const from = this.describe().from;
59
- // An empty `from` represents a migration with no prior origin (e.g.
60
- // initial baseline, or an in-process plan that was never persisted).
61
- // Surface that as a null origin so runners treat the plan as
62
- // origin-less rather than matching against an empty storage hash.
63
- return from === '' ? null : { storageHash: from };
84
+ return from === null ? null : { storageHash: from };
64
85
  }
65
86
 
66
87
  get destination(): { readonly storageHash: string } {
67
88
  return { storageHash: this.describe().to };
68
89
  }
90
+ }
69
91
 
70
- /**
71
- * Entrypoint guard for migration files. When called at module scope,
72
- * detects whether the file is being run directly (e.g. `node migration.ts`)
73
- * and if so, serializes the migration plan to `ops.json` and
74
- * `migration.json` in the same directory. When the file is imported by
75
- * another module, this is a no-op.
76
- *
77
- * Usage (at module scope, after the class definition):
78
- *
79
- * class MyMigration extends Migration { ... }
80
- * export default MyMigration;
81
- * Migration.run(import.meta.url, MyMigration);
82
- */
83
- static run(importMetaUrl: string, MigrationClass: new () => Migration): void {
84
- if (!importMetaUrl) return;
85
-
86
- const metaFilename = fileURLToPath(importMetaUrl);
87
- const argv1 = process.argv[1];
88
- if (!argv1) return;
89
-
90
- let isEntrypoint: boolean;
91
- try {
92
- isEntrypoint = realpathSync(metaFilename) === realpathSync(argv1);
93
- } catch {
94
- return;
95
- }
96
- if (!isEntrypoint) return;
97
-
98
- const args = process.argv.slice(2);
99
-
100
- if (args.includes('--help')) {
101
- printHelp();
102
- return;
103
- }
104
-
105
- const dryRun = args.includes('--dry-run');
106
- const migrationDir = dirname(metaFilename);
107
-
108
- try {
109
- serializeMigration(MigrationClass, migrationDir, dryRun);
110
- } catch (err) {
111
- process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
112
- process.exitCode = 1;
113
- }
92
+ /**
93
+ * Returns true when `import.meta.url` resolves to the same file that was
94
+ * invoked as the node entrypoint (`process.argv[1]`). Used by
95
+ * `MigrationCLI.run` (in `@prisma-next/cli/migration-cli`) to no-op when
96
+ * the migration module is being imported (e.g. by another script) rather
97
+ * than executed directly.
98
+ */
99
+ export function isDirectEntrypoint(importMetaUrl: string): boolean {
100
+ const metaFilename = fileURLToPath(importMetaUrl);
101
+ const argv1 = process.argv[1];
102
+ if (!argv1) return false;
103
+ try {
104
+ return realpathSync(metaFilename) === realpathSync(argv1);
105
+ } catch {
106
+ return false;
114
107
  }
115
108
  }
116
109
 
117
- function printHelp(): void {
118
- process.stdout.write(
119
- [
120
- 'Usage: node <migration-file> [options]',
121
- '',
122
- 'Options:',
123
- ' --dry-run Print operations to stdout without writing files',
124
- ' --help Show this help message',
125
- '',
126
- ].join('\n'),
127
- );
110
+ /**
111
+ * In-memory artifacts produced from a `Migration` instance: the
112
+ * serialized `ops.json` body, the `migration.json` metadata object, and
113
+ * its serialized form. Returned by `buildMigrationArtifacts` so callers
114
+ * (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can
115
+ * decide how to persist them — write to disk, print in dry-run, ship
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.
120
+ */
121
+ export interface MigrationArtifacts {
122
+ readonly opsJson: string;
123
+ readonly metadata: MigrationMetadata;
124
+ readonly metadataJson: string;
128
125
  }
129
126
 
130
127
  /**
131
- * Build the attested manifest written by `Migration.run()`.
128
+ * Build the attested metadata from `describe()`-derived metadata, the
129
+ * operations list, and the previously-scaffolded metadata (if any).
132
130
  *
133
- * When a `migration.json` already exists in the directory (the common case:
134
- * the package was scaffolded by `migration plan`), preserve the contract
131
+ * When a `migration.json` already exists for this package (the common
132
+ * case: it was scaffolded by `migration plan`), preserve the contract
135
133
  * bookends, hints, labels, and `createdAt` set there — those fields are
136
134
  * owned by the CLI scaffolder, not the authored class. Only the
137
- * `describe()`-derived fields (`from`, `to`, `kind`) and the operations
138
- * 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
139
137
  * `migration.ts` run from scratch), synthesize a minimal but
140
- * schema-conformant manifest so the resulting package can still be read,
138
+ * schema-conformant record so the resulting package can still be read,
141
139
  * verified, and applied.
142
140
  *
143
- * The `migrationId` is recomputed against the current manifest + ops so
141
+ * The `migrationHash` is recomputed against the current metadata + ops so
144
142
  * the on-disk artifacts are always fully attested.
145
143
  */
146
- function buildAttestedManifest(
147
- migrationDir: string,
144
+ function buildAttestedMetadata(
148
145
  meta: MigrationMeta,
149
146
  ops: MigrationOps,
150
- ): MigrationManifest {
151
- const existing = readExistingManifest(join(migrationDir, 'migration.json'));
147
+ existing: Partial<MigrationMetadata> | null,
148
+ ): MigrationMetadata {
149
+ assertBookendsMatchMeta(meta, existing);
152
150
 
153
- const baseManifest: Omit<MigrationManifest, 'migrationId'> = {
151
+ const baseMetadata: Omit<MigrationMetadata, 'migrationHash'> = {
154
152
  from: meta.from,
155
153
  to: meta.to,
156
- kind: meta.kind ?? 'regular',
157
154
  labels: meta.labels ?? existing?.labels ?? [],
155
+ providedInvariants: deriveProvidedInvariants(ops),
158
156
  createdAt: existing?.createdAt ?? new Date().toISOString(),
159
157
  fromContract: existing?.fromContract ?? null,
160
- // When no scaffolded manifest exists we synthesize a minimal contract
158
+ // When no scaffolded metadata exists we synthesize a minimal contract
161
159
  // stub so the package is still readable end-to-end. The cast is
162
160
  // intentional: only the storage bookend matters for hash computation
163
- // (everything else is stripped by `computeMigrationId`), and a real
161
+ // (everything else is stripped by `computeMigrationHash`), and a real
164
162
  // contract bookend would only be available after `migration plan`.
165
163
  toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
166
164
  hints: normalizeHints(existing?.hints),
167
165
  ...ifDefined('authorship', existing?.authorship),
168
166
  };
169
167
 
170
- const migrationId = computeMigrationId(baseManifest, ops);
171
- 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
+ }
172
213
  }
173
214
 
174
215
  /**
175
216
  * Project `existing.hints` down to the known `MigrationHints` shape, dropping
176
- * 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
177
218
  * versions (e.g. `planningStrategy`). Picking fields explicitly instead of
178
219
  * spreading keeps refreshed `migration.json` files schema-clean regardless
179
220
  * of what was on disk before.
@@ -186,34 +227,30 @@ function normalizeHints(existing: MigrationHints | undefined): MigrationHints {
186
227
  };
187
228
  }
188
229
 
189
- function readExistingManifest(manifestPath: string): Partial<MigrationManifest> | null {
190
- let raw: string;
191
- try {
192
- raw = readFileSync(manifestPath, 'utf-8');
193
- } catch {
194
- return null;
195
- }
196
- try {
197
- return JSON.parse(raw) as Partial<MigrationManifest>;
198
- } catch {
199
- return null;
200
- }
201
- }
202
-
203
- function serializeMigration(
204
- MigrationClass: new () => Migration,
205
- migrationDir: string,
206
- dryRun: boolean,
207
- ): void {
208
- const instance = new MigrationClass();
209
-
230
+ /**
231
+ * Pure conversion from a `Migration` instance (plus the previously
232
+ * scaffolded metadata, when one exists on disk) to the in-memory
233
+ * artifacts that downstream tooling persists. Owns metadata validation,
234
+ * metadata synthesis/preservation, hint normalization, and the
235
+ * content-addressed `migrationHash` computation, but performs no file I/O
236
+ * — callers handle reads (to source `existing`) and writes (to persist
237
+ * `opsJson` / `metadataJson`).
238
+ */
239
+ export function buildMigrationArtifacts(
240
+ instance: Migration,
241
+ existing: Partial<MigrationMetadata> | null,
242
+ ): MigrationArtifacts {
210
243
  const ops = instance.operations;
211
-
212
244
  if (!Array.isArray(ops)) {
213
245
  throw new Error('operations must be an array');
214
246
  }
215
247
 
216
- const serializedOps = JSON.stringify(ops, null, 2);
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
+ }
217
254
 
218
255
  const rawMeta: unknown = instance.describe();
219
256
  const parsed = MigrationMetaSchema(rawMeta);
@@ -221,17 +258,11 @@ function serializeMigration(
221
258
  throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
222
259
  }
223
260
 
224
- const manifest = buildAttestedManifest(migrationDir, parsed, ops);
225
-
226
- if (dryRun) {
227
- process.stdout.write(`--- migration.json ---\n${JSON.stringify(manifest, null, 2)}\n`);
228
- process.stdout.write('--- ops.json ---\n');
229
- process.stdout.write(`${serializedOps}\n`);
230
- return;
231
- }
261
+ const metadata = buildAttestedMetadata(parsed, ops, existing);
232
262
 
233
- writeFileSync(join(migrationDir, 'ops.json'), serializedOps);
234
- writeFileSync(join(migrationDir, 'migration.json'), JSON.stringify(manifest, null, 2));
235
-
236
- process.stdout.write(`Wrote ops.json + migration.json to ${migrationDir}\n`);
263
+ return {
264
+ opsJson: JSON.stringify(ops, null, 2),
265
+ metadata,
266
+ metadataJson: JSON.stringify(metadata, null, 2),
267
+ };
237
268
  }