@flowerforce/flowerbase 1.8.4-beta.3 → 1.8.4-beta.5

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 (39) hide show
  1. package/README.md +3 -0
  2. package/dist/auth/plugins/jwt.d.ts +1 -1
  3. package/dist/auth/plugins/jwt.d.ts.map +1 -1
  4. package/dist/features/functions/controller.d.ts.map +1 -1
  5. package/dist/features/functions/controller.js +29 -2
  6. package/dist/features/functions/dtos.d.ts +2 -0
  7. package/dist/features/functions/dtos.d.ts.map +1 -1
  8. package/dist/features/functions/interface.d.ts +2 -0
  9. package/dist/features/functions/interface.d.ts.map +1 -1
  10. package/dist/features/functions/utils.d.ts +3 -1
  11. package/dist/features/functions/utils.d.ts.map +1 -1
  12. package/dist/features/functions/utils.js +3 -1
  13. package/dist/services/index.d.ts +8 -8
  14. package/dist/services/mongodb-atlas/index.d.ts.map +1 -1
  15. package/dist/services/mongodb-atlas/index.js +112 -29
  16. package/dist/services/mongodb-atlas/model.d.ts +2 -0
  17. package/dist/services/mongodb-atlas/model.d.ts.map +1 -1
  18. package/dist/services/mongodb-atlas/utils.d.ts +15 -0
  19. package/dist/services/mongodb-atlas/utils.d.ts.map +1 -1
  20. package/dist/services/mongodb-atlas/utils.js +74 -12
  21. package/dist/utils/context/helpers.d.ts +24 -24
  22. package/dist/utils/roles/machines/read/D/validators.d.ts +1 -1
  23. package/dist/utils/roles/machines/read/D/validators.d.ts.map +1 -1
  24. package/dist/utils/roles/machines/write/C/validators.d.ts +1 -1
  25. package/dist/utils/roles/machines/write/C/validators.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/features/functions/__tests__/controller.test.ts +42 -2
  28. package/src/features/functions/__tests__/utils.test.ts +44 -0
  29. package/src/features/functions/controller.ts +36 -1
  30. package/src/features/functions/dtos.ts +2 -0
  31. package/src/features/functions/interface.ts +2 -0
  32. package/src/features/functions/utils.ts +13 -0
  33. package/src/services/mongodb-atlas/__tests__/realmCompatibility.test.ts +116 -0
  34. package/src/services/mongodb-atlas/__tests__/utils.test.ts +86 -1
  35. package/src/services/mongodb-atlas/index.ts +164 -44
  36. package/src/services/mongodb-atlas/model.ts +8 -0
  37. package/src/services/mongodb-atlas/utils.ts +81 -12
  38. package/tsconfig.json +0 -5
  39. package/tsconfig.spec.json +2 -1
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getCollectionsFromPipeline = exports.checkDenyOperation = exports.applyAccessControlToPipeline = exports.getFormattedProjection = exports.getFormattedQuery = exports.getValidRule = void 0;
6
+ exports.getCollectionsFromPipeline = exports.checkDenyOperation = exports.applyAccessControlToPipeline = exports.mergeProjections = exports.getFormattedProjection = exports.getFormattedQuery = exports.getValidRule = void 0;
7
7
  exports.normalizeQuery = normalizeQuery;
8
8
  exports.ensureClientPipelineStages = ensureClientPipelineStages;
9
9
  exports.getHiddenFieldsFromRulesConfig = getHiddenFieldsFromRulesConfig;
@@ -48,21 +48,83 @@ const getFormattedQuery = (filters = [], query, user) => {
48
48
  };
49
49
  exports.getFormattedQuery = getFormattedQuery;
50
50
  const getFormattedProjection = (filters = [], user) => {
51
- const projections = filters
52
- .filter((filter) => {
53
- if (filter.projection) {
54
- const preFilter = (0, exports.getValidRule)({ filters, user });
55
- const isValidPreFilter = !!(preFilter === null || preFilter === void 0 ? void 0 : preFilter.length);
56
- return isValidPreFilter;
57
- }
58
- return false;
59
- })
51
+ const projections = (0, exports.getValidRule)({ filters, user })
52
+ .filter((f) => !!f.projection)
60
53
  .map((f) => f.projection);
61
54
  if (!projections.length)
62
55
  return null;
63
56
  return Object.assign({}, ...projections);
64
57
  };
65
58
  exports.getFormattedProjection = getFormattedProjection;
59
+ /**
60
+ * Merges a client-provided projection with the one computed from rules filters.
61
+ *
62
+ * Rules have higher priority over the client:
63
+ * - If rules exclude a top-level field (e.g. `{ instock: 0 }`), every client
64
+ * reference to that field — including dotted sub-paths such as
65
+ * `"instock.qty": 1` — is dropped from the final projection.
66
+ * - If rules include a field (value `1`), it is always part of the final
67
+ * projection and overrides any conflicting client value.
68
+ * - The returned projection is always a valid MongoDB projection (no mixing of
69
+ * inclusion and exclusion on non-`_id` keys), so it can be passed as-is to
70
+ * native MongoDB methods.
71
+ * - Returns `undefined` when neither side provided a meaningful projection.
72
+ */
73
+ const mergeProjections = (clientProjection, rulesProjection) => {
74
+ const hasClient = !!clientProjection && Object.keys(clientProjection).length > 0;
75
+ const hasRules = !!rulesProjection && Object.keys(rulesProjection).length > 0;
76
+ if (!hasClient && !hasRules)
77
+ return undefined;
78
+ const client = (hasClient ? clientProjection : {});
79
+ const rules = (hasRules ? rulesProjection : {});
80
+ const getTopLevel = (key) => key.split('.')[0];
81
+ const rulesEntries = Object.entries(rules);
82
+ const rulesIncludeKeys = rulesEntries
83
+ .filter(([, value]) => value === 1)
84
+ .map(([key]) => key);
85
+ const rulesExcludeKeys = rulesEntries
86
+ .filter(([, value]) => value === 0)
87
+ .map(([key]) => key);
88
+ // Top-level fields excluded by rules (excluding `_id` which has special
89
+ // MongoDB semantics and is allowed alongside inclusion projections).
90
+ const excludedTopLevel = new Set(rulesExcludeKeys.map(getTopLevel).filter((key) => key !== '_id'));
91
+ const filteredClient = {};
92
+ for (const [key, value] of Object.entries(client)) {
93
+ if (excludedTopLevel.has(getTopLevel(key)))
94
+ continue;
95
+ filteredClient[key] = value;
96
+ }
97
+ const hasInclusion = rulesIncludeKeys.some((key) => key !== '_id') ||
98
+ Object.entries(filteredClient).some(([key, value]) => value === 1 && key !== '_id');
99
+ const merged = {};
100
+ if (hasInclusion) {
101
+ // Inclusion mode: keep only client inclusions, then overlay rules inclusions.
102
+ // Client exclusions (other than `_id: 0`) are incompatible with inclusion
103
+ // mode and are dropped; not-included fields are implicitly excluded anyway.
104
+ for (const [key, value] of Object.entries(filteredClient)) {
105
+ if (value === 1 || key === '_id')
106
+ merged[key] = value;
107
+ }
108
+ for (const key of rulesIncludeKeys)
109
+ merged[key] = 1;
110
+ // Allow `_id: 0` to be forced by rules in inclusion mode.
111
+ for (const key of rulesExcludeKeys) {
112
+ if (key === '_id')
113
+ merged[key] = 0;
114
+ }
115
+ }
116
+ else {
117
+ // Pure exclusion mode: combine all exclusions from both sides.
118
+ for (const [key, value] of Object.entries(filteredClient)) {
119
+ if (value === 0)
120
+ merged[key] = 0;
121
+ }
122
+ for (const key of rulesExcludeKeys)
123
+ merged[key] = 0;
124
+ }
125
+ return Object.keys(merged).length > 0 ? merged : undefined;
126
+ };
127
+ exports.mergeProjections = mergeProjections;
66
128
  const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, options) => {
67
129
  const { isClientPipeline = false } = options || {};
68
130
  const hiddenFieldsForCollection = isClientPipeline
@@ -77,7 +139,7 @@ const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, opt
77
139
  (0, exports.checkDenyOperation)(rules, currentCollection, model_1.CRUD_OPERATIONS.READ);
78
140
  const lookupRules = rules[currentCollection] || {};
79
141
  const formattedQuery = (0, exports.getFormattedQuery)(lookupRules.filters, {}, user);
80
- const projection = (0, exports.getFormattedProjection)(lookupRules.filters);
142
+ const projection = (0, exports.getFormattedProjection)(lookupRules.filters, user);
81
143
  const nestedPipeline = (0, exports.applyAccessControlToPipeline)(lookUpStage.pipeline || [], rules, user, currentCollection, { isClientPipeline });
82
144
  const lookupPipeline = [
83
145
  ...(formattedQuery.length ? [{ $match: { $and: formattedQuery } }] : []),
@@ -98,7 +160,7 @@ const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, opt
98
160
  (0, exports.checkDenyOperation)(rules, currentCollection, model_1.CRUD_OPERATIONS.READ);
99
161
  const unionRules = rules[currentCollection] || {};
100
162
  const formattedQuery = (0, exports.getFormattedQuery)(unionRules.filters, {}, user);
101
- const projection = (0, exports.getFormattedProjection)(unionRules.filters);
163
+ const projection = (0, exports.getFormattedProjection)(unionRules.filters, user);
102
164
  if (isSimpleStage) {
103
165
  return stage;
104
166
  }
@@ -176,23 +176,23 @@ export declare const generateContextData: ({ user, services, app, rules, current
176
176
  context: object;
177
177
  }>;
178
178
  } | {
179
- lambda: (region: string) => import("@aws-sdk/client-lambda/dist-types").Lambda & {
180
- Invoke: (params: import("@aws-sdk/client-lambda/dist-types").InvokeCommandInput) => Promise<Omit<import("@aws-sdk/client-lambda/dist-types").InvokeCommandOutput, "Payload"> & {
179
+ lambda: (region: string) => import("@aws-sdk/client-lambda").Lambda & {
180
+ Invoke: (params: import("@aws-sdk/client-lambda").InvokeCommandInput) => Promise<Omit<import("@aws-sdk/client-lambda").InvokeCommandOutput, "Payload"> & {
181
181
  Payload: {
182
182
  text: () => string | undefined;
183
183
  };
184
184
  }>;
185
- InvokeAsync: (params: import("@aws-sdk/client-lambda/dist-types").InvokeAsyncCommandInput) => Promise<import("@aws-sdk/client-lambda/dist-types").InvokeAsyncCommandOutput>;
185
+ InvokeAsync: (params: import("@aws-sdk/client-lambda").InvokeAsyncCommandInput) => Promise<import("@aws-sdk/client-lambda").InvokeAsyncCommandOutput>;
186
186
  };
187
- s3: (region: string) => import("@aws-sdk/client-s3/dist-types").S3Client & {
188
- PutObject: (params: import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) => Promise<import("@aws-sdk/client-s3/dist-types").PutObjectCommandOutput>;
189
- GetObject: (params: import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput) => Promise<import("@aws-sdk/client-s3/dist-types").GetObjectCommandOutput>;
190
- getSignedUrl: (operation: "getObject" | "putObject", params: (import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput | import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) & {
187
+ s3: (region: string) => import("@aws-sdk/client-s3").S3Client & {
188
+ PutObject: (params: import("@aws-sdk/client-s3").PutObjectCommandInput) => Promise<import("@aws-sdk/client-s3").PutObjectCommandOutput>;
189
+ GetObject: (params: import("@aws-sdk/client-s3").GetObjectCommandInput) => Promise<import("@aws-sdk/client-s3").GetObjectCommandOutput>;
190
+ getSignedUrl: (operation: "getObject" | "putObject", params: (import("@aws-sdk/client-s3").GetObjectCommandInput | import("@aws-sdk/client-s3").PutObjectCommandInput) & {
191
191
  Expires?: number;
192
192
  }, options?: {
193
193
  expiresIn?: number;
194
194
  }) => Promise<string>;
195
- PresignURL: (params: (import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput | import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) & {
195
+ PresignURL: (params: (import("@aws-sdk/client-s3").GetObjectCommandInput | import("@aws-sdk/client-s3").PutObjectCommandInput) & {
196
196
  Method?: string;
197
197
  ExpirationMS?: number;
198
198
  Expires?: number;
@@ -328,23 +328,23 @@ export declare const generateContextData: ({ user, services, app, rules, current
328
328
  context: object;
329
329
  }>;
330
330
  } | {
331
- lambda: (region: string) => import("@aws-sdk/client-lambda/dist-types").Lambda & {
332
- Invoke: (params: import("@aws-sdk/client-lambda/dist-types").InvokeCommandInput) => Promise<Omit<import("@aws-sdk/client-lambda/dist-types").InvokeCommandOutput, "Payload"> & {
331
+ lambda: (region: string) => import("@aws-sdk/client-lambda").Lambda & {
332
+ Invoke: (params: import("@aws-sdk/client-lambda").InvokeCommandInput) => Promise<Omit<import("@aws-sdk/client-lambda").InvokeCommandOutput, "Payload"> & {
333
333
  Payload: {
334
334
  text: () => string | undefined;
335
335
  };
336
336
  }>;
337
- InvokeAsync: (params: import("@aws-sdk/client-lambda/dist-types").InvokeAsyncCommandInput) => Promise<import("@aws-sdk/client-lambda/dist-types").InvokeAsyncCommandOutput>;
337
+ InvokeAsync: (params: import("@aws-sdk/client-lambda").InvokeAsyncCommandInput) => Promise<import("@aws-sdk/client-lambda").InvokeAsyncCommandOutput>;
338
338
  };
339
- s3: (region: string) => import("@aws-sdk/client-s3/dist-types").S3Client & {
340
- PutObject: (params: import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) => Promise<import("@aws-sdk/client-s3/dist-types").PutObjectCommandOutput>;
341
- GetObject: (params: import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput) => Promise<import("@aws-sdk/client-s3/dist-types").GetObjectCommandOutput>;
342
- getSignedUrl: (operation: "getObject" | "putObject", params: (import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput | import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) & {
339
+ s3: (region: string) => import("@aws-sdk/client-s3").S3Client & {
340
+ PutObject: (params: import("@aws-sdk/client-s3").PutObjectCommandInput) => Promise<import("@aws-sdk/client-s3").PutObjectCommandOutput>;
341
+ GetObject: (params: import("@aws-sdk/client-s3").GetObjectCommandInput) => Promise<import("@aws-sdk/client-s3").GetObjectCommandOutput>;
342
+ getSignedUrl: (operation: "getObject" | "putObject", params: (import("@aws-sdk/client-s3").GetObjectCommandInput | import("@aws-sdk/client-s3").PutObjectCommandInput) & {
343
343
  Expires?: number;
344
344
  }, options?: {
345
345
  expiresIn?: number;
346
346
  }) => Promise<string>;
347
- PresignURL: (params: (import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput | import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) & {
347
+ PresignURL: (params: (import("@aws-sdk/client-s3").GetObjectCommandInput | import("@aws-sdk/client-s3").PutObjectCommandInput) & {
348
348
  Method?: string;
349
349
  ExpirationMS?: number;
350
350
  Expires?: number;
@@ -479,23 +479,23 @@ export declare const generateContextData: ({ user, services, app, rules, current
479
479
  context: object;
480
480
  }>;
481
481
  } | {
482
- lambda: (region: string) => import("@aws-sdk/client-lambda/dist-types").Lambda & {
483
- Invoke: (params: import("@aws-sdk/client-lambda/dist-types").InvokeCommandInput) => Promise<Omit<import("@aws-sdk/client-lambda/dist-types").InvokeCommandOutput, "Payload"> & {
482
+ lambda: (region: string) => import("@aws-sdk/client-lambda").Lambda & {
483
+ Invoke: (params: import("@aws-sdk/client-lambda").InvokeCommandInput) => Promise<Omit<import("@aws-sdk/client-lambda").InvokeCommandOutput, "Payload"> & {
484
484
  Payload: {
485
485
  text: () => string | undefined;
486
486
  };
487
487
  }>;
488
- InvokeAsync: (params: import("@aws-sdk/client-lambda/dist-types").InvokeAsyncCommandInput) => Promise<import("@aws-sdk/client-lambda/dist-types").InvokeAsyncCommandOutput>;
488
+ InvokeAsync: (params: import("@aws-sdk/client-lambda").InvokeAsyncCommandInput) => Promise<import("@aws-sdk/client-lambda").InvokeAsyncCommandOutput>;
489
489
  };
490
- s3: (region: string) => import("@aws-sdk/client-s3/dist-types").S3Client & {
491
- PutObject: (params: import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) => Promise<import("@aws-sdk/client-s3/dist-types").PutObjectCommandOutput>;
492
- GetObject: (params: import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput) => Promise<import("@aws-sdk/client-s3/dist-types").GetObjectCommandOutput>;
493
- getSignedUrl: (operation: "getObject" | "putObject", params: (import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput | import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) & {
490
+ s3: (region: string) => import("@aws-sdk/client-s3").S3Client & {
491
+ PutObject: (params: import("@aws-sdk/client-s3").PutObjectCommandInput) => Promise<import("@aws-sdk/client-s3").PutObjectCommandOutput>;
492
+ GetObject: (params: import("@aws-sdk/client-s3").GetObjectCommandInput) => Promise<import("@aws-sdk/client-s3").GetObjectCommandOutput>;
493
+ getSignedUrl: (operation: "getObject" | "putObject", params: (import("@aws-sdk/client-s3").GetObjectCommandInput | import("@aws-sdk/client-s3").PutObjectCommandInput) & {
494
494
  Expires?: number;
495
495
  }, options?: {
496
496
  expiresIn?: number;
497
497
  }) => Promise<string>;
498
- PresignURL: (params: (import("@aws-sdk/client-s3/dist-types").GetObjectCommandInput | import("@aws-sdk/client-s3/dist-types").PutObjectCommandInput) & {
498
+ PresignURL: (params: (import("@aws-sdk/client-s3").GetObjectCommandInput | import("@aws-sdk/client-s3").PutObjectCommandInput) & {
499
499
  Method?: string;
500
500
  ExpirationMS?: number;
501
501
  Expires?: number;
@@ -1,4 +1,4 @@
1
1
  import { MachineContext } from '../../interface';
2
2
  export declare const checkAdditionalFieldsFn: ({ role }: MachineContext) => boolean;
3
- export declare const checkIsValidFieldNameFn: (context: MachineContext) => Promise<import("bson/bson").Document>;
3
+ export declare const checkIsValidFieldNameFn: (context: MachineContext) => Promise<import("bson").Document>;
4
4
  //# sourceMappingURL=validators.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../../../../src/utils/roles/machines/read/D/validators.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,eAAO,MAAM,uBAAuB,GAAI,UAAU,cAAc,YAE/D,CAAA;AAED,eAAO,MAAM,uBAAuB,GAAU,SAAS,cAAc,0CAOpE,CAAA"}
1
+ {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../../../../src/utils/roles/machines/read/D/validators.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,eAAO,MAAM,uBAAuB,GAAI,UAAU,cAAc,YAE/D,CAAA;AAED,eAAO,MAAM,uBAAuB,GAAU,SAAS,cAAc,qCAOpE,CAAA"}
@@ -1,4 +1,4 @@
1
1
  import { MachineContext } from '../../interface';
2
2
  export declare const checkAdditionalFieldsFn: ({ role }: MachineContext) => boolean;
3
- export declare const checkIsValidFieldNameFn: (context: MachineContext) => Promise<import("bson/bson").Document>;
3
+ export declare const checkIsValidFieldNameFn: (context: MachineContext) => Promise<import("bson").Document>;
4
4
  //# sourceMappingURL=validators.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../../../../src/utils/roles/machines/write/C/validators.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,eAAO,MAAM,uBAAuB,GAAI,UAAU,cAAc,YAE/D,CAAA;AAED,eAAO,MAAM,uBAAuB,GAAU,SAAS,cAAc,0CAIpE,CAAA"}
1
+ {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../../../../src/utils/roles/machines/write/C/validators.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,eAAO,MAAM,uBAAuB,GAAI,UAAU,cAAc,YAE/D,CAAA;AAED,eAAO,MAAM,uBAAuB,GAAU,SAAS,cAAc,qCAIpE,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowerforce/flowerbase",
3
- "version": "1.8.4-beta.3",
3
+ "version": "1.8.4-beta.5",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,4 +1,5 @@
1
1
  import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
2
+ import { services } from '../../../services'
2
3
  import { GenerateContext } from '../../../utils/context'
3
4
  import { functionsController } from '../controller'
4
5
 
@@ -8,18 +9,19 @@ jest.mock('../../../utils/context', () => ({
8
9
 
9
10
  describe('functionsController', () => {
10
11
  let app: FastifyInstance
12
+ const originalMongoService = services['mongodb-atlas']
11
13
 
12
14
  beforeEach(async () => {
13
15
  app = Fastify()
14
16
 
15
17
  app.decorate('jwtAuthentication', async (request: FastifyRequest, _reply: FastifyReply) => {
16
- ;(request as any).user = {
18
+ ; (request as any).user = {
17
19
  id: '507f191e810c19729de860ea',
18
20
  typ: 'access'
19
21
  }
20
22
  })
21
23
 
22
- ;(GenerateContext as jest.Mock).mockResolvedValue({ ok: true })
24
+ ; (GenerateContext as jest.Mock).mockResolvedValue({ ok: true })
23
25
 
24
26
  await app.register(functionsController, {
25
27
  functionsList: {
@@ -33,6 +35,7 @@ describe('functionsController', () => {
33
35
  })
34
36
 
35
37
  afterEach(async () => {
38
+ services['mongodb-atlas'] = originalMongoService
36
39
  await app.close()
37
40
  jest.clearAllMocks()
38
41
  })
@@ -57,4 +60,41 @@ describe('functionsController', () => {
57
60
  })
58
61
  )
59
62
  })
63
+
64
+ it('passes mongodb-atlas distinct service arguments through POST /call', async () => {
65
+ const distinct = jest.fn().mockResolvedValue(['open'])
66
+ services['mongodb-atlas'] = jest.fn(() => ({
67
+ db: jest.fn().mockReturnValue({
68
+ collection: jest.fn().mockReturnValue({
69
+ distinct
70
+ })
71
+ })
72
+ })) as any
73
+
74
+ const response = await app.inject({
75
+ method: 'POST',
76
+ url: '/call',
77
+ payload: {
78
+ service: 'mongodb-atlas',
79
+ name: 'distinct',
80
+ arguments: [
81
+ {
82
+ database: 'app',
83
+ collection: 'todos',
84
+ key: 'status',
85
+ query: { archived: false },
86
+ options: { maxTimeMS: 250 }
87
+ }
88
+ ]
89
+ }
90
+ })
91
+
92
+ expect(response.statusCode).toBe(200)
93
+ expect(JSON.parse(response.body)).toEqual(['open'])
94
+ expect(distinct).toHaveBeenCalledWith(
95
+ 'status',
96
+ { archived: false },
97
+ { maxTimeMS: 250 }
98
+ )
99
+ })
60
100
  })
@@ -30,4 +30,48 @@ describe('executeQuery', () => {
30
30
  { upsert: true }
31
31
  )
32
32
  })
33
+
34
+ it('passes distinct arguments through to the service method', async () => {
35
+ const currentMethod = jest.fn().mockResolvedValue(['open'])
36
+
37
+ const operators = await executeQuery({
38
+ currentMethod,
39
+ key: 'status',
40
+ query: { archived: false },
41
+ update: {},
42
+ options: { maxTimeMS: 500 }
43
+ } as any)
44
+
45
+ await operators.distinct()
46
+
47
+ expect(currentMethod).toHaveBeenCalledWith(
48
+ 'status',
49
+ { archived: false },
50
+ { maxTimeMS: 500 }
51
+ )
52
+ })
53
+
54
+ it('passes bulkWrite operations through to the service method', async () => {
55
+ const currentMethod = jest.fn().mockResolvedValue({ acknowledged: true })
56
+ const operations = [
57
+ {
58
+ updateOne: {
59
+ filter: { archived: false },
60
+ update: { $set: { archived: true } }
61
+ }
62
+ }
63
+ ]
64
+
65
+ const operators = await executeQuery({
66
+ currentMethod,
67
+ query: {},
68
+ update: {},
69
+ operations,
70
+ options: { ordered: false }
71
+ } as any)
72
+
73
+ await operators.bulkWrite()
74
+
75
+ expect(currentMethod).toHaveBeenCalledWith(operations, { ordered: false })
76
+ })
33
77
  })
@@ -9,6 +9,29 @@ import { Base64Function, FunctionCallBase64Dto, FunctionCallDto } from './dtos'
9
9
  import { FunctionController } from './interface'
10
10
  import { executeQuery } from './utils'
11
11
 
12
+ const SUPPORTED_QUERY_METHODS = [
13
+ 'find',
14
+ 'findOne',
15
+ 'count',
16
+ 'countDocuments',
17
+ 'distinct',
18
+ 'deleteOne',
19
+ 'insertOne',
20
+ 'updateOne',
21
+ 'findOneAndUpdate',
22
+ 'aggregate',
23
+ 'insertMany',
24
+ 'bulkWrite',
25
+ 'updateMany',
26
+ 'deleteMany'
27
+ ] as const
28
+
29
+ type SupportedQueryMethod = (typeof SUPPORTED_QUERY_METHODS)[number]
30
+
31
+ const isSupportedQueryMethod = (value: unknown): value is SupportedQueryMethod =>
32
+ typeof value === 'string' &&
33
+ (SUPPORTED_QUERY_METHODS as readonly string[]).includes(value)
34
+
12
35
  const normalizeUser = (payload: Record<string, any> | undefined) => {
13
36
  if (!payload) return undefined
14
37
  const nestedUser =
@@ -352,6 +375,7 @@ export const functionsController: FunctionController = async (
352
375
  const [{
353
376
  database,
354
377
  collection,
378
+ key,
355
379
  query,
356
380
  filter,
357
381
  update,
@@ -360,9 +384,14 @@ export const functionsController: FunctionController = async (
360
384
  returnNewDocument,
361
385
  document,
362
386
  documents,
387
+ operations,
363
388
  pipeline = []
364
389
  }] = args
365
390
 
391
+ if (!isSupportedQueryMethod(method)) {
392
+ throw new Error(`Unsupported service method "${String(method)}"`)
393
+ }
394
+
366
395
  const currentMethod = serviceFn(app, { rules, user })
367
396
  .db(database)
368
397
  .collection(collection)[method]
@@ -371,6 +400,7 @@ export const functionsController: FunctionController = async (
371
400
  const operatorsByType = await executeQuery({
372
401
  currentMethod,
373
402
  query,
403
+ key,
374
404
  filter,
375
405
  update,
376
406
  projection,
@@ -378,10 +408,15 @@ export const functionsController: FunctionController = async (
378
408
  returnNewDocument,
379
409
  document,
380
410
  documents,
411
+ operations,
381
412
  pipeline,
382
413
  isClient: true
383
414
  })
384
- const serviceResult = await operatorsByType[method as keyof typeof operatorsByType]()
415
+ const operator = operatorsByType[method]
416
+ if (typeof operator !== 'function') {
417
+ throw new Error(`Unsupported service method "${method}"`)
418
+ }
419
+ const serviceResult = await operator()
385
420
  res.type('application/json')
386
421
  return serializeEjson(serviceResult)
387
422
  }
@@ -23,6 +23,7 @@ export type FunctionCallBase64Dto = {
23
23
  type ArgumentsData = Arguments<{
24
24
  database: string
25
25
  collection: string
26
+ key?: string
26
27
  filter?: Document
27
28
  query: Parameters<GetOperatorsFunction>
28
29
  update: Document
@@ -31,6 +32,7 @@ type ArgumentsData = Arguments<{
31
32
  returnNewDocument?: boolean
32
33
  document: Document
33
34
  documents: Document[]
35
+ operations?: Document[]
34
36
  pipeline?: Document[]
35
37
  }>
36
38
 
@@ -27,12 +27,14 @@ export type ExecuteQueryParams = {
27
27
  currentMethod: ReturnType<GetOperatorsFunction>[keyof ReturnType<GetOperatorsFunction>]
28
28
  query: Parameters<GetOperatorsFunction>
29
29
  update: Document
30
+ key?: string
30
31
  filter?: Document
31
32
  projection?: Document
32
33
  options?: Document
33
34
  returnNewDocument?: boolean
34
35
  document: Document
35
36
  documents: Document[]
37
+ operations?: Document[]
36
38
  pipeline: Document[]
37
39
  isClient?: boolean
38
40
  }
@@ -45,12 +45,14 @@ export const executeQuery = async ({
45
45
  currentMethod,
46
46
  query,
47
47
  update,
48
+ key,
48
49
  filter,
49
50
  projection,
50
51
  options,
51
52
  returnNewDocument,
52
53
  document,
53
54
  documents,
55
+ operations,
54
56
  pipeline,
55
57
  isClient = false
56
58
  }: ExecuteQueryParams) => {
@@ -113,6 +115,12 @@ export const executeQuery = async ({
113
115
  EJSON.deserialize(resolvedQuery),
114
116
  parsedOptions
115
117
  ),
118
+ distinct: () =>
119
+ (currentMethod as ReturnType<GetOperatorsFunction>['distinct'])(
120
+ key ?? '',
121
+ EJSON.deserialize(resolvedQuery),
122
+ parsedOptions
123
+ ),
116
124
  deleteOne: () =>
117
125
  (currentMethod as ReturnType<GetOperatorsFunction>['deleteOne'])(
118
126
  EJSON.deserialize(resolvedQuery),
@@ -144,6 +152,11 @@ export const executeQuery = async ({
144
152
  (currentMethod as ReturnType<GetOperatorsFunction>['insertMany'])(
145
153
  EJSON.deserialize(documents)
146
154
  ),
155
+ bulkWrite: () =>
156
+ (currentMethod as ReturnType<GetOperatorsFunction>['bulkWrite'])(
157
+ EJSON.deserialize(operations ?? []),
158
+ parsedOptions
159
+ ),
147
160
  updateMany: () =>
148
161
  (currentMethod as ReturnType<GetOperatorsFunction>['updateMany'])(
149
162
  EJSON.deserialize(resolvedQuery),
@@ -1,3 +1,5 @@
1
+ /// <reference types="jest" />
2
+
1
3
  import { ObjectId } from 'mongodb'
2
4
  import MongoDbAtlas from '..'
3
5
  import { Rules } from '../../../features/rules/interface'
@@ -178,6 +180,70 @@ describe('mongodb-atlas Realm compatibility', () => {
178
180
  )
179
181
  })
180
182
 
183
+ it('forwards bulkWrite to the underlying collection in system mode', async () => {
184
+ const operations = [
185
+ {
186
+ updateOne: {
187
+ filter: { done: false },
188
+ update: { $set: { done: true } }
189
+ }
190
+ }
191
+ ]
192
+ const bulkWriteResult = {
193
+ acknowledged: true,
194
+ matchedCount: 1,
195
+ modifiedCount: 1,
196
+ insertedCount: 0,
197
+ deletedCount: 0,
198
+ upsertedCount: 0,
199
+ insertedIds: {},
200
+ upsertedIds: {}
201
+ }
202
+ const bulkWrite = jest.fn().mockResolvedValue(bulkWriteResult)
203
+ const collection = {
204
+ collectionName: 'todos',
205
+ bulkWrite
206
+ }
207
+
208
+ const operators = MongoDbAtlas(createAppWithCollection(collection) as any, {
209
+ run_as_system: true
210
+ }).db('db').collection('todos')
211
+
212
+ const result = await operators.bulkWrite(operations as any, { ordered: false } as any)
213
+
214
+ expect(result).toEqual(bulkWriteResult)
215
+ expect(bulkWrite).toHaveBeenCalledWith(operations, { ordered: false })
216
+ })
217
+
218
+ it('rejects bulkWrite outside run_as_system mode', async () => {
219
+ const bulkWrite = jest.fn()
220
+ const collection = {
221
+ collectionName: 'todos',
222
+ bulkWrite
223
+ }
224
+
225
+ const operators = MongoDbAtlas(createAppWithCollection(collection) as any, {
226
+ rules: createRules(),
227
+ user: { id: 'user-1' }
228
+ }).db('db').collection('todos')
229
+
230
+ await expect(
231
+ operators.bulkWrite(
232
+ [
233
+ {
234
+ updateOne: {
235
+ filter: { done: false },
236
+ update: { $set: { done: true } }
237
+ }
238
+ }
239
+ ] as any,
240
+ { ordered: false } as any
241
+ )
242
+ ).rejects.toThrow('bulkWrite is available only when run_as_system is enabled')
243
+
244
+ expect(bulkWrite).not.toHaveBeenCalled()
245
+ })
246
+
181
247
  it('supports operator updates in updateMany without using invalid aggregate stages', async () => {
182
248
  const id = new ObjectId()
183
249
  const find = jest.fn().mockReturnValue({
@@ -513,6 +579,56 @@ describe('mongodb-atlas Realm compatibility', () => {
513
579
  expect(result.insertedIds).toEqual([id0, id1])
514
580
  })
515
581
 
582
+ it('computes distinct values from readable documents only', async () => {
583
+ const find = jest.fn().mockReturnValue({
584
+ toArray: jest.fn().mockResolvedValue([
585
+ { _id: new ObjectId(), visible: 'A', secret: 'internal-1' },
586
+ { _id: new ObjectId(), visible: 'A', secret: 'internal-2' },
587
+ { _id: new ObjectId(), visible: 'B', secret: 'internal-1' }
588
+ ])
589
+ })
590
+ const collection = {
591
+ collectionName: 'todos',
592
+ find
593
+ }
594
+ const operators = MongoDbAtlas(createAppWithCollection(collection) as any, {
595
+ rules: createRules({
596
+ roles: [
597
+ {
598
+ name: 'reader',
599
+ apply_when: {},
600
+ insert: true,
601
+ delete: true,
602
+ search: true,
603
+ read: true,
604
+ write: true,
605
+ fields: {
606
+ visible: { read: true },
607
+ secret: { read: false, write: false }
608
+ }
609
+ } as any
610
+ ]
611
+ }),
612
+ user: { id: 'user-1' }
613
+ }).db('db').collection('todos')
614
+
615
+ const visibleResult = await operators.distinct('visible', { archived: false })
616
+ const secretResult = await operators.distinct('secret', { archived: false })
617
+
618
+ expect(visibleResult).toEqual(['A', 'B'])
619
+ expect(secretResult).toEqual([])
620
+ expect(find).toHaveBeenNthCalledWith(
621
+ 1,
622
+ { $and: [{ archived: false }] },
623
+ { projection: { _id: 1, visible: 1 } }
624
+ )
625
+ expect(find).toHaveBeenNthCalledWith(
626
+ 2,
627
+ { $and: [{ archived: false }] },
628
+ { projection: { _id: 1, secret: 1 } }
629
+ )
630
+ })
631
+
516
632
  it('exposes startSession and delegates to the underlying MongoClient', async () => {
517
633
  const mockSession = { withTransaction: jest.fn() }
518
634
  const startSession = jest.fn().mockReturnValue(mockSession)