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

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 (75) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +4 -1
  3. package/dist/package.json +4 -1
  4. package/dist/scripts/runtime/manage_database.mjs +17 -8
  5. package/dist/src/baseline/artifact.d.mts +14 -0
  6. package/dist/src/baseline/artifact.mjs +50 -0
  7. package/dist/src/cli/application_command_handlers.mjs +9 -4
  8. package/dist/src/cli/basic_command_handlers.mjs +2 -0
  9. package/dist/src/cli/inspection_command_handlers.mjs +45 -0
  10. package/dist/src/cli/project_commands.d.mts +9 -1
  11. package/dist/src/cli/project_commands.mjs +2 -2
  12. package/dist/src/cli/rehearsal.mjs +77 -65
  13. package/dist/src/cli/runtime_action_handlers.mjs +36 -9
  14. package/dist/src/cli/runtime_commands.d.mts +71 -7
  15. package/dist/src/cli/runtime_commands.mjs +99 -36
  16. package/dist/src/cli/source_commands.d.mts +12 -2
  17. package/dist/src/cli/source_commands.mjs +24 -15
  18. package/dist/src/index.d.mts +2 -2
  19. package/dist/src/index.mjs +1 -1
  20. package/dist/src/project/configuration.d.mts +2 -0
  21. package/dist/src/project/configuration.mjs +1 -0
  22. package/dist/src/project/configuration_contract.d.mts +10 -0
  23. package/dist/src/project/configuration_contract.mjs +4 -0
  24. package/dist/src/project/configuration_normalization.d.mts +4 -0
  25. package/dist/src/project/configuration_normalization.mjs +11 -1
  26. package/dist/src/project/configuration_rendering.mjs +14 -0
  27. package/dist/src/project/configuration_resolution.d.mts +4 -0
  28. package/dist/src/project/configuration_schema.d.mts +1 -0
  29. package/dist/src/project/configuration_schema.mjs +4 -0
  30. package/dist/src/project/project_discovery.d.mts +15 -0
  31. package/dist/src/project/project_discovery.mjs +350 -0
  32. package/dist/src/project/project_discovery_application.d.mts +16 -0
  33. package/dist/src/project/project_discovery_application.mjs +190 -0
  34. package/dist/src/project/project_discovery_contract.d.mts +78 -0
  35. package/dist/src/project/project_discovery_contract.mjs +43 -0
  36. package/dist/src/project/project_discovery_metadata.d.mts +14 -0
  37. package/dist/src/project/project_discovery_metadata.mjs +138 -0
  38. package/dist/src/runtime/lifecycle_engine.d.mts +6 -0
  39. package/dist/src/runtime/lifecycle_engine.mjs +24 -2
  40. package/dist/src/runtime/persistence.d.mts +19 -0
  41. package/dist/src/runtime/persistence.mjs +40 -0
  42. package/dist/src/runtime/plan.d.mts +18 -0
  43. package/dist/src/runtime/plan.mjs +27 -1
  44. package/dist/src/runtime/status.d.mts +32 -0
  45. package/dist/src/runtime/status.mjs +146 -0
  46. package/dist/src/shared/cancellation.d.mts +18 -0
  47. package/dist/src/shared/cancellation.mjs +80 -0
  48. package/dist/src/shared/diagnostics.d.mts +1 -0
  49. package/dist/src/shared/diagnostics.mjs +1 -0
  50. package/dist/src/shared/operation_guard.d.mts +12 -0
  51. package/dist/src/shared/operation_guard.mjs +91 -2
  52. package/dist/src/shared/owned_process.d.mts +30 -0
  53. package/dist/src/shared/owned_process.mjs +117 -0
  54. package/dist/src/targets/postgresql.d.mts +0 -4
  55. package/dist/src/targets/postgresql.mjs +40 -12
  56. package/dist/src/targets/postgresql_process.d.mts +8 -0
  57. package/dist/src/targets/postgresql_process.mjs +34 -0
  58. package/dist/src/targets/postgresql_status.d.mts +24 -0
  59. package/dist/src/targets/postgresql_status.mjs +64 -0
  60. package/dist/src/targets/supabase.mjs +34 -16
  61. package/dist/src/targets/supabase_environment.mjs +2 -0
  62. package/dist/src/targets/supabase_process.d.mts +7 -0
  63. package/dist/src/targets/supabase_process.mjs +21 -0
  64. package/dist/src/targets/supabase_status.d.mts +15 -0
  65. package/dist/src/targets/supabase_status.mjs +66 -0
  66. package/dist/src/targets/target.d.mts +3 -1
  67. package/dist/src/targets/target.mjs +3 -1
  68. package/docs/README.md +1 -0
  69. package/docs/architecture.md +8 -0
  70. package/docs/commands.md +188 -12
  71. package/docs/configuration.md +12 -0
  72. package/docs/runtime-lifecycle.md +31 -5
  73. package/docs/typescript.md +11 -0
  74. package/docs/vscode.md +53 -0
  75. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,58 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## [0.1.0-rc.4] - 2026-10-11
9
+
10
+ ### Added
11
+
12
+ - `discover --json` exposes a versioned, read-only Project Profile for editor clients,
13
+ separating proven facts, evidence-backed suggestions, and decisions that remain with
14
+ the user.
15
+ - Project discovery reports bounded application, package-manager, migration, target,
16
+ runtime, baseline-retention, configuration, and local-address metadata without reading
17
+ environment files, contacting databases, starting containers, or executing project code.
18
+ - Public TypeScript contracts now cover discovery results, retained baseline generations,
19
+ application readiness, cleanup policy, and structured PostgreSQL and Supabase runtime
20
+ state consumed by the Rehearsal VS Code extension.
21
+
22
+ ### Changed
23
+
24
+ - Plans and status results expose the additional bounded metadata required for a
25
+ project-aware editor experience while retaining core ownership of all safety checks and
26
+ mutating operations.
27
+ - Documentation now includes the supported VS Code integration boundary, privacy model,
28
+ compatibility floor, and safe automatic-refresh behavior.
29
+
30
+ ## [0.1.0-rc.3] - 2026-10-10
31
+
32
+ ### Added
33
+
34
+ - `status --json` now exposes bounded, target-scoped runtime state without requiring
35
+ editor clients to parse human-formatted process output.
36
+ - `activity --json` reports idle, busy, stale, invalid, and unavailable project-operation
37
+ state without exposing lock tokens, process arguments, credentials, or private paths.
38
+ - Supported mutating operations now have an explicit cancellation contract: owned child
39
+ groups receive one bounded shutdown request, rollback and lock cleanup complete before
40
+ exit `130`, and unrelated processes and Docker resources remain untouched.
41
+ - Root configuration can now choose `stop-after-run` for bounded automated checks and
42
+ `stop-on-application-exit` for bounded hands-on sessions. Both modes stop only the
43
+ configured Rehearsal targets while preserving database and Storage volumes, applied
44
+ migrations, and sandbox edits for the next `start` or `open`.
45
+ - Plans, JSON results, terminal receipts, generated configuration comments, and the
46
+ lifecycle guide now report the selected persistence mode and resulting runtime state.
47
+
48
+ ### Changed
49
+
50
+ - Transient application and helper processes are cleaned up before an opted-in runtime
51
+ stop after success, proof failure, readiness timeout, or supported interruption. The
52
+ compatibility default remains `keep-until-stop` for both `run` and `open`.
53
+
54
+ ### Fixed
55
+
56
+ - Read-only runtime inspection now works when a VS Code extension launches the CLI
57
+ through Electron's executable in Node mode. That mode is preserved only for
58
+ Rehearsal's nested runtime manager and is not exposed to project-owned commands.
59
+
8
60
  ## [0.1.0-rc.2] - 2026-10-09
9
61
 
10
62
  ### Changed
@@ -575,7 +627,9 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
575
627
  publication uses short-lived trusted OIDC, and every release tag must already exist on
576
628
  protected `main`.
577
629
 
578
- [Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.2...HEAD
630
+ [Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.4...HEAD
631
+ [0.1.0-rc.4]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.3...v0.1.0-rc.4
632
+ [0.1.0-rc.3]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.2...v0.1.0-rc.3
579
633
  [0.1.0-rc.2]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.1...v0.1.0-rc.2
580
634
  [0.1.0-rc.1]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.18...v0.1.0-rc.1
581
635
  [0.1.0-beta.18]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-beta.17...v0.1.0-beta.18
package/README.md CHANGED
@@ -116,7 +116,9 @@ source-owner approval; see [Standalone workflow](docs/standalone-workflow.md).
116
116
 
117
117
  `npx rehearsal open` starts and verifies the existing local runtime, keeps the configured
118
118
  app available until you press `Ctrl+C`, and preserves database and Storage changes for
119
- the next session.
119
+ the next session. Set `lifecycle.open` to `stop-on-application-exit` when you also want
120
+ its database targets stopped afterward; `lifecycle.run: "stop-after-run"` gives automated
121
+ checks the same bounded, non-destructive behavior.
120
122
 
121
123
  For an approved production-shaped copy, the optional sequence is `source plan`, `source
122
124
  apply`, `refresh`, and `source retire`. `refresh` verifies the replacement before it
@@ -164,6 +166,7 @@ Start here:
164
166
 
165
167
  - [Documentation home](docs/index.md) — choose the shortest path for your goal
166
168
  - [Getting started](docs/getting-started.md) — set up your own project
169
+ - [VS Code extension](docs/vscode.md) — use the project-aware visual workbench
167
170
  - [Safe hands-on tutorial](docs/tutorial.md) — try the full flow in a disposable project
168
171
  - [Troubleshooting](docs/troubleshooting.md) — fix common setup problems
169
172
  - [Next steps](docs/roadmap.md) — see the current product sequence
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehearsal-db/core",
3
- "version": "0.1.0-rc.2",
3
+ "version": "0.1.0-rc.4",
4
4
  "private": false,
5
5
  "description": "Catch PostgreSQL and Supabase migrations that pass on empty databases but fail on real data—before deploy.",
6
6
  "repository": {
@@ -22,6 +22,9 @@
22
22
  "bin": {
23
23
  "rehearsal": "dist/src/cli/rehearsal.mjs"
24
24
  },
25
+ "rehearsalContracts": {
26
+ "projectDiscovery": 1
27
+ },
25
28
  "exports": {
26
29
  ".": {
27
30
  "types": "./types/configuration.d.mts",
@@ -6,11 +6,20 @@
6
6
  import { loadRehearsalConfig } from "../../src/project/configuration.mjs";
7
7
  import { parseRuntimeInvocation } from "../../src/targets/target.mjs";
8
8
  import { runRuntimeTarget } from "../../src/targets/driver.mjs";
9
- const invocation = parseRuntimeInvocation();
10
- const { config } = await loadRehearsalConfig({
11
- projectRoot: process.cwd(),
12
- ...(invocation.configPath === undefined
13
- ? {}
14
- : { configPath: invocation.configPath }),
15
- });
16
- await runRuntimeTarget(config.runtime.target);
9
+ import { runWithOperationCancellation } from "../../src/shared/cancellation.mjs";
10
+ const run = async () => {
11
+ const invocation = parseRuntimeInvocation();
12
+ const { config } = await loadRehearsalConfig({
13
+ projectRoot: process.cwd(),
14
+ ...(invocation.configPath === undefined
15
+ ? {}
16
+ : { configPath: invocation.configPath }),
17
+ });
18
+ await runRuntimeTarget(config.runtime.target);
19
+ };
20
+ if (process.env.REHEARSAL_CANCELLABLE_OPERATION === "1") {
21
+ await runWithOperationCancellation({ task: run });
22
+ }
23
+ else {
24
+ await run();
25
+ }
@@ -66,6 +66,17 @@ export interface BaselinePrunePlan {
66
66
  readonly retained: string[];
67
67
  readonly removed: string[];
68
68
  }
69
+ export interface BaselineGenerationSummary {
70
+ readonly generationId: string;
71
+ readonly createdAt: string | null;
72
+ readonly active: boolean;
73
+ readonly formatVersion: 1 | 2;
74
+ readonly rowCount: number;
75
+ readonly tableCount: number;
76
+ readonly migrationCount: number;
77
+ readonly migrationCutoff: string;
78
+ readonly storageAssetCount: number;
79
+ }
69
80
  export declare const createBaselineGenerationId: ({ now, }?: {
70
81
  now?: Date;
71
82
  }) => string;
@@ -91,6 +102,9 @@ export declare const removeStagedBaselineGeneration: ({ artifactRoot, generation
91
102
  export declare const resolveActiveBaselinePaths: ({ artifactRoot, }: {
92
103
  artifactRoot: string;
93
104
  }) => Promise<ActiveBaselinePaths>;
105
+ export declare const inspectBaselineGenerations: ({ artifactRoot, }: {
106
+ artifactRoot: string;
107
+ }) => Promise<readonly BaselineGenerationSummary[]>;
94
108
  export declare const verifyActiveBaseline: ({ artifactRoot, }: {
95
109
  artifactRoot: string;
96
110
  }) => Promise<BaselineManifest>;
@@ -46,6 +46,12 @@ const assertGenerationId = (value) => {
46
46
  }
47
47
  return value;
48
48
  };
49
+ const generationCreatedAt = (generationId) => {
50
+ const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/u.exec(generationId);
51
+ return match
52
+ ? `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`
53
+ : null;
54
+ };
49
55
  const assertArtifactRoot = (artifactRoot) => {
50
56
  const resolved = resolve(artifactRoot);
51
57
  if (basename(resolved) !== ".rehearsal" || resolved === sep) {
@@ -364,6 +370,50 @@ export const resolveActiveBaselinePaths = async ({ artifactRoot, }) => {
364
370
  schemaPath: join(generationDirectory, "production-schema.sql"),
365
371
  };
366
372
  };
373
+ export const inspectBaselineGenerations = async ({ artifactRoot, }) => {
374
+ const root = assertArtifactRoot(artifactRoot);
375
+ const active = basename((await resolveActiveBaselinePaths({ artifactRoot: root }))
376
+ .generationDirectory);
377
+ const generationsRoot = join(root, generationsDirectoryName);
378
+ await verifyNoSymlink(generationsRoot, "The Rehearsal generations directory");
379
+ const entries = await readdir(generationsRoot, { withFileTypes: true });
380
+ if (entries.length > 1_000) {
381
+ throw new Error("The Rehearsal baseline generation limit was exceeded.");
382
+ }
383
+ const summaries = [];
384
+ for (const entry of entries.sort((left, right) => right.name.localeCompare(left.name))) {
385
+ if (!entry.isDirectory() || !generationPattern.test(entry.name))
386
+ continue;
387
+ const generationDirectory = assertInsideRoot(root, join(generationsRoot, entry.name));
388
+ await verifyNoSymlink(generationDirectory, "A Rehearsal baseline generation");
389
+ const manifest = parseManifest(await readFile(join(generationDirectory, "baseline-manifest.json"), "utf8"));
390
+ if (manifest.generationId !== entry.name ||
391
+ ![1, 2].includes(manifest.formatVersion) ||
392
+ !Number.isSafeInteger(manifest.rowCount) ||
393
+ manifest.rowCount < 0 ||
394
+ !isObject(manifest.tableCounts) ||
395
+ !isObject(manifest.migrations) ||
396
+ !isObject(manifest.storageAssets) ||
397
+ typeof manifest.migrationCutoff !== "string") {
398
+ throw new Error(`Rehearsal baseline generation ${entry.name} has an invalid manifest.`);
399
+ }
400
+ summaries.push({
401
+ generationId: entry.name,
402
+ createdAt: generationCreatedAt(entry.name),
403
+ active: entry.name === active,
404
+ formatVersion: manifest.formatVersion,
405
+ rowCount: manifest.rowCount,
406
+ tableCount: Object.keys(manifest.tableCounts).length,
407
+ migrationCount: Object.keys(manifest.migrations).length,
408
+ migrationCutoff: manifest.migrationCutoff,
409
+ storageAssetCount: Object.keys(manifest.storageAssets).length,
410
+ });
411
+ }
412
+ if (!summaries.some(({ active: isActive }) => isActive)) {
413
+ throw new Error("The active Rehearsal baseline generation is unavailable.");
414
+ }
415
+ return Object.freeze(summaries.map((summary) => Object.freeze(summary)));
416
+ };
367
417
  export const verifyActiveBaseline = async ({ artifactRoot, }) => {
368
418
  const paths = await resolveActiveBaselinePaths({ artifactRoot });
369
419
  const { generationDirectory } = paths;
@@ -14,10 +14,10 @@ export const createApplicationCommandHandlers = ({ openRuntimeApplication, }) =>
14
14
  onReady: (ready) => {
15
15
  const lines = [
16
16
  `Application: ${ready.url}`,
17
- "The verified database runtime stays running and keeps your local changes.",
17
+ "The configured lifecycle policy will preserve your local database and Storage changes.",
18
18
  guided
19
19
  ? "Press Ctrl+C to close the app and return here, or Ctrl+Z to exit Rehearsal."
20
- : "Press Ctrl+C or Ctrl+Z to close the app. Rehearsal will leave the database runtime running.",
20
+ : "Press Ctrl+C or Ctrl+Z to close the app. Rehearsal will then apply the configured lifecycle policy.",
21
21
  ];
22
22
  if (useStyledPrompts(flags)) {
23
23
  prompts.note(lines.join("\n"), "SANDBOX READY");
@@ -35,6 +35,7 @@ export const createApplicationCommandHandlers = ({ openRuntimeApplication, }) =>
35
35
  data.started.output,
36
36
  data.verified.output,
37
37
  ...data.preparations.map((preparation) => preparation.output),
38
+ data.lifecycle.stop?.output,
38
39
  ]
39
40
  .filter(Boolean)
40
41
  .join("\n");
@@ -44,8 +45,12 @@ export const createApplicationCommandHandlers = ({ openRuntimeApplication, }) =>
44
45
  render: (result) => [
45
46
  "SANDBOX APP CLOSED",
46
47
  `Application stopped after ${result.application.stoppedBy}.`,
47
- "The database runtime and its local database and Storage changes were preserved.",
48
- "Run rehearsal open to return, rehearsal verify to check it, or rehearsal stop to stop the databases.",
48
+ result.lifecycle.runtimeState === "stopped"
49
+ ? `Lifecycle ${result.lifecycle.mode} stopped the database runtime; its local database and Storage changes were preserved.`
50
+ : `Lifecycle ${result.lifecycle.mode} left the database runtime running; its local database and Storage changes were preserved.`,
51
+ result.lifecycle.runtimeState === "stopped"
52
+ ? "Run rehearsal start or rehearsal open to resume."
53
+ : "Run rehearsal open to return, rehearsal verify to check it, or rehearsal stop to stop the databases.",
49
54
  ].join("\n"),
50
55
  });
51
56
  }),
@@ -20,7 +20,9 @@ Commands:
20
20
  identity claim --identity= --confirm-identity= Apply the exact local association
21
21
  identity connect [--identity=] Keep the app open, detect sign-in, and connect once
22
22
  doctor Check whether Rehearsal is safe and ready
23
+ discover Inspect project metadata and explain setup suggestions
23
24
  support Print a safe, copy-ready support report
25
+ activity Report idle, busy, stale, or invalid operation state
24
26
  explain Show the immutable execution plan
25
27
  run --dry-run Alias the exact explain plan without mutations
26
28
  run --confirm-candidates= Execute reset, migration, and verification locally
@@ -1,10 +1,55 @@
1
1
  /** Read-only readiness, support, planning, and inspection command handlers. */
2
2
  import { collectRehearsalSupportReport } from "../project/support_report.mjs";
3
+ import { discoverProjectProfile } from "../project/project_discovery.mjs";
3
4
  import { inspectRehearsalBaseline, inspectRehearsalMigrations, } from "../runtime/plan.mjs";
4
5
  import { buildRuntimeStackPlan, runRuntimeStackDoctor, topologyCandidateSummary, } from "./runtime_commands.mjs";
5
6
  import { renderBaseline, renderCandidates, renderDoctor, renderMigrations, renderPlan, renderSupportReport, } from "./renderers.mjs";
6
7
  import { exactCommands, renderedCommand } from "./command_contract.mjs";
8
+ import { inspectProjectOperationState } from "../shared/operation_guard.mjs";
7
9
  export const createInspectionCommandHandlers = () => [
10
+ exactCommands("discover", ["discover"], async ({ planOptions }) => {
11
+ const data = await discoverProjectProfile({
12
+ projectRoot: planOptions.projectRoot ?? process.cwd(),
13
+ });
14
+ return renderedCommand({
15
+ data,
16
+ render: (result) => [
17
+ `PROJECT PROFILE — READ ONLY`,
18
+ `Detected: ${result.detected.length}`,
19
+ `Suggested: ${result.suggested.length}`,
20
+ `Your decision: ${result.decisions.length}`,
21
+ `Configuration: ${result.configuration.state.replaceAll("_", " ")}`,
22
+ "",
23
+ "Run rehearsal discover --json for evidence and proposed configuration fields.",
24
+ ].join("\n"),
25
+ });
26
+ }),
27
+ exactCommands("activity", ["activity"], async ({ planOptions }) => {
28
+ const data = await inspectProjectOperationState({
29
+ projectRoot: planOptions.projectRoot ?? process.cwd(),
30
+ });
31
+ return renderedCommand({
32
+ data,
33
+ render: (result) => {
34
+ if (result.state === "idle")
35
+ return "Rehearsal activity: idle.";
36
+ if (result.state === "invalid") {
37
+ return "Rehearsal activity: invalid lock state. No lock was changed; finish or diagnose the owning operation before retrying a mutation.";
38
+ }
39
+ const label = result.state === "busy" ? "busy" : "stale";
40
+ return [
41
+ `Rehearsal activity: ${label}.`,
42
+ `Operation: ${result.operation.kind}`,
43
+ `Started: ${result.operation.startedAt}`,
44
+ result.operation.rehearsalVersion
45
+ ? `Rehearsal: ${result.operation.rehearsalVersion}`
46
+ : null,
47
+ ]
48
+ .filter(Boolean)
49
+ .join("\n");
50
+ },
51
+ });
52
+ }),
8
53
  exactCommands("doctor", ["doctor"], async ({ guided, flags, planOptions }) => {
9
54
  const data = await runRuntimeStackDoctor(planOptions);
10
55
  return renderedCommand({
@@ -17,7 +17,7 @@ type RunManager = (input: {
17
17
  flags: RehearsalCliFlags;
18
18
  configPath?: string;
19
19
  targetName?: string;
20
- }) => unknown;
20
+ }) => Promise<unknown>;
21
21
  interface ProjectCommandInput {
22
22
  readonly flags: RehearsalCliFlags;
23
23
  readonly planOptions: RehearsalConfigPathOptions;
@@ -67,6 +67,10 @@ export declare const createProjectCommands: ({ projectRoot, runManager, }: {
67
67
  containerRuntime: Readonly<{
68
68
  autoStartColima: boolean;
69
69
  }>;
70
+ lifecycle: Readonly<{
71
+ run: "keep-until-stop" | "stop-after-run";
72
+ open: "keep-until-stop" | "stop-on-application-exit";
73
+ }>;
70
74
  cleanup: Readonly<{
71
75
  retainBaselineGenerations: number;
72
76
  }>;
@@ -191,6 +195,10 @@ export declare const createProjectCommands: ({ projectRoot, runManager, }: {
191
195
  containerRuntime: Readonly<{
192
196
  autoStartColima: boolean;
193
197
  }>;
198
+ lifecycle: Readonly<{
199
+ run: "keep-until-stop" | "stop-after-run";
200
+ open: "keep-until-stop" | "stop-on-application-exit";
201
+ }>;
194
202
  cleanup: Readonly<{
195
203
  retainBaselineGenerations: number;
196
204
  }>;
@@ -128,7 +128,7 @@ export const createProjectCommands = ({ projectRoot, runManager, }) => {
128
128
  projectRoot,
129
129
  configPath: target.configPath,
130
130
  removeRuntime: async () => {
131
- runManager({
131
+ await runManager({
132
132
  action: "discard",
133
133
  flags,
134
134
  configPath: target.configPath,
@@ -151,7 +151,7 @@ export const createProjectCommands = ({ projectRoot, runManager, }) => {
151
151
  : { confirmation: flags.cleanupConfirmation }),
152
152
  ...planOptions,
153
153
  removeRuntime: async () => {
154
- runManager({ action: "discard", flags });
154
+ await runManager({ action: "discard", flags });
155
155
  },
156
156
  });
157
157
  return { mode: "written", plan, applied };
@@ -20,6 +20,7 @@ import { createProjectCommands } from "./project_commands.mjs";
20
20
  import { buildRuntimeStackPlan, createRuntimeCommands, runRuntimeStackDoctor, topologyCandidateSummary, topologyCommandEnvironment, } from "./runtime_commands.mjs";
21
21
  import { buildRefreshWorkflowPlan, createRefreshWorkflow, } from "./source_commands.mjs";
22
22
  import { installGuidedExitShortcut, useStyledPrompts } from "./terminal.mjs";
23
+ import { runWithOperationCancellation, supportsOperationCancellation, } from "../shared/cancellation.mjs";
23
24
  const packageRoot = fileURLToPath(new URL("../../..", import.meta.url));
24
25
  const projectRoot = process.cwd();
25
26
  const managerPath = join(packageRoot, "dist/scripts/runtime/manage_database.mjs");
@@ -89,83 +90,94 @@ const main = async () => {
89
90
  const guided = command === "guide" || wantsAutomaticGuide;
90
91
  const mutating = commandMutatesProject({ command, flags, guided });
91
92
  const operation = mutating
92
- ? await acquireProjectOperation({ projectRoot, command })
93
+ ? await acquireProjectOperation({
94
+ projectRoot,
95
+ command: guided ? "guide" : command,
96
+ })
93
97
  : null;
94
- if (operation) {
95
- activePackageFingerprint = createInstalledPackageFingerprint({
96
- packageRoot,
97
- });
98
- }
99
- try {
100
- if (!guided) {
101
- await executeCommand({ command, flags, planOptions, guided: false });
102
- return;
103
- }
104
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
105
- throw new Error("The guided home screen requires an interactive terminal. Run rehearsal --help to list scriptable commands.");
106
- }
107
- flags.guided = true;
108
- const installExitShortcut = () => installGuidedExitShortcut(flags, {
109
- beforeExit: () => operation?.releaseSync(),
110
- });
111
- let removeGuidedExitShortcut = installExitShortcut();
112
- if (useStyledPrompts(flags)) {
113
- prompts.intro("REHEARSAL · Safe local migration testing");
98
+ const run = async () => {
99
+ if (operation) {
100
+ activePackageFingerprint = createInstalledPackageFingerprint({
101
+ packageRoot,
102
+ });
114
103
  }
115
104
  try {
116
- while (true) {
117
- resetGuidedFlags(flags);
118
- resetCommandSessionState(state);
119
- const selected = await runGuidedHome({ flags, planOptions });
120
- if (!selected)
121
- break;
122
- if (selected === "home")
123
- continue;
124
- const opensApplication = selected === "open";
125
- if (opensApplication) {
126
- removeGuidedExitShortcut?.();
127
- removeGuidedExitShortcut = null;
105
+ if (!guided) {
106
+ await executeCommand({ command, flags, planOptions, guided: false });
107
+ return;
108
+ }
109
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
110
+ throw new Error("The guided home screen requires an interactive terminal. Run rehearsal --help to list scriptable commands.");
111
+ }
112
+ flags.guided = true;
113
+ const installExitShortcut = () => installGuidedExitShortcut(flags, {
114
+ beforeExit: () => operation?.releaseSync(),
115
+ });
116
+ let removeGuidedExitShortcut = installExitShortcut();
117
+ if (useStyledPrompts(flags)) {
118
+ prompts.intro("REHEARSAL · Safe local migration testing");
119
+ }
120
+ try {
121
+ while (true) {
122
+ resetGuidedFlags(flags);
123
+ resetCommandSessionState(state);
124
+ const selected = await runGuidedHome({ flags, planOptions });
125
+ if (!selected)
126
+ break;
127
+ if (selected === "home")
128
+ continue;
129
+ const opensApplication = selected === "open";
130
+ if (opensApplication) {
131
+ removeGuidedExitShortcut?.();
132
+ removeGuidedExitShortcut = null;
133
+ }
134
+ try {
135
+ await executeCommand({
136
+ command: selected,
137
+ flags,
138
+ planOptions,
139
+ guided: true,
140
+ });
141
+ }
142
+ finally {
143
+ if (opensApplication) {
144
+ removeGuidedExitShortcut = installExitShortcut();
145
+ }
146
+ }
147
+ if (flags.exitGuidedSession)
148
+ break;
128
149
  }
150
+ }
151
+ finally {
152
+ removeGuidedExitShortcut?.();
153
+ }
154
+ if (useStyledPrompts(flags)) {
155
+ prompts.outro("See you at the next rehearsal.");
156
+ }
157
+ else {
158
+ console.log("See you at the next rehearsal.");
159
+ }
160
+ }
161
+ finally {
162
+ if (operation) {
129
163
  try {
130
- await executeCommand({
131
- command: selected,
132
- flags,
133
- planOptions,
134
- guided: true,
164
+ assertInstalledPackageFingerprint({
165
+ packageRoot,
166
+ expected: activePackageFingerprint,
135
167
  });
136
168
  }
137
169
  finally {
138
- if (opensApplication) {
139
- removeGuidedExitShortcut = installExitShortcut();
140
- }
170
+ activePackageFingerprint = null;
171
+ await operation.release();
141
172
  }
142
- if (flags.exitGuidedSession)
143
- break;
144
173
  }
145
174
  }
146
- finally {
147
- removeGuidedExitShortcut?.();
148
- }
149
- if (useStyledPrompts(flags)) {
150
- prompts.outro("See you at the next rehearsal.");
151
- }
152
- else {
153
- console.log("See you at the next rehearsal.");
154
- }
175
+ };
176
+ if (operation && !guided && supportsOperationCancellation(command)) {
177
+ await runWithOperationCancellation({ task: run });
155
178
  }
156
- finally {
157
- if (operation) {
158
- try {
159
- assertInstalledPackageFingerprint({
160
- packageRoot,
161
- expected: activePackageFingerprint,
162
- });
163
- }
164
- finally {
165
- activePackageFingerprint = null;
166
- await operation.release();
167
- }
168
- }
179
+ else {
180
+ await run();
169
181
  }
170
182
  };
171
183
  try {
@@ -1,6 +1,7 @@
1
1
  /** Database runtime action handlers shared by guided and scripted CLI flows. */
2
2
  import * as prompts from "@clack/prompts";
3
3
  import { buildApplicationEnvironment, runHttpProofs, startApplicationSession, } from "../application/session.mjs";
4
+ import { applyRuntimePersistence } from "../runtime/persistence.mjs";
4
5
  import { exactCommands, renderedCommand, silentCommand, } from "./command_contract.mjs";
5
6
  import { topologyCommandEnvironment, topologyEnvironmentFiles, } from "./runtime_commands.mjs";
6
7
  import { formatDuration, isHumanTerminal, terminalStyle, useStyledPrompts, } from "./terminal.mjs";
@@ -42,17 +43,20 @@ export const createRuntimeActionHandlers = ({ prepareCandidateConfirmation, prep
42
43
  });
43
44
  const { runtime, topology } = stack;
44
45
  const stackEnvironment = topologyCommandEnvironment(topology);
45
- const preparations = ["run", "reset", "migrate"].includes(command)
46
- ? prepareDependentTargets({
47
- topology,
48
- environment: stackEnvironment,
49
- })
50
- : [];
46
+ let preparations = [];
51
47
  let applicationProof;
52
48
  let dependentProofs = [];
53
49
  let httpProofResults = [];
54
50
  let applicationReadiness;
55
- if (command === "run") {
51
+ const completeFollowups = async () => {
52
+ preparations = ["run", "reset", "migrate"].includes(command)
53
+ ? await prepareDependentTargets({
54
+ topology,
55
+ environment: stackEnvironment,
56
+ })
57
+ : [];
58
+ if (command !== "run")
59
+ return;
56
60
  const applicationConfig = topology.primary.config.application;
57
61
  const mappedApplicationEnvironment = Object.keys(applicationConfig.environmentVariables).length
58
62
  ? await buildApplicationEnvironment({
@@ -111,7 +115,19 @@ export const createRuntimeActionHandlers = ({ prepareCandidateConfirmation, prep
111
115
  else if (isHumanTerminal(flags)) {
112
116
  console.log(`${terminalStyle(flags, "32", "✓")} Project proofs passed.`);
113
117
  }
114
- }
118
+ };
119
+ const persistence = command === "run"
120
+ ? await applyRuntimePersistence({
121
+ mode: topology.primary.config.lifecycle.run,
122
+ operation: completeFollowups,
123
+ stop: async () => (await runRuntimeStack({
124
+ command: "stop",
125
+ flags,
126
+ planOptions,
127
+ session: state,
128
+ })).runtime,
129
+ })
130
+ : (await completeFollowups(), null);
115
131
  const data = command === "run"
116
132
  ? {
117
133
  runtime,
@@ -120,6 +136,7 @@ export const createRuntimeActionHandlers = ({ prepareCandidateConfirmation, prep
120
136
  applicationProof,
121
137
  applicationReadiness,
122
138
  httpProofResults,
139
+ lifecycle: persistence.lifecycle,
123
140
  }
124
141
  : preparations.length
125
142
  ? { ...runtime, preparations }
@@ -130,6 +147,7 @@ export const createRuntimeActionHandlers = ({ prepareCandidateConfirmation, prep
130
147
  ...preparations.map((preparation) => preparation.output),
131
148
  ...dependentProofs.map((proof) => proof.output),
132
149
  applicationProof?.output,
150
+ persistence?.lifecycle.stop?.output,
133
151
  ]
134
152
  .filter(Boolean)
135
153
  .join("\n");
@@ -145,8 +163,11 @@ export const createRuntimeActionHandlers = ({ prepareCandidateConfirmation, prep
145
163
  ? value.applicationReadiness
146
164
  : undefined;
147
165
  const renderedHttpProofResults = "httpProofResults" in value ? value.httpProofResults : [];
166
+ const renderedLifecycle = "lifecycle" in value ? value.lifecycle : undefined;
148
167
  const nextAction = {
149
- run: "Next: run rehearsal open for hands-on testing, then rehearsal verify.",
168
+ run: renderedLifecycle?.runtimeState === "stopped"
169
+ ? "The runtime was stopped with its database and Storage changes preserved. Run rehearsal start or rehearsal open to resume."
170
+ : "Next: run rehearsal open for hands-on testing, then rehearsal verify.",
150
171
  start: "Next: run rehearsal open for hands-on testing or rehearsal status.",
151
172
  migrate: "Next: run rehearsal verify to prove the current runtime.",
152
173
  reset: "Next: run rehearsal open or rehearsal verify.",
@@ -171,6 +192,9 @@ export const createRuntimeActionHandlers = ({ prepareCandidateConfirmation, prep
171
192
  ? `✓ Application became ready at ${renderedApplicationReadiness.url}`
172
193
  : null,
173
194
  ...renderedHttpProofResults.map((proof) => `✓ ${proof.kind} HTTP proof passed: ${proof.name}`),
195
+ renderedLifecycle
196
+ ? `✓ Lifecycle ${renderedLifecycle.mode}: runtime ${renderedLifecycle.runtimeState}; data preserved`
197
+ : null,
174
198
  "",
175
199
  "Technical output is available from Show details in the guide.",
176
200
  ]
@@ -189,6 +213,9 @@ export const createRuntimeActionHandlers = ({ prepareCandidateConfirmation, prep
189
213
  ? `Application readiness passed: ${renderedApplicationReadiness.url}`
190
214
  : null,
191
215
  ...renderedHttpProofResults.map((proof) => `${proof.kind} HTTP proof passed: ${proof.name}`),
216
+ renderedLifecycle
217
+ ? `Lifecycle ${renderedLifecycle.mode}: runtime ${renderedLifecycle.runtimeState}; data preserved.`
218
+ : null,
192
219
  nextAction ? "" : null,
193
220
  nextAction,
194
221
  ]