@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -34,6 +34,8 @@ import {
34
34
  searchExtensionStatements,
35
35
  searchHelperFunctions,
36
36
  searchIndexStatements,
37
+ searchColumnStamps,
38
+ SEARCH_STAMP_PREFIX,
37
39
  SEARCH_TEXT_FN,
38
40
  SEARCH_UNACCENT_FN,
39
41
  type SearchColumnSpec
@@ -90,11 +92,19 @@ export interface ExistingSchema {
90
92
  * constraint that then fails harmlessly as a duplicate.
91
93
  */
92
94
  constraints?: Set<string>;
95
+ /**
96
+ * `schema.table.column` → that column's comment, for the columns that have
97
+ * one. This is where a generated search column's fingerprint lives, so it
98
+ * is the only evidence that a `search` block has changed since the column
99
+ * was built. Absent is read as "no column is stamped", which plans a stamp
100
+ * and reports nothing as drifted.
101
+ */
102
+ columnComments?: Map<string, string>;
93
103
  }
94
104
 
95
105
  export interface EnsureAction {
96
106
  kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
97
- | "create-extension" | "create-function" | "create-index";
107
+ | "create-extension" | "create-function" | "create-index" | "comment-column";
98
108
  /** Qualified target, for logging: `public.posts` or `public.posts.title`. */
99
109
  target: string;
100
110
  sql: string;
@@ -115,6 +125,47 @@ export interface EnsurePlan {
115
125
  * resolving to nothing — which is indistinguishable from having no data.
116
126
  */
117
127
  legacyForeignKeys: LegacyForeignKey[];
128
+ /**
129
+ * Generated search columns whose `search` block has changed since they were
130
+ * built. Reported, never planned into `actions` — see
131
+ * {@link SearchColumnDrift} for why applying it is not this path's call.
132
+ */
133
+ searchDrift: SearchColumnDrift[];
134
+ /**
135
+ * Generated search columns that exist but carry no fingerprint — created
136
+ * before this check existed, or by `search.sql` on an older CLI. The plan
137
+ * stamps them so the *next* change is detectable; whether they match the
138
+ * current block cannot be known, which is what the caller reports.
139
+ */
140
+ searchAdopted: { table: string; column: string }[];
141
+ }
142
+
143
+ /**
144
+ * A generated search column built from a `search` block that has since changed.
145
+ *
146
+ * Reported instead of applied because the two ways to apply it are both worse
147
+ * than stopping. `ALTER COLUMN … SET EXPRESSION` exists only on PG17+ and
148
+ * rewrites the table either way; `DROP COLUMN` + `ADD COLUMN` rewrites it under
149
+ * an ACCESS EXCLUSIVE lock and rebuilds the GIN index. This module runs
150
+ * unattended against live customer data with nobody reading a diff — the same
151
+ * reason it withholds `SET NOT NULL` from an adopted table — so a multi-minute
152
+ * outage is not a decision it may take on its own.
153
+ *
154
+ * Not applying it silently is not an option either: that is the bug this
155
+ * detection exists for. A collection that added a field, flipped `unaccent` or
156
+ * raised a weight kept indexing the *old* set forever, and the only symptom was
157
+ * searches returning nothing for content plainly in the row.
158
+ */
159
+ export interface SearchColumnDrift {
160
+ /** `schema.table`. */
161
+ table: string;
162
+ column: string;
163
+ /** The fingerprint recorded on the column. */
164
+ found: string;
165
+ /** The fingerprint the current `search` block computes. */
166
+ expected: string;
167
+ /** The statements that would rebuild the column, for the operator to run. */
168
+ rebuild: string[];
118
169
  }
119
170
 
120
171
  /** A relation column whose old and new spellings both plausibly apply. */
@@ -129,14 +180,16 @@ export interface LegacyForeignKey {
129
180
 
130
181
  export interface EnsureOutcome extends EnsurePlan {
131
182
  /**
132
- * Constraints that could not be added always non-fatal.
183
+ * Actions that could not be applied and are non-fatal by nature.
133
184
  *
134
- * A foreign key can only fail on data that already violates it, and the
135
- * column it would police exists either way, so the collection still serves.
136
- * Refusing to boot over one would turn a pre-existing data problem into an
137
- * outage. Reported loudly instead.
185
+ * Two kinds qualify. A foreign key can only fail on data that already
186
+ * violates it, and the column it would police exists either way, so the
187
+ * collection still serves; refusing to boot over one would turn a
188
+ * pre-existing data problem into an outage. A column comment is the search
189
+ * fingerprint, which needs table ownership — losing it costs drift
190
+ * detection on the next boot, not the deployment. Both are reported loudly.
138
191
  */
139
- failures: { target: string; error: string }[];
192
+ failures: { kind: EnsureAction["kind"]; target: string; error: string }[];
140
193
  }
141
194
 
142
195
  function schemaOf(collection: CollectionConfig): string {
@@ -424,13 +477,57 @@ export function planCollectionSchemaEnsure(
424
477
  // one is not free — but it is the same additive shape as every other
425
478
  // column here, and the alternative (leaving it out until someone runs a
426
479
  // migration) is a declared `search` block that silently does nothing.
480
+ //
481
+ // Changing one is not additive, and `ADD COLUMN IF NOT EXISTS` is a
482
+ // no-op against a column that is already there — which is why a `search`
483
+ // block that gained a field, flipped `unaccent` or moved a weight used
484
+ // to be inert forever, on every path, with nothing logged. Each column
485
+ // therefore carries a fingerprint of the expression it was built from
486
+ // (in its comment), and a mismatch is reported rather than applied.
487
+ const searchDrift: SearchColumnDrift[] = [];
488
+ const searchAdopted: { table: string; column: string }[] = [];
427
489
  for (const spec of searchSpecs) {
428
490
  const key = `${spec.schema}.${spec.table}`;
429
- addColumn(key, spec.schema, spec.table, spec.column,
430
- `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`);
491
+ const definitions: Record<string, string> = {
492
+ [spec.column]: `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`
493
+ };
431
494
  if (spec.fuzzy) {
432
- addColumn(key, spec.schema, spec.table, spec.fuzzy.column,
433
- `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`);
495
+ definitions[spec.fuzzy.column] = `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`;
496
+ }
497
+
498
+ for (const stamp of searchColumnStamps(spec)) {
499
+ const definition = definitions[stamp.column];
500
+ const exists = existing.tables.get(key)?.has(stamp.column) === true;
501
+ const recorded = existing.columnComments?.get(`${key}.${stamp.column}`);
502
+
503
+ if (exists && recorded?.startsWith(SEARCH_STAMP_PREFIX) && recorded !== stamp.fingerprint) {
504
+ searchDrift.push({
505
+ table: key,
506
+ column: stamp.column,
507
+ found: recorded,
508
+ expected: stamp.fingerprint,
509
+ rebuild: [
510
+ `ALTER TABLE "${spec.schema}"."${spec.table}" DROP COLUMN "${stamp.column}";`,
511
+ `ALTER TABLE "${spec.schema}"."${spec.table}" ADD COLUMN "${stamp.column}" ${definition};`,
512
+ stamp.sql
513
+ ]
514
+ });
515
+ // The old stamp is the only evidence of what the column holds;
516
+ // overwriting it here would erase the drift instead of fixing it.
517
+ continue;
518
+ }
519
+
520
+ addColumn(key, spec.schema, spec.table, stamp.column, definition);
521
+ if (exists && recorded === undefined) {
522
+ searchAdopted.push({ table: key, column: stamp.column });
523
+ }
524
+ if (recorded !== stamp.fingerprint) {
525
+ actions.push({
526
+ kind: "comment-column",
527
+ target: `${key}.${stamp.column}`,
528
+ sql: stamp.sql
529
+ });
530
+ }
434
531
  }
435
532
  }
436
533
 
@@ -498,7 +595,7 @@ export function planCollectionSchemaEnsure(
498
595
  }
499
596
  }
500
597
 
501
- return { actions, statements: actions.map(a => a.sql), legacyForeignKeys };
598
+ return { actions, statements: actions.map(a => a.sql), legacyForeignKeys, searchDrift, searchAdopted };
502
599
  }
503
600
 
504
601
  /** Read what the database has, for the schemas the collections live in. */
@@ -553,7 +650,78 @@ export async function readExistingSchema(
553
650
  );
554
651
  for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
555
652
 
556
- return { tables, enums, constraints };
653
+ // Column comments, which is where a generated search column records the
654
+ // expression it was built from. `objsubid > 0` is what makes a row a
655
+ // *column* comment rather than the table's own.
656
+ const columnComments = new Map<string, string>();
657
+ const { rows: commentRows } = await client.query<{
658
+ schema: string;
659
+ table: string;
660
+ column: string;
661
+ comment: string | null;
662
+ }>(
663
+ `SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, d.description AS comment
664
+ FROM pg_description d
665
+ JOIN pg_class c ON d.objoid = c.oid
666
+ JOIN pg_namespace n ON c.relnamespace = n.oid
667
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid
668
+ WHERE d.objsubid > 0 AND n.nspname IN (${inList})`
669
+ );
670
+ for (const row of commentRows) {
671
+ if (row.comment == null) continue;
672
+ columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
673
+ }
674
+
675
+ return { tables, enums, constraints, columnComments };
676
+ }
677
+
678
+ /**
679
+ * What to tell an operator whose `search` block no longer matches its column.
680
+ *
681
+ * Every line here is doing work: naming the collection is not enough, because
682
+ * the symptom (a search that finds nothing) points at the data, not the schema;
683
+ * and the remediation has to be exact, because it is a table rewrite the
684
+ * operator is being asked to schedule rather than discover.
685
+ */
686
+ function searchDriftMessage(drift: SearchColumnDrift[]): string {
687
+ const blocks = drift.map(d =>
688
+ ` "${d.table}"."${d.column}" was generated from a different \`search\` block ` +
689
+ `(recorded ${d.found}, current ${d.expected}).\n` +
690
+ d.rebuild.map(s => ` ${s}`).join("\n")
691
+ );
692
+ return (
693
+ "The `search` block changed after its generated column was created, and Postgres cannot alter a " +
694
+ "generated expression in place.\n" +
695
+ "Rebase will not rebuild it for you: dropping and re-adding a STORED generated column rewrites the whole " +
696
+ "table under an ACCESS EXCLUSIVE lock and rebuilds its GIN index, which is an outage this unattended path " +
697
+ "may not schedule on your behalf.\n" +
698
+ "Until it is rebuilt the column keeps indexing the previous fields, weights and language — searches for " +
699
+ "anything added since return nothing, which reads from outside as \"no such row\".\n" +
700
+ "Run these (or revert the block to what the column was built from), then boot again:\n" +
701
+ blocks.join("\n") +
702
+ "\n The GIN index is dropped with the column and recreated concurrently on the next boot."
703
+ );
704
+ }
705
+
706
+ /**
707
+ * The missing-pgvector explanation, appended to the error that reveals it.
708
+ *
709
+ * A `{ type: "vector" }` property compiles to `VECTOR(n)`, and nothing in the
710
+ * OSS pipeline installs pgvector — not this ensure, not `db push`, not the
711
+ * scaffold's `postgres:18-alpine`, which does not ship it. Installing an
712
+ * extension on someone's database is a decision with a deployment behind it
713
+ * (image, superuser, cloud allow-list), so this path stays a refusal; what it
714
+ * must not stay is a bare `type "vector" does not exist` on a crash-looping
715
+ * pod, which names nothing the reader can act on.
716
+ */
717
+ function vectorExtensionHint(message: string): string {
718
+ if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
719
+ return (
720
+ "\n pgvector is not installed on this database, and Rebase does not install it: it is a server extension, " +
721
+ "so it needs an image that ships it (e.g. `pgvector/pgvector:pg18` — the scaffold's `postgres:18-alpine` " +
722
+ "does not) and a role allowed to run `CREATE EXTENSION vector;`. Install it once, then boot again. " +
723
+ "Note also that Rebase creates no ANN index for a vector column, so `vectorSearch` is an exact scan."
724
+ );
557
725
  }
558
726
 
559
727
  /**
@@ -585,7 +753,7 @@ export async function ensureCollectionTables(
585
753
 
586
754
  const existing = await readExistingSchema(client, schemas);
587
755
  const plan = planCollectionSchemaEnsure(collections, existing);
588
- const failures: { target: string; error: string }[] = [];
756
+ const failures: EnsureOutcome["failures"] = [];
589
757
 
590
758
  // Reported, not warned: this is a rename the ensure is about to perform, and
591
759
  // the operator should be able to see in the log why a column changed name.
@@ -602,6 +770,29 @@ export async function ensureCollectionTables(
602
770
  log?.(message);
603
771
  }
604
772
 
773
+ // Before anything is applied: a `search` block that changed after its column
774
+ // was generated cannot be honoured by an additive plan, and serving the old
775
+ // index while the config describes a new one is the silent failure this
776
+ // check exists to end. Refusing is the loud half — boot is fatal on purpose
777
+ // (see `ensureCollectionSchema` in the server's boot) and the message
778
+ // carries the exact statements that resolve it.
779
+ if (plan.searchDrift.length > 0) {
780
+ throw new Error(searchDriftMessage(plan.searchDrift));
781
+ }
782
+
783
+ // Said once per column, at the moment the stamp is applied: from here on a
784
+ // change is detected, but whether *this* column matches the block it is
785
+ // being stamped with is not knowable — it predates the stamp.
786
+ for (const adopted of plan.searchAdopted) {
787
+ const message =
788
+ `Adopting the existing generated column "${adopted.table}"."${adopted.column}" and recording what the ` +
789
+ "current `search` block would generate. Any later change to that block will be detected and refused; a " +
790
+ "change made *before* this version was deployed cannot be, so if search has been missing content, " +
791
+ `rebuild the column once: ALTER TABLE "${adopted.table.split(".").join('"."')}" DROP COLUMN "${adopted.column}"; and boot again.`;
792
+ logger.info(`[schema] ${message}`);
793
+ log?.(message);
794
+ }
795
+
605
796
  if (plan.actions.length === 0) {
606
797
  log?.("Schema is up to date; nothing to create.");
607
798
  return { ...plan, failures };
@@ -617,12 +808,18 @@ export async function ensureCollectionTables(
617
808
  // data rather than on the schema. The column it polices is already
618
809
  // there, so the collection serves either way — record it and carry
619
810
  // on rather than crash-looping the deployment.
620
- if (action.kind === "add-constraint") {
621
- failures.push({ target: action.target, error: message });
811
+ //
812
+ // A comment is metadata about a column that was just created
813
+ // successfully, and it can only fail on ownership (COMMENT requires
814
+ // owning the table, which an adopted table may not grant). Losing
815
+ // the stamp costs drift detection on the next boot; it must not cost
816
+ // the deployment.
817
+ if (action.kind === "add-constraint" || action.kind === "comment-column") {
818
+ failures.push({ kind: action.kind, target: action.target, error: message });
622
819
  continue;
623
820
  }
624
821
  throw new Error(
625
- `Failed to ${action.kind} ${action.target}: ${message}\n ${action.sql}`
822
+ `Failed to ${action.kind} ${action.target}: ${message}${vectorExtensionHint(message)}\n ${action.sql}`
626
823
  );
627
824
  }
628
825
  }