@rehearsal-db/core 0.1.0-beta.4 → 0.1.0-beta.6

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.
@@ -0,0 +1,772 @@
1
+ /**
2
+ * Purpose: Restore and manage a disposable, loopback-only PostgreSQL runtime from
3
+ * the active verified Rehearsal baseline.
4
+ */
5
+
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { randomBytes } from "node:crypto";
8
+ import { createReadStream } from "node:fs";
9
+ import { once } from "node:events";
10
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
11
+ import { join, resolve } from "node:path";
12
+ import { createInterface } from "node:readline";
13
+ import { pathToFileURL } from "node:url";
14
+ import {
15
+ resolveActiveBaselinePaths,
16
+ verifyActiveBaseline,
17
+ } from "../rehearsal/baseline_artifact.mjs";
18
+ import { loadRehearsalConfig } from "../rehearsal/configuration.mjs";
19
+ import { formatCount } from "../rehearsal/human_output.mjs";
20
+ import { readMigrationFileInventory } from "../rehearsal/migration_history.mjs";
21
+ import { createCleanProcessEnvironment } from "../rehearsal/process_environment.mjs";
22
+ import {
23
+ buildRestoreSqlPrefix,
24
+ buildRestoreSqlSuffix,
25
+ createCandidateMigrationReceipt,
26
+ encodeBaselineRecordForCopy,
27
+ summarizeRestoreError,
28
+ } from "../rehearsal/runtime_restore.mjs";
29
+ import { readBoundRuntimeSanitizationPolicy } from "../rehearsal/sanitization_policy.mjs";
30
+
31
+ const repositoryRoot = process.cwd();
32
+ const loadedConfiguration = await loadRehearsalConfig({
33
+ projectRoot: repositoryRoot,
34
+ });
35
+ const { config, paths: configuredPaths } = loadedConfiguration;
36
+ const artifactRoot = configuredPaths.artifactDirectory;
37
+ const runtimeWorkdir = configuredPaths.runtimeWorkdir;
38
+ const runtimeMarkerPath = join(runtimeWorkdir, "baseline.json");
39
+ const runtimeCandidateReceiptPath = join(
40
+ runtimeWorkdir,
41
+ "candidate-receipt.json",
42
+ );
43
+ const environmentPath = configuredPaths.applicationEnvironment;
44
+ const manifestPath = configuredPaths.sanitizationPolicy;
45
+ const projectId = config.runtime.projectId;
46
+ const containerName = `${projectId}-postgresql`;
47
+ const volumeName = `${projectId}-postgresql-data`;
48
+ const label = `com.rehearsal-db.project=${projectId}`;
49
+ const image = config.postgresql.image;
50
+ const database = config.postgresql.database;
51
+ const databaseUser = config.postgresql.user;
52
+ const databasePort = config.runtime.ports.database;
53
+ const action = process.argv[2] ?? "status";
54
+ const applicationMigrations = new URL(
55
+ "./",
56
+ pathToFileURL(`${configuredPaths.migrationDirectory}/`),
57
+ );
58
+ const runtimeAdapter = configuredPaths.runtimeAdapter
59
+ ? await import(pathToFileURL(configuredPaths.runtimeAdapter).href)
60
+ : null;
61
+ const environmentKeyPattern = /^[A-Z][A-Z0-9_]*$/u;
62
+
63
+ const runCommand = (
64
+ command,
65
+ args,
66
+ { capture = false, input, allowFailure = false } = {},
67
+ ) => {
68
+ const piped = capture || input !== undefined || allowFailure;
69
+ const result = spawnSync(command, args, {
70
+ cwd: repositoryRoot,
71
+ encoding: "utf8",
72
+ env: createCleanProcessEnvironment(),
73
+ input,
74
+ maxBuffer: 16 * 1024 * 1024,
75
+ stdio: piped ? ["pipe", "pipe", "pipe"] : "inherit",
76
+ });
77
+ if (!allowFailure && result.error) throw result.error;
78
+ if (!allowFailure && result.status !== 0) {
79
+ const detail = [result.stdout, result.stderr]
80
+ .filter(Boolean)
81
+ .join("\n")
82
+ .trim();
83
+ throw new Error(
84
+ `${command} ${args.join(" ")} failed${detail ? `:\n${detail}` : "."}`,
85
+ );
86
+ }
87
+ return result;
88
+ };
89
+
90
+ const commandSucceeds = (command, args) =>
91
+ runCommand(command, args, { allowFailure: true }).status === 0;
92
+
93
+ const pathExists = async (path) =>
94
+ stat(path)
95
+ .then(() => true)
96
+ .catch((error) => {
97
+ if (error?.code === "ENOENT") return false;
98
+ throw error;
99
+ });
100
+
101
+ const assertRuntimePath = () => {
102
+ if (resolve(runtimeWorkdir) !== join(resolve(artifactRoot), "runtime")) {
103
+ throw new Error("Refusing an unsafe Rehearsal runtime path.");
104
+ }
105
+ };
106
+
107
+ const assertDocker = () => {
108
+ if (!commandSucceeds("docker", ["info"])) {
109
+ throw new Error(
110
+ "A running Docker-compatible runtime is required. Start Docker or Colima, then retry.",
111
+ );
112
+ }
113
+ };
114
+
115
+ const inspectResourceLabel = (kind, name) =>
116
+ runCommand(
117
+ "docker",
118
+ [
119
+ kind,
120
+ "inspect",
121
+ "--format",
122
+ kind === "container"
123
+ ? '{{index .Config.Labels "com.rehearsal-db.project"}}'
124
+ : '{{index .Labels "com.rehearsal-db.project"}}',
125
+ name,
126
+ ],
127
+ { allowFailure: true },
128
+ );
129
+
130
+ const resourceExists = (kind, name) =>
131
+ runCommand("docker", [kind, "inspect", name], { allowFailure: true })
132
+ .status === 0;
133
+
134
+ const assertOwnedResource = (kind, name) => {
135
+ if (!resourceExists(kind, name)) return false;
136
+ const inspection = inspectResourceLabel(kind, name);
137
+ if (inspection.status !== 0 || inspection.stdout.trim() !== projectId) {
138
+ throw new Error(
139
+ `Refusing to manage Docker ${kind} ${name} because it is not owned by this Rehearsal project.`,
140
+ );
141
+ }
142
+ return true;
143
+ };
144
+
145
+ const removeRuntime = async () => {
146
+ assertRuntimePath();
147
+ if (commandSucceeds("docker", ["info"])) {
148
+ if (assertOwnedResource("container", containerName)) {
149
+ runCommand("docker", ["container", "rm", "--force", containerName], {
150
+ capture: true,
151
+ });
152
+ }
153
+ if (assertOwnedResource("volume", volumeName)) {
154
+ runCommand("docker", ["volume", "rm", "--force", volumeName], {
155
+ capture: true,
156
+ });
157
+ }
158
+ }
159
+ await rm(runtimeWorkdir, { recursive: true, force: true });
160
+ await rm(environmentPath, { force: true });
161
+ };
162
+
163
+ const waitForDatabase = async () => {
164
+ let consecutiveReadyChecks = 0;
165
+ for (let attempt = 0; attempt < 60; attempt += 1) {
166
+ if (
167
+ commandSucceeds("docker", [
168
+ "exec",
169
+ containerName,
170
+ "pg_isready",
171
+ "--username",
172
+ databaseUser,
173
+ "--dbname",
174
+ database,
175
+ ])
176
+ ) {
177
+ consecutiveReadyChecks += 1;
178
+ if (consecutiveReadyChecks === 3) return;
179
+ } else {
180
+ consecutiveReadyChecks = 0;
181
+ }
182
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
183
+ }
184
+ throw new Error("The disposable PostgreSQL database did not become ready.");
185
+ };
186
+
187
+ const runPsql = (sql, { capture = true } = {}) =>
188
+ runCommand(
189
+ "docker",
190
+ [
191
+ "exec",
192
+ "--interactive",
193
+ containerName,
194
+ "psql",
195
+ "--quiet",
196
+ "--no-align",
197
+ "--tuples-only",
198
+ "--set",
199
+ "ON_ERROR_STOP=1",
200
+ "--username",
201
+ databaseUser,
202
+ "--dbname",
203
+ database,
204
+ ],
205
+ { capture, input: sql },
206
+ ).stdout;
207
+
208
+ const databaseEnvironment = (password) => ({
209
+ databaseUrl: [
210
+ "postgresql://",
211
+ databaseUser,
212
+ ":",
213
+ encodeURIComponent(password),
214
+ "@127.0.0.1:",
215
+ databasePort,
216
+ "/",
217
+ database,
218
+ "?sslmode=disable",
219
+ ].join(""),
220
+ host: "127.0.0.1",
221
+ port: databasePort,
222
+ database,
223
+ user: databaseUser,
224
+ password,
225
+ });
226
+
227
+ const quoteLiteral = (value) => `'${String(value).replaceAll("'", "''")}'`;
228
+
229
+ const initializeMigrationLedger = () =>
230
+ runPsql(`create schema if not exists rehearsal_internal;
231
+ create table if not exists rehearsal_internal.schema_migrations (
232
+ version text primary key,
233
+ name text not null,
234
+ file_sha256 text not null check (file_sha256 ~ '^[a-f0-9]{64}$'),
235
+ applied_at timestamptz not null default now()
236
+ );`);
237
+
238
+ const applyMigration = async ({ migration, directory }) => {
239
+ const source = await readFile(join(directory, migration.filename), "utf8");
240
+ runPsql(source, { capture: false });
241
+ runPsql(`insert into rehearsal_internal.schema_migrations
242
+ (version, name, file_sha256)
243
+ values (
244
+ ${quoteLiteral(migration.version)},
245
+ ${quoteLiteral(migration.name)},
246
+ ${quoteLiteral(migration.fileSha256)}
247
+ );`);
248
+ };
249
+
250
+ const readRuntimeMigrationLedger = () => {
251
+ const output =
252
+ runPsql(`select version || E'\\t' || name || E'\\t' || file_sha256
253
+ from rehearsal_internal.schema_migrations
254
+ order by version;`);
255
+ return output
256
+ .trim()
257
+ .split("\n")
258
+ .filter(Boolean)
259
+ .map((line) => {
260
+ const [version, name, fileSha256] = line.split("\t");
261
+ return { version, name, fileSha256 };
262
+ });
263
+ };
264
+
265
+ const verifyMigrationLedger = ({ currentFiles, baseline }) => {
266
+ const ledger = readRuntimeMigrationLedger();
267
+ const baselineCount = Object.keys(baseline.migrations).length;
268
+ if (ledger.length < baselineCount || ledger.length > currentFiles.length) {
269
+ throw new Error(
270
+ "The PostgreSQL runtime migration ledger has an unexpected length.",
271
+ );
272
+ }
273
+ for (let index = 0; index < ledger.length; index += 1) {
274
+ const applied = ledger[index];
275
+ const source = currentFiles[index];
276
+ if (
277
+ !source ||
278
+ applied.version !== source.version ||
279
+ applied.name !== source.name ||
280
+ applied.fileSha256 !== source.fileSha256
281
+ ) {
282
+ throw new Error(
283
+ `The PostgreSQL runtime migration ledger differs at ${applied.version ?? "unknown"}.`,
284
+ );
285
+ }
286
+ }
287
+ return { ledger, appliedCandidateCount: ledger.length - baselineCount };
288
+ };
289
+
290
+ const writeChunk = async (stream, chunk, completion) => {
291
+ if (stream.destroyed || stream.writableEnded) {
292
+ throw new Error("The Rehearsal restore connection closed early.");
293
+ }
294
+ if (!stream.write(chunk)) {
295
+ await Promise.race([once(stream, "drain"), completion]);
296
+ }
297
+ };
298
+
299
+ const restoreBaselineRows = async ({ baseline, paths, manifest }) => {
300
+ const child = spawn(
301
+ "docker",
302
+ [
303
+ "exec",
304
+ "--interactive",
305
+ containerName,
306
+ "psql",
307
+ "--quiet",
308
+ "--set",
309
+ "ON_ERROR_STOP=1",
310
+ "--username",
311
+ databaseUser,
312
+ "--dbname",
313
+ database,
314
+ ],
315
+ {
316
+ cwd: repositoryRoot,
317
+ env: createCleanProcessEnvironment(),
318
+ stdio: ["pipe", "ignore", "pipe"],
319
+ },
320
+ );
321
+ let stderr = "";
322
+ child.stderr.setEncoding("utf8");
323
+ child.stderr.on("data", (chunk) => {
324
+ stderr = `${stderr}${chunk}`.slice(-16_384);
325
+ });
326
+ const completion = new Promise((complete, reject) => {
327
+ child.once("error", reject);
328
+ child.once("close", complete);
329
+ });
330
+ child.stdin.on("error", () => undefined);
331
+ try {
332
+ await writeChunk(child.stdin, buildRestoreSqlPrefix(), completion);
333
+ const lines = createInterface({
334
+ input: createReadStream(paths.dataPath, { encoding: "utf8" }),
335
+ crlfDelay: Infinity,
336
+ });
337
+ for await (const line of lines) {
338
+ if (line) {
339
+ await writeChunk(
340
+ child.stdin,
341
+ `${encodeBaselineRecordForCopy(line)}\n`,
342
+ completion,
343
+ );
344
+ }
345
+ }
346
+ await writeChunk(
347
+ child.stdin,
348
+ buildRestoreSqlSuffix({
349
+ manifest,
350
+ tableCounts: baseline.tableCounts,
351
+ restoreSupabaseAuth: false,
352
+ }),
353
+ completion,
354
+ );
355
+ child.stdin.end();
356
+ const exitCode = await completion;
357
+ if (exitCode !== 0) {
358
+ throw new Error(
359
+ `The local Rehearsal restore failed. ${summarizeRestoreError(stderr)}`,
360
+ );
361
+ }
362
+ } catch (error) {
363
+ if (child.exitCode === null) child.kill("SIGTERM");
364
+ await completion.catch(() => undefined);
365
+ throw error;
366
+ }
367
+ };
368
+
369
+ const prepareProjectSchema = ({ baseline, environment }) => {
370
+ if (!runtimeAdapter?.prepareSchema) return;
371
+ const preparation = runtimeAdapter.prepareSchema({
372
+ baseline,
373
+ environment,
374
+ runSql: runPsql,
375
+ });
376
+ if (!preparation || typeof preparation.message !== "string") {
377
+ throw new Error(
378
+ "The project runtime adapter returned an invalid schema preparation result.",
379
+ );
380
+ }
381
+ console.log(preparation.message);
382
+ };
383
+
384
+ const configureProjectRuntime = ({ baseline, environment }) => {
385
+ if (!runtimeAdapter?.configureRuntime) {
386
+ return {
387
+ environmentVariables: {},
388
+ message: "Project-neutral PostgreSQL runtime is configured.",
389
+ };
390
+ }
391
+ const result = runtimeAdapter.configureRuntime({
392
+ baseline,
393
+ environment,
394
+ runSql: runPsql,
395
+ });
396
+ if (
397
+ !result ||
398
+ typeof result.message !== "string" ||
399
+ !result.environmentVariables ||
400
+ typeof result.environmentVariables !== "object" ||
401
+ Array.isArray(result.environmentVariables)
402
+ ) {
403
+ throw new Error("The project runtime adapter returned an invalid result.");
404
+ }
405
+ for (const [key, value] of Object.entries(result.environmentVariables)) {
406
+ if (
407
+ !environmentKeyPattern.test(key) ||
408
+ typeof value !== "string" ||
409
+ config.safety.blockedEnvironmentVariables.includes(key)
410
+ ) {
411
+ throw new Error(
412
+ "The project runtime adapter returned an unsafe environment variable.",
413
+ );
414
+ }
415
+ }
416
+ return result;
417
+ };
418
+
419
+ const writeRuntimeEnvironment = async ({
420
+ baseline,
421
+ environment,
422
+ projectRuntime,
423
+ }) => {
424
+ const variables = {
425
+ REHEARSAL_RUNTIME_ENVIRONMENT: "rehearsal",
426
+ REHEARSAL_BASELINE_ID: baseline.generationId,
427
+ DATABASE_URL: environment.databaseUrl,
428
+ PGHOST: environment.host,
429
+ PGPORT: String(environment.port),
430
+ PGDATABASE: environment.database,
431
+ PGUSER: environment.user,
432
+ PGPASSWORD: environment.password,
433
+ ...projectRuntime.environmentVariables,
434
+ };
435
+ await writeFile(
436
+ environmentPath,
437
+ [
438
+ "# Generated local Rehearsal runtime values. Do not commit.",
439
+ ...Object.entries(variables).map(([key, value]) => `${key}=${value}`),
440
+ "",
441
+ ].join("\n"),
442
+ { mode: 0o600 },
443
+ );
444
+ };
445
+
446
+ const readActive = async () => {
447
+ const baseline = await verifyActiveBaseline({ artifactRoot });
448
+ const paths = await resolveActiveBaselinePaths({ artifactRoot });
449
+ const manifest = readBoundRuntimeSanitizationPolicy({
450
+ bytes: await readFile(manifestPath),
451
+ expectedSha256: baseline.sanitizationPolicySha256,
452
+ });
453
+ if (Object.keys(baseline.storageAssets ?? {}).length > 0) {
454
+ throw new Error(
455
+ "The PostgreSQL target cannot restore Supabase Storage assets. Create this baseline without an assets manifest.",
456
+ );
457
+ }
458
+ if (
459
+ manifest.tables.some((table) =>
460
+ table.columns.some((column) => column.foreignKey?.schema === "auth"),
461
+ )
462
+ ) {
463
+ throw new Error(
464
+ "The PostgreSQL target cannot synthesize Supabase Auth rows. Remove auth-schema references or use the Supabase target.",
465
+ );
466
+ }
467
+ return { baseline, paths, manifest };
468
+ };
469
+
470
+ const createContainer = async (password) => {
471
+ assertDocker();
472
+ if (!commandSucceeds("docker", ["image", "inspect", image])) {
473
+ throw new Error(
474
+ `The configured local PostgreSQL image is unavailable: ${image}. Pull it explicitly, review it, then retry.`,
475
+ );
476
+ }
477
+ runCommand("docker", ["volume", "create", "--label", label, volumeName], {
478
+ capture: true,
479
+ });
480
+ runCommand(
481
+ "docker",
482
+ [
483
+ "run",
484
+ "--detach",
485
+ "--pull=never",
486
+ "--name",
487
+ containerName,
488
+ "--label",
489
+ label,
490
+ "--publish",
491
+ `127.0.0.1:${databasePort}:5432`,
492
+ "--env",
493
+ `POSTGRES_PASSWORD=${password}`,
494
+ "--env",
495
+ `POSTGRES_DB=${database}`,
496
+ "--env",
497
+ `POSTGRES_USER=${databaseUser}`,
498
+ "--volume",
499
+ `${volumeName}:/var/lib/postgresql/data`,
500
+ image,
501
+ ],
502
+ { capture: true },
503
+ );
504
+ await waitForDatabase();
505
+ };
506
+
507
+ const resetRuntime = async () => {
508
+ const active = await readActive();
509
+ await removeRuntime();
510
+ await mkdir(runtimeWorkdir, { recursive: true, mode: 0o700 });
511
+ try {
512
+ const password = randomBytes(24).toString("hex");
513
+ await createContainer(password);
514
+ initializeMigrationLedger();
515
+ const baselineMigrations = await readMigrationFileInventory(
516
+ new URL("./", pathToFileURL(`${active.paths.migrationsDirectory}/`)),
517
+ );
518
+ for (const migration of baselineMigrations) {
519
+ await applyMigration({
520
+ migration,
521
+ directory: active.paths.migrationsDirectory,
522
+ });
523
+ }
524
+ const environment = databaseEnvironment(password);
525
+ prepareProjectSchema({ baseline: active.baseline, environment });
526
+ await restoreBaselineRows({ ...active });
527
+ const projectRuntime = configureProjectRuntime({
528
+ baseline: active.baseline,
529
+ environment,
530
+ });
531
+ await writeRuntimeEnvironment({
532
+ baseline: active.baseline,
533
+ environment,
534
+ projectRuntime,
535
+ });
536
+ await writeFile(
537
+ runtimeMarkerPath,
538
+ `${JSON.stringify(
539
+ {
540
+ formatVersion: 1,
541
+ target: "postgresql",
542
+ generationId: active.baseline.generationId,
543
+ dataSha256: active.baseline.files["sanitized-data.ndjson"].sha256,
544
+ image,
545
+ databasePassword: password,
546
+ },
547
+ null,
548
+ "\t",
549
+ )}\n`,
550
+ { mode: 0o600 },
551
+ );
552
+ console.log(
553
+ `Restored Rehearsal baseline ${active.baseline.generationId}: ${formatCount(active.baseline.rowCount, "row")} across ${formatCount(Object.keys(active.baseline.tableCounts).length, "table")}.`,
554
+ );
555
+ console.log(projectRuntime.message);
556
+ return environment;
557
+ } catch (error) {
558
+ await removeRuntime().catch(() => undefined);
559
+ throw error;
560
+ }
561
+ };
562
+
563
+ const startRuntime = async () => {
564
+ const { baseline } = await readActive();
565
+ if (!(await pathExists(runtimeMarkerPath))) return resetRuntime();
566
+ const marker = JSON.parse(await readFile(runtimeMarkerPath, "utf8"));
567
+ if (
568
+ marker.target !== "postgresql" ||
569
+ marker.generationId !== baseline.generationId ||
570
+ marker.dataSha256 !== baseline.files["sanitized-data.ndjson"].sha256 ||
571
+ marker.image !== image ||
572
+ typeof marker.databasePassword !== "string" ||
573
+ marker.databasePassword.length < 32
574
+ ) {
575
+ throw new Error(
576
+ "The local Rehearsal runtime does not match the active baseline; reset it before continuing.",
577
+ );
578
+ }
579
+ if (!assertOwnedResource("container", containerName)) {
580
+ return resetRuntime();
581
+ }
582
+ if (
583
+ !commandSucceeds("docker", [
584
+ "inspect",
585
+ "--format",
586
+ "{{.State.Running}}",
587
+ containerName,
588
+ ])
589
+ ) {
590
+ throw new Error("The disposable PostgreSQL container cannot be inspected.");
591
+ }
592
+ const running = runCommand(
593
+ "docker",
594
+ ["inspect", "--format", "{{.State.Running}}", containerName],
595
+ { capture: true },
596
+ ).stdout.trim();
597
+ if (running !== "true") {
598
+ runCommand("docker", ["start", containerName], { capture: true });
599
+ }
600
+ await waitForDatabase();
601
+ const environment = databaseEnvironment(marker.databasePassword);
602
+ const projectRuntime = configureProjectRuntime({ baseline, environment });
603
+ await writeRuntimeEnvironment({ baseline, environment, projectRuntime });
604
+ return environment;
605
+ };
606
+
607
+ const readCandidateReceipt = async (baseline) => {
608
+ const currentFiles = await readMigrationFileInventory(applicationMigrations);
609
+ return {
610
+ currentFiles,
611
+ ...createCandidateMigrationReceipt({
612
+ baselineManifest: baseline,
613
+ currentFiles,
614
+ }),
615
+ };
616
+ };
617
+
618
+ const verifyRuntime = async () => {
619
+ const { baseline } = await readActive();
620
+ const environment = await startRuntime();
621
+ const receipt = await readCandidateReceipt(baseline);
622
+ const migrationVerification = verifyMigrationLedger({
623
+ currentFiles: receipt.currentFiles,
624
+ baseline,
625
+ });
626
+ let projectVerification;
627
+ if (runtimeAdapter?.verifyRuntime) {
628
+ projectVerification = runtimeAdapter.verifyRuntime({
629
+ baseline,
630
+ environment,
631
+ runSql: runPsql,
632
+ });
633
+ if (
634
+ !projectVerification ||
635
+ typeof projectVerification.message !== "string"
636
+ ) {
637
+ throw new Error(
638
+ "The project runtime adapter returned an invalid verification result.",
639
+ );
640
+ }
641
+ }
642
+ console.log(
643
+ `Verified Rehearsal runtime ${baseline.generationId}: immutable baseline and migration history are valid with ${formatCount(migrationVerification.appliedCandidateCount, "applied candidate migration")}. Runtime rows may differ from the baseline until the next reset.`,
644
+ );
645
+ if (projectVerification) console.log(projectVerification.message);
646
+ return { baseline, receipt, migrationVerification };
647
+ };
648
+
649
+ const migrateRuntime = async () => {
650
+ const { baseline } = await readActive();
651
+ await startRuntime();
652
+ const receipt = await readCandidateReceipt(baseline);
653
+ const confirmation = process.argv
654
+ .find((argument) => argument.startsWith("--confirm-candidates="))
655
+ ?.slice("--confirm-candidates=".length);
656
+ if (confirmation !== receipt.candidateSha256) {
657
+ throw new Error(
658
+ "Candidate migration confirmation is missing or does not match the exact SHA-256.",
659
+ );
660
+ }
661
+ try {
662
+ const ledger = readRuntimeMigrationLedger();
663
+ for (const migration of receipt.candidates.slice(
664
+ Math.max(0, ledger.length - Object.keys(baseline.migrations).length),
665
+ )) {
666
+ await applyMigration({
667
+ migration,
668
+ directory: configuredPaths.migrationDirectory,
669
+ });
670
+ }
671
+ const verification = verifyMigrationLedger({
672
+ currentFiles: receipt.currentFiles,
673
+ baseline,
674
+ });
675
+ if (verification.appliedCandidateCount !== receipt.candidates.length) {
676
+ throw new Error(
677
+ "Applied PostgreSQL migration history does not match the confirmed candidate suffix.",
678
+ );
679
+ }
680
+ await writeFile(
681
+ runtimeCandidateReceiptPath,
682
+ `${JSON.stringify(
683
+ {
684
+ formatVersion: 1,
685
+ baselineGenerationId: baseline.generationId,
686
+ candidateSha256: receipt.candidateSha256,
687
+ candidates: receipt.candidates.map((candidate) => ({
688
+ filename: candidate.filename,
689
+ sha256: candidate.fileSha256,
690
+ })),
691
+ },
692
+ null,
693
+ "\t",
694
+ )}\n`,
695
+ { mode: 0o600 },
696
+ );
697
+ console.log(
698
+ receipt.candidates.length
699
+ ? `Applied ${formatCount(receipt.candidates.length, "confirmed candidate migration")} to the disposable PostgreSQL runtime.`
700
+ : `No candidate migrations follow baseline ${baseline.migrationCutoff}.`,
701
+ );
702
+ return receipt;
703
+ } catch (error) {
704
+ await removeRuntime().catch(() => undefined);
705
+ throw error;
706
+ }
707
+ };
708
+
709
+ switch (action) {
710
+ case "reset":
711
+ await resetRuntime();
712
+ break;
713
+ case "start": {
714
+ const environment = await startRuntime();
715
+ console.log(
716
+ `Local Rehearsal PostgreSQL: ${environment.host}:${environment.port}/${environment.database}`,
717
+ );
718
+ break;
719
+ }
720
+ case "status": {
721
+ const baseline = await verifyActiveBaseline({ artifactRoot });
722
+ const receipt = await readCandidateReceipt(baseline);
723
+ const running =
724
+ commandSucceeds("docker", ["info"]) &&
725
+ resourceExists("container", containerName) &&
726
+ runCommand(
727
+ "docker",
728
+ ["inspect", "--format", "{{.State.Running}}", containerName],
729
+ { capture: true },
730
+ ).stdout.trim() === "true";
731
+ console.log(
732
+ `Local Rehearsal PostgreSQL: ${running ? `running on 127.0.0.1:${databasePort}` : "stopped"}`,
733
+ );
734
+ console.log(`Active sanitized baseline: ${baseline.generationId}`);
735
+ console.log(
736
+ `Candidate migrations (${receipt.candidateSha256}): ${receipt.candidates.map((candidate) => candidate.filename).join(", ") || "none"}`,
737
+ );
738
+ break;
739
+ }
740
+ case "candidates": {
741
+ const baseline = await verifyActiveBaseline({ artifactRoot });
742
+ const receipt = await readCandidateReceipt(baseline);
743
+ console.log(
744
+ `Candidate migrations (${receipt.candidateSha256}): ${receipt.candidates.map((candidate) => candidate.filename).join(", ") || "none"}`,
745
+ );
746
+ break;
747
+ }
748
+ case "migrate":
749
+ await migrateRuntime();
750
+ break;
751
+ case "verify":
752
+ await verifyRuntime();
753
+ break;
754
+ case "run":
755
+ await resetRuntime();
756
+ await migrateRuntime();
757
+ await verifyRuntime();
758
+ break;
759
+ case "stop":
760
+ if (
761
+ commandSucceeds("docker", ["info"]) &&
762
+ assertOwnedResource("container", containerName)
763
+ ) {
764
+ runCommand("docker", ["stop", containerName], { capture: true });
765
+ }
766
+ break;
767
+ case "discard":
768
+ await removeRuntime();
769
+ break;
770
+ default:
771
+ throw new Error(`Unknown Rehearsal database action: ${action}`);
772
+ }