@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b
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/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/auth/services.d.ts +19 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +1365 -338
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +23 -25
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/collection-helpers.d.ts +26 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +55 -6
- package/src/auth/ensure-tables.ts +207 -86
- package/src/auth/services.ts +69 -5
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +120 -17
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +132 -42
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +66 -34
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +175 -108
- package/src/services/PersistService.ts +38 -2
- package/src/services/RelationService.ts +110 -67
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +54 -1
- package/src/services/dataService.ts +5 -0
- package/src/services/realtimeService.ts +344 -79
- package/src/utils/drizzle-conditions.ts +365 -23
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { sql as drizzleSql, SQL } from "drizzle-orm";
|
|
2
2
|
import { ANONYMOUS_USER_ID, PolicyExpression, SecurityRule } from "@rebasepro/types";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AnonymousGrantRisk,
|
|
5
|
+
findAnonymousGrants,
|
|
6
|
+
REBASE_USER_ROLE,
|
|
7
|
+
revokeInternalTableAccess,
|
|
8
|
+
securityRuleToConditions
|
|
9
|
+
} from "@rebasepro/common";
|
|
10
|
+
import { REBASE_SCHEMA, usesLegacyRlsFunctions } from "@rebasepro/types";
|
|
4
11
|
import { logger } from "@rebasepro/server";
|
|
5
12
|
|
|
6
13
|
/**
|
|
@@ -16,9 +23,14 @@ import { logger } from "@rebasepro/server";
|
|
|
16
23
|
* are validation/side-effects, not a security boundary.
|
|
17
24
|
*
|
|
18
25
|
* - **Server context** — the base (owner) connection: auth flows, migrations,
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
26
|
+
* and raw `rebase.sql`. As table owner it bypasses RLS. This is the trusted
|
|
27
|
+
* plane, equivalent to Supabase's `service_role`.
|
|
28
|
+
*
|
|
29
|
+
* `rebase.dataAsAdmin` is **not** in it, despite the name. `init.ts` scopes
|
|
30
|
+
* that driver with `withAuth(SERVICE_IDENTITY)`, so it arrives as user
|
|
31
|
+
* context above — `rebase_user`, `app.uid = 'service'`, policies evaluated —
|
|
32
|
+
* and clears the default policies through their admin arm rather than the
|
|
33
|
+
* `auth.uid() IS NULL` one.
|
|
22
34
|
*
|
|
23
35
|
* This module provides the three pieces:
|
|
24
36
|
*
|
|
@@ -36,8 +48,14 @@ import { logger } from "@rebasepro/server";
|
|
|
36
48
|
* not an operator opt-in.
|
|
37
49
|
*/
|
|
38
50
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The restricted role every authenticated (user-context) request runs as.
|
|
53
|
+
*
|
|
54
|
+
* Re-exported, not re-declared: the same name is needed by
|
|
55
|
+
* `@rebasepro/common`'s internal-table revokes, and two spellings of a role name
|
|
56
|
+
* fail as a silent no-op rather than an error.
|
|
57
|
+
*/
|
|
58
|
+
export { REBASE_USER_ROLE };
|
|
41
59
|
|
|
42
60
|
/** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
|
|
43
61
|
export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
|
|
@@ -69,6 +87,57 @@ const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
|
|
|
69
87
|
/** DML the user role holds on managed tables (RLS still filters per row). */
|
|
70
88
|
const USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
|
|
71
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Warn when the connection role shares its name with an existing schema.
|
|
92
|
+
*
|
|
93
|
+
* Postgres resolves unqualified names through `search_path`, which defaults to
|
|
94
|
+
* `"$user", public` — and `$user` is the connection ROLE. When a schema of that
|
|
95
|
+
* name exists it sits ahead of `public`, so every unqualified statement
|
|
96
|
+
* silently operates on it instead:
|
|
97
|
+
*
|
|
98
|
+
* CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
|
|
99
|
+
*
|
|
100
|
+
* Nothing errors. You get a second table of the same name in the wrong schema,
|
|
101
|
+
* and reads that pin `public` cannot see it — which reads as "missing table" and
|
|
102
|
+
* sends people to re-run a push that creates a *third* copy. The bootstrapper
|
|
103
|
+
* has a whole branch dedicated to recognising the symptom after the fact.
|
|
104
|
+
*
|
|
105
|
+
* Rebase shipped straight into this: it creates a schema named `rebase` while
|
|
106
|
+
* every template named the database role `rebase` too. The scaffold uses
|
|
107
|
+
* `rebase_app` now, and every pool Rebase opens pins `search_path=public`
|
|
108
|
+
* (`pinSearchPath`), which covers the paths the framework controls. This covers
|
|
109
|
+
* the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
|
|
110
|
+
* a hand-written migration — because the hazard is a property of the two NAMES,
|
|
111
|
+
* not of any one connection.
|
|
112
|
+
*
|
|
113
|
+
* A warning rather than a boot failure: the database works, the framework's own
|
|
114
|
+
* traffic is pinned, and refusing to start over a naming choice a user may have
|
|
115
|
+
* inherited would be worse than the risk.
|
|
116
|
+
*/
|
|
117
|
+
export async function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
const rows = await run(`
|
|
120
|
+
SELECT current_user AS role,
|
|
121
|
+
EXISTS (
|
|
122
|
+
SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
|
|
123
|
+
) AS collides
|
|
124
|
+
`);
|
|
125
|
+
if (rows[0]?.collides !== true) return;
|
|
126
|
+
const role = String(rows[0]?.role ?? "the connection role");
|
|
127
|
+
logger.warn(
|
|
128
|
+
`⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified ` +
|
|
129
|
+
`names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched ` +
|
|
130
|
+
`BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the ` +
|
|
131
|
+
`path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. ` +
|
|
132
|
+
`Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the ` +
|
|
133
|
+
`hazard entirely, connect as a role whose name is not also a schema — the scaffold uses ` +
|
|
134
|
+
`"rebase_app".`
|
|
135
|
+
);
|
|
136
|
+
} catch {
|
|
137
|
+
// A diagnostic must never be the reason a boot fails.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
72
141
|
export async function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture> {
|
|
73
142
|
const rows = await run(`
|
|
74
143
|
SELECT current_user AS role,
|
|
@@ -180,6 +249,21 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
|
|
|
180
249
|
// migrations), so a migrate can never strand the user role.
|
|
181
250
|
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
|
|
182
251
|
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
|
|
252
|
+
|
|
253
|
+
// The grants above are deliberately schema-wide — a project's own
|
|
254
|
+
// collections may live in `rebase`, and future tables must be reachable
|
|
255
|
+
// or a migration strands the role. Rebase's OWN tables are the exception:
|
|
256
|
+
// refresh tokens, MFA secrets, API keys and the rest carry no RLS and no
|
|
257
|
+
// row an end user should ever address. Taking the privilege back here
|
|
258
|
+
// covers every table that already exists; each creator revokes on the
|
|
259
|
+
// table it just made, for the boot that creates them for the first time.
|
|
260
|
+
await revokeInternalTableAccess(async (text) => { await run(text); }, schema, {
|
|
261
|
+
onError: (table, error) => logger.warn(
|
|
262
|
+
`🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — ` +
|
|
263
|
+
"it stays reachable by authenticated requests: " +
|
|
264
|
+
(error instanceof Error ? error.message : String(error))
|
|
265
|
+
)
|
|
266
|
+
});
|
|
183
267
|
}
|
|
184
268
|
|
|
185
269
|
logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
|
|
@@ -293,6 +377,65 @@ export function warnOnAnonymousGrants(
|
|
|
293
377
|
);
|
|
294
378
|
}
|
|
295
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
|
|
382
|
+
*
|
|
383
|
+
* The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
|
|
384
|
+
* database, so nothing is broken and no policy is wrong — which is exactly why
|
|
385
|
+
* this has to be said out loud. A silent rewrite that works forever is not a
|
|
386
|
+
* migration, it is a second supported spelling nobody wrote down, and the next
|
|
387
|
+
* person to read those rules will copy the old one.
|
|
388
|
+
*
|
|
389
|
+
* Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
|
|
390
|
+
* `policy.rolesOverlap(...)`) compile from the model and were never affected.
|
|
391
|
+
*/
|
|
392
|
+
export function warnOnLegacyRlsFunctions(
|
|
393
|
+
collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]
|
|
394
|
+
): void {
|
|
395
|
+
const sites: string[] = [];
|
|
396
|
+
|
|
397
|
+
for (const collection of collections) {
|
|
398
|
+
for (const rule of collection.securityRules ?? []) {
|
|
399
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
400
|
+
const carriesLegacy = [usingExpr, withCheckExpr]
|
|
401
|
+
.filter((e): e is PolicyExpression => e !== null)
|
|
402
|
+
.some(containsLegacyRlsCall);
|
|
403
|
+
if (!carriesLegacy) continue;
|
|
404
|
+
|
|
405
|
+
const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
|
|
406
|
+
if (!sites.includes(site)) sites.push(site);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (sites.length === 0) return;
|
|
411
|
+
|
|
412
|
+
logger.warn(
|
|
413
|
+
`These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). ` +
|
|
414
|
+
`They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, ` +
|
|
415
|
+
`and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` ` +
|
|
416
|
+
`and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), ` +
|
|
417
|
+
`which never had to be spelled by hand:\n\n` +
|
|
418
|
+
sites.map(s => ` • ${s}`).join("\n") + "\n"
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
|
|
423
|
+
function containsLegacyRlsCall(expr: PolicyExpression): boolean {
|
|
424
|
+
switch (expr.kind) {
|
|
425
|
+
case "raw":
|
|
426
|
+
return usesLegacyRlsFunctions(expr.sql);
|
|
427
|
+
case "and":
|
|
428
|
+
case "or":
|
|
429
|
+
return expr.operands.some(containsLegacyRlsCall);
|
|
430
|
+
case "not":
|
|
431
|
+
return containsLegacyRlsCall(expr.operand);
|
|
432
|
+
case "existsIn":
|
|
433
|
+
return containsLegacyRlsCall(expr.where);
|
|
434
|
+
default:
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
296
439
|
/**
|
|
297
440
|
* Reject `pgRoles` that this server can never satisfy.
|
|
298
441
|
*
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { sql } from "drizzle-orm";
|
|
11
11
|
import { BranchInfo } from "@rebasepro/types";
|
|
12
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
12
13
|
import { DrizzleClient } from "../interfaces";
|
|
13
14
|
import { DatabasePoolManager } from "../databasePoolManager";
|
|
14
15
|
import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
|
|
@@ -124,6 +125,10 @@ export class BranchService {
|
|
|
124
125
|
metadata JSONB DEFAULT '{}'
|
|
125
126
|
);
|
|
126
127
|
`));
|
|
128
|
+
|
|
129
|
+
// Not a collection, so no RLS — and it names every branch database on
|
|
130
|
+
// this server. The driver's schema-wide grant reaches it, so revoke.
|
|
131
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
/**
|
|
@@ -22,6 +22,7 @@ import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query
|
|
|
22
22
|
import { DrizzleClient } from "../interfaces";
|
|
23
23
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
24
24
|
import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
25
|
+
import { visibleColumnProjection, hiddenColumnsOption } from "../schema/search-column";
|
|
25
26
|
import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
|
|
26
27
|
import { ApiError, logger } from "@rebasepro/server";
|
|
27
28
|
import { reachedDatabase } from "../utils/pg-error-utils";
|
|
@@ -119,6 +120,31 @@ export class FetchService {
|
|
|
119
120
|
* and skips rows rather than erroring. The guesses stay, last, for a
|
|
120
121
|
* caller that hands over no collection to resolve against.
|
|
121
122
|
*/
|
|
123
|
+
/**
|
|
124
|
+
* The ORDER BY target, which may be relevance rather than a column.
|
|
125
|
+
*
|
|
126
|
+
* `_score` is only meaningful for a collection that declared a `search`
|
|
127
|
+
* block *and* for a request that carried a search string — ranking rows
|
|
128
|
+
* against no query ranks them all at zero. Outside those two conditions it
|
|
129
|
+
* is an unknown field and gets the same 400 as any other typo, which is the
|
|
130
|
+
* behaviour that matters: a sort that is silently dropped returns 200 with
|
|
131
|
+
* rows in arbitrary order, and paging over that repeats and skips rows.
|
|
132
|
+
*/
|
|
133
|
+
static readonly SCORE_FIELD = "_score";
|
|
134
|
+
|
|
135
|
+
private resolveOrderTarget(
|
|
136
|
+
table: PgTable<any>,
|
|
137
|
+
orderBy: string,
|
|
138
|
+
collection?: CollectionConfig,
|
|
139
|
+
searchString?: string
|
|
140
|
+
): AnyPgColumn | SQL | undefined {
|
|
141
|
+
if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
|
|
142
|
+
const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
|
|
143
|
+
if (rank) return rank;
|
|
144
|
+
}
|
|
145
|
+
return this.resolveOrderByField(table, orderBy, collection);
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
private resolveOrderByField(
|
|
123
149
|
table: PgTable<any>,
|
|
124
150
|
orderBy: string,
|
|
@@ -407,6 +433,15 @@ export class FetchService {
|
|
|
407
433
|
): Record<string, unknown> {
|
|
408
434
|
const queryOpts: Record<string, unknown> = {};
|
|
409
435
|
|
|
436
|
+
// Same exclusion the `db.select` fallback applies, in the shape the
|
|
437
|
+
// relational query builder takes. Both paths serve the same request, so
|
|
438
|
+
// a row must not carry the search column down one and not the other.
|
|
439
|
+
const hidden = hiddenColumnsOption(
|
|
440
|
+
getTableColumns(table),
|
|
441
|
+
this.registry.getCollectionByPath(collectionPath) ?? undefined
|
|
442
|
+
);
|
|
443
|
+
if (hidden) queryOpts.columns = hidden;
|
|
444
|
+
|
|
410
445
|
if (withConfig) queryOpts.with = withConfig;
|
|
411
446
|
|
|
412
447
|
// Build where conditions
|
|
@@ -417,7 +452,7 @@ export class FetchService {
|
|
|
417
452
|
if (options.searchString) {
|
|
418
453
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
419
454
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
420
|
-
options.searchString, collection.properties, table
|
|
455
|
+
options.searchString, collection.properties, table, collection
|
|
421
456
|
);
|
|
422
457
|
if (searchConditions.length === 0) {
|
|
423
458
|
// Return options that will produce empty results
|
|
@@ -451,7 +486,7 @@ export class FetchService {
|
|
|
451
486
|
const orderExpressions: unknown[] = [];
|
|
452
487
|
if (options.orderBy) {
|
|
453
488
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
454
|
-
const orderByField = this.
|
|
489
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
455
490
|
if (orderByField) {
|
|
456
491
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
457
492
|
}
|
|
@@ -485,6 +520,21 @@ export class FetchService {
|
|
|
485
520
|
const cursor = options.startAfter;
|
|
486
521
|
|
|
487
522
|
if (options.orderBy) {
|
|
523
|
+
// Relevance is computed per query, not stored, so there is no value
|
|
524
|
+
// on the cursor row to compare a later page against — and two
|
|
525
|
+
// requests with different search strings would produce scores that
|
|
526
|
+
// are not on the same scale at all. Refusing is the only honest
|
|
527
|
+
// answer: a dropped cursor condition silently repeats and skips
|
|
528
|
+
// rows, which is precisely what paging exists to prevent.
|
|
529
|
+
if (options.orderBy === FetchService.SCORE_FIELD) {
|
|
530
|
+
throw ApiError.badRequest(
|
|
531
|
+
"Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. " +
|
|
532
|
+
"Relevance is computed per query rather than stored, so it cannot key a cursor. " +
|
|
533
|
+
"Use `limit`/`offset` for relevance-ordered pages, or order by a column.",
|
|
534
|
+
"SCORE_CURSOR_UNSUPPORTED",
|
|
535
|
+
{ field: FetchService.SCORE_FIELD }
|
|
536
|
+
);
|
|
537
|
+
}
|
|
488
538
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
|
|
489
539
|
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
490
540
|
if (orderByField) {
|
|
@@ -607,9 +657,12 @@ idColumn };
|
|
|
607
657
|
try {
|
|
608
658
|
const withConfig = this.buildWithConfig(collection);
|
|
609
659
|
|
|
660
|
+
const hidden = hiddenColumnsOption(getTableColumns(table), collection);
|
|
661
|
+
|
|
610
662
|
const row = await qb.findFirst({
|
|
611
663
|
where: eq(idField, parsedId),
|
|
612
|
-
with: withConfig
|
|
664
|
+
with: withConfig,
|
|
665
|
+
...(hidden ? { columns: hidden } : {})
|
|
613
666
|
} as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
|
|
614
667
|
|
|
615
668
|
if (!row) return undefined;
|
|
@@ -631,8 +684,9 @@ idColumn };
|
|
|
631
684
|
}
|
|
632
685
|
|
|
633
686
|
// Fallback: db.select + N+1 relation loading
|
|
687
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
634
688
|
const result = await this.db
|
|
635
|
-
.select()
|
|
689
|
+
.select(visibleOne as never)
|
|
636
690
|
.from(table)
|
|
637
691
|
.where(eq(idField, parsedId))
|
|
638
692
|
.limit(1);
|
|
@@ -700,6 +754,7 @@ idColumn };
|
|
|
700
754
|
offset?: number;
|
|
701
755
|
startAfter?: Record<string, unknown>;
|
|
702
756
|
searchString?: string;
|
|
757
|
+
searchExplain?: boolean;
|
|
703
758
|
databaseId?: string;
|
|
704
759
|
vectorSearch?: VectorSearchParams;
|
|
705
760
|
logical?: LogicalCondition;
|
|
@@ -763,17 +818,41 @@ idColumn };
|
|
|
763
818
|
vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
764
819
|
}
|
|
765
820
|
|
|
821
|
+
// A generated search column is an index in column form; `SELECT *`
|
|
822
|
+
// would ship it to every caller. The projection is undefined — and the
|
|
823
|
+
// SQL therefore unchanged — for any table without one.
|
|
824
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
825
|
+
|
|
826
|
+
// Relevance, alongside the row, exactly as `_distance` rides along with
|
|
827
|
+
// a vector search. Present only when the collection opted in and the
|
|
828
|
+
// request carried a search string, so a caller can order by it, show
|
|
829
|
+
// it, or blend it with a score of their own.
|
|
830
|
+
const rankSelect = options.searchString
|
|
831
|
+
? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
|
|
832
|
+
: undefined;
|
|
833
|
+
|
|
834
|
+
// Only when asked: a `ts_headline` per declared field per row.
|
|
835
|
+
const matchesSelect = options.searchString && options.searchExplain
|
|
836
|
+
? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
|
|
837
|
+
: undefined;
|
|
838
|
+
|
|
766
839
|
let query = vectorMeta
|
|
767
|
-
? this.db.select({ table_row: table,
|
|
840
|
+
? this.db.select({ table_row: (visible ?? table) as never,
|
|
768
841
|
_distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
769
|
-
:
|
|
842
|
+
: rankSelect
|
|
843
|
+
? this.db.select({
|
|
844
|
+
table_row: (visible ?? table) as never,
|
|
845
|
+
_score: rankSelect,
|
|
846
|
+
...(matchesSelect ? { _matches: matchesSelect } : {})
|
|
847
|
+
}).from(table).$dynamic()
|
|
848
|
+
: (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
|
|
770
849
|
const allConditions: SQL[] = [];
|
|
771
850
|
|
|
772
851
|
if (scopeCondition) allConditions.push(scopeCondition);
|
|
773
852
|
|
|
774
853
|
if (options.searchString) {
|
|
775
854
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
776
|
-
options.searchString, collection.properties, table
|
|
855
|
+
options.searchString, collection.properties, table, collection
|
|
777
856
|
);
|
|
778
857
|
if (searchConditions.length === 0) return [];
|
|
779
858
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -804,7 +883,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
804
883
|
if (vectorMeta) {
|
|
805
884
|
orderExpressions.push(asc(vectorMeta.orderBy));
|
|
806
885
|
} else if (options.orderBy) {
|
|
807
|
-
const orderByField = this.
|
|
886
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
808
887
|
if (orderByField) {
|
|
809
888
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
810
889
|
}
|
|
@@ -838,7 +917,15 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
838
917
|
...r.table_row,
|
|
839
918
|
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
840
919
|
}))
|
|
841
|
-
|
|
920
|
+
// Same nested shape, unwrapped the same way, when a relevance
|
|
921
|
+
// score was selected instead.
|
|
922
|
+
: rankSelect
|
|
923
|
+
? (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
|
|
924
|
+
...r.table_row,
|
|
925
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
926
|
+
...(matchesSelect ? { _matches: r._matches ?? [] } : {})
|
|
927
|
+
}))
|
|
928
|
+
: rawResults as Record<string, unknown>[];
|
|
842
929
|
|
|
843
930
|
return this.processRowResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
844
931
|
}
|
|
@@ -954,6 +1041,16 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
954
1041
|
collectionPath: string,
|
|
955
1042
|
options: {
|
|
956
1043
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1044
|
+
/**
|
|
1045
|
+
* An `or(...)`/`and(...)` group, applied alongside `filter`.
|
|
1046
|
+
*
|
|
1047
|
+
* `fetchRowsWithConditions` below has always applied this; it was
|
|
1048
|
+
* simply absent from this signature, so the only callers that could
|
|
1049
|
+
* pass one were the ones that went around this method. Realtime
|
|
1050
|
+
* came through here, which is why a subscription filtered by a
|
|
1051
|
+
* logical group was pushed every row in the table.
|
|
1052
|
+
*/
|
|
1053
|
+
logical?: LogicalCondition;
|
|
957
1054
|
orderBy?: string;
|
|
958
1055
|
order?: "desc" | "asc";
|
|
959
1056
|
limit?: number;
|
|
@@ -984,10 +1081,21 @@ relatedTo: hop });
|
|
|
984
1081
|
searchString: string,
|
|
985
1082
|
options: {
|
|
986
1083
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1084
|
+
/**
|
|
1085
|
+
* An `or(...)`/`and(...)` group, applied alongside `filter`.
|
|
1086
|
+
*
|
|
1087
|
+
* `fetchRowsWithConditions` has always applied one; it was missing
|
|
1088
|
+
* from this signature, so a realtime search subscription carrying a
|
|
1089
|
+
* group could not pass it on and served every row matching the text
|
|
1090
|
+
* that RLS allowed.
|
|
1091
|
+
*/
|
|
1092
|
+
logical?: LogicalCondition;
|
|
987
1093
|
orderBy?: string;
|
|
988
1094
|
order?: "desc" | "asc";
|
|
989
1095
|
limit?: number;
|
|
990
1096
|
databaseId?: string;
|
|
1097
|
+
/** Ask each row which declared search field matched. */
|
|
1098
|
+
searchExplain?: boolean;
|
|
991
1099
|
} = {}
|
|
992
1100
|
): Promise<Record<string, unknown>[]> {
|
|
993
1101
|
return this.fetchRowsWithConditions<M>(collectionPath, {
|
|
@@ -1024,7 +1132,7 @@ relatedTo: hop });
|
|
|
1024
1132
|
|
|
1025
1133
|
if (options.searchString) {
|
|
1026
1134
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1027
|
-
options.searchString, collection.properties, table
|
|
1135
|
+
options.searchString, collection.properties, table, collection
|
|
1028
1136
|
);
|
|
1029
1137
|
if (searchConditions.length === 0) return 0;
|
|
1030
1138
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -1298,8 +1406,9 @@ relatedTo: hop }, include
|
|
|
1298
1406
|
}
|
|
1299
1407
|
|
|
1300
1408
|
// Fallback: db.select + N+1 relation loading
|
|
1409
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
1301
1410
|
const result = await this.db
|
|
1302
|
-
.select()
|
|
1411
|
+
.select(visibleOne as never)
|
|
1303
1412
|
.from(table)
|
|
1304
1413
|
.where(eq(idField, parsedId))
|
|
1305
1414
|
.limit(1);
|
|
@@ -1353,12 +1462,26 @@ relatedTo: hop }, include
|
|
|
1353
1462
|
collectionPath: string,
|
|
1354
1463
|
options: {
|
|
1355
1464
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1465
|
+
/**
|
|
1466
|
+
* An `or(...)`/`and(...)` group, applied alongside `filter`.
|
|
1467
|
+
*
|
|
1468
|
+
* Declared *and applied*, because this is the path every REST search
|
|
1469
|
+
* and vector read takes: `fetchCollectionForRest` skips `db.query`
|
|
1470
|
+
* whenever a `searchString` or a `vectorSearch` is present. The
|
|
1471
|
+
* group arrived here on `options` from the very beginning and was
|
|
1472
|
+
* simply never read, so `?searchString=x&or=(...)` served every row
|
|
1473
|
+
* matching `x` that RLS allowed — while `count` (which does apply
|
|
1474
|
+
* it) reported the narrowed total, so `meta.total` and `data`
|
|
1475
|
+
* described different sets of rows.
|
|
1476
|
+
*/
|
|
1477
|
+
logical?: LogicalCondition;
|
|
1356
1478
|
orderBy?: string;
|
|
1357
1479
|
order?: "desc" | "asc";
|
|
1358
1480
|
limit?: number;
|
|
1359
1481
|
offset?: number;
|
|
1360
1482
|
startAfter?: Record<string, unknown>;
|
|
1361
1483
|
searchString?: string;
|
|
1484
|
+
searchExplain?: boolean;
|
|
1362
1485
|
vectorSearch?: VectorSearchParams;
|
|
1363
1486
|
relatedTo?: NestedPathHop;
|
|
1364
1487
|
} = {}
|
|
@@ -1374,17 +1497,41 @@ relatedTo: hop }, include
|
|
|
1374
1497
|
vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
1375
1498
|
}
|
|
1376
1499
|
|
|
1500
|
+
// A generated search column is an index in column form; `SELECT *`
|
|
1501
|
+
// would ship it to every caller. The projection is undefined — and the
|
|
1502
|
+
// SQL therefore unchanged — for any table without one.
|
|
1503
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
1504
|
+
|
|
1505
|
+
// Relevance, alongside the row, exactly as `_distance` rides along with
|
|
1506
|
+
// a vector search. Present only when the collection opted in and the
|
|
1507
|
+
// request carried a search string, so a caller can order by it, show
|
|
1508
|
+
// it, or blend it with a score of their own.
|
|
1509
|
+
const rankSelect = options.searchString
|
|
1510
|
+
? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
|
|
1511
|
+
: undefined;
|
|
1512
|
+
|
|
1513
|
+
// Only when asked: a `ts_headline` per declared field per row.
|
|
1514
|
+
const matchesSelect = options.searchString && options.searchExplain
|
|
1515
|
+
? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
|
|
1516
|
+
: undefined;
|
|
1517
|
+
|
|
1377
1518
|
let query = vectorMeta
|
|
1378
|
-
? this.db.select({ table_row: table,
|
|
1519
|
+
? this.db.select({ table_row: (visible ?? table) as never,
|
|
1379
1520
|
_distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
1380
|
-
:
|
|
1521
|
+
: rankSelect
|
|
1522
|
+
? this.db.select({
|
|
1523
|
+
table_row: (visible ?? table) as never,
|
|
1524
|
+
_score: rankSelect,
|
|
1525
|
+
...(matchesSelect ? { _matches: matchesSelect } : {})
|
|
1526
|
+
}).from(table).$dynamic()
|
|
1527
|
+
: (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
|
|
1381
1528
|
const allConditions: SQL[] = [];
|
|
1382
1529
|
|
|
1383
1530
|
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
1384
1531
|
|
|
1385
1532
|
if (options.searchString) {
|
|
1386
1533
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1387
|
-
options.searchString, collection.properties, table
|
|
1534
|
+
options.searchString, collection.properties, table, collection
|
|
1388
1535
|
);
|
|
1389
1536
|
if (searchConditions.length === 0) return [];
|
|
1390
1537
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -1395,6 +1542,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1395
1542
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
1396
1543
|
}
|
|
1397
1544
|
|
|
1545
|
+
if (options.logical) {
|
|
1546
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
1547
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1398
1550
|
if (vectorMeta?.filter) {
|
|
1399
1551
|
allConditions.push(vectorMeta.filter);
|
|
1400
1552
|
}
|
|
@@ -1408,7 +1560,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1408
1560
|
if (vectorMeta) {
|
|
1409
1561
|
orderExpressions.push(asc(vectorMeta.orderBy));
|
|
1410
1562
|
} else if (options.orderBy) {
|
|
1411
|
-
const orderByField = this.
|
|
1563
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
1412
1564
|
if (orderByField) {
|
|
1413
1565
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
1414
1566
|
}
|
|
@@ -1433,6 +1585,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1433
1585
|
}));
|
|
1434
1586
|
}
|
|
1435
1587
|
|
|
1588
|
+
if (rankSelect) {
|
|
1589
|
+
return (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
|
|
1590
|
+
...r.table_row,
|
|
1591
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
1592
|
+
...(matchesSelect ? { _matches: r._matches ?? [] } : {})
|
|
1593
|
+
}));
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1436
1596
|
return rawResults as Record<string, unknown>[];
|
|
1437
1597
|
}
|
|
1438
1598
|
|
|
@@ -1454,99 +1614,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1454
1614
|
return !!this.getQueryBuilder(tableName);
|
|
1455
1615
|
}
|
|
1456
1616
|
|
|
1457
|
-
/**
|
|
1458
|
-
* Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
|
|
1459
|
-
* for efficient JOIN-based relation loading.
|
|
1460
|
-
* Returns null if the API is not available or the query fails.
|
|
1461
|
-
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
1462
|
-
*/
|
|
1463
|
-
private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
|
|
1464
|
-
collectionPath: string,
|
|
1465
|
-
collection: CollectionConfig,
|
|
1466
|
-
options: {
|
|
1467
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1468
|
-
orderBy?: string;
|
|
1469
|
-
order?: "desc" | "asc";
|
|
1470
|
-
limit?: number;
|
|
1471
|
-
},
|
|
1472
|
-
include: string[],
|
|
1473
|
-
idInfo: { fieldName: string; type: "string" | "number" },
|
|
1474
|
-
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
1475
|
-
): Promise<Record<string, unknown>[] | null> {
|
|
1476
|
-
try {
|
|
1477
|
-
|
|
1478
|
-
const table = getTableForCollection(collection, this.registry);
|
|
1479
|
-
const tableName = getTableName(table);
|
|
1480
|
-
const queryTarget = this.getQueryBuilder(tableName);
|
|
1481
|
-
|
|
1482
|
-
if (!queryTarget?.findMany) return null;
|
|
1483
|
-
|
|
1484
|
-
// Build the `with` config from include array
|
|
1485
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1486
|
-
const withConfig: Record<string, boolean> = {};
|
|
1487
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1488
|
-
if (include[0] === "*" || include.includes(key)) {
|
|
1489
|
-
// Use the Drizzle relation name (from the schema)
|
|
1490
|
-
const drizzleRelName = relation.relationName || key;
|
|
1491
|
-
withConfig[drizzleRelName] = true;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
// Build query options
|
|
1496
|
-
const queryOpts: Record<string, unknown> = { with: withConfig };
|
|
1497
|
-
if (options.limit) queryOpts.limit = options.limit;
|
|
1498
|
-
|
|
1499
|
-
// Build where clause
|
|
1500
|
-
if (options.filter) {
|
|
1501
|
-
const filterConditions = this.buildFilterConditions(
|
|
1502
|
-
options.filter, table, collectionPath
|
|
1503
|
-
);
|
|
1504
|
-
if (filterConditions.length > 0) {
|
|
1505
|
-
queryOpts.where = and(...filterConditions);
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// Build orderBy
|
|
1510
|
-
if (options.orderBy) {
|
|
1511
|
-
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
1512
|
-
if (orderByField) {
|
|
1513
|
-
queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
|
|
1519
|
-
|
|
1520
|
-
// Inline the nested Drizzle results, columns only — no synthesized id.
|
|
1521
|
-
return results.map((row: Record<string, unknown>) => {
|
|
1522
|
-
const flat: Record<string, unknown> = {};
|
|
1523
|
-
for (const [k, v] of Object.entries(row)) {
|
|
1524
|
-
if (Array.isArray(v)) {
|
|
1525
|
-
// Many relation — inline each nested row
|
|
1526
|
-
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
1527
|
-
// Junction table rows may have the target nested, unwrap those
|
|
1528
|
-
const keys = Object.keys(item);
|
|
1529
|
-
const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
1530
|
-
if (nestedObj && keys.length <= 3) {
|
|
1531
|
-
return { ...(item[nestedObj] as Record<string, unknown>) };
|
|
1532
|
-
}
|
|
1533
|
-
return { ...item };
|
|
1534
|
-
});
|
|
1535
|
-
} else if (typeof v === "object" && v !== null) {
|
|
1536
|
-
// One-to-one relation — inline the target's columns
|
|
1537
|
-
flat[k] = { ...(v as Record<string, unknown>) };
|
|
1538
|
-
} else {
|
|
1539
|
-
flat[k] = v;
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
return flat;
|
|
1543
|
-
});
|
|
1544
|
-
} catch (e) {
|
|
1545
|
-
logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
|
|
1546
|
-
return null;
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
1617
|
/**
|
|
1551
1618
|
* Fallback path used when db.query is unavailable.
|
|
1552
1619
|
* The primary path uses db.query.findMany with `with` config, which
|