@rebasepro/server-mongo 0.13.0 → 0.13.1-canary.g06dbe5b

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 (35) hide show
  1. package/dist/MongoBootstrapper.d.ts.map +1 -1
  2. package/dist/auth/ensure-collections.d.ts.map +1 -1
  3. package/dist/db/MongoConditionBuilder.d.ts +28 -1
  4. package/dist/db/MongoConditionBuilder.d.ts.map +1 -1
  5. package/dist/db/MongoDataService.d.ts +19 -1
  6. package/dist/db/MongoDataService.d.ts.map +1 -1
  7. package/dist/db/securityRuleFilter.d.ts +65 -0
  8. package/dist/db/securityRuleFilter.d.ts.map +1 -0
  9. package/dist/ensure-collections-Bl12bSwO.js +130 -0
  10. package/dist/ensure-collections-Bl12bSwO.js.map +1 -0
  11. package/dist/factory.d.ts +11 -1
  12. package/dist/factory.d.ts.map +1 -1
  13. package/dist/index.es.js +487 -249
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/services/MongoDriver.d.ts +37 -5
  16. package/dist/services/MongoDriver.d.ts.map +1 -1
  17. package/dist/services/MongoRealtimeService.d.ts +23 -14
  18. package/dist/services/MongoRealtimeService.d.ts.map +1 -1
  19. package/dist/{websocket-74DDOdK0.js → websocket-Bs54-gR-.js} +32 -11
  20. package/dist/websocket-Bs54-gR-.js.map +1 -0
  21. package/dist/websocket.d.ts +2 -2
  22. package/dist/websocket.d.ts.map +1 -1
  23. package/package.json +5 -5
  24. package/src/MongoBootstrapper.ts +10 -2
  25. package/src/auth/ensure-collections.ts +61 -13
  26. package/src/db/MongoConditionBuilder.ts +112 -40
  27. package/src/db/MongoDataService.ts +51 -14
  28. package/src/db/securityRuleFilter.ts +398 -0
  29. package/src/factory.ts +17 -4
  30. package/src/services/MongoDriver.ts +101 -262
  31. package/src/services/MongoRealtimeService.ts +124 -66
  32. package/src/websocket.ts +66 -15
  33. package/dist/ensure-collections-Bkx_O5CQ.js +0 -94
  34. package/dist/ensure-collections-Bkx_O5CQ.js.map +0 -1
  35. package/dist/websocket-74DDOdK0.js.map +0 -1
@@ -22,16 +22,17 @@ import {
22
22
  RebaseClient,
23
23
  RebaseData,
24
24
  RebaseSdkData,
25
- SecurityRule
25
+ SecurityOperation
26
26
  } from "@rebasepro/types";
27
27
  import { MongoDataService } from "../db/MongoDataService";
28
28
  import { MongoRealtimeService } from "./MongoRealtimeService";
29
29
  import { MongoHistoryService } from "./MongoHistoryService";
30
- import { buildPropertyCallbacks, updateDateAutoValues, buildSdkData, checkOperation } from "@rebasepro/common";
30
+ import { buildPropertyCallbacks, updateDateAutoValues, buildSdkData, checkOperation, PolicyClauses } from "@rebasepro/common";
31
31
  import { mergeDeep } from "@rebasepro/utils";
32
32
  import { Filter, Document } from "mongodb";
33
33
  import { ApiError } from "@rebasepro/server";
34
34
  import { MongoConditionBuilder } from "../db/MongoConditionBuilder";
35
+ import { assertSecurityRulesEnforceable, buildMongoFilterFromSecurityRules } from "../db/securityRuleFilter";
35
36
  import { logger } from "@rebasepro/server";
36
37
 
37
38
  /**
@@ -109,23 +110,17 @@ propertyCallbacks: undefined };
109
110
  /**
110
111
  * Fetch a collection of rows
111
112
  */
112
- async fetchCollection<M extends Record<string, any>>({
113
- path,
114
- collection,
115
- filter,
116
- limit,
117
- startAfter,
118
- orderBy,
119
- searchString,
120
- order
121
- }: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> {
113
+ async fetchCollection<M extends Record<string, any>>(
114
+ props: FetchCollectionProps<M>
115
+ ): Promise<Record<string, unknown>[]> {
116
+ // Forwarded whole rather than re-listed. The hand-written list here
117
+ // named eight of the eleven fields `FetchCollectionProps` declares, so
118
+ // `logical` and `offset` were accepted by every type-checked boundary
119
+ // above and then dropped — an `or(...)` query ran unfiltered and
120
+ // `?offset=` served page one.
121
+ const { path, collection, ...query } = props;
122
122
  const rows = await this.dataService.fetchCollection<M>(path, {
123
- filter,
124
- limit,
125
- startAfter,
126
- orderBy,
127
- order,
128
- searchString,
123
+ ...query,
129
124
  collection: collection as CollectionConfig
130
125
  });
131
126
 
@@ -173,7 +168,13 @@ propertyCallbacks: undefined };
173
168
  }
174
169
 
175
170
  /**
176
- * Listen to collection changes
171
+ * Listen to collection changes.
172
+ *
173
+ * `authContext` is not part of `ListenCollectionProps`; it is supplied by
174
+ * {@link AuthenticatedMongoDriver}, which is the only caller that has one.
175
+ * It has to travel *into* the subscription config, because that config is
176
+ * what every re-fetch reads — the wrapper used to stamp the field on the
177
+ * `Subscription` object instead, and nothing has ever read that one.
177
178
  */
178
179
  listenCollection<M extends Record<string, any>>({
179
180
  path,
@@ -186,7 +187,7 @@ propertyCallbacks: undefined };
186
187
  order,
187
188
  onUpdate,
188
189
  onError
189
- }: ListenCollectionProps<M>): () => void {
190
+ }: ListenCollectionProps<M>, authContext?: { uid: string; roles: string[] }): () => void {
190
191
  const subscriptionId = this.generateSubscriptionId();
191
192
 
192
193
  const callback = (rows: Record<string, unknown>[]) => {
@@ -210,7 +211,8 @@ propertyCallbacks: undefined };
210
211
  order,
211
212
  limit,
212
213
  startAfter,
213
- searchString
214
+ searchString,
215
+ authContext
214
216
  },
215
217
  callback
216
218
  );
@@ -282,7 +284,7 @@ propertyCallbacks: undefined };
282
284
  collection,
283
285
  onUpdate,
284
286
  onError
285
- }: ListenOneProps<M>): () => void {
287
+ }: ListenOneProps<M>, authContext?: { uid: string; roles: string[] }): () => void {
286
288
  const subscriptionId = this.generateSubscriptionId();
287
289
 
288
290
  const callback = (row: Record<string, unknown> | null) => {
@@ -301,7 +303,8 @@ propertyCallbacks: undefined };
301
303
  {
302
304
  clientId: "driver",
303
305
  path,
304
- id
306
+ id,
307
+ authContext
305
308
  },
306
309
  callback
307
310
  );
@@ -656,9 +659,18 @@ propertyCallbacks: undefined };
656
659
  async count<M extends Record<string, any>>({
657
660
  path,
658
661
  collection,
659
- filter
662
+ filter,
663
+ logical,
664
+ searchString
660
665
  }: FetchCollectionProps<M>): Promise<number> {
661
- return this.dataService.count<M>(path, { filter });
666
+ // The same narrowing the listing gets, or the total describes a
667
+ // different query than the rows it is reported beside.
668
+ return this.dataService.count<M>(path, {
669
+ filter,
670
+ logical,
671
+ searchString,
672
+ collection: collection as CollectionConfig
673
+ });
662
674
  }
663
675
 
664
676
  /**
@@ -719,8 +731,13 @@ export class AuthenticatedMongoDriver implements DataDriver {
719
731
  return [];
720
732
  }
721
733
 
734
+ // `logical` belongs in this query, not in the props spread below: the
735
+ // repository reads `rawQuery ?? buildQuery(...)`, so a `logical` that
736
+ // travelled only in the spread was never consulted — and a dropped
737
+ // `or(...)` group does not fail, it widens.
722
738
  const userQuery = MongoConditionBuilder.buildQuery({
723
739
  filter: props.filter,
740
+ logical: props.logical,
724
741
  searchString: props.searchString,
725
742
  properties: resolvedCollection?.properties
726
743
  });
@@ -780,82 +797,103 @@ export class AuthenticatedMongoDriver implements DataDriver {
780
797
  }
781
798
 
782
799
  listenCollection<M extends Record<string, any>>(props: ListenCollectionProps<M>): () => void {
783
- const unsubscribe = this.delegate.listenCollection(props);
784
- const authContext = { uid: this.user.uid,
800
+ // Handed to the subscription rather than stamped on it afterwards: the
801
+ // config is what every re-fetch reads, and the stamp also landed after
802
+ // the initial fetch had already been dispatched unfiltered.
803
+ return this.delegate.listenCollection(props, this.authContext());
804
+ }
805
+
806
+ /** The acting user, in the shape the realtime subscriptions carry. */
807
+ private authContext(): { uid: string; roles: string[] } {
808
+ return { uid: this.user.uid,
785
809
  roles: this.user.roles ?? [] };
786
- const subscriptions = this.delegate.getRealtimeService().getSubscriptions();
787
- const lastEntry = Array.from(subscriptions.entries()).pop();
788
- const lastSub = lastEntry?.[1];
789
- if (lastSub && lastSub.config.clientId === "driver") {
790
- lastSub.authContext = authContext;
791
- }
792
- return unsubscribe;
810
+ }
811
+
812
+ /**
813
+ * Evaluate the collection's rules for one row, fail-closed.
814
+ *
815
+ * A path with no resolvable collection has no declared rules — the same
816
+ * answer `buildMongoFilterFromSecurityRules` gives a listing on such a path,
817
+ * so the two never disagree about whether this engine has row security.
818
+ */
819
+ private authorize(
820
+ collection: CollectionConfig | undefined,
821
+ entity: Entity,
822
+ operation: SecurityOperation,
823
+ clauses?: PolicyClauses
824
+ ): boolean {
825
+ if (!collection) return true;
826
+ return checkOperation(collection, { user: this.user }, entity, operation, { onUnknown: "deny",
827
+ clauses });
793
828
  }
794
829
 
795
830
  async fetchOne<M extends Record<string, any>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined> {
796
831
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
832
+ assertSecurityRulesEnforceable(resolvedCollection, "select");
797
833
  const row = await this.delegate.fetchOne(props);
798
- if (row) {
799
- const authorized = checkOperation(resolvedCollection as CollectionConfig, { user: this.user }, rowToEntityForCheck(row, props.path), "select", { onUnknown: "deny" });
800
- if (!authorized) {
801
- return undefined;
802
- }
834
+ if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) {
835
+ return undefined;
803
836
  }
804
837
  return row;
805
838
  }
806
839
 
807
840
  listenOne<M extends Record<string, any>>(props: ListenOneProps<M>): () => void {
808
- const unsubscribe = this.delegate.listenOne(props);
809
- const authContext = { uid: this.user.uid,
810
- roles: this.user.roles ?? [] };
811
- const subscriptions = this.delegate.getRealtimeService().getSubscriptions();
812
- const lastEntry = Array.from(subscriptions.entries()).pop();
813
- const lastSub = lastEntry?.[1];
814
- if (lastSub && lastSub.config.clientId === "driver") {
815
- lastSub.authContext = authContext;
816
- }
817
- return unsubscribe;
841
+ return this.delegate.listenOne(props, this.authContext());
818
842
  }
819
843
 
844
+ /**
845
+ * Save, with both halves of the rule checked *before* the write.
846
+ *
847
+ * There is no transaction here, so a check that runs after
848
+ * `delegate.save` cannot undo anything: the document is written, history is
849
+ * recorded and subscribers have been notified by then, and a 403 at that
850
+ * point only misleads the caller about what happened. Postgres evaluates
851
+ * `WITH CHECK` inside the transaction; the closest this driver can get is to
852
+ * evaluate it against the row as it *will* be, and refuse before writing.
853
+ */
820
854
  async save<M extends Record<string, any>>(props: SaveProps<M>): Promise<Record<string, unknown>> {
821
855
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
822
856
 
823
857
  if (props.status === "existing" && props.id) {
858
+ assertSecurityRulesEnforceable(resolvedCollection, "update");
824
859
  const existing = await this.delegate.fetchOne({ path: props.path,
825
860
  id: props.id,
826
861
  collection: resolvedCollection });
827
- if (!existing || !checkOperation(resolvedCollection as CollectionConfig, { user: this.user }, rowToEntityForCheck(existing, props.path), "update", { onUnknown: "deny" })) {
862
+ // USING against the stored row, WITH CHECK against the row that
863
+ // will replace it — the split Postgres makes, and the reason
864
+ // `clauses` exists on `checkOperation`.
865
+ const projected = rowToEntityForCheck({ ...existing,
866
+ ...props.values,
867
+ id: props.id }, props.path);
868
+ if (!existing ||
869
+ !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.path), "update", "using") ||
870
+ !this.authorize(resolvedCollection, projected, "update", "withCheck")) {
828
871
  throw ApiError.forbidden("Forbidden");
829
872
  }
830
873
  } else {
874
+ assertSecurityRulesEnforceable(resolvedCollection, "insert");
831
875
  const tempEntity = { id: props.id || "new",
832
876
  path: props.path,
833
877
  values: props.values } as Entity;
834
- if (!checkOperation(resolvedCollection as CollectionConfig, { user: this.user }, tempEntity, "insert", { onUnknown: "deny" })) {
878
+ if (!this.authorize(resolvedCollection, tempEntity, "insert")) {
835
879
  throw ApiError.forbidden("Forbidden");
836
880
  }
837
881
  }
838
882
 
839
- const saved = await this.delegate.save({
883
+ return this.delegate.save({
840
884
  ...props,
841
885
  collection: resolvedCollection
842
886
  });
843
-
844
- // After save / withCheck rules verification
845
- if (!checkOperation(resolvedCollection as CollectionConfig, { user: this.user }, rowToEntityForCheck(saved, props.path), props.status === "existing" ? "update" : "insert", { onUnknown: "deny" })) {
846
- throw ApiError.forbidden("Forbidden");
847
- }
848
-
849
- return saved;
850
887
  }
851
888
 
852
889
  async delete<M extends Record<string, any>>(props: DeleteProps<M>): Promise<void> {
853
890
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
891
+ assertSecurityRulesEnforceable(resolvedCollection, "delete");
854
892
 
855
893
  const existing = await this.delegate.fetchOne({ path: props.row.path,
856
894
  id: props.row.id,
857
895
  collection: resolvedCollection });
858
- if (!existing || !checkOperation(resolvedCollection as CollectionConfig, { user: this.user }, rowToEntityForCheck(existing, props.row.path), "delete", { onUnknown: "deny" })) {
896
+ if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) {
859
897
  throw ApiError.forbidden("Forbidden");
860
898
  }
861
899
 
@@ -883,8 +921,11 @@ collection: resolvedCollection });
883
921
  return 0;
884
922
  }
885
923
 
924
+ // Narrowed by exactly what the listing is narrowed by — `logical`
925
+ // included — or the total describes a different query than the rows.
886
926
  const userQuery = MongoConditionBuilder.buildQuery({
887
927
  filter: props.filter,
928
+ logical: props.logical,
888
929
  searchString: props.searchString,
889
930
  properties: resolvedCollection?.properties
890
931
  });
@@ -916,205 +957,3 @@ function rowToEntityForCheck(row: Record<string, unknown>, path: string): Entity
916
957
  values: row
917
958
  };
918
959
  }
919
-
920
- function getMongoFilterForSQL(sqlString: string, user: User): Filter<Document> | null {
921
- let cleanedSQL = sqlString.trim();
922
- while (cleanedSQL.startsWith("(") && cleanedSQL.endsWith(")")) {
923
- let openCount = 0;
924
- let isEnclosing = true;
925
- for (let i = 0; i < cleanedSQL.length - 1; i++) {
926
- if (cleanedSQL[i] === "(") openCount++;
927
- else if (cleanedSQL[i] === ")") openCount--;
928
- if (openCount === 0) {
929
- isEnclosing = false;
930
- break;
931
- }
932
- }
933
- if (isEnclosing) {
934
- cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
935
- } else {
936
- break;
937
- }
938
- }
939
-
940
- const splitByTopLevel = (str: string, delimiter: string) => {
941
- const parts: string[] = [];
942
- let current = "";
943
- let openCount = 0;
944
- let i = 0;
945
- while (i < str.length) {
946
- if (str[i] === "(") openCount++;
947
- else if (str[i] === ")") openCount--;
948
-
949
- if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
950
- parts.push(current);
951
- current = "";
952
- i += delimiter.length;
953
- } else {
954
- current += str[i];
955
- i++;
956
- }
957
- }
958
- parts.push(current);
959
- return parts;
960
- };
961
-
962
- const orParts = splitByTopLevel(cleanedSQL, " OR ");
963
- if (orParts.length > 1) {
964
- const subFilters = orParts.map(part => getMongoFilterForSQL(part, user)).filter(f => f !== null) as Filter<Document>[];
965
- if (subFilters.length === 0) return null;
966
- if (subFilters.length === 1) return subFilters[0];
967
- return { $or: subFilters } as Filter<Document>;
968
- }
969
-
970
- const andParts = splitByTopLevel(cleanedSQL, " AND ");
971
- if (andParts.length > 1) {
972
- const subFilters = andParts.map(part => getMongoFilterForSQL(part, user)).filter(f => f !== null) as Filter<Document>[];
973
- if (subFilters.length === 0) return null;
974
- if (subFilters.length === 1) return subFilters[0];
975
- return { $and: subFilters } as Filter<Document>;
976
- }
977
-
978
- const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
979
- if (roleIntersectMatch && roleIntersectMatch[1]) {
980
- const requiredRoles = roleIntersectMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
981
- const userRoles = user.roles || [];
982
- const matches = requiredRoles.some(r => userRoles.includes(r));
983
- return matches ? {} : { _id: { $exists: false } };
984
- }
985
-
986
- const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
987
- if (roleContainMatch && roleContainMatch[1]) {
988
- const requiredRoles = roleContainMatch[1].split(",").map(r => r.trim().replace(/'/g, ""));
989
- const userRoles = user.roles || [];
990
- const matches = requiredRoles.every(r => userRoles.includes(r));
991
- return matches ? {} : { _id: { $exists: false } };
992
- }
993
-
994
- const pattern1 = new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
995
- const pattern2 = new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
996
-
997
- const match1 = cleanedSQL.match(pattern1);
998
- if (match1 && match1[1]) {
999
- return { [match1[1]]: user.uid };
1000
- }
1001
-
1002
- const match2 = cleanedSQL.match(pattern2);
1003
- if (match2 && match2[1]) {
1004
- return { [match2[1]]: user.uid };
1005
- }
1006
-
1007
- const simpleEqualityMatch = cleanedSQL.match(/^\{?([\w_]+)\}?\s*(=|!=)\s*'([^']+)'$/i);
1008
- if (simpleEqualityMatch) {
1009
- const field = simpleEqualityMatch[1];
1010
- const operator = simpleEqualityMatch[2];
1011
- const value = simpleEqualityMatch[3];
1012
- if (operator === "=") return { [field]: value };
1013
- if (operator === "!=") return { [field]: { $ne: value } };
1014
- }
1015
-
1016
- return {};
1017
- }
1018
-
1019
- function getMongoFilterForRule(rule: SecurityRule, user: User): Filter<Document> | null {
1020
- if (rule.access === "public") return {};
1021
-
1022
- const filters: Filter<Document>[] = [];
1023
-
1024
- if (rule.ownerField) {
1025
- filters.push({ [rule.ownerField]: user.uid });
1026
- }
1027
-
1028
- if (rule.using) {
1029
- const f = getMongoFilterForSQL(rule.using, user);
1030
- if (f) filters.push(f);
1031
- }
1032
-
1033
- if (rule.withCheck) {
1034
- const f = getMongoFilterForSQL(rule.withCheck, user);
1035
- if (f) filters.push(f);
1036
- }
1037
-
1038
- if (filters.length === 0) return {};
1039
- if (filters.length === 1) return filters[0];
1040
- return { $and: filters } as Filter<Document>;
1041
- }
1042
-
1043
- function buildMongoFilterFromSecurityRules<M extends Record<string, any>>(
1044
- collection: CollectionConfig<M> | undefined,
1045
- user: User,
1046
- targetOperation: "select" | "insert" | "update" | "delete"
1047
- ): Filter<Document> | null {
1048
- if (!collection || !collection.securityRules || collection.securityRules.length === 0) {
1049
- return {};
1050
- }
1051
-
1052
- const applicableRules = collection.securityRules.filter((r: SecurityRule) =>
1053
- r.operation === targetOperation ||
1054
- r.operation === "all" ||
1055
- r.operations?.includes(targetOperation) ||
1056
- r.operations?.includes("all")
1057
- );
1058
-
1059
- if (applicableRules.length === 0) {
1060
- return null;
1061
- }
1062
-
1063
- const userRoleIds = user.roles ?? [];
1064
- const userRoles = [...userRoleIds, "public"];
1065
- const roleApplicableRules = applicableRules.filter((rule: SecurityRule) => {
1066
- if (!rule.roles || rule.roles.length === 0) return true;
1067
- return rule.roles.some((r: string) => userRoles.includes(r));
1068
- });
1069
-
1070
- if (roleApplicableRules.length === 0) {
1071
- return null;
1072
- }
1073
-
1074
- const permissiveFilters: Filter<Document>[] = [];
1075
- const restrictiveFilters: Filter<Document>[] = [];
1076
-
1077
- for (const rule of roleApplicableRules) {
1078
- const mode = rule.mode || "permissive";
1079
- const filter = getMongoFilterForRule(rule, user);
1080
- if (filter === null) {
1081
- if (mode === "restrictive") {
1082
- return null;
1083
- }
1084
- continue;
1085
- }
1086
-
1087
- if (mode === "restrictive") {
1088
- restrictiveFilters.push(filter);
1089
- } else {
1090
- permissiveFilters.push(filter);
1091
- }
1092
- }
1093
-
1094
- const finalAnds: Filter<Document>[] = [];
1095
-
1096
- if (permissiveFilters.length > 0) {
1097
- const hasAlwaysTruePermissive = permissiveFilters.some(f => Object.keys(f).length === 0);
1098
- if (!hasAlwaysTruePermissive) {
1099
- if (permissiveFilters.length === 1) {
1100
- finalAnds.push(permissiveFilters[0]);
1101
- } else {
1102
- finalAnds.push({ $or: permissiveFilters } as Filter<Document>);
1103
- }
1104
- }
1105
- } else {
1106
- return null;
1107
- }
1108
-
1109
- if (restrictiveFilters.length > 0) {
1110
- for (const rf of restrictiveFilters) {
1111
- if (Object.keys(rf).length > 0) {
1112
- finalAnds.push(rf);
1113
- }
1114
- }
1115
- }
1116
-
1117
- if (finalAnds.length === 0) return {};
1118
- if (finalAnds.length === 1) return finalAnds[0];
1119
- return { $and: finalAnds } as Filter<Document>;
1120
- }