@rebasepro/server-postgres 0.17.2-canary.g439a156 → 0.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/{backup-service-BtgHxfFm.js → backup-service-DCk7KhhL.js} +3 -3
  2. package/dist/{backup-service-BtgHxfFm.js.map → backup-service-DCk7KhhL.js.map} +1 -1
  3. package/dist/cli-helpers.d.ts +26 -14
  4. package/dist/{collection-index-DxJBvVTH.js → collection-index-BRUg10H5.js} +2 -2
  5. package/dist/{collection-index-DxJBvVTH.js.map → collection-index-BRUg10H5.js.map} +1 -1
  6. package/dist/{ensure-collection-policies-BHk4TRuf.js → ensure-collection-policies-UCqgv_8c.js} +4 -4
  7. package/dist/{ensure-collection-policies-BHk4TRuf.js.map → ensure-collection-policies-UCqgv_8c.js.map} +1 -1
  8. package/dist/{ensure-collection-tables-DMjOkeRy.js → ensure-collection-tables-DgVixhX3.js} +256 -38
  9. package/dist/ensure-collection-tables-DgVixhX3.js.map +1 -0
  10. package/dist/index.es.js +12 -12
  11. package/dist/{rls-bootstrap-sql-DNzaWd4C.js → rls-bootstrap-sql-B5C9LoJ6.js} +2 -2
  12. package/dist/{rls-bootstrap-sql-DNzaWd4C.js.map → rls-bootstrap-sql-B5C9LoJ6.js.map} +1 -1
  13. package/dist/{rls-enforcement-C1RJ1uI2.js → rls-enforcement-DvAbL9YJ.js} +3 -3
  14. package/dist/{rls-enforcement-C1RJ1uI2.js.map → rls-enforcement-DvAbL9YJ.js.map} +1 -1
  15. package/dist/schema/atlas-argv.d.ts +58 -0
  16. package/dist/schema/carved-out-migration.d.ts +65 -0
  17. package/dist/schema/ensure-collection-tables.d.ts +13 -1
  18. package/dist/schema/generate-postgres-ddl-logic.d.ts +57 -5
  19. package/dist/schema/vector-index.d.ts +120 -0
  20. package/dist/{src-DiDgtX8P.js → src-DiB5RP2Z.js} +33 -3
  21. package/dist/{src-DiDgtX8P.js.map → src-DiB5RP2Z.js.map} +1 -1
  22. package/dist/{websocket-g1Ji7m4o.js → websocket-BZ4H5wUz.js} +19 -9
  23. package/dist/websocket-BZ4H5wUz.js.map +1 -0
  24. package/package.json +6 -6
  25. package/src/cli-helpers.ts +65 -34
  26. package/src/cli.ts +168 -71
  27. package/src/schema/atlas-argv.ts +94 -0
  28. package/src/schema/carved-out-migration.ts +404 -0
  29. package/src/schema/ensure-collection-tables.ts +59 -32
  30. package/src/schema/generate-postgres-ddl-logic.ts +147 -21
  31. package/src/schema/generate-postgres-ddl.ts +59 -6
  32. package/src/schema/generate-schema-commit.ts +31 -6
  33. package/src/schema/vector-index.ts +213 -0
  34. package/dist/ensure-collection-tables-DMjOkeRy.js.map +0 -1
  35. 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 { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
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 { buildVectorIndexPlan, vectorIndexStatement, type SkippedVectorIndex } from "./vector-index";
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
- if (plannedExtensions.has(statement)) continue;
403
- plannedExtensions.add(statement);
404
- actions.push({ kind: "create-extension", target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""), sql: statement });
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
- const plan = planCollectionSchemaEnsure(collections, existing);
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