@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g249daa1

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 (70) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/{src-DlPBctw_.js → auth-users-columns-Dt9g712t.js} +721 -47
  3. package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
  4. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  5. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  6. package/dist/cli-helpers.d.ts +57 -1
  7. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-CwYUliAa.js} +2 -2
  8. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRxaUG96.js} +86 -15
  10. package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
  11. package/dist/index.es.js +765 -223
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
  14. package/dist/policy-CPkCqVTz.js.map +1 -0
  15. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  16. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  17. package/dist/schema/auth-users-columns.d.ts +97 -0
  18. package/dist/schema/ensure-collection-tables.d.ts +2 -2
  19. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  21. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  22. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  23. package/dist/schema/search-column.d.ts +199 -0
  24. package/dist/security/rls-enforcement.d.ts +53 -2
  25. package/dist/services/FetchService.d.ts +25 -7
  26. package/dist/services/dataService.d.ts +3 -0
  27. package/dist/services/realtimeService.d.ts +27 -21
  28. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  29. package/dist/src-C_wvdMnl.js.map +1 -0
  30. package/dist/utils/drizzle-conditions.d.ts +71 -2
  31. package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
  32. package/dist/websocket-D0TBU3ia.js.map +1 -0
  33. package/package.json +9 -8
  34. package/src/PostgresBackendDriver.ts +165 -3
  35. package/src/PostgresBootstrapper.ts +41 -2
  36. package/src/auth/ensure-tables.ts +185 -86
  37. package/src/cli-helpers.ts +129 -10
  38. package/src/cli.ts +232 -30
  39. package/src/collections/validate-relations.ts +124 -17
  40. package/src/data-transformer.ts +31 -3
  41. package/src/history/ensure-history-table.ts +7 -0
  42. package/src/schema/auth-users-columns.ts +131 -0
  43. package/src/schema/doctor.ts +7 -5
  44. package/src/schema/ensure-collection-tables.ts +165 -20
  45. package/src/schema/generate-drizzle-schema-logic.ts +33 -3
  46. package/src/schema/generate-postgres-ddl-logic.ts +266 -15
  47. package/src/schema/generate-postgres-ddl.ts +25 -2
  48. package/src/schema/generated-schema-staleness.ts +169 -0
  49. package/src/schema/introspect-db-logic.ts +1 -1
  50. package/src/schema/non-sql-collections.test.ts +131 -0
  51. package/src/schema/rls-bootstrap-sql.ts +288 -0
  52. package/src/schema/search-column.ts +558 -0
  53. package/src/security/anonymous-grants.test.ts +4 -2
  54. package/src/security/rls-enforcement.ts +141 -3
  55. package/src/services/BranchService.ts +5 -0
  56. package/src/services/FetchService.ts +148 -108
  57. package/src/services/PersistService.ts +14 -1
  58. package/src/services/RelationService.ts +2 -1
  59. package/src/services/channel-history.ts +8 -0
  60. package/src/services/channel-presence.ts +6 -0
  61. package/src/services/dataService.ts +3 -0
  62. package/src/services/realtimeService.ts +46 -37
  63. package/src/utils/drizzle-conditions.ts +223 -2
  64. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  65. package/dist/policy-CeA1JcxP.js.map +0 -1
  66. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  67. package/dist/src-DlPBctw_.js.map +0 -1
  68. package/dist/src-DoU9yPqq.js.map +0 -1
  69. package/dist/websocket-B2LsrINK.js.map +0 -1
  70. 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
  /**
@@ -36,8 +43,14 @@ import { logger } from "@rebasepro/server";
36
43
  * not an operator opt-in.
37
44
  */
38
45
 
39
- /** The restricted role every authenticated (user-context) request runs as. */
40
- export const REBASE_USER_ROLE = "rebase_user";
46
+ /**
47
+ * The restricted role every authenticated (user-context) request runs as.
48
+ *
49
+ * Re-exported, not re-declared: the same name is needed by
50
+ * `@rebasepro/common`'s internal-table revokes, and two spellings of a role name
51
+ * fail as a silent no-op rather than an error.
52
+ */
53
+ export { REBASE_USER_ROLE };
41
54
 
42
55
  /** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
43
56
  export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
@@ -69,6 +82,57 @@ const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
69
82
  /** DML the user role holds on managed tables (RLS still filters per row). */
70
83
  const USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
71
84
 
85
+ /**
86
+ * Warn when the connection role shares its name with an existing schema.
87
+ *
88
+ * Postgres resolves unqualified names through `search_path`, which defaults to
89
+ * `"$user", public` — and `$user` is the connection ROLE. When a schema of that
90
+ * name exists it sits ahead of `public`, so every unqualified statement
91
+ * silently operates on it instead:
92
+ *
93
+ * CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
94
+ *
95
+ * Nothing errors. You get a second table of the same name in the wrong schema,
96
+ * and reads that pin `public` cannot see it — which reads as "missing table" and
97
+ * sends people to re-run a push that creates a *third* copy. The bootstrapper
98
+ * has a whole branch dedicated to recognising the symptom after the fact.
99
+ *
100
+ * Rebase shipped straight into this: it creates a schema named `rebase` while
101
+ * every template named the database role `rebase` too. The scaffold uses
102
+ * `rebase_app` now, and every pool Rebase opens pins `search_path=public`
103
+ * (`pinSearchPath`), which covers the paths the framework controls. This covers
104
+ * the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
105
+ * a hand-written migration — because the hazard is a property of the two NAMES,
106
+ * not of any one connection.
107
+ *
108
+ * A warning rather than a boot failure: the database works, the framework's own
109
+ * traffic is pinned, and refusing to start over a naming choice a user may have
110
+ * inherited would be worse than the risk.
111
+ */
112
+ export async function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise<void> {
113
+ try {
114
+ const rows = await run(`
115
+ SELECT current_user AS role,
116
+ EXISTS (
117
+ SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
118
+ ) AS collides
119
+ `);
120
+ if (rows[0]?.collides !== true) return;
121
+ const role = String(rows[0]?.role ?? "the connection role");
122
+ logger.warn(
123
+ `⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified ` +
124
+ `names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched ` +
125
+ `BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the ` +
126
+ `path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. ` +
127
+ `Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the ` +
128
+ `hazard entirely, connect as a role whose name is not also a schema — the scaffold uses ` +
129
+ `"rebase_app".`
130
+ );
131
+ } catch {
132
+ // A diagnostic must never be the reason a boot fails.
133
+ }
134
+ }
135
+
72
136
  export async function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture> {
73
137
  const rows = await run(`
74
138
  SELECT current_user AS role,
@@ -180,6 +244,21 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
180
244
  // migrations), so a migrate can never strand the user role.
181
245
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
182
246
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
247
+
248
+ // The grants above are deliberately schema-wide — a project's own
249
+ // collections may live in `rebase`, and future tables must be reachable
250
+ // or a migration strands the role. Rebase's OWN tables are the exception:
251
+ // refresh tokens, MFA secrets, API keys and the rest carry no RLS and no
252
+ // row an end user should ever address. Taking the privilege back here
253
+ // covers every table that already exists; each creator revokes on the
254
+ // table it just made, for the boot that creates them for the first time.
255
+ await revokeInternalTableAccess(async (text) => { await run(text); }, schema, {
256
+ onError: (table, error) => logger.warn(
257
+ `🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — ` +
258
+ "it stays reachable by authenticated requests: " +
259
+ (error instanceof Error ? error.message : String(error))
260
+ )
261
+ });
183
262
  }
184
263
 
185
264
  logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
@@ -293,6 +372,65 @@ export function warnOnAnonymousGrants(
293
372
  );
294
373
  }
295
374
 
375
+ /**
376
+ * Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
377
+ *
378
+ * The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
379
+ * database, so nothing is broken and no policy is wrong — which is exactly why
380
+ * this has to be said out loud. A silent rewrite that works forever is not a
381
+ * migration, it is a second supported spelling nobody wrote down, and the next
382
+ * person to read those rules will copy the old one.
383
+ *
384
+ * Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
385
+ * `policy.rolesOverlap(...)`) compile from the model and were never affected.
386
+ */
387
+ export function warnOnLegacyRlsFunctions(
388
+ collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]
389
+ ): void {
390
+ const sites: string[] = [];
391
+
392
+ for (const collection of collections) {
393
+ for (const rule of collection.securityRules ?? []) {
394
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
395
+ const carriesLegacy = [usingExpr, withCheckExpr]
396
+ .filter((e): e is PolicyExpression => e !== null)
397
+ .some(containsLegacyRlsCall);
398
+ if (!carriesLegacy) continue;
399
+
400
+ const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
401
+ if (!sites.includes(site)) sites.push(site);
402
+ }
403
+ }
404
+
405
+ if (sites.length === 0) return;
406
+
407
+ logger.warn(
408
+ `These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). ` +
409
+ `They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, ` +
410
+ `and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` ` +
411
+ `and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), ` +
412
+ `which never had to be spelled by hand:\n\n` +
413
+ sites.map(s => ` • ${s}`).join("\n") + "\n"
414
+ );
415
+ }
416
+
417
+ /** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
418
+ function containsLegacyRlsCall(expr: PolicyExpression): boolean {
419
+ switch (expr.kind) {
420
+ case "raw":
421
+ return usesLegacyRlsFunctions(expr.sql);
422
+ case "and":
423
+ case "or":
424
+ return expr.operands.some(containsLegacyRlsCall);
425
+ case "not":
426
+ return containsLegacyRlsCall(expr.operand);
427
+ case "existsIn":
428
+ return containsLegacyRlsCall(expr.where);
429
+ default:
430
+ return false;
431
+ }
432
+ }
433
+
296
434
  /**
297
435
  * Reject `pgRoles` that this server can never satisfy.
298
436
  *
@@ -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;
@@ -988,6 +1085,8 @@ relatedTo: hop });
988
1085
  order?: "desc" | "asc";
989
1086
  limit?: number;
990
1087
  databaseId?: string;
1088
+ /** Ask each row which declared search field matched. */
1089
+ searchExplain?: boolean;
991
1090
  } = {}
992
1091
  ): Promise<Record<string, unknown>[]> {
993
1092
  return this.fetchRowsWithConditions<M>(collectionPath, {
@@ -1024,7 +1123,7 @@ relatedTo: hop });
1024
1123
 
1025
1124
  if (options.searchString) {
1026
1125
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1027
- options.searchString, collection.properties, table
1126
+ options.searchString, collection.properties, table, collection
1028
1127
  );
1029
1128
  if (searchConditions.length === 0) return 0;
1030
1129
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -1298,8 +1397,9 @@ relatedTo: hop }, include
1298
1397
  }
1299
1398
 
1300
1399
  // Fallback: db.select + N+1 relation loading
1400
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
1301
1401
  const result = await this.db
1302
- .select()
1402
+ .select(visibleOne as never)
1303
1403
  .from(table)
1304
1404
  .where(eq(idField, parsedId))
1305
1405
  .limit(1);
@@ -1359,6 +1459,7 @@ relatedTo: hop }, include
1359
1459
  offset?: number;
1360
1460
  startAfter?: Record<string, unknown>;
1361
1461
  searchString?: string;
1462
+ searchExplain?: boolean;
1362
1463
  vectorSearch?: VectorSearchParams;
1363
1464
  relatedTo?: NestedPathHop;
1364
1465
  } = {}
@@ -1374,17 +1475,41 @@ relatedTo: hop }, include
1374
1475
  vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1375
1476
  }
1376
1477
 
1478
+ // A generated search column is an index in column form; `SELECT *`
1479
+ // would ship it to every caller. The projection is undefined — and the
1480
+ // SQL therefore unchanged — for any table without one.
1481
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
1482
+
1483
+ // Relevance, alongside the row, exactly as `_distance` rides along with
1484
+ // a vector search. Present only when the collection opted in and the
1485
+ // request carried a search string, so a caller can order by it, show
1486
+ // it, or blend it with a score of their own.
1487
+ const rankSelect = options.searchString
1488
+ ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
1489
+ : undefined;
1490
+
1491
+ // Only when asked: a `ts_headline` per declared field per row.
1492
+ const matchesSelect = options.searchString && options.searchExplain
1493
+ ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
1494
+ : undefined;
1495
+
1377
1496
  let query = vectorMeta
1378
- ? this.db.select({ table_row: table,
1497
+ ? this.db.select({ table_row: (visible ?? table) as never,
1379
1498
  _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1380
- : this.db.select().from(table).$dynamic();
1499
+ : rankSelect
1500
+ ? this.db.select({
1501
+ table_row: (visible ?? table) as never,
1502
+ _score: rankSelect,
1503
+ ...(matchesSelect ? { _matches: matchesSelect } : {})
1504
+ }).from(table).$dynamic()
1505
+ : (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
1381
1506
  const allConditions: SQL[] = [];
1382
1507
 
1383
1508
  if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
1384
1509
 
1385
1510
  if (options.searchString) {
1386
1511
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1387
- options.searchString, collection.properties, table
1512
+ options.searchString, collection.properties, table, collection
1388
1513
  );
1389
1514
  if (searchConditions.length === 0) return [];
1390
1515
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -1408,7 +1533,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1408
1533
  if (vectorMeta) {
1409
1534
  orderExpressions.push(asc(vectorMeta.orderBy));
1410
1535
  } else if (options.orderBy) {
1411
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1536
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
1412
1537
  if (orderByField) {
1413
1538
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
1414
1539
  }
@@ -1433,6 +1558,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1433
1558
  }));
1434
1559
  }
1435
1560
 
1561
+ if (rankSelect) {
1562
+ return (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
1563
+ ...r.table_row,
1564
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
1565
+ ...(matchesSelect ? { _matches: r._matches ?? [] } : {})
1566
+ }));
1567
+ }
1568
+
1436
1569
  return rawResults as Record<string, unknown>[];
1437
1570
  }
1438
1571
 
@@ -1454,99 +1587,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1454
1587
  return !!this.getQueryBuilder(tableName);
1455
1588
  }
1456
1589
 
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
1590
  /**
1551
1591
  * Fallback path used when db.query is unavailable.
1552
1592
  * The primary path uses db.query.findMany with `with` config, which
@@ -477,7 +477,20 @@ export class PersistService {
477
477
  const pgError = extractPgError(error);
478
478
 
479
479
  if (pgError) {
480
- const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
480
+ const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
481
+ // This is the only layer that holds the SQLSTATE, so it is the only
482
+ // one that can say whose fault a failure was. Returning a bare
483
+ // `Error` threw that away, and the REST layer compensated by calling
484
+ // *every* unclassified error a 400 — so an unreachable database was
485
+ // reported to callers as a bad request. Classes 22 (data exception)
486
+ // and 23 (integrity constraint violation) are the caller's data;
487
+ // everything else — a dropped connection, a missing column, a
488
+ // permission problem — is ours, and stays a 500.
489
+ if (/^2[23]/.test(code)) {
490
+ return code === "23505"
491
+ ? ApiError.conflict(message, `PG_${code}`)
492
+ : ApiError.badRequest(message, `PG_${code}`);
493
+ }
481
494
  return new Error(message);
482
495
  }
483
496
 
@@ -423,7 +423,8 @@ export class RelationService {
423
423
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
424
424
  options.searchString,
425
425
  targetCollection.properties,
426
- targetTable
426
+ targetTable,
427
+ targetCollection
427
428
  );
428
429
 
429
430
  if (searchConditions.length === 0) {
@@ -34,6 +34,7 @@ import { sql } from "drizzle-orm";
34
34
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
35
35
  import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
36
36
  import { logger } from "@rebasepro/server";
37
+ import { revokeInternalTableSql } from "@rebasepro/common";
37
38
 
38
39
  /** How many messages a replay returns when the caller does not say. */
39
40
  const DEFAULT_REPLAY_LIMIT = 200;
@@ -198,6 +199,13 @@ export class ChannelHistoryStore {
198
199
  )
199
200
  `);
200
201
 
202
+ // Retained broadcasts for every channel in one table, with no RLS: who
203
+ // may replay a channel is decided by the channel rules the server
204
+ // evaluates before it reads. The driver's schema-wide grant reaches
205
+ // these (created here, after it ran), so take the privilege back.
206
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
207
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
208
+
201
209
  this.tablesReady = true;
202
210
  logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
203
211
  }