@friggframework/core 2.0.0--canary.395.a6dcc5a.0 → 2.0.0--canary.395.de6bcdf.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,140 @@
1
+ const { IntegrationEventDispatcher } = require('./integration-event-dispatcher');
2
+ const { IntegrationBase } = require('../integrations/integration-base');
3
+
4
+ class SimulatedAsanaIntegration extends IntegrationBase {
5
+ static Definition = {
6
+ name: 'asana',
7
+ version: '1.0.0',
8
+ modules: {},
9
+ routes: [
10
+ { path: '/auth', method: 'GET', event: 'AUTH_REQUEST' },
11
+ { path: '/auth/redirect/:provider', method: 'GET', event: 'AUTH_REDIRECT' },
12
+ { path: '/form', method: 'GET', event: 'LOAD_FORM' },
13
+ ],
14
+ };
15
+
16
+ constructor(params = {}) {
17
+ super(params);
18
+ this.events = {
19
+ AUTH_REQUEST: { handler: this.authRequest.bind(this) },
20
+ AUTH_REDIRECT: { handler: this.authRedirect.bind(this) },
21
+ LOAD_FORM: { handler: this.loadForm.bind(this) },
22
+ };
23
+ }
24
+
25
+ async authRequest() {
26
+ return {
27
+ success: true,
28
+ action: 'redirect',
29
+ hydrated: this.isHydrated,
30
+ };
31
+ }
32
+
33
+ async authRedirect({ req }) {
34
+ const { code } = req.query || {};
35
+ return {
36
+ success: true,
37
+ action: 'tokens_received',
38
+ receivedCode: code,
39
+ hydrated: this.isHydrated,
40
+ };
41
+ }
42
+
43
+ async loadForm() {
44
+ if (!this.isHydrated && SimulatedAsanaIntegration.testRecord) {
45
+ this.setIntegrationRecord({
46
+ record: SimulatedAsanaIntegration.testRecord.record,
47
+ modules: SimulatedAsanaIntegration.testRecord.modules,
48
+ });
49
+ }
50
+
51
+ this.assertHydrated('Integration not found - must authenticate first');
52
+
53
+ return {
54
+ success: true,
55
+ form: {
56
+ fields: ['field1', 'field2'],
57
+ },
58
+ integrationId: this.id,
59
+ };
60
+ }
61
+ }
62
+
63
+ describe('IntegrationEventDispatcher auth flow', () => {
64
+ const createDispatcher = () =>
65
+ new IntegrationEventDispatcher(new SimulatedAsanaIntegration());
66
+
67
+ beforeEach(() => {
68
+ SimulatedAsanaIntegration.testRecord = null;
69
+ });
70
+
71
+ it('handles auth request without hydration', async () => {
72
+ const dispatcher = createDispatcher();
73
+ const result = await dispatcher.dispatchHttp({
74
+ event: 'AUTH_REQUEST',
75
+ req: { params: { provider: 'asana' }, query: {} },
76
+ res: {},
77
+ next: jest.fn(),
78
+ });
79
+
80
+ expect(result).toEqual({ success: true, action: 'redirect', hydrated: false });
81
+ });
82
+
83
+ it('handles auth redirect without hydration', async () => {
84
+ const dispatcher = createDispatcher();
85
+ const result = await dispatcher.dispatchHttp({
86
+ event: 'AUTH_REDIRECT',
87
+ req: { params: { provider: 'asana' }, query: { code: 'abc123' } },
88
+ res: {},
89
+ next: jest.fn(),
90
+ });
91
+
92
+ expect(result).toEqual({
93
+ success: true,
94
+ action: 'tokens_received',
95
+ receivedCode: 'abc123',
96
+ hydrated: false,
97
+ });
98
+ });
99
+
100
+ it('throws for protected routes when no record is loaded', async () => {
101
+ const dispatcher = createDispatcher();
102
+ await expect(
103
+ dispatcher.dispatchHttp({
104
+ event: 'LOAD_FORM',
105
+ req: { query: {} },
106
+ res: {},
107
+ next: jest.fn(),
108
+ })
109
+ ).rejects.toThrow('Integration not found - must authenticate first');
110
+ });
111
+
112
+ it('allows handlers to hydrate explicitly before continuing', async () => {
113
+ SimulatedAsanaIntegration.testRecord = {
114
+ record: {
115
+ id: 'integration-123',
116
+ userId: 'user-456',
117
+ config: { type: 'asana' },
118
+ status: 'ENABLED',
119
+ version: '1.0.0',
120
+ messages: { errors: [], warnings: [] },
121
+ entities: [],
122
+ },
123
+ modules: [],
124
+ };
125
+
126
+ const dispatcher = createDispatcher();
127
+ const result = await dispatcher.dispatchHttp({
128
+ event: 'LOAD_FORM',
129
+ req: { query: {} },
130
+ res: {},
131
+ next: jest.fn(),
132
+ });
133
+
134
+ expect(result).toEqual({
135
+ success: true,
136
+ form: { fields: ['field1', 'field2'] },
137
+ integrationId: 'integration-123',
138
+ });
139
+ });
140
+ });
@@ -1,19 +1,10 @@
1
1
  const { Router } = require('express');
2
2
  const { Worker } = require('@friggframework/core');
3
- const { IntegrationRepository } = require('../integrations/integration-repository');
4
- const { ModuleFactory } = require('../modules/module-factory');
5
- const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
6
- const { ModuleRepository } = require('../modules/module-repository');
7
- const { GetIntegrationInstanceByDefinition } = require('../integrations/use-cases/get-integration-instance-by-definition');
3
+ const {
4
+ IntegrationEventDispatcher,
5
+ } = require('./integration-event-dispatcher');
8
6
 
9
7
  const loadRouterFromObject = (IntegrationClass, routerObject) => {
10
-
11
- const integrationRepository = new IntegrationRepository();
12
- const moduleRepository = new ModuleRepository();
13
- const moduleFactory = new ModuleFactory({
14
- moduleRepository,
15
- moduleDefinitions: getModulesDefinitionFromIntegrationClasses([IntegrationClass]),
16
- });
17
8
  const router = Router();
18
9
  const { path, method, event } = routerObject;
19
10
 
@@ -23,13 +14,16 @@ const loadRouterFromObject = (IntegrationClass, routerObject) => {
23
14
 
24
15
  router[method.toLowerCase()](path, async (req, res, next) => {
25
16
  try {
26
- const getIntegrationInstanceByDefinition = new GetIntegrationInstanceByDefinition({
27
- integrationRepository,
28
- moduleFactory,
29
- moduleRepository,
17
+ const integrationInstance = new IntegrationClass();
18
+ const dispatcher = new IntegrationEventDispatcher(
19
+ integrationInstance
20
+ );
21
+ const result = await dispatcher.dispatchHttp({
22
+ event,
23
+ req,
24
+ res,
25
+ next,
30
26
  });
31
- const integration = await getIntegrationInstanceByDefinition.execute(IntegrationClass);
32
- const result = await integration.send(event, { req, res, next });
33
27
  res.json(result);
34
28
  } catch (error) {
35
29
  next(error);
@@ -39,30 +33,18 @@ const loadRouterFromObject = (IntegrationClass, routerObject) => {
39
33
  return router;
40
34
  };
41
35
 
42
- //todo: this should be in a use case class
43
36
  const createQueueWorker = (integrationClass) => {
44
37
  class QueueWorker extends Worker {
45
-
46
- integrationRepository = new IntegrationRepository();
47
- moduleRepository = new ModuleRepository();
48
- moduleFactory = new ModuleFactory({
49
- moduleRepository: this.moduleRepository,
50
- moduleDefinitions: getModulesDefinitionFromIntegrationClasses([integrationClass]),
51
- });
52
-
53
38
  async _run(params, context) {
54
39
  try {
55
- const getIntegrationInstanceByDefinition = new GetIntegrationInstanceByDefinition({
56
- integrationRepository: this.integrationRepository,
57
- moduleFactory: this.moduleFactory,
58
- moduleRepository: this.moduleRepository,
59
- });
60
-
61
- const integration = await getIntegrationInstanceByDefinition.execute(integrationClass);
62
-
63
- const res = await integration.send(params.event, {
40
+ const integrationInstance = new integrationClass();
41
+ const dispatcher = new IntegrationEventDispatcher(
42
+ integrationInstance
43
+ );
44
+ const res = await dispatcher.dispatchJob({
45
+ event: params.event,
64
46
  data: params.data,
65
- context,
47
+ context: context,
66
48
  });
67
49
  return res;
68
50
  } catch (error) {
@@ -0,0 +1,55 @@
1
+
2
+ /**
3
+ * Lightweight dispatcher that executes integration event handlers.
4
+ * Signature: `new IntegrationEventDispatcher(integrationInstance)`
5
+ * @param {import('../integrations/integration-base')} integrationInstance Pre-instantiated integration.
6
+ */
7
+ class IntegrationEventDispatcher {
8
+ constructor(integrationInstance) {
9
+ if (!integrationInstance) {
10
+ throw new Error('Integration instance is required');
11
+ }
12
+ this.integrationInstance = integrationInstance;
13
+ }
14
+
15
+ async dispatchHttp({ event, req, res, next }) {
16
+ const instance = this.integrationInstance;
17
+
18
+ const handler = this.findEventHandler(instance, event);
19
+
20
+ if (!handler) {
21
+ const name = instance.constructor?.Definition?.name || 'integration';
22
+ throw new Error(`Event ${event} not registered for ${name}`);
23
+ }
24
+
25
+ return await handler.call(instance, { req, res, next });
26
+ }
27
+
28
+ async dispatchJob({ event, data, context }) {
29
+ const instance = this.integrationInstance;
30
+
31
+ const handler = this.findEventHandler(instance, event);
32
+
33
+ if (!handler) {
34
+ const name = instance.constructor?.Definition?.name || 'integration';
35
+ throw new Error(`Event ${event} not registered for ${name}`);
36
+ }
37
+
38
+ return await handler.call(instance, { data, context });
39
+ }
40
+
41
+ findEventHandler(integration, event) {
42
+ if (integration.events && integration.events[event]) {
43
+ return integration.events[event].handler;
44
+ }
45
+
46
+ if (integration.defaultEvents && integration.defaultEvents[event]) {
47
+ return integration.defaultEvents[event].handler;
48
+ }
49
+
50
+ return null;
51
+ }
52
+
53
+ }
54
+
55
+ module.exports = { IntegrationEventDispatcher };
@@ -0,0 +1,134 @@
1
+ const { IntegrationEventDispatcher } = require('./integration-event-dispatcher');
2
+ const { IntegrationBase } = require('../integrations/integration-base');
3
+
4
+ class TestIntegration extends IntegrationBase {
5
+ static Definition = {
6
+ name: 'test-integration',
7
+ version: '1.0.0',
8
+ modules: {},
9
+ routes: [
10
+ { path: '/auth', method: 'GET', event: 'AUTH_REQUEST' },
11
+ { path: '/data', method: 'GET', event: 'LOAD_DATA' },
12
+ { path: '/job', method: 'POST', event: 'TEST_EVENT' },
13
+ { path: '/dynamic', method: 'GET', event: 'DYNAMIC_EVENT' },
14
+ ],
15
+ };
16
+
17
+ constructor(params) {
18
+ super(params);
19
+ this.events = {
20
+ AUTH_REQUEST: { handler: this.authRequest.bind(this) },
21
+ LOAD_DATA: { handler: this.loadData.bind(this) },
22
+ TEST_EVENT: { handler: this.testHandler.bind(this) },
23
+ };
24
+ }
25
+
26
+ async authRequest() {
27
+ TestIntegration.latestInstance = this;
28
+ return {
29
+ success: true,
30
+ hydrated: this.isHydrated,
31
+ };
32
+ }
33
+
34
+ async loadData() {
35
+ this.assertHydrated('loadData requires hydration');
36
+ return { success: true };
37
+ }
38
+
39
+ async testHandler({ data }) {
40
+ TestIntegration.latestInstance = this;
41
+ return { received: data };
42
+ }
43
+
44
+ async initialize() {
45
+ this.events = {
46
+ ...this.events,
47
+ DYNAMIC_EVENT: { handler: this.dynamicHandler.bind(this) },
48
+ };
49
+ }
50
+
51
+ async dynamicHandler() {
52
+ TestIntegration.latestInstance = this;
53
+ return { dynamic: true };
54
+ }
55
+ }
56
+
57
+ describe('IntegrationEventDispatcher', () => {
58
+ const createDispatcher = () =>
59
+ new IntegrationEventDispatcher(new TestIntegration());
60
+
61
+ beforeEach(() => {
62
+ TestIntegration.latestInstance = null;
63
+ });
64
+
65
+ describe('dispatchHttp', () => {
66
+ it('creates a stateless integration instance for HTTP events', async () => {
67
+ const dispatcher = createDispatcher();
68
+ const result = await dispatcher.dispatchHttp({
69
+ event: 'AUTH_REQUEST',
70
+ req: {},
71
+ res: {},
72
+ next: jest.fn(),
73
+ });
74
+
75
+ expect(result).toEqual({ success: true, hydrated: false });
76
+ expect(TestIntegration.latestInstance).toBeInstanceOf(TestIntegration);
77
+ expect(TestIntegration.latestInstance.isHydrated).toBe(false);
78
+ });
79
+
80
+ it('calls initialize to register dynamic events', async () => {
81
+ const dispatcher = createDispatcher();
82
+ await dispatcher.integrationInstance.initialize();
83
+ const result = await dispatcher.dispatchHttp({
84
+ event: 'DYNAMIC_EVENT',
85
+ req: {},
86
+ res: {},
87
+ next: jest.fn(),
88
+ });
89
+
90
+ expect(result).toEqual({ dynamic: true });
91
+ expect(TestIntegration.latestInstance).toBeInstanceOf(TestIntegration);
92
+ });
93
+
94
+ it('throws when requesting an unknown event', async () => {
95
+ const dispatcher = createDispatcher();
96
+ await expect(
97
+ dispatcher.dispatchHttp({
98
+ event: 'UNKNOWN',
99
+ req: {},
100
+ res: {},
101
+ next: jest.fn(),
102
+ })
103
+ ).rejects.toThrow('Event UNKNOWN not registered for test-integration');
104
+ });
105
+
106
+ it('does not hydrate automatically for handlers that require data', async () => {
107
+ const dispatcher = createDispatcher();
108
+ await expect(
109
+ dispatcher.dispatchHttp({
110
+ event: 'LOAD_DATA',
111
+ req: {},
112
+ res: {},
113
+ next: jest.fn(),
114
+ })
115
+ ).rejects.toThrow('loadData requires hydration');
116
+ });
117
+ });
118
+
119
+ describe('dispatchJob', () => {
120
+ it('creates a stateless integration instance for job events', async () => {
121
+ const payload = { foo: 'bar' };
122
+ const dispatcher = createDispatcher();
123
+ const result = await dispatcher.dispatchJob({
124
+ event: 'TEST_EVENT',
125
+ data: payload,
126
+ context: {},
127
+ });
128
+
129
+ expect(result).toEqual({ received: payload });
130
+ expect(TestIntegration.latestInstance).toBeInstanceOf(TestIntegration);
131
+ expect(TestIntegration.latestInstance.isHydrated).toBe(false);
132
+ });
133
+ });
134
+ });
package/index.js CHANGED
@@ -40,6 +40,9 @@ const {
40
40
  IntegrationMapping,
41
41
  createIntegrationRouter,
42
42
  checkRequiredParams,
43
+ IntegrationRepository,
44
+ getModulesDefinitionFromIntegrationClasses,
45
+ LoadIntegrationContextUseCase,
43
46
  } = require('./integrations/index');
44
47
  const { TimeoutCatcher } = require('./lambda/index');
45
48
  const { debug, initDebugLog, flushDebugLog } = require('./logs/index');
@@ -51,6 +54,8 @@ const {
51
54
  OAuth2Requester,
52
55
  Requester,
53
56
  ModuleConstants,
57
+ ModuleFactory,
58
+ ModuleRepository,
54
59
  } = require('./modules/index');
55
60
  const utils = require('./utils');
56
61
 
@@ -104,6 +109,9 @@ module.exports = {
104
109
  IntegrationMapping,
105
110
  checkRequiredParams,
106
111
  createIntegrationRouter,
112
+ IntegrationRepository,
113
+ getModulesDefinitionFromIntegrationClasses,
114
+ LoadIntegrationContextUseCase,
107
115
 
108
116
  // lambda
109
117
  TimeoutCatcher,
@@ -121,6 +129,8 @@ module.exports = {
121
129
  OAuth2Requester,
122
130
  Requester,
123
131
  ModuleConstants,
132
+ ModuleFactory,
133
+ ModuleRepository,
124
134
  // queues
125
135
  QueuerUtil,
126
136
 
@@ -3,6 +3,9 @@ const { IntegrationModel } = require('./integration-model');
3
3
  const { Options } = require('./options');
4
4
  const { IntegrationMapping } = require('./integration-mapping');
5
5
  const { createIntegrationRouter, checkRequiredParams } = require('./integration-router');
6
+ const { IntegrationRepository } = require('./integration-repository');
7
+ const { getModulesDefinitionFromIntegrationClasses } = require('./utils/map-integration-dto');
8
+ const { LoadIntegrationContextUseCase } = require('./use-cases/load-integration-context');
6
9
 
7
10
  module.exports = {
8
11
  IntegrationBase,
@@ -11,4 +14,7 @@ module.exports = {
11
14
  IntegrationMapping,
12
15
  createIntegrationRouter,
13
16
  checkRequiredParams,
17
+ IntegrationRepository,
18
+ getModulesDefinitionFromIntegrationClasses,
19
+ LoadIntegrationContextUseCase,
14
20
  };
@@ -62,37 +62,17 @@ class IntegrationBase {
62
62
  return this.Definition.version;
63
63
  }
64
64
 
65
- registerEventHandlers() {
66
- this.on = {
67
- ...this.defaultEvents,
68
- ...this.events,
69
- };
70
- }
65
+ // REMOVED: registerEventHandlers() - Event handling is now done by IntegrationEventDispatcher
71
66
 
72
67
  constructor(params = {}) {
73
- // Data from database record (when instantiated by use cases)
74
- this.id = params.id;
75
- this.userId = params.userId || params.integrationId; // fallback for legacy
76
- this.entities = params.entities;
77
- this.config = params.config;
78
- this.status = params.status;
79
- this.version = params.version;
80
- this.messages = params.messages || { errors: [], warnings: [] };
81
-
82
- // Module instances (injected by factory)
83
68
  this.modules = {};
84
- if (params.modules) {
85
- for (const mod of params.modules) {
86
- const key = typeof mod.getName === 'function' ? mod.getName() : mod.name;
87
- if (key) {
88
- this.modules[key] = mod;
89
- this[key] = mod; // Direct access (e.g., this.hubspot)
90
- }
91
- }
92
- }
93
-
94
- // Initialize events object (will be populated by child classes)
95
69
  this.events = this.events || {};
70
+ this.messages = { errors: [], warnings: [] };
71
+ this._isHydrated = false;
72
+
73
+ if (params && Object.keys(params).length > 0) {
74
+ this.setIntegrationRecord(params);
75
+ }
96
76
 
97
77
  this.defaultEvents = {
98
78
  [constantsToBeMigrated.defaultEvents.ON_CREATE]: {
@@ -130,13 +110,93 @@ class IntegrationBase {
130
110
  };
131
111
  }
132
112
 
133
- async send(event, object) {
134
- if (!this.on[event]) {
135
- throw new Error(
136
- `Event ${event} is not defined in the Integration event object`
137
- );
113
+ // REMOVED: send() - Event dispatching is now done by IntegrationEventDispatcher
114
+
115
+ /**
116
+ * Persist the database record and module instances onto this integration instance.
117
+ * Accepts either a plain object containing the persisted fields or an object with
118
+ * a `record` property plus a `modules` collection.
119
+ * @param {Object} payload
120
+ * @param {Object} [payload.record]
121
+ * @param {Array|Object} [payload.modules]
122
+ */
123
+ setIntegrationRecord(payload = {}) {
124
+ if (!payload || Object.keys(payload).length === 0) {
125
+ throw new Error('setIntegrationRecord requires integration data');
126
+ }
127
+
128
+ const record = payload.record ? payload.record : payload;
129
+ const modulesInput = payload.modules ?? record.modules;
130
+
131
+ if (!record) {
132
+ throw new Error('Integration record not provided');
133
+ }
134
+
135
+ const {
136
+ id,
137
+ userId,
138
+ entities,
139
+ config,
140
+ status,
141
+ version,
142
+ messages,
143
+ } = record;
144
+
145
+ this.id = id;
146
+ this.userId = userId || record.integrationId;
147
+ this.entities = entities;
148
+ this.config = config;
149
+ this.status = status;
150
+ this.version = version;
151
+ this.messages = messages || { errors: [], warnings: [] };
152
+
153
+ const existingModuleKeys = Object.keys(this.modules || {});
154
+ for (const key of existingModuleKeys) {
155
+ if (Object.prototype.hasOwnProperty.call(this, key) && this[key] === this.modules[key]) {
156
+ delete this[key];
157
+ }
158
+ }
159
+
160
+ this.modules = {};
161
+
162
+ if (modulesInput) {
163
+ const modulesArray = Array.isArray(modulesInput)
164
+ ? modulesInput
165
+ : Object.values(modulesInput);
166
+
167
+ for (const mod of modulesArray) {
168
+ if (!mod) continue;
169
+ const key = typeof mod.getName === 'function' ? mod.getName() : mod.name;
170
+ if (key) {
171
+ this.modules[key] = mod;
172
+ this[key] = mod;
173
+ }
174
+ }
175
+ }
176
+
177
+ this.integrationRecord = {
178
+ id: this.id,
179
+ userId: this.userId,
180
+ entities: this.entities,
181
+ config: this.config,
182
+ status: this.status,
183
+ version: this.version,
184
+ messages: this.messages,
185
+ };
186
+ this.record = this.integrationRecord;
187
+
188
+ this._isHydrated = Boolean(this.id);
189
+ return this;
190
+ }
191
+
192
+ get isHydrated() {
193
+ return this._isHydrated;
194
+ }
195
+
196
+ assertHydrated(message = 'Integration instance is not hydrated') {
197
+ if (!this.isHydrated) {
198
+ throw new Error(message);
138
199
  }
139
- return this.on[event].handler.call(this, object);
140
200
  }
141
201
 
142
202
  async validateConfig() {
@@ -336,8 +396,7 @@ class IntegrationBase {
336
396
  this.addError(e);
337
397
  }
338
398
 
339
- // Register event handlers
340
- await this.registerEventHandlers();
399
+ // Event handlers are no longer registered here - handled by IntegrationEventDispatcher
341
400
  }
342
401
 
343
402
  getOptionDetails() {