@rebasepro/common 0.7.0 → 0.9.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.
- package/README.md +4 -4
- package/dist/collections/CollectionRegistry.d.ts +30 -15
- package/dist/collections/default-collections.d.ts +255 -2
- package/dist/data/buildRebaseData.d.ts +30 -2
- package/dist/data/buildRoutedRebaseData.d.ts +14 -9
- package/dist/data/filter-dialect.d.ts +75 -0
- package/dist/data/query_builder.d.ts +4 -4
- package/dist/data/resolveDataSource.d.ts +8 -8
- package/dist/data/sort-dialect.d.ts +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.es.js +1125 -299
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1138 -303
- package/dist/index.umd.js.map +1 -1
- package/dist/util/builders.d.ts +52 -42
- package/dist/util/callbacks.d.ts +8 -3
- package/dist/util/collections.d.ts +4 -4
- package/dist/util/entities.d.ts +2 -2
- package/dist/util/filter-operator-resolution.d.ts +32 -0
- package/dist/util/index.d.ts +2 -0
- package/dist/util/navigation_from_path.d.ts +4 -4
- package/dist/util/navigation_utils.d.ts +3 -3
- package/dist/util/parent_references_from_path.d.ts +2 -2
- package/dist/util/permissions.d.ts +30 -6
- package/dist/util/policy/evaluatePolicy.d.ts +31 -0
- package/dist/util/policy/index.d.ts +3 -0
- package/dist/util/policy/policyToPostgres.d.ts +22 -0
- package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
- package/dist/util/policy/sqlToPolicy.d.ts +20 -0
- package/dist/util/references.d.ts +2 -2
- package/dist/util/relations.d.ts +5 -5
- package/dist/util/resolutions.d.ts +2 -2
- package/dist/util/storage.d.ts +26 -1
- package/package.json +13 -13
- package/src/collections/CollectionRegistry.ts +92 -61
- package/src/collections/default-collections.ts +4 -4
- package/src/data/buildRebaseData.ts +336 -172
- package/src/data/buildRoutedRebaseData.ts +22 -16
- package/src/data/filter-dialect.ts +403 -0
- package/src/data/query_builder.ts +19 -10
- package/src/data/resolveDataSource.ts +10 -10
- package/src/data/sort-dialect.ts +56 -0
- package/src/index.ts +2 -0
- package/src/util/builders.ts +87 -84
- package/src/util/callbacks.ts +15 -8
- package/src/util/collections.ts +4 -4
- package/src/util/entities.ts +4 -4
- package/src/util/filter-operator-resolution.ts +81 -0
- package/src/util/index.ts +2 -0
- package/src/util/navigation_from_path.ts +4 -4
- package/src/util/navigation_utils.ts +8 -8
- package/src/util/parent_references_from_path.ts +3 -3
- package/src/util/permissions.test.ts +7 -5
- package/src/util/permissions.ts +90 -163
- package/src/util/policy/evaluatePolicy.ts +152 -0
- package/src/util/policy/index.ts +3 -0
- package/src/util/policy/policyToPostgres.ts +165 -0
- package/src/util/policy/securityRuleToConditions.ts +67 -0
- package/src/util/policy/sqlToPolicy.ts +88 -0
- package/src/util/references.ts +3 -3
- package/src/util/relations.ts +19 -20
- package/src/util/resolutions.ts +11 -11
- package/src/util/storage.ts +34 -1
package/dist/index.es.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_DATA_SOURCE_KEY, EntityReference, EntityRelation, getDataSourceCapabilities } from "@rebasepro/types";
|
|
1
|
+
import { ALL_WHERE_FILTER_OPS, CANONICAL_TO_REST, DEFAULT_DATA_SOURCE_KEY, EntityReference, EntityRelation, NULL_OPS, REST_TO_CANONICAL, getDataSourceCapabilities, getDeclaredSubcollections, policy, toCanonicalOp } from "@rebasepro/types";
|
|
2
2
|
import { deepClone, generateForeignKeyName, getIn, isDefaultFieldConfigId, mergeDeep, randomString, removeFunctions, toSnakeCase } from "@rebasepro/utils";
|
|
3
3
|
import jsonLogic from "json-logic-js";
|
|
4
4
|
import { deepEqual } from "fast-equals";
|
|
@@ -54,7 +54,7 @@ function getDefaultValueFortype(type) {
|
|
|
54
54
|
else return null;
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
* Update the automatic values in
|
|
57
|
+
* Update the automatic values in a entity before save
|
|
58
58
|
* @group Driver
|
|
59
59
|
*/
|
|
60
60
|
function updateDateAutoValues({ inputValues, properties, status, timestampNowValue }) {
|
|
@@ -66,7 +66,7 @@ function updateDateAutoValues({ inputValues, properties, status, timestampNowVal
|
|
|
66
66
|
}) ?? {};
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
|
-
* Add missing required fields, expected in the collection, to the values of
|
|
69
|
+
* Add missing required fields, expected in the collection, to the values of a entity
|
|
70
70
|
* @param values
|
|
71
71
|
* @param properties
|
|
72
72
|
* @group Driver
|
|
@@ -236,7 +236,7 @@ function getLocalChangesBackup(collection) {
|
|
|
236
236
|
return collection.localChangesBackup;
|
|
237
237
|
}
|
|
238
238
|
/**
|
|
239
|
-
* Returns the primary keys for
|
|
239
|
+
* Returns the primary keys for a entity collection by inspecting the properties
|
|
240
240
|
* and finding any properties with `isId`.
|
|
241
241
|
* Fallbacks to `["id"]` if no properties are marked as `isId: true`.
|
|
242
242
|
* @param collection
|
|
@@ -336,7 +336,7 @@ function sanitizeRelation(relation, sourceCollection, resolveCollection) {
|
|
|
336
336
|
if (!newRelation.foreignKeyOnTarget) {
|
|
337
337
|
let foundForeignKey = false;
|
|
338
338
|
try {
|
|
339
|
-
const targetRelations = getDataSourceCapabilities(targetCollection.
|
|
339
|
+
const targetRelations = getDataSourceCapabilities(targetCollection.engine).supportsRelations ? targetCollection.relations || [] : [];
|
|
340
340
|
for (const targetRel of targetRelations) if (targetRel.direction === "owning" && targetRel.cardinality === "one" && targetRel.localKey) try {
|
|
341
341
|
if (targetRel.target().slug === sourceCollection.slug) {
|
|
342
342
|
newRelation.foreignKeyOnTarget = targetRel.localKey;
|
|
@@ -352,7 +352,7 @@ function sanitizeRelation(relation, sourceCollection, resolveCollection) {
|
|
|
352
352
|
} else if (newRelation.cardinality === "many" && newRelation.direction === "inverse") {
|
|
353
353
|
let isManyToManyInverse = false;
|
|
354
354
|
if (newRelation.inverseRelationName && !newRelation.foreignKeyOnTarget) try {
|
|
355
|
-
const targetRelations = getDataSourceCapabilities(targetCollection.
|
|
355
|
+
const targetRelations = getDataSourceCapabilities(targetCollection.engine).supportsRelations ? targetCollection.relations || [] : [];
|
|
356
356
|
for (const targetRel of targetRelations) if (targetRel.cardinality === "many" && (targetRel.direction === "owning" || !targetRel.direction) && targetRel.relationName === newRelation.inverseRelationName) {
|
|
357
357
|
isManyToManyInverse = true;
|
|
358
358
|
break;
|
|
@@ -387,11 +387,10 @@ var _resolvedRelationsCache = /* @__PURE__ */ new WeakMap();
|
|
|
387
387
|
function resolveCollectionRelations(collection) {
|
|
388
388
|
const cached = _resolvedRelationsCache.get(collection);
|
|
389
389
|
if (cached) return cached;
|
|
390
|
-
if (!getDataSourceCapabilities(collection.
|
|
391
|
-
const relCollection = collection;
|
|
390
|
+
if (!getDataSourceCapabilities(collection.engine).supportsRelations) return {};
|
|
392
391
|
const relations = {};
|
|
393
392
|
const registeredRelationNames = /* @__PURE__ */ new Set();
|
|
394
|
-
if (
|
|
393
|
+
if (collection.relations) collection.relations.forEach((relation) => {
|
|
395
394
|
try {
|
|
396
395
|
const normalizedRelation = sanitizeRelation(relation, collection);
|
|
397
396
|
const relationKey = normalizedRelation.relationName;
|
|
@@ -438,7 +437,7 @@ function resolvePropertyRelation({ propertyKey, property, sourceCollection }) {
|
|
|
438
437
|
console.warn(`Unrecognized or missing relation target for property '${propertyKey}' in collection '${sourceCollection.slug}'`);
|
|
439
438
|
}
|
|
440
439
|
function getTableName(collection) {
|
|
441
|
-
if (getDataSourceCapabilities(collection.
|
|
440
|
+
if (getDataSourceCapabilities(collection.engine).supportsRelations) return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
|
442
441
|
return toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
|
|
443
442
|
}
|
|
444
443
|
function getTableVarName(tableName) {
|
|
@@ -643,8 +642,9 @@ function resolveEnumValues(input) {
|
|
|
643
642
|
}
|
|
644
643
|
function getSubcollections(collection) {
|
|
645
644
|
if (collection.childCollections) return collection.childCollections() ?? [];
|
|
646
|
-
|
|
647
|
-
if (getDataSourceCapabilities(collection.
|
|
645
|
+
const declaredSubcollections = getDeclaredSubcollections(collection);
|
|
646
|
+
if (getDataSourceCapabilities(collection.engine).supportsSubcollections && declaredSubcollections) return declaredSubcollections() ?? [];
|
|
647
|
+
if (getDataSourceCapabilities(collection.engine).supportsRelations) {
|
|
648
648
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
649
649
|
return Object.values(resolvedRelations).filter((r) => r.cardinality === "many").map((r) => {
|
|
650
650
|
const target = r.target();
|
|
@@ -670,113 +670,379 @@ function getSubcollections(collection) {
|
|
|
670
670
|
return [];
|
|
671
671
|
}
|
|
672
672
|
//#endregion
|
|
673
|
-
//#region src/util/
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
673
|
+
//#region src/util/policy/sqlToPolicy.ts
|
|
674
|
+
/**
|
|
675
|
+
* A tiny, regex-based SQL "parser" for security rules.
|
|
676
|
+
*
|
|
677
|
+
* This is NOT a full SQL parser. It is designed to handle the subset of SQL
|
|
678
|
+
* commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
|
|
679
|
+
* optimistic client-side UI decision.
|
|
680
|
+
*
|
|
681
|
+
* It handles:
|
|
682
|
+
* - `field = 'literal'`
|
|
683
|
+
* - `field != 'literal'`
|
|
684
|
+
* - `field = current_setting('app.user_id')`
|
|
685
|
+
* - `A AND B`
|
|
686
|
+
* - `true`
|
|
687
|
+
* - `IN (...)` (as optimistic true)
|
|
688
|
+
*
|
|
689
|
+
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
690
|
+
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
691
|
+
*/
|
|
692
|
+
function sqlToPolicy(sql) {
|
|
693
|
+
const trimmed = sql.trim();
|
|
694
|
+
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
695
|
+
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
696
|
+
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
697
|
+
if (overlapMatch) {
|
|
698
|
+
const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
699
|
+
return policy.rolesOverlap(roles);
|
|
700
|
+
}
|
|
701
|
+
const containMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
702
|
+
if (containMatch) {
|
|
703
|
+
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
704
|
+
return policy.rolesContain(roles);
|
|
705
|
+
}
|
|
706
|
+
if (trimmed.toUpperCase().includes(" OR ")) {
|
|
707
|
+
const parts = trimmed.split(/ OR /i);
|
|
708
|
+
return policy.or(...parts.map(sqlToPolicy));
|
|
709
|
+
}
|
|
710
|
+
if (trimmed.toUpperCase().includes(" AND ")) {
|
|
711
|
+
const parts = trimmed.split(/ AND /i);
|
|
712
|
+
return policy.and(...parts.map(sqlToPolicy));
|
|
713
|
+
}
|
|
714
|
+
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
715
|
+
if (match) {
|
|
716
|
+
const [, leftStr, op, rightStr] = match;
|
|
717
|
+
const left = parseOperand(leftStr.trim());
|
|
718
|
+
const right = parseOperand(rightStr.trim());
|
|
719
|
+
if (left && right) return policy.compare(left, op === "=" ? "eq" : "neq", right);
|
|
720
|
+
}
|
|
721
|
+
return policy.raw(sql);
|
|
722
|
+
}
|
|
723
|
+
function parseOperand(str) {
|
|
724
|
+
if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
|
|
725
|
+
const stringMatch = str.match(/^'(.+)'$/);
|
|
726
|
+
if (stringMatch) return policy.literal(stringMatch[1]);
|
|
727
|
+
if (/^\w+$/.test(str)) return policy.field(str);
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region src/util/policy/securityRuleToConditions.ts
|
|
732
|
+
/**
|
|
733
|
+
* Desugars a {@link SecurityRule} — its `access`/`ownerField`/`roles` shortcuts,
|
|
734
|
+
* structured `condition`/`check`, and raw `using`/`withCheck` — into a single
|
|
735
|
+
* normalized {@link PolicyExpression} pair.
|
|
736
|
+
*
|
|
737
|
+
* **This is the linchpin against drift:** both the Postgres DDL generators and
|
|
738
|
+
* the client-side evaluator consume this one function, so there is exactly one
|
|
739
|
+
* definition of what a rule means. In particular, application `roles` are folded
|
|
740
|
+
* into the expression here (AND'd with the base condition, matching how Postgres
|
|
741
|
+
* generates the clause) rather than being handled separately by each consumer.
|
|
742
|
+
*/
|
|
743
|
+
function securityRuleToConditions(rule) {
|
|
744
|
+
return {
|
|
745
|
+
usingExpr: withRoles(baseUsing(rule), rule),
|
|
746
|
+
withCheckExpr: withRoles(baseWithCheck(rule), rule)
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function baseUsing(rule) {
|
|
750
|
+
if (rule.condition) return rule.condition;
|
|
751
|
+
if (rule.using != null) return sqlToPolicy(rule.using);
|
|
752
|
+
if (rule.access === "public") return policy.true();
|
|
753
|
+
if (rule.ownerField) return policy.compare(policy.field(rule.ownerField), "eq", policy.authUid());
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
function baseWithCheck(rule) {
|
|
757
|
+
if (rule.check) return rule.check;
|
|
758
|
+
if (rule.withCheck != null) return sqlToPolicy(rule.withCheck);
|
|
759
|
+
return baseUsing(rule);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* AND the base condition with an application-role check, or produce a roles-only
|
|
763
|
+
* condition when there is no base. Mirrors the Postgres generator so that a
|
|
764
|
+
* role-scoped restrictive rule denies exactly the same set of users on both
|
|
765
|
+
* sides.
|
|
766
|
+
*/
|
|
767
|
+
function withRoles(base, rule) {
|
|
768
|
+
if (!rule.roles || rule.roles.length === 0) return base;
|
|
769
|
+
const rolesExpr = policy.rolesOverlap(rule.roles);
|
|
770
|
+
if (rule.mode === "restrictive") return base ? policy.or(policy.not(rolesExpr), base) : policy.not(rolesExpr);
|
|
771
|
+
return base ? policy.and(base, rolesExpr) : rolesExpr;
|
|
772
|
+
}
|
|
773
|
+
//#endregion
|
|
774
|
+
//#region src/util/policy/policyToPostgres.ts
|
|
775
|
+
/**
|
|
776
|
+
* Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
|
|
777
|
+
* suitable for a `USING (...)` / `WITH CHECK (...)` clause.
|
|
778
|
+
*
|
|
779
|
+
* This is one of the two consumers of the shared policy model (the other being
|
|
780
|
+
* {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
|
|
781
|
+
* and the admin UI derive from the exact same expression.
|
|
782
|
+
*/
|
|
783
|
+
function policyToPostgres(expr, collection, options) {
|
|
784
|
+
return compile(expr, {
|
|
785
|
+
fieldCollection: collection,
|
|
786
|
+
fieldPrefix: "",
|
|
787
|
+
outerCollection: collection,
|
|
788
|
+
outerPrefix: "",
|
|
789
|
+
resolveCollection: options?.resolveCollection,
|
|
790
|
+
alias: { n: 0 }
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
function compile(expr, scope) {
|
|
794
|
+
switch (expr.kind) {
|
|
795
|
+
case "true": return "true";
|
|
796
|
+
case "false": return "false";
|
|
797
|
+
case "and": return expr.operands.length === 0 ? "true" : expr.operands.map((o) => `(${compile(o, scope)})`).join(" AND ");
|
|
798
|
+
case "or": return expr.operands.length === 0 ? "false" : expr.operands.map((o) => `(${compile(o, scope)})`).join(" OR ");
|
|
799
|
+
case "not":
|
|
800
|
+
if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
|
|
801
|
+
return `NOT (${compile(expr.operand, scope)})`;
|
|
802
|
+
case "compare": return `${operandToSql(expr.left, scope)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, scope)}`;
|
|
803
|
+
case "rolesOverlap": return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
|
|
804
|
+
case "rolesContain": return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
|
|
805
|
+
case "authenticated": return "auth.uid() IS NOT NULL";
|
|
806
|
+
case "existsIn": return compileExistsIn(expr, scope);
|
|
807
|
+
case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => col);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.
|
|
812
|
+
* Inside the subquery, `field` operands bind to the aliased join table and
|
|
813
|
+
* `outerField` operands bind to the (table-qualified) outer RLS row.
|
|
814
|
+
*/
|
|
815
|
+
function compileExistsIn(expr, scope) {
|
|
816
|
+
const join = scope.resolveCollection?.(expr.collection);
|
|
817
|
+
const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);
|
|
818
|
+
const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
|
|
819
|
+
const alias = `_ex${scope.alias.n++}`;
|
|
820
|
+
const outerTable = scope.outerCollection ? getTableName(scope.outerCollection) : void 0;
|
|
821
|
+
const outerSchema = schemaOf(scope.outerCollection) ?? "public";
|
|
822
|
+
const outerPrefix = outerTable ? `"${outerSchema}"."${outerTable}".` : "";
|
|
823
|
+
const innerScope = {
|
|
824
|
+
fieldCollection: join,
|
|
825
|
+
fieldPrefix: `"${alias}".`,
|
|
826
|
+
outerCollection: scope.outerCollection,
|
|
827
|
+
outerPrefix,
|
|
828
|
+
resolveCollection: scope.resolveCollection,
|
|
829
|
+
alias: scope.alias
|
|
830
|
+
};
|
|
831
|
+
return `EXISTS (SELECT 1 FROM "${joinSchema}"."${joinTable}" "${alias}" WHERE ${compile(expr.where, innerScope)})`;
|
|
832
|
+
}
|
|
833
|
+
var COMPARE_SQL = {
|
|
834
|
+
eq: "=",
|
|
835
|
+
neq: "!=",
|
|
836
|
+
lt: "<",
|
|
837
|
+
lte: "<=",
|
|
838
|
+
gt: ">",
|
|
839
|
+
gte: ">="
|
|
840
|
+
};
|
|
841
|
+
function operandToSql(operand, scope) {
|
|
842
|
+
switch (operand.kind) {
|
|
843
|
+
case "field": return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
|
|
844
|
+
case "outerField": return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
|
|
845
|
+
case "literal": return quoteLiteral(operand.value);
|
|
846
|
+
case "authUid": return "auth.uid()";
|
|
847
|
+
case "authRoles": return "string_to_array(auth.roles(), ',')";
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
function schemaOf(collection) {
|
|
851
|
+
return collection?.schema || void 0;
|
|
852
|
+
}
|
|
853
|
+
function resolveColumnName(propName, collection) {
|
|
854
|
+
const prop = collection?.properties?.[propName];
|
|
855
|
+
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
856
|
+
return toSnakeCase(propName);
|
|
857
|
+
}
|
|
858
|
+
function quoteLiteral(value) {
|
|
859
|
+
if (value === null) return "NULL";
|
|
860
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
861
|
+
if (typeof value === "number") return String(value);
|
|
862
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
863
|
+
}
|
|
864
|
+
/** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
|
|
865
|
+
function rolesArraySql(roles) {
|
|
866
|
+
return `ARRAY[${[...roles].sort().map((r) => `'${r}'`).join(",")}]`;
|
|
867
|
+
}
|
|
868
|
+
//#endregion
|
|
869
|
+
//#region src/util/policy/evaluatePolicy.ts
|
|
870
|
+
/**
|
|
871
|
+
* Evaluates a {@link PolicyExpression} against a user + row, using three-valued
|
|
872
|
+
* (Kleene) logic so that `"unknown"` sub-results propagate soundly.
|
|
873
|
+
*
|
|
874
|
+
* This is the JavaScript twin of {@link policyToPostgres}: both derive from the
|
|
875
|
+
* same expression, so the admin UI matches database enforcement by construction
|
|
876
|
+
* for every non-raw rule.
|
|
877
|
+
*/
|
|
878
|
+
function evaluatePolicy(expr, ctx) {
|
|
879
|
+
switch (expr.kind) {
|
|
880
|
+
case "true": return true;
|
|
881
|
+
case "false": return false;
|
|
882
|
+
case "and": return kleeneAnd$1(expr.operands.map((o) => evaluatePolicy(o, ctx)));
|
|
883
|
+
case "or": return kleeneOr(expr.operands.map((o) => evaluatePolicy(o, ctx)));
|
|
884
|
+
case "not": return kleeneNot(evaluatePolicy(expr.operand, ctx));
|
|
885
|
+
case "compare": return evaluateCompare(expr.op, expr.left, expr.right, ctx);
|
|
886
|
+
case "rolesOverlap": {
|
|
887
|
+
const userRoles = ctx.roles ?? [];
|
|
888
|
+
return expr.roles.some((r) => r === "public" || userRoles.includes(r));
|
|
687
889
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const splitByTopLevel = (str, delimiter) => {
|
|
692
|
-
const parts = [];
|
|
693
|
-
let current = "";
|
|
694
|
-
let openCount = 0;
|
|
695
|
-
let i = 0;
|
|
696
|
-
while (i < str.length) {
|
|
697
|
-
if (str[i] === "(") openCount++;
|
|
698
|
-
else if (str[i] === ")") openCount--;
|
|
699
|
-
if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
|
|
700
|
-
parts.push(current);
|
|
701
|
-
current = "";
|
|
702
|
-
i += delimiter.length;
|
|
703
|
-
} else {
|
|
704
|
-
current += str[i];
|
|
705
|
-
i++;
|
|
706
|
-
}
|
|
890
|
+
case "rolesContain": {
|
|
891
|
+
const userRoles = ctx.roles ?? [];
|
|
892
|
+
return expr.roles.every((r) => r === "public" || userRoles.includes(r));
|
|
707
893
|
}
|
|
708
|
-
|
|
709
|
-
return
|
|
710
|
-
|
|
711
|
-
const orParts = splitByTopLevel(cleanedSQL, " OR ");
|
|
712
|
-
if (orParts.length > 1) return orParts.some((part) => evaluateAST(part, auth, entity));
|
|
713
|
-
const andParts = splitByTopLevel(cleanedSQL, " AND ");
|
|
714
|
-
if (andParts.length > 1) return andParts.every((part) => evaluateAST(part, auth, entity));
|
|
715
|
-
const upperSQL = cleanedSQL.toUpperCase();
|
|
716
|
-
if (upperSQL.includes(" IN ") || upperSQL.includes(" EXISTS ")) return true;
|
|
717
|
-
const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
|
|
718
|
-
if (roleIntersectMatch && roleIntersectMatch[1]) {
|
|
719
|
-
const requiredRoles = roleIntersectMatch[1].split(",").map((r) => r.trim().replace(/'/g, ""));
|
|
720
|
-
const userRoles = auth.user?.roles || [];
|
|
721
|
-
return requiredRoles.some((r) => userRoles.includes(r));
|
|
722
|
-
}
|
|
723
|
-
const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
|
|
724
|
-
if (roleContainMatch && roleContainMatch[1]) {
|
|
725
|
-
const requiredRoles = roleContainMatch[1].split(",").map((r) => r.trim().replace(/'/g, ""));
|
|
726
|
-
const userRoles = auth.user?.roles || [];
|
|
727
|
-
return requiredRoles.every((r) => userRoles.includes(r));
|
|
728
|
-
}
|
|
729
|
-
const pattern1 = /* @__PURE__ */ new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
|
|
730
|
-
const pattern2 = /* @__PURE__ */ new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
|
|
731
|
-
const match1 = cleanedSQL.match(pattern1);
|
|
732
|
-
if (match1 && match1[1]) return entity.values[match1[1]] === auth.user?.uid;
|
|
733
|
-
const match2 = cleanedSQL.match(pattern2);
|
|
734
|
-
if (match2 && match2[1]) return entity.values[match2[1]] === auth.user?.uid;
|
|
735
|
-
const simpleEqualityMatch = cleanedSQL.match(/^\{?([\w_]+)\}?\s*(=|!=)\s*'([^']+)'$/i);
|
|
736
|
-
if (simpleEqualityMatch) {
|
|
737
|
-
const field = simpleEqualityMatch[1];
|
|
738
|
-
const operator = simpleEqualityMatch[2];
|
|
739
|
-
const value = simpleEqualityMatch[3];
|
|
740
|
-
const entityValue = entity.values[field];
|
|
741
|
-
if (operator === "=") return entityValue === value;
|
|
742
|
-
if (operator === "!=") return entityValue !== value;
|
|
894
|
+
case "authenticated": return ctx.uid != null;
|
|
895
|
+
case "existsIn": return "unknown";
|
|
896
|
+
case "raw": return "unknown";
|
|
743
897
|
}
|
|
898
|
+
}
|
|
899
|
+
function kleeneAnd$1(values) {
|
|
900
|
+
if (values.some((v) => v === false)) return false;
|
|
901
|
+
if (values.some((v) => v === "unknown")) return "unknown";
|
|
744
902
|
return true;
|
|
745
903
|
}
|
|
746
|
-
function
|
|
747
|
-
if (
|
|
748
|
-
if (
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
if (
|
|
904
|
+
function kleeneOr(values) {
|
|
905
|
+
if (values.some((v) => v === true)) return true;
|
|
906
|
+
if (values.some((v) => v === "unknown")) return "unknown";
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
function kleeneNot(value) {
|
|
910
|
+
if (value === "unknown") return "unknown";
|
|
911
|
+
return !value;
|
|
912
|
+
}
|
|
913
|
+
function resolveOperand(operand, ctx) {
|
|
914
|
+
switch (operand.kind) {
|
|
915
|
+
case "literal": return {
|
|
916
|
+
known: true,
|
|
917
|
+
value: operand.value
|
|
918
|
+
};
|
|
919
|
+
case "authUid": return {
|
|
920
|
+
known: true,
|
|
921
|
+
value: ctx.uid ?? null
|
|
922
|
+
};
|
|
923
|
+
case "authRoles": return {
|
|
924
|
+
known: true,
|
|
925
|
+
value: ctx.roles ?? []
|
|
926
|
+
};
|
|
927
|
+
case "field":
|
|
928
|
+
if (!ctx.entity) return { known: false };
|
|
929
|
+
return {
|
|
930
|
+
known: true,
|
|
931
|
+
value: ctx.entity.values[operand.name]
|
|
932
|
+
};
|
|
933
|
+
case "outerField": return { known: false };
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function evaluateCompare(op, left, right, ctx) {
|
|
937
|
+
const l = resolveOperand(left, ctx);
|
|
938
|
+
const r = resolveOperand(right, ctx);
|
|
939
|
+
if (!l.known || !r.known) return "unknown";
|
|
940
|
+
const a = l.value;
|
|
941
|
+
const b = r.value;
|
|
942
|
+
if (a === null || b === null) {
|
|
943
|
+
if (op === "eq") return false;
|
|
944
|
+
if (op === "neq") return true;
|
|
945
|
+
return "unknown";
|
|
946
|
+
}
|
|
947
|
+
if (op === "eq") return a === b;
|
|
948
|
+
if (op === "neq") return a !== b;
|
|
949
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
950
|
+
if (op === "lt") return a < b;
|
|
951
|
+
if (op === "lte") return a <= b;
|
|
952
|
+
if (op === "gt") return a > b;
|
|
953
|
+
if (op === "gte") return a >= b;
|
|
954
|
+
}
|
|
955
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
956
|
+
if (op === "lt") return a < b;
|
|
957
|
+
if (op === "lte") return a <= b;
|
|
958
|
+
if (op === "gt") return a > b;
|
|
959
|
+
if (op === "gte") return a >= b;
|
|
960
|
+
}
|
|
961
|
+
if (typeof a === "bigint" && typeof b === "bigint") {
|
|
962
|
+
if (op === "lt") return a < b;
|
|
963
|
+
if (op === "lte") return a <= b;
|
|
964
|
+
if (op === "gt") return a > b;
|
|
965
|
+
if (op === "gte") return a >= b;
|
|
966
|
+
}
|
|
967
|
+
return "unknown";
|
|
968
|
+
}
|
|
969
|
+
//#endregion
|
|
970
|
+
//#region src/util/permissions.ts
|
|
971
|
+
/** Combine clause results with AND under three-valued (Kleene) logic. */
|
|
972
|
+
function kleeneAnd(values) {
|
|
973
|
+
if (values.some((v) => v === false)) return false;
|
|
974
|
+
if (values.some((v) => v === "unknown")) return "unknown";
|
|
753
975
|
return true;
|
|
754
976
|
}
|
|
755
|
-
|
|
756
|
-
|
|
977
|
+
/** The operations a rule covers, mirroring the Postgres generator's resolution. */
|
|
978
|
+
function ruleOperations(rule) {
|
|
979
|
+
return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
980
|
+
}
|
|
981
|
+
function ruleApplies(rule, targetOperation) {
|
|
982
|
+
const ops = ruleOperations(rule);
|
|
983
|
+
return ops.includes(targetOperation) || ops.includes("all");
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Evaluate a single rule for one operation, returning a tri-state.
|
|
987
|
+
*
|
|
988
|
+
* A `null` clause (the rule contributes no condition for a required clause)
|
|
989
|
+
* denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
|
|
990
|
+
* in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
|
|
991
|
+
* INSERT/UPDATE; both must pass for UPDATE.
|
|
992
|
+
*/
|
|
993
|
+
function evaluateRuleForOperation(rule, ctx, targetOperation) {
|
|
994
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
995
|
+
const clause = (expr) => expr === null ? false : evaluatePolicy(expr, ctx);
|
|
996
|
+
const needsUsing = targetOperation !== "insert";
|
|
997
|
+
const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
|
|
998
|
+
const results = [];
|
|
999
|
+
if (needsUsing) results.push(clause(usingExpr));
|
|
1000
|
+
if (needsWithCheck) results.push(clause(withCheckExpr));
|
|
1001
|
+
return kleeneAnd(results);
|
|
1002
|
+
}
|
|
1003
|
+
function resolveTriState(value, onUnknown) {
|
|
1004
|
+
if (value === "unknown") return onUnknown === "allow";
|
|
1005
|
+
return value;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Decide whether an operation is permitted for a user on a (possibly null) row,
|
|
1009
|
+
* by evaluating the collection's security rules with the shared policy model —
|
|
1010
|
+
* the same model compiled to Postgres RLS DDL, so the decision matches database
|
|
1011
|
+
* enforcement for every non-raw rule.
|
|
1012
|
+
*
|
|
1013
|
+
* @param options.onUnknown how to treat rules that cannot be decided
|
|
1014
|
+
* client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
|
|
1015
|
+
* for optimistic UI gating; enforcement callers should pass `"deny"`.
|
|
1016
|
+
*/
|
|
1017
|
+
function checkOperation(collection, authContext, entity, targetOperation, options) {
|
|
1018
|
+
const onUnknown = options?.onUnknown ?? "allow";
|
|
1019
|
+
const securityRules = getDataSourceCapabilities(collection.engine).supportsRLS ? collection.securityRules : void 0;
|
|
757
1020
|
if (!securityRules || securityRules.length === 0) return true;
|
|
758
|
-
const applicableRules = securityRules.filter((r) => r
|
|
1021
|
+
const applicableRules = securityRules.filter((r) => ruleApplies(r, targetOperation));
|
|
759
1022
|
if (applicableRules.length === 0) return false;
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}
|
|
765
|
-
if (roleApplicableRules.length === 0) return false;
|
|
1023
|
+
const ctx = {
|
|
1024
|
+
uid: authContext.user?.uid,
|
|
1025
|
+
roles: authContext.user?.roles ?? [],
|
|
1026
|
+
entity
|
|
1027
|
+
};
|
|
766
1028
|
let grantedByPermissive = false;
|
|
767
1029
|
let deniedByRestrictive = false;
|
|
768
|
-
|
|
1030
|
+
let hasPermissive = false;
|
|
1031
|
+
for (const rule of applicableRules) {
|
|
769
1032
|
const mode = rule.mode || "permissive";
|
|
770
|
-
const passed =
|
|
771
|
-
if (mode === "restrictive"
|
|
772
|
-
|
|
773
|
-
|
|
1033
|
+
const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
|
|
1034
|
+
if (mode === "restrictive") {
|
|
1035
|
+
if (!passed) {
|
|
1036
|
+
deniedByRestrictive = true;
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
} else {
|
|
1040
|
+
hasPermissive = true;
|
|
1041
|
+
if (passed) grantedByPermissive = true;
|
|
774
1042
|
}
|
|
775
|
-
if (mode === "permissive" && passed) grantedByPermissive = true;
|
|
776
1043
|
}
|
|
777
1044
|
if (deniedByRestrictive) return false;
|
|
778
|
-
|
|
779
|
-
else return false;
|
|
1045
|
+
return hasPermissive ? grantedByPermissive : false;
|
|
780
1046
|
}
|
|
781
1047
|
function canReadCollection(collection, authContext) {
|
|
782
1048
|
return checkOperation(collection, authContext, null, "select");
|
|
@@ -807,7 +1073,7 @@ function getEntityImagePreviewPropertyKey(collection) {
|
|
|
807
1073
|
}
|
|
808
1074
|
for (const key in collection.properties) {
|
|
809
1075
|
const property = collection.properties[key];
|
|
810
|
-
if (property.type === "array" && property.of && !Array.isArray(property.of) && property.of.type === "string" && property.of.url === "image") return key;
|
|
1076
|
+
if (property.type === "array" && property.of && !Array.isArray(property.of) && property.of.type === "string" && property.of.ui?.url === "image") return key;
|
|
811
1077
|
}
|
|
812
1078
|
for (const key in collection.properties) {
|
|
813
1079
|
const property = collection.properties[key];
|
|
@@ -876,7 +1142,7 @@ function resolveCollectionPathIds(path, allCollections) {
|
|
|
876
1142
|
} else {
|
|
877
1143
|
entityId = remainingPath;
|
|
878
1144
|
remainingPath = "";
|
|
879
|
-
console.warn(`resolveCollectionPathIds: Path seems to end with
|
|
1145
|
+
console.warn(`resolveCollectionPathIds: Path seems to end with a entity ID "${entityId}" instead of a collection segment in original path "${path}". This might indicate an invalid input path.`);
|
|
880
1146
|
}
|
|
881
1147
|
resolvedPathParts.push(entityId);
|
|
882
1148
|
currentCollections = getSubcollections(foundCollection);
|
|
@@ -1032,79 +1298,60 @@ function getParentReferencesFromPath(props) {
|
|
|
1032
1298
|
//#endregion
|
|
1033
1299
|
//#region src/util/builders.ts
|
|
1034
1300
|
/**
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1301
|
+
* @deprecated Use {@link defineCollection} instead — it infers property
|
|
1302
|
+
* types automatically (autocomplete on `titleProperty`, `sort`,
|
|
1303
|
+
* `propertiesOrder`, callbacks) without manual generics.
|
|
1304
|
+
* `buildCollection` is kept for FireCMS migration compatibility and will
|
|
1305
|
+
* be removed before 1.0.
|
|
1306
|
+
*
|
|
1038
1307
|
* @group Builder
|
|
1039
1308
|
*/
|
|
1040
1309
|
function buildCollection(collection) {
|
|
1041
1310
|
return collection;
|
|
1042
1311
|
}
|
|
1043
1312
|
/**
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
* @param property
|
|
1047
|
-
* @group Builder
|
|
1048
|
-
*/
|
|
1049
|
-
function buildProperty(property) {
|
|
1050
|
-
return property;
|
|
1051
|
-
}
|
|
1052
|
-
/**
|
|
1053
|
-
* Identity function we use to defeat the type system of Typescript and preserve
|
|
1054
|
-
* the properties keys.
|
|
1055
|
-
* @param properties
|
|
1056
|
-
* @group Builder
|
|
1057
|
-
*/
|
|
1058
|
-
function buildProperties(properties) {
|
|
1059
|
-
return properties;
|
|
1060
|
-
}
|
|
1061
|
-
/**
|
|
1062
|
-
* Identity function we use to defeat the type system of Typescript and preserve
|
|
1063
|
-
* the properties keys.
|
|
1064
|
-
* @param propertiesOrBuilder
|
|
1065
|
-
* @group Builder
|
|
1066
|
-
*/
|
|
1067
|
-
function buildPropertiesOrBuilder(propertiesOrBuilder) {
|
|
1068
|
-
return propertiesOrBuilder;
|
|
1069
|
-
}
|
|
1070
|
-
/**
|
|
1071
|
-
* Identity function we use to defeat the type system of Typescript and preserve
|
|
1072
|
-
* the properties keys.
|
|
1073
|
-
* @param enumValues
|
|
1074
|
-
* @group Builder
|
|
1075
|
-
*/
|
|
1076
|
-
function buildEnum(enumValues) {
|
|
1077
|
-
return enumValues;
|
|
1078
|
-
}
|
|
1079
|
-
/**
|
|
1080
|
-
* Identity function we use to defeat the type system of Typescript and preserve
|
|
1081
|
-
* the properties keys.
|
|
1082
|
-
* @param enumValueConfig
|
|
1083
|
-
* @group Builder
|
|
1313
|
+
* Implementation — delegates to the correct overload at the type level.
|
|
1314
|
+
* At runtime this is a plain identity function.
|
|
1084
1315
|
*/
|
|
1085
|
-
function
|
|
1086
|
-
return
|
|
1316
|
+
function defineCollection(collection) {
|
|
1317
|
+
return collection;
|
|
1087
1318
|
}
|
|
1088
1319
|
/**
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1320
|
+
* @deprecated Use plain typed property objects with {@link defineCollection}
|
|
1321
|
+
* instead — `defineCollection` infers property types automatically, making
|
|
1322
|
+
* this wrapper unnecessary. `buildProperty` is kept for FireCMS migration
|
|
1323
|
+
* compatibility and will be removed before 1.0.
|
|
1324
|
+
*
|
|
1092
1325
|
* @group Builder
|
|
1093
1326
|
*/
|
|
1094
|
-
function
|
|
1095
|
-
return
|
|
1327
|
+
function buildProperty(property) {
|
|
1328
|
+
return property;
|
|
1096
1329
|
}
|
|
1330
|
+
//#endregion
|
|
1331
|
+
//#region src/util/storage.ts
|
|
1097
1332
|
/**
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1333
|
+
* Resolve the {@link StorageSource} to use for a property, given the key
|
|
1334
|
+
* referenced by `StorageConfig.storageSource`.
|
|
1335
|
+
*
|
|
1336
|
+
* Resolution priority:
|
|
1337
|
+
* 1. No `sourceKey` → the default source (backward compatible).
|
|
1338
|
+
* 2. An explicit {@link StorageSourceRegistry} (e.g. `client.storageRegistry`).
|
|
1339
|
+
* 3. A `sources` lookup map (e.g. the `StorageSourcesContext`).
|
|
1340
|
+
* 4. Fall back to the default source.
|
|
1341
|
+
*
|
|
1342
|
+
* Shared by the upload hook, the markdown editor, and the read-only previews
|
|
1343
|
+
* so the resolution logic lives in one place.
|
|
1344
|
+
*
|
|
1345
|
+
* @group Storage
|
|
1102
1346
|
*/
|
|
1103
|
-
function
|
|
1104
|
-
|
|
1347
|
+
function resolveStorageSource(params) {
|
|
1348
|
+
const { sourceKey, sources, registry, defaultSource } = params;
|
|
1349
|
+
if (!sourceKey) return defaultSource;
|
|
1350
|
+
if (registry) return registry.getOrDefault(sourceKey);
|
|
1351
|
+
const fromSources = sources?.[sourceKey];
|
|
1352
|
+
if (fromSources) return fromSources;
|
|
1353
|
+
return defaultSource;
|
|
1105
1354
|
}
|
|
1106
|
-
//#endregion
|
|
1107
|
-
//#region src/util/storage.ts
|
|
1108
1355
|
async function resolveStorageFilenameString({ input, storage, values, entityId, path, property, file, propertyKey }) {
|
|
1109
1356
|
let result;
|
|
1110
1357
|
if (typeof input === "function") {
|
|
@@ -1215,16 +1462,17 @@ async function processProperties(properties, values, previousValues, propsContex
|
|
|
1215
1462
|
}
|
|
1216
1463
|
/**
|
|
1217
1464
|
* Helper function to extract field-level PropertyCallbacks from a properties schema
|
|
1218
|
-
* and wrap them into an
|
|
1465
|
+
* and wrap them into an CollectionCallbacks object recursively.
|
|
1219
1466
|
*/
|
|
1220
1467
|
var buildPropertyCallbacks = (properties) => {
|
|
1221
1468
|
if (!properties) return void 0;
|
|
1222
1469
|
const propertyCallbacks = {};
|
|
1223
1470
|
if (hasPropertyCallbacks(properties, "afterRead")) propertyCallbacks.afterRead = async (props) => {
|
|
1224
|
-
const
|
|
1471
|
+
const row = props.row;
|
|
1472
|
+
const processedValues = await processProperties(properties, row, row, props, "afterRead");
|
|
1225
1473
|
return {
|
|
1226
|
-
...props.
|
|
1227
|
-
|
|
1474
|
+
...props.row,
|
|
1475
|
+
...processedValues
|
|
1228
1476
|
};
|
|
1229
1477
|
};
|
|
1230
1478
|
if (hasPropertyCallbacks(properties, "beforeSave")) propertyCallbacks.beforeSave = async (props) => {
|
|
@@ -1411,6 +1659,84 @@ function applyEnumConditions(enumValues, conditions, context) {
|
|
|
1411
1659
|
return result;
|
|
1412
1660
|
}
|
|
1413
1661
|
//#endregion
|
|
1662
|
+
//#region src/util/filter-operator-resolution.ts
|
|
1663
|
+
/**
|
|
1664
|
+
* Default operators offered per property type, before engine capabilities and
|
|
1665
|
+
* per-property narrowing are applied. These mirror what the built-in filter
|
|
1666
|
+
* fields can render.
|
|
1667
|
+
*/
|
|
1668
|
+
var COMPARISON_OPS = [
|
|
1669
|
+
"==",
|
|
1670
|
+
"!=",
|
|
1671
|
+
">",
|
|
1672
|
+
">=",
|
|
1673
|
+
"<",
|
|
1674
|
+
"<="
|
|
1675
|
+
];
|
|
1676
|
+
var NULL_CHECK_OPS = ["is-null", "is-not-null"];
|
|
1677
|
+
var MEMBERSHIP_OPS = ["in", "not-in"];
|
|
1678
|
+
var PATTERN_OPS = [
|
|
1679
|
+
"like",
|
|
1680
|
+
"ilike",
|
|
1681
|
+
"not-like",
|
|
1682
|
+
"not-ilike"
|
|
1683
|
+
];
|
|
1684
|
+
var DEFAULT_OPS_BY_TYPE = {
|
|
1685
|
+
string: [
|
|
1686
|
+
...COMPARISON_OPS,
|
|
1687
|
+
...MEMBERSHIP_OPS,
|
|
1688
|
+
...PATTERN_OPS,
|
|
1689
|
+
...NULL_CHECK_OPS
|
|
1690
|
+
],
|
|
1691
|
+
number: [
|
|
1692
|
+
...COMPARISON_OPS,
|
|
1693
|
+
...MEMBERSHIP_OPS,
|
|
1694
|
+
...NULL_CHECK_OPS
|
|
1695
|
+
],
|
|
1696
|
+
date: [...COMPARISON_OPS, ...NULL_CHECK_OPS],
|
|
1697
|
+
boolean: [
|
|
1698
|
+
"==",
|
|
1699
|
+
"!=",
|
|
1700
|
+
...NULL_CHECK_OPS
|
|
1701
|
+
],
|
|
1702
|
+
reference: [
|
|
1703
|
+
"==",
|
|
1704
|
+
"!=",
|
|
1705
|
+
...MEMBERSHIP_OPS,
|
|
1706
|
+
...NULL_CHECK_OPS
|
|
1707
|
+
],
|
|
1708
|
+
relation: [
|
|
1709
|
+
"==",
|
|
1710
|
+
"!=",
|
|
1711
|
+
...MEMBERSHIP_OPS,
|
|
1712
|
+
...NULL_CHECK_OPS
|
|
1713
|
+
]
|
|
1714
|
+
};
|
|
1715
|
+
/** Operators offered when the property is an *array of* a filterable type. */
|
|
1716
|
+
var ARRAY_OPS = ["array-contains", "array-contains-any"];
|
|
1717
|
+
/**
|
|
1718
|
+
* Resolve which filter operators the UI should offer for a property.
|
|
1719
|
+
*
|
|
1720
|
+
* The result is the **intersection** of three sets:
|
|
1721
|
+
* 1. what the engine can execute — {@link DataSourceCapabilities.filterOperators}
|
|
1722
|
+
* (e.g. Firestore cannot run the LIKE family);
|
|
1723
|
+
* 2. what makes sense for the property type (e.g. no `>` on booleans);
|
|
1724
|
+
* 3. the developer's optional narrowing — `property.ui.filterOperators`.
|
|
1725
|
+
*
|
|
1726
|
+
* Returns an empty array when the property is not filterable (either by
|
|
1727
|
+
* type, or because the developer disabled it with `filterOperators: []`).
|
|
1728
|
+
*
|
|
1729
|
+
* @group Models
|
|
1730
|
+
*/
|
|
1731
|
+
function resolveFilterOperators({ property, isArray, engine }) {
|
|
1732
|
+
const typeDefaults = isArray ? ARRAY_OPS : DEFAULT_OPS_BY_TYPE[property.type] ?? [];
|
|
1733
|
+
if (typeDefaults.length === 0) return [];
|
|
1734
|
+
const engineOps = new Set(getDataSourceCapabilities(engine).filterOperators ?? ALL_WHERE_FILTER_OPS);
|
|
1735
|
+
const narrowing = property.ui?.filterOperators;
|
|
1736
|
+
const narrowingSet = narrowing !== void 0 ? new Set(narrowing) : void 0;
|
|
1737
|
+
return typeDefaults.filter((op) => engineOps.has(op) && (narrowingSet === void 0 || narrowingSet.has(op)));
|
|
1738
|
+
}
|
|
1739
|
+
//#endregion
|
|
1414
1740
|
//#region src/data/resolveDataSource.ts
|
|
1415
1741
|
/**
|
|
1416
1742
|
* Build a keyed registry from a list of {@link DataSourceDefinition}s.
|
|
@@ -1427,13 +1753,13 @@ function createDataSourceRegistry(definitions) {
|
|
|
1427
1753
|
* editor's capability lookups.
|
|
1428
1754
|
*
|
|
1429
1755
|
* Resolution order:
|
|
1430
|
-
* 1. The routing **key** is `collection.dataSource`, else
|
|
1431
|
-
*
|
|
1756
|
+
* 1. The routing **key** is `collection.dataSource`, else
|
|
1757
|
+
* {@link DEFAULT_DATA_SOURCE_KEY}.
|
|
1432
1758
|
* 2. If a definition is registered for that key, it provides `engine`,
|
|
1433
1759
|
* `transport`, and `databaseId`.
|
|
1434
|
-
* 3. Otherwise values are synthesized
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1760
|
+
* 3. Otherwise values are synthesized: `engine` from `collection.engine`
|
|
1761
|
+
* (or the key, or `"postgres"`), `transport` defaults to `"server"`,
|
|
1762
|
+
* and `databaseId` from the collection.
|
|
1437
1763
|
*
|
|
1438
1764
|
* `capabilities` are always derived from the resolved `engine`, so two
|
|
1439
1765
|
* data sources sharing an engine share capabilities.
|
|
@@ -1442,9 +1768,9 @@ function createDataSourceRegistry(definitions) {
|
|
|
1442
1768
|
* @param registry optional registry of declared data sources
|
|
1443
1769
|
*/
|
|
1444
1770
|
function resolveDataSource(collection, registry) {
|
|
1445
|
-
const key = collection?.dataSource ??
|
|
1771
|
+
const key = collection?.dataSource ?? DEFAULT_DATA_SOURCE_KEY;
|
|
1446
1772
|
const def = registry?.[key];
|
|
1447
|
-
const engine = def?.engine ?? collection?.
|
|
1773
|
+
const engine = def?.engine ?? collection?.engine ?? (key !== DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
|
|
1448
1774
|
return {
|
|
1449
1775
|
key,
|
|
1450
1776
|
engine,
|
|
@@ -1459,9 +1785,28 @@ var CollectionRegistry = class {
|
|
|
1459
1785
|
/**
|
|
1460
1786
|
* Declared data sources, used during normalization to resolve each
|
|
1461
1787
|
* collection's engine (so `dataSource`-only collections get the right
|
|
1462
|
-
* capabilities). Empty by default
|
|
1788
|
+
* capabilities). Empty by default.
|
|
1463
1789
|
*/
|
|
1464
1790
|
dataSources = {};
|
|
1791
|
+
/**
|
|
1792
|
+
* Global lifecycle callbacks applied to every collection.
|
|
1793
|
+
* Runs on all data paths (REST, WebSocket, `rebase.data`).
|
|
1794
|
+
* Execution order: global → collection → property callbacks.
|
|
1795
|
+
*/
|
|
1796
|
+
_globalCallbacks;
|
|
1797
|
+
/**
|
|
1798
|
+
* Set global lifecycle callbacks that apply to every collection.
|
|
1799
|
+
* Typically called once during backend initialization.
|
|
1800
|
+
*/
|
|
1801
|
+
setGlobalCallbacks(callbacks) {
|
|
1802
|
+
this._globalCallbacks = callbacks;
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Get the currently registered global callbacks, if any.
|
|
1806
|
+
*/
|
|
1807
|
+
getGlobalCallbacks() {
|
|
1808
|
+
return this._globalCallbacks;
|
|
1809
|
+
}
|
|
1465
1810
|
collectionsByTableName = /* @__PURE__ */ new Map();
|
|
1466
1811
|
collectionsBySlug = /* @__PURE__ */ new Map();
|
|
1467
1812
|
rootCollections = [];
|
|
@@ -1470,7 +1815,7 @@ var CollectionRegistry = class {
|
|
|
1470
1815
|
rawCollectionsBySlug = /* @__PURE__ */ new Map();
|
|
1471
1816
|
rawRootCollections = [];
|
|
1472
1817
|
cachedRawCollectionsList = null;
|
|
1473
|
-
|
|
1818
|
+
lastRawInputEntity = null;
|
|
1474
1819
|
constructor(collections, dataSources) {
|
|
1475
1820
|
if (dataSources) this.dataSources = dataSources;
|
|
1476
1821
|
if (collections) this.registerMultiple(collections);
|
|
@@ -1500,12 +1845,12 @@ var CollectionRegistry = class {
|
|
|
1500
1845
|
* Returns true if the collections have changed, false otherwise.
|
|
1501
1846
|
*
|
|
1502
1847
|
* Idempotent: compares the raw input (before normalization) against a stored
|
|
1503
|
-
*
|
|
1848
|
+
* entity. Only re-normalizes and re-registers when the raw input actually changed.
|
|
1504
1849
|
* @param collections
|
|
1505
1850
|
*/
|
|
1506
1851
|
registerMultiple(collections) {
|
|
1507
|
-
const
|
|
1508
|
-
if (this.
|
|
1852
|
+
const rawEntity = collections.map((c) => removeFunctions(c));
|
|
1853
|
+
if (this.lastRawInputEntity && deepEqual(this.lastRawInputEntity, rawEntity)) return false;
|
|
1509
1854
|
this.reset();
|
|
1510
1855
|
collections.forEach((c) => {
|
|
1511
1856
|
if (c.slug) this.collectionsBySlug.set(c.slug, c);
|
|
@@ -1529,7 +1874,7 @@ var CollectionRegistry = class {
|
|
|
1529
1874
|
this._registerRecursively(this.normalizeCollection({ ...subCollection }), deepClone(subCollection));
|
|
1530
1875
|
});
|
|
1531
1876
|
});
|
|
1532
|
-
this.
|
|
1877
|
+
this.lastRawInputEntity = rawEntity;
|
|
1533
1878
|
return true;
|
|
1534
1879
|
}
|
|
1535
1880
|
register(collection, rawCollection) {
|
|
@@ -1553,13 +1898,14 @@ var CollectionRegistry = class {
|
|
|
1553
1898
|
}
|
|
1554
1899
|
normalizeCollection(collection) {
|
|
1555
1900
|
const result = { ...collection };
|
|
1556
|
-
|
|
1557
|
-
const
|
|
1558
|
-
if (
|
|
1901
|
+
{
|
|
1902
|
+
const resolved = resolveDataSource(result, this.dataSources);
|
|
1903
|
+
if (!result.dataSource) result.dataSource = resolved.key;
|
|
1904
|
+
if (!result.engine) result.engine = resolved.engine;
|
|
1559
1905
|
}
|
|
1560
1906
|
const extractedRelations = this.extractRelationsFromProperties(result.properties);
|
|
1561
1907
|
const relResult = result;
|
|
1562
|
-
const manualRelations = getDataSourceCapabilities(result.
|
|
1908
|
+
const manualRelations = getDataSourceCapabilities(result.engine).supportsRelations ? relResult.relations ?? [] : [];
|
|
1563
1909
|
const mergedRelationsRaw = [...extractedRelations];
|
|
1564
1910
|
for (const manual of manualRelations) {
|
|
1565
1911
|
const name = manual.relationName;
|
|
@@ -1574,7 +1920,7 @@ var CollectionRegistry = class {
|
|
|
1574
1920
|
}
|
|
1575
1921
|
}
|
|
1576
1922
|
let mergedRelations = mergedRelationsRaw;
|
|
1577
|
-
if (getDataSourceCapabilities(result.
|
|
1923
|
+
if (getDataSourceCapabilities(result.engine).supportsRelations) {
|
|
1578
1924
|
mergedRelations = mergedRelationsRaw.map((r) => {
|
|
1579
1925
|
try {
|
|
1580
1926
|
return sanitizeRelation(r, result, (slug) => this.get(slug));
|
|
@@ -1586,8 +1932,10 @@ var CollectionRegistry = class {
|
|
|
1586
1932
|
}
|
|
1587
1933
|
result.properties = this.normalizeProperties(result.properties, mergedRelations);
|
|
1588
1934
|
if (!result.childCollections) {
|
|
1589
|
-
|
|
1590
|
-
|
|
1935
|
+
const capabilities = getDataSourceCapabilities(result.engine);
|
|
1936
|
+
const declaredSubcollections = getDeclaredSubcollections(result);
|
|
1937
|
+
if (capabilities.supportsSubcollections && declaredSubcollections) result.childCollections = declaredSubcollections;
|
|
1938
|
+
else if (capabilities.supportsRelations && relResult.relations) {
|
|
1591
1939
|
const manyRelations = relResult.relations.filter((r) => r.cardinality === "many");
|
|
1592
1940
|
if (manyRelations.length > 0) result.childCollections = () => manyRelations.map((r) => {
|
|
1593
1941
|
const target = r.target();
|
|
@@ -1689,7 +2037,7 @@ var CollectionRegistry = class {
|
|
|
1689
2037
|
if (!currentCollection) throw new Error(`Root collection not found: ${rootCollectionPath}`);
|
|
1690
2038
|
for (let i = 2; i < pathSegments.length; i += 2) {
|
|
1691
2039
|
const relationKey = pathSegments[i];
|
|
1692
|
-
if (!getDataSourceCapabilities(currentCollection.
|
|
2040
|
+
if (!getDataSourceCapabilities(currentCollection.engine).supportsRelations) throw new Error(`Relation path navigation requires a collection that supports relations, but '${currentCollection.slug}' uses engine '${currentCollection.engine}'`);
|
|
1693
2041
|
const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
|
|
1694
2042
|
if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
|
|
1695
2043
|
const target = relation.target();
|
|
@@ -1750,7 +2098,7 @@ var CollectionRegistry = class {
|
|
|
1750
2098
|
* Slug-based dedup (Map keyed by slug, last-write-wins) lets developers
|
|
1751
2099
|
* override by defining their own collection with `slug: "users"`.
|
|
1752
2100
|
*/
|
|
1753
|
-
var defaultUsersCollection = {
|
|
2101
|
+
var defaultUsersCollection = defineCollection({
|
|
1754
2102
|
name: "Users",
|
|
1755
2103
|
singularName: "User",
|
|
1756
2104
|
slug: "users",
|
|
@@ -1798,7 +2146,7 @@ var defaultUsersCollection = {
|
|
|
1798
2146
|
name: "Photo URL",
|
|
1799
2147
|
type: "string",
|
|
1800
2148
|
columnName: "photo_url",
|
|
1801
|
-
url: "image"
|
|
2149
|
+
ui: { url: "image" }
|
|
1802
2150
|
},
|
|
1803
2151
|
roles: {
|
|
1804
2152
|
name: "Roles",
|
|
@@ -1893,7 +2241,7 @@ var defaultUsersCollection = {
|
|
|
1893
2241
|
"roles",
|
|
1894
2242
|
"createdAt"
|
|
1895
2243
|
]
|
|
1896
|
-
};
|
|
2244
|
+
});
|
|
1897
2245
|
//#endregion
|
|
1898
2246
|
//#region src/data/query_builder.ts
|
|
1899
2247
|
function or(...conditions) {
|
|
@@ -1945,8 +2293,8 @@ var QueryBuilder = class {
|
|
|
1945
2293
|
* @example
|
|
1946
2294
|
* client.collection('users').orderBy('createdAt', 'desc').find()
|
|
1947
2295
|
*/
|
|
1948
|
-
orderBy(column,
|
|
1949
|
-
this.params.orderBy =
|
|
2296
|
+
orderBy(column, direction = "asc") {
|
|
2297
|
+
this.params.orderBy = [column, direction];
|
|
1950
2298
|
return this;
|
|
1951
2299
|
}
|
|
1952
2300
|
/**
|
|
@@ -2001,166 +2349,369 @@ var QueryBuilder = class {
|
|
|
2001
2349
|
}
|
|
2002
2350
|
};
|
|
2003
2351
|
//#endregion
|
|
2004
|
-
//#region src/data/
|
|
2352
|
+
//#region src/data/filter-dialect.ts
|
|
2005
2353
|
/**
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2009
|
-
* -
|
|
2010
|
-
*
|
|
2011
|
-
*
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2354
|
+
* REST wire-format adapter for the unified filter system.
|
|
2355
|
+
*
|
|
2356
|
+
* This module is the ONLY code in the entire codebase that knows about
|
|
2357
|
+
* PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
|
|
2358
|
+
* Everything else speaks `FilterValues` exclusively.
|
|
2359
|
+
*
|
|
2360
|
+
* Wire-format values are always strings — the wire format carries no type
|
|
2361
|
+
* metadata, so type coercion is the responsibility of the server-side data
|
|
2362
|
+
* driver which has access to the collection schema.
|
|
2363
|
+
*
|
|
2364
|
+
* Commas inside list values are backslash-escaped (`\,`), and literal
|
|
2365
|
+
* backslashes are escaped as `\\`.
|
|
2366
|
+
*
|
|
2367
|
+
* @module
|
|
2368
|
+
*/
|
|
2369
|
+
/**
|
|
2370
|
+
* Serialize a JS value to its querystring representation.
|
|
2371
|
+
* `null` is serialized as the literal string `"null"`.
|
|
2372
|
+
*/
|
|
2373
|
+
function stringifyValue(value) {
|
|
2374
|
+
if (value === null) return "null";
|
|
2375
|
+
return String(value);
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* Escape a single list item for the wire format.
|
|
2379
|
+
* `\` → `\\`, `,` → `\,`
|
|
2380
|
+
*/
|
|
2381
|
+
function escapeListItem(value) {
|
|
2382
|
+
return value.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Unescape a single list item from the wire format.
|
|
2386
|
+
* `\\` → `\`, `\,` → `,`
|
|
2387
|
+
*/
|
|
2388
|
+
function unescapeListItem(value) {
|
|
2389
|
+
let result = "";
|
|
2390
|
+
for (let i = 0; i < value.length; i++) if (value[i] === "\\" && i + 1 < value.length) {
|
|
2391
|
+
result += value[i + 1];
|
|
2392
|
+
i++;
|
|
2393
|
+
} else result += value[i];
|
|
2394
|
+
return result;
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* Split a parenthesized list string on unescaped commas.
|
|
2398
|
+
* Input is the content between `(` and `)`.
|
|
2399
|
+
*
|
|
2400
|
+
* @example
|
|
2401
|
+
* splitListItems("admin,editor") // ["admin", "editor"]
|
|
2402
|
+
* splitListItems("hello\\, world,foo") // ["hello, world", "foo"]
|
|
2403
|
+
*/
|
|
2404
|
+
function splitListItems(inner) {
|
|
2405
|
+
const items = [];
|
|
2406
|
+
let current = "";
|
|
2407
|
+
for (let i = 0; i < inner.length; i++) if (inner[i] === "\\" && i + 1 < inner.length) {
|
|
2408
|
+
current += inner[i] + inner[i + 1];
|
|
2409
|
+
i++;
|
|
2410
|
+
} else if (inner[i] === ",") {
|
|
2411
|
+
items.push(unescapeListItem(current));
|
|
2412
|
+
current = "";
|
|
2413
|
+
} else current += inner[i];
|
|
2414
|
+
items.push(unescapeListItem(current));
|
|
2415
|
+
return items;
|
|
2416
|
+
}
|
|
2417
|
+
var REST_OP_LOOKUP = REST_TO_CANONICAL;
|
|
2418
|
+
var CANONICAL_OP_LOOKUP = CANONICAL_TO_REST;
|
|
2419
|
+
/**
|
|
2420
|
+
* Serialize a single canonical condition tuple to a PostgREST dot-string.
|
|
2421
|
+
*
|
|
2422
|
+
* Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.
|
|
2423
|
+
*
|
|
2424
|
+
* @example
|
|
2425
|
+
* serializeTuple(["==", "active"]) // "eq.active"
|
|
2426
|
+
* serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
|
|
2427
|
+
* serializeTuple([">=", 18]) // "gte.18"
|
|
2428
|
+
*/
|
|
2429
|
+
function serializeTuple(tuple) {
|
|
2430
|
+
if (!Array.isArray(tuple) || tuple.length !== 2) throw new TypeError(`serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`);
|
|
2431
|
+
const [op, value] = tuple;
|
|
2432
|
+
if (typeof op !== "string") throw new TypeError(`serializeTuple: operator must be a string, got ${typeof op}`);
|
|
2433
|
+
const restOp = CANONICAL_OP_LOOKUP[op];
|
|
2434
|
+
if (!restOp) throw new TypeError(`serializeTuple: unknown operator "${op}". Valid operators: ${Object.keys(CANONICAL_TO_REST).join(", ")}`);
|
|
2435
|
+
if (Array.isArray(value)) return `${restOp}.(${value.map((v) => escapeListItem(stringifyValue(v))).join(",")})`;
|
|
2436
|
+
return `${restOp}.${stringifyValue(value)}`;
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2439
|
+
* Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style
|
|
2440
|
+
* querystring record.
|
|
2441
|
+
*
|
|
2442
|
+
* - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.
|
|
2443
|
+
* - Pre-serialized PostgREST strings (e.g. `"eq.published"`) are passed through.
|
|
2444
|
+
* - Single conditions produce a string value.
|
|
2445
|
+
* - Multiple conditions on the same field produce a string array (repeated params).
|
|
2446
|
+
*
|
|
2447
|
+
* @example
|
|
2448
|
+
* serializeFilter({ status: ["==", "active"] })
|
|
2449
|
+
* // → { status: "eq.active" }
|
|
2450
|
+
*
|
|
2451
|
+
* serializeFilter({ age: [[">=", 18], ["<", 65]] })
|
|
2452
|
+
* // → { age: ["gte.18", "lt.65"] }
|
|
2453
|
+
*
|
|
2454
|
+
* // Pre-serialized strings pass through unchanged:
|
|
2455
|
+
* serializeFilter({ status: "eq.published" })
|
|
2456
|
+
* // → { status: "eq.published" }
|
|
2457
|
+
*/
|
|
2458
|
+
function serializeFilter(filter) {
|
|
2459
|
+
const result = {};
|
|
2460
|
+
for (const [field, condition] of Object.entries(filter)) {
|
|
2461
|
+
if (condition === void 0) continue;
|
|
2462
|
+
if (typeof condition === "string") {
|
|
2463
|
+
result[field] = condition;
|
|
2050
2464
|
continue;
|
|
2051
2465
|
}
|
|
2052
|
-
if (Array.isArray(
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2466
|
+
if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) result[field] = condition.map(serializeTuple);
|
|
2467
|
+
else result[field] = serializeTuple(condition);
|
|
2468
|
+
}
|
|
2469
|
+
return result;
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
|
|
2473
|
+
*
|
|
2474
|
+
* All values are returned as strings — the wire format carries no type
|
|
2475
|
+
* metadata, so coercion is the data driver's responsibility.
|
|
2476
|
+
*
|
|
2477
|
+
* If the string doesn't match a known operator prefix, it falls back to
|
|
2478
|
+
* `["==", originalString]` (treating the whole string as an equality value).
|
|
2479
|
+
* This intentional defense handles values like `"user@host.com"` or
|
|
2480
|
+
* `"1.2.3"` that happen to contain dots.
|
|
2481
|
+
*/
|
|
2482
|
+
function deserializeSingle(raw) {
|
|
2483
|
+
const dotIndex = raw.indexOf(".");
|
|
2484
|
+
if (dotIndex === -1) return ["==", raw];
|
|
2485
|
+
const prefix = raw.substring(0, dotIndex);
|
|
2486
|
+
const rest = raw.substring(dotIndex + 1);
|
|
2487
|
+
const canonicalOp = REST_OP_LOOKUP[prefix];
|
|
2488
|
+
if (!canonicalOp) return ["==", raw];
|
|
2489
|
+
if (NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
|
|
2490
|
+
if (rest.startsWith("(") && rest.endsWith(")")) return [canonicalOp, splitListItems(rest.slice(1, -1))];
|
|
2491
|
+
return [canonicalOp, rest];
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* Convert a PostgREST-style querystring record to `FilterValues`.
|
|
2495
|
+
*
|
|
2496
|
+
* - String values are parsed as single conditions.
|
|
2497
|
+
* - String arrays (repeated query params) become multiple conditions on the same field.
|
|
2498
|
+
*
|
|
2499
|
+
* @example
|
|
2500
|
+
* deserializeFilter({ status: "eq.active" })
|
|
2501
|
+
* // → { status: ["==", "active"] }
|
|
2502
|
+
*
|
|
2503
|
+
* deserializeFilter({ age: ["gte.18", "lt.65"] })
|
|
2504
|
+
* // → { age: [[">=", "18"], ["<", "65"]] }
|
|
2505
|
+
*/
|
|
2506
|
+
function deserializeFilter(query) {
|
|
2507
|
+
const result = {};
|
|
2508
|
+
for (const [field, raw] of Object.entries(query)) {
|
|
2509
|
+
if (raw === void 0) continue;
|
|
2510
|
+
if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
|
|
2511
|
+
result[field] = raw;
|
|
2057
2512
|
continue;
|
|
2058
2513
|
}
|
|
2059
|
-
if (
|
|
2060
|
-
|
|
2061
|
-
if (
|
|
2062
|
-
|
|
2514
|
+
if (Array.isArray(raw)) {
|
|
2515
|
+
if (raw.length === 0) continue;
|
|
2516
|
+
if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
|
|
2517
|
+
result[field] = raw;
|
|
2063
2518
|
continue;
|
|
2064
2519
|
}
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2520
|
+
if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
|
|
2521
|
+
else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
|
|
2522
|
+
else result[field] = ["in", raw];
|
|
2523
|
+
} else if (typeof raw === "string") result[field] = deserializeSingle(raw);
|
|
2524
|
+
else result[field] = ["==", raw];
|
|
2525
|
+
}
|
|
2526
|
+
return result;
|
|
2527
|
+
}
|
|
2528
|
+
/**
|
|
2529
|
+
* Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
|
|
2530
|
+
*
|
|
2531
|
+
* @example
|
|
2532
|
+
* serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
|
|
2533
|
+
* // → "status.eq.active"
|
|
2534
|
+
*
|
|
2535
|
+
* serializeLogicalCondition({ type: "or", conditions: [...] })
|
|
2536
|
+
* // → "or(status.eq.active,status.eq.pending)"
|
|
2537
|
+
*/
|
|
2538
|
+
function serializeLogicalCondition(cond) {
|
|
2539
|
+
if ("type" in cond) {
|
|
2540
|
+
const inner = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
|
|
2541
|
+
return `${cond.type}(${inner})`;
|
|
2542
|
+
}
|
|
2543
|
+
const restOp = CANONICAL_OP_LOOKUP[cond.operator] ?? "eq";
|
|
2544
|
+
if (Array.isArray(cond.value)) {
|
|
2545
|
+
const items = cond.value.map((v) => escapeListItem(stringifyValue(v))).join(",");
|
|
2546
|
+
return `${cond.column}.${restOp}.(${items})`;
|
|
2547
|
+
}
|
|
2548
|
+
return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Parse a logical condition wire-format string back into a
|
|
2552
|
+
* `LogicalCondition` or `FilterCondition`.
|
|
2553
|
+
*
|
|
2554
|
+
* @example
|
|
2555
|
+
* deserializeLogicalCondition("status.eq.active")
|
|
2556
|
+
* // → { column: "status", operator: "==", value: "active" }
|
|
2557
|
+
*
|
|
2558
|
+
* deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
|
|
2559
|
+
* // → { type: "or", conditions: [...] }
|
|
2560
|
+
*/
|
|
2561
|
+
function deserializeLogicalCondition(str) {
|
|
2562
|
+
const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
|
|
2563
|
+
if (logicalMatch) {
|
|
2564
|
+
const type = logicalMatch[1];
|
|
2565
|
+
const innerStr = logicalMatch[2];
|
|
2566
|
+
const conditions = [];
|
|
2567
|
+
let depth = 0;
|
|
2568
|
+
let start = 0;
|
|
2569
|
+
for (let i = 0; i < innerStr.length; i++) if (innerStr[i] === "(") depth++;
|
|
2570
|
+
else if (innerStr[i] === ")") depth--;
|
|
2571
|
+
else if (innerStr[i] === "," && depth === 0) {
|
|
2572
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
|
|
2573
|
+
start = i + 1;
|
|
2074
2574
|
}
|
|
2575
|
+
conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
|
|
2576
|
+
return {
|
|
2577
|
+
type,
|
|
2578
|
+
conditions
|
|
2579
|
+
};
|
|
2075
2580
|
}
|
|
2076
|
-
|
|
2581
|
+
const firstDot = str.indexOf(".");
|
|
2582
|
+
if (firstDot === -1) return {
|
|
2583
|
+
column: str,
|
|
2584
|
+
operator: "==",
|
|
2585
|
+
value: true
|
|
2586
|
+
};
|
|
2587
|
+
const column = str.substring(0, firstDot);
|
|
2588
|
+
const rest = str.substring(firstDot + 1);
|
|
2589
|
+
const secondDot = rest.indexOf(".");
|
|
2590
|
+
if (secondDot === -1) return {
|
|
2591
|
+
column,
|
|
2592
|
+
operator: "==",
|
|
2593
|
+
value: rest
|
|
2594
|
+
};
|
|
2595
|
+
const opStr = rest.substring(0, secondDot);
|
|
2596
|
+
const valueStr = rest.substring(secondDot + 1);
|
|
2597
|
+
const operator = toCanonicalOp(opStr) ?? "==";
|
|
2598
|
+
if (valueStr.startsWith("(") && valueStr.endsWith(")")) return {
|
|
2599
|
+
column,
|
|
2600
|
+
operator,
|
|
2601
|
+
value: splitListItems(valueStr.slice(1, -1))
|
|
2602
|
+
};
|
|
2603
|
+
return {
|
|
2604
|
+
column,
|
|
2605
|
+
operator,
|
|
2606
|
+
value: valueStr
|
|
2607
|
+
};
|
|
2077
2608
|
}
|
|
2609
|
+
//#endregion
|
|
2610
|
+
//#region src/data/buildRebaseData.ts
|
|
2078
2611
|
/**
|
|
2079
|
-
*
|
|
2612
|
+
* Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
|
|
2613
|
+
* Mirrors the client SDK's rowToEntity conversion.
|
|
2080
2614
|
*/
|
|
2081
|
-
function
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2615
|
+
function rowToEntity(row, slug) {
|
|
2616
|
+
return {
|
|
2617
|
+
id: row.id,
|
|
2618
|
+
path: slug,
|
|
2619
|
+
values: row
|
|
2620
|
+
};
|
|
2085
2621
|
}
|
|
2086
2622
|
function createDriverAccessor(driver, slug) {
|
|
2087
2623
|
const accessor = {
|
|
2088
2624
|
async find(params) {
|
|
2089
|
-
const
|
|
2090
|
-
const
|
|
2625
|
+
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
2626
|
+
const limit = params?.limit ?? 20;
|
|
2627
|
+
const offset = params?.offset ?? 0;
|
|
2628
|
+
const fetchService = driver.restFetchService;
|
|
2629
|
+
const rows = fetchService && params?.include && params.include.length > 0 ? await fetchService.fetchCollectionForRest(slug, {
|
|
2630
|
+
filter,
|
|
2631
|
+
limit: params?.limit,
|
|
2632
|
+
offset: params?.offset,
|
|
2633
|
+
orderBy: params?.orderBy?.[0],
|
|
2634
|
+
order: params?.orderBy?.[1],
|
|
2635
|
+
searchString: params?.searchString
|
|
2636
|
+
}, params.include) : await driver.fetchCollection({
|
|
2091
2637
|
path: slug,
|
|
2092
2638
|
limit: params?.limit,
|
|
2093
2639
|
offset: params?.offset,
|
|
2094
|
-
filter
|
|
2095
|
-
orderBy:
|
|
2096
|
-
order:
|
|
2640
|
+
filter,
|
|
2641
|
+
orderBy: params?.orderBy?.[0],
|
|
2642
|
+
order: params?.orderBy?.[1],
|
|
2097
2643
|
searchString: params?.searchString
|
|
2098
2644
|
});
|
|
2099
|
-
|
|
2100
|
-
|
|
2645
|
+
let total = rows.length + offset;
|
|
2646
|
+
let hasMore = rows.length >= limit;
|
|
2647
|
+
if (driver.count) {
|
|
2648
|
+
total = await driver.count({
|
|
2649
|
+
path: slug,
|
|
2650
|
+
filter
|
|
2651
|
+
});
|
|
2652
|
+
hasMore = offset + rows.length < total;
|
|
2653
|
+
}
|
|
2101
2654
|
return {
|
|
2102
|
-
data:
|
|
2655
|
+
data: rows.map((row) => rowToEntity(row, slug)),
|
|
2103
2656
|
meta: {
|
|
2104
|
-
total
|
|
2657
|
+
total,
|
|
2105
2658
|
limit,
|
|
2106
2659
|
offset,
|
|
2107
|
-
hasMore
|
|
2660
|
+
hasMore
|
|
2108
2661
|
}
|
|
2109
2662
|
};
|
|
2110
2663
|
},
|
|
2111
2664
|
async findById(id) {
|
|
2112
|
-
|
|
2665
|
+
const row = await driver.fetchOne({
|
|
2113
2666
|
path: slug,
|
|
2114
|
-
|
|
2667
|
+
id
|
|
2115
2668
|
});
|
|
2669
|
+
return row ? rowToEntity(row, slug) : void 0;
|
|
2116
2670
|
},
|
|
2117
2671
|
async create(data, id) {
|
|
2118
|
-
return driver.
|
|
2672
|
+
return rowToEntity(await driver.save({
|
|
2119
2673
|
path: slug,
|
|
2120
2674
|
values: data,
|
|
2121
|
-
|
|
2675
|
+
id,
|
|
2122
2676
|
status: "new"
|
|
2123
|
-
});
|
|
2677
|
+
}), slug);
|
|
2124
2678
|
},
|
|
2125
2679
|
async update(id, data) {
|
|
2126
|
-
return driver.
|
|
2680
|
+
return rowToEntity(await driver.save({
|
|
2127
2681
|
path: slug,
|
|
2128
2682
|
values: data,
|
|
2129
|
-
|
|
2683
|
+
id,
|
|
2130
2684
|
status: "existing"
|
|
2131
|
-
});
|
|
2685
|
+
}), slug);
|
|
2132
2686
|
},
|
|
2133
2687
|
async delete(id) {
|
|
2134
|
-
return driver.
|
|
2688
|
+
return driver.delete({ row: {
|
|
2135
2689
|
id,
|
|
2136
2690
|
path: slug,
|
|
2137
2691
|
values: {}
|
|
2138
2692
|
} });
|
|
2139
2693
|
},
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
count: driver.countEntities ? async (params) => {
|
|
2144
|
-
return driver.countEntities({
|
|
2694
|
+
count: driver.count ? async (params) => {
|
|
2695
|
+
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
2696
|
+
return driver.count({
|
|
2145
2697
|
path: slug,
|
|
2146
|
-
filter
|
|
2698
|
+
filter
|
|
2147
2699
|
});
|
|
2148
2700
|
} : void 0,
|
|
2149
2701
|
listen: driver.listenCollection ? (params, onUpdate, onError) => {
|
|
2150
|
-
const orderParsed = parseOrderBy(params?.orderBy);
|
|
2151
2702
|
const limit = params?.limit ?? 20;
|
|
2152
2703
|
const offset = params?.offset ?? 0;
|
|
2153
2704
|
return driver.listenCollection({
|
|
2154
2705
|
path: slug,
|
|
2155
2706
|
limit: params?.limit,
|
|
2156
2707
|
offset: params?.offset,
|
|
2157
|
-
filter:
|
|
2158
|
-
orderBy:
|
|
2159
|
-
order:
|
|
2708
|
+
filter: params?.where,
|
|
2709
|
+
orderBy: params?.orderBy?.[0],
|
|
2710
|
+
order: params?.orderBy?.[1],
|
|
2160
2711
|
searchString: params?.searchString,
|
|
2161
2712
|
onUpdate: (entities) => {
|
|
2162
2713
|
onUpdate({
|
|
2163
|
-
data: entities,
|
|
2714
|
+
data: entities.map((row) => rowToEntity(row, slug)),
|
|
2164
2715
|
meta: {
|
|
2165
2716
|
total: entities.length,
|
|
2166
2717
|
limit,
|
|
@@ -2172,11 +2723,11 @@ function createDriverAccessor(driver, slug) {
|
|
|
2172
2723
|
onError
|
|
2173
2724
|
});
|
|
2174
2725
|
} : void 0,
|
|
2175
|
-
listenById: driver.
|
|
2176
|
-
return driver.
|
|
2726
|
+
listenById: driver.listenOne ? (id, onUpdate, onError) => {
|
|
2727
|
+
return driver.listenOne({
|
|
2177
2728
|
path: slug,
|
|
2178
|
-
|
|
2179
|
-
onUpdate: (entity) => onUpdate(entity
|
|
2729
|
+
id,
|
|
2730
|
+
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
|
|
2180
2731
|
onError
|
|
2181
2732
|
});
|
|
2182
2733
|
} : void 0,
|
|
@@ -2213,7 +2764,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
2213
2764
|
* @example
|
|
2214
2765
|
* const data = buildRebaseData(driver);
|
|
2215
2766
|
* await data.products.create({ name: "Camera", price: 299 });
|
|
2216
|
-
* const { data: items } = await data.products.find({ where: { status: "
|
|
2767
|
+
* const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
|
|
2217
2768
|
*/
|
|
2218
2769
|
function buildRebaseData(driver) {
|
|
2219
2770
|
const cache = /* @__PURE__ */ new Map();
|
|
@@ -2232,6 +2783,230 @@ function buildRebaseData(driver) {
|
|
|
2232
2783
|
return getAccessor(toSnakeCase(prop));
|
|
2233
2784
|
} });
|
|
2234
2785
|
}
|
|
2786
|
+
/**
|
|
2787
|
+
* Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
|
|
2788
|
+
* (id included) under `.values`, so this is just that payload.
|
|
2789
|
+
*/
|
|
2790
|
+
function entityToRow(entity) {
|
|
2791
|
+
return entity.values;
|
|
2792
|
+
}
|
|
2793
|
+
/**
|
|
2794
|
+
* Fluent query builder for the flat SDK data layer. Mirrors {@link QueryBuilder}
|
|
2795
|
+
* but resolves to `FindResult<M>` (flat rows) instead of Entity-wrapped
|
|
2796
|
+
* `FindResponse<M>`.
|
|
2797
|
+
*/
|
|
2798
|
+
var SdkQueryBuilder = class {
|
|
2799
|
+
client;
|
|
2800
|
+
params = { where: {} };
|
|
2801
|
+
constructor(client) {
|
|
2802
|
+
this.client = client;
|
|
2803
|
+
}
|
|
2804
|
+
where(columnOrCondition, operator, value) {
|
|
2805
|
+
if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
|
|
2806
|
+
this.params.logical = columnOrCondition;
|
|
2807
|
+
return this;
|
|
2808
|
+
}
|
|
2809
|
+
if (!this.params.where) this.params.where = {};
|
|
2810
|
+
const column = columnOrCondition;
|
|
2811
|
+
const condition = [operator, value];
|
|
2812
|
+
const existing = this.params.where[column];
|
|
2813
|
+
if (existing === void 0) this.params.where[column] = condition;
|
|
2814
|
+
else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) this.params.where[column].push(condition);
|
|
2815
|
+
else {
|
|
2816
|
+
let firstCondition;
|
|
2817
|
+
if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") firstCondition = existing;
|
|
2818
|
+
else firstCondition = ["==", existing];
|
|
2819
|
+
this.params.where[column] = [firstCondition, condition];
|
|
2820
|
+
}
|
|
2821
|
+
return this;
|
|
2822
|
+
}
|
|
2823
|
+
orderBy(column, direction = "asc") {
|
|
2824
|
+
this.params.orderBy = [column, direction];
|
|
2825
|
+
return this;
|
|
2826
|
+
}
|
|
2827
|
+
limit(count) {
|
|
2828
|
+
this.params.limit = count;
|
|
2829
|
+
return this;
|
|
2830
|
+
}
|
|
2831
|
+
offset(count) {
|
|
2832
|
+
this.params.offset = count;
|
|
2833
|
+
return this;
|
|
2834
|
+
}
|
|
2835
|
+
search(searchString) {
|
|
2836
|
+
this.params.searchString = searchString;
|
|
2837
|
+
return this;
|
|
2838
|
+
}
|
|
2839
|
+
include(...relations) {
|
|
2840
|
+
this.params.include = relations;
|
|
2841
|
+
return this;
|
|
2842
|
+
}
|
|
2843
|
+
async find() {
|
|
2844
|
+
return this.client.find(this.params);
|
|
2845
|
+
}
|
|
2846
|
+
async count() {
|
|
2847
|
+
return this.client.count ? this.client.count(this.params) : 0;
|
|
2848
|
+
}
|
|
2849
|
+
listen(onUpdate, onError) {
|
|
2850
|
+
if (!this.client.listen) throw new Error("Listen is only available when the driver supports realtime.");
|
|
2851
|
+
return this.client.listen(this.params, onUpdate, onError);
|
|
2852
|
+
}
|
|
2853
|
+
};
|
|
2854
|
+
/**
|
|
2855
|
+
* Wrap a Entity-shaped {@link CollectionAccessor} into a flat
|
|
2856
|
+
* {@link SDKCollectionClient}. Every returned record is unwrapped to a flat row
|
|
2857
|
+
* so the backend SDK is byte-for-byte the same shape as the frontend client.
|
|
2858
|
+
*/
|
|
2859
|
+
function toSdkCollectionClient(snap) {
|
|
2860
|
+
const client = {
|
|
2861
|
+
async find(params) {
|
|
2862
|
+
const res = await snap.find(params);
|
|
2863
|
+
return {
|
|
2864
|
+
data: res.data.map(entityToRow),
|
|
2865
|
+
meta: res.meta
|
|
2866
|
+
};
|
|
2867
|
+
},
|
|
2868
|
+
async findById(id) {
|
|
2869
|
+
const s = await snap.findById(id);
|
|
2870
|
+
return s ? entityToRow(s) : void 0;
|
|
2871
|
+
},
|
|
2872
|
+
async create(data, id) {
|
|
2873
|
+
return entityToRow(await snap.create(data, id));
|
|
2874
|
+
},
|
|
2875
|
+
async update(id, data) {
|
|
2876
|
+
return entityToRow(await snap.update(id, data));
|
|
2877
|
+
},
|
|
2878
|
+
delete(id) {
|
|
2879
|
+
return snap.delete(id);
|
|
2880
|
+
},
|
|
2881
|
+
count: snap.count ? (params) => snap.count(params) : void 0,
|
|
2882
|
+
listen: snap.listen ? (params, onUpdate, onError) => snap.listen(params, (res) => onUpdate({
|
|
2883
|
+
data: res.data.map(entityToRow),
|
|
2884
|
+
meta: res.meta
|
|
2885
|
+
}), onError) : void 0,
|
|
2886
|
+
listenById: snap.listenById ? (id, onUpdate, onError) => snap.listenById(id, (s) => onUpdate(s ? entityToRow(s) : void 0), onError) : void 0,
|
|
2887
|
+
where(columnOrCondition, operator, value) {
|
|
2888
|
+
const builder = new SdkQueryBuilder(client);
|
|
2889
|
+
if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
|
|
2890
|
+
return builder.where(columnOrCondition, operator, value);
|
|
2891
|
+
},
|
|
2892
|
+
orderBy: (column, direction) => new SdkQueryBuilder(client).orderBy(column, direction),
|
|
2893
|
+
limit: (count) => new SdkQueryBuilder(client).limit(count),
|
|
2894
|
+
offset: (count) => new SdkQueryBuilder(client).offset(count),
|
|
2895
|
+
search: (searchString) => new SdkQueryBuilder(client).search(searchString),
|
|
2896
|
+
include: (...relations) => new SdkQueryBuilder(client).include(...relations)
|
|
2897
|
+
};
|
|
2898
|
+
return client;
|
|
2899
|
+
}
|
|
2900
|
+
/**
|
|
2901
|
+
* Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
|
|
2902
|
+
* {@link CollectionAccessor}. Every returned row is re-wrapped into the
|
|
2903
|
+
* `{ id, path, values }` view-model the admin CMS renders.
|
|
2904
|
+
*/
|
|
2905
|
+
function toEntityAccessor(sdk, slug) {
|
|
2906
|
+
const accessor = {
|
|
2907
|
+
async find(params) {
|
|
2908
|
+
const res = await sdk.find(params);
|
|
2909
|
+
return {
|
|
2910
|
+
data: res.data.map((row) => rowToEntity(row, slug)),
|
|
2911
|
+
meta: res.meta
|
|
2912
|
+
};
|
|
2913
|
+
},
|
|
2914
|
+
async findById(id) {
|
|
2915
|
+
const row = await sdk.findById(id);
|
|
2916
|
+
return row ? rowToEntity(row, slug) : void 0;
|
|
2917
|
+
},
|
|
2918
|
+
async create(data, id) {
|
|
2919
|
+
return rowToEntity(await sdk.create(data, id), slug);
|
|
2920
|
+
},
|
|
2921
|
+
async update(id, data) {
|
|
2922
|
+
const row = await sdk.update(id, data);
|
|
2923
|
+
if (!row) throw new Error(`Update returned no data for id ${id}`);
|
|
2924
|
+
return rowToEntity(row, slug);
|
|
2925
|
+
},
|
|
2926
|
+
delete(id) {
|
|
2927
|
+
return sdk.delete(id);
|
|
2928
|
+
},
|
|
2929
|
+
count: sdk.count ? (params) => sdk.count(params) : void 0,
|
|
2930
|
+
listen: sdk.listen ? (params, onUpdate, onError) => sdk.listen(params, (res) => onUpdate({
|
|
2931
|
+
data: res.data.map((row) => rowToEntity(row, slug)),
|
|
2932
|
+
meta: res.meta
|
|
2933
|
+
}), onError) : void 0,
|
|
2934
|
+
listenById: sdk.listenById ? (id, onUpdate, onError) => sdk.listenById(id, (row) => onUpdate(row ? rowToEntity(row, slug) : void 0), onError) : void 0,
|
|
2935
|
+
where(columnOrCondition, operator, value) {
|
|
2936
|
+
const builder = new QueryBuilder(accessor);
|
|
2937
|
+
if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
|
|
2938
|
+
return builder.where(columnOrCondition, operator, value);
|
|
2939
|
+
},
|
|
2940
|
+
orderBy: (column, direction) => new QueryBuilder(accessor).orderBy(column, direction),
|
|
2941
|
+
limit: (count) => new QueryBuilder(accessor).limit(count),
|
|
2942
|
+
offset: (count) => new QueryBuilder(accessor).offset(count),
|
|
2943
|
+
search: (searchString) => new QueryBuilder(accessor).search(searchString),
|
|
2944
|
+
include: (...relations) => new QueryBuilder(accessor).include(...relations)
|
|
2945
|
+
};
|
|
2946
|
+
return accessor;
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Wrap a flat {@link RebaseSdkData} into a Entity-shaped {@link RebaseData}.
|
|
2950
|
+
*
|
|
2951
|
+
* This is the **CMS boundary**: the SDK client (`client.data`) returns flat
|
|
2952
|
+
* rows, but the admin renders the `Entity` view-model (`entity.values.*`).
|
|
2953
|
+
* `core/Rebase.tsx` wraps `client.data` through this before handing it to the
|
|
2954
|
+
* CMS `RebaseDataContext` — without it the admin renders rows with only their
|
|
2955
|
+
* `id`.
|
|
2956
|
+
*/
|
|
2957
|
+
function wrapAsEntityData(sdkData) {
|
|
2958
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2959
|
+
function getAccessor(slug) {
|
|
2960
|
+
let accessor = cache.get(slug);
|
|
2961
|
+
if (!accessor) {
|
|
2962
|
+
accessor = toEntityAccessor(sdkData.collection(slug), slug);
|
|
2963
|
+
cache.set(slug, accessor);
|
|
2964
|
+
}
|
|
2965
|
+
return accessor;
|
|
2966
|
+
}
|
|
2967
|
+
return new Proxy({ collection: getAccessor }, { get(_target, prop) {
|
|
2968
|
+
if (prop === "collection") return getAccessor;
|
|
2969
|
+
if (typeof prop === "symbol") return void 0;
|
|
2970
|
+
if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return void 0;
|
|
2971
|
+
return getAccessor(toSnakeCase(prop));
|
|
2972
|
+
} });
|
|
2973
|
+
}
|
|
2974
|
+
/**
|
|
2975
|
+
* Wrap a Entity-shaped {@link RebaseData} into a flat {@link RebaseSdkData}.
|
|
2976
|
+
*
|
|
2977
|
+
* Every collection accessor is adapted to return flat rows. Use this to derive
|
|
2978
|
+
* the flat SDK data layer (`context.data`) from an existing Entity data layer
|
|
2979
|
+
* — e.g. the admin routes its Entity data via `useData()` and exposes the
|
|
2980
|
+
* same routing as flat `context.data` for callbacks by wrapping it here.
|
|
2981
|
+
*/
|
|
2982
|
+
function wrapAsSdkData(entityData) {
|
|
2983
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2984
|
+
function getAccessor(slug) {
|
|
2985
|
+
let accessor = cache.get(slug);
|
|
2986
|
+
if (!accessor) {
|
|
2987
|
+
accessor = toSdkCollectionClient(entityData.collection(slug));
|
|
2988
|
+
cache.set(slug, accessor);
|
|
2989
|
+
}
|
|
2990
|
+
return accessor;
|
|
2991
|
+
}
|
|
2992
|
+
return new Proxy({ collection: getAccessor }, { get(_target, prop) {
|
|
2993
|
+
if (prop === "collection") return getAccessor;
|
|
2994
|
+
if (typeof prop === "symbol") return void 0;
|
|
2995
|
+
if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return void 0;
|
|
2996
|
+
return getAccessor(toSnakeCase(prop));
|
|
2997
|
+
} });
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* Build a flat {@link RebaseSdkData} from a `DataDriver`.
|
|
3001
|
+
*
|
|
3002
|
+
* This is the developer-facing SDK data layer used by backend framework
|
|
3003
|
+
* callbacks & scripts (`context.data` / `rebase.data`). It returns flat rows —
|
|
3004
|
+
* identical in shape to the frontend SDK client — so the API is symmetric
|
|
3005
|
+
* across front and back. The admin CMS uses {@link buildRebaseData} (Entity).
|
|
3006
|
+
*/
|
|
3007
|
+
function buildSdkData(driver) {
|
|
3008
|
+
return wrapAsSdkData(buildRebaseData(driver));
|
|
3009
|
+
}
|
|
2235
3010
|
//#endregion
|
|
2236
3011
|
//#region src/data/buildRoutedRebaseData.ts
|
|
2237
3012
|
/**
|
|
@@ -2276,6 +3051,57 @@ function buildRoutedRebaseData({ defaultData, sources, resolveKey }) {
|
|
|
2276
3051
|
} });
|
|
2277
3052
|
}
|
|
2278
3053
|
//#endregion
|
|
3054
|
+
//#region src/data/sort-dialect.ts
|
|
3055
|
+
/**
|
|
3056
|
+
* Sort-order wire codec.
|
|
3057
|
+
*
|
|
3058
|
+
* This is the ONLY module that knows about the colon-delimited wire format
|
|
3059
|
+
* (`"field:direction"`) used in HTTP query parameters.
|
|
3060
|
+
* Everything else speaks {@link OrderByTuple} exclusively.
|
|
3061
|
+
*
|
|
3062
|
+
* Mirrors the filter architecture in `filter-dialect.ts`.
|
|
3063
|
+
*
|
|
3064
|
+
* @module
|
|
3065
|
+
*/
|
|
3066
|
+
/**
|
|
3067
|
+
* Serialize an {@link OrderByTuple} to the wire format `"field:direction"`.
|
|
3068
|
+
*
|
|
3069
|
+
* **Runtime tolerance:** if the input is already a well-formed wire string
|
|
3070
|
+
* (from an untyped JS caller), it is returned unchanged.
|
|
3071
|
+
* This is undocumented tolerance, not public API — don't rely on it.
|
|
3072
|
+
*
|
|
3073
|
+
* @param orderBy - A canonical `[field, direction]` tuple, or at runtime
|
|
3074
|
+
* possibly a pre-serialized string (undocumented tolerance).
|
|
3075
|
+
* @returns The wire-format string, or `undefined` if the input is falsy.
|
|
3076
|
+
*
|
|
3077
|
+
* @remarks
|
|
3078
|
+
* Field names containing `:` are representable in the tuple form but
|
|
3079
|
+
* **not** on the wire — this is an inherent limitation of the colon-delimited
|
|
3080
|
+
* encoding and is not resolved here.
|
|
3081
|
+
*/
|
|
3082
|
+
function serializeOrderBy(orderBy) {
|
|
3083
|
+
if (!orderBy) return void 0;
|
|
3084
|
+
if (typeof orderBy === "string") return orderBy;
|
|
3085
|
+
return `${orderBy[0]}:${orderBy[1]}`;
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
|
|
3089
|
+
*
|
|
3090
|
+
* Lenient parsing (matches existing server behaviour):
|
|
3091
|
+
* - Bare field name (no colon): `"name"` → `["name", "asc"]`
|
|
3092
|
+
* - Unknown direction: `"name:foo"` → `["name", "asc"]`
|
|
3093
|
+
* - Empty / falsy input: → `undefined`
|
|
3094
|
+
*
|
|
3095
|
+
* @param raw - The wire-format string from an HTTP query parameter.
|
|
3096
|
+
* @returns The canonical tuple, or `undefined` if the input is empty/falsy.
|
|
3097
|
+
*/
|
|
3098
|
+
function deserializeOrderBy(raw) {
|
|
3099
|
+
if (!raw) return void 0;
|
|
3100
|
+
const idx = raw.indexOf(":");
|
|
3101
|
+
if (idx === -1) return [raw, "asc"];
|
|
3102
|
+
return [raw.slice(0, idx), raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
|
|
3103
|
+
}
|
|
3104
|
+
//#endregion
|
|
2279
3105
|
//#region src/table-classification.ts
|
|
2280
3106
|
/** Schemas that are always considered Rebase-internal. */
|
|
2281
3107
|
var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
|
|
@@ -2352,6 +3178,6 @@ async function detectJunctionTables(executeSql) {
|
|
|
2352
3178
|
return junctionTables;
|
|
2353
3179
|
}
|
|
2354
3180
|
//#endregion
|
|
2355
|
-
export { COLLECTION_PATH_SEPARATOR, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, JUNCTION_TABLES_SQL, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, addInitialSlash, and, applyPropertyConditions,
|
|
3181
|
+
export { COLLECTION_PATH_SEPARATOR, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, JUNCTION_TABLES_SQL, QueryBuilder, REBASE_INTERNAL_PREFIXES, REBASE_INTERNAL_SCHEMAS, addInitialSlash, and, applyPropertyConditions, buildCollection, buildConditionContext, buildProperty, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, buildSdkData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, cond, createDataSourceRegistry, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, deserializeOrderBy, detectJunctionTables, enumToObjectEntries, evaluateCondition, evaluatePolicy, findRelation, fullPathToCollectionSegments, getArrayResolvedProperties, getCollectionBySlugWithin, getCollectionPathsCombinations, getColumnName, getDefaultValueFor, getDefaultValueFortype, getDefaultValuesFor, getEntityImagePreviewPropertyKey, getEnumVarName, getLabelOrConfigFrom, getLastSegment, getLocalChangesBackup, getNavigationEntriesFromPath, getParentReferencesFromPath, getPrimaryKeys, getReferenceFrom, getRelationFrom, getSubcollections, getTableName, getTableVarName, isHidden, isPropertyBuilder, isReadOnly, isRebaseInternalTable, normalizeToEntityRelation, or, policyToPostgres, registerConditionOperations, removeInitialAndTrailingSlashes, removeInitialSlash, removeTrailingSlash, resolveArrayProperties, resolveCollectionPathIds, resolveCollectionRelations, resolveDataSource, resolveDefaultSelectedView, resolveEnumValues, resolveFilterOperators, resolveProperties, resolveProperty, resolvePropertyEnum, resolvePropertyRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, sanitizeData, sanitizeRelation, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, serializeOrderBy, sortProperties, stripCollectionPath, traverseValueProperty, traverseValuesProperties, updateDateAutoValues, wrapAsEntityData, wrapAsSdkData };
|
|
2356
3182
|
|
|
2357
3183
|
//# sourceMappingURL=index.es.js.map
|