@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.
Files changed (29) hide show
  1. package/dist/{auth-users-columns-C-FDnL_e.js → auth-users-columns-JJ8ngvy5.js} +59 -17
  2. package/dist/auth-users-columns-JJ8ngvy5.js.map +1 -0
  3. package/dist/{backup-service-BH0Dzo_h.js → backup-service-czK-OAuG.js} +2 -2
  4. package/dist/{backup-service-BH0Dzo_h.js.map → backup-service-czK-OAuG.js.map} +1 -1
  5. package/dist/{ensure-collection-policies-DoHwhVf8.js → ensure-collection-policies-D5PtQLyR.js} +3 -3
  6. package/dist/{ensure-collection-policies-DoHwhVf8.js.map → ensure-collection-policies-D5PtQLyR.js.map} +1 -1
  7. package/dist/{ensure-collection-tables-DT2eq859.js → ensure-collection-tables-BHUjQ-z4.js} +3 -3
  8. package/dist/{ensure-collection-tables-DT2eq859.js.map → ensure-collection-tables-BHUjQ-z4.js.map} +1 -1
  9. package/dist/index.es.js +485 -19
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/{rls-bootstrap-sql-69hYT8nr.js → rls-bootstrap-sql-B5Sajku6.js} +2 -2
  12. package/dist/{rls-bootstrap-sql-69hYT8nr.js.map → rls-bootstrap-sql-B5Sajku6.js.map} +1 -1
  13. package/dist/{rls-enforcement-gUNDfm7l.js → rls-enforcement-BDBfuTD4.js} +4 -3
  14. package/dist/rls-enforcement-BDBfuTD4.js.map +1 -0
  15. package/dist/services/FetchService.d.ts +22 -0
  16. package/dist/{src-DCdn3Val.js → src-BBFsDaeA.js} +60 -2
  17. package/dist/src-BBFsDaeA.js.map +1 -0
  18. package/dist/utils/drizzle-conditions.d.ts +168 -1
  19. package/dist/utils/pg-error-utils.d.ts +27 -0
  20. package/dist/{websocket-D2jXv0Ds.js → websocket-BVgDVO-V.js} +2 -2
  21. package/dist/{websocket-D2jXv0Ds.js.map → websocket-BVgDVO-V.js.map} +1 -1
  22. package/package.json +6 -6
  23. package/src/services/FetchService.ts +155 -15
  24. package/src/services/PersistService.ts +23 -2
  25. package/src/utils/drizzle-conditions.ts +594 -1
  26. package/src/utils/pg-error-utils.ts +49 -4
  27. package/dist/auth-users-columns-C-FDnL_e.js.map +0 -1
  28. package/dist/rls-enforcement-gUNDfm7l.js.map +0 -1
  29. 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
- /** One sort key, with the column or expression it was resolved to. */
34
- type ResolvedOrderKey = { field: string; direction: "asc" | "desc"; target: AnyPgColumn | SQL };
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" ? asc(target) : desc(target));
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 }) => (cursorValues && field in cursorValues)
616
- ? cursorValues[field]
617
- : cursor[field]);
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, not an expression: the one target that is an expression is
665
- // `_score`, and a cursor over relevance is refused before this is
666
- // reached. Drizzle's comparison helpers are typed per operand kind, so
667
- // the union has to be resolved here rather than at the call site.
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
- // permission problem — is ours, and stays a 500.
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