@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,779 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* IntegrationRuntime: one IntegrationBase subclass that runs any published definition.
|
|
3
|
-
*
|
|
4
|
-
* Frigg registers integration classes at startup (appDefinition.integrations). For integrations published as definitions the app
|
|
5
|
-
* registers one generated class per definition id:
|
|
6
|
-
*
|
|
7
|
-
* const { defineIntegrationClass } = require('@friggframework/core/integrations/definitions');
|
|
8
|
-
* integrations: [ defineIntegrationClass({ id: 'phone-hubspot', definitions, manifests, modules: { phone: PhoneModule, hubspot: HubSpotModule } }) ]
|
|
9
|
-
*
|
|
10
|
-
* The class's static Definition (name, modules) is what the infrastructure needs, and it is the same for every
|
|
11
|
-
* version, so publishing a new version deploys nothing. Each installation loads the exact version it pins
|
|
12
|
-
* (record.config.definition.version) when it is hydrated, compiles it with that installation's choices, and gets an
|
|
13
|
-
* ordinary `this.events` map. Everything after that is the existing Frigg machinery: the event dispatcher, the
|
|
14
|
-
* queue worker, the scheduler and the mapping repository.
|
|
15
|
-
*
|
|
16
|
-
* Ports used: definitions.get(id, version) -> definition doc (immutable; cache freely)
|
|
17
|
-
* runCode(ref, args) -> registered code steps (optional)
|
|
18
|
-
*/
|
|
19
|
-
const crypto = require('crypto');
|
|
20
|
-
const { compile, routeWebhook, eventName } = require('./compile');
|
|
21
|
-
const { argumentsFor, makePorts, runWorkflow, resumeWorkflow, runQuery, renderSurface, runLifecycle, resolveOptions, installContext } = require('./runtime');
|
|
22
|
-
const { createSyncEngine } = require('./sync-engine');
|
|
23
|
-
const { eventsIn, handshakeFor, modulesFor } = require('./webhooks');
|
|
24
|
-
const { buildConfigForm, missingRequired } = require('./config-form');
|
|
25
|
-
|
|
26
|
-
/** The tick every trigger of a published definition runs on: a chosen interval is rounded up to whole multiples of it. */
|
|
27
|
-
const INSTANCE_TICK = '5m';
|
|
28
|
-
|
|
29
|
-
/** A compiled definition's triggers as IntegrationBase trigger declarations: each runs its compiled event. */
|
|
30
|
-
function triggersOf(def, config) {
|
|
31
|
-
return compile(def, config).triggers.map((t) => ({
|
|
32
|
-
...t,
|
|
33
|
-
tick: INSTANCE_TICK,
|
|
34
|
-
handler(payload) {
|
|
35
|
-
const event = this.events?.[t.name];
|
|
36
|
-
if (!event) throw new Error(`Trigger "${t.name}" has no compiled event in this installation`);
|
|
37
|
-
return event.handler({ data: { integrationId: this.id, ...payload } });
|
|
38
|
-
},
|
|
39
|
-
}));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Shape A — one class per definition id (defineIntegrationClass): the class name and modules come from the
|
|
44
|
-
* definition. Publishing a new VERSION deploys nothing; a new definition ID is a new class (a deploy, and its own
|
|
45
|
-
* functions and queue). Right for a product team with a handful of integrations.
|
|
46
|
-
*
|
|
47
|
-
* Shape B — one class for every definition (defineSharedIntegrationClass): registered once as "integration-runtime", its
|
|
48
|
-
* static modules are every API module the app has installed, and each installation names its definition id and
|
|
49
|
-
* version in config. A new integration is a row, not a deploy, and every installation shares one set of functions
|
|
50
|
-
* and one queue. Right for a multi-tenant product, where definitions accumulate without bound.
|
|
51
|
-
*/
|
|
52
|
-
function defineIntegrationClass({ id, definitions, manifests, modules, runCode, IntegrationBase, lookup, runs }) {
|
|
53
|
-
const latest = definitions.latest(id);
|
|
54
|
-
if (!latest || typeof latest.then === 'function') throw new Error(`defineIntegrationClass needs the definitions of "${id}" synchronously at registration (bundled files); a database store goes with defineSharedIntegrationClass`);
|
|
55
|
-
return makeClass({
|
|
56
|
-
name: latest.id, defaultId: id, definitions, manifests, runCode, IntegrationBase, lookup, runs,
|
|
57
|
-
modules: Object.fromEntries(Object.keys(latest.modules).map((key) => [key, { definition: modules[key] }])),
|
|
58
|
-
display: { label: latest.display.label, description: latest.display.description, category: latest.display.category },
|
|
59
|
-
version: latest.version,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function defineSharedIntegrationClass({ name = 'integration-runtime', definitions, manifests, modules, runCode, IntegrationBase, lookup, runs }) {
|
|
64
|
-
return makeClass({
|
|
65
|
-
name, defaultId: null, definitions, manifests, runCode, IntegrationBase, lookup, runs,
|
|
66
|
-
modules: Object.fromEntries(Object.entries(modules).map(([key, definition]) => [key, { definition }])),
|
|
67
|
-
display: { label: 'Integration runtime', description: 'Runs any published integration definition.', category: 'Custom' },
|
|
68
|
-
version: '0.1.0',
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* App-level webhooks (one URL for every account: HubSpot, Stripe Connect, GitHub Apps) arrive with no integration id.
|
|
74
|
-
* The default lookup finds the installations of THIS class connected to that account, using Frigg's commands: the
|
|
75
|
-
* entity reverse lookup, then each candidate's record, because one account can be used by integrations of other
|
|
76
|
-
* types too, and the worker would hydrate those as their own class.
|
|
77
|
-
*/
|
|
78
|
-
/**
|
|
79
|
-
* A code integration that also embeds a definition: the class keeps its own name, modules, handlers and lifecycle,
|
|
80
|
-
* and gains what a published integration has — workflows (with delay and approval pauses), syncs, the generated
|
|
81
|
-
* configure form, UI surfaces, versions and run history — through the same records and routes. Code is the escape
|
|
82
|
-
* hatch inside it: a `code` step whose ref is `#<method>` calls that method on the integration.
|
|
83
|
-
*
|
|
84
|
-
* class HubspotSlack extends IntegrationBase { static Definition = { name: 'hubspot-slack', modules, … }; … }
|
|
85
|
-
* module.exports = extendIntegrationClass(HubspotSlack, { definition: require('./hubspot-slack.definition.json'), manifests });
|
|
86
|
-
*
|
|
87
|
-
* What the definition does not declare stays the code's: with no webhook-triggered workflow or sync the class's own
|
|
88
|
-
* webhook handling runs; with no settings, its own configure form; its own triggers run beside the compiled ones.
|
|
89
|
-
*/
|
|
90
|
-
function extendIntegrationClass(CodeClass, { definition, manifests, runCode, runs, lookup }) {
|
|
91
|
-
const name = CodeClass?.Definition?.name;
|
|
92
|
-
if (!name) throw new Error('extendIntegrationClass needs an IntegrationBase subclass with a static Definition.name');
|
|
93
|
-
if (!definition || definition.id !== name) throw new Error(`The definition embedded in "${name}" must have the id "${name}" (it has "${definition?.id}")`);
|
|
94
|
-
const { validate } = require('./validate');
|
|
95
|
-
const codeMethods = [];
|
|
96
|
-
for (let proto = CodeClass.prototype; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
|
|
97
|
-
for (const k of Object.getOwnPropertyNames(proto)) if (k !== 'constructor' && typeof Object.getOwnPropertyDescriptor(proto, k).value === 'function') codeMethods.push(k);
|
|
98
|
-
}
|
|
99
|
-
const problems = (validate(definition, manifests, { codeMethods }).errors || []);
|
|
100
|
-
if (problems.length) throw new Error(`The definition embedded in "${name}" is not valid: ${problems.map((e) => `${e.path || ''} ${e.message}`.trim()).join('; ')}`);
|
|
101
|
-
return makeClass({
|
|
102
|
-
name, defaultId: name, definitions: memoryDefinitions([definition]), manifests, runCode, IntegrationBase: CodeClass, lookup, runs,
|
|
103
|
-
modules: CodeClass.Definition.modules, display: CodeClass.Definition.display, version: CodeClass.Definition.version, embedded: true,
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Whether a definition has recurring work of its own (a schedule-triggered workflow or a polling sync). */
|
|
108
|
-
const hasRecurring = (def) => Boolean((def?.workflows || []).some((w) => w.trigger?.type === 'schedule') || (def?.syncs || []).some((sy) => sy.ongoing?.mode === 'poll'));
|
|
109
|
-
/** Whether a definition receives webhooks itself (a webhook-triggered workflow or a webhook-driven sync). */
|
|
110
|
-
const ownsWebhooks = (def) => Boolean((def?.workflows || []).some((w) => w.trigger?.type === 'webhook') || (def?.syncs || []).some((sy) => sy.ongoing?.mode === 'webhook'));
|
|
111
|
-
/** Whether a definition generates the configure form (it declares settings, at its level or a workflow's). */
|
|
112
|
-
const ownsConfigForm = (def) => Boolean(Object.keys(def?.settings || {}).length || (def?.workflows || []).some((w) => Object.keys(w.settings || {}).length));
|
|
113
|
-
|
|
114
|
-
function friggLookup(accepts) {
|
|
115
|
-
return async (externalId, moduleName) => {
|
|
116
|
-
const { createIntegrationCommands } = require('../../application/commands/integration-commands');
|
|
117
|
-
const commands = createIntegrationCommands();
|
|
118
|
-
const ids = await commands.listIntegrationsByEntityExternalId(externalId, moduleName);
|
|
119
|
-
const out = [];
|
|
120
|
-
for (const integrationId of ids) {
|
|
121
|
-
const found = await commands.loadIntegrationContextById(integrationId);
|
|
122
|
-
if (await accepts(found?.context?.record?.config?.type)) out.push(integrationId);
|
|
123
|
-
}
|
|
124
|
-
return out;
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function makeClass({ name, defaultId, definitions, manifests, runCode, IntegrationBase, modules, display, version, lookup, runs, embedded = false }) {
|
|
129
|
-
const Base = IntegrationBase || require('../integration-base').IntegrationBase;
|
|
130
|
-
const id = name;
|
|
131
|
-
// Where workflow runs are kept: the WorkflowRun table by default (loaded lazily, so tests and tools without a
|
|
132
|
-
// database can pass their own store or `false` to keep none).
|
|
133
|
-
let runStore = runs;
|
|
134
|
-
// App-level webhooks fan out to the installations of every type this deployment runs.
|
|
135
|
-
const findInstallations = (externalId, moduleName, Cls) => (lookup || friggLookup(async (type) => type === id || type === Cls.Definition.name || Boolean(await IntegrationRuntime.IntegrationProvider?.resolve(type))))(externalId, moduleName);
|
|
136
|
-
const runRepository = () => (runStore === undefined ? (runStore = require('./repositories/workflow-run-repository').createWorkflowRunRepository()) : runStore);
|
|
137
|
-
const baseFor = (typeName) => `${process.env.FRIGG_PUBLIC_URL || ''}/api/${typeName}-integration/webhooks`;
|
|
138
|
-
const pinnedDefinitions = new Map(); // `${id}@${version}` -> definition, for the trigger tick
|
|
139
|
-
// A code class embedding a definition provisions the per-installation tick only when its definition has
|
|
140
|
-
// schedule-triggered workflows or polling syncs; its own Definition.triggers are provisioned as they are.
|
|
141
|
-
const embeddedRecurring = embedded && hasRecurring(definitions.latest(defaultId));
|
|
142
|
-
// A code class's own Definition.triggers (IntegrationBase.triggersFor), kept beside the compiled ones.
|
|
143
|
-
const codeTriggers = async (Cls, installation) => (embedded && typeof Base.triggersFor === 'function' ? Base.triggersFor.call(Cls, installation) : []);
|
|
144
|
-
|
|
145
|
-
class IntegrationRuntime extends Base {
|
|
146
|
-
// version is display only; installations pin their own. A code class keeps every part of its Definition.
|
|
147
|
-
// instanceTriggers: schedule-triggered workflows and polling syncs are this class's recurring triggers,
|
|
148
|
-
// resolved per installation (triggersFor) on one 5m tick.
|
|
149
|
-
static Definition = embedded
|
|
150
|
-
? { ...Base.Definition, webhooks: Base.Definition.webhooks ?? { enabled: true }, ...(embeddedRecurring ? { instanceTriggers: { tick: INSTANCE_TICK } } : {}) }
|
|
151
|
-
: { name, version, modules, webhooks: { enabled: true }, display, instanceTriggers: { tick: INSTANCE_TICK } };
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* The recurring triggers of one installation (ADR-055): its pinned definition compiled with its config, so a
|
|
155
|
-
* switched-off workflow has none and a configurable one carries the installation's choice. Each runs its
|
|
156
|
-
* compiled event. A code class embedding a definition keeps its own Definition.triggers beside them. Read by
|
|
157
|
-
* the shared tick from the record alone (no modules, no credentials).
|
|
158
|
-
*/
|
|
159
|
-
static async triggersFor(installation = {}) {
|
|
160
|
-
const config = installation.config || {};
|
|
161
|
-
const own = await codeTriggers(this, installation);
|
|
162
|
-
const fixedId = this.definitionId;
|
|
163
|
-
let pin = config.definition || (fixedId ? { id: fixedId } : null);
|
|
164
|
-
if (!pin?.id) return own;
|
|
165
|
-
if (embedded) pin = { id: pin.id }; // the definition ships with the code: the deployed version runs
|
|
166
|
-
let def;
|
|
167
|
-
if (pin.version) {
|
|
168
|
-
// Published versions are immutable, so a tick over many installations reads each one once.
|
|
169
|
-
const key = `${pin.id}@${pin.version}`;
|
|
170
|
-
if (!pinnedDefinitions.has(key)) {
|
|
171
|
-
if (pinnedDefinitions.size >= 100) pinnedDefinitions.clear();
|
|
172
|
-
pinnedDefinitions.set(key, await definitions.get(pin.id, pin.version, { checksum: pin.checksum }));
|
|
173
|
-
}
|
|
174
|
-
def = pinnedDefinitions.get(key);
|
|
175
|
-
} else {
|
|
176
|
-
def = await definitions.latest(pin.id);
|
|
177
|
-
}
|
|
178
|
-
return def ? [...own, ...triggersOf(def, config)] : own;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* The installation types one tick of this class covers (RunTriggerTick): its own, plus, for the shared class,
|
|
183
|
-
* every published definition it provides, because each of those is an integration type of its own that runs
|
|
184
|
-
* on this deployment.
|
|
185
|
-
*/
|
|
186
|
-
static async triggerTypes() {
|
|
187
|
-
const own = this.Definition.name;
|
|
188
|
-
if (!this.IntegrationProvider) return [own];
|
|
189
|
-
const provided = (await this.IntegrationProvider.list()).map((c) => c.Definition.name);
|
|
190
|
-
return [...new Set([own, ...provided])];
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** How the routers find integration runtime classes and the store and manifests they run on. */
|
|
194
|
-
static IntegrationRuntime = { definitions, manifests, shared: !defaultId, runs: () => runRepository() };
|
|
195
|
-
|
|
196
|
-
/** Shape A: the one definition this class runs. A class generated for a published definition sets its own. */
|
|
197
|
-
static definitionId = defaultId;
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Frigg's use cases construct an integration from its record and call initialize() before anything else
|
|
201
|
-
* (create, update, delete, webhooks, user actions, config options). So this is where the pinned definition is
|
|
202
|
-
* loaded: every real entry point hydrates through here. A dry instance (no record, e.g. an app-level webhook
|
|
203
|
-
* before its installation is known) has no pin and loads nothing.
|
|
204
|
-
*/
|
|
205
|
-
async initialize() {
|
|
206
|
-
if (this.config?.definition || this.constructor.definitionId) await this.loadDefinition();
|
|
207
|
-
return super.initialize();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async loadDefinition() {
|
|
211
|
-
const fixedId = this.constructor.definitionId;
|
|
212
|
-
let pin = this.config?.definition || (fixedId ? { id: fixedId } : null);
|
|
213
|
-
if (fixedId && pin.id !== fixedId) throw new Error(`This installation pins "${pin.id}", but its type is "${fixedId}"`);
|
|
214
|
-
if (!pin?.id) throw new Error('This installation does not name a definition (config.definition.id)');
|
|
215
|
-
// A definition embedded in code ships with the code: an installation runs the version deployed now.
|
|
216
|
-
if (embedded) pin = { id: pin.id };
|
|
217
|
-
if (!pin.version) {
|
|
218
|
-
// Installed by id alone: pin the latest published version now (onCreate persists the pin).
|
|
219
|
-
const latest = await definitions.latest(pin.id);
|
|
220
|
-
if (!latest) throw new Error(`No published version of "${pin.id}"`);
|
|
221
|
-
pin = { id: pin.id, version: latest.version, checksum: await definitions.checksumOf?.(pin.id, latest.version) || undefined };
|
|
222
|
-
// Persisted by onCreate, so this installation stays on this version until it is upgraded.
|
|
223
|
-
this.config = { ...(this.config || {}), definition: pin };
|
|
224
|
-
}
|
|
225
|
-
this.def = await definitions.get(pin.id, pin.version, { checksum: pin.checksum });
|
|
226
|
-
if (!this.def) throw new Error(`Definition ${pin.id}@${pin.version} is not published`);
|
|
227
|
-
this.compiled = compile(this.def, this.config || {});
|
|
228
|
-
this.ports = this.portsFor(this.def);
|
|
229
|
-
// Replace, not merge: a workflow switched off since the last compile must lose its event.
|
|
230
|
-
for (const n of this.compiledEventNames || []) delete this.events[n];
|
|
231
|
-
this.compiledEventNames = Object.keys(this.compiled.events);
|
|
232
|
-
for (const [n, e] of Object.entries(this.compiled.events)) {
|
|
233
|
-
this.events[n] = { ...e, handler: (payload) => this.dispatch(e.ref, payload) };
|
|
234
|
-
}
|
|
235
|
-
this.registerEventHandlers();
|
|
236
|
-
return this.def;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Ports for a definition: Api calls through the manifest, and links/dedupe over IntegrationMapping. */
|
|
240
|
-
portsFor(def) {
|
|
241
|
-
return makePorts({
|
|
242
|
-
def, manifests,
|
|
243
|
-
// `#method` is a method of this integration (a code class embedding a definition); anything else is a
|
|
244
|
-
// registered code step.
|
|
245
|
-
runCode: (ref, args) => {
|
|
246
|
-
if (typeof ref === 'string' && ref.startsWith('#')) {
|
|
247
|
-
const fn = this[ref.slice(1)];
|
|
248
|
-
if (typeof fn !== 'function') throw new Error(`code: this integration has no method "${ref.slice(1)}"`);
|
|
249
|
-
return fn.call(this, args);
|
|
250
|
-
}
|
|
251
|
-
if (!runCode) throw new Error('code: this runtime has no code steps registered');
|
|
252
|
-
return runCode(ref, args);
|
|
253
|
-
},
|
|
254
|
-
// The shared class registers modules by module name; a definition may name them by its own keys.
|
|
255
|
-
callApi: (moduleKey, method, args, order) => this.moduleFor(moduleKey).api[method](...argumentsFor(args, order)),
|
|
256
|
-
store: this.mappingStore(),
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** Key-value rows in IntegrationMapping: links, dedupe claims and sync progress all live here. */
|
|
261
|
-
mappingStore() {
|
|
262
|
-
return {
|
|
263
|
-
get: async (key) => (await this.getMapping(key))?.mapping || null,
|
|
264
|
-
put: (key, value) => this.upsertMapping(key, value),
|
|
265
|
-
claim: async (key, value) => { try { await this.createMapping(key, value); return true; } catch (e) { if (e.name === 'MappingAlreadyExistsError') return false; throw e; } },
|
|
266
|
-
release: (key) => this.deleteMapping(key),
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
moduleFor(key) {
|
|
271
|
-
const m = this[key] || this[this.def?.modules?.[key]?.module];
|
|
272
|
-
if (!m?.api) throw new Error(`Module "${key}" is not connected to this installation`);
|
|
273
|
-
return m;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async dispatch(ref, payload = {}) {
|
|
277
|
-
const msg = payload.data || payload;
|
|
278
|
-
if (msg.resume) return this.resumeRun(msg.resume, msg); // a paused run carries on (its own version)
|
|
279
|
-
// A message queued under an older version runs on that version (an upgrade drains, it never re-interprets).
|
|
280
|
-
const def = msg.definitionVersion && msg.definitionVersion !== this.def.version
|
|
281
|
-
? await definitions.get(this.def.id, msg.definitionVersion) || this.def
|
|
282
|
-
: this.def;
|
|
283
|
-
const installation = this.installation();
|
|
284
|
-
// A trigger run may be delivered twice (a redelivery, two ticks in one slot); the trigger and the time it
|
|
285
|
-
// was scheduled for make its dedupe id.
|
|
286
|
-
const eventId = msg.eventId || (msg.trigger && msg.scheduledFor ? `tick:${msg.trigger}:${msg.scheduledFor}` : undefined);
|
|
287
|
-
if (ref.kind === 'workflow') return this.recordRun(await runWorkflow({ def, installation, workflowId: ref.id, event: msg.body ?? msg, eventId, record: msg.record, ports: this.ports, manifests }));
|
|
288
|
-
if (ref.kind === 'query') return runQuery({ def, installation, queryId: ref.id, record: msg.record, ports: this.ports });
|
|
289
|
-
if (ref.kind === 'ui') return renderSurface({ def, installation, surfaceId: ref.id, record: payload.req?.body?.record || msg.record, ports: this.ports });
|
|
290
|
-
if (ref.kind === 'sync') return this.runSync(ref, msg);
|
|
291
|
-
throw new Error(`Unknown event ref ${JSON.stringify(ref)}`);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Receipt (no database): answer a provider handshake synchronously (Slack url_verification, Asana
|
|
296
|
-
* X-Hook-Secret), otherwise queue the delivery as Frigg always does. The URL names the module (?module=).
|
|
297
|
-
*/
|
|
298
|
-
async onWebhookReceived({ req, res }) {
|
|
299
|
-
if (embedded && !ownsWebhooks(this.def)) return super.onWebhookReceived({ req, res });
|
|
300
|
-
const manifest = manifests[req.query?.module];
|
|
301
|
-
const h = manifest && handshakeFor(manifest, { body: req.body, headers: req.headers, query: req.query });
|
|
302
|
-
if (h) {
|
|
303
|
-
for (const [k, v] of Object.entries(h.headers || {})) res.set(k, String(v));
|
|
304
|
-
return typeof h.body === 'object' ? res.status(h.status).json(h.body) : res.status(h.status).send(h.body);
|
|
305
|
-
}
|
|
306
|
-
return super.onWebhookReceived({ req, res });
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Worker side of a raw delivery. Without an installation (an app-level webhook), find the installations
|
|
311
|
-
* connected to each account the delivery is about and queue it for each. With one: verify it, split it into
|
|
312
|
-
* module events, and queue one message per enabled workflow listening for each event.
|
|
313
|
-
*/
|
|
314
|
-
async onWebhook(params) {
|
|
315
|
-
if (embedded && !ownsWebhooks(this.def)) return super.onWebhook(params);
|
|
316
|
-
const { data = {} } = params || {};
|
|
317
|
-
if (data.module && data.topic) return this.routeEvents(data.module, [{ topic: data.topic, event: data.body, eventId: data.eventId || data.body?.id || null }]);
|
|
318
|
-
const delivery = { body: data.body, headers: data.headers || {}, query: data.query || {}, rawBody: data.rawBody, method: data.method, url: data.url };
|
|
319
|
-
// No installation yet (an app-level webhook): find the ones it is for.
|
|
320
|
-
if (!this.id || !this.def) return this.fanOutAppWebhook(delivery);
|
|
321
|
-
|
|
322
|
-
const keys = modulesFor(this.def, manifests, delivery);
|
|
323
|
-
if (keys.length !== 1) { this.logWebhook('webhook.unrouted', { module: delivery.query.module || null, candidates: keys }); return { routed: 0 }; }
|
|
324
|
-
const key = keys[0];
|
|
325
|
-
const manifest = manifests[this.def.modules[key].module];
|
|
326
|
-
if (manifest.webhook?.verify) {
|
|
327
|
-
let ok = false;
|
|
328
|
-
// The raw body, method and URL travel from receipt (IntegrationBase.onWebhookReceived): vendors sign bytes.
|
|
329
|
-
try { ok = await this.moduleFor(key).api[manifest.webhook.verify]({ headers: delivery.headers, body: delivery.body, query: delivery.query, rawBody: delivery.rawBody, method: delivery.method, url: delivery.url }); } catch { ok = false; }
|
|
330
|
-
if (ok !== true) { this.logWebhook('webhook.rejected', { module: key }); return { routed: 0, rejected: true }; }
|
|
331
|
-
}
|
|
332
|
-
const own = this.accountOf(key);
|
|
333
|
-
const events = eventsIn(manifest, delivery).filter((e) => manifest.webhook?.scope !== 'app' || !own || !e.account || String(e.account) === String(own));
|
|
334
|
-
return this.routeEvents(key, events);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async routeEvents(moduleKey, events) {
|
|
338
|
-
let routed = 0;
|
|
339
|
-
for (const e of events) {
|
|
340
|
-
for (const n of routeWebhook(this.compiled, moduleKey, e.topic)) {
|
|
341
|
-
await this.queueWebhook({ event: n, integrationId: this.id, module: moduleKey, topic: e.topic, body: e.event, eventId: e.eventId, definitionVersion: this.def.version });
|
|
342
|
-
routed += 1;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return { routed };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
async fanOutAppWebhook(delivery) {
|
|
349
|
-
const moduleName = delivery.query.module;
|
|
350
|
-
const manifest = manifests[moduleName];
|
|
351
|
-
if (!manifest?.webhook?.account) { this.logWebhook('webhook.unrouted', { module: moduleName || null, reason: 'no installation and no account to look it up by' }); return { queued: 0 }; }
|
|
352
|
-
const accounts = new Set(eventsIn(manifest, delivery).map((e) => e.account).filter(Boolean));
|
|
353
|
-
let queued = 0;
|
|
354
|
-
for (const account of accounts) {
|
|
355
|
-
for (const integrationId of await findInstallations(account, moduleName, this.constructor)) {
|
|
356
|
-
await this.queueWebhook({ integrationId, body: delivery.body, headers: delivery.headers, query: delivery.query, rawBody: delivery.rawBody, method: delivery.method, url: delivery.url });
|
|
357
|
-
queued += 1;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
return { queued };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** The connected account's id for a module (its entity externalId): what app-level events are matched on. */
|
|
364
|
-
accountOf(key) {
|
|
365
|
-
const m = this[key] || this[this.def?.modules?.[key]?.module] || this.modules?.[this.def?.modules?.[key]?.module];
|
|
366
|
-
return m?.entity?.externalId ?? null;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
logWebhook(eventName, fields) {
|
|
370
|
-
try { require('../../logs').getLogger('frigg.integrations.runtime').info('Webhook not routed', { eventName: `frigg.integrations.runtime.${eventName}`, integrationId: this.id || null, ...fields }); } catch { /* logging must never break delivery */ }
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
async onCreate(params) {
|
|
374
|
-
if (!this.def) await this.loadDefinition();
|
|
375
|
-
const out = await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onInstall', ports: this.ports });
|
|
376
|
-
// Every onInstall step with an id keeps its result, so onUninstall can undo exactly what install did
|
|
377
|
-
// ($.install.runtime.installed.<step id>). Saved even when empty, so a re-install replaces stale ids.
|
|
378
|
-
const installed = Object.fromEntries((this.def.lifecycle?.onInstall || []).filter((st) => st.id).map((st) => [st.id, out.steps?.[st.id] ?? null]));
|
|
379
|
-
await this.patchConfig({ definition: this.config.definition, runtime: { ...(this.config?.runtime || {}), installed } });
|
|
380
|
-
const result = await super.onCreate(params);
|
|
381
|
-
// Syncs that copy what already exists at install time start their backfill now (through the queue).
|
|
382
|
-
for (const sy of this.def.syncs || []) {
|
|
383
|
-
if (sy.backfill?.trigger === 'onInstall' && this.compiled.events[eventName('SYNC', sy.id, 'PAGE')]) await this.startBackfill(sy.id, 'install');
|
|
384
|
-
}
|
|
385
|
-
return result;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async onUpdate(params) {
|
|
389
|
-
await super.onUpdate(params); // merges config (enabled workflows, settings, mapping overrides)
|
|
390
|
-
return this.loadDefinition(); // recompile: enabling a workflow adds its event, disabling removes it
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
async onDelete(params) {
|
|
394
|
-
if (!this.def) await this.loadDefinition();
|
|
395
|
-
await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onUninstall', ports: this.ports });
|
|
396
|
-
return super.onDelete(params);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* This installation's triggers, from the definition it already loaded and its current config, beside a code
|
|
401
|
-
* class's own Definition.triggers.
|
|
402
|
-
*/
|
|
403
|
-
async triggerDeclarations() {
|
|
404
|
-
if (!this.def) return this.constructor.triggersFor({ config: this.config || {}, record: this.record });
|
|
405
|
-
const own = await codeTriggers(this.constructor, { config: this.config || {}, record: this.record });
|
|
406
|
-
return [...own, ...triggersOf(this.def, this.config || {})];
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* The queue worker's seam (IntegrationEventDispatcher.dispatchJob): a code integration's own queued events are
|
|
411
|
-
* runs too, recorded in the same history as its workflows. Compiled events record their own runs (with steps).
|
|
412
|
-
* As with workflows, the history never decides the outcome: a failed write is logged and the handler's result
|
|
413
|
-
* or error stands.
|
|
414
|
-
*/
|
|
415
|
-
async runEvent(event, invoke) {
|
|
416
|
-
if (!embedded || (this.compiledEventNames || []).includes(event)) return invoke();
|
|
417
|
-
const startedAt = new Date().toISOString();
|
|
418
|
-
const write = async (status, error) => {
|
|
419
|
-
try {
|
|
420
|
-
const store = runRepository();
|
|
421
|
-
if (store) await store.createRun({ integrationId: this.id, definitionId: name, definitionVersion: this.def?.version, workflowId: event, triggerType: 'code', status, error: error ? String(error.message || error) : null, retryable: Boolean(error), trace: [], startedAt, endedAt: new Date().toISOString() });
|
|
422
|
-
} catch (e) {
|
|
423
|
-
try { require('../../logs').getLogger('frigg.integrations.runtime').warn('Workflow run not recorded', { eventName: 'frigg.integrations.runtime.run.not_recorded', integrationId: this.id, workflowId: event, cause: e?.message }); } catch { /* ignore */ }
|
|
424
|
-
}
|
|
425
|
-
};
|
|
426
|
-
let out;
|
|
427
|
-
try { out = await invoke(); } catch (e) { await write('failed', e); throw e; }
|
|
428
|
-
await write('completed');
|
|
429
|
-
return out;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** The configure form (GET /api/integrations/:id/config/options), generated from the definition. */
|
|
433
|
-
async getConfigOptions() {
|
|
434
|
-
if (embedded && !ownsConfigForm(this.def)) return super.getConfigOptions();
|
|
435
|
-
return buildConfigForm(this.def, this.config || {}, (path, values, integrationSettings) => this.optionsFor(path, values, integrationSettings));
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** The same form re-filled from the values chosen so far (POST …/config/options/refresh): dependent dropdowns. */
|
|
439
|
-
async refreshConfigOptions(params = {}) {
|
|
440
|
-
if (embedded && !ownsConfigForm(this.def)) return super.refreshConfigOptions(params);
|
|
441
|
-
const values = params.config || params.formData || params.data || params;
|
|
442
|
-
return buildConfigForm(this.def, { ...(this.config || {}), ...values }, (path, v, integrationSettings) => this.optionsFor(path, v, integrationSettings));
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
async optionsFor(path, values, integrationSettings) {
|
|
446
|
-
try { return await resolveOptions({ def: this.def, manifests, ports: this.ports, path, values, integrationSettings }); } catch { return []; }
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/** Required settings come from the definition; checked without calling any API (the base class renders the form). */
|
|
450
|
-
async validateConfig() {
|
|
451
|
-
if (embedded && !ownsConfigForm(this.def)) return super.validateConfig();
|
|
452
|
-
return missingRequired(this.def, this.config || {}).length > 0;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** The input form for a button workflow (POST …/actions/:id/options): the inputs of the UI buttons that run it. */
|
|
456
|
-
async getActionOptions(actionId) {
|
|
457
|
-
const ref = this.compiled?.events?.[actionId]?.ref;
|
|
458
|
-
const inputs = {};
|
|
459
|
-
const visit = (node) => { if (!node) return; const a = node.props?.action; if (a?.type === 'userAction' && a.workflow === ref?.id) Object.assign(inputs, a.input || {}); (node.children || []).forEach(visit); };
|
|
460
|
-
if (ref?.kind === 'workflow') (this.def.ui || []).forEach((u) => visit(u.view));
|
|
461
|
-
const properties = Object.fromEntries(Object.entries(inputs).map(([k, f]) => [k, { type: f.type === 'number' ? 'number' : 'string', title: f.label || k }]));
|
|
462
|
-
return { jsonSchema: Object.keys(properties).length ? { type: 'object', properties: { input: { type: 'object', properties } } } : {}, uiSchema: {} };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
installation() {
|
|
466
|
-
// An installation's webhooks arrive at its own type's URL. App-level webhooks (one URL registered with the
|
|
467
|
-
// vendor for every account) arrive at the deployment's URL, which finds the installations they are for.
|
|
468
|
-
const base = baseFor(this.constructor.Definition.name);
|
|
469
|
-
const appBase = baseFor(this.constructor.getDeploymentName?.() || id);
|
|
470
|
-
const webhookUrls = Object.fromEntries(Object.entries(this.def?.modules || {}).map(([key, m]) => [key, manifests[m.module]?.webhook?.scope === 'app'
|
|
471
|
-
? `${appBase}?module=${encodeURIComponent(m.module)}`
|
|
472
|
-
: `${base}/${this.id}?module=${encodeURIComponent(m.module)}`]));
|
|
473
|
-
return { ...(this.config || {}), integrationId: this.id, accounts: this.accounts(), webhookUrl: `${base}/${this.id}`, webhookUrls };
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** $.install.accounts: per module key, the account fields its manifest declares, read from the module's Api
|
|
477
|
-
* (where apiPropertiesToPersist land after auth). An undeclared property is never exposed. */
|
|
478
|
-
accounts() {
|
|
479
|
-
return Object.fromEntries(Object.entries(this.def?.modules || {}).map(([key, m]) => {
|
|
480
|
-
const fields = manifests[m.module]?.account?.fields || [];
|
|
481
|
-
let api = {};
|
|
482
|
-
try { api = this.moduleFor(key).api || {}; } catch { api = {}; }
|
|
483
|
-
return [key, Object.fromEntries(fields.map((f) => [f.name, api[f.name] ?? null]))];
|
|
484
|
-
}));
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Move this installation to another published version (default: the latest). With apply false, only the plan:
|
|
489
|
-
* what a person must supply first and what will happen. Applying refuses while anything is needed, and when
|
|
490
|
-
* install steps changed, undoes the old ones before running the new ones (it never leaves the old webhooks).
|
|
491
|
-
*/
|
|
492
|
-
async upgradeTo(toVersion, { apply = false } = {}) {
|
|
493
|
-
// An embedded definition's version is the code's: a deploy moves every installation, nothing else does.
|
|
494
|
-
if (embedded) return { allowed: false, applied: false, needs: [], actions: [], from: this.def?.version ?? null, to: this.def?.version ?? null, reason: 'This integration moves to a new version when its code is deployed' };
|
|
495
|
-
const { PlanInstallationUpgrade } = require('./use-cases/definition-use-cases');
|
|
496
|
-
const plan = await new PlanInstallationUpgrade({ catalog: definitions }).execute({ config: this.config, toVersion });
|
|
497
|
-
if (!apply || !plan.allowed) return { ...plan, applied: false };
|
|
498
|
-
const reinstall = plan.actions.some((a) => a.kind === 'reinstall');
|
|
499
|
-
let config = plan.config;
|
|
500
|
-
if (reinstall) {
|
|
501
|
-
await runLifecycle({ def: this.def, installation: this.installation(), hook: 'onUninstall', ports: this.ports });
|
|
502
|
-
const next = await definitions.get(plan.config.definition.id, plan.config.definition.version);
|
|
503
|
-
const out = await runLifecycle({ def: next, installation: { ...this.installation(), ...config }, hook: 'onInstall', ports: this.portsFor(next) });
|
|
504
|
-
config = { ...config, runtime: { ...(this.config?.runtime || {}), installed: Object.fromEntries((next.lifecycle?.onInstall || []).filter((st) => st.id).map((st) => [st.id, out.steps?.[st.id] ?? null])) } };
|
|
505
|
-
}
|
|
506
|
-
await this.patchConfig(config);
|
|
507
|
-
this.config = { ...this.config, ...config };
|
|
508
|
-
await this.loadDefinition();
|
|
509
|
-
// Triggers need nothing here: the next tick reads the new pin and config.
|
|
510
|
-
// New identity keys: a backfill rewrites every link with them. A new sync starts its own backfill
|
|
511
|
-
// only if its definition says so (onInstall); otherwise it waits for its button.
|
|
512
|
-
for (const a of plan.actions.filter((x) => x.kind === 'relink')) await this.startBackfill(a.id, 'relink');
|
|
513
|
-
return { ...plan, config, applied: true };
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Keep the run's history, and act on its outcome: a failed run throws (the queue retries), a paused run saves
|
|
518
|
-
* its checkpoint and books its resume. A paused run is one row from first step to last: `rowId` updates it.
|
|
519
|
-
*/
|
|
520
|
-
async recordRun(run, { rowId } = {}) {
|
|
521
|
-
this.telemetry?.count?.('workflow.run', 1, { workflow: run.workflowId, status: run.status });
|
|
522
|
-
if (run.status === 'waiting') await this.pauseRun(run, rowId); // before the history write: a pause must not be lost
|
|
523
|
-
// Run history must never decide the run's outcome: a failed write is logged and the run stands.
|
|
524
|
-
let row = null;
|
|
525
|
-
try {
|
|
526
|
-
const store = runRepository();
|
|
527
|
-
const { checkpoint, ...stored } = run;
|
|
528
|
-
if (store && rowId && store.updateRun) row = await store.updateRun(rowId, { status: run.status, reason: run.reason, error: run.error, retryable: Boolean(run.retryable), trace: run.trace, waitingFor: run.waitingFor ?? null, resumeAt: run.resumeAt ?? null, endedAt: run.endedAt ?? null });
|
|
529
|
-
else if (store) row = await store.createRun({ ...stored, runKey: run.id, integrationId: this.id, definitionId: run.definition?.id, definitionVersion: run.definition?.version, triggerType: run.trigger?.type, eventId: run.trigger?.eventId });
|
|
530
|
-
} catch (e) {
|
|
531
|
-
try { require('../../logs').getLogger('frigg.integrations.runtime').warn('Workflow run not recorded', { eventName: 'frigg.integrations.runtime.run.not_recorded', integrationId: this.id, workflowId: run.workflowId, cause: e?.message }); } catch { /* ignore */ }
|
|
532
|
-
}
|
|
533
|
-
if (run.status === 'waiting' && row?.id && !rowId) await this.mappingStore().put(`wait:${run.id}`, { ...(await this.mappingStore().get(`wait:${run.id}`)), rowId: row.id });
|
|
534
|
-
if (run.status === 'failed') { const e = new Error(run.error); e.run = run; throw e; } // the queue retries
|
|
535
|
-
const { checkpoint, ...out } = run;
|
|
536
|
-
return out;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Save a paused run's checkpoint (IntegrationMapping, encrypted at rest: it holds step results) and book its
|
|
541
|
-
* resume: the delay's end, or an approval's timeout. If the checkpoint cannot be saved, the event's dedupe
|
|
542
|
-
* claim is given back and the error thrown, so the queue runs the event again rather than losing it.
|
|
543
|
-
*/
|
|
544
|
-
async pauseRun(run, rowId) {
|
|
545
|
-
const cp = run.checkpoint;
|
|
546
|
-
const store = this.mappingStore();
|
|
547
|
-
try {
|
|
548
|
-
await store.put(`wait:${run.id}`, { ...cp, runId: run.id, definitionVersion: run.definition.version, rowId: rowId ?? null });
|
|
549
|
-
} catch (e) {
|
|
550
|
-
if (cp.eventId != null) await this.ports.dedupe.release?.(`evt:${run.definition.id}:${cp.workflowId}:${cp.eventId}`);
|
|
551
|
-
throw e;
|
|
552
|
-
}
|
|
553
|
-
if (!cp.wait.until) return;
|
|
554
|
-
const name = this.constructor.Definition.name;
|
|
555
|
-
const commands = typeof this.schedulerCommands?.scheduleJob === 'function' ? this.schedulerCommands : require('../../application/commands/scheduler-commands').createSchedulerCommands({ integrationName: name });
|
|
556
|
-
const booked = await commands.scheduleJob({
|
|
557
|
-
jobId: `${name}-${this.id}-${run.id}-${cp.index}`.replace(/[^0-9A-Za-z_.-]/g, '-').slice(0, 64),
|
|
558
|
-
scheduledAt: new Date(cp.wait.until),
|
|
559
|
-
event: eventName('WF', cp.workflowId),
|
|
560
|
-
payload: { integrationId: this.id, resume: run.id, reason: cp.wait.kind === 'delay' ? 'delay' : 'timeout', index: cp.index },
|
|
561
|
-
queueUrl: typeof this.scheduleQueueUrl === 'function' ? this.scheduleQueueUrl() : process.env[`${name.toUpperCase().replace(/-/g, '_')}_QUEUE_URL`],
|
|
562
|
-
});
|
|
563
|
-
if (booked?.error || booked?.warning) {
|
|
564
|
-
try { require('../../logs').getLogger('frigg.integrations.runtime').error('Run resume not booked', { eventName: 'frigg.integrations.runtime.run.resume_not_booked', integrationId: this.id, runId: run.id, kind: cp.wait.kind, reason: booked.reason || booked.warning }); } catch { /* ignore */ }
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/**
|
|
569
|
-
* Carry on a paused run: its delay ended, its approval timed out, or someone decided. The first of those to
|
|
570
|
-
* claim the pause wins; the others are no-ops. The run continues on the definition version it started on.
|
|
571
|
-
*/
|
|
572
|
-
async resumeRun(runId, { reason, decision, by, note, index } = {}) {
|
|
573
|
-
const store = this.mappingStore();
|
|
574
|
-
const cp = await store.get(`wait:${runId}`);
|
|
575
|
-
if (!cp) return { status: 'gone', runId };
|
|
576
|
-
if (index !== undefined && Number(index) !== cp.index) return { status: 'ignored', runId, reason: 'a resume for an earlier pause' };
|
|
577
|
-
if (cp.wait.kind === 'delay' && reason !== 'delay') return { status: 'ignored', runId, reason: 'a delay resumes only when it ends' };
|
|
578
|
-
if (cp.wait.kind === 'approval' && !['decision', 'timeout'].includes(reason)) return { status: 'ignored', runId };
|
|
579
|
-
const claimKey = `resume:${runId}:${cp.index}`;
|
|
580
|
-
if (!(await store.claim(claimKey, { at: new Date().toISOString(), reason }))) return { status: 'duplicate', runId };
|
|
581
|
-
|
|
582
|
-
const at = new Date().toISOString();
|
|
583
|
-
let result, stop;
|
|
584
|
-
if (cp.wait.kind === 'delay') result = { resumedAt: at, until: cp.wait.until, waited: true };
|
|
585
|
-
else if (reason === 'decision') result = { decision, by: by ?? null, note: note ?? null, at };
|
|
586
|
-
else if (cp.wait.onTimeout === 'stop') stop = 'approval timed out';
|
|
587
|
-
else result = { decision: cp.wait.onTimeout === 'approve' ? 'approved' : 'rejected', by: 'timeout', note: null, at };
|
|
588
|
-
|
|
589
|
-
const def = cp.definitionVersion !== this.def.version ? (await definitions.get(this.def.id, cp.definitionVersion)) || this.def : this.def;
|
|
590
|
-
const run = await resumeWorkflow({ def, installation: this.installation(), checkpoint: cp, result, stop, ports: def === this.def ? this.ports : this.portsFor(def), runId });
|
|
591
|
-
if (run.status === 'failed') await store.release(claimKey); // the queue's retry may resume again
|
|
592
|
-
else if (run.status !== 'waiting') await store.release(`wait:${runId}`);
|
|
593
|
-
return this.recordRun(run, { rowId: cp.rowId });
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* An approval decision (POST /api/integrations/:id/runs/:runId/decision). Checked here, carried out through
|
|
598
|
-
* the queue (the steps after an approval may take longer than an HTTP request should).
|
|
599
|
-
*/
|
|
600
|
-
async decide(runId, { decision, userId, note, isOwner = true } = {}) {
|
|
601
|
-
const { DefinitionError } = require('./use-cases/definition-use-cases');
|
|
602
|
-
const store = this.mappingStore();
|
|
603
|
-
const cp = await store.get(`wait:${runId}`);
|
|
604
|
-
// Someone who neither owns the installation nor is named on this run learns nothing about it.
|
|
605
|
-
if (!isOwner && !(cp?.wait?.approvers || []).includes(String(userId))) throw new DefinitionError(`Integration ${this.id} not found`, { status: 404, code: 'NOT_FOUND' });
|
|
606
|
-
if (!['approve', 'reject'].includes(decision)) throw new DefinitionError('decision is approve or reject', { status: 400, code: 'BAD_DECISION' });
|
|
607
|
-
if (!cp) throw new DefinitionError(`Run ${runId} is not waiting`, { status: 404, code: 'NOT_WAITING' });
|
|
608
|
-
if (cp.wait.kind !== 'approval') throw new DefinitionError(`Run ${runId} is waiting on a ${cp.wait.kind}, not an approval`, { status: 409, code: 'NOT_AN_APPROVAL' });
|
|
609
|
-
if (cp.wait.approvers?.length && !cp.wait.approvers.includes(String(userId))) throw new DefinitionError('You are not an approver of this run', { status: 403, code: 'NOT_AN_APPROVER' });
|
|
610
|
-
if (await store.get(`resume:${runId}:${cp.index}`)) throw new DefinitionError(`Run ${runId} has already been decided`, { status: 409, code: 'ALREADY_DECIDED' });
|
|
611
|
-
await this.queueWebhook({ event: eventName('WF', cp.workflowId), integrationId: this.id, resume: runId, reason: 'decision', index: cp.index, decision: decision === 'approve' ? 'approved' : 'rejected', by: String(userId), note: note ?? null });
|
|
612
|
-
return { accepted: true, runId, decision };
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** The sync engine over this installation's ports and mapping store (sync-engine.js). */
|
|
616
|
-
syncEngine(def = this.def) {
|
|
617
|
-
const installation = this.installation();
|
|
618
|
-
return createSyncEngine({ def, installation, install: installContext(def, installation), manifests, ports: def === this.def ? this.ports : this.portsFor(def), state: this.mappingStore() });
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* SYNC_<ID>_BACKFILL (a button), SYNC_<ID>_PAGE (the queue, one page per message) and SYNC_<ID>_ONGOING
|
|
623
|
-
* (a webhook event about a synced object, or a poll tick). Each page queues the next, so a long backfill is
|
|
624
|
-
* many short messages; the progress row says where it is (GET /api/integrations/:id/syncs).
|
|
625
|
-
*/
|
|
626
|
-
async runSync(ref, msg = {}) {
|
|
627
|
-
const engine = this.syncEngine();
|
|
628
|
-
if (ref.phase === 'backfill') return this.startBackfill(ref.id, msg.reason || 'user');
|
|
629
|
-
if (ref.phase === 'page') {
|
|
630
|
-
const out = await engine.continueBackfill({ syncId: ref.id, runId: msg.runId, role: msg.role, cursor: msg.cursor ?? null });
|
|
631
|
-
if (out.next) await this.queueWebhook({ event: eventName('SYNC', ref.id, 'PAGE'), integrationId: this.id, ...out.next, definitionVersion: this.def.version });
|
|
632
|
-
await this.fireSyncWorkflows(ref.id, out.results || []);
|
|
633
|
-
return { progress: out.progress, stale: out.stale || false };
|
|
634
|
-
}
|
|
635
|
-
if (ref.phase === 'ongoing') {
|
|
636
|
-
if (msg.module && msg.topic) {
|
|
637
|
-
const result = await engine.handleEvent({ syncId: ref.id, moduleKey: msg.module, topic: msg.topic, event: msg.body });
|
|
638
|
-
await this.fireSyncWorkflows(ref.id, [result]);
|
|
639
|
-
return result;
|
|
640
|
-
}
|
|
641
|
-
return engine.poll({ syncId: ref.id });
|
|
642
|
-
}
|
|
643
|
-
throw new Error(`Unknown sync phase "${ref.phase}"`);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
/** Start a backfill (or a relink after an identity change) and queue its first page. */
|
|
647
|
-
async startBackfill(syncId, reason = 'user') {
|
|
648
|
-
const runId = `bf_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`;
|
|
649
|
-
const out = await this.syncEngine().startBackfill({ syncId, runId, reason });
|
|
650
|
-
if (out.started) await this.queueWebhook({ event: eventName('SYNC', syncId, 'PAGE'), integrationId: this.id, ...out.next, definitionVersion: this.def.version });
|
|
651
|
-
return { started: out.started, progress: out.progress };
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* syncRecord workflows: queue one run per synced record whose action (created, updated, linked) a switched-on
|
|
656
|
-
* workflow listens for. A create or link happens once per record, so it carries a dedupe id; updates do not.
|
|
657
|
-
*/
|
|
658
|
-
async fireSyncWorkflows(syncId, results) {
|
|
659
|
-
for (const w of this.def.workflows || []) {
|
|
660
|
-
const t = w.trigger || {};
|
|
661
|
-
const n = eventName('WF', w.id);
|
|
662
|
-
if (t.type !== 'syncRecord' || t.sync !== syncId || !this.compiled.events[n]) continue;
|
|
663
|
-
for (const r of results) {
|
|
664
|
-
if (!r?.action || !(t.on || []).includes(r.action)) continue;
|
|
665
|
-
const ids = Object.entries(r.ids || {}).map(([k, v]) => `${k}:${v}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join('|');
|
|
666
|
-
await this.queueWebhook({ event: n, integrationId: this.id, body: { sync: syncId, action: r.action, ids: r.ids }, ...(r.action !== 'updated' ? { eventId: `${syncId}:${r.action}:${ids}` } : {}), definitionVersion: this.def.version });
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/** Every switched-on sync's progress: the current or last backfill, and the poll marks. */
|
|
672
|
-
async syncStatus() {
|
|
673
|
-
const engine = this.syncEngine();
|
|
674
|
-
const out = [];
|
|
675
|
-
for (const sy of this.def.syncs || []) {
|
|
676
|
-
if (!this.compiled.events[eventName('SYNC', sy.id, 'PAGE')]) continue;
|
|
677
|
-
out.push({ id: sy.id, label: sy.label, source: sy.source, target: sy.target, direction: sy.direction, ongoing: sy.ongoing?.mode || 'none', ...(await engine.status(sy.id)) });
|
|
678
|
-
}
|
|
679
|
-
return out;
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
Object.defineProperty(IntegrationRuntime, 'name', { value: `IntegrationRuntime_${id.replace(/-/g, '_')}` });
|
|
683
|
-
if (!defaultId) IntegrationRuntime.IntegrationProvider = definitionProvider(IntegrationRuntime, definitions);
|
|
684
|
-
return IntegrationRuntime;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* The shared class as a provider of integration types (integration-types.js): every published definition is an
|
|
689
|
-
* integration type named by its id, run by a class of its own. The class differs from code only in where it gets its
|
|
690
|
-
* behaviour; it runs on the shared deployment (queue, functions) of the class that provides it.
|
|
691
|
-
*/
|
|
692
|
-
function definitionProvider(Host, definitions) {
|
|
693
|
-
const cache = new Map(); // id -> { version, cls }
|
|
694
|
-
const classFor = (entry) => {
|
|
695
|
-
const hit = cache.get(entry.id);
|
|
696
|
-
if (hit && hit.version === entry.version && hit.workspaceId === (entry.workspaceId ?? null)) return hit.cls;
|
|
697
|
-
const hostModules = Host.Definition.modules;
|
|
698
|
-
const modules = {};
|
|
699
|
-
for (const [key, m] of Object.entries(entry.modules || {})) {
|
|
700
|
-
const moduleName = typeof m === 'string' ? m : m?.module;
|
|
701
|
-
if (!hostModules[moduleName]) return null; // an app module this deployment does not have: not installable here
|
|
702
|
-
modules[key] = hostModules[moduleName];
|
|
703
|
-
}
|
|
704
|
-
const display = entry.display || {};
|
|
705
|
-
class PublishedIntegration extends Host {
|
|
706
|
-
static Definition = { name: entry.id, version: entry.version, modules, webhooks: { enabled: true }, display: { label: display.label, description: display.description, category: display.category } };
|
|
707
|
-
static definitionId = entry.id;
|
|
708
|
-
static Host = Host;
|
|
709
|
-
/** The workspace that built it (null: the app's own catalogue). */
|
|
710
|
-
static workspaceId = entry.workspaceId ?? null;
|
|
711
|
-
|
|
712
|
-
/** The install option (GET /api/integrations): the base class reports its first module's name as the type,
|
|
713
|
-
* which is not this integration's name. */
|
|
714
|
-
static getOptionDetails() {
|
|
715
|
-
return {
|
|
716
|
-
type: entry.id, hasUserConfig: true, isMany: false, requiresNewEntity: false,
|
|
717
|
-
// Built by this workspace: its page shows the editor views (rule: otherwise identical).
|
|
718
|
-
...(entry.workspaceId ? { editable: true } : {}),
|
|
719
|
-
display: { name: display.label, description: display.description, detailsUrl: display.detailsUrl, icon: display.icon },
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
Object.defineProperty(PublishedIntegration, 'IntegrationProvider', { value: undefined });
|
|
724
|
-
Object.defineProperty(PublishedIntegration, 'name', { value: `Integration_${entry.id.replace(/[^0-9A-Za-z]/g, '_')}` });
|
|
725
|
-
cache.set(entry.id, { version: entry.version, workspaceId: entry.workspaceId ?? null, cls: PublishedIntegration });
|
|
726
|
-
return PublishedIntegration;
|
|
727
|
-
};
|
|
728
|
-
const visible = (cls, context) => Boolean(cls) && (!context?.workspaceId || cls.workspaceId == null || cls.workspaceId === String(context.workspaceId));
|
|
729
|
-
return {
|
|
730
|
-
async resolve(type, context) {
|
|
731
|
-
if (!type || type === Host.Definition.name) return null;
|
|
732
|
-
let cls = cache.get(type)?.cls;
|
|
733
|
-
if (!cls) {
|
|
734
|
-
const latest = await definitions.latest(type);
|
|
735
|
-
if (!latest) return null;
|
|
736
|
-
cls = classFor({ ...latest, workspaceId: (await definitions.ownerOf?.(type)) ?? null });
|
|
737
|
-
}
|
|
738
|
-
return visible(cls, context) ? cls : null;
|
|
739
|
-
},
|
|
740
|
-
async list(context) {
|
|
741
|
-
const entries = (await definitions.list?.(context)) || [];
|
|
742
|
-
return entries.map(classFor).filter((cls) => visible(cls, context));
|
|
743
|
-
},
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/** In-memory definition store for tests and local dev. Production: the IntegrationDefinitionVersion table. */
|
|
748
|
-
function memoryDefinitions(docs) {
|
|
749
|
-
const byKey = new Map(docs.map((d) => [`${d.id}@${d.version}`, d]));
|
|
750
|
-
const cmp = (a, b) => a.split('.').map(Number).reduce((r, n, i) => r || n - Number(b.split('.')[i]), 0);
|
|
751
|
-
return {
|
|
752
|
-
get: async (defId, version) => byKey.get(`${defId}@${version}`) || null,
|
|
753
|
-
checksumOf: async (defId, version) => { const doc = byKey.get(`${defId}@${version}`); return doc ? require('./canonical').checksum(doc) : null; },
|
|
754
|
-
list: async () => {
|
|
755
|
-
const latestById = new Map();
|
|
756
|
-
for (const doc of docs) if (!latestById.has(doc.id) || cmp(doc.version, latestById.get(doc.id).version) > 0) latestById.set(doc.id, doc);
|
|
757
|
-
return [...latestById.values()].map((doc) => ({ id: doc.id, version: doc.version, checksum: require('./canonical').checksum(doc), display: doc.display, modules: Object.fromEntries(Object.entries(doc.modules || {}).map(([k, m]) => [k, m.module])), counts: { syncs: (doc.syncs || []).length, links: (doc.links || []).length, workflows: (doc.workflows || []).length, ui: (doc.ui || []).length } }));
|
|
758
|
-
},
|
|
759
|
-
latest: (id) => docs.filter((d) => d.id === id).sort((a, b) => cmp(b.version, a.version))[0] || null,
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/*
|
|
764
|
-
* Names (ADR-054 amendment, "One runtime, two front doors"): `defineIntegration` is the code front door, so the
|
|
765
|
-
* document front door is named for documents. The first names stay as aliases until Stack B merges, then go.
|
|
766
|
-
* fromDefinitionDocument (was defineIntegrationClass) one class per integration document (Shape A)
|
|
767
|
-
* definitionDocumentHost (was defineSharedIntegrationClass) one class hosting every published document (Shape B)
|
|
768
|
-
* embedDefinitionDocument (was extendIntegrationClass) a code class (hand-written or defineIntegration) plus a document
|
|
769
|
-
*/
|
|
770
|
-
const fromDefinitionDocument = defineIntegrationClass;
|
|
771
|
-
const definitionDocumentHost = defineSharedIntegrationClass;
|
|
772
|
-
const embedDefinitionDocument = extendIntegrationClass;
|
|
773
|
-
|
|
774
|
-
module.exports = {
|
|
775
|
-
fromDefinitionDocument, definitionDocumentHost, embedDefinitionDocument, memoryDefinitions,
|
|
776
|
-
/** @deprecated use fromDefinitionDocument */ defineIntegrationClass,
|
|
777
|
-
/** @deprecated use definitionDocumentHost */ defineSharedIntegrationClass,
|
|
778
|
-
/** @deprecated use embedDefinitionDocument */ extendIntegrationClass,
|
|
779
|
-
};
|