@rebasepro/server-postgres 0.10.1-canary.ff9ccd6 → 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.
- package/dist/auth/schema-version.d.ts +106 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/{ensure-collection-tables-C9gy4STB.js → ensure-collection-tables-DGMYK0fr.js} +5 -5
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +905 -383
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/{src-DG6ZsQQ3.js → src-3VmUJ8Xn.js} +211 -243
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/{src-CBgtrPhJ.js → src-D5xBTl32.js} +12 -2
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +15 -2
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/schema/doctor.ts +8 -8
- package/src/schema/generate-drizzle-schema-logic.ts +49 -101
- package/src/schema/generate-postgres-ddl-logic.ts +9 -9
- package/src/schema/introspect-db-logic.ts +19 -23
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +268 -330
- package/dist/ensure-collection-tables-C9gy4STB.js.map +0 -1
- package/dist/src-CBgtrPhJ.js.map +0 -1
- package/dist/src-DG6ZsQQ3.js.map +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
2
|
+
import { findRelation, resolveCollectionRelations } from "@rebasepro/common";
|
|
3
|
+
import { ApiError } from "@rebasepro/server";
|
|
4
|
+
|
|
5
|
+
import { getCollectionByPath } from "./collection-helpers";
|
|
6
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The last hop of a nested collection path, e.g. `authors/1/posts`.
|
|
10
|
+
*
|
|
11
|
+
* The walk that produces this was written out four separate times — in
|
|
12
|
+
* `FetchService.fetchCollectionFromPath`, `FetchService.countEntitiesFromPath`,
|
|
13
|
+
* `PersistService.save` and `CollectionRegistry.getCollectionByPath` — and had
|
|
14
|
+
* drifted, so the read path and the write path did not agree on which relation
|
|
15
|
+
* a path named. It lives here once now.
|
|
16
|
+
*/
|
|
17
|
+
export interface NestedPathHop {
|
|
18
|
+
/** The collection the final relation is declared on (e.g. `authors`). */
|
|
19
|
+
parentCollection: CollectionConfig;
|
|
20
|
+
/** The parent's id as it appeared in the path, unparsed. */
|
|
21
|
+
parentId: string;
|
|
22
|
+
/** The path segment that named the relation (e.g. `posts`). */
|
|
23
|
+
relationKey: string;
|
|
24
|
+
relation: ResolvedRelation;
|
|
25
|
+
/** `relation.target()`, resolved once. */
|
|
26
|
+
targetCollection: CollectionConfig;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when `path` addresses rows through a relation rather than a root
|
|
31
|
+
* collection.
|
|
32
|
+
*
|
|
33
|
+
* Any separator at all counts — a root collection slug never contains one — so
|
|
34
|
+
* a malformed path like `collection/id` is a *broken* nested path and gets
|
|
35
|
+
* reported as one by {@link resolveNestedPath}, rather than being looked up as
|
|
36
|
+
* a root collection whose slug happens to contain a slash.
|
|
37
|
+
*/
|
|
38
|
+
export function isNestedPath(path: string): boolean {
|
|
39
|
+
return path.includes("/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function splitPathSegments(path: string): string[] {
|
|
43
|
+
return path.split("/").filter(s => s && s !== "undefined");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Walk a nested collection path down to the relation it ends in.
|
|
48
|
+
*
|
|
49
|
+
* Returns `undefined` for a plain root-collection path so callers can keep the
|
|
50
|
+
* root case on its existing code path. Throws when the path is malformed, or
|
|
51
|
+
* when a segment names a relation that does not exist — the same errors the
|
|
52
|
+
* individual walks used to raise, with the available names attached.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveNestedPath(
|
|
55
|
+
path: string,
|
|
56
|
+
registry: PostgresCollectionRegistry
|
|
57
|
+
): NestedPathHop | undefined {
|
|
58
|
+
if (!isNestedPath(path)) return undefined;
|
|
59
|
+
|
|
60
|
+
const segments = splitPathSegments(path);
|
|
61
|
+
if (segments.length < 3 || segments.length % 2 === 0) {
|
|
62
|
+
throw new Error(`Invalid relation path: ${path}. Expected format: collection/id/relation`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let parentCollection = getCollectionByPath(segments[0], registry);
|
|
66
|
+
let parentId = segments[1];
|
|
67
|
+
|
|
68
|
+
for (let i = 2; i < segments.length; i += 2) {
|
|
69
|
+
const relationKey = segments[i];
|
|
70
|
+
const resolvedRelations = resolveCollectionRelations(parentCollection);
|
|
71
|
+
const relation = findRelation(resolvedRelations, relationKey);
|
|
72
|
+
|
|
73
|
+
if (!relation) {
|
|
74
|
+
const available = Object.keys(resolvedRelations).join(", ") || "(none)";
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Relation '${relationKey}' not found in collection '${parentCollection.slug}'. Available relations: [${available}]`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const targetCollection = relation.target();
|
|
81
|
+
|
|
82
|
+
if (i === segments.length - 1) {
|
|
83
|
+
return {
|
|
84
|
+
parentCollection,
|
|
85
|
+
parentId,
|
|
86
|
+
relationKey,
|
|
87
|
+
relation,
|
|
88
|
+
targetCollection
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
parentCollection = targetCollection;
|
|
93
|
+
parentId = segments[i + 1];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unreachable: the loop returns on the final segment, and the odd-length
|
|
97
|
+
// check above guarantees there is one.
|
|
98
|
+
throw new Error(`Unable to resolve path: ${path}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A relation reached through a junction table — many-to-many, or a multi-hop
|
|
103
|
+
* `joinPath`. The target row is shared with other parents, so writing "through"
|
|
104
|
+
* such a path addresses the *link*, not the row.
|
|
105
|
+
*/
|
|
106
|
+
export function isJunctionBackedRelation(relation: ResolvedRelation): boolean {
|
|
107
|
+
return relation.shared;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reject a nested write whose final segment is a to-one relation.
|
|
112
|
+
*
|
|
113
|
+
* There is no column on the target row that records a to-one parent — the
|
|
114
|
+
* foreign key lives on the *parent* table. The write path used to fall through
|
|
115
|
+
* to `relation.localKey` here and stamp the parent's own FK column onto the
|
|
116
|
+
* target row, which either raised an opaque "column does not exist" or, when a
|
|
117
|
+
* column of that name happened to exist on the target, silently wrote the wrong
|
|
118
|
+
* one.
|
|
119
|
+
*/
|
|
120
|
+
export function assertWritableThrough(hop: NestedPathHop, path: string): void {
|
|
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) {
|
|
131
|
+
throw ApiError.badRequest(
|
|
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.`,
|
|
134
|
+
"RELATION_NOT_WRITABLE"
|
|
135
|
+
);
|
|
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
|
+
);
|
|
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,
|
|
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:
|
|
34
|
+
export function isJunctionRelation(relation: ResolvedRelation): boolean {
|
|
35
35
|
// An explicit `through` says so outright.
|
|
36
|
-
if (relation
|
|
37
|
-
// A
|
|
38
|
-
|
|
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
|
/**
|