@friggframework/core 2.0.0--canary.703.12b980b.0 → 2.0.0--canary.707.df80bfd.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.
Files changed (308) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +20 -10
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/application/index.js +6 -2
  8. package/core/Worker.js +31 -7
  9. package/core/create-handler.js +9 -0
  10. package/credential/repositories/credential-repository-documentdb.js +34 -0
  11. package/credential/repositories/credential-repository-interface.js +15 -0
  12. package/credential/repositories/credential-repository-mongo.js +23 -0
  13. package/credential/repositories/credential-repository-postgres.js +28 -0
  14. package/credential/use-cases/credential-operations-for-user.js +202 -0
  15. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  16. package/database/config.js +48 -24
  17. package/database/documentdb-encryption-service.js +9 -22
  18. package/database/documentdb-utils.js +2 -2
  19. package/database/encryption/README.md +78 -14
  20. package/database/encryption/documentdb-encryption-service.md +78 -1
  21. package/database/encryption/encryption-config.js +219 -0
  22. package/database/encryption/encryption-schema-registry.js +121 -24
  23. package/database/encryption/field-encryption-service.js +69 -5
  24. package/database/encryption/integration-mapping-encryption.js +1 -1
  25. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  26. package/database/organization-where.js +18 -0
  27. package/database/prisma.js +15 -38
  28. package/database/unique-violation.js +25 -0
  29. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  30. package/errors/index.js +0 -4
  31. package/extensions/bootstrap-runner.js +43 -0
  32. package/extensions/extension-loader.js +230 -0
  33. package/extensions/index.js +44 -0
  34. package/extensions/initialize-app.js +123 -0
  35. package/extensions/prisma-composer.js +270 -0
  36. package/extensions/route-mounter.js +103 -0
  37. package/extensions/schema-composer.js +188 -0
  38. package/generated/prisma-mongodb/edge.js +33 -42
  39. package/generated/prisma-mongodb/index-browser.js +30 -39
  40. package/generated/prisma-mongodb/index.d.ts +3187 -2005
  41. package/generated/prisma-mongodb/index.js +33 -42
  42. package/generated/prisma-mongodb/package.json +1 -1
  43. package/generated/prisma-mongodb/schema.prisma +86 -59
  44. package/generated/prisma-mongodb/wasm.js +33 -42
  45. package/generated/prisma-postgresql/edge.js +33 -42
  46. package/generated/prisma-postgresql/index-browser.js +30 -39
  47. package/generated/prisma-postgresql/index.d.ts +3380 -2177
  48. package/generated/prisma-postgresql/index.js +33 -42
  49. package/generated/prisma-postgresql/package.json +1 -1
  50. package/generated/prisma-postgresql/schema.prisma +81 -57
  51. package/generated/prisma-postgresql/wasm.js +33 -42
  52. package/handlers/WEBHOOKS.md +0 -29
  53. package/handlers/app-definition-loader.js +128 -19
  54. package/handlers/app-handler-helpers.js +27 -8
  55. package/handlers/backend-utils.js +31 -25
  56. package/handlers/integration-event-dispatcher.js +3 -7
  57. package/handlers/middleware/admin-auth.js +14 -1
  58. package/handlers/routers/auth.js +46 -10
  59. package/handlers/routers/health.js +13 -4
  60. package/handlers/routers/integration-defined-routers.js +144 -92
  61. package/handlers/routers/integration-webhook-routers.js +100 -50
  62. package/handlers/workers/integration-defined-workers.js +5 -2
  63. package/index.js +66 -16
  64. package/infrastructure/scheduler/index.js +19 -6
  65. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  66. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  67. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  68. package/integrations/index.js +1 -14
  69. package/integrations/integration-base.js +2 -163
  70. package/integrations/integration-router.js +132 -102
  71. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  72. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  73. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  74. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  75. package/integrations/repositories/integration-mapping-repository.js +0 -29
  76. package/integrations/repositories/integration-not-found.js +14 -0
  77. package/integrations/repositories/integration-repository-documentdb.js +37 -9
  78. package/integrations/repositories/integration-repository-interface.js +17 -4
  79. package/integrations/repositories/integration-repository-mongo.js +64 -96
  80. package/integrations/repositories/integration-repository-postgres.js +63 -81
  81. package/integrations/repositories/process-repository-mongo.js +1 -1
  82. package/integrations/schedule-config.js +80 -0
  83. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  84. package/integrations/use-cases/create-integration.js +56 -31
  85. package/integrations/use-cases/delete-integration-for-user.js +4 -4
  86. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  87. package/integrations/use-cases/get-integration-for-user.js +2 -4
  88. package/integrations/use-cases/get-integration-instance.js +4 -4
  89. package/integrations/use-cases/get-integrations-for-user.js +79 -39
  90. package/integrations/use-cases/get-possible-integrations.js +2 -7
  91. package/integrations/use-cases/index.js +7 -1
  92. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  93. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  94. package/integrations/use-cases/update-integration.js +4 -4
  95. package/integrations/utils/map-integration-dto.js +43 -14
  96. package/management-api/create-management-api-router.js +49 -0
  97. package/management-api/error-response.js +116 -0
  98. package/management-api/management-api-config.js +151 -0
  99. package/management-api/meta-router.js +76 -0
  100. package/management-api/openapi/build-openapi.js +144 -0
  101. package/management-api/openapi/components.js +415 -0
  102. package/management-api/openapi/v1-operations.js +83 -0
  103. package/management-api/openapi/v2-operations.js +264 -0
  104. package/management-api/route-registry.js +411 -0
  105. package/management-api/test-support/http-client.js +39 -0
  106. package/management-api/use-cases/get-management-api-meta.js +57 -0
  107. package/management-api/v1-deprecation.js +120 -0
  108. package/management-api/v2/create-v2-router.js +85 -0
  109. package/management-api/v2/dependencies.js +281 -0
  110. package/management-api/v2/dto.js +143 -0
  111. package/management-api/v2/handlers/authorization.js +94 -0
  112. package/management-api/v2/handlers/credentials.js +33 -0
  113. package/management-api/v2/handlers/entities.js +77 -0
  114. package/management-api/v2/handlers/integrations.js +128 -0
  115. package/management-api/v2/handlers/proxy.js +34 -0
  116. package/management-api/v2/request.js +85 -0
  117. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  118. package/modules/domain/authorization-session.js +144 -0
  119. package/modules/entity-scope.js +15 -0
  120. package/modules/entity-type-registry.js +311 -0
  121. package/modules/index.js +14 -0
  122. package/modules/module-factory.js +12 -0
  123. package/modules/module.js +53 -2
  124. package/modules/proxy/proxy-bases.js +169 -0
  125. package/modules/proxy/proxy-errors.js +31 -0
  126. package/modules/proxy/proxy-path.js +183 -0
  127. package/modules/proxy/proxy-policy.js +277 -0
  128. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  129. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  130. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  131. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  132. package/modules/repositories/entity-not-found.js +18 -0
  133. package/modules/repositories/module-repository-documentdb.js +51 -6
  134. package/modules/repositories/module-repository-interface.js +19 -2
  135. package/modules/repositories/module-repository-mongo.js +67 -74
  136. package/modules/repositories/module-repository-postgres.js +66 -74
  137. package/modules/repositories/module-repository.js +2 -1
  138. package/modules/requester/proxy-request.js +245 -0
  139. package/modules/requester/requester.js +35 -5
  140. package/modules/scope-config.js +192 -0
  141. package/modules/scope-policy.js +112 -0
  142. package/modules/scope-resolution.js +288 -0
  143. package/modules/scope-visibility.js +247 -0
  144. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  145. package/modules/tests/doubles/ownership-matching.js +18 -0
  146. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  147. package/modules/use-cases/create-entity-from-credential.js +113 -0
  148. package/modules/use-cases/entity-operations-for-user.js +162 -0
  149. package/modules/use-cases/entity-types.js +139 -0
  150. package/modules/use-cases/execute-entity-proxy-request.js +331 -0
  151. package/modules/use-cases/get-authorization-requirements.js +100 -0
  152. package/modules/use-cases/get-entities-for-user.js +26 -13
  153. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  154. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  155. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  156. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  157. package/modules/use-cases/get-module.js +10 -4
  158. package/modules/use-cases/manage-access.js +21 -0
  159. package/modules/use-cases/multi-step-authorization.js +329 -0
  160. package/modules/use-cases/process-authorization-callback.js +53 -19
  161. package/modules/use-cases/refresh-entity-options.js +10 -4
  162. package/modules/use-cases/test-module-auth.js +10 -4
  163. package/package.json +6 -7
  164. package/prisma-mongodb/schema.prisma +86 -59
  165. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  166. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  167. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  168. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  169. package/prisma-postgresql/schema.prisma +81 -57
  170. package/providers/index.js +23 -0
  171. package/providers/resolve-provider.js +93 -0
  172. package/providers/runtime-detection.js +124 -0
  173. package/queues/index.js +25 -0
  174. package/queues/providers/index.js +11 -0
  175. package/queues/providers/netlify-background-provider.js +150 -0
  176. package/queues/providers/qstash-queue-provider.js +219 -0
  177. package/queues/providers/sqs-queue-provider.js +70 -0
  178. package/queues/queue-client-interface.js +55 -0
  179. package/queues/queue-message-auth.js +193 -0
  180. package/queues/queue-provider-factory.js +101 -0
  181. package/queues/queue-provider.js +65 -0
  182. package/queues/queuer-util.js +2 -9
  183. package/queues/sqs-queue-client.js +81 -0
  184. package/types/index.d.ts +2 -0
  185. package/types/modules/index.d.ts +214 -0
  186. package/types/user/index.d.ts +134 -0
  187. package/user/adopter-jwt-config.js +177 -0
  188. package/user/adopter-jwt-errors.js +59 -0
  189. package/user/adopter-token-verifier.js +122 -0
  190. package/user/app-user-helpers.js +97 -0
  191. package/user/create-user-authentication.js +64 -0
  192. package/user/membership.js +51 -0
  193. package/user/repositories/organization-membership-record.js +19 -0
  194. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  195. package/user/repositories/organization-membership-repository-factory.js +42 -0
  196. package/user/repositories/organization-membership-repository-interface.js +57 -0
  197. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  198. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  199. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  200. package/user/repositories/user-repository-documentdb.js +27 -2
  201. package/user/repositories/user-repository-interface.js +18 -0
  202. package/user/repositories/user-repository-mongo.js +24 -0
  203. package/user/repositories/user-repository-postgres.js +18 -0
  204. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  205. package/user/use-cases/authenticate-user.js +7 -1
  206. package/user/use-cases/ensure-organization-membership.js +92 -0
  207. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  208. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  209. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  210. package/user/user.js +64 -1
  211. package/errors/mapping-already-exists-error.js +0 -14
  212. package/handlers/workers/integration-triggers.js +0 -25
  213. package/infrastructure/scheduler/recurrence.js +0 -204
  214. package/integrations/define/DESIGN.md +0 -122
  215. package/integrations/define/config.js +0 -98
  216. package/integrations/define/define-app.js +0 -66
  217. package/integrations/define/define-extension.js +0 -154
  218. package/integrations/define/define-integration.js +0 -335
  219. package/integrations/define/examples/deal-alerts.js +0 -140
  220. package/integrations/define/extension-binding.js +0 -174
  221. package/integrations/define/index.js +0 -11
  222. package/integrations/define/json-schema-lite.js +0 -68
  223. package/integrations/define/subscriptions.js +0 -186
  224. package/integrations/define/tests/fixtures/hubspot.js +0 -109
  225. package/integrations/define/tests/fixtures/slack.js +0 -26
  226. package/integrations/define/use.js +0 -39
  227. package/integrations/definitions/README.md +0 -278
  228. package/integrations/definitions/canonical.js +0 -11
  229. package/integrations/definitions/capabilities/AUDIT.md +0 -480
  230. package/integrations/definitions/capabilities/acmeerp.capabilities.json +0 -633
  231. package/integrations/definitions/capabilities/asana.capabilities.json +0 -279
  232. package/integrations/definitions/capabilities/calendly.capabilities.json +0 -385
  233. package/integrations/definitions/capabilities/docusign.capabilities.json +0 -314
  234. package/integrations/definitions/capabilities/fathom.capabilities.json +0 -238
  235. package/integrations/definitions/capabilities/github.capabilities.json +0 -585
  236. package/integrations/definitions/capabilities/hubspot.capabilities.json +0 -822
  237. package/integrations/definitions/capabilities/jira.capabilities.json +0 -391
  238. package/integrations/definitions/capabilities/klaviyo.capabilities.json +0 -503
  239. package/integrations/definitions/capabilities/linear.capabilities.json +0 -378
  240. package/integrations/definitions/capabilities/netsuite.capabilities.json +0 -535
  241. package/integrations/definitions/capabilities/notion.capabilities.json +0 -208
  242. package/integrations/definitions/capabilities/phone.capabilities.json +0 -511
  243. package/integrations/definitions/capabilities/pipedrive.capabilities.json +0 -430
  244. package/integrations/definitions/capabilities/quickbooks.capabilities.json +0 -635
  245. package/integrations/definitions/capabilities/salesforce.capabilities.json +0 -568
  246. package/integrations/definitions/capabilities/shopify.capabilities.json +0 -1128
  247. package/integrations/definitions/capabilities/slack.capabilities.json +0 -373
  248. package/integrations/definitions/capabilities/stripe.capabilities.json +0 -630
  249. package/integrations/definitions/capabilities/webflow.capabilities.json +0 -196
  250. package/integrations/definitions/capabilities/zendesk.capabilities.json +0 -422
  251. package/integrations/definitions/compile.js +0 -80
  252. package/integrations/definitions/config-form.js +0 -137
  253. package/integrations/definitions/examples/acme-erp-quickbooks.definition.json +0 -467
  254. package/integrations/definitions/examples/asana-slack.definition.json +0 -219
  255. package/integrations/definitions/examples/calendly-salesforce.definition.json +0 -459
  256. package/integrations/definitions/examples/docusign-salesforce.definition.json +0 -305
  257. package/integrations/definitions/examples/fathom-hubspot-notion.definition.json +0 -575
  258. package/integrations/definitions/examples/github-linear.definition.json +0 -322
  259. package/integrations/definitions/examples/hubspot-slack.definition.json +0 -427
  260. package/integrations/definitions/examples/klaviyo-hubspot.definition.json +0 -336
  261. package/integrations/definitions/examples/phone-hubspot.definition.json +0 -633
  262. package/integrations/definitions/examples/pipedrive-hubspot.definition.json +0 -136
  263. package/integrations/definitions/examples/pipedrive-phone.definition.json +0 -340
  264. package/integrations/definitions/examples/records/phone-hubspot.definition-version.json +0 -652
  265. package/integrations/definitions/examples/records/phone-hubspot.integration.json +0 -86
  266. package/integrations/definitions/examples/records/phone-hubspot.mappings.json +0 -39
  267. package/integrations/definitions/examples/records/phone-hubspot.workflow-run.json +0 -62
  268. package/integrations/definitions/examples/shopify-klaviyo.definition.json +0 -488
  269. package/integrations/definitions/examples/shopify-netsuite.definition.json +0 -622
  270. package/integrations/definitions/examples/stripe-quickbooks.definition.json +0 -476
  271. package/integrations/definitions/examples/webflow-hubspot-slack.definition.json +0 -262
  272. package/integrations/definitions/examples/zendesk-jira.definition.json +0 -604
  273. package/integrations/definitions/expressions.js +0 -229
  274. package/integrations/definitions/index.js +0 -48
  275. package/integrations/definitions/integration-runtime.js +0 -779
  276. package/integrations/definitions/interpreter.js +0 -104
  277. package/integrations/definitions/manifests.js +0 -177
  278. package/integrations/definitions/renderers/common.js +0 -67
  279. package/integrations/definitions/renderers/html.js +0 -129
  280. package/integrations/definitions/renderers/hubspot.js +0 -59
  281. package/integrations/definitions/renderers/preview.js +0 -154
  282. package/integrations/definitions/renderers/slack.js +0 -146
  283. package/integrations/definitions/repositories/definition-version-repository.js +0 -67
  284. package/integrations/definitions/repositories/workflow-run-repository.js +0 -80
  285. package/integrations/definitions/router.js +0 -170
  286. package/integrations/definitions/runtime.js +0 -268
  287. package/integrations/definitions/schema/capability-manifest.v0.1.schema.json +0 -549
  288. package/integrations/definitions/schema/installation.v0.1.schema.json +0 -130
  289. package/integrations/definitions/schema/integration-definition.v0.1.schema.json +0 -909
  290. package/integrations/definitions/schema/records.md +0 -105
  291. package/integrations/definitions/schema-check.js +0 -58
  292. package/integrations/definitions/scripts/check-manifests.js +0 -220
  293. package/integrations/definitions/scripts/validate.js +0 -66
  294. package/integrations/definitions/steps.js +0 -123
  295. package/integrations/definitions/sync-engine.js +0 -297
  296. package/integrations/definitions/tests/helpers-fake-crm.js +0 -49
  297. package/integrations/definitions/transforms.js +0 -91
  298. package/integrations/definitions/use-cases/definition-use-cases.js +0 -245
  299. package/integrations/definitions/validate.js +0 -436
  300. package/integrations/definitions/versioning.js +0 -176
  301. package/integrations/definitions/webhooks.js +0 -119
  302. package/integrations/integration-types.js +0 -73
  303. package/integrations/triggers.js +0 -244
  304. package/integrations/use-cases/run-trigger-tick.js +0 -155
  305. package/integrations/use-cases/run-trigger.js +0 -77
  306. package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +0 -66
  307. package/prisma-postgresql/migrations/20261019000000_workflow_run_waits/migration.sql +0 -7
  308. package/prisma-postgresql/migrations/20261019120000_definition_workspace/migration.sql +0 -5
@@ -1,119 +0,0 @@
1
- /**
2
- * Turning a provider's webhook delivery into the module events a definition listens for. Pure; no I/O.
3
- *
4
- * Providers disagree about everything: where the topic is (a body field, a header, two fields combined), whether one
5
- * POST carries one event or a batch (HubSpot sends an array, QuickBooks nests entities inside notifications), which
6
- * account it is about, and whether the first request is a handshake to answer synchronously (Slack url_verification,
7
- * Asana X-Hook-Secret). A manifest says so in its `webhook` block, with the same template expressions definitions
8
- * use, so none of it is code:
9
- *
10
- * "webhook": {
11
- * "scope": "app", // one URL for every account ("installation": one per install)
12
- * "batch": "body", // path to the array of events; [outer, inner] for two levels
13
- * "topic": "{{item.subscriptionType}}", // the provider's topic; a list tries each until one is non-empty
14
- * "eventId": "{{item.eventId}}", // dedupe id
15
- * "account": "{{item.portalId}}", // the connected account (entity externalId) the event is about
16
- * "verify": "verifyWebhookSignature", // optional Api method: ({ headers, body, query }) -> boolean
17
- * "handshake": { "when": "...", "status": 200, "headers": {...}, "body": {...} },
18
- * "unwrap": "payload" // a form-encoded field holding the JSON body (Slack interactivity)
19
- * }
20
- *
21
- * and each event says which provider topic it is (`match`, default: the event key) and optionally a condition on the
22
- * record (`when`): a phone module reports a missed call as call.completed with status missed.
23
- *
24
- * Template context: { body, headers, query, item, parent }. `item` is the event (the body itself when there is no
25
- * batch); `parent` is the outer item of a two-level batch (a QuickBooks notification, which carries the realmId).
26
- */
27
- const { resolve, evaluate, getPath } = require('./expressions');
28
-
29
- const list = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
30
- const str = (v) => (v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
31
-
32
- /** The first template in `tpls` that renders to a non-empty string. */
33
- function renderFirst(tpls, ctx) {
34
- for (const t of list(tpls)) {
35
- const v = str(resolve(t, ctx)).trim();
36
- if (v) return v;
37
- }
38
- return '';
39
- }
40
-
41
- /** Lower-case header names, as Express and Lambda deliver them, so manifests can always write headers.x-shopify-topic. */
42
- function normaliseHeaders(headers = {}) {
43
- return Object.fromEntries(Object.entries(headers || {}).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v[0] : v]));
44
- }
45
-
46
- /** The delivery as the manifest sees it: lower-case headers, and the JSON body unwrapped from a form field if declared. */
47
- function prepare(webhook = {}, delivery) {
48
- let body = delivery.body;
49
- if (webhook.unwrap && body && typeof body[webhook.unwrap] === 'string') {
50
- try { body = JSON.parse(body[webhook.unwrap]); } catch { /* not JSON: leave the body as delivered */ }
51
- }
52
- return { body, headers: normaliseHeaders(delivery.headers), query: delivery.query || {} };
53
- }
54
-
55
- /** Split one delivery into events: [{ item, parent }]. */
56
- function splitBatch(webhook = {}, input) {
57
- const paths = list(webhook.batch);
58
- const at = (obj, p) => (p === 'body' ? obj.body : p === '$' ? obj : getPath(obj, '$.' + p));
59
- if (!paths.length) return [{ item: input.body, parent: null }];
60
- const outer = at(input, paths[0]);
61
- const outerItems = Array.isArray(outer) ? outer : outer == null ? [] : [outer];
62
- if (paths.length === 1) return outerItems.map((item) => ({ item, parent: null }));
63
- return outerItems.flatMap((parent) => {
64
- const inner = getPath(parent, '$.' + paths[1]);
65
- return (Array.isArray(inner) ? inner : inner == null ? [] : [inner]).map((item) => ({ item, parent }));
66
- });
67
- }
68
-
69
- /**
70
- * Every module event in one delivery.
71
- * @param {object} manifest the module's capability manifest
72
- * @param {{ body, headers?, query? }} delivery
73
- * @returns {Array<{ topic: string, providerTopic: string, eventId: string|null, account: string|null, event: object, record: any }>}
74
- * topic is the manifest's event key (what definitions name); event is the item the workflow sees as $.event.
75
- */
76
- function eventsIn(manifest, delivery) {
77
- const webhook = manifest?.webhook;
78
- if (!webhook) return [];
79
- const input = prepare(webhook, delivery);
80
- const out = [];
81
- for (const { item, parent } of splitBatch(webhook, input)) {
82
- const ctx = { ...input, item, parent };
83
- const providerTopic = renderFirst(webhook.topic, ctx);
84
- if (!providerTopic) continue;
85
- const eventId = renderFirst(webhook.eventId, ctx) || null;
86
- const account = webhook.account ? renderFirst(webhook.account, ctx) || null : null;
87
- for (const [key, ev] of Object.entries(manifest.events || {})) {
88
- if (ev.delivery && ev.delivery !== 'webhook') continue;
89
- if ((ev.match ?? key) !== providerTopic) continue;
90
- const record = ev.record ? getPath(item, '$.' + ev.record) : item;
91
- if (ev.when && !evaluate(ev.when, { event: item, record, headers: input.headers, parent })) continue;
92
- out.push({ topic: key, providerTopic, eventId, account, event: item, record });
93
- }
94
- }
95
- return out;
96
- }
97
-
98
- /** A handshake to answer at receipt, before anything is queued: { status, headers, body } or null. */
99
- function handshakeFor(manifest, delivery) {
100
- const h = manifest?.webhook?.handshake;
101
- if (!h) return null;
102
- const ctx = prepare(manifest.webhook, delivery);
103
- if (!evaluate(h.when, ctx)) return null;
104
- return { status: h.status || 200, headers: resolve(h.headers || {}, ctx), body: h.body === undefined ? '' : resolve(h.body, ctx) };
105
- }
106
-
107
- /**
108
- * Which module of a definition a delivery came from. The URL names it (?module=<module name>); without that, the one
109
- * module whose manifest recognises an event in the body. Returns module keys of the definition (usually one).
110
- */
111
- function modulesFor(def, manifests, delivery) {
112
- const named = delivery.query?.module;
113
- const keys = Object.entries(def.modules || {}).filter(([, m]) => (named ? m.module === named : true)).map(([k]) => k);
114
- if (named) return keys;
115
- const recognised = keys.filter((k) => eventsIn(manifests[def.modules[k].module], delivery).length > 0);
116
- return recognised.length === 1 ? recognised : [];
117
- }
118
-
119
- module.exports = { eventsIn, handshakeFor, modulesFor, splitBatch, renderFirst, normaliseHeaders };
@@ -1,73 +0,0 @@
1
- /**
2
- * Integration types: how an installation's `config.type` becomes the class that runs it.
3
- *
4
- * Most types are classes the app registers at startup (appDefinition.integrations). A registered class may also be
5
- * a provider: `static IntegrationProvider = { resolve(type), list() }` answers for types that exist as data rather
6
- * than code (published integration definitions). Those resolve to classes of their own, with their own name, so a
7
- * caller cannot tell how an integration was made; only the deployment underneath (queue, functions) is shared.
8
- *
9
- * Registered classes win over provided ones, so a published definition can never shadow code. When one would have,
10
- * that is logged (once per name per process) as `frigg.integrations.definition.shadowed_by_code`: the definition is
11
- * dead weight and whoever published it expects it to be installable.
12
- */
13
- const { getLogger } = require('../logs');
14
-
15
- const warnedShadowed = new Set();
16
-
17
- /** A definition is ignored because registered code has the same name. Logging must never break a listing. */
18
- function warnShadowed(name, provider) {
19
- if (warnedShadowed.has(name)) return;
20
- warnedShadowed.add(name);
21
- try {
22
- getLogger('frigg.integrations').warn('A published integration definition is ignored because registered code has the same name', {
23
- eventName: 'frigg.integrations.definition.shadowed_by_code',
24
- integrationName: name,
25
- provider: provider?.name || null,
26
- });
27
- } catch { /* ignore */ }
28
- }
29
-
30
- /**
31
- * The class for a type, or null.
32
- * @param {object} [context] who is asking. With `workspaceId`, a type built in another workspace does not resolve:
33
- * what a user may install or see. Without it (hydrating an installation that exists) every type resolves.
34
- */
35
- async function resolveIntegrationClass(integrationClasses, type, context) {
36
- if (!type) return null;
37
- const registered = integrationClasses.find((c) => c?.Definition?.name === type);
38
- if (registered) return (await isVisible(registered, context)) ? registered : null;
39
- for (const c of integrationClasses) {
40
- if (!c?.IntegrationProvider) continue;
41
- const provided = await c.IntegrationProvider.resolve(type, context);
42
- if (provided) return provided;
43
- }
44
- return null;
45
- }
46
-
47
- /**
48
- * Whether a registered class is offered to whoever is asking: `static visible(context)` (defineIntegration's
49
- * `visible`, ADR-063 §4). Without a context (hydrating an installation that exists) every class is visible.
50
- */
51
- async function isVisible(IntegrationClass, context) {
52
- if (!context || typeof IntegrationClass?.visible !== 'function') return true;
53
- return Boolean(await IntegrationClass.visible(context));
54
- }
55
-
56
- /**
57
- * Every type a user can install: the registered classes, except providers (which are hosts, not integrations),
58
- * plus every type each provider offers.
59
- */
60
- async function listIntegrationClasses(integrationClasses, context) {
61
- const registered = new Set(integrationClasses.map((c) => c?.Definition?.name));
62
- const out = [];
63
- for (const c of integrationClasses) {
64
- if (!c?.IntegrationProvider) { if (await isVisible(c, context)) out.push(c); continue; }
65
- for (const provided of await c.IntegrationProvider.list(context)) {
66
- if (registered.has(provided.Definition.name)) warnShadowed(provided.Definition.name, c);
67
- else out.push(provided);
68
- }
69
- }
70
- return out;
71
- }
72
-
73
- module.exports = { resolveIntegrationClass, listIntegrationClasses, _resetShadowWarningsForTests: () => warnedShadowed.clear() };
@@ -1,244 +0,0 @@
1
- /**
2
- * Recurring triggers (ADR-055): what an integration declares, how one installation resolves it, and whether it is
3
- * due at a tick. Pure; no I/O. The tick itself is `use-cases/run-trigger-tick.js`; the run is
4
- * `IntegrationBase.onTriggerRun`.
5
- *
6
- * static Definition = {
7
- * name: 'housecallpro',
8
- * triggers: {
9
- * pollErp: { every: '15m', handler: 'pollErp' }, // every installation
10
- * ongoingSync: { every: '1h', configurable: { min: '15m' }, handler: sync }, // an installation may retime it
11
- * perPlan: { every: (config) => config.pollEvery, when: (config) => config.poll !== false, handler },
12
- * digest: { cron: '0 8 * * 1-5', timezone: 'America/New_York', configurable: true, handler },
13
- * nightly: { at: 'cron(0 3 * * ? *)', timezone: 'Europe/London', handler }, // same time for everyone
14
- * },
15
- * };
16
- *
17
- * Nothing is created per installation. Devtools provisions one function per integration with one schedule per
18
- * distinct tick and one per `at` trigger (`provisioningOf`). At each tick the function pages through the ENABLED
19
- * installations and sends `{ event: 'FRIGG_TRIGGER_RUN', data: { integrationId, trigger, scheduledFor } }` for each
20
- * one that is due. A disabled or deleted installation is simply not due, and a config change applies at the next tick.
21
- *
22
- * Two additions to ADR-055 (its "Amendment (ADR-063)"):
23
- * - `configurable: true | { min }` on an `every` or `cron` trigger lets an installation choose its own timing at
24
- * `config.triggers.<name>` (`{ every }`, `{ cron, timezone }` or `{ enabled: false }`), never more often than `min`.
25
- * A choice that breaks the floor, or a trigger that is not configurable, keeps the default (and is reported).
26
- * - `cron` (five fields, with `timezone`) is a calendar trigger that may differ per installation. It runs off the
27
- * shared tick: due when a firing falls in (previous tick, this tick]. `at` stays the ADR-055 calendar trigger,
28
- * the same for every installation, with an exact schedule of its own.
29
- */
30
- const { parseEvery, validateRecurrence, minGapMinutes, lastFiringIn, isTimeZone } = require('../infrastructure/scheduler/recurrence');
31
-
32
- const TRIGGER_RUN = 'FRIGG_TRIGGER_RUN';
33
- /** A tick that ran out of time sends this to the integration's queue to carry on from its page cursor. */
34
- const TRIGGER_TICK = 'FRIGG_TRIGGER_TICK';
35
-
36
- const DEFAULT_TICK_MINUTES = 5;
37
- const MAX_EVERY_MINUTES = 1440; // longer cadences use a calendar (`at` / `cron`)
38
- const AT = /^cron\(\S+ \S+ \S+ \S+ \S+ \S+\)$/;
39
- const OVERLAP = ['skip', 'allow'];
40
-
41
- const configurableOf = (c) => (c ? (c === true ? {} : c) : false);
42
- const kindOf = (t) => (t.at !== undefined ? 'at' : t.cron !== undefined ? 'cron' : 'every');
43
- const recurrenceOf = (r) => (r?.every !== undefined ? { every: r.every } : r?.cron !== undefined ? { cron: r.cron, ...(r.timezone ? { timezone: r.timezone } : {}) } : null);
44
-
45
- /** A minutes count as the shortest duration string: 90 -> '90m', 60 -> '1h', 1440 -> '1d'. */
46
- function durationOf(minutes) {
47
- if (minutes % 1440 === 0) return `${minutes / 1440}d`;
48
- if (minutes % 60 === 0) return `${minutes / 60}h`;
49
- return `${minutes}m`;
50
- }
51
-
52
- /**
53
- * The tick a trigger runs on, in minutes. `tick` when set. Otherwise: for a constant `every`, that interval; for an
54
- * `every` given as a function, 5 minutes; for a `cron`, 5 minutes, or less when the cron itself fires more often.
55
- * A configurable trigger never ticks slower than its floor (or 5 minutes, without one), so the fastest choice it
56
- * allows can run. `at` triggers have no tick (null): each has its own schedule.
57
- */
58
- function tickMinutesOf(trigger) {
59
- if (kindOf(trigger) === 'at') return null;
60
- if (trigger.tick !== undefined) return parseEvery(trigger.tick);
61
- const configurable = configurableOf(trigger.configurable);
62
- const floor = configurable ? (configurable.min !== undefined ? parseEvery(configurable.min) : DEFAULT_TICK_MINUTES) : Infinity;
63
- let natural;
64
- if (kindOf(trigger) === 'cron') {
65
- const gap = minGapMinutes({ cron: trigger.cron });
66
- natural = Math.max(1, Math.min(DEFAULT_TICK_MINUTES, gap ?? DEFAULT_TICK_MINUTES));
67
- } else {
68
- natural = typeof trigger.every === 'function' ? DEFAULT_TICK_MINUTES : parseEvery(trigger.every) ?? DEFAULT_TICK_MINUTES;
69
- }
70
- return Math.max(1, Math.min(natural, floor ?? DEFAULT_TICK_MINUTES, MAX_EVERY_MINUTES));
71
- }
72
-
73
- /**
74
- * Problems with a trigger declaration, found without an installation (definition time and build time).
75
- * @returns {string[]}
76
- */
77
- function validateTrigger(name, t) {
78
- const at = (m) => `${name}: ${m}`;
79
- if (!t || typeof t !== 'object') return [at('A trigger is an object')];
80
- const problems = [];
81
- const kinds = ['every', 'at', 'cron'].filter((k) => t[k] !== undefined);
82
- if (kinds.length !== 1) return [at('Give exactly one of every, at or cron')];
83
- if (typeof t.handler !== 'function' && typeof t.handler !== 'string') problems.push(at('handler must be a function or a method name'));
84
- if (t.overlap !== undefined && !OVERLAP.includes(t.overlap)) problems.push(at(`overlap must be ${OVERLAP.join(' or ')}`));
85
- if (t.when !== undefined && typeof t.when !== 'function') problems.push(at('when must be a function of the installation config'));
86
- if (t.tick !== undefined) {
87
- const tick = parseEvery(t.tick);
88
- if (tick === null || tick > MAX_EVERY_MINUTES) problems.push(at(`tick "${t.tick}" is not a duration between 1m and 1d`));
89
- }
90
- const kind = kinds[0];
91
- if (kind === 'at') {
92
- if (typeof t.at !== 'string' || !AT.test(t.at)) problems.push(at(`at "${t.at}" must be an EventBridge cron(...) expression with six fields`));
93
- if (t.timezone !== undefined && !isTimeZone(t.timezone)) problems.push(at(`Unknown time zone "${t.timezone}"`));
94
- if (t.configurable) problems.push(at('An at trigger runs at one time for every installation; use cron to let installations choose'));
95
- if (t.tick !== undefined) problems.push(at('An at trigger has its own schedule; tick does not apply'));
96
- return problems;
97
- }
98
- const configurable = configurableOf(t.configurable);
99
- if (configurable && configurable.min !== undefined && parseEvery(configurable.min) === null) problems.push(at(`configurable.min "${configurable.min}" is not a duration (15m, 2h, 1d)`));
100
- if (kind === 'every' && typeof t.every === 'function') return problems;
101
- const recurrence = recurrenceOf(t);
102
- for (const p of validateRecurrence(recurrence)) problems.push(at(p));
103
- if (kind === 'every' && parseEvery(t.every) > MAX_EVERY_MINUTES) problems.push(at(`every "${t.every}" is longer than 1d; use at or cron`));
104
- if (!problems.length && configurable && configurable.min !== undefined) {
105
- for (const p of validateRecurrence(recurrence, { min: configurable.min })) problems.push(at(`The default breaks its own minimum: ${p}`));
106
- }
107
- return problems;
108
- }
109
-
110
- /** Problems with every trigger a Definition declares. */
111
- function validateTriggers(triggers = {}) {
112
- return Object.entries(triggers || {}).flatMap(([name, t]) => validateTrigger(name, t));
113
- }
114
-
115
- /**
116
- * What devtools provisions for an integration class: the distinct ticks (minutes) of its `every`/`cron` triggers,
117
- * including the tick of the triggers it resolves per installation (`Definition.instanceTriggers`), and one entry per
118
- * `at` trigger. Throws on an invalid declaration, so a bad trigger fails the build rather than the first tick.
119
- *
120
- * @returns {{ ticks: number[], at: Array<{ name, at, timezone? }> }}
121
- */
122
- function provisioningOf(IntegrationClass) {
123
- const Definition = IntegrationClass?.Definition || {};
124
- const triggers = Definition.triggers || {};
125
- const problems = validateTriggers(triggers);
126
- const instance = Definition.instanceTriggers;
127
- if (instance && parseEvery(instance.tick) === null) problems.push(`instanceTriggers.tick "${instance.tick}" is not a duration`);
128
- if (problems.length) throw new Error(`Integration "${Definition.name}" declares invalid triggers: ${problems.join('; ')}`);
129
- const ticks = new Set();
130
- const at = [];
131
- for (const [name, t] of Object.entries(triggers)) {
132
- if (kindOf(t) === 'at') at.push({ name, at: t.at, ...(t.timezone ? { timezone: t.timezone } : {}) });
133
- else ticks.add(tickMinutesOf(t));
134
- }
135
- if (instance) ticks.add(parseEvery(instance.tick));
136
- return { ticks: [...ticks].sort((a, b) => a - b), at };
137
- }
138
-
139
- /** Does this class declare any triggers (fixed or per installation)? */
140
- function hasTriggers(IntegrationClass) {
141
- const Definition = IntegrationClass?.Definition || {};
142
- return Object.keys(Definition.triggers || {}).length > 0 || Boolean(Definition.instanceTriggers);
143
- }
144
-
145
- /**
146
- * One trigger, resolved for one installation.
147
- *
148
- * @param {string} name
149
- * @param {Object} t - the declaration (plus `choice` when the caller found the installation's choice elsewhere)
150
- * @param {Object} config - the installation's config
151
- * @returns {{ name, kind, recurrence?, at?, tickMinutes, cadence?, overlap, active: boolean, inactive?: string,
152
- * source: 'definition'|'installation', problems: string[] }}
153
- * `active: false` when `when` excludes the installation or it paused a configurable trigger.
154
- * An invalid choice keeps the default and is listed in `problems`, so a bad edit never stops a trigger.
155
- */
156
- function resolveTrigger(name, t, config = {}) {
157
- const kind = kindOf(t);
158
- const out = { name, kind, tickMinutes: tickMinutesOf(t), overlap: t.overlap || 'skip', active: true, source: 'definition', problems: [] };
159
- if (typeof t.when === 'function' && !t.when(config)) return { ...out, active: false, inactive: 'when' };
160
- if (kind === 'at') return { ...out, at: t.at, timezone: t.timezone };
161
-
162
- let recurrence = kind === 'every' ? { every: typeof t.every === 'function' ? t.every(config) : t.every } : recurrenceOf(t);
163
- const configurable = configurableOf(t.configurable);
164
- const choice = t.choice !== undefined ? t.choice : config?.triggers?.[name];
165
- if (choice && typeof choice === 'object' && (recurrenceOf(choice) || choice.enabled !== undefined)) {
166
- if (!configurable) out.problems.push('This trigger is not configurable');
167
- else {
168
- const picked = recurrenceOf(choice);
169
- const issues = picked ? validateRecurrence(picked, { min: configurable.min }) : [];
170
- if (picked?.every !== undefined && !issues.length && parseEvery(picked.every) > MAX_EVERY_MINUTES) issues.push(`every "${picked.every}" is longer than 1d`);
171
- if (issues.length) out.problems.push(...issues);
172
- else {
173
- if (picked) { recurrence = picked; out.source = 'installation'; }
174
- if (choice.enabled === false) return { ...out, recurrence, active: false, inactive: 'paused' };
175
- }
176
- }
177
- }
178
- const own = validateRecurrence(recurrence);
179
- if (recurrence.every !== undefined && !own.length && parseEvery(recurrence.every) > MAX_EVERY_MINUTES) own.push(`every "${recurrence.every}" is longer than 1d`);
180
- if (own.length) return { ...out, recurrence, active: false, inactive: 'invalid', problems: [...out.problems, ...own] };
181
- out.recurrence = recurrence;
182
- // An interval runs every n ticks (rounded up to a whole number of ticks).
183
- if (recurrence.every !== undefined) out.cadence = Math.max(1, Math.ceil(parseEvery(recurrence.every) / out.tickMinutes));
184
- return out;
185
- }
186
-
187
- /** A stable, evenly spread number for an installation id (FNV-1a, 32 bits). */
188
- function hashId(value) {
189
- let h = 0x811c9dc5;
190
- for (const ch of String(value)) {
191
- h ^= ch.codePointAt(0);
192
- h = Math.imul(h, 0x01000193) >>> 0;
193
- }
194
- return h;
195
- }
196
-
197
- /** The start of the tick slot an instant falls in: tick runs are keyed by slot, so a late or repeated tick agrees. */
198
- function slotStart(timeMs, tickMinutes) {
199
- const size = tickMinutes * 60000;
200
- return Math.floor(timeMs / size) * size;
201
- }
202
-
203
- /**
204
- * Is a resolved trigger due for this installation at this tick slot?
205
- *
206
- * - every: due when `slot mod n == hash(integrationId) mod n` (n = cadence in ticks), so it is stateless and spreads
207
- * installations evenly across the cadence. scheduledFor is the slot start.
208
- * - cron: due when it fired in (slot start - tick, slot start]. scheduledFor is that firing, so two ticks in one slot
209
- * (a redelivery, a retried continuation) name the same run.
210
- * - at: due whenever its own schedule invoked the tick; scheduledFor is that time, to the minute.
211
- *
212
- * @param {Object} resolved - from resolveTrigger
213
- * @param {{ integrationId, slotMs: number, firing?: (recurrence, from, to) => number|null }} at
214
- * @returns {{ scheduledFor: string } | null}
215
- */
216
- function dueRun(resolved, { integrationId, slotMs, firing = lastFiringIn }) {
217
- if (!resolved?.active) return null;
218
- if (resolved.kind === 'at') return { scheduledFor: new Date(Math.floor(slotMs / 60000) * 60000).toISOString() };
219
- if (resolved.recurrence.every !== undefined) {
220
- const n = resolved.cadence;
221
- const slot = Math.floor(slotMs / (resolved.tickMinutes * 60000));
222
- return slot % n === hashId(integrationId) % n ? { scheduledFor: new Date(slotMs).toISOString() } : null;
223
- }
224
- const fired = firing(resolved.recurrence, slotMs - resolved.tickMinutes * 60000, slotMs);
225
- return fired === null ? null : { scheduledFor: new Date(fired).toISOString() };
226
- }
227
-
228
- module.exports = {
229
- TRIGGER_RUN,
230
- TRIGGER_TICK,
231
- DEFAULT_TICK_MINUTES,
232
- configurableOf,
233
- recurrenceOf,
234
- durationOf,
235
- tickMinutesOf,
236
- validateTrigger,
237
- validateTriggers,
238
- provisioningOf,
239
- hasTriggers,
240
- resolveTrigger,
241
- hashId,
242
- slotStart,
243
- dueRun,
244
- };
@@ -1,155 +0,0 @@
1
- /**
2
- * The trigger tick (ADR-055 "Runtime / Tick"): one invocation of an integration's `{integration}Triggers` function.
3
- *
4
- * It is invoked by one of the integration's schedules: `{ tick: '5m', time }` for the shared tick of its `every` and
5
- * `cron` triggers, or `{ at: '<trigger name>', time }` for an `at` trigger. `time` is the schedule's own firing time,
6
- * so the slot it falls in, and therefore every due decision and every `scheduledFor`, is the same if the same tick
7
- * is delivered twice or carried on by a continuation.
8
- *
9
- * 1. Page through the ENABLED installations of this integration, `pageSize` (500) at a time, by id. A class that hosts
10
- * other installation types (`static triggerTypes()`, the shared runtime of published definitions) pages each.
11
- * 2. For each installation and trigger, resolve config, `when` and the cadence (integrations/triggers.js).
12
- * 3. Due: `every` when `slot mod n == hash(integrationId) mod n`; `cron` when it fired in (previous tick, this tick];
13
- * `at` always (it is its own schedule).
14
- * 4. Send `{ event: 'FRIGG_TRIGGER_RUN', data: { integrationId, trigger, scheduledFor } }` to the integration queue in
15
- * batches, with `DelaySeconds` jitter (a stable 0..min(tick, 900 s) per installation and trigger) for tick runs.
16
- * 5. Near the Lambda deadline, send `{ event: 'FRIGG_TRIGGER_TICK', data: { tick|at, time, cursor } }` to the same
17
- * queue and stop; the queue worker carries on from the cursor (IntegrationBase.onTriggerTick).
18
- *
19
- * Missed ticks are not replayed: a trigger is a "check now" signal, and handlers keep their own watermark.
20
- */
21
- const { parseEvery, lastFiringIn } = require('../../infrastructure/scheduler/recurrence');
22
- const { TRIGGER_RUN, TRIGGER_TICK, resolveTrigger, dueRun, slotStart, hashId } = require('../triggers');
23
-
24
- const PAGE_SIZE = 500;
25
- const DEADLINE_MARGIN_MS = 30000;
26
- const MAX_DELAY_SECONDS = 900; // SQS DelaySeconds ceiling
27
-
28
- class RunTriggerTick {
29
- /**
30
- * @param {Object} deps
31
- * @param {Function} deps.integrationClass - the IntegrationBase subclass (Definition.name, triggersFor, optional triggerTypes)
32
- * @param {Object} deps.integrationRepository - findIntegrations({ type, status, limit, after })
33
- * @param {Object} deps.queue - { batchSend(entries, queueUrl, { delaySeconds }), send(message, queueUrl) }
34
- * @param {string} deps.queueUrl
35
- * @param {Object} [deps.logger]
36
- * @param {number} [deps.pageSize]
37
- */
38
- constructor({ integrationClass, integrationRepository, queue, queueUrl, logger, pageSize = PAGE_SIZE }) {
39
- this.Integration = integrationClass;
40
- this.repository = integrationRepository;
41
- this.queue = queue;
42
- this.queueUrl = queueUrl;
43
- this.logger = logger;
44
- this.pageSize = pageSize;
45
- }
46
-
47
- /**
48
- * @param {Object} params
49
- * @param {string} [params.tick] - '5m': evaluate the every/cron triggers on this tick
50
- * @param {string} [params.at] - an `at` trigger's name: run it for every ENABLED installation
51
- * @param {string} [params.time] - the schedule's firing time (ISO); defaults to now
52
- * @param {string|number|{type: string, after: string|number|null}} [params.cursor] - continue after this installation id
53
- * (in this type, when the class covers several)
54
- * @param {() => number} [params.remainingMs] - time left in this invocation
55
- * @returns {Promise<{ installations: number, sent: number, pages: number, continued: boolean, failed: Array }>}
56
- */
57
- async execute({ tick, at, time, cursor, remainingMs = () => Infinity }) {
58
- const name = this.Integration.Definition?.name;
59
- if (!this.queueUrl) throw new Error(`No queue URL for integration "${name}"; trigger runs have nowhere to go`);
60
- const tickMinutes = at ? null : parseEvery(tick);
61
- if (!at && tickMinutes === null) throw new Error(`Trigger tick "${tick}" is not a duration`);
62
- const timeMs = time && !Number.isNaN(Date.parse(time)) ? Date.parse(time) : Date.now();
63
- const slotMs = at ? Math.floor(timeMs / 60000) * 60000 : slotStart(timeMs, tickMinutes);
64
- const firings = new Map(); // one cron answer per recurrence per tick (the window is the same), not per installation
65
- const firing = (recurrence, from, to) => {
66
- const key = `${recurrence.cron}|${recurrence.timezone || 'UTC'}`;
67
- if (!firings.has(key)) firings.set(key, lastFiringIn(recurrence, from, to));
68
- return firings.get(key);
69
- };
70
- const delayOf = (m) => (at ? 0 : hashId(`${m.data.integrationId}:${m.data.trigger}`) % Math.min(tickMinutes * 60, MAX_DELAY_SECONDS));
71
-
72
- // The installation types this tick covers: the class's own, unless it hosts others (a shared runtime whose
73
- // published definitions are integration types of their own; static triggerTypes). With more than one, the
74
- // continuation cursor names the type it stopped in: `{ type, after }`.
75
- const types = typeof this.Integration.triggerTypes === 'function' ? await this.Integration.triggerTypes() : [name];
76
- let typeIndex = 0;
77
- let after = cursor;
78
- if (cursor && typeof cursor === 'object') {
79
- typeIndex = Math.max(0, types.indexOf(cursor.type));
80
- after = cursor.after ?? null;
81
- }
82
- const cursorAt = (type, id) => (types.length > 1 ? { type, after: id } : id);
83
- const continueAt = async (nextCursor) => {
84
- await this.queue.send({ event: TRIGGER_TICK, data: { ...(at ? { at } : { tick }), time: new Date(timeMs).toISOString(), cursor: nextCursor } }, this.queueUrl);
85
- };
86
-
87
- const out = { installations: 0, sent: 0, pages: 0, continued: false, failed: [] };
88
- for (;;) {
89
- const type = types[typeIndex];
90
- const rows = await this.repository.findIntegrations({ type, status: 'ENABLED', limit: this.pageSize, after });
91
- out.pages += 1;
92
- const messages = [];
93
- for (const row of rows) {
94
- out.installations += 1;
95
- try {
96
- const declarations = await this.Integration.triggersFor({ config: row.config || {}, record: row });
97
- for (const declaration of declarations) {
98
- const isAt = declaration.at !== undefined;
99
- if (at ? declaration.name !== at || !isAt : isAt) continue;
100
- const resolved = resolveTrigger(declaration.name, declaration, row.config || {});
101
- if (!at && resolved.tickMinutes !== tickMinutes) continue;
102
- const due = dueRun(resolved, { integrationId: row.id, slotMs, firing });
103
- if (due) messages.push({ event: TRIGGER_RUN, data: { integrationId: row.id, trigger: declaration.name, scheduledFor: due.scheduledFor } });
104
- }
105
- } catch (error) {
106
- // One installation's bad config must not stop the others' runs.
107
- out.failed.push({ integrationId: row.id, error: error.message });
108
- this.logger?.warn('Trigger tick skipped an installation', { eventName: 'frigg.triggers.installation_failed', integration: name, integrationId: row.id, error });
109
- }
110
- }
111
- if (messages.length) {
112
- await this.queue.batchSend(messages, this.queueUrl, { delaySeconds: delayOf });
113
- out.sent += messages.length;
114
- }
115
- if (rows.length < this.pageSize) {
116
- // This type is done; carry on with the next one, from its start.
117
- typeIndex += 1;
118
- if (typeIndex >= types.length) return out;
119
- after = null;
120
- } else {
121
- after = rows[rows.length - 1].id;
122
- }
123
- if (remainingMs() < DEADLINE_MARGIN_MS) {
124
- await continueAt(cursorAt(types[typeIndex], after));
125
- out.continued = true;
126
- return out;
127
- }
128
- }
129
- }
130
- }
131
-
132
- /** The queue URL Frigg's infrastructure gives an integration: `<NAME>_QUEUE_URL`, dashes as underscores. */
133
- function queueUrlFor(integrationName) {
134
- return process.env[`${String(integrationName).toUpperCase().replace(/-/g, '_')}_QUEUE_URL`];
135
- }
136
-
137
- /**
138
- * Run a tick with Frigg's real adapters: the integration repository, the SQS queuer and the integration's queue.
139
- * Used by the `{integration}Triggers` Lambda and by a continuation on the queue worker.
140
- */
141
- async function runTriggerTick(integrationClass, event = {}, context) {
142
- const { createIntegrationRepository } = require('../repositories/integration-repository-factory');
143
- const { QueuerUtil } = require('../../queues');
144
- const { getLogger } = require('../../logs');
145
- const remainingMs = typeof context?.getRemainingTimeInMillis === 'function' ? () => context.getRemainingTimeInMillis() : undefined;
146
- return new RunTriggerTick({
147
- integrationClass,
148
- integrationRepository: createIntegrationRepository(),
149
- queue: QueuerUtil,
150
- queueUrl: queueUrlFor(integrationClass.Definition.name),
151
- logger: getLogger('frigg.triggers'),
152
- }).execute({ tick: event.tick, at: event.at, time: event.time, cursor: event.cursor, remainingMs });
153
- }
154
-
155
- module.exports = { RunTriggerTick, runTriggerTick, queueUrlFor, PAGE_SIZE };