@rehearsal-db/core 1.0.2 → 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 +56 -4
- package/COMPATIBILITY.md +41 -4
- package/README.md +8 -4
- 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/project/configuration_contract.d.mts +1 -0
- package/dist/src/project/configuration_normalization.mjs +2 -1
- package/dist/src/project/configuration_rendering.mjs +3 -2
- package/dist/src/project/configuration_resolution.mjs +0 -2
- package/dist/src/project/project_detection.d.mts +2 -0
- package/dist/src/project/project_detection.mjs +1 -0
- package/dist/src/project/runtime_project_identity.d.mts +5 -0
- package/dist/src/project/runtime_project_identity.mjs +41 -0
- package/dist/src/project/setup.mjs +3 -1
- 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/policy.d.mts +2 -0
- package/dist/src/runtime/policy.mjs +13 -1
- package/dist/src/runtime/privileges.d.mts +25 -0
- package/dist/src/runtime/privileges.mjs +198 -0
- 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/dist/src/targets/supabase.mjs +1 -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 +42 -9
- package/docs/getting-started.md +4 -4
- package/docs/index.md +1 -0
- package/docs/releasing.md +15 -11
- package/docs/roadmap.md +49 -9
- package/docs/runtime-lifecycle.md +15 -4
- package/docs/runtime-policies.md +145 -0
- package/docs/stability.md +8 -4
- package/docs/updating.md +73 -0
- package/docs/vscode.md +30 -19
- package/package.json +3 -1
|
@@ -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 {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const namespace = "(select oid from pg_catalog.pg_namespace where nspname = 'pg_catalog')";
|
|
2
|
+
const scalarTypes = "('bool', 'int2', 'int4', 'int8', 'numeric', 'float4', 'float8', 'text', 'varchar', 'bpchar', 'uuid', 'date', 'timestamp', 'timestamptz')";
|
|
3
|
+
const relationAlias = (value) => {
|
|
4
|
+
if (value !== "r" && value !== "p")
|
|
5
|
+
throw new Error("The fixed migration-impact catalog alias is unsupported.");
|
|
6
|
+
return value;
|
|
7
|
+
};
|
|
8
|
+
/** Only bootstrap functions; arbitrary objects merely named pg_catalog do not qualify. */
|
|
9
|
+
const nativeProcedure = (oid) => `exists (
|
|
10
|
+
select 1 from pg_catalog.pg_proc mi_proc
|
|
11
|
+
where mi_proc.oid = ${oid} and mi_proc.oid < 16384
|
|
12
|
+
and mi_proc.pronamespace = ${namespace}
|
|
13
|
+
and mi_proc.prokind = 'f' and mi_proc.prolang in (12, 13)
|
|
14
|
+
)`;
|
|
15
|
+
/** Fixed aliases mi_class/mi_am belong to the enclosing predicate's subquery. */
|
|
16
|
+
const nativeClass = () => `(
|
|
17
|
+
mi_class.oid < 16384 and mi_class.opcnamespace = ${namespace}
|
|
18
|
+
and mi_class.opcmethod = mi_am.oid
|
|
19
|
+
and mi_am.amtype = 'i'
|
|
20
|
+
and ((mi_am.oid = 403 and mi_am.amname = 'btree')
|
|
21
|
+
or (mi_am.oid = 405 and mi_am.amname = 'hash'))
|
|
22
|
+
and ${nativeProcedure("mi_am.amhandler")}
|
|
23
|
+
and exists (select 1 from pg_catalog.pg_opfamily mi_family
|
|
24
|
+
where mi_family.oid = mi_class.opcfamily and mi_family.oid < 16384
|
|
25
|
+
and mi_family.opfnamespace = ${namespace}
|
|
26
|
+
and mi_family.opfmethod = mi_am.oid
|
|
27
|
+
and not exists (select 1 from pg_catalog.pg_amop mi_member
|
|
28
|
+
where mi_member.amopfamily = mi_family.oid and not (
|
|
29
|
+
mi_member.oid < 16384 and mi_member.amopmethod = mi_am.oid
|
|
30
|
+
and exists (select 1 from pg_catalog.pg_operator mi_operator
|
|
31
|
+
where mi_operator.oid = mi_member.amopopr and mi_operator.oid < 16384
|
|
32
|
+
and mi_operator.oprnamespace = ${namespace}
|
|
33
|
+
and ${nativeProcedure("mi_operator.oprcode")}
|
|
34
|
+
and (mi_operator.oprrest = 0 or ${nativeProcedure("mi_operator.oprrest")})
|
|
35
|
+
and (mi_operator.oprjoin = 0 or ${nativeProcedure("mi_operator.oprjoin")}))
|
|
36
|
+
))
|
|
37
|
+
and not exists (select 1 from pg_catalog.pg_amproc mi_support
|
|
38
|
+
where mi_support.amprocfamily = mi_family.oid and not (
|
|
39
|
+
mi_support.oid < 16384 and ${nativeProcedure("mi_support.amproc")}
|
|
40
|
+
))
|
|
41
|
+
)
|
|
42
|
+
)`;
|
|
43
|
+
/**
|
|
44
|
+
* Even COUNT planning opens indexes, including invalid ones, and may evaluate
|
|
45
|
+
* stored expressions. Permit only plain scalar bootstrap btree/hash metadata.
|
|
46
|
+
* Arrays/composites/domains are excluded because native support can delegate to
|
|
47
|
+
* their element/member operators. This is not protection against a compromised
|
|
48
|
+
* server or privileged replacement of PostgreSQL's bootstrap catalog contents.
|
|
49
|
+
*/
|
|
50
|
+
export const nativeMigrationImpactIndexes = (alias) => {
|
|
51
|
+
const owner = relationAlias(alias);
|
|
52
|
+
return `(not exists (
|
|
53
|
+
select 1 from pg_catalog.pg_index mi_index
|
|
54
|
+
where mi_index.indrelid = ${owner}.oid and (
|
|
55
|
+
mi_index.indexprs is null and mi_index.indpred is null
|
|
56
|
+
and mi_index.indnkeyatts > 0
|
|
57
|
+
and pg_catalog.array_length(mi_index.indkey::pg_catalog.int2[], 1) = mi_index.indnatts
|
|
58
|
+
and pg_catalog.array_length(mi_index.indclass::pg_catalog.oid[], 1) = mi_index.indnkeyatts
|
|
59
|
+
and pg_catalog.array_length(mi_index.indcollation::pg_catalog.oid[], 1) = mi_index.indnkeyatts
|
|
60
|
+
and exists (select 1 from pg_catalog.pg_class mi_index_relation
|
|
61
|
+
join pg_catalog.pg_am mi_am on mi_am.oid = mi_index_relation.relam
|
|
62
|
+
where mi_index_relation.oid = mi_index.indexrelid
|
|
63
|
+
and mi_index_relation.relkind = 'i' and not mi_index_relation.relispartition
|
|
64
|
+
and mi_am.amtype = 'i'
|
|
65
|
+
and ((mi_am.oid = 403 and mi_am.amname = 'btree')
|
|
66
|
+
or (mi_am.oid = 405 and mi_am.amname = 'hash'))
|
|
67
|
+
and ${nativeProcedure("mi_am.amhandler")}
|
|
68
|
+
and not exists (select 1 from pg_catalog.unnest(mi_index.indclass::pg_catalog.oid[]) mi_key(class_oid)
|
|
69
|
+
where not exists (select 1 from pg_catalog.pg_opclass mi_class
|
|
70
|
+
join pg_catalog.pg_type mi_input on mi_input.oid = mi_class.opcintype
|
|
71
|
+
where mi_class.oid = mi_key.class_oid
|
|
72
|
+
and mi_input.oid < 16384 and mi_input.typnamespace = ${namespace}
|
|
73
|
+
and mi_input.typbasetype = 0 and mi_input.typname in ${scalarTypes}
|
|
74
|
+
and ${nativeClass()}))
|
|
75
|
+
)
|
|
76
|
+
and not exists (select 1 from pg_catalog.unnest(mi_index.indkey::pg_catalog.int2[]) mi_key(attribute_number)
|
|
77
|
+
where not exists (select 1 from pg_catalog.pg_attribute mi_attribute
|
|
78
|
+
join pg_catalog.pg_type mi_type on mi_type.oid = mi_attribute.atttypid
|
|
79
|
+
where mi_attribute.attrelid = ${owner}.oid
|
|
80
|
+
and mi_attribute.attnum = mi_key.attribute_number and mi_attribute.attnum > 0
|
|
81
|
+
and not mi_attribute.attisdropped and mi_attribute.attgenerated in ('', 's')
|
|
82
|
+
and mi_type.oid < 16384 and mi_type.typnamespace = ${namespace}
|
|
83
|
+
and mi_type.typbasetype = 0 and mi_type.typname in ${scalarTypes}))
|
|
84
|
+
and not exists (select 1 from pg_catalog.unnest(mi_index.indcollation::pg_catalog.oid[]) mi_key(collation_oid)
|
|
85
|
+
where mi_key.collation_oid <> 0 and not exists (
|
|
86
|
+
select 1 from pg_catalog.pg_collation mi_collation
|
|
87
|
+
where mi_collation.oid = mi_key.collation_oid and mi_collation.oid < 16384
|
|
88
|
+
and mi_collation.collnamespace = ${namespace} and mi_collation.collisdeterministic))
|
|
89
|
+
) is not true
|
|
90
|
+
))`;
|
|
91
|
+
};
|
|
92
|
+
/** PostgreSQL may const-fold expression statistics before consulting their data. */
|
|
93
|
+
export const nativeMigrationImpactStatistics = (alias) => {
|
|
94
|
+
const owner = relationAlias(alias);
|
|
95
|
+
return `(not exists (select 1 from pg_catalog.pg_statistic_ext mi_statistics
|
|
96
|
+
where mi_statistics.stxrelid = ${owner}.oid and mi_statistics.stxexprs is not null))`;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Require one exact native default for each comparison AM. No binary-coercible
|
|
100
|
+
* fallback: varchar/bpchar are deliberately outside comparison recognition.
|
|
101
|
+
*/
|
|
102
|
+
export const nativeMigrationImpactComparison = (alias) => {
|
|
103
|
+
if (alias !== "t")
|
|
104
|
+
throw new Error("The fixed migration-impact catalog alias is unsupported.");
|
|
105
|
+
return `(${[403, 405]
|
|
106
|
+
.map((method) => `(
|
|
107
|
+
(select pg_catalog.count(*) from pg_catalog.pg_opclass mi_default
|
|
108
|
+
where mi_default.opcintype = t.oid and mi_default.opcmethod = ${method}
|
|
109
|
+
and mi_default.opcdefault) = 1
|
|
110
|
+
and exists (select 1 from pg_catalog.pg_opclass mi_class
|
|
111
|
+
join pg_catalog.pg_am mi_am on mi_am.oid = mi_class.opcmethod
|
|
112
|
+
where mi_class.opcintype = t.oid and mi_class.opcdefault
|
|
113
|
+
and mi_am.oid = ${method} and ${nativeClass()})
|
|
114
|
+
)`)
|
|
115
|
+
.join(" and ")})`;
|
|
116
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface MigrationImpactRelation {
|
|
2
|
+
readonly schema: string;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
}
|
|
5
|
+
export type MigrationImpactUnknownReason = "unsupported_sql" | "malformed_sql" | "input_limit";
|
|
6
|
+
interface MigrationImpactTaskSource {
|
|
7
|
+
readonly relation: MigrationImpactRelation;
|
|
8
|
+
readonly line: number;
|
|
9
|
+
readonly column: number;
|
|
10
|
+
}
|
|
11
|
+
/** Private measurement inputs, never a public SQL/identifier payload. */
|
|
12
|
+
export type MigrationImpactTask = MigrationImpactTaskSource & ({
|
|
13
|
+
readonly metric: "rows";
|
|
14
|
+
readonly operation: "drop_table" | "truncate" | "broad_delete";
|
|
15
|
+
} | {
|
|
16
|
+
readonly metric: "populated";
|
|
17
|
+
readonly operation: "drop_column";
|
|
18
|
+
readonly columns: readonly [string];
|
|
19
|
+
} | {
|
|
20
|
+
readonly metric: "nulls";
|
|
21
|
+
readonly operation: "not_null";
|
|
22
|
+
readonly columns: readonly [string];
|
|
23
|
+
} | {
|
|
24
|
+
readonly metric: "duplicate_rows";
|
|
25
|
+
readonly operation: "new_constraint";
|
|
26
|
+
readonly columns: readonly string[];
|
|
27
|
+
} | {
|
|
28
|
+
readonly metric: "orphan_rows";
|
|
29
|
+
readonly operation: "new_constraint";
|
|
30
|
+
readonly columns: readonly string[];
|
|
31
|
+
readonly referencedRelation: MigrationImpactRelation;
|
|
32
|
+
readonly referencedColumns: readonly string[];
|
|
33
|
+
});
|
|
34
|
+
export interface MigrationImpactSqlPlan {
|
|
35
|
+
/** Coverage of the narrow recognizer, never SQL validity or a safe verdict. */
|
|
36
|
+
readonly complete: boolean;
|
|
37
|
+
readonly tasks: readonly MigrationImpactTask[];
|
|
38
|
+
readonly unknownReasons: readonly MigrationImpactUnknownReason[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Plan narrow, current pre-migration snapshot measurements. No statement is
|
|
42
|
+
* executed; tasks do not predict earlier/later migration effects. The caller
|
|
43
|
+
* must validate the owned target, ordinary non-inherited/non-RLS tables and
|
|
44
|
+
* supported built-in column/operator semantics before using these private specs.
|
|
45
|
+
*/
|
|
46
|
+
export declare const planMigrationImpactSql: (source: string) => MigrationImpactSqlPlan;
|
|
47
|
+
export {};
|