@rehearsal-db/core 1.1.0 → 1.2.0

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 (47) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/COMPATIBILITY.md +31 -0
  3. package/README.md +7 -3
  4. package/dist/package.json +3 -1
  5. package/dist/src/cli/arguments.mjs +1 -0
  6. package/dist/src/cli/basic_command_handlers.mjs +4 -0
  7. package/dist/src/cli/editor_command_handlers.mjs +24 -0
  8. package/dist/src/runtime/migration_history.d.mts +5 -1
  9. package/dist/src/runtime/migration_history.mjs +24 -6
  10. package/dist/src/runtime/migration_impact.d.mts +125 -0
  11. package/dist/src/runtime/migration_impact.mjs +278 -0
  12. package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
  13. package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
  14. package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
  15. package/dist/src/runtime/migration_impact_plan.mjs +297 -0
  16. package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
  17. package/dist/src/runtime/migration_impact_queries.mjs +270 -0
  18. package/dist/src/runtime/migration_insights.d.mts +137 -0
  19. package/dist/src/runtime/migration_insights.mjs +143 -0
  20. package/dist/src/runtime/migration_insights_input.d.mts +13 -0
  21. package/dist/src/runtime/migration_insights_input.mjs +153 -0
  22. package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
  23. package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
  24. package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
  25. package/dist/src/runtime/migration_sql_scan.mjs +369 -0
  26. package/dist/src/runtime/plan.d.mts +2 -1
  27. package/dist/src/runtime/plan.mjs +4 -4
  28. package/dist/src/runtime/topology.d.mts +1 -1
  29. package/dist/src/runtime/topology.mjs +2 -2
  30. package/dist/src/shared/cancellation.mjs +1 -0
  31. package/dist/src/shared/held_path_ancestry.d.mts +7 -0
  32. package/dist/src/shared/held_path_ancestry.mjs +110 -0
  33. package/dist/src/shared/operation_guard.mjs +1 -0
  34. package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
  35. package/dist/src/targets/migration_impact_runtime.mjs +367 -0
  36. package/docs/README.md +20 -7
  37. package/docs/adapters.md +10 -0
  38. package/docs/architecture.md +75 -1
  39. package/docs/commands.md +169 -34
  40. package/docs/configuration.md +23 -5
  41. package/docs/index.md +1 -0
  42. package/docs/releasing.md +11 -9
  43. package/docs/roadmap.md +19 -0
  44. package/docs/runtime-lifecycle.md +10 -0
  45. package/docs/updating.md +73 -0
  46. package/docs/vscode.md +23 -17
  47. package/package.json +3 -1
package/CHANGELOG.md CHANGED
@@ -3,7 +3,33 @@
3
3
  All notable Rehearsal package changes are documented here. The format follows Keep
4
4
  a Changelog, and published versions follow Semantic Versioning.
5
5
 
6
- ## [Unreleased]
6
+ ## [1.2.0] - 2026-10-24
7
+
8
+ ### Added
9
+
10
+ - An optional `migrationImpact: 1` contract: offline `inspect impact` and explicitly
11
+ confirmed `measure impact` for one selected, already-verified disposable local
12
+ sandbox. Bounded read-only snapshot aggregates cover narrow supported row,
13
+ populated/NULL, duplicate and foreign-key orphan counts; unsupported or incomplete
14
+ evidence stays unknown. No pending migration is executed, no runtime is started
15
+ or repaired, and no SQL, object names or row contents are returned. Counts are
16
+ not sequential simulation, production prediction or a safety verdict. Existing
17
+ project locking/cancellation is reused without adding a rehearsal gate or CLI fee.
18
+ - An optional versioned `inspect insights` contract for bounded, read-only static
19
+ migration warnings. Findings bind to exact pending file bytes and the complete
20
+ existing candidate digest, with fixed explanations and lexical locations.
21
+ Unsupported, malformed or truncated analysis remains unknown; no detected
22
+ warning is never SQL validity, database verification or a safety verdict.
23
+ - Project-local input bounds, unchanged leaf/directory identity checks and shared
24
+ input hash binding. Inspection neither executes SQL nor starts an engine or
25
+ contacts a database. Existing migration-plan v1 and failure diagnosis remain
26
+ separate, unchanged contracts.
27
+
28
+ ### Documentation
29
+
30
+ - Clarify reviewed PostgreSQL/application/full service dependencies and resource
31
+ measurement limits without adding service exclusions or a new acceptance gate.
32
+ - Distinguish project runtime hooks from native migration-tool history support.
7
33
 
8
34
  ## [1.1.0] - 2026-10-21
9
35
 
package/COMPATIBILITY.md CHANGED
@@ -38,6 +38,37 @@ through the project's existing version manager or use the helper's explicit exec
38
38
  path; see [OS setup](installers/README.md). Helpers never install Node or switch a
39
39
  project's runtime automatically.
40
40
 
41
+ The 1.2.0 source candidate adds optional capability
42
+ `rehearsalContracts.migrationInsights: 1` and `inspect insights --json`. It preserves
43
+ existing migration-plan v1, baseline formats, digest meaning and failure diagnosis.
44
+ Consumers must check the capability and exact plan/file bindings; absent or
45
+ unavailable static analysis is not safety evidence. This candidate version and its
46
+ setup target do not establish publication or completed editor/native acceptance.
47
+
48
+ Further source work adds optional `rehearsalContracts.migrationImpact: 1` with
49
+ `inspect impact` and `measure impact`. Consumers must explicitly select a configured
50
+ local target and bind measurement consent to the exact whole-plan digest. Preview
51
+ does not contact a database; measurement reads only the current verified sandbox,
52
+ without applying migrations, starting/repairing a runtime or changing database data.
53
+ Aggregate execution requires PostgreSQL 17 or newer with server-side transaction
54
+ deadlines and narrowly supported ordinary-table/built-in semantics; older servers
55
+ remain unknown, with no automatic database upgrade.
56
+ Supabase measurement uses the ownership-proven local container's built-in
57
+ `supabase_admin` socket role so the fixed disk bound and catalog lock can be enforced.
58
+ It does not promote `postgres`, change grants, load credentials or connect remotely.
59
+ Read-only catalog preflight is separate from the count snapshot. Bounded strong
60
+ relation locks precede that snapshot and temporarily block writers and metadata
61
+ changes in the selected disposable sandbox; this is not a nonblocking query promise.
62
+ Counts are decimal strings or `null` with an explicit unknown reason, never a safety
63
+ verdict, sequential simulation or production prediction. Partial/unknown measurement
64
+ uses `not_ready` with the existing not-ready exit `1`; normal rehearsal gates and existing JSON contracts
65
+ are unchanged. This unpublished source capability does not claim a new editor UI,
66
+ new database family or additional platform/native acceptance.
67
+ Older strict editor activity consumers can reject the new `measure impact` operation
68
+ kind, including a retained completed receipt. Pair this capability with an editor
69
+ consumer explicitly supporting that kind; the CLI does not silently rename the
70
+ operation, relax unknown-kind validation or update an active editor installation.
71
+
41
72
  ## Operating systems and execution location
42
73
 
43
74
  Core 1.0.1's platform boundary distinguishes the computer running VS Code from the
package/README.md CHANGED
@@ -47,7 +47,7 @@ it through a reviewed privacy policy, and retire the access again. It is never p
47
47
  production deployment tool.
48
48
 
49
49
  The CLI is free and MIT-licensed, with stable releases on
50
- [npm](https://www.npmjs.com/package/@rehearsal-db/core). This guide targets **1.1.0**;
50
+ [npm](https://www.npmjs.com/package/@rehearsal-db/core). This guide targets **1.2.0**;
51
51
  install only a version actually published there. Prefer a visual workflow?
52
52
  [Install the free VS Code Starter preview](docs/vscode.md). Its included features are
53
53
  free indefinitely for personal or commercial development—no account, card, or trial.
@@ -198,6 +198,7 @@ Start here:
198
198
  - [VS Code extension](docs/vscode.md) — use the project-aware visual workbench
199
199
  - [Safe hands-on tutorial](docs/tutorial.md) — try the full flow in a disposable project
200
200
  - [Troubleshooting](docs/troubleshooting.md) — fix common setup problems
201
+ - [Updates](docs/updating.md) — update the CLI or extension without resetting data
201
202
  - [Next steps](docs/roadmap.md) — see the current product sequence
202
203
 
203
204
  Reference:
@@ -214,9 +215,12 @@ Reference:
214
215
  - [Standalone workflow and security gates](docs/standalone-workflow.md)
215
216
  - [Runtime and local identity policies](docs/runtime-policies.md)
216
217
  - [Glossary](docs/glossary.md)
217
- - [Repository architecture](docs/architecture.md)
218
218
  - [Stability and the first stable release](docs/stability.md)
219
- - [Release process](docs/releasing.md)
219
+
220
+ For maintainers developing and publishing Rehearsal:
221
+
222
+ - [Repository architecture](docs/architecture.md)
223
+ - [Maintainer release process](docs/releasing.md)
220
224
 
221
225
  ## Getting support
222
226
 
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rehearsal-db/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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": {
@@ -27,6 +27,8 @@
27
27
  "resourceInventory": 1,
28
28
  "resourceManager": 1,
29
29
  "migrationReview": 1,
30
+ "migrationInsights": 1,
31
+ "migrationImpact": 1,
30
32
  "configurationReview": 1,
31
33
  "baselineShape": 1
32
34
  },
@@ -150,6 +150,7 @@ export const commandMutatesProject = ({ command, flags, guided, }) => {
150
150
  "open",
151
151
  "start",
152
152
  "migrate",
153
+ "measure impact",
153
154
  "reset",
154
155
  "stop",
155
156
  "discard",
@@ -35,6 +35,9 @@ Commands:
35
35
  inspect baseline Show verified baseline provenance
36
36
  inspect migrations Classify represented, applied, and candidate migrations
37
37
  inspect plan Review ordered migrations, required dependencies, and warnings
38
+ inspect insights Review bounded static SQL warnings; never a safety verdict
39
+ inspect impact Preview aggregate measurements for one explicit --target
40
+ measure impact Measure reviewed aggregates on that running local sandbox
38
41
  start Start an existing verified local runtime
39
42
  migrate --confirm-candidates= Apply the exact candidate suffix without resetting
40
43
  reset Restore and verify the immutable local baseline
@@ -48,6 +51,7 @@ Commands:
48
51
 
49
52
  Options: --help, -h --version, -V --json --verbose --debug --plain
50
53
  --config=<path> --target=<provider for setup; configured name for runtime>
54
+ --confirm-candidates=<whole-plan digest for run, migrate, or measure impact>
51
55
  --confirm-source-access=<digest> --confirm-source-retirement=<digest>
52
56
  --confirm-refresh=<digest>`;
53
57
  export const createBasicCommandHandlers = () => [
@@ -1,9 +1,33 @@
1
1
  /** Versioned, read-only contracts for editor review; never a second runtime engine. */
2
2
  import { inspectMigrationReviewPlan } from "../runtime/migration_plan.mjs";
3
+ import { inspectMigrationInsights, renderMigrationInsights, } from "../runtime/migration_insights.mjs";
4
+ import { inspectMigrationImpact, measureMigrationImpact, renderMigrationImpact, } from "../runtime/migration_impact.mjs";
3
5
  import { exactCommands, renderedCommand } from "./command_contract.mjs";
4
6
  import { applyStopStalePlan, inspectStopStalePlan, stopReviewedStaleTarget, } from "../runtime/stale_environment_plan.mjs";
5
7
  import { inspectConfigurationMetadata, reviewConfigurationProposal, } from "../project/configuration_review.mjs";
6
8
  export const createEditorCommandHandlers = () => [
9
+ exactCommands("migration-impact-preview", ["inspect impact"], async ({ planOptions, flags }) => {
10
+ const data = await inspectMigrationImpact(planOptions, flags.target);
11
+ return renderedCommand({ data, render: renderMigrationImpact });
12
+ }),
13
+ exactCommands("migration-impact-measurement", ["measure impact"], async ({ planOptions, flags }) => {
14
+ const data = await measureMigrationImpact({
15
+ options: planOptions,
16
+ targetName: flags.target,
17
+ confirmation: flags.confirmation,
18
+ });
19
+ const incomplete = ["partial", "unknown"].includes(data.measurementStatus);
20
+ return renderedCommand({
21
+ data,
22
+ render: renderMigrationImpact,
23
+ status: incomplete ? "not_ready" : "success",
24
+ ...(incomplete ? { exitCode: 1 } : {}),
25
+ });
26
+ }),
27
+ exactCommands("migration-insights", ["inspect insights"], async ({ planOptions }) => {
28
+ const data = await inspectMigrationInsights(planOptions);
29
+ return renderedCommand({ data, render: renderMigrationInsights });
30
+ }),
7
31
  exactCommands("configuration", ["configuration", "configuration review"], async ({ command, planOptions, flags }) => {
8
32
  const data = command === "configuration review"
9
33
  ? await reviewConfigurationProposal(planOptions, flags.proposalPath ??
@@ -24,7 +24,11 @@ export interface MigrationReplayReceipt {
24
24
  entries: MigrationReplayEntry[];
25
25
  historySha256: string;
26
26
  }
27
- export declare const readMigrationFileInventory: (directory: URL) => Promise<MigrationFileEntry[]>;
27
+ export declare const readMigrationFileInventory: (directory: URL, options?: {
28
+ readonly maximumFiles?: number;
29
+ readonly maximumDirectoryEntries?: number;
30
+ readonly readBytes?: (path: URL, filename: string) => Promise<Buffer>;
31
+ }) => Promise<MigrationFileEntry[]>;
28
32
  export declare const readMigrationSourceBundle: ({ directory, sourceLedger, }: {
29
33
  directory: URL;
30
34
  sourceLedger: readonly MigrationLedgerEntry[];
@@ -4,7 +4,7 @@
4
4
  * this module is reusable script infrastructure.
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
- import { readFile, readdir } from "node:fs/promises";
7
+ import { opendir, readFile, readdir } from "node:fs/promises";
8
8
  const migrationNamePattern = /^(\d{14})_(.+)\.sql$/u;
9
9
  const sha256 = (value) => createHash("sha256").update(value).digest("hex");
10
10
  const canonicalJson = (value) => `${JSON.stringify(value, null, "\t")}\n`;
@@ -32,17 +32,35 @@ const assertOrderedUniqueHistory = (entries, label) => {
32
32
  }
33
33
  return entries;
34
34
  };
35
- export const readMigrationFileInventory = async (directory) => {
36
- const names = (await readdir(directory))
37
- .filter((name) => name.endsWith(".sql"))
38
- .sort();
35
+ export const readMigrationFileInventory = async (directory, options = {}) => {
36
+ let names = [];
37
+ if (options.maximumDirectoryEntries === undefined) {
38
+ names = (await readdir(directory)).filter((name) => name.endsWith(".sql"));
39
+ }
40
+ else {
41
+ let visited = 0;
42
+ // Async directory iteration closes its descriptor on success and refusal.
43
+ // Bounded callers must not allocate an arbitrary directory listing first.
44
+ for await (const entry of await opendir(directory)) {
45
+ if (++visited > options.maximumDirectoryEntries)
46
+ throw new Error("Migration inspection exceeds its bounded directory inventory.");
47
+ if (entry.name.endsWith(".sql"))
48
+ names.push(entry.name);
49
+ }
50
+ }
51
+ names.sort();
52
+ if (options.maximumFiles !== undefined && names.length > options.maximumFiles)
53
+ throw new Error("Migration inspection exceeds its bounded file inventory.");
39
54
  const entries = [];
40
55
  for (const filename of names) {
41
56
  const match = migrationNamePattern.exec(filename);
42
57
  if (!match) {
43
58
  throw new Error(`Invalid migration filename in ${directory}: ${filename}`);
44
59
  }
45
- const bytes = await readFile(new URL(filename, directory));
60
+ const path = new URL(filename, directory);
61
+ const bytes = options.readBytes
62
+ ? await options.readBytes(path, filename)
63
+ : await readFile(path);
46
64
  entries.push({
47
65
  version: match[1],
48
66
  name: match[2],
@@ -0,0 +1,125 @@
1
+ import type { RehearsalConfigPathOptions } from "../project/configuration.mjs";
2
+ import { type MigrationImpactTask, type MigrationImpactUnknownReason } from "./migration_impact_plan.mjs";
3
+ import { type MigrationImpactCount } from "./migration_impact_queries.mjs";
4
+ import { openMigrationImpactRuntime } from "../targets/migration_impact_runtime.mjs";
5
+ type CountUnknownReason = "unsupported_runtime_metadata" | "unsupported_server_version" | "query_failed" | "lock_timeout" | "statement_timeout" | "total_timeout" | "input_limit";
6
+ interface PreparedEntry {
7
+ readonly filename: string;
8
+ readonly sha256: string;
9
+ readonly tasks: readonly MigrationImpactTask[];
10
+ readonly unknownReasons: readonly MigrationImpactUnknownReason[];
11
+ }
12
+ declare const prepare: (options: RehearsalConfigPathOptions, targetName: string | undefined) => Promise<{
13
+ projectRoot: string;
14
+ target: import("./topology.mjs").RuntimeTopologyTarget & {
15
+ summary: ReturnType<(inspection: {
16
+ baselineGenerationId: string;
17
+ baselineCutoff: string;
18
+ candidateSha256: string;
19
+ migrations: {
20
+ version: string;
21
+ name: string;
22
+ filename: string;
23
+ sha256: string;
24
+ status: string;
25
+ }[];
26
+ definitions: {
27
+ represented_by_baseline: string;
28
+ applied_to_current_runtime: string;
29
+ candidate: string;
30
+ modified: string;
31
+ invalid: string;
32
+ };
33
+ }) => Readonly<{
34
+ baselineGenerationId: string;
35
+ candidateSha256: string;
36
+ candidateCount: number;
37
+ candidates: readonly {
38
+ version: string;
39
+ name: string;
40
+ filename: string;
41
+ sha256: string;
42
+ status: string;
43
+ }[];
44
+ }>>;
45
+ };
46
+ baseline: import("../baseline/artifact.mjs").BaselineManifest;
47
+ entries: PreparedEntry[];
48
+ binding: string;
49
+ candidateSha256: string;
50
+ candidateCount: number;
51
+ }>;
52
+ type Prepared = Awaited<ReturnType<typeof prepare>>;
53
+ declare const reportFor: ({ prepared, measured, counts, reason, snapshotAt, runtimeIdentitySha256, contactedDatabase, }: {
54
+ prepared: Prepared;
55
+ measured?: boolean;
56
+ counts?: readonly MigrationImpactCount[];
57
+ reason?: CountUnknownReason;
58
+ snapshotAt?: string | null;
59
+ runtimeIdentitySha256?: string | null;
60
+ contactedDatabase?: boolean;
61
+ }) => {
62
+ schemaVersion: 1;
63
+ inspectorVersion: string;
64
+ candidateSha256: string;
65
+ candidateCount: number;
66
+ target: {
67
+ name: string;
68
+ provider: "postgresql" | "supabase";
69
+ baselineGenerationId: string;
70
+ candidateSha256: string;
71
+ };
72
+ mode: "measurement" | "preview";
73
+ measurementStatus: "measured" | "not_required" | "partial" | "planned" | "unknown";
74
+ snapshotAt: string | null;
75
+ runtimeIdentitySha256: string | null;
76
+ entries: {
77
+ filename: string;
78
+ sha256: string;
79
+ coverage: "incomplete" | "recognized_forms";
80
+ unknownReasons: readonly MigrationImpactUnknownReason[];
81
+ measurements: {
82
+ operation: "broad_delete" | "drop_column" | "drop_table" | "new_constraint" | "not_null" | "truncate";
83
+ metric: "duplicate_rows" | "nulls" | "orphan_rows" | "populated" | "rows";
84
+ line: number;
85
+ column: number;
86
+ evidence: "measured_current_snapshot" | "planned" | "unknown";
87
+ count: string | null;
88
+ unknownReason: CountUnknownReason | null;
89
+ }[];
90
+ }[];
91
+ summary: {
92
+ plannedCount: number;
93
+ measuredCount: number;
94
+ unknownMeasurementCount: number;
95
+ unknownEntryCount: number;
96
+ };
97
+ limits: Readonly<{
98
+ maximumMeasurements: 100;
99
+ lockTimeoutMilliseconds: 1000;
100
+ statementTimeoutMilliseconds: 5000;
101
+ totalTimeoutMilliseconds: 30000;
102
+ maximumOutputBytes: 131072;
103
+ maximumScriptBytes: 262144;
104
+ }>;
105
+ safety: {
106
+ appliesMigrations: boolean;
107
+ startsOrRepairsRuntime: boolean;
108
+ returnsRowContents: boolean;
109
+ returnsSqlOrObjectNames: boolean;
110
+ contactsDatabase: boolean;
111
+ transaction: "repeatable_read_read_only" | null;
112
+ };
113
+ limitation: string;
114
+ };
115
+ export type MigrationImpactReport = ReturnType<typeof reportFor>;
116
+ export declare const inspectMigrationImpact: (options?: RehearsalConfigPathOptions, targetName?: string) => Promise<MigrationImpactReport>;
117
+ export declare const measureMigrationImpact: ({ options, targetName, confirmation, }: {
118
+ options?: RehearsalConfigPathOptions;
119
+ targetName?: string | undefined;
120
+ confirmation?: string | undefined;
121
+ }, implementation?: {
122
+ openRuntime?: typeof openMigrationImpactRuntime;
123
+ }) => Promise<MigrationImpactReport>;
124
+ export declare const renderMigrationImpact: (report: MigrationImpactReport) => string;
125
+ export {};
@@ -0,0 +1,278 @@
1
+ /** Bind aggregate-only sandbox measurements to exact reviewed pending inputs. */
2
+ import { createHash } from "node:crypto";
3
+ import { realpath } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
+ import { verifyActiveBaseline } from "../baseline/artifact.mjs";
7
+ import { REHEARSAL_VERSION, RehearsalError } from "../shared/diagnostics.mjs";
8
+ import { getOperationCancellationSignal, throwIfOperationCancelled, } from "../shared/cancellation.mjs";
9
+ import { inspectRehearsalMigrations } from "./plan.mjs";
10
+ import { inspectRuntimeTopologyCandidates } from "./topology.mjs";
11
+ import { createMigrationInsightsInput, MIGRATION_INSIGHTS_LIMITS, } from "./migration_insights_input.mjs";
12
+ import { planMigrationImpactSql, } from "./migration_impact_plan.mjs";
13
+ import { buildMigrationImpactScript, parseMigrationImpactBatchOutput, MIGRATION_IMPACT_LIMITS, } from "./migration_impact_queries.mjs";
14
+ import { openMigrationImpactRuntime, MigrationImpactRuntimeError, } from "../targets/migration_impact_runtime.mjs";
15
+ const refusal = (message, unsafe = false) => new RehearsalError({
16
+ category: unsafe ? "unsafe_environment" : "configuration_invalid",
17
+ code: unsafe ? "UNSAFE_ENVIRONMENT" : "CONFIGURATION_INVALID",
18
+ message,
19
+ refused: "No migration was applied, no runtime was started or repaired, and no row contents are returned.",
20
+ suggestions: [
21
+ "Review a fresh impact plan and explicitly select its already-running disposable local target.",
22
+ ],
23
+ });
24
+ const prepare = async (options, targetName) => {
25
+ if (typeof targetName !== "string" || !targetName || targetName.length > 128)
26
+ throw refusal("Impact review requires one explicit configured --target name, including primary.");
27
+ const projectRoot = await realpath(resolve(options.projectRoot ?? process.cwd()));
28
+ const input = createMigrationInsightsInput(projectRoot);
29
+ const candidates = await inspectRuntimeTopologyCandidates({ ...options, projectRoot }, (selected) => inspectRehearsalMigrations(selected, input.readInventory));
30
+ if (candidates.candidateCount > MIGRATION_INSIGHTS_LIMITS.maximumCandidates)
31
+ throw refusal("Impact review exceeds its bounded pending-migration input.");
32
+ const target = candidates.targets.find((entry) => entry.name === targetName);
33
+ if (!target)
34
+ throw refusal("Impact review requires one target from this project's configuration.");
35
+ let remaining = MIGRATION_IMPACT_LIMITS.maximumMeasurements;
36
+ const entries = target.summary.candidates.map((candidate) => {
37
+ const source = input.sourceFor(join(target.paths.migrationDirectory, candidate.filename), candidate.sha256);
38
+ if (source === undefined)
39
+ throw refusal("The exact impact-review input could not be retained. Refresh the plan.");
40
+ const plan = planMigrationImpactSql(source);
41
+ const tasks = plan.tasks.slice(0, remaining);
42
+ remaining -= tasks.length;
43
+ const unknownReasons = [
44
+ ...new Set([
45
+ ...plan.unknownReasons,
46
+ ...(tasks.length < plan.tasks.length ? ["input_limit"] : []),
47
+ ]),
48
+ ];
49
+ return {
50
+ filename: candidate.filename,
51
+ sha256: candidate.sha256,
52
+ tasks,
53
+ unknownReasons,
54
+ };
55
+ });
56
+ const baseline = await verifyActiveBaseline({
57
+ artifactRoot: target.paths.artifactDirectory,
58
+ });
59
+ if (baseline.generationId !== target.summary.baselineGenerationId)
60
+ throw refusal("The active baseline changed during impact review. Refresh the plan.");
61
+ const binding = createHash("sha256")
62
+ .update(JSON.stringify({
63
+ candidateSha256: candidates.candidateSha256,
64
+ target: target.name,
65
+ config: target.config,
66
+ paths: target.paths,
67
+ baseline,
68
+ inputs: entries.map((entry) => ({
69
+ filename: entry.filename,
70
+ sha256: entry.sha256,
71
+ })),
72
+ }))
73
+ .digest("hex");
74
+ return {
75
+ projectRoot,
76
+ target,
77
+ baseline,
78
+ entries,
79
+ binding,
80
+ candidateSha256: candidates.candidateSha256,
81
+ candidateCount: candidates.candidateCount,
82
+ };
83
+ };
84
+ const reportFor = ({ prepared, measured = false, counts, reason, snapshotAt = null, runtimeIdentitySha256 = null, contactedDatabase = false, }) => {
85
+ let index = 0;
86
+ let measuredCount = 0;
87
+ let unknownMeasurementCount = 0;
88
+ let unknownEntryCount = 0;
89
+ const entries = prepared.entries.map((entry) => {
90
+ const measurements = entry.tasks.map((task) => {
91
+ const count = counts?.[index++];
92
+ const unknownReason = reason ?? count?.reason ?? null;
93
+ const evidence = !measured
94
+ ? "planned"
95
+ : count?.count !== null &&
96
+ count?.count !== undefined &&
97
+ unknownReason === null
98
+ ? "measured_current_snapshot"
99
+ : "unknown";
100
+ if (evidence === "measured_current_snapshot")
101
+ measuredCount++;
102
+ if (evidence === "unknown")
103
+ unknownMeasurementCount++;
104
+ return {
105
+ operation: task.operation,
106
+ metric: task.metric,
107
+ line: task.line,
108
+ column: task.column,
109
+ evidence,
110
+ count: evidence === "measured_current_snapshot" ? count.count : null,
111
+ unknownReason: evidence === "unknown" ? (unknownReason ?? "query_failed") : null,
112
+ };
113
+ });
114
+ if (entry.unknownReasons.length ||
115
+ measurements.some((entry) => entry.evidence === "unknown"))
116
+ unknownEntryCount++;
117
+ return {
118
+ filename: entry.filename,
119
+ sha256: entry.sha256,
120
+ coverage: entry.unknownReasons.length
121
+ ? "incomplete"
122
+ : "recognized_forms",
123
+ unknownReasons: entry.unknownReasons,
124
+ measurements,
125
+ };
126
+ });
127
+ const plannedCount = entries.reduce((total, entry) => total + entry.measurements.length, 0);
128
+ const measurementStatus = !measured
129
+ ? "planned"
130
+ : unknownEntryCount
131
+ ? measuredCount
132
+ ? "partial"
133
+ : "unknown"
134
+ : plannedCount
135
+ ? "measured"
136
+ : "not_required";
137
+ return {
138
+ schemaVersion: 1,
139
+ inspectorVersion: REHEARSAL_VERSION,
140
+ candidateSha256: prepared.candidateSha256,
141
+ candidateCount: prepared.candidateCount,
142
+ target: {
143
+ name: prepared.target.name,
144
+ provider: prepared.target.config.runtime.target,
145
+ baselineGenerationId: prepared.baseline.generationId,
146
+ candidateSha256: prepared.target.summary.candidateSha256,
147
+ },
148
+ mode: measured ? "measurement" : "preview",
149
+ measurementStatus,
150
+ snapshotAt,
151
+ runtimeIdentitySha256,
152
+ entries,
153
+ summary: {
154
+ plannedCount,
155
+ measuredCount,
156
+ unknownMeasurementCount,
157
+ unknownEntryCount,
158
+ },
159
+ limits: MIGRATION_IMPACT_LIMITS,
160
+ safety: {
161
+ appliesMigrations: false,
162
+ startsOrRepairsRuntime: false,
163
+ returnsRowContents: false,
164
+ returnsSqlOrObjectNames: false,
165
+ contactsDatabase: contactedDatabase,
166
+ transaction: measured && snapshotAt !== null
167
+ ? "repeatable_read_read_only"
168
+ : null,
169
+ },
170
+ limitation: "Counts describe one current disposable-sandbox snapshot before any pending migration. They do not simulate sequential migration effects, establish SQL validity or safety, measure cascade/trigger effects, or predict production locks and performance. Unsupported objects, expressions and incomplete evidence stay unknown.",
171
+ };
172
+ };
173
+ export const inspectMigrationImpact = async (options = {}, targetName) => reportFor({ prepared: await prepare(options, targetName) });
174
+ export const measureMigrationImpact = async ({ options = {}, targetName, confirmation, }, implementation = {}) => {
175
+ const controller = new AbortController();
176
+ const externalSignal = getOperationCancellationSignal();
177
+ const signal = externalSignal
178
+ ? AbortSignal.any([externalSignal, controller.signal])
179
+ : controller.signal;
180
+ const deadline = performance.now() + MIGRATION_IMPACT_LIMITS.totalTimeoutMilliseconds;
181
+ let timedOut = false;
182
+ const timer = setTimeout(() => {
183
+ timedOut = true;
184
+ controller.abort();
185
+ }, MIGRATION_IMPACT_LIMITS.totalTimeoutMilliseconds);
186
+ const checkpoint = () => {
187
+ throwIfOperationCancelled();
188
+ if (timedOut || performance.now() >= deadline) {
189
+ timedOut = true;
190
+ controller.abort();
191
+ throw new MigrationImpactRuntimeError("cancelled");
192
+ }
193
+ };
194
+ try {
195
+ const prepared = await prepare(options, targetName);
196
+ if (confirmation !== prepared.candidateSha256)
197
+ throw refusal("Measurement requires the exact whole-plan --confirm-candidates digest from a fresh impact review.");
198
+ const tasks = prepared.entries.flatMap((entry) => entry.tasks);
199
+ let contactedDatabase = false;
200
+ try {
201
+ checkpoint();
202
+ if (!tasks.length)
203
+ return reportFor({ prepared, measured: true });
204
+ const runtime = await (implementation.openRuntime ?? openMigrationImpactRuntime)({
205
+ projectRoot: prepared.projectRoot,
206
+ target: prepared.target,
207
+ baseline: prepared.baseline,
208
+ signal,
209
+ });
210
+ checkpoint();
211
+ try {
212
+ buildMigrationImpactScript(tasks);
213
+ }
214
+ catch {
215
+ return reportFor({ prepared, measured: true, reason: "input_limit" });
216
+ }
217
+ contactedDatabase = true;
218
+ const result = parseMigrationImpactBatchOutput(await runtime.runSql(() => {
219
+ checkpoint();
220
+ const script = buildMigrationImpactScript(tasks, Math.max(1, Math.floor(deadline - performance.now())));
221
+ checkpoint();
222
+ return script;
223
+ }), tasks.length);
224
+ checkpoint();
225
+ await runtime.assertCurrent();
226
+ checkpoint();
227
+ const current = await prepare(options, targetName);
228
+ checkpoint();
229
+ if (prepared.binding !== current.binding)
230
+ throw refusal("The reviewed configuration, baseline or migration inputs changed during measurement. No counts are returned.");
231
+ return reportFor({
232
+ prepared,
233
+ measured: true,
234
+ counts: result.counts,
235
+ snapshotAt: result.snapshotAt,
236
+ runtimeIdentitySha256: runtime.identitySha256,
237
+ contactedDatabase,
238
+ });
239
+ }
240
+ catch (error) {
241
+ throwIfOperationCancelled();
242
+ if (error instanceof RehearsalError)
243
+ throw error;
244
+ if (error instanceof MigrationImpactRuntimeError &&
245
+ ["runtime_unavailable", "ownership_mismatch", "stale_runtime"].includes(error.reason))
246
+ throw refusal("The selected sandbox is not a verified, unchanged running local runtime. No counts are returned.", true);
247
+ const reason = timedOut ||
248
+ (error instanceof MigrationImpactRuntimeError &&
249
+ error.reason === "transaction_timeout")
250
+ ? "total_timeout"
251
+ : error instanceof MigrationImpactRuntimeError &&
252
+ ["lock_timeout", "statement_timeout"].includes(error.reason)
253
+ ? error.reason
254
+ : "query_failed";
255
+ return reportFor({ prepared, measured: true, reason, contactedDatabase });
256
+ }
257
+ }
258
+ finally {
259
+ clearTimeout(timer);
260
+ controller.abort();
261
+ }
262
+ };
263
+ export const renderMigrationImpact = (report) => [
264
+ `MIGRATION DATA IMPACT — ${report.measurementStatus.toUpperCase()}`,
265
+ `${report.target.name}: ${report.summary.measuredCount} measured / ${report.summary.plannedCount} planned aggregates; ${report.summary.unknownEntryCount} incomplete files`,
266
+ `Exact whole-plan digest: ${report.candidateSha256}`,
267
+ ...(report.snapshotAt
268
+ ? [`Current-row snapshot: ${report.snapshotAt}`]
269
+ : []),
270
+ ...report.entries.flatMap((entry) => [
271
+ entry.filename,
272
+ ...entry.measurements.map((metric) => ` ${metric.line}:${metric.column} ${metric.metric}: ${metric.count ?? metric.evidence} ${metric.unknownReason ?? ""}`.trimEnd()),
273
+ ...(entry.unknownReasons.length
274
+ ? [" Unsupported or bounded SQL remains unknown."]
275
+ : []),
276
+ ]),
277
+ report.limitation,
278
+ ].join("\n");