@prisma-next/migration-tools 0.4.1 → 0.4.2

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.
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
  }