@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g2d1aec8

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 (63) hide show
  1. package/dist/PostgresAdapter.d.ts +1 -1
  2. package/dist/PostgresBackendDriver.d.ts +16 -7
  3. package/dist/PostgresBootstrapper.d.ts +6 -6
  4. package/dist/auth/services.d.ts +1 -1
  5. package/dist/backup/backup-cron.d.ts +1 -1
  6. package/dist/backup/backup-service.d.ts +2 -2
  7. package/dist/backup/index.d.ts +4 -4
  8. package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
  9. package/dist/collections/buildRegistry.d.ts +1 -1
  10. package/dist/collections/validate-relations.d.ts +1 -1
  11. package/dist/data-transformer.d.ts +1 -1
  12. package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-CVfVHS6o.js} +3 -3
  13. package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-CVfVHS6o.js.map} +1 -1
  14. package/dist/{auth-users-columns-CgyPWQ18.js → ensure-collection-tables-jYsvOLZF.js} +1443 -11
  15. package/dist/ensure-collection-tables-jYsvOLZF.js.map +1 -0
  16. package/dist/index.d.ts +16 -16
  17. package/dist/index.es.js +2169 -1803
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CsD7nZDn.js} +2 -2
  20. package/dist/{rls-enforcement-Ch0T6OwW.js.map → rls-enforcement-CsD7nZDn.js.map} +1 -1
  21. package/dist/schema/classify-change.d.ts +82 -0
  22. package/dist/schema/dynamic-tables.d.ts +1 -1
  23. package/dist/schema/ensure-collection-policies.d.ts +1 -1
  24. package/dist/schema/ensure-collection-tables.d.ts +93 -2
  25. package/dist/schema/generate-schema-commit.d.ts +136 -0
  26. package/dist/schema/introspect-db-constraints.d.ts +1 -1
  27. package/dist/schema/introspect-db-logic.d.ts +3 -3
  28. package/dist/schema/introspect-db-project.d.ts +1 -1
  29. package/dist/schema/introspect-db-queries.d.ts +1 -1
  30. package/dist/schema/introspect-db-structure.d.ts +2 -2
  31. package/dist/schema/introspect-runtime.d.ts +1 -1
  32. package/dist/schema/vector-index.d.ts +88 -0
  33. package/dist/services/BranchService.d.ts +2 -2
  34. package/dist/services/FetchService.d.ts +4 -4
  35. package/dist/services/PersistService.d.ts +5 -5
  36. package/dist/services/RelationService.d.ts +3 -3
  37. package/dist/services/RelationWriteService.d.ts +3 -3
  38. package/dist/services/cdc/junction-tables.d.ts +1 -1
  39. package/dist/services/cdc/trigger-cdc.d.ts +1 -1
  40. package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
  41. package/dist/services/channel-bus/index.d.ts +2 -2
  42. package/dist/services/collection-helpers.d.ts +1 -1
  43. package/dist/services/dataService.d.ts +10 -10
  44. package/dist/services/index.d.ts +4 -4
  45. package/dist/services/junction-writes.d.ts +2 -2
  46. package/dist/services/nested-path.d.ts +1 -1
  47. package/dist/services/realtimeService.d.ts +3 -3
  48. package/dist/services/row-pipeline.d.ts +1 -1
  49. package/dist/services/write-denial.d.ts +1 -1
  50. package/dist/utils/drizzle-conditions.d.ts +2 -2
  51. package/dist/websocket-BVgDVO-V.js.map +1 -1
  52. package/dist/websocket.d.ts +2 -2
  53. package/package.json +7 -7
  54. package/src/PostgresBackendDriver.ts +40 -0
  55. package/src/schema/classify-change.ts +436 -0
  56. package/src/schema/ensure-collection-tables.test.ts +168 -1
  57. package/src/schema/ensure-collection-tables.ts +344 -14
  58. package/src/schema/generate-postgres-ddl-logic.ts +15 -0
  59. package/src/schema/generate-schema-commit.ts +242 -0
  60. package/src/schema/vector-index.ts +278 -0
  61. package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
  62. package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
  63. package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
@@ -1,840 +0,0 @@
1
- import { createRequire as __createRequire } from "module";
2
- import "process";
3
- __createRequire(import.meta.url);
4
- import { C as getJunctionCollectionConfig, D as policyToPostgres, E as getEffectiveSecurityRules, I as resolveCollectionRelations, J as legacyForeignKeyName, O as securityRuleToConditions, P as getTableName, S as resolveStringColumnLength, T as resolveJunctionSpecs, Y as toPostgresIdentifier, Z as getPolicyNamesForRule, c as assertSearchIsPostgresOnly, d as searchColumnStamps, f as searchExtensionStatements, i as isAuthCollection, j as findRelation, l as buildSearchColumnSpec, m as searchIndexStatements, n as authUsersColumnDefinition, o as SEARCH_TEXT_FN, p as searchHelperFunctions, q as generateForeignKeyName, r as authUsersColumnSql, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, tt as toSnakeCase, w as getJunctionSecurityRules, x as relationalCollections } from "./auth-users-columns-CgyPWQ18.js";
5
- import { c as isPostgresCollectionConfig } from "./src-BBFsDaeA.js";
6
- import { isConcurrentDdlRace, isDuplicateObjectRace, logger } from "@rebasepro/server";
7
- //#region src/schema/generate-postgres-ddl-logic.ts
8
- var resolveColumnName = (propName, prop) => {
9
- if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
10
- return toSnakeCase(propName);
11
- };
12
- var getPrimaryKeyProp = (collection) => {
13
- if (collection.properties) {
14
- const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in prop && Boolean(prop.isId));
15
- if (idPropEntry) {
16
- const prop = idPropEntry[1];
17
- const isUuid = prop.type === "string" && "isId" in prop && prop.isId === "uuid";
18
- return {
19
- name: idPropEntry[0],
20
- type: prop.type === "number" ? "number" : "string",
21
- isUuid
22
- };
23
- }
24
- }
25
- const idProp = collection.properties?.["id"];
26
- if (idProp?.type === "number") return {
27
- name: "id",
28
- type: "number",
29
- isUuid: false
30
- };
31
- return {
32
- name: "id",
33
- type: "string",
34
- isUuid: idProp?.type === "string" && "isId" in idProp && idProp.isId === "uuid"
35
- };
36
- };
37
- var isNumericId = (collection) => {
38
- return getPrimaryKeyProp(collection).type === "number";
39
- };
40
- var getPrimaryKeyName = (collection) => {
41
- return getPrimaryKeyProp(collection).name;
42
- };
43
- /** The column type a junction holds for one endpoint's primary key. */
44
- var junctionKeyType = (collection) => isNumericId(collection) ? "INTEGER" : getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT";
45
- var isIdProperty = (propName, prop, collection) => {
46
- if ("isId" in prop && Boolean(prop.isId)) return true;
47
- return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
48
- };
49
- /**
50
- * The individual SQL statements a single security rule compiles to: a
51
- * `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
52
- * statement (terminated by `;`, no trailing newline).
53
- *
54
- * This is the primitive the boot-time RLS applier runs one statement at a time
55
- * (the runtime's DB handle speaks the extended query protocol, which forbids
56
- * multiple commands in one execute), while `db push` writes the joined string.
57
- */
58
- var generatePolicyStatements = (collection, rule, resolveCollection) => {
59
- const tableName = getTableName(collection);
60
- const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
61
- const policyNames = getPolicyNamesForRule(rule, tableName);
62
- return ops.flatMap((op, opIdx) => {
63
- return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
64
- });
65
- };
66
- var generateSinglePolicyStatements = (collection, rule, operation, policyName, resolveCollection) => {
67
- const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
68
- const tableName = getTableName(collection);
69
- const mode = (rule.mode ?? "permissive").toUpperCase();
70
- const operationUpper = operation.toUpperCase();
71
- const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
72
- const needsUsing = operation !== "insert";
73
- const needsWithCheck = operation !== "select" && operation !== "delete";
74
- const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
75
- let usingClause = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection, { resolveCollection }) : null;
76
- let withCheckClause = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection, { resolveCollection }) : null;
77
- if (!usingClause && needsUsing) usingClause = "false";
78
- if (!withCheckClause && needsWithCheck) withCheckClause = "false";
79
- const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
80
- let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map((r) => `"${r}"`).join(", ")}`;
81
- if (usingClause) create += ` USING (${usingClause})`;
82
- if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
83
- create += ";";
84
- return [drop, create];
85
- };
86
- /**
87
- * Single-quote escaping for a SQL string literal (PostgreSQL doubles the
88
- * quote). Enum labels come straight from user-authored collection config, so a
89
- * label like `it's` closes the literal early and the whole generated file stops
90
- * parsing at the `CREATE TYPE`. Lives here rather than next to its other caller
91
- * because ensure-collection-tables already imports from this module — the
92
- * reverse would be a cycle.
93
- */
94
- var quoteSqlLiteral = (value) => `'${value.replace(/'/g, "''")}'`;
95
- var getSqlColumnType = (propName, prop, collection, collections) => {
96
- switch (prop.type) {
97
- case "string": {
98
- const stringProp = prop;
99
- if (stringProp.enum) {
100
- const tableName = getTableName(collection);
101
- const colName = resolveColumnName(propName, prop);
102
- return `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${tableName}_${colName}"`;
103
- }
104
- if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") return "UUID";
105
- if (stringProp.columnType === "char") return `CHAR(${resolveStringColumnLength(stringProp)})`;
106
- if (stringProp.columnType === "varchar") return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
107
- return "TEXT";
108
- }
109
- case "number": {
110
- const numProp = prop;
111
- const isId = isIdProperty(propName, prop, collection);
112
- if ("isId" in numProp && numProp.isId === "increment") return "INTEGER GENERATED BY DEFAULT AS IDENTITY";
113
- if (numProp.columnType) {
114
- if (numProp.columnType === "double precision") return "DOUBLE PRECISION";
115
- return numProp.columnType.toUpperCase();
116
- }
117
- return numProp.validation?.integer || isId ? "INTEGER" : "NUMERIC";
118
- }
119
- case "boolean": return "BOOLEAN";
120
- case "date": {
121
- const dateProp = prop;
122
- if (dateProp.columnType === "date") return "DATE";
123
- if (dateProp.columnType === "time") return "TIME";
124
- return "TIMESTAMP WITH TIME ZONE";
125
- }
126
- case "map": return prop.columnType === "json" ? "JSON" : "JSONB";
127
- case "geopoint": return "JSONB";
128
- case "array": {
129
- const arrayProp = prop;
130
- let colType = arrayProp.columnType;
131
- if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
132
- const ofProp = arrayProp.of;
133
- if (ofProp.type === "string") colType = "text[]";
134
- else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
135
- else if (ofProp.type === "boolean") colType = "boolean[]";
136
- }
137
- if (colType === "json") return "JSON";
138
- if (colType === "text[]") return "TEXT[]";
139
- if (colType === "integer[]") return "INTEGER[]";
140
- if (colType === "boolean[]") return "BOOLEAN[]";
141
- if (colType === "numeric[]") return "NUMERIC[]";
142
- return "JSONB";
143
- }
144
- case "vector": return `VECTOR(${prop.dimensions})`;
145
- case "binary": return "BYTEA";
146
- case "relation": {
147
- const refProp = prop;
148
- const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
149
- if (relation?.kind !== "belongsTo") throw new Error(`Relation ${propName} does not put a column on this table (only \`belongsTo\` does)`);
150
- let targetCollection;
151
- try {
152
- targetCollection = relation.target();
153
- } catch {
154
- return "TEXT";
155
- }
156
- const pkProp = getPrimaryKeyProp(targetCollection);
157
- return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
158
- }
159
- case "reference": {
160
- const refProp = prop;
161
- const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
162
- if (!targetCollection) return "TEXT";
163
- const pkProp = getPrimaryKeyProp(targetCollection);
164
- return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
165
- }
166
- default: throw new Error(`No Postgres column type for property '${propName}' of type '${prop.type}' in collection '${collection.slug}'. Add a case to \`getSqlColumnType\` (and to \`getDrizzleColumn\`, which must agree).`);
167
- }
168
- };
169
- var schemaOfCollection = (collection) => isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
170
- var bareTableName = (name) => name.includes(".") ? name.split(".").pop() : name;
171
- /**
172
- * Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
173
- *
174
- * This is not cosmetic, and not really a naming choice at all — it is agreeing
175
- * with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
176
- * succeeds and records the truncated form, so the untruncated name this used to
177
- * derive matched nothing in the catalogue. Boot-ensure compares its planned
178
- * constraints against `readExistingSchema`, which reads catalogue names, so the
179
- * comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
180
- * constraint, forever, and got "already exists" every time. Non-fatal (foreign
181
- * keys are the one action allowed to fail) and therefore permanent — an error in
182
- * the log on every restart of a project whose table and column names happened to
183
- * be long.
184
- *
185
- * Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
186
- * character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
187
- */
188
- var foreignKeyPlan = (args) => {
189
- const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
190
- const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
191
- return {
192
- constraintName,
193
- schema: args.schema,
194
- table: args.table,
195
- column: args.column,
196
- targetSchema: args.targetSchema,
197
- targetTable: args.targetTable,
198
- targetColumn: args.targetColumn,
199
- sql: `ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
200
- };
201
- };
202
- /**
203
- * The FK columns the declared collections own — one entry per `relation`
204
- * (`belongsTo` side) or `reference` property.
205
- *
206
- * Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
207
- * create the same columns with the same names, types and constraints. Before
208
- * this it skipped them outright, which was survivable only because `db push`
209
- * always followed; on a managed tenant nothing follows, so a table arrived
210
- * without the column its own collection reads and wrote 400 on every insert.
211
- *
212
- * A relation whose target is not in the bundle yields no column at all (the
213
- * generator returns early on an unresolvable target); a `reference` whose target
214
- * is unknown yields the column without a constraint. Both mirror the generator
215
- * exactly — a divergence here is a schema fork between boot and `db push`.
216
- */
217
- var planRelationalColumns = (allCollections) => {
218
- const collections = relationalCollections(allCollections);
219
- const plans = [];
220
- for (const collection of collections) {
221
- const tableName = getTableName(collection);
222
- if (!tableName) continue;
223
- const schema = schemaOfCollection(collection);
224
- const table = bareTableName(tableName);
225
- for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
226
- const prop = rawProp;
227
- if (prop.type === "relation") {
228
- const refProp = prop;
229
- const relInfo = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
230
- if (relInfo?.kind !== "belongsTo") continue;
231
- if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
232
- let targetCollection;
233
- try {
234
- targetCollection = relInfo.target();
235
- } catch {
236
- continue;
237
- }
238
- if (!targetCollection) continue;
239
- const required = prop.validation?.required;
240
- const relationName = refProp.relation?.relationName ?? propName;
241
- const legacyKey = legacyForeignKeyName(relationName);
242
- const derived = relInfo.localKey === generateForeignKeyName(relationName);
243
- plans.push({
244
- schema,
245
- table,
246
- column: relInfo.localKey,
247
- legacyColumn: derived && legacyKey !== relInfo.localKey ? legacyKey : void 0,
248
- type: getSqlColumnType(propName, prop, collection, collections),
249
- foreignKey: foreignKeyPlan({
250
- schema,
251
- table,
252
- column: relInfo.localKey,
253
- targetSchema: schemaOfCollection(targetCollection),
254
- targetTable: bareTableName(getTableName(targetCollection)),
255
- targetColumn: getPrimaryKeyName(targetCollection),
256
- onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
257
- onUpdate: relInfo.onUpdate
258
- })
259
- });
260
- } else if (prop.type === "reference") {
261
- const refProp = prop;
262
- const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
263
- const column = resolveColumnName(propName, prop);
264
- const type = getSqlColumnType(propName, prop, collection, collections);
265
- const required = prop.validation?.required;
266
- plans.push({
267
- schema,
268
- table,
269
- column,
270
- type,
271
- foreignKey: targetCollection ? foreignKeyPlan({
272
- schema,
273
- table,
274
- column,
275
- targetSchema: schemaOfCollection(targetCollection),
276
- targetTable: bareTableName(getTableName(targetCollection)),
277
- targetColumn: getPrimaryKeyName(targetCollection),
278
- onDelete: required ? "CASCADE" : "SET NULL"
279
- }) : void 0
280
- });
281
- }
282
- }
283
- }
284
- return plans;
285
- };
286
- /**
287
- * The junction tables a bundle's many-to-many relations imply.
288
- *
289
- * Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
290
- * comes from, so a table created here always has policies planned for it — a
291
- * junction with row-level security left off is readable and writable by every
292
- * signed-in user, which is why the two must ship together.
293
- */
294
- var planJunctionTables = (allCollections) => {
295
- const collections = relationalCollections(allCollections);
296
- const plans = [];
297
- for (const spec of resolveJunctionSpecs(collections).values()) {
298
- const [source, target] = spec.endpoints;
299
- const legacyFor = (endpoint) => {
300
- const slug = toSnakeCase(endpoint.collection.slug ?? endpoint.collection.name ?? "");
301
- const legacy = legacyForeignKeyName(slug);
302
- return endpoint.junctionColumn === generateForeignKeyName(slug) && legacy !== endpoint.junctionColumn ? legacy : void 0;
303
- };
304
- const columns = [{
305
- name: source.junctionColumn,
306
- type: junctionKeyType(source.collection),
307
- legacyName: legacyFor(source)
308
- }, {
309
- name: target.junctionColumn,
310
- type: junctionKeyType(target.collection),
311
- legacyName: legacyFor(target)
312
- }];
313
- const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
314
- plans.push({
315
- schema: spec.schema,
316
- table: spec.table,
317
- columns,
318
- createTable: `CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` + columns.map((c) => `"${c.name}" ${c.type} NOT NULL`).join(", ") + `, PRIMARY KEY (${columns.map((c) => `"${c.name}"`).join(", ")}));`,
319
- foreignKeys: [source, target].map((endpoint, i) => foreignKeyPlan({
320
- schema: spec.schema,
321
- table: spec.table,
322
- column: columns[i].name,
323
- targetSchema: schemaOfCollection(endpoint.collection),
324
- targetTable: bareTableName(getTableName(endpoint.collection)),
325
- targetColumn: getPrimaryKeyName(endpoint.collection),
326
- onDelete
327
- }))
328
- });
329
- }
330
- return plans;
331
- };
332
- /**
333
- * The per-table RLS plan for the *declared* collections, as executable
334
- * statements — what the managed runtime applies at boot so a freshly
335
- * provisioned tenant database serves data instead of 401ing every read.
336
- *
337
- * Mirrors {@link generatePostgresPoliciesDdl} exactly (same
338
- * `generatePolicyStatements`, same enable-RLS, same effective rules, same
339
- * derived junction rules), so boot and `db push` produce identical policies from
340
- * identical collections.
341
- *
342
- * Junction tables are included, and have to be: boot creates them now
343
- * ({@link planJunctionTables}), and a junction with RLS left off is readable and
344
- * writable by every signed-in user. A junction whose table is still absent is
345
- * skipped by the applier, not planned away here.
346
- */
347
- var planCollectionPolicies = (allCollections) => {
348
- const collections = relationalCollections(allCollections);
349
- const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
350
- const plans = [];
351
- const seen = /* @__PURE__ */ new Set();
352
- for (const collection of collections) {
353
- const tableName = getTableName(collection);
354
- if (!tableName) continue;
355
- const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
356
- const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
357
- const qualified = `${schema}.${baseTableName}`;
358
- if (seen.has(qualified)) continue;
359
- seen.add(qualified);
360
- const policyStatements = [];
361
- for (const rule of getEffectiveSecurityRules(collection)) policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
362
- plans.push({
363
- schema,
364
- table: baseTableName,
365
- qualified,
366
- enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
367
- policyStatements
368
- });
369
- }
370
- for (const spec of resolveJunctionSpecs(collections).values()) {
371
- const qualified = `${spec.schema}.${spec.table}`;
372
- if (seen.has(qualified)) continue;
373
- seen.add(qualified);
374
- const junctionCollection = getJunctionCollectionConfig(spec);
375
- const policyStatements = [];
376
- for (const rule of getJunctionSecurityRules(spec)) policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
377
- plans.push({
378
- schema: spec.schema,
379
- table: spec.table,
380
- qualified,
381
- enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
382
- policyStatements
383
- });
384
- }
385
- return plans;
386
- };
387
- //#endregion
388
- //#region src/schema/ensure-collection-tables.ts
389
- /**
390
- * Bringing a database up to date with a bundle's collections, additively.
391
- *
392
- * ## Why this exists
393
- *
394
- * A managed runtime boots someone else's compiled project against a database it
395
- * has never seen. Auth tables are ensured at boot already, but collection tables
396
- * were not created by anything: the platform ran the app and every `/api/data/*`
397
- * request answered 500 on a missing relation. `rebase db push` cannot help — it
398
- * is an Atlas-driven CLI command, and the runtime image ships no CLI.
399
- *
400
- * ## Why additive-only, forever
401
- *
402
- * This runs unattended, against a database with customers' data in it, with no
403
- * human reading a diff. So it may only ever do things that cannot lose data:
404
- * create a missing table, add a missing column, create a missing enum type.
405
- *
406
- * It will **never** drop a table or a column, narrow a type, or alter a
407
- * constraint. A removed field leaves its column behind; a renamed field looks
408
- * like an addition and the old column stays. That is the correct trade for an
409
- * automated path — the alternative is an unattended process that can silently
410
- * destroy a column, which is precisely the failure `db push` was hardened
411
- * against. Destructive changes stay a deliberate, human-reviewed migration.
412
- *
413
- * Because of that, this is safe to run on every boot, and re-running it is a
414
- * no-op.
415
- */
416
- /** Postgres identifiers this module is willing to interpolate. */
417
- var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
418
- function assertSafeIdentifier(value, what) {
419
- if (!SAFE_IDENTIFIER.test(value)) throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
420
- return value;
421
- }
422
- function schemaOf(collection) {
423
- return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
424
- }
425
- function qualified(collection) {
426
- return `${schemaOf(collection)}.${getTableName(collection)}`;
427
- }
428
- /**
429
- * Enum types a collection's properties require, as `schema.typename`.
430
- *
431
- * Named exactly as the DDL generator names them (`<table>_<column>`), because
432
- * a column added here has to reference the same type the generator would have
433
- * created — a second, differently-named type for the same field would be a
434
- * silent schema fork.
435
- */
436
- function requiredEnums(collection) {
437
- const table = getTableName(collection);
438
- const schema = schemaOf(collection);
439
- const out = [];
440
- for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
441
- const p = prop;
442
- if (!("enum" in p) || !p.enum) continue;
443
- if (p.type !== "string" && p.type !== "number") continue;
444
- const values = p.enum.map((entry) => entry && typeof entry === "object" && "id" in entry ? String(entry.id) : String(entry)).filter((v) => v.length > 0);
445
- if (values.length === 0) continue;
446
- out.push({
447
- name: `${schema}.${table}_${resolveColumnName(propName, p)}`,
448
- values
449
- });
450
- }
451
- return out;
452
- }
453
- /**
454
- * Decide what to add. Pure — the caller supplies what exists and runs the result.
455
- *
456
- * Ordering matters and is deliberate: enum types before the tables and columns
457
- * that reference them, tables before the columns added to other tables (a new
458
- * table may be the target of a relation), and nothing is emitted twice.
459
- */
460
- function planCollectionSchemaEnsure(allCollections, existing) {
461
- assertSearchIsPostgresOnly(allCollections);
462
- const collections = relationalCollections(allCollections);
463
- const actions = [];
464
- const plannedEnums = /* @__PURE__ */ new Set();
465
- for (const collection of collections) for (const { name, values } of requiredEnums(collection)) {
466
- if (existing.enums.has(name) || plannedEnums.has(name)) continue;
467
- plannedEnums.add(name);
468
- const [schema, typeName] = name.split(".");
469
- actions.push({
470
- kind: "create-enum",
471
- target: name,
472
- sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});`
473
- });
474
- }
475
- const searchSpecs = collections.map((c) => buildSearchColumnSpec(c)).filter((spec) => spec !== void 0);
476
- const plannedExtensions = /* @__PURE__ */ new Set();
477
- for (const spec of searchSpecs) for (const statement of searchExtensionStatements(spec)) {
478
- if (plannedExtensions.has(statement)) continue;
479
- plannedExtensions.add(statement);
480
- actions.push({
481
- kind: "create-extension",
482
- target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""),
483
- sql: statement
484
- });
485
- }
486
- const plannedFunctions = /* @__PURE__ */ new Set();
487
- for (const spec of searchSpecs) for (const statement of searchHelperFunctions(spec)) {
488
- if (plannedFunctions.has(statement)) continue;
489
- plannedFunctions.add(statement);
490
- actions.push({
491
- kind: "create-function",
492
- target: statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN,
493
- sql: statement
494
- });
495
- }
496
- const created = /* @__PURE__ */ new Set();
497
- for (const collection of collections) {
498
- const key = qualified(collection);
499
- if (existing.tables.has(key) || created.has(key)) continue;
500
- created.add(key);
501
- const schema = schemaOf(collection);
502
- const table = getTableName(collection);
503
- const idEntry = Object.entries(collection.properties ?? {}).find(([n, p]) => isIdProperty(n, p, collection));
504
- const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1]) : "id";
505
- const idProp = idEntry?.[1];
506
- let idDef = `"${idName}" ${idProp ? getSqlColumnType(idEntry[0], idProp, collection, collections) : "TEXT"} PRIMARY KEY`;
507
- if (idProp?.type === "string" && idProp.isId === "uuid") idDef += " DEFAULT gen_random_uuid()";
508
- actions.push({
509
- kind: "create-table",
510
- target: key,
511
- sql: `CREATE TABLE IF NOT EXISTS "${schema}"."${table}" (${idDef});`
512
- });
513
- }
514
- const junctions = planJunctionTables(collections);
515
- for (const junction of junctions) {
516
- const key = `${junction.schema}.${junction.table}`;
517
- if (existing.tables.has(key) || created.has(key)) continue;
518
- created.add(key);
519
- actions.push({
520
- kind: "create-table",
521
- target: key,
522
- sql: junction.createTable
523
- });
524
- }
525
- const legacyForeignKeys = [];
526
- /**
527
- * Move a relation column that is only missing because it was renamed.
528
- *
529
- * Returns true when it handled the column, so the caller skips the ordinary
530
- * ADD. Adding here would be the wrong move and a quiet one: the data is in
531
- * the old column, `ADD COLUMN` creates the new one empty beside it, every
532
- * statement succeeds, and the relation reads the empty one. A rename is
533
- * metadata-only in Postgres, keeps the values, and carries the column's
534
- * indexes and constraints with it.
535
- *
536
- * Only ever reached when the new name is absent and the old name is
537
- * present, so there is nothing to overwrite and nothing to choose between.
538
- */
539
- const renameLegacyColumn = (key, schema, table, column, legacyName) => {
540
- const present = existing.tables.get(key);
541
- if (!legacyName || !present) return false;
542
- if (present.has(column) || !present.has(legacyName)) return false;
543
- legacyForeignKeys.push({
544
- table: key,
545
- expected: column,
546
- legacy: legacyName
547
- });
548
- actions.push({
549
- kind: "rename-column",
550
- target: `${key}.${column}`,
551
- sql: `ALTER TABLE "${schema}"."${table}" RENAME COLUMN "${legacyName}" TO "${column}";`
552
- });
553
- return true;
554
- };
555
- const addColumn = (key, schema, table, column, definition) => {
556
- if (existing.tables.get(key)?.has(column)) return;
557
- actions.push({
558
- kind: "add-column",
559
- target: `${key}.${column}`,
560
- sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
561
- });
562
- };
563
- for (const collection of collections) {
564
- const key = qualified(collection);
565
- const schema = schemaOf(collection);
566
- const table = getTableName(collection);
567
- const fresh = created.has(key);
568
- const auth = isAuthCollection(collection);
569
- for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
570
- const p = prop;
571
- if (isIdProperty(propName, p, collection)) continue;
572
- if (p.type === "reference" || p.type === "relation") continue;
573
- const column = resolveColumnName(propName, p);
574
- const authDefinition = auth ? authUsersColumnDefinition(column) : void 0;
575
- if (authDefinition) {
576
- addColumn(key, schema, table, column, authDefinition);
577
- continue;
578
- }
579
- let definition = getSqlColumnType(propName, p, collection, collections);
580
- if (fresh && p.validation?.unique) definition += " UNIQUE";
581
- const autoValue = p.autoValue;
582
- if (p.type === "date" && (autoValue === "on_create" || autoValue === "on_update")) definition += " DEFAULT now()";
583
- if (fresh && p.validation?.required) definition += " NOT NULL";
584
- addColumn(key, schema, table, column, definition);
585
- }
586
- if (auth) {
587
- const declared = new Set(Object.entries(collection.properties ?? {}).map(([name, prop]) => resolveColumnName(name, prop)));
588
- for (const spec of AUTH_USERS_COLUMNS) {
589
- if (declared.has(spec.column)) continue;
590
- addColumn(key, schema, table, spec.column, authUsersColumnSql(spec));
591
- }
592
- }
593
- }
594
- const searchDrift = [];
595
- const searchAdopted = [];
596
- for (const spec of searchSpecs) {
597
- const key = `${spec.schema}.${spec.table}`;
598
- const definitions = { [spec.column]: `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED` };
599
- if (spec.fuzzy) definitions[spec.fuzzy.column] = `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`;
600
- for (const stamp of searchColumnStamps(spec)) {
601
- const definition = definitions[stamp.column];
602
- const exists = existing.tables.get(key)?.has(stamp.column) === true;
603
- const recorded = existing.columnComments?.get(`${key}.${stamp.column}`);
604
- if (exists && recorded?.startsWith("rebase:search:v1:") && recorded !== stamp.fingerprint) {
605
- searchDrift.push({
606
- table: key,
607
- column: stamp.column,
608
- found: recorded,
609
- expected: stamp.fingerprint,
610
- rebuild: [
611
- `ALTER TABLE "${spec.schema}"."${spec.table}" DROP COLUMN "${stamp.column}";`,
612
- `ALTER TABLE "${spec.schema}"."${spec.table}" ADD COLUMN "${stamp.column}" ${definition};`,
613
- stamp.sql
614
- ]
615
- });
616
- continue;
617
- }
618
- addColumn(key, spec.schema, spec.table, stamp.column, definition);
619
- if (exists && recorded === void 0) searchAdopted.push({
620
- table: key,
621
- column: stamp.column
622
- });
623
- if (recorded !== stamp.fingerprint) actions.push({
624
- kind: "comment-column",
625
- target: `${key}.${stamp.column}`,
626
- sql: stamp.sql
627
- });
628
- }
629
- }
630
- for (const junction of junctions) {
631
- const key = `${junction.schema}.${junction.table}`;
632
- if (created.has(key)) continue;
633
- for (const column of junction.columns) {
634
- if (renameLegacyColumn(key, junction.schema, junction.table, column.name, column.legacyName)) continue;
635
- addColumn(key, junction.schema, junction.table, column.name, column.type);
636
- }
637
- }
638
- for (const relational of planRelationalColumns(collections)) {
639
- const relKey = `${relational.schema}.${relational.table}`;
640
- if (renameLegacyColumn(relKey, relational.schema, relational.table, relational.column, relational.legacyColumn)) continue;
641
- addColumn(relKey, relational.schema, relational.table, relational.column, relational.type);
642
- }
643
- const knownConstraints = existing.constraints ?? /* @__PURE__ */ new Set();
644
- const plannedConstraints = /* @__PURE__ */ new Set();
645
- const foreignKeys = [...planRelationalColumns(collections).map((r) => r.foreignKey), ...junctions.flatMap((j) => j.foreignKeys)];
646
- for (const fk of foreignKeys) {
647
- if (!fk) continue;
648
- const name = `${fk.schema}.${fk.table}.${fk.constraintName}`;
649
- if (knownConstraints.has(name) || plannedConstraints.has(name)) continue;
650
- plannedConstraints.add(name);
651
- actions.push({
652
- kind: "add-constraint",
653
- target: `${fk.schema}.${fk.table}.${fk.constraintName}`,
654
- sql: fk.sql
655
- });
656
- }
657
- for (const spec of searchSpecs) for (const statement of searchIndexStatements(spec)) actions.push({
658
- kind: "create-index",
659
- target: `${spec.schema}.${spec.table}`,
660
- sql: statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
661
- });
662
- return {
663
- actions,
664
- statements: actions.map((a) => a.sql),
665
- legacyForeignKeys,
666
- searchDrift,
667
- searchAdopted
668
- };
669
- }
670
- /** Read what the database has, for the schemas the collections live in. */
671
- async function readExistingSchema(client, schemas) {
672
- const tables = /* @__PURE__ */ new Map();
673
- const enums = /* @__PURE__ */ new Set();
674
- if (schemas.length === 0) return {
675
- tables,
676
- enums
677
- };
678
- const inList = schemas.map((schema) => `'${assertSafeIdentifier(schema, "schema name")}'`).join(", ");
679
- const { rows: columns } = await client.query(`SELECT table_schema, table_name, column_name
680
- FROM information_schema.columns
681
- WHERE table_schema IN (${inList})`);
682
- for (const row of columns) {
683
- const key = `${row.table_schema}.${row.table_name}`;
684
- if (!tables.has(key)) tables.set(key, /* @__PURE__ */ new Set());
685
- tables.get(key).add(row.column_name);
686
- }
687
- const { rows: enumRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name
688
- FROM pg_type t
689
- JOIN pg_namespace n ON t.typnamespace = n.oid
690
- WHERE t.typtype = 'e' AND n.nspname IN (${inList})`);
691
- for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
692
- const constraints = /* @__PURE__ */ new Set();
693
- const { rows: constraintRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
694
- FROM pg_constraint con
695
- JOIN pg_class c ON con.conrelid = c.oid
696
- JOIN pg_namespace n ON c.relnamespace = n.oid
697
- WHERE n.nspname IN (${inList})`);
698
- for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
699
- const columnComments = /* @__PURE__ */ new Map();
700
- const { rows: commentRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, d.description AS comment
701
- FROM pg_description d
702
- JOIN pg_class c ON d.objoid = c.oid
703
- JOIN pg_namespace n ON c.relnamespace = n.oid
704
- JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid
705
- WHERE d.objsubid > 0 AND n.nspname IN (${inList})`);
706
- for (const row of commentRows) {
707
- if (row.comment == null) continue;
708
- columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
709
- }
710
- return {
711
- tables,
712
- enums,
713
- constraints,
714
- columnComments
715
- };
716
- }
717
- /**
718
- * What to tell an operator whose `search` block no longer matches its column.
719
- *
720
- * Every line here is doing work: naming the collection is not enough, because
721
- * the symptom (a search that finds nothing) points at the data, not the schema;
722
- * and the remediation has to be exact, because it is a table rewrite the
723
- * operator is being asked to schedule rather than discover.
724
- */
725
- function searchDriftMessage(drift) {
726
- return "The `search` block changed after its generated column was created, and Postgres cannot alter a generated expression in place.\nRebase will not rebuild it for you: dropping and re-adding a STORED generated column rewrites the whole table under an ACCESS EXCLUSIVE lock and rebuilds its GIN index, which is an outage this unattended path may not schedule on your behalf.\nUntil it is rebuilt the column keeps indexing the previous fields, weights and language — searches for anything added since return nothing, which reads from outside as \"no such row\".\nRun these (or revert the block to what the column was built from), then boot again:\n" + drift.map((d) => ` "${d.table}"."${d.column}" was generated from a different \`search\` block (recorded ${d.found}, current ${d.expected}).\n` + d.rebuild.map((s) => ` ${s}`).join("\n")).join("\n") + "\n The GIN index is dropped with the column and recreated concurrently on the next boot.";
727
- }
728
- /**
729
- * The missing-pgvector explanation, appended to the error that reveals it.
730
- *
731
- * A `{ type: "vector" }` property compiles to `VECTOR(n)`, and nothing in the
732
- * OSS pipeline installs pgvector — not this ensure, not `db push`, not the
733
- * scaffold's `postgres:18-alpine`, which does not ship it. Installing an
734
- * extension on someone's database is a decision with a deployment behind it
735
- * (image, superuser, cloud allow-list), so this path stays a refusal; what it
736
- * must not stay is a bare `type "vector" does not exist` on a crash-looping
737
- * pod, which names nothing the reader can act on.
738
- */
739
- function vectorExtensionHint(message) {
740
- if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
741
- return "\n pgvector is not installed on this database, and Rebase does not install it: it is a server extension, so it needs an image that ships it (e.g. `pgvector/pgvector:pg18` — the scaffold's `postgres:18-alpine` does not) and a role allowed to run `CREATE EXTENSION vector;`. Install it once, then boot again. Note also that Rebase creates no ANN index for a vector column, so `vectorSearch` is an exact scan.";
742
- }
743
- /**
744
- * Bring the database up to date. Returns what it did.
745
- *
746
- * Each statement runs on its own rather than in one transaction: they are all
747
- * independently safe and idempotent, and a single failure (an enum label that
748
- * cannot be added, say) should not roll back the tables that were created fine.
749
- * The error is surfaced with the statement that caused it.
750
- */
751
- async function ensureCollectionTables(client, collections, log) {
752
- const schemas = Array.from(/* @__PURE__ */ new Set([...collections.map(schemaOf), ...planJunctionTables(collections).map((j) => j.schema)]));
753
- for (const schema of schemas) {
754
- assertSafeIdentifier(schema, "schema name");
755
- if (schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}";`);
756
- }
757
- const plan = planCollectionSchemaEnsure(collections, await readExistingSchema(client, schemas));
758
- const failures = [];
759
- for (const legacy of plan.legacyForeignKeys) {
760
- const message = `Renaming "${legacy.table}"."${legacy.legacy}" to "${legacy.expected}". The old name is the one Rebase derived for this relation before it singularized properly; the column keeps its data, indexes and constraints. To keep the old name instead, set \`localKey: "${legacy.legacy}"\` on the relation and this will stop.`;
761
- logger.info(`[schema] ${message}`);
762
- log?.(message);
763
- }
764
- if (plan.searchDrift.length > 0) throw new Error(searchDriftMessage(plan.searchDrift));
765
- for (const adopted of plan.searchAdopted) {
766
- const message = `Adopting the existing generated column "${adopted.table}"."${adopted.column}" and recording what the current \`search\` block would generate. Any later change to that block will be detected and refused; a change made *before* this version was deployed cannot be, so if search has been missing content, rebuild the column once: ALTER TABLE "${adopted.table.split(".").join("\".\"")}" DROP COLUMN "${adopted.column}"; and boot again.`;
767
- logger.info(`[schema] ${message}`);
768
- log?.(message);
769
- }
770
- if (plan.actions.length === 0) {
771
- log?.("Schema is up to date; nothing to create.");
772
- return {
773
- ...plan,
774
- failures
775
- };
776
- }
777
- for (const action of plan.actions) try {
778
- if (await applyAction(client, action)) log?.(`${action.kind}: ${action.target}`);
779
- else log?.(`${action.kind}: ${action.target} (already created by a peer)`);
780
- } catch (err) {
781
- const message = err instanceof Error ? err.message : String(err);
782
- if (action.kind === "add-constraint" || action.kind === "comment-column") {
783
- failures.push({
784
- kind: action.kind,
785
- target: action.target,
786
- error: message
787
- });
788
- continue;
789
- }
790
- throw new Error(`Failed to ${action.kind} ${action.target}: ${message}${vectorExtensionHint(message)}\n ${action.sql}`);
791
- }
792
- return {
793
- ...plan,
794
- failures
795
- };
796
- }
797
- /** Attempts per action, including the first. Matches the server's bootstraps. */
798
- var DDL_ATTEMPTS = 4;
799
- /**
800
- * Run one planned statement, surviving a simultaneous boot.
801
- *
802
- * Every statement in a plan is written to be idempotent, and that is not the
803
- * same as being safe to run concurrently: `CREATE … IF NOT EXISTS` reads the
804
- * catalog and then writes to it as two steps, so peers starting together both
805
- * see "absent" and the loser gets a duplicate key on a *catalog* index. Measured
806
- * against Postgres 18: five instances, 8 of 10 calls lost. `CREATE TYPE` is
807
- * worse, because Postgres has no `IF NOT EXISTS` for it at all.
808
- *
809
- * What made that fatal here rather than merely noisy is the loop this sits in.
810
- * A losing statement threw, and the throw abandoned **every remaining action in
811
- * the plan** — so a replica that lost one race came up missing tables it never
812
- * attempted, and the boot log blamed the one statement that failed.
813
- *
814
- * @returns `true` if this process applied the statement, `false` if a peer had
815
- * already created the object. The distinction is only for the log; both mean
816
- * the object is now there.
817
- * @throws the original error for anything that is not a race — a syntax error, a
818
- * permission failure, a unique constraint the customer's own rows violate.
819
- */
820
- async function applyAction(client, action) {
821
- for (let attempt = 1;; attempt++) try {
822
- await client.query(action.sql);
823
- return true;
824
- } catch (err) {
825
- if (isDuplicateObjectRace(err)) {
826
- logger.debug(`[schema] ${action.kind} ${action.target}: already created by another instance`);
827
- return false;
828
- }
829
- if (isConcurrentDdlRace(err) && attempt < DDL_ATTEMPTS) {
830
- logger.debug(`[schema] ${action.kind} ${action.target}: lost a race with another instance (attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`);
831
- await new Promise((resolve) => setTimeout(resolve, 40 * attempt * (1 + Math.random())));
832
- continue;
833
- }
834
- throw err;
835
- }
836
- }
837
- //#endregion
838
- export { ensureCollectionTables, readExistingSchema, planCollectionPolicies as t };
839
-
840
- //# sourceMappingURL=ensure-collection-tables-BY1pHRD_.js.map