@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.
- package/CHANGELOG.md +27 -1
- package/COMPATIBILITY.md +31 -0
- package/README.md +7 -3
- package/dist/package.json +3 -1
- package/dist/src/cli/arguments.mjs +1 -0
- package/dist/src/cli/basic_command_handlers.mjs +4 -0
- package/dist/src/cli/editor_command_handlers.mjs +24 -0
- package/dist/src/runtime/migration_history.d.mts +5 -1
- package/dist/src/runtime/migration_history.mjs +24 -6
- package/dist/src/runtime/migration_impact.d.mts +125 -0
- package/dist/src/runtime/migration_impact.mjs +278 -0
- package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
- package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
- package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
- package/dist/src/runtime/migration_impact_plan.mjs +297 -0
- package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
- package/dist/src/runtime/migration_impact_queries.mjs +270 -0
- package/dist/src/runtime/migration_insights.d.mts +137 -0
- package/dist/src/runtime/migration_insights.mjs +143 -0
- package/dist/src/runtime/migration_insights_input.d.mts +13 -0
- package/dist/src/runtime/migration_insights_input.mjs +153 -0
- package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
- package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
- package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
- package/dist/src/runtime/migration_sql_scan.mjs +369 -0
- package/dist/src/runtime/plan.d.mts +2 -1
- package/dist/src/runtime/plan.mjs +4 -4
- package/dist/src/runtime/topology.d.mts +1 -1
- package/dist/src/runtime/topology.mjs +2 -2
- package/dist/src/shared/cancellation.mjs +1 -0
- package/dist/src/shared/held_path_ancestry.d.mts +7 -0
- package/dist/src/shared/held_path_ancestry.mjs +110 -0
- package/dist/src/shared/operation_guard.mjs +1 -0
- package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
- package/dist/src/targets/migration_impact_runtime.mjs +367 -0
- package/docs/README.md +20 -7
- package/docs/adapters.md +10 -0
- package/docs/architecture.md +75 -1
- package/docs/commands.md +169 -34
- package/docs/configuration.md +23 -5
- package/docs/index.md +1 -0
- package/docs/releasing.md +11 -9
- package/docs/roadmap.md +19 -0
- package/docs/runtime-lifecycle.md +10 -0
- package/docs/updating.md +73 -0
- package/docs/vscode.md +23 -17
- 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
|
-
## [
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
},
|
|
@@ -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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
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
|
|
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");
|