@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 { SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import { CollectionConfig, FilterValues, WhereFilterOp, LogicalCondition, FilterCondition, ResolvedRelation, ResolvedForeignKeyOnTarget, ResolvedManyToMany } from "@rebasepro/types";
3
+ import { CollectionConfig, FilterValues, WhereFilterOp, LogicalCondition, FilterCondition, ResolvedRelation, ResolvedBelongsTo, ResolvedForeignKeyOnTarget, ResolvedManyToMany, type RelationAggregateSort } from "@rebasepro/types";
4
4
  import { type SearchColumnSpec } from "../schema/search-column";
5
5
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
6
6
  /**
@@ -82,6 +82,52 @@ export interface FilterCompilationOptions {
82
82
  */
83
83
  sourceIdColumn?: AnyPgColumn;
84
84
  }
85
+ /**
86
+ * What a filter field turns out to name.
87
+ *
88
+ * A field naming a column compiles to a comparison on it. A field naming a
89
+ * relation that owns no column here compiles to a whole `EXISTS` condition
90
+ * instead, so there is no column to hand back — which is why resolution
91
+ * answers with a discriminated result rather than a column. The caller cannot
92
+ * tell the two apart from the field name, and the difference is not cosmetic:
93
+ * one is `column <op> value`, the other is a correlated subquery.
94
+ */
95
+ type FilterTarget = {
96
+ kind: "column";
97
+ column: AnyPgColumn;
98
+ } | {
99
+ /** A path *inside* a json/jsonb column — `metadata->>country`. */
100
+ kind: "json";
101
+ column: AnyPgColumn;
102
+ /** The keys to walk, outermost first. Always at least one. */
103
+ path: string[];
104
+ } | {
105
+ kind: "relation";
106
+ relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany;
107
+ /** Bound here so the compile step cannot be reached without them. */
108
+ registry: PostgresCollectionRegistry;
109
+ sourceIdColumn: AnyPgColumn;
110
+ } | {
111
+ /**
112
+ * A *column of the related row* — `applications.status`. Same `EXISTS`
113
+ * as `relation`, with the predicate moved off the target's id and onto
114
+ * one of its columns.
115
+ */
116
+ kind: "relation-field";
117
+ /**
118
+ * `via` is not among them: it is refused at resolution, and leaving it
119
+ * in the type would let a later edit reach the compile step with a
120
+ * relation there is no correlation for.
121
+ */
122
+ relation: ResolvedBelongsTo | ResolvedForeignKeyOnTarget | ResolvedManyToMany;
123
+ registry: PostgresCollectionRegistry;
124
+ /** The column on *this* table the subquery correlates back to. */
125
+ sourceIdColumn: AnyPgColumn;
126
+ /** The table the predicate is asked of, already resolved. */
127
+ targetTable: PgTable<any>;
128
+ /** The column on {@link targetTable} the predicate compares. */
129
+ targetColumn: AnyPgColumn;
130
+ };
85
131
  /** Drizzle dynamic query builder — accepts innerJoin + where chaining */
86
132
  export interface DrizzleDynamicQuery {
87
133
  innerJoin(table: PgTable<any>, condition: SQL): this;
@@ -166,6 +212,23 @@ export declare class DrizzleConditionBuilder {
166
212
  * rather than a half-built condition.
167
213
  */
168
214
  private static resolveFilterTarget;
215
+ /**
216
+ * `applications.status` — the relation, and the column of the target it
217
+ * addresses.
218
+ *
219
+ * `undefined` when the first segment names no relation: the field simply is
220
+ * not a relation path, and resolution carries on to the guesses and then to
221
+ * the unknown-field answer, which is where a typo belongs. A segment that
222
+ * *does* name a relation is a different matter — the author plainly meant
223
+ * this shape — so everything after that point throws rather than returning,
224
+ * naming what went wrong. Falling through would report "unknown filter
225
+ * field 'applications.status'" and list the columns of the wrong table.
226
+ *
227
+ * `via` is refused for the reason it is absent from
228
+ * `filterableRelationKinds`: its join path is authored source → target with
229
+ * no stated inverse, so there is nothing to correlate a subquery back to.
230
+ */
231
+ private static resolveRelationFieldTarget;
169
232
  /**
170
233
  * Build filter conditions from FilterValues
171
234
  */
@@ -278,6 +341,109 @@ export declare class DrizzleConditionBuilder {
278
341
  * a relation are exactly the six below.
279
342
  */
280
343
  private static buildRelationFilterPredicate;
344
+ /**
345
+ * A filter on a *column of the related row* — `applications.status`.
346
+ *
347
+ * The same `EXISTS` {@link buildRelationFilterCondition} builds, with the
348
+ * predicate moved off the target's id and onto one of its columns:
349
+ *
350
+ * EXISTS (SELECT 1 FROM talent_applications AS t
351
+ * WHERE t.talent_id = talents.id
352
+ * AND t.status IN ('applied', 'reviewing', 'interview'))
353
+ *
354
+ * which is the shape every "who is waiting" queue is written in. Without
355
+ * it the only way to ask is to fetch every row and filter in the browser,
356
+ * and a filter the client applies after paging is not a filter — the page
357
+ * was already chosen without it.
358
+ *
359
+ * A many-to-many needs one more table than the id filter does. That one
360
+ * stops at the junction, because the junction already holds the value it
361
+ * compares; a column of the target is a table further out, so the subquery
362
+ * joins the target to the junction and correlates from the junction. The
363
+ * join is inside `EXISTS`, so it cannot multiply the outer rows the way a
364
+ * top-level join through a junction would.
365
+ *
366
+ * `belongsTo` is included even though its foreign key is a column here:
367
+ * `author.name` is a column of another table either way, and refusing the
368
+ * one relation kind that reads most naturally would be a rule about
369
+ * implementation rather than about meaning.
370
+ *
371
+ * Under RLS the subquery runs as the reader, so it sees the target rows
372
+ * that reader's policies allow and no others. On the positive direction
373
+ * that is exactly right. On the negative — `!=`, `not-in`, and any
374
+ * `NOT EXISTS` — "no related row satisfies this" and "no related row this
375
+ * reader can see satisfies this" are the same sentence, so a target table
376
+ * with row-level security and no `SELECT` policy for `rebase_user` makes
377
+ * every row look unmatched and the negative filter over-reports. Nothing is
378
+ * leaked: the outer table's own policies still decide which rows exist. The
379
+ * cause is a missing policy on the target rather than anything here, and it
380
+ * is the same caveat the id-filter path carries.
381
+ */
382
+ static buildRelationFieldCondition(target: Extract<FilterTarget, {
383
+ kind: "relation-field";
384
+ }>, op: WhereFilterOp, value: unknown, field: string, collectionPath: string): SQL;
385
+ /**
386
+ * The inner predicate of a relation *column* filter, and whether the
387
+ * `EXISTS` wrapping it is negated.
388
+ *
389
+ * The negation rule is the one {@link buildRelationFilterPredicate} states
390
+ * and holds for exactly the same reason, one column over. A negative
391
+ * operator is `NOT EXISTS` of the **positive** predicate, never `EXISTS` of
392
+ * a negated one: `EXISTS (… AND status != 'hired')` asks "does some
393
+ * application differ from hired", which is true of nearly every candidate
394
+ * with more than one application and answers nothing anybody asked.
395
+ * `NOT EXISTS (… AND status = 'hired')` asks "is there no hired
396
+ * application", which is what unticking a value means — and it makes `==`
397
+ * and `!=` partition the rows, the way a filter implies they do.
398
+ *
399
+ * `is-null` and `is-not-null` are the exception, and deliberately not a
400
+ * complementary pair here. On a column they compile to `EXISTS (… AND col
401
+ * IS NULL)` and `EXISTS (… AND col IS NOT NULL)` — "has a related row whose
402
+ * column is unset" and "has one where it is set" — which is the plain
403
+ * reading of `applications.status is-not-null` and the useful one. They are
404
+ * both true of a candidate with two applications, one of each. Making
405
+ * `is-not-null` the negation instead would make it "no application has an
406
+ * unset status", which is true of a candidate with no applications at all
407
+ * and so answers a queue with the very rows the queue exists to exclude.
408
+ *
409
+ * Unlike the id path, every operator is available: the compared value is an
410
+ * ordinary column, so `>=` on a date and `ilike` on a name mean here what
411
+ * they mean anywhere else. Only an operator that does not exist is refused,
412
+ * and it throws rather than returning `null` — a dropped condition widens
413
+ * the read, which is the whole reason this file fails closed.
414
+ */
415
+ private static buildRelationColumnPredicate;
416
+ /**
417
+ * An aggregate over the rows a relation reaches, as a scalar expression —
418
+ * what `orderBy: [{ relation: "applications", field: "created_at", agg:
419
+ * "min" }, "asc"]` compiles to.
420
+ *
421
+ * (SELECT min(t.created_at) FROM talent_applications AS t
422
+ * WHERE t.talent_id = talents.id)
423
+ *
424
+ * A correlated scalar subquery rather than a `LEFT JOIN LATERAL`: the join
425
+ * would have to be threaded into a query the relational query builder
426
+ * assembles, while a scalar expression drops straight into `ORDER BY` and
427
+ * into the keyset comparison behind cursor paging — which has to be the
428
+ * *same* expression, or paging and ordering disagree and rows are skipped.
429
+ *
430
+ * `correlateTo` is what the subquery is pinned against. Left out, it is the
431
+ * outer row's key column and the expression is correlated in the ordinary
432
+ * way. Given a literal — the cursor row's id — the subquery stops being
433
+ * correlated at all, so Postgres evaluates it once for the whole statement
434
+ * rather than per row. That is how a cursor pages over an aggregate it has
435
+ * no stored value for: the value is recomputed from the id it does have.
436
+ *
437
+ * Over zero related rows `count` is 0 and every other function is NULL,
438
+ * which is what puts "nobody waiting" at a defined end of the order rather
439
+ * than wherever a missing value would land. See `buildOrderExpressions` for
440
+ * where that end is pinned.
441
+ *
442
+ * Under RLS the subquery runs as the reader, so a related row the reader
443
+ * cannot see does not contribute — an aggregate is over the rows that
444
+ * reader can see, which is the only total it could honestly report.
445
+ */
446
+ static buildRelationAggregateExpression(spec: RelationAggregateSort, table: PgTable<any>, collection: CollectionConfig, registry: PostgresCollectionRegistry, sourceIdColumn: AnyPgColumn, collectionPath: string, correlateTo?: unknown): SQL;
281
447
  /** The column a table's rows are keyed by: its primary key, else `id`. */
282
448
  private static primaryKeyColumn;
283
449
  /**
@@ -491,3 +657,4 @@ export declare class DrizzleConditionBuilder {
491
657
  * This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
492
658
  */
493
659
  export declare const PostgresConditionBuilder: typeof DrizzleConditionBuilder;
660
+ export {};
@@ -73,6 +73,33 @@ export declare function classifyConnectFailure(error: unknown): ConnectFailure;
73
73
  * error to the Studio SQL Editor user.
74
74
  */
75
75
  export declare function isRoleSwitchingPermissionError(error: unknown): boolean;
76
+ /**
77
+ * Was this `42501` the *caller* being refused by a policy, rather than the
78
+ * server lacking a privilege?
79
+ *
80
+ * Both arrive as `insufficient_privilege`, and they are opposite kinds of
81
+ * problem. A row-level-security refusal is a working access-control system
82
+ * doing its job: the caller asked for something their policies do not permit,
83
+ * which is a 403 and nobody's bug. A missing `GRANT` is the deployment being
84
+ * wrong — the connection role cannot touch the table at all, no policy is
85
+ * involved, and nothing the caller changes about the request will help.
86
+ *
87
+ * Postgres distinguishes them in the message, so this does too:
88
+ *
89
+ * new row violates row-level security policy for table "notes" → the caller
90
+ * permission denied for table notes → the server
91
+ *
92
+ * Only writes reach this. A read that RLS excludes is not an error — the rows
93
+ * are filtered and the caller gets an empty page — so the erroring case is
94
+ * specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.
95
+ *
96
+ * Matched on the message because that is the only thing carrying the
97
+ * distinction; the SQLSTATE is identical either way. Narrow by design: anything
98
+ * not naming row-level security stays the server's problem, since reporting a
99
+ * genuine privilege misconfiguration as "forbidden" would send an operator
100
+ * hunting for a policy bug that does not exist.
101
+ */
102
+ export declare function isRowLevelSecurityDenial(error: unknown): boolean;
76
103
  /**
77
104
  * Translate a raw PostgreSQL error into a user-friendly message.
78
105
  *
@@ -3,7 +3,7 @@ import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { c as __exportAll } from "./connection-BuZ97wsr.js";
5
5
  import { n as resolveClientListLimit, r as ANONYMOUS_USER_ID, t as ListLimitError } from "./data_driver-ULAyJEi9.js";
6
- import "./src-DCdn3Val.js";
6
+ import "./src-BBFsDaeA.js";
7
7
  import { ApiError, assertWriteRequestValid, extractUserFromToken, logger, resolveRequireAuth, safeCompare } from "@rebasepro/server";
8
8
  import { WebSocketServer } from "ws";
9
9
  import { inspect } from "util";
@@ -611,4 +611,4 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
611
611
  //#endregion
612
612
  export { websocket_exports as n, createPostgresWebSocket as t };
613
613
 
614
- //# sourceMappingURL=websocket-D2jXv0Ds.js.map
614
+ //# sourceMappingURL=websocket-BVgDVO-V.js.map