@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g041c925

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 (90) hide show
  1. package/dist/PostgresAdapter.d.ts +1 -1
  2. package/dist/PostgresBackendDriver.d.ts +16 -7
  3. package/dist/PostgresBootstrapper.d.ts +6 -6
  4. package/dist/auth/services.d.ts +1 -1
  5. package/dist/backup/backup-cron.d.ts +1 -1
  6. package/dist/backup/backup-service.d.ts +2 -2
  7. package/dist/backup/index.d.ts +4 -4
  8. package/dist/{backup-service-BZoixhVl.js → backup-service-FN6V3rVi.js} +4 -4
  9. package/dist/{backup-service-BZoixhVl.js.map → backup-service-FN6V3rVi.js.map} +1 -1
  10. package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
  11. package/dist/collections/buildRegistry.d.ts +1 -1
  12. package/dist/collections/validate-relations.d.ts +1 -1
  13. package/dist/{connection-BuZ97wsr.js → connection-GOKU3Hu5.js} +34 -7
  14. package/dist/connection-GOKU3Hu5.js.map +1 -0
  15. package/dist/connection.d.ts +16 -0
  16. package/dist/data-transformer.d.ts +1 -1
  17. package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-B01cv9UC.js} +4 -4
  18. package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-B01cv9UC.js.map} +1 -1
  19. package/dist/{auth-users-columns-CgyPWQ18.js → ensure-collection-tables-CvW6tbI7.js} +1482 -12
  20. package/dist/ensure-collection-tables-CvW6tbI7.js.map +1 -0
  21. package/dist/index.d.ts +16 -16
  22. package/dist/index.es.js +2183 -1817
  23. package/dist/index.es.js.map +1 -1
  24. package/dist/{rls-bootstrap-sql-B5Sajku6.js → rls-bootstrap-sql-DAwWHs81.js} +3 -3
  25. package/dist/{rls-bootstrap-sql-B5Sajku6.js.map → rls-bootstrap-sql-DAwWHs81.js.map} +1 -1
  26. package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-eSahD7ec.js} +13 -3
  27. package/dist/rls-enforcement-eSahD7ec.js.map +1 -0
  28. package/dist/schema/classify-change.d.ts +82 -0
  29. package/dist/schema/dynamic-tables.d.ts +1 -1
  30. package/dist/schema/ensure-collection-policies.d.ts +1 -1
  31. package/dist/schema/ensure-collection-tables.d.ts +93 -2
  32. package/dist/schema/generate-schema-commit.d.ts +136 -0
  33. package/dist/schema/generated-schema-staleness.d.ts +19 -0
  34. package/dist/schema/introspect-db-constraints.d.ts +1 -1
  35. package/dist/schema/introspect-db-logic.d.ts +3 -3
  36. package/dist/schema/introspect-db-project.d.ts +1 -1
  37. package/dist/schema/introspect-db-queries.d.ts +1 -1
  38. package/dist/schema/introspect-db-structure.d.ts +2 -2
  39. package/dist/schema/introspect-runtime.d.ts +1 -1
  40. package/dist/schema/vector-index.d.ts +88 -0
  41. package/dist/services/BranchService.d.ts +2 -2
  42. package/dist/services/FetchService.d.ts +4 -4
  43. package/dist/services/PersistService.d.ts +5 -5
  44. package/dist/services/RelationService.d.ts +3 -3
  45. package/dist/services/RelationWriteService.d.ts +3 -3
  46. package/dist/services/cdc/junction-tables.d.ts +1 -1
  47. package/dist/services/cdc/trigger-cdc.d.ts +1 -1
  48. package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
  49. package/dist/services/channel-bus/index.d.ts +2 -2
  50. package/dist/services/collection-helpers.d.ts +1 -1
  51. package/dist/services/dataService.d.ts +10 -10
  52. package/dist/services/index.d.ts +4 -4
  53. package/dist/services/junction-writes.d.ts +2 -2
  54. package/dist/services/nested-path.d.ts +1 -1
  55. package/dist/services/realtimeService.d.ts +3 -3
  56. package/dist/services/row-pipeline.d.ts +1 -1
  57. package/dist/services/write-denial.d.ts +1 -1
  58. package/dist/{src-BBFsDaeA.js → src-DolrXONo.js} +93 -1
  59. package/dist/src-DolrXONo.js.map +1 -0
  60. package/dist/utils/drizzle-conditions.d.ts +2 -2
  61. package/dist/{websocket-BVgDVO-V.js → websocket-7Dp77lTh.js} +43 -6
  62. package/dist/websocket-7Dp77lTh.js.map +1 -0
  63. package/dist/websocket.d.ts +29 -2
  64. package/package.json +7 -7
  65. package/src/PostgresBackendDriver.ts +41 -2
  66. package/src/backup/backup-service.ts +1 -1
  67. package/src/cli-helpers.ts +3 -2
  68. package/src/connection.ts +37 -3
  69. package/src/databasePoolManager.ts +5 -2
  70. package/src/schema/classify-change.ts +436 -0
  71. package/src/schema/ensure-collection-tables.test.ts +168 -1
  72. package/src/schema/ensure-collection-tables.ts +344 -14
  73. package/src/schema/generate-drizzle-schema-logic.ts +23 -11
  74. package/src/schema/generate-drizzle-schema.ts +13 -2
  75. package/src/schema/generate-postgres-ddl-logic.ts +16 -1
  76. package/src/schema/generate-postgres-ddl.ts +13 -2
  77. package/src/schema/generate-schema-commit.ts +242 -0
  78. package/src/schema/generated-schema-staleness.ts +114 -1
  79. package/src/schema/vector-index.ts +278 -0
  80. package/src/services/collection-helpers.ts +3 -2
  81. package/src/websocket.ts +36 -2
  82. package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
  83. package/dist/connection-BuZ97wsr.js.map +0 -1
  84. package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
  85. package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
  86. package/dist/rls-enforcement-Ch0T6OwW.js.map +0 -1
  87. package/dist/src-BBFsDaeA.js.map +0 -1
  88. package/dist/utils/table-classification.d.ts +0 -8
  89. package/dist/websocket-BVgDVO-V.js.map +0 -1
  90. package/src/utils/table-classification.ts +0 -16
@@ -196,6 +196,124 @@ describe("planning an additive schema ensure", () => {
196
196
  });
197
197
  });
198
198
 
199
+ /**
200
+ * The three changes the ensure path used to apply *partly* while reporting
201
+ * success. Each one left a database that did not match its own configuration,
202
+ * and nothing anywhere said so — which is what made them worth more than the
203
+ * refusals beside them.
204
+ */
205
+ describe("constraints the configuration asks for", () => {
206
+ const required = {
207
+ name: "Authors",
208
+ slug: "authors",
209
+ properties: {
210
+ id: { name: "ID", type: "string", isId: "uuid" },
211
+ name: { name: "Name", type: "string", validation: { required: true } }
212
+ }
213
+ } as unknown as CollectionConfig;
214
+
215
+ const optional = {
216
+ ...required,
217
+ properties: {
218
+ ...(required as unknown as { properties: Record<string, unknown> }).properties,
219
+ name: { name: "Name", type: "string" }
220
+ }
221
+ } as unknown as CollectionConfig;
222
+
223
+ it("adds NOT NULL when the existing table is empty", () => {
224
+ const plan = planCollectionSchemaEnsure([required], {
225
+ tables: new Map([["public.authors", new Set(["id"])]]),
226
+ enums: new Set(),
227
+ populatedTables: new Set()
228
+ });
229
+ const add = plan.actions.find(a => a.target === "public.authors.name");
230
+ expect(add!.sql).toContain("NOT NULL");
231
+ expect(plan.withheldConstraints).toEqual([]);
232
+ });
233
+
234
+ it("withholds NOT NULL when the table holds rows, and says so", () => {
235
+ const plan = planCollectionSchemaEnsure([required], {
236
+ tables: new Map([["public.authors", new Set(["id"])]]),
237
+ enums: new Set(),
238
+ populatedTables: new Set(["public.authors"])
239
+ });
240
+ const add = plan.actions.find(a => a.target === "public.authors.name");
241
+ expect(add!.sql).not.toContain("NOT NULL");
242
+ // The whole point: withheld is no longer the same as unmentioned.
243
+ expect(plan.withheldConstraints).toHaveLength(1);
244
+ expect(plan.withheldConstraints[0]).toMatchObject({
245
+ target: "public.authors.name",
246
+ kind: "not-null"
247
+ });
248
+ });
249
+
250
+ it("assumes rows when the caller does not know whether the table is empty", () => {
251
+ // A hand-built ExistingSchema carries no `populatedTables`. Reading that
252
+ // as "empty" would emit a NOT NULL checked against live data and abort
253
+ // the boot, so absent has to mean populated.
254
+ const plan = planCollectionSchemaEnsure([required], {
255
+ tables: new Map([["public.authors", new Set(["id"])]]),
256
+ enums: new Set()
257
+ });
258
+ expect(plan.actions.find(a => a.target === "public.authors.name")!.sql)
259
+ .not.toContain("NOT NULL");
260
+ expect(plan.withheldConstraints).toHaveLength(1);
261
+ });
262
+
263
+ it("leaves an existing column's constraints alone at boot", () => {
264
+ // `additive` is the boot default. A database adopted by introspection
265
+ // carries NOT NULL on columns the generated collection leaves optional,
266
+ // and converging those unasked would drop constraints nobody edited.
267
+ const plan = planCollectionSchemaEnsure([optional], {
268
+ tables: new Map([["public.authors", new Set(["id", "name"])]]),
269
+ enums: new Set(),
270
+ notNullColumns: new Set(["public.authors.name"]),
271
+ populatedTables: new Set()
272
+ });
273
+ expect(plan.actions).toEqual([]);
274
+ });
275
+
276
+ it("drops NOT NULL when a reviewed change relaxes the property", () => {
277
+ const plan = planCollectionSchemaEnsure([optional], {
278
+ tables: new Map([["public.authors", new Set(["id", "name"])]]),
279
+ enums: new Set(),
280
+ notNullColumns: new Set(["public.authors.name"]),
281
+ populatedTables: new Set(["public.authors"])
282
+ }, { constraints: "converge" });
283
+ expect(plan.actions).toEqual([{
284
+ kind: "drop-not-null",
285
+ target: "public.authors.name",
286
+ sql: `ALTER TABLE "public"."authors" ALTER COLUMN "name" DROP NOT NULL;`
287
+ }]);
288
+ });
289
+
290
+ it("sets NOT NULL on an existing empty column when the change is reviewed", () => {
291
+ const plan = planCollectionSchemaEnsure([required], {
292
+ tables: new Map([["public.authors", new Set(["id", "name"])]]),
293
+ enums: new Set(),
294
+ notNullColumns: new Set(),
295
+ populatedTables: new Set()
296
+ }, { constraints: "converge" });
297
+ expect(plan.actions).toEqual([{
298
+ kind: "set-not-null",
299
+ target: "public.authors.name",
300
+ sql: `ALTER TABLE "public"."authors" ALTER COLUMN "name" SET NOT NULL;`
301
+ }]);
302
+ });
303
+
304
+ it("refuses to set NOT NULL on a populated column, even when reviewed", () => {
305
+ const plan = planCollectionSchemaEnsure([required], {
306
+ tables: new Map([["public.authors", new Set(["id", "name"])]]),
307
+ enums: new Set(),
308
+ notNullColumns: new Set(),
309
+ populatedTables: new Set(["public.authors"])
310
+ }, { constraints: "converge" });
311
+ expect(plan.actions).toEqual([]);
312
+ expect(plan.withheldConstraints).toHaveLength(1);
313
+ expect(plan.withheldConstraints[0].remedy).toMatch(/Backfill/);
314
+ });
315
+ });
316
+
199
317
  describe("applying the plan", () => {
200
318
  function fakeClient(): { client: Queryable; executed: string[] } {
201
319
  const executed: string[] = [];
@@ -236,7 +354,20 @@ describe("applying the plan", () => {
236
354
  rows: ["id", "title", "views", "status"].map(c => ({
237
355
  table_schema: "public",
238
356
  table_name: "posts",
239
- column_name: c
357
+ column_name: c,
358
+ is_nullable: "YES"
359
+ })) as unknown as T[]
360
+ };
361
+ }
362
+ // Before the `pg_type` arm: the enum *values* query joins
363
+ // pg_type too, and answering it with the type-name rows would
364
+ // describe a type whose only value is `undefined`.
365
+ if (sql.includes("pg_enum")) {
366
+ return {
367
+ rows: ["draft", "published"].map(value => ({
368
+ schema: "public",
369
+ name: "posts_status",
370
+ value
240
371
  })) as unknown as T[]
241
372
  };
242
373
  }
@@ -249,6 +380,42 @@ describe("applying the plan", () => {
249
380
  const plan = await ensureCollectionTables(client, [posts]);
250
381
  expect(plan.actions).toEqual([]);
251
382
  });
383
+
384
+ it("adds an enum value the existing type is missing", async () => {
385
+ // The bug this covers: the type exists, so the whole type was skipped by
386
+ // name, the boot reported success, and the first row using the new value
387
+ // was rejected by a type that had never heard of it.
388
+ const client: Queryable = {
389
+ async query<T>(sql: string): Promise<{ rows: T[] }> {
390
+ if (sql.includes("information_schema.columns")) {
391
+ return {
392
+ rows: ["id", "title", "views", "status"].map(c => ({
393
+ table_schema: "public",
394
+ table_name: "posts",
395
+ column_name: c,
396
+ is_nullable: "YES"
397
+ })) as unknown as T[]
398
+ };
399
+ }
400
+ if (sql.includes("pg_enum")) {
401
+ // "published" is missing from the type.
402
+ return {
403
+ rows: [{ schema: "public", name: "posts_status", value: "draft" }] as unknown as T[]
404
+ };
405
+ }
406
+ if (sql.includes("pg_type")) {
407
+ return { rows: [{ schema: "public", name: "posts_status" }] as unknown as T[] };
408
+ }
409
+ return { rows: [] as T[] };
410
+ }
411
+ };
412
+ const plan = await ensureCollectionTables(client, [posts]);
413
+ expect(plan.actions).toEqual([{
414
+ kind: "add-enum-value",
415
+ target: "public.posts_status.published",
416
+ sql: `ALTER TYPE "public"."posts_status" ADD VALUE IF NOT EXISTS 'published';`
417
+ }]);
418
+ });
252
419
  });
253
420
 
254
421
  /**
@@ -48,6 +48,7 @@ import {
48
48
  planJunctionTables,
49
49
  quoteSqlLiteral
50
50
  } from "./generate-postgres-ddl-logic";
51
+ import { buildVectorIndexPlan, vectorIndexStatement, type SkippedVectorIndex } from "./vector-index";
51
52
  import {
52
53
  AUTH_USERS_COLUMNS,
53
54
  authUsersColumnDefinition,
@@ -100,11 +101,57 @@ export interface ExistingSchema {
100
101
  * and reports nothing as drifted.
101
102
  */
102
103
  columnComments?: Map<string, string>;
104
+ /**
105
+ * `schema.typename` → the values that type currently holds, in order.
106
+ *
107
+ * Without this, an enum type that already exists is skipped whole and a
108
+ * value added to it never reaches the database — the type is there, so
109
+ * nothing plans anything, and the first row using the new value is rejected
110
+ * by a constraint nobody changed. Absent is read as "the values are
111
+ * unknown", which keeps the old skip-by-name behaviour rather than guessing.
112
+ */
113
+ enumValues?: Map<string, string[]>;
114
+ /** `schema.table.column` for every column the database marks NOT NULL. */
115
+ notNullColumns?: Set<string>;
116
+ /**
117
+ * Tables known to hold at least one row.
118
+ *
119
+ * The only thing that decides whether a NOT NULL can be added without
120
+ * reading the data: on an empty table the constraint cannot fail, on a
121
+ * populated one it is checked against every existing row. Absent is read as
122
+ * "assume populated", which is the conservative direction — it withholds a
123
+ * constraint rather than attempting one that aborts the boot.
124
+ */
125
+ populatedTables?: Set<string>;
126
+ }
127
+
128
+ /**
129
+ * How far the planner may go in making the database's constraints match the
130
+ * configuration.
131
+ *
132
+ * - `additive` — the boot default. Columns, tables, indexes and enum values are
133
+ * created; no existing column's constraints are touched. Unattended boots run
134
+ * against customer data with nobody reading a diff, and a database adopted by
135
+ * introspection legitimately carries NOT NULL on columns the generated
136
+ * collection leaves optional (`introspect-db-logic` withholds `required` from
137
+ * a column with a default or a trigger behind it). Converging there would
138
+ * strip real constraints on first boot.
139
+ * - `converge` — the live schema editor. Every statement is planned, shown to
140
+ * the person making the change, and applied only once they confirm it. That
141
+ * is the context in which changing an existing column's constraints is a
142
+ * reviewed act rather than a surprise.
143
+ */
144
+ export type ConstraintPolicy = "additive" | "converge";
145
+
146
+ export interface EnsureOptions {
147
+ /** Defaults to `additive`. See {@link ConstraintPolicy}. */
148
+ constraints?: ConstraintPolicy;
103
149
  }
104
150
 
105
151
  export interface EnsureAction {
106
152
  kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
107
- | "create-extension" | "create-function" | "create-index" | "comment-column";
153
+ | "create-extension" | "create-function" | "create-index" | "comment-column"
154
+ | "add-enum-value" | "set-not-null" | "drop-not-null";
108
155
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
109
156
  target: string;
110
157
  sql: string;
@@ -138,6 +185,44 @@ export interface EnsurePlan {
138
185
  * current block cannot be known, which is what the caller reports.
139
186
  */
140
187
  searchAdopted: { table: string; column: string }[];
188
+ /**
189
+ * Vector columns this plan is deliberately leaving unindexed, because
190
+ * pgvector cannot build an ANN index that wide.
191
+ *
192
+ * Reported rather than thrown: the column is valid, storable and
193
+ * searchable, and refusing the boot over it would make a working
194
+ * configuration unbootable. Reported rather than dropped: an unindexed
195
+ * vector column and an indexed one differ only in latency, so nothing
196
+ * about the running system says which one you got.
197
+ */
198
+ vectorIndexSkipped: SkippedVectorIndex[];
199
+ /**
200
+ * Constraints the configuration asks for that this plan is not applying,
201
+ * and why.
202
+ *
203
+ * This is the half of the feature that matters most. Every one of these was
204
+ * previously withheld in silence: a required property arrived nullable, and
205
+ * the only evidence was a database that disagreed with its own
206
+ * configuration. Reporting them is what lets boot warn, the live editor
207
+ * refuse, and the doctor explain — three surfaces that until now had nothing
208
+ * to read.
209
+ */
210
+ withheldConstraints: WithheldConstraint[];
211
+ }
212
+
213
+ /** A constraint the configuration asks for that the planner is not applying. */
214
+ export interface WithheldConstraint {
215
+ /** `schema.table.column`. */
216
+ target: string;
217
+ kind: "not-null";
218
+ /**
219
+ * Why, in a sentence that names the obstacle rather than the rule. The
220
+ * reader is looking at a column that is nullable when they asked for
221
+ * required, and needs to know what to do about it.
222
+ */
223
+ reason: string;
224
+ /** What would make it applicable. */
225
+ remedy: string;
141
226
  }
142
227
 
143
228
  /**
@@ -238,8 +323,11 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
238
323
  */
239
324
  export function planCollectionSchemaEnsure(
240
325
  allCollections: CollectionConfig[],
241
- existing: ExistingSchema
326
+ existing: ExistingSchema,
327
+ options: EnsureOptions = {}
242
328
  ): EnsurePlan {
329
+ const constraintPolicy: ConstraintPolicy = options.constraints ?? "additive";
330
+ const withheldConstraints: WithheldConstraint[] = [];
243
331
  // Boot receives every collection the bundle declares, including the ones
244
332
  // served by another engine entirely. Creating a Postgres table for a
245
333
  // Firestore collection is not a harmless extra: the app keeps reading
@@ -257,7 +345,35 @@ export function planCollectionSchemaEnsure(
257
345
  // skipped by name rather than guarded in SQL.
258
346
  for (const collection of collections) {
259
347
  for (const { name, values } of requiredEnums(collection)) {
260
- if (existing.enums.has(name) || plannedEnums.has(name)) continue;
348
+ if (existing.enums.has(name) || plannedEnums.has(name)) {
349
+ // The type is there, but that says nothing about its *values*.
350
+ // Skipping the whole type by name is what made an added enum
351
+ // value vanish: nothing was planned, the boot reported success,
352
+ // and the first row using the value was rejected by a type that
353
+ // had never heard of it. `ADD VALUE` is the one alteration
354
+ // Postgres offers here, it is purely additive, and it is
355
+ // idempotent with `IF NOT EXISTS`.
356
+ //
357
+ // `enumValues` absent means the caller built the schema by hand
358
+ // and does not know the values; skip by name as before rather
359
+ // than plan against a guess.
360
+ const current = existing.enumValues?.get(name);
361
+ if (!current || plannedEnums.has(name)) continue;
362
+ const [schema, typeName] = name.split(".");
363
+ for (const value of values) {
364
+ if (current.includes(value)) continue;
365
+ actions.push({
366
+ kind: "add-enum-value",
367
+ target: `${name}.${value}`,
368
+ // Not inside a transaction with any use of the value:
369
+ // Postgres refuses to read a value added by the
370
+ // transaction still adding it. The applier runs these
371
+ // one statement at a time, which is what makes it legal.
372
+ sql: `ALTER TYPE "${schema}"."${typeName}" ADD VALUE IF NOT EXISTS ${quoteSqlLiteral(value)};`
373
+ });
374
+ }
375
+ continue;
376
+ }
261
377
  plannedEnums.add(name);
262
378
  const [schema, typeName] = name.split(".");
263
379
  actions.push({
@@ -445,11 +561,93 @@ export function planCollectionSchemaEnsure(
445
561
  // safe on a live table, and a column added without it would take the
446
562
  // value the application forgot to send rather than `now()`.
447
563
  const autoValue = (p as { autoValue?: string }).autoValue;
448
- if (p.type === "date" && (autoValue === "on_create" || autoValue === "on_update")) {
449
- definition += " DEFAULT now()";
564
+ const hasDefault = p.type === "date" && (autoValue === "on_create" || autoValue === "on_update");
565
+ if (hasDefault) definition += " DEFAULT now()";
566
+
567
+ const required = p.validation?.required === true;
568
+ const columnKey = `${key}.${column}`;
569
+ const columnExists = existing.tables.get(key)?.has(column) === true;
570
+
571
+ // A NOT NULL is safe exactly when it cannot fail against rows that
572
+ // are already there, and there are three ways to know that:
573
+ //
574
+ // - the table is being created by this plan (no rows yet);
575
+ // - the table exists and is empty;
576
+ // - the column arrives with a DEFAULT, which Postgres backfills
577
+ // into every existing row as part of ADD COLUMN.
578
+ //
579
+ // Anything else is checked against live data and can abort the boot,
580
+ // which is why it used to be withheld — correctly. What was wrong was
581
+ // withholding it in *silence*: the config said required, the column
582
+ // came out nullable, and nothing anywhere said so.
583
+ // `populatedTables` absent means the caller does not know, and not
584
+ // knowing has to read as "assume rows" — the other direction emits a
585
+ // NOT NULL that is checked against live data and aborts the boot.
586
+ // Written as an explicit `!== undefined` because the optional-chain
587
+ // form (`!existing.populatedTables?.has(key)`) quietly says *empty*
588
+ // when the fact is missing, which is the wrong way to be wrong.
589
+ const tableIsEmpty = existing.populatedTables !== undefined
590
+ && existing.tables.has(key)
591
+ && !existing.populatedTables.has(key);
592
+ const notNullIsSafe = fresh || tableIsEmpty || hasDefault;
593
+
594
+ if (required && !columnExists) {
595
+ if (notNullIsSafe) {
596
+ definition += " NOT NULL";
597
+ } else {
598
+ withheldConstraints.push({
599
+ target: columnKey,
600
+ kind: "not-null",
601
+ reason:
602
+ `"${column}" is required, but "${key}" already holds rows and the column ` +
603
+ "has no default to backfill them with, so NOT NULL would be checked " +
604
+ "against data that does not have a value yet.",
605
+ remedy:
606
+ "Backfill the column, then add the constraint — or give the property a " +
607
+ "default so every existing row gets one."
608
+ });
609
+ }
450
610
  }
451
- if (fresh && p.validation?.required) definition += " NOT NULL";
452
611
  addColumn(key, schema, table, column, definition);
612
+
613
+ // The column is already there and only its constraint differs. Two
614
+ // directions, and they are not equally safe — see `ConstraintPolicy`
615
+ // for why neither runs at an unattended boot.
616
+ if (columnExists && constraintPolicy === "converge") {
617
+ const isNotNull = existing.notNullColumns?.has(columnKey) === true;
618
+ if (required && !isNotNull) {
619
+ if (tableIsEmpty) {
620
+ actions.push({
621
+ kind: "set-not-null",
622
+ target: columnKey,
623
+ sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" SET NOT NULL;`
624
+ });
625
+ } else {
626
+ withheldConstraints.push({
627
+ target: columnKey,
628
+ kind: "not-null",
629
+ reason:
630
+ `"${column}" became required, but "${key}" holds rows and any of them ` +
631
+ "with no value would make SET NOT NULL fail.",
632
+ remedy:
633
+ "Backfill the column first — `UPDATE … SET \"" + column +
634
+ "\" = … WHERE \"" + column + "\" IS NULL` — then apply this again."
635
+ });
636
+ }
637
+ }
638
+ if (!required && isNotNull) {
639
+ // Loosening never fails and never loses data. It is here
640
+ // rather than at boot because a database adopted by
641
+ // introspection carries NOT NULL on columns the generated
642
+ // collection deliberately leaves optional, and converging
643
+ // those unasked would drop constraints nobody edited.
644
+ actions.push({
645
+ kind: "drop-not-null",
646
+ target: columnKey,
647
+ sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" DROP NOT NULL;`
648
+ });
649
+ }
650
+ }
453
651
  }
454
652
 
455
653
  // The auth columns the collection never mentions. The scaffold's users
@@ -595,7 +793,34 @@ export function planCollectionSchemaEnsure(
595
793
  }
596
794
  }
597
795
 
598
- return { actions, statements: actions.map(a => a.sql), legacyForeignKeys, searchDrift, searchAdopted };
796
+ // ANN indexes for vector columns, on the same terms: the column has to
797
+ // exist, the build is real work against real rows, and CONCURRENTLY is
798
+ // what keeps that from locking writes for its duration.
799
+ //
800
+ // A column too wide for pgvector to index is reported, not planned —
801
+ // silence there would read as "indexed" to anyone watching the boot.
802
+ const vectorIndexSkipped: SkippedVectorIndex[] = [];
803
+ for (const collection of collections) {
804
+ const plan = buildVectorIndexPlan(collection, resolveColumnName);
805
+ for (const spec of plan.specs) {
806
+ actions.push({
807
+ kind: "create-index",
808
+ target: `${spec.schema}.${spec.table}`,
809
+ sql: vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
810
+ });
811
+ }
812
+ vectorIndexSkipped.push(...plan.skipped);
813
+ }
814
+
815
+ return {
816
+ actions,
817
+ statements: actions.map(a => a.sql),
818
+ legacyForeignKeys,
819
+ searchDrift,
820
+ searchAdopted,
821
+ vectorIndexSkipped,
822
+ withheldConstraints
823
+ };
599
824
  }
600
825
 
601
826
  /** Read what the database has, for the schemas the collections live in. */
@@ -611,12 +836,14 @@ export async function readExistingSchema(
611
836
  .map(schema => `'${assertSafeIdentifier(schema, "schema name")}'`)
612
837
  .join(", ");
613
838
 
839
+ const notNullColumns = new Set<string>();
614
840
  const { rows: columns } = await client.query<{
615
841
  table_schema: string;
616
842
  table_name: string;
617
843
  column_name: string;
844
+ is_nullable: string;
618
845
  }>(
619
- `SELECT table_schema, table_name, column_name
846
+ `SELECT table_schema, table_name, column_name, is_nullable
620
847
  FROM information_schema.columns
621
848
  WHERE table_schema IN (${inList})`
622
849
  );
@@ -624,6 +851,64 @@ export async function readExistingSchema(
624
851
  const key = `${row.table_schema}.${row.table_name}`;
625
852
  if (!tables.has(key)) tables.set(key, new Set());
626
853
  tables.get(key)!.add(row.column_name);
854
+ if (row.is_nullable === "NO") notNullColumns.add(`${key}.${row.column_name}`);
855
+ }
856
+
857
+ // Which tables hold rows. This is the only fact that decides whether a
858
+ // NOT NULL can be added without reading the data, so it is worth a query.
859
+ //
860
+ // `reltuples` would be cheaper and is wrong for this: it is a planner
861
+ // estimate, it is -1 on a table that has never been analyzed, and a table
862
+ // that was full an hour ago still reads as full after a DELETE. A wrong
863
+ // "empty" here means a boot that aborts on a constraint violation, so the
864
+ // estimate is not good enough. `EXISTS … LIMIT 1` stops at the first row,
865
+ // which makes the true cost one page read per table.
866
+ //
867
+ // Restricted to ordinary and partitioned tables: `information_schema.columns`
868
+ // also lists views and materialized views, and probing those runs whatever
869
+ // query defines them.
870
+ const populatedTables = new Set<string>();
871
+ const { rows: realTables } = await client.query<{ schema: string; name: string }>(
872
+ `SELECT n.nspname AS schema, c.relname AS name
873
+ FROM pg_class c
874
+ JOIN pg_namespace n ON c.relnamespace = n.oid
875
+ WHERE c.relkind IN ('r', 'p') AND n.nspname IN (${inList})`
876
+ );
877
+ if (realTables.length > 0) {
878
+ const probes = realTables.map(row => {
879
+ const schema = assertSafeIdentifier(row.schema, "schema name");
880
+ const table = assertSafeIdentifier(row.name, "table name");
881
+ return `SELECT ${quoteSqlLiteral(`${schema}.${table}`)} AS key, ` +
882
+ `EXISTS(SELECT 1 FROM "${schema}"."${table}" LIMIT 1) AS populated`;
883
+ });
884
+ const { rows: populationRows } = await client.query<{ key: string; populated: boolean }>(
885
+ probes.join(" UNION ALL ")
886
+ );
887
+ for (const row of populationRows) {
888
+ if (row.populated) populatedTables.add(row.key);
889
+ }
890
+ }
891
+
892
+ const enumValues = new Map<string, string[]>();
893
+ const { rows: enumValueRows } = await client.query<{
894
+ schema: string;
895
+ name: string;
896
+ value: string;
897
+ }>(
898
+ // Ordered by `enumsortorder`, not by label: an enum's order is part of
899
+ // its meaning (it is what `<` compares), and reading it back sorted
900
+ // alphabetically would make a correct type look drifted.
901
+ `SELECT n.nspname AS schema, t.typname AS name, e.enumlabel AS value
902
+ FROM pg_enum e
903
+ JOIN pg_type t ON e.enumtypid = t.oid
904
+ JOIN pg_namespace n ON t.typnamespace = n.oid
905
+ WHERE n.nspname IN (${inList})
906
+ ORDER BY t.typname, e.enumsortorder`
907
+ );
908
+ for (const row of enumValueRows) {
909
+ const key = `${row.schema}.${row.name}`;
910
+ if (!enumValues.has(key)) enumValues.set(key, []);
911
+ enumValues.get(key)!.push(row.value);
627
912
  }
628
913
 
629
914
  const { rows: enumRows } = await client.query<{ schema: string; name: string }>(
@@ -672,7 +957,7 @@ export async function readExistingSchema(
672
957
  columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
673
958
  }
674
959
 
675
- return { tables, enums, constraints, columnComments };
960
+ return { tables, enums, constraints, columnComments, enumValues, notNullColumns, populatedTables };
676
961
  }
677
962
 
678
963
  /**
@@ -707,23 +992,47 @@ function searchDriftMessage(drift: SearchColumnDrift[]): string {
707
992
  * The missing-pgvector explanation, appended to the error that reveals it.
708
993
  *
709
994
  * 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
995
+ * OSS pipeline installs pgvector — not this ensure, not `db push`. Installing
996
+ * an extension on someone's database is a decision with a deployment behind it
713
997
  * (image, superuser, cloud allow-list), so this path stays a refusal; what it
714
998
  * must not stay is a bare `type "vector" does not exist` on a crash-looping
715
999
  * pod, which names nothing the reader can act on.
1000
+ *
1001
+ * The scaffold now ships `pgvector/pgvector:pg18`, so this is reached by a
1002
+ * project pointed at a database someone else provisioned — which is exactly
1003
+ * the case where naming the extension and the image is worth the words.
716
1004
  */
717
1005
  function vectorExtensionHint(message: string): string {
718
1006
  if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
719
1007
  return (
720
1008
  "\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` " +
1009
+ "so it needs an image that ships it (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` " +
722
1010
  "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."
1011
+ "Rebase then creates an ANN index for the column automatically — see the `index` option on the property."
724
1012
  );
725
1013
  }
726
1014
 
1015
+ /**
1016
+ * Read what the database looks like, for the schemas a set of collections
1017
+ * lives in.
1018
+ *
1019
+ * The same read `ensureCollectionTables` does at boot, exposed on its own for
1020
+ * the callers that want to *plan* against a real database without changing it —
1021
+ * the live schema editor, which has to tell somebody what a change would do
1022
+ * before they agree to it.
1023
+ */
1024
+ export async function readSchemaFactsFor(
1025
+ client: Queryable,
1026
+ collections: CollectionConfig[]
1027
+ ): Promise<ExistingSchema> {
1028
+ const relational = relationalCollections(collections);
1029
+ const schemas = Array.from(new Set([
1030
+ ...relational.map(schemaOf),
1031
+ ...planJunctionTables(relational).map(junction => junction.schema)
1032
+ ]));
1033
+ return readExistingSchema(client, schemas);
1034
+ }
1035
+
727
1036
  /**
728
1037
  * Bring the database up to date. Returns what it did.
729
1038
  *
@@ -793,6 +1102,27 @@ export async function ensureCollectionTables(
793
1102
  log?.(message);
794
1103
  }
795
1104
 
1105
+ // Said once per column, every boot: an unindexed vector column and an
1106
+ // indexed one behave identically apart from latency, so the only way anyone
1107
+ // learns which one they have is if the boot says so.
1108
+ for (const skip of plan.vectorIndexSkipped) {
1109
+ const message = `No ANN index on "${skip.table}"."${skip.column}": ${skip.reason}`;
1110
+ logger.warn(`[schema] ${message}`);
1111
+ log?.(message);
1112
+ }
1113
+
1114
+ // Said once per column, every boot, because the alternative is what this
1115
+ // whole feature exists to end: a column the configuration calls required,
1116
+ // sitting there nullable, with every surface reporting success. The boot
1117
+ // does not fail over it — the column is usable and the data is intact — but
1118
+ // it stops being invisible.
1119
+ for (const withheld of plan.withheldConstraints) {
1120
+ const message =
1121
+ `No NOT NULL on "${withheld.target}": ${withheld.reason} ${withheld.remedy}`;
1122
+ logger.warn(`[schema] ${message}`);
1123
+ log?.(message);
1124
+ }
1125
+
796
1126
  if (plan.actions.length === 0) {
797
1127
  log?.("Schema is up to date; nothing to create.");
798
1128
  return { ...plan, failures };