@friggframework/core 2.0.0--canary.690.08cd07e.0 → 2.0.0--canary.693.d8859c2.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 +0 -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/index.js +3 -65
- package/infrastructure/scheduler/index.js +6 -19
- package/infrastructure/scheduler/scheduler-service-factory.js +7 -55
- package/integrations/data-defined/README.md +169 -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 +77 -0
- package/integrations/data-defined/config-form.js +110 -0
- package/integrations/data-defined/data-defined-integration.js +366 -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 +83 -0
- package/integrations/data-defined/schema/integration-definition.v0.1.schema.json +848 -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 +370 -0
- package/integrations/data-defined/versioning.js +161 -0
- package/integrations/data-defined/webhooks.js +119 -0
- package/integrations/index.js +0 -1
- package/integrations/integration-base.js +29 -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 +3 -35
- package/integrations/repositories/integration-repository-interface.js +1 -17
- package/integrations/repositories/integration-repository-mongo.js +77 -60
- package/integrations/repositories/integration-repository-postgres.js +76 -62
- package/integrations/repositories/process-repository-mongo.js +1 -1
- package/integrations/tests/doubles/test-integration-repository.js +1 -12
- 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/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/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,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate a definition (v0.1) against the capability manifests of the modules it uses.
|
|
3
|
+
*
|
|
4
|
+
* Structural rules mirror schema/integration-definition.v0.1.schema.json (kept dependency-free so core needs no
|
|
5
|
+
* JSON-schema library). Semantic rules are the ones a schema cannot express: every step names a capability the
|
|
6
|
+
* module declares and passes its required arguments; $.steps.x refers to an earlier step; $.settings.y is declared;
|
|
7
|
+
* trigger topics are events the module emits; UI placements are ones the host supports.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} def
|
|
10
|
+
* @param {Record<string, object>} manifests manifest name -> manifest (capabilities/*.capabilities.json)
|
|
11
|
+
* @returns {{ valid: boolean, errors: Array<{path:string,message:string}>, warnings: Array<{path:string,message:string}> }}
|
|
12
|
+
*/
|
|
13
|
+
const { BUILTINS, isBuiltin, splitCapability } = require('./steps');
|
|
14
|
+
const { parseCondition, referencedPaths, templateTags, FILTERS } = require('./expressions');
|
|
15
|
+
const { TRANSFORMS: TRANSFORM_FNS } = require('./transforms');
|
|
16
|
+
|
|
17
|
+
const ID = /^[a-z][a-z0-9-]*$/;
|
|
18
|
+
const SEMVER = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
|
|
19
|
+
const ROOTS = new Set(['event', 'record', 'steps', 'settings', 'install', 'vars', 'item', 'index', 'data', 'row', 'now']);
|
|
20
|
+
const COMPONENTS = new Set(['Stack', 'Heading', 'Text', 'KeyValue', 'List', 'Table', 'Badge', 'Button', 'Empty', 'Divider']);
|
|
21
|
+
const TRIGGERS = new Set(['webhook', 'schedule', 'userAction', 'syncRecord']);
|
|
22
|
+
const TRANSFORMS = new Set(Object.keys(TRANSFORM_FNS));
|
|
23
|
+
const INSTALL_KEYS = new Set(['webhookUrl', 'webhookUrls', 'portalId', 'runtime', 'integrationId', 'accounts']);
|
|
24
|
+
|
|
25
|
+
function validate(def, manifests = {}) {
|
|
26
|
+
const errors = [], warnings = [];
|
|
27
|
+
const err = (path, message) => errors.push({ path, message });
|
|
28
|
+
const warn = (path, message) => warnings.push({ path, message });
|
|
29
|
+
|
|
30
|
+
if (!def || typeof def !== 'object') return { valid: false, errors: [{ path: '', message: 'Definition must be an object' }], warnings };
|
|
31
|
+
if (def.schemaVersion !== '0.1') err('schemaVersion', 'Must be "0.1"');
|
|
32
|
+
if (!ID.test(def.id || '')) err('id', 'Lowercase letters, digits and dashes, starting with a letter');
|
|
33
|
+
if (!SEMVER.test(def.version || '')) err('version', 'Must be a semantic version (1.4.0)');
|
|
34
|
+
if (!def.display?.label) err('display.label', 'Required');
|
|
35
|
+
|
|
36
|
+
// ---------- modules ----------
|
|
37
|
+
const mods = def.modules || {};
|
|
38
|
+
if (!Object.keys(mods).length) err('modules', 'At least one module is required');
|
|
39
|
+
const manifestOf = {};
|
|
40
|
+
for (const [key, m] of Object.entries(mods)) {
|
|
41
|
+
if (!m?.module) { err(`modules.${key}`, 'Needs a module name'); continue; }
|
|
42
|
+
if (!manifests[m.module]) err(`modules.${key}`, `No capability manifest for module "${m.module}"`);
|
|
43
|
+
else manifestOf[key] = manifests[m.module];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------- ids are unique across kinds (they become event names) ----------
|
|
47
|
+
const seen = new Map();
|
|
48
|
+
for (const kind of ['syncs', 'links', 'workflows', 'queries', 'ui']) {
|
|
49
|
+
(def[kind] || []).forEach((x, i) => {
|
|
50
|
+
if (!ID.test(x?.id || '')) err(`${kind}[${i}].id`, 'Lowercase letters, digits and dashes, starting with a letter');
|
|
51
|
+
else if (seen.has(x.id)) err(`${kind}[${i}].id`, `Duplicate id "${x.id}" (also ${seen.get(x.id)})`);
|
|
52
|
+
else seen.set(x.id, `${kind}[${i}]`);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const installKeys = new Set([...Object.keys(def.settings || {}), ...INSTALL_KEYS]);
|
|
56
|
+
let warnedWebhookUrl = false;
|
|
57
|
+
const installStepIds = new Set((def.lifecycle?.onInstall || []).map((st) => st?.id).filter(Boolean));
|
|
58
|
+
const syncs = Object.fromEntries((def.syncs || []).map((s) => [s.id, s]));
|
|
59
|
+
const linkSets = Object.fromEntries((def.links || []).map((l) => [l.id, l]));
|
|
60
|
+
const workflows = Object.fromEntries((def.workflows || []).map((w) => [w.id, w]));
|
|
61
|
+
const queries = Object.fromEntries((def.queries || []).map((q) => [q.id, q]));
|
|
62
|
+
const uis = Object.fromEntries((def.ui || []).map((u) => [u.id, u]));
|
|
63
|
+
|
|
64
|
+
// ---------- settings ----------
|
|
65
|
+
const checkSettings = (settings, path) => {
|
|
66
|
+
for (const [k, s] of Object.entries(settings || {})) {
|
|
67
|
+
const p = `${path}.${k}`;
|
|
68
|
+
if (!s?.label) err(p, 'Setting needs a label');
|
|
69
|
+
if (!['string', 'text', 'number', 'boolean', 'enum', 'option', 'duration'].includes(s?.type)) err(p, `Unknown setting type "${s?.type}"`);
|
|
70
|
+
if (s?.type === 'enum' && !Array.isArray(s.enum)) err(p, 'enum settings need an enum list');
|
|
71
|
+
if (s?.type === 'option') checkOptionSource(s.options, `${p}.options`, k, settings, path !== 'settings');
|
|
72
|
+
}
|
|
73
|
+
// An option list may read earlier choices; a loop (a reads b, b reads a) could never be filled in.
|
|
74
|
+
const reads = (k) => Object.values(optionInputs(settings?.[k])).flatMap((v) => referencedPaths(v)).map((r) => r.split('.')).filter((x) => x[1] === 'settings').map((x) => x[2]);
|
|
75
|
+
for (const k of Object.keys(settings || {})) {
|
|
76
|
+
const seenK = new Set([k]); const stack = [...reads(k)];
|
|
77
|
+
while (stack.length) { const n = stack.pop(); if (n === k) { err(`${path}.${k}.options`, `Option inputs form a loop through "${k}"`); break; } if (!seenK.has(n)) { seenK.add(n); stack.push(...reads(n)); } }
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const optionInputs = (s) => (s && typeof s.options === 'object' ? s.options.with || {} : {});
|
|
81
|
+
function checkOptionSource(opts, p, self, siblings, isWorkflow) {
|
|
82
|
+
const ref = typeof opts === 'string' ? { source: opts } : opts;
|
|
83
|
+
if (!ref || typeof ref.source !== 'string') { err(p, 'options is "module.list" or { source: "module.list", with: { ... } }'); return; }
|
|
84
|
+
const [mk, ok] = ref.source.split('.');
|
|
85
|
+
if (!manifestOf[mk]) { err(p, `options "${ref.source}" names an unknown module key`); return; }
|
|
86
|
+
const list = manifestOf[mk].options?.[ok];
|
|
87
|
+
if (!list) { err(p, `Module "${mk}" has no option list "${ok}"`); return; }
|
|
88
|
+
const inputs = list.inputs || {};
|
|
89
|
+
for (const [name, spec] of Object.entries(inputs)) if (spec.required && !(name in (ref.with || {}))) err(`${p}.with.${name}`, `Option list ${ref.source} needs input "${name}"`);
|
|
90
|
+
for (const [name, v] of Object.entries(ref.with || {})) {
|
|
91
|
+
if (!(name in inputs)) { err(`${p}.with.${name}`, `Option list ${ref.source} takes no input "${name}"`); continue; }
|
|
92
|
+
for (const r of referencedPaths(v)) {
|
|
93
|
+
const [, root, key] = r.split(/\.|\[/);
|
|
94
|
+
if (root === 'settings' && key === self) err(`${p}.with.${name}`, 'An option list cannot read its own setting');
|
|
95
|
+
else if (root === 'settings' && !(key in (siblings || {}))) err(`${p}.with.${name}`, `$.settings.${key} is not a setting at this level`);
|
|
96
|
+
else if (root === 'install' && !(isWorkflow && key in (def.settings || {}))) err(`${p}.with.${name}`, `$.install.${key} is not an integration setting`);
|
|
97
|
+
else if (root !== 'settings' && root !== 'install') err(`${p}.with.${name}`, 'Option inputs can only read $.settings (or $.install from a workflow setting)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
checkSettings(def.settings, 'settings');
|
|
102
|
+
|
|
103
|
+
const objectFields = (key, object) => (manifestOf[key]?.objects?.[object]?.fields || []).map((f) => f.name);
|
|
104
|
+
const splitRef = (ref) => { const [k, o] = String(ref || '').split('.'); return { k, o }; };
|
|
105
|
+
|
|
106
|
+
// ---------- link sets: links between records with no sync behind them ----------
|
|
107
|
+
(def.links || []).forEach((l, i) => {
|
|
108
|
+
const p = `links[${i}]`;
|
|
109
|
+
if (!Array.isArray(l.between) || l.between.length !== 2) { err(`${p}.between`, 'Two "module.object" references'); return; }
|
|
110
|
+
l.between.forEach((ref, j) => {
|
|
111
|
+
const r = splitRef(ref);
|
|
112
|
+
if (!manifestOf[r.k]) err(`${p}.between[${j}]`, `Unknown module key "${r.k}"`);
|
|
113
|
+
else if (!manifestOf[r.k].objects?.[r.o]) err(`${p}.between[${j}]`, `Module "${r.k}" has no object "${r.o}"`);
|
|
114
|
+
});
|
|
115
|
+
if (splitRef(l.between[0]).k === splitRef(l.between[1]).k) err(`${p}.between`, 'A link set joins two different modules');
|
|
116
|
+
(l.identity?.match || []).forEach((m, j) => {
|
|
117
|
+
if (!m.key) err(`${p}.identity.match[${j}].key`, 'Name the identity key (email, phone)');
|
|
118
|
+
if (m.transform && !TRANSFORMS.has(m.transform)) err(`${p}.identity.match[${j}].transform`, `Unknown transform "${m.transform}"`);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
const checkRows = (rows, p, sf, tf, srcName, tgtName, tgtFields) => (rows || []).forEach((r, j) => {
|
|
122
|
+
const q = `${p}[${j}]`;
|
|
123
|
+
const fixed = r && 'value' in r;
|
|
124
|
+
if (fixed && r.source != null) err(q, 'A row has a source or a fixed value, not both');
|
|
125
|
+
else if (!fixed && !r?.source) err(q, 'A row needs a source or a fixed value');
|
|
126
|
+
if (!fixed && sf?.length && !sf.includes(r.source)) err(`${q}.source`, `"${r.source}" is not a field of ${srcName}`);
|
|
127
|
+
if (tf?.length && !tf.includes(r.target)) err(`${q}.target`, `"${r.target}" is not a field of ${tgtName}`);
|
|
128
|
+
const tfield = tgtFields?.find((f) => f.name === r.target);
|
|
129
|
+
if (tfield?.readOnly && r.direction !== 'to_source') err(`${q}.target`, `"${r.target}" is read-only in ${tgtName}`);
|
|
130
|
+
if (fixed && r.direction === 'to_source') err(`${q}.direction`, 'A fixed value only writes to the target');
|
|
131
|
+
if (r.transform && !TRANSFORMS.has(r.transform)) err(`${q}.transform`, `Unknown transform "${r.transform}"`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------- syncs ----------
|
|
135
|
+
(def.syncs || []).forEach((s, i) => {
|
|
136
|
+
const p = `syncs[${i}]`;
|
|
137
|
+
if (!s.label) err(`${p}.label`, 'Required');
|
|
138
|
+
if (!['one_way', 'two_way'].includes(s.direction)) err(`${p}.direction`, 'one_way or two_way');
|
|
139
|
+
const src = splitRef(s.source), tgt = splitRef(s.target);
|
|
140
|
+
for (const [side, r] of [['source', src], ['target', tgt]]) {
|
|
141
|
+
if (!manifestOf[r.k]) err(`${p}.${side}`, `Unknown module key "${r.k}"`);
|
|
142
|
+
else if (!manifestOf[r.k].objects?.[r.o]) err(`${p}.${side}`, `Module "${r.k}" has no object "${r.o}"`);
|
|
143
|
+
}
|
|
144
|
+
const sf = objectFields(src.k, src.o), tf = objectFields(tgt.k, tgt.o);
|
|
145
|
+
const match = s.identity?.match || [];
|
|
146
|
+
if (!match.length) err(`${p}.identity.match`, 'At least one identity key is required');
|
|
147
|
+
match.forEach((m, j) => {
|
|
148
|
+
if (!m.key) err(`${p}.identity.match[${j}].key`, 'Name the identity key (email, phone)');
|
|
149
|
+
if (sf.length && !sf.includes(m.source)) err(`${p}.identity.match[${j}].source`, `"${m.source}" is not a field of ${s.source}`);
|
|
150
|
+
if (tf.length && !tf.includes(m.target)) err(`${p}.identity.match[${j}].target`, `"${m.target}" is not a field of ${s.target}`);
|
|
151
|
+
});
|
|
152
|
+
checkRows(s.mapping, `${p}.mapping`, sf, tf, s.source, s.target, manifestOf[tgt.k]?.objects?.[tgt.o]?.fields);
|
|
153
|
+
if (s.ongoing?.mode === 'webhook') {
|
|
154
|
+
const hasEvents = (k, o) => Object.values(manifestOf[k]?.events || {}).some((e) => e.object === o);
|
|
155
|
+
if (manifestOf[src.k] && !hasEvents(src.k, src.o)) warn(`${p}.ongoing`, `${s.source} declares no webhook events; ongoing sync will need polling`);
|
|
156
|
+
}
|
|
157
|
+
if (s.filter) checkCondition(s.filter, `${p}.filter`);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------- steps ----------
|
|
161
|
+
function checkCondition(src, path) {
|
|
162
|
+
try { parseCondition(src); } catch (e) { err(path, e.message); return; }
|
|
163
|
+
checkRefs(src, path, null);
|
|
164
|
+
}
|
|
165
|
+
function checkFilters(value, path) {
|
|
166
|
+
for (const t of templateTags(value)) for (const f of t.filters) if (!FILTERS[f.name]) err(path, `Unknown template filter "${f.name}" (${Object.keys(FILTERS).join(', ')})`);
|
|
167
|
+
}
|
|
168
|
+
function checkRefs(value, path, scope) {
|
|
169
|
+
checkFilters(value, path);
|
|
170
|
+
for (const ref of referencedPaths(value)) {
|
|
171
|
+
const segs = ref.replace(/^\$\./, '').split(/\.|\[/);
|
|
172
|
+
const root = segs[0];
|
|
173
|
+
if (!ROOTS.has(root)) { err(path, `Unknown root "$.${root}" (use event, record, steps, settings, install, vars, item, data)`); continue; }
|
|
174
|
+
if (!scope) { if (root === 'install') checkInstallRef(segs, path, null); continue; }
|
|
175
|
+
if (root === 'steps' && segs[1] && !scope.steps.has(segs[1])) err(path, `$.steps.${segs[1]} is not an earlier step in this list`);
|
|
176
|
+
if (root === 'settings' && segs[1] && !scope.settings.has(segs[1])) err(path, `$.settings.${segs[1]} is not a setting of this workflow`);
|
|
177
|
+
if (root === 'install') checkInstallRef(segs, path, scope);
|
|
178
|
+
if ((root === 'item' || root === 'index') && !scope.inLoop) err(path, `$.${root} is only available inside foreach`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// $.install.<integration setting> · $.install.accounts.<module key>.<account field the manifest declares>
|
|
182
|
+
// $.install.runtime.installed.<onInstall step id> (not readable while install itself is running)
|
|
183
|
+
function checkInstallRef(segs, path, scope) {
|
|
184
|
+
const [, key, a, b] = segs;
|
|
185
|
+
if (!key) return;
|
|
186
|
+
if (!installKeys.has(key)) { err(path, `$.install.${key} is not an integration setting`); return; }
|
|
187
|
+
if (key === 'accounts' && a) {
|
|
188
|
+
if (!manifestOf[a]) err(path, `$.install.accounts.${a}: "${a}" is not a module key`);
|
|
189
|
+
else if (b && !(manifestOf[a].account?.fields || []).some((f) => f.name === b)) err(path, `$.install.accounts.${a}.${b}: ${manifestOf[a].module} declares no account field "${b}" (${(manifestOf[a].account?.fields || []).map((f) => f.name).join(', ') || 'none'})`);
|
|
190
|
+
}
|
|
191
|
+
if (key === 'webhookUrls' && a && !manifestOf[a]) err(path, `$.install.webhookUrls.${a}: "${a}" is not a module key`);
|
|
192
|
+
if (key === 'webhookUrl' && Object.keys(mods).length > 1 && !warnedWebhookUrl) {
|
|
193
|
+
warnedWebhookUrl = true;
|
|
194
|
+
warn(path, 'Use $.install.webhookUrls.<module key>: with more than one module, the URL should say which module a delivery comes from');
|
|
195
|
+
}
|
|
196
|
+
if (key === 'runtime' && a) {
|
|
197
|
+
if (a !== 'installed') err(path, `$.install.runtime.${a}: runtime only holds "installed" (the results of onInstall steps)`);
|
|
198
|
+
else if (scope?.installing) err(path, 'Install steps cannot read $.install.runtime.installed; refer to an earlier step with $.steps');
|
|
199
|
+
else if (b && !installStepIds.has(b)) err(path, `$.install.runtime.installed.${b}: no onInstall step has id "${b}"`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function checkSteps(steps, path, scope) {
|
|
203
|
+
if (!Array.isArray(steps)) { err(path, 'Steps must be a list'); return; }
|
|
204
|
+
steps.forEach((st, i) => {
|
|
205
|
+
const p = `${path}[${i}]`;
|
|
206
|
+
if (!st?.use) { err(p, 'Step needs "use"'); return; }
|
|
207
|
+
if (st.when) checkCondition(st.when, `${p}.when`), checkRefs(st.when, `${p}.when`, scope);
|
|
208
|
+
if (st.onError && !['fail', 'continue', 'stop'].includes(st.onError)) err(`${p}.onError`, 'fail, continue or stop');
|
|
209
|
+
const args = st.with || {};
|
|
210
|
+
if (st.use === 'foreach') {
|
|
211
|
+
checkRefs(args.over, `${p}.with.over`, scope);
|
|
212
|
+
const inner = { ...scope, steps: new Set(scope.steps), declared: new Set(), inLoop: true };
|
|
213
|
+
if (args.where) { checkCondition(args.where, `${p}.with.where`); checkRefs(args.where, `${p}.with.where`, inner); }
|
|
214
|
+
if (!st.do && !('collect' in args)) err(p, 'foreach needs do steps, collect, or both');
|
|
215
|
+
if (st.do) checkSteps(st.do, `${p}.do`, inner);
|
|
216
|
+
if ('collect' in args) checkRefs(args.collect, `${p}.with.collect`, inner);
|
|
217
|
+
if (args.max != null && !(Number.isInteger(args.max) && args.max > 0 && args.max <= 1000)) err(`${p}.with.max`, 'A whole number from 1 to 1000');
|
|
218
|
+
} else if (st.use === 'branch') {
|
|
219
|
+
(st.cases || []).forEach((c, j) => { checkCondition(c.when, `${p}.cases[${j}].when`); checkRefs(c.when, `${p}.cases[${j}].when`, scope); checkSteps(c.do, `${p}.cases[${j}].do`, { ...scope, steps: new Set(scope.steps) }); });
|
|
220
|
+
if (st.else) checkSteps(st.else, `${p}.else`, { ...scope, steps: new Set(scope.steps) });
|
|
221
|
+
} else if (isBuiltin(st.use)) {
|
|
222
|
+
checkRefs(args, `${p}.with`, scope);
|
|
223
|
+
if (st.use.startsWith('links.')) {
|
|
224
|
+
const isLink = 'link' in args;
|
|
225
|
+
if (isLink === ('sync' in args)) err(`${p}.with`, `${st.use} names a link set (link) or a sync (sync), exactly one`);
|
|
226
|
+
const set = isLink ? linkSets[args.link] : syncs[args.sync];
|
|
227
|
+
if (!set) err(`${p}.with.${isLink ? 'link' : 'sync'}`, `Unknown ${isLink ? 'link set' : 'sync'} "${isLink ? args.link : args.sync}"`);
|
|
228
|
+
else {
|
|
229
|
+
const keys = new Set((set.identity?.match || []).map((m) => m.key));
|
|
230
|
+
const mods = new Set((isLink ? set.between : [set.source, set.target]).map((r) => splitRef(r).k));
|
|
231
|
+
const label = `${isLink ? 'link set' : 'sync'} "${set.id}"`;
|
|
232
|
+
for (const k of Object.keys((st.use === 'links.find' ? args.key : args.keys) || {})) if (!keys.has(k)) err(`${p}.with.${st.use === 'links.find' ? 'key' : 'keys'}`, `"${k}" is not an identity key of ${label}${keys.size ? ` (${[...keys].join(', ')})` : ''}`);
|
|
233
|
+
if (st.use === 'links.find' && !args.key && !(args.from && args.id)) err(`${p}.with`, 'links.find needs key, or from + id');
|
|
234
|
+
if (args.from && !mods.has(args.from)) err(`${p}.with.from`, `"${args.from}" is not one of the modules ${label} links`);
|
|
235
|
+
for (const m of Object.keys(args.ids || {})) if (!mods.has(m)) err(`${p}.with.ids.${m}`, `"${m}" is not one of the modules ${label} links`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (st.use === 'merge' && !(Array.isArray(args.objects) || (typeof args.objects === 'string' && args.objects.startsWith('$.')))) err(`${p}.with.objects`, 'A list of objects (or a reference to one)');
|
|
239
|
+
if (st.use === 'map' && Array.isArray(args.mapping)) checkRows(args.mapping, `${p}.with.mapping`);
|
|
240
|
+
if (st.use === 'template' && args.id && !def.templates?.[args.id]) err(`${p}.with.id`, `Unknown template "${args.id}"`);
|
|
241
|
+
if (st.use === 'map' && args.sync && !syncs[args.sync]) err(`${p}.with.sync`, `Unknown sync "${args.sync}"`);
|
|
242
|
+
if (st.use === 'code' && !/^[@\w/.-]+#\w+@\d+\.\d+\.\d+$/.test(args.ref || '')) err(`${p}.with.ref`, 'Code steps are "package#function@x.y.z"');
|
|
243
|
+
} else {
|
|
244
|
+
const cap = splitCapability(st.use);
|
|
245
|
+
const man = cap && manifestOf[cap.module];
|
|
246
|
+
if (!man) err(`${p}.use`, `"${st.use}" is not a built-in, and "${cap?.module}" is not a module key`);
|
|
247
|
+
else {
|
|
248
|
+
const c = man.capabilities?.[cap.capability];
|
|
249
|
+
if (!c) err(`${p}.use`, `Module "${cap.module}" (${man.module}) has no capability "${cap.capability}"`);
|
|
250
|
+
else {
|
|
251
|
+
for (const [name, spec] of Object.entries(c.input || {})) if (spec.required && !(name in args)) err(`${p}.with.${name}`, `Required by ${st.use}`);
|
|
252
|
+
for (const name of Object.keys(args)) if (c.input && !(name in c.input)) err(`${p}.with.${name}`, `${st.use} takes no argument "${name}"`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
checkRefs(args, `${p}.with`, scope);
|
|
256
|
+
}
|
|
257
|
+
if (st.id) {
|
|
258
|
+
if (scope.steps.has(st.id) && scope.declared.has(st.id)) err(`${p}.id`, `Step id "${st.id}" is used twice`);
|
|
259
|
+
scope.steps.add(st.id); scope.declared.add(st.id);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const newScope = (settings) => ({ steps: new Set(), declared: new Set(), settings: new Set(Object.keys(settings || {})), install: installKeys, inLoop: false });
|
|
264
|
+
|
|
265
|
+
// ---------- workflows ----------
|
|
266
|
+
(def.workflows || []).forEach((w, i) => {
|
|
267
|
+
const p = `workflows[${i}]`;
|
|
268
|
+
if (!w.label) err(`${p}.label`, 'Required');
|
|
269
|
+
checkSettings(w.settings, `${p}.settings`);
|
|
270
|
+
const t = w.trigger || {};
|
|
271
|
+
if (!TRIGGERS.has(t.type)) err(`${p}.trigger.type`, `One of ${[...TRIGGERS].join(', ')}`);
|
|
272
|
+
if (t.type === 'webhook') {
|
|
273
|
+
if (!manifestOf[t.module]) err(`${p}.trigger.module`, `Unknown module key "${t.module}"`);
|
|
274
|
+
else if (!manifestOf[t.module].events?.[t.topic]) err(`${p}.trigger.topic`, `Module "${t.module}" emits no event "${t.topic}"`);
|
|
275
|
+
}
|
|
276
|
+
if (t.type === 'schedule' && String(t.cron || '').trim().split(/\s+/).length !== 5) err(`${p}.trigger.cron`, 'Five-field cron expression');
|
|
277
|
+
if (t.type === 'userAction' && t.from && !uis[t.from]) err(`${p}.trigger.from`, `Unknown UI surface "${t.from}"`);
|
|
278
|
+
if (t.type === 'syncRecord' && !syncs[t.sync]) err(`${p}.trigger.sync`, `Unknown sync "${t.sync}"`);
|
|
279
|
+
if (t.filter) checkCondition(t.filter, `${p}.trigger.filter`);
|
|
280
|
+
if (!w.steps?.length) err(`${p}.steps`, 'At least one step');
|
|
281
|
+
checkSteps(w.steps, `${p}.steps`, newScope(w.settings));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ---------- queries ----------
|
|
285
|
+
(def.queries || []).forEach((q, i) => {
|
|
286
|
+
const scope = newScope({});
|
|
287
|
+
checkSteps(q.steps || [], `queries[${i}].steps`, scope);
|
|
288
|
+
checkRefs(q.returns, `queries[${i}].returns`, scope);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ---------- ui ----------
|
|
292
|
+
// A view sees $.data (the query's returns), $.record and $.install; $.item inside a List's title/meta, $.row inside
|
|
293
|
+
// a Table column. Anything else is a typo that would render blank, so it is an error here, not at runtime.
|
|
294
|
+
function checkViewRefs(value, path, ctx) {
|
|
295
|
+
checkFilters(value, path);
|
|
296
|
+
for (const ref of referencedPaths(value)) {
|
|
297
|
+
const [root, key] = ref.replace(/^\$\./, '').split(/\.|\[/);
|
|
298
|
+
const allowed = ['data', 'record', 'install', ...(ctx.item ? ['item'] : []), ...(ctx.row ? ['row'] : [])];
|
|
299
|
+
if (!allowed.includes(root)) err(path, `$.${root} is not available here (a view can read ${allowed.map((r) => '$.' + r).join(', ')})`);
|
|
300
|
+
else if (root === 'data' && key && ctx.dataKeys && !ctx.dataKeys.has(key)) err(path, `$.data.${key} is not returned by query "${ctx.query}" (${[...ctx.dataKeys].join(', ')})`);
|
|
301
|
+
else if (root === 'install') checkInstallRef(ref.replace(/^\$\./, '').split(/\.|\[/), path, null);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function checkView(node, path, ctx) {
|
|
305
|
+
if (!node || !COMPONENTS.has(node.component)) { err(path, `Unknown component "${node?.component}" (v0.1: ${[...COMPONENTS].join(', ')})`); return; }
|
|
306
|
+
if (node.when) { checkCondition(node.when, `${path}.when`); checkViewRefs(node.when, `${path}.when`, ctx); }
|
|
307
|
+
const props = { ...(node.props || {}) };
|
|
308
|
+
if (node.component === 'List') {
|
|
309
|
+
checkViewRefs(props.items, `${path}.props.items`, ctx);
|
|
310
|
+
for (const k of ['title', 'meta']) checkViewRefs(props[k], `${path}.props.${k}`, { ...ctx, item: true });
|
|
311
|
+
delete props.items; delete props.title; delete props.meta;
|
|
312
|
+
} else if (node.component === 'Table') {
|
|
313
|
+
checkViewRefs(props.rows, `${path}.props.rows`, ctx);
|
|
314
|
+
(props.columns || []).forEach((c, j) => checkViewRefs(c.value, `${path}.props.columns[${j}].value`, { ...ctx, row: true }));
|
|
315
|
+
delete props.rows; delete props.columns;
|
|
316
|
+
}
|
|
317
|
+
const { action, ...rest } = props;
|
|
318
|
+
checkViewRefs(rest, `${path}.props`, ctx);
|
|
319
|
+
if (action?.url) checkViewRefs(action.url, `${path}.props.action.url`, ctx);
|
|
320
|
+
const a = node.props?.action;
|
|
321
|
+
if (a?.type === 'userAction') {
|
|
322
|
+
const w = workflows[a.workflow];
|
|
323
|
+
if (!w) err(`${path}.props.action.workflow`, `Unknown workflow "${a.workflow}"`);
|
|
324
|
+
else if (w.trigger?.type !== 'userAction') err(`${path}.props.action.workflow`, `Workflow "${a.workflow}" is not triggered by a user action`);
|
|
325
|
+
} else if (a && a.type !== 'open_url') err(`${path}.props.action.type`, 'userAction or open_url');
|
|
326
|
+
(node.children || []).forEach((c, j) => checkView(c, `${path}.children[${j}]`, ctx));
|
|
327
|
+
}
|
|
328
|
+
(def.ui || []).forEach((u, i) => {
|
|
329
|
+
const p = `ui[${i}]`;
|
|
330
|
+
const host = manifestOf[u.host];
|
|
331
|
+
if (!host) err(`${p}.host`, `Unknown module key "${u.host}"`);
|
|
332
|
+
else if (!(host.ui?.placements || []).includes(u.placement)) err(`${p}.placement`, `"${u.placement}" is not a placement ${host.module} supports (${(host.ui?.placements || []).join(', ') || 'none'})`);
|
|
333
|
+
const q = queries[u.data?.query];
|
|
334
|
+
if (!q) err(`${p}.data.query`, `Unknown query "${u.data?.query}"`);
|
|
335
|
+
const returns = q?.returns;
|
|
336
|
+
const dataKeys = returns && typeof returns === 'object' && !Array.isArray(returns) ? new Set(Object.keys(returns)) : null;
|
|
337
|
+
checkView(u.view, `${p}.view`, { dataKeys, query: u.data?.query });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ---------- named templates ----------
|
|
341
|
+
// A template renders in the context of the step that uses it, so its paths are checked for root and filter here
|
|
342
|
+
// and its $.steps references against each workflow that renders it.
|
|
343
|
+
const usedTemplates = new Set();
|
|
344
|
+
const collectTemplateUse = (steps, w) => (steps || []).forEach((st) => {
|
|
345
|
+
if (st?.use === 'template' && st.with?.id) usedTemplates.add(st.with.id);
|
|
346
|
+
collectTemplateUse(st?.do, w); (st?.cases || []).forEach((c) => collectTemplateUse(c.do, w)); collectTemplateUse(st?.else, w);
|
|
347
|
+
});
|
|
348
|
+
(def.workflows || []).forEach((w) => collectTemplateUse(w.steps, w));
|
|
349
|
+
(def.queries || []).forEach((q) => collectTemplateUse(q.steps));
|
|
350
|
+
for (const hook of ['onInstall', 'onUninstall']) collectTemplateUse(def.lifecycle?.[hook]);
|
|
351
|
+
for (const [id, text] of Object.entries(def.templates || {})) {
|
|
352
|
+
if (typeof text !== 'string') { err(`templates.${id}`, 'A template is a string'); continue; }
|
|
353
|
+
checkRefs(text, `templates.${id}`, null);
|
|
354
|
+
if (!usedTemplates.has(id)) warn(`templates.${id}`, 'No step renders this template');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------- lifecycle ----------
|
|
358
|
+
// What install creates, uninstall should undo: an install step whose result onUninstall never reads is probably a
|
|
359
|
+
// webhook nobody deletes. A warning, because some install steps create nothing that needs undoing.
|
|
360
|
+
const uninstallReads = JSON.stringify(def.lifecycle?.onUninstall || []);
|
|
361
|
+
for (const [i, st] of (def.lifecycle?.onInstall || []).entries()) {
|
|
362
|
+
if (!st?.id) warn(`lifecycle.onInstall[${i}]`, 'This install step has no id, so its result is not kept and uninstall cannot undo it');
|
|
363
|
+
else if (!uninstallReads.includes(`install.runtime.installed.${st.id}`)) warn(`lifecycle.onInstall[${i}]`, `onUninstall never reads $.install.runtime.installed.${st.id}, so what this step creates is never removed`);
|
|
364
|
+
}
|
|
365
|
+
for (const hook of ['onInstall', 'onUninstall']) if (def.lifecycle?.[hook]) checkSteps(def.lifecycle[hook], `lifecycle.${hook}`, { ...newScope({}), installing: hook === 'onInstall' });
|
|
366
|
+
|
|
367
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = { validate };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioning for data-defined integrations.
|
|
3
|
+
*
|
|
4
|
+
* A published version is immutable and every installation pins one exact version, so a release never changes a
|
|
5
|
+
* running customer until that installation is upgraded. Three questions are answered here:
|
|
6
|
+
*
|
|
7
|
+
* diff(from, to) what changed, and how big each change is (patch / minor / major)
|
|
8
|
+
* checkBump(from, to) is the declared version bump at least as big as the changes require?
|
|
9
|
+
* planUpgrade(install, a, b) can this installation move from a to b, and what has to happen when it does?
|
|
10
|
+
*
|
|
11
|
+
* The rule of thumb: patch = behaviour inside the same contract (steps, templates, view, mapping rows);
|
|
12
|
+
* minor = additive contract (new workflow, optional setting, new UI); major = anything an installation could be
|
|
13
|
+
* relying on goes away or changes meaning (removed workflow, required setting, identity change, new module).
|
|
14
|
+
*/
|
|
15
|
+
const RANK = { none: 0, patch: 1, minor: 2, major: 3 };
|
|
16
|
+
const max = (a, b) => (RANK[a] >= RANK[b] ? a : b);
|
|
17
|
+
const byId = (xs) => Object.fromEntries((xs || []).map((x) => [x.id, x]));
|
|
18
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
19
|
+
const optionSource = (o) => (o && typeof o === 'object' ? o.source : o);
|
|
20
|
+
|
|
21
|
+
function diffSettings(a = {}, b = {}, path, push) {
|
|
22
|
+
for (const k of Object.keys(a)) if (!(k in b)) push('minor', `${path}.${k}`, 'Setting removed (saved values are dropped)');
|
|
23
|
+
for (const [k, s] of Object.entries(b)) {
|
|
24
|
+
if (!(k in a)) push(s.required && s.default === undefined ? 'major' : 'minor', `${path}.${k}`, s.required && s.default === undefined ? 'New required setting with no default' : 'New optional setting');
|
|
25
|
+
else if (a[k].type !== s.type || optionSource(a[k].options) !== optionSource(s.options)) push('major', `${path}.${k}`, 'Setting type or option list changed');
|
|
26
|
+
else if (!same(a[k].options, s.options)) push('minor', `${path}.${k}`, 'Option list inputs changed');
|
|
27
|
+
else if (!a[k].required && s.required && s.default === undefined) push('major', `${path}.${k}`, 'Setting became required');
|
|
28
|
+
else if (!same(a[k], s)) push('patch', `${path}.${k}`, 'Setting label, help or default changed');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function diff(from, to) {
|
|
33
|
+
const changes = [];
|
|
34
|
+
const push = (level, path, message) => changes.push({ level, path, message });
|
|
35
|
+
if (from.id !== to.id) push('major', 'id', 'Different integration');
|
|
36
|
+
|
|
37
|
+
for (const k of Object.keys(from.modules || {})) if (!to.modules?.[k]) push('major', `modules.${k}`, 'Module removed');
|
|
38
|
+
for (const [k, m] of Object.entries(to.modules || {})) {
|
|
39
|
+
if (!from.modules?.[k]) push('major', `modules.${k}`, 'New module: installations must connect it');
|
|
40
|
+
else if (from.modules[k].module !== m.module) push('major', `modules.${k}`, 'Module replaced');
|
|
41
|
+
else if (from.modules[k].version !== m.version) push('patch', `modules.${k}`, 'Module version range changed');
|
|
42
|
+
}
|
|
43
|
+
diffSettings(from.settings, to.settings, 'settings', push);
|
|
44
|
+
|
|
45
|
+
const sa = byId(from.syncs), sb = byId(to.syncs);
|
|
46
|
+
for (const id of Object.keys(sa)) if (!sb[id]) push('major', `syncs.${id}`, 'Sync removed');
|
|
47
|
+
for (const [id, s] of Object.entries(sb)) {
|
|
48
|
+
const o = sa[id];
|
|
49
|
+
if (!o) { push('minor', `syncs.${id}`, 'New sync'); continue; }
|
|
50
|
+
if (o.source !== s.source || o.target !== s.target) push('major', `syncs.${id}`, 'Sync objects changed');
|
|
51
|
+
if (o.direction !== s.direction) push('major', `syncs.${id}.direction`, 'Sync direction changed');
|
|
52
|
+
if (!same(o.identity, s.identity)) push('major', `syncs.${id}.identity`, 'Identity keys changed: existing links must be rebuilt');
|
|
53
|
+
if (!same(o.mapping, s.mapping)) push('patch', `syncs.${id}.mapping`, 'Default mapping changed (installations with their own mapping keep it)');
|
|
54
|
+
if (!same(o.ongoing, s.ongoing) || !same(o.backfill, s.backfill) || o.filter !== s.filter) push('minor', `syncs.${id}`, 'Sync schedule or filter changed');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const la = byId(from.links), lb = byId(to.links);
|
|
58
|
+
for (const id of Object.keys(la)) if (!lb[id]) push('major', `links.${id}`, 'Link set removed: workflows that read it lose their links');
|
|
59
|
+
for (const [id, l] of Object.entries(lb)) {
|
|
60
|
+
const o = la[id];
|
|
61
|
+
if (!o) { push('minor', `links.${id}`, 'New link set'); continue; }
|
|
62
|
+
if (!same(o.between, l.between)) push('major', `links.${id}.between`, 'Link set joins different objects');
|
|
63
|
+
if (!same(o.identity, l.identity)) push('major', `links.${id}.identity`, 'Identity keys changed: existing links must be rebuilt');
|
|
64
|
+
if (o.label !== l.label || o.description !== l.description) push('patch', `links.${id}`, 'Label or description changed');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const wa = byId(from.workflows), wb = byId(to.workflows);
|
|
68
|
+
for (const id of Object.keys(wa)) if (!wb[id]) push('major', `workflows.${id}`, 'Workflow removed');
|
|
69
|
+
for (const [id, w] of Object.entries(wb)) {
|
|
70
|
+
const o = wa[id];
|
|
71
|
+
if (!o) { push('minor', `workflows.${id}`, `New workflow (default ${w.default || 'on'})`); continue; }
|
|
72
|
+
if (o.trigger?.type !== w.trigger?.type || o.trigger?.module !== w.trigger?.module) push('major', `workflows.${id}.trigger`, 'Trigger source changed');
|
|
73
|
+
else if (!same(o.trigger, w.trigger)) push('minor', `workflows.${id}.trigger`, 'Trigger topic, schedule or filter changed');
|
|
74
|
+
diffSettings(o.settings, w.settings, `workflows.${id}.settings`, push);
|
|
75
|
+
if (!same(o.steps, w.steps)) push('patch', `workflows.${id}.steps`, 'Steps changed');
|
|
76
|
+
if (o.label !== w.label || o.description !== w.description) push('patch', `workflows.${id}`, 'Label or description changed');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const qa = byId(from.queries), qb = byId(to.queries);
|
|
80
|
+
for (const id of new Set([...Object.keys(qa), ...Object.keys(qb)])) if (!same(qa[id], qb[id])) push('patch', `queries.${id}`, qa[id] ? (qb[id] ? 'Query changed' : 'Query removed') : 'New query');
|
|
81
|
+
const ua = byId(from.ui), ub = byId(to.ui);
|
|
82
|
+
for (const id of Object.keys(ua)) if (!ub[id]) push('minor', `ui.${id}`, 'UI surface removed');
|
|
83
|
+
for (const [id, u] of Object.entries(ub)) {
|
|
84
|
+
if (!ua[id]) push('minor', `ui.${id}`, 'New UI surface');
|
|
85
|
+
else if (ua[id].host !== u.host || ua[id].placement !== u.placement) push('minor', `ui.${id}`, 'UI surface moved');
|
|
86
|
+
else if (!same(ua[id], u)) push('patch', `ui.${id}`, 'UI view or data changed');
|
|
87
|
+
}
|
|
88
|
+
if (!same(from.templates, to.templates)) push('patch', 'templates', 'Templates changed');
|
|
89
|
+
if (!same(from.lifecycle, to.lifecycle)) push('minor', 'lifecycle', 'Install/uninstall steps changed: upgrades re-run onInstall');
|
|
90
|
+
if (!same(from.display, to.display)) push('patch', 'display', 'Display text changed');
|
|
91
|
+
|
|
92
|
+
const level = changes.reduce((l, c) => max(l, c.level), 'none');
|
|
93
|
+
return { level, changes };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parse = (v) => String(v).split('-')[0].split('.').map(Number);
|
|
97
|
+
function bumpOf(fromV, toV) {
|
|
98
|
+
const [a, b, c] = parse(fromV), [x, y, z] = parse(toV);
|
|
99
|
+
if (x > a) return 'major';
|
|
100
|
+
if (x === a && y > b) return 'minor';
|
|
101
|
+
if (x === a && y === b && z > c) return 'patch';
|
|
102
|
+
return 'none';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The declared bump must cover the computed one (a major change cannot ship as 1.4.1). */
|
|
106
|
+
function checkBump(from, to) {
|
|
107
|
+
const d = diff(from, to);
|
|
108
|
+
const declared = bumpOf(from.version, to.version);
|
|
109
|
+
const ok = declared !== 'none' && RANK[declared] >= RANK[d.level];
|
|
110
|
+
return { ok, declared, required: d.level, changes: d.changes, message: ok ? null : declared === 'none' ? `${to.version} is not newer than ${from.version}` : `Changes need a ${d.level} bump; ${from.version} -> ${to.version} is ${declared}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What it takes to move one installation between versions.
|
|
115
|
+
* @returns {{ allowed:boolean, needs:Array<{kind:string,path:string,message:string}>, actions:Array<{kind:string,id?:string,message:string}>, config:object }}
|
|
116
|
+
* needs things a person must supply first (a required setting, a module connection)
|
|
117
|
+
* actions what the runtime does on upgrade (enable/disable workflows, rebuild links, re-run onInstall)
|
|
118
|
+
* config the installation config after the upgrade (pinned to the new version)
|
|
119
|
+
*/
|
|
120
|
+
function planUpgrade(installation, from, to) {
|
|
121
|
+
const needs = [], actions = [];
|
|
122
|
+
const config = JSON.parse(JSON.stringify(installation || {}));
|
|
123
|
+
config.definition = { id: to.id, version: to.version };
|
|
124
|
+
|
|
125
|
+
for (const k of Object.keys(to.modules || {})) if (!from.modules?.[k]) needs.push({ kind: 'connect', path: `modules.${k}`, message: `Connect ${to.modules[k].module}` });
|
|
126
|
+
for (const [k, s] of Object.entries(to.settings || {})) {
|
|
127
|
+
if (s.required && s.default === undefined && config.settings?.[k] === undefined) needs.push({ kind: 'setting', path: `settings.${k}`, message: `Choose "${s.label}"` });
|
|
128
|
+
}
|
|
129
|
+
const wa = byId(from.workflows), wb = byId(to.workflows);
|
|
130
|
+
config.workflows ||= {};
|
|
131
|
+
for (const id of Object.keys(config.workflows)) if (!wb[id]) { delete config.workflows[id]; actions.push({ kind: 'disable', id, message: `Workflow "${wa[id]?.label || id}" no longer exists` }); }
|
|
132
|
+
for (const [id, w] of Object.entries(wb)) {
|
|
133
|
+
const enabled = config.workflows[id]?.enabled ?? ((w.default || 'on') === 'on');
|
|
134
|
+
if (!wa[id]) {
|
|
135
|
+
config.workflows[id] = { enabled: (w.default || 'on') === 'on', settings: {} };
|
|
136
|
+
actions.push({ kind: config.workflows[id].enabled ? 'enable' : 'offer', id, message: `${config.workflows[id].enabled ? 'Turns on' : 'Available, off'}: ${w.label}` });
|
|
137
|
+
}
|
|
138
|
+
if (!enabled) continue;
|
|
139
|
+
for (const [k, s] of Object.entries(w.settings || {})) {
|
|
140
|
+
if (s.required && s.default === undefined && config.workflows[id]?.settings?.[k] === undefined) needs.push({ kind: 'setting', path: `workflows.${id}.settings.${k}`, message: `"${w.label}": choose "${s.label}"` });
|
|
141
|
+
}
|
|
142
|
+
const keep = new Set(Object.keys(w.settings || {}));
|
|
143
|
+
for (const k of Object.keys(config.workflows[id]?.settings || {})) if (!keep.has(k)) delete config.workflows[id].settings[k];
|
|
144
|
+
}
|
|
145
|
+
const sa = byId(from.syncs);
|
|
146
|
+
for (const s of to.syncs || []) {
|
|
147
|
+
if (sa[s.id] && !same(sa[s.id].identity, s.identity)) actions.push({ kind: 'relink', id: s.id, message: `Rebuild ${s.label} links with the new identity keys (runs a backfill)` });
|
|
148
|
+
if (!sa[s.id]) actions.push({ kind: 'backfill', id: s.id, message: `New sync: ${s.label}` });
|
|
149
|
+
}
|
|
150
|
+
// A link set has no backfill to rebuild from: its links by id still resolve, but its identity index was written
|
|
151
|
+
// with the old keys, so lookups by key miss until each pair is linked again. Said plainly rather than hidden.
|
|
152
|
+
const la = byId(from.links), lb = byId(to.links);
|
|
153
|
+
for (const l of to.links || []) if (la[l.id] && !same(la[l.id].identity, l.identity)) actions.push({ kind: 'reindex', id: l.id, message: `${l.label || l.id}: links by id still resolve; lookups by the new identity keys miss until each pair is linked again` });
|
|
154
|
+
for (const id of Object.keys(la)) if (!lb[id]) actions.push({ kind: 'drop-links', id, message: `Link set "${la[id].label || id}" is gone; its stored links are no longer read` });
|
|
155
|
+
if (!same(from.lifecycle?.onInstall, to.lifecycle?.onInstall)) actions.push({ kind: 'reinstall', message: 'Re-run install steps (webhook subscriptions)' });
|
|
156
|
+
actions.push({ kind: 'drain', message: `Messages already queued under ${from.version} finish on ${from.version}; new events run on ${to.version}` });
|
|
157
|
+
|
|
158
|
+
return { allowed: needs.length === 0, needs, actions, config };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { diff, checkBump, planUpgrade, bumpOf };
|