@rebasepro/server-postgres 0.18.0 → 0.18.1
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/BranchService-ByLfM_Tt.js +360 -0
- package/dist/BranchService-ByLfM_Tt.js.map +1 -0
- package/dist/__vite-browser-external-BnuHet1e.js +46 -0
- package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
- package/dist/arg-Dni7MzLB.js +87 -0
- package/dist/arg-Dni7MzLB.js.map +1 -0
- package/dist/auth-users-columns-D2LBFrMH.js +116 -0
- package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
- package/dist/backup-cli-oM9WQVlV.js +408 -0
- package/dist/backup-cli-oM9WQVlV.js.map +1 -0
- package/dist/backup-service-Br3tqwsy.js +715 -0
- package/dist/backup-service-Br3tqwsy.js.map +1 -0
- package/dist/cli-errors-DxN4yzdH.js +657 -0
- package/dist/cli-errors-DxN4yzdH.js.map +1 -0
- package/dist/cli-output-CNdMql-L.js +47 -0
- package/dist/cli-output-CNdMql-L.js.map +1 -0
- package/dist/cli.js +2020 -0
- package/dist/cli.js.map +1 -0
- package/dist/{collection-index-C7LopMyX.js → collection-index-BHgT_TKd.js} +32 -3
- package/dist/{collection-index-C7LopMyX.js.map → collection-index-BHgT_TKd.js.map} +1 -1
- package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
- package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
- package/dist/databasePoolManager-Bj5FbeAs.js +349 -0
- package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
- package/dist/doctor-D7HWmGzs.js +974 -0
- package/dist/doctor-D7HWmGzs.js.map +1 -0
- package/dist/{ensure-collection-policies-CagOzEPS.js → ensure-collection-policies-2c2NV8zX.js} +5 -25
- package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
- package/dist/ensure-tables-CIojK-Rx.js +650 -0
- package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js +1312 -0
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
- package/dist/index.es.js +849 -10807
- package/dist/index.es.js.map +1 -1
- package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
- package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
- package/dist/policy-drift-kNJ6X2Uj.js +313 -0
- package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
- package/dist/rls-bootstrap-sql-DLDnUjtx.js +7092 -0
- package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
- package/dist/{rls-enforcement-ClJoTboT.js → rls-enforcement-DeYEjYLt.js} +14 -3
- package/dist/{rls-enforcement-ClJoTboT.js.map → rls-enforcement-DeYEjYLt.js.map} +1 -1
- package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/doctor-cli.js +129 -0
- package/dist/schema/doctor-cli.js.map +1 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema.js +24 -0
- package/dist/schema/generate-drizzle-schema.js.map +1 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl.js +138 -0
- package/dist/schema/generate-postgres-ddl.js.map +1 -0
- package/dist/schema/introspect-db.js +866 -0
- package/dist/schema/introspect-db.js.map +1 -0
- package/dist/source-Br7L7GOI.js +410 -0
- package/dist/source-Br7L7GOI.js.map +1 -0
- package/dist/vector-index-DkWxm2de.js +309 -0
- package/dist/vector-index-DkWxm2de.js.map +1 -0
- package/package.json +7 -7
- package/dist/ensure-collection-policies-CagOzEPS.js.map +0 -1
- package/dist/ensure-collection-tables-DbAhrjnG.js +0 -2732
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +0 -1
|
@@ -1,2732 +0,0 @@
|
|
|
1
|
-
import { createRequire as __createRequire } from "module";
|
|
2
|
-
import process from "process";
|
|
3
|
-
const require = __createRequire(import.meta.url);
|
|
4
|
-
import { i as collectionIndexStatements, n as buildCollectionIndexSpecs, r as collectionIndexStatement, t as buildCollectionIndexPlan } from "./collection-index-C7LopMyX.js";
|
|
5
|
-
import { isConcurrentDdlRace, isDuplicateObjectRace, logger } from "@rebasepro/server";
|
|
6
|
-
import { DEFAULT_FUZZY_THRESHOLD, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_WEIGHT, REBASE_SCHEMA, declaredDatabaseExtensions, isManyToMany, isPostgresCollectionConfig } from "@rebasepro/types";
|
|
7
|
-
import { findRelation, getEffectiveSecurityRules, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getTableName, policyToPostgres, relationalCollections, resolveCollectionRelations, resolveJunctionSpecs, resolveStringColumnLength, securityRuleToConditions } from "@rebasepro/common";
|
|
8
|
-
import { generateForeignKeyName, getPolicyNamesForRule, legacyForeignKeyName, toPostgresIdentifier, toSnakeCase } from "@rebasepro/utils";
|
|
9
|
-
import { createHash } from "node:crypto";
|
|
10
|
-
//#region \0rolldown/runtime.js
|
|
11
|
-
var __create = Object.create;
|
|
12
|
-
var __defProp = Object.defineProperty;
|
|
13
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
14
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
15
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
16
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
17
|
-
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
|
|
18
|
-
var __exportAll = (all, no_symbols) => {
|
|
19
|
-
let target = {};
|
|
20
|
-
for (var name in all) __defProp(target, name, {
|
|
21
|
-
get: all[name],
|
|
22
|
-
enumerable: true
|
|
23
|
-
});
|
|
24
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
25
|
-
return target;
|
|
26
|
-
};
|
|
27
|
-
var __copyProps = (to, from, except, desc) => {
|
|
28
|
-
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
29
|
-
key = keys[i];
|
|
30
|
-
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
31
|
-
get: ((k) => from[k]).bind(null, key),
|
|
32
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
return to;
|
|
36
|
-
};
|
|
37
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
38
|
-
value: mod,
|
|
39
|
-
enumerable: true
|
|
40
|
-
}) : target, mod));
|
|
41
|
-
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
|
|
42
|
-
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
43
|
-
throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
|
|
44
|
-
});
|
|
45
|
-
//#endregion
|
|
46
|
-
//#region src/schema/search-column.ts
|
|
47
|
-
/**
|
|
48
|
-
* The one place a collection's `search` block becomes SQL.
|
|
49
|
-
*
|
|
50
|
-
* Four things describe a Postgres table in this codebase — the DDL generator,
|
|
51
|
-
* the Drizzle schema generator, the runtime table builder for BaaS mode, and
|
|
52
|
-
* the boot-time schema ensure — and each of them has, at some point, described
|
|
53
|
-
* a column differently from the others. The `varchar(255)` note in
|
|
54
|
-
* `generate-postgres-ddl-logic` is one such scar: the same property produced a
|
|
55
|
-
* capped column down one path and an uncapped one down the other, and nothing
|
|
56
|
-
* failed until a user hit the cap.
|
|
57
|
-
*
|
|
58
|
-
* So the search column is not implemented four times. It is computed once,
|
|
59
|
-
* here, and every generator renders the same {@link SearchColumnSpec}. There is
|
|
60
|
-
* a test asserting exactly that (`search-column-contract.test.ts`); the point of
|
|
61
|
-
* this module is that the test has something to assert *about*.
|
|
62
|
-
*
|
|
63
|
-
* ## Why the expressions look the way they do
|
|
64
|
-
*
|
|
65
|
-
* A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
|
|
66
|
-
* Postgres is stricter here than intuition. Verified against PostgreSQL 18:
|
|
67
|
-
*
|
|
68
|
-
* | expression | immutable |
|
|
69
|
-
* |-----------------------------------------|-----------|
|
|
70
|
-
* | `to_tsvector('spanish', col)` | yes |
|
|
71
|
-
* | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
|
|
72
|
-
* | `array_to_string(col, ' ')` | **no** |
|
|
73
|
-
* | `col::text` on `text[]` | **no** |
|
|
74
|
-
* | `to_jsonb(col)` | **no** |
|
|
75
|
-
* | `unaccent(col)` | **no** — dictionary lookup is STABLE |
|
|
76
|
-
* | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
|
|
77
|
-
* | `setweight(...) || setweight(...)` | yes |
|
|
78
|
-
*
|
|
79
|
-
* Three of the four things a real search column needs are therefore unavailable
|
|
80
|
-
* directly, which is why {@link searchHelperFunctions} exists: each wraps a
|
|
81
|
-
* stable built-in in an SQL function declared IMMUTABLE. That declaration is a
|
|
82
|
-
* promise, and it is a true one for these three — array joining, JSON string
|
|
83
|
-
* extraction and accent folding are all deterministic for a given input; the
|
|
84
|
-
* built-ins are marked stable only because they must account for element types
|
|
85
|
-
* and dictionaries in general.
|
|
86
|
-
*
|
|
87
|
-
* The alternative was to skip `unaccent` and text arrays entirely. That is not
|
|
88
|
-
* a real option in an accented language: Postgres stems `auditoría` to
|
|
89
|
-
* `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
|
|
90
|
-
* typed without accents misses every row that carries them.
|
|
91
|
-
*/
|
|
92
|
-
/** Schema-qualified so a collection outside `public` still resolves them. */
|
|
93
|
-
var HELPER_SCHEMA = "public";
|
|
94
|
-
/**
|
|
95
|
-
* Names of the helper functions. Frozen: they are recorded in the stored
|
|
96
|
-
* generation expression of every search column ever created, so renaming one
|
|
97
|
-
* orphans every table that already has a search column.
|
|
98
|
-
*/
|
|
99
|
-
var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
|
|
100
|
-
var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
|
|
101
|
-
/** Raised when a `search` block names something that cannot be searched. */
|
|
102
|
-
var SearchConfigError = class extends Error {
|
|
103
|
-
constructor(message) {
|
|
104
|
-
super(message);
|
|
105
|
-
this.name = "SearchConfigError";
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
/** The `search` block of a collection, or undefined when it has none. */
|
|
109
|
-
var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
|
|
110
|
-
/**
|
|
111
|
-
* Refuse a `search` block on a collection this engine does not store.
|
|
112
|
-
*
|
|
113
|
-
* The type only permits one on a `PostgresCollectionConfig`, so TypeScript
|
|
114
|
-
* already stops the ordinary case. This catches the rest — a JS config, a cast,
|
|
115
|
-
* a collection whose `engine` was changed after the block was written — because
|
|
116
|
-
* the alternative is the exact failure the block exists to prevent: a developer
|
|
117
|
-
* who declared what to index, saw no error, and got the substring fallback.
|
|
118
|
-
*
|
|
119
|
-
* Called with *every* collection, before the Postgres ones are filtered out.
|
|
120
|
-
*/
|
|
121
|
-
var assertSearchIsPostgresOnly = (collections) => {
|
|
122
|
-
for (const collection of collections) {
|
|
123
|
-
if (isPostgresCollectionConfig(collection)) continue;
|
|
124
|
-
if (!collection.search) continue;
|
|
125
|
-
const engine = collection.engine ?? "non-postgres";
|
|
126
|
-
throw new SearchConfigError(`${collection.slug}.search: full-text search is a Postgres feature, and this collection is served by \`${engine}\`. Remove the block — it would otherwise look configured while \`.search()\` kept using the default substring match.`);
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
|
|
130
|
-
/**
|
|
131
|
-
* Classify a property for search purposes.
|
|
132
|
-
*
|
|
133
|
-
* Deliberately narrower than `getSqlColumnType`: search only cares whether a
|
|
134
|
-
* value reaches text, and the mapping from property to *physical* type is
|
|
135
|
-
* asserted against `getSqlColumnType` in the contract test rather than
|
|
136
|
-
* duplicated here.
|
|
137
|
-
*
|
|
138
|
-
* Returns null for anything that is not text-bearing, which the caller turns
|
|
139
|
-
* into a boot error naming the property.
|
|
140
|
-
*/
|
|
141
|
-
var classify = (prop) => {
|
|
142
|
-
switch (prop.type) {
|
|
143
|
-
case "string": {
|
|
144
|
-
const sp = prop;
|
|
145
|
-
if (sp.enum) return {
|
|
146
|
-
kind: "text",
|
|
147
|
-
reason: "enum"
|
|
148
|
-
};
|
|
149
|
-
if (sp.isId === "uuid" || sp.columnType === "uuid") return {
|
|
150
|
-
kind: "text",
|
|
151
|
-
reason: "uuid"
|
|
152
|
-
};
|
|
153
|
-
return { kind: "text" };
|
|
154
|
-
}
|
|
155
|
-
case "map":
|
|
156
|
-
if (prop.columnType === "json") return {
|
|
157
|
-
kind: "jsonb",
|
|
158
|
-
reason: "json"
|
|
159
|
-
};
|
|
160
|
-
return { kind: "jsonb" };
|
|
161
|
-
case "array": {
|
|
162
|
-
const ap = prop;
|
|
163
|
-
let colType = ap.columnType;
|
|
164
|
-
if (!colType && ap.of && !Array.isArray(ap.of)) {
|
|
165
|
-
const of = ap.of;
|
|
166
|
-
if (of.type === "string") colType = "text[]";
|
|
167
|
-
else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
|
|
168
|
-
else if (of.type === "boolean") colType = "boolean[]";
|
|
169
|
-
}
|
|
170
|
-
if (colType === "text[]") return { kind: "text_array" };
|
|
171
|
-
if (colType === "json") return {
|
|
172
|
-
kind: "jsonb",
|
|
173
|
-
reason: "json"
|
|
174
|
-
};
|
|
175
|
-
if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
|
|
176
|
-
kind: "text_array",
|
|
177
|
-
reason: "non_text_array"
|
|
178
|
-
};
|
|
179
|
-
return { kind: "jsonb" };
|
|
180
|
-
}
|
|
181
|
-
default: return null;
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
|
|
185
|
-
/** SQL reading one field as plain text, before normalization. */
|
|
186
|
-
var rawTextSql = (field) => {
|
|
187
|
-
const col = `"${field.column}"`;
|
|
188
|
-
if (field.kind === "text") return `coalesce(${col}, '')`;
|
|
189
|
-
if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
|
|
190
|
-
return `${SEARCH_TEXT_FN}(coalesce(${field.jsonPath.length === 0 ? col : field.jsonPath.length === 1 ? `${col} -> ${quote$1(field.jsonPath[0])}` : `${col} #> ${quote$1(`{${field.jsonPath.join(",")}}`)}`}, '{}'::jsonb))`;
|
|
191
|
-
};
|
|
192
|
-
var quote$1 = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
193
|
-
/**
|
|
194
|
-
* Resolve and validate one declared field path.
|
|
195
|
-
*
|
|
196
|
-
* A path that does not resolve throws. The whole point of an explicit block is
|
|
197
|
-
* that the author knows what is indexed; a silently dropped field would make it
|
|
198
|
-
* a guess again, and the failure — a search that returns nothing for content
|
|
199
|
-
* that is plainly in the row — is invisible from the outside.
|
|
200
|
-
*/
|
|
201
|
-
var resolveField = (entry, collection, cfg) => {
|
|
202
|
-
const path = typeof entry === "string" ? entry : entry.path;
|
|
203
|
-
const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? DEFAULT_SEARCH_WEIGHT;
|
|
204
|
-
const where = `${collection.slug}.search`;
|
|
205
|
-
if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
|
|
206
|
-
const [head, ...rest] = path.split(".");
|
|
207
|
-
const prop = collection.properties?.[head];
|
|
208
|
-
if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
|
|
209
|
-
const classified = classify(prop);
|
|
210
|
-
if (!classified) throw new SearchConfigError(`${where}: "${path}" is a \`${prop.type}\` property, which holds no text to search. Searchable kinds are \`string\`, \`string[]\` and \`map\` (or a path inside one).`);
|
|
211
|
-
if (classified.reason === "enum") throw new SearchConfigError(`${where}: "${path}" is an enum. Enums are a fixed vocabulary — filter on them with \`where\` instead, which is exact and uses an index.`);
|
|
212
|
-
if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
|
|
213
|
-
if (classified.reason === "json") throw new SearchConfigError(`${where}: "${path}" is a \`json\` column, and the cast from \`json\` to \`jsonb\` is not immutable, so it cannot feed a generated column. Declare the property as \`jsonb\` (the default) to search it.`);
|
|
214
|
-
if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
|
|
215
|
-
if (rest.length > 0 && classified.kind !== "jsonb") throw new SearchConfigError(`${where}: "${path}" addresses a path inside "${head}", but "${head}" is a \`${prop.type}\` property, not a \`map\`. Only map properties have paths inside them.`);
|
|
216
|
-
const column = columnNameOf(head, prop);
|
|
217
|
-
const textSql = normalize(rawTextSql({
|
|
218
|
-
column,
|
|
219
|
-
jsonPath: rest,
|
|
220
|
-
kind: classified.kind
|
|
221
|
-
}), cfg.unaccent === true);
|
|
222
|
-
const language = cfg.language ?? DEFAULT_SEARCH_LANGUAGE;
|
|
223
|
-
return {
|
|
224
|
-
path,
|
|
225
|
-
column,
|
|
226
|
-
jsonPath: rest,
|
|
227
|
-
kind: classified.kind,
|
|
228
|
-
weight,
|
|
229
|
-
sql: `setweight(to_tsvector(${quote$1(language)}, ${textSql}), ${quote$1(weight)})`,
|
|
230
|
-
textSql
|
|
231
|
-
};
|
|
232
|
-
};
|
|
233
|
-
/**
|
|
234
|
-
* Build the full spec for a collection, or undefined when it has not opted in.
|
|
235
|
-
*
|
|
236
|
-
* Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
|
|
237
|
-
* at boot surface that as a startup failure — a search block that half-works is
|
|
238
|
-
* worse than one that refuses.
|
|
239
|
-
*/
|
|
240
|
-
var buildSearchColumnSpec = (collection) => {
|
|
241
|
-
const cfg = getSearchConfig(collection);
|
|
242
|
-
if (!cfg) return void 0;
|
|
243
|
-
if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) throw new SearchConfigError(`${collection.slug}.search: \`fields\` is empty. Name the properties to index, or remove the \`search\` block to keep the default ILIKE behaviour.`);
|
|
244
|
-
const table = getTableName(collection);
|
|
245
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
246
|
-
const column = cfg.column ?? DEFAULT_SEARCH_COLUMN;
|
|
247
|
-
if (collection.properties?.[column]) throw new SearchConfigError(`${collection.slug}.search: the generated column "${column}" collides with a declared property of the same name. Set \`search.column\` to something else.`);
|
|
248
|
-
const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
|
|
249
|
-
const seen = /* @__PURE__ */ new Set();
|
|
250
|
-
for (const f of fields) {
|
|
251
|
-
if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
|
|
252
|
-
seen.add(f.path);
|
|
253
|
-
}
|
|
254
|
-
const extensions = [];
|
|
255
|
-
if (cfg.unaccent) extensions.push("unaccent");
|
|
256
|
-
if (cfg.fuzzy) extensions.push("pg_trgm");
|
|
257
|
-
const spec = {
|
|
258
|
-
schema,
|
|
259
|
-
table,
|
|
260
|
-
column,
|
|
261
|
-
language: cfg.language ?? DEFAULT_SEARCH_LANGUAGE,
|
|
262
|
-
unaccent: cfg.unaccent === true,
|
|
263
|
-
fields,
|
|
264
|
-
expression: fields.map((f) => f.sql).join(" || "),
|
|
265
|
-
indexName: toPostgresIdentifier(`${table}_${column}_gin`),
|
|
266
|
-
extensions
|
|
267
|
-
};
|
|
268
|
-
if (cfg.fuzzy) {
|
|
269
|
-
const fuzzyColumn = `${column}_text`;
|
|
270
|
-
if (collection.properties?.[fuzzyColumn]) throw new SearchConfigError(`${collection.slug}.search: \`fuzzy\` needs the column "${fuzzyColumn}", which collides with a declared property. Set \`search.column\` to something else.`);
|
|
271
|
-
spec.fuzzy = {
|
|
272
|
-
column: fuzzyColumn,
|
|
273
|
-
expression: fields.map((f) => f.textSql).join(" || ' ' || "),
|
|
274
|
-
indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
|
|
275
|
-
threshold: cfg.fuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
return spec;
|
|
279
|
-
};
|
|
280
|
-
/**
|
|
281
|
-
* The IMMUTABLE wrappers the generated expressions call.
|
|
282
|
-
*
|
|
283
|
-
* `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
|
|
284
|
-
* than an error, and idempotent for the same reason every other boot-time DDL
|
|
285
|
-
* statement here is.
|
|
286
|
-
*
|
|
287
|
-
* The bodies are stable built-ins wrapped in an immutable promise — see the
|
|
288
|
-
* module comment for why that promise is sound. `STRICT` matters: it makes NULL
|
|
289
|
-
* in mean NULL out without executing the body, which is what the `coalesce` at
|
|
290
|
-
* each call site then absorbs.
|
|
291
|
-
*/
|
|
292
|
-
var searchHelperFunctions = (spec) => {
|
|
293
|
-
const statements = [`CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(text[]) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT array_to_string($1, ' ') $$;`, `CREATE OR REPLACE FUNCTION ${SEARCH_TEXT_FN}(jsonb) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT coalesce(string_agg(v, ' '), '')\n FROM jsonb_array_elements_text(jsonb_path_query_array($1, 'strict $.**?(@.type() == "string")')) AS v $$;`];
|
|
294
|
-
if (spec.unaccent) statements.push(`CREATE OR REPLACE FUNCTION ${SEARCH_UNACCENT_FN}(text) RETURNS text\n LANGUAGE sql IMMUTABLE STRICT PARALLEL SAFE AS\n $$ SELECT ${HELPER_SCHEMA}.unaccent('${HELPER_SCHEMA}.unaccent'::regdictionary, $1) $$;`);
|
|
295
|
-
return statements;
|
|
296
|
-
};
|
|
297
|
-
/**
|
|
298
|
-
* `CREATE EXTENSION` statements the spec's expressions depend on.
|
|
299
|
-
*
|
|
300
|
-
* `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
|
|
301
|
-
* `CREATE EXTENSION` installs into the first schema on `search_path`, which
|
|
302
|
-
* defaults to `"$user", public` — and the scaffold's database role is named
|
|
303
|
-
* `rebase`, the same as the schema the generator creates one statement earlier.
|
|
304
|
-
* So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
|
|
305
|
-
* dictionary in `rebase`, and every reference to `public.unaccent` below fails
|
|
306
|
-
* with "text search dictionary does not exist". Observed, not theorised.
|
|
307
|
-
*/
|
|
308
|
-
var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
|
|
309
|
-
/** The column definition as it appears inside `CREATE TABLE`. */
|
|
310
|
-
var searchColumnDefinition = (spec) => `"${spec.column}" tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`;
|
|
311
|
-
/** The fuzzy column definition, when the spec asks for one. */
|
|
312
|
-
var fuzzyColumnDefinition = (spec) => spec.fuzzy ? `"${spec.fuzzy.column}" text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED` : void 0;
|
|
313
|
-
/**
|
|
314
|
-
* Index statements for the spec.
|
|
315
|
-
*
|
|
316
|
-
* `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
|
|
317
|
-
* SQL file replayed as one unit — a migration, or `search.sql` — where a
|
|
318
|
-
* concurrent build is not allowed. The boot-time ensure path runs statement by
|
|
319
|
-
* statement against tables that are live and populated, and uses the
|
|
320
|
-
* concurrent form instead; see `ensureSearchColumns`.
|
|
321
|
-
*/
|
|
322
|
-
var searchIndexStatements = (spec) => {
|
|
323
|
-
const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
|
|
324
|
-
if (spec.fuzzy) statements.push(`CREATE INDEX IF NOT EXISTS "${spec.fuzzy.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.fuzzy.column}" ${HELPER_SCHEMA}.gin_trgm_ops);`);
|
|
325
|
-
return statements;
|
|
326
|
-
};
|
|
327
|
-
/**
|
|
328
|
-
* Marker on the comment of every generated search column this module creates.
|
|
329
|
-
*
|
|
330
|
-
* Versioned because the fingerprint below is only comparable against itself: a
|
|
331
|
-
* future change to how it is computed has to read as "not stamped by this
|
|
332
|
-
* version" rather than as drift on every existing column.
|
|
333
|
-
*/
|
|
334
|
-
var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
|
|
335
|
-
/**
|
|
336
|
-
* A stable fingerprint of one generated column's expression.
|
|
337
|
-
*
|
|
338
|
-
* Why a stamp rather than reading the expression back: Postgres stores a
|
|
339
|
-
* generated column's expression *parsed*, and hands it back deparsed — casts
|
|
340
|
-
* made explicit, identifiers requoted, schema qualifications added or dropped
|
|
341
|
-
* according to `search_path`. Comparing that text to the text we generated
|
|
342
|
-
* would report drift on wording, and this comparison decides whether a boot
|
|
343
|
-
* refuses, so a false positive is an outage. The stamp is written by the same
|
|
344
|
-
* code that writes the column, so equality means what it says.
|
|
345
|
-
*/
|
|
346
|
-
var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
|
|
347
|
-
/**
|
|
348
|
-
* The stamps for a spec's generated columns — one per column, never shared.
|
|
349
|
-
*
|
|
350
|
-
* Per column on purpose: turning `fuzzy` on adds a second column and changes
|
|
351
|
-
* nothing about the first, and a spec-wide fingerprint would report the
|
|
352
|
-
* untouched `tsvector` column as drifted and refuse a boot over a change that
|
|
353
|
-
* is purely additive.
|
|
354
|
-
*/
|
|
355
|
-
var searchColumnStamps = (spec) => {
|
|
356
|
-
const stamp = (column, expression) => {
|
|
357
|
-
const fingerprint = searchExpressionFingerprint(expression);
|
|
358
|
-
return {
|
|
359
|
-
column,
|
|
360
|
-
expression,
|
|
361
|
-
fingerprint,
|
|
362
|
-
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote$1(fingerprint)};`
|
|
363
|
-
};
|
|
364
|
-
};
|
|
365
|
-
const stamps = [stamp(spec.column, spec.expression)];
|
|
366
|
-
if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
|
|
367
|
-
return stamps;
|
|
368
|
-
};
|
|
369
|
-
/**
|
|
370
|
-
* The same drift check as the boot ensure, for the SQL file.
|
|
371
|
-
*
|
|
372
|
-
* Needed because {@link searchColumnStamps} would otherwise *launder* drift on
|
|
373
|
-
* the migration path: `ADD COLUMN IF NOT EXISTS` does nothing to a column that
|
|
374
|
-
* exists, so a re-generated `search.sql` would stamp a stale column with the
|
|
375
|
-
* new block's fingerprint and the next boot would find them in agreement.
|
|
376
|
-
* Guarding first means the file refuses instead — `rebase db push` is attended,
|
|
377
|
-
* and the operator reading the failure is the person who changed the block.
|
|
378
|
-
*/
|
|
379
|
-
var searchStampGuards = (spec) => searchColumnStamps(spec).map((stamp) => {
|
|
380
|
-
const relation = quote$1(`"${spec.schema}"."${spec.table}"`);
|
|
381
|
-
return `DO $rebase_search$
|
|
382
|
-
DECLARE recorded text;
|
|
383
|
-
BEGIN
|
|
384
|
-
SELECT col_description(a.attrelid, a.attnum) INTO recorded
|
|
385
|
-
FROM pg_attribute a
|
|
386
|
-
WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote$1(stamp.column)} AND NOT a.attisdropped;
|
|
387
|
-
IF recorded LIKE ${quote$1(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote$1(stamp.fingerprint)} THEN
|
|
388
|
-
RAISE EXCEPTION 'Rebase: the search block for ${spec.schema}.${spec.table} changed after the generated column "${stamp.column}" was built (recorded %, expected ${stamp.fingerprint}). Postgres cannot alter a generated expression in place. Drop the column and re-apply this file — it rewrites the table and rebuilds the index: ALTER TABLE ${relation.slice(1, -1)} DROP COLUMN "${stamp.column}";', recorded;
|
|
389
|
-
END IF;
|
|
390
|
-
END
|
|
391
|
-
$rebase_search$;`;
|
|
392
|
-
});
|
|
393
|
-
/**
|
|
394
|
-
* The generated column names a collection's search block adds, if any.
|
|
395
|
-
*
|
|
396
|
-
* These are physical columns on the table, so `SELECT *` returns them. They are
|
|
397
|
-
* an index in column form — a list of lexeme positions, or a concatenation of
|
|
398
|
-
* every searchable field on the row — and nothing outside the query planner has
|
|
399
|
-
* any use for them. Left in, every list response carries a second, larger copy
|
|
400
|
-
* of the row's text.
|
|
401
|
-
*/
|
|
402
|
-
var searchColumnNames = (collection) => {
|
|
403
|
-
let spec;
|
|
404
|
-
try {
|
|
405
|
-
spec = buildSearchColumnSpec(collection);
|
|
406
|
-
} catch {
|
|
407
|
-
return [];
|
|
408
|
-
}
|
|
409
|
-
if (!spec) return [];
|
|
410
|
-
return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
|
|
411
|
-
};
|
|
412
|
-
/**
|
|
413
|
-
* True for a column whose type only ever holds a search index.
|
|
414
|
-
*
|
|
415
|
-
* Independent of any collection config on purpose: an introspected database
|
|
416
|
-
* (BaaS mode) can carry a `tsvector` column this framework never created —
|
|
417
|
-
* Pagila's `film.fulltext` is the canonical one — and it should not be returned
|
|
418
|
-
* to callers either. `isDerivedIndexColumn` already keeps such a column out of
|
|
419
|
-
* the *properties*; this keeps it out of the *rows*.
|
|
420
|
-
*/
|
|
421
|
-
var isSearchIndexColumn = (column) => {
|
|
422
|
-
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
423
|
-
return sqlType === "tsvector" || sqlType === "tsquery";
|
|
424
|
-
};
|
|
425
|
-
/**
|
|
426
|
-
* A drizzle select projection over `table` with the search columns dropped.
|
|
427
|
-
*
|
|
428
|
-
* Returns undefined when nothing needs dropping, so the common case keeps using
|
|
429
|
-
* a plain `select()` and this stays invisible in the generated SQL.
|
|
430
|
-
*/
|
|
431
|
-
var visibleColumnProjection = (tableColumns, collection) => {
|
|
432
|
-
const excluded = excludedColumnNames(tableColumns, collection);
|
|
433
|
-
if (!tableColumns || excluded.length === 0) return void 0;
|
|
434
|
-
const projection = {};
|
|
435
|
-
for (const [name, column] of Object.entries(tableColumns)) if (!excluded.includes(name)) projection[name] = column;
|
|
436
|
-
return projection;
|
|
437
|
-
};
|
|
438
|
-
/** The same exclusion as a drizzle `db.query` `columns` denylist. */
|
|
439
|
-
var hiddenColumnsOption = (tableColumns, collection) => {
|
|
440
|
-
const excluded = excludedColumnNames(tableColumns, collection);
|
|
441
|
-
if (excluded.length === 0) return void 0;
|
|
442
|
-
return Object.fromEntries(excluded.map((name) => [name, false]));
|
|
443
|
-
};
|
|
444
|
-
/**
|
|
445
|
-
* The columns to keep out of a response, by name.
|
|
446
|
-
*
|
|
447
|
-
* `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
|
|
448
|
-
* for anything that is not a real drizzle table — a stub in a test, a derived
|
|
449
|
-
* or nested path with no table behind it. Nothing to exclude is the right
|
|
450
|
-
* answer there, and it has to be an answer rather than a throw: this runs on
|
|
451
|
-
* the read path of every collection, opted in or not.
|
|
452
|
-
*/
|
|
453
|
-
var excludedColumnNames = (tableColumns, collection) => {
|
|
454
|
-
if (!tableColumns || typeof tableColumns !== "object") return [];
|
|
455
|
-
const byName = new Set(collection ? searchColumnNames(collection) : []);
|
|
456
|
-
return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
|
|
457
|
-
};
|
|
458
|
-
//#endregion
|
|
459
|
-
//#region src/utils/pg-error-utils.ts
|
|
460
|
-
/**
|
|
461
|
-
* Shared PostgreSQL error extraction and user-friendly message formatting.
|
|
462
|
-
*
|
|
463
|
-
* Drizzle wraps native PG errors in a `.cause` chain. These utilities
|
|
464
|
-
* unwrap that chain to get the real PostgreSQL error (identified by a
|
|
465
|
-
* 5-character alphanumeric `code` such as `42P01`) and translate it into
|
|
466
|
-
* a message that is safe and helpful to show to end-users.
|
|
467
|
-
*/
|
|
468
|
-
/**
|
|
469
|
-
* Return the error when it is a deliberate 4xx, otherwise null.
|
|
470
|
-
*
|
|
471
|
-
* A thrown `ApiError` is a decision the server made about the request, not a
|
|
472
|
-
* database failure: its message and code are already written for the client.
|
|
473
|
-
*/
|
|
474
|
-
function asClientFacingError(error) {
|
|
475
|
-
if (!(error instanceof Error)) return null;
|
|
476
|
-
const e = error;
|
|
477
|
-
if (typeof e.statusCode !== "number" || e.statusCode < 400 || e.statusCode >= 500) return null;
|
|
478
|
-
return e;
|
|
479
|
-
}
|
|
480
|
-
/**
|
|
481
|
-
* Extract the underlying PostgreSQL error from a Drizzle wrapper.
|
|
482
|
-
* Drizzle wraps PG errors in a `cause` property — this function
|
|
483
|
-
* recursively walks the chain until it finds an object with a PG
|
|
484
|
-
* error code (5-char alphanumeric, e.g. `42P01`).
|
|
485
|
-
*/
|
|
486
|
-
function extractPgError(error) {
|
|
487
|
-
if (!error || typeof error !== "object") return null;
|
|
488
|
-
if (!(error instanceof Error)) {
|
|
489
|
-
if ("cause" in error && error.cause && typeof error.cause === "object") return extractPgError(error.cause);
|
|
490
|
-
return null;
|
|
491
|
-
}
|
|
492
|
-
if ("code" in error && typeof error.code === "string" && /^[0-9A-Z]{5}$/.test(error.code)) return error;
|
|
493
|
-
if (error.cause && typeof error.cause === "object") return extractPgError(error.cause);
|
|
494
|
-
return null;
|
|
495
|
-
}
|
|
496
|
-
/**
|
|
497
|
-
* Whether the failure came back from Postgres rather than from building the
|
|
498
|
-
* query — which decides whether a fallback query is worth issuing.
|
|
499
|
-
*
|
|
500
|
-
* Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
|
|
501
|
-
* RLS). Once a statement raises, that transaction is aborted, and every later
|
|
502
|
-
* statement on it returns `25P02` — "current transaction is aborted, commands
|
|
503
|
-
* ignored until end of transaction block". So a retry after a database error
|
|
504
|
-
* cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
|
|
505
|
-
* for type uuid") with a generic one. Rethrow instead.
|
|
506
|
-
*
|
|
507
|
-
* A query the driver could not even build — a missing reciprocal relation, say
|
|
508
|
-
* — never reached Postgres, leaves the transaction usable, and is exactly what
|
|
509
|
-
* the fallback paths exist for.
|
|
510
|
-
*/
|
|
511
|
-
function reachedDatabase(error) {
|
|
512
|
-
return extractPgError(error) !== null;
|
|
513
|
-
}
|
|
514
|
-
/**
|
|
515
|
-
* Walk the error cause chain and return the deepest meaningful message.
|
|
516
|
-
*/
|
|
517
|
-
function extractCauseMessage(error) {
|
|
518
|
-
if (!error || typeof error !== "object") return null;
|
|
519
|
-
if (!(error instanceof Error)) return null;
|
|
520
|
-
if (error.cause && typeof error.cause === "object") {
|
|
521
|
-
const deeper = extractCauseMessage(error.cause);
|
|
522
|
-
if (deeper) return deeper;
|
|
523
|
-
if (error.cause instanceof Error && error.cause.message) return error.cause.message;
|
|
524
|
-
}
|
|
525
|
-
return null;
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* Codes that mean "this connection will never work as configured".
|
|
529
|
-
*
|
|
530
|
-
* A wrong password or a database that does not exist is a settled fact about
|
|
531
|
-
* the connection string, not a transient fault — retrying produces the same
|
|
532
|
-
* answer forever.
|
|
533
|
-
*/
|
|
534
|
-
var UNRECOVERABLE_CONNECT_CODES = /* @__PURE__ */ new Set([
|
|
535
|
-
"28P01",
|
|
536
|
-
"28000",
|
|
537
|
-
"3D000",
|
|
538
|
-
"42501"
|
|
539
|
-
]);
|
|
540
|
-
/**
|
|
541
|
-
* Describe a failed connection attempt in terms a developer can act on.
|
|
542
|
-
*
|
|
543
|
-
* The error a caller catches is Drizzle's wrapper: its message is
|
|
544
|
-
* `Failed query: SELECT 1` and its stack runs through drizzle internals, while
|
|
545
|
-
* the sentence that says what is actually wrong — "password authentication
|
|
546
|
-
* failed for user …", "database … does not exist" — sits in `.cause`. Logging
|
|
547
|
-
* the wrapper, as the bootstrapper used to, tells a developer with a typo in
|
|
548
|
-
* their `DATABASE_URL` nothing at all.
|
|
549
|
-
*/
|
|
550
|
-
function classifyConnectFailure(error) {
|
|
551
|
-
const pgError = extractPgError(error);
|
|
552
|
-
const reason = pgError?.message ?? extractCauseMessage(error) ?? (error instanceof Error ? error.message : String(error));
|
|
553
|
-
return {
|
|
554
|
-
fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
|
|
555
|
-
reason,
|
|
556
|
-
code: pgError?.code
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
/**
|
|
560
|
-
* Detect whether an error is specifically a role-switching permission failure
|
|
561
|
-
* (e.g. "permission denied to set role" or "must be member of role"),
|
|
562
|
-
* as opposed to a table-level permission denial.
|
|
563
|
-
*
|
|
564
|
-
* This is used by the backend driver to auto-disable role switching when the
|
|
565
|
-
* connection user lacks SET ROLE privileges, rather than surfacing a confusing
|
|
566
|
-
* error to the Studio SQL Editor user.
|
|
567
|
-
*/
|
|
568
|
-
function isRoleSwitchingPermissionError(error) {
|
|
569
|
-
const pgError = extractPgError(error);
|
|
570
|
-
if (!pgError || pgError.code !== "42501") return false;
|
|
571
|
-
const msg = pgError.message.toLowerCase();
|
|
572
|
-
return msg.includes("set role") || msg.includes("member of role");
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Was this `42501` the *caller* being refused by a policy, rather than the
|
|
576
|
-
* server lacking a privilege?
|
|
577
|
-
*
|
|
578
|
-
* Both arrive as `insufficient_privilege`, and they are opposite kinds of
|
|
579
|
-
* problem. A row-level-security refusal is a working access-control system
|
|
580
|
-
* doing its job: the caller asked for something their policies do not permit,
|
|
581
|
-
* which is a 403 and nobody's bug. A missing `GRANT` is the deployment being
|
|
582
|
-
* wrong — the connection role cannot touch the table at all, no policy is
|
|
583
|
-
* involved, and nothing the caller changes about the request will help.
|
|
584
|
-
*
|
|
585
|
-
* Postgres distinguishes them in the message, so this does too:
|
|
586
|
-
*
|
|
587
|
-
* new row violates row-level security policy for table "notes" → the caller
|
|
588
|
-
* permission denied for table notes → the server
|
|
589
|
-
*
|
|
590
|
-
* Only writes reach this. A read that RLS excludes is not an error — the rows
|
|
591
|
-
* are filtered and the caller gets an empty page — so the erroring case is
|
|
592
|
-
* specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.
|
|
593
|
-
*
|
|
594
|
-
* Matched on the message because that is the only thing carrying the
|
|
595
|
-
* distinction; the SQLSTATE is identical either way. Narrow by design: anything
|
|
596
|
-
* not naming row-level security stays the server's problem, since reporting a
|
|
597
|
-
* genuine privilege misconfiguration as "forbidden" would send an operator
|
|
598
|
-
* hunting for a policy bug that does not exist.
|
|
599
|
-
*/
|
|
600
|
-
function isRowLevelSecurityDenial(error) {
|
|
601
|
-
const pgError = extractPgError(error);
|
|
602
|
-
if (!pgError || pgError.code !== "42501") return false;
|
|
603
|
-
return pgError.message.toLowerCase().includes("row-level security policy");
|
|
604
|
-
}
|
|
605
|
-
/**
|
|
606
|
-
* Translate a raw PostgreSQL error into a user-friendly message.
|
|
607
|
-
*
|
|
608
|
-
* @param pgError - The extracted PostgreSQL error (from {@link extractPgError})
|
|
609
|
-
* @param context - A human-readable context string (e.g. collection slug or path)
|
|
610
|
-
* @returns An object with a `message` safe for the client and the PG `code`.
|
|
611
|
-
*/
|
|
612
|
-
function pgErrorToFriendlyMessage(pgError, context, options = {}) {
|
|
613
|
-
const verbose = options.verbose ?? process.env.NODE_ENV !== "production";
|
|
614
|
-
const detail = verbose ? pgError.detail : void 0;
|
|
615
|
-
const hint = verbose ? pgError.hint : void 0;
|
|
616
|
-
const constraint = pgError.constraint;
|
|
617
|
-
const column = pgError.column;
|
|
618
|
-
const table = pgError.table;
|
|
619
|
-
const dataType = verbose ? pgError.dataType : void 0;
|
|
620
|
-
const rawMessage = pgError.message || "Unknown database error";
|
|
621
|
-
const pgMessage = verbose ? rawMessage : "";
|
|
622
|
-
const code = pgError.code || "UNKNOWN";
|
|
623
|
-
const suffix = hint ? ` Hint: ${hint}` : "";
|
|
624
|
-
const tableRef = table ?? context;
|
|
625
|
-
/** `": <postgres said>"`, or nothing when the details are withheld. */
|
|
626
|
-
const said = pgMessage ? `: ${pgMessage}` : "";
|
|
627
|
-
switch (pgError.code) {
|
|
628
|
-
case "23503": return {
|
|
629
|
-
message: detail ? `Foreign key constraint violated: ${detail}${suffix}` : `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
630
|
-
code
|
|
631
|
-
};
|
|
632
|
-
case "23505": return {
|
|
633
|
-
message: detail ? `Duplicate value: ${detail}${suffix}` : `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
634
|
-
code
|
|
635
|
-
};
|
|
636
|
-
case "23502": return {
|
|
637
|
-
message: `Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`,
|
|
638
|
-
code
|
|
639
|
-
};
|
|
640
|
-
case "23514": return {
|
|
641
|
-
message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
642
|
-
code
|
|
643
|
-
};
|
|
644
|
-
case "22P02": return {
|
|
645
|
-
message: `Invalid data format in "${context}"${said}.${suffix}`,
|
|
646
|
-
code
|
|
647
|
-
};
|
|
648
|
-
case "22001": return {
|
|
649
|
-
message: `Value too long for column "${column ?? "unknown"}" in "${tableRef}"${said}.${suffix}`,
|
|
650
|
-
code
|
|
651
|
-
};
|
|
652
|
-
case "22003": return {
|
|
653
|
-
message: `Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}"${said}.${suffix}`,
|
|
654
|
-
code
|
|
655
|
-
};
|
|
656
|
-
case "42703": return {
|
|
657
|
-
message: `Unknown column in "${tableRef}"${said}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
658
|
-
code
|
|
659
|
-
};
|
|
660
|
-
case "42P01": return {
|
|
661
|
-
message: `Table not found for "${context}"${said}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
662
|
-
code
|
|
663
|
-
};
|
|
664
|
-
case "42501": return rawMessage.toLowerCase().includes("row-level security policy") ? {
|
|
665
|
-
message: `Not permitted to write this row in "${tableRef}": it does not satisfy the row-level security policy.${suffix}`,
|
|
666
|
-
code
|
|
667
|
-
} : {
|
|
668
|
-
message: `Permission denied on "${tableRef}": the database role this server connects as lacks privileges on it.${suffix}`,
|
|
669
|
-
code
|
|
670
|
-
};
|
|
671
|
-
case "28000": return {
|
|
672
|
-
message: `Authorization failed for "${context}". Check your database credentials.${suffix}`,
|
|
673
|
-
code
|
|
674
|
-
};
|
|
675
|
-
default: {
|
|
676
|
-
const parts = [`Database error in "${context}" [${code}]${said}`];
|
|
677
|
-
if (detail) parts.push(`Detail: ${detail}`);
|
|
678
|
-
if (column) parts.push(`Column: ${column}`);
|
|
679
|
-
if (dataType) parts.push(`Data type: ${dataType}`);
|
|
680
|
-
if (constraint) parts.push(`Constraint: ${constraint}`);
|
|
681
|
-
if (hint) parts.push(`Hint: ${hint}`);
|
|
682
|
-
return {
|
|
683
|
-
message: parts.join(". "),
|
|
684
|
-
code
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
/**
|
|
690
|
-
* Sanitize any error into a message safe and helpful for the client.
|
|
691
|
-
*
|
|
692
|
-
* A deliberate 4xx (`ApiError`) passes through untouched — the server already
|
|
693
|
-
* decided what the client should read. Otherwise the PG error is extracted
|
|
694
|
-
* from the Drizzle cause chain, falling back to a generic message that
|
|
695
|
-
* doesn't leak SQL.
|
|
696
|
-
*
|
|
697
|
-
* @param error - The raw caught error
|
|
698
|
-
* @param context - A human-readable context string (e.g. collection path)
|
|
699
|
-
* @returns An object with `message` (user-friendly) and optional `code`
|
|
700
|
-
* (the `ApiError` code, or the PG SQLSTATE).
|
|
701
|
-
*/
|
|
702
|
-
function sanitizeErrorForClient(error, context) {
|
|
703
|
-
const clientError = asClientFacingError(error);
|
|
704
|
-
if (clientError) {
|
|
705
|
-
const line = `[API ${clientError.statusCode} ${clientError.code ?? "BAD_REQUEST"}] in "${context}": ${clientError.message}`;
|
|
706
|
-
if (clientError.expected) logger.debug(line);
|
|
707
|
-
else logger.warn(`⚠️ ${line}`);
|
|
708
|
-
return {
|
|
709
|
-
message: clientError.message,
|
|
710
|
-
...clientError.code && { code: clientError.code }
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
const pgError = extractPgError(error);
|
|
714
|
-
if (pgError) {
|
|
715
|
-
logger.error(`[PG ${pgError.code}] Error in "${context}"`, {
|
|
716
|
-
code: pgError.code,
|
|
717
|
-
message: pgError.message,
|
|
718
|
-
detail: pgError.detail,
|
|
719
|
-
hint: pgError.hint,
|
|
720
|
-
column: pgError.column,
|
|
721
|
-
table: pgError.table,
|
|
722
|
-
constraint: pgError.constraint,
|
|
723
|
-
dataType: pgError.dataType
|
|
724
|
-
});
|
|
725
|
-
return pgErrorToFriendlyMessage(pgError, context);
|
|
726
|
-
}
|
|
727
|
-
logger.error(`Database error in "${context}" (no PG error extracted)`, {
|
|
728
|
-
error: error instanceof Error ? error.message : String(error),
|
|
729
|
-
stack: error instanceof Error ? error.stack : void 0,
|
|
730
|
-
cause: error instanceof Error && error.cause ? error.cause instanceof Error ? error.cause.message : String(error.cause) : void 0
|
|
731
|
-
});
|
|
732
|
-
const causeMessage = extractCauseMessage(error);
|
|
733
|
-
if (causeMessage) return { message: `Database error in "${context}": ${causeMessage}` };
|
|
734
|
-
return { message: `Could not load data for "${context}". Check server logs for details.` };
|
|
735
|
-
}
|
|
736
|
-
//#endregion
|
|
737
|
-
//#region src/schema/auth-users-columns.ts
|
|
738
|
-
/**
|
|
739
|
-
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
740
|
-
* — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
|
|
741
|
-
* The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
|
|
742
|
-
* separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
|
|
743
|
-
* table that already holds a longer row.
|
|
744
|
-
*/
|
|
745
|
-
var AUTH_USERS_COLUMNS = [
|
|
746
|
-
{
|
|
747
|
-
column: "email",
|
|
748
|
-
type: "TEXT",
|
|
749
|
-
notNull: true
|
|
750
|
-
},
|
|
751
|
-
{
|
|
752
|
-
column: "display_name",
|
|
753
|
-
type: "TEXT"
|
|
754
|
-
},
|
|
755
|
-
{
|
|
756
|
-
column: "photo_url",
|
|
757
|
-
type: "TEXT"
|
|
758
|
-
},
|
|
759
|
-
{
|
|
760
|
-
column: "roles",
|
|
761
|
-
type: "TEXT[]",
|
|
762
|
-
default: "'{}'",
|
|
763
|
-
notNull: true
|
|
764
|
-
},
|
|
765
|
-
{
|
|
766
|
-
column: "password_hash",
|
|
767
|
-
type: "TEXT"
|
|
768
|
-
},
|
|
769
|
-
{
|
|
770
|
-
column: "email_verified",
|
|
771
|
-
type: "BOOLEAN",
|
|
772
|
-
default: "FALSE",
|
|
773
|
-
notNull: true
|
|
774
|
-
},
|
|
775
|
-
{
|
|
776
|
-
column: "email_verification_token",
|
|
777
|
-
type: "TEXT"
|
|
778
|
-
},
|
|
779
|
-
{
|
|
780
|
-
column: "email_verification_sent_at",
|
|
781
|
-
type: "TIMESTAMP WITH TIME ZONE"
|
|
782
|
-
},
|
|
783
|
-
{
|
|
784
|
-
column: "is_anonymous",
|
|
785
|
-
type: "BOOLEAN",
|
|
786
|
-
default: "FALSE",
|
|
787
|
-
notNull: true
|
|
788
|
-
},
|
|
789
|
-
{
|
|
790
|
-
column: "metadata",
|
|
791
|
-
type: "JSONB",
|
|
792
|
-
default: "'{}'",
|
|
793
|
-
notNull: true
|
|
794
|
-
},
|
|
795
|
-
{
|
|
796
|
-
column: "tokens_valid_after",
|
|
797
|
-
type: "TIMESTAMP WITH TIME ZONE"
|
|
798
|
-
},
|
|
799
|
-
{
|
|
800
|
-
column: "created_at",
|
|
801
|
-
type: "TIMESTAMP WITH TIME ZONE",
|
|
802
|
-
default: "NOW()",
|
|
803
|
-
notNull: true
|
|
804
|
-
},
|
|
805
|
-
{
|
|
806
|
-
column: "updated_at",
|
|
807
|
-
type: "TIMESTAMP WITH TIME ZONE",
|
|
808
|
-
default: "NOW()",
|
|
809
|
-
notNull: true
|
|
810
|
-
}
|
|
811
|
-
];
|
|
812
|
-
var BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map((c) => [c.column, c]));
|
|
813
|
-
/** Type + inline constraints, as they appear after the column name. */
|
|
814
|
-
function authUsersColumnSql(spec) {
|
|
815
|
-
return [
|
|
816
|
-
spec.type,
|
|
817
|
-
spec.default !== void 0 ? `DEFAULT ${spec.default}` : "",
|
|
818
|
-
spec.notNull ? "NOT NULL" : ""
|
|
819
|
-
].filter(Boolean).join(" ");
|
|
820
|
-
}
|
|
821
|
-
/**
|
|
822
|
-
* The auth-owned definition for a physical column name, or `undefined` when
|
|
823
|
-
* auth does not own it.
|
|
824
|
-
*
|
|
825
|
-
* Callers pass the RESOLVED column name (after `columnName` mapping), because
|
|
826
|
-
* that is the only name the three creators agree on: the scaffold's users
|
|
827
|
-
* collection spells the property `displayName` and the column `display_name`.
|
|
828
|
-
*/
|
|
829
|
-
function authUsersColumnDefinition(column) {
|
|
830
|
-
const spec = BY_COLUMN.get(column);
|
|
831
|
-
return spec ? authUsersColumnSql(spec) : void 0;
|
|
832
|
-
}
|
|
833
|
-
/**
|
|
834
|
-
* Whether a collection is an auth collection, i.e. whether the definitions in
|
|
835
|
-
* this module apply to its table at all.
|
|
836
|
-
*
|
|
837
|
-
* Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
|
|
838
|
-
* that one takes a `CollectionConfig`, this one is called from DDL code paths
|
|
839
|
-
* that hold looser objects, and both spellings must accept `auth: true` as well
|
|
840
|
-
* as `auth: { enabled: true }`.
|
|
841
|
-
*/
|
|
842
|
-
function isAuthCollection(collection) {
|
|
843
|
-
const auth = collection?.auth;
|
|
844
|
-
if (auth === true) return true;
|
|
845
|
-
return typeof auth === "object" && auth !== null && auth.enabled === true;
|
|
846
|
-
}
|
|
847
|
-
//#endregion
|
|
848
|
-
//#region src/schema/vector-index.ts
|
|
849
|
-
/**
|
|
850
|
-
* The widest `vector` pgvector will build an HNSW or IVFFlat index over.
|
|
851
|
-
* Storage and exact search are unaffected by this limit.
|
|
852
|
-
*/
|
|
853
|
-
var MAX_INDEXABLE_VECTOR_DIMENSIONS = 2e3;
|
|
854
|
-
/**
|
|
855
|
-
* Operator class per distance. These strings are part of the database contract:
|
|
856
|
-
* they appear in `CREATE INDEX`, so renaming one renames an index.
|
|
857
|
-
*/
|
|
858
|
-
var OPERATOR_CLASS = {
|
|
859
|
-
cosine: "vector_cosine_ops",
|
|
860
|
-
l2: "vector_l2_ops",
|
|
861
|
-
inner_product: "vector_ip_ops"
|
|
862
|
-
};
|
|
863
|
-
/** Short, stable tag per distance, used to name the index. */
|
|
864
|
-
var DISTANCE_TAG = {
|
|
865
|
-
cosine: "cosine",
|
|
866
|
-
l2: "l2",
|
|
867
|
-
inner_product: "ip"
|
|
868
|
-
};
|
|
869
|
-
var VectorIndexConfigError = class extends Error {
|
|
870
|
-
constructor(message) {
|
|
871
|
-
super(message);
|
|
872
|
-
this.name = "VectorIndexConfigError";
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
var isVectorProperty = (prop) => !!prop && typeof prop === "object" && prop.type === "vector";
|
|
876
|
-
var asDistances = (config, label) => {
|
|
877
|
-
const raw = config.distance ?? "cosine";
|
|
878
|
-
const list = Array.isArray(raw) ? raw : [raw];
|
|
879
|
-
if (list.length === 0) throw new VectorIndexConfigError(`${label}: \`index.distance\` is an empty array. Name at least one distance, or set \`index: false\` to create no index.`);
|
|
880
|
-
const seen = /* @__PURE__ */ new Set();
|
|
881
|
-
for (const distance of list) {
|
|
882
|
-
if (!(distance in OPERATOR_CLASS)) throw new VectorIndexConfigError(`${label}: \`index.distance\` is "${distance}", which is not a pgvector distance. Use ${Object.keys(OPERATOR_CLASS).map((d) => `"${d}"`).join(", ")}.`);
|
|
883
|
-
if (seen.has(distance)) throw new VectorIndexConfigError(`${label}: \`index.distance\` lists "${distance}" twice.`);
|
|
884
|
-
seen.add(distance);
|
|
885
|
-
}
|
|
886
|
-
return list;
|
|
887
|
-
};
|
|
888
|
-
var assertPositiveInteger = (value, key, label) => {
|
|
889
|
-
if (value === void 0) return;
|
|
890
|
-
if (!Number.isInteger(value) || value <= 0) throw new VectorIndexConfigError(`${label}: \`index.${key}\` is ${JSON.stringify(value)}. It must be a positive integer.`);
|
|
891
|
-
};
|
|
892
|
-
/**
|
|
893
|
-
* Index parameters for one method. Parameters belonging to the *other* method
|
|
894
|
-
* are rejected rather than ignored, because a silently dropped `lists` on an
|
|
895
|
-
* HNSW index reads, from the config, exactly like a tuned index.
|
|
896
|
-
*/
|
|
897
|
-
var parametersFor = (method, config, label) => {
|
|
898
|
-
assertPositiveInteger(config.m, "m", label);
|
|
899
|
-
assertPositiveInteger(config.efConstruction, "efConstruction", label);
|
|
900
|
-
assertPositiveInteger(config.lists, "lists", label);
|
|
901
|
-
if (method === "hnsw") {
|
|
902
|
-
if (config.lists !== void 0) throw new VectorIndexConfigError(`${label}: \`index.lists\` only applies to \`method: "ivfflat"\`. Remove it, or switch the method.`);
|
|
903
|
-
const params = [];
|
|
904
|
-
if (config.m !== void 0) params.push(["m", config.m]);
|
|
905
|
-
if (config.efConstruction !== void 0) params.push(["ef_construction", config.efConstruction]);
|
|
906
|
-
return params;
|
|
907
|
-
}
|
|
908
|
-
for (const key of ["m", "efConstruction"]) if (config[key] !== void 0) throw new VectorIndexConfigError(`${label}: \`index.${key}\` only applies to \`method: "hnsw"\`. Remove it, or switch the method.`);
|
|
909
|
-
return config.lists !== void 0 ? [["lists", config.lists]] : [];
|
|
910
|
-
};
|
|
911
|
-
/**
|
|
912
|
-
* Every ANN index a collection's vector properties call for.
|
|
913
|
-
*
|
|
914
|
-
* `resolveColumn` is passed in rather than imported so that this module stays
|
|
915
|
-
* free of the DDL generator, which imports *it*. Both callers hand it the same
|
|
916
|
-
* `resolveColumnName`, and a contract test asserts the names agree.
|
|
917
|
-
*/
|
|
918
|
-
var buildVectorIndexPlan = (collection, resolveColumn) => {
|
|
919
|
-
const specs = [];
|
|
920
|
-
const skipped = [];
|
|
921
|
-
const properties = collection.properties ?? {};
|
|
922
|
-
const table = getTableName(collection);
|
|
923
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
924
|
-
for (const [propName, prop] of Object.entries(properties)) {
|
|
925
|
-
if (!isVectorProperty(prop)) continue;
|
|
926
|
-
if (prop.index === false) continue;
|
|
927
|
-
const label = `${collection.slug}.${propName}`;
|
|
928
|
-
const column = resolveColumn(propName, prop);
|
|
929
|
-
const config = prop.index ?? {};
|
|
930
|
-
const method = config.method ?? "hnsw";
|
|
931
|
-
if (method !== "hnsw" && method !== "ivfflat") throw new VectorIndexConfigError(`${label}: \`index.method\` is "${method}". Use "hnsw" or "ivfflat".`);
|
|
932
|
-
const distances = asDistances(config, label);
|
|
933
|
-
const parameters = parametersFor(method, config, label);
|
|
934
|
-
if (!Number.isInteger(prop.dimensions) || prop.dimensions <= 0) throw new VectorIndexConfigError(`${label}: \`dimensions\` is ${JSON.stringify(prop.dimensions)}. It must be a positive integer.`);
|
|
935
|
-
if (prop.dimensions > 2e3) {
|
|
936
|
-
skipped.push({
|
|
937
|
-
schema,
|
|
938
|
-
table,
|
|
939
|
-
column,
|
|
940
|
-
dimensions: prop.dimensions,
|
|
941
|
-
reason: `pgvector cannot index a vector wider than ${MAX_INDEXABLE_VECTOR_DIMENSIONS} dimensions, and ${label} declares ${prop.dimensions}. The column works and \`vectorSearch\` still answers, as an exact scan. To index it, reduce the dimensions (many embedding models support a shorter output) or set \`index: false\` to state that the scan is intended.`
|
|
942
|
-
});
|
|
943
|
-
continue;
|
|
944
|
-
}
|
|
945
|
-
for (const distance of distances) specs.push({
|
|
946
|
-
schema,
|
|
947
|
-
table,
|
|
948
|
-
column,
|
|
949
|
-
indexName: toPostgresIdentifier(`${table}_${column}_${method}_${DISTANCE_TAG[distance]}`),
|
|
950
|
-
method,
|
|
951
|
-
distance,
|
|
952
|
-
operatorClass: OPERATOR_CLASS[distance],
|
|
953
|
-
parameters
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
return {
|
|
957
|
-
specs,
|
|
958
|
-
skipped
|
|
959
|
-
};
|
|
960
|
-
};
|
|
961
|
-
/**
|
|
962
|
-
* The `CREATE INDEX` for one spec.
|
|
963
|
-
*
|
|
964
|
-
* `CONCURRENTLY` is deliberately absent, for the same reason it is absent from
|
|
965
|
-
* `searchIndexStatements`: this form is replayed as part of a migration, where
|
|
966
|
-
* a concurrent build is not allowed. The boot-time ensure rewrites it — see
|
|
967
|
-
* `ensureCollectionTables`.
|
|
968
|
-
*/
|
|
969
|
-
var vectorIndexStatement = (spec) => {
|
|
970
|
-
const params = spec.parameters.length ? ` WITH (${spec.parameters.map(([key, value]) => `${key} = ${value}`).join(", ")})` : "";
|
|
971
|
-
return `CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING ${spec.method} ("${spec.column}" ${spec.operatorClass})${params};`;
|
|
972
|
-
};
|
|
973
|
-
/** Every statement for a plan, in a stable order. */
|
|
974
|
-
var vectorIndexStatements = (plan) => plan.specs.map(vectorIndexStatement);
|
|
975
|
-
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
976
|
-
/**
|
|
977
|
-
* The schema pgvector's types are installed into.
|
|
978
|
-
*
|
|
979
|
-
* `WITH SCHEMA public` for the same load-bearing reason `searchExtensionStatements`
|
|
980
|
-
* gives: an unqualified `CREATE EXTENSION` lands in the first schema on
|
|
981
|
-
* `search_path`, which is `"$user", public` — and the scaffold's role is named
|
|
982
|
-
* `rebase`, the same as a schema the generator creates. Left to itself the
|
|
983
|
-
* extension would install into `rebase`, and every unqualified `VECTOR(n)`
|
|
984
|
-
* below would fail to resolve.
|
|
985
|
-
*/
|
|
986
|
-
var VECTOR_EXTENSION_SCHEMA = "public";
|
|
987
|
-
/** The extension name a database has to name to let Rebase install pgvector. */
|
|
988
|
-
var VECTOR_EXTENSION = "vector";
|
|
989
|
-
/**
|
|
990
|
-
* How a project says Rebase may install pgvector, quoted into the messages that
|
|
991
|
-
* have to name it. Spelled once so the option and the advice cannot drift.
|
|
992
|
-
*/
|
|
993
|
-
var VECTOR_EXTENSION_OPT_IN = `database({ extensions: ["${VECTOR_EXTENSION}"] })`;
|
|
994
|
-
/** Did the project give Rebase leave to install pgvector? */
|
|
995
|
-
var vectorExtensionDeclared = (extensions) => (extensions ?? []).includes(VECTOR_EXTENSION);
|
|
996
|
-
/**
|
|
997
|
-
* Install pgvector — emitted only when the database asked for it.
|
|
998
|
-
*
|
|
999
|
-
* Opt-in because installing an extension is a decision with a deployment behind
|
|
1000
|
-
* it: the image has to ship the library, the role has to be allowed to install
|
|
1001
|
-
* it, and a managed provider has to have it on an allow-list. None of that is
|
|
1002
|
-
* visible from inside the connection, so Rebase does not decide it. See
|
|
1003
|
-
* `DatabaseOptions.extensions`.
|
|
1004
|
-
*
|
|
1005
|
-
* Withholding the statement is not withholding the *column*: the column is
|
|
1006
|
-
* still created, and Postgres refuses it with `type "vector" does not exist`
|
|
1007
|
-
* on a database where pgvector was never installed by hand. That error is the
|
|
1008
|
-
* one this design accepts, and `vectorExtensionHint` is what makes it name
|
|
1009
|
-
* {@link VECTOR_EXTENSION_OPT_IN} rather than nothing.
|
|
1010
|
-
*/
|
|
1011
|
-
var vectorExtensionStatement = () => `CREATE EXTENSION IF NOT EXISTS ${VECTOR_EXTENSION} WITH SCHEMA ${VECTOR_EXTENSION_SCHEMA};`;
|
|
1012
|
-
/**
|
|
1013
|
-
* The missing-pgvector explanation, appended to whichever error revealed it.
|
|
1014
|
-
*
|
|
1015
|
-
* Two readers, needing opposite things, which is why the branch is on the error
|
|
1016
|
-
* text rather than on the configuration:
|
|
1017
|
-
*
|
|
1018
|
-
* - **`type "vector" does not exist`** — nobody opted in and the database has
|
|
1019
|
-
* no pgvector. The fix is one line of config they cannot guess, so naming
|
|
1020
|
-
* the option *is* the hint.
|
|
1021
|
-
* - **the install itself failed** — the config is already right, and repeating
|
|
1022
|
-
* the option would send them to edit a correct line. What is missing is the
|
|
1023
|
-
* library on the server, or the grant.
|
|
1024
|
-
*
|
|
1025
|
-
* Lives here rather than beside either caller because both need it: the boot
|
|
1026
|
-
* ensure raises the first through its action applier, and `rebase db push`
|
|
1027
|
-
* raises it out of `applyVectorDdl`. A hint on only one path is how a bare
|
|
1028
|
-
* `type "vector" does not exist` reaches somebody — which is the thing this
|
|
1029
|
-
* exists to prevent.
|
|
1030
|
-
*/
|
|
1031
|
-
var vectorExtensionHint = (message) => {
|
|
1032
|
-
if (/extension "vector" is not available/i.test(message) || /(permission denied to create extension|must be (superuser|owner).{0,40}extension)/i.test(message)) return "\n pgvector was declared and could not be installed. It is a server extension, so it needs an image that ships the library (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` does not) and a role allowed to run `CREATE EXTENSION vector;`. Managed Postgres usually allows it once the extension is on the provider's allow-list.";
|
|
1033
|
-
if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
|
|
1034
|
-
return `
|
|
1035
|
-
pgvector is not installed on this database, and Rebase installs it only where a database says it may: add \`${VECTOR_EXTENSION_OPT_IN}\` in config/resources.ts, or install it once by hand with \`CREATE EXTENSION vector;\`. Either way the server needs an image that ships the library — the scaffold's \`pgvector/pgvector:pg18\` does, a stock \`postgres:18\` does not. Rebase then creates the column and its ANN index automatically — see the \`index\` option on the property.`;
|
|
1036
|
-
};
|
|
1037
|
-
/**
|
|
1038
|
-
* Every vector column a collection declares.
|
|
1039
|
-
*
|
|
1040
|
-
* Wider than {@link buildVectorIndexPlan} on purpose: that one answers "what
|
|
1041
|
-
* gets an ANN index", and skips both a property with `index: false` and one too
|
|
1042
|
-
* wide to index. Either still needs its column.
|
|
1043
|
-
*/
|
|
1044
|
-
var buildVectorColumnSpecs = (collection, resolveColumn) => {
|
|
1045
|
-
const table = getTableName(collection);
|
|
1046
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
1047
|
-
const specs = [];
|
|
1048
|
-
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
1049
|
-
if (!isVectorProperty(prop)) continue;
|
|
1050
|
-
const validation = prop.validation;
|
|
1051
|
-
let modifiers = "";
|
|
1052
|
-
if (validation?.unique) modifiers += " UNIQUE";
|
|
1053
|
-
if (validation?.required) modifiers += " NOT NULL";
|
|
1054
|
-
specs.push({
|
|
1055
|
-
schema,
|
|
1056
|
-
table,
|
|
1057
|
-
column: resolveColumn(propName, prop),
|
|
1058
|
-
dimensions: prop.dimensions,
|
|
1059
|
-
modifiers
|
|
1060
|
-
});
|
|
1061
|
-
}
|
|
1062
|
-
return specs;
|
|
1063
|
-
};
|
|
1064
|
-
/** The column type — the one place `VECTOR(n)` is spelled. */
|
|
1065
|
-
var vectorColumnType = (spec) => `VECTOR(${spec.dimensions})`;
|
|
1066
|
-
/** The column definition as it appears inside `CREATE TABLE`. */
|
|
1067
|
-
var vectorColumnDefinition = (spec) => `"${spec.column}" ${vectorColumnType(spec)}${spec.modifiers}`;
|
|
1068
|
-
/**
|
|
1069
|
-
* Refuse — or perform — a `dimensions` change that `ADD COLUMN IF NOT EXISTS`
|
|
1070
|
-
* would otherwise swallow.
|
|
1071
|
-
*
|
|
1072
|
-
* Without this the file *launders* the change: the ADD is a no-op against a
|
|
1073
|
-
* column that exists, so a project that went from 384 to 768 dimensions would
|
|
1074
|
-
* push clean, keep a 384-wide column, and fail on the next insert with a
|
|
1075
|
-
* message about the row rather than about the config. Atlas used to catch this
|
|
1076
|
-
* — it owned the column and planned the `ALTER … TYPE` — and taking the column
|
|
1077
|
-
* out of its sight is exactly what makes the guard necessary.
|
|
1078
|
-
*
|
|
1079
|
-
* The widening is performed when the column holds no values, because that is
|
|
1080
|
-
* the case Atlas handled and it is the common one: a developer changing
|
|
1081
|
-
* embedding models before there are any embeddings. With values present the
|
|
1082
|
-
* conversion is pgvector's to reject — every stored vector is the old width —
|
|
1083
|
-
* so this refuses first and names the statement to run afterwards.
|
|
1084
|
-
*
|
|
1085
|
-
* `atttypmod` on a `vector` column *is* the dimension count: pgvector stores it
|
|
1086
|
-
* directly rather than offsetting it the way `varchar` does. `-1` means the
|
|
1087
|
-
* column was declared as a bare `vector`, which is drift in the same sense.
|
|
1088
|
-
*/
|
|
1089
|
-
var vectorDimensionGuard = (spec) => {
|
|
1090
|
-
const relation = `"${spec.schema}"."${spec.table}"`;
|
|
1091
|
-
return `DO $rebase_vector$
|
|
1092
|
-
DECLARE actual int;
|
|
1093
|
-
BEGIN
|
|
1094
|
-
SELECT a.atttypmod INTO actual
|
|
1095
|
-
FROM pg_attribute a
|
|
1096
|
-
JOIN pg_type t ON t.oid = a.atttypid
|
|
1097
|
-
WHERE a.attrelid = ${quote(relation)}::regclass
|
|
1098
|
-
AND a.attname = ${quote(spec.column)}
|
|
1099
|
-
AND NOT a.attisdropped
|
|
1100
|
-
AND t.typname = 'vector';
|
|
1101
|
-
IF actual IS NOT NULL AND actual <> ${spec.dimensions} THEN
|
|
1102
|
-
IF EXISTS (SELECT 1 FROM ${relation} WHERE "${spec.column}" IS NOT NULL) THEN
|
|
1103
|
-
RAISE EXCEPTION 'Rebase: ${spec.schema}.${spec.table}."${spec.column}" declares ${spec.dimensions} dimensions but the column is vector(%), and it already holds values of the old width. pgvector cannot convert them. Re-embed the rows at ${spec.dimensions} dimensions (or clear the column), then re-apply: ALTER TABLE ${relation} ALTER COLUMN "${spec.column}" TYPE ${vectorColumnType(spec)};', actual;
|
|
1104
|
-
END IF;
|
|
1105
|
-
ALTER TABLE ${relation} ALTER COLUMN "${spec.column}" TYPE ${vectorColumnType(spec)};
|
|
1106
|
-
END IF;
|
|
1107
|
-
END
|
|
1108
|
-
$rebase_vector$;`;
|
|
1109
|
-
};
|
|
1110
|
-
//#endregion
|
|
1111
|
-
//#region src/schema/generate-postgres-ddl-logic.ts
|
|
1112
|
-
var resolveColumnName = (propName, prop) => {
|
|
1113
|
-
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
1114
|
-
return toSnakeCase(propName);
|
|
1115
|
-
};
|
|
1116
|
-
var getPrimaryKeyProp = (collection) => {
|
|
1117
|
-
if (collection.properties) {
|
|
1118
|
-
const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in prop && Boolean(prop.isId));
|
|
1119
|
-
if (idPropEntry) {
|
|
1120
|
-
const prop = idPropEntry[1];
|
|
1121
|
-
const isUuid = prop.type === "string" && "isId" in prop && prop.isId === "uuid";
|
|
1122
|
-
return {
|
|
1123
|
-
name: idPropEntry[0],
|
|
1124
|
-
type: prop.type === "number" ? "number" : "string",
|
|
1125
|
-
isUuid
|
|
1126
|
-
};
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
const idProp = collection.properties?.["id"];
|
|
1130
|
-
if (idProp?.type === "number") return {
|
|
1131
|
-
name: "id",
|
|
1132
|
-
type: "number",
|
|
1133
|
-
isUuid: false
|
|
1134
|
-
};
|
|
1135
|
-
return {
|
|
1136
|
-
name: "id",
|
|
1137
|
-
type: "string",
|
|
1138
|
-
isUuid: idProp?.type === "string" && "isId" in idProp && idProp.isId === "uuid"
|
|
1139
|
-
};
|
|
1140
|
-
};
|
|
1141
|
-
var isNumericId = (collection) => {
|
|
1142
|
-
return getPrimaryKeyProp(collection).type === "number";
|
|
1143
|
-
};
|
|
1144
|
-
var getPrimaryKeyName = (collection) => {
|
|
1145
|
-
return getPrimaryKeyProp(collection).name;
|
|
1146
|
-
};
|
|
1147
|
-
/** The column type a junction holds for one endpoint's primary key. */
|
|
1148
|
-
var junctionKeyType = (collection) => isNumericId(collection) ? "INTEGER" : getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT";
|
|
1149
|
-
var isIdProperty = (propName, prop, collection) => {
|
|
1150
|
-
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
1151
|
-
return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
|
|
1152
|
-
};
|
|
1153
|
-
/**
|
|
1154
|
-
* Render statements produced by {@link generatePolicyStatements} back into the
|
|
1155
|
-
* exact string the DDL/policies files have always carried: each statement on
|
|
1156
|
-
* its own line, terminated by a newline. Keeping the string form derived from
|
|
1157
|
-
* the statement array means the two can never drift — the boot-time applier and
|
|
1158
|
-
* the generated `policies.sql` emit the same SQL, from the same source.
|
|
1159
|
-
*/
|
|
1160
|
-
var statementsToDdl = (statements) => statements.map((s) => `${s}\n`).join("");
|
|
1161
|
-
/**
|
|
1162
|
-
* The `ON DELETE` a foreign key gets when the author did not say.
|
|
1163
|
-
*
|
|
1164
|
-
* One rule for every property that emits one — `belongsTo` relations and
|
|
1165
|
-
* `reference` properties alike. `reference` kept its own `CASCADE` default for
|
|
1166
|
-
* a release after this rule was written, which is exactly the split this
|
|
1167
|
-
* function exists to prevent: two spellings of the same link, two data-retention
|
|
1168
|
-
* behaviours, and only one of them documented.
|
|
1169
|
-
*
|
|
1170
|
-
* An optional link is `SET NULL`: the column can hold NULL, so dropping the
|
|
1171
|
-
* parent leaves the child row with an empty pointer, which is what "optional"
|
|
1172
|
-
* already means.
|
|
1173
|
-
*
|
|
1174
|
-
* A required link is **`RESTRICT`**, not `CASCADE`. `NOT NULL` says the child
|
|
1175
|
-
* cannot exist without a parent; it does not say deleting the parent should
|
|
1176
|
-
* take the child with it. That second claim is a data-retention decision, and
|
|
1177
|
-
* defaulting to it meant `onDelete` — a field nobody has to write — silently
|
|
1178
|
-
* turned every `DELETE FROM authors` into a cascade through posts, comments and
|
|
1179
|
-
* anything else that hung off them. `RESTRICT` fails the delete instead and
|
|
1180
|
-
* names the constraint, so the author chooses `cascade` on purpose or clears
|
|
1181
|
-
* the children first.
|
|
1182
|
-
*
|
|
1183
|
-
* Both DDL paths — the `CREATE TABLE` generator and {@link planRelationalColumns},
|
|
1184
|
-
* which boot-ensure compares the live database against — call this, so a
|
|
1185
|
-
* default that appears in one and not the other cannot make `db push` plan a
|
|
1186
|
-
* constraint rewrite on every run.
|
|
1187
|
-
*/
|
|
1188
|
-
var defaultBelongsToOnDelete = (required) => required ? "RESTRICT" : "SET NULL";
|
|
1189
|
-
var generatePolicyDdl = (collection, rule, resolveCollection) => statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
|
|
1190
|
-
/**
|
|
1191
|
-
* The individual SQL statements a single security rule compiles to: a
|
|
1192
|
-
* `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
|
|
1193
|
-
* statement (terminated by `;`, no trailing newline).
|
|
1194
|
-
*
|
|
1195
|
-
* This is the primitive the boot-time RLS applier runs one statement at a time
|
|
1196
|
-
* (the runtime's DB handle speaks the extended query protocol, which forbids
|
|
1197
|
-
* multiple commands in one execute), while `db push` writes the joined string.
|
|
1198
|
-
*/
|
|
1199
|
-
var generatePolicyStatements = (collection, rule, resolveCollection) => {
|
|
1200
|
-
const tableName = getTableName(collection);
|
|
1201
|
-
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
1202
|
-
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
1203
|
-
return ops.flatMap((op, opIdx) => {
|
|
1204
|
-
return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
1205
|
-
});
|
|
1206
|
-
};
|
|
1207
|
-
var generateSinglePolicyStatements = (collection, rule, operation, policyName, resolveCollection) => {
|
|
1208
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
1209
|
-
const tableName = getTableName(collection);
|
|
1210
|
-
const mode = (rule.mode ?? "permissive").toUpperCase();
|
|
1211
|
-
const operationUpper = operation.toUpperCase();
|
|
1212
|
-
const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
1213
|
-
const needsUsing = operation !== "insert";
|
|
1214
|
-
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
1215
|
-
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
1216
|
-
let usingClause = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection, { resolveCollection }) : null;
|
|
1217
|
-
let withCheckClause = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection, { resolveCollection }) : null;
|
|
1218
|
-
if (!usingClause && needsUsing) usingClause = "false";
|
|
1219
|
-
if (!withCheckClause && needsWithCheck) withCheckClause = "false";
|
|
1220
|
-
const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
|
|
1221
|
-
let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map((r) => `"${r}"`).join(", ")}`;
|
|
1222
|
-
if (usingClause) create += ` USING (${usingClause})`;
|
|
1223
|
-
if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
|
|
1224
|
-
create += ";";
|
|
1225
|
-
return [drop, create];
|
|
1226
|
-
};
|
|
1227
|
-
/**
|
|
1228
|
-
* Single-quote escaping for a SQL string literal (PostgreSQL doubles the
|
|
1229
|
-
* quote). Enum labels come straight from user-authored collection config, so a
|
|
1230
|
-
* label like `it's` closes the literal early and the whole generated file stops
|
|
1231
|
-
* parsing at the `CREATE TYPE`. Lives here rather than next to its other caller
|
|
1232
|
-
* because ensure-collection-tables already imports from this module — the
|
|
1233
|
-
* reverse would be a cycle.
|
|
1234
|
-
*/
|
|
1235
|
-
var quoteSqlLiteral = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
1236
|
-
var getSqlColumnType = (propName, prop, collection, collections) => {
|
|
1237
|
-
switch (prop.type) {
|
|
1238
|
-
case "string": {
|
|
1239
|
-
const stringProp = prop;
|
|
1240
|
-
if (stringProp.enum) {
|
|
1241
|
-
const tableName = getTableName(collection);
|
|
1242
|
-
const colName = resolveColumnName(propName, prop);
|
|
1243
|
-
return `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${tableName}_${colName}"`;
|
|
1244
|
-
}
|
|
1245
|
-
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") return "UUID";
|
|
1246
|
-
if (stringProp.columnType === "char") return `CHAR(${resolveStringColumnLength(stringProp)})`;
|
|
1247
|
-
if (stringProp.columnType === "varchar") return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
|
|
1248
|
-
return "TEXT";
|
|
1249
|
-
}
|
|
1250
|
-
case "number": {
|
|
1251
|
-
const numProp = prop;
|
|
1252
|
-
const isId = isIdProperty(propName, prop, collection);
|
|
1253
|
-
if ("isId" in numProp && numProp.isId === "increment") return "INTEGER GENERATED BY DEFAULT AS IDENTITY";
|
|
1254
|
-
if (numProp.columnType) {
|
|
1255
|
-
if (numProp.columnType === "double precision") return "DOUBLE PRECISION";
|
|
1256
|
-
return numProp.columnType.toUpperCase();
|
|
1257
|
-
}
|
|
1258
|
-
return numProp.validation?.integer || isId ? "INTEGER" : "NUMERIC";
|
|
1259
|
-
}
|
|
1260
|
-
case "boolean": return "BOOLEAN";
|
|
1261
|
-
case "date": {
|
|
1262
|
-
const dateProp = prop;
|
|
1263
|
-
if (dateProp.columnType === "date") return "DATE";
|
|
1264
|
-
if (dateProp.columnType === "time") return "TIME";
|
|
1265
|
-
return "TIMESTAMP WITH TIME ZONE";
|
|
1266
|
-
}
|
|
1267
|
-
case "map": return prop.columnType === "json" ? "JSON" : "JSONB";
|
|
1268
|
-
case "geopoint": return "JSONB";
|
|
1269
|
-
case "array": {
|
|
1270
|
-
const arrayProp = prop;
|
|
1271
|
-
let colType = arrayProp.columnType;
|
|
1272
|
-
if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
|
|
1273
|
-
const ofProp = arrayProp.of;
|
|
1274
|
-
if (ofProp.type === "string") colType = "text[]";
|
|
1275
|
-
else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
1276
|
-
else if (ofProp.type === "boolean") colType = "boolean[]";
|
|
1277
|
-
}
|
|
1278
|
-
if (colType === "json") return "JSON";
|
|
1279
|
-
if (colType === "text[]") return "TEXT[]";
|
|
1280
|
-
if (colType === "integer[]") return "INTEGER[]";
|
|
1281
|
-
if (colType === "boolean[]") return "BOOLEAN[]";
|
|
1282
|
-
if (colType === "numeric[]") return "NUMERIC[]";
|
|
1283
|
-
return "JSONB";
|
|
1284
|
-
}
|
|
1285
|
-
case "vector": return `VECTOR(${prop.dimensions})`;
|
|
1286
|
-
case "binary": return "BYTEA";
|
|
1287
|
-
case "relation": {
|
|
1288
|
-
const refProp = prop;
|
|
1289
|
-
const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
1290
|
-
if (relation?.kind !== "belongsTo") throw new Error(`Relation ${propName} does not put a column on this table (only \`belongsTo\` does)`);
|
|
1291
|
-
let targetCollection;
|
|
1292
|
-
try {
|
|
1293
|
-
targetCollection = relation.target();
|
|
1294
|
-
} catch {
|
|
1295
|
-
return "TEXT";
|
|
1296
|
-
}
|
|
1297
|
-
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
1298
|
-
return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
|
|
1299
|
-
}
|
|
1300
|
-
case "reference": {
|
|
1301
|
-
const refProp = prop;
|
|
1302
|
-
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
1303
|
-
if (!targetCollection) return "TEXT";
|
|
1304
|
-
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
1305
|
-
return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
|
|
1306
|
-
}
|
|
1307
|
-
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).`);
|
|
1308
|
-
}
|
|
1309
|
-
};
|
|
1310
|
-
/**
|
|
1311
|
-
* Everything a `search` block needs, as a file Rebase applies itself.
|
|
1312
|
-
*
|
|
1313
|
-
* Search is the one part of the schema Atlas does not own. Two independent
|
|
1314
|
-
* reasons, and either alone would be enough:
|
|
1315
|
-
*
|
|
1316
|
-
* 1. Its free tier refuses to *parse* a desired-state file that so much as
|
|
1317
|
-
* contains a function — "functions and procedures are available to
|
|
1318
|
-
* logged-in users only". A generated `tsvector` column cannot avoid one:
|
|
1319
|
-
* `unaccent` is STABLE and jsonb flattening needs a set-returning function,
|
|
1320
|
-
* so both have to be wrapped in an IMMUTABLE helper to be legal in a
|
|
1321
|
-
* generated column at all.
|
|
1322
|
-
* 2. Even with the file accepted, Atlas *wipes* the dev database it diffs
|
|
1323
|
-
* against, so a helper seeded there beforehand is gone by the time the plan
|
|
1324
|
-
* is analysed. There is no hook to reinstate it.
|
|
1325
|
-
*
|
|
1326
|
-
* So the column, its index and its helpers are excluded from Atlas's view
|
|
1327
|
-
* (`searchExcludePatterns`) and applied from here — the same arrangement the
|
|
1328
|
-
* RLS policies already use, and for the same underlying reason.
|
|
1329
|
-
*
|
|
1330
|
-
* Ordered as it must run: extensions, then helpers, then the column whose
|
|
1331
|
-
* expression calls them, then the index over that column. Every statement is
|
|
1332
|
-
* `IF NOT EXISTS` / `OR REPLACE`, because this is replayed on every push and
|
|
1333
|
-
* appended to migrations that run against databases at any stage of their
|
|
1334
|
-
* life. Empty when nothing opted in — the caller writes no file then.
|
|
1335
|
-
*
|
|
1336
|
-
* The dev database Atlas plans against needs none of this. `searchExcludePatterns`
|
|
1337
|
-
* removes the search column from the *inspected* state, so the state Atlas
|
|
1338
|
-
* replays there never mentions the column and never calls a helper. Seeding the
|
|
1339
|
-
* helpers into that database was tried and removed: measured on Atlas 1.2.3, a
|
|
1340
|
-
* `schema apply --dry-run` produces byte-identical output seeded or not, and
|
|
1341
|
-
* Atlas wipes the dev database before it plans anyway — so a seed could not have
|
|
1342
|
-
* helped even where the excludes are missing, which is the case that really does
|
|
1343
|
-
* fail with `function public.rebase_search_unaccent(text) does not exist`.
|
|
1344
|
-
*/
|
|
1345
|
-
var generatePostgresSearchDdl = (allCollections) => {
|
|
1346
|
-
const collections = relationalCollections(allCollections);
|
|
1347
|
-
const specs = collections.map((c) => buildSearchColumnSpec(c)).filter((s) => s !== void 0);
|
|
1348
|
-
if (specs.length === 0) return "";
|
|
1349
|
-
const extensions = Array.from(new Set(specs.flatMap(searchExtensionStatements)));
|
|
1350
|
-
const helpers = Array.from(new Set(specs.flatMap(searchHelperFunctions)));
|
|
1351
|
-
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n";
|
|
1352
|
-
ddl += "--\n";
|
|
1353
|
-
ddl += "-- Full-text search for the collections declaring a `search` block.\n";
|
|
1354
|
-
ddl += "-- Applied by Rebase, not by Atlas — see generatePostgresSearchDdl.\n\n";
|
|
1355
|
-
extensions.forEach((s) => {
|
|
1356
|
-
ddl += `${s}\n`;
|
|
1357
|
-
});
|
|
1358
|
-
if (extensions.length > 0) ddl += "\n";
|
|
1359
|
-
helpers.forEach((s) => {
|
|
1360
|
-
ddl += `${s}\n\n`;
|
|
1361
|
-
});
|
|
1362
|
-
for (const collection of collections) {
|
|
1363
|
-
const spec = buildSearchColumnSpec(collection);
|
|
1364
|
-
if (!spec) continue;
|
|
1365
|
-
const table = `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${getTableName(collection)}"`;
|
|
1366
|
-
searchStampGuards(spec).forEach((s) => {
|
|
1367
|
-
ddl += `${s}\n`;
|
|
1368
|
-
});
|
|
1369
|
-
ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${searchColumnDefinition(spec)};\n`;
|
|
1370
|
-
const fuzzyDef = fuzzyColumnDefinition(spec);
|
|
1371
|
-
if (fuzzyDef) ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${fuzzyDef};\n`;
|
|
1372
|
-
searchColumnStamps(spec).forEach((s) => {
|
|
1373
|
-
ddl += `${s.sql}\n`;
|
|
1374
|
-
});
|
|
1375
|
-
searchIndexStatements(spec).forEach((s) => {
|
|
1376
|
-
ddl += `${s}\n`;
|
|
1377
|
-
});
|
|
1378
|
-
ddl += "\n";
|
|
1379
|
-
}
|
|
1380
|
-
return ddl;
|
|
1381
|
-
};
|
|
1382
|
-
/**
|
|
1383
|
-
* Everything a `{ type: "vector" }` property needs, as a file Rebase applies
|
|
1384
|
-
* itself.
|
|
1385
|
-
*
|
|
1386
|
-
* Vector is the second part of the schema Atlas does not own, and it is out for
|
|
1387
|
-
* a narrower reason than search: not that Atlas refuses to parse the file, but
|
|
1388
|
-
* that it cannot *execute* it. To compute a desired state Atlas materialises
|
|
1389
|
-
* `schema.sql` in a dev database, that database is created empty, and Atlas
|
|
1390
|
-
* empties it again at the start of every run — so `VECTOR(384)` resolves
|
|
1391
|
-
* against a database that structurally cannot have pgvector, and every push
|
|
1392
|
-
* dies with `type "vector" does not exist`. Seeding the extension beforehand
|
|
1393
|
-
* does not survive the clean (measured: present before the run, gone after),
|
|
1394
|
-
* putting `CREATE EXTENSION` in `schema.sql` is refused as an Atlas Pro
|
|
1395
|
-
* feature, and `--exclude` alone cannot help because Atlas still parses and
|
|
1396
|
-
* applies the whole file.
|
|
1397
|
-
*
|
|
1398
|
-
* So the column, its ANN indexes and the extension are excluded from Atlas's
|
|
1399
|
-
* view ({@link vectorExcludePatterns}) and applied from here — the same
|
|
1400
|
-
* arrangement search and the RLS policies already use.
|
|
1401
|
-
*
|
|
1402
|
-
* `CREATE EXTENSION` appears only when the project's database named `vector` in
|
|
1403
|
-
* its `extensions` — see `DatabaseOptions.extensions`. Everything else is
|
|
1404
|
-
* emitted either way: a database that already has pgvector installed by hand
|
|
1405
|
-
* needs no permission from anyone, and one that does not gets the column
|
|
1406
|
-
* refused by Postgres with a message this repo makes name the opt-in.
|
|
1407
|
-
*
|
|
1408
|
-
* Ordered as it must run: the extension, then the drift guard, then the column
|
|
1409
|
-
* whose type needs the extension, then the indexes over that column. Every
|
|
1410
|
-
* statement is `IF NOT EXISTS`, because this is replayed on every push and
|
|
1411
|
-
* appended to migrations that run against databases at any stage of their life.
|
|
1412
|
-
* Empty when no collection declares a vector — the caller writes no file then.
|
|
1413
|
-
*/
|
|
1414
|
-
var generatePostgresVectorDdl = (allCollections, options = {}) => {
|
|
1415
|
-
const withVectors = relationalCollections(allCollections).filter((c) => buildVectorColumnSpecs(c, resolveColumnName).length > 0);
|
|
1416
|
-
if (withVectors.length === 0) return "";
|
|
1417
|
-
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n";
|
|
1418
|
-
ddl += "--\n";
|
|
1419
|
-
ddl += "-- Vector columns and their ANN indexes, for the collections declaring a\n";
|
|
1420
|
-
ddl += "-- `vector` property. Applied by Rebase, not by Atlas — see\n";
|
|
1421
|
-
ddl += "-- generatePostgresVectorDdl.\n\n";
|
|
1422
|
-
ddl += vectorExtensionDeclared(options.extensions) ? `${vectorExtensionStatement()}\n\n` : `-- pgvector is not installed from here: no database declared it. To let Rebase\n-- install it, add ${VECTOR_EXTENSION_OPT_IN} in config/resources.ts.\n-- Otherwise install it once by hand; the column below needs the type either way.\n\n`;
|
|
1423
|
-
for (const collection of withVectors) {
|
|
1424
|
-
const table = `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${getTableName(collection)}"`;
|
|
1425
|
-
for (const spec of buildVectorColumnSpecs(collection, resolveColumnName)) {
|
|
1426
|
-
ddl += `${vectorDimensionGuard(spec)}\n`;
|
|
1427
|
-
ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${vectorColumnDefinition(spec)};\n`;
|
|
1428
|
-
}
|
|
1429
|
-
const plan = buildVectorIndexPlan(collection, resolveColumnName);
|
|
1430
|
-
vectorIndexStatements(plan).forEach((s) => {
|
|
1431
|
-
ddl += `${s}\n`;
|
|
1432
|
-
});
|
|
1433
|
-
for (const skip of plan.skipped) ddl += `-- No ANN index on "${skip.schema}"."${skip.table}"."${skip.column}": ${skip.reason}\n`;
|
|
1434
|
-
ddl += "\n";
|
|
1435
|
-
}
|
|
1436
|
-
return ddl;
|
|
1437
|
-
};
|
|
1438
|
-
var generatePostgresDdl = async (allCollections, options = {
|
|
1439
|
-
includePolicies: true,
|
|
1440
|
-
includeSearch: true,
|
|
1441
|
-
includeVector: true
|
|
1442
|
-
}) => {
|
|
1443
|
-
const collections = relationalCollections(allCollections);
|
|
1444
|
-
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
1445
|
-
const uniqueSchemas = Array.from(/* @__PURE__ */ new Set([REBASE_SCHEMA, ...collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)]));
|
|
1446
|
-
uniqueSchemas.forEach((schema) => {
|
|
1447
|
-
if (schema) ddl += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`;
|
|
1448
|
-
});
|
|
1449
|
-
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
1450
|
-
const searchSpecs = collections.map((c) => buildSearchColumnSpec(c)).filter((s) => s !== void 0);
|
|
1451
|
-
if (searchSpecs.length > 0) if (options.includeSearch === false) ddl += "-- Full-text search support lives in `search.sql`, applied separately.\n\n";
|
|
1452
|
-
else {
|
|
1453
|
-
const extensions = Array.from(new Set(searchSpecs.flatMap(searchExtensionStatements)));
|
|
1454
|
-
const helpers = Array.from(new Set(searchSpecs.flatMap(searchHelperFunctions)));
|
|
1455
|
-
ddl += "-- Full-text search support (collections declaring a `search` block)\n";
|
|
1456
|
-
extensions.forEach((s) => {
|
|
1457
|
-
ddl += `${s}\n`;
|
|
1458
|
-
});
|
|
1459
|
-
if (extensions.length > 0) ddl += "\n";
|
|
1460
|
-
helpers.forEach((s) => {
|
|
1461
|
-
ddl += `${s}\n\n`;
|
|
1462
|
-
});
|
|
1463
|
-
}
|
|
1464
|
-
if (options.includeVector === false && collections.some((c) => buildVectorColumnSpecs(c, resolveColumnName).length > 0)) ddl += "-- Vector columns and their ANN indexes live in `vector.sql`, applied separately.\n\n";
|
|
1465
|
-
const emittedEnums = /* @__PURE__ */ new Set();
|
|
1466
|
-
collections.forEach((collection) => {
|
|
1467
|
-
const collectionTable = getTableName(collection);
|
|
1468
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
1469
|
-
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
1470
|
-
if ("enum" in prop && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
1471
|
-
const enumDbName = `${collectionTable}_${resolveColumnName(propName, prop)}`;
|
|
1472
|
-
const values = Array.isArray(prop.enum) ? prop.enum.map((v) => String(typeof v === "object" && v !== null && "id" in v ? v.id : v)) : Object.keys(prop.enum);
|
|
1473
|
-
if (values.length > 0 && !emittedEnums.has(`${schema}.${enumDbName}`)) {
|
|
1474
|
-
emittedEnums.add(`${schema}.${enumDbName}`);
|
|
1475
|
-
ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});\n`;
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
});
|
|
1479
|
-
});
|
|
1480
|
-
if (ddl.endsWith(";\n")) ddl += "\n";
|
|
1481
|
-
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
1482
|
-
const allTablesToGenerate = /* @__PURE__ */ new Map();
|
|
1483
|
-
for (const collection of collections) {
|
|
1484
|
-
const tableName = getTableName(collection);
|
|
1485
|
-
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
1486
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1487
|
-
for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
|
|
1488
|
-
const junctionTableName = relation.through.table;
|
|
1489
|
-
if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
|
|
1490
|
-
collection: {
|
|
1491
|
-
table: junctionTableName,
|
|
1492
|
-
properties: {}
|
|
1493
|
-
},
|
|
1494
|
-
isJunction: true,
|
|
1495
|
-
relation,
|
|
1496
|
-
sourceCollection: collection
|
|
1497
|
-
});
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
const fkStatements = [];
|
|
1501
|
-
const indexStatements = [];
|
|
1502
|
-
const policyStatements = [];
|
|
1503
|
-
for (const [tableName, { collection, isJunction, relation, sourceCollection }] of allTablesToGenerate.entries()) {
|
|
1504
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
1505
|
-
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
1506
|
-
if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
|
|
1507
|
-
const targetCollection = relation.target();
|
|
1508
|
-
const sourceTable = getTableName(sourceCollection);
|
|
1509
|
-
const targetTable = getTableName(targetCollection);
|
|
1510
|
-
const sourceSchema = isPostgresCollectionConfig(sourceCollection) && sourceCollection.schema ? sourceCollection.schema : "public";
|
|
1511
|
-
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
1512
|
-
const { sourceColumn, targetColumn } = relation.through;
|
|
1513
|
-
const sourceColType = isNumericId(sourceCollection) ? "INTEGER" : getPrimaryKeyProp(sourceCollection).isUuid ? "UUID" : "TEXT";
|
|
1514
|
-
const targetColType = isNumericId(targetCollection) ? "INTEGER" : getPrimaryKeyProp(targetCollection).isUuid ? "UUID" : "TEXT";
|
|
1515
|
-
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
1516
|
-
const targetId = getPrimaryKeyName(targetCollection);
|
|
1517
|
-
const onDelete = relation.onDelete ?? "CASCADE";
|
|
1518
|
-
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
1519
|
-
ddl += ` "${sourceColumn}" ${sourceColType} NOT NULL,\n`;
|
|
1520
|
-
ddl += ` "${targetColumn}" ${targetColType} NOT NULL,\n`;
|
|
1521
|
-
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
1522
|
-
ddl += `);\n\n`;
|
|
1523
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${sourceColumn}_fkey`)}" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
1524
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${targetColumn}_fkey`)}" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
1525
|
-
if (options.includePolicies) {
|
|
1526
|
-
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
1527
|
-
ddl += `\n`;
|
|
1528
|
-
const spec = junctionSpecs.get(baseTableName);
|
|
1529
|
-
if (spec) {
|
|
1530
|
-
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
1531
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
1532
|
-
getJunctionSecurityRules(spec).forEach((rule) => {
|
|
1533
|
-
policyStatements.push(generatePolicyDdl(junctionCollection, rule, resolveCollection));
|
|
1534
|
-
});
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
} else if (!isJunction) {
|
|
1538
|
-
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
1539
|
-
const columns = [];
|
|
1540
|
-
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
1541
|
-
if (prop.type === "vector" && options.includeVector === false) return;
|
|
1542
|
-
if (prop.type === "relation") {
|
|
1543
|
-
const refProp = prop;
|
|
1544
|
-
const relInfo = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
1545
|
-
if (relInfo?.kind !== "belongsTo") return;
|
|
1546
|
-
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) return;
|
|
1547
|
-
let targetCollection;
|
|
1548
|
-
try {
|
|
1549
|
-
targetCollection = relInfo.target();
|
|
1550
|
-
} catch {
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
const targetTable = getTableName(targetCollection);
|
|
1554
|
-
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
1555
|
-
const targetId = getPrimaryKeyName(targetCollection);
|
|
1556
|
-
const fkColType = getSqlColumnType(propName, prop, collection, collections);
|
|
1557
|
-
const onUpdate = relInfo.onUpdate ? ` ON UPDATE ${relInfo.onUpdate.toUpperCase()}` : "";
|
|
1558
|
-
const required = prop.validation?.required;
|
|
1559
|
-
const onDeleteVal = relInfo.onDelete ?? defaultBelongsToOnDelete(required);
|
|
1560
|
-
let colDef = ` "${relInfo.localKey}" ${fkColType}`;
|
|
1561
|
-
if (required) colDef += " NOT NULL";
|
|
1562
|
-
columns.push(colDef);
|
|
1563
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${relInfo.localKey}_fkey`)}" FOREIGN KEY ("${relInfo.localKey}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDeleteVal.toUpperCase()}${onUpdate};`);
|
|
1564
|
-
} else if (prop.type === "reference") {
|
|
1565
|
-
const refProp = prop;
|
|
1566
|
-
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
1567
|
-
const colName = resolveColumnName(propName, prop);
|
|
1568
|
-
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
1569
|
-
const required = prop.validation?.required;
|
|
1570
|
-
if (!targetCollection) {
|
|
1571
|
-
let colDef = ` "${colName}" ${colType}`;
|
|
1572
|
-
if (required) colDef += " NOT NULL";
|
|
1573
|
-
columns.push(colDef);
|
|
1574
|
-
} else {
|
|
1575
|
-
const targetTable = getTableName(targetCollection);
|
|
1576
|
-
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
1577
|
-
const targetId = getPrimaryKeyName(targetCollection);
|
|
1578
|
-
const onDelete = defaultBelongsToOnDelete(required);
|
|
1579
|
-
let colDef = ` "${colName}" ${colType}`;
|
|
1580
|
-
if (required) colDef += " NOT NULL";
|
|
1581
|
-
columns.push(colDef);
|
|
1582
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${colName}_fkey`)}" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
1583
|
-
}
|
|
1584
|
-
} else {
|
|
1585
|
-
const colName = resolveColumnName(propName, prop);
|
|
1586
|
-
const authDefinition = isAuthCollection(collection) ? authUsersColumnDefinition(colName) : void 0;
|
|
1587
|
-
if (authDefinition && !isIdProperty(propName, prop, collection)) {
|
|
1588
|
-
columns.push(` "${colName}" ${authDefinition}`);
|
|
1589
|
-
return;
|
|
1590
|
-
}
|
|
1591
|
-
let colDef = ` "${colName}" ${getSqlColumnType(propName, prop, collection, collections)}`;
|
|
1592
|
-
if (isIdProperty(propName, prop, collection)) colDef += " PRIMARY KEY";
|
|
1593
|
-
if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true && prop.isId !== "increment") {
|
|
1594
|
-
if (prop.isId === "uuid") colDef += " DEFAULT gen_random_uuid()";
|
|
1595
|
-
else if (prop.isId === "cuid") colDef += " DEFAULT cuid()";
|
|
1596
|
-
else if (typeof prop.isId === "string") colDef += ` DEFAULT ${prop.isId}`;
|
|
1597
|
-
}
|
|
1598
|
-
if (!isIdProperty(propName, prop, collection) && prop.validation?.unique) colDef += " UNIQUE";
|
|
1599
|
-
if (prop.type === "date") {
|
|
1600
|
-
const dateProp = prop;
|
|
1601
|
-
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") colDef += " DEFAULT now()";
|
|
1602
|
-
}
|
|
1603
|
-
if (prop.validation?.required && !colDef.includes("PRIMARY KEY")) colDef += " NOT NULL";
|
|
1604
|
-
columns.push(colDef);
|
|
1605
|
-
}
|
|
1606
|
-
});
|
|
1607
|
-
if (isAuthCollection(collection)) {
|
|
1608
|
-
const declared = new Set(Object.entries(collection.properties ?? {}).map(([name, prop]) => resolveColumnName(name, prop)));
|
|
1609
|
-
for (const spec of AUTH_USERS_COLUMNS) {
|
|
1610
|
-
if (declared.has(spec.column)) continue;
|
|
1611
|
-
columns.push(` "${spec.column}" ${authUsersColumnSql(spec)}`);
|
|
1612
|
-
}
|
|
1613
|
-
}
|
|
1614
|
-
const searchSpec = options.includeSearch === false ? void 0 : buildSearchColumnSpec(collection);
|
|
1615
|
-
if (searchSpec) {
|
|
1616
|
-
columns.push(` ${searchColumnDefinition(searchSpec)}`);
|
|
1617
|
-
const fuzzyDef = fuzzyColumnDefinition(searchSpec);
|
|
1618
|
-
if (fuzzyDef) columns.push(` ${fuzzyDef}`);
|
|
1619
|
-
indexStatements.push(...searchIndexStatements(searchSpec));
|
|
1620
|
-
}
|
|
1621
|
-
if (options.includeVector !== false) {
|
|
1622
|
-
const vectorPlan = buildVectorIndexPlan(collection, resolveColumnName);
|
|
1623
|
-
indexStatements.push(...vectorIndexStatements(vectorPlan));
|
|
1624
|
-
for (const skip of vectorPlan.skipped) indexStatements.push(`-- No ANN index on "${skip.schema}"."${skip.table}"."${skip.column}": ${skip.reason}`);
|
|
1625
|
-
}
|
|
1626
|
-
indexStatements.push(...collectionIndexStatements(buildCollectionIndexSpecs(collection, resolveColumnName)));
|
|
1627
|
-
if (!columns.some((c) => c.includes("PRIMARY KEY"))) columns.unshift(" \"id\" TEXT PRIMARY KEY");
|
|
1628
|
-
ddl += columns.join(",\n");
|
|
1629
|
-
ddl += `\n);\n\n`;
|
|
1630
|
-
if (options.includePolicies) {
|
|
1631
|
-
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
1632
|
-
ddl += `\n`;
|
|
1633
|
-
const securityRules = getEffectiveSecurityRules(collection);
|
|
1634
|
-
if (securityRules.length > 0) {
|
|
1635
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
1636
|
-
securityRules.forEach((rule) => {
|
|
1637
|
-
policyStatements.push(generatePolicyDdl(collection, rule, resolveCollection));
|
|
1638
|
-
});
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
if (indexStatements.length > 0) {
|
|
1644
|
-
ddl += "-- Indexes\n";
|
|
1645
|
-
ddl += indexStatements.join("\n") + "\n\n";
|
|
1646
|
-
}
|
|
1647
|
-
if (fkStatements.length > 0) {
|
|
1648
|
-
ddl += "-- Foreign Key Constraints\n";
|
|
1649
|
-
ddl += fkStatements.join("\n") + "\n\n";
|
|
1650
|
-
}
|
|
1651
|
-
if (policyStatements.length > 0) {
|
|
1652
|
-
ddl += "-- Row Level Security Policies\n";
|
|
1653
|
-
ddl += policyStatements.join("");
|
|
1654
|
-
ddl += "\n";
|
|
1655
|
-
}
|
|
1656
|
-
return ddl;
|
|
1657
|
-
};
|
|
1658
|
-
var schemaOfCollection = (collection) => isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
1659
|
-
var bareTableName = (name) => name.includes(".") ? name.split(".").pop() : name;
|
|
1660
|
-
/**
|
|
1661
|
-
* Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
|
|
1662
|
-
*
|
|
1663
|
-
* This is not cosmetic, and not really a naming choice at all — it is agreeing
|
|
1664
|
-
* with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
|
|
1665
|
-
* succeeds and records the truncated form, so the untruncated name this used to
|
|
1666
|
-
* derive matched nothing in the catalogue. Boot-ensure compares its planned
|
|
1667
|
-
* constraints against `readExistingSchema`, which reads catalogue names, so the
|
|
1668
|
-
* comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
|
|
1669
|
-
* constraint, forever, and got "already exists" every time. Non-fatal (foreign
|
|
1670
|
-
* keys are the one action allowed to fail) and therefore permanent — an error in
|
|
1671
|
-
* the log on every restart of a project whose table and column names happened to
|
|
1672
|
-
* be long.
|
|
1673
|
-
*
|
|
1674
|
-
* Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
|
|
1675
|
-
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
1676
|
-
*/
|
|
1677
|
-
var foreignKeyPlan = (args) => {
|
|
1678
|
-
const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
|
|
1679
|
-
const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
|
|
1680
|
-
return {
|
|
1681
|
-
constraintName,
|
|
1682
|
-
schema: args.schema,
|
|
1683
|
-
table: args.table,
|
|
1684
|
-
column: args.column,
|
|
1685
|
-
targetSchema: args.targetSchema,
|
|
1686
|
-
targetTable: args.targetTable,
|
|
1687
|
-
targetColumn: args.targetColumn,
|
|
1688
|
-
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};`
|
|
1689
|
-
};
|
|
1690
|
-
};
|
|
1691
|
-
/**
|
|
1692
|
-
* The FK columns the declared collections own — one entry per `relation`
|
|
1693
|
-
* (`belongsTo` side) or `reference` property.
|
|
1694
|
-
*
|
|
1695
|
-
* Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
|
|
1696
|
-
* create the same columns with the same names, types and constraints. Before
|
|
1697
|
-
* this it skipped them outright, which was survivable only because `db push`
|
|
1698
|
-
* always followed; on a managed tenant nothing follows, so a table arrived
|
|
1699
|
-
* without the column its own collection reads and wrote 400 on every insert.
|
|
1700
|
-
*
|
|
1701
|
-
* A relation whose target is not in the bundle yields no column at all (the
|
|
1702
|
-
* generator returns early on an unresolvable target); a `reference` whose target
|
|
1703
|
-
* is unknown yields the column without a constraint. Both mirror the generator
|
|
1704
|
-
* exactly — a divergence here is a schema fork between boot and `db push`.
|
|
1705
|
-
*/
|
|
1706
|
-
var planRelationalColumns = (allCollections) => {
|
|
1707
|
-
const collections = relationalCollections(allCollections);
|
|
1708
|
-
const plans = [];
|
|
1709
|
-
for (const collection of collections) {
|
|
1710
|
-
const tableName = getTableName(collection);
|
|
1711
|
-
if (!tableName) continue;
|
|
1712
|
-
const schema = schemaOfCollection(collection);
|
|
1713
|
-
const table = bareTableName(tableName);
|
|
1714
|
-
for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
|
|
1715
|
-
const prop = rawProp;
|
|
1716
|
-
if (prop.type === "relation") {
|
|
1717
|
-
const refProp = prop;
|
|
1718
|
-
const relInfo = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
1719
|
-
if (relInfo?.kind !== "belongsTo") continue;
|
|
1720
|
-
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
|
|
1721
|
-
let targetCollection;
|
|
1722
|
-
try {
|
|
1723
|
-
targetCollection = relInfo.target();
|
|
1724
|
-
} catch {
|
|
1725
|
-
continue;
|
|
1726
|
-
}
|
|
1727
|
-
if (!targetCollection) continue;
|
|
1728
|
-
const required = prop.validation?.required;
|
|
1729
|
-
const relationName = refProp.relation?.relationName ?? propName;
|
|
1730
|
-
const legacyKey = legacyForeignKeyName(relationName);
|
|
1731
|
-
const derived = relInfo.localKey === generateForeignKeyName(relationName);
|
|
1732
|
-
plans.push({
|
|
1733
|
-
schema,
|
|
1734
|
-
table,
|
|
1735
|
-
column: relInfo.localKey,
|
|
1736
|
-
legacyColumn: derived && legacyKey !== relInfo.localKey ? legacyKey : void 0,
|
|
1737
|
-
type: getSqlColumnType(propName, prop, collection, collections),
|
|
1738
|
-
foreignKey: foreignKeyPlan({
|
|
1739
|
-
schema,
|
|
1740
|
-
table,
|
|
1741
|
-
column: relInfo.localKey,
|
|
1742
|
-
targetSchema: schemaOfCollection(targetCollection),
|
|
1743
|
-
targetTable: bareTableName(getTableName(targetCollection)),
|
|
1744
|
-
targetColumn: getPrimaryKeyName(targetCollection),
|
|
1745
|
-
onDelete: relInfo.onDelete ?? defaultBelongsToOnDelete(required),
|
|
1746
|
-
onUpdate: relInfo.onUpdate
|
|
1747
|
-
})
|
|
1748
|
-
});
|
|
1749
|
-
} else if (prop.type === "reference") {
|
|
1750
|
-
const refProp = prop;
|
|
1751
|
-
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
1752
|
-
const column = resolveColumnName(propName, prop);
|
|
1753
|
-
const type = getSqlColumnType(propName, prop, collection, collections);
|
|
1754
|
-
const required = prop.validation?.required;
|
|
1755
|
-
plans.push({
|
|
1756
|
-
schema,
|
|
1757
|
-
table,
|
|
1758
|
-
column,
|
|
1759
|
-
type,
|
|
1760
|
-
foreignKey: targetCollection ? foreignKeyPlan({
|
|
1761
|
-
schema,
|
|
1762
|
-
table,
|
|
1763
|
-
column,
|
|
1764
|
-
targetSchema: schemaOfCollection(targetCollection),
|
|
1765
|
-
targetTable: bareTableName(getTableName(targetCollection)),
|
|
1766
|
-
targetColumn: getPrimaryKeyName(targetCollection),
|
|
1767
|
-
onDelete: defaultBelongsToOnDelete(required)
|
|
1768
|
-
}) : void 0
|
|
1769
|
-
});
|
|
1770
|
-
}
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
return plans;
|
|
1774
|
-
};
|
|
1775
|
-
/**
|
|
1776
|
-
* The junction tables a bundle's many-to-many relations imply.
|
|
1777
|
-
*
|
|
1778
|
-
* Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
|
|
1779
|
-
* comes from, so a table created here always has policies planned for it — a
|
|
1780
|
-
* junction with row-level security left off is readable and writable by every
|
|
1781
|
-
* signed-in user, which is why the two must ship together.
|
|
1782
|
-
*/
|
|
1783
|
-
var planJunctionTables = (allCollections) => {
|
|
1784
|
-
const collections = relationalCollections(allCollections);
|
|
1785
|
-
const plans = [];
|
|
1786
|
-
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
1787
|
-
const [source, target] = spec.endpoints;
|
|
1788
|
-
const legacyFor = (endpoint) => {
|
|
1789
|
-
const slug = toSnakeCase(endpoint.collection.slug ?? endpoint.collection.name ?? "");
|
|
1790
|
-
const legacy = legacyForeignKeyName(slug);
|
|
1791
|
-
return endpoint.junctionColumn === generateForeignKeyName(slug) && legacy !== endpoint.junctionColumn ? legacy : void 0;
|
|
1792
|
-
};
|
|
1793
|
-
const columns = [{
|
|
1794
|
-
name: source.junctionColumn,
|
|
1795
|
-
type: junctionKeyType(source.collection),
|
|
1796
|
-
legacyName: legacyFor(source)
|
|
1797
|
-
}, {
|
|
1798
|
-
name: target.junctionColumn,
|
|
1799
|
-
type: junctionKeyType(target.collection),
|
|
1800
|
-
legacyName: legacyFor(target)
|
|
1801
|
-
}];
|
|
1802
|
-
const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
|
|
1803
|
-
plans.push({
|
|
1804
|
-
schema: spec.schema,
|
|
1805
|
-
table: spec.table,
|
|
1806
|
-
columns,
|
|
1807
|
-
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(", ")}));`,
|
|
1808
|
-
foreignKeys: [source, target].map((endpoint, i) => foreignKeyPlan({
|
|
1809
|
-
schema: spec.schema,
|
|
1810
|
-
table: spec.table,
|
|
1811
|
-
column: columns[i].name,
|
|
1812
|
-
targetSchema: schemaOfCollection(endpoint.collection),
|
|
1813
|
-
targetTable: bareTableName(getTableName(endpoint.collection)),
|
|
1814
|
-
targetColumn: getPrimaryKeyName(endpoint.collection),
|
|
1815
|
-
onDelete
|
|
1816
|
-
}))
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
1819
|
-
return plans;
|
|
1820
|
-
};
|
|
1821
|
-
/**
|
|
1822
|
-
* The per-table RLS plan for the *declared* collections, as executable
|
|
1823
|
-
* statements — what the managed runtime applies at boot so a freshly
|
|
1824
|
-
* provisioned tenant database serves data instead of 401ing every read.
|
|
1825
|
-
*
|
|
1826
|
-
* Mirrors {@link generatePostgresPoliciesDdl} exactly (same
|
|
1827
|
-
* `generatePolicyStatements`, same enable-RLS, same effective rules, same
|
|
1828
|
-
* derived junction rules), so boot and `db push` produce identical policies from
|
|
1829
|
-
* identical collections.
|
|
1830
|
-
*
|
|
1831
|
-
* Junction tables are included, and have to be: boot creates them now
|
|
1832
|
-
* ({@link planJunctionTables}), and a junction with RLS left off is readable and
|
|
1833
|
-
* writable by every signed-in user. A junction whose table is still absent is
|
|
1834
|
-
* skipped by the applier, not planned away here.
|
|
1835
|
-
*/
|
|
1836
|
-
var planCollectionPolicies = (allCollections) => {
|
|
1837
|
-
const collections = relationalCollections(allCollections);
|
|
1838
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
1839
|
-
const plans = [];
|
|
1840
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1841
|
-
for (const collection of collections) {
|
|
1842
|
-
const tableName = getTableName(collection);
|
|
1843
|
-
if (!tableName) continue;
|
|
1844
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
1845
|
-
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
1846
|
-
const qualified = `${schema}.${baseTableName}`;
|
|
1847
|
-
if (seen.has(qualified)) continue;
|
|
1848
|
-
seen.add(qualified);
|
|
1849
|
-
const policyStatements = [];
|
|
1850
|
-
for (const rule of getEffectiveSecurityRules(collection)) policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
|
|
1851
|
-
plans.push({
|
|
1852
|
-
schema,
|
|
1853
|
-
table: baseTableName,
|
|
1854
|
-
qualified,
|
|
1855
|
-
enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
|
|
1856
|
-
policyStatements
|
|
1857
|
-
});
|
|
1858
|
-
}
|
|
1859
|
-
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
1860
|
-
const qualified = `${spec.schema}.${spec.table}`;
|
|
1861
|
-
if (seen.has(qualified)) continue;
|
|
1862
|
-
seen.add(qualified);
|
|
1863
|
-
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
1864
|
-
const policyStatements = [];
|
|
1865
|
-
for (const rule of getJunctionSecurityRules(spec)) policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
|
|
1866
|
-
plans.push({
|
|
1867
|
-
schema: spec.schema,
|
|
1868
|
-
table: spec.table,
|
|
1869
|
-
qualified,
|
|
1870
|
-
enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
|
|
1871
|
-
policyStatements
|
|
1872
|
-
});
|
|
1873
|
-
}
|
|
1874
|
-
return plans;
|
|
1875
|
-
};
|
|
1876
|
-
var generatePostgresPoliciesDdl = (allCollections) => {
|
|
1877
|
-
const collections = relationalCollections(allCollections);
|
|
1878
|
-
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
1879
|
-
const allTablesToGenerate = /* @__PURE__ */ new Map();
|
|
1880
|
-
for (const collection of collections) {
|
|
1881
|
-
const tableName = getTableName(collection);
|
|
1882
|
-
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
1883
|
-
}
|
|
1884
|
-
for (const [tableName, { collection }] of allTablesToGenerate.entries()) {
|
|
1885
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
1886
|
-
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
1887
|
-
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
1888
|
-
ddl += `\n`;
|
|
1889
|
-
const securityRules = getEffectiveSecurityRules(collection);
|
|
1890
|
-
if (securityRules.length > 0) {
|
|
1891
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
1892
|
-
const injectedNames = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
|
|
1893
|
-
securityRules.forEach((rule) => {
|
|
1894
|
-
if (rule.name && injectedNames.has(rule.name)) {
|
|
1895
|
-
ddl += `-- Injected by Rebase (not from this collection's securityRules).\n`;
|
|
1896
|
-
ddl += `-- Set \`disableDefaultPolicies: true\` on "${collection.slug}" to drop these and own its RLS outright.\n`;
|
|
1897
|
-
}
|
|
1898
|
-
ddl += generatePolicyDdl(collection, rule, resolveCollection);
|
|
1899
|
-
});
|
|
1900
|
-
ddl += "\n";
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
1904
|
-
for (const spec of junctionSpecs.values()) {
|
|
1905
|
-
ddl += `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
1906
|
-
ddl += `\n`;
|
|
1907
|
-
const junctionRules = getJunctionSecurityRules(spec);
|
|
1908
|
-
if (junctionRules.length === 0) continue;
|
|
1909
|
-
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
1910
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
1911
|
-
const declaringSlugs = spec.declaringSides.map((s) => s.collection.slug).join("\", \"");
|
|
1912
|
-
ddl += `-- Derived by Rebase for the junction "${spec.table}" (no collection declares it).\n`;
|
|
1913
|
-
ddl += `-- Reads require both endpoint rows to be visible; writes follow the update\n`;
|
|
1914
|
-
ddl += `-- rules of "${declaringSlugs}". Set \`disableDefaultPolicies: true\` on the\n`;
|
|
1915
|
-
ddl += `-- declaring collection(s) to drop these and police the junction yourself.\n`;
|
|
1916
|
-
junctionRules.forEach((rule) => {
|
|
1917
|
-
ddl += generatePolicyDdl(junctionCollection, rule, resolveCollection);
|
|
1918
|
-
});
|
|
1919
|
-
ddl += "\n";
|
|
1920
|
-
}
|
|
1921
|
-
return ddl;
|
|
1922
|
-
};
|
|
1923
|
-
//#endregion
|
|
1924
|
-
//#region src/schema/column-type-drift.ts
|
|
1925
|
-
/**
|
|
1926
|
-
* Types that mean the same thing to everything above the driver.
|
|
1927
|
-
*
|
|
1928
|
-
* Keyed by every spelling either side can produce: `information_schema` reports
|
|
1929
|
-
* `udt_name` (`int4`, `_numeric`, `bpchar`), while the generators emit SQL
|
|
1930
|
-
* (`INTEGER`, `NUMERIC[]`, `CHAR(3)`). Both are normalised through here.
|
|
1931
|
-
*/
|
|
1932
|
-
var FAMILIES = {
|
|
1933
|
-
int2: "number",
|
|
1934
|
-
smallint: "number",
|
|
1935
|
-
int4: "number",
|
|
1936
|
-
int: "number",
|
|
1937
|
-
integer: "number",
|
|
1938
|
-
int8: "number",
|
|
1939
|
-
bigint: "number",
|
|
1940
|
-
numeric: "number",
|
|
1941
|
-
decimal: "number",
|
|
1942
|
-
float4: "number",
|
|
1943
|
-
real: "number",
|
|
1944
|
-
float8: "number",
|
|
1945
|
-
"double precision": "number",
|
|
1946
|
-
money: "number",
|
|
1947
|
-
text: "text",
|
|
1948
|
-
varchar: "text",
|
|
1949
|
-
"character varying": "text",
|
|
1950
|
-
bpchar: "text",
|
|
1951
|
-
char: "text",
|
|
1952
|
-
character: "text",
|
|
1953
|
-
citext: "text",
|
|
1954
|
-
bool: "boolean",
|
|
1955
|
-
boolean: "boolean",
|
|
1956
|
-
json: "json",
|
|
1957
|
-
jsonb: "jsonb",
|
|
1958
|
-
uuid: "uuid",
|
|
1959
|
-
bytea: "bytea",
|
|
1960
|
-
date: "date",
|
|
1961
|
-
time: "time",
|
|
1962
|
-
"time without time zone": "time",
|
|
1963
|
-
timetz: "time",
|
|
1964
|
-
"time with time zone": "time",
|
|
1965
|
-
timestamp: "timestamp",
|
|
1966
|
-
"timestamp without time zone": "timestamp",
|
|
1967
|
-
timestamptz: "timestamptz",
|
|
1968
|
-
"timestamp with time zone": "timestamptz",
|
|
1969
|
-
interval: "interval",
|
|
1970
|
-
tsvector: "tsvector",
|
|
1971
|
-
vector: "vector",
|
|
1972
|
-
halfvec: "halfvec",
|
|
1973
|
-
sparsevec: "sparsevec"
|
|
1974
|
-
};
|
|
1975
|
-
/**
|
|
1976
|
-
* One SQL or `udt_name` spelling reduced to its family, or `null` when this
|
|
1977
|
-
* module has no opinion about it.
|
|
1978
|
-
*
|
|
1979
|
-
* `null` is not a failure. It is the answer for an enum type, a domain, a
|
|
1980
|
-
* PostGIS column, a quoted qualified name — everything whose identity is a
|
|
1981
|
-
* *name* rather than a shape, where two spellings being different says nothing
|
|
1982
|
-
* about whether the values are compatible.
|
|
1983
|
-
*/
|
|
1984
|
-
function typeFamily(raw) {
|
|
1985
|
-
if (!raw) return null;
|
|
1986
|
-
let type = raw.trim().toLowerCase();
|
|
1987
|
-
if (!type) return null;
|
|
1988
|
-
if (type.startsWith("\"")) return null;
|
|
1989
|
-
const generated = type.indexOf(" generated ");
|
|
1990
|
-
if (generated > 0) type = type.slice(0, generated).trim();
|
|
1991
|
-
if (type.endsWith("[]")) {
|
|
1992
|
-
const element = typeFamily(type.slice(0, -2));
|
|
1993
|
-
return element && `array:${element}`;
|
|
1994
|
-
}
|
|
1995
|
-
if (type.startsWith("_")) {
|
|
1996
|
-
const element = typeFamily(type.slice(1));
|
|
1997
|
-
return element && `array:${element}`;
|
|
1998
|
-
}
|
|
1999
|
-
const paren = type.indexOf("(");
|
|
2000
|
-
if (paren > 0) type = type.slice(0, paren).trim();
|
|
2001
|
-
return FAMILIES[type] ?? null;
|
|
2002
|
-
}
|
|
2003
|
-
/**
|
|
2004
|
-
* Do these two spellings describe columns that hold the same kind of value?
|
|
2005
|
-
*
|
|
2006
|
-
* `true` whenever the answer is not confidently "no" — an unrecognised type on
|
|
2007
|
-
* either side agrees with everything, because a report needs evidence and an
|
|
2008
|
-
* unknown type is the absence of it.
|
|
2009
|
-
*/
|
|
2010
|
-
function typesAgree(declared, actual) {
|
|
2011
|
-
const a = typeFamily(declared);
|
|
2012
|
-
const b = typeFamily(actual);
|
|
2013
|
-
if (a === null || b === null) return true;
|
|
2014
|
-
return a === b;
|
|
2015
|
-
}
|
|
2016
|
-
/**
|
|
2017
|
-
* The sentence a boot log says about one drifted column.
|
|
2018
|
-
*
|
|
2019
|
-
* Written to be actionable in the place it is read, which is a runtime log with
|
|
2020
|
-
* no way to run a migration from it: it names both types, says which surface
|
|
2021
|
-
* will break first, and gives the statement that resolves it.
|
|
2022
|
-
*/
|
|
2023
|
-
function columnTypeDriftMessage(drift) {
|
|
2024
|
-
const [schema, table] = drift.table.split(".");
|
|
2025
|
-
return `Column "${drift.table}"."${drift.column}" is ${drift.actual} in the database, but this collection declares ${drift.declared}. Boot does not change an existing column's type, so the database keeps what it has and writes that match the declaration will be rejected by Postgres. Change it deliberately, when you can watch it:
|
|
2026
|
-
ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${drift.column}" TYPE ${drift.declared} USING "${drift.column}"::${drift.declared};`;
|
|
2027
|
-
}
|
|
2028
|
-
//#endregion
|
|
2029
|
-
//#region src/schema/ensure-collection-tables.ts
|
|
2030
|
-
/**
|
|
2031
|
-
* Bringing a database up to date with a bundle's collections, additively.
|
|
2032
|
-
*
|
|
2033
|
-
* ## Why this exists
|
|
2034
|
-
*
|
|
2035
|
-
* A managed runtime boots someone else's compiled project against a database it
|
|
2036
|
-
* has never seen. Auth tables are ensured at boot already, but collection tables
|
|
2037
|
-
* were not created by anything: the platform ran the app and every `/api/data/*`
|
|
2038
|
-
* request answered 500 on a missing relation. `rebase db push` cannot help — it
|
|
2039
|
-
* is an Atlas-driven CLI command, and the runtime image ships no CLI.
|
|
2040
|
-
*
|
|
2041
|
-
* ## Why additive-only, forever
|
|
2042
|
-
*
|
|
2043
|
-
* This runs unattended, against a database with customers' data in it, with no
|
|
2044
|
-
* human reading a diff. So it may only ever do things that cannot lose data:
|
|
2045
|
-
* create a missing table, add a missing column, create a missing enum type.
|
|
2046
|
-
*
|
|
2047
|
-
* It will **never** drop a table or a column, narrow a type, or alter a
|
|
2048
|
-
* constraint. A removed field leaves its column behind; a renamed field looks
|
|
2049
|
-
* like an addition and the old column stays. That is the correct trade for an
|
|
2050
|
-
* automated path — the alternative is an unattended process that can silently
|
|
2051
|
-
* destroy a column, which is precisely the failure `db push` was hardened
|
|
2052
|
-
* against. Destructive changes stay a deliberate, human-reviewed migration.
|
|
2053
|
-
*
|
|
2054
|
-
* Because of that, this is safe to run on every boot, and re-running it is a
|
|
2055
|
-
* no-op.
|
|
2056
|
-
*/
|
|
2057
|
-
var ensure_collection_tables_exports = /* @__PURE__ */ __exportAll({
|
|
2058
|
-
describeDriverError: () => describeDriverError,
|
|
2059
|
-
ensureCollectionTables: () => ensureCollectionTables,
|
|
2060
|
-
planCollectionSchemaEnsure: () => planCollectionSchemaEnsure,
|
|
2061
|
-
readExistingSchema: () => readExistingSchema,
|
|
2062
|
-
readSchemaFactsFor: () => readSchemaFactsFor
|
|
2063
|
-
});
|
|
2064
|
-
/** Postgres identifiers this module is willing to interpolate. */
|
|
2065
|
-
var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
2066
|
-
function assertSafeIdentifier(value, what) {
|
|
2067
|
-
if (!SAFE_IDENTIFIER.test(value)) throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
|
|
2068
|
-
return value;
|
|
2069
|
-
}
|
|
2070
|
-
/**
|
|
2071
|
-
* The schema a collection lives in — validated, because it is about to be
|
|
2072
|
-
* interpolated into DDL.
|
|
2073
|
-
*
|
|
2074
|
-
* Validation used to run only on the names read back OUT of the catalogue,
|
|
2075
|
-
* which is the direction that cannot hurt anyone: those came from Postgres. The
|
|
2076
|
-
* names going IN — the schema and table a collection declares — were quoted and
|
|
2077
|
-
* concatenated on trust, and quoting is not escaping. A table named
|
|
2078
|
-
* `x" (id int); DROP TABLE users; --` closes the quote and the statement, and
|
|
2079
|
-
* the whole thing runs on the owner connection, which is the one connection in
|
|
2080
|
-
* the system that is exempt from every RLS policy.
|
|
2081
|
-
*
|
|
2082
|
-
* That is only a config file on a self-hosted project, where the person writing
|
|
2083
|
-
* it could run the SQL directly anyway. It is not only a config file where a
|
|
2084
|
-
* collection can be defined over the wire — the live-schema and source editors
|
|
2085
|
-
* both do that — and there the caller is an admin on the app, not an operator
|
|
2086
|
-
* of the database.
|
|
2087
|
-
*/
|
|
2088
|
-
function schemaOf(collection) {
|
|
2089
|
-
return assertSafeIdentifier(isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public", "schema name");
|
|
2090
|
-
}
|
|
2091
|
-
/** A collection's table name, validated for the same reason as {@link schemaOf}. */
|
|
2092
|
-
function tableOf(collection) {
|
|
2093
|
-
return assertSafeIdentifier(getTableName(collection), "table name");
|
|
2094
|
-
}
|
|
2095
|
-
function qualified(collection) {
|
|
2096
|
-
return `${schemaOf(collection)}.${tableOf(collection)}`;
|
|
2097
|
-
}
|
|
2098
|
-
/**
|
|
2099
|
-
* Enum types a collection's properties require, as `schema.typename`.
|
|
2100
|
-
*
|
|
2101
|
-
* Named exactly as the DDL generator names them (`<table>_<column>`), because
|
|
2102
|
-
* a column added here has to reference the same type the generator would have
|
|
2103
|
-
* created — a second, differently-named type for the same field would be a
|
|
2104
|
-
* silent schema fork.
|
|
2105
|
-
*/
|
|
2106
|
-
function requiredEnums(collection) {
|
|
2107
|
-
const table = tableOf(collection);
|
|
2108
|
-
const schema = schemaOf(collection);
|
|
2109
|
-
const out = [];
|
|
2110
|
-
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
2111
|
-
const p = prop;
|
|
2112
|
-
if (!("enum" in p) || !p.enum) continue;
|
|
2113
|
-
if (p.type !== "string" && p.type !== "number") continue;
|
|
2114
|
-
const values = p.enum.map((entry) => entry && typeof entry === "object" && "id" in entry ? String(entry.id) : String(entry)).filter((v) => v.length > 0);
|
|
2115
|
-
if (values.length === 0) continue;
|
|
2116
|
-
out.push({
|
|
2117
|
-
name: `${schema}.${table}_${resolveColumnName(propName, p)}`,
|
|
2118
|
-
values
|
|
2119
|
-
});
|
|
2120
|
-
}
|
|
2121
|
-
return out;
|
|
2122
|
-
}
|
|
2123
|
-
/**
|
|
2124
|
-
* Decide what to add. Pure — the caller supplies what exists and runs the result.
|
|
2125
|
-
*
|
|
2126
|
-
* Ordering matters and is deliberate: enum types before the tables and columns
|
|
2127
|
-
* that reference them, tables before the columns added to other tables (a new
|
|
2128
|
-
* table may be the target of a relation), and nothing is emitted twice.
|
|
2129
|
-
*/
|
|
2130
|
-
function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
|
|
2131
|
-
const constraintPolicy = options.constraints ?? "additive";
|
|
2132
|
-
const withheldConstraints = [];
|
|
2133
|
-
const columnTypeDrift = [];
|
|
2134
|
-
assertSearchIsPostgresOnly(allCollections);
|
|
2135
|
-
const collections = relationalCollections(allCollections);
|
|
2136
|
-
const actions = [];
|
|
2137
|
-
const plannedEnums = /* @__PURE__ */ new Set();
|
|
2138
|
-
for (const collection of collections) for (const { name, values } of requiredEnums(collection)) {
|
|
2139
|
-
if (existing.enums.has(name) || plannedEnums.has(name)) {
|
|
2140
|
-
const current = existing.enumValues?.get(name);
|
|
2141
|
-
if (!current || plannedEnums.has(name)) continue;
|
|
2142
|
-
const [schema, typeName] = name.split(".");
|
|
2143
|
-
for (const value of values) {
|
|
2144
|
-
if (current.includes(value)) continue;
|
|
2145
|
-
actions.push({
|
|
2146
|
-
kind: "add-enum-value",
|
|
2147
|
-
target: `${name}.${value}`,
|
|
2148
|
-
sql: `ALTER TYPE "${schema}"."${typeName}" ADD VALUE IF NOT EXISTS ${quoteSqlLiteral(value)};`
|
|
2149
|
-
});
|
|
2150
|
-
}
|
|
2151
|
-
continue;
|
|
2152
|
-
}
|
|
2153
|
-
plannedEnums.add(name);
|
|
2154
|
-
const [schema, typeName] = name.split(".");
|
|
2155
|
-
actions.push({
|
|
2156
|
-
kind: "create-enum",
|
|
2157
|
-
target: name,
|
|
2158
|
-
sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});`
|
|
2159
|
-
});
|
|
2160
|
-
}
|
|
2161
|
-
const searchSpecs = collections.map((c) => buildSearchColumnSpec(c)).filter((spec) => spec !== void 0);
|
|
2162
|
-
const plannedExtensions = /* @__PURE__ */ new Set();
|
|
2163
|
-
const planExtension = (statement) => {
|
|
2164
|
-
if (plannedExtensions.has(statement)) return;
|
|
2165
|
-
plannedExtensions.add(statement);
|
|
2166
|
-
actions.push({
|
|
2167
|
-
kind: "create-extension",
|
|
2168
|
-
target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""),
|
|
2169
|
-
sql: statement
|
|
2170
|
-
});
|
|
2171
|
-
};
|
|
2172
|
-
for (const spec of searchSpecs) for (const statement of searchExtensionStatements(spec)) planExtension(statement);
|
|
2173
|
-
if (vectorExtensionDeclared(options.databaseExtensions) && collections.some((c) => buildVectorColumnSpecs(c, resolveColumnName).length > 0)) planExtension(vectorExtensionStatement());
|
|
2174
|
-
const plannedFunctions = /* @__PURE__ */ new Set();
|
|
2175
|
-
for (const spec of searchSpecs) for (const statement of searchHelperFunctions(spec)) {
|
|
2176
|
-
if (plannedFunctions.has(statement)) continue;
|
|
2177
|
-
plannedFunctions.add(statement);
|
|
2178
|
-
actions.push({
|
|
2179
|
-
kind: "create-function",
|
|
2180
|
-
target: statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN,
|
|
2181
|
-
sql: statement
|
|
2182
|
-
});
|
|
2183
|
-
}
|
|
2184
|
-
const created = /* @__PURE__ */ new Set();
|
|
2185
|
-
for (const collection of collections) {
|
|
2186
|
-
const key = qualified(collection);
|
|
2187
|
-
if (existing.tables.has(key) || created.has(key)) continue;
|
|
2188
|
-
created.add(key);
|
|
2189
|
-
const schema = schemaOf(collection);
|
|
2190
|
-
const table = tableOf(collection);
|
|
2191
|
-
const idEntry = Object.entries(collection.properties ?? {}).find(([n, p]) => isIdProperty(n, p, collection));
|
|
2192
|
-
const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1]) : "id";
|
|
2193
|
-
const idProp = idEntry?.[1];
|
|
2194
|
-
const idType = idProp ? getSqlColumnType(idEntry[0], idProp, collection, collections) : "TEXT";
|
|
2195
|
-
let idDef = `"${assertSafeIdentifier(idName, "column name")}" ${idType} PRIMARY KEY`;
|
|
2196
|
-
if (idProp?.type === "string" && idProp.isId === "uuid") idDef += " DEFAULT gen_random_uuid()";
|
|
2197
|
-
actions.push({
|
|
2198
|
-
kind: "create-table",
|
|
2199
|
-
target: key,
|
|
2200
|
-
sql: `CREATE TABLE IF NOT EXISTS "${schema}"."${table}" (${idDef});`
|
|
2201
|
-
});
|
|
2202
|
-
}
|
|
2203
|
-
const junctions = planJunctionTables(collections);
|
|
2204
|
-
for (const junction of junctions) {
|
|
2205
|
-
const key = `${junction.schema}.${junction.table}`;
|
|
2206
|
-
if (existing.tables.has(key) || created.has(key)) continue;
|
|
2207
|
-
created.add(key);
|
|
2208
|
-
actions.push({
|
|
2209
|
-
kind: "create-table",
|
|
2210
|
-
target: key,
|
|
2211
|
-
sql: junction.createTable
|
|
2212
|
-
});
|
|
2213
|
-
}
|
|
2214
|
-
const legacyForeignKeys = [];
|
|
2215
|
-
/**
|
|
2216
|
-
* Move a relation column that is only missing because it was renamed.
|
|
2217
|
-
*
|
|
2218
|
-
* Returns true when it handled the column, so the caller skips the ordinary
|
|
2219
|
-
* ADD. Adding here would be the wrong move and a quiet one: the data is in
|
|
2220
|
-
* the old column, `ADD COLUMN` creates the new one empty beside it, every
|
|
2221
|
-
* statement succeeds, and the relation reads the empty one. A rename is
|
|
2222
|
-
* metadata-only in Postgres, keeps the values, and carries the column's
|
|
2223
|
-
* indexes and constraints with it.
|
|
2224
|
-
*
|
|
2225
|
-
* Only ever reached when the new name is absent and the old name is
|
|
2226
|
-
* present, so there is nothing to overwrite and nothing to choose between.
|
|
2227
|
-
*/
|
|
2228
|
-
const renameLegacyColumn = (key, schema, table, column, legacyName) => {
|
|
2229
|
-
const present = existing.tables.get(key);
|
|
2230
|
-
if (!legacyName || !present) return false;
|
|
2231
|
-
if (present.has(column) || !present.has(legacyName)) return false;
|
|
2232
|
-
legacyForeignKeys.push({
|
|
2233
|
-
table: key,
|
|
2234
|
-
expected: column,
|
|
2235
|
-
legacy: legacyName
|
|
2236
|
-
});
|
|
2237
|
-
actions.push({
|
|
2238
|
-
kind: "rename-column",
|
|
2239
|
-
target: `${key}.${column}`,
|
|
2240
|
-
sql: `ALTER TABLE "${schema}"."${table}" RENAME COLUMN "${legacyName}" TO "${column}";`
|
|
2241
|
-
});
|
|
2242
|
-
return true;
|
|
2243
|
-
};
|
|
2244
|
-
const addColumn = (key, schema, table, column, definition) => {
|
|
2245
|
-
if (existing.tables.get(key)?.has(column)) return;
|
|
2246
|
-
assertSafeIdentifier(column, "column name");
|
|
2247
|
-
actions.push({
|
|
2248
|
-
kind: "add-column",
|
|
2249
|
-
target: `${key}.${column}`,
|
|
2250
|
-
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
|
|
2251
|
-
});
|
|
2252
|
-
};
|
|
2253
|
-
for (const collection of collections) {
|
|
2254
|
-
const key = qualified(collection);
|
|
2255
|
-
const schema = schemaOf(collection);
|
|
2256
|
-
const table = tableOf(collection);
|
|
2257
|
-
const fresh = created.has(key);
|
|
2258
|
-
const auth = isAuthCollection(collection);
|
|
2259
|
-
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
2260
|
-
const p = prop;
|
|
2261
|
-
if (isIdProperty(propName, p, collection)) continue;
|
|
2262
|
-
if (p.type === "reference" || p.type === "relation") continue;
|
|
2263
|
-
const column = resolveColumnName(propName, p);
|
|
2264
|
-
const authDefinition = auth ? authUsersColumnDefinition(column) : void 0;
|
|
2265
|
-
if (authDefinition) {
|
|
2266
|
-
addColumn(key, schema, table, column, authDefinition);
|
|
2267
|
-
continue;
|
|
2268
|
-
}
|
|
2269
|
-
const declaredType = getSqlColumnType(propName, p, collection, collections);
|
|
2270
|
-
let definition = declaredType;
|
|
2271
|
-
if (fresh && p.validation?.unique) definition += " UNIQUE";
|
|
2272
|
-
const autoValue = p.autoValue;
|
|
2273
|
-
const hasDefault = p.type === "date" && (autoValue === "on_create" || autoValue === "on_update");
|
|
2274
|
-
if (hasDefault) definition += " DEFAULT now()";
|
|
2275
|
-
const required = p.validation?.required === true;
|
|
2276
|
-
const columnKey = `${key}.${column}`;
|
|
2277
|
-
const columnExists = existing.tables.get(key)?.has(column) === true;
|
|
2278
|
-
const actualType = existing.columnTypes?.get(columnKey);
|
|
2279
|
-
if (columnExists && actualType && !typesAgree(declaredType, actualType)) columnTypeDrift.push({
|
|
2280
|
-
table: key,
|
|
2281
|
-
column,
|
|
2282
|
-
declared: declaredType,
|
|
2283
|
-
actual: actualType
|
|
2284
|
-
});
|
|
2285
|
-
const tableIsEmpty = existing.populatedTables !== void 0 && existing.tables.has(key) && !existing.populatedTables.has(key);
|
|
2286
|
-
const notNullIsSafe = fresh || tableIsEmpty || hasDefault;
|
|
2287
|
-
if (required && !columnExists) if (notNullIsSafe) definition += " NOT NULL";
|
|
2288
|
-
else withheldConstraints.push({
|
|
2289
|
-
target: columnKey,
|
|
2290
|
-
kind: "not-null",
|
|
2291
|
-
reason: `"${column}" is required, but "${key}" already holds rows and the column has no default to backfill them with, so NOT NULL would be checked against data that does not have a value yet.`,
|
|
2292
|
-
remedy: "Backfill the column, then add the constraint — or give the property a default so every existing row gets one."
|
|
2293
|
-
});
|
|
2294
|
-
addColumn(key, schema, table, column, definition);
|
|
2295
|
-
if (columnExists && constraintPolicy === "converge") {
|
|
2296
|
-
const isNotNull = existing.notNullColumns?.has(columnKey) === true;
|
|
2297
|
-
if (required && !isNotNull) if (tableIsEmpty) actions.push({
|
|
2298
|
-
kind: "set-not-null",
|
|
2299
|
-
target: columnKey,
|
|
2300
|
-
sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" SET NOT NULL;`
|
|
2301
|
-
});
|
|
2302
|
-
else withheldConstraints.push({
|
|
2303
|
-
target: columnKey,
|
|
2304
|
-
kind: "not-null",
|
|
2305
|
-
reason: `"${column}" became required, but "${key}" holds rows and any of them with no value would make SET NOT NULL fail.`,
|
|
2306
|
-
remedy: "Backfill the column first — `UPDATE … SET \"" + column + "\" = … WHERE \"" + column + "\" IS NULL` — then apply this again."
|
|
2307
|
-
});
|
|
2308
|
-
if (!required && isNotNull) actions.push({
|
|
2309
|
-
kind: "drop-not-null",
|
|
2310
|
-
target: columnKey,
|
|
2311
|
-
sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" DROP NOT NULL;`
|
|
2312
|
-
});
|
|
2313
|
-
}
|
|
2314
|
-
}
|
|
2315
|
-
if (auth) {
|
|
2316
|
-
const declared = new Set(Object.entries(collection.properties ?? {}).map(([name, prop]) => resolveColumnName(name, prop)));
|
|
2317
|
-
for (const spec of AUTH_USERS_COLUMNS) {
|
|
2318
|
-
if (declared.has(spec.column)) continue;
|
|
2319
|
-
addColumn(key, schema, table, spec.column, authUsersColumnSql(spec));
|
|
2320
|
-
}
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
|
-
const searchDrift = [];
|
|
2324
|
-
const searchAdopted = [];
|
|
2325
|
-
for (const spec of searchSpecs) {
|
|
2326
|
-
const key = `${spec.schema}.${spec.table}`;
|
|
2327
|
-
const definitions = { [spec.column]: `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED` };
|
|
2328
|
-
if (spec.fuzzy) definitions[spec.fuzzy.column] = `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`;
|
|
2329
|
-
for (const stamp of searchColumnStamps(spec)) {
|
|
2330
|
-
const definition = definitions[stamp.column];
|
|
2331
|
-
const exists = existing.tables.get(key)?.has(stamp.column) === true;
|
|
2332
|
-
const recorded = existing.columnComments?.get(`${key}.${stamp.column}`);
|
|
2333
|
-
if (exists && recorded?.startsWith("rebase:search:v1:") && recorded !== stamp.fingerprint) {
|
|
2334
|
-
searchDrift.push({
|
|
2335
|
-
table: key,
|
|
2336
|
-
column: stamp.column,
|
|
2337
|
-
found: recorded,
|
|
2338
|
-
expected: stamp.fingerprint,
|
|
2339
|
-
rebuild: [
|
|
2340
|
-
`ALTER TABLE "${spec.schema}"."${spec.table}" DROP COLUMN "${stamp.column}";`,
|
|
2341
|
-
`ALTER TABLE "${spec.schema}"."${spec.table}" ADD COLUMN "${stamp.column}" ${definition};`,
|
|
2342
|
-
stamp.sql
|
|
2343
|
-
]
|
|
2344
|
-
});
|
|
2345
|
-
continue;
|
|
2346
|
-
}
|
|
2347
|
-
addColumn(key, spec.schema, spec.table, stamp.column, definition);
|
|
2348
|
-
if (exists && recorded === void 0) searchAdopted.push({
|
|
2349
|
-
table: key,
|
|
2350
|
-
column: stamp.column
|
|
2351
|
-
});
|
|
2352
|
-
if (recorded !== stamp.fingerprint) actions.push({
|
|
2353
|
-
kind: "comment-column",
|
|
2354
|
-
target: `${key}.${stamp.column}`,
|
|
2355
|
-
sql: stamp.sql
|
|
2356
|
-
});
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
for (const junction of junctions) {
|
|
2360
|
-
const key = `${junction.schema}.${junction.table}`;
|
|
2361
|
-
if (created.has(key)) continue;
|
|
2362
|
-
for (const column of junction.columns) {
|
|
2363
|
-
if (renameLegacyColumn(key, junction.schema, junction.table, column.name, column.legacyName)) continue;
|
|
2364
|
-
addColumn(key, junction.schema, junction.table, column.name, column.type);
|
|
2365
|
-
}
|
|
2366
|
-
}
|
|
2367
|
-
const plannedRelationColumns = /* @__PURE__ */ new Map();
|
|
2368
|
-
for (const relational of planRelationalColumns(collections)) {
|
|
2369
|
-
const relKey = `${relational.schema}.${relational.table}`;
|
|
2370
|
-
if (!plannedRelationColumns.has(relKey)) plannedRelationColumns.set(relKey, /* @__PURE__ */ new Set());
|
|
2371
|
-
plannedRelationColumns.get(relKey).add(relational.column);
|
|
2372
|
-
if (relational.legacyColumn) plannedRelationColumns.get(relKey).add(relational.legacyColumn);
|
|
2373
|
-
if (renameLegacyColumn(relKey, relational.schema, relational.table, relational.column, relational.legacyColumn)) continue;
|
|
2374
|
-
addColumn(relKey, relational.schema, relational.table, relational.column, relational.type);
|
|
2375
|
-
}
|
|
2376
|
-
const knownConstraints = existing.constraints ?? /* @__PURE__ */ new Set();
|
|
2377
|
-
const plannedConstraints = /* @__PURE__ */ new Set();
|
|
2378
|
-
const foreignKeys = [...planRelationalColumns(collections).map((r) => r.foreignKey), ...junctions.flatMap((j) => j.foreignKeys)];
|
|
2379
|
-
for (const fk of foreignKeys) {
|
|
2380
|
-
if (!fk) continue;
|
|
2381
|
-
const name = `${fk.schema}.${fk.table}.${fk.constraintName}`;
|
|
2382
|
-
if (knownConstraints.has(name) || plannedConstraints.has(name)) continue;
|
|
2383
|
-
plannedConstraints.add(name);
|
|
2384
|
-
actions.push({
|
|
2385
|
-
kind: "add-constraint",
|
|
2386
|
-
target: `${fk.schema}.${fk.table}.${fk.constraintName}`,
|
|
2387
|
-
sql: fk.sql
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
const concurrentIndexing = (schema, table) => !created.has(`${schema}.${table}`);
|
|
2391
|
-
for (const spec of searchSpecs) for (const statement of searchIndexStatements(spec)) actions.push({
|
|
2392
|
-
kind: "create-index",
|
|
2393
|
-
target: `${spec.schema}.${spec.table}`,
|
|
2394
|
-
sql: concurrentIndexing(spec.schema, spec.table) ? statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : statement
|
|
2395
|
-
});
|
|
2396
|
-
const vectorIndexSkipped = [];
|
|
2397
|
-
for (const collection of collections) {
|
|
2398
|
-
const plan = buildVectorIndexPlan(collection, resolveColumnName);
|
|
2399
|
-
for (const spec of plan.specs) actions.push({
|
|
2400
|
-
kind: "create-index",
|
|
2401
|
-
target: `${spec.schema}.${spec.table}`,
|
|
2402
|
-
sql: concurrentIndexing(spec.schema, spec.table) ? vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS") : vectorIndexStatement(spec)
|
|
2403
|
-
});
|
|
2404
|
-
vectorIndexSkipped.push(...plan.skipped);
|
|
2405
|
-
}
|
|
2406
|
-
for (const spec of buildCollectionIndexPlan(collections, resolveColumnName)) actions.push({
|
|
2407
|
-
kind: "create-index",
|
|
2408
|
-
target: `${spec.schema}.${spec.table}`,
|
|
2409
|
-
sql: collectionIndexStatement(spec, {
|
|
2410
|
-
concurrently: concurrentIndexing(spec.schema, spec.table),
|
|
2411
|
-
ifNotExists: true
|
|
2412
|
-
})
|
|
2413
|
-
});
|
|
2414
|
-
const orphanedRequiredColumns = [];
|
|
2415
|
-
if (existing.notNullColumns && existing.columnDefaults) for (const collection of collections) {
|
|
2416
|
-
const key = qualified(collection);
|
|
2417
|
-
const live = existing.tables.get(key);
|
|
2418
|
-
if (!live || created.has(key)) continue;
|
|
2419
|
-
const declared = /* @__PURE__ */ new Set();
|
|
2420
|
-
for (const [propName, prop] of Object.entries(collection.properties ?? {})) declared.add(resolveColumnName(propName, prop));
|
|
2421
|
-
for (const column of plannedRelationColumns.get(key) ?? []) declared.add(column);
|
|
2422
|
-
for (const column of live) {
|
|
2423
|
-
const columnKey = `${key}.${column}`;
|
|
2424
|
-
if (declared.has(column)) continue;
|
|
2425
|
-
if (!existing.notNullColumns.has(columnKey)) continue;
|
|
2426
|
-
if (existing.columnDefaults.has(columnKey)) continue;
|
|
2427
|
-
orphanedRequiredColumns.push({
|
|
2428
|
-
table: key,
|
|
2429
|
-
column,
|
|
2430
|
-
slug: collection.slug
|
|
2431
|
-
});
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
return {
|
|
2435
|
-
actions,
|
|
2436
|
-
statements: actions.map((a) => a.sql),
|
|
2437
|
-
legacyForeignKeys,
|
|
2438
|
-
searchDrift,
|
|
2439
|
-
searchAdopted,
|
|
2440
|
-
vectorIndexSkipped,
|
|
2441
|
-
withheldConstraints,
|
|
2442
|
-
columnTypeDrift,
|
|
2443
|
-
orphanedRequiredColumns
|
|
2444
|
-
};
|
|
2445
|
-
}
|
|
2446
|
-
/** Read what the database has, for the schemas the collections live in. */
|
|
2447
|
-
async function readExistingSchema(client, schemas) {
|
|
2448
|
-
const tables = /* @__PURE__ */ new Map();
|
|
2449
|
-
const enums = /* @__PURE__ */ new Set();
|
|
2450
|
-
if (schemas.length === 0) return {
|
|
2451
|
-
tables,
|
|
2452
|
-
enums
|
|
2453
|
-
};
|
|
2454
|
-
const inList = schemas.map((schema) => `'${assertSafeIdentifier(schema, "schema name")}'`).join(", ");
|
|
2455
|
-
const notNullColumns = /* @__PURE__ */ new Set();
|
|
2456
|
-
const columnTypes = /* @__PURE__ */ new Map();
|
|
2457
|
-
const columnDefaults = /* @__PURE__ */ new Set();
|
|
2458
|
-
const { rows: columns } = await client.query(`SELECT table_schema, table_name, column_name, is_nullable, udt_name, column_default
|
|
2459
|
-
FROM information_schema.columns
|
|
2460
|
-
WHERE table_schema IN (${inList})`);
|
|
2461
|
-
for (const row of columns) {
|
|
2462
|
-
const key = `${row.table_schema}.${row.table_name}`;
|
|
2463
|
-
if (!tables.has(key)) tables.set(key, /* @__PURE__ */ new Set());
|
|
2464
|
-
tables.get(key).add(row.column_name);
|
|
2465
|
-
if (row.is_nullable === "NO") notNullColumns.add(`${key}.${row.column_name}`);
|
|
2466
|
-
if (row.udt_name) columnTypes.set(`${key}.${row.column_name}`, row.udt_name);
|
|
2467
|
-
if (row.column_default !== null) columnDefaults.add(`${key}.${row.column_name}`);
|
|
2468
|
-
}
|
|
2469
|
-
const populatedTables = /* @__PURE__ */ new Set();
|
|
2470
|
-
const { rows: realTables } = await client.query(`SELECT n.nspname AS schema, c.relname AS name
|
|
2471
|
-
FROM pg_class c
|
|
2472
|
-
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
2473
|
-
WHERE c.relkind IN ('r', 'p') AND n.nspname IN (${inList})`);
|
|
2474
|
-
if (realTables.length > 0) {
|
|
2475
|
-
const probes = realTables.map((row) => {
|
|
2476
|
-
const schema = assertSafeIdentifier(row.schema, "schema name");
|
|
2477
|
-
const table = assertSafeIdentifier(row.name, "table name");
|
|
2478
|
-
return `SELECT ${quoteSqlLiteral(`${schema}.${table}`)} AS key, EXISTS(SELECT 1 FROM "${schema}"."${table}" LIMIT 1) AS populated`;
|
|
2479
|
-
});
|
|
2480
|
-
const { rows: populationRows } = await client.query(probes.join(" UNION ALL "));
|
|
2481
|
-
for (const row of populationRows) if (row.populated) populatedTables.add(row.key);
|
|
2482
|
-
}
|
|
2483
|
-
const enumValues = /* @__PURE__ */ new Map();
|
|
2484
|
-
const { rows: enumValueRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name, e.enumlabel AS value
|
|
2485
|
-
FROM pg_enum e
|
|
2486
|
-
JOIN pg_type t ON e.enumtypid = t.oid
|
|
2487
|
-
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
2488
|
-
WHERE n.nspname IN (${inList})
|
|
2489
|
-
ORDER BY t.typname, e.enumsortorder`);
|
|
2490
|
-
for (const row of enumValueRows) {
|
|
2491
|
-
const key = `${row.schema}.${row.name}`;
|
|
2492
|
-
if (!enumValues.has(key)) enumValues.set(key, []);
|
|
2493
|
-
enumValues.get(key).push(row.value);
|
|
2494
|
-
}
|
|
2495
|
-
const { rows: enumRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name
|
|
2496
|
-
FROM pg_type t
|
|
2497
|
-
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
2498
|
-
WHERE t.typtype = 'e' AND n.nspname IN (${inList})`);
|
|
2499
|
-
for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
|
|
2500
|
-
const constraints = /* @__PURE__ */ new Set();
|
|
2501
|
-
const { rows: constraintRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
|
|
2502
|
-
FROM pg_constraint con
|
|
2503
|
-
JOIN pg_class c ON con.conrelid = c.oid
|
|
2504
|
-
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
2505
|
-
WHERE n.nspname IN (${inList})`);
|
|
2506
|
-
for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
|
|
2507
|
-
const columnComments = /* @__PURE__ */ new Map();
|
|
2508
|
-
const { rows: commentRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, d.description AS comment
|
|
2509
|
-
FROM pg_description d
|
|
2510
|
-
JOIN pg_class c ON d.objoid = c.oid
|
|
2511
|
-
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
2512
|
-
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid
|
|
2513
|
-
WHERE d.objsubid > 0 AND n.nspname IN (${inList})`);
|
|
2514
|
-
for (const row of commentRows) {
|
|
2515
|
-
if (row.comment == null) continue;
|
|
2516
|
-
columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
|
|
2517
|
-
}
|
|
2518
|
-
return {
|
|
2519
|
-
tables,
|
|
2520
|
-
enums,
|
|
2521
|
-
constraints,
|
|
2522
|
-
columnComments,
|
|
2523
|
-
enumValues,
|
|
2524
|
-
notNullColumns,
|
|
2525
|
-
populatedTables,
|
|
2526
|
-
columnTypes,
|
|
2527
|
-
columnDefaults
|
|
2528
|
-
};
|
|
2529
|
-
}
|
|
2530
|
-
/**
|
|
2531
|
-
* What to tell an operator whose `search` block no longer matches its column.
|
|
2532
|
-
*
|
|
2533
|
-
* Every line here is doing work: naming the collection is not enough, because
|
|
2534
|
-
* the symptom (a search that finds nothing) points at the data, not the schema;
|
|
2535
|
-
* and the remediation has to be exact, because it is a table rewrite the
|
|
2536
|
-
* operator is being asked to schedule rather than discover.
|
|
2537
|
-
*/
|
|
2538
|
-
function searchDriftMessage(drift) {
|
|
2539
|
-
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.";
|
|
2540
|
-
}
|
|
2541
|
-
/**
|
|
2542
|
-
* Read what the database looks like, for the schemas a set of collections
|
|
2543
|
-
* lives in.
|
|
2544
|
-
*
|
|
2545
|
-
* The same read `ensureCollectionTables` does at boot, exposed on its own for
|
|
2546
|
-
* the callers that want to *plan* against a real database without changing it —
|
|
2547
|
-
* the live schema editor, which has to tell somebody what a change would do
|
|
2548
|
-
* before they agree to it.
|
|
2549
|
-
*/
|
|
2550
|
-
async function readSchemaFactsFor(client, collections) {
|
|
2551
|
-
const relational = relationalCollections(collections);
|
|
2552
|
-
return readExistingSchema(client, Array.from(/* @__PURE__ */ new Set([...relational.map(schemaOf), ...planJunctionTables(relational).map((junction) => junction.schema)])));
|
|
2553
|
-
}
|
|
2554
|
-
/**
|
|
2555
|
-
* Bring the database up to date. Returns what it did.
|
|
2556
|
-
*
|
|
2557
|
-
* Each statement runs on its own rather than in one transaction: they are all
|
|
2558
|
-
* independently safe and idempotent, and a single failure (an enum label that
|
|
2559
|
-
* cannot be added, say) should not roll back the tables that were created fine.
|
|
2560
|
-
* The error is surfaced with the statement that caused it.
|
|
2561
|
-
*/
|
|
2562
|
-
async function ensureCollectionTables(client, collections, log, options = {}) {
|
|
2563
|
-
const schemas = Array.from(/* @__PURE__ */ new Set([...collections.map(schemaOf), ...planJunctionTables(collections).map((j) => j.schema)]));
|
|
2564
|
-
for (const schema of schemas) {
|
|
2565
|
-
assertSafeIdentifier(schema, "schema name");
|
|
2566
|
-
if (schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}";`);
|
|
2567
|
-
}
|
|
2568
|
-
const plan = planCollectionSchemaEnsure(collections, await readExistingSchema(client, schemas), {
|
|
2569
|
-
...options,
|
|
2570
|
-
databaseExtensions: options.databaseExtensions ?? declaredDatabaseExtensions()
|
|
2571
|
-
});
|
|
2572
|
-
const failures = [];
|
|
2573
|
-
for (const legacy of plan.legacyForeignKeys) {
|
|
2574
|
-
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.`;
|
|
2575
|
-
logger.info(`[schema] ${message}`);
|
|
2576
|
-
log?.(message);
|
|
2577
|
-
}
|
|
2578
|
-
if (plan.searchDrift.length > 0) throw new Error(searchDriftMessage(plan.searchDrift));
|
|
2579
|
-
for (const adopted of plan.searchAdopted) {
|
|
2580
|
-
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.`;
|
|
2581
|
-
logger.info(`[schema] ${message}`);
|
|
2582
|
-
log?.(message);
|
|
2583
|
-
}
|
|
2584
|
-
for (const skip of plan.vectorIndexSkipped) {
|
|
2585
|
-
const message = `No ANN index on "${skip.table}"."${skip.column}": ${skip.reason}`;
|
|
2586
|
-
logger.warn(`[schema] ${message}`);
|
|
2587
|
-
log?.(message);
|
|
2588
|
-
}
|
|
2589
|
-
for (const withheld of plan.withheldConstraints) {
|
|
2590
|
-
const message = `No NOT NULL on "${withheld.target}": ${withheld.reason} ${withheld.remedy}`;
|
|
2591
|
-
logger.warn(`[schema] ${message}`);
|
|
2592
|
-
log?.(message);
|
|
2593
|
-
}
|
|
2594
|
-
for (const drift of plan.columnTypeDrift) {
|
|
2595
|
-
const message = columnTypeDriftMessage(drift);
|
|
2596
|
-
logger.warn(`[schema] ${message}`);
|
|
2597
|
-
log?.(message);
|
|
2598
|
-
}
|
|
2599
|
-
for (const orphan of plan.orphanedRequiredColumns) {
|
|
2600
|
-
const [schema, table] = orphan.table.split(".");
|
|
2601
|
-
const message = `"${orphan.table}"."${orphan.column}" is NOT NULL with no default, and the "${orphan.slug}" collection no longer declares it. Every insert will be rejected for a field you cannot set (PG_23502, naming "${orphan.column}"). This is what a renamed property looks like: the new column was added, and the old one cannot be dropped unattended. Fix it with ONE of:
|
|
2602
|
-
ALTER TABLE "${schema}"."${table}" DROP COLUMN "${orphan.column}"; -- the rename is complete\n ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${orphan.column}" DROP NOT NULL; -- keep the data, stop requiring it\n ...or declare the property again, if the column is still wanted.`;
|
|
2603
|
-
logger.warn(`[schema] ${message}`);
|
|
2604
|
-
log?.(message);
|
|
2605
|
-
}
|
|
2606
|
-
if (plan.actions.length === 0) {
|
|
2607
|
-
log?.("Schema is up to date; nothing to create.");
|
|
2608
|
-
return {
|
|
2609
|
-
...plan,
|
|
2610
|
-
failures
|
|
2611
|
-
};
|
|
2612
|
-
}
|
|
2613
|
-
for (const action of plan.actions) try {
|
|
2614
|
-
if (await applyAction(client, action)) log?.(`${action.kind}: ${action.target}`);
|
|
2615
|
-
else log?.(`${action.kind}: ${action.target} (already created by a peer)`);
|
|
2616
|
-
} catch (err) {
|
|
2617
|
-
const message = describeDriverError(err);
|
|
2618
|
-
if (action.kind === "add-constraint" || action.kind === "comment-column" || action.kind === "create-index" && !/CREATE\s+UNIQUE\s+INDEX/i.test(action.sql)) {
|
|
2619
|
-
failures.push({
|
|
2620
|
-
kind: action.kind,
|
|
2621
|
-
target: action.target,
|
|
2622
|
-
error: message
|
|
2623
|
-
});
|
|
2624
|
-
continue;
|
|
2625
|
-
}
|
|
2626
|
-
throw new Error(`Failed to ${action.kind} ${action.target}: ${message}${vectorExtensionHint(message)}\n ${action.sql}`);
|
|
2627
|
-
}
|
|
2628
|
-
return {
|
|
2629
|
-
...plan,
|
|
2630
|
-
failures
|
|
2631
|
-
};
|
|
2632
|
-
}
|
|
2633
|
-
/**
|
|
2634
|
-
* What actually went wrong, rather than what the ORM said about it.
|
|
2635
|
-
*
|
|
2636
|
-
* Drizzle wraps every driver error, and its `message` is literally
|
|
2637
|
-
* ``Failed query: ${sql}\nparams: ${params}`` — the statement we already know,
|
|
2638
|
-
* and not one word of Postgres's answer. Reporting `err.message` therefore
|
|
2639
|
-
* produced a failure that named the statement twice and the *reason* zero
|
|
2640
|
-
* times, so four unrelated boot failures — a missing extension, a wrong
|
|
2641
|
-
* privilege, a bad index method, a constraint the rows violate — all read
|
|
2642
|
-
* identically to whoever had to fix them.
|
|
2643
|
-
*
|
|
2644
|
-
* Two consequences, both observed in the field before this existed:
|
|
2645
|
-
*
|
|
2646
|
-
* 1. On a managed deployment the reason never reaches anybody. Boot dies, the
|
|
2647
|
-
* pod never goes ready, and the deploy reports a readiness timeout — so the
|
|
2648
|
-
* one line that says why is the one line that was thrown away here.
|
|
2649
|
-
* 2. {@link vectorExtensionHint} matches on the error text. Against the
|
|
2650
|
-
* wrapper it matched nothing, so the hint written specifically to explain a
|
|
2651
|
-
* missing pgvector never fired on the path that raises it.
|
|
2652
|
-
*
|
|
2653
|
-
* `detail` and `hint` come along because they are the fields Postgres uses to
|
|
2654
|
-
* say which object it meant and what to do about it — `CREATE EXTENSION` names
|
|
2655
|
-
* the missing library there, and a rejected constraint names the row. This
|
|
2656
|
-
* writes into the tenant's own boot log, about the tenant's own database.
|
|
2657
|
-
*/
|
|
2658
|
-
function describeDriverError(err) {
|
|
2659
|
-
const pg = extractPgError(err);
|
|
2660
|
-
if (pg) {
|
|
2661
|
-
const parts = [pg.code ? `${pg.message} [${pg.code}]` : pg.message];
|
|
2662
|
-
if (pg.detail) parts.push(` detail: ${pg.detail}`);
|
|
2663
|
-
if (pg.hint) parts.push(` hint: ${pg.hint}`);
|
|
2664
|
-
return parts.join("\n");
|
|
2665
|
-
}
|
|
2666
|
-
const cause = extractCauseMessage(err);
|
|
2667
|
-
if (cause) return cause;
|
|
2668
|
-
return err instanceof Error ? err.message : String(err);
|
|
2669
|
-
}
|
|
2670
|
-
/** ` CONCURRENTLY ` as it appears in a rendered `CREATE [UNIQUE] INDEX`. */
|
|
2671
|
-
var CONCURRENTLY_RE = /\sCONCURRENTLY\s/i;
|
|
2672
|
-
/**
|
|
2673
|
-
* `25001 active_sql_transaction` — "cannot run inside a transaction block".
|
|
2674
|
-
*
|
|
2675
|
-
* Only ever raised here by `CREATE INDEX CONCURRENTLY`, and only when the
|
|
2676
|
-
* connection we were handed is inside a transaction. Deliberately narrow: it
|
|
2677
|
-
* decides to retry a *different* statement, so it must not fire on anything it
|
|
2678
|
-
* has not been reasoned about.
|
|
2679
|
-
*/
|
|
2680
|
-
function isTransactionBlockRefusal(err) {
|
|
2681
|
-
return extractPgError(err)?.code === "25001";
|
|
2682
|
-
}
|
|
2683
|
-
/** Attempts per action, including the first. Matches the server's bootstraps. */
|
|
2684
|
-
var DDL_ATTEMPTS = 4;
|
|
2685
|
-
/**
|
|
2686
|
-
* Run one planned statement, surviving a simultaneous boot.
|
|
2687
|
-
*
|
|
2688
|
-
* Every statement in a plan is written to be idempotent, and that is not the
|
|
2689
|
-
* same as being safe to run concurrently: `CREATE … IF NOT EXISTS` reads the
|
|
2690
|
-
* catalog and then writes to it as two steps, so peers starting together both
|
|
2691
|
-
* see "absent" and the loser gets a duplicate key on a *catalog* index. Measured
|
|
2692
|
-
* against Postgres 18: five instances, 8 of 10 calls lost. `CREATE TYPE` is
|
|
2693
|
-
* worse, because Postgres has no `IF NOT EXISTS` for it at all.
|
|
2694
|
-
*
|
|
2695
|
-
* What made that fatal here rather than merely noisy is the loop this sits in.
|
|
2696
|
-
* A losing statement threw, and the throw abandoned **every remaining action in
|
|
2697
|
-
* the plan** — so a replica that lost one race came up missing tables it never
|
|
2698
|
-
* attempted, and the boot log blamed the one statement that failed.
|
|
2699
|
-
*
|
|
2700
|
-
* @returns `true` if this process applied the statement, `false` if a peer had
|
|
2701
|
-
* already created the object. The distinction is only for the log; both mean
|
|
2702
|
-
* the object is now there.
|
|
2703
|
-
* @throws the original error for anything that is not a race — a syntax error, a
|
|
2704
|
-
* permission failure, a unique constraint the customer's own rows violate.
|
|
2705
|
-
*/
|
|
2706
|
-
async function applyAction(client, action) {
|
|
2707
|
-
let sqlText = action.sql;
|
|
2708
|
-
for (let attempt = 1;; attempt++) try {
|
|
2709
|
-
await client.query(sqlText);
|
|
2710
|
-
return true;
|
|
2711
|
-
} catch (err) {
|
|
2712
|
-
if (isTransactionBlockRefusal(err) && CONCURRENTLY_RE.test(sqlText)) {
|
|
2713
|
-
sqlText = sqlText.replace(CONCURRENTLY_RE, " ");
|
|
2714
|
-
logger.warn(`[schema] ${action.kind} ${action.target}: this connection is inside a transaction, so the index is being built with a write lock rather than concurrently. Writes to this table block until it finishes.`);
|
|
2715
|
-
continue;
|
|
2716
|
-
}
|
|
2717
|
-
if (isDuplicateObjectRace(err)) {
|
|
2718
|
-
logger.debug(`[schema] ${action.kind} ${action.target}: already created by another instance`);
|
|
2719
|
-
return false;
|
|
2720
|
-
}
|
|
2721
|
-
if (isConcurrentDdlRace(err) && attempt < DDL_ATTEMPTS) {
|
|
2722
|
-
logger.debug(`[schema] ${action.kind} ${action.target}: lost a race with another instance (attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`);
|
|
2723
|
-
await new Promise((resolve) => setTimeout(resolve, 40 * attempt * (1 + Math.random())));
|
|
2724
|
-
continue;
|
|
2725
|
-
}
|
|
2726
|
-
throw err;
|
|
2727
|
-
}
|
|
2728
|
-
}
|
|
2729
|
-
//#endregion
|
|
2730
|
-
export { __toESM as A, SEARCH_UNACCENT_FN as C, __commonJSMin as D, visibleColumnProjection as E, __exportAll as O, sanitizeErrorForClient as S, hiddenColumnsOption as T, extractPgError as _, readSchemaFactsFor as a, pgErrorToFriendlyMessage as b, generatePostgresPoliciesDdl as c, planCollectionPolicies as d, resolveColumnName as f, extractCauseMessage as g, classifyConnectFailure as h, readExistingSchema as i, __require as k, generatePostgresSearchDdl as l, authUsersColumnSql as m, ensure_collection_tables_exports as n, defaultBelongsToOnDelete as o, AUTH_USERS_COLUMNS as p, planCollectionSchemaEnsure as r, generatePostgresDdl as s, describeDriverError as t, generatePostgresVectorDdl as u, isRoleSwitchingPermissionError as v, buildSearchColumnSpec as w, reachedDatabase as x, isRowLevelSecurityDenial as y };
|
|
2731
|
-
|
|
2732
|
-
//# sourceMappingURL=ensure-collection-tables-DbAhrjnG.js.map
|