@rehearsal-db/core 1.1.0 → 1.3.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 (73) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/COMPATIBILITY.md +40 -0
  3. package/README.md +7 -3
  4. package/dist/package.json +4 -1
  5. package/dist/src/cli/arguments.d.mts +1 -0
  6. package/dist/src/cli/arguments.mjs +5 -0
  7. package/dist/src/cli/basic_command_handlers.mjs +6 -0
  8. package/dist/src/cli/cli_commands.mjs +2 -0
  9. package/dist/src/cli/editor_command_handlers.mjs +24 -0
  10. package/dist/src/cli/project_commands.d.mts +8 -0
  11. package/dist/src/cli/runtime_commands.d.mts +2 -19
  12. package/dist/src/cli/runtime_commands.mjs +3 -90
  13. package/dist/src/cli/runtime_stack_runner.d.mts +41 -0
  14. package/dist/src/cli/runtime_stack_runner.mjs +104 -0
  15. package/dist/src/cli/runtime_workload_admission.d.mts +8 -0
  16. package/dist/src/cli/runtime_workload_admission.mjs +22 -0
  17. package/dist/src/cli/source_commands.d.mts +8 -0
  18. package/dist/src/cli/workload_command_handlers.d.mts +2 -0
  19. package/dist/src/cli/workload_command_handlers.mjs +38 -0
  20. package/dist/src/project/configuration_contract.d.mts +5 -0
  21. package/dist/src/project/configuration_normalization.d.mts +4 -0
  22. package/dist/src/project/configuration_resolution.d.mts +4 -0
  23. package/dist/src/project/configuration_resource_policy.d.mts +4 -0
  24. package/dist/src/project/configuration_resource_policy.mjs +20 -1
  25. package/dist/src/project/configuration_schema.mjs +1 -0
  26. package/dist/src/runtime/migration_history.d.mts +5 -1
  27. package/dist/src/runtime/migration_history.mjs +24 -6
  28. package/dist/src/runtime/migration_impact.d.mts +125 -0
  29. package/dist/src/runtime/migration_impact.mjs +278 -0
  30. package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
  31. package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
  32. package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
  33. package/dist/src/runtime/migration_impact_plan.mjs +297 -0
  34. package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
  35. package/dist/src/runtime/migration_impact_queries.mjs +270 -0
  36. package/dist/src/runtime/migration_insights.d.mts +137 -0
  37. package/dist/src/runtime/migration_insights.mjs +143 -0
  38. package/dist/src/runtime/migration_insights_input.d.mts +13 -0
  39. package/dist/src/runtime/migration_insights_input.mjs +153 -0
  40. package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
  41. package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
  42. package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
  43. package/dist/src/runtime/migration_sql_scan.mjs +369 -0
  44. package/dist/src/runtime/plan.d.mts +2 -1
  45. package/dist/src/runtime/plan.mjs +4 -4
  46. package/dist/src/runtime/topology.d.mts +1 -1
  47. package/dist/src/runtime/topology.mjs +2 -2
  48. package/dist/src/runtime/workload_queue.d.mts +39 -0
  49. package/dist/src/runtime/workload_queue.mjs +211 -0
  50. package/dist/src/runtime/workload_queue_location.d.mts +1 -0
  51. package/dist/src/runtime/workload_queue_location.mjs +43 -0
  52. package/dist/src/runtime/workload_queue_store.d.mts +27 -0
  53. package/dist/src/runtime/workload_queue_store.mjs +182 -0
  54. package/dist/src/shared/cancellation.mjs +2 -0
  55. package/dist/src/shared/held_path_ancestry.d.mts +7 -0
  56. package/dist/src/shared/held_path_ancestry.mjs +110 -0
  57. package/dist/src/shared/operation_guard.mjs +1 -0
  58. package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
  59. package/dist/src/targets/migration_impact_runtime.mjs +367 -0
  60. package/docs/README.md +20 -7
  61. package/docs/adapters.md +10 -0
  62. package/docs/architecture.md +84 -1
  63. package/docs/commands.md +189 -34
  64. package/docs/configuration.md +56 -6
  65. package/docs/getting-started.md +1 -1
  66. package/docs/index.md +1 -0
  67. package/docs/releasing.md +15 -9
  68. package/docs/roadmap.md +27 -6
  69. package/docs/runtime-lifecycle.md +10 -0
  70. package/docs/stability.md +4 -4
  71. package/docs/updating.md +73 -0
  72. package/docs/vscode.md +23 -17
  73. package/package.json +4 -1
@@ -0,0 +1,2 @@
1
+ import { type CommandDefinition } from "./command_contract.mjs";
2
+ export declare const createWorkloadCommandHandlers: () => readonly CommandDefinition[];
@@ -0,0 +1,38 @@
1
+ /** Inspect/recover queue metadata without needing Docker, config or a project lock. */
2
+ import { inspectWorkloads, recoverWorkloads, withWorkloadDiagnostics, } from "../runtime/workload_queue.mjs";
3
+ import { exactCommands, renderedCommand, } from "./command_contract.mjs";
4
+ import { RehearsalError } from "../shared/diagnostics.mjs";
5
+ export const createWorkloadCommandHandlers = () => [
6
+ exactCommands("workloads", ["workloads"], async () => renderedCommand({
7
+ data: await withWorkloadDiagnostics(() => inspectWorkloads()),
8
+ render: (result) => [
9
+ "LOCAL WORKLOAD QUEUE — READ ONLY",
10
+ "Participating lifecycle phases only. Host/VM capacity is not measured.",
11
+ ...result.entries.map((entry) => `${entry.command} · ${entry.phase} · owner ${entry.owner} · PID ${entry.pid}${entry.recoveryRequired ? " · recovery required" : ""}`),
12
+ result.entries.length
13
+ ? ""
14
+ : "No participating workload is queued or held.",
15
+ ...(result.recoveryDigest
16
+ ? [
17
+ "A dead holder may have surviving children. Check its owned commands before recovery.",
18
+ `After checking: rehearsal workloads recover --confirm-workloads=${result.recoveryDigest}`,
19
+ "Recovery clears exactly reviewed queue metadata; it does not stop processes or change databases.",
20
+ ]
21
+ : []),
22
+ ]
23
+ .filter(Boolean)
24
+ .join("\n"),
25
+ })),
26
+ exactCommands("workloads-recover", ["workloads recover"], async ({ flags }) => {
27
+ if (!flags.workloadConfirmation)
28
+ throw new RehearsalError({
29
+ category: "configuration_invalid",
30
+ code: "CONFIGURATION_INVALID",
31
+ message: "Inspect rehearsal workloads and review dead holders before using --confirm-workloads=<digest>. Check their owned commands first; recovery does not stop surviving children.",
32
+ });
33
+ return renderedCommand({
34
+ data: await withWorkloadDiagnostics(() => recoverWorkloads(flags.workloadConfirmation)),
35
+ render: (result) => `Recovered ${result.recovered} reviewed dead workload holder(s). No process, container or database was changed.`,
36
+ });
37
+ }),
38
+ ];
@@ -4,6 +4,11 @@ export type RunPersistenceMode = "keep-until-stop" | "stop-after-run";
4
4
  export type OpenPersistenceMode = "keep-until-stop" | "stop-on-application-exit";
5
5
  export type ApplicationRequiredService = "database" | "auth" | "storage" | "postgrest" | "gateway" | "mail" | "studio" | "realtime" | "edge-functions" | "analytics" | "image-transforms";
6
6
  export interface RehearsalResourcePolicy {
7
+ /** Cooperatively serialize participating local runtime lifecycle phases. */
8
+ workloadQueue?: {
9
+ enabled: boolean;
10
+ waitTimeoutSeconds?: number;
11
+ };
7
12
  /** Review cutoff based on container start/stop, not application or database use. */
8
13
  staleAfterSeconds?: number | null;
9
14
  /** Advisory limit; retained databases are never automatically deleted. */
@@ -54,6 +54,10 @@ export declare const normalizeRehearsalConfig: (input: unknown) => Readonly<{
54
54
  retainBaselineGenerations: number;
55
55
  }>;
56
56
  resources: Readonly<{
57
+ workloadQueue?: {
58
+ enabled: boolean;
59
+ waitTimeoutSeconds: number;
60
+ };
57
61
  staleAfterSeconds: number | null;
58
62
  retainedEnvironmentCount: number;
59
63
  maximumBytes: number | null;
@@ -54,6 +54,10 @@ export declare const loadRehearsalConfig: ({ projectRoot, configPath, }?: Rehear
54
54
  retainBaselineGenerations: number;
55
55
  }>;
56
56
  resources: Readonly<{
57
+ workloadQueue?: {
58
+ enabled: boolean;
59
+ waitTimeoutSeconds: number;
60
+ };
57
61
  staleAfterSeconds: number | null;
58
62
  retainedEnvironmentCount: number;
59
63
  maximumBytes: number | null;
@@ -3,6 +3,10 @@ import type { ApplicationRequiredService } from "./configuration_contract.mjs";
3
3
  import { type PlainObject } from "./configuration_validation.mjs";
4
4
  export declare const APPLICATION_REQUIRED_SERVICES: readonly ["database", "auth", "storage", "postgrest", "gateway", "mail", "studio", "realtime", "edge-functions", "analytics", "image-transforms"];
5
5
  export declare const normalizeResourcePolicy: (resources: PlainObject) => Readonly<{
6
+ workloadQueue?: {
7
+ enabled: boolean;
8
+ waitTimeoutSeconds: number;
9
+ };
6
10
  staleAfterSeconds: number | null;
7
11
  retainedEnvironmentCount: number;
8
12
  maximumBytes: number | null;
@@ -1,5 +1,5 @@
1
1
  /** Resource limits and proof dependencies, validated before any runtime starts. */
2
- import { assertPositiveInteger, } from "./configuration_validation.mjs";
2
+ import { assertKnownKeys, assertPlainObject, assertPositiveInteger, } from "./configuration_validation.mjs";
3
3
  export const APPLICATION_REQUIRED_SERVICES = Object.freeze([
4
4
  "database",
5
5
  "auth",
@@ -29,6 +29,24 @@ const nullablePositiveInteger = (value, path) => {
29
29
  return normalized;
30
30
  };
31
31
  export const normalizeResourcePolicy = (resources) => {
32
+ let workloadQueue;
33
+ if (resources.workloadQueue !== undefined) {
34
+ const path = "config.resources.workloadQueue";
35
+ const value = assertPlainObject(resources.workloadQueue, path);
36
+ assertKnownKeys(value, ["enabled", "waitTimeoutSeconds"], path);
37
+ if (typeof value.enabled !== "boolean")
38
+ throw new Error(`${path}.enabled must be a boolean.`);
39
+ const waitTimeoutSeconds = value.waitTimeoutSeconds === undefined ? 30 : value.waitTimeoutSeconds;
40
+ if (!Number.isInteger(waitTimeoutSeconds) ||
41
+ typeof waitTimeoutSeconds !== "number" ||
42
+ waitTimeoutSeconds < 0 ||
43
+ waitTimeoutSeconds > 300)
44
+ throw new Error(`${path}.waitTimeoutSeconds must be an integer from 0 to 300.`);
45
+ workloadQueue = Object.freeze({
46
+ enabled: value.enabled,
47
+ waitTimeoutSeconds,
48
+ });
49
+ }
32
50
  const schedule = resources.cleanupSchedule ?? "manual";
33
51
  if (typeof schedule !== "string" ||
34
52
  !["manual", "daily-preview", "weekly-preview"].includes(schedule)) {
@@ -39,6 +57,7 @@ export const normalizeResourcePolicy = (resources) => {
39
57
  throw new Error("config.resources.retainedEnvironmentCount must be at most 1000.");
40
58
  }
41
59
  return Object.freeze({
60
+ ...(workloadQueue === undefined ? {} : { workloadQueue }),
42
61
  staleAfterSeconds: nullablePositiveInteger(resources.staleAfterSeconds, "config.resources.staleAfterSeconds"),
43
62
  retainedEnvironmentCount,
44
63
  maximumBytes: nullablePositiveInteger(resources.maximumBytes, "config.resources.maximumBytes"),
@@ -92,6 +92,7 @@ export const validateConfigurationSchema = (input) => {
92
92
  "retainedEnvironmentCount",
93
93
  "maximumBytes",
94
94
  "cleanupSchedule",
95
+ "workloadQueue",
95
96
  ], "config.resources");
96
97
  const preparation = root.preparation === undefined
97
98
  ? null
@@ -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");
@@ -0,0 +1,18 @@
1
+ /** Fixed catalog predicates. They inspect metadata, never evaluate stored SQL. */
2
+ type RelationAlias = "r" | "p";
3
+ /**
4
+ * Even COUNT planning opens indexes, including invalid ones, and may evaluate
5
+ * stored expressions. Permit only plain scalar bootstrap btree/hash metadata.
6
+ * Arrays/composites/domains are excluded because native support can delegate to
7
+ * their element/member operators. This is not protection against a compromised
8
+ * server or privileged replacement of PostgreSQL's bootstrap catalog contents.
9
+ */
10
+ export declare const nativeMigrationImpactIndexes: (alias: RelationAlias) => string;
11
+ /** PostgreSQL may const-fold expression statistics before consulting their data. */
12
+ export declare const nativeMigrationImpactStatistics: (alias: RelationAlias) => string;
13
+ /**
14
+ * Require one exact native default for each comparison AM. No binary-coercible
15
+ * fallback: varchar/bpchar are deliberately outside comparison recognition.
16
+ */
17
+ export declare const nativeMigrationImpactComparison: (alias: "t") => string;
18
+ export {};