@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.14

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 (38) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/COMPATIBILITY.md +6 -0
  3. package/README.md +5 -3
  4. package/docs/commands.md +15 -11
  5. package/docs/configuration.md +40 -5
  6. package/docs/getting-started.md +32 -3
  7. package/docs/production-source.md +175 -17
  8. package/docs/releasing.md +5 -3
  9. package/docs/roadmap.md +3 -2
  10. package/docs/sanitization.md +479 -12
  11. package/docs/security-model.md +8 -0
  12. package/docs/standalone-workflow.md +34 -13
  13. package/docs/troubleshooting.md +17 -6
  14. package/docs/tutorial.md +7 -1
  15. package/package.json +2 -2
  16. package/scripts/install/create_root_config.mjs +5 -22
  17. package/src/baseline/artifact.mjs +27 -4
  18. package/src/baseline/privacy_engine.mjs +865 -207
  19. package/src/baseline/privacy_structured_json.mjs +554 -0
  20. package/src/cli/arguments.mjs +3 -0
  21. package/src/cli/guided.mjs +8 -6
  22. package/src/cli/rehearsal.mjs +23 -3
  23. package/src/cli/renderers.mjs +29 -4
  24. package/src/cli/runtime_commands.mjs +92 -9
  25. package/src/cli/source_commands.mjs +37 -8
  26. package/src/identity/claim.mjs +23 -21
  27. package/src/project/install_scaffold.mjs +35 -1
  28. package/src/project/setup.mjs +14 -24
  29. package/src/runtime/plan.mjs +7 -3
  30. package/src/runtime/restore.mjs +13 -5
  31. package/src/shared/diagnostics.mjs +7 -0
  32. package/src/shared/node_runtime.mjs +31 -0
  33. package/src/source/access.mjs +271 -51
  34. package/src/source/asset_transfer.mjs +156 -74
  35. package/src/source/baseline.mjs +42 -6
  36. package/src/source/external_reader.mjs +368 -0
  37. package/src/source/postgresql_access.mjs +213 -55
  38. package/src/source/privacy_paths.mjs +38 -0
@@ -270,6 +270,18 @@ export const renderSetup = (result) => {
270
270
  return [
271
271
  `REHEARSAL SETUP — ${result.mode.toUpperCase()}`,
272
272
  `Project: ${result.project}`,
273
+ `Configuration: ${result.configurationPath} (${
274
+ result.configurationAction === "create"
275
+ ? result.mode === "written"
276
+ ? "created"
277
+ : "will create"
278
+ : "existing"
279
+ })`,
280
+ ...(result.rootConfigGenerationSkipped
281
+ ? [
282
+ "Root config: not created because the existing supported configuration remains active.",
283
+ ]
284
+ : []),
273
285
  `Database: ${result.target === "postgresql" ? "PostgreSQL" : "Supabase"}`,
274
286
  `Runtime ID: ${result.projectId}`,
275
287
  `Application: ${result.applicationUrl}`,
@@ -507,7 +519,10 @@ export const renderSourceAccessPlan = (plan) =>
507
519
  "SOURCE ACCESS — PREVIEW",
508
520
  "",
509
521
  `Target fingerprint: ${plan.review.targetFingerprint}`,
510
- `Temporary reader: ${plan.review.reader.role} (${plan.review.reader.validForMinutes} minutes)`,
522
+ plan.review.reader.mode === "managed"
523
+ ? `Managed temporary reader: ${plan.review.reader.role} (${plan.review.reader.validForMinutes} minutes)`
524
+ : `Externally managed reader: ${plan.review.reader.role} (maximum ${plan.review.reader.maximumValidForMinutes} minutes remaining)`,
525
+ `Source changes by Rehearsal: ${plan.review.reader.sourceChanges ? "export views and temporary roles" : "none"}`,
511
526
  `Export schema: ${plan.review.exportSchema}`,
512
527
  ...plan.review.relations.map(
513
528
  (relation) =>
@@ -516,7 +531,10 @@ export const renderSourceAccessPlan = (plan) =>
516
531
  ...(plan.review.assets.length
517
532
  ? plan.review.assets.map(
518
533
  (asset) =>
519
- ` → Storage ${asset.bucket}/${asset.prefix} (${asset.rights})`,
534
+ ` → Storage ${asset.bucket}/${
535
+ asset.prefix ??
536
+ `[private prefix from ${asset.prefixEnvironmentVariable}]`
537
+ } (${asset.rights})`,
520
538
  )
521
539
  : [" ○ No source assets declared"]),
522
540
  "",
@@ -574,7 +592,12 @@ export const renderSourceRetirement = ({ mode, plan }) =>
574
592
  `Target fingerprint: ${plan.review.targetFingerprint}`,
575
593
  `Reader: ${plan.review.readerRole}`,
576
594
  `Export schema: ${plan.review.exportSchema}`,
577
- ...plan.review.views.map((view) => ` → remove view ${view}`),
595
+ ...(plan.review.accessMode === "managed"
596
+ ? plan.review.views.map((view) => ` → remove view ${view}`)
597
+ : [
598
+ " → remove only Rehearsal's local credential and receipt",
599
+ " ✓ preserve the provider-owned reader and export views",
600
+ ]),
578
601
  "",
579
602
  ...(mode === "preview"
580
603
  ? [
@@ -582,7 +605,9 @@ export const renderSourceRetirement = ({ mode, plan }) =>
582
605
  `Exact confirmation: --confirm-source-retirement=${plan.digest}`,
583
606
  ]
584
607
  : [
585
- "The exact reader, export views, and local credential files were retired.",
608
+ plan.review.accessMode === "managed"
609
+ ? "The exact reader, export views, and local credential files were retired."
610
+ : "Rehearsal's local credential and receipt were removed. Provider-owned database resources were preserved.",
586
611
  ]),
587
612
  ].join("\n");
588
613
 
@@ -211,6 +211,82 @@ const projectCommandEnvironment = (additional = {}) => ({
211
211
  ...additional,
212
212
  });
213
213
 
214
+ const RUNTIME_STARTUP_FAILURE_PATTERN =
215
+ /container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu;
216
+
217
+ export const classifyManagerFailure = ({
218
+ action,
219
+ output,
220
+ inferredCategory,
221
+ }) => {
222
+ if (RUNTIME_STARTUP_FAILURE_PATTERN.test(output)) {
223
+ return "runtime_dependency_failure";
224
+ }
225
+ if (
226
+ action === "migrate" ||
227
+ (action === "run" &&
228
+ /migrateRuntime|candidate migration|migration up/iu.test(output))
229
+ ) {
230
+ return "migration_candidate_failure";
231
+ }
232
+ return inferredCategory;
233
+ };
234
+
235
+ export const parseDockerCapacity = (output) => {
236
+ const [cpuText, memoryText, runningText, totalText] = output
237
+ .trim()
238
+ .split(/\s+/u);
239
+ const values = [cpuText, memoryText, runningText, totalText].map(Number);
240
+ if (!values.every(Number.isFinite)) return null;
241
+ const [cpus, memoryBytes, runningContainers, containers] = values;
242
+ return { cpus, memoryBytes, runningContainers, containers };
243
+ };
244
+
245
+ export const parseColimaMemory = (output) => {
246
+ const line = output
247
+ .split("\n")
248
+ .map((entry) => entry.trim())
249
+ .find((entry) => entry.startsWith("Mem:"));
250
+ if (!line) return null;
251
+ const columns = line.split(/\s+/u);
252
+ const totalBytes = Number(columns[1]);
253
+ const availableBytes = Number(columns[6]);
254
+ if (![totalBytes, availableBytes].every(Number.isFinite)) return null;
255
+ return { totalBytes, availableBytes };
256
+ };
257
+
258
+ const diagnosticCommand = (command, args) =>
259
+ spawnSync(command, args, {
260
+ encoding: "utf8",
261
+ env: projectCommandEnvironment(),
262
+ stdio: ["ignore", "pipe", "ignore"],
263
+ timeout: 5_000,
264
+ });
265
+
266
+ const inspectContainerCapacity = () => {
267
+ const docker = diagnosticCommand("docker", [
268
+ "info",
269
+ "--format",
270
+ "{{.NCPU}} {{.MemTotal}} {{.ContainersRunning}} {{.Containers}}",
271
+ ]);
272
+ const capacity =
273
+ docker.status === 0 ? parseDockerCapacity(docker.stdout) : null;
274
+ if (!capacity) return undefined;
275
+
276
+ const context = diagnosticCommand("docker", ["context", "show"]);
277
+ if (context.status !== 0 || context.stdout.trim() !== "colima") {
278
+ return { engine: "docker-compatible", ...capacity };
279
+ }
280
+ const memory = diagnosticCommand("colima", ["ssh", "--", "free", "-b"]);
281
+ return {
282
+ engine: "colima",
283
+ ...capacity,
284
+ ...(memory.status === 0
285
+ ? { runtimeMemory: parseColimaMemory(memory.stdout) ?? undefined }
286
+ : {}),
287
+ };
288
+ };
289
+
214
290
  export const topologyCommandEnvironment = (topology) =>
215
291
  Object.fromEntries(
216
292
  topology.targets.map((target) => [
@@ -344,14 +420,15 @@ export const createRuntimeCommands = ({
344
420
  const inferred = normalizeRehearsalError(
345
421
  new Error(completeOutput || `Rehearsal ${action} failed.`),
346
422
  );
347
- const category =
348
- action === "migrate" ||
349
- (action === "run" &&
350
- /migrateRuntime|candidate migration|migration up/iu.test(
351
- completeOutput,
352
- ))
353
- ? "migration_candidate_failure"
354
- : inferred.category;
423
+ const category = classifyManagerFailure({
424
+ action,
425
+ output: completeOutput,
426
+ inferredCategory: inferred.category,
427
+ });
428
+ const startupFailure = category === "runtime_dependency_failure";
429
+ const engineCapacity = startupFailure
430
+ ? inspectContainerCapacity()
431
+ : undefined;
355
432
  const conciseOutput = completeOutput
356
433
  .replaceAll(String.fromCodePoint(27), "")
357
434
  .split("\n")
@@ -366,10 +443,16 @@ export const createRuntimeCommands = ({
366
443
  message: `The local Rehearsal ${action} operation did not complete.`,
367
444
  expected: "the isolated runtime operation to finish and verify",
368
445
  actual: conciseOutput || `exit ${result.status}`,
369
- context: { action },
446
+ context: { action, ...(engineCapacity ? { engineCapacity } : {}) },
370
447
  refused: "The runtime was not reported as trusted.",
371
448
  suggestions: [
372
449
  "Review the concise failure above, then rerun rehearsal doctor before retrying.",
450
+ ...(startupFailure
451
+ ? [
452
+ "Stop unrelated local stacks or increase Docker/Colima capacity, then retry. Rehearsal will not change engine settings or bypass service health checks.",
453
+ "Run docker stats --no-stream to identify active containers using the shared engine.",
454
+ ]
455
+ : []),
373
456
  "Use --debug only when the safe diagnostic detail is needed.",
374
457
  ],
375
458
  cause: new Error(completeOutput || `exit ${result.status}`),
@@ -13,7 +13,10 @@ import {
13
13
  pruneBaselineGenerations,
14
14
  verifyActiveBaseline,
15
15
  } from "../baseline/artifact.mjs";
16
- import { readPrivacyKey } from "../baseline/privacy_engine.mjs";
16
+ import {
17
+ createPrivacyEngine,
18
+ readPrivacyKey,
19
+ } from "../baseline/privacy_engine.mjs";
17
20
  import { loadRehearsalConfig } from "../project/configuration.mjs";
18
21
  import {
19
22
  createSourceAccessPlan,
@@ -21,9 +24,11 @@ import {
21
24
  } from "../source/access.mjs";
22
25
  import { streamApprovedSupabaseAssets } from "../source/asset_transfer.mjs";
23
26
  import { refreshPostgresqlBaseline } from "../source/baseline.mjs";
27
+ import { resolvePrivacyMappedAssets } from "../source/privacy_paths.mjs";
24
28
  import {
25
29
  applyPostgresqlSourceAccess,
26
30
  planPostgresqlSourceAccessRetirement,
31
+ resolvePostgresqlSourceStatePaths,
27
32
  retirePostgresqlSourceAccess,
28
33
  } from "../source/postgresql_access.mjs";
29
34
  import { loadRuntimeTopology } from "../runtime/topology.mjs";
@@ -50,6 +55,7 @@ export const runSourceAccess = async ({ flags, planOptions }) => {
50
55
  plan,
51
56
  confirmation: flags.sourceAccessConfirmation,
52
57
  projectRoot: loaded.projectRoot,
58
+ artifactRoot: loaded.paths.artifactDirectory,
53
59
  });
54
60
  return {
55
61
  mode: "written",
@@ -63,6 +69,8 @@ export const runSourceRetirement = async ({ flags, planOptions }) => {
63
69
  const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
64
70
  const plan = await planPostgresqlSourceAccessRetirement({
65
71
  projectRoot: loaded.projectRoot,
72
+ artifactRoot: loaded.paths.artifactDirectory,
73
+ credentialFile: sourcePolicy.reader.credentialFile,
66
74
  targetFingerprint: sourcePolicy.targetFingerprint,
67
75
  });
68
76
  if (!flags.sourceRetirementConfirmation) return { mode: "preview", plan };
@@ -72,6 +80,7 @@ export const runSourceRetirement = async ({ flags, planOptions }) => {
72
80
  administratorEnvironmentVariable:
73
81
  sourcePolicy.administratorEnvironmentVariable,
74
82
  projectRoot: loaded.projectRoot,
83
+ artifactRoot: loaded.paths.artifactDirectory,
75
84
  credentialFile: sourcePolicy.reader.credentialFile,
76
85
  });
77
86
  return { mode: "written", plan };
@@ -98,6 +107,11 @@ const readSourceReaderCredential = async (path) => {
98
107
 
99
108
  export const runSourceBaselineRefresh = async ({ planOptions }) => {
100
109
  const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
110
+ const { credentialPath } = resolvePostgresqlSourceStatePaths({
111
+ projectRoot: loaded.projectRoot,
112
+ artifactRoot: loaded.paths.artifactDirectory,
113
+ credentialFile: sourcePolicy.reader.credentialFile,
114
+ });
101
115
  const [
102
116
  sourceAccessReceipt,
103
117
  privacyPolicyBytes,
@@ -110,10 +124,17 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
110
124
  ).then(JSON.parse),
111
125
  readFile(loaded.paths.sanitizationPolicy),
112
126
  readPrivacyKey(loaded.paths.privacyKey),
113
- readSourceReaderCredential(
114
- join(loaded.projectRoot, sourcePolicy.reader.credentialFile),
115
- ),
127
+ readSourceReaderCredential(credentialPath),
116
128
  ]);
129
+ const privacyPolicy = JSON.parse(privacyPolicyBytes.toString("utf8"));
130
+ const assetPrivacyEngine = createPrivacyEngine({
131
+ policy: privacyPolicy,
132
+ key: privacyKey,
133
+ });
134
+ const assetDeclarations = resolvePrivacyMappedAssets({
135
+ sourcePolicy,
136
+ privacyPolicy,
137
+ });
117
138
  return refreshPostgresqlBaseline({
118
139
  connectionString,
119
140
  sourcePolicy,
@@ -133,10 +154,12 @@ export const runSourceBaselineRefresh = async ({ planOptions }) => {
133
154
  baseUrl:
134
155
  process.env[sourcePolicy.assetReader.baseUrlEnvironmentVariable],
135
156
  token: process.env[sourcePolicy.assetReader.tokenEnvironmentVariable],
136
- declarations: sourcePolicy.assets,
157
+ declarations: assetDeclarations,
137
158
  maximumObjects: sourcePolicy.assetReader.maximumObjects,
138
159
  maximumObjectBytes: sourcePolicy.assetReader.maximumObjectBytes,
139
160
  maximumTotalBytes: sourcePolicy.assetReader.maximumTotalBytes,
161
+ pathMapper: ({ mapping, value }) =>
162
+ assetPrivacyEngine.remapPath({ mapping, value }),
140
163
  })
141
164
  : [],
142
165
  });
@@ -244,9 +267,15 @@ export const buildRefreshWorkflowPlan = async (planOptions) => {
244
267
  };
245
268
 
246
269
  export const createRefreshWorkflow =
247
- ({ runRuntimeStack, prepareDependentTargets, topologyCommandEnvironment }) =>
270
+ ({
271
+ runRuntimeStack,
272
+ prepareDependentTargets,
273
+ topologyCommandEnvironment,
274
+ buildPlan = buildRefreshWorkflowPlan,
275
+ refreshBaseline = runSourceBaselineRefresh,
276
+ }) =>
248
277
  async ({ flags, planOptions }) => {
249
- const context = await buildRefreshWorkflowPlan(planOptions);
278
+ const context = await buildPlan(planOptions);
250
279
  if (!flags.refreshConfirmation) {
251
280
  return { mode: "preview", plan: context.plan };
252
281
  }
@@ -259,7 +288,7 @@ export const createRefreshWorkflow =
259
288
  let runtime;
260
289
  let preparations = [];
261
290
  try {
262
- refreshed = await runSourceBaselineRefresh({ planOptions });
291
+ refreshed = await refreshBaseline({ planOptions });
263
292
  const stack = await runRuntimeStack({
264
293
  command: "reset",
265
294
  flags,
@@ -740,18 +740,18 @@ export const applyIdentityClaim = async ({
740
740
  "select 1 from auth.users where id = $1::uuid limit 1",
741
741
  [plan.identity.placeholderUserId],
742
742
  );
743
- const placeholderReferenceCounts =
744
- localUserId === plan.identity.placeholderUserId
745
- ? []
746
- : await Promise.all(
747
- plan.identity.references.map((reference) =>
748
- countIdentityRows({
749
- client,
750
- reference,
751
- userId: plan.identity.placeholderUserId,
752
- }),
753
- ),
754
- );
743
+ const placeholderReferenceCounts = [];
744
+ if (localUserId !== plan.identity.placeholderUserId) {
745
+ for (const reference of plan.identity.references) {
746
+ placeholderReferenceCounts.push(
747
+ await countIdentityRows({
748
+ client,
749
+ reference,
750
+ userId: plan.identity.placeholderUserId,
751
+ }),
752
+ );
753
+ }
754
+ }
755
755
  const transferReferences = plan.identity.references.filter(
756
756
  (reference) => reference.strategy === "transfer",
757
757
  );
@@ -762,15 +762,17 @@ export const applyIdentityClaim = async ({
762
762
  const index = plan.identity.references.indexOf(reference);
763
763
  return placeholderReferenceCounts[index] ?? 0;
764
764
  });
765
- const transferLocalCounts =
766
- localUserId === plan.identity.placeholderUserId ||
767
- preservedReferences.length === 0
768
- ? []
769
- : await Promise.all(
770
- transferReferences.map((reference) =>
771
- countIdentityRows({ client, reference, userId: localUserId }),
772
- ),
773
- );
765
+ const transferLocalCounts = [];
766
+ if (
767
+ localUserId !== plan.identity.placeholderUserId &&
768
+ preservedReferences.length > 0
769
+ ) {
770
+ for (const reference of transferReferences) {
771
+ transferLocalCounts.push(
772
+ await countIdentityRows({ client, reference, userId: localUserId }),
773
+ );
774
+ }
775
+ }
774
776
  const alreadyClaimed =
775
777
  localUserId !== plan.identity.placeholderUserId &&
776
778
  transferPlaceholderCounts.every((count) => count === 0) &&
@@ -138,6 +138,31 @@ const uniqueProjects = (projects) => [
138
138
 
139
139
  const skip = (reason) => ({ status: "skipped", reason });
140
140
 
141
+ export const renderInstalledRehearsalScaffold = (result) => {
142
+ if (result.status !== "ready") {
143
+ return `Rehearsal did not create project files: ${result.reason}.\nNext: run npx rehearsal from the application directory.`;
144
+ }
145
+ const changed = result.files.filter((file) => file.action !== "unchanged");
146
+ return [
147
+ "Rehearsal is ready in this project.",
148
+ `Configuration: ${result.configurationPath} (${result.configurationAction === "create" ? "created" : "existing"})`,
149
+ ...(result.rootConfigGenerationSkipped
150
+ ? [
151
+ ` Existing supported configuration detected at ${result.configurationPath}.`,
152
+ " rehearsal.config.mjs was not created because Rehearsal preserves one active configuration and never creates a competing file.",
153
+ ]
154
+ : []),
155
+ ...(changed.length
156
+ ? [
157
+ ...changed.map(
158
+ (file) => ` ${file.action === "create" ? "+" : "~"} ${file.path}`,
159
+ ),
160
+ ]
161
+ : [" Existing Rehearsal files preserved."]),
162
+ `Next: review ${result.configurationPath}, then run npx rehearsal.`,
163
+ ].join("\n");
164
+ };
165
+
141
166
  export const resolveRehearsalConsumerRoot = async ({
142
167
  packageRoot,
143
168
  initCwd,
@@ -281,11 +306,20 @@ export const createInstalledRehearsalScaffold = async ({
281
306
  isPortAvailable,
282
307
  });
283
308
  const result = await applyRehearsalSetup(plan);
284
- await loadRehearsalConfig({ projectRoot: resolution.projectRoot });
309
+ const loaded = await loadRehearsalConfig({
310
+ projectRoot: resolution.projectRoot,
311
+ });
312
+ const configurationPath = relative(resolution.projectRoot, loaded.configPath);
313
+ const configurationAction =
314
+ result.files.find(({ path }) => path === configurationPath)?.action ??
315
+ "unchanged";
285
316
  return {
286
317
  status: "ready",
287
318
  projectRoot: resolution.projectRoot,
288
319
  target: plan.target,
289
320
  files: result.files,
321
+ configurationPath,
322
+ configurationAction,
323
+ rootConfigGenerationSkipped: configurationPath !== "rehearsal.config.mjs",
290
324
  };
291
325
  };
@@ -23,6 +23,11 @@ import {
23
23
  renderDetectedPostgresqlConfig,
24
24
  } from "./configuration.mjs";
25
25
  import { resolveRuntimeTarget } from "../targets/target.mjs";
26
+ export {
27
+ assertSupportedRehearsalNodeRuntime,
28
+ inspectRehearsalNodeRuntime,
29
+ } from "../shared/node_runtime.mjs";
30
+ import { assertSupportedRehearsalNodeRuntime } from "../shared/node_runtime.mjs";
26
31
 
27
32
  const CONFIG_PATH = "rehearsal.config.mjs";
28
33
  const LOCAL_SUPABASE_CONFIG_PATH =
@@ -82,29 +87,6 @@ export const isRehearsalPortAvailable = async (port) => {
82
87
  return canBindToPort(port);
83
88
  };
84
89
 
85
- export const inspectRehearsalNodeRuntime = (
86
- version = process.versions.node,
87
- ) => {
88
- const major = Number.parseInt(String(version).split(".")[0], 10);
89
- return {
90
- version: String(version),
91
- major,
92
- supported: major === 24,
93
- };
94
- };
95
-
96
- export const assertSupportedRehearsalNodeRuntime = (
97
- version = process.versions.node,
98
- ) => {
99
- const runtime = inspectRehearsalNodeRuntime(version);
100
- if (!runtime.supported) {
101
- throw new Error(
102
- `Rehearsal setup requires Node.js 24; this shell is using Node.js ${runtime.version}. Switch to Node.js 24, then rerun setup. With nvm: nvm install 24 && nvm use 24.`,
103
- );
104
- }
105
- return runtime;
106
- };
107
-
108
90
  export const findAvailableRehearsalPorts = async ({
109
91
  isAvailable = isRehearsalPortAvailable,
110
92
  } = {}) => {
@@ -392,6 +374,9 @@ export const planRehearsalSetup = async ({
392
374
  ];
393
375
  return {
394
376
  projectRoot: root,
377
+ configurationPath: existingConfigPath
378
+ ? relative(root, existingConfigPath)
379
+ : CONFIG_PATH,
395
380
  project: detected.projectName,
396
381
  projectId,
397
382
  target: runtimeTarget,
@@ -462,6 +447,11 @@ export const applyRehearsalSetup = async (plan) => {
462
447
 
463
448
  export const summarizeRehearsalSetup = (plan, { mode }) => ({
464
449
  mode,
450
+ configurationPath: plan.configurationPath,
451
+ configurationAction:
452
+ plan.files.find((file) => file.path === plan.configurationPath)?.action ??
453
+ "unchanged",
454
+ rootConfigGenerationSkipped: plan.configurationPath !== CONFIG_PATH,
465
455
  project: plan.project,
466
456
  projectId: plan.projectId,
467
457
  target: plan.target,
@@ -479,6 +469,6 @@ export const summarizeRehearsalSetup = (plan, { mode }) => ({
479
469
  safety: plan.safety,
480
470
  nextAction:
481
471
  mode === "written"
482
- ? "Review the generated commands and sanitization policy path, then run rehearsal doctor."
472
+ ? `Review ${plan.configurationPath}, including its commands and sanitization policy path, then run rehearsal doctor.`
483
473
  : "Review this plan, then rerun rehearsal setup --write to create it.",
484
474
  });
@@ -22,6 +22,10 @@ import {
22
22
  import { createCandidateMigrationReceipt } from "./restore.mjs";
23
23
  import { readMigrationFileInventory } from "./migration_history.mjs";
24
24
  import { REHEARSAL_VERSION } from "../shared/diagnostics.mjs";
25
+ import {
26
+ inspectRehearsalNodeRuntime,
27
+ REHEARSAL_NODE_LABEL,
28
+ } from "../shared/node_runtime.mjs";
25
29
  import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
26
30
  import {
27
31
  readBoundRuntimeSanitizationPolicy,
@@ -357,17 +361,17 @@ export const runRehearsalDoctor = async (options = {}) => {
357
361
  };
358
362
  }
359
363
  const { config, paths, projectRoot } = loaded;
360
- const nodeMajor = Number(process.versions.node.split(".")[0]);
364
+ const nodeRuntime = inspectRehearsalNodeRuntime();
361
365
  const checkDefinitions = [
362
366
  {
363
367
  id: "node",
364
368
  label: "Node.js compatibility",
365
369
  run: async () => {
366
- if (nodeMajor !== 24)
370
+ if (!nodeRuntime.supported)
367
371
  throw new Error(`Node ${process.versions.node} is unsupported.`);
368
372
  return `Node ${process.versions.node}`;
369
373
  },
370
- remediation: "Use Node.js 24.",
374
+ remediation: `Use ${REHEARSAL_NODE_LABEL}. Node.js 24 LTS is recommended.`,
371
375
  },
372
376
  {
373
377
  id: "operating-system",
@@ -194,11 +194,19 @@ const buildIdentitySequenceSql = (table) =>
194
194
  const schema = tableSchema(table);
195
195
  const relation = qualifiedRelation(table);
196
196
  const columnName = quoteIdentifier(column.name);
197
- return `select setval(
198
- pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)}),
199
- coalesce((select max(${columnName}) from ${relation}), 1),
200
- exists(select 1 from ${relation})
201
- );`;
197
+ const sequenceValueQuery = `select max(${columnName})::bigint, exists(select 1 from ${relation}) from ${relation}`;
198
+ return `do $rehearsal_sequence$
199
+ declare
200
+ sequence_name text := pg_get_serial_sequence(${quoteLiteral(`${schema}.${table.name}`)}, ${quoteLiteral(column.name)});
201
+ maximum_value bigint;
202
+ has_rows boolean;
203
+ begin
204
+ if sequence_name is not null then
205
+ execute ${quoteLiteral(sequenceValueQuery)} into maximum_value, has_rows;
206
+ perform setval(sequence_name, coalesce(maximum_value, 1), has_rows);
207
+ end if;
208
+ end
209
+ $rehearsal_sequence$;`;
202
210
  })
203
211
  .join("\n");
204
212
 
@@ -94,6 +94,13 @@ export class RehearsalError extends Error {
94
94
  const inferCategory = (error) => {
95
95
  const message = String(error?.message ?? error);
96
96
  if (/checksum/iu.test(message)) return "baseline_checksum_mismatch";
97
+ if (
98
+ /container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu.test(
99
+ message,
100
+ )
101
+ ) {
102
+ return "runtime_dependency_failure";
103
+ }
97
104
  if (/candidate|migration history diverges/iu.test(message)) {
98
105
  return "migration_candidate_failure";
99
106
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Supported Node.js release lines. Keep this aligned with package.json and the
3
+ * verification matrix.
4
+ */
5
+
6
+ export const REHEARSAL_NODE_MAJORS = Object.freeze([22, 24, 26]);
7
+ export const REHEARSAL_NODE_LABEL = "Node.js 22, 24, or 26";
8
+ export const REHEARSAL_RECOMMENDED_NODE_MAJOR = 24;
9
+
10
+ export const inspectRehearsalNodeRuntime = (
11
+ version = process.versions.node,
12
+ ) => {
13
+ const major = Number.parseInt(String(version).split(".")[0], 10);
14
+ return {
15
+ version: String(version),
16
+ major,
17
+ supported: REHEARSAL_NODE_MAJORS.includes(major),
18
+ };
19
+ };
20
+
21
+ export const assertSupportedRehearsalNodeRuntime = (
22
+ version = process.versions.node,
23
+ ) => {
24
+ const runtime = inspectRehearsalNodeRuntime(version);
25
+ if (!runtime.supported) {
26
+ throw new Error(
27
+ `Rehearsal requires ${REHEARSAL_NODE_LABEL}; this shell is using Node.js ${runtime.version}. Switch to a supported release, then rerun setup. With nvm: nvm install --lts && nvm use --lts.`,
28
+ );
29
+ }
30
+ return runtime;
31
+ };