@friggframework/core 2.0.0--canary.703.12b980b.0 → 2.0.0--canary.707.df80bfd.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-encryption-service.js +9 -22
- package/database/documentdb-utils.js +2 -2
- package/database/encryption/README.md +78 -14
- package/database/encryption/documentdb-encryption-service.md +78 -1
- package/database/encryption/encryption-config.js +219 -0
- package/database/encryption/encryption-schema-registry.js +121 -24
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/integration-mapping-encryption.js +1 -1
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- package/database/organization-where.js +18 -0
- package/database/prisma.js +15 -38
- package/database/unique-violation.js +25 -0
- package/database/use-cases/check-encryption-health-use-case.js +43 -52
- package/errors/index.js +0 -4
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +230 -0
- package/extensions/index.js +44 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/prisma-composer.js +270 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +33 -42
- package/generated/prisma-mongodb/index-browser.js +30 -39
- package/generated/prisma-mongodb/index.d.ts +3187 -2005
- package/generated/prisma-mongodb/index.js +33 -42
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +86 -59
- package/generated/prisma-mongodb/wasm.js +33 -42
- package/generated/prisma-postgresql/edge.js +33 -42
- package/generated/prisma-postgresql/index-browser.js +30 -39
- package/generated/prisma-postgresql/index.d.ts +3380 -2177
- package/generated/prisma-postgresql/index.js +33 -42
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +81 -57
- package/generated/prisma-postgresql/wasm.js +33 -42
- package/handlers/WEBHOOKS.md +0 -29
- package/handlers/app-definition-loader.js +128 -19
- package/handlers/app-handler-helpers.js +27 -8
- package/handlers/backend-utils.js +31 -25
- package/handlers/integration-event-dispatcher.js +3 -7
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +46 -10
- package/handlers/routers/health.js +13 -4
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +100 -50
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +66 -16
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +1 -14
- package/integrations/integration-base.js +2 -163
- package/integrations/integration-router.js +132 -102
- package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
- package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
- package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
- package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
- package/integrations/repositories/integration-mapping-repository.js +0 -29
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +37 -9
- package/integrations/repositories/integration-repository-interface.js +17 -4
- package/integrations/repositories/integration-repository-mongo.js +64 -96
- package/integrations/repositories/integration-repository-postgres.js +63 -81
- package/integrations/repositories/process-repository-mongo.js +1 -1
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +56 -31
- package/integrations/use-cases/delete-integration-for-user.js +4 -4
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integration-for-user.js +2 -4
- package/integrations/use-cases/get-integration-instance.js +4 -4
- package/integrations/use-cases/get-integrations-for-user.js +79 -39
- package/integrations/use-cases/get-possible-integrations.js +2 -7
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +256 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/use-cases/update-integration.js +4 -4
- package/integrations/utils/map-integration-dto.js +43 -14
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +151 -0
- package/management-api/meta-router.js +76 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +415 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-bases.js +169 -0
- package/modules/proxy/proxy-errors.js +31 -0
- package/modules/proxy/proxy-path.js +183 -0
- package/modules/proxy/proxy-policy.js +277 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/requester/proxy-request.js +245 -0
- package/modules/requester/requester.js +35 -5
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +331 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +329 -0
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -7
- package/prisma-mongodb/schema.prisma +86 -59
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +81 -57
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/queuer-util.js +2 -9
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +214 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
- package/errors/mapping-already-exists-error.js +0 -14
- package/handlers/workers/integration-triggers.js +0 -25
- package/infrastructure/scheduler/recurrence.js +0 -204
- package/integrations/define/DESIGN.md +0 -122
- package/integrations/define/config.js +0 -98
- package/integrations/define/define-app.js +0 -66
- package/integrations/define/define-extension.js +0 -154
- package/integrations/define/define-integration.js +0 -335
- package/integrations/define/examples/deal-alerts.js +0 -140
- package/integrations/define/extension-binding.js +0 -174
- package/integrations/define/index.js +0 -11
- package/integrations/define/json-schema-lite.js +0 -68
- package/integrations/define/subscriptions.js +0 -186
- package/integrations/define/tests/fixtures/hubspot.js +0 -109
- package/integrations/define/tests/fixtures/slack.js +0 -26
- package/integrations/define/use.js +0 -39
- package/integrations/definitions/README.md +0 -278
- package/integrations/definitions/canonical.js +0 -11
- package/integrations/definitions/capabilities/AUDIT.md +0 -480
- package/integrations/definitions/capabilities/acmeerp.capabilities.json +0 -633
- package/integrations/definitions/capabilities/asana.capabilities.json +0 -279
- package/integrations/definitions/capabilities/calendly.capabilities.json +0 -385
- package/integrations/definitions/capabilities/docusign.capabilities.json +0 -314
- package/integrations/definitions/capabilities/fathom.capabilities.json +0 -238
- package/integrations/definitions/capabilities/github.capabilities.json +0 -585
- package/integrations/definitions/capabilities/hubspot.capabilities.json +0 -822
- package/integrations/definitions/capabilities/jira.capabilities.json +0 -391
- package/integrations/definitions/capabilities/klaviyo.capabilities.json +0 -503
- package/integrations/definitions/capabilities/linear.capabilities.json +0 -378
- package/integrations/definitions/capabilities/netsuite.capabilities.json +0 -535
- package/integrations/definitions/capabilities/notion.capabilities.json +0 -208
- package/integrations/definitions/capabilities/phone.capabilities.json +0 -511
- package/integrations/definitions/capabilities/pipedrive.capabilities.json +0 -430
- package/integrations/definitions/capabilities/quickbooks.capabilities.json +0 -635
- package/integrations/definitions/capabilities/salesforce.capabilities.json +0 -568
- package/integrations/definitions/capabilities/shopify.capabilities.json +0 -1128
- package/integrations/definitions/capabilities/slack.capabilities.json +0 -373
- package/integrations/definitions/capabilities/stripe.capabilities.json +0 -630
- package/integrations/definitions/capabilities/webflow.capabilities.json +0 -196
- package/integrations/definitions/capabilities/zendesk.capabilities.json +0 -422
- package/integrations/definitions/compile.js +0 -80
- package/integrations/definitions/config-form.js +0 -137
- package/integrations/definitions/examples/acme-erp-quickbooks.definition.json +0 -467
- package/integrations/definitions/examples/asana-slack.definition.json +0 -219
- package/integrations/definitions/examples/calendly-salesforce.definition.json +0 -459
- package/integrations/definitions/examples/docusign-salesforce.definition.json +0 -305
- package/integrations/definitions/examples/fathom-hubspot-notion.definition.json +0 -575
- package/integrations/definitions/examples/github-linear.definition.json +0 -322
- package/integrations/definitions/examples/hubspot-slack.definition.json +0 -427
- package/integrations/definitions/examples/klaviyo-hubspot.definition.json +0 -336
- package/integrations/definitions/examples/phone-hubspot.definition.json +0 -633
- package/integrations/definitions/examples/pipedrive-hubspot.definition.json +0 -136
- package/integrations/definitions/examples/pipedrive-phone.definition.json +0 -340
- package/integrations/definitions/examples/records/phone-hubspot.definition-version.json +0 -652
- package/integrations/definitions/examples/records/phone-hubspot.integration.json +0 -86
- package/integrations/definitions/examples/records/phone-hubspot.mappings.json +0 -39
- package/integrations/definitions/examples/records/phone-hubspot.workflow-run.json +0 -62
- package/integrations/definitions/examples/shopify-klaviyo.definition.json +0 -488
- package/integrations/definitions/examples/shopify-netsuite.definition.json +0 -622
- package/integrations/definitions/examples/stripe-quickbooks.definition.json +0 -476
- package/integrations/definitions/examples/webflow-hubspot-slack.definition.json +0 -262
- package/integrations/definitions/examples/zendesk-jira.definition.json +0 -604
- package/integrations/definitions/expressions.js +0 -229
- package/integrations/definitions/index.js +0 -48
- package/integrations/definitions/integration-runtime.js +0 -779
- package/integrations/definitions/interpreter.js +0 -104
- package/integrations/definitions/manifests.js +0 -177
- package/integrations/definitions/renderers/common.js +0 -67
- package/integrations/definitions/renderers/html.js +0 -129
- package/integrations/definitions/renderers/hubspot.js +0 -59
- package/integrations/definitions/renderers/preview.js +0 -154
- package/integrations/definitions/renderers/slack.js +0 -146
- package/integrations/definitions/repositories/definition-version-repository.js +0 -67
- package/integrations/definitions/repositories/workflow-run-repository.js +0 -80
- package/integrations/definitions/router.js +0 -170
- package/integrations/definitions/runtime.js +0 -268
- package/integrations/definitions/schema/capability-manifest.v0.1.schema.json +0 -549
- package/integrations/definitions/schema/installation.v0.1.schema.json +0 -130
- package/integrations/definitions/schema/integration-definition.v0.1.schema.json +0 -909
- package/integrations/definitions/schema/records.md +0 -105
- package/integrations/definitions/schema-check.js +0 -58
- package/integrations/definitions/scripts/check-manifests.js +0 -220
- package/integrations/definitions/scripts/validate.js +0 -66
- package/integrations/definitions/steps.js +0 -123
- package/integrations/definitions/sync-engine.js +0 -297
- package/integrations/definitions/tests/helpers-fake-crm.js +0 -49
- package/integrations/definitions/transforms.js +0 -91
- package/integrations/definitions/use-cases/definition-use-cases.js +0 -245
- package/integrations/definitions/validate.js +0 -436
- package/integrations/definitions/versioning.js +0 -176
- package/integrations/definitions/webhooks.js +0 -119
- package/integrations/integration-types.js +0 -73
- package/integrations/triggers.js +0 -244
- package/integrations/use-cases/run-trigger-tick.js +0 -155
- package/integrations/use-cases/run-trigger.js +0 -77
- package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +0 -66
- package/prisma-postgresql/migrations/20261019000000_workflow_run_waits/migration.sql +0 -7
- package/prisma-postgresql/migrations/20261019120000_definition_workspace/migration.sql +0 -5
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The sync engine: keeps records on two modules as copies of each other, per a definition's `syncs`. I/O comes in
|
|
3
|
-
* as ports (Api calls through the manifest, links over IntegrationMapping) and a state store, so it runs the same in a
|
|
4
|
-
* test and in Lambda. Queueing the next page is the caller's job: each step returns what to do next.
|
|
5
|
-
*
|
|
6
|
-
* One record, one direction (syncRecord):
|
|
7
|
-
* 1. Find the other side: its link (link:<sync>:<module>:<id>), else the identity index (idx:<sync>:<key>:<value>),
|
|
8
|
-
* else the other side's search by identity key (onAmbiguous decides 0/1/many), else create.
|
|
9
|
-
* 2. Map: rows flowing this way. On an update a row applies only when its master is the side the change came from
|
|
10
|
-
* (`newest`: unless the other side changed later). On a create every row flowing this way applies.
|
|
11
|
-
* 3. Write only what changed: the link remembers a hash of every value known on each side, so an unchanged
|
|
12
|
-
* payload is not written, and the echo of our own write (the other side's webhook) ends there.
|
|
13
|
-
* 4. Link both ids and identity keys, with what is now known on each side.
|
|
14
|
-
*
|
|
15
|
-
* How each object is read and written is the manifest's `objects.<obj>.sync` (list, get, search, create, update).
|
|
16
|
-
*/
|
|
17
|
-
const { createHash } = require('crypto');
|
|
18
|
-
const { applyMapping, readField, TRANSFORMS } = require('./transforms');
|
|
19
|
-
const { resolve, evaluate, getPath } = require('./expressions');
|
|
20
|
-
|
|
21
|
-
const hash = (v) => createHash('sha256').update(JSON.stringify(v ?? null)).digest('hex').slice(0, 16);
|
|
22
|
-
const otherRole = (role) => (role === 'source' ? 'target' : 'source');
|
|
23
|
-
const blank = (v) => v === undefined || v === null || v === '';
|
|
24
|
-
const ERROR_SAMPLE = 20;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @param {object} p
|
|
28
|
-
* @param {object} p.def
|
|
29
|
-
* @param {object} p.installation Integration.config (+ integrationId); syncs.<id>.mapping overrides the default mapping
|
|
30
|
-
* @param {object} p.install the $.install context (installContext)
|
|
31
|
-
* @param {Record<string, object>} p.manifests
|
|
32
|
-
* @param {{ call: Function, links: { find: Function, upsert: Function } }} p.ports
|
|
33
|
-
* @param {{ get(key): Promise<object|null>, put(key, value): Promise<void> }} p.state raw IntegrationMapping rows
|
|
34
|
-
* @param {() => Date} [p.now]
|
|
35
|
-
*/
|
|
36
|
-
function createSyncEngine({ def, installation = {}, install = {}, manifests, ports, state, now = () => new Date() }) {
|
|
37
|
-
const syncOf = (id) => {
|
|
38
|
-
const s = (def.syncs || []).find((x) => x.id === id);
|
|
39
|
-
if (!s) throw new Error(`Unknown sync "${id}"`);
|
|
40
|
-
return s;
|
|
41
|
-
};
|
|
42
|
-
const sideOf = (s, role) => {
|
|
43
|
-
const [key, obj] = s[role].split('.');
|
|
44
|
-
const manifest = manifests[def.modules[key]?.module];
|
|
45
|
-
return { role, key, obj, manifest, spec: manifest?.objects?.[obj]?.sync || {} };
|
|
46
|
-
};
|
|
47
|
-
const rowsToward = (s, toRole) => {
|
|
48
|
-
const rows = installation.syncs?.[s.id]?.mapping || s.mapping || [];
|
|
49
|
-
if (toRole === 'target') return rows.filter((r) => (r.direction || 'both') !== 'to_source');
|
|
50
|
-
// Flowing back to the source, a row reads its target and writes its source. A fixed value has no source.
|
|
51
|
-
return rows.filter((r) => (r.direction || 'both') !== 'to_target' && !('value' in r)).map((r) => ({ ...r, source: r.target, target: r.source }));
|
|
52
|
-
};
|
|
53
|
-
/** Call one of a side's sync operations, with the sync's extra arguments for that module (s.args). */
|
|
54
|
-
const call = (s, side, op, args) => {
|
|
55
|
-
const spec = side.spec[op];
|
|
56
|
-
if (!spec) throw new Error(`${side.key}.${side.obj} declares no sync.${op}`);
|
|
57
|
-
const extra = resolve(s.args?.[side.key] || {}, { install });
|
|
58
|
-
return ports.call(side.key, spec.capability, { ...extra, ...args });
|
|
59
|
-
};
|
|
60
|
-
const idOf = (side, record) => {
|
|
61
|
-
const v = readField(record, side.spec.id || 'id');
|
|
62
|
-
return blank(v) ? null : String(v);
|
|
63
|
-
};
|
|
64
|
-
/** Identity values, normalised as the index stores them (email lower-cased, phone E.164), so search and index agree. */
|
|
65
|
-
const identityKeys = (s, role, record) => Object.fromEntries((s.identity?.match || []).map((m) => {
|
|
66
|
-
const raw = readField(record, m[role]);
|
|
67
|
-
const fn = m.transform && TRANSFORMS[m.transform];
|
|
68
|
-
return [m.key, fn && !blank(raw) ? fn(raw, {}, record) : raw];
|
|
69
|
-
}).filter(([, v]) => !blank(v)));
|
|
70
|
-
const wrap = (op, payload) => (op.body ? { [op.body]: payload } : payload);
|
|
71
|
-
|
|
72
|
-
/** Does this row apply to an update flowing from `from`? */
|
|
73
|
-
const applies = (row, from, link, toKey, updatedAt) => {
|
|
74
|
-
const master = row.master || 'source';
|
|
75
|
-
if (master === 'newest') {
|
|
76
|
-
const seen = link?.seen?.[toKey];
|
|
77
|
-
return !(seen && updatedAt && Date.parse(seen) > Date.parse(updatedAt));
|
|
78
|
-
}
|
|
79
|
-
return master === from;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Sync one record from one side to the other.
|
|
84
|
-
* @returns {Promise<{ status: 'created'|'updated'|'linked'|'unchanged'|'skipped', reason?, ids?, action? }>}
|
|
85
|
-
* action is what a syncRecord workflow hears: created, updated or linked (absent when nothing happened)
|
|
86
|
-
*/
|
|
87
|
-
async function syncRecord({ syncId, from, record }) {
|
|
88
|
-
const s = syncOf(syncId);
|
|
89
|
-
if (s.direction !== 'two_way' && from === 'target') return { status: 'skipped', reason: 'one-way sync' };
|
|
90
|
-
const A = sideOf(s, from), B = sideOf(s, otherRole(from));
|
|
91
|
-
if (s.filter && from === 'source' && !evaluate(s.filter, { record, install })) return { status: 'skipped', reason: 'filtered' };
|
|
92
|
-
const idA = idOf(A, record);
|
|
93
|
-
if (!idA) return { status: 'skipped', reason: `no id at ${A.spec.id || 'id'}` };
|
|
94
|
-
|
|
95
|
-
let link = await ports.links.find({ sync: s.id, from: A.key, id: idA });
|
|
96
|
-
let idB = link?.[B.key] ?? null;
|
|
97
|
-
let linked = false;
|
|
98
|
-
if (idB == null) {
|
|
99
|
-
const keys = identityKeys(s, from, record);
|
|
100
|
-
const hit = Object.keys(keys).length ? await ports.links.find({ sync: s.id, key: keys }) : null;
|
|
101
|
-
if (hit?.[B.key] != null) {
|
|
102
|
-
idB = String(hit[B.key]);
|
|
103
|
-
// The index row may be older than the record's own link rows: read B's link for its current state.
|
|
104
|
-
link = (await ports.links.find({ sync: s.id, from: B.key, id: idB })) || hit;
|
|
105
|
-
linked = true;
|
|
106
|
-
} else if (B.spec.search) {
|
|
107
|
-
const input = B.manifest.capabilities?.[B.spec.search.capability]?.input || {};
|
|
108
|
-
const args = Object.fromEntries(Object.entries(keys).filter(([k]) => k in input));
|
|
109
|
-
if (Object.keys(args).length) {
|
|
110
|
-
const res = await call(s, B, 'search', args);
|
|
111
|
-
const found = Array.isArray(res) ? res : res?.results || res?.data || [];
|
|
112
|
-
if (found.length === 1 || (found.length > 1 && s.identity?.onAmbiguous === 'first')) { idB = idOf(B, found[0]); linked = !!idB; }
|
|
113
|
-
else if (found.length > 1 && (s.identity?.onAmbiguous || 'skip') === 'skip') return { status: 'skipped', reason: `ambiguous: ${found.length} matches in ${B.key}.${B.obj}` };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const isCreate = idB == null;
|
|
119
|
-
const updatedAt = A.spec.updatedAt ? readField(record, A.spec.updatedAt) ?? null : null;
|
|
120
|
-
// Only fields the write accepts: Phone's update takes defaultFields but not externalId, and a field the API drops
|
|
121
|
-
// would read as changed forever. (A write that takes the record in one argument, body, is not checked.)
|
|
122
|
-
const op = B.spec[isCreate ? 'create' : 'update'];
|
|
123
|
-
const accepts = op && !op.body ? new Set(Object.keys(B.manifest?.capabilities?.[op.capability]?.input || {})) : null;
|
|
124
|
-
const rows = rowsToward(s, B.role)
|
|
125
|
-
.filter((r) => !accepts || accepts.has(r.target.split(/[.[]/)[0]))
|
|
126
|
-
.filter((r) => isCreate || applies(r, from, link, B.key, updatedAt));
|
|
127
|
-
const knownB = link?.known?.[B.key] || {};
|
|
128
|
-
const mapped = applyMapping(rows, record);
|
|
129
|
-
const written = {};
|
|
130
|
-
for (const r of rows) { const v = readField(mapped, r.target); if (v !== undefined) written[r.target] = hash(v); }
|
|
131
|
-
// An update sends only the fields whose value B does not already hold, so it cannot overwrite a concurrent
|
|
132
|
-
// edit to another field on B. A create sends everything.
|
|
133
|
-
const changedRows = isCreate ? rows : rows.filter((r) => written[r.target] !== undefined && knownB[r.target] !== written[r.target]);
|
|
134
|
-
const payload = isCreate ? mapped : applyMapping(changedRows, record);
|
|
135
|
-
// What A holds now, on the paths a sync would write into A: next time, a payload equal to it is not written.
|
|
136
|
-
const knownA = Object.fromEntries(rowsToward(s, A.role).map((r) => [r.target, hash(readField(record, r.target))]));
|
|
137
|
-
|
|
138
|
-
let wrote = false;
|
|
139
|
-
if (isCreate) {
|
|
140
|
-
if (!B.spec.create) return { status: 'skipped', reason: `${B.key}.${B.obj} cannot be created by a sync` };
|
|
141
|
-
if (!Object.keys(payload).length) return { status: 'skipped', reason: 'nothing to write' };
|
|
142
|
-
const res = await call(s, B, 'create', wrap(B.spec.create, payload));
|
|
143
|
-
idB = idOf(B, res);
|
|
144
|
-
if (!idB) throw new Error(`${B.key}.${B.obj} create returned no id at ${B.spec.id || 'id'}`);
|
|
145
|
-
wrote = true;
|
|
146
|
-
} else if (changedRows.length) {
|
|
147
|
-
if (!B.spec.update) return { status: 'skipped', reason: `${B.key}.${B.obj} cannot be updated by a sync` };
|
|
148
|
-
await call(s, B, 'update', { ...wrap(B.spec.update, payload), [B.spec.update.id]: idB });
|
|
149
|
-
wrote = true;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const changedKnowledge = wrote || linked || JSON.stringify(knownA) !== JSON.stringify(link?.known?.[A.key] || {});
|
|
153
|
-
if (changedKnowledge) {
|
|
154
|
-
const keysB = Object.fromEntries((s.identity?.match || []).map((m) => [m.key, readField(payload, m[B.role])]).filter(([, v]) => !blank(v)));
|
|
155
|
-
await ports.links.upsert({
|
|
156
|
-
sync: s.id,
|
|
157
|
-
ids: { [A.key]: idA, [B.key]: idB },
|
|
158
|
-
keys: { ...keysB, ...identityKeys(s, from, record) },
|
|
159
|
-
meta: {
|
|
160
|
-
known: { ...(link?.known || {}), [A.key]: knownA, [B.key]: { ...knownB, ...written } },
|
|
161
|
-
seen: { ...(link?.seen || {}), ...(updatedAt ? { [A.key]: updatedAt } : {}), ...(wrote ? { [B.key]: now().toISOString() } : {}) },
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
const ids = { [A.key]: idA, [B.key]: idB };
|
|
166
|
-
if (isCreate) return { status: 'created', action: 'created', ids };
|
|
167
|
-
if (linked) return { status: 'linked', action: 'linked', ids, wrote };
|
|
168
|
-
return wrote ? { status: 'updated', action: 'updated', ids } : { status: 'unchanged', ids };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** One page of one side. @returns {{ results, next }} next: the cursor of the following page, or null */
|
|
172
|
-
async function readPage({ syncId, role, cursor, pageSize, since }) {
|
|
173
|
-
const s = syncOf(syncId);
|
|
174
|
-
const S = sideOf(s, role);
|
|
175
|
-
const L = S.spec.list;
|
|
176
|
-
if (!L) throw new Error(`${S.key}.${S.obj} declares no sync.list`);
|
|
177
|
-
const mode = L.paging || 'cursor';
|
|
178
|
-
const args = {};
|
|
179
|
-
if (L.limit) args[L.limit] = pageSize;
|
|
180
|
-
if (L.cursor && cursor != null) args[L.cursor] = cursor;
|
|
181
|
-
if (L.since && since) args[L.since] = since;
|
|
182
|
-
const res = await call(s, S, 'list', args);
|
|
183
|
-
const items = (L.items ? readField(res, L.items) : res) || [];
|
|
184
|
-
const list = Array.isArray(items) ? items : [];
|
|
185
|
-
let next = null;
|
|
186
|
-
if (mode === 'cursor') { const n = L.next ? readField(res, L.next) : null; next = blank(n) ? null : n; }
|
|
187
|
-
else if (list.length >= pageSize) next = mode === 'offset' ? (Number(cursor) || 0) + list.length : (Number(cursor) || 1) + 1;
|
|
188
|
-
if (next !== null && String(next) === String(cursor)) next = null; // a cursor that does not move would loop
|
|
189
|
-
const results = [];
|
|
190
|
-
for (const record of list) {
|
|
191
|
-
try { results.push(await syncRecord({ syncId, from: role, record })); }
|
|
192
|
-
catch (e) { results.push({ status: 'failed', error: String(e.message || e), id: idOf(S, record) }); }
|
|
193
|
-
}
|
|
194
|
-
return { results, next, records: list };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const tally = (counts, results) => {
|
|
198
|
-
const out = { ...counts };
|
|
199
|
-
for (const r of results) out[r.status] = (out[r.status] || 0) + 1;
|
|
200
|
-
return out;
|
|
201
|
-
};
|
|
202
|
-
const errorsOf = (prev, results, role) => [...(prev || []), ...results.filter((r) => r.status === 'failed').map((r) => ({ role, id: r.id, error: r.error }))].slice(-ERROR_SAMPLE);
|
|
203
|
-
const backfillKey = (id) => `sync:${id}:backfill`;
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Start a backfill: every record of the source (and of the target, for a two-way sync that can list it).
|
|
207
|
-
* Refused while another is running and has made progress in the last hour.
|
|
208
|
-
* @returns {{ started: boolean, progress, next?: { role, cursor, runId } }}
|
|
209
|
-
*/
|
|
210
|
-
async function startBackfill({ syncId, runId, reason = 'backfill' }) {
|
|
211
|
-
const s = syncOf(syncId);
|
|
212
|
-
const prev = await state.get(backfillKey(s.id));
|
|
213
|
-
if (prev?.status === 'running' && Date.now() - Date.parse(prev.updatedAt) < 3600000) return { started: false, progress: prev };
|
|
214
|
-
const at = now().toISOString();
|
|
215
|
-
const progress = { runId, reason, status: 'running', role: 'source', cursor: null, pages: 0, counts: {}, errors: [], startedAt: at, updatedAt: at };
|
|
216
|
-
await state.put(backfillKey(s.id), progress);
|
|
217
|
-
return { started: true, progress, next: { role: 'source', cursor: null, runId } };
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Run one backfill page. A message for a run that is no longer current is dropped. */
|
|
221
|
-
async function continueBackfill({ syncId, runId, role, cursor }) {
|
|
222
|
-
const s = syncOf(syncId);
|
|
223
|
-
const progress = await state.get(backfillKey(s.id));
|
|
224
|
-
if (!progress || progress.runId !== runId || progress.status !== 'running') return { stale: true, progress };
|
|
225
|
-
const pageSize = s.backfill?.pageSize || 100;
|
|
226
|
-
const { results, next } = await readPage({ syncId, role, cursor, pageSize });
|
|
227
|
-
const updated = { ...progress, role, cursor: next, pages: progress.pages + 1, counts: tally(progress.counts, results), errors: errorsOf(progress.errors, results, role), updatedAt: now().toISOString() };
|
|
228
|
-
let following = null;
|
|
229
|
-
if (next !== null) following = { role, cursor: next, runId };
|
|
230
|
-
else if (role === 'source' && s.direction === 'two_way' && sideOf(s, 'target').spec.list) following = { role: 'target', cursor: null, runId };
|
|
231
|
-
if (following) Object.assign(updated, { role: following.role, cursor: following.cursor });
|
|
232
|
-
else Object.assign(updated, { status: 'completed', finishedAt: updated.updatedAt });
|
|
233
|
-
await state.put(backfillKey(s.id), updated);
|
|
234
|
-
return { progress: updated, next: following, results };
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* One poll: records changed since the last poll, on each side that can list by modified time. The first poll
|
|
239
|
-
* only sets the mark (a backfill copies what already exists). At most `maxPages` per side per poll; the rest
|
|
240
|
-
* continues from the saved cursor at the next tick.
|
|
241
|
-
*/
|
|
242
|
-
async function poll({ syncId, maxPages = 10 }) {
|
|
243
|
-
const s = syncOf(syncId);
|
|
244
|
-
const key = `sync:${s.id}:poll`;
|
|
245
|
-
const st = (await state.get(key)) || { since: {}, cursor: {} };
|
|
246
|
-
const roles = s.direction === 'two_way' ? ['source', 'target'] : ['source'];
|
|
247
|
-
const summary = {};
|
|
248
|
-
for (const role of roles) {
|
|
249
|
-
const S = sideOf(s, role);
|
|
250
|
-
if (!S.spec.list?.since) continue;
|
|
251
|
-
const started = now().toISOString();
|
|
252
|
-
if (!st.since[role]) { st.since[role] = started; summary[role] = { marked: started }; continue; }
|
|
253
|
-
let cursor = st.cursor[role] ?? null, pages = 0, newest = null, results = [];
|
|
254
|
-
do {
|
|
255
|
-
const page = await readPage({ syncId, role, cursor, pageSize: s.backfill?.pageSize || 100, since: st.since[role] });
|
|
256
|
-
results = results.concat(page.results);
|
|
257
|
-
for (const r of page.records) {
|
|
258
|
-
const u = S.spec.updatedAt ? readField(r, S.spec.updatedAt) : null;
|
|
259
|
-
if (u && (!newest || Date.parse(u) > Date.parse(newest))) newest = u;
|
|
260
|
-
}
|
|
261
|
-
cursor = page.next;
|
|
262
|
-
pages += 1;
|
|
263
|
-
} while (cursor !== null && pages < maxPages);
|
|
264
|
-
if (cursor !== null) st.cursor[role] = cursor; // more to read: same mark, carry on next tick
|
|
265
|
-
else { delete st.cursor[role]; st.since[role] = newest ? new Date(Date.parse(newest)).toISOString() : started; }
|
|
266
|
-
summary[role] = { pages, counts: tally({}, results), more: cursor !== null };
|
|
267
|
-
}
|
|
268
|
-
st.lastPolledAt = now().toISOString();
|
|
269
|
-
await state.put(key, st);
|
|
270
|
-
return summary;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** A webhook event about a synced object. An event that carries only an id is fetched first. */
|
|
274
|
-
async function handleEvent({ syncId, moduleKey, topic, event }) {
|
|
275
|
-
const s = syncOf(syncId);
|
|
276
|
-
const role = sideOf(s, 'source').key === moduleKey ? 'source' : sideOf(s, 'target').key === moduleKey ? 'target' : null;
|
|
277
|
-
if (!role) return { status: 'skipped', reason: `${moduleKey} is not a side of sync ${s.id}` };
|
|
278
|
-
if (role === 'target' && s.direction !== 'two_way') return { status: 'skipped', reason: 'one-way sync' };
|
|
279
|
-
const S = sideOf(s, role);
|
|
280
|
-
const ev = S.manifest?.events?.[topic] || {};
|
|
281
|
-
if (ev.object && ev.object !== S.obj) return { status: 'skipped', reason: `${topic} is about ${ev.object}` };
|
|
282
|
-
let record = ev.record ? getPath(event, '$.' + ev.record) : event;
|
|
283
|
-
if (ev.recordId) {
|
|
284
|
-
const id = readField(event, ev.recordId);
|
|
285
|
-
if (blank(id)) return { status: 'skipped', reason: `no id at ${ev.recordId}` };
|
|
286
|
-
record = await call(s, S, 'get', { [S.spec.get.id]: id });
|
|
287
|
-
if (!record) return { status: 'skipped', reason: 'record not found' };
|
|
288
|
-
}
|
|
289
|
-
return syncRecord({ syncId, from: role, record });
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const status = async (syncId) => ({ backfill: await state.get(backfillKey(syncId)), poll: await state.get(`sync:${syncId}:poll`) });
|
|
293
|
-
|
|
294
|
-
return { syncRecord, readPage, startBackfill, continueBackfill, poll, handleEvent, status };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
module.exports = { createSyncEngine };
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory HubSpot and Phone APIs with the real Api classes' call shapes (the manifests' `args`): HubSpot's
|
|
3
|
-
* getContactById(id), createContact(properties), updateContact(id, properties); Phone's getContact(id) and
|
|
4
|
-
* updateContact(id, { defaultFields }). searchContacts is on both only because the sync tests need a search; neither
|
|
5
|
-
* real Api has one (the manifests mark contact.search unimplemented). Test helper.
|
|
6
|
-
*/
|
|
7
|
-
/** A fake API: records by id, a write log, and the methods the manifest names. */
|
|
8
|
-
function hubspotApi() {
|
|
9
|
-
const records = new Map();
|
|
10
|
-
let n = 100;
|
|
11
|
-
const api = {
|
|
12
|
-
records, writes: [],
|
|
13
|
-
seed(props, extra = {}) { const id = String(++n); records.set(id, { id, properties: { ...props }, updatedAt: extra.updatedAt || '2026-10-01T00:00:00Z' }); return id; },
|
|
14
|
-
async getContactById(id) { return records.get(String(id)) || null; },
|
|
15
|
-
async searchContacts({ email, phone }) { return [...records.values()].filter((r) => (email && r.properties.email === email) || (phone && r.properties.phone === phone)); },
|
|
16
|
-
async listContacts({ after, limit = 100, updatedSince }) {
|
|
17
|
-
const all = [...records.values()].filter((r) => !updatedSince || Date.parse(r.updatedAt) > Date.parse(updatedSince));
|
|
18
|
-
const start = after ? Number(after) : 0;
|
|
19
|
-
const page = all.slice(start, start + limit);
|
|
20
|
-
return { results: page, ...(start + limit < all.length ? { paging: { next: { after: String(start + limit) } } } : {}) };
|
|
21
|
-
},
|
|
22
|
-
async createContact(properties) { api.writes.push(['create', properties]); const id = api.seed(properties, { updatedAt: '2026-10-02T00:00:00Z' }); return records.get(id); },
|
|
23
|
-
async updateContact(id, properties) { api.writes.push(['update', id, properties]); const r = records.get(String(id)); Object.assign(r.properties, properties); r.updatedAt = '2026-10-03T00:00:00Z'; return r; },
|
|
24
|
-
};
|
|
25
|
-
return api;
|
|
26
|
-
}
|
|
27
|
-
function phoneApi() {
|
|
28
|
-
const records = new Map();
|
|
29
|
-
let n = 0;
|
|
30
|
-
const api = {
|
|
31
|
-
records, writes: [], searches: [],
|
|
32
|
-
seed(fields, extra = {}) { const id = `CT${++n}`; records.set(id, { id, defaultFields: JSON.parse(JSON.stringify(fields)), ...extra }); return id; },
|
|
33
|
-
async getContact(id) { return records.get(id) || null; },
|
|
34
|
-
async searchContacts(args) {
|
|
35
|
-
api.searches.push(args);
|
|
36
|
-
return [...records.values()].filter((r) => (args.phone && r.defaultFields.phoneNumbers?.[0]?.value === args.phone) || (args.email && r.defaultFields.emails?.[0]?.value === args.email));
|
|
37
|
-
},
|
|
38
|
-
async listContacts({ pageToken, maxResults = 100 }) {
|
|
39
|
-
const all = [...records.values()];
|
|
40
|
-
const start = pageToken ? Number(pageToken) : 0;
|
|
41
|
-
return { data: all.slice(start, start + maxResults), ...(start + maxResults < all.length ? { nextPageToken: String(start + maxResults) } : {}) };
|
|
42
|
-
},
|
|
43
|
-
async createContact({ defaultFields, externalId }) { api.writes.push(['create', defaultFields, externalId]); const id = api.seed(defaultFields, { externalId }); return records.get(id); },
|
|
44
|
-
async updateContact(id, { defaultFields }) { api.writes.push(['update', id, defaultFields]); const r = records.get(id); r.defaultFields = { ...r.defaultFields, ...defaultFields }; return r; },
|
|
45
|
-
};
|
|
46
|
-
return api;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
module.exports = { hubspotApi, phoneApi };
|
|
@@ -1,91 +0,0 @@
|
|
|
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 };
|