@rebasepro/server-postgres 0.16.1-canary.ge71347e → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +59 -5
- package/dist/{backup-service-BL5x6Fj5.js → backup-service-BtgHxfFm.js} +2 -1
- package/dist/{backup-service-BL5x6Fj5.js.map → backup-service-BtgHxfFm.js.map} +1 -1
- package/dist/cli-helpers.d.ts +41 -0
- package/dist/{ensure-collection-tables-C_Gr59le.js → collection-index-DxJBvVTH.js} +427 -1914
- package/dist/collection-index-DxJBvVTH.js.map +1 -0
- package/dist/{ensure-collection-policies-CMYAvFpM.js → ensure-collection-policies-DFpOl8SM.js} +3 -3
- package/dist/{ensure-collection-policies-CMYAvFpM.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
- package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
- package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
- package/dist/index.es.js +13 -7488
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-HLy7w5hL.js → rls-enforcement-CInuYj1-.js} +3 -3
- package/dist/rls-enforcement-CInuYj1-.js.map +1 -0
- package/dist/schema/collection-index.d.ts +182 -0
- package/dist/schema/introspect-db-inference.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +4 -4
- package/dist/schema/introspect-db-project.d.ts +2 -2
- package/dist/src-DiDgtX8P.js.map +1 -1
- package/dist/websocket-HcyLl1ZM.js +8188 -0
- package/dist/websocket-HcyLl1ZM.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +149 -57
- package/src/cli-helpers.ts +114 -0
- package/src/cli.ts +22 -0
- package/src/schema/collection-index.ts +427 -0
- package/src/schema/ensure-collection-tables.ts +21 -0
- package/src/schema/generate-postgres-ddl-logic.ts +17 -5
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +4 -4
- package/src/schema/introspect-db-project.ts +2 -2
- package/src/schema/introspect-db.ts +2 -2
- package/src/services/realtimeService.ts +17 -4
- package/src/websocket.ts +12 -2
- package/dist/data_driver-ULAyJEi9.js +0 -193
- package/dist/data_driver-ULAyJEi9.js.map +0 -1
- package/dist/ensure-collection-tables-C_Gr59le.js.map +0 -1
- package/dist/rls-enforcement-HLy7w5hL.js.map +0 -1
- package/dist/websocket-D0YNv8hp.js +0 -651
- package/dist/websocket-D0YNv8hp.js.map +0 -1
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import { d as __require, l as __commonJSMin
|
|
5
|
-
import { a as
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
import { d as __require, l as __commonJSMin } from "./connection-GOKU3Hu5.js";
|
|
5
|
+
import { _ as isRelationAggregateSort, a as rewriteLegacyRlsFunctions, b as toCanonicalOp, c as isPostgresCollectionConfig, d as getDataSourceCapabilities, f as ALL_WHERE_FILTER_OPS, h as REST_TO_CANONICAL, i as RLS_UID_SQL, l as isRelationalCollectionConfig, m as NULL_OPS, p as CANONICAL_TO_REST, r as RLS_ROLES_SQL, s as getDeclaredSubcollections, y as sortKeyToString } from "./src-DiDgtX8P.js";
|
|
6
|
+
//#region ../types/src/errors.ts
|
|
7
|
+
/**
|
|
8
|
+
* The single error type thrown across the entire Rebase client surface —
|
|
9
|
+
* HTTP data/control-plane calls, realtime/WebSocket operations, and
|
|
10
|
+
* client-side logic errors (e.g. an unknown collection accessor). A `catch`
|
|
11
|
+
* block only ever needs to check for this one class:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { RebaseApiError } from "@rebasepro/client"; // re-exported
|
|
15
|
+
*
|
|
16
|
+
* try {
|
|
17
|
+
* await client.data.products.update(id, { price: 9 });
|
|
18
|
+
* } catch (e) {
|
|
19
|
+
* if (e instanceof RebaseApiError) {
|
|
20
|
+
* if (e.status === 404) { ... } // HTTP failures carry a status
|
|
21
|
+
* console.error(e.code, e.details);
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* `status` is present for HTTP failures and `undefined` otherwise, so its
|
|
27
|
+
* presence distinguishes transport-level errors from realtime/logic errors.
|
|
28
|
+
*
|
|
29
|
+
* @group Errors
|
|
30
|
+
*/
|
|
31
|
+
var RebaseApiError = class extends Error {
|
|
32
|
+
/** HTTP status code, or `undefined` for non-HTTP errors. */
|
|
33
|
+
status;
|
|
34
|
+
/** Stable machine-readable error code, when the server supplied one. See {@link RebaseErrorCode}. */
|
|
35
|
+
code;
|
|
36
|
+
/** Structured error payload from the server, when present. */
|
|
37
|
+
details;
|
|
38
|
+
constructor(message, init = {}) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "RebaseApiError";
|
|
41
|
+
this.status = init.status;
|
|
42
|
+
this.code = init.code;
|
|
43
|
+
this.details = init.details;
|
|
44
|
+
if (init.cause !== void 0) this.cause = init.cause;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
9
48
|
//#region ../types/src/types/entities.ts
|
|
10
49
|
/**
|
|
11
50
|
* Class used to create a reference to a entity in a different path
|
|
@@ -58,6 +97,151 @@ function isManyToMany(relation) {
|
|
|
58
97
|
return relation.kind === "manyToMany";
|
|
59
98
|
}
|
|
60
99
|
//#endregion
|
|
100
|
+
//#region ../types/src/types/policy.ts
|
|
101
|
+
/**
|
|
102
|
+
* The id a request without a logged-in user reports as `rebase.uid()`.
|
|
103
|
+
*
|
|
104
|
+
* A user-context request always sets `app.uid`: blank would read back as
|
|
105
|
+
* `NULL`, and `NULL` is how the trusted server context is recognised, so an
|
|
106
|
+
* anonymous visitor would be promoted to server privileges. The driver
|
|
107
|
+
* therefore substitutes this sentinel at the single chokepoint where the GUC
|
|
108
|
+
* is set.
|
|
109
|
+
*
|
|
110
|
+
* The consequence for policy authors is that **`rebase.uid() IS NOT NULL` is a
|
|
111
|
+
* tautology on the user path** — it is true for anonymous visitors too. Use
|
|
112
|
+
* {@link policy.authenticated} to mean "signed in", and
|
|
113
|
+
* {@link policy.serverContext} to mean "the trusted server context". Do not
|
|
114
|
+
* hand-write the comparison: see {@link ANONYMOUS_USER_IDS} for why one
|
|
115
|
+
* literal is not enough.
|
|
116
|
+
*
|
|
117
|
+
* @group Models
|
|
118
|
+
*/
|
|
119
|
+
var ANONYMOUS_USER_ID = "anonymous";
|
|
120
|
+
/**
|
|
121
|
+
* Every uid that has ever meant "nobody is signed in" — newest first.
|
|
122
|
+
*
|
|
123
|
+
* There are two because there were two. The types, the policy compiler, the
|
|
124
|
+
* JavaScript evaluator and the linter were all built on
|
|
125
|
+
* {@link ANONYMOUS_USER_ID}, while the request path scoped unauthenticated
|
|
126
|
+
* callers as `'anon'` — so `policy.authenticated()`, which compiled to
|
|
127
|
+
* `rebase.uid() <> 'anonymous'`, was *true* for an anonymous visitor. The
|
|
128
|
+
* sanctioned way to write "signed in" granted to everyone, and the linter
|
|
129
|
+
* flagged the spelling that actually worked as a foreign convention.
|
|
130
|
+
*
|
|
131
|
+
* The request path now reports {@link ANONYMOUS_USER_ID}. `'anon'` stays here
|
|
132
|
+
* because policies outlive the server that generated them: a database still
|
|
133
|
+
* holding policies from before the fix, or a project whose server has not been
|
|
134
|
+
* upgraded yet, must not become a grant in either direction. Compile against
|
|
135
|
+
* this list, not against a single literal.
|
|
136
|
+
*
|
|
137
|
+
* No real user id is ever one of these, so a match is always "not signed in".
|
|
138
|
+
*
|
|
139
|
+
* @group Models
|
|
140
|
+
*/
|
|
141
|
+
var ANONYMOUS_USER_IDS = [ANONYMOUS_USER_ID, "anon"];
|
|
142
|
+
/** @group Models */
|
|
143
|
+
var policy = {
|
|
144
|
+
true: () => ({ kind: "true" }),
|
|
145
|
+
false: () => ({ kind: "false" }),
|
|
146
|
+
and: (...operands) => ({
|
|
147
|
+
kind: "and",
|
|
148
|
+
operands
|
|
149
|
+
}),
|
|
150
|
+
or: (...operands) => ({
|
|
151
|
+
kind: "or",
|
|
152
|
+
operands
|
|
153
|
+
}),
|
|
154
|
+
not: (operand) => ({
|
|
155
|
+
kind: "not",
|
|
156
|
+
operand
|
|
157
|
+
}),
|
|
158
|
+
compare: (left, op, right) => ({
|
|
159
|
+
kind: "compare",
|
|
160
|
+
op,
|
|
161
|
+
left,
|
|
162
|
+
right
|
|
163
|
+
}),
|
|
164
|
+
rolesOverlap: (roles) => ({
|
|
165
|
+
kind: "rolesOverlap",
|
|
166
|
+
roles
|
|
167
|
+
}),
|
|
168
|
+
rolesContain: (roles) => ({
|
|
169
|
+
kind: "rolesContain",
|
|
170
|
+
roles
|
|
171
|
+
}),
|
|
172
|
+
authenticated: () => ({ kind: "authenticated" }),
|
|
173
|
+
serverContext: () => ({ kind: "serverContext" }),
|
|
174
|
+
existsIn: (args) => ({
|
|
175
|
+
kind: "existsIn",
|
|
176
|
+
collection: args.collection,
|
|
177
|
+
where: args.where
|
|
178
|
+
}),
|
|
179
|
+
raw: (sql) => ({
|
|
180
|
+
kind: "raw",
|
|
181
|
+
sql
|
|
182
|
+
}),
|
|
183
|
+
field: (name) => ({
|
|
184
|
+
kind: "field",
|
|
185
|
+
name
|
|
186
|
+
}),
|
|
187
|
+
outerField: (name) => ({
|
|
188
|
+
kind: "outerField",
|
|
189
|
+
name
|
|
190
|
+
}),
|
|
191
|
+
literal: (value) => ({
|
|
192
|
+
kind: "literal",
|
|
193
|
+
value
|
|
194
|
+
}),
|
|
195
|
+
authUid: () => ({ kind: "authUid" }),
|
|
196
|
+
authRoles: () => ({ kind: "authRoles" })
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* Thrown by {@link resolveClientListLimit} for a `limit` the platform will not
|
|
200
|
+
* serve. Carries an HTTP status so an ingress that speaks HTTP can forward it
|
|
201
|
+
* verbatim, and `maxLimit` so one can be built without re-deriving the ceiling.
|
|
202
|
+
*
|
|
203
|
+
* @group Errors
|
|
204
|
+
*/
|
|
205
|
+
var ListLimitError = class ListLimitError extends RebaseApiError {
|
|
206
|
+
/** The ceiling that was exceeded — what the caller should page by instead. */
|
|
207
|
+
maxLimit;
|
|
208
|
+
constructor(message, maxLimit) {
|
|
209
|
+
super(message, {
|
|
210
|
+
status: 400,
|
|
211
|
+
code: "INVALID_LIMIT"
|
|
212
|
+
});
|
|
213
|
+
this.name = "ListLimitError";
|
|
214
|
+
this.maxLimit = maxLimit;
|
|
215
|
+
Object.setPrototypeOf(this, ListLimitError.prototype);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
/**
|
|
219
|
+
* Resolve a client-supplied list `limit` into a safe, always-defined value.
|
|
220
|
+
*
|
|
221
|
+
* - An absent / blank limit falls back to the mode default:
|
|
222
|
+
* `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
|
|
223
|
+
* - A limit that is present must be an integer in `[1, maxLimit]`. Anything
|
|
224
|
+
* else — `0`, a negative, `1.5`, `abc`, `100000000` — throws
|
|
225
|
+
* {@link ListLimitError} rather than being coerced into range, because every
|
|
226
|
+
* coercion answers a question the caller did not ask with a page it cannot
|
|
227
|
+
* tell apart from the whole collection.
|
|
228
|
+
*
|
|
229
|
+
* The return is never `undefined` — no ingress that routes its client limit
|
|
230
|
+
* through this can produce an unbounded read.
|
|
231
|
+
*
|
|
232
|
+
* @throws {ListLimitError} when a present `limit` is not an integer in range.
|
|
233
|
+
*/
|
|
234
|
+
function resolveClientListLimit(rawLimit, opts = {}) {
|
|
235
|
+
const maxLimit = opts.maxLimit ?? 1e3;
|
|
236
|
+
if (rawLimit != null && String(rawLimit).trim() !== "") {
|
|
237
|
+
const parsed = typeof rawLimit === "number" ? rawLimit : Number(String(rawLimit).trim());
|
|
238
|
+
if (!Number.isInteger(parsed) || parsed < 1) throw new ListLimitError(`Invalid \`limit\`: ${String(rawLimit)}. Expected a whole number between 1 and ${maxLimit}.`, maxLimit);
|
|
239
|
+
if (parsed > maxLimit) throw new ListLimitError(`\`limit\` ${parsed} is above the maximum of ${maxLimit}. Ask for at most ${maxLimit} rows per read and page through the rest with \`offset\` — answering with a smaller page would be indistinguishable from there being no more rows.`, maxLimit);
|
|
240
|
+
return parsed;
|
|
241
|
+
}
|
|
242
|
+
return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
|
|
243
|
+
}
|
|
244
|
+
//#endregion
|
|
61
245
|
//#region ../common/src/util/common.ts
|
|
62
246
|
var DEFAULT_ONE_OF_TYPE = "type";
|
|
63
247
|
var DEFAULT_ONE_OF_VALUE = "value";
|
|
@@ -1409,9 +1593,28 @@ function legacyForeignKeyName(name) {
|
|
|
1409
1593
|
* and `TextDecoder` are standard in both runtimes and need no ambient types.
|
|
1410
1594
|
*/
|
|
1411
1595
|
function toPostgresIdentifier(name) {
|
|
1596
|
+
return truncateToBytes(name, 63);
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* {@link toPostgresIdentifier} with the bound lifted to a parameter.
|
|
1600
|
+
*
|
|
1601
|
+
* Exists for names that end in something load-bearing. Truncating at 63 keeps
|
|
1602
|
+
* the *head* of a name and discards the tail, which is right for a descriptive
|
|
1603
|
+
* identifier and wrong for a hashed one: the hash is the part that makes it
|
|
1604
|
+
* unique, and it is at the end. A caller that appends a fingerprint truncates
|
|
1605
|
+
* the readable head to `63 - <tail>` itself and then appends, so the bound is
|
|
1606
|
+
* still 63 and the hash always survives.
|
|
1607
|
+
*
|
|
1608
|
+
* `contracts/derived-names.txt` records what the alternative costs — a foreign
|
|
1609
|
+
* key frozen as `..._corres`, its `_fkey` suffix truncated away, so a second
|
|
1610
|
+
* foreign key on that table would derive a byte-identical name.
|
|
1611
|
+
*
|
|
1612
|
+
* One truncation rule, in one function, so the two cannot drift.
|
|
1613
|
+
*/
|
|
1614
|
+
function truncateToBytes(name, maxBytes) {
|
|
1412
1615
|
const bytes = new TextEncoder().encode(name);
|
|
1413
|
-
if (bytes.byteLength <=
|
|
1414
|
-
return new TextDecoder("utf-8").decode(bytes.subarray(0,
|
|
1616
|
+
if (bytes.byteLength <= maxBytes) return name;
|
|
1617
|
+
return new TextDecoder("utf-8").decode(bytes.subarray(0, maxBytes)).replace(/�+$/, "");
|
|
1415
1618
|
}
|
|
1416
1619
|
/**
|
|
1417
1620
|
* The API name a database column is served under.
|
|
@@ -2517,10 +2720,10 @@ function compile(expr, scope) {
|
|
|
2517
2720
|
}
|
|
2518
2721
|
case "rolesOverlap": return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
|
|
2519
2722
|
case "rolesContain": return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
|
|
2520
|
-
case "authenticated": return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
|
|
2723
|
+
case "authenticated": return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral$1).join(", ")})`;
|
|
2521
2724
|
case "serverContext": return `${RLS_UID_SQL} IS NULL`;
|
|
2522
2725
|
case "existsIn": return compileExistsIn(expr, scope);
|
|
2523
|
-
case "raw": return rewriteLegacyRlsFunctions(expr.sql).replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName
|
|
2726
|
+
case "raw": return rewriteLegacyRlsFunctions(expr.sql).replace(/\{(\w+)\}/g, (_, col) => `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
|
|
2524
2727
|
}
|
|
2525
2728
|
}
|
|
2526
2729
|
/**
|
|
@@ -2531,7 +2734,7 @@ function compile(expr, scope) {
|
|
|
2531
2734
|
function compileExistsIn(expr, scope) {
|
|
2532
2735
|
const join = scope.resolveCollection?.(expr.collection);
|
|
2533
2736
|
const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);
|
|
2534
|
-
const joinSchema = schemaOf
|
|
2737
|
+
const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
|
|
2535
2738
|
const alias = `_ex${scope.alias.n++}`;
|
|
2536
2739
|
const outerPrefix = outerQualifier(scope);
|
|
2537
2740
|
const innerScope = {
|
|
@@ -2554,9 +2757,9 @@ var COMPARE_SQL = {
|
|
|
2554
2757
|
};
|
|
2555
2758
|
function operandToSql(operand, scope) {
|
|
2556
2759
|
switch (operand.kind) {
|
|
2557
|
-
case "field": return `${scope.fieldPrefix}${resolveColumnName
|
|
2558
|
-
case "outerField": return `${scope.outerPrefix}${resolveColumnName
|
|
2559
|
-
case "literal": return quoteLiteral(operand.value);
|
|
2760
|
+
case "field": return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
|
|
2761
|
+
case "outerField": return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
|
|
2762
|
+
case "literal": return quoteLiteral$1(operand.value);
|
|
2560
2763
|
case "authUid": return RLS_UID_SQL;
|
|
2561
2764
|
case "authRoles": return `string_to_array(${RLS_ROLES_SQL}, ',')`;
|
|
2562
2765
|
}
|
|
@@ -2568,12 +2771,12 @@ function operandToSql(operand, scope) {
|
|
|
2568
2771
|
function outerQualifier(scope) {
|
|
2569
2772
|
const table = scope.outerCollection ? getTableName(scope.outerCollection) : void 0;
|
|
2570
2773
|
if (!table) return "";
|
|
2571
|
-
return `"${schemaOf
|
|
2774
|
+
return `"${schemaOf(scope.outerCollection) ?? "public"}"."${table}".`;
|
|
2572
2775
|
}
|
|
2573
|
-
function schemaOf
|
|
2776
|
+
function schemaOf(collection) {
|
|
2574
2777
|
return collection?.schema || void 0;
|
|
2575
2778
|
}
|
|
2576
|
-
function resolveColumnName
|
|
2779
|
+
function resolveColumnName(propName, collection) {
|
|
2577
2780
|
const prop = collection?.properties?.[propName];
|
|
2578
2781
|
if (prop && "columnName" in prop && typeof prop.columnName === "string") return quoteColumnIdentifier(prop.columnName);
|
|
2579
2782
|
return quoteColumnIdentifier(toSnakeCase(propName));
|
|
@@ -2717,7 +2920,7 @@ function quoteColumnIdentifier(name) {
|
|
|
2717
2920
|
if (BARE_IDENTIFIER.test(name) && !RESERVED_SQL_WORDS.has(name)) return name;
|
|
2718
2921
|
return `"${name.replace(/"/g, "\"\"")}"`;
|
|
2719
2922
|
}
|
|
2720
|
-
function quoteLiteral(value) {
|
|
2923
|
+
function quoteLiteral$1(value) {
|
|
2721
2924
|
if (value === null) return "NULL";
|
|
2722
2925
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
2723
2926
|
if (typeof value === "number") return String(value);
|
|
@@ -2783,7 +2986,7 @@ var DEFAULT_GUARDED_OPS = [
|
|
|
2783
2986
|
"delete"
|
|
2784
2987
|
];
|
|
2785
2988
|
/** Whether a collection is flagged as an authentication collection. */
|
|
2786
|
-
function isAuthCollection
|
|
2989
|
+
function isAuthCollection(collection) {
|
|
2787
2990
|
const auth = collection.auth;
|
|
2788
2991
|
return auth === true || typeof auth === "object" && auth?.enabled === true;
|
|
2789
2992
|
}
|
|
@@ -2821,7 +3024,7 @@ function getEffectiveSecurityRules(collection) {
|
|
|
2821
3024
|
const explicit = [...collection.securityRules ?? []];
|
|
2822
3025
|
const tableName = getTableName(collection);
|
|
2823
3026
|
const injected = [];
|
|
2824
|
-
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection
|
|
3027
|
+
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection(collection) ? [...explicit, adminWriteGate(tableName)] : explicit;
|
|
2825
3028
|
injected.push({
|
|
2826
3029
|
name: `${tableName}_default_admin_read`,
|
|
2827
3030
|
operations: ["select"],
|
|
@@ -2833,7 +3036,7 @@ function getEffectiveSecurityRules(collection) {
|
|
|
2833
3036
|
condition: SERVER_OR_ADMIN_EXPR$1,
|
|
2834
3037
|
check: SERVER_OR_ADMIN_EXPR$1
|
|
2835
3038
|
});
|
|
2836
|
-
if (isAuthCollection
|
|
3039
|
+
if (isAuthCollection(collection)) {
|
|
2837
3040
|
injected.push({
|
|
2838
3041
|
name: `${tableName}_default_self_read`,
|
|
2839
3042
|
operations: ["select"],
|
|
@@ -2855,7 +3058,7 @@ function getEffectiveSecurityRules(collection) {
|
|
|
2855
3058
|
* DDL, which policies are injected and how to take them off.
|
|
2856
3059
|
*/
|
|
2857
3060
|
function getInjectedSecurityRules(collection) {
|
|
2858
|
-
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection
|
|
3061
|
+
if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return isAuthCollection(collection) ? [adminWriteGate(getTableName(collection))] : [];
|
|
2859
3062
|
const explicitCount = (collection.securityRules ?? []).length;
|
|
2860
3063
|
return getEffectiveSecurityRules(collection).slice(explicitCount);
|
|
2861
3064
|
}
|
|
@@ -3335,25 +3538,8 @@ function getJunctionSecurityRules(spec) {
|
|
|
3335
3538
|
});
|
|
3336
3539
|
})))();
|
|
3337
3540
|
/**
|
|
3338
|
-
*
|
|
3339
|
-
*
|
|
3340
|
-
* One definition, three call sites, because they used to disagree. For the same
|
|
3341
|
-
* `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
|
|
3342
|
-
* while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
|
|
3343
|
-
* reads as *unbounded* — so which of the two you ran decided whether the column
|
|
3344
|
-
* had a limit at all. Introspection then dropped the length entirely, so reading
|
|
3345
|
-
* an existing `character varying(500)` column back and regenerating it produced
|
|
3346
|
-
* a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
|
|
3347
|
-
*
|
|
3348
|
-
* `validation.max` is the property's own statement about how long the value may
|
|
3349
|
-
* be, so it is the only sensible source for the column's width — and it keeps
|
|
3350
|
-
* the constraint the database enforces in step with the one the app enforces,
|
|
3351
|
-
* rather than inventing a second, different limit underneath it.
|
|
3541
|
+
* Apply PropertyConditions to a resolved property, evaluating all JSON Logic rules.
|
|
3352
3542
|
*/
|
|
3353
|
-
function resolveStringColumnLength(prop) {
|
|
3354
|
-
const max = prop.validation?.max;
|
|
3355
|
-
return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 255;
|
|
3356
|
-
}
|
|
3357
3543
|
//#endregion
|
|
3358
3544
|
//#region ../../node_modules/.pnpm/fast-equals@6.0.2/node_modules/fast-equals/dist/es/index.mjs
|
|
3359
3545
|
var { getOwnPropertyNames, getOwnPropertySymbols } = Object;
|
|
@@ -5261,1917 +5447,244 @@ function buildSdkData(driver) {
|
|
|
5261
5447
|
return wrapAsSdkData(buildRebaseData(driver));
|
|
5262
5448
|
}
|
|
5263
5449
|
//#endregion
|
|
5264
|
-
//#region src/schema/
|
|
5265
|
-
/**
|
|
5266
|
-
* The one place a collection's `search` block becomes SQL.
|
|
5267
|
-
*
|
|
5268
|
-
* Four things describe a Postgres table in this codebase — the DDL generator,
|
|
5269
|
-
* the Drizzle schema generator, the runtime table builder for BaaS mode, and
|
|
5270
|
-
* the boot-time schema ensure — and each of them has, at some point, described
|
|
5271
|
-
* a column differently from the others. The `varchar(255)` note in
|
|
5272
|
-
* `generate-postgres-ddl-logic` is one such scar: the same property produced a
|
|
5273
|
-
* capped column down one path and an uncapped one down the other, and nothing
|
|
5274
|
-
* failed until a user hit the cap.
|
|
5275
|
-
*
|
|
5276
|
-
* So the search column is not implemented four times. It is computed once,
|
|
5277
|
-
* here, and every generator renders the same {@link SearchColumnSpec}. There is
|
|
5278
|
-
* a test asserting exactly that (`search-column-contract.test.ts`); the point of
|
|
5279
|
-
* this module is that the test has something to assert *about*.
|
|
5280
|
-
*
|
|
5281
|
-
* ## Why the expressions look the way they do
|
|
5282
|
-
*
|
|
5283
|
-
* A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
|
|
5284
|
-
* Postgres is stricter here than intuition. Verified against PostgreSQL 18:
|
|
5285
|
-
*
|
|
5286
|
-
* | expression | immutable |
|
|
5287
|
-
* |-----------------------------------------|-----------|
|
|
5288
|
-
* | `to_tsvector('spanish', col)` | yes |
|
|
5289
|
-
* | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
|
|
5290
|
-
* | `array_to_string(col, ' ')` | **no** |
|
|
5291
|
-
* | `col::text` on `text[]` | **no** |
|
|
5292
|
-
* | `to_jsonb(col)` | **no** |
|
|
5293
|
-
* | `unaccent(col)` | **no** — dictionary lookup is STABLE |
|
|
5294
|
-
* | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
|
|
5295
|
-
* | `setweight(...) || setweight(...)` | yes |
|
|
5296
|
-
*
|
|
5297
|
-
* Three of the four things a real search column needs are therefore unavailable
|
|
5298
|
-
* directly, which is why {@link searchHelperFunctions} exists: each wraps a
|
|
5299
|
-
* stable built-in in an SQL function declared IMMUTABLE. That declaration is a
|
|
5300
|
-
* promise, and it is a true one for these three — array joining, JSON string
|
|
5301
|
-
* extraction and accent folding are all deterministic for a given input; the
|
|
5302
|
-
* built-ins are marked stable only because they must account for element types
|
|
5303
|
-
* and dictionaries in general.
|
|
5304
|
-
*
|
|
5305
|
-
* The alternative was to skip `unaccent` and text arrays entirely. That is not
|
|
5306
|
-
* a real option in an accented language: Postgres stems `auditoría` to
|
|
5307
|
-
* `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
|
|
5308
|
-
* typed without accents misses every row that carries them.
|
|
5309
|
-
*/
|
|
5310
|
-
/** Schema-qualified so a collection outside `public` still resolves them. */
|
|
5311
|
-
var HELPER_SCHEMA = "public";
|
|
5312
|
-
/**
|
|
5313
|
-
* Names of the helper functions. Frozen: they are recorded in the stored
|
|
5314
|
-
* generation expression of every search column ever created, so renaming one
|
|
5315
|
-
* orphans every table that already has a search column.
|
|
5316
|
-
*/
|
|
5317
|
-
var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
|
|
5318
|
-
var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
|
|
5319
|
-
/** Raised when a `search` block names something that cannot be searched. */
|
|
5320
|
-
var SearchConfigError = class extends Error {
|
|
5321
|
-
constructor(message) {
|
|
5322
|
-
super(message);
|
|
5323
|
-
this.name = "SearchConfigError";
|
|
5324
|
-
}
|
|
5325
|
-
};
|
|
5326
|
-
/** The `search` block of a collection, or undefined when it has none. */
|
|
5327
|
-
var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
|
|
5328
|
-
/**
|
|
5329
|
-
* Refuse a `search` block on a collection this engine does not store.
|
|
5330
|
-
*
|
|
5331
|
-
* The type only permits one on a `PostgresCollectionConfig`, so TypeScript
|
|
5332
|
-
* already stops the ordinary case. This catches the rest — a JS config, a cast,
|
|
5333
|
-
* a collection whose `engine` was changed after the block was written — because
|
|
5334
|
-
* the alternative is the exact failure the block exists to prevent: a developer
|
|
5335
|
-
* who declared what to index, saw no error, and got the substring fallback.
|
|
5336
|
-
*
|
|
5337
|
-
* Called with *every* collection, before the Postgres ones are filtered out.
|
|
5338
|
-
*/
|
|
5339
|
-
var assertSearchIsPostgresOnly = (collections) => {
|
|
5340
|
-
for (const collection of collections) {
|
|
5341
|
-
if (isPostgresCollectionConfig(collection)) continue;
|
|
5342
|
-
if (!collection.search) continue;
|
|
5343
|
-
const engine = collection.engine ?? "non-postgres";
|
|
5344
|
-
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.`);
|
|
5345
|
-
}
|
|
5346
|
-
};
|
|
5347
|
-
var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
|
|
5450
|
+
//#region src/schema/collection-index.ts
|
|
5348
5451
|
/**
|
|
5349
|
-
*
|
|
5350
|
-
*
|
|
5351
|
-
* Deliberately narrower than `getSqlColumnType`: search only cares whether a
|
|
5352
|
-
* value reaches text, and the mapping from property to *physical* type is
|
|
5353
|
-
* asserted against `getSqlColumnType` in the contract test rather than
|
|
5354
|
-
* duplicated here.
|
|
5452
|
+
* A declaration that cannot become an index.
|
|
5355
5453
|
*
|
|
5356
|
-
*
|
|
5357
|
-
*
|
|
5454
|
+
* Thrown at build time, naming the collection and the array position, because
|
|
5455
|
+
* the alternative is a `CREATE INDEX` that fails during a push with a Postgres
|
|
5456
|
+
* error mentioning a column the author never wrote.
|
|
5358
5457
|
*/
|
|
5359
|
-
var
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
if (sp.isId === "uuid" || sp.columnType === "uuid") return {
|
|
5368
|
-
kind: "text",
|
|
5369
|
-
reason: "uuid"
|
|
5370
|
-
};
|
|
5371
|
-
return { kind: "text" };
|
|
5372
|
-
}
|
|
5373
|
-
case "map":
|
|
5374
|
-
if (prop.columnType === "json") return {
|
|
5375
|
-
kind: "jsonb",
|
|
5376
|
-
reason: "json"
|
|
5377
|
-
};
|
|
5378
|
-
return { kind: "jsonb" };
|
|
5379
|
-
case "array": {
|
|
5380
|
-
const ap = prop;
|
|
5381
|
-
let colType = ap.columnType;
|
|
5382
|
-
if (!colType && ap.of && !Array.isArray(ap.of)) {
|
|
5383
|
-
const of = ap.of;
|
|
5384
|
-
if (of.type === "string") colType = "text[]";
|
|
5385
|
-
else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
|
|
5386
|
-
else if (of.type === "boolean") colType = "boolean[]";
|
|
5387
|
-
}
|
|
5388
|
-
if (colType === "text[]") return { kind: "text_array" };
|
|
5389
|
-
if (colType === "json") return {
|
|
5390
|
-
kind: "jsonb",
|
|
5391
|
-
reason: "json"
|
|
5392
|
-
};
|
|
5393
|
-
if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
|
|
5394
|
-
kind: "text_array",
|
|
5395
|
-
reason: "non_text_array"
|
|
5396
|
-
};
|
|
5397
|
-
return { kind: "jsonb" };
|
|
5398
|
-
}
|
|
5399
|
-
default: return null;
|
|
5458
|
+
var CollectionIndexConfigError = class extends Error {
|
|
5459
|
+
collectionSlug;
|
|
5460
|
+
position;
|
|
5461
|
+
constructor(collectionSlug, position, message) {
|
|
5462
|
+
super(`${collectionSlug}.indexes[${position}]: ${message}`);
|
|
5463
|
+
this.name = "CollectionIndexConfigError";
|
|
5464
|
+
this.collectionSlug = collectionSlug;
|
|
5465
|
+
this.position = position;
|
|
5400
5466
|
}
|
|
5401
5467
|
};
|
|
5402
|
-
var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
|
|
5403
|
-
/** SQL reading one field as plain text, before normalization. */
|
|
5404
|
-
var rawTextSql = (field) => {
|
|
5405
|
-
const col = `"${field.column}"`;
|
|
5406
|
-
if (field.kind === "text") return `coalesce(${col}, '')`;
|
|
5407
|
-
if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
|
|
5408
|
-
return `${SEARCH_TEXT_FN}(coalesce(${field.jsonPath.length === 0 ? col : field.jsonPath.length === 1 ? `${col} -> ${quote(field.jsonPath[0])}` : `${col} #> ${quote(`{${field.jsonPath.join(",")}}`)}`}, '{}'::jsonb))`;
|
|
5409
|
-
};
|
|
5410
|
-
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
5411
|
-
/**
|
|
5412
|
-
* Resolve and validate one declared field path.
|
|
5413
|
-
*
|
|
5414
|
-
* A path that does not resolve throws. The whole point of an explicit block is
|
|
5415
|
-
* that the author knows what is indexed; a silently dropped field would make it
|
|
5416
|
-
* a guess again, and the failure — a search that returns nothing for content
|
|
5417
|
-
* that is plainly in the row — is invisible from the outside.
|
|
5418
|
-
*/
|
|
5419
|
-
var resolveField = (entry, collection, cfg) => {
|
|
5420
|
-
const path = typeof entry === "string" ? entry : entry.path;
|
|
5421
|
-
const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? "B";
|
|
5422
|
-
const where = `${collection.slug}.search`;
|
|
5423
|
-
if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
|
|
5424
|
-
const [head, ...rest] = path.split(".");
|
|
5425
|
-
const prop = collection.properties?.[head];
|
|
5426
|
-
if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
|
|
5427
|
-
const classified = classify(prop);
|
|
5428
|
-
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).`);
|
|
5429
|
-
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.`);
|
|
5430
|
-
if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
|
|
5431
|
-
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.`);
|
|
5432
|
-
if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
|
|
5433
|
-
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.`);
|
|
5434
|
-
const column = columnNameOf(head, prop);
|
|
5435
|
-
const textSql = normalize(rawTextSql({
|
|
5436
|
-
column,
|
|
5437
|
-
jsonPath: rest,
|
|
5438
|
-
kind: classified.kind
|
|
5439
|
-
}), cfg.unaccent === true);
|
|
5440
|
-
const language = cfg.language ?? "simple";
|
|
5441
|
-
return {
|
|
5442
|
-
path,
|
|
5443
|
-
column,
|
|
5444
|
-
jsonPath: rest,
|
|
5445
|
-
kind: classified.kind,
|
|
5446
|
-
weight,
|
|
5447
|
-
sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
|
|
5448
|
-
textSql
|
|
5449
|
-
};
|
|
5450
|
-
};
|
|
5451
5468
|
/**
|
|
5452
|
-
*
|
|
5453
|
-
*
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5469
|
+
* `_ix`/`_ux` plus `_` plus 7 hex — the part of the name that must always
|
|
5470
|
+
* survive truncation, and therefore is never inside the truncated portion.
|
|
5471
|
+
*/
|
|
5472
|
+
var NAME_SUFFIX_BYTES = 11;
|
|
5473
|
+
var isOrderedMethod = (method) => method === "btree";
|
|
5474
|
+
/**
|
|
5475
|
+
* The parts of an index that decide what it *is*.
|
|
5476
|
+
*
|
|
5477
|
+
* A semantic projection, not the rendered statement — the same arrangement as
|
|
5478
|
+
* `getPolicyNameHash`, and for the same reason. A change to how this file
|
|
5479
|
+
* formats SQL (eliding a default `USING btree`, quoting differently, emitting
|
|
5480
|
+
* `NULLS LAST` explicitly) must not silently rename every index in every
|
|
5481
|
+
* deployed database. Hashing generator output would make every cosmetic edit a
|
|
5482
|
+
* fleet-wide DROP + CREATE.
|
|
5483
|
+
*
|
|
5484
|
+
* `reason` is deliberately absent: rewording a comment must not rebuild an
|
|
5485
|
+
* index. `nulls` is the *effective* placement, so writing Postgres's own
|
|
5486
|
+
* default down is a no-op rather than a redefinition.
|
|
5487
|
+
*
|
|
5488
|
+
* `v` is the only escape hatch, and it is expensive on purpose: bumping it
|
|
5489
|
+
* renames every index in the field.
|
|
5490
|
+
*/
|
|
5491
|
+
var indexFingerprint = (spec) => sha1Hex(JSON.stringify({
|
|
5492
|
+
v: 1,
|
|
5493
|
+
s: spec.schema,
|
|
5494
|
+
t: spec.table,
|
|
5495
|
+
m: spec.method,
|
|
5496
|
+
u: spec.unique,
|
|
5497
|
+
k: spec.keys.map((k) => [
|
|
5498
|
+
k.column,
|
|
5499
|
+
k.direction,
|
|
5500
|
+
k.nulls
|
|
5501
|
+
]),
|
|
5502
|
+
i: spec.include,
|
|
5503
|
+
w: spec.predicate
|
|
5504
|
+
})).substring(0, 7);
|
|
5505
|
+
/**
|
|
5506
|
+
* `<table>_<columns>_ix_<hash>`, or `_ux_` when unique.
|
|
5507
|
+
*
|
|
5508
|
+
* Truncation eats the readable head and never the hash. `toPostgresIdentifier`
|
|
5509
|
+
* truncates the whole string at 63 bytes, which on a hashed name would cut off
|
|
5510
|
+
* the one part that makes it unique — the failure already frozen into
|
|
5511
|
+
* `contracts/derived-names.txt`, where a foreign key is recorded with its
|
|
5512
|
+
* `_fkey` suffix truncated away, so a second foreign key on that table would
|
|
5513
|
+
* derive a byte-identical name.
|
|
5514
|
+
*/
|
|
5515
|
+
var deriveIndexName = (spec) => {
|
|
5516
|
+
const suffix = `_${spec.unique ? "ux" : "ix"}_${indexFingerprint(spec)}`;
|
|
5517
|
+
return `${truncateToBytes(`${spec.table}_${spec.keys.map((k) => k.column).join("_")}`, 63 - NAME_SUFFIX_BYTES)}${suffix}`;
|
|
5497
5518
|
};
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
* than an error, and idempotent for the same reason every other boot-time DDL
|
|
5503
|
-
* statement here is.
|
|
5504
|
-
*
|
|
5505
|
-
* The bodies are stable built-ins wrapped in an immutable promise — see the
|
|
5506
|
-
* module comment for why that promise is sound. `STRICT` matters: it makes NULL
|
|
5507
|
-
* in mean NULL out without executing the body, which is what the `coalesce` at
|
|
5508
|
-
* each call site then absorbs.
|
|
5509
|
-
*/
|
|
5510
|
-
var searchHelperFunctions = (spec) => {
|
|
5511
|
-
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 $$;`];
|
|
5512
|
-
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) $$;`);
|
|
5513
|
-
return statements;
|
|
5514
|
-
};
|
|
5515
|
-
/**
|
|
5516
|
-
* `CREATE EXTENSION` statements the spec's expressions depend on.
|
|
5517
|
-
*
|
|
5518
|
-
* `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
|
|
5519
|
-
* `CREATE EXTENSION` installs into the first schema on `search_path`, which
|
|
5520
|
-
* defaults to `"$user", public` — and the scaffold's database role is named
|
|
5521
|
-
* `rebase`, the same as the schema the generator creates one statement earlier.
|
|
5522
|
-
* So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
|
|
5523
|
-
* dictionary in `rebase`, and every reference to `public.unaccent` below fails
|
|
5524
|
-
* with "text search dictionary does not exist". Observed, not theorised.
|
|
5525
|
-
*/
|
|
5526
|
-
var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
|
|
5527
|
-
/** The column definition as it appears inside `CREATE TABLE`. */
|
|
5528
|
-
var searchColumnDefinition = (spec) => `"${spec.column}" tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`;
|
|
5529
|
-
/** The fuzzy column definition, when the spec asks for one. */
|
|
5530
|
-
var fuzzyColumnDefinition = (spec) => spec.fuzzy ? `"${spec.fuzzy.column}" text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED` : void 0;
|
|
5531
|
-
/**
|
|
5532
|
-
* Index statements for the spec.
|
|
5533
|
-
*
|
|
5534
|
-
* `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
|
|
5535
|
-
* SQL file replayed as one unit — a migration, or `search.sql` — where a
|
|
5536
|
-
* concurrent build is not allowed. The boot-time ensure path runs statement by
|
|
5537
|
-
* statement against tables that are live and populated, and uses the
|
|
5538
|
-
* concurrent form instead; see `ensureSearchColumns`.
|
|
5539
|
-
*/
|
|
5540
|
-
var searchIndexStatements = (spec) => {
|
|
5541
|
-
const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
|
|
5542
|
-
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);`);
|
|
5543
|
-
return statements;
|
|
5544
|
-
};
|
|
5545
|
-
/**
|
|
5546
|
-
* Marker on the comment of every generated search column this module creates.
|
|
5547
|
-
*
|
|
5548
|
-
* Versioned because the fingerprint below is only comparable against itself: a
|
|
5549
|
-
* future change to how it is computed has to read as "not stamped by this
|
|
5550
|
-
* version" rather than as drift on every existing column.
|
|
5551
|
-
*/
|
|
5552
|
-
var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
|
|
5553
|
-
/**
|
|
5554
|
-
* A stable fingerprint of one generated column's expression.
|
|
5555
|
-
*
|
|
5556
|
-
* Why a stamp rather than reading the expression back: Postgres stores a
|
|
5557
|
-
* generated column's expression *parsed*, and hands it back deparsed — casts
|
|
5558
|
-
* made explicit, identifiers requoted, schema qualifications added or dropped
|
|
5559
|
-
* according to `search_path`. Comparing that text to the text we generated
|
|
5560
|
-
* would report drift on wording, and this comparison decides whether a boot
|
|
5561
|
-
* refuses, so a false positive is an outage. The stamp is written by the same
|
|
5562
|
-
* code that writes the column, so equality means what it says.
|
|
5563
|
-
*/
|
|
5564
|
-
var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
|
|
5565
|
-
/**
|
|
5566
|
-
* The stamps for a spec's generated columns — one per column, never shared.
|
|
5567
|
-
*
|
|
5568
|
-
* Per column on purpose: turning `fuzzy` on adds a second column and changes
|
|
5569
|
-
* nothing about the first, and a spec-wide fingerprint would report the
|
|
5570
|
-
* untouched `tsvector` column as drifted and refuse a boot over a change that
|
|
5571
|
-
* is purely additive.
|
|
5572
|
-
*/
|
|
5573
|
-
var searchColumnStamps = (spec) => {
|
|
5574
|
-
const stamp = (column, expression) => {
|
|
5575
|
-
const fingerprint = searchExpressionFingerprint(expression);
|
|
5576
|
-
return {
|
|
5577
|
-
column,
|
|
5578
|
-
expression,
|
|
5579
|
-
fingerprint,
|
|
5580
|
-
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
|
|
5581
|
-
};
|
|
5582
|
-
};
|
|
5583
|
-
const stamps = [stamp(spec.column, spec.expression)];
|
|
5584
|
-
if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
|
|
5585
|
-
return stamps;
|
|
5519
|
+
var quoteLiteral = (value) => {
|
|
5520
|
+
if (typeof value === "number") return String(value);
|
|
5521
|
+
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
|
5522
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
5586
5523
|
};
|
|
5587
|
-
/**
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
* and the operator reading the failure is the person who changed the block.
|
|
5596
|
-
*/
|
|
5597
|
-
var searchStampGuards = (spec) => searchColumnStamps(spec).map((stamp) => {
|
|
5598
|
-
const relation = quote(`"${spec.schema}"."${spec.table}"`);
|
|
5599
|
-
return `DO $rebase_search$
|
|
5600
|
-
DECLARE recorded text;
|
|
5601
|
-
BEGIN
|
|
5602
|
-
SELECT col_description(a.attrelid, a.attnum) INTO recorded
|
|
5603
|
-
FROM pg_attribute a
|
|
5604
|
-
WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote(stamp.column)} AND NOT a.attisdropped;
|
|
5605
|
-
IF recorded LIKE ${quote(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote(stamp.fingerprint)} THEN
|
|
5606
|
-
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;
|
|
5607
|
-
END IF;
|
|
5608
|
-
END
|
|
5609
|
-
$rebase_search$;`;
|
|
5610
|
-
});
|
|
5611
|
-
/**
|
|
5612
|
-
* The generated column names a collection's search block adds, if any.
|
|
5613
|
-
*
|
|
5614
|
-
* These are physical columns on the table, so `SELECT *` returns them. They are
|
|
5615
|
-
* an index in column form — a list of lexeme positions, or a concatenation of
|
|
5616
|
-
* every searchable field on the row — and nothing outside the query planner has
|
|
5617
|
-
* any use for them. Left in, every list response carries a second, larger copy
|
|
5618
|
-
* of the row's text.
|
|
5619
|
-
*/
|
|
5620
|
-
var searchColumnNames = (collection) => {
|
|
5621
|
-
let spec;
|
|
5622
|
-
try {
|
|
5623
|
-
spec = buildSearchColumnSpec(collection);
|
|
5624
|
-
} catch {
|
|
5625
|
-
return [];
|
|
5524
|
+
/** Render a resolved predicate as the body of a `WHERE` clause. */
|
|
5525
|
+
var renderPredicate = (predicate) => {
|
|
5526
|
+
if ("and" in predicate) return predicate.and.map(renderPredicate).join(" AND ");
|
|
5527
|
+
switch (predicate.op) {
|
|
5528
|
+
case "is null":
|
|
5529
|
+
case "is not null": return `"${predicate.column}" ${predicate.op.toUpperCase()}`;
|
|
5530
|
+
case "in": return `"${predicate.column}" IN (${predicate.value.map(quoteLiteral).join(", ")})`;
|
|
5531
|
+
default: return `"${predicate.column}" ${predicate.op} ${quoteLiteral(predicate.value)}`;
|
|
5626
5532
|
}
|
|
5627
|
-
if (!spec) return [];
|
|
5628
|
-
return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
|
|
5629
5533
|
};
|
|
5630
5534
|
/**
|
|
5631
|
-
*
|
|
5632
|
-
*
|
|
5633
|
-
* Independent of any collection config on purpose: an introspected database
|
|
5634
|
-
* (BaaS mode) can carry a `tsvector` column this framework never created —
|
|
5635
|
-
* Pagila's `film.fulltext` is the canonical one — and it should not be returned
|
|
5636
|
-
* to callers either. `isDerivedIndexColumn` already keeps such a column out of
|
|
5637
|
-
* the *properties*; this keeps it out of the *rows*.
|
|
5638
|
-
*/
|
|
5639
|
-
var isSearchIndexColumn = (column) => {
|
|
5640
|
-
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
5641
|
-
return sqlType === "tsvector" || sqlType === "tsquery";
|
|
5642
|
-
};
|
|
5643
|
-
/**
|
|
5644
|
-
* A drizzle select projection over `table` with the search columns dropped.
|
|
5535
|
+
* The `CREATE INDEX` for one spec.
|
|
5645
5536
|
*
|
|
5646
|
-
*
|
|
5647
|
-
*
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5537
|
+
* `concurrently` is a parameter rather than a string replacement on the way
|
|
5538
|
+
* out. `search-column.ts` and `vector-index.ts` both reach for
|
|
5539
|
+
* `.replace("CREATE INDEX IF NOT EXISTS", …)` instead, which silently does
|
|
5540
|
+
* nothing for a UNIQUE index — the rendered text is `CREATE UNIQUE INDEX …`
|
|
5541
|
+
* and the pattern never matches.
|
|
5542
|
+
*/
|
|
5543
|
+
var collectionIndexStatement = (spec, options = {}) => {
|
|
5544
|
+
const unique = spec.unique ? "UNIQUE " : "";
|
|
5545
|
+
const concurrently = options.concurrently ? "CONCURRENTLY " : "";
|
|
5546
|
+
const ifNotExists = options.ifNotExists ? "IF NOT EXISTS " : "";
|
|
5547
|
+
const using = spec.method === "btree" ? "" : ` USING ${spec.method}`;
|
|
5548
|
+
const keys = spec.keys.map((k) => {
|
|
5549
|
+
if (!isOrderedMethod(spec.method)) return `"${k.column}"`;
|
|
5550
|
+
const direction = k.direction === "desc" ? " DESC" : "";
|
|
5551
|
+
const impliedNulls = k.direction === "desc" ? "first" : "last";
|
|
5552
|
+
const nulls = k.nulls === impliedNulls ? "" : ` NULLS ${k.nulls.toUpperCase()}`;
|
|
5553
|
+
return `"${k.column}"${direction}${nulls}`;
|
|
5554
|
+
}).join(", ");
|
|
5555
|
+
const include = spec.include.length > 0 ? ` INCLUDE (${spec.include.map((c) => `"${c}"`).join(", ")})` : "";
|
|
5556
|
+
const where = spec.predicate ? ` WHERE ${renderPredicate(spec.predicate)}` : "";
|
|
5557
|
+
return `CREATE ${unique}INDEX ${concurrently}${ifNotExists}"${spec.indexName}" ON "${spec.schema}"."${spec.table}"${using} (${keys})${include}${where};`;
|
|
5661
5558
|
};
|
|
5559
|
+
var collectionIndexStatements = (specs, options = {}) => specs.map((spec) => collectionIndexStatement(spec, options));
|
|
5560
|
+
var relationOf = (collection, propKey) => resolveCollectionRelations(collection)[propKey];
|
|
5662
5561
|
/**
|
|
5663
|
-
* The
|
|
5562
|
+
* The column a property key indexes.
|
|
5664
5563
|
*
|
|
5665
|
-
* `
|
|
5666
|
-
*
|
|
5667
|
-
*
|
|
5668
|
-
*
|
|
5669
|
-
* the read path of every collection, opted in or not.
|
|
5670
|
-
*/
|
|
5671
|
-
var excludedColumnNames = (tableColumns, collection) => {
|
|
5672
|
-
if (!tableColumns || typeof tableColumns !== "object") return [];
|
|
5673
|
-
const byName = new Set(collection ? searchColumnNames(collection) : []);
|
|
5674
|
-
return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
|
|
5675
|
-
};
|
|
5676
|
-
//#endregion
|
|
5677
|
-
//#region src/schema/auth-users-columns.ts
|
|
5678
|
-
/**
|
|
5679
|
-
* `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
|
|
5680
|
-
* — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
|
|
5681
|
-
* The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
|
|
5682
|
-
* separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
|
|
5683
|
-
* table that already holds a longer row.
|
|
5684
|
-
*/
|
|
5685
|
-
var AUTH_USERS_COLUMNS = [
|
|
5686
|
-
{
|
|
5687
|
-
column: "email",
|
|
5688
|
-
type: "TEXT",
|
|
5689
|
-
notNull: true
|
|
5690
|
-
},
|
|
5691
|
-
{
|
|
5692
|
-
column: "display_name",
|
|
5693
|
-
type: "TEXT"
|
|
5694
|
-
},
|
|
5695
|
-
{
|
|
5696
|
-
column: "photo_url",
|
|
5697
|
-
type: "TEXT"
|
|
5698
|
-
},
|
|
5699
|
-
{
|
|
5700
|
-
column: "roles",
|
|
5701
|
-
type: "TEXT[]",
|
|
5702
|
-
default: "'{}'",
|
|
5703
|
-
notNull: true
|
|
5704
|
-
},
|
|
5705
|
-
{
|
|
5706
|
-
column: "password_hash",
|
|
5707
|
-
type: "TEXT"
|
|
5708
|
-
},
|
|
5709
|
-
{
|
|
5710
|
-
column: "email_verified",
|
|
5711
|
-
type: "BOOLEAN",
|
|
5712
|
-
default: "FALSE",
|
|
5713
|
-
notNull: true
|
|
5714
|
-
},
|
|
5715
|
-
{
|
|
5716
|
-
column: "email_verification_token",
|
|
5717
|
-
type: "TEXT"
|
|
5718
|
-
},
|
|
5719
|
-
{
|
|
5720
|
-
column: "email_verification_sent_at",
|
|
5721
|
-
type: "TIMESTAMP WITH TIME ZONE"
|
|
5722
|
-
},
|
|
5723
|
-
{
|
|
5724
|
-
column: "is_anonymous",
|
|
5725
|
-
type: "BOOLEAN",
|
|
5726
|
-
default: "FALSE",
|
|
5727
|
-
notNull: true
|
|
5728
|
-
},
|
|
5729
|
-
{
|
|
5730
|
-
column: "metadata",
|
|
5731
|
-
type: "JSONB",
|
|
5732
|
-
default: "'{}'",
|
|
5733
|
-
notNull: true
|
|
5734
|
-
},
|
|
5735
|
-
{
|
|
5736
|
-
column: "tokens_valid_after",
|
|
5737
|
-
type: "TIMESTAMP WITH TIME ZONE"
|
|
5738
|
-
},
|
|
5739
|
-
{
|
|
5740
|
-
column: "created_at",
|
|
5741
|
-
type: "TIMESTAMP WITH TIME ZONE",
|
|
5742
|
-
default: "NOW()",
|
|
5743
|
-
notNull: true
|
|
5744
|
-
},
|
|
5745
|
-
{
|
|
5746
|
-
column: "updated_at",
|
|
5747
|
-
type: "TIMESTAMP WITH TIME ZONE",
|
|
5748
|
-
default: "NOW()",
|
|
5749
|
-
notNull: true
|
|
5750
|
-
}
|
|
5751
|
-
];
|
|
5752
|
-
var BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map((c) => [c.column, c]));
|
|
5753
|
-
/** Type + inline constraints, as they appear after the column name. */
|
|
5754
|
-
function authUsersColumnSql(spec) {
|
|
5755
|
-
return [
|
|
5756
|
-
spec.type,
|
|
5757
|
-
spec.default !== void 0 ? `DEFAULT ${spec.default}` : "",
|
|
5758
|
-
spec.notNull ? "NOT NULL" : ""
|
|
5759
|
-
].filter(Boolean).join(" ");
|
|
5760
|
-
}
|
|
5761
|
-
/**
|
|
5762
|
-
* The auth-owned definition for a physical column name, or `undefined` when
|
|
5763
|
-
* auth does not own it.
|
|
5564
|
+
* A `belongsTo` resolves to its `localKey` — `primaryCategory` becomes
|
|
5565
|
+
* `primary_category_id` — which is the case an index is most often wanted for
|
|
5566
|
+
* and the case where the property key and the column differ. Everything else
|
|
5567
|
+
* goes through `resolveColumnName`.
|
|
5764
5568
|
*
|
|
5765
|
-
*
|
|
5766
|
-
*
|
|
5767
|
-
*
|
|
5569
|
+
* The other relation kinds have no local column at all: the foreign key lives
|
|
5570
|
+
* on the target's table, or in a junction. Indexing them here is refused
|
|
5571
|
+
* rather than resolved to a column that does not exist.
|
|
5768
5572
|
*/
|
|
5769
|
-
|
|
5770
|
-
const
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
* Whether a collection is an auth collection, i.e. whether the definitions in
|
|
5775
|
-
* this module apply to its table at all.
|
|
5776
|
-
*
|
|
5777
|
-
* Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
|
|
5778
|
-
* that one takes a `CollectionConfig`, this one is called from DDL code paths
|
|
5779
|
-
* that hold looser objects, and both spellings must accept `auth: true` as well
|
|
5780
|
-
* as `auth: { enabled: true }`.
|
|
5781
|
-
*/
|
|
5782
|
-
function isAuthCollection(collection) {
|
|
5783
|
-
const auth = collection?.auth;
|
|
5784
|
-
if (auth === true) return true;
|
|
5785
|
-
return typeof auth === "object" && auth !== null && auth.enabled === true;
|
|
5786
|
-
}
|
|
5787
|
-
//#endregion
|
|
5788
|
-
//#region src/schema/vector-index.ts
|
|
5789
|
-
/**
|
|
5790
|
-
* The widest `vector` pgvector will build an HNSW or IVFFlat index over.
|
|
5791
|
-
* Storage and exact search are unaffected by this limit.
|
|
5792
|
-
*/
|
|
5793
|
-
var MAX_INDEXABLE_VECTOR_DIMENSIONS = 2e3;
|
|
5794
|
-
/**
|
|
5795
|
-
* Operator class per distance. These strings are part of the database contract:
|
|
5796
|
-
* they appear in `CREATE INDEX`, so renaming one renames an index.
|
|
5797
|
-
*/
|
|
5798
|
-
var OPERATOR_CLASS = {
|
|
5799
|
-
cosine: "vector_cosine_ops",
|
|
5800
|
-
l2: "vector_l2_ops",
|
|
5801
|
-
inner_product: "vector_ip_ops"
|
|
5802
|
-
};
|
|
5803
|
-
/** Short, stable tag per distance, used to name the index. */
|
|
5804
|
-
var DISTANCE_TAG = {
|
|
5805
|
-
cosine: "cosine",
|
|
5806
|
-
l2: "l2",
|
|
5807
|
-
inner_product: "ip"
|
|
5808
|
-
};
|
|
5809
|
-
var VectorIndexConfigError = class extends Error {
|
|
5810
|
-
constructor(message) {
|
|
5811
|
-
super(message);
|
|
5812
|
-
this.name = "VectorIndexConfigError";
|
|
5573
|
+
var resolveIndexableColumn = (collection, propKey, resolveColumnName, fail) => {
|
|
5574
|
+
const relation = relationOf(collection, propKey);
|
|
5575
|
+
if (relation) {
|
|
5576
|
+
if (relation.kind === "belongsTo") return relation.localKey;
|
|
5577
|
+
fail(`"${propKey}" is a ${relation.kind} relation, which has no column on this table — the foreign key lives on "${relation.targetSlug}". Declare the index there.`);
|
|
5813
5578
|
}
|
|
5579
|
+
const property = collection.properties?.[propKey];
|
|
5580
|
+
if (!property) fail(`"${propKey}" is not a property of this collection.`);
|
|
5581
|
+
return resolveColumnName(propKey, property);
|
|
5814
5582
|
};
|
|
5815
|
-
var
|
|
5816
|
-
|
|
5817
|
-
const
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
for (const distance of list) {
|
|
5822
|
-
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(", ")}.`);
|
|
5823
|
-
if (seen.has(distance)) throw new VectorIndexConfigError(`${label}: \`index.distance\` lists "${distance}" twice.`);
|
|
5824
|
-
seen.add(distance);
|
|
5825
|
-
}
|
|
5826
|
-
return list;
|
|
5827
|
-
};
|
|
5828
|
-
var assertPositiveInteger = (value, key, label) => {
|
|
5829
|
-
if (value === void 0) return;
|
|
5830
|
-
if (!Number.isInteger(value) || value <= 0) throw new VectorIndexConfigError(`${label}: \`index.${key}\` is ${JSON.stringify(value)}. It must be a positive integer.`);
|
|
5831
|
-
};
|
|
5832
|
-
/**
|
|
5833
|
-
* Index parameters for one method. Parameters belonging to the *other* method
|
|
5834
|
-
* are rejected rather than ignored, because a silently dropped `lists` on an
|
|
5835
|
-
* HNSW index reads, from the config, exactly like a tuned index.
|
|
5836
|
-
*/
|
|
5837
|
-
var parametersFor = (method, config, label) => {
|
|
5838
|
-
assertPositiveInteger(config.m, "m", label);
|
|
5839
|
-
assertPositiveInteger(config.efConstruction, "efConstruction", label);
|
|
5840
|
-
assertPositiveInteger(config.lists, "lists", label);
|
|
5841
|
-
if (method === "hnsw") {
|
|
5842
|
-
if (config.lists !== void 0) throw new VectorIndexConfigError(`${label}: \`index.lists\` only applies to \`method: "ivfflat"\`. Remove it, or switch the method.`);
|
|
5843
|
-
const params = [];
|
|
5844
|
-
if (config.m !== void 0) params.push(["m", config.m]);
|
|
5845
|
-
if (config.efConstruction !== void 0) params.push(["ef_construction", config.efConstruction]);
|
|
5846
|
-
return params;
|
|
5847
|
-
}
|
|
5848
|
-
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.`);
|
|
5849
|
-
return config.lists !== void 0 ? [["lists", config.lists]] : [];
|
|
5850
|
-
};
|
|
5851
|
-
/**
|
|
5852
|
-
* Every ANN index a collection's vector properties call for.
|
|
5853
|
-
*
|
|
5854
|
-
* `resolveColumn` is passed in rather than imported so that this module stays
|
|
5855
|
-
* free of the DDL generator, which imports *it*. Both callers hand it the same
|
|
5856
|
-
* `resolveColumnName`, and a contract test asserts the names agree.
|
|
5857
|
-
*/
|
|
5858
|
-
var buildVectorIndexPlan = (collection, resolveColumn) => {
|
|
5859
|
-
const specs = [];
|
|
5860
|
-
const skipped = [];
|
|
5861
|
-
const properties = collection.properties ?? {};
|
|
5862
|
-
const table = getTableName(collection);
|
|
5863
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
5864
|
-
for (const [propName, prop] of Object.entries(properties)) {
|
|
5865
|
-
if (!isVectorProperty(prop)) continue;
|
|
5866
|
-
if (prop.index === false) continue;
|
|
5867
|
-
const label = `${collection.slug}.${propName}`;
|
|
5868
|
-
const column = resolveColumn(propName, prop);
|
|
5869
|
-
const config = prop.index ?? {};
|
|
5870
|
-
const method = config.method ?? "hnsw";
|
|
5871
|
-
if (method !== "hnsw" && method !== "ivfflat") throw new VectorIndexConfigError(`${label}: \`index.method\` is "${method}". Use "hnsw" or "ivfflat".`);
|
|
5872
|
-
const distances = asDistances(config, label);
|
|
5873
|
-
const parameters = parametersFor(method, config, label);
|
|
5874
|
-
if (!Number.isInteger(prop.dimensions) || prop.dimensions <= 0) throw new VectorIndexConfigError(`${label}: \`dimensions\` is ${JSON.stringify(prop.dimensions)}. It must be a positive integer.`);
|
|
5875
|
-
if (prop.dimensions > 2e3) {
|
|
5876
|
-
skipped.push({
|
|
5877
|
-
schema,
|
|
5878
|
-
table,
|
|
5879
|
-
column,
|
|
5880
|
-
dimensions: prop.dimensions,
|
|
5881
|
-
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.`
|
|
5882
|
-
});
|
|
5883
|
-
continue;
|
|
5884
|
-
}
|
|
5885
|
-
for (const distance of distances) specs.push({
|
|
5886
|
-
schema,
|
|
5887
|
-
table,
|
|
5583
|
+
var resolvePredicate = (collection, predicate, resolveColumnName, fail) => {
|
|
5584
|
+
if ("and" in predicate) return { and: predicate.and.map((p) => resolvePredicate(collection, p, resolveColumnName, fail)) };
|
|
5585
|
+
const column = resolveIndexableColumn(collection, predicate.prop, resolveColumnName, fail);
|
|
5586
|
+
switch (predicate.op) {
|
|
5587
|
+
case "is null":
|
|
5588
|
+
case "is not null": return {
|
|
5888
5589
|
column,
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
parameters
|
|
5894
|
-
});
|
|
5895
|
-
}
|
|
5896
|
-
return {
|
|
5897
|
-
specs,
|
|
5898
|
-
skipped
|
|
5899
|
-
};
|
|
5900
|
-
};
|
|
5901
|
-
/**
|
|
5902
|
-
* The `CREATE INDEX` for one spec.
|
|
5903
|
-
*
|
|
5904
|
-
* `CONCURRENTLY` is deliberately absent, for the same reason it is absent from
|
|
5905
|
-
* `searchIndexStatements`: this form is replayed as part of a migration, where
|
|
5906
|
-
* a concurrent build is not allowed. The boot-time ensure rewrites it — see
|
|
5907
|
-
* `ensureCollectionTables`.
|
|
5908
|
-
*/
|
|
5909
|
-
var vectorIndexStatement = (spec) => {
|
|
5910
|
-
const params = spec.parameters.length ? ` WITH (${spec.parameters.map(([key, value]) => `${key} = ${value}`).join(", ")})` : "";
|
|
5911
|
-
return `CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING ${spec.method} ("${spec.column}" ${spec.operatorClass})${params};`;
|
|
5912
|
-
};
|
|
5913
|
-
/** Every statement for a plan, in a stable order. */
|
|
5914
|
-
var vectorIndexStatements = (plan) => plan.specs.map(vectorIndexStatement);
|
|
5915
|
-
//#endregion
|
|
5916
|
-
//#region src/schema/generate-postgres-ddl-logic.ts
|
|
5917
|
-
var resolveColumnName = (propName, prop) => {
|
|
5918
|
-
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
5919
|
-
return toSnakeCase(propName);
|
|
5920
|
-
};
|
|
5921
|
-
var getPrimaryKeyProp = (collection) => {
|
|
5922
|
-
if (collection.properties) {
|
|
5923
|
-
const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in prop && Boolean(prop.isId));
|
|
5924
|
-
if (idPropEntry) {
|
|
5925
|
-
const prop = idPropEntry[1];
|
|
5926
|
-
const isUuid = prop.type === "string" && "isId" in prop && prop.isId === "uuid";
|
|
5590
|
+
op: predicate.op
|
|
5591
|
+
};
|
|
5592
|
+
case "in":
|
|
5593
|
+
if (new Set(predicate.value).size !== predicate.value.length) fail(`the \`in\` list for "${predicate.prop}" repeats a value, which changes nothing.`);
|
|
5927
5594
|
return {
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5595
|
+
column,
|
|
5596
|
+
op: "in",
|
|
5597
|
+
value: [...predicate.value]
|
|
5931
5598
|
};
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
name: "id",
|
|
5937
|
-
type: "number",
|
|
5938
|
-
isUuid: false
|
|
5939
|
-
};
|
|
5940
|
-
return {
|
|
5941
|
-
name: "id",
|
|
5942
|
-
type: "string",
|
|
5943
|
-
isUuid: idProp?.type === "string" && "isId" in idProp && idProp.isId === "uuid"
|
|
5944
|
-
};
|
|
5945
|
-
};
|
|
5946
|
-
var isNumericId = (collection) => {
|
|
5947
|
-
return getPrimaryKeyProp(collection).type === "number";
|
|
5948
|
-
};
|
|
5949
|
-
var getPrimaryKeyName = (collection) => {
|
|
5950
|
-
return getPrimaryKeyProp(collection).name;
|
|
5951
|
-
};
|
|
5952
|
-
/** The column type a junction holds for one endpoint's primary key. */
|
|
5953
|
-
var junctionKeyType = (collection) => isNumericId(collection) ? "INTEGER" : getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT";
|
|
5954
|
-
var isIdProperty = (propName, prop, collection) => {
|
|
5955
|
-
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
5956
|
-
return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
|
|
5957
|
-
};
|
|
5958
|
-
/**
|
|
5959
|
-
* Render statements produced by {@link generatePolicyStatements} back into the
|
|
5960
|
-
* exact string the DDL/policies files have always carried: each statement on
|
|
5961
|
-
* its own line, terminated by a newline. Keeping the string form derived from
|
|
5962
|
-
* the statement array means the two can never drift — the boot-time applier and
|
|
5963
|
-
* the generated `policies.sql` emit the same SQL, from the same source.
|
|
5964
|
-
*/
|
|
5965
|
-
var statementsToDdl = (statements) => statements.map((s) => `${s}\n`).join("");
|
|
5966
|
-
var generatePolicyDdl = (collection, rule, resolveCollection) => statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
|
|
5967
|
-
/**
|
|
5968
|
-
* The individual SQL statements a single security rule compiles to: a
|
|
5969
|
-
* `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
|
|
5970
|
-
* statement (terminated by `;`, no trailing newline).
|
|
5971
|
-
*
|
|
5972
|
-
* This is the primitive the boot-time RLS applier runs one statement at a time
|
|
5973
|
-
* (the runtime's DB handle speaks the extended query protocol, which forbids
|
|
5974
|
-
* multiple commands in one execute), while `db push` writes the joined string.
|
|
5975
|
-
*/
|
|
5976
|
-
var generatePolicyStatements = (collection, rule, resolveCollection) => {
|
|
5977
|
-
const tableName = getTableName(collection);
|
|
5978
|
-
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
5979
|
-
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
5980
|
-
return ops.flatMap((op, opIdx) => {
|
|
5981
|
-
return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
5982
|
-
});
|
|
5983
|
-
};
|
|
5984
|
-
var generateSinglePolicyStatements = (collection, rule, operation, policyName, resolveCollection) => {
|
|
5985
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
5986
|
-
const tableName = getTableName(collection);
|
|
5987
|
-
const mode = (rule.mode ?? "permissive").toUpperCase();
|
|
5988
|
-
const operationUpper = operation.toUpperCase();
|
|
5989
|
-
const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
|
|
5990
|
-
const needsUsing = operation !== "insert";
|
|
5991
|
-
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
5992
|
-
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
5993
|
-
let usingClause = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection, { resolveCollection }) : null;
|
|
5994
|
-
let withCheckClause = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection, { resolveCollection }) : null;
|
|
5995
|
-
if (!usingClause && needsUsing) usingClause = "false";
|
|
5996
|
-
if (!withCheckClause && needsWithCheck) withCheckClause = "false";
|
|
5997
|
-
const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
|
|
5998
|
-
let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map((r) => `"${r}"`).join(", ")}`;
|
|
5999
|
-
if (usingClause) create += ` USING (${usingClause})`;
|
|
6000
|
-
if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
|
|
6001
|
-
create += ";";
|
|
6002
|
-
return [drop, create];
|
|
6003
|
-
};
|
|
6004
|
-
/**
|
|
6005
|
-
* Single-quote escaping for a SQL string literal (PostgreSQL doubles the
|
|
6006
|
-
* quote). Enum labels come straight from user-authored collection config, so a
|
|
6007
|
-
* label like `it's` closes the literal early and the whole generated file stops
|
|
6008
|
-
* parsing at the `CREATE TYPE`. Lives here rather than next to its other caller
|
|
6009
|
-
* because ensure-collection-tables already imports from this module — the
|
|
6010
|
-
* reverse would be a cycle.
|
|
6011
|
-
*/
|
|
6012
|
-
var quoteSqlLiteral = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
6013
|
-
var getSqlColumnType = (propName, prop, collection, collections) => {
|
|
6014
|
-
switch (prop.type) {
|
|
6015
|
-
case "string": {
|
|
6016
|
-
const stringProp = prop;
|
|
6017
|
-
if (stringProp.enum) {
|
|
6018
|
-
const tableName = getTableName(collection);
|
|
6019
|
-
const colName = resolveColumnName(propName, prop);
|
|
6020
|
-
return `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${tableName}_${colName}"`;
|
|
6021
|
-
}
|
|
6022
|
-
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") return "UUID";
|
|
6023
|
-
if (stringProp.columnType === "char") return `CHAR(${resolveStringColumnLength(stringProp)})`;
|
|
6024
|
-
if (stringProp.columnType === "varchar") return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
|
|
6025
|
-
return "TEXT";
|
|
6026
|
-
}
|
|
6027
|
-
case "number": {
|
|
6028
|
-
const numProp = prop;
|
|
6029
|
-
const isId = isIdProperty(propName, prop, collection);
|
|
6030
|
-
if ("isId" in numProp && numProp.isId === "increment") return "INTEGER GENERATED BY DEFAULT AS IDENTITY";
|
|
6031
|
-
if (numProp.columnType) {
|
|
6032
|
-
if (numProp.columnType === "double precision") return "DOUBLE PRECISION";
|
|
6033
|
-
return numProp.columnType.toUpperCase();
|
|
6034
|
-
}
|
|
6035
|
-
return numProp.validation?.integer || isId ? "INTEGER" : "NUMERIC";
|
|
6036
|
-
}
|
|
6037
|
-
case "boolean": return "BOOLEAN";
|
|
6038
|
-
case "date": {
|
|
6039
|
-
const dateProp = prop;
|
|
6040
|
-
if (dateProp.columnType === "date") return "DATE";
|
|
6041
|
-
if (dateProp.columnType === "time") return "TIME";
|
|
6042
|
-
return "TIMESTAMP WITH TIME ZONE";
|
|
6043
|
-
}
|
|
6044
|
-
case "map": return prop.columnType === "json" ? "JSON" : "JSONB";
|
|
6045
|
-
case "geopoint": return "JSONB";
|
|
6046
|
-
case "array": {
|
|
6047
|
-
const arrayProp = prop;
|
|
6048
|
-
let colType = arrayProp.columnType;
|
|
6049
|
-
if (!colType && arrayProp.of && !Array.isArray(arrayProp.of)) {
|
|
6050
|
-
const ofProp = arrayProp.of;
|
|
6051
|
-
if (ofProp.type === "string") colType = "text[]";
|
|
6052
|
-
else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
6053
|
-
else if (ofProp.type === "boolean") colType = "boolean[]";
|
|
6054
|
-
}
|
|
6055
|
-
if (colType === "json") return "JSON";
|
|
6056
|
-
if (colType === "text[]") return "TEXT[]";
|
|
6057
|
-
if (colType === "integer[]") return "INTEGER[]";
|
|
6058
|
-
if (colType === "boolean[]") return "BOOLEAN[]";
|
|
6059
|
-
if (colType === "numeric[]") return "NUMERIC[]";
|
|
6060
|
-
return "JSONB";
|
|
6061
|
-
}
|
|
6062
|
-
case "vector": return `VECTOR(${prop.dimensions})`;
|
|
6063
|
-
case "binary": return "BYTEA";
|
|
6064
|
-
case "relation": {
|
|
6065
|
-
const refProp = prop;
|
|
6066
|
-
const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
6067
|
-
if (relation?.kind !== "belongsTo") throw new Error(`Relation ${propName} does not put a column on this table (only \`belongsTo\` does)`);
|
|
6068
|
-
let targetCollection;
|
|
6069
|
-
try {
|
|
6070
|
-
targetCollection = relation.target();
|
|
6071
|
-
} catch {
|
|
6072
|
-
return "TEXT";
|
|
6073
|
-
}
|
|
6074
|
-
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
6075
|
-
return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
|
|
6076
|
-
}
|
|
6077
|
-
case "reference": {
|
|
6078
|
-
const refProp = prop;
|
|
6079
|
-
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
6080
|
-
if (!targetCollection) return "TEXT";
|
|
6081
|
-
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
6082
|
-
return pkProp.type === "number" ? "INTEGER" : pkProp.isUuid ? "UUID" : "TEXT";
|
|
6083
|
-
}
|
|
6084
|
-
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).`);
|
|
6085
|
-
}
|
|
6086
|
-
};
|
|
6087
|
-
var generatePostgresSearchDdl = (allCollections) => {
|
|
6088
|
-
const collections = relationalCollections(allCollections);
|
|
6089
|
-
const specs = collections.map((c) => buildSearchColumnSpec(c)).filter((s) => s !== void 0);
|
|
6090
|
-
if (specs.length === 0) return "";
|
|
6091
|
-
const extensions = Array.from(new Set(specs.flatMap(searchExtensionStatements)));
|
|
6092
|
-
const helpers = Array.from(new Set(specs.flatMap(searchHelperFunctions)));
|
|
6093
|
-
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n";
|
|
6094
|
-
ddl += "--\n";
|
|
6095
|
-
ddl += "-- Full-text search for the collections declaring a `search` block.\n";
|
|
6096
|
-
ddl += "-- Applied by Rebase, not by Atlas — see generatePostgresSearchDdl.\n\n";
|
|
6097
|
-
extensions.forEach((s) => {
|
|
6098
|
-
ddl += `${s}\n`;
|
|
6099
|
-
});
|
|
6100
|
-
if (extensions.length > 0) ddl += "\n";
|
|
6101
|
-
helpers.forEach((s) => {
|
|
6102
|
-
ddl += `${s}\n\n`;
|
|
6103
|
-
});
|
|
6104
|
-
for (const collection of collections) {
|
|
6105
|
-
const spec = buildSearchColumnSpec(collection);
|
|
6106
|
-
if (!spec) continue;
|
|
6107
|
-
const table = `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${getTableName(collection)}"`;
|
|
6108
|
-
searchStampGuards(spec).forEach((s) => {
|
|
6109
|
-
ddl += `${s}\n`;
|
|
6110
|
-
});
|
|
6111
|
-
ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${searchColumnDefinition(spec)};\n`;
|
|
6112
|
-
const fuzzyDef = fuzzyColumnDefinition(spec);
|
|
6113
|
-
if (fuzzyDef) ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${fuzzyDef};\n`;
|
|
6114
|
-
searchColumnStamps(spec).forEach((s) => {
|
|
6115
|
-
ddl += `${s.sql}\n`;
|
|
6116
|
-
});
|
|
6117
|
-
searchIndexStatements(spec).forEach((s) => {
|
|
6118
|
-
ddl += `${s}\n`;
|
|
6119
|
-
});
|
|
6120
|
-
ddl += "\n";
|
|
6121
|
-
}
|
|
6122
|
-
return ddl;
|
|
6123
|
-
};
|
|
6124
|
-
var generatePostgresDdl = async (allCollections, options = {
|
|
6125
|
-
includePolicies: true,
|
|
6126
|
-
includeSearch: true
|
|
6127
|
-
}) => {
|
|
6128
|
-
const collections = relationalCollections(allCollections);
|
|
6129
|
-
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
6130
|
-
const uniqueSchemas = Array.from(/* @__PURE__ */ new Set([REBASE_SCHEMA, ...collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)]));
|
|
6131
|
-
uniqueSchemas.forEach((schema) => {
|
|
6132
|
-
if (schema) ddl += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`;
|
|
6133
|
-
});
|
|
6134
|
-
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
6135
|
-
const searchSpecs = collections.map((c) => buildSearchColumnSpec(c)).filter((s) => s !== void 0);
|
|
6136
|
-
if (searchSpecs.length > 0) if (options.includeSearch === false) ddl += "-- Full-text search support lives in `search.sql`, applied separately.\n\n";
|
|
6137
|
-
else {
|
|
6138
|
-
const extensions = Array.from(new Set(searchSpecs.flatMap(searchExtensionStatements)));
|
|
6139
|
-
const helpers = Array.from(new Set(searchSpecs.flatMap(searchHelperFunctions)));
|
|
6140
|
-
ddl += "-- Full-text search support (collections declaring a `search` block)\n";
|
|
6141
|
-
extensions.forEach((s) => {
|
|
6142
|
-
ddl += `${s}\n`;
|
|
6143
|
-
});
|
|
6144
|
-
if (extensions.length > 0) ddl += "\n";
|
|
6145
|
-
helpers.forEach((s) => {
|
|
6146
|
-
ddl += `${s}\n\n`;
|
|
6147
|
-
});
|
|
6148
|
-
}
|
|
6149
|
-
const emittedEnums = /* @__PURE__ */ new Set();
|
|
6150
|
-
collections.forEach((collection) => {
|
|
6151
|
-
const collectionTable = getTableName(collection);
|
|
6152
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
6153
|
-
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
6154
|
-
if ("enum" in prop && (prop.type === "string" || prop.type === "number") && prop.enum) {
|
|
6155
|
-
const enumDbName = `${collectionTable}_${resolveColumnName(propName, prop)}`;
|
|
6156
|
-
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);
|
|
6157
|
-
if (values.length > 0 && !emittedEnums.has(`${schema}.${enumDbName}`)) {
|
|
6158
|
-
emittedEnums.add(`${schema}.${enumDbName}`);
|
|
6159
|
-
ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});\n`;
|
|
6160
|
-
}
|
|
6161
|
-
}
|
|
6162
|
-
});
|
|
6163
|
-
});
|
|
6164
|
-
if (ddl.endsWith(";\n")) ddl += "\n";
|
|
6165
|
-
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
6166
|
-
const allTablesToGenerate = /* @__PURE__ */ new Map();
|
|
6167
|
-
for (const collection of collections) {
|
|
6168
|
-
const tableName = getTableName(collection);
|
|
6169
|
-
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
6170
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
6171
|
-
for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
|
|
6172
|
-
const junctionTableName = relation.through.table;
|
|
6173
|
-
if (!allTablesToGenerate.has(junctionTableName)) allTablesToGenerate.set(junctionTableName, {
|
|
6174
|
-
collection: {
|
|
6175
|
-
table: junctionTableName,
|
|
6176
|
-
properties: {}
|
|
6177
|
-
},
|
|
6178
|
-
isJunction: true,
|
|
6179
|
-
relation,
|
|
6180
|
-
sourceCollection: collection
|
|
6181
|
-
});
|
|
6182
|
-
}
|
|
6183
|
-
}
|
|
6184
|
-
const fkStatements = [];
|
|
6185
|
-
const indexStatements = [];
|
|
6186
|
-
const policyStatements = [];
|
|
6187
|
-
for (const [tableName, { collection, isJunction, relation, sourceCollection }] of allTablesToGenerate.entries()) {
|
|
6188
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
6189
|
-
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
6190
|
-
if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
|
|
6191
|
-
const targetCollection = relation.target();
|
|
6192
|
-
const sourceTable = getTableName(sourceCollection);
|
|
6193
|
-
const targetTable = getTableName(targetCollection);
|
|
6194
|
-
const sourceSchema = isPostgresCollectionConfig(sourceCollection) && sourceCollection.schema ? sourceCollection.schema : "public";
|
|
6195
|
-
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
6196
|
-
const { sourceColumn, targetColumn } = relation.through;
|
|
6197
|
-
const sourceColType = isNumericId(sourceCollection) ? "INTEGER" : getPrimaryKeyProp(sourceCollection).isUuid ? "UUID" : "TEXT";
|
|
6198
|
-
const targetColType = isNumericId(targetCollection) ? "INTEGER" : getPrimaryKeyProp(targetCollection).isUuid ? "UUID" : "TEXT";
|
|
6199
|
-
const sourceId = getPrimaryKeyName(sourceCollection);
|
|
6200
|
-
const targetId = getPrimaryKeyName(targetCollection);
|
|
6201
|
-
const onDelete = relation.onDelete ?? "CASCADE";
|
|
6202
|
-
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
6203
|
-
ddl += ` "${sourceColumn}" ${sourceColType} NOT NULL,\n`;
|
|
6204
|
-
ddl += ` "${targetColumn}" ${targetColType} NOT NULL,\n`;
|
|
6205
|
-
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
6206
|
-
ddl += `);\n\n`;
|
|
6207
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${sourceColumn}_fkey`)}" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
6208
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${targetColumn}_fkey`)}" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
6209
|
-
if (options.includePolicies) {
|
|
6210
|
-
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
6211
|
-
ddl += `\n`;
|
|
6212
|
-
const spec = junctionSpecs.get(baseTableName);
|
|
6213
|
-
if (spec) {
|
|
6214
|
-
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
6215
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
6216
|
-
getJunctionSecurityRules(spec).forEach((rule) => {
|
|
6217
|
-
policyStatements.push(generatePolicyDdl(junctionCollection, rule, resolveCollection));
|
|
6218
|
-
});
|
|
6219
|
-
}
|
|
6220
|
-
}
|
|
6221
|
-
} else if (!isJunction) {
|
|
6222
|
-
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
6223
|
-
const columns = [];
|
|
6224
|
-
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
6225
|
-
if (prop.type === "relation") {
|
|
6226
|
-
const refProp = prop;
|
|
6227
|
-
const relInfo = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
6228
|
-
if (relInfo?.kind !== "belongsTo") return;
|
|
6229
|
-
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) return;
|
|
6230
|
-
let targetCollection;
|
|
6231
|
-
try {
|
|
6232
|
-
targetCollection = relInfo.target();
|
|
6233
|
-
} catch {
|
|
6234
|
-
return;
|
|
6235
|
-
}
|
|
6236
|
-
const targetTable = getTableName(targetCollection);
|
|
6237
|
-
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
6238
|
-
const targetId = getPrimaryKeyName(targetCollection);
|
|
6239
|
-
const fkColType = getSqlColumnType(propName, prop, collection, collections);
|
|
6240
|
-
const onUpdate = relInfo.onUpdate ? ` ON UPDATE ${relInfo.onUpdate.toUpperCase()}` : "";
|
|
6241
|
-
const required = prop.validation?.required;
|
|
6242
|
-
const onDeleteVal = relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL");
|
|
6243
|
-
let colDef = ` "${relInfo.localKey}" ${fkColType}`;
|
|
6244
|
-
if (required) colDef += " NOT NULL";
|
|
6245
|
-
columns.push(colDef);
|
|
6246
|
-
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};`);
|
|
6247
|
-
} else if (prop.type === "reference") {
|
|
6248
|
-
const refProp = prop;
|
|
6249
|
-
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
6250
|
-
const colName = resolveColumnName(propName, prop);
|
|
6251
|
-
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
6252
|
-
const required = prop.validation?.required;
|
|
6253
|
-
if (!targetCollection) {
|
|
6254
|
-
let colDef = ` "${colName}" ${colType}`;
|
|
6255
|
-
if (required) colDef += " NOT NULL";
|
|
6256
|
-
columns.push(colDef);
|
|
6257
|
-
} else {
|
|
6258
|
-
const targetTable = getTableName(targetCollection);
|
|
6259
|
-
const targetSchema = isPostgresCollectionConfig(targetCollection) && targetCollection.schema ? targetCollection.schema : "public";
|
|
6260
|
-
const targetId = getPrimaryKeyName(targetCollection);
|
|
6261
|
-
const onDelete = required ? "CASCADE" : "SET NULL";
|
|
6262
|
-
let colDef = ` "${colName}" ${colType}`;
|
|
6263
|
-
if (required) colDef += " NOT NULL";
|
|
6264
|
-
columns.push(colDef);
|
|
6265
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${colName}_fkey`)}" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
6266
|
-
}
|
|
6267
|
-
} else {
|
|
6268
|
-
const colName = resolveColumnName(propName, prop);
|
|
6269
|
-
const authDefinition = isAuthCollection(collection) ? authUsersColumnDefinition(colName) : void 0;
|
|
6270
|
-
if (authDefinition && !isIdProperty(propName, prop, collection)) {
|
|
6271
|
-
columns.push(` "${colName}" ${authDefinition}`);
|
|
6272
|
-
return;
|
|
6273
|
-
}
|
|
6274
|
-
let colDef = ` "${colName}" ${getSqlColumnType(propName, prop, collection, collections)}`;
|
|
6275
|
-
if (isIdProperty(propName, prop, collection)) colDef += " PRIMARY KEY";
|
|
6276
|
-
if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true && prop.isId !== "increment") {
|
|
6277
|
-
if (prop.isId === "uuid") colDef += " DEFAULT gen_random_uuid()";
|
|
6278
|
-
else if (prop.isId === "cuid") colDef += " DEFAULT cuid()";
|
|
6279
|
-
else if (typeof prop.isId === "string") colDef += ` DEFAULT ${prop.isId}`;
|
|
6280
|
-
}
|
|
6281
|
-
if (!isIdProperty(propName, prop, collection) && prop.validation?.unique) colDef += " UNIQUE";
|
|
6282
|
-
if (prop.type === "date") {
|
|
6283
|
-
const dateProp = prop;
|
|
6284
|
-
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") colDef += " DEFAULT now()";
|
|
6285
|
-
}
|
|
6286
|
-
if (prop.validation?.required && !colDef.includes("PRIMARY KEY")) colDef += " NOT NULL";
|
|
6287
|
-
columns.push(colDef);
|
|
6288
|
-
}
|
|
6289
|
-
});
|
|
6290
|
-
if (isAuthCollection(collection)) {
|
|
6291
|
-
const declared = new Set(Object.entries(collection.properties ?? {}).map(([name, prop]) => resolveColumnName(name, prop)));
|
|
6292
|
-
for (const spec of AUTH_USERS_COLUMNS) {
|
|
6293
|
-
if (declared.has(spec.column)) continue;
|
|
6294
|
-
columns.push(` "${spec.column}" ${authUsersColumnSql(spec)}`);
|
|
6295
|
-
}
|
|
6296
|
-
}
|
|
6297
|
-
const searchSpec = options.includeSearch === false ? void 0 : buildSearchColumnSpec(collection);
|
|
6298
|
-
if (searchSpec) {
|
|
6299
|
-
columns.push(` ${searchColumnDefinition(searchSpec)}`);
|
|
6300
|
-
const fuzzyDef = fuzzyColumnDefinition(searchSpec);
|
|
6301
|
-
if (fuzzyDef) columns.push(` ${fuzzyDef}`);
|
|
6302
|
-
indexStatements.push(...searchIndexStatements(searchSpec));
|
|
6303
|
-
}
|
|
6304
|
-
const vectorPlan = buildVectorIndexPlan(collection, resolveColumnName);
|
|
6305
|
-
indexStatements.push(...vectorIndexStatements(vectorPlan));
|
|
6306
|
-
for (const skip of vectorPlan.skipped) indexStatements.push(`-- No ANN index on "${skip.schema}"."${skip.table}"."${skip.column}": ${skip.reason}`);
|
|
6307
|
-
if (!columns.some((c) => c.includes("PRIMARY KEY"))) columns.unshift(" \"id\" TEXT PRIMARY KEY");
|
|
6308
|
-
ddl += columns.join(",\n");
|
|
6309
|
-
ddl += `\n);\n\n`;
|
|
6310
|
-
if (options.includePolicies) {
|
|
6311
|
-
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
6312
|
-
ddl += `\n`;
|
|
6313
|
-
const securityRules = getEffectiveSecurityRules(collection);
|
|
6314
|
-
if (securityRules.length > 0) {
|
|
6315
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
6316
|
-
securityRules.forEach((rule) => {
|
|
6317
|
-
policyStatements.push(generatePolicyDdl(collection, rule, resolveCollection));
|
|
6318
|
-
});
|
|
6319
|
-
}
|
|
6320
|
-
}
|
|
6321
|
-
}
|
|
6322
|
-
}
|
|
6323
|
-
if (fkStatements.length > 0) {
|
|
6324
|
-
ddl += "-- Foreign Key Constraints\n";
|
|
6325
|
-
ddl += fkStatements.join("\n") + "\n\n";
|
|
6326
|
-
}
|
|
6327
|
-
if (indexStatements.length > 0) {
|
|
6328
|
-
ddl += "-- Indexes\n";
|
|
6329
|
-
ddl += indexStatements.join("\n") + "\n\n";
|
|
6330
|
-
}
|
|
6331
|
-
if (policyStatements.length > 0) {
|
|
6332
|
-
ddl += "-- Row Level Security Policies\n";
|
|
6333
|
-
ddl += policyStatements.join("");
|
|
6334
|
-
ddl += "\n";
|
|
6335
|
-
}
|
|
6336
|
-
return ddl;
|
|
6337
|
-
};
|
|
6338
|
-
var schemaOfCollection = (collection) => isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
6339
|
-
var bareTableName = (name) => name.includes(".") ? name.split(".").pop() : name;
|
|
6340
|
-
/**
|
|
6341
|
-
* Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
|
|
6342
|
-
*
|
|
6343
|
-
* This is not cosmetic, and not really a naming choice at all — it is agreeing
|
|
6344
|
-
* with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
|
|
6345
|
-
* succeeds and records the truncated form, so the untruncated name this used to
|
|
6346
|
-
* derive matched nothing in the catalogue. Boot-ensure compares its planned
|
|
6347
|
-
* constraints against `readExistingSchema`, which reads catalogue names, so the
|
|
6348
|
-
* comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
|
|
6349
|
-
* constraint, forever, and got "already exists" every time. Non-fatal (foreign
|
|
6350
|
-
* keys are the one action allowed to fail) and therefore permanent — an error in
|
|
6351
|
-
* the log on every restart of a project whose table and column names happened to
|
|
6352
|
-
* be long.
|
|
6353
|
-
*
|
|
6354
|
-
* Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
|
|
6355
|
-
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
6356
|
-
*/
|
|
6357
|
-
var foreignKeyPlan = (args) => {
|
|
6358
|
-
const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
|
|
6359
|
-
const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
|
|
6360
|
-
return {
|
|
6361
|
-
constraintName,
|
|
6362
|
-
schema: args.schema,
|
|
6363
|
-
table: args.table,
|
|
6364
|
-
column: args.column,
|
|
6365
|
-
targetSchema: args.targetSchema,
|
|
6366
|
-
targetTable: args.targetTable,
|
|
6367
|
-
targetColumn: args.targetColumn,
|
|
6368
|
-
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};`
|
|
6369
|
-
};
|
|
6370
|
-
};
|
|
6371
|
-
/**
|
|
6372
|
-
* The FK columns the declared collections own — one entry per `relation`
|
|
6373
|
-
* (`belongsTo` side) or `reference` property.
|
|
6374
|
-
*
|
|
6375
|
-
* Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
|
|
6376
|
-
* create the same columns with the same names, types and constraints. Before
|
|
6377
|
-
* this it skipped them outright, which was survivable only because `db push`
|
|
6378
|
-
* always followed; on a managed tenant nothing follows, so a table arrived
|
|
6379
|
-
* without the column its own collection reads and wrote 400 on every insert.
|
|
6380
|
-
*
|
|
6381
|
-
* A relation whose target is not in the bundle yields no column at all (the
|
|
6382
|
-
* generator returns early on an unresolvable target); a `reference` whose target
|
|
6383
|
-
* is unknown yields the column without a constraint. Both mirror the generator
|
|
6384
|
-
* exactly — a divergence here is a schema fork between boot and `db push`.
|
|
6385
|
-
*/
|
|
6386
|
-
var planRelationalColumns = (allCollections) => {
|
|
6387
|
-
const collections = relationalCollections(allCollections);
|
|
6388
|
-
const plans = [];
|
|
6389
|
-
for (const collection of collections) {
|
|
6390
|
-
const tableName = getTableName(collection);
|
|
6391
|
-
if (!tableName) continue;
|
|
6392
|
-
const schema = schemaOfCollection(collection);
|
|
6393
|
-
const table = bareTableName(tableName);
|
|
6394
|
-
for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
|
|
6395
|
-
const prop = rawProp;
|
|
6396
|
-
if (prop.type === "relation") {
|
|
6397
|
-
const refProp = prop;
|
|
6398
|
-
const relInfo = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
6399
|
-
if (relInfo?.kind !== "belongsTo") continue;
|
|
6400
|
-
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
|
|
6401
|
-
let targetCollection;
|
|
6402
|
-
try {
|
|
6403
|
-
targetCollection = relInfo.target();
|
|
6404
|
-
} catch {
|
|
6405
|
-
continue;
|
|
6406
|
-
}
|
|
6407
|
-
if (!targetCollection) continue;
|
|
6408
|
-
const required = prop.validation?.required;
|
|
6409
|
-
const relationName = refProp.relation?.relationName ?? propName;
|
|
6410
|
-
const legacyKey = legacyForeignKeyName(relationName);
|
|
6411
|
-
const derived = relInfo.localKey === generateForeignKeyName(relationName);
|
|
6412
|
-
plans.push({
|
|
6413
|
-
schema,
|
|
6414
|
-
table,
|
|
6415
|
-
column: relInfo.localKey,
|
|
6416
|
-
legacyColumn: derived && legacyKey !== relInfo.localKey ? legacyKey : void 0,
|
|
6417
|
-
type: getSqlColumnType(propName, prop, collection, collections),
|
|
6418
|
-
foreignKey: foreignKeyPlan({
|
|
6419
|
-
schema,
|
|
6420
|
-
table,
|
|
6421
|
-
column: relInfo.localKey,
|
|
6422
|
-
targetSchema: schemaOfCollection(targetCollection),
|
|
6423
|
-
targetTable: bareTableName(getTableName(targetCollection)),
|
|
6424
|
-
targetColumn: getPrimaryKeyName(targetCollection),
|
|
6425
|
-
onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
|
|
6426
|
-
onUpdate: relInfo.onUpdate
|
|
6427
|
-
})
|
|
6428
|
-
});
|
|
6429
|
-
} else if (prop.type === "reference") {
|
|
6430
|
-
const refProp = prop;
|
|
6431
|
-
const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
6432
|
-
const column = resolveColumnName(propName, prop);
|
|
6433
|
-
const type = getSqlColumnType(propName, prop, collection, collections);
|
|
6434
|
-
const required = prop.validation?.required;
|
|
6435
|
-
plans.push({
|
|
6436
|
-
schema,
|
|
6437
|
-
table,
|
|
6438
|
-
column,
|
|
6439
|
-
type,
|
|
6440
|
-
foreignKey: targetCollection ? foreignKeyPlan({
|
|
6441
|
-
schema,
|
|
6442
|
-
table,
|
|
6443
|
-
column,
|
|
6444
|
-
targetSchema: schemaOfCollection(targetCollection),
|
|
6445
|
-
targetTable: bareTableName(getTableName(targetCollection)),
|
|
6446
|
-
targetColumn: getPrimaryKeyName(targetCollection),
|
|
6447
|
-
onDelete: required ? "CASCADE" : "SET NULL"
|
|
6448
|
-
}) : void 0
|
|
6449
|
-
});
|
|
6450
|
-
}
|
|
6451
|
-
}
|
|
6452
|
-
}
|
|
6453
|
-
return plans;
|
|
6454
|
-
};
|
|
6455
|
-
/**
|
|
6456
|
-
* The junction tables a bundle's many-to-many relations imply.
|
|
6457
|
-
*
|
|
6458
|
-
* Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
|
|
6459
|
-
* comes from, so a table created here always has policies planned for it — a
|
|
6460
|
-
* junction with row-level security left off is readable and writable by every
|
|
6461
|
-
* signed-in user, which is why the two must ship together.
|
|
6462
|
-
*/
|
|
6463
|
-
var planJunctionTables = (allCollections) => {
|
|
6464
|
-
const collections = relationalCollections(allCollections);
|
|
6465
|
-
const plans = [];
|
|
6466
|
-
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
6467
|
-
const [source, target] = spec.endpoints;
|
|
6468
|
-
const legacyFor = (endpoint) => {
|
|
6469
|
-
const slug = toSnakeCase(endpoint.collection.slug ?? endpoint.collection.name ?? "");
|
|
6470
|
-
const legacy = legacyForeignKeyName(slug);
|
|
6471
|
-
return endpoint.junctionColumn === generateForeignKeyName(slug) && legacy !== endpoint.junctionColumn ? legacy : void 0;
|
|
5599
|
+
default: return {
|
|
5600
|
+
column,
|
|
5601
|
+
op: predicate.op,
|
|
5602
|
+
value: predicate.value
|
|
6472
5603
|
};
|
|
6473
|
-
const columns = [{
|
|
6474
|
-
name: source.junctionColumn,
|
|
6475
|
-
type: junctionKeyType(source.collection),
|
|
6476
|
-
legacyName: legacyFor(source)
|
|
6477
|
-
}, {
|
|
6478
|
-
name: target.junctionColumn,
|
|
6479
|
-
type: junctionKeyType(target.collection),
|
|
6480
|
-
legacyName: legacyFor(target)
|
|
6481
|
-
}];
|
|
6482
|
-
const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
|
|
6483
|
-
plans.push({
|
|
6484
|
-
schema: spec.schema,
|
|
6485
|
-
table: spec.table,
|
|
6486
|
-
columns,
|
|
6487
|
-
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(", ")}));`,
|
|
6488
|
-
foreignKeys: [source, target].map((endpoint, i) => foreignKeyPlan({
|
|
6489
|
-
schema: spec.schema,
|
|
6490
|
-
table: spec.table,
|
|
6491
|
-
column: columns[i].name,
|
|
6492
|
-
targetSchema: schemaOfCollection(endpoint.collection),
|
|
6493
|
-
targetTable: bareTableName(getTableName(endpoint.collection)),
|
|
6494
|
-
targetColumn: getPrimaryKeyName(endpoint.collection),
|
|
6495
|
-
onDelete
|
|
6496
|
-
}))
|
|
6497
|
-
});
|
|
6498
|
-
}
|
|
6499
|
-
return plans;
|
|
6500
|
-
};
|
|
6501
|
-
/**
|
|
6502
|
-
* The per-table RLS plan for the *declared* collections, as executable
|
|
6503
|
-
* statements — what the managed runtime applies at boot so a freshly
|
|
6504
|
-
* provisioned tenant database serves data instead of 401ing every read.
|
|
6505
|
-
*
|
|
6506
|
-
* Mirrors {@link generatePostgresPoliciesDdl} exactly (same
|
|
6507
|
-
* `generatePolicyStatements`, same enable-RLS, same effective rules, same
|
|
6508
|
-
* derived junction rules), so boot and `db push` produce identical policies from
|
|
6509
|
-
* identical collections.
|
|
6510
|
-
*
|
|
6511
|
-
* Junction tables are included, and have to be: boot creates them now
|
|
6512
|
-
* ({@link planJunctionTables}), and a junction with RLS left off is readable and
|
|
6513
|
-
* writable by every signed-in user. A junction whose table is still absent is
|
|
6514
|
-
* skipped by the applier, not planned away here.
|
|
6515
|
-
*/
|
|
6516
|
-
var planCollectionPolicies = (allCollections) => {
|
|
6517
|
-
const collections = relationalCollections(allCollections);
|
|
6518
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
6519
|
-
const plans = [];
|
|
6520
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6521
|
-
for (const collection of collections) {
|
|
6522
|
-
const tableName = getTableName(collection);
|
|
6523
|
-
if (!tableName) continue;
|
|
6524
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
6525
|
-
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
6526
|
-
const qualified = `${schema}.${baseTableName}`;
|
|
6527
|
-
if (seen.has(qualified)) continue;
|
|
6528
|
-
seen.add(qualified);
|
|
6529
|
-
const policyStatements = [];
|
|
6530
|
-
for (const rule of getEffectiveSecurityRules(collection)) policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
|
|
6531
|
-
plans.push({
|
|
6532
|
-
schema,
|
|
6533
|
-
table: baseTableName,
|
|
6534
|
-
qualified,
|
|
6535
|
-
enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
|
|
6536
|
-
policyStatements
|
|
6537
|
-
});
|
|
6538
|
-
}
|
|
6539
|
-
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
6540
|
-
const qualified = `${spec.schema}.${spec.table}`;
|
|
6541
|
-
if (seen.has(qualified)) continue;
|
|
6542
|
-
seen.add(qualified);
|
|
6543
|
-
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
6544
|
-
const policyStatements = [];
|
|
6545
|
-
for (const rule of getJunctionSecurityRules(spec)) policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
|
|
6546
|
-
plans.push({
|
|
6547
|
-
schema: spec.schema,
|
|
6548
|
-
table: spec.table,
|
|
6549
|
-
qualified,
|
|
6550
|
-
enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
|
|
6551
|
-
policyStatements
|
|
6552
|
-
});
|
|
6553
5604
|
}
|
|
6554
|
-
return plans;
|
|
6555
5605
|
};
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
6559
|
-
const allTablesToGenerate = /* @__PURE__ */ new Map();
|
|
6560
|
-
for (const collection of collections) {
|
|
6561
|
-
const tableName = getTableName(collection);
|
|
6562
|
-
if (tableName) allTablesToGenerate.set(tableName, { collection });
|
|
6563
|
-
}
|
|
6564
|
-
for (const [tableName, { collection }] of allTablesToGenerate.entries()) {
|
|
6565
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
6566
|
-
const baseTableName = tableName.includes(".") ? tableName.split(".").pop() : tableName;
|
|
6567
|
-
ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
6568
|
-
ddl += `\n`;
|
|
6569
|
-
const securityRules = getEffectiveSecurityRules(collection);
|
|
6570
|
-
if (securityRules.length > 0) {
|
|
6571
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
6572
|
-
const injectedNames = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
|
|
6573
|
-
securityRules.forEach((rule) => {
|
|
6574
|
-
if (rule.name && injectedNames.has(rule.name)) {
|
|
6575
|
-
ddl += `-- Injected by Rebase (not from this collection's securityRules).\n`;
|
|
6576
|
-
ddl += `-- Set \`disableDefaultPolicies: true\` on "${collection.slug}" to drop these and own its RLS outright.\n`;
|
|
6577
|
-
}
|
|
6578
|
-
ddl += generatePolicyDdl(collection, rule, resolveCollection);
|
|
6579
|
-
});
|
|
6580
|
-
ddl += "\n";
|
|
6581
|
-
}
|
|
6582
|
-
}
|
|
6583
|
-
const junctionSpecs = resolveJunctionSpecs(collections);
|
|
6584
|
-
for (const spec of junctionSpecs.values()) {
|
|
6585
|
-
ddl += `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;\n`;
|
|
6586
|
-
ddl += `\n`;
|
|
6587
|
-
const junctionRules = getJunctionSecurityRules(spec);
|
|
6588
|
-
if (junctionRules.length === 0) continue;
|
|
6589
|
-
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
6590
|
-
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
6591
|
-
const declaringSlugs = spec.declaringSides.map((s) => s.collection.slug).join("\", \"");
|
|
6592
|
-
ddl += `-- Derived by Rebase for the junction "${spec.table}" (no collection declares it).\n`;
|
|
6593
|
-
ddl += `-- Reads require both endpoint rows to be visible; writes follow the update\n`;
|
|
6594
|
-
ddl += `-- rules of "${declaringSlugs}". Set \`disableDefaultPolicies: true\` on the\n`;
|
|
6595
|
-
ddl += `-- declaring collection(s) to drop these and police the junction yourself.\n`;
|
|
6596
|
-
junctionRules.forEach((rule) => {
|
|
6597
|
-
ddl += generatePolicyDdl(junctionCollection, rule, resolveCollection);
|
|
6598
|
-
});
|
|
6599
|
-
ddl += "\n";
|
|
6600
|
-
}
|
|
6601
|
-
return ddl;
|
|
6602
|
-
};
|
|
6603
|
-
//#endregion
|
|
6604
|
-
//#region src/schema/ensure-collection-tables.ts
|
|
5606
|
+
/** The primary key columns of a collection, for the "you already have this" refusal. */
|
|
5607
|
+
var primaryKeyColumns = (collection, resolveColumnName) => Object.entries(collection.properties ?? {}).filter(([, prop]) => prop && typeof prop === "object" && "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => resolveColumnName(key, prop));
|
|
6605
5608
|
/**
|
|
6606
|
-
*
|
|
5609
|
+
* Every index one collection declares, resolved and named.
|
|
6607
5610
|
*
|
|
6608
|
-
*
|
|
6609
|
-
*
|
|
6610
|
-
*
|
|
6611
|
-
* has never seen. Auth tables are ensured at boot already, but collection tables
|
|
6612
|
-
* were not created by anything: the platform ran the app and every `/api/data/*`
|
|
6613
|
-
* request answered 500 on a missing relation. `rebase db push` cannot help — it
|
|
6614
|
-
* is an Atlas-driven CLI command, and the runtime image ships no CLI.
|
|
6615
|
-
*
|
|
6616
|
-
* ## Why additive-only, forever
|
|
6617
|
-
*
|
|
6618
|
-
* This runs unattended, against a database with customers' data in it, with no
|
|
6619
|
-
* human reading a diff. So it may only ever do things that cannot lose data:
|
|
6620
|
-
* create a missing table, add a missing column, create a missing enum type.
|
|
6621
|
-
*
|
|
6622
|
-
* It will **never** drop a table or a column, narrow a type, or alter a
|
|
6623
|
-
* constraint. A removed field leaves its column behind; a renamed field looks
|
|
6624
|
-
* like an addition and the old column stays. That is the correct trade for an
|
|
6625
|
-
* automated path — the alternative is an unattended process that can silently
|
|
6626
|
-
* destroy a column, which is precisely the failure `db push` was hardened
|
|
6627
|
-
* against. Destructive changes stay a deliberate, human-reviewed migration.
|
|
6628
|
-
*
|
|
6629
|
-
* Because of that, this is safe to run on every boot, and re-running it is a
|
|
6630
|
-
* no-op.
|
|
6631
|
-
*/
|
|
6632
|
-
var ensure_collection_tables_exports = /* @__PURE__ */ __exportAll({
|
|
6633
|
-
ensureCollectionTables: () => ensureCollectionTables,
|
|
6634
|
-
planCollectionSchemaEnsure: () => planCollectionSchemaEnsure,
|
|
6635
|
-
readExistingSchema: () => readExistingSchema,
|
|
6636
|
-
readSchemaFactsFor: () => readSchemaFactsFor
|
|
6637
|
-
});
|
|
6638
|
-
/** Postgres identifiers this module is willing to interpolate. */
|
|
6639
|
-
var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
6640
|
-
function assertSafeIdentifier(value, what) {
|
|
6641
|
-
if (!SAFE_IDENTIFIER.test(value)) throw new Error(`Refusing to build SQL with an unsafe ${what}: ${JSON.stringify(value)}`);
|
|
6642
|
-
return value;
|
|
6643
|
-
}
|
|
6644
|
-
function schemaOf(collection) {
|
|
6645
|
-
return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
6646
|
-
}
|
|
6647
|
-
function qualified(collection) {
|
|
6648
|
-
return `${schemaOf(collection)}.${getTableName(collection)}`;
|
|
6649
|
-
}
|
|
6650
|
-
/**
|
|
6651
|
-
* Enum types a collection's properties require, as `schema.typename`.
|
|
6652
|
-
*
|
|
6653
|
-
* Named exactly as the DDL generator names them (`<table>_<column>`), because
|
|
6654
|
-
* a column added here has to reference the same type the generator would have
|
|
6655
|
-
* created — a second, differently-named type for the same field would be a
|
|
6656
|
-
* silent schema fork.
|
|
5611
|
+
* Throws {@link CollectionIndexConfigError} rather than dropping a bad entry:
|
|
5612
|
+
* an index that silently does not exist is the failure mode this whole feature
|
|
5613
|
+
* is here to remove.
|
|
6657
5614
|
*/
|
|
6658
|
-
|
|
5615
|
+
var buildCollectionIndexSpecs = (collection, resolveColumnName) => {
|
|
5616
|
+
if (!isPostgresCollectionConfig(collection)) return [];
|
|
5617
|
+
const declared = collection.indexes;
|
|
5618
|
+
if (!declared || declared.length === 0) return [];
|
|
5619
|
+
const slug = collection.slug ?? getTableName(collection);
|
|
5620
|
+
const schema = collection.schema ?? "public";
|
|
6659
5621
|
const table = getTableName(collection);
|
|
6660
|
-
const
|
|
6661
|
-
const
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
if (
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
}
|
|
6675
|
-
/**
|
|
6676
|
-
* Decide what to add. Pure — the caller supplies what exists and runs the result.
|
|
6677
|
-
*
|
|
6678
|
-
* Ordering matters and is deliberate: enum types before the tables and columns
|
|
6679
|
-
* that reference them, tables before the columns added to other tables (a new
|
|
6680
|
-
* table may be the target of a relation), and nothing is emitted twice.
|
|
6681
|
-
*/
|
|
6682
|
-
function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
|
|
6683
|
-
const constraintPolicy = options.constraints ?? "additive";
|
|
6684
|
-
const withheldConstraints = [];
|
|
6685
|
-
assertSearchIsPostgresOnly(allCollections);
|
|
6686
|
-
const collections = relationalCollections(allCollections);
|
|
6687
|
-
const actions = [];
|
|
6688
|
-
const plannedEnums = /* @__PURE__ */ new Set();
|
|
6689
|
-
for (const collection of collections) for (const { name, values } of requiredEnums(collection)) {
|
|
6690
|
-
if (existing.enums.has(name) || plannedEnums.has(name)) {
|
|
6691
|
-
const current = existing.enumValues?.get(name);
|
|
6692
|
-
if (!current || plannedEnums.has(name)) continue;
|
|
6693
|
-
const [schema, typeName] = name.split(".");
|
|
6694
|
-
for (const value of values) {
|
|
6695
|
-
if (current.includes(value)) continue;
|
|
6696
|
-
actions.push({
|
|
6697
|
-
kind: "add-enum-value",
|
|
6698
|
-
target: `${name}.${value}`,
|
|
6699
|
-
sql: `ALTER TYPE "${schema}"."${typeName}" ADD VALUE IF NOT EXISTS ${quoteSqlLiteral(value)};`
|
|
6700
|
-
});
|
|
6701
|
-
}
|
|
6702
|
-
continue;
|
|
5622
|
+
const pk = primaryKeyColumns(collection, resolveColumnName).sort().join(",");
|
|
5623
|
+
const specs = [];
|
|
5624
|
+
const byName = /* @__PURE__ */ new Map();
|
|
5625
|
+
declared.forEach((index, position) => {
|
|
5626
|
+
const fail = (message) => {
|
|
5627
|
+
throw new CollectionIndexConfigError(slug, position, message);
|
|
5628
|
+
};
|
|
5629
|
+
if (typeof index.reason !== "string" || index.reason.trim() === "") fail("`reason` is required — see the doc comment. An index nobody can justify is one nobody can delete.");
|
|
5630
|
+
if (!Array.isArray(index.on) || index.on.length === 0) fail("`on` must name at least one property.");
|
|
5631
|
+
if (index.on.length > 5) fail(`\`on\` has ${index.on.length} keys; the limit is 5. Payload columns belong in \`include\`.`);
|
|
5632
|
+
const method = index.using ?? "btree";
|
|
5633
|
+
const unique = method === "btree" && Boolean(index.unique);
|
|
5634
|
+
if (!isOrderedMethod(method)) {
|
|
5635
|
+
for (const key of index.on) if (typeof key !== "string" && ("direction" in key || "nulls" in key)) fail(`access method "${method}" does not support ASC/DESC or NULLS options.`);
|
|
6703
5636
|
}
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
const searchSpecs = collections.map((c) => buildSearchColumnSpec(c)).filter((spec) => spec !== void 0);
|
|
6713
|
-
const plannedExtensions = /* @__PURE__ */ new Set();
|
|
6714
|
-
for (const spec of searchSpecs) for (const statement of searchExtensionStatements(spec)) {
|
|
6715
|
-
if (plannedExtensions.has(statement)) continue;
|
|
6716
|
-
plannedExtensions.add(statement);
|
|
6717
|
-
actions.push({
|
|
6718
|
-
kind: "create-extension",
|
|
6719
|
-
target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""),
|
|
6720
|
-
sql: statement
|
|
6721
|
-
});
|
|
6722
|
-
}
|
|
6723
|
-
const plannedFunctions = /* @__PURE__ */ new Set();
|
|
6724
|
-
for (const spec of searchSpecs) for (const statement of searchHelperFunctions(spec)) {
|
|
6725
|
-
if (plannedFunctions.has(statement)) continue;
|
|
6726
|
-
plannedFunctions.add(statement);
|
|
6727
|
-
actions.push({
|
|
6728
|
-
kind: "create-function",
|
|
6729
|
-
target: statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN,
|
|
6730
|
-
sql: statement
|
|
6731
|
-
});
|
|
6732
|
-
}
|
|
6733
|
-
const created = /* @__PURE__ */ new Set();
|
|
6734
|
-
for (const collection of collections) {
|
|
6735
|
-
const key = qualified(collection);
|
|
6736
|
-
if (existing.tables.has(key) || created.has(key)) continue;
|
|
6737
|
-
created.add(key);
|
|
6738
|
-
const schema = schemaOf(collection);
|
|
6739
|
-
const table = getTableName(collection);
|
|
6740
|
-
const idEntry = Object.entries(collection.properties ?? {}).find(([n, p]) => isIdProperty(n, p, collection));
|
|
6741
|
-
const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1]) : "id";
|
|
6742
|
-
const idProp = idEntry?.[1];
|
|
6743
|
-
let idDef = `"${idName}" ${idProp ? getSqlColumnType(idEntry[0], idProp, collection, collections) : "TEXT"} PRIMARY KEY`;
|
|
6744
|
-
if (idProp?.type === "string" && idProp.isId === "uuid") idDef += " DEFAULT gen_random_uuid()";
|
|
6745
|
-
actions.push({
|
|
6746
|
-
kind: "create-table",
|
|
6747
|
-
target: key,
|
|
6748
|
-
sql: `CREATE TABLE IF NOT EXISTS "${schema}"."${table}" (${idDef});`
|
|
6749
|
-
});
|
|
6750
|
-
}
|
|
6751
|
-
const junctions = planJunctionTables(collections);
|
|
6752
|
-
for (const junction of junctions) {
|
|
6753
|
-
const key = `${junction.schema}.${junction.table}`;
|
|
6754
|
-
if (existing.tables.has(key) || created.has(key)) continue;
|
|
6755
|
-
created.add(key);
|
|
6756
|
-
actions.push({
|
|
6757
|
-
kind: "create-table",
|
|
6758
|
-
target: key,
|
|
6759
|
-
sql: junction.createTable
|
|
6760
|
-
});
|
|
6761
|
-
}
|
|
6762
|
-
const legacyForeignKeys = [];
|
|
6763
|
-
/**
|
|
6764
|
-
* Move a relation column that is only missing because it was renamed.
|
|
6765
|
-
*
|
|
6766
|
-
* Returns true when it handled the column, so the caller skips the ordinary
|
|
6767
|
-
* ADD. Adding here would be the wrong move and a quiet one: the data is in
|
|
6768
|
-
* the old column, `ADD COLUMN` creates the new one empty beside it, every
|
|
6769
|
-
* statement succeeds, and the relation reads the empty one. A rename is
|
|
6770
|
-
* metadata-only in Postgres, keeps the values, and carries the column's
|
|
6771
|
-
* indexes and constraints with it.
|
|
6772
|
-
*
|
|
6773
|
-
* Only ever reached when the new name is absent and the old name is
|
|
6774
|
-
* present, so there is nothing to overwrite and nothing to choose between.
|
|
6775
|
-
*/
|
|
6776
|
-
const renameLegacyColumn = (key, schema, table, column, legacyName) => {
|
|
6777
|
-
const present = existing.tables.get(key);
|
|
6778
|
-
if (!legacyName || !present) return false;
|
|
6779
|
-
if (present.has(column) || !present.has(legacyName)) return false;
|
|
6780
|
-
legacyForeignKeys.push({
|
|
6781
|
-
table: key,
|
|
6782
|
-
expected: column,
|
|
6783
|
-
legacy: legacyName
|
|
6784
|
-
});
|
|
6785
|
-
actions.push({
|
|
6786
|
-
kind: "rename-column",
|
|
6787
|
-
target: `${key}.${column}`,
|
|
6788
|
-
sql: `ALTER TABLE "${schema}"."${table}" RENAME COLUMN "${legacyName}" TO "${column}";`
|
|
6789
|
-
});
|
|
6790
|
-
return true;
|
|
6791
|
-
};
|
|
6792
|
-
const addColumn = (key, schema, table, column, definition) => {
|
|
6793
|
-
if (existing.tables.get(key)?.has(column)) return;
|
|
6794
|
-
actions.push({
|
|
6795
|
-
kind: "add-column",
|
|
6796
|
-
target: `${key}.${column}`,
|
|
6797
|
-
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
|
|
5637
|
+
const keys = index.on.map((key) => {
|
|
5638
|
+
const column = resolveIndexableColumn(collection, typeof key === "string" ? key : key.prop, resolveColumnName, fail);
|
|
5639
|
+
const direction = (typeof key === "string" ? void 0 : key.direction) ?? "asc";
|
|
5640
|
+
return {
|
|
5641
|
+
column,
|
|
5642
|
+
direction,
|
|
5643
|
+
nulls: (typeof key === "string" ? void 0 : key.nulls) ?? (direction === "desc" ? "first" : "last")
|
|
5644
|
+
};
|
|
6798
5645
|
});
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
const
|
|
6803
|
-
const
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
if (isIdProperty(propName, p, collection)) continue;
|
|
6809
|
-
if (p.type === "reference" || p.type === "relation") continue;
|
|
6810
|
-
const column = resolveColumnName(propName, p);
|
|
6811
|
-
const authDefinition = auth ? authUsersColumnDefinition(column) : void 0;
|
|
6812
|
-
if (authDefinition) {
|
|
6813
|
-
addColumn(key, schema, table, column, authDefinition);
|
|
6814
|
-
continue;
|
|
6815
|
-
}
|
|
6816
|
-
let definition = getSqlColumnType(propName, p, collection, collections);
|
|
6817
|
-
if (fresh && p.validation?.unique) definition += " UNIQUE";
|
|
6818
|
-
const autoValue = p.autoValue;
|
|
6819
|
-
const hasDefault = p.type === "date" && (autoValue === "on_create" || autoValue === "on_update");
|
|
6820
|
-
if (hasDefault) definition += " DEFAULT now()";
|
|
6821
|
-
const required = p.validation?.required === true;
|
|
6822
|
-
const columnKey = `${key}.${column}`;
|
|
6823
|
-
const columnExists = existing.tables.get(key)?.has(column) === true;
|
|
6824
|
-
const tableIsEmpty = existing.populatedTables !== void 0 && existing.tables.has(key) && !existing.populatedTables.has(key);
|
|
6825
|
-
const notNullIsSafe = fresh || tableIsEmpty || hasDefault;
|
|
6826
|
-
if (required && !columnExists) if (notNullIsSafe) definition += " NOT NULL";
|
|
6827
|
-
else withheldConstraints.push({
|
|
6828
|
-
target: columnKey,
|
|
6829
|
-
kind: "not-null",
|
|
6830
|
-
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.`,
|
|
6831
|
-
remedy: "Backfill the column, then add the constraint — or give the property a default so every existing row gets one."
|
|
6832
|
-
});
|
|
6833
|
-
addColumn(key, schema, table, column, definition);
|
|
6834
|
-
if (columnExists && constraintPolicy === "converge") {
|
|
6835
|
-
const isNotNull = existing.notNullColumns?.has(columnKey) === true;
|
|
6836
|
-
if (required && !isNotNull) if (tableIsEmpty) actions.push({
|
|
6837
|
-
kind: "set-not-null",
|
|
6838
|
-
target: columnKey,
|
|
6839
|
-
sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" SET NOT NULL;`
|
|
6840
|
-
});
|
|
6841
|
-
else withheldConstraints.push({
|
|
6842
|
-
target: columnKey,
|
|
6843
|
-
kind: "not-null",
|
|
6844
|
-
reason: `"${column}" became required, but "${key}" holds rows and any of them with no value would make SET NOT NULL fail.`,
|
|
6845
|
-
remedy: "Backfill the column first — `UPDATE … SET \"" + column + "\" = … WHERE \"" + column + "\" IS NULL` — then apply this again."
|
|
6846
|
-
});
|
|
6847
|
-
if (!required && isNotNull) actions.push({
|
|
6848
|
-
kind: "drop-not-null",
|
|
6849
|
-
target: columnKey,
|
|
6850
|
-
sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" DROP NOT NULL;`
|
|
6851
|
-
});
|
|
6852
|
-
}
|
|
6853
|
-
}
|
|
6854
|
-
if (auth) {
|
|
6855
|
-
const declared = new Set(Object.entries(collection.properties ?? {}).map(([name, prop]) => resolveColumnName(name, prop)));
|
|
6856
|
-
for (const spec of AUTH_USERS_COLUMNS) {
|
|
6857
|
-
if (declared.has(spec.column)) continue;
|
|
6858
|
-
addColumn(key, schema, table, spec.column, authUsersColumnSql(spec));
|
|
6859
|
-
}
|
|
6860
|
-
}
|
|
6861
|
-
}
|
|
6862
|
-
const searchDrift = [];
|
|
6863
|
-
const searchAdopted = [];
|
|
6864
|
-
for (const spec of searchSpecs) {
|
|
6865
|
-
const key = `${spec.schema}.${spec.table}`;
|
|
6866
|
-
const definitions = { [spec.column]: `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED` };
|
|
6867
|
-
if (spec.fuzzy) definitions[spec.fuzzy.column] = `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`;
|
|
6868
|
-
for (const stamp of searchColumnStamps(spec)) {
|
|
6869
|
-
const definition = definitions[stamp.column];
|
|
6870
|
-
const exists = existing.tables.get(key)?.has(stamp.column) === true;
|
|
6871
|
-
const recorded = existing.columnComments?.get(`${key}.${stamp.column}`);
|
|
6872
|
-
if (exists && recorded?.startsWith("rebase:search:v1:") && recorded !== stamp.fingerprint) {
|
|
6873
|
-
searchDrift.push({
|
|
6874
|
-
table: key,
|
|
6875
|
-
column: stamp.column,
|
|
6876
|
-
found: recorded,
|
|
6877
|
-
expected: stamp.fingerprint,
|
|
6878
|
-
rebuild: [
|
|
6879
|
-
`ALTER TABLE "${spec.schema}"."${spec.table}" DROP COLUMN "${stamp.column}";`,
|
|
6880
|
-
`ALTER TABLE "${spec.schema}"."${spec.table}" ADD COLUMN "${stamp.column}" ${definition};`,
|
|
6881
|
-
stamp.sql
|
|
6882
|
-
]
|
|
6883
|
-
});
|
|
6884
|
-
continue;
|
|
6885
|
-
}
|
|
6886
|
-
addColumn(key, spec.schema, spec.table, stamp.column, definition);
|
|
6887
|
-
if (exists && recorded === void 0) searchAdopted.push({
|
|
6888
|
-
table: key,
|
|
6889
|
-
column: stamp.column
|
|
6890
|
-
});
|
|
6891
|
-
if (recorded !== stamp.fingerprint) actions.push({
|
|
6892
|
-
kind: "comment-column",
|
|
6893
|
-
target: `${key}.${stamp.column}`,
|
|
6894
|
-
sql: stamp.sql
|
|
6895
|
-
});
|
|
6896
|
-
}
|
|
6897
|
-
}
|
|
6898
|
-
for (const junction of junctions) {
|
|
6899
|
-
const key = `${junction.schema}.${junction.table}`;
|
|
6900
|
-
if (created.has(key)) continue;
|
|
6901
|
-
for (const column of junction.columns) {
|
|
6902
|
-
if (renameLegacyColumn(key, junction.schema, junction.table, column.name, column.legacyName)) continue;
|
|
6903
|
-
addColumn(key, junction.schema, junction.table, column.name, column.type);
|
|
5646
|
+
const duplicateKey = keys.map((k) => k.column).find((c, i, all) => all.indexOf(c) !== i);
|
|
5647
|
+
if (duplicateKey) fail(`"${duplicateKey}" appears twice in \`on\`.`);
|
|
5648
|
+
if (keys.map((k) => k.column).sort().join(",") === pk && pk !== "") fail(`this is the primary key — "${table}_pkey" already indexes exactly these columns.`);
|
|
5649
|
+
const include = (index.include ?? []).map((propKey) => resolveIndexableColumn(collection, propKey, resolveColumnName, fail));
|
|
5650
|
+
const overlap = include.find((c) => keys.some((k) => k.column === c));
|
|
5651
|
+
if (overlap) fail(`"${overlap}" is in both \`on\` and \`include\`; Postgres rejects the overlap.`);
|
|
5652
|
+
if (unique && keys.length === 1) {
|
|
5653
|
+
const propKey = typeof index.on[0] === "string" ? index.on[0] : index.on[0].prop;
|
|
5654
|
+
if ((collection.properties?.[propKey])?.validation?.unique) fail(`"${propKey}" already declares \`validation.unique\`, which compiles to an inline UNIQUE. Two declarations of one guarantee — remove one.`);
|
|
6904
5655
|
}
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
const
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
5656
|
+
const predicate = index.where ? resolvePredicate(collection, index.where, resolveColumnName, fail) : null;
|
|
5657
|
+
const withoutName = {
|
|
5658
|
+
schema,
|
|
5659
|
+
table,
|
|
5660
|
+
method,
|
|
5661
|
+
unique,
|
|
5662
|
+
keys,
|
|
5663
|
+
include,
|
|
5664
|
+
predicate,
|
|
5665
|
+
reason: index.reason
|
|
5666
|
+
};
|
|
5667
|
+
const indexName = deriveIndexName(withoutName);
|
|
5668
|
+
const clash = byName.get(indexName);
|
|
5669
|
+
if (clash !== void 0) fail(`derives the same name as indexes[${clash}] — they are the same index declared twice.`);
|
|
5670
|
+
byName.set(indexName, position);
|
|
5671
|
+
specs.push({
|
|
5672
|
+
...withoutName,
|
|
5673
|
+
indexName
|
|
6923
5674
|
});
|
|
6924
|
-
}
|
|
6925
|
-
for (const spec of searchSpecs) for (const statement of searchIndexStatements(spec)) actions.push({
|
|
6926
|
-
kind: "create-index",
|
|
6927
|
-
target: `${spec.schema}.${spec.table}`,
|
|
6928
|
-
sql: statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
|
|
6929
5675
|
});
|
|
6930
|
-
|
|
6931
|
-
|
|
6932
|
-
const plan = buildVectorIndexPlan(collection, resolveColumnName);
|
|
6933
|
-
for (const spec of plan.specs) actions.push({
|
|
6934
|
-
kind: "create-index",
|
|
6935
|
-
target: `${spec.schema}.${spec.table}`,
|
|
6936
|
-
sql: vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
|
|
6937
|
-
});
|
|
6938
|
-
vectorIndexSkipped.push(...plan.skipped);
|
|
6939
|
-
}
|
|
6940
|
-
return {
|
|
6941
|
-
actions,
|
|
6942
|
-
statements: actions.map((a) => a.sql),
|
|
6943
|
-
legacyForeignKeys,
|
|
6944
|
-
searchDrift,
|
|
6945
|
-
searchAdopted,
|
|
6946
|
-
vectorIndexSkipped,
|
|
6947
|
-
withheldConstraints
|
|
6948
|
-
};
|
|
6949
|
-
}
|
|
6950
|
-
/** Read what the database has, for the schemas the collections live in. */
|
|
6951
|
-
async function readExistingSchema(client, schemas) {
|
|
6952
|
-
const tables = /* @__PURE__ */ new Map();
|
|
6953
|
-
const enums = /* @__PURE__ */ new Set();
|
|
6954
|
-
if (schemas.length === 0) return {
|
|
6955
|
-
tables,
|
|
6956
|
-
enums
|
|
6957
|
-
};
|
|
6958
|
-
const inList = schemas.map((schema) => `'${assertSafeIdentifier(schema, "schema name")}'`).join(", ");
|
|
6959
|
-
const notNullColumns = /* @__PURE__ */ new Set();
|
|
6960
|
-
const { rows: columns } = await client.query(`SELECT table_schema, table_name, column_name, is_nullable
|
|
6961
|
-
FROM information_schema.columns
|
|
6962
|
-
WHERE table_schema IN (${inList})`);
|
|
6963
|
-
for (const row of columns) {
|
|
6964
|
-
const key = `${row.table_schema}.${row.table_name}`;
|
|
6965
|
-
if (!tables.has(key)) tables.set(key, /* @__PURE__ */ new Set());
|
|
6966
|
-
tables.get(key).add(row.column_name);
|
|
6967
|
-
if (row.is_nullable === "NO") notNullColumns.add(`${key}.${row.column_name}`);
|
|
6968
|
-
}
|
|
6969
|
-
const populatedTables = /* @__PURE__ */ new Set();
|
|
6970
|
-
const { rows: realTables } = await client.query(`SELECT n.nspname AS schema, c.relname AS name
|
|
6971
|
-
FROM pg_class c
|
|
6972
|
-
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
6973
|
-
WHERE c.relkind IN ('r', 'p') AND n.nspname IN (${inList})`);
|
|
6974
|
-
if (realTables.length > 0) {
|
|
6975
|
-
const probes = realTables.map((row) => {
|
|
6976
|
-
const schema = assertSafeIdentifier(row.schema, "schema name");
|
|
6977
|
-
const table = assertSafeIdentifier(row.name, "table name");
|
|
6978
|
-
return `SELECT ${quoteSqlLiteral(`${schema}.${table}`)} AS key, EXISTS(SELECT 1 FROM "${schema}"."${table}" LIMIT 1) AS populated`;
|
|
6979
|
-
});
|
|
6980
|
-
const { rows: populationRows } = await client.query(probes.join(" UNION ALL "));
|
|
6981
|
-
for (const row of populationRows) if (row.populated) populatedTables.add(row.key);
|
|
6982
|
-
}
|
|
6983
|
-
const enumValues = /* @__PURE__ */ new Map();
|
|
6984
|
-
const { rows: enumValueRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name, e.enumlabel AS value
|
|
6985
|
-
FROM pg_enum e
|
|
6986
|
-
JOIN pg_type t ON e.enumtypid = t.oid
|
|
6987
|
-
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
6988
|
-
WHERE n.nspname IN (${inList})
|
|
6989
|
-
ORDER BY t.typname, e.enumsortorder`);
|
|
6990
|
-
for (const row of enumValueRows) {
|
|
6991
|
-
const key = `${row.schema}.${row.name}`;
|
|
6992
|
-
if (!enumValues.has(key)) enumValues.set(key, []);
|
|
6993
|
-
enumValues.get(key).push(row.value);
|
|
6994
|
-
}
|
|
6995
|
-
const { rows: enumRows } = await client.query(`SELECT n.nspname AS schema, t.typname AS name
|
|
6996
|
-
FROM pg_type t
|
|
6997
|
-
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
6998
|
-
WHERE t.typtype = 'e' AND n.nspname IN (${inList})`);
|
|
6999
|
-
for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
|
|
7000
|
-
const constraints = /* @__PURE__ */ new Set();
|
|
7001
|
-
const { rows: constraintRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
|
|
7002
|
-
FROM pg_constraint con
|
|
7003
|
-
JOIN pg_class c ON con.conrelid = c.oid
|
|
7004
|
-
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
7005
|
-
WHERE n.nspname IN (${inList})`);
|
|
7006
|
-
for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
|
|
7007
|
-
const columnComments = /* @__PURE__ */ new Map();
|
|
7008
|
-
const { rows: commentRows } = await client.query(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, d.description AS comment
|
|
7009
|
-
FROM pg_description d
|
|
7010
|
-
JOIN pg_class c ON d.objoid = c.oid
|
|
7011
|
-
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
7012
|
-
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid
|
|
7013
|
-
WHERE d.objsubid > 0 AND n.nspname IN (${inList})`);
|
|
7014
|
-
for (const row of commentRows) {
|
|
7015
|
-
if (row.comment == null) continue;
|
|
7016
|
-
columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
|
|
7017
|
-
}
|
|
7018
|
-
return {
|
|
7019
|
-
tables,
|
|
7020
|
-
enums,
|
|
7021
|
-
constraints,
|
|
7022
|
-
columnComments,
|
|
7023
|
-
enumValues,
|
|
7024
|
-
notNullColumns,
|
|
7025
|
-
populatedTables
|
|
7026
|
-
};
|
|
7027
|
-
}
|
|
7028
|
-
/**
|
|
7029
|
-
* What to tell an operator whose `search` block no longer matches its column.
|
|
7030
|
-
*
|
|
7031
|
-
* Every line here is doing work: naming the collection is not enough, because
|
|
7032
|
-
* the symptom (a search that finds nothing) points at the data, not the schema;
|
|
7033
|
-
* and the remediation has to be exact, because it is a table rewrite the
|
|
7034
|
-
* operator is being asked to schedule rather than discover.
|
|
7035
|
-
*/
|
|
7036
|
-
function searchDriftMessage(drift) {
|
|
7037
|
-
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.";
|
|
7038
|
-
}
|
|
7039
|
-
/**
|
|
7040
|
-
* The missing-pgvector explanation, appended to the error that reveals it.
|
|
7041
|
-
*
|
|
7042
|
-
* A `{ type: "vector" }` property compiles to `VECTOR(n)`, and nothing in the
|
|
7043
|
-
* OSS pipeline installs pgvector — not this ensure, not `db push`. Installing
|
|
7044
|
-
* an extension on someone's database is a decision with a deployment behind it
|
|
7045
|
-
* (image, superuser, cloud allow-list), so this path stays a refusal; what it
|
|
7046
|
-
* must not stay is a bare `type "vector" does not exist` on a crash-looping
|
|
7047
|
-
* pod, which names nothing the reader can act on.
|
|
7048
|
-
*
|
|
7049
|
-
* The scaffold now ships `pgvector/pgvector:pg18`, so this is reached by a
|
|
7050
|
-
* project pointed at a database someone else provisioned — which is exactly
|
|
7051
|
-
* the case where naming the extension and the image is worth the words.
|
|
7052
|
-
*/
|
|
7053
|
-
function vectorExtensionHint(message) {
|
|
7054
|
-
if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
|
|
7055
|
-
return "\n pgvector is not installed on this database, and Rebase does not install it: it is a server extension, so it needs an image that ships it (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` does not) and a role allowed to run `CREATE EXTENSION vector;`. Install it once, then boot again. Rebase then creates an ANN index for the column automatically — see the `index` option on the property.";
|
|
7056
|
-
}
|
|
7057
|
-
/**
|
|
7058
|
-
* Read what the database looks like, for the schemas a set of collections
|
|
7059
|
-
* lives in.
|
|
7060
|
-
*
|
|
7061
|
-
* The same read `ensureCollectionTables` does at boot, exposed on its own for
|
|
7062
|
-
* the callers that want to *plan* against a real database without changing it —
|
|
7063
|
-
* the live schema editor, which has to tell somebody what a change would do
|
|
7064
|
-
* before they agree to it.
|
|
7065
|
-
*/
|
|
7066
|
-
async function readSchemaFactsFor(client, collections) {
|
|
7067
|
-
const relational = relationalCollections(collections);
|
|
7068
|
-
return readExistingSchema(client, Array.from(/* @__PURE__ */ new Set([...relational.map(schemaOf), ...planJunctionTables(relational).map((junction) => junction.schema)])));
|
|
7069
|
-
}
|
|
7070
|
-
/**
|
|
7071
|
-
* Bring the database up to date. Returns what it did.
|
|
7072
|
-
*
|
|
7073
|
-
* Each statement runs on its own rather than in one transaction: they are all
|
|
7074
|
-
* independently safe and idempotent, and a single failure (an enum label that
|
|
7075
|
-
* cannot be added, say) should not roll back the tables that were created fine.
|
|
7076
|
-
* The error is surfaced with the statement that caused it.
|
|
7077
|
-
*/
|
|
7078
|
-
async function ensureCollectionTables(client, collections, log) {
|
|
7079
|
-
const schemas = Array.from(/* @__PURE__ */ new Set([...collections.map(schemaOf), ...planJunctionTables(collections).map((j) => j.schema)]));
|
|
7080
|
-
for (const schema of schemas) {
|
|
7081
|
-
assertSafeIdentifier(schema, "schema name");
|
|
7082
|
-
if (schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}";`);
|
|
7083
|
-
}
|
|
7084
|
-
const plan = planCollectionSchemaEnsure(collections, await readExistingSchema(client, schemas));
|
|
7085
|
-
const failures = [];
|
|
7086
|
-
for (const legacy of plan.legacyForeignKeys) {
|
|
7087
|
-
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.`;
|
|
7088
|
-
logger.info(`[schema] ${message}`);
|
|
7089
|
-
log?.(message);
|
|
7090
|
-
}
|
|
7091
|
-
if (plan.searchDrift.length > 0) throw new Error(searchDriftMessage(plan.searchDrift));
|
|
7092
|
-
for (const adopted of plan.searchAdopted) {
|
|
7093
|
-
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.`;
|
|
7094
|
-
logger.info(`[schema] ${message}`);
|
|
7095
|
-
log?.(message);
|
|
7096
|
-
}
|
|
7097
|
-
for (const skip of plan.vectorIndexSkipped) {
|
|
7098
|
-
const message = `No ANN index on "${skip.table}"."${skip.column}": ${skip.reason}`;
|
|
7099
|
-
logger.warn(`[schema] ${message}`);
|
|
7100
|
-
log?.(message);
|
|
7101
|
-
}
|
|
7102
|
-
for (const withheld of plan.withheldConstraints) {
|
|
7103
|
-
const message = `No NOT NULL on "${withheld.target}": ${withheld.reason} ${withheld.remedy}`;
|
|
7104
|
-
logger.warn(`[schema] ${message}`);
|
|
7105
|
-
log?.(message);
|
|
7106
|
-
}
|
|
7107
|
-
if (plan.actions.length === 0) {
|
|
7108
|
-
log?.("Schema is up to date; nothing to create.");
|
|
7109
|
-
return {
|
|
7110
|
-
...plan,
|
|
7111
|
-
failures
|
|
7112
|
-
};
|
|
7113
|
-
}
|
|
7114
|
-
for (const action of plan.actions) try {
|
|
7115
|
-
if (await applyAction(client, action)) log?.(`${action.kind}: ${action.target}`);
|
|
7116
|
-
else log?.(`${action.kind}: ${action.target} (already created by a peer)`);
|
|
7117
|
-
} catch (err) {
|
|
7118
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
7119
|
-
if (action.kind === "add-constraint" || action.kind === "comment-column") {
|
|
7120
|
-
failures.push({
|
|
7121
|
-
kind: action.kind,
|
|
7122
|
-
target: action.target,
|
|
7123
|
-
error: message
|
|
7124
|
-
});
|
|
7125
|
-
continue;
|
|
7126
|
-
}
|
|
7127
|
-
throw new Error(`Failed to ${action.kind} ${action.target}: ${message}${vectorExtensionHint(message)}\n ${action.sql}`);
|
|
7128
|
-
}
|
|
7129
|
-
return {
|
|
7130
|
-
...plan,
|
|
7131
|
-
failures
|
|
7132
|
-
};
|
|
7133
|
-
}
|
|
7134
|
-
/** Attempts per action, including the first. Matches the server's bootstraps. */
|
|
7135
|
-
var DDL_ATTEMPTS = 4;
|
|
5676
|
+
return specs;
|
|
5677
|
+
};
|
|
7136
5678
|
/**
|
|
7137
|
-
*
|
|
5679
|
+
* Every declared index across a set of collections, in a stable order.
|
|
7138
5680
|
*
|
|
7139
|
-
*
|
|
7140
|
-
*
|
|
7141
|
-
*
|
|
7142
|
-
*
|
|
7143
|
-
* against Postgres 18: five instances, 8 of 10 calls lost. `CREATE TYPE` is
|
|
7144
|
-
* worse, because Postgres has no `IF NOT EXISTS` for it at all.
|
|
7145
|
-
*
|
|
7146
|
-
* What made that fatal here rather than merely noisy is the loop this sits in.
|
|
7147
|
-
* A losing statement threw, and the throw abandoned **every remaining action in
|
|
7148
|
-
* the plan** — so a replica that lost one race came up missing tables it never
|
|
7149
|
-
* attempted, and the boot log blamed the one statement that failed.
|
|
7150
|
-
*
|
|
7151
|
-
* @returns `true` if this process applied the statement, `false` if a peer had
|
|
7152
|
-
* already created the object. The distinction is only for the log; both mean
|
|
7153
|
-
* the object is now there.
|
|
7154
|
-
* @throws the original error for anything that is not a race — a syntax error, a
|
|
7155
|
-
* permission failure, a unique constraint the customer's own rows violate.
|
|
5681
|
+
* Sorted because the result reaches `schema.sql`, which `doctor` string-
|
|
5682
|
+
* compares against a regenerated copy — `generatePostgresDdl` does not sort its
|
|
5683
|
+
* collections, so leaving this in declaration order would make the artifact
|
|
5684
|
+
* depend on the order files happened to load in.
|
|
7156
5685
|
*/
|
|
7157
|
-
|
|
7158
|
-
for (let attempt = 1;; attempt++) try {
|
|
7159
|
-
await client.query(action.sql);
|
|
7160
|
-
return true;
|
|
7161
|
-
} catch (err) {
|
|
7162
|
-
if (isDuplicateObjectRace(err)) {
|
|
7163
|
-
logger.debug(`[schema] ${action.kind} ${action.target}: already created by another instance`);
|
|
7164
|
-
return false;
|
|
7165
|
-
}
|
|
7166
|
-
if (isConcurrentDdlRace(err) && attempt < DDL_ATTEMPTS) {
|
|
7167
|
-
logger.debug(`[schema] ${action.kind} ${action.target}: lost a race with another instance (attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`);
|
|
7168
|
-
await new Promise((resolve) => setTimeout(resolve, 40 * attempt * (1 + Math.random())));
|
|
7169
|
-
continue;
|
|
7170
|
-
}
|
|
7171
|
-
throw err;
|
|
7172
|
-
}
|
|
7173
|
-
}
|
|
5686
|
+
var buildCollectionIndexPlan = (collections, resolveColumnName) => collections.flatMap((collection) => buildCollectionIndexSpecs(collection, resolveColumnName)).sort((a, b) => a.schema.localeCompare(b.schema) || a.table.localeCompare(b.table) || a.indexName.localeCompare(b.indexName));
|
|
7174
5687
|
//#endregion
|
|
7175
|
-
export {
|
|
5688
|
+
export { sortCollectionsBySlug as A, getPolicyNamesForRule as B, getTableName as C, getDeclaredPrimaryKeys as D, buildCompositeId as E, firstFreeKey as F, DEFAULT_ONE_OF_TYPE as G, mergeDeep as H, generateForeignKeyName as I, resolveClientListLimit as J, DEFAULT_ONE_OF_VALUE as K, legacyForeignKeyName as L, createRelationRefWithData as M, normalizeToEntityRelation as N, isAddressableId as O, updateDateAutoValues as P, Vector as Q, toPostgresIdentifier as R, getEnumVarName as S, resolveCollectionRelations as T, camelCase as U, isPrototypePollutingKey as V, toSnakeCase as W, hasForeignKeyOnTarget as X, ANONYMOUS_USER_ID as Y, isManyToMany as Z, securityRuleToConditions as _, buildSdkData as a, findRelation as b, parseOrderBySpecStrict as c, getJunctionCollectionConfig as d, getJunctionSecurityRules as f, policyToPostgres as g, getInjectedSecurityRules as h, collectionIndexStatements as i, createRelationRef as j, parseIdValues as k, CollectionRegistry as l, getEffectiveSecurityRules as m, buildCollectionIndexSpecs as n, OrderBySpecError as o, resolveJunctionSpecs as p, ListLimitError as q, collectionIndexStatement as r, normalizeDriverOrderBy as s, buildCollectionIndexPlan as t, relationalCollections as u, findAnonymousGrants as v, getTableVarName as w, getColumnName as x, fieldKeyForColumn as y, toWireKey as z };
|
|
7176
5689
|
|
|
7177
|
-
//# sourceMappingURL=
|
|
5690
|
+
//# sourceMappingURL=collection-index-DxJBvVTH.js.map
|