@rebasepro/common 0.7.0 → 0.9.0

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