@rehearsal-db/core 0.1.0-rc.2 → 0.1.0-rc.3

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 (56) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +3 -1
  3. package/dist/package.json +1 -1
  4. package/dist/scripts/runtime/manage_database.mjs +17 -8
  5. package/dist/src/cli/application_command_handlers.mjs +9 -4
  6. package/dist/src/cli/basic_command_handlers.mjs +1 -0
  7. package/dist/src/cli/inspection_command_handlers.mjs +27 -0
  8. package/dist/src/cli/project_commands.d.mts +9 -1
  9. package/dist/src/cli/project_commands.mjs +2 -2
  10. package/dist/src/cli/rehearsal.mjs +77 -65
  11. package/dist/src/cli/runtime_action_handlers.mjs +36 -9
  12. package/dist/src/cli/runtime_commands.d.mts +26 -7
  13. package/dist/src/cli/runtime_commands.mjs +94 -36
  14. package/dist/src/cli/source_commands.d.mts +12 -2
  15. package/dist/src/cli/source_commands.mjs +24 -15
  16. package/dist/src/project/configuration_contract.d.mts +10 -0
  17. package/dist/src/project/configuration_contract.mjs +4 -0
  18. package/dist/src/project/configuration_normalization.d.mts +4 -0
  19. package/dist/src/project/configuration_normalization.mjs +11 -1
  20. package/dist/src/project/configuration_rendering.mjs +14 -0
  21. package/dist/src/project/configuration_resolution.d.mts +4 -0
  22. package/dist/src/project/configuration_schema.d.mts +1 -0
  23. package/dist/src/project/configuration_schema.mjs +4 -0
  24. package/dist/src/runtime/lifecycle_engine.mjs +10 -2
  25. package/dist/src/runtime/persistence.d.mts +19 -0
  26. package/dist/src/runtime/persistence.mjs +40 -0
  27. package/dist/src/runtime/plan.d.mts +5 -0
  28. package/dist/src/runtime/plan.mjs +8 -0
  29. package/dist/src/runtime/status.d.mts +30 -0
  30. package/dist/src/runtime/status.mjs +141 -0
  31. package/dist/src/shared/cancellation.d.mts +18 -0
  32. package/dist/src/shared/cancellation.mjs +80 -0
  33. package/dist/src/shared/diagnostics.d.mts +1 -0
  34. package/dist/src/shared/diagnostics.mjs +1 -0
  35. package/dist/src/shared/operation_guard.d.mts +12 -0
  36. package/dist/src/shared/operation_guard.mjs +91 -2
  37. package/dist/src/shared/owned_process.d.mts +30 -0
  38. package/dist/src/shared/owned_process.mjs +117 -0
  39. package/dist/src/targets/postgresql.d.mts +0 -4
  40. package/dist/src/targets/postgresql.mjs +37 -12
  41. package/dist/src/targets/postgresql_process.d.mts +8 -0
  42. package/dist/src/targets/postgresql_process.mjs +34 -0
  43. package/dist/src/targets/postgresql_status.d.mts +21 -0
  44. package/dist/src/targets/postgresql_status.mjs +51 -0
  45. package/dist/src/targets/supabase.mjs +31 -16
  46. package/dist/src/targets/supabase_environment.mjs +2 -0
  47. package/dist/src/targets/supabase_process.d.mts +7 -0
  48. package/dist/src/targets/supabase_process.mjs +21 -0
  49. package/dist/src/targets/supabase_status.d.mts +12 -0
  50. package/dist/src/targets/supabase_status.mjs +56 -0
  51. package/dist/src/targets/target.d.mts +3 -1
  52. package/dist/src/targets/target.mjs +3 -1
  53. package/docs/commands.md +127 -2
  54. package/docs/configuration.md +12 -0
  55. package/docs/runtime-lifecycle.md +31 -5
  56. package/package.json +1 -1
@@ -13,7 +13,11 @@ import { buildRehearsalPlan, runRehearsalDoctor } from "../runtime/plan.mjs";
13
13
  import { inspectRuntimeTopologyCandidates, loadRuntimeTopology, } from "../runtime/topology.mjs";
14
14
  import { RehearsalError, normalizeRehearsalError, redactDiagnosticValue, } from "../shared/diagnostics.mjs";
15
15
  import { assertInstalledPackageFingerprint } from "../shared/operation_guard.mjs";
16
+ import { getOperationCancellationSignal, throwIfOperationCancelled, } from "../shared/cancellation.mjs";
17
+ import { runOwnedProcess } from "../shared/owned_process.mjs";
16
18
  import { buildRuntimeManagerArguments } from "../targets/target.mjs";
19
+ import { parseRuntimeStatusSnapshot, renderRuntimeStatusSnapshot, } from "../runtime/status.mjs";
20
+ import { applyRuntimePersistence } from "../runtime/persistence.mjs";
17
21
  import { formatDuration, isHumanTerminal, promptForConfirmation, terminalStyle, useStyledPrompts, } from "./terminal.mjs";
18
22
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
19
23
  export const buildRuntimeStackPlan = async (planOptions) => {
@@ -38,6 +42,11 @@ export const buildRuntimeStackPlan = async (planOptions) => {
38
42
  candidateCount: candidates.candidateCount,
39
43
  candidateSha256: candidates.candidateSha256,
40
44
  },
45
+ lifecycle: {
46
+ run: topology.primary.config.lifecycle.run,
47
+ open: topology.primary.config.lifecycle.open,
48
+ dataOnStop: "preserved",
49
+ },
41
50
  execution: [
42
51
  "verify every immutable baseline and migration prefix",
43
52
  "restore and migrate the primary runtime",
@@ -47,6 +56,9 @@ export const buildRuntimeStackPlan = async (planOptions) => {
47
56
  .map((target) => `run ${target.name} preparation: ${target.declaration.prepareCommand}`),
48
57
  ...topology.dependents.map((target) => `run ${target.name} proof: ${target.config.application.proofCommand}`),
49
58
  `run primary proof: ${topology.primary.config.application.proofCommand}`,
59
+ topology.primary.config.lifecycle.run === "stop-after-run"
60
+ ? "stop every database target after proofs while preserving its data"
61
+ : "keep every database target available until an explicit stop",
50
62
  ],
51
63
  guarantee: "Every database target is isolated by its own project ID, ports, artifact directory, and local-only safety policy. No production resources will be contacted.",
52
64
  };
@@ -256,15 +268,18 @@ export const topologyEnvironmentFiles = (topology) => ({
256
268
  target.paths.applicationEnvironment,
257
269
  ])),
258
270
  });
259
- const runProjectCommand = ({ source, configuredRoot, label, project, environment = {}, }) => {
271
+ const runProjectCommand = async ({ source, configuredRoot, label, project, environment = {}, }) => {
260
272
  const [command, ...args] = parseCommand(source);
261
- const result = spawnSync(command, args, {
273
+ const cancellationSignal = getOperationCancellationSignal();
274
+ const result = await runOwnedProcess({
275
+ command,
276
+ args,
262
277
  cwd: configuredRoot,
263
- encoding: "utf8",
264
278
  env: projectCommandEnvironment(environment),
265
- stdio: ["ignore", "pipe", "pipe"],
266
- maxBuffer: 16 * 1024 * 1024,
279
+ stdin: "ignore",
280
+ ...(cancellationSignal ? { signal: cancellationSignal } : {}),
267
281
  });
282
+ throwIfOperationCancelled();
268
283
  if (result.error || result.status !== 0) {
269
284
  throw new RehearsalError({
270
285
  category: "application_proof_failure",
@@ -286,7 +301,7 @@ const runProjectCommand = ({ source, configuredRoot, label, project, environment
286
301
  };
287
302
  };
288
303
  export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, getActivePackageFingerprint, }) => {
289
- const runManager = ({ action, flags, configPath = flags.configPath, confirmation = flags.confirmation, targetName, }) => {
304
+ const runManager = async ({ action, flags, configPath = flags.configPath, confirmation = flags.confirmation, targetName, }) => {
290
305
  const fingerprint = getActivePackageFingerprint();
291
306
  if (fingerprint) {
292
307
  assertInstalledPackageFingerprint({
@@ -299,6 +314,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
299
314
  action,
300
315
  ...(configPath === undefined ? {} : { configPath }),
301
316
  ...(confirmation === undefined ? {} : { confirmation }),
317
+ structuredResult: action === "status",
302
318
  });
303
319
  const runtimeStartedMs = performance.now();
304
320
  const actionDescription = {
@@ -321,13 +337,25 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
321
337
  else if (isHumanTerminal(flags)) {
322
338
  console.log(`\n${terminalStyle(flags, "1;36", action === "run" ? "REHEARSING" : "REHEARSAL")}\n${terminalStyle(flags, "36", "→")} ${describedAction}. This can take a moment.`);
323
339
  }
324
- const result = spawnSync(process.execPath, args, {
340
+ const cancellationSignal = getOperationCancellationSignal();
341
+ // VS Code extensions launch the package CLI through Electron's executable in
342
+ // Node mode. Preserve that mode only for Rehearsal's nested Node manager;
343
+ // project-owned application commands continue to receive the reduced
344
+ // project environment above.
345
+ const managerEnvironment = {
346
+ ...(cancellationSignal ? { REHEARSAL_CANCELLABLE_OPERATION: "1" } : {}),
347
+ ...(process.env.ELECTRON_RUN_AS_NODE === "1"
348
+ ? { ELECTRON_RUN_AS_NODE: "1" }
349
+ : {}),
350
+ };
351
+ const result = await runOwnedProcess({
352
+ command: process.execPath,
353
+ args,
325
354
  cwd: projectRoot,
326
- encoding: "utf8",
327
- env: projectCommandEnvironment(),
328
- stdio: ["inherit", "pipe", "pipe"],
329
- maxBuffer: 16 * 1024 * 1024,
355
+ env: projectCommandEnvironment(managerEnvironment),
356
+ ...(cancellationSignal ? { signal: cancellationSignal } : {}),
330
357
  });
358
+ throwIfOperationCancelled();
331
359
  const currentFingerprint = getActivePackageFingerprint();
332
360
  if (currentFingerprint) {
333
361
  assertInstalledPackageFingerprint({
@@ -396,11 +424,17 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
396
424
  else if (isHumanTerminal(flags)) {
397
425
  console.log(`${terminalStyle(flags, "32", "✓")} Local runtime step completed in ${formatDuration(durationMs)}.`);
398
426
  }
427
+ const runtimeStatus = action === "status"
428
+ ? parseRuntimeStatusSnapshot(JSON.parse(String(result.stdout ?? "")))
429
+ : undefined;
399
430
  return {
400
431
  action,
401
432
  target: targetName ?? "primary",
402
433
  durationMs: Math.round(durationMs * 100) / 100,
403
- output: String(redactDiagnosticValue(String(result.stdout ?? "").trim())),
434
+ output: runtimeStatus
435
+ ? renderRuntimeStatusSnapshot(runtimeStatus)
436
+ : String(redactDiagnosticValue(String(result.stdout ?? "").trim())),
437
+ ...(runtimeStatus ? { runtimeStatus } : {}),
404
438
  };
405
439
  };
406
440
  const runRuntimeStack = async ({ command, flags, planOptions, session, }) => {
@@ -410,16 +444,17 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
410
444
  const targets = reverse
411
445
  ? [...topology.targets].reverse()
412
446
  : topology.targets;
413
- const results = targets.map((target) => {
447
+ const results = [];
448
+ for (const target of targets) {
414
449
  const confirmation = session.targetConfirmations?.get(target.configPath);
415
- return runManager({
450
+ results.push(await runManager({
416
451
  action: command,
417
452
  flags,
418
453
  configPath: target.configPath,
419
454
  ...(confirmation === undefined ? {} : { confirmation }),
420
455
  ...(topology.targets.length > 1 ? { targetName: target.name } : {}),
421
- });
422
- });
456
+ }));
457
+ }
423
458
  if (results.length === 1)
424
459
  return { topology, runtime: results[0] };
425
460
  return {
@@ -449,22 +484,24 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
449
484
  : { environment: options.environment }),
450
485
  });
451
486
  };
452
- const prepareDependentTargets = ({ topology, environment, }) => topology.dependents.flatMap((target) => {
453
- if (!target.declaration?.prepareCommand)
454
- return [];
455
- return [
456
- {
487
+ const prepareDependentTargets = async ({ topology, environment, }) => {
488
+ const preparations = [];
489
+ for (const target of topology.dependents) {
490
+ if (!target.declaration?.prepareCommand)
491
+ continue;
492
+ preparations.push({
457
493
  target: target.name,
458
- ...runProjectCommand({
494
+ ...(await runProjectCommand({
459
495
  source: target.declaration.prepareCommand,
460
496
  configuredRoot: topology.projectRoot,
461
497
  label: `preparation command for ${target.name}`,
462
498
  project: topology.primary.config.project.name,
463
499
  environment,
464
- }),
465
- },
466
- ];
467
- });
500
+ })),
501
+ });
502
+ }
503
+ return preparations;
504
+ };
468
505
  const proveRuntimeStack = async ({ topology, planOptions, environment, }) => {
469
506
  const dependentProofs = [];
470
507
  for (const target of topology.dependents) {
@@ -479,7 +516,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
479
516
  });
480
517
  return { applicationProof, dependentProofs };
481
518
  };
482
- const startRuntimeApplication = async ({ flags, planOptions, session: commandSession, }) => {
519
+ const startRuntimeApplication = async ({ flags, planOptions, session: commandSession, onRuntimeStarted = () => undefined, }) => {
483
520
  const topology = await loadRuntimeTopology(planOptions);
484
521
  const application = topology.primary.config.application;
485
522
  if (!application.readiness) {
@@ -505,13 +542,14 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
505
542
  planOptions,
506
543
  session: commandSession,
507
544
  });
545
+ onRuntimeStarted();
508
546
  const verified = await runRuntimeStack({
509
547
  command: "verify",
510
548
  flags,
511
549
  planOptions,
512
550
  session: commandSession,
513
551
  });
514
- const preparations = prepareDependentTargets({
552
+ const preparations = await prepareDependentTargets({
515
553
  topology,
516
554
  environment: topologyCommandEnvironment(topology),
517
555
  });
@@ -531,15 +569,34 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
531
569
  };
532
570
  };
533
571
  const openRuntimeApplication = async ({ flags, planOptions, session: commandSession, onReady = () => undefined, }) => {
534
- const active = await startRuntimeApplication({
535
- flags,
536
- planOptions,
537
- session: commandSession,
538
- });
539
- const closed = await holdApplicationSession({
540
- session: active.session,
541
- onReady,
572
+ const topology = await loadRuntimeTopology(planOptions);
573
+ let runtimeStarted = false;
574
+ const { result, lifecycle } = await applyRuntimePersistence({
575
+ mode: topology.primary.config.lifecycle.open,
576
+ canStop: () => runtimeStarted,
577
+ operation: async () => {
578
+ const active = await startRuntimeApplication({
579
+ flags,
580
+ planOptions,
581
+ session: commandSession,
582
+ onRuntimeStarted: () => {
583
+ runtimeStarted = true;
584
+ },
585
+ });
586
+ const closed = await holdApplicationSession({
587
+ session: active.session,
588
+ onReady,
589
+ });
590
+ return { active, closed };
591
+ },
592
+ stop: async () => (await runRuntimeStack({
593
+ command: "stop",
594
+ flags,
595
+ planOptions,
596
+ session: commandSession,
597
+ })).runtime,
542
598
  });
599
+ const { active, closed } = result;
543
600
  return {
544
601
  action: "open",
545
602
  started: active.started,
@@ -551,6 +608,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
551
608
  stoppedBy: closed.signal,
552
609
  diagnostics: closed.diagnostics,
553
610
  },
611
+ lifecycle,
554
612
  };
555
613
  };
556
614
  const prepareCandidateConfirmation = async ({ command, flags, planOptions, session, }) => {
@@ -2,6 +2,7 @@
2
2
  * CLI orchestration for temporary production-source access and safe baseline
3
3
  * replacement. Domain validation remains in src/source and src/baseline.
4
4
  */
5
+ import { activateBaselineGeneration } from "../baseline/artifact.mjs";
5
6
  import type { RehearsalConfigPathOptions } from "../project/configuration.mjs";
6
7
  import type { RehearsalCliFlags } from "./arguments.mjs";
7
8
  import type { CliSessionState } from "./command_contract.mjs";
@@ -53,6 +54,10 @@ export declare const loadPreparationContext: (planOptions: RehearsalConfigPathOp
53
54
  containerRuntime: Readonly<{
54
55
  autoStartColima: boolean;
55
56
  }>;
57
+ lifecycle: Readonly<{
58
+ run: "keep-until-stop" | "stop-after-run";
59
+ open: "keep-until-stop" | "stop-on-application-exit";
60
+ }>;
56
61
  cleanup: Readonly<{
57
62
  retainBaselineGenerations: number;
58
63
  }>;
@@ -201,6 +206,10 @@ export declare const buildRefreshWorkflowPlan: (planOptions: RehearsalConfigPath
201
206
  containerRuntime: Readonly<{
202
207
  autoStartColima: boolean;
203
208
  }>;
209
+ lifecycle: Readonly<{
210
+ run: "keep-until-stop" | "stop-after-run";
211
+ open: "keep-until-stop" | "stop-on-application-exit";
212
+ }>;
204
213
  cleanup: Readonly<{
205
214
  retainBaselineGenerations: number;
206
215
  }>;
@@ -323,7 +332,7 @@ export declare const buildRefreshWorkflowPlan: (planOptions: RehearsalConfigPath
323
332
  digest: string;
324
333
  };
325
334
  }>;
326
- export declare const createRefreshWorkflow: ({ runRuntimeStack, prepareDependentTargets, topologyCommandEnvironment, buildPlan, refreshBaseline, }: {
335
+ export declare const createRefreshWorkflow: ({ runRuntimeStack, prepareDependentTargets, topologyCommandEnvironment, buildPlan, refreshBaseline, activateBaseline, }: {
327
336
  runRuntimeStack: (input: {
328
337
  command: "reset";
329
338
  flags: RehearsalCliFlags;
@@ -336,10 +345,11 @@ export declare const createRefreshWorkflow: ({ runRuntimeStack, prepareDependent
336
345
  prepareDependentTargets: (input: {
337
346
  topology: RuntimeTopology;
338
347
  environment: NodeJS.ProcessEnv;
339
- }) => unknown[];
348
+ }) => Promise<unknown[]>;
340
349
  topologyCommandEnvironment: (topology: RuntimeTopology) => NodeJS.ProcessEnv;
341
350
  buildPlan?: (options: RehearsalConfigPathOptions) => Promise<RefreshPlanContext>;
342
351
  refreshBaseline?: typeof runSourceBaselineRefresh;
352
+ activateBaseline?: typeof activateBaselineGeneration;
343
353
  }) => ({ flags, planOptions, session, }: SourceCommandInput & {
344
354
  session: CliSessionState;
345
355
  }) => Promise<{
@@ -14,6 +14,7 @@ import { refreshPostgresqlBaseline } from "../source/baseline.mjs";
14
14
  import { resolvePrivacyMappedAssets } from "../source/privacy_paths.mjs";
15
15
  import { applyPostgresqlSourceAccess, planPostgresqlSourceAccessRetirement, resolvePostgresqlSourceStatePaths, retirePostgresqlSourceAccess, } from "../source/postgresql_access.mjs";
16
16
  import { loadRuntimeTopology } from "../runtime/topology.mjs";
17
+ import { isOperationCancelled, throwIfOperationCancelled, withOperationCancellationShield, } from "../shared/cancellation.mjs";
17
18
  const parseSourceAccessReceipt = (source) => {
18
19
  const parsed = JSON.parse(source);
19
20
  if (typeof parsed.targetFingerprint !== "string" ||
@@ -224,7 +225,7 @@ export const buildRefreshWorkflowPlan = async (planOptions) => {
224
225
  },
225
226
  };
226
227
  };
227
- export const createRefreshWorkflow = ({ runRuntimeStack, prepareDependentTargets, topologyCommandEnvironment, buildPlan = buildRefreshWorkflowPlan, refreshBaseline = runSourceBaselineRefresh, }) => async ({ flags, planOptions, session, }) => {
228
+ export const createRefreshWorkflow = ({ runRuntimeStack, prepareDependentTargets, topologyCommandEnvironment, buildPlan = buildRefreshWorkflowPlan, refreshBaseline = runSourceBaselineRefresh, activateBaseline = activateBaselineGeneration, }) => async ({ flags, planOptions, session, }) => {
228
229
  const context = await buildPlan(planOptions);
229
230
  if (!flags.refreshConfirmation) {
230
231
  return { mode: "preview", plan: context.plan };
@@ -236,40 +237,48 @@ export const createRefreshWorkflow = ({ runRuntimeStack, prepareDependentTargets
236
237
  let runtime;
237
238
  let preparations = [];
238
239
  try {
240
+ throwIfOperationCancelled();
239
241
  refreshed = await refreshBaseline({ planOptions });
242
+ throwIfOperationCancelled();
240
243
  const stack = await runRuntimeStack({
241
244
  command: "reset",
242
245
  flags,
243
246
  planOptions,
244
247
  session,
245
248
  });
249
+ throwIfOperationCancelled();
246
250
  runtime = stack.runtime;
247
- preparations = prepareDependentTargets({
251
+ preparations = await prepareDependentTargets({
248
252
  topology: stack.topology,
249
253
  environment: topologyCommandEnvironment(stack.topology),
250
254
  });
255
+ throwIfOperationCancelled();
251
256
  }
252
257
  catch (error) {
253
258
  if (refreshed) {
254
- await activateBaselineGeneration({
255
- artifactRoot: context.loaded.paths.artifactDirectory,
256
- generationId: context.plan.review.currentBaseline,
257
- });
258
259
  try {
259
- const rollback = await runRuntimeStack({
260
- command: "reset",
261
- flags,
262
- planOptions,
263
- session,
264
- });
265
- prepareDependentTargets({
266
- topology: rollback.topology,
267
- environment: topologyCommandEnvironment(rollback.topology),
260
+ await withOperationCancellationShield(async () => {
261
+ await activateBaseline({
262
+ artifactRoot: context.loaded.paths.artifactDirectory,
263
+ generationId: context.plan.review.currentBaseline,
264
+ });
265
+ const rollback = await runRuntimeStack({
266
+ command: "reset",
267
+ flags,
268
+ planOptions,
269
+ session,
270
+ });
271
+ await prepareDependentTargets({
272
+ topology: rollback.topology,
273
+ environment: topologyCommandEnvironment(rollback.topology),
274
+ });
268
275
  });
269
276
  }
270
277
  catch (rollbackError) {
271
278
  throw new AggregateError([error, rollbackError], "The refreshed runtime failed and the previous local runtime could not be restored automatically. The previous immutable baseline is active; run rehearsal reset after correcting the reported problem.");
272
279
  }
280
+ if (isOperationCancelled(error))
281
+ throw error;
273
282
  throw new Error("The refreshed runtime failed verification. Rehearsal restored the previous baseline and local runtime.", { cause: error });
274
283
  }
275
284
  throw error;
@@ -1,5 +1,7 @@
1
1
  /** Public configuration types, format version, and stable defaults. */
2
2
  export type RehearsalConfigVersion = 1;
3
+ export type RunPersistenceMode = "keep-until-stop" | "stop-after-run";
4
+ export type OpenPersistenceMode = "keep-until-stop" | "stop-on-application-exit";
3
5
  export interface RehearsalConfig {
4
6
  schemaVersion: RehearsalConfigVersion;
5
7
  project: {
@@ -38,6 +40,10 @@ export interface RehearsalConfig {
38
40
  containerRuntime?: {
39
41
  autoStartColima?: boolean;
40
42
  };
43
+ lifecycle?: {
44
+ run?: RunPersistenceMode;
45
+ open?: OpenPersistenceMode;
46
+ };
41
47
  cleanup?: {
42
48
  retainBaselineGenerations?: number;
43
49
  };
@@ -108,6 +114,10 @@ export declare const REHEARSAL_DEFAULTS: Readonly<{
108
114
  containerRuntime: Readonly<{
109
115
  autoStartColima: false;
110
116
  }>;
117
+ lifecycle: Readonly<{
118
+ run: RunPersistenceMode;
119
+ open: OpenPersistenceMode;
120
+ }>;
111
121
  cleanup: Readonly<{
112
122
  retainBaselineGenerations: 2;
113
123
  }>;
@@ -3,6 +3,10 @@ export const REHEARSAL_CONFIG_VERSION = 1;
3
3
  export const REHEARSAL_DEFAULTS = Object.freeze({
4
4
  baseline: Object.freeze({ artifactDirectory: ".rehearsal" }),
5
5
  containerRuntime: Object.freeze({ autoStartColima: false }),
6
+ lifecycle: Object.freeze({
7
+ run: "keep-until-stop",
8
+ open: "keep-until-stop",
9
+ }),
6
10
  cleanup: Object.freeze({ retainBaselineGenerations: 2 }),
7
11
  runtime: Object.freeze({
8
12
  applicationUrl: "http://localhost:5175",
@@ -41,6 +41,10 @@ export declare const normalizeRehearsalConfig: (input: unknown) => Readonly<{
41
41
  containerRuntime: Readonly<{
42
42
  autoStartColima: boolean;
43
43
  }>;
44
+ lifecycle: Readonly<{
45
+ run: "keep-until-stop" | "stop-after-run";
46
+ open: "keep-until-stop" | "stop-on-application-exit";
47
+ }>;
44
48
  cleanup: Readonly<{
45
49
  retainBaselineGenerations: number;
46
50
  }>;
@@ -8,6 +8,12 @@ import { validateConfigurationSchema } from "./configuration_schema.mjs";
8
8
  import { assertBoolean, assertCommand, assertDatabaseIdentifier, assertEnvironmentKey, assertEnvironmentReference, assertHttpStatus, assertIdentifier, assertKnownKeys, assertLoopbackUrl, assertNonEmptyString, assertPlainObject, assertPort, assertPostgresImage, assertPositiveInteger, assertRelativePath, assertStringArray, } from "./configuration_validation.mjs";
9
9
  const LOOPBACK_HOSTS = new Set(REHEARSAL_DEFAULTS.safety.allowedHosts);
10
10
  const SUPPORTED_SUPABASE_OAUTH_PROVIDERS = new Set(["github", "google"]);
11
+ const assertChoice = (value, choices, path) => {
12
+ if (typeof value !== "string" || !choices.includes(value)) {
13
+ throw new Error(`${path} must be one of: ${choices.join(", ")}.`);
14
+ }
15
+ return value;
16
+ };
11
17
  const normalizeApplicationEnvironmentMappings = (value = {}) => {
12
18
  const mappings = assertPlainObject(value, "config.application.environmentVariables");
13
19
  return Object.freeze(Object.fromEntries(Object.entries(mappings).map(([key, reference]) => {
@@ -17,7 +23,7 @@ const normalizeApplicationEnvironmentMappings = (value = {}) => {
17
23
  })));
18
24
  };
19
25
  export const normalizeRehearsalConfig = (input) => {
20
- const { root, project, runtime, runtimeTarget, supabase, postgresql, baseline, containerRuntime, cleanup, preparation, dependentTargets, application, readiness, httpProofs, safety, verification, authentication, } = validateConfigurationSchema(input);
26
+ const { root, project, runtime, runtimeTarget, supabase, postgresql, baseline, containerRuntime, lifecycle, cleanup, preparation, dependentTargets, application, readiness, httpProofs, safety, verification, authentication, } = validateConfigurationSchema(input);
21
27
  const projectName = assertIdentifier(project.name, "config.project.name");
22
28
  const normalizedDependentTargets = dependentTargets.map((target, index) => {
23
29
  const path = `config.dependentTargets[${index}]`;
@@ -190,6 +196,10 @@ export const normalizeRehearsalConfig = (input) => {
190
196
  autoStartColima: assertBoolean(containerRuntime.autoStartColima ??
191
197
  REHEARSAL_DEFAULTS.containerRuntime.autoStartColima, "config.containerRuntime.autoStartColima"),
192
198
  }),
199
+ lifecycle: Object.freeze({
200
+ run: assertChoice(lifecycle.run ?? REHEARSAL_DEFAULTS.lifecycle.run, ["keep-until-stop", "stop-after-run"], "config.lifecycle.run"),
201
+ open: assertChoice(lifecycle.open ?? REHEARSAL_DEFAULTS.lifecycle.open, ["keep-until-stop", "stop-on-application-exit"], "config.lifecycle.open"),
202
+ }),
193
203
  cleanup: Object.freeze({
194
204
  retainBaselineGenerations: assertPositiveInteger(cleanup.retainBaselineGenerations ??
195
205
  REHEARSAL_DEFAULTS.cleanup.retainBaselineGenerations, "config.cleanup.retainBaselineGenerations"),
@@ -67,6 +67,13 @@ export default defineRehearsalConfig({
67
67
  autoStartColima: false,
68
68
  },
69
69
 
70
+ // Choose when Rehearsal stops its database targets. Stopping preserves all
71
+ // database and Storage changes; only reset or discard removes runtime state.
72
+ lifecycle: {
73
+ run: "keep-until-stop", // Or "stop-after-run" for bounded automated checks.
74
+ open: "keep-until-stop", // Or "stop-on-application-exit" after Ctrl+C/Ctrl+Z.
75
+ },
76
+
70
77
  // Keep this many baseline generations, always including the active one.
71
78
  cleanup: {
72
79
  retainBaselineGenerations: 2,
@@ -173,6 +180,13 @@ export default defineRehearsalConfig({
173
180
  autoStartColima: false,
174
181
  },
175
182
 
183
+ // Choose when Rehearsal stops its database targets. Stopping preserves all
184
+ // database changes; only reset or discard removes runtime state.
185
+ lifecycle: {
186
+ run: "keep-until-stop", // Or "stop-after-run" for bounded automated checks.
187
+ open: "keep-until-stop", // Or "stop-on-application-exit" after Ctrl+C/Ctrl+Z.
188
+ },
189
+
176
190
  // Keep this many baseline generations, always including the active one.
177
191
  cleanup: {
178
192
  retainBaselineGenerations: 2,
@@ -41,6 +41,10 @@ export declare const loadRehearsalConfig: ({ projectRoot, configPath, }?: Rehear
41
41
  containerRuntime: Readonly<{
42
42
  autoStartColima: boolean;
43
43
  }>;
44
+ lifecycle: Readonly<{
45
+ run: "keep-until-stop" | "stop-after-run";
46
+ open: "keep-until-stop" | "stop-on-application-exit";
47
+ }>;
44
48
  cleanup: Readonly<{
45
49
  retainBaselineGenerations: number;
46
50
  }>;
@@ -9,6 +9,7 @@ export interface ValidatedConfigurationSchema {
9
9
  postgresql: PlainObject | null;
10
10
  baseline: PlainObject;
11
11
  containerRuntime: PlainObject;
12
+ lifecycle: PlainObject;
12
13
  cleanup: PlainObject;
13
14
  preparation: PlainObject | null;
14
15
  dependentTargets: PlainObject[];
@@ -16,6 +16,7 @@ export const validateConfigurationSchema = (input) => {
16
16
  "postgresql",
17
17
  "baseline",
18
18
  "containerRuntime",
19
+ "lifecycle",
19
20
  "cleanup",
20
21
  "preparation",
21
22
  "runtimePolicy",
@@ -71,6 +72,8 @@ export const validateConfigurationSchema = (input) => {
71
72
  assertKnownKeys(baseline, ["artifactDirectory", "sanitizationPolicy"], "config.baseline");
72
73
  const containerRuntime = assertPlainObject(root.containerRuntime ?? {}, "config.containerRuntime");
73
74
  assertKnownKeys(containerRuntime, ["autoStartColima"], "config.containerRuntime");
75
+ const lifecycle = assertPlainObject(root.lifecycle ?? {}, "config.lifecycle");
76
+ assertKnownKeys(lifecycle, ["run", "open"], "config.lifecycle");
74
77
  const cleanup = assertPlainObject(root.cleanup ?? {}, "config.cleanup");
75
78
  assertKnownKeys(cleanup, ["retainBaselineGenerations"], "config.cleanup");
76
79
  const preparation = root.preparation === undefined
@@ -147,6 +150,7 @@ export const validateConfigurationSchema = (input) => {
147
150
  postgresql,
148
151
  baseline,
149
152
  containerRuntime,
153
+ lifecycle,
150
154
  cleanup,
151
155
  preparation,
152
156
  dependentTargets,
@@ -4,6 +4,7 @@ import { resolveActiveBaselinePaths, verifyActiveBaseline, } from "../baseline/a
4
4
  import { readBoundRuntimeSanitizationPolicy } from "../baseline/sanitization_policy.mjs";
5
5
  import { readMigrationFileInventory } from "./migration_history.mjs";
6
6
  import { createCandidateMigrationReceipt } from "./restore.mjs";
7
+ import { reachOperationCancellationCheckpoint, withOperationCancellationShield, } from "../shared/cancellation.mjs";
7
8
  export const readActiveRuntimeInput = async ({ artifactRoot, manifestPath, validate, }) => {
8
9
  const [baseline, paths, policyBytes] = await Promise.all([
9
10
  verifyActiveBaseline({ artifactRoot }),
@@ -70,10 +71,17 @@ export const readMatchingRuntimeMarker = async ({ path, expected, mismatchMessag
70
71
  };
71
72
  export const withOwnedRuntimeRollback = async ({ operation, rollback, }) => {
72
73
  try {
73
- return await operation();
74
+ const result = await operation();
75
+ await reachOperationCancellationCheckpoint();
76
+ return result;
74
77
  }
75
78
  catch (error) {
76
- await rollback().catch(() => undefined);
79
+ try {
80
+ await withOperationCancellationShield(rollback);
81
+ }
82
+ catch (rollbackError) {
83
+ throw new AggregateError([error, rollbackError], "The runtime operation failed and its owned runtime could not be removed automatically.");
84
+ }
77
85
  throw error;
78
86
  }
79
87
  };
@@ -0,0 +1,19 @@
1
+ /** Explicit, non-destructive persistence policy for project-owned runtimes. */
2
+ import type { OpenPersistenceMode, RunPersistenceMode } from "../project/configuration_contract.mjs";
3
+ export type RuntimePersistenceMode = RunPersistenceMode | OpenPersistenceMode;
4
+ export interface RuntimePersistenceOutcome<StopResult> {
5
+ readonly mode: RuntimePersistenceMode;
6
+ readonly runtimeState: "running" | "stopped";
7
+ readonly dataState: "preserved";
8
+ readonly stop: StopResult | null;
9
+ }
10
+ export declare const shouldStopRuntime: (mode: RuntimePersistenceMode) => boolean;
11
+ export declare const applyRuntimePersistence: <Result, StopResult>({ mode, operation, stop, canStop, }: {
12
+ mode: RuntimePersistenceMode;
13
+ operation: () => Promise<Result>;
14
+ stop: () => Promise<StopResult>;
15
+ canStop?: () => boolean;
16
+ }) => Promise<{
17
+ result: Result;
18
+ lifecycle: RuntimePersistenceOutcome<StopResult>;
19
+ }>;
@@ -0,0 +1,40 @@
1
+ /** Explicit, non-destructive persistence policy for project-owned runtimes. */
2
+ import { withOperationCancellationShield } from "../shared/cancellation.mjs";
3
+ export const shouldStopRuntime = (mode) => mode === "stop-after-run" || mode === "stop-on-application-exit";
4
+ export const applyRuntimePersistence = async ({ mode, operation, stop, canStop = () => true, }) => {
5
+ let result;
6
+ let operationFailed = false;
7
+ let operationError;
8
+ try {
9
+ result = await operation();
10
+ }
11
+ catch (error) {
12
+ operationFailed = true;
13
+ operationError = error;
14
+ }
15
+ let stopResult = null;
16
+ let stopped = false;
17
+ if (shouldStopRuntime(mode) && canStop()) {
18
+ try {
19
+ stopResult = await withOperationCancellationShield(stop);
20
+ stopped = true;
21
+ }
22
+ catch (stopError) {
23
+ if (operationFailed) {
24
+ throw new AggregateError([operationError, stopError], "The Rehearsal operation failed and its owned runtime could not be stopped automatically.");
25
+ }
26
+ throw stopError;
27
+ }
28
+ }
29
+ if (operationFailed)
30
+ throw operationError;
31
+ return {
32
+ result: result,
33
+ lifecycle: Object.freeze({
34
+ mode,
35
+ runtimeState: stopped ? "stopped" : "running",
36
+ dataState: "preserved",
37
+ stop: stopResult,
38
+ }),
39
+ };
40
+ };
@@ -49,6 +49,11 @@ export declare const buildRehearsalPlan: (options?: RehearsalConfigPathOptions)
49
49
  sha256: string;
50
50
  }[];
51
51
  };
52
+ lifecycle: {
53
+ run: "keep-until-stop" | "stop-after-run";
54
+ open: "keep-until-stop" | "stop-on-application-exit";
55
+ dataOnStop: string;
56
+ };
52
57
  execution: string[];
53
58
  guarantee: string;
54
59
  paths: {