@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0
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/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1720 -946
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +24 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +139 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +124 -11
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +299 -168
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +129 -25
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +366 -30
- package/src/schema/generate-drizzle-schema-logic.ts +146 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +277 -10
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +14 -7
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +243 -22
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +3 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +347 -86
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
- package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
- package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-CU6WZGYV.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { and, eq, inArray, notInArray } from "drizzle-orm";
|
|
2
|
+
import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
|
+
import { CollectionConfig, ResolvedManyToMany, ResolvedRelation, ResolvedVia } from "@rebasepro/types";
|
|
4
|
+
import { hasForeignKeyOnTarget, isManyToMany } from "@rebasepro/types";
|
|
5
|
+
import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn } from "@rebasepro/common";
|
|
6
|
+
import { ApiError, logger } from "@rebasepro/server";
|
|
7
|
+
import { DrizzleClient } from "../interfaces";
|
|
8
|
+
import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
|
|
9
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
10
|
+
import {
|
|
11
|
+
getTableForCollection,
|
|
12
|
+
relationMisconfigured,
|
|
13
|
+
requirePrimaryKeys,
|
|
14
|
+
parseIdValues,
|
|
15
|
+
sourceKeyField
|
|
16
|
+
} from "./collection-helpers";
|
|
17
|
+
import type { NestedPathHop } from "./nested-path";
|
|
18
|
+
import { RelationService } from "./RelationService";
|
|
19
|
+
import {
|
|
20
|
+
applyJunctionMembership,
|
|
21
|
+
bindJoinPathJunction,
|
|
22
|
+
bindThroughJunction,
|
|
23
|
+
removeJunctionLink
|
|
24
|
+
} from "./junction-writes";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The ids in a to-many relation write, whatever shape the caller sent.
|
|
28
|
+
*
|
|
29
|
+
* A membership list is written as either the related rows (`[{ id: 1 }]`, what
|
|
30
|
+
* the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
|
|
31
|
+
* what anyone writing the API by hand sends). Only the first was read, via a
|
|
32
|
+
* blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
|
|
33
|
+
* on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
|
|
34
|
+
* and on a string-keyed one it did not surface at all — `String(undefined)`
|
|
35
|
+
* wrote a junction row pointing at the literal `"undefined"`, which no read
|
|
36
|
+
* would ever match. Both shapes are accepted here, in one place, because both
|
|
37
|
+
* call sites had the same assumption.
|
|
38
|
+
*
|
|
39
|
+
* An element that carries no key is refused rather than skipped: dropping it
|
|
40
|
+
* would silently write a shorter membership list than the caller asked for.
|
|
41
|
+
*/
|
|
42
|
+
function relationTargetIds(value: unknown, relationName: string, collectionSlug: string): (string | number)[] {
|
|
43
|
+
if (!Array.isArray(value)) return [];
|
|
44
|
+
|
|
45
|
+
return value.map((element, index) => {
|
|
46
|
+
if (typeof element === "string" || typeof element === "number") return element;
|
|
47
|
+
if (element && typeof element === "object") {
|
|
48
|
+
const id = (element as { id?: unknown }).id;
|
|
49
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
50
|
+
}
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. ` +
|
|
53
|
+
"Pass either the related rows (`[{ id: … }]`) or their keys (`[1, 2]`), not " +
|
|
54
|
+
`${element === null ? "null" : typeof element}.`
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Writing relations: junction membership, foreign-key stamping, and the links a
|
|
61
|
+
* nested path creates or removes.
|
|
62
|
+
*
|
|
63
|
+
* Split from {@link RelationService}, which now only reads. The two had grown
|
|
64
|
+
* into one 1700-line class doing two unrelated jobs, and sharing one habit —
|
|
65
|
+
* warning about a relation it could not resolve and carrying on, which surfaces
|
|
66
|
+
* as an empty list on the read side and as a successful save on the write side.
|
|
67
|
+
* Separating them is what made that visible as one class of defect rather than
|
|
68
|
+
* eighteen scattered log lines.
|
|
69
|
+
*
|
|
70
|
+
* The reads it still needs — the source key a link joins on — it asks
|
|
71
|
+
* {@link RelationService} for rather than reimplementing.
|
|
72
|
+
*/
|
|
73
|
+
export class RelationWriteService {
|
|
74
|
+
private reads: RelationService;
|
|
75
|
+
|
|
76
|
+
constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
|
|
77
|
+
this.reads = new RelationService(db, registry);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
83
|
+
* row itself alone.
|
|
84
|
+
*
|
|
85
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
86
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
87
|
+
* post that uses it. It used to do exactly that — resolve the path to the
|
|
88
|
+
* `tags` table and delete by primary key.
|
|
89
|
+
*/
|
|
90
|
+
async unlinkRelatedEntity(
|
|
91
|
+
tx: DrizzleClient,
|
|
92
|
+
hop: NestedPathHop,
|
|
93
|
+
targetId: string | number
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (!isManyToMany(hop.relation)) {
|
|
96
|
+
throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const binding = bindThroughJunction(
|
|
100
|
+
this.registry,
|
|
101
|
+
hop.relation.through,
|
|
102
|
+
`${hop.parentCollection.slug}.${hop.relationKey}`
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await removeJunctionLink(
|
|
106
|
+
tx,
|
|
107
|
+
binding,
|
|
108
|
+
this.parsedId(hop.parentCollection, hop.parentId),
|
|
109
|
+
this.parsedId(hop.targetCollection, targetId),
|
|
110
|
+
{ parent: hop.parentCollection.slug, relation: hop.relationKey }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
/** A collection's id, parsed to the type its primary key column holds. */
|
|
116
|
+
private parsedId(collection: CollectionConfig, id: string | number): unknown {
|
|
117
|
+
const pks = requirePrimaryKeys(collection, this.registry);
|
|
118
|
+
return parseIdValues(id, pks)[pks[0].fieldName];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
/** The same, for the membership list a to-many write names. */
|
|
123
|
+
private parsedIds(collection: CollectionConfig, ids: Array<string | number>): unknown[] {
|
|
124
|
+
if (ids.length === 0) return [];
|
|
125
|
+
const pks = requirePrimaryKeys(collection, this.registry);
|
|
126
|
+
return ids.map(id => parseIdValues(id, pks)[pks[0].fieldName]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Update many-to-many and junction relations
|
|
132
|
+
*/
|
|
133
|
+
async updateRelationsUsingJoins<M extends Record<string, unknown>>(
|
|
134
|
+
tx: DrizzleClient,
|
|
135
|
+
collection: CollectionConfig,
|
|
136
|
+
id: string | number,
|
|
137
|
+
relationValues: Partial<M>
|
|
138
|
+
) {
|
|
139
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
140
|
+
|
|
141
|
+
for (const [key, value] of Object.entries(relationValues)) {
|
|
142
|
+
const relation = findRelation(resolvedRelations, key);
|
|
143
|
+
if (!relation || relation.cardinality !== "many") continue;
|
|
144
|
+
|
|
145
|
+
const targetEntityIds = relationTargetIds(value, key, collection.slug);
|
|
146
|
+
const targetCollection = relation.target();
|
|
147
|
+
|
|
148
|
+
const label = `${collection.slug}.${key}`;
|
|
149
|
+
|
|
150
|
+
if (relation.kind === "via") {
|
|
151
|
+
await applyJunctionMembership(
|
|
152
|
+
tx,
|
|
153
|
+
bindJoinPathJunction(
|
|
154
|
+
this.registry,
|
|
155
|
+
relation.joinPath,
|
|
156
|
+
getTableName(collection),
|
|
157
|
+
getTableName(targetCollection),
|
|
158
|
+
label
|
|
159
|
+
),
|
|
160
|
+
this.parsedId(collection, id),
|
|
161
|
+
this.parsedIds(targetCollection, targetEntityIds)
|
|
162
|
+
);
|
|
163
|
+
} else if (relation.kind === "manyToMany") {
|
|
164
|
+
await applyJunctionMembership(
|
|
165
|
+
tx,
|
|
166
|
+
bindThroughJunction(this.registry, relation.through, label),
|
|
167
|
+
this.parsedId(collection, id),
|
|
168
|
+
this.parsedIds(targetCollection, targetEntityIds)
|
|
169
|
+
);
|
|
170
|
+
} else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
171
|
+
// Handle one-to-many (inverse) by updating target FK to point to parent
|
|
172
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
173
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
174
|
+
const targetIdInfo = targetPks[0];
|
|
175
|
+
const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
176
|
+
// The wire name the target's table is keyed by, not the column.
|
|
177
|
+
const fkField = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
|
|
178
|
+
const fkCol = targetTable[fkField as keyof typeof targetTable] as AnyPgColumn;
|
|
179
|
+
|
|
180
|
+
if (!fkCol || !targetIdCol) {
|
|
181
|
+
throw relationMisconfigured(
|
|
182
|
+
label,
|
|
183
|
+
`the target table '${getTableName(targetCollection)}' has no ` +
|
|
184
|
+
`${fkCol ? `'${targetIdInfo.fieldName}' key column` : `'${relation.foreignKeyOnTarget}' foreign-key column`}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// What the children's foreign key must hold — the parent's id
|
|
189
|
+
// unless the link declares a `sourceKey`, and then the value of
|
|
190
|
+
// that column on this parent row.
|
|
191
|
+
const parentKeyValue = (await this.reads.resolveSourceKeys(collection, relation, [id], tx))
|
|
192
|
+
.keyByParentId.get(String(id));
|
|
193
|
+
|
|
194
|
+
if (parentKeyValue === undefined) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in ` +
|
|
197
|
+
`\`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is ` +
|
|
198
|
+
"nothing for the related rows to point at."
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Clear existing links not in the new set
|
|
203
|
+
if (targetEntityIds.length > 0) {
|
|
204
|
+
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
205
|
+
await tx
|
|
206
|
+
.update(targetTable)
|
|
207
|
+
.set({ [fkField]: null })
|
|
208
|
+
// `notInArray`, not a hand-built fragment: the sibling
|
|
209
|
+
// update three lines below already uses `inArray`, and
|
|
210
|
+
// the hand-built version called `sql.join` with no
|
|
211
|
+
// separator — the only such call in the workspace —
|
|
212
|
+
// which renders `NOT IN ($1$2$3)`. That is a syntax
|
|
213
|
+
// error, so writing a `hasMany` relation with two or
|
|
214
|
+
// more children aborted the whole save transaction.
|
|
215
|
+
.where(and(eq(fkCol, parentKeyValue), notInArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[])));
|
|
216
|
+
|
|
217
|
+
// Set FK for the provided targets
|
|
218
|
+
await tx
|
|
219
|
+
.update(targetTable)
|
|
220
|
+
.set({ [fkField]: parentKeyValue })
|
|
221
|
+
.where(inArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[]));
|
|
222
|
+
} else {
|
|
223
|
+
// If empty array provided, clear all existing links for this parent
|
|
224
|
+
await tx
|
|
225
|
+
.update(targetTable)
|
|
226
|
+
.set({ [fkField]: null })
|
|
227
|
+
.where(eq(fkCol, parentKeyValue));
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
// Every to-many kind in the union is handled above — TypeScript
|
|
231
|
+
// narrows `relation` to `never` here — so reaching this means a
|
|
232
|
+
// relation resolved to something no writer knows. It used to be
|
|
233
|
+
// skipped with a warning, which told the caller their membership
|
|
234
|
+
// had been stored when nothing had happened.
|
|
235
|
+
throw relationMisconfigured(
|
|
236
|
+
label,
|
|
237
|
+
"it is a to-many relation with no way to write links — a `manyToMany` " +
|
|
238
|
+
"needs `through`, a `hasMany` needs `foreignKeyOnTarget`"
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Update inverse relations (where FK is on the target table)
|
|
247
|
+
*/
|
|
248
|
+
async updateInverseRelations(
|
|
249
|
+
tx: DrizzleClient,
|
|
250
|
+
sourceCollection: CollectionConfig,
|
|
251
|
+
sourceEntityId: string | number,
|
|
252
|
+
inverseRelationUpdates: Array<{
|
|
253
|
+
relationKey: string;
|
|
254
|
+
relation: ResolvedRelation;
|
|
255
|
+
newValue: unknown;
|
|
256
|
+
}>
|
|
257
|
+
) {
|
|
258
|
+
for (const update of inverseRelationUpdates) {
|
|
259
|
+
const { relation, newValue } = update;
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const targetCollection = relation.target();
|
|
263
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
264
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
265
|
+
const targetIdInfo = targetPks[0];
|
|
266
|
+
const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
|
|
267
|
+
const sourceIdInfo = sourcePks[0];
|
|
268
|
+
|
|
269
|
+
if (relation.kind === "via") {
|
|
270
|
+
await this.updateInverseJoinPathRelation(
|
|
271
|
+
tx,
|
|
272
|
+
sourceCollection,
|
|
273
|
+
sourceEntityId,
|
|
274
|
+
targetCollection,
|
|
275
|
+
relation,
|
|
276
|
+
newValue
|
|
277
|
+
);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// A many-to-many names its own junction. This used to walk the
|
|
282
|
+
// *target's* relations looking for an owning side whose
|
|
283
|
+
// `relationName` matched `inverseRelationName`, and swap its
|
|
284
|
+
// source/target columns — a search that silently did nothing
|
|
285
|
+
// when the far side was declared differently than expected.
|
|
286
|
+
if (isManyToMany(relation)) {
|
|
287
|
+
await this.updateManyToManyInverseRelation(
|
|
288
|
+
tx,
|
|
289
|
+
sourceCollection,
|
|
290
|
+
sourceEntityId,
|
|
291
|
+
targetCollection,
|
|
292
|
+
relation,
|
|
293
|
+
newValue,
|
|
294
|
+
{
|
|
295
|
+
table: relation.through.table,
|
|
296
|
+
sourceColumn: relation.through.sourceColumn,
|
|
297
|
+
targetColumn: relation.through.targetColumn
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// What is left names the parent with a column on the target.
|
|
304
|
+
const label = `${sourceCollection.slug}.${relation.relationName}`;
|
|
305
|
+
if (!hasForeignKeyOnTarget(relation)) {
|
|
306
|
+
throw relationMisconfigured(
|
|
307
|
+
label,
|
|
308
|
+
`a '${relation.kind}' relation names no column on the target to write the link into`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The wire name the target's table is keyed by, not the column.
|
|
313
|
+
const fkField = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget!);
|
|
314
|
+
const foreignKeyColumn = targetTable[fkField as keyof typeof targetTable] as AnyPgColumn;
|
|
315
|
+
if (!foreignKeyColumn) {
|
|
316
|
+
throw relationMisconfigured(
|
|
317
|
+
label,
|
|
318
|
+
`'${relation.foreignKeyOnTarget}' is not a column on the target table ` +
|
|
319
|
+
`'${getTableName(targetCollection)}'`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The value the target's foreign key holds: this row's id, or
|
|
324
|
+
// the column named by `sourceKey` when the link joins on one.
|
|
325
|
+
const sourceKeyValue = (await this.reads.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx))
|
|
326
|
+
.keyByParentId.get(String(sourceEntityId));
|
|
327
|
+
|
|
328
|
+
if (sourceKeyValue === undefined) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row ` +
|
|
331
|
+
`'${sourceEntityId}' has no value in \`sourceKey: ` +
|
|
332
|
+
`"${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing ` +
|
|
333
|
+
"for the related row to point at."
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (newValue === null || newValue === undefined) {
|
|
338
|
+
await tx
|
|
339
|
+
.update(targetTable)
|
|
340
|
+
.set({ [fkField]: null })
|
|
341
|
+
.where(eq(foreignKeyColumn, sourceKeyValue));
|
|
342
|
+
} else {
|
|
343
|
+
const parsedNewTargetIdObj = parseIdValues(newValue as string | number, targetPks);
|
|
344
|
+
const parsedNewTargetId = parsedNewTargetIdObj[targetIdInfo.fieldName];
|
|
345
|
+
const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
346
|
+
|
|
347
|
+
// First, clear any existing FK that points to this source row
|
|
348
|
+
await tx
|
|
349
|
+
.update(targetTable)
|
|
350
|
+
.set({ [fkField]: null })
|
|
351
|
+
.where(eq(foreignKeyColumn, sourceKeyValue));
|
|
352
|
+
|
|
353
|
+
// Then, update the new target row to point to this source row
|
|
354
|
+
await tx
|
|
355
|
+
.update(targetTable)
|
|
356
|
+
.set({ [fkField]: sourceKeyValue })
|
|
357
|
+
.where(eq(targetIdField, parsedNewTargetId));
|
|
358
|
+
}
|
|
359
|
+
} catch (e) {
|
|
360
|
+
// This catch is here so that one relation whose columns cannot
|
|
361
|
+
// be resolved does not abort the others. A refusal is not a
|
|
362
|
+
// misconfiguration: swallowing WRITE_DENIED here reported the
|
|
363
|
+
// save as done while the database had rejected the write, which
|
|
364
|
+
// is the whole defect this path was just fixed for. `name` as
|
|
365
|
+
// well as `instanceof`, because a skewed install can hold two
|
|
366
|
+
// copies of the server package.
|
|
367
|
+
if (e instanceof ApiError || (e as Error)?.name === "ApiError") throw e;
|
|
368
|
+
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Handle inverse relations with joinPath
|
|
376
|
+
*/
|
|
377
|
+
private async updateInverseJoinPathRelation(
|
|
378
|
+
tx: DrizzleClient,
|
|
379
|
+
sourceCollection: CollectionConfig,
|
|
380
|
+
sourceEntityId: string | number,
|
|
381
|
+
targetCollection: CollectionConfig,
|
|
382
|
+
relation: ResolvedVia,
|
|
383
|
+
newValue: unknown
|
|
384
|
+
) {
|
|
385
|
+
const sourceTableName = getTableName(sourceCollection);
|
|
386
|
+
const targetTableName = getTableName(targetCollection);
|
|
387
|
+
|
|
388
|
+
// Only a path with exactly one table between the two ends holds links to
|
|
389
|
+
// write. Anything else is a read-only reach, and skipping it is a
|
|
390
|
+
// statement about the relation's shape rather than a failure to resolve
|
|
391
|
+
// one — which is why this is a `return` and the binder below throws.
|
|
392
|
+
const intermediateTables = relation.joinPath
|
|
393
|
+
.map(step => step.table)
|
|
394
|
+
.filter(table => table !== sourceTableName && table !== targetTableName);
|
|
395
|
+
|
|
396
|
+
if (intermediateTables.length !== 1 || relation.cardinality !== "many") return;
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const binding = bindJoinPathJunction(
|
|
400
|
+
this.registry,
|
|
401
|
+
relation.joinPath,
|
|
402
|
+
sourceTableName,
|
|
403
|
+
targetTableName,
|
|
404
|
+
`${sourceCollection.slug}.${relation.relationName}`
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// The membership this write asks for. A single value is the
|
|
408
|
+
// one-to-one case and reads as a set of one.
|
|
409
|
+
const targetIds = Array.isArray(newValue)
|
|
410
|
+
? relationTargetIds(newValue, relation.relationName, sourceCollection.slug)
|
|
411
|
+
: newValue === null || newValue === undefined
|
|
412
|
+
? []
|
|
413
|
+
: relationTargetIds([newValue], relation.relationName, sourceCollection.slug);
|
|
414
|
+
|
|
415
|
+
await applyJunctionMembership(
|
|
416
|
+
tx,
|
|
417
|
+
binding,
|
|
418
|
+
this.parsedId(sourceCollection, sourceEntityId),
|
|
419
|
+
this.parsedIds(targetCollection, targetIds)
|
|
420
|
+
);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error: error });
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Handle many-to-many inverse relation updates using junction tables
|
|
430
|
+
*/
|
|
431
|
+
private async updateManyToManyInverseRelation(
|
|
432
|
+
tx: DrizzleClient,
|
|
433
|
+
sourceCollection: CollectionConfig,
|
|
434
|
+
sourceEntityId: string | number,
|
|
435
|
+
targetCollection: CollectionConfig,
|
|
436
|
+
relation: ResolvedRelation,
|
|
437
|
+
newValue: unknown,
|
|
438
|
+
junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
|
|
439
|
+
) {
|
|
440
|
+
try {
|
|
441
|
+
const targetIds = Array.isArray(newValue)
|
|
442
|
+
? relationTargetIds(newValue, relation.relationName, sourceCollection.slug)
|
|
443
|
+
: [];
|
|
444
|
+
|
|
445
|
+
await applyJunctionMembership(
|
|
446
|
+
tx,
|
|
447
|
+
bindThroughJunction(
|
|
448
|
+
this.registry,
|
|
449
|
+
junctionInfo,
|
|
450
|
+
`${sourceCollection.slug}.${relation.relationName}`
|
|
451
|
+
),
|
|
452
|
+
this.parsedId(sourceCollection, sourceEntityId),
|
|
453
|
+
this.parsedIds(targetCollection, targetIds)
|
|
454
|
+
);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error: error });
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Update one-to-one relations that use joinPath
|
|
464
|
+
*/
|
|
465
|
+
async updateJoinPathOneToOneRelations(
|
|
466
|
+
tx: DrizzleClient,
|
|
467
|
+
parentCollection: CollectionConfig,
|
|
468
|
+
parentId: string | number,
|
|
469
|
+
updates: Array<{
|
|
470
|
+
relationKey: string;
|
|
471
|
+
// Only a `via` relation has a join path to write through, and the
|
|
472
|
+
// caller only collects those.
|
|
473
|
+
relation: ResolvedVia;
|
|
474
|
+
newTargetId: string | number | null;
|
|
475
|
+
}>
|
|
476
|
+
) {
|
|
477
|
+
for (const upd of updates) {
|
|
478
|
+
const { relation, newTargetId } = upd;
|
|
479
|
+
const targetCollection = relation.target();
|
|
480
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
481
|
+
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
482
|
+
const targetIdInfo = targetPks[0];
|
|
483
|
+
const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
|
|
484
|
+
|
|
485
|
+
// Determine mapping of columns
|
|
486
|
+
//
|
|
487
|
+
// A join path is authored in SQL terms — `posts.author_id`,
|
|
488
|
+
// `user_profiles.user_id` — because that is what a join is written
|
|
489
|
+
// in. Everything below indexes a Drizzle table or builds a `set`
|
|
490
|
+
// payload with these, and both are keyed by the wire name, so the
|
|
491
|
+
// two are translated here rather than at each of the six uses.
|
|
492
|
+
const { targetFKColName: targetFKColumn, parentSourceColName: parentSourceColumn } =
|
|
493
|
+
this.resolveJoinPathWriteMapping(parentCollection, relation);
|
|
494
|
+
const targetFKColName = fieldKeyForColumn(targetCollection, targetFKColumn);
|
|
495
|
+
const parentSourceColName = fieldKeyForColumn(parentCollection, parentSourceColumn);
|
|
496
|
+
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
497
|
+
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
498
|
+
const parentIdInfo = parentPks[0];
|
|
499
|
+
const parsedParentIdObj = parseIdValues(parentId, parentPks);
|
|
500
|
+
const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
|
|
501
|
+
|
|
502
|
+
const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
|
|
503
|
+
const parentSourceCol = parentTable[parentSourceColName as keyof typeof parentTable] as AnyPgColumn;
|
|
504
|
+
const targetFKCol = targetTable[targetFKColName as keyof typeof targetTable] as AnyPgColumn;
|
|
505
|
+
|
|
506
|
+
const label = `${parentCollection.slug}.${relation.relationName}`;
|
|
507
|
+
if (!parentSourceCol) {
|
|
508
|
+
throw relationMisconfigured(
|
|
509
|
+
label,
|
|
510
|
+
`its joinPath reads '${parentSourceColName}', which is not a column on ` +
|
|
511
|
+
`'${getTableName(parentCollection)}'`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (!targetFKCol) {
|
|
515
|
+
throw relationMisconfigured(
|
|
516
|
+
label,
|
|
517
|
+
`its joinPath writes '${targetFKColName}', which is not a column on ` +
|
|
518
|
+
`'${getTableName(targetCollection)}'`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Fetch the parent row to obtain the value for parentSourceCol
|
|
523
|
+
const parentRows = await tx
|
|
524
|
+
.select({ val: parentSourceCol })
|
|
525
|
+
.from(parentTable)
|
|
526
|
+
.where(eq(parentIdCol, parsedParentId))
|
|
527
|
+
.limit(1);
|
|
528
|
+
if (parentRows.length === 0) continue;
|
|
529
|
+
const parentFKValue = parentRows[0].val as string | number | null;
|
|
530
|
+
|
|
531
|
+
if (newTargetId === null || newTargetId === undefined) {
|
|
532
|
+
// Clear any target rows currently linked to this parent via the FK
|
|
533
|
+
if (parentFKValue !== null && parentFKValue !== undefined) {
|
|
534
|
+
await tx.update(targetTable)
|
|
535
|
+
.set({ [targetFKColName]: null })
|
|
536
|
+
.where(eq(targetFKCol, String(parentFKValue)));
|
|
537
|
+
}
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Parse the new target id
|
|
542
|
+
const parsedTargetIdObj = parseIdValues(newTargetId, targetPks);
|
|
543
|
+
const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
|
|
544
|
+
|
|
545
|
+
// Ensure one-to-one by clearing existing link from any target rows with this parent FK
|
|
546
|
+
if (parentFKValue !== null && parentFKValue !== undefined) {
|
|
547
|
+
await tx.update(targetTable)
|
|
548
|
+
.set({ [targetFKColName]: null })
|
|
549
|
+
.where(eq(targetFKCol, String(parentFKValue)));
|
|
550
|
+
} else {
|
|
551
|
+
// Not a configuration problem: the row is simply missing the
|
|
552
|
+
// value the link joins on, so there is nothing for the target
|
|
553
|
+
// to point at. The `hasMany` writer already refuses this case
|
|
554
|
+
// in the same words rather than saving a row it did not write.
|
|
555
|
+
throw ApiError.badRequest(
|
|
556
|
+
`Cannot write relation '${label}': row '${parentId}' has no value in ` +
|
|
557
|
+
`'${parentSourceColName}', which is the column its joinPath joins on, so there ` +
|
|
558
|
+
"is nothing for the related row to point at.",
|
|
559
|
+
"RELATION_SOURCE_KEY_EMPTY"
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Now set the FK on the target row
|
|
564
|
+
await tx.update(targetTable)
|
|
565
|
+
.set({ [targetFKColName]: parentFKValue })
|
|
566
|
+
.where(eq(targetIdCol, parsedTargetId));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Resolve joinPath write mapping for one-to-one relations
|
|
573
|
+
*/
|
|
574
|
+
resolveJoinPathWriteMapping(
|
|
575
|
+
parentCollection: CollectionConfig,
|
|
576
|
+
relation: ResolvedVia
|
|
577
|
+
): { targetFKColName: string; parentSourceColName: string } {
|
|
578
|
+
if (!relation.joinPath || relation.joinPath.length === 0) {
|
|
579
|
+
throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
|
|
580
|
+
}
|
|
581
|
+
const parentTableName = getTableName(parentCollection);
|
|
582
|
+
const lastStep = relation.joinPath[relation.joinPath.length - 1];
|
|
583
|
+
const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
|
|
584
|
+
let currentFrom = lastStep.on.from;
|
|
585
|
+
|
|
586
|
+
let safety = 0;
|
|
587
|
+
while (safety++ < 10) {
|
|
588
|
+
const currentFromTable = DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0];
|
|
589
|
+
if (currentFromTable === parentTableName) {
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
const prevStep = relation.joinPath.find((s) => {
|
|
593
|
+
const to = Array.isArray(s.on.to) ? s.on.to[0] : s.on.to;
|
|
594
|
+
return to === currentFrom;
|
|
595
|
+
});
|
|
596
|
+
if (!prevStep) {
|
|
597
|
+
throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
|
|
598
|
+
}
|
|
599
|
+
currentFrom = prevStep.on.from;
|
|
600
|
+
}
|
|
601
|
+
const parentSourceColName = DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0];
|
|
602
|
+
return { targetFKColName,
|
|
603
|
+
parentSourceColName };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Handle junction table creation for many-to-many path-based saves
|
|
609
|
+
*/
|
|
610
|
+
async handleJunctionTableCreation(
|
|
611
|
+
tx: DrizzleClient,
|
|
612
|
+
newEntityId: string | number,
|
|
613
|
+
junctionTableInfo: {
|
|
614
|
+
parentCollection: CollectionConfig;
|
|
615
|
+
parentId: string | number;
|
|
616
|
+
// Only a junction-backed relation has a link to write, and the
|
|
617
|
+
// caller only builds this for one — stated here so the body needs
|
|
618
|
+
// no narrowing.
|
|
619
|
+
relation: ResolvedManyToMany;
|
|
620
|
+
relationKey: string;
|
|
621
|
+
}
|
|
622
|
+
) {
|
|
623
|
+
const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
|
|
624
|
+
const targetCollection = relation.target();
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
const binding = bindThroughJunction(
|
|
628
|
+
this.registry,
|
|
629
|
+
relation.through,
|
|
630
|
+
`${parentCollection.slug}.${relationKey}`
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
const parsedNewEntityId = this.parsedId(targetCollection, newEntityId);
|
|
634
|
+
|
|
635
|
+
// Create the junction table entry linking parent to the target row.
|
|
636
|
+
const junctionData = {
|
|
637
|
+
[binding.parentColumn.name]: parentId,
|
|
638
|
+
[binding.targetColumn.name]: parsedNewEntityId
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// Idempotent: a link either exists or it does not, so asking for one
|
|
642
|
+
// twice is not an error. This is what lets `PUT parent/id/child/childId`
|
|
643
|
+
// mean "this row belongs to this parent's set" — the only way to
|
|
644
|
+
// attach an *existing* row, which previously had none.
|
|
645
|
+
await tx.insert(binding.table).values(junctionData).onConflictDoNothing();
|
|
646
|
+
|
|
647
|
+
logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error: error });
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
@@ -160,7 +160,11 @@ export async function provisionTriggerCdc(
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
// Wiring detail. The single `Realtime source = …` line in the
|
|
164
|
+
// bootstrapper is the fact a developer acts on; how many triggers it
|
|
165
|
+
// took is for a diagnosis, and the skipped-table warning above still
|
|
166
|
+
// fires on its own.
|
|
167
|
+
logger.debug(
|
|
164
168
|
`📡 [CDC] Trigger-based change capture provisioned on ${installed.length} table(s)` +
|
|
165
169
|
(skipped.length ? ` (${skipped.length} skipped)` : "") + "."
|
|
166
170
|
);
|