pg-admin-mcp 0.0.0-stage → 0.1.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/README.md +608 -2
- package/dist/config/env.js +67 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/profiles.js +257 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/core/audit-context.js +21 -0
- package/dist/core/audit-context.js.map +1 -0
- package/dist/core/audit.js +91 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/brands.js +24 -0
- package/dist/core/brands.js.map +1 -0
- package/dist/core/classifier.js +279 -0
- package/dist/core/classifier.js.map +1 -0
- package/dist/core/errors.js +197 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/format.js +258 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/guards.js +513 -0
- package/dist/core/guards.js.map +1 -0
- package/dist/core/identifiers.js +431 -0
- package/dist/core/identifiers.js.map +1 -0
- package/dist/core/limits.js +79 -0
- package/dist/core/limits.js.map +1 -0
- package/dist/core/logger.js +34 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/paginate.js +195 -0
- package/dist/core/paginate.js.map +1 -0
- package/dist/core/paths.js +392 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/policy.js +98 -0
- package/dist/core/policy.js.map +1 -0
- package/dist/core/shutdown.js +10 -0
- package/dist/core/shutdown.js.map +1 -0
- package/dist/core/stdout-guard.js +25 -0
- package/dist/core/stdout-guard.js.map +1 -0
- package/dist/db/catalog.js +1006 -0
- package/dist/db/catalog.js.map +1 -0
- package/dist/db/pool.js +158 -0
- package/dist/db/pool.js.map +1 -0
- package/dist/db/session.js +258 -0
- package/dist/db/session.js.map +1 -0
- package/dist/db/types.js +2 -0
- package/dist/db/types.js.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/index.js +88 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/runtime/binaries.js +356 -0
- package/dist/runtime/binaries.js.map +1 -0
- package/dist/runtime/env-file.js +62 -0
- package/dist/runtime/env-file.js.map +1 -0
- package/dist/runtime/environment.js +34 -0
- package/dist/runtime/environment.js.map +1 -0
- package/dist/server.js +106 -0
- package/dist/server.js.map +1 -0
- package/dist/snapshot/capture.js +189 -0
- package/dist/snapshot/capture.js.map +1 -0
- package/dist/snapshot/diff.js +254 -0
- package/dist/snapshot/diff.js.map +1 -0
- package/dist/snapshot/migrate.js +1596 -0
- package/dist/snapshot/migrate.js.map +1 -0
- package/dist/snapshot/model.js +80 -0
- package/dist/snapshot/model.js.map +1 -0
- package/dist/tools/_archive_meta.js +138 -0
- package/dist/tools/_archive_meta.js.map +1 -0
- package/dist/tools/_batch_shared.js +73 -0
- package/dist/tools/_batch_shared.js.map +1 -0
- package/dist/tools/_export_format.js +107 -0
- package/dist/tools/_export_format.js.map +1 -0
- package/dist/tools/_object_kinds.js +97 -0
- package/dist/tools/_object_kinds.js.map +1 -0
- package/dist/tools/_relation_detail.js +55 -0
- package/dist/tools/_relation_detail.js.map +1 -0
- package/dist/tools/_shared.js +17 -0
- package/dist/tools/_shared.js.map +1 -0
- package/dist/tools/_snapshot_io.js +238 -0
- package/dist/tools/_snapshot_io.js.map +1 -0
- package/dist/tools/admin/_shared.js +18 -0
- package/dist/tools/admin/_shared.js.map +1 -0
- package/dist/tools/admin/extensions.js +80 -0
- package/dist/tools/admin/extensions.js.map +1 -0
- package/dist/tools/admin/grants.js +207 -0
- package/dist/tools/admin/grants.js.map +1 -0
- package/dist/tools/admin/index.js +138 -0
- package/dist/tools/admin/index.js.map +1 -0
- package/dist/tools/admin/maintenance.js +106 -0
- package/dist/tools/admin/maintenance.js.map +1 -0
- package/dist/tools/admin/roles.js +144 -0
- package/dist/tools/admin/roles.js.map +1 -0
- package/dist/tools/admin/sessions.js +104 -0
- package/dist/tools/admin/sessions.js.map +1 -0
- package/dist/tools/admin/stats.js +63 -0
- package/dist/tools/admin/stats.js.map +1 -0
- package/dist/tools/backup.js +288 -0
- package/dist/tools/backup.js.map +1 -0
- package/dist/tools/connection_status.js +138 -0
- package/dist/tools/connection_status.js.map +1 -0
- package/dist/tools/copy.js +355 -0
- package/dist/tools/copy.js.map +1 -0
- package/dist/tools/delete_batch.js +73 -0
- package/dist/tools/delete_batch.js.map +1 -0
- package/dist/tools/describe_object.js +87 -0
- package/dist/tools/describe_object.js.map +1 -0
- package/dist/tools/execute_sql.js +395 -0
- package/dist/tools/execute_sql.js.map +1 -0
- package/dist/tools/explain.js +134 -0
- package/dist/tools/explain.js.map +1 -0
- package/dist/tools/export.js +215 -0
- package/dist/tools/export.js.map +1 -0
- package/dist/tools/get_schema.js +148 -0
- package/dist/tools/get_schema.js.map +1 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/insert_batch.js +212 -0
- package/dist/tools/insert_batch.js.map +1 -0
- package/dist/tools/list_objects.js +160 -0
- package/dist/tools/list_objects.js.map +1 -0
- package/dist/tools/prepare_migration.js +112 -0
- package/dist/tools/prepare_migration.js.map +1 -0
- package/dist/tools/restore.js +301 -0
- package/dist/tools/restore.js.map +1 -0
- package/dist/tools/schema_diff.js +79 -0
- package/dist/tools/schema_diff.js.map +1 -0
- package/dist/tools/schema_snapshot.js +57 -0
- package/dist/tools/schema_snapshot.js.map +1 -0
- package/dist/tools/select.js +162 -0
- package/dist/tools/select.js.map +1 -0
- package/dist/tools/update_batch.js +154 -0
- package/dist/tools/update_batch.js.map +1 -0
- package/package.json +47 -5
|
@@ -0,0 +1,1596 @@
|
|
|
1
|
+
import { quoteIdent } from '../core/identifiers.js';
|
|
2
|
+
import { functionKey } from './model.js';
|
|
3
|
+
/** Thrown by {@link topoSort} when the graph cannot be linearized — see the module doc's "What happens on a genuine cycle" section. */
|
|
4
|
+
export class DependencyCycleError extends Error {
|
|
5
|
+
cycle;
|
|
6
|
+
constructor(cycle) {
|
|
7
|
+
super(`dependency cycle detected — these nodes can never become ready: ${cycle.join(', ')}`);
|
|
8
|
+
this.name = 'DependencyCycleError';
|
|
9
|
+
this.cycle = cycle;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Plain Kahn's algorithm: repeatedly emit a node with no remaining
|
|
14
|
+
* unsatisfied dependency, lowest `(sortKey, id)` first among ties. Anything
|
|
15
|
+
* left when the ready queue runs dry is, by definition, part of (or depends
|
|
16
|
+
* on) a cycle.
|
|
17
|
+
*/
|
|
18
|
+
export function topoSort(nodes) {
|
|
19
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
20
|
+
const inDegree = new Map();
|
|
21
|
+
const dependents = new Map();
|
|
22
|
+
for (const n of nodes) {
|
|
23
|
+
inDegree.set(n.id, 0);
|
|
24
|
+
dependents.set(n.id, []);
|
|
25
|
+
}
|
|
26
|
+
for (const n of nodes) {
|
|
27
|
+
let degree = 0;
|
|
28
|
+
for (const dep of n.dependsOn) {
|
|
29
|
+
if (!byId.has(dep))
|
|
30
|
+
continue; // outside this graph — see the field doc
|
|
31
|
+
degree++;
|
|
32
|
+
dependents.get(dep)?.push(n.id);
|
|
33
|
+
}
|
|
34
|
+
inDegree.set(n.id, degree);
|
|
35
|
+
}
|
|
36
|
+
const bySort = (a, b) => {
|
|
37
|
+
const na = byId.get(a);
|
|
38
|
+
const nb = byId.get(b);
|
|
39
|
+
const ka = na?.sortKey ?? 0;
|
|
40
|
+
const kb = nb?.sortKey ?? 0;
|
|
41
|
+
if (ka !== kb)
|
|
42
|
+
return ka - kb;
|
|
43
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
44
|
+
};
|
|
45
|
+
const ready = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
|
|
46
|
+
ready.sort(bySort);
|
|
47
|
+
const order = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
while (ready.length > 0) {
|
|
50
|
+
// Small graphs (a schema diff is at most a few hundred statements) — a
|
|
51
|
+
// sorted-insert into an array is simpler than a real priority queue and
|
|
52
|
+
// fast enough here.
|
|
53
|
+
const id = ready.shift();
|
|
54
|
+
if (id === undefined)
|
|
55
|
+
break;
|
|
56
|
+
seen.add(id);
|
|
57
|
+
const node = byId.get(id);
|
|
58
|
+
if (node !== undefined)
|
|
59
|
+
order.push(node.value);
|
|
60
|
+
for (const dep of dependents.get(id) ?? []) {
|
|
61
|
+
const remaining = (inDegree.get(dep) ?? 0) - 1;
|
|
62
|
+
inDegree.set(dep, remaining);
|
|
63
|
+
if (remaining === 0) {
|
|
64
|
+
const idx = ready.findIndex((r) => bySort(dep, r) < 0);
|
|
65
|
+
if (idx === -1)
|
|
66
|
+
ready.push(dep);
|
|
67
|
+
else
|
|
68
|
+
ready.splice(idx, 0, dep);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (order.length < nodes.length) {
|
|
73
|
+
const stuck = nodes.filter((n) => !seen.has(n.id)).map((n) => n.id);
|
|
74
|
+
throw new DependencyCycleError(stuck);
|
|
75
|
+
}
|
|
76
|
+
return order;
|
|
77
|
+
}
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Small SQL-building helpers.
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
function qualify(schema, name) {
|
|
82
|
+
return `${quoteIdent(schema)}.${quoteIdent(name)}`;
|
|
83
|
+
}
|
|
84
|
+
/** Quotes each dot-separated segment of an already-qualified catalog name (e.g. an `inherits` entry). */
|
|
85
|
+
function quoteQualifiedName(qualified) {
|
|
86
|
+
return qualified
|
|
87
|
+
.split('.')
|
|
88
|
+
.map((seg) => quoteIdent(seg))
|
|
89
|
+
.join('.');
|
|
90
|
+
}
|
|
91
|
+
function splitFirstDot(path) {
|
|
92
|
+
const i = path.indexOf('.');
|
|
93
|
+
if (i === -1)
|
|
94
|
+
return { schema: path, rest: '' };
|
|
95
|
+
return { schema: path.slice(0, i), rest: path.slice(i + 1) };
|
|
96
|
+
}
|
|
97
|
+
/** `schema.table.leaf` — takes the *last* segment as the leaf and the *second-to-last* as the table, so a schema name is never mistaken for one. Inherits the same "no literal dots in names" assumption `diff.ts#diffList`'s own `path` building already makes; not a new limitation introduced here. */
|
|
98
|
+
function splitTablePath(path) {
|
|
99
|
+
const segs = path.split('.');
|
|
100
|
+
const leaf = segs.pop() ?? '';
|
|
101
|
+
const table = segs.pop() ?? '';
|
|
102
|
+
return { schema: segs.join('.'), table, leaf };
|
|
103
|
+
}
|
|
104
|
+
/** `name(args)` -> `{ name, args }`. Functions do not legally contain `(` in an unquoted name; the rare quoted-name case with a literal `(` is not handled. */
|
|
105
|
+
function splitFunctionKey(key) {
|
|
106
|
+
const i = key.indexOf('(');
|
|
107
|
+
if (i === -1)
|
|
108
|
+
return { name: key, args: '' };
|
|
109
|
+
return { name: key.slice(0, i), args: key.slice(i + 1, -1) };
|
|
110
|
+
}
|
|
111
|
+
const VOLATILE_DEFAULT = /\b(now|clock_timestamp|statement_timestamp|transaction_timestamp|random|gen_random_uuid|uuid_generate_v4|txid_current)\s*\(|nextval\s*\(/i;
|
|
112
|
+
function columnDefClause(col) {
|
|
113
|
+
const parts = [`${quoteIdent(col.name)} ${col.type}`];
|
|
114
|
+
if (col.generated === 'stored') {
|
|
115
|
+
// ponytail: a collation on a generated column is vanishingly rare and not
|
|
116
|
+
// representable alongside GENERATED ... STORED in the same clause; not handled.
|
|
117
|
+
parts.push(`GENERATED ALWAYS AS (${col.default ?? 'NULL'}) STORED`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
if (col.collation !== null)
|
|
121
|
+
parts.push(`COLLATE ${quoteIdent(col.collation)}`);
|
|
122
|
+
if (col.identity !== null)
|
|
123
|
+
parts.push(`GENERATED ${col.identity === 'always' ? 'ALWAYS' : 'BY DEFAULT'} AS IDENTITY`);
|
|
124
|
+
if (col.default !== null)
|
|
125
|
+
parts.push(`DEFAULT ${col.default}`);
|
|
126
|
+
}
|
|
127
|
+
if (!col.nullable)
|
|
128
|
+
parts.push('NOT NULL');
|
|
129
|
+
return parts.join(' ');
|
|
130
|
+
}
|
|
131
|
+
function field(fields, name) {
|
|
132
|
+
return fields.find((f) => f.field === name);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* `diffSnapshots` only calls its per-object-type `nested` diff (columns,
|
|
136
|
+
* constraints, indexes, …) when an object exists on *both* sides — an object
|
|
137
|
+
* that is wholly added or removed instead carries its full nested content as
|
|
138
|
+
* `object` and relies on the consumer to walk it (`diff.ts`'s own header
|
|
139
|
+
* comment: "migrate.ts needs it to emit the CREATE/DROP"). A table already
|
|
140
|
+
* carries everything it needs flat on one object (columns, keys, indexes,
|
|
141
|
+
* triggers, policies — see `handleTable`'s `added` branch). A whole *schema*
|
|
142
|
+
* does not: `SnapshotSchema` nests tables/views/materialized views/
|
|
143
|
+
* sequences/functions/enums, each of which is its own top-level object type
|
|
144
|
+
* with its own handler. This expands a wholly added/removed `schema` change
|
|
145
|
+
* into that schema change *plus* one added/removed change per nested object,
|
|
146
|
+
* so the existing per-object-type handlers below build every one of them
|
|
147
|
+
* without duplicating their logic.
|
|
148
|
+
*/
|
|
149
|
+
function expandSchemaChanges(changes) {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const c of changes) {
|
|
152
|
+
out.push(c);
|
|
153
|
+
if (c.objectType !== 'schema' || c.kind === 'modified')
|
|
154
|
+
continue;
|
|
155
|
+
const schema = c.object;
|
|
156
|
+
const schemaName = c.path;
|
|
157
|
+
for (const t of schema.tables)
|
|
158
|
+
out.push({ kind: c.kind, objectType: 'table', path: `${schemaName}.${t.name}`, object: t });
|
|
159
|
+
for (const v of schema.views)
|
|
160
|
+
out.push({ kind: c.kind, objectType: 'view', path: `${schemaName}.${v.name}`, object: v });
|
|
161
|
+
for (const mv of schema.materializedViews)
|
|
162
|
+
out.push({ kind: c.kind, objectType: 'materialized_view', path: `${schemaName}.${mv.name}`, object: mv });
|
|
163
|
+
for (const s of schema.sequences)
|
|
164
|
+
out.push({ kind: c.kind, objectType: 'sequence', path: `${schemaName}.${s.name}`, object: s });
|
|
165
|
+
for (const fn of schema.functions)
|
|
166
|
+
out.push({ kind: c.kind, objectType: 'function', path: `${schemaName}.${functionKey(fn)}`, object: fn });
|
|
167
|
+
for (const e of schema.enums)
|
|
168
|
+
out.push({ kind: c.kind, objectType: 'enum', path: `${schemaName}.${e.name}`, object: e });
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
const PHASE = {
|
|
173
|
+
SCHEMA_CREATE: 0,
|
|
174
|
+
ENUM_CREATE: 10,
|
|
175
|
+
ENUM_ALTER: 11,
|
|
176
|
+
EXTENSION_CREATE: 12,
|
|
177
|
+
EXTENSION_ALTER: 13,
|
|
178
|
+
SEQUENCE_CREATE: 20,
|
|
179
|
+
SEQUENCE_ALTER: 21,
|
|
180
|
+
TABLE_CREATE: 30,
|
|
181
|
+
TABLE_ALTER: 31,
|
|
182
|
+
VIEW_CREATE: 40,
|
|
183
|
+
VIEW_ALTER: 41,
|
|
184
|
+
FUNCTION_CREATE: 50,
|
|
185
|
+
FUNCTION_ALTER: 51,
|
|
186
|
+
COLUMN_ADD: 60,
|
|
187
|
+
COLUMN_ALTER: 61,
|
|
188
|
+
INDEX_CREATE: 70,
|
|
189
|
+
CONSTRAINT_ADD: 80,
|
|
190
|
+
FK_ADD: 90,
|
|
191
|
+
TRIGGER_CREATE: 100,
|
|
192
|
+
POLICY_CREATE: 110,
|
|
193
|
+
RLS_ALTER: 111,
|
|
194
|
+
RLS_DISABLE: 200,
|
|
195
|
+
POLICY_DROP: 201,
|
|
196
|
+
TRIGGER_DROP: 202,
|
|
197
|
+
FK_DROP: 203,
|
|
198
|
+
INDEX_DROP: 204,
|
|
199
|
+
CONSTRAINT_DROP: 205,
|
|
200
|
+
COLUMN_DROP: 206,
|
|
201
|
+
VIEW_DROP: 207,
|
|
202
|
+
TABLE_DROP: 208,
|
|
203
|
+
SEQUENCE_DROP: 209,
|
|
204
|
+
FUNCTION_DROP: 210,
|
|
205
|
+
ENUM_DROP: 211,
|
|
206
|
+
EXTENSION_DROP: 212,
|
|
207
|
+
SCHEMA_DROP: 213,
|
|
208
|
+
};
|
|
209
|
+
class Builder {
|
|
210
|
+
drafts = [];
|
|
211
|
+
unsupported = [];
|
|
212
|
+
extraDeps = new Map();
|
|
213
|
+
tableCreateIds;
|
|
214
|
+
tableDropIds;
|
|
215
|
+
enumCreateIds;
|
|
216
|
+
/** Schema names being wholly dropped in this plan — every nested object's drop statement must land before that schema's DROP SCHEMA, so it does not fail with "schema not empty". See `expandSchemaChanges`. */
|
|
217
|
+
schemaDropNames;
|
|
218
|
+
/** `schema\0name` -> the statements that create something under that name. See {@link claimName}. */
|
|
219
|
+
nameClaims = new Map();
|
|
220
|
+
/** `schema\0name` -> the statements that free that name. See {@link claimName}. */
|
|
221
|
+
nameReleases = new Map();
|
|
222
|
+
constructor(changes) {
|
|
223
|
+
// Pre-scan so any later edge check ("does this table also get created in
|
|
224
|
+
// this plan?") is order-independent — see the module doc, rule 6.
|
|
225
|
+
this.tableCreateIds = new Set(changes.filter((c) => c.objectType === 'table' && c.kind === 'added').map((c) => `table:${c.path}`));
|
|
226
|
+
this.tableDropIds = new Set(changes.filter((c) => c.objectType === 'table' && c.kind === 'removed').map((c) => `tableDrop:${c.path}`));
|
|
227
|
+
this.enumCreateIds = new Set(changes.filter((c) => c.objectType === 'enum' && c.kind === 'added').map((c) => `enum:${c.path}`));
|
|
228
|
+
this.schemaDropNames = new Set(changes.filter((c) => c.objectType === 'schema' && c.kind === 'removed').map((c) => c.path));
|
|
229
|
+
}
|
|
230
|
+
/** Wires `dropId` to finish before its owning schema's DROP SCHEMA, when that schema is itself being dropped in this plan. */
|
|
231
|
+
linkToSchemaDrop(schema, dropId) {
|
|
232
|
+
if (this.schemaDropNames.has(schema))
|
|
233
|
+
this.addExtraDep(`schemaDrop:${schema}`, dropId);
|
|
234
|
+
}
|
|
235
|
+
has(id) {
|
|
236
|
+
return this.tableCreateIds.has(id) || this.tableDropIds.has(id) || this.enumCreateIds.has(id) || this.drafts.some((d) => d.id === id);
|
|
237
|
+
}
|
|
238
|
+
emit(id, deps, sortKey, stmt) {
|
|
239
|
+
this.drafts.push({ id, deps: new Set(deps), sortKey, stmt });
|
|
240
|
+
}
|
|
241
|
+
/** Adds a dependency edge from `targetId` onto `dependsOnId`, applied even if `targetId` has not been emitted yet. */
|
|
242
|
+
addExtraDep(targetId, dependsOnId) {
|
|
243
|
+
const set = this.extraDeps.get(targetId) ?? new Set();
|
|
244
|
+
set.add(dependsOnId);
|
|
245
|
+
this.extraDeps.set(targetId, set);
|
|
246
|
+
}
|
|
247
|
+
markUnsupported(objectType, path, changeKind, reason) {
|
|
248
|
+
this.unsupported.push({ objectType, path, changeKind, reason });
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Records that `claim.id` will occupy `name` in `schema`. Tables, views,
|
|
252
|
+
* materialized views, sequences, indexes and the indexes behind PRIMARY
|
|
253
|
+
* KEY/UNIQUE constraints all share one per-schema namespace (`pg_class`), and
|
|
254
|
+
* an enum's type name collides with a table's there too (every table gets a
|
|
255
|
+
* composite type of the same name). See {@link resolveNameCollisions}.
|
|
256
|
+
*/
|
|
257
|
+
claimName(schema, name, claim) {
|
|
258
|
+
const key = `${schema}\u0000${name}`;
|
|
259
|
+
const list = this.nameClaims.get(key) ?? [];
|
|
260
|
+
list.push(claim);
|
|
261
|
+
this.nameClaims.set(key, list);
|
|
262
|
+
}
|
|
263
|
+
/** Records that `dropId` frees `name` in `schema`. Counterpart of {@link claimName}. */
|
|
264
|
+
releaseName(schema, name, dropId) {
|
|
265
|
+
const key = `${schema}\u0000${name}`;
|
|
266
|
+
const list = this.nameReleases.get(key) ?? [];
|
|
267
|
+
list.push(dropId);
|
|
268
|
+
this.nameReleases.set(key, list);
|
|
269
|
+
}
|
|
270
|
+
/** True when `from` already (transitively) depends on `to`. Used only to refuse an edge that would close a cycle. */
|
|
271
|
+
dependsOn(from, to) {
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
const stack = [from];
|
|
274
|
+
while (stack.length > 0) {
|
|
275
|
+
const id = stack.pop();
|
|
276
|
+
if (id === undefined || seen.has(id))
|
|
277
|
+
continue;
|
|
278
|
+
seen.add(id);
|
|
279
|
+
if (id === to)
|
|
280
|
+
return true;
|
|
281
|
+
const draft = this.drafts.find((d) => d.id === id);
|
|
282
|
+
if (draft !== undefined)
|
|
283
|
+
stack.push(...draft.deps);
|
|
284
|
+
const extra = this.extraDeps.get(id);
|
|
285
|
+
if (extra !== undefined)
|
|
286
|
+
stack.push(...extra);
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* A rename is drop + create in the snapshot model, and the phase numbers put
|
|
292
|
+
* every create before every drop — so a `CREATE` reusing a name the plan has
|
|
293
|
+
* not dropped yet fails ("relation … already exists"), mid-run in a large
|
|
294
|
+
* plan. PostgreSQL preserves constraint and index names across `ALTER TABLE
|
|
295
|
+
* … RENAME`, so this bites even when the two relation names differ: the old
|
|
296
|
+
* table's `widgets_pkey` index is still there when the new table declares
|
|
297
|
+
* its own `widgets_pkey`.
|
|
298
|
+
*
|
|
299
|
+
* Every create that claims a name some drop in the same schema frees gets an
|
|
300
|
+
* explicit edge onto that drop, which overrides the phase ordering (the
|
|
301
|
+
* phase number is a tie-break, never a dependency — see the module doc).
|
|
302
|
+
*/
|
|
303
|
+
resolveNameCollisions() {
|
|
304
|
+
for (const [key, claims] of this.nameClaims) {
|
|
305
|
+
const drops = this.nameReleases.get(key);
|
|
306
|
+
if (drops === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
const name = key.slice(key.indexOf('\u0000') + 1);
|
|
309
|
+
for (const claim of claims) {
|
|
310
|
+
for (const dropId of drops) {
|
|
311
|
+
if (this.dependsOn(dropId, claim.id)) {
|
|
312
|
+
this.markUnsupported(claim.objectType, claim.path, claim.changeKind, `this statement needs the name "${name}" freed by another statement in this plan that in turn depends on this one — a rename cannot be expressed as drop+create without a name collision. Rename manually with ALTER TABLE ... RENAME (which preserves the data, unlike drop+create) and re-diff.`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
this.addExtraDep(claim.id, dropId);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
toGraph() {
|
|
321
|
+
return this.drafts.map((d) => {
|
|
322
|
+
const extra = this.extraDeps.get(d.id);
|
|
323
|
+
const deps = extra === undefined ? [...d.deps] : [...d.deps, ...extra];
|
|
324
|
+
return { id: d.id, value: d.stmt, dependsOn: deps, sortKey: d.sortKey };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Per-object-type handlers.
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
function handleSchema(b, c) {
|
|
332
|
+
const name = c.path;
|
|
333
|
+
if (c.kind === 'added') {
|
|
334
|
+
b.emit(`schema:${name}`, [], PHASE.SCHEMA_CREATE, {
|
|
335
|
+
sql: `CREATE SCHEMA ${quoteIdent(name)};`,
|
|
336
|
+
objectType: 'schema',
|
|
337
|
+
path: name,
|
|
338
|
+
changeKind: 'added',
|
|
339
|
+
risk: 'safe',
|
|
340
|
+
riskNote: 'creates an empty namespace — nothing else in the database is affected.',
|
|
341
|
+
reversible: true,
|
|
342
|
+
down: `DROP SCHEMA ${quoteIdent(name)};`,
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (c.kind === 'removed') {
|
|
347
|
+
b.emit(`schemaDrop:${name}`, [], PHASE.SCHEMA_DROP, {
|
|
348
|
+
sql: `DROP SCHEMA ${quoteIdent(name)};`,
|
|
349
|
+
objectType: 'schema',
|
|
350
|
+
path: name,
|
|
351
|
+
changeKind: 'removed',
|
|
352
|
+
risk: 'high',
|
|
353
|
+
riskNote: 'fails outright if the schema still contains anything (this plan drops its contents first via their own statements); once it succeeds, the schema and every object it owned are gone.',
|
|
354
|
+
reversible: false,
|
|
355
|
+
downNote: 'cannot restore: the schema and everything it owned (tables, data, functions, …) are gone once this runs.',
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const owner = field(c.fields, 'owner');
|
|
360
|
+
if (owner !== undefined) {
|
|
361
|
+
b.emit(`schemaOwner:${name}`, [], PHASE.SCHEMA_CREATE, {
|
|
362
|
+
sql: `ALTER SCHEMA ${quoteIdent(name)} OWNER TO ${quoteIdent(String(owner.after))};`,
|
|
363
|
+
objectType: 'schema',
|
|
364
|
+
path: name,
|
|
365
|
+
changeKind: 'modified',
|
|
366
|
+
risk: 'low',
|
|
367
|
+
riskNote: 'metadata-only ownership change; grants and object contents are untouched.',
|
|
368
|
+
reversible: true,
|
|
369
|
+
down: `ALTER SCHEMA ${quoteIdent(name)} OWNER TO ${quoteIdent(String(owner.before))};`,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function handleExtension(b, c) {
|
|
374
|
+
const name = c.path;
|
|
375
|
+
if (c.kind === 'added') {
|
|
376
|
+
const obj = c.object;
|
|
377
|
+
b.emit(`extension:${name}`, [], PHASE.EXTENSION_CREATE, {
|
|
378
|
+
sql: `CREATE EXTENSION ${quoteIdent(name)} VERSION '${obj.version}';`,
|
|
379
|
+
objectType: 'extension',
|
|
380
|
+
path: name,
|
|
381
|
+
changeKind: 'added',
|
|
382
|
+
risk: 'moderate',
|
|
383
|
+
riskNote: 'runs the extension\'s own install script, which can create arbitrary objects (types, functions, operators) with the privileges of the installing role — review what the extension installs before applying.',
|
|
384
|
+
reversible: true,
|
|
385
|
+
down: `DROP EXTENSION ${quoteIdent(name)};`,
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (c.kind === 'removed') {
|
|
390
|
+
b.emit(`extensionDrop:${name}`, [], PHASE.EXTENSION_DROP, {
|
|
391
|
+
sql: `DROP EXTENSION ${quoteIdent(name)};`,
|
|
392
|
+
objectType: 'extension',
|
|
393
|
+
path: name,
|
|
394
|
+
changeKind: 'removed',
|
|
395
|
+
risk: 'high',
|
|
396
|
+
riskNote: 'drops every object the extension owns (its functions, types, operators); anything a table or view built on top of those becomes broken unless it is also being dropped in this same plan.',
|
|
397
|
+
reversible: false,
|
|
398
|
+
downNote: 'cannot restore: any object that depended on what the extension provided, and was dropped along with it, is not recoverable by reinstalling the extension.',
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const version = field(c.fields, 'version');
|
|
403
|
+
if (version !== undefined) {
|
|
404
|
+
b.emit(`extensionAlter:${name}`, [], PHASE.EXTENSION_ALTER, {
|
|
405
|
+
sql: `ALTER EXTENSION ${quoteIdent(name)} UPDATE TO '${String(version.after)}';`,
|
|
406
|
+
objectType: 'extension',
|
|
407
|
+
path: name,
|
|
408
|
+
changeKind: 'modified',
|
|
409
|
+
risk: 'moderate',
|
|
410
|
+
riskNote: 'runs the extension\'s own update script for every version step between the old and new version — review the extension\'s changelog first.',
|
|
411
|
+
reversible: true,
|
|
412
|
+
down: `ALTER EXTENSION ${quoteIdent(name)} UPDATE TO '${String(version.before)}';`,
|
|
413
|
+
downNote: 'downgrade support depends entirely on the extension itself; not every extension ships a script for moving backward.',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function handleEnum(b, c) {
|
|
418
|
+
const { schema, rest: enumName } = splitFirstDot(c.path);
|
|
419
|
+
const id = `enum:${c.path}`;
|
|
420
|
+
if (c.kind === 'added') {
|
|
421
|
+
const obj = c.object;
|
|
422
|
+
const labelList = obj.labels.map((l) => `'${l.replace(/'/g, "''")}'`).join(', ');
|
|
423
|
+
b.claimName(schema, enumName, { id, objectType: 'enum', path: c.path, changeKind: 'added' });
|
|
424
|
+
b.emit(id, [`schema:${schema}`], PHASE.ENUM_CREATE, {
|
|
425
|
+
sql: `CREATE TYPE ${qualify(schema, enumName)} AS ENUM (${labelList});`,
|
|
426
|
+
objectType: 'enum',
|
|
427
|
+
path: c.path,
|
|
428
|
+
changeKind: 'added',
|
|
429
|
+
risk: 'safe',
|
|
430
|
+
riskNote: 'creates a new type; nothing references it yet.',
|
|
431
|
+
reversible: true,
|
|
432
|
+
down: `DROP TYPE ${qualify(schema, enumName)};`,
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (c.kind === 'removed') {
|
|
437
|
+
const enumDropId = `enumDrop:${c.path}`;
|
|
438
|
+
b.releaseName(schema, enumName, enumDropId);
|
|
439
|
+
b.emit(enumDropId, [], PHASE.ENUM_DROP, {
|
|
440
|
+
sql: `DROP TYPE ${qualify(schema, enumName)};`,
|
|
441
|
+
objectType: 'enum',
|
|
442
|
+
path: c.path,
|
|
443
|
+
changeKind: 'removed',
|
|
444
|
+
risk: 'high',
|
|
445
|
+
riskNote: 'fails if any column still uses this type — every such column must already have been changed to a different type earlier in this plan (or dropped) before this statement can succeed.',
|
|
446
|
+
reversible: false,
|
|
447
|
+
downNote: 'cannot restore: recreating the type does not restore the columns that used to reference it, or their data.',
|
|
448
|
+
});
|
|
449
|
+
b.linkToSchemaDrop(schema, enumDropId);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const labels = field(c.fields, 'labels');
|
|
453
|
+
if (labels !== undefined) {
|
|
454
|
+
const before = labels.before;
|
|
455
|
+
const after = labels.after;
|
|
456
|
+
if (isPureInsertion(before, after)) {
|
|
457
|
+
let prevId;
|
|
458
|
+
let cursor = 0;
|
|
459
|
+
for (let i = 0; i < after.length; i++) {
|
|
460
|
+
const label = after[i];
|
|
461
|
+
if (cursor < before.length && before[cursor] === label) {
|
|
462
|
+
cursor++;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
// `ADD VALUE ... BEFORE x` requires x to *already exist* — so the
|
|
466
|
+
// anchor must be the next label that was in `before`, not merely the
|
|
467
|
+
// next one in `after` (which can itself be another new label this same
|
|
468
|
+
// loop has not added yet: ['a','d'] -> ['a','b','c','d'] would anchor
|
|
469
|
+
// 'b' on 'c'). With no such label left, there is nothing after this
|
|
470
|
+
// one that already exists, so appending is exactly right. Successive
|
|
471
|
+
// new labels sharing one anchor each land immediately before it, in
|
|
472
|
+
// loop order, which reproduces `after`.
|
|
473
|
+
const anchor = after.slice(i + 1).find((l) => before.includes(l));
|
|
474
|
+
const stmtId = `enumAlter:${c.path}:${i}`;
|
|
475
|
+
const clause = anchor === undefined ? '' : ` BEFORE '${anchor.replace(/'/g, "''")}'`;
|
|
476
|
+
b.emit(stmtId, prevId === undefined ? [] : [prevId], PHASE.ENUM_ALTER, {
|
|
477
|
+
sql: `ALTER TYPE ${qualify(schema, enumName)} ADD VALUE '${label.replace(/'/g, "''")}'${clause};`,
|
|
478
|
+
objectType: 'enum',
|
|
479
|
+
path: c.path,
|
|
480
|
+
changeKind: 'modified',
|
|
481
|
+
risk: 'low',
|
|
482
|
+
riskNote: 'appends (or inserts) one label; existing rows and existing labels are untouched. PostgreSQL cannot run this inside the same transaction as a statement that already used the type in this session — apply it as its own statement.',
|
|
483
|
+
reversible: false,
|
|
484
|
+
downNote: 'cannot restore: PostgreSQL has no ALTER TYPE ... DROP VALUE — removing an enum label requires recreating the type and rewriting every column that uses it.',
|
|
485
|
+
});
|
|
486
|
+
prevId = stmtId;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
b.markUnsupported('enum', c.path, 'modified', 'labels were removed or reordered without a pure insertion — PostgreSQL cannot drop or reorder enum labels without recreating the type and rewriting every column that uses it; not attempted.');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const comment = field(c.fields, 'comment');
|
|
494
|
+
if (comment !== undefined) {
|
|
495
|
+
b.emit(`enumComment:${c.path}`, [], PHASE.ENUM_ALTER, commentStatement('enum', c.path, `TYPE ${qualify(schema, enumName)}`, comment));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** True when `after` equals `before` with zero or more labels inserted, in order — i.e. `before` is a subsequence of `after`. */
|
|
499
|
+
function isPureInsertion(before, after) {
|
|
500
|
+
let i = 0;
|
|
501
|
+
for (const label of after) {
|
|
502
|
+
if (i < before.length && before[i] === label)
|
|
503
|
+
i++;
|
|
504
|
+
}
|
|
505
|
+
return i === before.length;
|
|
506
|
+
}
|
|
507
|
+
function commentStatement(objectType, path, onClause, change) {
|
|
508
|
+
const after = change.after === null ? 'NULL' : `'${String(change.after).replace(/'/g, "''")}'`;
|
|
509
|
+
const before = change.before === null ? 'NULL' : `'${String(change.before).replace(/'/g, "''")}'`;
|
|
510
|
+
return {
|
|
511
|
+
sql: `COMMENT ON ${onClause} IS ${after};`,
|
|
512
|
+
objectType,
|
|
513
|
+
path,
|
|
514
|
+
changeKind: 'modified',
|
|
515
|
+
risk: 'safe',
|
|
516
|
+
riskNote: 'documentation only — no effect on data, access, or behaviour.',
|
|
517
|
+
reversible: true,
|
|
518
|
+
down: `COMMENT ON ${onClause} IS ${before};`,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function handleSequence(b, c) {
|
|
522
|
+
const { schema: sSchema, rest: sName } = splitFirstDot(c.path);
|
|
523
|
+
if (c.kind === 'added') {
|
|
524
|
+
const s = c.object;
|
|
525
|
+
b.claimName(sSchema, sName, { id: `sequence:${c.path}`, objectType: 'sequence', path: c.path, changeKind: 'added' });
|
|
526
|
+
b.emit(`sequence:${c.path}`, [`schema:${sSchema}`], PHASE.SEQUENCE_CREATE, {
|
|
527
|
+
sql: `CREATE SEQUENCE ${qualify(sSchema, sName)} AS ${s.dataType} INCREMENT BY ${s.incrementBy} ` +
|
|
528
|
+
`MINVALUE ${s.minValue} MAXVALUE ${s.maxValue} START WITH ${s.startValue} CACHE ${s.cacheSize} ${s.cycle ? 'CYCLE' : 'NO CYCLE'};`,
|
|
529
|
+
objectType: 'sequence',
|
|
530
|
+
path: c.path,
|
|
531
|
+
changeKind: 'added',
|
|
532
|
+
risk: 'safe',
|
|
533
|
+
riskNote: 'creates a new counter; nothing depends on it yet.',
|
|
534
|
+
reversible: true,
|
|
535
|
+
down: `DROP SEQUENCE ${qualify(sSchema, sName)};`,
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (c.kind === 'removed') {
|
|
540
|
+
const sequenceDropId = `sequenceDrop:${c.path}`;
|
|
541
|
+
b.releaseName(sSchema, sName, sequenceDropId);
|
|
542
|
+
b.emit(sequenceDropId, [], PHASE.SEQUENCE_DROP, {
|
|
543
|
+
sql: `DROP SEQUENCE ${qualify(sSchema, sName)};`,
|
|
544
|
+
objectType: 'sequence',
|
|
545
|
+
path: c.path,
|
|
546
|
+
changeKind: 'removed',
|
|
547
|
+
risk: 'high',
|
|
548
|
+
riskNote: 'fails if a column default still calls nextval() on it; that default must already be dropped or changed earlier in this plan. Any column whose identity/default depended on it stops working once this runs.',
|
|
549
|
+
reversible: false,
|
|
550
|
+
downNote: 'the sequence itself was deliberately never captured with its current counter position (model.ts: "a sequence\'s last_value is data, not schema") — recreating it restores the structure (increment, bounds, cycle) but not the exact value it was at when dropped.',
|
|
551
|
+
});
|
|
552
|
+
b.linkToSchemaDrop(sSchema, sequenceDropId);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const clauses = [];
|
|
556
|
+
const down = [];
|
|
557
|
+
const map = {
|
|
558
|
+
dataType: 'AS',
|
|
559
|
+
incrementBy: 'INCREMENT BY',
|
|
560
|
+
minValue: 'MINVALUE',
|
|
561
|
+
maxValue: 'MAXVALUE',
|
|
562
|
+
startValue: 'START WITH',
|
|
563
|
+
cacheSize: 'CACHE',
|
|
564
|
+
};
|
|
565
|
+
for (const [f, keyword] of Object.entries(map)) {
|
|
566
|
+
const ch = field(c.fields, f);
|
|
567
|
+
if (ch !== undefined) {
|
|
568
|
+
clauses.push(`${keyword} ${String(ch.after)}`);
|
|
569
|
+
down.push(`${keyword} ${String(ch.before)}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const cycle = field(c.fields, 'cycle');
|
|
573
|
+
if (cycle !== undefined) {
|
|
574
|
+
clauses.push(cycle.after === true ? 'CYCLE' : 'NO CYCLE');
|
|
575
|
+
down.push(cycle.before === true ? 'CYCLE' : 'NO CYCLE');
|
|
576
|
+
}
|
|
577
|
+
if (clauses.length > 0) {
|
|
578
|
+
b.emit(`sequenceAlter:${c.path}`, [], PHASE.SEQUENCE_ALTER, {
|
|
579
|
+
sql: `ALTER SEQUENCE ${qualify(sSchema, sName)} ${clauses.join(' ')};`,
|
|
580
|
+
objectType: 'sequence',
|
|
581
|
+
path: c.path,
|
|
582
|
+
changeKind: 'modified',
|
|
583
|
+
risk: field(c.fields, 'dataType') === undefined ? 'low' : 'moderate',
|
|
584
|
+
riskNote: field(c.fields, 'dataType') === undefined
|
|
585
|
+
? 'metadata-only; does not move the current counter position.'
|
|
586
|
+
: 'changing the underlying data type fails if the sequence\'s current value is out of range for the new type.',
|
|
587
|
+
reversible: true,
|
|
588
|
+
down: `ALTER SEQUENCE ${qualify(sSchema, sName)} ${down.join(' ')};`,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
const owner = field(c.fields, 'owner');
|
|
592
|
+
if (owner !== undefined) {
|
|
593
|
+
b.emit(`sequenceOwner:${c.path}`, [], PHASE.SEQUENCE_ALTER, {
|
|
594
|
+
sql: `ALTER SEQUENCE ${qualify(sSchema, sName)} OWNER TO ${quoteIdent(String(owner.after))};`,
|
|
595
|
+
objectType: 'sequence',
|
|
596
|
+
path: c.path,
|
|
597
|
+
changeKind: 'modified',
|
|
598
|
+
risk: 'low',
|
|
599
|
+
riskNote: 'metadata-only ownership change.',
|
|
600
|
+
reversible: true,
|
|
601
|
+
down: `ALTER SEQUENCE ${qualify(sSchema, sName)} OWNER TO ${quoteIdent(String(owner.before))};`,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
const comment = field(c.fields, 'comment');
|
|
605
|
+
if (comment !== undefined) {
|
|
606
|
+
b.emit(`sequenceComment:${c.path}`, [], PHASE.SEQUENCE_ALTER, commentStatement('sequence', c.path, `SEQUENCE ${qualify(sSchema, sName)}`, comment));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function handleFunction(b, c) {
|
|
610
|
+
const { schema } = splitFirstDot(c.path);
|
|
611
|
+
const key = splitFirstDot(c.path).rest;
|
|
612
|
+
const { name, args } = splitFunctionKey(key);
|
|
613
|
+
if (c.kind === 'added') {
|
|
614
|
+
const f = c.object;
|
|
615
|
+
if (f.kind !== 'f') {
|
|
616
|
+
b.markUnsupported('function', c.path, 'added', `pg_proc.prokind = '${f.kind}' (procedure/aggregate/window) — only a plain function's body/return type/language is captured; recreating one of these needs attributes (strictness, volatility, argument defaults, transition/final functions, …) this server does not capture. Not attempted.`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (f.body === null) {
|
|
620
|
+
b.markUnsupported('function', c.path, 'added', 'the function body was not captured (NULL prosrc — likely a C/internal-language function); nothing to recreate from.');
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
b.emit(`function:${c.path}`, [`schema:${schema}`], PHASE.FUNCTION_CREATE, {
|
|
624
|
+
sql: `CREATE FUNCTION ${qualify(schema, f.name)}(${f.arguments}) RETURNS ${f.returns} LANGUAGE ${quoteIdent(f.language)} AS $migrate$${f.body}$migrate$;`,
|
|
625
|
+
objectType: 'function',
|
|
626
|
+
path: c.path,
|
|
627
|
+
changeKind: 'added',
|
|
628
|
+
risk: 'moderate',
|
|
629
|
+
riskNote: 'best-effort: recreates the body, return type and language only — volatility, strictness, security definer, cost/rows estimates and argument defaults are not captured by a snapshot and are not reproduced here. Review the created function\'s attributes if any of those matter.',
|
|
630
|
+
reversible: true,
|
|
631
|
+
down: `DROP FUNCTION ${qualify(schema, f.name)}(${f.arguments});`,
|
|
632
|
+
});
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (c.kind === 'removed') {
|
|
636
|
+
const functionDropId = `functionDrop:${c.path}`;
|
|
637
|
+
b.emit(functionDropId, [], PHASE.FUNCTION_DROP, {
|
|
638
|
+
sql: `DROP FUNCTION ${qualify(schema, name)}(${args});`,
|
|
639
|
+
objectType: 'function',
|
|
640
|
+
path: c.path,
|
|
641
|
+
changeKind: 'removed',
|
|
642
|
+
risk: 'high',
|
|
643
|
+
riskNote: 'fails if anything still depends on it (a trigger, a view, another function) — those must already be handled earlier in this plan. Anything that called it stops working once this runs.',
|
|
644
|
+
reversible: false,
|
|
645
|
+
downNote: 'recreating a dropped function from this diff alone would need its full definition (attributes this server does not capture — see the module\'s header comment); not attempted.',
|
|
646
|
+
});
|
|
647
|
+
b.linkToSchemaDrop(schema, functionDropId);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const changedFields = c.fields.map((f) => f.field);
|
|
651
|
+
const onlyComment = changedFields.length === 1 && changedFields[0] === 'comment';
|
|
652
|
+
const comment = field(c.fields, 'comment');
|
|
653
|
+
if (onlyComment && comment !== undefined) {
|
|
654
|
+
b.emit(`functionComment:${c.path}`, [], PHASE.FUNCTION_ALTER, commentStatement('function', c.path, `FUNCTION ${qualify(schema, name)}(${args})`, comment));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
b.markUnsupported('function', c.path, 'modified', 'the diff carries only the fields that changed, not the function\'s full definition — CREATE OR REPLACE FUNCTION needs the complete signature restated even for the fields that did not change, and that is not recoverable from a diff alone. Not attempted (a comment-only change is the sole exception).');
|
|
658
|
+
}
|
|
659
|
+
// --- Constraints (primary_key / unique_constraint / check_constraint) ------
|
|
660
|
+
function handleGenericConstraint(b, c, dropPhase, addPhase) {
|
|
661
|
+
const { schema, table, leaf: name } = splitTablePath(c.path);
|
|
662
|
+
const tableQualified = qualify(schema, table);
|
|
663
|
+
const tableCreateId = `table:${schema}.${table}`;
|
|
664
|
+
const tableDropId = `tableDrop:${schema}.${table}`;
|
|
665
|
+
// A PRIMARY KEY/UNIQUE constraint creates a backing index of the same name,
|
|
666
|
+
// which lives in the schema-wide pg_class namespace; a CHECK creates nothing.
|
|
667
|
+
const claimsIndexName = c.objectType === 'primary_key' || c.objectType === 'unique_constraint';
|
|
668
|
+
if (c.kind === 'added') {
|
|
669
|
+
const obj = c.object;
|
|
670
|
+
const columns = extractColumnList(obj.definition);
|
|
671
|
+
if (claimsIndexName)
|
|
672
|
+
b.claimName(schema, name, { id: `constraint:${c.path}`, objectType: c.objectType, path: c.path, changeKind: 'added' });
|
|
673
|
+
const deps = [b.has(tableCreateId) ? tableCreateId : undefined, ...columns.map((col) => `column:${schema}.${table}.${col}`)].filter((x) => x !== undefined);
|
|
674
|
+
b.emit(`constraint:${c.path}`, deps, addPhase, {
|
|
675
|
+
sql: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(name)} ${obj.definition};`,
|
|
676
|
+
objectType: c.objectType,
|
|
677
|
+
path: c.path,
|
|
678
|
+
changeKind: 'added',
|
|
679
|
+
risk: 'moderate',
|
|
680
|
+
riskNote: c.objectType === 'check_constraint'
|
|
681
|
+
? 'validates every existing row against the check expression (a full table scan) and takes a lock for the duration. For a large table, add it as NOT VALID and run VALIDATE CONSTRAINT separately instead — this statement does not do that split.'
|
|
682
|
+
: 'builds a unique index to enforce the constraint, holding an ACCESS EXCLUSIVE lock on the table for the whole build — reads are blocked as well as writes (unlike a plain CREATE INDEX). For a large table, CREATE UNIQUE INDEX CONCURRENTLY followed by ADD CONSTRAINT ... UNIQUE USING INDEX is the safer two-step alternative — this statement does not do that split.',
|
|
683
|
+
reversible: true,
|
|
684
|
+
down: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(name)};`,
|
|
685
|
+
});
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (c.kind === 'removed') {
|
|
689
|
+
const obj = c.object;
|
|
690
|
+
if (claimsIndexName)
|
|
691
|
+
b.releaseName(schema, name, `constraintDrop:${c.path}`);
|
|
692
|
+
b.emit(`constraintDrop:${c.path}`, [], dropPhase, {
|
|
693
|
+
sql: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(name)};`,
|
|
694
|
+
objectType: c.objectType,
|
|
695
|
+
path: c.path,
|
|
696
|
+
changeKind: 'removed',
|
|
697
|
+
risk: c.objectType === 'primary_key' ? 'high' : 'moderate',
|
|
698
|
+
riskNote: c.objectType === 'primary_key'
|
|
699
|
+
? 'drops the primary key and its backing index; any foreign key elsewhere that references it must already be dropped earlier in this plan, or this fails.'
|
|
700
|
+
: 'removes the enforcement (and, for a unique constraint, its backing index); existing rows are untouched.',
|
|
701
|
+
reversible: true,
|
|
702
|
+
down: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(name)} ${obj.definition};`,
|
|
703
|
+
});
|
|
704
|
+
if (b.has(tableDropId))
|
|
705
|
+
b.addExtraDep(tableDropId, `constraintDrop:${c.path}`);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
const def = field(c.fields, 'definition');
|
|
709
|
+
if (def === undefined)
|
|
710
|
+
return;
|
|
711
|
+
const dropId = `constraintDropM:${c.path}`;
|
|
712
|
+
const addId = `constraintAddM:${c.path}`;
|
|
713
|
+
b.emit(dropId, [], dropPhase, {
|
|
714
|
+
sql: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(name)};`,
|
|
715
|
+
objectType: c.objectType,
|
|
716
|
+
path: c.path,
|
|
717
|
+
changeKind: 'modified',
|
|
718
|
+
risk: 'moderate',
|
|
719
|
+
riskNote: 'a constraint cannot be altered in place — this drops the old definition immediately before the next statement adds the new one.',
|
|
720
|
+
reversible: true,
|
|
721
|
+
down: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(name)} ${String(def.before)};`,
|
|
722
|
+
});
|
|
723
|
+
b.emit(addId, [dropId], addPhase, {
|
|
724
|
+
sql: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(name)} ${String(def.after)};`,
|
|
725
|
+
objectType: c.objectType,
|
|
726
|
+
path: c.path,
|
|
727
|
+
changeKind: 'modified',
|
|
728
|
+
risk: 'moderate',
|
|
729
|
+
riskNote: 'rebuilds the constraint (and, for a unique/primary-key constraint, its backing index) — same lock/scan cost as adding it fresh.',
|
|
730
|
+
reversible: true,
|
|
731
|
+
down: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(name)};`,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
/** Best-effort extraction of a simple `(col1, col2)` column list from a PRIMARY KEY/UNIQUE constraint definition. Returns `[]` for anything else rather than guessing — notably a CHECK, whose parenthesised *expression* would otherwise yield pseudo-columns like `qty > 0` that match no real column id. */
|
|
735
|
+
export function extractColumnList(definition) {
|
|
736
|
+
// `UNIQUE NULLS NOT DISTINCT (...)` (PG15+) puts words between the keyword
|
|
737
|
+
// and the list, so allow them — but nothing that opens a paren of its own.
|
|
738
|
+
if (!/^\s*(PRIMARY\s+KEY|UNIQUE)[^(]*\(/i.test(definition))
|
|
739
|
+
return [];
|
|
740
|
+
const m = /\(([^()]*)\)/.exec(definition);
|
|
741
|
+
if (m?.[1] === undefined)
|
|
742
|
+
return [];
|
|
743
|
+
return m[1]
|
|
744
|
+
.split(',')
|
|
745
|
+
.map((s) => s.trim().replace(/^"(.*)"$/, '$1'))
|
|
746
|
+
.filter((s) => s.length > 0 && !s.includes('('));
|
|
747
|
+
}
|
|
748
|
+
// --- Foreign keys -----------------------------------------------------------
|
|
749
|
+
function handleForeignKey(b, c) {
|
|
750
|
+
const { schema, table, leaf: name } = splitTablePath(c.path);
|
|
751
|
+
const tableQualified = qualify(schema, table);
|
|
752
|
+
const tableCreateId = `table:${schema}.${table}`;
|
|
753
|
+
const tableDropId = `tableDrop:${schema}.${table}`;
|
|
754
|
+
if (c.kind === 'added') {
|
|
755
|
+
const fk = c.object;
|
|
756
|
+
const refTableCreateId = `table:${fk.refSchema}.${fk.refTable}`;
|
|
757
|
+
const deps = [b.has(tableCreateId) ? tableCreateId : undefined, b.has(refTableCreateId) ? refTableCreateId : undefined].filter((x) => x !== undefined);
|
|
758
|
+
b.emit(`fk:${c.path}`, deps, PHASE.FK_ADD, {
|
|
759
|
+
sql: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(name)} ${fk.definition};`,
|
|
760
|
+
objectType: 'foreign_key',
|
|
761
|
+
path: c.path,
|
|
762
|
+
changeKind: 'added',
|
|
763
|
+
risk: 'moderate',
|
|
764
|
+
riskNote: 'validates every existing row in this table against the referenced table (a full scan of this table, plus a lock on both while it runs). For a large table, add it as NOT VALID and run VALIDATE CONSTRAINT separately — this statement does not do that split.',
|
|
765
|
+
reversible: true,
|
|
766
|
+
down: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(name)};`,
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (c.kind === 'removed') {
|
|
771
|
+
const fk = c.object;
|
|
772
|
+
const dropId = `fkDrop:${c.path}`;
|
|
773
|
+
b.emit(dropId, [], PHASE.FK_DROP, {
|
|
774
|
+
sql: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(name)};`,
|
|
775
|
+
objectType: 'foreign_key',
|
|
776
|
+
path: c.path,
|
|
777
|
+
changeKind: 'removed',
|
|
778
|
+
risk: 'low',
|
|
779
|
+
riskNote: 'removes referential enforcement only — no data on either table is touched.',
|
|
780
|
+
reversible: true,
|
|
781
|
+
down: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(name)} ${fk.definition};`,
|
|
782
|
+
});
|
|
783
|
+
const refDropId = `tableDrop:${fk.refSchema}.${fk.refTable}`;
|
|
784
|
+
if (b.has(refDropId))
|
|
785
|
+
b.addExtraDep(refDropId, dropId);
|
|
786
|
+
if (b.has(tableDropId))
|
|
787
|
+
b.addExtraDep(tableDropId, dropId);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const def = field(c.fields, 'definition');
|
|
791
|
+
if (def === undefined)
|
|
792
|
+
return;
|
|
793
|
+
// refSchema/refTable changes always accompany a `definition` change (the
|
|
794
|
+
// rendered clause embeds the reference), so `definition` alone is enough
|
|
795
|
+
// to rebuild both the drop's down-statement and the add's new clause.
|
|
796
|
+
const dropId = `fkDropM:${c.path}`;
|
|
797
|
+
const addId = `fkAddM:${c.path}`;
|
|
798
|
+
b.emit(dropId, [], PHASE.FK_DROP, {
|
|
799
|
+
sql: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(name)};`,
|
|
800
|
+
objectType: 'foreign_key',
|
|
801
|
+
path: c.path,
|
|
802
|
+
changeKind: 'modified',
|
|
803
|
+
risk: 'moderate',
|
|
804
|
+
riskNote: 'a foreign key cannot be altered in place — this drops the old definition immediately before the next statement adds the new one.',
|
|
805
|
+
reversible: true,
|
|
806
|
+
down: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(name)} ${String(def.before)};`,
|
|
807
|
+
});
|
|
808
|
+
b.emit(addId, [dropId], PHASE.FK_ADD, {
|
|
809
|
+
sql: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(name)} ${String(def.after)};`,
|
|
810
|
+
objectType: 'foreign_key',
|
|
811
|
+
path: c.path,
|
|
812
|
+
changeKind: 'modified',
|
|
813
|
+
risk: 'moderate',
|
|
814
|
+
riskNote: 'revalidates every existing row against the (possibly new) referenced table — same cost as adding it fresh.',
|
|
815
|
+
reversible: true,
|
|
816
|
+
down: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(name)};`,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
// --- Indexes -----------------------------------------------------------------
|
|
820
|
+
function withConcurrently(createStmt, concurrently) {
|
|
821
|
+
if (!concurrently)
|
|
822
|
+
return createStmt;
|
|
823
|
+
return createStmt.replace(/^CREATE\s+(UNIQUE\s+)?INDEX\s+/i, (m) => `${m.trimEnd()} CONCURRENTLY `);
|
|
824
|
+
}
|
|
825
|
+
function handleIndex(b, c, opts) {
|
|
826
|
+
const { schema, table, leaf: name } = splitTablePath(c.path);
|
|
827
|
+
const indexQualified = qualify(schema, name);
|
|
828
|
+
const tableCreateId = `table:${schema}.${table}`;
|
|
829
|
+
const tableDropId = `tableDrop:${schema}.${table}`;
|
|
830
|
+
// CONCURRENTLY is only meaningful (and only necessary) once the table
|
|
831
|
+
// already exists with other traffic on it — a table this same plan just
|
|
832
|
+
// created has no concurrent readers/writers to protect yet, and
|
|
833
|
+
// CONCURRENTLY cannot run in the same transaction as the CREATE TABLE that
|
|
834
|
+
// precedes it anyway.
|
|
835
|
+
const tableIsNew = b.has(tableCreateId);
|
|
836
|
+
const concurrently = opts.concurrentIndexes && !tableIsNew;
|
|
837
|
+
if (c.kind === 'added') {
|
|
838
|
+
const idx = c.object;
|
|
839
|
+
const cols = idx.columns.filter((col) => !col.includes('('));
|
|
840
|
+
const deps = [tableIsNew ? tableCreateId : undefined, ...cols.map((col) => `column:${schema}.${table}.${col}`)].filter((x) => x !== undefined);
|
|
841
|
+
b.claimName(schema, name, { id: `index:${c.path}`, objectType: 'index', path: c.path, changeKind: 'added' });
|
|
842
|
+
b.emit(`index:${c.path}`, deps, PHASE.INDEX_CREATE, {
|
|
843
|
+
sql: `${withConcurrently(idx.definition, concurrently)};`,
|
|
844
|
+
objectType: 'index',
|
|
845
|
+
path: c.path,
|
|
846
|
+
changeKind: 'added',
|
|
847
|
+
risk: concurrently ? 'low' : 'moderate',
|
|
848
|
+
riskNote: concurrently
|
|
849
|
+
? 'CONCURRENTLY avoids blocking reads or writes, at the cost of a slower build and two full table scans; if it fails partway it leaves an INVALID index behind that must be dropped and retried — check for that before assuming success.'
|
|
850
|
+
: 'takes a SHARE lock for the duration, which blocks writes to the table (reads are unaffected) until the index finishes building.',
|
|
851
|
+
reversible: true,
|
|
852
|
+
down: `DROP INDEX ${concurrently ? 'CONCURRENTLY ' : ''}${indexQualified};`,
|
|
853
|
+
});
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (c.kind === 'removed') {
|
|
857
|
+
const idx = c.object;
|
|
858
|
+
const dropId = `indexDrop:${c.path}`;
|
|
859
|
+
b.releaseName(schema, name, dropId);
|
|
860
|
+
b.emit(dropId, [], PHASE.INDEX_DROP, {
|
|
861
|
+
sql: `DROP INDEX ${concurrently ? 'CONCURRENTLY ' : ''}${indexQualified};`,
|
|
862
|
+
objectType: 'index',
|
|
863
|
+
path: c.path,
|
|
864
|
+
changeKind: 'removed',
|
|
865
|
+
risk: 'low',
|
|
866
|
+
riskNote: 'removes an access path only — no data loss. Query plans that relied on it may get slower.',
|
|
867
|
+
reversible: true,
|
|
868
|
+
down: `${idx.definition};`,
|
|
869
|
+
});
|
|
870
|
+
if (b.has(tableDropId))
|
|
871
|
+
b.addExtraDep(tableDropId, dropId);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const def = field(c.fields, 'definition');
|
|
875
|
+
if (def === undefined)
|
|
876
|
+
return;
|
|
877
|
+
const dropId = `indexDropM:${c.path}`;
|
|
878
|
+
const addId = `indexAddM:${c.path}`;
|
|
879
|
+
b.emit(dropId, [], PHASE.INDEX_DROP, {
|
|
880
|
+
sql: `DROP INDEX ${concurrently ? 'CONCURRENTLY ' : ''}${indexQualified};`,
|
|
881
|
+
objectType: 'index',
|
|
882
|
+
path: c.path,
|
|
883
|
+
changeKind: 'modified',
|
|
884
|
+
risk: 'moderate',
|
|
885
|
+
riskNote: 'an index cannot be altered structurally — this drops it immediately before the next statement rebuilds it. The table has no index covering this access path in between; for a large or hot table, build the new index under a temporary name with CREATE INDEX CONCURRENTLY and ALTER INDEX ... RENAME TO instead of this drop-then-create pair.',
|
|
886
|
+
reversible: true,
|
|
887
|
+
down: `${String(def.before)};`,
|
|
888
|
+
});
|
|
889
|
+
b.emit(addId, [dropId], PHASE.INDEX_CREATE, {
|
|
890
|
+
sql: `${withConcurrently(String(def.after), concurrently)};`,
|
|
891
|
+
objectType: 'index',
|
|
892
|
+
path: c.path,
|
|
893
|
+
changeKind: 'modified',
|
|
894
|
+
risk: concurrently ? 'low' : 'moderate',
|
|
895
|
+
riskNote: concurrently
|
|
896
|
+
? 'CONCURRENTLY avoids blocking reads or writes while rebuilding; check for an INVALID index afterward if it failed partway.'
|
|
897
|
+
: 'takes a SHARE lock for the duration, blocking writes until the rebuild finishes.',
|
|
898
|
+
reversible: true,
|
|
899
|
+
down: `DROP INDEX ${concurrently ? 'CONCURRENTLY ' : ''}${indexQualified};`,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
// --- Triggers ------------------------------------------------------------
|
|
903
|
+
function handleTrigger(b, c) {
|
|
904
|
+
const { schema, table, leaf: name } = splitTablePath(c.path);
|
|
905
|
+
const tableQualified = qualify(schema, table);
|
|
906
|
+
const tableCreateId = `table:${schema}.${table}`;
|
|
907
|
+
const tableDropId = `tableDrop:${schema}.${table}`;
|
|
908
|
+
if (c.kind === 'added') {
|
|
909
|
+
const t = c.object;
|
|
910
|
+
b.emit(`trigger:${c.path}`, [b.has(tableCreateId) ? tableCreateId : undefined].filter((x) => x !== undefined), PHASE.TRIGGER_CREATE, {
|
|
911
|
+
sql: `${t.definition};`,
|
|
912
|
+
objectType: 'trigger',
|
|
913
|
+
path: c.path,
|
|
914
|
+
changeKind: 'added',
|
|
915
|
+
risk: 'moderate',
|
|
916
|
+
riskNote: 'changes behaviour for every future write that matches it — review the trigger function\'s body; existing rows are untouched.',
|
|
917
|
+
reversible: true,
|
|
918
|
+
down: `DROP TRIGGER ${quoteIdent(name)} ON ${tableQualified};`,
|
|
919
|
+
});
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (c.kind === 'removed') {
|
|
923
|
+
const t = c.object;
|
|
924
|
+
const dropId = `triggerDrop:${c.path}`;
|
|
925
|
+
b.emit(dropId, [], PHASE.TRIGGER_DROP, {
|
|
926
|
+
sql: `DROP TRIGGER ${quoteIdent(name)} ON ${tableQualified};`,
|
|
927
|
+
objectType: 'trigger',
|
|
928
|
+
path: c.path,
|
|
929
|
+
changeKind: 'removed',
|
|
930
|
+
risk: 'moderate',
|
|
931
|
+
riskNote: 'silently changes application behaviour — anything that relied on this trigger firing (e.g. an updated_at maintenance trigger) stops working. Existing data is untouched.',
|
|
932
|
+
reversible: true,
|
|
933
|
+
down: `${t.definition};`,
|
|
934
|
+
});
|
|
935
|
+
if (b.has(tableDropId))
|
|
936
|
+
b.addExtraDep(tableDropId, dropId);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const def = field(c.fields, 'definition');
|
|
940
|
+
if (def === undefined)
|
|
941
|
+
return;
|
|
942
|
+
const dropId = `triggerDropM:${c.path}`;
|
|
943
|
+
const addId = `triggerAddM:${c.path}`;
|
|
944
|
+
b.emit(dropId, [], PHASE.TRIGGER_DROP, {
|
|
945
|
+
sql: `DROP TRIGGER ${quoteIdent(name)} ON ${tableQualified};`,
|
|
946
|
+
objectType: 'trigger',
|
|
947
|
+
path: c.path,
|
|
948
|
+
changeKind: 'modified',
|
|
949
|
+
risk: 'moderate',
|
|
950
|
+
riskNote: 'a trigger\'s timing/events/function cannot be altered in place — this drops it immediately before the next statement recreates it. Writes between the two statements do not fire this trigger at all.',
|
|
951
|
+
reversible: true,
|
|
952
|
+
down: `${String(def.before)};`,
|
|
953
|
+
});
|
|
954
|
+
b.emit(addId, [dropId], PHASE.TRIGGER_CREATE, {
|
|
955
|
+
sql: `${String(def.after)};`,
|
|
956
|
+
objectType: 'trigger',
|
|
957
|
+
path: c.path,
|
|
958
|
+
changeKind: 'modified',
|
|
959
|
+
risk: 'moderate',
|
|
960
|
+
riskNote: 'review the new trigger function\'s body before applying.',
|
|
961
|
+
reversible: true,
|
|
962
|
+
down: `DROP TRIGGER ${quoteIdent(name)} ON ${tableQualified};`,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
// --- Policies (added/removed only — see the module doc for `modified`) ---
|
|
966
|
+
function policyClause(p, schema, table) {
|
|
967
|
+
const parts = [`CREATE POLICY ${quoteIdent(p.name)} ON ${qualify(schema, table)}`];
|
|
968
|
+
parts.push(`AS ${p.permissive ? 'PERMISSIVE' : 'RESTRICTIVE'}`);
|
|
969
|
+
parts.push(`FOR ${p.command}`);
|
|
970
|
+
parts.push(`TO ${p.roles.length === 0 ? 'PUBLIC' : p.roles.map((r) => quoteIdent(r)).join(', ')}`);
|
|
971
|
+
if (p.using !== null)
|
|
972
|
+
parts.push(`USING (${p.using})`);
|
|
973
|
+
if (p.withCheck !== null)
|
|
974
|
+
parts.push(`WITH CHECK (${p.withCheck})`);
|
|
975
|
+
return `${parts.join(' ')};`;
|
|
976
|
+
}
|
|
977
|
+
function handlePolicy(b, c) {
|
|
978
|
+
const { schema, table, leaf: name } = splitTablePath(c.path);
|
|
979
|
+
const tableCreateId = `table:${schema}.${table}`;
|
|
980
|
+
const tableDropId = `tableDrop:${schema}.${table}`;
|
|
981
|
+
if (c.kind === 'added') {
|
|
982
|
+
const p = c.object;
|
|
983
|
+
b.emit(`policy:${c.path}`, [b.has(tableCreateId) ? tableCreateId : undefined].filter((x) => x !== undefined), PHASE.POLICY_CREATE, {
|
|
984
|
+
sql: policyClause(p, schema, table),
|
|
985
|
+
objectType: 'policy',
|
|
986
|
+
path: c.path,
|
|
987
|
+
changeKind: 'added',
|
|
988
|
+
risk: 'moderate',
|
|
989
|
+
riskNote: 'row-level security policies gate which rows a role can see or write — a wrong USING/WITH CHECK expression can silently hide or expose rows. Test as the target roles before relying on it.',
|
|
990
|
+
reversible: true,
|
|
991
|
+
down: `DROP POLICY ${quoteIdent(name)} ON ${qualify(schema, table)};`,
|
|
992
|
+
});
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
if (c.kind === 'removed') {
|
|
996
|
+
const p = c.object;
|
|
997
|
+
const dropId = `policyDrop:${c.path}`;
|
|
998
|
+
b.emit(dropId, [], PHASE.POLICY_DROP, {
|
|
999
|
+
sql: `DROP POLICY ${quoteIdent(name)} ON ${qualify(schema, table)};`,
|
|
1000
|
+
objectType: 'policy',
|
|
1001
|
+
path: c.path,
|
|
1002
|
+
changeKind: 'removed',
|
|
1003
|
+
risk: 'moderate',
|
|
1004
|
+
riskNote: 'if row-level security is (or stays) enabled and forced on this table, removing this policy can immediately change which rows matching roles can see or write.',
|
|
1005
|
+
reversible: true,
|
|
1006
|
+
down: policyClause(p, schema, table),
|
|
1007
|
+
});
|
|
1008
|
+
if (b.has(tableDropId))
|
|
1009
|
+
b.addExtraDep(tableDropId, dropId);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
b.markUnsupported('policy', c.path, 'modified', 'the diff carries only the fields that changed (e.g. just USING), not the policy\'s full definition (command, roles, permissive, WITH CHECK) — CREATE POLICY needs the whole statement restated even for fields that did not change, and that is not recoverable from a diff alone. Not attempted; drop and recreate the policy manually.');
|
|
1013
|
+
}
|
|
1014
|
+
// --- Views / materialized views -------------------------------------------
|
|
1015
|
+
/** `pg_get_viewdef` — what `capture.ts` stores verbatim — already ends in `;`. Every site here appends its own terminator, so strip the captured one rather than emit `…;;`, which is two statements to any applier that splits on `;`. */
|
|
1016
|
+
function viewBody(definition) {
|
|
1017
|
+
return definition.replace(/;\s*$/, '');
|
|
1018
|
+
}
|
|
1019
|
+
function handleView(b, c, isMaterialized) {
|
|
1020
|
+
const kw = isMaterialized ? 'MATERIALIZED VIEW' : 'VIEW';
|
|
1021
|
+
const { schema, rest: name } = splitFirstDot(c.path);
|
|
1022
|
+
const viewQualified = qualify(schema, name);
|
|
1023
|
+
const createId = `${isMaterialized ? 'matview' : 'view'}:${c.path}`;
|
|
1024
|
+
if (c.kind === 'added') {
|
|
1025
|
+
const v = c.object;
|
|
1026
|
+
if (v.definition === null) {
|
|
1027
|
+
b.markUnsupported(isMaterialized ? 'materialized_view' : 'view', c.path, 'added', 'no SELECT definition was captured for this view (an unusual state) — nothing to recreate from.');
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
b.claimName(schema, name, { id: createId, objectType: isMaterialized ? 'materialized_view' : 'view', path: c.path, changeKind: 'added' });
|
|
1031
|
+
b.emit(createId, [`schema:${schema}`], PHASE.VIEW_CREATE, {
|
|
1032
|
+
sql: `CREATE ${kw} ${viewQualified} AS ${viewBody(v.definition)};`,
|
|
1033
|
+
objectType: isMaterialized ? 'materialized_view' : 'view',
|
|
1034
|
+
path: c.path,
|
|
1035
|
+
changeKind: 'added',
|
|
1036
|
+
risk: isMaterialized ? 'moderate' : 'safe',
|
|
1037
|
+
riskNote: isMaterialized
|
|
1038
|
+
? 'creates the view but does not populate it — it starts unscannable until the first REFRESH MATERIALIZED VIEW.'
|
|
1039
|
+
: 'a view stores no data of its own; this only fails if the underlying query is invalid or references something not yet created.',
|
|
1040
|
+
reversible: true,
|
|
1041
|
+
down: `DROP ${kw} ${viewQualified};`,
|
|
1042
|
+
});
|
|
1043
|
+
// Only a materialized view can carry indexes; only INSTEAD OF triggers
|
|
1044
|
+
// land on a plain view — both empty lists for the common case, but the
|
|
1045
|
+
// object already carries them (see `expandSchemaChanges`'s header doc on
|
|
1046
|
+
// why `added` gets the full nested object rather than separate entries).
|
|
1047
|
+
for (const idx of v.indexes) {
|
|
1048
|
+
b.claimName(schema, idx.name, { id: `index:${c.path}.${idx.name}`, objectType: 'index', path: `${c.path}.${idx.name}`, changeKind: 'added' });
|
|
1049
|
+
b.emit(`index:${c.path}.${idx.name}`, [createId], PHASE.INDEX_CREATE, {
|
|
1050
|
+
sql: `${idx.definition};`,
|
|
1051
|
+
objectType: 'index',
|
|
1052
|
+
path: `${c.path}.${idx.name}`,
|
|
1053
|
+
changeKind: 'added',
|
|
1054
|
+
risk: 'moderate',
|
|
1055
|
+
riskNote: 'built on a materialized view that has no rows yet (empty until REFRESH MATERIALIZED VIEW) — cheap now, but the view must be refreshed before this index means anything.',
|
|
1056
|
+
reversible: true,
|
|
1057
|
+
down: `DROP INDEX ${qualify(schema, idx.name)};`,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
for (const trg of v.triggers) {
|
|
1061
|
+
b.emit(`trigger:${c.path}.${trg.name}`, [createId], PHASE.TRIGGER_CREATE, {
|
|
1062
|
+
sql: `${trg.definition};`,
|
|
1063
|
+
objectType: 'trigger',
|
|
1064
|
+
path: `${c.path}.${trg.name}`,
|
|
1065
|
+
changeKind: 'added',
|
|
1066
|
+
risk: 'moderate',
|
|
1067
|
+
riskNote: 'an INSTEAD OF trigger changes what writes against this view actually do — review its function\'s body.',
|
|
1068
|
+
reversible: true,
|
|
1069
|
+
down: `DROP TRIGGER ${quoteIdent(trg.name)} ON ${viewQualified};`,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (c.kind === 'removed') {
|
|
1075
|
+
const v = c.object;
|
|
1076
|
+
const dropId = `${isMaterialized ? 'matviewDrop' : 'viewDrop'}:${c.path}`;
|
|
1077
|
+
b.releaseName(schema, name, dropId);
|
|
1078
|
+
for (const idx of v.indexes ?? [])
|
|
1079
|
+
b.releaseName(schema, idx.name, dropId);
|
|
1080
|
+
b.emit(dropId, [], PHASE.VIEW_DROP, {
|
|
1081
|
+
sql: `DROP ${kw} ${viewQualified};`,
|
|
1082
|
+
objectType: isMaterialized ? 'materialized_view' : 'view',
|
|
1083
|
+
path: c.path,
|
|
1084
|
+
changeKind: 'removed',
|
|
1085
|
+
risk: isMaterialized ? 'high' : 'low',
|
|
1086
|
+
riskNote: isMaterialized
|
|
1087
|
+
? 'discards the materialized (stored) result rows permanently, in addition to the view definition.'
|
|
1088
|
+
: 'a view holds no data of its own — this only removes the saved query.',
|
|
1089
|
+
reversible: v.definition !== null,
|
|
1090
|
+
...(v.definition === null
|
|
1091
|
+
? { downNote: 'no SELECT definition was captured to recreate it from.' }
|
|
1092
|
+
: {
|
|
1093
|
+
down: `CREATE ${kw} ${viewQualified} AS ${viewBody(v.definition)};`,
|
|
1094
|
+
...(isMaterialized ? { downNote: 'restores the definition only — the recreated materialized view is empty until REFRESH MATERIALIZED VIEW is run.' } : {}),
|
|
1095
|
+
}),
|
|
1096
|
+
});
|
|
1097
|
+
b.linkToSchemaDrop(schema, dropId);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const definition = field(c.fields, 'definition');
|
|
1101
|
+
if (definition !== undefined) {
|
|
1102
|
+
if (isMaterialized) {
|
|
1103
|
+
b.markUnsupported('materialized_view', c.path, 'modified', 'changing a materialized view\'s definition needs DROP + CREATE MATERIALIZED VIEW, which discards its stored rows and can fail against anything that depends on it — not attempted automatically. Drop and recreate it manually, then REFRESH it.');
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
b.emit(`viewAlter:${c.path}`, [], PHASE.VIEW_ALTER, {
|
|
1107
|
+
sql: `CREATE OR REPLACE VIEW ${viewQualified} AS ${viewBody(String(definition.after))};`,
|
|
1108
|
+
objectType: 'view',
|
|
1109
|
+
path: c.path,
|
|
1110
|
+
changeKind: 'modified',
|
|
1111
|
+
risk: 'moderate',
|
|
1112
|
+
riskNote: 'PostgreSQL only allows CREATE OR REPLACE VIEW to add new output columns at the end, or change an expression\'s text without changing its name/type — removing, reordering or retyping an existing output column fails outright and needs DROP + CREATE instead.',
|
|
1113
|
+
reversible: true,
|
|
1114
|
+
down: `CREATE OR REPLACE VIEW ${viewQualified} AS ${viewBody(String(definition.before))};`,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
const owner = field(c.fields, 'owner');
|
|
1119
|
+
if (owner !== undefined) {
|
|
1120
|
+
b.emit(`viewOwner:${c.path}`, [], PHASE.VIEW_ALTER, {
|
|
1121
|
+
sql: `ALTER ${kw} ${viewQualified} OWNER TO ${quoteIdent(String(owner.after))};`,
|
|
1122
|
+
objectType: isMaterialized ? 'materialized_view' : 'view',
|
|
1123
|
+
path: c.path,
|
|
1124
|
+
changeKind: 'modified',
|
|
1125
|
+
risk: 'low',
|
|
1126
|
+
riskNote: 'metadata-only ownership change.',
|
|
1127
|
+
reversible: true,
|
|
1128
|
+
down: `ALTER ${kw} ${viewQualified} OWNER TO ${quoteIdent(String(owner.before))};`,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
const comment = field(c.fields, 'comment');
|
|
1132
|
+
if (comment !== undefined) {
|
|
1133
|
+
b.emit(`viewComment:${c.path}`, [], PHASE.VIEW_ALTER, commentStatement(isMaterialized ? 'materialized_view' : 'view', c.path, `${kw} ${viewQualified}`, comment));
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
// --- Columns ------------------------------------------------------------
|
|
1137
|
+
function handleColumn(b, c) {
|
|
1138
|
+
const { schema, table, leaf: name } = splitTablePath(c.path);
|
|
1139
|
+
const tableQualified = qualify(schema, table);
|
|
1140
|
+
const tableCreateId = `table:${schema}.${table}`;
|
|
1141
|
+
const tableDropId = `tableDrop:${schema}.${table}`;
|
|
1142
|
+
if (c.kind === 'added') {
|
|
1143
|
+
const col = c.object;
|
|
1144
|
+
const volatile = col.default !== null && VOLATILE_DEFAULT.test(col.default);
|
|
1145
|
+
const rewrite = volatile || col.generated === 'stored';
|
|
1146
|
+
b.emit(`column:${c.path}`, [b.has(tableCreateId) ? tableCreateId : undefined].filter((x) => x !== undefined), PHASE.COLUMN_ADD, {
|
|
1147
|
+
sql: `ALTER TABLE ${tableQualified} ADD COLUMN ${columnDefClause(col)};`,
|
|
1148
|
+
objectType: 'column',
|
|
1149
|
+
path: c.path,
|
|
1150
|
+
changeKind: 'added',
|
|
1151
|
+
risk: !col.nullable && col.default === null ? 'high' : rewrite ? 'moderate' : 'low',
|
|
1152
|
+
riskNote: !col.nullable && col.default === null
|
|
1153
|
+
? 'NOT NULL with no default fails outright on any table that already has rows — this only works on an empty table, or needs a default added first.'
|
|
1154
|
+
: rewrite
|
|
1155
|
+
? `${col.generated === 'stored' ? 'a STORED generated column computes its expression for every existing row' : 'a volatile default (e.g. now()/random()/nextval()) is evaluated per row rather than once'} — this rewrites the entire table, taking an ACCESS EXCLUSIVE lock for the duration, even on PostgreSQL 11+.`
|
|
1156
|
+
: col.default === null
|
|
1157
|
+
? 'no default and no generation expression, so every existing row simply reads as NULL — a catalog-only change with no table rewrite.'
|
|
1158
|
+
: 'a constant default is stored in the catalog and read back for existing rows rather than written to them (PostgreSQL 11+ fast path) — no table rewrite.',
|
|
1159
|
+
reversible: true,
|
|
1160
|
+
down: `ALTER TABLE ${tableQualified} DROP COLUMN ${quoteIdent(name)};`,
|
|
1161
|
+
});
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
if (c.kind === 'removed') {
|
|
1165
|
+
const dropId = `columnDrop:${c.path}`;
|
|
1166
|
+
b.emit(dropId, [], PHASE.COLUMN_DROP, {
|
|
1167
|
+
sql: `ALTER TABLE ${tableQualified} DROP COLUMN ${quoteIdent(name)};`,
|
|
1168
|
+
objectType: 'column',
|
|
1169
|
+
path: c.path,
|
|
1170
|
+
changeKind: 'removed',
|
|
1171
|
+
risk: 'high',
|
|
1172
|
+
riskNote: 'discards every existing value in this column, for every row, irreversibly. Also drops any default, NOT NULL, and (with a NOTICE) any index or constraint still referencing only this column that was not already dropped earlier in this plan.',
|
|
1173
|
+
reversible: false,
|
|
1174
|
+
downNote: 'cannot restore: the column\'s data is gone once this runs. Recreating an empty column of the same name and type is not a real undo and is deliberately not emitted.',
|
|
1175
|
+
});
|
|
1176
|
+
if (b.has(tableDropId))
|
|
1177
|
+
b.addExtraDep(tableDropId, dropId);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const type = field(c.fields, 'type');
|
|
1181
|
+
const collation = field(c.fields, 'collation');
|
|
1182
|
+
const nullable = field(c.fields, 'nullable');
|
|
1183
|
+
const dflt = field(c.fields, 'default');
|
|
1184
|
+
const identity = field(c.fields, 'identity');
|
|
1185
|
+
const generated = field(c.fields, 'generated');
|
|
1186
|
+
const comment = field(c.fields, 'comment');
|
|
1187
|
+
if (type !== undefined) {
|
|
1188
|
+
const collationClause = collation !== undefined && collation.after !== null ? ` COLLATE ${quoteIdent(String(collation.after))}` : '';
|
|
1189
|
+
const usingClause = `USING ${quoteIdent(name)}::${String(type.after)}`;
|
|
1190
|
+
b.emit(`columnType:${c.path}`, [], PHASE.COLUMN_ALTER, {
|
|
1191
|
+
sql: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} TYPE ${String(type.after)}${collationClause} ${usingClause};`,
|
|
1192
|
+
objectType: 'column',
|
|
1193
|
+
path: c.path,
|
|
1194
|
+
changeKind: 'modified',
|
|
1195
|
+
risk: 'high',
|
|
1196
|
+
riskNote: 'takes an ACCESS EXCLUSIVE lock and rewrites the entire table (the table is unavailable for reads and writes for the duration). The USING clause is a plain cast — review it against the real data first; some conversions fail outright or silently truncate/round.',
|
|
1197
|
+
reversible: true,
|
|
1198
|
+
down: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} TYPE ${String(type.before)} USING ${quoteIdent(name)}::${String(type.before)};`,
|
|
1199
|
+
downNote: 'the reverse cast has the exact same rewrite/lock cost, and the same risk of losing precision if the forward cast already did.',
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
else if (collation !== undefined) {
|
|
1203
|
+
b.markUnsupported('column', c.path, 'modified', 'collation changed with no accompanying type change — ALTER COLUMN ... TYPE <type> COLLATE <collation> needs the column\'s current type restated, which is not present in the diff when only collation differs. Not attempted.');
|
|
1204
|
+
}
|
|
1205
|
+
if (nullable !== undefined) {
|
|
1206
|
+
if (nullable.after === true) {
|
|
1207
|
+
b.emit(`columnNullable:${c.path}`, [], PHASE.COLUMN_ALTER, {
|
|
1208
|
+
sql: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} DROP NOT NULL;`,
|
|
1209
|
+
objectType: 'column',
|
|
1210
|
+
path: c.path,
|
|
1211
|
+
changeKind: 'modified',
|
|
1212
|
+
risk: 'safe',
|
|
1213
|
+
riskNote: 'metadata-only — no scan, no lock beyond the brief catalog update.',
|
|
1214
|
+
reversible: true,
|
|
1215
|
+
down: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} SET NOT NULL;`,
|
|
1216
|
+
downNote: 'the reverse direction re-adds NOT NULL and carries the same precondition as adding it fresh: it fails if any row already has NULL here.',
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
else {
|
|
1220
|
+
b.emit(`columnNullable:${c.path}`, [], PHASE.COLUMN_ALTER, {
|
|
1221
|
+
sql: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} SET NOT NULL;`,
|
|
1222
|
+
objectType: 'column',
|
|
1223
|
+
path: c.path,
|
|
1224
|
+
changeKind: 'modified',
|
|
1225
|
+
risk: 'moderate',
|
|
1226
|
+
riskNote: 'scans the whole table to verify no existing row is NULL here, and fails outright if one is — run the precondition query first.',
|
|
1227
|
+
precondition: `SELECT count(*) FROM ${tableQualified} WHERE ${quoteIdent(name)} IS NULL; -- expect 0`,
|
|
1228
|
+
reversible: true,
|
|
1229
|
+
down: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} DROP NOT NULL;`,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (dflt !== undefined) {
|
|
1234
|
+
const after = dflt.after === null ? 'DROP DEFAULT' : `SET DEFAULT ${String(dflt.after)}`;
|
|
1235
|
+
const before = dflt.before === null ? 'DROP DEFAULT' : `SET DEFAULT ${String(dflt.before)}`;
|
|
1236
|
+
b.emit(`columnDefault:${c.path}`, [], PHASE.COLUMN_ALTER, {
|
|
1237
|
+
sql: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} ${after};`,
|
|
1238
|
+
objectType: 'column',
|
|
1239
|
+
path: c.path,
|
|
1240
|
+
changeKind: 'modified',
|
|
1241
|
+
risk: 'safe',
|
|
1242
|
+
riskNote: 'metadata-only — applies only to rows written after this runs; existing rows keep whatever value they already have.',
|
|
1243
|
+
reversible: true,
|
|
1244
|
+
down: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} ${before};`,
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
if (identity !== undefined) {
|
|
1248
|
+
const toClause = (v) => {
|
|
1249
|
+
if (v === null)
|
|
1250
|
+
return `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} DROP IDENTITY IF EXISTS;`;
|
|
1251
|
+
const kind = v === 'always' ? 'ALWAYS' : 'BY DEFAULT';
|
|
1252
|
+
return identity.before === null
|
|
1253
|
+
? `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} ADD GENERATED ${kind} AS IDENTITY;`
|
|
1254
|
+
: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} SET GENERATED ${kind};`;
|
|
1255
|
+
};
|
|
1256
|
+
b.emit(`columnIdentity:${c.path}`, [], PHASE.COLUMN_ALTER, {
|
|
1257
|
+
sql: toClause(identity.after),
|
|
1258
|
+
objectType: 'column',
|
|
1259
|
+
path: c.path,
|
|
1260
|
+
changeKind: 'modified',
|
|
1261
|
+
risk: 'moderate',
|
|
1262
|
+
riskNote: identity.before === null
|
|
1263
|
+
? 'ADD GENERATED ... AS IDENTITY fails if the column already has an ordinary default — that default must be dropped first.'
|
|
1264
|
+
: 'metadata-only toggle between ALWAYS and BY DEFAULT (or dropping identity outright).',
|
|
1265
|
+
reversible: true,
|
|
1266
|
+
down: toClause(identity.before),
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
if (generated !== undefined) {
|
|
1270
|
+
if (generated.before === null && generated.after === 'stored') {
|
|
1271
|
+
b.markUnsupported('column', c.path, 'modified', 'PostgreSQL has no ALTER COLUMN ... ADD GENERATED for a value-generation expression (only for identity) — adding one to an existing column needs DROP + ADD COLUMN GENERATED ..., which discards the column\'s current data. Not attempted automatically.');
|
|
1272
|
+
}
|
|
1273
|
+
else if (generated.before === 'stored' && generated.after === null) {
|
|
1274
|
+
b.emit(`columnGenerated:${c.path}`, [], PHASE.COLUMN_ALTER, {
|
|
1275
|
+
sql: `ALTER TABLE ${tableQualified} ALTER COLUMN ${quoteIdent(name)} DROP EXPRESSION;`,
|
|
1276
|
+
objectType: 'column',
|
|
1277
|
+
path: c.path,
|
|
1278
|
+
changeKind: 'modified',
|
|
1279
|
+
risk: 'moderate',
|
|
1280
|
+
riskNote: 'the column keeps whatever value it last computed as an ordinary stored value; future writes must now supply it explicitly (or it becomes NULL/default) since it is no longer computed automatically.',
|
|
1281
|
+
reversible: false,
|
|
1282
|
+
downNote: 'cannot restore: the original generation expression is not recoverable without recreating the column, and even then would need to recompute over current (possibly since-changed) data.',
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (comment !== undefined) {
|
|
1287
|
+
b.emit(`columnComment:${c.path}`, [], PHASE.COLUMN_ALTER, commentStatement('column', c.path, `COLUMN ${tableQualified}.${quoteIdent(name)}`, comment));
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
// --- Tables ---------------------------------------------------------------
|
|
1291
|
+
function handleTable(b, c) {
|
|
1292
|
+
const { schema, rest: name } = splitFirstDot(c.path);
|
|
1293
|
+
const tableQualified = qualify(schema, name);
|
|
1294
|
+
if (c.kind === 'added') {
|
|
1295
|
+
const t = c.object;
|
|
1296
|
+
if (t.partitionBound !== null) {
|
|
1297
|
+
b.markUnsupported('table', c.path, 'added', 'this table is a declarative partition (PARTITION OF) — that CREATE TABLE form inherits its columns from the parent rather than declaring them, a materially different statement this generator does not build. Create it manually with CREATE TABLE ... PARTITION OF ... FOR VALUES ...');
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (t.kind !== 'table') {
|
|
1301
|
+
b.markUnsupported('table', c.path, 'added', `relation kind "${t.kind}" (partitioned/foreign table) needs its own CREATE TABLE form (PARTITION BY / FOREIGN TABLE ... SERVER ...) that this generator does not build. Not attempted.`);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const columnClauses = t.columns.map((col) => columnDefClause(col));
|
|
1305
|
+
if (t.primaryKey !== null)
|
|
1306
|
+
columnClauses.push(`CONSTRAINT ${quoteIdent(t.primaryKey.name)} ${t.primaryKey.definition}`);
|
|
1307
|
+
for (const uq of t.uniqueConstraints)
|
|
1308
|
+
columnClauses.push(`CONSTRAINT ${quoteIdent(uq.name)} ${uq.definition}`);
|
|
1309
|
+
for (const ck of t.checkConstraints)
|
|
1310
|
+
columnClauses.push(`CONSTRAINT ${quoteIdent(ck.name)} ${ck.definition}`);
|
|
1311
|
+
const inheritsClause = t.inherits.length === 0 ? '' : ` INHERITS (${t.inherits.map(quoteQualifiedName).join(', ')})`;
|
|
1312
|
+
const tableId = `table:${c.path}`;
|
|
1313
|
+
b.emit(tableId, [`schema:${schema}`], PHASE.TABLE_CREATE, {
|
|
1314
|
+
sql: `CREATE TABLE ${tableQualified} (\n ${columnClauses.join(',\n ')}\n)${inheritsClause};`,
|
|
1315
|
+
objectType: 'table',
|
|
1316
|
+
path: c.path,
|
|
1317
|
+
changeKind: 'added',
|
|
1318
|
+
risk: 'safe',
|
|
1319
|
+
riskNote: 'creates an empty table — no existing data anywhere is touched. Foreign keys owned by this table are scheduled as separate statements after every table in this plan exists, deliberately, so two new tables with mutual foreign keys never need each other mid-creation.',
|
|
1320
|
+
reversible: true,
|
|
1321
|
+
down: `DROP TABLE ${tableQualified};`,
|
|
1322
|
+
});
|
|
1323
|
+
// Everything CREATE TABLE itself puts into the schema's pg_class namespace
|
|
1324
|
+
// — the table, plus the index behind each PRIMARY KEY/UNIQUE constraint
|
|
1325
|
+
// declared inline. See `Builder.resolveNameCollisions`.
|
|
1326
|
+
const tableClaim = { id: tableId, objectType: 'table', path: c.path, changeKind: 'added' };
|
|
1327
|
+
b.claimName(schema, name, tableClaim);
|
|
1328
|
+
if (t.primaryKey !== null)
|
|
1329
|
+
b.claimName(schema, t.primaryKey.name, tableClaim);
|
|
1330
|
+
for (const uq of t.uniqueConstraints)
|
|
1331
|
+
b.claimName(schema, uq.name, tableClaim);
|
|
1332
|
+
for (const idx of t.indexes) {
|
|
1333
|
+
b.claimName(schema, idx.name, { id: `index:${c.path}.${idx.name}`, objectType: 'index', path: `${c.path}.${idx.name}`, changeKind: 'added' });
|
|
1334
|
+
b.emit(`index:${c.path}.${idx.name}`, [tableId], PHASE.INDEX_CREATE, {
|
|
1335
|
+
sql: `${idx.definition};`,
|
|
1336
|
+
objectType: 'index',
|
|
1337
|
+
path: `${c.path}.${idx.name}`,
|
|
1338
|
+
changeKind: 'added',
|
|
1339
|
+
risk: 'safe',
|
|
1340
|
+
riskNote: 'built on a brand-new, empty table — no lock contention with any other session is possible.',
|
|
1341
|
+
reversible: true,
|
|
1342
|
+
down: `DROP INDEX ${qualify(schema, idx.name)};`,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
for (const trg of t.triggers) {
|
|
1346
|
+
b.emit(`trigger:${c.path}.${trg.name}`, [tableId], PHASE.TRIGGER_CREATE, {
|
|
1347
|
+
sql: `${trg.definition};`,
|
|
1348
|
+
objectType: 'trigger',
|
|
1349
|
+
path: `${c.path}.${trg.name}`,
|
|
1350
|
+
changeKind: 'added',
|
|
1351
|
+
risk: 'low',
|
|
1352
|
+
riskNote: 'applies to writes against a table that has no rows yet.',
|
|
1353
|
+
reversible: true,
|
|
1354
|
+
down: `DROP TRIGGER ${quoteIdent(trg.name)} ON ${tableQualified};`,
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
for (const fk of t.foreignKeys) {
|
|
1358
|
+
const refTableCreateId = `table:${fk.refSchema}.${fk.refTable}`;
|
|
1359
|
+
const deps = [tableId, b.has(refTableCreateId) ? refTableCreateId : undefined].filter((x) => x !== undefined);
|
|
1360
|
+
b.emit(`fk:${c.path}.${fk.name}`, deps, PHASE.FK_ADD, {
|
|
1361
|
+
sql: `ALTER TABLE ${tableQualified} ADD CONSTRAINT ${quoteIdent(fk.name)} ${fk.definition};`,
|
|
1362
|
+
objectType: 'foreign_key',
|
|
1363
|
+
path: `${c.path}.${fk.name}`,
|
|
1364
|
+
changeKind: 'added',
|
|
1365
|
+
risk: 'safe',
|
|
1366
|
+
riskNote: 'validates zero existing rows — the table is brand new and empty.',
|
|
1367
|
+
reversible: true,
|
|
1368
|
+
down: `ALTER TABLE ${tableQualified} DROP CONSTRAINT ${quoteIdent(fk.name)};`,
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
// One PlannedStatement is exactly one statement — the plan is meant to be
|
|
1372
|
+
// applied statement by statement (postgres_execute_sql refuses a
|
|
1373
|
+
// multi-statement string outright), so ENABLE and FORCE are two nodes, and
|
|
1374
|
+
// FORCE is emitted only when it is actually forced: NO FORCE is the
|
|
1375
|
+
// default a fresh CREATE TABLE already has.
|
|
1376
|
+
if (t.rowSecurity.enabled) {
|
|
1377
|
+
b.emit(`rls:${c.path}`, [tableId], PHASE.RLS_ALTER, {
|
|
1378
|
+
sql: `ALTER TABLE ${tableQualified} ENABLE ROW LEVEL SECURITY;`,
|
|
1379
|
+
objectType: 'table',
|
|
1380
|
+
path: c.path,
|
|
1381
|
+
changeKind: 'added',
|
|
1382
|
+
risk: 'moderate',
|
|
1383
|
+
riskNote: 'once enabled, every role without BYPASSRLS sees only rows a policy allows — with no policies yet, that means no rows at all until the policies below are also applied.',
|
|
1384
|
+
reversible: true,
|
|
1385
|
+
down: `ALTER TABLE ${tableQualified} DISABLE ROW LEVEL SECURITY;`,
|
|
1386
|
+
});
|
|
1387
|
+
if (t.rowSecurity.forced) {
|
|
1388
|
+
b.emit(`rlsForce:${c.path}`, [`rls:${c.path}`], PHASE.RLS_ALTER, {
|
|
1389
|
+
sql: `ALTER TABLE ${tableQualified} FORCE ROW LEVEL SECURITY;`,
|
|
1390
|
+
objectType: 'table',
|
|
1391
|
+
path: c.path,
|
|
1392
|
+
changeKind: 'added',
|
|
1393
|
+
risk: 'moderate',
|
|
1394
|
+
riskNote: 'the table owner stops being exempt from its own row-level security policies — a role that could previously see every row now sees only what a policy allows.',
|
|
1395
|
+
reversible: true,
|
|
1396
|
+
down: `ALTER TABLE ${tableQualified} NO FORCE ROW LEVEL SECURITY;`,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
for (const p of t.rowSecurity.policies) {
|
|
1401
|
+
b.emit(`policy:${c.path}.${p.name}`, [tableId], PHASE.POLICY_CREATE, {
|
|
1402
|
+
sql: policyClause(p, schema, name),
|
|
1403
|
+
objectType: 'policy',
|
|
1404
|
+
path: `${c.path}.${p.name}`,
|
|
1405
|
+
changeKind: 'added',
|
|
1406
|
+
risk: 'moderate',
|
|
1407
|
+
riskNote: 'row-level security policies gate row visibility/writes for matching roles — test as those roles before relying on it.',
|
|
1408
|
+
reversible: true,
|
|
1409
|
+
down: `DROP POLICY ${quoteIdent(p.name)} ON ${tableQualified};`,
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (c.kind === 'removed') {
|
|
1415
|
+
const dropId = `tableDrop:${c.path}`;
|
|
1416
|
+
// DROP TABLE frees the table's own name and, in the same statement, every
|
|
1417
|
+
// index name it owned — including the ones behind its constraints, which
|
|
1418
|
+
// survive an ALTER TABLE ... RENAME and so are exactly what a renamed
|
|
1419
|
+
// table's re-created twin collides with.
|
|
1420
|
+
const t = c.object;
|
|
1421
|
+
b.releaseName(schema, name, dropId);
|
|
1422
|
+
if (t.primaryKey != null)
|
|
1423
|
+
b.releaseName(schema, t.primaryKey.name, dropId);
|
|
1424
|
+
for (const uq of t.uniqueConstraints ?? [])
|
|
1425
|
+
b.releaseName(schema, uq.name, dropId);
|
|
1426
|
+
for (const idx of t.indexes ?? [])
|
|
1427
|
+
b.releaseName(schema, idx.name, dropId);
|
|
1428
|
+
b.emit(dropId, [], PHASE.TABLE_DROP, {
|
|
1429
|
+
sql: `DROP TABLE ${tableQualified};`,
|
|
1430
|
+
objectType: 'table',
|
|
1431
|
+
path: c.path,
|
|
1432
|
+
changeKind: 'removed',
|
|
1433
|
+
risk: 'high',
|
|
1434
|
+
riskNote: 'discards every row in the table, plus every index, constraint, trigger, and policy it owned, irreversibly. Fails if another table still has a foreign key pointing at this one — that foreign key must already be dropped earlier in this plan (this plan does that automatically whenever the diff shows it).',
|
|
1435
|
+
reversible: false,
|
|
1436
|
+
downNote: 'cannot restore: the table\'s definition and every row it held are gone once this runs. Recreate it from a snapshot taken before this change and restore data from a backup.',
|
|
1437
|
+
});
|
|
1438
|
+
b.linkToSchemaDrop(schema, dropId);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
const owner = field(c.fields, 'owner');
|
|
1442
|
+
if (owner !== undefined) {
|
|
1443
|
+
b.emit(`tableOwner:${c.path}`, [], PHASE.TABLE_ALTER, {
|
|
1444
|
+
sql: `ALTER TABLE ${tableQualified} OWNER TO ${quoteIdent(String(owner.after))};`,
|
|
1445
|
+
objectType: 'table',
|
|
1446
|
+
path: c.path,
|
|
1447
|
+
changeKind: 'modified',
|
|
1448
|
+
risk: 'low',
|
|
1449
|
+
riskNote: 'metadata-only ownership change.',
|
|
1450
|
+
reversible: true,
|
|
1451
|
+
down: `ALTER TABLE ${tableQualified} OWNER TO ${quoteIdent(String(owner.before))};`,
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
const comment = field(c.fields, 'comment');
|
|
1455
|
+
if (comment !== undefined) {
|
|
1456
|
+
b.emit(`tableComment:${c.path}`, [], PHASE.TABLE_ALTER, commentStatement('table', c.path, `TABLE ${tableQualified}`, comment));
|
|
1457
|
+
}
|
|
1458
|
+
const kind = field(c.fields, 'kind');
|
|
1459
|
+
if (kind !== undefined) {
|
|
1460
|
+
b.markUnsupported('table', c.path, 'modified', `relation kind changed from "${String(kind.before)}" to "${String(kind.after)}" — ALTER TABLE cannot convert between a plain, partitioned, or foreign table. Recreate it explicitly.`);
|
|
1461
|
+
}
|
|
1462
|
+
const partitionBound = field(c.fields, 'partitionBound');
|
|
1463
|
+
if (partitionBound !== undefined) {
|
|
1464
|
+
b.markUnsupported('table', c.path, 'modified', 'a partition\'s bound changed — moving it needs the parent table\'s name (a separate, possibly-unchanged field not in this diff entry) and is a DETACH PARTITION + ATTACH PARTITION pair with its own window where the partition is attached to neither bound. Not attempted; do this manually.');
|
|
1465
|
+
}
|
|
1466
|
+
const inherits = field(c.fields, 'inherits');
|
|
1467
|
+
if (inherits !== undefined) {
|
|
1468
|
+
const before = new Set(inherits.before);
|
|
1469
|
+
const after = new Set(inherits.after);
|
|
1470
|
+
for (const parent of after) {
|
|
1471
|
+
if (before.has(parent))
|
|
1472
|
+
continue;
|
|
1473
|
+
b.emit(`tableInherit:${c.path}:${parent}`, [], PHASE.TABLE_ALTER, {
|
|
1474
|
+
sql: `ALTER TABLE ${tableQualified} INHERIT ${quoteQualifiedName(parent)};`,
|
|
1475
|
+
objectType: 'table',
|
|
1476
|
+
path: c.path,
|
|
1477
|
+
changeKind: 'modified',
|
|
1478
|
+
risk: 'moderate',
|
|
1479
|
+
riskNote: 'requires every row already in this table to satisfy the parent\'s constraints (scanned to verify) and changes what queries against the parent return.',
|
|
1480
|
+
reversible: true,
|
|
1481
|
+
down: `ALTER TABLE ${tableQualified} NO INHERIT ${quoteQualifiedName(parent)};`,
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
for (const parent of before) {
|
|
1485
|
+
if (after.has(parent))
|
|
1486
|
+
continue;
|
|
1487
|
+
b.emit(`tableNoInherit:${c.path}:${parent}`, [], PHASE.TABLE_ALTER, {
|
|
1488
|
+
sql: `ALTER TABLE ${tableQualified} NO INHERIT ${quoteQualifiedName(parent)};`,
|
|
1489
|
+
objectType: 'table',
|
|
1490
|
+
path: c.path,
|
|
1491
|
+
changeKind: 'modified',
|
|
1492
|
+
risk: 'low',
|
|
1493
|
+
riskNote: 'metadata-only — rows stay exactly as they are; only whether a parent-table query includes them changes.',
|
|
1494
|
+
reversible: true,
|
|
1495
|
+
down: `ALTER TABLE ${tableQualified} INHERIT ${quoteQualifiedName(parent)};`,
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
for (const [f, sql] of [
|
|
1500
|
+
['rowSecurity.enabled', 'ROW LEVEL SECURITY'],
|
|
1501
|
+
['rowSecurity.forced', 'FORCE ROW LEVEL SECURITY'],
|
|
1502
|
+
]) {
|
|
1503
|
+
const ch = field(c.fields, f);
|
|
1504
|
+
if (ch === undefined)
|
|
1505
|
+
continue;
|
|
1506
|
+
const enable = ch.after === true;
|
|
1507
|
+
const verb = f === 'rowSecurity.enabled' ? (enable ? 'ENABLE' : 'DISABLE') : enable ? 'FORCE' : 'NO FORCE';
|
|
1508
|
+
const downVerb = f === 'rowSecurity.enabled' ? (enable ? 'DISABLE' : 'ENABLE') : enable ? 'NO FORCE' : 'FORCE';
|
|
1509
|
+
b.emit(`table${f}:${c.path}`, [], PHASE.RLS_ALTER, {
|
|
1510
|
+
sql: `ALTER TABLE ${tableQualified} ${verb} ${sql === 'ROW LEVEL SECURITY' ? sql : sql};`,
|
|
1511
|
+
objectType: 'table',
|
|
1512
|
+
path: c.path,
|
|
1513
|
+
changeKind: 'modified',
|
|
1514
|
+
risk: f === 'rowSecurity.enabled' && enable ? 'moderate' : 'low',
|
|
1515
|
+
riskNote: f === 'rowSecurity.enabled' && enable
|
|
1516
|
+
? 'once enabled, every role without BYPASSRLS immediately sees only rows the existing policies allow — if there are no policies, that means no rows at all.'
|
|
1517
|
+
: 'metadata-only toggle.',
|
|
1518
|
+
reversible: true,
|
|
1519
|
+
down: `ALTER TABLE ${tableQualified} ${downVerb} ${sql};`,
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
// --- Database (never actionable) ------------------------------------------
|
|
1524
|
+
function handleDatabase(b, c) {
|
|
1525
|
+
b.markUnsupported('database', c.path, c.kind, 'the two snapshots were captured from differently-named databases — that is not a schema change to plan a migration for. Make sure both snapshots come from the same database.');
|
|
1526
|
+
}
|
|
1527
|
+
// ---------------------------------------------------------------------------
|
|
1528
|
+
// Entry point.
|
|
1529
|
+
// ---------------------------------------------------------------------------
|
|
1530
|
+
export function planMigration(rawChanges, opts = {}) {
|
|
1531
|
+
const resolved = { concurrentIndexes: opts.concurrentIndexes ?? true };
|
|
1532
|
+
const changes = expandSchemaChanges(rawChanges);
|
|
1533
|
+
const b = new Builder(changes);
|
|
1534
|
+
for (const c of changes) {
|
|
1535
|
+
switch (c.objectType) {
|
|
1536
|
+
case 'database':
|
|
1537
|
+
handleDatabase(b, c);
|
|
1538
|
+
break;
|
|
1539
|
+
case 'schema':
|
|
1540
|
+
handleSchema(b, c);
|
|
1541
|
+
break;
|
|
1542
|
+
case 'extension':
|
|
1543
|
+
handleExtension(b, c);
|
|
1544
|
+
break;
|
|
1545
|
+
case 'enum':
|
|
1546
|
+
handleEnum(b, c);
|
|
1547
|
+
break;
|
|
1548
|
+
case 'sequence':
|
|
1549
|
+
handleSequence(b, c);
|
|
1550
|
+
break;
|
|
1551
|
+
case 'function':
|
|
1552
|
+
handleFunction(b, c);
|
|
1553
|
+
break;
|
|
1554
|
+
case 'table':
|
|
1555
|
+
handleTable(b, c);
|
|
1556
|
+
break;
|
|
1557
|
+
case 'view':
|
|
1558
|
+
handleView(b, c, false);
|
|
1559
|
+
break;
|
|
1560
|
+
case 'materialized_view':
|
|
1561
|
+
handleView(b, c, true);
|
|
1562
|
+
break;
|
|
1563
|
+
case 'column':
|
|
1564
|
+
handleColumn(b, c);
|
|
1565
|
+
break;
|
|
1566
|
+
case 'primary_key':
|
|
1567
|
+
handleGenericConstraint(b, c, PHASE.CONSTRAINT_DROP, PHASE.CONSTRAINT_ADD);
|
|
1568
|
+
break;
|
|
1569
|
+
case 'unique_constraint':
|
|
1570
|
+
handleGenericConstraint(b, c, PHASE.CONSTRAINT_DROP, PHASE.CONSTRAINT_ADD);
|
|
1571
|
+
break;
|
|
1572
|
+
case 'check_constraint':
|
|
1573
|
+
handleGenericConstraint(b, c, PHASE.CONSTRAINT_DROP, PHASE.CONSTRAINT_ADD);
|
|
1574
|
+
break;
|
|
1575
|
+
case 'foreign_key':
|
|
1576
|
+
handleForeignKey(b, c);
|
|
1577
|
+
break;
|
|
1578
|
+
case 'index':
|
|
1579
|
+
handleIndex(b, c, resolved);
|
|
1580
|
+
break;
|
|
1581
|
+
case 'trigger':
|
|
1582
|
+
handleTrigger(b, c);
|
|
1583
|
+
break;
|
|
1584
|
+
case 'policy':
|
|
1585
|
+
handlePolicy(b, c);
|
|
1586
|
+
break;
|
|
1587
|
+
default:
|
|
1588
|
+
b.markUnsupported(c.objectType, c.path, c.kind, `no handler registered for object type "${c.objectType}".`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
b.resolveNameCollisions();
|
|
1592
|
+
const graph = b.toGraph();
|
|
1593
|
+
const statements = topoSort(graph);
|
|
1594
|
+
return { statements, unsupported: b.unsupported };
|
|
1595
|
+
}
|
|
1596
|
+
//# sourceMappingURL=migrate.js.map
|