import { SQL } from "drizzle-orm"; import { PgTable } from "drizzle-orm/pg-core"; import { FilterValues, OrderByTuple, LogicalCondition } from "@rebasepro/types"; import type { VectorSearchParams } from "@rebasepro/types"; import { RelationService } from "./RelationService"; import { DrizzleClient } from "../interfaces"; import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry"; import { type NestedPathHop } from "./nested-path"; /** * Service for handling all row read operations. * Handles fetching, searching, counting, and filtering rows. */ export declare class FetchService { private db; private registry; private relationService; constructor(db: DrizzleClient, registry: PostgresCollectionRegistry); /** * Get the relational query builder for a given table name. * Safely narrows the DrizzleClient union type to access db.query[tableName]. */ private getQueryBuilder; /** * The context the condition builder needs to compile a filter key that is * not a column name outright. * * Two such keys. An owning relation's key resolves through the collection's * relations to its foreign-key column; a relation whose link lives on the * target table or in a junction resolves to a correlated `EXISTS`, which * needs the registry to reach that other table and this table's key column * to correlate back. * * Looked up rather than passed: every read path already has the path, only * some have the collection, and a path that names no registered collection * (a nested/derived one) is not an error here — the builder simply falls * back to guessing the default key shapes, and a relation filter it cannot * compile stays unresolvable and so fails closed. */ private filterContext; /** * The table column this collection's rows are keyed by, or `undefined`. * * `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no * resolvable key is not an error on the filter path — it only means the * relation filters that would correlate on it cannot be compiled, which * the builder already handles by failing that field closed. */ private resolveIdColumn; /** * Build filter conditions from FilterValues * Delegates to DrizzleConditionBuilder.buildFilterConditions */ buildFilterConditions>(filter: FilterValues>, table: PgTable, collectionPath: string): SQL[]; /** * Resolves the correct Drizzle column for sorting. * Automatically maps owning relation property keys to their underlying foreign key column. * * The relation's own `localKey` is the authority for that foreign key, not * `_id`. The default local key comes from `generateForeignKeyName`, * which snake-cases *and singularises* — `userProfile` → `user_profile_id`, * `users` → `user_id` — and an author can override it outright. A wrong * guess resolves to nothing, the caller drops the `ORDER BY`, and the rows * come back in whatever order Postgres pleases: paging over that repeats * and skips rows rather than erroring. The guesses stay, last, for a * caller that hands over no collection to resolve against. */ /** * The ORDER BY target, which may be relevance rather than a column. * * `_score` is only meaningful for a collection that declared a `search` * block *and* for a request that carried a search string — ranking rows * against no query ranks them all at zero. Outside those two conditions it * is an unknown field and gets the same 400 as any other typo, which is the * behaviour that matters: a sort that is silently dropped returns 200 with * rows in arbitrary order, and paging over that repeats and skips rows. */ static readonly SCORE_FIELD = "_score"; private resolveOrderTarget; /** * The aggregate a sort key names, as an expression, or `undefined` if the * key is not one. * * `cursorId` builds the same expression pinned to the cursor row — see * {@link DrizzleConditionBuilder.buildRelationAggregateExpression}. * * A key that *parses* as an aggregate but names no relation, or a column * the target does not have, throws rather than falling through to the * column path. Falling through would report `min(applications.created_at)` * as an unknown column and list the columns of the wrong table. */ private resolveAggregateOrderTarget; /** * Resolve every sort key to the expression it orders by, in order of * significance. * * A key that resolves to nothing is dropped rather than skipping the rest: * `resolveOrderByField` only *returns* undefined under the lenient * unknown-field mode, where dropping is the configured answer, and dropping * one key of several still honours the ones that did resolve. */ private resolveOrderKeys; /** * The full `ORDER BY`: the caller's keys, then the id. * * The id is always last and always descending. It is not decoration — it is * what makes the ordering *total*, and a cursor over a non-total order * repeats and skips rows among the ties. Every keyset comparison built by * {@link buildCursorConditions} ends on the same `id DESC`, and the two have * to agree: they did not, and an ascending sort paged with `id >` against an * `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from * every page after the first. * * Where the NULLs go is written out rather than inherited. Postgres already * defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this * changes no query — but {@link buildKeysetComparison} encodes that exact * placement, and an invariant two functions depend on should be stated in * both rather than assumed in one. It matters most for the keys that are * *always* nullable: an aggregate over a relation is NULL for every row the * relation reaches nothing from, which is precisely the "nobody waiting" * end of a queue. */ private buildOrderExpressions; private resolveOrderByField; /** * Build the `with` config for Drizzle's relational query API. * Converts collection relations to a Drizzle-compatible `with` object. * * When `include` is provided, only those relations are loaded. * When `include` is absent, ALL relations are loaded (the admin path). * * Automatically detects many-to-many junction tables and nests * the target relation so actual row data is returned. */ private buildWithConfig; /** * Get the Drizzle relation name on the junction table that points to the actual target row. * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags). */ private getJunctionTargetRelationName; /** * Post-fetch joinPath relations for a single flat row. * joinPath relations cannot be expressed via Drizzle's `with` config, * so they must be loaded separately after the primary query. */ private resolveJoinPathRelations; /** * Resolves joinPath relations for raw REST rows and directly injects them. * Uses RelationService to query the database and maps results back to the flattened objects. */ private resolveJoinPathRelationsBatchRest; /** * Build db.query-compatible options from standard fetch options. * Handles filter, search, orderBy, limit, and cursor-based pagination. */ private buildDrizzleQueryOptions; /** * Extract cursor pagination conditions from startAfter options. * * "Every row that sorts after this one", written out as a comparison over * the same keys the `ORDER BY` uses and ending on the same `id DESC`. With * one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with * several it nests, each key's tie handing the decision to the next. */ private buildCursorConditions; /** * "Sorts strictly after the cursor row", over `keys` and then the id. * * Built by recursion rather than as a row-value comparison — `(a, b) > (x, y)` * would be shorter, but it is only correct when every key runs the same * direction, and `roles ASC, created_at DESC` is exactly the case this * exists to serve. * * NULLs are compared by the rule Postgres sorts them under (last ascending, * first descending) rather than by `>`/`<`, which answer *unknown* against * NULL and therefore match nothing. Ordering by a nullable column and paging * used to drop every row whose sort value was NULL from page two onward. */ private buildKeysetComparison; /** * Compile "rows reachable from this parent" into a `WHERE` condition on the * target table, so a nested listing can run as an ordinary collection query. */ private buildRelationScope; /** * Whether `id` is actually reachable at `collectionPath`. * * Trivially true for a root path. For a nested one it is a real question: * the path resolves to the target collection, and matching on the primary * key alone made the parent segment decorative — `authors/1/posts/43` * returned post 43 whoever wrote it, and the REST layer's delete then * deleted it. A row that is not under this parent is reported as absent, * which is what a caller addressing it through the parent should see. */ private isAddressableUnder; /** * Fetch a single row by ID */ fetchOne>(collectionPath: string, id: string | number, databaseId?: string): Promise | undefined>; /** * Unified method to fetch rows with optional search functionality */ fetchRowsWithConditions>(collectionPath: string, options?: { filter?: FilterValues>; orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record; searchString?: string; searchExplain?: boolean; databaseId?: string; vectorSearch?: VectorSearchParams; logical?: LogicalCondition; /** Narrow to the rows reachable from a parent through a relation. */ relatedTo?: NestedPathHop; }): Promise[]>; /** * Fallback path used when db.query is unavailable. * * The primary path runs the results through `toFlatRow`, which maps * relations from what drizzle already nested — no query per row. This one * has no nesting to read, so it resolves relations itself, in batches. * * Process raw database results into flat rows with relations. */ private processRowResults; /** * Fetch a collection of rows */ fetchCollection>(collectionPath: string, options?: { filter?: FilterValues>; /** * An `or(...)`/`and(...)` group, applied alongside `filter`. * * `fetchRowsWithConditions` below has always applied this; it was * simply absent from this signature, so the only callers that could * pass one were the ones that went around this method. Realtime * came through here, which is why a subscription filtered by a * logical group was pushed every row in the table. */ logical?: LogicalCondition; orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record; searchString?: string; databaseId?: string; vectorSearch?: VectorSearchParams; }): Promise[]>; /** * Search rows by text */ searchRows>(collectionPath: string, searchString: string, options?: { filter?: FilterValues>; /** * An `or(...)`/`and(...)` group, applied alongside `filter`. * * `fetchRowsWithConditions` has always applied one; it was missing * from this signature, so a realtime search subscription carrying a * group could not pass it on and served every row matching the text * that RLS allowed. */ logical?: LogicalCondition; orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; limit?: number; databaseId?: string; /** Ask each row which declared search field matched. */ searchExplain?: boolean; }): Promise[]>; /** * Count rows in a collection */ count>(collectionPath: string, options?: { filter?: FilterValues>; logical?: LogicalCondition; searchString?: string; databaseId?: string; /** * Only the `threshold` half of a vector search narrows a count: the * distance ordering and the `_distance` column change which rows * come back first, not how many there are. Omitting it here left * `meta.total` counting rows the threshold had excluded, so a * request that was served three rows was told there were nine. */ vectorSearch?: VectorSearchParams; }): Promise; /** * `count`/`sum`/`avg`/`min`/`max`, optionally grouped. * * The gap this fills is narrow and constant: every dashboard wants "revenue * by status" and "orders per day", and without it the options were a custom * function holding hand-written SQL, or fetching every row and reducing in * JavaScript — which is wrong at any size that matters, and silently wrong * under a `limit`. * * It runs through the same request-scoped handle as every other read, so * **RLS applies to the rows being aggregated**. That is the property worth * protecting here: an aggregate is an effective way to read data you cannot * select, and `count(*)` over a table whose policies would return nothing * has to be zero. */ aggregate>(collectionPath: string, options: { aggregates: { fn: "count" | "sum" | "avg" | "min" | "max"; field?: string; alias: string; }[]; groupBy?: string[]; filter?: FilterValues>; logical?: LogicalCondition; searchString?: string; limit?: number; }): Promise[]>; /** * Check if a field value is unique */ checkUniqueField(collectionPath: string, fieldName: string, value: unknown, excludeEntityId?: string, _databaseId?: string): Promise; /** * Get the RelationService instance for external use */ getRelationService(): RelationService; /** * Fetch a collection of rows with optional relation includes. * When `include` is provided, only the specified relations are populated * with full row data (not just { id, path, __type }). * When `include` is absent, no relation queries are made (fast path). * * @param include - Array of relation keys to populate, or ["*"] for all */ fetchCollectionForRest>(collectionPath: string, options?: { filter?: FilterValues>; /** An `or(...)`/`and(...)` group, applied alongside `filter`. */ logical?: LogicalCondition; orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record; searchString?: string; databaseId?: string; vectorSearch?: VectorSearchParams; /** Narrow to the rows reachable from a parent through a relation. */ relatedTo?: NestedPathHop; }, include?: string[]): Promise[]>; /** * Fetch a single row with optional relation includes for REST API. */ fetchOneForRest>(collectionPath: string, id: string | number, include?: string[], databaseId?: string): Promise | null>; /** * Fetch raw rows without any relation processing (for REST fast path) */ private fetchRowsWithConditionsRaw; /** * Check if the Drizzle instance has the relational query API available * for a given collection path. * Note: Primary path now uses inline `getQueryBuilder()` checks. */ private hasDrizzleQueryAPI; /** * Fallback path used when db.query is unavailable. * The primary path uses db.query.findMany with `with` config, which * loads all relations in a single query. * * Batch fetch many-to-many related rows for multiple parent IDs. * Groups results by parent ID to avoid N+1. */ private batchFetchManyRelatedRows; }