@rehearsal-db/core 0.1.0-beta.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.
- package/BENCHMARKS.md +61 -0
- package/CHANGELOG.md +59 -0
- package/COMPATIBILITY.md +22 -0
- package/LICENSE +21 -0
- package/README.md +375 -0
- package/SECURITY.md +19 -0
- package/SUPPORT.md +15 -0
- package/docs/adapters.md +23 -0
- package/docs/baselines.md +33 -0
- package/docs/commands.md +35 -0
- package/docs/configuration.md +98 -0
- package/docs/getting-started.md +247 -0
- package/docs/glossary.md +33 -0
- package/docs/production-source.md +33 -0
- package/docs/releasing.md +79 -0
- package/docs/sanitization.md +69 -0
- package/docs/security-model.md +40 -0
- package/docs/troubleshooting.md +50 -0
- package/docs/tutorial.md +96 -0
- package/package.json +77 -0
- package/scripts/lib/environment/local_supabase.mjs +197 -0
- package/scripts/lib/rehearsal/baseline_artifact.mjs +536 -0
- package/scripts/lib/rehearsal/baseline_builder.mjs +155 -0
- package/scripts/lib/rehearsal/configuration.d.mts +85 -0
- package/scripts/lib/rehearsal/configuration.mjs +559 -0
- package/scripts/lib/rehearsal/diagnostics.mjs +193 -0
- package/scripts/lib/rehearsal/migration_history.mjs +220 -0
- package/scripts/lib/rehearsal/plan.mjs +587 -0
- package/scripts/lib/rehearsal/process_environment.mjs +64 -0
- package/scripts/lib/rehearsal/runtime_restore.mjs +310 -0
- package/scripts/lib/rehearsal/sanitization_policy.mjs +169 -0
- package/scripts/lib/rehearsal/schema_snapshot.mjs +113 -0
- package/scripts/lib/rehearsal/service_environment.mjs +82 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +784 -0
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +565 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Write, verify, and atomically activate immutable sanitized Rehearsal
|
|
3
|
+
* baseline generations. Do not run directly; this module is reusable script
|
|
4
|
+
* infrastructure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
chmod,
|
|
10
|
+
lstat,
|
|
11
|
+
mkdir,
|
|
12
|
+
open,
|
|
13
|
+
readFile,
|
|
14
|
+
readdir,
|
|
15
|
+
readlink,
|
|
16
|
+
rename,
|
|
17
|
+
rm,
|
|
18
|
+
symlink,
|
|
19
|
+
} from "node:fs/promises";
|
|
20
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
21
|
+
import { createMigrationReplayReceipt } from "./migration_history.mjs";
|
|
22
|
+
|
|
23
|
+
const generationPattern = /^\d{8}T\d{6}Z-[a-f0-9]{12}$/u;
|
|
24
|
+
const buildPrefix = ".building-";
|
|
25
|
+
const generationsDirectoryName = "generations";
|
|
26
|
+
const currentLinkName = "current";
|
|
27
|
+
const migrationNamePattern = /^\d{14}_[a-z0-9_]+\.sql$/u;
|
|
28
|
+
const storageBucketPattern = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
|
|
29
|
+
const maximumAssetBytes = 50 * 1024 * 1024;
|
|
30
|
+
const maximumTotalAssetBytes = 2 * 1024 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
|
|
33
|
+
|
|
34
|
+
const assertGenerationId = (value) => {
|
|
35
|
+
if (!generationPattern.test(value)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid Rehearsal baseline generation identifier: ${value}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const assertArtifactRoot = (artifactRoot) => {
|
|
44
|
+
const resolved = resolve(artifactRoot);
|
|
45
|
+
if (basename(resolved) !== ".rehearsal" || resolved === sep) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Refusing an unsafe Rehearsal artifact root: ${artifactRoot}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const pathInside = (parent, child) => {
|
|
54
|
+
const childRelative = relative(parent, child);
|
|
55
|
+
return (
|
|
56
|
+
childRelative !== "" &&
|
|
57
|
+
!childRelative.startsWith(`..${sep}`) &&
|
|
58
|
+
childRelative !== ".." &&
|
|
59
|
+
!childRelative.startsWith(sep)
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const assertInsideRoot = (root, target) => {
|
|
64
|
+
if (!pathInside(root, target)) {
|
|
65
|
+
throw new Error(`Rehearsal artifact path escaped its root: ${target}`);
|
|
66
|
+
}
|
|
67
|
+
return target;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const writeImmutableFile = async (path, content) => {
|
|
71
|
+
const handle = await open(path, "wx", 0o600);
|
|
72
|
+
try {
|
|
73
|
+
await handle.writeFile(content);
|
|
74
|
+
await handle.sync();
|
|
75
|
+
} finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
await chmod(path, 0o400);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const assetRelativePath = (bucket, objectPath) => {
|
|
82
|
+
if (!storageBucketPattern.test(bucket)) {
|
|
83
|
+
throw new Error(`Invalid Rehearsal Storage bucket: ${bucket}.`);
|
|
84
|
+
}
|
|
85
|
+
const segments = objectPath.split("/");
|
|
86
|
+
if (
|
|
87
|
+
!objectPath ||
|
|
88
|
+
segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
89
|
+
) {
|
|
90
|
+
throw new Error(`Invalid Rehearsal Storage object path: ${objectPath}.`);
|
|
91
|
+
}
|
|
92
|
+
return join(
|
|
93
|
+
"storage",
|
|
94
|
+
encodeURIComponent(bucket),
|
|
95
|
+
...segments.map(encodeURIComponent),
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const verifyNoSymlink = async (path, label) => {
|
|
100
|
+
try {
|
|
101
|
+
const stats = await lstat(path);
|
|
102
|
+
if (stats.isSymbolicLink()) {
|
|
103
|
+
throw new Error(`${label} may not be a symbolic link.`);
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code !== "ENOENT") throw error;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const makeArtifactTreeWritable = async (path) => {
|
|
111
|
+
await chmod(path, 0o700).catch(() => undefined);
|
|
112
|
+
for (const entry of await readdir(path, { withFileTypes: true }).catch(
|
|
113
|
+
() => [],
|
|
114
|
+
)) {
|
|
115
|
+
const child = join(path, entry.name);
|
|
116
|
+
if (entry.isDirectory()) await makeArtifactTreeWritable(child);
|
|
117
|
+
else if (!entry.isSymbolicLink()) await chmod(child, 0o600);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const createBaselineGenerationId = ({ now = new Date() } = {}) => {
|
|
122
|
+
const timestamp = now
|
|
123
|
+
.toISOString()
|
|
124
|
+
.replace(/\.\d{3}Z$/u, "Z")
|
|
125
|
+
.replaceAll("-", "")
|
|
126
|
+
.replaceAll(":", "");
|
|
127
|
+
return `${timestamp}-${randomBytes(6).toString("hex")}`;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const createAndActivateBaseline = async ({
|
|
131
|
+
artifactRoot,
|
|
132
|
+
generationId = createBaselineGenerationId(),
|
|
133
|
+
records,
|
|
134
|
+
metadata,
|
|
135
|
+
expectedTables = [],
|
|
136
|
+
migrationFiles = [],
|
|
137
|
+
assets = [],
|
|
138
|
+
schemaSql = null,
|
|
139
|
+
}) => {
|
|
140
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
141
|
+
assertGenerationId(generationId);
|
|
142
|
+
if (!records?.[Symbol.asyncIterator] && !records?.[Symbol.iterator]) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
"A Rehearsal baseline requires an iterable sanitized record stream.",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
148
|
+
throw new Error("A Rehearsal baseline requires explicit metadata.");
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
!Array.isArray(expectedTables) ||
|
|
152
|
+
expectedTables.some((name) => !/^[a-z][a-z0-9_]*$/u.test(name)) ||
|
|
153
|
+
new Set(expectedTables).size !== expectedTables.length
|
|
154
|
+
) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
"Rehearsal expected table names are invalid or duplicated.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const expectedTableSet = new Set(expectedTables);
|
|
160
|
+
await verifyNoSymlink(root, "The Rehearsal artifact root");
|
|
161
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
162
|
+
const generationsRoot = assertInsideRoot(
|
|
163
|
+
root,
|
|
164
|
+
join(root, generationsDirectoryName),
|
|
165
|
+
);
|
|
166
|
+
await verifyNoSymlink(generationsRoot, "The Rehearsal generations directory");
|
|
167
|
+
await mkdir(generationsRoot, { recursive: true, mode: 0o700 });
|
|
168
|
+
|
|
169
|
+
const finalDirectory = assertInsideRoot(
|
|
170
|
+
root,
|
|
171
|
+
join(generationsRoot, generationId),
|
|
172
|
+
);
|
|
173
|
+
const buildDirectory = assertInsideRoot(
|
|
174
|
+
root,
|
|
175
|
+
join(
|
|
176
|
+
root,
|
|
177
|
+
`${buildPrefix}${generationId}-${randomBytes(4).toString("hex")}`,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
const dataPath = join(buildDirectory, "sanitized-data.ndjson");
|
|
181
|
+
let dataHandle;
|
|
182
|
+
let finalCreated = false;
|
|
183
|
+
let rowCount = 0;
|
|
184
|
+
const tableCounts = new Map(expectedTables.map((name) => [name, 0]));
|
|
185
|
+
const dataHash = createHash("sha256");
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await mkdir(buildDirectory, { mode: 0o700 });
|
|
189
|
+
dataHandle = await open(dataPath, "wx", 0o600);
|
|
190
|
+
for await (const record of records) {
|
|
191
|
+
if (
|
|
192
|
+
!record ||
|
|
193
|
+
typeof record.table !== "string" ||
|
|
194
|
+
!record.row ||
|
|
195
|
+
typeof record.row !== "object" ||
|
|
196
|
+
Array.isArray(record.row)
|
|
197
|
+
) {
|
|
198
|
+
throw new Error("A sanitized baseline record has an invalid shape.");
|
|
199
|
+
}
|
|
200
|
+
if (expectedTableSet.size && !expectedTableSet.has(record.table)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`The baseline stream exposed unexpected table ${record.table}.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const line = `${JSON.stringify(record)}\n`;
|
|
206
|
+
dataHash.update(line);
|
|
207
|
+
await dataHandle.write(line, null, "utf8");
|
|
208
|
+
rowCount += 1;
|
|
209
|
+
tableCounts.set(record.table, (tableCounts.get(record.table) ?? 0) + 1);
|
|
210
|
+
}
|
|
211
|
+
await dataHandle.sync();
|
|
212
|
+
await dataHandle.close();
|
|
213
|
+
dataHandle = null;
|
|
214
|
+
await chmod(dataPath, 0o400);
|
|
215
|
+
let schemaReceipt;
|
|
216
|
+
if (schemaSql !== null) {
|
|
217
|
+
if (typeof schemaSql !== "string" || schemaSql.length === 0) {
|
|
218
|
+
throw new Error("A Rehearsal production schema snapshot is invalid.");
|
|
219
|
+
}
|
|
220
|
+
const schemaPath = join(buildDirectory, "production-schema.sql");
|
|
221
|
+
await writeImmutableFile(schemaPath, schemaSql);
|
|
222
|
+
schemaReceipt = {
|
|
223
|
+
sha256: createHash("sha256").update(schemaSql).digest("hex"),
|
|
224
|
+
bytes: Buffer.byteLength(schemaSql),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const resolvedMigrationFiles =
|
|
229
|
+
typeof migrationFiles === "function"
|
|
230
|
+
? await migrationFiles()
|
|
231
|
+
: await migrationFiles;
|
|
232
|
+
if (!Array.isArray(resolvedMigrationFiles)) {
|
|
233
|
+
throw new Error("Rehearsal migration source must be an array.");
|
|
234
|
+
}
|
|
235
|
+
if (!/^[a-f0-9]{64}$/u.test(metadata.migrationHistorySha256 ?? "")) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
"A Rehearsal baseline requires a migration-history SHA-256.",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (metadata.sourceMigrationHistory) {
|
|
241
|
+
const migrationReceipt = createMigrationReplayReceipt({
|
|
242
|
+
files: resolvedMigrationFiles,
|
|
243
|
+
ledger: metadata.sourceMigrationHistory,
|
|
244
|
+
});
|
|
245
|
+
if (migrationReceipt.historySha256 !== metadata.migrationHistorySha256) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
"The Rehearsal baseline migration-history SHA-256 is invalid.",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const migrationDirectory = join(buildDirectory, "migrations");
|
|
252
|
+
await mkdir(migrationDirectory, { mode: 0o700 });
|
|
253
|
+
const migrationManifest = {};
|
|
254
|
+
for (const migration of resolvedMigrationFiles) {
|
|
255
|
+
if (
|
|
256
|
+
!migration ||
|
|
257
|
+
!migrationNamePattern.test(migration.filename) ||
|
|
258
|
+
typeof migration.content !== "string" ||
|
|
259
|
+
Object.hasOwn(migrationManifest, migration.filename)
|
|
260
|
+
) {
|
|
261
|
+
throw new Error("A Rehearsal migration bundle entry is invalid.");
|
|
262
|
+
}
|
|
263
|
+
const contentHash = createHash("sha256")
|
|
264
|
+
.update(migration.content)
|
|
265
|
+
.digest("hex");
|
|
266
|
+
if (migration.fileSha256 && migration.fileSha256 !== contentHash) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Rehearsal migration source checksum mismatch for ${migration.filename}.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
await writeImmutableFile(
|
|
272
|
+
join(migrationDirectory, migration.filename),
|
|
273
|
+
migration.content,
|
|
274
|
+
);
|
|
275
|
+
migrationManifest[migration.filename] = { sha256: contentHash };
|
|
276
|
+
}
|
|
277
|
+
await chmod(migrationDirectory, 0o500);
|
|
278
|
+
|
|
279
|
+
const assetManifest = {};
|
|
280
|
+
let totalAssetBytes = 0;
|
|
281
|
+
for await (const asset of assets) {
|
|
282
|
+
if (
|
|
283
|
+
!asset ||
|
|
284
|
+
typeof asset.bucket !== "string" ||
|
|
285
|
+
typeof asset.objectPath !== "string" ||
|
|
286
|
+
(!asset.content?.[Symbol.asyncIterator] &&
|
|
287
|
+
!asset.content?.[Symbol.iterator] &&
|
|
288
|
+
!Buffer.isBuffer(asset.content))
|
|
289
|
+
) {
|
|
290
|
+
throw new Error("A Rehearsal Storage asset has an invalid shape.");
|
|
291
|
+
}
|
|
292
|
+
const relativePath = assetRelativePath(asset.bucket, asset.objectPath);
|
|
293
|
+
if (Object.hasOwn(assetManifest, relativePath)) {
|
|
294
|
+
throw new Error(`Duplicate Rehearsal Storage asset: ${relativePath}.`);
|
|
295
|
+
}
|
|
296
|
+
const target = assertInsideRoot(
|
|
297
|
+
buildDirectory,
|
|
298
|
+
join(buildDirectory, relativePath),
|
|
299
|
+
);
|
|
300
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
301
|
+
const handle = await open(target, "wx", 0o600);
|
|
302
|
+
const hash = createHash("sha256");
|
|
303
|
+
let bytes = 0;
|
|
304
|
+
try {
|
|
305
|
+
const chunks = Buffer.isBuffer(asset.content)
|
|
306
|
+
? [asset.content]
|
|
307
|
+
: asset.content;
|
|
308
|
+
for await (const chunk of chunks) {
|
|
309
|
+
const buffer = Buffer.from(chunk);
|
|
310
|
+
bytes += buffer.length;
|
|
311
|
+
totalAssetBytes += buffer.length;
|
|
312
|
+
if (
|
|
313
|
+
bytes > maximumAssetBytes ||
|
|
314
|
+
totalAssetBytes > maximumTotalAssetBytes
|
|
315
|
+
) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
"The Rehearsal Storage asset boundary was exceeded.",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
hash.update(buffer);
|
|
321
|
+
await handle.write(buffer);
|
|
322
|
+
}
|
|
323
|
+
await handle.sync();
|
|
324
|
+
} finally {
|
|
325
|
+
await handle.close();
|
|
326
|
+
}
|
|
327
|
+
await chmod(target, 0o400);
|
|
328
|
+
assetManifest[relativePath] = {
|
|
329
|
+
sha256: hash.digest("hex"),
|
|
330
|
+
bytes,
|
|
331
|
+
bucket: asset.bucket,
|
|
332
|
+
objectPath: asset.objectPath,
|
|
333
|
+
contentType: asset.contentType ?? "application/octet-stream",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const manifest = {
|
|
338
|
+
...metadata,
|
|
339
|
+
formatVersion: schemaReceipt ? 2 : 1,
|
|
340
|
+
generationId,
|
|
341
|
+
rowCount,
|
|
342
|
+
tableCounts: Object.fromEntries([...tableCounts].sort()),
|
|
343
|
+
files: {
|
|
344
|
+
"sanitized-data.ndjson": {
|
|
345
|
+
sha256: dataHash.digest("hex"),
|
|
346
|
+
rows: rowCount,
|
|
347
|
+
},
|
|
348
|
+
...(schemaReceipt ? { "production-schema.sql": schemaReceipt } : {}),
|
|
349
|
+
},
|
|
350
|
+
storageAssets: assetManifest,
|
|
351
|
+
migrations: migrationManifest,
|
|
352
|
+
};
|
|
353
|
+
await writeImmutableFile(
|
|
354
|
+
join(buildDirectory, "baseline-manifest.json"),
|
|
355
|
+
canonicalJson(manifest),
|
|
356
|
+
);
|
|
357
|
+
await rename(buildDirectory, finalDirectory);
|
|
358
|
+
finalCreated = true;
|
|
359
|
+
await chmod(finalDirectory, 0o500);
|
|
360
|
+
|
|
361
|
+
const temporaryLink = assertInsideRoot(
|
|
362
|
+
root,
|
|
363
|
+
join(root, `.current-${randomBytes(4).toString("hex")}`),
|
|
364
|
+
);
|
|
365
|
+
await symlink(join(generationsDirectoryName, generationId), temporaryLink);
|
|
366
|
+
await rename(temporaryLink, join(root, currentLinkName));
|
|
367
|
+
return manifest;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
if (dataHandle) await dataHandle.close().catch(() => undefined);
|
|
370
|
+
await chmod(buildDirectory, 0o700).catch(() => undefined);
|
|
371
|
+
await rm(buildDirectory, { recursive: true, force: true });
|
|
372
|
+
if (finalCreated) {
|
|
373
|
+
await chmod(finalDirectory, 0o700).catch(() => undefined);
|
|
374
|
+
await rm(finalDirectory, { recursive: true, force: true });
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
export const resolveActiveBaselinePaths = async ({ artifactRoot }) => {
|
|
381
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
382
|
+
const currentTarget = await readlink(join(root, currentLinkName));
|
|
383
|
+
const generationDirectory = resolve(root, currentTarget);
|
|
384
|
+
assertInsideRoot(root, generationDirectory);
|
|
385
|
+
if (dirname(generationDirectory) !== join(root, generationsDirectoryName)) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
"The active Rehearsal baseline points outside generations.",
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
artifactRoot: root,
|
|
392
|
+
generationDirectory,
|
|
393
|
+
dataPath: join(generationDirectory, "sanitized-data.ndjson"),
|
|
394
|
+
manifestPath: join(generationDirectory, "baseline-manifest.json"),
|
|
395
|
+
migrationsDirectory: join(generationDirectory, "migrations"),
|
|
396
|
+
schemaPath: join(generationDirectory, "production-schema.sql"),
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
export const verifyActiveBaseline = async ({ artifactRoot }) => {
|
|
401
|
+
const paths = await resolveActiveBaselinePaths({ artifactRoot });
|
|
402
|
+
const { generationDirectory } = paths;
|
|
403
|
+
const manifest = JSON.parse(await readFile(paths.manifestPath, "utf8"));
|
|
404
|
+
assertGenerationId(manifest.generationId);
|
|
405
|
+
if (basename(generationDirectory) !== manifest.generationId) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
"The active Rehearsal baseline identifier does not match its path.",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const data = await readFile(paths.dataPath);
|
|
411
|
+
const actualHash = createHash("sha256").update(data).digest("hex");
|
|
412
|
+
if (actualHash !== manifest.files?.["sanitized-data.ndjson"]?.sha256) {
|
|
413
|
+
throw new Error("The active Rehearsal baseline data checksum is invalid.");
|
|
414
|
+
}
|
|
415
|
+
if (manifest.files?.["production-schema.sql"]) {
|
|
416
|
+
const schema = await readFile(paths.schemaPath);
|
|
417
|
+
const receipt = manifest.files["production-schema.sql"];
|
|
418
|
+
if (
|
|
419
|
+
schema.length !== receipt.bytes ||
|
|
420
|
+
createHash("sha256").update(schema).digest("hex") !== receipt.sha256
|
|
421
|
+
) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"The active Rehearsal production schema checksum is invalid.",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const migrationDirectory = paths.migrationsDirectory;
|
|
428
|
+
const expectedMigrationNames = Object.keys(manifest.migrations ?? {}).sort();
|
|
429
|
+
const actualMigrationNames = (await readdir(migrationDirectory)).sort();
|
|
430
|
+
if (expectedMigrationNames.join("\0") !== actualMigrationNames.join("\0")) {
|
|
431
|
+
throw new Error("The active Rehearsal migration bundle is not exact.");
|
|
432
|
+
}
|
|
433
|
+
for (const filename of expectedMigrationNames) {
|
|
434
|
+
if (!migrationNamePattern.test(filename)) {
|
|
435
|
+
throw new Error("The active Rehearsal migration manifest is invalid.");
|
|
436
|
+
}
|
|
437
|
+
const migrationHash = createHash("sha256")
|
|
438
|
+
.update(await readFile(join(migrationDirectory, filename)))
|
|
439
|
+
.digest("hex");
|
|
440
|
+
if (migrationHash !== manifest.migrations[filename]?.sha256) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`The active Rehearsal migration checksum is invalid for ${filename}.`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
for (const [relativePath, receipt] of Object.entries(
|
|
447
|
+
manifest.storageAssets ?? {},
|
|
448
|
+
)) {
|
|
449
|
+
if (
|
|
450
|
+
relativePath !== assetRelativePath(receipt.bucket, receipt.objectPath)
|
|
451
|
+
) {
|
|
452
|
+
throw new Error("The active Rehearsal Storage manifest path is invalid.");
|
|
453
|
+
}
|
|
454
|
+
const assetPath = assertInsideRoot(
|
|
455
|
+
generationDirectory,
|
|
456
|
+
join(generationDirectory, relativePath),
|
|
457
|
+
);
|
|
458
|
+
const asset = await readFile(assetPath);
|
|
459
|
+
if (
|
|
460
|
+
asset.length !== receipt.bytes ||
|
|
461
|
+
createHash("sha256").update(asset).digest("hex") !== receipt.sha256
|
|
462
|
+
) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`The active Rehearsal Storage checksum is invalid for ${relativePath}.`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return manifest;
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
export const listIncompleteBaselineBuilds = async ({ artifactRoot }) => {
|
|
472
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
473
|
+
try {
|
|
474
|
+
return (await readdir(root)).filter((name) => name.startsWith(buildPrefix));
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error?.code === "ENOENT") return [];
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
export const pruneBaselineGenerations = async ({
|
|
482
|
+
artifactRoot,
|
|
483
|
+
retain = 2,
|
|
484
|
+
}) => {
|
|
485
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
486
|
+
if (!Number.isSafeInteger(retain) || retain < 1) {
|
|
487
|
+
throw new Error("Rehearsal baseline retention must be a positive integer.");
|
|
488
|
+
}
|
|
489
|
+
const active = await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
490
|
+
const generationsRoot = join(root, generationsDirectoryName);
|
|
491
|
+
await verifyNoSymlink(generationsRoot, "The Rehearsal generations directory");
|
|
492
|
+
const entries = await readdir(generationsRoot, { withFileTypes: true });
|
|
493
|
+
for (const entry of entries) {
|
|
494
|
+
if (
|
|
495
|
+
!entry.isDirectory() ||
|
|
496
|
+
entry.isSymbolicLink() ||
|
|
497
|
+
!generationPattern.test(entry.name)
|
|
498
|
+
) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`Refusing to prune an unexpected Rehearsal generation entry: ${entry.name}`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const activeName = basename(active.generationDirectory);
|
|
505
|
+
const retained = new Set(
|
|
506
|
+
[
|
|
507
|
+
activeName,
|
|
508
|
+
...entries
|
|
509
|
+
.map((entry) => entry.name)
|
|
510
|
+
.sort()
|
|
511
|
+
.reverse(),
|
|
512
|
+
]
|
|
513
|
+
.filter((name, index, values) => values.indexOf(name) === index)
|
|
514
|
+
.slice(0, retain),
|
|
515
|
+
);
|
|
516
|
+
const removed = entries
|
|
517
|
+
.map((entry) => entry.name)
|
|
518
|
+
.filter((name) => !retained.has(name));
|
|
519
|
+
for (const name of removed) {
|
|
520
|
+
const target = assertInsideRoot(
|
|
521
|
+
generationsRoot,
|
|
522
|
+
join(generationsRoot, name),
|
|
523
|
+
);
|
|
524
|
+
await verifyNoSymlink(target, "A Rehearsal baseline generation");
|
|
525
|
+
await makeArtifactTreeWritable(target);
|
|
526
|
+
await rm(target, { recursive: true, force: true });
|
|
527
|
+
}
|
|
528
|
+
return { retained: [...retained], removed };
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
export const removeBaselineArtifactRoot = async ({ artifactRoot }) => {
|
|
532
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
533
|
+
await verifyNoSymlink(root, "The Rehearsal artifact root");
|
|
534
|
+
await makeArtifactTreeWritable(root);
|
|
535
|
+
await rm(root, { recursive: true, force: true });
|
|
536
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a synthetic baseline from explicit project-owned local files. This module does
|
|
3
|
+
* not connect to a hosted source or decide which values are safe.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { createReadStream } from "node:fs";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
import {
|
|
13
|
+
createAndActivateBaseline,
|
|
14
|
+
createBaselineGenerationId,
|
|
15
|
+
} from "./baseline_artifact.mjs";
|
|
16
|
+
import { loadRehearsalConfig } from "./configuration.mjs";
|
|
17
|
+
import {
|
|
18
|
+
buildMigrationLedgerInventory,
|
|
19
|
+
createMigrationReplayReceipt,
|
|
20
|
+
readMigrationSourceBundle,
|
|
21
|
+
} from "./migration_history.mjs";
|
|
22
|
+
|
|
23
|
+
const resolveProjectInput = (projectRoot, value, label) => {
|
|
24
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
25
|
+
throw new Error(`${label} is required.`);
|
|
26
|
+
}
|
|
27
|
+
const path = resolve(projectRoot, value);
|
|
28
|
+
const owned = relative(projectRoot, path);
|
|
29
|
+
if (
|
|
30
|
+
owned === "" ||
|
|
31
|
+
owned === ".." ||
|
|
32
|
+
owned.startsWith(`..${sep}`) ||
|
|
33
|
+
isAbsolute(owned)
|
|
34
|
+
) {
|
|
35
|
+
throw new Error(`${label} must be a file inside the project root.`);
|
|
36
|
+
}
|
|
37
|
+
return path;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const readRecords = async function* (path) {
|
|
41
|
+
const lines = createInterface({
|
|
42
|
+
input: createReadStream(path, { encoding: "utf8" }),
|
|
43
|
+
crlfDelay: Infinity,
|
|
44
|
+
});
|
|
45
|
+
for await (const line of lines) {
|
|
46
|
+
if (line.trim() === "") continue;
|
|
47
|
+
let record;
|
|
48
|
+
try {
|
|
49
|
+
record = JSON.parse(line);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error("Synthetic baseline input contains invalid NDJSON.", {
|
|
52
|
+
cause: error,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
yield record;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const createSyntheticBaselineFromFiles = async ({
|
|
60
|
+
projectRoot = process.cwd(),
|
|
61
|
+
configPath,
|
|
62
|
+
recordsPath,
|
|
63
|
+
ledgerPath,
|
|
64
|
+
assetsPath,
|
|
65
|
+
}) => {
|
|
66
|
+
const loaded = await loadRehearsalConfig({ projectRoot, configPath });
|
|
67
|
+
const records = resolveProjectInput(
|
|
68
|
+
loaded.projectRoot,
|
|
69
|
+
recordsPath,
|
|
70
|
+
"Synthetic records path",
|
|
71
|
+
);
|
|
72
|
+
const ledger = resolveProjectInput(
|
|
73
|
+
loaded.projectRoot,
|
|
74
|
+
ledgerPath,
|
|
75
|
+
"Migration ledger path",
|
|
76
|
+
);
|
|
77
|
+
const [policyBytes, ledgerBytes] = await Promise.all([
|
|
78
|
+
readFile(loaded.paths.sanitizationPolicy),
|
|
79
|
+
readFile(ledger),
|
|
80
|
+
]);
|
|
81
|
+
const assets = assetsPath
|
|
82
|
+
? await Promise.all(
|
|
83
|
+
JSON.parse(
|
|
84
|
+
await readFile(
|
|
85
|
+
resolveProjectInput(
|
|
86
|
+
loaded.projectRoot,
|
|
87
|
+
assetsPath,
|
|
88
|
+
"Synthetic assets manifest path",
|
|
89
|
+
),
|
|
90
|
+
"utf8",
|
|
91
|
+
),
|
|
92
|
+
).map(async (asset) => ({
|
|
93
|
+
bucket: asset.bucket,
|
|
94
|
+
objectPath: asset.objectPath,
|
|
95
|
+
contentType: asset.contentType,
|
|
96
|
+
content: await readFile(
|
|
97
|
+
resolveProjectInput(
|
|
98
|
+
loaded.projectRoot,
|
|
99
|
+
asset.file,
|
|
100
|
+
"Synthetic asset file path",
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
})),
|
|
104
|
+
)
|
|
105
|
+
: [];
|
|
106
|
+
const policy = JSON.parse(policyBytes.toString("utf8"));
|
|
107
|
+
if (!Array.isArray(policy.tables)) {
|
|
108
|
+
throw new Error("The configured sanitization policy does not list tables.");
|
|
109
|
+
}
|
|
110
|
+
const expectedTables = policy.tables
|
|
111
|
+
.filter((table) => table.sourceRows !== "EXCLUDE")
|
|
112
|
+
.map((table) => table.name);
|
|
113
|
+
if (expectedTables.length === 0) {
|
|
114
|
+
throw new Error("The sanitization policy includes no restorable tables.");
|
|
115
|
+
}
|
|
116
|
+
const sourceMigrationHistory = buildMigrationLedgerInventory(
|
|
117
|
+
JSON.parse(ledgerBytes.toString("utf8")),
|
|
118
|
+
"Synthetic migration ledger",
|
|
119
|
+
);
|
|
120
|
+
const migrationFiles = await readMigrationSourceBundle({
|
|
121
|
+
directory: new URL(
|
|
122
|
+
"./",
|
|
123
|
+
pathToFileURL(`${loaded.paths.migrationDirectory}${sep}`),
|
|
124
|
+
),
|
|
125
|
+
sourceLedger: sourceMigrationHistory,
|
|
126
|
+
});
|
|
127
|
+
const migrationReceipt = createMigrationReplayReceipt({
|
|
128
|
+
files: migrationFiles,
|
|
129
|
+
ledger: sourceMigrationHistory,
|
|
130
|
+
});
|
|
131
|
+
const generationId = createBaselineGenerationId();
|
|
132
|
+
const baseline = await createAndActivateBaseline({
|
|
133
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
134
|
+
generationId,
|
|
135
|
+
records: readRecords(records),
|
|
136
|
+
metadata: {
|
|
137
|
+
migrationCutoff: sourceMigrationHistory.at(-1).version,
|
|
138
|
+
migrationHistorySha256: migrationReceipt.historySha256,
|
|
139
|
+
sanitizationPolicySha256: createHash("sha256")
|
|
140
|
+
.update(policyBytes)
|
|
141
|
+
.digest("hex"),
|
|
142
|
+
sourceMigrationHistory,
|
|
143
|
+
},
|
|
144
|
+
expectedTables,
|
|
145
|
+
migrationFiles,
|
|
146
|
+
assets,
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
generationId,
|
|
150
|
+
migrationCutoff: baseline.migrationCutoff,
|
|
151
|
+
migrationCount: baseline.migrationCount,
|
|
152
|
+
rowCount: baseline.rowCount,
|
|
153
|
+
tableCount: baseline.tableCount,
|
|
154
|
+
};
|
|
155
|
+
};
|