@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.
Files changed (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. 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
- logger.info(
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
  );