@rehearsal-db/core 0.1.0-beta.1 → 0.1.0-beta.10

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 (68) hide show
  1. package/CHANGELOG.md +244 -1
  2. package/COMPATIBILITY.md +17 -0
  3. package/README.md +139 -346
  4. package/SECURITY.md +2 -1
  5. package/SUPPORT.md +5 -0
  6. package/docs/README.md +29 -0
  7. package/docs/adapters.md +20 -6
  8. package/docs/architecture.md +58 -0
  9. package/docs/baselines.md +102 -22
  10. package/docs/commands.md +196 -33
  11. package/docs/configuration.md +258 -13
  12. package/docs/getting-started.md +106 -182
  13. package/docs/glossary.md +11 -8
  14. package/docs/production-source.md +176 -33
  15. package/docs/releasing.md +28 -38
  16. package/docs/roadmap.md +41 -0
  17. package/docs/runtime-policies.md +193 -0
  18. package/docs/sanitization.md +43 -8
  19. package/docs/security-model.md +34 -9
  20. package/docs/standalone-workflow.md +107 -0
  21. package/docs/troubleshooting.md +102 -2
  22. package/docs/tutorial.md +70 -59
  23. package/package.json +30 -22
  24. package/scripts/runtime/manage_database.mjs +18 -0
  25. package/src/README.md +17 -0
  26. package/src/application/session.mjs +435 -0
  27. package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +142 -17
  28. package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +19 -10
  29. package/src/baseline/input_discovery.mjs +155 -0
  30. package/src/baseline/policy_review.mjs +92 -0
  31. package/src/baseline/preparation.mjs +323 -0
  32. package/src/baseline/privacy_engine.mjs +413 -0
  33. package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +133 -3
  34. package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
  35. package/src/cli/arguments.mjs +123 -0
  36. package/src/cli/guided.mjs +812 -0
  37. package/src/cli/rehearsal.mjs +997 -0
  38. package/src/cli/renderers.mjs +584 -0
  39. package/src/cli/runtime_commands.mjs +624 -0
  40. package/src/cli/source_commands.mjs +326 -0
  41. package/src/cli/terminal.mjs +275 -0
  42. package/src/identity/claim.mjs +975 -0
  43. package/src/identity/storage.mjs +165 -0
  44. package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +72 -4
  45. package/src/project/configuration.mjs +1199 -0
  46. package/src/project/setup.mjs +379 -0
  47. package/src/project/support_report.mjs +105 -0
  48. package/src/runtime/cleanup.mjs +381 -0
  49. package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +118 -20
  50. package/src/runtime/policy.mjs +438 -0
  51. package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +46 -27
  52. package/src/runtime/topology.mjs +177 -0
  53. package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +10 -3
  54. package/src/shared/human_output.mjs +4 -0
  55. package/src/shared/operation_guard.mjs +162 -0
  56. package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
  57. package/src/source/access.mjs +578 -0
  58. package/src/source/asset_transfer.mjs +177 -0
  59. package/src/source/baseline.mjs +446 -0
  60. package/src/source/postgresql_access.mjs +480 -0
  61. package/src/targets/postgresql.mjs +808 -0
  62. package/{scripts/operations/database/manage_rehearsal_database.mjs → src/targets/supabase.mjs} +66 -20
  63. package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +60 -28
  64. package/src/targets/target.mjs +66 -0
  65. package/scripts/lib/rehearsal/configuration.mjs +0 -559
  66. package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -565
  67. /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
  68. /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
@@ -0,0 +1,435 @@
1
+ /** Launch ordinary application commands against verified local runtime files. */
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { parseEnv } from "node:util";
6
+ import {
7
+ createCleanProcessEnvironment,
8
+ isLoopbackUrl,
9
+ } from "../shared/process_environment.mjs";
10
+
11
+ const ENV_KEY = /^[A-Z][A-Z0-9_]*$/u;
12
+ const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
13
+ const SESSION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGTSTP"];
14
+
15
+ const parseCommand = (source) => {
16
+ if (typeof source !== "string" || !source.trim())
17
+ throw new Error("Application command is empty.");
18
+ const values = [];
19
+ let value = "";
20
+ let quote = null;
21
+ for (const character of source) {
22
+ if (quote) {
23
+ if (character === quote) quote = null;
24
+ else value += character;
25
+ } else if (character === '"' || character === "'") quote = character;
26
+ else if (/\s/u.test(character)) {
27
+ if (value) values.push(value);
28
+ value = "";
29
+ } else value += character;
30
+ }
31
+ if (quote)
32
+ throw new Error("Application command contains an unmatched quote.");
33
+ if (value) values.push(value);
34
+ return values;
35
+ };
36
+
37
+ const readOwnedEnvironment = async (path) => {
38
+ const details = await stat(path);
39
+ if (!details.isFile() || (details.mode & 0o077) !== 0) {
40
+ throw new Error(
41
+ "Runtime environment file must be an owner-only regular file.",
42
+ );
43
+ }
44
+ return parseEnv(await readFile(path, "utf8"));
45
+ };
46
+
47
+ const assertLocalValue = (key, value) => {
48
+ if (/(_URL|_URI|_ENDPOINT)$/u.test(key)) {
49
+ let parsed;
50
+ try {
51
+ parsed = new URL(value);
52
+ } catch {
53
+ throw new Error(`Mapped runtime variable ${key} is not a URL.`);
54
+ }
55
+ if (
56
+ !isLoopbackUrl(value) &&
57
+ !["postgres:", "postgresql:"].includes(parsed.protocol)
58
+ ) {
59
+ throw new Error(`Mapped runtime variable ${key} is not local.`);
60
+ }
61
+ if (
62
+ ["postgres:", "postgresql:"].includes(parsed.protocol) &&
63
+ !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname)
64
+ ) {
65
+ throw new Error(`Mapped runtime variable ${key} is not local.`);
66
+ }
67
+ }
68
+ };
69
+
70
+ export const buildApplicationEnvironment = async ({ files, mappings }) => {
71
+ if (!files?.primary)
72
+ throw new Error("Primary runtime environment file is required.");
73
+ const environments = {};
74
+ for (const [name, path] of Object.entries(files)) {
75
+ environments[name] = await readOwnedEnvironment(path);
76
+ }
77
+ if (!mappings || typeof mappings !== "object" || Array.isArray(mappings)) {
78
+ throw new Error("Application environment mappings must be an object.");
79
+ }
80
+ const result = {};
81
+ for (const [destination, reference] of Object.entries(mappings)) {
82
+ if (!ENV_KEY.test(destination) || typeof reference !== "string") {
83
+ throw new Error("Application environment mapping is invalid.");
84
+ }
85
+ const separator = reference.indexOf(":");
86
+ if (separator < 1)
87
+ throw new Error(
88
+ `Application mapping ${destination} has an invalid reference.`,
89
+ );
90
+ const target = reference.slice(0, separator);
91
+ const source = reference.slice(separator + 1);
92
+ if (!ENV_KEY.test(source) || !environments[target]?.[source]) {
93
+ throw new Error(
94
+ `Application mapping ${destination} references a missing runtime value.`,
95
+ );
96
+ }
97
+ assertLocalValue(source, environments[target][source]);
98
+ result[destination] = environments[target][source];
99
+ }
100
+ return result;
101
+ };
102
+
103
+ const boundedAppend = (current, chunk) =>
104
+ `${current}${chunk}`.slice(-MAX_DIAGNOSTIC_BYTES);
105
+
106
+ const childHasExited = (child) =>
107
+ child.exitCode !== null || child.signalCode !== null;
108
+
109
+ const waitForChildExit = (child) => {
110
+ if (childHasExited(child)) {
111
+ return Promise.resolve({
112
+ status: child.exitCode,
113
+ signal: child.signalCode,
114
+ });
115
+ }
116
+ return new Promise((resolve) => {
117
+ child.once("exit", (status, signal) => resolve({ status, signal }));
118
+ });
119
+ };
120
+
121
+ export const summarizeProjectCommandFailure = ({
122
+ error,
123
+ status,
124
+ signal,
125
+ stdout = "",
126
+ stderr = "",
127
+ stdoutByteCount,
128
+ stderrByteCount,
129
+ }) => {
130
+ const stdoutBytes = stdoutByteCount ?? Buffer.byteLength(String(stdout));
131
+ const stderrBytes = stderrByteCount ?? Buffer.byteLength(String(stderr));
132
+ if (error) {
133
+ const code = /^[A-Z0-9_]+$/u.test(error.code ?? "")
134
+ ? ` (${error.code})`
135
+ : "";
136
+ return `command could not start${code}; output withheld`;
137
+ }
138
+ const outcome = signal
139
+ ? `signal ${signal}`
140
+ : `exit ${Number.isInteger(status) ? status : "unknown"}`;
141
+ return `${outcome}; stdout ${stdoutBytes} bytes; stderr ${stderrBytes} bytes; output withheld`;
142
+ };
143
+
144
+ const waitForReadiness = async ({ readiness, fetchImplementation, child }) => {
145
+ if (!readiness || !isLoopbackUrl(readiness.url)) {
146
+ throw new Error("Application readiness must use a loopback URL.");
147
+ }
148
+ const expected = readiness.expectedStatus ?? 200;
149
+ const deadline = Date.now() + (readiness.timeoutSeconds ?? 30) * 1_000;
150
+ let lastStatus = null;
151
+ while (Date.now() < deadline) {
152
+ if (childHasExited(child))
153
+ throw new Error("Application exited before becoming ready.");
154
+ try {
155
+ const response = await fetchImplementation(readiness.url, {
156
+ redirect: "manual",
157
+ signal: AbortSignal.timeout(2_000),
158
+ });
159
+ lastStatus = response.status;
160
+ if (response.status === expected)
161
+ return { url: readiness.url, status: response.status };
162
+ } catch {
163
+ // A bounded retry is expected while the local process starts.
164
+ }
165
+ await new Promise((resolve) => setTimeout(resolve, 200));
166
+ }
167
+ throw new Error(
168
+ `Application readiness timed out${lastStatus === null ? "" : ` after HTTP ${lastStatus}`}.`,
169
+ );
170
+ };
171
+
172
+ const stopOwnedChild = async (child) => {
173
+ if (childHasExited(child)) return;
174
+ const exit = waitForChildExit(child);
175
+ const kill = (signal) => {
176
+ try {
177
+ if (process.platform === "win32") child.kill(signal);
178
+ else process.kill(-child.pid, signal);
179
+ } catch (error) {
180
+ if (error?.code !== "ESRCH") throw error;
181
+ }
182
+ };
183
+ kill("SIGTERM");
184
+ await Promise.race([
185
+ exit,
186
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
187
+ ]);
188
+ if (!childHasExited(child)) {
189
+ kill("SIGKILL");
190
+ await exit;
191
+ }
192
+ };
193
+
194
+ export const startApplicationSession = async ({
195
+ command,
196
+ cwd,
197
+ files,
198
+ mappings,
199
+ readiness,
200
+ inheritedEnvironment = process.env,
201
+ spawnImplementation = spawn,
202
+ fetchImplementation = fetch,
203
+ signalTarget = process,
204
+ }) => {
205
+ const mapped = await buildApplicationEnvironment({ files, mappings });
206
+ const [program, ...args] = parseCommand(command);
207
+ const child = spawnImplementation(program, args, {
208
+ cwd,
209
+ env: createCleanProcessEnvironment({
210
+ inheritedEnvironment,
211
+ overrides: mapped,
212
+ }),
213
+ detached: process.platform !== "win32",
214
+ stdio: ["ignore", "pipe", "pipe"],
215
+ });
216
+ let stdout = "";
217
+ let stderr = "";
218
+ child.stdout?.on("data", (chunk) => {
219
+ stdout = boundedAppend(stdout, chunk.toString("utf8"));
220
+ });
221
+ child.stderr?.on("data", (chunk) => {
222
+ stderr = boundedAppend(stderr, chunk.toString("utf8"));
223
+ });
224
+ await new Promise((resolve, reject) => {
225
+ child.once("spawn", resolve);
226
+ child.once("error", reject);
227
+ });
228
+ let interruptStartup;
229
+ const interruption = new Promise((_, reject) => {
230
+ interruptStartup = (signal) =>
231
+ reject(new Error(`Application startup was interrupted by ${signal}.`));
232
+ });
233
+ const startupHandlers = new Map(
234
+ SESSION_SIGNALS.map((signal) => [signal, () => interruptStartup(signal)]),
235
+ );
236
+ for (const [signal, handler] of startupHandlers) {
237
+ signalTarget.prependListener(signal, handler);
238
+ }
239
+ try {
240
+ const ready = await Promise.race([
241
+ waitForReadiness({
242
+ readiness,
243
+ fetchImplementation,
244
+ child,
245
+ }),
246
+ interruption,
247
+ ]);
248
+ return Object.freeze({
249
+ pid: child.pid,
250
+ ready,
251
+ stop: () => stopOwnedChild(child),
252
+ wait: () => waitForChildExit(child),
253
+ diagnostics: () => ({
254
+ stdoutBytes: Buffer.byteLength(stdout),
255
+ stderrBytes: Buffer.byteLength(stderr),
256
+ }),
257
+ });
258
+ } catch (error) {
259
+ await stopOwnedChild(child);
260
+ throw error;
261
+ } finally {
262
+ for (const [signal, handler] of startupHandlers) {
263
+ signalTarget.removeListener(signal, handler);
264
+ }
265
+ }
266
+ };
267
+
268
+ /**
269
+ * Keep a ready application session attached until the user interrupts it.
270
+ * The child process group is always stopped before this function returns.
271
+ */
272
+ export const holdApplicationSession = async ({
273
+ session,
274
+ signalTarget = process,
275
+ input = process.stdin,
276
+ onReady = () => undefined,
277
+ }) => {
278
+ if (
279
+ !session ||
280
+ typeof session.wait !== "function" ||
281
+ typeof session.stop !== "function"
282
+ ) {
283
+ throw new Error("A started application session is required.");
284
+ }
285
+ let finishInterruption;
286
+ const interruption = new Promise((resolve) => {
287
+ finishInterruption = resolve;
288
+ });
289
+ const handlers = new Map(
290
+ SESSION_SIGNALS.map((signal) => [
291
+ signal,
292
+ () => finishInterruption({ kind: "interrupted", signal }),
293
+ ]),
294
+ );
295
+ for (const [signal, handler] of handlers) {
296
+ signalTarget.prependListener(signal, handler);
297
+ }
298
+ const onInput = (chunk) => {
299
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
300
+ if (bytes.includes(0x03)) {
301
+ finishInterruption({ kind: "interrupted", signal: "SIGINT" });
302
+ } else if (bytes.includes(0x1a)) {
303
+ finishInterruption({ kind: "interrupted", signal: "SIGTSTP" });
304
+ }
305
+ };
306
+ if (input?.isTTY) input.prependListener("data", onInput);
307
+ try {
308
+ onReady(session.ready);
309
+ const outcome = await Promise.race([
310
+ interruption,
311
+ session.wait().then((exit) => ({ kind: "exited", ...exit })),
312
+ ]);
313
+ if (outcome.kind === "exited") {
314
+ const diagnostics = session.diagnostics?.() ?? {};
315
+ throw new Error(
316
+ `Application exited before the sandbox was closed (${summarizeProjectCommandFailure(
317
+ {
318
+ status: outcome.status,
319
+ signal: outcome.signal,
320
+ stdoutByteCount: diagnostics.stdoutBytes ?? 0,
321
+ stderrByteCount: diagnostics.stderrBytes ?? 0,
322
+ },
323
+ )}).`,
324
+ );
325
+ }
326
+ return Object.freeze({
327
+ signal: outcome.signal,
328
+ diagnostics: session.diagnostics?.() ?? {
329
+ stdoutBytes: 0,
330
+ stderrBytes: 0,
331
+ },
332
+ });
333
+ } finally {
334
+ try {
335
+ // npm can forward the terminal's SIGINT after the CLI receives it.
336
+ // Keep our handlers installed until the owned process group is fully
337
+ // stopped so a duplicate signal cannot terminate Rehearsal mid-cleanup.
338
+ await session.stop();
339
+ } finally {
340
+ for (const [signal, handler] of handlers) {
341
+ signalTarget.removeListener(signal, handler);
342
+ }
343
+ if (input?.isTTY) input.removeListener("data", onInput);
344
+ }
345
+ }
346
+ };
347
+
348
+ const valueAtPath = (value, path) => {
349
+ let current = value;
350
+ for (const segment of path) {
351
+ if (
352
+ current === null ||
353
+ typeof current !== "object" ||
354
+ !Object.hasOwn(current, segment)
355
+ ) {
356
+ return undefined;
357
+ }
358
+ current = current[segment];
359
+ }
360
+ return current;
361
+ };
362
+
363
+ export const runHttpProofs = async ({
364
+ checks,
365
+ fetchImplementation = fetch,
366
+ }) => {
367
+ if (!Array.isArray(checks) || checks.length < 2) {
368
+ throw new Error(
369
+ "HTTP proof requires explicit positive and negative checks.",
370
+ );
371
+ }
372
+ if (
373
+ !checks.some((check) => check.kind === "positive") ||
374
+ !checks.some((check) => check.kind === "negative")
375
+ ) {
376
+ throw new Error(
377
+ "HTTP proof requires at least one positive and one negative check.",
378
+ );
379
+ }
380
+ const results = [];
381
+ for (const check of checks) {
382
+ if (!isLoopbackUrl(check.url))
383
+ throw new Error(`HTTP proof ${check.name} is not local.`);
384
+ const response = await fetchImplementation(check.url, {
385
+ method: check.method ?? "GET",
386
+ redirect: "manual",
387
+ signal: AbortSignal.timeout(check.timeoutMs ?? 5_000),
388
+ });
389
+ if (response.status !== check.expectedStatus) {
390
+ throw new Error(
391
+ `HTTP proof ${check.name} expected status ${check.expectedStatus} but received ${response.status}.`,
392
+ );
393
+ }
394
+ const bytes = Buffer.from(await response.arrayBuffer());
395
+ if (bytes.length > 1024 * 1024)
396
+ throw new Error(`HTTP proof ${check.name} returned too much data.`);
397
+ if (check.json) {
398
+ let parsed;
399
+ try {
400
+ parsed = JSON.parse(bytes.toString("utf8"));
401
+ } catch {
402
+ throw new Error(`HTTP proof ${check.name} did not return JSON.`);
403
+ }
404
+ const actual = valueAtPath(parsed, check.json.path);
405
+ if (
406
+ Object.hasOwn(check.json, "equals") &&
407
+ !Object.is(actual, check.json.equals)
408
+ ) {
409
+ throw new Error(
410
+ `HTTP proof ${check.name} did not return its required value.`,
411
+ );
412
+ }
413
+ if (
414
+ Object.hasOwn(check.json, "notEquals") &&
415
+ Object.is(actual, check.json.notEquals)
416
+ ) {
417
+ throw new Error(`HTTP proof ${check.name} returned a forbidden value.`);
418
+ }
419
+ if (
420
+ check.json.minimumItems !== undefined &&
421
+ (!Array.isArray(actual) || actual.length < check.json.minimumItems)
422
+ ) {
423
+ throw new Error(
424
+ `HTTP proof ${check.name} returned too few matching items.`,
425
+ );
426
+ }
427
+ }
428
+ results.push({
429
+ name: check.name,
430
+ kind: check.kind,
431
+ status: response.status,
432
+ });
433
+ }
434
+ return Object.freeze(results);
435
+ };
@@ -18,7 +18,7 @@ import {
18
18
  symlink,
19
19
  } from "node:fs/promises";
20
20
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
21
- import { createMigrationReplayReceipt } from "./migration_history.mjs";
21
+ import { createMigrationReplayReceipt } from "../runtime/migration_history.mjs";
22
22
 
23
23
  const generationPattern = /^\d{8}T\d{6}Z-[a-f0-9]{12}$/u;
24
24
  const buildPrefix = ".building-";
@@ -28,6 +28,9 @@ const migrationNamePattern = /^\d{14}_[a-z0-9_]+\.sql$/u;
28
28
  const storageBucketPattern = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
29
29
  const maximumAssetBytes = 50 * 1024 * 1024;
30
30
  const maximumTotalAssetBytes = 2 * 1024 * 1024 * 1024;
31
+ const relationPattern = /^(?:[a-z][a-z0-9_]{0,62}\.)?[a-z][a-z0-9_]{0,62}$/u;
32
+ const relationKey = (schema, table) =>
33
+ schema === "public" ? table : `${schema}.${table}`;
31
34
 
32
35
  const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
33
36
 
@@ -136,6 +139,7 @@ export const createAndActivateBaseline = async ({
136
139
  migrationFiles = [],
137
140
  assets = [],
138
141
  schemaSql = null,
142
+ activate = true,
139
143
  }) => {
140
144
  const root = assertArtifactRoot(artifactRoot);
141
145
  assertGenerationId(generationId);
@@ -149,7 +153,7 @@ export const createAndActivateBaseline = async ({
149
153
  }
150
154
  if (
151
155
  !Array.isArray(expectedTables) ||
152
- expectedTables.some((name) => !/^[a-z][a-z0-9_]*$/u.test(name)) ||
156
+ expectedTables.some((name) => !relationPattern.test(name)) ||
153
157
  new Set(expectedTables).size !== expectedTables.length
154
158
  ) {
155
159
  throw new Error(
@@ -190,23 +194,25 @@ export const createAndActivateBaseline = async ({
190
194
  for await (const record of records) {
191
195
  if (
192
196
  !record ||
197
+ (record.schema !== undefined &&
198
+ !/^[a-z][a-z0-9_]{0,62}$/u.test(record.schema)) ||
193
199
  typeof record.table !== "string" ||
200
+ !/^[a-z][a-z0-9_]{0,62}$/u.test(record.table) ||
194
201
  !record.row ||
195
202
  typeof record.row !== "object" ||
196
203
  Array.isArray(record.row)
197
204
  ) {
198
205
  throw new Error("A sanitized baseline record has an invalid shape.");
199
206
  }
200
- if (expectedTableSet.size && !expectedTableSet.has(record.table)) {
201
- throw new Error(
202
- `The baseline stream exposed unexpected table ${record.table}.`,
203
- );
207
+ const key = relationKey(record.schema ?? "public", record.table);
208
+ if (expectedTableSet.size && !expectedTableSet.has(key)) {
209
+ throw new Error(`The baseline stream exposed unexpected table ${key}.`);
204
210
  }
205
211
  const line = `${JSON.stringify(record)}\n`;
206
212
  dataHash.update(line);
207
213
  await dataHandle.write(line, null, "utf8");
208
214
  rowCount += 1;
209
- tableCounts.set(record.table, (tableCounts.get(record.table) ?? 0) + 1);
215
+ tableCounts.set(key, (tableCounts.get(key) ?? 0) + 1);
210
216
  }
211
217
  await dataHandle.sync();
212
218
  await dataHandle.close();
@@ -358,12 +364,12 @@ export const createAndActivateBaseline = async ({
358
364
  finalCreated = true;
359
365
  await chmod(finalDirectory, 0o500);
360
366
 
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
+ if (activate) {
368
+ await activateBaselineGeneration({
369
+ artifactRoot: root,
370
+ generationId,
371
+ });
372
+ }
367
373
  return manifest;
368
374
  } catch (error) {
369
375
  if (dataHandle) await dataHandle.close().catch(() => undefined);
@@ -377,6 +383,58 @@ export const createAndActivateBaseline = async ({
377
383
  }
378
384
  };
379
385
 
386
+ export const activateBaselineGeneration = async ({
387
+ artifactRoot,
388
+ generationId,
389
+ }) => {
390
+ const root = assertArtifactRoot(artifactRoot);
391
+ assertGenerationId(generationId);
392
+ const generationDirectory = assertInsideRoot(
393
+ root,
394
+ join(root, generationsDirectoryName, generationId),
395
+ );
396
+ const manifest = JSON.parse(
397
+ await readFile(join(generationDirectory, "baseline-manifest.json"), "utf8"),
398
+ );
399
+ if (manifest.generationId !== generationId) {
400
+ throw new Error(
401
+ "The staged baseline identifier does not match its manifest.",
402
+ );
403
+ }
404
+ const temporaryLink = assertInsideRoot(
405
+ root,
406
+ join(root, `.current-${randomBytes(4).toString("hex")}`),
407
+ );
408
+ await symlink(join(generationsDirectoryName, generationId), temporaryLink);
409
+ await rename(temporaryLink, join(root, currentLinkName));
410
+ return manifest;
411
+ };
412
+
413
+ export const removeStagedBaselineGeneration = async ({
414
+ artifactRoot,
415
+ generationId,
416
+ }) => {
417
+ const root = assertArtifactRoot(artifactRoot);
418
+ assertGenerationId(generationId);
419
+ try {
420
+ const active = await resolveActiveBaselinePaths({ artifactRoot: root });
421
+ if (basename(active.generationDirectory) === generationId) {
422
+ throw new Error(
423
+ "Refusing to remove the active Rehearsal baseline generation.",
424
+ );
425
+ }
426
+ } catch (error) {
427
+ if (error?.code !== "ENOENT") throw error;
428
+ }
429
+ const target = assertInsideRoot(
430
+ root,
431
+ join(root, generationsDirectoryName, generationId),
432
+ );
433
+ await verifyNoSymlink(target, "A staged Rehearsal baseline generation");
434
+ await makeArtifactTreeWritable(target);
435
+ await rm(target, { recursive: true, force: true });
436
+ };
437
+
380
438
  export const resolveActiveBaselinePaths = async ({ artifactRoot }) => {
381
439
  const root = assertArtifactRoot(artifactRoot);
382
440
  const currentTarget = await readlink(join(root, currentLinkName));
@@ -478,7 +536,7 @@ export const listIncompleteBaselineBuilds = async ({ artifactRoot }) => {
478
536
  }
479
537
  };
480
538
 
481
- export const pruneBaselineGenerations = async ({
539
+ export const planBaselineGenerationPrune = async ({
482
540
  artifactRoot,
483
541
  retain = 2,
484
542
  }) => {
@@ -515,8 +573,75 @@ export const pruneBaselineGenerations = async ({
515
573
  );
516
574
  const removed = entries
517
575
  .map((entry) => entry.name)
518
- .filter((name) => !retained.has(name));
519
- for (const name of removed) {
576
+ .filter((name) => !retained.has(name))
577
+ .sort();
578
+ return { retained: [...retained], removed };
579
+ };
580
+
581
+ export const planBaselineReplacementPrune = async ({
582
+ artifactRoot,
583
+ retain = 2,
584
+ }) => {
585
+ const root = assertArtifactRoot(artifactRoot);
586
+ if (!Number.isSafeInteger(retain) || retain < 1) {
587
+ throw new Error("Rehearsal baseline retention must be a positive integer.");
588
+ }
589
+ await resolveActiveBaselinePaths({ artifactRoot: root });
590
+ const generationsRoot = join(root, generationsDirectoryName);
591
+ await verifyNoSymlink(generationsRoot, "The Rehearsal generations directory");
592
+ const entries = await readdir(generationsRoot, { withFileTypes: true });
593
+ for (const entry of entries) {
594
+ if (
595
+ !entry.isDirectory() ||
596
+ entry.isSymbolicLink() ||
597
+ !generationPattern.test(entry.name)
598
+ ) {
599
+ throw new Error(
600
+ `Refusing to plan replacement around an unexpected Rehearsal generation entry: ${entry.name}`,
601
+ );
602
+ }
603
+ }
604
+ const current = entries
605
+ .map((entry) => entry.name)
606
+ .sort()
607
+ .reverse();
608
+ const retained = current.slice(0, Math.max(0, retain - 1));
609
+ const retainedSet = new Set(retained);
610
+ return {
611
+ retainedAfterReplacement: retained,
612
+ removedAfterReplacement: current
613
+ .filter((name) => !retainedSet.has(name))
614
+ .sort(),
615
+ };
616
+ };
617
+
618
+ export const pruneBaselineGenerations = async ({
619
+ artifactRoot,
620
+ retain = 2,
621
+ expectedRemoved,
622
+ }) => {
623
+ const root = assertArtifactRoot(artifactRoot);
624
+ const plan = await planBaselineGenerationPrune({
625
+ artifactRoot: root,
626
+ retain,
627
+ });
628
+ const generationsRoot = join(root, generationsDirectoryName);
629
+ if (
630
+ expectedRemoved &&
631
+ (expectedRemoved.length !== plan.removed.length ||
632
+ expectedRemoved.some((name, index) => name !== plan.removed[index]))
633
+ ) {
634
+ throw new Error(
635
+ "Rehearsal baseline generations changed after the cleanup preview.",
636
+ );
637
+ }
638
+ for (const name of plan.removed) {
639
+ const active = await resolveActiveBaselinePaths({ artifactRoot: root });
640
+ if (basename(active.generationDirectory) === name) {
641
+ throw new Error(
642
+ "Refusing to prune the active Rehearsal baseline generation.",
643
+ );
644
+ }
520
645
  const target = assertInsideRoot(
521
646
  generationsRoot,
522
647
  join(generationsRoot, name),
@@ -525,7 +650,7 @@ export const pruneBaselineGenerations = async ({
525
650
  await makeArtifactTreeWritable(target);
526
651
  await rm(target, { recursive: true, force: true });
527
652
  }
528
- return { retained: [...retained], removed };
653
+ return plan;
529
654
  };
530
655
 
531
656
  export const removeBaselineArtifactRoot = async ({ artifactRoot }) => {