@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  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-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -29,6 +29,20 @@ export interface PolicyRef {
29
29
  hasUsing: boolean;
30
30
  /** Whether a WITH CHECK clause is present at all (not what it says). */
31
31
  hasWithCheck: boolean;
32
+ /**
33
+ * PERMISSIVE or RESTRICTIVE — the `AS` clause.
34
+ *
35
+ * An exact catalogue value on both sides, so it belongs with roles and
36
+ * command rather than with the expression text. It matters more than either:
37
+ * permissive policies are ORed together and restrictive ones ANDed, so a rule
38
+ * declared `mode: "restrictive"` whose live policy is PERMISSIVE has had its
39
+ * gate turned from a requirement into an alternative — the maximally
40
+ * permissive way for this to be wrong.
41
+ *
42
+ * The DDL regex captured this from the start and the destructuring threw it
43
+ * away; `pg_policies.permissive` was never selected.
44
+ */
45
+ mode?: "PERMISSIVE" | "RESTRICTIVE";
32
46
  /**
33
47
  * The live clause text, when read from `pg_policies`. Present only for live
34
48
  * policies (the expected side is parsed from DDL and does not carry it).
@@ -59,6 +73,22 @@ export interface PolicyDrift {
59
73
  * @see reason a sentence naming the clause and what to do.
60
74
  */
61
75
  insecure: { policy: PolicyRef; reason: string }[];
76
+ /**
77
+ * A table the collections describe whose RLS switch is off.
78
+ *
79
+ * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in
80
+ * `pg_policies` untouched, so before this category every expected policy
81
+ * still matched on name, roles, command and clause presence and the checker
82
+ * reported clean — on a table Postgres was applying no filter to at all.
83
+ * Requests run as `rebase_user`, which holds full DML, so the table is wide
84
+ * open while `doctor` certifies it.
85
+ *
86
+ * `forced` reports `relforcerowsecurity`, which is what also subjects the
87
+ * table's *owner* to its policies. Its absence is not drift on its own —
88
+ * Rebase does not connect as the owner in the request path — so it is
89
+ * reported for context rather than raised as a failure.
90
+ */
91
+ rlsDisabled: { schema: string; table: string; forced: boolean }[];
62
92
  }
63
93
 
64
94
  export interface Queryable {
@@ -98,11 +128,29 @@ function clauseEnd(ddl: string, open: number): number {
98
128
  return ddl.length;
99
129
  }
100
130
 
131
+ /**
132
+ * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.
133
+ *
134
+ * The DDL spells it as a bare word; `pg_policies.permissive` is the string
135
+ * `"PERMISSIVE"`/`"RESTRICTIVE"` on modern Postgres but a boolean on some
136
+ * drivers and older servers, so both are accepted. Anything unrecognised
137
+ * becomes `undefined` and the comparison below skips it rather than inventing
138
+ * drift from a shape we did not anticipate.
139
+ */
140
+ function normalizeMode(raw: unknown): "PERMISSIVE" | "RESTRICTIVE" | undefined {
141
+ if (typeof raw === "boolean") return raw ? "PERMISSIVE" : "RESTRICTIVE";
142
+ if (typeof raw !== "string") return undefined;
143
+ const upper = raw.trim().toUpperCase();
144
+ if (upper === "PERMISSIVE" || upper === "TRUE" || upper === "T") return "PERMISSIVE";
145
+ if (upper === "RESTRICTIVE" || upper === "FALSE" || upper === "F") return "RESTRICTIVE";
146
+ return undefined;
147
+ }
148
+
101
149
  /** Parse the generated DDL rather than rebuilding the shape by hand. */
102
150
  export function parseExpectedPolicies(ddl: string): PolicyRef[] {
103
151
  const found: PolicyRef[] = [];
104
152
  for (const m of ddl.matchAll(CREATE_POLICY)) {
105
- const [, name, schema, table, , command, rolesRaw, clause] = m;
153
+ const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;
106
154
  const roles = rolesRaw
107
155
  .split(",")
108
156
  .map((r) => r.trim().replace(/^"|"$/g, ""))
@@ -115,7 +163,12 @@ export function parseExpectedPolicies(ddl: string): PolicyRef[] {
115
163
  ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))
116
164
  : /WITH CHECK/i.test(clause);
117
165
 
118
- found.push({ schema, table, name, roles, command: command.toUpperCase(), hasUsing, hasWithCheck });
166
+ found.push({
167
+ schema, table, name, roles,
168
+ command: command.toUpperCase(),
169
+ hasUsing, hasWithCheck,
170
+ mode: normalizeMode(modeRaw)
171
+ });
119
172
  }
120
173
  return found;
121
174
  }
@@ -123,9 +176,9 @@ export function parseExpectedPolicies(ddl: string): PolicyRef[] {
123
176
  async function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {
124
177
  const { rows } = await client.query<{
125
178
  schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;
126
- qual: string | null; with_check: string | null;
179
+ qual: string | null; with_check: string | null; permissive: string | boolean | null;
127
180
  }>(
128
- `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check
181
+ `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive
129
182
  FROM pg_policies
130
183
  WHERE schemaname = ANY($1)`,
131
184
  [schemas]
@@ -144,11 +197,51 @@ async function readLivePolicies(client: Queryable, schemas: string[]): Promise<P
144
197
  // drop a clause: NULL here means the policy genuinely has none.
145
198
  hasUsing: r.qual != null,
146
199
  hasWithCheck: r.with_check != null,
200
+ mode: normalizeMode(r.permissive),
147
201
  qual: r.qual,
148
202
  withCheck: r.with_check
149
203
  }));
150
204
  }
151
205
 
206
+ /**
207
+ * Tables the collections describe whose RLS switch is off.
208
+ *
209
+ * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS
210
+ * leaves the policy rows in place, so every other comparison in this file keeps
211
+ * matching while Postgres applies none of them.
212
+ *
213
+ * Only tables that expect at least one policy are considered. A table with no
214
+ * declared rules is not asserting anything about RLS, and reporting it would
215
+ * make the check noisy on exactly the projects least able to judge the noise.
216
+ */
217
+ async function readTablesWithRlsOff(
218
+ client: Queryable,
219
+ tables: { schema: string; table: string }[]
220
+ ): Promise<{ schema: string; table: string; forced: boolean }[]> {
221
+ if (tables.length === 0) return [];
222
+ const { rows } = await client.query<{
223
+ schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;
224
+ }>(
225
+ `SELECT n.nspname AS schemaname,
226
+ c.relname AS tablename,
227
+ c.relrowsecurity,
228
+ c.relforcerowsecurity
229
+ FROM pg_class c
230
+ JOIN pg_namespace n ON n.oid = c.relnamespace
231
+ WHERE c.relkind = 'r'
232
+ AND (n.nspname || '.' || c.relname) = ANY($1)`,
233
+ [tables.map((t) => `${t.schema}.${t.table}`)]
234
+ );
235
+
236
+ return rows
237
+ .filter((r) => !r.relrowsecurity)
238
+ .map((r) => ({
239
+ schema: r.schemaname,
240
+ table: r.tablename,
241
+ forced: !!r.relforcerowsecurity
242
+ }));
243
+ }
244
+
152
245
  /**
153
246
  * The permissive tautology `auth.uid() IS NOT NULL`, without the
154
247
  * `<> 'anonymous'` guard that makes it mean "signed in".
@@ -195,13 +288,21 @@ export async function checkPolicyDrift(
195
288
  const schemas = [...new Set(expected.map((p) => p.schema))];
196
289
  // Nothing expected means nothing to reconcile against; scanning every
197
290
  // schema would report the whole database as orphaned.
198
- if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [] };
291
+ if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };
199
292
 
200
293
  const live = await readLivePolicies(client, schemas);
201
294
  const liveByKey = new Map(live.map((p) => [keyOf(p), p]));
202
295
  const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));
203
296
 
204
- const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [] };
297
+ const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };
298
+
299
+ // Before comparing policy against policy: is the switch even on? A table
300
+ // with RLS disabled matches every expected policy on every field compared
301
+ // below, so this has to be asked separately or it cannot be asked at all.
302
+ const expectedTables = [...new Map(
303
+ expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])
304
+ ).values()];
305
+ drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);
205
306
 
206
307
  // Scan every live policy for the permissive tautology. This is deliberately
207
308
  // independent of the name-keyed diff below: a database pushed before the
@@ -234,6 +335,16 @@ export async function checkPolicyDrift(
234
335
  if (want.command !== got.command) {
235
336
  differences.push(`command: expected ${want.command}, database has ${got.command}`);
236
337
  }
338
+ // Skipped when either side is unknown: an unrecognised catalogue shape
339
+ // should not manufacture drift. See `normalizeMode`.
340
+ if (want.mode && got.mode && want.mode !== got.mode) {
341
+ differences.push(
342
+ `mode: expected ${want.mode}, database has ${got.mode}` +
343
+ (got.mode === "PERMISSIVE"
344
+ ? " — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it"
345
+ : " — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it")
346
+ );
347
+ }
237
348
  for (const clause of ["USING", "WITH CHECK"] as const) {
238
349
  const key = clause === "USING" ? "hasUsing" : "hasWithCheck";
239
350
  if (want[key] === got[key]) continue;
@@ -308,13 +419,24 @@ export async function dropOrphanedPolicies(
308
419
  }
309
420
 
310
421
  export const hasDrift = (d: PolicyDrift): boolean =>
311
- d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0;
422
+ d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0
423
+ || d.rlsDisabled.length > 0;
312
424
 
313
425
  /** Human-readable report; empty string when the database matches the config. */
314
426
  export function formatPolicyDrift(drift: PolicyDrift): string {
315
427
  if (!hasDrift(drift)) return "";
316
428
  const lines: string[] = [];
317
429
 
430
+ // First, because it subsumes every other finding on the same table: if RLS
431
+ // is off, the policies listed below are not being applied at all.
432
+ if (drift.rlsDisabled.length > 0) {
433
+ lines.push(" RLS DISABLED — the table has policies, and Postgres is applying none of them:");
434
+ for (const t of drift.rlsDisabled) {
435
+ lines.push(` • ${t.schema}.${t.table}${t.forced ? "" : " (and FORCE is off)"}`);
436
+ }
437
+ lines.push(" Every row is readable and writable by any request that reaches the table.");
438
+ lines.push(` Fix: ALTER TABLE "<schema>"."<table>" ENABLE ROW LEVEL SECURITY; or re-run \`rebase db push\`.`);
439
+ }
318
440
  if (drift.missing.length > 0) {
319
441
  lines.push(" Missing — described by your collections, absent from the database:");
320
442
  for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`);
@@ -23,9 +23,14 @@ import { logger } from "@rebasepro/server";
23
23
  * are validation/side-effects, not a security boundary.
24
24
  *
25
25
  * - **Server context** — the base (owner) connection: auth flows, migrations,
26
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
27
- * owner it bypasses RLS. This is the trusted plane, equivalent to
28
- * 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.
29
34
  *
30
35
  * This module provides the three pieces:
31
36
  *
@@ -261,7 +266,7 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
261
266
  });
262
267
  }
263
268
 
264
- logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
269
+ logger.debug(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
265
270
  }
266
271
 
267
272
  /**
@@ -2,8 +2,8 @@ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, lt, or, S
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
- import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
- import { generateForeignKeyName } from "@rebasepro/utils";
5
+ import { resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
+ import { generateForeignKeyName, toWireKey } from "@rebasepro/utils";
7
7
  import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
8
8
  import {
9
9
  getCollectionByPath,
@@ -159,14 +159,21 @@ export class FetchService {
159
159
  // Owning relation, resolved: the relation names its own local key.
160
160
  const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : undefined;
161
161
  if (declaredRelation?.kind === "belongsTo") {
162
- const foreignKey = columnAt(declaredRelation.localKey);
162
+ // `localKey` is the column; the table is keyed by the wire name.
163
+ const foreignKey = columnAt(fieldKeyForColumn(collection, declaredRelation.localKey));
163
164
  if (foreignKey) return foreignKey;
164
165
  }
165
166
 
166
- // No collection in hand — the two shapes an owning relation's key takes
167
- // by default (e.g. `project` → `project_id`, `userProfile` →
168
- // `user_profile_id`).
169
- for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
167
+ // No collection in hand — the shapes an owning relation's key takes by
168
+ // default (e.g. `project` → `projectId`, `userProfile` →
169
+ // `userProfileId`), then the snake forms for a project that authored the
170
+ // property under its column name.
171
+ for (const guess of [
172
+ `${orderBy}Id`,
173
+ toWireKey(generateForeignKeyName(orderBy)),
174
+ `${orderBy}_id`,
175
+ generateForeignKeyName(orderBy)
176
+ ]) {
170
177
  const foreignKey = columnAt(guess);
171
178
  if (foreignKey) return foreignKey;
172
179
  }
@@ -326,6 +333,14 @@ export class FetchService {
326
333
  );
327
334
  }
328
335
  } catch (e) {
336
+ // A relation that failed to load is not a relation that is absent.
337
+ // Without this the request answers 200 with the field quietly
338
+ // missing — and because a Postgres error poisons the surrounding
339
+ // transaction, every later relation in the same request is
340
+ // swallowed too, so one failure becomes a response missing
341
+ // several fields. Same guard the four other catches in this file
342
+ // already use.
343
+ if (reachedDatabase(e)) throw e;
329
344
  logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
330
345
  }
331
346
  });
@@ -404,6 +419,14 @@ export class FetchService {
404
419
  }
405
420
  }
406
421
  } catch (e) {
422
+ // A relation that failed to load is not a relation that is absent.
423
+ // Without this the request answers 200 with the field quietly
424
+ // missing — and because a Postgres error poisons the surrounding
425
+ // transaction, every later relation in the same request is
426
+ // swallowed too, so one failure becomes a response missing
427
+ // several fields. Same guard the four other catches in this file
428
+ // already use.
429
+ if (reachedDatabase(e)) throw e;
407
430
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
408
431
  }
409
432
  }
@@ -727,6 +750,14 @@ idColumn };
727
750
  values[key] = createRelationRef(e.id, e.path);
728
751
  }
729
752
  } catch (e) {
753
+ // A relation that failed to load is not a relation that is absent.
754
+ // Without this the request answers 200 with the field quietly
755
+ // missing — and because a Postgres error poisons the surrounding
756
+ // transaction, every later relation in the same request is
757
+ // swallowed too, so one failure becomes a response missing
758
+ // several fields. Same guard the four other catches in this file
759
+ // already use.
760
+ if (reachedDatabase(e)) throw e;
730
761
  logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
731
762
  }
732
763
  }
@@ -998,6 +1029,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
998
1029
  }
999
1030
  });
1000
1031
  } catch (e) {
1032
+ // A relation that failed to load is not a relation that is absent.
1033
+ // Without this the request answers 200 with the field quietly
1034
+ // missing — and because a Postgres error poisons the surrounding
1035
+ // transaction, every later relation in the same request is
1036
+ // swallowed too, so one failure becomes a response missing
1037
+ // several fields. Same guard the four other catches in this file
1038
+ // already use.
1039
+ if (reachedDatabase(e)) throw e;
1001
1040
  logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
1002
1041
  }
1003
1042
  }
@@ -1025,6 +1064,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1025
1064
  );
1026
1065
  });
1027
1066
  } catch (e) {
1067
+ // A relation that failed to load is not a relation that is absent.
1068
+ // Without this the request answers 200 with the field quietly
1069
+ // missing — and because a Postgres error poisons the surrounding
1070
+ // transaction, every later relation in the same request is
1071
+ // swallowed too, so one failure becomes a response missing
1072
+ // several fields. Same guard the four other catches in this file
1073
+ // already use.
1074
+ if (reachedDatabase(e)) throw e;
1028
1075
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
1029
1076
  }
1030
1077
  }
@@ -1081,6 +1128,15 @@ relatedTo: hop });
1081
1128
  searchString: string,
1082
1129
  options: {
1083
1130
  filter?: FilterValues<Extract<keyof M, string>>;
1131
+ /**
1132
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
1133
+ *
1134
+ * `fetchRowsWithConditions` has always applied one; it was missing
1135
+ * from this signature, so a realtime search subscription carrying a
1136
+ * group could not pass it on and served every row matching the text
1137
+ * that RLS allowed.
1138
+ */
1139
+ logical?: LogicalCondition;
1084
1140
  orderBy?: string;
1085
1141
  order?: "desc" | "asc";
1086
1142
  limit?: number;
@@ -1315,6 +1371,14 @@ relatedTo: hop }, include
1315
1371
  }
1316
1372
  }
1317
1373
  } catch (e) {
1374
+ // A relation that failed to load is not a relation that is absent.
1375
+ // Without this the request answers 200 with the field quietly
1376
+ // missing — and because a Postgres error poisons the surrounding
1377
+ // transaction, every later relation in the same request is
1378
+ // swallowed too, so one failure becomes a response missing
1379
+ // several fields. Same guard the four other catches in this file
1380
+ // already use.
1381
+ if (reachedDatabase(e)) throw e;
1318
1382
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
1319
1383
  }
1320
1384
  }
@@ -1331,6 +1395,14 @@ relatedTo: hop }, include
1331
1395
  (row as Record<string, unknown>)[key] = relatedList;
1332
1396
  }
1333
1397
  } catch (e) {
1398
+ // A relation that failed to load is not a relation that is absent.
1399
+ // Without this the request answers 200 with the field quietly
1400
+ // missing — and because a Postgres error poisons the surrounding
1401
+ // transaction, every later relation in the same request is
1402
+ // swallowed too, so one failure becomes a response missing
1403
+ // several fields. Same guard the four other catches in this file
1404
+ // already use.
1405
+ if (reachedDatabase(e)) throw e;
1334
1406
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
1335
1407
  }
1336
1408
  }
@@ -1439,6 +1511,14 @@ relatedTo: hop }, include
1439
1511
  }));
1440
1512
  }
1441
1513
  } catch (e) {
1514
+ // A relation that failed to load is not a relation that is absent.
1515
+ // Without this the request answers 200 with the field quietly
1516
+ // missing — and because a Postgres error poisons the surrounding
1517
+ // transaction, every later relation in the same request is
1518
+ // swallowed too, so one failure becomes a response missing
1519
+ // several fields. Same guard the four other catches in this file
1520
+ // already use.
1521
+ if (reachedDatabase(e)) throw e;
1442
1522
  logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
1443
1523
  }
1444
1524
  }
@@ -1453,6 +1533,19 @@ relatedTo: hop }, include
1453
1533
  collectionPath: string,
1454
1534
  options: {
1455
1535
  filter?: FilterValues<Extract<keyof M, string>>;
1536
+ /**
1537
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
1538
+ *
1539
+ * Declared *and applied*, because this is the path every REST search
1540
+ * and vector read takes: `fetchCollectionForRest` skips `db.query`
1541
+ * whenever a `searchString` or a `vectorSearch` is present. The
1542
+ * group arrived here on `options` from the very beginning and was
1543
+ * simply never read, so `?searchString=x&or=(...)` served every row
1544
+ * matching `x` that RLS allowed — while `count` (which does apply
1545
+ * it) reported the narrowed total, so `meta.total` and `data`
1546
+ * described different sets of rows.
1547
+ */
1548
+ logical?: LogicalCondition;
1456
1549
  orderBy?: string;
1457
1550
  order?: "desc" | "asc";
1458
1551
  limit?: number;
@@ -1520,6 +1613,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1520
1613
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
1521
1614
  }
1522
1615
 
1616
+ if (options.logical) {
1617
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
1618
+ if (logicalCondition) allConditions.push(logicalCondition);
1619
+ }
1620
+
1523
1621
  if (vectorMeta?.filter) {
1524
1622
  allConditions.push(vectorMeta.filter);
1525
1623
  }