@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g06dbe5b

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 (120) hide show
  1. package/dist/PostgresBackendDriver.d.ts +49 -2
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +40 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-CBEOeYqa.js} +1118 -220
  5. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  6. package/dist/backup/backup-service.d.ts +10 -1
  7. package/dist/backup/pg-tools.d.ts +47 -0
  8. package/dist/backup-service-Bww-Lg0s.js +8999 -0
  9. package/dist/backup-service-Bww-Lg0s.js.map +1 -0
  10. package/dist/cli-helpers.d.ts +96 -1
  11. package/dist/connection-BuZ97wsr.js +250 -0
  12. package/dist/connection-BuZ97wsr.js.map +1 -0
  13. package/dist/connection.d.ts +42 -0
  14. package/dist/data-transformer.d.ts +7 -2
  15. package/dist/data_driver-ULAyJEi9.js +193 -0
  16. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  17. package/dist/ensure-collection-policies-B_JMGa5K.js +57 -0
  18. package/dist/ensure-collection-policies-B_JMGa5K.js.map +1 -0
  19. package/dist/ensure-collection-tables-DzeTEvMv.js +800 -0
  20. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  21. package/dist/index.es.js +2460 -10233
  22. package/dist/index.es.js.map +1 -1
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  24. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  25. package/dist/schema/auth-schema.d.ts +185 -144
  26. package/dist/schema/auth-users-columns.d.ts +97 -0
  27. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  28. package/dist/schema/doctor.d.ts +23 -25
  29. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  30. package/dist/schema/ensure-collection-tables.d.ts +99 -3
  31. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  32. package/dist/schema/generate-postgres-ddl-logic.d.ts +185 -3
  33. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  34. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  35. package/dist/schema/introspect-db-logic.d.ts +94 -5
  36. package/dist/schema/introspect-db-queries.d.ts +119 -0
  37. package/dist/schema/introspect-db-structure.d.ts +263 -0
  38. package/dist/schema/introspect-db-types.d.ts +11 -0
  39. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  40. package/dist/schema/search-column.d.ts +248 -0
  41. package/dist/security/rls-enforcement.d.ts +61 -5
  42. package/dist/services/FetchService.d.ts +38 -8
  43. package/dist/services/RelationService.d.ts +54 -1
  44. package/dist/services/channel-bus/index.d.ts +1 -7
  45. package/dist/services/collection-helpers.d.ts +62 -2
  46. package/dist/services/dataService.d.ts +8 -1
  47. package/dist/services/realtimeService.d.ts +131 -21
  48. package/dist/services/row-pipeline.d.ts +1 -1
  49. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  50. package/dist/src-C_wvdMnl.js.map +1 -0
  51. package/dist/utils/connection-string.d.ts +29 -0
  52. package/dist/utils/drizzle-conditions.d.ts +129 -6
  53. package/dist/utils/pg-error-utils.d.ts +35 -0
  54. package/dist/websocket-D1qbmLZ2.js +587 -0
  55. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  56. package/package.json +17 -16
  57. package/src/PostgresAdapter.ts +21 -2
  58. package/src/PostgresBackendDriver.ts +176 -6
  59. package/src/PostgresBootstrapper.ts +245 -37
  60. package/src/auth/ensure-tables.ts +357 -81
  61. package/src/auth/services.ts +93 -7
  62. package/src/backup/backup-cli.ts +41 -2
  63. package/src/backup/backup-service.ts +38 -5
  64. package/src/backup/pg-tools.ts +96 -3
  65. package/src/cli-helpers.ts +199 -10
  66. package/src/cli.ts +276 -56
  67. package/src/collections/validate-relations.ts +136 -14
  68. package/src/connection.ts +73 -0
  69. package/src/data-transformer.ts +129 -20
  70. package/src/databasePoolManager.ts +5 -2
  71. package/src/history/ensure-history-table.ts +7 -0
  72. package/src/schema/auth-schema.ts +46 -19
  73. package/src/schema/auth-users-columns.ts +131 -0
  74. package/src/schema/doctor-cli.ts +12 -63
  75. package/src/schema/doctor-policy-checks.ts +105 -0
  76. package/src/schema/doctor.ts +135 -76
  77. package/src/schema/ensure-collection-policies.ts +105 -0
  78. package/src/schema/ensure-collection-tables.test.ts +105 -9
  79. package/src/schema/ensure-collection-tables.ts +572 -42
  80. package/src/schema/generate-drizzle-schema-logic.ts +169 -43
  81. package/src/schema/generate-postgres-ddl-logic.ts +670 -29
  82. package/src/schema/generate-postgres-ddl.ts +25 -2
  83. package/src/schema/generated-schema-staleness.ts +169 -0
  84. package/src/schema/introspect-db-constraints.ts +385 -0
  85. package/src/schema/introspect-db-inference.ts +18 -8
  86. package/src/schema/introspect-db-logic.ts +446 -100
  87. package/src/schema/introspect-db-queries.ts +326 -0
  88. package/src/schema/introspect-db-structure.ts +670 -0
  89. package/src/schema/introspect-db-types.ts +56 -0
  90. package/src/schema/introspect-db.ts +37 -80
  91. package/src/schema/introspect-runtime.test.ts +56 -8
  92. package/src/schema/introspect-runtime.ts +31 -9
  93. package/src/schema/non-sql-collections.test.ts +131 -0
  94. package/src/schema/rls-bootstrap-sql.ts +288 -0
  95. package/src/schema/search-column.ts +643 -0
  96. package/src/security/anonymous-grants.test.ts +4 -2
  97. package/src/security/policy-drift.test.ts +11 -3
  98. package/src/security/rls-enforcement.ts +149 -6
  99. package/src/services/BranchService.ts +71 -28
  100. package/src/services/FetchService.ts +265 -122
  101. package/src/services/PersistService.ts +57 -7
  102. package/src/services/RelationService.ts +358 -114
  103. package/src/services/channel-bus/index.ts +0 -9
  104. package/src/services/channel-history.ts +14 -0
  105. package/src/services/channel-presence.ts +13 -0
  106. package/src/services/collection-helpers.ts +123 -4
  107. package/src/services/dataService.ts +8 -1
  108. package/src/services/realtimeService.ts +346 -81
  109. package/src/services/row-pipeline.ts +1 -1
  110. package/src/utils/connection-string.ts +58 -0
  111. package/src/utils/drizzle-conditions.ts +396 -29
  112. package/src/utils/pg-error-utils.ts +73 -3
  113. package/src/websocket.ts +130 -24
  114. package/dist/chunk-DSJWtz9O.js +0 -40
  115. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  116. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  117. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  118. package/dist/src-BbFOPJ1S.js.map +0 -1
  119. package/dist/src-Zqwaw3P5.js.map +0 -1
  120. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -1,6 +1,21 @@
1
1
  import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo } from "@rebasepro/types";
2
- import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
3
- import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
2
+ import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
3
+ import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName, toPostgresIdentifier } from "@rebasepro/utils";
4
+ import { AUTH_USERS_COLUMNS, authUsersColumnDefinition, authUsersColumnSql, isAuthCollection } from "./auth-users-columns";
5
+ import {
6
+ buildSearchColumnSpec,
7
+ searchColumnDefinition,
8
+ fuzzyColumnDefinition,
9
+ searchIndexStatements,
10
+ searchHelperFunctions,
11
+ searchExtensionStatements,
12
+ searchColumnNames,
13
+ searchColumnStamps,
14
+ searchStampGuards,
15
+ searchIndexNames,
16
+ type SearchColumnSpec
17
+ } from "./search-column";
18
+ import { REBASE_SCHEMA } from "@rebasepro/types";
4
19
 
5
20
  // --- Helper Functions ---
6
21
 
@@ -11,7 +26,7 @@ export const resolveColumnName = (propName: string, prop?: Property | null): str
11
26
  return toSnakeCase(propName);
12
27
  };
13
28
 
14
- const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
29
+ export const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
15
30
  if (collection.properties) {
16
31
  const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as unknown as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
17
32
  if (idPropEntry) {
@@ -28,14 +43,18 @@ const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type:
28
43
  return { name: "id", type: "string", isUuid: isUuid ?? false };
29
44
  };
30
45
 
31
- const isNumericId = (collection: CollectionConfig): boolean => {
46
+ export const isNumericId = (collection: CollectionConfig): boolean => {
32
47
  return getPrimaryKeyProp(collection).type === "number";
33
48
  };
34
49
 
35
- const getPrimaryKeyName = (collection: CollectionConfig): string => {
50
+ export const getPrimaryKeyName = (collection: CollectionConfig): string => {
36
51
  return getPrimaryKeyProp(collection).name;
37
52
  };
38
53
 
54
+ /** The column type a junction holds for one endpoint's primary key. */
55
+ const junctionKeyType = (collection: CollectionConfig): string =>
56
+ isNumericId(collection) ? "INTEGER" : (getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT");
57
+
39
58
  export const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
40
59
  if ("isId" in prop && Boolean(prop.isId)) return true;
41
60
  const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
@@ -45,7 +64,28 @@ export const isIdProperty = (propName: string, prop: Property, collection: Colle
45
64
 
46
65
  type ResolveCollection = (slug: string) => CollectionConfig | undefined;
47
66
 
48
- const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string => {
67
+ /**
68
+ * Render statements produced by {@link generatePolicyStatements} back into the
69
+ * exact string the DDL/policies files have always carried: each statement on
70
+ * its own line, terminated by a newline. Keeping the string form derived from
71
+ * the statement array means the two can never drift — the boot-time applier and
72
+ * the generated `policies.sql` emit the same SQL, from the same source.
73
+ */
74
+ const statementsToDdl = (statements: string[]): string => statements.map(s => `${s}\n`).join("");
75
+
76
+ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string =>
77
+ statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
78
+
79
+ /**
80
+ * The individual SQL statements a single security rule compiles to: a
81
+ * `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
82
+ * statement (terminated by `;`, no trailing newline).
83
+ *
84
+ * This is the primitive the boot-time RLS applier runs one statement at a time
85
+ * (the runtime's DB handle speaks the extended query protocol, which forbids
86
+ * multiple commands in one execute), while `db push` writes the joined string.
87
+ */
88
+ export const generatePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string[] => {
49
89
  const tableName = getTableName(collection);
50
90
  const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
51
91
  ? rule.operations
@@ -53,12 +93,12 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
53
93
 
54
94
  const policyNames = getPolicyNamesForRule(rule, tableName);
55
95
 
56
- return ops.map((op, opIdx) => {
57
- return generateSinglePolicyDdl(collection, rule, op, policyNames[opIdx], resolveCollection);
58
- }).join("");
96
+ return ops.flatMap((op, opIdx) => {
97
+ return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
98
+ });
59
99
  };
60
100
 
61
- const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string => {
101
+ const generateSinglePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string[] => {
62
102
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
63
103
  const tableName = getTableName(collection);
64
104
  const mode = (rule.mode ?? "permissive").toUpperCase();
@@ -83,13 +123,24 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
83
123
  withCheckClause = "false";
84
124
  }
85
125
 
86
- let ddl = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";\n`;
87
- ddl += `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
88
- if (usingClause) ddl += ` USING (${usingClause})`;
89
- if (withCheckClause) ddl += ` WITH CHECK (${withCheckClause})`;
90
- return `${ddl};\n`;
126
+ const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
127
+ let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
128
+ if (usingClause) create += ` USING (${usingClause})`;
129
+ if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
130
+ create += ";";
131
+ return [drop, create];
91
132
  };
92
133
 
134
+ /**
135
+ * Single-quote escaping for a SQL string literal (PostgreSQL doubles the
136
+ * quote). Enum labels come straight from user-authored collection config, so a
137
+ * label like `it's` closes the literal early and the whole generated file stops
138
+ * parsing at the `CREATE TYPE`. Lives here rather than next to its other caller
139
+ * because ensure-collection-tables already imports from this module — the
140
+ * reverse would be a cycle.
141
+ */
142
+ export const quoteSqlLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
143
+
93
144
  export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
94
145
  switch (prop.type) {
95
146
  case "string": {
@@ -103,11 +154,15 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
103
154
  if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
104
155
  return "UUID";
105
156
  }
157
+ // Width comes from `validation.max` when the property states one.
158
+ // It used to be a hardcoded 255 here and *absent* on the Drizzle
159
+ // path, so the same property produced a bounded column down one
160
+ // generator and an unbounded one down the other.
106
161
  if (stringProp.columnType === "char") {
107
- return "CHAR(255)";
162
+ return `CHAR(${resolveStringColumnLength(stringProp)})`;
108
163
  }
109
164
  if (stringProp.columnType === "varchar") {
110
- return "VARCHAR(255)";
165
+ return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
111
166
  }
112
167
  // `text` is the default. The two generators disagreed here before:
113
168
  // this one emitted VARCHAR(255) while the drizzle path emitted a bare
@@ -140,6 +195,11 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
140
195
  const mapProp = prop as MapProperty;
141
196
  return mapProp.columnType === "json" ? "JSON" : "JSONB";
142
197
  }
198
+ // `{ latitude, longitude }` — a document, like `map`. It used to fall to
199
+ // the `TEXT` default below while the Drizzle generator emitted no column
200
+ // at all, which is the divergence that made the type unpersistable.
201
+ case "geopoint":
202
+ return "JSONB";
143
203
  case "array": {
144
204
  const arrayProp = prop as ArrayProperty;
145
205
  let colType = arrayProp.columnType;
@@ -191,19 +251,162 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
191
251
  return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "TEXT");
192
252
  }
193
253
  default:
194
- return "TEXT";
254
+ // A silent `TEXT` here is how the two generators drifted apart: the
255
+ // database got a column for `geopoint` and the Drizzle table did
256
+ // not, so the type looked supported and persisted nothing. Every
257
+ // member of `DataType` is handled above; anything else is a
258
+ // generator that has not been taught the type yet, and it should
259
+ // say so rather than invent a column.
260
+ throw new Error(
261
+ `No Postgres column type for property '${propName}' of type ` +
262
+ `'${(prop as Property).type}' in collection '${collection.slug}'. ` +
263
+ "Add a case to `getSqlColumnType` (and to `getDrizzleColumn`, which must agree)."
264
+ );
265
+ }
266
+ };
267
+
268
+ /**
269
+ * Everything a `search` block needs, as a file Rebase applies itself.
270
+ *
271
+ * Search is the one part of the schema Atlas does not own. Two independent
272
+ * reasons, and either alone would be enough:
273
+ *
274
+ * 1. Its free tier refuses to *parse* a desired-state file that so much as
275
+ * contains a function — "functions and procedures are available to
276
+ * logged-in users only". A generated `tsvector` column cannot avoid one:
277
+ * `unaccent` is STABLE and jsonb flattening needs a set-returning function,
278
+ * so both have to be wrapped in an IMMUTABLE helper to be legal in a
279
+ * generated column at all.
280
+ * 2. Even with the file accepted, Atlas *wipes* the dev database it diffs
281
+ * against, so a helper seeded there beforehand is gone by the time the plan
282
+ * is analysed. There is no hook to reinstate it.
283
+ *
284
+ * So the column, its index and its helpers are excluded from Atlas's view
285
+ * (`searchExcludePatterns`) and applied from here — the same arrangement the
286
+ * RLS policies already use, and for the same underlying reason.
287
+ *
288
+ * Ordered as it must run: extensions, then helpers, then the column whose
289
+ * expression calls them, then the index over that column. Every statement is
290
+ * `IF NOT EXISTS` / `OR REPLACE`, because this is replayed on every push and
291
+ * appended to migrations that run against databases at any stage of their
292
+ * life. Empty when nothing opted in — the caller writes no file then.
293
+ *
294
+ * The leading half — extensions and helpers, without the table-shaped
295
+ * statements — is available on its own as {@link searchPrerequisiteStatements},
296
+ * for the dev database Atlas analyses plans against. That database has none of
297
+ * the project's tables, so it wants the functions and nothing else.
298
+ */
299
+ export const searchPrerequisiteStatements = (allCollections: CollectionConfig[]): string[] => {
300
+ const specs = relationalCollections(allCollections)
301
+ .map(c => buildSearchColumnSpec(c))
302
+ .filter((s): s is SearchColumnSpec => s !== undefined);
303
+ if (specs.length === 0) return [];
304
+ return [
305
+ ...new Set(specs.flatMap(searchExtensionStatements)),
306
+ ...new Set(specs.flatMap(searchHelperFunctions))
307
+ ];
308
+ };
309
+
310
+ export const generatePostgresSearchDdl = (allCollections: CollectionConfig[]): string => {
311
+ const collections = relationalCollections(allCollections);
312
+ const specs = collections
313
+ .map(c => buildSearchColumnSpec(c))
314
+ .filter((s): s is SearchColumnSpec => s !== undefined);
315
+ if (specs.length === 0) return "";
316
+
317
+ const extensions = Array.from(new Set(specs.flatMap(searchExtensionStatements)));
318
+ const helpers = Array.from(new Set(specs.flatMap(searchHelperFunctions)));
319
+
320
+ let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n";
321
+ ddl += "--\n";
322
+ ddl += "-- Full-text search for the collections declaring a `search` block.\n";
323
+ ddl += "-- Applied by Rebase, not by Atlas — see generatePostgresSearchDdl.\n\n";
324
+
325
+ extensions.forEach(s => { ddl += `${s}\n`; });
326
+ if (extensions.length > 0) ddl += "\n";
327
+ helpers.forEach(s => { ddl += `${s}\n\n`; });
328
+
329
+ for (const collection of collections) {
330
+ const spec = buildSearchColumnSpec(collection);
331
+ if (!spec) continue;
332
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
333
+ const table = `"${schema}"."${getTableName(collection)}"`;
334
+
335
+ // ADD COLUMN IF NOT EXISTS rather than the inline definition the
336
+ // CREATE TABLE would use: by the time this runs the table exists,
337
+ // whether Atlas just created it or it has been live for a year.
338
+ // Refuse before altering anything if the column in the database was
339
+ // generated from a different `search` block: `ADD COLUMN IF NOT EXISTS`
340
+ // is a no-op against an existing column, so without this the file would
341
+ // report success and leave the old expression in place — and then stamp
342
+ // it as current.
343
+ searchStampGuards(spec).forEach(s => { ddl += `${s}\n`; });
344
+ ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${searchColumnDefinition(spec)};\n`;
345
+ const fuzzyDef = fuzzyColumnDefinition(spec);
346
+ if (fuzzyDef) ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${fuzzyDef};\n`;
347
+ // The fingerprint of the expression each column was built from — the
348
+ // only record of *which* block a generated column came from, and what
349
+ // the guard above and the boot-time ensure both compare against.
350
+ searchColumnStamps(spec).forEach(s => { ddl += `${s.sql}\n`; });
351
+ searchIndexStatements(spec).forEach(s => { ddl += `${s}\n`; });
352
+ ddl += "\n";
195
353
  }
354
+
355
+ return ddl;
356
+ };
357
+
358
+ /**
359
+ * Glob patterns telling Atlas to leave the search column and its index alone.
360
+ *
361
+ * Without these, a desired state that omits search reads to Atlas as an
362
+ * instruction to drop the column — taking the index and the whole search
363
+ * feature with it on the next push.
364
+ *
365
+ * Fully qualified, `schema.table.object`, matching the include list. The
366
+ * two-part form is what Atlas wants when the connection URL scopes it to one
367
+ * schema and is *silently ignored* otherwise: it reads `posts.search_vector`
368
+ * as a table named `search_vector` in a schema named `posts`, matches nothing,
369
+ * and reports no error for the pattern that never fired.
370
+ */
371
+ export const searchExcludePatterns = (allCollections: CollectionConfig[]): string[] => {
372
+ const patterns: string[] = [];
373
+ for (const collection of relationalCollections(allCollections)) {
374
+ const spec = buildSearchColumnSpec(collection);
375
+ if (!spec) continue;
376
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
377
+ const table = getTableName(collection);
378
+ for (const name of searchColumnNames(collection)) {
379
+ patterns.push(`${schema}.${table}.${name}`);
380
+ }
381
+ for (const index of searchIndexNames(spec)) {
382
+ patterns.push(`${schema}.${table}.${index}`);
383
+ }
384
+ }
385
+ return patterns;
196
386
  };
197
387
 
198
388
  export const generatePostgresDdl = async (
199
- collections: CollectionConfig[],
200
- options: { includePolicies?: boolean } = { includePolicies: true }
389
+ allCollections: CollectionConfig[],
390
+ options: { includePolicies?: boolean; includeSearch?: boolean } = {
391
+ includePolicies: true,
392
+ includeSearch: true
393
+ }
201
394
  ): Promise<string> => {
395
+ // Only the collections this engine stores. See `relationalCollections`.
396
+ const collections = relationalCollections(allCollections);
202
397
  let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
203
398
 
204
- // 1. Create custom schemas
399
+ // 1. Create custom schemas.
400
+ //
401
+ // `rebase` is unconditional and load-bearing. The RLS helper functions live
402
+ // in it, so the migration preamble creates it — which puts it in Atlas's
403
+ // replayed state, and anything in that state but absent from this desired
404
+ // schema gets a `DROP SCHEMA … CASCADE` planned against it. That would take
405
+ // the auth tables. It used to appear here only when some collection
406
+ // happened to declare `schema: "rebase"`; the scaffold's users collection
407
+ // does, which is why nobody hit it, but nothing guaranteed it.
205
408
  const uniqueSchemas = Array.from(new Set([
206
- "auth",
409
+ REBASE_SCHEMA,
207
410
  ...collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
208
411
  ]));
209
412
  uniqueSchemas.forEach(schema => {
@@ -211,7 +414,40 @@ export const generatePostgresDdl = async (
211
414
  });
212
415
  if (uniqueSchemas.length > 0) ddl += "\n";
213
416
 
417
+ // 1b. Search support, for collections that opted in.
418
+ //
419
+ // Extensions and helper functions come before every CREATE TABLE because a
420
+ // generated column's expression is resolved at creation time: a table whose
421
+ // search column calls `rebase_search_text` cannot be created before that
422
+ // function exists. Both are `IF NOT EXISTS` / `OR REPLACE`, so a file
423
+ // replayed against a live database is a no-op here.
424
+ const searchSpecs = collections
425
+ .map(c => buildSearchColumnSpec(c))
426
+ .filter((s): s is SearchColumnSpec => s !== undefined);
427
+
428
+ if (searchSpecs.length > 0) {
429
+ if (options.includeSearch === false) {
430
+ // Everything search-related lives in `search.sql` — see
431
+ // `generatePostgresSearchDdl` for why Atlas is not shown any of it.
432
+ ddl += "-- Full-text search support lives in `search.sql`, applied separately.\n\n";
433
+ } else {
434
+ const extensions = Array.from(new Set(searchSpecs.flatMap(searchExtensionStatements)));
435
+ const helpers = Array.from(new Set(searchSpecs.flatMap(searchHelperFunctions)));
436
+ ddl += "-- Full-text search support (collections declaring a `search` block)\n";
437
+ extensions.forEach(s => { ddl += `${s}\n`; });
438
+ if (extensions.length > 0) ddl += "\n";
439
+ helpers.forEach(s => { ddl += `${s}\n\n`; });
440
+ }
441
+ }
442
+
214
443
  // 2. Generate Enums
444
+ //
445
+ // The enum type name is derived from table + column, so two collections
446
+ // mapped onto the same table — or two properties whose `columnName`
447
+ // resolves to the same column — land on the same name. `CREATE TYPE` has no
448
+ // IF NOT EXISTS, so emitting it twice aborts the whole file on the second
449
+ // statement. Dedupe by name, matching planCollectionSchemaEnsure.
450
+ const emittedEnums = new Set<string>();
215
451
  collections.forEach(collection => {
216
452
  const collectionTable = getTableName(collection);
217
453
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
@@ -223,8 +459,9 @@ export const generatePostgresDdl = async (
223
459
  String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
224
460
  )
225
461
  : Object.keys(prop.enum);
226
- if (values.length > 0) {
227
- ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
462
+ if (values.length > 0 && !emittedEnums.has(`${schema}.${enumDbName}`)) {
463
+ emittedEnums.add(`${schema}.${enumDbName}`);
464
+ ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});\n`;
228
465
  }
229
466
  }
230
467
  });
@@ -270,6 +507,9 @@ export const generatePostgresDdl = async (
270
507
 
271
508
  // 3. Generate tables
272
509
  const fkStatements: string[] = [];
510
+ // Indexes follow the tables for the same reason the FK constraints do: the
511
+ // table has to exist first.
512
+ const indexStatements: string[] = [];
273
513
  // Policies are emitted after every CREATE TABLE, like the FK constraints:
274
514
  // a policy may reference other tables (a junction's derived policies always
275
515
  // reference both endpoints; `policy.existsIn` references a join table), and
@@ -307,8 +547,8 @@ export const generatePostgresDdl = async (
307
547
  ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
308
548
  ddl += `);\n\n`;
309
549
 
310
- fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
311
- fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
550
+ fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${sourceColumn}_fkey`)}" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
551
+ fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${targetColumn}_fkey`)}" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
312
552
 
313
553
  if (options.includePolicies) {
314
554
  // Junction tables are generated tables like any other: locked by
@@ -366,7 +606,7 @@ export const generatePostgresDdl = async (
366
606
  if (required) colDef += " NOT NULL";
367
607
  columns.push(colDef);
368
608
 
369
- fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${relInfo.localKey}_fkey" FOREIGN KEY ("${relInfo.localKey}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDeleteVal.toUpperCase()}${onUpdate};`);
609
+ fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${relInfo.localKey}_fkey`)}" FOREIGN KEY ("${relInfo.localKey}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDeleteVal.toUpperCase()}${onUpdate};`);
370
610
  } else if (prop.type === "reference") {
371
611
  const refProp = prop as ReferenceProperty;
372
612
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
@@ -388,10 +628,27 @@ export const generatePostgresDdl = async (
388
628
  if (required) colDef += " NOT NULL";
389
629
  columns.push(colDef);
390
630
 
391
- fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
631
+ fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${colName}_fkey`)}" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
392
632
  }
393
633
  } else {
394
634
  const colName = resolveColumnName(propName, prop);
635
+
636
+ // On an auth collection, the columns auth reads and writes
637
+ // are defined once, in `auth-users-columns`, and every
638
+ // creator of this table emits that definition. Before this,
639
+ // the desired state here was built from the collection file
640
+ // alone — which knows nothing of `is_anonymous` or
641
+ // `tokens_valid_after` — so `db push` and the runtime
642
+ // disagreed about `email`'s nullability and about which
643
+ // columns exist at all.
644
+ const authDefinition = isAuthCollection(collection)
645
+ ? authUsersColumnDefinition(colName)
646
+ : undefined;
647
+ if (authDefinition && !isIdProperty(propName, prop, collection)) {
648
+ columns.push(` "${colName}" ${authDefinition}`);
649
+ return;
650
+ }
651
+
395
652
  const colType = getSqlColumnType(propName, prop, collection, collections);
396
653
  let colDef = ` "${colName}" ${colType}`;
397
654
 
@@ -428,6 +685,40 @@ export const generatePostgresDdl = async (
428
685
  }
429
686
  });
430
687
 
688
+ // ── Auth columns the collection file never mentions ──────────────
689
+ // `db push` is declarative: Atlas diffs the database against exactly
690
+ // what is emitted here and drops anything else. The scaffold's users
691
+ // collection describes 12 columns while auth needs 14, so
692
+ // `is_anonymous` and `tokens_valid_after` — created at boot by
693
+ // `ensureAuthTablesExist` — read as unmanaged drift, and a push run
694
+ // after the server had started once planned to DROP them. The
695
+ // destructive gate catches it, which makes it a data-loss prompt on
696
+ // the auth table rather than silent damage; either way the desired
697
+ // state was wrong. Emit the full contract so the two agree.
698
+ if (isAuthCollection(collection)) {
699
+ const declared = new Set(
700
+ Object.entries(collection.properties ?? {})
701
+ .map(([name, prop]) => resolveColumnName(name, prop))
702
+ );
703
+ for (const spec of AUTH_USERS_COLUMNS) {
704
+ if (declared.has(spec.column)) continue;
705
+ columns.push(` "${spec.column}" ${authUsersColumnSql(spec)}`);
706
+ }
707
+ }
708
+
709
+ // ── The opt-in search column ─────────────────────────────────────
710
+ // Last, so it reads as what it is: derived from the columns above
711
+ // it. Postgres recomputes it on every write of a source column and
712
+ // rejects any attempt to write it directly, which is the property
713
+ // that makes it impossible for the index to drift from the row.
714
+ const searchSpec = options.includeSearch === false ? undefined : buildSearchColumnSpec(collection);
715
+ if (searchSpec) {
716
+ columns.push(` ${searchColumnDefinition(searchSpec)}`);
717
+ const fuzzyDef = fuzzyColumnDefinition(searchSpec);
718
+ if (fuzzyDef) columns.push(` ${fuzzyDef}`);
719
+ indexStatements.push(...searchIndexStatements(searchSpec));
720
+ }
721
+
431
722
  // Backwards compatibility: add default id primary key if missing
432
723
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
433
724
  if (!hasPk) {
@@ -461,6 +752,11 @@ export const generatePostgresDdl = async (
461
752
  ddl += fkStatements.join("\n") + "\n\n";
462
753
  }
463
754
 
755
+ if (indexStatements.length > 0) {
756
+ ddl += "-- Indexes\n";
757
+ ddl += indexStatements.join("\n") + "\n\n";
758
+ }
759
+
464
760
  if (policyStatements.length > 0) {
465
761
  ddl += "-- Row Level Security Policies\n";
466
762
  ddl += policyStatements.join("");
@@ -470,7 +766,352 @@ export const generatePostgresDdl = async (
470
766
  return ddl;
471
767
  };
472
768
 
473
- export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
769
+ /** The RLS statements one declared collection's table needs, ready to run. */
770
+ /**
771
+ * A foreign key, as both its parts and the statement that creates it.
772
+ *
773
+ * `ALTER TABLE … ADD CONSTRAINT` has no `IF NOT EXISTS`, so a caller applying
774
+ * these has to skip by name — hence the name is a field and not only a substring
775
+ * of the SQL.
776
+ */
777
+ export interface ForeignKeyPlan {
778
+ constraintName: string;
779
+ schema: string;
780
+ /** Bare table name, no schema prefix. */
781
+ table: string;
782
+ column: string;
783
+ targetSchema: string;
784
+ targetTable: string;
785
+ targetColumn: string;
786
+ sql: string;
787
+ }
788
+
789
+ /** A column a `relation` or `reference` property owns on its own table. */
790
+ export interface RelationalColumnPlan {
791
+ schema: string;
792
+ /** Bare table name, no schema prefix. */
793
+ table: string;
794
+ column: string;
795
+ /** Postgres type, exactly as the DDL generator declares it. */
796
+ type: string;
797
+ /** Absent when the target collection is not part of this bundle. */
798
+ foreignKey?: ForeignKeyPlan;
799
+ /**
800
+ * What this column would have been called before `generateForeignKeyName`
801
+ * learned to singularize — set only when the two differ and the column name
802
+ * is the derived default rather than one the author wrote.
803
+ *
804
+ * Carried so the boot-time ensure can notice a database provisioned under
805
+ * the old rule. It is never used to name anything.
806
+ */
807
+ legacyColumn?: string;
808
+ }
809
+
810
+ /** The table behind a many-to-many `through` relation. */
811
+ export interface JunctionTablePlan {
812
+ schema: string;
813
+ /** Bare table name, no schema prefix. */
814
+ table: string;
815
+ columns: { name: string; type: string; legacyName?: string }[];
816
+ /** Both endpoint columns plus the composite primary key. */
817
+ createTable: string;
818
+ foreignKeys: ForeignKeyPlan[];
819
+ }
820
+
821
+ const schemaOfCollection = (collection: CollectionConfig): string =>
822
+ isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
823
+
824
+ const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
825
+
826
+ /**
827
+ * Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
828
+ *
829
+ * This is not cosmetic, and not really a naming choice at all — it is agreeing
830
+ * with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
831
+ * succeeds and records the truncated form, so the untruncated name this used to
832
+ * derive matched nothing in the catalogue. Boot-ensure compares its planned
833
+ * constraints against `readExistingSchema`, which reads catalogue names, so the
834
+ * comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
835
+ * constraint, forever, and got "already exists" every time. Non-fatal (foreign
836
+ * keys are the one action allowed to fail) and therefore permanent — an error in
837
+ * the log on every restart of a project whose table and column names happened to
838
+ * be long.
839
+ *
840
+ * Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
841
+ * character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
842
+ */
843
+ // Moved to `@rebasepro/utils` so the search-column builder derives index names
844
+ // under the same 63-byte rule this file derives constraint names under. Two
845
+ // copies of a truncation rule is two rules the moment one of them is edited.
846
+
847
+ const foreignKeyPlan = (
848
+ args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
849
+ ): ForeignKeyPlan => {
850
+ const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
851
+ const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
852
+ return {
853
+ constraintName,
854
+ schema: args.schema,
855
+ table: args.table,
856
+ column: args.column,
857
+ targetSchema: args.targetSchema,
858
+ targetTable: args.targetTable,
859
+ targetColumn: args.targetColumn,
860
+ sql:
861
+ `ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" ` +
862
+ `FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ` +
863
+ `("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
864
+ };
865
+ };
866
+
867
+ /**
868
+ * The FK columns the declared collections own — one entry per `relation`
869
+ * (`belongsTo` side) or `reference` property.
870
+ *
871
+ * Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
872
+ * create the same columns with the same names, types and constraints. Before
873
+ * this it skipped them outright, which was survivable only because `db push`
874
+ * always followed; on a managed tenant nothing follows, so a table arrived
875
+ * without the column its own collection reads and wrote 400 on every insert.
876
+ *
877
+ * A relation whose target is not in the bundle yields no column at all (the
878
+ * generator returns early on an unresolvable target); a `reference` whose target
879
+ * is unknown yields the column without a constraint. Both mirror the generator
880
+ * exactly — a divergence here is a schema fork between boot and `db push`.
881
+ */
882
+ export const planRelationalColumns = (allCollections: CollectionConfig[]): RelationalColumnPlan[] => {
883
+ const collections = relationalCollections(allCollections);
884
+ const plans: RelationalColumnPlan[] = [];
885
+
886
+ for (const collection of collections) {
887
+ const tableName = getTableName(collection);
888
+ if (!tableName) continue;
889
+ const schema = schemaOfCollection(collection);
890
+ const table = bareTableName(tableName);
891
+
892
+ for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
893
+ const prop = rawProp as Property;
894
+
895
+ if (prop.type === "relation") {
896
+ const refProp = prop as RelationProperty;
897
+ const resolvedRelations = resolveCollectionRelations(collection);
898
+ const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
899
+ if (relInfo?.kind !== "belongsTo") continue;
900
+ // The relation and an explicit FK property can both be declared;
901
+ // the explicit one owns the column.
902
+ if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
903
+
904
+ let targetCollection: CollectionConfig;
905
+ try {
906
+ targetCollection = relInfo.target();
907
+ } catch {
908
+ continue;
909
+ }
910
+ if (!targetCollection) continue;
911
+
912
+ const required = prop.validation?.required;
913
+ // Only a *derived* column name can be affected by the change to
914
+ // `generateForeignKeyName`; one the author wrote is theirs.
915
+ const relationName = refProp.relation?.relationName ?? propName;
916
+ const legacyKey = legacyForeignKeyName(relationName);
917
+ const derived = relInfo.localKey === generateForeignKeyName(relationName);
918
+ plans.push({
919
+ schema,
920
+ table,
921
+ column: relInfo.localKey,
922
+ legacyColumn: derived && legacyKey !== relInfo.localKey ? legacyKey : undefined,
923
+ type: getSqlColumnType(propName, prop, collection, collections),
924
+ foreignKey: foreignKeyPlan({
925
+ schema,
926
+ table,
927
+ column: relInfo.localKey,
928
+ targetSchema: schemaOfCollection(targetCollection),
929
+ targetTable: bareTableName(getTableName(targetCollection)),
930
+ targetColumn: getPrimaryKeyName(targetCollection),
931
+ onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
932
+ onUpdate: relInfo.onUpdate
933
+ })
934
+ });
935
+ } else if (prop.type === "reference") {
936
+ const refProp = prop as ReferenceProperty;
937
+ const targetCollection = collections.find(
938
+ c => c.slug === refProp.path || getTableName(c) === refProp.path
939
+ );
940
+ const column = resolveColumnName(propName, prop);
941
+ const type = getSqlColumnType(propName, prop, collection, collections);
942
+ const required = prop.validation?.required;
943
+
944
+ plans.push({
945
+ schema,
946
+ table,
947
+ column,
948
+ type,
949
+ foreignKey: targetCollection
950
+ ? foreignKeyPlan({
951
+ schema,
952
+ table,
953
+ column,
954
+ targetSchema: schemaOfCollection(targetCollection),
955
+ targetTable: bareTableName(getTableName(targetCollection)),
956
+ targetColumn: getPrimaryKeyName(targetCollection),
957
+ onDelete: required ? "CASCADE" : "SET NULL"
958
+ })
959
+ : undefined
960
+ });
961
+ }
962
+ }
963
+ }
964
+
965
+ return plans;
966
+ };
967
+
968
+ /**
969
+ * The junction tables a bundle's many-to-many relations imply.
970
+ *
971
+ * Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
972
+ * comes from, so a table created here always has policies planned for it — a
973
+ * junction with row-level security left off is readable and writable by every
974
+ * signed-in user, which is why the two must ship together.
975
+ */
976
+ export const planJunctionTables = (allCollections: CollectionConfig[]): JunctionTablePlan[] => {
977
+ const collections = relationalCollections(allCollections);
978
+ const plans: JunctionTablePlan[] = [];
979
+
980
+ for (const spec of resolveJunctionSpecs(collections).values()) {
981
+ const [source, target] = spec.endpoints;
982
+ // A junction column's default name is derived from the endpoint
983
+ // collection's slug — which is normally plural, so this is where the
984
+ // singularization change actually lands: a `categories` endpoint used
985
+ // to give `categorie_id` and now gives `category_id`. Recorded, not
986
+ // used, so boot-ensure can recognise a table built under the old rule.
987
+ const legacyFor = (endpoint: typeof source): string | undefined => {
988
+ const slug = toSnakeCase(endpoint.collection.slug ?? endpoint.collection.name ?? "");
989
+ const legacy = legacyForeignKeyName(slug);
990
+ const derived = endpoint.junctionColumn === generateForeignKeyName(slug);
991
+ return derived && legacy !== endpoint.junctionColumn ? legacy : undefined;
992
+ };
993
+ const columns = [
994
+ { name: source.junctionColumn, type: junctionKeyType(source.collection), legacyName: legacyFor(source) },
995
+ { name: target.junctionColumn, type: junctionKeyType(target.collection), legacyName: legacyFor(target) }
996
+ ];
997
+ // Every declaring side agrees on the edge's lifetime; the first one wins,
998
+ // as it does in the generator's walk.
999
+ const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
1000
+
1001
+ plans.push({
1002
+ schema: spec.schema,
1003
+ table: spec.table,
1004
+ columns,
1005
+ createTable:
1006
+ `CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` +
1007
+ columns.map(c => `"${c.name}" ${c.type} NOT NULL`).join(", ") +
1008
+ `, PRIMARY KEY (${columns.map(c => `"${c.name}"`).join(", ")}));`,
1009
+ foreignKeys: [source, target].map((endpoint, i) =>
1010
+ foreignKeyPlan({
1011
+ schema: spec.schema,
1012
+ table: spec.table,
1013
+ column: columns[i].name,
1014
+ targetSchema: schemaOfCollection(endpoint.collection),
1015
+ targetTable: bareTableName(getTableName(endpoint.collection)),
1016
+ targetColumn: getPrimaryKeyName(endpoint.collection),
1017
+ onDelete
1018
+ })
1019
+ )
1020
+ });
1021
+ }
1022
+
1023
+ return plans;
1024
+ };
1025
+
1026
+ export interface CollectionPolicyPlan {
1027
+ /** The table's schema (e.g. `public`, `rebase`). */
1028
+ schema: string;
1029
+ /** The bare table name, no schema prefix. */
1030
+ table: string;
1031
+ /** `schema.table` — matches the keys `readExistingSchema` returns. */
1032
+ qualified: string;
1033
+ /** `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` — locked by default. */
1034
+ enableRls: string;
1035
+ /** `DROP POLICY IF EXISTS` / `CREATE POLICY` statements, in order. */
1036
+ policyStatements: string[];
1037
+ }
1038
+
1039
+ /**
1040
+ * The per-table RLS plan for the *declared* collections, as executable
1041
+ * statements — what the managed runtime applies at boot so a freshly
1042
+ * provisioned tenant database serves data instead of 401ing every read.
1043
+ *
1044
+ * Mirrors {@link generatePostgresPoliciesDdl} exactly (same
1045
+ * `generatePolicyStatements`, same enable-RLS, same effective rules, same
1046
+ * derived junction rules), so boot and `db push` produce identical policies from
1047
+ * identical collections.
1048
+ *
1049
+ * Junction tables are included, and have to be: boot creates them now
1050
+ * ({@link planJunctionTables}), and a junction with RLS left off is readable and
1051
+ * writable by every signed-in user. A junction whose table is still absent is
1052
+ * skipped by the applier, not planned away here.
1053
+ */
1054
+ export const planCollectionPolicies = (allCollections: CollectionConfig[]): CollectionPolicyPlan[] => {
1055
+ // A store with no RLS gets no policies planned for it — `supportsRLS` is
1056
+ // false for exactly the engines `relationalCollections` filters out.
1057
+ const collections = relationalCollections(allCollections);
1058
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
1059
+ const plans: CollectionPolicyPlan[] = [];
1060
+ const seen = new Set<string>();
1061
+
1062
+ for (const collection of collections) {
1063
+ const tableName = getTableName(collection);
1064
+ if (!tableName) continue;
1065
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
1066
+ const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
1067
+ const qualified = `${schema}.${baseTableName}`;
1068
+ if (seen.has(qualified)) continue;
1069
+ seen.add(qualified);
1070
+
1071
+ const policyStatements: string[] = [];
1072
+ for (const rule of getEffectiveSecurityRules(collection)) {
1073
+ policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
1074
+ }
1075
+
1076
+ plans.push({
1077
+ schema,
1078
+ table: baseTableName,
1079
+ qualified,
1080
+ enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
1081
+ policyStatements
1082
+ });
1083
+ }
1084
+
1085
+ // Junctions are derived from `through` relations rather than declared, so
1086
+ // the walk above never sees them.
1087
+ for (const spec of resolveJunctionSpecs(collections).values()) {
1088
+ const qualified = `${spec.schema}.${spec.table}`;
1089
+ if (seen.has(qualified)) continue;
1090
+ seen.add(qualified);
1091
+
1092
+ const junctionCollection = getJunctionCollectionConfig(spec);
1093
+ const policyStatements: string[] = [];
1094
+ for (const rule of getJunctionSecurityRules(spec)) {
1095
+ policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
1096
+ }
1097
+
1098
+ plans.push({
1099
+ schema: spec.schema,
1100
+ table: spec.table,
1101
+ qualified,
1102
+ enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
1103
+ policyStatements
1104
+ });
1105
+ }
1106
+
1107
+ return plans;
1108
+ };
1109
+
1110
+ export const generatePostgresPoliciesDdl = (allCollections: CollectionConfig[]): string => {
1111
+ // Also the expectation `checkPolicyDrift` reconciles the database against,
1112
+ // so a non-SQL collection filtered out here is one the drift report cannot
1113
+ // invent a missing policy for.
1114
+ const collections = relationalCollections(allCollections);
474
1115
  let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
475
1116
 
476
1117
  const allTablesToGenerate = new Map<string, {