@rebasepro/server-postgres 0.13.1-canary.g18cfeb7 → 0.13.1-canary.g249daa1

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 (35) hide show
  1. package/dist/{auth-users-columns-Do9mw5Y5.js → auth-users-columns-Dt9g712t.js} +405 -7
  2. package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
  3. package/dist/backup-service-Bww-Lg0s.js.map +1 -1
  4. package/dist/cli-helpers.d.ts +56 -0
  5. package/dist/{ensure-collection-policies-DMUdRQdM.js → ensure-collection-policies-CwYUliAa.js} +2 -2
  6. package/dist/{ensure-collection-policies-DMUdRQdM.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
  7. package/dist/{ensure-collection-tables-DSIxvLLD.js → ensure-collection-tables-DRxaUG96.js} +34 -7
  8. package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
  9. package/dist/index.es.js +234 -26
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/schema/ensure-collection-tables.d.ts +1 -1
  12. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  13. package/dist/schema/search-column.d.ts +199 -0
  14. package/dist/services/FetchService.d.ts +15 -0
  15. package/dist/services/dataService.d.ts +1 -0
  16. package/dist/services/realtimeService.d.ts +2 -0
  17. package/dist/src-C_wvdMnl.js.map +1 -1
  18. package/dist/utils/drizzle-conditions.d.ts +71 -2
  19. package/dist/websocket-D0TBU3ia.js.map +1 -1
  20. package/package.json +6 -6
  21. package/src/cli-helpers.ts +107 -1
  22. package/src/cli.ts +57 -0
  23. package/src/data-transformer.ts +18 -0
  24. package/src/schema/ensure-collection-tables.ts +77 -1
  25. package/src/schema/generate-drizzle-schema-logic.ts +25 -1
  26. package/src/schema/generate-postgres-ddl-logic.ts +176 -9
  27. package/src/schema/generate-postgres-ddl.ts +25 -2
  28. package/src/schema/search-column.ts +558 -0
  29. package/src/services/FetchService.ts +138 -15
  30. package/src/services/RelationService.ts +2 -1
  31. package/src/services/dataService.ts +1 -0
  32. package/src/services/realtimeService.ts +10 -4
  33. package/src/utils/drizzle-conditions.ts +223 -2
  34. package/dist/auth-users-columns-Do9mw5Y5.js.map +0 -1
  35. package/dist/ensure-collection-tables-DSIxvLLD.js.map +0 -1
package/src/cli.ts CHANGED
@@ -12,6 +12,10 @@ import {
12
12
  getTableIncludes,
13
13
  getDevDatabaseUrl,
14
14
  ensureDevDatabaseExists,
15
+ applySearchDdl,
16
+ getSearchExcludes,
17
+ readSearchDdl,
18
+ seedDevDatabaseSearchHelpers,
15
19
  getTableExcludes,
16
20
  ExcludeIntrospectionError,
17
21
  promptConfirm
@@ -70,6 +74,13 @@ export async function runPluginCommand(args: string[]) {
70
74
  }
71
75
  }
72
76
 
77
+ /** The migration files on disk, sorted, or none if the directory is absent. */
78
+ function listMigrationFiles(): string[] {
79
+ const dir = path.resolve(process.cwd(), "drizzle", "migrations");
80
+ if (!fs.existsSync(dir)) return [];
81
+ return fs.readdirSync(dir).filter(f => f.endsWith(".sql")).sort();
82
+ }
83
+
73
84
  async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
74
85
  const VALID_ACTIONS = ["push", "generate", "migrate", "branch", "backup", "restore", "backups"];
75
86
  if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
@@ -116,7 +127,9 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
116
127
  logger.info(chalk.gray(" Step 2/2: Generating SQL migration files with Atlas..."));
117
128
  logger.info("");
118
129
  const migrationName = argsList._[0] || "migration";
130
+ const migrationsBefore = listMigrationFiles();
119
131
  await runAtlas("migrate", ["diff", migrationName, "--dir", "file://drizzle/migrations", "--to", "file://drizzle/schema.sql"], collectionsPath);
132
+ const wroteNewMigration = listMigrationFiles().length > migrationsBefore.length;
120
133
 
121
134
  // Post-process the newest migration file
122
135
  try {
@@ -137,6 +150,33 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
137
150
  /CREATE SCHEMA (?!IF NOT EXISTS)("[^"]+");/g,
138
151
  "CREATE SCHEMA IF NOT EXISTS $1;"
139
152
  );
153
+ // Atlas never writes the search objects into a migration —
154
+ // it is not shown them — so a migration replayed against a
155
+ // fresh database would build every table except the part
156
+ // that makes search work. Appending, because these are
157
+ // `ALTER TABLE ... ADD COLUMN` over the tables the
158
+ // migration just created.
159
+ //
160
+ // Only into a migration Atlas just wrote. The file picked
161
+ // here is simply the newest one, which when there was no
162
+ // diff is a migration that has already run in production:
163
+ // editing it changes a hash Atlas has recorded, and the
164
+ // appended SQL would never be applied anywhere.
165
+ const searchContent = readSearchDdl();
166
+ if (searchContent && wroteNewMigration) {
167
+ migrationContent = `${migrationContent}\n\n${searchContent}`;
168
+ logger.info(chalk.gray(" ✓ Appended search DDL to the migration"));
169
+ } else if (searchContent) {
170
+ // Reachable whenever a `search` block is the *only*
171
+ // thing that changed: Atlas cannot see one, so it finds
172
+ // nothing to diff and writes no file.
173
+ logger.info(chalk.gray(
174
+ " ℹ Search DDL not written to a migration — this change produced none.\n" +
175
+ " It is applied by `rebase db push`, and at boot by the schema ensure.\n" +
176
+ " For a migration-only deployment, add drizzle/search.sql to a migration by hand."
177
+ ));
178
+ }
179
+
140
180
  fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
141
181
 
142
182
  // Append RLS policies, preceded by the RLS bootstrap so the
@@ -234,6 +274,10 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
234
274
 
235
275
  if (databaseUrl) {
236
276
  await ensureAuthTables(databaseUrl, collectionsPath);
277
+ // After the tables exist and before the policies: the search
278
+ // column is `ALTER TABLE ... ADD COLUMN`, and a policy may
279
+ // reference the table it is added to.
280
+ await applySearchDdl(databaseUrl);
237
281
  await applyPolicies(databaseUrl);
238
282
  await reconcilePolicies(databaseUrl, collectionsPath);
239
283
  await ensureRlsUserRole(databaseUrl);
@@ -759,6 +803,9 @@ async function runAtlas(
759
803
 
760
804
  const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
761
805
  await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
806
+ if (collectionsPath) {
807
+ await seedDevDatabaseSearchHelpers(devDatabaseUrl, collectionsPath);
808
+ }
762
809
 
763
810
  // Atlas speaks libpq, which rejects the `sslmode=no-verify` that
764
811
  // node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
@@ -785,6 +832,16 @@ async function runAtlas(
785
832
  }
786
833
  }
787
834
 
835
+ // Search objects are Rebase's, not Atlas's — on both paths. `schema apply`
836
+ // would drop them as absent from the desired state; `migrate diff` would
837
+ // write that same drop into the next migration file, where it would sit
838
+ // waiting to be applied later.
839
+ if (collectionsPath && (args.includes("apply") || args.includes("diff"))) {
840
+ for (const exc of await getSearchExcludes(collectionsPath)) {
841
+ atlasArgs.push("--exclude", exc);
842
+ }
843
+ }
844
+
788
845
  if (domain === "schema" && args.includes("apply") && collectionsPath) {
789
846
  // Fail CLOSED: the exclude list is the only thing shielding
790
847
  // non-collection tables from the auto-approved apply. If we can't
@@ -685,6 +685,15 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
685
685
  * from the result (used by `normalizeDbValues` where
686
686
  * Drizzle's relational API already hydrates them).
687
687
  */
688
+ /**
689
+ * Keys a query computes and attaches to a row, rather than reads from a column.
690
+ *
691
+ * An explicit set rather than a `_` prefix rule: a user's column may perfectly
692
+ * well be called `_internal`, and passing it through here would put a value on
693
+ * the row that no property describes and nothing downstream knows how to type.
694
+ */
695
+ const QUERY_METADATA_KEYS = new Set(["_score", "_distance", "_matches"]);
696
+
688
697
  function normalizeScalarValues<M extends Record<string, unknown>>(
689
698
  data: M,
690
699
  properties: Properties,
@@ -709,6 +718,15 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
709
718
  continue;
710
719
  }
711
720
 
721
+ // Query-computed metadata, which by definition is not a column and so
722
+ // has no property to be found. Dropped here until now — which meant
723
+ // `_distance` was computed for every vector search on this path and
724
+ // then discarded before the caller ever saw it.
725
+ if (QUERY_METADATA_KEYS.has(key)) {
726
+ result[key] = value;
727
+ continue;
728
+ }
729
+
712
730
  const property = properties[key as keyof M] as Property;
713
731
  if (!property) continue; // Skip DB columns not defined in properties
714
732
 
@@ -28,6 +28,16 @@
28
28
  import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
29
29
  import { getTableName, relationalCollections } from "@rebasepro/common";
30
30
  import { logger } from "@rebasepro/server";
31
+ import {
32
+ assertSearchIsPostgresOnly,
33
+ buildSearchColumnSpec,
34
+ searchExtensionStatements,
35
+ searchHelperFunctions,
36
+ searchIndexStatements,
37
+ SEARCH_TEXT_FN,
38
+ SEARCH_UNACCENT_FN,
39
+ type SearchColumnSpec
40
+ } from "./search-column";
31
41
  import {
32
42
  getSqlColumnType,
33
43
  resolveColumnName,
@@ -83,7 +93,8 @@ export interface ExistingSchema {
83
93
  }
84
94
 
85
95
  export interface EnsureAction {
86
- kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column";
96
+ kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
97
+ | "create-extension" | "create-function" | "create-index";
87
98
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
88
99
  target: string;
89
100
  sql: string;
@@ -181,6 +192,10 @@ export function planCollectionSchemaEnsure(
181
192
  // Firestore collection is not a harmless extra: the app keeps reading
182
193
  // documents from Firestore while an empty table with the same name accretes
183
194
  // policies and shows up in every drift report.
195
+ // Before the filter, deliberately: a `search` block on a collection this
196
+ // engine does not store would otherwise be dropped here without a word.
197
+ assertSearchIsPostgresOnly(allCollections);
198
+
184
199
  const collections = relationalCollections(allCollections);
185
200
  const actions: EnsureAction[] = [];
186
201
  const plannedEnums = new Set<string>();
@@ -200,6 +215,34 @@ export function planCollectionSchemaEnsure(
200
215
  }
201
216
  }
202
217
 
218
+ // 1b. Search support, for collections that declared a `search` block.
219
+ //
220
+ // Before the tables, because a generated column's expression is
221
+ // resolved when the column is created: a table whose search column
222
+ // calls `rebase_search_text` cannot be added before that function
223
+ // exists. Both forms are idempotent, so a boot against a database that
224
+ // already has them plans nothing.
225
+ const searchSpecs = collections
226
+ .map(c => buildSearchColumnSpec(c))
227
+ .filter((spec): spec is SearchColumnSpec => spec !== undefined);
228
+
229
+ const plannedExtensions = new Set<string>();
230
+ for (const spec of searchSpecs) {
231
+ for (const statement of searchExtensionStatements(spec)) {
232
+ if (plannedExtensions.has(statement)) continue;
233
+ plannedExtensions.add(statement);
234
+ actions.push({ kind: "create-extension", target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""), sql: statement });
235
+ }
236
+ }
237
+ const plannedFunctions = new Set<string>();
238
+ for (const spec of searchSpecs) {
239
+ for (const statement of searchHelperFunctions(spec)) {
240
+ if (plannedFunctions.has(statement)) continue;
241
+ plannedFunctions.add(statement);
242
+ actions.push({ kind: "create-function", target: statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN, sql: statement });
243
+ }
244
+ }
245
+
203
246
  // 2. Missing tables. Only the identity column is created here; every other
204
247
  // column is added by step 3, so a new table and an existing table that
205
248
  // gained a field travel the exact same code path. One way to build a
@@ -375,6 +418,22 @@ export function planCollectionSchemaEnsure(
375
418
  }
376
419
  }
377
420
 
421
+ // 3aa. The generated search columns.
422
+ //
423
+ // Adding a STORED generated column rewrites the table, which on a large
424
+ // one is not free — but it is the same additive shape as every other
425
+ // column here, and the alternative (leaving it out until someone runs a
426
+ // migration) is a declared `search` block that silently does nothing.
427
+ for (const spec of searchSpecs) {
428
+ const key = `${spec.schema}.${spec.table}`;
429
+ addColumn(key, spec.schema, spec.table, spec.column,
430
+ `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`);
431
+ if (spec.fuzzy) {
432
+ addColumn(key, spec.schema, spec.table, spec.fuzzy.column,
433
+ `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`);
434
+ }
435
+ }
436
+
378
437
  // 3b. A junction that already existed, but is short a column. One created
379
438
  // above already carries both — unlike a collection table, whose CREATE
380
439
  // declares only the identity column — so re-listing them would log two
@@ -422,6 +481,23 @@ export function planCollectionSchemaEnsure(
422
481
  });
423
482
  }
424
483
 
484
+ // 5. Search indexes, after everything — the column has to exist, and this is
485
+ // the one step that runs against a populated table for real work.
486
+ //
487
+ // CONCURRENTLY: a plain CREATE INDEX takes a lock that blocks writes for
488
+ // the duration of the build, which on a live table is an outage. Each
489
+ // statement here is issued on its own, outside any transaction, which is
490
+ // the condition CONCURRENTLY requires.
491
+ for (const spec of searchSpecs) {
492
+ for (const statement of searchIndexStatements(spec)) {
493
+ actions.push({
494
+ kind: "create-index",
495
+ target: `${spec.schema}.${spec.table}`,
496
+ sql: statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
497
+ });
498
+ }
499
+ }
500
+
425
501
  return { actions, statements: actions.map(a => a.sql), legacyForeignKeys };
426
502
  }
427
503
 
@@ -1,5 +1,6 @@
1
1
  import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/collection-helpers";
3
+ import { buildSearchColumnSpec } from "./search-column";
3
4
  import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
4
5
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
5
6
  import { logger } from "@rebasepro/server";
@@ -479,11 +480,15 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
479
480
  )
480
481
  );
481
482
 
483
+ // drizzle ships no `tsvector` builder, so an opted-in search column reaches
484
+ // the schema the same way `bytea` does — through `customType`.
485
+ const hasSearch = collections.some(c => buildSearchColumnSpec(c) !== undefined);
486
+
482
487
  // Always import pgPolicy and sql — RLS is enabled on every table (secure by default)
483
488
  const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
484
489
  if (hasUuid) pgCoreImports.push("uuid");
485
490
  if (hasVector) pgCoreImports.push("vector");
486
- if (hasBinary) pgCoreImports.push("customType");
491
+ if (hasBinary || hasSearch) pgCoreImports.push("customType");
487
492
 
488
493
  const uniqueSchemas = Array.from(new Set(
489
494
  collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
@@ -632,6 +637,25 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
632
637
 
633
638
  });
634
639
 
640
+ // The opt-in search column. Declared with its real generation
641
+ // expression rather than as a bare custom type: `schema.generated.ts`
642
+ // is what a developer running drizzle-kit themselves diffs against,
643
+ // and a column declared without its expression reads to drizzle-kit
644
+ // as one it should alter.
645
+ const searchSpec = buildSearchColumnSpec(collection);
646
+ if (searchSpec) {
647
+ columns.add(
648
+ ` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}")` +
649
+ `.generatedAlwaysAs(sql\`${searchSpec.expression}\`)`
650
+ );
651
+ if (searchSpec.fuzzy) {
652
+ columns.add(
653
+ ` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}")` +
654
+ `.generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`
655
+ );
656
+ }
657
+ }
658
+
635
659
  // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
636
660
  // We should generate a basic id column if one was completely omitted.
637
661
  const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
@@ -1,7 +1,18 @@
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
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
+ searchIndexNames,
14
+ type SearchColumnSpec
15
+ } from "./search-column";
5
16
  import { REBASE_SCHEMA } from "@rebasepro/types";
6
17
 
7
18
  // --- Helper Functions ---
@@ -237,9 +248,122 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
237
248
  }
238
249
  };
239
250
 
251
+ /**
252
+ * Everything a `search` block needs, as a file Rebase applies itself.
253
+ *
254
+ * Search is the one part of the schema Atlas does not own. Two independent
255
+ * reasons, and either alone would be enough:
256
+ *
257
+ * 1. Its free tier refuses to *parse* a desired-state file that so much as
258
+ * contains a function — "functions and procedures are available to
259
+ * logged-in users only". A generated `tsvector` column cannot avoid one:
260
+ * `unaccent` is STABLE and jsonb flattening needs a set-returning function,
261
+ * so both have to be wrapped in an IMMUTABLE helper to be legal in a
262
+ * generated column at all.
263
+ * 2. Even with the file accepted, Atlas *wipes* the dev database it diffs
264
+ * against, so a helper seeded there beforehand is gone by the time the plan
265
+ * is analysed. There is no hook to reinstate it.
266
+ *
267
+ * So the column, its index and its helpers are excluded from Atlas's view
268
+ * (`searchExcludePatterns`) and applied from here — the same arrangement the
269
+ * RLS policies already use, and for the same underlying reason.
270
+ *
271
+ * Ordered as it must run: extensions, then helpers, then the column whose
272
+ * expression calls them, then the index over that column. Every statement is
273
+ * `IF NOT EXISTS` / `OR REPLACE`, because this is replayed on every push and
274
+ * appended to migrations that run against databases at any stage of their
275
+ * life. Empty when nothing opted in — the caller writes no file then.
276
+ *
277
+ * The leading half — extensions and helpers, without the table-shaped
278
+ * statements — is available on its own as {@link searchPrerequisiteStatements},
279
+ * for the dev database Atlas analyses plans against. That database has none of
280
+ * the project's tables, so it wants the functions and nothing else.
281
+ */
282
+ export const searchPrerequisiteStatements = (allCollections: CollectionConfig[]): string[] => {
283
+ const specs = relationalCollections(allCollections)
284
+ .map(c => buildSearchColumnSpec(c))
285
+ .filter((s): s is SearchColumnSpec => s !== undefined);
286
+ if (specs.length === 0) return [];
287
+ return [
288
+ ...new Set(specs.flatMap(searchExtensionStatements)),
289
+ ...new Set(specs.flatMap(searchHelperFunctions))
290
+ ];
291
+ };
292
+
293
+ export const generatePostgresSearchDdl = (allCollections: CollectionConfig[]): string => {
294
+ const collections = relationalCollections(allCollections);
295
+ const specs = collections
296
+ .map(c => buildSearchColumnSpec(c))
297
+ .filter((s): s is SearchColumnSpec => s !== undefined);
298
+ if (specs.length === 0) return "";
299
+
300
+ const extensions = Array.from(new Set(specs.flatMap(searchExtensionStatements)));
301
+ const helpers = Array.from(new Set(specs.flatMap(searchHelperFunctions)));
302
+
303
+ let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n";
304
+ ddl += "--\n";
305
+ ddl += "-- Full-text search for the collections declaring a `search` block.\n";
306
+ ddl += "-- Applied by Rebase, not by Atlas — see generatePostgresSearchDdl.\n\n";
307
+
308
+ extensions.forEach(s => { ddl += `${s}\n`; });
309
+ if (extensions.length > 0) ddl += "\n";
310
+ helpers.forEach(s => { ddl += `${s}\n\n`; });
311
+
312
+ for (const collection of collections) {
313
+ const spec = buildSearchColumnSpec(collection);
314
+ if (!spec) continue;
315
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
316
+ const table = `"${schema}"."${getTableName(collection)}"`;
317
+
318
+ // ADD COLUMN IF NOT EXISTS rather than the inline definition the
319
+ // CREATE TABLE would use: by the time this runs the table exists,
320
+ // whether Atlas just created it or it has been live for a year.
321
+ ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${searchColumnDefinition(spec)};\n`;
322
+ const fuzzyDef = fuzzyColumnDefinition(spec);
323
+ if (fuzzyDef) ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${fuzzyDef};\n`;
324
+ searchIndexStatements(spec).forEach(s => { ddl += `${s}\n`; });
325
+ ddl += "\n";
326
+ }
327
+
328
+ return ddl;
329
+ };
330
+
331
+ /**
332
+ * Glob patterns telling Atlas to leave the search column and its index alone.
333
+ *
334
+ * Without these, a desired state that omits search reads to Atlas as an
335
+ * instruction to drop the column — taking the index and the whole search
336
+ * feature with it on the next push.
337
+ *
338
+ * Fully qualified, `schema.table.object`, matching the include list. The
339
+ * two-part form is what Atlas wants when the connection URL scopes it to one
340
+ * schema and is *silently ignored* otherwise: it reads `posts.search_vector`
341
+ * as a table named `search_vector` in a schema named `posts`, matches nothing,
342
+ * and reports no error for the pattern that never fired.
343
+ */
344
+ export const searchExcludePatterns = (allCollections: CollectionConfig[]): string[] => {
345
+ const patterns: string[] = [];
346
+ for (const collection of relationalCollections(allCollections)) {
347
+ const spec = buildSearchColumnSpec(collection);
348
+ if (!spec) continue;
349
+ const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
350
+ const table = getTableName(collection);
351
+ for (const name of searchColumnNames(collection)) {
352
+ patterns.push(`${schema}.${table}.${name}`);
353
+ }
354
+ for (const index of searchIndexNames(spec)) {
355
+ patterns.push(`${schema}.${table}.${index}`);
356
+ }
357
+ }
358
+ return patterns;
359
+ };
360
+
240
361
  export const generatePostgresDdl = async (
241
362
  allCollections: CollectionConfig[],
242
- options: { includePolicies?: boolean } = { includePolicies: true }
363
+ options: { includePolicies?: boolean; includeSearch?: boolean } = {
364
+ includePolicies: true,
365
+ includeSearch: true
366
+ }
243
367
  ): Promise<string> => {
244
368
  // Only the collections this engine stores. See `relationalCollections`.
245
369
  const collections = relationalCollections(allCollections);
@@ -263,6 +387,32 @@ export const generatePostgresDdl = async (
263
387
  });
264
388
  if (uniqueSchemas.length > 0) ddl += "\n";
265
389
 
390
+ // 1b. Search support, for collections that opted in.
391
+ //
392
+ // Extensions and helper functions come before every CREATE TABLE because a
393
+ // generated column's expression is resolved at creation time: a table whose
394
+ // search column calls `rebase_search_text` cannot be created before that
395
+ // function exists. Both are `IF NOT EXISTS` / `OR REPLACE`, so a file
396
+ // replayed against a live database is a no-op here.
397
+ const searchSpecs = collections
398
+ .map(c => buildSearchColumnSpec(c))
399
+ .filter((s): s is SearchColumnSpec => s !== undefined);
400
+
401
+ if (searchSpecs.length > 0) {
402
+ if (options.includeSearch === false) {
403
+ // Everything search-related lives in `search.sql` — see
404
+ // `generatePostgresSearchDdl` for why Atlas is not shown any of it.
405
+ ddl += "-- Full-text search support lives in `search.sql`, applied separately.\n\n";
406
+ } else {
407
+ const extensions = Array.from(new Set(searchSpecs.flatMap(searchExtensionStatements)));
408
+ const helpers = Array.from(new Set(searchSpecs.flatMap(searchHelperFunctions)));
409
+ ddl += "-- Full-text search support (collections declaring a `search` block)\n";
410
+ extensions.forEach(s => { ddl += `${s}\n`; });
411
+ if (extensions.length > 0) ddl += "\n";
412
+ helpers.forEach(s => { ddl += `${s}\n\n`; });
413
+ }
414
+ }
415
+
266
416
  // 2. Generate Enums
267
417
  //
268
418
  // The enum type name is derived from table + column, so two collections
@@ -330,6 +480,9 @@ export const generatePostgresDdl = async (
330
480
 
331
481
  // 3. Generate tables
332
482
  const fkStatements: string[] = [];
483
+ // Indexes follow the tables for the same reason the FK constraints do: the
484
+ // table has to exist first.
485
+ const indexStatements: string[] = [];
333
486
  // Policies are emitted after every CREATE TABLE, like the FK constraints:
334
487
  // a policy may reference other tables (a junction's derived policies always
335
488
  // reference both endpoints; `policy.existsIn` references a join table), and
@@ -526,6 +679,19 @@ export const generatePostgresDdl = async (
526
679
  }
527
680
  }
528
681
 
682
+ // ── The opt-in search column ─────────────────────────────────────
683
+ // Last, so it reads as what it is: derived from the columns above
684
+ // it. Postgres recomputes it on every write of a source column and
685
+ // rejects any attempt to write it directly, which is the property
686
+ // that makes it impossible for the index to drift from the row.
687
+ const searchSpec = options.includeSearch === false ? undefined : buildSearchColumnSpec(collection);
688
+ if (searchSpec) {
689
+ columns.push(` ${searchColumnDefinition(searchSpec)}`);
690
+ const fuzzyDef = fuzzyColumnDefinition(searchSpec);
691
+ if (fuzzyDef) columns.push(` ${fuzzyDef}`);
692
+ indexStatements.push(...searchIndexStatements(searchSpec));
693
+ }
694
+
529
695
  // Backwards compatibility: add default id primary key if missing
530
696
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
531
697
  if (!hasPk) {
@@ -559,6 +725,11 @@ export const generatePostgresDdl = async (
559
725
  ddl += fkStatements.join("\n") + "\n\n";
560
726
  }
561
727
 
728
+ if (indexStatements.length > 0) {
729
+ ddl += "-- Indexes\n";
730
+ ddl += indexStatements.join("\n") + "\n\n";
731
+ }
732
+
562
733
  if (policyStatements.length > 0) {
563
734
  ddl += "-- Row Level Security Policies\n";
564
735
  ddl += policyStatements.join("");
@@ -642,13 +813,9 @@ const bareTableName = (name: string): string => (name.includes(".") ? name.split
642
813
  * Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
643
814
  * character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
644
815
  */
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
- };
816
+ // Moved to `@rebasepro/utils` so the search-column builder derives index names
817
+ // under the same 63-byte rule this file derives constraint names under. Two
818
+ // copies of a truncation rule is two rules the moment one of them is edited.
652
819
 
653
820
  const foreignKeyPlan = (
654
821
  args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: 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));