@rebasepro/server-postgres 0.11.1-canary.gfd39654 → 0.12.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.
Files changed (30) hide show
  1. package/dist/PostgresBootstrapper.d.ts +8 -0
  2. package/dist/collections/buildRegistry.d.ts +1 -1
  3. package/dist/{ensure-collection-tables-DGMYK0fr.js → ensure-collection-tables-CNTcZGvn.js} +3 -3
  4. package/dist/{ensure-collection-tables-DGMYK0fr.js.map → ensure-collection-tables-CNTcZGvn.js.map} +1 -1
  5. package/dist/history/HistoryService.d.ts +9 -29
  6. package/dist/index.es.js +397 -53
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/schema/dynamic-tables.d.ts +1 -1
  9. package/dist/schema/introspect-runtime.d.ts +1 -1
  10. package/dist/services/FetchService.d.ts +36 -1
  11. package/dist/services/row-pipeline.d.ts +3 -1
  12. package/dist/{src-3VmUJ8Xn.js → src-BbFOPJ1S.js} +197 -18
  13. package/dist/src-BbFOPJ1S.js.map +1 -0
  14. package/dist/{src-D5xBTl32.js → src-Zqwaw3P5.js} +136 -90
  15. package/dist/src-Zqwaw3P5.js.map +1 -0
  16. package/dist/utils/drizzle-conditions.d.ts +157 -3
  17. package/dist/utils/pg-error-utils.d.ts +6 -3
  18. package/package.json +6 -6
  19. package/src/PostgresBootstrapper.ts +23 -6
  20. package/src/collections/buildRegistry.ts +1 -1
  21. package/src/history/HistoryService.ts +13 -31
  22. package/src/schema/dynamic-tables.ts +1 -1
  23. package/src/schema/generate-drizzle-schema-logic.ts +10 -2
  24. package/src/schema/introspect-runtime.ts +1 -1
  25. package/src/services/FetchService.ts +79 -11
  26. package/src/services/row-pipeline.ts +3 -1
  27. package/src/utils/drizzle-conditions.ts +509 -45
  28. package/src/utils/pg-error-utils.ts +52 -3
  29. package/dist/src-3VmUJ8Xn.js.map +0 -1
  30. package/dist/src-D5xBTl32.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -2,8 +2,8 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { i as __toESM, n as __exportAll, r as __require, t as __commonJSMin } from "./chunk-DSJWtz9O.js";
5
- import { a as isSchemaAdmin, c as hasForeignKeyOnTarget, d as isPostgresCollectionConfig, g as Vector, i as isSQLAdmin, l as isManyToMany, o as ANONYMOUS_USER_ID, r as isChannelBusInstance } from "./src-D5xBTl32.js";
6
- import { C as createRelationRefWithData, D as mergeDeep, E as getPolicyNamesForRule, O as camelCase, S as createRelationRef, T as updateDateAutoValues, _ as getTableVarName, a as getJunctionCollectionConfig, b as getDeclaredPrimaryKeys, c as getEffectiveSecurityRules, d as securityRuleToConditions, f as findAnonymousGrants, g as getTableName$1, h as getEnumVarName, i as CollectionRegistry, k as toSnakeCase, l as buildPropertyCallbacks, m as getColumnName, n as detectJunctionTables, o as getJunctionSecurityRules, p as findRelation, r as buildSdkData, s as resolveJunctionSpecs, t as classifyTable, u as policyToPostgres, v as resolveCollectionRelations, w as normalizeToEntityRelation, x as parseIdValues, y as buildCompositeId } from "./src-3VmUJ8Xn.js";
5
+ import { _ as Vector, i as ANONYMOUS_USER_ID, l as isPostgresCollectionConfig, n as isSQLAdmin, o as hasForeignKeyOnTarget, r as isSchemaAdmin, s as isManyToMany, t as isChannelBusInstance, u as isRelationalCollectionConfig } from "./src-Zqwaw3P5.js";
6
+ import { A as toSnakeCase, C as createRelationRefWithData, D as getPolicyNamesForRule, E as generateForeignKeyName, O as mergeDeep, S as createRelationRef, T as updateDateAutoValues, _ as getTableVarName, a as getJunctionCollectionConfig, b as getDeclaredPrimaryKeys, c as getEffectiveSecurityRules, d as securityRuleToConditions, f as findAnonymousGrants, g as getTableName$1, h as getEnumVarName, i as CollectionRegistry, k as camelCase, l as buildPropertyCallbacks, m as getColumnName, n as detectJunctionTables, o as getJunctionSecurityRules, p as findRelation, r as buildSdkData, s as resolveJunctionSpecs, t as classifyTable, u as policyToPostgres, v as resolveCollectionRelations, w as normalizeToEntityRelation, x as parseIdValues, y as buildCompositeId } from "./src-BbFOPJ1S.js";
7
7
  import { Client, Pool } from "pg";
8
8
  import { drizzle } from "drizzle-orm/node-postgres";
9
9
  import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
@@ -372,6 +372,28 @@ function deriveRowAddress(row, collection, registry) {
372
372
  //#endregion
373
373
  //#region src/utils/drizzle-conditions.ts
374
374
  /**
375
+ * Process-wide default, set once when the driver is constructed.
376
+ *
377
+ * The condition builder is a set of *static* methods reached from a dozen
378
+ * `FetchService` call sites, none of which carry the driver's config — the
379
+ * service is built from `(db, registry)` alone. Threading an option from
380
+ * `createPostgresAdapter` down to each of them would mean touching every
381
+ * intermediate signature to plumb a value that is a single deployment-wide
382
+ * switch. A module-level default set at adapter construction, plus an explicit
383
+ * per-call override for callers that have one (tests, mainly), buys the same
384
+ * control for none of the churn. It is safe by default, so the only reason to
385
+ * set it at all is to opt *out*.
386
+ */
387
+ var defaultUnknownFilterFieldsMode = "error";
388
+ /** Set the process-wide behaviour for unresolvable filter fields. */
389
+ function configureUnknownFilterFields(mode) {
390
+ defaultUnknownFilterFieldsMode = mode;
391
+ }
392
+ /** The process-wide behaviour for unresolvable filter fields. */
393
+ function getUnknownFilterFieldsMode() {
394
+ return defaultUnknownFilterFieldsMode;
395
+ }
396
+ /**
375
397
  * Filter values may arrive as relation wire objects — `EntityRelation`
376
398
  * instances or their JSON form `{ __type: "relation", id, path }` — e.g. when
377
399
  * the admin filters a relation column. SQL comparisons need the raw id, so
@@ -383,6 +405,22 @@ function unwrapRelationFilterValue(value) {
383
405
  return relation ? relation.id : value;
384
406
  }
385
407
  /**
408
+ * The operand of `in`/`not-in`, as a list.
409
+ *
410
+ * A scalar is the one-element list, because that is what it means and because
411
+ * the wire produces one: `?filter=id.in.5` parses to the string `"5"`, not to
412
+ * `["5"]` — the REST dialect only builds an array when the value is
413
+ * parenthesised. Treating that as malformed and dropping the condition turned
414
+ * a perfectly ordinary query into an unfiltered read.
415
+ *
416
+ * The empty list stays empty. Callers must decide what "no candidates" means
417
+ * for their operator — it is `FALSE` for `in` and `TRUE` for `not-in` — and
418
+ * neither of those is "no condition at all".
419
+ */
420
+ function toMembershipList(value) {
421
+ return Array.isArray(value) ? value : [value];
422
+ }
423
+ /**
386
424
  * Unified condition builder for Drizzle/PostgreSQL queries.
387
425
  *
388
426
  * This class uses static methods and satisfies the ConditionBuilderStatic<SQL> type.
@@ -425,7 +463,9 @@ var DrizzleConditionBuilder = class {
425
463
  const sourceCol = junctionTable[sourceColumn];
426
464
  const targetCol = junctionTable[targetColumn];
427
465
  if (!sourceCol || !targetCol) throw new Error(`Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`);
428
- return sql`EXISTS (SELECT 1 FROM ${junctionTable} WHERE ${targetCol} = ${targetIdColumn} AND ${sourceCol} = ${parentId})`;
466
+ const junctionAlias = "__rel_m2m";
467
+ const junctionRef = (column) => sql`${sql.identifier(junctionAlias)}.${sql.identifier(column.name)}`;
468
+ return sql`EXISTS (SELECT 1 FROM ${junctionTable} AS ${sql.identifier(junctionAlias)} WHERE ${junctionRef(targetCol)} = ${targetIdColumn} AND ${junctionRef(sourceCol)} = ${parentId})`;
429
469
  }
430
470
  case "hasOne":
431
471
  case "hasMany": {
@@ -480,24 +520,88 @@ var DrizzleConditionBuilder = class {
480
520
  return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
481
521
  }
482
522
  /**
523
+ * What a filter field names, or `undefined` if it names nothing.
524
+ *
525
+ * Three ways a field resolves. It may address its column directly; it may
526
+ * be an owning relation, whose foreign key is a column here; or it may be
527
+ * a relation whose link lives on another table entirely, which compiles to
528
+ * a subquery instead of a column. Only a field that resolves to *none* of
529
+ * them is an error, and by default it is one: see
530
+ * {@link UnknownFilterFieldsMode} for why silently dropping it is a
531
+ * data-exposure primitive rather than a convenience.
532
+ *
533
+ * For an owning relation the relation's own `localKey` is the authority,
534
+ * not `<field>_id`. The default local key is `generateForeignKeyName`,
535
+ * which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
536
+ * `users` → `user_id` — and it can be overridden outright. Guessing
537
+ * `<field>_id` therefore misses perfectly ordinary owning relations, and
538
+ * with this resolution failing closed that miss is a 400 on a filter that
539
+ * has nothing wrong with it. The guesses stay, last, for callers that hand
540
+ * over no collection to resolve against.
541
+ *
542
+ * The subquery kinds need a registry and the source table's key column on
543
+ * top of the collection. A caller that supplies neither gets the behaviour
544
+ * it had before they were compilable — unresolvable, and so fail-closed —
545
+ * rather than a half-built condition.
546
+ */
547
+ static resolveFilterTarget(table, field, collectionPath, mode, options) {
548
+ const { collection, registry, sourceIdColumn } = options;
549
+ const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
550
+ const direct = columnAt(field);
551
+ if (direct) return {
552
+ kind: "column",
553
+ column: direct
554
+ };
555
+ if (collection) {
556
+ const relation = resolveCollectionRelations(collection)[field];
557
+ if (relation?.kind === "belongsTo") {
558
+ const foreignKey = columnAt(relation.localKey);
559
+ if (foreignKey) return {
560
+ kind: "column",
561
+ column: foreignKey
562
+ };
563
+ }
564
+ if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) return {
565
+ kind: "relation",
566
+ relation,
567
+ registry,
568
+ sourceIdColumn
569
+ };
570
+ }
571
+ for (const guess of [`${field}_id`, generateForeignKeyName(field)]) {
572
+ const foreignKey = columnAt(guess);
573
+ if (foreignKey) return {
574
+ kind: "column",
575
+ column: foreignKey
576
+ };
577
+ }
578
+ if (mode === "warn") {
579
+ logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
580
+ return;
581
+ }
582
+ let validFields = [];
583
+ try {
584
+ validFields = Object.keys(getTableColumns(table)).sort();
585
+ } catch {}
586
+ throw ApiError.badRequest(`Unknown filter field '${field}' on collection '${collectionPath}'` + (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""), "UNKNOWN_FILTER_FIELD", {
587
+ field,
588
+ collection: collectionPath,
589
+ ...validFields.length > 0 && { validFields }
590
+ });
591
+ }
592
+ /**
483
593
  * Build filter conditions from FilterValues
484
594
  */
485
- static buildFilterConditions(filter, table, collectionPath) {
595
+ static buildFilterConditions(filter, table, collectionPath, options = {}) {
596
+ const mode = options.unknownFields ?? defaultUnknownFilterFieldsMode;
486
597
  const conditions = [];
487
598
  for (const [field, filterParam] of Object.entries(filter)) {
488
599
  if (!filterParam) continue;
489
- let fieldColumn = table[field];
490
- if (!fieldColumn) {
491
- const relationKey = `${field}_id`;
492
- if (relationKey in table) fieldColumn = table[relationKey];
493
- }
494
- if (!fieldColumn) {
495
- logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
496
- continue;
497
- }
600
+ const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
601
+ if (!target) continue;
498
602
  const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0]) ? filterParam : [filterParam];
499
603
  for (const [op, value] of paramsList) {
500
- const condition = this.buildSingleFilterCondition(fieldColumn, op, value);
604
+ const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
501
605
  if (condition) conditions.push(condition);
502
606
  }
503
607
  }
@@ -506,24 +610,175 @@ var DrizzleConditionBuilder = class {
506
610
  /**
507
611
  * Build logical conditions recursively from LogicalCondition or FilterCondition
508
612
  */
509
- static buildLogicalConditions(cond, table, collectionPath) {
613
+ static buildLogicalConditions(cond, table, collectionPath, options = {}) {
510
614
  if ("type" in cond) {
511
- const subSQLs = cond.conditions.map((c) => this.buildLogicalConditions(c, table, collectionPath)).filter((sql) => sql !== null);
615
+ const subSQLs = cond.conditions.map((c) => this.buildLogicalConditions(c, table, collectionPath, options)).filter((sql) => sql !== null);
512
616
  if (subSQLs.length === 0) return null;
513
617
  return (cond.type === "or" ? or(...subSQLs) : and(...subSQLs)) ?? null;
514
618
  } else {
515
- let fieldColumn = table[cond.column];
516
- if (!fieldColumn) {
517
- const relationKey = `${cond.column}_id`;
518
- if (relationKey in table) fieldColumn = table[relationKey];
519
- }
520
- if (!fieldColumn) {
521
- logger.warn(`Filtering by field '${cond.column}', but it does not exist in table for collection '${collectionPath}'`);
522
- return null;
523
- }
524
- return this.buildSingleFilterCondition(fieldColumn, cond.operator, cond.value);
619
+ const target = this.resolveFilterTarget(table, cond.column, collectionPath, options.unknownFields ?? defaultUnknownFilterFieldsMode, options);
620
+ if (!target) return null;
621
+ return this.compileFilterTarget(target, cond.operator, cond.value, cond.column, collectionPath);
622
+ }
623
+ }
624
+ /** Dispatch a resolved filter field onto the shape it actually compiles to. */
625
+ static compileFilterTarget(target, op, value, field, collectionPath) {
626
+ return target.kind === "column" ? this.buildSingleFilterCondition(target.column, op, value) : this.buildRelationFilterCondition(target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath);
627
+ }
628
+ /**
629
+ * A filter on a relation that owns no column on this row — `EXISTS` over
630
+ * the rows it reaches.
631
+ *
632
+ * `posts` filtered by `tags == <tagId>` is not a comparison on `posts`; it
633
+ * is a question about the junction:
634
+ *
635
+ * EXISTS (SELECT 1 FROM posts_tags AS j
636
+ * WHERE j.post_id = posts.id AND j.tag_id = <tagId>)
637
+ *
638
+ * which is {@link buildRelationScopeCondition}'s many-to-many shape with
639
+ * source and target swapped — there the junction's *target* column
640
+ * correlates and the source is pinned; here the *source* column correlates
641
+ * and the target is what the filter constrains.
642
+ *
643
+ * `hasMany`/`hasOne` are the same shape one table over: the target row
644
+ * carries the foreign key, so the correlation is on that key and the
645
+ * compared column is the target's own id.
646
+ *
647
+ * `EXISTS` and not a join, for the reason the scope condition gives: a join
648
+ * through a junction multiplies the outer rows by the number of matching
649
+ * links, which duplicates results and silently breaks `limit`/`offset`.
650
+ *
651
+ * Everything inside the subquery is referenced by identifier against a
652
+ * local alias, and only `sourceIdColumn` stays a Drizzle column object —
653
+ * again see {@link buildRelationScopeCondition}, which explains why a
654
+ * column object renders against whatever table the surrounding builder
655
+ * thinks is current and so cannot be used for the inner references. The
656
+ * alias is also what keeps a self-referential relation unambiguous
657
+ * (`categories.children`, or a many-to-many whose junction and target are
658
+ * the same table), where the subquery's table and the outer one coincide.
659
+ */
660
+ static buildRelationFilterCondition(relation, op, value, sourceIdColumn, registry, field, collectionPath) {
661
+ const alias = "__rel_filter";
662
+ const ref = (column) => sql`${sql.identifier(alias)}.${sql.identifier(column.name)}`;
663
+ let scanTable;
664
+ let correlation;
665
+ let comparedColumn;
666
+ if (relation.kind === "manyToMany") {
667
+ const { table: junctionName, sourceColumn, targetColumn } = relation.through;
668
+ const junctionTable = registry.getTable(junctionName);
669
+ if (!junctionTable) throw new Error(`Junction table not found: ${junctionName}`);
670
+ const sourceCol = junctionTable[sourceColumn];
671
+ const targetCol = junctionTable[targetColumn];
672
+ if (!sourceCol || !targetCol) throw new Error(`Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`);
673
+ scanTable = junctionTable;
674
+ correlation = sql`${ref(sourceCol)} = ${sourceIdColumn}`;
675
+ comparedColumn = targetCol;
676
+ } else {
677
+ const targetCollection = relation.target();
678
+ const targetTable = registry.getTable(getTableName$1(targetCollection));
679
+ if (!targetTable) throw new Error(`Table not found for the target of relation '${relation.relationName}' (collection '${targetCollection.slug}')`);
680
+ const fkColumn = targetTable[relation.foreignKeyOnTarget];
681
+ if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
682
+ const targetIdColumn = this.primaryKeyColumn(targetTable);
683
+ if (!targetIdColumn) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}', so a filter on it has nothing to match against.`);
684
+ scanTable = targetTable;
685
+ correlation = sql`${ref(fkColumn)} = ${sourceIdColumn}`;
686
+ comparedColumn = targetIdColumn;
687
+ }
688
+ const { predicate, negate } = this.buildRelationFilterPredicate(ref(comparedColumn), op, value, field, collectionPath);
689
+ const where = predicate ? sql`${correlation} AND ${predicate}` : correlation;
690
+ const exists = sql`EXISTS (SELECT 1 FROM ${scanTable} AS ${sql.identifier(alias)} WHERE ${where})`;
691
+ return negate ? sql`NOT ${exists}` : exists;
692
+ }
693
+ /**
694
+ * The inner predicate of a relation filter, and whether the `EXISTS`
695
+ * wrapping it is negated.
696
+ *
697
+ * Negation is `NOT EXISTS` of the *positive* predicate, never `EXISTS` of a
698
+ * negated one. On a many-valued relation the two are different questions:
699
+ * `EXISTS (… AND tag_id != X)` asks "does some tag differ from X", which is
700
+ * true of nearly every post with more than one tag and answers nothing
701
+ * anybody asked. `NOT EXISTS (… AND tag_id = X)` asks "is X absent", which
702
+ * is what unticking a value in a filter control means — and it makes `==`
703
+ * and `!=` partition the rows, the way a filter implies they do.
704
+ *
705
+ * `is-null`/`is-not-null` drop the predicate entirely: with nothing but the
706
+ * correlation left, they become "has no related row at all" and "has at
707
+ * least one", which is the only reading of null a link can have.
708
+ *
709
+ * Under RLS, "no related row" means *no row this reader can see*. A junction
710
+ * with row-level security but no `SELECT` policy for `rebase_user` is opaque
711
+ * to it, so every row comes back looking unlinked and `is-null` matches all
712
+ * of them. That is not a leak — the outer table's own policies still decide
713
+ * which rows exist at all, and the positive direction correctly returns
714
+ * nothing — but it over-reports, and the cause is a missing junction policy
715
+ * rather than anything here. Rebase derives one for a declared many-to-many;
716
+ * a hand-written schema has to supply it.
717
+ *
718
+ * `in`/`not-in` against a *null value* mean the same thing, rather than
719
+ * membership of an empty list. Membership against null is not a membership
720
+ * question, and the admin's "filter for null values" control emits the
721
+ * operator that happens to be selected — on a to-many relation that is
722
+ * always `in` or `not-in`, because those are the only ones the multi-select
723
+ * can produce. Reading `["in", null]` as an empty list would answer "posts
724
+ * with no tags" with no posts at all.
725
+ *
726
+ * An empty `in` list compiles to `FALSE` rather than being dropped. Dropped
727
+ * is what the column path does, and dropping a condition widens the result
728
+ * — the whole reason this resolution fails closed. `in []` matches nothing
729
+ * and `not-in []` matches everything, and `NOT EXISTS (… AND FALSE)` gives
730
+ * the second for free.
731
+ *
732
+ * Anything else is rejected. Returning `null` for an operator this cannot
733
+ * express would drop the condition, and the operators the admin offers for
734
+ * a relation are exactly the six below.
735
+ */
736
+ static buildRelationFilterPredicate(ref, op, value, field, collectionPath) {
737
+ value = unwrapRelationFilterValue(value);
738
+ const isNullish = value === null || value === void 0;
739
+ const equals = () => sql`${ref} = ${value}`;
740
+ const inList = () => {
741
+ const values = toMembershipList(value);
742
+ return values.length === 0 ? sql`FALSE` : sql`${ref} IN (${sql.join(values.map((v) => sql`${v}`), sql`, `)})`;
743
+ };
744
+ switch (op) {
745
+ case "==": return isNullish ? { negate: true } : {
746
+ predicate: equals(),
747
+ negate: false
748
+ };
749
+ case "!=": return isNullish ? { negate: false } : {
750
+ predicate: equals(),
751
+ negate: true
752
+ };
753
+ case "in": return isNullish ? { negate: true } : {
754
+ predicate: inList(),
755
+ negate: false
756
+ };
757
+ case "not-in": return isNullish ? { negate: false } : {
758
+ predicate: inList(),
759
+ negate: true
760
+ };
761
+ case "is-null": return { negate: true };
762
+ case "is-not-null": return { negate: false };
763
+ case "array-contains": return isNullish ? { negate: true } : {
764
+ predicate: equals(),
765
+ negate: false
766
+ };
767
+ case "array-contains-any": return isNullish ? { negate: true } : {
768
+ predicate: inList(),
769
+ negate: false
770
+ };
771
+ default: throw ApiError.badRequest(`Operator '${op}' cannot be applied to relation field '${field}' on collection '${collectionPath}'. A relation with no column on this row is filtered by membership: ==, !=, in, not-in, array-contains, array-contains-any, is-null, is-not-null.`, "UNSUPPORTED_RELATION_FILTER_OPERATOR", {
772
+ field,
773
+ collection: collectionPath,
774
+ operator: op
775
+ });
525
776
  }
526
777
  }
778
+ /** The column a table's rows are keyed by: its primary key, else `id`. */
779
+ static primaryKeyColumn(table) {
780
+ return Object.values(table).find((col) => col.primary) ?? Object.values(table).find((col) => col.name === "id");
781
+ }
527
782
  /**
528
783
  * Build a single filter condition for a specific operator and value
529
784
  */
@@ -540,9 +795,11 @@ var DrizzleConditionBuilder = class {
540
795
  case ">=": return sql`${column} >= ${value}`;
541
796
  case "<": return sql`${column} < ${value}`;
542
797
  case "<=": return sql`${column} <= ${value}`;
543
- case "in":
544
- if (Array.isArray(value) && value.length > 0) return inArray(column, value);
545
- return null;
798
+ case "in": {
799
+ if (value === null || value === void 0) return sql`${column} IS NULL`;
800
+ const values = toMembershipList(value);
801
+ return values.length === 0 ? sql`FALSE` : inArray(column, values);
802
+ }
546
803
  case "array-contains": {
547
804
  const meta = getColumnMeta(column);
548
805
  if (meta.dataType === "array" || meta.columnType === "PgArray") return sql`${column} @> ARRAY[${value}]`;
@@ -551,6 +808,7 @@ var DrizzleConditionBuilder = class {
551
808
  case "array-contains-any": {
552
809
  const meta = getColumnMeta(column);
553
810
  const isNativeArray = meta.dataType === "array" || meta.columnType === "PgArray";
811
+ if (Array.isArray(value) && value.length === 0) return sql`FALSE`;
554
812
  if (Array.isArray(value) && value.length > 0) if (isNativeArray) return sql`${column} && ARRAY[${sql.join(value.map((v) => sql`${v}`), sql`, `)}]`;
555
813
  else {
556
814
  const textValues = value.map((v) => String(v));
@@ -559,9 +817,12 @@ var DrizzleConditionBuilder = class {
559
817
  if (isNativeArray) return sql`${column} @> ARRAY[${value}]`;
560
818
  return sql`${column} @> ${JSON.stringify([value])}`;
561
819
  }
562
- case "not-in":
563
- if (Array.isArray(value) && value.length > 0) return sql`${column} NOT IN (${sql.join(value.map((v) => sql`${v}`), sql`, `)})`;
564
- return null;
820
+ case "not-in": {
821
+ if (value === null || value === void 0) return sql`${column} IS NOT NULL`;
822
+ const values = toMembershipList(value);
823
+ if (values.length === 0) return sql`TRUE`;
824
+ return sql`${column} NOT IN (${sql.join(values.map((v) => sql`${v}`), sql`, `)})`;
825
+ }
565
826
  case "like": return sql`${column} LIKE ${String(value)}`;
566
827
  case "ilike": return sql`${column} ILIKE ${String(value)}`;
567
828
  case "not-like": return sql`${column} NOT LIKE ${String(value)}`;
@@ -763,7 +1024,7 @@ var DrizzleConditionBuilder = class {
763
1024
  static buildSimpleRelationCondition(relation, targetTable, parentTable, parentId) {
764
1025
  const match = (column) => Array.isArray(parentId) ? inArray(column, parentId) : eq(column, parentId);
765
1026
  if (relation.kind === "belongsTo") {
766
- const targetIdCol = Object.values(targetTable).find((col) => col.primary) ?? Object.values(targetTable).find((col) => col.name === "id");
1027
+ const targetIdCol = this.primaryKeyColumn(targetTable);
767
1028
  if (!targetIdCol) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}'.`);
768
1029
  return match(targetIdCol);
769
1030
  }
@@ -2380,30 +2641,84 @@ var FetchService = class {
2380
2641
  return this.db.query?.[tableName];
2381
2642
  }
2382
2643
  /**
2644
+ * The context the condition builder needs to compile a filter key that is
2645
+ * not a column name outright.
2646
+ *
2647
+ * Two such keys. An owning relation's key resolves through the collection's
2648
+ * relations to its foreign-key column; a relation whose link lives on the
2649
+ * target table or in a junction resolves to a correlated `EXISTS`, which
2650
+ * needs the registry to reach that other table and this table's key column
2651
+ * to correlate back.
2652
+ *
2653
+ * Looked up rather than passed: every read path already has the path, only
2654
+ * some have the collection, and a path that names no registered collection
2655
+ * (a nested/derived one) is not an error here — the builder simply falls
2656
+ * back to guessing the default key shapes, and a relation filter it cannot
2657
+ * compile stays unresolvable and so fails closed.
2658
+ */
2659
+ filterContext(collectionPath, table) {
2660
+ const collection = this.registry.getCollectionByPath(collectionPath) ?? void 0;
2661
+ return {
2662
+ collection,
2663
+ registry: this.registry,
2664
+ sourceIdColumn: collection ? this.resolveIdColumn(collection, table) : void 0
2665
+ };
2666
+ }
2667
+ /**
2668
+ * The table column this collection's rows are keyed by, or `undefined`.
2669
+ *
2670
+ * `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
2671
+ * resolvable key is not an error on the filter path — it only means the
2672
+ * relation filters that would correlate on it cannot be compiled, which
2673
+ * the builder already handles by failing that field closed.
2674
+ */
2675
+ resolveIdColumn(collection, table) {
2676
+ const [idInfo] = getPrimaryKeys(collection, this.registry);
2677
+ if (!idInfo) return void 0;
2678
+ return table[idInfo.fieldName];
2679
+ }
2680
+ /**
2383
2681
  * Build filter conditions from FilterValues
2384
2682
  * Delegates to DrizzleConditionBuilder.buildFilterConditions
2385
2683
  */
2386
2684
  buildFilterConditions(filter, table, collectionPath) {
2387
- return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath);
2685
+ return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath, this.filterContext(collectionPath, table));
2388
2686
  }
2389
2687
  /**
2390
2688
  * Resolves the correct Drizzle column for sorting.
2391
2689
  * Automatically maps owning relation property keys to their underlying foreign key column.
2690
+ *
2691
+ * The relation's own `localKey` is the authority for that foreign key, not
2692
+ * `<field>_id`. The default local key comes from `generateForeignKeyName`,
2693
+ * which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
2694
+ * `users` → `user_id` — and an author can override it outright. A wrong
2695
+ * guess resolves to nothing, the caller drops the `ORDER BY`, and the rows
2696
+ * come back in whatever order Postgres pleases: paging over that repeats
2697
+ * and skips rows rather than erroring. The guesses stay, last, for a
2698
+ * caller that hands over no collection to resolve against.
2392
2699
  */
2393
2700
  resolveOrderByField(table, orderBy, collection) {
2394
- let orderByField = table[orderBy];
2395
- if (!orderByField && collection) {
2396
- const property = collection.properties[orderBy];
2397
- if (property && property.type === "relation" && "relation" in property && property.resolvedRelation?.kind === "belongsTo") orderByField = table[`${orderBy}_id`];
2701
+ const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
2702
+ const direct = columnAt(orderBy);
2703
+ if (direct) return direct;
2704
+ if (collection) {
2705
+ const relation = resolveCollectionRelations(collection)[orderBy];
2706
+ if (relation?.kind === "belongsTo") {
2707
+ const foreignKey = columnAt(relation.localKey);
2708
+ if (foreignKey) return foreignKey;
2709
+ }
2710
+ }
2711
+ for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
2712
+ const foreignKey = columnAt(guess);
2713
+ if (foreignKey) return foreignKey;
2398
2714
  }
2399
- return orderByField;
2400
2715
  }
2401
2716
  /**
2402
2717
  * Build the `with` config for Drizzle's relational query API.
2403
2718
  * Converts collection relations to a Drizzle-compatible `with` object.
2404
2719
  *
2405
2720
  * When `include` is provided, only those relations are loaded.
2406
- * When `include` is absent, ALL relations are loaded (CMS path).
2721
+ * When `include` is absent, ALL relations are loaded (the admin path).
2407
2722
  *
2408
2723
  * Automatically detects many-to-many junction tables and nests
2409
2724
  * the target relation so actual row data is returned.
@@ -2508,7 +2823,7 @@ var FetchService = class {
2508
2823
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
2509
2824
  }
2510
2825
  if (options.logical) {
2511
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
2826
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
2512
2827
  if (logicalCondition) allConditions.push(logicalCondition);
2513
2828
  }
2514
2829
  if (options.startAfter) {
@@ -2689,7 +3004,7 @@ var FetchService = class {
2689
3004
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
2690
3005
  }
2691
3006
  if (options.logical) {
2692
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
3007
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
2693
3008
  if (logicalCondition) allConditions.push(logicalCondition);
2694
3009
  }
2695
3010
  if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
@@ -3115,6 +3430,18 @@ var FetchService = class {
3115
3430
  * a message that is safe and helpful to show to end-users.
3116
3431
  */
3117
3432
  /**
3433
+ * Return the error when it is a deliberate 4xx, otherwise null.
3434
+ *
3435
+ * A thrown `ApiError` is a decision the server made about the request, not a
3436
+ * database failure: its message and code are already written for the client.
3437
+ */
3438
+ function asClientFacingError(error) {
3439
+ if (!(error instanceof Error)) return null;
3440
+ const e = error;
3441
+ if (typeof e.statusCode !== "number" || e.statusCode < 400 || e.statusCode >= 500) return null;
3442
+ return e;
3443
+ }
3444
+ /**
3118
3445
  * Extract the underlying PostgreSQL error from a Drizzle wrapper.
3119
3446
  * Drizzle wraps PG errors in a `cause` property — this function
3120
3447
  * recursively walks the chain until it finds an object with a PG
@@ -3238,14 +3565,27 @@ function pgErrorToFriendlyMessage(pgError, context) {
3238
3565
  /**
3239
3566
  * Sanitize any error into a message safe and helpful for the client.
3240
3567
  *
3241
- * Extracts the PG error from the Drizzle cause chain when possible;
3242
- * falls back to a generic message that doesn't leak SQL.
3568
+ * A deliberate 4xx (`ApiError`) passes through untouched the server already
3569
+ * decided what the client should read. Otherwise the PG error is extracted
3570
+ * from the Drizzle cause chain, falling back to a generic message that
3571
+ * doesn't leak SQL.
3243
3572
  *
3244
3573
  * @param error - The raw caught error
3245
3574
  * @param context - A human-readable context string (e.g. collection path)
3246
- * @returns An object with `message` (user-friendly) and optional `code` (PG code).
3575
+ * @returns An object with `message` (user-friendly) and optional `code`
3576
+ * (the `ApiError` code, or the PG SQLSTATE).
3247
3577
  */
3248
3578
  function sanitizeErrorForClient(error, context) {
3579
+ const clientError = asClientFacingError(error);
3580
+ if (clientError) {
3581
+ const line = `[API ${clientError.statusCode} ${clientError.code ?? "BAD_REQUEST"}] in "${context}": ${clientError.message}`;
3582
+ if (clientError.expected) logger.debug(line);
3583
+ else logger.warn(`⚠️ ${line}`);
3584
+ return {
3585
+ message: clientError.message,
3586
+ ...clientError.code && { code: clientError.code }
3587
+ };
3588
+ }
3249
3589
  const pgError = extractPgError(error);
3250
3590
  if (pgError) {
3251
3591
  logger.error(`[PG ${pgError.code}] Error in "${context}"`, {
@@ -5706,9 +6046,11 @@ var generateSchema = async (collections, stripPolicies = false) => {
5706
6046
  if (emittedRelationNames.has(deduplicationKey)) continue;
5707
6047
  emittedRelationNames.add(deduplicationKey);
5708
6048
  switch (rel.kind) {
5709
- case "belongsTo":
5710
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
6049
+ case "belongsTo": {
6050
+ const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
6051
+ tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${localFieldKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
5711
6052
  break;
6053
+ }
5712
6054
  case "hasOne":
5713
6055
  tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
5714
6056
  break;
@@ -20295,7 +20637,7 @@ function buildCollectionsFromSchema({ tablesMap, enumMap, joinTables }, pgSchema
20295
20637
  /**
20296
20638
  * Build drizzle tables at runtime from an introspected schema.
20297
20639
  *
20298
- * CMS mode gets its drizzle tables from a generated `schema.generated.ts` that
20640
+ * A project with declared collections gets its drizzle tables from a generated `schema.generated.ts` that
20299
20641
  * the developer commits. BaaS mode has no such file — it points at a database
20300
20642
  * and serves it — so the equivalent table objects are constructed here from
20301
20643
  * `information_schema` metadata.
@@ -20488,17 +20830,18 @@ function isEconnrefused(err) {
20488
20830
  * ```
20489
20831
  */
20490
20832
  function createPostgresBootstrapper(pgConfig) {
20833
+ if (pgConfig.unknownFilterFields) configureUnknownFilterFields(pgConfig.unknownFilterFields);
20491
20834
  return {
20492
20835
  type: "postgres",
20493
20836
  async initializeDriver(config) {
20494
- const { collections, collectionRegistry, mode, baas } = config;
20837
+ const { collections, collectionRegistry, introspectCollections, baas } = config;
20495
20838
  const unprotectedTables = baas?.unprotectedTables ?? "exclude";
20496
20839
  const connection = pgConfig.connection;
20497
20840
  const rawClient = connection && typeof connection === "object" && "$client" in connection ? connection.$client : connection;
20498
20841
  let introspectedCollections;
20499
20842
  let introspectedTables;
20500
20843
  let introspectedRelations;
20501
- if (mode === "baas" && (!collections || collections.length === 0)) {
20844
+ if (introspectCollections && (!collections || collections.length === 0)) {
20502
20845
  const pgSchemaName = pgConfig.introspectionSchema ?? "public";
20503
20846
  const schema = await introspectSchema(rawClient, pgSchemaName);
20504
20847
  const rlsStatus = await readRlsStatus(rawClient, pgSchemaName);
@@ -20667,7 +21010,8 @@ function createPostgresBootstrapper(pgConfig) {
20667
21010
  for (const col of registeredCollections) {
20668
21011
  if (col.auth?.enabled) continue;
20669
21012
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
20670
- const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
21013
+ const declaredTable = isRelationalCollectionConfig(col) ? col.table : void 0;
21014
+ const tableName = registry.hasTableForCollection(declaredTable ?? col.slug) ? declaredTable ?? col.slug : col.slug;
20671
21015
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
20672
21016
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
20673
21017
  if (!dbTables.has(fullCheckName)) missing.push({
@@ -20774,7 +21118,7 @@ function createPostgresBootstrapper(pgConfig) {
20774
21118
  */
20775
21119
  async ensureCollectionSchema(collections, driverResult, log) {
20776
21120
  const internals = driverResult.internals;
20777
- const { ensureCollectionTables } = await import("./ensure-collection-tables-DGMYK0fr.js");
21121
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-CNTcZGvn.js");
20778
21122
  return { applied: (await ensureCollectionTables({ async query(text) {
20779
21123
  const result = await internals.db.execute(sql.raw(text));
20780
21124
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
@@ -20823,6 +21167,6 @@ function createPostgresAdapter(pgConfig) {
20823
21167
  };
20824
21168
  }
20825
21169
  //#endregion
20826
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, frameByteLength, generateSchema, getServerVersionMajor, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusConfig, resolveChannelBusSetting, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
21170
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, frameByteLength, generateSchema, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusConfig, resolveChannelBusSetting, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
20827
21171
 
20828
21172
  //# sourceMappingURL=index.es.js.map