@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.umd.js CHANGED
@@ -83,7 +83,7 @@
83
83
  else return null;
84
84
  }
85
85
  /**
86
- * Update the automatic values in an entity before save
86
+ * Update the automatic values in a entity before save
87
87
  * @group Driver
88
88
  */
89
89
  function updateDateAutoValues({ inputValues, properties, status, timestampNowValue }) {
@@ -95,7 +95,7 @@
95
95
  }) ?? {};
96
96
  }
97
97
  /**
98
- * Add missing required fields, expected in the collection, to the values of an entity
98
+ * Add missing required fields, expected in the collection, to the values of a entity
99
99
  * @param values
100
100
  * @param properties
101
101
  * @group Driver
@@ -265,7 +265,7 @@
265
265
  return collection.localChangesBackup;
266
266
  }
267
267
  /**
268
- * Returns the primary keys for an entity collection by inspecting the properties
268
+ * Returns the primary keys for a entity collection by inspecting the properties
269
269
  * and finding any properties with `isId`.
270
270
  * Fallbacks to `["id"]` if no properties are marked as `isId: true`.
271
271
  * @param collection
@@ -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,379 @@
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, options) {
813
+ return compile(expr, {
814
+ fieldCollection: collection,
815
+ fieldPrefix: "",
816
+ outerCollection: collection,
817
+ outerPrefix: "",
818
+ resolveCollection: options?.resolveCollection,
819
+ alias: { n: 0 }
820
+ });
821
+ }
822
+ function compile(expr, scope) {
823
+ switch (expr.kind) {
824
+ case "true": return "true";
825
+ case "false": return "false";
826
+ case "and": return expr.operands.length === 0 ? "true" : expr.operands.map((o) => `(${compile(o, scope)})`).join(" AND ");
827
+ case "or": return expr.operands.length === 0 ? "false" : expr.operands.map((o) => `(${compile(o, scope)})`).join(" OR ");
828
+ case "not":
829
+ if (expr.operand.kind === "authenticated") return "auth.uid() IS NULL";
830
+ return `NOT (${compile(expr.operand, scope)})`;
831
+ case "compare": return `${operandToSql(expr.left, scope)} ${COMPARE_SQL[expr.op]} ${operandToSql(expr.right, scope)}`;
832
+ case "rolesOverlap": return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
833
+ case "rolesContain": return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
834
+ case "authenticated": return "auth.uid() IS NOT NULL";
835
+ case "existsIn": return compileExistsIn(expr, scope);
836
+ case "raw": return expr.sql.replace(/\{(\w+)\}/g, (_, col) => col);
837
+ }
838
+ }
839
+ /**
840
+ * Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.
841
+ * Inside the subquery, `field` operands bind to the aliased join table and
842
+ * `outerField` operands bind to the (table-qualified) outer RLS row.
843
+ */
844
+ function compileExistsIn(expr, scope) {
845
+ const join = scope.resolveCollection?.(expr.collection);
846
+ const joinTable = join ? getTableName(join) : (0, _rebasepro_utils.toSnakeCase)(expr.collection);
847
+ const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? "public";
848
+ const alias = `_ex${scope.alias.n++}`;
849
+ const outerTable = scope.outerCollection ? getTableName(scope.outerCollection) : void 0;
850
+ const outerSchema = schemaOf(scope.outerCollection) ?? "public";
851
+ const outerPrefix = outerTable ? `"${outerSchema}"."${outerTable}".` : "";
852
+ const innerScope = {
853
+ fieldCollection: join,
854
+ fieldPrefix: `"${alias}".`,
855
+ outerCollection: scope.outerCollection,
856
+ outerPrefix,
857
+ resolveCollection: scope.resolveCollection,
858
+ alias: scope.alias
859
+ };
860
+ return `EXISTS (SELECT 1 FROM "${joinSchema}"."${joinTable}" "${alias}" WHERE ${compile(expr.where, innerScope)})`;
861
+ }
862
+ var COMPARE_SQL = {
863
+ eq: "=",
864
+ neq: "!=",
865
+ lt: "<",
866
+ lte: "<=",
867
+ gt: ">",
868
+ gte: ">="
869
+ };
870
+ function operandToSql(operand, scope) {
871
+ switch (operand.kind) {
872
+ case "field": return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;
873
+ case "outerField": return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;
874
+ case "literal": return quoteLiteral(operand.value);
875
+ case "authUid": return "auth.uid()";
876
+ case "authRoles": return "string_to_array(auth.roles(), ',')";
877
+ }
878
+ }
879
+ function schemaOf(collection) {
880
+ return collection?.schema || void 0;
881
+ }
882
+ function resolveColumnName(propName, collection) {
883
+ const prop = collection?.properties?.[propName];
884
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
885
+ return (0, _rebasepro_utils.toSnakeCase)(propName);
886
+ }
887
+ function quoteLiteral(value) {
888
+ if (value === null) return "NULL";
889
+ if (typeof value === "boolean") return value ? "true" : "false";
890
+ if (typeof value === "number") return String(value);
891
+ return `'${value.replace(/'/g, "''")}'`;
892
+ }
893
+ /** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */
894
+ function rolesArraySql(roles) {
895
+ return `ARRAY[${[...roles].sort().map((r) => `'${r}'`).join(",")}]`;
896
+ }
897
+ //#endregion
898
+ //#region src/util/policy/evaluatePolicy.ts
899
+ /**
900
+ * Evaluates a {@link PolicyExpression} against a user + row, using three-valued
901
+ * (Kleene) logic so that `"unknown"` sub-results propagate soundly.
902
+ *
903
+ * This is the JavaScript twin of {@link policyToPostgres}: both derive from the
904
+ * same expression, so the admin UI matches database enforcement by construction
905
+ * for every non-raw rule.
906
+ */
907
+ function evaluatePolicy(expr, ctx) {
908
+ switch (expr.kind) {
909
+ case "true": return true;
910
+ case "false": return false;
911
+ case "and": return kleeneAnd$1(expr.operands.map((o) => evaluatePolicy(o, ctx)));
912
+ case "or": return kleeneOr(expr.operands.map((o) => evaluatePolicy(o, ctx)));
913
+ case "not": return kleeneNot(evaluatePolicy(expr.operand, ctx));
914
+ case "compare": return evaluateCompare(expr.op, expr.left, expr.right, ctx);
915
+ case "rolesOverlap": {
916
+ const userRoles = ctx.roles ?? [];
917
+ return expr.roles.some((r) => r === "public" || userRoles.includes(r));
716
918
  }
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
- }
919
+ case "rolesContain": {
920
+ const userRoles = ctx.roles ?? [];
921
+ return expr.roles.every((r) => r === "public" || userRoles.includes(r));
736
922
  }
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;
923
+ case "authenticated": return ctx.uid != null;
924
+ case "existsIn": return "unknown";
925
+ case "raw": return "unknown";
772
926
  }
927
+ }
928
+ function kleeneAnd$1(values) {
929
+ if (values.some((v) => v === false)) return false;
930
+ if (values.some((v) => v === "unknown")) return "unknown";
773
931
  return true;
774
932
  }
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;
779
- }
780
- if (rule.using && !evaluateAST(rule.using, auth, entity)) return false;
781
- if (rule.withCheck && !evaluateAST(rule.withCheck, auth, entity)) return false;
933
+ function kleeneOr(values) {
934
+ if (values.some((v) => v === true)) return true;
935
+ if (values.some((v) => v === "unknown")) return "unknown";
936
+ return false;
937
+ }
938
+ function kleeneNot(value) {
939
+ if (value === "unknown") return "unknown";
940
+ return !value;
941
+ }
942
+ function resolveOperand(operand, ctx) {
943
+ switch (operand.kind) {
944
+ case "literal": return {
945
+ known: true,
946
+ value: operand.value
947
+ };
948
+ case "authUid": return {
949
+ known: true,
950
+ value: ctx.uid ?? null
951
+ };
952
+ case "authRoles": return {
953
+ known: true,
954
+ value: ctx.roles ?? []
955
+ };
956
+ case "field":
957
+ if (!ctx.entity) return { known: false };
958
+ return {
959
+ known: true,
960
+ value: ctx.entity.values[operand.name]
961
+ };
962
+ case "outerField": return { known: false };
963
+ }
964
+ }
965
+ function evaluateCompare(op, left, right, ctx) {
966
+ const l = resolveOperand(left, ctx);
967
+ const r = resolveOperand(right, ctx);
968
+ if (!l.known || !r.known) return "unknown";
969
+ const a = l.value;
970
+ const b = r.value;
971
+ if (a === null || b === null) {
972
+ if (op === "eq") return false;
973
+ if (op === "neq") return true;
974
+ return "unknown";
975
+ }
976
+ if (op === "eq") return a === b;
977
+ if (op === "neq") return a !== b;
978
+ if (typeof a === "string" && typeof b === "string") {
979
+ if (op === "lt") return a < b;
980
+ if (op === "lte") return a <= b;
981
+ if (op === "gt") return a > b;
982
+ if (op === "gte") return a >= b;
983
+ }
984
+ if (typeof a === "number" && typeof b === "number") {
985
+ if (op === "lt") return a < b;
986
+ if (op === "lte") return a <= b;
987
+ if (op === "gt") return a > b;
988
+ if (op === "gte") return a >= b;
989
+ }
990
+ if (typeof a === "bigint" && typeof b === "bigint") {
991
+ if (op === "lt") return a < b;
992
+ if (op === "lte") return a <= b;
993
+ if (op === "gt") return a > b;
994
+ if (op === "gte") return a >= b;
995
+ }
996
+ return "unknown";
997
+ }
998
+ //#endregion
999
+ //#region src/util/permissions.ts
1000
+ /** Combine clause results with AND under three-valued (Kleene) logic. */
1001
+ function kleeneAnd(values) {
1002
+ if (values.some((v) => v === false)) return false;
1003
+ if (values.some((v) => v === "unknown")) return "unknown";
782
1004
  return true;
783
1005
  }
784
- function checkOperation(collection, authContext, entity, targetOperation) {
785
- const securityRules = (0, _rebasepro_types.getDataSourceCapabilities)(collection.driver).supportsRLS ? collection.securityRules : void 0;
1006
+ /** The operations a rule covers, mirroring the Postgres generator's resolution. */
1007
+ function ruleOperations(rule) {
1008
+ return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
1009
+ }
1010
+ function ruleApplies(rule, targetOperation) {
1011
+ const ops = ruleOperations(rule);
1012
+ return ops.includes(targetOperation) || ops.includes("all");
1013
+ }
1014
+ /**
1015
+ * Evaluate a single rule for one operation, returning a tri-state.
1016
+ *
1017
+ * A `null` clause (the rule contributes no condition for a required clause)
1018
+ * denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
1019
+ * in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
1020
+ * INSERT/UPDATE; both must pass for UPDATE.
1021
+ */
1022
+ function evaluateRuleForOperation(rule, ctx, targetOperation) {
1023
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
1024
+ const clause = (expr) => expr === null ? false : evaluatePolicy(expr, ctx);
1025
+ const needsUsing = targetOperation !== "insert";
1026
+ const needsWithCheck = targetOperation === "insert" || targetOperation === "update";
1027
+ const results = [];
1028
+ if (needsUsing) results.push(clause(usingExpr));
1029
+ if (needsWithCheck) results.push(clause(withCheckExpr));
1030
+ return kleeneAnd(results);
1031
+ }
1032
+ function resolveTriState(value, onUnknown) {
1033
+ if (value === "unknown") return onUnknown === "allow";
1034
+ return value;
1035
+ }
1036
+ /**
1037
+ * Decide whether an operation is permitted for a user on a (possibly null) row,
1038
+ * by evaluating the collection's security rules with the shared policy model —
1039
+ * the same model compiled to Postgres RLS DDL, so the decision matches database
1040
+ * enforcement for every non-raw rule.
1041
+ *
1042
+ * @param options.onUnknown how to treat rules that cannot be decided
1043
+ * client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
1044
+ * for optimistic UI gating; enforcement callers should pass `"deny"`.
1045
+ */
1046
+ function checkOperation(collection, authContext, entity, targetOperation, options) {
1047
+ const onUnknown = options?.onUnknown ?? "allow";
1048
+ const securityRules = (0, _rebasepro_types.getDataSourceCapabilities)(collection.engine).supportsRLS ? collection.securityRules : void 0;
786
1049
  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"));
1050
+ const applicableRules = securityRules.filter((r) => ruleApplies(r, targetOperation));
788
1051
  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;
1052
+ const ctx = {
1053
+ uid: authContext.user?.uid,
1054
+ roles: authContext.user?.roles ?? [],
1055
+ entity
1056
+ };
795
1057
  let grantedByPermissive = false;
796
1058
  let deniedByRestrictive = false;
797
- for (const rule of roleApplicableRules) {
1059
+ let hasPermissive = false;
1060
+ for (const rule of applicableRules) {
798
1061
  const mode = rule.mode || "permissive";
799
- const passed = evaluateRule(rule, authContext, entity);
800
- if (mode === "restrictive" && !passed) {
801
- deniedByRestrictive = true;
802
- break;
1062
+ const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation), onUnknown);
1063
+ if (mode === "restrictive") {
1064
+ if (!passed) {
1065
+ deniedByRestrictive = true;
1066
+ break;
1067
+ }
1068
+ } else {
1069
+ hasPermissive = true;
1070
+ if (passed) grantedByPermissive = true;
803
1071
  }
804
- if (mode === "permissive" && passed) grantedByPermissive = true;
805
1072
  }
806
1073
  if (deniedByRestrictive) return false;
807
- if (roleApplicableRules.some((r) => (r.mode || "permissive") === "permissive")) return grantedByPermissive;
808
- else return false;
1074
+ return hasPermissive ? grantedByPermissive : false;
809
1075
  }
810
1076
  function canReadCollection(collection, authContext) {
811
1077
  return checkOperation(collection, authContext, null, "select");
@@ -836,7 +1102,7 @@
836
1102
  }
837
1103
  for (const key in collection.properties) {
838
1104
  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;
1105
+ if (property.type === "array" && property.of && !Array.isArray(property.of) && property.of.type === "string" && property.of.ui?.url === "image") return key;
840
1106
  }
841
1107
  for (const key in collection.properties) {
842
1108
  const property = collection.properties[key];
@@ -905,7 +1171,7 @@
905
1171
  } else {
906
1172
  entityId = remainingPath;
907
1173
  remainingPath = "";
908
- 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.`);
1174
+ 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.`);
909
1175
  }
910
1176
  resolvedPathParts.push(entityId);
911
1177
  currentCollections = getSubcollections(foundCollection);
@@ -1061,79 +1327,60 @@
1061
1327
  //#endregion
1062
1328
  //#region src/util/builders.ts
1063
1329
  /**
1064
- * Identity function we use to defeat the type system of Typescript and build
1065
- * collection views with all its properties
1066
- * @param collection
1330
+ * @deprecated Use {@link defineCollection} instead it infers property
1331
+ * types automatically (autocomplete on `titleProperty`, `sort`,
1332
+ * `propertiesOrder`, callbacks) without manual generics.
1333
+ * `buildCollection` is kept for FireCMS migration compatibility and will
1334
+ * be removed before 1.0.
1335
+ *
1067
1336
  * @group Builder
1068
1337
  */
1069
1338
  function buildCollection(collection) {
1070
1339
  return collection;
1071
1340
  }
1072
1341
  /**
1073
- * Identity function we use to defeat the type system of Typescript and preserve
1074
- * the property keys.
1075
- * @param property
1076
- * @group Builder
1077
- */
1078
- function buildProperty(property) {
1079
- return property;
1080
- }
1081
- /**
1082
- * Identity function we use to defeat the type system of Typescript and preserve
1083
- * the properties keys.
1084
- * @param properties
1085
- * @group Builder
1086
- */
1087
- function buildProperties(properties) {
1088
- return properties;
1089
- }
1090
- /**
1091
- * Identity function we use to defeat the type system of Typescript and preserve
1092
- * the properties keys.
1093
- * @param propertiesOrBuilder
1094
- * @group Builder
1095
- */
1096
- function buildPropertiesOrBuilder(propertiesOrBuilder) {
1097
- return propertiesOrBuilder;
1098
- }
1099
- /**
1100
- * Identity function we use to defeat the type system of Typescript and preserve
1101
- * the properties keys.
1102
- * @param enumValues
1103
- * @group Builder
1104
- */
1105
- function buildEnum(enumValues) {
1106
- return enumValues;
1107
- }
1108
- /**
1109
- * Identity function we use to defeat the type system of Typescript and preserve
1110
- * the properties keys.
1111
- * @param enumValueConfig
1112
- * @group Builder
1342
+ * Implementation delegates to the correct overload at the type level.
1343
+ * At runtime this is a plain identity function.
1113
1344
  */
1114
- function buildEnumValueConfig(enumValueConfig) {
1115
- return enumValueConfig;
1345
+ function defineCollection(collection) {
1346
+ return collection;
1116
1347
  }
1117
1348
  /**
1118
- * Identity function we use to defeat the type system of Typescript and preserve
1119
- * the properties keys.
1120
- * @param callbacks
1349
+ * @deprecated Use plain typed property objects with {@link defineCollection}
1350
+ * instead `defineCollection` infers property types automatically, making
1351
+ * this wrapper unnecessary. `buildProperty` is kept for FireCMS migration
1352
+ * compatibility and will be removed before 1.0.
1353
+ *
1121
1354
  * @group Builder
1122
1355
  */
1123
- function buildEntityCallbacks(callbacks) {
1124
- return callbacks;
1356
+ function buildProperty(property) {
1357
+ return property;
1125
1358
  }
1359
+ //#endregion
1360
+ //#region src/util/storage.ts
1126
1361
  /**
1127
- * Identity function we use to defeat the type system of Typescript and build
1128
- * additional field delegates views with all its properties
1129
- * @param additionalFieldDelegate
1130
- * @group Builder
1362
+ * Resolve the {@link StorageSource} to use for a property, given the key
1363
+ * referenced by `StorageConfig.storageSource`.
1364
+ *
1365
+ * Resolution priority:
1366
+ * 1. No `sourceKey` → the default source (backward compatible).
1367
+ * 2. An explicit {@link StorageSourceRegistry} (e.g. `client.storageRegistry`).
1368
+ * 3. A `sources` lookup map (e.g. the `StorageSourcesContext`).
1369
+ * 4. Fall back to the default source.
1370
+ *
1371
+ * Shared by the upload hook, the markdown editor, and the read-only previews
1372
+ * so the resolution logic lives in one place.
1373
+ *
1374
+ * @group Storage
1131
1375
  */
1132
- function buildAdditionalFieldDelegate(additionalFieldDelegate) {
1133
- return additionalFieldDelegate;
1376
+ function resolveStorageSource(params) {
1377
+ const { sourceKey, sources, registry, defaultSource } = params;
1378
+ if (!sourceKey) return defaultSource;
1379
+ if (registry) return registry.getOrDefault(sourceKey);
1380
+ const fromSources = sources?.[sourceKey];
1381
+ if (fromSources) return fromSources;
1382
+ return defaultSource;
1134
1383
  }
1135
- //#endregion
1136
- //#region src/util/storage.ts
1137
1384
  async function resolveStorageFilenameString({ input, storage, values, entityId, path, property, file, propertyKey }) {
1138
1385
  let result;
1139
1386
  if (typeof input === "function") {
@@ -1244,16 +1491,17 @@
1244
1491
  }
1245
1492
  /**
1246
1493
  * Helper function to extract field-level PropertyCallbacks from a properties schema
1247
- * and wrap them into an EntityCallbacks object recursively.
1494
+ * and wrap them into an CollectionCallbacks object recursively.
1248
1495
  */
1249
1496
  var buildPropertyCallbacks = (properties) => {
1250
1497
  if (!properties) return void 0;
1251
1498
  const propertyCallbacks = {};
1252
1499
  if (hasPropertyCallbacks(properties, "afterRead")) propertyCallbacks.afterRead = async (props) => {
1253
- const processedValues = await processProperties(properties, props.entity.values, props.entity.values, props, "afterRead");
1500
+ const row = props.row;
1501
+ const processedValues = await processProperties(properties, row, row, props, "afterRead");
1254
1502
  return {
1255
- ...props.entity,
1256
- values: processedValues
1503
+ ...props.row,
1504
+ ...processedValues
1257
1505
  };
1258
1506
  };
1259
1507
  if (hasPropertyCallbacks(properties, "beforeSave")) propertyCallbacks.beforeSave = async (props) => {
@@ -1440,6 +1688,84 @@
1440
1688
  return result;
1441
1689
  }
1442
1690
  //#endregion
1691
+ //#region src/util/filter-operator-resolution.ts
1692
+ /**
1693
+ * Default operators offered per property type, before engine capabilities and
1694
+ * per-property narrowing are applied. These mirror what the built-in filter
1695
+ * fields can render.
1696
+ */
1697
+ var COMPARISON_OPS = [
1698
+ "==",
1699
+ "!=",
1700
+ ">",
1701
+ ">=",
1702
+ "<",
1703
+ "<="
1704
+ ];
1705
+ var NULL_CHECK_OPS = ["is-null", "is-not-null"];
1706
+ var MEMBERSHIP_OPS = ["in", "not-in"];
1707
+ var PATTERN_OPS = [
1708
+ "like",
1709
+ "ilike",
1710
+ "not-like",
1711
+ "not-ilike"
1712
+ ];
1713
+ var DEFAULT_OPS_BY_TYPE = {
1714
+ string: [
1715
+ ...COMPARISON_OPS,
1716
+ ...MEMBERSHIP_OPS,
1717
+ ...PATTERN_OPS,
1718
+ ...NULL_CHECK_OPS
1719
+ ],
1720
+ number: [
1721
+ ...COMPARISON_OPS,
1722
+ ...MEMBERSHIP_OPS,
1723
+ ...NULL_CHECK_OPS
1724
+ ],
1725
+ date: [...COMPARISON_OPS, ...NULL_CHECK_OPS],
1726
+ boolean: [
1727
+ "==",
1728
+ "!=",
1729
+ ...NULL_CHECK_OPS
1730
+ ],
1731
+ reference: [
1732
+ "==",
1733
+ "!=",
1734
+ ...MEMBERSHIP_OPS,
1735
+ ...NULL_CHECK_OPS
1736
+ ],
1737
+ relation: [
1738
+ "==",
1739
+ "!=",
1740
+ ...MEMBERSHIP_OPS,
1741
+ ...NULL_CHECK_OPS
1742
+ ]
1743
+ };
1744
+ /** Operators offered when the property is an *array of* a filterable type. */
1745
+ var ARRAY_OPS = ["array-contains", "array-contains-any"];
1746
+ /**
1747
+ * Resolve which filter operators the UI should offer for a property.
1748
+ *
1749
+ * The result is the **intersection** of three sets:
1750
+ * 1. what the engine can execute — {@link DataSourceCapabilities.filterOperators}
1751
+ * (e.g. Firestore cannot run the LIKE family);
1752
+ * 2. what makes sense for the property type (e.g. no `>` on booleans);
1753
+ * 3. the developer's optional narrowing — `property.ui.filterOperators`.
1754
+ *
1755
+ * Returns an empty array when the property is not filterable (either by
1756
+ * type, or because the developer disabled it with `filterOperators: []`).
1757
+ *
1758
+ * @group Models
1759
+ */
1760
+ function resolveFilterOperators({ property, isArray, engine }) {
1761
+ const typeDefaults = isArray ? ARRAY_OPS : DEFAULT_OPS_BY_TYPE[property.type] ?? [];
1762
+ if (typeDefaults.length === 0) return [];
1763
+ const engineOps = new Set((0, _rebasepro_types.getDataSourceCapabilities)(engine).filterOperators ?? _rebasepro_types.ALL_WHERE_FILTER_OPS);
1764
+ const narrowing = property.ui?.filterOperators;
1765
+ const narrowingSet = narrowing !== void 0 ? new Set(narrowing) : void 0;
1766
+ return typeDefaults.filter((op) => engineOps.has(op) && (narrowingSet === void 0 || narrowingSet.has(op)));
1767
+ }
1768
+ //#endregion
1443
1769
  //#region src/data/resolveDataSource.ts
1444
1770
  /**
1445
1771
  * Build a keyed registry from a list of {@link DataSourceDefinition}s.
@@ -1456,13 +1782,13 @@
1456
1782
  * editor's capability lookups.
1457
1783
  *
1458
1784
  * Resolution order:
1459
- * 1. The routing **key** is `collection.dataSource`, else the legacy
1460
- * `collection.driver`, else {@link DEFAULT_DATA_SOURCE_KEY}.
1785
+ * 1. The routing **key** is `collection.dataSource`, else
1786
+ * {@link DEFAULT_DATA_SOURCE_KEY}.
1461
1787
  * 2. If a definition is registered for that key, it provides `engine`,
1462
1788
  * `transport`, and `databaseId`.
1463
- * 3. Otherwise values are synthesized for backward compatibility: `engine`
1464
- * from the legacy `driver` (or the key, or `"postgres"`), `transport`
1465
- * defaults to `"server"`, and `databaseId` from the collection.
1789
+ * 3. Otherwise values are synthesized: `engine` from `collection.engine`
1790
+ * (or the key, or `"postgres"`), `transport` defaults to `"server"`,
1791
+ * and `databaseId` from the collection.
1466
1792
  *
1467
1793
  * `capabilities` are always derived from the resolved `engine`, so two
1468
1794
  * data sources sharing an engine share capabilities.
@@ -1471,9 +1797,9 @@
1471
1797
  * @param registry optional registry of declared data sources
1472
1798
  */
1473
1799
  function resolveDataSource(collection, registry) {
1474
- const key = collection?.dataSource ?? collection?.driver ?? _rebasepro_types.DEFAULT_DATA_SOURCE_KEY;
1800
+ const key = collection?.dataSource ?? _rebasepro_types.DEFAULT_DATA_SOURCE_KEY;
1475
1801
  const def = registry?.[key];
1476
- const engine = def?.engine ?? collection?.driver ?? (key !== _rebasepro_types.DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
1802
+ const engine = def?.engine ?? collection?.engine ?? (key !== _rebasepro_types.DEFAULT_DATA_SOURCE_KEY ? key : "postgres");
1477
1803
  return {
1478
1804
  key,
1479
1805
  engine,
@@ -1488,9 +1814,28 @@
1488
1814
  /**
1489
1815
  * Declared data sources, used during normalization to resolve each
1490
1816
  * collection's engine (so `dataSource`-only collections get the right
1491
- * capabilities). Empty by default → behaviour keys off `driver` as before.
1817
+ * capabilities). Empty by default.
1492
1818
  */
1493
1819
  dataSources = {};
1820
+ /**
1821
+ * Global lifecycle callbacks applied to every collection.
1822
+ * Runs on all data paths (REST, WebSocket, `rebase.data`).
1823
+ * Execution order: global → collection → property callbacks.
1824
+ */
1825
+ _globalCallbacks;
1826
+ /**
1827
+ * Set global lifecycle callbacks that apply to every collection.
1828
+ * Typically called once during backend initialization.
1829
+ */
1830
+ setGlobalCallbacks(callbacks) {
1831
+ this._globalCallbacks = callbacks;
1832
+ }
1833
+ /**
1834
+ * Get the currently registered global callbacks, if any.
1835
+ */
1836
+ getGlobalCallbacks() {
1837
+ return this._globalCallbacks;
1838
+ }
1494
1839
  collectionsByTableName = /* @__PURE__ */ new Map();
1495
1840
  collectionsBySlug = /* @__PURE__ */ new Map();
1496
1841
  rootCollections = [];
@@ -1499,7 +1844,7 @@
1499
1844
  rawCollectionsBySlug = /* @__PURE__ */ new Map();
1500
1845
  rawRootCollections = [];
1501
1846
  cachedRawCollectionsList = null;
1502
- lastRawInputSnapshot = null;
1847
+ lastRawInputEntity = null;
1503
1848
  constructor(collections, dataSources) {
1504
1849
  if (dataSources) this.dataSources = dataSources;
1505
1850
  if (collections) this.registerMultiple(collections);
@@ -1529,12 +1874,12 @@
1529
1874
  * Returns true if the collections have changed, false otherwise.
1530
1875
  *
1531
1876
  * Idempotent: compares the raw input (before normalization) against a stored
1532
- * snapshot. Only re-normalizes and re-registers when the raw input actually changed.
1877
+ * entity. Only re-normalizes and re-registers when the raw input actually changed.
1533
1878
  * @param collections
1534
1879
  */
1535
1880
  registerMultiple(collections) {
1536
- const rawSnapshot = collections.map((c) => (0, _rebasepro_utils.removeFunctions)(c));
1537
- if (this.lastRawInputSnapshot && (0, fast_equals.deepEqual)(this.lastRawInputSnapshot, rawSnapshot)) return false;
1881
+ const rawEntity = collections.map((c) => (0, _rebasepro_utils.removeFunctions)(c));
1882
+ if (this.lastRawInputEntity && (0, fast_equals.deepEqual)(this.lastRawInputEntity, rawEntity)) return false;
1538
1883
  this.reset();
1539
1884
  collections.forEach((c) => {
1540
1885
  if (c.slug) this.collectionsBySlug.set(c.slug, c);
@@ -1558,7 +1903,7 @@
1558
1903
  this._registerRecursively(this.normalizeCollection({ ...subCollection }), (0, _rebasepro_utils.deepClone)(subCollection));
1559
1904
  });
1560
1905
  });
1561
- this.lastRawInputSnapshot = rawSnapshot;
1906
+ this.lastRawInputEntity = rawEntity;
1562
1907
  return true;
1563
1908
  }
1564
1909
  register(collection, rawCollection) {
@@ -1582,13 +1927,14 @@
1582
1927
  }
1583
1928
  normalizeCollection(collection) {
1584
1929
  const result = { ...collection };
1585
- if (result.dataSource && !result.driver) {
1586
- const engine = resolveDataSource(result, this.dataSources).engine;
1587
- if (engine) result.driver = engine;
1930
+ {
1931
+ const resolved = resolveDataSource(result, this.dataSources);
1932
+ if (!result.dataSource) result.dataSource = resolved.key;
1933
+ if (!result.engine) result.engine = resolved.engine;
1588
1934
  }
1589
1935
  const extractedRelations = this.extractRelationsFromProperties(result.properties);
1590
1936
  const relResult = result;
1591
- const manualRelations = (0, _rebasepro_types.getDataSourceCapabilities)(result.driver).supportsRelations ? relResult.relations ?? [] : [];
1937
+ const manualRelations = (0, _rebasepro_types.getDataSourceCapabilities)(result.engine).supportsRelations ? relResult.relations ?? [] : [];
1592
1938
  const mergedRelationsRaw = [...extractedRelations];
1593
1939
  for (const manual of manualRelations) {
1594
1940
  const name = manual.relationName;
@@ -1603,7 +1949,7 @@
1603
1949
  }
1604
1950
  }
1605
1951
  let mergedRelations = mergedRelationsRaw;
1606
- if ((0, _rebasepro_types.getDataSourceCapabilities)(result.driver).supportsRelations) {
1952
+ if ((0, _rebasepro_types.getDataSourceCapabilities)(result.engine).supportsRelations) {
1607
1953
  mergedRelations = mergedRelationsRaw.map((r) => {
1608
1954
  try {
1609
1955
  return sanitizeRelation(r, result, (slug) => this.get(slug));
@@ -1615,8 +1961,10 @@
1615
1961
  }
1616
1962
  result.properties = this.normalizeProperties(result.properties, mergedRelations);
1617
1963
  if (!result.childCollections) {
1618
- if ((0, _rebasepro_types.getDataSourceCapabilities)(result.driver).supportsSubcollections && result.subcollections) result.childCollections = result.subcollections;
1619
- else if ((0, _rebasepro_types.getDataSourceCapabilities)(result.driver).supportsRelations && relResult.relations) {
1964
+ const capabilities = (0, _rebasepro_types.getDataSourceCapabilities)(result.engine);
1965
+ const declaredSubcollections = (0, _rebasepro_types.getDeclaredSubcollections)(result);
1966
+ if (capabilities.supportsSubcollections && declaredSubcollections) result.childCollections = declaredSubcollections;
1967
+ else if (capabilities.supportsRelations && relResult.relations) {
1620
1968
  const manyRelations = relResult.relations.filter((r) => r.cardinality === "many");
1621
1969
  if (manyRelations.length > 0) result.childCollections = () => manyRelations.map((r) => {
1622
1970
  const target = r.target();
@@ -1718,7 +2066,7 @@
1718
2066
  if (!currentCollection) throw new Error(`Root collection not found: ${rootCollectionPath}`);
1719
2067
  for (let i = 2; i < pathSegments.length; i += 2) {
1720
2068
  const relationKey = pathSegments[i];
1721
- 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}'`);
2069
+ 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}'`);
1722
2070
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
1723
2071
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
1724
2072
  const target = relation.target();
@@ -1779,7 +2127,7 @@
1779
2127
  * Slug-based dedup (Map keyed by slug, last-write-wins) lets developers
1780
2128
  * override by defining their own collection with `slug: "users"`.
1781
2129
  */
1782
- var defaultUsersCollection = {
2130
+ var defaultUsersCollection = defineCollection({
1783
2131
  name: "Users",
1784
2132
  singularName: "User",
1785
2133
  slug: "users",
@@ -1827,7 +2175,7 @@
1827
2175
  name: "Photo URL",
1828
2176
  type: "string",
1829
2177
  columnName: "photo_url",
1830
- url: "image"
2178
+ ui: { url: "image" }
1831
2179
  },
1832
2180
  roles: {
1833
2181
  name: "Roles",
@@ -1922,7 +2270,7 @@
1922
2270
  "roles",
1923
2271
  "createdAt"
1924
2272
  ]
1925
- };
2273
+ });
1926
2274
  //#endregion
1927
2275
  //#region src/data/query_builder.ts
1928
2276
  function or(...conditions) {
@@ -1974,8 +2322,8 @@
1974
2322
  * @example
1975
2323
  * client.collection('users').orderBy('createdAt', 'desc').find()
1976
2324
  */
1977
- orderBy(column, ascending = "asc") {
1978
- this.params.orderBy = `${column}:${ascending}`;
2325
+ orderBy(column, direction = "asc") {
2326
+ this.params.orderBy = [column, direction];
1979
2327
  return this;
1980
2328
  }
1981
2329
  /**
@@ -2030,166 +2378,369 @@
2030
2378
  }
2031
2379
  };
2032
2380
  //#endregion
2033
- //#region src/data/buildRebaseData.ts
2381
+ //#region src/data/filter-dialect.ts
2034
2382
  /**
2035
- * Convert where-clause filter object to the internal DataDriver FilterValues format.
2036
- *
2037
- * Supports multiple value formats:
2038
- * - PostgREST string: { status: "eq.published", age: "gte.18" }
2039
- * - Equality shorthand: { company_profile_id: null, status: "active", age: 18 }
2040
- * - Tuple syntax: { age: [">=", 18], role: ["in", ["admin", "editor"]] }
2041
- *
2042
- * Internal: { status: ["==", "published"], age: [">=", 18] }
2043
- */
2044
- function convertWhereToFilter(where) {
2045
- if (!where) return void 0;
2046
- const operatorMap = {
2047
- "eq": "==",
2048
- "neq": "!=",
2049
- "gt": ">",
2050
- "gte": ">=",
2051
- "lt": "<",
2052
- "lte": "<=",
2053
- "in": "in",
2054
- "nin": "not-in",
2055
- "not-in": "not-in",
2056
- "cs": "array-contains",
2057
- "csa": "array-contains-any",
2058
- "==": "==",
2059
- "!=": "!=",
2060
- ">": ">",
2061
- ">=": ">=",
2062
- "<": "<",
2063
- "<=": "<=",
2064
- "array-contains": "array-contains",
2065
- "array-contains-any": "array-contains-any"
2066
- };
2067
- const filter = {};
2068
- for (const [field, rawValue] of Object.entries(where)) {
2069
- if (rawValue === null) {
2070
- filter[field] = ["==", null];
2071
- continue;
2072
- }
2073
- if (typeof rawValue === "boolean") {
2074
- filter[field] = ["==", rawValue];
2075
- continue;
2076
- }
2077
- if (typeof rawValue === "number") {
2078
- filter[field] = ["==", rawValue];
2383
+ * REST wire-format adapter for the unified filter system.
2384
+ *
2385
+ * This module is the ONLY code in the entire codebase that knows about
2386
+ * PostgREST-style dot-syntax strings (`eq.active`, `gt.18`, `in.(a,b)`).
2387
+ * Everything else speaks `FilterValues` exclusively.
2388
+ *
2389
+ * Wire-format values are always strings — the wire format carries no type
2390
+ * metadata, so type coercion is the responsibility of the server-side data
2391
+ * driver which has access to the collection schema.
2392
+ *
2393
+ * Commas inside list values are backslash-escaped (`\,`), and literal
2394
+ * backslashes are escaped as `\\`.
2395
+ *
2396
+ * @module
2397
+ */
2398
+ /**
2399
+ * Serialize a JS value to its querystring representation.
2400
+ * `null` is serialized as the literal string `"null"`.
2401
+ */
2402
+ function stringifyValue(value) {
2403
+ if (value === null) return "null";
2404
+ return String(value);
2405
+ }
2406
+ /**
2407
+ * Escape a single list item for the wire format.
2408
+ * `\` → `\\`, `,` → `\,`
2409
+ */
2410
+ function escapeListItem(value) {
2411
+ return value.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
2412
+ }
2413
+ /**
2414
+ * Unescape a single list item from the wire format.
2415
+ * `\\` `\`, `\,` → `,`
2416
+ */
2417
+ function unescapeListItem(value) {
2418
+ let result = "";
2419
+ for (let i = 0; i < value.length; i++) if (value[i] === "\\" && i + 1 < value.length) {
2420
+ result += value[i + 1];
2421
+ i++;
2422
+ } else result += value[i];
2423
+ return result;
2424
+ }
2425
+ /**
2426
+ * Split a parenthesized list string on unescaped commas.
2427
+ * Input is the content between `(` and `)`.
2428
+ *
2429
+ * @example
2430
+ * splitListItems("admin,editor") // ["admin", "editor"]
2431
+ * splitListItems("hello\\, world,foo") // ["hello, world", "foo"]
2432
+ */
2433
+ function splitListItems(inner) {
2434
+ const items = [];
2435
+ let current = "";
2436
+ for (let i = 0; i < inner.length; i++) if (inner[i] === "\\" && i + 1 < inner.length) {
2437
+ current += inner[i] + inner[i + 1];
2438
+ i++;
2439
+ } else if (inner[i] === ",") {
2440
+ items.push(unescapeListItem(current));
2441
+ current = "";
2442
+ } else current += inner[i];
2443
+ items.push(unescapeListItem(current));
2444
+ return items;
2445
+ }
2446
+ var REST_OP_LOOKUP = _rebasepro_types.REST_TO_CANONICAL;
2447
+ var CANONICAL_OP_LOOKUP = _rebasepro_types.CANONICAL_TO_REST;
2448
+ /**
2449
+ * Serialize a single canonical condition tuple to a PostgREST dot-string.
2450
+ *
2451
+ * Throws `TypeError` if the input is not a valid `[WhereFilterOp, unknown]` tuple.
2452
+ *
2453
+ * @example
2454
+ * serializeTuple(["==", "active"]) // "eq.active"
2455
+ * serializeTuple(["in", ["admin","editor"]]) // "in.(admin,editor)"
2456
+ * serializeTuple([">=", 18]) // "gte.18"
2457
+ */
2458
+ function serializeTuple(tuple) {
2459
+ if (!Array.isArray(tuple) || tuple.length !== 2) throw new TypeError(`serializeTuple: expected a [WhereFilterOp, value] tuple, got ${JSON.stringify(tuple)}`);
2460
+ const [op, value] = tuple;
2461
+ if (typeof op !== "string") throw new TypeError(`serializeTuple: operator must be a string, got ${typeof op}`);
2462
+ const restOp = CANONICAL_OP_LOOKUP[op];
2463
+ if (!restOp) throw new TypeError(`serializeTuple: unknown operator "${op}". Valid operators: ${Object.keys(_rebasepro_types.CANONICAL_TO_REST).join(", ")}`);
2464
+ if (Array.isArray(value)) return `${restOp}.(${value.map((v) => escapeListItem(stringifyValue(v))).join(",")})`;
2465
+ return `${restOp}.${stringifyValue(value)}`;
2466
+ }
2467
+ /**
2468
+ * Convert `FilterValues` (or `WireFilterValues`) to a PostgREST-style
2469
+ * querystring record.
2470
+ *
2471
+ * - Canonical `[WhereFilterOp, value]` tuples are serialized strictly.
2472
+ * - Pre-serialized PostgREST strings (e.g. `"eq.published"`) are passed through.
2473
+ * - Single conditions produce a string value.
2474
+ * - Multiple conditions on the same field produce a string array (repeated params).
2475
+ *
2476
+ * @example
2477
+ * serializeFilter({ status: ["==", "active"] })
2478
+ * // → { status: "eq.active" }
2479
+ *
2480
+ * serializeFilter({ age: [[">=", 18], ["<", 65]] })
2481
+ * // → { age: ["gte.18", "lt.65"] }
2482
+ *
2483
+ * // Pre-serialized strings pass through unchanged:
2484
+ * serializeFilter({ status: "eq.published" })
2485
+ * // → { status: "eq.published" }
2486
+ */
2487
+ function serializeFilter(filter) {
2488
+ const result = {};
2489
+ for (const [field, condition] of Object.entries(filter)) {
2490
+ if (condition === void 0) continue;
2491
+ if (typeof condition === "string") {
2492
+ result[field] = condition;
2079
2493
  continue;
2080
2494
  }
2081
- if (Array.isArray(rawValue)) {
2082
- const mappedConditions = (Array.isArray(rawValue[0]) ? rawValue : [rawValue]).map(([rawOp, val]) => {
2083
- return [operatorMap[rawOp] ?? "==", val];
2084
- });
2085
- filter[field] = Array.isArray(rawValue[0]) ? mappedConditions : mappedConditions[0];
2495
+ if (Array.isArray(condition) && condition.length > 0 && Array.isArray(condition[0])) result[field] = condition.map(serializeTuple);
2496
+ else result[field] = serializeTuple(condition);
2497
+ }
2498
+ return result;
2499
+ }
2500
+ /**
2501
+ * Parse a single PostgREST dot-string into a `[WhereFilterOp, unknown]` tuple.
2502
+ *
2503
+ * All values are returned as strings — the wire format carries no type
2504
+ * metadata, so coercion is the data driver's responsibility.
2505
+ *
2506
+ * If the string doesn't match a known operator prefix, it falls back to
2507
+ * `["==", originalString]` (treating the whole string as an equality value).
2508
+ * This intentional defense handles values like `"user@host.com"` or
2509
+ * `"1.2.3"` that happen to contain dots.
2510
+ */
2511
+ function deserializeSingle(raw) {
2512
+ const dotIndex = raw.indexOf(".");
2513
+ if (dotIndex === -1) return ["==", raw];
2514
+ const prefix = raw.substring(0, dotIndex);
2515
+ const rest = raw.substring(dotIndex + 1);
2516
+ const canonicalOp = REST_OP_LOOKUP[prefix];
2517
+ if (!canonicalOp) return ["==", raw];
2518
+ if (_rebasepro_types.NULL_OPS.has(canonicalOp)) return [canonicalOp, null];
2519
+ if (rest.startsWith("(") && rest.endsWith(")")) return [canonicalOp, splitListItems(rest.slice(1, -1))];
2520
+ return [canonicalOp, rest];
2521
+ }
2522
+ /**
2523
+ * Convert a PostgREST-style querystring record to `FilterValues`.
2524
+ *
2525
+ * - String values are parsed as single conditions.
2526
+ * - String arrays (repeated query params) become multiple conditions on the same field.
2527
+ *
2528
+ * @example
2529
+ * deserializeFilter({ status: "eq.active" })
2530
+ * // → { status: ["==", "active"] }
2531
+ *
2532
+ * deserializeFilter({ age: ["gte.18", "lt.65"] })
2533
+ * // → { age: [[">=", "18"], ["<", "65"]] }
2534
+ */
2535
+ function deserializeFilter(query) {
2536
+ const result = {};
2537
+ for (const [field, raw] of Object.entries(query)) {
2538
+ if (raw === void 0) continue;
2539
+ if (Array.isArray(raw) && raw.length === 2 && typeof raw[0] === "string" && (0, _rebasepro_types.toCanonicalOp)(raw[0]) === raw[0]) {
2540
+ result[field] = raw;
2086
2541
  continue;
2087
2542
  }
2088
- if (typeof rawValue === "string") {
2089
- const dotIndex = rawValue.indexOf(".");
2090
- if (dotIndex === -1) {
2091
- filter[field] = ["==", rawValue];
2543
+ if (Array.isArray(raw)) {
2544
+ if (raw.length === 0) continue;
2545
+ if (Array.isArray(raw[0]) && raw[0].length === 2 && typeof raw[0][0] === "string" && (0, _rebasepro_types.toCanonicalOp)(raw[0][0]) === raw[0][0]) {
2546
+ result[field] = raw;
2092
2547
  continue;
2093
2548
  }
2094
- const op = rawValue.substring(0, dotIndex);
2095
- let value = rawValue.substring(dotIndex + 1);
2096
- if (typeof value === "string" && value.startsWith("(") && value.endsWith(")")) value = value.slice(1, -1).split(",").map((v) => v.trim());
2097
- if (value === "null") value = null;
2098
- else if (value === "true") value = true;
2099
- else if (value === "false") value = false;
2100
- else if (typeof value === "string" && !isNaN(Number(value)) && value.trim() !== "") value = Number(value);
2101
- const mappedOp = operatorMap[op];
2102
- if (mappedOp) filter[field] = [mappedOp, value];
2549
+ if (raw.length === 1) result[field] = typeof raw[0] === "string" ? deserializeSingle(raw[0]) : ["==", raw[0]];
2550
+ else if (typeof raw[0] === "string" && raw[0].includes(".")) result[field] = raw.map((r) => typeof r === "string" ? deserializeSingle(r) : ["==", r]);
2551
+ else result[field] = ["in", raw];
2552
+ } else if (typeof raw === "string") result[field] = deserializeSingle(raw);
2553
+ else result[field] = ["==", raw];
2554
+ }
2555
+ return result;
2556
+ }
2557
+ /**
2558
+ * Serialize a `LogicalCondition` or `FilterCondition` to its wire-format string.
2559
+ *
2560
+ * @example
2561
+ * serializeLogicalCondition({ column: "status", operator: "==", value: "active" })
2562
+ * // → "status.eq.active"
2563
+ *
2564
+ * serializeLogicalCondition({ type: "or", conditions: [...] })
2565
+ * // → "or(status.eq.active,status.eq.pending)"
2566
+ */
2567
+ function serializeLogicalCondition(cond) {
2568
+ if ("type" in cond) {
2569
+ const inner = (cond.conditions ?? []).map(serializeLogicalCondition).join(",");
2570
+ return `${cond.type}(${inner})`;
2571
+ }
2572
+ const restOp = CANONICAL_OP_LOOKUP[cond.operator] ?? "eq";
2573
+ if (Array.isArray(cond.value)) {
2574
+ const items = cond.value.map((v) => escapeListItem(stringifyValue(v))).join(",");
2575
+ return `${cond.column}.${restOp}.(${items})`;
2576
+ }
2577
+ return `${cond.column}.${restOp}.${stringifyValue(cond.value)}`;
2578
+ }
2579
+ /**
2580
+ * Parse a logical condition wire-format string back into a
2581
+ * `LogicalCondition` or `FilterCondition`.
2582
+ *
2583
+ * @example
2584
+ * deserializeLogicalCondition("status.eq.active")
2585
+ * // → { column: "status", operator: "==", value: "active" }
2586
+ *
2587
+ * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
2588
+ * // → { type: "or", conditions: [...] }
2589
+ */
2590
+ function deserializeLogicalCondition(str) {
2591
+ const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
2592
+ if (logicalMatch) {
2593
+ const type = logicalMatch[1];
2594
+ const innerStr = logicalMatch[2];
2595
+ const conditions = [];
2596
+ let depth = 0;
2597
+ let start = 0;
2598
+ for (let i = 0; i < innerStr.length; i++) if (innerStr[i] === "(") depth++;
2599
+ else if (innerStr[i] === ")") depth--;
2600
+ else if (innerStr[i] === "," && depth === 0) {
2601
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
2602
+ start = i + 1;
2103
2603
  }
2604
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
2605
+ return {
2606
+ type,
2607
+ conditions
2608
+ };
2104
2609
  }
2105
- return Object.keys(filter).length > 0 ? filter : void 0;
2610
+ const firstDot = str.indexOf(".");
2611
+ if (firstDot === -1) return {
2612
+ column: str,
2613
+ operator: "==",
2614
+ value: true
2615
+ };
2616
+ const column = str.substring(0, firstDot);
2617
+ const rest = str.substring(firstDot + 1);
2618
+ const secondDot = rest.indexOf(".");
2619
+ if (secondDot === -1) return {
2620
+ column,
2621
+ operator: "==",
2622
+ value: rest
2623
+ };
2624
+ const opStr = rest.substring(0, secondDot);
2625
+ const valueStr = rest.substring(secondDot + 1);
2626
+ const operator = (0, _rebasepro_types.toCanonicalOp)(opStr) ?? "==";
2627
+ if (valueStr.startsWith("(") && valueStr.endsWith(")")) return {
2628
+ column,
2629
+ operator,
2630
+ value: splitListItems(valueStr.slice(1, -1))
2631
+ };
2632
+ return {
2633
+ column,
2634
+ operator,
2635
+ value: valueStr
2636
+ };
2106
2637
  }
2638
+ //#endregion
2639
+ //#region src/data/buildRebaseData.ts
2107
2640
  /**
2108
- * Parse an orderBy string like "created_at:desc" into [field, direction].
2641
+ * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
2642
+ * Mirrors the client SDK's rowToEntity conversion.
2109
2643
  */
2110
- function parseOrderBy(orderBy) {
2111
- if (!orderBy) return void 0;
2112
- const parts = orderBy.split(":");
2113
- return [parts[0], parts[1] || "asc"];
2644
+ function rowToEntity(row, slug) {
2645
+ return {
2646
+ id: row.id,
2647
+ path: slug,
2648
+ values: row
2649
+ };
2114
2650
  }
2115
2651
  function createDriverAccessor(driver, slug) {
2116
2652
  const accessor = {
2117
2653
  async find(params) {
2118
- const orderParsed = parseOrderBy(params?.orderBy);
2119
- const entities = await driver.fetchCollection({
2654
+ const filter = params?.where ? deserializeFilter(params.where) : void 0;
2655
+ const limit = params?.limit ?? 20;
2656
+ const offset = params?.offset ?? 0;
2657
+ const fetchService = driver.restFetchService;
2658
+ const rows = fetchService && params?.include && params.include.length > 0 ? await fetchService.fetchCollectionForRest(slug, {
2659
+ filter,
2660
+ limit: params?.limit,
2661
+ offset: params?.offset,
2662
+ orderBy: params?.orderBy?.[0],
2663
+ order: params?.orderBy?.[1],
2664
+ searchString: params?.searchString
2665
+ }, params.include) : await driver.fetchCollection({
2120
2666
  path: slug,
2121
2667
  limit: params?.limit,
2122
2668
  offset: params?.offset,
2123
- filter: convertWhereToFilter(params?.where),
2124
- orderBy: orderParsed?.[0],
2125
- order: orderParsed?.[1],
2669
+ filter,
2670
+ orderBy: params?.orderBy?.[0],
2671
+ order: params?.orderBy?.[1],
2126
2672
  searchString: params?.searchString
2127
2673
  });
2128
- const limit = params?.limit ?? 20;
2129
- const offset = params?.offset ?? 0;
2674
+ let total = rows.length + offset;
2675
+ let hasMore = rows.length >= limit;
2676
+ if (driver.count) {
2677
+ total = await driver.count({
2678
+ path: slug,
2679
+ filter
2680
+ });
2681
+ hasMore = offset + rows.length < total;
2682
+ }
2130
2683
  return {
2131
- data: entities,
2684
+ data: rows.map((row) => rowToEntity(row, slug)),
2132
2685
  meta: {
2133
- total: entities.length,
2686
+ total,
2134
2687
  limit,
2135
2688
  offset,
2136
- hasMore: entities.length >= limit
2689
+ hasMore
2137
2690
  }
2138
2691
  };
2139
2692
  },
2140
2693
  async findById(id) {
2141
- return driver.fetchEntity({
2694
+ const row = await driver.fetchOne({
2142
2695
  path: slug,
2143
- entityId: id
2696
+ id
2144
2697
  });
2698
+ return row ? rowToEntity(row, slug) : void 0;
2145
2699
  },
2146
2700
  async create(data, id) {
2147
- return driver.saveEntity({
2701
+ return rowToEntity(await driver.save({
2148
2702
  path: slug,
2149
2703
  values: data,
2150
- entityId: id,
2704
+ id,
2151
2705
  status: "new"
2152
- });
2706
+ }), slug);
2153
2707
  },
2154
2708
  async update(id, data) {
2155
- return driver.saveEntity({
2709
+ return rowToEntity(await driver.save({
2156
2710
  path: slug,
2157
2711
  values: data,
2158
- entityId: id,
2712
+ id,
2159
2713
  status: "existing"
2160
- });
2714
+ }), slug);
2161
2715
  },
2162
2716
  async delete(id) {
2163
- return driver.deleteEntity({ entity: {
2717
+ return driver.delete({ row: {
2164
2718
  id,
2165
2719
  path: slug,
2166
2720
  values: {}
2167
2721
  } });
2168
2722
  },
2169
- deleteAll: driver.deleteAll ? async () => {
2170
- return driver.deleteAll(slug);
2171
- } : void 0,
2172
- count: driver.countEntities ? async (params) => {
2173
- return driver.countEntities({
2723
+ count: driver.count ? async (params) => {
2724
+ const filter = params?.where ? deserializeFilter(params.where) : void 0;
2725
+ return driver.count({
2174
2726
  path: slug,
2175
- filter: convertWhereToFilter(params?.where)
2727
+ filter
2176
2728
  });
2177
2729
  } : void 0,
2178
2730
  listen: driver.listenCollection ? (params, onUpdate, onError) => {
2179
- const orderParsed = parseOrderBy(params?.orderBy);
2180
2731
  const limit = params?.limit ?? 20;
2181
2732
  const offset = params?.offset ?? 0;
2182
2733
  return driver.listenCollection({
2183
2734
  path: slug,
2184
2735
  limit: params?.limit,
2185
2736
  offset: params?.offset,
2186
- filter: convertWhereToFilter(params?.where),
2187
- orderBy: orderParsed?.[0],
2188
- order: orderParsed?.[1],
2737
+ filter: params?.where,
2738
+ orderBy: params?.orderBy?.[0],
2739
+ order: params?.orderBy?.[1],
2189
2740
  searchString: params?.searchString,
2190
2741
  onUpdate: (entities) => {
2191
2742
  onUpdate({
2192
- data: entities,
2743
+ data: entities.map((row) => rowToEntity(row, slug)),
2193
2744
  meta: {
2194
2745
  total: entities.length,
2195
2746
  limit,
@@ -2201,11 +2752,11 @@
2201
2752
  onError
2202
2753
  });
2203
2754
  } : void 0,
2204
- listenById: driver.listenEntity ? (id, onUpdate, onError) => {
2205
- return driver.listenEntity({
2755
+ listenById: driver.listenOne ? (id, onUpdate, onError) => {
2756
+ return driver.listenOne({
2206
2757
  path: slug,
2207
- entityId: id,
2208
- onUpdate: (entity) => onUpdate(entity ?? void 0),
2758
+ id,
2759
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
2209
2760
  onError
2210
2761
  });
2211
2762
  } : void 0,
@@ -2242,7 +2793,7 @@
2242
2793
  * @example
2243
2794
  * const data = buildRebaseData(driver);
2244
2795
  * await data.products.create({ name: "Camera", price: 299 });
2245
- * const { data: items } = await data.products.find({ where: { status: "eq.published" } });
2796
+ * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
2246
2797
  */
2247
2798
  function buildRebaseData(driver) {
2248
2799
  const cache = /* @__PURE__ */ new Map();
@@ -2261,6 +2812,230 @@
2261
2812
  return getAccessor((0, _rebasepro_utils.toSnakeCase)(prop));
2262
2813
  } });
2263
2814
  }
2815
+ /**
2816
+ * Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
2817
+ * (id included) under `.values`, so this is just that payload.
2818
+ */
2819
+ function entityToRow(entity) {
2820
+ return entity.values;
2821
+ }
2822
+ /**
2823
+ * Fluent query builder for the flat SDK data layer. Mirrors {@link QueryBuilder}
2824
+ * but resolves to `FindResult<M>` (flat rows) instead of Entity-wrapped
2825
+ * `FindResponse<M>`.
2826
+ */
2827
+ var SdkQueryBuilder = class {
2828
+ client;
2829
+ params = { where: {} };
2830
+ constructor(client) {
2831
+ this.client = client;
2832
+ }
2833
+ where(columnOrCondition, operator, value) {
2834
+ if (typeof columnOrCondition === "object" && columnOrCondition !== null && "type" in columnOrCondition) {
2835
+ this.params.logical = columnOrCondition;
2836
+ return this;
2837
+ }
2838
+ if (!this.params.where) this.params.where = {};
2839
+ const column = columnOrCondition;
2840
+ const condition = [operator, value];
2841
+ const existing = this.params.where[column];
2842
+ if (existing === void 0) this.params.where[column] = condition;
2843
+ else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) this.params.where[column].push(condition);
2844
+ else {
2845
+ let firstCondition;
2846
+ if (Array.isArray(existing) && existing.length === 2 && typeof existing[0] === "string") firstCondition = existing;
2847
+ else firstCondition = ["==", existing];
2848
+ this.params.where[column] = [firstCondition, condition];
2849
+ }
2850
+ return this;
2851
+ }
2852
+ orderBy(column, direction = "asc") {
2853
+ this.params.orderBy = [column, direction];
2854
+ return this;
2855
+ }
2856
+ limit(count) {
2857
+ this.params.limit = count;
2858
+ return this;
2859
+ }
2860
+ offset(count) {
2861
+ this.params.offset = count;
2862
+ return this;
2863
+ }
2864
+ search(searchString) {
2865
+ this.params.searchString = searchString;
2866
+ return this;
2867
+ }
2868
+ include(...relations) {
2869
+ this.params.include = relations;
2870
+ return this;
2871
+ }
2872
+ async find() {
2873
+ return this.client.find(this.params);
2874
+ }
2875
+ async count() {
2876
+ return this.client.count ? this.client.count(this.params) : 0;
2877
+ }
2878
+ listen(onUpdate, onError) {
2879
+ if (!this.client.listen) throw new Error("Listen is only available when the driver supports realtime.");
2880
+ return this.client.listen(this.params, onUpdate, onError);
2881
+ }
2882
+ };
2883
+ /**
2884
+ * Wrap a Entity-shaped {@link CollectionAccessor} into a flat
2885
+ * {@link SDKCollectionClient}. Every returned record is unwrapped to a flat row
2886
+ * so the backend SDK is byte-for-byte the same shape as the frontend client.
2887
+ */
2888
+ function toSdkCollectionClient(snap) {
2889
+ const client = {
2890
+ async find(params) {
2891
+ const res = await snap.find(params);
2892
+ return {
2893
+ data: res.data.map(entityToRow),
2894
+ meta: res.meta
2895
+ };
2896
+ },
2897
+ async findById(id) {
2898
+ const s = await snap.findById(id);
2899
+ return s ? entityToRow(s) : void 0;
2900
+ },
2901
+ async create(data, id) {
2902
+ return entityToRow(await snap.create(data, id));
2903
+ },
2904
+ async update(id, data) {
2905
+ return entityToRow(await snap.update(id, data));
2906
+ },
2907
+ delete(id) {
2908
+ return snap.delete(id);
2909
+ },
2910
+ count: snap.count ? (params) => snap.count(params) : void 0,
2911
+ listen: snap.listen ? (params, onUpdate, onError) => snap.listen(params, (res) => onUpdate({
2912
+ data: res.data.map(entityToRow),
2913
+ meta: res.meta
2914
+ }), onError) : void 0,
2915
+ listenById: snap.listenById ? (id, onUpdate, onError) => snap.listenById(id, (s) => onUpdate(s ? entityToRow(s) : void 0), onError) : void 0,
2916
+ where(columnOrCondition, operator, value) {
2917
+ const builder = new SdkQueryBuilder(client);
2918
+ if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
2919
+ return builder.where(columnOrCondition, operator, value);
2920
+ },
2921
+ orderBy: (column, direction) => new SdkQueryBuilder(client).orderBy(column, direction),
2922
+ limit: (count) => new SdkQueryBuilder(client).limit(count),
2923
+ offset: (count) => new SdkQueryBuilder(client).offset(count),
2924
+ search: (searchString) => new SdkQueryBuilder(client).search(searchString),
2925
+ include: (...relations) => new SdkQueryBuilder(client).include(...relations)
2926
+ };
2927
+ return client;
2928
+ }
2929
+ /**
2930
+ * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
2931
+ * {@link CollectionAccessor}. Every returned row is re-wrapped into the
2932
+ * `{ id, path, values }` view-model the admin CMS renders.
2933
+ */
2934
+ function toEntityAccessor(sdk, slug) {
2935
+ const accessor = {
2936
+ async find(params) {
2937
+ const res = await sdk.find(params);
2938
+ return {
2939
+ data: res.data.map((row) => rowToEntity(row, slug)),
2940
+ meta: res.meta
2941
+ };
2942
+ },
2943
+ async findById(id) {
2944
+ const row = await sdk.findById(id);
2945
+ return row ? rowToEntity(row, slug) : void 0;
2946
+ },
2947
+ async create(data, id) {
2948
+ return rowToEntity(await sdk.create(data, id), slug);
2949
+ },
2950
+ async update(id, data) {
2951
+ const row = await sdk.update(id, data);
2952
+ if (!row) throw new Error(`Update returned no data for id ${id}`);
2953
+ return rowToEntity(row, slug);
2954
+ },
2955
+ delete(id) {
2956
+ return sdk.delete(id);
2957
+ },
2958
+ count: sdk.count ? (params) => sdk.count(params) : void 0,
2959
+ listen: sdk.listen ? (params, onUpdate, onError) => sdk.listen(params, (res) => onUpdate({
2960
+ data: res.data.map((row) => rowToEntity(row, slug)),
2961
+ meta: res.meta
2962
+ }), onError) : void 0,
2963
+ listenById: sdk.listenById ? (id, onUpdate, onError) => sdk.listenById(id, (row) => onUpdate(row ? rowToEntity(row, slug) : void 0), onError) : void 0,
2964
+ where(columnOrCondition, operator, value) {
2965
+ const builder = new QueryBuilder(accessor);
2966
+ if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
2967
+ return builder.where(columnOrCondition, operator, value);
2968
+ },
2969
+ orderBy: (column, direction) => new QueryBuilder(accessor).orderBy(column, direction),
2970
+ limit: (count) => new QueryBuilder(accessor).limit(count),
2971
+ offset: (count) => new QueryBuilder(accessor).offset(count),
2972
+ search: (searchString) => new QueryBuilder(accessor).search(searchString),
2973
+ include: (...relations) => new QueryBuilder(accessor).include(...relations)
2974
+ };
2975
+ return accessor;
2976
+ }
2977
+ /**
2978
+ * Wrap a flat {@link RebaseSdkData} into a Entity-shaped {@link RebaseData}.
2979
+ *
2980
+ * This is the **CMS boundary**: the SDK client (`client.data`) returns flat
2981
+ * rows, but the admin renders the `Entity` view-model (`entity.values.*`).
2982
+ * `core/Rebase.tsx` wraps `client.data` through this before handing it to the
2983
+ * CMS `RebaseDataContext` — without it the admin renders rows with only their
2984
+ * `id`.
2985
+ */
2986
+ function wrapAsEntityData(sdkData) {
2987
+ const cache = /* @__PURE__ */ new Map();
2988
+ function getAccessor(slug) {
2989
+ let accessor = cache.get(slug);
2990
+ if (!accessor) {
2991
+ accessor = toEntityAccessor(sdkData.collection(slug), slug);
2992
+ cache.set(slug, accessor);
2993
+ }
2994
+ return accessor;
2995
+ }
2996
+ return new Proxy({ collection: getAccessor }, { get(_target, prop) {
2997
+ if (prop === "collection") return getAccessor;
2998
+ if (typeof prop === "symbol") return void 0;
2999
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return void 0;
3000
+ return getAccessor((0, _rebasepro_utils.toSnakeCase)(prop));
3001
+ } });
3002
+ }
3003
+ /**
3004
+ * Wrap a Entity-shaped {@link RebaseData} into a flat {@link RebaseSdkData}.
3005
+ *
3006
+ * Every collection accessor is adapted to return flat rows. Use this to derive
3007
+ * the flat SDK data layer (`context.data`) from an existing Entity data layer
3008
+ * — e.g. the admin routes its Entity data via `useData()` and exposes the
3009
+ * same routing as flat `context.data` for callbacks by wrapping it here.
3010
+ */
3011
+ function wrapAsSdkData(entityData) {
3012
+ const cache = /* @__PURE__ */ new Map();
3013
+ function getAccessor(slug) {
3014
+ let accessor = cache.get(slug);
3015
+ if (!accessor) {
3016
+ accessor = toSdkCollectionClient(entityData.collection(slug));
3017
+ cache.set(slug, accessor);
3018
+ }
3019
+ return accessor;
3020
+ }
3021
+ return new Proxy({ collection: getAccessor }, { get(_target, prop) {
3022
+ if (prop === "collection") return getAccessor;
3023
+ if (typeof prop === "symbol") return void 0;
3024
+ if (prop === "then" || prop === "toJSON" || prop === "$$typeof") return void 0;
3025
+ return getAccessor((0, _rebasepro_utils.toSnakeCase)(prop));
3026
+ } });
3027
+ }
3028
+ /**
3029
+ * Build a flat {@link RebaseSdkData} from a `DataDriver`.
3030
+ *
3031
+ * This is the developer-facing SDK data layer used by backend framework
3032
+ * callbacks & scripts (`context.data` / `rebase.data`). It returns flat rows —
3033
+ * identical in shape to the frontend SDK client — so the API is symmetric
3034
+ * across front and back. The admin CMS uses {@link buildRebaseData} (Entity).
3035
+ */
3036
+ function buildSdkData(driver) {
3037
+ return wrapAsSdkData(buildRebaseData(driver));
3038
+ }
2264
3039
  //#endregion
2265
3040
  //#region src/data/buildRoutedRebaseData.ts
2266
3041
  /**
@@ -2305,6 +3080,57 @@
2305
3080
  } });
2306
3081
  }
2307
3082
  //#endregion
3083
+ //#region src/data/sort-dialect.ts
3084
+ /**
3085
+ * Sort-order wire codec.
3086
+ *
3087
+ * This is the ONLY module that knows about the colon-delimited wire format
3088
+ * (`"field:direction"`) used in HTTP query parameters.
3089
+ * Everything else speaks {@link OrderByTuple} exclusively.
3090
+ *
3091
+ * Mirrors the filter architecture in `filter-dialect.ts`.
3092
+ *
3093
+ * @module
3094
+ */
3095
+ /**
3096
+ * Serialize an {@link OrderByTuple} to the wire format `"field:direction"`.
3097
+ *
3098
+ * **Runtime tolerance:** if the input is already a well-formed wire string
3099
+ * (from an untyped JS caller), it is returned unchanged.
3100
+ * This is undocumented tolerance, not public API — don't rely on it.
3101
+ *
3102
+ * @param orderBy - A canonical `[field, direction]` tuple, or at runtime
3103
+ * possibly a pre-serialized string (undocumented tolerance).
3104
+ * @returns The wire-format string, or `undefined` if the input is falsy.
3105
+ *
3106
+ * @remarks
3107
+ * Field names containing `:` are representable in the tuple form but
3108
+ * **not** on the wire — this is an inherent limitation of the colon-delimited
3109
+ * encoding and is not resolved here.
3110
+ */
3111
+ function serializeOrderBy(orderBy) {
3112
+ if (!orderBy) return void 0;
3113
+ if (typeof orderBy === "string") return orderBy;
3114
+ return `${orderBy[0]}:${orderBy[1]}`;
3115
+ }
3116
+ /**
3117
+ * Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
3118
+ *
3119
+ * Lenient parsing (matches existing server behaviour):
3120
+ * - Bare field name (no colon): `"name"` → `["name", "asc"]`
3121
+ * - Unknown direction: `"name:foo"` → `["name", "asc"]`
3122
+ * - Empty / falsy input: → `undefined`
3123
+ *
3124
+ * @param raw - The wire-format string from an HTTP query parameter.
3125
+ * @returns The canonical tuple, or `undefined` if the input is empty/falsy.
3126
+ */
3127
+ function deserializeOrderBy(raw) {
3128
+ if (!raw) return void 0;
3129
+ const idx = raw.indexOf(":");
3130
+ if (idx === -1) return [raw, "asc"];
3131
+ return [raw.slice(0, idx), raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
3132
+ }
3133
+ //#endregion
2308
3134
  //#region src/table-classification.ts
2309
3135
  /** Schemas that are always considered Rebase-internal. */
2310
3136
  var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
@@ -2392,18 +3218,13 @@
2392
3218
  exports.addInitialSlash = addInitialSlash;
2393
3219
  exports.and = and;
2394
3220
  exports.applyPropertyConditions = applyPropertyConditions;
2395
- exports.buildAdditionalFieldDelegate = buildAdditionalFieldDelegate;
2396
3221
  exports.buildCollection = buildCollection;
2397
3222
  exports.buildConditionContext = buildConditionContext;
2398
- exports.buildEntityCallbacks = buildEntityCallbacks;
2399
- exports.buildEnum = buildEnum;
2400
- exports.buildEnumValueConfig = buildEnumValueConfig;
2401
- exports.buildProperties = buildProperties;
2402
- exports.buildPropertiesOrBuilder = buildPropertiesOrBuilder;
2403
3223
  exports.buildProperty = buildProperty;
2404
3224
  exports.buildPropertyCallbacks = buildPropertyCallbacks;
2405
3225
  exports.buildRebaseData = buildRebaseData;
2406
3226
  exports.buildRoutedRebaseData = buildRoutedRebaseData;
3227
+ exports.buildSdkData = buildSdkData;
2407
3228
  exports.canCreateEntity = canCreateEntity;
2408
3229
  exports.canDeleteEntity = canDeleteEntity;
2409
3230
  exports.canEditEntity = canEditEntity;
@@ -2415,9 +3236,14 @@
2415
3236
  exports.createRelationRef = createRelationRef;
2416
3237
  exports.createRelationRefWithData = createRelationRefWithData;
2417
3238
  exports.defaultUsersCollection = defaultUsersCollection;
3239
+ exports.defineCollection = defineCollection;
3240
+ exports.deserializeFilter = deserializeFilter;
3241
+ exports.deserializeLogicalCondition = deserializeLogicalCondition;
3242
+ exports.deserializeOrderBy = deserializeOrderBy;
2418
3243
  exports.detectJunctionTables = detectJunctionTables;
2419
3244
  exports.enumToObjectEntries = enumToObjectEntries;
2420
3245
  exports.evaluateCondition = evaluateCondition;
3246
+ exports.evaluatePolicy = evaluatePolicy;
2421
3247
  exports.findRelation = findRelation;
2422
3248
  exports.fullPathToCollectionSegments = fullPathToCollectionSegments;
2423
3249
  exports.getArrayResolvedProperties = getArrayResolvedProperties;
@@ -2446,6 +3272,7 @@
2446
3272
  exports.isRebaseInternalTable = isRebaseInternalTable;
2447
3273
  exports.normalizeToEntityRelation = normalizeToEntityRelation;
2448
3274
  exports.or = or;
3275
+ exports.policyToPostgres = policyToPostgres;
2449
3276
  exports.registerConditionOperations = registerConditionOperations;
2450
3277
  exports.removeInitialAndTrailingSlashes = removeInitialAndTrailingSlashes;
2451
3278
  exports.removeInitialSlash = removeInitialSlash;
@@ -2456,6 +3283,7 @@
2456
3283
  exports.resolveDataSource = resolveDataSource;
2457
3284
  exports.resolveDefaultSelectedView = resolveDefaultSelectedView;
2458
3285
  exports.resolveEnumValues = resolveEnumValues;
3286
+ exports.resolveFilterOperators = resolveFilterOperators;
2459
3287
  exports.resolveProperties = resolveProperties;
2460
3288
  exports.resolveProperty = resolveProperty;
2461
3289
  exports.resolvePropertyEnum = resolvePropertyEnum;
@@ -2463,14 +3291,21 @@
2463
3291
  exports.resolveRelationProperty = resolveRelationProperty;
2464
3292
  exports.resolveStorageFilenameString = resolveStorageFilenameString;
2465
3293
  exports.resolveStoragePathString = resolveStoragePathString;
3294
+ exports.resolveStorageSource = resolveStorageSource;
2466
3295
  exports.sanitizeData = sanitizeData;
2467
3296
  exports.sanitizeRelation = sanitizeRelation;
3297
+ exports.securityRuleToConditions = securityRuleToConditions;
2468
3298
  exports.segmentsToStrippedPath = segmentsToStrippedPath;
3299
+ exports.serializeFilter = serializeFilter;
3300
+ exports.serializeLogicalCondition = serializeLogicalCondition;
3301
+ exports.serializeOrderBy = serializeOrderBy;
2469
3302
  exports.sortProperties = sortProperties;
2470
3303
  exports.stripCollectionPath = stripCollectionPath;
2471
3304
  exports.traverseValueProperty = traverseValueProperty;
2472
3305
  exports.traverseValuesProperties = traverseValuesProperties;
2473
3306
  exports.updateDateAutoValues = updateDateAutoValues;
3307
+ exports.wrapAsEntityData = wrapAsEntityData;
3308
+ exports.wrapAsSdkData = wrapAsSdkData;
2474
3309
  });
2475
3310
 
2476
3311
  //# sourceMappingURL=index.umd.js.map