thinkwork-cli 0.13.2 → 1.0.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 (231) hide show
  1. package/README.md +3 -14
  2. package/dist/{api-client-JYZXYQJR.js → api-client-4P4JZOBX.js} +1 -1
  3. package/dist/{chunk-Z2F6RJFR.js → chunk-4OMDWYZQ.js} +4 -5
  4. package/dist/{chunk-ILZKG5WV.js → chunk-AD2NIWB5.js} +434 -69
  5. package/dist/chunk-MWQ7RO76.js +137 -0
  6. package/dist/{chunk-DVPBI5HM.js → chunk-OLUQ4U2Z.js} +26 -26
  7. package/dist/{chunk-QOERT44A.js → chunk-YQCYD6NE.js} +57 -44
  8. package/dist/cli.js +4400 -16431
  9. package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +0 -1
  10. package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +0 -1
  11. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +0 -14
  12. package/dist/{dist-es-CRV46D5B.js → dist-es-2OTNTL4N.js} +4 -4
  13. package/dist/{dist-es-ENBUC3JP.js → dist-es-2XVAH4BQ.js} +2 -2
  14. package/dist/{dist-es-DCY4IC3Y.js → dist-es-3DPDUJAH.js} +2 -2
  15. package/dist/{dist-es-G7PTG75M.js → dist-es-BNZTBBVC.js} +1 -1
  16. package/dist/{dist-es-GKDBE7E6.js → dist-es-CD2RBDAC.js} +1 -1
  17. package/dist/{dist-es-R7FGL2IB.js → dist-es-I4FW57PS.js} +1 -1
  18. package/dist/{dist-es-USORBXZ5.js → dist-es-KVFWJNDY.js} +4 -4
  19. package/dist/{dist-es-HY3535OI.js → dist-es-PS73DWNL.js} +2 -2
  20. package/dist/{dist-es-LDE437AM.js → dist-es-S3NZQFGT.js} +1 -1
  21. package/dist/{dist-es-G54LBMRN.js → dist-es-SGCA4P32.js} +1 -1
  22. package/dist/{dist-es-Q57SZXZ7.js → dist-es-WBM6J4RA.js} +2 -2
  23. package/dist/{dist-es-P26FWFLT.js → dist-es-ZGVADYIF.js} +8 -8
  24. package/dist/drizzle/0227_analyst_reader_role.sql +17 -26
  25. package/dist/drizzle/0230_analyst_rls.sql +28 -45
  26. package/dist/drizzle/0261_native_auth_control_plane.sql +7 -3
  27. package/dist/drizzle/0270_tool_execution_ledger.sql +115 -0
  28. package/dist/drizzle/0271_ontology_twin_declarations.sql +47 -0
  29. package/dist/drizzle/0272_identity_graph_projection_cursor.sql +23 -0
  30. package/dist/drizzle/0273_twin_materialization_suggestions.sql +29 -0
  31. package/dist/drizzle/0274_tenant_mcp_twin_keys.sql +59 -0
  32. package/dist/drizzle/0275_identity_bulk_rebuild_fence.sql +30 -0
  33. package/dist/drizzle/0276_tool_execution_ledger_allow_cascade_delete.sql +18 -0
  34. package/dist/drizzle/0277_knowledge_base_sources.sql +107 -0
  35. package/dist/drizzle/0278_kb_page_transcription.sql +55 -0
  36. package/dist/drizzle/0279_mcp_servers_off_plugin_provenance.sql +93 -0
  37. package/dist/drizzle/0280_work_item_external_refs_drop_plane_provider.sql +46 -0
  38. package/dist/drizzle/0281_tenant_mcp_twin_keys_expiry_suffix.sql +9 -0
  39. package/dist/drizzle/0282_tenant_mcp_twin_keys_grants.sql +24 -0
  40. package/dist/drizzle/0283_agents_agentcore_runtime_dispatch.sql +13 -0
  41. package/dist/drizzle/0284_user_brain_claims.sql +69 -0
  42. package/dist/drizzle/0285_tenant_mcp_twin_keys_trusted_subsystem.sql +30 -0
  43. package/dist/drizzle/0286_brain_analytics_key.sql +26 -0
  44. package/dist/drizzle/0287_brain_subject.sql +25 -0
  45. package/dist/drizzle/0288_multi_lane_identities.sql +25 -0
  46. package/dist/{loadSso-DJVB7BXE.js → loadSso-ETCXTR4I.js} +2 -2
  47. package/dist/{loadSso-JAMNA3WY.js → loadSso-UBD7KUHE.js} +2 -2
  48. package/dist/plugins/README.md +2 -2
  49. package/dist/plugins/catalog/package.json +0 -3
  50. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +6 -3
  51. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +21 -21
  52. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +102 -25
  53. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +7 -6
  54. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -15
  55. package/dist/plugins/catalog/src/contracts.ts +15 -0
  56. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -18
  57. package/dist/plugins/n8n/test/integrated-app-contract.test.ts +1 -1
  58. package/dist/plugins/twenty/package.json +2 -1
  59. package/dist/plugins/twenty/scripts/LAMBDA_DEPLOY.md +114 -0
  60. package/dist/plugins/twenty/scripts/build-lambda.sh +29 -0
  61. package/dist/plugins/twenty/scripts/configure-product-views.ts +195 -0
  62. package/dist/plugins/twenty/scripts/lambda-handler.ts +49 -0
  63. package/dist/plugins/twenty/scripts/lib/__tests__/load-attachments.test.ts +38 -0
  64. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +145 -2
  65. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +397 -151
  66. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +28 -129
  67. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +10 -0
  68. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +13 -8
  69. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +222 -2
  70. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +29 -0
  71. package/dist/plugins/twenty/scripts/lib/load-records.ts +160 -43
  72. package/dist/plugins/twenty/scripts/lib/mappers.ts +368 -152
  73. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +43 -268
  74. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +57 -0
  75. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +136 -31
  76. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +254 -152
  77. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +44 -1
  78. package/dist/plugins/twenty/scripts/purge-lastmile-import.ts +178 -0
  79. package/dist/plugins/twenty/scripts/restructure-products.ts +312 -0
  80. package/dist/plugins/twenty/scripts/retag-stage-values.ts +362 -0
  81. package/dist/plugins/twenty/test/manifest.test.ts +2 -4
  82. package/dist/scripts/post-deploy.sh +79 -1
  83. package/dist/{signin-UGDWTGS4.js → signin-5QZNRSOO.js} +2 -2
  84. package/dist/{sso-oidc-Q4VXANXY.js → sso-oidc-XK673P54.js} +2 -2
  85. package/dist/{sts-CWLSE3GH.js → sts-OXI2UALF.js} +6 -134
  86. package/dist/{sts-DNZLNVOV.js → sts-SQW4NBK3.js} +6 -134
  87. package/dist/terraform/examples/greenfield/main.tf +233 -175
  88. package/dist/terraform/examples/greenfield/terraform.tfvars.example +12 -8
  89. package/dist/terraform/modules/app/agentcore-admin/main.tf +47 -0
  90. package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +39 -47
  91. package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +13 -66
  92. package/dist/terraform/modules/app/agentcore-identity/main.tf +106 -26
  93. package/dist/terraform/modules/app/agentcore-identity/outputs.tf +6 -6
  94. package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +216 -99
  95. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +6 -9
  96. package/dist/terraform/modules/app/agentcore-identity/scripts/delete_workload_identity.sh +19 -0
  97. package/dist/terraform/modules/app/agentcore-identity/scripts/ensure_workload_identity.sh +29 -0
  98. package/dist/terraform/modules/app/agentcore-identity/scripts/read_identity.sh +22 -7
  99. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +17 -46
  100. package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +65 -3
  101. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/after.tf +15 -0
  102. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/before.tf +7 -0
  103. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-after.tf +8 -0
  104. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-before.tf +9 -0
  105. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-after.tf +3 -0
  106. package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-before.tf +3 -0
  107. package/dist/terraform/modules/app/agentcore-identity/variables.tf +6 -0
  108. package/dist/terraform/modules/app/agentcore-memory/README.md +46 -9
  109. package/dist/terraform/modules/app/agentcore-memory/main.tf +27 -10
  110. package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +156 -24
  111. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  112. package/dist/terraform/modules/app/agentcore-pi/main.tf +42 -52
  113. package/dist/terraform/modules/app/agentcore-pi/outputs.tf +3 -3
  114. package/dist/terraform/modules/app/agentcore-pi/scripts/reconcile_pi_runtime.mjs +175 -0
  115. package/dist/terraform/modules/app/agentcore-pi/variables.tf +35 -28
  116. package/dist/terraform/modules/app/agentcore-platform/main.tf +68 -0
  117. package/dist/terraform/modules/app/agentcore-runtime/main.tf +4 -20
  118. package/dist/terraform/modules/app/capability-broker/main.tf +2 -2
  119. package/dist/terraform/modules/app/capability-broker/variables.tf +11 -0
  120. package/dist/terraform/modules/app/deployment-control-plane/README.md +264 -0
  121. package/dist/terraform/modules/app/deployment-control-plane/buildspec.yml +4 -1
  122. package/dist/terraform/modules/app/deployment-control-plane/main.tf +94 -0
  123. package/dist/terraform/modules/app/deployment-control-plane/outputs.tf +5 -0
  124. package/dist/terraform/modules/app/deployment-control-plane/runner.py +2641 -124
  125. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +2034 -154
  126. package/dist/terraform/modules/app/deployment-control-plane/variables.tf +33 -0
  127. package/dist/terraform/modules/app/lambda-api/auth-state.tf +72 -0
  128. package/dist/terraform/modules/app/lambda-api/chat-latency-observability.tf +466 -0
  129. package/dist/terraform/modules/app/lambda-api/dispatch-dlq.tf +55 -0
  130. package/dist/terraform/modules/app/lambda-api/handlers.tf +411 -723
  131. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +262 -198
  132. package/dist/terraform/modules/app/lambda-api/main.tf +51 -13
  133. package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +4 -181
  134. package/dist/terraform/modules/app/lambda-api/memory-alarms.tf +3 -28
  135. package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +1 -2
  136. package/dist/terraform/modules/app/lambda-api/outputs.tf +17 -45
  137. package/dist/terraform/modules/app/lambda-api/turn-assertion.tf +45 -0
  138. package/dist/terraform/modules/app/lambda-api/variables.tf +219 -126
  139. package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/README.md +1 -1
  140. package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/main.tf +18 -18
  141. package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +11 -10
  142. package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +5 -5
  143. package/dist/terraform/modules/app/static-site/main.tf +1 -1
  144. package/dist/terraform/modules/app/www-dns/main.tf +9 -27
  145. package/dist/terraform/modules/app/www-dns/variables.tf +4 -10
  146. package/dist/terraform/modules/data/aurora-postgres/README.md +34 -0
  147. package/dist/terraform/modules/data/aurora-postgres/alarms.tf +82 -0
  148. package/dist/terraform/modules/data/aurora-postgres/main.tf +27 -57
  149. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +0 -9
  150. package/dist/terraform/modules/data/aurora-postgres/variables.tf +41 -0
  151. package/dist/terraform/modules/data/s3-buckets/main.tf +5 -6
  152. package/dist/terraform/modules/foundation/cognito/main.tf +81 -8
  153. package/dist/terraform/modules/foundation/cognito/outputs.tf +5 -0
  154. package/dist/terraform/modules/foundation/cognito/variables.tf +24 -12
  155. package/dist/terraform/modules/thinkwork/README.md +5 -1
  156. package/dist/terraform/modules/thinkwork/main.tf +376 -374
  157. package/dist/terraform/modules/thinkwork/outputs.tf +12 -158
  158. package/dist/terraform/modules/thinkwork/variables.tf +246 -89
  159. package/dist/workspace-defaults/files/INSTRUCTIONS.md +9 -11
  160. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +38 -35
  161. package/dist/workspace-defaults/files/ROUTER.md +1 -1
  162. package/dist/workspace-defaults/files/TOOLS.md +9 -39
  163. package/dist/workspace-defaults/files/agents/analyst/INSTRUCTIONS.md +6 -3
  164. package/dist/workspace-defaults/files/catalog-skills/brain-brief-builder/SKILL.md +165 -0
  165. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/SKILL.md +75 -0
  166. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/mcp-tooling.md +40 -0
  167. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/validation-and-handoff.md +40 -0
  168. package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/workflow-authoring.md +46 -0
  169. package/dist/workspace-defaults/files/skills/artifact-builder/references/crm-dashboard.md +1 -1
  170. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +22 -3
  171. package/package.json +1 -1
  172. package/dist/drizzle/0261_managed_harness_multiplayer.sql +0 -313
  173. package/dist/drizzle/0262_parallel_harness_threads.sql +0 -9
  174. package/dist/drizzle/0264_harness_tool_execution_ledger.sql +0 -113
  175. package/dist/plugins/company-data/README.md +0 -49
  176. package/dist/plugins/company-data/package.json +0 -21
  177. package/dist/plugins/company-data/src/index.ts +0 -30
  178. package/dist/plugins/company-data/src/manifest.ts +0 -26
  179. package/dist/plugins/company-data/test/manifest.test.ts +0 -116
  180. package/dist/plugins/company-data/tsconfig.json +0 -8
  181. package/dist/plugins/company-etl/README.md +0 -46
  182. package/dist/plugins/company-etl/package.json +0 -21
  183. package/dist/plugins/company-etl/src/index.ts +0 -29
  184. package/dist/plugins/company-etl/src/manifest.ts +0 -26
  185. package/dist/plugins/company-etl/test/manifest.test.ts +0 -108
  186. package/dist/plugins/company-etl/tsconfig.json +0 -8
  187. package/dist/plugins/lastmile/README.md +0 -50
  188. package/dist/plugins/lastmile/package.json +0 -23
  189. package/dist/plugins/lastmile/smoke/lastmile-plugin-smoke.mjs +0 -661
  190. package/dist/plugins/lastmile/src/api/tasks-adapter.ts +0 -572
  191. package/dist/plugins/lastmile/src/discovery.fixture.ts +0 -64
  192. package/dist/plugins/lastmile/src/index.ts +0 -46
  193. package/dist/plugins/lastmile/src/manifest.ts +0 -139
  194. package/dist/plugins/lastmile/test/api/tasks-adapter.test.ts +0 -266
  195. package/dist/plugins/lastmile/test/discovery.test.ts +0 -88
  196. package/dist/plugins/lastmile/tsconfig.json +0 -8
  197. package/dist/plugins/n8n/smoke/n8n-managed-app-smoke.mjs +0 -409
  198. package/dist/plugins/twenty/smoke/twenty-managed-app-smoke.mjs +0 -359
  199. package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +0 -61
  200. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/sitecustomize.py +0 -227
  201. package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/test_sitecustomize.py +0 -277
  202. package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +0 -70
  203. package/dist/terraform/modules/app/agentcore-gateway/main.tf +0 -305
  204. package/dist/terraform/modules/app/agentcore-gateway/outputs.tf +0 -44
  205. package/dist/terraform/modules/app/agentcore-gateway/scripts/delete_gateway.sh +0 -93
  206. package/dist/terraform/modules/app/agentcore-gateway/scripts/read_gateway.sh +0 -33
  207. package/dist/terraform/modules/app/agentcore-gateway/scripts/reconcile_gateway.sh +0 -543
  208. package/dist/terraform/modules/app/agentcore-gateway/variables.tf +0 -70
  209. package/dist/terraform/modules/app/agentcore-harness/main.tf +0 -330
  210. package/dist/terraform/modules/app/agentcore-harness/outputs.tf +0 -72
  211. package/dist/terraform/modules/app/agentcore-harness/scripts/delete_harness.sh +0 -5
  212. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-lifecycle.mjs +0 -430
  213. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-readback.mjs +0 -180
  214. package/dist/terraform/modules/app/agentcore-harness/scripts/harness-tool-contract.mjs +0 -193
  215. package/dist/terraform/modules/app/agentcore-harness/scripts/prune_harness.sh +0 -5
  216. package/dist/terraform/modules/app/agentcore-harness/scripts/read_harness.sh +0 -5
  217. package/dist/terraform/modules/app/agentcore-harness/scripts/reconcile_harness.sh +0 -5
  218. package/dist/terraform/modules/app/agentcore-harness/variables.tf +0 -96
  219. package/dist/terraform/modules/app/hindsight-memory/README.md +0 -89
  220. package/dist/terraform/modules/app/hindsight-memory/main.tf +0 -411
  221. package/dist/terraform/modules/app/lambda-api/agentcore-harness-alarms.tf +0 -88
  222. package/dist/terraform/modules/app/lambda-api/agentcore-identity-alarms.tf +0 -91
  223. package/dist/terraform/modules/data/bedrock-knowledge-base/main.tf +0 -102
  224. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/outputs.tf +0 -0
  225. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/sync-database.py +0 -0
  226. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/test_sync_database.py +0 -0
  227. /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/variables.tf +0 -0
  228. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/README.md +0 -0
  229. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/main.tf +0 -0
  230. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/outputs.tf +0 -0
  231. /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/variables.tf +0 -0
@@ -210,6 +210,15 @@ export interface McpServerComponent {
210
210
  resultTransforms?: McpResultTransform[];
211
211
  /** Optional human notes about the tools the server exposes. */
212
212
  toolNotes?: string[];
213
+ /**
214
+ * THINK-623 — opt in to the long-call profile for this server's tools.
215
+ * Set it only when the server streams MCP progress notifications during
216
+ * long calls: the runtime then resets the per-attempt `callTool` wall on
217
+ * every notification (bounded by an absolute total ceiling) instead of
218
+ * killing the call at the fixed 60s wall. Servers that go quiet for
219
+ * longer than the per-attempt wall still time out.
220
+ */
221
+ longRunning?: boolean;
213
222
  }
214
223
 
215
224
  export interface SkillSupportingFile {
@@ -671,6 +680,12 @@ function validateMcpServerComponent(
671
680
  );
672
681
  }
673
682
  }
683
+ if (
684
+ component.longRunning !== undefined &&
685
+ typeof component.longRunning !== "boolean"
686
+ ) {
687
+ throw new PluginManifestError(`${prefix}.longRunning must be a boolean`);
688
+ }
674
689
  const auth = component.auth as Partial<McpServerAuth> | undefined;
675
690
  if (!auth || typeof auth !== "object" || Array.isArray(auth)) {
676
691
  throw new PluginManifestError(`${prefix}.auth is required`);
@@ -3,26 +3,14 @@
3
3
  // pnpm --filter @thinkwork/plugin-catalog generate:plugins
4
4
 
5
5
  import { defineFirstPartyPluginPackage } from "../plugin-package";
6
- import { companyDataPluginPackage as rawCompanyDataPluginPackage } from "@thinkwork/plugin-company-data";
7
- import { companyEtlPluginPackage as rawCompanyEtlPluginPackage } from "@thinkwork/plugin-company-etl";
8
6
  import { emailChannelPluginPackage as rawEmailChannelPluginPackage } from "@thinkwork/plugin-email-channel";
9
- import { lastmilePluginPackage as rawLastmilePluginPackage } from "@thinkwork/plugin-lastmile";
10
7
  import { n8nPluginPackage as rawN8nPluginPackage } from "@thinkwork/plugin-n8n";
11
8
  import { sendgridPluginPackage as rawSendgridPluginPackage } from "@thinkwork/plugin-sendgrid";
12
9
  import { twentyPluginPackage as rawTwentyPluginPackage } from "@thinkwork/plugin-twenty";
13
10
 
14
- export const companyDataPluginPackage = defineFirstPartyPluginPackage(
15
- rawCompanyDataPluginPackage,
16
- );
17
- export const companyEtlPluginPackage = defineFirstPartyPluginPackage(
18
- rawCompanyEtlPluginPackage,
19
- );
20
11
  export const emailChannelPluginPackage = defineFirstPartyPluginPackage(
21
12
  rawEmailChannelPluginPackage,
22
13
  );
23
- export const lastmilePluginPackage = defineFirstPartyPluginPackage(
24
- rawLastmilePluginPackage,
25
- );
26
14
  export const n8nPluginPackage = defineFirstPartyPluginPackage(
27
15
  rawN8nPluginPackage,
28
16
  );
@@ -33,19 +21,13 @@ export const twentyPluginPackage = defineFirstPartyPluginPackage(
33
21
  rawTwentyPluginPackage,
34
22
  );
35
23
 
36
- export const companyDataManifest = companyDataPluginPackage.manifest;
37
- export const companyEtlManifest = companyEtlPluginPackage.manifest;
38
24
  export const emailChannelManifest = emailChannelPluginPackage.manifest;
39
- export const lastmileManifest = lastmilePluginPackage.manifest;
40
25
  export const n8nManifest = n8nPluginPackage.manifest;
41
26
  export const sendgridManifest = sendgridPluginPackage.manifest;
42
27
  export const twentyManifest = twentyPluginPackage.manifest;
43
28
 
44
29
  export const firstPartyPluginPackages = [
45
- companyDataPluginPackage,
46
- companyEtlPluginPackage,
47
30
  emailChannelPluginPackage,
48
- lastmilePluginPackage,
49
31
  n8nPluginPackage,
50
32
  sendgridPluginPackage,
51
33
  twentyPluginPackage,
@@ -211,7 +211,7 @@ describe("n8n integrated app contract", () => {
211
211
  const docsPage = await readFile(
212
212
  fileURLToPath(
213
213
  new URL(
214
- "../../../docs/src/content/docs/applications/n8n.mdx",
214
+ "../../../docs/reference/applications/n8n.mdx",
215
215
  import.meta.url,
216
216
  ),
217
217
  ),
@@ -13,7 +13,8 @@
13
13
  "scripts": {
14
14
  "build": "tsc --build",
15
15
  "typecheck": "tsc --noEmit",
16
- "test": "vitest run"
16
+ "test": "vitest run",
17
+ "migrate:lastmile": "tsx scripts/migrate-lastmile.ts"
17
18
  },
18
19
  "dependencies": {
19
20
  "graphql": "^16.10.0"
@@ -0,0 +1,114 @@
1
+ # TEI LastMile → Twenty sync — Lambda deploy runbook
2
+
3
+ Deploy the nightly LastMile → Twenty delta sync as an AWS Lambda in **TEI's
4
+ account** (the same account as `n8n.lastmile-tei.com` and the LastMile RDS), and
5
+ invoke it nightly from n8n. The function reuses the exact code proven against
6
+ TEI's live Twenty; a full delta run is ~90s, so it fits Lambda's 15-minute
7
+ ceiling with wide headroom.
8
+
9
+ **Properties:** idempotent (upsert by `sourceId` + deletion mirror), reads
10
+ LastMile **read-only**, never provisions members, needs **no AWS credentials**
11
+ of its own (the ~4 CRM attachments Twenty can't ingest are reported, not fatal).
12
+
13
+ ---
14
+
15
+ ## 1. Build the deployment zip
16
+
17
+ From the repo root:
18
+
19
+ ```bash
20
+ pnpm install
21
+ bash plugins/twenty/scripts/build-lambda.sh
22
+ # → plugins/twenty/dist/tei-lastmile-sync-lambda.zip (handler: index.handler)
23
+ ```
24
+
25
+ The zip is self-contained (pg, bcryptjs, aws-sdk all inlined) — no `npm install`
26
+ in the account.
27
+
28
+ ## 2. Create the function
29
+
30
+ - **Runtime:** Node.js 22.x
31
+ - **Handler:** `index.handler`
32
+ - **Architecture:** arm64 or x86_64 (either)
33
+ - **Timeout:** 300s (run is ~90s; leaves headroom for a busier delta)
34
+ - **Memory:** 512 MB
35
+ - **Code:** upload `tei-lastmile-sync-lambda.zip`
36
+
37
+ ```bash
38
+ aws lambda create-function \
39
+ --function-name tei-lastmile-sync \
40
+ --runtime nodejs22.x --handler index.handler \
41
+ --timeout 300 --memory-size 512 \
42
+ --role arn:aws:iam::<ACCOUNT_ID>:role/<lambda-exec-role> \
43
+ --zip-file fileb://plugins/twenty/dist/tei-lastmile-sync-lambda.zip
44
+ # later updates: aws lambda update-function-code --function-name tei-lastmile-sync \
45
+ # --zip-file fileb://plugins/twenty/dist/tei-lastmile-sync-lambda.zip
46
+ ```
47
+
48
+ ## 3. Configuration (the three env vars)
49
+
50
+ | Variable | Value |
51
+ | ----------------------- | ---------------------------------------------------- |
52
+ | `TWENTY_PUBLIC_URL` | `https://crm.tei.thinkwork.ai` |
53
+ | `TWENTY_API_KEY` | Twenty workspace API key (Settings → API & Webhooks) |
54
+ | `LASTMILE_DATABASE_URL` | read-only DSN for the `dispatch` database |
55
+
56
+ Set them directly as Lambda environment variables, or (preferred) store the two
57
+ secrets in Secrets Manager and resolve them at start. If you use Secrets
58
+ Manager, add `secretsmanager:GetSecretValue` to the execution role.
59
+
60
+ ```bash
61
+ aws lambda update-function-configuration --function-name tei-lastmile-sync \
62
+ --environment "Variables={TWENTY_PUBLIC_URL=https://crm.tei.thinkwork.ai,TWENTY_API_KEY=***,LASTMILE_DATABASE_URL=***}"
63
+ ```
64
+
65
+ ## 4. Networking — the one thing that must be right
66
+
67
+ The function must reach **both** the LastMile RDS **and** `crm.tei.thinkwork.ai`.
68
+ Mirror wherever n8n already connects to the RDS:
69
+
70
+ - **In-VPC (cleanest):** attach the Lambda to the RDS's VPC/subnets, give it a
71
+ security group allowed to reach the RDS on 5432, and ensure the subnets have a
72
+ **NAT gateway** so it can still reach `crm.tei.thinkwork.ai` over HTTPS.
73
+ - **No-VPC (public):** the RDS is a public endpoint — the function reaches it and
74
+ Twenty over the internet, but the **RDS security group must admit the
75
+ function's egress IP** (the account's NAT/EIP). This is the same allowlisting
76
+ n8n already has.
77
+
78
+ Execution role needs at least `AWSLambdaBasicExecutionRole` (CloudWatch Logs),
79
+ plus `AWSLambdaVPCAccessExecutionRole` if you attach it to a VPC.
80
+
81
+ ## 5. Smoke test before scheduling
82
+
83
+ Always dry-run first (reads + reports, writes nothing):
84
+
85
+ ```bash
86
+ aws lambda invoke --function-name tei-lastmile-sync \
87
+ --payload '{"mode":"dry-run"}' --cli-binary-format raw-in-base64-out out.json
88
+ cat out.json # → {"ok":true,"failed":0,"report":{...parity...}}
89
+ ```
90
+
91
+ If `report.mode` is `dry-run` and the entity counts look right, you're wired
92
+ correctly. Then run once for real: `--payload '{"mode":"apply"}'` (or `'{}'` —
93
+ apply is the default).
94
+
95
+ ## 6. Wire n8n
96
+
97
+ 1. **Schedule Trigger** node — cron `0 3 * * *` (03:00 America/Chicago), or your
98
+ preferred window.
99
+ 2. **AWS Lambda** node → _Invoke_, region `us-east-1`, function
100
+ `tei-lastmile-sync`, invocation type `RequestResponse`. Leave the payload
101
+ empty (defaults to apply) or send `{"mode":"apply"}`.
102
+ 3. (Optional) an **IF** node on `{{$json.ok}}` / `{{$json.failed}}` to post a
103
+ Slack/email alert when a run reports soft failures.
104
+
105
+ n8n's AWS credential needs `lambda:InvokeFunction` on the function ARN.
106
+
107
+ ## 7. Operational invariants (human-owned)
108
+
109
+ - Keep Twenty's **"Create company when adding a new person"** workflow
110
+ **deactivated** — the API key can't toggle it, and it mislinks new people.
111
+ - **Rotate** the shared `TWENTY_REP_PASSWORD` once the validation window closes.
112
+ - A **cold re-seed** (empty Twenty) creates thousands of records at Twenty's rate
113
+ limit and can exceed 15 min — run that from the CLI/bundle, not this Lambda.
114
+ Steady-state nightly deltas are the Lambda's job.
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # Build a self-contained AWS Lambda deployment zip for the LastMile → Twenty
3
+ # nightly sync. The bundle inlines every dependency (pg, bcryptjs, aws-sdk),
4
+ # so the target account needs no `npm install` — just upload the zip.
5
+ #
6
+ # Usage: bash scripts/build-lambda.sh
7
+ # Output: plugins/twenty/dist/tei-lastmile-sync-lambda.zip (handler: index.handler)
8
+ set -euo pipefail
9
+
10
+ cd "$(dirname "$0")/.." # -> plugins/twenty
11
+ OUT_DIR="dist/tei-lastmile-sync-lambda"
12
+ ZIP="dist/tei-lastmile-sync-lambda.zip"
13
+
14
+ rm -rf "$OUT_DIR" "$ZIP"
15
+ mkdir -p "$OUT_DIR"
16
+
17
+ # CJS + .js so Lambda's Node runtime resolves `index.handler` with no wrapper.
18
+ npx esbuild scripts/lambda-handler.ts \
19
+ --bundle --platform=node --format=cjs --target=node20 \
20
+ --outfile="$OUT_DIR/index.js"
21
+
22
+ # The repo's plugins/twenty/package.json is "type":"module", which would make
23
+ # Node treat the CJS index.js as ESM. Ship a CommonJS marker inside the zip so
24
+ # the handler loads correctly regardless of the deploy environment.
25
+ printf '{\n "type": "commonjs"\n}\n' >"$OUT_DIR/package.json"
26
+
27
+ (cd "$OUT_DIR" && zip -q -r "../../$ZIP" index.js package.json)
28
+ echo "built $(cd "$(dirname "$ZIP")" && pwd)/$(basename "$ZIP")"
29
+ echo " handler: index.handler size: $(du -h "$ZIP" | cut -f1)"
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * One-off: make the Opportunity's Products table show Product, Quantity, and
4
+ * Amount.
5
+ *
6
+ * Twenty gives a new custom object a default TABLE view whose columns are
7
+ * `name` plus the audit fields (createdAt, createdBy, updatedAt, updatedBy).
8
+ * So the Products section rendered a row per line with no product, no quantity,
9
+ * and no amount — the data was all there, just never surfaced.
10
+ *
11
+ * This sets the opportunityProduct table view to:
12
+ * Product | Quantity | Amount | Line
13
+ * and hides the audit columns. It also orders the record-page field widget the
14
+ * same way.
15
+ *
16
+ * Idempotent: re-running reconciles the view to the same state.
17
+ *
18
+ * Usage:
19
+ * npx tsx scripts/configure-product-views.ts # dry-run
20
+ * npx tsx scripts/configure-product-views.ts --apply
21
+ */
22
+
23
+ import process from "node:process";
24
+
25
+ import { fetchObjectMetadata } from "./lib/schema-ensure";
26
+ import { TwentyClient, normalizeBaseUrl } from "./lib/twenty-client";
27
+
28
+ /**
29
+ * Exactly what Eric asked for: "Each line needs to be Product Name, Quantity and
30
+ * Amount." `name` is the record's label identifier (it holds the product name,
31
+ * or "Line 2" for the 173 lines whose brand did not map), so it leads. Twenty
32
+ * will not let the label identifier be hidden, which is also why it stays.
33
+ */
34
+ const VISIBLE_COLUMNS = ["name", "quantity", "amount"] as const;
35
+
36
+ /** Noise on a line item: audit trail, internals, and the product relation whose
37
+ * value already reads out through `name`. */
38
+ const HIDDEN_COLUMNS = [
39
+ "createdAt",
40
+ "createdBy",
41
+ "updatedAt",
42
+ "updatedBy",
43
+ "sourceId",
44
+ "sourceHash",
45
+ "isMobil",
46
+ "opportunity",
47
+ "lineNumber",
48
+ "product",
49
+ ] as const;
50
+
51
+ interface ViewField {
52
+ id: string;
53
+ fieldMetadataId: string;
54
+ isVisible: boolean;
55
+ position: number;
56
+ }
57
+
58
+ function requireEnv(name: string): string {
59
+ const value = process.env[name];
60
+ if (!value) throw new Error(`Missing required environment variable: ${name}`);
61
+ return value;
62
+ }
63
+
64
+ function log(message: string): void {
65
+ process.stderr.write(`${message}\n`);
66
+ }
67
+
68
+ async function main(): Promise<void> {
69
+ const apply = process.argv.slice(2).includes("--apply");
70
+ const client = new TwentyClient({
71
+ baseUrl: normalizeBaseUrl(requireEnv("TWENTY_PUBLIC_URL")),
72
+ authToken: requireEnv("TWENTY_API_KEY"),
73
+ });
74
+
75
+ const objects = await fetchObjectMetadata(client);
76
+ const line = objects.get("opportunityProduct");
77
+ if (!line) throw new Error('Twenty object "opportunityProduct" not found.');
78
+ const fieldIdByName = new Map(
79
+ [...line.fields.values()].map((field) => [field.name, field.id]),
80
+ );
81
+
82
+ const views = await client.requestWithRetry<{
83
+ getViews: Array<{ id: string; name: string; type: string }>;
84
+ }>(
85
+ "/metadata",
86
+ `query ConfigViews($objectMetadataId: String!) {
87
+ getViews(objectMetadataId: $objectMetadataId) { id name type }
88
+ }`,
89
+ { objectMetadataId: line.id },
90
+ );
91
+
92
+ const report: Record<string, unknown> = {
93
+ mode: apply ? "apply" : "dry-run",
94
+ views: views.getViews.map((view) => `${view.type}:${view.name}`),
95
+ };
96
+ const changes: string[] = [];
97
+
98
+ for (const view of views.getViews) {
99
+ const fields = await client.requestWithRetry<{
100
+ getViewFields: ViewField[];
101
+ }>(
102
+ "/metadata",
103
+ `query ConfigViewFields($viewId: String!) {
104
+ getViewFields(viewId: $viewId) { id fieldMetadataId isVisible position }
105
+ }`,
106
+ { viewId: view.id },
107
+ );
108
+ const existingByFieldId = new Map(
109
+ fields.getViewFields.map((field) => [field.fieldMetadataId, field]),
110
+ );
111
+
112
+ // Wanted columns, in order.
113
+ for (const [index, name] of VISIBLE_COLUMNS.entries()) {
114
+ const fieldMetadataId = fieldIdByName.get(name);
115
+ if (!fieldMetadataId) {
116
+ throw new Error(
117
+ `opportunityProduct.${name} not found; run restructure-products first.`,
118
+ );
119
+ }
120
+ const existing = existingByFieldId.get(fieldMetadataId);
121
+ if (existing?.isVisible && existing.position === index) continue;
122
+
123
+ changes.push(`${view.type}: show ${name} at ${index}`);
124
+ if (!apply) continue;
125
+
126
+ if (existing) {
127
+ await client.requestOnce(
128
+ "/metadata",
129
+ `mutation ConfigUpdateViewField($input: UpdateViewFieldInput!) {
130
+ updateViewField(input: $input) { id }
131
+ }`,
132
+ {
133
+ input: {
134
+ id: existing.id,
135
+ update: { isVisible: true, position: index },
136
+ },
137
+ },
138
+ );
139
+ } else {
140
+ await client.requestOnce(
141
+ "/metadata",
142
+ `mutation ConfigCreateViewField($input: CreateViewFieldInput!) {
143
+ createViewField(input: $input) { id }
144
+ }`,
145
+ {
146
+ input: {
147
+ viewId: view.id,
148
+ fieldMetadataId,
149
+ isVisible: true,
150
+ position: index,
151
+ size: 150,
152
+ },
153
+ },
154
+ );
155
+ }
156
+ }
157
+
158
+ // Everything else on the line is noise in a table of deal lines.
159
+ for (const name of HIDDEN_COLUMNS) {
160
+ const fieldMetadataId = fieldIdByName.get(name);
161
+ if (!fieldMetadataId) continue;
162
+ const existing = existingByFieldId.get(fieldMetadataId);
163
+ if (!existing || !existing.isVisible) continue;
164
+ changes.push(`${view.type}: hide ${name}`);
165
+ if (!apply) continue;
166
+ await client.requestOnce(
167
+ "/metadata",
168
+ `mutation ConfigHideViewField($input: UpdateViewFieldInput!) {
169
+ updateViewField(input: $input) { id }
170
+ }`,
171
+ { input: { id: existing.id, update: { isVisible: false } } },
172
+ );
173
+ }
174
+ }
175
+
176
+ // The opportunity's `products` relation is left as-is (visible chips). An
177
+ // earlier version hid it and/or replaced it with a denormalized text field;
178
+ // both were wrong. The line records (Product / Quantity / Amount) are the
179
+ // correct structured model. A real inline COLUMNAR table on the record page
180
+ // is not something stock Twenty renders for a one-to-many relation — that
181
+ // needs a custom Twenty-app widget, scoped separately.
182
+
183
+ report.changes =
184
+ changes.length > 0 ? changes : "none — views already correct";
185
+ if (apply && changes.length > 0)
186
+ log(`applied ${changes.length} view changes`);
187
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
188
+ }
189
+
190
+ main().catch((error) => {
191
+ process.stderr.write(
192
+ `${error instanceof Error ? error.message : String(error)}\n`,
193
+ );
194
+ process.exit(1);
195
+ });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * AWS Lambda entry point for the nightly LastMile → Twenty sync.
3
+ *
4
+ * Invoked by n8n (Schedule → AWS Lambda) in TEI's account, where the function
5
+ * can reach both the LastMile RDS and crm.tei.thinkwork.ai. Reads the same
6
+ * three env vars as the CLI and runs an APPLY (delta re-sync) by default; pass
7
+ * `{"mode":"dry-run"}` to read + report without writing. Returns the parity
8
+ * report so n8n can log it or branch on failures.
9
+ *
10
+ * The sync is idempotent (upsert by sourceId + deletion mirror), reads LastMile
11
+ * read-only, never provisions members, and needs no AWS credentials. Runtime is
12
+ * ~90s against TEI's current data — set the Lambda timeout to 300s for headroom.
13
+ *
14
+ * Build the deployment zip with scripts/build-lambda.sh; deploy per
15
+ * scripts/LAMBDA_DEPLOY.md. Handler string: `index.handler`.
16
+ */
17
+ import { executeMigration } from "./migrate-lastmile";
18
+
19
+ interface SyncEvent {
20
+ mode?: "apply" | "dry-run";
21
+ }
22
+
23
+ interface EntityFailures {
24
+ failed?: number;
25
+ }
26
+
27
+ function totalFailed(report: Record<string, unknown>): number {
28
+ let failed = 0;
29
+ for (const value of Object.values(report)) {
30
+ if (value && typeof value === "object" && "failed" in value) {
31
+ const n = (value as EntityFailures).failed;
32
+ if (typeof n === "number") failed += n;
33
+ }
34
+ }
35
+ return failed;
36
+ }
37
+
38
+ export async function handler(
39
+ event: SyncEvent | null,
40
+ ): Promise<{ ok: boolean; failed: number; report: Record<string, unknown> }> {
41
+ // Default to apply — the whole point of the scheduled invocation. A hard
42
+ // failure (DB unreachable, sourceId abort) throws and fails the invocation;
43
+ // per-record soft failures surface via `failed` so n8n can alert without the
44
+ // run being considered a crash.
45
+ const apply = event?.mode !== "dry-run";
46
+ const report = await executeMigration({ apply });
47
+ const failed = totalFailed(report);
48
+ return { ok: failed === 0, failed, report };
49
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { isUploadUnsupported } from "../load-attachments";
4
+ import { TwentyGraphqlError } from "../twenty-client";
5
+
6
+ describe("isUploadUnsupported", () => {
7
+ it("recognizes the missing Upload scalar and uploadFile mutation", () => {
8
+ expect(
9
+ isUploadUnsupported(
10
+ new TwentyGraphqlError("x", {
11
+ errors: [{ message: 'Unknown type "Upload". Did you mean "Float"?' }],
12
+ }),
13
+ ),
14
+ ).toBe(true);
15
+ expect(
16
+ isUploadUnsupported(
17
+ new TwentyGraphqlError("x", {
18
+ errors: [
19
+ { message: 'Cannot query field "uploadFile" on type "Mutation".' },
20
+ ],
21
+ }),
22
+ ),
23
+ ).toBe(true);
24
+ });
25
+
26
+ it("does not swallow a genuine upload failure", () => {
27
+ // A real 500 or a missing binary must still count as a failure, so the run
28
+ // exits non-zero and someone looks at it.
29
+ expect(
30
+ isUploadUnsupported(
31
+ new TwentyGraphqlError("x", {
32
+ errors: [{ message: "Internal server error" }],
33
+ }),
34
+ ),
35
+ ).toBe(false);
36
+ expect(isUploadUnsupported(new Error("connect ETIMEDOUT"))).toBe(false);
37
+ });
38
+ });
@@ -3,12 +3,14 @@ import { describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  COMPANY,
5
5
  emptyCounters,
6
+ isMissingSourceIdFieldError,
6
7
  mirrorDeletions,
8
+ upsertNotes,
7
9
  upsertRecords,
8
10
  } from "../load-records";
9
11
  import { contentHash } from "../mappers";
10
- import type { MappedRecord } from "../mappers";
11
- import type { TwentyClient } from "../twenty-client";
12
+ import type { MappedNote, MappedRecord } from "../mappers";
13
+ import { TwentyGraphqlError, type TwentyClient } from "../twenty-client";
12
14
 
13
15
  function mapped(
14
16
  sourceId: string,
@@ -230,3 +232,144 @@ describe("mirrorDeletions (AE2 / KTD7)", () => {
230
232
  ]);
231
233
  });
232
234
  });
235
+
236
+ describe("isMissingSourceIdFieldError", () => {
237
+ const gqlError = (message: string) =>
238
+ new TwentyGraphqlError("wrapped", { errors: [{ message }] });
239
+
240
+ it("recognizes a not-yet-created sourceId field", () => {
241
+ expect(
242
+ isMissingSourceIdFieldError(
243
+ gqlError('Object company doesn\'t have any "sourceId" field.'),
244
+ ),
245
+ ).toBe(true);
246
+ });
247
+
248
+ it("recognizes a not-yet-created custom object", () => {
249
+ expect(
250
+ isMissingSourceIdFieldError(
251
+ gqlError('Unknown type "OrganizationFilterInput".'),
252
+ ),
253
+ ).toBe(true);
254
+ expect(
255
+ isMissingSourceIdFieldError(
256
+ gqlError('Cannot query field "organizations" on type "Query".'),
257
+ ),
258
+ ).toBe(true);
259
+ });
260
+
261
+ it("does not swallow unrelated errors", () => {
262
+ expect(isMissingSourceIdFieldError(gqlError("Rate limit reached"))).toBe(
263
+ false,
264
+ );
265
+ expect(isMissingSourceIdFieldError(new Error("boom"))).toBe(false);
266
+ });
267
+ });
268
+
269
+ describe("upsertNotes batches the NoteTarget existence check (perf)", () => {
270
+ function note(sourceId: string): MappedNote {
271
+ return {
272
+ sourceId,
273
+ title: sourceId,
274
+ bodyMarkdown: sourceId,
275
+ targetSourceId: "opportunity:o1",
276
+ targetKind: "opportunity",
277
+ isDeleted: false,
278
+ createdAt: null,
279
+ authorName: null,
280
+ };
281
+ }
282
+
283
+ // A note whose id appears here already has a NoteTarget in Twenty.
284
+ function noteClient(pairedNoteIds: string[]): {
285
+ client: TwentyClient;
286
+ lookups: number;
287
+ createdTargets: Array<Record<string, unknown>>;
288
+ } {
289
+ const state = {
290
+ lookups: 0,
291
+ createdTargets: [] as Record<string, unknown>[],
292
+ };
293
+ const client = {
294
+ requestWithRetry: vi.fn(
295
+ async (
296
+ _path: string,
297
+ query: string,
298
+ variables: { filter?: { noteId?: { in?: string[] } } },
299
+ ) => {
300
+ if (query.includes("MigrationNoteTargets")) {
301
+ state.lookups += 1;
302
+ const ids = variables.filter?.noteId?.in ?? [];
303
+ return {
304
+ noteTargets: {
305
+ edges: ids
306
+ .filter((id) => pairedNoteIds.includes(id))
307
+ .map((id) => ({ node: { noteId: id } })),
308
+ pageInfo: { hasNextPage: false, endCursor: null },
309
+ },
310
+ };
311
+ }
312
+ // Note sourceId existence lookups → nothing exists yet (force create).
313
+ return { notes: { edges: [] } };
314
+ },
315
+ ),
316
+ requestOnce: vi.fn(
317
+ async (
318
+ _path: string,
319
+ query: string,
320
+ variables: { data: Array<Record<string, unknown>> },
321
+ ) => {
322
+ if (query.includes("createNotes")) {
323
+ return {
324
+ createNotes: variables.data.map((row) => ({
325
+ id: `note-${row.sourceId as string}`,
326
+ sourceId: row.sourceId,
327
+ })),
328
+ };
329
+ }
330
+ if (query.includes("createNoteTargets")) {
331
+ state.createdTargets.push(...variables.data);
332
+ return {
333
+ createNoteTargets: variables.data.map((_, i) => ({
334
+ id: `nt-${i}`,
335
+ })),
336
+ };
337
+ }
338
+ return { ok: true };
339
+ },
340
+ ),
341
+ } as unknown as TwentyClient;
342
+ return { client, ...state };
343
+ }
344
+
345
+ it("issues one lookup for many notes and only creates the missing targets", async () => {
346
+ const notes = [note("task_comment:c1"), note("task_comment:c2")];
347
+ // c1 already has its target; c2 does not.
348
+ const harness = noteClient(["note-task_comment:c1"]);
349
+ const counters = emptyCounters();
350
+
351
+ await upsertNotes({
352
+ client: harness.client,
353
+ notes,
354
+ targetIdBySourceId: new Map([["opportunity:o1", "opp1"]]),
355
+ dryRun: false,
356
+ counters,
357
+ });
358
+
359
+ // Batched: a single noteTargets query covers both notes (was one per note).
360
+ const lookupCalls = (
361
+ harness.client.requestWithRetry as unknown as {
362
+ mock: { calls: unknown[] };
363
+ }
364
+ ).mock.calls.filter((c) =>
365
+ String((c as unknown[])[1]).includes("MigrationNoteTargets"),
366
+ ).length;
367
+ expect(lookupCalls).toBe(1);
368
+
369
+ // Only the unpaired note gets a target, and it carries the opportunity FK.
370
+ expect(harness.createdTargets).toEqual([
371
+ { noteId: "note-task_comment:c2", targetOpportunityId: "opp1" },
372
+ ]);
373
+ expect(counters.failed).toBe(0);
374
+ });
375
+ });