@flowerforce/flowerbase 1.7.4 → 1.7.5-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.
@@ -277,6 +277,17 @@ const handleAuthenticationTrigger = async ({
277
277
  changeStream.on('error', (error) => {
278
278
  if (shouldIgnoreStreamError(error)) return
279
279
  console.error('Authentication trigger change stream error', error)
280
+ emitTriggerEvent({
281
+ status: 'error',
282
+ triggerName,
283
+ triggerType,
284
+ functionName,
285
+ meta: {
286
+ ...baseMeta,
287
+ event: 'CHANGE_STREAM'
288
+ },
289
+ error
290
+ })
280
291
  })
281
292
  changeStream.on('change', async function (change) {
282
293
  const operationType = change['operationType' as keyof typeof change] as
@@ -365,13 +376,6 @@ const handleAuthenticationTrigger = async ({
365
376
  updateDescription
366
377
  }
367
378
  try {
368
- emitTriggerEvent({
369
- status: 'fired',
370
- triggerName,
371
- triggerType,
372
- functionName,
373
- meta: { ...baseMeta, event: 'LOGOUT' }
374
- })
375
379
  await GenerateContext({
376
380
  args: [{ user: userData, ...op }],
377
381
  app,
@@ -383,6 +387,13 @@ const handleAuthenticationTrigger = async ({
383
387
  services,
384
388
  runAsSystem: true
385
389
  })
390
+ emitTriggerEvent({
391
+ status: 'fired',
392
+ triggerName,
393
+ triggerType,
394
+ functionName,
395
+ meta: { ...baseMeta, event: 'LOGOUT' }
396
+ })
386
397
  } catch (error) {
387
398
  emitTriggerEvent({
388
399
  status: 'error',
@@ -417,13 +428,6 @@ const handleAuthenticationTrigger = async ({
417
428
  updateDescription
418
429
  }
419
430
  try {
420
- emitTriggerEvent({
421
- status: 'fired',
422
- triggerName,
423
- triggerType,
424
- functionName,
425
- meta: { ...baseMeta, event: 'DELETE' }
426
- })
427
431
  await GenerateContext({
428
432
  args: isAutoTrigger ? [userData] : [{ user: userData, ...op }],
429
433
  app,
@@ -435,6 +439,13 @@ const handleAuthenticationTrigger = async ({
435
439
  services,
436
440
  runAsSystem: true
437
441
  })
442
+ emitTriggerEvent({
443
+ status: 'fired',
444
+ triggerName,
445
+ triggerType,
446
+ functionName,
447
+ meta: { ...baseMeta, event: 'DELETE' }
448
+ })
438
449
  } catch (error) {
439
450
  emitTriggerEvent({
440
451
  status: 'error',
@@ -471,13 +482,6 @@ const handleAuthenticationTrigger = async ({
471
482
  updateDescription
472
483
  }
473
484
  try {
474
- emitTriggerEvent({
475
- status: 'fired',
476
- triggerName,
477
- triggerType,
478
- functionName,
479
- meta: { ...baseMeta, event: 'UPDATE' }
480
- })
481
485
  await GenerateContext({
482
486
  args: isAutoTrigger ? [userData] : [{ user: userData, ...op }],
483
487
  app,
@@ -489,6 +493,13 @@ const handleAuthenticationTrigger = async ({
489
493
  services,
490
494
  runAsSystem: true
491
495
  })
496
+ emitTriggerEvent({
497
+ status: 'fired',
498
+ triggerName,
499
+ triggerType,
500
+ functionName,
501
+ meta: { ...baseMeta, event: 'UPDATE' }
502
+ })
492
503
  } catch (error) {
493
504
  emitTriggerEvent({
494
505
  status: 'error',
@@ -575,13 +586,6 @@ const handleAuthenticationTrigger = async ({
575
586
  }
576
587
 
577
588
  try {
578
- emitTriggerEvent({
579
- status: 'fired',
580
- triggerName,
581
- triggerType,
582
- functionName,
583
- meta: { ...baseMeta, event: 'CREATE' }
584
- })
585
589
  await GenerateContext({
586
590
  args: isAutoTrigger ? [userData] : [{ user: userData, ...op }],
587
591
  app,
@@ -593,6 +597,13 @@ const handleAuthenticationTrigger = async ({
593
597
  services,
594
598
  runAsSystem: true
595
599
  })
600
+ emitTriggerEvent({
601
+ status: 'fired',
602
+ triggerName,
603
+ triggerType,
604
+ functionName,
605
+ meta: { ...baseMeta, event: 'CREATE' }
606
+ })
596
607
  } catch (error) {
597
608
  emitTriggerEvent({
598
609
  status: 'error',
@@ -4,6 +4,7 @@ import isEqual from 'lodash/isEqual'
4
4
  import set from 'lodash/set'
5
5
  import unset from 'lodash/unset'
6
6
  import {
7
+ ChangeStreamOptions,
7
8
  ClientSession,
8
9
  ClientSessionOptions,
9
10
  Collection,
@@ -139,7 +140,73 @@ const normalizeFindOneAndUpdateOptions = (
139
140
  const buildAndQuery = (clauses: MongoFilter<Document>[]): MongoFilter<Document> =>
140
141
  clauses.length ? { $and: clauses } : {}
141
142
 
142
- const hasAtomicOperators = (data: Document) => Object.keys(data).some((key) => key.startsWith('$'))
143
+ const toWatchMatchFilter = (value: unknown): unknown => {
144
+ if (Array.isArray(value)) {
145
+ return value.map((item) => toWatchMatchFilter(item))
146
+ }
147
+
148
+ if (!isPlainObject(value)) return value
149
+
150
+ return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, current]) => {
151
+ if (key.startsWith('$')) {
152
+ acc[key] = toWatchMatchFilter(current)
153
+ return acc
154
+ }
155
+ acc[`fullDocument.${key}`] = toWatchMatchFilter(current)
156
+ return acc
157
+ }, {})
158
+ }
159
+
160
+ type RealmCompatibleWatchOptions = Document & {
161
+ filter?: MongoFilter<Document>
162
+ ids?: unknown[]
163
+ }
164
+
165
+ const resolveWatchArgs = (
166
+ pipelineOrOptions?: Document[] | RealmCompatibleWatchOptions,
167
+ options?: RealmCompatibleWatchOptions
168
+ ) => {
169
+ const inputPipeline = Array.isArray(pipelineOrOptions) ? pipelineOrOptions : []
170
+ const rawOptions = (Array.isArray(pipelineOrOptions) ? options : pipelineOrOptions) ?? {}
171
+
172
+ if (!isPlainObject(rawOptions)) {
173
+ return {
174
+ pipeline: inputPipeline,
175
+ options: options as ChangeStreamOptions | undefined,
176
+ extraMatches: [] as Document[]
177
+ }
178
+ }
179
+
180
+ const {
181
+ filter: watchFilter,
182
+ ids,
183
+ ...watchOptions
184
+ } = rawOptions as RealmCompatibleWatchOptions
185
+
186
+ const extraMatches: Document[] = []
187
+ if (typeof watchFilter !== 'undefined') {
188
+ extraMatches.push({ $match: toWatchMatchFilter(watchFilter) as Document })
189
+ }
190
+ if (Array.isArray(ids)) {
191
+ extraMatches.push({
192
+ $match: {
193
+ $or: [
194
+ { 'documentKey._id': { $in: ids } },
195
+ { 'fullDocument._id': { $in: ids } }
196
+ ]
197
+ }
198
+ })
199
+ }
200
+
201
+ return {
202
+ pipeline: inputPipeline,
203
+ options: watchOptions as ChangeStreamOptions,
204
+ extraMatches
205
+ }
206
+ }
207
+
208
+ const hasAtomicOperators = (data: Document) =>
209
+ Object.keys(data).some((key) => key.startsWith('$'))
143
210
 
144
211
  const normalizeUpdatePayload = (data: Document) =>
145
212
  hasAtomicOperators(data) ? data : { $set: data }
@@ -364,22 +431,22 @@ const getOperators: GetOperatorsFunction = (
364
431
 
365
432
  return {
366
433
  /**
367
- * Finds a single document in a MongoDB collection with optional role-based filtering and validation.
368
- *
369
- * @param {Filter<Document>} query - The MongoDB query used to match the document.
370
- * @param {Document} [projection] - Optional projection to select returned fields.
371
- * @param {FindOneOptions} [options] - Optional settings for the findOne operation.
372
- * @returns {Promise<Document | {} | null>} A promise resolving to the document if found and permitted, an empty object if access is denied, or `null` if not found.
373
- *
374
- * @description
375
- * If `run_as_system` is enabled, the function behaves like a standard `collection.findOne(query)` with no access checks.
376
- * Otherwise:
377
- * - Merges the provided query with any access control filters using `getFormattedQuery`.
378
- * - Attempts to find the document using the formatted query.
379
- * - Determines the user's role via `getWinningRole`.
380
- * - Validates the result using `checkValidation` to ensure read permission.
381
- * - If validation fails, returns an empty object; otherwise returns the validated document.
382
- */
434
+ * Finds a single document in a MongoDB collection with optional role-based filtering and validation.
435
+ *
436
+ * @param {Filter<Document>} query - The MongoDB query used to match the document.
437
+ * @param {Document} [projection] - Optional projection to select returned fields.
438
+ * @param {FindOneOptions} [options] - Optional settings for the findOne operation.
439
+ * @returns {Promise<Document | {} | null>} A promise resolving to the document if found and permitted, an empty object if access is denied, or `null` if not found.
440
+ *
441
+ * @description
442
+ * If `run_as_system` is enabled, the function behaves like a standard `collection.findOne(query)` with no access checks.
443
+ * Otherwise:
444
+ * - Merges the provided query with any access control filters using `getFormattedQuery`.
445
+ * - Attempts to find the document using the formatted query.
446
+ * - Determines the user's role via `getWinningRole`.
447
+ * - Validates the result using `checkValidation` to ensure read permission.
448
+ * - If validation fails, returns an empty object; otherwise returns the validated document.
449
+ */
383
450
  findOne: async (query = {}, projectionOrOptions, options) => {
384
451
  try {
385
452
  const { projection, options: normalizedOptions } = resolveFindArgs(
@@ -389,9 +456,9 @@ const getOperators: GetOperatorsFunction = (
389
456
  const resolvedOptions =
390
457
  projection || normalizedOptions
391
458
  ? {
392
- ...(normalizedOptions ?? {}),
393
- ...(projection ? { projection } : {})
394
- }
459
+ ...(normalizedOptions ?? {}),
460
+ ...(projection ? { projection } : {})
461
+ }
395
462
  : undefined
396
463
  const resolvedQuery = query ?? {}
397
464
  if (!run_as_system) {
@@ -429,15 +496,15 @@ const getOperators: GetOperatorsFunction = (
429
496
  })
430
497
  const { status, document } = winningRole
431
498
  ? await checkValidation(
432
- winningRole,
433
- {
434
- type: 'read',
435
- roles,
436
- cursor: result,
437
- expansions: {}
438
- },
439
- user
440
- )
499
+ winningRole,
500
+ {
501
+ type: 'read',
502
+ roles,
503
+ cursor: result,
504
+ expansions: {}
505
+ },
506
+ user
507
+ )
441
508
  : fallbackAccess(result)
442
509
 
443
510
  // Return validated document or empty object if not permitted
@@ -490,15 +557,15 @@ const getOperators: GetOperatorsFunction = (
490
557
  })
491
558
  const { status } = winningRole
492
559
  ? await checkValidation(
493
- winningRole,
494
- {
495
- type: 'delete',
496
- roles,
497
- cursor: result,
498
- expansions: {}
499
- },
500
- user
501
- )
560
+ winningRole,
561
+ {
562
+ type: 'delete',
563
+ roles,
564
+ cursor: result,
565
+ expansions: {}
566
+ },
567
+ user
568
+ )
502
569
  : fallbackAccess(result)
503
570
 
504
571
  if (!status) {
@@ -545,15 +612,15 @@ const getOperators: GetOperatorsFunction = (
545
612
 
546
613
  const { status, document } = winningRole
547
614
  ? await checkValidation(
548
- winningRole,
549
- {
550
- type: 'insert',
551
- roles,
552
- cursor: data,
553
- expansions: {}
554
- },
555
- user
556
- )
615
+ winningRole,
616
+ {
617
+ type: 'insert',
618
+ roles,
619
+ cursor: data,
620
+ expansions: {}
621
+ },
622
+ user
623
+ )
557
624
  : fallbackAccess(data)
558
625
 
559
626
  if (!status || !isEqual(data, document)) {
@@ -636,15 +703,15 @@ const getOperators: GetOperatorsFunction = (
636
703
  // Validate update permissions
637
704
  const { status, document } = winningRole
638
705
  ? await checkValidation(
639
- winningRole,
640
- {
641
- type: 'write',
642
- roles,
643
- cursor: docToCheck,
644
- expansions: {}
645
- },
646
- user
647
- )
706
+ winningRole,
707
+ {
708
+ type: 'write',
709
+ roles,
710
+ cursor: docToCheck,
711
+ expansions: {}
712
+ },
713
+ user
714
+ )
648
715
  : fallbackAccess(docToCheck)
649
716
  // Ensure no unauthorized changes are made
650
717
  const areDocumentsEqual = areUpdatedFieldsAllowed(document, docToCheck, updatedPaths)
@@ -751,15 +818,15 @@ const getOperators: GetOperatorsFunction = (
751
818
  const readRole = getWinningRole(updateResult, user, roles)
752
819
  const readResult = readRole
753
820
  ? await checkValidation(
754
- readRole,
755
- {
756
- type: 'read',
757
- roles,
758
- cursor: updateResult,
759
- expansions: {}
760
- },
761
- user
762
- )
821
+ readRole,
822
+ {
823
+ type: 'read',
824
+ roles,
825
+ cursor: updateResult,
826
+ expansions: {}
827
+ },
828
+ user
829
+ )
763
830
  : fallbackAccess(updateResult)
764
831
 
765
832
  const sanitizedDoc = readResult.status ? (readResult.document ?? updateResult) : {}
@@ -806,9 +873,9 @@ const getOperators: GetOperatorsFunction = (
806
873
  const resolvedOptions =
807
874
  projection || normalizedOptions
808
875
  ? {
809
- ...(normalizedOptions ?? {}),
810
- ...(projection ? { projection } : {})
811
- }
876
+ ...(normalizedOptions ?? {}),
877
+ ...(projection ? { projection } : {})
878
+ }
812
879
  : undefined
813
880
  if (!run_as_system) {
814
881
  checkDenyOperation(normalizedRules, collection.collectionName, CRUD_OPERATIONS.READ)
@@ -839,15 +906,15 @@ const getOperators: GetOperatorsFunction = (
839
906
  })
840
907
  const { status, document } = winningRole
841
908
  ? await checkValidation(
842
- winningRole,
843
- {
844
- type: 'read',
845
- roles,
846
- cursor: currentDoc,
847
- expansions: {}
848
- },
849
- user
850
- )
909
+ winningRole,
910
+ {
911
+ type: 'read',
912
+ roles,
913
+ cursor: currentDoc,
914
+ expansions: {}
915
+ },
916
+ user
917
+ )
851
918
  : fallbackAccess(currentDoc)
852
919
 
853
920
  return status ? document : undefined
@@ -928,63 +995,85 @@ const getOperators: GetOperatorsFunction = (
928
995
  *
929
996
  * This allows fine-grained control over what change events a user can observe, based on roles and filters.
930
997
  */
931
- watch: (pipeline = [], options) => {
998
+ watch: (pipelineOrOptions = [], options) => {
932
999
  try {
1000
+ const {
1001
+ pipeline,
1002
+ options: watchOptions,
1003
+ extraMatches
1004
+ } = resolveWatchArgs(pipelineOrOptions as Document[] | RealmCompatibleWatchOptions, options as RealmCompatibleWatchOptions)
1005
+
933
1006
  if (!run_as_system) {
934
- checkDenyOperation(normalizedRules, collection.collectionName, CRUD_OPERATIONS.READ)
1007
+ checkDenyOperation(
1008
+ normalizedRules,
1009
+ collection.collectionName,
1010
+ CRUD_OPERATIONS.READ
1011
+ )
935
1012
  // Apply access filters to initial change stream pipeline
936
1013
  const formattedQuery = getFormattedQuery(filters, {}, user)
1014
+ const watchFormattedQuery = formattedQuery.map(
1015
+ (condition) => toWatchMatchFilter(condition) as MongoFilter<Document>
1016
+ )
937
1017
 
938
- const firstStep = formattedQuery.length ? {
939
- $match: {
940
- $and: formattedQuery
941
- }
942
- } : undefined
1018
+ const firstStep = watchFormattedQuery.length
1019
+ ? {
1020
+ $match: {
1021
+ $and: watchFormattedQuery
1022
+ }
1023
+ }
1024
+ : undefined
943
1025
 
944
- const formattedPipeline = [
945
- firstStep,
946
- ...pipeline
947
- ].filter(Boolean) as Document[]
1026
+ const formattedPipeline = [firstStep, ...extraMatches, ...pipeline].filter(Boolean) as Document[]
948
1027
 
949
- const result = collection.watch(formattedPipeline, options)
1028
+ const result = collection.watch(formattedPipeline, watchOptions)
950
1029
  const originalOn = result.on.bind(result)
951
1030
 
952
1031
  /**
953
1032
  * Validates a change event against the user's roles.
954
1033
  *
955
1034
  * @param {Document} change - A change event from the ChangeStream.
956
- * @returns {Promise<{ status: boolean, document: Document, updatedFieldsStatus: boolean, updatedFields: Document }>}
1035
+ * @returns {Promise<{ status: boolean, document: Document, updatedFieldsStatus: boolean, updatedFields: Document, hasFullDocument: boolean, hasWinningRole: boolean }>}
957
1036
  */
958
- const isValidChange = async ({ fullDocument, updateDescription }: Document) => {
1037
+ const isValidChange = async (change: Document) => {
1038
+ const { fullDocument, updateDescription } = change
1039
+ const hasFullDocument = !!fullDocument
959
1040
  const winningRole = getWinningRole(fullDocument, user, roles)
960
1041
 
961
- const { status, document } = winningRole
1042
+ const fullDocumentValidation = winningRole
962
1043
  ? await checkValidation(
963
- winningRole,
964
- {
965
- type: 'read',
966
- roles,
967
- cursor: fullDocument,
968
- expansions: {}
969
- },
970
- user
971
- )
1044
+ winningRole,
1045
+ {
1046
+ type: 'read',
1047
+ roles,
1048
+ cursor: fullDocument,
1049
+ expansions: {}
1050
+ },
1051
+ user
1052
+ )
972
1053
  : fallbackAccess(fullDocument)
1054
+ const { status, document } = fullDocumentValidation
973
1055
 
974
1056
  const { status: updatedFieldsStatus, document: updatedFields } = winningRole
975
1057
  ? await checkValidation(
976
- winningRole,
977
- {
978
- type: 'read',
979
- roles,
980
- cursor: updateDescription?.updatedFields,
981
- expansions: {}
982
- },
983
- user
984
- )
1058
+ winningRole,
1059
+ {
1060
+ type: 'read',
1061
+ roles,
1062
+ cursor: updateDescription?.updatedFields,
1063
+ expansions: {}
1064
+ },
1065
+ user
1066
+ )
985
1067
  : fallbackAccess(updateDescription?.updatedFields)
986
1068
 
987
- return { status, document, updatedFieldsStatus, updatedFields }
1069
+ return {
1070
+ status,
1071
+ document,
1072
+ updatedFieldsStatus,
1073
+ updatedFields,
1074
+ hasFullDocument,
1075
+ hasWinningRole: !!winningRole
1076
+ }
988
1077
  }
989
1078
 
990
1079
  // Override the .on() method to apply validation before emitting events
@@ -993,9 +1082,13 @@ const getOperators: GetOperatorsFunction = (
993
1082
  listener: EventsDescription[EventKey]
994
1083
  ) => {
995
1084
  return originalOn(eventType, async (change: Document) => {
996
- const { status, document, updatedFieldsStatus, updatedFields } =
997
- await isValidChange(change)
998
- if (!status) return
1085
+ const {
1086
+ document,
1087
+ updatedFieldsStatus,
1088
+ updatedFields,
1089
+ hasFullDocument,
1090
+ hasWinningRole
1091
+ } = await isValidChange(change)
999
1092
 
1000
1093
  const filteredChange = {
1001
1094
  ...change,
@@ -1006,6 +1099,18 @@ const getOperators: GetOperatorsFunction = (
1006
1099
  }
1007
1100
  }
1008
1101
 
1102
+ console.log('[flowerbase watch] delivered change', {
1103
+ collection: collName,
1104
+ operationType: change?.operationType,
1105
+ eventType,
1106
+ hasFullDocument,
1107
+ hasWinningRole,
1108
+ updatedFieldsStatus,
1109
+ documentKey:
1110
+ change?.documentKey?._id?.toString?.() ||
1111
+ change?.documentKey?._id ||
1112
+ null
1113
+ })
1009
1114
  listener(filteredChange)
1010
1115
  })
1011
1116
  }
@@ -1014,7 +1119,7 @@ const getOperators: GetOperatorsFunction = (
1014
1119
  }
1015
1120
 
1016
1121
  // System mode: no filtering applied
1017
- const result = collection.watch(pipeline, options)
1122
+ const result = collection.watch([...extraMatches, ...pipeline], watchOptions)
1018
1123
  emitMongoEvent('watch')
1019
1124
  return result
1020
1125
  } catch (error) {
@@ -1105,15 +1210,15 @@ const getOperators: GetOperatorsFunction = (
1105
1210
 
1106
1211
  const { status, document } = winningRole
1107
1212
  ? await checkValidation(
1108
- winningRole,
1109
- {
1110
- type: 'insert',
1111
- roles,
1112
- cursor: currentDoc,
1113
- expansions: {}
1114
- },
1115
- user
1116
- )
1213
+ winningRole,
1214
+ {
1215
+ type: 'insert',
1216
+ roles,
1217
+ cursor: currentDoc,
1218
+ expansions: {}
1219
+ },
1220
+ user
1221
+ )
1117
1222
  : fallbackAccess(currentDoc)
1118
1223
 
1119
1224
  return status ? document : undefined
@@ -1166,15 +1271,15 @@ const getOperators: GetOperatorsFunction = (
1166
1271
 
1167
1272
  const { status, document } = winningRole
1168
1273
  ? await checkValidation(
1169
- winningRole,
1170
- {
1171
- type: 'write',
1172
- roles,
1173
- cursor: currentDoc,
1174
- expansions: {}
1175
- },
1176
- user
1177
- )
1274
+ winningRole,
1275
+ {
1276
+ type: 'write',
1277
+ roles,
1278
+ cursor: currentDoc,
1279
+ expansions: {}
1280
+ },
1281
+ user
1282
+ )
1178
1283
  : fallbackAccess(currentDoc)
1179
1284
 
1180
1285
  return status ? document : undefined
@@ -1236,15 +1341,15 @@ const getOperators: GetOperatorsFunction = (
1236
1341
 
1237
1342
  const { status, document } = winningRole
1238
1343
  ? await checkValidation(
1239
- winningRole,
1240
- {
1241
- type: 'delete',
1242
- roles,
1243
- cursor: currentDoc,
1244
- expansions: {}
1245
- },
1246
- user
1247
- )
1344
+ winningRole,
1345
+ {
1346
+ type: 'delete',
1347
+ roles,
1348
+ cursor: currentDoc,
1349
+ expansions: {}
1350
+ },
1351
+ user
1352
+ )
1248
1353
  : fallbackAccess(currentDoc)
1249
1354
 
1250
1355
  return status ? document : undefined
@@ -49,6 +49,7 @@ export const exposeRoutes = async (fastify: FastifyInstance) => {
49
49
  const db = fastify.mongo.client.db(DB_NAME)
50
50
  const { email, password } = req.body
51
51
  const hashedPassword = await hashPassword(password)
52
+ const now = new Date()
52
53
 
53
54
  const users = db.collection(authCollection!).find()
54
55
 
@@ -65,6 +66,7 @@ export const exposeRoutes = async (fastify: FastifyInstance) => {
65
66
  email: email,
66
67
  password: hashedPassword,
67
68
  status: 'confirmed',
69
+ createdAt: now,
68
70
  custom_data: {}
69
71
  })
70
72