@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
@@ -28,6 +28,18 @@
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
+ searchColumnStamps,
38
+ SEARCH_STAMP_PREFIX,
39
+ SEARCH_TEXT_FN,
40
+ SEARCH_UNACCENT_FN,
41
+ type SearchColumnSpec
42
+ } from "./search-column";
31
43
  import {
32
44
  getSqlColumnType,
33
45
  resolveColumnName,
@@ -36,6 +48,12 @@ import {
36
48
  planJunctionTables,
37
49
  quoteSqlLiteral
38
50
  } from "./generate-postgres-ddl-logic";
51
+ import {
52
+ AUTH_USERS_COLUMNS,
53
+ authUsersColumnDefinition,
54
+ authUsersColumnSql,
55
+ isAuthCollection
56
+ } from "./auth-users-columns";
39
57
 
40
58
  /**
41
59
  * The subset of a database handle this needs: run a statement, get rows back.
@@ -74,10 +92,19 @@ export interface ExistingSchema {
74
92
  * constraint that then fails harmlessly as a duplicate.
75
93
  */
76
94
  constraints?: Set<string>;
95
+ /**
96
+ * `schema.table.column` → that column's comment, for the columns that have
97
+ * one. This is where a generated search column's fingerprint lives, so it
98
+ * is the only evidence that a `search` block has changed since the column
99
+ * was built. Absent is read as "no column is stamped", which plans a stamp
100
+ * and reports nothing as drifted.
101
+ */
102
+ columnComments?: Map<string, string>;
77
103
  }
78
104
 
79
105
  export interface EnsureAction {
80
- kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column";
106
+ kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
107
+ | "create-extension" | "create-function" | "create-index" | "comment-column";
81
108
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
82
109
  target: string;
83
110
  sql: string;
@@ -98,6 +125,47 @@ export interface EnsurePlan {
98
125
  * resolving to nothing — which is indistinguishable from having no data.
99
126
  */
100
127
  legacyForeignKeys: LegacyForeignKey[];
128
+ /**
129
+ * Generated search columns whose `search` block has changed since they were
130
+ * built. Reported, never planned into `actions` — see
131
+ * {@link SearchColumnDrift} for why applying it is not this path's call.
132
+ */
133
+ searchDrift: SearchColumnDrift[];
134
+ /**
135
+ * Generated search columns that exist but carry no fingerprint — created
136
+ * before this check existed, or by `search.sql` on an older CLI. The plan
137
+ * stamps them so the *next* change is detectable; whether they match the
138
+ * current block cannot be known, which is what the caller reports.
139
+ */
140
+ searchAdopted: { table: string; column: string }[];
141
+ }
142
+
143
+ /**
144
+ * A generated search column built from a `search` block that has since changed.
145
+ *
146
+ * Reported instead of applied because the two ways to apply it are both worse
147
+ * than stopping. `ALTER COLUMN … SET EXPRESSION` exists only on PG17+ and
148
+ * rewrites the table either way; `DROP COLUMN` + `ADD COLUMN` rewrites it under
149
+ * an ACCESS EXCLUSIVE lock and rebuilds the GIN index. This module runs
150
+ * unattended against live customer data with nobody reading a diff — the same
151
+ * reason it withholds `SET NOT NULL` from an adopted table — so a multi-minute
152
+ * outage is not a decision it may take on its own.
153
+ *
154
+ * Not applying it silently is not an option either: that is the bug this
155
+ * detection exists for. A collection that added a field, flipped `unaccent` or
156
+ * raised a weight kept indexing the *old* set forever, and the only symptom was
157
+ * searches returning nothing for content plainly in the row.
158
+ */
159
+ export interface SearchColumnDrift {
160
+ /** `schema.table`. */
161
+ table: string;
162
+ column: string;
163
+ /** The fingerprint recorded on the column. */
164
+ found: string;
165
+ /** The fingerprint the current `search` block computes. */
166
+ expected: string;
167
+ /** The statements that would rebuild the column, for the operator to run. */
168
+ rebuild: string[];
101
169
  }
102
170
 
103
171
  /** A relation column whose old and new spellings both plausibly apply. */
@@ -112,14 +180,16 @@ export interface LegacyForeignKey {
112
180
 
113
181
  export interface EnsureOutcome extends EnsurePlan {
114
182
  /**
115
- * Constraints that could not be added always non-fatal.
183
+ * Actions that could not be applied and are non-fatal by nature.
116
184
  *
117
- * A foreign key can only fail on data that already violates it, and the
118
- * column it would police exists either way, so the collection still serves.
119
- * Refusing to boot over one would turn a pre-existing data problem into an
120
- * outage. Reported loudly instead.
185
+ * Two kinds qualify. A foreign key can only fail on data that already
186
+ * violates it, and the column it would police exists either way, so the
187
+ * collection still serves; refusing to boot over one would turn a
188
+ * pre-existing data problem into an outage. A column comment is the search
189
+ * fingerprint, which needs table ownership — losing it costs drift
190
+ * detection on the next boot, not the deployment. Both are reported loudly.
121
191
  */
122
- failures: { target: string; error: string }[];
192
+ failures: { kind: EnsureAction["kind"]; target: string; error: string }[];
123
193
  }
124
194
 
125
195
  function schemaOf(collection: CollectionConfig): string {
@@ -175,6 +245,10 @@ export function planCollectionSchemaEnsure(
175
245
  // Firestore collection is not a harmless extra: the app keeps reading
176
246
  // documents from Firestore while an empty table with the same name accretes
177
247
  // policies and shows up in every drift report.
248
+ // Before the filter, deliberately: a `search` block on a collection this
249
+ // engine does not store would otherwise be dropped here without a word.
250
+ assertSearchIsPostgresOnly(allCollections);
251
+
178
252
  const collections = relationalCollections(allCollections);
179
253
  const actions: EnsureAction[] = [];
180
254
  const plannedEnums = new Set<string>();
@@ -194,6 +268,34 @@ export function planCollectionSchemaEnsure(
194
268
  }
195
269
  }
196
270
 
271
+ // 1b. Search support, for collections that declared a `search` block.
272
+ //
273
+ // Before the tables, because a generated column's expression is
274
+ // resolved when the column is created: a table whose search column
275
+ // calls `rebase_search_text` cannot be added before that function
276
+ // exists. Both forms are idempotent, so a boot against a database that
277
+ // already has them plans nothing.
278
+ const searchSpecs = collections
279
+ .map(c => buildSearchColumnSpec(c))
280
+ .filter((spec): spec is SearchColumnSpec => spec !== undefined);
281
+
282
+ const plannedExtensions = new Set<string>();
283
+ for (const spec of searchSpecs) {
284
+ for (const statement of searchExtensionStatements(spec)) {
285
+ if (plannedExtensions.has(statement)) continue;
286
+ plannedExtensions.add(statement);
287
+ actions.push({ kind: "create-extension", target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""), sql: statement });
288
+ }
289
+ }
290
+ const plannedFunctions = new Set<string>();
291
+ for (const spec of searchSpecs) {
292
+ for (const statement of searchHelperFunctions(spec)) {
293
+ if (plannedFunctions.has(statement)) continue;
294
+ plannedFunctions.add(statement);
295
+ actions.push({ kind: "create-function", target: statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN, sql: statement });
296
+ }
297
+ }
298
+
197
299
  // 2. Missing tables. Only the identity column is created here; every other
198
300
  // column is added by step 3, so a new table and an existing table that
199
301
  // gained a field travel the exact same code path. One way to build a
@@ -210,17 +312,22 @@ export function planCollectionSchemaEnsure(
210
312
  );
211
313
  const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1] as Property) : "id";
212
314
  const idProp = idEntry?.[1] as Property | undefined;
213
- let idDef: string;
214
- if (idProp?.type === "number") {
215
- idDef = `"${idName}" BIGSERIAL PRIMARY KEY`;
216
- } else if (
217
- idProp &&
218
- idProp.type === "string" &&
219
- (idProp as { isId?: unknown }).isId === "uuid"
220
- ) {
221
- idDef = `"${idName}" UUID PRIMARY KEY DEFAULT gen_random_uuid()`;
222
- } else {
223
- idDef = `"${idName}" TEXT PRIMARY KEY`;
315
+ // Derived through the generator's own type mapping, not re-decided here.
316
+ // This branch used to emit BIGSERIAL for a numeric id while `db push`
317
+ // emitted INTEGER GENERATED BY DEFAULT AS IDENTITY, so the same project
318
+ // got an int8 key when the runtime brought the schema up and an int4 key
319
+ // when a human pushed it. Two consequences, both real: node-postgres
320
+ // hands back int8 as a *string*, so a collection declaring
321
+ // `type: "number"` served `"1"` instead of `1` on the managed path only;
322
+ // and every foreign key and junction column pointing at it stayed
323
+ // INTEGER on both paths, which is a truncation waiting for the sequence
324
+ // to pass 2^31. The agreement test now pins this.
325
+ const idType = idProp
326
+ ? getSqlColumnType(idEntry![0], idProp, collection, collections)
327
+ : "TEXT";
328
+ let idDef = `"${idName}" ${idType} PRIMARY KEY`;
329
+ if (idProp?.type === "string" && (idProp as { isId?: unknown }).isId === "uuid") {
330
+ idDef += " DEFAULT gen_random_uuid()";
224
331
  }
225
332
  actions.push({
226
333
  kind: "create-table",
@@ -282,17 +389,14 @@ export function planCollectionSchemaEnsure(
282
389
  schema: string,
283
390
  table: string,
284
391
  column: string,
285
- type: string
392
+ definition: string
286
393
  ): void => {
287
394
  const present = existing.tables.get(key);
288
395
  if (present?.has(column)) return;
289
396
  actions.push({
290
397
  kind: "add-column",
291
398
  target: `${key}.${column}`,
292
- // Never NOT NULL: an existing table with rows cannot take a
293
- // non-null column without a default, and inventing one would be
294
- // guessing at the customer's data.
295
- sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
399
+ sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
296
400
  });
297
401
  };
298
402
 
@@ -300,6 +404,16 @@ export function planCollectionSchemaEnsure(
300
404
  const key = qualified(collection);
301
405
  const schema = schemaOf(collection);
302
406
  const table = getTableName(collection);
407
+ // A table this run is creating has no rows yet, so the constraints
408
+ // `db push` writes are free to apply. On a table that already exists
409
+ // they are not: `SET NOT NULL` is checked against live rows and a UNIQUE
410
+ // would fail on existing duplicates, and this module runs unattended
411
+ // against customer data with nobody reading a diff. So the constraints
412
+ // are emitted for the fresh case — which is the whole managed-runtime
413
+ // path, and the one that diverged from `db push` — and withheld for the
414
+ // adopted one. `rebase db push` remains how an existing table gets them.
415
+ const fresh = created.has(key);
416
+ const auth = isAuthCollection(collection);
303
417
  for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
304
418
  const p = prop as Property;
305
419
  if (isIdProperty(propName, p, collection)) continue;
@@ -308,7 +422,112 @@ export function planCollectionSchemaEnsure(
308
422
  // foreign key the same way `db push` does. Deriving them here as
309
423
  // plain columns is what once produced a column with no constraint.
310
424
  if (p.type === "reference" || p.type === "relation") continue;
311
- addColumn(key, schema, table, resolveColumnName(propName, p), getSqlColumnType(propName, p, collection, collections));
425
+
426
+ const column = resolveColumnName(propName, p);
427
+ // On an auth collection, the columns auth itself reads and writes
428
+ // have exactly one definition, wherever the table is created from —
429
+ // see `auth-users-columns`. Anything else on that collection is an
430
+ // ordinary user-declared field and is generated like any other.
431
+ const authDefinition = auth ? authUsersColumnDefinition(column) : undefined;
432
+ if (authDefinition) {
433
+ addColumn(key, schema, table, column, authDefinition);
434
+ continue;
435
+ }
436
+
437
+ // Assembled in the generator's order — type, UNIQUE, DEFAULT,
438
+ // NOT NULL — so the two produce byte-identical column definitions
439
+ // and the agreement test can compare them directly instead of
440
+ // checking that a column merely exists, which is how BIGSERIAL-vs-
441
+ // INTEGER and every missing constraint went unnoticed.
442
+ let definition = getSqlColumnType(propName, p, collection, collections);
443
+ if (fresh && p.validation?.unique) definition += " UNIQUE";
444
+ // Not gated on `fresh`: a default binds future writes only, so it is
445
+ // safe on a live table, and a column added without it would take the
446
+ // value the application forgot to send rather than `now()`.
447
+ const autoValue = (p as { autoValue?: string }).autoValue;
448
+ if (p.type === "date" && (autoValue === "on_create" || autoValue === "on_update")) {
449
+ definition += " DEFAULT now()";
450
+ }
451
+ if (fresh && p.validation?.required) definition += " NOT NULL";
452
+ addColumn(key, schema, table, column, definition);
453
+ }
454
+
455
+ // The auth columns the collection never mentions. The scaffold's users
456
+ // collection describes 12 of the 14 auth reads and writes, so planning
457
+ // only from properties left `is_anonymous` and `tokens_valid_after` to
458
+ // `ensureAuthTablesExist` — which does create them, but only because
459
+ // that function happens to run later in the same boot. Planning them
460
+ // here makes this path self-contained and identical to `db push`, so
461
+ // neither depends on the other having run.
462
+ if (auth) {
463
+ const declared = new Set(
464
+ Object.entries(collection.properties ?? {})
465
+ .map(([name, prop]) => resolveColumnName(name, prop as Property))
466
+ );
467
+ for (const spec of AUTH_USERS_COLUMNS) {
468
+ if (declared.has(spec.column)) continue;
469
+ addColumn(key, schema, table, spec.column, authUsersColumnSql(spec));
470
+ }
471
+ }
472
+ }
473
+
474
+ // 3aa. The generated search columns.
475
+ //
476
+ // Adding a STORED generated column rewrites the table, which on a large
477
+ // one is not free — but it is the same additive shape as every other
478
+ // column here, and the alternative (leaving it out until someone runs a
479
+ // migration) is a declared `search` block that silently does nothing.
480
+ //
481
+ // Changing one is not additive, and `ADD COLUMN IF NOT EXISTS` is a
482
+ // no-op against a column that is already there — which is why a `search`
483
+ // block that gained a field, flipped `unaccent` or moved a weight used
484
+ // to be inert forever, on every path, with nothing logged. Each column
485
+ // therefore carries a fingerprint of the expression it was built from
486
+ // (in its comment), and a mismatch is reported rather than applied.
487
+ const searchDrift: SearchColumnDrift[] = [];
488
+ const searchAdopted: { table: string; column: string }[] = [];
489
+ for (const spec of searchSpecs) {
490
+ const key = `${spec.schema}.${spec.table}`;
491
+ const definitions: Record<string, string> = {
492
+ [spec.column]: `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`
493
+ };
494
+ if (spec.fuzzy) {
495
+ definitions[spec.fuzzy.column] = `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`;
496
+ }
497
+
498
+ for (const stamp of searchColumnStamps(spec)) {
499
+ const definition = definitions[stamp.column];
500
+ const exists = existing.tables.get(key)?.has(stamp.column) === true;
501
+ const recorded = existing.columnComments?.get(`${key}.${stamp.column}`);
502
+
503
+ if (exists && recorded?.startsWith(SEARCH_STAMP_PREFIX) && recorded !== stamp.fingerprint) {
504
+ searchDrift.push({
505
+ table: key,
506
+ column: stamp.column,
507
+ found: recorded,
508
+ expected: stamp.fingerprint,
509
+ rebuild: [
510
+ `ALTER TABLE "${spec.schema}"."${spec.table}" DROP COLUMN "${stamp.column}";`,
511
+ `ALTER TABLE "${spec.schema}"."${spec.table}" ADD COLUMN "${stamp.column}" ${definition};`,
512
+ stamp.sql
513
+ ]
514
+ });
515
+ // The old stamp is the only evidence of what the column holds;
516
+ // overwriting it here would erase the drift instead of fixing it.
517
+ continue;
518
+ }
519
+
520
+ addColumn(key, spec.schema, spec.table, stamp.column, definition);
521
+ if (exists && recorded === undefined) {
522
+ searchAdopted.push({ table: key, column: stamp.column });
523
+ }
524
+ if (recorded !== stamp.fingerprint) {
525
+ actions.push({
526
+ kind: "comment-column",
527
+ target: `${key}.${stamp.column}`,
528
+ sql: stamp.sql
529
+ });
530
+ }
312
531
  }
313
532
  }
314
533
 
@@ -359,7 +578,24 @@ export function planCollectionSchemaEnsure(
359
578
  });
360
579
  }
361
580
 
362
- return { actions, statements: actions.map(a => a.sql), legacyForeignKeys };
581
+ // 5. Search indexes, after everything the column has to exist, and this is
582
+ // the one step that runs against a populated table for real work.
583
+ //
584
+ // CONCURRENTLY: a plain CREATE INDEX takes a lock that blocks writes for
585
+ // the duration of the build, which on a live table is an outage. Each
586
+ // statement here is issued on its own, outside any transaction, which is
587
+ // the condition CONCURRENTLY requires.
588
+ for (const spec of searchSpecs) {
589
+ for (const statement of searchIndexStatements(spec)) {
590
+ actions.push({
591
+ kind: "create-index",
592
+ target: `${spec.schema}.${spec.table}`,
593
+ sql: statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
594
+ });
595
+ }
596
+ }
597
+
598
+ return { actions, statements: actions.map(a => a.sql), legacyForeignKeys, searchDrift, searchAdopted };
363
599
  }
364
600
 
365
601
  /** Read what the database has, for the schemas the collections live in. */
@@ -414,7 +650,78 @@ export async function readExistingSchema(
414
650
  );
415
651
  for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
416
652
 
417
- return { tables, enums, constraints };
653
+ // Column comments, which is where a generated search column records the
654
+ // expression it was built from. `objsubid > 0` is what makes a row a
655
+ // *column* comment rather than the table's own.
656
+ const columnComments = new Map<string, string>();
657
+ const { rows: commentRows } = await client.query<{
658
+ schema: string;
659
+ table: string;
660
+ column: string;
661
+ comment: string | null;
662
+ }>(
663
+ `SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, d.description AS comment
664
+ FROM pg_description d
665
+ JOIN pg_class c ON d.objoid = c.oid
666
+ JOIN pg_namespace n ON c.relnamespace = n.oid
667
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid
668
+ WHERE d.objsubid > 0 AND n.nspname IN (${inList})`
669
+ );
670
+ for (const row of commentRows) {
671
+ if (row.comment == null) continue;
672
+ columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
673
+ }
674
+
675
+ return { tables, enums, constraints, columnComments };
676
+ }
677
+
678
+ /**
679
+ * What to tell an operator whose `search` block no longer matches its column.
680
+ *
681
+ * Every line here is doing work: naming the collection is not enough, because
682
+ * the symptom (a search that finds nothing) points at the data, not the schema;
683
+ * and the remediation has to be exact, because it is a table rewrite the
684
+ * operator is being asked to schedule rather than discover.
685
+ */
686
+ function searchDriftMessage(drift: SearchColumnDrift[]): string {
687
+ const blocks = drift.map(d =>
688
+ ` "${d.table}"."${d.column}" was generated from a different \`search\` block ` +
689
+ `(recorded ${d.found}, current ${d.expected}).\n` +
690
+ d.rebuild.map(s => ` ${s}`).join("\n")
691
+ );
692
+ return (
693
+ "The `search` block changed after its generated column was created, and Postgres cannot alter a " +
694
+ "generated expression in place.\n" +
695
+ "Rebase will not rebuild it for you: dropping and re-adding a STORED generated column rewrites the whole " +
696
+ "table under an ACCESS EXCLUSIVE lock and rebuilds its GIN index, which is an outage this unattended path " +
697
+ "may not schedule on your behalf.\n" +
698
+ "Until it is rebuilt the column keeps indexing the previous fields, weights and language — searches for " +
699
+ "anything added since return nothing, which reads from outside as \"no such row\".\n" +
700
+ "Run these (or revert the block to what the column was built from), then boot again:\n" +
701
+ blocks.join("\n") +
702
+ "\n The GIN index is dropped with the column and recreated concurrently on the next boot."
703
+ );
704
+ }
705
+
706
+ /**
707
+ * The missing-pgvector explanation, appended to the error that reveals it.
708
+ *
709
+ * A `{ type: "vector" }` property compiles to `VECTOR(n)`, and nothing in the
710
+ * OSS pipeline installs pgvector — not this ensure, not `db push`, not the
711
+ * scaffold's `postgres:18-alpine`, which does not ship it. Installing an
712
+ * extension on someone's database is a decision with a deployment behind it
713
+ * (image, superuser, cloud allow-list), so this path stays a refusal; what it
714
+ * must not stay is a bare `type "vector" does not exist` on a crash-looping
715
+ * pod, which names nothing the reader can act on.
716
+ */
717
+ function vectorExtensionHint(message: string): string {
718
+ if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
719
+ return (
720
+ "\n pgvector is not installed on this database, and Rebase does not install it: it is a server extension, " +
721
+ "so it needs an image that ships it (e.g. `pgvector/pgvector:pg18` — the scaffold's `postgres:18-alpine` " +
722
+ "does not) and a role allowed to run `CREATE EXTENSION vector;`. Install it once, then boot again. " +
723
+ "Note also that Rebase creates no ANN index for a vector column, so `vectorSearch` is an exact scan."
724
+ );
418
725
  }
419
726
 
420
727
  /**
@@ -446,7 +753,7 @@ export async function ensureCollectionTables(
446
753
 
447
754
  const existing = await readExistingSchema(client, schemas);
448
755
  const plan = planCollectionSchemaEnsure(collections, existing);
449
- const failures: { target: string; error: string }[] = [];
756
+ const failures: EnsureOutcome["failures"] = [];
450
757
 
451
758
  // Reported, not warned: this is a rename the ensure is about to perform, and
452
759
  // the operator should be able to see in the log why a column changed name.
@@ -463,6 +770,29 @@ export async function ensureCollectionTables(
463
770
  log?.(message);
464
771
  }
465
772
 
773
+ // Before anything is applied: a `search` block that changed after its column
774
+ // was generated cannot be honoured by an additive plan, and serving the old
775
+ // index while the config describes a new one is the silent failure this
776
+ // check exists to end. Refusing is the loud half — boot is fatal on purpose
777
+ // (see `ensureCollectionSchema` in the server's boot) and the message
778
+ // carries the exact statements that resolve it.
779
+ if (plan.searchDrift.length > 0) {
780
+ throw new Error(searchDriftMessage(plan.searchDrift));
781
+ }
782
+
783
+ // Said once per column, at the moment the stamp is applied: from here on a
784
+ // change is detected, but whether *this* column matches the block it is
785
+ // being stamped with is not knowable — it predates the stamp.
786
+ for (const adopted of plan.searchAdopted) {
787
+ const message =
788
+ `Adopting the existing generated column "${adopted.table}"."${adopted.column}" and recording what the ` +
789
+ "current `search` block would generate. Any later change to that block will be detected and refused; a " +
790
+ "change made *before* this version was deployed cannot be, so if search has been missing content, " +
791
+ `rebuild the column once: ALTER TABLE "${adopted.table.split(".").join('"."')}" DROP COLUMN "${adopted.column}"; and boot again.`;
792
+ logger.info(`[schema] ${message}`);
793
+ log?.(message);
794
+ }
795
+
466
796
  if (plan.actions.length === 0) {
467
797
  log?.("Schema is up to date; nothing to create.");
468
798
  return { ...plan, failures };
@@ -478,12 +808,18 @@ export async function ensureCollectionTables(
478
808
  // data rather than on the schema. The column it polices is already
479
809
  // there, so the collection serves either way — record it and carry
480
810
  // on rather than crash-looping the deployment.
481
- if (action.kind === "add-constraint") {
482
- failures.push({ target: action.target, error: message });
811
+ //
812
+ // A comment is metadata about a column that was just created
813
+ // successfully, and it can only fail on ownership (COMMENT requires
814
+ // owning the table, which an adopted table may not grant). Losing
815
+ // the stamp costs drift detection on the next boot; it must not cost
816
+ // the deployment.
817
+ if (action.kind === "add-constraint" || action.kind === "comment-column") {
818
+ failures.push({ kind: action.kind, target: action.target, error: message });
483
819
  continue;
484
820
  }
485
821
  throw new Error(
486
- `Failed to ${action.kind} ${action.target}: ${message}\n ${action.sql}`
822
+ `Failed to ${action.kind} ${action.target}: ${message}${vectorExtensionHint(message)}\n ${action.sql}`
487
823
  );
488
824
  }
489
825
  }