@rebasepro/server-postgres 0.10.1-canary.262a5be → 0.10.1-canary.3d09338
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/{ensure-collection-tables-CNlIONzj.js → ensure-collection-tables-BVvtkkRm.js} +10 -10
- package/dist/ensure-collection-tables-BVvtkkRm.js.map +1 -0
- package/dist/index.es.js +446 -122
- 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 +30 -0
- 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/{src-B0v4IKaI.js → src-CBgtrPhJ.js} +8 -1
- package/dist/{src-B0v4IKaI.js.map → src-CBgtrPhJ.js.map} +1 -1
- package/dist/{src-DmsRg8MR.js → src-lcfUP4xg.js} +126 -76
- package/dist/src-lcfUP4xg.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +41 -0
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +10 -1
- package/src/schema/doctor.ts +6 -6
- package/src/schema/generate-drizzle-schema-logic.ts +13 -9
- package/src/schema/generate-postgres-ddl-logic.ts +19 -12
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +6 -6
- package/src/services/FetchService.ts +104 -114
- package/src/services/PersistService.ts +127 -85
- package/src/services/RelationService.ts +95 -6
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +130 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/utils/drizzle-conditions.ts +154 -0
- package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
- package/dist/src-DmsRg8MR.js.map +0 -1
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { parseDataFromServer } from "../data-transformer";
|
|
16
16
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
17
17
|
import { logger } from "@rebasepro/server";
|
|
18
|
+
import type { NestedPathHop } from "./nested-path";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
@@ -333,6 +334,24 @@ export class RelationService {
|
|
|
333
334
|
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
334
335
|
}
|
|
335
336
|
|
|
337
|
+
return this.countRelatedRows(parentCollection, parentId, relation, []);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Count the target rows a parent reaches through `relation`, narrowed by
|
|
342
|
+
* `additionalFilters` (conditions on the target table).
|
|
343
|
+
*
|
|
344
|
+
* Shared by the public count and by {@link isRelated}, so "how many children
|
|
345
|
+
* does this parent have" and "is this row one of them" are answered by the
|
|
346
|
+
* same join — a membership test that reconstructed the join separately would
|
|
347
|
+
* be free to disagree with the listing it is supposed to gate.
|
|
348
|
+
*/
|
|
349
|
+
private async countRelatedRows(
|
|
350
|
+
parentCollection: CollectionConfig,
|
|
351
|
+
parentId: string | number,
|
|
352
|
+
relation: Relation,
|
|
353
|
+
additionalFilters: SQL[]
|
|
354
|
+
): Promise<number> {
|
|
336
355
|
const targetCollection = relation.target();
|
|
337
356
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
338
357
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -350,9 +369,6 @@ export class RelationService {
|
|
|
350
369
|
// Start count with distinct to avoid duplicates from junction tables
|
|
351
370
|
let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
352
371
|
|
|
353
|
-
// Build additional filter conditions
|
|
354
|
-
const additionalFilters: SQL[] = [];
|
|
355
|
-
|
|
356
372
|
// Use unified count query builder from DrizzleConditionBuilder
|
|
357
373
|
query = DrizzleConditionBuilder.buildRelationCountQuery(
|
|
358
374
|
query,
|
|
@@ -370,6 +386,75 @@ export class RelationService {
|
|
|
370
386
|
return Number(result[0]?.count || 0);
|
|
371
387
|
}
|
|
372
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Whether `targetId` is actually reachable from the parent named in `hop`.
|
|
391
|
+
*
|
|
392
|
+
* A nested address like `authors/1/posts/43` used to resolve to the target
|
|
393
|
+
* collection and then match on the primary key alone, so the parent segment
|
|
394
|
+
* decided nothing: the row came back, and was updated or deleted, whoever it
|
|
395
|
+
* belonged to. Reads, updates and deletes now all gate on this.
|
|
396
|
+
*/
|
|
397
|
+
async isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean> {
|
|
398
|
+
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
399
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
400
|
+
const parsedTargetId = parseIdValues(targetId, targetPks);
|
|
401
|
+
|
|
402
|
+
const identity: SQL[] = targetPks.map(pk => {
|
|
403
|
+
const column = targetTable[pk.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
404
|
+
if (!column) {
|
|
405
|
+
throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
406
|
+
}
|
|
407
|
+
return eq(column, parsedTargetId[pk.fieldName]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return (await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity)) > 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
415
|
+
* row itself alone.
|
|
416
|
+
*
|
|
417
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
418
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
419
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
420
|
+
* `tags` table and delete by primary key.
|
|
421
|
+
*/
|
|
422
|
+
async unlinkRelatedEntity(
|
|
423
|
+
tx: DrizzleClient,
|
|
424
|
+
hop: NestedPathHop,
|
|
425
|
+
targetId: string | number
|
|
426
|
+
): Promise<void> {
|
|
427
|
+
const through = hop.relation.through;
|
|
428
|
+
if (!through) {
|
|
429
|
+
throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const junctionTable = this.registry.getTable(through.table);
|
|
433
|
+
if (!junctionTable) {
|
|
434
|
+
throw new Error(`Junction table not found: ${through.table}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const sourceJunctionColumn = junctionTable[through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
438
|
+
const targetJunctionColumn = junctionTable[through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
439
|
+
|
|
440
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
441
|
+
throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
445
|
+
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
446
|
+
|
|
447
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
448
|
+
const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
|
|
449
|
+
|
|
450
|
+
await tx.delete(junctionTable).where(and(
|
|
451
|
+
eq(sourceJunctionColumn, parsedParentId),
|
|
452
|
+
eq(targetJunctionColumn, parsedTargetId)
|
|
453
|
+
));
|
|
454
|
+
|
|
455
|
+
logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
373
458
|
/**
|
|
374
459
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
375
460
|
*/
|
|
@@ -1348,15 +1433,19 @@ parentSourceColName };
|
|
|
1348
1433
|
const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
|
|
1349
1434
|
const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
|
|
1350
1435
|
|
|
1351
|
-
// Create the junction table entry linking parent to the
|
|
1436
|
+
// Create the junction table entry linking parent to the target row.
|
|
1352
1437
|
const junctionData = {
|
|
1353
1438
|
[sourceJunctionColumn.name]: parentId,
|
|
1354
1439
|
[targetJunctionColumn.name]: parsedNewEntityId
|
|
1355
1440
|
};
|
|
1356
1441
|
|
|
1357
|
-
|
|
1442
|
+
// Idempotent: a link either exists or it does not, so asking for one
|
|
1443
|
+
// twice is not an error. This is what lets `PUT parent/id/child/childId`
|
|
1444
|
+
// mean "this row belongs to this parent's set" — the only way to
|
|
1445
|
+
// attach an *existing* row, which previously had none.
|
|
1446
|
+
await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
|
|
1358
1447
|
|
|
1359
|
-
logger.info(`
|
|
1448
|
+
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
1360
1449
|
} catch (error) {
|
|
1361
1450
|
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error: error });
|
|
1362
1451
|
throw error;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
import { resolveCollectionRelations } from "@rebasepro/common";
|
|
3
|
+
|
|
4
|
+
import { PostgresCollectionRegistry } from "../../collections/PostgresCollectionRegistry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One end of a many-to-many, as seen from the junction table.
|
|
8
|
+
*
|
|
9
|
+
* A junction table is not a collection, so nothing in the registry maps it to
|
|
10
|
+
* one — which is why a change to it was invisible to change capture. But its
|
|
11
|
+
* rows are exactly the contents of a parent's child list, so a write to it is a
|
|
12
|
+
* change to `<parentSlug>/<sourceId>/<relationKey>` and to nothing else.
|
|
13
|
+
*/
|
|
14
|
+
export interface JunctionLink {
|
|
15
|
+
schema: string;
|
|
16
|
+
/** The junction table itself, e.g. `posts_tags`. */
|
|
17
|
+
table: string;
|
|
18
|
+
/** The collection whose relation this is, e.g. `posts`. */
|
|
19
|
+
parentCollection: CollectionConfig;
|
|
20
|
+
/** The relation's key — the path segment a child list is addressed by. */
|
|
21
|
+
relationKey: string;
|
|
22
|
+
/** Junction column holding the parent's id. */
|
|
23
|
+
sourceColumn: string;
|
|
24
|
+
/** Junction column holding the target's id. */
|
|
25
|
+
targetColumn: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every junction table reachable from a registered collection, once per
|
|
30
|
+
* relation that uses it.
|
|
31
|
+
*
|
|
32
|
+
* A junction is listed once per *direction* when both sides declare it, because
|
|
33
|
+
* each direction addresses a different child list: `posts/1/tags` and
|
|
34
|
+
* `tags/t/posts` both change when one link is written.
|
|
35
|
+
*/
|
|
36
|
+
export function collectJunctionLinks(registry: PostgresCollectionRegistry): JunctionLink[] {
|
|
37
|
+
const links: JunctionLink[] = [];
|
|
38
|
+
const seen = new Set<string>();
|
|
39
|
+
|
|
40
|
+
for (const collection of registry.getCollections()) {
|
|
41
|
+
let relations: Record<string, { through?: { table: string; sourceColumn: string; targetColumn: string } }>;
|
|
42
|
+
try {
|
|
43
|
+
relations = resolveCollectionRelations(collection);
|
|
44
|
+
} catch {
|
|
45
|
+
// A collection whose relations cannot be resolved (an unresolvable
|
|
46
|
+
// target, typically mid-migration) simply contributes none.
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const [relationKey, relation] of Object.entries(relations)) {
|
|
51
|
+
const through = relation?.through;
|
|
52
|
+
if (!through?.table) continue;
|
|
53
|
+
|
|
54
|
+
// Same relation registered under both its canonical name and the
|
|
55
|
+
// declaring property key would otherwise notify the same path twice.
|
|
56
|
+
const key = `${collection.slug}::${relationKey}::${through.table}`;
|
|
57
|
+
if (seen.has(key)) continue;
|
|
58
|
+
seen.add(key);
|
|
59
|
+
|
|
60
|
+
links.push({
|
|
61
|
+
schema: (collection as { schema?: string }).schema ?? "public",
|
|
62
|
+
table: through.table,
|
|
63
|
+
parentCollection: collection,
|
|
64
|
+
relationKey,
|
|
65
|
+
sourceColumn: through.sourceColumn,
|
|
66
|
+
targetColumn: through.targetColumn
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return links;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Index {@link collectJunctionLinks} by table, under both the qualified and the
|
|
76
|
+
* bare name — a change event carries whatever the trigger reports, and a
|
|
77
|
+
* collection need not declare a schema.
|
|
78
|
+
*/
|
|
79
|
+
export function buildJunctionLinkMap(registry: PostgresCollectionRegistry): Map<string, JunctionLink[]> {
|
|
80
|
+
const map = new Map<string, JunctionLink[]>();
|
|
81
|
+
|
|
82
|
+
for (const link of collectJunctionLinks(registry)) {
|
|
83
|
+
for (const key of [`${link.schema}.${link.table}`, link.table]) {
|
|
84
|
+
const existing = map.get(key);
|
|
85
|
+
if (existing) existing.push(link);
|
|
86
|
+
else map.set(key, [link]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { CollectionConfig, Relation } 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: Relation;
|
|
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: Relation): boolean {
|
|
107
|
+
return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
|
|
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
|
+
if (hop.relation.cardinality !== "many") {
|
|
122
|
+
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.`,
|
|
127
|
+
"RELATION_NOT_WRITABLE"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -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
|
*/
|