@prisma-next/migration-tools 0.5.0-dev.2 → 0.5.0-dev.21

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 (86) hide show
  1. package/README.md +34 -22
  2. package/dist/{constants-BRi0X7B_.mjs → constants-DOzBI2EP.mjs} +1 -1
  3. package/dist/{constants-BRi0X7B_.mjs.map → constants-DOzBI2EP.mjs.map} +1 -1
  4. package/dist/{errors-BKbRGCJM.mjs → errors-BS_Kq8GF.mjs} +83 -21
  5. package/dist/errors-BS_Kq8GF.mjs.map +1 -0
  6. package/dist/exports/constants.mjs +1 -1
  7. package/dist/exports/{types.d.mts → errors.d.mts} +6 -8
  8. package/dist/exports/errors.d.mts.map +1 -0
  9. package/dist/exports/errors.mjs +3 -0
  10. package/dist/exports/graph.d.mts +2 -0
  11. package/dist/exports/graph.mjs +1 -0
  12. package/dist/exports/hash.d.mts +52 -0
  13. package/dist/exports/hash.d.mts.map +1 -0
  14. package/dist/exports/hash.mjs +3 -0
  15. package/dist/exports/invariants.d.mts +24 -0
  16. package/dist/exports/invariants.d.mts.map +1 -0
  17. package/dist/exports/invariants.mjs +4 -0
  18. package/dist/exports/io.d.mts +7 -6
  19. package/dist/exports/io.d.mts.map +1 -1
  20. package/dist/exports/io.mjs +166 -2
  21. package/dist/exports/io.mjs.map +1 -0
  22. package/dist/exports/metadata.d.mts +2 -0
  23. package/dist/exports/metadata.mjs +1 -0
  24. package/dist/exports/{dag.d.mts → migration-graph.d.mts} +10 -9
  25. package/dist/exports/migration-graph.d.mts.map +1 -0
  26. package/dist/exports/{dag.mjs → migration-graph.mjs} +18 -17
  27. package/dist/exports/migration-graph.mjs.map +1 -0
  28. package/dist/exports/migration-ts.mjs +1 -1
  29. package/dist/exports/migration.d.mts +13 -10
  30. package/dist/exports/migration.d.mts.map +1 -1
  31. package/dist/exports/migration.mjs +23 -21
  32. package/dist/exports/migration.mjs.map +1 -1
  33. package/dist/exports/package.d.mts +2 -0
  34. package/dist/exports/package.mjs +1 -0
  35. package/dist/exports/refs.d.mts +11 -5
  36. package/dist/exports/refs.d.mts.map +1 -1
  37. package/dist/exports/refs.mjs +106 -30
  38. package/dist/exports/refs.mjs.map +1 -1
  39. package/dist/graph-coc0V7k2.d.mts +28 -0
  40. package/dist/graph-coc0V7k2.d.mts.map +1 -0
  41. package/dist/hash-BARZdVgW.mjs +76 -0
  42. package/dist/hash-BARZdVgW.mjs.map +1 -0
  43. package/dist/invariants-jlMTqh_Q.mjs +42 -0
  44. package/dist/invariants-jlMTqh_Q.mjs.map +1 -0
  45. package/dist/metadata-CdSwaQ2k.d.mts +51 -0
  46. package/dist/metadata-CdSwaQ2k.d.mts.map +1 -0
  47. package/dist/package-DFjGigEm.d.mts +21 -0
  48. package/dist/package-DFjGigEm.d.mts.map +1 -0
  49. package/package.json +32 -16
  50. package/src/errors.ts +106 -15
  51. package/src/exports/errors.ts +1 -0
  52. package/src/exports/graph.ts +1 -0
  53. package/src/exports/hash.ts +2 -0
  54. package/src/exports/invariants.ts +1 -0
  55. package/src/exports/io.ts +1 -1
  56. package/src/exports/metadata.ts +1 -0
  57. package/src/exports/{dag.ts → migration-graph.ts} +2 -2
  58. package/src/exports/package.ts +1 -0
  59. package/src/exports/refs.ts +10 -2
  60. package/src/graph.ts +25 -0
  61. package/src/hash.ts +91 -0
  62. package/src/invariants.ts +45 -0
  63. package/src/io.ts +55 -20
  64. package/src/metadata.ts +42 -0
  65. package/src/migration-base.ts +34 -28
  66. package/src/{dag.ts → migration-graph.ts} +36 -38
  67. package/src/package.ts +18 -0
  68. package/src/refs.ts +148 -37
  69. package/dist/attestation-DtF8tEOM.mjs +0 -65
  70. package/dist/attestation-DtF8tEOM.mjs.map +0 -1
  71. package/dist/errors-BKbRGCJM.mjs.map +0 -1
  72. package/dist/exports/attestation.d.mts +0 -37
  73. package/dist/exports/attestation.d.mts.map +0 -1
  74. package/dist/exports/attestation.mjs +0 -4
  75. package/dist/exports/dag.d.mts.map +0 -1
  76. package/dist/exports/dag.mjs.map +0 -1
  77. package/dist/exports/types.d.mts.map +0 -1
  78. package/dist/exports/types.mjs +0 -3
  79. package/dist/io-CCnYsUHU.mjs +0 -153
  80. package/dist/io-CCnYsUHU.mjs.map +0 -1
  81. package/dist/types-DyGXcWWp.d.mts +0 -71
  82. package/dist/types-DyGXcWWp.d.mts.map +0 -1
  83. package/src/attestation.ts +0 -81
  84. package/src/exports/attestation.ts +0 -2
  85. package/src/exports/types.ts +0 -10
  86. package/src/types.ts +0 -66
@@ -8,8 +8,10 @@ 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 { deriveProvidedInvariants } from './invariants';
13
+ import type { MigrationHints, MigrationMetadata } from './metadata';
14
+ import type { MigrationOps } from './package';
13
15
 
14
16
  export interface MigrationMeta {
15
17
  readonly from: string;
@@ -30,7 +32,7 @@ const MigrationMetaSchema = type({
30
32
  *
31
33
  * A `Migration` subclass is itself a `MigrationPlan`: CLI commands and the
32
34
  * runner can consume it directly via `targetId`, `operations`, `origin`, and
33
- * `destination`. The manifest-shaped inputs come from `describe()`, which
35
+ * `destination`. The metadata-shaped inputs come from `describe()`, which
34
36
  * every migration must implement — `migration.json` is required for a
35
37
  * migration to be valid.
36
38
  */
@@ -123,64 +125,68 @@ function printHelp(): void {
123
125
 
124
126
  /**
125
127
  * In-memory artifacts produced from a `Migration` instance: the
126
- * serialized `ops.json` body, the `migration.json` manifest object, and
128
+ * serialized `ops.json` body, the `migration.json` metadata object, and
127
129
  * its serialized form. Returned by `buildMigrationArtifacts` so callers
128
130
  * (today: `MigrationCLI.run` in `@prisma-next/cli/migration-cli`) can
129
131
  * decide how to persist them — write to disk, print in dry-run, ship
130
132
  * over the wire — without coupling artifact construction to file I/O.
133
+ *
134
+ * `metadataJson` is `JSON.stringify(metadata, null, 2)` — the canonical
135
+ * on-disk shape that the arktype loader-schema in `./io` validates.
131
136
  */
132
137
  export interface MigrationArtifacts {
133
138
  readonly opsJson: string;
134
- readonly manifest: MigrationManifest;
135
- readonly manifestJson: string;
139
+ readonly metadata: MigrationMetadata;
140
+ readonly metadataJson: string;
136
141
  }
137
142
 
138
143
  /**
139
- * Build the attested manifest from `describe()`-derived metadata, the
140
- * operations list, and the previously-scaffolded manifest (if any).
144
+ * Build the attested metadata from `describe()`-derived metadata, the
145
+ * operations list, and the previously-scaffolded metadata (if any).
141
146
  *
142
147
  * When a `migration.json` already exists for this package (the common
143
148
  * case: it was scaffolded by `migration plan`), preserve the contract
144
149
  * bookends, hints, labels, and `createdAt` set there — those fields are
145
150
  * owned by the CLI scaffolder, not the authored class. Only the
146
151
  * `describe()`-derived fields (`from`, `to`, `kind`) and the operations
147
- * change as the author iterates. When no manifest exists yet (a bare
152
+ * change as the author iterates. When no metadata exists yet (a bare
148
153
  * `migration.ts` run from scratch), synthesize a minimal but
149
- * schema-conformant manifest so the resulting package can still be read,
154
+ * schema-conformant record so the resulting package can still be read,
150
155
  * verified, and applied.
151
156
  *
152
- * The `migrationId` is recomputed against the current manifest + ops so
157
+ * The `migrationHash` is recomputed against the current metadata + ops so
153
158
  * the on-disk artifacts are always fully attested.
154
159
  */
155
- function buildAttestedManifest(
160
+ function buildAttestedMetadata(
156
161
  meta: MigrationMeta,
157
162
  ops: MigrationOps,
158
- existing: Partial<MigrationManifest> | null,
159
- ): MigrationManifest {
160
- const baseManifest: Omit<MigrationManifest, 'migrationId'> = {
163
+ existing: Partial<MigrationMetadata> | null,
164
+ ): MigrationMetadata {
165
+ const baseMetadata: Omit<MigrationMetadata, 'migrationHash'> = {
161
166
  from: meta.from,
162
167
  to: meta.to,
163
168
  kind: meta.kind ?? 'regular',
164
169
  labels: meta.labels ?? existing?.labels ?? [],
170
+ providedInvariants: deriveProvidedInvariants(ops),
165
171
  createdAt: existing?.createdAt ?? new Date().toISOString(),
166
172
  fromContract: existing?.fromContract ?? null,
167
- // When no scaffolded manifest exists we synthesize a minimal contract
173
+ // When no scaffolded metadata exists we synthesize a minimal contract
168
174
  // stub so the package is still readable end-to-end. The cast is
169
175
  // intentional: only the storage bookend matters for hash computation
170
- // (everything else is stripped by `computeMigrationId`), and a real
176
+ // (everything else is stripped by `computeMigrationHash`), and a real
171
177
  // contract bookend would only be available after `migration plan`.
172
178
  toContract: existing?.toContract ?? ({ storage: { storageHash: meta.to } } as Contract),
173
179
  hints: normalizeHints(existing?.hints),
174
180
  ...ifDefined('authorship', existing?.authorship),
175
181
  };
176
182
 
177
- const migrationId = computeMigrationId(baseManifest, ops);
178
- return { ...baseManifest, migrationId };
183
+ const migrationHash = computeMigrationHash(baseMetadata, ops);
184
+ return { ...baseMetadata, migrationHash };
179
185
  }
180
186
 
181
187
  /**
182
188
  * Project `existing.hints` down to the known `MigrationHints` shape, dropping
183
- * any legacy keys that may linger in manifests scaffolded by older CLI
189
+ * any legacy keys that may linger in metadata scaffolded by older CLI
184
190
  * versions (e.g. `planningStrategy`). Picking fields explicitly instead of
185
191
  * spreading keeps refreshed `migration.json` files schema-clean regardless
186
192
  * of what was on disk before.
@@ -195,16 +201,16 @@ function normalizeHints(existing: MigrationHints | undefined): MigrationHints {
195
201
 
196
202
  /**
197
203
  * Pure conversion from a `Migration` instance (plus the previously
198
- * scaffolded manifest, when one exists on disk) to the in-memory
204
+ * scaffolded metadata, when one exists on disk) to the in-memory
199
205
  * 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
206
+ * metadata synthesis/preservation, hint normalization, and the
207
+ * content-addressed `migrationHash` computation, but performs no file I/O
202
208
  * — callers handle reads (to source `existing`) and writes (to persist
203
- * `opsJson` / `manifestJson`).
209
+ * `opsJson` / `metadataJson`).
204
210
  */
205
211
  export function buildMigrationArtifacts(
206
212
  instance: Migration,
207
- existing: Partial<MigrationManifest> | null,
213
+ existing: Partial<MigrationMetadata> | null,
208
214
  ): MigrationArtifacts {
209
215
  const ops = instance.operations;
210
216
  if (!Array.isArray(ops)) {
@@ -217,11 +223,11 @@ export function buildMigrationArtifacts(
217
223
  throw new Error(`describe() returned invalid metadata: ${parsed.summary}`);
218
224
  }
219
225
 
220
- const manifest = buildAttestedManifest(parsed, ops, existing);
226
+ const metadata = buildAttestedMetadata(parsed, ops, existing);
221
227
 
222
228
  return {
223
229
  opsJson: JSON.stringify(ops, null, 2),
224
- manifest,
225
- manifestJson: JSON.stringify(manifest, null, 2),
230
+ metadata,
231
+ metadataJson: JSON.stringify(metadata, null, 2),
226
232
  };
227
233
  }
@@ -2,13 +2,14 @@ import { ifDefined } from '@prisma-next/utils/defined';
2
2
  import { EMPTY_CONTRACT_HASH } from './constants';
3
3
  import {
4
4
  errorAmbiguousTarget,
5
- errorDuplicateMigrationId,
5
+ errorDuplicateMigrationHash,
6
6
  errorNoInitialMigration,
7
7
  errorNoTarget,
8
8
  errorSameSourceAndTarget,
9
9
  } from './errors';
10
+ import type { MigrationEdge, MigrationGraph } from './graph';
10
11
  import { bfs } from './graph-ops';
11
- import type { MigrationBundle, MigrationChainEntry, MigrationGraph } from './types';
12
+ import type { MigrationPackage } from './package';
12
13
 
13
14
  /** Forward-edge neighbours for BFS: edge `e` from `n` visits `e.to` next. */
14
15
  function forwardNeighbours(graph: MigrationGraph, node: string) {
@@ -20,57 +21,54 @@ function reverseNeighbours(graph: MigrationGraph, node: string) {
20
21
  return (graph.reverseChain.get(node) ?? []).map((edge) => ({ next: edge.from, edge }));
21
22
  }
22
23
 
23
- function appendEdge(
24
- map: Map<string, MigrationChainEntry[]>,
25
- key: string,
26
- entry: MigrationChainEntry,
27
- ): void {
24
+ function appendEdge(map: Map<string, MigrationEdge[]>, key: string, entry: MigrationEdge): void {
28
25
  const bucket = map.get(key);
29
26
  if (bucket) bucket.push(entry);
30
27
  else map.set(key, [entry]);
31
28
  }
32
29
 
33
- export function reconstructGraph(packages: readonly MigrationBundle[]): MigrationGraph {
30
+ export function reconstructGraph(packages: readonly MigrationPackage[]): MigrationGraph {
34
31
  const nodes = new Set<string>();
35
- const forwardChain = new Map<string, MigrationChainEntry[]>();
36
- const reverseChain = new Map<string, MigrationChainEntry[]>();
37
- const migrationById = new Map<string, MigrationChainEntry>();
32
+ const forwardChain = new Map<string, MigrationEdge[]>();
33
+ const reverseChain = new Map<string, MigrationEdge[]>();
34
+ const migrationByHash = new Map<string, MigrationEdge>();
38
35
 
39
36
  for (const pkg of packages) {
40
- const { from, to } = pkg.manifest;
37
+ const { from, to } = pkg.metadata;
41
38
 
42
39
  if (from === to) {
43
- throw errorSameSourceAndTarget(pkg.dirName, from);
40
+ throw errorSameSourceAndTarget(pkg.dirPath, from);
44
41
  }
45
42
 
46
43
  nodes.add(from);
47
44
  nodes.add(to);
48
45
 
49
- const migration: MigrationChainEntry = {
46
+ const migration: MigrationEdge = {
50
47
  from,
51
48
  to,
52
- migrationId: pkg.manifest.migrationId,
49
+ migrationHash: pkg.metadata.migrationHash,
53
50
  dirName: pkg.dirName,
54
- createdAt: pkg.manifest.createdAt,
55
- labels: pkg.manifest.labels,
51
+ createdAt: pkg.metadata.createdAt,
52
+ labels: pkg.metadata.labels,
53
+ invariants: pkg.metadata.providedInvariants,
56
54
  };
57
55
 
58
- if (migrationById.has(migration.migrationId)) {
59
- throw errorDuplicateMigrationId(migration.migrationId);
56
+ if (migrationByHash.has(migration.migrationHash)) {
57
+ throw errorDuplicateMigrationHash(migration.migrationHash);
60
58
  }
61
- migrationById.set(migration.migrationId, migration);
59
+ migrationByHash.set(migration.migrationHash, migration);
62
60
 
63
61
  appendEdge(forwardChain, from, migration);
64
62
  appendEdge(reverseChain, to, migration);
65
63
  }
66
64
 
67
- return { nodes, forwardChain, reverseChain, migrationById };
65
+ return { nodes, forwardChain, reverseChain, migrationByHash };
68
66
  }
69
67
 
70
68
  // ---------------------------------------------------------------------------
71
69
  // Deterministic tie-breaking for BFS neighbour order.
72
70
  // Used by `findPath` and `findPathWithDecision` only; not a general-purpose
73
- // utility. Ordering: label priority → createdAt → to → migrationId.
71
+ // utility. Ordering: label priority → createdAt → to → migrationHash.
74
72
  // ---------------------------------------------------------------------------
75
73
 
76
74
  const LABEL_PRIORITY: Record<string, number> = { main: 0, default: 1, feature: 2 };
@@ -84,24 +82,24 @@ function labelPriority(labels: readonly string[]): number {
84
82
  return best;
85
83
  }
86
84
 
87
- function compareTieBreak(a: MigrationChainEntry, b: MigrationChainEntry): number {
85
+ function compareTieBreak(a: MigrationEdge, b: MigrationEdge): number {
88
86
  const lp = labelPriority(a.labels) - labelPriority(b.labels);
89
87
  if (lp !== 0) return lp;
90
88
  const ca = a.createdAt.localeCompare(b.createdAt);
91
89
  if (ca !== 0) return ca;
92
90
  const tc = a.to.localeCompare(b.to);
93
91
  if (tc !== 0) return tc;
94
- return a.migrationId.localeCompare(b.migrationId);
92
+ return a.migrationHash.localeCompare(b.migrationHash);
95
93
  }
96
94
 
97
- function sortedNeighbors(edges: readonly MigrationChainEntry[]): readonly MigrationChainEntry[] {
95
+ function sortedNeighbors(edges: readonly MigrationEdge[]): readonly MigrationEdge[] {
98
96
  return [...edges].sort(compareTieBreak);
99
97
  }
100
98
 
101
99
  /** Ordering adapter for `bfs` — sorts `{next, edge}` pairs by tie-break. */
102
100
  function bfsOrdering(
103
- items: readonly { next: string; edge: MigrationChainEntry }[],
104
- ): readonly { next: string; edge: MigrationChainEntry }[] {
101
+ items: readonly { next: string; edge: MigrationEdge }[],
102
+ ): readonly { next: string; edge: MigrationEdge }[] {
105
103
  return items.slice().sort((a, b) => compareTieBreak(a.edge, b.edge));
106
104
  }
107
105
 
@@ -111,22 +109,22 @@ function bfsOrdering(
111
109
  * exists. Returns an empty array when `fromHash === toHash` (no-op).
112
110
  *
113
111
  * Neighbor ordering is deterministic via the tie-break sort key:
114
- * label priority → createdAt → to → migrationId.
112
+ * label priority → createdAt → to → migrationHash.
115
113
  */
116
114
  export function findPath(
117
115
  graph: MigrationGraph,
118
116
  fromHash: string,
119
117
  toHash: string,
120
- ): readonly MigrationChainEntry[] | null {
118
+ ): readonly MigrationEdge[] | null {
121
119
  if (fromHash === toHash) return [];
122
120
 
123
- const parents = new Map<string, { parent: string; edge: MigrationChainEntry }>();
121
+ const parents = new Map<string, { parent: string; edge: MigrationEdge }>();
124
122
  for (const step of bfs([fromHash], (n) => forwardNeighbours(graph, n), bfsOrdering)) {
125
123
  if (step.parent !== null && step.incomingEdge !== null) {
126
124
  parents.set(step.node, { parent: step.parent, edge: step.incomingEdge });
127
125
  }
128
126
  if (step.node === toHash) {
129
- const path: MigrationChainEntry[] = [];
127
+ const path: MigrationEdge[] = [];
130
128
  let cur = toHash;
131
129
  let p = parents.get(cur);
132
130
  while (p) {
@@ -155,7 +153,7 @@ function collectNodesReachingTarget(graph: MigrationGraph, toHash: string): Set<
155
153
  }
156
154
 
157
155
  export interface PathDecision {
158
- readonly selectedPath: readonly MigrationChainEntry[];
156
+ readonly selectedPath: readonly MigrationEdge[];
159
157
  readonly fromHash: string;
160
158
  readonly toHash: string;
161
159
  readonly alternativeCount: number;
@@ -202,8 +200,8 @@ export function findPathWithDecision(
202
200
  if (reachable.length > 1) {
203
201
  alternativeCount += reachable.length - 1;
204
202
  const sorted = sortedNeighbors(reachable);
205
- if (sorted[0] && sorted[0].migrationId === edge.migrationId) {
206
- if (reachable.some((e) => e.migrationId !== edge.migrationId)) {
203
+ if (sorted[0] && sorted[0].migrationHash === edge.migrationHash) {
204
+ if (reachable.some((e) => e.migrationHash !== edge.migrationHash)) {
207
205
  tieBreakReasons.push(
208
206
  `at ${edge.from}: ${reachable.length} candidates, selected by tie-break`,
209
207
  );
@@ -319,7 +317,7 @@ export function findLeaf(graph: MigrationGraph): string | null {
319
317
  * to the single target. Returns null for an empty graph.
320
318
  * Throws AMBIGUOUS_TARGET if the graph has multiple branch tips.
321
319
  */
322
- export function findLatestMigration(graph: MigrationGraph): MigrationChainEntry | null {
320
+ export function findLatestMigration(graph: MigrationGraph): MigrationEdge | null {
323
321
  const leafHash = findLeaf(graph);
324
322
  if (leafHash === null) return null;
325
323
 
@@ -343,7 +341,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
343
341
  // Iterative three-color DFS. A frame is (node, outgoing edges, next-index).
344
342
  interface Frame {
345
343
  node: string;
346
- outgoing: readonly MigrationChainEntry[];
344
+ outgoing: readonly MigrationEdge[];
347
345
  index: number;
348
346
  }
349
347
  const stack: Frame[] = [];
@@ -389,7 +387,7 @@ export function detectCycles(graph: MigrationGraph): readonly string[][] {
389
387
  return cycles;
390
388
  }
391
389
 
392
- export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEntry[] {
390
+ export function detectOrphans(graph: MigrationGraph): readonly MigrationEdge[] {
393
391
  if (graph.nodes.size === 0) return [];
394
392
 
395
393
  const reachable = new Set<string>();
@@ -415,7 +413,7 @@ export function detectOrphans(graph: MigrationGraph): readonly MigrationChainEnt
415
413
  reachable.add(step.node);
416
414
  }
417
415
 
418
- const orphans: MigrationChainEntry[] = [];
416
+ const orphans: MigrationEdge[] = [];
419
417
  for (const [from, migrations] of graph.forwardChain) {
420
418
  if (!reachable.has(from)) {
421
419
  orphans.push(...migrations);
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
+ }
package/src/refs.ts CHANGED
@@ -1,14 +1,19 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readdir, readFile, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
2
2
  import { type } from 'arktype';
3
- import { dirname, join } from 'pathe';
3
+ import { dirname, join, relative } from 'pathe';
4
4
  import {
5
+ errorInvalidRefFile,
5
6
  errorInvalidRefName,
6
- errorInvalidRefs,
7
7
  errorInvalidRefValue,
8
8
  MigrationToolsError,
9
9
  } from './errors';
10
10
 
11
- export type Refs = Readonly<Record<string, string>>;
11
+ export interface RefEntry {
12
+ readonly hash: string;
13
+ readonly invariants: readonly string[];
14
+ }
15
+
16
+ export type Refs = Readonly<Record<string, RefEntry>>;
12
17
 
13
18
  const REF_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
14
19
  const REF_VALUE_PATTERN = /^sha256:(empty|[0-9a-f]{64})$/;
@@ -25,22 +30,40 @@ export function validateRefValue(value: string): boolean {
25
30
  return REF_VALUE_PATTERN.test(value);
26
31
  }
27
32
 
28
- const RefsSchema = type('Record<string, string>').narrow((refs, ctx) => {
29
- for (const [key, value] of Object.entries(refs)) {
30
- if (!validateRefName(key)) return ctx.mustBe(`valid ref names (invalid: "${key}")`);
31
- if (!validateRefValue(value))
32
- return ctx.mustBe(`valid contract hashes (invalid value for "${key}": "${value}")`);
33
- }
33
+ const RefEntrySchema = type({
34
+ hash: 'string',
35
+ invariants: 'string[]',
36
+ }).narrow((entry, ctx) => {
37
+ if (!validateRefValue(entry.hash))
38
+ return ctx.mustBe(`a valid contract hash (got "${entry.hash}")`);
34
39
  return true;
35
40
  });
36
41
 
37
- export async function readRefs(refsPath: string): Promise<Refs> {
42
+ function refFilePath(refsDir: string, name: string): string {
43
+ return join(refsDir, `${name}.json`);
44
+ }
45
+
46
+ function refNameFromPath(refsDir: string, filePath: string): string {
47
+ const rel = relative(refsDir, filePath);
48
+ return rel.replace(/\.json$/, '');
49
+ }
50
+
51
+ export async function readRef(refsDir: string, name: string): Promise<RefEntry> {
52
+ if (!validateRefName(name)) {
53
+ throw errorInvalidRefName(name);
54
+ }
55
+
56
+ const filePath = refFilePath(refsDir, name);
38
57
  let raw: string;
39
58
  try {
40
- raw = await readFile(refsPath, 'utf-8');
59
+ raw = await readFile(filePath, 'utf-8');
41
60
  } catch (error) {
42
61
  if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
43
- return {};
62
+ throw new MigrationToolsError('MIGRATION.UNKNOWN_REF', `Unknown ref "${name}"`, {
63
+ why: `No ref file found at "${filePath}".`,
64
+ fix: `Create the ref with: prisma-next migration ref set ${name} <hash>`,
65
+ details: { refName: name, filePath },
66
+ });
44
67
  }
45
68
  throw error;
46
69
  }
@@ -49,54 +72,142 @@ export async function readRefs(refsPath: string): Promise<Refs> {
49
72
  try {
50
73
  parsed = JSON.parse(raw);
51
74
  } catch {
52
- throw errorInvalidRefs(refsPath, 'Failed to parse as JSON');
75
+ throw errorInvalidRefFile(filePath, 'Failed to parse as JSON');
53
76
  }
54
77
 
55
- const result = RefsSchema(parsed);
78
+ const result = RefEntrySchema(parsed);
56
79
  if (result instanceof type.errors) {
57
- throw errorInvalidRefs(refsPath, result.summary);
80
+ throw errorInvalidRefFile(filePath, result.summary);
58
81
  }
59
82
 
60
83
  return result;
61
84
  }
62
85
 
63
- export async function writeRefs(refsPath: string, refs: Refs): Promise<void> {
64
- for (const [key, value] of Object.entries(refs)) {
65
- if (!validateRefName(key)) {
66
- throw errorInvalidRefName(key);
86
+ export async function readRefs(refsDir: string): Promise<Refs> {
87
+ let entries: string[];
88
+ try {
89
+ entries = await readdir(refsDir, { recursive: true, encoding: 'utf-8' });
90
+ } catch (error) {
91
+ if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
92
+ return {};
93
+ }
94
+ throw error;
95
+ }
96
+
97
+ const jsonFiles = entries.filter((entry) => entry.endsWith('.json'));
98
+ const refs: Record<string, RefEntry> = {};
99
+
100
+ for (const jsonFile of jsonFiles) {
101
+ const filePath = join(refsDir, jsonFile);
102
+ const name = refNameFromPath(refsDir, filePath);
103
+
104
+ let raw: string;
105
+ try {
106
+ raw = await readFile(filePath, 'utf-8');
107
+ } catch (error) {
108
+ // Tolerate the TOCTOU race between `readdir` and `readFile` (ENOENT) and
109
+ // benign EISDIR if a directory happens to end in `.json`. Anything else
110
+ // (EACCES, EIO, EMFILE, …) is a real failure and propagates so the CLI
111
+ // surfaces it rather than silently dropping the ref.
112
+ const code = error instanceof Error ? (error as { code?: string }).code : undefined;
113
+ if (code === 'ENOENT' || code === 'EISDIR') {
114
+ continue;
115
+ }
116
+ throw error;
117
+ }
118
+
119
+ let parsed: unknown;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ } catch {
123
+ throw errorInvalidRefFile(filePath, 'Failed to parse as JSON');
67
124
  }
68
- if (!validateRefValue(value)) {
69
- throw errorInvalidRefValue(value);
125
+
126
+ const result = RefEntrySchema(parsed);
127
+ if (result instanceof type.errors) {
128
+ throw errorInvalidRefFile(filePath, result.summary);
70
129
  }
130
+
131
+ refs[name] = result;
71
132
  }
72
133
 
73
- const sorted = Object.fromEntries(Object.entries(refs).sort(([a], [b]) => a.localeCompare(b)));
134
+ return refs;
135
+ }
136
+
137
+ export async function writeRef(refsDir: string, name: string, entry: RefEntry): Promise<void> {
138
+ if (!validateRefName(name)) {
139
+ throw errorInvalidRefName(name);
140
+ }
141
+ if (!validateRefValue(entry.hash)) {
142
+ throw errorInvalidRefValue(entry.hash);
143
+ }
74
144
 
75
- const dir = dirname(refsPath);
145
+ const filePath = refFilePath(refsDir, name);
146
+ const dir = dirname(filePath);
76
147
  await mkdir(dir, { recursive: true });
77
148
 
78
- const tmpPath = join(dir, `.refs.json.${Date.now()}.tmp`);
79
- await writeFile(tmpPath, `${JSON.stringify(sorted, null, 2)}\n`);
80
- await rename(tmpPath, refsPath);
149
+ const tmpPath = join(dir, `.${name.split('/').pop()}.json.${Date.now()}.tmp`);
150
+ await writeFile(
151
+ tmpPath,
152
+ `${JSON.stringify({ hash: entry.hash, invariants: [...entry.invariants] }, null, 2)}\n`,
153
+ );
154
+ await rename(tmpPath, filePath);
81
155
  }
82
156
 
83
- export function resolveRef(refs: Refs, name: string): string {
157
+ export async function deleteRef(refsDir: string, name: string): Promise<void> {
84
158
  if (!validateRefName(name)) {
85
159
  throw errorInvalidRefName(name);
86
160
  }
87
161
 
88
- const hash = refs[name];
89
- if (hash === undefined) {
162
+ const filePath = refFilePath(refsDir, name);
163
+ try {
164
+ await unlink(filePath);
165
+ } catch (error) {
166
+ if (error instanceof Error && (error as { code?: string }).code === 'ENOENT') {
167
+ throw new MigrationToolsError('MIGRATION.UNKNOWN_REF', `Unknown ref "${name}"`, {
168
+ why: `No ref file found at "${filePath}".`,
169
+ fix: 'Run `prisma-next migration ref list` to see available refs.',
170
+ details: { refName: name, filePath },
171
+ });
172
+ }
173
+ throw error;
174
+ }
175
+
176
+ // Clean empty parent directories up to refsDir. Stop walking on the expected
177
+ // "directory has siblings" signal (ENOTEMPTY on Linux, EEXIST on some BSDs)
178
+ // and on ENOENT (concurrent removal). Anything else (EACCES, EIO, …) is a
179
+ // real failure and propagates.
180
+ let dir = dirname(filePath);
181
+ while (dir !== refsDir && dir.startsWith(refsDir)) {
182
+ try {
183
+ await rmdir(dir);
184
+ dir = dirname(dir);
185
+ } catch (error) {
186
+ const code = error instanceof Error ? (error as { code?: string }).code : undefined;
187
+ if (code === 'ENOTEMPTY' || code === 'EEXIST' || code === 'ENOENT') {
188
+ break;
189
+ }
190
+ throw error;
191
+ }
192
+ }
193
+ }
194
+
195
+ export function resolveRef(refs: Refs, name: string): RefEntry {
196
+ if (!validateRefName(name)) {
197
+ throw errorInvalidRefName(name);
198
+ }
199
+
200
+ // Object.hasOwn gate: plain-object `refs` would otherwise let
201
+ // `refs['constructor']` return Object.prototype.constructor and bypass the
202
+ // UNKNOWN_REF throw. validateRefName accepts `"constructor"` as a name shape.
203
+ if (!Object.hasOwn(refs, name)) {
90
204
  throw new MigrationToolsError('MIGRATION.UNKNOWN_REF', `Unknown ref "${name}"`, {
91
- why: `No ref named "${name}" exists in refs.json.`,
92
- fix: `Available refs: ${Object.keys(refs).join(', ') || '(none)'}. Create a ref with: set the "${name}" key in migrations/refs.json.`,
205
+ why: `No ref named "${name}" exists.`,
206
+ fix: `Available refs: ${Object.keys(refs).join(', ') || '(none)'}. Create a ref with: prisma-next migration ref set ${name} <hash>`,
93
207
  details: { refName: name, availableRefs: Object.keys(refs) },
94
208
  });
95
209
  }
96
210
 
97
- if (!validateRefValue(hash)) {
98
- throw errorInvalidRefValue(hash);
99
- }
100
-
101
- return hash;
211
+ // biome-ignore lint/style/noNonNullAssertion: Object.hasOwn gate above guarantees this is defined
212
+ return refs[name]!;
102
213
  }
@@ -1,65 +0,0 @@
1
- import { r as readMigrationPackage } from "./io-CCnYsUHU.mjs";
2
- import { createHash } from "node:crypto";
3
-
4
- //#region src/canonicalize-json.ts
5
- function sortKeys(value) {
6
- if (value === null || typeof value !== "object") return value;
7
- if (Array.isArray(value)) return value.map(sortKeys);
8
- const sorted = {};
9
- for (const key of Object.keys(value).sort()) sorted[key] = sortKeys(value[key]);
10
- return sorted;
11
- }
12
- function canonicalizeJson(value) {
13
- return JSON.stringify(sortKeys(value));
14
- }
15
-
16
- //#endregion
17
- //#region src/attestation.ts
18
- function sha256Hex(input) {
19
- return createHash("sha256").update(input).digest("hex");
20
- }
21
- /**
22
- * Content-addressed migration identity over (manifest envelope sans
23
- * contracts/hints, ops). See ADR 199 "Storage-only migration identity"
24
- * for the rationale: contracts are anchored separately by the
25
- * storage-hash bookends inside the envelope; planner hints are advisory
26
- * and must not affect identity.
27
- *
28
- * The `migrationId` field on the manifest is stripped before hashing so
29
- * the function can be used both at write time (when no id exists yet)
30
- * and at verify time (rehashing an already-attested manifest).
31
- */
32
- function computeMigrationId(manifest, ops) {
33
- const { migrationId: _migrationId, signature: _signature, fromContract: _fromContract, toContract: _toContract, hints: _hints, ...strippedMeta } = manifest;
34
- return `sha256:${sha256Hex(canonicalizeJson([canonicalizeJson(strippedMeta), canonicalizeJson(ops)].map(sha256Hex)))}`;
35
- }
36
- /**
37
- * Re-hash an on-disk migration bundle and compare against the stored
38
- * `migrationId`. Returns `{ ok: true }` when the package is internally
39
- * consistent (manifest + ops still produce the recorded id), or
40
- * `{ ok: false, reason: 'mismatch', stored, computed }` when they do
41
- * not — typically a sign of FS corruption, partial writes, or a
42
- * post-emit hand edit.
43
- */
44
- function verifyMigrationBundle(bundle) {
45
- const computed = computeMigrationId(bundle.manifest, bundle.ops);
46
- if (bundle.manifest.migrationId === computed) return {
47
- ok: true,
48
- storedMigrationId: bundle.manifest.migrationId,
49
- computedMigrationId: computed
50
- };
51
- return {
52
- ok: false,
53
- reason: "mismatch",
54
- storedMigrationId: bundle.manifest.migrationId,
55
- computedMigrationId: computed
56
- };
57
- }
58
- /** Convenience wrapper: read the package from disk then verify it. */
59
- async function verifyMigration(dir) {
60
- return verifyMigrationBundle(await readMigrationPackage(dir));
61
- }
62
-
63
- //#endregion
64
- export { verifyMigration as n, verifyMigrationBundle as r, computeMigrationId as t };
65
- //# sourceMappingURL=attestation-DtF8tEOM.mjs.map