@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- 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
|
-
}
|