@friggframework/core 2.0.0--canary.690.08cd07e.0 → 2.0.0--canary.694.f66a64a.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/CLAUDE.md +0 -28
- package/README.md +0 -105
- package/application/commands/integration-commands.js +10 -20
- package/application/commands/scheduler-commands.js +7 -11
- package/application/commands/user-commands.js +5 -17
- package/application/index.js +2 -6
- package/core/Worker.js +7 -31
- package/core/create-handler.js +0 -9
- package/credential/repositories/credential-repository-documentdb.js +0 -34
- package/credential/repositories/credential-repository-interface.js +0 -15
- package/credential/repositories/credential-repository-mongo.js +0 -23
- package/credential/repositories/credential-repository-postgres.js +0 -28
- package/database/config.js +24 -48
- package/database/documentdb-utils.js +2 -2
- package/database/encryption/encryption-schema-registry.js +12 -93
- package/database/prisma.js +2 -4
- package/errors/index.js +4 -0
- package/errors/mapping-already-exists-error.js +14 -0
- package/generated/prisma-mongodb/edge.js +38 -33
- package/generated/prisma-mongodb/index-browser.js +35 -30
- package/generated/prisma-mongodb/index.d.ts +1880 -3191
- package/generated/prisma-mongodb/index.js +38 -33
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +50 -86
- package/generated/prisma-mongodb/wasm.js +38 -33
- package/generated/prisma-postgresql/edge.js +38 -33
- package/generated/prisma-postgresql/index-browser.js +35 -30
- package/generated/prisma-postgresql/index.d.ts +2050 -3390
- package/generated/prisma-postgresql/index.js +38 -33
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +48 -81
- package/generated/prisma-postgresql/wasm.js +38 -33
- package/handlers/WEBHOOKS.md +29 -0
- package/handlers/app-definition-loader.js +19 -128
- package/handlers/app-handler-helpers.js +3 -25
- package/handlers/backend-utils.js +25 -31
- package/handlers/middleware/admin-auth.js +1 -14
- package/handlers/routers/auth.js +10 -46
- package/handlers/routers/health.js +4 -13
- package/handlers/routers/integration-defined-routers.js +92 -144
- package/handlers/routers/integration-webhook-routers.js +64 -102
- package/handlers/workers/integration-defined-workers.js +2 -5
- package/handlers/workers/integration-triggers.js +25 -0
- package/index.js +3 -65
- package/infrastructure/scheduler/index.js +6 -19
- package/infrastructure/scheduler/recurrence.js +204 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +7 -55
- package/integrations/data-defined/README.md +185 -0
- package/integrations/data-defined/canonical.js +11 -0
- package/integrations/data-defined/capabilities/acmeerp.capabilities.json +608 -0
- package/integrations/data-defined/capabilities/asana.capabilities.json +265 -0
- package/integrations/data-defined/capabilities/calendly.capabilities.json +385 -0
- package/integrations/data-defined/capabilities/docusign.capabilities.json +314 -0
- package/integrations/data-defined/capabilities/fathom.capabilities.json +228 -0
- package/integrations/data-defined/capabilities/github.capabilities.json +563 -0
- package/integrations/data-defined/capabilities/hubspot.capabilities.json +715 -0
- package/integrations/data-defined/capabilities/jira.capabilities.json +376 -0
- package/integrations/data-defined/capabilities/klaviyo.capabilities.json +475 -0
- package/integrations/data-defined/capabilities/linear.capabilities.json +324 -0
- package/integrations/data-defined/capabilities/netsuite.capabilities.json +491 -0
- package/integrations/data-defined/capabilities/notion.capabilities.json +208 -0
- package/integrations/data-defined/capabilities/phone.capabilities.json +470 -0
- package/integrations/data-defined/capabilities/pipedrive.capabilities.json +388 -0
- package/integrations/data-defined/capabilities/quickbooks.capabilities.json +615 -0
- package/integrations/data-defined/capabilities/salesforce.capabilities.json +507 -0
- package/integrations/data-defined/capabilities/shopify.capabilities.json +1082 -0
- package/integrations/data-defined/capabilities/slack.capabilities.json +363 -0
- package/integrations/data-defined/capabilities/stripe.capabilities.json +580 -0
- package/integrations/data-defined/capabilities/webflow.capabilities.json +196 -0
- package/integrations/data-defined/capabilities/zendesk.capabilities.json +409 -0
- package/integrations/data-defined/compile.js +80 -0
- package/integrations/data-defined/config-form.js +137 -0
- package/integrations/data-defined/data-defined-integration.js +417 -0
- package/integrations/data-defined/examples/acme-erp-quickbooks.definition.json +467 -0
- package/integrations/data-defined/examples/asana-slack.definition.json +220 -0
- package/integrations/data-defined/examples/calendly-salesforce.definition.json +459 -0
- package/integrations/data-defined/examples/docusign-salesforce.definition.json +305 -0
- package/integrations/data-defined/examples/fathom-hubspot-notion.definition.json +575 -0
- package/integrations/data-defined/examples/github-linear.definition.json +316 -0
- package/integrations/data-defined/examples/hubspot-slack.definition.json +409 -0
- package/integrations/data-defined/examples/klaviyo-hubspot.definition.json +339 -0
- package/integrations/data-defined/examples/phone-hubspot.definition.json +637 -0
- package/integrations/data-defined/examples/pipedrive-hubspot.definition.json +136 -0
- package/integrations/data-defined/examples/pipedrive-phone.definition.json +340 -0
- package/integrations/data-defined/examples/records/phone-hubspot.definition-version.json +652 -0
- package/integrations/data-defined/examples/records/phone-hubspot.integration.json +86 -0
- package/integrations/data-defined/examples/records/phone-hubspot.mappings.json +39 -0
- package/integrations/data-defined/examples/records/phone-hubspot.workflow-run.json +62 -0
- package/integrations/data-defined/examples/shopify-klaviyo.definition.json +488 -0
- package/integrations/data-defined/examples/shopify-netsuite.definition.json +622 -0
- package/integrations/data-defined/examples/stripe-quickbooks.definition.json +476 -0
- package/integrations/data-defined/examples/webflow-hubspot-slack.definition.json +262 -0
- package/integrations/data-defined/examples/zendesk-jira.definition.json +604 -0
- package/integrations/data-defined/expressions.js +225 -0
- package/integrations/data-defined/index.js +39 -0
- package/integrations/data-defined/interpreter.js +89 -0
- package/integrations/data-defined/manifests.js +125 -0
- package/integrations/data-defined/repositories/definition-version-repository.js +67 -0
- package/integrations/data-defined/repositories/workflow-run-repository.js +59 -0
- package/integrations/data-defined/router.js +97 -0
- package/integrations/data-defined/runtime.js +208 -0
- package/integrations/data-defined/schema/capability-manifest.v0.1.schema.json +377 -0
- package/integrations/data-defined/schema/installation.v0.1.schema.json +130 -0
- package/integrations/data-defined/schema/integration-definition.v0.1.schema.json +902 -0
- package/integrations/data-defined/schema/records.md +98 -0
- package/integrations/data-defined/schema-check.js +58 -0
- package/integrations/data-defined/scripts/validate.js +66 -0
- package/integrations/data-defined/steps.js +89 -0
- package/integrations/data-defined/transforms.js +91 -0
- package/integrations/data-defined/use-cases/definition-use-cases.js +187 -0
- package/integrations/data-defined/validate.js +384 -0
- package/integrations/data-defined/versioning.js +176 -0
- package/integrations/data-defined/webhooks.js +119 -0
- package/integrations/index.js +0 -1
- package/integrations/integration-base.js +117 -0
- package/integrations/integration-router.js +101 -131
- package/integrations/repositories/integration-mapping-repository-documentdb.js +50 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +15 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +29 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +30 -0
- package/integrations/repositories/integration-mapping-repository.js +29 -0
- package/integrations/repositories/integration-repository-documentdb.js +9 -37
- package/integrations/repositories/integration-repository-interface.js +4 -17
- package/integrations/repositories/integration-repository-mongo.js +96 -64
- package/integrations/repositories/integration-repository-postgres.js +81 -63
- package/integrations/repositories/process-repository-mongo.js +1 -1
- package/integrations/tests/doubles/test-integration-repository.js +1 -12
- package/integrations/triggers.js +244 -0
- package/integrations/use-cases/create-integration.js +20 -55
- package/integrations/use-cases/get-integrations-for-user.js +40 -80
- package/integrations/use-cases/index.js +1 -7
- package/integrations/use-cases/run-trigger-tick.js +132 -0
- package/integrations/use-cases/run-trigger.js +77 -0
- package/integrations/utils/map-integration-dto.js +14 -43
- package/modules/index.js +0 -14
- package/modules/module-factory.js +0 -12
- package/modules/module.js +2 -53
- package/modules/repositories/module-repository-documentdb.js +6 -51
- package/modules/repositories/module-repository-interface.js +2 -19
- package/modules/repositories/module-repository-mongo.js +74 -67
- package/modules/repositories/module-repository-postgres.js +74 -66
- package/modules/repositories/module-repository.js +1 -2
- package/modules/requester/requester.js +5 -35
- package/modules/use-cases/get-entities-for-user.js +13 -26
- package/modules/use-cases/get-entity-options-by-id.js +4 -10
- 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 +4 -10
- package/modules/use-cases/process-authorization-callback.js +19 -53
- package/modules/use-cases/refresh-entity-options.js +4 -10
- package/modules/use-cases/test-module-auth.js +4 -10
- package/package.json +6 -6
- package/prisma-mongodb/schema.prisma +50 -86
- package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +66 -0
- package/prisma-postgresql/schema.prisma +48 -81
- package/queues/index.js +0 -25
- package/queues/queuer-util.js +9 -2
- package/types/index.d.ts +0 -2
- package/user/repositories/user-repository-documentdb.js +2 -27
- package/user/repositories/user-repository-interface.js +0 -18
- package/user/repositories/user-repository-mongo.js +0 -24
- package/user/repositories/user-repository-postgres.js +0 -18
- package/user/use-cases/authenticate-user.js +1 -7
- package/user/use-cases/get-user-from-adopter-jwt.js +124 -95
- package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
- package/user/user.js +1 -64
- package/credential/use-cases/credential-operations-for-user.js +0 -202
- package/credential/use-cases/reauthorize-credential-for-user.js +0 -73
- package/database/organization-where.js +0 -18
- package/database/unique-violation.js +0 -25
- package/extensions/bootstrap-runner.js +0 -43
- package/extensions/extension-loader.js +0 -187
- package/extensions/index.js +0 -41
- package/extensions/initialize-app.js +0 -123
- package/extensions/route-mounter.js +0 -103
- package/extensions/schema-composer.js +0 -188
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +0 -216
- package/infrastructure/scheduler/scheduled-job-repository.js +0 -118
- package/integrations/repositories/integration-not-found.js +0 -14
- package/integrations/schedule-config.js +0 -80
- package/integrations/use-cases/dispatch-ongoing-syncs.js +0 -100
- package/integrations/use-cases/integration-operations-for-user.js +0 -256
- package/integrations/use-cases/resolve-integration-ownership.js +0 -177
- package/management-api/create-management-api-router.js +0 -49
- package/management-api/error-response.js +0 -116
- package/management-api/management-api-config.js +0 -151
- package/management-api/meta-router.js +0 -76
- package/management-api/openapi/build-openapi.js +0 -144
- package/management-api/openapi/components.js +0 -415
- package/management-api/openapi/v1-operations.js +0 -83
- package/management-api/openapi/v2-operations.js +0 -264
- package/management-api/route-registry.js +0 -411
- package/management-api/test-support/http-client.js +0 -39
- package/management-api/use-cases/get-management-api-meta.js +0 -57
- package/management-api/v1-deprecation.js +0 -120
- package/management-api/v2/create-v2-router.js +0 -85
- package/management-api/v2/dependencies.js +0 -281
- package/management-api/v2/dto.js +0 -143
- package/management-api/v2/handlers/authorization.js +0 -94
- package/management-api/v2/handlers/credentials.js +0 -33
- package/management-api/v2/handlers/entities.js +0 -77
- package/management-api/v2/handlers/integrations.js +0 -128
- package/management-api/v2/handlers/proxy.js +0 -34
- package/management-api/v2/request.js +0 -85
- package/management-api/v2/test-support/fake-dependencies.js +0 -103
- package/modules/domain/authorization-session.js +0 -144
- package/modules/entity-scope.js +0 -15
- package/modules/entity-type-registry.js +0 -311
- package/modules/proxy/proxy-bases.js +0 -169
- package/modules/proxy/proxy-errors.js +0 -31
- package/modules/proxy/proxy-path.js +0 -183
- package/modules/proxy/proxy-policy.js +0 -277
- package/modules/repositories/authorization-session-repository-documentdb.js +0 -112
- package/modules/repositories/authorization-session-repository-factory.js +0 -31
- package/modules/repositories/authorization-session-repository-interface.js +0 -47
- package/modules/repositories/authorization-session-repository-prisma.js +0 -87
- package/modules/repositories/entity-not-found.js +0 -18
- package/modules/requester/proxy-request.js +0 -245
- package/modules/scope-config.js +0 -192
- package/modules/scope-policy.js +0 -112
- package/modules/scope-resolution.js +0 -288
- package/modules/scope-visibility.js +0 -247
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +0 -45
- package/modules/tests/doubles/ownership-matching.js +0 -18
- package/modules/use-cases/authorize-entity-for-user.js +0 -61
- package/modules/use-cases/create-entity-from-credential.js +0 -113
- package/modules/use-cases/entity-operations-for-user.js +0 -162
- package/modules/use-cases/entity-types.js +0 -139
- package/modules/use-cases/execute-entity-proxy-request.js +0 -331
- package/modules/use-cases/get-authorization-requirements.js +0 -100
- package/modules/use-cases/get-entity-module-for-user.js +0 -113
- package/modules/use-cases/manage-access.js +0 -21
- package/modules/use-cases/multi-step-authorization.js +0 -329
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +0 -28
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +0 -28
- package/providers/index.js +0 -23
- package/providers/resolve-provider.js +0 -93
- package/providers/runtime-detection.js +0 -124
- package/queues/providers/index.js +0 -11
- package/queues/providers/netlify-background-provider.js +0 -150
- package/queues/providers/qstash-queue-provider.js +0 -219
- package/queues/providers/sqs-queue-provider.js +0 -70
- package/queues/queue-client-interface.js +0 -55
- package/queues/queue-message-auth.js +0 -193
- package/queues/queue-provider-factory.js +0 -101
- package/queues/queue-provider.js +0 -65
- package/queues/sqs-queue-client.js +0 -81
- package/types/modules/index.d.ts +0 -214
- package/types/user/index.d.ts +0 -134
- package/user/adopter-jwt-config.js +0 -177
- package/user/adopter-jwt-errors.js +0 -59
- package/user/adopter-token-verifier.js +0 -122
- package/user/app-user-helpers.js +0 -97
- package/user/create-user-authentication.js +0 -64
- package/user/membership.js +0 -51
- package/user/repositories/organization-membership-record.js +0 -19
- package/user/repositories/organization-membership-repository-documentdb.js +0 -106
- package/user/repositories/organization-membership-repository-factory.js +0 -42
- package/user/repositories/organization-membership-repository-interface.js +0 -57
- package/user/repositories/organization-membership-repository-mongo.js +0 -13
- package/user/repositories/organization-membership-repository-postgres.js +0 -29
- package/user/repositories/organization-membership-repository-prisma.js +0 -81
- package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
- package/user/use-cases/ensure-organization-membership.js +0 -92
- package/user/use-cases/find-individual-users-by-organization.js +0 -54
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expressions for data-defined integrations (v0.1).
|
|
3
|
+
*
|
|
4
|
+
* Three forms, and no others (nothing is ever passed to eval):
|
|
5
|
+
* 1. Reference "$.event.contact.id" A string that is exactly a path. Resolves to the value, keeping its type.
|
|
6
|
+
* 2. Template "Call with {{event.from}}" A string with {{path | filter}} parts. Resolves to a string, except
|
|
7
|
+
* that a string which is exactly one tag ("{{record.amount | to_cents}}") keeps the value's type, so
|
|
8
|
+
* a filter can produce a number or a list for an API argument.
|
|
9
|
+
* 3. Condition "$.steps.contact == null && $.event.duration > 30" Used by `when` and trigger filters.
|
|
10
|
+
*
|
|
11
|
+
* Paths are read from a context object: { event, steps, settings, install, vars, item, record, now }.
|
|
12
|
+
* A missing segment resolves to undefined; it never throws.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const PATH_RE = /^\$\.[A-Za-z_][\w-]*(\.[\w-]+|\[\d+\])*$/;
|
|
16
|
+
|
|
17
|
+
function getPath(ctx, path) {
|
|
18
|
+
const clean = path.replace(/^\$\./, '');
|
|
19
|
+
let cur = ctx;
|
|
20
|
+
for (const seg of clean.split(/\.|(?=\[)/)) {
|
|
21
|
+
if (cur == null) return undefined;
|
|
22
|
+
const m = /^\[(\d+)\]$/.exec(seg);
|
|
23
|
+
cur = m ? cur[Number(m[1])] : cur[seg];
|
|
24
|
+
}
|
|
25
|
+
return cur;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const num = (v) => (v == null || v === '' ? null : Number(v));
|
|
29
|
+
const arith = (f) => (v, arg) => { const a = num(v), b = num(arg); return a == null || b == null || Number.isNaN(a) || Number.isNaN(b) ? null : f(a, b); };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Template filters. Each is a named, total function of (value, argument): no filter loops, calls out or throws on
|
|
33
|
+
* bad input (a non-number gives null, which renders as ''). Arithmetic is here, as named filters, instead of an
|
|
34
|
+
* expression language: {{item.amount | to_cents}}, {{record.created | from_unix}}.
|
|
35
|
+
*/
|
|
36
|
+
// ISO 4217 minor-unit exponents that are not 2 (Stripe's zero-decimal and three-decimal lists).
|
|
37
|
+
const MINOR_EXPONENT = { BIF: 0, CLP: 0, DJF: 0, GNF: 0, JPY: 0, KMF: 0, KRW: 0, MGA: 0, PYG: 0, RWF: 0, UGX: 0, VND: 0, VUV: 0, XAF: 0, XOF: 0, XPF: 0, BHD: 3, JOD: 3, KWD: 3, OMR: 3, TND: 3 };
|
|
38
|
+
const exponentOf = (currency) => MINOR_EXPONENT[String(currency || '').toUpperCase()] ?? 2;
|
|
39
|
+
/** a * 10^e, rounded half-up in decimal (19.995 -> 1999.5 -> 2000; 19.995 * 100 is 1999.4999... in binary). */
|
|
40
|
+
const shiftRound = (a, e) => Math.round(Number(`${a}e${e}`));
|
|
41
|
+
|
|
42
|
+
const FILTERS = {
|
|
43
|
+
upper: (v) => String(v ?? '').toUpperCase(),
|
|
44
|
+
lower: (v) => String(v ?? '').toLowerCase(),
|
|
45
|
+
minutes: (v) => (v == null ? '' : String(Math.round(Number(v) / 60000))),
|
|
46
|
+
seconds: (v) => (v == null ? '' : String(Math.round(Number(v) / 1000))),
|
|
47
|
+
date: (v) => (v ? new Date(v).toISOString().slice(0, 10) : ''),
|
|
48
|
+
json: (v) => JSON.stringify(v),
|
|
49
|
+
default: (v, arg) => (v == null || v === '' ? arg : v),
|
|
50
|
+
truncate: (v, arg) => { const s = String(v ?? ''); const n = Number(arg) || 80; return s.length > n ? s.slice(0, n - 1) + '…' : s; },
|
|
51
|
+
// lists
|
|
52
|
+
join: (v, arg) => (Array.isArray(v) ? v.filter((x) => x != null && x !== '').join(arg ?? ', ') : v),
|
|
53
|
+
size: (v) => (Array.isArray(v) || typeof v === 'string' ? v.length : v && typeof v === 'object' ? Object.keys(v).length : 0),
|
|
54
|
+
first: (v) => (Array.isArray(v) ? v[0] : v),
|
|
55
|
+
// arithmetic
|
|
56
|
+
plus: arith((a, b) => a + b),
|
|
57
|
+
minus: arith((a, b) => a - b),
|
|
58
|
+
times: arith((a, b) => a * b),
|
|
59
|
+
divided_by: arith((a, b) => (b === 0 ? null : a / b)),
|
|
60
|
+
// Rounded in decimal-exponent form, so 1.005 | round: 2 is 1.01 (1.005 * 100 is 100.49999... in binary floating point).
|
|
61
|
+
round: (v, arg) => { const a = num(v); if (a == null || Number.isNaN(a)) return null; const d = Math.max(0, Math.min(10, Number(arg) || 0)); return Number(`${shiftRound(a, d)}e-${d}`); },
|
|
62
|
+
abs: (v) => { const a = num(v); return a == null || Number.isNaN(a) ? null : Math.abs(a); },
|
|
63
|
+
number: (v) => { const a = num(v); return a == null || Number.isNaN(a) ? null : a; },
|
|
64
|
+
// money in minor units, rounded so 19.99 -> 1999 (19.99 * 100 is 1998.9999999999998)
|
|
65
|
+
to_cents: (v) => { const a = num(v); return a == null || Number.isNaN(a) ? null : shiftRound(a, 2); },
|
|
66
|
+
from_cents: (v) => { const a = num(v); return a == null || Number.isNaN(a) ? null : Math.round(a) / 100; },
|
|
67
|
+
// the same for any currency: {{record.amount | from_minor_units: record.currency}} (JPY has no minor unit, KWD three)
|
|
68
|
+
to_minor_units: (v, cur) => { const a = num(v); if (a == null || Number.isNaN(a)) return null; return shiftRound(a, exponentOf(cur)); },
|
|
69
|
+
from_minor_units: (v, cur) => { const a = num(v); if (a == null || Number.isNaN(a)) return null; const e = exponentOf(cur); return Math.round(a) / 10 ** e; },
|
|
70
|
+
// unix seconds <-> ISO 8601
|
|
71
|
+
from_unix: (v) => { const a = num(v); return a == null || Number.isNaN(a) ? null : new Date(a * 1000).toISOString(); },
|
|
72
|
+
to_unix: (v) => { const t = v ? Date.parse(v) : NaN; return Number.isNaN(t) ? null : Math.floor(t / 1000); },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** A filter argument is a quoted literal ('x'), a number, or a path read from the context (steps.full.owner). */
|
|
76
|
+
function filterArg(raw, ctx) {
|
|
77
|
+
if (raw == null) return undefined;
|
|
78
|
+
const a = raw.trim();
|
|
79
|
+
if (/^'.*'$/.test(a)) return a.slice(1, -1).replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
80
|
+
if (/^-?\d+(\.\d+)?$/.test(a)) return a;
|
|
81
|
+
return getPath(ctx, '$.' + a.replace(/^\$\./, ''));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const TAG_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
85
|
+
const SINGLE_TAG_RE = /^\{\{\s*([^}]+?)\s*\}\}$/;
|
|
86
|
+
|
|
87
|
+
/** Split one tag's expression into its path and filters: "item.amount | times: 100" -> { path, filters: [{ name, arg }] }. */
|
|
88
|
+
function parseTag(expr) {
|
|
89
|
+
const [p, ...filters] = expr.split('|').map((x) => x.trim());
|
|
90
|
+
return { path: p.replace(/^\$\./, ''), filters: filters.map((f) => { const m = /^(\w+)(?::\s*(.*))?$/.exec(f); return m ? { name: m[1], arg: m[2] } : { name: f, arg: undefined, bad: true }; }) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function evalTag(expr, ctx) {
|
|
94
|
+
const { path, filters } = parseTag(expr);
|
|
95
|
+
let v = getPath(ctx, '$.' + path);
|
|
96
|
+
for (const f of filters) {
|
|
97
|
+
const fn = !f.bad && FILTERS[f.name];
|
|
98
|
+
if (!fn) throw new Error(`Unknown template filter "${f.name}"`);
|
|
99
|
+
v = fn(v, filterArg(f.arg, ctx));
|
|
100
|
+
}
|
|
101
|
+
return v;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderTemplate(str, ctx) {
|
|
105
|
+
const single = SINGLE_TAG_RE.exec(str);
|
|
106
|
+
if (single) return evalTag(single[1], ctx) ?? '';
|
|
107
|
+
return str.replace(TAG_RE, (_, expr) => {
|
|
108
|
+
const v = evalTag(expr, ctx);
|
|
109
|
+
return v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Every tag in a value (deep), for validation: [{ path, filters }]. */
|
|
114
|
+
function templateTags(value, acc = []) {
|
|
115
|
+
if (typeof value === 'string') for (const m of value.matchAll(TAG_RE)) acc.push(parseTag(m[1]));
|
|
116
|
+
else if (Array.isArray(value)) value.forEach((v) => templateTags(v, acc));
|
|
117
|
+
else if (value && typeof value === 'object') Object.values(value).forEach((v) => templateTags(v, acc));
|
|
118
|
+
return acc;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Resolve every reference and template inside a value (deep). Non-string scalars pass through. */
|
|
122
|
+
function resolve(value, ctx) {
|
|
123
|
+
if (typeof value === 'string') {
|
|
124
|
+
if (PATH_RE.test(value)) return getPath(ctx, value);
|
|
125
|
+
if (value.includes('{{')) return renderTemplate(value, ctx);
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(value)) return value.map((v) => resolve(v, ctx));
|
|
129
|
+
if (value && typeof value === 'object') {
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const [k, v] of Object.entries(value)) out[k] = resolve(v, ctx);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------- conditions ----------
|
|
138
|
+
const TOKEN_RE = /\s*(\$\.[A-Za-z_][\w.\-[\]]*|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|==|!=|>=|<=|&&|\|\||[()!<>]|\bin\b|\bcontains\b|\bexists\b|\btrue\b|\bfalse\b|\bnull\b)/y;
|
|
139
|
+
|
|
140
|
+
function tokenize(src) {
|
|
141
|
+
const out = [];
|
|
142
|
+
TOKEN_RE.lastIndex = 0;
|
|
143
|
+
let pos = 0;
|
|
144
|
+
while (pos < src.length) {
|
|
145
|
+
if (/^\s*$/.test(src.slice(pos))) break;
|
|
146
|
+
TOKEN_RE.lastIndex = pos;
|
|
147
|
+
const m = TOKEN_RE.exec(src);
|
|
148
|
+
if (!m) throw new Error(`Cannot parse condition near "${src.slice(pos, pos + 20)}"`);
|
|
149
|
+
out.push(m[1]);
|
|
150
|
+
pos = TOKEN_RE.lastIndex;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Parse a condition into a small AST. Grammar: or := and ('||' and)* ; and := not ('&&' not)* ; not := '!' not | cmp ; cmp := atom (op atom | 'exists')? */
|
|
156
|
+
function parseCondition(src) {
|
|
157
|
+
const t = tokenize(src);
|
|
158
|
+
let i = 0;
|
|
159
|
+
const peek = () => t[i];
|
|
160
|
+
const next = () => t[i++];
|
|
161
|
+
function atom() {
|
|
162
|
+
const tok = next();
|
|
163
|
+
if (i > t.length) throw new Error(`Condition ends early: "${src}"`);
|
|
164
|
+
if (tok === '(') { const e = or(); if (next() !== ')') throw new Error(`Missing ) in "${src}"`); return e; }
|
|
165
|
+
if (tok.startsWith('$.')) return { ref: tok };
|
|
166
|
+
if (tok.startsWith("'")) return { lit: tok.slice(1, -1).replace(/\\'/g, "'") };
|
|
167
|
+
if (tok === 'true' || tok === 'false') return { lit: tok === 'true' };
|
|
168
|
+
if (tok === 'null') return { lit: null };
|
|
169
|
+
if (/^-?\d/.test(tok)) return { lit: Number(tok) };
|
|
170
|
+
throw new Error(`Unexpected "${tok}" in "${src}"`);
|
|
171
|
+
}
|
|
172
|
+
function cmp() {
|
|
173
|
+
const left = atom();
|
|
174
|
+
const op = peek();
|
|
175
|
+
if (op === 'exists') { next(); return { op: 'exists', left }; }
|
|
176
|
+
if (['==', '!=', '>', '>=', '<', '<=', 'in', 'contains'].includes(op)) { next(); return { op, left, right: atom() }; }
|
|
177
|
+
return { op: 'truthy', left };
|
|
178
|
+
}
|
|
179
|
+
function not() { if (peek() === '!') { next(); return { op: '!', left: not() }; } return cmp(); }
|
|
180
|
+
function and() { let l = not(); while (peek() === '&&') { next(); l = { op: '&&', left: l, right: not() }; } return l; }
|
|
181
|
+
function or() { let l = and(); while (peek() === '||') { next(); l = { op: '||', left: l, right: and() }; } return l; }
|
|
182
|
+
const ast = or();
|
|
183
|
+
if (i < t.length) throw new Error(`Unexpected "${t[i]}" in "${src}"`);
|
|
184
|
+
return ast;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function evalAst(n, ctx) {
|
|
188
|
+
if ('ref' in n) return getPath(ctx, n.ref);
|
|
189
|
+
if ('lit' in n) return n.lit;
|
|
190
|
+
const L = () => evalAst(n.left, ctx), R = () => evalAst(n.right, ctx);
|
|
191
|
+
switch (n.op) {
|
|
192
|
+
case 'truthy': return Boolean(L());
|
|
193
|
+
case 'exists': return L() != null;
|
|
194
|
+
case '!': return !evalAst(n.left, ctx);
|
|
195
|
+
case '&&': return Boolean(L()) && Boolean(R());
|
|
196
|
+
case '||': return Boolean(L()) || Boolean(R());
|
|
197
|
+
case '==': return L() == R(); // eslint-disable-line eqeqeq -- null == undefined is intended
|
|
198
|
+
case '!=': return L() != R(); // eslint-disable-line eqeqeq
|
|
199
|
+
case '>': return L() > R();
|
|
200
|
+
case '>=': return L() >= R();
|
|
201
|
+
case '<': return L() < R();
|
|
202
|
+
case '<=': return L() <= R();
|
|
203
|
+
case 'in': { const r = R(); return Array.isArray(r) ? r.includes(L()) : typeof r === 'string' && r.includes(String(L())); }
|
|
204
|
+
case 'contains': { const l = L(); return Array.isArray(l) ? l.includes(R()) : String(l ?? '').includes(String(R())); }
|
|
205
|
+
default: throw new Error(`Unknown operator ${n.op}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function evaluate(condition, ctx) {
|
|
210
|
+
if (condition == null) return true;
|
|
211
|
+
return Boolean(evalAst(parseCondition(condition), ctx));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Every $.root referenced by an expression-bearing value (for validation). */
|
|
215
|
+
function referencedPaths(value, acc = []) {
|
|
216
|
+
if (typeof value === 'string') {
|
|
217
|
+
if (PATH_RE.test(value)) acc.push(value);
|
|
218
|
+
for (const m of value.matchAll(/\{\{\s*([^}|]+?)\s*(\|[^}]*)?\}\}/g)) acc.push('$.' + m[1].replace(/^\$\./, ''));
|
|
219
|
+
for (const m of value.matchAll(/\$\.[A-Za-z_][\w.\-[\]]*/g)) if (!acc.includes(m[0])) acc.push(m[0]);
|
|
220
|
+
} else if (Array.isArray(value)) value.forEach((v) => referencedPaths(v, acc));
|
|
221
|
+
else if (value && typeof value === 'object') Object.values(value).forEach((v) => referencedPaths(v, acc));
|
|
222
|
+
return acc;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { getPath, resolve, renderTemplate, evaluate, parseCondition, referencedPaths, templateTags, FILTERS };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data-defined integrations, v0.1 (ADR-063). An integration as a document: modules, syncs, workflows, queries,
|
|
3
|
+
* UI surfaces and templates, run by one runtime class on the existing Frigg infrastructure.
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { validate } = require('./validate');
|
|
8
|
+
const { compile, routeWebhook, eventName } = require('./compile');
|
|
9
|
+
const { execute } = require('./interpreter');
|
|
10
|
+
const { makePorts, runWorkflow, runQuery, renderSurface, resolveView, runLifecycle, resolveOptions } = require('./runtime');
|
|
11
|
+
const { diff, checkBump, planUpgrade } = require('./versioning');
|
|
12
|
+
const { defineIntegrationClass, defineSharedIntegrationClass, memoryDefinitions } = require('./data-defined-integration');
|
|
13
|
+
const { BUILTINS } = require('./steps');
|
|
14
|
+
const { TRANSFORMS, applyMapping } = require('./transforms');
|
|
15
|
+
const { FILTERS } = require('./expressions');
|
|
16
|
+
const { validateManifest } = require('./manifests');
|
|
17
|
+
const { checkDefinition, checkManifest, checkInstallation } = require('./schema-check');
|
|
18
|
+
const useCases = require('./use-cases/definition-use-cases');
|
|
19
|
+
|
|
20
|
+
/** Load every capabilities/*.capabilities.json (bundled mocks; real modules ship their own). */
|
|
21
|
+
function loadManifests(dir = path.join(__dirname, 'capabilities')) {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const f of fs.readdirSync(dir)) if (f.endsWith('.capabilities.json')) { const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); out[m.module] = m; }
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
function loadExamples(dir = path.join(__dirname, 'examples')) {
|
|
27
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.definition.json')).map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
SCHEMA_VERSION: '0.1',
|
|
32
|
+
validate, validateManifest, checkDefinition, checkManifest, checkInstallation, compile, routeWebhook, eventName, execute,
|
|
33
|
+
makePorts, runWorkflow, runQuery, renderSurface, resolveView, runLifecycle,
|
|
34
|
+
diff, checkBump, planUpgrade,
|
|
35
|
+
defineIntegrationClass, defineSharedIntegrationClass, memoryDefinitions,
|
|
36
|
+
BUILTINS, TRANSFORMS, FILTERS, applyMapping, resolveOptions,
|
|
37
|
+
loadManifests, loadExamples,
|
|
38
|
+
...useCases,
|
|
39
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a list of steps against a context. Pure apart from the ports it is given:
|
|
3
|
+
* ports.call(moduleKey, capability, args) a module capability (the runtime maps it to Api.<method>)
|
|
4
|
+
* ports.links.find / ports.links.upsert the link + identity store (IntegrationMapping rows)
|
|
5
|
+
* ports.runCode(ref, args) registered code steps (optional)
|
|
6
|
+
*
|
|
7
|
+
* Every step leaves a trace entry: { id, use, status, ms, error? }. A step's result is stored at $.steps.<id>.
|
|
8
|
+
* onError per step: 'fail' (default: throw, so the queue retries the whole run), 'continue' (result null), 'stop'.
|
|
9
|
+
*/
|
|
10
|
+
const { resolve, evaluate } = require('./expressions');
|
|
11
|
+
const { BUILTINS, FOREACH_HARD_CAP, isBuiltin, splitCapability } = require('./steps');
|
|
12
|
+
|
|
13
|
+
class StopRun extends Error { constructor(reason) { super(reason); this.name = 'StopRun'; this.reason = reason; } }
|
|
14
|
+
|
|
15
|
+
async function runSteps(steps, env, path = 'steps') {
|
|
16
|
+
const { ctx, trace } = env;
|
|
17
|
+
for (let i = 0; i < (steps || []).length; i++) {
|
|
18
|
+
const step = steps[i];
|
|
19
|
+
const id = step.id || `${step.use}#${i}`;
|
|
20
|
+
const at = `${path}[${i}]`;
|
|
21
|
+
if (step.when && !evaluate(step.when, ctx)) { trace.push({ id, use: step.use, at, status: 'skipped' }); continue; }
|
|
22
|
+
const t0 = Date.now();
|
|
23
|
+
try {
|
|
24
|
+
let out;
|
|
25
|
+
if (step.use === 'foreach') {
|
|
26
|
+
const all = resolve(step.with?.over, ctx) ?? [];
|
|
27
|
+
if (!Array.isArray(all)) throw new Error('foreach: `over` is not an array');
|
|
28
|
+
const max = Math.min(step.with?.max || 100, FOREACH_HARD_CAP);
|
|
29
|
+
const saved = { item: ctx.item, index: ctx.index };
|
|
30
|
+
// `where` is checked per item, before the cap, so max bounds the work actually done.
|
|
31
|
+
const items = [];
|
|
32
|
+
for (const [n, item] of all.entries()) {
|
|
33
|
+
ctx.item = item; ctx.index = n;
|
|
34
|
+
if (!step.with?.where || evaluate(step.with.where, ctx)) items.push(item);
|
|
35
|
+
}
|
|
36
|
+
Object.assign(ctx, saved);
|
|
37
|
+
const collect = step.with && 'collect' in step.with;
|
|
38
|
+
out = [];
|
|
39
|
+
for (const [n, item] of items.slice(0, max).entries()) {
|
|
40
|
+
ctx.item = item; ctx.index = n;
|
|
41
|
+
await runSteps(step.do || [], env, `${at}.do#${n}`);
|
|
42
|
+
out.push(collect
|
|
43
|
+
? resolve(step.with.collect, ctx)
|
|
44
|
+
: Object.fromEntries((step.do || []).filter((s) => s.id).map((s) => [s.id, ctx.steps[s.id]])));
|
|
45
|
+
Object.assign(ctx, saved);
|
|
46
|
+
}
|
|
47
|
+
if (items.length > max) trace.push({ id, use: 'foreach', at, status: 'truncated', note: `${items.length - max} items over max ${max}` });
|
|
48
|
+
} else if (step.use === 'branch') {
|
|
49
|
+
const hit = (step.cases || []).find((c) => evaluate(c.when, ctx));
|
|
50
|
+
await runSteps(hit ? hit.do : step.else || [], env, `${at}.${hit ? 'case' : 'else'}`);
|
|
51
|
+
out = { matched: hit ? hit.when : null };
|
|
52
|
+
} else if (isBuiltin(step.use)) {
|
|
53
|
+
out = await BUILTINS[step.use].run(resolve(step.with || {}, ctx), env);
|
|
54
|
+
if (out && out.stop) { ctx.steps[id] = out; trace.push({ id, use: step.use, at, status: 'stopped', ms: Date.now() - t0, reason: out.reason }); throw new StopRun(out.reason); }
|
|
55
|
+
} else {
|
|
56
|
+
const cap = splitCapability(step.use);
|
|
57
|
+
if (!cap) throw new Error(`Unknown step "${step.use}"`);
|
|
58
|
+
out = await env.ports.call(cap.module, cap.capability, resolve(step.with || {}, ctx));
|
|
59
|
+
}
|
|
60
|
+
ctx.steps[id] = out === undefined ? null : out;
|
|
61
|
+
trace.push({ id, use: step.use, at, status: 'ok', ms: Date.now() - t0 });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e instanceof StopRun) throw e;
|
|
64
|
+
const policy = step.onError || 'fail';
|
|
65
|
+
trace.push({ id, use: step.use, at, status: 'error', ms: Date.now() - t0, error: String(e && e.message || e), policy });
|
|
66
|
+
if (policy === 'continue') { ctx.steps[id] = null; continue; }
|
|
67
|
+
if (policy === 'stop') throw new StopRun(`step ${id} failed`);
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run steps and report the outcome instead of throwing for a normal stop.
|
|
75
|
+
* @returns {Promise<{status:'completed'|'stopped', reason?:string, steps:object, trace:Array}>}
|
|
76
|
+
*/
|
|
77
|
+
async function execute(steps, { def, installation, ctx, ports }) {
|
|
78
|
+
const env = { def, installation, ports, trace: [], ctx: { steps: {}, vars: {}, ...ctx } };
|
|
79
|
+
try {
|
|
80
|
+
await runSteps(steps, env);
|
|
81
|
+
return { status: 'completed', steps: env.ctx.steps, trace: env.trace };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e instanceof StopRun) return { status: 'stopped', reason: e.reason, steps: env.ctx.steps, trace: env.trace };
|
|
84
|
+
e.trace = env.trace;
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { execute, runSteps, StopRun };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate a capability manifest (v0.1): the module author's promise of what a definition may call. Definitions are
|
|
3
|
+
* validated against manifests, so a broken manifest would let a broken definition through; this checks the manifest
|
|
4
|
+
* itself.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} m capabilities/<module>.capabilities.json
|
|
7
|
+
* @returns {{ valid: boolean, errors: Array<{path:string,message:string}> }}
|
|
8
|
+
*/
|
|
9
|
+
const { templateTags, parseCondition, FILTERS } = require('./expressions');
|
|
10
|
+
const { eventsIn } = require('./webhooks');
|
|
11
|
+
|
|
12
|
+
const PATH_RE = /^\$\.[A-Za-z_][\w-]*(\.[\w-]+|\[\d+\])*$/;
|
|
13
|
+
const KINDS = new Set(['read', 'search', 'write', 'delete', 'subscribe']); // schema/capability-manifest.v0.1.schema.json
|
|
14
|
+
const READS = new Set(['read', 'search']);
|
|
15
|
+
|
|
16
|
+
function validateManifest(m) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
const err = (path, message) => errors.push({ path, message });
|
|
19
|
+
if (!m || typeof m !== 'object') return { valid: false, errors: [{ path: '', message: 'Manifest must be an object' }] };
|
|
20
|
+
if (!/^[a-z][a-z0-9-]*$/.test(m.module || '')) err('module', 'Lowercase module name');
|
|
21
|
+
if (!m.label) err('label', 'Required');
|
|
22
|
+
|
|
23
|
+
const objects = m.objects || {};
|
|
24
|
+
for (const [o, def] of Object.entries(objects)) {
|
|
25
|
+
const names = new Set();
|
|
26
|
+
(def.fields || []).forEach((f, i) => {
|
|
27
|
+
if (!f?.name) err(`objects.${o}.fields[${i}]`, 'Field needs a name');
|
|
28
|
+
else if (names.has(f.name)) err(`objects.${o}.fields[${i}]`, `Duplicate field "${f.name}"`);
|
|
29
|
+
else names.add(f.name);
|
|
30
|
+
// An enum lists its values, or names one of this module's option lists when they differ per account
|
|
31
|
+
// (HubSpot deal stages are per portal).
|
|
32
|
+
if (f?.type === 'enum' && !Array.isArray(f.values) && !(typeof f.values === 'string' && m.options?.[f.values])) err(`objects.${o}.fields[${i}]`, 'An enum lists its values, or names an option list of this module');
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const caps = m.capabilities || {};
|
|
37
|
+
for (const [name, c] of Object.entries(caps)) {
|
|
38
|
+
const p = `capabilities.${name}`;
|
|
39
|
+
if (!/^[A-Za-z][\w]*\.[A-Za-z][\w]*$/.test(name)) err(p, 'Capability names are "<object>.<verb>"');
|
|
40
|
+
if (!KINDS.has(c?.kind)) err(`${p}.kind`, [...KINDS].join(', '));
|
|
41
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(c?.method || '')) err(`${p}.method`, 'Name of a method on the module\'s Api class');
|
|
42
|
+
for (const [arg, spec] of Object.entries(c?.input || {})) if (!spec?.type) err(`${p}.input.${arg}`, 'Input needs a type');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const [topic, e] of Object.entries(m.events || {})) {
|
|
46
|
+
const p = `events.${topic}`;
|
|
47
|
+
if (e.object && !objects[e.object]) err(`${p}.object`, `"${e.object}" is not an object of this module`);
|
|
48
|
+
if (e.payload && (e.delivery || 'webhook') === 'webhook' && m.webhook) {
|
|
49
|
+
// The example is a real delivery (body, plus headers when the topic is in one): it must route to this event
|
|
50
|
+
// through the same code a live webhook goes through, and its record path must find something.
|
|
51
|
+
const found = eventsIn(m, { body: e.payload, headers: e.headers }).find((x) => x.topic === topic);
|
|
52
|
+
if (!found) err(`${p}.payload`, `The example delivery does not route to "${topic}" (check webhook.topic and this event's match)`);
|
|
53
|
+
else if (e.record && found.record === undefined) err(`${p}.record`, `"${e.record}" finds nothing in the example event`);
|
|
54
|
+
} else if (e.record && e.payload) {
|
|
55
|
+
let cur = e.payload;
|
|
56
|
+
for (const seg of e.record.split(/\.|(?=\[)/)) { const i = /^\[(\d+)\]$/.exec(seg); cur = cur == null ? undefined : i ? cur[Number(i[1])] : cur[seg]; }
|
|
57
|
+
if (cur === undefined) err(`${p}.record`, `"${e.record}" is not a path in the example payload`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// An option list: a read capability, its inputs, and how each item becomes { label, value } (one reference, or
|
|
62
|
+
// a template). A string that mixes "$.x" into text renders the path literally, so it is refused.
|
|
63
|
+
const checkItemExpr = (v, p) => {
|
|
64
|
+
if (typeof v !== 'string') { err(p, 'A reference ($.item.id) or a template ({{item.name}})'); return; }
|
|
65
|
+
if (!PATH_RE.test(v) && v.includes('$.')) err(p, `"${v}" mixes a $. reference into text, which renders literally; use {{item.x}} for every part`);
|
|
66
|
+
for (const t of templateTags(v)) {
|
|
67
|
+
if (t.path.split(/\.|\[/)[0] !== 'item') err(p, `{{${t.path}}} — an option label can only read item`);
|
|
68
|
+
for (const f of t.filters) if (!FILTERS[f.name]) err(p, `Unknown template filter "${f.name}"`);
|
|
69
|
+
}
|
|
70
|
+
if (PATH_RE.test(v) && !v.startsWith('$.item')) err(p, 'An option value can only read $.item');
|
|
71
|
+
};
|
|
72
|
+
for (const [name, o] of Object.entries(m.options || {})) {
|
|
73
|
+
const p = `options.${name}`;
|
|
74
|
+
const c = caps[o?.capability];
|
|
75
|
+
if (!c) err(`${p}.capability`, `"${o?.capability}" is not a capability of this module`);
|
|
76
|
+
else if (!READS.has(c.kind)) err(`${p}.capability`, `An option list reads; ${o.capability} is a ${c.kind} capability`);
|
|
77
|
+
checkItemExpr(o?.label, `${p}.label`);
|
|
78
|
+
checkItemExpr(o?.value, `${p}.value`);
|
|
79
|
+
for (const [input, spec] of Object.entries(o?.inputs || {})) {
|
|
80
|
+
if (c && !(input in (c.input || {}))) err(`${p}.inputs.${input}`, `${o.capability} takes no argument "${input}"`);
|
|
81
|
+
if (!spec?.type) err(`${p}.inputs.${input}`, 'Input needs a type');
|
|
82
|
+
}
|
|
83
|
+
if (c) for (const [arg, spec] of Object.entries(c.input || {})) if (spec.required && !(arg in (o.inputs || {}))) err(`${p}.inputs`, `${o.capability} requires "${arg}", so the option list must declare it as an input`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Webhook delivery: how a POST from this provider becomes module events (webhooks.js). Templates may read body,
|
|
87
|
+
// headers (lower-case), query, item (one event of a batch) and parent (the outer item of a two-level batch).
|
|
88
|
+
const w = m.webhook;
|
|
89
|
+
const webhookEvents = Object.entries(m.events || {}).filter(([, e]) => (e.delivery || 'webhook') === 'webhook');
|
|
90
|
+
if (webhookEvents.length && !w) err('webhook', 'This module has webhook events, so it needs a webhook block saying where the topic is');
|
|
91
|
+
if (w) {
|
|
92
|
+
if (!['installation', 'app'].includes(w.scope)) err('webhook.scope', 'installation (a URL per installation) or app (one URL for every account)');
|
|
93
|
+
const tpl = (v, p, roots = ['body', 'headers', 'query', 'item', 'parent']) => {
|
|
94
|
+
for (const t of (Array.isArray(v) ? v : [v])) {
|
|
95
|
+
if (typeof t !== 'string') { err(p, 'A template string, or a list of them'); continue; }
|
|
96
|
+
for (const tag of templateTags(t)) {
|
|
97
|
+
if (!roots.includes(tag.path.split(/\.|\[/)[0])) err(p, `{{${tag.path}}}: a webhook template can read ${roots.join(', ')}`);
|
|
98
|
+
for (const f of tag.filters) if (!FILTERS[f.name]) err(p, `Unknown template filter "${f.name}"`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
if (!w.topic) err('webhook.topic', 'Required: where the provider puts the event type');
|
|
103
|
+
else tpl(w.topic, 'webhook.topic');
|
|
104
|
+
if (w.eventId) tpl(w.eventId, 'webhook.eventId'); else err('webhook.eventId', 'Required: the id that makes a redelivery a no-op');
|
|
105
|
+
if (w.scope === 'app' && !w.account) err('webhook.account', 'An app-level webhook needs account: which connected account an event is about');
|
|
106
|
+
if (w.account) tpl(w.account, 'webhook.account');
|
|
107
|
+
const batch = w.batch == null ? [] : Array.isArray(w.batch) ? w.batch : [w.batch];
|
|
108
|
+
if (batch.length > 2 || batch.some((b) => typeof b !== 'string')) err('webhook.batch', 'A path to the array of events, or [outer, inner] for two levels');
|
|
109
|
+
if (w.verify != null && !/^[A-Za-z_$][\w$]*$/.test(w.verify)) err('webhook.verify', 'Name of an Api method that checks the signature');
|
|
110
|
+
if (w.handshake) {
|
|
111
|
+
try { parseCondition(w.handshake.when); } catch (e) { err('webhook.handshake.when', e.message); }
|
|
112
|
+
if (w.handshake.status != null && !Number.isInteger(w.handshake.status)) err('webhook.handshake.status', 'An HTTP status code');
|
|
113
|
+
}
|
|
114
|
+
if (w.unwrap != null && typeof w.unwrap !== 'string') err('webhook.unwrap', 'The name of a form field holding the JSON body');
|
|
115
|
+
}
|
|
116
|
+
for (const [topic, e] of webhookEvents) {
|
|
117
|
+
if (e.match != null && typeof e.match !== 'string') err(`events.${topic}.match`, 'The provider\'s topic string for this event');
|
|
118
|
+
if (e.when) { try { parseCondition(e.when); } catch (x) { err(`events.${topic}.when`, x.message); } }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const pl of m.ui?.placements || []) if (typeof pl !== 'string') err('ui.placements', 'Placements are strings');
|
|
122
|
+
return { valid: errors.length === 0, errors };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { validateManifest };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IntegrationDefinitionVersion persistence (ADR-063). Pure data access: one Prisma call per method, no business rules.
|
|
3
|
+
* Which version is "latest", whether a publish is allowed and what a status change means are decided by the use cases
|
|
4
|
+
* (../use-cases/definition-use-cases.js); this layer only stores and reads rows.
|
|
5
|
+
*
|
|
6
|
+
* Rows come back as plain objects with string ids on every database. `document` is the definition verbatim.
|
|
7
|
+
*/
|
|
8
|
+
const { prisma } = require('../../../database/prisma');
|
|
9
|
+
|
|
10
|
+
const FIELDS = ['definitionId', 'version', 'schemaVersion', 'status', 'checksum', 'document', 'changeLevel', 'previousVersion', 'notes', 'createdBy', 'publishedAt'];
|
|
11
|
+
const pick = (o) => Object.fromEntries(FIELDS.filter((k) => o[k] !== undefined).map((k) => [k, o[k]]));
|
|
12
|
+
|
|
13
|
+
class DefinitionVersionRepositoryMongo {
|
|
14
|
+
constructor({ client } = {}) { this.prisma = client || prisma; }
|
|
15
|
+
toRow(r) { return r ? { ...r, id: String(r.id) } : null; }
|
|
16
|
+
|
|
17
|
+
async findVersion(definitionId, version) {
|
|
18
|
+
return this.toRow(await this.prisma.integrationDefinitionVersion.findUnique({ where: { definitionId_version: { definitionId, version } } }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Every version of one definition, any status, unordered (semver order is the caller's business). */
|
|
22
|
+
async listVersions(definitionId) {
|
|
23
|
+
return (await this.prisma.integrationDefinitionVersion.findMany({ where: { definitionId } })).map((r) => this.toRow(r));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Rows in the given statuses across all definitions, without documents (a catalogue listing). */
|
|
27
|
+
async listByStatus(statuses) {
|
|
28
|
+
const rows = await this.prisma.integrationDefinitionVersion.findMany({
|
|
29
|
+
where: { status: { in: statuses } },
|
|
30
|
+
select: { id: true, definitionId: true, version: true, schemaVersion: true, status: true, checksum: true, changeLevel: true, publishedAt: true, createdAt: true },
|
|
31
|
+
});
|
|
32
|
+
return rows.map((r) => this.toRow(r));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async createVersion(data) {
|
|
36
|
+
return this.toRow(await this.prisma.integrationDefinitionVersion.create({ data: pick(data) }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Change a row only if it is still in `fromStatus` (the use case's precondition, checked by the database, so two
|
|
41
|
+
* concurrent publishes cannot both win). Returns the updated row, or null if it was not in that status.
|
|
42
|
+
*/
|
|
43
|
+
async updateIfStatus(definitionId, version, fromStatus, data) {
|
|
44
|
+
const { count } = await this.prisma.integrationDefinitionVersion.updateMany({ where: { definitionId, version, status: fromStatus }, data: pick(data) });
|
|
45
|
+
return count ? this.findVersion(definitionId, version) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async deleteIfStatus(definitionId, version, status) {
|
|
49
|
+
const { count } = await this.prisma.integrationDefinitionVersion.deleteMany({ where: { definitionId, version, status } });
|
|
50
|
+
return count > 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class DefinitionVersionRepositoryPostgres extends DefinitionVersionRepositoryMongo {}
|
|
55
|
+
class DefinitionVersionRepositoryDocumentDB extends DefinitionVersionRepositoryMongo {}
|
|
56
|
+
|
|
57
|
+
function createDefinitionVersionRepository() {
|
|
58
|
+
const { DB_TYPE } = require('../../../database/config');
|
|
59
|
+
switch (DB_TYPE) {
|
|
60
|
+
case 'mongodb': return new DefinitionVersionRepositoryMongo();
|
|
61
|
+
case 'postgresql': return new DefinitionVersionRepositoryPostgres();
|
|
62
|
+
case 'documentdb': return new DefinitionVersionRepositoryDocumentDB();
|
|
63
|
+
default: throw new Error(`Unsupported database type: ${DB_TYPE}. Supported values: 'mongodb', 'documentdb', 'postgresql'`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { createDefinitionVersionRepository, DefinitionVersionRepositoryMongo, DefinitionVersionRepositoryPostgres, DefinitionVersionRepositoryDocumentDB };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkflowRun persistence (ADR-063). Pure data access. A run row never holds request or response bodies: `trace` is
|
|
3
|
+
* step ids, statuses, timings and error messages (see runtime.js).
|
|
4
|
+
*/
|
|
5
|
+
const { prisma } = require('../../../database/prisma');
|
|
6
|
+
|
|
7
|
+
class WorkflowRunRepositoryMongo {
|
|
8
|
+
constructor({ client } = {}) { this.prisma = client || prisma; }
|
|
9
|
+
toId(id) { return String(id); }
|
|
10
|
+
toRow(r) { return r ? { ...r, id: String(r.id), integrationId: String(r.integrationId) } : null; }
|
|
11
|
+
|
|
12
|
+
async createRun(run) {
|
|
13
|
+
return this.toRow(await this.prisma.workflowRun.create({
|
|
14
|
+
data: {
|
|
15
|
+
integrationId: this.toId(run.integrationId), definitionId: run.definitionId, definitionVersion: run.definitionVersion,
|
|
16
|
+
workflowId: run.workflowId, triggerType: run.triggerType, eventId: run.eventId ?? null, status: run.status,
|
|
17
|
+
reason: run.reason ?? null, error: run.error ?? null, retryable: Boolean(run.retryable), trace: run.trace || [],
|
|
18
|
+
startedAt: new Date(run.startedAt), endedAt: run.endedAt ? new Date(run.endedAt) : null,
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Newest first. `before` pages by startedAt. */
|
|
24
|
+
async listRuns(integrationId, { workflowId, status, before, limit = 50 } = {}) {
|
|
25
|
+
const rows = await this.prisma.workflowRun.findMany({
|
|
26
|
+
where: { integrationId: this.toId(integrationId), ...(workflowId ? { workflowId } : {}), ...(status ? { status } : {}), ...(before ? { startedAt: { lt: new Date(before) } } : {}) },
|
|
27
|
+
orderBy: { startedAt: 'desc' },
|
|
28
|
+
take: Math.min(Math.max(1, limit), 500),
|
|
29
|
+
});
|
|
30
|
+
return rows.map((r) => this.toRow(r));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Delete runs that started before `olderThan`, optionally only in some statuses. Returns the count. */
|
|
34
|
+
async deleteRunsBefore(olderThan, { statuses } = {}) {
|
|
35
|
+
const { count } = await this.prisma.workflowRun.deleteMany({ where: { startedAt: { lt: new Date(olderThan) }, ...(statuses ? { status: { in: statuses } } : {}) } });
|
|
36
|
+
return count;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class WorkflowRunRepositoryPostgres extends WorkflowRunRepositoryMongo {
|
|
41
|
+
toId(id) {
|
|
42
|
+
const n = Number.parseInt(id, 10);
|
|
43
|
+
if (Number.isNaN(n)) throw new Error(`Invalid integration id: ${id}`);
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
class WorkflowRunRepositoryDocumentDB extends WorkflowRunRepositoryMongo {}
|
|
48
|
+
|
|
49
|
+
function createWorkflowRunRepository() {
|
|
50
|
+
const { DB_TYPE } = require('../../../database/config');
|
|
51
|
+
switch (DB_TYPE) {
|
|
52
|
+
case 'mongodb': return new WorkflowRunRepositoryMongo();
|
|
53
|
+
case 'postgresql': return new WorkflowRunRepositoryPostgres();
|
|
54
|
+
case 'documentdb': return new WorkflowRunRepositoryDocumentDB();
|
|
55
|
+
default: throw new Error(`Unsupported database type: ${DB_TYPE}. Supported values: 'mongodb', 'documentdb', 'postgresql'`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { createWorkflowRunRepository, WorkflowRunRepositoryMongo, WorkflowRunRepositoryPostgres, WorkflowRunRepositoryDocumentDB };
|