@rebasepro/server-postgres 0.14.1 → 0.14.2-canary.g27a129e
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/{auth-users-columns-C-FDnL_e.js → auth-users-columns-JJ8ngvy5.js} +59 -17
- package/dist/auth-users-columns-JJ8ngvy5.js.map +1 -0
- package/dist/{backup-service-BH0Dzo_h.js → backup-service-czK-OAuG.js} +2 -2
- package/dist/{backup-service-BH0Dzo_h.js.map → backup-service-czK-OAuG.js.map} +1 -1
- package/dist/{ensure-collection-policies-DoHwhVf8.js → ensure-collection-policies-D5PtQLyR.js} +3 -3
- package/dist/{ensure-collection-policies-DoHwhVf8.js.map → ensure-collection-policies-D5PtQLyR.js.map} +1 -1
- package/dist/{ensure-collection-tables-DT2eq859.js → ensure-collection-tables-BHUjQ-z4.js} +3 -3
- package/dist/{ensure-collection-tables-DT2eq859.js.map → ensure-collection-tables-BHUjQ-z4.js.map} +1 -1
- package/dist/index.es.js +485 -19
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-69hYT8nr.js → rls-bootstrap-sql-B5Sajku6.js} +2 -2
- package/dist/{rls-bootstrap-sql-69hYT8nr.js.map → rls-bootstrap-sql-B5Sajku6.js.map} +1 -1
- package/dist/{rls-enforcement-gUNDfm7l.js → rls-enforcement-BDBfuTD4.js} +4 -3
- package/dist/rls-enforcement-BDBfuTD4.js.map +1 -0
- package/dist/services/FetchService.d.ts +22 -0
- package/dist/{src-DCdn3Val.js → src-BBFsDaeA.js} +60 -2
- package/dist/src-BBFsDaeA.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +168 -1
- package/dist/utils/pg-error-utils.d.ts +27 -0
- package/dist/{websocket-D2jXv0Ds.js → websocket-BVgDVO-V.js} +2 -2
- package/dist/{websocket-D2jXv0Ds.js.map → websocket-BVgDVO-V.js.map} +1 -1
- package/package.json +6 -6
- package/src/services/FetchService.ts +155 -15
- package/src/services/PersistService.ts +23 -2
- package/src/utils/drizzle-conditions.ts +594 -1
- package/src/utils/pg-error-utils.ts +49 -4
- package/dist/auth-users-columns-C-FDnL_e.js.map +0 -1
- package/dist/rls-enforcement-gUNDfm7l.js.map +0 -1
- package/dist/src-DCdn3Val.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, isNotNull, isNull, lt, or, sql, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
-
import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
|
|
3
|
+
import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, LogicalCondition, isManyToMany, parseRelationAggregateSort } from "@rebasepro/types";
|
|
4
4
|
import type { VectorSearchParams } from "@rebasepro/types";
|
|
5
5
|
import { resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, createRelationRefWithData, normalizeDriverOrderBy } from "@rebasepro/common";
|
|
6
6
|
import { generateForeignKeyName, toWireKey } from "@rebasepro/utils";
|
|
@@ -30,8 +30,21 @@ import { reachedDatabase } from "../utils/pg-error-utils";
|
|
|
30
30
|
/** Type-safe accessor for Drizzle's relational query API via dynamic table name */
|
|
31
31
|
type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* One sort key, with the column or expression it was resolved to.
|
|
35
|
+
*
|
|
36
|
+
* `cursorTarget` is the same expression pinned to the cursor row instead of the
|
|
37
|
+
* outer one, and is set only for a key that has no stored value to compare a
|
|
38
|
+
* later page against — an aggregate over a relation. Where it is present the
|
|
39
|
+
* keyset comparison recomputes the cursor row's value in SQL rather than
|
|
40
|
+
* reading it off the cursor; see {@link FetchService.buildKeysetComparison}.
|
|
41
|
+
*/
|
|
42
|
+
type ResolvedOrderKey = {
|
|
43
|
+
field: string;
|
|
44
|
+
direction: "asc" | "desc";
|
|
45
|
+
target: AnyPgColumn | SQL;
|
|
46
|
+
cursorTarget?: SQL;
|
|
47
|
+
};
|
|
35
48
|
|
|
36
49
|
/**
|
|
37
50
|
* Service for handling all row read operations.
|
|
@@ -148,6 +161,52 @@ export class FetchService {
|
|
|
148
161
|
return this.resolveOrderByField(table, orderBy, collection);
|
|
149
162
|
}
|
|
150
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The aggregate a sort key names, as an expression, or `undefined` if the
|
|
166
|
+
* key is not one.
|
|
167
|
+
*
|
|
168
|
+
* `cursorId` builds the same expression pinned to the cursor row — see
|
|
169
|
+
* {@link DrizzleConditionBuilder.buildRelationAggregateExpression}.
|
|
170
|
+
*
|
|
171
|
+
* A key that *parses* as an aggregate but names no relation, or a column
|
|
172
|
+
* the target does not have, throws rather than falling through to the
|
|
173
|
+
* column path. Falling through would report `min(applications.created_at)`
|
|
174
|
+
* as an unknown column and list the columns of the wrong table.
|
|
175
|
+
*/
|
|
176
|
+
private resolveAggregateOrderTarget(
|
|
177
|
+
table: PgTable<any>,
|
|
178
|
+
orderBy: string,
|
|
179
|
+
collection: CollectionConfig | undefined,
|
|
180
|
+
collectionPath: string | undefined,
|
|
181
|
+
cursorId?: unknown
|
|
182
|
+
): SQL | undefined {
|
|
183
|
+
const spec = parseRelationAggregateSort(orderBy);
|
|
184
|
+
if (!spec) return undefined;
|
|
185
|
+
if (!collection || !collectionPath) {
|
|
186
|
+
throw ApiError.badRequest(
|
|
187
|
+
`Cannot sort by '${orderBy}': an aggregate sort needs the collection it is written against, ` +
|
|
188
|
+
"and this query carries none.",
|
|
189
|
+
"ORDER_BY_FIELD_NOT_SORTABLE",
|
|
190
|
+
{ field: orderBy }
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const primaryKeys = getPrimaryKeys(collection, this.registry);
|
|
194
|
+
const idColumn = primaryKeys.length === 1
|
|
195
|
+
? table[primaryKeys[0].fieldName as keyof typeof table] as AnyPgColumn
|
|
196
|
+
: undefined;
|
|
197
|
+
if (!idColumn) {
|
|
198
|
+
throw ApiError.badRequest(
|
|
199
|
+
`Cannot sort by '${orderBy}' on collection '${collectionPath}': the subquery correlates on a ` +
|
|
200
|
+
"single key column, and this collection has none or has a composite one.",
|
|
201
|
+
"ORDER_BY_FIELD_NOT_SORTABLE",
|
|
202
|
+
{ field: orderBy, collection: collectionPath }
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return DrizzleConditionBuilder.buildRelationAggregateExpression(
|
|
206
|
+
spec, table, collection, this.registry, idColumn, collectionPath, cursorId
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
151
210
|
/**
|
|
152
211
|
* Resolve every sort key to the expression it orders by, in order of
|
|
153
212
|
* significance.
|
|
@@ -161,10 +220,31 @@ export class FetchService {
|
|
|
161
220
|
table: PgTable<any>,
|
|
162
221
|
keys: OrderByTuple[],
|
|
163
222
|
collection?: CollectionConfig,
|
|
164
|
-
searchString?: string
|
|
223
|
+
searchString?: string,
|
|
224
|
+
collectionPath?: string,
|
|
225
|
+
cursorId?: unknown
|
|
165
226
|
): ResolvedOrderKey[] {
|
|
166
227
|
const resolved: ResolvedOrderKey[] = [];
|
|
167
228
|
for (const [field, direction] of keys) {
|
|
229
|
+
// Checked before the column path: an aggregate key is not a column
|
|
230
|
+
// name and would otherwise be reported as a typo'd one.
|
|
231
|
+
const aggregate = this.resolveAggregateOrderTarget(table, field, collection, collectionPath);
|
|
232
|
+
if (aggregate) {
|
|
233
|
+
resolved.push({
|
|
234
|
+
field,
|
|
235
|
+
direction,
|
|
236
|
+
target: aggregate,
|
|
237
|
+
// Built only when a cursor is in play: it is a second
|
|
238
|
+
// subquery, and a listing with no `startAfter` has nothing
|
|
239
|
+
// to compare against.
|
|
240
|
+
...(cursorId !== undefined && {
|
|
241
|
+
cursorTarget: this.resolveAggregateOrderTarget(
|
|
242
|
+
table, field, collection, collectionPath, cursorId
|
|
243
|
+
)
|
|
244
|
+
})
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
168
248
|
const target = this.resolveOrderTarget(table, field, collection, searchString);
|
|
169
249
|
if (target) resolved.push({ field,
|
|
170
250
|
direction,
|
|
@@ -183,9 +263,20 @@ target });
|
|
|
183
263
|
* to agree: they did not, and an ascending sort paged with `id >` against an
|
|
184
264
|
* `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
|
|
185
265
|
* every page after the first.
|
|
266
|
+
*
|
|
267
|
+
* Where the NULLs go is written out rather than inherited. Postgres already
|
|
268
|
+
* defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this
|
|
269
|
+
* changes no query — but {@link buildKeysetComparison} encodes that exact
|
|
270
|
+
* placement, and an invariant two functions depend on should be stated in
|
|
271
|
+
* both rather than assumed in one. It matters most for the keys that are
|
|
272
|
+
* *always* nullable: an aggregate over a relation is NULL for every row the
|
|
273
|
+
* relation reaches nothing from, which is precisely the "nobody waiting"
|
|
274
|
+
* end of a queue.
|
|
186
275
|
*/
|
|
187
276
|
private buildOrderExpressions(keys: ResolvedOrderKey[], idField: AnyPgColumn): SQL[] {
|
|
188
|
-
const expressions = keys.map(({ direction, target }) => direction === "asc"
|
|
277
|
+
const expressions = keys.map(({ direction, target }) => direction === "asc"
|
|
278
|
+
? sql`${target} ASC NULLS LAST`
|
|
279
|
+
: sql`${target} DESC NULLS FIRST`);
|
|
189
280
|
expressions.push(desc(idField));
|
|
190
281
|
return expressions as SQL[];
|
|
191
282
|
}
|
|
@@ -603,8 +694,14 @@ target });
|
|
|
603
694
|
);
|
|
604
695
|
}
|
|
605
696
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
|
|
606
|
-
const resolved = this.resolveOrderKeys(table, keys, collection);
|
|
607
697
|
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
698
|
+
const resolved = this.resolveOrderKeys(
|
|
699
|
+
table, keys, collection, undefined, collectionPath,
|
|
700
|
+
// A null id addresses no row, so pinning a subquery to it would
|
|
701
|
+
// aggregate over nothing and read as "the cursor row has no
|
|
702
|
+
// related rows" rather than as the absent cursor it is.
|
|
703
|
+
startAfterId ?? undefined
|
|
704
|
+
);
|
|
608
705
|
|
|
609
706
|
if (resolved.length > 0 && startAfterId !== undefined) {
|
|
610
707
|
const cursorValues = cursor.values as Record<string, unknown> | undefined;
|
|
@@ -612,15 +709,19 @@ target });
|
|
|
612
709
|
// NULL is a row this has to be able to page past, and `??`
|
|
613
710
|
// read it as "the cursor did not carry this key" and dropped
|
|
614
711
|
// the whole condition.
|
|
615
|
-
const values = resolved.map(({ field }) =>
|
|
616
|
-
|
|
617
|
-
|
|
712
|
+
const values = resolved.map(({ field, cursorTarget }) => cursorTarget
|
|
713
|
+
// An aggregate is not stored on the row, so the cursor
|
|
714
|
+
// never carried it and never could. Its value is recomputed
|
|
715
|
+
// from the cursor id instead, in SQL, by `cursorTarget` —
|
|
716
|
+
// there is nothing for this list to supply.
|
|
717
|
+
? null
|
|
718
|
+
: (cursorValues && field in cursorValues) ? cursorValues[field] : cursor[field]);
|
|
618
719
|
// Every key needs a value from the cursor row. A missing one
|
|
619
720
|
// cannot be guessed, and a comparison built from the keys that
|
|
620
721
|
// happen to be present is not the same comparison — so this
|
|
621
722
|
// falls through to no cursor condition, which is what a single
|
|
622
723
|
// missing sort value has always done here.
|
|
623
|
-
if (values.every((value) => value !== undefined)) {
|
|
724
|
+
if (values.every((value, i) => resolved[i].cursorTarget || value !== undefined)) {
|
|
624
725
|
return [this.buildKeysetComparison(resolved, values, idField, startAfterId)];
|
|
625
726
|
}
|
|
626
727
|
}
|
|
@@ -660,15 +761,54 @@ target });
|
|
|
660
761
|
// the cursor row means a smaller id.
|
|
661
762
|
if (index >= keys.length) return lt(idField, cursorId);
|
|
662
763
|
|
|
663
|
-
const { direction } = keys[index];
|
|
664
|
-
// A column
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
764
|
+
const { direction, cursorTarget } = keys[index];
|
|
765
|
+
// A column or an expression. `_score` is an expression too, but a
|
|
766
|
+
// cursor over relevance is refused before this is reached; an aggregate
|
|
767
|
+
// over a relation is the one that gets here. Drizzle's comparison
|
|
768
|
+
// helpers are typed per operand kind, so the union has to be resolved
|
|
769
|
+
// here rather than at the call site.
|
|
668
770
|
const target = keys[index].target as AnyPgColumn;
|
|
669
771
|
const value = values[index];
|
|
670
772
|
const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
|
|
671
773
|
|
|
774
|
+
// No stored value to compare against — the cursor row's is recomputed
|
|
775
|
+
// by an expression instead, and whether it is NULL is a question only
|
|
776
|
+
// SQL can answer. So both branches of the null test below have to exist
|
|
777
|
+
// in the statement rather than being chosen here.
|
|
778
|
+
//
|
|
779
|
+
// `cursorTarget` references only the cursor id, never the outer row, so
|
|
780
|
+
// Postgres evaluates it once for the whole statement rather than per
|
|
781
|
+
// row — repeating it across the branches costs nothing.
|
|
782
|
+
if (cursorTarget) {
|
|
783
|
+
return direction === "asc"
|
|
784
|
+
// NULLS LAST. A cursor row among the NULLs has only later NULLs
|
|
785
|
+
// after it; otherwise everything greater, then the NULLs, then
|
|
786
|
+
// the ties.
|
|
787
|
+
? or(
|
|
788
|
+
and(isNull(cursorTarget), isNull(target), rest),
|
|
789
|
+
and(
|
|
790
|
+
isNotNull(cursorTarget),
|
|
791
|
+
or(
|
|
792
|
+
sql`${target} > ${cursorTarget}`,
|
|
793
|
+
isNull(target),
|
|
794
|
+
and(sql`${target} = ${cursorTarget}`, rest)
|
|
795
|
+
)
|
|
796
|
+
)
|
|
797
|
+
)!
|
|
798
|
+
// NULLS FIRST. A cursor row among the NULLs still has every
|
|
799
|
+
// non-null row after it.
|
|
800
|
+
: or(
|
|
801
|
+
and(isNull(cursorTarget), or(isNotNull(target), and(isNull(target), rest))),
|
|
802
|
+
and(
|
|
803
|
+
isNotNull(cursorTarget),
|
|
804
|
+
or(
|
|
805
|
+
sql`${target} < ${cursorTarget}`,
|
|
806
|
+
and(sql`${target} = ${cursorTarget}`, rest)
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
)!;
|
|
810
|
+
}
|
|
811
|
+
|
|
672
812
|
if (value === null) {
|
|
673
813
|
// The cursor row sorts among the NULLs.
|
|
674
814
|
return direction === "asc"
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type NestedPathHop
|
|
27
27
|
} from "./nested-path";
|
|
28
28
|
import { ApiError, logger } from "@rebasepro/server";
|
|
29
|
-
import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage } from "../utils/pg-error-utils";
|
|
29
|
+
import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage, isRowLevelSecurityDenial } from "../utils/pg-error-utils";
|
|
30
30
|
import { explainZeroRowWrite } from "./write-denial";
|
|
31
31
|
|
|
32
32
|
/**
|
|
@@ -511,12 +511,33 @@ export class PersistService {
|
|
|
511
511
|
// reported to callers as a bad request. Classes 22 (data exception)
|
|
512
512
|
// and 23 (integrity constraint violation) are the caller's data;
|
|
513
513
|
// everything else — a dropped connection, a missing column, a
|
|
514
|
-
//
|
|
514
|
+
// *privilege* problem — is ours, and stays a 500.
|
|
515
515
|
if (/^2[23]/.test(code)) {
|
|
516
516
|
return code === "23505"
|
|
517
517
|
? ApiError.conflict(message, `PG_${code}`)
|
|
518
518
|
: ApiError.badRequest(message, `PG_${code}`);
|
|
519
519
|
}
|
|
520
|
+
// With one exception inside class 42: a row-level-security policy
|
|
521
|
+
// refusing the caller is not a fault at all, it is access control
|
|
522
|
+
// working. It fell through to the 500 below, so the client could not
|
|
523
|
+
// tell "you may not do this" from "the server is broken" — and a
|
|
524
|
+
// 500's message is sanitized on the way out, so the reason was lost
|
|
525
|
+
// too. `expected`, because a caller attempting what their policies
|
|
526
|
+
// forbid is routine and should not page anyone; that is the same
|
|
527
|
+
// treatment `unauthenticated()` gets one status code down.
|
|
528
|
+
// `WRITE_DENIED`, the same code `explainZeroRowWrite` returns for an
|
|
529
|
+
// UPDATE or DELETE that RLS refused. Those already answered 403; only
|
|
530
|
+
// INSERT reached here, because a failed `WITH CHECK` raises 42501 while
|
|
531
|
+
// a refused UPDATE simply matches no rows. Two spellings of one denial
|
|
532
|
+
// should not be two status codes.
|
|
533
|
+
//
|
|
534
|
+
// Left at the default log level rather than `expected`: `ApiError`'s
|
|
535
|
+
// own documentation puts "a permission the database refused" in the
|
|
536
|
+
// stays-at-warn column, and the status code is the defect here. The
|
|
537
|
+
// log level is a separate decision that already has an answer.
|
|
538
|
+
if (isRowLevelSecurityDenial(error)) {
|
|
539
|
+
return ApiError.forbidden(message, "WRITE_DENIED");
|
|
540
|
+
}
|
|
520
541
|
return new Error(message);
|
|
521
542
|
}
|
|
522
543
|
|