@rebasepro/server-postgres 0.10.1-canary.ed8caed → 0.10.1-canary.g4db1bb1

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 (42) hide show
  1. package/dist/auth/schema-version.d.ts +106 -0
  2. package/dist/collections/validate-relations.d.ts +53 -0
  3. package/dist/data-transformer.d.ts +3 -3
  4. package/dist/{ensure-collection-tables-BVvtkkRm.js → ensure-collection-tables-DGMYK0fr.js} +5 -5
  5. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  6. package/dist/index.es.js +610 -313
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/services/RelationService.d.ts +9 -8
  9. package/dist/services/cdc/junction-tables.d.ts +38 -0
  10. package/dist/services/nested-path.d.ts +3 -3
  11. package/dist/services/realtimeService.d.ts +19 -0
  12. package/dist/services/row-pipeline.d.ts +2 -2
  13. package/dist/{src-lcfUP4xg.js → src-3VmUJ8Xn.js} +135 -247
  14. package/dist/src-3VmUJ8Xn.js.map +1 -0
  15. package/dist/{src-CBgtrPhJ.js → src-D5xBTl32.js} +12 -2
  16. package/dist/src-D5xBTl32.js.map +1 -0
  17. package/dist/utils/drizzle-conditions.d.ts +35 -23
  18. package/package.json +6 -6
  19. package/src/PostgresBootstrapper.ts +15 -2
  20. package/src/auth/ensure-tables.ts +23 -0
  21. package/src/auth/schema-version.ts +260 -0
  22. package/src/cli-errors.ts +1 -1
  23. package/src/cli-helpers.ts +4 -3
  24. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  25. package/src/collections/buildRegistry.ts +7 -0
  26. package/src/collections/validate-relations.ts +280 -0
  27. package/src/data-transformer.ts +28 -38
  28. package/src/schema/doctor.ts +8 -8
  29. package/src/schema/generate-drizzle-schema-logic.ts +49 -101
  30. package/src/schema/generate-postgres-ddl-logic.ts +9 -9
  31. package/src/schema/introspect-db-logic.ts +19 -23
  32. package/src/services/FetchService.ts +12 -12
  33. package/src/services/PersistService.ts +23 -27
  34. package/src/services/RelationService.ts +65 -83
  35. package/src/services/cdc/junction-tables.ts +91 -0
  36. package/src/services/nested-path.ts +24 -9
  37. package/src/services/realtimeService.ts +60 -0
  38. package/src/services/row-pipeline.ts +5 -6
  39. package/src/utils/drizzle-conditions.ts +177 -382
  40. package/dist/ensure-collection-tables-BVvtkkRm.js.map +0 -1
  41. package/dist/src-CBgtrPhJ.js.map +0 -1
  42. package/dist/src-lcfUP4xg.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, Relation } from "@rebasepro/types";
1
+ import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
2
2
  import { findRelation, resolveCollectionRelations } from "@rebasepro/common";
3
3
  import { ApiError } from "@rebasepro/server";
4
4
 
@@ -21,7 +21,7 @@ export interface NestedPathHop {
21
21
  parentId: string;
22
22
  /** The path segment that named the relation (e.g. `posts`). */
23
23
  relationKey: string;
24
- relation: Relation;
24
+ relation: ResolvedRelation;
25
25
  /** `relation.target()`, resolved once. */
26
26
  targetCollection: CollectionConfig;
27
27
  }
@@ -103,8 +103,8 @@ export function resolveNestedPath(
103
103
  * `joinPath`. The target row is shared with other parents, so writing "through"
104
104
  * such a path addresses the *link*, not the row.
105
105
  */
106
- export function isJunctionBackedRelation(relation: Relation): boolean {
107
- return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
106
+ export function isJunctionBackedRelation(relation: ResolvedRelation): boolean {
107
+ return relation.shared;
108
108
  }
109
109
 
110
110
  /**
@@ -118,13 +118,28 @@ export function isJunctionBackedRelation(relation: Relation): boolean {
118
118
  * one.
119
119
  */
120
120
  export function assertWritableThrough(hop: NestedPathHop, path: string): void {
121
- if (hop.relation.cardinality !== "many") {
121
+ const { relation } = hop;
122
+
123
+ // Read the flag rather than re-deriving the rule. This guard used to test
124
+ // `cardinality !== "many"`, which let a to-many `via` through even though
125
+ // its type declares `writable: false` — a second expression of one rule,
126
+ // free to disagree with the first, which is the shape of defect this union
127
+ // exists to remove.
128
+ if (relation.writable && relation.cardinality === "many") return;
129
+
130
+ if (!relation.writable) {
122
131
  throw ApiError.badRequest(
123
- `"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: ` +
124
- `the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on ` +
125
- `'${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set ` +
126
- `'${hop.relationKey}' on the parent instead.`,
132
+ `"${path}" ends in '${hop.relationKey}', a \`via\` relation. Rebase will not infer how to write ` +
133
+ `through an arbitrary join chain — write the row at "${hop.targetCollection.slug}" directly.`,
127
134
  "RELATION_NOT_WRITABLE"
128
135
  );
129
136
  }
137
+
138
+ throw ApiError.badRequest(
139
+ `"${path}" ends in the to-one relation '${hop.relationKey}', which cannot be written through: ` +
140
+ `the foreign key for a to-one relation lives on '${hop.parentCollection.slug}', not on ` +
141
+ `'${hop.targetCollection.slug}'. Write the target row at "${hop.targetCollection.slug}" and set ` +
142
+ `'${hop.relationKey}' on the parent instead.`,
143
+ "RELATION_NOT_WRITABLE"
144
+ );
130
145
  }
@@ -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
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, Property, Relation } from "@rebasepro/types";
1
+ import { CollectionConfig, Property, ResolvedRelation, isManyToMany, type ResolvedVia } from "@rebasepro/types";
2
2
  import { resolveCollectionRelations, findRelation, createRelationRefWithData } from "@rebasepro/common";
3
3
  import { normalizeDbValues } from "../data-transformer";
4
4
  import { deriveRowAddress } from "./collection-helpers";
@@ -31,12 +31,11 @@ export type RelationStyle = "ref" | "inline";
31
31
  * the query has to nest one level deeper for a junction, and the row walk has
32
32
  * to unwrap that same level back out.
33
33
  */
34
- export function isJunctionRelation(relation: Relation): boolean {
34
+ export function isJunctionRelation(relation: ResolvedRelation): boolean {
35
35
  // An explicit `through` says so outright.
36
- if (relation.through) return true;
37
- // A joinPath with an intermediate table is the same thing, spelled longhand.
38
- if (relation.joinPath && relation.joinPath.length > 1) return true;
39
- return false;
36
+ if (isManyToMany(relation)) return true;
37
+ // A multi-hop join path is the same thing spelled longhand.
38
+ return relation.kind === "via" && relation.joinPath.length > 1;
40
39
  }
41
40
 
42
41
  /**