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

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 (99) hide show
  1. package/dist/PostgresAdapter.d.ts +1 -1
  2. package/dist/PostgresBackendDriver.d.ts +75 -12
  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-BtgHxfFm.js} +5 -4
  9. package/dist/{backup-service-BZoixhVl.js.map → backup-service-BtgHxfFm.js.map} +1 -1
  10. package/dist/cli-helpers.d.ts +41 -0
  11. package/dist/{auth-users-columns-CgyPWQ18.js → collection-index-DxJBvVTH.js} +486 -503
  12. package/dist/collection-index-DxJBvVTH.js.map +1 -0
  13. package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
  14. package/dist/collections/buildRegistry.d.ts +1 -1
  15. package/dist/collections/validate-relations.d.ts +1 -1
  16. package/dist/{connection-BuZ97wsr.js → connection-GOKU3Hu5.js} +34 -7
  17. package/dist/connection-GOKU3Hu5.js.map +1 -0
  18. package/dist/connection.d.ts +16 -0
  19. package/dist/data-transformer.d.ts +1 -1
  20. package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-DFpOl8SM.js} +4 -4
  21. package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
  22. package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
  23. package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
  24. package/dist/index.d.ts +16 -16
  25. package/dist/index.es.js +19 -7128
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/{rls-bootstrap-sql-B5Sajku6.js → rls-bootstrap-sql-DNzaWd4C.js} +3 -3
  28. package/dist/{rls-bootstrap-sql-B5Sajku6.js.map → rls-bootstrap-sql-DNzaWd4C.js.map} +1 -1
  29. package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CInuYj1-.js} +14 -4
  30. package/dist/rls-enforcement-CInuYj1-.js.map +1 -0
  31. package/dist/schema/classify-change.d.ts +82 -0
  32. package/dist/schema/collection-index.d.ts +182 -0
  33. package/dist/schema/dynamic-tables.d.ts +1 -1
  34. package/dist/schema/ensure-collection-policies.d.ts +1 -1
  35. package/dist/schema/ensure-collection-tables.d.ts +93 -2
  36. package/dist/schema/generate-schema-commit.d.ts +136 -0
  37. package/dist/schema/generated-schema-staleness.d.ts +19 -0
  38. package/dist/schema/introspect-db-constraints.d.ts +1 -1
  39. package/dist/schema/introspect-db-logic.d.ts +3 -3
  40. package/dist/schema/introspect-db-project.d.ts +1 -1
  41. package/dist/schema/introspect-db-queries.d.ts +1 -1
  42. package/dist/schema/introspect-db-structure.d.ts +2 -2
  43. package/dist/schema/introspect-runtime.d.ts +1 -1
  44. package/dist/schema/vector-index.d.ts +88 -0
  45. package/dist/services/BranchService.d.ts +2 -2
  46. package/dist/services/FetchService.d.ts +4 -4
  47. package/dist/services/PersistService.d.ts +5 -5
  48. package/dist/services/RelationService.d.ts +3 -3
  49. package/dist/services/RelationWriteService.d.ts +3 -3
  50. package/dist/services/cdc/junction-tables.d.ts +1 -1
  51. package/dist/services/cdc/trigger-cdc.d.ts +1 -1
  52. package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
  53. package/dist/services/channel-bus/index.d.ts +2 -2
  54. package/dist/services/collection-helpers.d.ts +1 -1
  55. package/dist/services/dataService.d.ts +10 -10
  56. package/dist/services/index.d.ts +4 -4
  57. package/dist/services/junction-writes.d.ts +2 -2
  58. package/dist/services/nested-path.d.ts +1 -1
  59. package/dist/services/realtimeService.d.ts +3 -3
  60. package/dist/services/row-pipeline.d.ts +1 -1
  61. package/dist/services/write-denial.d.ts +1 -1
  62. package/dist/{src-BBFsDaeA.js → src-DiDgtX8P.js} +97 -1
  63. package/dist/src-DiDgtX8P.js.map +1 -0
  64. package/dist/utils/drizzle-conditions.d.ts +2 -2
  65. package/dist/websocket-CUnpSe8v.js +8188 -0
  66. package/dist/websocket-CUnpSe8v.js.map +1 -0
  67. package/dist/websocket.d.ts +29 -2
  68. package/package.json +7 -7
  69. package/src/PostgresBackendDriver.ts +190 -59
  70. package/src/backup/backup-service.ts +1 -1
  71. package/src/cli-helpers.ts +117 -2
  72. package/src/cli.ts +22 -0
  73. package/src/connection.ts +37 -3
  74. package/src/databasePoolManager.ts +5 -2
  75. package/src/schema/classify-change.ts +436 -0
  76. package/src/schema/collection-index.ts +427 -0
  77. package/src/schema/ensure-collection-tables.test.ts +168 -1
  78. package/src/schema/ensure-collection-tables.ts +365 -14
  79. package/src/schema/generate-drizzle-schema-logic.ts +23 -11
  80. package/src/schema/generate-drizzle-schema.ts +13 -2
  81. package/src/schema/generate-postgres-ddl-logic.ts +33 -6
  82. package/src/schema/generate-postgres-ddl.ts +13 -2
  83. package/src/schema/generate-schema-commit.ts +242 -0
  84. package/src/schema/generated-schema-staleness.ts +114 -1
  85. package/src/schema/vector-index.ts +278 -0
  86. package/src/services/collection-helpers.ts +3 -2
  87. package/src/websocket.ts +47 -3
  88. package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
  89. package/dist/connection-BuZ97wsr.js.map +0 -1
  90. package/dist/data_driver-ULAyJEi9.js +0 -193
  91. package/dist/data_driver-ULAyJEi9.js.map +0 -1
  92. package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
  93. package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
  94. package/dist/rls-enforcement-Ch0T6OwW.js.map +0 -1
  95. package/dist/src-BBFsDaeA.js.map +0 -1
  96. package/dist/utils/table-classification.d.ts +0 -8
  97. package/dist/websocket-BVgDVO-V.js +0 -614
  98. package/dist/websocket-BVgDVO-V.js.map +0 -1
  99. package/src/utils/table-classification.ts +0 -16
@@ -48,6 +48,8 @@ import {
48
48
  planJunctionTables,
49
49
  quoteSqlLiteral
50
50
  } from "./generate-postgres-ddl-logic";
51
+ import { buildVectorIndexPlan, vectorIndexStatement, type SkippedVectorIndex } from "./vector-index";
52
+ import { buildCollectionIndexPlan, collectionIndexStatement } from "./collection-index";
51
53
  import {
52
54
  AUTH_USERS_COLUMNS,
53
55
  authUsersColumnDefinition,
@@ -100,11 +102,57 @@ export interface ExistingSchema {
100
102
  * and reports nothing as drifted.
101
103
  */
102
104
  columnComments?: Map<string, string>;
105
+ /**
106
+ * `schema.typename` → the values that type currently holds, in order.
107
+ *
108
+ * Without this, an enum type that already exists is skipped whole and a
109
+ * value added to it never reaches the database — the type is there, so
110
+ * nothing plans anything, and the first row using the new value is rejected
111
+ * by a constraint nobody changed. Absent is read as "the values are
112
+ * unknown", which keeps the old skip-by-name behaviour rather than guessing.
113
+ */
114
+ enumValues?: Map<string, string[]>;
115
+ /** `schema.table.column` for every column the database marks NOT NULL. */
116
+ notNullColumns?: Set<string>;
117
+ /**
118
+ * Tables known to hold at least one row.
119
+ *
120
+ * The only thing that decides whether a NOT NULL can be added without
121
+ * reading the data: on an empty table the constraint cannot fail, on a
122
+ * populated one it is checked against every existing row. Absent is read as
123
+ * "assume populated", which is the conservative direction — it withholds a
124
+ * constraint rather than attempting one that aborts the boot.
125
+ */
126
+ populatedTables?: Set<string>;
127
+ }
128
+
129
+ /**
130
+ * How far the planner may go in making the database's constraints match the
131
+ * configuration.
132
+ *
133
+ * - `additive` — the boot default. Columns, tables, indexes and enum values are
134
+ * created; no existing column's constraints are touched. Unattended boots run
135
+ * against customer data with nobody reading a diff, and a database adopted by
136
+ * introspection legitimately carries NOT NULL on columns the generated
137
+ * collection leaves optional (`introspect-db-logic` withholds `required` from
138
+ * a column with a default or a trigger behind it). Converging there would
139
+ * strip real constraints on first boot.
140
+ * - `converge` — the live schema editor. Every statement is planned, shown to
141
+ * the person making the change, and applied only once they confirm it. That
142
+ * is the context in which changing an existing column's constraints is a
143
+ * reviewed act rather than a surprise.
144
+ */
145
+ export type ConstraintPolicy = "additive" | "converge";
146
+
147
+ export interface EnsureOptions {
148
+ /** Defaults to `additive`. See {@link ConstraintPolicy}. */
149
+ constraints?: ConstraintPolicy;
103
150
  }
104
151
 
105
152
  export interface EnsureAction {
106
153
  kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
107
- | "create-extension" | "create-function" | "create-index" | "comment-column";
154
+ | "create-extension" | "create-function" | "create-index" | "comment-column"
155
+ | "add-enum-value" | "set-not-null" | "drop-not-null";
108
156
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
109
157
  target: string;
110
158
  sql: string;
@@ -138,6 +186,44 @@ export interface EnsurePlan {
138
186
  * current block cannot be known, which is what the caller reports.
139
187
  */
140
188
  searchAdopted: { table: string; column: string }[];
189
+ /**
190
+ * Vector columns this plan is deliberately leaving unindexed, because
191
+ * pgvector cannot build an ANN index that wide.
192
+ *
193
+ * Reported rather than thrown: the column is valid, storable and
194
+ * searchable, and refusing the boot over it would make a working
195
+ * configuration unbootable. Reported rather than dropped: an unindexed
196
+ * vector column and an indexed one differ only in latency, so nothing
197
+ * about the running system says which one you got.
198
+ */
199
+ vectorIndexSkipped: SkippedVectorIndex[];
200
+ /**
201
+ * Constraints the configuration asks for that this plan is not applying,
202
+ * and why.
203
+ *
204
+ * This is the half of the feature that matters most. Every one of these was
205
+ * previously withheld in silence: a required property arrived nullable, and
206
+ * the only evidence was a database that disagreed with its own
207
+ * configuration. Reporting them is what lets boot warn, the live editor
208
+ * refuse, and the doctor explain — three surfaces that until now had nothing
209
+ * to read.
210
+ */
211
+ withheldConstraints: WithheldConstraint[];
212
+ }
213
+
214
+ /** A constraint the configuration asks for that the planner is not applying. */
215
+ export interface WithheldConstraint {
216
+ /** `schema.table.column`. */
217
+ target: string;
218
+ kind: "not-null";
219
+ /**
220
+ * Why, in a sentence that names the obstacle rather than the rule. The
221
+ * reader is looking at a column that is nullable when they asked for
222
+ * required, and needs to know what to do about it.
223
+ */
224
+ reason: string;
225
+ /** What would make it applicable. */
226
+ remedy: string;
141
227
  }
142
228
 
143
229
  /**
@@ -238,8 +324,11 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
238
324
  */
239
325
  export function planCollectionSchemaEnsure(
240
326
  allCollections: CollectionConfig[],
241
- existing: ExistingSchema
327
+ existing: ExistingSchema,
328
+ options: EnsureOptions = {}
242
329
  ): EnsurePlan {
330
+ const constraintPolicy: ConstraintPolicy = options.constraints ?? "additive";
331
+ const withheldConstraints: WithheldConstraint[] = [];
243
332
  // Boot receives every collection the bundle declares, including the ones
244
333
  // served by another engine entirely. Creating a Postgres table for a
245
334
  // Firestore collection is not a harmless extra: the app keeps reading
@@ -257,7 +346,35 @@ export function planCollectionSchemaEnsure(
257
346
  // skipped by name rather than guarded in SQL.
258
347
  for (const collection of collections) {
259
348
  for (const { name, values } of requiredEnums(collection)) {
260
- if (existing.enums.has(name) || plannedEnums.has(name)) continue;
349
+ if (existing.enums.has(name) || plannedEnums.has(name)) {
350
+ // The type is there, but that says nothing about its *values*.
351
+ // Skipping the whole type by name is what made an added enum
352
+ // value vanish: nothing was planned, the boot reported success,
353
+ // and the first row using the value was rejected by a type that
354
+ // had never heard of it. `ADD VALUE` is the one alteration
355
+ // Postgres offers here, it is purely additive, and it is
356
+ // idempotent with `IF NOT EXISTS`.
357
+ //
358
+ // `enumValues` absent means the caller built the schema by hand
359
+ // and does not know the values; skip by name as before rather
360
+ // than plan against a guess.
361
+ const current = existing.enumValues?.get(name);
362
+ if (!current || plannedEnums.has(name)) continue;
363
+ const [schema, typeName] = name.split(".");
364
+ for (const value of values) {
365
+ if (current.includes(value)) continue;
366
+ actions.push({
367
+ kind: "add-enum-value",
368
+ target: `${name}.${value}`,
369
+ // Not inside a transaction with any use of the value:
370
+ // Postgres refuses to read a value added by the
371
+ // transaction still adding it. The applier runs these
372
+ // one statement at a time, which is what makes it legal.
373
+ sql: `ALTER TYPE "${schema}"."${typeName}" ADD VALUE IF NOT EXISTS ${quoteSqlLiteral(value)};`
374
+ });
375
+ }
376
+ continue;
377
+ }
261
378
  plannedEnums.add(name);
262
379
  const [schema, typeName] = name.split(".");
263
380
  actions.push({
@@ -445,11 +562,93 @@ export function planCollectionSchemaEnsure(
445
562
  // safe on a live table, and a column added without it would take the
446
563
  // value the application forgot to send rather than `now()`.
447
564
  const autoValue = (p as { autoValue?: string }).autoValue;
448
- if (p.type === "date" && (autoValue === "on_create" || autoValue === "on_update")) {
449
- definition += " DEFAULT now()";
565
+ const hasDefault = p.type === "date" && (autoValue === "on_create" || autoValue === "on_update");
566
+ if (hasDefault) definition += " DEFAULT now()";
567
+
568
+ const required = p.validation?.required === true;
569
+ const columnKey = `${key}.${column}`;
570
+ const columnExists = existing.tables.get(key)?.has(column) === true;
571
+
572
+ // A NOT NULL is safe exactly when it cannot fail against rows that
573
+ // are already there, and there are three ways to know that:
574
+ //
575
+ // - the table is being created by this plan (no rows yet);
576
+ // - the table exists and is empty;
577
+ // - the column arrives with a DEFAULT, which Postgres backfills
578
+ // into every existing row as part of ADD COLUMN.
579
+ //
580
+ // Anything else is checked against live data and can abort the boot,
581
+ // which is why it used to be withheld — correctly. What was wrong was
582
+ // withholding it in *silence*: the config said required, the column
583
+ // came out nullable, and nothing anywhere said so.
584
+ // `populatedTables` absent means the caller does not know, and not
585
+ // knowing has to read as "assume rows" — the other direction emits a
586
+ // NOT NULL that is checked against live data and aborts the boot.
587
+ // Written as an explicit `!== undefined` because the optional-chain
588
+ // form (`!existing.populatedTables?.has(key)`) quietly says *empty*
589
+ // when the fact is missing, which is the wrong way to be wrong.
590
+ const tableIsEmpty = existing.populatedTables !== undefined
591
+ && existing.tables.has(key)
592
+ && !existing.populatedTables.has(key);
593
+ const notNullIsSafe = fresh || tableIsEmpty || hasDefault;
594
+
595
+ if (required && !columnExists) {
596
+ if (notNullIsSafe) {
597
+ definition += " NOT NULL";
598
+ } else {
599
+ withheldConstraints.push({
600
+ target: columnKey,
601
+ kind: "not-null",
602
+ reason:
603
+ `"${column}" is required, but "${key}" already holds rows and the column ` +
604
+ "has no default to backfill them with, so NOT NULL would be checked " +
605
+ "against data that does not have a value yet.",
606
+ remedy:
607
+ "Backfill the column, then add the constraint — or give the property a " +
608
+ "default so every existing row gets one."
609
+ });
610
+ }
450
611
  }
451
- if (fresh && p.validation?.required) definition += " NOT NULL";
452
612
  addColumn(key, schema, table, column, definition);
613
+
614
+ // The column is already there and only its constraint differs. Two
615
+ // directions, and they are not equally safe — see `ConstraintPolicy`
616
+ // for why neither runs at an unattended boot.
617
+ if (columnExists && constraintPolicy === "converge") {
618
+ const isNotNull = existing.notNullColumns?.has(columnKey) === true;
619
+ if (required && !isNotNull) {
620
+ if (tableIsEmpty) {
621
+ actions.push({
622
+ kind: "set-not-null",
623
+ target: columnKey,
624
+ sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" SET NOT NULL;`
625
+ });
626
+ } else {
627
+ withheldConstraints.push({
628
+ target: columnKey,
629
+ kind: "not-null",
630
+ reason:
631
+ `"${column}" became required, but "${key}" holds rows and any of them ` +
632
+ "with no value would make SET NOT NULL fail.",
633
+ remedy:
634
+ "Backfill the column first — `UPDATE … SET \"" + column +
635
+ "\" = … WHERE \"" + column + "\" IS NULL` — then apply this again."
636
+ });
637
+ }
638
+ }
639
+ if (!required && isNotNull) {
640
+ // Loosening never fails and never loses data. It is here
641
+ // rather than at boot because a database adopted by
642
+ // introspection carries NOT NULL on columns the generated
643
+ // collection deliberately leaves optional, and converging
644
+ // those unasked would drop constraints nobody edited.
645
+ actions.push({
646
+ kind: "drop-not-null",
647
+ target: columnKey,
648
+ sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" DROP NOT NULL;`
649
+ });
650
+ }
651
+ }
453
652
  }
454
653
 
455
654
  // The auth columns the collection never mentions. The scaffold's users
@@ -595,7 +794,54 @@ export function planCollectionSchemaEnsure(
595
794
  }
596
795
  }
597
796
 
598
- return { actions, statements: actions.map(a => a.sql), legacyForeignKeys, searchDrift, searchAdopted };
797
+ // ANN indexes for vector columns, on the same terms: the column has to
798
+ // exist, the build is real work against real rows, and CONCURRENTLY is
799
+ // what keeps that from locking writes for its duration.
800
+ //
801
+ // A column too wide for pgvector to index is reported, not planned —
802
+ // silence there would read as "indexed" to anyone watching the boot.
803
+ const vectorIndexSkipped: SkippedVectorIndex[] = [];
804
+ for (const collection of collections) {
805
+ const plan = buildVectorIndexPlan(collection, resolveColumnName);
806
+ for (const spec of plan.specs) {
807
+ actions.push({
808
+ kind: "create-index",
809
+ target: `${spec.schema}.${spec.table}`,
810
+ sql: vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
811
+ });
812
+ }
813
+ vectorIndexSkipped.push(...plan.skipped);
814
+ }
815
+
816
+ // Declared indexes, on exactly the same terms as the ANN ones above.
817
+ //
818
+ // Boot has to emit these, not just `db push`: the managed runtime
819
+ // provisions at boot and never runs a push, so a push-only index would
820
+ // simply not exist there — and nothing would say so.
821
+ // `contracts/derived-names.txt` states the rule ("Both, or it is a
822
+ // bug") and the gate enforces it, which is what caught this.
823
+ //
824
+ // `concurrently` is a parameter here rather than a string replacement
825
+ // on the rendered SQL. The `.replace("CREATE INDEX IF NOT EXISTS", …)`
826
+ // just above silently does nothing for a UNIQUE index, whose text is
827
+ // `CREATE UNIQUE INDEX …` and never matches the pattern.
828
+ for (const spec of buildCollectionIndexPlan(collections, resolveColumnName)) {
829
+ actions.push({
830
+ kind: "create-index",
831
+ target: `${spec.schema}.${spec.table}`,
832
+ sql: collectionIndexStatement(spec, { concurrently: true, ifNotExists: true })
833
+ });
834
+ }
835
+
836
+ return {
837
+ actions,
838
+ statements: actions.map(a => a.sql),
839
+ legacyForeignKeys,
840
+ searchDrift,
841
+ searchAdopted,
842
+ vectorIndexSkipped,
843
+ withheldConstraints
844
+ };
599
845
  }
600
846
 
601
847
  /** Read what the database has, for the schemas the collections live in. */
@@ -611,12 +857,14 @@ export async function readExistingSchema(
611
857
  .map(schema => `'${assertSafeIdentifier(schema, "schema name")}'`)
612
858
  .join(", ");
613
859
 
860
+ const notNullColumns = new Set<string>();
614
861
  const { rows: columns } = await client.query<{
615
862
  table_schema: string;
616
863
  table_name: string;
617
864
  column_name: string;
865
+ is_nullable: string;
618
866
  }>(
619
- `SELECT table_schema, table_name, column_name
867
+ `SELECT table_schema, table_name, column_name, is_nullable
620
868
  FROM information_schema.columns
621
869
  WHERE table_schema IN (${inList})`
622
870
  );
@@ -624,6 +872,64 @@ export async function readExistingSchema(
624
872
  const key = `${row.table_schema}.${row.table_name}`;
625
873
  if (!tables.has(key)) tables.set(key, new Set());
626
874
  tables.get(key)!.add(row.column_name);
875
+ if (row.is_nullable === "NO") notNullColumns.add(`${key}.${row.column_name}`);
876
+ }
877
+
878
+ // Which tables hold rows. This is the only fact that decides whether a
879
+ // NOT NULL can be added without reading the data, so it is worth a query.
880
+ //
881
+ // `reltuples` would be cheaper and is wrong for this: it is a planner
882
+ // estimate, it is -1 on a table that has never been analyzed, and a table
883
+ // that was full an hour ago still reads as full after a DELETE. A wrong
884
+ // "empty" here means a boot that aborts on a constraint violation, so the
885
+ // estimate is not good enough. `EXISTS … LIMIT 1` stops at the first row,
886
+ // which makes the true cost one page read per table.
887
+ //
888
+ // Restricted to ordinary and partitioned tables: `information_schema.columns`
889
+ // also lists views and materialized views, and probing those runs whatever
890
+ // query defines them.
891
+ const populatedTables = new Set<string>();
892
+ const { rows: realTables } = await client.query<{ schema: string; name: string }>(
893
+ `SELECT n.nspname AS schema, c.relname AS name
894
+ FROM pg_class c
895
+ JOIN pg_namespace n ON c.relnamespace = n.oid
896
+ WHERE c.relkind IN ('r', 'p') AND n.nspname IN (${inList})`
897
+ );
898
+ if (realTables.length > 0) {
899
+ const probes = realTables.map(row => {
900
+ const schema = assertSafeIdentifier(row.schema, "schema name");
901
+ const table = assertSafeIdentifier(row.name, "table name");
902
+ return `SELECT ${quoteSqlLiteral(`${schema}.${table}`)} AS key, ` +
903
+ `EXISTS(SELECT 1 FROM "${schema}"."${table}" LIMIT 1) AS populated`;
904
+ });
905
+ const { rows: populationRows } = await client.query<{ key: string; populated: boolean }>(
906
+ probes.join(" UNION ALL ")
907
+ );
908
+ for (const row of populationRows) {
909
+ if (row.populated) populatedTables.add(row.key);
910
+ }
911
+ }
912
+
913
+ const enumValues = new Map<string, string[]>();
914
+ const { rows: enumValueRows } = await client.query<{
915
+ schema: string;
916
+ name: string;
917
+ value: string;
918
+ }>(
919
+ // Ordered by `enumsortorder`, not by label: an enum's order is part of
920
+ // its meaning (it is what `<` compares), and reading it back sorted
921
+ // alphabetically would make a correct type look drifted.
922
+ `SELECT n.nspname AS schema, t.typname AS name, e.enumlabel AS value
923
+ FROM pg_enum e
924
+ JOIN pg_type t ON e.enumtypid = t.oid
925
+ JOIN pg_namespace n ON t.typnamespace = n.oid
926
+ WHERE n.nspname IN (${inList})
927
+ ORDER BY t.typname, e.enumsortorder`
928
+ );
929
+ for (const row of enumValueRows) {
930
+ const key = `${row.schema}.${row.name}`;
931
+ if (!enumValues.has(key)) enumValues.set(key, []);
932
+ enumValues.get(key)!.push(row.value);
627
933
  }
628
934
 
629
935
  const { rows: enumRows } = await client.query<{ schema: string; name: string }>(
@@ -672,7 +978,7 @@ export async function readExistingSchema(
672
978
  columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
673
979
  }
674
980
 
675
- return { tables, enums, constraints, columnComments };
981
+ return { tables, enums, constraints, columnComments, enumValues, notNullColumns, populatedTables };
676
982
  }
677
983
 
678
984
  /**
@@ -707,23 +1013,47 @@ function searchDriftMessage(drift: SearchColumnDrift[]): string {
707
1013
  * The missing-pgvector explanation, appended to the error that reveals it.
708
1014
  *
709
1015
  * 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
1016
+ * OSS pipeline installs pgvector — not this ensure, not `db push`. Installing
1017
+ * an extension on someone's database is a decision with a deployment behind it
713
1018
  * (image, superuser, cloud allow-list), so this path stays a refusal; what it
714
1019
  * must not stay is a bare `type "vector" does not exist` on a crash-looping
715
1020
  * pod, which names nothing the reader can act on.
1021
+ *
1022
+ * The scaffold now ships `pgvector/pgvector:pg18`, so this is reached by a
1023
+ * project pointed at a database someone else provisioned — which is exactly
1024
+ * the case where naming the extension and the image is worth the words.
716
1025
  */
717
1026
  function vectorExtensionHint(message: string): string {
718
1027
  if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
719
1028
  return (
720
1029
  "\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` " +
1030
+ "so it needs an image that ships it (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` " +
722
1031
  "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."
1032
+ "Rebase then creates an ANN index for the column automatically — see the `index` option on the property."
724
1033
  );
725
1034
  }
726
1035
 
1036
+ /**
1037
+ * Read what the database looks like, for the schemas a set of collections
1038
+ * lives in.
1039
+ *
1040
+ * The same read `ensureCollectionTables` does at boot, exposed on its own for
1041
+ * the callers that want to *plan* against a real database without changing it —
1042
+ * the live schema editor, which has to tell somebody what a change would do
1043
+ * before they agree to it.
1044
+ */
1045
+ export async function readSchemaFactsFor(
1046
+ client: Queryable,
1047
+ collections: CollectionConfig[]
1048
+ ): Promise<ExistingSchema> {
1049
+ const relational = relationalCollections(collections);
1050
+ const schemas = Array.from(new Set([
1051
+ ...relational.map(schemaOf),
1052
+ ...planJunctionTables(relational).map(junction => junction.schema)
1053
+ ]));
1054
+ return readExistingSchema(client, schemas);
1055
+ }
1056
+
727
1057
  /**
728
1058
  * Bring the database up to date. Returns what it did.
729
1059
  *
@@ -793,6 +1123,27 @@ export async function ensureCollectionTables(
793
1123
  log?.(message);
794
1124
  }
795
1125
 
1126
+ // Said once per column, every boot: an unindexed vector column and an
1127
+ // indexed one behave identically apart from latency, so the only way anyone
1128
+ // learns which one they have is if the boot says so.
1129
+ for (const skip of plan.vectorIndexSkipped) {
1130
+ const message = `No ANN index on "${skip.table}"."${skip.column}": ${skip.reason}`;
1131
+ logger.warn(`[schema] ${message}`);
1132
+ log?.(message);
1133
+ }
1134
+
1135
+ // Said once per column, every boot, because the alternative is what this
1136
+ // whole feature exists to end: a column the configuration calls required,
1137
+ // sitting there nullable, with every surface reporting success. The boot
1138
+ // does not fail over it — the column is usable and the data is intact — but
1139
+ // it stops being invisible.
1140
+ for (const withheld of plan.withheldConstraints) {
1141
+ const message =
1142
+ `No NOT NULL on "${withheld.target}": ${withheld.reason} ${withheld.remedy}`;
1143
+ logger.warn(`[schema] ${message}`);
1144
+ log?.(message);
1145
+ }
1146
+
796
1147
  if (plan.actions.length === 0) {
797
1148
  log?.("Schema is up to date; nothing to create.");
798
1149
  return { ...plan, failures };
@@ -308,7 +308,12 @@ export const getDrizzleColumn = (propName: string, prop: Property, collection: C
308
308
  const targetTableVar = getTableVarName(getTableName(targetCollection));
309
309
  const pkProp = getPrimaryKeyProp(targetCollection);
310
310
  const targetIdField = pkProp.name;
311
- const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`);
311
+ // `quote`, like every other column literal in this file: a column
312
+ // name only has to be quotable in Postgres, and this one is derived
313
+ // from a relation the author wrote.
314
+ const baseColumn = pkProp.type === "number"
315
+ ? `integer(${quote(fkColumnName)})`
316
+ : (pkProp.isUuid ? `uuid(${quote(fkColumnName)})` : `text(${quote(fkColumnName)})`);
312
317
 
313
318
  const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
314
319
  const required = prop.validation?.required;
@@ -681,10 +686,13 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
681
686
  const targetId = getPrimaryKeyName(targetCollection);
682
687
 
683
688
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
684
- schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
685
- schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
689
+ // The junction block was the one place these three helpers were not
690
+ // applied, so a junction column containing a space or a hyphen —
691
+ // both legal in Postgres — produced a file that does not parse.
692
+ schemaContent += ` ${propKey(sourceColumn)}: ${sourceColType}(${quote(sourceColumn)}).notNull().references(() => ${member(getTableVarName(getTableName(sourceCollection)), sourceId)}, ${refOptions}),\n`;
693
+ schemaContent += ` ${propKey(targetColumn)}: ${targetColType}(${quote(targetColumn)}).notNull().references(() => ${member(getTableVarName(getTableName(targetCollection)), targetId)}, ${refOptions}),\n`;
686
694
  schemaContent += "}, (table) => ([\n";
687
- schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
695
+ schemaContent += ` primaryKey({ columns: [${member("table", sourceColumn)}, ${member("table", targetColumn)}] }),\n`;
688
696
 
689
697
  // Junctions are generated tables like any other: locked by default,
690
698
  // with derived policies (reads follow the endpoints, writes follow
@@ -719,19 +727,20 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
719
727
  const searchSpec = buildSearchColumnSpec(collection);
720
728
  if (searchSpec) {
721
729
  columns.add(
722
- ` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}")` +
730
+ ` ${propKey(searchSpec.column)}: customType({ dataType() { return 'tsvector'; } })(${quote(searchSpec.column)})` +
723
731
  `.generatedAlwaysAs(sql\`${searchSpec.expression}\`)`
724
732
  );
725
733
  if (searchSpec.fuzzy) {
726
734
  columns.add(
727
- ` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}")` +
735
+ ` ${propKey(searchSpec.fuzzy.column)}: text(${quote(searchSpec.fuzzy.column)})` +
728
736
  `.generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`
729
737
  );
730
738
  }
731
739
  }
732
740
 
733
- // Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
734
- // We should generate a basic id column if one was completely omitted.
741
+ // A collection that declares no primary key gets an implicit one:
742
+ // `id TEXT PRIMARY KEY`. The DDL generator emits the same column,
743
+ // and `derivePrimaryKeys` reads it back.
735
744
  const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
736
745
  if (!hasIdColumn) {
737
746
  columns.add(" id: text(\"id\").primaryKey()");
@@ -860,11 +869,14 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
860
869
  // `fields`/`references` here is invalid and crashes
861
870
  // `normalizeRelation` with "Cannot read properties of
862
871
  // undefined (reading 'referencedTable')".
863
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
872
+ // A `relationName` is authored, and a `"` in one
873
+ // closed this string literal early — in a file that
874
+ // is compiled and imported by the server.
875
+ tableRelations.push(` ${quote(relationKey)}: one(${targetTableVar}, {\n relationName: ${quote(drizzleRelationName)}\n })`);
864
876
  break;
865
877
 
866
878
  case "hasMany":
867
- tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
879
+ tableRelations.push(` ${quote(relationKey)}: many(${targetTableVar}, { relationName: ${quote(drizzleRelationName)} })`);
868
880
  break;
869
881
 
870
882
  case "manyToMany": {
@@ -874,7 +886,7 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
874
886
  // junction table — unnecessary now that each side names
875
887
  // its own.
876
888
  const junctionTableVar = getTableVarName(rel.through.table);
877
- tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
889
+ tableRelations.push(` ${quote(relationKey)}: many(${junctionTableVar}, { relationName: ${quote(drizzleRelationName)} })`);
878
890
  break;
879
891
  }
880
892
 
@@ -2,7 +2,6 @@ import { promises as fsPromises } from "fs";
2
2
  import * as fs from "fs";
3
3
  import path from "path";
4
4
  import { pathToFileURL } from "url";
5
- import chokidar from "chokidar";
6
5
  import { generateSchema } from "./generate-drizzle-schema-logic";
7
6
  import { CollectionConfig } from "@rebasepro/types";
8
7
  import { loadCollectionsFromDirectory } from "@rebasepro/server";
@@ -93,7 +92,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
93
92
  }
94
93
  };
95
94
 
96
- const main = () => {
95
+ const main = async () => {
97
96
  const collectionsFilePathArg = process.argv.find(arg => arg.startsWith("--collections="));
98
97
  const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
99
98
 
@@ -112,6 +111,18 @@ const main = () => {
112
111
 
113
112
  if (watch) {
114
113
  out(`Watching for changes in ${resolvedPath}...`);
114
+ // Imported here rather than at module scope, and this is not a style
115
+ // choice: chokidar is needed only by `--watch`, which is a
116
+ // schema-authoring path that never runs inside the runtime image. A
117
+ // top-level import puts it on the boot path of the published driver
118
+ // bundle, and the image installs a hand-listed set of runtime
119
+ // dependencies that does not include it — so the whole driver failed to
120
+ // load with "Cannot find package 'chokidar'", and every self-hosted
121
+ // container answered 500 with a stack trace about a file watcher.
122
+ //
123
+ // Same reasoning the image already applies to @ariga/atlas: an
124
+ // authoring-only dependency does not belong on a boot path.
125
+ const { default: chokidar } = await import("chokidar");
115
126
  const watcher = chokidar.watch(resolvedPath, {
116
127
  persistent: true,
117
128
  ignoreInitial: false