@rebasepro/server-postgres 0.17.3 → 0.18.1

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 (204) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/BranchService-ByLfM_Tt.js +360 -0
  4. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  5. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  6. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  7. package/dist/arg-Dni7MzLB.js +87 -0
  8. package/dist/arg-Dni7MzLB.js.map +1 -0
  9. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  10. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  11. package/dist/backup-argv.d.ts +19 -0
  12. package/dist/backup-cli-oM9WQVlV.js +408 -0
  13. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  14. package/dist/backup-service-Br3tqwsy.js +715 -0
  15. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  16. package/dist/branch-argv.d.ts +28 -0
  17. package/dist/branch-prune.d.ts +77 -0
  18. package/dist/cli-collections-path.d.ts +31 -0
  19. package/dist/cli-errors-DxN4yzdH.js +657 -0
  20. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  21. package/dist/cli-errors.d.ts +123 -0
  22. package/dist/cli-flags.d.ts +101 -0
  23. package/dist/cli-helpers.d.ts +94 -10
  24. package/dist/cli-output-CNdMql-L.js +47 -0
  25. package/dist/cli-output-CNdMql-L.js.map +1 -0
  26. package/dist/cli.js +2020 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/collection-index-BHgT_TKd.js +276 -0
  29. package/dist/collection-index-BHgT_TKd.js.map +1 -0
  30. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  31. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  32. package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
  33. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  34. package/dist/databasePoolManager.d.ts +27 -0
  35. package/dist/doctor-D7HWmGzs.js +974 -0
  36. package/dist/doctor-D7HWmGzs.js.map +1 -0
  37. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
  38. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  39. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  40. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  41. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  42. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  43. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  44. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  45. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  46. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  47. package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
  48. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  49. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  50. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.es.js +7423 -1133
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  55. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  56. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  57. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  58. package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
  59. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  60. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
  61. package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
  62. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  63. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  64. package/dist/schema/column-type-drift.d.ts +79 -0
  65. package/dist/schema/doctor-cli.js +129 -0
  66. package/dist/schema/doctor-cli.js.map +1 -0
  67. package/dist/schema/doctor.d.ts +42 -0
  68. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  69. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  70. package/dist/schema/generate-drizzle-schema.js +24 -0
  71. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  72. package/dist/schema/generate-next-step.d.ts +30 -0
  73. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  74. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  75. package/dist/schema/generate-postgres-ddl.js +138 -0
  76. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  77. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  78. package/dist/schema/introspect-db-queries.d.ts +34 -0
  79. package/dist/schema/introspect-db.js +866 -0
  80. package/dist/schema/introspect-db.js.map +1 -0
  81. package/dist/security/rls-enforcement.d.ts +63 -2
  82. package/dist/services/BranchService.d.ts +86 -1
  83. package/dist/services/collection-helpers.d.ts +1 -1
  84. package/dist/services/realtimeService.d.ts +30 -2
  85. package/dist/services/row-pipeline.d.ts +12 -1
  86. package/dist/source-Br7L7GOI.js +410 -0
  87. package/dist/source-Br7L7GOI.js.map +1 -0
  88. package/dist/utils/pg-error-utils.d.ts +3 -1
  89. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  90. package/dist/vector-index-DkWxm2de.js +309 -0
  91. package/dist/vector-index-DkWxm2de.js.map +1 -0
  92. package/package.json +32 -27
  93. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  94. package/dist/collection-index-BRUg10H5.js +0 -5690
  95. package/dist/collection-index-BRUg10H5.js.map +0 -1
  96. package/dist/connection-GOKU3Hu5.js.map +0 -1
  97. package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
  98. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  99. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  100. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  101. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  102. package/dist/src-DiB5RP2Z.js +0 -541
  103. package/dist/src-DiB5RP2Z.js.map +0 -1
  104. package/dist/websocket-BZ4H5wUz.js +0 -8198
  105. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  106. package/src/PostgresAdapter.ts +0 -91
  107. package/src/PostgresBackendDriver.ts +0 -1857
  108. package/src/PostgresBootstrapper.ts +0 -1157
  109. package/src/auth/ensure-tables.ts +0 -896
  110. package/src/auth/schema-version.ts +0 -260
  111. package/src/auth/services.ts +0 -1610
  112. package/src/backup/backup-cli.ts +0 -520
  113. package/src/backup/backup-cron.ts +0 -212
  114. package/src/backup/backup-logic.ts +0 -93
  115. package/src/backup/backup-service.ts +0 -485
  116. package/src/backup/index.ts +0 -12
  117. package/src/backup/pg-tools.ts +0 -403
  118. package/src/backup/retention.ts +0 -86
  119. package/src/cli-errors.ts +0 -265
  120. package/src/cli-helpers.ts +0 -569
  121. package/src/cli-output.ts +0 -43
  122. package/src/cli.ts +0 -1339
  123. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  124. package/src/collections/buildRegistry.ts +0 -68
  125. package/src/collections/validate-relations.ts +0 -402
  126. package/src/connection.ts +0 -333
  127. package/src/data-transformer.ts +0 -845
  128. package/src/databasePoolManager.ts +0 -95
  129. package/src/history/HistoryService.ts +0 -254
  130. package/src/history/ensure-history-table.ts +0 -53
  131. package/src/index.ts +0 -19
  132. package/src/interfaces.ts +0 -60
  133. package/src/module-dir.ts +0 -7
  134. package/src/schema/atlas-argv.ts +0 -94
  135. package/src/schema/auth-schema.ts +0 -298
  136. package/src/schema/auth-users-columns.ts +0 -131
  137. package/src/schema/carved-out-migration.ts +0 -404
  138. package/src/schema/classify-change.ts +0 -436
  139. package/src/schema/collection-index.ts +0 -427
  140. package/src/schema/collections-schema-version.ts +0 -103
  141. package/src/schema/destructive-sql.ts +0 -94
  142. package/src/schema/doctor-cli.ts +0 -66
  143. package/src/schema/doctor-policy-checks.ts +0 -105
  144. package/src/schema/doctor.ts +0 -837
  145. package/src/schema/drizzle-ddl.ts +0 -33
  146. package/src/schema/dynamic-tables.test.ts +0 -302
  147. package/src/schema/dynamic-tables.ts +0 -293
  148. package/src/schema/ensure-collection-policies.ts +0 -198
  149. package/src/schema/ensure-collection-tables.test.ts +0 -518
  150. package/src/schema/ensure-collection-tables.ts +0 -1267
  151. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  152. package/src/schema/generate-drizzle-schema.ts +0 -143
  153. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  154. package/src/schema/generate-postgres-ddl.ts +0 -180
  155. package/src/schema/generate-schema-commit.ts +0 -267
  156. package/src/schema/generated-schema-staleness.ts +0 -284
  157. package/src/schema/introspect-db-constraints.ts +0 -385
  158. package/src/schema/introspect-db-inference.ts +0 -255
  159. package/src/schema/introspect-db-logic.ts +0 -1387
  160. package/src/schema/introspect-db-naming.ts +0 -15
  161. package/src/schema/introspect-db-project.ts +0 -78
  162. package/src/schema/introspect-db-queries.ts +0 -326
  163. package/src/schema/introspect-db-structure.ts +0 -670
  164. package/src/schema/introspect-db-types.ts +0 -56
  165. package/src/schema/introspect-db.ts +0 -257
  166. package/src/schema/introspect-runtime.test.ts +0 -260
  167. package/src/schema/introspect-runtime.ts +0 -327
  168. package/src/schema/non-sql-collections.test.ts +0 -131
  169. package/src/schema/rls-bootstrap-sql.ts +0 -288
  170. package/src/schema/search-column.ts +0 -643
  171. package/src/schema/test-schema.ts +0 -11
  172. package/src/schema/vector-index.ts +0 -491
  173. package/src/security/anonymous-grants.test.ts +0 -73
  174. package/src/security/policy-drift.test.ts +0 -435
  175. package/src/security/policy-drift.ts +0 -473
  176. package/src/security/rls-enforcement.ts +0 -505
  177. package/src/services/BranchService.ts +0 -326
  178. package/src/services/FetchService.ts +0 -2115
  179. package/src/services/PersistService.ts +0 -557
  180. package/src/services/RelationService.ts +0 -978
  181. package/src/services/RelationWriteService.ts +0 -653
  182. package/src/services/cdc/CdcListener.ts +0 -103
  183. package/src/services/cdc/junction-tables.ts +0 -91
  184. package/src/services/cdc/trigger-cdc.ts +0 -173
  185. package/src/services/channel-bus/ChannelBus.ts +0 -44
  186. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  187. package/src/services/channel-bus/index.ts +0 -114
  188. package/src/services/channel-history.ts +0 -424
  189. package/src/services/channel-presence.ts +0 -185
  190. package/src/services/collection-helpers.ts +0 -420
  191. package/src/services/dataService.ts +0 -256
  192. package/src/services/index.ts +0 -14
  193. package/src/services/junction-writes.ts +0 -295
  194. package/src/services/nested-path.ts +0 -145
  195. package/src/services/pg-notify-listener.ts +0 -151
  196. package/src/services/realtimeService.ts +0 -2545
  197. package/src/services/row-pipeline.ts +0 -240
  198. package/src/services/write-denial.ts +0 -55
  199. package/src/types.ts +0 -4
  200. package/src/utils/connection-string.ts +0 -58
  201. package/src/utils/drizzle-conditions.ts +0 -2743
  202. package/src/utils/pg-array-null-patch.ts +0 -42
  203. package/src/utils/pg-error-utils.ts +0 -391
  204. package/src/websocket.ts +0 -855
@@ -1,978 +0,0 @@
1
- import { and, eq, inArray, notInArray, or, sql, SQL, getTableName as drizzleTableName } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { DrizzleClient } from "../interfaces";
4
- import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
5
- import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn } from "@rebasepro/common";
6
- import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
7
- import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
8
- import {
9
- getCollectionByPath,
10
- getTableForCollection,
11
- relationMisconfigured,
12
- requirePrimaryKeys,
13
- parseIdValues,
14
- buildCompositeId,
15
- joinsOnNaturalKey,
16
- sourceKeyField,
17
- type PrimaryKeyInfo
18
- } from "./collection-helpers";
19
- import { parseDataFromServer } from "../data-transformer";
20
- import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
21
- import { ApiError, logger } from "@rebasepro/server";
22
- import type { NestedPathHop } from "./nested-path";
23
- import {
24
- applyJunctionMembership,
25
- bindJoinPathJunction,
26
- bindThroughJunction,
27
- removeJunctionLink
28
- } from "./junction-writes";
29
-
30
- /**
31
- * The ids in a to-many relation write, whatever shape the caller sent.
32
- *
33
- * A membership list is written as either the related rows (`[{ id: 1 }]`, what
34
- * the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
35
- * what anyone writing the API by hand sends). Only the first was read, via a
36
- * blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
37
- * on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
38
- * and on a string-keyed one it did not surface at all — `String(undefined)`
39
- * wrote a junction row pointing at the literal `"undefined"`, which no read
40
- * would ever match. Both shapes are accepted here, in one place, because both
41
- * call sites had the same assumption.
42
- *
43
- * An element that carries no key is refused rather than skipped: dropping it
44
- * would silently write a shorter membership list than the caller asked for.
45
- */
46
- function relationTargetIds(value: unknown, relationName: string, collectionSlug: string): (string | number)[] {
47
- if (!Array.isArray(value)) return [];
48
-
49
- return value.map((element, index) => {
50
- if (typeof element === "string" || typeof element === "number") return element;
51
- if (element && typeof element === "object") {
52
- const id = (element as { id?: unknown }).id;
53
- if (typeof id === "string" || typeof id === "number") return id;
54
- }
55
- throw new Error(
56
- `Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. ` +
57
- "Pass either the related rows (`[{ id: … }]`) or their keys (`[1, 2]`), not " +
58
- `${element === null ? "null" : typeof element}.`
59
- );
60
- });
61
- }
62
-
63
- /**
64
- * Typed wrapper for Drizzle dynamic query innerJoin.
65
- * Drizzle's `$dynamic()` queries lose the `innerJoin` method from
66
- * their static type, but it exists at runtime. This helper bridges
67
- * the gap with a single confined cast.
68
- */
69
- function applyDynamicJoin<T>(query: T, joinTable: PgTable, condition: SQL): T {
70
- return (query as unknown as { innerJoin(t: PgTable, c: SQL): T }).innerJoin(joinTable, condition) as T;
71
- }
72
-
73
- /**
74
- * Typed wrapper for DrizzleConditionBuilder.buildRelationQuery on dynamic queries.
75
- * The method returns a widened generic that doesn't reassign cleanly;
76
- * this helper confines the cast.
77
- */
78
- function applyDynamicRelationQuery<T>(
79
- query: T,
80
- ...args: Parameters<typeof DrizzleConditionBuilder.buildRelationQuery>
81
- ): T {
82
- return DrizzleConditionBuilder.buildRelationQuery(...args) as unknown as T;
83
- }
84
-
85
- /**
86
- * Service for handling all relation-related operations.
87
- * Handles fetching, updating, and managing row relations.
88
- */
89
- /**
90
- * A related record resolved by {@link RelationService}: the target row's
91
- * values plus the identity (`id`) and originating collection (`path`)
92
- * needed to build relation references. Internal to the postgres driver —
93
- * flattened to plain rows at the fetch-service boundary.
94
- */
95
- export interface RelatedRow<M extends Record<string, unknown> = Record<string, unknown>> {
96
- id: string | number;
97
- path: string;
98
- values: M;
99
- }
100
-
101
- export class RelationService {
102
- constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) { }
103
-
104
- /**
105
- * One target row, as the {@link RelatedRow} everything here returns.
106
- *
107
- * Eight sites built this by hand, which is how the address came to be the
108
- * target's first key column in all eight — one edit, eight places to miss.
109
- *
110
- * `resolveNested` is the one thing they did not agree on, and the
111
- * disagreement was invisible: the single-parent fetches pass `db` and
112
- * `registry` to `parseDataFromServer`, so the target's *own* relations get
113
- * resolved too, while the batch paths deliberately do not — a query per
114
- * target row is the N+1 the batching exists to avoid. Naming the parameter
115
- * makes that a decision rather than a difference between two call sites
116
- * nobody was comparing.
117
- */
118
- private async toRelatedRow<M extends Record<string, unknown>>(
119
- targetRow: Record<string, unknown>,
120
- targetCollection: CollectionConfig,
121
- targetPks: PrimaryKeyInfo[],
122
- options?: { resolveNested?: boolean }
123
- ): Promise<RelatedRow<M>> {
124
- const values = options?.resolveNested
125
- ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry)
126
- : await parseDataFromServer(targetRow, targetCollection);
127
-
128
- return {
129
- // The whole key: a composite target addressed by its first column
130
- // names every row that shares it.
131
- id: buildCompositeId(targetRow, targetPks),
132
- path: targetCollection.slug,
133
- values: values as M
134
- };
135
- }
136
-
137
- /**
138
- * A WHERE matching any of `parentIds`, by the whole key.
139
- *
140
- * A single key is an `IN (…)`. A composite one cannot be: matching
141
- * `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
142
- * share their first column each receive the other's relations. It becomes
143
- * an OR of ANDs — one exact address per parent — which Postgres indexes the
144
- * same way it would a multi-column key lookup.
145
- */
146
- private parentKeyCondition(
147
- parentTable: PgTable,
148
- parentPks: PrimaryKeyInfo[],
149
- parentIds: (string | number)[]
150
- ): SQL {
151
- const columnFor = (fieldName: string) => {
152
- const col = parentTable[fieldName as keyof typeof parentTable] as AnyPgColumn;
153
- if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
154
- return col;
155
- };
156
-
157
- if (parentPks.length === 1) {
158
- const values = parentIds.map(id => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
159
- return inArray(columnFor(parentPks[0].fieldName), values);
160
- }
161
-
162
- const perParent = parentIds.map(id => {
163
- const values = parseIdValues(id, parentPks);
164
- return and(...parentPks.map(pk => eq(columnFor(pk.fieldName), values[pk.fieldName])));
165
- });
166
- return or(...perParent) as SQL;
167
- }
168
-
169
- /**
170
- * Reject a relation that cannot express a composite-keyed parent.
171
- *
172
- * `localKey` and `foreignKeyOnTarget` are single column names: one column
173
- * cannot reference a two-column key, so such a relation has no correct
174
- * reading. Left alone it would silently match on the first key column and
175
- * hand a tenant's rows to its neighbour — say so instead.
176
- */
177
- private assertSingleKeyAddressable(
178
- parentCollection: CollectionConfig,
179
- parentPks: PrimaryKeyInfo[],
180
- via: string,
181
- relation?: ResolvedRelation
182
- ): void {
183
- // A `sourceKey` names the single column the link actually points at, so
184
- // the parent's key can be as wide as it likes — nothing here references
185
- // it. That is the one way out of this error, and the message below now
186
- // has to say so.
187
- if (relation && hasForeignKeyOnTarget(relation) && relation.sourceKey) return;
188
-
189
- if (parentPks.length > 1) {
190
- throw new Error(
191
- `Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but ` +
192
- `'${parentCollection.slug}' is keyed on ${parentPks.map(k => `'${k.fieldName}'`).join(" + ")}. ` +
193
- `One column cannot reference a composite key — give the relation a \`sourceKey\` naming ` +
194
- `a single unique column to point at, or express it with \`joinPath\`, whose ` +
195
- `\`on.from\`/\`on.to\` take every key column.`
196
- );
197
- }
198
- }
199
-
200
- /**
201
- * What the target's foreign key holds, for each of these parent rows.
202
- *
203
- * Ordinarily the parent's id, and then this is free. When the relation
204
- * declares a `sourceKey` the two are different values, and the mapping
205
- * between them lives in the source table — so it costs one SELECT, issued
206
- * once for the whole batch rather than per parent.
207
- *
208
- * Both directions come back because both are needed and deriving one from
209
- * the other by hand is how a batch loader ends up attributing a child to the
210
- * wrong parent: reads translate id → key to build the WHERE, and then
211
- * translate key → id to attribute each row that comes back.
212
- */
213
- /**
214
- * The value a related row's foreign key must hold to belong to this parent.
215
- *
216
- * `undefined` when the parent's source key is null — which is not an error
217
- * here, only in the callers that were about to write it. Exposed for
218
- * {@link PersistService}, which stamps this onto a child created under a
219
- * nested path and would otherwise write the id and lose the row.
220
- */
221
- async parentKeyValue(
222
- parentCollection: CollectionConfig,
223
- relation: ResolvedHasOne | ResolvedHasMany,
224
- parentId: string | number,
225
- db: DrizzleClient = this.db
226
- ): Promise<string | number | undefined> {
227
- const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
228
- return keyByParentId.get(String(parentId));
229
- }
230
-
231
- /**
232
- * Shared with {@link RelationWriteService}: a write needs the same source
233
- * key a read does, and resolving it twice is how the two would disagree.
234
- */
235
- async resolveSourceKeys(
236
- parentCollection: CollectionConfig,
237
- relation: ResolvedHasOne | ResolvedHasMany,
238
- parentIds: (string | number)[],
239
- // Writes pass their transaction: reading the source key on the pool
240
- // while the same transaction is holding an uncommitted change to it
241
- // would translate the id against a stale value.
242
- db: DrizzleClient = this.db
243
- ): Promise<{ keyByParentId: Map<string, string | number>; parentIdByKey: Map<string, string | number> }> {
244
- const keyByParentId = new Map<string, string | number>();
245
- const parentIdByKey = new Map<string, string | number>();
246
-
247
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
248
-
249
- if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
250
- // The ordinary case, and it must stay free of a round-trip: the
251
- // key IS the id. Parsed, not passed through — an id arrives as the
252
- // string from a URL, and comparing "7" against an integer column is
253
- // how this returns nothing at all.
254
- for (const id of parentIds) {
255
- const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
256
- keyByParentId.set(String(id), value);
257
- parentIdByKey.set(String(value), id);
258
- }
259
- return { keyByParentId, parentIdByKey };
260
- }
261
-
262
- const field = sourceKeyField(relation, parentCollection, this.registry);
263
- const parentTable = getTableForCollection(parentCollection, this.registry);
264
- const keyColumn = parentTable[field as keyof typeof parentTable] as AnyPgColumn;
265
- if (!keyColumn) {
266
- throw new Error(
267
- `\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not a column on ` +
268
- `'${parentCollection.slug}'. It names a column on the source table, not on the target.`
269
- );
270
- }
271
-
272
- const rows = await db
273
- .select()
274
- .from(parentTable)
275
- .where(this.parentKeyCondition(parentTable, parentPks, parentIds));
276
-
277
- for (const row of rows as Array<Record<string, unknown>>) {
278
- const keyValue = row[field] as string | number | null;
279
- if (keyValue === null || keyValue === undefined) continue;
280
- const parentId = buildCompositeId(row, parentPks);
281
- keyByParentId.set(String(parentId), keyValue);
282
- // A duplicate here means the source key is not unique, which makes
283
- // "which parent does this child belong to" unanswerable. Refuse
284
- // rather than pick the last one seen.
285
- const existing = parentIdByKey.get(String(keyValue));
286
- if (existing !== undefined && String(existing) !== String(parentId)) {
287
- throw new Error(
288
- `\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not unique on ` +
289
- `'${parentCollection.slug}': rows '${existing}' and '${parentId}' both hold ` +
290
- `'${keyValue}'. Add a unique constraint — a link that addresses more than one source ` +
291
- `row cannot say which one a related row belongs to.`
292
- );
293
- }
294
- parentIdByKey.set(String(keyValue), parentId);
295
- }
296
-
297
- return { keyByParentId, parentIdByKey };
298
- }
299
-
300
- /**
301
- * Fetch rows related to a parent row through a specific relation
302
- */
303
- async fetchRelatedEntities<M extends Record<string, unknown>>(
304
- parentCollectionPath: string,
305
- parentId: string | number,
306
- relationKey: string,
307
- options: {
308
- filter?: FilterValues<Extract<keyof M, string>>;
309
- orderBy?: string | OrderByTuple[];
310
- order?: "desc" | "asc";
311
- limit?: number;
312
- startAfter?: Record<string, unknown>;
313
- searchString?: string;
314
- databaseId?: string;
315
- } = {}
316
- ): Promise<RelatedRow<M>[]> {
317
- const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
318
- const resolvedRelations = resolveCollectionRelations(parentCollection);
319
- const relation = findRelation(resolvedRelations, relationKey);
320
-
321
- if (!relation) {
322
- const available = Object.keys(resolvedRelations).join(", ") || "(none)";
323
- throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
324
- }
325
-
326
- return this.fetchEntitiesUsingJoins<M>(parentCollection, parentId, relation, options);
327
- }
328
-
329
- /**
330
- * Fetch rows using join paths for complex relations
331
- */
332
- async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
333
- parentCollection: CollectionConfig,
334
- parentId: string | number,
335
- relation: ResolvedRelation,
336
- options: {
337
- filter?: FilterValues<Extract<keyof M, string>>;
338
- orderBy?: string | OrderByTuple[];
339
- order?: "desc" | "asc";
340
- limit?: number;
341
- startAfter?: Record<string, unknown>;
342
- searchString?: string;
343
- databaseId?: string;
344
- } = {}
345
- ): Promise<RelatedRow<M>[]> {
346
- const targetCollection = relation.target();
347
- const targetTable = getTableForCollection(targetCollection, this.registry);
348
- const idInfo = requirePrimaryKeys(targetCollection, this.registry);
349
- const idField = targetTable[idInfo[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
350
-
351
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
352
- const parentIdInfo = parentPks[0];
353
- const parsedParentIdObj = parseIdValues(parentId, parentPks);
354
- const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
355
- const parentTable = this.registry.getTable(getTableName(parentCollection));
356
- if (!parentTable) throw new Error("Parent table not found");
357
- const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
358
-
359
- // Handle join path relations
360
- if (relation.kind === "via") {
361
- let query = this.db.select().from(parentTable).$dynamic();
362
- let currentTable = parentTable;
363
-
364
- // Apply each join in the path
365
- for (const join of relation.joinPath) {
366
- const joinTable = this.registry.getTable(join.table);
367
- if (!joinTable) {
368
- throw new Error(`Join table not found: ${join.table}`);
369
- }
370
-
371
- const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
372
- const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
373
-
374
- const fromParts = fromColumn.split(".");
375
- const toParts = toColumn.split(".");
376
-
377
- const fromColName = fromParts[fromParts.length - 1];
378
- const toColName = toParts[toParts.length - 1];
379
-
380
- const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
381
- const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
382
-
383
- if (!fromCol || !toCol) {
384
- throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
385
- }
386
-
387
- query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
388
- currentTable = joinTable;
389
- }
390
-
391
- // Add where condition for the parent row
392
- const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
393
- query = query.where(eq(parentIdField, parsedParentId));
394
-
395
- if (options.limit) {
396
- query = query.limit(options.limit);
397
- }
398
-
399
- const results = await query;
400
- const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
401
-
402
- // Process results
403
- const rows: RelatedRow<M>[] = [];
404
- for (const row of results as Array<Record<string, unknown>>) {
405
- const targetRow = (row[targetTableName] as Record<string, unknown>) || row;
406
- rows.push(await this.toRelatedRow<M>(targetRow, targetCollection, idInfo, { resolveNested: true }));
407
- }
408
-
409
- return rows;
410
- }
411
-
412
- // Handle other relation types.
413
- //
414
- // The query builder compares the target's foreign key against a value,
415
- // and for a link on a natural key that value is not the id in the URL.
416
- // Resolved first, before anything is built, so a parent that reaches
417
- // nothing costs one statement rather than two.
418
- const matchValue = hasForeignKeyOnTarget(relation)
419
- ? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
420
- .keyByParentId.get(String(parentId))
421
- : parsedParentId;
422
-
423
- // A parent whose source key is null reaches nothing: NULL never equals
424
- // a foreign key. Say so with an empty list rather than an `= NULL`.
425
- if (matchValue === undefined) return [];
426
-
427
- let query = this.db.select().from(targetTable).$dynamic();
428
-
429
- // Build additional filter conditions
430
- const additionalFilters: SQL[] = [];
431
-
432
- // Handle search conditions if searchString is provided
433
- if (options.searchString) {
434
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
435
- options.searchString,
436
- targetCollection.properties,
437
- targetTable,
438
- targetCollection
439
- );
440
-
441
- if (searchConditions.length === 0) {
442
- // No searchable fields found, return empty results
443
- return [];
444
- }
445
-
446
- const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
447
- if (searchCombined) {
448
- additionalFilters.push(searchCombined);
449
- }
450
- }
451
-
452
- // Use unified relation query builder
453
- query = applyDynamicRelationQuery(
454
- query,
455
- query,
456
- relation,
457
- matchValue,
458
- targetTable,
459
- parentTable,
460
- parentIdCol,
461
- idField,
462
- this.registry,
463
- additionalFilters
464
- );
465
-
466
- if (options.limit) {
467
- query = query.limit(options.limit);
468
- }
469
-
470
- const results = await query;
471
-
472
- // Process results - ensure results is iterable
473
- if (!results || !Array.isArray(results)) {
474
- return [];
475
- }
476
-
477
- const rows: RelatedRow<M>[] = [];
478
- for (const row of results) {
479
- const targetRow = row[getTableName(targetCollection)] || row;
480
- rows.push(await this.toRelatedRow<M>(targetRow as Record<string, unknown>, targetCollection, idInfo, { resolveNested: true }));
481
- }
482
-
483
- return rows;
484
- }
485
-
486
- /**
487
- * Count related rows for a parent row
488
- */
489
- async countRelatedEntities<M extends Record<string, unknown>>(
490
- parentCollectionPath: string,
491
- parentId: string | number,
492
- relationKey: string,
493
- options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
494
- ): Promise<number> {
495
- const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
496
- const resolvedRelations = resolveCollectionRelations(parentCollection);
497
- const relation = findRelation(resolvedRelations, relationKey);
498
- if (!relation) {
499
- const available = Object.keys(resolvedRelations).join(", ") || "(none)";
500
- throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
501
- }
502
-
503
- return this.countRelatedRows(parentCollection, parentId, relation, []);
504
- }
505
-
506
- /**
507
- * Count the target rows a parent reaches through `relation`, narrowed by
508
- * `additionalFilters` (conditions on the target table).
509
- *
510
- * Shared by the public count and by {@link isRelated}, so "how many children
511
- * does this parent have" and "is this row one of them" are answered by the
512
- * same join — a membership test that reconstructed the join separately would
513
- * be free to disagree with the listing it is supposed to gate.
514
- */
515
- private async countRelatedRows(
516
- parentCollection: CollectionConfig,
517
- parentId: string | number,
518
- relation: ResolvedRelation,
519
- additionalFilters: SQL[]
520
- ): Promise<number> {
521
- const targetCollection = relation.target();
522
- const targetTable = getTableForCollection(targetCollection, this.registry);
523
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
524
- const targetIdInfo = targetPks[0];
525
- const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
526
-
527
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
528
- const parentIdInfo = parentPks[0];
529
- const parsedParentIdObj = parseIdValues(parentId, parentPks);
530
- const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
531
- const parentTable = this.registry.getTable(getTableName(parentCollection));
532
- if (!parentTable) throw new Error("Parent table not found");
533
- const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
534
-
535
- // Same translation the listing does, and it has to be: `isRelated`
536
- // gates writes on this count, so a count built from a different value
537
- // than the read would authorise rows the read never returned.
538
- const matchValue = hasForeignKeyOnTarget(relation)
539
- ? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
540
- .keyByParentId.get(String(parentId))
541
- : parsedParentId;
542
-
543
- if (matchValue === undefined) return 0;
544
-
545
- // Start count with distinct to avoid duplicates from junction tables
546
- let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
547
-
548
- // Use unified count query builder from DrizzleConditionBuilder
549
- query = DrizzleConditionBuilder.buildRelationCountQuery(
550
- query,
551
- relation,
552
- matchValue,
553
- targetTable,
554
- parentTable,
555
- parentIdCol,
556
- targetIdField,
557
- this.registry,
558
- additionalFilters
559
- );
560
-
561
- const result = await query;
562
- return Number(result[0]?.count || 0);
563
- }
564
-
565
- /**
566
- * Whether `targetId` is actually reachable from the parent named in `hop`.
567
- *
568
- * A nested address like `authors/1/posts/43` used to resolve to the target
569
- * collection and then match on the primary key alone, so the parent segment
570
- * decided nothing: the row came back, and was updated or deleted, whoever it
571
- * belonged to. Reads, updates and deletes now all gate on this.
572
- */
573
- async isRelated(hop: NestedPathHop, targetId: string | number): Promise<boolean> {
574
- const targetTable = getTableForCollection(hop.targetCollection, this.registry);
575
- const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
576
- const parsedTargetId = parseIdValues(targetId, targetPks);
577
-
578
- const identity: SQL[] = targetPks.map(pk => {
579
- const column = targetTable[pk.fieldName as keyof typeof targetTable] as AnyPgColumn;
580
- if (!column) {
581
- throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
582
- }
583
- return eq(column, parsedTargetId[pk.fieldName]);
584
- });
585
-
586
- return (await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity)) > 0;
587
- }
588
-
589
- /**
590
- * Batch fetch related rows for multiple parent rows to avoid N+1 queries
591
- */
592
- async batchFetchRelatedEntities(
593
- parentCollectionPath: string,
594
- parentIds: (string | number)[],
595
- _relationKey: string,
596
- relation: ResolvedRelation
597
- ): Promise<Map<string, RelatedRow<Record<string, unknown>>>> {
598
- if (parentIds.length === 0) return new Map();
599
-
600
- const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
601
- const targetCollection = relation.target();
602
- const targetTable = getTableForCollection(targetCollection, this.registry);
603
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
604
- const targetIdInfo = targetPks[0];
605
- const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
606
-
607
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
608
- const parentIdInfo = parentPks[0];
609
- const parentTable = this.registry.getTable(getTableName(parentCollection));
610
- if (!parentTable) throw new Error("Parent table not found");
611
- const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
612
-
613
- // Parse all parent IDs once
614
- const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
615
-
616
- // Handle join path relations with batching
617
- if (relation.kind === "via") {
618
- let query = this.db.select().from(parentTable).$dynamic();
619
- let currentTable = parentTable;
620
-
621
- // Apply each join in the path
622
- for (const join of relation.joinPath) {
623
- const joinTable = this.registry.getTable(join.table);
624
- if (!joinTable) {
625
- throw new Error(`Join table not found: ${join.table}`);
626
- }
627
-
628
- const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
629
- const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
630
-
631
- const fromParts = fromColumn.split(".");
632
- const toParts = toColumn.split(".");
633
-
634
- const fromColName = fromParts[fromParts.length - 1];
635
- const toColName = toParts[toParts.length - 1];
636
-
637
- const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
638
- const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
639
-
640
- if (!fromCol || !toCol) {
641
- throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
642
- }
643
-
644
- query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
645
- currentTable = joinTable;
646
- }
647
-
648
- // Match every parent at once, each by its whole key.
649
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
650
-
651
- const results = await query;
652
- const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
653
- const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
654
-
655
- // Group by the parent's address — the same token the caller looks
656
- // results up by, derived the same way on both sides.
657
- for (const row of results as Array<Record<string, unknown>>) {
658
- const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
659
- const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
660
-
661
- resultMap.set(
662
- buildCompositeId(parentRow, parentPks),
663
- await this.toRelatedRow(targetRow, targetCollection, targetPks)
664
- );
665
- }
666
-
667
- return resultMap;
668
- }
669
-
670
- // Handle owning relations with proper FK-based batching.
671
- // For owning relations, parent rows hold the FK (e.g. posts.author_id).
672
- // We need to:
673
- // 1. Fetch FK values from the parent table in a single query
674
- // 2. Query the target table with unique FK values
675
- // 3. Map results back to parent rows via their FK values
676
- if (relation.kind === "belongsTo") {
677
- this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
678
- // `localKey` is the column; the generated table is keyed by the wire
679
- // name, so `author_id` finds nothing where the key is `authorId`.
680
- const localKeyField = fieldKeyForColumn(parentCollection, relation.localKey);
681
- const localKeyCol = parentTable[localKeyField as keyof typeof parentTable] as AnyPgColumn;
682
- if (!localKeyCol) {
683
- throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
684
- }
685
-
686
- // Step 1: Fetch all FK values from parent table in ONE query
687
- const fkRows = await this.db
688
- .select({
689
- parentId: parentIdCol,
690
- fkValue: localKeyCol
691
- })
692
- .from(parentTable)
693
- .where(inArray(parentIdCol, parsedParentIds));
694
-
695
- // Build parentId → fkValue mapping and collect unique FK values
696
- const parentToFk = new Map<string, string | number>();
697
- const uniqueFkValues: (string | number)[] = [];
698
- const seenFks = new Set<string>();
699
-
700
- for (const row of fkRows as Array<{ parentId: string | number; fkValue: string | number | null }>) {
701
- if (row.fkValue == null) continue;
702
- parentToFk.set(String(row.parentId), row.fkValue);
703
- const fkStr = String(row.fkValue);
704
- if (!seenFks.has(fkStr)) {
705
- seenFks.add(fkStr);
706
- uniqueFkValues.push(row.fkValue);
707
- }
708
- }
709
-
710
- if (uniqueFkValues.length === 0) return new Map();
711
-
712
- // Step 2: Fetch all target rows in ONE query
713
- const targetResults = await this.db
714
- .select()
715
- .from(targetTable)
716
- .where(inArray(targetIdField, uniqueFkValues));
717
-
718
- // Index target rows by their ID
719
- const targetById = new Map<string, Record<string, unknown>>();
720
- for (const row of targetResults as Array<Record<string, unknown>>) {
721
- const tid = String(row[targetIdInfo.fieldName]);
722
- targetById.set(tid, row);
723
- }
724
-
725
- // Step 3: Map back to parent rows
726
- const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
727
- for (const [parentIdStr, fkValue] of parentToFk) {
728
- const targetRow = targetById.get(String(fkValue));
729
- if (targetRow) {
730
- resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
731
- }
732
- }
733
-
734
- return resultMap;
735
- }
736
-
737
- // Handle inverse relation types with batching. The parent is named by a
738
- // single FK column on the target, so a composite-keyed parent has no
739
- // correct reading here either.
740
- this.assertSingleKeyAddressable(
741
- parentCollection,
742
- parentPks,
743
- hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
744
- relation
745
- );
746
-
747
- // One lookup for the whole batch, both directions: the WHERE is built
748
- // from the source-key values, and each row that comes back carries one
749
- // of those values rather than a parent id.
750
- const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
751
- ? await this.resolveSourceKeys(parentCollection, relation, parentIds)
752
- : { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
753
-
754
- const matchValues = hasForeignKeyOnTarget(relation)
755
- ? [...keyByParentId.values()]
756
- : parsedParentIds;
757
- if (matchValues.length === 0) return new Map();
758
-
759
- let query = this.db.select().from(targetTable).$dynamic();
760
-
761
- // Build the relation query with ALL parent IDs
762
- query = applyDynamicRelationQuery(
763
- query,
764
- query,
765
- relation,
766
- matchValues, // Pass array instead of single ID
767
- targetTable,
768
- parentTable,
769
- parentIdCol,
770
- targetIdField,
771
- this.registry,
772
- []
773
- );
774
-
775
- const results = await query;
776
- const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
777
-
778
- // Map results back to parent rows
779
- for (const row of results as Array<Record<string, unknown>>) {
780
- const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
781
-
782
- // The parent's key is on the target row, in the relation's own
783
- // column. There used to be a second branch here that guessed the
784
- // column by appending `_id` to `inverseRelationName`, reached only
785
- // when the foreign key had not been resolved — which cannot happen
786
- // now that resolution fills it in.
787
- if (!hasForeignKeyOnTarget(relation)) continue;
788
-
789
- // Keyed by string throughout: Drizzle returns a numeric column as a
790
- // number or a string depending on the column type and the driver.
791
- const foreignKeyValue = targetRow[
792
- fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)
793
- ] as string | number | undefined;
794
- if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
795
-
796
- const parentId = parentIdByKey.get(String(foreignKeyValue));
797
- if (parentId !== undefined) {
798
- resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
799
- }
800
- }
801
-
802
- return resultMap;
803
- }
804
-
805
- /**
806
- * Batch fetch many-cardinality related rows for multiple parent rows.
807
- * Returns a Map<parentId, RelatedRow[]> instead of Map<parentId, RelatedRow>.
808
- * Uses a single SQL query with IN clause to avoid N+1.
809
- */
810
- async batchFetchRelatedEntitiesMany(
811
- parentCollectionPath: string,
812
- parentIds: (string | number)[],
813
- _relationKey: string,
814
- relation: ResolvedRelation
815
- ): Promise<Map<string, RelatedRow<Record<string, unknown>>[]>> {
816
- if (parentIds.length === 0) return new Map();
817
-
818
- const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
819
- const targetCollection = relation.target();
820
- const targetTable = getTableForCollection(targetCollection, this.registry);
821
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
822
- const targetIdInfo = targetPks[0];
823
- const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
824
-
825
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
826
- const parentIdInfo = parentPks[0];
827
- const parentTable = this.registry.getTable(getTableName(parentCollection));
828
- if (!parentTable) throw new Error("Parent table not found");
829
- const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
830
-
831
- const parsedParentIds = parentIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
832
-
833
- // Handle join path relations (many-to-many through junction tables)
834
- if (relation.kind === "via") {
835
- let query = this.db.select().from(parentTable).$dynamic();
836
- let currentTable = parentTable;
837
-
838
- for (const join of relation.joinPath) {
839
- const joinTable = this.registry.getTable(join.table);
840
- if (!joinTable) throw new Error(`Join table not found: ${join.table}`);
841
-
842
- const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
843
- const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
844
- const fromColName = fromColumn.split(".").pop()!;
845
- const toColName = toColumn.split(".").pop()!;
846
-
847
- const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
848
- const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
849
- if (!fromCol || !toCol) throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
850
-
851
- query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
852
- currentTable = joinTable;
853
- }
854
-
855
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
856
-
857
- const results = await query;
858
- const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
859
- const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
860
-
861
- for (const row of results as Array<Record<string, unknown>>) {
862
- const parentRow = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
863
- const targetRow = (row[targetTableName] || row) as Record<string, unknown>;
864
- const parentId = buildCompositeId(parentRow, parentPks);
865
- const arr = resultMap.get(parentId) || [];
866
- arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
867
- resultMap.set(parentId, arr);
868
- }
869
-
870
- return resultMap;
871
- }
872
-
873
- // Handle many-to-many owning relations with junction table (relation.through)
874
- // This is the standard path for posts→tags style relations where
875
- // sanitizeRelation populated the `through` config.
876
- if (relation.kind === "manyToMany") {
877
- // The junction names its parent with one column, so the same
878
- // single-key limit applies as for a direct foreign key.
879
- this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
880
-
881
- // An empty map here is a lie a caller cannot see through: `posts/1/tags`
882
- // answering `[]` is exactly what a post with no tags looks like. That
883
- // is the failure `assertRelationsResolve` was written to make
884
- // impossible at boot; this says the same thing if one gets past it.
885
- const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } =
886
- bindThroughJunction(
887
- this.registry,
888
- relation.through,
889
- `${parentCollection.slug}.${relation.relationName}`
890
- );
891
-
892
- // SELECT target.*, junction.sourceColumn FROM junction
893
- // INNER JOIN target ON junction.targetColumn = target.id
894
- // WHERE junction.sourceColumn IN (parentIds)
895
- const query = this.db
896
- .select()
897
- .from(junctionTable)
898
- .innerJoin(targetTable, eq(targetJunctionCol, targetIdField))
899
- .where(inArray(sourceJunctionCol, parsedParentIds));
900
-
901
- const results = await query;
902
- const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
903
- const targetTableName = getTableName(targetCollection);
904
-
905
- for (const row of results as Array<Record<string, unknown>>) {
906
- // The junction table data is namespaced under its table name
907
- const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
908
- const targetData = (row[targetTableName] || row) as Record<string, unknown>;
909
-
910
- const parentId = String(junctionData[relation.through.sourceColumn]);
911
- const arr = resultMap.get(parentId) || [];
912
- arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
913
- resultMap.set(parentId, arr);
914
- }
915
-
916
- return resultMap;
917
- }
918
-
919
- // Handle FK-based relations (one-to-many inverse). One column on the
920
- // target names the parent, so a composite-keyed parent cannot be named.
921
- this.assertSingleKeyAddressable(
922
- parentCollection,
923
- parentPks,
924
- hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
925
- relation
926
- );
927
-
928
- const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
929
- ? await this.resolveSourceKeys(parentCollection, relation, parentIds)
930
- : { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
931
-
932
- const matchValues = hasForeignKeyOnTarget(relation)
933
- ? [...keyByParentId.values()]
934
- : parsedParentIds;
935
- if (matchValues.length === 0) return new Map();
936
-
937
- let query = this.db.select().from(targetTable).$dynamic();
938
-
939
- query = applyDynamicRelationQuery(
940
- query,
941
- query,
942
- relation,
943
- matchValues,
944
- targetTable,
945
- parentTable,
946
- parentIdCol,
947
- targetIdField,
948
- this.registry,
949
- []
950
- );
951
-
952
- const results = await query;
953
- const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
954
-
955
- for (const row of results as Array<Record<string, unknown>>) {
956
- const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
957
-
958
- // Junction-backed relations returned earlier in this method, so
959
- // what reaches here names the parent with a column on the target.
960
- if (!hasForeignKeyOnTarget(relation)) continue;
961
-
962
- const foreignKeyValue = targetRow[
963
- fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)
964
- ] as string | number | undefined;
965
- if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
966
-
967
- const parentId = parentIdByKey.get(String(foreignKeyValue));
968
- if (parentId !== undefined) {
969
- const key = String(parentId);
970
- const arr = resultMap.get(key) || [];
971
- arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
972
- resultMap.set(key, arr);
973
- }
974
- }
975
-
976
- return resultMap;
977
- }
978
- }