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

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 (112) 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 +21 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
  5. package/dist/auth-users-columns-Do9mw5Y5.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 +40 -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/ensure-collection-policies-DMUdRQdM.js +57 -0
  15. package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
  16. package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
  17. package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
  18. package/dist/index.es.js +1701 -10167
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/policy-CPkCqVTz.js +105 -0
  21. package/dist/policy-CPkCqVTz.js.map +1 -0
  22. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  24. package/dist/schema/auth-schema.d.ts +83 -144
  25. package/dist/schema/auth-users-columns.d.ts +97 -0
  26. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  27. package/dist/schema/ensure-collection-tables.d.ts +45 -3
  28. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  29. package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -3
  30. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  31. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  32. package/dist/schema/introspect-db-logic.d.ts +94 -5
  33. package/dist/schema/introspect-db-queries.d.ts +119 -0
  34. package/dist/schema/introspect-db-structure.d.ts +263 -0
  35. package/dist/schema/introspect-db-types.d.ts +11 -0
  36. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  37. package/dist/security/rls-enforcement.d.ts +53 -2
  38. package/dist/services/FetchService.d.ts +14 -8
  39. package/dist/services/RelationService.d.ts +24 -1
  40. package/dist/services/channel-bus/index.d.ts +1 -7
  41. package/dist/services/collection-helpers.d.ts +36 -2
  42. package/dist/services/dataService.d.ts +5 -1
  43. package/dist/services/realtimeService.d.ts +25 -21
  44. package/dist/services/row-pipeline.d.ts +1 -1
  45. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  46. package/dist/src-C_wvdMnl.js.map +1 -0
  47. package/dist/utils/connection-string.d.ts +29 -0
  48. package/dist/utils/drizzle-conditions.d.ts +5 -4
  49. package/dist/utils/pg-error-utils.d.ts +35 -0
  50. package/dist/websocket-D0TBU3ia.js +530 -0
  51. package/dist/websocket-D0TBU3ia.js.map +1 -0
  52. package/package.json +17 -16
  53. package/src/PostgresAdapter.ts +21 -2
  54. package/src/PostgresBackendDriver.ts +169 -3
  55. package/src/PostgresBootstrapper.ts +233 -35
  56. package/src/auth/ensure-tables.ts +335 -81
  57. package/src/auth/services.ts +24 -2
  58. package/src/backup/backup-cli.ts +41 -2
  59. package/src/backup/backup-service.ts +38 -5
  60. package/src/backup/pg-tools.ts +96 -3
  61. package/src/cli-helpers.ts +92 -9
  62. package/src/cli.ts +219 -56
  63. package/src/collections/validate-relations.ts +136 -14
  64. package/src/connection.ts +73 -0
  65. package/src/data-transformer.ts +22 -6
  66. package/src/databasePoolManager.ts +5 -2
  67. package/src/history/ensure-history-table.ts +7 -0
  68. package/src/schema/auth-schema.ts +30 -19
  69. package/src/schema/auth-users-columns.ts +131 -0
  70. package/src/schema/doctor.ts +7 -5
  71. package/src/schema/ensure-collection-policies.ts +105 -0
  72. package/src/schema/ensure-collection-tables.test.ts +105 -9
  73. package/src/schema/ensure-collection-tables.ts +301 -44
  74. package/src/schema/generate-drizzle-schema-logic.ts +49 -7
  75. package/src/schema/generate-postgres-ddl-logic.ts +474 -27
  76. package/src/schema/generated-schema-staleness.ts +169 -0
  77. package/src/schema/introspect-db-constraints.ts +385 -0
  78. package/src/schema/introspect-db-inference.ts +18 -8
  79. package/src/schema/introspect-db-logic.ts +385 -71
  80. package/src/schema/introspect-db-queries.ts +326 -0
  81. package/src/schema/introspect-db-structure.ts +670 -0
  82. package/src/schema/introspect-db-types.ts +56 -0
  83. package/src/schema/introspect-db.ts +37 -80
  84. package/src/schema/introspect-runtime.test.ts +56 -8
  85. package/src/schema/introspect-runtime.ts +31 -9
  86. package/src/schema/non-sql-collections.test.ts +131 -0
  87. package/src/schema/rls-bootstrap-sql.ts +288 -0
  88. package/src/security/anonymous-grants.test.ts +4 -2
  89. package/src/security/policy-drift.test.ts +11 -3
  90. package/src/security/rls-enforcement.ts +141 -3
  91. package/src/services/BranchService.ts +71 -28
  92. package/src/services/FetchService.ts +100 -107
  93. package/src/services/PersistService.ts +34 -7
  94. package/src/services/RelationService.ts +249 -48
  95. package/src/services/channel-bus/index.ts +0 -9
  96. package/src/services/channel-history.ts +8 -0
  97. package/src/services/channel-presence.ts +6 -0
  98. package/src/services/collection-helpers.ts +69 -3
  99. package/src/services/dataService.ts +5 -1
  100. package/src/services/realtimeService.ts +38 -35
  101. package/src/services/row-pipeline.ts +1 -1
  102. package/src/utils/connection-string.ts +58 -0
  103. package/src/utils/drizzle-conditions.ts +31 -6
  104. package/src/utils/pg-error-utils.ts +65 -0
  105. package/src/websocket.ts +18 -9
  106. package/dist/chunk-DSJWtz9O.js +0 -40
  107. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  108. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  109. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  110. package/dist/src-BbFOPJ1S.js.map +0 -1
  111. package/dist/src-Zqwaw3P5.js.map +0 -1
  112. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -1,6 +1,8 @@
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 } from "@rebasepro/utils";
4
+ import { AUTH_USERS_COLUMNS, authUsersColumnDefinition, authUsersColumnSql, isAuthCollection } from "./auth-users-columns";
5
+ import { REBASE_SCHEMA } from "@rebasepro/types";
4
6
 
5
7
  // --- Helper Functions ---
6
8
 
@@ -11,7 +13,7 @@ export const resolveColumnName = (propName: string, prop?: Property | null): str
11
13
  return toSnakeCase(propName);
12
14
  };
13
15
 
14
- const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
16
+ export const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
15
17
  if (collection.properties) {
16
18
  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
19
  if (idPropEntry) {
@@ -28,14 +30,18 @@ const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type:
28
30
  return { name: "id", type: "string", isUuid: isUuid ?? false };
29
31
  };
30
32
 
31
- const isNumericId = (collection: CollectionConfig): boolean => {
33
+ export const isNumericId = (collection: CollectionConfig): boolean => {
32
34
  return getPrimaryKeyProp(collection).type === "number";
33
35
  };
34
36
 
35
- const getPrimaryKeyName = (collection: CollectionConfig): string => {
37
+ export const getPrimaryKeyName = (collection: CollectionConfig): string => {
36
38
  return getPrimaryKeyProp(collection).name;
37
39
  };
38
40
 
41
+ /** The column type a junction holds for one endpoint's primary key. */
42
+ const junctionKeyType = (collection: CollectionConfig): string =>
43
+ isNumericId(collection) ? "INTEGER" : (getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT");
44
+
39
45
  export const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
40
46
  if ("isId" in prop && Boolean(prop.isId)) return true;
41
47
  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 +51,28 @@ export const isIdProperty = (propName: string, prop: Property, collection: Colle
45
51
 
46
52
  type ResolveCollection = (slug: string) => CollectionConfig | undefined;
47
53
 
48
- const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string => {
54
+ /**
55
+ * Render statements produced by {@link generatePolicyStatements} back into the
56
+ * exact string the DDL/policies files have always carried: each statement on
57
+ * its own line, terminated by a newline. Keeping the string form derived from
58
+ * the statement array means the two can never drift — the boot-time applier and
59
+ * the generated `policies.sql` emit the same SQL, from the same source.
60
+ */
61
+ const statementsToDdl = (statements: string[]): string => statements.map(s => `${s}\n`).join("");
62
+
63
+ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string =>
64
+ statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
65
+
66
+ /**
67
+ * The individual SQL statements a single security rule compiles to: a
68
+ * `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
69
+ * statement (terminated by `;`, no trailing newline).
70
+ *
71
+ * This is the primitive the boot-time RLS applier runs one statement at a time
72
+ * (the runtime's DB handle speaks the extended query protocol, which forbids
73
+ * multiple commands in one execute), while `db push` writes the joined string.
74
+ */
75
+ export const generatePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string[] => {
49
76
  const tableName = getTableName(collection);
50
77
  const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
51
78
  ? rule.operations
@@ -53,12 +80,12 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
53
80
 
54
81
  const policyNames = getPolicyNamesForRule(rule, tableName);
55
82
 
56
- return ops.map((op, opIdx) => {
57
- return generateSinglePolicyDdl(collection, rule, op, policyNames[opIdx], resolveCollection);
58
- }).join("");
83
+ return ops.flatMap((op, opIdx) => {
84
+ return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
85
+ });
59
86
  };
60
87
 
61
- const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string => {
88
+ const generateSinglePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string[] => {
62
89
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
63
90
  const tableName = getTableName(collection);
64
91
  const mode = (rule.mode ?? "permissive").toUpperCase();
@@ -83,13 +110,24 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
83
110
  withCheckClause = "false";
84
111
  }
85
112
 
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`;
113
+ const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
114
+ let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
115
+ if (usingClause) create += ` USING (${usingClause})`;
116
+ if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
117
+ create += ";";
118
+ return [drop, create];
91
119
  };
92
120
 
121
+ /**
122
+ * Single-quote escaping for a SQL string literal (PostgreSQL doubles the
123
+ * quote). Enum labels come straight from user-authored collection config, so a
124
+ * label like `it's` closes the literal early and the whole generated file stops
125
+ * parsing at the `CREATE TYPE`. Lives here rather than next to its other caller
126
+ * because ensure-collection-tables already imports from this module — the
127
+ * reverse would be a cycle.
128
+ */
129
+ export const quoteSqlLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
130
+
93
131
  export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
94
132
  switch (prop.type) {
95
133
  case "string": {
@@ -103,11 +141,15 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
103
141
  if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
104
142
  return "UUID";
105
143
  }
144
+ // Width comes from `validation.max` when the property states one.
145
+ // It used to be a hardcoded 255 here and *absent* on the Drizzle
146
+ // path, so the same property produced a bounded column down one
147
+ // generator and an unbounded one down the other.
106
148
  if (stringProp.columnType === "char") {
107
- return "CHAR(255)";
149
+ return `CHAR(${resolveStringColumnLength(stringProp)})`;
108
150
  }
109
151
  if (stringProp.columnType === "varchar") {
110
- return "VARCHAR(255)";
152
+ return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
111
153
  }
112
154
  // `text` is the default. The two generators disagreed here before:
113
155
  // this one emitted VARCHAR(255) while the drizzle path emitted a bare
@@ -196,14 +238,24 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
196
238
  };
197
239
 
198
240
  export const generatePostgresDdl = async (
199
- collections: CollectionConfig[],
241
+ allCollections: CollectionConfig[],
200
242
  options: { includePolicies?: boolean } = { includePolicies: true }
201
243
  ): Promise<string> => {
244
+ // Only the collections this engine stores. See `relationalCollections`.
245
+ const collections = relationalCollections(allCollections);
202
246
  let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
203
247
 
204
- // 1. Create custom schemas
248
+ // 1. Create custom schemas.
249
+ //
250
+ // `rebase` is unconditional and load-bearing. The RLS helper functions live
251
+ // in it, so the migration preamble creates it — which puts it in Atlas's
252
+ // replayed state, and anything in that state but absent from this desired
253
+ // schema gets a `DROP SCHEMA … CASCADE` planned against it. That would take
254
+ // the auth tables. It used to appear here only when some collection
255
+ // happened to declare `schema: "rebase"`; the scaffold's users collection
256
+ // does, which is why nobody hit it, but nothing guaranteed it.
205
257
  const uniqueSchemas = Array.from(new Set([
206
- "auth",
258
+ REBASE_SCHEMA,
207
259
  ...collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
208
260
  ]));
209
261
  uniqueSchemas.forEach(schema => {
@@ -212,6 +264,13 @@ export const generatePostgresDdl = async (
212
264
  if (uniqueSchemas.length > 0) ddl += "\n";
213
265
 
214
266
  // 2. Generate Enums
267
+ //
268
+ // The enum type name is derived from table + column, so two collections
269
+ // mapped onto the same table — or two properties whose `columnName`
270
+ // resolves to the same column — land on the same name. `CREATE TYPE` has no
271
+ // IF NOT EXISTS, so emitting it twice aborts the whole file on the second
272
+ // statement. Dedupe by name, matching planCollectionSchemaEnsure.
273
+ const emittedEnums = new Set<string>();
215
274
  collections.forEach(collection => {
216
275
  const collectionTable = getTableName(collection);
217
276
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
@@ -223,8 +282,9 @@ export const generatePostgresDdl = async (
223
282
  String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
224
283
  )
225
284
  : Object.keys(prop.enum);
226
- if (values.length > 0) {
227
- ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
285
+ if (values.length > 0 && !emittedEnums.has(`${schema}.${enumDbName}`)) {
286
+ emittedEnums.add(`${schema}.${enumDbName}`);
287
+ ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});\n`;
228
288
  }
229
289
  }
230
290
  });
@@ -307,8 +367,8 @@ export const generatePostgresDdl = async (
307
367
  ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
308
368
  ddl += `);\n\n`;
309
369
 
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()};`);
370
+ fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${sourceColumn}_fkey`)}" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
371
+ fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${targetColumn}_fkey`)}" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
312
372
 
313
373
  if (options.includePolicies) {
314
374
  // Junction tables are generated tables like any other: locked by
@@ -366,7 +426,7 @@ export const generatePostgresDdl = async (
366
426
  if (required) colDef += " NOT NULL";
367
427
  columns.push(colDef);
368
428
 
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};`);
429
+ 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
430
  } else if (prop.type === "reference") {
371
431
  const refProp = prop as ReferenceProperty;
372
432
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
@@ -388,10 +448,27 @@ export const generatePostgresDdl = async (
388
448
  if (required) colDef += " NOT NULL";
389
449
  columns.push(colDef);
390
450
 
391
- fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
451
+ fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${colName}_fkey`)}" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
392
452
  }
393
453
  } else {
394
454
  const colName = resolveColumnName(propName, prop);
455
+
456
+ // On an auth collection, the columns auth reads and writes
457
+ // are defined once, in `auth-users-columns`, and every
458
+ // creator of this table emits that definition. Before this,
459
+ // the desired state here was built from the collection file
460
+ // alone — which knows nothing of `is_anonymous` or
461
+ // `tokens_valid_after` — so `db push` and the runtime
462
+ // disagreed about `email`'s nullability and about which
463
+ // columns exist at all.
464
+ const authDefinition = isAuthCollection(collection)
465
+ ? authUsersColumnDefinition(colName)
466
+ : undefined;
467
+ if (authDefinition && !isIdProperty(propName, prop, collection)) {
468
+ columns.push(` "${colName}" ${authDefinition}`);
469
+ return;
470
+ }
471
+
395
472
  const colType = getSqlColumnType(propName, prop, collection, collections);
396
473
  let colDef = ` "${colName}" ${colType}`;
397
474
 
@@ -428,6 +505,27 @@ export const generatePostgresDdl = async (
428
505
  }
429
506
  });
430
507
 
508
+ // ── Auth columns the collection file never mentions ──────────────
509
+ // `db push` is declarative: Atlas diffs the database against exactly
510
+ // what is emitted here and drops anything else. The scaffold's users
511
+ // collection describes 12 columns while auth needs 14, so
512
+ // `is_anonymous` and `tokens_valid_after` — created at boot by
513
+ // `ensureAuthTablesExist` — read as unmanaged drift, and a push run
514
+ // after the server had started once planned to DROP them. The
515
+ // destructive gate catches it, which makes it a data-loss prompt on
516
+ // the auth table rather than silent damage; either way the desired
517
+ // state was wrong. Emit the full contract so the two agree.
518
+ if (isAuthCollection(collection)) {
519
+ const declared = new Set(
520
+ Object.entries(collection.properties ?? {})
521
+ .map(([name, prop]) => resolveColumnName(name, prop))
522
+ );
523
+ for (const spec of AUTH_USERS_COLUMNS) {
524
+ if (declared.has(spec.column)) continue;
525
+ columns.push(` "${spec.column}" ${authUsersColumnSql(spec)}`);
526
+ }
527
+ }
528
+
431
529
  // Backwards compatibility: add default id primary key if missing
432
530
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
433
531
  if (!hasPk) {
@@ -470,7 +568,356 @@ export const generatePostgresDdl = async (
470
568
  return ddl;
471
569
  };
472
570
 
473
- export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
571
+ /** The RLS statements one declared collection's table needs, ready to run. */
572
+ /**
573
+ * A foreign key, as both its parts and the statement that creates it.
574
+ *
575
+ * `ALTER TABLE … ADD CONSTRAINT` has no `IF NOT EXISTS`, so a caller applying
576
+ * these has to skip by name — hence the name is a field and not only a substring
577
+ * of the SQL.
578
+ */
579
+ export interface ForeignKeyPlan {
580
+ constraintName: string;
581
+ schema: string;
582
+ /** Bare table name, no schema prefix. */
583
+ table: string;
584
+ column: string;
585
+ targetSchema: string;
586
+ targetTable: string;
587
+ targetColumn: string;
588
+ sql: string;
589
+ }
590
+
591
+ /** A column a `relation` or `reference` property owns on its own table. */
592
+ export interface RelationalColumnPlan {
593
+ schema: string;
594
+ /** Bare table name, no schema prefix. */
595
+ table: string;
596
+ column: string;
597
+ /** Postgres type, exactly as the DDL generator declares it. */
598
+ type: string;
599
+ /** Absent when the target collection is not part of this bundle. */
600
+ foreignKey?: ForeignKeyPlan;
601
+ /**
602
+ * What this column would have been called before `generateForeignKeyName`
603
+ * learned to singularize — set only when the two differ and the column name
604
+ * is the derived default rather than one the author wrote.
605
+ *
606
+ * Carried so the boot-time ensure can notice a database provisioned under
607
+ * the old rule. It is never used to name anything.
608
+ */
609
+ legacyColumn?: string;
610
+ }
611
+
612
+ /** The table behind a many-to-many `through` relation. */
613
+ export interface JunctionTablePlan {
614
+ schema: string;
615
+ /** Bare table name, no schema prefix. */
616
+ table: string;
617
+ columns: { name: string; type: string; legacyName?: string }[];
618
+ /** Both endpoint columns plus the composite primary key. */
619
+ createTable: string;
620
+ foreignKeys: ForeignKeyPlan[];
621
+ }
622
+
623
+ const schemaOfCollection = (collection: CollectionConfig): string =>
624
+ isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
625
+
626
+ const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
627
+
628
+ /**
629
+ * Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
630
+ *
631
+ * This is not cosmetic, and not really a naming choice at all — it is agreeing
632
+ * with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
633
+ * succeeds and records the truncated form, so the untruncated name this used to
634
+ * derive matched nothing in the catalogue. Boot-ensure compares its planned
635
+ * constraints against `readExistingSchema`, which reads catalogue names, so the
636
+ * comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
637
+ * constraint, forever, and got "already exists" every time. Non-fatal (foreign
638
+ * keys are the one action allowed to fail) and therefore permanent — an error in
639
+ * the log on every restart of a project whose table and column names happened to
640
+ * be long.
641
+ *
642
+ * Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
643
+ * character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
644
+ */
645
+ const toPostgresIdentifier = (name: string): string => {
646
+ const bytes = Buffer.from(name, "utf8");
647
+ if (bytes.byteLength <= 63) return name;
648
+ // `toString` on a slice that ends mid-character yields U+FFFD; dropping it
649
+ // lands on the last whole character that fits, which is what Postgres does.
650
+ return bytes.subarray(0, 63).toString("utf8").replace(/�+$/, "");
651
+ };
652
+
653
+ const foreignKeyPlan = (
654
+ args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
655
+ ): ForeignKeyPlan => {
656
+ const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
657
+ const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
658
+ return {
659
+ constraintName,
660
+ schema: args.schema,
661
+ table: args.table,
662
+ column: args.column,
663
+ targetSchema: args.targetSchema,
664
+ targetTable: args.targetTable,
665
+ targetColumn: args.targetColumn,
666
+ sql:
667
+ `ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" ` +
668
+ `FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ` +
669
+ `("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
670
+ };
671
+ };
672
+
673
+ /**
674
+ * The FK columns the declared collections own — one entry per `relation`
675
+ * (`belongsTo` side) or `reference` property.
676
+ *
677
+ * Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
678
+ * create the same columns with the same names, types and constraints. Before
679
+ * this it skipped them outright, which was survivable only because `db push`
680
+ * always followed; on a managed tenant nothing follows, so a table arrived
681
+ * without the column its own collection reads and wrote 400 on every insert.
682
+ *
683
+ * A relation whose target is not in the bundle yields no column at all (the
684
+ * generator returns early on an unresolvable target); a `reference` whose target
685
+ * is unknown yields the column without a constraint. Both mirror the generator
686
+ * exactly — a divergence here is a schema fork between boot and `db push`.
687
+ */
688
+ export const planRelationalColumns = (allCollections: CollectionConfig[]): RelationalColumnPlan[] => {
689
+ const collections = relationalCollections(allCollections);
690
+ const plans: RelationalColumnPlan[] = [];
691
+
692
+ for (const collection of collections) {
693
+ const tableName = getTableName(collection);
694
+ if (!tableName) continue;
695
+ const schema = schemaOfCollection(collection);
696
+ const table = bareTableName(tableName);
697
+
698
+ for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
699
+ const prop = rawProp as Property;
700
+
701
+ if (prop.type === "relation") {
702
+ const refProp = prop as RelationProperty;
703
+ const resolvedRelations = resolveCollectionRelations(collection);
704
+ const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
705
+ if (relInfo?.kind !== "belongsTo") continue;
706
+ // The relation and an explicit FK property can both be declared;
707
+ // the explicit one owns the column.
708
+ if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
709
+
710
+ let targetCollection: CollectionConfig;
711
+ try {
712
+ targetCollection = relInfo.target();
713
+ } catch {
714
+ continue;
715
+ }
716
+ if (!targetCollection) continue;
717
+
718
+ const required = prop.validation?.required;
719
+ // Only a *derived* column name can be affected by the change to
720
+ // `generateForeignKeyName`; one the author wrote is theirs.
721
+ const relationName = refProp.relation?.relationName ?? propName;
722
+ const legacyKey = legacyForeignKeyName(relationName);
723
+ const derived = relInfo.localKey === generateForeignKeyName(relationName);
724
+ plans.push({
725
+ schema,
726
+ table,
727
+ column: relInfo.localKey,
728
+ legacyColumn: derived && legacyKey !== relInfo.localKey ? legacyKey : undefined,
729
+ type: getSqlColumnType(propName, prop, collection, collections),
730
+ foreignKey: foreignKeyPlan({
731
+ schema,
732
+ table,
733
+ column: relInfo.localKey,
734
+ targetSchema: schemaOfCollection(targetCollection),
735
+ targetTable: bareTableName(getTableName(targetCollection)),
736
+ targetColumn: getPrimaryKeyName(targetCollection),
737
+ onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
738
+ onUpdate: relInfo.onUpdate
739
+ })
740
+ });
741
+ } else if (prop.type === "reference") {
742
+ const refProp = prop as ReferenceProperty;
743
+ const targetCollection = collections.find(
744
+ c => c.slug === refProp.path || getTableName(c) === refProp.path
745
+ );
746
+ const column = resolveColumnName(propName, prop);
747
+ const type = getSqlColumnType(propName, prop, collection, collections);
748
+ const required = prop.validation?.required;
749
+
750
+ plans.push({
751
+ schema,
752
+ table,
753
+ column,
754
+ type,
755
+ foreignKey: targetCollection
756
+ ? foreignKeyPlan({
757
+ schema,
758
+ table,
759
+ column,
760
+ targetSchema: schemaOfCollection(targetCollection),
761
+ targetTable: bareTableName(getTableName(targetCollection)),
762
+ targetColumn: getPrimaryKeyName(targetCollection),
763
+ onDelete: required ? "CASCADE" : "SET NULL"
764
+ })
765
+ : undefined
766
+ });
767
+ }
768
+ }
769
+ }
770
+
771
+ return plans;
772
+ };
773
+
774
+ /**
775
+ * The junction tables a bundle's many-to-many relations imply.
776
+ *
777
+ * Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
778
+ * comes from, so a table created here always has policies planned for it — a
779
+ * junction with row-level security left off is readable and writable by every
780
+ * signed-in user, which is why the two must ship together.
781
+ */
782
+ export const planJunctionTables = (allCollections: CollectionConfig[]): JunctionTablePlan[] => {
783
+ const collections = relationalCollections(allCollections);
784
+ const plans: JunctionTablePlan[] = [];
785
+
786
+ for (const spec of resolveJunctionSpecs(collections).values()) {
787
+ const [source, target] = spec.endpoints;
788
+ // A junction column's default name is derived from the endpoint
789
+ // collection's slug — which is normally plural, so this is where the
790
+ // singularization change actually lands: a `categories` endpoint used
791
+ // to give `categorie_id` and now gives `category_id`. Recorded, not
792
+ // used, so boot-ensure can recognise a table built under the old rule.
793
+ const legacyFor = (endpoint: typeof source): string | undefined => {
794
+ const slug = toSnakeCase(endpoint.collection.slug ?? endpoint.collection.name ?? "");
795
+ const legacy = legacyForeignKeyName(slug);
796
+ const derived = endpoint.junctionColumn === generateForeignKeyName(slug);
797
+ return derived && legacy !== endpoint.junctionColumn ? legacy : undefined;
798
+ };
799
+ const columns = [
800
+ { name: source.junctionColumn, type: junctionKeyType(source.collection), legacyName: legacyFor(source) },
801
+ { name: target.junctionColumn, type: junctionKeyType(target.collection), legacyName: legacyFor(target) }
802
+ ];
803
+ // Every declaring side agrees on the edge's lifetime; the first one wins,
804
+ // as it does in the generator's walk.
805
+ const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
806
+
807
+ plans.push({
808
+ schema: spec.schema,
809
+ table: spec.table,
810
+ columns,
811
+ createTable:
812
+ `CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` +
813
+ columns.map(c => `"${c.name}" ${c.type} NOT NULL`).join(", ") +
814
+ `, PRIMARY KEY (${columns.map(c => `"${c.name}"`).join(", ")}));`,
815
+ foreignKeys: [source, target].map((endpoint, i) =>
816
+ foreignKeyPlan({
817
+ schema: spec.schema,
818
+ table: spec.table,
819
+ column: columns[i].name,
820
+ targetSchema: schemaOfCollection(endpoint.collection),
821
+ targetTable: bareTableName(getTableName(endpoint.collection)),
822
+ targetColumn: getPrimaryKeyName(endpoint.collection),
823
+ onDelete
824
+ })
825
+ )
826
+ });
827
+ }
828
+
829
+ return plans;
830
+ };
831
+
832
+ export interface CollectionPolicyPlan {
833
+ /** The table's schema (e.g. `public`, `rebase`). */
834
+ schema: string;
835
+ /** The bare table name, no schema prefix. */
836
+ table: string;
837
+ /** `schema.table` — matches the keys `readExistingSchema` returns. */
838
+ qualified: string;
839
+ /** `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` — locked by default. */
840
+ enableRls: string;
841
+ /** `DROP POLICY IF EXISTS` / `CREATE POLICY` statements, in order. */
842
+ policyStatements: string[];
843
+ }
844
+
845
+ /**
846
+ * The per-table RLS plan for the *declared* collections, as executable
847
+ * statements — what the managed runtime applies at boot so a freshly
848
+ * provisioned tenant database serves data instead of 401ing every read.
849
+ *
850
+ * Mirrors {@link generatePostgresPoliciesDdl} exactly (same
851
+ * `generatePolicyStatements`, same enable-RLS, same effective rules, same
852
+ * derived junction rules), so boot and `db push` produce identical policies from
853
+ * identical collections.
854
+ *
855
+ * Junction tables are included, and have to be: boot creates them now
856
+ * ({@link planJunctionTables}), and a junction with RLS left off is readable and
857
+ * writable by every signed-in user. A junction whose table is still absent is
858
+ * skipped by the applier, not planned away here.
859
+ */
860
+ export const planCollectionPolicies = (allCollections: CollectionConfig[]): CollectionPolicyPlan[] => {
861
+ // A store with no RLS gets no policies planned for it — `supportsRLS` is
862
+ // false for exactly the engines `relationalCollections` filters out.
863
+ const collections = relationalCollections(allCollections);
864
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
865
+ const plans: CollectionPolicyPlan[] = [];
866
+ const seen = new Set<string>();
867
+
868
+ for (const collection of collections) {
869
+ const tableName = getTableName(collection);
870
+ if (!tableName) continue;
871
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
872
+ const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
873
+ const qualified = `${schema}.${baseTableName}`;
874
+ if (seen.has(qualified)) continue;
875
+ seen.add(qualified);
876
+
877
+ const policyStatements: string[] = [];
878
+ for (const rule of getEffectiveSecurityRules(collection)) {
879
+ policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
880
+ }
881
+
882
+ plans.push({
883
+ schema,
884
+ table: baseTableName,
885
+ qualified,
886
+ enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
887
+ policyStatements
888
+ });
889
+ }
890
+
891
+ // Junctions are derived from `through` relations rather than declared, so
892
+ // the walk above never sees them.
893
+ for (const spec of resolveJunctionSpecs(collections).values()) {
894
+ const qualified = `${spec.schema}.${spec.table}`;
895
+ if (seen.has(qualified)) continue;
896
+ seen.add(qualified);
897
+
898
+ const junctionCollection = getJunctionCollectionConfig(spec);
899
+ const policyStatements: string[] = [];
900
+ for (const rule of getJunctionSecurityRules(spec)) {
901
+ policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
902
+ }
903
+
904
+ plans.push({
905
+ schema: spec.schema,
906
+ table: spec.table,
907
+ qualified,
908
+ enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
909
+ policyStatements
910
+ });
911
+ }
912
+
913
+ return plans;
914
+ };
915
+
916
+ export const generatePostgresPoliciesDdl = (allCollections: CollectionConfig[]): string => {
917
+ // Also the expectation `checkPolicyDrift` reconciles the database against,
918
+ // so a non-SQL collection filtered out here is one the drift report cannot
919
+ // invent a missing policy for.
920
+ const collections = relationalCollections(allCollections);
474
921
  let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
475
922
 
476
923
  const allTablesToGenerate = new Map<string, {