@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.
Files changed (308) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +20 -10
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/application/index.js +6 -2
  8. package/core/Worker.js +31 -7
  9. package/core/create-handler.js +9 -0
  10. package/credential/repositories/credential-repository-documentdb.js +34 -0
  11. package/credential/repositories/credential-repository-interface.js +15 -0
  12. package/credential/repositories/credential-repository-mongo.js +23 -0
  13. package/credential/repositories/credential-repository-postgres.js +28 -0
  14. package/credential/use-cases/credential-operations-for-user.js +202 -0
  15. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  16. package/database/config.js +48 -24
  17. package/database/documentdb-encryption-service.js +9 -22
  18. package/database/documentdb-utils.js +2 -2
  19. package/database/encryption/README.md +78 -14
  20. package/database/encryption/documentdb-encryption-service.md +78 -1
  21. package/database/encryption/encryption-config.js +219 -0
  22. package/database/encryption/encryption-schema-registry.js +121 -24
  23. package/database/encryption/field-encryption-service.js +69 -5
  24. package/database/encryption/integration-mapping-encryption.js +1 -1
  25. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  26. package/database/organization-where.js +18 -0
  27. package/database/prisma.js +15 -38
  28. package/database/unique-violation.js +25 -0
  29. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  30. package/errors/index.js +0 -4
  31. package/extensions/bootstrap-runner.js +43 -0
  32. package/extensions/extension-loader.js +230 -0
  33. package/extensions/index.js +44 -0
  34. package/extensions/initialize-app.js +123 -0
  35. package/extensions/prisma-composer.js +270 -0
  36. package/extensions/route-mounter.js +103 -0
  37. package/extensions/schema-composer.js +188 -0
  38. package/generated/prisma-mongodb/edge.js +33 -42
  39. package/generated/prisma-mongodb/index-browser.js +30 -39
  40. package/generated/prisma-mongodb/index.d.ts +3187 -2005
  41. package/generated/prisma-mongodb/index.js +33 -42
  42. package/generated/prisma-mongodb/package.json +1 -1
  43. package/generated/prisma-mongodb/schema.prisma +86 -59
  44. package/generated/prisma-mongodb/wasm.js +33 -42
  45. package/generated/prisma-postgresql/edge.js +33 -42
  46. package/generated/prisma-postgresql/index-browser.js +30 -39
  47. package/generated/prisma-postgresql/index.d.ts +3380 -2177
  48. package/generated/prisma-postgresql/index.js +33 -42
  49. package/generated/prisma-postgresql/package.json +1 -1
  50. package/generated/prisma-postgresql/schema.prisma +81 -57
  51. package/generated/prisma-postgresql/wasm.js +33 -42
  52. package/handlers/WEBHOOKS.md +0 -29
  53. package/handlers/app-definition-loader.js +128 -19
  54. package/handlers/app-handler-helpers.js +27 -8
  55. package/handlers/backend-utils.js +31 -25
  56. package/handlers/integration-event-dispatcher.js +3 -7
  57. package/handlers/middleware/admin-auth.js +14 -1
  58. package/handlers/routers/auth.js +46 -10
  59. package/handlers/routers/health.js +13 -4
  60. package/handlers/routers/integration-defined-routers.js +144 -92
  61. package/handlers/routers/integration-webhook-routers.js +100 -50
  62. package/handlers/workers/integration-defined-workers.js +5 -2
  63. package/index.js +66 -16
  64. package/infrastructure/scheduler/index.js +19 -6
  65. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  66. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  67. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  68. package/integrations/index.js +1 -14
  69. package/integrations/integration-base.js +2 -163
  70. package/integrations/integration-router.js +132 -102
  71. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  72. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  73. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  74. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  75. package/integrations/repositories/integration-mapping-repository.js +0 -29
  76. package/integrations/repositories/integration-not-found.js +14 -0
  77. package/integrations/repositories/integration-repository-documentdb.js +37 -9
  78. package/integrations/repositories/integration-repository-interface.js +17 -4
  79. package/integrations/repositories/integration-repository-mongo.js +64 -96
  80. package/integrations/repositories/integration-repository-postgres.js +63 -81
  81. package/integrations/repositories/process-repository-mongo.js +1 -1
  82. package/integrations/schedule-config.js +80 -0
  83. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  84. package/integrations/use-cases/create-integration.js +56 -31
  85. package/integrations/use-cases/delete-integration-for-user.js +4 -4
  86. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  87. package/integrations/use-cases/get-integration-for-user.js +2 -4
  88. package/integrations/use-cases/get-integration-instance.js +4 -4
  89. package/integrations/use-cases/get-integrations-for-user.js +79 -39
  90. package/integrations/use-cases/get-possible-integrations.js +2 -7
  91. package/integrations/use-cases/index.js +7 -1
  92. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  93. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  94. package/integrations/use-cases/update-integration.js +4 -4
  95. package/integrations/utils/map-integration-dto.js +43 -14
  96. package/management-api/create-management-api-router.js +49 -0
  97. package/management-api/error-response.js +116 -0
  98. package/management-api/management-api-config.js +151 -0
  99. package/management-api/meta-router.js +76 -0
  100. package/management-api/openapi/build-openapi.js +144 -0
  101. package/management-api/openapi/components.js +415 -0
  102. package/management-api/openapi/v1-operations.js +83 -0
  103. package/management-api/openapi/v2-operations.js +264 -0
  104. package/management-api/route-registry.js +411 -0
  105. package/management-api/test-support/http-client.js +39 -0
  106. package/management-api/use-cases/get-management-api-meta.js +57 -0
  107. package/management-api/v1-deprecation.js +120 -0
  108. package/management-api/v2/create-v2-router.js +85 -0
  109. package/management-api/v2/dependencies.js +281 -0
  110. package/management-api/v2/dto.js +143 -0
  111. package/management-api/v2/handlers/authorization.js +94 -0
  112. package/management-api/v2/handlers/credentials.js +33 -0
  113. package/management-api/v2/handlers/entities.js +77 -0
  114. package/management-api/v2/handlers/integrations.js +128 -0
  115. package/management-api/v2/handlers/proxy.js +34 -0
  116. package/management-api/v2/request.js +85 -0
  117. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  118. package/modules/domain/authorization-session.js +144 -0
  119. package/modules/entity-scope.js +15 -0
  120. package/modules/entity-type-registry.js +311 -0
  121. package/modules/index.js +14 -0
  122. package/modules/module-factory.js +12 -0
  123. package/modules/module.js +53 -2
  124. package/modules/proxy/proxy-bases.js +169 -0
  125. package/modules/proxy/proxy-errors.js +31 -0
  126. package/modules/proxy/proxy-path.js +183 -0
  127. package/modules/proxy/proxy-policy.js +277 -0
  128. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  129. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  130. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  131. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  132. package/modules/repositories/entity-not-found.js +18 -0
  133. package/modules/repositories/module-repository-documentdb.js +51 -6
  134. package/modules/repositories/module-repository-interface.js +19 -2
  135. package/modules/repositories/module-repository-mongo.js +67 -74
  136. package/modules/repositories/module-repository-postgres.js +66 -74
  137. package/modules/repositories/module-repository.js +2 -1
  138. package/modules/requester/proxy-request.js +245 -0
  139. package/modules/requester/requester.js +35 -5
  140. package/modules/scope-config.js +192 -0
  141. package/modules/scope-policy.js +112 -0
  142. package/modules/scope-resolution.js +288 -0
  143. package/modules/scope-visibility.js +247 -0
  144. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  145. package/modules/tests/doubles/ownership-matching.js +18 -0
  146. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  147. package/modules/use-cases/create-entity-from-credential.js +113 -0
  148. package/modules/use-cases/entity-operations-for-user.js +162 -0
  149. package/modules/use-cases/entity-types.js +139 -0
  150. package/modules/use-cases/execute-entity-proxy-request.js +331 -0
  151. package/modules/use-cases/get-authorization-requirements.js +100 -0
  152. package/modules/use-cases/get-entities-for-user.js +26 -13
  153. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  154. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  155. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  156. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  157. package/modules/use-cases/get-module.js +10 -4
  158. package/modules/use-cases/manage-access.js +21 -0
  159. package/modules/use-cases/multi-step-authorization.js +329 -0
  160. package/modules/use-cases/process-authorization-callback.js +53 -19
  161. package/modules/use-cases/refresh-entity-options.js +10 -4
  162. package/modules/use-cases/test-module-auth.js +10 -4
  163. package/package.json +6 -7
  164. package/prisma-mongodb/schema.prisma +86 -59
  165. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  166. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  167. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  168. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  169. package/prisma-postgresql/schema.prisma +81 -57
  170. package/providers/index.js +23 -0
  171. package/providers/resolve-provider.js +93 -0
  172. package/providers/runtime-detection.js +124 -0
  173. package/queues/index.js +25 -0
  174. package/queues/providers/index.js +11 -0
  175. package/queues/providers/netlify-background-provider.js +150 -0
  176. package/queues/providers/qstash-queue-provider.js +219 -0
  177. package/queues/providers/sqs-queue-provider.js +70 -0
  178. package/queues/queue-client-interface.js +55 -0
  179. package/queues/queue-message-auth.js +193 -0
  180. package/queues/queue-provider-factory.js +101 -0
  181. package/queues/queue-provider.js +65 -0
  182. package/queues/queuer-util.js +2 -9
  183. package/queues/sqs-queue-client.js +81 -0
  184. package/types/index.d.ts +2 -0
  185. package/types/modules/index.d.ts +214 -0
  186. package/types/user/index.d.ts +134 -0
  187. package/user/adopter-jwt-config.js +177 -0
  188. package/user/adopter-jwt-errors.js +59 -0
  189. package/user/adopter-token-verifier.js +122 -0
  190. package/user/app-user-helpers.js +97 -0
  191. package/user/create-user-authentication.js +64 -0
  192. package/user/membership.js +51 -0
  193. package/user/repositories/organization-membership-record.js +19 -0
  194. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  195. package/user/repositories/organization-membership-repository-factory.js +42 -0
  196. package/user/repositories/organization-membership-repository-interface.js +57 -0
  197. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  198. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  199. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  200. package/user/repositories/user-repository-documentdb.js +27 -2
  201. package/user/repositories/user-repository-interface.js +18 -0
  202. package/user/repositories/user-repository-mongo.js +24 -0
  203. package/user/repositories/user-repository-postgres.js +18 -0
  204. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  205. package/user/use-cases/authenticate-user.js +7 -1
  206. package/user/use-cases/ensure-organization-membership.js +92 -0
  207. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  208. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  209. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  210. package/user/user.js +64 -1
  211. package/errors/mapping-already-exists-error.js +0 -14
  212. package/handlers/workers/integration-triggers.js +0 -25
  213. package/infrastructure/scheduler/recurrence.js +0 -204
  214. package/integrations/define/DESIGN.md +0 -122
  215. package/integrations/define/config.js +0 -98
  216. package/integrations/define/define-app.js +0 -66
  217. package/integrations/define/define-extension.js +0 -154
  218. package/integrations/define/define-integration.js +0 -335
  219. package/integrations/define/examples/deal-alerts.js +0 -140
  220. package/integrations/define/extension-binding.js +0 -174
  221. package/integrations/define/index.js +0 -11
  222. package/integrations/define/json-schema-lite.js +0 -68
  223. package/integrations/define/subscriptions.js +0 -186
  224. package/integrations/define/tests/fixtures/hubspot.js +0 -109
  225. package/integrations/define/tests/fixtures/slack.js +0 -26
  226. package/integrations/define/use.js +0 -39
  227. package/integrations/definitions/README.md +0 -278
  228. package/integrations/definitions/canonical.js +0 -11
  229. package/integrations/definitions/capabilities/AUDIT.md +0 -480
  230. package/integrations/definitions/capabilities/acmeerp.capabilities.json +0 -633
  231. package/integrations/definitions/capabilities/asana.capabilities.json +0 -279
  232. package/integrations/definitions/capabilities/calendly.capabilities.json +0 -385
  233. package/integrations/definitions/capabilities/docusign.capabilities.json +0 -314
  234. package/integrations/definitions/capabilities/fathom.capabilities.json +0 -238
  235. package/integrations/definitions/capabilities/github.capabilities.json +0 -585
  236. package/integrations/definitions/capabilities/hubspot.capabilities.json +0 -822
  237. package/integrations/definitions/capabilities/jira.capabilities.json +0 -391
  238. package/integrations/definitions/capabilities/klaviyo.capabilities.json +0 -503
  239. package/integrations/definitions/capabilities/linear.capabilities.json +0 -378
  240. package/integrations/definitions/capabilities/netsuite.capabilities.json +0 -535
  241. package/integrations/definitions/capabilities/notion.capabilities.json +0 -208
  242. package/integrations/definitions/capabilities/phone.capabilities.json +0 -511
  243. package/integrations/definitions/capabilities/pipedrive.capabilities.json +0 -430
  244. package/integrations/definitions/capabilities/quickbooks.capabilities.json +0 -635
  245. package/integrations/definitions/capabilities/salesforce.capabilities.json +0 -568
  246. package/integrations/definitions/capabilities/shopify.capabilities.json +0 -1128
  247. package/integrations/definitions/capabilities/slack.capabilities.json +0 -373
  248. package/integrations/definitions/capabilities/stripe.capabilities.json +0 -630
  249. package/integrations/definitions/capabilities/webflow.capabilities.json +0 -196
  250. package/integrations/definitions/capabilities/zendesk.capabilities.json +0 -422
  251. package/integrations/definitions/compile.js +0 -80
  252. package/integrations/definitions/config-form.js +0 -137
  253. package/integrations/definitions/examples/acme-erp-quickbooks.definition.json +0 -467
  254. package/integrations/definitions/examples/asana-slack.definition.json +0 -219
  255. package/integrations/definitions/examples/calendly-salesforce.definition.json +0 -459
  256. package/integrations/definitions/examples/docusign-salesforce.definition.json +0 -305
  257. package/integrations/definitions/examples/fathom-hubspot-notion.definition.json +0 -575
  258. package/integrations/definitions/examples/github-linear.definition.json +0 -322
  259. package/integrations/definitions/examples/hubspot-slack.definition.json +0 -427
  260. package/integrations/definitions/examples/klaviyo-hubspot.definition.json +0 -336
  261. package/integrations/definitions/examples/phone-hubspot.definition.json +0 -633
  262. package/integrations/definitions/examples/pipedrive-hubspot.definition.json +0 -136
  263. package/integrations/definitions/examples/pipedrive-phone.definition.json +0 -340
  264. package/integrations/definitions/examples/records/phone-hubspot.definition-version.json +0 -652
  265. package/integrations/definitions/examples/records/phone-hubspot.integration.json +0 -86
  266. package/integrations/definitions/examples/records/phone-hubspot.mappings.json +0 -39
  267. package/integrations/definitions/examples/records/phone-hubspot.workflow-run.json +0 -62
  268. package/integrations/definitions/examples/shopify-klaviyo.definition.json +0 -488
  269. package/integrations/definitions/examples/shopify-netsuite.definition.json +0 -622
  270. package/integrations/definitions/examples/stripe-quickbooks.definition.json +0 -476
  271. package/integrations/definitions/examples/webflow-hubspot-slack.definition.json +0 -262
  272. package/integrations/definitions/examples/zendesk-jira.definition.json +0 -604
  273. package/integrations/definitions/expressions.js +0 -229
  274. package/integrations/definitions/index.js +0 -48
  275. package/integrations/definitions/integration-runtime.js +0 -779
  276. package/integrations/definitions/interpreter.js +0 -104
  277. package/integrations/definitions/manifests.js +0 -177
  278. package/integrations/definitions/renderers/common.js +0 -67
  279. package/integrations/definitions/renderers/html.js +0 -129
  280. package/integrations/definitions/renderers/hubspot.js +0 -59
  281. package/integrations/definitions/renderers/preview.js +0 -154
  282. package/integrations/definitions/renderers/slack.js +0 -146
  283. package/integrations/definitions/repositories/definition-version-repository.js +0 -67
  284. package/integrations/definitions/repositories/workflow-run-repository.js +0 -80
  285. package/integrations/definitions/router.js +0 -170
  286. package/integrations/definitions/runtime.js +0 -268
  287. package/integrations/definitions/schema/capability-manifest.v0.1.schema.json +0 -549
  288. package/integrations/definitions/schema/installation.v0.1.schema.json +0 -130
  289. package/integrations/definitions/schema/integration-definition.v0.1.schema.json +0 -909
  290. package/integrations/definitions/schema/records.md +0 -105
  291. package/integrations/definitions/schema-check.js +0 -58
  292. package/integrations/definitions/scripts/check-manifests.js +0 -220
  293. package/integrations/definitions/scripts/validate.js +0 -66
  294. package/integrations/definitions/steps.js +0 -123
  295. package/integrations/definitions/sync-engine.js +0 -297
  296. package/integrations/definitions/tests/helpers-fake-crm.js +0 -49
  297. package/integrations/definitions/transforms.js +0 -91
  298. package/integrations/definitions/use-cases/definition-use-cases.js +0 -245
  299. package/integrations/definitions/validate.js +0 -436
  300. package/integrations/definitions/versioning.js +0 -176
  301. package/integrations/definitions/webhooks.js +0 -119
  302. package/integrations/integration-types.js +0 -73
  303. package/integrations/triggers.js +0 -244
  304. package/integrations/use-cases/run-trigger-tick.js +0 -155
  305. package/integrations/use-cases/run-trigger.js +0 -77
  306. package/prisma-postgresql/migrations/20261018000000_add_data_defined_integrations/migration.sql +0 -66
  307. package/prisma-postgresql/migrations/20261019000000_workflow_run_waits/migration.sql +0 -7
  308. 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 };