@rebasepro/server-postgres 0.17.1 → 0.17.2-canary.g4d21a53
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/dist/{backup-service-BtgHxfFm.js → backup-service-DCk7KhhL.js} +3 -3
- package/dist/{backup-service-BtgHxfFm.js.map → backup-service-DCk7KhhL.js.map} +1 -1
- package/dist/cli-helpers.d.ts +26 -14
- package/dist/{collection-index-DxJBvVTH.js → collection-index-BRUg10H5.js} +2 -2
- package/dist/{collection-index-DxJBvVTH.js.map → collection-index-BRUg10H5.js.map} +1 -1
- package/dist/{ensure-collection-policies-BHk4TRuf.js → ensure-collection-policies-UCqgv_8c.js} +4 -4
- package/dist/{ensure-collection-policies-BHk4TRuf.js.map → ensure-collection-policies-UCqgv_8c.js.map} +1 -1
- package/dist/{ensure-collection-tables-DMjOkeRy.js → ensure-collection-tables-DgVixhX3.js} +256 -38
- package/dist/ensure-collection-tables-DgVixhX3.js.map +1 -0
- package/dist/index.es.js +12 -12
- package/dist/{rls-bootstrap-sql-DNzaWd4C.js → rls-bootstrap-sql-B5C9LoJ6.js} +2 -2
- package/dist/{rls-bootstrap-sql-DNzaWd4C.js.map → rls-bootstrap-sql-B5C9LoJ6.js.map} +1 -1
- package/dist/{rls-enforcement-C1RJ1uI2.js → rls-enforcement-DvAbL9YJ.js} +3 -3
- package/dist/{rls-enforcement-C1RJ1uI2.js.map → rls-enforcement-DvAbL9YJ.js.map} +1 -1
- package/dist/schema/atlas-argv.d.ts +58 -0
- package/dist/schema/carved-out-migration.d.ts +65 -0
- package/dist/schema/ensure-collection-tables.d.ts +13 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +57 -5
- package/dist/schema/vector-index.d.ts +120 -0
- package/dist/{src-DiDgtX8P.js → src-DiB5RP2Z.js} +33 -3
- package/dist/{src-DiDgtX8P.js.map → src-DiB5RP2Z.js.map} +1 -1
- package/dist/{websocket-g1Ji7m4o.js → websocket-BZ4H5wUz.js} +19 -9
- package/dist/websocket-BZ4H5wUz.js.map +1 -0
- package/package.json +6 -6
- package/src/cli-helpers.ts +65 -34
- package/src/cli.ts +168 -71
- package/src/schema/atlas-argv.ts +94 -0
- package/src/schema/carved-out-migration.ts +404 -0
- package/src/schema/ensure-collection-tables.ts +59 -32
- package/src/schema/generate-postgres-ddl-logic.ts +147 -21
- package/src/schema/generate-postgres-ddl.ts +59 -6
- package/src/schema/generate-schema-commit.ts +31 -6
- package/src/schema/vector-index.ts +213 -0
- package/dist/ensure-collection-tables-DMjOkeRy.js.map +0 -1
- package/dist/websocket-g1Ji7m4o.js.map +0 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the objects Rebase carved out of Atlas's view out of the migrations
|
|
3
|
+
* Atlas writes.
|
|
4
|
+
*
|
|
5
|
+
* `db push` protects them with `--exclude`. `atlas migrate diff` has no such
|
|
6
|
+
* flag — measured on the pinned 1.2.3, `--exclude` is rejected outright with
|
|
7
|
+
* `unknown flag`, and the `exclude` attribute of an `atlas.hcl` `env` block is
|
|
8
|
+
* accepted and then silently ignored on that path. So the diff sees the search
|
|
9
|
+
* column that the migration directory's own appended DDL created when it
|
|
10
|
+
* replayed, does not see it in `schema.sql`, and plans a drop:
|
|
11
|
+
*
|
|
12
|
+
* ALTER TABLE "public"."talents" DROP COLUMN "search_vector";
|
|
13
|
+
*
|
|
14
|
+
* That statement is removed here, after Atlas has written the file and before
|
|
15
|
+
* anything is appended to it — so the input is always Atlas's own plain output,
|
|
16
|
+
* never the dollar-quoted helper bodies that get appended afterwards.
|
|
17
|
+
*
|
|
18
|
+
* The drop does not arrive alone. Atlas folds every change to one table into a
|
|
19
|
+
* single statement, so a real edit in the same run produces
|
|
20
|
+
*
|
|
21
|
+
* ALTER TABLE "public"."talents"
|
|
22
|
+
* DROP COLUMN "search_vector", DROP COLUMN "interests", ADD COLUMN "headline" text NULL;
|
|
23
|
+
*
|
|
24
|
+
* and dropping the whole statement would silently throw away a migration the
|
|
25
|
+
* project asked for. The removal is therefore per *clause*.
|
|
26
|
+
*
|
|
27
|
+
* Fail-safe throughout: a statement that mentions a carved-out object but does
|
|
28
|
+
* not match a shape this understands is left exactly as it is and reported as
|
|
29
|
+
* `unhandled`, so the caller can say so out loud. A spurious drop the developer
|
|
30
|
+
* can see beats a migration quietly rewritten wrong.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Anything Postgres accepts in an unquoted identifier past ASCII. */
|
|
34
|
+
const IDENT = "(?:\"(?:[^\"]|\"\")*\"|[A-Za-z_\\u0080-\\uffff][\\w$\\u0080-\\uffff]*)";
|
|
35
|
+
const QUALIFIED = `${IDENT}(?:\\s*\\.\\s*${IDENT})*`;
|
|
36
|
+
|
|
37
|
+
const ALTER_TABLE_RE = new RegExp(`^ALTER\\s+TABLE\\s+(?:ONLY\\s+)?(${QUALIFIED})\\s+([\\s\\S]+)$`, "i");
|
|
38
|
+
const DROP_COLUMN_RE = new RegExp(`^DROP\\s+(?:COLUMN\\s+)?(?:IF\\s+EXISTS\\s+)?(${IDENT})$`, "i");
|
|
39
|
+
const DROP_INDEX_RE = new RegExp(`^DROP\\s+INDEX\\s+(?:CONCURRENTLY\\s+)?(?:IF\\s+EXISTS\\s+)?(${QUALIFIED})$`, "i");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Does this statement destroy anything at all?
|
|
43
|
+
*
|
|
44
|
+
* The gate on reporting. A statement that merely *names* a carved-out object —
|
|
45
|
+
* `COMMENT ON COLUMN … "search_vector"` — is not a problem and must not be
|
|
46
|
+
* reported as one, because an unrewritable drop stops the caller outright.
|
|
47
|
+
*/
|
|
48
|
+
const MENTIONS_DROP = /\bDROP\b/i;
|
|
49
|
+
|
|
50
|
+
/** One `schema.table.object` exclude pattern, taken apart. */
|
|
51
|
+
export interface CarvedOutObject {
|
|
52
|
+
schema: string;
|
|
53
|
+
table: string;
|
|
54
|
+
object: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StripResult {
|
|
58
|
+
/** The migration text with the carved-out clauses gone. */
|
|
59
|
+
sql: string;
|
|
60
|
+
/** What was removed, as SQL fragments, for reporting. */
|
|
61
|
+
removed: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Statements naming a carved-out object that this could not rewrite with
|
|
64
|
+
* confidence. Left untouched in `sql`.
|
|
65
|
+
*/
|
|
66
|
+
unhandled: string[];
|
|
67
|
+
/** True when nothing but comments and whitespace survives. */
|
|
68
|
+
empty: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Split `schema.table.object` patterns. Anything not in three parts is
|
|
73
|
+
* ignored rather than guessed at — the two-part form is the one Atlas itself
|
|
74
|
+
* silently mis-reads, and repeating that mistake here would be worse than
|
|
75
|
+
* doing nothing.
|
|
76
|
+
*/
|
|
77
|
+
export function parseExcludePatterns(patterns: string[]): CarvedOutObject[] {
|
|
78
|
+
const parsed: CarvedOutObject[] = [];
|
|
79
|
+
for (const pattern of patterns) {
|
|
80
|
+
const parts = pattern.split(".");
|
|
81
|
+
if (parts.length !== 3) continue;
|
|
82
|
+
if (parts.some((p) => p.length === 0)) continue;
|
|
83
|
+
parsed.push({ schema: parts[0], table: parts[1], object: parts[2] });
|
|
84
|
+
}
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `"public"` and `public` are the same name; `"Public"` is not `public`. */
|
|
89
|
+
function unquoteIdentifier(raw: string): string {
|
|
90
|
+
const trimmed = raw.trim();
|
|
91
|
+
if (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) {
|
|
92
|
+
return trimmed.slice(1, -1).replace(/""/g, "\"");
|
|
93
|
+
}
|
|
94
|
+
// An unquoted identifier is folded to lower case by Postgres.
|
|
95
|
+
return trimmed.toLowerCase();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Split a dotted identifier at its top-level dots, respecting quoting. */
|
|
99
|
+
function splitQualifiedName(raw: string): string[] | null {
|
|
100
|
+
const parts: string[] = [];
|
|
101
|
+
let current = "";
|
|
102
|
+
let inQuote = false;
|
|
103
|
+
for (let i = 0; i < raw.length; i++) {
|
|
104
|
+
const ch = raw[i];
|
|
105
|
+
if (inQuote) {
|
|
106
|
+
if (ch === "\"") {
|
|
107
|
+
if (raw[i + 1] === "\"") { current += "\"\""; i++; continue; }
|
|
108
|
+
inQuote = false;
|
|
109
|
+
}
|
|
110
|
+
current += ch;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (ch === "\"") { inQuote = true; current += ch; continue; }
|
|
114
|
+
if (ch === ".") { parts.push(current); current = ""; continue; }
|
|
115
|
+
current += ch;
|
|
116
|
+
}
|
|
117
|
+
if (inQuote) return null;
|
|
118
|
+
parts.push(current);
|
|
119
|
+
return parts.map(unquoteIdentifier);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One statement located in the source text, with the comment lines that
|
|
124
|
+
* introduce it. Atlas writes `-- Modify "talents" table` above each statement;
|
|
125
|
+
* removing the statement without its comment leaves a caption over nothing.
|
|
126
|
+
*/
|
|
127
|
+
interface LocatedStatement {
|
|
128
|
+
/** Offset of the first character of the leading comment block. */
|
|
129
|
+
start: number;
|
|
130
|
+
/** Offset just past the terminating semicolon. */
|
|
131
|
+
end: number;
|
|
132
|
+
/** Offset of the first character of the statement itself. */
|
|
133
|
+
bodyStart: number;
|
|
134
|
+
/** The statement without its leading comments or trailing semicolon. */
|
|
135
|
+
body: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Walk the script and locate every statement. Quote-, comment- and
|
|
140
|
+
* dollar-aware: the appended halves of a migration are not passed here, but a
|
|
141
|
+
* splitter that shreds a `DO $tag$ ... $tag$` block is a trap worth not
|
|
142
|
+
* leaving behind — it is how the derived-names renderer broke.
|
|
143
|
+
*
|
|
144
|
+
* Returns null if the text ends inside a quote or a dollar-quoted body, or
|
|
145
|
+
* trails off in a statement with no semicolon. Either means this cannot be
|
|
146
|
+
* reasoned about safely.
|
|
147
|
+
*/
|
|
148
|
+
function locateStatements(sql: string): LocatedStatement[] | null {
|
|
149
|
+
const statements: LocatedStatement[] = [];
|
|
150
|
+
let i = 0;
|
|
151
|
+
let statementStart = 0;
|
|
152
|
+
let sawCode = false;
|
|
153
|
+
|
|
154
|
+
const pushStatement = (endExclusive: number) => {
|
|
155
|
+
const leading = sql.slice(statementStart, endExclusive);
|
|
156
|
+
// The leading run of comment/blank lines belongs to this statement.
|
|
157
|
+
let consumed = 0;
|
|
158
|
+
for (const line of leading.split("\n")) {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
if (trimmed === "" || trimmed.startsWith("--")) {
|
|
161
|
+
consumed += line.length + 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const bodyStart = statementStart + consumed;
|
|
167
|
+
statements.push({
|
|
168
|
+
start: statementStart,
|
|
169
|
+
end: endExclusive,
|
|
170
|
+
bodyStart,
|
|
171
|
+
body: sql.slice(bodyStart, endExclusive).replace(/;\s*$/, "").trim()
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
while (i < sql.length) {
|
|
176
|
+
const ch = sql[i];
|
|
177
|
+
|
|
178
|
+
if (ch === "-" && sql[i + 1] === "-") {
|
|
179
|
+
const nl = sql.indexOf("\n", i);
|
|
180
|
+
i = nl === -1 ? sql.length : nl + 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (ch === "/" && sql[i + 1] === "*") {
|
|
184
|
+
const close = sql.indexOf("*/", i + 2);
|
|
185
|
+
if (close === -1) return null;
|
|
186
|
+
i = close + 2;
|
|
187
|
+
sawCode = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (ch === "'" || ch === "\"") {
|
|
191
|
+
const quote = ch;
|
|
192
|
+
i++;
|
|
193
|
+
let closed = false;
|
|
194
|
+
while (i < sql.length) {
|
|
195
|
+
if (sql[i] === quote) {
|
|
196
|
+
if (sql[i + 1] === quote) { i += 2; continue; }
|
|
197
|
+
i++;
|
|
198
|
+
closed = true;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
i++;
|
|
202
|
+
}
|
|
203
|
+
if (!closed) return null;
|
|
204
|
+
sawCode = true;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (ch === "$") {
|
|
208
|
+
const tag = /^\$(?:[A-Za-z_\u0080-\uffff][\w\u0080-\uffff]*)?\$/.exec(sql.slice(i));
|
|
209
|
+
if (tag) {
|
|
210
|
+
const close = sql.indexOf(tag[0], i + tag[0].length);
|
|
211
|
+
if (close === -1) return null;
|
|
212
|
+
i = close + tag[0].length;
|
|
213
|
+
sawCode = true;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (ch === ";") {
|
|
218
|
+
pushStatement(i + 1);
|
|
219
|
+
i++;
|
|
220
|
+
statementStart = i;
|
|
221
|
+
sawCode = false;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!/\s/.test(ch)) sawCode = true;
|
|
225
|
+
i++;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Trailing text with no semicolon: only comments and whitespace is fine.
|
|
229
|
+
if (sawCode) return null;
|
|
230
|
+
return statements;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Split an ALTER TABLE action list at its top-level commas. */
|
|
234
|
+
function splitClauses(actions: string): string[] | null {
|
|
235
|
+
const clauses: string[] = [];
|
|
236
|
+
let depth = 0;
|
|
237
|
+
let current = "";
|
|
238
|
+
let i = 0;
|
|
239
|
+
while (i < actions.length) {
|
|
240
|
+
const ch = actions[i];
|
|
241
|
+
if (ch === "'" || ch === "\"") {
|
|
242
|
+
const quote = ch;
|
|
243
|
+
current += ch;
|
|
244
|
+
i++;
|
|
245
|
+
let closed = false;
|
|
246
|
+
while (i < actions.length) {
|
|
247
|
+
current += actions[i];
|
|
248
|
+
if (actions[i] === quote) {
|
|
249
|
+
if (actions[i + 1] === quote) { current += actions[i + 1]; i += 2; continue; }
|
|
250
|
+
i++;
|
|
251
|
+
closed = true;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
i++;
|
|
255
|
+
}
|
|
256
|
+
if (!closed) return null;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (ch === "(") { depth++; current += ch; i++; continue; }
|
|
260
|
+
if (ch === ")") { depth--; current += ch; i++; continue; }
|
|
261
|
+
if (ch === "," && depth === 0) { clauses.push(current); current = ""; i++; continue; }
|
|
262
|
+
current += ch;
|
|
263
|
+
i++;
|
|
264
|
+
}
|
|
265
|
+
if (depth !== 0) return null;
|
|
266
|
+
clauses.push(current);
|
|
267
|
+
return clauses.map((c) => c.trim()).filter((c) => c.length > 0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Remove every clause and statement that would drop one of `patterns`.
|
|
272
|
+
*
|
|
273
|
+
* `patterns` are the `schema.table.object` strings the CLI already builds for
|
|
274
|
+
* Atlas's `--exclude`, so the diff path and the apply path protect exactly the
|
|
275
|
+
* same set by construction.
|
|
276
|
+
*/
|
|
277
|
+
export function stripCarvedOutStatements(migrationSql: string, patterns: string[]): StripResult {
|
|
278
|
+
const carved = parseExcludePatterns(patterns);
|
|
279
|
+
if (carved.length === 0) {
|
|
280
|
+
return { sql: migrationSql, removed: [], unhandled: [], empty: isBlank(migrationSql) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const located = locateStatements(migrationSql);
|
|
284
|
+
if (located === null) {
|
|
285
|
+
// Nothing can be trusted about the offsets, so nothing moves. Worth
|
|
286
|
+
// stopping the caller over only if one of ours is being dropped in
|
|
287
|
+
// there; otherwise this file has nothing to do with us.
|
|
288
|
+
const atStake = MENTIONS_DROP.test(migrationSql)
|
|
289
|
+
&& carved.some((c) => migrationSql.includes(c.object));
|
|
290
|
+
return {
|
|
291
|
+
sql: migrationSql,
|
|
292
|
+
removed: [],
|
|
293
|
+
unhandled: atStake ? [migrationSql.trim()] : [],
|
|
294
|
+
empty: isBlank(migrationSql)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const removed: string[] = [];
|
|
299
|
+
const unhandled: string[] = [];
|
|
300
|
+
// Edits as [start, end) → replacement, applied back-to-front so the
|
|
301
|
+
// offsets of the ones still pending stay valid.
|
|
302
|
+
const edits: { start: number; end: number; replacement: string }[] = [];
|
|
303
|
+
|
|
304
|
+
for (const statement of located) {
|
|
305
|
+
if (!carved.some((c) => statement.body.includes(c.object))) continue;
|
|
306
|
+
|
|
307
|
+
const dropIndex = DROP_INDEX_RE.exec(statement.body);
|
|
308
|
+
if (dropIndex) {
|
|
309
|
+
const parts = splitQualifiedName(dropIndex[1]);
|
|
310
|
+
const name = parts?.[parts.length - 1];
|
|
311
|
+
const schema = parts && parts.length > 1 ? parts[parts.length - 2] : undefined;
|
|
312
|
+
if (name !== undefined && carved.some((c) =>
|
|
313
|
+
c.object === name && (schema === undefined || c.schema === schema))) {
|
|
314
|
+
removed.push(statement.body);
|
|
315
|
+
edits.push({ start: statement.start, end: statement.end, replacement: "" });
|
|
316
|
+
} else {
|
|
317
|
+
// A near-miss on the name — some other index whose name
|
|
318
|
+
// contains ours. Left alone, and still a drop, so still
|
|
319
|
+
// reported.
|
|
320
|
+
unhandled.push(statement.body);
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Anything else naming a carved-out object is only our business if it
|
|
326
|
+
// destroys something. `COMMENT ON COLUMN … "search_vector"` does not.
|
|
327
|
+
const alter = ALTER_TABLE_RE.exec(statement.body);
|
|
328
|
+
if (!alter) {
|
|
329
|
+
if (MENTIONS_DROP.test(statement.body)) unhandled.push(statement.body);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const target = splitQualifiedName(alter[1]);
|
|
334
|
+
if (!target) {
|
|
335
|
+
if (MENTIONS_DROP.test(statement.body)) unhandled.push(statement.body);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const table = target[target.length - 1];
|
|
339
|
+
const schema = target.length > 1 ? target[target.length - 2] : undefined;
|
|
340
|
+
|
|
341
|
+
const clauses = splitClauses(alter[2]);
|
|
342
|
+
if (!clauses) {
|
|
343
|
+
if (MENTIONS_DROP.test(statement.body)) unhandled.push(statement.body);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const survivors: string[] = [];
|
|
348
|
+
let removedHere = 0;
|
|
349
|
+
let unhandledHere = false;
|
|
350
|
+
for (const clause of clauses) {
|
|
351
|
+
const drop = DROP_COLUMN_RE.exec(clause);
|
|
352
|
+
const column = drop ? unquoteIdentifier(drop[1]) : undefined;
|
|
353
|
+
const isCarved = column !== undefined && carved.some((c) =>
|
|
354
|
+
c.object === column &&
|
|
355
|
+
c.table === table &&
|
|
356
|
+
(schema === undefined || c.schema === schema));
|
|
357
|
+
if (isCarved) {
|
|
358
|
+
removed.push(`ALTER TABLE ${alter[1]}: ${clause}`);
|
|
359
|
+
removedHere++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// A clause naming a carved-out object in some other shape is not
|
|
363
|
+
// something to guess at — if it destroys it.
|
|
364
|
+
if (MENTIONS_DROP.test(clause) && carved.some((c) => clause.includes(c.object))) {
|
|
365
|
+
unhandledHere = true;
|
|
366
|
+
}
|
|
367
|
+
survivors.push(clause);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (unhandledHere) unhandled.push(statement.body);
|
|
371
|
+
if (removedHere === 0) continue;
|
|
372
|
+
|
|
373
|
+
if (survivors.length === 0) {
|
|
374
|
+
edits.push({ start: statement.start, end: statement.end, replacement: "" });
|
|
375
|
+
} else {
|
|
376
|
+
edits.push({
|
|
377
|
+
start: statement.bodyStart,
|
|
378
|
+
end: statement.end,
|
|
379
|
+
replacement: `ALTER TABLE ${alter[1]} ${survivors.join(", ")};`
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let sql = migrationSql;
|
|
385
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
386
|
+
sql = sql.slice(0, edit.start) + edit.replacement + sql.slice(edit.end);
|
|
387
|
+
}
|
|
388
|
+
if (edits.length > 0) sql = tidy(sql);
|
|
389
|
+
|
|
390
|
+
return { sql, removed, unhandled, empty: isBlank(sql) };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Only comments and whitespace left — nothing a migration would do. */
|
|
394
|
+
function isBlank(sql: string): boolean {
|
|
395
|
+
return sql
|
|
396
|
+
.split("\n")
|
|
397
|
+
.every((line) => line.trim() === "" || line.trim().startsWith("--"));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Collapse the blank runs an excision leaves behind. */
|
|
401
|
+
function tidy(sql: string): string {
|
|
402
|
+
const trimmed = sql.replace(/\n{3,}/g, "\n\n").replace(/^\s*\n/, "").trimEnd();
|
|
403
|
+
return trimmed.length === 0 ? "" : `${trimmed}\n`;
|
|
404
|
+
}
|
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
* Because of that, this is safe to run on every boot, and re-running it is a
|
|
26
26
|
* no-op.
|
|
27
27
|
*/
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
declaredDatabaseExtensions,
|
|
30
|
+
isPostgresCollectionConfig,
|
|
31
|
+
type CollectionConfig,
|
|
32
|
+
type Property
|
|
33
|
+
} from "@rebasepro/types";
|
|
29
34
|
import { getTableName, relationalCollections } from "@rebasepro/common";
|
|
30
35
|
import { logger, isConcurrentDdlRace, isDuplicateObjectRace } from "@rebasepro/server";
|
|
31
36
|
import {
|
|
@@ -48,7 +53,15 @@ import {
|
|
|
48
53
|
planJunctionTables,
|
|
49
54
|
quoteSqlLiteral
|
|
50
55
|
} from "./generate-postgres-ddl-logic";
|
|
51
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
buildVectorColumnSpecs,
|
|
58
|
+
buildVectorIndexPlan,
|
|
59
|
+
vectorExtensionDeclared,
|
|
60
|
+
vectorExtensionHint,
|
|
61
|
+
vectorExtensionStatement,
|
|
62
|
+
vectorIndexStatement,
|
|
63
|
+
type SkippedVectorIndex
|
|
64
|
+
} from "./vector-index";
|
|
52
65
|
import { buildCollectionIndexPlan, collectionIndexStatement } from "./collection-index";
|
|
53
66
|
import {
|
|
54
67
|
AUTH_USERS_COLUMNS,
|
|
@@ -147,6 +160,18 @@ export type ConstraintPolicy = "additive" | "converge";
|
|
|
147
160
|
export interface EnsureOptions {
|
|
148
161
|
/** Defaults to `additive`. See {@link ConstraintPolicy}. */
|
|
149
162
|
constraints?: ConstraintPolicy;
|
|
163
|
+
/**
|
|
164
|
+
* Server extensions the project's databases gave Rebase leave to install —
|
|
165
|
+
* `declaredDatabaseExtensions()`. Absent means none, which is a refusal and
|
|
166
|
+
* is the right default for a planner given no configuration at all.
|
|
167
|
+
*
|
|
168
|
+
* Explicit rather than read from the resource registry in here, because
|
|
169
|
+
* this function is pure and several callers plan against fixtures: reaching
|
|
170
|
+
* for a process-wide registry would make the plan depend on whatever some
|
|
171
|
+
* other module happened to import. `ensureCollectionTables` is the boundary
|
|
172
|
+
* that reads the world.
|
|
173
|
+
*/
|
|
174
|
+
databaseExtensions?: readonly string[];
|
|
150
175
|
}
|
|
151
176
|
|
|
152
177
|
export interface EnsureAction {
|
|
@@ -397,12 +422,28 @@ export function planCollectionSchemaEnsure(
|
|
|
397
422
|
.filter((spec): spec is SearchColumnSpec => spec !== undefined);
|
|
398
423
|
|
|
399
424
|
const plannedExtensions = new Set<string>();
|
|
425
|
+
const planExtension = (statement: string): void => {
|
|
426
|
+
if (plannedExtensions.has(statement)) return;
|
|
427
|
+
plannedExtensions.add(statement);
|
|
428
|
+
actions.push({ kind: "create-extension", target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""), sql: statement });
|
|
429
|
+
};
|
|
400
430
|
for (const spec of searchSpecs) {
|
|
401
|
-
for (const statement of searchExtensionStatements(spec))
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
431
|
+
for (const statement of searchExtensionStatements(spec)) planExtension(statement);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// pgvector, for collections that declared a `vector` property — and only
|
|
435
|
+
// when a database gave leave to install it. Unlike the search extensions
|
|
436
|
+
// above, which are contrib modules every Postgres carries, pgvector is a
|
|
437
|
+
// separate build behind an image, a grant and a provider allow-list, so
|
|
438
|
+
// whether Rebase may install it is not Rebase's to decide. See
|
|
439
|
+
// `DatabaseOptions.extensions`.
|
|
440
|
+
//
|
|
441
|
+
// Boot has to make the same call `rebase db push` makes, or the two produce
|
|
442
|
+
// different databases from one commit — which is the rule
|
|
443
|
+
// `contracts/derived-names.txt` enforces.
|
|
444
|
+
if (vectorExtensionDeclared(options.databaseExtensions)
|
|
445
|
+
&& collections.some(c => buildVectorColumnSpecs(c, resolveColumnName).length > 0)) {
|
|
446
|
+
planExtension(vectorExtensionStatement());
|
|
406
447
|
}
|
|
407
448
|
const plannedFunctions = new Set<string>();
|
|
408
449
|
for (const spec of searchSpecs) {
|
|
@@ -1009,29 +1050,6 @@ function searchDriftMessage(drift: SearchColumnDrift[]): string {
|
|
|
1009
1050
|
);
|
|
1010
1051
|
}
|
|
1011
1052
|
|
|
1012
|
-
/**
|
|
1013
|
-
* The missing-pgvector explanation, appended to the error that reveals it.
|
|
1014
|
-
*
|
|
1015
|
-
* A `{ type: "vector" }` property compiles to `VECTOR(n)`, and nothing in the
|
|
1016
|
-
* OSS pipeline installs pgvector — not this ensure, not `db push`. Installing
|
|
1017
|
-
* an extension on someone's database is a decision with a deployment behind it
|
|
1018
|
-
* (image, superuser, cloud allow-list), so this path stays a refusal; what it
|
|
1019
|
-
* must not stay is a bare `type "vector" does not exist` on a crash-looping
|
|
1020
|
-
* pod, which names nothing the reader can act on.
|
|
1021
|
-
*
|
|
1022
|
-
* The scaffold now ships `pgvector/pgvector:pg18`, so this is reached by a
|
|
1023
|
-
* project pointed at a database someone else provisioned — which is exactly
|
|
1024
|
-
* the case where naming the extension and the image is worth the words.
|
|
1025
|
-
*/
|
|
1026
|
-
function vectorExtensionHint(message: string): string {
|
|
1027
|
-
if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
|
|
1028
|
-
return (
|
|
1029
|
-
"\n pgvector is not installed on this database, and Rebase does not install it: it is a server extension, " +
|
|
1030
|
-
"so it needs an image that ships it (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` " +
|
|
1031
|
-
"does not) and a role allowed to run `CREATE EXTENSION vector;`. Install it once, then boot again. " +
|
|
1032
|
-
"Rebase then creates an ANN index for the column automatically — see the `index` option on the property."
|
|
1033
|
-
);
|
|
1034
|
-
}
|
|
1035
1053
|
|
|
1036
1054
|
/**
|
|
1037
1055
|
* Read what the database looks like, for the schemas a set of collections
|
|
@@ -1065,7 +1083,8 @@ export async function readSchemaFactsFor(
|
|
|
1065
1083
|
export async function ensureCollectionTables(
|
|
1066
1084
|
client: Queryable,
|
|
1067
1085
|
collections: CollectionConfig[],
|
|
1068
|
-
log?: (message: string) => void
|
|
1086
|
+
log?: (message: string) => void,
|
|
1087
|
+
options: EnsureOptions = {}
|
|
1069
1088
|
): Promise<EnsureOutcome> {
|
|
1070
1089
|
// Junctions live alongside the collections that declare them, so their
|
|
1071
1090
|
// schema has to be read too — otherwise an existing junction reads as
|
|
@@ -1082,7 +1101,15 @@ export async function ensureCollectionTables(
|
|
|
1082
1101
|
}
|
|
1083
1102
|
|
|
1084
1103
|
const existing = await readExistingSchema(client, schemas);
|
|
1085
|
-
|
|
1104
|
+
// This is the boundary, so this is where the world is read: the planner
|
|
1105
|
+
// itself takes the permission as an argument and never reaches for the
|
|
1106
|
+
// registry. By the time boot gets here `loadBundleResourceGraph` has
|
|
1107
|
+
// evaluated the project's `resources.ts`, so the declarations are there —
|
|
1108
|
+
// and a caller that already knows them can still say so.
|
|
1109
|
+
const plan = planCollectionSchemaEnsure(collections, existing, {
|
|
1110
|
+
...options,
|
|
1111
|
+
databaseExtensions: options.databaseExtensions ?? declaredDatabaseExtensions()
|
|
1112
|
+
});
|
|
1086
1113
|
const failures: EnsureOutcome["failures"] = [];
|
|
1087
1114
|
|
|
1088
1115
|
// Reported, not warned: this is a rename the ensure is about to perform, and
|