@rebasepro/common 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/collections/CollectionRegistry.d.ts +30 -2
  2. package/dist/collections/default-collections.d.ts +255 -2
  3. package/dist/data/buildRoutedRebaseData.d.ts +53 -0
  4. package/dist/data/filter-dialect.d.ts +61 -0
  5. package/dist/data/query_builder.d.ts +4 -4
  6. package/dist/data/resolveDataSource.d.ts +43 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.es.js +777 -178
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.umd.js +793 -176
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/table-classification.d.ts +47 -0
  13. package/dist/util/builders.d.ts +48 -1
  14. package/dist/util/callbacks.d.ts +6 -1
  15. package/dist/util/index.d.ts +1 -0
  16. package/dist/util/permissions.d.ts +26 -2
  17. package/dist/util/policy/evaluatePolicy.d.ts +31 -0
  18. package/dist/util/policy/index.d.ts +3 -0
  19. package/dist/util/policy/policyToPostgres.d.ts +10 -0
  20. package/dist/util/policy/securityRuleToConditions.d.ts +24 -0
  21. package/dist/util/policy/sqlToPolicy.d.ts +20 -0
  22. package/dist/util/storage.d.ts +26 -1
  23. package/package.json +3 -3
  24. package/src/collections/CollectionRegistry.ts +80 -16
  25. package/src/collections/default-collections.ts +4 -4
  26. package/src/data/buildRebaseData.ts +9 -120
  27. package/src/data/buildRoutedRebaseData.ts +97 -0
  28. package/src/data/filter-dialect.ts +318 -0
  29. package/src/data/query_builder.ts +10 -10
  30. package/src/data/resolveDataSource.ts +79 -0
  31. package/src/index.ts +4 -1
  32. package/src/table-classification.ts +109 -0
  33. package/src/util/builders.ts +78 -1
  34. package/src/util/callbacks.ts +8 -1
  35. package/src/util/index.ts +1 -0
  36. package/src/util/permissions.test.ts +5 -3
  37. package/src/util/permissions.ts +85 -158
  38. package/src/util/policy/evaluatePolicy.ts +146 -0
  39. package/src/util/policy/index.ts +3 -0
  40. package/src/util/policy/policyToPostgres.ts +85 -0
  41. package/src/util/policy/securityRuleToConditions.ts +67 -0
  42. package/src/util/policy/sqlToPolicy.ts +88 -0
  43. package/src/util/references.ts +1 -1
  44. package/src/util/relations.ts +8 -9
  45. package/src/util/resolutions.ts +6 -6
  46. package/src/util/storage.ts +34 -1
package/dist/index.es.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EntityReference, EntityRelation, getDataSourceCapabilities } from "@rebasepro/types";
1
+ import { CANONICAL_TO_REST, DEFAULT_DATA_SOURCE_KEY, EntityReference, EntityRelation, 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";
@@ -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.driver).supportsRelations ? targetCollection.relations || [] : [];
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.driver).supportsRelations ? targetCollection.relations || [] : [];
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.driver).supportsRelations) return {};
391
- const relCollection = collection;
390
+ if (!getDataSourceCapabilities(collection.engine).supportsRelations) return {};
392
391
  const relations = {};
393
392
  const registeredRelationNames = /* @__PURE__ */ new Set();
394
- if (relCollection.relations) relCollection.relations.forEach((relation) => {
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.driver).supportsRelations) return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
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
- if (getDataSourceCapabilities(collection.driver).supportsSubcollections && collection.subcollections) return collection.subcollections() ?? [];
647
- if (getDataSourceCapabilities(collection.driver).supportsRelations) {
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,339 @@ function getSubcollections(collection) {
670
670
  return [];
671
671
  }
672
672
  //#endregion
673
- //#region src/util/permissions.ts
674
- function evaluateAST(sqlString, auth, entity) {
675
- if (!entity) return true;
676
- let cleanedSQL = sqlString.trim();
677
- while (cleanedSQL.startsWith("(") && cleanedSQL.endsWith(")")) {
678
- let openCount = 0;
679
- let isEnclosing = true;
680
- for (let i = 0; i < cleanedSQL.length - 1; i++) {
681
- if (cleanedSQL[i] === "(") openCount++;
682
- else if (cleanedSQL[i] === ")") openCount--;
683
- if (openCount === 0) {
684
- isEnclosing = false;
685
- break;
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) {
784
+ switch (expr.kind) {
785
+ case "true": return "true";
786
+ case "false": return "false";
787
+ case "and": return expr.operands.length === 0 ? "true" : expr.operands.map((o) => `(${policyToPostgres(o, collection)})`).join(" AND ");
788
+ case "or": return expr.operands.length === 0 ? "false" : expr.operands.map((o) => `(${policyToPostgres(o, collection)})`).join(" OR ");
789
+ case "not":
790
+ if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
791
+ return `NOT (${policyToPostgres(expr.operand, collection)})`;
792
+ case "compare": return `${operandToSql(expr.left, collection)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, collection)}`;
793
+ case "rolesOverlap": return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
794
+ case "rolesContain": return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
795
+ case "authenticated": return "auth.uid() IS NOT NULL";
796
+ case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => col);
797
+ }
798
+ }
799
+ var COMPARE_SQL = {
800
+ eq: "=",
801
+ neq: "!=",
802
+ lt: "<",
803
+ lte: "<=",
804
+ gt: ">",
805
+ gte: ">="
806
+ };
807
+ function operandToSql(operand, collection) {
808
+ switch (operand.kind) {
809
+ case "field": return resolveColumnName(operand.name, collection);
810
+ case "literal": return quoteLiteral(operand.value);
811
+ case "authUid": return "auth.uid()";
812
+ case "authRoles": return "string_to_array(auth.roles(), ',')";
813
+ }
814
+ }
815
+ function resolveColumnName(propName, collection) {
816
+ const prop = collection?.properties?.[propName];
817
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
818
+ return toSnakeCase(propName);
819
+ }
820
+ function quoteLiteral(value) {
821
+ if (value === null) return "NULL";
822
+ if (typeof value === "boolean") return value ? "true" : "false";
823
+ if (typeof value === "number") return String(value);
824
+ return `'${value.replace(/'/g, "''")}'`;
825
+ }
826
+ /** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
827
+ function rolesArraySql(roles) {
828
+ return `ARRAY[${[...roles].sort().map((r) => `'${r}'`).join(",")}]`;
829
+ }
830
+ //#endregion
831
+ //#region src/util/policy/evaluatePolicy.ts
832
+ /**
833
+ * Evaluates a {@link PolicyExpression} against a user + row, using three-valued
834
+ * (Kleene) logic so that `"unknown"` sub-results propagate soundly.
835
+ *
836
+ * This is the JavaScript twin of {@link policyToPostgres}: both derive from the
837
+ * same expression, so the admin UI matches database enforcement by construction
838
+ * for every non-raw rule.
839
+ */
840
+ function evaluatePolicy(expr, ctx) {
841
+ switch (expr.kind) {
842
+ case "true": return true;
843
+ case "false": return false;
844
+ case "and": return kleeneAnd$1(expr.operands.map((o) => evaluatePolicy(o, ctx)));
845
+ case "or": return kleeneOr(expr.operands.map((o) => evaluatePolicy(o, ctx)));
846
+ case "not": return kleeneNot(evaluatePolicy(expr.operand, ctx));
847
+ case "compare": return evaluateCompare(expr.op, expr.left, expr.right, ctx);
848
+ case "rolesOverlap": {
849
+ const userRoles = ctx.roles ?? [];
850
+ return expr.roles.some((r) => r === "public" || userRoles.includes(r));
687
851
  }
688
- if (isEnclosing) cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
689
- else break;
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
- }
852
+ case "rolesContain": {
853
+ const userRoles = ctx.roles ?? [];
854
+ return expr.roles.every((r) => r === "public" || userRoles.includes(r));
707
855
  }
708
- parts.push(current);
709
- return parts;
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;
856
+ case "authenticated": return ctx.uid != null;
857
+ case "raw": return "unknown";
743
858
  }
859
+ }
860
+ function kleeneAnd$1(values) {
861
+ if (values.some((v) => v === false)) return false;
862
+ if (values.some((v) => v === "unknown")) return "unknown";
744
863
  return true;
745
864
  }
746
- function evaluateRule(rule, auth, entity) {
747
- if (rule.access === "public") return true;
748
- if (rule.ownerField) {
749
- if (!entity) {} else if (entity.values[rule.ownerField] !== auth.user?.uid) return false;
865
+ function kleeneOr(values) {
866
+ if (values.some((v) => v === true)) return true;
867
+ if (values.some((v) => v === "unknown")) return "unknown";
868
+ return false;
869
+ }
870
+ function kleeneNot(value) {
871
+ if (value === "unknown") return "unknown";
872
+ return !value;
873
+ }
874
+ function resolveOperand(operand, ctx) {
875
+ switch (operand.kind) {
876
+ case "literal": return {
877
+ known: true,
878
+ value: operand.value
879
+ };
880
+ case "authUid": return {
881
+ known: true,
882
+ value: ctx.uid ?? null
883
+ };
884
+ case "authRoles": return {
885
+ known: true,
886
+ value: ctx.roles ?? []
887
+ };
888
+ case "field":
889
+ if (!ctx.entity) return { known: false };
890
+ return {
891
+ known: true,
892
+ value: ctx.entity.values[operand.name]
893
+ };
894
+ }
895
+ }
896
+ function evaluateCompare(op, left, right, ctx) {
897
+ const l = resolveOperand(left, ctx);
898
+ const r = resolveOperand(right, ctx);
899
+ if (!l.known || !r.known) return "unknown";
900
+ const a = l.value;
901
+ const b = r.value;
902
+ if (a === null || b === null) {
903
+ if (op === "eq") return false;
904
+ if (op === "neq") return true;
905
+ return "unknown";
906
+ }
907
+ if (op === "eq") return a === b;
908
+ if (op === "neq") return a !== b;
909
+ if (typeof a === "string" && typeof b === "string") {
910
+ if (op === "lt") return a < b;
911
+ if (op === "lte") return a <= b;
912
+ if (op === "gt") return a > b;
913
+ if (op === "gte") return a >= b;
750
914
  }
751
- if (rule.using && !evaluateAST(rule.using, auth, entity)) return false;
752
- if (rule.withCheck && !evaluateAST(rule.withCheck, auth, entity)) return false;
915
+ if (typeof a === "number" && typeof b === "number") {
916
+ if (op === "lt") return a < b;
917
+ if (op === "lte") return a <= b;
918
+ if (op === "gt") return a > b;
919
+ if (op === "gte") return a >= b;
920
+ }
921
+ if (typeof a === "bigint" && typeof b === "bigint") {
922
+ if (op === "lt") return a < b;
923
+ if (op === "lte") return a <= b;
924
+ if (op === "gt") return a > b;
925
+ if (op === "gte") return a >= b;
926
+ }
927
+ return "unknown";
928
+ }
929
+ //#endregion
930
+ //#region src/util/permissions.ts
931
+ /** Combine clause results with AND under three-valued (Kleene) logic. */
932
+ function kleeneAnd(values) {
933
+ if (values.some((v) => v === false)) return false;
934
+ if (values.some((v) => v === "unknown")) return "unknown";
753
935
  return true;
754
936
  }
755
- function checkOperation(collection, authContext, entity, targetOperation) {
756
- const securityRules = getDataSourceCapabilities(collection.driver).supportsRLS ? collection.securityRules : void 0;
937
+ /** The operations a rule covers, mirroring the Postgres generator's resolution. */
938
+ function ruleOperations(rule) {
939
+ return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
940
+ }
941
+ function ruleApplies(rule, targetOperation) {
942
+ const ops = ruleOperations(rule);
943
+ return ops.includes(targetOperation) || ops.includes("all");
944
+ }
945
+ /**
946
+ * Evaluate a single rule for one operation, returning a tri-state.
947
+ *
948
+ * A `null` clause (the rule contributes no condition for a required clause)
949
+ * denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
950
+ * in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
951
+ * INSERT/UPDATE; both must pass for UPDATE.
952
+ */
953
+ function evaluateRuleForOperation(rule, ctx, targetOperation) {
954
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
955
+ const clause = (expr) => expr === null ? false : evaluatePolicy(expr, ctx);
956
+ const needsUsing = targetOperation !== "insert";
957
+ const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
958
+ const results = [];
959
+ if (needsUsing) results.push(clause(usingExpr));
960
+ if (needsWithCheck) results.push(clause(withCheckExpr));
961
+ return kleeneAnd(results);
962
+ }
963
+ function resolveTriState(value, onUnknown) {
964
+ if (value === "unknown") return onUnknown === "allow";
965
+ return value;
966
+ }
967
+ /**
968
+ * Decide whether an operation is permitted for a user on a (possibly null) row,
969
+ * by evaluating the collection's security rules with the shared policy model —
970
+ * the same model compiled to Postgres RLS DDL, so the decision matches database
971
+ * enforcement for every non-raw rule.
972
+ *
973
+ * @param options.onUnknown how to treat rules that cannot be decided
974
+ * client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
975
+ * for optimistic UI gating; enforcement callers should pass `"deny"`.
976
+ */
977
+ function checkOperation(collection, authContext, entity, targetOperation, options) {
978
+ const onUnknown = options?.onUnknown ?? "allow";
979
+ const securityRules = getDataSourceCapabilities(collection.engine).supportsRLS ? collection.securityRules : void 0;
757
980
  if (!securityRules || securityRules.length === 0) return true;
758
- const applicableRules = securityRules.filter((r) => r.operation === targetOperation || r.operation === "all" || r.operations?.includes(targetOperation) || r.operations?.includes("all"));
981
+ const applicableRules = securityRules.filter((r) => ruleApplies(r, targetOperation));
759
982
  if (applicableRules.length === 0) return false;
760
- const userRoles = [...authContext.user?.roles ?? [], "public"];
761
- const roleApplicableRules = applicableRules.filter((rule) => {
762
- if (!rule.roles || rule.roles.length === 0) return true;
763
- return rule.roles.some((r) => userRoles.includes(r));
764
- });
765
- if (roleApplicableRules.length === 0) return false;
983
+ const ctx = {
984
+ uid: authContext.user?.uid,
985
+ roles: authContext.user?.roles ?? [],
986
+ entity
987
+ };
766
988
  let grantedByPermissive = false;
767
989
  let deniedByRestrictive = false;
768
- for (const rule of roleApplicableRules) {
990
+ let hasPermissive = false;
991
+ for (const rule of applicableRules) {
769
992
  const mode = rule.mode || "permissive";
770
- const passed = evaluateRule(rule, authContext, entity);
771
- if (mode === "restrictive" && !passed) {
772
- deniedByRestrictive = true;
773
- break;
993
+ const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
994
+ if (mode === "restrictive") {
995
+ if (!passed) {
996
+ deniedByRestrictive = true;
997
+ break;
998
+ }
999
+ } else {
1000
+ hasPermissive = true;
1001
+ if (passed) grantedByPermissive = true;
774
1002
  }
775
- if (mode === "permissive" && passed) grantedByPermissive = true;
776
1003
  }
777
1004
  if (deniedByRestrictive) return false;
778
- if (roleApplicableRules.some((r) => (r.mode || "permissive") === "permissive")) return grantedByPermissive;
779
- else return false;
1005
+ return hasPermissive ? grantedByPermissive : false;
780
1006
  }
781
1007
  function canReadCollection(collection, authContext) {
782
1008
  return checkOperation(collection, authContext, null, "select");
@@ -807,7 +1033,7 @@ function getEntityImagePreviewPropertyKey(collection) {
807
1033
  }
808
1034
  for (const key in collection.properties) {
809
1035
  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;
1036
+ if (property.type === "array" && property.of && !Array.isArray(property.of) && property.of.type === "string" && property.of.ui?.url === "image") return key;
811
1037
  }
812
1038
  for (const key in collection.properties) {
813
1039
  const property = collection.properties[key];
@@ -1041,6 +1267,13 @@ function buildCollection(collection) {
1041
1267
  return collection;
1042
1268
  }
1043
1269
  /**
1270
+ * Implementation — delegates to the correct overload at the type level.
1271
+ * At runtime this is a plain identity function.
1272
+ */
1273
+ function defineCollection(collection) {
1274
+ return collection;
1275
+ }
1276
+ /**
1044
1277
  * Identity function we use to defeat the type system of Typescript and preserve
1045
1278
  * the property keys.
1046
1279
  * @param property
@@ -1105,6 +1338,29 @@ function buildAdditionalFieldDelegate(additionalFieldDelegate) {
1105
1338
  }
1106
1339
  //#endregion
1107
1340
  //#region src/util/storage.ts
1341
+ /**
1342
+ * Resolve the {@link StorageSource} to use for a property, given the key
1343
+ * referenced by `StorageConfig.storageSource`.
1344
+ *
1345
+ * Resolution priority:
1346
+ * 1. No `sourceKey` → the default source (backward compatible).
1347
+ * 2. An explicit {@link StorageSourceRegistry} (e.g. `client.storageRegistry`).
1348
+ * 3. A `sources` lookup map (e.g. the `StorageSourcesContext`).
1349
+ * 4. Fall back to the default source.
1350
+ *
1351
+ * Shared by the upload hook, the markdown editor, and the read-only previews
1352
+ * so the resolution logic lives in one place.
1353
+ *
1354
+ * @group Storage
1355
+ */
1356
+ function resolveStorageSource(params) {
1357
+ const { sourceKey, sources, registry, defaultSource } = params;
1358
+ if (!sourceKey) return defaultSource;
1359
+ if (registry) return registry.getOrDefault(sourceKey);
1360
+ const fromSources = sources?.[sourceKey];
1361
+ if (fromSources) return fromSources;
1362
+ return defaultSource;
1363
+ }
1108
1364
  async function resolveStorageFilenameString({ input, storage, values, entityId, path, property, file, propertyKey }) {
1109
1365
  let result;
1110
1366
  if (typeof input === "function") {
@@ -1411,8 +1667,76 @@ function applyEnumConditions(enumValues, conditions, context) {
1411
1667
  return result;
1412
1668
  }
1413
1669
  //#endregion
1670
+ //#region src/data/resolveDataSource.ts
1671
+ /**
1672
+ * Build a keyed registry from a list of {@link DataSourceDefinition}s.
1673
+ * Later entries win on key collision.
1674
+ */
1675
+ function createDataSourceRegistry(definitions) {
1676
+ const registry = {};
1677
+ for (const def of definitions ?? []) registry[def.key] = def;
1678
+ return registry;
1679
+ }
1680
+ /**
1681
+ * Resolve the effective data source for a collection — the single source of
1682
+ * truth shared by the frontend router, the backend driver registry, and the
1683
+ * editor's capability lookups.
1684
+ *
1685
+ * Resolution order:
1686
+ * 1. The routing **key** is `collection.dataSource`, else
1687
+ * {@link DEFAULT_DATA_SOURCE_KEY}.
1688
+ * 2. If a definition is registered for that key, it provides `engine`,
1689
+ * `transport`, and `databaseId`.
1690
+ * 3. Otherwise values are synthesized: `engine` from `collection.engine`
1691
+ * (or the key, or `"postgres"`), `transport` defaults to `"server"`,
1692
+ * and `databaseId` from the collection.
1693
+ *
1694
+ * `capabilities` are always derived from the resolved `engine`, so two
1695
+ * data sources sharing an engine share capabilities.
1696
+ *
1697
+ * @param collection the collection (or any object carrying the routing fields)
1698
+ * @param registry optional registry of declared data sources
1699
+ */
1700
+ function resolveDataSource(collection, registry) {
1701
+ const key = collection?.dataSource ?? DEFAULT_DATA_SOURCE_KEY;
1702
+ const def = registry?.[key];
1703
+ const engine = def?.engine ?? collection?.engine ?? (key !== DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
1704
+ return {
1705
+ key,
1706
+ engine,
1707
+ transport: def?.transport ?? "server",
1708
+ databaseId: collection?.databaseId ?? def?.databaseId,
1709
+ capabilities: getDataSourceCapabilities(engine)
1710
+ };
1711
+ }
1712
+ //#endregion
1414
1713
  //#region src/collections/CollectionRegistry.ts
1415
1714
  var CollectionRegistry = class {
1715
+ /**
1716
+ * Declared data sources, used during normalization to resolve each
1717
+ * collection's engine (so `dataSource`-only collections get the right
1718
+ * capabilities). Empty by default.
1719
+ */
1720
+ dataSources = {};
1721
+ /**
1722
+ * Global lifecycle callbacks applied to every collection.
1723
+ * Runs on all data paths (REST, WebSocket, `rebase.data`).
1724
+ * Execution order: global → collection → property callbacks.
1725
+ */
1726
+ _globalCallbacks;
1727
+ /**
1728
+ * Set global lifecycle callbacks that apply to every collection.
1729
+ * Typically called once during backend initialization.
1730
+ */
1731
+ setGlobalCallbacks(callbacks) {
1732
+ this._globalCallbacks = callbacks;
1733
+ }
1734
+ /**
1735
+ * Get the currently registered global callbacks, if any.
1736
+ */
1737
+ getGlobalCallbacks() {
1738
+ return this._globalCallbacks;
1739
+ }
1416
1740
  collectionsByTableName = /* @__PURE__ */ new Map();
1417
1741
  collectionsBySlug = /* @__PURE__ */ new Map();
1418
1742
  rootCollections = [];
@@ -1422,9 +1746,20 @@ var CollectionRegistry = class {
1422
1746
  rawRootCollections = [];
1423
1747
  cachedRawCollectionsList = null;
1424
1748
  lastRawInputSnapshot = null;
1425
- constructor(collections) {
1749
+ constructor(collections, dataSources) {
1750
+ if (dataSources) this.dataSources = dataSources;
1426
1751
  if (collections) this.registerMultiple(collections);
1427
1752
  }
1753
+ /**
1754
+ * Provide the declared data sources used to resolve each collection's
1755
+ * engine during normalization. Set this before registering collections.
1756
+ * Returns true if the registry changed (callers may re-register).
1757
+ */
1758
+ setDataSources(dataSources) {
1759
+ if (deepEqual(this.dataSources, dataSources)) return false;
1760
+ this.dataSources = dataSources ?? {};
1761
+ return true;
1762
+ }
1428
1763
  reset() {
1429
1764
  this.collectionsByTableName.clear();
1430
1765
  this.collectionsBySlug.clear();
@@ -1493,9 +1828,14 @@ var CollectionRegistry = class {
1493
1828
  }
1494
1829
  normalizeCollection(collection) {
1495
1830
  const result = { ...collection };
1831
+ {
1832
+ const resolved = resolveDataSource(result, this.dataSources);
1833
+ if (!result.dataSource) result.dataSource = resolved.key;
1834
+ if (!result.engine) result.engine = resolved.engine;
1835
+ }
1496
1836
  const extractedRelations = this.extractRelationsFromProperties(result.properties);
1497
1837
  const relResult = result;
1498
- const manualRelations = getDataSourceCapabilities(result.driver).supportsRelations ? relResult.relations ?? [] : [];
1838
+ const manualRelations = getDataSourceCapabilities(result.engine).supportsRelations ? relResult.relations ?? [] : [];
1499
1839
  const mergedRelationsRaw = [...extractedRelations];
1500
1840
  for (const manual of manualRelations) {
1501
1841
  const name = manual.relationName;
@@ -1510,7 +1850,7 @@ var CollectionRegistry = class {
1510
1850
  }
1511
1851
  }
1512
1852
  let mergedRelations = mergedRelationsRaw;
1513
- if (getDataSourceCapabilities(result.driver).supportsRelations) {
1853
+ if (getDataSourceCapabilities(result.engine).supportsRelations) {
1514
1854
  mergedRelations = mergedRelationsRaw.map((r) => {
1515
1855
  try {
1516
1856
  return sanitizeRelation(r, result, (slug) => this.get(slug));
@@ -1522,8 +1862,10 @@ var CollectionRegistry = class {
1522
1862
  }
1523
1863
  result.properties = this.normalizeProperties(result.properties, mergedRelations);
1524
1864
  if (!result.childCollections) {
1525
- if (getDataSourceCapabilities(result.driver).supportsSubcollections && result.subcollections) result.childCollections = result.subcollections;
1526
- else if (getDataSourceCapabilities(result.driver).supportsRelations && relResult.relations) {
1865
+ const capabilities = getDataSourceCapabilities(result.engine);
1866
+ const declaredSubcollections = getDeclaredSubcollections(result);
1867
+ if (capabilities.supportsSubcollections && declaredSubcollections) result.childCollections = declaredSubcollections;
1868
+ else if (capabilities.supportsRelations && relResult.relations) {
1527
1869
  const manyRelations = relResult.relations.filter((r) => r.cardinality === "many");
1528
1870
  if (manyRelations.length > 0) result.childCollections = () => manyRelations.map((r) => {
1529
1871
  const target = r.target();
@@ -1625,7 +1967,7 @@ var CollectionRegistry = class {
1625
1967
  if (!currentCollection) throw new Error(`Root collection not found: ${rootCollectionPath}`);
1626
1968
  for (let i = 2; i < pathSegments.length; i += 2) {
1627
1969
  const relationKey = pathSegments[i];
1628
- if (!getDataSourceCapabilities(currentCollection.driver).supportsRelations) throw new Error(`Relation path navigation requires a collection that supports relations, but '${currentCollection.slug}' uses driver '${currentCollection.driver}'`);
1970
+ if (!getDataSourceCapabilities(currentCollection.engine).supportsRelations) throw new Error(`Relation path navigation requires a collection that supports relations, but '${currentCollection.slug}' uses engine '${currentCollection.engine}'`);
1629
1971
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
1630
1972
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
1631
1973
  const target = relation.target();
@@ -1686,7 +2028,7 @@ var CollectionRegistry = class {
1686
2028
  * Slug-based dedup (Map keyed by slug, last-write-wins) lets developers
1687
2029
  * override by defining their own collection with `slug: "users"`.
1688
2030
  */
1689
- var defaultUsersCollection = {
2031
+ var defaultUsersCollection = defineCollection({
1690
2032
  name: "Users",
1691
2033
  singularName: "User",
1692
2034
  slug: "users",
@@ -1734,7 +2076,7 @@ var defaultUsersCollection = {
1734
2076
  name: "Photo URL",
1735
2077
  type: "string",
1736
2078
  columnName: "photo_url",
1737
- url: "image"
2079
+ ui: { url: "image" }
1738
2080
  },
1739
2081
  roles: {
1740
2082
  name: "Roles",
@@ -1829,7 +2171,7 @@ var defaultUsersCollection = {
1829
2171
  "roles",
1830
2172
  "createdAt"
1831
2173
  ]
1832
- };
2174
+ });
1833
2175
  //#endregion
1834
2176
  //#region src/data/query_builder.ts
1835
2177
  function or(...conditions) {
@@ -1881,8 +2223,8 @@ var QueryBuilder = class {
1881
2223
  * @example
1882
2224
  * client.collection('users').orderBy('createdAt', 'desc').find()
1883
2225
  */
1884
- orderBy(column, ascending = "asc") {
1885
- this.params.orderBy = `${column}:${ascending}`;
2226
+ orderBy(column, direction = "asc") {
2227
+ this.params.orderBy = `${column}:${direction}`;
1886
2228
  return this;
1887
2229
  }
1888
2230
  /**
@@ -1937,80 +2279,216 @@ var QueryBuilder = class {
1937
2279
  }
1938
2280
  };
1939
2281
  //#endregion
1940
- //#region src/data/buildRebaseData.ts
2282
+ //#region src/data/filter-dialect.ts
1941
2283
  /**
1942
- * Convert where-clause filter object to the internal DataDriver FilterValues format.
2284
+ * REST wire-format adapter for the unified filter system.
1943
2285
  *
1944
- * Supports multiple value formats:
1945
- * - PostgREST string: { status: "eq.published", age: "gte.18" }
1946
- * - Equality shorthand: { company_profile_id: null, status: "active", age: 18 }
1947
- * - Tuple syntax: { age: [">=", 18], role: ["in", ["admin", "editor"]] }
2286
+ * This module is the ONLY code in the entire codebase that knows about
2287
+ * PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
2288
+ * Everything else speaks `FilterValues` exclusively.
1948
2289
  *
1949
- * Internal: { status: ["==", "published"], age: [">=", 18] }
2290
+ * @module
1950
2291
  */
1951
- function convertWhereToFilter(where) {
1952
- if (!where) return void 0;
1953
- const operatorMap = {
1954
- "eq": "==",
1955
- "neq": "!=",
1956
- "gt": ">",
1957
- "gte": ">=",
1958
- "lt": "<",
1959
- "lte": "<=",
1960
- "in": "in",
1961
- "nin": "not-in",
1962
- "not-in": "not-in",
1963
- "cs": "array-contains",
1964
- "csa": "array-contains-any",
1965
- "==": "==",
1966
- "!=": "!=",
1967
- ">": ">",
1968
- ">=": ">=",
1969
- "<": "<",
1970
- "<=": "<=",
1971
- "array-contains": "array-contains",
1972
- "array-contains-any": "array-contains-any"
1973
- };
1974
- const filter = {};
1975
- for (const [field, rawValue] of Object.entries(where)) {
1976
- if (rawValue === null) {
1977
- filter[field] = ["==", null];
1978
- continue;
1979
- }
1980
- if (typeof rawValue === "boolean") {
1981
- filter[field] = ["==", rawValue];
1982
- continue;
1983
- }
1984
- if (typeof rawValue === "number") {
1985
- filter[field] = ["==", rawValue];
1986
- continue;
2292
+ /**
2293
+ * Coerce a raw querystring value to its natural JS type.
2294
+ * - `"true"` / `"false"` → boolean
2295
+ * - `"null"` → null
2296
+ * - Numeric strings → number
2297
+ * - Everything else → string (unchanged)
2298
+ */
2299
+ function coerceValue(raw) {
2300
+ if (raw === "true") return true;
2301
+ if (raw === "false") return false;
2302
+ if (raw === "null") return null;
2303
+ if (raw !== "" && !isNaN(Number(raw))) return Number(raw);
2304
+ return raw;
2305
+ }
2306
+ /**
2307
+ * Serialize a JS value to its querystring representation.
2308
+ */
2309
+ function stringifyValue(value) {
2310
+ if (value === null) return "null";
2311
+ if (typeof value === "boolean") return String(value);
2312
+ return String(value);
2313
+ }
2314
+ /**
2315
+ * Serialize a single condition tuple to a PostgREST dot-string.
2316
+ *
2317
+ * @example
2318
+ * serializeTuple(["==", "active"]) // "eq.active"
2319
+ * serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
2320
+ * serializeTuple([">=", 18]) // "gte.18"
2321
+ */
2322
+ function serializeTuple(tuple) {
2323
+ if (typeof tuple === "string") {
2324
+ if (tuple.includes(".")) {
2325
+ const dotIndex = tuple.indexOf(".");
2326
+ if (REST_TO_CANONICAL[tuple.substring(0, dotIndex)]) return tuple;
1987
2327
  }
1988
- if (Array.isArray(rawValue)) {
1989
- const mappedConditions = (Array.isArray(rawValue[0]) ? rawValue : [rawValue]).map(([rawOp, val]) => {
1990
- return [operatorMap[rawOp] ?? "==", val];
1991
- });
1992
- filter[field] = Array.isArray(rawValue[0]) ? mappedConditions : mappedConditions[0];
2328
+ return tuple;
2329
+ }
2330
+ if (!Array.isArray(tuple) || tuple.length !== 2 || typeof tuple[0] !== "string" || !CANONICAL_TO_REST[tuple[0]]) return `eq.${stringifyValue(tuple)}`;
2331
+ const [op, value] = tuple;
2332
+ const restOp = CANONICAL_TO_REST[op];
2333
+ if (Array.isArray(value)) return `${restOp}.(${value.map(stringifyValue).join(",")})`;
2334
+ return `${restOp}.${stringifyValue(value)}`;
2335
+ }
2336
+ /**
2337
+ * Convert `FilterValues` to a PostgREST-style querystring record.
2338
+ *
2339
+ * - Single conditions produce a string value.
2340
+ * - Multiple conditions on the same field produce a string array (repeated params).
2341
+ *
2342
+ * @example
2343
+ * serializeFilter({ status: ["==", "active"] })
2344
+ * // → { status: "eq.active" }
2345
+ *
2346
+ * serializeFilter({ age: [[">=", 18], ["<", 65]] })
2347
+ * // → { age: ["gte.18", "lt.65"] }
2348
+ */
2349
+ function serializeFilter(filter) {
2350
+ const result = {};
2351
+ for (const [field, condition] of Object.entries(filter)) {
2352
+ if (condition === void 0) continue;
2353
+ if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) result[field] = condition.map(serializeTuple);
2354
+ else result[field] = serializeTuple(condition);
2355
+ }
2356
+ return result;
2357
+ }
2358
+ /**
2359
+ * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
2360
+ *
2361
+ * If the string doesn't match a known operator prefix, falls back to
2362
+ * `["==", originalString]` (treating the whole string as an equality value).
2363
+ */
2364
+ function deserializeSingle(raw) {
2365
+ const dotIndex = raw.indexOf(".");
2366
+ if (dotIndex === -1) return ["==", coerceValue(raw)];
2367
+ const prefix = raw.substring(0, dotIndex);
2368
+ const rest = raw.substring(dotIndex + 1);
2369
+ const canonicalOp = REST_TO_CANONICAL[prefix];
2370
+ if (!canonicalOp) return ["==", raw];
2371
+ if (rest.startsWith("(") && rest.endsWith(")")) return [canonicalOp, rest.slice(1, -1).split(",").map((s) => coerceValue(s.trim()))];
2372
+ return [canonicalOp, coerceValue(rest)];
2373
+ }
2374
+ /**
2375
+ * Convert a PostgREST-style querystring record to `FilterValues`.
2376
+ *
2377
+ * - String values are parsed as single conditions.
2378
+ * - String arrays (repeated query params) become multiple conditions on the same field.
2379
+ *
2380
+ * @example
2381
+ * deserializeFilter({ status: "eq.active" })
2382
+ * // → { status: ["==", "active"] }
2383
+ *
2384
+ * deserializeFilter({ age: ["gte.18", "lt.65"] })
2385
+ * // → { age: [[">=", 18], ["<", 65]] }
2386
+ */
2387
+ function deserializeFilter(query) {
2388
+ const result = {};
2389
+ for (const [field, raw] of Object.entries(query)) {
2390
+ if (raw === void 0) continue;
2391
+ if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && toCanonicalOp(raw[0]) === raw[0]) {
2392
+ result[field] = raw;
1993
2393
  continue;
1994
2394
  }
1995
- if (typeof rawValue === "string") {
1996
- const dotIndex = rawValue.indexOf(".");
1997
- if (dotIndex === -1) {
1998
- filter[field] = ["==", rawValue];
2395
+ if (Array.isArray(raw)) {
2396
+ if (raw.length === 0) continue;
2397
+ if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && toCanonicalOp(raw[0][0]) === raw[0][0]) {
2398
+ result[field] = raw;
1999
2399
  continue;
2000
2400
  }
2001
- const op = rawValue.substring(0, dotIndex);
2002
- let value = rawValue.substring(dotIndex + 1);
2003
- if (typeof value === "string" && value.startsWith("(") && value.endsWith(")")) value = value.slice(1, -1).split(",").map((v) => v.trim());
2004
- if (value === "null") value = null;
2005
- else if (value === "true") value = true;
2006
- else if (value === "false") value = false;
2007
- else if (typeof value === "string" && !isNaN(Number(value)) && value.trim() !== "") value = Number(value);
2008
- const mappedOp = operatorMap[op];
2009
- if (mappedOp) filter[field] = [mappedOp, value];
2401
+ if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
2402
+ else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
2403
+ else result[field] = ["in", raw];
2404
+ } else if (typeof raw === "string") result[field] = deserializeSingle(raw);
2405
+ else result[field] = ["==", raw];
2406
+ }
2407
+ return result;
2408
+ }
2409
+ /**
2410
+ * Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
2411
+ *
2412
+ * @example
2413
+ * serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
2414
+ * // → "status.eq.active"
2415
+ *
2416
+ * serializeLogicalCondition({ type: "or", conditions: [...] })
2417
+ * // → "or(status.eq.active,status.eq.pending)"
2418
+ */
2419
+ function serializeLogicalCondition(cond) {
2420
+ if ("type" in cond) {
2421
+ const inner = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
2422
+ return `${cond.type}(${inner})`;
2423
+ }
2424
+ const restOp = CANONICAL_TO_REST[cond.operator] || "eq";
2425
+ if (Array.isArray(cond.value)) {
2426
+ const items = cond.value.map(stringifyValue).join(",");
2427
+ return `${cond.column}.${restOp}.(${items})`;
2428
+ }
2429
+ return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
2430
+ }
2431
+ /**
2432
+ * Parse a logical condition wire-format string back into a
2433
+ * `LogicalCondition` or `FilterCondition`.
2434
+ *
2435
+ * @example
2436
+ * deserializeLogicalCondition("status.eq.active")
2437
+ * // → { column: "status", operator: "==", value: "active" }
2438
+ *
2439
+ * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
2440
+ * // → { type: "or", conditions: [...] }
2441
+ */
2442
+ function deserializeLogicalCondition(str) {
2443
+ const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
2444
+ if (logicalMatch) {
2445
+ const type = logicalMatch[1];
2446
+ const innerStr = logicalMatch[2];
2447
+ const conditions = [];
2448
+ let depth = 0;
2449
+ let start = 0;
2450
+ for (let i = 0; i < innerStr.length; i++) if (innerStr[i] === "(") depth++;
2451
+ else if (innerStr[i] === ")") depth--;
2452
+ else if (innerStr[i] === "," && depth === 0) {
2453
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
2454
+ start = i + 1;
2010
2455
  }
2456
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
2457
+ return {
2458
+ type,
2459
+ conditions
2460
+ };
2011
2461
  }
2012
- return Object.keys(filter).length > 0 ? filter : void 0;
2462
+ const firstDot = str.indexOf(".");
2463
+ if (firstDot === -1) return {
2464
+ column: str,
2465
+ operator: "==",
2466
+ value: true
2467
+ };
2468
+ const column = str.substring(0, firstDot);
2469
+ const rest = str.substring(firstDot + 1);
2470
+ const secondDot = rest.indexOf(".");
2471
+ if (secondDot === -1) return {
2472
+ column,
2473
+ operator: "==",
2474
+ value: coerceValue(rest)
2475
+ };
2476
+ const opStr = rest.substring(0, secondDot);
2477
+ let valueStr = rest.substring(secondDot + 1);
2478
+ const operator = toCanonicalOp(opStr) ?? "==";
2479
+ if (valueStr.startsWith("(") && valueStr.endsWith(")")) return {
2480
+ column,
2481
+ operator,
2482
+ value: valueStr.slice(1, -1).split(",").map((s) => coerceValue(s.trim()))
2483
+ };
2484
+ return {
2485
+ column,
2486
+ operator,
2487
+ value: coerceValue(valueStr)
2488
+ };
2013
2489
  }
2490
+ //#endregion
2491
+ //#region src/data/buildRebaseData.ts
2014
2492
  /**
2015
2493
  * Parse an orderBy string like "created_at:desc" into [field, direction].
2016
2494
  */
@@ -2023,11 +2501,12 @@ function createDriverAccessor(driver, slug) {
2023
2501
  const accessor = {
2024
2502
  async find(params) {
2025
2503
  const orderParsed = parseOrderBy(params?.orderBy);
2504
+ const filter = params?.where ? deserializeFilter(params.where) : void 0;
2026
2505
  const entities = await driver.fetchCollection({
2027
2506
  path: slug,
2028
2507
  limit: params?.limit,
2029
2508
  offset: params?.offset,
2030
- filter: convertWhereToFilter(params?.where),
2509
+ filter,
2031
2510
  orderBy: orderParsed?.[0],
2032
2511
  order: orderParsed?.[1],
2033
2512
  searchString: params?.searchString
@@ -2077,9 +2556,10 @@ function createDriverAccessor(driver, slug) {
2077
2556
  return driver.deleteAll(slug);
2078
2557
  } : void 0,
2079
2558
  count: driver.countEntities ? async (params) => {
2559
+ const filter = params?.where ? deserializeFilter(params.where) : void 0;
2080
2560
  return driver.countEntities({
2081
2561
  path: slug,
2082
- filter: convertWhereToFilter(params?.where)
2562
+ filter
2083
2563
  });
2084
2564
  } : void 0,
2085
2565
  listen: driver.listenCollection ? (params, onUpdate, onError) => {
@@ -2090,7 +2570,7 @@ function createDriverAccessor(driver, slug) {
2090
2570
  path: slug,
2091
2571
  limit: params?.limit,
2092
2572
  offset: params?.offset,
2093
- filter: convertWhereToFilter(params?.where),
2573
+ filter: params?.where,
2094
2574
  orderBy: orderParsed?.[0],
2095
2575
  order: orderParsed?.[1],
2096
2576
  searchString: params?.searchString,
@@ -2169,6 +2649,125 @@ function buildRebaseData(driver) {
2169
2649
  } });
2170
2650
  }
2171
2651
  //#endregion
2172
- export { COLLECTION_PATH_SEPARATOR, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, QueryBuilder, addInitialSlash, and, applyPropertyConditions, buildAdditionalFieldDelegate, buildCollection, buildConditionContext, buildEntityCallbacks, buildEnum, buildEnumValueConfig, buildProperties, buildPropertiesOrBuilder, buildProperty, buildPropertyCallbacks, buildRebaseData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, cond, createRelationRef, createRelationRefWithData, defaultUsersCollection, enumToObjectEntries, evaluateCondition, 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, normalizeToEntityRelation, or, registerConditionOperations, removeInitialAndTrailingSlashes, removeInitialSlash, removeTrailingSlash, resolveArrayProperties, resolveCollectionPathIds, resolveCollectionRelations, resolveDefaultSelectedView, resolveEnumValues, resolveProperties, resolveProperty, resolvePropertyEnum, resolvePropertyRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, sanitizeData, sanitizeRelation, segmentsToStrippedPath, sortProperties, stripCollectionPath, traverseValueProperty, traverseValuesProperties, updateDateAutoValues };
2652
+ //#region src/data/buildRoutedRebaseData.ts
2653
+ /**
2654
+ * Build a {@link RebaseData} that routes each collection to the right
2655
+ * backend based on its resolved data source.
2656
+ *
2657
+ * `.collection(path)` (and dynamic `data.products`-style access) resolves the
2658
+ * collection's data-source key via `resolveKey` and delegates to the matching
2659
+ * entry in `sources`, falling back to `defaultData` when there is no match.
2660
+ * Because routing keys off the *path being accessed*, a reference widget
2661
+ * inside a Firestore form that points at a Postgres collection is still
2662
+ * served by Postgres — routing follows the target, not the ancestor.
2663
+ *
2664
+ * When `sources` is empty this returns `defaultData` untouched, so the
2665
+ * single-driver setup keeps identical behaviour and identity (important for
2666
+ * effect dependencies that key off the data instance).
2667
+ *
2668
+ * @example
2669
+ * const data = buildRoutedRebaseData({
2670
+ * defaultData: client.data,
2671
+ * sources: { analytics: buildRebaseData(firestoreDriver) },
2672
+ * resolveKey: (path) => resolveDataSource(registry.getCollection(path), defs).key
2673
+ * });
2674
+ * await data.products.find(); // → default (server / Postgres)
2675
+ * await data.events.find(); // → Firestore, if `events.dataSource === "analytics"`
2676
+ */
2677
+ function buildRoutedRebaseData({ defaultData, sources, resolveKey }) {
2678
+ if (!sources || Object.keys(sources).length === 0) return defaultData;
2679
+ function resolve(slugOrPath) {
2680
+ const key = resolveKey(slugOrPath);
2681
+ if (key && sources[key]) return sources[key];
2682
+ return defaultData;
2683
+ }
2684
+ function getAccessor(slugOrPath) {
2685
+ return resolve(slugOrPath).collection(slugOrPath);
2686
+ }
2687
+ return new Proxy({ collection: getAccessor }, { get(_target, prop) {
2688
+ if (prop === "collection") return getAccessor;
2689
+ if (typeof prop === "symbol") return void 0;
2690
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return void 0;
2691
+ return getAccessor(toSnakeCase(prop));
2692
+ } });
2693
+ }
2694
+ //#endregion
2695
+ //#region src/table-classification.ts
2696
+ /** Schemas that are always considered Rebase-internal. */
2697
+ var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
2698
+ /** Table-name prefixes that mark a table as Rebase-internal regardless of schema. */
2699
+ var REBASE_INTERNAL_PREFIXES = [
2700
+ "_rebase_",
2701
+ "_auth_",
2702
+ "drizzle_"
2703
+ ];
2704
+ /**
2705
+ * Synchronously classify a table based on naming conventions.
2706
+ *
2707
+ * @param tableName - The unqualified name of the table.
2708
+ * @param schemaName - The schema the table belongs to (e.g. `"public"`, `"rebase"`).
2709
+ * @returns `"rebase-internal"` when the table belongs to a reserved schema or
2710
+ * carries a reserved prefix; `"user"` otherwise.
2711
+ *
2712
+ * @remarks
2713
+ * Junction-table detection requires an async database query and is therefore
2714
+ * **not** handled by this function. Use {@link detectJunctionTables} to obtain
2715
+ * the set of junction tables, then reclassify as needed.
2716
+ */
2717
+ function classifyTable(tableName, schemaName) {
2718
+ if (REBASE_INTERNAL_SCHEMAS.includes(schemaName) || REBASE_INTERNAL_PREFIXES.some((prefix) => tableName.startsWith(prefix))) return "rebase-internal";
2719
+ return "user";
2720
+ }
2721
+ /**
2722
+ * Convenience predicate that checks whether a table is Rebase-internal.
2723
+ *
2724
+ * @param tableName - The unqualified name of the table.
2725
+ * @param schemaName - The schema the table belongs to.
2726
+ * @returns `true` if the table is classified as `"rebase-internal"`.
2727
+ */
2728
+ function isRebaseInternalTable(tableName, schemaName) {
2729
+ return classifyTable(tableName, schemaName) === "rebase-internal";
2730
+ }
2731
+ /** SQL query that detects junction tables in the `public` schema. */
2732
+ var JUNCTION_TABLES_SQL = `
2733
+ SELECT t.table_name
2734
+ FROM information_schema.tables t
2735
+ WHERE t.table_schema = 'public'
2736
+ AND t.table_type = 'BASE TABLE'
2737
+ AND NOT EXISTS (
2738
+ SELECT 1
2739
+ FROM information_schema.columns c
2740
+ WHERE c.table_schema = t.table_schema
2741
+ AND c.table_name = t.table_name
2742
+ AND c.column_name NOT IN (
2743
+ SELECT kcu.column_name
2744
+ FROM information_schema.key_column_usage kcu
2745
+ JOIN information_schema.table_constraints tc
2746
+ ON tc.constraint_name = kcu.constraint_name
2747
+ AND tc.table_schema = kcu.table_schema
2748
+ WHERE tc.constraint_type = 'FOREIGN KEY'
2749
+ AND kcu.table_schema = t.table_schema
2750
+ AND kcu.table_name = t.table_name
2751
+ )
2752
+ )
2753
+ `;
2754
+ /**
2755
+ * Asynchronously detect junction (link) tables in the `public` schema.
2756
+ *
2757
+ * A junction table is defined as a table where **every** column participates in
2758
+ * at least one foreign-key constraint.
2759
+ *
2760
+ * @param executeSql - A callback that executes a raw SQL string and returns the
2761
+ * resulting rows.
2762
+ * @returns A `Set` containing the names of all detected junction tables.
2763
+ */
2764
+ async function detectJunctionTables(executeSql) {
2765
+ const rows = await executeSql(JUNCTION_TABLES_SQL);
2766
+ const junctionTables = /* @__PURE__ */ new Set();
2767
+ for (const row of rows) if (typeof row.table_name === "string") junctionTables.add(row.table_name);
2768
+ return junctionTables;
2769
+ }
2770
+ //#endregion
2771
+ 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, buildAdditionalFieldDelegate, buildCollection, buildConditionContext, buildEntityCallbacks, buildEnum, buildEnumValueConfig, buildProperties, buildPropertiesOrBuilder, buildProperty, buildPropertyCallbacks, buildRebaseData, buildRoutedRebaseData, canCreateEntity, canDeleteEntity, canEditEntity, canReadCollection, checkOperation, classifyTable, cond, createDataSourceRegistry, createRelationRef, createRelationRefWithData, defaultUsersCollection, defineCollection, deserializeFilter, deserializeLogicalCondition, 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, resolveProperties, resolveProperty, resolvePropertyEnum, resolvePropertyRelation, resolveRelationProperty, resolveStorageFilenameString, resolveStoragePathString, resolveStorageSource, sanitizeData, sanitizeRelation, securityRuleToConditions, segmentsToStrippedPath, serializeFilter, serializeLogicalCondition, sortProperties, stripCollectionPath, traverseValueProperty, traverseValuesProperties, updateDateAutoValues };
2173
2772
 
2174
2773
  //# sourceMappingURL=index.es.js.map