@friggframework/core 2.0.0--canary.5ff5209.0 → 2.0.0--canary.590.094a71b.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.
package/core/Worker.js CHANGED
@@ -20,15 +20,45 @@ class Worker {
20
20
  const records = get(params, 'Records');
21
21
  const batchItemFailures = [];
22
22
 
23
+ console.log(
24
+ `[Worker] run: processing ${records.length} record(s)`
25
+ );
26
+
23
27
  for (const record of records) {
28
+ // Log record entry with SQS-provided attributes useful for tracing
29
+ // delivery history (ApproximateReceiveCount for retries, etc.).
30
+ let parsedEvent;
31
+ try {
32
+ parsedEvent = JSON.parse(record.body)?.event;
33
+ } catch {
34
+ parsedEvent = undefined;
35
+ }
36
+ console.log(`[Worker] record begin`, {
37
+ messageId: record.messageId,
38
+ event: parsedEvent,
39
+ receiveCount: record.attributes?.ApproximateReceiveCount,
40
+ });
41
+
24
42
  try {
25
43
  const runParams = JSON.parse(record.body);
26
44
  this._validateParams(runParams);
27
45
  await this._run(runParams, context);
46
+ console.log(`[Worker] record success`, {
47
+ messageId: record.messageId,
48
+ event: runParams?.event,
49
+ });
28
50
  } catch (error) {
29
51
  if (error.isHaltError) {
30
52
  // HaltError means "discard this message, don't retry".
31
53
  // Treat as success so SQS deletes it from the queue.
54
+ // Logged explicitly — silent discards made prod debugging
55
+ // extremely hard; keep this visible.
56
+ console.warn(`[Worker] record halted (discarded, no retry)`, {
57
+ messageId: record.messageId,
58
+ event: parsedEvent,
59
+ reason: error.message,
60
+ statusCode: error.statusCode,
61
+ });
32
62
  continue;
33
63
  }
34
64
  console.error(`[Worker] Failed to process record ${record.messageId}:`, error);
@@ -36,6 +66,12 @@ class Worker {
36
66
  }
37
67
  }
38
68
 
69
+ if (batchItemFailures.length > 0) {
70
+ console.warn(
71
+ `[Worker] run: returning ${batchItemFailures.length} batchItemFailure(s) of ${records.length}`
72
+ );
73
+ }
74
+
39
75
  return { batchItemFailures };
40
76
  }
41
77
 
@@ -5,6 +5,45 @@
5
5
  const { initDebugLog, flushDebugLog } = require('../logs');
6
6
  const { secretsToEnv } = require('./secrets-to-env');
7
7
 
8
+ // Best-effort extraction of correlation identifiers from a Lambda event.
9
+ // For SQS: pulls messageIds + parsed event/processId/integrationId from each
10
+ // record body. For HTTP: pulls method+path. Never throws.
11
+ const summarizeLambdaEvent = (event) => {
12
+ if (!event) return {};
13
+ if (Array.isArray(event.Records)) {
14
+ return {
15
+ source: 'sqs',
16
+ records: event.Records.map((r) => {
17
+ let parsed = {};
18
+ try {
19
+ const body = JSON.parse(r.body);
20
+ parsed = {
21
+ event: body?.event,
22
+ processId: body?.data?.processId,
23
+ integrationId: body?.data?.integrationId,
24
+ };
25
+ } catch {
26
+ // ignore unparseable bodies
27
+ }
28
+ return {
29
+ messageId: r.messageId,
30
+ receiveCount: r.attributes?.ApproximateReceiveCount,
31
+ ...parsed,
32
+ };
33
+ }),
34
+ };
35
+ }
36
+ if (event.httpMethod || event.requestContext?.http) {
37
+ return {
38
+ source: 'http',
39
+ method:
40
+ event.httpMethod || event.requestContext?.http?.method,
41
+ path: event.path || event.rawPath,
42
+ };
43
+ }
44
+ return { source: 'other' };
45
+ };
46
+
8
47
  const createHandler = (optionByName = {}) => {
9
48
  const {
10
49
  eventName = 'Event',
@@ -17,7 +56,18 @@ const createHandler = (optionByName = {}) => {
17
56
  }
18
57
 
19
58
  return async (event, context) => {
59
+ const eventSummary = summarizeLambdaEvent(event);
60
+
20
61
  try {
62
+ console.info(
63
+ `[createHandler] ${eventName}: handler entry`,
64
+ {
65
+ eventName,
66
+ awsRequestId: context?.awsRequestId,
67
+ ...eventSummary,
68
+ }
69
+ );
70
+
21
71
  initDebugLog(eventName, event);
22
72
 
23
73
  const requestMethod = event.httpMethod;
@@ -62,7 +112,21 @@ const createHandler = (optionByName = {}) => {
62
112
  // Handle server-to-server responses.
63
113
 
64
114
  // Halt errors are logged but suceed and won't be retried.
115
+ // Log explicitly — silent suppression here previously made stuck
116
+ // messages invisible to observability tooling. Include
117
+ // eventSummary so operators can correlate across concurrent
118
+ // invocations (processId / messageIds / HTTP path).
65
119
  if (error.isHaltError === true) {
120
+ console.warn(
121
+ `[createHandler] ${eventName}: halt error suppressed (no retry)`,
122
+ {
123
+ eventName,
124
+ errorName: error.name,
125
+ errorMessage: error.message,
126
+ statusCode: error.statusCode,
127
+ ...eventSummary,
128
+ }
129
+ );
66
130
  return;
67
131
  }
68
132
 
@@ -42,6 +42,17 @@ const CORE_ENCRYPTION_SCHEMA = {
42
42
 
43
43
  let customSchema = {};
44
44
 
45
+ /**
46
+ * Per-model write-side opt-out: fields registered here are NOT encrypted on
47
+ * write, but ARE still decrypted on read so legacy encrypted rows continue to
48
+ * deserialize. Lets apps migrate a model from encrypted to plain JSON without
49
+ * a data migration — touched rows naturally rewrite as plain on the next save,
50
+ * untouched rows stay encrypted-but-readable forever.
51
+ *
52
+ * Shape: `{ ModelName: ['field.path', ...] }`
53
+ */
54
+ let encryptionOptOut = {};
55
+
45
56
  /**
46
57
  * Validates a custom encryption schema
47
58
  * @returns {{valid: boolean, errors: string[]}}
@@ -218,6 +229,14 @@ function loadCustomEncryptionSchema() {
218
229
  registerCustomSchema(customSchema);
219
230
  }
220
231
 
232
+ // Load app-level encryption opt-out — apps can declare fields they
233
+ // don't want encrypted on write (decryption on read still works,
234
+ // so legacy data remains readable).
235
+ const disable = appDefinition.encryption?.disable;
236
+ if (disable && Object.keys(disable).length > 0) {
237
+ registerEncryptionOptOut(disable);
238
+ }
239
+
221
240
  // Load module-level encryption schemas from integrations
222
241
  const integrations = appDefinition.integrations;
223
242
  if (integrations && Array.isArray(integrations)) {
@@ -240,6 +259,115 @@ function getEncryptedFields(modelName) {
240
259
  return [...new Set(allFields)];
241
260
  }
242
261
 
262
+ /**
263
+ * Validates an encryption opt-out config.
264
+ *
265
+ * Unlike custom schema validation, opt-out IS allowed to target paths that
266
+ * already live in CORE_ENCRYPTION_SCHEMA — that's the entire point.
267
+ *
268
+ * @param {Object} optOut - Map of `{ ModelName: ['field.path', ...] }`
269
+ * @returns {{valid: boolean, errors: string[]}}
270
+ */
271
+ function validateOptOut(optOut) {
272
+ const errors = [];
273
+
274
+ if (!optOut || typeof optOut !== 'object') {
275
+ errors.push('Encryption opt-out must be an object');
276
+ return { valid: false, errors };
277
+ }
278
+
279
+ for (const [modelName, fields] of Object.entries(optOut)) {
280
+ if (typeof modelName !== 'string' || !modelName) {
281
+ errors.push(`Invalid model name in opt-out: ${modelName}`);
282
+ continue;
283
+ }
284
+
285
+ if (!Array.isArray(fields)) {
286
+ errors.push(
287
+ `Model "${modelName}" opt-out must be an array of field paths`
288
+ );
289
+ continue;
290
+ }
291
+
292
+ for (const fieldPath of fields) {
293
+ if (typeof fieldPath !== 'string' || !fieldPath) {
294
+ errors.push(
295
+ `Model "${modelName}" has invalid opt-out field path: ${fieldPath}`
296
+ );
297
+ }
298
+ }
299
+ }
300
+
301
+ return { valid: errors.length === 0, errors };
302
+ }
303
+
304
+ /**
305
+ * Registers an encryption opt-out config. Listed fields will be skipped during
306
+ * encryption on write while still being eligible for decryption on read (so
307
+ * legacy encrypted rows still deserialize correctly).
308
+ *
309
+ * Intended call site: `appDefinition.encryption.disable` via
310
+ * `loadCustomEncryptionSchema`.
311
+ *
312
+ * @param {Object} optOut - Map of `{ ModelName: ['field.path', ...] }`
313
+ * @throws {Error} If opt-out validation fails
314
+ */
315
+ function registerEncryptionOptOut(optOut) {
316
+ if (!optOut || Object.keys(optOut).length === 0) {
317
+ return;
318
+ }
319
+
320
+ const validation = validateOptOut(optOut);
321
+ if (!validation.valid) {
322
+ throw new Error(
323
+ `Invalid encryption opt-out:\n- ${validation.errors.join('\n- ')}`
324
+ );
325
+ }
326
+
327
+ encryptionOptOut = { ...optOut };
328
+ logger.info(
329
+ `Registered encryption opt-out for models: ${Object.keys(
330
+ encryptionOptOut
331
+ ).join(', ')}`
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Returns the field paths that should be encrypted when writing the given
337
+ * model. This is `getEncryptedFields` minus any paths the app has opted out
338
+ * of via `registerEncryptionOptOut`.
339
+ *
340
+ * Use this in the encrypt-on-write path of the FieldEncryptionService.
341
+ */
342
+ function getFieldsToEncryptOnWrite(modelName) {
343
+ const allFields = getEncryptedFields(modelName);
344
+ const optedOut = new Set(encryptionOptOut[modelName] || []);
345
+ if (optedOut.size === 0) return allFields;
346
+ return allFields.filter((path) => !optedOut.has(path));
347
+ }
348
+
349
+ /**
350
+ * Returns the field paths that should be checked for decryption when reading
351
+ * the given model. Always includes opted-out paths so legacy encrypted rows
352
+ * remain readable after an app opts a field out.
353
+ *
354
+ * `FieldEncryptionService._isEncrypted` already short-circuits for plain JSON
355
+ * values, so listing more fields than necessary here is harmless.
356
+ *
357
+ * Use this in the decrypt-on-read path of the FieldEncryptionService.
358
+ */
359
+ function getFieldsToDecryptOnRead(modelName) {
360
+ return getEncryptedFields(modelName);
361
+ }
362
+
363
+ /**
364
+ * Clears any registered encryption opt-outs. Test-helper; not intended for
365
+ * runtime use.
366
+ */
367
+ function resetEncryptionOptOut() {
368
+ encryptionOptOut = {};
369
+ }
370
+
243
371
  function hasEncryptedFields(modelName) {
244
372
  return getEncryptedFields(modelName).length > 0;
245
373
  }
@@ -257,12 +385,17 @@ function resetCustomSchema() {
257
385
  module.exports = {
258
386
  CORE_ENCRYPTION_SCHEMA,
259
387
  getEncryptedFields,
388
+ getFieldsToEncryptOnWrite,
389
+ getFieldsToDecryptOnRead,
260
390
  hasEncryptedFields,
261
391
  getEncryptedModels,
262
392
  registerCustomSchema,
393
+ registerEncryptionOptOut,
263
394
  loadCustomEncryptionSchema,
264
395
  loadModuleEncryptionSchemas,
265
396
  extractCredentialFieldsFromModules,
266
397
  validateCustomSchema,
398
+ validateOptOut,
267
399
  resetCustomSchema,
400
+ resetEncryptionOptOut,
268
401
  };
@@ -17,12 +17,40 @@ class FieldEncryptionService {
17
17
  this.schema = schema;
18
18
  }
19
19
 
20
+ /**
21
+ * Resolve the field paths to encrypt on write. Prefers
22
+ * `schema.getFieldsToEncryptOnWrite` (which respects app opt-outs); falls
23
+ * back to `schema.getEncryptedFields` for backwards compatibility with
24
+ * older schema adapters.
25
+ * @private
26
+ */
27
+ _getWriteFields(modelName) {
28
+ if (typeof this.schema.getFieldsToEncryptOnWrite === 'function') {
29
+ return this.schema.getFieldsToEncryptOnWrite(modelName);
30
+ }
31
+ return this.schema.getEncryptedFields(modelName);
32
+ }
33
+
34
+ /**
35
+ * Resolve the field paths to attempt decryption on read. Prefers
36
+ * `schema.getFieldsToDecryptOnRead` (which IGNORES opt-outs so legacy
37
+ * encrypted rows still deserialize); falls back to
38
+ * `schema.getEncryptedFields` for backwards compatibility.
39
+ * @private
40
+ */
41
+ _getReadFields(modelName) {
42
+ if (typeof this.schema.getFieldsToDecryptOnRead === 'function') {
43
+ return this.schema.getFieldsToDecryptOnRead(modelName);
44
+ }
45
+ return this.schema.getEncryptedFields(modelName);
46
+ }
47
+
20
48
  async encryptFields(modelName, document) {
21
49
  if (!document || typeof document !== 'object') {
22
50
  return document;
23
51
  }
24
52
 
25
- const fields = this.schema.getEncryptedFields(modelName);
53
+ const fields = this._getWriteFields(modelName);
26
54
  if (fields.length === 0) {
27
55
  return document;
28
56
  }
@@ -58,7 +86,7 @@ class FieldEncryptionService {
58
86
  return document;
59
87
  }
60
88
 
61
- const fields = this.schema.getEncryptedFields(modelName);
89
+ const fields = this._getReadFields(modelName);
62
90
  if (fields.length === 0) {
63
91
  return document;
64
92
  }
@@ -3,7 +3,11 @@
3
3
  * Intercepts Prisma queries to encrypt on write and decrypt on read.
4
4
  */
5
5
 
6
- const { getEncryptedFields } = require('./encryption-schema-registry');
6
+ const {
7
+ getEncryptedFields,
8
+ getFieldsToEncryptOnWrite,
9
+ getFieldsToDecryptOnRead,
10
+ } = require('./encryption-schema-registry');
7
11
  const { FieldEncryptionService } = require('./field-encryption-service');
8
12
 
9
13
  function createEncryptionExtension({ cryptor, enabled = true }) {
@@ -19,7 +23,11 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
19
23
 
20
24
  const encryptionService = new FieldEncryptionService({
21
25
  cryptor,
22
- schema: { getEncryptedFields },
26
+ schema: {
27
+ getEncryptedFields,
28
+ getFieldsToEncryptOnWrite,
29
+ getFieldsToDecryptOnRead,
30
+ },
23
31
  });
24
32
 
25
33
  return {
@@ -31,6 +31,9 @@ const loadRouterFromObject = (IntegrationClass, routerObject) => {
31
31
  router[method.toLowerCase()](path, async (req, res, next) => {
32
32
  try {
33
33
  const integrationInstance = new IntegrationClass();
34
+ // initialize() registers dynamic user actions AND merges any Tier 3
35
+ // Integration Extension events into instance.events before dispatch.
36
+ await integrationInstance.initialize();
34
37
  const dispatcher = new IntegrationEventDispatcher(
35
38
  integrationInstance
36
39
  );
@@ -141,8 +144,17 @@ const loadIntegrationForProcess = async (processId, integrationClass) => {
141
144
  };
142
145
 
143
146
  const createQueueWorker = (integrationClass) => {
147
+ const integrationName = integrationClass.Definition.name;
148
+
144
149
  class QueueWorker extends Worker {
145
150
  async _run(params, context) {
151
+ const logCtx = {
152
+ integration: integrationName,
153
+ event: params.event,
154
+ processId: params.data?.processId,
155
+ integrationId: params.data?.integrationId,
156
+ };
157
+
146
158
  try {
147
159
  let integrationInstance;
148
160
 
@@ -150,29 +162,46 @@ const createQueueWorker = (integrationClass) => {
150
162
  // then integrationId (for ANY event type that needs hydration),
151
163
  // fallback to unhydrated instance
152
164
  if (params.data?.processId) {
165
+ console.log(
166
+ `[QueueWorker] hydrating by processId`,
167
+ logCtx
168
+ );
153
169
  integrationInstance = await loadIntegrationForProcess(
154
170
  params.data.processId,
155
171
  integrationClass
156
172
  );
173
+ console.log(`[QueueWorker] hydrated`, {
174
+ ...logCtx,
175
+ integrationStatus: integrationInstance?.status,
176
+ hydratedIntegrationId: integrationInstance?.id,
177
+ });
157
178
  if (['DISABLED', 'ERROR'].includes(integrationInstance?.status)) {
158
179
  console.warn(
159
- `[${integrationClass.Definition.name}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
180
+ `[${integrationName}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
160
181
  );
161
182
  return;
162
183
  }
163
184
  } else if (params.data?.integrationId) {
185
+ console.log(
186
+ `[QueueWorker] hydrating by integrationId`,
187
+ logCtx
188
+ );
164
189
  integrationInstance = await loadIntegrationForWebhook(
165
190
  params.data.integrationId
166
191
  );
167
192
  if (!integrationInstance) {
168
193
  console.warn(
169
- `[${integrationClass.Definition.name}] Integration ${params.data.integrationId} no longer exists. Discarding ${params.event} message.`
194
+ `[${integrationName}] Integration ${params.data.integrationId} no longer exists. Discarding ${params.event} message.`
170
195
  );
171
196
  return;
172
197
  }
198
+ console.log(`[QueueWorker] hydrated`, {
199
+ ...logCtx,
200
+ integrationStatus: integrationInstance?.status,
201
+ });
173
202
  if (['DISABLED', 'ERROR'].includes(integrationInstance.status)) {
174
203
  console.warn(
175
- `[${integrationClass.Definition.name}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
204
+ `[${integrationName}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
176
205
  );
177
206
  return;
178
207
  }
@@ -181,21 +210,31 @@ const createQueueWorker = (integrationClass) => {
181
210
  // There will be cases where we need to use helpers that the api modules can export.
182
211
  // Like for HubSpot, the answer is to do a reverse lookup for the integration by the entity external ID (HubSpot Portal ID),
183
212
  // and then you'll have the integration ID available to hydrate from.
213
+ console.log(
214
+ `[QueueWorker] no processId/integrationId — running dry instance`,
215
+ logCtx
216
+ );
184
217
  integrationInstance = new integrationClass();
218
+ // Merge Tier 3 Integration Extension events into instance.events
219
+ // so extension-contributed queue events can be dispatched.
220
+ await integrationInstance.initialize();
185
221
  }
186
222
 
187
223
  const dispatcher = new IntegrationEventDispatcher(
188
224
  integrationInstance
189
225
  );
190
226
 
191
- return await dispatcher.dispatchJob({
227
+ console.log(`[QueueWorker] dispatching ${params.event}`, logCtx);
228
+ const result = await dispatcher.dispatchJob({
192
229
  event: params.event,
193
230
  data: params.data,
194
231
  context: context,
195
232
  });
233
+ console.log(`[QueueWorker] ${params.event} dispatched ok`, logCtx);
234
+ return result;
196
235
  } catch (error) {
197
236
  console.error(
198
- `Error in ${params.event} for ${integrationClass.Definition.name}:`,
237
+ `Error in ${params.event} for ${integrationName}:`,
199
238
  error
200
239
  );
201
240
 
@@ -207,7 +246,12 @@ const createQueueWorker = (integrationClass) => {
207
246
  if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
208
247
  error.isHaltError = true;
209
248
  console.warn(
210
- `[${integrationClass.Definition.name}] Permanent ${status} error for ${params.event} — message will be discarded (no retry)`
249
+ `[${integrationName}] Permanent ${status} error for ${params.event} — message will be discarded (no retry)`,
250
+ {
251
+ ...logCtx,
252
+ errorName: error.name,
253
+ errorMessage: error.message,
254
+ }
211
255
  );
212
256
  }
213
257
 
@@ -2,24 +2,47 @@ const { createAppHandler } = require('./../app-handler-helpers');
2
2
  const {
3
3
  loadAppDefinition,
4
4
  } = require('../app-definition-loader');
5
- const { Router } = require('express');
5
+ const express = require('express');
6
+ const { Router } = express;
6
7
  const { loadRouterFromObject } = require('../backend-utils');
8
+ const { getExtensionRoutes } = require('../../integrations/extension');
7
9
 
8
10
  const handlers = {};
9
11
  const { integrations: integrationClasses } = loadAppDefinition();
10
12
 
13
+ const routeKey = (method, path) => `${(method || 'ANY').toUpperCase()} ${path}`;
14
+
11
15
  //todo: this should be in a use case class
12
16
  for (const IntegrationClass of integrationClasses) {
13
17
  const router = Router();
14
18
  const basePath = `/api/${IntegrationClass.Definition.name}-integration`;
19
+ // Track (method, path) tuples to fail fast on conflicts between Definition.routes,
20
+ // extension routes, or two extensions claiming the same path.
21
+ const claimedRoutes = new Map();
22
+ const claim = (method, path, source) => {
23
+ const key = routeKey(method, path);
24
+ if (claimedRoutes.has(key)) {
25
+ const prev = claimedRoutes.get(key);
26
+ throw new Error(
27
+ `Integration "${IntegrationClass.Definition.name}" route conflict: ` +
28
+ `${key} declared by ${prev} and ${source}`
29
+ );
30
+ }
31
+ claimedRoutes.set(key, source);
32
+ };
15
33
 
16
34
  console.log(`\n│ Configuring routes for ${IntegrationClass.Definition.name} Integration:`);
17
35
 
18
- for (const routeDef of IntegrationClass.Definition.routes) {
36
+ const routes = IntegrationClass.Definition.routes || [];
37
+ for (const routeDef of routes) {
19
38
  if (typeof routeDef === 'function') {
20
39
  router.use(basePath, routeDef(IntegrationClass));
21
40
  console.log(`│ ANY ${basePath}/* (function handler)`);
41
+ } else if (routeDef instanceof express.Router) {
42
+ router.use(basePath, routeDef);
43
+ console.log(`│ ANY ${basePath}/* (express router)`);
22
44
  } else if (typeof routeDef === 'object') {
45
+ claim(routeDef.method, routeDef.path, 'Definition.routes');
23
46
  router.use(
24
47
  basePath,
25
48
  loadRouterFromObject(IntegrationClass, routeDef)
@@ -27,11 +50,25 @@ for (const IntegrationClass of integrationClasses) {
27
50
  const method = (routeDef.method || 'ANY').toUpperCase();
28
51
  const fullPath = `${basePath}${routeDef.path}`;
29
52
  console.log(`│ ${method} ${fullPath}`);
30
- } else if (routeDef instanceof express.Router) {
31
- router.use(basePath, routeDef);
32
- console.log(`│ ANY ${basePath}/* (express router)`);
33
53
  }
34
54
  }
55
+
56
+ // Tier 3 Integration Extension routes — see EXTENSIONS.md
57
+ for (const extRoute of getExtensionRoutes(IntegrationClass)) {
58
+ claim(
59
+ extRoute.method,
60
+ extRoute.path,
61
+ `extension "${extRoute.extensionName}" (binding "${extRoute.bindingName}")`
62
+ );
63
+ router.use(
64
+ basePath,
65
+ loadRouterFromObject(IntegrationClass, extRoute)
66
+ );
67
+ const method = extRoute.method.toUpperCase();
68
+ console.log(
69
+ `│ ${method} ${basePath}${extRoute.path} (extension: ${extRoute.extensionName})`
70
+ );
71
+ }
35
72
  console.log('│');
36
73
 
37
74
  handlers[`${IntegrationClass.Definition.name}`] = {
@@ -1,6 +1,13 @@
1
1
  const { createHandler } = require('@friggframework/core');
2
2
  const { loadAppDefinition } = require('../app-definition-loader');
3
3
  const { createQueueWorker } = require('../backend-utils');
4
+ // TODO(Phase 2): mount extension-declared workers in addition to the per-integration
5
+ // default queue worker. Today, getExtensionWorkers(IntegrationClass) returns the
6
+ // declared workers but they are not yet bound to dedicated SQS sources. Extension-
7
+ // contributed *events* still flow through the default queue worker below because
8
+ // _mergeExtensions() registers them in instance.events, so end-to-end webhook
9
+ // delivery for Tier 3 extensions works without this Phase 2 work.
10
+ // const { getExtensionWorkers } = require('../../integrations/extension');
4
11
 
5
12
  const handlers = {};
6
13
  const { integrations: integrationClasses } = loadAppDefinition();