@rebasepro/server-postgres 0.12.1-canary.g4e7bcbf → 0.12.1-canary.g52d71ee
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/services.d.ts +6 -1
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/{backup-service-DLb2drIH.js → backup-service-CD8o_1Sl.js} +136 -10
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/{ensure-collection-policies-Dv21KDMJ.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
- package/dist/{ensure-collection-policies-Dv21KDMJ.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
- package/dist/{ensure-collection-tables-CT6xHB1d.js → ensure-collection-tables-CBQdOETu.js} +2 -2
- package/dist/{ensure-collection-tables-CT6xHB1d.js.map → ensure-collection-tables-CBQdOETu.js.map} +1 -1
- package/dist/index.es.js +768 -537
- package/dist/index.es.js.map +1 -1
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/{src-BkdpiQdw.js → src-DlPBctw_.js} +72 -6
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +16 -0
- package/package.json +6 -6
- package/src/auth/services.ts +6 -3
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli.ts +11 -4
- package/src/collections/validate-relations.ts +15 -0
- package/src/data-transformer.ts +9 -3
- package/src/schema/generate-drizzle-schema-logic.ts +26 -1
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +364 -68
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +14 -0
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +211 -45
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +19 -0
- package/dist/backup-service-DLb2drIH.js.map +0 -1
- package/dist/src-BkdpiQdw.js.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
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, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
|
|
4
|
+
import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
6
6
|
import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
|
|
7
7
|
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
requirePrimaryKeys,
|
|
12
12
|
parseIdValues,
|
|
13
13
|
buildCompositeId,
|
|
14
|
+
joinsOnNaturalKey,
|
|
15
|
+
sourceKeyField,
|
|
14
16
|
type PrimaryKeyInfo
|
|
15
17
|
} from "./collection-helpers";
|
|
16
18
|
import { parseDataFromServer } from "../data-transformer";
|
|
@@ -168,18 +170,122 @@ export class RelationService {
|
|
|
168
170
|
private assertSingleKeyAddressable(
|
|
169
171
|
parentCollection: CollectionConfig,
|
|
170
172
|
parentPks: PrimaryKeyInfo[],
|
|
171
|
-
via: string
|
|
173
|
+
via: string,
|
|
174
|
+
relation?: ResolvedRelation
|
|
172
175
|
): void {
|
|
176
|
+
// A `sourceKey` names the single column the link actually points at, so
|
|
177
|
+
// the parent's key can be as wide as it likes — nothing here references
|
|
178
|
+
// it. That is the one way out of this error, and the message below now
|
|
179
|
+
// has to say so.
|
|
180
|
+
if (relation && hasForeignKeyOnTarget(relation) && relation.sourceKey) return;
|
|
181
|
+
|
|
173
182
|
if (parentPks.length > 1) {
|
|
174
183
|
throw new Error(
|
|
175
184
|
`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but ` +
|
|
176
185
|
`'${parentCollection.slug}' is keyed on ${parentPks.map(k => `'${k.fieldName}'`).join(" + ")}. ` +
|
|
177
|
-
`One column cannot reference a composite key —
|
|
186
|
+
`One column cannot reference a composite key — give the relation a \`sourceKey\` naming ` +
|
|
187
|
+
`a single unique column to point at, or express it with \`joinPath\`, whose ` +
|
|
178
188
|
`\`on.from\`/\`on.to\` take every key column.`
|
|
179
189
|
);
|
|
180
190
|
}
|
|
181
191
|
}
|
|
182
192
|
|
|
193
|
+
/**
|
|
194
|
+
* What the target's foreign key holds, for each of these parent rows.
|
|
195
|
+
*
|
|
196
|
+
* Ordinarily the parent's id, and then this is free. When the relation
|
|
197
|
+
* declares a `sourceKey` the two are different values, and the mapping
|
|
198
|
+
* between them lives in the source table — so it costs one SELECT, issued
|
|
199
|
+
* once for the whole batch rather than per parent.
|
|
200
|
+
*
|
|
201
|
+
* Both directions come back because both are needed and deriving one from
|
|
202
|
+
* the other by hand is how a batch loader ends up attributing a child to the
|
|
203
|
+
* wrong parent: reads translate id → key to build the WHERE, and then
|
|
204
|
+
* translate key → id to attribute each row that comes back.
|
|
205
|
+
*/
|
|
206
|
+
/**
|
|
207
|
+
* The value a related row's foreign key must hold to belong to this parent.
|
|
208
|
+
*
|
|
209
|
+
* `undefined` when the parent's source key is null — which is not an error
|
|
210
|
+
* here, only in the callers that were about to write it. Exposed for
|
|
211
|
+
* {@link PersistService}, which stamps this onto a child created under a
|
|
212
|
+
* nested path and would otherwise write the id and lose the row.
|
|
213
|
+
*/
|
|
214
|
+
async parentKeyValue(
|
|
215
|
+
parentCollection: CollectionConfig,
|
|
216
|
+
relation: ResolvedHasOne | ResolvedHasMany,
|
|
217
|
+
parentId: string | number,
|
|
218
|
+
db: DrizzleClient = this.db
|
|
219
|
+
): Promise<string | number | undefined> {
|
|
220
|
+
const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
|
|
221
|
+
return keyByParentId.get(String(parentId));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async resolveSourceKeys(
|
|
225
|
+
parentCollection: CollectionConfig,
|
|
226
|
+
relation: ResolvedHasOne | ResolvedHasMany,
|
|
227
|
+
parentIds: (string | number)[],
|
|
228
|
+
// Writes pass their transaction: reading the source key on the pool
|
|
229
|
+
// while the same transaction is holding an uncommitted change to it
|
|
230
|
+
// would translate the id against a stale value.
|
|
231
|
+
db: DrizzleClient = this.db
|
|
232
|
+
): Promise<{ keyByParentId: Map<string, string | number>; parentIdByKey: Map<string, string | number> }> {
|
|
233
|
+
const keyByParentId = new Map<string, string | number>();
|
|
234
|
+
const parentIdByKey = new Map<string, string | number>();
|
|
235
|
+
|
|
236
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
237
|
+
|
|
238
|
+
if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
|
|
239
|
+
// The ordinary case, and it must stay free of a round-trip: the
|
|
240
|
+
// key IS the id. Parsed, not passed through — an id arrives as the
|
|
241
|
+
// string from a URL, and comparing "7" against an integer column is
|
|
242
|
+
// how this returns nothing at all.
|
|
243
|
+
for (const id of parentIds) {
|
|
244
|
+
const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
|
|
245
|
+
keyByParentId.set(String(id), value);
|
|
246
|
+
parentIdByKey.set(String(value), id);
|
|
247
|
+
}
|
|
248
|
+
return { keyByParentId, parentIdByKey };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const field = sourceKeyField(relation, parentCollection, this.registry);
|
|
252
|
+
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
253
|
+
const keyColumn = parentTable[field as keyof typeof parentTable] as AnyPgColumn;
|
|
254
|
+
if (!keyColumn) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not a column on ` +
|
|
257
|
+
`'${parentCollection.slug}'. It names a column on the source table, not on the target.`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const rows = await db
|
|
262
|
+
.select()
|
|
263
|
+
.from(parentTable)
|
|
264
|
+
.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
|
|
265
|
+
|
|
266
|
+
for (const row of rows as Array<Record<string, unknown>>) {
|
|
267
|
+
const keyValue = row[field] as string | number | null;
|
|
268
|
+
if (keyValue === null || keyValue === undefined) continue;
|
|
269
|
+
const parentId = buildCompositeId(row, parentPks);
|
|
270
|
+
keyByParentId.set(String(parentId), keyValue);
|
|
271
|
+
// A duplicate here means the source key is not unique, which makes
|
|
272
|
+
// "which parent does this child belong to" unanswerable. Refuse
|
|
273
|
+
// rather than pick the last one seen.
|
|
274
|
+
const existing = parentIdByKey.get(String(keyValue));
|
|
275
|
+
if (existing !== undefined && String(existing) !== String(parentId)) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not unique on ` +
|
|
278
|
+
`'${parentCollection.slug}': rows '${existing}' and '${parentId}' both hold ` +
|
|
279
|
+
`'${keyValue}'. Add a unique constraint — a link that addresses more than one source ` +
|
|
280
|
+
`row cannot say which one a related row belongs to.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
parentIdByKey.set(String(keyValue), parentId);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { keyByParentId, parentIdByKey };
|
|
287
|
+
}
|
|
288
|
+
|
|
183
289
|
/**
|
|
184
290
|
* Fetch rows related to a parent row through a specific relation
|
|
185
291
|
*/
|
|
@@ -292,7 +398,21 @@ export class RelationService {
|
|
|
292
398
|
return rows;
|
|
293
399
|
}
|
|
294
400
|
|
|
295
|
-
// Handle other relation types
|
|
401
|
+
// Handle other relation types.
|
|
402
|
+
//
|
|
403
|
+
// The query builder compares the target's foreign key against a value,
|
|
404
|
+
// and for a link on a natural key that value is not the id in the URL.
|
|
405
|
+
// Resolved first, before anything is built, so a parent that reaches
|
|
406
|
+
// nothing costs one statement rather than two.
|
|
407
|
+
const matchValue = hasForeignKeyOnTarget(relation)
|
|
408
|
+
? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
|
|
409
|
+
.keyByParentId.get(String(parentId))
|
|
410
|
+
: parsedParentId;
|
|
411
|
+
|
|
412
|
+
// A parent whose source key is null reaches nothing: NULL never equals
|
|
413
|
+
// a foreign key. Say so with an empty list rather than an `= NULL`.
|
|
414
|
+
if (matchValue === undefined) return [];
|
|
415
|
+
|
|
296
416
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
297
417
|
|
|
298
418
|
// Build additional filter conditions
|
|
@@ -322,7 +442,7 @@ export class RelationService {
|
|
|
322
442
|
query,
|
|
323
443
|
query,
|
|
324
444
|
relation,
|
|
325
|
-
|
|
445
|
+
matchValue,
|
|
326
446
|
targetTable,
|
|
327
447
|
parentTable,
|
|
328
448
|
parentIdCol,
|
|
@@ -400,6 +520,16 @@ export class RelationService {
|
|
|
400
520
|
if (!parentTable) throw new Error("Parent table not found");
|
|
401
521
|
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
402
522
|
|
|
523
|
+
// Same translation the listing does, and it has to be: `isRelated`
|
|
524
|
+
// gates writes on this count, so a count built from a different value
|
|
525
|
+
// than the read would authorise rows the read never returned.
|
|
526
|
+
const matchValue = hasForeignKeyOnTarget(relation)
|
|
527
|
+
? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
|
|
528
|
+
.keyByParentId.get(String(parentId))
|
|
529
|
+
: parsedParentId;
|
|
530
|
+
|
|
531
|
+
if (matchValue === undefined) return 0;
|
|
532
|
+
|
|
403
533
|
// Start count with distinct to avoid duplicates from junction tables
|
|
404
534
|
let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
405
535
|
|
|
@@ -407,7 +537,7 @@ export class RelationService {
|
|
|
407
537
|
query = DrizzleConditionBuilder.buildRelationCountQuery(
|
|
408
538
|
query,
|
|
409
539
|
relation,
|
|
410
|
-
|
|
540
|
+
matchValue,
|
|
411
541
|
targetTable,
|
|
412
542
|
parentTable,
|
|
413
543
|
parentIdCol,
|
|
@@ -640,8 +770,22 @@ export class RelationService {
|
|
|
640
770
|
this.assertSingleKeyAddressable(
|
|
641
771
|
parentCollection,
|
|
642
772
|
parentPks,
|
|
643
|
-
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
|
|
773
|
+
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
|
|
774
|
+
relation
|
|
644
775
|
);
|
|
776
|
+
|
|
777
|
+
// One lookup for the whole batch, both directions: the WHERE is built
|
|
778
|
+
// from the source-key values, and each row that comes back carries one
|
|
779
|
+
// of those values rather than a parent id.
|
|
780
|
+
const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
|
|
781
|
+
? await this.resolveSourceKeys(parentCollection, relation, parentIds)
|
|
782
|
+
: { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
|
|
783
|
+
|
|
784
|
+
const matchValues = hasForeignKeyOnTarget(relation)
|
|
785
|
+
? [...keyByParentId.values()]
|
|
786
|
+
: parsedParentIds;
|
|
787
|
+
if (matchValues.length === 0) return new Map();
|
|
788
|
+
|
|
645
789
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
646
790
|
|
|
647
791
|
// Build the relation query with ALL parent IDs
|
|
@@ -649,7 +793,7 @@ export class RelationService {
|
|
|
649
793
|
query,
|
|
650
794
|
query,
|
|
651
795
|
relation,
|
|
652
|
-
|
|
796
|
+
matchValues, // Pass array instead of single ID
|
|
653
797
|
targetTable,
|
|
654
798
|
parentTable,
|
|
655
799
|
parentIdCol,
|
|
@@ -661,28 +805,24 @@ export class RelationService {
|
|
|
661
805
|
const results = await query;
|
|
662
806
|
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
|
|
663
807
|
|
|
664
|
-
// Build a Set<string> for O(1) parent-ID lookups that is immune to
|
|
665
|
-
// number-vs-string type mismatches (Drizzle may return either depending
|
|
666
|
-
// on the column type and driver).
|
|
667
|
-
const parentIdSet = new Set(parsedParentIds.map(String));
|
|
668
|
-
|
|
669
808
|
// Map results back to parent rows
|
|
670
809
|
for (const row of results as Array<Record<string, unknown>>) {
|
|
671
810
|
const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
|
|
672
811
|
|
|
673
|
-
// Determine the parent ID this result belongs to based on the relation type
|
|
674
|
-
let parentId: string | number | undefined;
|
|
675
|
-
|
|
676
812
|
// The parent's key is on the target row, in the relation's own
|
|
677
813
|
// column. There used to be a second branch here that guessed the
|
|
678
814
|
// column by appending `_id` to `inverseRelationName`, reached only
|
|
679
815
|
// when the foreign key had not been resolved — which cannot happen
|
|
680
816
|
// now that resolution fills it in.
|
|
681
|
-
if (hasForeignKeyOnTarget(relation))
|
|
682
|
-
|
|
683
|
-
|
|
817
|
+
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
818
|
+
|
|
819
|
+
// Keyed by string throughout: Drizzle returns a numeric column as a
|
|
820
|
+
// number or a string depending on the column type and the driver.
|
|
821
|
+
const foreignKeyValue = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
822
|
+
if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
|
|
684
823
|
|
|
685
|
-
|
|
824
|
+
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
825
|
+
if (parentId !== undefined) {
|
|
686
826
|
resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
687
827
|
}
|
|
688
828
|
}
|
|
@@ -811,15 +951,26 @@ export class RelationService {
|
|
|
811
951
|
this.assertSingleKeyAddressable(
|
|
812
952
|
parentCollection,
|
|
813
953
|
parentPks,
|
|
814
|
-
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
|
|
954
|
+
hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
|
|
955
|
+
relation
|
|
815
956
|
);
|
|
957
|
+
|
|
958
|
+
const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
|
|
959
|
+
? await this.resolveSourceKeys(parentCollection, relation, parentIds)
|
|
960
|
+
: { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
|
|
961
|
+
|
|
962
|
+
const matchValues = hasForeignKeyOnTarget(relation)
|
|
963
|
+
? [...keyByParentId.values()]
|
|
964
|
+
: parsedParentIds;
|
|
965
|
+
if (matchValues.length === 0) return new Map();
|
|
966
|
+
|
|
816
967
|
let query = this.db.select().from(targetTable).$dynamic();
|
|
817
968
|
|
|
818
969
|
query = applyDynamicRelationQuery(
|
|
819
970
|
query,
|
|
820
971
|
query,
|
|
821
972
|
relation,
|
|
822
|
-
|
|
973
|
+
matchValues,
|
|
823
974
|
targetTable,
|
|
824
975
|
parentTable,
|
|
825
976
|
parentIdCol,
|
|
@@ -831,23 +982,18 @@ export class RelationService {
|
|
|
831
982
|
const results = await query;
|
|
832
983
|
const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
|
|
833
984
|
|
|
834
|
-
// Build a Set<string> for O(1) parent-ID lookups that is immune to
|
|
835
|
-
// number-vs-string type mismatches (Drizzle may return either depending
|
|
836
|
-
// on the column type and driver).
|
|
837
|
-
const parentIdSet = new Set(parsedParentIds.map(String));
|
|
838
|
-
|
|
839
985
|
for (const row of results as Array<Record<string, unknown>>) {
|
|
840
986
|
const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
|
|
841
987
|
|
|
842
|
-
let parentId: string | number | undefined;
|
|
843
|
-
|
|
844
988
|
// Junction-backed relations returned earlier in this method, so
|
|
845
989
|
// what reaches here names the parent with a column on the target.
|
|
846
|
-
if (hasForeignKeyOnTarget(relation))
|
|
847
|
-
|
|
848
|
-
|
|
990
|
+
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
991
|
+
|
|
992
|
+
const foreignKeyValue = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
|
|
993
|
+
if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
|
|
849
994
|
|
|
850
|
-
|
|
995
|
+
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
996
|
+
if (parentId !== undefined) {
|
|
851
997
|
const key = String(parentId);
|
|
852
998
|
const arr = resultMap.get(key) || [];
|
|
853
999
|
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
@@ -994,10 +1140,19 @@ export class RelationService {
|
|
|
994
1140
|
continue;
|
|
995
1141
|
}
|
|
996
1142
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const
|
|
1143
|
+
// What the children's foreign key must hold — the parent's id
|
|
1144
|
+
// unless the link declares a `sourceKey`, and then the value of
|
|
1145
|
+
// that column on this parent row.
|
|
1146
|
+
const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx))
|
|
1147
|
+
.keyByParentId.get(String(id));
|
|
1148
|
+
|
|
1149
|
+
if (parentKeyValue === undefined) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in ` +
|
|
1152
|
+
`\`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is ` +
|
|
1153
|
+
"nothing for the related rows to point at."
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1001
1156
|
|
|
1002
1157
|
// Clear existing links not in the new set
|
|
1003
1158
|
if (targetEntityIds.length > 0) {
|
|
@@ -1005,19 +1160,19 @@ export class RelationService {
|
|
|
1005
1160
|
await tx
|
|
1006
1161
|
.update(targetTable)
|
|
1007
1162
|
.set({ [relation.foreignKeyOnTarget]: null })
|
|
1008
|
-
.where(and(eq(fkCol,
|
|
1163
|
+
.where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
|
|
1009
1164
|
|
|
1010
1165
|
// Set FK for the provided targets
|
|
1011
1166
|
await tx
|
|
1012
1167
|
.update(targetTable)
|
|
1013
|
-
.set({ [relation.foreignKeyOnTarget]:
|
|
1168
|
+
.set({ [relation.foreignKeyOnTarget]: parentKeyValue })
|
|
1014
1169
|
.where(inArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[]));
|
|
1015
1170
|
} else {
|
|
1016
1171
|
// If empty array provided, clear all existing links for this parent
|
|
1017
1172
|
await tx
|
|
1018
1173
|
.update(targetTable)
|
|
1019
1174
|
.set({ [relation.foreignKeyOnTarget]: null })
|
|
1020
|
-
.where(eq(fkCol,
|
|
1175
|
+
.where(eq(fkCol, parentKeyValue));
|
|
1021
1176
|
}
|
|
1022
1177
|
} else {
|
|
1023
1178
|
logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
|
|
@@ -1095,14 +1250,25 @@ export class RelationService {
|
|
|
1095
1250
|
continue;
|
|
1096
1251
|
}
|
|
1097
1252
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1253
|
+
// The value the target's foreign key holds: this row's id, or
|
|
1254
|
+
// the column named by `sourceKey` when the link joins on one.
|
|
1255
|
+
const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx))
|
|
1256
|
+
.keyByParentId.get(String(sourceEntityId));
|
|
1257
|
+
|
|
1258
|
+
if (sourceKeyValue === undefined) {
|
|
1259
|
+
throw new Error(
|
|
1260
|
+
`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row ` +
|
|
1261
|
+
`'${sourceEntityId}' has no value in \`sourceKey: ` +
|
|
1262
|
+
`"${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing ` +
|
|
1263
|
+
"for the related row to point at."
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1100
1266
|
|
|
1101
1267
|
if (newValue === null || newValue === undefined) {
|
|
1102
1268
|
await tx
|
|
1103
1269
|
.update(targetTable)
|
|
1104
1270
|
.set({ [relation.foreignKeyOnTarget!]: null })
|
|
1105
|
-
.where(eq(foreignKeyColumn,
|
|
1271
|
+
.where(eq(foreignKeyColumn, sourceKeyValue));
|
|
1106
1272
|
} else {
|
|
1107
1273
|
const parsedNewTargetIdObj = parseIdValues(newValue as string | number, targetPks);
|
|
1108
1274
|
const parsedNewTargetId = parsedNewTargetIdObj[targetIdInfo.fieldName];
|
|
@@ -1112,12 +1278,12 @@ export class RelationService {
|
|
|
1112
1278
|
await tx
|
|
1113
1279
|
.update(targetTable)
|
|
1114
1280
|
.set({ [relation.foreignKeyOnTarget!]: null })
|
|
1115
|
-
.where(eq(foreignKeyColumn,
|
|
1281
|
+
.where(eq(foreignKeyColumn, sourceKeyValue));
|
|
1116
1282
|
|
|
1117
1283
|
// Then, update the new target row to point to this source row
|
|
1118
1284
|
await tx
|
|
1119
1285
|
.update(targetTable)
|
|
1120
|
-
.set({ [relation.foreignKeyOnTarget!]:
|
|
1286
|
+
.set({ [relation.foreignKeyOnTarget!]: sourceKeyValue })
|
|
1121
1287
|
.where(eq(targetIdField, parsedNewTargetId));
|
|
1122
1288
|
}
|
|
1123
1289
|
} catch (e) {
|
|
@@ -80,15 +80,6 @@ export function resolveChannelBusSetting(configured?: ChannelBusSetting): Channe
|
|
|
80
80
|
return raw === "memory" ? { type: "memory" } : { type: "postgres" };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/**
|
|
84
|
-
* @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
|
|
85
|
-
* supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
|
|
86
|
-
* config-only callers keep their exact types.
|
|
87
|
-
*/
|
|
88
|
-
export function resolveChannelBusConfig(configured?: ChannelBusConfig): ChannelBusConfig {
|
|
89
|
-
return resolveChannelBusSetting(configured) as ChannelBusConfig;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
83
|
/**
|
|
93
84
|
* Produce the bus a setting asks for.
|
|
94
85
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
|
-
import { CollectionConfig, Property } from "@rebasepro/types";
|
|
2
|
+
import { CollectionConfig, Property, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
|
|
3
3
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
4
4
|
import { getTableName } from "@rebasepro/common";
|
|
5
5
|
import { logger } from "@rebasepro/server";
|
|
@@ -7,9 +7,9 @@ import { logger } from "@rebasepro/server";
|
|
|
7
7
|
// Row identity is derived on both sides of the wire — the driver parses an
|
|
8
8
|
// incoming address into key columns, the admin derives one from a served row —
|
|
9
9
|
// so the implementation lives in `common` and both agree by construction.
|
|
10
|
-
export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
|
|
10
|
+
export { buildCompositeId, parseIdValues, isAddressableId, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
|
|
11
11
|
export type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
12
|
-
import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys } from "@rebasepro/common";
|
|
12
|
+
import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys, isAddressableId } from "@rebasepro/common";
|
|
13
13
|
import type { PrimaryKeyInfo } from "@rebasepro/common";
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -40,6 +40,33 @@ export function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Whether an address could name a row in this table, judged by the columns.
|
|
45
|
+
*
|
|
46
|
+
* {@link getPrimaryKeys} lets a config's `isId: "uuid"` win over the schema, so
|
|
47
|
+
* its `isUUID` is a claim rather than a fact — right for deriving addresses,
|
|
48
|
+
* wrong for refusing a query. Here the Drizzle column type decides, because it
|
|
49
|
+
* is what Postgres will enforce: a `uuid` column meets `/c/products/new` with
|
|
50
|
+
* `22P02`, which aborts the surrounding transaction and turns every later
|
|
51
|
+
* statement into an unrelated-looking `25P02`.
|
|
52
|
+
*/
|
|
53
|
+
export function idCanAddressTable(
|
|
54
|
+
id: string | number,
|
|
55
|
+
table: PgTable,
|
|
56
|
+
idInfoArray: PrimaryKeyInfo[]
|
|
57
|
+
): boolean {
|
|
58
|
+
const columnBacked = idInfoArray.map(info => {
|
|
59
|
+
const col = table[info.fieldName as keyof typeof table] as AnyPgColumn | undefined;
|
|
60
|
+
const meta = col ? getColumnMeta(col) : undefined;
|
|
61
|
+
// No column to ask (a key the schema does not carry) leaves the id
|
|
62
|
+
// addressable: refusing on a guess would 404 rows that do exist.
|
|
63
|
+
if (!meta?.columnType) return info;
|
|
64
|
+
return { ...info,
|
|
65
|
+
isUUID: meta.columnType === "PgUUID" };
|
|
66
|
+
});
|
|
67
|
+
return isAddressableId(id, columnBacked);
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
export function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig {
|
|
44
71
|
const collection = registry.getCollectionByPath(collectionPath);
|
|
45
72
|
if (!collection) {
|
|
@@ -149,6 +176,45 @@ export function requirePrimaryKeys(collection: CollectionConfig, registry: Postg
|
|
|
149
176
|
return keys;
|
|
150
177
|
}
|
|
151
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The column on the *source* table that a `hasOne`/`hasMany` link points at.
|
|
181
|
+
*
|
|
182
|
+
* `sourceKey` is authored when the two sides join on a natural key — an
|
|
183
|
+
* external identity id, a SKU — and left off when they join on the row id,
|
|
184
|
+
* which is the overwhelming majority. That makes `undefined` the only optional
|
|
185
|
+
* field on a resolved relation, so it gets exactly one reader: this function.
|
|
186
|
+
* Every consumer that needs the column asks here, and none of them re-derives
|
|
187
|
+
* "or else the primary key" for itself. That is the whole point — the fallback
|
|
188
|
+
* chains this codebase removed from relation resolution were dangerous because
|
|
189
|
+
* they were *duplicated* and could disagree, not because they existed.
|
|
190
|
+
*/
|
|
191
|
+
export function sourceKeyField(
|
|
192
|
+
relation: ResolvedHasOne | ResolvedHasMany,
|
|
193
|
+
sourceCollection: CollectionConfig,
|
|
194
|
+
registry: PostgresCollectionRegistry
|
|
195
|
+
): string {
|
|
196
|
+
if (relation.sourceKey) return relation.sourceKey;
|
|
197
|
+
return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether this link joins on something other than the source's primary key.
|
|
202
|
+
*
|
|
203
|
+
* Callers that hold a parent *id* — which is most of them, since an id is what
|
|
204
|
+
* a URL carries — must translate it to the source key's value before it can be
|
|
205
|
+
* compared with the target's foreign key. Those that hold the parent *row*, or
|
|
206
|
+
* that build a correlated subquery over the source table, can read the column
|
|
207
|
+
* directly and skip the lookup.
|
|
208
|
+
*/
|
|
209
|
+
export function joinsOnNaturalKey(
|
|
210
|
+
relation: ResolvedHasOne | ResolvedHasMany,
|
|
211
|
+
sourceCollection: CollectionConfig,
|
|
212
|
+
registry: PostgresCollectionRegistry
|
|
213
|
+
): boolean {
|
|
214
|
+
if (!relation.sourceKey) return false;
|
|
215
|
+
return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
|
|
216
|
+
}
|
|
217
|
+
|
|
152
218
|
/**
|
|
153
219
|
* Collections whose key the *browser* cannot resolve, and what it will do
|
|
154
220
|
* instead.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection-string rewrites for the tools Rebase shells out to.
|
|
3
|
+
*
|
|
4
|
+
* Pure and dependency-free: `pg-tools` builds argument vectors without a live
|
|
5
|
+
* server, and these have to be usable from there.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `sslmode` values libpq accepts. `no-verify` is conspicuously not one.
|
|
10
|
+
*/
|
|
11
|
+
const LIBPQ_SSLMODES = new Set(["disable", "allow", "prefer", "require", "verify-ca", "verify-full"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Make a connection string safe to hand to a libpq program.
|
|
15
|
+
*
|
|
16
|
+
* `sslmode=no-verify` is a node-postgres convention — encrypt, but do not check
|
|
17
|
+
* the certificate — and libpq does not have it. It does not degrade or warn:
|
|
18
|
+
*
|
|
19
|
+
* psql: error: invalid sslmode value: "no-verify"
|
|
20
|
+
*
|
|
21
|
+
* which means a `DATABASE_URL` that works perfectly for the app makes `psql`,
|
|
22
|
+
* `pg_dump`, `pg_restore` and Atlas all refuse to start, with an error that
|
|
23
|
+
* points at the value rather than at the convention it comes from. It has cost
|
|
24
|
+
* this project time more than once.
|
|
25
|
+
*
|
|
26
|
+
* `require` is the honest translation: libpq's `require` encrypts and does not
|
|
27
|
+
* verify the certificate either, which is exactly what `no-verify` asks for.
|
|
28
|
+
* Nothing is relaxed by the rewrite — `verify-ca` and `verify-full` are left
|
|
29
|
+
* alone, so a connection string that asked for verification still gets it.
|
|
30
|
+
*
|
|
31
|
+
* Only `sslmode` is touched, and only when it is a value libpq would reject.
|
|
32
|
+
* Anything unparseable is returned as given: a connection that works
|
|
33
|
+
* unverified beats one this corrupted.
|
|
34
|
+
*/
|
|
35
|
+
export function forLibpq(connectionString: string): string {
|
|
36
|
+
let url: URL;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(connectionString);
|
|
39
|
+
} catch {
|
|
40
|
+
// Key/value DSNs and anything else we cannot parse. A `sslmode` in one
|
|
41
|
+
// of those is space-separated rather than a query parameter, and
|
|
42
|
+
// rewriting it with a regex is how a password containing "sslmode="
|
|
43
|
+
// gets mangled.
|
|
44
|
+
return connectionString;
|
|
45
|
+
}
|
|
46
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return connectionString;
|
|
47
|
+
|
|
48
|
+
const sslmode = url.searchParams.get("sslmode");
|
|
49
|
+
if (sslmode === null || LIBPQ_SSLMODES.has(sslmode)) return connectionString;
|
|
50
|
+
|
|
51
|
+
url.searchParams.set("sslmode", "require");
|
|
52
|
+
// Re-serialize by hand for the reason `pinSearchPath` does: URLSearchParams
|
|
53
|
+
// writes a space as `+`, which node-postgres decodes and libpq does not.
|
|
54
|
+
url.search = Array.from(url.searchParams.entries())
|
|
55
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
56
|
+
.join("&");
|
|
57
|
+
return url.toString();
|
|
58
|
+
}
|
|
@@ -175,10 +175,11 @@ export class DrizzleConditionBuilder {
|
|
|
175
175
|
static buildRelationScopeCondition(
|
|
176
176
|
relation: ResolvedRelation,
|
|
177
177
|
/**
|
|
178
|
-
* Lazy:
|
|
179
|
-
*
|
|
180
|
-
* the parent's *id* alone, and
|
|
181
|
-
* a child listing fail on a
|
|
178
|
+
* Lazy: `via`, `belongsTo`, and a foreign key that points at a
|
|
179
|
+
* `sourceKey` need the parent's own table. A junction and a plain
|
|
180
|
+
* foreign key are expressible from the parent's *id* alone, and
|
|
181
|
+
* requiring the table for them would make a child listing fail on a
|
|
182
|
+
* parent whose table isn't registered.
|
|
182
183
|
*/
|
|
183
184
|
parent: () => { table: PgTable<any>; idColumn: AnyPgColumn },
|
|
184
185
|
parentId: string | number,
|
|
@@ -239,7 +240,16 @@ export class DrizzleConditionBuilder {
|
|
|
239
240
|
`relation '${relation.relationName}'.`
|
|
240
241
|
);
|
|
241
242
|
}
|
|
242
|
-
return eq(fkColumn, parentId);
|
|
243
|
+
if (!relation.sourceKey) return eq(fkColumn, parentId);
|
|
244
|
+
|
|
245
|
+
// A link on a natural key: the foreign key holds a column of the
|
|
246
|
+
// parent row, not its id, so the parent has to be read. As a
|
|
247
|
+
// subquery rather than a prior SELECT, for the same reason
|
|
248
|
+
// `belongsTo` below is — one statement sees one snapshot, and a
|
|
249
|
+
// scope condition that read the key separately could be built
|
|
250
|
+
// from a value the very next statement no longer agrees with.
|
|
251
|
+
const { table, idColumn } = parent();
|
|
252
|
+
return sql`${fkColumn} = (SELECT ${sql.identifier(relation.sourceKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
|
|
243
253
|
}
|
|
244
254
|
|
|
245
255
|
case "belongsTo": {
|
|
@@ -385,7 +395,22 @@ export class DrizzleConditionBuilder {
|
|
|
385
395
|
// inverse, so reversing it into a filter is a different problem
|
|
386
396
|
// from the two shapes below rather than a third case of them.
|
|
387
397
|
if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
|
|
388
|
-
|
|
398
|
+
// The `EXISTS` correlates the target's foreign key with a column
|
|
399
|
+
// on *this* table, and that is the primary key only when the
|
|
400
|
+
// link joins on it. A `sourceKey` names a different one, and
|
|
401
|
+
// correlating on the id anyway silently matches nothing —
|
|
402
|
+
// "filter by this relation" would quietly return zero rows.
|
|
403
|
+
const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey
|
|
404
|
+
? columnAt(relation.sourceKey)
|
|
405
|
+
: sourceIdColumn;
|
|
406
|
+
if (!correlationColumn) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`\`sourceKey: "${(relation as ResolvedForeignKeyOnTarget).sourceKey}"\` on relation ` +
|
|
409
|
+
`'${relation.relationName}' is not a column on '${collectionPath}', so a filter on ` +
|
|
410
|
+
"that relation has nothing to correlate against."
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
return { kind: "relation", relation, registry, sourceIdColumn: correlationColumn };
|
|
389
414
|
}
|
|
390
415
|
}
|
|
391
416
|
|