@rebasepro/server-postgres 0.23.0 → 0.24.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/{BranchService-DGPL6G_C.js → BranchService-BRt78gfa.js} +35 -18
- package/dist/BranchService-BRt78gfa.js.map +1 -0
- package/dist/PostgresBackendDriver.d.ts +84 -12
- package/dist/auth/services.d.ts +72 -14
- package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
- package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
- package/dist/{backup-cli-24v4OlSp.js → backup-cli-DW5p9_zv.js} +2 -2
- package/dist/{backup-cli-24v4OlSp.js.map → backup-cli-DW5p9_zv.js.map} +1 -1
- package/dist/{backup-service-HQ9GC4tN.js → backup-service-EwcDVG-8.js} +7 -9
- package/dist/{backup-service-HQ9GC4tN.js.map → backup-service-EwcDVG-8.js.map} +1 -1
- package/dist/{cli-errors-B8qHg02P.js → cli-errors-DsA-K9uP.js} +96 -1
- package/dist/{cli-errors-B8qHg02P.js.map → cli-errors-DsA-K9uP.js.map} +1 -1
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +12 -12
- package/dist/cli.js +91 -37
- package/dist/cli.js.map +1 -1
- package/dist/{column-plan-helpers-DF-8dTVa.js → column-plan-helpers-1-LQD0yI.js} +34 -24
- package/dist/column-plan-helpers-1-LQD0yI.js.map +1 -0
- package/dist/data-transformer.d.ts +0 -8
- package/dist/{doctor-Cb2thZ8s.js → doctor-C-sYWbmt.js} +224 -43
- package/dist/doctor-C-sYWbmt.js.map +1 -0
- package/dist/{ensure-collection-policies-RHUcEp4v.js → ensure-collection-policies-B1ureSIV.js} +73 -18
- package/dist/ensure-collection-policies-B1ureSIV.js.map +1 -0
- package/dist/{ensure-collection-tables-BEEjn5cn.js → ensure-collection-tables-kkkHk8oo.js} +76 -20
- package/dist/ensure-collection-tables-kkkHk8oo.js.map +1 -0
- package/dist/{ensure-tables-Dhn9KM3B.js → ensure-tables-BmI_tRxc.js} +10 -3
- package/dist/ensure-tables-BmI_tRxc.js.map +1 -0
- package/dist/{generate-drizzle-schema-yzY_BLhr.js → generate-drizzle-schema-93M0lUxK.js} +2 -2
- package/dist/{generate-drizzle-schema-yzY_BLhr.js.map → generate-drizzle-schema-93M0lUxK.js.map} +1 -1
- package/dist/{generate-drizzle-schema-logic-BfzK7UQd.js → generate-drizzle-schema-logic-sSFDp6LR.js} +26 -8
- package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +1 -0
- package/dist/generate-postgres-ddl-logic-BJsLaVNX.js +152 -0
- package/dist/generate-postgres-ddl-logic-BJsLaVNX.js.map +1 -0
- package/dist/generated-sql.d.ts +28 -0
- package/dist/index.es.js +3415 -917
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-WMuAfxvw.js → introspect-db-logic-kCETE8TY.js} +523 -349
- package/dist/introspect-db-logic-kCETE8TY.js.map +1 -0
- package/dist/introspect-db-queries-C_Q5VgQw.js +317 -0
- package/dist/introspect-db-queries-C_Q5VgQw.js.map +1 -0
- package/dist/{plan-schema-C0fxM8dY.js → plan-schema-DU9exq6C.js} +139 -504
- package/dist/plan-schema-DU9exq6C.js.map +1 -0
- package/dist/{policy-drift-DljYdrpW.js → policy-drift-B-J2hhm0.js} +3 -3
- package/dist/policy-drift-B-J2hhm0.js.map +1 -0
- package/dist/{generate-postgres-ddl-logic-Bt2d2mRH.js → render-ddl-Ds2t_d9V.js} +11 -149
- package/dist/render-ddl-Ds2t_d9V.js.map +1 -0
- package/dist/{rls-bootstrap-sql-B8EclDyM.js → rls-bootstrap-sql-_KNnjanK.js} +263 -39
- package/dist/rls-bootstrap-sql-_KNnjanK.js.map +1 -0
- package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-CfXOJJaW.js} +10 -2
- package/dist/rls-enforcement-CfXOJJaW.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/classify-change.d.ts +29 -1
- package/dist/schema/column-plan-helpers.d.ts +33 -21
- package/dist/schema/destructive-sql.d.ts +20 -1
- package/dist/schema/doctor-cli.js +4 -4
- package/dist/schema/doctor.d.ts +34 -1
- package/dist/schema/ensure-collection-policies.d.ts +22 -0
- package/dist/schema/generate-drizzle-schema.js +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
- package/dist/schema/generate-postgres-ddl.js +1 -1
- package/dist/schema/generate-schema-commit.d.ts +12 -0
- package/dist/schema/introspect-db-logic.d.ts +31 -0
- package/dist/schema/introspect-db-queries.d.ts +1 -1
- package/dist/schema/introspect-db-search.d.ts +22 -0
- package/dist/schema/introspect-db-storage.d.ts +83 -0
- package/dist/schema/introspect-db.js +15 -3
- package/dist/schema/introspect-db.js.map +1 -1
- package/dist/schema/plan/diff-plan.d.ts +1 -1
- package/dist/schema/plan/plan-schema.d.ts +16 -8
- package/dist/schema/plan/render-ddl.d.ts +6 -0
- package/dist/schema/plan/types.d.ts +37 -11
- package/dist/search-column-BM-GV6vH.js +442 -0
- package/dist/search-column-BM-GV6vH.js.map +1 -0
- package/dist/security/policy-drift.d.ts +1 -1
- package/dist/security/rls-enforcement.d.ts +7 -0
- package/dist/services/BranchService.d.ts +22 -1
- package/dist/services/FetchService.d.ts +5 -4
- package/dist/services/RelationService.d.ts +18 -0
- package/dist/services/cdc/CdcListener.d.ts +9 -4
- package/dist/services/cdc/identity-columns.d.ts +19 -0
- package/dist/services/cdc/trigger-cdc.d.ts +45 -7
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
- package/dist/services/channel-history.d.ts +17 -1
- package/dist/services/collection-helpers.d.ts +11 -1
- package/dist/services/pg-notify-listener.d.ts +88 -6
- package/dist/services/realtimeService.d.ts +159 -45
- package/dist/services/socket-liveness.d.ts +45 -0
- package/dist/services/soft-delete.d.ts +12 -0
- package/dist/services/sql-script.d.ts +71 -0
- package/dist/services/write-depth.d.ts +16 -0
- package/dist/services/write-transaction-scope.d.ts +6 -3
- package/dist/utils/drizzle-conditions.d.ts +42 -2
- package/dist/utils/sql-redaction.d.ts +21 -0
- package/dist/websocket.d.ts +50 -18
- package/package.json +7 -7
- package/dist/BranchService-DGPL6G_C.js.map +0 -1
- package/dist/column-plan-helpers-DF-8dTVa.js.map +0 -1
- package/dist/doctor-Cb2thZ8s.js.map +0 -1
- package/dist/ensure-collection-policies-RHUcEp4v.js.map +0 -1
- package/dist/ensure-collection-tables-BEEjn5cn.js.map +0 -1
- package/dist/ensure-tables-Dhn9KM3B.js.map +0 -1
- package/dist/generate-drizzle-schema-logic-BfzK7UQd.js.map +0 -1
- package/dist/generate-postgres-ddl-logic-Bt2d2mRH.js.map +0 -1
- package/dist/introspect-db-logic-WMuAfxvw.js.map +0 -1
- package/dist/plan-schema-C0fxM8dY.js.map +0 -1
- package/dist/policy-drift-DljYdrpW.js.map +0 -1
- package/dist/rls-bootstrap-sql-B8EclDyM.js.map +0 -1
- package/dist/rls-enforcement-C6Xk0lA6.js.map +0 -1
|
@@ -1,446 +1,13 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
__createRequire(import.meta.url);
|
|
3
|
-
import { a as
|
|
4
|
-
import { i as
|
|
3
|
+
import { a as buildSearchColumnSpec, d as searchColumnTypeSql, f as searchExtensionStatements, i as assertSearchIsPostgresOnly, p as searchHelperFunctions } from "./search-column-BM-GV6vH.js";
|
|
4
|
+
import { a as getPrimaryKeyName, c as isIdProperty, i as getPrimaryKeyColumn, l as primaryKeyPropertyNames, n as declaresEnumType, o as getPrimaryKeyProp, r as enumLabelsOf, s as idColumnDefault, u as resolveColumnName } from "./column-plan-helpers-1-LQD0yI.js";
|
|
5
|
+
import { i as isAuthCollection, n as authUsersColumnDefinition, r as authUsersColumnSql, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-C72EMoDJ.js";
|
|
5
6
|
import { c as vectorExtensionStatement, n as buildVectorColumnSpecs, o as vectorExtensionDeclared, r as buildVectorIndexPlan } from "./vector-index-CMJKb3C9.js";
|
|
6
7
|
import { a as deriveIndexName, t as buildCollectionIndexSpecs } from "./collection-index-DdnsxiJ_.js";
|
|
7
|
-
import {
|
|
8
|
-
import { TENANT_INDEX_REASON, assertBeforeQueryIsPostgresOnly, fieldKeyForColumn, findRelation, getEffectiveSecurityRules, getEnumVarName, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getTableName, getTableVarName, getTenantConfig,
|
|
9
|
-
import { generateForeignKeyName,
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
11
|
-
//#region src/schema/search-column.ts
|
|
12
|
-
/**
|
|
13
|
-
* The one place a collection's `search` block becomes SQL.
|
|
14
|
-
*
|
|
15
|
-
* Four things describe a Postgres table in this codebase — the DDL generator,
|
|
16
|
-
* the Drizzle schema generator, the runtime table builder for BaaS mode, and
|
|
17
|
-
* the boot-time schema ensure — and each of them has, at some point, described
|
|
18
|
-
* a column differently from the others. The `varchar(255)` note in
|
|
19
|
-
* `generate-postgres-ddl-logic` is one such scar: the same property produced a
|
|
20
|
-
* capped column down one path and an uncapped one down the other, and nothing
|
|
21
|
-
* failed until a user hit the cap.
|
|
22
|
-
*
|
|
23
|
-
* So the search column is not implemented four times. It is computed once,
|
|
24
|
-
* here, and every generator renders the same {@link SearchColumnSpec}. There is
|
|
25
|
-
* a test asserting exactly that (`search-column-contract.test.ts`); the point of
|
|
26
|
-
* this module is that the test has something to assert *about*.
|
|
27
|
-
*
|
|
28
|
-
* ## Why the expressions look the way they do
|
|
29
|
-
*
|
|
30
|
-
* A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and
|
|
31
|
-
* Postgres is stricter here than intuition. Verified against PostgreSQL 18:
|
|
32
|
-
*
|
|
33
|
-
* | expression | immutable |
|
|
34
|
-
* |-----------------------------------------|-----------|
|
|
35
|
-
* | `to_tsvector('spanish', col)` | yes |
|
|
36
|
-
* | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` |
|
|
37
|
-
* | `array_to_string(col, ' ')` | **no** |
|
|
38
|
-
* | `col::text` on `text[]` | **no** |
|
|
39
|
-
* | `to_jsonb(col)` | **no** |
|
|
40
|
-
* | `unaccent(col)` | **no** — dictionary lookup is STABLE |
|
|
41
|
-
* | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes |
|
|
42
|
-
* | `setweight(...) || setweight(...)` | yes |
|
|
43
|
-
*
|
|
44
|
-
* Three of the four things a real search column needs are therefore unavailable
|
|
45
|
-
* directly, which is why {@link searchHelperFunctions} exists: each wraps a
|
|
46
|
-
* stable built-in in an SQL function declared IMMUTABLE. That declaration is a
|
|
47
|
-
* promise, and it is a true one for these three — array joining, JSON string
|
|
48
|
-
* extraction and accent folding are all deterministic for a given input; the
|
|
49
|
-
* built-ins are marked stable only because they must account for element types
|
|
50
|
-
* and dictionaries in general.
|
|
51
|
-
*
|
|
52
|
-
* The alternative was to skip `unaccent` and text arrays entirely. That is not
|
|
53
|
-
* a real option in an accented language: Postgres stems `auditoría` to
|
|
54
|
-
* `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query
|
|
55
|
-
* typed without accents misses every row that carries them.
|
|
56
|
-
*/
|
|
57
|
-
/** Schema-qualified so a collection outside `public` still resolves them. */
|
|
58
|
-
var HELPER_SCHEMA = "public";
|
|
59
|
-
/**
|
|
60
|
-
* Names of the helper functions. Frozen: they are recorded in the stored
|
|
61
|
-
* generation expression of every search column ever created, so renaming one
|
|
62
|
-
* orphans every table that already has a search column.
|
|
63
|
-
*/
|
|
64
|
-
var SEARCH_TEXT_FN = `${HELPER_SCHEMA}.rebase_search_text`;
|
|
65
|
-
var SEARCH_UNACCENT_FN = `${HELPER_SCHEMA}.rebase_search_unaccent`;
|
|
66
|
-
/** Raised when a `search` block names something that cannot be searched. */
|
|
67
|
-
var SearchConfigError = class extends Error {
|
|
68
|
-
constructor(message) {
|
|
69
|
-
super(message);
|
|
70
|
-
this.name = "SearchConfigError";
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
/** The `search` block of a collection, or undefined when it has none. */
|
|
74
|
-
var getSearchConfig = (collection) => isPostgresCollectionConfig(collection) ? collection.search : void 0;
|
|
75
|
-
/**
|
|
76
|
-
* Refuse a `search` block on a collection this engine does not store.
|
|
77
|
-
*
|
|
78
|
-
* The type only permits one on a `PostgresCollectionConfig`, so TypeScript
|
|
79
|
-
* already stops the ordinary case. This catches the rest — a JS config, a cast,
|
|
80
|
-
* a collection whose `engine` was changed after the block was written — because
|
|
81
|
-
* the alternative is the exact failure the block exists to prevent: a developer
|
|
82
|
-
* who declared what to index, saw no error, and got the substring fallback.
|
|
83
|
-
*
|
|
84
|
-
* Called with *every* collection, before the Postgres ones are filtered out.
|
|
85
|
-
*/
|
|
86
|
-
var assertSearchIsPostgresOnly = (collections) => {
|
|
87
|
-
for (const collection of collections) {
|
|
88
|
-
if (isPostgresCollectionConfig(collection)) continue;
|
|
89
|
-
if (!collection.search) continue;
|
|
90
|
-
const engine = collection.engine ?? "non-postgres";
|
|
91
|
-
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.`);
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
var columnNameOf = (propName, prop) => prop && "columnName" in prop && typeof prop.columnName === "string" ? prop.columnName : toSnakeCase(propName);
|
|
95
|
-
/**
|
|
96
|
-
* Classify a property for search purposes.
|
|
97
|
-
*
|
|
98
|
-
* Deliberately narrower than the schema plan's `PgType`: search only cares
|
|
99
|
-
* whether a value reaches text, and the mapping from property to *physical*
|
|
100
|
-
* type is asserted against the plan in the contract test rather than duplicated
|
|
101
|
-
* here.
|
|
102
|
-
*
|
|
103
|
-
* Returns null for anything that is not text-bearing, which the caller turns
|
|
104
|
-
* into a boot error naming the property.
|
|
105
|
-
*/
|
|
106
|
-
var classify = (prop) => {
|
|
107
|
-
switch (prop.type) {
|
|
108
|
-
case "string": {
|
|
109
|
-
const sp = prop;
|
|
110
|
-
if (sp.enum) return {
|
|
111
|
-
kind: "text",
|
|
112
|
-
reason: "enum"
|
|
113
|
-
};
|
|
114
|
-
if (sp.isId === "uuid" || sp.columnType === "uuid") return {
|
|
115
|
-
kind: "text",
|
|
116
|
-
reason: "uuid"
|
|
117
|
-
};
|
|
118
|
-
return { kind: "text" };
|
|
119
|
-
}
|
|
120
|
-
case "map":
|
|
121
|
-
if (prop.columnType === "json") return {
|
|
122
|
-
kind: "jsonb",
|
|
123
|
-
reason: "json"
|
|
124
|
-
};
|
|
125
|
-
return { kind: "jsonb" };
|
|
126
|
-
case "array": {
|
|
127
|
-
const ap = prop;
|
|
128
|
-
let colType = ap.columnType;
|
|
129
|
-
if (!colType && ap.of && !Array.isArray(ap.of)) {
|
|
130
|
-
const of = ap.of;
|
|
131
|
-
if (of.type === "string") colType = "text[]";
|
|
132
|
-
else if (of.type === "number") colType = of.validation?.integer ? "integer[]" : "numeric[]";
|
|
133
|
-
else if (of.type === "boolean") colType = "boolean[]";
|
|
134
|
-
}
|
|
135
|
-
if (colType === "text[]") return { kind: "text_array" };
|
|
136
|
-
if (colType === "json") return {
|
|
137
|
-
kind: "jsonb",
|
|
138
|
-
reason: "json"
|
|
139
|
-
};
|
|
140
|
-
if (colType === "integer[]" || colType === "boolean[]" || colType === "numeric[]") return {
|
|
141
|
-
kind: "text_array",
|
|
142
|
-
reason: "non_text_array"
|
|
143
|
-
};
|
|
144
|
-
return { kind: "jsonb" };
|
|
145
|
-
}
|
|
146
|
-
default: return null;
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
var normalize = (inner, unaccent) => unaccent ? `${SEARCH_UNACCENT_FN}(${inner})` : inner;
|
|
150
|
-
/** SQL reading one field as plain text, before normalization. */
|
|
151
|
-
var rawTextSql = (field) => {
|
|
152
|
-
const col = `"${field.column}"`;
|
|
153
|
-
if (field.kind === "text") return `coalesce(${col}, '')`;
|
|
154
|
-
if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
|
|
155
|
-
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))`;
|
|
156
|
-
};
|
|
157
|
-
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
158
|
-
/**
|
|
159
|
-
* Resolve and validate one declared field path.
|
|
160
|
-
*
|
|
161
|
-
* A path that does not resolve throws. The whole point of an explicit block is
|
|
162
|
-
* that the author knows what is indexed; a silently dropped field would make it
|
|
163
|
-
* a guess again, and the failure — a search that returns nothing for content
|
|
164
|
-
* that is plainly in the row — is invisible from the outside.
|
|
165
|
-
*/
|
|
166
|
-
var resolveField = (entry, collection, cfg) => {
|
|
167
|
-
const path = typeof entry === "string" ? entry : entry.path;
|
|
168
|
-
const weight = (typeof entry === "string" ? void 0 : entry.weight) ?? DEFAULT_SEARCH_WEIGHT;
|
|
169
|
-
const where = `${collection.slug}.search`;
|
|
170
|
-
if (!path || typeof path !== "string") throw new SearchConfigError(`${where}: every entry in \`fields\` needs a property path.`);
|
|
171
|
-
const [head, ...rest] = path.split(".");
|
|
172
|
-
const prop = collection.properties?.[head];
|
|
173
|
-
if (!prop) throw new SearchConfigError(`${where}: "${path}" starts at property "${head}", which this collection does not declare. Known properties: ${Object.keys(collection.properties ?? {}).join(", ")}.`);
|
|
174
|
-
const classified = classify(prop);
|
|
175
|
-
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).`);
|
|
176
|
-
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.`);
|
|
177
|
-
if (classified.reason === "uuid") throw new SearchConfigError(`${where}: "${path}" is a UUID column. Look it up by id rather than searching it.`);
|
|
178
|
-
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.`);
|
|
179
|
-
if (classified.reason === "non_text_array") throw new SearchConfigError(`${where}: "${path}" is an array of numbers or booleans. Only \`string[]\` carries text to search.`);
|
|
180
|
-
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.`);
|
|
181
|
-
const column = columnNameOf(head, prop);
|
|
182
|
-
const raw = rawTextSql({
|
|
183
|
-
column,
|
|
184
|
-
jsonPath: rest,
|
|
185
|
-
kind: classified.kind
|
|
186
|
-
});
|
|
187
|
-
const textSql = normalize(raw, cfg.unaccent === true);
|
|
188
|
-
const language = cfg.language ?? DEFAULT_SEARCH_LANGUAGE;
|
|
189
|
-
return {
|
|
190
|
-
path,
|
|
191
|
-
column,
|
|
192
|
-
jsonPath: rest,
|
|
193
|
-
kind: classified.kind,
|
|
194
|
-
weight,
|
|
195
|
-
sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
|
|
196
|
-
textSql,
|
|
197
|
-
foldedTextSql: normalize(raw, true)
|
|
198
|
-
};
|
|
199
|
-
};
|
|
200
|
-
/**
|
|
201
|
-
* Build the full spec for a collection, or undefined when it has not opted in.
|
|
202
|
-
*
|
|
203
|
-
* Throws {@link SearchConfigError} on a config that cannot be honoured. Callers
|
|
204
|
-
* at boot surface that as a startup failure — a search block that half-works is
|
|
205
|
-
* worse than one that refuses.
|
|
206
|
-
*/
|
|
207
|
-
var buildSearchColumnSpec = (collection) => {
|
|
208
|
-
const cfg = getSearchConfig(collection);
|
|
209
|
-
if (!cfg) return void 0;
|
|
210
|
-
if (!Array.isArray(cfg.fields) || cfg.fields.length === 0) throw new SearchConfigError(`${collection.slug}.search: \`fields\` is empty. Name the properties to index, or remove the \`search\` block to keep the default ILIKE behaviour.`);
|
|
211
|
-
const table = getTableName(collection);
|
|
212
|
-
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
213
|
-
const column = cfg.column ?? DEFAULT_SEARCH_COLUMN;
|
|
214
|
-
if (collection.properties?.[column]) throw new SearchConfigError(`${collection.slug}.search: the generated column "${column}" collides with a declared property of the same name. Set \`search.column\` to something else.`);
|
|
215
|
-
const fields = cfg.fields.map((entry) => resolveField(entry, collection, cfg));
|
|
216
|
-
const seen = /* @__PURE__ */ new Set();
|
|
217
|
-
for (const f of fields) {
|
|
218
|
-
if (seen.has(f.path)) throw new SearchConfigError(`${collection.slug}.search: "${f.path}" is listed twice.`);
|
|
219
|
-
seen.add(f.path);
|
|
220
|
-
}
|
|
221
|
-
const mode = cfg.mode ?? DEFAULT_SEARCH_MODE;
|
|
222
|
-
const extensions = [];
|
|
223
|
-
if (cfg.unaccent || mode === "hybrid") extensions.push("unaccent");
|
|
224
|
-
if (cfg.fuzzy) extensions.push("pg_trgm");
|
|
225
|
-
const spec = {
|
|
226
|
-
schema,
|
|
227
|
-
table,
|
|
228
|
-
column,
|
|
229
|
-
language: cfg.language ?? DEFAULT_SEARCH_LANGUAGE,
|
|
230
|
-
unaccent: cfg.unaccent === true,
|
|
231
|
-
mode,
|
|
232
|
-
fields,
|
|
233
|
-
expression: fields.map((f) => f.sql).join(" || "),
|
|
234
|
-
indexName: toPostgresIdentifier(`${table}_${column}_gin`),
|
|
235
|
-
extensions
|
|
236
|
-
};
|
|
237
|
-
if (cfg.fuzzy) {
|
|
238
|
-
const fuzzyColumn = `${column}_text`;
|
|
239
|
-
if (collection.properties?.[fuzzyColumn]) throw new SearchConfigError(`${collection.slug}.search: \`fuzzy\` needs the column "${fuzzyColumn}", which collides with a declared property. Set \`search.column\` to something else.`);
|
|
240
|
-
spec.fuzzy = {
|
|
241
|
-
column: fuzzyColumn,
|
|
242
|
-
expression: fields.map((f) => f.textSql).join(" || ' ' || "),
|
|
243
|
-
indexName: toPostgresIdentifier(`${table}_${fuzzyColumn}_trgm`),
|
|
244
|
-
threshold: cfg.fuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
return spec;
|
|
248
|
-
};
|
|
249
|
-
/**
|
|
250
|
-
* The IMMUTABLE wrappers the generated expressions call.
|
|
251
|
-
*
|
|
252
|
-
* `CREATE OR REPLACE` so a boot against an existing database is a no-op rather
|
|
253
|
-
* than an error, and idempotent for the same reason every other boot-time DDL
|
|
254
|
-
* statement here is.
|
|
255
|
-
*
|
|
256
|
-
* The bodies are stable built-ins wrapped in an immutable promise — see the
|
|
257
|
-
* module comment for why that promise is sound. `STRICT` matters: it makes NULL
|
|
258
|
-
* in mean NULL out without executing the body, which is what the `coalesce` at
|
|
259
|
-
* each call site then absorbs.
|
|
260
|
-
*/
|
|
261
|
-
var searchHelperFunctions = (spec) => {
|
|
262
|
-
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 $$;`];
|
|
263
|
-
if (spec.unaccent || spec.mode === "hybrid") 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) $$;`);
|
|
264
|
-
return statements;
|
|
265
|
-
};
|
|
266
|
-
/**
|
|
267
|
-
* `CREATE EXTENSION` statements the spec's expressions depend on.
|
|
268
|
-
*
|
|
269
|
-
* `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified
|
|
270
|
-
* `CREATE EXTENSION` installs into the first schema on `search_path`, which
|
|
271
|
-
* defaults to `"$user", public` — and the scaffold's database role is named
|
|
272
|
-
* `rebase`, the same as the schema the generator creates one statement earlier.
|
|
273
|
-
* So the moment that schema exists, `CREATE EXTENSION unaccent` puts the
|
|
274
|
-
* dictionary in `rebase`, and every reference to `public.unaccent` below fails
|
|
275
|
-
* with "text search dictionary does not exist". Observed, not theorised.
|
|
276
|
-
*/
|
|
277
|
-
var searchExtensionStatements = (spec) => spec.extensions.map((e) => `CREATE EXTENSION IF NOT EXISTS ${e} WITH SCHEMA ${HELPER_SCHEMA};`);
|
|
278
|
-
/**
|
|
279
|
-
* Everything after the column name — the type and the generation expression.
|
|
280
|
-
*
|
|
281
|
-
* Split out because four emitters need it and only two of them have a place to
|
|
282
|
-
* put the name: `CREATE TABLE` and `ADD COLUMN` write `"col" <this>`, while the
|
|
283
|
-
* schema plan carries it as the column's SQL definition and the boot-time
|
|
284
|
-
* rebuild statement interpolates it on its own.
|
|
285
|
-
*/
|
|
286
|
-
var searchColumnTypeSql = (expression, kind) => `${kind} GENERATED ALWAYS AS (${expression}) STORED`;
|
|
287
|
-
/** The column definition as it appears inside `CREATE TABLE`. */
|
|
288
|
-
var searchColumnDefinition = (spec) => `"${spec.column}" ${searchColumnTypeSql(spec.expression, "tsvector")}`;
|
|
289
|
-
/** The fuzzy column definition, when the spec asks for one. */
|
|
290
|
-
var fuzzyColumnDefinition = (spec) => spec.fuzzy ? `"${spec.fuzzy.column}" ${searchColumnTypeSql(spec.fuzzy.expression, "text")}` : void 0;
|
|
291
|
-
/**
|
|
292
|
-
* Index statements for the spec.
|
|
293
|
-
*
|
|
294
|
-
* `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a
|
|
295
|
-
* SQL file replayed as one unit — a migration, or `search.sql` — where a
|
|
296
|
-
* concurrent build is not allowed. The boot-time ensure path runs statement by
|
|
297
|
-
* statement against tables that are live and populated, and uses the
|
|
298
|
-
* concurrent form instead; see `ensureSearchColumns`.
|
|
299
|
-
*/
|
|
300
|
-
var searchIndexStatements = (spec) => {
|
|
301
|
-
const statements = [`CREATE INDEX IF NOT EXISTS "${spec.indexName}" ON "${spec.schema}"."${spec.table}" USING GIN ("${spec.column}");`];
|
|
302
|
-
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);`);
|
|
303
|
-
return statements;
|
|
304
|
-
};
|
|
305
|
-
/**
|
|
306
|
-
* Marker on the comment of every generated search column this module creates.
|
|
307
|
-
*
|
|
308
|
-
* Versioned because the fingerprint below is only comparable against itself: a
|
|
309
|
-
* future change to how it is computed has to read as "not stamped by this
|
|
310
|
-
* version" rather than as drift on every existing column.
|
|
311
|
-
*/
|
|
312
|
-
var SEARCH_STAMP_PREFIX = "rebase:search:v1:";
|
|
313
|
-
/**
|
|
314
|
-
* A stable fingerprint of one generated column's expression.
|
|
315
|
-
*
|
|
316
|
-
* Why a stamp rather than reading the expression back: Postgres stores a
|
|
317
|
-
* generated column's expression *parsed*, and hands it back deparsed — casts
|
|
318
|
-
* made explicit, identifiers requoted, schema qualifications added or dropped
|
|
319
|
-
* according to `search_path`. Comparing that text to the text we generated
|
|
320
|
-
* would report drift on wording, and this comparison decides whether a boot
|
|
321
|
-
* refuses, so a false positive is an outage. The stamp is written by the same
|
|
322
|
-
* code that writes the column, so equality means what it says.
|
|
323
|
-
*/
|
|
324
|
-
var searchExpressionFingerprint = (expression) => `${SEARCH_STAMP_PREFIX}${createHash("sha256").update(expression).digest("hex").slice(0, 16)}`;
|
|
325
|
-
/**
|
|
326
|
-
* The stamps for a spec's generated columns — one per column, never shared.
|
|
327
|
-
*
|
|
328
|
-
* Per column on purpose: turning `fuzzy` on adds a second column and changes
|
|
329
|
-
* nothing about the first, and a spec-wide fingerprint would report the
|
|
330
|
-
* untouched `tsvector` column as drifted and refuse a boot over a change that
|
|
331
|
-
* is purely additive.
|
|
332
|
-
*/
|
|
333
|
-
var searchColumnStamps = (spec) => {
|
|
334
|
-
const stamp = (column, expression) => {
|
|
335
|
-
const fingerprint = searchExpressionFingerprint(expression);
|
|
336
|
-
return {
|
|
337
|
-
column,
|
|
338
|
-
expression,
|
|
339
|
-
fingerprint,
|
|
340
|
-
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
|
|
341
|
-
};
|
|
342
|
-
};
|
|
343
|
-
const stamps = [stamp(spec.column, spec.expression)];
|
|
344
|
-
if (spec.fuzzy) stamps.push(stamp(spec.fuzzy.column, spec.fuzzy.expression));
|
|
345
|
-
return stamps;
|
|
346
|
-
};
|
|
347
|
-
/**
|
|
348
|
-
* The same drift check as the boot ensure, for the SQL file.
|
|
349
|
-
*
|
|
350
|
-
* Needed because {@link searchColumnStamps} would otherwise *launder* drift on
|
|
351
|
-
* the migration path: `ADD COLUMN IF NOT EXISTS` does nothing to a column that
|
|
352
|
-
* exists, so a re-generated `search.sql` would stamp a stale column with the
|
|
353
|
-
* new block's fingerprint and the next boot would find them in agreement.
|
|
354
|
-
* Guarding first means the file refuses instead — `rebase db push` is attended,
|
|
355
|
-
* and the operator reading the failure is the person who changed the block.
|
|
356
|
-
*/
|
|
357
|
-
var searchStampGuards = (spec) => searchColumnStamps(spec).map((stamp) => {
|
|
358
|
-
const relation = quote(`"${spec.schema}"."${spec.table}"`);
|
|
359
|
-
return `DO $rebase_search$
|
|
360
|
-
DECLARE recorded text;
|
|
361
|
-
BEGIN
|
|
362
|
-
SELECT col_description(a.attrelid, a.attnum) INTO recorded
|
|
363
|
-
FROM pg_attribute a
|
|
364
|
-
WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote(stamp.column)} AND NOT a.attisdropped;
|
|
365
|
-
IF recorded LIKE ${quote(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote(stamp.fingerprint)} THEN
|
|
366
|
-
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;
|
|
367
|
-
END IF;
|
|
368
|
-
END
|
|
369
|
-
$rebase_search$;`;
|
|
370
|
-
});
|
|
371
|
-
/**
|
|
372
|
-
* The index names the spec creates.
|
|
373
|
-
*
|
|
374
|
-
* Needed by name, not just by statement, so Atlas can be told to exclude them
|
|
375
|
-
* from its diff — see `searchExcludePatterns`.
|
|
376
|
-
*/
|
|
377
|
-
var searchIndexNames = (spec) => spec.fuzzy ? [spec.indexName, spec.fuzzy.indexName] : [spec.indexName];
|
|
378
|
-
/**
|
|
379
|
-
* The generated column names a collection's search block adds, if any.
|
|
380
|
-
*
|
|
381
|
-
* These are physical columns on the table, so `SELECT *` returns them. They are
|
|
382
|
-
* an index in column form — a list of lexeme positions, or a concatenation of
|
|
383
|
-
* every searchable field on the row — and nothing outside the query planner has
|
|
384
|
-
* any use for them. Left in, every list response carries a second, larger copy
|
|
385
|
-
* of the row's text.
|
|
386
|
-
*/
|
|
387
|
-
var searchColumnNames = (collection) => {
|
|
388
|
-
let spec;
|
|
389
|
-
try {
|
|
390
|
-
spec = buildSearchColumnSpec(collection);
|
|
391
|
-
} catch {
|
|
392
|
-
return [];
|
|
393
|
-
}
|
|
394
|
-
if (!spec) return [];
|
|
395
|
-
return spec.fuzzy ? [spec.column, spec.fuzzy.column] : [spec.column];
|
|
396
|
-
};
|
|
397
|
-
/**
|
|
398
|
-
* True for a column whose type only ever holds a search index.
|
|
399
|
-
*
|
|
400
|
-
* Independent of any collection config on purpose: an introspected database
|
|
401
|
-
* (BaaS mode) can carry a `tsvector` column this framework never created —
|
|
402
|
-
* Pagila's `film.fulltext` is the canonical one — and it should not be returned
|
|
403
|
-
* to callers either. `isDerivedIndexColumn` already keeps such a column out of
|
|
404
|
-
* the *properties*; this keeps it out of the *rows*.
|
|
405
|
-
*/
|
|
406
|
-
var isSearchIndexColumn = (column) => {
|
|
407
|
-
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
408
|
-
return sqlType === "tsvector" || sqlType === "tsquery";
|
|
409
|
-
};
|
|
410
|
-
/**
|
|
411
|
-
* A drizzle select projection over `table` with the search columns dropped.
|
|
412
|
-
*
|
|
413
|
-
* Returns undefined when nothing needs dropping, so the common case keeps using
|
|
414
|
-
* a plain `select()` and this stays invisible in the generated SQL.
|
|
415
|
-
*/
|
|
416
|
-
var visibleColumnProjection = (tableColumns, collection) => {
|
|
417
|
-
const excluded = excludedColumnNames(tableColumns, collection);
|
|
418
|
-
if (!tableColumns || excluded.length === 0) return void 0;
|
|
419
|
-
const projection = {};
|
|
420
|
-
for (const [name, column] of Object.entries(tableColumns)) if (!excluded.includes(name)) projection[name] = column;
|
|
421
|
-
return projection;
|
|
422
|
-
};
|
|
423
|
-
/** The same exclusion as a drizzle `db.query` `columns` denylist. */
|
|
424
|
-
var hiddenColumnsOption = (tableColumns, collection) => {
|
|
425
|
-
const excluded = excludedColumnNames(tableColumns, collection);
|
|
426
|
-
if (excluded.length === 0) return void 0;
|
|
427
|
-
return Object.fromEntries(excluded.map((name) => [name, false]));
|
|
428
|
-
};
|
|
429
|
-
/**
|
|
430
|
-
* The columns to keep out of a response, by name.
|
|
431
|
-
*
|
|
432
|
-
* `tableColumns` is whatever `getTableColumns` returned, which is `undefined`
|
|
433
|
-
* for anything that is not a real drizzle table — a stub in a test, a derived
|
|
434
|
-
* or nested path with no table behind it. Nothing to exclude is the right
|
|
435
|
-
* answer there, and it has to be an answer rather than a throw: this runs on
|
|
436
|
-
* the read path of every collection, opted in or not.
|
|
437
|
-
*/
|
|
438
|
-
var excludedColumnNames = (tableColumns, collection) => {
|
|
439
|
-
if (!tableColumns || typeof tableColumns !== "object") return [];
|
|
440
|
-
const byName = new Set(collection ? searchColumnNames(collection) : []);
|
|
441
|
-
return Object.keys(tableColumns).filter((name) => byName.has(name) || isSearchIndexColumn(tableColumns[name]));
|
|
442
|
-
};
|
|
443
|
-
//#endregion
|
|
8
|
+
import { REBASE_SCHEMA, hasForeignKeyOnTarget, isManyToMany, isPostgresCollectionConfig } from "@rebasepro/types";
|
|
9
|
+
import { TENANT_INDEX_REASON, assertBeforeQueryIsPostgresOnly, compileRulePolicies, fieldKeyForColumn, findRelation, getEffectiveSecurityRules, getEnumVarName, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getTableName, getTableVarName, getTenantConfig, relationalCollections, resolveCollectionRelations, resolveJunctionSpecs, resolveStringColumnLength } from "@rebasepro/common";
|
|
10
|
+
import { generateForeignKeyName, legacyForeignKeyName, toPostgresIdentifier, toSnakeCase } from "@rebasepro/utils";
|
|
444
11
|
//#region src/schema/relation-names.ts
|
|
445
12
|
/**
|
|
446
13
|
* The `relationName` both sides of a link must agree on.
|
|
@@ -571,7 +138,7 @@ var triggerStatements = (plan) => [dropTriggerStatement(plan), createTriggerStat
|
|
|
571
138
|
* imported would not be comparable with itself.
|
|
572
139
|
*
|
|
573
140
|
* It **throws** rather than guessing. A configuration no renderer can honour —
|
|
574
|
-
* an empty enum,
|
|
141
|
+
* an empty enum, `isId: "cuid"`, a foreign key into a composite key, an unknown
|
|
575
142
|
* `columnType`, a relation to a collection that is not in the bundle, a
|
|
576
143
|
* `search` block on a collection Postgres does not store — used to fail
|
|
577
144
|
* differently in each of the three emitters, or not at all, and the failure
|
|
@@ -588,6 +155,8 @@ var quoteSqlLiteral = (value) => `'${value.replace(/'/g, "''")}'`;
|
|
|
588
155
|
var schemaOf = (collection) => isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
589
156
|
var bareTableName = (name) => name.includes(".") ? name.split(".").pop() : name;
|
|
590
157
|
var describe = (collection) => collection.slug ?? collection.name ?? "(unnamed)";
|
|
158
|
+
/** A link, as a refusal names it: `relation "posts.author"`. */
|
|
159
|
+
var linkLabel = (kind, collection, key) => `${kind} "${describe(collection)}.${key}"`;
|
|
591
160
|
/**
|
|
592
161
|
* The `ON DELETE` a foreign key gets when the author did not say.
|
|
593
162
|
*
|
|
@@ -632,13 +201,22 @@ var foreignKeyPlan = (args) => {
|
|
|
632
201
|
* One exception: a `serial` key owns a sequence, and a column pointing at it is
|
|
633
202
|
* the plain integer of the same width — `SERIAL` there would give every
|
|
634
203
|
* referencing column a sequence of its own.
|
|
204
|
+
*
|
|
205
|
+
* @param via the link asking, named if the collection's key is composite and so
|
|
206
|
+
* cannot be pointed at — see `getPrimaryKeyProp`.
|
|
635
207
|
*/
|
|
636
|
-
var primaryKeyPgType = (collection) => {
|
|
637
|
-
const { name, prop } = getPrimaryKeyProp(collection);
|
|
208
|
+
var primaryKeyPgType = (collection, via) => {
|
|
209
|
+
const { name, prop } = getPrimaryKeyProp(collection, via);
|
|
638
210
|
if (prop?.type === "number") return withoutSequence(numberType(name, prop, collection, true));
|
|
639
211
|
if (prop?.type === "string") return stringType(name, prop, collection);
|
|
640
212
|
return { kind: "text" };
|
|
641
213
|
};
|
|
214
|
+
/**
|
|
215
|
+
* The DEFAULT of the implicit `id TEXT PRIMARY KEY` a collection that declares
|
|
216
|
+
* no key gets: a uuid, as text. The same generator `isId: "uuid"` uses, cast to
|
|
217
|
+
* the column the key has always been.
|
|
218
|
+
*/
|
|
219
|
+
var IMPLICIT_ID_DEFAULT = "gen_random_uuid()::text";
|
|
642
220
|
var withoutSequence = (type) => {
|
|
643
221
|
switch (type.kind) {
|
|
644
222
|
case "smallserial": return { kind: "smallint" };
|
|
@@ -762,7 +340,7 @@ var columnPgType = (propName, prop, collection, resolveCollection) => {
|
|
|
762
340
|
case "relation":
|
|
763
341
|
case "reference": {
|
|
764
342
|
const target = linkTarget(propName, prop, collection, resolveCollection);
|
|
765
|
-
return target ? primaryKeyPgType(target) : { kind: "text" };
|
|
343
|
+
return target ? primaryKeyPgType(target, linkLabel(prop.type, collection, propName)) : { kind: "text" };
|
|
766
344
|
}
|
|
767
345
|
default: throw new Error(`No Postgres column type for property "${propName}" of type "${prop.type}" in collection "${describe(collection)}". Add a case to \`columnPgType\` in schema/plan/plan-schema.ts.`);
|
|
768
346
|
}
|
|
@@ -896,42 +474,21 @@ var columnDefault = (propName, prop, collection, type) => {
|
|
|
896
474
|
/**
|
|
897
475
|
* One security rule, compiled to the clauses a policy is made of.
|
|
898
476
|
*
|
|
899
|
-
* The
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
* the deny-all fallback for a clause that compiled to nothing.
|
|
477
|
+
* The compilation is `compileRulePolicies` in `@rebasepro/common`, which the
|
|
478
|
+
* Studio's RLS editor also compares the live database against — so the UI, the
|
|
479
|
+
* DDL and the database agree about who can read a row. What this adds is only
|
|
480
|
+
* the planner's bookkeeping: which rule a policy came from, and whether Rebase
|
|
481
|
+
* injected it.
|
|
905
482
|
*/
|
|
906
|
-
var compileSecurityRule = (collection, rule, resolveCollection, injected, ruleKey = rule.name ?? "(unnamed)") => {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return ops.map((operation, index) => {
|
|
912
|
-
const needsUsing = operation !== "insert";
|
|
913
|
-
const needsWithCheck = operation !== "select" && operation !== "delete";
|
|
914
|
-
let using = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection, { resolveCollection }) : null;
|
|
915
|
-
let withCheck = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection, { resolveCollection }) : null;
|
|
916
|
-
if (!using && needsUsing) using = "false";
|
|
917
|
-
if (!withCheck && needsWithCheck) withCheck = "false";
|
|
918
|
-
return {
|
|
919
|
-
name: policyNames[index],
|
|
920
|
-
ruleKey,
|
|
921
|
-
operation,
|
|
922
|
-
mode: rule.mode ?? "permissive",
|
|
923
|
-
roles: rule.pgRoles ? [...rule.pgRoles].sort() : ["public"],
|
|
924
|
-
using,
|
|
925
|
-
withCheck,
|
|
926
|
-
injected
|
|
927
|
-
};
|
|
928
|
-
});
|
|
929
|
-
};
|
|
483
|
+
var compileSecurityRule = (collection, rule, resolveCollection, injected, ruleKey = rule.name ?? "(unnamed)") => compileRulePolicies(collection, rule, { resolveCollection }).map((policy) => ({
|
|
484
|
+
...policy,
|
|
485
|
+
ruleKey,
|
|
486
|
+
injected
|
|
487
|
+
}));
|
|
930
488
|
function planSchema(allCollections, options = {}) {
|
|
931
489
|
assertSearchIsPostgresOnly(allCollections);
|
|
932
490
|
assertBeforeQueryIsPostgresOnly(allCollections);
|
|
933
491
|
const collections = relationalCollections(allCollections);
|
|
934
|
-
collections.forEach(assertSinglePrimaryKey);
|
|
935
492
|
const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
|
|
936
493
|
const declaredSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
|
|
937
494
|
const schemas = Array.from(/* @__PURE__ */ new Set([REBASE_SCHEMA, ...declaredSchemas]));
|
|
@@ -1156,21 +713,10 @@ function planCollectionTable(collection, resolveCollection, triggerFunctionNeede
|
|
|
1156
713
|
}
|
|
1157
714
|
});
|
|
1158
715
|
}
|
|
1159
|
-
|
|
1160
|
-
key: "id",
|
|
1161
|
-
column: "id",
|
|
1162
|
-
type: { kind: "text" },
|
|
1163
|
-
nullable: false,
|
|
1164
|
-
primaryKey: true,
|
|
1165
|
-
unique: false,
|
|
1166
|
-
source: {
|
|
1167
|
-
kind: "implicit-id",
|
|
1168
|
-
slug: collection.slug
|
|
1169
|
-
}
|
|
1170
|
-
});
|
|
716
|
+
const primaryKey = planPrimaryKey(collection, columns);
|
|
1171
717
|
const injected = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
|
|
1172
718
|
const policies = getEffectiveSecurityRules(collection).flatMap((rule, index) => compileSecurityRule(collection, rule, resolveCollection, Boolean(rule.name && injected.has(rule.name)), rule.name ?? `#${index}`));
|
|
1173
|
-
const tenantIndexes = applyTenantColumnEffects(collection, columns, schema, table);
|
|
719
|
+
const tenantIndexes = applyTenantColumnEffects(collection, columns, primaryKey, schema, table);
|
|
1174
720
|
settleDefaultOnDelete(collection, columns, relations);
|
|
1175
721
|
return {
|
|
1176
722
|
schema,
|
|
@@ -1181,7 +727,7 @@ function planCollectionTable(collection, resolveCollection, triggerFunctionNeede
|
|
|
1181
727
|
kind: "collection",
|
|
1182
728
|
slug: collection.slug,
|
|
1183
729
|
columns,
|
|
1184
|
-
primaryKey
|
|
730
|
+
primaryKey,
|
|
1185
731
|
indexes: [...buildCollectionIndexSpecs(collection, resolveColumnName), ...tenantIndexes],
|
|
1186
732
|
search,
|
|
1187
733
|
vector: buildVectorIndexPlan(collection, resolveColumnName),
|
|
@@ -1192,6 +738,50 @@ function planCollectionTable(collection, resolveCollection, triggerFunctionNeede
|
|
|
1192
738
|
};
|
|
1193
739
|
}
|
|
1194
740
|
/**
|
|
741
|
+
* The table's primary key, as the column names {@link TablePlan.primaryKey}
|
|
742
|
+
* lists — and the columns adjusted to state it one way only.
|
|
743
|
+
*
|
|
744
|
+
* One `isId` property is a key column of its own, declared inline
|
|
745
|
+
* (`"id" INTEGER PRIMARY KEY`, `.primaryKey()`), which is how
|
|
746
|
+
* {@link planPropertyColumn} planned it. Several are one composite key, and a
|
|
747
|
+
* composite key cannot be said column by column: two inline `PRIMARY KEY`
|
|
748
|
+
* clauses are two primary keys, which Postgres refuses. So each member stops
|
|
749
|
+
* being a key of its own, keeps NOT NULL (the constraint implies it, and every
|
|
750
|
+
* emitter spells it on the column so the three read the same), and the table
|
|
751
|
+
* gets one `PRIMARY KEY (a, b)` over the list returned here — the shape a
|
|
752
|
+
* junction's key has always had.
|
|
753
|
+
*
|
|
754
|
+
* A collection that declares no key gets an implicit `id TEXT PRIMARY KEY`,
|
|
755
|
+
* which is what `getPrimaryKeys` reads back.
|
|
756
|
+
*/
|
|
757
|
+
function planPrimaryKey(collection, columns) {
|
|
758
|
+
const keyNames = primaryKeyPropertyNames(collection);
|
|
759
|
+
if (keyNames.length > 1) return keyNames.map((name) => {
|
|
760
|
+
const column = columns.find((c) => c.source.propName === name && !c.columnOwnedByProperty);
|
|
761
|
+
if (!column) throw new Error(`Property "${name}" of collection "${describe(collection)}" is marked \`isId\`, and it puts no column on this table, so it cannot be part of the table's primary key. Mark the property that holds the column instead.`);
|
|
762
|
+
column.primaryKey = false;
|
|
763
|
+
column.nullable = false;
|
|
764
|
+
return column.column;
|
|
765
|
+
});
|
|
766
|
+
if (!columns.some((c) => c.primaryKey)) columns.unshift({
|
|
767
|
+
key: "id",
|
|
768
|
+
column: "id",
|
|
769
|
+
type: { kind: "text" },
|
|
770
|
+
nullable: false,
|
|
771
|
+
primaryKey: true,
|
|
772
|
+
unique: false,
|
|
773
|
+
default: {
|
|
774
|
+
kind: "sql",
|
|
775
|
+
expression: IMPLICIT_ID_DEFAULT
|
|
776
|
+
},
|
|
777
|
+
source: {
|
|
778
|
+
kind: "implicit-id",
|
|
779
|
+
slug: collection.slug
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
return columns.filter((c) => c.primaryKey).map((c) => c.column);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
1195
785
|
* One declared, non-linking property, as a column.
|
|
1196
786
|
*
|
|
1197
787
|
* Extracted from {@link planCollectionTable} the moment a second table needed
|
|
@@ -1274,12 +864,12 @@ function planPropertyColumn(propName, prop, ctx) {
|
|
|
1274
864
|
* column that does not exist: `CREATE POLICY` fails, RLS stays enabled, and the
|
|
1275
865
|
* collection denies every row.
|
|
1276
866
|
*/
|
|
1277
|
-
function applyTenantColumnEffects(collection, columns, schema, table) {
|
|
867
|
+
function applyTenantColumnEffects(collection, columns, primaryKey, schema, table) {
|
|
1278
868
|
const tenant = getTenantConfig(collection);
|
|
1279
869
|
if (!tenant) return [];
|
|
1280
870
|
const column = findTenantColumn(collection, columns, tenant.field);
|
|
1281
871
|
if (!column) throw new Error(`Collection "${describe(collection)}" declares \`tenant: { field: "${tenant.field}" }\`, and no property of that name reaches a column. \`tenant\` says what a column means; it does not create one. Declare the property, or name the one that holds the tenant id.`);
|
|
1282
|
-
if (column.
|
|
872
|
+
if (primaryKey[0] === column.column) return [];
|
|
1283
873
|
column.nullable = false;
|
|
1284
874
|
const withoutName = {
|
|
1285
875
|
schema,
|
|
@@ -1384,10 +974,11 @@ function planRelationColumn(propName, prop, collection, relations, schema, table
|
|
|
1384
974
|
const relationName = prop.relation?.relationName ?? propName;
|
|
1385
975
|
const legacyKey = legacyForeignKeyName(relationName);
|
|
1386
976
|
const derived = relation.localKey === generateForeignKeyName(relationName);
|
|
977
|
+
const via = linkLabel("relation", collection, propName);
|
|
1387
978
|
return {
|
|
1388
979
|
key: fkFieldKey,
|
|
1389
980
|
column: relation.localKey,
|
|
1390
|
-
type: primaryKeyPgType(target),
|
|
981
|
+
type: primaryKeyPgType(target, via),
|
|
1391
982
|
nullable: !required,
|
|
1392
983
|
primaryKey: false,
|
|
1393
984
|
unique: prop.validation?.unique === true,
|
|
@@ -1399,7 +990,7 @@ function planRelationColumn(propName, prop, collection, relations, schema, table
|
|
|
1399
990
|
column: relation.localKey,
|
|
1400
991
|
targetSchema: schemaOf(target),
|
|
1401
992
|
targetTable: bareTableName(getTableName(target)),
|
|
1402
|
-
targetColumn: getPrimaryKeyColumn(target),
|
|
993
|
+
targetColumn: getPrimaryKeyColumn(target, via),
|
|
1403
994
|
onDelete: relation.onDelete ?? defaultBelongsToOnDelete(required),
|
|
1404
995
|
onUpdate: relation.onUpdate
|
|
1405
996
|
}),
|
|
@@ -1415,10 +1006,11 @@ function planReferenceColumn(propName, prop, collection, resolveCollection, sche
|
|
|
1415
1006
|
const target = prop.path ? resolveCollection(prop.path) : void 0;
|
|
1416
1007
|
const column = resolveColumnName(propName, prop);
|
|
1417
1008
|
const required = prop.validation?.required === true;
|
|
1009
|
+
const via = linkLabel("reference", collection, propName);
|
|
1418
1010
|
return {
|
|
1419
1011
|
key: propName,
|
|
1420
1012
|
column,
|
|
1421
|
-
type: target ? primaryKeyPgType(target) : { kind: "text" },
|
|
1013
|
+
type: target ? primaryKeyPgType(target, via) : { kind: "text" },
|
|
1422
1014
|
nullable: !required,
|
|
1423
1015
|
primaryKey: false,
|
|
1424
1016
|
unique: prop.validation?.unique === true,
|
|
@@ -1428,7 +1020,7 @@ function planReferenceColumn(propName, prop, collection, resolveCollection, sche
|
|
|
1428
1020
|
column,
|
|
1429
1021
|
targetSchema: schemaOf(target),
|
|
1430
1022
|
targetTable: bareTableName(getTableName(target)),
|
|
1431
|
-
targetColumn: getPrimaryKeyColumn(target),
|
|
1023
|
+
targetColumn: getPrimaryKeyColumn(target, via),
|
|
1432
1024
|
onDelete: defaultBelongsToOnDelete(required)
|
|
1433
1025
|
}) : void 0,
|
|
1434
1026
|
source: {
|
|
@@ -1462,7 +1054,44 @@ var authColumnType = (sqlType) => {
|
|
|
1462
1054
|
if (!mapped) throw new Error(`\`auth-users-columns\` declares a column of type "${sqlType}", which the schema plan has no tag for. Add it to \`AUTH_COLUMN_TYPES\` in schema/plan/plan-schema.ts.`);
|
|
1463
1055
|
return mapped;
|
|
1464
1056
|
};
|
|
1465
|
-
|
|
1057
|
+
/**
|
|
1058
|
+
* Which declaring side a junction is planned from.
|
|
1059
|
+
*
|
|
1060
|
+
* With one side, that side — as it always was, so a junction only one end
|
|
1061
|
+
* declares keeps the key every database already has. With both ends declaring
|
|
1062
|
+
* it, the planner used to take whichever it reached first, and the two doors
|
|
1063
|
+
* reach them in different orders: `db push` loads files by name, boot walks the
|
|
1064
|
+
* bundle's array. The same junction was keyed `(a_id, b_id)` by one and
|
|
1065
|
+
* `(b_id, a_id)` by the other, and a push against a boot-built database rebuilt
|
|
1066
|
+
* the key. Now the side whose table sorts first, which is the order `db push`
|
|
1067
|
+
* already used wherever a file is named after its table.
|
|
1068
|
+
*/
|
|
1069
|
+
function canonicalJunctionSide(table, relation, source, spec) {
|
|
1070
|
+
if (!spec || spec.declaringSides.length < 2) return {
|
|
1071
|
+
relation,
|
|
1072
|
+
source
|
|
1073
|
+
};
|
|
1074
|
+
const chosen = [...spec.declaringSides].sort((a, b) => {
|
|
1075
|
+
const left = bareTableName(getTableName(a.collection));
|
|
1076
|
+
const right = bareTableName(getTableName(b.collection));
|
|
1077
|
+
if (left !== right) return left < right ? -1 : 1;
|
|
1078
|
+
return a.junctionColumn < b.junctionColumn ? -1 : a.junctionColumn > b.junctionColumn ? 1 : 0;
|
|
1079
|
+
})[0].collection;
|
|
1080
|
+
if (chosen === source) return {
|
|
1081
|
+
relation,
|
|
1082
|
+
source
|
|
1083
|
+
};
|
|
1084
|
+
const resolved = Object.values(resolveCollectionRelations(chosen)).find((r) => isManyToMany(r) && bareTableName(r.through.table) === table);
|
|
1085
|
+
return resolved ? {
|
|
1086
|
+
relation: resolved,
|
|
1087
|
+
source: chosen
|
|
1088
|
+
} : {
|
|
1089
|
+
relation,
|
|
1090
|
+
source
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
function planJunctionTable(tableName, reachedRelation, reachedSource, junctionSpecs, resolveCollection, triggerFunctionNeeded) {
|
|
1094
|
+
const { relation, source } = canonicalJunctionSide(bareTableName(tableName), reachedRelation, reachedSource, junctionSpecs.get(bareTableName(tableName)));
|
|
1466
1095
|
if (!isManyToMany(relation)) throw new Error(`Internal: junction table "${tableName}" was reached from a ${relation.kind} relation.`);
|
|
1467
1096
|
const table = bareTableName(tableName);
|
|
1468
1097
|
const schema = "public";
|
|
@@ -1475,10 +1104,11 @@ function planJunctionTable(tableName, relation, source, junctionSpecs, resolveCo
|
|
|
1475
1104
|
const legacy = legacyForeignKeyName(slug);
|
|
1476
1105
|
return column === generateForeignKeyName(slug) && legacy !== column ? legacy : void 0;
|
|
1477
1106
|
};
|
|
1107
|
+
const via = linkLabel("relation", source, relation.relationName);
|
|
1478
1108
|
const endpoint = (collection, column) => ({
|
|
1479
1109
|
key: column,
|
|
1480
1110
|
column,
|
|
1481
|
-
type: primaryKeyPgType(collection),
|
|
1111
|
+
type: primaryKeyPgType(collection, via),
|
|
1482
1112
|
nullable: false,
|
|
1483
1113
|
primaryKey: false,
|
|
1484
1114
|
unique: false,
|
|
@@ -1489,7 +1119,7 @@ function planJunctionTable(tableName, relation, source, junctionSpecs, resolveCo
|
|
|
1489
1119
|
column,
|
|
1490
1120
|
targetSchema: schemaOf(collection),
|
|
1491
1121
|
targetTable: bareTableName(getTableName(collection)),
|
|
1492
|
-
targetColumn: getPrimaryKeyColumn(collection),
|
|
1122
|
+
targetColumn: getPrimaryKeyColumn(collection, via),
|
|
1493
1123
|
onDelete
|
|
1494
1124
|
}),
|
|
1495
1125
|
source: { kind: "junction-key" }
|
|
@@ -1544,6 +1174,7 @@ function planRelations(collections, tableEntries) {
|
|
|
1544
1174
|
const { relation, source } = entry.junction;
|
|
1545
1175
|
if (!isManyToMany(relation)) continue;
|
|
1546
1176
|
const target = relation.target();
|
|
1177
|
+
const via = linkLabel("relation", source, relation.relationName);
|
|
1547
1178
|
const owningRelationName = sharedRelationName(relation, source);
|
|
1548
1179
|
let inverse;
|
|
1549
1180
|
try {
|
|
@@ -1557,7 +1188,7 @@ function planRelations(collections, tableEntries) {
|
|
|
1557
1188
|
targetVar: getTableVarName(getTableName(source)),
|
|
1558
1189
|
relationName: owningRelationName,
|
|
1559
1190
|
fields: [relation.through.sourceColumn],
|
|
1560
|
-
references: [getPrimaryKeyName(source)]
|
|
1191
|
+
references: [getPrimaryKeyName(source, via)]
|
|
1561
1192
|
});
|
|
1562
1193
|
if (inverseRelationName === owningRelationName) continue;
|
|
1563
1194
|
plans.push({
|
|
@@ -1567,7 +1198,7 @@ function planRelations(collections, tableEntries) {
|
|
|
1567
1198
|
targetVar: getTableVarName(getTableName(target)),
|
|
1568
1199
|
relationName: inverseRelationName,
|
|
1569
1200
|
fields: [relation.through.targetColumn],
|
|
1570
|
-
references: [getPrimaryKeyName(target)]
|
|
1201
|
+
references: [getPrimaryKeyName(target, via)]
|
|
1571
1202
|
});
|
|
1572
1203
|
continue;
|
|
1573
1204
|
}
|
|
@@ -1594,7 +1225,7 @@ function planRelations(collections, tableEntries) {
|
|
|
1594
1225
|
targetVar,
|
|
1595
1226
|
relationName,
|
|
1596
1227
|
fields: [fieldKeyForColumn(collection, relation.localKey)],
|
|
1597
|
-
references: [getPrimaryKeyName(target)]
|
|
1228
|
+
references: [getPrimaryKeyName(target, linkLabel("relation", collection, key))]
|
|
1598
1229
|
});
|
|
1599
1230
|
break;
|
|
1600
1231
|
case "hasOne":
|
|
@@ -1602,7 +1233,11 @@ function planRelations(collections, tableEntries) {
|
|
|
1602
1233
|
tableVar,
|
|
1603
1234
|
key,
|
|
1604
1235
|
kind: "one",
|
|
1605
|
-
targetVar
|
|
1236
|
+
targetVar,
|
|
1237
|
+
relationName,
|
|
1238
|
+
fields: [relation.sourceKey ? fieldKeyForColumn(collection, relation.sourceKey) : getPrimaryKeyName(collection, linkLabel("relation", collection, key))],
|
|
1239
|
+
references: [fieldKeyForColumn(target, relation.foreignKeyOnTarget)],
|
|
1240
|
+
nullable: true
|
|
1606
1241
|
});
|
|
1607
1242
|
break;
|
|
1608
1243
|
case "hasMany":
|
|
@@ -1650,7 +1285,7 @@ function planRelations(collections, tableEntries) {
|
|
|
1650
1285
|
targetVar: otherVar,
|
|
1651
1286
|
relationName,
|
|
1652
1287
|
fields: [fieldKey],
|
|
1653
|
-
references: [otherRel.sourceKey ? fieldKeyForColumn(other, otherRel.sourceKey) : getPrimaryKeyName(other)]
|
|
1288
|
+
references: [otherRel.sourceKey ? fieldKeyForColumn(other, otherRel.sourceKey) : getPrimaryKeyName(other, linkLabel("relation", other, otherRel.relationName))]
|
|
1654
1289
|
});
|
|
1655
1290
|
}
|
|
1656
1291
|
}
|
|
@@ -1658,6 +1293,6 @@ function planRelations(collections, tableEntries) {
|
|
|
1658
1293
|
return plans;
|
|
1659
1294
|
}
|
|
1660
1295
|
//#endregion
|
|
1661
|
-
export {
|
|
1296
|
+
export { triggerStatements as a, SET_UPDATED_AT_FN as i, planSchema as n, sharedRelationName as o, quoteSqlLiteral as r, columnPgType as t };
|
|
1662
1297
|
|
|
1663
|
-
//# sourceMappingURL=plan-schema-
|
|
1298
|
+
//# sourceMappingURL=plan-schema-DU9exq6C.js.map
|