@rebasepro/server-postgres 0.9.1-canary.58368ce → 0.9.1-canary.682a9cf
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/PostgresBootstrapper.d.ts +10 -0
- package/dist/auth/services.d.ts +16 -0
- package/dist/connection.d.ts +21 -0
- package/dist/index.es.js +455 -243
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/services/FetchService.d.ts +4 -38
- package/dist/services/RelationService.d.ts +34 -0
- package/dist/services/collection-helpers.d.ts +34 -7
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +10 -7
- package/src/PostgresBackendDriver.ts +18 -5
- package/src/PostgresBootstrapper.ts +51 -2
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- package/src/connection.ts +61 -1
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/doctor.ts +45 -20
- package/src/schema/introspect-db.ts +19 -2
- package/src/services/FetchService.ts +29 -163
- package/src/services/PersistService.ts +9 -2
- package/src/services/RelationService.ts +153 -93
- package/src/services/collection-helpers.ts +59 -27
- package/src/services/realtimeService.ts +4 -2
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, eq, inArray, sql, SQL } from "drizzle-orm";
|
|
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
4
|
import { CollectionConfig, FilterValues, Relation } from "@rebasepro/types";
|
|
@@ -7,9 +7,10 @@ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
|
7
7
|
import {
|
|
8
8
|
getCollectionByPath,
|
|
9
9
|
getTableForCollection,
|
|
10
|
-
|
|
10
|
+
requirePrimaryKeys,
|
|
11
11
|
parseIdValues,
|
|
12
|
-
buildCompositeId
|
|
12
|
+
buildCompositeId,
|
|
13
|
+
type PrimaryKeyInfo
|
|
13
14
|
} from "./collection-helpers";
|
|
14
15
|
import { parseDataFromServer } from "../data-transformer";
|
|
15
16
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
@@ -56,6 +57,94 @@ export interface RelatedRow<M extends Record<string, unknown> = Record<string, u
|
|
|
56
57
|
export class RelationService {
|
|
57
58
|
constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) { }
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* One target row, as the {@link RelatedRow} everything here returns.
|
|
62
|
+
*
|
|
63
|
+
* Eight sites built this by hand, which is how the address came to be the
|
|
64
|
+
* target's first key column in all eight — one edit, eight places to miss.
|
|
65
|
+
*
|
|
66
|
+
* `resolveNested` is the one thing they did not agree on, and the
|
|
67
|
+
* disagreement was invisible: the single-parent fetches pass `db` and
|
|
68
|
+
* `registry` to `parseDataFromServer`, so the target's *own* relations get
|
|
69
|
+
* resolved too, while the batch paths deliberately do not — a query per
|
|
70
|
+
* target row is the N+1 the batching exists to avoid. Naming the parameter
|
|
71
|
+
* makes that a decision rather than a difference between two call sites
|
|
72
|
+
* nobody was comparing.
|
|
73
|
+
*/
|
|
74
|
+
private async toRelatedRow<M extends Record<string, unknown>>(
|
|
75
|
+
targetRow: Record<string, unknown>,
|
|
76
|
+
targetCollection: CollectionConfig,
|
|
77
|
+
targetPks: PrimaryKeyInfo[],
|
|
78
|
+
options?: { resolveNested?: boolean }
|
|
79
|
+
): Promise<RelatedRow<M>> {
|
|
80
|
+
const values = options?.resolveNested
|
|
81
|
+
? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry)
|
|
82
|
+
: await parseDataFromServer(targetRow, targetCollection);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
// The whole key: a composite target addressed by its first column
|
|
86
|
+
// names every row that shares it.
|
|
87
|
+
id: buildCompositeId(targetRow, targetPks),
|
|
88
|
+
path: targetCollection.slug,
|
|
89
|
+
values: values as M
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A WHERE matching any of `parentIds`, by the whole key.
|
|
95
|
+
*
|
|
96
|
+
* A single key is an `IN (…)`. A composite one cannot be: matching
|
|
97
|
+
* `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
|
|
98
|
+
* share their first column each receive the other's relations. It becomes
|
|
99
|
+
* an OR of ANDs — one exact address per parent — which Postgres indexes the
|
|
100
|
+
* same way it would a multi-column key lookup.
|
|
101
|
+
*/
|
|
102
|
+
private parentKeyCondition(
|
|
103
|
+
parentTable: PgTable,
|
|
104
|
+
parentPks: PrimaryKeyInfo[],
|
|
105
|
+
parentIds: (string | number)[]
|
|
106
|
+
): SQL {
|
|
107
|
+
const columnFor = (fieldName: string) => {
|
|
108
|
+
const col = parentTable[fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
109
|
+
if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
|
|
110
|
+
return col;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (parentPks.length === 1) {
|
|
114
|
+
const values = parentIds.map(id => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
|
|
115
|
+
return inArray(columnFor(parentPks[0].fieldName), values);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const perParent = parentIds.map(id => {
|
|
119
|
+
const values = parseIdValues(id, parentPks);
|
|
120
|
+
return and(...parentPks.map(pk => eq(columnFor(pk.fieldName), values[pk.fieldName])));
|
|
121
|
+
});
|
|
122
|
+
return or(...perParent) as SQL;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reject a relation that cannot express a composite-keyed parent.
|
|
127
|
+
*
|
|
128
|
+
* `localKey` and `foreignKeyOnTarget` are single column names: one column
|
|
129
|
+
* cannot reference a two-column key, so such a relation has no correct
|
|
130
|
+
* reading. Left alone it would silently match on the first key column and
|
|
131
|
+
* hand a tenant's rows to its neighbour — say so instead.
|
|
132
|
+
*/
|
|
133
|
+
private assertSingleKeyAddressable(
|
|
134
|
+
parentCollection: CollectionConfig,
|
|
135
|
+
parentPks: PrimaryKeyInfo[],
|
|
136
|
+
via: string
|
|
137
|
+
): void {
|
|
138
|
+
if (parentPks.length > 1) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but ` +
|
|
141
|
+
`'${parentCollection.slug}' is keyed on ${parentPks.map(k => `'${k.fieldName}'`).join(" + ")}. ` +
|
|
142
|
+
`One column cannot reference a composite key — express this relation with \`joinPath\`, whose ` +
|
|
143
|
+
`\`on.from\`/\`on.to\` take every key column.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
59
148
|
/**
|
|
60
149
|
* Fetch rows related to a parent row through a specific relation
|
|
61
150
|
*/
|
|
@@ -104,10 +193,10 @@ export class RelationService {
|
|
|
104
193
|
): Promise<RelatedRow<M>[]> {
|
|
105
194
|
const targetCollection = relation.target();
|
|
106
195
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
107
|
-
const idInfo =
|
|
196
|
+
const idInfo = requirePrimaryKeys(targetCollection, this.registry);
|
|
108
197
|
const idField = targetTable[idInfo[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
109
198
|
|
|
110
|
-
const parentPks =
|
|
199
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
111
200
|
const parentIdInfo = parentPks[0];
|
|
112
201
|
const parsedParentIdObj = parseIdValues(parentId, parentPks);
|
|
113
202
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
@@ -148,7 +237,7 @@ export class RelationService {
|
|
|
148
237
|
}
|
|
149
238
|
|
|
150
239
|
// Add where condition for the parent row
|
|
151
|
-
const parentIdField = parentTable[
|
|
240
|
+
const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
152
241
|
query = query.where(eq(parentIdField, parsedParentId));
|
|
153
242
|
|
|
154
243
|
if (options.limit) {
|
|
@@ -162,15 +251,7 @@ export class RelationService {
|
|
|
162
251
|
const rows: RelatedRow<M>[] = [];
|
|
163
252
|
for (const row of results as Array<Record<string, unknown>>) {
|
|
164
253
|
const targetRow = (row[targetTableName] as Record<string, unknown>) || row;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
rows.push({
|
|
168
|
-
// The whole key: the first column of a composite one names
|
|
169
|
-
// every row that shares it.
|
|
170
|
-
id: buildCompositeId(targetRow, idInfo),
|
|
171
|
-
path: targetCollection.slug,
|
|
172
|
-
values: parsedValues as M
|
|
173
|
-
});
|
|
254
|
+
rows.push(await this.toRelatedRow<M>(targetRow, targetCollection, idInfo, { resolveNested: true }));
|
|
174
255
|
}
|
|
175
256
|
|
|
176
257
|
return rows;
|
|
@@ -229,13 +310,7 @@ export class RelationService {
|
|
|
229
310
|
const rows: RelatedRow<M>[] = [];
|
|
230
311
|
for (const row of results) {
|
|
231
312
|
const targetRow = row[getTableName(targetCollection)] || row;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
rows.push({
|
|
235
|
-
id: buildCompositeId(targetRow as Record<string, unknown>, idInfo),
|
|
236
|
-
path: targetCollection.slug,
|
|
237
|
-
values: parsedValues as M
|
|
238
|
-
});
|
|
313
|
+
rows.push(await this.toRelatedRow<M>(targetRow as Record<string, unknown>, targetCollection, idInfo, { resolveNested: true }));
|
|
239
314
|
}
|
|
240
315
|
|
|
241
316
|
return rows;
|
|
@@ -260,11 +335,11 @@ export class RelationService {
|
|
|
260
335
|
|
|
261
336
|
const targetCollection = relation.target();
|
|
262
337
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
263
|
-
const targetPks =
|
|
338
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
264
339
|
const targetIdInfo = targetPks[0];
|
|
265
340
|
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
266
341
|
|
|
267
|
-
const parentPks =
|
|
342
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
268
343
|
const parentIdInfo = parentPks[0];
|
|
269
344
|
const parsedParentIdObj = parseIdValues(parentId, parentPks);
|
|
270
345
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
@@ -309,11 +384,11 @@ export class RelationService {
|
|
|
309
384
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
310
385
|
const targetCollection = relation.target();
|
|
311
386
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
312
|
-
const targetPks =
|
|
387
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
313
388
|
const targetIdInfo = targetPks[0];
|
|
314
389
|
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
315
390
|
|
|
316
|
-
const parentPks =
|
|
391
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
317
392
|
const parentIdInfo = parentPks[0];
|
|
318
393
|
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
319
394
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -354,27 +429,23 @@ export class RelationService {
|
|
|
354
429
|
currentTable = joinTable;
|
|
355
430
|
}
|
|
356
431
|
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
432
|
+
// Match every parent at once, each by its whole key.
|
|
433
|
+
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
360
434
|
|
|
361
435
|
const results = await query;
|
|
362
436
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
363
437
|
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
|
|
364
438
|
|
|
365
|
-
// Group
|
|
439
|
+
// Group by the parent's address — the same token the caller looks
|
|
440
|
+
// results up by, derived the same way on both sides.
|
|
366
441
|
for (const row of results as Array<Record<string, unknown>>) {
|
|
367
442
|
const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
|
|
368
443
|
const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
|
|
369
|
-
const parentId = parentRow[parentIdInfo.fieldName] as string | number;
|
|
370
|
-
|
|
371
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
372
444
|
|
|
373
|
-
resultMap.set(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
});
|
|
445
|
+
resultMap.set(
|
|
446
|
+
buildCompositeId(parentRow, parentPks),
|
|
447
|
+
await this.toRelatedRow(targetRow, targetCollection, targetPks)
|
|
448
|
+
);
|
|
378
449
|
}
|
|
379
450
|
|
|
380
451
|
return resultMap;
|
|
@@ -387,6 +458,7 @@ export class RelationService {
|
|
|
387
458
|
// 2. Query the target table with unique FK values
|
|
388
459
|
// 3. Map results back to parent rows via their FK values
|
|
389
460
|
if (relation.direction === "owning" && relation.localKey) {
|
|
461
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
|
|
390
462
|
const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
|
|
391
463
|
if (!localKeyCol) {
|
|
392
464
|
throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
|
|
@@ -436,19 +508,21 @@ export class RelationService {
|
|
|
436
508
|
for (const [parentIdStr, fkValue] of parentToFk) {
|
|
437
509
|
const targetRow = targetById.get(String(fkValue));
|
|
438
510
|
if (targetRow) {
|
|
439
|
-
|
|
440
|
-
resultMap.set(parentIdStr, {
|
|
441
|
-
id: buildCompositeId(targetRow, targetPks),
|
|
442
|
-
path: targetCollection.slug,
|
|
443
|
-
values: parsedValues as Record<string, unknown>
|
|
444
|
-
});
|
|
511
|
+
resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
445
512
|
}
|
|
446
513
|
}
|
|
447
514
|
|
|
448
515
|
return resultMap;
|
|
449
516
|
}
|
|
450
517
|
|
|
451
|
-
// Handle inverse relation types with batching
|
|
518
|
+
// Handle inverse relation types with batching. The parent is named by a
|
|
519
|
+
// single FK column on the target, so a composite-keyed parent has no
|
|
520
|
+
// correct reading here either.
|
|
521
|
+
this.assertSingleKeyAddressable(
|
|
522
|
+
parentCollection,
|
|
523
|
+
parentPks,
|
|
524
|
+
relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`
|
|
525
|
+
);
|
|
452
526
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
453
527
|
|
|
454
528
|
// Build the relation query with ALL parent IDs
|
|
@@ -488,12 +562,7 @@ export class RelationService {
|
|
|
488
562
|
}
|
|
489
563
|
|
|
490
564
|
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
491
|
-
|
|
492
|
-
resultMap.set(String(parentId), {
|
|
493
|
-
id: buildCompositeId(targetRow, targetPks),
|
|
494
|
-
path: targetCollection.slug,
|
|
495
|
-
values: parsedValues as Record<string, unknown>
|
|
496
|
-
});
|
|
565
|
+
resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
497
566
|
}
|
|
498
567
|
}
|
|
499
568
|
|
|
@@ -516,11 +585,11 @@ export class RelationService {
|
|
|
516
585
|
const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
|
|
517
586
|
const targetCollection = relation.target();
|
|
518
587
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
519
|
-
const targetPks =
|
|
588
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
520
589
|
const targetIdInfo = targetPks[0];
|
|
521
590
|
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
522
591
|
|
|
523
|
-
const parentPks =
|
|
592
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
524
593
|
const parentIdInfo = parentPks[0];
|
|
525
594
|
const parentTable = this.registry.getTable(getTableName(parentCollection));
|
|
526
595
|
if (!parentTable) throw new Error("Parent table not found");
|
|
@@ -550,8 +619,7 @@ export class RelationService {
|
|
|
550
619
|
currentTable = joinTable;
|
|
551
620
|
}
|
|
552
621
|
|
|
553
|
-
|
|
554
|
-
query = query.where(inArray(parentIdField, parsedParentIds));
|
|
622
|
+
query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
555
623
|
|
|
556
624
|
const results = await query;
|
|
557
625
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
@@ -560,15 +628,9 @@ export class RelationService {
|
|
|
560
628
|
for (const row of results as Array<Record<string, unknown>>) {
|
|
561
629
|
const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
|
|
562
630
|
const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
|
|
563
|
-
const parentId =
|
|
564
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
565
|
-
|
|
631
|
+
const parentId = buildCompositeId(parentRow, parentPks);
|
|
566
632
|
const arr = resultMap.get(parentId) || [];
|
|
567
|
-
arr.push(
|
|
568
|
-
id: buildCompositeId(targetRow, targetPks),
|
|
569
|
-
path: targetCollection.slug,
|
|
570
|
-
values: parsedValues as Record<string, unknown>
|
|
571
|
-
});
|
|
633
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
572
634
|
resultMap.set(parentId, arr);
|
|
573
635
|
}
|
|
574
636
|
|
|
@@ -579,6 +641,9 @@ export class RelationService {
|
|
|
579
641
|
// This is the standard path for posts→tags style relations where
|
|
580
642
|
// sanitizeRelation populated the `through` config.
|
|
581
643
|
if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
|
|
644
|
+
// The junction names its parent with one column, so the same
|
|
645
|
+
// single-key limit applies as for a direct foreign key.
|
|
646
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
|
|
582
647
|
const junctionTable = this.registry.getTable(relation.through.table);
|
|
583
648
|
if (!junctionTable) {
|
|
584
649
|
logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
|
|
@@ -612,21 +677,21 @@ export class RelationService {
|
|
|
612
677
|
const targetData = (row[targetTableName] || row) as Record<string, unknown>;
|
|
613
678
|
|
|
614
679
|
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
615
|
-
const parsedValues = await parseDataFromServer(targetData, targetCollection);
|
|
616
|
-
|
|
617
680
|
const arr = resultMap.get(parentId) || [];
|
|
618
|
-
arr.push(
|
|
619
|
-
id: buildCompositeId(targetData, targetPks),
|
|
620
|
-
path: targetCollection.slug,
|
|
621
|
-
values: parsedValues as Record<string, unknown>
|
|
622
|
-
});
|
|
681
|
+
arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
|
|
623
682
|
resultMap.set(parentId, arr);
|
|
624
683
|
}
|
|
625
684
|
|
|
626
685
|
return resultMap;
|
|
627
686
|
}
|
|
628
687
|
|
|
629
|
-
// Handle FK-based relations (one-to-many inverse)
|
|
688
|
+
// Handle FK-based relations (one-to-many inverse). One column on the
|
|
689
|
+
// target names the parent, so a composite-keyed parent cannot be named.
|
|
690
|
+
this.assertSingleKeyAddressable(
|
|
691
|
+
parentCollection,
|
|
692
|
+
parentPks,
|
|
693
|
+
relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`
|
|
694
|
+
);
|
|
630
695
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
631
696
|
|
|
632
697
|
query = applyDynamicRelationQuery(
|
|
@@ -668,14 +733,9 @@ export class RelationService {
|
|
|
668
733
|
}
|
|
669
734
|
|
|
670
735
|
if (parentId !== undefined && parentIdSet.has(String(parentId))) {
|
|
671
|
-
const parsedValues = await parseDataFromServer(targetRow, targetCollection);
|
|
672
736
|
const key = String(parentId);
|
|
673
737
|
const arr = resultMap.get(key) || [];
|
|
674
|
-
arr.push(
|
|
675
|
-
id: buildCompositeId(targetRow, targetPks),
|
|
676
|
-
path: targetCollection.slug,
|
|
677
|
-
values: parsedValues as Record<string, unknown>
|
|
678
|
-
});
|
|
738
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
679
739
|
resultMap.set(key, arr);
|
|
680
740
|
}
|
|
681
741
|
}
|
|
@@ -746,7 +806,7 @@ export class RelationService {
|
|
|
746
806
|
continue;
|
|
747
807
|
}
|
|
748
808
|
|
|
749
|
-
const parentPks =
|
|
809
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
750
810
|
const parentIdInfo = parentPks[0];
|
|
751
811
|
const parsedParentIdObj = parseIdValues(id, parentPks);
|
|
752
812
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
@@ -755,7 +815,7 @@ export class RelationService {
|
|
|
755
815
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
756
816
|
|
|
757
817
|
if (targetEntityIds.length > 0) {
|
|
758
|
-
const targetPks =
|
|
818
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
759
819
|
const targetIdInfo = targetPks[0];
|
|
760
820
|
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
761
821
|
|
|
@@ -784,7 +844,7 @@ export class RelationService {
|
|
|
784
844
|
continue;
|
|
785
845
|
}
|
|
786
846
|
|
|
787
|
-
const parentPks =
|
|
847
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
788
848
|
const parentIdInfo = parentPks[0];
|
|
789
849
|
const parsedParentIdObj = parseIdValues(id, parentPks);
|
|
790
850
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
@@ -793,7 +853,7 @@ export class RelationService {
|
|
|
793
853
|
await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
|
|
794
854
|
|
|
795
855
|
if (targetEntityIds.length > 0) {
|
|
796
|
-
const targetPks =
|
|
856
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
797
857
|
const targetIdInfo = targetPks[0];
|
|
798
858
|
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
799
859
|
|
|
@@ -813,7 +873,7 @@ export class RelationService {
|
|
|
813
873
|
} else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
|
|
814
874
|
// Handle one-to-many (inverse) by updating target FK to point to parent
|
|
815
875
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
816
|
-
const targetPks =
|
|
876
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
817
877
|
const targetIdInfo = targetPks[0];
|
|
818
878
|
const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
819
879
|
const fkCol = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
|
|
@@ -823,7 +883,7 @@ export class RelationService {
|
|
|
823
883
|
continue;
|
|
824
884
|
}
|
|
825
885
|
|
|
826
|
-
const parentPks =
|
|
886
|
+
const parentPks = requirePrimaryKeys(collection, this.registry);
|
|
827
887
|
const parentIdInfo = parentPks[0];
|
|
828
888
|
const parsedParentIdObj = parseIdValues(id, parentPks);
|
|
829
889
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
@@ -873,9 +933,9 @@ export class RelationService {
|
|
|
873
933
|
try {
|
|
874
934
|
const targetCollection = relation.target();
|
|
875
935
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
876
|
-
const targetPks =
|
|
936
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
877
937
|
const targetIdInfo = targetPks[0];
|
|
878
|
-
const sourcePks =
|
|
938
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
879
939
|
const sourceIdInfo = sourcePks[0];
|
|
880
940
|
|
|
881
941
|
// Handle inverse relations with joinPath
|
|
@@ -1032,7 +1092,7 @@ export class RelationService {
|
|
|
1032
1092
|
}
|
|
1033
1093
|
|
|
1034
1094
|
// Perform the junction table update
|
|
1035
|
-
const sourcePks =
|
|
1095
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
1036
1096
|
const sourceIdInfo = sourcePks[0];
|
|
1037
1097
|
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
1038
1098
|
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
@@ -1042,7 +1102,7 @@ export class RelationService {
|
|
|
1042
1102
|
|
|
1043
1103
|
// Add new entries if newValue is provided
|
|
1044
1104
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1045
|
-
const targetPks =
|
|
1105
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1046
1106
|
const targetIdInfo = targetPks[0];
|
|
1047
1107
|
const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
|
|
1048
1108
|
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
@@ -1057,7 +1117,7 @@ export class RelationService {
|
|
|
1057
1117
|
}
|
|
1058
1118
|
} else if (newValue && !Array.isArray(newValue)) {
|
|
1059
1119
|
// Single value for one-to-one
|
|
1060
|
-
const targetPks =
|
|
1120
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1061
1121
|
const targetIdInfo = targetPks[0];
|
|
1062
1122
|
const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
|
|
1063
1123
|
const parsedTargetIdObj = parseIdValues(targetId, targetPks);
|
|
@@ -1104,7 +1164,7 @@ export class RelationService {
|
|
|
1104
1164
|
return;
|
|
1105
1165
|
}
|
|
1106
1166
|
|
|
1107
|
-
const sourcePks =
|
|
1167
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
1108
1168
|
const sourceIdInfo = sourcePks[0];
|
|
1109
1169
|
const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
|
|
1110
1170
|
const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
|
|
@@ -1114,7 +1174,7 @@ export class RelationService {
|
|
|
1114
1174
|
|
|
1115
1175
|
// Add new entries if newValue is provided
|
|
1116
1176
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1117
|
-
const targetPks =
|
|
1177
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1118
1178
|
const targetIdInfo = targetPks[0];
|
|
1119
1179
|
const targetEntityIds = (newValue as Array<{ id: string | number }>).map((rel) => rel.id);
|
|
1120
1180
|
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
@@ -1151,14 +1211,14 @@ export class RelationService {
|
|
|
1151
1211
|
const { relation, newTargetId } = upd;
|
|
1152
1212
|
const targetCollection = relation.target();
|
|
1153
1213
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
1154
|
-
const targetPks =
|
|
1214
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1155
1215
|
const targetIdInfo = targetPks[0];
|
|
1156
1216
|
const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
1157
1217
|
|
|
1158
1218
|
// Determine mapping of columns
|
|
1159
1219
|
const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
1160
1220
|
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
1161
|
-
const parentPks =
|
|
1221
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
1162
1222
|
const parentIdInfo = parentPks[0];
|
|
1163
1223
|
const parsedParentIdObj = parseIdValues(parentId, parentPks);
|
|
1164
1224
|
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
@@ -1283,7 +1343,7 @@ parentSourceColName };
|
|
|
1283
1343
|
}
|
|
1284
1344
|
|
|
1285
1345
|
// Parse the new row ID to the correct type
|
|
1286
|
-
const targetPks =
|
|
1346
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1287
1347
|
const targetIdInfo = targetPks[0];
|
|
1288
1348
|
const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
|
|
1289
1349
|
const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
|
|
@@ -58,10 +58,27 @@ export function getTableForCollection(collection: CollectionConfig, registry: Po
|
|
|
58
58
|
return table;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The key columns a collection's rows are addressed by.
|
|
63
|
+
*
|
|
64
|
+
* Three tiers, in order: properties marked `isId`, the primary keys of the
|
|
65
|
+
* drizzle schema, and finally a column literally named `id`. Only the first is
|
|
66
|
+
* visible to the browser, which is why a key known only to drizzle is reported
|
|
67
|
+
* at boot — see {@link warnOnKeysTheAdminCannotResolve}.
|
|
68
|
+
*
|
|
69
|
+
* Returns `[]` when nothing resolves, rather than throwing. It used to open by
|
|
70
|
+
* resolving the table, which throws when there is none — so the `isId` tier,
|
|
71
|
+
* which needs no table at all, was unreachable for exactly the collections
|
|
72
|
+
* most likely to have no table registered. Every caller that wanted "no keys"
|
|
73
|
+
* to mean "no keys" had to spell that out in a try/catch.
|
|
74
|
+
*
|
|
75
|
+
* Callers that cannot proceed without a key must say so themselves, naming the
|
|
76
|
+
* collection: an empty array here means "this collection has no address", which
|
|
77
|
+
* is a different answer in a notification (broadcast a wildcard) than in a save
|
|
78
|
+
* (fail).
|
|
79
|
+
*/
|
|
61
80
|
export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// Fallback to explicitly defined isId properties
|
|
81
|
+
// Explicitly declared `isId` properties win, and need no table.
|
|
65
82
|
if (collection.properties) {
|
|
66
83
|
const idProps = Object.entries(collection.properties)
|
|
67
84
|
.filter(([_, prop]) => "isId" in (prop as object) && Boolean((prop as { isId?: unknown }).isId))
|
|
@@ -76,6 +93,12 @@ export function getPrimaryKeys(collection: CollectionConfig, registry: PostgresC
|
|
|
76
93
|
}
|
|
77
94
|
}
|
|
78
95
|
|
|
96
|
+
// The remaining tiers read the drizzle schema, so they need the table. A
|
|
97
|
+
// collection without one — another engine's, or simply unregistered — has
|
|
98
|
+
// nothing more to offer.
|
|
99
|
+
const table = registry.getTable(getTableName(collection));
|
|
100
|
+
if (!table) return [];
|
|
101
|
+
|
|
79
102
|
// Otherwise infer from Drizzle schema
|
|
80
103
|
const keys: PrimaryKeyInfo[] = [];
|
|
81
104
|
for (const [key, colRaw] of Object.entries(table)) {
|
|
@@ -105,6 +128,27 @@ isUUID });
|
|
|
105
128
|
return keys;
|
|
106
129
|
}
|
|
107
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The key columns, for callers that cannot do their job without one.
|
|
133
|
+
*
|
|
134
|
+
* {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
|
|
135
|
+
* collection with no address. Most of this driver, though, is building a WHERE
|
|
136
|
+
* clause and has no meaning without a key — for those, an empty array is not an
|
|
137
|
+
* answer, and indexing `[0]` into it produces `Cannot read properties of
|
|
138
|
+
* undefined` three frames from where the real problem is. This says what is
|
|
139
|
+
* wrong and which collection it is wrong about.
|
|
140
|
+
*/
|
|
141
|
+
export function requirePrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[] {
|
|
142
|
+
const keys = getPrimaryKeys(collection, registry);
|
|
143
|
+
if (keys.length === 0) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. ` +
|
|
146
|
+
`Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return keys;
|
|
150
|
+
}
|
|
151
|
+
|
|
108
152
|
/**
|
|
109
153
|
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
110
154
|
* instead.
|
|
@@ -138,14 +182,9 @@ export function findUnresolvableKeyCollections(
|
|
|
138
182
|
// Declared `isId` is the tier both sides share: if it is there, they agree.
|
|
139
183
|
if (getDeclaredPrimaryKeys(collection).length > 0) continue;
|
|
140
184
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
} catch {
|
|
145
|
-
// No registered table — nothing resolved here either, so there is no
|
|
146
|
-
// disagreement to report.
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
185
|
+
// No registered table resolves to no keys, and a collection this cannot
|
|
186
|
+
// resolve a key for is one it has nothing to say about.
|
|
187
|
+
const keys = getPrimaryKeys(collection, registry);
|
|
149
188
|
if (keys.length === 0) continue;
|
|
150
189
|
|
|
151
190
|
// A single key named `id` is the last tier on both sides: they agree
|
|
@@ -207,28 +246,21 @@ export function warnOnKeysTheAdminCannotResolve(
|
|
|
207
246
|
* The address of a row: derived from the collection's primary keys, because a
|
|
208
247
|
* row does not carry one — it is exactly its columns.
|
|
209
248
|
*
|
|
210
|
-
* Falls back to a literal `id` column,
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
* (
|
|
214
|
-
* nothing). Returns `""` when there is no key and no `id` — callers decide what
|
|
215
|
-
* that means, since "unaddressable" is a different answer in a notification
|
|
216
|
-
* (broadcast a wildcard) than in a save (fail).
|
|
249
|
+
* Falls back to a literal `id` column, for a row that reached us from somewhere
|
|
250
|
+
* other than this driver. Returns `""` when there is no key and no `id` —
|
|
251
|
+
* callers decide what that means, since "unaddressable" is a different answer
|
|
252
|
+
* in a notification (broadcast a wildcard) than in a save (fail).
|
|
217
253
|
*/
|
|
218
254
|
export function deriveRowAddress(
|
|
219
255
|
row: Record<string, unknown>,
|
|
220
256
|
collection: CollectionConfig,
|
|
221
257
|
registry: PostgresCollectionRegistry
|
|
222
258
|
): string {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
return composite;
|
|
229
|
-
}
|
|
230
|
-
} catch {
|
|
231
|
-
/* unresolvable table or keys — fall through to the literal column */
|
|
259
|
+
const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
|
|
260
|
+
// An all-empty composite is indistinguishable from a missing key, and
|
|
261
|
+
// `buildCompositeId` returns "" for no keys at all.
|
|
262
|
+
if (composite && composite.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "")) {
|
|
263
|
+
return composite;
|
|
232
264
|
}
|
|
233
265
|
if (row.id !== undefined && row.id !== null) return String(row.id);
|
|
234
266
|
return "";
|
|
@@ -963,8 +963,10 @@ roles: activeAuth.roles },
|
|
|
963
963
|
const keys = getPrimaryKeys(collection, this.registry);
|
|
964
964
|
return keys.length > 0 ? keys : undefined;
|
|
965
965
|
} catch {
|
|
966
|
-
//
|
|
967
|
-
//
|
|
966
|
+
// `getCollectionByPath` throws on a path it cannot walk — and this
|
|
967
|
+
// is called for parent paths too, which include entity paths like
|
|
968
|
+
// `posts/1` that name no collection. Telling the subscriber nothing
|
|
969
|
+
// is right here; letting it throw would drop the notification.
|
|
968
970
|
return undefined;
|
|
969
971
|
}
|
|
970
972
|
}
|