@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.
Files changed (35) hide show
  1. package/BENCHMARKS.md +61 -0
  2. package/CHANGELOG.md +59 -0
  3. package/COMPATIBILITY.md +22 -0
  4. package/LICENSE +21 -0
  5. package/README.md +375 -0
  6. package/SECURITY.md +19 -0
  7. package/SUPPORT.md +15 -0
  8. package/docs/adapters.md +23 -0
  9. package/docs/baselines.md +33 -0
  10. package/docs/commands.md +35 -0
  11. package/docs/configuration.md +98 -0
  12. package/docs/getting-started.md +247 -0
  13. package/docs/glossary.md +33 -0
  14. package/docs/production-source.md +33 -0
  15. package/docs/releasing.md +79 -0
  16. package/docs/sanitization.md +69 -0
  17. package/docs/security-model.md +40 -0
  18. package/docs/troubleshooting.md +50 -0
  19. package/docs/tutorial.md +96 -0
  20. package/package.json +77 -0
  21. package/scripts/lib/environment/local_supabase.mjs +197 -0
  22. package/scripts/lib/rehearsal/baseline_artifact.mjs +536 -0
  23. package/scripts/lib/rehearsal/baseline_builder.mjs +155 -0
  24. package/scripts/lib/rehearsal/configuration.d.mts +85 -0
  25. package/scripts/lib/rehearsal/configuration.mjs +559 -0
  26. package/scripts/lib/rehearsal/diagnostics.mjs +193 -0
  27. package/scripts/lib/rehearsal/migration_history.mjs +220 -0
  28. package/scripts/lib/rehearsal/plan.mjs +587 -0
  29. package/scripts/lib/rehearsal/process_environment.mjs +64 -0
  30. package/scripts/lib/rehearsal/runtime_restore.mjs +310 -0
  31. package/scripts/lib/rehearsal/sanitization_policy.mjs +169 -0
  32. package/scripts/lib/rehearsal/schema_snapshot.mjs +113 -0
  33. package/scripts/lib/rehearsal/service_environment.mjs +82 -0
  34. package/scripts/operations/database/manage_rehearsal_database.mjs +784 -0
  35. package/scripts/operations/rehearsal/rehearsal_cli.mjs +565 -0
@@ -0,0 +1,565 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Purpose: Run the versioned, project-configured Rehearsal developer CLI for
4
+ * initialization, readiness, planning, inspection, and verified local execution.
5
+ * Run: `npm run rehearsal -- doctor` or `npm run rehearsal -- explain`.
6
+ */
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import { writeFile } from "node:fs/promises";
10
+ import { join, relative } from "node:path";
11
+ import { performance } from "node:perf_hooks";
12
+ import { fileURLToPath } from "node:url";
13
+ import {
14
+ RehearsalError,
15
+ createRehearsalResult,
16
+ normalizeRehearsalError,
17
+ redactDiagnosticValue,
18
+ renderHumanError,
19
+ serializeRehearsalError,
20
+ } from "../../lib/rehearsal/diagnostics.mjs";
21
+ import {
22
+ inspectDetectedProject,
23
+ findRehearsalConfigPath,
24
+ loadRehearsalConfig,
25
+ renderDetectedConfig,
26
+ } from "../../lib/rehearsal/configuration.mjs";
27
+ import {
28
+ buildRehearsalPlan,
29
+ inspectRehearsalBaseline,
30
+ inspectRehearsalMigrations,
31
+ runRehearsalDoctor,
32
+ } from "../../lib/rehearsal/plan.mjs";
33
+ import { createSyntheticBaselineFromFiles } from "../../lib/rehearsal/baseline_builder.mjs";
34
+
35
+ const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
36
+ const projectRoot = process.cwd();
37
+ const commandStartedAt = new Date();
38
+ const commandStartedMs = performance.now();
39
+ const managerPath = join(
40
+ packageRoot,
41
+ "scripts/operations/database/manage_rehearsal_database.mjs",
42
+ );
43
+
44
+ const parseArguments = (arguments_) => {
45
+ const flags = {
46
+ json: false,
47
+ help: false,
48
+ verbosity: "normal",
49
+ dryRun: false,
50
+ write: false,
51
+ configPath: undefined,
52
+ confirmation: undefined,
53
+ recordsPath: undefined,
54
+ ledgerPath: undefined,
55
+ assetsPath: undefined,
56
+ };
57
+ const positionals = [];
58
+ for (const argument of arguments_) {
59
+ if (argument === "--json") flags.json = true;
60
+ else if (argument === "--help" || argument === "-h") flags.help = true;
61
+ else if (argument === "--verbose") flags.verbosity = "verbose";
62
+ else if (argument === "--debug") flags.verbosity = "debug";
63
+ else if (argument === "--dry-run") flags.dryRun = true;
64
+ else if (argument === "--write") flags.write = true;
65
+ else if (argument.startsWith("--config=")) {
66
+ flags.configPath = argument.slice("--config=".length);
67
+ } else if (argument.startsWith("--confirm-candidates=")) {
68
+ flags.confirmation = argument.slice("--confirm-candidates=".length);
69
+ } else if (argument.startsWith("--records=")) {
70
+ flags.recordsPath = argument.slice("--records=".length);
71
+ } else if (argument.startsWith("--ledger=")) {
72
+ flags.ledgerPath = argument.slice("--ledger=".length);
73
+ } else if (argument.startsWith("--assets=")) {
74
+ flags.assetsPath = argument.slice("--assets=".length);
75
+ } else if (argument.startsWith("--")) {
76
+ throw new Error(`Unknown Rehearsal option: ${argument}.`);
77
+ } else positionals.push(argument);
78
+ }
79
+ return { flags, positionals };
80
+ };
81
+
82
+ const renderPlan = (plan, verbosity) => {
83
+ const candidates = plan.migrations.candidates.length
84
+ ? plan.migrations.candidates
85
+ .map((entry) => ` → ${entry.filename} (${entry.sha256.slice(0, 12)})`)
86
+ .join("\n")
87
+ : " ✓ No candidate migrations";
88
+ const verbose =
89
+ verbosity === "normal"
90
+ ? []
91
+ : [
92
+ "",
93
+ "Safety barriers",
94
+ ...plan.environment.barriers.map((barrier) => ` ✓ ${barrier}`),
95
+ ` ✓ Candidate digest: ${plan.migrations.candidateSha256}`,
96
+ ];
97
+ return [
98
+ "REHEARSAL PLAN",
99
+ "",
100
+ "Environment",
101
+ ` ✓ ${plan.environment.kind}`,
102
+ ` ✓ Hosted access ${plan.environment.hostedAccess}`,
103
+ ` ✓ Application/provider-data egress ${plan.environment.outboundNetwork}`,
104
+ ...(plan.environment.authenticationProviders.length
105
+ ? [
106
+ ` ✓ External identity providers ${plan.environment.authenticationProviders.join(", ")}`,
107
+ ]
108
+ : []),
109
+ "",
110
+ "Baseline",
111
+ ` ✓ ${plan.baseline.generationId}`,
112
+ ` ✓ ${plan.baseline.tableCount} tables; ${plan.baseline.rowCount} rows`,
113
+ ` ✓ ${plan.baseline.verification}`,
114
+ "",
115
+ "Migrations",
116
+ ` ✓ ${plan.migrations.representedCount} represented by baseline`,
117
+ candidates,
118
+ "",
119
+ "Execution plan",
120
+ ...plan.execution.map((step, index) => ` ${index + 1}. ${step}`),
121
+ ...verbose,
122
+ "",
123
+ plan.guarantee,
124
+ ].join("\n");
125
+ };
126
+
127
+ const renderDoctor = (doctor, verbosity) => {
128
+ const lines = doctor.checks.map((check) => {
129
+ const marker = check.status === "pass" ? "✓" : "✗";
130
+ const detail =
131
+ verbosity === "normal" && check.status === "pass"
132
+ ? ""
133
+ : ` — ${check.detail}`;
134
+ return `${marker} ${check.label}${detail}`;
135
+ });
136
+ for (const check of doctor.checks.filter(
137
+ (entry) => entry.status === "fail",
138
+ )) {
139
+ if (check.remediation) lines.push(` Try: ${check.remediation}`);
140
+ }
141
+ if (doctor.ambientHostedVariables.presentButQuarantined.length) {
142
+ lines.push(
143
+ `! ${doctor.ambientHostedVariables.presentButQuarantined.length} ambient hosted credential variables detected and quarantined.`,
144
+ );
145
+ }
146
+ return [...lines, "", doctor.state].join("\n");
147
+ };
148
+
149
+ const renderBaseline = (baseline) =>
150
+ [
151
+ "REHEARSAL BASELINE",
152
+ `Rehearsal: ${baseline.rehearsalVersion}`,
153
+ `Generation: ${baseline.generationId}`,
154
+ `Created: ${baseline.createdAt ?? "unknown"}`,
155
+ `Format: ${baseline.formatVersion}`,
156
+ `Tables: ${baseline.tableCount}`,
157
+ `Rows: ${baseline.rowCount}`,
158
+ `Migrations: ${baseline.migrationCount} through ${baseline.migrationCutoff}`,
159
+ `Data SHA-256: ${baseline.dataSha256}`,
160
+ `Sanitization policy SHA-256: ${baseline.sanitizationPolicySha256}`,
161
+ `Verification: ${baseline.verification}`,
162
+ "Source rows and sensitive values are never printed by inspect.",
163
+ ].join("\n");
164
+
165
+ const renderMigrations = (inspection, verbosity) => {
166
+ const entries = inspection.migrations.map((migration) => {
167
+ const digest = verbosity === "normal" ? "" : ` (${migration.sha256})`;
168
+ return `${migration.status.padEnd(28)} ${migration.filename}${digest}`;
169
+ });
170
+ return [
171
+ "REHEARSAL MIGRATIONS",
172
+ `Baseline: ${inspection.baselineGenerationId} through ${inspection.baselineCutoff}`,
173
+ `Candidate digest: ${inspection.candidateSha256}`,
174
+ "",
175
+ ...entries,
176
+ ].join("\n");
177
+ };
178
+
179
+ const candidateSummary = (inspection) => {
180
+ const candidates = inspection.migrations.filter(
181
+ (migration) => migration.status === "candidate",
182
+ );
183
+ return {
184
+ baselineGenerationId: inspection.baselineGenerationId,
185
+ candidateSha256: inspection.candidateSha256,
186
+ candidateCount: candidates.length,
187
+ candidates,
188
+ };
189
+ };
190
+
191
+ const renderCandidates = (summary, verbosity) =>
192
+ [
193
+ "REHEARSAL CANDIDATES",
194
+ `Baseline: ${summary.baselineGenerationId}`,
195
+ `Candidate digest: ${summary.candidateSha256}`,
196
+ `Pending: ${summary.candidateCount}`,
197
+ ...(summary.candidates.length
198
+ ? summary.candidates.map((migration) =>
199
+ verbosity === "normal"
200
+ ? ` → ${migration.filename}`
201
+ : ` → ${migration.filename} (${migration.sha256})`,
202
+ )
203
+ : [" ✓ No candidate migrations"]),
204
+ ].join("\n");
205
+
206
+ const emit = ({ command, data, flags, render, status = "success" }) => {
207
+ const result = createRehearsalResult({
208
+ command,
209
+ status,
210
+ data,
211
+ startedAt: commandStartedAt,
212
+ durationMs: Math.round((performance.now() - commandStartedMs) * 100) / 100,
213
+ });
214
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
215
+ else console.log(render(data, flags.verbosity));
216
+ };
217
+
218
+ const runManager = ({ action, flags }) => {
219
+ const args = [managerPath, action];
220
+ if (flags.confirmation) {
221
+ args.push(`--confirm-candidates=${flags.confirmation}`);
222
+ }
223
+ const environment = Object.fromEntries(
224
+ [
225
+ "CI",
226
+ "COLORTERM",
227
+ "FORCE_COLOR",
228
+ "HOME",
229
+ "LANG",
230
+ "LC_ALL",
231
+ "NO_COLOR",
232
+ "PATH",
233
+ "SHELL",
234
+ "TERM",
235
+ "TMPDIR",
236
+ "USER",
237
+ ].flatMap((key) =>
238
+ process.env[key] === undefined ? [] : [[key, process.env[key]]],
239
+ ),
240
+ );
241
+ const result = spawnSync(process.execPath, args, {
242
+ cwd: projectRoot,
243
+ encoding: "utf8",
244
+ env: environment,
245
+ stdio: ["inherit", "pipe", "pipe"],
246
+ maxBuffer: 16 * 1024 * 1024,
247
+ });
248
+ if (result.error) throw result.error;
249
+ if (result.status !== 0) {
250
+ const completeOutput = [
251
+ String(result.stdout ?? ""),
252
+ String(result.stderr ?? ""),
253
+ ]
254
+ .filter(Boolean)
255
+ .join("\n")
256
+ .trim();
257
+ const inferred = normalizeRehearsalError(
258
+ new Error(completeOutput || `Rehearsal ${action} failed.`),
259
+ );
260
+ const conciseOutput = completeOutput
261
+ .replaceAll(String.fromCodePoint(27), "")
262
+ .split("\n")
263
+ .map((line) => line.trim())
264
+ .filter(Boolean)
265
+ .slice(-8)
266
+ .join("\n")
267
+ .slice(-2_000);
268
+ throw new RehearsalError({
269
+ category: inferred.category,
270
+ code: `MANAGER_${action.toUpperCase()}_FAILED`,
271
+ message: `The local Rehearsal ${action} operation did not complete.`,
272
+ expected: "the isolated runtime operation to finish and verify",
273
+ actual: conciseOutput || `exit ${result.status}`,
274
+ context: { action },
275
+ refused: "The runtime was not reported as trusted.",
276
+ suggestions: [
277
+ "Review the concise failure above, then rerun rehearsal doctor before retrying.",
278
+ "Use --debug only when the safe diagnostic detail is needed.",
279
+ ],
280
+ cause: new Error(completeOutput || `exit ${result.status}`),
281
+ });
282
+ }
283
+ return {
284
+ action,
285
+ output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
286
+ };
287
+ };
288
+
289
+ const parseCommand = (source) => {
290
+ const values = [];
291
+ let current = "";
292
+ let quote = null;
293
+ for (const character of source) {
294
+ if (quote) {
295
+ if (character === quote) quote = null;
296
+ else current += character;
297
+ } else if (character === '"' || character === "'") quote = character;
298
+ else if (/\s/u.test(character)) {
299
+ if (current) values.push(current);
300
+ current = "";
301
+ } else current += character;
302
+ }
303
+ if (quote)
304
+ throw new Error("Application proof command contains an unmatched quote.");
305
+ if (current) values.push(current);
306
+ if (values.length === 0)
307
+ throw new Error("Application proof command is empty.");
308
+ return values;
309
+ };
310
+
311
+ const runApplicationProof = async (planOptions) => {
312
+ const { config, projectRoot: configuredRoot } =
313
+ await loadRehearsalConfig(planOptions);
314
+ const [command, ...args] = parseCommand(config.application.proofCommand);
315
+ const environment = Object.fromEntries(
316
+ [
317
+ "CI",
318
+ "COLORTERM",
319
+ "FORCE_COLOR",
320
+ "HOME",
321
+ "LANG",
322
+ "LC_ALL",
323
+ "NO_COLOR",
324
+ "PATH",
325
+ "SHELL",
326
+ "TERM",
327
+ "TMPDIR",
328
+ "USER",
329
+ ].flatMap((key) =>
330
+ process.env[key] === undefined ? [] : [[key, process.env[key]]],
331
+ ),
332
+ );
333
+ const result = spawnSync(command, args, {
334
+ cwd: configuredRoot,
335
+ encoding: "utf8",
336
+ env: environment,
337
+ stdio: ["ignore", "pipe", "pipe"],
338
+ maxBuffer: 16 * 1024 * 1024,
339
+ });
340
+ if (result.error || result.status !== 0) {
341
+ throw new RehearsalError({
342
+ category: "application_proof_failure",
343
+ code: "APPLICATION_PROOF_FAILED",
344
+ message: "The configured application proof did not pass.",
345
+ expected: `${config.application.proofCommand} exits successfully`,
346
+ actual: result.error?.message ?? result.stderr ?? `exit ${result.status}`,
347
+ context: { project: config.project.name },
348
+ refused:
349
+ "Rehearsal did not report the migrated runtime as fully verified.",
350
+ suggestions: [
351
+ "Run the proof command directly in the same project and correct the failure.",
352
+ ],
353
+ cause: result.error,
354
+ });
355
+ }
356
+ return {
357
+ command: config.application.proofCommand,
358
+ output: redactDiagnosticValue(String(result.stdout ?? "").trim()),
359
+ };
360
+ };
361
+
362
+ const runInit = async ({ flags }) => {
363
+ const detected = await inspectDetectedProject({ projectRoot });
364
+ const source = renderDetectedConfig(detected);
365
+ const destination = join(projectRoot, "rehearsal.config.ts");
366
+ let existingPath = null;
367
+ try {
368
+ existingPath = await findRehearsalConfigPath({ projectRoot });
369
+ } catch (error) {
370
+ if (
371
+ !String(error?.message ?? error).startsWith("No Rehearsal configuration")
372
+ ) {
373
+ throw error;
374
+ }
375
+ }
376
+ if (existingPath) {
377
+ if (flags.write) {
378
+ throw new Error(
379
+ `A Rehearsal configuration already exists at ${relative(projectRoot, existingPath)}; Rehearsal will not overwrite it.`,
380
+ );
381
+ }
382
+ return {
383
+ mode: "existing",
384
+ destination: relative(projectRoot, existingPath),
385
+ detected,
386
+ source: "",
387
+ nextAction:
388
+ "Review the existing configuration, then run rehearsal doctor.",
389
+ };
390
+ }
391
+ if (flags.write)
392
+ await writeFile(destination, source, { flag: "wx", mode: 0o600 });
393
+ return {
394
+ mode: flags.write ? "written" : "preview",
395
+ destination: relative(projectRoot, destination),
396
+ detected,
397
+ source,
398
+ nextAction: flags.write
399
+ ? "Review the generated safety settings and run rehearsal doctor."
400
+ : "Review this preview, then rerun rehearsal init --write to create it.",
401
+ };
402
+ };
403
+
404
+ const renderInit = (result) =>
405
+ [
406
+ `REHEARSAL INIT — ${result.mode.toUpperCase()}`,
407
+ `Destination: ${result.destination}`,
408
+ `Detected package manager: ${result.detected.packageManager}`,
409
+ `Detected Supabase config: ${result.detected.hasSupabaseConfig ? "yes" : "no"}`,
410
+ `Detected migrations: ${result.detected.hasMigrations ? "yes" : "no"}`,
411
+ ...(result.source ? ["", result.source] : []),
412
+ result.nextAction,
413
+ ].join("\n");
414
+
415
+ const usage = () => `Usage: rehearsal <command> [options]
416
+
417
+ Commands:
418
+ init [--write] Preview or explicitly write safe starter config
419
+ baseline create --records= --ledger= [--assets=] Create a baseline from safe local inputs
420
+ doctor Check whether Rehearsal is safe and ready
421
+ explain Show the immutable execution plan
422
+ run --dry-run Alias the exact explain plan without mutations
423
+ run --confirm-candidates= Execute reset, migration, and verification locally
424
+ candidates Show the exact pending migration digest
425
+ inspect baseline Show verified baseline provenance
426
+ inspect migrations Classify represented, applied, and candidate migrations
427
+ start Start an existing verified local runtime
428
+ migrate --confirm-candidates= Apply the exact candidate suffix without resetting
429
+ reset Restore and verify the immutable local baseline
430
+ status Report the disposable local runtime state
431
+ stop Stop only this project's local runtime
432
+ discard Remove only this project's disposable runtime
433
+ verify Verify the current local Rehearsal runtime
434
+
435
+ Options: --json --verbose --debug --config=<path>`;
436
+
437
+ const main = async () => {
438
+ const { flags, positionals } = parseArguments(process.argv.slice(2));
439
+ const command = positionals.join(" ") || "help";
440
+ const planOptions = {
441
+ projectRoot,
442
+ configPath: flags.configPath,
443
+ };
444
+ if (command === "help" || flags.help) {
445
+ console.log(usage());
446
+ return;
447
+ }
448
+ if (command === "init") {
449
+ const data = await runInit({ flags });
450
+ emit({ command, data, flags, render: renderInit });
451
+ return;
452
+ }
453
+ if (command === "baseline create") {
454
+ const data = await createSyntheticBaselineFromFiles({
455
+ ...planOptions,
456
+ recordsPath: flags.recordsPath,
457
+ ledgerPath: flags.ledgerPath,
458
+ assetsPath: flags.assetsPath,
459
+ });
460
+ emit({
461
+ command,
462
+ data,
463
+ flags,
464
+ render: (baseline) =>
465
+ `Activated synthetic baseline ${baseline.generationId}: ${baseline.rowCount} rows across ${baseline.tableCount} tables; ${baseline.migrationCount} migrations through ${baseline.migrationCutoff}.`,
466
+ });
467
+ return;
468
+ }
469
+ if (command === "doctor") {
470
+ const data = await runRehearsalDoctor(planOptions);
471
+ emit({
472
+ command,
473
+ data,
474
+ flags,
475
+ render: renderDoctor,
476
+ status: data.state === "READY" ? "success" : "not_ready",
477
+ });
478
+ if (data.state !== "READY") process.exitCode = 1;
479
+ return;
480
+ }
481
+ if (command === "explain" || (command === "run" && flags.dryRun)) {
482
+ const data = await buildRehearsalPlan(planOptions);
483
+ emit({ command, data, flags, render: renderPlan });
484
+ return;
485
+ }
486
+ if (command === "inspect baseline") {
487
+ const data = await inspectRehearsalBaseline(planOptions);
488
+ emit({ command, data, flags, render: renderBaseline });
489
+ return;
490
+ }
491
+ if (command === "inspect migrations") {
492
+ const data = await inspectRehearsalMigrations(planOptions);
493
+ emit({ command, data, flags, render: renderMigrations });
494
+ return;
495
+ }
496
+ if (command === "candidates") {
497
+ const data = candidateSummary(
498
+ await inspectRehearsalMigrations(planOptions),
499
+ );
500
+ emit({ command, data, flags, render: renderCandidates });
501
+ return;
502
+ }
503
+ if (
504
+ [
505
+ "run",
506
+ "start",
507
+ "migrate",
508
+ "reset",
509
+ "status",
510
+ "stop",
511
+ "discard",
512
+ "verify",
513
+ ].includes(command)
514
+ ) {
515
+ if (flags.dryRun && command !== "run") {
516
+ throw new Error("--dry-run is supported only by rehearsal run.");
517
+ }
518
+ const runtime = runManager({ action: command, flags });
519
+ const data =
520
+ command === "run"
521
+ ? {
522
+ runtime,
523
+ applicationProof: await runApplicationProof(planOptions),
524
+ }
525
+ : runtime;
526
+ emit({
527
+ command,
528
+ data,
529
+ flags,
530
+ render: (value) => {
531
+ const runtimeResult = value.runtime ?? value;
532
+ return [
533
+ runtimeResult.output ||
534
+ `Rehearsal ${runtimeResult.action} completed.`,
535
+ value.applicationProof
536
+ ? `Application proof passed: ${value.applicationProof.command}`
537
+ : null,
538
+ ]
539
+ .filter(Boolean)
540
+ .join("\n");
541
+ },
542
+ });
543
+ return;
544
+ }
545
+ throw new Error(`Unknown Rehearsal command: ${command}.\n\n${usage()}`);
546
+ };
547
+
548
+ try {
549
+ await main();
550
+ } catch (error) {
551
+ const failure = normalizeRehearsalError(error, {
552
+ expected: "a safe, versioned, local-only Rehearsal operation",
553
+ actual: String(error?.message ?? error),
554
+ refused: "No further Rehearsal action was performed.",
555
+ suggestions: ["Run rehearsal doctor for actionable readiness checks."],
556
+ });
557
+ const wantsJson = process.argv.includes("--json");
558
+ const debug = process.argv.includes("--debug");
559
+ if (wantsJson)
560
+ console.log(
561
+ JSON.stringify(serializeRehearsalError(failure, { debug }), null, 2),
562
+ );
563
+ else console.error(renderHumanError(failure, { debug }));
564
+ process.exitCode = failure.exitCode;
565
+ }