@flowerforce/flowerbase 1.7.2 → 1.7.3-beta.1

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.
@@ -1,9 +1,16 @@
1
1
  import get from 'lodash/get'
2
2
  import isEqual from 'lodash/isEqual'
3
+ import set from 'lodash/set'
4
+ import unset from 'lodash/unset'
5
+ import cloneDeep from 'lodash/cloneDeep'
3
6
  import {
7
+ ClientSession,
8
+ ClientSessionOptions,
4
9
  Collection,
5
10
  Document,
6
11
  EventsDescription,
12
+ FindOneOptions,
13
+ FindOptions,
7
14
  FindOneAndUpdateOptions,
8
15
  Filter as MongoFilter,
9
16
  UpdateFilter,
@@ -45,6 +52,222 @@ const logService = (message: string, payload?: unknown) => {
45
52
  console.log('[service-debug]', message, payload ?? '')
46
53
  }
47
54
 
55
+ const findOptionKeys = new Set([
56
+ 'sort',
57
+ 'skip',
58
+ 'limit',
59
+ 'session',
60
+ 'hint',
61
+ 'maxTimeMS',
62
+ 'collation',
63
+ 'allowPartialResults',
64
+ 'noCursorTimeout',
65
+ 'batchSize',
66
+ 'returnKey',
67
+ 'showRecordId',
68
+ 'comment',
69
+ 'let',
70
+ 'projection'
71
+ ])
72
+
73
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
74
+ !!value && typeof value === 'object' && !Array.isArray(value)
75
+
76
+ const looksLikeFindOptions = (value: unknown) => {
77
+ if (!isPlainObject(value)) return false
78
+ return Object.keys(value).some((key) => findOptionKeys.has(key))
79
+ }
80
+
81
+ const resolveFindArgs = (
82
+ projectionOrOptions?: Document | FindOptions | FindOneOptions,
83
+ options?: FindOptions | FindOneOptions
84
+ ) => {
85
+ if (typeof options !== 'undefined') {
86
+ return {
87
+ projection: projectionOrOptions as Document | undefined,
88
+ options
89
+ }
90
+ }
91
+
92
+ if (looksLikeFindOptions(projectionOrOptions)) {
93
+ const resolvedOptions = projectionOrOptions as FindOptions | FindOneOptions
94
+ const projection =
95
+ isPlainObject(resolvedOptions) && isPlainObject(resolvedOptions.projection)
96
+ ? (resolvedOptions.projection as Document)
97
+ : undefined
98
+ return {
99
+ projection,
100
+ options: resolvedOptions
101
+ }
102
+ }
103
+
104
+ return {
105
+ projection: projectionOrOptions as Document | undefined,
106
+ options: undefined
107
+ }
108
+ }
109
+
110
+ const normalizeInsertManyResult = <T extends { insertedIds?: Record<string, unknown> }>(result: T) => {
111
+ if (!result?.insertedIds || Array.isArray(result.insertedIds)) return result
112
+ return {
113
+ ...result,
114
+ insertedIds: Object.values(result.insertedIds)
115
+ }
116
+ }
117
+
118
+ const hasAtomicOperators = (data: Document) => Object.keys(data).some((key) => key.startsWith('$'))
119
+
120
+ const normalizeUpdatePayload = (data: Document) =>
121
+ hasAtomicOperators(data) ? data : { $set: data }
122
+
123
+ const hasOperatorExpressions = (value: unknown) =>
124
+ isPlainObject(value) && Object.keys(value).some((key) => key.startsWith('$'))
125
+
126
+ const matchesPullCondition = (item: unknown, operand: unknown) => {
127
+ if (!isPlainObject(operand)) return isEqual(item, operand)
128
+ if (hasOperatorExpressions(operand)) {
129
+ if (Array.isArray((operand as { $in?: unknown }).$in)) {
130
+ return ((operand as { $in: unknown[] }).$in).some((candidate) => isEqual(candidate, item))
131
+ }
132
+ return false
133
+ }
134
+ return Object.entries(operand).every(([key, value]) => isEqual(get(item, key), value))
135
+ }
136
+
137
+ const applyDocumentUpdateOperators = (baseDocument: Document, update: Document): Document => {
138
+ const updated = cloneDeep(baseDocument)
139
+
140
+ for (const [operator, payload] of Object.entries(update)) {
141
+ if (!isPlainObject(payload)) continue
142
+
143
+ switch (operator) {
144
+ case '$set':
145
+ Object.entries(payload).forEach(([path, value]) => set(updated, path, value))
146
+ break
147
+ case '$unset':
148
+ Object.keys(payload).forEach((path) => {
149
+ unset(updated, path)
150
+ })
151
+ break
152
+ case '$inc':
153
+ Object.entries(payload).forEach(([path, value]) => {
154
+ const currentValue = get(updated, path)
155
+ const increment = typeof value === 'number' ? value : 0
156
+ if (typeof currentValue === 'undefined') {
157
+ set(updated, path, increment)
158
+ return
159
+ }
160
+ if (typeof currentValue !== 'number') {
161
+ throw new Error(`Cannot apply $inc to a non-numeric value at path "${path}"`)
162
+ }
163
+ set(updated, path, currentValue + increment)
164
+ })
165
+ break
166
+ case '$push':
167
+ Object.entries(payload).forEach(([path, value]) => {
168
+ const currentValue = get(updated, path)
169
+ const targetArray = Array.isArray(currentValue) ? [...currentValue] : []
170
+ if (isPlainObject(value) && Array.isArray((value as { $each?: unknown[] }).$each)) {
171
+ targetArray.push(...((value as { $each: unknown[] }).$each))
172
+ } else {
173
+ targetArray.push(value)
174
+ }
175
+ set(updated, path, targetArray)
176
+ })
177
+ break
178
+ case '$addToSet':
179
+ Object.entries(payload).forEach(([path, value]) => {
180
+ const currentValue = get(updated, path)
181
+ const targetArray = Array.isArray(currentValue) ? [...currentValue] : []
182
+ const valuesToAdd =
183
+ isPlainObject(value) && Array.isArray((value as { $each?: unknown[] }).$each)
184
+ ? (value as { $each: unknown[] }).$each
185
+ : [value]
186
+ valuesToAdd.forEach((entry) => {
187
+ if (!targetArray.some((existing) => isEqual(existing, entry))) {
188
+ targetArray.push(entry)
189
+ }
190
+ })
191
+ set(updated, path, targetArray)
192
+ })
193
+ break
194
+ case '$pull':
195
+ Object.entries(payload).forEach(([path, value]) => {
196
+ const currentValue = get(updated, path)
197
+ if (!Array.isArray(currentValue)) return
198
+ const filtered = currentValue.filter((entry) => !matchesPullCondition(entry, value))
199
+ set(updated, path, filtered)
200
+ })
201
+ break
202
+ case '$pop':
203
+ Object.entries(payload).forEach(([path, value]) => {
204
+ const currentValue = get(updated, path)
205
+ if (!Array.isArray(currentValue) || !currentValue.length) return
206
+ const next = [...currentValue]
207
+ if (value === -1) {
208
+ next.shift()
209
+ } else {
210
+ next.pop()
211
+ }
212
+ set(updated, path, next)
213
+ })
214
+ break
215
+ case '$mul':
216
+ Object.entries(payload).forEach(([path, value]) => {
217
+ const currentValue = get(updated, path)
218
+ const factor = typeof value === 'number' ? value : 1
219
+ if (typeof currentValue === 'undefined') {
220
+ set(updated, path, 0)
221
+ return
222
+ }
223
+ if (typeof currentValue !== 'number') {
224
+ throw new Error(`Cannot apply $mul to a non-numeric value at path "${path}"`)
225
+ }
226
+ set(updated, path, currentValue * factor)
227
+ })
228
+ break
229
+ case '$min':
230
+ Object.entries(payload).forEach(([path, value]) => {
231
+ const currentValue = get(updated, path)
232
+ const comparableCurrent = currentValue as any
233
+ const comparableValue = value as any
234
+ if (typeof currentValue === 'undefined' || comparableCurrent > comparableValue) {
235
+ set(updated, path, value)
236
+ }
237
+ })
238
+ break
239
+ case '$max':
240
+ Object.entries(payload).forEach(([path, value]) => {
241
+ const currentValue = get(updated, path)
242
+ const comparableCurrent = currentValue as any
243
+ const comparableValue = value as any
244
+ if (typeof currentValue === 'undefined' || comparableCurrent < comparableValue) {
245
+ set(updated, path, value)
246
+ }
247
+ })
248
+ break
249
+ case '$rename':
250
+ Object.entries(payload).forEach(([fromPath, toPath]) => {
251
+ if (typeof toPath !== 'string') return
252
+ const currentValue = get(updated, fromPath)
253
+ if (typeof currentValue === 'undefined') return
254
+ set(updated, toPath, currentValue)
255
+ unset(updated, fromPath)
256
+ })
257
+ break
258
+ case '$currentDate':
259
+ Object.keys(payload).forEach((path) => set(updated, path, new Date()))
260
+ break
261
+ case '$setOnInsert':
262
+ break
263
+ default:
264
+ break
265
+ }
266
+ }
267
+
268
+ return updated
269
+ }
270
+
48
271
  const getUpdatedPaths = (update: Document) => {
49
272
  const entries = Object.entries(update ?? {})
50
273
  const hasOperators = entries.some(([key]) => key.startsWith('$'))
@@ -133,12 +356,16 @@ const getOperators: GetOperatorsFunction = (
133
356
  * - Validates the result using `checkValidation` to ensure read permission.
134
357
  * - If validation fails, returns an empty object; otherwise returns the validated document.
135
358
  */
136
- findOne: async (query = {}, projection, options) => {
359
+ findOne: async (query = {}, projectionOrOptions, options) => {
137
360
  try {
361
+ const { projection, options: normalizedOptions } = resolveFindArgs(
362
+ projectionOrOptions,
363
+ options
364
+ )
138
365
  const resolvedOptions =
139
- projection || options
366
+ projection || normalizedOptions
140
367
  ? {
141
- ...(options ?? {}),
368
+ ...(normalizedOptions ?? {}),
142
369
  ...(projection ? { projection } : {})
143
370
  }
144
371
  : undefined
@@ -350,6 +577,7 @@ const getOperators: GetOperatorsFunction = (
350
577
  */
351
578
  updateOne: async (query, data, options) => {
352
579
  try {
580
+ const normalizedData = normalizeUpdatePayload(data as Document)
353
581
  if (!run_as_system) {
354
582
 
355
583
  checkDenyOperation(normalizedRules, collection.collectionName, CRUD_OPERATIONS.UPDATE)
@@ -364,31 +592,23 @@ const getOperators: GetOperatorsFunction = (
364
592
  const result = await collection.findOne({ $and: safeQuery })
365
593
 
366
594
  if (!result) {
595
+ if (options?.upsert) {
596
+ const upsertResult = await collection.updateOne(
597
+ { $and: safeQuery },
598
+ normalizedData,
599
+ options
600
+ )
601
+ emitMongoEvent('updateOne')
602
+ return upsertResult
603
+ }
367
604
  throw new Error('Update not permitted')
368
605
  }
369
606
 
370
607
  const winningRole = getWinningRole(result, user, roles)
371
608
 
372
609
  // Check if the update data contains MongoDB update operators (e.g., $set, $inc)
373
- const hasOperators = Object.keys(data).some((key) => key.startsWith('$'))
374
- const updatedPaths = getUpdatedPaths(data as Document)
375
-
376
- // Flatten the update object to extract the actual fields being modified
377
- // const docToCheck = hasOperators
378
- // ? Object.values(data).reduce((acc, operation) => ({ ...acc, ...operation }), {})
379
- // : data
380
- const pipeline = [
381
- {
382
- $match: { $and: safeQuery }
383
- },
384
- {
385
- $limit: 1
386
- },
387
- ...Object.entries(data).map(([key, value]) => ({ [key]: value }))
388
- ]
389
- const [docToCheck] = hasOperators
390
- ? await collection.aggregate(pipeline).toArray()
391
- : ([data] as [Document])
610
+ const updatedPaths = getUpdatedPaths(normalizedData)
611
+ const docToCheck = applyDocumentUpdateOperators(result, normalizedData)
392
612
  // Validate update permissions
393
613
  const { status, document } = winningRole
394
614
  ? await checkValidation(
@@ -408,11 +628,11 @@ const getOperators: GetOperatorsFunction = (
408
628
  if (!status || !areDocumentsEqual) {
409
629
  throw new Error('Update not permitted')
410
630
  }
411
- const res = await collection.updateOne({ $and: safeQuery }, data, options)
631
+ const res = await collection.updateOne({ $and: safeQuery }, normalizedData, options)
412
632
  emitMongoEvent('updateOne')
413
633
  return res
414
634
  }
415
- const result = await collection.updateOne(query, data, options)
635
+ const result = await collection.updateOne(query, normalizedData, options)
416
636
  emitMongoEvent('updateOne')
417
637
  return result
418
638
  } catch (error) {
@@ -450,20 +670,19 @@ const getOperators: GetOperatorsFunction = (
450
670
  }
451
671
 
452
672
  const winningRole = getWinningRole(result, user, roles)
453
- const hasOperators = Object.keys(data).some((key) => key.startsWith('$'))
454
- const updatedPaths = Array.isArray(data) ? [] : getUpdatedPaths(data as Document)
455
- const pipeline = [
456
- {
457
- $match: { $and: safeQuery }
458
- },
459
- {
460
- $limit: 1
461
- },
462
- ...Object.entries(data).map(([key, value]) => ({ [key]: value }))
463
- ]
464
- const [docToCheck] = hasOperators
465
- ? await collection.aggregate(pipeline).toArray()
466
- : ([data] as [Document])
673
+ const normalizedData = Array.isArray(data)
674
+ ? data
675
+ : normalizeUpdatePayload(data as Document)
676
+ const updatedPaths = Array.isArray(normalizedData)
677
+ ? []
678
+ : getUpdatedPaths(normalizedData as Document)
679
+ const [docToCheck] = Array.isArray(normalizedData)
680
+ ? await collection.aggregate([
681
+ { $match: { $and: safeQuery } },
682
+ { $limit: 1 },
683
+ ...normalizedData
684
+ ]).toArray()
685
+ : [applyDocumentUpdateOperators(result, normalizedData as Document)]
467
686
 
468
687
  const { status, document } = winningRole
469
688
  ? await checkValidation(
@@ -484,8 +703,8 @@ const getOperators: GetOperatorsFunction = (
484
703
  }
485
704
 
486
705
  const updateResult = options
487
- ? await collection.findOneAndUpdate({ $and: safeQuery }, data, options)
488
- : await collection.findOneAndUpdate({ $and: safeQuery }, data)
706
+ ? await collection.findOneAndUpdate({ $and: safeQuery }, normalizedData, options)
707
+ : await collection.findOneAndUpdate({ $and: safeQuery }, normalizedData)
489
708
  if (!updateResult) {
490
709
  emitMongoEvent('findOneAndUpdate')
491
710
  return updateResult
@@ -540,12 +759,16 @@ const getOperators: GetOperatorsFunction = (
540
759
  *
541
760
  * This ensures that both pre-query filtering and post-query validation are applied consistently.
542
761
  */
543
- find: (query = {}, projection, options) => {
762
+ find: (query = {}, projectionOrOptions, options) => {
544
763
  try {
764
+ const { projection, options: normalizedOptions } = resolveFindArgs(
765
+ projectionOrOptions,
766
+ options
767
+ )
545
768
  const resolvedOptions =
546
- projection || options
769
+ projection || normalizedOptions
547
770
  ? {
548
- ...(options ?? {}),
771
+ ...(normalizedOptions ?? {}),
549
772
  ...(projection ? { projection } : {})
550
773
  }
551
774
  : undefined
@@ -867,12 +1090,12 @@ const getOperators: GetOperatorsFunction = (
867
1090
 
868
1091
  const result = await collection.insertMany(documents, options)
869
1092
  emitMongoEvent('insertMany')
870
- return result
1093
+ return normalizeInsertManyResult(result)
871
1094
  }
872
1095
  // If system mode is active, insert all documents without validation
873
1096
  const result = await collection.insertMany(documents, options)
874
1097
  emitMongoEvent('insertMany')
875
- return result
1098
+ return normalizeInsertManyResult(result)
876
1099
  } catch (error) {
877
1100
  emitMongoEvent('insertMany', undefined, error)
878
1101
  throw error
@@ -880,6 +1103,7 @@ const getOperators: GetOperatorsFunction = (
880
1103
  },
881
1104
  updateMany: async (query, data, options) => {
882
1105
  try {
1106
+ const normalizedData = normalizeUpdatePayload(data as Document)
883
1107
  if (!run_as_system) {
884
1108
  checkDenyOperation(normalizedRules, collection.collectionName, CRUD_OPERATIONS.UPDATE)
885
1109
  // Apply access control filters
@@ -893,24 +1117,10 @@ const getOperators: GetOperatorsFunction = (
893
1117
  }
894
1118
 
895
1119
  // Check if the update data contains MongoDB update operators (e.g., $set, $inc)
896
- const hasOperators = Object.keys(data).some((key) => key.startsWith('$'))
897
- const updatedPaths = getUpdatedPaths(data as Document)
898
-
899
- // Flatten the update object to extract the actual fields being modified
900
- // const docToCheck = hasOperators
901
- // ? Object.values(data).reduce((acc, operation) => ({ ...acc, ...operation }), {})
902
- // : data
903
-
904
- const pipeline = [
905
- {
906
- $match: { $and: formattedQuery }
907
- },
908
- ...Object.entries(data).map(([key, value]) => ({ [key]: value }))
909
- ]
910
-
911
- const docsToCheck = hasOperators
912
- ? await collection.aggregate(pipeline).toArray()
913
- : result
1120
+ const updatedPaths = getUpdatedPaths(normalizedData)
1121
+ const docsToCheck = result.map((currentDoc) =>
1122
+ applyDocumentUpdateOperators(currentDoc, normalizedData)
1123
+ )
914
1124
 
915
1125
  const filteredItems = await Promise.all(
916
1126
  docsToCheck.map(async (currentDoc) => {
@@ -944,11 +1154,11 @@ const getOperators: GetOperatorsFunction = (
944
1154
  throw new Error('Update not permitted')
945
1155
  }
946
1156
 
947
- const res = await collection.updateMany({ $and: formattedQuery }, data, options)
1157
+ const res = await collection.updateMany({ $and: formattedQuery }, normalizedData, options)
948
1158
  emitMongoEvent('updateMany')
949
1159
  return res
950
1160
  }
951
- const result = await collection.updateMany(query, data, options)
1161
+ const result = await collection.updateMany(query, normalizedData, options)
952
1162
  emitMongoEvent('updateMany')
953
1163
  return result
954
1164
  } catch (error) {
@@ -1040,6 +1250,12 @@ const MongodbAtlas: MongodbAtlasFunction = (
1040
1250
  app,
1041
1251
  { rules, user, run_as_system, monitoring } = {}
1042
1252
  ) => ({
1253
+ startSession: (options?: ClientSessionOptions) => {
1254
+ const mongoClient = app.mongo.client as unknown as {
1255
+ startSession: (sessionOptions?: ClientSessionOptions) => ClientSession
1256
+ }
1257
+ return mongoClient.startSession(options)
1258
+ },
1043
1259
  db: (dbName: string) => {
1044
1260
  return {
1045
1261
  collection: (collName: string) => {
@@ -1,5 +1,7 @@
1
1
  import { FastifyInstance } from 'fastify'
2
2
  import {
3
+ ClientSession,
4
+ ClientSessionOptions,
3
5
  Collection,
4
6
  Document,
5
7
  FindCursor,
@@ -31,6 +33,7 @@ export type MongodbAtlasFunction = (
31
33
  db: (dbName: string) => {
32
34
  collection: (collName: string) => ReturnType<GetOperatorsFunction>
33
35
  }
36
+ startSession: (options?: ClientSessionOptions) => ClientSession
34
37
  }
35
38
 
36
39
  export type GetValidRuleParams<T extends Role | Filter> = {
@@ -0,0 +1,60 @@
1
+ import { GenerateContextSync } from '../context'
2
+ import { Functions } from '../../features/functions/interface'
3
+
4
+ const mockServices = {
5
+ api: jest.fn().mockReturnValue({}),
6
+ aws: jest.fn().mockReturnValue({}),
7
+ 'mongodb-atlas': jest.fn().mockReturnValue({})
8
+ } as any
9
+
10
+ describe('context.functions.execute compatibility', () => {
11
+ it('returns direct value when target function is synchronous', () => {
12
+ const functionsList = {
13
+ caller: {
14
+ code: 'module.exports = function() { return context.functions.execute("syncTarget") }'
15
+ },
16
+ syncTarget: {
17
+ code: 'module.exports = function() { return { ok: true } }'
18
+ }
19
+ } as Functions
20
+
21
+ const result = GenerateContextSync({
22
+ args: [],
23
+ app: {} as any,
24
+ rules: {} as any,
25
+ user: {} as any,
26
+ currentFunction: functionsList.caller,
27
+ functionsList,
28
+ services: mockServices,
29
+ functionName: 'caller'
30
+ })
31
+
32
+ expect(result).toEqual({ ok: true })
33
+ expect(result).not.toBeInstanceOf(Promise)
34
+ })
35
+
36
+ it('returns Promise when target function is asynchronous', async () => {
37
+ const functionsList = {
38
+ caller: {
39
+ code: 'module.exports = function() { return context.functions.execute("asyncTarget") }'
40
+ },
41
+ asyncTarget: {
42
+ code: 'module.exports = async function() { return { ok: true } }'
43
+ }
44
+ } as Functions
45
+
46
+ const result = GenerateContextSync({
47
+ args: [],
48
+ app: {} as any,
49
+ rules: {} as any,
50
+ user: {} as any,
51
+ currentFunction: functionsList.caller,
52
+ functionsList,
53
+ services: mockServices,
54
+ functionName: 'caller'
55
+ })
56
+
57
+ expect(result && typeof (result as Promise<unknown>).then).toBe('function')
58
+ await expect(result).resolves.toEqual({ ok: true })
59
+ })
60
+ })
@@ -25,6 +25,7 @@ const mockFunctions = {
25
25
 
26
26
  const currentFunction = mockFunctions.test
27
27
  const GenerateContextMock = jest.fn()
28
+ const GenerateContextSyncMock = jest.fn()
28
29
  const mockUser = {} as User
29
30
  const mockRules = {} as Rules
30
31
  const mockEnv = {
@@ -52,6 +53,7 @@ describe('generateContextData', () => {
52
53
  functionsList: mockFunctions,
53
54
  currentFunction,
54
55
  GenerateContext: GenerateContextMock,
56
+ GenerateContextSync: GenerateContextSyncMock,
55
57
  user: mockUser,
56
58
  rules: mockRules
57
59
  })
@@ -77,7 +79,7 @@ describe('generateContextData', () => {
77
79
  mockErrorLog.mockRestore()
78
80
 
79
81
  context.functions.execute('test')
80
- expect(GenerateContextMock).toHaveBeenCalled()
82
+ expect(GenerateContextSyncMock).toHaveBeenCalled()
81
83
 
82
84
  const token = jwt.sign(
83
85
  { sub: 'user', role: 'admin' },
@@ -117,6 +117,7 @@ export const generateContextData = ({
117
117
  functionName,
118
118
  functionsList,
119
119
  GenerateContext,
120
+ GenerateContextSync,
120
121
  request
121
122
  }: GenerateContextDataParams) => {
122
123
  const BSON = mongodb.BSON
@@ -206,7 +207,7 @@ export const generateContextData = ({
206
207
  functions: {
207
208
  execute: (name: keyof typeof functionsList, ...args: Arguments) => {
208
209
  const currentFunction = functionsList[name] as Function
209
- return GenerateContext({
210
+ return GenerateContextSync({
210
211
  args,
211
212
  app,
212
213
  rules,