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

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 (51) hide show
  1. package/README.md +1 -1
  2. package/dist/{attestation-DnebS4XZ.mjs → attestation-DtF8tEOM.mjs} +24 -23
  3. package/dist/attestation-DtF8tEOM.mjs.map +1 -0
  4. package/dist/{errors-C_XuSbX7.mjs → errors-BKbRGCJM.mjs} +9 -2
  5. package/dist/errors-BKbRGCJM.mjs.map +1 -0
  6. package/dist/exports/attestation.d.mts +20 -6
  7. package/dist/exports/attestation.d.mts.map +1 -1
  8. package/dist/exports/attestation.mjs +3 -3
  9. package/dist/exports/dag.d.mts +8 -6
  10. package/dist/exports/dag.d.mts.map +1 -1
  11. package/dist/exports/dag.mjs +181 -107
  12. package/dist/exports/dag.mjs.map +1 -1
  13. package/dist/exports/io.d.mts +16 -13
  14. package/dist/exports/io.d.mts.map +1 -1
  15. package/dist/exports/io.mjs +2 -2
  16. package/dist/exports/migration-ts.d.mts +15 -21
  17. package/dist/exports/migration-ts.d.mts.map +1 -1
  18. package/dist/exports/migration-ts.mjs +28 -36
  19. package/dist/exports/migration-ts.mjs.map +1 -1
  20. package/dist/exports/migration.d.mts +48 -18
  21. package/dist/exports/migration.d.mts.map +1 -1
  22. package/dist/exports/migration.mjs +75 -85
  23. package/dist/exports/migration.mjs.map +1 -1
  24. package/dist/exports/refs.mjs +1 -1
  25. package/dist/exports/types.d.mts +2 -2
  26. package/dist/exports/types.mjs +2 -16
  27. package/dist/{io-Cun81AIZ.mjs → io-CCnYsUHU.mjs} +18 -22
  28. package/dist/io-CCnYsUHU.mjs.map +1 -0
  29. package/dist/types-DyGXcWWp.d.mts +71 -0
  30. package/dist/types-DyGXcWWp.d.mts.map +1 -0
  31. package/package.json +5 -4
  32. package/src/attestation.ts +34 -26
  33. package/src/dag.ts +140 -154
  34. package/src/errors.ts +8 -0
  35. package/src/exports/attestation.ts +2 -1
  36. package/src/exports/io.ts +1 -1
  37. package/src/exports/migration-ts.ts +1 -1
  38. package/src/exports/migration.ts +8 -1
  39. package/src/exports/types.ts +2 -8
  40. package/src/graph-ops.ts +65 -0
  41. package/src/io.ts +23 -24
  42. package/src/migration-base.ts +99 -101
  43. package/src/migration-ts.ts +28 -50
  44. package/src/queue.ts +37 -0
  45. package/src/types.ts +15 -55
  46. package/dist/attestation-DnebS4XZ.mjs.map +0 -1
  47. package/dist/errors-C_XuSbX7.mjs.map +0 -1
  48. package/dist/exports/types.mjs.map +0 -1
  49. package/dist/io-Cun81AIZ.mjs.map +0 -1
  50. package/dist/types-D2uX4ql7.d.mts +0 -100
  51. package/dist/types-D2uX4ql7.d.mts.map +0 -1
@@ -1,15 +1,15 @@
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
11
  import { computeMigrationId } from './attestation';
12
- import type { MigrationManifest, MigrationOps } from './types';
12
+ import type { MigrationHints, MigrationManifest, MigrationOps } from './types';
13
13
 
14
14
  export interface MigrationMeta {
15
15
  readonly from: string;
@@ -26,7 +26,7 @@ const MigrationMetaSchema = type({
26
26
  });
27
27
 
28
28
  /**
29
- * Base class for class-flow migrations.
29
+ * Base class for migrations.
30
30
  *
31
31
  * A `Migration` subclass is itself a `MigrationPlan`: CLI commands and the
32
32
  * runner can consume it directly via `targetId`, `operations`, `origin`, and
@@ -34,11 +34,29 @@ const MigrationMetaSchema = type({
34
34
  * every migration must implement — `migration.json` is required for a
35
35
  * migration to be valid.
36
36
  */
37
- export abstract class Migration<TOperation extends MigrationPlanOperation = MigrationPlanOperation>
38
- implements MigrationPlan
37
+ export abstract class Migration<
38
+ TOperation extends MigrationPlanOperation = MigrationPlanOperation,
39
+ TFamilyId extends string = string,
40
+ TTargetId extends string = string,
41
+ > implements MigrationPlan
39
42
  {
40
43
  abstract readonly targetId: string;
41
44
 
45
+ /**
46
+ * Assembled `ControlStack` injected by the orchestrator (`runMigration`).
47
+ *
48
+ * Subclasses (e.g. `PostgresMigration`) read the stack to materialize their
49
+ * adapter once per instance. Optional at the abstract level so unit tests can
50
+ * construct `Migration` instances purely for `operations` / `describe`
51
+ * assertions without needing a real stack; concrete subclasses that need the
52
+ * stack at runtime should narrow the parameter to required.
53
+ */
54
+ protected readonly stack: ControlStack<TFamilyId, TTargetId> | undefined;
55
+
56
+ constructor(stack?: ControlStack<TFamilyId, TTargetId>) {
57
+ this.stack = stack;
58
+ }
59
+
42
60
  /**
43
61
  * Ordered list of operations this migration performs.
44
62
  *
@@ -66,54 +84,30 @@ export abstract class Migration<TOperation extends MigrationPlanOperation = Migr
66
84
  get destination(): { readonly storageHash: string } {
67
85
  return { storageHash: this.describe().to };
68
86
  }
87
+ }
69
88
 
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
- }
89
+ /**
90
+ * Returns true when `import.meta.url` resolves to the same file that was
91
+ * invoked as the node entrypoint (`process.argv[1]`). Used by
92
+ * `MigrationCLI.run` (in `@prisma-next/cli/migration-cli`) to no-op when
93
+ * the migration module is being imported (e.g. by another script) rather
94
+ * than executed directly.
95
+ */
96
+ export function isDirectEntrypoint(importMetaUrl: string): boolean {
97
+ const metaFilename = fileURLToPath(importMetaUrl);
98
+ const argv1 = process.argv[1];
99
+ if (!argv1) return false;
100
+ try {
101
+ return realpathSync(metaFilename) === realpathSync(argv1);
102
+ } catch {
103
+ return false;
114
104
  }
115
105
  }
116
106
 
107
+ export function printMigrationHelp(): void {
108
+ printHelp();
109
+ }
110
+
117
111
  function printHelp(): void {
118
112
  process.stdout.write(
119
113
  [
@@ -128,10 +122,25 @@ function printHelp(): void {
128
122
  }
129
123
 
130
124
  /**
131
- * Build the attested manifest written by `Migration.run()`.
125
+ * In-memory artifacts produced from a `Migration` instance: the
126
+ * serialized `ops.json` body, the `migration.json` manifest object, and
127
+ * its serialized form. Returned by `buildMigrationArtifacts` so callers
128
+ * (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can
129
+ * decide how to persist them — write to disk, print in dry-run, ship
130
+ * over the wire — without coupling artifact construction to file I/O.
131
+ */
132
+ export interface MigrationArtifacts {
133
+ readonly opsJson: string;
134
+ readonly manifest: MigrationManifest;
135
+ readonly manifestJson: string;
136
+ }
137
+
138
+ /**
139
+ * Build the attested manifest from `describe()`-derived metadata, the
140
+ * operations list, and the previously-scaffolded manifest (if any).
132
141
  *
133
- * When a `migration.json` already exists in the directory (the common case:
134
- * the package was scaffolded by `migration plan`), preserve the contract
142
+ * When a `migration.json` already exists for this package (the common
143
+ * case: it was scaffolded by `migration plan`), preserve the contract
135
144
  * bookends, hints, labels, and `createdAt` set there — those fields are
136
145
  * owned by the CLI scaffolder, not the authored class. Only the
137
146
  * `describe()`-derived fields (`from`, `to`, `kind`) and the operations
@@ -140,19 +149,15 @@ function printHelp(): void {
140
149
  * schema-conformant manifest so the resulting package can still be read,
141
150
  * verified, and applied.
142
151
  *
143
- * In both cases the `migrationId` is recomputed against the current
144
- * manifest + ops so the on-disk artifacts are always fully attested — no
145
- * draft (`migrationId: null`) ever leaves this function.
152
+ * The `migrationId` is recomputed against the current manifest + ops so
153
+ * the on-disk artifacts are always fully attested.
146
154
  */
147
155
  function buildAttestedManifest(
148
- migrationDir: string,
149
156
  meta: MigrationMeta,
150
157
  ops: MigrationOps,
158
+ existing: Partial<MigrationManifest> | null,
151
159
  ): MigrationManifest {
152
- const existing = readExistingManifest(join(migrationDir, 'migration.json'));
153
-
154
- const baseManifest: MigrationManifest = {
155
- migrationId: null,
160
+ const baseManifest: Omit<MigrationManifest, 'migrationId'> = {
156
161
  from: meta.from,
157
162
  to: meta.to,
158
163
  kind: meta.kind ?? 'regular',
@@ -165,12 +170,7 @@ function buildAttestedManifest(
165
170
  // (everything else is stripped by `computeMigrationId`), and a real
166
171
  // contract bookend would only be available after `migration plan`.
167
172
  toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
168
- hints: existing?.hints ?? {
169
- used: [],
170
- applied: [],
171
- plannerVersion: '2.0.0',
172
- planningStrategy: 'class-based',
173
- },
173
+ hints: normalizeHints(existing?.hints),
174
174
  ...ifDefined('authorship', existing?.authorship),
175
175
  };
176
176
 
@@ -178,52 +178,50 @@ function buildAttestedManifest(
178
178
  return { ...baseManifest, migrationId };
179
179
  }
180
180
 
181
- function readExistingManifest(manifestPath: string): Partial<MigrationManifest> | null {
182
- let raw: string;
183
- try {
184
- raw = readFileSync(manifestPath, 'utf-8');
185
- } catch {
186
- return null;
187
- }
188
- try {
189
- return JSON.parse(raw) as Partial<MigrationManifest>;
190
- } catch {
191
- return null;
192
- }
181
+ /**
182
+ * Project `existing.hints` down to the known `MigrationHints` shape, dropping
183
+ * any legacy keys that may linger in manifests scaffolded by older CLI
184
+ * versions (e.g. `planningStrategy`). Picking fields explicitly instead of
185
+ * spreading keeps refreshed `migration.json` files schema-clean regardless
186
+ * of what was on disk before.
187
+ */
188
+ function normalizeHints(existing: MigrationHints | undefined): MigrationHints {
189
+ return {
190
+ used: existing?.used ?? [],
191
+ applied: existing?.applied ?? [],
192
+ plannerVersion: existing?.plannerVersion ?? '2.0.0',
193
+ };
193
194
  }
194
195
 
195
- function serializeMigration(
196
- MigrationClass: new () => Migration,
197
- migrationDir: string,
198
- dryRun: boolean,
199
- ): void {
200
- const instance = new MigrationClass();
201
-
196
+ /**
197
+ * Pure conversion from a `Migration` instance (plus the previously
198
+ * scaffolded manifest, when one exists on disk) to the in-memory
199
+ * 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
202
+ * — callers handle reads (to source `existing`) and writes (to persist
203
+ * `opsJson` / `manifestJson`).
204
+ */
205
+ export function buildMigrationArtifacts(
206
+ instance: Migration,
207
+ existing: Partial<MigrationManifest> | null,
208
+ ): MigrationArtifacts {
202
209
  const ops = instance.operations;
203
-
204
210
  if (!Array.isArray(ops)) {
205
211
  throw new Error('operations must be an array');
206
212
  }
207
213
 
208
- const serializedOps = JSON.stringify(ops, null, 2);
209
-
210
214
  const rawMeta: unknown = instance.describe();
211
215
  const parsed = MigrationMetaSchema(rawMeta);
212
216
  if (parsed instanceof type.errors) {
213
217
  throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
214
218
  }
215
219
 
216
- const manifest = buildAttestedManifest(migrationDir, parsed, ops);
217
-
218
- if (dryRun) {
219
- process.stdout.write(`--- migration.json ---\n${JSON.stringify(manifest, null, 2)}\n`);
220
- process.stdout.write('--- ops.json ---\n');
221
- process.stdout.write(`${serializedOps}\n`);
222
- return;
223
- }
220
+ const manifest = buildAttestedManifest(parsed, ops, existing);
224
221
 
225
- writeFileSync(join(migrationDir, 'ops.json'), serializedOps);
226
- writeFileSync(join(migrationDir, 'migration.json'), JSON.stringify(manifest, null, 2));
227
-
228
- process.stdout.write(`Wrote ops.json + migration.json to ${migrationDir}\n`);
222
+ return {
223
+ opsJson: JSON.stringify(ops, null, 2),
224
+ manifest,
225
+ manifestJson: JSON.stringify(manifest, null, 2),
226
+ };
229
227
  }
@@ -1,17 +1,15 @@
1
1
  /**
2
2
  * Utilities for reading/writing `migration.ts` files.
3
3
  *
4
- * Rendering migration.ts source is now the target's responsibility — the CLI
5
- * obtains source strings either from a class-flow planner's
6
- * `plan.renderTypeScript()` or from a descriptor-flow target's
7
- * `migrations.renderDescriptorTypeScript(descriptors, context)`. The helper
8
- * here is limited to file I/O: writing the returned source with the right
9
- * executable bit, probing for existence, and evaluating legacy descriptor-
10
- * flow files.
4
+ * Rendering migration.ts source is the target's responsibility — the CLI
5
+ * obtains source strings from a planner's `plan.renderTypeScript()`. The
6
+ * helper here is limited to file I/O: writing the returned source with the
7
+ * right executable bit and probing for existence.
11
8
  */
12
9
 
13
10
  import { stat, writeFile } from 'node:fs/promises';
14
- import { join, resolve } from 'pathe';
11
+ import { join } from 'pathe';
12
+ import { format } from 'prettier';
15
13
 
16
14
  const MIGRATION_TS_FILE = 'migration.ts';
17
15
 
@@ -19,17 +17,36 @@ const MIGRATION_TS_FILE = 'migration.ts';
19
17
  * Writes a pre-rendered `migration.ts` source string to the given package
20
18
  * directory. If the source begins with a shebang, the file is written with
21
19
  * executable permissions (0o755) so it can be run directly via
22
- * `./migration.ts` by the authoring class's `Migration.run(...)` guard.
20
+ * `./migration.ts` the rendered scaffold ends with
21
+ * `MigrationCLI.run(import.meta.url, M)` from
22
+ * `@prisma-next/cli/migration-cli` (re-exported by the postgres facade),
23
+ * which guards on the entrypoint and serializes when the file is the main
24
+ * module.
25
+ *
26
+ * The source is run through prettier before writing so migration renderers
27
+ * can produce structurally-correct but loosely-indented source and rely on
28
+ * a single canonical format on disk. Matches what `@prisma-next/emitter`
29
+ * already does for generated `contract.d.ts`.
23
30
  */
24
31
  export async function writeMigrationTs(packageDir: string, content: string): Promise<void> {
25
- const isExecutable = content.startsWith('#!');
32
+ const formatted = await formatMigrationTsSource(content);
33
+ const isExecutable = formatted.startsWith('#!');
26
34
  await writeFile(
27
35
  join(packageDir, MIGRATION_TS_FILE),
28
- content,
36
+ formatted,
29
37
  isExecutable ? { mode: 0o755 } : undefined,
30
38
  );
31
39
  }
32
40
 
41
+ async function formatMigrationTsSource(source: string): Promise<string> {
42
+ return format(source, {
43
+ parser: 'typescript',
44
+ singleQuote: true,
45
+ semi: true,
46
+ printWidth: 100,
47
+ });
48
+ }
49
+
33
50
  /**
34
51
  * Checks whether a migration.ts file exists in the package directory.
35
52
  */
@@ -41,42 +58,3 @@ export async function hasMigrationTs(packageDir: string): Promise<boolean> {
41
58
  return false;
42
59
  }
43
60
  }
44
-
45
- /**
46
- * Evaluates a descriptor-flow migration.ts file by loading it via native
47
- * Node import. Returns the result of calling the default export (expected
48
- * to be a function returning an array of operation descriptors).
49
- *
50
- * Class-flow migration.ts files use a different shape — their default
51
- * export is a class that extends `Migration` — and are evaluated by the
52
- * target's `emit` capability, not this helper.
53
- *
54
- * Requires Node ≥24 for native TypeScript support.
55
- */
56
- export async function evaluateMigrationTs(packageDir: string): Promise<readonly unknown[]> {
57
- const filePath = resolve(join(packageDir, MIGRATION_TS_FILE));
58
-
59
- try {
60
- await stat(filePath);
61
- } catch {
62
- throw new Error(`migration.ts not found at "${filePath}"`);
63
- }
64
-
65
- const mod = (await import(filePath)) as { default?: unknown };
66
-
67
- if (typeof mod.default !== 'function') {
68
- throw new Error(
69
- `migration.ts must export a default function returning an operation list. Got: ${typeof mod.default}`,
70
- );
71
- }
72
-
73
- const result: unknown = mod.default();
74
-
75
- if (!Array.isArray(result)) {
76
- throw new Error(
77
- `migration.ts default export must return an array of operations. Got: ${typeof result}`,
78
- );
79
- }
80
-
81
- return result;
82
- }
package/src/queue.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * FIFO queue with amortised O(1) push and shift.
3
+ *
4
+ * Uses a head-index cursor over a backing array rather than
5
+ * `Array.prototype.shift()`, which is O(n) on V8. Intended for BFS-shaped
6
+ * traversals where the queue is drained in a single pass — it does not
7
+ * reclaim memory for already-shifted items, so it is not suitable for
8
+ * long-lived queues with many push/shift cycles.
9
+ */
10
+ export class Queue<T> {
11
+ private readonly items: T[];
12
+ private head = 0;
13
+
14
+ constructor(initial: Iterable<T> = []) {
15
+ this.items = [...initial];
16
+ }
17
+
18
+ push(item: T): void {
19
+ this.items.push(item);
20
+ }
21
+
22
+ /**
23
+ * Remove and return the next item. Caller must check `isEmpty` first —
24
+ * shifting an empty queue throws.
25
+ */
26
+ shift(): T {
27
+ if (this.head >= this.items.length) {
28
+ throw new Error('Queue.shift called on empty queue');
29
+ }
30
+ // biome-ignore lint/style/noNonNullAssertion: bounds-checked on the line above
31
+ return this.items[this.head++]!;
32
+ }
33
+
34
+ get isEmpty(): boolean {
35
+ return this.head >= this.items.length;
36
+ }
37
+ }
package/src/types.ts CHANGED
@@ -5,13 +5,22 @@ export interface MigrationHints {
5
5
  readonly used: readonly string[];
6
6
  readonly applied: readonly string[];
7
7
  readonly plannerVersion: string;
8
- readonly planningStrategy: string;
9
8
  }
10
9
 
11
10
  /**
12
- * Shared fields for all migration manifests (draft and attested).
11
+ * On-disk migration manifest. Every migration is content-addressed: the
12
+ * `migrationId` is a hash over the manifest envelope plus the operations
13
+ * list, computed at write time. There is no draft state — a migration
14
+ * directory either exists with a fully attested manifest or it does not.
15
+ *
16
+ * When the planner cannot lower an operation because of an unfilled
17
+ * `placeholder(...)` slot, the migration is still written with
18
+ * `migrationId` hashed over `ops: []`. Re-running self-emit after the
19
+ * user fills the placeholder produces a *different* `migrationId`
20
+ * (committed to the real ops); this is intentional.
13
21
  */
14
- interface MigrationManifestBase {
22
+ export interface MigrationManifest {
23
+ readonly migrationId: string;
15
24
  readonly from: string;
16
25
  readonly to: string;
17
26
  readonly kind: 'regular' | 'baseline';
@@ -24,37 +33,12 @@ interface MigrationManifestBase {
24
33
  readonly createdAt: string;
25
34
  }
26
35
 
27
- /**
28
- * A draft migration that has been planned but not yet attested.
29
- * Draft migrations have `migrationId: null` and are excluded from
30
- * graph reconstruction and apply.
31
- */
32
- export interface DraftMigrationManifest extends MigrationManifestBase {
33
- readonly migrationId: null;
34
- }
35
-
36
- /**
37
- * An attested migration with a content-addressed migrationId.
38
- * Only attested migrations participate in the migration graph.
39
- */
40
- export interface AttestedMigrationManifest extends MigrationManifestBase {
41
- readonly migrationId: string;
42
- }
43
-
44
- /**
45
- * Union of draft and attested manifests. This is what the on-disk
46
- * format represents — `migrationId` is `null` for drafts, a string
47
- * for attested migrations.
48
- */
49
- export type MigrationManifest = DraftMigrationManifest | AttestedMigrationManifest;
50
-
51
36
  export type MigrationOps = readonly MigrationPlanOperation[];
52
37
 
53
38
  /**
54
39
  * An on-disk migration directory containing a manifest and operations.
55
- * The manifest may be draft or attested.
56
40
  */
57
- export interface BaseMigrationBundle {
41
+ export interface MigrationBundle {
58
42
  readonly dirName: string;
59
43
  readonly dirPath: string;
60
44
  readonly manifest: MigrationManifest;
@@ -62,20 +46,8 @@ export interface BaseMigrationBundle {
62
46
  }
63
47
 
64
48
  /**
65
- * A bundle known to be attested (migrationId is a string).
66
- * Use this after filtering bundles to attested-only.
67
- */
68
- export interface AttestedMigrationBundle extends BaseMigrationBundle {
69
- readonly manifest: AttestedMigrationManifest;
70
- }
71
-
72
- export interface DraftMigrationBundle extends BaseMigrationBundle {
73
- readonly manifest: DraftMigrationManifest;
74
- }
75
-
76
- /**
77
- * An entry in the migration graph. Only attested migrations appear in the
78
- * graph, so `migrationId` is always a string.
49
+ * An entry in the migration graph. All on-disk migrations are attested,
50
+ * so `migrationId` is always a string.
79
51
  */
80
52
  export interface MigrationChainEntry {
81
53
  readonly from: string;
@@ -92,15 +64,3 @@ export interface MigrationGraph {
92
64
  readonly reverseChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;
93
65
  readonly migrationById: ReadonlyMap<string, MigrationChainEntry>;
94
66
  }
95
-
96
- /**
97
- * Type guard that narrows a MigrationBundle to an AttestedMigrationBundle.
98
- * Use with `.filter(isAttested)` to get a typed array of attested bundles.
99
- */
100
- export function isAttested(bundle: BaseMigrationBundle): bundle is AttestedMigrationBundle {
101
- return typeof bundle.manifest.migrationId === 'string';
102
- }
103
-
104
- export function isDraft(bundle: BaseMigrationBundle): bundle is DraftMigrationBundle {
105
- return bundle.manifest.migrationId === null;
106
- }
@@ -1 +0,0 @@
1
- {"version":3,"file":"attestation-DnebS4XZ.mjs","names":["sorted: Record<string, unknown>"],"sources":["../src/canonicalize-json.ts","../src/attestation.ts"],"sourcesContent":["function sortKeys(value: unknown): unknown {\n if (value === null || typeof value !== 'object') {\n return value;\n }\n if (Array.isArray(value)) {\n return value.map(sortKeys);\n }\n const sorted: Record<string, unknown> = {};\n for (const key of Object.keys(value).sort()) {\n sorted[key] = sortKeys((value as Record<string, unknown>)[key]);\n }\n return sorted;\n}\n\nexport function canonicalizeJson(value: unknown): string {\n return JSON.stringify(sortKeys(value));\n}\n","import { createHash } from 'node:crypto';\nimport { canonicalizeJson } from './canonicalize-json';\nimport { readMigrationPackage, writeMigrationManifest } from './io';\nimport type { MigrationManifest, MigrationOps } from './types';\n\nexport interface VerifyResult {\n readonly ok: boolean;\n readonly reason?: 'draft' | 'mismatch';\n readonly storedMigrationId?: string;\n readonly computedMigrationId?: string;\n}\n\nfunction sha256Hex(input: string): string {\n return createHash('sha256').update(input).digest('hex');\n}\n\n/**\n * Content-addressed migration identity over (manifest envelope sans\n * contracts/hints, ops). See ADR 199 \"Storage-only migration identity\"\n * for the rationale: contracts are anchored separately by the\n * storage-hash bookends inside the envelope; planner hints are advisory\n * and must not affect identity.\n */\nexport function computeMigrationId(manifest: MigrationManifest, ops: MigrationOps): string {\n const {\n migrationId: _migrationId,\n signature: _signature,\n fromContract: _fromContract,\n toContract: _toContract,\n hints: _hints,\n ...strippedMeta\n } = manifest;\n\n const canonicalManifest = canonicalizeJson(strippedMeta);\n const canonicalOps = canonicalizeJson(ops);\n\n const partHashes = [canonicalManifest, canonicalOps].map(sha256Hex);\n const hash = sha256Hex(canonicalizeJson(partHashes));\n\n return `sha256:${hash}`;\n}\n\n/** Compute and persist `migrationId` to `manifest.json`. */\nexport async function attestMigration(dir: string): Promise<string> {\n const pkg = await readMigrationPackage(dir);\n const migrationId = computeMigrationId(pkg.manifest, pkg.ops);\n\n const updated = { ...pkg.manifest, migrationId };\n await writeMigrationManifest(dir, updated);\n\n return migrationId;\n}\n\nexport async function verifyMigration(dir: string): Promise<VerifyResult> {\n const pkg = await readMigrationPackage(dir);\n\n if (pkg.manifest.migrationId === null) {\n return { ok: false, reason: 'draft' };\n }\n\n const computed = computeMigrationId(pkg.manifest, pkg.ops);\n\n if (pkg.manifest.migrationId === computed) {\n return { ok: true, storedMigrationId: pkg.manifest.migrationId, computedMigrationId: computed };\n }\n\n return {\n ok: false,\n reason: 'mismatch',\n storedMigrationId: pkg.manifest.migrationId,\n computedMigrationId: computed,\n };\n}\n"],"mappings":";;;;AAAA,SAAS,SAAS,OAAyB;AACzC,KAAI,UAAU,QAAQ,OAAO,UAAU,SACrC,QAAO;AAET,KAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,MAAM,IAAI,SAAS;CAE5B,MAAMA,SAAkC,EAAE;AAC1C,MAAK,MAAM,OAAO,OAAO,KAAK,MAAM,CAAC,MAAM,CACzC,QAAO,OAAO,SAAU,MAAkC,KAAK;AAEjE,QAAO;;AAGT,SAAgB,iBAAiB,OAAwB;AACvD,QAAO,KAAK,UAAU,SAAS,MAAM,CAAC;;;;;ACHxC,SAAS,UAAU,OAAuB;AACxC,QAAO,WAAW,SAAS,CAAC,OAAO,MAAM,CAAC,OAAO,MAAM;;;;;;;;;AAUzD,SAAgB,mBAAmB,UAA6B,KAA2B;CACzF,MAAM,EACJ,aAAa,cACb,WAAW,YACX,cAAc,eACd,YAAY,aACZ,OAAO,QACP,GAAG,iBACD;AAQJ,QAAO,UAFM,UAAU,iBADJ,CAHO,iBAAiB,aAAa,EACnC,iBAAiB,IAAI,CAEU,CAAC,IAAI,UAAU,CAChB,CAAC;;;AAMtD,eAAsB,gBAAgB,KAA8B;CAClE,MAAM,MAAM,MAAM,qBAAqB,IAAI;CAC3C,MAAM,cAAc,mBAAmB,IAAI,UAAU,IAAI,IAAI;AAG7D,OAAM,uBAAuB,KADb;EAAE,GAAG,IAAI;EAAU;EAAa,CACN;AAE1C,QAAO;;AAGT,eAAsB,gBAAgB,KAAoC;CACxE,MAAM,MAAM,MAAM,qBAAqB,IAAI;AAE3C,KAAI,IAAI,SAAS,gBAAgB,KAC/B,QAAO;EAAE,IAAI;EAAO,QAAQ;EAAS;CAGvC,MAAM,WAAW,mBAAmB,IAAI,UAAU,IAAI,IAAI;AAE1D,KAAI,IAAI,SAAS,gBAAgB,SAC/B,QAAO;EAAE,IAAI;EAAM,mBAAmB,IAAI,SAAS;EAAa,qBAAqB;EAAU;AAGjG,QAAO;EACL,IAAI;EACJ,QAAQ;EACR,mBAAmB,IAAI,SAAS;EAChC,qBAAqB;EACtB"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"errors-C_XuSbX7.mjs","names":[],"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Structured error for migration tooling operations.\n *\n * Follows the NAMESPACE.SUBCODE convention from ADR 027. All codes live under\n * the MIGRATION namespace. These are tooling-time errors (file I/O, attestation,\n * migration history reconstruction), distinct from the runtime MIGRATION.* codes for apply-time\n * failures (PRECHECK_FAILED, POSTCHECK_FAILED, etc.).\n *\n * Fields:\n * - code: Stable machine-readable code (MIGRATION.SUBCODE)\n * - category: Always 'MIGRATION'\n * - why: Explains the cause in plain language\n * - fix: Actionable remediation step\n * - details: Machine-readable structured data for agents\n */\nexport class MigrationToolsError extends Error {\n readonly code: string;\n readonly category = 'MIGRATION' as const;\n readonly why: string;\n readonly fix: string;\n readonly details: Record<string, unknown> | undefined;\n\n constructor(\n code: string,\n summary: string,\n options: {\n readonly why: string;\n readonly fix: string;\n readonly details?: Record<string, unknown>;\n },\n ) {\n super(summary);\n this.name = 'MigrationToolsError';\n this.code = code;\n this.why = options.why;\n this.fix = options.fix;\n this.details = options.details;\n }\n\n static is(error: unknown): error is MigrationToolsError {\n if (!(error instanceof Error)) return false;\n const candidate = error as MigrationToolsError;\n return candidate.name === 'MigrationToolsError' && typeof candidate.code === 'string';\n }\n}\n\nexport function errorDirectoryExists(dir: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.DIR_EXISTS', 'Migration directory already exists', {\n why: `The directory \"${dir}\" already exists. Each migration must have a unique directory.`,\n fix: 'Use --name to pick a different name, or delete the existing directory and re-run.',\n details: { dir },\n });\n}\n\nexport function errorMissingFile(file: string, dir: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.FILE_MISSING', `Missing ${file}`, {\n why: `Expected \"${file}\" in \"${dir}\" but the file does not exist.`,\n fix: 'Ensure the migration directory contains both migration.json and ops.json. If the directory is corrupt, delete it and re-run migration plan.',\n details: { file, dir },\n });\n}\n\nexport function errorInvalidJson(filePath: string, parseError: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_JSON', 'Invalid JSON in migration file', {\n why: `Failed to parse \"${filePath}\": ${parseError}`,\n fix: 'Fix the JSON syntax error, or delete the migration directory and re-run migration plan.',\n details: { filePath, parseError },\n });\n}\n\nexport function errorInvalidManifest(filePath: string, reason: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_MANIFEST', 'Invalid migration manifest', {\n why: `Manifest at \"${filePath}\" is invalid: ${reason}`,\n fix: 'Ensure the manifest has all required fields (from, to, kind, toContract). If corrupt, delete and re-plan.',\n details: { filePath, reason },\n });\n}\n\nexport function errorInvalidSlug(slug: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_NAME', 'Invalid migration name', {\n why: `The slug \"${slug}\" contains no valid characters after sanitization (only a-z, 0-9 are kept).`,\n fix: 'Provide a name with at least one alphanumeric character, e.g. --name add_users.',\n details: { slug },\n });\n}\n\nexport function errorSameSourceAndTarget(dirName: string, hash: string): MigrationToolsError {\n return new MigrationToolsError(\n 'MIGRATION.SAME_SOURCE_AND_TARGET',\n 'Migration has same source and target',\n {\n why: `Migration \"${dirName}\" has from === to === \"${hash}\". A migration must transition between two different contract states.`,\n fix: 'Delete the invalid migration directory and re-run migration plan.',\n details: { dirName, hash },\n },\n );\n}\n\nexport function errorAmbiguousTarget(\n branchTips: readonly string[],\n context?: {\n divergencePoint: string;\n branches: readonly {\n tip: string;\n edges: readonly { dirName: string; from: string; to: string }[];\n }[];\n },\n): MigrationToolsError {\n const divergenceInfo = context\n ? `\\nDivergence point: ${context.divergencePoint}\\nBranches:\\n${context.branches.map((b) => ` → ${b.tip} (${b.edges.length} edge(s): ${b.edges.map((e) => e.dirName).join(' → ') || 'direct'})`).join('\\n')}`\n : '';\n return new MigrationToolsError('MIGRATION.AMBIGUOUS_TARGET', 'Ambiguous migration target', {\n why: `The migration history has diverged into multiple branches: ${branchTips.join(', ')}. This typically happens when two developers plan migrations from the same starting point.${divergenceInfo}`,\n fix: 'Use `migration ref set <name> <hash>` to target a specific branch, delete one of the conflicting migration directories and re-run `migration plan`, or use --from <hash> to explicitly select a starting point.',\n details: {\n branchTips,\n ...(context ? { divergencePoint: context.divergencePoint, branches: context.branches } : {}),\n },\n });\n}\n\nexport function errorNoInitialMigration(nodes: readonly string[]): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.NO_INITIAL_MIGRATION', 'No initial migration found', {\n why: `No migration starts from the empty contract state (known hashes: ${nodes.join(', ')}). At least one migration must originate from the empty state.`,\n fix: 'Inspect the migrations directory for corrupted migration.json files. At least one migration must start from the empty contract hash.',\n details: { nodes },\n });\n}\n\nexport function errorInvalidRefs(refsPath: string, reason: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_REFS', 'Invalid refs.json', {\n why: `refs.json at \"${refsPath}\" is invalid: ${reason}`,\n fix: 'Ensure refs.json is a flat object mapping valid ref names to contract hash strings.',\n details: { path: refsPath, reason },\n });\n}\n\nexport function errorInvalidRefFile(filePath: string, reason: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_REF_FILE', 'Invalid ref file', {\n why: `Ref file at \"${filePath}\" is invalid: ${reason}`,\n fix: 'Ensure the ref file contains valid JSON with { \"hash\": \"sha256:<64 hex chars>\", \"invariants\": [\"...\"] }.',\n details: { path: filePath, reason },\n });\n}\n\nexport function errorInvalidRefName(refName: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_REF_NAME', 'Invalid ref name', {\n why: `Ref name \"${refName}\" is invalid. Names must be lowercase alphanumeric with hyphens or forward slashes (no \".\" or \"..\" segments).`,\n fix: `Use a valid ref name (e.g., \"staging\", \"envs/production\").`,\n details: { refName },\n });\n}\n\nexport function errorNoTarget(reachableHashes: readonly string[]): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.NO_TARGET', 'No migration target could be resolved', {\n why: `The migration history contains cycles and no target can be resolved automatically (reachable hashes: ${reachableHashes.join(', ')}). This typically happens after rollback migrations (e.g., C1→C2→C1).`,\n fix: 'Use --from <hash> to specify the planning origin explicitly.',\n details: { reachableHashes },\n });\n}\n\nexport function errorInvalidRefValue(value: string): MigrationToolsError {\n return new MigrationToolsError('MIGRATION.INVALID_REF_VALUE', 'Invalid ref value', {\n why: `Ref value \"${value}\" is not a valid contract hash. Values must be in the format \"sha256:<64 hex chars>\" or \"sha256:empty\".`,\n fix: 'Use a valid storage hash from `prisma-next contract emit` output or an existing migration.',\n details: { value },\n });\n}\n\nexport function errorDuplicateMigrationId(migrationId: string): MigrationToolsError {\n return new MigrationToolsError(\n 'MIGRATION.DUPLICATE_MIGRATION_ID',\n 'Duplicate migrationId in migration graph',\n {\n why: `Multiple migrations share migrationId \"${migrationId}\". Each migration must have a unique content-addressed identity.`,\n fix: 'Regenerate one of the conflicting migrations so each migrationId is unique, then re-run migration commands.',\n details: { migrationId },\n },\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAeA,IAAa,sBAAb,cAAyC,MAAM;CAC7C,AAAS;CACT,AAAS,WAAW;CACpB,AAAS;CACT,AAAS;CACT,AAAS;CAET,YACE,MACA,SACA,SAKA;AACA,QAAM,QAAQ;AACd,OAAK,OAAO;AACZ,OAAK,OAAO;AACZ,OAAK,MAAM,QAAQ;AACnB,OAAK,MAAM,QAAQ;AACnB,OAAK,UAAU,QAAQ;;CAGzB,OAAO,GAAG,OAA8C;AACtD,MAAI,EAAE,iBAAiB,OAAQ,QAAO;EACtC,MAAM,YAAY;AAClB,SAAO,UAAU,SAAS,yBAAyB,OAAO,UAAU,SAAS;;;AAIjF,SAAgB,qBAAqB,KAAkC;AACrE,QAAO,IAAI,oBAAoB,wBAAwB,sCAAsC;EAC3F,KAAK,kBAAkB,IAAI;EAC3B,KAAK;EACL,SAAS,EAAE,KAAK;EACjB,CAAC;;AAGJ,SAAgB,iBAAiB,MAAc,KAAkC;AAC/E,QAAO,IAAI,oBAAoB,0BAA0B,WAAW,QAAQ;EAC1E,KAAK,aAAa,KAAK,QAAQ,IAAI;EACnC,KAAK;EACL,SAAS;GAAE;GAAM;GAAK;EACvB,CAAC;;AAGJ,SAAgB,iBAAiB,UAAkB,YAAyC;AAC1F,QAAO,IAAI,oBAAoB,0BAA0B,kCAAkC;EACzF,KAAK,oBAAoB,SAAS,KAAK;EACvC,KAAK;EACL,SAAS;GAAE;GAAU;GAAY;EAClC,CAAC;;AAGJ,SAAgB,qBAAqB,UAAkB,QAAqC;AAC1F,QAAO,IAAI,oBAAoB,8BAA8B,8BAA8B;EACzF,KAAK,gBAAgB,SAAS,gBAAgB;EAC9C,KAAK;EACL,SAAS;GAAE;GAAU;GAAQ;EAC9B,CAAC;;AAGJ,SAAgB,iBAAiB,MAAmC;AAClE,QAAO,IAAI,oBAAoB,0BAA0B,0BAA0B;EACjF,KAAK,aAAa,KAAK;EACvB,KAAK;EACL,SAAS,EAAE,MAAM;EAClB,CAAC;;AAGJ,SAAgB,yBAAyB,SAAiB,MAAmC;AAC3F,QAAO,IAAI,oBACT,oCACA,wCACA;EACE,KAAK,cAAc,QAAQ,yBAAyB,KAAK;EACzD,KAAK;EACL,SAAS;GAAE;GAAS;GAAM;EAC3B,CACF;;AAGH,SAAgB,qBACd,YACA,SAOqB;CACrB,MAAM,iBAAiB,UACnB,uBAAuB,QAAQ,gBAAgB,eAAe,QAAQ,SAAS,KAAK,MAAM,OAAO,EAAE,IAAI,IAAI,EAAE,MAAM,OAAO,YAAY,EAAE,MAAM,KAAK,MAAM,EAAE,QAAQ,CAAC,KAAK,MAAM,IAAI,SAAS,GAAG,CAAC,KAAK,KAAK,KAC1M;AACJ,QAAO,IAAI,oBAAoB,8BAA8B,8BAA8B;EACzF,KAAK,8DAA8D,WAAW,KAAK,KAAK,CAAC,4FAA4F;EACrL,KAAK;EACL,SAAS;GACP;GACA,GAAI,UAAU;IAAE,iBAAiB,QAAQ;IAAiB,UAAU,QAAQ;IAAU,GAAG,EAAE;GAC5F;EACF,CAAC;;AAGJ,SAAgB,wBAAwB,OAA+C;AACrF,QAAO,IAAI,oBAAoB,kCAAkC,8BAA8B;EAC7F,KAAK,oEAAoE,MAAM,KAAK,KAAK,CAAC;EAC1F,KAAK;EACL,SAAS,EAAE,OAAO;EACnB,CAAC;;AAGJ,SAAgB,iBAAiB,UAAkB,QAAqC;AACtF,QAAO,IAAI,oBAAoB,0BAA0B,qBAAqB;EAC5E,KAAK,iBAAiB,SAAS,gBAAgB;EAC/C,KAAK;EACL,SAAS;GAAE,MAAM;GAAU;GAAQ;EACpC,CAAC;;AAWJ,SAAgB,oBAAoB,SAAsC;AACxE,QAAO,IAAI,oBAAoB,8BAA8B,oBAAoB;EAC/E,KAAK,aAAa,QAAQ;EAC1B,KAAK;EACL,SAAS,EAAE,SAAS;EACrB,CAAC;;AAGJ,SAAgB,cAAc,iBAAyD;AACrF,QAAO,IAAI,oBAAoB,uBAAuB,yCAAyC;EAC7F,KAAK,wGAAwG,gBAAgB,KAAK,KAAK,CAAC;EACxI,KAAK;EACL,SAAS,EAAE,iBAAiB;EAC7B,CAAC;;AAGJ,SAAgB,qBAAqB,OAAoC;AACvE,QAAO,IAAI,oBAAoB,+BAA+B,qBAAqB;EACjF,KAAK,cAAc,MAAM;EACzB,KAAK;EACL,SAAS,EAAE,OAAO;EACnB,CAAC;;AAGJ,SAAgB,0BAA0B,aAA0C;AAClF,QAAO,IAAI,oBACT,oCACA,4CACA;EACE,KAAK,0CAA0C,YAAY;EAC3D,KAAK;EACL,SAAS,EAAE,aAAa;EACzB,CACF"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"types.mjs","names":[],"sources":["../../src/types.ts"],"sourcesContent":["import type { Contract } from '@prisma-next/contract/types';\nimport type { MigrationPlanOperation } from '@prisma-next/framework-components/control';\n\nexport interface MigrationHints {\n readonly used: readonly string[];\n readonly applied: readonly string[];\n readonly plannerVersion: string;\n readonly planningStrategy: string;\n}\n\n/**\n * Shared fields for all migration manifests (draft and attested).\n */\ninterface MigrationManifestBase {\n readonly from: string;\n readonly to: string;\n readonly kind: 'regular' | 'baseline';\n readonly fromContract: Contract | null;\n readonly toContract: Contract;\n readonly hints: MigrationHints;\n readonly labels: readonly string[];\n readonly authorship?: { readonly author?: string; readonly email?: string };\n readonly signature?: { readonly keyId: string; readonly value: string } | null;\n readonly createdAt: string;\n}\n\n/**\n * A draft migration that has been planned but not yet attested.\n * Draft migrations have `migrationId: null` and are excluded from\n * graph reconstruction and apply.\n */\nexport interface DraftMigrationManifest extends MigrationManifestBase {\n readonly migrationId: null;\n}\n\n/**\n * An attested migration with a content-addressed migrationId.\n * Only attested migrations participate in the migration graph.\n */\nexport interface AttestedMigrationManifest extends MigrationManifestBase {\n readonly migrationId: string;\n}\n\n/**\n * Union of draft and attested manifests. This is what the on-disk\n * format represents — `migrationId` is `null` for drafts, a string\n * for attested migrations.\n */\nexport type MigrationManifest = DraftMigrationManifest | AttestedMigrationManifest;\n\nexport type MigrationOps = readonly MigrationPlanOperation[];\n\n/**\n * An on-disk migration directory containing a manifest and operations.\n * The manifest may be draft or attested.\n */\nexport interface BaseMigrationBundle {\n readonly dirName: string;\n readonly dirPath: string;\n readonly manifest: MigrationManifest;\n readonly ops: MigrationOps;\n}\n\n/**\n * A bundle known to be attested (migrationId is a string).\n * Use this after filtering bundles to attested-only.\n */\nexport interface AttestedMigrationBundle extends BaseMigrationBundle {\n readonly manifest: AttestedMigrationManifest;\n}\n\nexport interface DraftMigrationBundle extends BaseMigrationBundle {\n readonly manifest: DraftMigrationManifest;\n}\n\n/**\n * An entry in the migration graph. Only attested migrations appear in the\n * graph, so `migrationId` is always a string.\n */\nexport interface MigrationChainEntry {\n readonly from: string;\n readonly to: string;\n readonly migrationId: string;\n readonly dirName: string;\n readonly createdAt: string;\n readonly labels: readonly string[];\n}\n\nexport interface MigrationGraph {\n readonly nodes: ReadonlySet<string>;\n readonly forwardChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;\n readonly reverseChain: ReadonlyMap<string, readonly MigrationChainEntry[]>;\n readonly migrationById: ReadonlyMap<string, MigrationChainEntry>;\n}\n\n/**\n * Type guard that narrows a MigrationBundle to an AttestedMigrationBundle.\n * Use with `.filter(isAttested)` to get a typed array of attested bundles.\n */\nexport function isAttested(bundle: BaseMigrationBundle): bundle is AttestedMigrationBundle {\n return typeof bundle.manifest.migrationId === 'string';\n}\n\nexport function isDraft(bundle: BaseMigrationBundle): bundle is DraftMigrationBundle {\n return bundle.manifest.migrationId === null;\n}\n"],"mappings":";;;;;;;AAmGA,SAAgB,WAAW,QAAgE;AACzF,QAAO,OAAO,OAAO,SAAS,gBAAgB;;AAGhD,SAAgB,QAAQ,QAA6D;AACnF,QAAO,OAAO,SAAS,gBAAgB"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"io-Cun81AIZ.mjs","names":["manifestRaw: string","opsRaw: string","manifest: MigrationManifest","ops: MigrationOps","entries: string[]","packages: BaseMigrationBundle[]"],"sources":["../src/io.ts"],"sourcesContent":["import { copyFile, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';\nimport { type } from 'arktype';\nimport { basename, dirname, join } from 'pathe';\nimport {\n errorDirectoryExists,\n errorInvalidJson,\n errorInvalidManifest,\n errorInvalidSlug,\n errorMissingFile,\n} from './errors';\nimport type { BaseMigrationBundle, MigrationManifest, MigrationOps } from './types';\n\nconst MANIFEST_FILE = 'migration.json';\nconst OPS_FILE = 'ops.json';\nconst MAX_SLUG_LENGTH = 64;\n\nfunction hasErrnoCode(error: unknown, code: string): boolean {\n return error instanceof Error && (error as { code?: string }).code === code;\n}\n\nconst MigrationHintsSchema = type({\n used: 'string[]',\n applied: 'string[]',\n plannerVersion: 'string',\n planningStrategy: 'string',\n});\n\nconst MigrationManifestSchema = type({\n from: 'string',\n to: 'string',\n migrationId: 'string | null',\n kind: \"'regular' | 'baseline'\",\n fromContract: 'object | null',\n toContract: 'object',\n hints: MigrationHintsSchema,\n labels: 'string[]',\n 'authorship?': type({\n 'author?': 'string',\n 'email?': 'string',\n }),\n 'signature?': type({\n keyId: 'string',\n value: 'string',\n }).or('null'),\n createdAt: 'string',\n});\n\nconst MigrationOpSchema = type({\n id: 'string',\n label: 'string',\n operationClass: \"'additive' | 'widening' | 'destructive' | 'data'\",\n});\n\n// Intentionally shallow: operation-specific payload validation is owned by planner/runner layers.\nconst MigrationOpsSchema = MigrationOpSchema.array();\n\nexport async function writeMigrationPackage(\n dir: string,\n manifest: MigrationManifest,\n ops: MigrationOps,\n): Promise<void> {\n await mkdir(dirname(dir), { recursive: true });\n\n try {\n await mkdir(dir);\n } catch (error) {\n if (hasErrnoCode(error, 'EEXIST')) {\n throw errorDirectoryExists(dir);\n }\n throw error;\n }\n\n await writeFile(join(dir, MANIFEST_FILE), JSON.stringify(manifest, null, 2), { flag: 'wx' });\n await writeFile(join(dir, OPS_FILE), JSON.stringify(ops, null, 2), { flag: 'wx' });\n}\n\n/**\n * Copy the destination contract artifacts (`contract.json` and the\n * colocated `contract.d.ts`) into the migration package directory so\n * authors of the scaffolded `migration.ts` can import the typed\n * contract relative to the migration directory\n * (`import type { Contract } from './contract'`).\n *\n * A missing `.d.ts` is tolerated (only the `.json` is required) so the\n * helper stays usable in tests that hand-roll a bare `contract.json`.\n * A missing `contract.json` — or any other I/O failure — throws.\n */\nexport async function copyContractToMigrationDir(\n packageDir: string,\n contractJsonPath: string,\n): Promise<void> {\n await copyFile(contractJsonPath, join(packageDir, 'contract.json'));\n const dtsPath = `${contractJsonPath.slice(0, -'.json'.length)}.d.ts`;\n try {\n await copyFile(dtsPath, join(packageDir, 'contract.d.ts'));\n } catch (error) {\n if (hasErrnoCode(error, 'ENOENT')) return;\n throw error;\n }\n}\n\nexport async function writeMigrationManifest(\n dir: string,\n manifest: MigrationManifest,\n): Promise<void> {\n await writeFile(join(dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\\n`);\n}\n\nexport async function writeMigrationOps(dir: string, ops: MigrationOps): Promise<void> {\n await writeFile(join(dir, OPS_FILE), `${JSON.stringify(ops, null, 2)}\\n`);\n}\n\nexport async function readMigrationPackage(dir: string): Promise<BaseMigrationBundle> {\n const manifestPath = join(dir, MANIFEST_FILE);\n const opsPath = join(dir, OPS_FILE);\n\n let manifestRaw: string;\n try {\n manifestRaw = await readFile(manifestPath, 'utf-8');\n } catch (error) {\n if (hasErrnoCode(error, 'ENOENT')) {\n throw errorMissingFile(MANIFEST_FILE, dir);\n }\n throw error;\n }\n\n let opsRaw: string;\n try {\n opsRaw = await readFile(opsPath, 'utf-8');\n } catch (error) {\n if (hasErrnoCode(error, 'ENOENT')) {\n throw errorMissingFile(OPS_FILE, dir);\n }\n throw error;\n }\n\n let manifest: MigrationManifest;\n try {\n manifest = JSON.parse(manifestRaw);\n } catch (e) {\n throw errorInvalidJson(manifestPath, e instanceof Error ? e.message : String(e));\n }\n\n let ops: MigrationOps;\n try {\n ops = JSON.parse(opsRaw);\n } catch (e) {\n throw errorInvalidJson(opsPath, e instanceof Error ? e.message : String(e));\n }\n\n validateManifest(manifest, manifestPath);\n validateOps(ops, opsPath);\n\n return {\n dirName: basename(dir),\n dirPath: dir,\n manifest,\n ops,\n };\n}\n\nfunction validateManifest(\n manifest: unknown,\n filePath: string,\n): asserts manifest is MigrationManifest {\n const result = MigrationManifestSchema(manifest);\n if (result instanceof type.errors) {\n throw errorInvalidManifest(filePath, result.summary);\n }\n}\n\nfunction validateOps(ops: unknown, filePath: string): asserts ops is MigrationOps {\n const result = MigrationOpsSchema(ops);\n if (result instanceof type.errors) {\n throw errorInvalidManifest(filePath, result.summary);\n }\n}\n\nexport async function readMigrationsDir(\n migrationsRoot: string,\n): Promise<readonly BaseMigrationBundle[]> {\n let entries: string[];\n try {\n entries = await readdir(migrationsRoot);\n } catch (error) {\n if (hasErrnoCode(error, 'ENOENT')) {\n return [];\n }\n throw error;\n }\n\n const packages: BaseMigrationBundle[] = [];\n\n for (const entry of entries.sort()) {\n const entryPath = join(migrationsRoot, entry);\n const entryStat = await stat(entryPath);\n if (!entryStat.isDirectory()) continue;\n\n const manifestPath = join(entryPath, MANIFEST_FILE);\n try {\n await stat(manifestPath);\n } catch {\n continue; // skip non-migration directories\n }\n\n packages.push(await readMigrationPackage(entryPath));\n }\n\n return packages;\n}\n\nexport function formatMigrationDirName(timestamp: Date, slug: string): string {\n const sanitized = slug\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '_')\n .replace(/_+/g, '_')\n .replace(/^_|_$/g, '');\n\n if (sanitized.length === 0) {\n throw errorInvalidSlug(slug);\n }\n\n const truncated = sanitized.slice(0, MAX_SLUG_LENGTH);\n\n const y = timestamp.getUTCFullYear();\n const mo = String(timestamp.getUTCMonth() + 1).padStart(2, '0');\n const d = String(timestamp.getUTCDate()).padStart(2, '0');\n const h = String(timestamp.getUTCHours()).padStart(2, '0');\n const mi = String(timestamp.getUTCMinutes()).padStart(2, '0');\n\n return `${y}${mo}${d}T${h}${mi}_${truncated}`;\n}\n"],"mappings":"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"}