@rebasepro/server-postgres 0.11.1-canary.gfd39654 → 0.12.1-canary.g009ed95

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 (98) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +33 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/backup-service-CD8o_1Sl.js +8999 -0
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/collections/buildRegistry.d.ts +1 -1
  10. package/dist/connection-BuZ97wsr.js +250 -0
  11. package/dist/connection-BuZ97wsr.js.map +1 -0
  12. package/dist/connection.d.ts +42 -0
  13. package/dist/ensure-collection-policies-BrUVgjz3.js +57 -0
  14. package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
  15. package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
  16. package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
  17. package/dist/history/HistoryService.d.ts +9 -29
  18. package/dist/index.es.js +1234 -9753
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/policy-CeA1JcxP.js +105 -0
  21. package/dist/policy-CeA1JcxP.js.map +1 -0
  22. package/dist/schema/auth-schema.d.ts +83 -144
  23. package/dist/schema/dynamic-tables.d.ts +1 -1
  24. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  25. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  27. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  28. package/dist/schema/introspect-db-logic.d.ts +94 -5
  29. package/dist/schema/introspect-db-queries.d.ts +119 -0
  30. package/dist/schema/introspect-db-structure.d.ts +263 -0
  31. package/dist/schema/introspect-db-types.d.ts +11 -0
  32. package/dist/schema/introspect-runtime.d.ts +1 -1
  33. package/dist/services/FetchService.d.ts +40 -2
  34. package/dist/services/RelationService.d.ts +24 -1
  35. package/dist/services/channel-bus/index.d.ts +1 -7
  36. package/dist/services/collection-helpers.d.ts +24 -1
  37. package/dist/services/dataService.d.ts +3 -1
  38. package/dist/services/row-pipeline.d.ts +4 -2
  39. package/dist/{src-3VmUJ8Xn.js → src-CzbghKwf.js} +464 -187
  40. package/dist/src-CzbghKwf.js.map +1 -0
  41. package/dist/{src-D5xBTl32.js → src-DoU9yPqq.js} +79 -189
  42. package/dist/src-DoU9yPqq.js.map +1 -0
  43. package/dist/utils/connection-string.d.ts +29 -0
  44. package/dist/utils/drizzle-conditions.d.ts +162 -7
  45. package/dist/utils/pg-error-utils.d.ts +25 -3
  46. package/dist/websocket-B2LsrINK.js +530 -0
  47. package/dist/websocket-B2LsrINK.js.map +1 -0
  48. package/package.json +14 -14
  49. package/src/PostgresAdapter.ts +21 -2
  50. package/src/PostgresBackendDriver.ts +4 -0
  51. package/src/PostgresBootstrapper.ts +212 -36
  52. package/src/auth/ensure-tables.ts +164 -9
  53. package/src/auth/services.ts +24 -2
  54. package/src/backup/backup-cli.ts +41 -2
  55. package/src/backup/backup-service.ts +38 -5
  56. package/src/backup/pg-tools.ts +96 -3
  57. package/src/cli-helpers.ts +70 -0
  58. package/src/cli.ts +44 -26
  59. package/src/collections/buildRegistry.ts +1 -1
  60. package/src/collections/validate-relations.ts +15 -0
  61. package/src/connection.ts +73 -0
  62. package/src/data-transformer.ts +9 -3
  63. package/src/databasePoolManager.ts +5 -2
  64. package/src/history/HistoryService.ts +13 -31
  65. package/src/schema/auth-schema.ts +30 -19
  66. package/src/schema/dynamic-tables.ts +1 -1
  67. package/src/schema/ensure-collection-policies.ts +105 -0
  68. package/src/schema/ensure-collection-tables.test.ts +105 -9
  69. package/src/schema/ensure-collection-tables.ts +220 -32
  70. package/src/schema/generate-drizzle-schema-logic.ts +33 -8
  71. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  72. package/src/schema/introspect-db-constraints.ts +385 -0
  73. package/src/schema/introspect-db-inference.ts +18 -8
  74. package/src/schema/introspect-db-logic.ts +385 -71
  75. package/src/schema/introspect-db-queries.ts +326 -0
  76. package/src/schema/introspect-db-structure.ts +670 -0
  77. package/src/schema/introspect-db-types.ts +56 -0
  78. package/src/schema/introspect-db.ts +37 -80
  79. package/src/schema/introspect-runtime.test.ts +56 -8
  80. package/src/schema/introspect-runtime.ts +32 -10
  81. package/src/security/policy-drift.test.ts +11 -3
  82. package/src/services/FetchService.ts +148 -18
  83. package/src/services/PersistService.ts +20 -6
  84. package/src/services/RelationService.ts +249 -48
  85. package/src/services/channel-bus/index.ts +0 -9
  86. package/src/services/collection-helpers.ts +40 -1
  87. package/src/services/dataService.ts +3 -1
  88. package/src/services/realtimeService.ts +3 -3
  89. package/src/services/row-pipeline.ts +4 -2
  90. package/src/utils/connection-string.ts +58 -0
  91. package/src/utils/drizzle-conditions.ts +539 -50
  92. package/src/utils/pg-error-utils.ts +98 -3
  93. package/src/websocket.ts +18 -9
  94. package/dist/chunk-DSJWtz9O.js +0 -40
  95. package/dist/ensure-collection-tables-DGMYK0fr.js +0 -304
  96. package/dist/ensure-collection-tables-DGMYK0fr.js.map +0 -1
  97. package/dist/src-3VmUJ8Xn.js.map +0 -1
  98. package/dist/src-D5xBTl32.js.map +0 -1
@@ -1,15 +1,109 @@
1
- import { and, eq, or, sql, SQL, ilike, inArray } from "drizzle-orm";
1
+ import { and, eq, or, sql, SQL, ilike, inArray, getTableColumns } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable, PgVarchar, PgText, PgChar } from "drizzle-orm/pg-core";
3
3
  import {
4
- FilterValues, WhereFilterOp, JoinStep, LogicalCondition, FilterCondition,
5
- ResolvedRelation, ResolvedBelongsTo, ResolvedHasOne, ResolvedHasMany
4
+ CollectionConfig, FilterValues, WhereFilterOp, JoinStep, LogicalCondition, FilterCondition,
5
+ ResolvedRelation, ResolvedBelongsTo, ResolvedHasOne, ResolvedHasMany,
6
+ ResolvedForeignKeyOnTarget, ResolvedManyToMany, hasForeignKeyOnTarget, isManyToMany
6
7
  } from "@rebasepro/types";
7
- import { getColumnName, normalizeToEntityRelation, resolveCollectionRelations } from "@rebasepro/common";
8
+ import {
9
+ getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations
10
+ } from "@rebasepro/common";
11
+ import { generateForeignKeyName } from "@rebasepro/utils";
8
12
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
9
13
  import { ConditionBuilderStatic } from "../interfaces";
10
- import { logger } from "@rebasepro/server";
14
+ import { ApiError, logger } from "@rebasepro/server";
11
15
  import { getColumnMeta } from "../services/collection-helpers";
12
16
 
17
+ /**
18
+ * What to do with a filter field that resolves to no column at all.
19
+ *
20
+ * - `"error"` (default) — reject the request. A filter that cannot be
21
+ * compiled is *dropped*, and dropping a condition can only ever widen the
22
+ * result set. On a data plane where row-level security is the last line of
23
+ * defence, a typo'd or renamed filter key therefore runs the query without
24
+ * that condition and returns everything RLS happens to allow.
25
+ * - `"warn"` — the historical behaviour: log and silently drop the condition.
26
+ * Only for a deployment that knowingly sends filter keys the table does not
27
+ * have and has satisfied itself that widening is safe there.
28
+ */
29
+ export type UnknownFilterFieldsMode = "error" | "warn";
30
+
31
+ /**
32
+ * Process-wide default, set once when the driver is constructed.
33
+ *
34
+ * The condition builder is a set of *static* methods reached from a dozen
35
+ * `FetchService` call sites, none of which carry the driver's config — the
36
+ * service is built from `(db, registry)` alone. Threading an option from
37
+ * `createPostgresAdapter` down to each of them would mean touching every
38
+ * intermediate signature to plumb a value that is a single deployment-wide
39
+ * switch. A module-level default set at adapter construction, plus an explicit
40
+ * per-call override for callers that have one (tests, mainly), buys the same
41
+ * control for none of the churn. It is safe by default, so the only reason to
42
+ * set it at all is to opt *out*.
43
+ */
44
+ let defaultUnknownFilterFieldsMode: UnknownFilterFieldsMode = "error";
45
+
46
+ /** Set the process-wide behaviour for unresolvable filter fields. */
47
+ export function configureUnknownFilterFields(mode: UnknownFilterFieldsMode): void {
48
+ defaultUnknownFilterFieldsMode = mode;
49
+ }
50
+
51
+ /** The process-wide behaviour for unresolvable filter fields. */
52
+ export function getUnknownFilterFieldsMode(): UnknownFilterFieldsMode {
53
+ return defaultUnknownFilterFieldsMode;
54
+ }
55
+
56
+ /** Per-call context for compiling a filter into SQL. */
57
+ export interface FilterCompilationOptions {
58
+ /**
59
+ * Overrides the process-wide {@link UnknownFilterFieldsMode} for this call.
60
+ */
61
+ unknownFields?: UnknownFilterFieldsMode;
62
+ /**
63
+ * The collection the filter is written against. Its resolved relations are
64
+ * what turn an owning-relation filter key into the foreign-key column it
65
+ * actually lives in; without it only the default key shapes can be guessed.
66
+ */
67
+ collection?: CollectionConfig;
68
+ /**
69
+ * The driver's registry, for relations whose link is not on this row at
70
+ * all. A `manyToMany` compiles to an `EXISTS` over its junction and a
71
+ * `hasMany`/`hasOne` to one over the target table — neither of which this
72
+ * builder can reach from the collection alone.
73
+ */
74
+ registry?: PostgresCollectionRegistry;
75
+ /**
76
+ * The key column of the table being filtered — what those `EXISTS`
77
+ * subqueries correlate back to.
78
+ *
79
+ * It has to be the Drizzle column object rather than a name: a column
80
+ * renders qualified with its own table, which is what binds it to the
81
+ * *outer* row instead of to the junction or target aliased inside the
82
+ * subquery. See {@link DrizzleConditionBuilder.buildRelationFilterCondition}.
83
+ */
84
+ sourceIdColumn?: AnyPgColumn;
85
+ }
86
+
87
+ /**
88
+ * What a filter field turns out to name.
89
+ *
90
+ * A field naming a column compiles to a comparison on it. A field naming a
91
+ * relation that owns no column here compiles to a whole `EXISTS` condition
92
+ * instead, so there is no column to hand back — which is why resolution
93
+ * answers with a discriminated result rather than a column. The caller cannot
94
+ * tell the two apart from the field name, and the difference is not cosmetic:
95
+ * one is `column <op> value`, the other is a correlated subquery.
96
+ */
97
+ type FilterTarget =
98
+ | { kind: "column"; column: AnyPgColumn }
99
+ | {
100
+ kind: "relation";
101
+ relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany;
102
+ /** Bound here so the compile step cannot be reached without them. */
103
+ registry: PostgresCollectionRegistry;
104
+ sourceIdColumn: AnyPgColumn;
105
+ };
106
+
13
107
  /**
14
108
  * Filter values may arrive as relation wire objects — `EntityRelation`
15
109
  * instances or their JSON form `{ __type: "relation", id, path }` — e.g. when
@@ -22,6 +116,23 @@ function unwrapRelationFilterValue(value: unknown): unknown {
22
116
  return relation ? relation.id : value;
23
117
  }
24
118
 
119
+ /**
120
+ * The operand of `in`/`not-in`, as a list.
121
+ *
122
+ * A scalar is the one-element list, because that is what it means and because
123
+ * the wire produces one: `?filter=id.in.5` parses to the string `"5"`, not to
124
+ * `["5"]` — the REST dialect only builds an array when the value is
125
+ * parenthesised. Treating that as malformed and dropping the condition turned
126
+ * a perfectly ordinary query into an unfiltered read.
127
+ *
128
+ * The empty list stays empty. Callers must decide what "no candidates" means
129
+ * for their operator — it is `FALSE` for `in` and `TRUE` for `not-in` — and
130
+ * neither of those is "no condition at all".
131
+ */
132
+ function toMembershipList(value: unknown): unknown[] {
133
+ return Array.isArray(value) ? value : [value];
134
+ }
135
+
25
136
  /** Drizzle dynamic query builder — accepts innerJoin + where chaining */
26
137
 
27
138
  export interface DrizzleDynamicQuery {
@@ -64,10 +175,11 @@ export class DrizzleConditionBuilder {
64
175
  static buildRelationScopeCondition(
65
176
  relation: ResolvedRelation,
66
177
  /**
67
- * Lazy: only `via` and `belongsTo` need the parent's own table. A
68
- * foreign key on the target and a junction are both expressible from
69
- * the parent's *id* alone, and requiring the table for them would make
70
- * a child listing fail on a parent whose table isn't registered.
178
+ * Lazy: `via`, `belongsTo`, and a foreign key that points at a
179
+ * `sourceKey` need the parent's own table. A junction and a plain
180
+ * foreign key are expressible from the parent's *id* alone, and
181
+ * requiring the table for them would make a child listing fail on a
182
+ * parent whose table isn't registered.
71
183
  */
72
184
  parent: () => { table: PgTable<any>; idColumn: AnyPgColumn },
73
185
  parentId: string | number,
@@ -99,7 +211,24 @@ export class DrizzleConditionBuilder {
99
211
  // Correlated, not joined: a join through a junction multiplies
100
212
  // the target rows by the number of matching links and silently
101
213
  // breaks `limit`/`offset`.
102
- return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
214
+ //
215
+ // The junction is aliased and referenced by identifier, never as a
216
+ // Drizzle column. A column object carries no table qualifier of its
217
+ // own — it is rendered against whatever the surrounding builder
218
+ // thinks the current table is — so inside `db.query.findMany`, which
219
+ // aliases the root table, `${sourceCol}` came out qualified with the
220
+ // *target's* alias: `podcast.podcast_id`, a column that does not
221
+ // exist. That aborts the transaction, and the fallback read then
222
+ // fails on the poisoned transaction rather than on anything to do
223
+ // with the relation. Only `targetIdColumn` stays a column object,
224
+ // because that one *must* bind to the outer row to correlate.
225
+ //
226
+ // Aliasing also disambiguates a self-referential many-to-many, where
227
+ // the junction and the target are the same table.
228
+ const junctionAlias = "__rel_m2m";
229
+ const junctionRef = (column: AnyPgColumn) =>
230
+ sql`${sql.identifier(junctionAlias)}.${sql.identifier(column.name)}`;
231
+ return sql`EXISTS (SELECT 1 FROM ${junctionTable} AS ${sql.identifier(junctionAlias)} WHERE ${junctionRef(targetCol)} = ${targetIdColumn} AND ${junctionRef(sourceCol)} = ${parentId})`;
103
232
  }
104
233
 
105
234
  case "hasOne":
@@ -111,7 +240,16 @@ export class DrizzleConditionBuilder {
111
240
  `relation '${relation.relationName}'.`
112
241
  );
113
242
  }
114
- return eq(fkColumn, parentId);
243
+ if (!relation.sourceKey) return eq(fkColumn, parentId);
244
+
245
+ // A link on a natural key: the foreign key holds a column of the
246
+ // parent row, not its id, so the parent has to be read. As a
247
+ // subquery rather than a prior SELECT, for the same reason
248
+ // `belongsTo` below is — one statement sees one snapshot, and a
249
+ // scope condition that read the key separately could be built
250
+ // from a value the very next statement no longer agrees with.
251
+ const { table, idColumn } = parent();
252
+ return sql`${fkColumn} = (SELECT ${sql.identifier(relation.sourceKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
115
253
  }
116
254
 
117
255
  case "belongsTo": {
@@ -203,40 +341,136 @@ export class DrizzleConditionBuilder {
203
341
  return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
204
342
  }
205
343
 
344
+ /**
345
+ * What a filter field names, or `undefined` if it names nothing.
346
+ *
347
+ * Three ways a field resolves. It may address its column directly; it may
348
+ * be an owning relation, whose foreign key is a column here; or it may be
349
+ * a relation whose link lives on another table entirely, which compiles to
350
+ * a subquery instead of a column. Only a field that resolves to *none* of
351
+ * them is an error, and by default it is one: see
352
+ * {@link UnknownFilterFieldsMode} for why silently dropping it is a
353
+ * data-exposure primitive rather than a convenience.
354
+ *
355
+ * For an owning relation the relation's own `localKey` is the authority,
356
+ * not `<field>_id`. The default local key is `generateForeignKeyName`,
357
+ * which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
358
+ * `users` → `user_id` — and it can be overridden outright. Guessing
359
+ * `<field>_id` therefore misses perfectly ordinary owning relations, and
360
+ * with this resolution failing closed that miss is a 400 on a filter that
361
+ * has nothing wrong with it. The guesses stay, last, for callers that hand
362
+ * over no collection to resolve against.
363
+ *
364
+ * The subquery kinds need a registry and the source table's key column on
365
+ * top of the collection. A caller that supplies neither gets the behaviour
366
+ * it had before they were compilable — unresolvable, and so fail-closed —
367
+ * rather than a half-built condition.
368
+ */
369
+ private static resolveFilterTarget(
370
+ table: PgTable<any>,
371
+ field: string,
372
+ collectionPath: string,
373
+ mode: UnknownFilterFieldsMode,
374
+ options: FilterCompilationOptions
375
+ ): FilterTarget | undefined {
376
+ const { collection, registry, sourceIdColumn } = options;
377
+
378
+ const columnAt = (key: string): AnyPgColumn | undefined =>
379
+ (key in table ? table[key as keyof typeof table] as AnyPgColumn : undefined) || undefined;
380
+
381
+ const direct = columnAt(field);
382
+ if (direct) return { kind: "column", column: direct };
383
+
384
+ if (collection) {
385
+ const relation = resolveCollectionRelations(collection)[field];
386
+
387
+ // Owning relation, resolved: the relation names its own local key.
388
+ if (relation?.kind === "belongsTo") {
389
+ const foreignKey = columnAt(relation.localKey);
390
+ if (foreignKey) return { kind: "column", column: foreignKey };
391
+ }
392
+
393
+ // The link is on the target table or in a junction. `via` is left
394
+ // out: its join path is authored source → target with no stated
395
+ // inverse, so reversing it into a filter is a different problem
396
+ // from the two shapes below rather than a third case of them.
397
+ if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
398
+ // The `EXISTS` correlates the target's foreign key with a column
399
+ // on *this* table, and that is the primary key only when the
400
+ // link joins on it. A `sourceKey` names a different one, and
401
+ // correlating on the id anyway silently matches nothing —
402
+ // "filter by this relation" would quietly return zero rows.
403
+ const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey
404
+ ? columnAt(relation.sourceKey)
405
+ : sourceIdColumn;
406
+ if (!correlationColumn) {
407
+ throw new Error(
408
+ `\`sourceKey: "${(relation as ResolvedForeignKeyOnTarget).sourceKey}"\` on relation ` +
409
+ `'${relation.relationName}' is not a column on '${collectionPath}', so a filter on ` +
410
+ "that relation has nothing to correlate against."
411
+ );
412
+ }
413
+ return { kind: "relation", relation, registry, sourceIdColumn: correlationColumn };
414
+ }
415
+ }
416
+
417
+ // No collection in hand — the two shapes an owning relation's key takes
418
+ // by default (e.g. `project` → `project_id`, `userProfile` →
419
+ // `user_profile_id`).
420
+ for (const guess of [`${field}_id`, generateForeignKeyName(field)]) {
421
+ const foreignKey = columnAt(guess);
422
+ if (foreignKey) return { kind: "column", column: foreignKey };
423
+ }
424
+
425
+ if (mode === "warn") {
426
+ logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
427
+ return undefined;
428
+ }
429
+
430
+ let validFields: string[] = [];
431
+ try {
432
+ validFields = Object.keys(getTableColumns(table)).sort();
433
+ } catch {
434
+ // A table stand-in without Drizzle's column symbols — the message
435
+ // is worth less without the list, but not worth failing over.
436
+ }
437
+
438
+ // Not `expected`: unlike an anonymous token refresh, this is never a
439
+ // routine outcome. It means a filter key and the schema have drifted
440
+ // apart, which used to widen results silently — exactly the thing an
441
+ // operator wants in the log at warn.
442
+ throw ApiError.badRequest(
443
+ `Unknown filter field '${field}' on collection '${collectionPath}'` +
444
+ (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
445
+ "UNKNOWN_FILTER_FIELD",
446
+ { field, collection: collectionPath, ...(validFields.length > 0 && { validFields }) }
447
+ );
448
+ }
449
+
206
450
  /**
207
451
  * Build filter conditions from FilterValues
208
452
  */
209
453
  static buildFilterConditions<M extends Record<string, unknown>>(
210
454
  filter: FilterValues<Extract<keyof M, string>>,
211
455
  table: PgTable<any>,
212
- collectionPath: string
456
+ collectionPath: string,
457
+ options: FilterCompilationOptions = {}
213
458
  ): SQL[] {
459
+ const mode = options.unknownFields ?? defaultUnknownFilterFieldsMode;
214
460
  const conditions: SQL[] = [];
215
461
 
216
462
  for (const [field, filterParam] of Object.entries(filter)) {
217
463
  if (!filterParam) continue;
218
464
 
219
- let fieldColumn = table[field as keyof typeof table] as AnyPgColumn;
220
-
221
- if (!fieldColumn) {
222
- // Fallback for relations (e.g. project -> project_id)
223
- const relationKey = `${field}_id`;
224
- if (relationKey in table) {
225
- fieldColumn = table[relationKey as keyof typeof table] as AnyPgColumn;
226
- }
227
- }
228
-
229
- if (!fieldColumn) {
230
- logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
231
- continue;
232
- }
465
+ const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
466
+ if (!target) continue;
233
467
 
234
468
  const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0])
235
469
  ? (filterParam as [WhereFilterOp, any][])
236
470
  : [filterParam as [WhereFilterOp, any]];
237
471
 
238
472
  for (const [op, value] of paramsList) {
239
- const condition = this.buildSingleFilterCondition(fieldColumn, op, value);
473
+ const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
240
474
  if (condition) {
241
475
  conditions.push(condition);
242
476
  }
@@ -252,28 +486,257 @@ export class DrizzleConditionBuilder {
252
486
  static buildLogicalConditions(
253
487
  cond: LogicalCondition | FilterCondition,
254
488
  table: PgTable<any>,
255
- collectionPath: string
489
+ collectionPath: string,
490
+ options: FilterCompilationOptions = {}
256
491
  ): SQL | null {
257
492
  if ("type" in cond) {
258
493
  const subSQLs = cond.conditions
259
- .map(c => this.buildLogicalConditions(c, table, collectionPath))
494
+ .map(c => this.buildLogicalConditions(c, table, collectionPath, options))
260
495
  .filter((sql): sql is SQL => sql !== null);
261
496
  if (subSQLs.length === 0) return null;
262
497
  return (cond.type === "or" ? or(...subSQLs) : and(...subSQLs)) ?? null;
263
498
  } else {
264
- let fieldColumn = table[cond.column as keyof typeof table] as AnyPgColumn;
265
- if (!fieldColumn) {
266
- const relationKey = `${cond.column}_id`;
267
- if (relationKey in table) {
268
- fieldColumn = table[relationKey as keyof typeof table] as AnyPgColumn;
269
- }
499
+ // A dropped leaf is worse here than in a flat filter: inside an
500
+ // `or(...)` the disjunction loses a branch, so the surviving
501
+ // branches match on their own and the result set widens by
502
+ // everything the dropped leaf would have excluded.
503
+ const target = this.resolveFilterTarget(
504
+ table,
505
+ cond.column,
506
+ collectionPath,
507
+ options.unknownFields ?? defaultUnknownFilterFieldsMode,
508
+ options
509
+ );
510
+ if (!target) return null;
511
+ return this.compileFilterTarget(
512
+ target, cond.operator as WhereFilterOp, cond.value, cond.column, collectionPath
513
+ );
514
+ }
515
+ }
516
+
517
+ /** Dispatch a resolved filter field onto the shape it actually compiles to. */
518
+ private static compileFilterTarget(
519
+ target: FilterTarget,
520
+ op: WhereFilterOp,
521
+ value: unknown,
522
+ field: string,
523
+ collectionPath: string
524
+ ): SQL | null {
525
+ return target.kind === "column"
526
+ ? this.buildSingleFilterCondition(target.column, op, value)
527
+ : this.buildRelationFilterCondition(
528
+ target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath
529
+ );
530
+ }
531
+
532
+ /**
533
+ * A filter on a relation that owns no column on this row — `EXISTS` over
534
+ * the rows it reaches.
535
+ *
536
+ * `posts` filtered by `tags == <tagId>` is not a comparison on `posts`; it
537
+ * is a question about the junction:
538
+ *
539
+ * EXISTS (SELECT 1 FROM posts_tags AS j
540
+ * WHERE j.post_id = posts.id AND j.tag_id = <tagId>)
541
+ *
542
+ * which is {@link buildRelationScopeCondition}'s many-to-many shape with
543
+ * source and target swapped — there the junction's *target* column
544
+ * correlates and the source is pinned; here the *source* column correlates
545
+ * and the target is what the filter constrains.
546
+ *
547
+ * `hasMany`/`hasOne` are the same shape one table over: the target row
548
+ * carries the foreign key, so the correlation is on that key and the
549
+ * compared column is the target's own id.
550
+ *
551
+ * `EXISTS` and not a join, for the reason the scope condition gives: a join
552
+ * through a junction multiplies the outer rows by the number of matching
553
+ * links, which duplicates results and silently breaks `limit`/`offset`.
554
+ *
555
+ * Everything inside the subquery is referenced by identifier against a
556
+ * local alias, and only `sourceIdColumn` stays a Drizzle column object —
557
+ * again see {@link buildRelationScopeCondition}, which explains why a
558
+ * column object renders against whatever table the surrounding builder
559
+ * thinks is current and so cannot be used for the inner references. The
560
+ * alias is also what keeps a self-referential relation unambiguous
561
+ * (`categories.children`, or a many-to-many whose junction and target are
562
+ * the same table), where the subquery's table and the outer one coincide.
563
+ */
564
+ static buildRelationFilterCondition(
565
+ relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany,
566
+ op: WhereFilterOp,
567
+ value: unknown,
568
+ sourceIdColumn: AnyPgColumn,
569
+ registry: PostgresCollectionRegistry,
570
+ field: string,
571
+ collectionPath: string
572
+ ): SQL {
573
+ const alias = "__rel_filter";
574
+ const ref = (column: AnyPgColumn) =>
575
+ sql`${sql.identifier(alias)}.${sql.identifier(column.name)}`;
576
+
577
+ let scanTable: PgTable<any>;
578
+ let correlation: SQL;
579
+ let comparedColumn: AnyPgColumn;
580
+
581
+ if (relation.kind === "manyToMany") {
582
+ const { table: junctionName, sourceColumn, targetColumn } = relation.through;
583
+ const junctionTable = registry.getTable(junctionName);
584
+ if (!junctionTable) {
585
+ throw new Error(`Junction table not found: ${junctionName}`);
270
586
  }
271
- if (!fieldColumn) {
272
- logger.warn(`Filtering by field '${cond.column}', but it does not exist in table for collection '${collectionPath}'`);
273
- return null;
587
+ const sourceCol = junctionTable[sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
588
+ const targetCol = junctionTable[targetColumn as keyof typeof junctionTable] as AnyPgColumn;
589
+ if (!sourceCol || !targetCol) {
590
+ throw new Error(
591
+ `Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`
592
+ );
593
+ }
594
+ scanTable = junctionTable;
595
+ correlation = sql`${ref(sourceCol)} = ${sourceIdColumn}`;
596
+ comparedColumn = targetCol;
597
+ } else {
598
+ const targetCollection = relation.target();
599
+ const targetTable = registry.getTable(getTableName(targetCollection));
600
+ if (!targetTable) {
601
+ throw new Error(
602
+ `Table not found for the target of relation '${relation.relationName}' ` +
603
+ `(collection '${targetCollection.slug}')`
604
+ );
274
605
  }
275
- return this.buildSingleFilterCondition(fieldColumn, cond.operator as WhereFilterOp, cond.value);
606
+ const fkColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
607
+ if (!fkColumn) {
608
+ throw new Error(
609
+ `Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of ` +
610
+ `relation '${relation.relationName}'.`
611
+ );
612
+ }
613
+ // The filter value is a target row's id, so that is what the
614
+ // subquery compares — the foreign key is spent on the correlation.
615
+ const targetIdColumn = this.primaryKeyColumn(targetTable);
616
+ if (!targetIdColumn) {
617
+ throw new Error(
618
+ `No primary key or "id" column in the target table of relation '${relation.relationName}', ` +
619
+ `so a filter on it has nothing to match against.`
620
+ );
621
+ }
622
+ scanTable = targetTable;
623
+ correlation = sql`${ref(fkColumn)} = ${sourceIdColumn}`;
624
+ comparedColumn = targetIdColumn;
276
625
  }
626
+
627
+ const { predicate, negate } = this.buildRelationFilterPredicate(
628
+ ref(comparedColumn), op, value, field, collectionPath
629
+ );
630
+
631
+ const where = predicate ? sql`${correlation} AND ${predicate}` : correlation;
632
+ const exists = sql`EXISTS (SELECT 1 FROM ${scanTable} AS ${sql.identifier(alias)} WHERE ${where})`;
633
+ return negate ? sql`NOT ${exists}` : exists;
634
+ }
635
+
636
+ /**
637
+ * The inner predicate of a relation filter, and whether the `EXISTS`
638
+ * wrapping it is negated.
639
+ *
640
+ * Negation is `NOT EXISTS` of the *positive* predicate, never `EXISTS` of a
641
+ * negated one. On a many-valued relation the two are different questions:
642
+ * `EXISTS (… AND tag_id != X)` asks "does some tag differ from X", which is
643
+ * true of nearly every post with more than one tag and answers nothing
644
+ * anybody asked. `NOT EXISTS (… AND tag_id = X)` asks "is X absent", which
645
+ * is what unticking a value in a filter control means — and it makes `==`
646
+ * and `!=` partition the rows, the way a filter implies they do.
647
+ *
648
+ * `is-null`/`is-not-null` drop the predicate entirely: with nothing but the
649
+ * correlation left, they become "has no related row at all" and "has at
650
+ * least one", which is the only reading of null a link can have.
651
+ *
652
+ * Under RLS, "no related row" means *no row this reader can see*. A junction
653
+ * with row-level security but no `SELECT` policy for `rebase_user` is opaque
654
+ * to it, so every row comes back looking unlinked and `is-null` matches all
655
+ * of them. That is not a leak — the outer table's own policies still decide
656
+ * which rows exist at all, and the positive direction correctly returns
657
+ * nothing — but it over-reports, and the cause is a missing junction policy
658
+ * rather than anything here. Rebase derives one for a declared many-to-many;
659
+ * a hand-written schema has to supply it.
660
+ *
661
+ * `in`/`not-in` against a *null value* mean the same thing, rather than
662
+ * membership of an empty list. Membership against null is not a membership
663
+ * question, and the admin's "filter for null values" control emits the
664
+ * operator that happens to be selected — on a to-many relation that is
665
+ * always `in` or `not-in`, because those are the only ones the multi-select
666
+ * can produce. Reading `["in", null]` as an empty list would answer "posts
667
+ * with no tags" with no posts at all.
668
+ *
669
+ * An empty `in` list compiles to `FALSE` rather than being dropped. Dropped
670
+ * is what the column path does, and dropping a condition widens the result
671
+ * — the whole reason this resolution fails closed. `in []` matches nothing
672
+ * and `not-in []` matches everything, and `NOT EXISTS (… AND FALSE)` gives
673
+ * the second for free.
674
+ *
675
+ * Anything else is rejected. Returning `null` for an operator this cannot
676
+ * express would drop the condition, and the operators the admin offers for
677
+ * a relation are exactly the six below.
678
+ */
679
+ private static buildRelationFilterPredicate(
680
+ ref: SQL,
681
+ op: WhereFilterOp,
682
+ value: unknown,
683
+ field: string,
684
+ collectionPath: string
685
+ ): { predicate?: SQL; negate: boolean } {
686
+ value = unwrapRelationFilterValue(value);
687
+ const isNullish = value === null || value === undefined;
688
+
689
+ const equals = () => sql`${ref} = ${value}`;
690
+ const inList = () => {
691
+ // Same reading as the column path: a scalar is the one-element
692
+ // list, an empty list is `FALSE`. Here `FALSE` also gives
693
+ // `not-in []` its answer for free — `NOT EXISTS (… AND FALSE)`
694
+ // is true of every row, which is what excluding nothing means.
695
+ const values = toMembershipList(value);
696
+ return values.length === 0
697
+ ? sql`FALSE`
698
+ : sql`${ref} IN (${sql.join(values.map(v => sql`${v}`), sql`, `)})`;
699
+ };
700
+
701
+ switch (op) {
702
+ case "==":
703
+ return isNullish ? { negate: true } : { predicate: equals(), negate: false };
704
+ case "!=":
705
+ return isNullish ? { negate: false } : { predicate: equals(), negate: true };
706
+ case "in":
707
+ return isNullish ? { negate: true } : { predicate: inList(), negate: false };
708
+ case "not-in":
709
+ return isNullish ? { negate: false } : { predicate: inList(), negate: true };
710
+ case "is-null":
711
+ return { negate: true };
712
+ case "is-not-null":
713
+ return { negate: false };
714
+ // A to-many relation *is* the list, so the array operators ask the
715
+ // same two questions under different names: "contains X" is "some
716
+ // related row is X", and "contains any of [X, Y]" is `in`. They
717
+ // reach here because the admin offers them for a property that is
718
+ // an *array of* relations, and rejecting a question the shape
719
+ // answers perfectly well would put a 400 behind a working control.
720
+ case "array-contains":
721
+ return isNullish ? { negate: true } : { predicate: equals(), negate: false };
722
+ case "array-contains-any":
723
+ return isNullish ? { negate: true } : { predicate: inList(), negate: false };
724
+ default:
725
+ throw ApiError.badRequest(
726
+ `Operator '${op}' cannot be applied to relation field '${field}' on collection ` +
727
+ `'${collectionPath}'. A relation with no column on this row is filtered by ` +
728
+ "membership: ==, !=, in, not-in, array-contains, array-contains-any, is-null, " +
729
+ "is-not-null.",
730
+ "UNSUPPORTED_RELATION_FILTER_OPERATOR",
731
+ { field, collection: collectionPath, operator: op }
732
+ );
733
+ }
734
+ }
735
+
736
+ /** The column a table's rows are keyed by: its primary key, else `id`. */
737
+ private static primaryKeyColumn(table: PgTable<any>): AnyPgColumn | undefined {
738
+ return (Object.values(table).find((col: Record<string, unknown>) => col.primary)
739
+ ?? Object.values(table).find((col: Record<string, unknown>) => col.name === "id")) as AnyPgColumn | undefined;
277
740
  }
278
741
 
279
742
  /**
@@ -304,11 +767,25 @@ export class DrizzleConditionBuilder {
304
767
  return sql`${column} < ${value}`;
305
768
  case "<=":
306
769
  return sql`${column} <= ${value}`;
307
- case "in":
308
- if (Array.isArray(value) && value.length > 0) {
309
- return inArray(column, value);
770
+ case "in": {
771
+ // Membership against a null *value* is a null check, not an
772
+ // empty list — the admin's "filter for null values" control
773
+ // emits whichever operator is selected, so `["in", null]` is
774
+ // how it asks for a null foreign key when the user picked
775
+ // `in`. Reading it as an empty list dropped the condition
776
+ // outright, which widened the read to every row.
777
+ if (value === null || value === undefined) {
778
+ return sql`${column} IS NULL`;
310
779
  }
311
- return null;
780
+ const values = toMembershipList(value);
781
+ // An empty list matches nothing. Returning no condition — what
782
+ // this did — matches *everything*, which is the same inversion
783
+ // one layer down from the one `UnknownFilterFieldsMode` exists
784
+ // for. It is the dangerous shape too: `filter: { id: ["in",
785
+ // teamIds] }` with no teams is how a caller asks for nothing,
786
+ // and it answered with the whole table.
787
+ return values.length === 0 ? sql`FALSE` : inArray(column, values);
788
+ }
312
789
  case "array-contains": {
313
790
  const meta = getColumnMeta(column);
314
791
  if (meta.dataType === "array" || meta.columnType === "PgArray") {
@@ -320,6 +797,13 @@ export class DrizzleConditionBuilder {
320
797
  case "array-contains-any": {
321
798
  const meta = getColumnMeta(column);
322
799
  const isNativeArray = meta.dataType === "array" || meta.columnType === "PgArray";
800
+ // "Overlaps nothing" is false, not a licence to skip the
801
+ // condition. The single-value fallback below is for a *scalar*
802
+ // operand; an empty array fell into it and built
803
+ // `@> ARRAY[$1]` around an empty binding.
804
+ if (Array.isArray(value) && value.length === 0) {
805
+ return sql`FALSE`;
806
+ }
323
807
  if (Array.isArray(value) && value.length > 0) {
324
808
  if (isNativeArray) {
325
809
  return sql`${column} && ARRAY[${sql.join(value.map(v => sql`${v}`), sql`, `)}]`;
@@ -335,11 +819,18 @@ export class DrizzleConditionBuilder {
335
819
  }
336
820
  return sql`${column} @> ${JSON.stringify([value])}`;
337
821
  }
338
- case "not-in":
339
- if (Array.isArray(value) && value.length > 0) {
340
- return sql`${column} NOT IN (${sql.join(value.map(v => sql`${v}`), sql`, `)})`;
822
+ case "not-in": {
823
+ // The mirror of `in` above, including the empty list — which
824
+ // excludes nothing, and so matches every row. Same condition
825
+ // the old code produced by accident, now on purpose and for
826
+ // the empty list only.
827
+ if (value === null || value === undefined) {
828
+ return sql`${column} IS NOT NULL`;
341
829
  }
342
- return null;
830
+ const values = toMembershipList(value);
831
+ if (values.length === 0) return sql`TRUE`;
832
+ return sql`${column} NOT IN (${sql.join(values.map(v => sql`${v}`), sql`, `)})`;
833
+ }
343
834
  case "like":
344
835
  return sql`${column} LIKE ${String(value)}`;
345
836
  case "ilike":
@@ -746,9 +1237,7 @@ whereConditions };
746
1237
  if (relation.kind === "belongsTo") {
747
1238
  // `parentId` is the foreign key's value, matched against the
748
1239
  // target's own key.
749
- const targetIdCol =
750
- (Object.values(targetTable).find((col: Record<string, unknown>) => col.primary)
751
- ?? Object.values(targetTable).find((col: Record<string, unknown>) => col.name === "id")) as AnyPgColumn | undefined;
1240
+ const targetIdCol = this.primaryKeyColumn(targetTable);
752
1241
  if (!targetIdCol) {
753
1242
  throw new Error(
754
1243
  `No primary key or "id" column in the target table of relation '${relation.relationName}'.`