@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.
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1310 -1060
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/search-column.d.ts +49 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +8 -3
- package/dist/services/FetchService.d.ts +9 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +137 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +54 -1
- package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +95 -9
- package/src/auth/ensure-tables.ts +27 -5
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +4 -4
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +155 -147
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +111 -25
- package/src/history/ensure-history-table.ts +2 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +214 -17
- package/src/schema/generate-drizzle-schema-logic.ts +121 -65
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +28 -1
- package/src/schema/generate-postgres-ddl.ts +14 -13
- package/src/schema/generated-schema-staleness.ts +7 -5
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/search-column.ts +85 -0
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +9 -4
- package/src/services/FetchService.ts +105 -7
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +35 -695
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +9 -3
- package/src/services/channel-presence.ts +10 -3
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +2 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +337 -82
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +211 -34
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
- package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
- package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
- package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
- package/dist/policy-CPkCqVTz.js +0 -105
- package/dist/policy-CPkCqVTz.js.map +0 -1
- package/dist/src-C_wvdMnl.js.map +0 -1
- 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({
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
|
167
|
-
//
|
|
168
|
-
// `
|
|
169
|
-
|
|
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
|
}
|