@rebasepro/server-postgres 0.11.1-canary.g8caabf3 → 0.11.1-canary.g95e61c2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBootstrapper.d.ts +8 -0
- package/dist/{ensure-collection-tables-DWEvKSY8.js → ensure-collection-tables-CNTcZGvn.js} +3 -3
- package/dist/{ensure-collection-tables-DWEvKSY8.js.map → ensure-collection-tables-CNTcZGvn.js.map} +1 -1
- package/dist/history/HistoryService.d.ts +9 -29
- package/dist/index.es.js +386 -46
- package/dist/index.es.js.map +1 -1
- package/dist/services/FetchService.d.ts +35 -0
- package/dist/services/row-pipeline.d.ts +3 -1
- package/dist/{src-Da-RFMnG.js → src-BbFOPJ1S.js} +196 -17
- package/dist/src-BbFOPJ1S.js.map +1 -0
- package/dist/{src-D5xBTl32.js → src-Zqwaw3P5.js} +136 -90
- package/dist/src-Zqwaw3P5.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +157 -3
- package/dist/utils/pg-error-utils.d.ts +6 -3
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +19 -2
- package/src/history/HistoryService.ts +13 -31
- package/src/services/FetchService.ts +78 -10
- package/src/services/row-pipeline.ts +3 -1
- package/src/utils/drizzle-conditions.ts +491 -44
- package/src/utils/pg-error-utils.ts +52 -3
- package/dist/src-D5xBTl32.js.map +0 -1
- package/dist/src-Da-RFMnG.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 {
|
|
6
|
-
import { C as createRelationRefWithData, D as
|
|
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.
|
|
@@ -482,24 +520,88 @@ var DrizzleConditionBuilder = class {
|
|
|
482
520
|
return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
|
|
483
521
|
}
|
|
484
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
|
+
/**
|
|
485
593
|
* Build filter conditions from FilterValues
|
|
486
594
|
*/
|
|
487
|
-
static buildFilterConditions(filter, table, collectionPath) {
|
|
595
|
+
static buildFilterConditions(filter, table, collectionPath, options = {}) {
|
|
596
|
+
const mode = options.unknownFields ?? defaultUnknownFilterFieldsMode;
|
|
488
597
|
const conditions = [];
|
|
489
598
|
for (const [field, filterParam] of Object.entries(filter)) {
|
|
490
599
|
if (!filterParam) continue;
|
|
491
|
-
|
|
492
|
-
if (!
|
|
493
|
-
const relationKey = `${field}_id`;
|
|
494
|
-
if (relationKey in table) fieldColumn = table[relationKey];
|
|
495
|
-
}
|
|
496
|
-
if (!fieldColumn) {
|
|
497
|
-
logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
600
|
+
const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
|
|
601
|
+
if (!target) continue;
|
|
500
602
|
const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0]) ? filterParam : [filterParam];
|
|
501
603
|
for (const [op, value] of paramsList) {
|
|
502
|
-
const condition = this.
|
|
604
|
+
const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
|
|
503
605
|
if (condition) conditions.push(condition);
|
|
504
606
|
}
|
|
505
607
|
}
|
|
@@ -508,24 +610,175 @@ var DrizzleConditionBuilder = class {
|
|
|
508
610
|
/**
|
|
509
611
|
* Build logical conditions recursively from LogicalCondition or FilterCondition
|
|
510
612
|
*/
|
|
511
|
-
static buildLogicalConditions(cond, table, collectionPath) {
|
|
613
|
+
static buildLogicalConditions(cond, table, collectionPath, options = {}) {
|
|
512
614
|
if ("type" in cond) {
|
|
513
|
-
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);
|
|
514
616
|
if (subSQLs.length === 0) return null;
|
|
515
617
|
return (cond.type === "or" ? or(...subSQLs) : and(...subSQLs)) ?? null;
|
|
516
618
|
} else {
|
|
517
|
-
|
|
518
|
-
if (!
|
|
519
|
-
|
|
520
|
-
if (relationKey in table) fieldColumn = table[relationKey];
|
|
521
|
-
}
|
|
522
|
-
if (!fieldColumn) {
|
|
523
|
-
logger.warn(`Filtering by field '${cond.column}', but it does not exist in table for collection '${collectionPath}'`);
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
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);
|
|
527
622
|
}
|
|
528
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
|
+
});
|
|
776
|
+
}
|
|
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
|
+
}
|
|
529
782
|
/**
|
|
530
783
|
* Build a single filter condition for a specific operator and value
|
|
531
784
|
*/
|
|
@@ -542,9 +795,11 @@ var DrizzleConditionBuilder = class {
|
|
|
542
795
|
case ">=": return sql`${column} >= ${value}`;
|
|
543
796
|
case "<": return sql`${column} < ${value}`;
|
|
544
797
|
case "<=": return sql`${column} <= ${value}`;
|
|
545
|
-
case "in":
|
|
546
|
-
if (
|
|
547
|
-
|
|
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
|
+
}
|
|
548
803
|
case "array-contains": {
|
|
549
804
|
const meta = getColumnMeta(column);
|
|
550
805
|
if (meta.dataType === "array" || meta.columnType === "PgArray") return sql`${column} @> ARRAY[${value}]`;
|
|
@@ -553,6 +808,7 @@ var DrizzleConditionBuilder = class {
|
|
|
553
808
|
case "array-contains-any": {
|
|
554
809
|
const meta = getColumnMeta(column);
|
|
555
810
|
const isNativeArray = meta.dataType === "array" || meta.columnType === "PgArray";
|
|
811
|
+
if (Array.isArray(value) && value.length === 0) return sql`FALSE`;
|
|
556
812
|
if (Array.isArray(value) && value.length > 0) if (isNativeArray) return sql`${column} && ARRAY[${sql.join(value.map((v) => sql`${v}`), sql`, `)}]`;
|
|
557
813
|
else {
|
|
558
814
|
const textValues = value.map((v) => String(v));
|
|
@@ -561,9 +817,12 @@ var DrizzleConditionBuilder = class {
|
|
|
561
817
|
if (isNativeArray) return sql`${column} @> ARRAY[${value}]`;
|
|
562
818
|
return sql`${column} @> ${JSON.stringify([value])}`;
|
|
563
819
|
}
|
|
564
|
-
case "not-in":
|
|
565
|
-
if (
|
|
566
|
-
|
|
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
|
+
}
|
|
567
826
|
case "like": return sql`${column} LIKE ${String(value)}`;
|
|
568
827
|
case "ilike": return sql`${column} ILIKE ${String(value)}`;
|
|
569
828
|
case "not-like": return sql`${column} NOT LIKE ${String(value)}`;
|
|
@@ -765,7 +1024,7 @@ var DrizzleConditionBuilder = class {
|
|
|
765
1024
|
static buildSimpleRelationCondition(relation, targetTable, parentTable, parentId) {
|
|
766
1025
|
const match = (column) => Array.isArray(parentId) ? inArray(column, parentId) : eq(column, parentId);
|
|
767
1026
|
if (relation.kind === "belongsTo") {
|
|
768
|
-
const targetIdCol =
|
|
1027
|
+
const targetIdCol = this.primaryKeyColumn(targetTable);
|
|
769
1028
|
if (!targetIdCol) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}'.`);
|
|
770
1029
|
return match(targetIdCol);
|
|
771
1030
|
}
|
|
@@ -2382,23 +2641,77 @@ var FetchService = class {
|
|
|
2382
2641
|
return this.db.query?.[tableName];
|
|
2383
2642
|
}
|
|
2384
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
|
+
/**
|
|
2385
2681
|
* Build filter conditions from FilterValues
|
|
2386
2682
|
* Delegates to DrizzleConditionBuilder.buildFilterConditions
|
|
2387
2683
|
*/
|
|
2388
2684
|
buildFilterConditions(filter, table, collectionPath) {
|
|
2389
|
-
return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath);
|
|
2685
|
+
return DrizzleConditionBuilder.buildFilterConditions(filter, table, collectionPath, this.filterContext(collectionPath, table));
|
|
2390
2686
|
}
|
|
2391
2687
|
/**
|
|
2392
2688
|
* Resolves the correct Drizzle column for sorting.
|
|
2393
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.
|
|
2394
2699
|
*/
|
|
2395
2700
|
resolveOrderByField(table, orderBy, collection) {
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
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;
|
|
2400
2714
|
}
|
|
2401
|
-
return orderByField;
|
|
2402
2715
|
}
|
|
2403
2716
|
/**
|
|
2404
2717
|
* Build the `with` config for Drizzle's relational query API.
|
|
@@ -2510,7 +2823,7 @@ var FetchService = class {
|
|
|
2510
2823
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
2511
2824
|
}
|
|
2512
2825
|
if (options.logical) {
|
|
2513
|
-
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
|
|
2826
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
2514
2827
|
if (logicalCondition) allConditions.push(logicalCondition);
|
|
2515
2828
|
}
|
|
2516
2829
|
if (options.startAfter) {
|
|
@@ -2691,7 +3004,7 @@ var FetchService = class {
|
|
|
2691
3004
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
2692
3005
|
}
|
|
2693
3006
|
if (options.logical) {
|
|
2694
|
-
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
|
|
3007
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
2695
3008
|
if (logicalCondition) allConditions.push(logicalCondition);
|
|
2696
3009
|
}
|
|
2697
3010
|
if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
|
|
@@ -3117,6 +3430,18 @@ var FetchService = class {
|
|
|
3117
3430
|
* a message that is safe and helpful to show to end-users.
|
|
3118
3431
|
*/
|
|
3119
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
|
+
/**
|
|
3120
3445
|
* Extract the underlying PostgreSQL error from a Drizzle wrapper.
|
|
3121
3446
|
* Drizzle wraps PG errors in a `cause` property — this function
|
|
3122
3447
|
* recursively walks the chain until it finds an object with a PG
|
|
@@ -3240,14 +3565,27 @@ function pgErrorToFriendlyMessage(pgError, context) {
|
|
|
3240
3565
|
/**
|
|
3241
3566
|
* Sanitize any error into a message safe and helpful for the client.
|
|
3242
3567
|
*
|
|
3243
|
-
*
|
|
3244
|
-
*
|
|
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.
|
|
3245
3572
|
*
|
|
3246
3573
|
* @param error - The raw caught error
|
|
3247
3574
|
* @param context - A human-readable context string (e.g. collection path)
|
|
3248
|
-
* @returns An object with `message` (user-friendly) and optional `code`
|
|
3575
|
+
* @returns An object with `message` (user-friendly) and optional `code`
|
|
3576
|
+
* (the `ApiError` code, or the PG SQLSTATE).
|
|
3249
3577
|
*/
|
|
3250
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
|
+
}
|
|
3251
3589
|
const pgError = extractPgError(error);
|
|
3252
3590
|
if (pgError) {
|
|
3253
3591
|
logger.error(`[PG ${pgError.code}] Error in "${context}"`, {
|
|
@@ -20492,6 +20830,7 @@ function isEconnrefused(err) {
|
|
|
20492
20830
|
* ```
|
|
20493
20831
|
*/
|
|
20494
20832
|
function createPostgresBootstrapper(pgConfig) {
|
|
20833
|
+
if (pgConfig.unknownFilterFields) configureUnknownFilterFields(pgConfig.unknownFilterFields);
|
|
20495
20834
|
return {
|
|
20496
20835
|
type: "postgres",
|
|
20497
20836
|
async initializeDriver(config) {
|
|
@@ -20671,7 +21010,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20671
21010
|
for (const col of registeredCollections) {
|
|
20672
21011
|
if (col.auth?.enabled) continue;
|
|
20673
21012
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
20674
|
-
const
|
|
21013
|
+
const declaredTable = isRelationalCollectionConfig(col) ? col.table : void 0;
|
|
21014
|
+
const tableName = registry.hasTableForCollection(declaredTable ?? col.slug) ? declaredTable ?? col.slug : col.slug;
|
|
20675
21015
|
const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
|
|
20676
21016
|
const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
|
|
20677
21017
|
if (!dbTables.has(fullCheckName)) missing.push({
|
|
@@ -20778,7 +21118,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
20778
21118
|
*/
|
|
20779
21119
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
20780
21120
|
const internals = driverResult.internals;
|
|
20781
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
21121
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-CNTcZGvn.js");
|
|
20782
21122
|
return { applied: (await ensureCollectionTables({ async query(text) {
|
|
20783
21123
|
const result = await internals.db.execute(sql.raw(text));
|
|
20784
21124
|
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
@@ -20827,6 +21167,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
20827
21167
|
};
|
|
20828
21168
|
}
|
|
20829
21169
|
//#endregion
|
|
20830
|
-
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 };
|
|
20831
21171
|
|
|
20832
21172
|
//# sourceMappingURL=index.es.js.map
|