@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
|
@@ -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 {};
|
|
@@ -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[];
|