@rebasepro/server-postgres 0.22.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.
Files changed (123) hide show
  1. package/dist/{BranchService-CucnFcSE.js → BranchService-BRt78gfa.js} +35 -18
  2. package/dist/BranchService-BRt78gfa.js.map +1 -0
  3. package/dist/PostgresBackendDriver.d.ts +116 -4
  4. package/dist/auth/services.d.ts +99 -22
  5. package/dist/{auth-users-columns-D2LBFrMH.js → auth-users-columns-C72EMoDJ.js} +17 -1
  6. package/dist/{auth-users-columns-D2LBFrMH.js.map → auth-users-columns-C72EMoDJ.js.map} +1 -1
  7. package/dist/backup/backup-cli.d.ts +22 -0
  8. package/dist/{backup-cli-DqBakiMO.js → backup-cli-DW5p9_zv.js} +17 -14
  9. package/dist/backup-cli-DW5p9_zv.js.map +1 -0
  10. package/dist/{backup-service-CaMOS76G.js → backup-service-EwcDVG-8.js} +7 -9
  11. package/dist/{backup-service-CaMOS76G.js.map → backup-service-EwcDVG-8.js.map} +1 -1
  12. package/dist/{cli-errors-Dka89exj.js → cli-errors-DsA-K9uP.js} +101 -1
  13. package/dist/cli-errors-DsA-K9uP.js.map +1 -0
  14. package/dist/cli-errors.d.ts +42 -0
  15. package/dist/cli-flags-BglvpjHv.js +138 -0
  16. package/dist/cli-flags-BglvpjHv.js.map +1 -0
  17. package/dist/cli-flags.d.ts +28 -0
  18. package/dist/cli-helpers.d.ts +18 -12
  19. package/dist/cli-scratch-database.d.ts +34 -0
  20. package/dist/cli.js +177 -287
  21. package/dist/cli.js.map +1 -1
  22. package/dist/{column-plan-helpers-CpILzHJS.js → column-plan-helpers-1-LQD0yI.js} +44 -38
  23. package/dist/column-plan-helpers-1-LQD0yI.js.map +1 -0
  24. package/dist/data-transformer.d.ts +0 -8
  25. package/dist/{doctor-CU9IogdL.js → doctor-C-sYWbmt.js} +228 -46
  26. package/dist/doctor-C-sYWbmt.js.map +1 -0
  27. package/dist/{ensure-collection-policies-Dzd-2S81.js → ensure-collection-policies-B1ureSIV.js} +73 -18
  28. package/dist/ensure-collection-policies-B1ureSIV.js.map +1 -0
  29. package/dist/{ensure-collection-tables-CpAgy51F.js → ensure-collection-tables-kkkHk8oo.js} +124 -37
  30. package/dist/ensure-collection-tables-kkkHk8oo.js.map +1 -0
  31. package/dist/{ensure-tables-Cr5B4UmH.js → ensure-tables-BmI_tRxc.js} +10 -3
  32. package/dist/ensure-tables-BmI_tRxc.js.map +1 -0
  33. package/dist/{generate-drizzle-schema-B537GIvz.js → generate-drizzle-schema-93M0lUxK.js} +2 -2
  34. package/dist/{generate-drizzle-schema-B537GIvz.js.map → generate-drizzle-schema-93M0lUxK.js.map} +1 -1
  35. package/dist/{generate-drizzle-schema-logic-BcMl7VSy.js → generate-drizzle-schema-logic-sSFDp6LR.js} +26 -8
  36. package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +1 -0
  37. package/dist/generate-postgres-ddl-logic-BJsLaVNX.js +152 -0
  38. package/dist/generate-postgres-ddl-logic-BJsLaVNX.js.map +1 -0
  39. package/dist/generated-sql.d.ts +28 -0
  40. package/dist/index.es.js +4768 -1117
  41. package/dist/index.es.js.map +1 -1
  42. package/dist/{introspect-db-logic-C6LQdTxj.js → introspect-db-logic-kCETE8TY.js} +532 -39
  43. package/dist/introspect-db-logic-kCETE8TY.js.map +1 -0
  44. package/dist/introspect-db-queries-C_Q5VgQw.js +317 -0
  45. package/dist/introspect-db-queries-C_Q5VgQw.js.map +1 -0
  46. package/dist/{plan-schema-CboAIwLN.js → plan-schema-DU9exq6C.js} +291 -529
  47. package/dist/plan-schema-DU9exq6C.js.map +1 -0
  48. package/dist/{policy-drift-xJfy9xG7.js → policy-drift-B-J2hhm0.js} +3 -3
  49. package/dist/policy-drift-B-J2hhm0.js.map +1 -0
  50. package/dist/{generate-postgres-ddl-logic-D7imhYV8.js → render-ddl-Ds2t_d9V.js} +11 -149
  51. package/dist/render-ddl-Ds2t_d9V.js.map +1 -0
  52. package/dist/{rls-bootstrap-sql-H3rCFi3F.js → rls-bootstrap-sql-_KNnjanK.js} +562 -35
  53. package/dist/rls-bootstrap-sql-_KNnjanK.js.map +1 -0
  54. package/dist/{rls-enforcement-C6Xk0lA6.js → rls-enforcement-CfXOJJaW.js} +10 -2
  55. package/dist/rls-enforcement-CfXOJJaW.js.map +1 -0
  56. package/dist/schema/atlas-argv.d.ts +15 -0
  57. package/dist/schema/auth-schema.d.ts +170 -0
  58. package/dist/schema/classify-change.d.ts +29 -1
  59. package/dist/schema/column-plan-helpers.d.ts +51 -20
  60. package/dist/schema/destructive-sql.d.ts +71 -1
  61. package/dist/schema/doctor-cli.js +4 -4
  62. package/dist/schema/doctor.d.ts +34 -1
  63. package/dist/schema/ensure-collection-policies.d.ts +22 -0
  64. package/dist/schema/generate-drizzle-schema.js +1 -1
  65. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  66. package/dist/schema/generate-postgres-ddl.js +1 -1
  67. package/dist/schema/generate-schema-commit.d.ts +12 -0
  68. package/dist/schema/introspect-db-logic.d.ts +31 -0
  69. package/dist/schema/introspect-db-queries.d.ts +1 -1
  70. package/dist/schema/introspect-db-search.d.ts +22 -0
  71. package/dist/schema/introspect-db-storage.d.ts +83 -0
  72. package/dist/schema/introspect-db.js +15 -314
  73. package/dist/schema/introspect-db.js.map +1 -1
  74. package/dist/schema/plan/diff-plan.d.ts +4 -3
  75. package/dist/schema/plan/plan-schema.d.ts +26 -11
  76. package/dist/schema/plan/render-ddl.d.ts +6 -0
  77. package/dist/schema/plan/types.d.ts +43 -12
  78. package/dist/search-column-BM-GV6vH.js +442 -0
  79. package/dist/search-column-BM-GV6vH.js.map +1 -0
  80. package/dist/security/policy-drift.d.ts +1 -1
  81. package/dist/security/rls-enforcement.d.ts +7 -0
  82. package/dist/services/BranchService.d.ts +22 -1
  83. package/dist/services/FetchService.d.ts +102 -32
  84. package/dist/services/PersistService.d.ts +41 -5
  85. package/dist/services/RelationService.d.ts +29 -0
  86. package/dist/services/RelationWriteService.d.ts +6 -0
  87. package/dist/services/cdc/CdcListener.d.ts +15 -5
  88. package/dist/services/cdc/identity-columns.d.ts +19 -0
  89. package/dist/services/cdc/trigger-cdc.d.ts +45 -7
  90. package/dist/services/channel-bus/PostgresChannelBus.d.ts +12 -7
  91. package/dist/services/channel-history.d.ts +17 -1
  92. package/dist/services/collection-helpers.d.ts +21 -0
  93. package/dist/services/dataService.d.ts +3 -20
  94. package/dist/services/field-op-sql.d.ts +71 -0
  95. package/dist/services/junction-writes.d.ts +19 -3
  96. package/dist/services/pg-notify-listener.d.ts +95 -6
  97. package/dist/services/read-field-access.d.ts +19 -0
  98. package/dist/services/realtimeService.d.ts +232 -44
  99. package/dist/services/row-pipeline.d.ts +5 -0
  100. package/dist/services/socket-liveness.d.ts +45 -0
  101. package/dist/services/soft-delete.d.ts +12 -2
  102. package/dist/services/sql-script.d.ts +71 -0
  103. package/dist/services/write-depth.d.ts +16 -0
  104. package/dist/services/write-transaction-scope.d.ts +42 -0
  105. package/dist/utils/drizzle-conditions.d.ts +49 -4
  106. package/dist/utils/sql-redaction.d.ts +21 -0
  107. package/dist/websocket.d.ts +57 -18
  108. package/package.json +9 -9
  109. package/dist/BranchService-CucnFcSE.js.map +0 -1
  110. package/dist/backup-cli-DqBakiMO.js.map +0 -1
  111. package/dist/cli-errors-Dka89exj.js.map +0 -1
  112. package/dist/column-plan-helpers-CpILzHJS.js.map +0 -1
  113. package/dist/doctor-CU9IogdL.js.map +0 -1
  114. package/dist/ensure-collection-policies-Dzd-2S81.js.map +0 -1
  115. package/dist/ensure-collection-tables-CpAgy51F.js.map +0 -1
  116. package/dist/ensure-tables-Cr5B4UmH.js.map +0 -1
  117. package/dist/generate-drizzle-schema-logic-BcMl7VSy.js.map +0 -1
  118. package/dist/generate-postgres-ddl-logic-D7imhYV8.js.map +0 -1
  119. package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
  120. package/dist/plan-schema-CboAIwLN.js.map +0 -1
  121. package/dist/policy-drift-xJfy9xG7.js.map +0 -1
  122. package/dist/rls-bootstrap-sql-H3rCFi3F.js.map +0 -1
  123. 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 getPrimaryKeyName, c as isIdProperty, i as enumLabelsOf, l as resolveColumnName, o as getPrimaryKeyProp, r as declaresEnumType, s as idColumnDefault, t as assertSinglePrimaryKey } from "./column-plan-helpers-CpILzHJS.js";
4
- import { i as isAuthCollection, n as authUsersColumnDefinition, r as authUsersColumnSql, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
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 { DEFAULT_FUZZY_THRESHOLD, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_MODE, DEFAULT_SEARCH_WEIGHT, REBASE_SCHEMA, hasForeignKeyOnTarget, isManyToMany, isPostgresCollectionConfig } from "@rebasepro/types";
8
- import { TENANT_INDEX_REASON, assertBeforeQueryIsPostgresOnly, fieldKeyForColumn, findRelation, getEffectiveSecurityRules, getEnumVarName, getInjectedSecurityRules, getJunctionCollectionConfig, getJunctionSecurityRules, getTableName, getTableVarName, getTenantConfig, policyToPostgres, relationalCollections, resolveCollectionRelations, resolveJunctionSpecs, resolveStringColumnLength, securityRuleToConditions } from "@rebasepro/common";
9
- import { generateForeignKeyName, getPolicyNamesForRule, legacyForeignKeyName, toPostgresIdentifier, toSnakeCase } from "@rebasepro/utils";
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, two `isId` properties, `isId: "cuid"`, an unknown
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
  *
@@ -622,14 +191,39 @@ var foreignKeyPlan = (args) => {
622
191
  * The type a column pointing at this collection's primary key must have — a
623
192
  * junction endpoint, a `belongsTo` foreign key, a `reference`.
624
193
  *
625
- * One function because it was three: the ladder was spelled inline in
626
- * `generatePostgresDdl`, again in `planJunctionTables` and a third time in the
627
- * Drizzle generator.
194
+ * The key's own column type, from the same functions that typed the key: a
195
+ * foreign key only holds between compatible types, so any second reading of
196
+ * the key is a constraint waiting to fail. This used to look at the id
197
+ * strategy alone, so `id: { isId: true, columnType: "uuid" }` was referenced by
198
+ * TEXT columns (42804 — the constraint was never created) and a `bigint` key by
199
+ * INTEGER ones, which overflow at 2^31.
200
+ *
201
+ * One exception: a `serial` key owns a sequence, and a column pointing at it is
202
+ * the plain integer of the same width — `SERIAL` there would give every
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`.
628
207
  */
629
- var primaryKeyPgType = (collection) => {
630
- const pk = getPrimaryKeyProp(collection);
631
- if (pk.type === "number") return { kind: "integer" };
632
- return pk.isUuid ? { kind: "uuid" } : { kind: "text" };
208
+ var primaryKeyPgType = (collection, via) => {
209
+ const { name, prop } = getPrimaryKeyProp(collection, via);
210
+ if (prop?.type === "number") return withoutSequence(numberType(name, prop, collection, true));
211
+ if (prop?.type === "string") return stringType(name, prop, collection);
212
+ return { kind: "text" };
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";
220
+ var withoutSequence = (type) => {
221
+ switch (type.kind) {
222
+ case "smallserial": return { kind: "smallint" };
223
+ case "serial": return { kind: "integer" };
224
+ case "bigserial": return { kind: "bigint" };
225
+ default: return type;
226
+ }
633
227
  };
634
228
  /** The `columnType`s a `number` property may name, and what each one is. */
635
229
  var NUMBER_COLUMN_TYPES = {
@@ -746,7 +340,7 @@ var columnPgType = (propName, prop, collection, resolveCollection) => {
746
340
  case "relation":
747
341
  case "reference": {
748
342
  const target = linkTarget(propName, prop, collection, resolveCollection);
749
- return target ? primaryKeyPgType(target) : { kind: "text" };
343
+ return target ? primaryKeyPgType(target, linkLabel(prop.type, collection, propName)) : { kind: "text" };
750
344
  }
751
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.`);
752
346
  }
@@ -766,6 +360,33 @@ var linkTarget = (propName, prop, collection, resolveCollection) => {
766
360
  return;
767
361
  }
768
362
  };
363
+ /** A value already written as a day, or as a time of day. */
364
+ var BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
365
+ var BARE_TIME = /^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
366
+ /**
367
+ * A date default in the shape its column's type accepts: a day for `date`, a
368
+ * time of day for `time`, an instant for a timestamp.
369
+ *
370
+ * Rendered as a full ISO timestamp whatever the column, a `time` column's
371
+ * DEFAULT was `'2024-03-01T15:30:00.000Z'`, which Postgres refuses — "invalid
372
+ * input syntax for type time" — so the schema would not apply. A value already
373
+ * written in the column's shape (`"2024-12-25"`, `"09:00"`) is kept as written;
374
+ * anything else is read as an instant and its UTC day or time of day taken, the
375
+ * same zone the timestamp rendering has always used. A string that is no date
376
+ * at all is no DEFAULT, rather than a schema that cannot be applied.
377
+ */
378
+ var dateDefaultLiteral = (value, type) => {
379
+ if (typeof value === "string") {
380
+ if (type.kind === "date" && BARE_DATE.test(value)) return value;
381
+ if (type.kind === "time" && BARE_TIME.test(value)) return value;
382
+ }
383
+ const instant = value instanceof Date ? value : typeof value === "string" ? new Date(value) : void 0;
384
+ if (!instant || Number.isNaN(instant.getTime())) return void 0;
385
+ const iso = instant.toISOString();
386
+ if (type.kind === "date") return iso.slice(0, 10);
387
+ if (type.kind === "time") return iso.slice(11, 23);
388
+ return typeof value === "string" ? value : iso;
389
+ };
769
390
  /**
770
391
  * A `defaultValue` as a database DEFAULT.
771
392
  *
@@ -800,11 +421,11 @@ var literalDefault = (prop, type) => {
800
421
  sql: value ? "TRUE" : "FALSE"
801
422
  } : void 0;
802
423
  case "date": {
803
- const iso = value instanceof Date ? Number.isNaN(value.getTime()) ? void 0 : value.toISOString() : typeof value === "string" ? value : void 0;
804
- return iso === void 0 ? void 0 : {
424
+ const literal = dateDefaultLiteral(value, type);
425
+ return literal === void 0 ? void 0 : {
805
426
  kind: "literal",
806
- value: iso,
807
- sql: quoteSqlLiteral(iso)
427
+ value: literal,
428
+ sql: quoteSqlLiteral(literal)
808
429
  };
809
430
  }
810
431
  case "map":
@@ -853,47 +474,26 @@ var columnDefault = (propName, prop, collection, type) => {
853
474
  /**
854
475
  * One security rule, compiled to the clauses a policy is made of.
855
476
  *
856
- * The desugaring (`access` / `ownerField` / `roles` / structured condition /
857
- * raw SQL → `PolicyExpression`) and the SQL compilation are `@rebasepro/common`'s,
858
- * which is what the client-side evaluator uses too — so the UI, the DDL and the
859
- * database agree about who can read a row. What lives here is only the shape:
860
- * which operations a rule expands to, which clauses each operation takes, and
861
- * 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.
862
482
  */
863
- var compileSecurityRule = (collection, rule, resolveCollection, injected, ruleKey = rule.name ?? "(unnamed)") => {
864
- const tableName = getTableName(collection);
865
- const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
866
- const policyNames = getPolicyNamesForRule(rule, tableName);
867
- const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
868
- return ops.map((operation, index) => {
869
- const needsUsing = operation !== "insert";
870
- const needsWithCheck = operation !== "select" && operation !== "delete";
871
- let using = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection, { resolveCollection }) : null;
872
- let withCheck = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection, { resolveCollection }) : null;
873
- if (!using && needsUsing) using = "false";
874
- if (!withCheck && needsWithCheck) withCheck = "false";
875
- return {
876
- name: policyNames[index],
877
- ruleKey,
878
- operation,
879
- mode: rule.mode ?? "permissive",
880
- roles: rule.pgRoles ? [...rule.pgRoles].sort() : ["public"],
881
- using,
882
- withCheck,
883
- injected
884
- };
885
- });
886
- };
483
+ var compileSecurityRule = (collection, rule, resolveCollection, injected, ruleKey = rule.name ?? "(unnamed)") => compileRulePolicies(collection, rule, { resolveCollection }).map((policy) => ({
484
+ ...policy,
485
+ ruleKey,
486
+ injected
487
+ }));
887
488
  function planSchema(allCollections, options = {}) {
888
489
  assertSearchIsPostgresOnly(allCollections);
889
490
  assertBeforeQueryIsPostgresOnly(allCollections);
890
491
  const collections = relationalCollections(allCollections);
891
- collections.forEach(assertSinglePrimaryKey);
892
492
  const resolveCollection = (slug) => collections.find((c) => c.slug === slug || getTableName(c) === slug);
893
493
  const declaredSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
894
494
  const schemas = Array.from(/* @__PURE__ */ new Set([REBASE_SCHEMA, ...declaredSchemas]));
895
495
  const enums = [];
896
- const seenEnums = /* @__PURE__ */ new Set();
496
+ const enumOwners = /* @__PURE__ */ new Map();
897
497
  /**
898
498
  * Every enum type one table's properties declare, appended once.
899
499
  *
@@ -911,10 +511,21 @@ function planSchema(allCollections, options = {}) {
911
511
  const labels = enumLabelsOf(propName, prop, collection);
912
512
  if (!declaresEnumType(prop)) continue;
913
513
  const schema = schemaOf(collection);
914
- const name = `${getTableName(collection)}_${resolveColumnName(propName, prop)}`;
915
- const qualified = `${schema}.${name}`;
916
- if (seenEnums.has(qualified)) continue;
917
- seenEnums.add(qualified);
514
+ const table = getTableName(collection);
515
+ const column = resolveColumnName(propName, prop);
516
+ const name = `${table}_${column}`;
517
+ const qualified = `${schema}.${toPostgresIdentifier(name)}`;
518
+ const owner = `property "${propName}" of collection "${describe(collection)}"`;
519
+ const claimed = enumOwners.get(qualified);
520
+ if (claimed) {
521
+ if (claimed.table === table && claimed.column === column) continue;
522
+ throw new Error(`The enum ${owner} and the enum ${claimed.owner} both derive the Postgres type "${schema}"."${toPostgresIdentifier(name)}" (an enum type is named <table>_<column>, and Postgres keeps the first 63 bytes). One type cannot hold both columns' values, and a type's name cannot change once a database holds it. Give one of the two columns another name with \`columnName\`, or its collection another \`table\` or \`schema\`.`);
523
+ }
524
+ enumOwners.set(qualified, {
525
+ table,
526
+ column,
527
+ owner
528
+ });
918
529
  enums.push({
919
530
  schema,
920
531
  name,
@@ -927,12 +538,14 @@ function planSchema(allCollections, options = {}) {
927
538
  };
928
539
  for (const collection of collections) collectEnumTypes(collection, collection.properties ?? {}, isPostgresCollectionConfig(collection) ? collection.schema : void 0);
929
540
  const tableEntries = /* @__PURE__ */ new Map();
541
+ const junctionShapes = /* @__PURE__ */ new Map();
930
542
  for (const collection of collections) {
931
543
  const tableName = getTableName(collection);
932
544
  if (tableName) tableEntries.set(tableName, { collection });
933
- for (const relation of Object.values(resolveCollectionRelations(collection))) {
545
+ for (const [relationKey, relation] of Object.entries(resolveCollectionRelations(collection))) {
934
546
  if (!isManyToMany(relation)) continue;
935
547
  const junctionTable = relation.through.table;
548
+ assertOneJunctionShape(junctionShapes, junctionTable, collection, relation.relationName ?? relationKey, relation);
936
549
  if (tableEntries.has(junctionTable)) continue;
937
550
  tableEntries.set(junctionTable, {
938
551
  collection: {
@@ -984,6 +597,38 @@ function planSchema(allCollections, options = {}) {
984
597
  options
985
598
  };
986
599
  }
600
+ /**
601
+ * Refuse two many-to-many relations that resolve to one junction table with
602
+ * different key columns.
603
+ *
604
+ * A default `through` is named after the two tables, so `posts.tags` and
605
+ * `posts.featuredTags` both resolve to `posts_tags` — one keyed by `tag_id`,
606
+ * the other by `featured_tag_id`. One table is created, from whichever came
607
+ * first, so the other relation read and wrote a column that was never there.
608
+ * The two sides of one link (`posts.tags` and `tags.posts`) share a junction
609
+ * legitimately, and they name the same two columns — so the comparison is of
610
+ * the (column, endpoint) pairs, in either order.
611
+ */
612
+ function assertOneJunctionShape(seen, junctionTable, collection, relationName, relation) {
613
+ if (!isManyToMany(relation)) return;
614
+ const target = relation.target();
615
+ if (!target) return;
616
+ const sourceTable = getTableName(collection);
617
+ const targetTable = getTableName(target);
618
+ const { sourceColumn, targetColumn } = relation.through;
619
+ const keys = JSON.stringify([[sourceColumn, sourceTable], [targetColumn, targetTable]].sort());
620
+ const claim = `the relation "${relationName}" of collection "${describe(collection)}" ("${sourceColumn}" → ${sourceTable}, "${targetColumn}" → ${targetTable})`;
621
+ const first = seen.get(junctionTable);
622
+ if (!first) {
623
+ seen.set(junctionTable, {
624
+ keys,
625
+ claim
626
+ });
627
+ return;
628
+ }
629
+ if (first.keys === keys) return;
630
+ throw new Error(`Two many-to-many relations resolve to the junction table "${junctionTable}" with different key columns: ${first.claim} and ${claim}. Only one table is created, so one of them would read and write columns that do not exist. Give the second its own junction — \`through: { table: "${sourceTable}_${toSnakeCase(relationName)}" }\` — or, if both are meant to be one link, declare it once.`);
631
+ }
987
632
  function planCollectionTable(collection, resolveCollection, triggerFunctionNeeded) {
988
633
  const schema = schemaOf(collection);
989
634
  const table = bareTableName(getTableName(collection));
@@ -1068,21 +713,11 @@ function planCollectionTable(collection, resolveCollection, triggerFunctionNeede
1068
713
  }
1069
714
  });
1070
715
  }
1071
- if (!columns.some((c) => c.primaryKey)) columns.unshift({
1072
- key: "id",
1073
- column: "id",
1074
- type: { kind: "text" },
1075
- nullable: false,
1076
- primaryKey: true,
1077
- unique: false,
1078
- source: {
1079
- kind: "implicit-id",
1080
- slug: collection.slug
1081
- }
1082
- });
716
+ const primaryKey = planPrimaryKey(collection, columns);
1083
717
  const injected = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
1084
718
  const policies = getEffectiveSecurityRules(collection).flatMap((rule, index) => compileSecurityRule(collection, rule, resolveCollection, Boolean(rule.name && injected.has(rule.name)), rule.name ?? `#${index}`));
1085
- const tenantIndexes = applyTenantColumnEffects(collection, columns, schema, table);
719
+ const tenantIndexes = applyTenantColumnEffects(collection, columns, primaryKey, schema, table);
720
+ settleDefaultOnDelete(collection, columns, relations);
1086
721
  return {
1087
722
  schema,
1088
723
  table,
@@ -1092,7 +727,7 @@ function planCollectionTable(collection, resolveCollection, triggerFunctionNeede
1092
727
  kind: "collection",
1093
728
  slug: collection.slug,
1094
729
  columns,
1095
- primaryKey: columns.filter((c) => c.primaryKey).map((c) => c.column),
730
+ primaryKey,
1096
731
  indexes: [...buildCollectionIndexSpecs(collection, resolveColumnName), ...tenantIndexes],
1097
732
  search,
1098
733
  vector: buildVectorIndexPlan(collection, resolveColumnName),
@@ -1103,6 +738,50 @@ function planCollectionTable(collection, resolveCollection, triggerFunctionNeede
1103
738
  };
1104
739
  }
1105
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
+ /**
1106
785
  * One declared, non-linking property, as a column.
1107
786
  *
1108
787
  * Extracted from {@link planCollectionTable} the moment a second table needed
@@ -1185,12 +864,12 @@ function planPropertyColumn(propName, prop, ctx) {
1185
864
  * column that does not exist: `CREATE POLICY` fails, RLS stays enabled, and the
1186
865
  * collection denies every row.
1187
866
  */
1188
- function applyTenantColumnEffects(collection, columns, schema, table) {
867
+ function applyTenantColumnEffects(collection, columns, primaryKey, schema, table) {
1189
868
  const tenant = getTenantConfig(collection);
1190
869
  if (!tenant) return [];
1191
870
  const column = findTenantColumn(collection, columns, tenant.field);
1192
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.`);
1193
- if (column.primaryKey) return [];
872
+ if (primaryKey[0] === column.column) return [];
1194
873
  column.nullable = false;
1195
874
  const withoutName = {
1196
875
  schema,
@@ -1222,18 +901,57 @@ function applyTenantColumnEffects(collection, columns, schema, table) {
1222
901
  * column — for the shape the documentation recommends.
1223
902
  */
1224
903
  function findTenantColumn(collection, columns, field) {
1225
- const direct = columns.find((c) => c.key === field);
904
+ const created = columns.filter((c) => !c.columnOwnedByProperty);
905
+ const direct = created.find((c) => c.key === field);
1226
906
  if (direct) return direct;
1227
907
  const prop = collection.properties?.[field];
1228
908
  if (prop?.type === "relation") {
1229
909
  const relation = findRelation(resolveCollectionRelations(collection), prop.relation?.relationName ?? field);
1230
- if (relation?.kind === "belongsTo" && relation.localKey) return columns.find((c) => c.column === relation.localKey);
910
+ if (relation?.kind === "belongsTo" && relation.localKey) return created.find((c) => c.column === relation.localKey);
1231
911
  return;
1232
912
  }
1233
913
  if (!prop) return void 0;
1234
914
  const columnName = resolveColumnName(field, prop);
1235
- return columns.find((c) => c.column === columnName);
915
+ return created.find((c) => c.column === columnName);
916
+ }
917
+ /**
918
+ * The `ON DELETE` a link gets when the author did not say, decided from the
919
+ * column as it will be created.
920
+ *
921
+ * {@link planRelationColumn} and {@link planReferenceColumn} see only their own
922
+ * property's `required`, and two later facts change the premise: `tenant` makes
923
+ * its column NOT NULL, and a declared property that owns a relation's column
924
+ * decides that column's nullability itself. Defaulting before either produced
925
+ * `NOT NULL … ON DELETE SET NULL` — a parent's delete that can only fail, and
926
+ * fails with 23502 naming the child's column rather than as the foreign-key
927
+ * refusal it is. An `onDelete` the author wrote is theirs and is kept.
928
+ */
929
+ function settleDefaultOnDelete(collection, columns, relations) {
930
+ for (const column of columns) {
931
+ const fk = column.foreignKey;
932
+ if (!fk || declaresOnDelete(collection, relations, column)) continue;
933
+ const onDelete = defaultBelongsToOnDelete(!(columns.find((c) => c.column === column.column && !c.columnOwnedByProperty) ?? column).nullable);
934
+ if (onDelete === fk.onDelete) continue;
935
+ column.foreignKey = foreignKeyPlan({
936
+ schema: fk.schema,
937
+ table: fk.table,
938
+ column: fk.column,
939
+ targetSchema: fk.targetSchema,
940
+ targetTable: fk.targetTable,
941
+ targetColumn: fk.targetColumn,
942
+ onDelete,
943
+ onUpdate: fk.onUpdate
944
+ });
945
+ }
1236
946
  }
947
+ /** Whether the author wrote this link's `onDelete`. A `reference` has none to write. */
948
+ var declaresOnDelete = (collection, relations, column) => {
949
+ const propName = column.source.propName;
950
+ if (column.source.kind !== "relation" || !propName) return false;
951
+ const prop = collection.properties?.[propName];
952
+ const relation = findRelation(relations, prop?.relation?.relationName ?? propName);
953
+ return relation?.kind === "belongsTo" && relation.onDelete !== void 0;
954
+ };
1237
955
  /**
1238
956
  * The column a `belongsTo` relation owns, or `undefined` when the relation puts
1239
957
  * no column on this table.
@@ -1256,10 +974,11 @@ function planRelationColumn(propName, prop, collection, relations, schema, table
1256
974
  const relationName = prop.relation?.relationName ?? propName;
1257
975
  const legacyKey = legacyForeignKeyName(relationName);
1258
976
  const derived = relation.localKey === generateForeignKeyName(relationName);
977
+ const via = linkLabel("relation", collection, propName);
1259
978
  return {
1260
979
  key: fkFieldKey,
1261
980
  column: relation.localKey,
1262
- type: primaryKeyPgType(target),
981
+ type: primaryKeyPgType(target, via),
1263
982
  nullable: !required,
1264
983
  primaryKey: false,
1265
984
  unique: prop.validation?.unique === true,
@@ -1271,7 +990,7 @@ function planRelationColumn(propName, prop, collection, relations, schema, table
1271
990
  column: relation.localKey,
1272
991
  targetSchema: schemaOf(target),
1273
992
  targetTable: bareTableName(getTableName(target)),
1274
- targetColumn: getPrimaryKeyName(target),
993
+ targetColumn: getPrimaryKeyColumn(target, via),
1275
994
  onDelete: relation.onDelete ?? defaultBelongsToOnDelete(required),
1276
995
  onUpdate: relation.onUpdate
1277
996
  }),
@@ -1287,10 +1006,11 @@ function planReferenceColumn(propName, prop, collection, resolveCollection, sche
1287
1006
  const target = prop.path ? resolveCollection(prop.path) : void 0;
1288
1007
  const column = resolveColumnName(propName, prop);
1289
1008
  const required = prop.validation?.required === true;
1009
+ const via = linkLabel("reference", collection, propName);
1290
1010
  return {
1291
1011
  key: propName,
1292
1012
  column,
1293
- type: target ? primaryKeyPgType(target) : { kind: "text" },
1013
+ type: target ? primaryKeyPgType(target, via) : { kind: "text" },
1294
1014
  nullable: !required,
1295
1015
  primaryKey: false,
1296
1016
  unique: prop.validation?.unique === true,
@@ -1300,7 +1020,7 @@ function planReferenceColumn(propName, prop, collection, resolveCollection, sche
1300
1020
  column,
1301
1021
  targetSchema: schemaOf(target),
1302
1022
  targetTable: bareTableName(getTableName(target)),
1303
- targetColumn: getPrimaryKeyName(target),
1023
+ targetColumn: getPrimaryKeyColumn(target, via),
1304
1024
  onDelete: defaultBelongsToOnDelete(required)
1305
1025
  }) : void 0,
1306
1026
  source: {
@@ -1334,7 +1054,44 @@ var authColumnType = (sqlType) => {
1334
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.`);
1335
1055
  return mapped;
1336
1056
  };
1337
- function planJunctionTable(tableName, relation, source, junctionSpecs, resolveCollection, triggerFunctionNeeded) {
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)));
1338
1095
  if (!isManyToMany(relation)) throw new Error(`Internal: junction table "${tableName}" was reached from a ${relation.kind} relation.`);
1339
1096
  const table = bareTableName(tableName);
1340
1097
  const schema = "public";
@@ -1347,10 +1104,11 @@ function planJunctionTable(tableName, relation, source, junctionSpecs, resolveCo
1347
1104
  const legacy = legacyForeignKeyName(slug);
1348
1105
  return column === generateForeignKeyName(slug) && legacy !== column ? legacy : void 0;
1349
1106
  };
1107
+ const via = linkLabel("relation", source, relation.relationName);
1350
1108
  const endpoint = (collection, column) => ({
1351
1109
  key: column,
1352
1110
  column,
1353
- type: primaryKeyPgType(collection),
1111
+ type: primaryKeyPgType(collection, via),
1354
1112
  nullable: false,
1355
1113
  primaryKey: false,
1356
1114
  unique: false,
@@ -1361,7 +1119,7 @@ function planJunctionTable(tableName, relation, source, junctionSpecs, resolveCo
1361
1119
  column,
1362
1120
  targetSchema: schemaOf(collection),
1363
1121
  targetTable: bareTableName(getTableName(collection)),
1364
- targetColumn: getPrimaryKeyName(collection),
1122
+ targetColumn: getPrimaryKeyColumn(collection, via),
1365
1123
  onDelete
1366
1124
  }),
1367
1125
  source: { kind: "junction-key" }
@@ -1416,14 +1174,13 @@ function planRelations(collections, tableEntries) {
1416
1174
  const { relation, source } = entry.junction;
1417
1175
  if (!isManyToMany(relation)) continue;
1418
1176
  const target = relation.target();
1419
- const owningRelationName = relation.relationName ?? toSnakeCase(getTableName(target));
1420
- let inverseRelationName;
1177
+ const via = linkLabel("relation", source, relation.relationName);
1178
+ const owningRelationName = sharedRelationName(relation, source);
1179
+ let inverse;
1421
1180
  try {
1422
- for (const targetRel of Object.values(resolveCollectionRelations(target))) if (targetRel.kind !== "belongsTo" && targetRel.cardinality === "many" && targetRel.relationName === owningRelationName) {
1423
- inverseRelationName = targetRel.relationName;
1424
- break;
1425
- }
1181
+ inverse = Object.values(resolveCollectionRelations(target)).find((r) => r.kind === "manyToMany" && bareTableName(r.through.table) === bareTableName(tableName));
1426
1182
  } catch {}
1183
+ const inverseRelationName = inverse ? sharedRelationName(inverse, target) : `${tableName}_${relation.through.targetColumn}`;
1427
1184
  plans.push({
1428
1185
  tableVar,
1429
1186
  key: relation.through.sourceColumn,
@@ -1431,16 +1188,17 @@ function planRelations(collections, tableEntries) {
1431
1188
  targetVar: getTableVarName(getTableName(source)),
1432
1189
  relationName: owningRelationName,
1433
1190
  fields: [relation.through.sourceColumn],
1434
- references: [getPrimaryKeyName(source)]
1191
+ references: [getPrimaryKeyName(source, via)]
1435
1192
  });
1193
+ if (inverseRelationName === owningRelationName) continue;
1436
1194
  plans.push({
1437
1195
  tableVar,
1438
1196
  key: relation.through.targetColumn,
1439
1197
  kind: "one",
1440
1198
  targetVar: getTableVarName(getTableName(target)),
1441
- relationName: inverseRelationName ?? `${tableName}_${relation.through.targetColumn}`,
1199
+ relationName: inverseRelationName,
1442
1200
  fields: [relation.through.targetColumn],
1443
- references: [getPrimaryKeyName(target)]
1201
+ references: [getPrimaryKeyName(target, via)]
1444
1202
  });
1445
1203
  continue;
1446
1204
  }
@@ -1467,7 +1225,7 @@ function planRelations(collections, tableEntries) {
1467
1225
  targetVar,
1468
1226
  relationName,
1469
1227
  fields: [fieldKeyForColumn(collection, relation.localKey)],
1470
- references: [getPrimaryKeyName(target)]
1228
+ references: [getPrimaryKeyName(target, linkLabel("relation", collection, key))]
1471
1229
  });
1472
1230
  break;
1473
1231
  case "hasOne":
@@ -1475,7 +1233,11 @@ function planRelations(collections, tableEntries) {
1475
1233
  tableVar,
1476
1234
  key,
1477
1235
  kind: "one",
1478
- 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
1479
1241
  });
1480
1242
  break;
1481
1243
  case "hasMany":
@@ -1523,7 +1285,7 @@ function planRelations(collections, tableEntries) {
1523
1285
  targetVar: otherVar,
1524
1286
  relationName,
1525
1287
  fields: [fieldKey],
1526
- references: [otherRel.sourceKey ? fieldKeyForColumn(other, otherRel.sourceKey) : getPrimaryKeyName(other)]
1288
+ references: [otherRel.sourceKey ? fieldKeyForColumn(other, otherRel.sourceKey) : getPrimaryKeyName(other, linkLabel("relation", other, otherRel.relationName))]
1527
1289
  });
1528
1290
  }
1529
1291
  }
@@ -1531,6 +1293,6 @@ function planRelations(collections, tableEntries) {
1531
1293
  return plans;
1532
1294
  }
1533
1295
  //#endregion
1534
- export { searchHelperFunctions as _, triggerStatements as a, searchStampGuards as b, SEARCH_TEXT_FN as c, fuzzyColumnDefinition as d, hiddenColumnsOption as f, searchExtensionStatements as g, searchColumnStamps as h, SET_UPDATED_AT_FN as i, SEARCH_UNACCENT_FN as l, searchColumnNames as m, planSchema as n, sharedRelationName as o, searchColumnDefinition as p, quoteSqlLiteral as r, SEARCH_STAMP_PREFIX as s, columnPgType as t, buildSearchColumnSpec as u, searchIndexNames as v, visibleColumnProjection as x, searchIndexStatements as y };
1296
+ export { triggerStatements as a, SET_UPDATED_AT_FN as i, planSchema as n, sharedRelationName as o, quoteSqlLiteral as r, columnPgType as t };
1535
1297
 
1536
- //# sourceMappingURL=plan-schema-CboAIwLN.js.map
1298
+ //# sourceMappingURL=plan-schema-DU9exq6C.js.map