@friggframework/core 2.0.0--canary.590.c1af198.0 → 2.0.0--canary.596.6355e72.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.
@@ -31,7 +31,8 @@ const ERROR_CODE_MAP = {
31
31
  ENTITY_NOT_FOUND: 401,
32
32
  ENTITY_USER_NOT_FOUND: 401,
33
33
  INTEGRATION_NOT_FOUND: 404,
34
- EXTERNAL_ENTITY_ID_REQUIRED: 400,
34
+ EXTERNAL_ID_REQUIRED: 400,
35
+ TYPE_REQUIRED: 400,
35
36
  INTEGRATION_RECORD_NOT_FOUND: 404,
36
37
  };
37
38
 
@@ -101,14 +102,20 @@ function createIntegrationCommands({ integrationClass }) {
101
102
  });
102
103
 
103
104
  return {
104
- async findIntegrationContextByExternalEntityId(externalEntityId) {
105
+ /**
106
+ * Find integration context by external entity ID and type
107
+ * @param {Object} params
108
+ * @param {string} params.externalId - External ID of the entity
109
+ * @param {string} params.type - Integration type (config.type)
110
+ * @returns {Promise<Object>} Integration context, entity, and record
111
+ */
112
+ async findIntegrationContextByExternalEntityId({ externalId, type }) {
105
113
  try {
106
- const { context } = await findByExternalEntityIdUseCase.execute(
107
- {
108
- externalEntityId,
109
- }
110
- );
111
- return { context };
114
+ const result = await findByExternalEntityIdUseCase.execute({
115
+ externalId,
116
+ type,
117
+ });
118
+ return result;
112
119
  } catch (error) {
113
120
  return mapErrorToResponse(error);
114
121
  }
@@ -250,11 +257,12 @@ function createIntegrationCommands({ integrationClass }) {
250
257
 
251
258
  async function findIntegrationContextByExternalEntityId({
252
259
  integrationClass,
253
- externalEntityId,
260
+ externalId,
261
+ type,
254
262
  } = {}) {
255
263
  const commands = createIntegrationCommands({ integrationClass });
256
264
 
257
- return commands.findIntegrationContextByExternalEntityId(externalEntityId);
265
+ return commands.findIntegrationContextByExternalEntityId({ externalId, type });
258
266
  }
259
267
 
260
268
  module.exports = {
@@ -49,6 +49,7 @@ const createHandler = (optionByName = {}) => {
49
49
  eventName = 'Event',
50
50
  isUserFacingResponse = true,
51
51
  method,
52
+ shouldUseDatabase = true,
52
53
  } = optionByName;
53
54
 
54
55
  if (!method) {
@@ -79,6 +80,15 @@ const createHandler = (optionByName = {}) => {
79
80
  // If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
80
81
  await secretsToEnv();
81
82
 
83
+ // Open the database connection up front when the handler needs it.
84
+ // Lazy-required so DB-free handlers (e.g. extension webhook
85
+ // receivers with useDatabase:false) never load the Prisma client.
86
+ // $connect is idempotent, so this safely reuses a warm connection.
87
+ if (shouldUseDatabase) {
88
+ const { connectPrisma } = require('../database/prisma');
89
+ await connectPrisma();
90
+ }
91
+
82
92
  // Helps reuse the database connection. Lowers response times.
83
93
  context.callbackWaitsForEmptyEventLoop = false;
84
94
 
@@ -12,6 +12,9 @@ const { integrations: integrationClasses } = loadAppDefinition();
12
12
 
13
13
  const routeKey = (method, path) => `${(method || 'ANY').toUpperCase()} ${path}`;
14
14
 
15
+ // Serverless function keys must be alphanumeric; binding keys are developer-chosen.
16
+ const sanitizeBindingKey = (name) => String(name).replace(/[^A-Za-z0-9]/g, '');
17
+
15
18
  //todo: this should be in a use case class
16
19
  for (const IntegrationClass of integrationClasses) {
17
20
  const router = Router();
@@ -53,20 +56,32 @@ for (const IntegrationClass of integrationClasses) {
53
56
  }
54
57
  }
55
58
 
56
- // Tier 3 Integration Extension routes — see EXTENSIONS.md
59
+ // Tier 3 Integration Extension routes — each binding gets a dedicated
60
+ // handler, namespaced under /{bindingName}, so multiple modules' extensions
61
+ // can declare the same relative path (e.g. two /webhooks) without colliding.
62
+ // Each per-binding handler carries its own shouldUseDatabase (resolved from
63
+ // the extension/binding `useDatabase`, default false → DB-free receiver).
64
+ const bindingGroups = new Map();
57
65
  for (const extRoute of getExtensionRoutes(IntegrationClass)) {
66
+ const namespacedPath = `/${extRoute.bindingName}${extRoute.path}`;
58
67
  claim(
59
68
  extRoute.method,
60
- extRoute.path,
69
+ namespacedPath,
61
70
  `extension "${extRoute.extensionName}" (binding "${extRoute.bindingName}")`
62
71
  );
63
- router.use(
64
- basePath,
72
+ if (!bindingGroups.has(extRoute.bindingName)) {
73
+ bindingGroups.set(extRoute.bindingName, {
74
+ router: Router(),
75
+ useDatabase: extRoute.useDatabase,
76
+ });
77
+ }
78
+ const group = bindingGroups.get(extRoute.bindingName);
79
+ group.router.use(
80
+ `${basePath}/${extRoute.bindingName}`,
65
81
  loadRouterFromObject(IntegrationClass, extRoute)
66
82
  );
67
- const method = extRoute.method.toUpperCase();
68
83
  console.log(
69
- `│ ${method} ${basePath}${extRoute.path} (extension: ${extRoute.extensionName})`
84
+ `│ ${extRoute.method.toUpperCase()} ${basePath}/${extRoute.bindingName}${extRoute.path} (extension: ${extRoute.extensionName}, useDatabase: ${extRoute.useDatabase})`
70
85
  );
71
86
  }
72
87
  console.log('│');
@@ -77,6 +92,19 @@ for (const IntegrationClass of integrationClasses) {
77
92
  router
78
93
  ),
79
94
  };
95
+
96
+ for (const [bindingName, group] of bindingGroups) {
97
+ const fnKey = `${IntegrationClass.Definition.name}__${sanitizeBindingKey(
98
+ bindingName
99
+ )}`;
100
+ handlers[fnKey] = {
101
+ handler: createAppHandler(
102
+ `HTTP Event: ${IntegrationClass.Definition.name} extension ${bindingName}`,
103
+ group.router,
104
+ group.useDatabase
105
+ ),
106
+ };
107
+ }
80
108
  }
81
109
 
82
110
  module.exports = { handlers };
@@ -24,6 +24,8 @@ class HubSpotIntegration extends IntegrationBase {
24
24
  hubspotWebhooks: {
25
25
  extension: hubspot.extensions.webhooks,
26
26
  handlers: { HUBSPOT_WEBHOOK: 'onHubSpotEvent' },
27
+ // optional: override the extension's declared useDatabase
28
+ // useDatabase: true,
27
29
  },
28
30
  },
29
31
  };
@@ -39,19 +41,39 @@ class HubSpotIntegration extends IntegrationBase {
39
41
  }
40
42
  ```
41
43
 
42
- The binding key (`hubspotWebhooks`) is your local name. The extension reference (`hubspot.extensions.webhooks`) is whatever the API module exports.
44
+ The binding key (`hubspotWebhooks`) is your local name. It is also the **URL namespace** for the extension's routes (see below), so pick something readable — `hubspot` yields a cleaner URL than `hubspotWebhooks`. The extension reference (`hubspot.extensions.webhooks`) is whatever the API module exports.
43
45
 
44
46
  ## Step 2: Deploy
45
47
 
46
- The framework auto-mounts each extension's routes at the integration's base path. Boot logs show:
48
+ Each extension binding is mounted under its **binding key**, on its own dedicated handler/Lambda function. This means two modules' extensions (e.g. a HubSpot and a Clockwork webhooks extension on the same integration) never collide — each lives at a distinct namespaced path. Boot logs show:
47
49
 
48
50
  ```
49
51
  │ Configuring routes for hubspot Integration:
50
- │ POST /api/hubspot-integration/webhooks (extension: hubspot-webhooks)
52
+ │ POST /api/hubspot-integration/hubspotWebhooks/webhooks (extension: hubspot-webhooks, useDatabase: false)
51
53
 
52
54
  ```
53
55
 
54
- Hit that URL and the bound method (`onHubSpotEvent`) fires on the resolved per-account integration instance.
56
+ So the full URL is `/api/{integration-name}-integration/{bindingKey}{route.path}`. Register that URL with the upstream provider (e.g. paste it into your HubSpot app's webhook settings). Hit it and the bound method (`onHubSpotEvent`) fires on the resolved per-account integration instance.
57
+
58
+ ## `useDatabase` — does the receiver open a DB connection?
59
+
60
+ Each extension declares whether its route handler should open a database connection:
61
+
62
+ ```javascript
63
+ // in the extension bundle (api-module side)
64
+ module.exports = {
65
+ name: 'hubspot-webhooks',
66
+ useDatabase: false, // default — the receiver is DB-free
67
+ routes: [ /* ... */ ],
68
+ events: { /* ... */ },
69
+ };
70
+ ```
71
+
72
+ - **Default is `false`** — a webhook receiver that only verifies a signature and enqueues should not pay for a DB connection (faster cold start; at build time its Lambda doesn't get the Prisma layer).
73
+ - Set `useDatabase: true` at the **extension level** if the receiver itself needs the DB. A binding may override it locally (`extensions: { x: { extension, useDatabase: true } }`), though that's rarely needed.
74
+ - Resolution order: `binding.useDatabase ?? extension.useDatabase ?? false`.
75
+
76
+ If `useDatabase` is `false`, the receiver must not touch the database. Work that needs the DB (e.g. resolving `portalId → integrationId`) belongs in the queue worker that processes the dispatched event, not in the receiver.
55
77
 
56
78
  ## How handler binding works
57
79
 
@@ -84,7 +106,7 @@ This means **binding the same extension twice only works if the extension itself
84
106
 
85
107
  Subclass overrides via `this.events[eventName]` (set in the constructor) take precedence over extension-declared events. If a binding tried to wire a handler that's now shadowed, the framework logs a warning naming the integration, binding, and ignored method.
86
108
 
87
- Route path conflicts (two extensions declaring the same `method + path`, or an extension colliding with a `Definition.routes` entry) also throw at boot.
109
+ **Routes do not collide across bindings** — each binding's routes are namespaced under its binding key (`/{bindingKey}{route.path}`), so two extensions can both declare `POST /webhooks` and live at distinct URLs. A route conflict only throws at boot if a *single* binding declares two routes with the same `method + path` (or a `Definition.routes` entry exactly matches an extension's namespaced path). Note this is independent of event-name conflicts above: namespacing disambiguates URLs, but two bindings still must use distinct **event** names since events are merged into one `this.events` map.
88
110
 
89
111
  ## Authoring an extension (for API module authors)
90
112
 
@@ -70,6 +70,24 @@ function validateExtensionBinding(extension, bindingName, integrationName, bindi
70
70
  throw new Error(`${ctx}: extension is missing required "name" field`);
71
71
  }
72
72
 
73
+ if (
74
+ extension.useDatabase !== undefined &&
75
+ typeof extension.useDatabase !== 'boolean'
76
+ ) {
77
+ throw new Error(
78
+ `${ctx}: extension "${extension.name}" "useDatabase" must be a boolean`
79
+ );
80
+ }
81
+ if (
82
+ binding &&
83
+ binding.useDatabase !== undefined &&
84
+ typeof binding.useDatabase !== 'boolean'
85
+ ) {
86
+ throw new Error(
87
+ `${ctx}: binding "useDatabase" must be a boolean`
88
+ );
89
+ }
90
+
73
91
  const events = extension.events || {};
74
92
  if (typeof events !== 'object' || Array.isArray(events)) {
75
93
  throw new Error(
@@ -181,6 +199,10 @@ function getExtensionRoutes(IntegrationClass) {
181
199
  integrationName,
182
200
  binding
183
201
  );
202
+ // useDatabase is resolved at the binding level, falling back to the
203
+ // extension's declared value, then to false (DB-free by default).
204
+ const useDatabase =
205
+ binding.useDatabase ?? binding.extension.useDatabase ?? false;
184
206
  const routes = binding.extension.routes || [];
185
207
  for (const route of routes) {
186
208
  flat.push({
@@ -189,6 +211,7 @@ function getExtensionRoutes(IntegrationClass) {
189
211
  path: route.path,
190
212
  method: route.method,
191
213
  event: route.event,
214
+ useDatabase,
192
215
  });
193
216
  }
194
217
  }
@@ -19,39 +19,43 @@ class FindIntegrationContextByExternalEntityIdUseCase {
19
19
  this.loadIntegrationContextUseCase = loadIntegrationContextUseCase;
20
20
  }
21
21
 
22
- async execute({ externalEntityId }) {
23
- if (!externalEntityId) {
24
- const error = new Error('externalEntityId is required');
25
- error.code = 'EXTERNAL_ENTITY_ID_REQUIRED';
22
+ async execute({ externalId, type }) {
23
+ if (!externalId) {
24
+ const error = new Error('externalId is required');
25
+ error.code = 'EXTERNAL_ID_REQUIRED';
26
+ throw error;
27
+ }
28
+
29
+ if (!type) {
30
+ const error = new Error('type is required');
31
+ error.code = 'TYPE_REQUIRED';
26
32
  throw error;
27
33
  }
28
34
 
29
35
  const entity = await this.moduleRepository.findEntity({
30
- externalId: externalEntityId,
36
+ externalId,
31
37
  });
32
38
 
33
39
  if (!entity) {
34
40
  const error = new Error(
35
- `Entity not found for externalId: ${externalEntityId}`
41
+ `Entity not found for externalId: ${externalId}`
36
42
  );
37
43
  error.code = 'ENTITY_NOT_FOUND';
38
44
  throw error;
39
45
  }
40
46
 
41
- if (!entity.userId) {
42
- const error = new Error('Entity does not have an associated user');
43
- error.code = 'ENTITY_USER_NOT_FOUND';
44
- throw error;
45
- }
46
-
47
- const integrationRecord =
48
- await this.integrationRepository.findIntegrationByUserId(
49
- entity.userId
47
+ const integrations =
48
+ await this.integrationRepository.findIntegrationsByEntityId(
49
+ entity.id
50
50
  );
51
51
 
52
+ const integrationRecord = integrations?.find(
53
+ (i) => i.config?.type === type
54
+ );
55
+
52
56
  if (!integrationRecord) {
53
57
  const error = new Error(
54
- `Integration not found for user: ${entity.userId}`
58
+ `Integration of type '${type}' not found for entity: ${entity.id}`
55
59
  );
56
60
  error.code = 'INTEGRATION_NOT_FOUND';
57
61
  throw error;
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.590.c1af198.0",
4
+ "version": "2.0.0--canary.596.6355e72.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.590.c1af198.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.590.c1af198.0",
43
- "@friggframework/test": "2.0.0--canary.590.c1af198.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.596.6355e72.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.596.6355e72.0",
43
+ "@friggframework/test": "2.0.0--canary.596.6355e72.0",
44
44
  "@prisma/client": "^6.17.0",
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": "c1af198e54a9e8986a69a91653b6134b9f26251c"
83
+ "gitHead": "6355e7230ef1f29f10e5336c88d4ddd257dffe18"
84
84
  }