@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.umd.js CHANGED
@@ -365,7 +365,7 @@
365
365
  if (!newRelation.foreignKeyOnTarget) {
366
366
  let foundForeignKey = false;
367
367
  try {
368
- const targetRelations = (0, _rebasepro_types.getDataSourceCapabilities)(targetCollection.driver).supportsRelations ? targetCollection.relations || [] : [];
368
+ const targetRelations = (0, _rebasepro_types.getDataSourceCapabilities)(targetCollection.engine).supportsRelations ? targetCollection.relations || [] : [];
369
369
  for (const targetRel of targetRelations) if (targetRel.direction === "owning" && targetRel.cardinality === "one" && targetRel.localKey) try {
370
370
  if (targetRel.target().slug === sourceCollection.slug) {
371
371
  newRelation.foreignKeyOnTarget = targetRel.localKey;
@@ -381,7 +381,7 @@
381
381
  } else if (newRelation.cardinality === "many" && newRelation.direction === "inverse") {
382
382
  let isManyToManyInverse = false;
383
383
  if (newRelation.inverseRelationName && !newRelation.foreignKeyOnTarget) try {
384
- const targetRelations = (0, _rebasepro_types.getDataSourceCapabilities)(targetCollection.driver).supportsRelations ? targetCollection.relations || [] : [];
384
+ const targetRelations = (0, _rebasepro_types.getDataSourceCapabilities)(targetCollection.engine).supportsRelations ? targetCollection.relations || [] : [];
385
385
  for (const targetRel of targetRelations) if (targetRel.cardinality === "many" && (targetRel.direction === "owning" || !targetRel.direction) && targetRel.relationName === newRelation.inverseRelationName) {
386
386
  isManyToManyInverse = true;
387
387
  break;
@@ -416,11 +416,10 @@
416
416
  function resolveCollectionRelations(collection) {
417
417
  const cached = _resolvedRelationsCache.get(collection);
418
418
  if (cached) return cached;
419
- if (!(0, _rebasepro_types.getDataSourceCapabilities)(collection.driver).supportsRelations) return {};
420
- const relCollection = collection;
419
+ if (!(0, _rebasepro_types.getDataSourceCapabilities)(collection.engine).supportsRelations) return {};
421
420
  const relations = {};
422
421
  const registeredRelationNames = /* @__PURE__ */ new Set();
423
- if (relCollection.relations) relCollection.relations.forEach((relation) => {
422
+ if (collection.relations) collection.relations.forEach((relation) => {
424
423
  try {
425
424
  const normalizedRelation = sanitizeRelation(relation, collection);
426
425
  const relationKey = normalizedRelation.relationName;
@@ -467,7 +466,7 @@
467
466
  console.warn(`Unrecognized or missing relation target for property '${propertyKey}' in collection '${sourceCollection.slug}'`);
468
467
  }
469
468
  function getTableName(collection) {
470
- if ((0, _rebasepro_types.getDataSourceCapabilities)(collection.driver).supportsRelations) return collection.table ?? (0, _rebasepro_utils.toSnakeCase)(collection.slug) ?? (0, _rebasepro_utils.toSnakeCase)(collection.name);
469
+ if ((0, _rebasepro_types.getDataSourceCapabilities)(collection.engine).supportsRelations) return collection.table ?? (0, _rebasepro_utils.toSnakeCase)(collection.slug) ?? (0, _rebasepro_utils.toSnakeCase)(collection.name);
471
470
  return (0, _rebasepro_utils.toSnakeCase)(collection.slug) ?? (0, _rebasepro_utils.toSnakeCase)(collection.name);
472
471
  }
473
472
  function getTableVarName(tableName) {
@@ -672,8 +671,9 @@
672
671
  }
673
672
  function getSubcollections(collection) {
674
673
  if (collection.childCollections) return collection.childCollections() ?? [];
675
- if ((0, _rebasepro_types.getDataSourceCapabilities)(collection.driver).supportsSubcollections && collection.subcollections) return collection.subcollections() ?? [];
676
- if ((0, _rebasepro_types.getDataSourceCapabilities)(collection.driver).supportsRelations) {
674
+ const declaredSubcollections = (0, _rebasepro_types.getDeclaredSubcollections)(collection);
675
+ if ((0, _rebasepro_types.getDataSourceCapabilities)(collection.engine).supportsSubcollections && declaredSubcollections) return declaredSubcollections() ?? [];
676
+ if ((0, _rebasepro_types.getDataSourceCapabilities)(collection.engine).supportsRelations) {
677
677
  const resolvedRelations = resolveCollectionRelations(collection);
678
678
  return Object.values(resolvedRelations).filter((r) => r.cardinality === "many").map((r) => {
679
679
  const target = r.target();
@@ -699,113 +699,339 @@
699
699
  return [];
700
700
  }
701
701
  //#endregion
702
- //#region src/util/permissions.ts
703
- function evaluateAST(sqlString, auth, entity) {
704
- if (!entity) return true;
705
- let cleanedSQL = sqlString.trim();
706
- while (cleanedSQL.startsWith("(") && cleanedSQL.endsWith(")")) {
707
- let openCount = 0;
708
- let isEnclosing = true;
709
- for (let i = 0; i < cleanedSQL.length - 1; i++) {
710
- if (cleanedSQL[i] === "(") openCount++;
711
- else if (cleanedSQL[i] === ")") openCount--;
712
- if (openCount === 0) {
713
- isEnclosing = false;
714
- break;
715
- }
702
+ //#region src/util/policy/sqlToPolicy.ts
703
+ /**
704
+ * A tiny, regex-based SQL "parser" for security rules.
705
+ *
706
+ * This is NOT a full SQL parser. It is designed to handle the subset of SQL
707
+ * commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
708
+ * optimistic client-side UI decision.
709
+ *
710
+ * It handles:
711
+ * - `field = 'literal'`
712
+ * - `field != 'literal'`
713
+ * - `field = current_setting('app.user_id')`
714
+ * - `A AND B`
715
+ * - `true`
716
+ * - `IN (...)` (as optimistic true)
717
+ *
718
+ * For anything it doesn't understand, it returns a `raw` expression, which
719
+ * the evaluator treats as "unknown" (and usually optimistic true).
720
+ */
721
+ function sqlToPolicy(sql) {
722
+ const trimmed = sql.trim();
723
+ if (trimmed.toLowerCase() === "true") return _rebasepro_types.policy.true();
724
+ if (trimmed.toLowerCase() === "false") return _rebasepro_types.policy.false();
725
+ const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
726
+ if (overlapMatch) {
727
+ const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
728
+ return _rebasepro_types.policy.rolesOverlap(roles);
729
+ }
730
+ const containMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
731
+ if (containMatch) {
732
+ const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
733
+ return _rebasepro_types.policy.rolesContain(roles);
734
+ }
735
+ if (trimmed.toUpperCase().includes(" OR ")) {
736
+ const parts = trimmed.split(/ OR /i);
737
+ return _rebasepro_types.policy.or(...parts.map(sqlToPolicy));
738
+ }
739
+ if (trimmed.toUpperCase().includes(" AND ")) {
740
+ const parts = trimmed.split(/ AND /i);
741
+ return _rebasepro_types.policy.and(...parts.map(sqlToPolicy));
742
+ }
743
+ const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
744
+ if (match) {
745
+ const [, leftStr, op, rightStr] = match;
746
+ const left = parseOperand(leftStr.trim());
747
+ const right = parseOperand(rightStr.trim());
748
+ if (left && right) return _rebasepro_types.policy.compare(left, op === "=" ? "eq" : "neq", right);
749
+ }
750
+ return _rebasepro_types.policy.raw(sql);
751
+ }
752
+ function parseOperand(str) {
753
+ if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return _rebasepro_types.policy.authUid();
754
+ const stringMatch = str.match(/^'(.+)'$/);
755
+ if (stringMatch) return _rebasepro_types.policy.literal(stringMatch[1]);
756
+ if (/^\w+$/.test(str)) return _rebasepro_types.policy.field(str);
757
+ return null;
758
+ }
759
+ //#endregion
760
+ //#region src/util/policy/securityRuleToConditions.ts
761
+ /**
762
+ * Desugars a {@link SecurityRule} — its `access`/`ownerField`/`roles` shortcuts,
763
+ * structured `condition`/`check`, and raw `using`/`withCheck` — into a single
764
+ * normalized {@link PolicyExpression} pair.
765
+ *
766
+ * **This is the linchpin against drift:** both the Postgres DDL generators and
767
+ * the client-side evaluator consume this one function, so there is exactly one
768
+ * definition of what a rule means. In particular, application `roles` are folded
769
+ * into the expression here (AND'd with the base condition, matching how Postgres
770
+ * generates the clause) rather than being handled separately by each consumer.
771
+ */
772
+ function securityRuleToConditions(rule) {
773
+ return {
774
+ usingExpr: withRoles(baseUsing(rule), rule),
775
+ withCheckExpr: withRoles(baseWithCheck(rule), rule)
776
+ };
777
+ }
778
+ function baseUsing(rule) {
779
+ if (rule.condition) return rule.condition;
780
+ if (rule.using != null) return sqlToPolicy(rule.using);
781
+ if (rule.access === "public") return _rebasepro_types.policy.true();
782
+ if (rule.ownerField) return _rebasepro_types.policy.compare(_rebasepro_types.policy.field(rule.ownerField), "eq", _rebasepro_types.policy.authUid());
783
+ return null;
784
+ }
785
+ function baseWithCheck(rule) {
786
+ if (rule.check) return rule.check;
787
+ if (rule.withCheck != null) return sqlToPolicy(rule.withCheck);
788
+ return baseUsing(rule);
789
+ }
790
+ /**
791
+ * AND the base condition with an application-role check, or produce a roles-only
792
+ * condition when there is no base. Mirrors the Postgres generator so that a
793
+ * role-scoped restrictive rule denies exactly the same set of users on both
794
+ * sides.
795
+ */
796
+ function withRoles(base, rule) {
797
+ if (!rule.roles || rule.roles.length === 0) return base;
798
+ const rolesExpr = _rebasepro_types.policy.rolesOverlap(rule.roles);
799
+ if (rule.mode === "restrictive") return base ? _rebasepro_types.policy.or(_rebasepro_types.policy.not(rolesExpr), base) : _rebasepro_types.policy.not(rolesExpr);
800
+ return base ? _rebasepro_types.policy.and(base, rolesExpr) : rolesExpr;
801
+ }
802
+ //#endregion
803
+ //#region src/util/policy/policyToPostgres.ts
804
+ /**
805
+ * Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,
806
+ * suitable for a `USING (...)` / `WITH CHECK (...)` clause.
807
+ *
808
+ * This is one of the two consumers of the shared policy model (the other being
809
+ * {@link evaluatePolicy}); the Postgres schema generators call it so that DDL
810
+ * and the admin UI derive from the exact same expression.
811
+ */
812
+ function policyToPostgres(expr, collection) {
813
+ switch (expr.kind) {
814
+ case "true": return "true";
815
+ case "false": return "false";
816
+ case "and": return expr.operands.length === 0 ? "true" : expr.operands.map((o) => `(${policyToPostgres(o, collection)})`).join(" AND ");
817
+ case "or": return expr.operands.length === 0 ? "false" : expr.operands.map((o) => `(${policyToPostgres(o, collection)})`).join(" OR ");
818
+ case "not":
819
+ if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
820
+ return `NOT (${policyToPostgres(expr.operand, collection)})`;
821
+ case "compare": return `${operandToSql(expr.left, collection)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, collection)}`;
822
+ case "rolesOverlap": return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
823
+ case "rolesContain": return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
824
+ case "authenticated": return "auth.uid() IS NOT NULL";
825
+ case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => col);
826
+ }
827
+ }
828
+ var COMPARE_SQL = {
829
+ eq: "=",
830
+ neq: "!=",
831
+ lt: "<",
832
+ lte: "<=",
833
+ gt: ">",
834
+ gte: ">="
835
+ };
836
+ function operandToSql(operand, collection) {
837
+ switch (operand.kind) {
838
+ case "field": return resolveColumnName(operand.name, collection);
839
+ case "literal": return quoteLiteral(operand.value);
840
+ case "authUid": return "auth.uid()";
841
+ case "authRoles": return "string_to_array(auth.roles(), ',')";
842
+ }
843
+ }
844
+ function resolveColumnName(propName, collection) {
845
+ const prop = collection?.properties?.[propName];
846
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
847
+ return (0, _rebasepro_utils.toSnakeCase)(propName);
848
+ }
849
+ function quoteLiteral(value) {
850
+ if (value === null) return "NULL";
851
+ if (typeof value === "boolean") return value ? "true" : "false";
852
+ if (typeof value === "number") return String(value);
853
+ return `'${value.replace(/'/g, "''")}'`;
854
+ }
855
+ /** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
856
+ function rolesArraySql(roles) {
857
+ return `ARRAY[${[...roles].sort().map((r) => `'${r}'`).join(",")}]`;
858
+ }
859
+ //#endregion
860
+ //#region src/util/policy/evaluatePolicy.ts
861
+ /**
862
+ * Evaluates a {@link PolicyExpression} against a user + row, using three-valued
863
+ * (Kleene) logic so that `"unknown"` sub-results propagate soundly.
864
+ *
865
+ * This is the JavaScript twin of {@link policyToPostgres}: both derive from the
866
+ * same expression, so the admin UI matches database enforcement by construction
867
+ * for every non-raw rule.
868
+ */
869
+ function evaluatePolicy(expr, ctx) {
870
+ switch (expr.kind) {
871
+ case "true": return true;
872
+ case "false": return false;
873
+ case "and": return kleeneAnd$1(expr.operands.map((o) => evaluatePolicy(o, ctx)));
874
+ case "or": return kleeneOr(expr.operands.map((o) => evaluatePolicy(o, ctx)));
875
+ case "not": return kleeneNot(evaluatePolicy(expr.operand, ctx));
876
+ case "compare": return evaluateCompare(expr.op, expr.left, expr.right, ctx);
877
+ case "rolesOverlap": {
878
+ const userRoles = ctx.roles ?? [];
879
+ return expr.roles.some((r) => r === "public" || userRoles.includes(r));
716
880
  }
717
- if (isEnclosing) cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
718
- else break;
719
- }
720
- const splitByTopLevel = (str, delimiter) => {
721
- const parts = [];
722
- let current = "";
723
- let openCount = 0;
724
- let i = 0;
725
- while (i < str.length) {
726
- if (str[i] === "(") openCount++;
727
- else if (str[i] === ")") openCount--;
728
- if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
729
- parts.push(current);
730
- current = "";
731
- i += delimiter.length;
732
- } else {
733
- current += str[i];
734
- i++;
735
- }
881
+ case "rolesContain": {
882
+ const userRoles = ctx.roles ?? [];
883
+ return expr.roles.every((r) => r === "public" || userRoles.includes(r));
736
884
  }
737
- parts.push(current);
738
- return parts;
739
- };
740
- const orParts = splitByTopLevel(cleanedSQL, " OR ");
741
- if (orParts.length > 1) return orParts.some((part) => evaluateAST(part, auth, entity));
742
- const andParts = splitByTopLevel(cleanedSQL, " AND ");
743
- if (andParts.length > 1) return andParts.every((part) => evaluateAST(part, auth, entity));
744
- const upperSQL = cleanedSQL.toUpperCase();
745
- if (upperSQL.includes(" IN ") || upperSQL.includes(" EXISTS ")) return true;
746
- const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
747
- if (roleIntersectMatch && roleIntersectMatch[1]) {
748
- const requiredRoles = roleIntersectMatch[1].split(",").map((r) => r.trim().replace(/'/g, ""));
749
- const userRoles = auth.user?.roles || [];
750
- return requiredRoles.some((r) => userRoles.includes(r));
751
- }
752
- const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
753
- if (roleContainMatch && roleContainMatch[1]) {
754
- const requiredRoles = roleContainMatch[1].split(",").map((r) => r.trim().replace(/'/g, ""));
755
- const userRoles = auth.user?.roles || [];
756
- return requiredRoles.every((r) => userRoles.includes(r));
757
- }
758
- const pattern1 = /* @__PURE__ */ new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
759
- const pattern2 = /* @__PURE__ */ new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
760
- const match1 = cleanedSQL.match(pattern1);
761
- if (match1 && match1[1]) return entity.values[match1[1]] === auth.user?.uid;
762
- const match2 = cleanedSQL.match(pattern2);
763
- if (match2 && match2[1]) return entity.values[match2[1]] === auth.user?.uid;
764
- const simpleEqualityMatch = cleanedSQL.match(/^\{?([\w_]+)\}?\s*(=|!=)\s*'([^']+)'$/i);
765
- if (simpleEqualityMatch) {
766
- const field = simpleEqualityMatch[1];
767
- const operator = simpleEqualityMatch[2];
768
- const value = simpleEqualityMatch[3];
769
- const entityValue = entity.values[field];
770
- if (operator === "=") return entityValue === value;
771
- if (operator === "!=") return entityValue !== value;
885
+ case "authenticated": return ctx.uid != null;
886
+ case "raw": return "unknown";
772
887
  }
888
+ }
889
+ function kleeneAnd$1(values) {
890
+ if (values.some((v) => v === false)) return false;
891
+ if (values.some((v) => v === "unknown")) return "unknown";
773
892
  return true;
774
893
  }
775
- function evaluateRule(rule, auth, entity) {
776
- if (rule.access === "public") return true;
777
- if (rule.ownerField) {
778
- if (!entity) {} else if (entity.values[rule.ownerField] !== auth.user?.uid) return false;
894
+ function kleeneOr(values) {
895
+ if (values.some((v) => v === true)) return true;
896
+ if (values.some((v) => v === "unknown")) return "unknown";
897
+ return false;
898
+ }
899
+ function kleeneNot(value) {
900
+ if (value === "unknown") return "unknown";
901
+ return !value;
902
+ }
903
+ function resolveOperand(operand, ctx) {
904
+ switch (operand.kind) {
905
+ case "literal": return {
906
+ known: true,
907
+ value: operand.value
908
+ };
909
+ case "authUid": return {
910
+ known: true,
911
+ value: ctx.uid ?? null
912
+ };
913
+ case "authRoles": return {
914
+ known: true,
915
+ value: ctx.roles ?? []
916
+ };
917
+ case "field":
918
+ if (!ctx.entity) return { known: false };
919
+ return {
920
+ known: true,
921
+ value: ctx.entity.values[operand.name]
922
+ };
779
923
  }
780
- if (rule.using && !evaluateAST(rule.using, auth, entity)) return false;
781
- if (rule.withCheck && !evaluateAST(rule.withCheck, auth, entity)) return false;
924
+ }
925
+ function evaluateCompare(op, left, right, ctx) {
926
+ const l = resolveOperand(left, ctx);
927
+ const r = resolveOperand(right, ctx);
928
+ if (!l.known || !r.known) return "unknown";
929
+ const a = l.value;
930
+ const b = r.value;
931
+ if (a === null || b === null) {
932
+ if (op === "eq") return false;
933
+ if (op === "neq") return true;
934
+ return "unknown";
935
+ }
936
+ if (op === "eq") return a === b;
937
+ if (op === "neq") return a !== b;
938
+ if (typeof a === "string" && typeof b === "string") {
939
+ if (op === "lt") return a < b;
940
+ if (op === "lte") return a <= b;
941
+ if (op === "gt") return a > b;
942
+ if (op === "gte") return a >= b;
943
+ }
944
+ if (typeof a === "number" && typeof b === "number") {
945
+ if (op === "lt") return a < b;
946
+ if (op === "lte") return a <= b;
947
+ if (op === "gt") return a > b;
948
+ if (op === "gte") return a >= b;
949
+ }
950
+ if (typeof a === "bigint" && typeof b === "bigint") {
951
+ if (op === "lt") return a < b;
952
+ if (op === "lte") return a <= b;
953
+ if (op === "gt") return a > b;
954
+ if (op === "gte") return a >= b;
955
+ }
956
+ return "unknown";
957
+ }
958
+ //#endregion
959
+ //#region src/util/permissions.ts
960
+ /** Combine clause results with AND under three-valued (Kleene) logic. */
961
+ function kleeneAnd(values) {
962
+ if (values.some((v) => v === false)) return false;
963
+ if (values.some((v) => v === "unknown")) return "unknown";
782
964
  return true;
783
965
  }
784
- function checkOperation(collection, authContext, entity, targetOperation) {
785
- const securityRules = (0, _rebasepro_types.getDataSourceCapabilities)(collection.driver).supportsRLS ? collection.securityRules : void 0;
966
+ /** The operations a rule covers, mirroring the Postgres generator's resolution. */
967
+ function ruleOperations(rule) {
968
+ return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
969
+ }
970
+ function ruleApplies(rule, targetOperation) {
971
+ const ops = ruleOperations(rule);
972
+ return ops.includes(targetOperation) || ops.includes("all");
973
+ }
974
+ /**
975
+ * Evaluate a single rule for one operation, returning a tri-state.
976
+ *
977
+ * A `null` clause (the rule contributes no condition for a required clause)
978
+ * denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
979
+ * in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
980
+ * INSERT/UPDATE; both must pass for UPDATE.
981
+ */
982
+ function evaluateRuleForOperation(rule, ctx, targetOperation) {
983
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
984
+ const clause = (expr) => expr === null ? false : evaluatePolicy(expr, ctx);
985
+ const needsUsing = targetOperation !== "insert";
986
+ const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
987
+ const results = [];
988
+ if (needsUsing) results.push(clause(usingExpr));
989
+ if (needsWithCheck) results.push(clause(withCheckExpr));
990
+ return kleeneAnd(results);
991
+ }
992
+ function resolveTriState(value, onUnknown) {
993
+ if (value === "unknown") return onUnknown === "allow";
994
+ return value;
995
+ }
996
+ /**
997
+ * Decide whether an operation is permitted for a user on a (possibly null) row,
998
+ * by evaluating the collection's security rules with the shared policy model —
999
+ * the same model compiled to Postgres RLS DDL, so the decision matches database
1000
+ * enforcement for every non-raw rule.
1001
+ *
1002
+ * @param options.onUnknown how to treat rules that cannot be decided
1003
+ * client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
1004
+ * for optimistic UI gating; enforcement callers should pass `"deny"`.
1005
+ */
1006
+ function checkOperation(collection, authContext, entity, targetOperation, options) {
1007
+ const onUnknown = options?.onUnknown ?? "allow";
1008
+ const securityRules = (0, _rebasepro_types.getDataSourceCapabilities)(collection.engine).supportsRLS ? collection.securityRules : void 0;
786
1009
  if (!securityRules || securityRules.length === 0) return true;
787
- const applicableRules = securityRules.filter((r) => r.operation === targetOperation || r.operation === "all" || r.operations?.includes(targetOperation) || r.operations?.includes("all"));
1010
+ const applicableRules = securityRules.filter((r) => ruleApplies(r, targetOperation));
788
1011
  if (applicableRules.length === 0) return false;
789
- const userRoles = [...authContext.user?.roles ?? [], "public"];
790
- const roleApplicableRules = applicableRules.filter((rule) => {
791
- if (!rule.roles || rule.roles.length === 0) return true;
792
- return rule.roles.some((r) => userRoles.includes(r));
793
- });
794
- if (roleApplicableRules.length === 0) return false;
1012
+ const ctx = {
1013
+ uid: authContext.user?.uid,
1014
+ roles: authContext.user?.roles ?? [],
1015
+ entity
1016
+ };
795
1017
  let grantedByPermissive = false;
796
1018
  let deniedByRestrictive = false;
797
- for (const rule of roleApplicableRules) {
1019
+ let hasPermissive = false;
1020
+ for (const rule of applicableRules) {
798
1021
  const mode = rule.mode || "permissive";
799
- const passed = evaluateRule(rule, authContext, entity);
800
- if (mode === "restrictive" && !passed) {
801
- deniedByRestrictive = true;
802
- break;
1022
+ const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
1023
+ if (mode === "restrictive") {
1024
+ if (!passed) {
1025
+ deniedByRestrictive = true;
1026
+ break;
1027
+ }
1028
+ } else {
1029
+ hasPermissive = true;
1030
+ if (passed) grantedByPermissive = true;
803
1031
  }
804
- if (mode === "permissive" && passed) grantedByPermissive = true;
805
1032
  }
806
1033
  if (deniedByRestrictive) return false;
807
- if (roleApplicableRules.some((r) => (r.mode || "permissive") === "permissive")) return grantedByPermissive;
808
- else return false;
1034
+ return hasPermissive ? grantedByPermissive : false;
809
1035
  }
810
1036
  function canReadCollection(collection, authContext) {
811
1037
  return checkOperation(collection, authContext, null, "select");
@@ -836,7 +1062,7 @@
836
1062
  }
837
1063
  for (const key in collection.properties) {
838
1064
  const property = collection.properties[key];
839
- if (property.type === "array" && property.of && !Array.isArray(property.of) && property.of.type === "string" && property.of.url === "image") return key;
1065
+ if (property.type === "array" && property.of && !Array.isArray(property.of) && property.of.type === "string" && property.of.ui?.url === "image") return key;
840
1066
  }
841
1067
  for (const key in collection.properties) {
842
1068
  const property = collection.properties[key];
@@ -1070,6 +1296,13 @@
1070
1296
  return collection;
1071
1297
  }
1072
1298
  /**
1299
+ * Implementation — delegates to the correct overload at the type level.
1300
+ * At runtime this is a plain identity function.
1301
+ */
1302
+ function defineCollection(collection) {
1303
+ return collection;
1304
+ }
1305
+ /**
1073
1306
  * Identity function we use to defeat the type system of Typescript and preserve
1074
1307
  * the property keys.
1075
1308
  * @param property
@@ -1134,6 +1367,29 @@
1134
1367
  }
1135
1368
  //#endregion
1136
1369
  //#region src/util/storage.ts
1370
+ /**
1371
+ * Resolve the {@link StorageSource} to use for a property, given the key
1372
+ * referenced by `StorageConfig.storageSource`.
1373
+ *
1374
+ * Resolution priority:
1375
+ * 1. No `sourceKey` → the default source (backward compatible).
1376
+ * 2. An explicit {@link StorageSourceRegistry} (e.g. `client.storageRegistry`).
1377
+ * 3. A `sources` lookup map (e.g. the `StorageSourcesContext`).
1378
+ * 4. Fall back to the default source.
1379
+ *
1380
+ * Shared by the upload hook, the markdown editor, and the read-only previews
1381
+ * so the resolution logic lives in one place.
1382
+ *
1383
+ * @group Storage
1384
+ */
1385
+ function resolveStorageSource(params) {
1386
+ const { sourceKey, sources, registry, defaultSource } = params;
1387
+ if (!sourceKey) return defaultSource;
1388
+ if (registry) return registry.getOrDefault(sourceKey);
1389
+ const fromSources = sources?.[sourceKey];
1390
+ if (fromSources) return fromSources;
1391
+ return defaultSource;
1392
+ }
1137
1393
  async function resolveStorageFilenameString({ input, storage, values, entityId, path, property, file, propertyKey }) {
1138
1394
  let result;
1139
1395
  if (typeof input === "function") {
@@ -1440,8 +1696,76 @@
1440
1696
  return result;
1441
1697
  }
1442
1698
  //#endregion
1699
+ //#region src/data/resolveDataSource.ts
1700
+ /**
1701
+ * Build a keyed registry from a list of {@link DataSourceDefinition}s.
1702
+ * Later entries win on key collision.
1703
+ */
1704
+ function createDataSourceRegistry(definitions) {
1705
+ const registry = {};
1706
+ for (const def of definitions ?? []) registry[def.key] = def;
1707
+ return registry;
1708
+ }
1709
+ /**
1710
+ * Resolve the effective data source for a collection — the single source of
1711
+ * truth shared by the frontend router, the backend driver registry, and the
1712
+ * editor's capability lookups.
1713
+ *
1714
+ * Resolution order:
1715
+ * 1. The routing **key** is `collection.dataSource`, else
1716
+ * {@link DEFAULT_DATA_SOURCE_KEY}.
1717
+ * 2. If a definition is registered for that key, it provides `engine`,
1718
+ * `transport`, and `databaseId`.
1719
+ * 3. Otherwise values are synthesized: `engine` from `collection.engine`
1720
+ * (or the key, or `"postgres"`), `transport` defaults to `"server"`,
1721
+ * and `databaseId` from the collection.
1722
+ *
1723
+ * `capabilities` are always derived from the resolved `engine`, so two
1724
+ * data sources sharing an engine share capabilities.
1725
+ *
1726
+ * @param collection the collection (or any object carrying the routing fields)
1727
+ * @param registry optional registry of declared data sources
1728
+ */
1729
+ function resolveDataSource(collection, registry) {
1730
+ const key = collection?.dataSource ?? _rebasepro_types.DEFAULT_DATA_SOURCE_KEY;
1731
+ const def = registry?.[key];
1732
+ const engine = def?.engine ?? collection?.engine ?? (key !== _rebasepro_types.DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
1733
+ return {
1734
+ key,
1735
+ engine,
1736
+ transport: def?.transport ?? "server",
1737
+ databaseId: collection?.databaseId ?? def?.databaseId,
1738
+ capabilities: (0, _rebasepro_types.getDataSourceCapabilities)(engine)
1739
+ };
1740
+ }
1741
+ //#endregion
1443
1742
  //#region src/collections/CollectionRegistry.ts
1444
1743
  var CollectionRegistry = class {
1744
+ /**
1745
+ * Declared data sources, used during normalization to resolve each
1746
+ * collection's engine (so `dataSource`-only collections get the right
1747
+ * capabilities). Empty by default.
1748
+ */
1749
+ dataSources = {};
1750
+ /**
1751
+ * Global lifecycle callbacks applied to every collection.
1752
+ * Runs on all data paths (REST, WebSocket, `rebase.data`).
1753
+ * Execution order: global → collection → property callbacks.
1754
+ */
1755
+ _globalCallbacks;
1756
+ /**
1757
+ * Set global lifecycle callbacks that apply to every collection.
1758
+ * Typically called once during backend initialization.
1759
+ */
1760
+ setGlobalCallbacks(callbacks) {
1761
+ this._globalCallbacks = callbacks;
1762
+ }
1763
+ /**
1764
+ * Get the currently registered global callbacks, if any.
1765
+ */
1766
+ getGlobalCallbacks() {
1767
+ return this._globalCallbacks;
1768
+ }
1445
1769
  collectionsByTableName = /* @__PURE__ */ new Map();
1446
1770
  collectionsBySlug = /* @__PURE__ */ new Map();
1447
1771
  rootCollections = [];
@@ -1451,9 +1775,20 @@
1451
1775
  rawRootCollections = [];
1452
1776
  cachedRawCollectionsList = null;
1453
1777
  lastRawInputSnapshot = null;
1454
- constructor(collections) {
1778
+ constructor(collections, dataSources) {
1779
+ if (dataSources) this.dataSources = dataSources;
1455
1780
  if (collections) this.registerMultiple(collections);
1456
1781
  }
1782
+ /**
1783
+ * Provide the declared data sources used to resolve each collection's
1784
+ * engine during normalization. Set this before registering collections.
1785
+ * Returns true if the registry changed (callers may re-register).
1786
+ */
1787
+ setDataSources(dataSources) {
1788
+ if ((0, fast_equals.deepEqual)(this.dataSources, dataSources)) return false;
1789
+ this.dataSources = dataSources ?? {};
1790
+ return true;
1791
+ }
1457
1792
  reset() {
1458
1793
  this.collectionsByTableName.clear();
1459
1794
  this.collectionsBySlug.clear();
@@ -1522,9 +1857,14 @@
1522
1857
  }
1523
1858
  normalizeCollection(collection) {
1524
1859
  const result = { ...collection };
1860
+ {
1861
+ const resolved = resolveDataSource(result, this.dataSources);
1862
+ if (!result.dataSource) result.dataSource = resolved.key;
1863
+ if (!result.engine) result.engine = resolved.engine;
1864
+ }
1525
1865
  const extractedRelations = this.extractRelationsFromProperties(result.properties);
1526
1866
  const relResult = result;
1527
- const manualRelations = (0, _rebasepro_types.getDataSourceCapabilities)(result.driver).supportsRelations ? relResult.relations ?? [] : [];
1867
+ const manualRelations = (0, _rebasepro_types.getDataSourceCapabilities)(result.engine).supportsRelations ? relResult.relations ?? [] : [];
1528
1868
  const mergedRelationsRaw = [...extractedRelations];
1529
1869
  for (const manual of manualRelations) {
1530
1870
  const name = manual.relationName;
@@ -1539,7 +1879,7 @@
1539
1879
  }
1540
1880
  }
1541
1881
  let mergedRelations = mergedRelationsRaw;
1542
- if ((0, _rebasepro_types.getDataSourceCapabilities)(result.driver).supportsRelations) {
1882
+ if ((0, _rebasepro_types.getDataSourceCapabilities)(result.engine).supportsRelations) {
1543
1883
  mergedRelations = mergedRelationsRaw.map((r) => {
1544
1884
  try {
1545
1885
  return sanitizeRelation(r, result, (slug) => this.get(slug));
@@ -1551,8 +1891,10 @@
1551
1891
  }
1552
1892
  result.properties = this.normalizeProperties(result.properties, mergedRelations);
1553
1893
  if (!result.childCollections) {
1554
- if ((0, _rebasepro_types.getDataSourceCapabilities)(result.driver).supportsSubcollections && result.subcollections) result.childCollections = result.subcollections;
1555
- else if ((0, _rebasepro_types.getDataSourceCapabilities)(result.driver).supportsRelations && relResult.relations) {
1894
+ const capabilities = (0, _rebasepro_types.getDataSourceCapabilities)(result.engine);
1895
+ const declaredSubcollections = (0, _rebasepro_types.getDeclaredSubcollections)(result);
1896
+ if (capabilities.supportsSubcollections && declaredSubcollections) result.childCollections = declaredSubcollections;
1897
+ else if (capabilities.supportsRelations && relResult.relations) {
1556
1898
  const manyRelations = relResult.relations.filter((r) => r.cardinality === "many");
1557
1899
  if (manyRelations.length > 0) result.childCollections = () => manyRelations.map((r) => {
1558
1900
  const target = r.target();
@@ -1654,7 +1996,7 @@
1654
1996
  if (!currentCollection) throw new Error(`Root collection not found: ${rootCollectionPath}`);
1655
1997
  for (let i = 2; i < pathSegments.length; i += 2) {
1656
1998
  const relationKey = pathSegments[i];
1657
- if (!(0, _rebasepro_types.getDataSourceCapabilities)(currentCollection.driver).supportsRelations) throw new Error(`Relation path navigation requires a collection that supports relations, but '${currentCollection.slug}' uses driver '${currentCollection.driver}'`);
1999
+ if (!(0, _rebasepro_types.getDataSourceCapabilities)(currentCollection.engine).supportsRelations) throw new Error(`Relation path navigation requires a collection that supports relations, but '${currentCollection.slug}' uses engine '${currentCollection.engine}'`);
1658
2000
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
1659
2001
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
1660
2002
  const target = relation.target();
@@ -1715,7 +2057,7 @@
1715
2057
  * Slug-based dedup (Map keyed by slug, last-write-wins) lets developers
1716
2058
  * override by defining their own collection with `slug: "users"`.
1717
2059
  */
1718
- var defaultUsersCollection = {
2060
+ var defaultUsersCollection = defineCollection({
1719
2061
  name: "Users",
1720
2062
  singularName: "User",
1721
2063
  slug: "users",
@@ -1763,7 +2105,7 @@
1763
2105
  name: "Photo URL",
1764
2106
  type: "string",
1765
2107
  columnName: "photo_url",
1766
- url: "image"
2108
+ ui: { url: "image" }
1767
2109
  },
1768
2110
  roles: {
1769
2111
  name: "Roles",
@@ -1858,7 +2200,7 @@
1858
2200
  "roles",
1859
2201
  "createdAt"
1860
2202
  ]
1861
- };
2203
+ });
1862
2204
  //#endregion
1863
2205
  //#region src/data/query_builder.ts
1864
2206
  function or(...conditions) {
@@ -1910,8 +2252,8 @@
1910
2252
  * @example
1911
2253
  * client.collection('users').orderBy('createdAt', 'desc').find()
1912
2254
  */
1913
- orderBy(column, ascending = "asc") {
1914
- this.params.orderBy = `${column}:${ascending}`;
2255
+ orderBy(column, direction = "asc") {
2256
+ this.params.orderBy = `${column}:${direction}`;
1915
2257
  return this;
1916
2258
  }
1917
2259
  /**
@@ -1966,80 +2308,216 @@
1966
2308
  }
1967
2309
  };
1968
2310
  //#endregion
1969
- //#region src/data/buildRebaseData.ts
2311
+ //#region src/data/filter-dialect.ts
1970
2312
  /**
1971
- * Convert where-clause filter object to the internal DataDriver FilterValues format.
2313
+ * REST wire-format adapter for the unified filter system.
1972
2314
  *
1973
- * Supports multiple value formats:
1974
- * - PostgREST string: { status: "eq.published", age: "gte.18" }
1975
- * - Equality shorthand: { company_profile_id: null, status: "active", age: 18 }
1976
- * - Tuple syntax: { age: [">=", 18], role: ["in", ["admin", "editor"]] }
2315
+ * This module is the ONLY code in the entire codebase that knows about
2316
+ * PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
2317
+ * Everything else speaks `FilterValues` exclusively.
1977
2318
  *
1978
- * Internal: { status: ["==", "published"], age: [">=", 18] }
2319
+ * @module
1979
2320
  */
1980
- function convertWhereToFilter(where) {
1981
- if (!where) return void 0;
1982
- const operatorMap = {
1983
- "eq": "==",
1984
- "neq": "!=",
1985
- "gt": ">",
1986
- "gte": ">=",
1987
- "lt": "<",
1988
- "lte": "<=",
1989
- "in": "in",
1990
- "nin": "not-in",
1991
- "not-in": "not-in",
1992
- "cs": "array-contains",
1993
- "csa": "array-contains-any",
1994
- "==": "==",
1995
- "!=": "!=",
1996
- ">": ">",
1997
- ">=": ">=",
1998
- "<": "<",
1999
- "<=": "<=",
2000
- "array-contains": "array-contains",
2001
- "array-contains-any": "array-contains-any"
2002
- };
2003
- const filter = {};
2004
- for (const [field, rawValue] of Object.entries(where)) {
2005
- if (rawValue === null) {
2006
- filter[field] = ["==", null];
2007
- continue;
2008
- }
2009
- if (typeof rawValue === "boolean") {
2010
- filter[field] = ["==", rawValue];
2011
- continue;
2012
- }
2013
- if (typeof rawValue === "number") {
2014
- filter[field] = ["==", rawValue];
2015
- continue;
2321
+ /**
2322
+ * Coerce a raw querystring value to its natural JS type.
2323
+ * - `"true"` / `"false"` → boolean
2324
+ * - `"null"` → null
2325
+ * - Numeric strings → number
2326
+ * - Everything else → string (unchanged)
2327
+ */
2328
+ function coerceValue(raw) {
2329
+ if (raw === "true") return true;
2330
+ if (raw === "false") return false;
2331
+ if (raw === "null") return null;
2332
+ if (raw !== "" && !isNaN(Number(raw))) return Number(raw);
2333
+ return raw;
2334
+ }
2335
+ /**
2336
+ * Serialize a JS value to its querystring representation.
2337
+ */
2338
+ function stringifyValue(value) {
2339
+ if (value === null) return "null";
2340
+ if (typeof value === "boolean") return String(value);
2341
+ return String(value);
2342
+ }
2343
+ /**
2344
+ * Serialize a single condition tuple to a PostgREST dot-string.
2345
+ *
2346
+ * @example
2347
+ * serializeTuple(["==", "active"]) // "eq.active"
2348
+ * serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
2349
+ * serializeTuple([">=", 18]) // "gte.18"
2350
+ */
2351
+ function serializeTuple(tuple) {
2352
+ if (typeof tuple === "string") {
2353
+ if (tuple.includes(".")) {
2354
+ const dotIndex = tuple.indexOf(".");
2355
+ if (_rebasepro_types.REST_TO_CANONICAL[tuple.substring(0, dotIndex)]) return tuple;
2016
2356
  }
2017
- if (Array.isArray(rawValue)) {
2018
- const mappedConditions = (Array.isArray(rawValue[0]) ? rawValue : [rawValue]).map(([rawOp, val]) => {
2019
- return [operatorMap[rawOp] ?? "==", val];
2020
- });
2021
- filter[field] = Array.isArray(rawValue[0]) ? mappedConditions : mappedConditions[0];
2357
+ return tuple;
2358
+ }
2359
+ if (!Array.isArray(tuple) || tuple.length !== 2 || typeof tuple[0] !== "string" || !_rebasepro_types.CANONICAL_TO_REST[tuple[0]]) return `eq.${stringifyValue(tuple)}`;
2360
+ const [op, value] = tuple;
2361
+ const restOp = _rebasepro_types.CANONICAL_TO_REST[op];
2362
+ if (Array.isArray(value)) return `${restOp}.(${value.map(stringifyValue).join(",")})`;
2363
+ return `${restOp}.${stringifyValue(value)}`;
2364
+ }
2365
+ /**
2366
+ * Convert `FilterValues` to a PostgREST-style querystring record.
2367
+ *
2368
+ * - Single conditions produce a string value.
2369
+ * - Multiple conditions on the same field produce a string array (repeated params).
2370
+ *
2371
+ * @example
2372
+ * serializeFilter({ status: ["==", "active"] })
2373
+ * // → { status: "eq.active" }
2374
+ *
2375
+ * serializeFilter({ age: [[">=", 18], ["<", 65]] })
2376
+ * // → { age: ["gte.18", "lt.65"] }
2377
+ */
2378
+ function serializeFilter(filter) {
2379
+ const result = {};
2380
+ for (const [field, condition] of Object.entries(filter)) {
2381
+ if (condition === void 0) continue;
2382
+ if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) result[field] = condition.map(serializeTuple);
2383
+ else result[field] = serializeTuple(condition);
2384
+ }
2385
+ return result;
2386
+ }
2387
+ /**
2388
+ * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
2389
+ *
2390
+ * If the string doesn't match a known operator prefix, falls back to
2391
+ * `["==", originalString]` (treating the whole string as an equality value).
2392
+ */
2393
+ function deserializeSingle(raw) {
2394
+ const dotIndex = raw.indexOf(".");
2395
+ if (dotIndex === -1) return ["==", coerceValue(raw)];
2396
+ const prefix = raw.substring(0, dotIndex);
2397
+ const rest = raw.substring(dotIndex + 1);
2398
+ const canonicalOp = _rebasepro_types.REST_TO_CANONICAL[prefix];
2399
+ if (!canonicalOp) return ["==", raw];
2400
+ if (rest.startsWith("(") && rest.endsWith(")")) return [canonicalOp, rest.slice(1, -1).split(",").map((s) => coerceValue(s.trim()))];
2401
+ return [canonicalOp, coerceValue(rest)];
2402
+ }
2403
+ /**
2404
+ * Convert a PostgREST-style querystring record to `FilterValues`.
2405
+ *
2406
+ * - String values are parsed as single conditions.
2407
+ * - String arrays (repeated query params) become multiple conditions on the same field.
2408
+ *
2409
+ * @example
2410
+ * deserializeFilter({ status: "eq.active" })
2411
+ * // → { status: ["==", "active"] }
2412
+ *
2413
+ * deserializeFilter({ age: ["gte.18", "lt.65"] })
2414
+ * // → { age: [[">=", 18], ["<", 65]] }
2415
+ */
2416
+ function deserializeFilter(query) {
2417
+ const result = {};
2418
+ for (const [field, raw] of Object.entries(query)) {
2419
+ if (raw === void 0) continue;
2420
+ if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && (0, _rebasepro_types.toCanonicalOp)(raw[0]) === raw[0]) {
2421
+ result[field] = raw;
2022
2422
  continue;
2023
2423
  }
2024
- if (typeof rawValue === "string") {
2025
- const dotIndex = rawValue.indexOf(".");
2026
- if (dotIndex === -1) {
2027
- filter[field] = ["==", rawValue];
2424
+ if (Array.isArray(raw)) {
2425
+ if (raw.length === 0) continue;
2426
+ if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && (0, _rebasepro_types.toCanonicalOp)(raw[0][0]) === raw[0][0]) {
2427
+ result[field] = raw;
2028
2428
  continue;
2029
2429
  }
2030
- const op = rawValue.substring(0, dotIndex);
2031
- let value = rawValue.substring(dotIndex + 1);
2032
- if (typeof value === "string" && value.startsWith("(") && value.endsWith(")")) value = value.slice(1, -1).split(",").map((v) => v.trim());
2033
- if (value === "null") value = null;
2034
- else if (value === "true") value = true;
2035
- else if (value === "false") value = false;
2036
- else if (typeof value === "string" && !isNaN(Number(value)) && value.trim() !== "") value = Number(value);
2037
- const mappedOp = operatorMap[op];
2038
- if (mappedOp) filter[field] = [mappedOp, value];
2430
+ if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
2431
+ else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
2432
+ else result[field] = ["in", raw];
2433
+ } else if (typeof raw === "string") result[field] = deserializeSingle(raw);
2434
+ else result[field] = ["==", raw];
2435
+ }
2436
+ return result;
2437
+ }
2438
+ /**
2439
+ * Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
2440
+ *
2441
+ * @example
2442
+ * serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
2443
+ * // → "status.eq.active"
2444
+ *
2445
+ * serializeLogicalCondition({ type: "or", conditions: [...] })
2446
+ * // → "or(status.eq.active,status.eq.pending)"
2447
+ */
2448
+ function serializeLogicalCondition(cond) {
2449
+ if ("type" in cond) {
2450
+ const inner = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
2451
+ return `${cond.type}(${inner})`;
2452
+ }
2453
+ const restOp = _rebasepro_types.CANONICAL_TO_REST[cond.operator] || "eq";
2454
+ if (Array.isArray(cond.value)) {
2455
+ const items = cond.value.map(stringifyValue).join(",");
2456
+ return `${cond.column}.${restOp}.(${items})`;
2457
+ }
2458
+ return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
2459
+ }
2460
+ /**
2461
+ * Parse a logical condition wire-format string back into a
2462
+ * `LogicalCondition` or `FilterCondition`.
2463
+ *
2464
+ * @example
2465
+ * deserializeLogicalCondition("status.eq.active")
2466
+ * // → { column: "status", operator: "==", value: "active" }
2467
+ *
2468
+ * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
2469
+ * // → { type: "or", conditions: [...] }
2470
+ */
2471
+ function deserializeLogicalCondition(str) {
2472
+ const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
2473
+ if (logicalMatch) {
2474
+ const type = logicalMatch[1];
2475
+ const innerStr = logicalMatch[2];
2476
+ const conditions = [];
2477
+ let depth = 0;
2478
+ let start = 0;
2479
+ for (let i = 0; i < innerStr.length; i++) if (innerStr[i] === "(") depth++;
2480
+ else if (innerStr[i] === ")") depth--;
2481
+ else if (innerStr[i] === "," && depth === 0) {
2482
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
2483
+ start = i + 1;
2039
2484
  }
2485
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
2486
+ return {
2487
+ type,
2488
+ conditions
2489
+ };
2040
2490
  }
2041
- return Object.keys(filter).length > 0 ? filter : void 0;
2491
+ const firstDot = str.indexOf(".");
2492
+ if (firstDot === -1) return {
2493
+ column: str,
2494
+ operator: "==",
2495
+ value: true
2496
+ };
2497
+ const column = str.substring(0, firstDot);
2498
+ const rest = str.substring(firstDot + 1);
2499
+ const secondDot = rest.indexOf(".");
2500
+ if (secondDot === -1) return {
2501
+ column,
2502
+ operator: "==",
2503
+ value: coerceValue(rest)
2504
+ };
2505
+ const opStr = rest.substring(0, secondDot);
2506
+ let valueStr = rest.substring(secondDot + 1);
2507
+ const operator = (0, _rebasepro_types.toCanonicalOp)(opStr) ?? "==";
2508
+ if (valueStr.startsWith("(") && valueStr.endsWith(")")) return {
2509
+ column,
2510
+ operator,
2511
+ value: valueStr.slice(1, -1).split(",").map((s) => coerceValue(s.trim()))
2512
+ };
2513
+ return {
2514
+ column,
2515
+ operator,
2516
+ value: coerceValue(valueStr)
2517
+ };
2042
2518
  }
2519
+ //#endregion
2520
+ //#region src/data/buildRebaseData.ts
2043
2521
  /**
2044
2522
  * Parse an orderBy string like "created_at:desc" into [field, direction].
2045
2523
  */
@@ -2052,11 +2530,12 @@
2052
2530
  const accessor = {
2053
2531
  async find(params) {
2054
2532
  const orderParsed = parseOrderBy(params?.orderBy);
2533
+ const filter = params?.where ? deserializeFilter(params.where) : void 0;
2055
2534
  const entities = await driver.fetchCollection({
2056
2535
  path: slug,
2057
2536
  limit: params?.limit,
2058
2537
  offset: params?.offset,
2059
- filter: convertWhereToFilter(params?.where),
2538
+ filter,
2060
2539
  orderBy: orderParsed?.[0],
2061
2540
  order: orderParsed?.[1],
2062
2541
  searchString: params?.searchString
@@ -2106,9 +2585,10 @@
2106
2585
  return driver.deleteAll(slug);
2107
2586
  } : void 0,
2108
2587
  count: driver.countEntities ? async (params) => {
2588
+ const filter = params?.where ? deserializeFilter(params.where) : void 0;
2109
2589
  return driver.countEntities({
2110
2590
  path: slug,
2111
- filter: convertWhereToFilter(params?.where)
2591
+ filter
2112
2592
  });
2113
2593
  } : void 0,
2114
2594
  listen: driver.listenCollection ? (params, onUpdate, onError) => {
@@ -2119,7 +2599,7 @@
2119
2599
  path: slug,
2120
2600
  limit: params?.limit,
2121
2601
  offset: params?.offset,
2122
- filter: convertWhereToFilter(params?.where),
2602
+ filter: params?.where,
2123
2603
  orderBy: orderParsed?.[0],
2124
2604
  order: orderParsed?.[1],
2125
2605
  searchString: params?.searchString,
@@ -2198,11 +2678,133 @@
2198
2678
  } });
2199
2679
  }
2200
2680
  //#endregion
2681
+ //#region src/data/buildRoutedRebaseData.ts
2682
+ /**
2683
+ * Build a {@link RebaseData} that routes each collection to the right
2684
+ * backend based on its resolved data source.
2685
+ *
2686
+ * `.collection(path)` (and dynamic `data.products`-style access) resolves the
2687
+ * collection's data-source key via `resolveKey` and delegates to the matching
2688
+ * entry in `sources`, falling back to `defaultData` when there is no match.
2689
+ * Because routing keys off the *path being accessed*, a reference widget
2690
+ * inside a Firestore form that points at a Postgres collection is still
2691
+ * served by Postgres — routing follows the target, not the ancestor.
2692
+ *
2693
+ * When `sources` is empty this returns `defaultData` untouched, so the
2694
+ * single-driver setup keeps identical behaviour and identity (important for
2695
+ * effect dependencies that key off the data instance).
2696
+ *
2697
+ * @example
2698
+ * const data = buildRoutedRebaseData({
2699
+ * defaultData: client.data,
2700
+ * sources: { analytics: buildRebaseData(firestoreDriver) },
2701
+ * resolveKey: (path) => resolveDataSource(registry.getCollection(path), defs).key
2702
+ * });
2703
+ * await data.products.find(); // → default (server / Postgres)
2704
+ * await data.events.find(); // → Firestore, if `events.dataSource === "analytics"`
2705
+ */
2706
+ function buildRoutedRebaseData({ defaultData, sources, resolveKey }) {
2707
+ if (!sources || Object.keys(sources).length === 0) return defaultData;
2708
+ function resolve(slugOrPath) {
2709
+ const key = resolveKey(slugOrPath);
2710
+ if (key && sources[key]) return sources[key];
2711
+ return defaultData;
2712
+ }
2713
+ function getAccessor(slugOrPath) {
2714
+ return resolve(slugOrPath).collection(slugOrPath);
2715
+ }
2716
+ return new Proxy({ collection: getAccessor }, { get(_target, prop) {
2717
+ if (prop === "collection") return getAccessor;
2718
+ if (typeof prop === "symbol") return void 0;
2719
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return void 0;
2720
+ return getAccessor((0, _rebasepro_utils.toSnakeCase)(prop));
2721
+ } });
2722
+ }
2723
+ //#endregion
2724
+ //#region src/table-classification.ts
2725
+ /** Schemas that are always considered Rebase-internal. */
2726
+ var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
2727
+ /** Table-name prefixes that mark a table as Rebase-internal regardless of schema. */
2728
+ var REBASE_INTERNAL_PREFIXES = [
2729
+ "_rebase_",
2730
+ "_auth_",
2731
+ "drizzle_"
2732
+ ];
2733
+ /**
2734
+ * Synchronously classify a table based on naming conventions.
2735
+ *
2736
+ * @param tableName - The unqualified name of the table.
2737
+ * @param schemaName - The schema the table belongs to (e.g. `"public"`, `"rebase"`).
2738
+ * @returns `"rebase-internal"` when the table belongs to a reserved schema or
2739
+ * carries a reserved prefix; `"user"` otherwise.
2740
+ *
2741
+ * @remarks
2742
+ * Junction-table detection requires an async database query and is therefore
2743
+ * **not** handled by this function. Use {@link detectJunctionTables} to obtain
2744
+ * the set of junction tables, then reclassify as needed.
2745
+ */
2746
+ function classifyTable(tableName, schemaName) {
2747
+ if (REBASE_INTERNAL_SCHEMAS.includes(schemaName) || REBASE_INTERNAL_PREFIXES.some((prefix) => tableName.startsWith(prefix))) return "rebase-internal";
2748
+ return "user";
2749
+ }
2750
+ /**
2751
+ * Convenience predicate that checks whether a table is Rebase-internal.
2752
+ *
2753
+ * @param tableName - The unqualified name of the table.
2754
+ * @param schemaName - The schema the table belongs to.
2755
+ * @returns `true` if the table is classified as `"rebase-internal"`.
2756
+ */
2757
+ function isRebaseInternalTable(tableName, schemaName) {
2758
+ return classifyTable(tableName, schemaName) === "rebase-internal";
2759
+ }
2760
+ /** SQL query that detects junction tables in the `public` schema. */
2761
+ var JUNCTION_TABLES_SQL = `
2762
+ SELECT t.table_name
2763
+ FROM information_schema.tables t
2764
+ WHERE t.table_schema = 'public'
2765
+ AND t.table_type = 'BASE TABLE'
2766
+ AND NOT EXISTS (
2767
+ SELECT 1
2768
+ FROM information_schema.columns c
2769
+ WHERE c.table_schema = t.table_schema
2770
+ AND c.table_name = t.table_name
2771
+ AND c.column_name NOT IN (
2772
+ SELECT kcu.column_name
2773
+ FROM information_schema.key_column_usage kcu
2774
+ JOIN information_schema.table_constraints tc
2775
+ ON tc.constraint_name = kcu.constraint_name
2776
+ AND tc.table_schema = kcu.table_schema
2777
+ WHERE tc.constraint_type = 'FOREIGN KEY'
2778
+ AND kcu.table_schema = t.table_schema
2779
+ AND kcu.table_name = t.table_name
2780
+ )
2781
+ )
2782
+ `;
2783
+ /**
2784
+ * Asynchronously detect junction (link) tables in the `public` schema.
2785
+ *
2786
+ * A junction table is defined as a table where **every** column participates in
2787
+ * at least one foreign-key constraint.
2788
+ *
2789
+ * @param executeSql - A callback that executes a raw SQL string and returns the
2790
+ * resulting rows.
2791
+ * @returns A `Set` containing the names of all detected junction tables.
2792
+ */
2793
+ async function detectJunctionTables(executeSql) {
2794
+ const rows = await executeSql(JUNCTION_TABLES_SQL);
2795
+ const junctionTables = /* @__PURE__ */ new Set();
2796
+ for (const row of rows) if (typeof row.table_name === "string") junctionTables.add(row.table_name);
2797
+ return junctionTables;
2798
+ }
2799
+ //#endregion
2201
2800
  exports.COLLECTION_PATH_SEPARATOR = COLLECTION_PATH_SEPARATOR;
2202
2801
  exports.CollectionRegistry = CollectionRegistry;
2203
2802
  exports.DEFAULT_ONE_OF_TYPE = DEFAULT_ONE_OF_TYPE;
2204
2803
  exports.DEFAULT_ONE_OF_VALUE = DEFAULT_ONE_OF_VALUE;
2804
+ exports.JUNCTION_TABLES_SQL = JUNCTION_TABLES_SQL;
2205
2805
  exports.QueryBuilder = QueryBuilder;
2806
+ exports.REBASE_INTERNAL_PREFIXES = REBASE_INTERNAL_PREFIXES;
2807
+ exports.REBASE_INTERNAL_SCHEMAS = REBASE_INTERNAL_SCHEMAS;
2206
2808
  exports.addInitialSlash = addInitialSlash;
2207
2809
  exports.and = and;
2208
2810
  exports.applyPropertyConditions = applyPropertyConditions;
@@ -2217,17 +2819,25 @@
2217
2819
  exports.buildProperty = buildProperty;
2218
2820
  exports.buildPropertyCallbacks = buildPropertyCallbacks;
2219
2821
  exports.buildRebaseData = buildRebaseData;
2822
+ exports.buildRoutedRebaseData = buildRoutedRebaseData;
2220
2823
  exports.canCreateEntity = canCreateEntity;
2221
2824
  exports.canDeleteEntity = canDeleteEntity;
2222
2825
  exports.canEditEntity = canEditEntity;
2223
2826
  exports.canReadCollection = canReadCollection;
2224
2827
  exports.checkOperation = checkOperation;
2828
+ exports.classifyTable = classifyTable;
2225
2829
  exports.cond = cond;
2830
+ exports.createDataSourceRegistry = createDataSourceRegistry;
2226
2831
  exports.createRelationRef = createRelationRef;
2227
2832
  exports.createRelationRefWithData = createRelationRefWithData;
2228
2833
  exports.defaultUsersCollection = defaultUsersCollection;
2834
+ exports.defineCollection = defineCollection;
2835
+ exports.deserializeFilter = deserializeFilter;
2836
+ exports.deserializeLogicalCondition = deserializeLogicalCondition;
2837
+ exports.detectJunctionTables = detectJunctionTables;
2229
2838
  exports.enumToObjectEntries = enumToObjectEntries;
2230
2839
  exports.evaluateCondition = evaluateCondition;
2840
+ exports.evaluatePolicy = evaluatePolicy;
2231
2841
  exports.findRelation = findRelation;
2232
2842
  exports.fullPathToCollectionSegments = fullPathToCollectionSegments;
2233
2843
  exports.getArrayResolvedProperties = getArrayResolvedProperties;
@@ -2253,8 +2863,10 @@
2253
2863
  exports.isHidden = isHidden;
2254
2864
  exports.isPropertyBuilder = isPropertyBuilder;
2255
2865
  exports.isReadOnly = isReadOnly;
2866
+ exports.isRebaseInternalTable = isRebaseInternalTable;
2256
2867
  exports.normalizeToEntityRelation = normalizeToEntityRelation;
2257
2868
  exports.or = or;
2869
+ exports.policyToPostgres = policyToPostgres;
2258
2870
  exports.registerConditionOperations = registerConditionOperations;
2259
2871
  exports.removeInitialAndTrailingSlashes = removeInitialAndTrailingSlashes;
2260
2872
  exports.removeInitialSlash = removeInitialSlash;
@@ -2262,6 +2874,7 @@
2262
2874
  exports.resolveArrayProperties = resolveArrayProperties;
2263
2875
  exports.resolveCollectionPathIds = resolveCollectionPathIds;
2264
2876
  exports.resolveCollectionRelations = resolveCollectionRelations;
2877
+ exports.resolveDataSource = resolveDataSource;
2265
2878
  exports.resolveDefaultSelectedView = resolveDefaultSelectedView;
2266
2879
  exports.resolveEnumValues = resolveEnumValues;
2267
2880
  exports.resolveProperties = resolveProperties;
@@ -2271,9 +2884,13 @@
2271
2884
  exports.resolveRelationProperty = resolveRelationProperty;
2272
2885
  exports.resolveStorageFilenameString = resolveStorageFilenameString;
2273
2886
  exports.resolveStoragePathString = resolveStoragePathString;
2887
+ exports.resolveStorageSource = resolveStorageSource;
2274
2888
  exports.sanitizeData = sanitizeData;
2275
2889
  exports.sanitizeRelation = sanitizeRelation;
2890
+ exports.securityRuleToConditions = securityRuleToConditions;
2276
2891
  exports.segmentsToStrippedPath = segmentsToStrippedPath;
2892
+ exports.serializeFilter = serializeFilter;
2893
+ exports.serializeLogicalCondition = serializeLogicalCondition;
2277
2894
  exports.sortProperties = sortProperties;
2278
2895
  exports.stripCollectionPath = stripCollectionPath;
2279
2896
  exports.traverseValueProperty = traverseValueProperty;