@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +56 -4
  2. package/COMPATIBILITY.md +41 -4
  3. package/README.md +8 -4
  4. package/dist/package.json +3 -1
  5. package/dist/src/cli/arguments.mjs +1 -0
  6. package/dist/src/cli/basic_command_handlers.mjs +4 -0
  7. package/dist/src/cli/editor_command_handlers.mjs +24 -0
  8. package/dist/src/project/configuration_contract.d.mts +1 -0
  9. package/dist/src/project/configuration_normalization.mjs +2 -1
  10. package/dist/src/project/configuration_rendering.mjs +3 -2
  11. package/dist/src/project/configuration_resolution.mjs +0 -2
  12. package/dist/src/project/project_detection.d.mts +2 -0
  13. package/dist/src/project/project_detection.mjs +1 -0
  14. package/dist/src/project/runtime_project_identity.d.mts +5 -0
  15. package/dist/src/project/runtime_project_identity.mjs +41 -0
  16. package/dist/src/project/setup.mjs +3 -1
  17. package/dist/src/runtime/migration_history.d.mts +5 -1
  18. package/dist/src/runtime/migration_history.mjs +24 -6
  19. package/dist/src/runtime/migration_impact.d.mts +125 -0
  20. package/dist/src/runtime/migration_impact.mjs +278 -0
  21. package/dist/src/runtime/migration_impact_catalog.d.mts +18 -0
  22. package/dist/src/runtime/migration_impact_catalog.mjs +116 -0
  23. package/dist/src/runtime/migration_impact_plan.d.mts +47 -0
  24. package/dist/src/runtime/migration_impact_plan.mjs +297 -0
  25. package/dist/src/runtime/migration_impact_queries.d.mts +22 -0
  26. package/dist/src/runtime/migration_impact_queries.mjs +270 -0
  27. package/dist/src/runtime/migration_insights.d.mts +137 -0
  28. package/dist/src/runtime/migration_insights.mjs +143 -0
  29. package/dist/src/runtime/migration_insights_input.d.mts +13 -0
  30. package/dist/src/runtime/migration_insights_input.mjs +153 -0
  31. package/dist/src/runtime/migration_sql_lexer.d.mts +14 -0
  32. package/dist/src/runtime/migration_sql_lexer.mjs +204 -0
  33. package/dist/src/runtime/migration_sql_scan.d.mts +16 -0
  34. package/dist/src/runtime/migration_sql_scan.mjs +369 -0
  35. package/dist/src/runtime/plan.d.mts +2 -1
  36. package/dist/src/runtime/plan.mjs +4 -4
  37. package/dist/src/runtime/policy.d.mts +2 -0
  38. package/dist/src/runtime/policy.mjs +13 -1
  39. package/dist/src/runtime/privileges.d.mts +25 -0
  40. package/dist/src/runtime/privileges.mjs +198 -0
  41. package/dist/src/runtime/topology.d.mts +1 -1
  42. package/dist/src/runtime/topology.mjs +2 -2
  43. package/dist/src/shared/cancellation.mjs +1 -0
  44. package/dist/src/shared/held_path_ancestry.d.mts +7 -0
  45. package/dist/src/shared/held_path_ancestry.mjs +110 -0
  46. package/dist/src/shared/operation_guard.mjs +1 -0
  47. package/dist/src/targets/migration_impact_runtime.d.mts +22 -0
  48. package/dist/src/targets/migration_impact_runtime.mjs +367 -0
  49. package/dist/src/targets/supabase.mjs +1 -0
  50. package/docs/README.md +20 -7
  51. package/docs/adapters.md +10 -0
  52. package/docs/architecture.md +75 -1
  53. package/docs/commands.md +169 -34
  54. package/docs/configuration.md +42 -9
  55. package/docs/getting-started.md +4 -4
  56. package/docs/index.md +1 -0
  57. package/docs/releasing.md +15 -11
  58. package/docs/roadmap.md +49 -9
  59. package/docs/runtime-lifecycle.md +15 -4
  60. package/docs/runtime-policies.md +145 -0
  61. package/docs/stability.md +8 -4
  62. package/docs/updating.md +73 -0
  63. package/docs/vscode.md +30 -19
  64. package/package.json +3 -1
@@ -0,0 +1,297 @@
1
+ import { lexMigrationSql } from "./migration_sql_lexer.mjs";
2
+ const maximumTasks = 100;
3
+ // More columns are outside the supported grammar, not a whole-input limit.
4
+ const maximumColumns = 8;
5
+ const maximumIdentifierBytes = 63;
6
+ /**
7
+ * The shared lexer folds words to uppercase. Check the original ASCII span as
8
+ * well: Unicode uppercase expansions must not become supported identifiers or
9
+ * keywords. This walk is linear and uses the lexer's UTF-16/CRLF coordinates.
10
+ */
11
+ const asciiWords = (source, tokens) => {
12
+ const result = new Map();
13
+ let offset = 0;
14
+ let line = 1;
15
+ let column = 1;
16
+ const advance = () => {
17
+ const character = source[offset++];
18
+ if (character === "\r") {
19
+ if (source[offset] === "\n")
20
+ offset += 1;
21
+ line += 1;
22
+ column = 1;
23
+ }
24
+ else if (character === "\n") {
25
+ line += 1;
26
+ column = 1;
27
+ }
28
+ else
29
+ column += 1;
30
+ };
31
+ for (const token of tokens) {
32
+ while (offset < source.length &&
33
+ (line < token.line || (line === token.line && column < token.column)))
34
+ advance();
35
+ if (token.kind !== "word")
36
+ continue;
37
+ const start = offset;
38
+ if (!/[A-Za-z_]/u.test(source[offset] ?? ""))
39
+ continue;
40
+ while (/[A-Za-z0-9_$]/u.test(source[offset] ?? ""))
41
+ advance();
42
+ const raw = source.slice(start, offset);
43
+ if (raw.toUpperCase() === token.value)
44
+ result.set(token, raw.toLowerCase());
45
+ }
46
+ return result;
47
+ };
48
+ /** Exact token-shape matching, not a general PostgreSQL grammar/parser. */
49
+ class StatementReader {
50
+ tokens;
51
+ words;
52
+ index = 0;
53
+ constructor(tokens, words) {
54
+ this.tokens = tokens;
55
+ this.words = words;
56
+ }
57
+ get current() {
58
+ return this.tokens[this.index];
59
+ }
60
+ get done() {
61
+ return this.index === this.tokens.length;
62
+ }
63
+ word(value) {
64
+ const token = this.current;
65
+ if (!token || this.words.get(token) !== value.toLowerCase())
66
+ return false;
67
+ this.index += 1;
68
+ return true;
69
+ }
70
+ symbol(value) {
71
+ const token = this.current;
72
+ if (token?.kind !== "symbol" || token.value !== value)
73
+ return false;
74
+ this.index += 1;
75
+ return true;
76
+ }
77
+ identifier() {
78
+ const token = this.current;
79
+ const value = token && this.words.get(token);
80
+ if (!value || value.length > maximumIdentifierBytes)
81
+ return undefined;
82
+ this.index += 1;
83
+ return value;
84
+ }
85
+ relation() {
86
+ const schema = this.identifier();
87
+ if (!schema || !this.symbol("."))
88
+ return undefined;
89
+ const name = this.identifier();
90
+ return name ? { schema, name } : undefined;
91
+ }
92
+ columns() {
93
+ if (!this.symbol("("))
94
+ return undefined;
95
+ const columns = [];
96
+ do {
97
+ const column = this.identifier();
98
+ if (!column ||
99
+ columns.length >= maximumColumns ||
100
+ columns.includes(column))
101
+ return undefined;
102
+ columns.push(column);
103
+ } while (this.symbol(","));
104
+ return this.symbol(")") ? columns : undefined;
105
+ }
106
+ }
107
+ const recognizeStatement = (tokens, words) => {
108
+ const reader = new StatementReader(tokens, words);
109
+ const first = reader.current;
110
+ if (!first)
111
+ return undefined;
112
+ const position = { line: first.line, column: first.column };
113
+ if (reader.word("DROP")) {
114
+ if (!reader.word("TABLE"))
115
+ return undefined;
116
+ if (reader.word("IF") && !reader.word("EXISTS"))
117
+ return undefined;
118
+ const relation = reader.relation();
119
+ reader.word("RESTRICT");
120
+ return relation && reader.done
121
+ ? { ...position, relation, metric: "rows", operation: "drop_table" }
122
+ : undefined;
123
+ }
124
+ if (reader.word("TRUNCATE")) {
125
+ reader.word("TABLE");
126
+ reader.word("ONLY");
127
+ const relation = reader.relation();
128
+ if ((reader.word("RESTART") || reader.word("CONTINUE")) &&
129
+ !reader.word("IDENTITY"))
130
+ return undefined;
131
+ reader.word("RESTRICT");
132
+ return relation && reader.done
133
+ ? { ...position, relation, metric: "rows", operation: "truncate" }
134
+ : undefined;
135
+ }
136
+ if (reader.word("DELETE")) {
137
+ if (!reader.word("FROM"))
138
+ return undefined;
139
+ reader.word("ONLY");
140
+ const relation = reader.relation();
141
+ return relation && reader.done
142
+ ? { ...position, relation, metric: "rows", operation: "broad_delete" }
143
+ : undefined;
144
+ }
145
+ if (!reader.word("ALTER") || !reader.word("TABLE"))
146
+ return undefined;
147
+ reader.word("ONLY");
148
+ const relation = reader.relation();
149
+ if (!relation)
150
+ return undefined;
151
+ const action = reader.current;
152
+ if (!action)
153
+ return undefined;
154
+ if (reader.word("DROP")) {
155
+ reader.word("COLUMN");
156
+ if (reader.word("IF") && !reader.word("EXISTS"))
157
+ return undefined;
158
+ const column = reader.identifier();
159
+ reader.word("RESTRICT");
160
+ return column && reader.done
161
+ ? {
162
+ relation,
163
+ line: action.line,
164
+ column: action.column,
165
+ operation: "drop_column",
166
+ metric: "populated",
167
+ columns: [column],
168
+ }
169
+ : undefined;
170
+ }
171
+ if (reader.word("ALTER")) {
172
+ reader.word("COLUMN");
173
+ const column = reader.identifier();
174
+ if (!column || !reader.word("SET"))
175
+ return undefined;
176
+ const not = reader.current;
177
+ if (!not || !reader.word("NOT") || !reader.word("NULL") || !reader.done)
178
+ return undefined;
179
+ return {
180
+ relation,
181
+ line: not.line,
182
+ column: not.column,
183
+ operation: "not_null",
184
+ metric: "nulls",
185
+ columns: [column],
186
+ };
187
+ }
188
+ if (!reader.word("ADD"))
189
+ return undefined;
190
+ if (reader.word("CONSTRAINT") && !reader.identifier())
191
+ return undefined;
192
+ const constraintPosition = { line: action.line, column: action.column };
193
+ if (reader.word("UNIQUE")) {
194
+ const columns = reader.columns();
195
+ return columns && reader.done
196
+ ? {
197
+ ...constraintPosition,
198
+ relation,
199
+ operation: "new_constraint",
200
+ metric: "duplicate_rows",
201
+ columns,
202
+ }
203
+ : undefined;
204
+ }
205
+ if (!reader.word("FOREIGN") || !reader.word("KEY"))
206
+ return undefined;
207
+ const columns = reader.columns();
208
+ if (!columns || !reader.word("REFERENCES"))
209
+ return undefined;
210
+ const referencedRelation = reader.relation();
211
+ const referencedColumns = reader.columns();
212
+ if (reader.word("MATCH") && !reader.word("SIMPLE"))
213
+ return undefined;
214
+ return referencedRelation &&
215
+ referencedColumns &&
216
+ columns.length === referencedColumns.length &&
217
+ reader.done
218
+ ? {
219
+ ...constraintPosition,
220
+ relation,
221
+ operation: "new_constraint",
222
+ metric: "orphan_rows",
223
+ columns,
224
+ referencedRelation,
225
+ referencedColumns,
226
+ }
227
+ : undefined;
228
+ };
229
+ /**
230
+ * Plan narrow, current pre-migration snapshot measurements. No statement is
231
+ * executed; tasks do not predict earlier/later migration effects. The caller
232
+ * must validate the owned target, ordinary non-inherited/non-RLS tables and
233
+ * supported built-in column/operator semantics before using these private specs.
234
+ */
235
+ export const planMigrationImpactSql = (source) => {
236
+ const lexical = lexMigrationSql(source);
237
+ const reasons = new Set(lexical.unknownReasons);
238
+ const refusal = () => ({
239
+ complete: false,
240
+ tasks: [],
241
+ unknownReasons: [...reasons].sort(),
242
+ });
243
+ // Lexical ambiguity, malformed input and limits invalidate all boundaries.
244
+ if (reasons.size > 0 || lexical.ambiguousQuotes)
245
+ return refusal();
246
+ const words = asciiWords(source, lexical.tokens);
247
+ const statements = [];
248
+ let statement = [];
249
+ for (const token of lexical.tokens) {
250
+ if (token.kind === "symbol" && token.value === ";" && token.depth === 0) {
251
+ if (statement.length > 0)
252
+ statements.push(statement);
253
+ statement = [];
254
+ }
255
+ else
256
+ statement.push(token);
257
+ }
258
+ if (statement.length > 0)
259
+ statements.push(statement);
260
+ // SQL-standard routine bodies and explicit transactions need a real parser /
261
+ // transactional model. Never reinterpret their inner statements as top-level.
262
+ const transactionCommands = new Set([
263
+ "begin",
264
+ "start",
265
+ "commit",
266
+ "end",
267
+ "rollback",
268
+ "abort",
269
+ "savepoint",
270
+ "release",
271
+ "prepare",
272
+ ]);
273
+ if (lexical.tokens.some((token, index) => words.get(token) === "begin" &&
274
+ words.get(lexical.tokens[index + 1]) === "atomic") ||
275
+ statements.some((tokens) => transactionCommands.has(words.get(tokens[0]) ?? ""))) {
276
+ reasons.add("unsupported_sql");
277
+ return refusal();
278
+ }
279
+ const tasks = [];
280
+ for (const tokens of statements) {
281
+ const task = recognizeStatement(tokens, words);
282
+ if (!task)
283
+ reasons.add("unsupported_sql");
284
+ else {
285
+ if (tasks.length >= maximumTasks) {
286
+ reasons.add("input_limit");
287
+ return refusal();
288
+ }
289
+ tasks.push(task);
290
+ }
291
+ }
292
+ return {
293
+ complete: reasons.size === 0,
294
+ tasks,
295
+ unknownReasons: [...reasons].sort(),
296
+ };
297
+ };
@@ -0,0 +1,22 @@
1
+ /** Compile only fixed aggregate queries; migration SQL and identifiers stay private. */
2
+ import type { MigrationImpactTask } from "./migration_impact_plan.mjs";
3
+ export declare const MIGRATION_IMPACT_LIMITS: Readonly<{
4
+ maximumMeasurements: 100;
5
+ lockTimeoutMilliseconds: 1000;
6
+ statementTimeoutMilliseconds: 5000;
7
+ totalTimeoutMilliseconds: 30000;
8
+ maximumOutputBytes: 131072;
9
+ maximumScriptBytes: 262144;
10
+ }>;
11
+ export interface MigrationImpactCount {
12
+ readonly index: number;
13
+ readonly count: string | null;
14
+ readonly reason: "unsupported_runtime_metadata" | "unsupported_server_version" | "total_timeout" | null;
15
+ }
16
+ /** One consistent current-row snapshot. No pending migration statement is executed. */
17
+ export declare const buildMigrationImpactScript: (tasks: readonly MigrationImpactTask[], transactionTimeoutMilliseconds?: number) => string;
18
+ export declare const parseMigrationImpactBatchOutput: (output: string, expectedCount: number) => {
19
+ readonly snapshotAt: string | null;
20
+ readonly counts: readonly MigrationImpactCount[];
21
+ };
22
+ export declare const parseMigrationImpactOutput: (output: string, expectedCount: number) => readonly MigrationImpactCount[];
@@ -0,0 +1,270 @@
1
+ import { nativeMigrationImpactIndexes, nativeMigrationImpactStatistics, nativeMigrationImpactComparison, } from "./migration_impact_catalog.mjs";
2
+ export const MIGRATION_IMPACT_LIMITS = Object.freeze({
3
+ maximumMeasurements: 100,
4
+ lockTimeoutMilliseconds: 1_000,
5
+ statementTimeoutMilliseconds: 5_000,
6
+ totalTimeoutMilliseconds: 30_000,
7
+ maximumOutputBytes: 131_072,
8
+ maximumScriptBytes: 262_144,
9
+ });
10
+ const identifier = (value) => {
11
+ if (typeof value !== "string" || !/^[a-z_][a-z0-9_$]{0,62}$/u.test(value))
12
+ throw new Error("The private measurement identifier is unsupported.");
13
+ return value;
14
+ };
15
+ const quoted = (value) => `"${identifier(value)}"`;
16
+ const literal = (value) => `'${identifier(value)}'`;
17
+ const relationName = (relation) => `${quoted(relation.schema)}.${quoted(relation.name)}`;
18
+ const relationLiteral = (relation) => `'${relationName(relation)}'`;
19
+ const relationPredicate = (relation, alias) => `${alias}.relnamespace = (select oid from pg_catalog.pg_namespace where nspname = ${literal(relation.schema)})
20
+ and ${alias}.relname = ${literal(relation.name)}
21
+ and ${alias}.relkind = 'r' and ${alias}.relpersistence in ('p', 'u')
22
+ and not ${alias}.relrowsecurity and not ${alias}.relispartition
23
+ and ${alias}.relam = (select oid from pg_catalog.pg_am where amname = 'heap')
24
+ and not exists (select 1 from pg_catalog.pg_inherits h
25
+ where h.inhparent = ${alias}.oid or h.inhrelid = ${alias}.oid)
26
+ and pg_catalog.to_regclass(${relationLiteral(relation)})::oid = ${alias}.oid
27
+ and ${nativeMigrationImpactIndexes(alias)}
28
+ and ${nativeMigrationImpactStatistics(alias)}`;
29
+ const columnPredicate = (relationAlias, column, compare) => `exists (
30
+ select 1 from pg_catalog.pg_attribute a
31
+ join pg_catalog.pg_type t on t.oid = a.atttypid
32
+ join pg_catalog.pg_namespace tn on tn.oid = t.typnamespace
33
+ where a.attrelid = ${relationAlias}.oid and a.attname = ${literal(column)}
34
+ and a.attnum > 0 and not a.attisdropped and a.attgenerated in ('', 's')
35
+ and tn.nspname = 'pg_catalog' and t.oid < 16384 and t.typbasetype = 0
36
+ and t.typname in ('bool', 'int2', 'int4', 'int8', 'numeric', 'float4', 'float8',
37
+ 'text', ${compare ? "" : "'varchar', 'bpchar',"} 'uuid', 'date', 'timestamp', 'timestamptz')
38
+ ${compare ? `and ${nativeMigrationImpactComparison("t")}` : ""}
39
+ ${compare ? `and (a.attcollation = 0 or exists (select 1 from pg_catalog.pg_collation co join pg_catalog.pg_namespace cn on cn.oid = co.collnamespace where co.oid = a.attcollation and co.oid < 16384 and cn.nspname = 'pg_catalog' and co.collisdeterministic))` : ""}
40
+ )`;
41
+ const validateTask = (task) => {
42
+ relationName(task.relation);
43
+ if (!Number.isSafeInteger(task.line) ||
44
+ task.line < 1 ||
45
+ !Number.isSafeInteger(task.column) ||
46
+ task.column < 1)
47
+ throw new Error("The private measurement location is unsupported.");
48
+ const operations = {
49
+ rows: ["drop_table", "truncate", "broad_delete"],
50
+ populated: ["drop_column"],
51
+ nulls: ["not_null"],
52
+ duplicate_rows: ["new_constraint"],
53
+ orphan_rows: ["new_constraint"],
54
+ };
55
+ if (!operations[task.metric]?.includes(task.operation))
56
+ throw new Error("The private measurement kind is unsupported.");
57
+ if (task.metric !== "rows") {
58
+ if (!Array.isArray(task.columns) ||
59
+ task.columns.length < 1 ||
60
+ task.columns.length > 8 ||
61
+ new Set(task.columns).size !== task.columns.length ||
62
+ (["populated", "nulls"].includes(task.metric) &&
63
+ task.columns.length !== 1))
64
+ throw new Error("The private measurement columns are unsupported.");
65
+ task.columns.forEach(identifier);
66
+ }
67
+ if (task.metric === "orphan_rows") {
68
+ relationName(task.referencedRelation);
69
+ if (!Array.isArray(task.referencedColumns) ||
70
+ task.referencedColumns.length !== task.columns.length ||
71
+ new Set(task.referencedColumns).size !== task.referencedColumns.length)
72
+ throw new Error("The private measurement reference is unsupported.");
73
+ task.referencedColumns.forEach(identifier);
74
+ }
75
+ };
76
+ const metadataCheck = (task, relationIds) => {
77
+ const compare = ["duplicate_rows", "orphan_rows"].includes(task.metric);
78
+ const expectedOid = (relation, alias) => `${alias}.oid = :'impact_relation_${relationIds.get(relationName(relation))}_oid'::pg_catalog.oid`;
79
+ const predicates = [
80
+ relationPredicate(task.relation, "r"),
81
+ expectedOid(task.relation, "r"),
82
+ ];
83
+ if (task.metric !== "rows")
84
+ predicates.push(...task.columns.map((column) => columnPredicate("r", column, compare)));
85
+ if (task.metric === "orphan_rows") {
86
+ const pairedColumns = task.columns.map((column, index) => `exists (select 1 from pg_catalog.pg_attribute ca join pg_catalog.pg_attribute pa
87
+ on pa.attrelid = p.oid and pa.attname = ${literal(task.referencedColumns[index])}
88
+ where ca.attrelid = r.oid and ca.attname = ${literal(column)}
89
+ and ca.atttypid = pa.atttypid and ca.attcollation = pa.attcollation)`);
90
+ predicates.push(`exists (select 1 from pg_catalog.pg_class p where
91
+ ${relationPredicate(task.referencedRelation, "p")} and ${expectedOid(task.referencedRelation, "p")} and
92
+ ${task.referencedColumns.map((column) => columnPredicate("p", column, true)).join(" and ")} and
93
+ ${pairedColumns.join(" and ")})`);
94
+ }
95
+ return `select exists (select 1 from pg_catalog.pg_class r where ${predicates.join(" and ")}) as ok`;
96
+ };
97
+ const countQuery = (task) => {
98
+ const table = relationName(task.relation);
99
+ if (task.metric === "rows")
100
+ return `select pg_catalog.count(*)::text as count from only ${table}`;
101
+ if (task.metric === "nulls" || task.metric === "populated")
102
+ return `select pg_catalog.count(*)::text as count from only ${table}
103
+ where ${quoted(task.columns[0])} is ${task.metric === "populated" ? "not " : ""}null`;
104
+ const populated = task.columns
105
+ .map((column) => `c.${quoted(column)} is not null`)
106
+ .join(" and ");
107
+ if (task.metric === "duplicate_rows")
108
+ return `select coalesce(pg_catalog.sum(n), 0)::text as count from (
109
+ select pg_catalog.count(*) as n from only ${table} c where ${populated}
110
+ group by ${task.columns.map((column) => `c.${quoted(column)}`).join(", ")}
111
+ having pg_catalog.count(*) > 1) duplicates`;
112
+ const equal = task.columns
113
+ .map((column, index) => `p.${quoted(task.referencedColumns[index])} operator(pg_catalog.=) c.${quoted(column)}`)
114
+ .join(" and ");
115
+ return `select pg_catalog.count(*)::text as count from only ${table} c
116
+ where ${populated} and not exists (select 1 from only ${relationName(task.referencedRelation)} p where ${equal})`;
117
+ };
118
+ /** One consistent current-row snapshot. No pending migration statement is executed. */
119
+ export const buildMigrationImpactScript = (tasks, transactionTimeoutMilliseconds = MIGRATION_IMPACT_LIMITS.totalTimeoutMilliseconds) => {
120
+ if (tasks.length > MIGRATION_IMPACT_LIMITS.maximumMeasurements)
121
+ throw new Error("The measurement count exceeds its fixed bound.");
122
+ tasks.forEach(validateTask);
123
+ if (!Number.isSafeInteger(transactionTimeoutMilliseconds) ||
124
+ transactionTimeoutMilliseconds < 1 ||
125
+ transactionTimeoutMilliseconds >
126
+ MIGRATION_IMPACT_LIMITS.totalTimeoutMilliseconds)
127
+ throw new Error("The server transaction deadline exceeds its fixed bound.");
128
+ const relations = [
129
+ ...new Map(tasks.flatMap((task) => [
130
+ task.relation,
131
+ ...(task.metric === "orphan_rows" ? [task.referencedRelation] : []),
132
+ ].map((relation) => [relationName(relation), relation]))).entries(),
133
+ ].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
134
+ const relationIds = new Map(relations.map(([name], index) => [name, index]));
135
+ const script = [
136
+ "\\set ON_ERROR_STOP on",
137
+ "set default_transaction_read_only = on;",
138
+ "set lock_timeout = '1s';",
139
+ "set statement_timeout = '5s';",
140
+ "set idle_in_transaction_session_timeout = '5s';",
141
+ "set search_path = pg_catalog;",
142
+ "set row_security = off;",
143
+ "set max_parallel_workers_per_gather = 0;",
144
+ "set work_mem = '1MB';",
145
+ "set temp_file_limit = '8MB';",
146
+ "set constraint_exclusion = off;",
147
+ "select pg_catalog.current_setting('server_version_num')::integer >= 170000 as supported \\gset impact_server_",
148
+ "\\if :impact_server_supported",
149
+ `set transaction_timeout = '${transactionTimeoutMilliseconds}ms';`,
150
+ "select pg_catalog.clock_timestamp()::text as started \\gset impact_clock_",
151
+ "begin isolation level read committed read only;",
152
+ ...relations.map(([, relation], index) => `with eligible as (select r.oid from pg_catalog.pg_class r where ${relationPredicate(relation, "r")})
153
+ select exists (select 1 from eligible) as ok, coalesce((select oid from eligible), 0)::pg_catalog.oid as oid \\gset impact_relation_${index}_`),
154
+ "rollback;",
155
+ // Preflight must not freeze the later repeatable-read catalog snapshot.
156
+ `select greatest(1, least(${transactionTimeoutMilliseconds}, ${transactionTimeoutMilliseconds} - elapsed))::text as milliseconds,
157
+ elapsed < ${transactionTimeoutMilliseconds} as available from
158
+ (select pg_catalog.ceil(extract(epoch from (pg_catalog.clock_timestamp() - :'impact_clock_started'::pg_catalog.timestamptz)) * 1000) as elapsed) budget \\gset impact_remaining_`,
159
+ "\\if :impact_remaining_available",
160
+ "begin isolation level repeatable read read only;",
161
+ "set local transaction_timeout = :'impact_remaining_milliseconds';",
162
+ // Schema names also participate in query resolution. A database-local
163
+ // namespace-catalog lock prevents schema rename/swap after the checks;
164
+ // relation locks alone do not stabilize that mapping.
165
+ "lock table pg_catalog.pg_namespace in share mode;",
166
+ // Globally ordered strong locks exclude ordinary/concurrent index and statistics
167
+ // DDL before the first repeatable-read SELECT establishes the count snapshot.
168
+ // A name swapped to a view can transiently lock its dependencies; the retained
169
+ // OID/ordinary-table checks still prohibit reading it or executing its query.
170
+ ...relations.flatMap(([name], index) => [
171
+ `\\if :impact_relation_${index}_ok`,
172
+ `lock table only ${name} in share row exclusive mode;`,
173
+ "\\endif",
174
+ ]),
175
+ `select pg_catalog.json_build_object('snapshotAt', pg_catalog.to_char(pg_catalog.statement_timestamp() at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'));`,
176
+ ];
177
+ tasks.forEach((task, index) => {
178
+ const unknown = `select pg_catalog.json_build_object('index', ${index}, 'count', null, 'reason', 'unsupported_runtime_metadata');`;
179
+ script.push(`${metadataCheck(task, relationIds)} \\gset locked_${index}_`, `\\if :locked_${index}_ok`, `select pg_catalog.json_build_object('index', ${index}, 'count', measured.count, 'reason', null) from (${countQuery(task)}) measured;`, "\\else", unknown, "\\endif");
180
+ });
181
+ script.push("rollback;", "\\else", "select pg_catalog.json_build_object('totalTimeout', true);", "\\endif", "\\else", "select pg_catalog.json_build_object('unsupportedServerVersion', true);", "\\endif", "");
182
+ const result = script.join("\n");
183
+ if (Buffer.byteLength(result) > MIGRATION_IMPACT_LIMITS.maximumScriptBytes)
184
+ throw new Error("The fixed measurement script exceeds its byte bound.");
185
+ return result;
186
+ };
187
+ export const parseMigrationImpactBatchOutput = (output, expectedCount) => {
188
+ const invalid = () => {
189
+ throw new Error("The sandbox returned an unsupported aggregate snapshot.");
190
+ };
191
+ if (Buffer.byteLength(output) > MIGRATION_IMPACT_LIMITS.maximumOutputBytes)
192
+ return invalid();
193
+ const lines = output.trim().split(/\r?\n/u);
194
+ let header;
195
+ try {
196
+ header = JSON.parse(lines.shift() ?? "");
197
+ }
198
+ catch {
199
+ return invalid();
200
+ }
201
+ if (!header || typeof header !== "object" || Array.isArray(header))
202
+ return invalid();
203
+ const value = header;
204
+ if ((Object.keys(value).join(",") === "unsupportedServerVersion" &&
205
+ value.unsupportedServerVersion === true) ||
206
+ (Object.keys(value).join(",") === "totalTimeout" &&
207
+ value.totalTimeout === true)) {
208
+ if (lines.length ||
209
+ !Number.isSafeInteger(expectedCount) ||
210
+ expectedCount < 0 ||
211
+ expectedCount > MIGRATION_IMPACT_LIMITS.maximumMeasurements)
212
+ return invalid();
213
+ return {
214
+ snapshotAt: null,
215
+ counts: Array.from({ length: expectedCount }, (_, index) => ({
216
+ index,
217
+ count: null,
218
+ reason: value.totalTimeout === true
219
+ ? "total_timeout"
220
+ : "unsupported_server_version",
221
+ })),
222
+ };
223
+ }
224
+ if (Object.keys(value).join(",") !== "snapshotAt" ||
225
+ typeof value.snapshotAt !== "string" ||
226
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value.snapshotAt) ||
227
+ !Number.isFinite(Date.parse(value.snapshotAt)) ||
228
+ new Date(value.snapshotAt).toISOString() !== value.snapshotAt)
229
+ return invalid();
230
+ return {
231
+ snapshotAt: value.snapshotAt,
232
+ counts: parseMigrationImpactOutput(lines.join("\n"), expectedCount),
233
+ };
234
+ };
235
+ export const parseMigrationImpactOutput = (output, expectedCount) => {
236
+ const invalid = () => {
237
+ throw new Error("The sandbox returned an unsupported aggregate report.");
238
+ };
239
+ if (!Number.isSafeInteger(expectedCount) ||
240
+ expectedCount < 0 ||
241
+ expectedCount > MIGRATION_IMPACT_LIMITS.maximumMeasurements ||
242
+ Buffer.byteLength(output) > MIGRATION_IMPACT_LIMITS.maximumOutputBytes)
243
+ return invalid();
244
+ const lines = output.trim() ? output.trim().split(/\r?\n/u) : [];
245
+ if (lines.length !== expectedCount)
246
+ return invalid();
247
+ return lines.map((line, index) => {
248
+ let parsed;
249
+ try {
250
+ parsed = JSON.parse(line);
251
+ }
252
+ catch {
253
+ return invalid();
254
+ }
255
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
256
+ return invalid();
257
+ const value = parsed;
258
+ if (Object.keys(value).sort().join(",") !== "count,index,reason" ||
259
+ value.index !== index)
260
+ return invalid();
261
+ if (value.count === null && value.reason === "unsupported_runtime_metadata")
262
+ return { index, count: null, reason: value.reason };
263
+ if (typeof value.count !== "string" ||
264
+ !/^(?:0|[1-9][0-9]{0,18})$/u.test(value.count) ||
265
+ BigInt(value.count) > 9223372036854775807n ||
266
+ value.reason !== null)
267
+ return invalid();
268
+ return { index, count: value.count, reason: null };
269
+ });
270
+ };