@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.
- 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-BVvtkkRm.js → ensure-collection-tables-DGMYK0fr.js} +5 -5
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +610 -313
- package/dist/index.es.js.map +1 -1
- package/dist/services/RelationService.d.ts +9 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +3 -3
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/{src-lcfUP4xg.js → src-3VmUJ8Xn.js} +135 -247
- 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 +35 -23
- 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 +12 -12
- package/src/services/PersistService.ts +23 -27
- package/src/services/RelationService.ts +65 -83
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +24 -9
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +177 -382
- package/dist/ensure-collection-tables-BVvtkkRm.js.map +0 -1
- package/dist/src-CBgtrPhJ.js.map +0 -1
- package/dist/src-lcfUP4xg.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
-
import { CollectionConfig, FilterValues,
|
|
3
|
+
import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
|
|
4
4
|
import type { VectorSearchParams } from "@rebasepro/types";
|
|
5
5
|
import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
@@ -73,7 +73,7 @@ export class FetchService {
|
|
|
73
73
|
let orderByField = table[orderBy as keyof typeof table] as AnyPgColumn;
|
|
74
74
|
if (!orderByField && collection) {
|
|
75
75
|
const property = collection.properties[orderBy];
|
|
76
|
-
if (property && property.type === "relation" && "relation" in property && property.
|
|
76
|
+
if (property && property.type === "relation" && "relation" in property && property.resolvedRelation?.kind === "belongsTo") {
|
|
77
77
|
orderByField = table[`${orderBy}_id` as keyof typeof table] as AnyPgColumn;
|
|
78
78
|
}
|
|
79
79
|
}
|
|
@@ -106,7 +106,7 @@ export class FetchService {
|
|
|
106
106
|
const drizzleRelName = relation.relationName || key;
|
|
107
107
|
|
|
108
108
|
// Skip relations that use joinPath as they are not mapped in Drizzle schemas
|
|
109
|
-
if (relation.
|
|
109
|
+
if (relation.kind === "via") {
|
|
110
110
|
continue;
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -135,8 +135,8 @@ export class FetchService {
|
|
|
135
135
|
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
136
136
|
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
137
137
|
*/
|
|
138
|
-
private getJunctionTargetRelationName(relation:
|
|
139
|
-
if (relation
|
|
138
|
+
private getJunctionTargetRelationName(relation: ResolvedRelation, _collection: CollectionConfig): string | null {
|
|
139
|
+
if (isManyToMany(relation)) {
|
|
140
140
|
// The junction relation on the junction table pointing to the target
|
|
141
141
|
// uses the targetColumn name as the Drizzle relation name
|
|
142
142
|
return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
@@ -159,7 +159,7 @@ export class FetchService {
|
|
|
159
159
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
160
160
|
|
|
161
161
|
const promises = Object.entries(resolvedRelations)
|
|
162
|
-
.filter(([key, relation]) => relation.
|
|
162
|
+
.filter(([key, relation]) => relation.kind === "via")
|
|
163
163
|
.map(async ([key, relation]) => {
|
|
164
164
|
try {
|
|
165
165
|
const relatedRows = await this.relationService.fetchRelatedEntities(
|
|
@@ -204,7 +204,7 @@ export class FetchService {
|
|
|
204
204
|
!include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
205
205
|
|
|
206
206
|
const joinPathRelations = Object.entries(resolvedRelations)
|
|
207
|
-
.filter(([key, relation]) => relation.
|
|
207
|
+
.filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
|
|
208
208
|
|
|
209
209
|
if (joinPathRelations.length === 0) return;
|
|
210
210
|
|
|
@@ -495,7 +495,7 @@ idColumn };
|
|
|
495
495
|
return flatRow;
|
|
496
496
|
} catch (e) {
|
|
497
497
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
498
|
-
logger.error(`[FetchService]
|
|
498
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
499
499
|
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
500
500
|
}
|
|
501
501
|
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
@@ -620,7 +620,7 @@ idColumn };
|
|
|
620
620
|
return rows;
|
|
621
621
|
} catch (e) {
|
|
622
622
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
623
|
-
logger.error(`[FetchService]
|
|
623
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
624
624
|
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
625
625
|
}
|
|
626
626
|
logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
@@ -1040,7 +1040,7 @@ relatedTo: hop }, include
|
|
|
1040
1040
|
return restRows;
|
|
1041
1041
|
} catch (e) {
|
|
1042
1042
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
1043
|
-
logger.error(`[FetchService]
|
|
1043
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
1044
1044
|
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
1045
1045
|
}
|
|
1046
1046
|
logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
|
|
@@ -1146,7 +1146,7 @@ relatedTo: hop }, include
|
|
|
1146
1146
|
return restRow;
|
|
1147
1147
|
} catch (e) {
|
|
1148
1148
|
if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
|
|
1149
|
-
logger.error(`[FetchService]
|
|
1149
|
+
logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
|
|
1150
1150
|
logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
|
|
1151
1151
|
}
|
|
1152
1152
|
logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
|
|
@@ -1424,7 +1424,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1424
1424
|
const relation = resolvedRelations[relationKey];
|
|
1425
1425
|
|
|
1426
1426
|
if (!relation) {
|
|
1427
|
-
logger.warn(`[batchFetchManyRelatedRows]
|
|
1427
|
+
logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
|
|
1428
1428
|
return new Map();
|
|
1429
1429
|
}
|
|
1430
1430
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { eq, and, sql, SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
// import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
-
import { CollectionConfig, Properties,
|
|
4
|
+
import { CollectionConfig, Properties, ResolvedRelation, type ResolvedManyToMany, isManyToMany } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
6
6
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
7
7
|
import {
|
|
@@ -99,7 +99,7 @@ export class PersistService {
|
|
|
99
99
|
|
|
100
100
|
if (isJunctionBackedRelation(hop.relation)) {
|
|
101
101
|
// Shared target: drop the link, not the row.
|
|
102
|
-
if (!hop.relation
|
|
102
|
+
if (!isManyToMany(hop.relation)) {
|
|
103
103
|
throw ApiError.badRequest(
|
|
104
104
|
`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, ` +
|
|
105
105
|
`so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" ` +
|
|
@@ -159,30 +159,26 @@ export class PersistService {
|
|
|
159
159
|
* do, and first — stamped the parent's own foreign key onto the child row.
|
|
160
160
|
*/
|
|
161
161
|
private resolveParentForeignKeyColumn(hop: NestedPathHop): string | undefined {
|
|
162
|
-
const { relation
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
162
|
+
const { relation } = hop;
|
|
163
|
+
|
|
164
|
+
switch (relation.kind) {
|
|
165
|
+
case "hasOne":
|
|
166
|
+
case "hasMany":
|
|
167
|
+
return relation.foreignKeyOnTarget;
|
|
168
|
+
|
|
169
|
+
case "via":
|
|
170
|
+
// The link lives in an intermediate table, not in a column on
|
|
171
|
+
// the target — nothing to stamp.
|
|
172
|
+
return relation.joinPath.length === 1
|
|
173
|
+
? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
default:
|
|
177
|
+
// `belongsTo` names a column on the *parent*, and `manyToMany`
|
|
178
|
+
// is written as a junction row. Neither is a column here, and
|
|
179
|
+
// `assertWritableThrough` has already rejected the first.
|
|
180
|
+
return undefined;
|
|
173
181
|
}
|
|
174
|
-
|
|
175
|
-
if (relation.joinPath && relation.joinPath.length > 1) {
|
|
176
|
-
// Multi-hop: the link lives in an intermediate table, not in a
|
|
177
|
-
// column on the target. Nothing to stamp.
|
|
178
|
-
return undefined;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
throw ApiError.badRequest(
|
|
182
|
-
`Relation '${relationKey}' on '${hop.parentCollection.slug}' cannot be written through: it declares no ` +
|
|
183
|
-
"`foreignKeyOnTarget` (the column on " + `'${targetCollection.slug}'` + " that records the parent) and no `joinPath`.",
|
|
184
|
-
"RELATION_NOT_WRITABLE"
|
|
185
|
-
);
|
|
186
182
|
}
|
|
187
183
|
|
|
188
184
|
/**
|
|
@@ -204,7 +200,7 @@ export class PersistService {
|
|
|
204
200
|
// If saving under a nested relation path, resolve the relation it ends in.
|
|
205
201
|
let effectiveCollectionPath = collectionPath;
|
|
206
202
|
const effectiveValues: Partial<M> = { ...values };
|
|
207
|
-
let junctionTableInfo: { parentCollection: CollectionConfig; parentId: string | number; relation:
|
|
203
|
+
let junctionTableInfo: { parentCollection: CollectionConfig; parentId: string | number; relation: ResolvedManyToMany; relationKey: string; } | undefined;
|
|
208
204
|
|
|
209
205
|
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
|
|
210
206
|
|
|
@@ -217,7 +213,7 @@ export class PersistService {
|
|
|
217
213
|
return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
218
214
|
};
|
|
219
215
|
|
|
220
|
-
if (hop.relation
|
|
216
|
+
if (isManyToMany(hop.relation)) {
|
|
221
217
|
// A junction path addresses set membership, so a write through it
|
|
222
218
|
// asserts "this row is in this parent's set" — on create *and* on
|
|
223
219
|
// update. The junction row is written after the main write below,
|
|
@@ -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,
|
|
@@ -181,7 +182,7 @@ export class RelationService {
|
|
|
181
182
|
async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
|
|
182
183
|
parentCollection: CollectionConfig,
|
|
183
184
|
parentId: string | number,
|
|
184
|
-
relation:
|
|
185
|
+
relation: ResolvedRelation,
|
|
185
186
|
options: {
|
|
186
187
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
187
188
|
orderBy?: string;
|
|
@@ -206,7 +207,7 @@ export class RelationService {
|
|
|
206
207
|
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
207
208
|
|
|
208
209
|
// Handle join path relations
|
|
209
|
-
if (relation.
|
|
210
|
+
if (relation.kind === "via") {
|
|
210
211
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
211
212
|
let currentTable = parentTable;
|
|
212
213
|
|
|
@@ -349,7 +350,7 @@ export class RelationService {
|
|
|
349
350
|
private async countRelatedRows(
|
|
350
351
|
parentCollection: CollectionConfig,
|
|
351
352
|
parentId: string | number,
|
|
352
|
-
relation:
|
|
353
|
+
relation: ResolvedRelation,
|
|
353
354
|
additionalFilters: SQL[]
|
|
354
355
|
): Promise<number> {
|
|
355
356
|
const targetCollection = relation.target();
|
|
@@ -424,10 +425,10 @@ export class RelationService {
|
|
|
424
425
|
hop: NestedPathHop,
|
|
425
426
|
targetId: string | number
|
|
426
427
|
): Promise<void> {
|
|
427
|
-
|
|
428
|
-
if (!through) {
|
|
428
|
+
if (!isManyToMany(hop.relation)) {
|
|
429
429
|
throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
430
430
|
}
|
|
431
|
+
const through = hop.relation.through;
|
|
431
432
|
|
|
432
433
|
const junctionTable = this.registry.getTable(through.table);
|
|
433
434
|
if (!junctionTable) {
|
|
@@ -462,7 +463,7 @@ export class RelationService {
|
|
|
462
463
|
parentCollectionPath: string,
|
|
463
464
|
parentIds: (string | number)[],
|
|
464
465
|
_relationKey: string,
|
|
465
|
-
relation:
|
|
466
|
+
relation: ResolvedRelation
|
|
466
467
|
): Promise<Map<string, RelatedRow<Record<string, unknown>>>> {
|
|
467
468
|
if (parentIds.length === 0) return new Map();
|
|
468
469
|
|
|
@@ -483,7 +484,7 @@ export class RelationService {
|
|
|
483
484
|
const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
484
485
|
|
|
485
486
|
// Handle join path relations with batching
|
|
486
|
-
if (relation.
|
|
487
|
+
if (relation.kind === "via") {
|
|
487
488
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
488
489
|
let currentTable = parentTable;
|
|
489
490
|
|
|
@@ -542,7 +543,7 @@ export class RelationService {
|
|
|
542
543
|
// 1. Fetch FK values from the parent table in a single query
|
|
543
544
|
// 2. Query the target table with unique FK values
|
|
544
545
|
// 3. Map results back to parent rows via their FK values
|
|
545
|
-
if (relation.
|
|
546
|
+
if (relation.kind === "belongsTo") {
|
|
546
547
|
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
547
548
|
const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
|
|
548
549
|
if (!localKeyCol) {
|
|
@@ -606,7 +607,7 @@ export class RelationService {
|
|
|
606
607
|
this.assertSingleKeyAddressable(
|
|
607
608
|
parentCollection,
|
|
608
609
|
parentPks,
|
|
609
|
-
relation.foreignKeyOnTarget
|
|
610
|
+
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
|
|
610
611
|
);
|
|
611
612
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
612
613
|
|
|
@@ -639,11 +640,13 @@ export class RelationService {
|
|
|
639
640
|
// Determine the parent ID this result belongs to based on the relation type
|
|
640
641
|
let parentId: string | number | undefined;
|
|
641
642
|
|
|
642
|
-
|
|
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)) {
|
|
643
649
|
parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
644
|
-
} else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
|
|
645
|
-
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
646
|
-
parentId = targetRow[inferredForeignKeyName] as string | number | undefined;
|
|
647
650
|
}
|
|
648
651
|
|
|
649
652
|
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
@@ -663,7 +666,7 @@ export class RelationService {
|
|
|
663
666
|
parentCollectionPath: string,
|
|
664
667
|
parentIds: (string | number)[],
|
|
665
668
|
_relationKey: string,
|
|
666
|
-
relation:
|
|
669
|
+
relation: ResolvedRelation
|
|
667
670
|
): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>> {
|
|
668
671
|
if (parentIds.length === 0) return new Map();
|
|
669
672
|
|
|
@@ -683,7 +686,7 @@ export class RelationService {
|
|
|
683
686
|
const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
684
687
|
|
|
685
688
|
// Handle join path relations (many-to-many through junction tables)
|
|
686
|
-
if (relation.
|
|
689
|
+
if (relation.kind === "via") {
|
|
687
690
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
688
691
|
let currentTable = parentTable;
|
|
689
692
|
|
|
@@ -725,7 +728,7 @@ export class RelationService {
|
|
|
725
728
|
// Handle many-to-many owning relations with junction table (relation.through)
|
|
726
729
|
// This is the standard path for posts→tags style relations where
|
|
727
730
|
// sanitizeRelation populated the `through` config.
|
|
728
|
-
if (relation.
|
|
731
|
+
if (relation.kind === "manyToMany") {
|
|
729
732
|
// The junction names its parent with one column, so the same
|
|
730
733
|
// single-key limit applies as for a direct foreign key.
|
|
731
734
|
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
@@ -775,7 +778,7 @@ export class RelationService {
|
|
|
775
778
|
this.assertSingleKeyAddressable(
|
|
776
779
|
parentCollection,
|
|
777
780
|
parentPks,
|
|
778
|
-
relation.foreignKeyOnTarget
|
|
781
|
+
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
|
|
779
782
|
);
|
|
780
783
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
781
784
|
|
|
@@ -805,16 +808,10 @@ export class RelationService {
|
|
|
805
808
|
|
|
806
809
|
let parentId: string | number | undefined;
|
|
807
810
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
|
|
812
|
-
parentId = junctionData[relation.through.targetColumn] as string | number | undefined;
|
|
813
|
-
} 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)) {
|
|
814
814
|
parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
815
|
-
} else if (relation.direction === "inverse" && relation.inverseRelationName) {
|
|
816
|
-
const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
|
|
817
|
-
parentId = targetRow[inferredForeignKeyName] as string | number | undefined;
|
|
818
815
|
}
|
|
819
816
|
|
|
820
817
|
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
@@ -847,7 +844,7 @@ export class RelationService {
|
|
|
847
844
|
const targetCollection = relation.target();
|
|
848
845
|
|
|
849
846
|
// Use joinPath if available
|
|
850
|
-
if (relation.
|
|
847
|
+
if (relation.kind === "via") {
|
|
851
848
|
const parentTableName = getTableName(collection);
|
|
852
849
|
const targetTableName = getTableName(targetCollection);
|
|
853
850
|
|
|
@@ -913,7 +910,7 @@ export class RelationService {
|
|
|
913
910
|
await tx.insert(junctionTable).values(newLinks);
|
|
914
911
|
}
|
|
915
912
|
}
|
|
916
|
-
} else if (relation.
|
|
913
|
+
} else if (relation.kind === "manyToMany") {
|
|
917
914
|
// Handle many-to-many relations with junction table using 'through' property
|
|
918
915
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
919
916
|
if (!junctionTable) {
|
|
@@ -951,11 +948,7 @@ export class RelationService {
|
|
|
951
948
|
await tx.insert(junctionTable).values(newLinks);
|
|
952
949
|
}
|
|
953
950
|
}
|
|
954
|
-
} else if (relation.
|
|
955
|
-
// Inverse M2M relations should be saved from the owning side.
|
|
956
|
-
// The owning collection manages the junction table rows.
|
|
957
|
-
logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
|
|
958
|
-
} else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
951
|
+
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
959
952
|
// Handle one-to-many (inverse) by updating target FK to point to parent
|
|
960
953
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
961
954
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -1008,7 +1001,7 @@ export class RelationService {
|
|
|
1008
1001
|
sourceEntityId: string | number,
|
|
1009
1002
|
inverseRelationUpdates: Array<{
|
|
1010
1003
|
relationKey: string;
|
|
1011
|
-
relation:
|
|
1004
|
+
relation: ResolvedRelation;
|
|
1012
1005
|
newValue: unknown;
|
|
1013
1006
|
}>
|
|
1014
1007
|
) {
|
|
@@ -1023,8 +1016,7 @@ export class RelationService {
|
|
|
1023
1016
|
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
1024
1017
|
const sourceIdInfo = sourcePks[0];
|
|
1025
1018
|
|
|
1026
|
-
|
|
1027
|
-
if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
|
|
1019
|
+
if (relation.kind === "via") {
|
|
1028
1020
|
await this.updateInverseJoinPathRelation(
|
|
1029
1021
|
tx,
|
|
1030
1022
|
sourceCollection,
|
|
@@ -1036,42 +1028,31 @@ export class RelationService {
|
|
|
1036
1028
|
continue;
|
|
1037
1029
|
}
|
|
1038
1030
|
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
|
1055
1048
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
if (junctionInfo) {
|
|
1059
|
-
await this.updateManyToManyInverseRelation(
|
|
1060
|
-
tx,
|
|
1061
|
-
sourceCollection,
|
|
1062
|
-
sourceEntityId,
|
|
1063
|
-
targetCollection,
|
|
1064
|
-
relation,
|
|
1065
|
-
newValue,
|
|
1066
|
-
junctionInfo
|
|
1067
|
-
);
|
|
1068
|
-
continue;
|
|
1069
|
-
}
|
|
1049
|
+
);
|
|
1050
|
+
continue;
|
|
1070
1051
|
}
|
|
1071
1052
|
|
|
1072
|
-
//
|
|
1073
|
-
if (!relation
|
|
1074
|
-
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.`);
|
|
1075
1056
|
continue;
|
|
1076
1057
|
}
|
|
1077
1058
|
|
|
@@ -1120,14 +1101,10 @@ export class RelationService {
|
|
|
1120
1101
|
sourceCollection: CollectionConfig,
|
|
1121
1102
|
sourceEntityId: string | number,
|
|
1122
1103
|
targetCollection: CollectionConfig,
|
|
1123
|
-
relation:
|
|
1104
|
+
relation: ResolvedVia,
|
|
1124
1105
|
newValue: unknown
|
|
1125
1106
|
) {
|
|
1126
1107
|
try {
|
|
1127
|
-
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
1128
|
-
logger.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
|
|
1129
|
-
return;
|
|
1130
|
-
}
|
|
1131
1108
|
|
|
1132
1109
|
const sourceTableName = getTableName(sourceCollection);
|
|
1133
1110
|
const targetTableName = getTableName(targetCollection);
|
|
@@ -1230,7 +1207,7 @@ export class RelationService {
|
|
|
1230
1207
|
sourceCollection: CollectionConfig,
|
|
1231
1208
|
sourceEntityId: string | number,
|
|
1232
1209
|
targetCollection: CollectionConfig,
|
|
1233
|
-
relation:
|
|
1210
|
+
relation: ResolvedRelation,
|
|
1234
1211
|
newValue: unknown,
|
|
1235
1212
|
junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
|
|
1236
1213
|
) {
|
|
@@ -1288,7 +1265,9 @@ export class RelationService {
|
|
|
1288
1265
|
parentId: string | number,
|
|
1289
1266
|
updates: Array<{
|
|
1290
1267
|
relationKey: string;
|
|
1291
|
-
relation
|
|
1268
|
+
// Only a `via` relation has a join path to write through, and the
|
|
1269
|
+
// caller only collects those.
|
|
1270
|
+
relation: ResolvedVia;
|
|
1292
1271
|
newTargetId: string | number | null;
|
|
1293
1272
|
}>
|
|
1294
1273
|
) {
|
|
@@ -1366,7 +1345,7 @@ export class RelationService {
|
|
|
1366
1345
|
*/
|
|
1367
1346
|
resolveJoinPathWriteMapping(
|
|
1368
1347
|
parentCollection: CollectionConfig,
|
|
1369
|
-
relation:
|
|
1348
|
+
relation: ResolvedVia
|
|
1370
1349
|
): { targetFKColName: string; parentSourceColName: string } {
|
|
1371
1350
|
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
1372
1351
|
throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
@@ -1405,7 +1384,10 @@ parentSourceColName };
|
|
|
1405
1384
|
junctionTableInfo: {
|
|
1406
1385
|
parentCollection: CollectionConfig;
|
|
1407
1386
|
parentId: string | number;
|
|
1408
|
-
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;
|
|
1409
1391
|
relationKey: string;
|
|
1410
1392
|
}
|
|
1411
1393
|
) {
|
|
@@ -1413,14 +1395,14 @@ parentSourceColName };
|
|
|
1413
1395
|
const targetCollection = relation.target();
|
|
1414
1396
|
|
|
1415
1397
|
try {
|
|
1416
|
-
const junctionTable = this.registry.getTable(relation.through
|
|
1398
|
+
const junctionTable = this.registry.getTable(relation.through.table);
|
|
1417
1399
|
if (!junctionTable) {
|
|
1418
|
-
logger.warn(`Junction table '${relation.through
|
|
1400
|
+
logger.warn(`Junction table '${relation.through.table}' not found for relation '${relationKey}'`);
|
|
1419
1401
|
return;
|
|
1420
1402
|
}
|
|
1421
1403
|
|
|
1422
|
-
const sourceJunctionColumn = junctionTable[relation.through
|
|
1423
|
-
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;
|
|
1424
1406
|
|
|
1425
1407
|
if (!sourceJunctionColumn || !targetJunctionColumn) {
|
|
1426
1408
|
logger.warn(`Junction columns not found for relation '${relationKey}'`);
|
|
@@ -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
|
+
}
|