@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,2115 +0,0 @@
1
- import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, isNotNull, isNull, lt, or, sql, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, LogicalCondition, isManyToMany, parseRelationAggregateSort } from "@rebasepro/types";
4
- import type { VectorSearchParams } from "@rebasepro/types";
5
- import { resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, createRelationRefWithData, normalizeDriverOrderBy } from "@rebasepro/common";
6
- import { generateForeignKeyName, toWireKey } from "@rebasepro/utils";
7
- import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
8
- import {
9
- getCollectionByPath,
10
- getTableForCollection,
11
- getPrimaryKeys,
12
- requirePrimaryKeys,
13
- deriveRowAddress,
14
- parseIdValues,
15
- idCanAddressTable,
16
- buildCompositeId,
17
- COMPOSITE_ID_SEPARATOR
18
- } from "./collection-helpers";
19
- import { parseDataFromServer, normalizeDbValues } from "../data-transformer";
20
- import { RelationService } from "./RelationService";
21
- import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
22
- import { DrizzleClient } from "../interfaces";
23
- import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
24
- import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
25
- import { visibleColumnProjection, hiddenColumnsOption } from "../schema/search-column";
26
- import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
27
- import { ApiError, logger } from "@rebasepro/server";
28
- import { reachedDatabase } from "../utils/pg-error-utils";
29
-
30
- /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
31
- type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
32
-
33
- /**
34
- * One sort key, with the column or expression it was resolved to.
35
- *
36
- * `cursorTarget` is the same expression pinned to the cursor row instead of the
37
- * outer one, and is set only for a key that has no stored value to compare a
38
- * later page against — an aggregate over a relation. Where it is present the
39
- * keyset comparison recomputes the cursor row's value in SQL rather than
40
- * reading it off the cursor; see {@link FetchService.buildKeysetComparison}.
41
- */
42
- type ResolvedOrderKey = {
43
- field: string;
44
- direction: "asc" | "desc";
45
- target: AnyPgColumn | SQL;
46
- cursorTarget?: SQL;
47
- };
48
-
49
- /**
50
- * Service for handling all row read operations.
51
- * Handles fetching, searching, counting, and filtering rows.
52
- */
53
- export class FetchService {
54
- private relationService: RelationService;
55
-
56
- constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) {
57
- this.relationService = new RelationService(db, registry);
58
- }
59
-
60
- /**
61
- * Get the relational query builder for a given table name.
62
- * Safely narrows the DrizzleClient union type to access db.query[tableName].
63
- */
64
- private getQueryBuilder(tableName: string): RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined {
65
- const query = (this.db as { query?: DbQueryAccessor }).query;
66
- return query?.[tableName] as RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined;
67
- }
68
-
69
- /**
70
- * The context the condition builder needs to compile a filter key that is
71
- * not a column name outright.
72
- *
73
- * Two such keys. An owning relation's key resolves through the collection's
74
- * relations to its foreign-key column; a relation whose link lives on the
75
- * target table or in a junction resolves to a correlated `EXISTS`, which
76
- * needs the registry to reach that other table and this table's key column
77
- * to correlate back.
78
- *
79
- * Looked up rather than passed: every read path already has the path, only
80
- * some have the collection, and a path that names no registered collection
81
- * (a nested/derived one) is not an error here — the builder simply falls
82
- * back to guessing the default key shapes, and a relation filter it cannot
83
- * compile stays unresolvable and so fails closed.
84
- */
85
- private filterContext(collectionPath: string, table: PgTable<any>): FilterCompilationOptions {
86
- const collection = this.registry.getCollectionByPath(collectionPath) ?? undefined;
87
- return {
88
- collection,
89
- registry: this.registry,
90
- sourceIdColumn: collection ? this.resolveIdColumn(collection, table) : undefined
91
- };
92
- }
93
-
94
- /**
95
- * The table column this collection's rows are keyed by, or `undefined`.
96
- *
97
- * `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
98
- * resolvable key is not an error on the filter path — it only means the
99
- * relation filters that would correlate on it cannot be compiled, which
100
- * the builder already handles by failing that field closed.
101
- */
102
- private resolveIdColumn(collection: CollectionConfig, table: PgTable<any>): AnyPgColumn | undefined {
103
- const [idInfo] = getPrimaryKeys(collection, this.registry);
104
- if (!idInfo) return undefined;
105
- return table[idInfo.fieldName as keyof typeof table] as AnyPgColumn | undefined;
106
- }
107
-
108
- /**
109
- * Build filter conditions from FilterValues
110
- * Delegates to DrizzleConditionBuilder.buildFilterConditions
111
- */
112
- buildFilterConditions<M extends Record<string, unknown>>(
113
- filter: FilterValues<Extract<keyof M, string>>,
114
- table: PgTable<any>,
115
- collectionPath: string
116
- ): SQL[] {
117
- return DrizzleConditionBuilder.buildFilterConditions(
118
- filter, table, collectionPath, this.filterContext(collectionPath, table)
119
- );
120
- }
121
-
122
- // =============================================================
123
- // DRIZZLE QUERY HELPERS
124
- // =============================================================
125
-
126
- /**
127
- * Resolves the correct Drizzle column for sorting.
128
- * Automatically maps owning relation property keys to their underlying foreign key column.
129
- *
130
- * The relation's own `localKey` is the authority for that foreign key, not
131
- * `<field>_id`. The default local key comes from `generateForeignKeyName`,
132
- * which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
133
- * `users` → `user_id` — and an author can override it outright. A wrong
134
- * guess resolves to nothing, the caller drops the `ORDER BY`, and the rows
135
- * come back in whatever order Postgres pleases: paging over that repeats
136
- * and skips rows rather than erroring. The guesses stay, last, for a
137
- * caller that hands over no collection to resolve against.
138
- */
139
- /**
140
- * The ORDER BY target, which may be relevance rather than a column.
141
- *
142
- * `_score` is only meaningful for a collection that declared a `search`
143
- * block *and* for a request that carried a search string — ranking rows
144
- * against no query ranks them all at zero. Outside those two conditions it
145
- * is an unknown field and gets the same 400 as any other typo, which is the
146
- * behaviour that matters: a sort that is silently dropped returns 200 with
147
- * rows in arbitrary order, and paging over that repeats and skips rows.
148
- */
149
- static readonly SCORE_FIELD = "_score";
150
-
151
- private resolveOrderTarget(
152
- table: PgTable<any>,
153
- orderBy: string,
154
- collection?: CollectionConfig,
155
- searchString?: string
156
- ): AnyPgColumn | SQL | undefined {
157
- if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
158
- const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
159
- if (rank) return rank;
160
- }
161
- return this.resolveOrderByField(table, orderBy, collection);
162
- }
163
-
164
- /**
165
- * The aggregate a sort key names, as an expression, or `undefined` if the
166
- * key is not one.
167
- *
168
- * `cursorId` builds the same expression pinned to the cursor row — see
169
- * {@link DrizzleConditionBuilder.buildRelationAggregateExpression}.
170
- *
171
- * A key that *parses* as an aggregate but names no relation, or a column
172
- * the target does not have, throws rather than falling through to the
173
- * column path. Falling through would report `min(applications.created_at)`
174
- * as an unknown column and list the columns of the wrong table.
175
- */
176
- private resolveAggregateOrderTarget(
177
- table: PgTable<any>,
178
- orderBy: string,
179
- collection: CollectionConfig | undefined,
180
- collectionPath: string | undefined,
181
- cursorId?: unknown
182
- ): SQL | undefined {
183
- const spec = parseRelationAggregateSort(orderBy);
184
- if (!spec) return undefined;
185
- if (!collection || !collectionPath) {
186
- throw ApiError.badRequest(
187
- `Cannot sort by '${orderBy}': an aggregate sort needs the collection it is written against, ` +
188
- "and this query carries none.",
189
- "ORDER_BY_FIELD_NOT_SORTABLE",
190
- { field: orderBy }
191
- );
192
- }
193
- const primaryKeys = getPrimaryKeys(collection, this.registry);
194
- const idColumn = primaryKeys.length === 1
195
- ? table[primaryKeys[0].fieldName as keyof typeof table] as AnyPgColumn
196
- : undefined;
197
- if (!idColumn) {
198
- throw ApiError.badRequest(
199
- `Cannot sort by '${orderBy}' on collection '${collectionPath}': the subquery correlates on a ` +
200
- "single key column, and this collection has none or has a composite one.",
201
- "ORDER_BY_FIELD_NOT_SORTABLE",
202
- { field: orderBy, collection: collectionPath }
203
- );
204
- }
205
- return DrizzleConditionBuilder.buildRelationAggregateExpression(
206
- spec, table, collection, this.registry, idColumn, collectionPath, cursorId
207
- );
208
- }
209
-
210
- /**
211
- * Resolve every sort key to the expression it orders by, in order of
212
- * significance.
213
- *
214
- * A key that resolves to nothing is dropped rather than skipping the rest:
215
- * `resolveOrderByField` only *returns* undefined under the lenient
216
- * unknown-field mode, where dropping is the configured answer, and dropping
217
- * one key of several still honours the ones that did resolve.
218
- */
219
- private resolveOrderKeys(
220
- table: PgTable<any>,
221
- keys: OrderByTuple[],
222
- collection?: CollectionConfig,
223
- searchString?: string,
224
- collectionPath?: string,
225
- cursorId?: unknown
226
- ): ResolvedOrderKey[] {
227
- const resolved: ResolvedOrderKey[] = [];
228
- for (const [field, direction] of keys) {
229
- // Checked before the column path: an aggregate key is not a column
230
- // name and would otherwise be reported as a typo'd one.
231
- const aggregate = this.resolveAggregateOrderTarget(table, field, collection, collectionPath);
232
- if (aggregate) {
233
- resolved.push({
234
- field,
235
- direction,
236
- target: aggregate,
237
- // Built only when a cursor is in play: it is a second
238
- // subquery, and a listing with no `startAfter` has nothing
239
- // to compare against.
240
- ...(cursorId !== undefined && {
241
- cursorTarget: this.resolveAggregateOrderTarget(
242
- table, field, collection, collectionPath, cursorId
243
- )
244
- })
245
- });
246
- continue;
247
- }
248
- const target = this.resolveOrderTarget(table, field, collection, searchString);
249
- if (target) resolved.push({ field,
250
- direction,
251
- target });
252
- }
253
- return resolved;
254
- }
255
-
256
- /**
257
- * The full `ORDER BY`: the caller's keys, then the id.
258
- *
259
- * The id is always last and always descending. It is not decoration — it is
260
- * what makes the ordering *total*, and a cursor over a non-total order
261
- * repeats and skips rows among the ties. Every keyset comparison built by
262
- * {@link buildCursorConditions} ends on the same `id DESC`, and the two have
263
- * to agree: they did not, and an ascending sort paged with `id >` against an
264
- * `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
265
- * every page after the first.
266
- *
267
- * Where the NULLs go is written out rather than inherited. Postgres already
268
- * defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this
269
- * changes no query — but {@link buildKeysetComparison} encodes that exact
270
- * placement, and an invariant two functions depend on should be stated in
271
- * both rather than assumed in one. It matters most for the keys that are
272
- * *always* nullable: an aggregate over a relation is NULL for every row the
273
- * relation reaches nothing from, which is precisely the "nobody waiting"
274
- * end of a queue.
275
- */
276
- private buildOrderExpressions(keys: ResolvedOrderKey[], idField: AnyPgColumn): SQL[] {
277
- const expressions = keys.map(({ direction, target }) => direction === "asc"
278
- ? sql`${target} ASC NULLS LAST`
279
- : sql`${target} DESC NULLS FIRST`);
280
- expressions.push(desc(idField));
281
- return expressions as SQL[];
282
- }
283
-
284
- private resolveOrderByField(
285
- table: PgTable<any>,
286
- orderBy: string,
287
- collection?: CollectionConfig
288
- ): AnyPgColumn | undefined {
289
- const columnAt = (key: string): AnyPgColumn | undefined =>
290
- (key in table ? table[key as keyof typeof table] as AnyPgColumn : undefined) || undefined;
291
-
292
- const direct = columnAt(orderBy);
293
- if (direct) return direct;
294
-
295
- // Owning relation, resolved: the relation names its own local key.
296
- const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : undefined;
297
- if (declaredRelation?.kind === "belongsTo") {
298
- // `localKey` is the column; the table is keyed by the wire name.
299
- const foreignKey = columnAt(fieldKeyForColumn(collection, declaredRelation.localKey));
300
- if (foreignKey) return foreignKey;
301
- }
302
-
303
- // No collection in hand — the shapes an owning relation's key takes by
304
- // default (e.g. `project` → `projectId`, `userProfile` →
305
- // `userProfileId`), then the snake forms for a project that authored the
306
- // property under its column name.
307
- for (const guess of [
308
- `${orderBy}Id`,
309
- toWireKey(generateForeignKeyName(orderBy)),
310
- `${orderBy}_id`,
311
- generateForeignKeyName(orderBy)
312
- ]) {
313
- const foreignKey = columnAt(guess);
314
- if (foreignKey) return foreignKey;
315
- }
316
-
317
- // Nothing resolved. Returning `undefined` is exactly what the docblock
318
- // above describes: the caller drops the ORDER BY and hands back rows in
319
- // whatever order Postgres pleases, while the requester believes they
320
- // are sorted. `?orderBy=titel` answered 200 with unsorted data and no
321
- // hint that the sort had been ignored.
322
- //
323
- // A *filter* naming a field that does not exist is already refused for
324
- // precisely this reason — it "used to widen results silently". An
325
- // unresolvable sort field is the same drift between a query and the
326
- // schema, so it answers the same way and honours the same switch: one
327
- // knob, because a deployment that wants the lenient behaviour wants it
328
- // for both.
329
- const collectionName = collection?.slug ?? collection?.name;
330
- const onCollection = collectionName ? ` on collection '${collectionName}'` : "";
331
-
332
- // A declared to-many relation is a different mistake from a typo, and
333
- // saying "unknown field" about a field the collection plainly declares
334
- // sends the reader looking for a spelling error that is not there.
335
- // There is simply no single value per row to order by — `posts.tags` is
336
- // a set — so no ORDER BY exists to write, with or without a typo.
337
- if (declaredRelation && declaredRelation.kind !== "belongsTo") {
338
- throw ApiError.badRequest(
339
- `Cannot sort by '${orderBy}'${onCollection}: it is a to-many relation ` +
340
- `(${declaredRelation.kind}), which has no single value per row to order by.`,
341
- "ORDER_BY_FIELD_NOT_SORTABLE",
342
- { field: orderBy, kind: declaredRelation.kind, ...(collectionName && { collection: collectionName }) }
343
- );
344
- }
345
-
346
- if (getUnknownFilterFieldsMode() === "warn") {
347
- logger.warn(
348
- `Sorting by field '${orderBy}'${onCollection}, but it does not exist in the table — ` +
349
- "the ORDER BY was dropped and these rows are unsorted."
350
- );
351
- return undefined;
352
- }
353
-
354
- let validFields: string[] = [];
355
- try {
356
- validFields = Object.keys(getTableColumns(table)).sort();
357
- } catch {
358
- // A table stand-in without Drizzle's column symbols — the message
359
- // is worth less without the list, but not worth failing over.
360
- }
361
-
362
- throw ApiError.badRequest(
363
- `Unknown orderBy field '${orderBy}'${onCollection}` +
364
- (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
365
- "UNKNOWN_ORDER_BY_FIELD",
366
- {
367
- field: orderBy,
368
- ...(collectionName && { collection: collectionName }),
369
- ...(validFields.length > 0 && { validFields })
370
- }
371
- );
372
- }
373
-
374
- /**
375
- * Build the `with` config for Drizzle's relational query API.
376
- * Converts collection relations to a Drizzle-compatible `with` object.
377
- *
378
- * When `include` is provided, only those relations are loaded.
379
- * When `include` is absent, ALL relations are loaded (the admin path).
380
- *
381
- * Automatically detects many-to-many junction tables and nests
382
- * the target relation so actual row data is returned.
383
- */
384
- private buildWithConfig(
385
- collection: CollectionConfig,
386
- include?: string[]
387
- ): Record<string, boolean | { with: Record<string, boolean> }> {
388
- const resolvedRelations = resolveCollectionRelations(collection);
389
- const withConfig: Record<string, boolean | { with: Record<string, boolean> }> = {};
390
-
391
- const shouldInclude = (key: string) =>
392
- !include || include.length === 0 || include[0] === "*" || include.includes(key);
393
-
394
- for (const [key, relation] of Object.entries(resolvedRelations)) {
395
- if (!shouldInclude(key)) continue;
396
-
397
- const drizzleRelName = relation.relationName || key;
398
-
399
- // Skip relations that use joinPath as they are not mapped in Drizzle schemas
400
- if (relation.kind === "via") {
401
- continue;
402
- }
403
-
404
- // Detect many-to-many junction tables:
405
- // If the relation goes through a junction table (relation.through exists or
406
- // the Drizzle schema maps to a junction table), we need two-level with.
407
- if (relation.cardinality === "many" && isJunctionRelation(relation)) {
408
- // The Drizzle relation points to the junction table.
409
- // We need: { [junctionRelName]: { with: { [targetFkName]: true } } }
410
- // The target FK name is the relation on the junction table that points to the actual target.
411
- const targetFkName = this.getJunctionTargetRelationName(relation, collection);
412
- if (targetFkName) {
413
- withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
414
- } else {
415
- withConfig[drizzleRelName] = true;
416
- }
417
- } else {
418
- withConfig[drizzleRelName] = true;
419
- }
420
- }
421
-
422
- return withConfig;
423
- }
424
-
425
- /**
426
- * Get the Drizzle relation name on the junction table that points to the actual target row.
427
- * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
428
- */
429
- private getJunctionTargetRelationName(relation: ResolvedRelation, _collection: CollectionConfig): string | null {
430
- if (isManyToMany(relation)) {
431
- // The junction relation on the junction table pointing to the target
432
- // uses the targetColumn name as the Drizzle relation name
433
- return relation.through.targetColumn.replace(/_id$/, "_id");
434
- }
435
- return null;
436
- }
437
-
438
- /**
439
- * Post-fetch joinPath relations for a single flat row.
440
- * joinPath relations cannot be expressed via Drizzle's `with` config,
441
- * so they must be loaded separately after the primary query.
442
- */
443
- private async resolveJoinPathRelations<M extends Record<string, unknown>>(
444
- row: Record<string, unknown>,
445
- collection: CollectionConfig,
446
- collectionPath: string,
447
- parsedId: string | number,
448
- _databaseId?: string
449
- ): Promise<void> {
450
- const resolvedRelations = resolveCollectionRelations(collection);
451
-
452
- const promises = Object.entries(resolvedRelations)
453
- .filter(([key, relation]) => relation.kind === "via")
454
- .map(async ([key, relation]) => {
455
- try {
456
- const relatedRows = await this.relationService.fetchRelatedEntities(
457
- collectionPath,
458
- parsedId,
459
- key,
460
- { limit: relation.cardinality === "one" ? 1 : undefined }
461
- );
462
-
463
- if (relation.cardinality === "one" && relatedRows.length > 0) {
464
- const e = relatedRows[0];
465
- row[key] = createRelationRefWithData(e.id, e.path, e);
466
- } else if (relation.cardinality === "many") {
467
- row[key] = relatedRows.map(e =>
468
- createRelationRefWithData(e.id, e.path, e)
469
- );
470
- }
471
- } catch (e) {
472
- // A relation that failed to load is not a relation that is absent.
473
- // Without this the request answers 200 with the field quietly
474
- // missing — and because a Postgres error poisons the surrounding
475
- // transaction, every later relation in the same request is
476
- // swallowed too, so one failure becomes a response missing
477
- // several fields. Same guard the four other catches in this file
478
- // already use.
479
- if (reachedDatabase(e)) throw e;
480
- logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
481
- }
482
- });
483
-
484
- await Promise.all(promises);
485
- }
486
-
487
- /**
488
- * Resolves joinPath relations for raw REST rows and directly injects them.
489
- * Uses RelationService to query the database and maps results back to the flattened objects.
490
- */
491
- private async resolveJoinPathRelationsBatchRest(
492
- rows: Record<string, unknown>[],
493
- collection: CollectionConfig,
494
- collectionPath: string,
495
- idInfoArray: { fieldName: string; type: "string" | "number" }[],
496
- include?: string[]
497
- ): Promise<void> {
498
- if (rows.length === 0) return;
499
-
500
- const resolvedRelations = resolveCollectionRelations(collection);
501
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
502
- const shouldInclude = (key: string) =>
503
- !include || include.length === 0 || include[0] === "*" || include.includes(key);
504
-
505
- const joinPathRelations = Object.entries(resolvedRelations)
506
- .filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
507
-
508
- if (joinPathRelations.length === 0) return;
509
-
510
- // These rows carry their key columns verbatim, so the parent's address
511
- // is derived from them. It used to be parsed back out of a synthesized
512
- // `id` — which no longer exists on a row, and threw (composite: parts
513
- // mismatch; numeric: NaN) into the catch below, where a warning is all
514
- // that separates "no relations" from "relations dropped".
515
- //
516
- // The whole key, because that is what the batch groups its results by:
517
- // both sides derive the token the same way, so they agree by
518
- // construction rather than by both happening to pick column zero.
519
- const parentIdOf = (row: Record<string, unknown>): string | undefined => {
520
- const address = buildCompositeId(row, idInfoArray);
521
- return address && address.split(COMPOSITE_ID_SEPARATOR).some(part => part !== "") ? address : undefined;
522
- };
523
-
524
- for (const [key, relation] of joinPathRelations) {
525
- try {
526
- const addressable = rows.filter(r => parentIdOf(r) !== undefined && parentIdOf(r) !== null);
527
- if (addressable.length === 0) continue;
528
- const rowIds = addressable.map(r => parentIdOf(r) as string | number);
529
-
530
- if (relation.cardinality === "one") {
531
- const resultMap = await this.relationService.batchFetchRelatedEntities(
532
- collectionPath,
533
- rowIds,
534
- key,
535
- relation
536
- );
537
-
538
- for (const row of addressable) {
539
- const relatedRow = resultMap.get(String(parentIdOf(row)));
540
- // Columns only: the target's address is the consumer's to
541
- // derive, and merging it last overwrote a real `id` column.
542
- row[key] = relatedRow ? { ...relatedRow.values } : null;
543
- }
544
- } else if (relation.cardinality === "many") {
545
- const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(
546
- collectionPath,
547
- rowIds,
548
- key,
549
- relation
550
- );
551
-
552
- for (const row of addressable) {
553
- const relatedList = resultMap.get(String(parentIdOf(row))) || [];
554
- row[key] = relatedList.map(e => ({ ...e.values }));
555
- }
556
- }
557
- } catch (e) {
558
- // A relation that failed to load is not a relation that is absent.
559
- // Without this the request answers 200 with the field quietly
560
- // missing — and because a Postgres error poisons the surrounding
561
- // transaction, every later relation in the same request is
562
- // swallowed too, so one failure becomes a response missing
563
- // several fields. Same guard the four other catches in this file
564
- // already use.
565
- if (reachedDatabase(e)) throw e;
566
- logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
567
- }
568
- }
569
- }
570
-
571
- /**
572
- * Build db.query-compatible options from standard fetch options.
573
- * Handles filter, search, orderBy, limit, and cursor-based pagination.
574
- */
575
- private buildDrizzleQueryOptions<M extends Record<string, unknown>>(
576
- table: PgTable<any>,
577
- idField: AnyPgColumn,
578
- idInfo: { fieldName: string; type: "string" | "number" },
579
- options: {
580
- filter?: FilterValues<Extract<keyof M, string>>;
581
- orderBy?: string | OrderByTuple[];
582
- order?: "desc" | "asc";
583
- limit?: number;
584
- offset?: number;
585
- startAfter?: Record<string, unknown>;
586
- searchString?: string;
587
- logical?: LogicalCondition;
588
- },
589
- collectionPath: string,
590
- withConfig?: Record<string, unknown>,
591
- scopeCondition?: SQL
592
- ): Record<string, unknown> {
593
- const queryOpts: Record<string, unknown> = {};
594
-
595
- // Same exclusion the `db.select` fallback applies, in the shape the
596
- // relational query builder takes. Both paths serve the same request, so
597
- // a row must not carry the search column down one and not the other.
598
- const hidden = hiddenColumnsOption(
599
- getTableColumns(table),
600
- this.registry.getCollectionByPath(collectionPath) ?? undefined
601
- );
602
- if (hidden) queryOpts.columns = hidden;
603
-
604
- if (withConfig) queryOpts.with = withConfig;
605
-
606
- // Build where conditions
607
- const allConditions: SQL[] = [];
608
-
609
- if (scopeCondition) allConditions.push(scopeCondition);
610
-
611
- if (options.searchString) {
612
- const collection = getCollectionByPath(collectionPath, this.registry);
613
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
614
- options.searchString, collection.properties, table, collection
615
- );
616
- if (searchConditions.length === 0) {
617
- // Return options that will produce empty results
618
- queryOpts.where = and(eq(idField, -99999999)); // impossible condition
619
- return queryOpts;
620
- }
621
- allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
622
- }
623
-
624
- if (options.filter) {
625
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
626
- if (filterConditions.length > 0) allConditions.push(...filterConditions);
627
- }
628
-
629
- if (options.logical) {
630
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
631
- if (logicalCondition) allConditions.push(logicalCondition);
632
- }
633
-
634
- // Cursor-based pagination (startAfter)
635
- if (options.startAfter) {
636
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
637
- if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
638
- }
639
-
640
- if (allConditions.length > 0) {
641
- queryOpts.where = and(...allConditions);
642
- }
643
-
644
- // OrderBy
645
- const orderKeys = normalizeDriverOrderBy(options.orderBy, options.order);
646
- const resolvedOrder = orderKeys
647
- ? this.resolveOrderKeys(table, orderKeys, getCollectionByPath(collectionPath, this.registry), options.searchString)
648
- : [];
649
- queryOpts.orderBy = this.buildOrderExpressions(resolvedOrder, idField);
650
-
651
- // Limit
652
- const limitValue = options.searchString ? (options.limit || 50) : options.limit;
653
- if (limitValue) queryOpts.limit = limitValue;
654
-
655
- // Offset (numeric pagination)
656
- if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
657
-
658
- return queryOpts;
659
- }
660
-
661
- /**
662
- * Extract cursor pagination conditions from startAfter options.
663
- *
664
- * "Every row that sorts after this one", written out as a comparison over
665
- * the same keys the `ORDER BY` uses and ending on the same `id DESC`. With
666
- * one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with
667
- * several it nests, each key's tie handing the decision to the next.
668
- */
669
- private buildCursorConditions(
670
- table: PgTable<any>,
671
- idField: AnyPgColumn,
672
- idInfo: { fieldName: string; type: "string" | "number" },
673
- options: { orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; startAfter?: Record<string, unknown> },
674
- collectionPath?: string
675
- ): SQL[] {
676
- if (!options.startAfter) return [];
677
- const cursor = options.startAfter;
678
- const keys = normalizeDriverOrderBy(options.orderBy, options.order);
679
-
680
- if (keys) {
681
- // Relevance is computed per query, not stored, so there is no value
682
- // on the cursor row to compare a later page against — and two
683
- // requests with different search strings would produce scores that
684
- // are not on the same scale at all. Refusing is the only honest
685
- // answer: a dropped cursor condition silently repeats and skips
686
- // rows, which is precisely what paging exists to prevent.
687
- if (keys.some(([field]) => field === FetchService.SCORE_FIELD)) {
688
- throw ApiError.badRequest(
689
- "Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. " +
690
- "Relevance is computed per query rather than stored, so it cannot key a cursor. " +
691
- "Use `limit`/`offset` for relevance-ordered pages, or order by a column.",
692
- "SCORE_CURSOR_UNSUPPORTED",
693
- { field: FetchService.SCORE_FIELD }
694
- );
695
- }
696
- const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
697
- const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
698
- const resolved = this.resolveOrderKeys(
699
- table, keys, collection, undefined, collectionPath,
700
- // A null id addresses no row, so pinning a subquery to it would
701
- // aggregate over nothing and read as "the cursor row has no
702
- // related rows" rather than as the absent cursor it is.
703
- startAfterId ?? undefined
704
- );
705
-
706
- if (resolved.length > 0 && startAfterId !== undefined) {
707
- const cursorValues = cursor.values as Record<string, unknown> | undefined;
708
- // `in`, not `??`: a cursor row whose sort value is genuinely
709
- // NULL is a row this has to be able to page past, and `??`
710
- // read it as "the cursor did not carry this key" and dropped
711
- // the whole condition.
712
- const values = resolved.map(({ field, cursorTarget }) => cursorTarget
713
- // An aggregate is not stored on the row, so the cursor
714
- // never carried it and never could. Its value is recomputed
715
- // from the cursor id instead, in SQL, by `cursorTarget` —
716
- // there is nothing for this list to supply.
717
- ? null
718
- : (cursorValues && field in cursorValues) ? cursorValues[field] : cursor[field]);
719
- // Every key needs a value from the cursor row. A missing one
720
- // cannot be guessed, and a comparison built from the keys that
721
- // happen to be present is not the same comparison — so this
722
- // falls through to no cursor condition, which is what a single
723
- // missing sort value has always done here.
724
- if (values.every((value, i) => resolved[i].cursorTarget || value !== undefined)) {
725
- return [this.buildKeysetComparison(resolved, values, idField, startAfterId)];
726
- }
727
- }
728
- } else {
729
- const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
730
- if (startAfterId !== undefined && startAfterId !== null) {
731
- const idInfoArray = [idInfo] as Array<{ fieldName: string; type: "string" | "number" }>;
732
- const parsedStartAfterIdObj = parseIdValues(startAfterId as string | number, idInfoArray);
733
- return [lt(idField, parsedStartAfterIdObj[idInfo.fieldName])];
734
- }
735
- }
736
-
737
- return [];
738
- }
739
-
740
- /**
741
- * "Sorts strictly after the cursor row", over `keys` and then the id.
742
- *
743
- * Built by recursion rather than as a row-value comparison — `(a, b) > (x, y)`
744
- * would be shorter, but it is only correct when every key runs the same
745
- * direction, and `roles ASC, created_at DESC` is exactly the case this
746
- * exists to serve.
747
- *
748
- * NULLs are compared by the rule Postgres sorts them under (last ascending,
749
- * first descending) rather than by `>`/`<`, which answer *unknown* against
750
- * NULL and therefore match nothing. Ordering by a nullable column and paging
751
- * used to drop every row whose sort value was NULL from page two onward.
752
- */
753
- private buildKeysetComparison(
754
- keys: ResolvedOrderKey[],
755
- values: unknown[],
756
- idField: AnyPgColumn,
757
- cursorId: unknown,
758
- index = 0
759
- ): SQL {
760
- // Past the last key, the id settles it. It is ordered `DESC`, so "after"
761
- // the cursor row means a smaller id.
762
- if (index >= keys.length) return lt(idField, cursorId);
763
-
764
- const { direction, cursorTarget } = keys[index];
765
- // A column or an expression. `_score` is an expression too, but a
766
- // cursor over relevance is refused before this is reached; an aggregate
767
- // over a relation is the one that gets here. Drizzle's comparison
768
- // helpers are typed per operand kind, so the union has to be resolved
769
- // here rather than at the call site.
770
- const target = keys[index].target as AnyPgColumn;
771
- const value = values[index];
772
- const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
773
-
774
- // No stored value to compare against — the cursor row's is recomputed
775
- // by an expression instead, and whether it is NULL is a question only
776
- // SQL can answer. So both branches of the null test below have to exist
777
- // in the statement rather than being chosen here.
778
- //
779
- // `cursorTarget` references only the cursor id, never the outer row, so
780
- // Postgres evaluates it once for the whole statement rather than per
781
- // row — repeating it across the branches costs nothing.
782
- if (cursorTarget) {
783
- return direction === "asc"
784
- // NULLS LAST. A cursor row among the NULLs has only later NULLs
785
- // after it; otherwise everything greater, then the NULLs, then
786
- // the ties.
787
- ? or(
788
- and(isNull(cursorTarget), isNull(target), rest),
789
- and(
790
- isNotNull(cursorTarget),
791
- or(
792
- sql`${target} > ${cursorTarget}`,
793
- isNull(target),
794
- and(sql`${target} = ${cursorTarget}`, rest)
795
- )
796
- )
797
- )!
798
- // NULLS FIRST. A cursor row among the NULLs still has every
799
- // non-null row after it.
800
- : or(
801
- and(isNull(cursorTarget), or(isNotNull(target), and(isNull(target), rest))),
802
- and(
803
- isNotNull(cursorTarget),
804
- or(
805
- sql`${target} < ${cursorTarget}`,
806
- and(sql`${target} = ${cursorTarget}`, rest)
807
- )
808
- )
809
- )!;
810
- }
811
-
812
- if (value === null) {
813
- // The cursor row sorts among the NULLs.
814
- return direction === "asc"
815
- // NULLS LAST: nothing non-null is left, so only later NULLs.
816
- ? and(isNull(target), rest)!
817
- // NULLS FIRST: every non-null row is still ahead, plus later NULLs.
818
- : or(isNotNull(target), and(isNull(target), rest))!;
819
- }
820
-
821
- return direction === "asc"
822
- // NULLS LAST, so the NULLs are still ahead of a non-null cursor row.
823
- ? or(gt(target, value), isNull(target), and(eq(target, value), rest))!
824
- : or(lt(target, value), and(eq(target, value), rest))!;
825
- }
826
-
827
- /**
828
- * Compile "rows reachable from this parent" into a `WHERE` condition on the
829
- * target table, so a nested listing can run as an ordinary collection query.
830
- */
831
- private buildRelationScope(hop: NestedPathHop): SQL {
832
- const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
833
- const parentIdInfo = parentPks[0];
834
- const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
835
-
836
- const parent = () => {
837
- const table = getTableForCollection(hop.parentCollection, this.registry);
838
- const idColumn = table[parentIdInfo.fieldName as keyof typeof table] as AnyPgColumn;
839
- if (!idColumn) {
840
- throw new Error(`ID field '${parentIdInfo.fieldName}' not found in table for collection '${hop.parentCollection.slug}'`);
841
- }
842
- return { table,
843
- idColumn };
844
- };
845
-
846
- const targetTable = getTableForCollection(hop.targetCollection, this.registry);
847
- const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
848
- const targetIdColumn = targetTable[targetPks[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
849
- if (!targetIdColumn) {
850
- throw new Error(`ID field '${targetPks[0].fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
851
- }
852
-
853
- return DrizzleConditionBuilder.buildRelationScopeCondition(
854
- hop.relation,
855
- parent,
856
- parsedParentId as string | number,
857
- targetTable,
858
- targetIdColumn,
859
- this.registry
860
- );
861
- }
862
-
863
- /**
864
- * Whether `id` is actually reachable at `collectionPath`.
865
- *
866
- * Trivially true for a root path. For a nested one it is a real question:
867
- * the path resolves to the target collection, and matching on the primary
868
- * key alone made the parent segment decorative — `authors/1/posts/43`
869
- * returned post 43 whoever wrote it, and the REST layer's delete then
870
- * deleted it. A row that is not under this parent is reported as absent,
871
- * which is what a caller addressing it through the parent should see.
872
- */
873
- private async isAddressableUnder(collectionPath: string, id: string | number): Promise<boolean> {
874
- if (!isNestedPath(collectionPath)) return true;
875
- const hop = resolveNestedPath(collectionPath, this.registry);
876
- if (!hop) return true;
877
- return this.relationService.isRelated(hop, id);
878
- }
879
-
880
- /**
881
- * Fetch a single row by ID
882
- */
883
- async fetchOne<M extends Record<string, unknown>>(
884
- collectionPath: string,
885
- id: string | number,
886
- databaseId?: string
887
- ): Promise<Record<string, unknown> | undefined> {
888
- if (!await this.isAddressableUnder(collectionPath, id)) return undefined;
889
-
890
- const collection = getCollectionByPath(collectionPath, this.registry);
891
- const table = getTableForCollection(collection, this.registry);
892
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
893
- const idInfo = idInfoArray[0];
894
- const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
895
-
896
- if (!idField) {
897
- throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
898
- }
899
-
900
- // An address the key columns cannot hold names no row — the same answer
901
- // as a well-formed id nobody has. Asking Postgres instead raises 22P02
902
- // and aborts the transaction around this read.
903
- if (!idCanAddressTable(id, table, idInfoArray)) return undefined;
904
-
905
- const parsedIdObj = parseIdValues(id, idInfoArray);
906
- const parsedId = parsedIdObj[idInfo.fieldName];
907
-
908
- // Primary path: use db.query.findFirst with relation loading
909
-
910
- const tableName = getTableName(table);
911
-
912
- const qb = this.getQueryBuilder(tableName);
913
- if (qb) {
914
- try {
915
- const withConfig = this.buildWithConfig(collection);
916
-
917
- const hidden = hiddenColumnsOption(getTableColumns(table), collection);
918
-
919
- const row = await qb.findFirst({
920
- where: eq(idField, parsedId),
921
- with: withConfig,
922
- ...(hidden ? { columns: hidden } : {})
923
- } as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
924
-
925
- if (!row) return undefined;
926
-
927
- const flatRow = toFlatRow(row, collection, this.registry);
928
-
929
- // Post-fetch joinPath relations that Drizzle's `with` can't express
930
- await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
931
-
932
- return flatRow;
933
- } catch (e) {
934
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
935
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
936
- logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
937
- }
938
- if (reachedDatabase(e)) throw e;
939
- logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
940
- }
941
- }
942
-
943
- // Fallback: db.select + N+1 relation loading
944
- const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
945
- const result = await this.db
946
- .select(visibleOne as never)
947
- .from(table)
948
- .where(eq(idField, parsedId))
949
- .limit(1);
950
-
951
- if (result.length === 0) return undefined;
952
-
953
- const raw = result[0] as M;
954
- const values = await parseDataFromServer(raw, collection, this.db, this.registry) as Record<string, unknown>;
955
-
956
- // Load relations based on cardinality (N+1 — only used in fallback)
957
- const resolvedRelations = resolveCollectionRelations(collection);
958
- const propertyKeys = new Set(Object.keys(collection.properties));
959
-
960
- const relationPromises = Object.entries(resolvedRelations)
961
- .filter(([key]) => propertyKeys.has(key))
962
- .map(async ([key, relation]) => {
963
- if (relation.cardinality === "many") {
964
- const relatedRows = await this.relationService.fetchRelatedEntities(
965
- collectionPath,
966
- parsedId,
967
- key,
968
- {}
969
- );
970
- values[key] = relatedRows.map(e =>
971
- createRelationRef(e.id, e.path)
972
- );
973
- } else if (relation.cardinality === "one") {
974
- if (values[key] == null) {
975
- try {
976
- const relatedRows = await this.relationService.fetchRelatedEntities(
977
- collectionPath,
978
- parsedId,
979
- key,
980
- { limit: 1 }
981
- );
982
- if (relatedRows.length > 0) {
983
- const e = relatedRows[0];
984
- values[key] = createRelationRef(e.id, e.path);
985
- }
986
- } catch (e) {
987
- // A relation that failed to load is not a relation that is absent.
988
- // Without this the request answers 200 with the field quietly
989
- // missing — and because a Postgres error poisons the surrounding
990
- // transaction, every later relation in the same request is
991
- // swallowed too, so one failure becomes a response missing
992
- // several fields. Same guard the four other catches in this file
993
- // already use.
994
- if (reachedDatabase(e)) throw e;
995
- logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
996
- }
997
- }
998
- }
999
- });
1000
-
1001
- await Promise.all(relationPromises);
1002
-
1003
- return {
1004
- ...values,
1005
- id: id.toString()
1006
- };
1007
- }
1008
-
1009
- /**
1010
- * Unified method to fetch rows with optional search functionality
1011
- */
1012
- async fetchRowsWithConditions<M extends Record<string, unknown>>(
1013
- collectionPath: string,
1014
- options: {
1015
- filter?: FilterValues<Extract<keyof M, string>>;
1016
- orderBy?: string | OrderByTuple[];
1017
- order?: "desc" | "asc";
1018
- limit?: number;
1019
- offset?: number;
1020
- startAfter?: Record<string, unknown>;
1021
- searchString?: string;
1022
- searchExplain?: boolean;
1023
- databaseId?: string;
1024
- vectorSearch?: VectorSearchParams;
1025
- logical?: LogicalCondition;
1026
- /** Narrow to the rows reachable from a parent through a relation. */
1027
- relatedTo?: NestedPathHop;
1028
- } = {}
1029
- ): Promise<Record<string, unknown>[]> {
1030
- const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : undefined;
1031
- const collection = getCollectionByPath(collectionPath, this.registry);
1032
- const table = getTableForCollection(collection, this.registry);
1033
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1034
- const idInfo = idInfoArray[0];
1035
- const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1036
-
1037
- if (!idField) {
1038
- throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
1039
- }
1040
-
1041
- // Primary path: use db.query.findMany with relation loading
1042
- // Skip when searchString is present (same reason as fetchCollectionForRest)
1043
- // Skip when collection has relations — lateral JOINs are catastrophically
1044
- // slow for large collections (7s+ for 350 rows). The db.select fallback
1045
- // path uses batch relation resolution which is 50x faster.
1046
-
1047
- const tableName = getTableName(table);
1048
-
1049
- const qb = this.getQueryBuilder(tableName);
1050
- const withConfig = this.buildWithConfig(collection);
1051
- const hasRelations = withConfig && Object.keys(withConfig).length > 0;
1052
-
1053
- // Skip db.query path when vectorSearch is present — it doesn't support
1054
- // custom SELECT expressions needed for the _distance column.
1055
- if (qb && !options.searchString && !hasRelations && !options.vectorSearch) {
1056
- try {
1057
- const queryOpts = this.buildDrizzleQueryOptions<M>(
1058
- table, idField, idInfo, options, collectionPath, undefined, scopeCondition
1059
- );
1060
-
1061
-
1062
- const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1063
-
1064
- const rows = (results as Record<string, unknown>[]).map(row =>
1065
- toFlatRow(row, collection, this.registry)
1066
- );
1067
-
1068
- return rows;
1069
- } catch (e) {
1070
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1071
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1072
- logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1073
- }
1074
- if (reachedDatabase(e)) throw e;
1075
- logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
1076
- }
1077
- }
1078
-
1079
- // Fallback: db.select + processRowResults (N+1 for relations)
1080
- // When vectorSearch is present, add _distance to the SELECT.
1081
- let vectorMeta: { orderBy: SQL; filter?: SQL; distanceSelect: SQL } | undefined;
1082
- if (options.vectorSearch) {
1083
- vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1084
- }
1085
-
1086
- // A generated search column is an index in column form; `SELECT *`
1087
- // would ship it to every caller. The projection is undefined — and the
1088
- // SQL therefore unchanged — for any table without one.
1089
- const visible = visibleColumnProjection(getTableColumns(table), collection);
1090
-
1091
- // Relevance, alongside the row, exactly as `_distance` rides along with
1092
- // a vector search. Present only when the collection opted in and the
1093
- // request carried a search string, so a caller can order by it, show
1094
- // it, or blend it with a score of their own.
1095
- const rankSelect = options.searchString
1096
- ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
1097
- : undefined;
1098
-
1099
- // Only when asked: a `ts_headline` per declared field per row.
1100
- const matchesSelect = options.searchString && options.searchExplain
1101
- ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
1102
- : undefined;
1103
-
1104
- let query = vectorMeta
1105
- ? this.db.select({ table_row: (visible ?? table) as never,
1106
- _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1107
- : rankSelect
1108
- ? this.db.select({
1109
- table_row: (visible ?? table) as never,
1110
- _score: rankSelect,
1111
- ...(matchesSelect ? { _matches: matchesSelect } : {})
1112
- }).from(table).$dynamic()
1113
- : (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
1114
- const allConditions: SQL[] = [];
1115
-
1116
- if (scopeCondition) allConditions.push(scopeCondition);
1117
-
1118
- if (options.searchString) {
1119
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1120
- options.searchString, collection.properties, table, collection
1121
- );
1122
- if (searchConditions.length === 0) return [];
1123
- allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
1124
- }
1125
-
1126
- if (options.filter) {
1127
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
1128
- if (filterConditions.length > 0) allConditions.push(...filterConditions);
1129
- }
1130
-
1131
- if (options.logical) {
1132
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
1133
- if (logicalCondition) allConditions.push(logicalCondition);
1134
- }
1135
-
1136
- // Vector distance threshold filter
1137
- if (vectorMeta?.filter) {
1138
- allConditions.push(vectorMeta.filter);
1139
- }
1140
-
1141
- if (allConditions.length > 0) {
1142
- const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
1143
- if (finalCondition) query = query.where(finalCondition);
1144
- }
1145
-
1146
- // Vector search overrides ORDER BY with distance (ascending = closest first)
1147
- const orderExpressions = vectorMeta
1148
- ? [asc(vectorMeta.orderBy), desc(idField)]
1149
- : this.buildOrderExpressions(
1150
- this.resolveOrderKeys(
1151
- table,
1152
- normalizeDriverOrderBy(options.orderBy, options.order) ?? [],
1153
- collection,
1154
- options.searchString
1155
- ),
1156
- idField
1157
- );
1158
- query = query.orderBy(...orderExpressions);
1159
-
1160
- if (options.startAfter) {
1161
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
1162
- if (cursorConditions.length > 0) {
1163
- allConditions.push(...cursorConditions);
1164
- const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
1165
- if (finalCondition) query = query.where(finalCondition);
1166
- }
1167
- }
1168
-
1169
- const limitValue = options.vectorSearch
1170
- ? (options.limit || 10)
1171
- : options.searchString ? (options.limit || 50) : options.limit;
1172
- if (limitValue) query = query.limit(limitValue);
1173
-
1174
- // Offset (numeric pagination)
1175
- if (options.offset && options.offset > 0) query = query.offset(options.offset);
1176
-
1177
- const rawResults = await query;
1178
-
1179
- // When vector search is active, unwrap the nested select shape and
1180
- // attach _distance to each row's values.
1181
- const results = vectorMeta
1182
- ? (rawResults as { table_row: Record<string, unknown>; _distance: unknown }[]).map(r => ({
1183
- ...r.table_row,
1184
- _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
1185
- }))
1186
- // Same nested shape, unwrapped the same way, when a relevance
1187
- // score was selected instead.
1188
- : rankSelect
1189
- ? (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
1190
- ...r.table_row,
1191
- _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
1192
- ...(matchesSelect ? { _matches: r._matches ?? [] } : {})
1193
- }))
1194
- : rawResults as Record<string, unknown>[];
1195
-
1196
- return this.processRowResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
1197
- }
1198
-
1199
- /**
1200
- * Fallback path used when db.query is unavailable.
1201
- *
1202
- * The primary path runs the results through `toFlatRow`, which maps
1203
- * relations from what drizzle already nested — no query per row. This one
1204
- * has no nesting to read, so it resolves relations itself, in batches.
1205
- *
1206
- * Process raw database results into flat rows with relations.
1207
- */
1208
- private async processRowResults<M extends Record<string, unknown>>(
1209
- results: Record<string, unknown>[],
1210
- collection: CollectionConfig,
1211
- collectionPath: string,
1212
- idInfo: { fieldName: string; type: "string" | "number" },
1213
- _databaseId?: string,
1214
- skipRelations = false,
1215
- idInfoArray?: { fieldName: string; type: "string" | "number" }[]
1216
- ): Promise<Record<string, unknown>[]> {
1217
- if (results.length === 0) return [];
1218
-
1219
- // First pass: parse all rows WITHOUT per-row relation queries.
1220
- // We deliberately omit db/registry so parseDataFromServer only does type
1221
- // coercion (dates, numbers, FK→relation stubs for owning relations) and
1222
- // does NOT issue individual SQL queries for inverse relations. The second
1223
- // pass below batch-loads all inverse/many relations in O(1) queries per
1224
- // relation type, avoiding the N+1 that plagued the old path.
1225
- const parsedRows = await Promise.all(results.map(async (rawRow: Record<string, unknown>) => {
1226
- const values = await parseDataFromServer(rawRow as M, collection) as Record<string, unknown>;
1227
- return {
1228
- rawRow,
1229
- values
1230
- };
1231
- }));
1232
-
1233
- if (!skipRelations) {
1234
- // Second pass: batch load missing one-to-one relations
1235
- const resolvedRelations = resolveCollectionRelations(collection);
1236
- const propertyKeys = new Set(Object.keys(collection.properties));
1237
-
1238
- for (const [key, relation] of Object.entries(resolvedRelations)) {
1239
- if (!propertyKeys.has(key) || relation.cardinality !== "one") continue;
1240
-
1241
- const rowsMissingRelation = parsedRows.filter(item => {
1242
- const val = item.values[key];
1243
- if (val == null) return true;
1244
- if (typeof val === "object" && !Array.isArray(val) && (val as Record<string, unknown>).__type === "relation" && (val as Record<string, unknown>).data == null) return true;
1245
- return false;
1246
- });
1247
-
1248
- if (rowsMissingRelation.length === 0) continue;
1249
-
1250
- try {
1251
- const rowIds = rowsMissingRelation.map(item => item.rawRow[idInfo.fieldName] as string | number);
1252
- const relationResults = await this.relationService.batchFetchRelatedEntities(
1253
- collectionPath,
1254
- rowIds,
1255
- key,
1256
- relation
1257
- );
1258
-
1259
- rowsMissingRelation.forEach(item => {
1260
- const id = item.rawRow[idInfo.fieldName] as string | number;
1261
- const relatedRow = relationResults.get(String(id));
1262
- if (relatedRow) {
1263
- item.values[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
1264
- }
1265
- });
1266
- } catch (e) {
1267
- // A relation that failed to load is not a relation that is absent.
1268
- // Without this the request answers 200 with the field quietly
1269
- // missing — and because a Postgres error poisons the surrounding
1270
- // transaction, every later relation in the same request is
1271
- // swallowed too, so one failure becomes a response missing
1272
- // several fields. Same guard the four other catches in this file
1273
- // already use.
1274
- if (reachedDatabase(e)) throw e;
1275
- logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
1276
- }
1277
- }
1278
-
1279
- // Batch load many-cardinality relations (1 query per relation type
1280
- // instead of N queries per row)
1281
- const manyRelations = Object.entries(resolvedRelations)
1282
- .filter(([key, relation]) => propertyKeys.has(key) && relation.cardinality === "many");
1283
-
1284
- for (const [key, relation] of manyRelations) {
1285
- try {
1286
- const rowIds = parsedRows.map(item => item.rawRow[idInfo.fieldName] as string | number);
1287
- const relationResults = await this.relationService.batchFetchRelatedEntitiesMany(
1288
- collectionPath,
1289
- rowIds,
1290
- key,
1291
- relation
1292
- );
1293
-
1294
- parsedRows.forEach(item => {
1295
- const id = String(item.rawRow[idInfo.fieldName]);
1296
- const relatedRows = relationResults.get(id) || [];
1297
- item.values[key] = relatedRows.map(e =>
1298
- createRelationRefWithData(e.id, e.path, e)
1299
- );
1300
- });
1301
- } catch (e) {
1302
- // A relation that failed to load is not a relation that is absent.
1303
- // Without this the request answers 200 with the field quietly
1304
- // missing — and because a Postgres error poisons the surrounding
1305
- // transaction, every later relation in the same request is
1306
- // swallowed too, so one failure becomes a response missing
1307
- // several fields. Same guard the four other catches in this file
1308
- // already use.
1309
- if (reachedDatabase(e)) throw e;
1310
- logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
1311
- }
1312
- }
1313
- }
1314
-
1315
- // Columns only — the address is the consumer's to derive.
1316
- return parsedRows.map(item => item.values);
1317
- }
1318
-
1319
- /**
1320
- * Fetch a collection of rows
1321
- */
1322
- async fetchCollection<M extends Record<string, unknown>>(
1323
- collectionPath: string,
1324
- options: {
1325
- filter?: FilterValues<Extract<keyof M, string>>;
1326
- /**
1327
- * An `or(...)`/`and(...)` group, applied alongside `filter`.
1328
- *
1329
- * `fetchRowsWithConditions` below has always applied this; it was
1330
- * simply absent from this signature, so the only callers that could
1331
- * pass one were the ones that went around this method. Realtime
1332
- * came through here, which is why a subscription filtered by a
1333
- * logical group was pushed every row in the table.
1334
- */
1335
- logical?: LogicalCondition;
1336
- orderBy?: string | OrderByTuple[];
1337
- order?: "desc" | "asc";
1338
- limit?: number;
1339
- offset?: number;
1340
- startAfter?: Record<string, unknown>;
1341
- searchString?: string;
1342
- databaseId?: string;
1343
- vectorSearch?: VectorSearchParams;
1344
- } = {}
1345
- ): Promise<Record<string, unknown>[]> {
1346
- // A nested path is the target collection narrowed by a relation — the
1347
- // same query, one condition heavier. It used to be a separate builder
1348
- // that honoured `limit` and nothing else.
1349
- const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
1350
- if (hop) {
1351
- return this.fetchRowsWithConditions<M>(hop.targetCollection.slug, { ...options,
1352
- relatedTo: hop });
1353
- }
1354
-
1355
- return this.fetchRowsWithConditions<M>(collectionPath, options);
1356
- }
1357
-
1358
- /**
1359
- * Search rows by text
1360
- */
1361
- async searchRows<M extends Record<string, unknown>>(
1362
- collectionPath: string,
1363
- searchString: string,
1364
- options: {
1365
- filter?: FilterValues<Extract<keyof M, string>>;
1366
- /**
1367
- * An `or(...)`/`and(...)` group, applied alongside `filter`.
1368
- *
1369
- * `fetchRowsWithConditions` has always applied one; it was missing
1370
- * from this signature, so a realtime search subscription carrying a
1371
- * group could not pass it on and served every row matching the text
1372
- * that RLS allowed.
1373
- */
1374
- logical?: LogicalCondition;
1375
- orderBy?: string | OrderByTuple[];
1376
- order?: "desc" | "asc";
1377
- limit?: number;
1378
- databaseId?: string;
1379
- /** Ask each row which declared search field matched. */
1380
- searchExplain?: boolean;
1381
- } = {}
1382
- ): Promise<Record<string, unknown>[]> {
1383
- return this.fetchRowsWithConditions<M>(collectionPath, {
1384
- ...options,
1385
- searchString
1386
- });
1387
- }
1388
-
1389
- /**
1390
- * Count rows in a collection
1391
- */
1392
- async count<M extends Record<string, unknown>>(
1393
- collectionPath: string,
1394
- options: {
1395
- filter?: FilterValues<Extract<keyof M, string>>;
1396
- logical?: LogicalCondition;
1397
- searchString?: string;
1398
- databaseId?: string;
1399
- /**
1400
- * Only the `threshold` half of a vector search narrows a count: the
1401
- * distance ordering and the `_distance` column change which rows
1402
- * come back first, not how many there are. Omitting it here left
1403
- * `meta.total` counting rows the threshold had excluded, so a
1404
- * request that was served three rows was told there were nine.
1405
- */
1406
- vectorSearch?: VectorSearchParams;
1407
- } = {}
1408
- ): Promise<number> {
1409
- // Same narrowing as the listing — and, unlike the count it replaces,
1410
- // the same `filter` and `searchString` too, so `total` describes the
1411
- // rows that were actually served.
1412
- const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : undefined;
1413
- const effectivePath = hop ? hop.targetCollection.slug : collectionPath;
1414
-
1415
- const collection = getCollectionByPath(effectivePath, this.registry);
1416
- const table = getTableForCollection(collection, this.registry);
1417
-
1418
- let query = this.db.select({ count: count() }).from(table).$dynamic();
1419
- const allConditions: SQL[] = [];
1420
-
1421
- if (hop) allConditions.push(this.buildRelationScope(hop));
1422
-
1423
- if (options.searchString) {
1424
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1425
- options.searchString, collection.properties, table, collection
1426
- );
1427
- if (searchConditions.length === 0) return 0;
1428
- allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
1429
- }
1430
-
1431
- if (options.filter) {
1432
- const filterConditions = this.buildFilterConditions(options.filter, table, effectivePath);
1433
- if (filterConditions.length > 0) allConditions.push(...filterConditions);
1434
- }
1435
-
1436
- if (options.logical) {
1437
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(
1438
- options.logical, table, effectivePath, this.filterContext(effectivePath, table)
1439
- );
1440
- if (logicalCondition) allConditions.push(logicalCondition);
1441
- }
1442
-
1443
- // A `threshold` genuinely narrows the row set on the fetch path, and
1444
- // this count did not apply it — so a similarity-filtered listing
1445
- // reported the size of the *unfiltered* set, and `hasMore` stayed true
1446
- // over pages that were already empty. Only the threshold narrows it:
1447
- // the ORDER BY and the `_distance` projection change which rows come
1448
- // first and what rides along with them, not how many there are.
1449
- if (options.vectorSearch) {
1450
- // Built for any vector search rather than only a thresholded one,
1451
- // because this is also where an unknown or non-vector
1452
- // `vector_search` property is refused with a 400. Without a
1453
- // threshold it contributes no filter, so the count is unchanged and
1454
- // what is gained is that `/count` refuses the request the listing
1455
- // refuses instead of answering it with a number.
1456
- const vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1457
- if (vectorMeta.filter) allConditions.push(vectorMeta.filter);
1458
- }
1459
-
1460
- if (allConditions.length > 0) {
1461
- const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
1462
- if (finalCondition) query = query.where(finalCondition);
1463
- }
1464
-
1465
- const result = await query;
1466
- return Number(result[0]?.count || 0);
1467
- }
1468
-
1469
- /**
1470
- * `count`/`sum`/`avg`/`min`/`max`, optionally grouped.
1471
- *
1472
- * The gap this fills is narrow and constant: every dashboard wants "revenue
1473
- * by status" and "orders per day", and without it the options were a custom
1474
- * function holding hand-written SQL, or fetching every row and reducing in
1475
- * JavaScript — which is wrong at any size that matters, and silently wrong
1476
- * under a `limit`.
1477
- *
1478
- * It runs through the same request-scoped handle as every other read, so
1479
- * **RLS applies to the rows being aggregated**. That is the property worth
1480
- * protecting here: an aggregate is an effective way to read data you cannot
1481
- * select, and `count(*)` over a table whose policies would return nothing
1482
- * has to be zero.
1483
- */
1484
- async aggregate<M extends Record<string, unknown>>(
1485
- collectionPath: string,
1486
- options: {
1487
- aggregates: { fn: "count" | "sum" | "avg" | "min" | "max"; field?: string; alias: string }[];
1488
- groupBy?: string[];
1489
- filter?: FilterValues<Extract<keyof M, string>>;
1490
- logical?: LogicalCondition;
1491
- searchString?: string;
1492
- limit?: number;
1493
- }
1494
- ): Promise<Record<string, unknown>[]> {
1495
- const collection = getCollectionByPath(collectionPath, this.registry);
1496
- const table = getTableForCollection(collection, this.registry);
1497
- const columns = getTableColumns(table);
1498
-
1499
- const columnFor = (field: string, forWhat: string): AnyPgColumn => {
1500
- const column = columns[field as keyof typeof columns] as AnyPgColumn | undefined;
1501
- if (!column) {
1502
- throw ApiError.badRequest(
1503
- `Unknown field '${field}' in ${forWhat}. Valid fields: ${Object.keys(columns).sort().join(", ")}`,
1504
- "UNKNOWN_AGGREGATE_FIELD"
1505
- );
1506
- }
1507
- return column;
1508
- };
1509
-
1510
- const selection: Record<string, SQL> = {};
1511
-
1512
- for (const aggregate of options.aggregates) {
1513
- if (aggregate.fn === "count" && !aggregate.field) {
1514
- selection[aggregate.alias] = sql`count(*)`;
1515
- continue;
1516
- }
1517
- const column = columnFor(aggregate.field as string, `${aggregate.fn}()`);
1518
- switch (aggregate.fn) {
1519
- case "count": selection[aggregate.alias] = sql`count(${column})`; break;
1520
- // Cast through numeric so what comes back is a string this
1521
- // method parses, rather than a float whose precision depends on
1522
- // the column type — `avg` over an integer column is otherwise
1523
- // one shape here and another there.
1524
- case "sum": selection[aggregate.alias] = sql`sum(${column})::numeric`; break;
1525
- case "avg": selection[aggregate.alias] = sql`avg(${column})::numeric`; break;
1526
- case "min": selection[aggregate.alias] = sql`min(${column})`; break;
1527
- case "max": selection[aggregate.alias] = sql`max(${column})`; break;
1528
- }
1529
- }
1530
-
1531
- const groupColumns = (options.groupBy ?? []).map(field => ({
1532
- field,
1533
- column: columnFor(field, "groupBy")
1534
- }));
1535
- for (const group of groupColumns) {
1536
- selection[group.field] = sql`${group.column}`;
1537
- }
1538
-
1539
- let query = this.db.select(selection).from(table).$dynamic();
1540
-
1541
- const conditions: SQL[] = [];
1542
- if (options.searchString) {
1543
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1544
- options.searchString, collection.properties, table, collection
1545
- );
1546
- // No searchable field means no row matches — the same impossible
1547
- // WHERE the listing uses, rather than an unfiltered aggregate.
1548
- if (searchConditions.length === 0) return [];
1549
- conditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions) as SQL);
1550
- }
1551
- if (options.filter) {
1552
- conditions.push(...this.buildFilterConditions(options.filter, table, collectionPath));
1553
- }
1554
- if (options.logical) {
1555
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(
1556
- options.logical, table, collectionPath, this.filterContext(collectionPath, table)
1557
- );
1558
- if (logicalCondition) conditions.push(logicalCondition);
1559
- }
1560
- if (conditions.length > 0) {
1561
- const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(conditions);
1562
- if (finalCondition) query = query.where(finalCondition);
1563
- }
1564
-
1565
- if (groupColumns.length > 0) {
1566
- query = query.groupBy(...groupColumns.map(g => g.column));
1567
- // Bounded for the same reason a listing is: grouping by a
1568
- // high-cardinality column is a whole table's worth of rows in one
1569
- // response.
1570
- if (options.limit) query = query.limit(options.limit);
1571
- }
1572
-
1573
- const rows = await query as Record<string, unknown>[];
1574
-
1575
- // `count`, `sum` and `avg` arrive as strings: Postgres returns bigint
1576
- // and numeric that way because they do not fit a JS number in general.
1577
- // They do fit for every aggregate anyone puts on a dashboard, and a
1578
- // caller handed `"12"` where they expected `12` has to find that out
1579
- // for themselves. Parsed once, here.
1580
- const numericAliases = new Set(
1581
- options.aggregates.filter(a => a.fn === "count" || a.fn === "sum" || a.fn === "avg").map(a => a.alias)
1582
- );
1583
- return rows.map(row => {
1584
- const out: Record<string, unknown> = { ...row };
1585
- for (const alias of numericAliases) {
1586
- if (out[alias] === null || out[alias] === undefined) continue;
1587
- const parsed = Number(out[alias]);
1588
- if (!Number.isNaN(parsed)) out[alias] = parsed;
1589
- }
1590
- return out;
1591
- });
1592
- }
1593
-
1594
- /**
1595
- * Check if a field value is unique
1596
- */
1597
- async checkUniqueField(
1598
- collectionPath: string,
1599
- fieldName: string,
1600
- value: unknown,
1601
- excludeEntityId?: string,
1602
- _databaseId?: string
1603
- ): Promise<boolean> {
1604
- if (value === undefined || value === null) return true;
1605
-
1606
- const collection = getCollectionByPath(collectionPath, this.registry);
1607
- const table = getTableForCollection(collection, this.registry);
1608
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1609
- const idInfo = idInfoArray[0];
1610
- const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1611
- const field = table[fieldName as keyof typeof table] as AnyPgColumn;
1612
-
1613
- if (!field) return true;
1614
-
1615
- const parsedExcludeId = excludeEntityId ? parseIdValues(excludeEntityId, idInfoArray)[idInfo.fieldName] : undefined;
1616
- const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(
1617
- field,
1618
- value,
1619
- idField,
1620
- parsedExcludeId
1621
- );
1622
-
1623
- const result = await this.db
1624
- .select({ count: count() })
1625
- .from(table)
1626
- .where(and(...conditions));
1627
-
1628
- const countResult = Number(result[0]?.count || 0);
1629
- return countResult === 0;
1630
- }
1631
-
1632
- /**
1633
- * Get the RelationService instance for external use
1634
- */
1635
- getRelationService(): RelationService {
1636
- return this.relationService;
1637
- }
1638
-
1639
- // =============================================================
1640
- // REST API INCLUDE-AWARE METHODS
1641
- // =============================================================
1642
-
1643
- /**
1644
- * Fetch a collection of rows with optional relation includes.
1645
- * When `include` is provided, only the specified relations are populated
1646
- * with full row data (not just { id, path, __type }).
1647
- * When `include` is absent, no relation queries are made (fast path).
1648
- *
1649
- * @param include - Array of relation keys to populate, or ["*"] for all
1650
- */
1651
- async fetchCollectionForRest<M extends Record<string, unknown>>(
1652
- collectionPath: string,
1653
- options: {
1654
- filter?: FilterValues<Extract<keyof M, string>>;
1655
- /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
1656
- logical?: LogicalCondition;
1657
- orderBy?: string | OrderByTuple[];
1658
- order?: "desc" | "asc";
1659
- limit?: number;
1660
- offset?: number;
1661
- startAfter?: Record<string, unknown>;
1662
- searchString?: string;
1663
- databaseId?: string;
1664
- vectorSearch?: VectorSearchParams;
1665
- /** Narrow to the rows reachable from a parent through a relation. */
1666
- relatedTo?: NestedPathHop;
1667
- } = {},
1668
- include?: string[]
1669
- ): Promise<Record<string, unknown>[]> {
1670
- // Resolve a nested path here rather than at the route, so `include`,
1671
- // `offset` and the rest reach a child listing by the same route they
1672
- // reach a root one.
1673
- if (isNestedPath(collectionPath)) {
1674
- const hop = resolveNestedPath(collectionPath, this.registry);
1675
- if (hop) {
1676
- return this.fetchCollectionForRest<M>(
1677
- hop.targetCollection.slug, { ...options,
1678
- relatedTo: hop }, include
1679
- );
1680
- }
1681
- }
1682
-
1683
- const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : undefined;
1684
- const collection = getCollectionByPath(collectionPath, this.registry);
1685
- const table = getTableForCollection(collection, this.registry);
1686
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1687
- const idInfo = idInfoArray[0];
1688
- const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1689
-
1690
- // Primary path: use db.query.findMany
1691
- // NOTE: Skip db.query path when searchString is present because
1692
- // Drizzle's relational query API doesn't properly apply raw SQL
1693
- // ILIKE conditions — the fallback db.select path handles them correctly.
1694
-
1695
- const tableName = getTableName(table);
1696
-
1697
- const qb = this.getQueryBuilder(tableName);
1698
- // Skip db.query path when vectorSearch is present — needs custom SELECT
1699
- if (qb && !options.searchString && !options.vectorSearch) {
1700
- try {
1701
- const withConfig = (include && include.length > 0)
1702
- ? this.buildWithConfig(collection, include)
1703
- : undefined;
1704
-
1705
- const queryOpts = this.buildDrizzleQueryOptions<M>(
1706
- table, idField, idInfo, options, collectionPath, withConfig, scopeCondition
1707
- );
1708
-
1709
-
1710
- const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
1711
-
1712
- const restRows = (results as Record<string, unknown>[]).map(row =>
1713
- toRestRow(row, collection, this.registry)
1714
- );
1715
-
1716
- // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1717
- await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
1718
-
1719
- return restRows;
1720
- } catch (e) {
1721
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1722
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1723
- logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1724
- }
1725
- if (reachedDatabase(e)) throw e;
1726
- logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
1727
- }
1728
- }
1729
-
1730
- // Fallback: fetch base rows without relations
1731
- const rows = await this.fetchRowsWithConditionsRaw<M>(collectionPath, options);
1732
-
1733
- if (!include || include.length === 0) {
1734
- return rows;
1735
- }
1736
-
1737
- // Fallback relation loading via batch
1738
- const resolvedRelations = resolveCollectionRelations(collection);
1739
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
1740
- const shouldInclude = (key: string) =>
1741
- include[0] === "*" || include.includes(key);
1742
-
1743
- const rowIds = rows.map(e => e[idInfo.fieldName] as string | number);
1744
-
1745
- for (const [key, relation] of Object.entries(resolvedRelations)) {
1746
- if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "one") continue;
1747
- try {
1748
- const batchResults = await this.relationService.batchFetchRelatedEntities(
1749
- collectionPath, rowIds, key, relation
1750
- );
1751
- for (const row of rows) {
1752
- const eid = row[idInfo.fieldName] as string | number;
1753
- const related = batchResults.get(String(eid));
1754
- if (related) {
1755
- (row as Record<string, unknown>)[key] = { ...related.values };
1756
- }
1757
- }
1758
- } catch (e) {
1759
- // A relation that failed to load is not a relation that is absent.
1760
- // Without this the request answers 200 with the field quietly
1761
- // missing — and because a Postgres error poisons the surrounding
1762
- // transaction, every later relation in the same request is
1763
- // swallowed too, so one failure becomes a response missing
1764
- // several fields. Same guard the four other catches in this file
1765
- // already use.
1766
- if (reachedDatabase(e)) throw e;
1767
- logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
1768
- }
1769
- }
1770
-
1771
- for (const [key, relation] of Object.entries(resolvedRelations)) {
1772
- if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "many") continue;
1773
- try {
1774
- const batchResults = await this.batchFetchManyRelatedRows(
1775
- collectionPath, rowIds, key
1776
- );
1777
- for (const row of rows) {
1778
- const eid = row[idInfo.fieldName] as string | number;
1779
- const relatedList = batchResults.get(String(eid)) || [];
1780
- (row as Record<string, unknown>)[key] = relatedList;
1781
- }
1782
- } catch (e) {
1783
- // A relation that failed to load is not a relation that is absent.
1784
- // Without this the request answers 200 with the field quietly
1785
- // missing — and because a Postgres error poisons the surrounding
1786
- // transaction, every later relation in the same request is
1787
- // swallowed too, so one failure becomes a response missing
1788
- // several fields. Same guard the four other catches in this file
1789
- // already use.
1790
- if (reachedDatabase(e)) throw e;
1791
- logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
1792
- }
1793
- }
1794
-
1795
- return rows;
1796
- }
1797
-
1798
- /**
1799
- * Fetch a single row with optional relation includes for REST API.
1800
- */
1801
- async fetchOneForRest<M extends Record<string, unknown>>(
1802
- collectionPath: string,
1803
- id: string | number,
1804
- include?: string[],
1805
- databaseId?: string
1806
- ): Promise<Record<string, unknown> | null> {
1807
- if (!await this.isAddressableUnder(collectionPath, id)) return null;
1808
-
1809
- const collection = getCollectionByPath(collectionPath, this.registry);
1810
- const table = getTableForCollection(collection, this.registry);
1811
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1812
- const idInfo = idInfoArray[0];
1813
- const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1814
-
1815
- // See `fetchOne`: an unaddressable id is a 404, not a database error.
1816
- if (!idCanAddressTable(id, table, idInfoArray)) return null;
1817
-
1818
- const parsedIdObj = parseIdValues(id, idInfoArray);
1819
- const parsedId = parsedIdObj[idInfo.fieldName];
1820
-
1821
- // Primary path: use db.query.findFirst
1822
-
1823
- const tableName = getTableName(table);
1824
-
1825
- const qb = this.getQueryBuilder(tableName);
1826
- if (qb) {
1827
- try {
1828
- const withConfig = (include && include.length > 0)
1829
- ? this.buildWithConfig(collection, include)
1830
- : undefined;
1831
-
1832
-
1833
- const row = await qb.findFirst({
1834
- where: eq(idField, parsedId),
1835
- ...(withConfig ? { with: withConfig } : {})
1836
- } as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
1837
-
1838
- if (!row) return null;
1839
-
1840
- const restRow = toRestRow(row, collection, this.registry);
1841
-
1842
- // Drizzle relational query API doesn't resolve joinPath relations, fetch manually
1843
- await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
1844
-
1845
- return restRow;
1846
- } catch (e) {
1847
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1848
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1849
- logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1850
- }
1851
- if (reachedDatabase(e)) throw e;
1852
- logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
1853
- }
1854
- }
1855
-
1856
- // Fallback: db.select + N+1 relation loading
1857
- const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
1858
- const result = await this.db
1859
- .select(visibleOne as never)
1860
- .from(table)
1861
- .where(eq(idField, parsedId))
1862
- .limit(1);
1863
-
1864
- if (result.length === 0) return null;
1865
-
1866
- const flatEntity: Record<string, unknown> = { ...(result[0] as Record<string, unknown>) };
1867
-
1868
- if (!include || include.length === 0) {
1869
- return flatEntity;
1870
- }
1871
-
1872
- // Fallback relation population
1873
- const resolvedRelations = resolveCollectionRelations(collection);
1874
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
1875
- const shouldInclude = (key: string) =>
1876
- include[0] === "*" || include.includes(key);
1877
-
1878
- for (const [key, relation] of Object.entries(resolvedRelations)) {
1879
- if (!propertyKeys.has(key) || !shouldInclude(key)) continue;
1880
-
1881
- try {
1882
- const relatedRows = await this.relationService.fetchRelatedEntities(
1883
- collectionPath, parsedId, key, {}
1884
- );
1885
-
1886
- if (relation.cardinality === "one") {
1887
- if (relatedRows.length > 0) {
1888
- const e = relatedRows[0];
1889
- flatEntity[key] = { id: e.id,
1890
- ...e.values };
1891
- }
1892
- } else {
1893
- flatEntity[key] = relatedRows.map(e => ({
1894
- id: e.id,
1895
- ...e.values
1896
- }));
1897
- }
1898
- } catch (e) {
1899
- // A relation that failed to load is not a relation that is absent.
1900
- // Without this the request answers 200 with the field quietly
1901
- // missing — and because a Postgres error poisons the surrounding
1902
- // transaction, every later relation in the same request is
1903
- // swallowed too, so one failure becomes a response missing
1904
- // several fields. Same guard the four other catches in this file
1905
- // already use.
1906
- if (reachedDatabase(e)) throw e;
1907
- logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
1908
- }
1909
- }
1910
-
1911
- return flatEntity;
1912
- }
1913
-
1914
- /**
1915
- * Fetch raw rows without any relation processing (for REST fast path)
1916
- */
1917
- private async fetchRowsWithConditionsRaw<M extends Record<string, unknown>>(
1918
- collectionPath: string,
1919
- options: {
1920
- filter?: FilterValues<Extract<keyof M, string>>;
1921
- /**
1922
- * An `or(...)`/`and(...)` group, applied alongside `filter`.
1923
- *
1924
- * Declared *and applied*, because this is the path every REST search
1925
- * and vector read takes: `fetchCollectionForRest` skips `db.query`
1926
- * whenever a `searchString` or a `vectorSearch` is present. The
1927
- * group arrived here on `options` from the very beginning and was
1928
- * simply never read, so `?searchString=x&or=(...)` served every row
1929
- * matching `x` that RLS allowed — while `count` (which does apply
1930
- * it) reported the narrowed total, so `meta.total` and `data`
1931
- * described different sets of rows.
1932
- */
1933
- logical?: LogicalCondition;
1934
- orderBy?: string | OrderByTuple[];
1935
- order?: "desc" | "asc";
1936
- limit?: number;
1937
- offset?: number;
1938
- startAfter?: Record<string, unknown>;
1939
- searchString?: string;
1940
- searchExplain?: boolean;
1941
- vectorSearch?: VectorSearchParams;
1942
- relatedTo?: NestedPathHop;
1943
- } = {}
1944
- ): Promise<Record<string, unknown>[]> {
1945
- const collection = getCollectionByPath(collectionPath, this.registry);
1946
- const table = getTableForCollection(collection, this.registry);
1947
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
1948
- const idInfo = idInfoArray[0];
1949
- const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1950
-
1951
- let vectorMeta: { orderBy: SQL; filter?: SQL; distanceSelect: SQL } | undefined;
1952
- if (options.vectorSearch) {
1953
- vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1954
- }
1955
-
1956
- // A generated search column is an index in column form; `SELECT *`
1957
- // would ship it to every caller. The projection is undefined — and the
1958
- // SQL therefore unchanged — for any table without one.
1959
- const visible = visibleColumnProjection(getTableColumns(table), collection);
1960
-
1961
- // Relevance, alongside the row, exactly as `_distance` rides along with
1962
- // a vector search. Present only when the collection opted in and the
1963
- // request carried a search string, so a caller can order by it, show
1964
- // it, or blend it with a score of their own.
1965
- const rankSelect = options.searchString
1966
- ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
1967
- : undefined;
1968
-
1969
- // Only when asked: a `ts_headline` per declared field per row.
1970
- const matchesSelect = options.searchString && options.searchExplain
1971
- ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
1972
- : undefined;
1973
-
1974
- let query = vectorMeta
1975
- ? this.db.select({ table_row: (visible ?? table) as never,
1976
- _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1977
- : rankSelect
1978
- ? this.db.select({
1979
- table_row: (visible ?? table) as never,
1980
- _score: rankSelect,
1981
- ...(matchesSelect ? { _matches: matchesSelect } : {})
1982
- }).from(table).$dynamic()
1983
- : (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
1984
- const allConditions: SQL[] = [];
1985
-
1986
- if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
1987
-
1988
- if (options.searchString) {
1989
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1990
- options.searchString, collection.properties, table, collection
1991
- );
1992
- if (searchConditions.length === 0) return [];
1993
- allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
1994
- }
1995
-
1996
- if (options.filter) {
1997
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
1998
- if (filterConditions.length > 0) allConditions.push(...filterConditions);
1999
- }
2000
-
2001
- if (options.logical) {
2002
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
2003
- if (logicalCondition) allConditions.push(logicalCondition);
2004
- }
2005
-
2006
- if (vectorMeta?.filter) {
2007
- allConditions.push(vectorMeta.filter);
2008
- }
2009
-
2010
- if (allConditions.length > 0) {
2011
- const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
2012
- if (finalCondition) query = query.where(finalCondition);
2013
- }
2014
-
2015
- // Vector search overrides ORDER BY with distance (ascending = closest first)
2016
- const orderExpressions = vectorMeta
2017
- ? [asc(vectorMeta.orderBy), desc(idField)]
2018
- : this.buildOrderExpressions(
2019
- this.resolveOrderKeys(
2020
- table,
2021
- normalizeDriverOrderBy(options.orderBy, options.order) ?? [],
2022
- collection,
2023
- options.searchString
2024
- ),
2025
- idField
2026
- );
2027
- query = query.orderBy(...orderExpressions);
2028
-
2029
- const limitValue = options.vectorSearch
2030
- ? (options.limit || 10)
2031
- : options.searchString ? (options.limit || 50) : options.limit;
2032
- if (limitValue) query = query.limit(limitValue);
2033
-
2034
- // Offset (numeric pagination)
2035
- if (options.offset && options.offset > 0) query = query.offset(options.offset);
2036
-
2037
- const rawResults = await query;
2038
-
2039
- if (vectorMeta) {
2040
- return (rawResults as { table_row: Record<string, unknown>; _distance: unknown }[]).map(r => ({
2041
- ...r.table_row,
2042
- _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
2043
- }));
2044
- }
2045
-
2046
- if (rankSelect) {
2047
- return (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
2048
- ...r.table_row,
2049
- _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
2050
- ...(matchesSelect ? { _matches: r._matches ?? [] } : {})
2051
- }));
2052
- }
2053
-
2054
- return rawResults as Record<string, unknown>[];
2055
- }
2056
-
2057
- /**
2058
- * Check if the Drizzle instance has the relational query API available
2059
- * for a given collection path.
2060
- * Note: Primary path now uses inline `getQueryBuilder()` checks.
2061
- */
2062
- private hasDrizzleQueryAPI(collectionPath: string): boolean {
2063
-
2064
- const qb = this.getQueryBuilder("__probe__");
2065
- if (!qb) {
2066
- // If getQueryBuilder returns undefined even for a probe, query API is not available
2067
- return false;
2068
- }
2069
- const collection = getCollectionByPath(collectionPath, this.registry);
2070
- const table = getTableForCollection(collection, this.registry);
2071
- const tableName = getTableName(table);
2072
- return !!this.getQueryBuilder(tableName);
2073
- }
2074
-
2075
- /**
2076
- * Fallback path used when db.query is unavailable.
2077
- * The primary path uses db.query.findMany with `with` config, which
2078
- * loads all relations in a single query.
2079
- *
2080
- * Batch fetch many-to-many related rows for multiple parent IDs.
2081
- * Groups results by parent ID to avoid N+1.
2082
- */
2083
- private async batchFetchManyRelatedRows(
2084
- parentCollectionPath: string,
2085
- parentIds: (string | number)[],
2086
- relationKey: string
2087
- ): Promise<Map<string, Record<string, unknown>[]>> {
2088
- if (parentIds.length === 0) return new Map();
2089
-
2090
- // Resolve the relation definition so we can use the true batch method
2091
- const collection = getCollectionByPath(parentCollectionPath, this.registry);
2092
- const resolvedRelations = resolveCollectionRelations(collection);
2093
- const relation = resolvedRelations[relationKey];
2094
-
2095
- if (!relation) {
2096
- logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
2097
- return new Map();
2098
- }
2099
-
2100
- // Delegate to RelationService.batchFetchRelatedEntitiesMany which
2101
- // uses a single SQL query with IN(...) — O(1) instead of O(N).
2102
- // RelationService returns RelatedRow shapes (id + path + values) — flatten to plain rows here.
2103
- const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(
2104
- parentCollectionPath,
2105
- parentIds,
2106
- relationKey,
2107
- relation
2108
- );
2109
- const flatMap = new Map<string, Record<string, unknown>[]>();
2110
- for (const [key, rows] of entityMap) {
2111
- flatMap.set(key, rows.map(e => ({ ...e.values, id: e.id })));
2112
- }
2113
- return flatMap;
2114
- }
2115
- }