@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.
Files changed (85) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/auth/services.d.ts +19 -0
  3. package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
  4. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  5. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  6. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  7. package/dist/cli-helpers.d.ts +57 -1
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
  12. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
  13. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
  14. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  15. package/dist/index.es.js +1365 -338
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  18. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  19. package/dist/schema/auth-schema.d.ts +102 -0
  20. package/dist/schema/auth-users-columns.d.ts +97 -0
  21. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  22. package/dist/schema/doctor.d.ts +23 -25
  23. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  24. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  25. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  26. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  27. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  28. package/dist/schema/search-column.d.ts +248 -0
  29. package/dist/security/rls-enforcement.d.ts +61 -5
  30. package/dist/services/FetchService.d.ts +34 -7
  31. package/dist/services/RelationService.d.ts +30 -0
  32. package/dist/services/collection-helpers.d.ts +26 -0
  33. package/dist/services/dataService.d.ts +5 -0
  34. package/dist/services/realtimeService.d.ts +131 -21
  35. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  36. package/dist/src-C_wvdMnl.js.map +1 -0
  37. package/dist/utils/drizzle-conditions.d.ts +124 -2
  38. package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
  39. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  40. package/package.json +9 -8
  41. package/src/PostgresBackendDriver.ts +172 -6
  42. package/src/PostgresBootstrapper.ts +55 -6
  43. package/src/auth/ensure-tables.ts +207 -86
  44. package/src/auth/services.ts +69 -5
  45. package/src/cli-helpers.ts +129 -10
  46. package/src/cli.ts +232 -30
  47. package/src/collections/validate-relations.ts +124 -17
  48. package/src/data-transformer.ts +120 -17
  49. package/src/history/ensure-history-table.ts +7 -0
  50. package/src/schema/auth-schema.ts +17 -1
  51. package/src/schema/auth-users-columns.ts +131 -0
  52. package/src/schema/doctor-cli.ts +12 -63
  53. package/src/schema/doctor-policy-checks.ts +105 -0
  54. package/src/schema/doctor.ts +135 -76
  55. package/src/schema/ensure-collection-tables.ts +374 -32
  56. package/src/schema/generate-drizzle-schema-logic.ts +132 -42
  57. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  58. package/src/schema/generate-postgres-ddl.ts +25 -2
  59. package/src/schema/generated-schema-staleness.ts +169 -0
  60. package/src/schema/introspect-db-logic.ts +66 -34
  61. package/src/schema/non-sql-collections.test.ts +131 -0
  62. package/src/schema/rls-bootstrap-sql.ts +288 -0
  63. package/src/schema/search-column.ts +643 -0
  64. package/src/security/anonymous-grants.test.ts +4 -2
  65. package/src/security/rls-enforcement.ts +149 -6
  66. package/src/services/BranchService.ts +5 -0
  67. package/src/services/FetchService.ts +175 -108
  68. package/src/services/PersistService.ts +38 -2
  69. package/src/services/RelationService.ts +110 -67
  70. package/src/services/channel-history.ts +14 -0
  71. package/src/services/channel-presence.ts +13 -0
  72. package/src/services/collection-helpers.ts +54 -1
  73. package/src/services/dataService.ts +5 -0
  74. package/src/services/realtimeService.ts +344 -79
  75. package/src/utils/drizzle-conditions.ts +365 -23
  76. package/src/utils/pg-error-utils.ts +8 -3
  77. package/src/websocket.ts +113 -16
  78. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  79. package/dist/policy-CeA1JcxP.js +0 -105
  80. package/dist/policy-CeA1JcxP.js.map +0 -1
  81. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  82. package/dist/src-DlPBctw_.js.map +0 -1
  83. package/dist/src-DoU9yPqq.js.map +0 -1
  84. package/dist/websocket-B2LsrINK.js.map +0 -1
  85. 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 { AnonymousGrantRisk, findAnonymousGrants, securityRuleToConditions } from "@rebasepro/common";
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
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
20
- * owner it bypasses RLS. This is the trusted plane, equivalent to
21
- * Supabase's `service_role`.
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
- /** The restricted role every authenticated (user-context) request runs as. */
40
- export const REBASE_USER_ROLE = "rebase_user";
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.resolveOrderByField(table, options.orderBy, collection);
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
- : this.db.select().from(table).$dynamic();
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.resolveOrderByField(table, options.orderBy, collection);
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
- : rawResults as Record<string, unknown>[];
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
- : this.db.select().from(table).$dynamic();
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.resolveOrderByField(table, options.orderBy, collection);
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