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

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 (63) 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/collections/PostgresCollectionRegistry.d.ts +1 -1
  9. package/dist/collections/buildRegistry.d.ts +1 -1
  10. package/dist/collections/validate-relations.d.ts +1 -1
  11. package/dist/data-transformer.d.ts +1 -1
  12. package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-CVfVHS6o.js} +3 -3
  13. package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-CVfVHS6o.js.map} +1 -1
  14. package/dist/{auth-users-columns-CgyPWQ18.js → ensure-collection-tables-jYsvOLZF.js} +1443 -11
  15. package/dist/ensure-collection-tables-jYsvOLZF.js.map +1 -0
  16. package/dist/index.d.ts +16 -16
  17. package/dist/index.es.js +2169 -1803
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CsD7nZDn.js} +2 -2
  20. package/dist/{rls-enforcement-Ch0T6OwW.js.map → rls-enforcement-CsD7nZDn.js.map} +1 -1
  21. package/dist/schema/classify-change.d.ts +82 -0
  22. package/dist/schema/dynamic-tables.d.ts +1 -1
  23. package/dist/schema/ensure-collection-policies.d.ts +1 -1
  24. package/dist/schema/ensure-collection-tables.d.ts +93 -2
  25. package/dist/schema/generate-schema-commit.d.ts +136 -0
  26. package/dist/schema/introspect-db-constraints.d.ts +1 -1
  27. package/dist/schema/introspect-db-logic.d.ts +3 -3
  28. package/dist/schema/introspect-db-project.d.ts +1 -1
  29. package/dist/schema/introspect-db-queries.d.ts +1 -1
  30. package/dist/schema/introspect-db-structure.d.ts +2 -2
  31. package/dist/schema/introspect-runtime.d.ts +1 -1
  32. package/dist/schema/vector-index.d.ts +88 -0
  33. package/dist/services/BranchService.d.ts +2 -2
  34. package/dist/services/FetchService.d.ts +4 -4
  35. package/dist/services/PersistService.d.ts +5 -5
  36. package/dist/services/RelationService.d.ts +3 -3
  37. package/dist/services/RelationWriteService.d.ts +3 -3
  38. package/dist/services/cdc/junction-tables.d.ts +1 -1
  39. package/dist/services/cdc/trigger-cdc.d.ts +1 -1
  40. package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
  41. package/dist/services/channel-bus/index.d.ts +2 -2
  42. package/dist/services/collection-helpers.d.ts +1 -1
  43. package/dist/services/dataService.d.ts +10 -10
  44. package/dist/services/index.d.ts +4 -4
  45. package/dist/services/junction-writes.d.ts +2 -2
  46. package/dist/services/nested-path.d.ts +1 -1
  47. package/dist/services/realtimeService.d.ts +3 -3
  48. package/dist/services/row-pipeline.d.ts +1 -1
  49. package/dist/services/write-denial.d.ts +1 -1
  50. package/dist/utils/drizzle-conditions.d.ts +2 -2
  51. package/dist/websocket-BVgDVO-V.js.map +1 -1
  52. package/dist/websocket.d.ts +2 -2
  53. package/package.json +7 -7
  54. package/src/PostgresBackendDriver.ts +40 -0
  55. package/src/schema/classify-change.ts +436 -0
  56. package/src/schema/ensure-collection-tables.test.ts +168 -1
  57. package/src/schema/ensure-collection-tables.ts +344 -14
  58. package/src/schema/generate-postgres-ddl-logic.ts +15 -0
  59. package/src/schema/generate-schema-commit.ts +242 -0
  60. package/src/schema/vector-index.ts +278 -0
  61. package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
  62. package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
  63. package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
@@ -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 };
@@ -15,6 +15,11 @@ import {
15
15
  searchIndexNames,
16
16
  type SearchColumnSpec
17
17
  } from "./search-column";
18
+ import {
19
+ buildVectorIndexPlan,
20
+ vectorIndexStatements,
21
+ type VectorIndexPlan
22
+ } from "./vector-index";
18
23
  import { REBASE_SCHEMA } from "@rebasepro/types";
19
24
 
20
25
  // --- Helper Functions ---
@@ -719,6 +724,16 @@ export const generatePostgresDdl = async (
719
724
  indexStatements.push(...searchIndexStatements(searchSpec));
720
725
  }
721
726
 
727
+ // ANN indexes for vector columns. Emitted with the other indexes
728
+ // rather than inline, because `CREATE INDEX` is a statement and a
729
+ // column definition is not — and because a column too wide for
730
+ // pgvector to index still needs its column.
731
+ const vectorPlan: VectorIndexPlan = buildVectorIndexPlan(collection, resolveColumnName);
732
+ indexStatements.push(...vectorIndexStatements(vectorPlan));
733
+ for (const skip of vectorPlan.skipped) {
734
+ indexStatements.push(`-- No ANN index on "${skip.schema}"."${skip.table}"."${skip.column}": ${skip.reason}`);
735
+ }
736
+
722
737
  // Backwards compatibility: add default id primary key if missing
723
738
  const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
724
739
  if (!hasPk) {