@friggframework/core 2.0.0--canary.606.7fde0eb.0 → 2.0.0--canary.608.ba60ba6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ const { createReportingRouter } = require('@friggframework/core');
2
+ const { createAppHandler } = require('./../app-handler-helpers');
3
+
4
+ const router = createReportingRouter();
5
+
6
+ // true → eager-connect Prisma; the reporting endpoints read the DB.
7
+ const handler = createAppHandler('HTTP Event: Reporting', router, true);
8
+
9
+ module.exports = { handler, router };
@@ -0,0 +1,155 @@
1
+ const { Worker } = require('../../core/Worker');
2
+ const {
3
+ GetIntegrationInstance,
4
+ } = require('../../integrations/use-cases/get-integration-instance');
5
+ const { ModuleFactory } = require('../../modules/module-factory');
6
+ const {
7
+ createIntegrationRepository,
8
+ } = require('../../integrations/repositories/integration-repository-factory');
9
+ const {
10
+ createModuleRepository,
11
+ } = require('../../modules/repositories/module-repository-factory');
12
+ const {
13
+ getModulesDefinitionFromIntegrationClasses,
14
+ } = require('../../integrations/utils/map-integration-dto');
15
+
16
+ /**
17
+ * App-level worker for events dispatched with `dispatch: 'queue'`.
18
+ *
19
+ * A single Lambda serves every integration: messages arrive on the FIFO queue
20
+ * serialized per `MessageGroupId = integrationId`, so concurrent mutations for
21
+ * one integration run one-at-a-time. The worker re-hydrates the integration by
22
+ * id + owning user and runs `instance.send(event, data)` — byte-identical to
23
+ * the in-process (sync) path it replaces.
24
+ *
25
+ * Failure handling:
26
+ * - missing ids / un-hydratable / id mismatch → HaltError (discard, no retry)
27
+ * - handler throw → record ERROR status + a warning message, then rethrow so
28
+ * SQS retries and ultimately routes to the FIFO DLQ.
29
+ *
30
+ * DISABLED/ERROR integrations are NOT discarded — these are user-initiated
31
+ * mutations (including ERROR recovery), unlike webhook/cron traffic.
32
+ */
33
+ class UserActionWorker extends Worker {
34
+ constructor({ getIntegrationInstance } = {}) {
35
+ super();
36
+ this._getIntegrationInstance = getIntegrationInstance || null;
37
+ }
38
+
39
+ _validateParams(params) {
40
+ this._verifyParamExists(params, 'event');
41
+ this._verifyParamExists(params, 'data');
42
+ }
43
+
44
+ _resolveGetIntegrationInstance() {
45
+ if (this._getIntegrationInstance) return this._getIntegrationInstance;
46
+
47
+ const { loadAppDefinition } = require('../app-definition-loader');
48
+ const { integrations: integrationClasses } = loadAppDefinition();
49
+ const integrationRepository = createIntegrationRepository();
50
+ const moduleRepository = createModuleRepository();
51
+ const moduleFactory = new ModuleFactory({
52
+ moduleRepository,
53
+ moduleDefinitions:
54
+ getModulesDefinitionFromIntegrationClasses(integrationClasses),
55
+ });
56
+
57
+ this._getIntegrationInstance = new GetIntegrationInstance({
58
+ integrationRepository,
59
+ integrationClasses,
60
+ moduleFactory,
61
+ });
62
+ return this._getIntegrationInstance;
63
+ }
64
+
65
+ async _run(params) {
66
+ // Routing metadata is at the envelope top level; `data` is the exact
67
+ // handler payload (byte-identical to the sync path).
68
+ const { event, data = {}, integrationId, userId, requestId } = params;
69
+ const logCtx = { event, integrationId, userId, requestId };
70
+
71
+ if (!integrationId || !userId) {
72
+ const err = new Error(
73
+ '[UserActionWorker] message missing integrationId/userId'
74
+ );
75
+ err.isHaltError = true;
76
+ console.error(err.message, logCtx);
77
+ throw err;
78
+ }
79
+
80
+ const getIntegrationInstance = this._resolveGetIntegrationInstance();
81
+
82
+ let instance;
83
+ try {
84
+ instance = await getIntegrationInstance.execute(
85
+ integrationId,
86
+ userId
87
+ );
88
+ } catch (error) {
89
+ // Discard ONLY terminal failures (gone / not owned / unknown class)
90
+ // — no retry can ever succeed. Transient failures (DB blip, Prisma
91
+ // timeout, KMS throttle during credential decrypt) are NOT marked
92
+ // terminal, so they retry and ultimately reach the FIFO DLQ instead
93
+ // of being silently dropped.
94
+ if (error.isTerminal) {
95
+ error.isHaltError = true;
96
+ console.warn(
97
+ `[UserActionWorker] integration ${integrationId} gone/invalid — discarding`,
98
+ { ...logCtx, reason: error.message }
99
+ );
100
+ } else {
101
+ console.error(
102
+ `[UserActionWorker] transient hydration failure for ${integrationId} — will retry`,
103
+ { ...logCtx, reason: error.message }
104
+ );
105
+ }
106
+ throw error;
107
+ }
108
+
109
+ if (String(instance.id) !== String(integrationId)) {
110
+ const err = new Error(
111
+ `[UserActionWorker] hydrated id ${instance.id} != message integrationId ${integrationId}`
112
+ );
113
+ err.isHaltError = true;
114
+ console.error(err.message, logCtx);
115
+ throw err;
116
+ }
117
+
118
+ try {
119
+ console.log(`[UserActionWorker] dispatching ${event}`, logCtx);
120
+ const result = await instance.send(event, data);
121
+ console.log(`[UserActionWorker] ${event} ok`, logCtx);
122
+ return result;
123
+ } catch (error) {
124
+ try {
125
+ await instance.updateIntegrationStatus.execute(
126
+ integrationId,
127
+ 'ERROR'
128
+ );
129
+ await instance.updateIntegrationMessages.execute(
130
+ integrationId,
131
+ 'warnings',
132
+ `Queued ${event} failed`,
133
+ error.message,
134
+ new Date().toISOString()
135
+ );
136
+ } catch (statusErr) {
137
+ console.error(
138
+ '[UserActionWorker] failed to record error status/messages',
139
+ { ...logCtx, statusErr: statusErr.message }
140
+ );
141
+ }
142
+ console.error(`[UserActionWorker] ${event} failed`, {
143
+ ...logCtx,
144
+ error: error.message,
145
+ });
146
+ throw error; // → SQS retry → FIFO DLQ
147
+ }
148
+ }
149
+ }
150
+
151
+ async function userActionQueueWorker(event, context) {
152
+ return new UserActionWorker().run(event, context);
153
+ }
154
+
155
+ module.exports = { UserActionWorker, userActionQueueWorker };
package/index.js CHANGED
@@ -30,9 +30,7 @@ const {
30
30
  const {
31
31
  GetUserFromAdopterJwt,
32
32
  } = require('./user/use-cases/get-user-from-adopter-jwt');
33
- const {
34
- AuthenticateUser,
35
- } = require('./user/use-cases/authenticate-user');
33
+ const { AuthenticateUser } = require('./user/use-cases/authenticate-user');
36
34
 
37
35
  const {
38
36
  CredentialRepository,
@@ -43,18 +41,14 @@ const {
43
41
  const {
44
42
  IntegrationMappingRepository,
45
43
  } = require('./integrations/repositories/integration-mapping-repository');
46
- const {
47
- CreateProcess,
48
- } = require('./integrations/use-cases/create-process');
44
+ const { CreateProcess } = require('./integrations/use-cases/create-process');
49
45
  const {
50
46
  UpdateProcessState,
51
47
  } = require('./integrations/use-cases/update-process-state');
52
48
  const {
53
49
  UpdateProcessMetrics,
54
50
  } = require('./integrations/use-cases/update-process-metrics');
55
- const {
56
- GetProcess,
57
- } = require('./integrations/use-cases/get-process');
51
+ const { GetProcess } = require('./integrations/use-cases/get-process');
58
52
  const { Cryptor } = require('./encrypt');
59
53
  const {
60
54
  BaseError,
@@ -71,6 +65,10 @@ const {
71
65
  getModulesDefinitionFromIntegrationClasses,
72
66
  LoadIntegrationContextUseCase,
73
67
  } = require('./integrations/index');
68
+ const {
69
+ createReportingRouter,
70
+ createReportingRepository,
71
+ } = require('./reporting/index');
74
72
  const { TimeoutCatcher } = require('./lambda/index');
75
73
  const { debug, initDebugLog, flushDebugLog } = require('./logs/index');
76
74
  const {
@@ -87,6 +85,12 @@ const application = require('./application');
87
85
  const utils = require('./utils');
88
86
 
89
87
  const { QueuerUtil } = require('./queues');
88
+ const {
89
+ dispatchIntegrationEvent,
90
+ } = require('./integrations/use-cases/dispatch-integration-event');
91
+ const {
92
+ userActionQueueWorker,
93
+ } = require('./handlers/workers/user-action-worker');
90
94
 
91
95
  module.exports = {
92
96
  // assertions
@@ -133,12 +137,17 @@ module.exports = {
133
137
  checkRequiredParams,
134
138
  createIntegrationRouter,
135
139
  getModulesDefinitionFromIntegrationClasses,
140
+ dispatchIntegrationEvent,
136
141
  LoadIntegrationContextUseCase,
137
142
  CreateProcess,
138
143
  UpdateProcessState,
139
144
  UpdateProcessMetrics,
140
145
  GetProcess,
141
146
 
147
+ // reporting
148
+ createReportingRouter,
149
+ createReportingRepository,
150
+
142
151
  // application - Command factories for integration developers
143
152
  application,
144
153
  createFriggCommands: application.createFriggCommands,
@@ -172,6 +181,9 @@ module.exports = {
172
181
  // queues
173
182
  QueuerUtil,
174
183
 
184
+ // workers
185
+ userActionQueueWorker,
186
+
175
187
  // utils
176
188
  ...utils,
177
189
  };
@@ -31,6 +31,13 @@ const constantsToBeMigrated = {
31
31
  LIFE_CYCLE_EVENT: 'LIFE_CYCLE_EVENT',
32
32
  USER_ACTION: 'USER_ACTION',
33
33
  },
34
+ // Per-event dispatch mode. 'sync' (default) runs the handler in-process and
35
+ // returns its result; 'queue' routes the event through the framework-owned
36
+ // FIFO queue, serialized by integrationId, returning a 202 ack.
37
+ dispatch: {
38
+ SYNC: 'sync',
39
+ QUEUE: 'queue',
40
+ },
34
41
  };
35
42
 
36
43
  class IntegrationBase {
@@ -208,11 +215,11 @@ class IntegrationBase {
208
215
  /**
209
216
  * Returns the modules as object with keys as module names.
210
217
  * Uses the keys from Definition.modules to attach modules correctly.
211
- *
218
+ *
212
219
  * Example:
213
220
  * Definition.modules = { attio: {...}, quo: { definition: { getName: () => 'quo-attio' } } }
214
221
  * Module with getName()='quo-attio' gets attached as this.quo (not this['quo-attio'])
215
- *
222
+ *
216
223
  * @private
217
224
  * @param {Array} integrationModules - Array of module instances
218
225
  * @returns {Object} The modules object
@@ -224,13 +231,16 @@ class IntegrationBase {
224
231
  // e.g., 'quo-attio' → 'quo', 'attio' → 'attio'
225
232
  const moduleNameToKey = {};
226
233
  if (this.constructor.Definition?.modules) {
227
- for (const [key, moduleConfig] of Object.entries(this.constructor.Definition.modules)) {
234
+ for (const [key, moduleConfig] of Object.entries(
235
+ this.constructor.Definition.modules
236
+ )) {
228
237
  const definition = moduleConfig.definition;
229
238
  if (definition) {
230
239
  // Use getName() if available, fallback to moduleName
231
- const definitionName = typeof definition.getName === 'function'
232
- ? definition.getName()
233
- : definition.moduleName;
240
+ const definitionName =
241
+ typeof definition.getName === 'function'
242
+ ? definition.getName()
243
+ : definition.moduleName;
234
244
  if (definitionName) {
235
245
  moduleNameToKey[definitionName] = key;
236
246
  }
@@ -521,6 +531,24 @@ class IntegrationBase {
521
531
  ...this.defaultEvents,
522
532
  ...this.events,
523
533
  };
534
+
535
+ // Apply Definition.eventDispatch onto the resolved handler entries. This
536
+ // lets a default lifecycle event (e.g. ON_UPDATE) be marked 'queue'
537
+ // without re-declaring its handler. An inline `dispatch` on the event
538
+ // entry always wins; unknown event names are ignored (a typo stays sync
539
+ // rather than crashing initialize()).
540
+ const eventDispatch = this.constructor.Definition?.eventDispatch || {};
541
+ for (const [eventName, mode] of Object.entries(eventDispatch)) {
542
+ if (
543
+ this.on[eventName] &&
544
+ this.on[eventName].dispatch === undefined
545
+ ) {
546
+ this.on[eventName] = {
547
+ ...this.on[eventName],
548
+ dispatch: mode,
549
+ };
550
+ }
551
+ }
524
552
  }
525
553
 
526
554
  /**
@@ -608,6 +636,9 @@ class IntegrationBase {
608
636
  this.events[eventName] = {
609
637
  type: eventDef.type,
610
638
  handler: fn.bind(this),
639
+ ...(eventDef.dispatch !== undefined && {
640
+ dispatch: eventDef.dispatch,
641
+ }),
611
642
  };
612
643
  mergedByExtension.set(eventName, bindingName);
613
644
  }
@@ -675,7 +706,11 @@ class IntegrationBase {
675
706
 
676
707
  if (delegateString === 'CREDENTIAL_INVALIDATED') {
677
708
  console.log(
678
- `[Frigg] Module ${notifier?.name || '?'} reported invalid credentials for integration ${this.id} — marking ERROR`
709
+ `[Frigg] Module ${
710
+ notifier?.name || '?'
711
+ } reported invalid credentials for integration ${
712
+ this.id
713
+ } — marking ERROR`
679
714
  );
680
715
  await this.updateIntegrationStatus.execute(this.id, 'ERROR');
681
716
  this.status = 'ERROR';
@@ -685,7 +720,11 @@ class IntegrationBase {
685
720
  if (delegateString === 'CREDENTIAL_VALIDATED') {
686
721
  if (this.status !== 'ERROR') return;
687
722
  console.log(
688
- `[Frigg] Module ${notifier?.name || '?'} reported valid credentials for integration ${this.id} — clearing ERROR → ENABLED`
723
+ `[Frigg] Module ${
724
+ notifier?.name || '?'
725
+ } reported valid credentials for integration ${
726
+ this.id
727
+ } — clearing ERROR → ENABLED`
689
728
  );
690
729
  await this.updateIntegrationStatus.execute(this.id, 'ENABLED');
691
730
  this.status = 'ENABLED';
@@ -30,6 +30,9 @@ const {
30
30
  GetIntegrationInstance,
31
31
  } = require('./use-cases/get-integration-instance');
32
32
  const { UpdateIntegration } = require('./use-cases/update-integration');
33
+ const {
34
+ dispatchIntegrationEvent,
35
+ } = require('./use-cases/dispatch-integration-event');
33
36
  const {
34
37
  getModulesDefinitionFromIntegrationClasses,
35
38
  } = require('./utils/map-integration-dto');
@@ -293,6 +296,9 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
293
296
  params.config
294
297
  );
295
298
 
299
+ if (integration?.queued) {
300
+ return res.status(202).json(integration);
301
+ }
296
302
  res.status(201).json(integration);
297
303
  })
298
304
  );
@@ -308,6 +314,9 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
308
314
  userId,
309
315
  params.config
310
316
  );
317
+ if (integration?.queued) {
318
+ return res.status(202).json(integration);
319
+ }
311
320
  res.json(integration);
312
321
  })
313
322
  );
@@ -425,7 +434,21 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
425
434
  params.integrationId,
426
435
  user.getId()
427
436
  );
428
- res.json(await integration.send(params.actionId, req.body));
437
+ const outcome = await dispatchIntegrationEvent({
438
+ instance: integration,
439
+ event: params.actionId,
440
+ data: req.body,
441
+ userId: user.getId(),
442
+ });
443
+ if (outcome.queued) {
444
+ return res.status(202).json({
445
+ queued: true,
446
+ integrationId: integration.id,
447
+ messageId: outcome.messageId,
448
+ requestId: outcome.requestId,
449
+ });
450
+ }
451
+ res.json(outcome.result);
429
452
  })
430
453
  );
431
454
 
@@ -536,7 +559,9 @@ function setEntityRoutes(router, authenticateUser, useCases) {
536
559
  ? Object.keys(params.data)
537
560
  : [];
538
561
  console.log(
539
- `[Frigg] POST /api/authorize userId=${userId} entityType=${params.entityType} dataKeys=${JSON.stringify(dataKeys)}`
562
+ `[Frigg] POST /api/authorize userId=${userId} entityType=${
563
+ params.entityType
564
+ } dataKeys=${JSON.stringify(dataKeys)}`
540
565
  );
541
566
 
542
567
  try {
@@ -554,7 +579,9 @@ function setEntityRoutes(router, authenticateUser, useCases) {
554
579
  res.json(entityDetails);
555
580
  } catch (err) {
556
581
  console.error(
557
- `[Frigg] POST /api/authorize failed userId=${userId} entityType=${params.entityType} error=${err?.message || err}`
582
+ `[Frigg] POST /api/authorize failed userId=${userId} entityType=${
583
+ params.entityType
584
+ } error=${err?.message || err}`
558
585
  );
559
586
  throw err;
560
587
  }
@@ -2,6 +2,7 @@
2
2
  const {
3
3
  mapIntegrationClassToIntegrationDTO,
4
4
  } = require('../utils/map-integration-dto');
5
+ const { dispatchIntegrationEvent } = require('./dispatch-integration-event');
5
6
 
6
7
  /**
7
8
  * Use case for creating a new integration instance.
@@ -71,11 +72,26 @@ class CreateIntegration {
71
72
  modules,
72
73
  });
73
74
 
75
+ // ON_CREATE runs in-process by default. Routed through the dispatch
76
+ // helper so an integration can opt into dispatch:'queue' if desired;
77
+ // when queued, an ack is returned instead of the DTO.
74
78
  await integrationInstance.initialize();
75
- await integrationInstance.send('ON_CREATE', {
76
- integrationId: integrationRecord.id,
79
+ const outcome = await dispatchIntegrationEvent({
80
+ instance: integrationInstance,
81
+ event: 'ON_CREATE',
82
+ data: { integrationId: integrationRecord.id },
83
+ userId,
77
84
  });
78
85
 
86
+ if (outcome.queued) {
87
+ return {
88
+ queued: true,
89
+ integrationId: integrationInstance.id,
90
+ messageId: outcome.messageId,
91
+ requestId: outcome.requestId,
92
+ };
93
+ }
94
+
79
95
  return mapIntegrationClassToIntegrationDTO(integrationInstance);
80
96
  }
81
97
  }
@@ -0,0 +1,76 @@
1
+ const { v4: uuid } = require('uuid');
2
+ const { QueuerUtil } = require('../../queues');
3
+
4
+ const SCHEMA_VERSION = 1;
5
+
6
+ // Default lifecycle events that MUTATE and may therefore be routed to the queue.
7
+ // Read-shaped defaults (GET_*/REFRESH_*/WEBHOOK_RECEIVED) must never be queued —
8
+ // a queued read would return a 202 ack instead of the data the caller expects.
9
+ const MUTATING_DEFAULT_EVENTS = new Set([
10
+ 'ON_CREATE',
11
+ 'ON_UPDATE',
12
+ 'ON_DELETE',
13
+ ]);
14
+
15
+ // Custom user actions (events not present in defaultEvents) are mutating by
16
+ // intent; only default events are filtered against the allowlist above.
17
+ function isMutatingEvent(instance, event) {
18
+ if (!instance.defaultEvents || !instance.defaultEvents[event]) {
19
+ return true;
20
+ }
21
+ return MUTATING_DEFAULT_EVENTS.has(event);
22
+ }
23
+
24
+ /**
25
+ * Central producer decision for dispatching an integration event.
26
+ *
27
+ * If the event opts into `dispatch: 'queue'`, is a mutating event, and the
28
+ * framework queue is configured, the event is enqueued on the app-level FIFO
29
+ * queue (serialized per integrationId) and a `{ queued }` ack is returned.
30
+ * Otherwise the handler runs in-process and its result is returned. When the
31
+ * queue URL is missing we degrade gracefully to in-process execution.
32
+ *
33
+ * @param {Object} args
34
+ * @param {Object} args.instance - Initialized integration instance (has `on`, `id`, `send`).
35
+ * @param {string} args.event - Event name to dispatch.
36
+ * @param {Object} args.data - Payload passed to the handler / placed in the envelope.
37
+ * @param {string} args.userId - Owning user id (used by the worker to re-hydrate).
38
+ * @returns {Promise<{queued:true, messageId:string, requestId:string} | {result:any}>}
39
+ */
40
+ async function dispatchIntegrationEvent({ instance, event, data, userId }) {
41
+ const mode = instance.on?.[event]?.dispatch;
42
+ const queueUrl = process.env.USER_ACTION_QUEUE_URL;
43
+
44
+ const eligible = mode === 'queue' && isMutatingEvent(instance, event);
45
+
46
+ if (eligible && !queueUrl) {
47
+ console.warn(
48
+ `[dispatchIntegrationEvent] event "${event}" requested dispatch:'queue' ` +
49
+ `but USER_ACTION_QUEUE_URL is not set — running in-process (sync)`
50
+ );
51
+ }
52
+
53
+ if (eligible && queueUrl) {
54
+ const requestId = uuid();
55
+ // Routing metadata lives at the envelope top level (alongside event),
56
+ // NOT inside `data`. `data` stays the exact handler payload the sync
57
+ // path passes, so the worker's send(event, data) is byte-identical.
58
+ const envelope = {
59
+ schemaVersion: SCHEMA_VERSION,
60
+ event,
61
+ integrationId: instance.id,
62
+ userId,
63
+ requestId,
64
+ data,
65
+ };
66
+ const result = await QueuerUtil.send(envelope, queueUrl, {
67
+ messageGroupId: instance.id,
68
+ messageDeduplicationId: requestId,
69
+ });
70
+ return { queued: true, messageId: result?.MessageId, requestId };
71
+ }
72
+
73
+ return { result: await instance.send(event, data) };
74
+ }
75
+
76
+ module.exports = { dispatchIntegrationEvent, SCHEMA_VERSION };
@@ -31,9 +31,12 @@ class GetIntegrationInstance {
31
31
  await this.integrationRepository.findIntegrationById(integrationId);
32
32
 
33
33
  if (!integrationRecord) {
34
- throw new Error(
34
+ const error = new Error(
35
35
  `No integration found by the ID of ${integrationId}`
36
36
  );
37
+ // Terminal: the integration does not exist — no retry can succeed.
38
+ error.isTerminal = true;
39
+ throw error;
37
40
  }
38
41
 
39
42
  const integrationClass = this.integrationClasses.find(
@@ -43,15 +46,21 @@ class GetIntegrationInstance {
43
46
  );
44
47
 
45
48
  if (!integrationClass) {
46
- throw new Error(
49
+ const error = new Error(
47
50
  `No integration class found for type: ${integrationRecord.config.type}`
48
51
  );
52
+ // Terminal: the integration type is not registered — no retry helps.
53
+ error.isTerminal = true;
54
+ throw error;
49
55
  }
50
56
 
51
57
  if (integrationRecord.userId !== userId) {
52
- throw new Error(
58
+ const error = new Error(
53
59
  `Integration ${integrationId} does not belong to User ${userId}`
54
60
  );
61
+ // Terminal: ownership mismatch — no retry can succeed.
62
+ error.isTerminal = true;
63
+ throw error;
55
64
  }
56
65
 
57
66
  const modules = [];
@@ -1,6 +1,7 @@
1
1
  const {
2
2
  mapIntegrationClassToIntegrationDTO,
3
3
  } = require('../utils/map-integration-dto');
4
+ const { dispatchIntegrationEvent } = require('./dispatch-integration-event');
4
5
 
5
6
  /**
6
7
  * Use case for updating a single integration by ID and user.
@@ -81,9 +82,26 @@ class UpdateIntegration {
81
82
  modules,
82
83
  });
83
84
 
84
- // 5. Complete async initialization and trigger update event
85
+ // 5. Complete async initialization and dispatch the update event.
86
+ // When ON_UPDATE is marked dispatch:'queue', the update is enqueued
87
+ // (serialized per integration) and an ack is returned; otherwise it
88
+ // runs in-process and the updated DTO is returned (unchanged behavior).
85
89
  await integrationInstance.initialize();
86
- await integrationInstance.send('ON_UPDATE', { config });
90
+ const outcome = await dispatchIntegrationEvent({
91
+ instance: integrationInstance,
92
+ event: 'ON_UPDATE',
93
+ data: { config },
94
+ userId,
95
+ });
96
+
97
+ if (outcome.queued) {
98
+ return {
99
+ queued: true,
100
+ integrationId: integrationInstance.id,
101
+ messageId: outcome.messageId,
102
+ requestId: outcome.requestId,
103
+ };
104
+ }
87
105
 
88
106
  return mapIntegrationClassToIntegrationDTO(integrationInstance);
89
107
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.606.7fde0eb.0",
4
+ "version": "2.0.0--canary.608.ba60ba6.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -38,9 +38,9 @@
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "@friggframework/eslint-config": "2.0.0--canary.606.7fde0eb.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.606.7fde0eb.0",
43
- "@friggframework/test": "2.0.0--canary.606.7fde0eb.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.608.ba60ba6.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.608.ba60ba6.0",
43
+ "@friggframework/test": "2.0.0--canary.608.ba60ba6.0",
44
44
  "@prisma/client": "^6.19.3",
45
45
  "@types/lodash": "4.17.15",
46
46
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -80,5 +80,5 @@
80
80
  "publishConfig": {
81
81
  "access": "public"
82
82
  },
83
- "gitHead": "7fde0eb1c5ed7a7be35cb02b07dcba85ffef5b1c"
83
+ "gitHead": "ba60ba64c680647ce1fdb2e5453b29acc38d6f49"
84
84
  }
@@ -1,5 +1,9 @@
1
1
  const { v4: uuid } = require('uuid');
2
- const { SQSClient, SendMessageCommand, SendMessageBatchCommand } = require('@aws-sdk/client-sqs');
2
+ const {
3
+ SQSClient,
4
+ SendMessageCommand,
5
+ SendMessageBatchCommand,
6
+ } = require('@aws-sdk/client-sqs');
3
7
 
4
8
  const awsConfigOptions = () => {
5
9
  const config = {};
@@ -90,10 +94,23 @@ const inspectBatchResult = (result, queueUrl, buffer) => {
90
94
  };
91
95
 
92
96
  const QueuerUtil = {
93
- send: async (message, queueUrl) => {
97
+ // `messageGroupId`/`messageDeduplicationId` are only used for FIFO queues.
98
+ // Standard sends omit them, so the command is byte-identical to before.
99
+ // FIFO queues require a deduplication id whenever ContentBasedDeduplication
100
+ // is off — we default to a uuid so every send is treated as distinct (two
101
+ // distinct requests with identical bodies must both run, not be dropped).
102
+ send: async (
103
+ message,
104
+ queueUrl,
105
+ { messageGroupId, messageDeduplicationId } = {}
106
+ ) => {
94
107
  const command = new SendMessageCommand({
95
108
  MessageBody: JSON.stringify(message),
96
109
  QueueUrl: queueUrl,
110
+ ...(messageGroupId !== undefined && {
111
+ MessageGroupId: messageGroupId,
112
+ MessageDeduplicationId: messageDeduplicationId || uuid(),
113
+ }),
97
114
  });
98
115
  const result = await sqs.send(command);
99
116
  console.log(
@@ -0,0 +1,17 @@
1
+ const { createReportingRouter } = require('./reporting-router');
2
+ const {
3
+ createReportingRepository,
4
+ ReportingRepositoryMongo,
5
+ ReportingRepositoryPostgres,
6
+ ReportingRepositoryDocumentDB,
7
+ } = require('./repositories/reporting-repository-factory');
8
+ const { ListIntegrationsReport } = require('./use-cases');
9
+
10
+ module.exports = {
11
+ createReportingRouter,
12
+ createReportingRepository,
13
+ ReportingRepositoryMongo,
14
+ ReportingRepositoryPostgres,
15
+ ReportingRepositoryDocumentDB,
16
+ ListIntegrationsReport,
17
+ };
@@ -0,0 +1,48 @@
1
+ const express = require('express');
2
+ const catchAsyncError = require('express-async-handler');
3
+ const {
4
+ createReportingRepository,
5
+ } = require('./repositories/reporting-repository-factory');
6
+ const { ListIntegrationsReport } = require('./use-cases/list-integrations-report');
7
+
8
+ function createReportingRouter() {
9
+ const reportingRepository = createReportingRepository();
10
+ const listIntegrationsReport = new ListIntegrationsReport({
11
+ reportingRepository,
12
+ });
13
+
14
+ const router = express.Router();
15
+ router.use(validateApiKey);
16
+
17
+ router.get('/api/v2/reports', (_req, res) => {
18
+ res.json({ service: 'frigg-core-api', reports: ['integrations'] });
19
+ });
20
+
21
+ router.get(
22
+ '/api/v2/reports/integrations',
23
+ catchAsyncError(async (req, res) => {
24
+ const { status, type, userId } = req.query;
25
+ res.json(
26
+ await listIntegrationsReport.execute({ status, type, userId })
27
+ );
28
+ })
29
+ );
30
+
31
+ return router;
32
+ }
33
+
34
+ function validateApiKey(req, res, next) {
35
+ const apiKey = req.headers['x-frigg-reporting-api-key'];
36
+
37
+ if (!apiKey || apiKey !== process.env.REPORTING_API_KEY) {
38
+ console.error('Unauthorized access attempt to reporting endpoint');
39
+ return res.status(401).json({
40
+ status: 'error',
41
+ message: 'Unauthorized - x-frigg-reporting-api-key header required',
42
+ });
43
+ }
44
+
45
+ next();
46
+ }
47
+
48
+ module.exports = { createReportingRouter, validateApiKey };
@@ -0,0 +1,127 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const { toObjectId, fromObjectId } = require('../../database/documentdb-utils');
3
+ const {
4
+ ReportingRepositoryInterface,
5
+ } = require('./reporting-repository-interface');
6
+
7
+ const DRAIN_BATCH_SIZE = 1000;
8
+ const MAX_BATCHES = 100000;
9
+
10
+ // Drains cursors via getMore rather than reusing documentdb-utils.findMany/
11
+ // aggregate, which return only the first batch (~101 docs) and would silently
12
+ // truncate a deployment-wide report.
13
+ class ReportingRepositoryDocumentDB extends ReportingRepositoryInterface {
14
+ constructor() {
15
+ super();
16
+ this.prisma = prisma;
17
+ }
18
+
19
+ async findIntegrationsForReport({ status, userId } = {}) {
20
+ const filter = {};
21
+ if (status) filter.status = status;
22
+ if (userId !== undefined && userId !== null) {
23
+ const objectId = toObjectId(userId);
24
+ // An invalid userId means no matches — must not fall through to an
25
+ // unfiltered query that returns the whole deployment.
26
+ if (!objectId) return [];
27
+ filter.userId = objectId;
28
+ }
29
+
30
+ const docs = await this._findDrained('Integration', filter);
31
+
32
+ return docs.map((doc) => {
33
+ const errors = this._extractErrors(doc);
34
+ return {
35
+ id: fromObjectId(doc?._id),
36
+ type: doc?.config?.type ?? null,
37
+ status: doc?.status ?? null,
38
+ userId: fromObjectId(doc?.userId) ?? null,
39
+ version: doc?.version ?? null,
40
+ errorCount: Array.isArray(errors) ? errors.length : 0,
41
+ moduleCount: Array.isArray(doc?.entityIds)
42
+ ? doc.entityIds.length
43
+ : 0,
44
+ createdAt: doc?.createdAt ?? null,
45
+ updatedAt: doc?.updatedAt ?? null,
46
+ };
47
+ });
48
+ }
49
+
50
+ async countMappingsByIntegrationIds(ids = []) {
51
+ const counts = new Map();
52
+ if (!ids || ids.length === 0) return counts;
53
+
54
+ // IntegrationMapping.integrationId is stored as a string in DocumentDB,
55
+ // so match by string — an ObjectId $in would never match (always 0).
56
+ const stringIds = ids.map(String);
57
+
58
+ const rows = await this._aggregateDrained('IntegrationMapping', [
59
+ { $match: { integrationId: { $in: stringIds } } },
60
+ { $group: { _id: '$integrationId', count: { $sum: 1 } } },
61
+ ]);
62
+
63
+ for (const row of rows) {
64
+ counts.set(String(row?._id), row?.count ?? 0);
65
+ }
66
+ return counts;
67
+ }
68
+
69
+ async _findDrained(collection, filter) {
70
+ const first = await this.prisma.$runCommandRaw({
71
+ find: collection,
72
+ filter,
73
+ batchSize: DRAIN_BATCH_SIZE,
74
+ });
75
+ return this._drain(collection, first);
76
+ }
77
+
78
+ async _aggregateDrained(collection, pipeline) {
79
+ const first = await this.prisma.$runCommandRaw({
80
+ aggregate: collection,
81
+ pipeline,
82
+ cursor: { batchSize: DRAIN_BATCH_SIZE },
83
+ });
84
+ return this._drain(collection, first);
85
+ }
86
+
87
+ async _drain(collection, firstResult) {
88
+ const cursor = firstResult?.cursor || {};
89
+ const docs = [...(cursor.firstBatch || [])];
90
+ let cursorId = cursor.id;
91
+ let batches = 0;
92
+
93
+ while (this._cursorOpen(cursorId) && batches < MAX_BATCHES) {
94
+ batches += 1;
95
+ const next = await this.prisma.$runCommandRaw({
96
+ getMore: cursorId,
97
+ collection,
98
+ batchSize: DRAIN_BATCH_SIZE,
99
+ });
100
+ const nextCursor = next?.cursor || {};
101
+ const nextBatch = nextCursor.nextBatch || [];
102
+ docs.push(...nextBatch);
103
+ cursorId = nextCursor.id;
104
+ if (nextBatch.length === 0) break;
105
+ }
106
+ return docs;
107
+ }
108
+
109
+ _cursorOpen(id) {
110
+ if (id === undefined || id === null) return false;
111
+ if (typeof id === 'number') return id !== 0;
112
+ if (typeof id === 'bigint') return id !== 0n;
113
+ // Extended JSON can surface a 64-bit cursor id as { $numberLong: "..." }.
114
+ if (typeof id === 'object' && id.$numberLong !== undefined) {
115
+ return id.$numberLong !== '0';
116
+ }
117
+ return String(id) !== '0';
118
+ }
119
+
120
+ _extractErrors(doc) {
121
+ if (Array.isArray(doc?.errors)) return doc.errors;
122
+ if (Array.isArray(doc?.messages?.errors)) return doc.messages.errors;
123
+ return [];
124
+ }
125
+ }
126
+
127
+ module.exports = { ReportingRepositoryDocumentDB };
@@ -0,0 +1,35 @@
1
+ const { ReportingRepositoryMongo } = require('./reporting-repository-mongo');
2
+ const {
3
+ ReportingRepositoryPostgres,
4
+ } = require('./reporting-repository-postgres');
5
+ const {
6
+ ReportingRepositoryDocumentDB,
7
+ } = require('./reporting-repository-documentdb');
8
+ const config = require('../../database/config');
9
+
10
+ function createReportingRepository() {
11
+ const dbType = config.DB_TYPE;
12
+
13
+ switch (dbType) {
14
+ case 'mongodb':
15
+ return new ReportingRepositoryMongo();
16
+
17
+ case 'postgresql':
18
+ return new ReportingRepositoryPostgres();
19
+
20
+ case 'documentdb':
21
+ return new ReportingRepositoryDocumentDB();
22
+
23
+ default:
24
+ throw new Error(
25
+ `Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'`
26
+ );
27
+ }
28
+ }
29
+
30
+ module.exports = {
31
+ createReportingRepository,
32
+ ReportingRepositoryMongo,
33
+ ReportingRepositoryPostgres,
34
+ ReportingRepositoryDocumentDB,
35
+ };
@@ -0,0 +1,16 @@
1
+ class ReportingRepositoryInterface {
2
+ // returns: [{ id, type, status, userId, version, errorCount, moduleCount, createdAt, updatedAt }]
3
+ async findIntegrationsForReport(filter) {
4
+ throw new Error(
5
+ 'Method findIntegrationsForReport must be implemented by subclass'
6
+ );
7
+ }
8
+
9
+ async countMappingsByIntegrationIds(ids) {
10
+ throw new Error(
11
+ 'Method countMappingsByIntegrationIds must be implemented by subclass'
12
+ );
13
+ }
14
+ }
15
+
16
+ module.exports = { ReportingRepositoryInterface };
@@ -0,0 +1,54 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const {
3
+ ReportingRepositoryInterface,
4
+ } = require('./reporting-repository-interface');
5
+
6
+ class ReportingRepositoryMongo extends ReportingRepositoryInterface {
7
+ constructor() {
8
+ super();
9
+ this.prisma = prisma;
10
+ }
11
+
12
+ async findIntegrationsForReport({ status, userId } = {}) {
13
+ const where = {};
14
+ if (status) where.status = status;
15
+ if (userId !== undefined && userId !== null) where.userId = userId;
16
+
17
+ const integrations = await this.prisma.integration.findMany({
18
+ where,
19
+ include: { entities: { select: { id: true } } },
20
+ });
21
+
22
+ return integrations.map((integration) => ({
23
+ id: integration.id,
24
+ type: integration.config?.type ?? null,
25
+ status: integration.status ?? null,
26
+ userId: integration.userId ?? null,
27
+ version: integration.version ?? null,
28
+ errorCount: Array.isArray(integration.errors)
29
+ ? integration.errors.length
30
+ : 0,
31
+ moduleCount: integration.entities?.length ?? 0,
32
+ createdAt: integration.createdAt ?? null,
33
+ updatedAt: integration.updatedAt ?? null,
34
+ }));
35
+ }
36
+
37
+ async countMappingsByIntegrationIds(ids = []) {
38
+ const counts = new Map();
39
+ if (!ids || ids.length === 0) return counts;
40
+
41
+ const groups = await this.prisma.integrationMapping.groupBy({
42
+ by: ['integrationId'],
43
+ where: { integrationId: { in: ids } },
44
+ _count: { _all: true },
45
+ });
46
+
47
+ for (const group of groups) {
48
+ counts.set(group.integrationId, group._count._all);
49
+ }
50
+ return counts;
51
+ }
52
+ }
53
+
54
+ module.exports = { ReportingRepositoryMongo };
@@ -0,0 +1,70 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const {
3
+ ReportingRepositoryInterface,
4
+ } = require('./reporting-repository-interface');
5
+
6
+ class ReportingRepositoryPostgres extends ReportingRepositoryInterface {
7
+ constructor() {
8
+ super();
9
+ this.prisma = prisma;
10
+ }
11
+
12
+ async findIntegrationsForReport({ status, userId } = {}) {
13
+ const where = {};
14
+ if (status) where.status = status;
15
+ if (userId !== undefined && userId !== null) {
16
+ where.userId = this._convertId(userId);
17
+ }
18
+
19
+ const integrations = await this.prisma.integration.findMany({
20
+ where,
21
+ include: { entities: { select: { id: true } } },
22
+ });
23
+
24
+ return integrations.map((integration) => ({
25
+ id: integration.id?.toString(),
26
+ type: integration.config?.type ?? null,
27
+ status: integration.status ?? null,
28
+ userId: integration.userId?.toString() ?? null,
29
+ version: integration.version ?? null,
30
+ errorCount: Array.isArray(integration.errors)
31
+ ? integration.errors.length
32
+ : 0,
33
+ moduleCount: integration.entities?.length ?? 0,
34
+ createdAt: integration.createdAt ?? null,
35
+ updatedAt: integration.updatedAt ?? null,
36
+ }));
37
+ }
38
+
39
+ async countMappingsByIntegrationIds(ids = []) {
40
+ const counts = new Map();
41
+ if (!ids || ids.length === 0) return counts;
42
+
43
+ const intIds = ids.map((id) => this._convertId(id));
44
+ const groups = await this.prisma.integrationMapping.groupBy({
45
+ by: ['integrationId'],
46
+ where: { integrationId: { in: intIds } },
47
+ _count: { _all: true },
48
+ });
49
+
50
+ for (const group of groups) {
51
+ counts.set(group.integrationId?.toString(), group._count._all);
52
+ }
53
+ return counts;
54
+ }
55
+
56
+ _convertId(id) {
57
+ if (id === null || id === undefined) return id;
58
+ // Reject anything that isn't an exact integer — parseInt would coerce
59
+ // '12abc'/'12.9' to 12 and return the wrong record.
60
+ const str = String(id).trim();
61
+ if (!/^-?\d+$/.test(str)) {
62
+ throw new TypeError(
63
+ `Invalid ID: ${id} cannot be converted to integer`
64
+ );
65
+ }
66
+ return Number.parseInt(str, 10);
67
+ }
68
+ }
69
+
70
+ module.exports = { ReportingRepositoryPostgres };
@@ -0,0 +1,6 @@
1
+ const {
2
+ ListIntegrationsReport,
3
+ SCHEMA_VERSION,
4
+ } = require('./list-integrations-report');
5
+
6
+ module.exports = { ListIntegrationsReport, SCHEMA_VERSION };
@@ -0,0 +1,137 @@
1
+ const Boom = require('@hapi/boom');
2
+
3
+ const SCHEMA_VERSION = 1;
4
+ const SERVICE = 'frigg-core-api';
5
+
6
+ // Seeded so every known status appears (even at 0); unknown values added to
7
+ // the schema later are still counted dynamically.
8
+ const KNOWN_STATUSES = [
9
+ 'ENABLED',
10
+ 'ERROR',
11
+ 'NEEDS_CONFIG',
12
+ 'PROCESSING',
13
+ 'DISABLED',
14
+ ];
15
+
16
+ class ListIntegrationsReport {
17
+ constructor({ reportingRepository } = {}) {
18
+ if (!reportingRepository) {
19
+ throw new Error('reportingRepository is required');
20
+ }
21
+ this.reportingRepository = reportingRepository;
22
+ }
23
+
24
+ async execute(query = {}) {
25
+ const { status, type, userId } = this._validateQuery(query);
26
+
27
+ const rows = await this.reportingRepository.findIntegrationsForReport({
28
+ status,
29
+ userId,
30
+ });
31
+
32
+ // type lives in config.type (a JSON path not portably groupable across
33
+ // DBs), so it is filtered here rather than in the repository query.
34
+ const filtered =
35
+ type === undefined
36
+ ? rows
37
+ : rows.filter((row) => (row.type ?? 'unknown') === type);
38
+
39
+ const ids = filtered.map((row) => row.id);
40
+ const mappingCounts = ids.length
41
+ ? await this.reportingRepository.countMappingsByIntegrationIds(ids)
42
+ : new Map();
43
+
44
+ const integrations = filtered.map((row) => ({
45
+ id: row.id,
46
+ type: row.type ?? 'unknown',
47
+ status: row.status ?? null,
48
+ userId: row.userId ?? null,
49
+ version: row.version ?? null,
50
+ moduleCount: row.moduleCount ?? 0,
51
+ errorCount: row.errorCount ?? 0,
52
+ mappedRecordCount: mappingCounts.get(row.id) ?? 0,
53
+ createdAt: toIso(row.createdAt),
54
+ updatedAt: toIso(row.updatedAt),
55
+ }));
56
+
57
+ const byStatus = emptyStatusCounts();
58
+ const byTypeMap = new Map();
59
+ for (const integration of integrations) {
60
+ // sentinel so a missing status never becomes a literal "null" key
61
+ const statusKey = integration.status ?? 'UNKNOWN';
62
+ byStatus[statusKey] = (byStatus[statusKey] ?? 0) + 1;
63
+
64
+ if (!byTypeMap.has(integration.type)) {
65
+ byTypeMap.set(integration.type, {
66
+ type: integration.type,
67
+ total: 0,
68
+ byStatus: emptyStatusCounts(),
69
+ });
70
+ }
71
+ const bucket = byTypeMap.get(integration.type);
72
+ bucket.total += 1;
73
+ bucket.byStatus[statusKey] = (bucket.byStatus[statusKey] ?? 0) + 1;
74
+ }
75
+
76
+ return {
77
+ schemaVersion: SCHEMA_VERSION,
78
+ service: SERVICE,
79
+ generatedAt: new Date().toISOString(),
80
+ filters: {
81
+ status: status ?? null,
82
+ type: type ?? null,
83
+ userId: userId ?? null,
84
+ },
85
+ metrics: {
86
+ total: integrations.length,
87
+ byStatus,
88
+ byType: Array.from(byTypeMap.values()),
89
+ integrations,
90
+ },
91
+ };
92
+ }
93
+
94
+ _validateQuery({ status, type, userId } = {}) {
95
+ for (const [key, value] of Object.entries({ status, type, userId })) {
96
+ if (value !== undefined && value !== null && typeof value !== 'string') {
97
+ throw Boom.badRequest(
98
+ `Invalid query parameter '${key}': expected a string`
99
+ );
100
+ }
101
+ }
102
+ const normalize = (value) => value || undefined;
103
+ const normalized = {
104
+ status: normalize(status),
105
+ type: normalize(type),
106
+ userId: normalize(userId),
107
+ };
108
+ if (normalized.status && !KNOWN_STATUSES.includes(normalized.status)) {
109
+ throw Boom.badRequest(
110
+ `Invalid status '${normalized.status}'. Expected one of: ${KNOWN_STATUSES.join(
111
+ ', '
112
+ )}`
113
+ );
114
+ }
115
+ return normalized;
116
+ }
117
+ }
118
+
119
+ function emptyStatusCounts() {
120
+ return KNOWN_STATUSES.reduce((acc, status) => {
121
+ acc[status] = 0;
122
+ return acc;
123
+ }, {});
124
+ }
125
+
126
+ function toIso(value) {
127
+ if (!value) return null;
128
+ if (value instanceof Date) return value.toISOString();
129
+ // DocumentDB raw reads surface dates as { $date: ... }
130
+ if (typeof value === 'object' && value.$date) {
131
+ const date = new Date(value.$date);
132
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
133
+ }
134
+ return String(value);
135
+ }
136
+
137
+ module.exports = { ListIntegrationsReport, SCHEMA_VERSION };