@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. 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
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";
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,21 +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
+ );
235
265
  }
236
266
  };
237
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
+ }
384
+ }
385
+ return patterns;
386
+ };
387
+
238
388
  export const generatePostgresDdl = async (
239
389
  allCollections: CollectionConfig[],
240
- options: { includePolicies?: boolean } = { includePolicies: true }
390
+ options: { includePolicies?: boolean; includeSearch?: boolean } = {
391
+ includePolicies: true,
392
+ includeSearch: true
393
+ }
241
394
  ): Promise<string> => {
242
395
  // Only the collections this engine stores. See `relationalCollections`.
243
396
  const collections = relationalCollections(allCollections);
244
397
  let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
245
398
 
246
- // 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.
247
408
  const uniqueSchemas = Array.from(new Set([
248
- "auth",
409
+ REBASE_SCHEMA,
249
410
  ...collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
250
411
  ]));
251
412
  uniqueSchemas.forEach(schema => {
@@ -253,6 +414,32 @@ export const generatePostgresDdl = async (
253
414
  });
254
415
  if (uniqueSchemas.length > 0) ddl += "\n";
255
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
+
256
443
  // 2. Generate Enums
257
444
  //
258
445
  // The enum type name is derived from table + column, so two collections
@@ -320,6 +507,9 @@ export const generatePostgresDdl = async (
320
507
 
321
508
  // 3. Generate tables
322
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[] = [];
323
513
  // Policies are emitted after every CREATE TABLE, like the FK constraints:
324
514
  // a policy may reference other tables (a junction's derived policies always
325
515
  // reference both endpoints; `policy.existsIn` references a join table), and
@@ -357,8 +547,8 @@ export const generatePostgresDdl = async (
357
547
  ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
358
548
  ddl += `);\n\n`;
359
549
 
360
- fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
361
- 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()};`);
362
552
 
363
553
  if (options.includePolicies) {
364
554
  // Junction tables are generated tables like any other: locked by
@@ -416,7 +606,7 @@ export const generatePostgresDdl = async (
416
606
  if (required) colDef += " NOT NULL";
417
607
  columns.push(colDef);
418
608
 
419
- 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};`);
420
610
  } else if (prop.type === "reference") {
421
611
  const refProp = prop as ReferenceProperty;
422
612
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
@@ -438,10 +628,27 @@ export const generatePostgresDdl = async (
438
628
  if (required) colDef += " NOT NULL";
439
629
  columns.push(colDef);
440
630
 
441
- 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()};`);
442
632
  }
443
633
  } else {
444
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
+
445
652
  const colType = getSqlColumnType(propName, prop, collection, collections);
446
653
  let colDef = ` "${colName}" ${colType}`;
447
654
 
@@ -478,6 +685,40 @@ export const generatePostgresDdl = async (
478
685
  }
479
686
  });
480
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
+
481
722
  // Backwards compatibility: add default id primary key if missing
482
723
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
483
724
  if (!hasPk) {
@@ -511,6 +752,11 @@ export const generatePostgresDdl = async (
511
752
  ddl += fkStatements.join("\n") + "\n\n";
512
753
  }
513
754
 
755
+ if (indexStatements.length > 0) {
756
+ ddl += "-- Indexes\n";
757
+ ddl += indexStatements.join("\n") + "\n\n";
758
+ }
759
+
514
760
  if (policyStatements.length > 0) {
515
761
  ddl += "-- Row Level Security Policies\n";
516
762
  ddl += policyStatements.join("");
@@ -577,10 +823,31 @@ const schemaOfCollection = (collection: CollectionConfig): string =>
577
823
 
578
824
  const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
579
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
+
580
847
  const foreignKeyPlan = (
581
848
  args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
582
849
  ): ForeignKeyPlan => {
583
- const constraintName = `${args.table}_${args.column}_fkey`;
850
+ const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
584
851
  const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
585
852
  return {
586
853
  constraintName,
@@ -3,15 +3,20 @@ 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
- import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
12
+ import { loadCollectionsFromDirectory } from "@rebasepro/server";
13
+ import { out, outError } from "../cli-output";
9
14
 
10
15
 
11
16
  const runGeneration = async (collectionsFilePath?: string, outputPath?: string) => {
12
17
  try {
13
18
  if (!collectionsFilePath) {
14
- logger.error("Error: No collections file path provided. Skipping schema generation.");
19
+ outError("Error: No collections file path provided. Skipping schema generation.");
15
20
  return;
16
21
  }
17
22
 
@@ -29,27 +34,46 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
29
34
  // Sort collections by slug alphabetically to ensure deterministic DDL generation
30
35
  collections.sort((a, b) => a.slug.localeCompare(b.slug));
31
36
 
32
- const ddlContent = await generatePostgresDdl(collections, { includePolicies: false });
37
+ // `schema.sql` is Atlas's desired state, and it carries neither the RLS
38
+ // policies nor the search apparatus: Atlas manages neither, and in the
39
+ // search case cannot. Both are written beside it and applied by the CLI
40
+ // in their own right.
41
+ const ddlContent = await generatePostgresDdl(collections, {
42
+ includePolicies: false,
43
+ includeSearch: false
44
+ });
33
45
  const policiesContent = generatePostgresPoliciesDdl(collections);
46
+ const searchContent = generatePostgresSearchDdl(collections);
34
47
 
35
48
  if (outputPath) {
36
49
  const outputDir = path.dirname(outputPath);
37
50
  await fsPromises.mkdir(outputDir, { recursive: true });
38
51
  await fsPromises.writeFile(outputPath, ddlContent);
39
- logger.info(`✅ PostgreSQL DDL generated successfully at ${outputPath}`);
52
+ out(`✅ PostgreSQL DDL generated successfully at ${outputPath}`);
40
53
 
41
54
  const policiesPath = path.join(outputDir, "policies.sql");
42
55
  await fsPromises.writeFile(policiesPath, policiesContent);
43
- logger.info(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
56
+ out(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
57
+
58
+ // Removed when the last `search` block goes: the CLI applies this
59
+ // file whenever it exists, and a stale one would keep re-creating
60
+ // functions for collections that no longer want them.
61
+ const searchPath = path.join(outputDir, "search.sql");
62
+ if (searchContent) {
63
+ await fsPromises.writeFile(searchPath, searchContent);
64
+ out(`✅ PostgreSQL search DDL generated successfully at ${searchPath}`);
65
+ } else if (fs.existsSync(searchPath)) {
66
+ await fsPromises.rm(searchPath);
67
+ }
44
68
  } else {
45
- logger.info("✅ PostgreSQL DDL generated successfully.");
46
- logger.info(String(ddlContent));
47
- logger.info("\n✅ PostgreSQL Policies DDL generated successfully.");
48
- logger.info(String(policiesContent));
69
+ out("✅ PostgreSQL DDL generated successfully.");
70
+ out(String(ddlContent));
71
+ out("\n✅ PostgreSQL Policies DDL generated successfully.");
72
+ out(String(policiesContent));
49
73
  }
50
74
 
51
75
  } catch (error) {
52
- logger.error("Error generating DDL schema", { error: error });
76
+ outError(`Error generating DDL schema: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
53
77
  }
54
78
  };
55
79
 
@@ -63,7 +87,7 @@ const main = () => {
63
87
  const watch = process.argv.includes("--watch");
64
88
 
65
89
  if (!collectionsFilePath) {
66
- logger.info("Usage: ts-node generate-postgres-ddl.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
90
+ out("Usage: ts-node generate-postgres-ddl.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
67
91
  return;
68
92
  }
69
93
 
@@ -71,14 +95,14 @@ const main = () => {
71
95
  const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : undefined;
72
96
 
73
97
  if (watch) {
74
- logger.info(`Watching for changes in ${resolvedPath}...`);
98
+ out(`Watching for changes in ${resolvedPath}...`);
75
99
  const watcher = chokidar.watch(resolvedPath, {
76
100
  persistent: true,
77
101
  ignoreInitial: false
78
102
  });
79
103
 
80
104
  watcher.on("all", (event, filePath) => {
81
- logger.info(`[${event}] ${filePath}. Regenerating DDL schema...`);
105
+ out(`[${event}] ${filePath}. Regenerating DDL schema...`);
82
106
  runGeneration(resolvedPath, resolvedOutputPath);
83
107
  });
84
108
  } else {
@@ -1,5 +1,5 @@
1
1
  import { CollectionConfig } from "@rebasepro/types";
2
- import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
2
+ import { getTableName, resolveCollectionRelations, relationalCollections } from "@rebasepro/common";
3
3
  import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
4
4
 
5
5
  /**
@@ -60,11 +60,13 @@ function tableBlock(source: string, table: string): string {
60
60
  * clear the finding and the check would be permanently red.
61
61
  */
62
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);
63
+ // `categorieId: integer("categorie_id")` — the Drizzle column shape. Only
64
+ // the string argument is the column; the key beside it is the *wire* name
65
+ // and no longer agrees with it (`authorId: integer("author_id")`). Matching
66
+ // both, as this did, made every camelCased key look like a column the
67
+ // generated file did not declare.
68
+ const literal = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
69
+ return new RegExp(`(^|[\\s,{])[\\w$"']+\\s*:\\s*\\w+\\(\\s*["']${literal}["']`, "m").test(block);
68
70
  }
69
71
 
70
72
  /**
@@ -111,7 +113,12 @@ export function findLegacyForeignKeyNames(
111
113
  found.push({ table, legacy, current, relation });
112
114
  };
113
115
 
114
- for (const collection of collections) {
116
+ // Same rule as the generator whose output this reads: a collection that
117
+ // gets no table cannot have named a column in it. Inert in practice today —
118
+ // the finding also requires the generated file to declare the table — but
119
+ // the guard is what keeps that true when a non-SQL collection's slug happens
120
+ // to match a SQL one's table.
121
+ for (const collection of relationalCollections(collections)) {
115
122
  const sourceTable = getTableName(collection);
116
123
 
117
124
  for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
@@ -15,7 +15,14 @@ export function inferPropertyFromData(
15
15
  pgDataType: string,
16
16
  currentPropType: string,
17
17
  sampleValues: unknown[],
18
- isPk: boolean
18
+ isPk: boolean,
19
+ /**
20
+ * False when generating for a project without `@rebasepro/admin-types`, where
21
+ * `BaseProperty` declares no `admin` field and the block below would not
22
+ * compile. The type-level inferences (`propType`, `url`, `storage`) are
23
+ * unaffected — only the form-widget hints are dropped.
24
+ */
25
+ emitAdmin = true
19
26
  ): InferenceResult {
20
27
  const result: InferenceResult = {};
21
28
  const extraLines: string[] = [];
@@ -239,7 +246,7 @@ export function inferPropertyFromData(
239
246
  }
240
247
  }
241
248
 
242
- if (adminOptions.length > 0) {
249
+ if (emitAdmin && adminOptions.length > 0) {
243
250
  extraLines.push(` admin: {\n${adminOptions.map((o) => ` ${o}`).join(",\n")}\n }`);
244
251
  }
245
252