@rebasepro/server-postgres 0.10.1-canary.8d27c46 → 0.10.1-canary.af2af91

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.
@@ -11,6 +11,7 @@ import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfi
11
11
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
12
12
  import { buildPropertyCallbacks, getTableName } from "@rebasepro/common";
13
13
  import { applyAuthContext } from "../security/rls-enforcement";
14
+ import { buildJunctionLinkMap, type JunctionLink } from "./cdc/junction-tables";
14
15
  import { logger } from "@rebasepro/server";
15
16
  import { sanitizeErrorForClient } from "../utils/pg-error-utils";
16
17
  import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
@@ -159,6 +160,9 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
159
160
  private cdcListener?: CdcListener;
160
161
  /** Whether database-level CDC is the active cross-instance change source. */
161
162
  private cdcActive = false;
163
+ /** Junction table → the child lists its rows belong to, built when CDC starts. */
164
+ private junctionLinkMap?: Map<string, JunctionLink[]>;
165
+
162
166
  /** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
163
167
  private cdcTableMap?: Map<string, CollectionConfig>;
164
168
  /**
@@ -1795,6 +1799,7 @@ lastSeen: Date.now() });
1795
1799
  return;
1796
1800
  }
1797
1801
  this.cdcTableMap = this.buildCdcTableMap();
1802
+ this.junctionLinkMap = buildJunctionLinkMap(this.registry);
1798
1803
  this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
1799
1804
  try {
1800
1805
  // start() validates the initial connection; if it can't be established
@@ -1805,6 +1810,7 @@ lastSeen: Date.now() });
1805
1810
  await this.cdcListener.stop().catch(() => { /* best effort */ });
1806
1811
  this.cdcListener = undefined;
1807
1812
  this.cdcTableMap = undefined;
1813
+ this.junctionLinkMap = undefined;
1808
1814
  throw err;
1809
1815
  }
1810
1816
  this.cdcActive = true;
@@ -1822,6 +1828,7 @@ lastSeen: Date.now() });
1822
1828
  this.cdcListener = undefined;
1823
1829
  }
1824
1830
  this.cdcTableMap = undefined;
1831
+ this.junctionLinkMap = undefined;
1825
1832
  this.recentAppEmits.clear();
1826
1833
  }
1827
1834
 
@@ -1862,6 +1869,10 @@ lastSeen: Date.now() });
1862
1869
  private async handleCdcEvent(event: CdcChangeEvent): Promise<void> {
1863
1870
  const collection = this.resolveCollectionForTable(event.schema, event.table);
1864
1871
  if (!collection) {
1872
+ // A junction table backs no collection, but its rows *are* a child
1873
+ // list. Route the change to the lists it changes before giving up.
1874
+ if (await this.handleJunctionCdcEvent(event)) return;
1875
+
1865
1876
  // Unmapped table (not backed by a collection) — nothing to deliver.
1866
1877
  this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
1867
1878
  return;
@@ -1878,6 +1889,55 @@ lastSeen: Date.now() });
1878
1889
  await this.notifyUpdate(path, id, row, databaseId, /* broadcast */ false, /* origin */ "cdc");
1879
1890
  }
1880
1891
 
1892
+ /**
1893
+ * Deliver a change on a many-to-many junction table as a change to the child
1894
+ * lists it belongs to.
1895
+ *
1896
+ * Linking a tag to a post writes only `posts_tags`. That table backs no
1897
+ * collection, so change capture dropped the event as unmapped and the
1898
+ * subscribers of `posts/1/tags` never heard about it — every other write in
1899
+ * the system was realtime, and this one silently was not. The junction row
1900
+ * carries both ids, so it names its own paths exactly.
1901
+ *
1902
+ * Notifies the nested path rather than either endpoint collection, because
1903
+ * invalidation walks *parent* paths and never child ones: telling `tags` it
1904
+ * changed would not reach a subscription on `posts/1/tags`.
1905
+ *
1906
+ * Returns whether the table was recognised as a junction.
1907
+ */
1908
+ private async handleJunctionCdcEvent(event: CdcChangeEvent): Promise<boolean> {
1909
+ const links = this.junctionLinkMap?.get(`${event.schema}.${event.table}`)
1910
+ ?? this.junctionLinkMap?.get(event.table);
1911
+ if (!links?.length) return false;
1912
+
1913
+ for (const link of links) {
1914
+ const sourceId = event.row?.[link.sourceColumn];
1915
+ const targetId = event.row?.[link.targetColumn];
1916
+ if (sourceId === undefined || sourceId === null || targetId === undefined || targetId === null) {
1917
+ this.debugLog(
1918
+ `📡 [CDC] Junction row on ${event.table} is missing '${link.sourceColumn}'/'${link.targetColumn}' — skipping.`
1919
+ );
1920
+ continue;
1921
+ }
1922
+
1923
+ const path = `${link.parentCollection.slug}/${String(sourceId)}/${link.relationKey}`;
1924
+ // An unlink removes the target from this list; a link invalidates it
1925
+ // so each subscriber refetches under its own RLS context.
1926
+ const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
1927
+
1928
+ await this.notifyUpdate(
1929
+ path,
1930
+ String(targetId),
1931
+ row,
1932
+ (link.parentCollection as { databaseId?: string }).databaseId,
1933
+ /* broadcast */ false,
1934
+ /* origin */ "cdc"
1935
+ );
1936
+ }
1937
+
1938
+ return true;
1939
+ }
1940
+
1881
1941
  /** Compute the canonical (possibly composite) id string from a captured row. */
1882
1942
  private extractIdFromCdcRow(collection: CollectionConfig, row: Record<string, unknown>): string {
1883
1943
  // Unaddressable falls back to a collection-level invalidation: single-row
@@ -38,6 +38,160 @@ export interface DrizzleDynamicQuery {
38
38
  */
39
39
  export class DrizzleConditionBuilder {
40
40
 
41
+ /**
42
+ * Express "reachable from this parent through this relation" as a plain
43
+ * `WHERE` condition on the target table.
44
+ *
45
+ * This is the primitive that lets a relation be a *filter* rather than an
46
+ * addressing scheme. A nested listing used to be served by its own query
47
+ * builder — `fetchEntitiesUsingJoins`, which grew joins the root pipeline
48
+ * did not have and lost the options the root pipeline did have (offset,
49
+ * filter, orderBy, include). Reduced to a condition, the same listing runs
50
+ * through the ordinary collection query, so it inherits all of them and
51
+ * there is one read path instead of two.
52
+ *
53
+ * The shapes:
54
+ * - inverse FK → `target.<fk> = :parentId`, a column comparison.
55
+ * - `through` → `EXISTS (SELECT 1 FROM junction …)`, correlated on the
56
+ * target's key, so the junction never multiplies rows the
57
+ * way an `INNER JOIN` would.
58
+ * - `joinPath` → the same `EXISTS`, with the path's steps joined inside
59
+ * it and the final step correlating to the outer row.
60
+ */
61
+ static buildRelationScopeCondition(
62
+ relation: Relation,
63
+ /**
64
+ * Lazy: only `joinPath` and `localKey` relations need the parent's own
65
+ * table. An inverse foreign key and a junction are both expressible
66
+ * from the parent's *id* alone, and requiring the table for them would
67
+ * make a child listing fail on a parent whose table isn't registered.
68
+ */
69
+ parent: () => { table: PgTable<any>; idColumn: AnyPgColumn },
70
+ parentId: string | number,
71
+ targetTable: PgTable<any>,
72
+ targetIdColumn: AnyPgColumn,
73
+ registry: PostgresCollectionRegistry
74
+ ): SQL {
75
+ if (relation.joinPath && relation.joinPath.length > 0) {
76
+ const { table, idColumn } = parent();
77
+ return this.buildJoinPathScopeCondition(
78
+ relation.joinPath, table, idColumn, parentId, targetTable, registry
79
+ );
80
+ }
81
+
82
+ if (relation.through) {
83
+ const junctionTable = registry.getTable(relation.through.table);
84
+ if (!junctionTable) {
85
+ throw new Error(`Junction table not found: ${relation.through.table}`);
86
+ }
87
+ const sourceCol = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
88
+ const targetCol = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
89
+ if (!sourceCol || !targetCol) {
90
+ throw new Error(
91
+ `Junction columns '${relation.through.sourceColumn}'/'${relation.through.targetColumn}' not found in '${relation.through.table}'`
92
+ );
93
+ }
94
+ return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
95
+ }
96
+
97
+ if (relation.foreignKeyOnTarget) {
98
+ const fkColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
99
+ if (!fkColumn) {
100
+ throw new Error(
101
+ `Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation ` +
102
+ `'${relation.relationName}'. A many-to-many relation needs \`through\` instead.`
103
+ );
104
+ }
105
+ return eq(fkColumn, parentId);
106
+ }
107
+
108
+ if (relation.localKey) {
109
+ // A to-one owning relation read as a scope: the single target row is
110
+ // the one the parent's foreign key points at.
111
+ const { table, idColumn } = parent();
112
+ return sql`${targetIdColumn} = (SELECT ${sql.identifier(relation.localKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
113
+ }
114
+
115
+ throw new Error(
116
+ `Relation '${relation.relationName}' declares no \`foreignKeyOnTarget\`, \`through\`, \`joinPath\` or ` +
117
+ "`localKey`, so there is no way to tell which target rows belong to a parent."
118
+ );
119
+ }
120
+
121
+ /**
122
+ * `EXISTS` for an explicit `joinPath`.
123
+ *
124
+ * The path is declared source → target. The subquery replays every step but
125
+ * the last from inside, and turns the last one into the correlation with the
126
+ * outer target row — so the target table is never named twice and needs no
127
+ * alias. Each intermediate table is aliased positionally, which keeps a path
128
+ * that revisits a table (a self-referencing many-to-many) unambiguous.
129
+ */
130
+ private static buildJoinPathScopeCondition(
131
+ joinPath: JoinStep[],
132
+ parentTable: PgTable<any>,
133
+ parentIdColumn: AnyPgColumn,
134
+ parentId: string | number,
135
+ targetTable: PgTable<any>,
136
+ registry: PostgresCollectionRegistry
137
+ ): SQL {
138
+ const sourceAlias = "__rel_src";
139
+ const aliasFor = (index: number) => `__rel_j${index}`;
140
+
141
+ // Column reference against the previous hop: the aliased source for the
142
+ // first step, the previous aliased join table after that.
143
+ const fromRef = (stepIndex: number, column: string) =>
144
+ sql`${sql.identifier(stepIndex === 0 ? sourceAlias : aliasFor(stepIndex - 1))}.${sql.identifier(getColumnName(column))}`;
145
+
146
+ const pairs = (step: JoinStep): { from: string; to: string }[] => {
147
+ const from = Array.isArray(step.on.from) ? step.on.from : [step.on.from];
148
+ const to = Array.isArray(step.on.to) ? step.on.to : [step.on.to];
149
+ if (from.length !== to.length) {
150
+ throw new Error(`Join step on '${step.table}' has ${from.length} \`from\` columns and ${to.length} \`to\` columns`);
151
+ }
152
+ return from.map((f, i) => ({ from: f, to: to[i] }));
153
+ };
154
+
155
+ const inner = joinPath.slice(0, -1);
156
+ const last = joinPath[joinPath.length - 1];
157
+
158
+ const joins: SQL[] = inner.map((step, index) => {
159
+ const table = registry.getTable(step.table);
160
+ if (!table) throw new Error(`Join table not found: ${step.table}`);
161
+ const on = pairs(step).map(({ from, to }) =>
162
+ sql`${fromRef(index, from)} = ${sql.identifier(aliasFor(index))}.${sql.identifier(getColumnName(to))}`
163
+ );
164
+ return sql`JOIN ${table} AS ${sql.identifier(aliasFor(index))} ON ${sql.join(on, sql` AND `)}`;
165
+ });
166
+
167
+ // The last step correlates to the outer row instead of joining the
168
+ // target table into the subquery.
169
+ //
170
+ // On the step's own `to` column, which is not always the target's
171
+ // primary key: a one-step path like `{ table: "posts", on: { from:
172
+ // "id", to: "author_id" } }` correlates through a foreign key, and
173
+ // matching `authors.id = posts.id` there compares two unrelated
174
+ // identifiers — which returns nothing, quietly. Referencing the Drizzle
175
+ // column rather than a bare name also keeps it qualified, so it binds
176
+ // to the outer target and not to a table joined inside the EXISTS.
177
+ const targetColumn = (name: string): AnyPgColumn => {
178
+ const column = targetTable[getColumnName(name) as keyof typeof targetTable] as AnyPgColumn;
179
+ if (!column) {
180
+ throw new Error(`Join step column '${name}' not found in the target table of this joinPath`);
181
+ }
182
+ return column;
183
+ };
184
+
185
+ const correlation = sql.join(
186
+ pairs(last).map(({ from, to }) => sql`${fromRef(inner.length, from)} = ${targetColumn(to)}`),
187
+ sql` AND `
188
+ );
189
+
190
+ const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
191
+
192
+ return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
193
+ }
194
+
41
195
  /**
42
196
  * Build filter conditions from FilterValues
43
197
  */