@rebasepro/server-postgres 0.13.0 → 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 (85) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/auth/services.d.ts +19 -0
  3. package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
  4. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  5. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  6. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  7. package/dist/cli-helpers.d.ts +57 -1
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
  12. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
  13. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
  14. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  15. package/dist/index.es.js +1365 -338
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  18. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  19. package/dist/schema/auth-schema.d.ts +102 -0
  20. package/dist/schema/auth-users-columns.d.ts +97 -0
  21. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  22. package/dist/schema/doctor.d.ts +23 -25
  23. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  24. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  25. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  26. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  27. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  28. package/dist/schema/search-column.d.ts +248 -0
  29. package/dist/security/rls-enforcement.d.ts +61 -5
  30. package/dist/services/FetchService.d.ts +34 -7
  31. package/dist/services/RelationService.d.ts +30 -0
  32. package/dist/services/collection-helpers.d.ts +26 -0
  33. package/dist/services/dataService.d.ts +5 -0
  34. package/dist/services/realtimeService.d.ts +131 -21
  35. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  36. package/dist/src-C_wvdMnl.js.map +1 -0
  37. package/dist/utils/drizzle-conditions.d.ts +124 -2
  38. package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
  39. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  40. package/package.json +9 -8
  41. package/src/PostgresBackendDriver.ts +172 -6
  42. package/src/PostgresBootstrapper.ts +55 -6
  43. package/src/auth/ensure-tables.ts +207 -86
  44. package/src/auth/services.ts +69 -5
  45. package/src/cli-helpers.ts +129 -10
  46. package/src/cli.ts +232 -30
  47. package/src/collections/validate-relations.ts +124 -17
  48. package/src/data-transformer.ts +120 -17
  49. package/src/history/ensure-history-table.ts +7 -0
  50. package/src/schema/auth-schema.ts +17 -1
  51. package/src/schema/auth-users-columns.ts +131 -0
  52. package/src/schema/doctor-cli.ts +12 -63
  53. package/src/schema/doctor-policy-checks.ts +105 -0
  54. package/src/schema/doctor.ts +135 -76
  55. package/src/schema/ensure-collection-tables.ts +374 -32
  56. package/src/schema/generate-drizzle-schema-logic.ts +132 -42
  57. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  58. package/src/schema/generate-postgres-ddl.ts +25 -2
  59. package/src/schema/generated-schema-staleness.ts +169 -0
  60. package/src/schema/introspect-db-logic.ts +66 -34
  61. package/src/schema/non-sql-collections.test.ts +131 -0
  62. package/src/schema/rls-bootstrap-sql.ts +288 -0
  63. package/src/schema/search-column.ts +643 -0
  64. package/src/security/anonymous-grants.test.ts +4 -2
  65. package/src/security/rls-enforcement.ts +149 -6
  66. package/src/services/BranchService.ts +5 -0
  67. package/src/services/FetchService.ts +175 -108
  68. package/src/services/PersistService.ts +38 -2
  69. package/src/services/RelationService.ts +110 -67
  70. package/src/services/channel-history.ts +14 -0
  71. package/src/services/channel-presence.ts +13 -0
  72. package/src/services/collection-helpers.ts +54 -1
  73. package/src/services/dataService.ts +5 -0
  74. package/src/services/realtimeService.ts +344 -79
  75. package/src/utils/drizzle-conditions.ts +365 -23
  76. package/src/utils/pg-error-utils.ts +8 -3
  77. package/src/websocket.ts +113 -16
  78. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  79. package/dist/policy-CeA1JcxP.js +0 -105
  80. package/dist/policy-CeA1JcxP.js.map +0 -1
  81. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  82. package/dist/src-DlPBctw_.js.map +0 -1
  83. package/dist/src-DoU9yPqq.js.map +0 -1
  84. package/dist/websocket-B2LsrINK.js.map +0 -1
  85. 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, resolveStringColumnLength } from "@rebasepro/common";
3
- import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName } 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
 
@@ -180,6 +195,11 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
180
195
  const mapProp = prop as MapProperty;
181
196
  return mapProp.columnType === "json" ? "JSON" : "JSONB";
182
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";
183
203
  case "array": {
184
204
  const arrayProp = prop as ArrayProperty;
185
205
  let colType = arrayProp.columnType;
@@ -231,19 +251,162 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
231
251
  return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "TEXT");
232
252
  }
233
253
  default:
234
- 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";
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
+ }
235
384
  }
385
+ return patterns;
236
386
  };
237
387
 
238
388
  export const generatePostgresDdl = async (
239
- collections: CollectionConfig[],
240
- options: { includePolicies?: boolean } = { includePolicies: true }
389
+ allCollections: CollectionConfig[],
390
+ options: { includePolicies?: boolean; includeSearch?: boolean } = {
391
+ includePolicies: true,
392
+ includeSearch: true
393
+ }
241
394
  ): Promise<string> => {
395
+ // Only the collections this engine stores. See `relationalCollections`.
396
+ const collections = relationalCollections(allCollections);
242
397
  let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
243
398
 
244
- // 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.
245
408
  const uniqueSchemas = Array.from(new Set([
246
- "auth",
409
+ REBASE_SCHEMA,
247
410
  ...collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
248
411
  ]));
249
412
  uniqueSchemas.forEach(schema => {
@@ -251,6 +414,32 @@ export const generatePostgresDdl = async (
251
414
  });
252
415
  if (uniqueSchemas.length > 0) ddl += "\n";
253
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
+
254
443
  // 2. Generate Enums
255
444
  //
256
445
  // The enum type name is derived from table + column, so two collections
@@ -318,6 +507,9 @@ export const generatePostgresDdl = async (
318
507
 
319
508
  // 3. Generate tables
320
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[] = [];
321
513
  // Policies are emitted after every CREATE TABLE, like the FK constraints:
322
514
  // a policy may reference other tables (a junction's derived policies always
323
515
  // reference both endpoints; `policy.existsIn` references a join table), and
@@ -355,8 +547,8 @@ export const generatePostgresDdl = async (
355
547
  ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
356
548
  ddl += `);\n\n`;
357
549
 
358
- fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
359
- 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()};`);
360
552
 
361
553
  if (options.includePolicies) {
362
554
  // Junction tables are generated tables like any other: locked by
@@ -414,7 +606,7 @@ export const generatePostgresDdl = async (
414
606
  if (required) colDef += " NOT NULL";
415
607
  columns.push(colDef);
416
608
 
417
- 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};`);
418
610
  } else if (prop.type === "reference") {
419
611
  const refProp = prop as ReferenceProperty;
420
612
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
@@ -436,10 +628,27 @@ export const generatePostgresDdl = async (
436
628
  if (required) colDef += " NOT NULL";
437
629
  columns.push(colDef);
438
630
 
439
- 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()};`);
440
632
  }
441
633
  } else {
442
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
+
443
652
  const colType = getSqlColumnType(propName, prop, collection, collections);
444
653
  let colDef = ` "${colName}" ${colType}`;
445
654
 
@@ -476,6 +685,40 @@ export const generatePostgresDdl = async (
476
685
  }
477
686
  });
478
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
+
479
722
  // Backwards compatibility: add default id primary key if missing
480
723
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
481
724
  if (!hasPk) {
@@ -509,6 +752,11 @@ export const generatePostgresDdl = async (
509
752
  ddl += fkStatements.join("\n") + "\n\n";
510
753
  }
511
754
 
755
+ if (indexStatements.length > 0) {
756
+ ddl += "-- Indexes\n";
757
+ ddl += indexStatements.join("\n") + "\n\n";
758
+ }
759
+
512
760
  if (policyStatements.length > 0) {
513
761
  ddl += "-- Row Level Security Policies\n";
514
762
  ddl += policyStatements.join("");
@@ -575,10 +823,31 @@ const schemaOfCollection = (collection: CollectionConfig): string =>
575
823
 
576
824
  const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
577
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
+
578
847
  const foreignKeyPlan = (
579
848
  args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
580
849
  ): ForeignKeyPlan => {
581
- const constraintName = `${args.table}_${args.column}_fkey`;
850
+ const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
582
851
  const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
583
852
  return {
584
853
  constraintName,
@@ -610,7 +879,8 @@ const foreignKeyPlan = (
610
879
  * is unknown yields the column without a constraint. Both mirror the generator
611
880
  * exactly — a divergence here is a schema fork between boot and `db push`.
612
881
  */
613
- export const planRelationalColumns = (collections: CollectionConfig[]): RelationalColumnPlan[] => {
882
+ export const planRelationalColumns = (allCollections: CollectionConfig[]): RelationalColumnPlan[] => {
883
+ const collections = relationalCollections(allCollections);
614
884
  const plans: RelationalColumnPlan[] = [];
615
885
 
616
886
  for (const collection of collections) {
@@ -703,7 +973,8 @@ export const planRelationalColumns = (collections: CollectionConfig[]): Relation
703
973
  * junction with row-level security left off is readable and writable by every
704
974
  * signed-in user, which is why the two must ship together.
705
975
  */
706
- export const planJunctionTables = (collections: CollectionConfig[]): JunctionTablePlan[] => {
976
+ export const planJunctionTables = (allCollections: CollectionConfig[]): JunctionTablePlan[] => {
977
+ const collections = relationalCollections(allCollections);
707
978
  const plans: JunctionTablePlan[] = [];
708
979
 
709
980
  for (const spec of resolveJunctionSpecs(collections).values()) {
@@ -780,7 +1051,10 @@ export interface CollectionPolicyPlan {
780
1051
  * writable by every signed-in user. A junction whose table is still absent is
781
1052
  * skipped by the applier, not planned away here.
782
1053
  */
783
- export const planCollectionPolicies = (collections: CollectionConfig[]): CollectionPolicyPlan[] => {
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);
784
1058
  const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
785
1059
  const plans: CollectionPolicyPlan[] = [];
786
1060
  const seen = new Set<string>();
@@ -833,7 +1107,11 @@ export const planCollectionPolicies = (collections: CollectionConfig[]): Collect
833
1107
  return plans;
834
1108
  };
835
1109
 
836
- export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
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);
837
1115
  let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
838
1116
 
839
1117
  const allTablesToGenerate = new Map<string, {
@@ -3,7 +3,11 @@ import * as fs from "fs";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
5
  import chokidar from "chokidar";
6
- import { generatePostgresDdl, generatePostgresPoliciesDdl } from "./generate-postgres-ddl-logic";
6
+ import {
7
+ generatePostgresDdl,
8
+ generatePostgresPoliciesDdl,
9
+ generatePostgresSearchDdl
10
+ } from "./generate-postgres-ddl-logic";
7
11
  import { CollectionConfig } from "@rebasepro/types";
8
12
  import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
9
13
 
@@ -29,8 +33,16 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
29
33
  // Sort collections by slug alphabetically to ensure deterministic DDL generation
30
34
  collections.sort((a, b) => a.slug.localeCompare(b.slug));
31
35
 
32
- const ddlContent = await generatePostgresDdl(collections, { includePolicies: false });
36
+ // `schema.sql` is Atlas's desired state, and it carries neither the RLS
37
+ // policies nor the search apparatus: Atlas manages neither, and in the
38
+ // search case cannot. Both are written beside it and applied by the CLI
39
+ // in their own right.
40
+ const ddlContent = await generatePostgresDdl(collections, {
41
+ includePolicies: false,
42
+ includeSearch: false
43
+ });
33
44
  const policiesContent = generatePostgresPoliciesDdl(collections);
45
+ const searchContent = generatePostgresSearchDdl(collections);
34
46
 
35
47
  if (outputPath) {
36
48
  const outputDir = path.dirname(outputPath);
@@ -41,6 +53,17 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
41
53
  const policiesPath = path.join(outputDir, "policies.sql");
42
54
  await fsPromises.writeFile(policiesPath, policiesContent);
43
55
  logger.info(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
56
+
57
+ // Removed when the last `search` block goes: the CLI applies this
58
+ // file whenever it exists, and a stale one would keep re-creating
59
+ // functions for collections that no longer want them.
60
+ const searchPath = path.join(outputDir, "search.sql");
61
+ if (searchContent) {
62
+ await fsPromises.writeFile(searchPath, searchContent);
63
+ logger.info(`✅ PostgreSQL search DDL generated successfully at ${searchPath}`);
64
+ } else if (fs.existsSync(searchPath)) {
65
+ await fsPromises.rm(searchPath);
66
+ }
44
67
  } else {
45
68
  logger.info("✅ PostgreSQL DDL generated successfully.");
46
69
  logger.info(String(ddlContent));
@@ -0,0 +1,169 @@
1
+ import { CollectionConfig } from "@rebasepro/types";
2
+ import { getTableName, resolveCollectionRelations, relationalCollections } from "@rebasepro/common";
3
+ import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
4
+
5
+ /**
6
+ * Notice a generated Drizzle schema that a *library upgrade* invalidated.
7
+ *
8
+ * `rebase dev` already watches `config/collections` and warns when a collection
9
+ * file changes. That covers drift the developer caused. It cannot cover this one,
10
+ * because nothing the developer owns changed: 0.13 derives `category_id` where
11
+ * 0.12 derived `categorie_id`, from the same unedited collection. The watcher
12
+ * never fires, and `backend/src/schema.generated.ts` quietly stops describing the
13
+ * schema the runtime expects.
14
+ *
15
+ * The consequence is not cosmetic. Boot-ensure renames the column in the
16
+ * database, then relation validation reads the stale module and refuses to
17
+ * start — on that boot and every boot after it, because the rename is already
18
+ * applied and will not be attempted again.
19
+ *
20
+ * Deliberately narrow: this answers "does the generated schema name a foreign key
21
+ * the way the previous rule did", not "is this file what we would generate now".
22
+ * The wide question would report every whitespace change in the generator as a
23
+ * fatal staleness, and a check that cries wolf gets switched off.
24
+ */
25
+
26
+ /** One column the generated schema names under the pre-0.13 rule. */
27
+ export interface LegacyForeignKeyName {
28
+ /** Table whose column declaration is stale. */
29
+ table: string;
30
+ /** The name the generated schema declares. */
31
+ legacy: string;
32
+ /** The name this release derives, and which the database now carries. */
33
+ current: string;
34
+ /** `<collection>.<relation>` that derives it, for the message. */
35
+ relation: string;
36
+ }
37
+
38
+ /**
39
+ * The slice of the generated source declaring one table.
40
+ *
41
+ * Scoping matters: two junctions in one file can carry columns of the same name,
42
+ * and a whole-file match would attribute a stale column to whichever table the
43
+ * reader looks at first. Returns "" when the table is not in the file at all,
44
+ * which is not staleness — it is a table the generator has not been asked about.
45
+ */
46
+ function tableBlock(source: string, table: string): string {
47
+ const start = source.indexOf(`pgTable("${table}"`);
48
+ if (start === -1) return "";
49
+ // Generated files put every table in its own `export const`, so the next one
50
+ // is the end of this block. No brace counting, nothing to get wrong.
51
+ const next = source.indexOf("\nexport ", start);
52
+ return next === -1 ? source.slice(start) : source.slice(start, next);
53
+ }
54
+
55
+ /**
56
+ * Whether a block *declares* the column, rather than merely mentioning it.
57
+ *
58
+ * A comment explaining the rename, or a policy expression naming the old column,
59
+ * must not read as a declaration — otherwise regenerating the file would not
60
+ * clear the finding and the check would be permanently red.
61
+ */
62
+ function declaresColumn(block: string, column: string): boolean {
63
+ // `categorie_id: integer("categorie_id")` — the Drizzle column shape. The key
64
+ // and the string argument agree in generated output, so requiring both is
65
+ // both precise and cheap.
66
+ const key = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+ return new RegExp(`(^|[\\s,{])${key}\\s*:\\s*\\w+\\(\\s*["']${key}["']`, "m").test(block);
68
+ }
69
+
70
+ /**
71
+ * @param generatedSource contents of `backend/src/schema.generated.ts`
72
+ * @param collections the project's collections, as this release reads them
73
+ */
74
+ export function findLegacyForeignKeyNames(
75
+ generatedSource: string,
76
+ collections: CollectionConfig[]
77
+ ): LegacyForeignKeyName[] {
78
+ const found: LegacyForeignKeyName[] = [];
79
+ const seen = new Set<string>();
80
+
81
+ /**
82
+ * Report `wanted` as stale when the generated schema declares what the
83
+ * previous rule would have derived from `source` instead.
84
+ *
85
+ * The `wanted !== current` guard is what honours an explicitly named column.
86
+ * An author who pinned `categorie_id` has said so on the relation, so the
87
+ * generated file agreeing with them is correct, not stale — and the rename
88
+ * note documents exactly that opt-out.
89
+ */
90
+ const consider = (
91
+ table: string,
92
+ wanted: string,
93
+ sourceName: string | undefined,
94
+ relation: string
95
+ ): void => {
96
+ if (!table || !wanted || !sourceName) return;
97
+
98
+ const current = generateForeignKeyName(sourceName);
99
+ const legacy = legacyForeignKeyName(sourceName);
100
+ if (legacy === current) return; // this name never moved
101
+ if (wanted !== current) return; // pinned by the author, or unrelated
102
+
103
+ const block = tableBlock(generatedSource, table);
104
+ if (!block) return;
105
+ if (!declaresColumn(block, legacy)) return;
106
+ if (declaresColumn(block, current)) return; // already regenerated
107
+
108
+ const key = `${table}.${legacy}`;
109
+ if (seen.has(key)) return;
110
+ seen.add(key);
111
+ found.push({ table, legacy, current, relation });
112
+ };
113
+
114
+ // Same rule as the generator whose output this reads: a collection that
115
+ // gets no table cannot have named a column in it. Inert in practice today —
116
+ // the finding also requires the generated file to declare the table — but
117
+ // the guard is what keeps that true when a non-SQL collection's slug happens
118
+ // to match a SQL one's table.
119
+ for (const collection of relationalCollections(collections)) {
120
+ const sourceTable = getTableName(collection);
121
+
122
+ for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
123
+ const at = `${collection.slug}.${name}`;
124
+
125
+ let target: CollectionConfig | undefined;
126
+ try {
127
+ target = relation.target?.();
128
+ } catch {
129
+ // A throwing target thunk is its own defect, reported at boot by
130
+ // `validate-relations`. Nothing to say about its column names.
131
+ continue;
132
+ }
133
+
134
+ switch (relation.kind) {
135
+ case "belongsTo":
136
+ consider(sourceTable, relation.localKey, relation.relationName, at);
137
+ break;
138
+
139
+ case "hasOne":
140
+ case "hasMany":
141
+ if (target) {
142
+ consider(getTableName(target), relation.foreignKeyOnTarget, collection.slug, at);
143
+ }
144
+ break;
145
+
146
+ case "manyToMany":
147
+ consider(relation.through.table, relation.through.sourceColumn, collection.slug, at);
148
+ if (target) {
149
+ consider(relation.through.table, relation.through.targetColumn, target.slug, at);
150
+ }
151
+ break;
152
+
153
+ default:
154
+ // `via` joins are written by hand — there is no derived name
155
+ // for the rule change to have moved.
156
+ break;
157
+ }
158
+ }
159
+ }
160
+
161
+ return found;
162
+ }
163
+
164
+ /** One-line summary for a log or a CLI notice. */
165
+ export function describeLegacyForeignKeyNames(found: LegacyForeignKeyName[]): string {
166
+ return found
167
+ .map(f => ` • ${f.table}.${f.legacy} → ${f.current} (${f.relation})`)
168
+ .join("\n");
169
+ }