@rebasepro/server-postgres 0.10.1-canary.d8d45b2 → 0.10.1-canary.ed78a2c
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-CNlIONzj.js → ensure-collection-tables-DGMYK0fr.js} +12 -12
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +913 -391
- 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-DmsRg8MR.js → src-3VmUJ8Xn.js} +214 -276
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/{src-B0v4IKaI.js → src-D5xBTl32.js} +19 -2
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- 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 +14 -14
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +28 -21
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- 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-CNlIONzj.js.map +0 -1
- package/dist/src-B0v4IKaI.js.map +0 -1
- package/dist/src-DmsRg8MR.js.map +0 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { and, eq, inArray, or, sql, SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { DrizzleClient } from "../interfaces";
|
|
4
|
-
import { CollectionConfig, FilterValues,
|
|
4
|
+
import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
6
|
+
import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
|
|
6
7
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
8
|
import {
|
|
8
9
|
getCollectionByPath,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
import { parseDataFromServer } from "../data-transformer";
|
|
16
17
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
17
18
|
import { logger } from "@rebasepro/server";
|
|
19
|
+
import type { NestedPathHop } from "./nested-path";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
@@ -180,7 +182,7 @@ export class RelationService {
|
|
|
180
182
|
async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
|
|
181
183
|
parentCollection: CollectionConfig,
|
|
182
184
|
parentId: string | number,
|
|
183
|
-
relation:
|
|
185
|
+
relation: ResolvedRelation,
|
|
184
186
|
options: {
|
|
185
187
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
186
188
|
orderBy?: string;
|
|
@@ -205,7 +207,7 @@ export class RelationService {
|
|
|
205
207
|
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
206
208
|
|
|
207
209
|
// Handle join path relations
|
|
208
|
-
if (relation.
|
|
210
|
+
if (relation.kind === "via") {
|
|
209
211
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
210
212
|
let currentTable = parentTable;
|
|
211
213
|
|
|
@@ -333,6 +335,24 @@ export class RelationService {
|
|
|
333
335
|
throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
|
|
334
336
|
}
|
|
335
337
|
|
|
338
|
+
return this.countRelatedRows(parentCollection, parentId, relation, []);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Count the target rows a parent reaches through `relation`, narrowed by
|
|
343
|
+
* `additionalFilters` (conditions on the target table).
|
|
344
|
+
*
|
|
345
|
+
* Shared by the public count and by {@link isRelated}, so "how many children
|
|
346
|
+
* does this parent have" and "is this row one of them" are answered by the
|
|
347
|
+
* same join — a membership test that reconstructed the join separately would
|
|
348
|
+
* be free to disagree with the listing it is supposed to gate.
|
|
349
|
+
*/
|
|
350
|
+
private async countRelatedRows(
|
|
351
|
+
parentCollection: CollectionConfig,
|
|
352
|
+
parentId: string | number,
|
|
353
|
+
relation: ResolvedRelation,
|
|
354
|
+
additionalFilters: SQL[]
|
|
355
|
+
): Promise<number> {
|
|
336
356
|
const targetCollection = relation.target();
|
|
337
357
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
338
358
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -350,9 +370,6 @@ export class RelationService {
|
|
|
350
370
|
// Start count with distinct to avoid duplicates from junction tables
|
|
351
371
|
let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
352
372
|
|
|
353
|
-
// Build additional filter conditions
|
|
354
|
-
const additionalFilters: SQL[] = [];
|
|
355
|
-
|
|
356
373
|
// Use unified count query builder from DrizzleConditionBuilder
|
|
357
374
|
query = DrizzleConditionBuilder.buildRelationCountQuery(
|
|
358
375
|
query,
|
|
@@ -370,6 +387,75 @@ export class RelationService {
|
|
|
370
387
|
return Number(result[0]?.count || 0);
|
|
371
388
|
}
|
|
372
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Whether `targetId` is actually reachable from the parent named in `hop`.
|
|
392
|
+
*
|
|
393
|
+
* A nested address like `authors/1/posts/43` used to resolve to the target
|
|
394
|
+
* collection and then match on the primary key alone, so the parent segment
|
|
395
|
+
* decided nothing: the row came back, and was updated or deleted, whoever it
|
|
396
|
+
* belonged to. Reads, updates and deletes now all gate on this.
|
|
397
|
+
*/
|
|
398
|
+
async isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean> {
|
|
399
|
+
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
400
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
401
|
+
const parsedTargetId = parseIdValues(targetId, targetPks);
|
|
402
|
+
|
|
403
|
+
const identity: SQL[] = targetPks.map(pk => {
|
|
404
|
+
const column = targetTable[pk.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
405
|
+
if (!column) {
|
|
406
|
+
throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
407
|
+
}
|
|
408
|
+
return eq(column, parsedTargetId[pk.fieldName]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return (await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity)) > 0;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
416
|
+
* row itself alone.
|
|
417
|
+
*
|
|
418
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
419
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
420
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
421
|
+
* `tags` table and delete by primary key.
|
|
422
|
+
*/
|
|
423
|
+
async unlinkRelatedEntity(
|
|
424
|
+
tx: DrizzleClient,
|
|
425
|
+
hop: NestedPathHop,
|
|
426
|
+
targetId: string | number
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
if (!isManyToMany(hop.relation)) {
|
|
429
|
+
throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
430
|
+
}
|
|
431
|
+
const through = hop.relation.through;
|
|
432
|
+
|
|
433
|
+
const junctionTable = this.registry.getTable(through.table);
|
|
434
|
+
if (!junctionTable) {
|
|
435
|
+
throw new Error(`Junction table not found: ${through.table}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const sourceJunctionColumn = junctionTable[through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
439
|
+
const targetJunctionColumn = junctionTable[through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
440
|
+
|
|
441
|
+
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
442
|
+
throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
446
|
+
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
447
|
+
|
|
448
|
+
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
449
|
+
const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
|
|
450
|
+
|
|
451
|
+
await tx.delete(junctionTable).where(and(
|
|
452
|
+
eq(sourceJunctionColumn, parsedParentId),
|
|
453
|
+
eq(targetJunctionColumn, parsedTargetId)
|
|
454
|
+
));
|
|
455
|
+
|
|
456
|
+
logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
373
459
|
/**
|
|
374
460
|
* Batch fetch related rows for multiple parent rows to avoid N+1 queries
|
|
375
461
|
*/
|
|
@@ -377,7 +463,7 @@ export class RelationService {
|
|
|
377
463
|
parentCollectionPath: string,
|
|
378
464
|
parentIds: (string | number)[],
|
|
379
465
|
_relationKey: string,
|
|
380
|
-
relation:
|
|
466
|
+
relation: ResolvedRelation
|
|
381
467
|
): Promise<Map<string, RelatedRow<Record<string, unknown>>>> {
|
|
382
468
|
if (parentIds.length === 0) return new Map();
|
|
383
469
|
|
|
@@ -398,7 +484,7 @@ export class RelationService {
|
|
|
398
484
|
const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
399
485
|
|
|
400
486
|
// Handle join path relations with batching
|
|
401
|
-
if (relation.
|
|
487
|
+
if (relation.kind === "via") {
|
|
402
488
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
403
489
|
let currentTable = parentTable;
|
|
404
490
|
|
|
@@ -457,7 +543,7 @@ export class RelationService {
|
|
|
457
543
|
// 1. Fetch FK values from the parent table in a single query
|
|
458
544
|
// 2. Query the target table with unique FK values
|
|
459
545
|
// 3. Map results back to parent rows via their FK values
|
|
460
|
-
if (relation.
|
|
546
|
+
if (relation.kind === "belongsTo") {
|
|
461
547
|
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
462
548
|
const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
|
|
463
549
|
if (!localKeyCol) {
|
|
@@ -521,7 +607,7 @@ export class RelationService {
|
|
|
521
607
|
this.assertSingleKeyAddressable(
|
|
522
608
|
parentCollection,
|
|
523
609
|
parentPks,
|
|
524
|
-
relation.foreignKeyOnTarget
|
|
610
|
+
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
|
|
525
611
|
);
|
|
526
612
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
527
613
|
|
|
@@ -554,11 +640,13 @@ export class RelationService {
|
|
|
554
640
|
// Determine the parent ID this result belongs to based on the relation type
|
|
555
641
|
let parentId: string | number | undefined;
|
|
556
642
|
|
|
557
|
-
|
|
643
|
+
// The parent's key is on the target row, in the relation's own
|
|
644
|
+
// column. There used to be a second branch here that guessed the
|
|
645
|
+
// column by appending `_id` to `inverseRelationName`, reached only
|
|
646
|
+
// when the foreign key had not been resolved — which cannot happen
|
|
647
|
+
// now that resolution fills it in.
|
|
648
|
+
if (hasForeignKeyOnTarget(relation)) {
|
|
558
649
|
parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
559
|
-
} else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
|
|
560
|
-
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
561
|
-
parentId = targetRow[inferredForeignKeyName] as string | number | undefined;
|
|
562
650
|
}
|
|
563
651
|
|
|
564
652
|
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
@@ -578,7 +666,7 @@ export class RelationService {
|
|
|
578
666
|
parentCollectionPath: string,
|
|
579
667
|
parentIds: (string | number)[],
|
|
580
668
|
_relationKey: string,
|
|
581
|
-
relation:
|
|
669
|
+
relation: ResolvedRelation
|
|
582
670
|
): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>> {
|
|
583
671
|
if (parentIds.length === 0) return new Map();
|
|
584
672
|
|
|
@@ -598,7 +686,7 @@ export class RelationService {
|
|
|
598
686
|
const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
599
687
|
|
|
600
688
|
// Handle join path relations (many-to-many through junction tables)
|
|
601
|
-
if (relation.
|
|
689
|
+
if (relation.kind === "via") {
|
|
602
690
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
603
691
|
let currentTable = parentTable;
|
|
604
692
|
|
|
@@ -640,7 +728,7 @@ export class RelationService {
|
|
|
640
728
|
// Handle many-to-many owning relations with junction table (relation.through)
|
|
641
729
|
// This is the standard path for posts→tags style relations where
|
|
642
730
|
// sanitizeRelation populated the `through` config.
|
|
643
|
-
if (relation.
|
|
731
|
+
if (relation.kind === "manyToMany") {
|
|
644
732
|
// The junction names its parent with one column, so the same
|
|
645
733
|
// single-key limit applies as for a direct foreign key.
|
|
646
734
|
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
@@ -690,7 +778,7 @@ export class RelationService {
|
|
|
690
778
|
this.assertSingleKeyAddressable(
|
|
691
779
|
parentCollection,
|
|
692
780
|
parentPks,
|
|
693
|
-
relation.foreignKeyOnTarget
|
|
781
|
+
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
|
|
694
782
|
);
|
|
695
783
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
696
784
|
|
|
@@ -720,16 +808,10 @@ export class RelationService {
|
|
|
720
808
|
|
|
721
809
|
let parentId: string | number | undefined;
|
|
722
810
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
|
|
727
|
-
parentId = junctionData[relation.through.targetColumn] as string | number | undefined;
|
|
728
|
-
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
811
|
+
// Junction-backed relations returned earlier in this method, so
|
|
812
|
+
// what reaches here names the parent with a column on the target.
|
|
813
|
+
if (hasForeignKeyOnTarget(relation)) {
|
|
729
814
|
parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
730
|
-
} else if (relation.direction === "inverse" && relation.inverseRelationName) {
|
|
731
|
-
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
732
|
-
parentId = targetRow[inferredForeignKeyName] as string | number | undefined;
|
|
733
815
|
}
|
|
734
816
|
|
|
735
817
|
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
@@ -762,7 +844,7 @@ export class RelationService {
|
|
|
762
844
|
const targetCollection = relation.target();
|
|
763
845
|
|
|
764
846
|
// Use joinPath if available
|
|
765
|
-
if (relation.
|
|
847
|
+
if (relation.kind === "via") {
|
|
766
848
|
const parentTableName = getTableName(collection);
|
|
767
849
|
const targetTableName = getTableName(targetCollection);
|
|
768
850
|
|
|
@@ -828,7 +910,7 @@ export class RelationService {
|
|
|
828
910
|
await tx.insert(junctionTable).values(newLinks);
|
|
829
911
|
}
|
|
830
912
|
}
|
|
831
|
-
} else if (relation.
|
|
913
|
+
} else if (relation.kind === "manyToMany") {
|
|
832
914
|
// Handle many-to-many relations with junction table using 'through' property
|
|
833
915
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
834
916
|
if (!junctionTable) {
|
|
@@ -866,11 +948,7 @@ export class RelationService {
|
|
|
866
948
|
await tx.insert(junctionTable).values(newLinks);
|
|
867
949
|
}
|
|
868
950
|
}
|
|
869
|
-
} else if (relation.
|
|
870
|
-
// Inverse M2M relations should be saved from the owning side.
|
|
871
|
-
// The owning collection manages the junction table rows.
|
|
872
|
-
logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
|
|
873
|
-
} else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
951
|
+
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
874
952
|
// Handle one-to-many (inverse) by updating target FK to point to parent
|
|
875
953
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
876
954
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -923,7 +1001,7 @@ export class RelationService {
|
|
|
923
1001
|
sourceEntityId: string | number,
|
|
924
1002
|
inverseRelationUpdates: Array<{
|
|
925
1003
|
relationKey: string;
|
|
926
|
-
relation:
|
|
1004
|
+
relation: ResolvedRelation;
|
|
927
1005
|
newValue: unknown;
|
|
928
1006
|
}>
|
|
929
1007
|
) {
|
|
@@ -938,8 +1016,7 @@ export class RelationService {
|
|
|
938
1016
|
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
939
1017
|
const sourceIdInfo = sourcePks[0];
|
|
940
1018
|
|
|
941
|
-
|
|
942
|
-
if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
1019
|
+
if (relation.kind === "via") {
|
|
943
1020
|
await this.updateInverseJoinPathRelation(
|
|
944
1021
|
tx,
|
|
945
1022
|
sourceCollection,
|
|
@@ -951,42 +1028,31 @@ export class RelationService {
|
|
|
951
1028
|
continue;
|
|
952
1029
|
}
|
|
953
1030
|
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1031
|
+
// A many-to-many names its own junction. This used to walk the
|
|
1032
|
+
// *target's* relations looking for an owning side whose
|
|
1033
|
+
// `relationName` matched `inverseRelationName`, and swap its
|
|
1034
|
+
// source/target columns — a search that silently did nothing
|
|
1035
|
+
// when the far side was declared differently than expected.
|
|
1036
|
+
if (isManyToMany(relation)) {
|
|
1037
|
+
await this.updateManyToManyInverseRelation(
|
|
1038
|
+
tx,
|
|
1039
|
+
sourceCollection,
|
|
1040
|
+
sourceEntityId,
|
|
1041
|
+
targetCollection,
|
|
1042
|
+
relation,
|
|
1043
|
+
newValue,
|
|
1044
|
+
{
|
|
1045
|
+
table: relation.through.table,
|
|
1046
|
+
sourceColumn: relation.through.sourceColumn,
|
|
1047
|
+
targetColumn: relation.through.targetColumn
|
|
970
1048
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
if (junctionInfo) {
|
|
974
|
-
await this.updateManyToManyInverseRelation(
|
|
975
|
-
tx,
|
|
976
|
-
sourceCollection,
|
|
977
|
-
sourceEntityId,
|
|
978
|
-
targetCollection,
|
|
979
|
-
relation,
|
|
980
|
-
newValue,
|
|
981
|
-
junctionInfo
|
|
982
|
-
);
|
|
983
|
-
continue;
|
|
984
|
-
}
|
|
1049
|
+
);
|
|
1050
|
+
continue;
|
|
985
1051
|
}
|
|
986
1052
|
|
|
987
|
-
//
|
|
988
|
-
if (!relation
|
|
989
|
-
logger.warn(`
|
|
1053
|
+
// What is left names the parent with a column on the target.
|
|
1054
|
+
if (!hasForeignKeyOnTarget(relation)) {
|
|
1055
|
+
logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
|
|
990
1056
|
continue;
|
|
991
1057
|
}
|
|
992
1058
|
|
|
@@ -1035,14 +1101,10 @@ export class RelationService {
|
|
|
1035
1101
|
sourceCollection: CollectionConfig,
|
|
1036
1102
|
sourceEntityId: string | number,
|
|
1037
1103
|
targetCollection: CollectionConfig,
|
|
1038
|
-
relation:
|
|
1104
|
+
relation: ResolvedVia,
|
|
1039
1105
|
newValue: unknown
|
|
1040
1106
|
) {
|
|
1041
1107
|
try {
|
|
1042
|
-
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
1043
|
-
logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
1108
|
|
|
1047
1109
|
const sourceTableName = getTableName(sourceCollection);
|
|
1048
1110
|
const targetTableName = getTableName(targetCollection);
|
|
@@ -1145,7 +1207,7 @@ export class RelationService {
|
|
|
1145
1207
|
sourceCollection: CollectionConfig,
|
|
1146
1208
|
sourceEntityId: string | number,
|
|
1147
1209
|
targetCollection: CollectionConfig,
|
|
1148
|
-
relation:
|
|
1210
|
+
relation: ResolvedRelation,
|
|
1149
1211
|
newValue: unknown,
|
|
1150
1212
|
junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
|
|
1151
1213
|
) {
|
|
@@ -1203,7 +1265,9 @@ export class RelationService {
|
|
|
1203
1265
|
parentId: string | number,
|
|
1204
1266
|
updates: Array<{
|
|
1205
1267
|
relationKey: string;
|
|
1206
|
-
relation
|
|
1268
|
+
// Only a `via` relation has a join path to write through, and the
|
|
1269
|
+
// caller only collects those.
|
|
1270
|
+
relation: ResolvedVia;
|
|
1207
1271
|
newTargetId: string | number | null;
|
|
1208
1272
|
}>
|
|
1209
1273
|
) {
|
|
@@ -1281,7 +1345,7 @@ export class RelationService {
|
|
|
1281
1345
|
*/
|
|
1282
1346
|
resolveJoinPathWriteMapping(
|
|
1283
1347
|
parentCollection: CollectionConfig,
|
|
1284
|
-
relation:
|
|
1348
|
+
relation: ResolvedVia
|
|
1285
1349
|
): { targetFKColName: string; parentSourceColName: string } {
|
|
1286
1350
|
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
1287
1351
|
throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
@@ -1320,7 +1384,10 @@ parentSourceColName };
|
|
|
1320
1384
|
junctionTableInfo: {
|
|
1321
1385
|
parentCollection: CollectionConfig;
|
|
1322
1386
|
parentId: string | number;
|
|
1323
|
-
relation
|
|
1387
|
+
// Only a junction-backed relation has a link to write, and the
|
|
1388
|
+
// caller only builds this for one — stated here so the body needs
|
|
1389
|
+
// no narrowing.
|
|
1390
|
+
relation: ResolvedManyToMany;
|
|
1324
1391
|
relationKey: string;
|
|
1325
1392
|
}
|
|
1326
1393
|
) {
|
|
@@ -1328,14 +1395,14 @@ parentSourceColName };
|
|
|
1328
1395
|
const targetCollection = relation.target();
|
|
1329
1396
|
|
|
1330
1397
|
try {
|
|
1331
|
-
const junctionTable = this.registry.getTable(relation.through
|
|
1398
|
+
const junctionTable = this.registry.getTable(relation.through.table);
|
|
1332
1399
|
if (!junctionTable) {
|
|
1333
|
-
logger.warn(`Junction table '${relation.through
|
|
1400
|
+
logger.warn(`Junction table '${relation.through.table}' not found for relation '${relationKey}'`);
|
|
1334
1401
|
return;
|
|
1335
1402
|
}
|
|
1336
1403
|
|
|
1337
|
-
const sourceJunctionColumn = junctionTable[relation.through
|
|
1338
|
-
const targetJunctionColumn = junctionTable[relation.through
|
|
1404
|
+
const sourceJunctionColumn = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1405
|
+
const targetJunctionColumn = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1339
1406
|
|
|
1340
1407
|
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
1341
1408
|
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
@@ -1348,15 +1415,19 @@ parentSourceColName };
|
|
|
1348
1415
|
const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
|
|
1349
1416
|
const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
|
|
1350
1417
|
|
|
1351
|
-
// Create the junction table entry linking parent to the
|
|
1418
|
+
// Create the junction table entry linking parent to the target row.
|
|
1352
1419
|
const junctionData = {
|
|
1353
1420
|
[sourceJunctionColumn.name]: parentId,
|
|
1354
1421
|
[targetJunctionColumn.name]: parsedNewEntityId
|
|
1355
1422
|
};
|
|
1356
1423
|
|
|
1357
|
-
|
|
1424
|
+
// Idempotent: a link either exists or it does not, so asking for one
|
|
1425
|
+
// twice is not an error. This is what lets `PUT parent/id/child/childId`
|
|
1426
|
+
// mean "this row belongs to this parent's set" — the only way to
|
|
1427
|
+
// attach an *existing* row, which previously had none.
|
|
1428
|
+
await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
|
|
1358
1429
|
|
|
1359
|
-
logger.info(`
|
|
1430
|
+
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
1360
1431
|
} catch (error) {
|
|
1361
1432
|
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error: error });
|
|
1362
1433
|
throw error;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CollectionConfig, ResolvedRelation, isManyToMany } 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, ResolvedRelation>;
|
|
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
|
+
if (!isManyToMany(relation)) continue;
|
|
52
|
+
const through = relation.through;
|
|
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
|
+
}
|