@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,98 @@
|
|
|
1
|
+
# Data-defined integrations: database records (v0.1)
|
|
2
|
+
|
|
3
|
+
Three layers, stored separately. Worked examples for Phone ↔ HubSpot are in `examples/records/`.
|
|
4
|
+
|
|
5
|
+
| Layer | What it holds | Table | Changes when |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| Definition | What the integration can do | `IntegrationDefinitionVersion` (new) | A version is published (then never again) |
|
|
8
|
+
| Installation | What one customer turned on and chose | `Integration.config` (existing column, new shape) | The customer configures, or an upgrade moves the pin |
|
|
9
|
+
| State | What has happened | `IntegrationMapping` (existing), `WorkflowRun` (new) | Every event |
|
|
10
|
+
|
|
11
|
+
## IntegrationDefinitionVersion (new)
|
|
12
|
+
|
|
13
|
+
One row per published version. `document` is the definition verbatim; `checksum` is sha256 of its canonical JSON
|
|
14
|
+
(sorted keys), so the same content always has the same checksum and an installation can prove what it runs.
|
|
15
|
+
|
|
16
|
+
```prisma
|
|
17
|
+
model IntegrationDefinitionVersion {
|
|
18
|
+
id String @id @default(cuid())
|
|
19
|
+
definitionId String // "phone-hubspot"
|
|
20
|
+
version String // "1.4.0" (semver)
|
|
21
|
+
schemaVersion String // "0.1"
|
|
22
|
+
status DefinitionStatus @default(DRAFT)
|
|
23
|
+
checksum String // "sha256:..." of canonical(document)
|
|
24
|
+
document Json // the definition, immutable once PUBLISHED
|
|
25
|
+
changeLevel String? // patch | minor | major, computed by versioning.diff at publish
|
|
26
|
+
previousVersion String?
|
|
27
|
+
notes String?
|
|
28
|
+
createdBy String?
|
|
29
|
+
createdAt DateTime @default(now())
|
|
30
|
+
publishedAt DateTime?
|
|
31
|
+
|
|
32
|
+
@@unique([definitionId, version])
|
|
33
|
+
@@index([definitionId, status])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
enum DefinitionStatus { DRAFT PUBLISHED DEPRECATED RETIRED }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Rules: a `DRAFT` may be edited; publishing runs `validate` (must be valid) and `checkBump` against the latest
|
|
40
|
+
published version (the declared bump must cover the computed change level), then freezes the row. `DEPRECATED`
|
|
41
|
+
versions keep running for installations pinned to them but cannot be newly installed. A version is `RETIRED` only
|
|
42
|
+
when no installation pins it and no queued message references it.
|
|
43
|
+
|
|
44
|
+
## Integration.config (existing column)
|
|
45
|
+
|
|
46
|
+
```jsonc
|
|
47
|
+
{
|
|
48
|
+
"definition": { "id": "phone-hubspot", "version": "1.4.0", "checksum": "sha256:…" }, // the pin
|
|
49
|
+
"settings": { "fromNumber": "+15125550100" }, // integration-level settings
|
|
50
|
+
"syncs": { "contacts": { "enabled": true, "mapping": [ /* optional full override */ ] } },
|
|
51
|
+
"workflows": { "missed-call-task": { "enabled": true, "settings": { "owner": "81273645" } } },
|
|
52
|
+
"ui": { "phone-card": { "enabled": true } },
|
|
53
|
+
"runtime": { "installed": { "hook": { "id": "WHk2c9", "url": "…", "events": [ … ] } } } // runtime-owned: onInstall results by step id
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
An absent `enabled` means "use the definition's default". The existing `Integration.version` column mirrors
|
|
58
|
+
`config.definition.version` so it can be queried and indexed.
|
|
59
|
+
|
|
60
|
+
## IntegrationMapping (existing) — three kinds of row
|
|
61
|
+
|
|
62
|
+
| `sourceId` | `mapping` | Written by |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `link:<sync>:<moduleKey>:<id>` | `{ "<moduleKey>": id, …, linkedAt }` | `links.upsert` (both sides get a row) |
|
|
65
|
+
| `idx:<sync>:<identityKey>:<normalised value>` | same as link | `links.upsert` with `keys` (email lowercased, phone E.164) |
|
|
66
|
+
| `evt:<definition>:<workflow>:<providerEventId>` | `{ at }` | `runWorkflow`, insert-only (`createMapping`) so a redelivery is a no-op |
|
|
67
|
+
|
|
68
|
+
No new table: these are the rows hand-written integrations already keep, under a fixed naming scheme.
|
|
69
|
+
|
|
70
|
+
## WorkflowRun (new)
|
|
71
|
+
|
|
72
|
+
One row per workflow execution. Carries the definition version it ran on, so a run is always explainable against the
|
|
73
|
+
exact document, even after an upgrade.
|
|
74
|
+
|
|
75
|
+
```prisma
|
|
76
|
+
model WorkflowRun {
|
|
77
|
+
id String @id @default(cuid())
|
|
78
|
+
integrationId String
|
|
79
|
+
definitionId String
|
|
80
|
+
definitionVersion String
|
|
81
|
+
workflowId String
|
|
82
|
+
triggerType String // webhook | schedule | userAction | syncRecord
|
|
83
|
+
eventId String? // provider event id (dedupe key)
|
|
84
|
+
status String // completed | stopped | filtered | duplicate | failed
|
|
85
|
+
reason String? // why it stopped
|
|
86
|
+
error String?
|
|
87
|
+
attempt Int @default(1)
|
|
88
|
+
trace Json // [{ id, use, status, ms, error? }] — no payload bodies
|
|
89
|
+
startedAt DateTime
|
|
90
|
+
endedAt DateTime?
|
|
91
|
+
|
|
92
|
+
@@index([integrationId, workflowId, startedAt])
|
|
93
|
+
@@index([integrationId, status])
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Retention: keep `failed` runs 30 days and the rest 7 by default. `trace` holds step ids, timings and error messages,
|
|
98
|
+
never request or response bodies (those can hold customer data; the existing logging rules apply).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two layers of validation, run together everywhere (the CLI, the tests, anything that publishes):
|
|
3
|
+
* 1. JSON Schema (schema/*.schema.json) — the published shape. Needs ajv, which the monorepo installs for
|
|
4
|
+
* @friggframework/schemas; core's runtime stays dependency-free, so ajv is loaded only here.
|
|
5
|
+
* 2. References (validate.js / manifests.js) — what a schema cannot see: steps name declared capabilities,
|
|
6
|
+
* $.steps.x is an earlier step, view paths exist, option inputs are declared, and so on.
|
|
7
|
+
* A document is valid only when both pass.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { validate } = require('./validate');
|
|
12
|
+
const { validateManifest } = require('./manifests');
|
|
13
|
+
|
|
14
|
+
const SCHEMA_DIR = path.join(__dirname, 'schema');
|
|
15
|
+
let compiled;
|
|
16
|
+
|
|
17
|
+
function loadAjv() {
|
|
18
|
+
try { return require('ajv/dist/2020'); } catch {
|
|
19
|
+
const e = new Error('ajv is not installed. Run npm install at the monorepo root (@friggframework/schemas depends on ajv@8).');
|
|
20
|
+
e.code = 'NO_AJV';
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function schemas() {
|
|
26
|
+
if (compiled) return compiled;
|
|
27
|
+
const Ajv = loadAjv();
|
|
28
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
29
|
+
const read = (f) => JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, f), 'utf8'));
|
|
30
|
+
compiled = {
|
|
31
|
+
definition: ajv.compile(read('integration-definition.v0.1.schema.json')),
|
|
32
|
+
manifest: ajv.compile(read('capability-manifest.v0.1.schema.json')),
|
|
33
|
+
installation: ajv.compile(read('installation.v0.1.schema.json')),
|
|
34
|
+
};
|
|
35
|
+
return compiled;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const schemaErrors = (check, doc) => (check(doc) ? [] : check.errors.map((e) => ({ layer: 'schema', path: e.instancePath.replace(/^\//, '').replace(/\//g, '.') || '(root)', message: e.message + (e.params?.allowedValues ? ` (${e.params.allowedValues.join(', ')})` : e.params?.additionalProperty ? ` ("${e.params.additionalProperty}")` : '') })));
|
|
39
|
+
|
|
40
|
+
/** @returns {{ valid: boolean, errors: Array<{layer,path,message}>, warnings: Array<{layer,path,message}> }} */
|
|
41
|
+
function checkManifest(manifest) {
|
|
42
|
+
const errors = [...schemaErrors(schemas().manifest, manifest), ...validateManifest(manifest).errors.map((e) => ({ layer: 'references', ...e }))];
|
|
43
|
+
return { valid: errors.length === 0, errors, warnings: [] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A definition against the manifests of the modules it uses. Both layers run even when the first fails. */
|
|
47
|
+
function checkDefinition(def, manifests) {
|
|
48
|
+
const v = validate(def, manifests);
|
|
49
|
+
const errors = [...schemaErrors(schemas().definition, def), ...v.errors.map((e) => ({ layer: 'references', ...e }))];
|
|
50
|
+
return { valid: errors.length === 0, errors, warnings: v.warnings.map((w) => ({ layer: 'references', ...w })) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkInstallation(config) {
|
|
54
|
+
const errors = schemaErrors(schemas().installation, config);
|
|
55
|
+
return { valid: errors.length === 0, errors, warnings: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { checkDefinition, checkManifest, checkInstallation, loadAjv };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Validate data-defined integration definitions and capability manifests: JSON Schema, then references.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/validate.js every bundled manifest and example
|
|
6
|
+
* node scripts/validate.js my.definition.json one or more definitions (files or directories)
|
|
7
|
+
* node scripts/validate.js --manifests dir/ ... check against manifests in another directory
|
|
8
|
+
* node scripts/validate.js --strict ... warnings fail too
|
|
9
|
+
* node scripts/validate.js --json ... machine-readable result on stdout
|
|
10
|
+
*
|
|
11
|
+
* Exit codes: 0 valid · 1 invalid · 2 could not run (bad arguments, unreadable file, ajv missing).
|
|
12
|
+
* Manifests are always checked first, because a definition is only as trustworthy as the manifests it is checked
|
|
13
|
+
* against: an invalid manifest fails the run even if every definition passes.
|
|
14
|
+
*/
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const root = path.join(__dirname, '..');
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const flag = (name) => { const i = args.indexOf(name); if (i < 0) return false; args.splice(i, 1); return true; };
|
|
21
|
+
const option = (name) => { const i = args.indexOf(name); if (i < 0) return null; const v = args[i + 1]; args.splice(i, 2); return v; };
|
|
22
|
+
|
|
23
|
+
const strict = flag('--strict');
|
|
24
|
+
const asJson = flag('--json');
|
|
25
|
+
const manifestDir = option('--manifests') || path.join(root, 'capabilities');
|
|
26
|
+
const unknown = args.find((a) => a.startsWith('--'));
|
|
27
|
+
if (unknown) { console.error(`Unknown option ${unknown}`); process.exit(2); }
|
|
28
|
+
|
|
29
|
+
const jsonFiles = (p) => {
|
|
30
|
+
if (!fs.existsSync(p)) { console.error(`No such file or directory: ${p}`); process.exit(2); }
|
|
31
|
+
return fs.statSync(p).isDirectory() ? fs.readdirSync(p).filter((f) => f.endsWith('.json')).map((f) => path.join(p, f)) : [p];
|
|
32
|
+
};
|
|
33
|
+
const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { console.error(`${f}: not valid JSON (${e.message})`); process.exit(2); } };
|
|
34
|
+
|
|
35
|
+
let check;
|
|
36
|
+
try { check = require('../schema-check'); check.loadAjv(); } catch (e) { console.error(e.message); process.exit(2); }
|
|
37
|
+
|
|
38
|
+
const manifestFiles = jsonFiles(manifestDir).filter((f) => f.endsWith('.capabilities.json'));
|
|
39
|
+
const definitionFiles = (args.length ? args : [path.join(root, 'examples')]).flatMap(jsonFiles).filter((f) => !f.endsWith('.capabilities.json') && !f.includes(`${path.sep}records${path.sep}`));
|
|
40
|
+
|
|
41
|
+
const manifests = {};
|
|
42
|
+
const results = [];
|
|
43
|
+
for (const f of manifestFiles) {
|
|
44
|
+
const m = readJson(f);
|
|
45
|
+
manifests[m.module] = m;
|
|
46
|
+
results.push({ kind: 'manifest', file: path.relative(process.cwd(), f), name: m.module, ...check.checkManifest(m) });
|
|
47
|
+
}
|
|
48
|
+
for (const f of definitionFiles) {
|
|
49
|
+
const def = readJson(f);
|
|
50
|
+
results.push({ kind: 'definition', file: path.relative(process.cwd(), f), name: `${def.id}@${def.version}`, ...check.checkDefinition(def, manifests) });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const failed = results.filter((r) => !r.valid || (strict && r.warnings.length));
|
|
54
|
+
if (asJson) {
|
|
55
|
+
process.stdout.write(JSON.stringify({ valid: failed.length === 0, strict, results }, null, 2) + '\n');
|
|
56
|
+
} else {
|
|
57
|
+
for (const r of results) {
|
|
58
|
+
const bad = !r.valid || (strict && r.warnings.length);
|
|
59
|
+
console.log(`${bad ? 'FAIL' : 'ok '} ${r.kind.padEnd(10)} ${r.name}${r.warnings.length ? ` (${r.warnings.length} warning${r.warnings.length > 1 ? 's' : ''})` : ''}`);
|
|
60
|
+
for (const e of r.errors) console.log(` error [${e.layer}] ${e.path}: ${e.message}`);
|
|
61
|
+
for (const w of r.warnings) console.log(` warning [${w.layer}] ${w.path}: ${w.message}`);
|
|
62
|
+
}
|
|
63
|
+
const n = (k) => results.filter((r) => r.kind === k).length;
|
|
64
|
+
console.log(`\n${n('manifest')} manifests, ${n('definition')} definitions: ${failed.length ? `${failed.length} failed` : 'all valid'}${strict ? ' (strict)' : ''}`);
|
|
65
|
+
}
|
|
66
|
+
process.exit(failed.length ? 1 : 0);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The v0.1 step catalogue. A step is { id?, use, with?, when?, onError? }.
|
|
3
|
+
*
|
|
4
|
+
* `use` is either a built-in below or "<moduleKey>.<object>.<verb>": a capability the module's manifest declares
|
|
5
|
+
* (e.g. "hubspot.task.create" -> HubSpot Api.createTask). Nothing else can run: there is no http, eval or script step.
|
|
6
|
+
* The only way to run custom code is `code`, which calls a named, versioned function registered with the runtime.
|
|
7
|
+
*/
|
|
8
|
+
const { applyMapping, deepMerge } = require('./transforms');
|
|
9
|
+
const { resolve } = require('./expressions');
|
|
10
|
+
|
|
11
|
+
const BUILTINS = {
|
|
12
|
+
'links.find': {
|
|
13
|
+
summary: 'Find records already linked, by a sync or a link set (definition.links). By one side\'s id, or by an identity key it indexes (email, phone).',
|
|
14
|
+
args: { link: 'link set id (or sync: sync id)', from: 'module key, with id', id: 'that module\'s record id', key: '{ <identity field>: value }' },
|
|
15
|
+
run: (a, { ports }) => ports.links.find(a),
|
|
16
|
+
},
|
|
17
|
+
'links.upsert': {
|
|
18
|
+
summary: 'Record that records on two sides are the same thing, and index their identity keys.',
|
|
19
|
+
args: { link: 'link set id (or sync: sync id)', ids: '{ <module key>: id }', keys: '{ <identity field>: value }' },
|
|
20
|
+
run: (a, { ports }) => ports.links.upsert(a),
|
|
21
|
+
},
|
|
22
|
+
map: {
|
|
23
|
+
summary: 'Apply a field mapping: a sync\'s mapping (with the installation\'s overrides) or an inline one.',
|
|
24
|
+
args: { sync: 'sync id', mapping: 'inline rows', record: 'the source record' },
|
|
25
|
+
run: (a, { def, installation }) => {
|
|
26
|
+
let rows = a.mapping;
|
|
27
|
+
if (!rows && a.sync) {
|
|
28
|
+
const sync = (def.syncs || []).find((s) => s.id === a.sync);
|
|
29
|
+
if (!sync) throw new Error(`map: unknown sync "${a.sync}"`);
|
|
30
|
+
rows = installation?.syncs?.[a.sync]?.mapping || sync.mapping;
|
|
31
|
+
}
|
|
32
|
+
return applyMapping(rows, a.record || {});
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
template: {
|
|
36
|
+
summary: 'Render a named template (definition.templates) or inline text.',
|
|
37
|
+
args: { id: 'template id', text: 'inline template' },
|
|
38
|
+
run: (a, { def, ctx }) => {
|
|
39
|
+
const src = a.id ? def.templates?.[a.id] : a.text;
|
|
40
|
+
if (src == null) throw new Error(`template: unknown template "${a.id}"`);
|
|
41
|
+
return resolve(src, ctx);
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
merge: {
|
|
45
|
+
summary: 'Deep-merge objects into one; later objects win, lists are replaced.',
|
|
46
|
+
args: { objects: '[object, ...]' },
|
|
47
|
+
run: (a) => deepMerge(...(Array.isArray(a.objects) ? a.objects : [])),
|
|
48
|
+
},
|
|
49
|
+
set: {
|
|
50
|
+
summary: 'Store values in $.vars for later steps.',
|
|
51
|
+
args: { '<name>': 'value' },
|
|
52
|
+
run: (a, { ctx }) => { Object.assign(ctx.vars, a); return a; },
|
|
53
|
+
},
|
|
54
|
+
stop: {
|
|
55
|
+
summary: 'End the run early (a normal outcome, not an error). Usually guarded by `when`.',
|
|
56
|
+
args: { reason: 'string' },
|
|
57
|
+
run: (a) => ({ stop: true, reason: a.reason || 'stopped' }),
|
|
58
|
+
},
|
|
59
|
+
foreach: {
|
|
60
|
+
summary: 'Run nested steps once per item ($.item). Bounded by max (default 100, hard cap 1000). With `collect`, the step\'s result is the list of that value per item; `where` skips items.',
|
|
61
|
+
args: { over: 'array', max: 'number', where: 'condition on $.item', collect: 'value per item (may use $.item and this iteration\'s $.steps)', do: 'steps (optional when collect is set)' },
|
|
62
|
+
nested: true,
|
|
63
|
+
},
|
|
64
|
+
branch: {
|
|
65
|
+
summary: 'Run the first case whose `when` holds, else the `else` steps.',
|
|
66
|
+
args: { cases: '[{ when, do }]', else: 'steps' },
|
|
67
|
+
nested: true,
|
|
68
|
+
},
|
|
69
|
+
code: {
|
|
70
|
+
summary: 'Call a registered code step: a named, versioned function shipped in reviewed code ("pkg#fn@1.2.0").',
|
|
71
|
+
args: { ref: 'string', with: 'object' },
|
|
72
|
+
run: (a, { ports }) => {
|
|
73
|
+
if (!ports.runCode) throw new Error('code: this runtime has no code steps registered');
|
|
74
|
+
return ports.runCode(a.ref, a.with || {});
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const FOREACH_HARD_CAP = 1000;
|
|
80
|
+
|
|
81
|
+
function isBuiltin(use) { return Object.prototype.hasOwnProperty.call(BUILTINS, use); }
|
|
82
|
+
|
|
83
|
+
/** "hubspot.task.create" -> { module: 'hubspot', capability: 'task.create' } */
|
|
84
|
+
function splitCapability(use) {
|
|
85
|
+
const i = use.indexOf('.');
|
|
86
|
+
return i < 0 ? null : { module: use.slice(0, i), capability: use.slice(i + 1) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { BUILTINS, FOREACH_HARD_CAP, isBuiltin, splitCapability };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Field transforms used by sync mappings and the `map` step (v0.1). Pure functions: (value, options, record) -> value. */
|
|
2
|
+
const TRANSFORMS = {
|
|
3
|
+
none: (v) => v,
|
|
4
|
+
trim: (v) => (typeof v === 'string' ? v.trim() : v),
|
|
5
|
+
lowercase: (v) => (typeof v === 'string' ? v.toLowerCase() : v),
|
|
6
|
+
uppercase: (v) => (typeof v === 'string' ? v.toUpperCase() : v),
|
|
7
|
+
number: (v) => (v == null || v === '' ? undefined : Number(v)),
|
|
8
|
+
boolean: (v) => (v == null ? undefined : v === true || v === 'true' || v === 1 || v === '1'),
|
|
9
|
+
to_iso_date: (v) => { if (!v) return undefined; const d = new Date(v); return isNaN(d) ? undefined : d.toISOString(); },
|
|
10
|
+
/** Digits with a leading +. A 10-digit number is assumed to be NANP (options.defaultCountry overrides). */
|
|
11
|
+
to_e164_phone: (v, o = {}) => {
|
|
12
|
+
if (v == null || v === '') return undefined;
|
|
13
|
+
const s = String(v).trim();
|
|
14
|
+
const digits = s.replace(/\D/g, '');
|
|
15
|
+
if (!digits) return undefined;
|
|
16
|
+
if (s.startsWith('+')) return '+' + digits;
|
|
17
|
+
if (digits.length === 10) return (o.defaultCountry === 'GB' ? '+44' : '+1') + digits;
|
|
18
|
+
if (digits.length === 11 && digits.startsWith('1')) return '+' + digits;
|
|
19
|
+
return '+' + digits;
|
|
20
|
+
},
|
|
21
|
+
split_full_name: (v, o = {}) => {
|
|
22
|
+
if (!v) return undefined;
|
|
23
|
+
const parts = String(v).trim().split(/\s+/);
|
|
24
|
+
return o.part === 'last' ? parts.slice(1).join(' ') || undefined : parts[0];
|
|
25
|
+
},
|
|
26
|
+
join: (v, o = {}, record = {}) => (o.fields || []).map((f) => readField(record, f)).filter((x) => x != null && x !== '').join(o.separator ?? ' ') || undefined,
|
|
27
|
+
map_values: (v, o = {}) => (v == null ? undefined : (o.values || {})[v] ?? o.fallback),
|
|
28
|
+
default: (v, o = {}) => (v == null || v === '' ? o.value : v),
|
|
29
|
+
// money and time, rounded and total (bad input -> undefined, which the mapping leaves out)
|
|
30
|
+
units_to_cents: (v) => { const n = v == null || v === '' ? NaN : Number(v); return Number.isNaN(n) ? undefined : Math.round(Number(`${n}e2`)); },
|
|
31
|
+
cents_to_units: (v) => { const n = v == null || v === '' ? NaN : Number(v); return Number.isNaN(n) ? undefined : Math.round(n) / 100; },
|
|
32
|
+
from_unix_seconds: (v) => { const n = v == null || v === '' ? NaN : Number(v); return Number.isNaN(n) ? undefined : new Date(n * 1000).toISOString(); },
|
|
33
|
+
to_unix_seconds: (v) => { const t = v ? Date.parse(v) : NaN; return Number.isNaN(t) ? undefined : Math.floor(t / 1000); },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function readField(record, path) {
|
|
37
|
+
let cur = record;
|
|
38
|
+
for (const seg of String(path).split(/\.|(?=\[)/)) {
|
|
39
|
+
if (cur == null) return undefined;
|
|
40
|
+
const m = /^\[(\d+)\]$/.exec(seg);
|
|
41
|
+
cur = m ? cur[Number(m[1])] : cur[seg];
|
|
42
|
+
}
|
|
43
|
+
return cur;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeField(target, path, value) {
|
|
47
|
+
const segs = String(path).split(/\.|(?=\[)/);
|
|
48
|
+
let cur = target;
|
|
49
|
+
segs.forEach((seg, i) => {
|
|
50
|
+
const m = /^\[(\d+)\]$/.exec(seg);
|
|
51
|
+
const key = m ? Number(m[1]) : seg;
|
|
52
|
+
if (i === segs.length - 1) { cur[key] = value; return; }
|
|
53
|
+
const nextIsIndex = /^\[\d+\]$/.test(segs[i + 1]);
|
|
54
|
+
if (cur[key] == null) cur[key] = nextIsIndex ? [] : {};
|
|
55
|
+
cur = cur[key];
|
|
56
|
+
});
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Apply a field mapping to a record. A blank result is left out, never written (a blank must not overwrite a value).
|
|
62
|
+
* A row either reads `source` from the record or writes a fixed `value` (QuickBooks' Line[0].DetailType, a lifecycle
|
|
63
|
+
* stage): { target, value }. A fixed value still goes through the row's transform.
|
|
64
|
+
* @param {Array<{source?:string,value?:any,target:string,transform?:string,options?:object}>} rows
|
|
65
|
+
*/
|
|
66
|
+
function applyMapping(rows, record) {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const r of rows || []) {
|
|
69
|
+
const fn = TRANSFORMS[r.transform || 'none'];
|
|
70
|
+
if (!fn) throw new Error(`Unknown transform "${r.transform}"`);
|
|
71
|
+
const input = 'value' in r ? r.value : readField(record, r.source);
|
|
72
|
+
const v = fn(input, r.options || {}, record);
|
|
73
|
+
if (v === undefined || v === null || v === '') continue;
|
|
74
|
+
writeField(out, r.target, v);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Deep merge, later objects win; arrays and scalars are replaced, not merged. Inputs are not modified. */
|
|
80
|
+
function deepMerge(...objects) {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const o of objects) {
|
|
83
|
+
if (!o || typeof o !== 'object' || Array.isArray(o)) continue;
|
|
84
|
+
for (const [k, v] of Object.entries(o)) {
|
|
85
|
+
out[k] = v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k]) ? deepMerge(out[k], v) : v;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { TRANSFORMS, applyMapping, readField, writeField, deepMerge };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application layer for data-defined integrations (ADR-063): publishing definition versions, the catalogue the
|
|
3
|
+
* runtime reads from, and per-installation upgrades. Business rules live here; the repositories only store rows.
|
|
4
|
+
*
|
|
5
|
+
* createDefinitionCatalog() what defineSharedIntegrationClass({ definitions }) reads: get(id, version), latest(id)
|
|
6
|
+
* PublishDefinitionVersion validate (schema + references) → checkBump against the latest published → store
|
|
7
|
+
* DeprecateDefinitionVersion PUBLISHED → DEPRECATED (still runs for its pins, cannot be newly installed)
|
|
8
|
+
* ListPublishedDefinitions the install catalogue: latest published version of each definition
|
|
9
|
+
* GetDefinitionHistory every version of one definition, newest first
|
|
10
|
+
* PlanInstallationUpgrade planUpgrade(installation, pinned, target), without changing anything
|
|
11
|
+
*/
|
|
12
|
+
const { checksum } = require('../canonical');
|
|
13
|
+
const { checkBump, planUpgrade } = require('../versioning');
|
|
14
|
+
|
|
15
|
+
class DefinitionError extends Error {
|
|
16
|
+
constructor(message, { status = 400, code, details } = {}) { super(message); this.name = 'DefinitionError'; this.status = status; this.code = code; this.details = details; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?$/;
|
|
20
|
+
function compareVersions(a, b) {
|
|
21
|
+
const x = SEMVER.exec(a), y = SEMVER.exec(b);
|
|
22
|
+
if (!x || !y) return String(a).localeCompare(String(b));
|
|
23
|
+
for (let i = 1; i <= 3; i += 1) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) - Number(y[i]);
|
|
24
|
+
if (x[4] && !y[4]) return -1; // 1.0.0-beta < 1.0.0
|
|
25
|
+
if (!x[4] && y[4]) return 1;
|
|
26
|
+
return String(x[4] || '').localeCompare(String(y[4] || ''));
|
|
27
|
+
}
|
|
28
|
+
const newestFirst = (rows) => [...rows].sort((a, b) => compareVersions(b.version, a.version));
|
|
29
|
+
const SERVABLE = new Set(['PUBLISHED', 'DEPRECATED', 'RETIRED']); // a DRAFT never reaches the runtime
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The definition store the runtime reads from. Published rows are immutable, so they are cached by id@version for the
|
|
33
|
+
* life of the process; a pinned checksum is verified, so an installation provably runs the document it was pinned to.
|
|
34
|
+
*/
|
|
35
|
+
function createDefinitionCatalog({ repository } = {}) {
|
|
36
|
+
const repo = () => repository || (repository = require('../repositories/definition-version-repository').createDefinitionVersionRepository());
|
|
37
|
+
const cache = new Map();
|
|
38
|
+
return {
|
|
39
|
+
async get(definitionId, version, { checksum: expected } = {}) {
|
|
40
|
+
const key = `${definitionId}@${version}`;
|
|
41
|
+
let row = cache.get(key);
|
|
42
|
+
if (!row) {
|
|
43
|
+
row = await repo().findVersion(definitionId, version);
|
|
44
|
+
if (!row || !SERVABLE.has(row.status)) return null;
|
|
45
|
+
cache.set(key, row);
|
|
46
|
+
}
|
|
47
|
+
if (expected && expected !== row.checksum) throw new DefinitionError(`${key} does not match the pinned checksum`, { status: 409, code: 'CHECKSUM_MISMATCH' });
|
|
48
|
+
return row.document;
|
|
49
|
+
},
|
|
50
|
+
/** The latest PUBLISHED version (not deprecated, not draft): what a new installation pins. */
|
|
51
|
+
async latest(definitionId) {
|
|
52
|
+
const rows = (await repo().listVersions(definitionId)).filter((r) => r.status === 'PUBLISHED');
|
|
53
|
+
return rows.length ? newestFirst(rows)[0].document : null;
|
|
54
|
+
},
|
|
55
|
+
async checksumOf(definitionId, version) {
|
|
56
|
+
return (await repo().findVersion(definitionId, version))?.checksum || null;
|
|
57
|
+
},
|
|
58
|
+
/** The install catalogue: latest published version of each definition. */
|
|
59
|
+
list: () => new ListPublishedDefinitions({ repository: repo() }).execute(),
|
|
60
|
+
/** The repository behind this catalogue, for publishing (bundled stores have none). */
|
|
61
|
+
get repository() { return repo(); },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class PublishDefinitionVersion {
|
|
66
|
+
/**
|
|
67
|
+
* @param {{ repository, manifests: object, check?: (def, manifests) => { valid, errors, warnings } }} deps
|
|
68
|
+
* check defaults to schema-check's checkDefinition (JSON Schema + references)
|
|
69
|
+
*/
|
|
70
|
+
constructor({ repository, manifests, check }) {
|
|
71
|
+
this.repository = repository;
|
|
72
|
+
this.manifests = manifests;
|
|
73
|
+
this.check = check || require('../schema-check').checkDefinition;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @returns {Promise<{ version: object, created: boolean, changeLevel: string|null, warnings: array }>} */
|
|
77
|
+
async execute({ document, notes, createdBy, publish = true, strict = false } = {}) {
|
|
78
|
+
if (!document || typeof document !== 'object') throw new DefinitionError('Send the definition as `document`');
|
|
79
|
+
const result = this.check(document, this.manifests);
|
|
80
|
+
if (!result.valid || (strict && result.warnings.length)) throw new DefinitionError(`${document.id}@${document.version} is not valid`, { code: 'INVALID', details: { errors: result.errors, warnings: result.warnings } });
|
|
81
|
+
|
|
82
|
+
const sum = checksum(document);
|
|
83
|
+
const existing = await this.repository.findVersion(document.id, document.version);
|
|
84
|
+
if (existing && existing.status !== 'DRAFT') {
|
|
85
|
+
if (existing.checksum === sum) return { version: existing, created: false, changeLevel: existing.changeLevel, warnings: result.warnings };
|
|
86
|
+
throw new DefinitionError(`${document.id}@${document.version} is already ${existing.status.toLowerCase()} and cannot change. Publish a new version.`, { status: 409, code: 'IMMUTABLE' });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const published = newestFirst((await this.repository.listVersions(document.id)).filter((r) => r.status === 'PUBLISHED' || r.status === 'DEPRECATED'));
|
|
90
|
+
const previous = published.find((r) => r.version !== document.version) || null;
|
|
91
|
+
let changeLevel = null;
|
|
92
|
+
if (previous) {
|
|
93
|
+
if (compareVersions(document.version, previous.version) <= 0) throw new DefinitionError(`${document.version} is not newer than the latest published ${previous.version}`, { status: 409, code: 'NOT_NEWER' });
|
|
94
|
+
const bump = checkBump(previous.document, document);
|
|
95
|
+
if (!bump.ok) throw new DefinitionError(bump.message, { status: 409, code: 'BUMP_TOO_SMALL', details: { required: bump.required, declared: bump.declared, changes: bump.changes } });
|
|
96
|
+
changeLevel = bump.required;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const row = {
|
|
100
|
+
definitionId: document.id, version: document.version, schemaVersion: document.schemaVersion, checksum: sum, document,
|
|
101
|
+
changeLevel, previousVersion: previous?.version || null, notes: notes ?? null, createdBy: createdBy ?? null,
|
|
102
|
+
status: publish ? 'PUBLISHED' : 'DRAFT', publishedAt: publish ? new Date() : null,
|
|
103
|
+
};
|
|
104
|
+
if (existing) {
|
|
105
|
+
// A draft may be rewritten, and published, as long as it is still a draft when we write (checked by the
|
|
106
|
+
// database, so two concurrent publishes cannot both win).
|
|
107
|
+
const updated = await this.repository.updateIfStatus(document.id, document.version, 'DRAFT', row);
|
|
108
|
+
if (!updated) throw new DefinitionError(`${document.id}@${document.version} changed while publishing; try again`, { status: 409, code: 'CONFLICT' });
|
|
109
|
+
return { version: updated, created: false, changeLevel, warnings: result.warnings };
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return { version: await this.repository.createVersion(row), created: true, changeLevel, warnings: result.warnings };
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (e?.code === 'P2002') {
|
|
115
|
+
// Lost a race to the same version: the same document is the same publish; anything else is a conflict.
|
|
116
|
+
const winner = await this.repository.findVersion(document.id, document.version);
|
|
117
|
+
if (winner?.checksum === sum) return { version: winner, created: false, changeLevel: winner.changeLevel, warnings: result.warnings };
|
|
118
|
+
throw new DefinitionError(`${document.id}@${document.version} was published with different content just now`, { status: 409, code: 'CONFLICT' });
|
|
119
|
+
}
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class DeprecateDefinitionVersion {
|
|
126
|
+
constructor({ repository }) { this.repository = repository; }
|
|
127
|
+
async execute({ definitionId, version }) {
|
|
128
|
+
const row = await this.repository.updateIfStatus(definitionId, version, 'PUBLISHED', { status: 'DEPRECATED' });
|
|
129
|
+
if (row) return row;
|
|
130
|
+
const existing = await this.repository.findVersion(definitionId, version);
|
|
131
|
+
if (!existing) throw new DefinitionError(`${definitionId}@${version} does not exist`, { status: 404, code: 'NOT_FOUND' });
|
|
132
|
+
throw new DefinitionError(`${definitionId}@${version} is ${existing.status.toLowerCase()}; only a published version can be deprecated`, { status: 409, code: 'WRONG_STATUS' });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class ListPublishedDefinitions {
|
|
137
|
+
constructor({ repository }) { this.repository = repository; }
|
|
138
|
+
/** Latest published version of each definition, with what an install screen shows. */
|
|
139
|
+
async execute() {
|
|
140
|
+
const byId = new Map();
|
|
141
|
+
for (const r of await this.repository.listByStatus(['PUBLISHED'])) {
|
|
142
|
+
const cur = byId.get(r.definitionId);
|
|
143
|
+
if (!cur || compareVersions(r.version, cur.version) > 0) byId.set(r.definitionId, r);
|
|
144
|
+
}
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const r of byId.values()) {
|
|
147
|
+
const doc = (await this.repository.findVersion(r.definitionId, r.version)).document;
|
|
148
|
+
out.push({
|
|
149
|
+
id: r.definitionId, version: r.version, checksum: r.checksum, publishedAt: r.publishedAt,
|
|
150
|
+
display: doc.display, modules: Object.fromEntries(Object.entries(doc.modules || {}).map(([k, m]) => [k, m.module])),
|
|
151
|
+
counts: { syncs: (doc.syncs || []).length, links: (doc.links || []).length, workflows: (doc.workflows || []).length, ui: (doc.ui || []).length },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return out.sort((a, b) => String(a.display?.label || a.id).localeCompare(String(b.display?.label || b.id)));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
class GetDefinitionHistory {
|
|
159
|
+
constructor({ repository }) { this.repository = repository; }
|
|
160
|
+
async execute({ definitionId }) {
|
|
161
|
+
const rows = newestFirst(await this.repository.listVersions(definitionId));
|
|
162
|
+
if (!rows.length) throw new DefinitionError(`No definition "${definitionId}"`, { status: 404, code: 'NOT_FOUND' });
|
|
163
|
+
return rows.map(({ document, ...meta }) => meta);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class PlanInstallationUpgrade {
|
|
168
|
+
constructor({ catalog }) { this.catalog = catalog; }
|
|
169
|
+
/** @returns {Promise<{ from, to, allowed, needs, actions, config, changes }>} */
|
|
170
|
+
async execute({ config, toVersion }) {
|
|
171
|
+
const pin = config?.definition;
|
|
172
|
+
if (!pin?.id || !pin?.version) throw new DefinitionError('This installation does not name a definition', { code: 'NO_PIN' });
|
|
173
|
+
const from = await this.catalog.get(pin.id, pin.version);
|
|
174
|
+
const target = toVersion || (await this.catalog.latest(pin.id))?.version;
|
|
175
|
+
if (!target) throw new DefinitionError(`No published version of "${pin.id}"`, { status: 404, code: 'NOT_FOUND' });
|
|
176
|
+
const to = await this.catalog.get(pin.id, target);
|
|
177
|
+
if (!from || !to) throw new DefinitionError(`${pin.id}@${!from ? pin.version : target} is not published`, { status: 404, code: 'NOT_FOUND' });
|
|
178
|
+
const plan = planUpgrade(config, from, to);
|
|
179
|
+
plan.config.definition = { ...plan.config.definition, checksum: await this.catalog.checksumOf(pin.id, target) };
|
|
180
|
+
return { from: pin.version, to: target, ...plan, changes: require('../versioning').diff(from, to).changes };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
DefinitionError, compareVersions, createDefinitionCatalog,
|
|
186
|
+
PublishDefinitionVersion, DeprecateDefinitionVersion, ListPublishedDefinitions, GetDefinitionHistory, PlanInstallationUpgrade,
|
|
187
|
+
};
|