@rehearsal-db/core 0.1.0-beta.7 → 0.1.0-beta.9

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 (64) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/COMPATIBILITY.md +15 -3
  3. package/README.md +31 -13
  4. package/docs/README.md +29 -0
  5. package/docs/adapters.md +15 -6
  6. package/docs/architecture.md +58 -0
  7. package/docs/baselines.md +22 -1
  8. package/docs/commands.md +81 -14
  9. package/docs/configuration.md +144 -11
  10. package/docs/getting-started.md +12 -2
  11. package/docs/glossary.md +4 -0
  12. package/docs/production-source.md +176 -33
  13. package/docs/roadmap.md +30 -28
  14. package/docs/runtime-policies.md +191 -0
  15. package/docs/sanitization.md +24 -8
  16. package/docs/security-model.md +29 -8
  17. package/docs/standalone-workflow.md +107 -0
  18. package/docs/troubleshooting.md +8 -0
  19. package/package.json +25 -22
  20. package/scripts/runtime/manage_database.mjs +18 -0
  21. package/src/README.md +17 -0
  22. package/src/application/session.mjs +313 -0
  23. package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +110 -14
  24. package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +8 -4
  25. package/{scripts/lib/rehearsal/baseline_preparation.mjs → src/baseline/preparation.mjs} +11 -4
  26. package/src/baseline/privacy_engine.mjs +413 -0
  27. package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +27 -7
  28. package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
  29. package/src/cli/arguments.mjs +120 -0
  30. package/src/cli/guided.mjs +807 -0
  31. package/src/cli/rehearsal.mjs +933 -0
  32. package/src/cli/renderers.mjs +584 -0
  33. package/src/cli/runtime_commands.mjs +553 -0
  34. package/src/cli/source_commands.mjs +326 -0
  35. package/src/cli/terminal.mjs +275 -0
  36. package/src/identity/claim.mjs +975 -0
  37. package/src/identity/storage.mjs +165 -0
  38. package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +34 -0
  39. package/{scripts/lib/rehearsal → src/project}/configuration.mjs +353 -3
  40. package/{scripts/lib/rehearsal → src/project}/setup.mjs +1 -1
  41. package/{scripts/lib/rehearsal → src/project}/support_report.mjs +5 -2
  42. package/{scripts/lib/rehearsal → src/runtime}/cleanup.mjs +3 -3
  43. package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +5 -5
  44. package/src/runtime/policy.mjs +438 -0
  45. package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +40 -25
  46. package/src/runtime/topology.mjs +177 -0
  47. package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +1 -1
  48. package/src/shared/operation_guard.mjs +162 -0
  49. package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
  50. package/src/source/access.mjs +578 -0
  51. package/src/source/asset_transfer.mjs +177 -0
  52. package/src/source/baseline.mjs +446 -0
  53. package/src/source/postgresql_access.mjs +480 -0
  54. package/{scripts/lib/runtime/postgresql_runtime.mjs → src/targets/postgresql.mjs} +49 -13
  55. package/{scripts/lib/runtime/supabase_runtime.mjs → src/targets/supabase.mjs} +54 -16
  56. package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +52 -20
  57. package/{scripts/lib/runtime/runtime_target.mjs → src/targets/target.mjs} +27 -2
  58. package/scripts/operations/database/manage_rehearsal_database.mjs +0 -11
  59. package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -2166
  60. /package/{scripts/lib/rehearsal → src/baseline}/input_discovery.mjs +0 -0
  61. /package/{scripts/lib/rehearsal → src/baseline}/policy_review.mjs +0 -0
  62. /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
  63. /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
  64. /package/{scripts/lib/rehearsal → src/shared}/human_output.mjs +0 -0
@@ -0,0 +1,313 @@
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
+
14
+ const parseCommand = (source) => {
15
+ if (typeof source !== "string" || !source.trim())
16
+ throw new Error("Application command is empty.");
17
+ const values = [];
18
+ let value = "";
19
+ let quote = null;
20
+ for (const character of source) {
21
+ if (quote) {
22
+ if (character === quote) quote = null;
23
+ else value += character;
24
+ } else if (character === '"' || character === "'") quote = character;
25
+ else if (/\s/u.test(character)) {
26
+ if (value) values.push(value);
27
+ value = "";
28
+ } else value += character;
29
+ }
30
+ if (quote)
31
+ throw new Error("Application command contains an unmatched quote.");
32
+ if (value) values.push(value);
33
+ return values;
34
+ };
35
+
36
+ const readOwnedEnvironment = async (path) => {
37
+ const details = await stat(path);
38
+ if (!details.isFile() || (details.mode & 0o077) !== 0) {
39
+ throw new Error(
40
+ "Runtime environment file must be an owner-only regular file.",
41
+ );
42
+ }
43
+ return parseEnv(await readFile(path, "utf8"));
44
+ };
45
+
46
+ const assertLocalValue = (key, value) => {
47
+ if (/(_URL|_URI|_ENDPOINT)$/u.test(key)) {
48
+ let parsed;
49
+ try {
50
+ parsed = new URL(value);
51
+ } catch {
52
+ throw new Error(`Mapped runtime variable ${key} is not a URL.`);
53
+ }
54
+ if (
55
+ !isLoopbackUrl(value) &&
56
+ !["postgres:", "postgresql:"].includes(parsed.protocol)
57
+ ) {
58
+ throw new Error(`Mapped runtime variable ${key} is not local.`);
59
+ }
60
+ if (
61
+ ["postgres:", "postgresql:"].includes(parsed.protocol) &&
62
+ !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname)
63
+ ) {
64
+ throw new Error(`Mapped runtime variable ${key} is not local.`);
65
+ }
66
+ }
67
+ };
68
+
69
+ export const buildApplicationEnvironment = async ({ files, mappings }) => {
70
+ if (!files?.primary)
71
+ throw new Error("Primary runtime environment file is required.");
72
+ const environments = {};
73
+ for (const [name, path] of Object.entries(files)) {
74
+ environments[name] = await readOwnedEnvironment(path);
75
+ }
76
+ if (!mappings || typeof mappings !== "object" || Array.isArray(mappings)) {
77
+ throw new Error("Application environment mappings must be an object.");
78
+ }
79
+ const result = {};
80
+ for (const [destination, reference] of Object.entries(mappings)) {
81
+ if (!ENV_KEY.test(destination) || typeof reference !== "string") {
82
+ throw new Error("Application environment mapping is invalid.");
83
+ }
84
+ const separator = reference.indexOf(":");
85
+ if (separator < 1)
86
+ throw new Error(
87
+ `Application mapping ${destination} has an invalid reference.`,
88
+ );
89
+ const target = reference.slice(0, separator);
90
+ const source = reference.slice(separator + 1);
91
+ if (!ENV_KEY.test(source) || !environments[target]?.[source]) {
92
+ throw new Error(
93
+ `Application mapping ${destination} references a missing runtime value.`,
94
+ );
95
+ }
96
+ assertLocalValue(source, environments[target][source]);
97
+ result[destination] = environments[target][source];
98
+ }
99
+ return result;
100
+ };
101
+
102
+ const boundedAppend = (current, chunk) =>
103
+ `${current}${chunk}`.slice(-MAX_DIAGNOSTIC_BYTES);
104
+
105
+ export const summarizeProjectCommandFailure = ({
106
+ error,
107
+ status,
108
+ stdout = "",
109
+ stderr = "",
110
+ }) => {
111
+ const stdoutBytes = Buffer.byteLength(String(stdout));
112
+ const stderrBytes = Buffer.byteLength(String(stderr));
113
+ if (error) {
114
+ const code = /^[A-Z0-9_]+$/u.test(error.code ?? "")
115
+ ? ` (${error.code})`
116
+ : "";
117
+ return `command could not start${code}; output withheld`;
118
+ }
119
+ return `exit ${Number.isInteger(status) ? status : "unknown"}; stdout ${stdoutBytes} bytes; stderr ${stderrBytes} bytes; output withheld`;
120
+ };
121
+
122
+ const waitForReadiness = async ({ readiness, fetchImplementation, child }) => {
123
+ if (!readiness || !isLoopbackUrl(readiness.url)) {
124
+ throw new Error("Application readiness must use a loopback URL.");
125
+ }
126
+ const expected = readiness.expectedStatus ?? 200;
127
+ const deadline = Date.now() + (readiness.timeoutSeconds ?? 30) * 1_000;
128
+ let lastStatus = null;
129
+ while (Date.now() < deadline) {
130
+ if (child.exitCode !== null || child.signalCode !== null)
131
+ throw new Error("Application exited before becoming ready.");
132
+ try {
133
+ const response = await fetchImplementation(readiness.url, {
134
+ redirect: "manual",
135
+ signal: AbortSignal.timeout(2_000),
136
+ });
137
+ lastStatus = response.status;
138
+ if (response.status === expected)
139
+ return { url: readiness.url, status: response.status };
140
+ } catch {
141
+ // A bounded retry is expected while the local process starts.
142
+ }
143
+ await new Promise((resolve) => setTimeout(resolve, 200));
144
+ }
145
+ throw new Error(
146
+ `Application readiness timed out${lastStatus === null ? "" : ` after HTTP ${lastStatus}`}.`,
147
+ );
148
+ };
149
+
150
+ const stopOwnedChild = async (child) => {
151
+ const exited = () => child.exitCode !== null || child.signalCode !== null;
152
+ if (exited()) return;
153
+ const kill = (signal) => {
154
+ try {
155
+ if (process.platform === "win32") child.kill(signal);
156
+ else process.kill(-child.pid, signal);
157
+ } catch (error) {
158
+ if (error?.code !== "ESRCH") throw error;
159
+ }
160
+ };
161
+ kill("SIGTERM");
162
+ await Promise.race([
163
+ new Promise((resolve) => child.once("exit", resolve)),
164
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
165
+ ]);
166
+ if (!exited()) {
167
+ kill("SIGKILL");
168
+ await new Promise((resolve) => child.once("exit", resolve));
169
+ }
170
+ };
171
+
172
+ export const startApplicationSession = async ({
173
+ command,
174
+ cwd,
175
+ files,
176
+ mappings,
177
+ readiness,
178
+ inheritedEnvironment = process.env,
179
+ spawnImplementation = spawn,
180
+ fetchImplementation = fetch,
181
+ }) => {
182
+ const mapped = await buildApplicationEnvironment({ files, mappings });
183
+ const [program, ...args] = parseCommand(command);
184
+ const child = spawnImplementation(program, args, {
185
+ cwd,
186
+ env: createCleanProcessEnvironment({
187
+ inheritedEnvironment,
188
+ overrides: mapped,
189
+ }),
190
+ detached: process.platform !== "win32",
191
+ stdio: ["ignore", "pipe", "pipe"],
192
+ });
193
+ let stdout = "";
194
+ let stderr = "";
195
+ child.stdout?.on("data", (chunk) => {
196
+ stdout = boundedAppend(stdout, chunk.toString("utf8"));
197
+ });
198
+ child.stderr?.on("data", (chunk) => {
199
+ stderr = boundedAppend(stderr, chunk.toString("utf8"));
200
+ });
201
+ await new Promise((resolve, reject) => {
202
+ child.once("spawn", resolve);
203
+ child.once("error", reject);
204
+ });
205
+ try {
206
+ const ready = await waitForReadiness({
207
+ readiness,
208
+ fetchImplementation,
209
+ child,
210
+ });
211
+ return Object.freeze({
212
+ pid: child.pid,
213
+ ready,
214
+ stop: () => stopOwnedChild(child),
215
+ diagnostics: () => ({
216
+ stdoutBytes: Buffer.byteLength(stdout),
217
+ stderrBytes: Buffer.byteLength(stderr),
218
+ }),
219
+ });
220
+ } catch (error) {
221
+ await stopOwnedChild(child);
222
+ throw error;
223
+ }
224
+ };
225
+
226
+ const valueAtPath = (value, path) => {
227
+ let current = value;
228
+ for (const segment of path) {
229
+ if (
230
+ current === null ||
231
+ typeof current !== "object" ||
232
+ !Object.hasOwn(current, segment)
233
+ ) {
234
+ return undefined;
235
+ }
236
+ current = current[segment];
237
+ }
238
+ return current;
239
+ };
240
+
241
+ export const runHttpProofs = async ({
242
+ checks,
243
+ fetchImplementation = fetch,
244
+ }) => {
245
+ if (!Array.isArray(checks) || checks.length < 2) {
246
+ throw new Error(
247
+ "HTTP proof requires explicit positive and negative checks.",
248
+ );
249
+ }
250
+ if (
251
+ !checks.some((check) => check.kind === "positive") ||
252
+ !checks.some((check) => check.kind === "negative")
253
+ ) {
254
+ throw new Error(
255
+ "HTTP proof requires at least one positive and one negative check.",
256
+ );
257
+ }
258
+ const results = [];
259
+ for (const check of checks) {
260
+ if (!isLoopbackUrl(check.url))
261
+ throw new Error(`HTTP proof ${check.name} is not local.`);
262
+ const response = await fetchImplementation(check.url, {
263
+ method: check.method ?? "GET",
264
+ redirect: "manual",
265
+ signal: AbortSignal.timeout(check.timeoutMs ?? 5_000),
266
+ });
267
+ if (response.status !== check.expectedStatus) {
268
+ throw new Error(
269
+ `HTTP proof ${check.name} expected status ${check.expectedStatus} but received ${response.status}.`,
270
+ );
271
+ }
272
+ const bytes = Buffer.from(await response.arrayBuffer());
273
+ if (bytes.length > 1024 * 1024)
274
+ throw new Error(`HTTP proof ${check.name} returned too much data.`);
275
+ if (check.json) {
276
+ let parsed;
277
+ try {
278
+ parsed = JSON.parse(bytes.toString("utf8"));
279
+ } catch {
280
+ throw new Error(`HTTP proof ${check.name} did not return JSON.`);
281
+ }
282
+ const actual = valueAtPath(parsed, check.json.path);
283
+ if (
284
+ Object.hasOwn(check.json, "equals") &&
285
+ !Object.is(actual, check.json.equals)
286
+ ) {
287
+ throw new Error(
288
+ `HTTP proof ${check.name} did not return its required value.`,
289
+ );
290
+ }
291
+ if (
292
+ Object.hasOwn(check.json, "notEquals") &&
293
+ Object.is(actual, check.json.notEquals)
294
+ ) {
295
+ throw new Error(`HTTP proof ${check.name} returned a forbidden value.`);
296
+ }
297
+ if (
298
+ check.json.minimumItems !== undefined &&
299
+ (!Array.isArray(actual) || actual.length < check.json.minimumItems)
300
+ ) {
301
+ throw new Error(
302
+ `HTTP proof ${check.name} returned too few matching items.`,
303
+ );
304
+ }
305
+ }
306
+ results.push({
307
+ name: check.name,
308
+ kind: check.kind,
309
+ status: response.status,
310
+ });
311
+ }
312
+ return Object.freeze(results);
313
+ };
@@ -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));
@@ -515,10 +573,48 @@ export const planBaselineGenerationPrune = async ({
515
573
  );
516
574
  const removed = entries
517
575
  .map((entry) => entry.name)
518
- .filter((name) => !retained.has(name));
576
+ .filter((name) => !retained.has(name))
577
+ .sort();
519
578
  return { retained: [...retained], removed };
520
579
  };
521
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
+
522
618
  export const pruneBaselineGenerations = async ({
523
619
  artifactRoot,
524
620
  retain = 2,
@@ -12,13 +12,13 @@ import { pathToFileURL } from "node:url";
12
12
  import {
13
13
  createAndActivateBaseline,
14
14
  createBaselineGenerationId,
15
- } from "./baseline_artifact.mjs";
16
- import { loadRehearsalConfig } from "./configuration.mjs";
15
+ } from "./artifact.mjs";
16
+ import { loadRehearsalConfig } from "../project/configuration.mjs";
17
17
  import {
18
18
  buildMigrationLedgerInventory,
19
19
  createMigrationReplayReceipt,
20
20
  readMigrationSourceBundle,
21
- } from "./migration_history.mjs";
21
+ } from "../runtime/migration_history.mjs";
22
22
  import { validateRuntimeSanitizationPolicy } from "./sanitization_policy.mjs";
23
23
 
24
24
  const resolveProjectInput = (projectRoot, value, label) => {
@@ -109,7 +109,11 @@ export const createSyntheticBaselineFromFiles = async ({
109
109
  );
110
110
  const expectedTables = policy.tables
111
111
  .filter((table) => table.sourceRows !== "EXCLUDE")
112
- .map((table) => table.name);
112
+ .map((table) =>
113
+ (table.schema ?? "public") === "public"
114
+ ? table.name
115
+ : `${table.schema}.${table.name}`,
116
+ );
113
117
  if (expectedTables.length === 0) {
114
118
  throw new Error("The sanitization policy includes no restorable tables.");
115
119
  }
@@ -7,8 +7,8 @@ import { createReadStream } from "node:fs";
7
7
  import { access, lstat, mkdir, open, readFile } from "node:fs/promises";
8
8
  import { createInterface } from "node:readline";
9
9
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
10
- import { loadRehearsalConfig } from "./configuration.mjs";
11
- import { buildMigrationLedgerInventory } from "./migration_history.mjs";
10
+ import { loadRehearsalConfig } from "../project/configuration.mjs";
11
+ import { buildMigrationLedgerInventory } from "../runtime/migration_history.mjs";
12
12
 
13
13
  const identifierPattern = /^[a-z][a-z0-9_]{0,62}$/u;
14
14
  const storageBucketPattern = /^[a-z0-9][a-z0-9.-]{0,99}$/u;
@@ -70,6 +70,7 @@ const inspectSyntheticRecords = async (path) => {
70
70
  }
71
71
  if (
72
72
  !record ||
73
+ !identifierPattern.test(record.schema ?? "public") ||
73
74
  !identifierPattern.test(record.table ?? "") ||
74
75
  !record.row ||
75
76
  typeof record.row !== "object" ||
@@ -86,14 +87,18 @@ const inspectSyntheticRecords = async (path) => {
86
87
  `Synthetic baseline table ${record.table} has invalid or empty columns.`,
87
88
  );
88
89
  }
89
- const table = tables.get(record.table) ?? {
90
+ const schema = record.schema ?? "public";
91
+ const key =
92
+ schema === "public" ? record.table : `${schema}.${record.table}`;
93
+ const table = tables.get(key) ?? {
94
+ schema,
90
95
  name: record.table,
91
96
  rowCount: 0,
92
97
  columns: new Set(),
93
98
  };
94
99
  table.rowCount += 1;
95
100
  for (const column of columns) table.columns.add(column);
96
- tables.set(record.table, table);
101
+ tables.set(key, table);
97
102
  rowCount += 1;
98
103
  }
99
104
  if (rowCount === 0) {
@@ -104,6 +109,7 @@ const inspectSyntheticRecords = async (path) => {
104
109
  tables: [...tables.values()]
105
110
  .sort((left, right) => left.name.localeCompare(right.name))
106
111
  .map((table) => ({
112
+ schema: table.schema,
107
113
  name: table.name,
108
114
  rowCount: table.rowCount,
109
115
  columns: [...table.columns].sort(),
@@ -232,6 +238,7 @@ const renderPolicyDraft = ({ migrationCutoff, tables }) =>
232
238
  draft: true,
233
239
  migrationCutoff,
234
240
  tables: tables.map((table) => ({
241
+ ...(table.schema === "public" ? {} : { schema: table.schema }),
235
242
  name: table.name,
236
243
  group: "synthetic",
237
244
  sourceRows: "STREAM AND SANITIZE",