@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.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-encryption-service.js +9 -22
- package/database/documentdb-utils.js +2 -2
- package/database/encryption/README.md +78 -14
- package/database/encryption/documentdb-encryption-service.md +78 -1
- package/database/encryption/encryption-config.js +219 -0
- package/database/encryption/encryption-schema-registry.js +121 -24
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/integration-mapping-encryption.js +1 -1
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- package/database/organization-where.js +18 -0
- package/database/prisma.js +15 -38
- package/database/unique-violation.js +25 -0
- package/database/use-cases/check-encryption-health-use-case.js +43 -52
- package/errors/index.js +0 -4
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +230 -0
- package/extensions/index.js +44 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/prisma-composer.js +270 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +33 -42
- package/generated/prisma-mongodb/index-browser.js +30 -39
- package/generated/prisma-mongodb/index.d.ts +3187 -2005
- package/generated/prisma-mongodb/index.js +33 -42
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +86 -59
- package/generated/prisma-mongodb/wasm.js +33 -42
- package/generated/prisma-postgresql/edge.js +33 -42
- package/generated/prisma-postgresql/index-browser.js +30 -39
- package/generated/prisma-postgresql/index.d.ts +3380 -2177
- package/generated/prisma-postgresql/index.js +33 -42
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +81 -57
- package/generated/prisma-postgresql/wasm.js +33 -42
- package/handlers/WEBHOOKS.md +0 -29
- package/handlers/app-definition-loader.js +128 -19
- package/handlers/app-handler-helpers.js +27 -8
- package/handlers/backend-utils.js +31 -25
- package/handlers/integration-event-dispatcher.js +3 -7
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +46 -10
- package/handlers/routers/health.js +13 -4
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +100 -50
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +66 -16
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +1 -14
- package/integrations/integration-base.js +2 -163
- package/integrations/integration-router.js +132 -102
- package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
- package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
- package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
- package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
- package/integrations/repositories/integration-mapping-repository.js +0 -29
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +37 -9
- package/integrations/repositories/integration-repository-interface.js +17 -4
- package/integrations/repositories/integration-repository-mongo.js +64 -96
- package/integrations/repositories/integration-repository-postgres.js +63 -81
- package/integrations/repositories/process-repository-mongo.js +1 -1
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +56 -31
- package/integrations/use-cases/delete-integration-for-user.js +4 -4
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integration-for-user.js +2 -4
- package/integrations/use-cases/get-integration-instance.js +4 -4
- package/integrations/use-cases/get-integrations-for-user.js +79 -39
- package/integrations/use-cases/get-possible-integrations.js +2 -7
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +256 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/use-cases/update-integration.js +4 -4
- package/integrations/utils/map-integration-dto.js +43 -14
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +151 -0
- package/management-api/meta-router.js +76 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +415 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-bases.js +169 -0
- package/modules/proxy/proxy-errors.js +31 -0
- package/modules/proxy/proxy-path.js +183 -0
- package/modules/proxy/proxy-policy.js +277 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/requester/proxy-request.js +245 -0
- package/modules/requester/requester.js +35 -5
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +331 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +329 -0
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -7
- package/prisma-mongodb/schema.prisma +86 -59
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +81 -57
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/queuer-util.js +2 -9
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +214 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
- package/errors/mapping-already-exists-error.js +0 -14
- package/handlers/workers/integration-triggers.js +0 -25
- package/infrastructure/scheduler/recurrence.js +0 -204
- package/integrations/define/DESIGN.md +0 -122
- package/integrations/define/config.js +0 -98
- package/integrations/define/define-app.js +0 -66
- package/integrations/define/define-extension.js +0 -154
- package/integrations/define/define-integration.js +0 -335
- package/integrations/define/examples/deal-alerts.js +0 -140
- package/integrations/define/extension-binding.js +0 -174
- package/integrations/define/index.js +0 -11
- package/integrations/define/json-schema-lite.js +0 -68
- package/integrations/define/subscriptions.js +0 -186
- package/integrations/define/tests/fixtures/hubspot.js +0 -109
- package/integrations/define/tests/fixtures/slack.js +0 -26
- package/integrations/define/use.js +0 -39
- package/integrations/definitions/README.md +0 -278
- package/integrations/definitions/canonical.js +0 -11
- package/integrations/definitions/capabilities/AUDIT.md +0 -480
- package/integrations/definitions/capabilities/acmeerp.capabilities.json +0 -633
- package/integrations/definitions/capabilities/asana.capabilities.json +0 -279
- package/integrations/definitions/capabilities/calendly.capabilities.json +0 -385
- package/integrations/definitions/capabilities/docusign.capabilities.json +0 -314
- package/integrations/definitions/capabilities/fathom.capabilities.json +0 -238
- package/integrations/definitions/capabilities/github.capabilities.json +0 -585
- package/integrations/definitions/capabilities/hubspot.capabilities.json +0 -822
- package/integrations/definitions/capabilities/jira.capabilities.json +0 -391
- package/integrations/definitions/capabilities/klaviyo.capabilities.json +0 -503
- package/integrations/definitions/capabilities/linear.capabilities.json +0 -378
- package/integrations/definitions/capabilities/netsuite.capabilities.json +0 -535
- package/integrations/definitions/capabilities/notion.capabilities.json +0 -208
- package/integrations/definitions/capabilities/phone.capabilities.json +0 -511
- package/integrations/definitions/capabilities/pipedrive.capabilities.json +0 -430
- package/integrations/definitions/capabilities/quickbooks.capabilities.json +0 -635
- package/integrations/definitions/capabilities/salesforce.capabilities.json +0 -568
- package/integrations/definitions/capabilities/shopify.capabilities.json +0 -1128
- package/integrations/definitions/capabilities/slack.capabilities.json +0 -373
- package/integrations/definitions/capabilities/stripe.capabilities.json +0 -630
- package/integrations/definitions/capabilities/webflow.capabilities.json +0 -196
- package/integrations/definitions/capabilities/zendesk.capabilities.json +0 -422
- package/integrations/definitions/compile.js +0 -80
- package/integrations/definitions/config-form.js +0 -137
- package/integrations/definitions/examples/acme-erp-quickbooks.definition.json +0 -467
- package/integrations/definitions/examples/asana-slack.definition.json +0 -219
- package/integrations/definitions/examples/calendly-salesforce.definition.json +0 -459
- package/integrations/definitions/examples/docusign-salesforce.definition.json +0 -305
- package/integrations/definitions/examples/fathom-hubspot-notion.definition.json +0 -575
- package/integrations/definitions/examples/github-linear.definition.json +0 -322
- package/integrations/definitions/examples/hubspot-slack.definition.json +0 -427
- package/integrations/definitions/examples/klaviyo-hubspot.definition.json +0 -336
- package/integrations/definitions/examples/phone-hubspot.definition.json +0 -633
- package/integrations/definitions/examples/pipedrive-hubspot.definition.json +0 -136
- package/integrations/definitions/examples/pipedrive-phone.definition.json +0 -340
- package/integrations/definitions/examples/records/phone-hubspot.definition-version.json +0 -652
- package/integrations/definitions/examples/records/phone-hubspot.integration.json +0 -86
- package/integrations/definitions/examples/records/phone-hubspot.mappings.json +0 -39
- package/integrations/definitions/examples/records/phone-hubspot.workflow-run.json +0 -62
- package/integrations/definitions/examples/shopify-klaviyo.definition.json +0 -488
- package/integrations/definitions/examples/shopify-netsuite.definition.json +0 -622
- package/integrations/definitions/examples/stripe-quickbooks.definition.json +0 -476
- package/integrations/definitions/examples/webflow-hubspot-slack.definition.json +0 -262
- package/integrations/definitions/examples/zendesk-jira.definition.json +0 -604
- package/integrations/definitions/expressions.js +0 -229
- package/integrations/definitions/index.js +0 -48
- package/integrations/definitions/integration-runtime.js +0 -779
- package/integrations/definitions/interpreter.js +0 -104
- package/integrations/definitions/manifests.js +0 -177
- package/integrations/definitions/renderers/common.js +0 -67
- package/integrations/definitions/renderers/html.js +0 -129
- package/integrations/definitions/renderers/hubspot.js +0 -59
- package/integrations/definitions/renderers/preview.js +0 -154
- package/integrations/definitions/renderers/slack.js +0 -146
- package/integrations/definitions/repositories/definition-version-repository.js +0 -67
- package/integrations/definitions/repositories/workflow-run-repository.js +0 -80
- package/integrations/definitions/router.js +0 -170
- package/integrations/definitions/runtime.js +0 -268
- package/integrations/definitions/schema/capability-manifest.v0.1.schema.json +0 -549
- package/integrations/definitions/schema/installation.v0.1.schema.json +0 -130
- package/integrations/definitions/schema/integration-definition.v0.1.schema.json +0 -909
- package/integrations/definitions/schema/records.md +0 -105
- package/integrations/definitions/schema-check.js +0 -58
- package/integrations/definitions/scripts/check-manifests.js +0 -220
- package/integrations/definitions/scripts/validate.js +0 -66
- package/integrations/definitions/steps.js +0 -123
- package/integrations/definitions/sync-engine.js +0 -297
- package/integrations/definitions/tests/helpers-fake-crm.js +0 -49
- package/integrations/definitions/transforms.js +0 -91
- package/integrations/definitions/use-cases/definition-use-cases.js +0 -245
- package/integrations/definitions/validate.js +0 -436
- package/integrations/definitions/versioning.js +0 -176
- package/integrations/definitions/webhooks.js +0 -119
- package/integrations/integration-types.js +0 -73
- package/integrations/triggers.js +0 -244
- package/integrations/use-cases/run-trigger-tick.js +0 -155
- package/integrations/use-cases/run-trigger.js +0 -77
- package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +0 -66
- package/prisma-postgresql/migrations/20261019000000_workflow_run_waits/migration.sql +0 -7
- 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() };
|
package/integrations/triggers.js
DELETED
|
@@ -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 };
|