@teleporthq/teleport-plugin-next-workflows 0.44.11 → 0.45.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 (976) hide show
  1. package/__tests__/_helpers/fake-fetch.ts +75 -0
  2. package/__tests__/_helpers/gui-place-order-statements.ts +17 -0
  3. package/__tests__/_helpers/inserted-lines.ts +22 -0
  4. package/__tests__/_helpers/invoice-store.ts +10 -1
  5. package/__tests__/_helpers/load-security-runtime.ts +45 -0
  6. package/__tests__/_helpers/load-server-runtime.ts +26 -3
  7. package/__tests__/_helpers/run-account-delete-route.ts +3 -1
  8. package/__tests__/_helpers/run-generated-middleware.ts +7 -2
  9. package/__tests__/account-delete-owned-rows.test.ts +3 -1
  10. package/__tests__/adopted-element-trigger-code.test.ts +128 -0
  11. package/__tests__/analytics-track-outcome.test.ts +145 -0
  12. package/__tests__/auth-password-change-ends-sessions.test.ts +8 -1
  13. package/__tests__/auth-password-security.test.ts +2275 -0
  14. package/__tests__/auth-session-cost.test.ts +31 -0
  15. package/__tests__/auth-signup-route.test.ts +960 -0
  16. package/__tests__/authored-code-every-path.test.ts +247 -0
  17. package/__tests__/automations-bus.test.ts +3820 -0
  18. package/__tests__/automations-catalog-drift.test.ts +97 -0
  19. package/__tests__/booking-calendar-feed-route.test.ts +20 -3
  20. package/__tests__/cart-price-order.test.ts +336 -1
  21. package/__tests__/code-lookup-rate-limit-route.test.ts +189 -0
  22. package/__tests__/cost-bearing-node-types.test.ts +119 -0
  23. package/__tests__/credential-columns-reply.test.ts +9 -0
  24. package/__tests__/credential-result-reply.test.ts +19 -5
  25. package/__tests__/cron-route-loop-templateparams.test.ts +6 -3
  26. package/__tests__/custom-js-authored-code.test.ts +304 -0
  27. package/__tests__/custom-node-custom-code-gate.test.ts +136 -0
  28. package/__tests__/custom-node-step-handlers.test.ts +251 -0
  29. package/__tests__/custom-node-step-reach.test.ts +63 -0
  30. package/__tests__/data-api-anon-user-id-fallback.test.ts +7 -2
  31. package/__tests__/data-api-browser-reads.test.ts +7 -0
  32. package/__tests__/data-api-business-events.test.ts +403 -0
  33. package/__tests__/data-api-invalid-body.test.ts +7 -0
  34. package/__tests__/data-api-low-stock-autofire.test.ts +30 -4
  35. package/__tests__/data-api-optional-feature-columns.test.ts +44 -0
  36. package/__tests__/data-api-owner-scope-credentials.test.ts +1324 -0
  37. package/__tests__/data-api-protected-tables.test.ts +48 -15
  38. package/__tests__/data-api-retired-table-and-account-fields.test.ts +208 -0
  39. package/__tests__/email-locale-senders.test.ts +5 -3
  40. package/__tests__/expression-injection.test.ts +343 -0
  41. package/__tests__/fixtures/admin-file-workflows.json +242 -0
  42. package/__tests__/form-confirmation-email-template.test.ts +44 -0
  43. package/__tests__/internal-request-headers.test.ts +25 -0
  44. package/__tests__/invoice-pdf-service-project-header.test.ts +120 -0
  45. package/__tests__/invoice-self-call-origin.test.ts +57 -1
  46. package/__tests__/legacy-webhook-self-calls.test.ts +86 -1
  47. package/__tests__/middleware-home-route-protection.test.ts +3 -1
  48. package/__tests__/middleware-subscriber-access.test.ts +1 -1
  49. package/__tests__/newsletter-admin-route.test.ts +649 -0
  50. package/__tests__/newsletter-plugin.test.ts +1248 -0
  51. package/__tests__/outbound-nodes-security.test.ts +1929 -0
  52. package/__tests__/page-access-guard.test.ts +217 -0
  53. package/__tests__/page-access-route-table.test.ts +231 -0
  54. package/__tests__/payment-drivers-module.test.ts +23 -0
  55. package/__tests__/payment-legacy-webhook-routes.test.ts +100 -5
  56. package/__tests__/payment-webhook-route.test.ts +4 -1
  57. package/__tests__/paypal-webhook-signature-verification.test.ts +44 -0
  58. package/__tests__/place-order-guards.test.ts +506 -0
  59. package/__tests__/public-step-guards.test.ts +310 -0
  60. package/__tests__/publish-refusals.test.ts +505 -0
  61. package/__tests__/realtime-identity-routes.test.ts +492 -0
  62. package/__tests__/record-event-writer-server-only.test.ts +975 -0
  63. package/__tests__/resolve-config-template-params.test.ts +3 -3
  64. package/__tests__/runtime-storage-upload-access.test.ts +280 -17
  65. package/__tests__/security-integration-env-names.test.ts +74 -0
  66. package/__tests__/security-lists-parity.test.ts +245 -0
  67. package/__tests__/security-runtime-shadow.test.ts +224 -1
  68. package/__tests__/security-runtime.test.ts +893 -0
  69. package/__tests__/segment-rate-limit-error-branch.test.ts +284 -0
  70. package/__tests__/segment-route-abuse-gate.test.ts +309 -0
  71. package/__tests__/server-job-hops-real-data-api.test.ts +669 -0
  72. package/__tests__/server-session-identity.test.ts +522 -0
  73. package/__tests__/server-step-chain.test.ts +222 -0
  74. package/__tests__/server-step-signing.test.ts +107 -2
  75. package/__tests__/session-cookie-resolver.test.ts +62 -106
  76. package/__tests__/signup-event.test.ts +649 -0
  77. package/__tests__/signup-reply-identical.test.ts +144 -0
  78. package/__tests__/slack-message-escaping.test.ts +123 -0
  79. package/__tests__/visitor-step-limits.test.ts +318 -0
  80. package/__tests__/webhook-delivery-lease.test.ts +4 -1
  81. package/__tests__/webpack-safe-handlers.test.ts +57 -8
  82. package/__tests__/workflow-auth-guard.test.ts +9 -1
  83. package/dist/cjs/account-delete-route-generator.d.ts +1 -0
  84. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  85. package/dist/cjs/account-delete-route-generator.js +18 -3
  86. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  87. package/dist/cjs/api-route-generator.d.ts +52 -3
  88. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  89. package/dist/cjs/api-route-generator.js +600 -145
  90. package/dist/cjs/api-route-generator.js.map +1 -1
  91. package/dist/cjs/auth-generator.d.ts +126 -3
  92. package/dist/cjs/auth-generator.d.ts.map +1 -1
  93. package/dist/cjs/auth-generator.js +1353 -139
  94. package/dist/cjs/auth-generator.js.map +1 -1
  95. package/dist/cjs/automations/activity-kicks.d.ts +12 -0
  96. package/dist/cjs/automations/activity-kicks.d.ts.map +1 -0
  97. package/dist/cjs/automations/activity-kicks.js +123 -0
  98. package/dist/cjs/automations/activity-kicks.js.map +1 -0
  99. package/dist/cjs/automations/app-events-source.d.ts +33 -0
  100. package/dist/cjs/automations/app-events-source.d.ts.map +1 -0
  101. package/dist/cjs/automations/app-events-source.js +109 -0
  102. package/dist/cjs/automations/app-events-source.js.map +1 -0
  103. package/dist/cjs/automations/automations-project-plugin.d.ts +21 -0
  104. package/dist/cjs/automations/automations-project-plugin.d.ts.map +1 -0
  105. package/dist/cjs/automations/automations-project-plugin.js +102 -0
  106. package/dist/cjs/automations/automations-project-plugin.js.map +1 -0
  107. package/dist/cjs/automations/automations-scope.d.ts +104 -0
  108. package/dist/cjs/automations/automations-scope.d.ts.map +1 -0
  109. package/dist/cjs/automations/automations-scope.js +176 -0
  110. package/dist/cjs/automations/automations-scope.js.map +1 -0
  111. package/dist/cjs/automations/dispatcher-source.d.ts +72 -0
  112. package/dist/cjs/automations/dispatcher-source.d.ts.map +1 -0
  113. package/dist/cjs/automations/dispatcher-source.js +1008 -0
  114. package/dist/cjs/automations/dispatcher-source.js.map +1 -0
  115. package/dist/cjs/automations/event-bus-source.d.ts +44 -0
  116. package/dist/cjs/automations/event-bus-source.d.ts.map +1 -0
  117. package/dist/cjs/automations/event-bus-source.js +427 -0
  118. package/dist/cjs/automations/event-bus-source.js.map +1 -0
  119. package/dist/cjs/automations/handler-module-generator.d.ts +25 -0
  120. package/dist/cjs/automations/handler-module-generator.d.ts.map +1 -0
  121. package/dist/cjs/automations/handler-module-generator.js +78 -0
  122. package/dist/cjs/automations/handler-module-generator.js.map +1 -0
  123. package/dist/cjs/automations/hydrators-source.d.ts +33 -0
  124. package/dist/cjs/automations/hydrators-source.d.ts.map +1 -0
  125. package/dist/cjs/automations/hydrators-source.js +571 -0
  126. package/dist/cjs/automations/hydrators-source.js.map +1 -0
  127. package/dist/cjs/booking-calendar-feed-route-generator.d.ts.map +1 -1
  128. package/dist/cjs/booking-calendar-feed-route-generator.js +8 -0
  129. package/dist/cjs/booking-calendar-feed-route-generator.js.map +1 -1
  130. package/dist/cjs/configured-nextauth-url.d.ts +17 -0
  131. package/dist/cjs/configured-nextauth-url.d.ts.map +1 -0
  132. package/dist/cjs/configured-nextauth-url.js +20 -0
  133. package/dist/cjs/configured-nextauth-url.js.map +1 -0
  134. package/dist/cjs/cron-request-verification.d.ts +22 -1
  135. package/dist/cjs/cron-request-verification.d.ts.map +1 -1
  136. package/dist/cjs/cron-request-verification.js +50 -6
  137. package/dist/cjs/cron-request-verification.js.map +1 -1
  138. package/dist/cjs/data-api-route-generator.d.ts +19 -0
  139. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  140. package/dist/cjs/data-api-route-generator.js +711 -43
  141. package/dist/cjs/data-api-route-generator.js.map +1 -1
  142. package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts +3 -1
  143. package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts.map +1 -1
  144. package/dist/cjs/ecommerce/parameterized-stock-chain.js +45 -5
  145. package/dist/cjs/ecommerce/parameterized-stock-chain.js.map +1 -1
  146. package/dist/cjs/ecommerce/place-order-guards.d.ts +126 -0
  147. package/dist/cjs/ecommerce/place-order-guards.d.ts.map +1 -0
  148. package/dist/cjs/ecommerce/place-order-guards.js +670 -0
  149. package/dist/cjs/ecommerce/place-order-guards.js.map +1 -0
  150. package/dist/cjs/ecommerce-customhandler-rewriter.d.ts +2 -0
  151. package/dist/cjs/ecommerce-customhandler-rewriter.d.ts.map +1 -1
  152. package/dist/cjs/ecommerce-customhandler-rewriter.js +38 -2
  153. package/dist/cjs/ecommerce-customhandler-rewriter.js.map +1 -1
  154. package/dist/cjs/executor-generator.d.ts.map +1 -1
  155. package/dist/cjs/executor-generator.js +368 -60
  156. package/dist/cjs/executor-generator.js.map +1 -1
  157. package/dist/cjs/graph-utils.d.ts +9 -0
  158. package/dist/cjs/graph-utils.d.ts.map +1 -1
  159. package/dist/cjs/graph-utils.js +32 -1
  160. package/dist/cjs/graph-utils.js.map +1 -1
  161. package/dist/cjs/index.d.ts +20 -1
  162. package/dist/cjs/index.d.ts.map +1 -1
  163. package/dist/cjs/index.js +72 -2
  164. package/dist/cjs/index.js.map +1 -1
  165. package/dist/cjs/invoice/api-routes-code.d.ts +9 -3
  166. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  167. package/dist/cjs/invoice/api-routes-code.js +9 -7
  168. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  169. package/dist/cjs/invoice/index.d.ts.map +1 -1
  170. package/dist/cjs/invoice/index.js +12 -7
  171. package/dist/cjs/invoice/index.js.map +1 -1
  172. package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
  173. package/dist/cjs/invoice/invoice-assembly-code.js +8 -3
  174. package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
  175. package/dist/cjs/invoice/pdf-generator-code.d.ts.map +1 -1
  176. package/dist/cjs/invoice/pdf-generator-code.js +2 -1
  177. package/dist/cjs/invoice/pdf-generator-code.js.map +1 -1
  178. package/dist/cjs/invoice/pdf-service-client-code.d.ts.map +1 -1
  179. package/dist/cjs/invoice/pdf-service-client-code.js +15 -7
  180. package/dist/cjs/invoice/pdf-service-client-code.js.map +1 -1
  181. package/dist/cjs/newsletter/admin-route-code.d.ts +60 -0
  182. package/dist/cjs/newsletter/admin-route-code.d.ts.map +1 -0
  183. package/dist/cjs/newsletter/admin-route-code.js +300 -0
  184. package/dist/cjs/newsletter/admin-route-code.js.map +1 -0
  185. package/dist/cjs/newsletter/helper-code.d.ts +109 -0
  186. package/dist/cjs/newsletter/helper-code.d.ts.map +1 -0
  187. package/dist/cjs/newsletter/helper-code.js +662 -0
  188. package/dist/cjs/newsletter/helper-code.js.map +1 -0
  189. package/dist/cjs/newsletter/newsletter-scope.d.ts +65 -0
  190. package/dist/cjs/newsletter/newsletter-scope.d.ts.map +1 -0
  191. package/dist/cjs/newsletter/newsletter-scope.js +103 -0
  192. package/dist/cjs/newsletter/newsletter-scope.js.map +1 -0
  193. package/dist/cjs/newsletter/project-plugin.d.ts +18 -0
  194. package/dist/cjs/newsletter/project-plugin.d.ts.map +1 -0
  195. package/dist/cjs/newsletter/project-plugin.js +97 -0
  196. package/dist/cjs/newsletter/project-plugin.js.map +1 -0
  197. package/dist/cjs/newsletter/routes-code.d.ts +6 -0
  198. package/dist/cjs/newsletter/routes-code.d.ts.map +1 -0
  199. package/dist/cjs/newsletter/routes-code.js +181 -0
  200. package/dist/cjs/newsletter/routes-code.js.map +1 -0
  201. package/dist/cjs/nodes/account/account-compare-passwords.d.ts.map +1 -1
  202. package/dist/cjs/nodes/account/account-compare-passwords.js +16 -6
  203. package/dist/cjs/nodes/account/account-compare-passwords.js.map +1 -1
  204. package/dist/cjs/nodes/account/account-delete-current.js +1 -1
  205. package/dist/cjs/nodes/account/account-get-current.js +1 -1
  206. package/dist/cjs/nodes/account/account-hash-password.d.ts.map +1 -1
  207. package/dist/cjs/nodes/account/account-hash-password.js +15 -3
  208. package/dist/cjs/nodes/account/account-hash-password.js.map +1 -1
  209. package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
  210. package/dist/cjs/nodes/account/account-login.js +23 -6
  211. package/dist/cjs/nodes/account/account-login.js.map +1 -1
  212. package/dist/cjs/nodes/account/account-logout.js +1 -1
  213. package/dist/cjs/nodes/account/account-refresh-session.js +1 -1
  214. package/dist/cjs/nodes/account/account-request-password-reset.d.ts +3 -0
  215. package/dist/cjs/nodes/account/account-request-password-reset.d.ts.map +1 -0
  216. package/dist/cjs/nodes/account/account-request-password-reset.js +46 -0
  217. package/dist/cjs/nodes/account/account-request-password-reset.js.map +1 -0
  218. package/dist/cjs/nodes/account/account-reset-password.d.ts +3 -0
  219. package/dist/cjs/nodes/account/account-reset-password.d.ts.map +1 -0
  220. package/dist/cjs/nodes/account/account-reset-password.js +44 -0
  221. package/dist/cjs/nodes/account/account-reset-password.js.map +1 -0
  222. package/dist/cjs/nodes/account/account-signup.d.ts.map +1 -1
  223. package/dist/cjs/nodes/account/account-signup.js +82 -19
  224. package/dist/cjs/nodes/account/account-signup.js.map +1 -1
  225. package/dist/cjs/nodes/account/account-social-login.js +1 -1
  226. package/dist/cjs/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
  227. package/dist/cjs/nodes/ai/ai-custom-prompt.js +20 -5
  228. package/dist/cjs/nodes/ai/ai-custom-prompt.js.map +1 -1
  229. package/dist/cjs/nodes/ai/ai-detect-language.d.ts.map +1 -1
  230. package/dist/cjs/nodes/ai/ai-detect-language.js +20 -5
  231. package/dist/cjs/nodes/ai/ai-detect-language.js.map +1 -1
  232. package/dist/cjs/nodes/ai/ai-generate-text-embedding.d.ts.map +1 -1
  233. package/dist/cjs/nodes/ai/ai-generate-text-embedding.js +10 -2
  234. package/dist/cjs/nodes/ai/ai-generate-text-embedding.js.map +1 -1
  235. package/dist/cjs/nodes/ai/ai-provider-utils-sources.generated.js +2 -2
  236. package/dist/cjs/nodes/ai/ai-provider-utils-sources.generated.js.map +1 -1
  237. package/dist/cjs/nodes/ai/ai-provider-utils.d.ts +31 -2
  238. package/dist/cjs/nodes/ai/ai-provider-utils.d.ts.map +1 -1
  239. package/dist/cjs/nodes/ai/ai-provider-utils.js +125 -11
  240. package/dist/cjs/nodes/ai/ai-provider-utils.js.map +1 -1
  241. package/dist/cjs/nodes/ai/ai-select-database-data.d.ts.map +1 -1
  242. package/dist/cjs/nodes/ai/ai-select-database-data.js +6 -2
  243. package/dist/cjs/nodes/ai/ai-select-database-data.js.map +1 -1
  244. package/dist/cjs/nodes/ai/ai-sentiment-analysis.d.ts.map +1 -1
  245. package/dist/cjs/nodes/ai/ai-sentiment-analysis.js +20 -5
  246. package/dist/cjs/nodes/ai/ai-sentiment-analysis.js.map +1 -1
  247. package/dist/cjs/nodes/ai/ai-summarization.d.ts.map +1 -1
  248. package/dist/cjs/nodes/ai/ai-summarization.js +20 -5
  249. package/dist/cjs/nodes/ai/ai-summarization.js.map +1 -1
  250. package/dist/cjs/nodes/ai/ai-text-classifier.d.ts.map +1 -1
  251. package/dist/cjs/nodes/ai/ai-text-classifier.js +20 -5
  252. package/dist/cjs/nodes/ai/ai-text-classifier.js.map +1 -1
  253. package/dist/cjs/nodes/ai/ai-text-transform.d.ts.map +1 -1
  254. package/dist/cjs/nodes/ai/ai-text-transform.js +20 -5
  255. package/dist/cjs/nodes/ai/ai-text-transform.js.map +1 -1
  256. package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts +3 -0
  257. package/dist/cjs/nodes/analytics/analytics-track-outcome.d.ts.map +1 -0
  258. package/dist/cjs/nodes/analytics/analytics-track-outcome.js +110 -0
  259. package/dist/cjs/nodes/analytics/analytics-track-outcome.js.map +1 -0
  260. package/dist/cjs/nodes/cart/cart-price-order.d.ts.map +1 -1
  261. package/dist/cjs/nodes/cart/cart-price-order.js +161 -2
  262. package/dist/cjs/nodes/cart/cart-price-order.js.map +1 -1
  263. package/dist/cjs/nodes/cost-bearing-node-types.d.ts +4 -0
  264. package/dist/cjs/nodes/cost-bearing-node-types.d.ts.map +1 -0
  265. package/dist/cjs/nodes/cost-bearing-node-types.js +43 -0
  266. package/dist/cjs/nodes/cost-bearing-node-types.js.map +1 -0
  267. package/dist/cjs/nodes/data/data-select.d.ts.map +1 -1
  268. package/dist/cjs/nodes/data/data-select.js +12 -2
  269. package/dist/cjs/nodes/data/data-select.js.map +1 -1
  270. package/dist/cjs/nodes/email/email-mailersend.d.ts.map +1 -1
  271. package/dist/cjs/nodes/email/email-mailersend.js +15 -11
  272. package/dist/cjs/nodes/email/email-mailersend.js.map +1 -1
  273. package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
  274. package/dist/cjs/nodes/email/email-mailgun.js +15 -15
  275. package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
  276. package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
  277. package/dist/cjs/nodes/email/email-postmark.js +15 -15
  278. package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
  279. package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
  280. package/dist/cjs/nodes/email/email-resend.js +15 -15
  281. package/dist/cjs/nodes/email/email-resend.js.map +1 -1
  282. package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
  283. package/dist/cjs/nodes/email/email-sendgrid.js +15 -15
  284. package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
  285. package/dist/cjs/nodes/email/html-escape-code.d.ts +3 -0
  286. package/dist/cjs/nodes/email/html-escape-code.d.ts.map +1 -0
  287. package/dist/cjs/nodes/email/html-escape-code.js +23 -0
  288. package/dist/cjs/nodes/email/html-escape-code.js.map +1 -0
  289. package/dist/cjs/nodes/email/message-trust.d.ts +40 -0
  290. package/dist/cjs/nodes/email/message-trust.d.ts.map +1 -0
  291. package/dist/cjs/nodes/email/message-trust.js +167 -0
  292. package/dist/cjs/nodes/email/message-trust.js.map +1 -0
  293. package/dist/cjs/nodes/email/outbound-message-guard.d.ts +113 -0
  294. package/dist/cjs/nodes/email/outbound-message-guard.d.ts.map +1 -0
  295. package/dist/cjs/nodes/email/outbound-message-guard.js +460 -0
  296. package/dist/cjs/nodes/email/outbound-message-guard.js.map +1 -0
  297. package/dist/cjs/nodes/email/trusted-html-code.d.ts +22 -0
  298. package/dist/cjs/nodes/email/trusted-html-code.d.ts.map +1 -0
  299. package/dist/cjs/nodes/email/trusted-html-code.js +52 -0
  300. package/dist/cjs/nodes/email/trusted-html-code.js.map +1 -0
  301. package/dist/cjs/nodes/general/general-custom-js.d.ts.map +1 -1
  302. package/dist/cjs/nodes/general/general-custom-js.js +44 -7
  303. package/dist/cjs/nodes/general/general-custom-js.js.map +1 -1
  304. package/dist/cjs/nodes/general/general-http-request.d.ts.map +1 -1
  305. package/dist/cjs/nodes/general/general-http-request.js +431 -45
  306. package/dist/cjs/nodes/general/general-http-request.js.map +1 -1
  307. package/dist/cjs/nodes/general/general-rate-limiter.d.ts.map +1 -1
  308. package/dist/cjs/nodes/general/general-rate-limiter.js +46 -78
  309. package/dist/cjs/nodes/general/general-rate-limiter.js.map +1 -1
  310. package/dist/cjs/nodes/general/general-wait.d.ts +3 -0
  311. package/dist/cjs/nodes/general/general-wait.d.ts.map +1 -0
  312. package/dist/cjs/nodes/general/general-wait.js +81 -0
  313. package/dist/cjs/nodes/general/general-wait.js.map +1 -0
  314. package/dist/cjs/nodes/general/visitor-request.d.ts +44 -0
  315. package/dist/cjs/nodes/general/visitor-request.d.ts.map +1 -0
  316. package/dist/cjs/nodes/general/visitor-request.js +72 -0
  317. package/dist/cjs/nodes/general/visitor-request.js.map +1 -0
  318. package/dist/cjs/nodes/index.d.ts.map +1 -1
  319. package/dist/cjs/nodes/index.js +7 -1
  320. package/dist/cjs/nodes/index.js.map +1 -1
  321. package/dist/cjs/nodes/integrations/integration-slack.d.ts.map +1 -1
  322. package/dist/cjs/nodes/integrations/integration-slack.js +17 -6
  323. package/dist/cjs/nodes/integrations/integration-slack.js.map +1 -1
  324. package/dist/cjs/nodes/newsletter/newsletter-confirm.d.ts +3 -0
  325. package/dist/cjs/nodes/newsletter/newsletter-confirm.d.ts.map +1 -0
  326. package/dist/cjs/nodes/newsletter/newsletter-confirm.js +41 -0
  327. package/dist/cjs/nodes/newsletter/newsletter-confirm.js.map +1 -0
  328. package/dist/cjs/nodes/newsletter/newsletter-subscribe.d.ts +7 -0
  329. package/dist/cjs/nodes/newsletter/newsletter-subscribe.d.ts.map +1 -0
  330. package/dist/cjs/nodes/newsletter/newsletter-subscribe.js +69 -0
  331. package/dist/cjs/nodes/newsletter/newsletter-subscribe.js.map +1 -0
  332. package/dist/cjs/nodes/newsletter/newsletter-unsubscribe.d.ts +3 -0
  333. package/dist/cjs/nodes/newsletter/newsletter-unsubscribe.d.ts.map +1 -0
  334. package/dist/cjs/nodes/newsletter/newsletter-unsubscribe.js +42 -0
  335. package/dist/cjs/nodes/newsletter/newsletter-unsubscribe.js.map +1 -0
  336. package/dist/cjs/nodes/realtime/realtime-join-channel.d.ts.map +1 -1
  337. package/dist/cjs/nodes/realtime/realtime-join-channel.js +5 -4
  338. package/dist/cjs/nodes/realtime/realtime-join-channel.js.map +1 -1
  339. package/dist/cjs/nodes/realtime/realtime-send-channel-event.d.ts.map +1 -1
  340. package/dist/cjs/nodes/realtime/realtime-send-channel-event.js +11 -2
  341. package/dist/cjs/nodes/realtime/realtime-send-channel-event.js.map +1 -1
  342. package/dist/cjs/nodes/realtime/realtime-send-channel-message.d.ts.map +1 -1
  343. package/dist/cjs/nodes/realtime/realtime-send-channel-message.js +11 -2
  344. package/dist/cjs/nodes/realtime/realtime-send-channel-message.js.map +1 -1
  345. package/dist/cjs/nodes/sms/sms-infobip.d.ts.map +1 -1
  346. package/dist/cjs/nodes/sms/sms-infobip.js +11 -8
  347. package/dist/cjs/nodes/sms/sms-infobip.js.map +1 -1
  348. package/dist/cjs/nodes/sms/sms-smsapi.d.ts.map +1 -1
  349. package/dist/cjs/nodes/sms/sms-smsapi.js +11 -8
  350. package/dist/cjs/nodes/sms/sms-smsapi.js.map +1 -1
  351. package/dist/cjs/nodes/sms/sms-textmagic.d.ts.map +1 -1
  352. package/dist/cjs/nodes/sms/sms-textmagic.js +11 -8
  353. package/dist/cjs/nodes/sms/sms-textmagic.js.map +1 -1
  354. package/dist/cjs/nodes/sms/sms-twilio.d.ts.map +1 -1
  355. package/dist/cjs/nodes/sms/sms-twilio.js +12 -10
  356. package/dist/cjs/nodes/sms/sms-twilio.js.map +1 -1
  357. package/dist/cjs/nodes/types.d.ts +8 -1
  358. package/dist/cjs/nodes/types.d.ts.map +1 -1
  359. package/dist/cjs/nodes/utility/utility-similarity-scoring.d.ts.map +1 -1
  360. package/dist/cjs/nodes/utility/utility-similarity-scoring.js +29 -10
  361. package/dist/cjs/nodes/utility/utility-similarity-scoring.js.map +1 -1
  362. package/dist/cjs/page-access-guard-generator.d.ts +23 -0
  363. package/dist/cjs/page-access-guard-generator.d.ts.map +1 -0
  364. package/dist/cjs/page-access-guard-generator.js +135 -0
  365. package/dist/cjs/page-access-guard-generator.js.map +1 -0
  366. package/dist/cjs/password-reset-route-generator.d.ts +119 -0
  367. package/dist/cjs/password-reset-route-generator.d.ts.map +1 -0
  368. package/dist/cjs/password-reset-route-generator.js +460 -0
  369. package/dist/cjs/password-reset-route-generator.js.map +1 -0
  370. package/dist/cjs/payments/payment-core-code.d.ts.map +1 -1
  371. package/dist/cjs/payments/payment-core-code.js +12 -7
  372. package/dist/cjs/payments/payment-core-code.js.map +1 -1
  373. package/dist/cjs/project-code-scanner.d.ts +11 -0
  374. package/dist/cjs/project-code-scanner.d.ts.map +1 -0
  375. package/dist/cjs/project-code-scanner.js +80 -0
  376. package/dist/cjs/project-code-scanner.js.map +1 -0
  377. package/dist/cjs/public-step-guards.d.ts +122 -0
  378. package/dist/cjs/public-step-guards.d.ts.map +1 -0
  379. package/dist/cjs/public-step-guards.js +288 -0
  380. package/dist/cjs/public-step-guards.js.map +1 -0
  381. package/dist/cjs/publish-refusals.d.ts +66 -0
  382. package/dist/cjs/publish-refusals.d.ts.map +1 -0
  383. package/dist/cjs/publish-refusals.js +278 -0
  384. package/dist/cjs/publish-refusals.js.map +1 -0
  385. package/dist/cjs/realtime-generator.d.ts +63 -1
  386. package/dist/cjs/realtime-generator.d.ts.map +1 -1
  387. package/dist/cjs/realtime-generator.js +404 -230
  388. package/dist/cjs/realtime-generator.js.map +1 -1
  389. package/dist/cjs/runtime-storage-generator.d.ts +32 -1
  390. package/dist/cjs/runtime-storage-generator.d.ts.map +1 -1
  391. package/dist/cjs/runtime-storage-generator.js +265 -12
  392. package/dist/cjs/runtime-storage-generator.js.map +1 -1
  393. package/dist/cjs/security-runtime/index.d.ts +14 -0
  394. package/dist/cjs/security-runtime/index.d.ts.map +1 -0
  395. package/dist/cjs/security-runtime/index.js +48 -0
  396. package/dist/cjs/security-runtime/index.js.map +1 -0
  397. package/dist/cjs/security-runtime/security-runtime-code.d.ts +53 -0
  398. package/dist/cjs/security-runtime/security-runtime-code.d.ts.map +1 -0
  399. package/dist/cjs/security-runtime/security-runtime-code.js +580 -0
  400. package/dist/cjs/security-runtime/security-runtime-code.js.map +1 -0
  401. package/dist/cjs/security-scanner.d.ts +72 -3
  402. package/dist/cjs/security-scanner.d.ts.map +1 -1
  403. package/dist/cjs/security-scanner.js +220 -8
  404. package/dist/cjs/security-scanner.js.map +1 -1
  405. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  406. package/dist/cjs/segment-splitter.js +10 -2
  407. package/dist/cjs/segment-splitter.js.map +1 -1
  408. package/dist/cjs/segment-trust.d.ts +36 -0
  409. package/dist/cjs/segment-trust.d.ts.map +1 -1
  410. package/dist/cjs/segment-trust.js +147 -3
  411. package/dist/cjs/segment-trust.js.map +1 -1
  412. package/dist/cjs/self-call-headers.d.ts +31 -0
  413. package/dist/cjs/self-call-headers.d.ts.map +1 -0
  414. package/dist/cjs/self-call-headers.js +48 -0
  415. package/dist/cjs/self-call-headers.js.map +1 -0
  416. package/dist/cjs/sent-email-log/index.d.ts +7 -0
  417. package/dist/cjs/sent-email-log/index.d.ts.map +1 -1
  418. package/dist/cjs/sent-email-log/index.js +17 -1
  419. package/dist/cjs/sent-email-log/index.js.map +1 -1
  420. package/dist/cjs/sent-email-log/sent-email-log-code.d.ts +7 -0
  421. package/dist/cjs/sent-email-log/sent-email-log-code.d.ts.map +1 -1
  422. package/dist/cjs/sent-email-log/sent-email-log-code.js +17 -3
  423. package/dist/cjs/sent-email-log/sent-email-log-code.js.map +1 -1
  424. package/dist/cjs/server-custom-node-call.d.ts +79 -0
  425. package/dist/cjs/server-custom-node-call.d.ts.map +1 -0
  426. package/dist/cjs/server-custom-node-call.js +285 -0
  427. package/dist/cjs/server-custom-node-call.js.map +1 -0
  428. package/dist/cjs/server-runtime-code.d.ts +39 -12
  429. package/dist/cjs/server-runtime-code.d.ts.map +1 -1
  430. package/dist/cjs/server-runtime-code.js +484 -41
  431. package/dist/cjs/server-runtime-code.js.map +1 -1
  432. package/dist/cjs/session-cookie-resolver.d.ts +3 -1
  433. package/dist/cjs/session-cookie-resolver.d.ts.map +1 -1
  434. package/dist/cjs/session-revocation-generator.d.ts +39 -0
  435. package/dist/cjs/session-revocation-generator.d.ts.map +1 -0
  436. package/dist/cjs/session-revocation-generator.js +177 -0
  437. package/dist/cjs/session-revocation-generator.js.map +1 -0
  438. package/dist/cjs/slack-message-values.d.ts +6 -0
  439. package/dist/cjs/slack-message-values.d.ts.map +1 -0
  440. package/dist/cjs/slack-message-values.js +73 -0
  441. package/dist/cjs/slack-message-values.js.map +1 -0
  442. package/dist/cjs/transactional-email-code.d.ts +11 -0
  443. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  444. package/dist/cjs/transactional-email-code.js +29 -5
  445. package/dist/cjs/transactional-email-code.js.map +1 -1
  446. package/dist/cjs/trigger-generator.d.ts +2 -0
  447. package/dist/cjs/trigger-generator.d.ts.map +1 -1
  448. package/dist/cjs/trigger-generator.js +17 -4
  449. package/dist/cjs/trigger-generator.js.map +1 -1
  450. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  451. package/dist/cjs/visitor-step-limits.d.ts +66 -0
  452. package/dist/cjs/visitor-step-limits.d.ts.map +1 -0
  453. package/dist/cjs/visitor-step-limits.js +187 -0
  454. package/dist/cjs/visitor-step-limits.js.map +1 -0
  455. package/dist/cjs/webhook-generator.d.ts.map +1 -1
  456. package/dist/cjs/webhook-generator.js +93 -15
  457. package/dist/cjs/webhook-generator.js.map +1 -1
  458. package/dist/cjs/webhook-signature-verification.d.ts.map +1 -1
  459. package/dist/cjs/webhook-signature-verification.js +13 -3
  460. package/dist/cjs/webhook-signature-verification.js.map +1 -1
  461. package/dist/cjs/workflow-auth-generator.d.ts +4 -1
  462. package/dist/cjs/workflow-auth-generator.d.ts.map +1 -1
  463. package/dist/cjs/workflow-auth-generator.js +8 -3
  464. package/dist/cjs/workflow-auth-generator.js.map +1 -1
  465. package/dist/cjs/workflow-component-plugin.js +11 -3
  466. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  467. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  468. package/dist/cjs/workflow-project-plugin.js +302 -56
  469. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  470. package/dist/cjs/workflow-scope.d.ts.map +1 -1
  471. package/dist/cjs/workflow-scope.js +5 -0
  472. package/dist/cjs/workflow-scope.js.map +1 -1
  473. package/dist/esm/account-delete-route-generator.d.ts +1 -0
  474. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  475. package/dist/esm/account-delete-route-generator.js +18 -3
  476. package/dist/esm/account-delete-route-generator.js.map +1 -1
  477. package/dist/esm/api-route-generator.d.ts +52 -3
  478. package/dist/esm/api-route-generator.d.ts.map +1 -1
  479. package/dist/esm/api-route-generator.js +595 -142
  480. package/dist/esm/api-route-generator.js.map +1 -1
  481. package/dist/esm/auth-generator.d.ts +126 -3
  482. package/dist/esm/auth-generator.d.ts.map +1 -1
  483. package/dist/esm/auth-generator.js +1350 -140
  484. package/dist/esm/auth-generator.js.map +1 -1
  485. package/dist/esm/automations/activity-kicks.d.ts +12 -0
  486. package/dist/esm/automations/activity-kicks.d.ts.map +1 -0
  487. package/dist/esm/automations/activity-kicks.js +118 -0
  488. package/dist/esm/automations/activity-kicks.js.map +1 -0
  489. package/dist/esm/automations/app-events-source.d.ts +33 -0
  490. package/dist/esm/automations/app-events-source.d.ts.map +1 -0
  491. package/dist/esm/automations/app-events-source.js +104 -0
  492. package/dist/esm/automations/app-events-source.js.map +1 -0
  493. package/dist/esm/automations/automations-project-plugin.d.ts +21 -0
  494. package/dist/esm/automations/automations-project-plugin.d.ts.map +1 -0
  495. package/dist/esm/automations/automations-project-plugin.js +98 -0
  496. package/dist/esm/automations/automations-project-plugin.js.map +1 -0
  497. package/dist/esm/automations/automations-scope.d.ts +104 -0
  498. package/dist/esm/automations/automations-scope.d.ts.map +1 -0
  499. package/dist/esm/automations/automations-scope.js +165 -0
  500. package/dist/esm/automations/automations-scope.js.map +1 -0
  501. package/dist/esm/automations/dispatcher-source.d.ts +72 -0
  502. package/dist/esm/automations/dispatcher-source.d.ts.map +1 -0
  503. package/dist/esm/automations/dispatcher-source.js +999 -0
  504. package/dist/esm/automations/dispatcher-source.js.map +1 -0
  505. package/dist/esm/automations/event-bus-source.d.ts +44 -0
  506. package/dist/esm/automations/event-bus-source.d.ts.map +1 -0
  507. package/dist/esm/automations/event-bus-source.js +421 -0
  508. package/dist/esm/automations/event-bus-source.js.map +1 -0
  509. package/dist/esm/automations/handler-module-generator.d.ts +25 -0
  510. package/dist/esm/automations/handler-module-generator.d.ts.map +1 -0
  511. package/dist/esm/automations/handler-module-generator.js +71 -0
  512. package/dist/esm/automations/handler-module-generator.js.map +1 -0
  513. package/dist/esm/automations/hydrators-source.d.ts +33 -0
  514. package/dist/esm/automations/hydrators-source.d.ts.map +1 -0
  515. package/dist/esm/automations/hydrators-source.js +567 -0
  516. package/dist/esm/automations/hydrators-source.js.map +1 -0
  517. package/dist/esm/booking-calendar-feed-route-generator.d.ts.map +1 -1
  518. package/dist/esm/booking-calendar-feed-route-generator.js +8 -0
  519. package/dist/esm/booking-calendar-feed-route-generator.js.map +1 -1
  520. package/dist/esm/configured-nextauth-url.d.ts +17 -0
  521. package/dist/esm/configured-nextauth-url.d.ts.map +1 -0
  522. package/dist/esm/configured-nextauth-url.js +17 -0
  523. package/dist/esm/configured-nextauth-url.js.map +1 -0
  524. package/dist/esm/cron-request-verification.d.ts +22 -1
  525. package/dist/esm/cron-request-verification.d.ts.map +1 -1
  526. package/dist/esm/cron-request-verification.js +48 -5
  527. package/dist/esm/cron-request-verification.js.map +1 -1
  528. package/dist/esm/data-api-route-generator.d.ts +19 -0
  529. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  530. package/dist/esm/data-api-route-generator.js +710 -43
  531. package/dist/esm/data-api-route-generator.js.map +1 -1
  532. package/dist/esm/ecommerce/parameterized-stock-chain.d.ts +3 -1
  533. package/dist/esm/ecommerce/parameterized-stock-chain.d.ts.map +1 -1
  534. package/dist/esm/ecommerce/parameterized-stock-chain.js +43 -4
  535. package/dist/esm/ecommerce/parameterized-stock-chain.js.map +1 -1
  536. package/dist/esm/ecommerce/place-order-guards.d.ts +126 -0
  537. package/dist/esm/ecommerce/place-order-guards.d.ts.map +1 -0
  538. package/dist/esm/ecommerce/place-order-guards.js +655 -0
  539. package/dist/esm/ecommerce/place-order-guards.js.map +1 -0
  540. package/dist/esm/ecommerce-customhandler-rewriter.d.ts +2 -0
  541. package/dist/esm/ecommerce-customhandler-rewriter.d.ts.map +1 -1
  542. package/dist/esm/ecommerce-customhandler-rewriter.js +38 -2
  543. package/dist/esm/ecommerce-customhandler-rewriter.js.map +1 -1
  544. package/dist/esm/executor-generator.d.ts.map +1 -1
  545. package/dist/esm/executor-generator.js +368 -60
  546. package/dist/esm/executor-generator.js.map +1 -1
  547. package/dist/esm/graph-utils.d.ts +9 -0
  548. package/dist/esm/graph-utils.d.ts.map +1 -1
  549. package/dist/esm/graph-utils.js +30 -0
  550. package/dist/esm/graph-utils.js.map +1 -1
  551. package/dist/esm/index.d.ts +20 -1
  552. package/dist/esm/index.d.ts.map +1 -1
  553. package/dist/esm/index.js +27 -1
  554. package/dist/esm/index.js.map +1 -1
  555. package/dist/esm/invoice/api-routes-code.d.ts +9 -3
  556. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  557. package/dist/esm/invoice/api-routes-code.js +9 -7
  558. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  559. package/dist/esm/invoice/index.d.ts.map +1 -1
  560. package/dist/esm/invoice/index.js +12 -7
  561. package/dist/esm/invoice/index.js.map +1 -1
  562. package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
  563. package/dist/esm/invoice/invoice-assembly-code.js +8 -3
  564. package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
  565. package/dist/esm/invoice/pdf-generator-code.d.ts.map +1 -1
  566. package/dist/esm/invoice/pdf-generator-code.js +2 -1
  567. package/dist/esm/invoice/pdf-generator-code.js.map +1 -1
  568. package/dist/esm/invoice/pdf-service-client-code.d.ts.map +1 -1
  569. package/dist/esm/invoice/pdf-service-client-code.js +15 -7
  570. package/dist/esm/invoice/pdf-service-client-code.js.map +1 -1
  571. package/dist/esm/newsletter/admin-route-code.d.ts +60 -0
  572. package/dist/esm/newsletter/admin-route-code.d.ts.map +1 -0
  573. package/dist/esm/newsletter/admin-route-code.js +295 -0
  574. package/dist/esm/newsletter/admin-route-code.js.map +1 -0
  575. package/dist/esm/newsletter/helper-code.d.ts +109 -0
  576. package/dist/esm/newsletter/helper-code.d.ts.map +1 -0
  577. package/dist/esm/newsletter/helper-code.js +656 -0
  578. package/dist/esm/newsletter/helper-code.js.map +1 -0
  579. package/dist/esm/newsletter/newsletter-scope.d.ts +65 -0
  580. package/dist/esm/newsletter/newsletter-scope.d.ts.map +1 -0
  581. package/dist/esm/newsletter/newsletter-scope.js +97 -0
  582. package/dist/esm/newsletter/newsletter-scope.js.map +1 -0
  583. package/dist/esm/newsletter/project-plugin.d.ts +18 -0
  584. package/dist/esm/newsletter/project-plugin.d.ts.map +1 -0
  585. package/dist/esm/newsletter/project-plugin.js +93 -0
  586. package/dist/esm/newsletter/project-plugin.js.map +1 -0
  587. package/dist/esm/newsletter/routes-code.d.ts +6 -0
  588. package/dist/esm/newsletter/routes-code.d.ts.map +1 -0
  589. package/dist/esm/newsletter/routes-code.js +175 -0
  590. package/dist/esm/newsletter/routes-code.js.map +1 -0
  591. package/dist/esm/nodes/account/account-compare-passwords.d.ts.map +1 -1
  592. package/dist/esm/nodes/account/account-compare-passwords.js +16 -6
  593. package/dist/esm/nodes/account/account-compare-passwords.js.map +1 -1
  594. package/dist/esm/nodes/account/account-delete-current.js +1 -1
  595. package/dist/esm/nodes/account/account-get-current.js +1 -1
  596. package/dist/esm/nodes/account/account-hash-password.d.ts.map +1 -1
  597. package/dist/esm/nodes/account/account-hash-password.js +15 -3
  598. package/dist/esm/nodes/account/account-hash-password.js.map +1 -1
  599. package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
  600. package/dist/esm/nodes/account/account-login.js +23 -6
  601. package/dist/esm/nodes/account/account-login.js.map +1 -1
  602. package/dist/esm/nodes/account/account-logout.js +1 -1
  603. package/dist/esm/nodes/account/account-refresh-session.js +1 -1
  604. package/dist/esm/nodes/account/account-request-password-reset.d.ts +3 -0
  605. package/dist/esm/nodes/account/account-request-password-reset.d.ts.map +1 -0
  606. package/dist/esm/nodes/account/account-request-password-reset.js +43 -0
  607. package/dist/esm/nodes/account/account-request-password-reset.js.map +1 -0
  608. package/dist/esm/nodes/account/account-reset-password.d.ts +3 -0
  609. package/dist/esm/nodes/account/account-reset-password.d.ts.map +1 -0
  610. package/dist/esm/nodes/account/account-reset-password.js +41 -0
  611. package/dist/esm/nodes/account/account-reset-password.js.map +1 -0
  612. package/dist/esm/nodes/account/account-signup.d.ts.map +1 -1
  613. package/dist/esm/nodes/account/account-signup.js +82 -19
  614. package/dist/esm/nodes/account/account-signup.js.map +1 -1
  615. package/dist/esm/nodes/account/account-social-login.js +1 -1
  616. package/dist/esm/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
  617. package/dist/esm/nodes/ai/ai-custom-prompt.js +20 -5
  618. package/dist/esm/nodes/ai/ai-custom-prompt.js.map +1 -1
  619. package/dist/esm/nodes/ai/ai-detect-language.d.ts.map +1 -1
  620. package/dist/esm/nodes/ai/ai-detect-language.js +20 -5
  621. package/dist/esm/nodes/ai/ai-detect-language.js.map +1 -1
  622. package/dist/esm/nodes/ai/ai-generate-text-embedding.d.ts.map +1 -1
  623. package/dist/esm/nodes/ai/ai-generate-text-embedding.js +10 -2
  624. package/dist/esm/nodes/ai/ai-generate-text-embedding.js.map +1 -1
  625. package/dist/esm/nodes/ai/ai-provider-utils-sources.generated.js +2 -2
  626. package/dist/esm/nodes/ai/ai-provider-utils-sources.generated.js.map +1 -1
  627. package/dist/esm/nodes/ai/ai-provider-utils.d.ts +31 -2
  628. package/dist/esm/nodes/ai/ai-provider-utils.d.ts.map +1 -1
  629. package/dist/esm/nodes/ai/ai-provider-utils.js +124 -10
  630. package/dist/esm/nodes/ai/ai-provider-utils.js.map +1 -1
  631. package/dist/esm/nodes/ai/ai-select-database-data.d.ts.map +1 -1
  632. package/dist/esm/nodes/ai/ai-select-database-data.js +6 -2
  633. package/dist/esm/nodes/ai/ai-select-database-data.js.map +1 -1
  634. package/dist/esm/nodes/ai/ai-sentiment-analysis.d.ts.map +1 -1
  635. package/dist/esm/nodes/ai/ai-sentiment-analysis.js +20 -5
  636. package/dist/esm/nodes/ai/ai-sentiment-analysis.js.map +1 -1
  637. package/dist/esm/nodes/ai/ai-summarization.d.ts.map +1 -1
  638. package/dist/esm/nodes/ai/ai-summarization.js +20 -5
  639. package/dist/esm/nodes/ai/ai-summarization.js.map +1 -1
  640. package/dist/esm/nodes/ai/ai-text-classifier.d.ts.map +1 -1
  641. package/dist/esm/nodes/ai/ai-text-classifier.js +20 -5
  642. package/dist/esm/nodes/ai/ai-text-classifier.js.map +1 -1
  643. package/dist/esm/nodes/ai/ai-text-transform.d.ts.map +1 -1
  644. package/dist/esm/nodes/ai/ai-text-transform.js +20 -5
  645. package/dist/esm/nodes/ai/ai-text-transform.js.map +1 -1
  646. package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts +3 -0
  647. package/dist/esm/nodes/analytics/analytics-track-outcome.d.ts.map +1 -0
  648. package/dist/esm/nodes/analytics/analytics-track-outcome.js +107 -0
  649. package/dist/esm/nodes/analytics/analytics-track-outcome.js.map +1 -0
  650. package/dist/esm/nodes/cart/cart-price-order.d.ts.map +1 -1
  651. package/dist/esm/nodes/cart/cart-price-order.js +161 -2
  652. package/dist/esm/nodes/cart/cart-price-order.js.map +1 -1
  653. package/dist/esm/nodes/cost-bearing-node-types.d.ts +4 -0
  654. package/dist/esm/nodes/cost-bearing-node-types.d.ts.map +1 -0
  655. package/dist/esm/nodes/cost-bearing-node-types.js +39 -0
  656. package/dist/esm/nodes/cost-bearing-node-types.js.map +1 -0
  657. package/dist/esm/nodes/data/data-select.d.ts.map +1 -1
  658. package/dist/esm/nodes/data/data-select.js +12 -2
  659. package/dist/esm/nodes/data/data-select.js.map +1 -1
  660. package/dist/esm/nodes/email/email-mailersend.d.ts.map +1 -1
  661. package/dist/esm/nodes/email/email-mailersend.js +15 -11
  662. package/dist/esm/nodes/email/email-mailersend.js.map +1 -1
  663. package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
  664. package/dist/esm/nodes/email/email-mailgun.js +15 -15
  665. package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
  666. package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
  667. package/dist/esm/nodes/email/email-postmark.js +15 -15
  668. package/dist/esm/nodes/email/email-postmark.js.map +1 -1
  669. package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
  670. package/dist/esm/nodes/email/email-resend.js +15 -15
  671. package/dist/esm/nodes/email/email-resend.js.map +1 -1
  672. package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
  673. package/dist/esm/nodes/email/email-sendgrid.js +15 -15
  674. package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
  675. package/dist/esm/nodes/email/html-escape-code.d.ts +3 -0
  676. package/dist/esm/nodes/email/html-escape-code.d.ts.map +1 -0
  677. package/dist/esm/nodes/email/html-escape-code.js +18 -0
  678. package/dist/esm/nodes/email/html-escape-code.js.map +1 -0
  679. package/dist/esm/nodes/email/message-trust.d.ts +40 -0
  680. package/dist/esm/nodes/email/message-trust.d.ts.map +1 -0
  681. package/dist/esm/nodes/email/message-trust.js +161 -0
  682. package/dist/esm/nodes/email/message-trust.js.map +1 -0
  683. package/dist/esm/nodes/email/outbound-message-guard.d.ts +113 -0
  684. package/dist/esm/nodes/email/outbound-message-guard.d.ts.map +1 -0
  685. package/dist/esm/nodes/email/outbound-message-guard.js +452 -0
  686. package/dist/esm/nodes/email/outbound-message-guard.js.map +1 -0
  687. package/dist/esm/nodes/email/trusted-html-code.d.ts +22 -0
  688. package/dist/esm/nodes/email/trusted-html-code.d.ts.map +1 -0
  689. package/dist/esm/nodes/email/trusted-html-code.js +49 -0
  690. package/dist/esm/nodes/email/trusted-html-code.js.map +1 -0
  691. package/dist/esm/nodes/general/general-custom-js.d.ts.map +1 -1
  692. package/dist/esm/nodes/general/general-custom-js.js +44 -7
  693. package/dist/esm/nodes/general/general-custom-js.js.map +1 -1
  694. package/dist/esm/nodes/general/general-http-request.d.ts.map +1 -1
  695. package/dist/esm/nodes/general/general-http-request.js +431 -45
  696. package/dist/esm/nodes/general/general-http-request.js.map +1 -1
  697. package/dist/esm/nodes/general/general-rate-limiter.d.ts.map +1 -1
  698. package/dist/esm/nodes/general/general-rate-limiter.js +46 -78
  699. package/dist/esm/nodes/general/general-rate-limiter.js.map +1 -1
  700. package/dist/esm/nodes/general/general-wait.d.ts +3 -0
  701. package/dist/esm/nodes/general/general-wait.d.ts.map +1 -0
  702. package/dist/esm/nodes/general/general-wait.js +78 -0
  703. package/dist/esm/nodes/general/general-wait.js.map +1 -0
  704. package/dist/esm/nodes/general/visitor-request.d.ts +44 -0
  705. package/dist/esm/nodes/general/visitor-request.d.ts.map +1 -0
  706. package/dist/esm/nodes/general/visitor-request.js +65 -0
  707. package/dist/esm/nodes/general/visitor-request.js.map +1 -0
  708. package/dist/esm/nodes/index.d.ts.map +1 -1
  709. package/dist/esm/nodes/index.js +7 -1
  710. package/dist/esm/nodes/index.js.map +1 -1
  711. package/dist/esm/nodes/integrations/integration-slack.d.ts.map +1 -1
  712. package/dist/esm/nodes/integrations/integration-slack.js +17 -6
  713. package/dist/esm/nodes/integrations/integration-slack.js.map +1 -1
  714. package/dist/esm/nodes/newsletter/newsletter-confirm.d.ts +3 -0
  715. package/dist/esm/nodes/newsletter/newsletter-confirm.d.ts.map +1 -0
  716. package/dist/esm/nodes/newsletter/newsletter-confirm.js +38 -0
  717. package/dist/esm/nodes/newsletter/newsletter-confirm.js.map +1 -0
  718. package/dist/esm/nodes/newsletter/newsletter-subscribe.d.ts +7 -0
  719. package/dist/esm/nodes/newsletter/newsletter-subscribe.d.ts.map +1 -0
  720. package/dist/esm/nodes/newsletter/newsletter-subscribe.js +66 -0
  721. package/dist/esm/nodes/newsletter/newsletter-subscribe.js.map +1 -0
  722. package/dist/esm/nodes/newsletter/newsletter-unsubscribe.d.ts +3 -0
  723. package/dist/esm/nodes/newsletter/newsletter-unsubscribe.d.ts.map +1 -0
  724. package/dist/esm/nodes/newsletter/newsletter-unsubscribe.js +39 -0
  725. package/dist/esm/nodes/newsletter/newsletter-unsubscribe.js.map +1 -0
  726. package/dist/esm/nodes/realtime/realtime-join-channel.d.ts.map +1 -1
  727. package/dist/esm/nodes/realtime/realtime-join-channel.js +5 -4
  728. package/dist/esm/nodes/realtime/realtime-join-channel.js.map +1 -1
  729. package/dist/esm/nodes/realtime/realtime-send-channel-event.d.ts.map +1 -1
  730. package/dist/esm/nodes/realtime/realtime-send-channel-event.js +11 -2
  731. package/dist/esm/nodes/realtime/realtime-send-channel-event.js.map +1 -1
  732. package/dist/esm/nodes/realtime/realtime-send-channel-message.d.ts.map +1 -1
  733. package/dist/esm/nodes/realtime/realtime-send-channel-message.js +11 -2
  734. package/dist/esm/nodes/realtime/realtime-send-channel-message.js.map +1 -1
  735. package/dist/esm/nodes/sms/sms-infobip.d.ts.map +1 -1
  736. package/dist/esm/nodes/sms/sms-infobip.js +11 -8
  737. package/dist/esm/nodes/sms/sms-infobip.js.map +1 -1
  738. package/dist/esm/nodes/sms/sms-smsapi.d.ts.map +1 -1
  739. package/dist/esm/nodes/sms/sms-smsapi.js +11 -8
  740. package/dist/esm/nodes/sms/sms-smsapi.js.map +1 -1
  741. package/dist/esm/nodes/sms/sms-textmagic.d.ts.map +1 -1
  742. package/dist/esm/nodes/sms/sms-textmagic.js +11 -8
  743. package/dist/esm/nodes/sms/sms-textmagic.js.map +1 -1
  744. package/dist/esm/nodes/sms/sms-twilio.d.ts.map +1 -1
  745. package/dist/esm/nodes/sms/sms-twilio.js +12 -10
  746. package/dist/esm/nodes/sms/sms-twilio.js.map +1 -1
  747. package/dist/esm/nodes/types.d.ts +8 -1
  748. package/dist/esm/nodes/types.d.ts.map +1 -1
  749. package/dist/esm/nodes/utility/utility-similarity-scoring.d.ts.map +1 -1
  750. package/dist/esm/nodes/utility/utility-similarity-scoring.js +29 -10
  751. package/dist/esm/nodes/utility/utility-similarity-scoring.js.map +1 -1
  752. package/dist/esm/page-access-guard-generator.d.ts +23 -0
  753. package/dist/esm/page-access-guard-generator.d.ts.map +1 -0
  754. package/dist/esm/page-access-guard-generator.js +131 -0
  755. package/dist/esm/page-access-guard-generator.js.map +1 -0
  756. package/dist/esm/password-reset-route-generator.d.ts +119 -0
  757. package/dist/esm/password-reset-route-generator.d.ts.map +1 -0
  758. package/dist/esm/password-reset-route-generator.js +448 -0
  759. package/dist/esm/password-reset-route-generator.js.map +1 -0
  760. package/dist/esm/payments/payment-core-code.d.ts.map +1 -1
  761. package/dist/esm/payments/payment-core-code.js +12 -7
  762. package/dist/esm/payments/payment-core-code.js.map +1 -1
  763. package/dist/esm/project-code-scanner.d.ts +11 -0
  764. package/dist/esm/project-code-scanner.d.ts.map +1 -0
  765. package/dist/esm/project-code-scanner.js +75 -0
  766. package/dist/esm/project-code-scanner.js.map +1 -0
  767. package/dist/esm/public-step-guards.d.ts +122 -0
  768. package/dist/esm/public-step-guards.d.ts.map +1 -0
  769. package/dist/esm/public-step-guards.js +280 -0
  770. package/dist/esm/public-step-guards.js.map +1 -0
  771. package/dist/esm/publish-refusals.d.ts +66 -0
  772. package/dist/esm/publish-refusals.d.ts.map +1 -0
  773. package/dist/esm/publish-refusals.js +271 -0
  774. package/dist/esm/publish-refusals.js.map +1 -0
  775. package/dist/esm/realtime-generator.d.ts +63 -1
  776. package/dist/esm/realtime-generator.d.ts.map +1 -1
  777. package/dist/esm/realtime-generator.js +401 -229
  778. package/dist/esm/realtime-generator.js.map +1 -1
  779. package/dist/esm/runtime-storage-generator.d.ts +32 -1
  780. package/dist/esm/runtime-storage-generator.d.ts.map +1 -1
  781. package/dist/esm/runtime-storage-generator.js +264 -11
  782. package/dist/esm/runtime-storage-generator.js.map +1 -1
  783. package/dist/esm/security-runtime/index.d.ts +14 -0
  784. package/dist/esm/security-runtime/index.d.ts.map +1 -0
  785. package/dist/esm/security-runtime/index.js +37 -0
  786. package/dist/esm/security-runtime/index.js.map +1 -0
  787. package/dist/esm/security-runtime/security-runtime-code.d.ts +53 -0
  788. package/dist/esm/security-runtime/security-runtime-code.d.ts.map +1 -0
  789. package/dist/esm/security-runtime/security-runtime-code.js +575 -0
  790. package/dist/esm/security-runtime/security-runtime-code.js.map +1 -0
  791. package/dist/esm/security-scanner.d.ts +72 -3
  792. package/dist/esm/security-scanner.d.ts.map +1 -1
  793. package/dist/esm/security-scanner.js +217 -8
  794. package/dist/esm/security-scanner.js.map +1 -1
  795. package/dist/esm/segment-splitter.d.ts.map +1 -1
  796. package/dist/esm/segment-splitter.js +10 -2
  797. package/dist/esm/segment-splitter.js.map +1 -1
  798. package/dist/esm/segment-trust.d.ts +36 -0
  799. package/dist/esm/segment-trust.d.ts.map +1 -1
  800. package/dist/esm/segment-trust.js +146 -2
  801. package/dist/esm/segment-trust.js.map +1 -1
  802. package/dist/esm/self-call-headers.d.ts +31 -0
  803. package/dist/esm/self-call-headers.d.ts.map +1 -0
  804. package/dist/esm/self-call-headers.js +43 -0
  805. package/dist/esm/self-call-headers.js.map +1 -0
  806. package/dist/esm/sent-email-log/index.d.ts +7 -0
  807. package/dist/esm/sent-email-log/index.d.ts.map +1 -1
  808. package/dist/esm/sent-email-log/index.js +15 -0
  809. package/dist/esm/sent-email-log/index.js.map +1 -1
  810. package/dist/esm/sent-email-log/sent-email-log-code.d.ts +7 -0
  811. package/dist/esm/sent-email-log/sent-email-log-code.d.ts.map +1 -1
  812. package/dist/esm/sent-email-log/sent-email-log-code.js +17 -3
  813. package/dist/esm/sent-email-log/sent-email-log-code.js.map +1 -1
  814. package/dist/esm/server-custom-node-call.d.ts +79 -0
  815. package/dist/esm/server-custom-node-call.d.ts.map +1 -0
  816. package/dist/esm/server-custom-node-call.js +278 -0
  817. package/dist/esm/server-custom-node-call.js.map +1 -0
  818. package/dist/esm/server-runtime-code.d.ts +39 -12
  819. package/dist/esm/server-runtime-code.d.ts.map +1 -1
  820. package/dist/esm/server-runtime-code.js +483 -40
  821. package/dist/esm/server-runtime-code.js.map +1 -1
  822. package/dist/esm/session-cookie-resolver.d.ts +3 -1
  823. package/dist/esm/session-cookie-resolver.d.ts.map +1 -1
  824. package/dist/esm/session-revocation-generator.d.ts +39 -0
  825. package/dist/esm/session-revocation-generator.d.ts.map +1 -0
  826. package/dist/esm/session-revocation-generator.js +172 -0
  827. package/dist/esm/session-revocation-generator.js.map +1 -0
  828. package/dist/esm/slack-message-values.d.ts +6 -0
  829. package/dist/esm/slack-message-values.d.ts.map +1 -0
  830. package/dist/esm/slack-message-values.js +68 -0
  831. package/dist/esm/slack-message-values.js.map +1 -0
  832. package/dist/esm/transactional-email-code.d.ts +11 -0
  833. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  834. package/dist/esm/transactional-email-code.js +28 -4
  835. package/dist/esm/transactional-email-code.js.map +1 -1
  836. package/dist/esm/trigger-generator.d.ts +2 -0
  837. package/dist/esm/trigger-generator.d.ts.map +1 -1
  838. package/dist/esm/trigger-generator.js +15 -3
  839. package/dist/esm/trigger-generator.js.map +1 -1
  840. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  841. package/dist/esm/visitor-step-limits.d.ts +66 -0
  842. package/dist/esm/visitor-step-limits.d.ts.map +1 -0
  843. package/dist/esm/visitor-step-limits.js +181 -0
  844. package/dist/esm/visitor-step-limits.js.map +1 -0
  845. package/dist/esm/webhook-generator.d.ts.map +1 -1
  846. package/dist/esm/webhook-generator.js +93 -15
  847. package/dist/esm/webhook-generator.js.map +1 -1
  848. package/dist/esm/webhook-signature-verification.d.ts.map +1 -1
  849. package/dist/esm/webhook-signature-verification.js +13 -3
  850. package/dist/esm/webhook-signature-verification.js.map +1 -1
  851. package/dist/esm/workflow-auth-generator.d.ts +4 -1
  852. package/dist/esm/workflow-auth-generator.d.ts.map +1 -1
  853. package/dist/esm/workflow-auth-generator.js +8 -3
  854. package/dist/esm/workflow-auth-generator.js.map +1 -1
  855. package/dist/esm/workflow-component-plugin.js +12 -4
  856. package/dist/esm/workflow-component-plugin.js.map +1 -1
  857. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  858. package/dist/esm/workflow-project-plugin.js +306 -60
  859. package/dist/esm/workflow-project-plugin.js.map +1 -1
  860. package/dist/esm/workflow-scope.d.ts.map +1 -1
  861. package/dist/esm/workflow-scope.js +5 -0
  862. package/dist/esm/workflow-scope.js.map +1 -1
  863. package/package.json +4 -4
  864. package/src/account-delete-route-generator.ts +28 -4
  865. package/src/api-route-generator.ts +712 -134
  866. package/src/auth-generator.ts +1471 -142
  867. package/src/automations/activity-kicks.ts +139 -0
  868. package/src/automations/app-events-source.ts +117 -0
  869. package/src/automations/automations-project-plugin.ts +202 -0
  870. package/src/automations/automations-scope.ts +196 -0
  871. package/src/automations/dispatcher-source.ts +1072 -0
  872. package/src/automations/event-bus-source.ts +428 -0
  873. package/src/automations/handler-module-generator.ts +107 -0
  874. package/src/automations/hydrators-source.ts +647 -0
  875. package/src/booking-calendar-feed-route-generator.ts +8 -0
  876. package/src/configured-nextauth-url.ts +17 -0
  877. package/src/cron-request-verification.ts +55 -5
  878. package/src/data-api-route-generator.ts +780 -43
  879. package/src/ecommerce/parameterized-stock-chain.ts +52 -5
  880. package/src/ecommerce/place-order-guards.ts +785 -0
  881. package/src/ecommerce-customhandler-rewriter.ts +55 -2
  882. package/src/executor-generator.ts +372 -60
  883. package/src/graph-utils.ts +33 -0
  884. package/src/index.ts +70 -0
  885. package/src/invoice/api-routes-code.ts +20 -7
  886. package/src/invoice/index.ts +11 -6
  887. package/src/invoice/invoice-assembly-code.ts +8 -3
  888. package/src/invoice/pdf-generator-code.ts +2 -1
  889. package/src/invoice/pdf-service-client-code.ts +15 -7
  890. package/src/newsletter/admin-route-code.ts +319 -0
  891. package/src/newsletter/helper-code.ts +748 -0
  892. package/src/newsletter/newsletter-scope.ts +116 -0
  893. package/src/newsletter/project-plugin.ts +119 -0
  894. package/src/newsletter/routes-code.ts +186 -0
  895. package/src/nodes/account/account-compare-passwords.ts +17 -6
  896. package/src/nodes/account/account-delete-current.ts +1 -1
  897. package/src/nodes/account/account-get-current.ts +1 -1
  898. package/src/nodes/account/account-hash-password.ts +19 -3
  899. package/src/nodes/account/account-login.ts +24 -7
  900. package/src/nodes/account/account-logout.ts +1 -1
  901. package/src/nodes/account/account-refresh-session.ts +1 -1
  902. package/src/nodes/account/account-request-password-reset.ts +47 -0
  903. package/src/nodes/account/account-reset-password.ts +45 -0
  904. package/src/nodes/account/account-signup.ts +86 -20
  905. package/src/nodes/account/account-social-login.ts +1 -1
  906. package/src/nodes/ai/ai-custom-prompt.ts +25 -6
  907. package/src/nodes/ai/ai-detect-language.ts +25 -6
  908. package/src/nodes/ai/ai-generate-text-embedding.ts +16 -3
  909. package/src/nodes/ai/ai-provider-utils-sources.generated.ts +2 -2
  910. package/src/nodes/ai/ai-provider-utils.ts +135 -10
  911. package/src/nodes/ai/ai-select-database-data.ts +8 -3
  912. package/src/nodes/ai/ai-sentiment-analysis.ts +25 -6
  913. package/src/nodes/ai/ai-summarization.ts +25 -6
  914. package/src/nodes/ai/ai-text-classifier.ts +25 -6
  915. package/src/nodes/ai/ai-text-transform.ts +25 -6
  916. package/src/nodes/analytics/analytics-track-outcome.ts +111 -0
  917. package/src/nodes/cart/cart-price-order.ts +200 -2
  918. package/src/nodes/cost-bearing-node-types.ts +45 -0
  919. package/src/nodes/data/data-select.ts +11 -2
  920. package/src/nodes/email/email-mailersend.ts +16 -8
  921. package/src/nodes/email/email-mailgun.ts +17 -12
  922. package/src/nodes/email/email-postmark.ts +17 -12
  923. package/src/nodes/email/email-resend.ts +17 -12
  924. package/src/nodes/email/email-sendgrid.ts +17 -12
  925. package/src/nodes/email/html-escape-code.ts +21 -0
  926. package/src/nodes/email/message-trust.ts +198 -0
  927. package/src/nodes/email/outbound-message-guard.ts +572 -0
  928. package/src/nodes/email/trusted-html-code.ts +49 -0
  929. package/src/nodes/general/general-custom-js.ts +46 -7
  930. package/src/nodes/general/general-http-request.ts +449 -46
  931. package/src/nodes/general/general-rate-limiter.ts +54 -81
  932. package/src/nodes/general/general-wait.ts +80 -0
  933. package/src/nodes/general/visitor-request.ts +79 -0
  934. package/src/nodes/index.ts +12 -0
  935. package/src/nodes/integrations/integration-slack.ts +21 -6
  936. package/src/nodes/newsletter/newsletter-confirm.ts +44 -0
  937. package/src/nodes/newsletter/newsletter-subscribe.ts +78 -0
  938. package/src/nodes/newsletter/newsletter-unsubscribe.ts +43 -0
  939. package/src/nodes/realtime/realtime-join-channel.ts +5 -4
  940. package/src/nodes/realtime/realtime-send-channel-event.ts +13 -2
  941. package/src/nodes/realtime/realtime-send-channel-message.ts +13 -2
  942. package/src/nodes/sms/sms-infobip.ts +14 -4
  943. package/src/nodes/sms/sms-smsapi.ts +14 -4
  944. package/src/nodes/sms/sms-textmagic.ts +14 -4
  945. package/src/nodes/sms/sms-twilio.ts +14 -4
  946. package/src/nodes/types.ts +9 -1
  947. package/src/nodes/utility/utility-similarity-scoring.ts +33 -12
  948. package/src/page-access-guard-generator.ts +154 -0
  949. package/src/password-reset-route-generator.ts +531 -0
  950. package/src/payments/payment-core-code.ts +12 -7
  951. package/src/project-code-scanner.ts +98 -0
  952. package/src/public-step-guards.ts +448 -0
  953. package/src/publish-refusals.ts +459 -0
  954. package/src/realtime-generator.ts +457 -234
  955. package/src/runtime-storage-generator.ts +270 -11
  956. package/src/security-runtime/index.ts +53 -0
  957. package/src/security-runtime/security-runtime-code.ts +579 -0
  958. package/src/security-scanner.ts +253 -10
  959. package/src/segment-splitter.ts +10 -2
  960. package/src/segment-trust.ts +205 -2
  961. package/src/self-call-headers.ts +46 -0
  962. package/src/sent-email-log/index.ts +16 -0
  963. package/src/sent-email-log/sent-email-log-code.ts +30 -4
  964. package/src/server-custom-node-call.ts +331 -0
  965. package/src/server-runtime-code.ts +488 -40
  966. package/src/session-revocation-generator.ts +177 -0
  967. package/src/slack-message-values.ts +84 -0
  968. package/src/transactional-email-code.ts +30 -4
  969. package/src/trigger-generator.ts +19 -3
  970. package/src/visitor-step-limits.ts +228 -0
  971. package/src/webhook-generator.ts +94 -14
  972. package/src/webhook-signature-verification.ts +13 -3
  973. package/src/workflow-auth-generator.ts +9 -4
  974. package/src/workflow-component-plugin.ts +14 -4
  975. package/src/workflow-project-plugin.ts +374 -61
  976. package/src/workflow-scope.ts +6 -0
@@ -0,0 +1,3820 @@
1
+ import { createHash, createHmac } from 'crypto'
2
+ import * as nodePath from 'path'
3
+ import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
4
+ import { PROTECTED_TABLES } from '@teleporthq/teleport-shared/src/utils/table-access'
5
+ import { generateDataSourceFetcherWithCore } from '../../teleport-plugin-next-data-source/src/data-source-fetchers'
6
+ import { FETCH_BACKED_NETWORK, FetchCall, withEnv, withFetch } from './_helpers/fake-fetch'
7
+ import { insertedLines } from './_helpers/inserted-lines'
8
+
9
+ /**
10
+ * The in-app event bus (spec §5.2): a project with an automation (a workflow
11
+ * started by `event-business-event`) gets the bus, the queue worker, one
12
+ * handler module per automation and the dispatch route; a project without one
13
+ * gets none of it, and nothing else in its output changes. The generated
14
+ * modules are EXECUTED against an in-memory Postgres that answers the
15
+ * statements they send, so the claim, the delivery ledger and the Wait are
16
+ * proven by what lands in the tables.
17
+ */
18
+
19
+ type Row = Record<string, unknown>
20
+
21
+ const DATA_API_ROUTE = 'pages/api/data/[...params].js'
22
+
23
+ interface GeneratedFile {
24
+ name: string
25
+ fileType?: string
26
+ content: unknown
27
+ }
28
+
29
+ interface GeneratedEntry {
30
+ path: string[]
31
+ files: GeneratedFile[]
32
+ }
33
+
34
+ interface Structure {
35
+ uidl: Row & { globals: { env: Record<string, string> } }
36
+ files: Map<string, GeneratedEntry>
37
+ dependencies: Record<string, string>
38
+ }
39
+
40
+ const NOW = Date.now()
41
+ const MINUTE_MS = 60 * 1000
42
+ const DAY_MS = 24 * 60 * MINUTE_MS
43
+ const ECHO = 'https://echo.test'
44
+ const trigger = (eventType: string) => ({
45
+ type: 'event-business-event',
46
+ nodeId: 't',
47
+ scope: 'global',
48
+ config: { eventType },
49
+ })
50
+ const ref = (nodeId: string, path: string[]) => ({ type: 'workflowContext', nodeId, path })
51
+ const post = (id: string, path: string, stepNumber: number, body: Row = {}) => ({
52
+ id,
53
+ type: 'general-http-request',
54
+ config: { url: ECHO + path, method: 'POST', body },
55
+ stepNumber,
56
+ label: 'Post ' + path,
57
+ })
58
+
59
+ const AUTOMATIONS: Record<string, Row> = {
60
+ 'wf-paid-ok': {
61
+ id: 'wf-paid-ok',
62
+ name: 'Thank the customer',
63
+ trigger: trigger('order-paid'),
64
+ nodes: [
65
+ post('thanks', '/thanks', 1, {
66
+ email: ref('t', ['order', 'customer', 'email']),
67
+ number: ref('t', ['order', 'number']),
68
+ test: ref('t', ['event', 'test']),
69
+ // Recorded by the writer, read by no hydrator: kept.
70
+ giftMessage: ref('t', ['order', 'giftMessage']),
71
+ // Not a block of order-paid: a test that sends it never passes it on.
72
+ extra: ref('t', ['extra']),
73
+ }),
74
+ ],
75
+ edges: [{ id: 'e1', source: 't', target: 'thanks' }],
76
+ },
77
+ 'wf-paid-fail': {
78
+ id: 'wf-paid-fail',
79
+ name: 'Tell the warehouse',
80
+ trigger: trigger('order-paid'),
81
+ nodes: [
82
+ post('warehouse', '/fails', 1, {
83
+ actor: ref('t', ['actor', 'email']),
84
+ email: ref('t', ['order', 'customer', 'email']),
85
+ }),
86
+ ],
87
+ edges: [{ id: 'e1', source: 't', target: 'warehouse' }],
88
+ },
89
+ 'wf-paid-off': {
90
+ id: 'wf-paid-off',
91
+ name: 'Switched off',
92
+ trigger: trigger('order-paid'),
93
+ nodes: [post('off', '/off', 1)],
94
+ edges: [{ id: 'e1', source: 't', target: 'off' }],
95
+ },
96
+ 'wf-signup-wait': {
97
+ id: 'wf-signup-wait',
98
+ name: 'Welcome two days later',
99
+ trigger: trigger('user-signed-up'),
100
+ nodes: [
101
+ post('before', '/before', 1, { email: ref('t', ['user', 'email']) }),
102
+ {
103
+ id: 'wait',
104
+ type: 'general-wait',
105
+ config: { amount: 2, unit: 'days' },
106
+ stepNumber: 2,
107
+ label: 'Wait 2 days',
108
+ },
109
+ post('after', '/after', 3, {
110
+ email: ref('t', ['user', 'email']),
111
+ waitedUntil: ref('wait', ['until']),
112
+ }),
113
+ ],
114
+ edges: [
115
+ { id: 'e1', source: 't', target: 'before' },
116
+ { id: 'e2', source: 'before', target: 'wait' },
117
+ { id: 'e3', source: 'wait', target: 'after' },
118
+ ],
119
+ },
120
+ 'wf-booking-slow': {
121
+ id: 'wf-booking-slow',
122
+ name: 'Slow booking notice',
123
+ trigger: trigger('booking-created'),
124
+ nodes: [post('hold', '/hold', 1)],
125
+ edges: [{ id: 'e1', source: 't', target: 'hold' }],
126
+ },
127
+ }
128
+
129
+ // The rest of the project: a page workflow with a server data read and a link
130
+ // to the order details page, a global browser workflow (so
131
+ // `global-workflows.js` exists) and a webhook.
132
+ const BASE_WORKFLOWS: Record<string, Row> = {
133
+ 'wf-page': {
134
+ id: 'wf-page',
135
+ name: 'Load orders',
136
+ trigger: { type: 'event-page-loaded', nodeId: 't', scope: 'page', config: { allPages: true } },
137
+ nodes: [
138
+ {
139
+ id: 'select',
140
+ type: 'data-select',
141
+ config: { dataSourceId: 'ds-1', tableName: 'teleport_orders', filters: [] },
142
+ stepNumber: 1,
143
+ label: 'Orders',
144
+ },
145
+ { id: 'set', type: 'state-update-local-state', config: {}, stepNumber: 2, label: 'Set' },
146
+ {
147
+ id: 'open',
148
+ type: 'navigation-go-to-page',
149
+ config: {
150
+ pageId: '/orders/[order_number]',
151
+ targetPage: {
152
+ isDetailsPage: true,
153
+ rowOwnerTable: 'teleport_orders',
154
+ staticUrl: '/orders',
155
+ },
156
+ },
157
+ stepNumber: 3,
158
+ label: 'Open the order',
159
+ },
160
+ ],
161
+ edges: [
162
+ { id: 'e1', source: 't', target: 'select' },
163
+ { id: 'e2', source: 'select', target: 'set' },
164
+ { id: 'e3', source: 'set', target: 'open' },
165
+ ],
166
+ },
167
+ 'wf-logout': {
168
+ id: 'wf-logout',
169
+ name: 'Forget the cart',
170
+ trigger: { type: 'event-user-logged-out', nodeId: 't', scope: 'global', config: {} },
171
+ nodes: [
172
+ {
173
+ id: 'forget',
174
+ type: 'storage-local-remove',
175
+ config: { key: 'cart' },
176
+ stepNumber: 1,
177
+ label: 'Forget',
178
+ },
179
+ ],
180
+ edges: [{ id: 'e1', source: 't', target: 'forget' }],
181
+ },
182
+ 'wf-hook': {
183
+ id: 'wf-hook',
184
+ name: 'Orders hook',
185
+ trigger: { type: 'event-webhook-received', nodeId: 't', scope: 'global', config: {} },
186
+ nodes: [post('relay', '/relay', 1)],
187
+ edges: [{ id: 'e1', source: 't', target: 'relay' }],
188
+ webhookConfig: { httpMethod: 'POST', urlPath: 'webhooks/orders' },
189
+ },
190
+ }
191
+
192
+ // The data source the editor keeps the automation tables in (the GUI mapper's
193
+ // `automations`): the bus opens that database.
194
+ const TELEPORT_AUTOMATIONS = { dataSourceId: 'ds-1', dataSourceType: 'teleport' }
195
+
196
+ // The data-source modules the page plugins write before this plugin runs: a
197
+ // page's server read of the TeleportHQ database, an API route reading it, an
198
+ // API route that only re-exports a module, and another database's module.
199
+ const TELEPORT_SOURCE = { id: 'ds-1', type: 'teleport', name: 'Store', config: {} }
200
+ const OWN_POSTGRES = {
201
+ id: 'ds-2',
202
+ type: 'postgresql',
203
+ name: 'Own',
204
+ config: { connectionString: 'postgresql://u:p@db.test:5432/d' },
205
+ }
206
+ const TELEPORT_PRODUCTS_MODULE = 'utils/data-sources/teleport-products-ds-1.js'
207
+ const TELEPORT_ORDERS_ROUTE = 'pages/api/teleport-orders-ds-1.js'
208
+ const REEXPORT_ROUTE = 'pages/api/teleport-products-ds-1.js'
209
+ const OWN_ITEMS_MODULE = 'utils/data-sources/postgresql-items-ds-2.js'
210
+ const FETCHERS = (): Map<string, GeneratedEntry> => {
211
+ const entry = (path: string, content: string): [string, GeneratedEntry] => {
212
+ const parts = path.replace(/\.js$/, '').split('/')
213
+ const name = parts.pop() as string
214
+ return ['resource-' + path, { path: parts, files: [{ name, fileType: 'js', content }] }]
215
+ }
216
+ return new Map([
217
+ entry(
218
+ TELEPORT_PRODUCTS_MODULE,
219
+ generateDataSourceFetcherWithCore(TELEPORT_SOURCE as never, 'teleport_products', false)
220
+ ),
221
+ entry(
222
+ TELEPORT_ORDERS_ROUTE,
223
+ generateDataSourceFetcherWithCore(TELEPORT_SOURCE as never, 'teleport_orders', true)
224
+ ),
225
+ entry(
226
+ REEXPORT_ROUTE,
227
+ "import dataSourceModule from '../../utils/data-sources/teleport-products-ds-1'\n\nexport default dataSourceModule.handler\n"
228
+ ),
229
+ entry(
230
+ OWN_ITEMS_MODULE,
231
+ generateDataSourceFetcherWithCore(OWN_POSTGRES as never, 'items', false)
232
+ ),
233
+ ])
234
+ }
235
+ const TELEPORT_READS = [TELEPORT_PRODUCTS_MODULE, TELEPORT_ORDERS_ROUTE]
236
+
237
+ const generate = async (
238
+ workflows: Record<string, Row>,
239
+ automations: Row | null = TELEPORT_AUTOMATIONS,
240
+ // What the other generators wrote before this plugin runs, and more of the UIDL.
241
+ extra: { files?: () => Map<string, GeneratedEntry>; uidl?: Row } = {}
242
+ ): Promise<Structure> => {
243
+ const structure = {
244
+ uidl: {
245
+ name: 'Store',
246
+ globals: { settings: { title: 'Store', language: 'en' }, env: { NEXTAUTH_SECRET: 'k' } },
247
+ root: {
248
+ name: 'Root',
249
+ stateDefinitions: {
250
+ route: { type: 'string', defaultValue: 'Home', values: [{ value: 'Home' }] },
251
+ },
252
+ node: { type: 'element', content: { elementType: 'container', children: [] } },
253
+ },
254
+ components: {},
255
+ dataSources: { 'ds-1': { id: 'ds-1', type: 'teleport', name: 'Store', config: {} } },
256
+ ...(automations && { automations }),
257
+ workflows: { workflows: JSON.parse(JSON.stringify(workflows)) },
258
+ ...extra.uidl,
259
+ },
260
+ strategy: { pages: { options: {} } },
261
+ files: extra.files ? extra.files() : new Map<string, GeneratedEntry>(),
262
+ dependencies: {} as Record<string, string>,
263
+ devDependencies: {},
264
+ }
265
+ await new NextWorkflowProjectPlugin().runAfter(structure as never)
266
+ return structure as unknown as Structure
267
+ }
268
+
269
+ const filePath = (entry: GeneratedEntry, file: GeneratedFile): string =>
270
+ [...entry.path, `${file.name}.${file.fileType || 'js'}`].join('/')
271
+
272
+ const sources = (structure: Structure): Map<string, string> => {
273
+ const files = new Map<string, string>()
274
+ for (const entry of structure.files.values()) {
275
+ for (const file of entry.files) {
276
+ files.set(filePath(entry, file), String(file.content))
277
+ }
278
+ }
279
+ return files
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // An in-memory Postgres for the statements the bus sends
284
+ // ---------------------------------------------------------------------------
285
+
286
+ interface EventRow {
287
+ id: string
288
+ event_type: string
289
+ dedupe_key: string
290
+ order_id: string | null
291
+ entity_type: string | null
292
+ entity_id: string | null
293
+ payload: string | null
294
+ actor: string | null
295
+ actor_type: string
296
+ status: string
297
+ attempts: number
298
+ claimed_at?: Date | null
299
+ created_at: Date
300
+ /** Held by another dispatcher's open claim: SKIP LOCKED passes it over. */
301
+ locked?: boolean
302
+ }
303
+
304
+ interface AutomationRow {
305
+ workflow_id: string
306
+ event_type: string
307
+ enabled: boolean
308
+ enabled_at: Date | null
309
+ created_at: Date
310
+ }
311
+
312
+ interface DeliveryRow {
313
+ id: number
314
+ event_id: string
315
+ workflow_id: string
316
+ status: string
317
+ error: string | null
318
+ attempts: number
319
+ started_at: Date | null
320
+ finished_at: Date | null
321
+ }
322
+
323
+ interface WaitRow {
324
+ id: number
325
+ event_id: string
326
+ workflow_id: string
327
+ resume_node_id: string
328
+ context: string | null
329
+ run_at: Date
330
+ status: string
331
+ created_at: Date
332
+ }
333
+
334
+ const STALE_MS = 15 * MINUTE_MS
335
+
336
+ class FakeDatabase {
337
+ events: EventRow[] = []
338
+ automations: AutomationRow[] = []
339
+ deliveries: DeliveryRow[] = []
340
+ waits: WaitRow[] = []
341
+ orders: Row[] = []
342
+ orderItems: Row[] = []
343
+ users: Row[] = []
344
+ bookings: Row[] = []
345
+ sentEmails: Row[] = []
346
+ mailSent: Row[] = []
347
+ reviews: Row[] = []
348
+ waitlistEntries: Row[] = []
349
+ waitlists: Row[] = []
350
+ statements: string[] = []
351
+ /** What every database client was opened with. */
352
+ clientOptions: Row[] = []
353
+ /** Answers a read of teleport_orders with this error instead. */
354
+ ordersError: (Error & { code?: string }) | null = null
355
+ products: Row[] = []
356
+ /** Runs right after a claim is answered (an owner switching automations off meanwhile). */
357
+ afterClaim: (() => void) | null = null
358
+ /** The signed requests the dispatch route took: key -> expires_at (unix seconds). */
359
+ signedRequests = new Map<string, number>()
360
+ /** Fails every statement on the signed-request table. */
361
+ signedRequestsDown = false
362
+ private serial = 0
363
+
364
+ query(sql: string, values: unknown[] = []): { rows: Row[]; rowCount?: number } {
365
+ const text = sql.trim()
366
+ this.statements.push(text)
367
+ const event = (id: unknown) => this.events.find((candidate) => candidate.id === id)
368
+ if (text === 'BEGIN' || text === 'COMMIT' || text === 'ROLLBACK') {
369
+ return { rows: [] }
370
+ }
371
+ if (text.includes('teleport_automation_requests') || text.includes('pg_advisory_xact_lock')) {
372
+ if (this.signedRequestsDown) {
373
+ throw new Error('connection terminated')
374
+ }
375
+ if (text === 'SELECT pg_advisory_xact_lock(hashtext($1))') {
376
+ return { rows: [] }
377
+ }
378
+ if (
379
+ text ===
380
+ 'CREATE TABLE IF NOT EXISTS teleport_automation_requests (request_key TEXT PRIMARY KEY, expires_at BIGINT NOT NULL)' ||
381
+ text ===
382
+ 'CREATE INDEX IF NOT EXISTS teleport_automation_requests_expires_at ON teleport_automation_requests (expires_at)'
383
+ ) {
384
+ return { rows: [] }
385
+ }
386
+ if (
387
+ text ===
388
+ 'DELETE FROM teleport_automation_requests WHERE request_key IN (SELECT request_key FROM teleport_automation_requests WHERE expires_at < $1 LIMIT 1000)'
389
+ ) {
390
+ this.signedRequests.forEach((expiresAt, key) => {
391
+ if (expiresAt < Number(values[0])) {
392
+ this.signedRequests.delete(key)
393
+ }
394
+ })
395
+ return { rows: [] }
396
+ }
397
+ if (
398
+ text ===
399
+ 'INSERT INTO teleport_automation_requests (request_key, expires_at) VALUES ($1, $2) ON CONFLICT (request_key) DO NOTHING RETURNING request_key'
400
+ ) {
401
+ const [key, expiresAt] = values as [string, number]
402
+ if (this.signedRequests.has(key)) {
403
+ return { rows: [] }
404
+ }
405
+ this.signedRequests.set(key, expiresAt)
406
+ return { rows: [{ request_key: key }] }
407
+ }
408
+ }
409
+ if (text.startsWith('INSERT INTO teleport_events (event_type, dedupe_key, order_id')) {
410
+ const [type, dedupeKey, orderId, entityType, entityId, payload, actor, actorType] = values
411
+ if (this.events.some((candidate) => candidate.dedupe_key === dedupeKey)) {
412
+ return { rows: [] }
413
+ }
414
+ const id = this.newEvent({
415
+ event_type: String(type),
416
+ dedupe_key: String(dedupeKey),
417
+ order_id: (orderId as string) || null,
418
+ entity_type: (entityType as string) || null,
419
+ entity_id: (entityId as string) || null,
420
+ payload: (payload as string) || null,
421
+ actor: (actor as string) || null,
422
+ actor_type: String(actorType || 'system'),
423
+ })
424
+ return { rows: [{ id }] }
425
+ }
426
+ if (text.startsWith('INSERT INTO teleport_events (event_type, dedupe_key, payload')) {
427
+ const [type, dedupeKey, payload, actor, actorType] = values
428
+ const id = this.newEvent({
429
+ event_type: String(type),
430
+ dedupe_key: String(dedupeKey),
431
+ payload: (payload as string) || null,
432
+ actor: (actor as string) || null,
433
+ actor_type: String(actorType || 'system'),
434
+ status: 'test',
435
+ })
436
+ return { rows: [{ id, created_at: event(id)!.created_at }] }
437
+ }
438
+ if (text.includes('FROM teleport_events e') && text.includes('FOR UPDATE SKIP LOCKED')) {
439
+ const [types, workflowIds] = values as string[][]
440
+ const now = Date.now()
441
+ // Answered as the statement asks: the clauses below are read off its text.
442
+ const staleCountsDeliveries = text.includes(
443
+ 'GREATEST(COALESCE(e.claimed_at, e.created_at), COALESCE((SELECT max(d.started_at)'
444
+ )
445
+ const settlesExhausted = text.includes("OR (e.status = 'pending' AND e.attempts >= 5")
446
+ const sparesWaiting = text.includes("d.status = 'waiting'")
447
+ // Only a run parked by an automation of this build that is on holds the event.
448
+ const sparesOnlyLiveWaits = text.includes(
449
+ "d.status = 'waiting' AND d.workflow_id = ANY($2::text[]) AND EXISTS (SELECT 1 FROM teleport_automations a WHERE a.workflow_id = d.workflow_id AND a.enabled)"
450
+ )
451
+ const holdsEvent = (d: DeliveryRow) =>
452
+ !sparesOnlyLiveWaits ||
453
+ (workflowIds.includes(d.workflow_id) &&
454
+ this.automations.some((a) => a.workflow_id === d.workflow_id && a.enabled))
455
+ // Untouched for the stale window: its claim, and (as the claim asks) the
456
+ // latest start of one of its deliveries.
457
+ const stale = (candidate: EventRow) =>
458
+ !(
459
+ sparesWaiting &&
460
+ this.deliveries.some(
461
+ (d) => d.event_id === candidate.id && d.status === 'waiting' && holdsEvent(d)
462
+ )
463
+ ) &&
464
+ Math.max(
465
+ (candidate.claimed_at || candidate.created_at).getTime(),
466
+ ...(staleCountsDeliveries
467
+ ? this.deliveries
468
+ .filter((d) => d.event_id === candidate.id && d.started_at)
469
+ .map((d) => d.started_at!.getTime())
470
+ : [])
471
+ ) <
472
+ now - STALE_MS
473
+ const due = this.events.filter(
474
+ (candidate) =>
475
+ !candidate.locked &&
476
+ types.includes(candidate.event_type) &&
477
+ ((candidate.attempts < 5 &&
478
+ ((candidate.status === 'recorded' &&
479
+ candidate.created_at.getTime() > now - 3 * DAY_MS) ||
480
+ candidate.status === 'failed' ||
481
+ (candidate.status === 'pending' && stale(candidate))) &&
482
+ this.enabledFor(candidate, workflowIds).length > 0) ||
483
+ (settlesExhausted &&
484
+ candidate.status === 'pending' &&
485
+ candidate.attempts >= 5 &&
486
+ stale(candidate)))
487
+ )
488
+ due.sort(
489
+ (a, b) =>
490
+ Number(a.status !== 'recorded') - Number(b.status !== 'recorded') ||
491
+ a.created_at.getTime() - b.created_at.getTime()
492
+ )
493
+ const claimed = due.slice(0, 20).map((candidate) => ({ ...candidate }))
494
+ if (this.afterClaim) {
495
+ this.afterClaim()
496
+ }
497
+ return { rows: claimed }
498
+ }
499
+ // The claim's later writes hold only while it is still the claim.
500
+ const holdsClaim = (target: EventRow | undefined, claimedAt: unknown): target is EventRow =>
501
+ !!target &&
502
+ target.status === 'pending' &&
503
+ !!target.claimed_at &&
504
+ claimedAt instanceof Date &&
505
+ target.claimed_at.getTime() === claimedAt.getTime()
506
+ if (text.startsWith("UPDATE teleport_events SET status = 'pending', claimed_at = date_trunc")) {
507
+ const marked: Row[] = []
508
+ for (const candidate of this.events) {
509
+ if ((values[0] as string[]).includes(candidate.id)) {
510
+ candidate.status = 'pending'
511
+ candidate.claimed_at = new Date()
512
+ // An attempt counted by the claim itself (it must not be: see the time budget).
513
+ if (text.includes('attempts = attempts + 1')) {
514
+ candidate.attempts += 1
515
+ }
516
+ marked.push({ id: candidate.id, claimed_at: candidate.claimed_at })
517
+ }
518
+ }
519
+ return { rows: marked }
520
+ }
521
+ if (text.startsWith('UPDATE teleport_events SET attempts = attempts + 1')) {
522
+ const target = event(values[0])
523
+ if (holdsClaim(target, values[1])) {
524
+ target.attempts += 1
525
+ }
526
+ return { rows: [] }
527
+ }
528
+ if (text.startsWith("UPDATE teleport_events SET status = 'failed', attempts = attempts + 1")) {
529
+ const target = event(values[0])
530
+ if (holdsClaim(target, values[1])) {
531
+ target.status = 'failed'
532
+ target.attempts += 1
533
+ }
534
+ return { rows: [] }
535
+ }
536
+ if (text.startsWith('UPDATE teleport_events SET status = $2')) {
537
+ const target = event(values[0])
538
+ // Guarded by the claim when the statement names it.
539
+ const guarded = text.includes("AND status = 'pending' AND claimed_at = $3")
540
+ if (target && (guarded ? holdsClaim(target, values[2]) : true)) {
541
+ target.status = String(values[1])
542
+ }
543
+ return { rows: [] }
544
+ }
545
+
546
+ if (text.includes('FROM teleport_automations a JOIN teleport_events e')) {
547
+ const target = event(values[0])
548
+ return {
549
+ rows: target
550
+ ? this.enabledFor(target, values[1] as string[]).map((a) => ({
551
+ workflow_id: a.workflow_id,
552
+ }))
553
+ : [],
554
+ }
555
+ }
556
+ if (
557
+ text.startsWith('INSERT INTO teleport_event_deliveries AS d') &&
558
+ text.includes("VALUES ($1, $2, 'failed', 0, $3")
559
+ ) {
560
+ // A refusal: the automation never ran; its delivery says why.
561
+ const [eventId, workflowId, error] = values as string[]
562
+ const existing = this.deliveries.find(
563
+ (d) => d.event_id === eventId && d.workflow_id === workflowId
564
+ )
565
+ if (!existing) {
566
+ this.deliveries.push({
567
+ id: ++this.serial,
568
+ event_id: eventId,
569
+ workflow_id: workflowId,
570
+ status: 'failed',
571
+ error,
572
+ attempts: 0,
573
+ started_at: new Date(),
574
+ finished_at: new Date(),
575
+ })
576
+ } else if (existing.status === 'failed') {
577
+ existing.error = error
578
+ }
579
+ return { rows: [] }
580
+ }
581
+ if (text.startsWith('INSERT INTO teleport_event_deliveries AS d')) {
582
+ const [eventId, workflowId] = values as string[]
583
+ const existing = this.deliveries.find(
584
+ (d) => d.event_id === eventId && d.workflow_id === workflowId
585
+ )
586
+ if (!existing) {
587
+ return { rows: [this.newDelivery(eventId, workflowId)] }
588
+ }
589
+ const stale =
590
+ existing.status === 'running' &&
591
+ !!existing.started_at &&
592
+ existing.started_at.getTime() < Date.now() - STALE_MS
593
+ if (existing.status !== 'failed' && !stale) {
594
+ return { rows: [] }
595
+ }
596
+ existing.status = 'running'
597
+ existing.attempts += 1
598
+ existing.error = null
599
+ existing.started_at = new Date()
600
+ existing.finished_at = null
601
+ return { rows: [{ id: existing.id, attempts: existing.attempts }] }
602
+ }
603
+ if (text.startsWith('INSERT INTO teleport_event_deliveries (event_id')) {
604
+ const [eventId, workflowId] = values as string[]
605
+ return { rows: [this.newDelivery(eventId, workflowId)] }
606
+ }
607
+ if (text.startsWith('WITH w AS (UPDATE teleport_automation_waits SET status = $1')) {
608
+ // A resume's outcome, as one statement: the Wait under its claim token,
609
+ // and its delivery only when that matched.
610
+ const [waitStatus, waitId, runAt, status, error, startedAt, eventId, workflowId] = values
611
+ const wait = this.waits.find((w) => w.id === waitId)
612
+ const held =
613
+ !!wait && wait.status === 'resuming' && wait.run_at.getTime() === (runAt as Date).getTime()
614
+ if (!held) {
615
+ return { rows: [], rowCount: 0 }
616
+ }
617
+ wait!.status = String(waitStatus)
618
+ let rowCount = 0
619
+ for (const d of this.deliveries) {
620
+ if (d.event_id === eventId && d.workflow_id === workflowId && d.status === 'waiting') {
621
+ d.status = String(status)
622
+ d.error = (error as string) || null
623
+ d.started_at = startedAt as Date
624
+ d.finished_at = new Date()
625
+ rowCount += 1
626
+ }
627
+ }
628
+ return { rows: [], rowCount }
629
+ }
630
+ if (text.startsWith('INSERT INTO "teleport_sent_emails"')) {
631
+ const columns = (text.match(/\(([^)]*)\) VALUES/) || ['', ''])[1]
632
+ .split(',')
633
+ .map((column) => column.trim().replace(/"/g, ''))
634
+ this.sentEmails.push(Object.fromEntries(columns.map((column, i) => [column, values[i]])))
635
+ return { rows: [] }
636
+ }
637
+ if (
638
+ text.startsWith("UPDATE teleport_event_deliveries SET status = 'failed', error = 'Timed out'")
639
+ ) {
640
+ for (const d of this.deliveries) {
641
+ if (
642
+ d.event_id === values[0] &&
643
+ d.status === 'running' &&
644
+ d.started_at!.getTime() < Date.now() - STALE_MS
645
+ ) {
646
+ d.status = 'failed'
647
+ d.error = 'Timed out'
648
+ d.finished_at = new Date()
649
+ }
650
+ }
651
+ return { rows: [] }
652
+ }
653
+ if (text.startsWith('UPDATE teleport_event_deliveries')) {
654
+ const delivery = this.deliveries.find((d) => d.id === values[0])
655
+ // Only while the same claim (its attempt number) holds the row.
656
+ const guarded = text.includes("AND status = 'running' AND attempts = $4")
657
+ if (delivery && guarded && delivery.status === 'running' && delivery.attempts === values[3]) {
658
+ delivery.status = String(values[1])
659
+ delivery.error = (values[2] as string) || null
660
+ delivery.finished_at = new Date()
661
+ return { rows: [], rowCount: 1 }
662
+ }
663
+ return { rows: [], rowCount: 0 }
664
+ }
665
+ if (text.startsWith('SELECT workflow_id, status FROM teleport_event_deliveries')) {
666
+ return {
667
+ rows: this.deliveries
668
+ .filter((d) => d.event_id === values[0])
669
+ .map((d) => ({ workflow_id: d.workflow_id, status: d.status })),
670
+ }
671
+ }
672
+ if (text.startsWith('INSERT INTO teleport_automation_waits')) {
673
+ const [eventId, workflowId, resumeNodeId, context, runAt] = values
674
+ this.waits.push({
675
+ id: ++this.serial,
676
+ event_id: String(eventId),
677
+ workflow_id: String(workflowId),
678
+ resume_node_id: String(resumeNodeId),
679
+ context: (context as string) || null,
680
+ run_at: new Date(String(runAt)),
681
+ status: 'waiting',
682
+ created_at: new Date(),
683
+ })
684
+ return { rows: [] }
685
+ }
686
+ if (text.includes('FROM teleport_automation_waits w') && text.includes('SKIP LOCKED')) {
687
+ const workflowIds = values[0] as string[]
688
+ const now = Date.now()
689
+ return {
690
+ rows: this.waits
691
+ .filter(
692
+ (w) =>
693
+ workflowIds.includes(w.workflow_id) &&
694
+ ((w.status === 'waiting' && w.run_at.getTime() <= now) ||
695
+ (w.status === 'resuming' && w.run_at.getTime() < now - STALE_MS)) &&
696
+ this.automations.some((a) => a.workflow_id === w.workflow_id && a.enabled)
697
+ )
698
+ .map((w) => ({ ...w })),
699
+ }
700
+ }
701
+ if (text.startsWith("UPDATE teleport_automation_waits SET status = 'resuming'")) {
702
+ const marked: Row[] = []
703
+ for (const wait of this.waits) {
704
+ if ((values[0] as number[]).includes(wait.id)) {
705
+ wait.status = 'resuming'
706
+ wait.run_at = new Date()
707
+ marked.push({ id: wait.id, run_at: wait.run_at })
708
+ }
709
+ }
710
+ return { rows: marked }
711
+ }
712
+ if (
713
+ text.includes(
714
+ "FROM teleport_automation_waits WHERE event_id = $1 AND workflow_id = $2 AND status = 'failed' ORDER BY id DESC LIMIT 1"
715
+ )
716
+ ) {
717
+ const failed = this.waits
718
+ .filter(
719
+ (w) => w.event_id === values[0] && w.workflow_id === values[1] && w.status === 'failed'
720
+ )
721
+ .sort((a, b) => b.id - a.id)
722
+ return { rows: failed.slice(0, 1).map((w) => ({ ...w })) }
723
+ }
724
+ if (
725
+ text.startsWith(
726
+ "UPDATE teleport_automation_waits SET status = 'done' WHERE id = $1 AND status = 'failed'"
727
+ )
728
+ ) {
729
+ const wait = this.waits.find((w) => w.id === values[0])
730
+ if (wait && wait.status === 'failed') {
731
+ wait.status = 'done'
732
+ }
733
+ return { rows: [] }
734
+ }
735
+ if (text.startsWith('SELECT (EXISTS (SELECT 1 FROM teleport_automation_waits w')) {
736
+ // The activity kick's due check, read the way the dispatcher's claims are.
737
+ const [workflowIds, types] = values as string[][]
738
+ const now = Date.now()
739
+ const on = (workflowId: string) =>
740
+ workflowIds.includes(workflowId) &&
741
+ this.automations.some((a) => a.workflow_id === workflowId && a.enabled)
742
+ const dueWait = this.waits.some(
743
+ (w) =>
744
+ on(w.workflow_id) &&
745
+ ((w.status === 'waiting' && w.run_at.getTime() <= now) ||
746
+ (w.status === 'resuming' && w.run_at.getTime() < now - STALE_MS))
747
+ )
748
+ const dueRetry = this.events.some(
749
+ (e) =>
750
+ types.includes(e.event_type) &&
751
+ ((e.attempts < 5 &&
752
+ (e.status === 'failed' || this.stalePending(e, workflowIds)) &&
753
+ this.enabledFor(e, workflowIds).length > 0) ||
754
+ (e.attempts >= 5 && this.stalePending(e, workflowIds)))
755
+ )
756
+ return { rows: [{ due: dueWait || dueRetry }] }
757
+ }
758
+ if (text.includes('FROM teleport_products WHERE id = $1')) {
759
+ return { rows: this.products.filter((product) => product.id === values[0]) }
760
+ }
761
+ if (text.startsWith("UPDATE teleport_automation_waits SET status = 'waiting'")) {
762
+ const wait = this.waits.find((w) => w.id === values[0])
763
+ // Guarded by the claim's run_at token when the statement names it.
764
+ const guarded = text.includes("AND status = 'resuming' AND run_at = $2")
765
+ if (
766
+ wait &&
767
+ wait.status === 'resuming' &&
768
+ (!guarded || wait.run_at.getTime() === (values[1] as Date).getTime())
769
+ ) {
770
+ wait.status = 'waiting'
771
+ }
772
+ return { rows: [] }
773
+ }
774
+ if (text.includes('FROM teleport_events e WHERE e.id = $1')) {
775
+ return {
776
+ rows: this.events
777
+ .filter((candidate) => candidate.id === values[0])
778
+ .map((candidate) => ({ ...candidate })),
779
+ }
780
+ }
781
+ if (text.includes('FROM teleport_orders WHERE id = $1')) {
782
+ if (this.ordersError) {
783
+ throw this.ordersError
784
+ }
785
+ return { rows: this.orders.filter((order) => order.id === values[0]) }
786
+ }
787
+ if (text.includes('FROM teleport_order_items WHERE order_id = $1')) {
788
+ return { rows: this.orderItems.filter((item) => item.order_id === values[0]) }
789
+ }
790
+ if (text.includes('FROM "users" WHERE LOWER(email) = LOWER($1)')) {
791
+ return {
792
+ rows: this.users.filter(
793
+ (user) => String(user.email).toLowerCase() === String(values[0]).toLowerCase()
794
+ ),
795
+ }
796
+ }
797
+ if (text.includes('FROM teleport_product_reviews WHERE id = $1')) {
798
+ return { rows: this.reviews.filter((review) => review.id === values[0]) }
799
+ }
800
+ if (text.includes('FROM teleport_waitlist_entries WHERE id = $1')) {
801
+ return { rows: this.waitlistEntries.filter((entry) => entry.id === values[0]) }
802
+ }
803
+ if (text.includes('FROM teleport_waitlists WHERE id = $1')) {
804
+ return { rows: this.waitlists.filter((list) => list.id === values[0]) }
805
+ }
806
+ if (text.includes('FROM "users" WHERE id = $1')) {
807
+ return { rows: this.users.filter((user) => user.id === values[0]) }
808
+ }
809
+ if (text.includes('FROM teleport_bookings WHERE id = $1')) {
810
+ return { rows: this.bookings.filter((booking) => booking.id === values[0]) }
811
+ }
812
+ throw new Error('Unexpected statement: ' + text)
813
+ }
814
+
815
+ claimStatement(): string {
816
+ const claim = this.statements.find(
817
+ (text) => text.includes('FROM teleport_events e') && text.includes('SKIP LOCKED')
818
+ )
819
+ if (!claim) {
820
+ throw new Error('No claim was sent')
821
+ }
822
+ return claim
823
+ }
824
+
825
+ delivery(workflowId: string): DeliveryRow | undefined {
826
+ return this.deliveries.find((d) => d.workflow_id === workflowId)
827
+ }
828
+
829
+ /** A pending event nothing touched for the stale window, as the dispatcher reads it. */
830
+ private stalePending(event: EventRow, workflowIds: string[]): boolean {
831
+ const heldByLiveWait = this.deliveries.some(
832
+ (d) =>
833
+ d.event_id === event.id &&
834
+ d.status === 'waiting' &&
835
+ workflowIds.includes(d.workflow_id) &&
836
+ this.automations.some((a) => a.workflow_id === d.workflow_id && a.enabled)
837
+ )
838
+ const touched = Math.max(
839
+ (event.claimed_at || event.created_at).getTime(),
840
+ ...this.deliveries
841
+ .filter((d) => d.event_id === event.id && d.started_at)
842
+ .map((d) => d.started_at!.getTime())
843
+ )
844
+ return event.status === 'pending' && !heldByLiveWait && touched < Date.now() - STALE_MS
845
+ }
846
+
847
+ private enabledFor(event: EventRow, workflowIds: string[]): AutomationRow[] {
848
+ return this.automations.filter(
849
+ (a) =>
850
+ a.event_type === event.event_type &&
851
+ workflowIds.includes(a.workflow_id) &&
852
+ a.enabled &&
853
+ (a.enabled_at || a.created_at).getTime() <= event.created_at.getTime()
854
+ )
855
+ }
856
+
857
+ private newEvent(values: Partial<EventRow>): string {
858
+ const id = `00000000-0000-4000-8000-${String(++this.serial).padStart(12, '0')}`
859
+ this.events.push({
860
+ id,
861
+ event_type: '',
862
+ dedupe_key: '',
863
+ order_id: null,
864
+ entity_type: null,
865
+ entity_id: null,
866
+ payload: null,
867
+ actor: null,
868
+ actor_type: 'system',
869
+ status: 'recorded',
870
+ attempts: 0,
871
+ created_at: new Date(),
872
+ ...values,
873
+ })
874
+ return id
875
+ }
876
+
877
+ private newDelivery(eventId: string, workflowId: string): Row {
878
+ const id = ++this.serial
879
+ this.deliveries.push({
880
+ id,
881
+ event_id: eventId,
882
+ workflow_id: workflowId,
883
+ status: 'running',
884
+ error: null,
885
+ attempts: 1,
886
+ started_at: new Date(),
887
+ finished_at: null,
888
+ })
889
+ return { id, attempts: 1 }
890
+ }
891
+ }
892
+
893
+ // ---------------------------------------------------------------------------
894
+ // Running the generated modules
895
+ // ---------------------------------------------------------------------------
896
+
897
+ const resolveRelative = (fromDir: string, id: string): string => {
898
+ const joined = nodePath.posix.normalize(nodePath.posix.join(fromDir, id))
899
+ return joined.endsWith('.js') ? joined : `${joined}.js`
900
+ }
901
+
902
+ /** A CommonJS loader over the generated files, with `pg` answered by `db`. */
903
+ const createApp = (files: Map<string, string>, db: FakeDatabase) => {
904
+ const cache = new Map<string, { exports: unknown }>()
905
+ class Client {
906
+ constructor(options: Row) {
907
+ db.clientOptions.push(options)
908
+ }
909
+ async connect(): Promise<void> {
910
+ return undefined
911
+ }
912
+ async query(sql: string, values?: unknown[]) {
913
+ return db.query(sql, values)
914
+ }
915
+ async end(): Promise<void> {
916
+ return undefined
917
+ }
918
+ }
919
+ const load = (path: string): unknown => {
920
+ const cached = cache.get(path)
921
+ if (cached) {
922
+ return cached.exports
923
+ }
924
+ const source = files.get(path)
925
+ if (source === undefined) {
926
+ throw new Error(`Cannot find module '${path}'`)
927
+ }
928
+ const module = { exports: {} as unknown }
929
+ cache.set(path, module)
930
+ const dir = nodePath.posix.dirname(path)
931
+ const localRequire = (id: string): unknown => {
932
+ if (id === 'pg') {
933
+ return { Client }
934
+ }
935
+ // The mail provider's SDK: every send succeeds, and is kept.
936
+ if (id === 'resend') {
937
+ return {
938
+ Resend: class {
939
+ emails = {
940
+ send: async (payload: Row) => {
941
+ db.mailSent.push(payload)
942
+ return { data: { id: 'msg-' + db.mailSent.length } }
943
+ },
944
+ }
945
+ },
946
+ }
947
+ }
948
+ if (id === '@vercel/functions' || id === 'node-fetch') {
949
+ throw new Error(`Cannot find module '${id}'`)
950
+ }
951
+ if (id.startsWith('.')) {
952
+ return load(resolveRelative(dir, id))
953
+ }
954
+ if (id in FETCH_BACKED_NETWORK) {
955
+ return FETCH_BACKED_NETWORK[id]
956
+ }
957
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
958
+ return require(id)
959
+ }
960
+ // eslint-disable-next-line no-new-func
961
+ new Function('module', 'exports', 'require', source)(module, module.exports, localRequire)
962
+ return module.exports
963
+ }
964
+ return load
965
+ }
966
+
967
+ interface Totals {
968
+ claimed: number
969
+ delivered: number
970
+ failed: number
971
+ resumed: number
972
+ wouldWait?: Row
973
+ }
974
+
975
+ interface Bus {
976
+ emitBusinessEvent(input: Row, req?: Row): Promise<{ recorded: boolean; id: string | null }>
977
+ scheduleDispatch(req?: Row): string
978
+ dispatch(options?: Row): Promise<Totals>
979
+ }
980
+
981
+ type RouteHandler = (req: unknown, res: unknown) => Promise<void>
982
+
983
+ const ENV = {
984
+ TELEPORT_DB_CONNECTION_STRING: 'postgresql://u:p@localhost:5432/db',
985
+ NEXTAUTH_SECRET: 'app-secret',
986
+ NEXTAUTH_URL: 'https://shop.test',
987
+ VERCEL: undefined,
988
+ TELEPORT_CRON_ALLOW_UNSIGNED: undefined,
989
+ TELEPORT_CRON_SECRET: undefined,
990
+ }
991
+
992
+ const silenced = async <T>(run: () => Promise<T>): Promise<T> => {
993
+ const sink = console as unknown as { warn: typeof console.warn; error: typeof console.warn }
994
+ const saved = { warn: sink.warn, error: sink.error }
995
+ sink.warn = () => undefined
996
+ sink.error = () => undefined
997
+ try {
998
+ return await run()
999
+ } finally {
1000
+ sink.warn = saved.warn
1001
+ sink.error = saved.error
1002
+ }
1003
+ }
1004
+
1005
+ /** Runs `run` with every fetch answered: echo.test/fails is down, the rest answer 200. */
1006
+ const withEcho = <T>(run: (calls: FetchCall[]) => Promise<T>, env: Row = {}) =>
1007
+ withEnv({ ...ENV, ...env } as Record<string, string | undefined>, () =>
1008
+ withFetch((call) => {
1009
+ if (call.url === ECHO + '/fails') {
1010
+ throw new Error('network down')
1011
+ }
1012
+ return { status: 200, body: { ok: true } }
1013
+ }, run)
1014
+ )
1015
+
1016
+ const ago = (ms: number) => new Date(Date.now() - ms)
1017
+
1018
+ const automationRow = (workflowId: string, eventType: string, enabled = true): AutomationRow => ({
1019
+ workflow_id: workflowId,
1020
+ event_type: eventType,
1021
+ enabled,
1022
+ enabled_at: ago(DAY_MS),
1023
+ created_at: ago(DAY_MS),
1024
+ })
1025
+
1026
+ const eventRow = (values: Partial<EventRow> & { id: string; event_type: string }): EventRow => ({
1027
+ dedupe_key: values.id,
1028
+ order_id: null,
1029
+ entity_type: null,
1030
+ entity_id: null,
1031
+ payload: null,
1032
+ actor: null,
1033
+ actor_type: 'system',
1034
+ status: 'recorded',
1035
+ attempts: 0,
1036
+ created_at: ago(1000),
1037
+ ...values,
1038
+ })
1039
+
1040
+ const seedOrderPaid = (db: FakeDatabase): EventRow => {
1041
+ db.automations.push(
1042
+ automationRow('wf-paid-ok', 'order-paid'),
1043
+ automationRow('wf-paid-fail', 'order-paid'),
1044
+ automationRow('wf-paid-off', 'order-paid', false)
1045
+ )
1046
+ db.orders.push({
1047
+ id: 'ord-1',
1048
+ order_number: 'ORD-1042',
1049
+ status: 'paid',
1050
+ payment_status: 'paid',
1051
+ total_amount: '53.20',
1052
+ tax_amount: '8',
1053
+ shipping_amount: '10',
1054
+ discount_amount: '4.8',
1055
+ currency: 'USD',
1056
+ billing_email: 'ava@example.com',
1057
+ billing_name: 'Ava Thompson',
1058
+ fulfillment_method: 'delivery',
1059
+ payment_method: 'online',
1060
+ payment_provider: 'stripe',
1061
+ created_at: ago(MINUTE_MS),
1062
+ })
1063
+ db.orderItems.push({
1064
+ id: 'item-1',
1065
+ order_id: 'ord-1',
1066
+ product_id: 'p-1',
1067
+ product_name: 'Linen Throw Pillow',
1068
+ quantity: 2,
1069
+ unit_price: '20',
1070
+ total_price: '40',
1071
+ unit_price_paid: '18',
1072
+ total_price_paid: '36',
1073
+ })
1074
+ const event = eventRow({
1075
+ id: 'evt-paid',
1076
+ event_type: 'order-paid',
1077
+ dedupe_key: 'order-paid:ord-1',
1078
+ order_id: 'ord-1',
1079
+ entity_type: 'order',
1080
+ entity_id: 'ord-1',
1081
+ payload: JSON.stringify({ amount: 53.2, provider: 'stripe', discountDetails: [] }),
1082
+ actor: 'stripe',
1083
+ })
1084
+ db.events.push(event)
1085
+ return event
1086
+ }
1087
+
1088
+ const seedSignup = (db: FakeDatabase): EventRow => {
1089
+ db.automations.push(automationRow('wf-signup-wait', 'user-signed-up'))
1090
+ db.users.push({
1091
+ id: 'user-1',
1092
+ email: 'noah@example.com',
1093
+ name: 'Noah',
1094
+ role: 'user',
1095
+ password: 'hash',
1096
+ created_at: ago(1000),
1097
+ })
1098
+ const event = eventRow({
1099
+ id: 'evt-signup',
1100
+ event_type: 'user-signed-up',
1101
+ entity_type: 'user',
1102
+ entity_id: 'user-1',
1103
+ actor: 'noah@example.com',
1104
+ actor_type: 'customer',
1105
+ })
1106
+ db.events.push(event)
1107
+ return event
1108
+ }
1109
+
1110
+ // The auth route (and the bus, reading it first) keeps the configured
1111
+ // NEXTAUTH_URL on globalThis once per process: each test is its own process.
1112
+ afterEach(() => {
1113
+ delete (globalThis as Row).__tqConfiguredNextAuthUrl
1114
+ })
1115
+
1116
+ const AUTOMATION_KEYS = (structure: Structure) =>
1117
+ Array.from(structure.files.keys()).filter((key) => key.startsWith('automations-'))
1118
+
1119
+ describe('in-app event bus — generation', () => {
1120
+ it('emits the bus, the queue worker, the registry, one handler per automation and the dispatch route', async () => {
1121
+ const structure = await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS })
1122
+ const files = sources(structure)
1123
+
1124
+ for (const path of [
1125
+ 'lib/automations/event-bus.js',
1126
+ 'lib/automations/dispatcher.js',
1127
+ 'lib/automations/registry.js',
1128
+ 'lib/automations/store.js',
1129
+ 'lib/automations/wait-until.js',
1130
+ 'lib/automations/due-check.js',
1131
+ 'lib/automations/hydrate/order.js',
1132
+ 'lib/automations/hydrate/user.js',
1133
+ 'lib/automations/hydrate/booking.js',
1134
+ 'pages/api/automations/dispatch.js',
1135
+ 'lib/automations/handlers/thank-the-customer-wf-paid.js',
1136
+ 'lib/automations/handlers/welcome-two-days-later-wf-signu.js',
1137
+ ]) {
1138
+ expect(files.has(path)).toBe(true)
1139
+ }
1140
+ const handlers = Array.from(files.keys()).filter((path) =>
1141
+ path.startsWith('lib/automations/handlers/')
1142
+ )
1143
+ expect(handlers).toHaveLength(5)
1144
+
1145
+ const registry = files.get('lib/automations/registry.js')!
1146
+ expect(registry).toContain('"order-paid": [')
1147
+ expect(registry).toContain('"user-signed-up": [')
1148
+ expect(registry).toContain("require('./handlers/thank-the-customer-wf-paid.js').run")
1149
+
1150
+ // Never in the browser: not in the global hook, which still runs the
1151
+ // project's own global workflow.
1152
+ const globalHook = files.get('utils/workflows/global-workflows.js')!
1153
+ expect(globalHook).toContain("'storage-local-remove'")
1154
+ expect(globalHook).not.toContain('echo.test')
1155
+ expect(globalHook).not.toContain('general-wait')
1156
+ // The queue lives in Postgres, and Vercel keeps a dispatch alive with waitUntil.
1157
+ expect(structure.dependencies.pg).toBeDefined()
1158
+ expect(structure.dependencies['@vercel/functions']).toBe('^2.2.13')
1159
+ // The dispatch route reads its body raw (to check the signed digest) and
1160
+ // asks for a time limit its 50 s budget fits in.
1161
+ expect(files.get('pages/api/automations/dispatch.js')).toContain(
1162
+ 'module.exports.config = { api: { bodyParser: false }, maxDuration: 60 };'
1163
+ )
1164
+ })
1165
+
1166
+ it('generates nothing for a project without an automation, and adding one changes nothing else', async () => {
1167
+ const without = await generate(BASE_WORKFLOWS, TELEPORT_AUTOMATIONS, { files: FETCHERS })
1168
+ const withAutomations = await generate(
1169
+ { ...BASE_WORKFLOWS, ...AUTOMATIONS },
1170
+ TELEPORT_AUTOMATIONS,
1171
+ { files: FETCHERS }
1172
+ )
1173
+
1174
+ expect(AUTOMATION_KEYS(without)).toEqual([])
1175
+ const withoutFiles = sources(without)
1176
+ expect(Array.from(withoutFiles.keys()).some((path) => path.includes('automations'))).toBe(false)
1177
+ // The data-source modules are left exactly as their plugin wrote them.
1178
+ const seeded = sources({ files: FETCHERS() } as Structure)
1179
+ for (const [path, content] of seeded) {
1180
+ expect({ path, content: withoutFiles.get(path) }).toEqual({ path, content })
1181
+ }
1182
+
1183
+ const withFiles = sources(withAutomations)
1184
+ for (const [path, content] of withoutFiles) {
1185
+ // The data API only gains the wake-up after a write to the event ledger
1186
+ // (data-api-business-events.test.ts), and the activity kick.
1187
+ if (path === DATA_API_ROUTE) {
1188
+ const diff = insertedLines(content, withFiles.get(path) as string)
1189
+ expect(diff.kept).toBe(true)
1190
+ expect(diff.inserted.join('\n')).toContain('wakeAutomations(req);')
1191
+ expect(diff.inserted.join('\n')).toContain('kickDueAutomations(req);')
1192
+ continue
1193
+ }
1194
+ // A server read of the TeleportHQ database only gains the activity kick.
1195
+ if (TELEPORT_READS.indexOf(path) !== -1) {
1196
+ const diff = insertedLines(content, withFiles.get(path) as string)
1197
+ expect({ path, kept: diff.kept }).toEqual({ path, kept: true })
1198
+ expect(diff.inserted).toEqual([
1199
+ "import { kickWhenDue as __tqKickDueAutomations } from '../../lib/automations/due-check'",
1200
+ ' // Activity on the site: due automation work (a Wait, a retry) starts off this request.',
1201
+ ' __tqKickDueAutomations(req)',
1202
+ ])
1203
+ continue
1204
+ }
1205
+ expect({ path, content: withFiles.get(path) }).toEqual({ path, content })
1206
+ }
1207
+ const added = Array.from(withFiles.keys()).filter((path) => !withoutFiles.has(path))
1208
+ expect(added.every((path) => /^(lib|pages\/api)\/automations\//.test(path))).toBe(true)
1209
+ // The dispatch route verifies signed requests with the cron signing keys.
1210
+ expect(withAutomations.uidl.globals.env).toEqual({
1211
+ ...without.uidl.globals.env,
1212
+ TELEPORT_CRON_SECRET: '',
1213
+ TELEPORT_CRON_SECRET_PREVIOUS: '',
1214
+ })
1215
+ expect(withAutomations.dependencies).toEqual({
1216
+ ...without.dependencies,
1217
+ pg: expect.any(String),
1218
+ '@vercel/functions': expect.any(String),
1219
+ })
1220
+ })
1221
+
1222
+ it('records the low-stock events through utils/app-events.js, which wakes the queue only with the bus', async () => {
1223
+ const LOW_STOCK = {
1224
+ ecommerceSettings: {
1225
+ stockManagement: true,
1226
+ stockManagementConfig: {
1227
+ lowStockAlerts: true,
1228
+ lowStockThreshold: 3,
1229
+ lowStockAlertConfig: { provider: 'email-resend' },
1230
+ },
1231
+ },
1232
+ }
1233
+ const withBus = sources(
1234
+ await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }, TELEPORT_AUTOMATIONS, {
1235
+ uidl: LOW_STOCK,
1236
+ })
1237
+ )
1238
+ expect(withBus.get(DATA_API_ROUTE)).toContain(
1239
+ "var appEvents = require('../../../utils/app-events');"
1240
+ )
1241
+ expect(withBus.get(DATA_API_ROUTE)).toContain(
1242
+ 'recordLowStockEvents(req, rows, extractThresholdFromQuery(query));'
1243
+ )
1244
+ expect(withBus.get('utils/app-events.js')).toContain(
1245
+ "require('../lib/automations/event-bus').scheduleDispatch(req);"
1246
+ )
1247
+
1248
+ // Without the bus the data API records nothing and has no app-events module.
1249
+ const withoutBus = sources(
1250
+ await generate(BASE_WORKFLOWS, TELEPORT_AUTOMATIONS, { uidl: LOW_STOCK })
1251
+ )
1252
+ expect(withoutBus.has('utils/app-events.js')).toBe(false)
1253
+ expect(withoutBus.get(DATA_API_ROUTE)).not.toContain('app-events')
1254
+ })
1255
+
1256
+ it('declares the cron signing keys for a project whose only signed route is the dispatch route', async () => {
1257
+ const automationsOnly = await generate(AUTOMATIONS)
1258
+ expect(automationsOnly.uidl.globals.env).toMatchObject({
1259
+ TELEPORT_CRON_SECRET: '',
1260
+ TELEPORT_CRON_SECRET_PREVIOUS: '',
1261
+ })
1262
+ })
1263
+
1264
+ describe('runs on the TeleportHQ database only', () => {
1265
+ const quietly = async <T>(
1266
+ run: () => Promise<T>
1267
+ ): Promise<{ result: T; warnings: string[] }> => {
1268
+ const warnings: string[] = []
1269
+ const saved = console.warn
1270
+ console.warn = (...args: unknown[]) => {
1271
+ warnings.push(args.map(String).join(' '))
1272
+ }
1273
+ try {
1274
+ return { result: await run(), warnings }
1275
+ } finally {
1276
+ console.warn = saved
1277
+ }
1278
+ }
1279
+ const ROUTE = 'pages/api/automations/dispatch.js'
1280
+ const REASON =
1281
+ 'Automations are not set up on this site: no TeleportHQ database holds the automation tables'
1282
+
1283
+ it('the TeleportHQ database: the bus on TELEPORT_DB_*, the real dispatch route', async () => {
1284
+ const structure = await generate(AUTOMATIONS, TELEPORT_AUTOMATIONS)
1285
+ const files = sources(structure)
1286
+ expect(files.get('lib/automations/store.js')).toContain(
1287
+ 'process.env.TELEPORT_DB_CONNECTION_STRING'
1288
+ )
1289
+ expect(files.get('lib/automations/store.js')).not.toContain('DATABASE_URL')
1290
+ expect(files.get(ROUTE)).toContain('verifyCronRequest(req, rawBody)')
1291
+ expect(Object.keys(structure.uidl.globals.env).sort()).toEqual([
1292
+ 'NEXTAUTH_SECRET',
1293
+ 'TELEPORT_CRON_SECRET',
1294
+ 'TELEPORT_CRON_SECRET_PREVIOUS',
1295
+ ])
1296
+ })
1297
+
1298
+ it('anything else: no bus, one build warning, a dispatch route that says why, nothing else changes', async () => {
1299
+ const without = await generate(BASE_WORKFLOWS, null, { files: FETCHERS })
1300
+ const withoutFiles = sources(without)
1301
+ for (const automations of [null, { dataSourceId: 'ds-own', dataSourceType: 'postgresql' }]) {
1302
+ const { result: refused, warnings } = await quietly(() =>
1303
+ generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }, automations, { files: FETCHERS })
1304
+ )
1305
+ expect(AUTOMATION_KEYS(refused)).toEqual(['automations-dispatch-route'])
1306
+ expect(warnings.filter((line) => line.includes('no TeleportHQ database'))).toHaveLength(1)
1307
+ const refusedFiles = sources(refused)
1308
+ expect(
1309
+ Array.from(refusedFiles.keys()).filter((path) => path.includes('automations'))
1310
+ ).toEqual([ROUTE])
1311
+ expect(Array.from(refusedFiles.keys()).sort()).toEqual(
1312
+ [...Array.from(withoutFiles.keys()), ROUTE].sort()
1313
+ )
1314
+ for (const [path, content] of withoutFiles) {
1315
+ expect({ path, content: refusedFiles.get(path) }).toEqual({ path, content })
1316
+ }
1317
+ expect(refused.uidl.globals.env).toEqual(without.uidl.globals.env)
1318
+ expect(refused.dependencies).toEqual(without.dependencies)
1319
+
1320
+ // Whatever is sent, the route answers the reason (the editor's test
1321
+ // proxy shows it), never a missing page.
1322
+ const handler = createApp(refusedFiles, new FakeDatabase())(ROUTE) as RouteHandler
1323
+ for (const method of ['GET', 'POST']) {
1324
+ const reply = { status: 0, body: null as unknown }
1325
+ await handler(
1326
+ { method, url: '/api/automations/dispatch', headers: {} },
1327
+ {
1328
+ status(code: number) {
1329
+ reply.status = code
1330
+ return {
1331
+ json(payload: unknown) {
1332
+ reply.body = payload
1333
+ },
1334
+ }
1335
+ },
1336
+ }
1337
+ )
1338
+ expect(reply).toEqual({ status: 503, body: { error: REASON } })
1339
+ }
1340
+ }
1341
+ })
1342
+ })
1343
+
1344
+ it('keeps the queue tables out of every browser read', () => {
1345
+ for (const table of [
1346
+ 'teleport_automations',
1347
+ 'teleport_event_deliveries',
1348
+ 'teleport_automation_waits',
1349
+ ]) {
1350
+ expect(PROTECTED_TABLES).toContain(table)
1351
+ }
1352
+ })
1353
+ })
1354
+
1355
+ describe('in-app event bus — dispatcher', () => {
1356
+ let files: Map<string, string>
1357
+
1358
+ beforeAll(async () => {
1359
+ files = sources(await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }))
1360
+ })
1361
+
1362
+ const bus = (db: FakeDatabase) => createApp(files, db)('lib/automations/event-bus.js') as Bus
1363
+ const dispatchOnce = (db: FakeDatabase, options: Row = {}) =>
1364
+ withEcho(async (calls) => ({
1365
+ result: await silenced(() => bus(db).dispatch(options)),
1366
+ calls,
1367
+ }))
1368
+
1369
+ it('claims due events with SKIP LOCKED, runs every enabled automation and records each outcome', async () => {
1370
+ const db = new FakeDatabase()
1371
+ const event = seedOrderPaid(db)
1372
+
1373
+ const { result, calls } = await dispatchOnce(db)
1374
+
1375
+ const claim = db.claimStatement()
1376
+ expect(claim).toContain('FOR UPDATE SKIP LOCKED')
1377
+ expect(claim).toContain('LIMIT 20')
1378
+ expect(claim).toContain('e.attempts < 5')
1379
+ expect(claim).toContain("(e.status = 'recorded' AND e.created_at > now() - interval '3 days')")
1380
+ expect(claim).toContain("OR e.status = 'failed'")
1381
+ expect(claim).toContain(
1382
+ 'GREATEST(COALESCE(e.claimed_at, e.created_at), COALESCE((SELECT max(d.started_at) FROM teleport_event_deliveries d WHERE d.event_id = e.id), e.created_at)) ' +
1383
+ "< now() - interval '15 minutes'"
1384
+ )
1385
+ expect(claim).toContain('a.workflow_id = ANY($2::text[]) AND a.enabled')
1386
+ expect(claim).toContain("ORDER BY CASE WHEN e.status = 'recorded' THEN 0 ELSE 1 END")
1387
+ expect(result).toEqual({ claimed: 1, delivered: 1, failed: 1, resumed: 0 })
1388
+
1389
+ // The handler read the hydrated order: the customer and the number come
1390
+ // from teleport_orders, not from the payload, whose other keys are not
1391
+ // blocks of the event and are dropped.
1392
+ const thanks = calls.filter((call) => call.url === ECHO + '/thanks')
1393
+ expect(thanks.map((call) => call.json)).toEqual([
1394
+ { email: 'ava@example.com', number: 'ORD-1042', test: false },
1395
+ ])
1396
+ // Switched off: never run, no delivery row.
1397
+ expect(calls.some((call) => call.url === ECHO + '/off')).toBe(false)
1398
+
1399
+ expect(db.delivery('wf-paid-ok')).toMatchObject({ status: 'delivered', error: null })
1400
+ expect(db.delivery('wf-paid-fail')).toMatchObject({ status: 'failed' })
1401
+ expect(db.delivery('wf-paid-fail')!.error).toContain('network down')
1402
+ expect(db.delivery('wf-paid-off')).toBeUndefined()
1403
+ // Settled from its delivery rows; one attempt, counted when a handler started.
1404
+ expect(db.events.find((e) => e.id === event.id)).toMatchObject({
1405
+ status: 'failed',
1406
+ attempts: 1,
1407
+ })
1408
+ })
1409
+
1410
+ it('reads the order block with its v1 meanings', async () => {
1411
+ const db = new FakeDatabase()
1412
+ seedOrderPaid(db)
1413
+ const app = createApp(files, db)
1414
+ const hydrateOrder = (
1415
+ app('lib/automations/hydrate/order.js') as {
1416
+ hydrate: (client: unknown, row: Row, payload: Row, context: Row) => Promise<Row>
1417
+ }
1418
+ ).hydrate
1419
+ const client = { query: async (sql: string, values: unknown[]) => db.query(sql, values) }
1420
+
1421
+ const blocks = await withEnv(ENV, () =>
1422
+ hydrateOrder(client, db.events[0] as never, {}, { baseUrl: 'https://shop.test' })
1423
+ )
1424
+
1425
+ expect(blocks.order).toMatchObject({
1426
+ number: 'ORD-1042',
1427
+ // Checkout records a provider payment as 'online': the provider is the method.
1428
+ paymentMethod: 'stripe',
1429
+ // The lines at their shop prices (total_price), not what was paid.
1430
+ subtotal: 40,
1431
+ itemCount: 2,
1432
+ total: 53.2,
1433
+ pageUrl: 'https://shop.test/orders/ORD-1042',
1434
+ })
1435
+ expect((blocks.order as Row).items).toEqual([
1436
+ expect.objectContaining({ unitPrice: 18, total: 36 }),
1437
+ ])
1438
+ })
1439
+
1440
+ it('never runs an (event, automation) pair twice: a delivered or running pair is a no-op', async () => {
1441
+ const db = new FakeDatabase()
1442
+ seedOrderPaid(db)
1443
+
1444
+ const first = await dispatchOnce(db)
1445
+ // The retry: the event failed, so it is claimed again.
1446
+ const second = await dispatchOnce(db)
1447
+ expect(second.result).toEqual({ claimed: 1, delivered: 0, failed: 1, resumed: 0 })
1448
+
1449
+ const calls = [...first.calls, ...second.calls]
1450
+ expect(calls.filter((call) => call.url === ECHO + '/thanks')).toHaveLength(1)
1451
+ expect(calls.filter((call) => call.url === ECHO + '/fails')).toHaveLength(2)
1452
+ expect(db.delivery('wf-paid-fail')!.attempts).toBe(2)
1453
+ expect(db.events[0]).toMatchObject({ status: 'failed', attempts: 2 })
1454
+
1455
+ // Another dispatcher is running the pair right now: this one leaves it.
1456
+ const busy = new FakeDatabase()
1457
+ seedOrderPaid(busy)
1458
+ busy.deliveries.push({
1459
+ id: 900,
1460
+ event_id: 'evt-paid',
1461
+ workflow_id: 'wf-paid-ok',
1462
+ status: 'running',
1463
+ error: null,
1464
+ attempts: 1,
1465
+ started_at: new Date(),
1466
+ finished_at: null,
1467
+ })
1468
+ const { calls: busyCalls } = await dispatchOnce(busy)
1469
+ expect(busyCalls.some((call) => call.url === ECHO + '/thanks')).toBe(false)
1470
+ // Still running elsewhere: the event stays claimed, not settled.
1471
+ expect(busy.events[0].status).toBe('pending')
1472
+ })
1473
+
1474
+ it('leaves a handler that outlives its timeout running, and never starts it again until its claim is stale', async () => {
1475
+ const db = new FakeDatabase()
1476
+ db.automations.push(automationRow('wf-booking-slow', 'booking-created'))
1477
+ db.bookings.push({ id: 'b-1', reference: 'BK-1', customer_email: 'liam@example.com' })
1478
+ db.events.push(
1479
+ eventRow({
1480
+ id: 'evt-booking',
1481
+ event_type: 'booking-created',
1482
+ entity_type: 'booking',
1483
+ entity_id: 'b-1',
1484
+ })
1485
+ )
1486
+ const held: Array<() => void> = []
1487
+ const scope = globalThis as Record<string, unknown>
1488
+ const previousFetch = scope.fetch
1489
+ scope.fetch = () =>
1490
+ new Promise((resolve) => {
1491
+ held.push(() =>
1492
+ resolve({
1493
+ ok: true,
1494
+ status: 200,
1495
+ headers: new Map([['content-type', 'application/json']]),
1496
+ json: async () => ({}),
1497
+ text: async () => '{}',
1498
+ })
1499
+ )
1500
+ })
1501
+ const app = createApp(files, db)
1502
+ const run = (options: Row) =>
1503
+ (app('lib/automations/event-bus.js') as Bus).dispatch({ handlerTimeoutMs: 20, ...options })
1504
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 10))
1505
+ try {
1506
+ await withEnv(ENV, () =>
1507
+ silenced(async () => {
1508
+ expect(await run({})).toEqual({ claimed: 1, delivered: 0, failed: 0, resumed: 0 })
1509
+ expect(held).toHaveLength(1)
1510
+ expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'running', error: null })
1511
+ expect(db.events[0]).toMatchObject({ status: 'pending', attempts: 1 })
1512
+
1513
+ // The next kick: the claim is fresh, so nothing starts the handler again.
1514
+ expect(await run({})).toEqual({ claimed: 0, delivered: 0, failed: 0, resumed: 0 })
1515
+ expect(held).toHaveLength(1)
1516
+
1517
+ // Fifteen minutes on, the run never reported back: it is taken over.
1518
+ db.events[0].claimed_at = ago(STALE_MS + MINUTE_MS)
1519
+ db.delivery('wf-booking-slow')!.started_at = ago(STALE_MS + MINUTE_MS)
1520
+ await run({})
1521
+ expect(held).toHaveLength(2)
1522
+ expect(db.delivery('wf-booking-slow')!.attempts).toBe(2)
1523
+
1524
+ // The first run ends at last: its claim is gone, so it writes nothing.
1525
+ held[0]()
1526
+ await settle()
1527
+ expect(db.delivery('wf-booking-slow')!.status).toBe('running')
1528
+ // The second one ends: recorded as delivered.
1529
+ held[1]()
1530
+ await settle()
1531
+ expect(db.delivery('wf-booking-slow')!.status).toBe('delivered')
1532
+ })
1533
+ )
1534
+ } finally {
1535
+ scope.fetch = previousFetch
1536
+ }
1537
+ })
1538
+
1539
+ it('starts nothing once its time budget is spent, and hands the claims back untouched', async () => {
1540
+ const db = new FakeDatabase()
1541
+ seedOrderPaid(db)
1542
+ const { result, calls } = await dispatchOnce(db, { deadlineMs: -1 })
1543
+ expect(result).toEqual({ claimed: 0, delivered: 0, failed: 0, resumed: 0 })
1544
+ expect(calls).toEqual([])
1545
+ expect(db.deliveries).toEqual([])
1546
+ expect(db.events[0]).toMatchObject({ status: 'recorded', attempts: 0 })
1547
+ })
1548
+
1549
+ it('retries a failed event whatever its age, and a new one only within the window', async () => {
1550
+ const db = new FakeDatabase()
1551
+ seedOrderPaid(db)
1552
+ db.events[0].status = 'failed'
1553
+ db.events[0].attempts = 1
1554
+ db.events[0].created_at = ago(5 * DAY_MS)
1555
+ db.automations.forEach((a) => {
1556
+ a.enabled_at = ago(10 * DAY_MS)
1557
+ })
1558
+ db.events.push(
1559
+ eventRow({ id: 'evt-stale', event_type: 'order-paid', created_at: ago(4 * DAY_MS) })
1560
+ )
1561
+ const { result } = await dispatchOnce(db)
1562
+ expect(result.claimed).toBe(1)
1563
+ expect(db.events.find((e) => e.id === 'evt-stale')!.status).toBe('recorded')
1564
+ expect(db.events[0].attempts).toBe(2)
1565
+ })
1566
+
1567
+ it('fails the event, and counts the attempt, when its record cannot be read — never delivers it without', async () => {
1568
+ const db = new FakeDatabase()
1569
+ seedOrderPaid(db)
1570
+ db.ordersError = Object.assign(new Error('connection terminated'), { code: '08006' })
1571
+ const { result, calls } = await dispatchOnce(db)
1572
+ expect(result.delivered).toBe(0)
1573
+ expect(calls).toEqual([])
1574
+ expect(db.events[0]).toMatchObject({ status: 'failed', attempts: 1 })
1575
+
1576
+ // A table the project does not have reads as no record: the event is
1577
+ // refused, never delivered from what the row recorded.
1578
+ const missing = new FakeDatabase()
1579
+ seedOrderPaid(missing)
1580
+ missing.ordersError = Object.assign(new Error('relation "teleport_orders" does not exist'), {
1581
+ code: '42P01',
1582
+ })
1583
+ const refused = await dispatchOnce(missing)
1584
+ expect(refused.result.delivered).toBe(0)
1585
+ expect(refused.calls).toEqual([])
1586
+ expect(missing.delivery('wf-paid-ok')).toMatchObject({
1587
+ status: 'failed',
1588
+ error: 'No such order',
1589
+ })
1590
+ })
1591
+
1592
+ it('passes over an event another dispatcher holds, one recorded before the automation was switched on, and automations this build does not have', async () => {
1593
+ const db = new FakeDatabase()
1594
+ const event = seedOrderPaid(db)
1595
+ event.locked = true
1596
+ db.events.push({
1597
+ ...event,
1598
+ id: 'evt-old',
1599
+ dedupe_key: 'old',
1600
+ locked: false,
1601
+ created_at: ago(2 * DAY_MS),
1602
+ })
1603
+ // On in the table, but not in this build's registry.
1604
+ db.automations.push(automationRow('wf-ghost', 'user-signed-up'))
1605
+ db.events.push(eventRow({ id: 'evt-ghost', event_type: 'user-signed-up' }))
1606
+
1607
+ const { result } = await dispatchOnce(db)
1608
+
1609
+ expect(result.claimed).toBe(0)
1610
+ expect(db.deliveries).toEqual([])
1611
+ // Recorded before the automation was switched on: left as it was.
1612
+ expect(db.events.find((e) => e.id === 'evt-old')!.status).toBe('recorded')
1613
+ })
1614
+
1615
+ it('leaves an event recorded when every automation of it was switched off after the claim', async () => {
1616
+ const db = new FakeDatabase()
1617
+ seedOrderPaid(db)
1618
+ db.afterClaim = () => {
1619
+ db.automations.forEach((a) => {
1620
+ a.enabled = false
1621
+ })
1622
+ }
1623
+ const { result, calls } = await dispatchOnce(db)
1624
+ expect(result).toEqual({ claimed: 1, delivered: 0, failed: 0, resumed: 0 })
1625
+ expect(calls).toEqual([])
1626
+ expect(db.deliveries).toEqual([])
1627
+ // Not delivered: nothing ran for it.
1628
+ expect(db.events[0]).toMatchObject({ status: 'recorded', attempts: 0 })
1629
+
1630
+ // Nothing is read for it either: a record missing meanwhile neither
1631
+ // fails the event nor counts an attempt.
1632
+ const gone = new FakeDatabase()
1633
+ seedOrderPaid(gone)
1634
+ gone.orders = []
1635
+ gone.afterClaim = () => {
1636
+ gone.automations.forEach((a) => {
1637
+ a.enabled = false
1638
+ })
1639
+ }
1640
+ await dispatchOnce(gone)
1641
+ expect(gone.deliveries).toEqual([])
1642
+ expect(gone.events[0]).toMatchObject({ status: 'recorded', attempts: 0 })
1643
+ expect(gone.statements.some((text) => text.includes('FROM teleport_orders'))).toBe(false)
1644
+ })
1645
+
1646
+ it('parks a run at a Wait, and resumes it at the next node once it is due', async () => {
1647
+ const db = new FakeDatabase()
1648
+ seedSignup(db)
1649
+
1650
+ const first = await dispatchOnce(db)
1651
+ expect(first.result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
1652
+ expect(first.calls.map((call) => call.url)).toEqual([ECHO + '/before'])
1653
+ expect(first.calls[0].json).toEqual({ email: 'noah@example.com' })
1654
+ // Not finished: the delivery waits with it, and the event stays open.
1655
+ expect(db.delivery('wf-signup-wait')).toMatchObject({ status: 'waiting', attempts: 1 })
1656
+ expect(db.events[0]).toMatchObject({ status: 'pending', attempts: 1 })
1657
+
1658
+ expect(db.waits).toHaveLength(1)
1659
+ const wait = db.waits[0]
1660
+ expect(wait).toMatchObject({
1661
+ event_id: 'evt-signup',
1662
+ workflow_id: 'wf-signup-wait',
1663
+ resume_node_id: 'after',
1664
+ status: 'waiting',
1665
+ })
1666
+ const due = wait.run_at.getTime() - NOW
1667
+ expect(due).toBeGreaterThan(2 * DAY_MS - MINUTE_MS)
1668
+ expect(due).toBeLessThan(2 * DAY_MS + MINUTE_MS)
1669
+ const saved = JSON.parse(wait.context!)
1670
+ expect(saved.wait).toEqual({ until: wait.run_at.toISOString() })
1671
+ expect(saved.before).toBeDefined()
1672
+ // Nothing of the run's credentials is stored with it.
1673
+ expect(saved.__internalHeaders).toBeUndefined()
1674
+ expect(saved.__baseUrl).toBeUndefined()
1675
+ // The account's password never reaches the event.
1676
+ expect(JSON.stringify(saved)).not.toContain('hash')
1677
+
1678
+ // Not due yet: nothing resumes.
1679
+ expect((await dispatchOnce(db)).result.resumed).toBe(0)
1680
+
1681
+ wait.run_at = ago(1000)
1682
+ const second = await dispatchOnce(db)
1683
+ expect(second.result).toEqual({ claimed: 0, delivered: 0, failed: 0, resumed: 1 })
1684
+ expect(second.calls.map((call) => call.url)).toEqual([ECHO + '/after'])
1685
+ expect(second.calls[0].json).toEqual({
1686
+ email: 'noah@example.com',
1687
+ waitedUntil: saved.wait.until,
1688
+ })
1689
+ expect(wait.status).toBe('done')
1690
+ // The delivery takes the resumed run's outcome; the event settles.
1691
+ expect(db.delivery('wf-signup-wait')).toMatchObject({ status: 'delivered', attempts: 1 })
1692
+ // The Wait and its delivery in one statement, never two a crash could split.
1693
+ const resumeWrites = db.statements.filter(
1694
+ (text) =>
1695
+ text.startsWith('WITH w AS (UPDATE teleport_automation_waits') ||
1696
+ /^UPDATE teleport_automation_waits SET status = \$|^UPDATE teleport_event_deliveries SET status = \$3/.test(
1697
+ text
1698
+ )
1699
+ )
1700
+ expect(resumeWrites).toHaveLength(1)
1701
+ expect(resumeWrites[0]).toContain(
1702
+ 'AND run_at = $3 RETURNING id) UPDATE teleport_event_deliveries SET status = $4'
1703
+ )
1704
+ expect(resumeWrites[0]).toContain("AND status = 'waiting' AND EXISTS (SELECT 1 FROM w)")
1705
+ expect(db.events[0]).toMatchObject({ status: 'delivered', attempts: 1 })
1706
+ })
1707
+
1708
+ it('records a failure after the Wait on the delivery, so Run again and the retries see it', async () => {
1709
+ const db = new FakeDatabase()
1710
+ seedSignup(db)
1711
+ await dispatchOnce(db)
1712
+ db.waits[0].run_at = ago(1000)
1713
+ const resumed = await withEnv(ENV, () =>
1714
+ withFetch(
1715
+ (call) => {
1716
+ if (call.url === ECHO + '/after') {
1717
+ throw new Error('mail provider down')
1718
+ }
1719
+ return { status: 200, body: {} }
1720
+ },
1721
+ () => silenced(() => bus(db).dispatch({}))
1722
+ )
1723
+ )
1724
+ expect(resumed.resumed).toBe(0)
1725
+ expect(db.waits[0].status).toBe('failed')
1726
+ const delivery = db.delivery('wf-signup-wait')!
1727
+ expect(delivery).toMatchObject({ status: 'failed', attempts: 1 })
1728
+ expect(delivery.error).toContain('mail provider down')
1729
+ // The resumed part only: the History's duration starts at the resume.
1730
+ expect(delivery.started_at!.getTime()).toBeGreaterThan(Date.now() - 60 * 1000)
1731
+ expect(db.events[0]).toMatchObject({ status: 'failed', attempts: 1 })
1732
+ })
1733
+
1734
+ it('never takes over an event whose run is parked at a Wait, however long it waits', async () => {
1735
+ const db = new FakeDatabase()
1736
+ seedSignup(db)
1737
+ await dispatchOnce(db)
1738
+ // Days later: the claim and the run's start are long stale.
1739
+ db.events[0].claimed_at = ago(2 * DAY_MS)
1740
+ db.delivery('wf-signup-wait')!.started_at = ago(2 * DAY_MS)
1741
+ const { result, calls } = await dispatchOnce(db)
1742
+ expect(result.claimed).toBe(0)
1743
+ expect(calls).toEqual([])
1744
+ expect(db.delivery('wf-signup-wait')!.status).toBe('waiting')
1745
+ expect(db.events[0].status).toBe('pending')
1746
+ })
1747
+
1748
+ it('takes over a resume left running past the stale window', async () => {
1749
+ const db = new FakeDatabase()
1750
+ seedSignup(db)
1751
+ await dispatchOnce(db)
1752
+ const wait = db.waits[0]
1753
+ wait.status = 'resuming'
1754
+ wait.run_at = ago(5 * MINUTE_MS)
1755
+ expect((await dispatchOnce(db)).result.resumed).toBe(0)
1756
+ wait.run_at = ago(STALE_MS + MINUTE_MS)
1757
+ expect((await dispatchOnce(db)).result.resumed).toBe(1)
1758
+ expect(wait.status).toBe('done')
1759
+ })
1760
+ })
1761
+
1762
+ describe('in-app event bus — Waits', () => {
1763
+ const wait = (id: string, stepNumber: number) => ({
1764
+ id,
1765
+ type: 'general-wait',
1766
+ config: { amount: 1, unit: 'days' },
1767
+ stepNumber,
1768
+ label: 'Wait a day',
1769
+ })
1770
+ const chain = (nodes: Row[]) =>
1771
+ nodes.map((node, i) => ({
1772
+ id: 'e' + i,
1773
+ source: i === 0 ? 't' : String(nodes[i - 1].id),
1774
+ target: String(node.id),
1775
+ }))
1776
+ const welcomeMail = {
1777
+ id: 'mail',
1778
+ type: 'email-resend',
1779
+ config: {
1780
+ apiKey: 'k',
1781
+ from: 'shop@example.com',
1782
+ to: ref('t', ['user', 'email']),
1783
+ subject: 'Welcome',
1784
+ body: '<p>Welcome</p>',
1785
+ },
1786
+ stepNumber: 1,
1787
+ label: 'Welcome',
1788
+ }
1789
+ const afterWait = post('after', '/after', 3, {
1790
+ email: ref('t', ['user', 'email']),
1791
+ waitedUntil: ref('wait', ['until']),
1792
+ })
1793
+ const WAITS: Record<string, Row> = {
1794
+ 'wf-mail-wait': {
1795
+ id: 'wf-mail-wait',
1796
+ name: 'Welcome, then follow up',
1797
+ trigger: trigger('user-signed-up'),
1798
+ nodes: [welcomeMail, wait('wait', 2), afterWait],
1799
+ edges: chain([welcomeMail, wait('wait', 2), afterWait]),
1800
+ },
1801
+ 'wf-trailing': {
1802
+ id: 'wf-trailing',
1803
+ name: 'Welcome, then wait',
1804
+ trigger: trigger('booking-created'),
1805
+ nodes: [post('hello', '/hello', 1), wait('last', 2)],
1806
+ edges: chain([post('hello', '/hello', 1), wait('last', 2)]),
1807
+ },
1808
+ 'wf-note': {
1809
+ id: 'wf-note',
1810
+ name: 'Note the signup',
1811
+ trigger: trigger('user-signed-up'),
1812
+ nodes: [post('note', '/note', 1)],
1813
+ edges: chain([post('note', '/note', 1)]),
1814
+ },
1815
+ }
1816
+ let files: Map<string, string>
1817
+
1818
+ beforeAll(async () => {
1819
+ files = sources(await generate(WAITS))
1820
+ })
1821
+
1822
+ /** One dispatch, every fetch answered by `answer` (200 when it answers nothing). */
1823
+ const dispatchWith = (db: FakeDatabase, answer: (url: string) => boolean = () => true) =>
1824
+ withEnv(ENV, () =>
1825
+ withFetch(
1826
+ (call) => {
1827
+ if (!answer(call.url)) {
1828
+ throw new Error('provider down')
1829
+ }
1830
+ return { status: 200, body: { ok: true } }
1831
+ },
1832
+ async (calls) => ({
1833
+ result: await silenced(() =>
1834
+ (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({})
1835
+ ),
1836
+ calls,
1837
+ })
1838
+ )
1839
+ )
1840
+
1841
+ const seedSignupFor = (db: FakeDatabase, workflowIds: string[]) => {
1842
+ workflowIds.forEach((id) => db.automations.push(automationRow(id, 'user-signed-up')))
1843
+ db.users.push({ id: 'user-1', email: 'noah@example.com', name: 'Noah', created_at: ago(1000) })
1844
+ db.events.push(
1845
+ eventRow({
1846
+ id: 'evt-signup',
1847
+ event_type: 'user-signed-up',
1848
+ entity_type: 'user',
1849
+ entity_id: 'user-1',
1850
+ actor: 'noah@example.com',
1851
+ actor_type: 'customer',
1852
+ })
1853
+ )
1854
+ }
1855
+
1856
+ it('retries a run that failed after its Wait from the step after it: what ran before is never repeated', async () => {
1857
+ const db = new FakeDatabase()
1858
+ seedSignupFor(db, ['wf-mail-wait'])
1859
+
1860
+ await dispatchWith(db)
1861
+ expect(db.mailSent).toHaveLength(1)
1862
+ expect(db.waits).toHaveLength(1)
1863
+ const parked = db.waits[0]
1864
+ const { wait: waited } = JSON.parse(parked.context!)
1865
+
1866
+ // Due, and the step after the Wait fails.
1867
+ parked.run_at = ago(1000)
1868
+ await dispatchWith(db, (url) => url !== ECHO + '/after')
1869
+ expect(parked.status).toBe('failed')
1870
+ expect(db.delivery('wf-mail-wait')).toMatchObject({ status: 'failed', attempts: 1 })
1871
+ expect(db.events[0]).toMatchObject({ status: 'failed', attempts: 1 })
1872
+
1873
+ // The retry resumes after the Wait, with what the run had stored.
1874
+ const retry = await dispatchWith(db)
1875
+ expect(retry.result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
1876
+ expect(retry.calls.map((call) => [call.url, call.json])).toEqual([
1877
+ [ECHO + '/after', { email: 'noah@example.com', waitedUntil: waited.until }],
1878
+ ])
1879
+ // The welcome email before the Wait went out once.
1880
+ expect(db.mailSent).toHaveLength(1)
1881
+ expect(db.waits).toHaveLength(1)
1882
+ expect(parked.status).toBe('done')
1883
+ expect(db.delivery('wf-mail-wait')).toMatchObject({ status: 'delivered', attempts: 2 })
1884
+ expect(db.events[0]).toMatchObject({ status: 'delivered', attempts: 2 })
1885
+ })
1886
+
1887
+ it('keeps resuming a retry from the same Wait while it fails, and starts a first failure over', async () => {
1888
+ const db = new FakeDatabase()
1889
+ seedSignupFor(db, ['wf-mail-wait'])
1890
+ await dispatchWith(db)
1891
+ db.waits[0].run_at = ago(1000)
1892
+ const down = (url: string) => url !== ECHO + '/after'
1893
+ await dispatchWith(db, down)
1894
+ const again = await dispatchWith(db, down)
1895
+ expect(again.calls.map((call) => call.url)).toEqual([ECHO + '/after'])
1896
+ expect(db.waits[0].status).toBe('failed')
1897
+ expect(db.mailSent).toHaveLength(1)
1898
+ expect(db.delivery('wf-mail-wait')).toMatchObject({ status: 'failed', attempts: 2 })
1899
+
1900
+ // A run that failed before reaching any Wait is run again from its start.
1901
+ const early = new FakeDatabase()
1902
+ seedSignupFor(early, ['wf-note'])
1903
+ await dispatchWith(early, (url) => url !== ECHO + '/note')
1904
+ expect(early.events[0].status).toBe('failed')
1905
+ const rerun = await dispatchWith(early)
1906
+ expect(rerun.calls.map((call) => call.url)).toEqual([ECHO + '/note'])
1907
+ expect(early.delivery('wf-note')).toMatchObject({ status: 'delivered', attempts: 2 })
1908
+ })
1909
+
1910
+ /** Every fetch answered at once, except /after's, which waits for its release. */
1911
+ const holdAfter = () => {
1912
+ const held: Array<() => void> = []
1913
+ const answer = {
1914
+ ok: true,
1915
+ status: 200,
1916
+ headers: new Map([['content-type', 'application/json']]),
1917
+ json: async () => ({}),
1918
+ text: async () => '{}',
1919
+ }
1920
+ const fetch = (url: string) =>
1921
+ url === ECHO + '/after'
1922
+ ? new Promise((resolve) => held.push(() => resolve(answer)))
1923
+ : Promise.resolve(answer)
1924
+ return { held, fetch }
1925
+ }
1926
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 10))
1927
+
1928
+ /**
1929
+ * A dispatch whose handlers are waited for 20 ms, every fetch answered by
1930
+ * `fetch`, then `afterwards` (in the same environment: a late run writes
1931
+ * its outcome on the database the app is configured with).
1932
+ */
1933
+ const slowDispatch = async (
1934
+ db: FakeDatabase,
1935
+ fetch: unknown,
1936
+ afterwards: () => Promise<void>
1937
+ ) => {
1938
+ const scope = globalThis as Record<string, unknown>
1939
+ const previous = scope.fetch
1940
+ scope.fetch = fetch
1941
+ try {
1942
+ await withEnv(ENV, async () => {
1943
+ await silenced(() =>
1944
+ (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({
1945
+ handlerTimeoutMs: 20,
1946
+ })
1947
+ )
1948
+ await silenced(afterwards)
1949
+ })
1950
+ } finally {
1951
+ scope.fetch = previous
1952
+ }
1953
+ }
1954
+
1955
+ /** A run of wf-mail-wait whose part after the Wait failed once. */
1956
+ const failedAfterWait = async () => {
1957
+ const db = new FakeDatabase()
1958
+ seedSignupFor(db, ['wf-mail-wait'])
1959
+ await dispatchWith(db)
1960
+ db.waits[0].run_at = ago(1000)
1961
+ await dispatchWith(db, (url) => url !== ECHO + '/after')
1962
+ expect(db.waits[0].status).toBe('failed')
1963
+ return db
1964
+ }
1965
+
1966
+ it('settles the Wait of a late retry only when that retry still owns its delivery', async () => {
1967
+ for (const takenOver of [true, false]) {
1968
+ const db = await failedAfterWait()
1969
+ const slow = holdAfter()
1970
+ const delivery = () => db.delivery('wf-mail-wait')!
1971
+ // The retry resumes after the Wait and outlives the dispatcher's patience.
1972
+ await slowDispatch(db, slow.fetch, async () => {
1973
+ expect(slow.held).toHaveLength(1)
1974
+ expect(delivery()).toMatchObject({ status: 'running', attempts: 2 })
1975
+ if (takenOver) {
1976
+ // Another dispatcher took the stale run over: the delivery is its.
1977
+ delivery().attempts = 3
1978
+ }
1979
+ slow.held[0]()
1980
+ await settle()
1981
+ })
1982
+ expect({ takenOver, wait: db.waits[0].status, delivery: delivery().status }).toEqual(
1983
+ takenOver
1984
+ ? { takenOver, wait: 'failed', delivery: 'running' }
1985
+ : { takenOver, wait: 'done', delivery: 'delivered' }
1986
+ )
1987
+ }
1988
+ })
1989
+
1990
+ it("records a late resume's outcome only while its claim still holds the Wait", async () => {
1991
+ for (const takenOver of [true, false]) {
1992
+ const db = new FakeDatabase()
1993
+ seedSignupFor(db, ['wf-mail-wait'])
1994
+ await dispatchWith(db)
1995
+ const parked = db.waits[0]
1996
+ parked.run_at = ago(1000)
1997
+ const slow = holdAfter()
1998
+ await slowDispatch(db, slow.fetch, async () => {
1999
+ expect(slow.held).toHaveLength(1)
2000
+ expect(parked.status).toBe('resuming')
2001
+ if (takenOver) {
2002
+ // Stale, and claimed again by another dispatcher: a new run_at token.
2003
+ parked.run_at = new Date(parked.run_at.getTime() + 1000)
2004
+ }
2005
+ slow.held[0]()
2006
+ await settle()
2007
+ })
2008
+ const delivery = db.delivery('wf-mail-wait')!
2009
+ expect({ takenOver, wait: parked.status, delivery: delivery.status }).toEqual(
2010
+ takenOver
2011
+ ? { takenOver, wait: 'resuming', delivery: 'waiting' }
2012
+ : { takenOver, wait: 'done', delivery: 'delivered' }
2013
+ )
2014
+ }
2015
+ })
2016
+
2017
+ it('ends a run whose last step is a Wait: nothing is parked, the event is delivered', async () => {
2018
+ const db = new FakeDatabase()
2019
+ db.automations.push(automationRow('wf-trailing', 'booking-created'))
2020
+ db.bookings.push({ id: 'bk-1', reference: 'BK-1', status: 'pending', customer_email: 'a@b.c' })
2021
+ db.events.push(
2022
+ eventRow({
2023
+ id: 'evt-booking',
2024
+ event_type: 'booking-created',
2025
+ entity_type: 'booking',
2026
+ entity_id: 'bk-1',
2027
+ })
2028
+ )
2029
+ const { result, calls } = await dispatchWith(db)
2030
+ expect(result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
2031
+ expect(calls.map((call) => call.url)).toEqual([ECHO + '/hello'])
2032
+ expect(db.waits).toEqual([])
2033
+ expect(db.delivery('wf-trailing')).toMatchObject({ status: 'delivered' })
2034
+ expect(db.events[0]).toMatchObject({ status: 'delivered' })
2035
+ })
2036
+
2037
+ it('lets a run parked by an automation that is now off no longer hold back the retry of another', async () => {
2038
+ const db = new FakeDatabase()
2039
+ seedSignupFor(db, ['wf-mail-wait', 'wf-note'])
2040
+ // The note fails; the welcome parks at its Wait.
2041
+ await dispatchWith(db, (url) => url !== ECHO + '/note')
2042
+ expect(db.delivery('wf-mail-wait')!.status).toBe('waiting')
2043
+ expect(db.delivery('wf-note')!.status).toBe('failed')
2044
+ expect(db.events[0].status).toBe('pending')
2045
+
2046
+ // Long after, with the welcome automation still on, its parked run holds the event.
2047
+ const longAgo = ago(STALE_MS + MINUTE_MS)
2048
+ db.events[0].claimed_at = longAgo
2049
+ db.deliveries.forEach((d) => {
2050
+ d.started_at = longAgo
2051
+ })
2052
+ expect((await dispatchWith(db)).result.claimed).toBe(0)
2053
+
2054
+ // Switched off: its run stays parked, and the note is retried.
2055
+ db.automations.find((a) => a.workflow_id === 'wf-mail-wait')!.enabled = false
2056
+ const retried = await dispatchWith(db)
2057
+ expect(db.claimStatement()).toContain(
2058
+ "d.status = 'waiting' AND d.workflow_id = ANY($2::text[]) AND EXISTS (SELECT 1 FROM teleport_automations a WHERE a.workflow_id = d.workflow_id AND a.enabled)"
2059
+ )
2060
+ expect(retried.result).toEqual({ claimed: 1, delivered: 1, failed: 0, resumed: 0 })
2061
+ expect(retried.calls.map((call) => call.url)).toEqual([ECHO + '/note'])
2062
+ expect(db.delivery('wf-note')).toMatchObject({ status: 'delivered', attempts: 2 })
2063
+ expect(db.delivery('wf-mail-wait')!.status).toBe('waiting')
2064
+ expect(db.waits[0].status).toBe('waiting')
2065
+ expect(db.events[0].status).toBe('delivered')
2066
+ })
2067
+ })
2068
+
2069
+ describe('in-app event bus — claims that outlive their run', () => {
2070
+ let files: Map<string, string>
2071
+
2072
+ beforeAll(async () => {
2073
+ files = sources(await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }))
2074
+ })
2075
+
2076
+ const okResponse = () => ({
2077
+ ok: true,
2078
+ status: 200,
2079
+ headers: new Map([['content-type', 'application/json']]),
2080
+ json: async () => ({}),
2081
+ text: async () => '{}',
2082
+ })
2083
+
2084
+ /** Every fetch waits until released, then answers 200. */
2085
+ const holdFetches = () => {
2086
+ const held: Array<() => void> = []
2087
+ const urls: string[] = []
2088
+ const scope = globalThis as Record<string, unknown>
2089
+ const previous = scope.fetch
2090
+ scope.fetch = (url: string) => {
2091
+ urls.push(url)
2092
+ return new Promise((resolve) => {
2093
+ held.push(() => resolve(okResponse()))
2094
+ })
2095
+ }
2096
+ return { held, urls, restore: () => (scope.fetch = previous) }
2097
+ }
2098
+
2099
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 10))
2100
+
2101
+ const seedBooking = (db: FakeDatabase): EventRow => {
2102
+ db.automations.push(automationRow('wf-booking-slow', 'booking-created'))
2103
+ db.bookings.push({
2104
+ id: 'b-1',
2105
+ reference: 'BK-1',
2106
+ status: 'confirmed',
2107
+ customer_email: 'liam@example.com',
2108
+ starts_at: ago(-DAY_MS),
2109
+ ends_at: ago(-DAY_MS - 60 * MINUTE_MS),
2110
+ })
2111
+ const event = eventRow({
2112
+ id: 'evt-booking',
2113
+ event_type: 'booking-created',
2114
+ entity_type: 'booking',
2115
+ entity_id: 'b-1',
2116
+ })
2117
+ db.events.push(event)
2118
+ return event
2119
+ }
2120
+
2121
+ it('settles an event whose last attempt never reported back, starting no handler', async () => {
2122
+ const db = new FakeDatabase()
2123
+ const event = seedBooking(db)
2124
+ event.status = 'pending'
2125
+ event.attempts = 5
2126
+ event.claimed_at = ago(5 * MINUTE_MS)
2127
+ db.deliveries.push({
2128
+ id: 700,
2129
+ event_id: event.id,
2130
+ workflow_id: 'wf-booking-slow',
2131
+ status: 'running',
2132
+ error: null,
2133
+ attempts: 5,
2134
+ started_at: ago(5 * MINUTE_MS),
2135
+ finished_at: null,
2136
+ })
2137
+ const dispatch = () =>
2138
+ withEcho(async (recorded) => ({
2139
+ result: await silenced(() =>
2140
+ (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({})
2141
+ ),
2142
+ calls: recorded,
2143
+ }))
2144
+
2145
+ // Inside the stale window: left alone.
2146
+ await dispatch()
2147
+ expect(event.status).toBe('pending')
2148
+ expect(db.delivery('wf-booking-slow')!.status).toBe('running')
2149
+
2150
+ event.claimed_at = ago(STALE_MS + MINUTE_MS)
2151
+ db.delivery('wf-booking-slow')!.started_at = ago(STALE_MS + MINUTE_MS)
2152
+ const { result, calls } = await dispatch()
2153
+ expect(calls).toEqual([])
2154
+ expect(result).toEqual({ claimed: 0, delivered: 0, failed: 0, resumed: 0 })
2155
+ expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'failed', error: 'Timed out' })
2156
+ expect(event).toMatchObject({ status: 'failed', attempts: 5 })
2157
+ })
2158
+
2159
+ const exhausted = (db: FakeDatabase, deliveryStatus: string) => {
2160
+ const event = seedBooking(db)
2161
+ event.status = 'pending'
2162
+ event.attempts = 5
2163
+ event.claimed_at = ago(STALE_MS + MINUTE_MS)
2164
+ db.deliveries.push({
2165
+ id: 701,
2166
+ event_id: event.id,
2167
+ workflow_id: 'wf-booking-slow',
2168
+ status: deliveryStatus,
2169
+ error: null,
2170
+ attempts: 5,
2171
+ started_at: ago(STALE_MS + MINUTE_MS),
2172
+ finished_at: deliveryStatus === 'running' ? null : ago(STALE_MS),
2173
+ })
2174
+ return event
2175
+ }
2176
+ const dispatchWith = (db: FakeDatabase, options: Row = {}) =>
2177
+ withEcho(async (calls) => ({
2178
+ result: await silenced(() =>
2179
+ (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch(options)
2180
+ ),
2181
+ calls,
2182
+ }))
2183
+
2184
+ it('settles an exhausted event from its delivery rows: a 5th attempt that finished late ends delivered', async () => {
2185
+ const db = new FakeDatabase()
2186
+ const event = exhausted(db, 'delivered')
2187
+ const { calls } = await dispatchWith(db)
2188
+ expect(calls).toEqual([])
2189
+ expect(db.delivery('wf-booking-slow')!.status).toBe('delivered')
2190
+ expect(event).toMatchObject({ status: 'delivered', attempts: 5 })
2191
+ })
2192
+
2193
+ it('times out the runs of an exhausted event even out of time, so no final event keeps a running row', async () => {
2194
+ const db = new FakeDatabase()
2195
+ const event = exhausted(db, 'running')
2196
+ const { calls } = await dispatchWith(db, { deadlineMs: -1 })
2197
+ expect(calls).toEqual([])
2198
+ expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'failed', error: 'Timed out' })
2199
+ expect(event.status).toBe('failed')
2200
+ expect(db.deliveries.filter((d) => d.status === 'running')).toEqual([])
2201
+ })
2202
+
2203
+ it('lets a dispatcher that ran out of time hand back only the Waits it still holds', async () => {
2204
+ const db = new FakeDatabase()
2205
+ seedSignup(db)
2206
+ db.events[0].status = 'delivered'
2207
+ for (const id of [1, 2]) {
2208
+ db.waits.push({
2209
+ id,
2210
+ event_id: 'evt-signup',
2211
+ workflow_id: 'wf-signup-wait',
2212
+ resume_node_id: 'after',
2213
+ context: JSON.stringify({ wait: { until: ago(MINUTE_MS).toISOString() } }),
2214
+ run_at: ago(MINUTE_MS),
2215
+ status: 'waiting',
2216
+ created_at: ago(DAY_MS),
2217
+ })
2218
+ }
2219
+ const fetches = holdFetches()
2220
+ const first = createApp(files, db)('lib/automations/event-bus.js') as Bus
2221
+ const second = createApp(files, db)('lib/automations/event-bus.js') as Bus
2222
+ try {
2223
+ await withEnv(ENV, () =>
2224
+ silenced(async () => {
2225
+ // The first dispatcher claims both Waits and freezes on the first resume.
2226
+ const frozen = first.dispatch({ deadlineMs: 30, handlerTimeoutMs: 60000 })
2227
+ await settle()
2228
+ expect(db.waits.map((w) => w.status)).toEqual(['resuming', 'resuming'])
2229
+ expect(fetches.urls).toHaveLength(1)
2230
+
2231
+ // Long enough later, a second dispatcher takes the other Wait over.
2232
+ const other = db.waits[1]
2233
+ other.run_at = ago(STALE_MS + MINUTE_MS)
2234
+ await second.dispatch({ handlerTimeoutMs: 20 })
2235
+ expect(fetches.urls).toHaveLength(2)
2236
+ const takeover = other.run_at
2237
+
2238
+ // The first wakes past its budget and hands back what it did not
2239
+ // start: that Wait is no longer its own, so it writes nothing.
2240
+ await new Promise((resolve) => setTimeout(resolve, 40))
2241
+ fetches.held[0]()
2242
+ await frozen
2243
+ expect(db.waits[0].status).toBe('done')
2244
+ expect(other).toMatchObject({ status: 'resuming', run_at: takeover })
2245
+
2246
+ fetches.held[1]()
2247
+ await settle()
2248
+ expect(other.status).toBe('done')
2249
+ })
2250
+ )
2251
+ } finally {
2252
+ fetches.restore()
2253
+ }
2254
+ })
2255
+
2256
+ it('takes an event over within one stale window: not while one of its deliveries started recently', async () => {
2257
+ const db = new FakeDatabase()
2258
+ const event = seedOrderPaid(db)
2259
+ event.status = 'pending'
2260
+ event.attempts = 1
2261
+ event.claimed_at = ago(STALE_MS + 5 * MINUTE_MS)
2262
+ db.deliveries.push({
2263
+ id: 800,
2264
+ event_id: event.id,
2265
+ workflow_id: 'wf-paid-ok',
2266
+ status: 'running',
2267
+ error: null,
2268
+ attempts: 1,
2269
+ started_at: ago(5 * MINUTE_MS),
2270
+ finished_at: null,
2271
+ })
2272
+ const dispatch = () =>
2273
+ withEcho(async (recorded) => ({
2274
+ result: await silenced(() =>
2275
+ (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({})
2276
+ ),
2277
+ calls: recorded,
2278
+ }))
2279
+
2280
+ // Its claim is stale, but a delivery started five minutes ago.
2281
+ expect((await dispatch()).result.claimed).toBe(0)
2282
+
2283
+ // Once that start is stale too, one takeover runs everything again.
2284
+ db.delivery('wf-paid-ok')!.started_at = ago(STALE_MS + MINUTE_MS)
2285
+ const { result, calls } = await dispatch()
2286
+ expect(result.claimed).toBe(1)
2287
+ expect(calls.filter((call) => call.url === ECHO + '/thanks')).toHaveLength(1)
2288
+ expect(db.delivery('wf-paid-ok')).toMatchObject({ status: 'delivered', attempts: 2 })
2289
+ })
2290
+
2291
+ it('lets a run that froze and woke after a takeover write nothing over it', async () => {
2292
+ const db = new FakeDatabase()
2293
+ const event = seedBooking(db)
2294
+ event.created_at = ago(2000)
2295
+ // A second event the first dispatcher claimed and never reached.
2296
+ const later = seedSignup(db)
2297
+ const fetches = holdFetches()
2298
+ // Two processes sharing one database.
2299
+ const first = createApp(files, db)('lib/automations/event-bus.js') as Bus
2300
+ const second = createApp(files, db)('lib/automations/event-bus.js') as Bus
2301
+ try {
2302
+ await withEnv(ENV, () =>
2303
+ silenced(async () => {
2304
+ // The first dispatcher claims both and waits for its first handler (it froze there).
2305
+ const frozen = first.dispatch({ deadlineMs: 30, handlerTimeoutMs: 60000 })
2306
+ await settle()
2307
+ expect(fetches.urls).toHaveLength(1)
2308
+ expect(later.status).toBe('pending')
2309
+ const firstClaim = event.claimed_at
2310
+
2311
+ // Long enough later, a second dispatcher takes both over.
2312
+ for (const stale of [event, later]) {
2313
+ stale.claimed_at = ago(STALE_MS + MINUTE_MS)
2314
+ }
2315
+ db.delivery('wf-booking-slow')!.started_at = ago(STALE_MS + MINUTE_MS)
2316
+ await second.dispatch({ handlerTimeoutMs: 20 })
2317
+ expect(fetches.urls).toHaveLength(3)
2318
+ expect(event.claimed_at).not.toBe(firstClaim)
2319
+ expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'running', attempts: 2 })
2320
+ const takeover = { event: event.claimed_at, later: later.claimed_at }
2321
+
2322
+ // The first run wakes: its claims are gone, it writes nothing — not
2323
+ // the delivery, not the event, not the hand-back of the event it
2324
+ // never reached.
2325
+ fetches.held[0]()
2326
+ await frozen
2327
+ expect(db.delivery('wf-booking-slow')).toMatchObject({ status: 'running', attempts: 2 })
2328
+ expect(event).toMatchObject({ status: 'pending', claimed_at: takeover.event })
2329
+ expect(later).toMatchObject({ status: 'pending', claimed_at: takeover.later })
2330
+
2331
+ // The runs of the takeover end: recorded.
2332
+ fetches.held.slice(1).forEach((release) => release())
2333
+ await settle()
2334
+ expect(db.delivery('wf-booking-slow')!.status).toBe('delivered')
2335
+ })
2336
+ )
2337
+ } finally {
2338
+ fetches.restore()
2339
+ }
2340
+ })
2341
+
2342
+ it('starts nothing for a call that joins once the time budget is spent: the next kick takes it', async () => {
2343
+ const db = new FakeDatabase()
2344
+ seedBooking(db)
2345
+ const fetches = holdFetches()
2346
+ const app = createApp(files, db)('lib/automations/event-bus.js') as Bus
2347
+ try {
2348
+ await withEnv(ENV, () =>
2349
+ silenced(async () => {
2350
+ const running = app.dispatch({ deadlineMs: 30, handlerTimeoutMs: 60000 })
2351
+ await settle()
2352
+ // Joins the dispatch already running: the same promise, no new claim yet.
2353
+ const joined = app.dispatch({})
2354
+ expect(joined).toBe(running)
2355
+ await new Promise((resolve) => setTimeout(resolve, 40))
2356
+ fetches.held[0]()
2357
+ await running
2358
+ await settle()
2359
+ const claims = db.statements.filter(
2360
+ (text) => text.includes('FROM teleport_events e') && text.includes('SKIP LOCKED')
2361
+ )
2362
+ expect(claims).toHaveLength(1)
2363
+ })
2364
+ )
2365
+ } finally {
2366
+ fetches.restore()
2367
+ }
2368
+ })
2369
+
2370
+ it('fails a Wait inside a loop the same way in a test as in a live run', async () => {
2371
+ const loopFiles = sources(
2372
+ await generate({
2373
+ 'wf-loop-wait': {
2374
+ id: 'wf-loop-wait',
2375
+ name: 'Wait in a loop',
2376
+ trigger: trigger('form-submitted'),
2377
+ nodes: [
2378
+ {
2379
+ id: 'loop',
2380
+ type: 'general-loop',
2381
+ config: { collection: [1, 2] },
2382
+ stepNumber: 1,
2383
+ label: 'Each',
2384
+ },
2385
+ {
2386
+ id: 'wait',
2387
+ type: 'general-wait',
2388
+ config: { amount: 1, unit: 'hours' },
2389
+ stepNumber: 2,
2390
+ label: 'Wait',
2391
+ },
2392
+ ],
2393
+ edges: [
2394
+ { id: 'e1', source: 't', target: 'loop' },
2395
+ { id: 'e2', source: 'loop', target: 'wait', sourceHandle: 'loop' },
2396
+ ],
2397
+ },
2398
+ })
2399
+ )
2400
+ const db = new FakeDatabase()
2401
+ db.automations.push(automationRow('wf-loop-wait', 'form-submitted'))
2402
+ db.events.push(eventRow({ id: 'evt-form', event_type: 'form-submitted' }))
2403
+ const bus = () =>
2404
+ createApp(loopFiles, db)('lib/automations/event-bus.js') as Bus & {
2405
+ runTestEvent(input: Row, req?: Row): Promise<Totals>
2406
+ }
2407
+
2408
+ await withEcho(() => silenced(() => bus().dispatch({})))
2409
+ const live = db.delivery('wf-loop-wait')!
2410
+ expect(live.status).toBe('failed')
2411
+ expect(live.error).toBe('A Wait cannot run inside a loop')
2412
+
2413
+ const tested = await withEcho(() =>
2414
+ silenced(() =>
2415
+ bus().runTestEvent({
2416
+ workflowId: 'wf-loop-wait',
2417
+ event: { event: { type: 'form-submitted' }, form: { id: 'f-1', name: 'Contact' } },
2418
+ })
2419
+ )
2420
+ )
2421
+ expect(tested).toEqual({ claimed: 1, delivered: 0, failed: 1, resumed: 0 })
2422
+ const testRun = db.deliveries.find((d) => d.status === 'test')!
2423
+ expect(testRun.error).toBe('A Wait cannot run inside a loop')
2424
+ expect(db.waits).toEqual([])
2425
+ })
2426
+ })
2427
+
2428
+ describe('in-app event bus — acts only on what the database stands behind', () => {
2429
+ // A row of the ledger can be written by a caller (the shared writer's
2430
+ // segment is reachable from a browser); the record it names cannot.
2431
+ const ATTACKER = 'attacker@evil.test'
2432
+ const greet = (id: string, eventType: string, emailPath: string[]): Row => ({
2433
+ id,
2434
+ name: 'Greet ' + eventType,
2435
+ trigger: trigger(eventType),
2436
+ nodes: [
2437
+ post('greet', '/greet/' + eventType, 1, {
2438
+ email: ref('t', emailPath),
2439
+ actor: ref('t', ['actor', 'email']),
2440
+ }),
2441
+ ],
2442
+ edges: [{ id: 'e1', source: 't', target: 'greet' }],
2443
+ })
2444
+ const GREETERS: Record<string, Row> = {
2445
+ 'wf-review': greet('wf-review', 'review-submitted', ['review', 'authorEmail']),
2446
+ 'wf-news': greet('wf-news', 'newsletter-subscribed', ['subscriber', 'email']),
2447
+ 'wf-wl': greet('wf-wl', 'waitlist-joined', ['waitlist', 'entry', 'email']),
2448
+ 'wf-form': greet('wf-form', 'form-submitted', ['form', 'fields', 'email']),
2449
+ 'wf-bye': {
2450
+ id: 'wf-bye',
2451
+ name: 'Say goodbye',
2452
+ trigger: trigger('user-deleted'),
2453
+ nodes: [
2454
+ post('bye', '/bye', 1, {
2455
+ id: ref('t', ['user', 'id']),
2456
+ email: ref('t', ['user', 'email']),
2457
+ name: ref('t', ['user', 'name']),
2458
+ actor: ref('t', ['actor', 'email']),
2459
+ }),
2460
+ ],
2461
+ edges: [{ id: 'e1', source: 't', target: 'bye' }],
2462
+ },
2463
+ 'wf-low': {
2464
+ id: 'wf-low',
2465
+ name: 'Restock',
2466
+ trigger: trigger('product-low-stock'),
2467
+ nodes: [
2468
+ post('low', '/low', 1, {
2469
+ name: ref('t', ['product', 'name']),
2470
+ stock: ref('t', ['product', 'stock']),
2471
+ threshold: ref('t', ['product', 'threshold']),
2472
+ sku: ref('t', ['product', 'sku']),
2473
+ }),
2474
+ ],
2475
+ edges: [{ id: 'e1', source: 't', target: 'low' }],
2476
+ },
2477
+ 'wf-source': {
2478
+ id: 'wf-source',
2479
+ name: 'Where they came from',
2480
+ trigger: trigger('user-signed-up'),
2481
+ nodes: [post('source', '/source', 1, { source: ref('t', ['user', 'source']) })],
2482
+ edges: [{ id: 'e1', source: 't', target: 'source' }],
2483
+ },
2484
+ }
2485
+ let files: Map<string, string>
2486
+ let orderFiles: Map<string, string>
2487
+
2488
+ beforeAll(async () => {
2489
+ files = sources(await generate(GREETERS))
2490
+ orderFiles = sources(await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }))
2491
+ })
2492
+
2493
+ const dispatchIn = (target: Map<string, string>, db: FakeDatabase) =>
2494
+ withEcho(async (calls) => ({
2495
+ result: await silenced(() =>
2496
+ (createApp(target, db)('lib/automations/event-bus.js') as Bus).dispatch({})
2497
+ ),
2498
+ calls,
2499
+ }))
2500
+
2501
+ it('lays the record over what the row recorded, and takes the actor from the record', async () => {
2502
+ const db = new FakeDatabase()
2503
+ const event = seedOrderPaid(db)
2504
+ event.actor = ATTACKER
2505
+ event.actor_type = 'customer'
2506
+ event.payload = JSON.stringify({
2507
+ order: { customer: { email: ATTACKER, name: 'Mallory' }, giftMessage: 'kept' },
2508
+ })
2509
+
2510
+ const { calls } = await dispatchIn(orderFiles, db)
2511
+
2512
+ const thanks = calls.find((call) => call.url === ECHO + '/thanks')!
2513
+ expect(thanks.json).toEqual({
2514
+ email: 'ava@example.com',
2515
+ number: 'ORD-1042',
2516
+ test: false,
2517
+ giftMessage: 'kept',
2518
+ })
2519
+ const warehouse = calls.find((call) => call.url === ECHO + '/fails')!
2520
+ expect(warehouse.json).toEqual({ actor: 'ava@example.com', email: 'ava@example.com' })
2521
+ expect(JSON.stringify(calls.map((call) => call.json))).not.toContain(ATTACKER)
2522
+ })
2523
+
2524
+ it('refuses an order event whose order is not there: no automation runs, each delivery says why', async () => {
2525
+ const db = new FakeDatabase()
2526
+ const event = seedOrderPaid(db)
2527
+ event.order_id = 'ord-forged'
2528
+ event.entity_id = 'ord-forged'
2529
+ event.payload = JSON.stringify({ order: { number: 'ORD-9', customer: { email: ATTACKER } } })
2530
+
2531
+ const { result, calls } = await dispatchIn(orderFiles, db)
2532
+
2533
+ expect(calls).toEqual([])
2534
+ expect(result).toEqual({ claimed: 1, delivered: 0, failed: 2, resumed: 0 })
2535
+ expect(db.deliveries.map((d) => [d.workflow_id, d.status, d.error, d.attempts])).toEqual([
2536
+ ['wf-paid-ok', 'failed', 'No such order', 0],
2537
+ ['wf-paid-fail', 'failed', 'No such order', 0],
2538
+ ])
2539
+ // Failed and counted, so a forged row is given up after five tries.
2540
+ expect(event).toMatchObject({ status: 'failed', attempts: 1 })
2541
+ })
2542
+
2543
+ const seedGreeters = (db: FakeDatabase) => {
2544
+ for (const [workflowId, eventType] of [
2545
+ ['wf-review', 'review-submitted'],
2546
+ ['wf-news', 'newsletter-subscribed'],
2547
+ ['wf-wl', 'waitlist-joined'],
2548
+ ['wf-form', 'form-submitted'],
2549
+ ]) {
2550
+ db.automations.push(automationRow(workflowId, eventType))
2551
+ }
2552
+ db.reviews.push({
2553
+ id: 'rev-1',
2554
+ product_id: 'p-1',
2555
+ rating: 5,
2556
+ reviewer_email: 'mia@example.com',
2557
+ })
2558
+ db.users.push({
2559
+ id: 'user-9',
2560
+ email: 'zoe@example.com',
2561
+ source: 'newsletter',
2562
+ marketing_consent: true,
2563
+ })
2564
+ db.waitlists.push({ id: 'wl-1', kind: 'stock', target_id: 'p-1', target_url: '/products/p-1' })
2565
+ db.waitlistEntries.push({
2566
+ id: 'we-1',
2567
+ waitlist_id: 'wl-1',
2568
+ email: 'noor@example.com',
2569
+ status: 'confirmed',
2570
+ position: 3,
2571
+ })
2572
+ }
2573
+
2574
+ const forgedPayloads: Record<string, Row> = {
2575
+ review: { review: { authorEmail: ATTACKER } },
2576
+ subscriber: { subscriber: { email: ATTACKER } },
2577
+ waitlist: { waitlist: { entry: { email: ATTACKER } } },
2578
+ }
2579
+
2580
+ it('checks every kind of record before an automation runs: a review, a subscriber, a waitlist entry', async () => {
2581
+ const real = new FakeDatabase()
2582
+ seedGreeters(real)
2583
+ real.events.push(
2584
+ eventRow({
2585
+ id: 'e-rev',
2586
+ event_type: 'review-submitted',
2587
+ entity_type: 'review',
2588
+ entity_id: 'rev-1',
2589
+ payload: JSON.stringify(forgedPayloads.review),
2590
+ actor: ATTACKER,
2591
+ }),
2592
+ eventRow({
2593
+ id: 'e-news',
2594
+ event_type: 'newsletter-subscribed',
2595
+ entity_type: 'subscriber',
2596
+ entity_id: 'user-9',
2597
+ payload: JSON.stringify(forgedPayloads.subscriber),
2598
+ actor: ATTACKER,
2599
+ }),
2600
+ eventRow({
2601
+ id: 'e-wl',
2602
+ event_type: 'waitlist-joined',
2603
+ entity_type: 'waitlist',
2604
+ entity_id: 'we-1',
2605
+ payload: JSON.stringify(forgedPayloads.waitlist),
2606
+ actor: ATTACKER,
2607
+ })
2608
+ )
2609
+ const ran = await dispatchIn(files, real)
2610
+ expect(ran.result.delivered).toBe(3)
2611
+ const sent = Object.fromEntries(
2612
+ ran.calls.map((call) => [call.url.replace(ECHO + '/greet/', ''), call.json])
2613
+ )
2614
+ expect(sent).toEqual({
2615
+ 'review-submitted': { email: 'mia@example.com', actor: 'mia@example.com' },
2616
+ 'newsletter-subscribed': { email: 'zoe@example.com', actor: 'zoe@example.com' },
2617
+ 'waitlist-joined': { email: 'noor@example.com', actor: 'noor@example.com' },
2618
+ })
2619
+
2620
+ // The same events naming records that are not there.
2621
+ const forged = new FakeDatabase()
2622
+ seedGreeters(forged)
2623
+ forged.events.push(
2624
+ eventRow({
2625
+ id: 'f-rev',
2626
+ event_type: 'review-submitted',
2627
+ entity_type: 'review',
2628
+ entity_id: 'rev-x',
2629
+ payload: JSON.stringify(forgedPayloads.review),
2630
+ actor: ATTACKER,
2631
+ }),
2632
+ eventRow({
2633
+ id: 'f-news',
2634
+ event_type: 'newsletter-subscribed',
2635
+ entity_type: 'subscriber',
2636
+ entity_id: 'user-x',
2637
+ payload: JSON.stringify(forgedPayloads.subscriber),
2638
+ actor: ATTACKER,
2639
+ }),
2640
+ eventRow({
2641
+ id: 'f-wl',
2642
+ event_type: 'waitlist-joined',
2643
+ entity_type: 'waitlist',
2644
+ entity_id: 'we-x',
2645
+ payload: JSON.stringify(forgedPayloads.waitlist),
2646
+ actor: ATTACKER,
2647
+ })
2648
+ )
2649
+ const refused = await dispatchIn(files, forged)
2650
+ expect(refused.calls).toEqual([])
2651
+ expect(forged.deliveries.map((d) => [d.workflow_id, d.error]).sort()).toEqual([
2652
+ ['wf-news', 'No such subscriber'],
2653
+ ['wf-review', 'No such review'],
2654
+ ['wf-wl', 'No such waitlist entry'],
2655
+ ])
2656
+ expect(forged.events.map((e) => e.status)).toEqual(['failed', 'failed', 'failed'])
2657
+ })
2658
+
2659
+ it('finds a newsletter subscriber by the address when the row names no id', async () => {
2660
+ const db = new FakeDatabase()
2661
+ seedGreeters(db)
2662
+ db.events.push(
2663
+ eventRow({
2664
+ id: 'e-news',
2665
+ event_type: 'newsletter-subscribed',
2666
+ entity_type: 'subscriber',
2667
+ payload: JSON.stringify({ subscriber: { email: 'ZOE@example.com' } }),
2668
+ })
2669
+ )
2670
+ const { calls } = await dispatchIn(files, db)
2671
+ expect(calls.map((call) => call.json)).toEqual([
2672
+ { email: 'zoe@example.com', actor: 'zoe@example.com' },
2673
+ ])
2674
+ })
2675
+
2676
+ it('runs a form submission from what it recorded: a submission has no row of its own', async () => {
2677
+ const db = new FakeDatabase()
2678
+ seedGreeters(db)
2679
+ db.events.push(
2680
+ eventRow({
2681
+ id: 'e-form',
2682
+ event_type: 'form-submitted',
2683
+ entity_type: 'form',
2684
+ entity_id: 'form-node-1',
2685
+ payload: JSON.stringify({
2686
+ form: { id: 'form-node-1', name: 'Contact', fields: { email: 'sam@example.com' } },
2687
+ }),
2688
+ actor: 'sam@example.com',
2689
+ })
2690
+ )
2691
+ const { calls } = await dispatchIn(files, db)
2692
+ expect(calls.map((call) => call.json)).toEqual([
2693
+ { email: 'sam@example.com', actor: 'sam@example.com' },
2694
+ ])
2695
+ })
2696
+
2697
+ it('runs a deleted account from the snapshot its deletion recorded, and nothing else', async () => {
2698
+ const snapshot = {
2699
+ id: 'user-7',
2700
+ email: 'liam@example.com',
2701
+ name: 'Liam',
2702
+ role: 'user',
2703
+ source: null,
2704
+ createdAt: '2026-01-02T03:04:05.000Z',
2705
+ }
2706
+ const deleted = (id: string, user: Row) =>
2707
+ eventRow({
2708
+ id,
2709
+ event_type: 'user-deleted',
2710
+ dedupe_key: 'user-deleted:' + id,
2711
+ entity_type: 'user',
2712
+ entity_id: 'user-7',
2713
+ payload: JSON.stringify({ user }),
2714
+ actor: String(user.email),
2715
+ actor_type: 'customer',
2716
+ })
2717
+ const db = new FakeDatabase()
2718
+ db.automations.push(automationRow('wf-bye', 'user-deleted'))
2719
+ // The row is gone: the route deleted it before recording the event.
2720
+ db.events.push(deleted('evt-bye', snapshot))
2721
+ const { result, calls } = await dispatchIn(files, db)
2722
+ expect(result.delivered).toBe(1)
2723
+ expect(calls.map((call) => call.json)).toEqual([
2724
+ { id: 'user-7', email: 'liam@example.com', name: 'Liam', actor: 'liam@example.com' },
2725
+ ])
2726
+
2727
+ // A snapshot of another account than the event names is no record.
2728
+ const forged = new FakeDatabase()
2729
+ forged.automations.push(automationRow('wf-bye', 'user-deleted'))
2730
+ forged.events.push(deleted('evt-forged', { ...snapshot, id: 'user-8' }))
2731
+ const refused = await dispatchIn(files, forged)
2732
+ expect(refused.calls).toEqual([])
2733
+ expect(forged.delivery('wf-bye')).toMatchObject({ status: 'failed', error: 'No such user' })
2734
+
2735
+ // Any other account event still needs its row.
2736
+ const signup = new FakeDatabase()
2737
+ signup.automations.push(automationRow('wf-source', 'user-signed-up'))
2738
+ signup.events.push({ ...deleted('evt-signup', snapshot), event_type: 'user-signed-up' })
2739
+ expect((await dispatchIn(files, signup)).calls).toEqual([])
2740
+ expect(signup.delivery('wf-source')).toMatchObject({ status: 'failed', error: 'No such user' })
2741
+ })
2742
+
2743
+ it('keeps what the writer recorded where the record has nothing: the low-stock level, the signup source', async () => {
2744
+ const db = new FakeDatabase()
2745
+ db.automations.push(
2746
+ automationRow('wf-low', 'product-low-stock'),
2747
+ automationRow('wf-source', 'user-signed-up')
2748
+ )
2749
+ db.products.push({ id: 'p-1', name: 'Beeswax Candle', sku: null, quantity: 2 })
2750
+ db.users.push({ id: 'user-1', email: 'noah@example.com', name: 'Noah', created_at: ago(1000) })
2751
+ db.events.push(
2752
+ eventRow({
2753
+ id: 'evt-low',
2754
+ event_type: 'product-low-stock',
2755
+ entity_type: 'product',
2756
+ entity_id: 'p-1',
2757
+ // As the data API records it.
2758
+ payload: JSON.stringify({
2759
+ product: { id: 'p-1', name: 'Old name', stock: 3, threshold: 5, sku: 'BWC-01' },
2760
+ }),
2761
+ }),
2762
+ eventRow({
2763
+ id: 'evt-joined',
2764
+ event_type: 'user-signed-up',
2765
+ entity_type: 'user',
2766
+ entity_id: 'user-1',
2767
+ // As the signup route records it.
2768
+ payload: JSON.stringify({
2769
+ user: { id: 'user-1', email: 'noah@example.com', source: 'signup' },
2770
+ }),
2771
+ })
2772
+ )
2773
+ const { calls } = await dispatchIn(files, db)
2774
+ const sent = Object.fromEntries(
2775
+ calls.map((call) => [call.url.replace(ECHO + '/', ''), call.json])
2776
+ )
2777
+ expect(sent).toEqual({
2778
+ // The record's name and stock win; its empty sku does not erase the recorded one.
2779
+ low: { name: 'Beeswax Candle', stock: 2, threshold: 5, sku: 'BWC-01' },
2780
+ source: { source: 'signup' },
2781
+ })
2782
+ })
2783
+
2784
+ it('ends a parked run whose record is gone instead of resuming it', async () => {
2785
+ const db = new FakeDatabase()
2786
+ seedSignup(db)
2787
+ await dispatchIn(orderFiles, db)
2788
+ expect(db.waits).toHaveLength(1)
2789
+ db.users = []
2790
+ db.waits[0].run_at = ago(1000)
2791
+ const { result, calls } = await dispatchIn(orderFiles, db)
2792
+ expect(result.resumed).toBe(0)
2793
+ expect(calls).toEqual([])
2794
+ expect(db.waits[0].status).toBe('failed')
2795
+ })
2796
+ })
2797
+
2798
+ describe('in-app event bus — the mail automations send', () => {
2799
+ it("files it under the automation and the event's order, so the order's email trail lists it", async () => {
2800
+ const files = sources(
2801
+ await generate({
2802
+ 'wf-mail': {
2803
+ id: 'wf-mail',
2804
+ name: 'Thank by email',
2805
+ trigger: trigger('order-paid'),
2806
+ nodes: [
2807
+ {
2808
+ id: 'mail',
2809
+ type: 'email-resend',
2810
+ config: {
2811
+ apiKey: 'k',
2812
+ from: 'shop@example.com',
2813
+ to: ref('t', ['order', 'customer', 'email']),
2814
+ subject: 'Thank you',
2815
+ body: '<p>Thank you</p>',
2816
+ },
2817
+ stepNumber: 1,
2818
+ label: 'Thank',
2819
+ },
2820
+ ],
2821
+ edges: [{ id: 'e1', source: 't', target: 'mail' }],
2822
+ },
2823
+ })
2824
+ )
2825
+ expect(files.get('utils/email/sent-email-log.js')).toContain(
2826
+ 'function __attribute(entry, context)'
2827
+ )
2828
+
2829
+ const ORDER_ID = '5f0c2b1e-8a3d-4c7b-9e2f-1a2b3c4d5e6f'
2830
+ const db = new FakeDatabase()
2831
+ seedOrderPaid(db)
2832
+ db.automations = [automationRow('wf-mail', 'order-paid')]
2833
+ db.orders[0].id = ORDER_ID
2834
+ db.orderItems[0].order_id = ORDER_ID
2835
+ db.events[0].order_id = ORDER_ID
2836
+ db.events[0].entity_id = ORDER_ID
2837
+
2838
+ const result = await withEcho(() =>
2839
+ silenced(() => (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({}))
2840
+ )
2841
+
2842
+ expect(result.delivered).toBe(1)
2843
+ expect(db.mailSent.map((mail) => mail.to)).toEqual([['ava@example.com']])
2844
+ expect(db.sentEmails).toHaveLength(1)
2845
+ expect(db.sentEmails[0]).toMatchObject({
2846
+ recipient_email: 'ava@example.com',
2847
+ order_id: ORDER_ID,
2848
+ source: 'automation',
2849
+ source_ref: 'wf-mail',
2850
+ status: 'sent',
2851
+ })
2852
+ })
2853
+
2854
+ it('leaves the sent-email module untouched in a project without automations', async () => {
2855
+ const mailer = (id: string, eventType: string) => ({
2856
+ id,
2857
+ name: 'Mail',
2858
+ trigger: { type: eventType, nodeId: 't', scope: 'page', config: { allPages: true } },
2859
+ nodes: [
2860
+ {
2861
+ id: 'mail',
2862
+ type: 'email-resend',
2863
+ config: { apiKey: 'k', from: 'a@b.c', to: 'c@d.e', subject: 's', body: 'b' },
2864
+ stepNumber: 1,
2865
+ label: 'Mail',
2866
+ },
2867
+ ],
2868
+ edges: [{ id: 'e1', source: 't', target: 'mail' }],
2869
+ })
2870
+ const files = sources(
2871
+ await generate({ 'wf-page-mail': mailer('wf-page-mail', 'event-page-loaded') })
2872
+ )
2873
+ expect(files.get('utils/email/sent-email-log.js')).not.toContain('__attribute')
2874
+ })
2875
+ })
2876
+
2877
+ describe('in-app event bus — emitting and the dispatch route', () => {
2878
+ let files: Map<string, string>
2879
+
2880
+ beforeAll(async () => {
2881
+ files = sources(await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }))
2882
+ })
2883
+
2884
+ const signup = {
2885
+ type: 'user-signed-up',
2886
+ entityType: 'user',
2887
+ entityId: 'user-1',
2888
+ payload: { source: 'signup' },
2889
+ actor: 'noah@example.com',
2890
+ actorType: 'customer',
2891
+ dedupeKey: 'user-signed-up:user-1',
2892
+ }
2893
+
2894
+ it('records an event once per dedupe key and kicks the dispatch route off the request path', async () => {
2895
+ const db = new FakeDatabase()
2896
+ const app = createApp(files, db)
2897
+ const emitted = await withEnv(ENV, () =>
2898
+ withFetch(
2899
+ () => ({ status: 200, body: {} }),
2900
+ async (calls) => {
2901
+ const busModule = app('lib/automations/event-bus.js') as Bus
2902
+ // The origin comes from the request being served, never from the caller.
2903
+ const req = { headers: { host: 'shop.test', 'x-forwarded-proto': 'https' } }
2904
+ const first = await busModule.emitBusinessEvent(
2905
+ { ...signup, baseUrl: 'https://evil.test' },
2906
+ req
2907
+ )
2908
+ const again = await busModule.emitBusinessEvent(signup, req)
2909
+ return { first, again, calls }
2910
+ }
2911
+ )
2912
+ )
2913
+
2914
+ expect(emitted.first.recorded).toBe(true)
2915
+ expect(emitted.again.recorded).toBe(false)
2916
+ expect(db.events).toHaveLength(1)
2917
+ const insert = db.statements.find((text) => text.startsWith('INSERT INTO teleport_events'))!
2918
+ expect(insert).toContain('ON CONFLICT (dedupe_key) DO NOTHING RETURNING id')
2919
+ // One kick, for the row that was inserted.
2920
+ expect(emitted.calls).toHaveLength(1)
2921
+ expect(emitted.calls[0].url).toBe('https://shop.test/api/automations/dispatch')
2922
+ expect(emitted.calls[0].method).toBe('POST')
2923
+ expect(emitted.calls[0].headers['x-internal-data-secret']).toBe('app-secret')
2924
+ })
2925
+
2926
+ const scheduleWith = async (env: Row) => {
2927
+ const db = new FakeDatabase()
2928
+ const scope = globalThis as Record<string, unknown>
2929
+ const held: Array<Promise<unknown>> = []
2930
+ scope.__tqWaitUntil = (promise: Promise<unknown>) => {
2931
+ held.push(promise)
2932
+ }
2933
+ try {
2934
+ return await withEnv({ ...ENV, ...env } as Record<string, string | undefined>, () =>
2935
+ withFetch(
2936
+ () => ({ status: 200, body: {} }),
2937
+ async (calls) => {
2938
+ const busModule = createApp(files, db)('lib/automations/event-bus.js') as Bus
2939
+ const mode = busModule.scheduleDispatch()
2940
+ await Promise.all(held)
2941
+ return { mode, calls, held, db }
2942
+ }
2943
+ )
2944
+ )
2945
+ } finally {
2946
+ delete scope.__tqWaitUntil
2947
+ }
2948
+ }
2949
+
2950
+ it("dispatches in Vercel's waitUntil there, and calls its own route everywhere else", async () => {
2951
+ const onVercel = await scheduleWith({ VERCEL: '1' })
2952
+ expect(onVercel.mode).toBe('wait-until')
2953
+ expect(onVercel.held).toHaveLength(1)
2954
+ expect(onVercel.calls).toEqual([])
2955
+ expect(onVercel.db.claimStatement()).toContain('SKIP LOCKED')
2956
+
2957
+ // A waitUntil that is not the host's keeps nothing alive.
2958
+ const elsewhere = await scheduleWith({})
2959
+ expect(elsewhere.mode).toBe('fetch')
2960
+ expect(elsewhere.held).toEqual([])
2961
+ expect(elsewhere.calls.map((call) => call.url)).toEqual([
2962
+ 'https://shop.test/api/automations/dispatch',
2963
+ ])
2964
+ })
2965
+
2966
+ // Signed as the services-worker signs a test (the cron scheme, task 10a R3):
2967
+ // HMAC over v1:<ts>:<project>:<cron>:<execution>:<path>[:<body sha256>].
2968
+ const CRON_KEY = 'c'.repeat(64)
2969
+ // Every request the worker signs names an execution of its own.
2970
+ let executions = 0
2971
+ const signedHeaders = (
2972
+ rawBody: string,
2973
+ withDigest: boolean,
2974
+ executionId = `exec-${++executions}`
2975
+ ): Record<string, string> => {
2976
+ const timestamp = String(Math.floor(Date.now() / 1000))
2977
+ const digest = createHash('sha256').update(rawBody).digest('hex')
2978
+ const signed = [
2979
+ 'v1',
2980
+ timestamp,
2981
+ 'project-1',
2982
+ 'automations-test',
2983
+ executionId,
2984
+ '/api/automations/dispatch',
2985
+ ...(withDigest ? [digest] : []),
2986
+ ].join(':')
2987
+ return {
2988
+ 'x-teleport-cron-id': 'automations-test',
2989
+ 'x-teleport-project-id': 'project-1',
2990
+ 'x-teleport-cron-execution-id': executionId,
2991
+ 'x-teleport-cron-timestamp': timestamp,
2992
+ ...(withDigest ? { 'x-teleport-body-sha256': digest } : {}),
2993
+ 'x-teleport-cron-signature':
2994
+ 'v1=' + createHmac('sha256', CRON_KEY).update(signed).digest('hex'),
2995
+ }
2996
+ }
2997
+
2998
+ type Caller = 'app' | 'wrong-secret' | 'signed' | 'signed-without-digest' | 'tampered'
2999
+
3000
+ const callRoute = async (db: FakeDatabase, caller: Caller, body: unknown) => {
3001
+ const rawBody = JSON.stringify(body)
3002
+ const headers: Record<string, string> =
3003
+ caller === 'app'
3004
+ ? { 'x-internal-data-secret': 'app-secret' }
3005
+ : caller === 'wrong-secret'
3006
+ ? { 'x-internal-data-secret': 'wrong' }
3007
+ : signedHeaders(rawBody, caller !== 'signed-without-digest')
3008
+ // What arrives differs from what was signed.
3009
+ const sent = caller === 'tampered' ? rawBody.replace('wf-paid-ok', 'wf-paid-fail') : rawBody
3010
+ const reply = { status: 0, body: null as unknown }
3011
+ const res = {
3012
+ setHeader: () => undefined,
3013
+ status(code: number) {
3014
+ reply.status = code
3015
+ return {
3016
+ json(payload: unknown) {
3017
+ reply.body = payload
3018
+ },
3019
+ }
3020
+ },
3021
+ }
3022
+ // The route reads its body raw (bodyParser: false): a Buffer, as Next hands it.
3023
+ const req = {
3024
+ method: 'POST',
3025
+ url: '/api/automations/dispatch',
3026
+ headers: {
3027
+ host: 'shop.test',
3028
+ 'x-forwarded-proto': 'https',
3029
+ 'content-type': 'application/json',
3030
+ ...headers,
3031
+ },
3032
+ body: Buffer.from(sent),
3033
+ }
3034
+ const calls = await withEcho(
3035
+ async (recorded) => {
3036
+ const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
3037
+ await silenced(() => handler(req, res))
3038
+ return recorded
3039
+ },
3040
+ { TELEPORT_CRON_SECRET: CRON_KEY }
3041
+ )
3042
+ return { reply, calls }
3043
+ }
3044
+
3045
+ it("presents the deployment's protection bypass on its own calls when one is set", async () => {
3046
+ const walled = await scheduleWith({ VERCEL_AUTOMATION_BYPASS_SECRET: 'bypass-1' })
3047
+ expect(walled.calls).toHaveLength(1)
3048
+ expect(walled.calls[0].headers['x-vercel-protection-bypass']).toBe('bypass-1')
3049
+ expect(walled.calls[0].headers['x-vercel-set-bypass-cookie']).toBe('false')
3050
+ expect(walled.calls[0].headers['x-internal-data-secret']).toBe('app-secret')
3051
+
3052
+ const open = await scheduleWith({ VERCEL_AUTOMATION_BYPASS_SECRET: undefined })
3053
+ expect(open.calls).toHaveLength(1)
3054
+ expect(open.calls[0].headers).not.toHaveProperty('x-vercel-protection-bypass')
3055
+ })
3056
+
3057
+ // A request sent exactly as it was captured: the same headers, the same body.
3058
+ const resend = async (db: FakeDatabase, headers: Record<string, string>, rawBody: string) => {
3059
+ const reply = { status: 0, body: null as unknown }
3060
+ const res = {
3061
+ setHeader: () => undefined,
3062
+ status(code: number) {
3063
+ reply.status = code
3064
+ return {
3065
+ json(payload: unknown) {
3066
+ reply.body = payload
3067
+ },
3068
+ }
3069
+ },
3070
+ }
3071
+ const req = {
3072
+ method: 'POST',
3073
+ url: '/api/automations/dispatch',
3074
+ headers: {
3075
+ host: 'shop.test',
3076
+ 'x-forwarded-proto': 'https',
3077
+ 'content-type': 'application/json',
3078
+ ...headers,
3079
+ },
3080
+ body: Buffer.from(rawBody),
3081
+ }
3082
+ const calls = await withEcho(
3083
+ async (recorded) => {
3084
+ const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
3085
+ await silenced(() => handler(req, res))
3086
+ return recorded
3087
+ },
3088
+ { TELEPORT_CRON_SECRET: CRON_KEY }
3089
+ )
3090
+ return { reply, calls }
3091
+ }
3092
+
3093
+ it('takes a signed request once: the same request again is refused before anything runs', async () => {
3094
+ const db = new FakeDatabase()
3095
+ seedOrderPaid(db)
3096
+ db.events = []
3097
+ const rawBody = JSON.stringify({ test: true, workflowId: 'wf-paid-ok', event: ORDER_EXAMPLE })
3098
+ const captured = signedHeaders(rawBody, true)
3099
+
3100
+ const first = await resend(db, captured, rawBody)
3101
+ expect(first.reply.status).toBe(200)
3102
+ expect(db.deliveries.map((d) => d.workflow_id)).toEqual(['wf-paid-ok'])
3103
+
3104
+ const again = await resend(db, captured, rawBody)
3105
+ expect(again.reply).toEqual({
3106
+ status: 409,
3107
+ body: { error: 'This request was already received' },
3108
+ })
3109
+ expect(again.calls).toEqual([])
3110
+ expect(db.events).toHaveLength(1)
3111
+ expect(db.deliveries).toHaveLength(1)
3112
+ // Kept as a digest: nothing the request sent is stored as it was sent.
3113
+ expect(Array.from(db.signedRequests.keys())).toEqual([expect.stringMatching(/^[0-9a-f]{64}$/)])
3114
+
3115
+ // A signed run of the queue is taken once too.
3116
+ const kick = signedHeaders('{}', false)
3117
+ expect((await resend(db, kick, '{}')).reply.status).toBe(200)
3118
+ expect((await resend(db, kick, '{}')).reply.status).toBe(409)
3119
+ // The app's own calls name no execution: never remembered, never refused.
3120
+ for (let i = 0; i < 2; i++) {
3121
+ expect((await callRoute(db, 'app', {})).reply.status).toBe(200)
3122
+ }
3123
+ expect(db.signedRequests.size).toBe(2)
3124
+ })
3125
+
3126
+ it('remembers a signed request while its signature can verify, and refuses one it cannot check', async () => {
3127
+ const db = new FakeDatabase()
3128
+ const nowSeconds = Math.floor(Date.now() / 1000)
3129
+ db.signedRequests.set('over', nowSeconds - 1)
3130
+ expect((await resend(db, signedHeaders('{}', false), '{}')).reply.status).toBe(200)
3131
+ expect(db.signedRequests.has('over')).toBe(false)
3132
+ const [expiresAt] = Array.from(db.signedRequests.values())
3133
+ // The signature accepts a timestamp up to 300 s off either way.
3134
+ expect(expiresAt - nowSeconds).toBeGreaterThanOrEqual(600)
3135
+
3136
+ const down = new FakeDatabase()
3137
+ seedOrderPaid(down)
3138
+ down.signedRequestsDown = true
3139
+ const refused = await resend(down, signedHeaders('{}', false), '{}')
3140
+ expect(refused.reply).toEqual({
3141
+ status: 503,
3142
+ body: { error: 'The request could not be checked. Try again.' },
3143
+ })
3144
+ expect(refused.calls).toEqual([])
3145
+ expect(down.deliveries).toEqual([])
3146
+ })
3147
+
3148
+ it('refuses a caller without the app secret or a cron signature', async () => {
3149
+ const db = new FakeDatabase()
3150
+ seedOrderPaid(db)
3151
+ const { reply, calls } = await callRoute(db, 'wrong-secret', {})
3152
+ expect(reply).toEqual({ status: 401, body: { error: 'Unauthorized' } })
3153
+ expect(calls).toEqual([])
3154
+ expect(db.statements).toEqual([])
3155
+ })
3156
+
3157
+ // A request as Node hands it to the route: a stream, read only when listened to.
3158
+ const streamed = (headers: Record<string, string>, chunks: Buffer[]) => {
3159
+ const read = { started: false }
3160
+ const req = {
3161
+ method: 'POST',
3162
+ url: '/api/automations/dispatch',
3163
+ headers: { host: 'shop.test', ...headers },
3164
+ on(name: string, listener: (value?: unknown) => void) {
3165
+ read.started = true
3166
+ if (name === 'data') {
3167
+ setImmediate(() => chunks.forEach((chunk) => listener(chunk)))
3168
+ }
3169
+ if (name === 'end') {
3170
+ setImmediate(() => setImmediate(() => listener()))
3171
+ }
3172
+ return req
3173
+ },
3174
+ }
3175
+ return { req, read }
3176
+ }
3177
+
3178
+ const answer = async (db: FakeDatabase, req: unknown, env: Row = {}) => {
3179
+ const reply = { status: 0, body: null as unknown }
3180
+ const res = {
3181
+ setHeader: () => undefined,
3182
+ status(code: number) {
3183
+ reply.status = code
3184
+ return {
3185
+ json(payload: unknown) {
3186
+ reply.body = payload
3187
+ },
3188
+ }
3189
+ },
3190
+ }
3191
+ await withEcho(async () => {
3192
+ const handler = createApp(files, db)('pages/api/automations/dispatch.js') as RouteHandler
3193
+ await silenced(() => handler(req, res))
3194
+ }, env)
3195
+ return reply
3196
+ }
3197
+
3198
+ it('refuses a caller that presents no credentials before reading anything it sent', async () => {
3199
+ const db = new FakeDatabase()
3200
+ seedOrderPaid(db)
3201
+ const { req, read } = streamed({}, [Buffer.from('{}')])
3202
+ expect(await answer(db, req)).toEqual({ status: 401, body: { error: 'Unauthorized' } })
3203
+ expect(read.started).toBe(false)
3204
+ expect(db.statements).toEqual([])
3205
+
3206
+ // A local run that allows unsigned calls still runs.
3207
+ const local = streamed({}, [Buffer.from('{}')])
3208
+ const ran = await answer(db, local.req, { TELEPORT_CRON_ALLOW_UNSIGNED: 'true' })
3209
+ expect(ran.status).toBe(200)
3210
+ })
3211
+
3212
+ it('refuses a body larger than 64 KB, without reading past it', async () => {
3213
+ const app = { 'x-internal-data-secret': 'app-secret' }
3214
+ const db = new FakeDatabase()
3215
+ seedOrderPaid(db)
3216
+ const tooLarge = { status: 413, body: { error: 'The request body is too large' } }
3217
+
3218
+ // Announced: refused before a byte is read.
3219
+ const announced = streamed({ ...app, 'content-length': String(70 * 1024) }, [])
3220
+ expect(await answer(db, announced.req)).toEqual(tooLarge)
3221
+ expect(announced.read.started).toBe(false)
3222
+
3223
+ // Streamed in pieces.
3224
+ const pieces = streamed(app, [Buffer.alloc(40 * 1024, 32), Buffer.alloc(40 * 1024, 32)])
3225
+ expect(await answer(db, pieces.req)).toEqual(tooLarge)
3226
+
3227
+ // Handed over whole.
3228
+ const whole = { ...streamed(app, []).req, body: Buffer.alloc(65 * 1024, 32) }
3229
+ expect(await answer(db, whole)).toEqual(tooLarge)
3230
+ expect(db.statements).toEqual([])
3231
+
3232
+ // 64 KB is still a body.
3233
+ const fits = { ...streamed(app, []).req, body: Buffer.alloc(64 * 1024, 32) }
3234
+ expect((await answer(db, fits)).status).toBe(200)
3235
+ })
3236
+
3237
+ it("calls the app's own routes on its own address, never on a host a caller picked", async () => {
3238
+ const store = createApp(files, new FakeDatabase())('lib/automations/store.js') as {
3239
+ appBaseUrl(req?: Row): string
3240
+ }
3241
+ const from = (host: string) => ({ headers: { host } })
3242
+ const scope = globalThis as Row
3243
+ // Every generation writes NEXTAUTH_URL=http://localhost:3000, and the
3244
+ // deploy keeps it: that is no address.
3245
+ const GENERATED = 'http://localhost:3000'
3246
+ const VERCEL = { VERCEL: '1', VERCEL_URL: 'shop-abc123.vercel.app' }
3247
+ // [env, request, the auth route's snapshot of NEXTAUTH_URL, origin]
3248
+ const cases: Array<[Row, Row | undefined, string | undefined, string]> = [
3249
+ // On Vercel: the origin the request reached.
3250
+ [{ ...VERCEL, NEXTAUTH_URL: GENERATED }, from('shop.com'), undefined, 'https://shop.com'],
3251
+ [VERCEL, from('shop.com'), undefined, 'https://shop.com'],
3252
+ // No request (a page render, a handler): the production domain, else the deployment.
3253
+ [
3254
+ { ...VERCEL, NEXTAUTH_URL: GENERATED, VERCEL_PROJECT_PRODUCTION_URL: 'shop.com' },
3255
+ undefined,
3256
+ undefined,
3257
+ 'https://shop.com',
3258
+ ],
3259
+ [
3260
+ { ...VERCEL, NEXTAUTH_URL: GENERATED },
3261
+ { headers: {} },
3262
+ undefined,
3263
+ 'https://shop-abc123.vercel.app',
3264
+ ],
3265
+ // A real NEXTAUTH_URL wins over any header, on Vercel or not.
3266
+ [
3267
+ { ...VERCEL, NEXTAUTH_URL: 'https://shop.test' },
3268
+ from('evil.test'),
3269
+ undefined,
3270
+ 'https://shop.test',
3271
+ ],
3272
+ [{ NEXTAUTH_URL: 'https://shop.test' }, from('evil.test'), undefined, 'https://shop.test'],
3273
+ [
3274
+ { NEXTAUTH_URL: 'https://shop.test' },
3275
+ from('localhost:4000'),
3276
+ undefined,
3277
+ 'https://shop.test',
3278
+ ],
3279
+ // Anywhere else a public Host is the caller's word: never used.
3280
+ [{}, from('shop.com'), undefined, 'http://localhost:3000'],
3281
+ [{ PORT: '8080' }, from('shop.com'), undefined, 'http://localhost:8080'],
3282
+ [
3283
+ { NEXTAUTH_URL: 'teleporthq.secrets.NEXTAUTH_URL' },
3284
+ from('shop.com'),
3285
+ undefined,
3286
+ 'http://localhost:3000',
3287
+ ],
3288
+ [{}, undefined, undefined, 'http://localhost:3000'],
3289
+ // The auth route rewrote NEXTAUTH_URL from X-Forwarded-Host: its snapshot is read.
3290
+ [
3291
+ { NEXTAUTH_URL: 'https://evil.example' },
3292
+ from('shop.com'),
3293
+ GENERATED,
3294
+ 'http://localhost:3000',
3295
+ ],
3296
+ [{ NEXTAUTH_URL: 'https://evil.example' }, from('shop.com'), '', 'http://localhost:3000'],
3297
+ [
3298
+ { NEXTAUTH_URL: 'https://evil.example' },
3299
+ from('evil.example'),
3300
+ 'https://shop.test',
3301
+ 'https://shop.test',
3302
+ ],
3303
+ // A local server keeps its own loopback address.
3304
+ [{}, from('localhost:4000'), undefined, 'http://localhost:4000'],
3305
+ [{ NEXTAUTH_URL: GENERATED }, from('127.0.0.1:4000'), undefined, 'http://127.0.0.1:4000'],
3306
+ ]
3307
+ for (const [env, req, snapshot, expected] of cases) {
3308
+ delete scope.__tqConfiguredNextAuthUrl
3309
+ if (snapshot !== undefined) {
3310
+ scope.__tqConfiguredNextAuthUrl = snapshot
3311
+ }
3312
+ const origin = await withEnv(
3313
+ {
3314
+ NEXTAUTH_URL: undefined,
3315
+ VERCEL_URL: undefined,
3316
+ VERCEL_PROJECT_PRODUCTION_URL: undefined,
3317
+ VERCEL: undefined,
3318
+ PORT: undefined,
3319
+ ...env,
3320
+ } as Record<string, string | undefined>,
3321
+ async () => store.appBaseUrl(req)
3322
+ )
3323
+ const host = req && (req.headers as Row).host
3324
+ expect({ env, host, snapshot, origin }).toEqual({ env, host, snapshot, origin: expected })
3325
+ }
3326
+
3327
+ // Read first by the bus, the configured value is kept the way the auth
3328
+ // route keeps it: a later rewrite of process.env changes nothing.
3329
+ delete scope.__tqConfiguredNextAuthUrl
3330
+ const kept = await withEnv(
3331
+ { NEXTAUTH_URL: 'https://shop.test', VERCEL: undefined },
3332
+ async () => {
3333
+ const first = store.appBaseUrl(from('shop.com'))
3334
+ process.env.NEXTAUTH_URL = 'https://evil.example'
3335
+ return [first, store.appBaseUrl(from('evil.example'))]
3336
+ }
3337
+ )
3338
+ expect(kept).toEqual(['https://shop.test', 'https://shop.test'])
3339
+ })
3340
+
3341
+ it('never posts the app secret to a host the auth route took from a request', async () => {
3342
+ const scope = globalThis as Row
3343
+ // What /api/auth leaves behind off Vercel: the configured value kept, the
3344
+ // live one rewritten from X-Forwarded-Host.
3345
+ scope.__tqConfiguredNextAuthUrl = 'http://localhost:3000'
3346
+ const db = new FakeDatabase()
3347
+ const calls = await withEnv({ ...ENV, NEXTAUTH_URL: 'https://evil.example' }, () =>
3348
+ withFetch(
3349
+ () => ({ status: 200, body: {} }),
3350
+ async (recorded) => {
3351
+ const busModule = createApp(files, db)('lib/automations/event-bus.js') as Bus
3352
+ await busModule.emitBusinessEvent(signup, {
3353
+ headers: { host: 'evil.example', 'x-forwarded-host': 'evil.example' },
3354
+ })
3355
+ return recorded
3356
+ }
3357
+ )
3358
+ )
3359
+ expect(calls.map((call) => [call.url, call.headers['x-internal-data-secret']])).toEqual([
3360
+ ['http://localhost:3000/api/automations/dispatch', 'app-secret'],
3361
+ ])
3362
+ })
3363
+
3364
+ it('runs the queue for the app itself, and for a signed request without a body digest', async () => {
3365
+ const db = new FakeDatabase()
3366
+ seedOrderPaid(db)
3367
+ const { reply } = await callRoute(db, 'app', {})
3368
+ expect(reply).toEqual({
3369
+ status: 200,
3370
+ body: { claimed: 1, delivered: 1, failed: 1, resumed: 0 },
3371
+ })
3372
+ // A plain scheduled kick keeps today's signed string.
3373
+ const kicked = new FakeDatabase()
3374
+ seedOrderPaid(kicked)
3375
+ expect((await callRoute(kicked, 'signed-without-digest', {})).reply.status).toBe(200)
3376
+ })
3377
+
3378
+ const ORDER_EXAMPLE = {
3379
+ event: { id: 'placeholder', type: 'order-paid', version: 1, occurredAt: '', test: true },
3380
+ actor: { type: 'system', email: null },
3381
+ order: { id: 'ord-x', number: 'ORD-TEST', customer: { email: 'test@example.com' } },
3382
+ extra: { not: 'a block of order-paid' },
3383
+ }
3384
+
3385
+ it('runs a test of ONE automation once, with the catalog blocks only, and never queues it', async () => {
3386
+ const db = new FakeDatabase()
3387
+ seedOrderPaid(db)
3388
+ db.events = []
3389
+ // Switched off: a test still runs it.
3390
+ db.automations.find((a) => a.workflow_id === 'wf-paid-ok')!.enabled = false
3391
+
3392
+ const { reply, calls } = await callRoute(db, 'signed', {
3393
+ test: true,
3394
+ workflowId: 'wf-paid-ok',
3395
+ event: ORDER_EXAMPLE,
3396
+ })
3397
+
3398
+ expect(reply).toEqual({
3399
+ status: 200,
3400
+ body: { claimed: 1, delivered: 1, failed: 0, resumed: 0 },
3401
+ })
3402
+ expect(calls.map((call) => [call.url, call.json])).toEqual([
3403
+ [ECHO + '/thanks', { email: 'test@example.com', number: 'ORD-TEST', test: true }],
3404
+ ])
3405
+ expect(db.events).toHaveLength(1)
3406
+ expect(db.events[0].status).toBe('test')
3407
+ expect(db.events[0].id).not.toBe('placeholder')
3408
+ expect(db.deliveries.map((d) => [d.workflow_id, d.status, d.error])).toEqual([
3409
+ ['wf-paid-ok', 'test', null],
3410
+ ])
3411
+
3412
+ // Nothing is left to claim.
3413
+ const again = await withEcho(() =>
3414
+ silenced(() => (createApp(files, db)('lib/automations/event-bus.js') as Bus).dispatch({}))
3415
+ )
3416
+ expect(again.claimed).toBe(0)
3417
+ })
3418
+
3419
+ it('takes a test only from a request whose signature covers its body', async () => {
3420
+ const body = { test: true, workflowId: 'wf-paid-ok', event: ORDER_EXAMPLE }
3421
+
3422
+ // Changed on the way: the signature no longer holds.
3423
+ const tampered = new FakeDatabase()
3424
+ seedOrderPaid(tampered)
3425
+ const refused = await callRoute(tampered, 'tampered', body)
3426
+ expect(refused.reply).toEqual({ status: 401, body: { error: 'Unauthorized' } })
3427
+ expect(refused.calls).toEqual([])
3428
+ expect(tampered.statements).toEqual([])
3429
+
3430
+ // Authenticated, but nothing vouches for the body.
3431
+ for (const caller of ['app', 'signed-without-digest'] as const) {
3432
+ const unsignedDb = new FakeDatabase()
3433
+ seedOrderPaid(unsignedDb)
3434
+ const unsigned = await callRoute(unsignedDb, caller, body)
3435
+ expect(unsigned.reply).toEqual({ status: 400, body: { error: 'Unsigned body' } })
3436
+ expect(unsigned.calls).toEqual([])
3437
+ }
3438
+
3439
+ // The body as signed: it runs.
3440
+ const db = new FakeDatabase()
3441
+ seedOrderPaid(db)
3442
+ const ran = await callRoute(db, 'signed', body)
3443
+ expect(ran.reply.status).toBe(200)
3444
+ expect(ran.calls.map((call) => call.url)).toEqual([ECHO + '/thanks'])
3445
+ })
3446
+
3447
+ it('names what a test body lacks, so a sender without workflowId cannot fail silently', async () => {
3448
+ const db = new FakeDatabase()
3449
+ seedOrderPaid(db)
3450
+ const { reply, calls } = await callRoute(db, 'signed', { test: true, event: ORDER_EXAMPLE })
3451
+ expect(reply).toEqual({
3452
+ status: 400,
3453
+ body: { error: 'A test names the automation to run: workflowId is missing' },
3454
+ })
3455
+ expect(calls).toEqual([])
3456
+ const noEvent = await callRoute(db, 'signed', { test: true, workflowId: 'wf-paid-ok' })
3457
+ expect(noEvent.reply).toEqual({
3458
+ status: 400,
3459
+ body: { error: 'A test carries the event to send: event is missing' },
3460
+ })
3461
+ })
3462
+
3463
+ it('stops a test at a Wait and reports how long it would have waited, parking nothing', async () => {
3464
+ const db = new FakeDatabase()
3465
+ seedSignup(db)
3466
+ db.events = []
3467
+ const { reply, calls } = await callRoute(db, 'signed', {
3468
+ test: true,
3469
+ workflowId: 'wf-signup-wait',
3470
+ event: {
3471
+ event: { id: 'x', type: 'user-signed-up', version: 1, occurredAt: '', test: true },
3472
+ actor: { type: 'customer', email: 'test@example.com' },
3473
+ user: { id: 'u-x', email: 'test@example.com' },
3474
+ },
3475
+ })
3476
+ expect(reply).toEqual({
3477
+ status: 200,
3478
+ body: {
3479
+ claimed: 1,
3480
+ delivered: 1,
3481
+ failed: 0,
3482
+ resumed: 0,
3483
+ wouldWait: { amount: 2, unit: 'days' },
3484
+ },
3485
+ })
3486
+ expect(calls.map((call) => call.url)).toEqual([ECHO + '/before'])
3487
+ expect(db.waits).toEqual([])
3488
+ expect(JSON.parse(db.deliveries[0].error!)).toEqual({ wouldWait: { amount: 2, unit: 'days' } })
3489
+ })
3490
+
3491
+ it('refuses a test of an automation the site does not have, or of another event', async () => {
3492
+ const db = new FakeDatabase()
3493
+ const unknown = await callRoute(db, 'signed', {
3494
+ test: true,
3495
+ workflowId: 'wf-nope',
3496
+ event: ORDER_EXAMPLE,
3497
+ })
3498
+ expect(unknown.reply.status).toBe(404)
3499
+ const other = await callRoute(db, 'signed', {
3500
+ test: true,
3501
+ workflowId: 'wf-signup-wait',
3502
+ event: ORDER_EXAMPLE,
3503
+ })
3504
+ expect(other.reply.status).toBe(400)
3505
+ expect(db.events).toEqual([])
3506
+ })
3507
+ })
3508
+
3509
+ describe('in-app event bus — Slack messages', () => {
3510
+ // A Slack message is mrkdwn: `<…>` there is a link or a mention. What a
3511
+ // customer typed reaches it as a value of the event.
3512
+ const chip = (path: string[], label: string) =>
3513
+ `<span class="context-value-inline" data-ctx-node-id="t" data-ctx-path='${JSON.stringify([
3514
+ 't',
3515
+ ...path,
3516
+ ])}' data-ctx-label="${label}" contenteditable="false" draggable="false">${label}</span>`
3517
+ const slackStep = (id: string, text: unknown, stepNumber: number) => ({
3518
+ id,
3519
+ type: 'integration-slack',
3520
+ config: { action: 'send-message', token: 'xoxb-test', channel: 'C1', text },
3521
+ stepNumber,
3522
+ label: 'Post to Slack',
3523
+ })
3524
+ const SLACK_AUTOMATION: Record<string, Row> = {
3525
+ 'wf-slack': {
3526
+ id: 'wf-slack',
3527
+ name: 'Tell me on Slack',
3528
+ trigger: trigger('order-paid'),
3529
+ nodes: [
3530
+ // As the recipe writes it: the owner's own words and link, the values as chips.
3531
+ slackStep(
3532
+ 'chips',
3533
+ `New order from ${chip(['order', 'customer', 'name'], 'Customer name')}: ${chip(
3534
+ ['order', 'notes'],
3535
+ 'Order notes'
3536
+ )} <https://shop.test/admin|Open the admin>`,
3537
+ 1
3538
+ ),
3539
+ // The whole message bound to one value.
3540
+ slackStep('whole', ref('t', ['order', 'notes']), 2),
3541
+ ],
3542
+ edges: [
3543
+ { id: 'e1', source: 't', target: 'chips' },
3544
+ { id: 'e2', source: 'chips', target: 'whole' },
3545
+ ],
3546
+ },
3547
+ }
3548
+
3549
+ it('escapes every event value it posts, and keeps the owner’s own links', async () => {
3550
+ const files = sources(await generate({ ...BASE_WORKFLOWS, ...SLACK_AUTOMATION }))
3551
+ const db = new FakeDatabase()
3552
+ const posted = await withEnv(ENV, () =>
3553
+ withFetch(
3554
+ () => ({ status: 200, body: { ok: true, ts: '1.0' } }),
3555
+ async (calls) => {
3556
+ const registry = createApp(files, db)('lib/automations/registry.js') as Record<
3557
+ string,
3558
+ Array<{ workflowId: string; run: (event: Row, context: Row) => Promise<unknown> }>
3559
+ >
3560
+ const entry = registry['order-paid'].find((e) => e.workflowId === 'wf-slack')!
3561
+ await silenced(() =>
3562
+ entry.run(
3563
+ {
3564
+ event: { id: 'evt-1', type: 'order-paid', version: 1, occurredAt: '' },
3565
+ actor: { type: 'customer', email: 'ava@example.com' },
3566
+ order: {
3567
+ id: 'ord-1',
3568
+ customer: { name: `Ava "O'Brien" <!channel>`, email: 'ava@example.com' },
3569
+ notes: '<https://evil.test|Claim your refund> & more',
3570
+ },
3571
+ },
3572
+ { __baseUrl: 'https://shop.test' }
3573
+ )
3574
+ )
3575
+ return calls
3576
+ .filter((call) => call.url === 'https://slack.com/api/chat.postMessage')
3577
+ .map((call) => call.json.text)
3578
+ }
3579
+ )
3580
+ )
3581
+
3582
+ expect(posted).toEqual([
3583
+ `New order from Ava "O'Brien" &lt;!channel&gt;: &lt;https://evil.test|Claim your refund&gt; &amp; more <https://shop.test/admin|Open the admin>`,
3584
+ '&lt;https://evil.test|Claim your refund&gt; &amp; more',
3585
+ ])
3586
+ })
3587
+
3588
+ it('leaves the handler of an automation without a bound Slack message as it was', async () => {
3589
+ const { generateAutomationHandlerModule } = await import(
3590
+ '../src/automations/handler-module-generator'
3591
+ )
3592
+ const { generateHandlerFunction } = await import('../src/api-route-generator')
3593
+ const workflow = JSON.parse(JSON.stringify(AUTOMATIONS['wf-paid-ok']))
3594
+ expect(generateAutomationHandlerModule(workflow)).toBe(generateHandlerFunction(workflow))
3595
+ const chipsOnly = JSON.parse(JSON.stringify(SLACK_AUTOMATION['wf-slack']))
3596
+ chipsOnly.nodes = chipsOnly.nodes.slice(0, 1)
3597
+ expect(generateAutomationHandlerModule(chipsOnly)).toBe(generateHandlerFunction(chipsOnly))
3598
+ })
3599
+ })
3600
+
3601
+ describe('in-app event bus — the activity kick', () => {
3602
+ let files: Map<string, string>
3603
+
3604
+ beforeAll(async () => {
3605
+ files = sources(
3606
+ await generate({ ...BASE_WORKFLOWS, ...AUTOMATIONS }, TELEPORT_AUTOMATIONS, {
3607
+ files: FETCHERS,
3608
+ })
3609
+ )
3610
+ })
3611
+
3612
+ interface DueCheck {
3613
+ kickWhenDue(req?: Row): Promise<boolean> | null
3614
+ }
3615
+
3616
+ const seedDueWait = (db: FakeDatabase) => {
3617
+ seedSignup(db)
3618
+ db.waits.push({
3619
+ id: 900,
3620
+ event_id: 'evt-signup',
3621
+ workflow_id: 'wf-signup-wait',
3622
+ resume_node_id: 'after',
3623
+ context: JSON.stringify({ wait: { until: ago(MINUTE_MS).toISOString() } }),
3624
+ run_at: ago(MINUTE_MS),
3625
+ status: 'waiting',
3626
+ created_at: ago(DAY_MS),
3627
+ })
3628
+ }
3629
+
3630
+ const DISPATCH = 'https://shop.test/api/automations/dispatch'
3631
+
3632
+ it('checks for due work at most once a minute per process, and wakes the queue only for some', async () => {
3633
+ const db = new FakeDatabase()
3634
+ seedDueWait(db)
3635
+ let now = Date.now()
3636
+ const clock = jest.spyOn(Date, 'now').mockImplementation(() => now)
3637
+ try {
3638
+ const run = await withEnv(ENV, () =>
3639
+ withFetch(
3640
+ () => ({ status: 200, body: {} }),
3641
+ async (calls) => {
3642
+ const check = createApp(files, db)('lib/automations/due-check.js') as DueCheck
3643
+ const req = { headers: { host: 'shop.test' } }
3644
+ const first = await check.kickWhenDue(req)
3645
+ const afterFirst = calls.length
3646
+ // Within the minute: no query, no kick.
3647
+ const statements = db.statements.length
3648
+ const second = check.kickWhenDue(req)
3649
+ const quiet = db.statements.length === statements
3650
+ now += 61 * 1000
3651
+ const third = await check.kickWhenDue(req)
3652
+ return { first, afterFirst, second, quiet, third, calls }
3653
+ }
3654
+ )
3655
+ )
3656
+ expect(run.first).toBe(true)
3657
+ expect(run.afterFirst).toBe(1)
3658
+ expect(run.second).toBeNull()
3659
+ expect(run.quiet).toBe(true)
3660
+ expect(run.third).toBe(true)
3661
+ expect(run.calls.map((call) => [call.url, call.headers['x-internal-data-secret']])).toEqual([
3662
+ [DISPATCH, 'app-secret'],
3663
+ [DISPATCH, 'app-secret'],
3664
+ ])
3665
+ } finally {
3666
+ clock.mockRestore()
3667
+ }
3668
+ })
3669
+
3670
+ it('wakes nothing when nothing is due, and never runs while the site is being built', async () => {
3671
+ const idle = new FakeDatabase()
3672
+ seedSignup(idle)
3673
+ idle.events[0].status = 'delivered'
3674
+ const quiet = await withEnv(ENV, () =>
3675
+ withFetch(
3676
+ () => ({ status: 200, body: {} }),
3677
+ async (calls) => {
3678
+ const check = createApp(files, idle)('lib/automations/due-check.js') as DueCheck
3679
+ return { kicked: await check.kickWhenDue(), calls }
3680
+ }
3681
+ )
3682
+ )
3683
+ expect(quiet.kicked).toBe(false)
3684
+ expect(quiet.calls).toEqual([])
3685
+ const due = idle.statements.find((text) => text.startsWith('SELECT (EXISTS'))!
3686
+ expect(due).toContain("w.status = 'waiting' AND w.run_at <= now()")
3687
+ expect(due).toContain("(e.status = 'failed' OR (e.status = 'pending' AND (GREATEST(")
3688
+
3689
+ // A retry due counts as much as a Wait.
3690
+ const failed = new FakeDatabase()
3691
+ seedSignup(failed)
3692
+ failed.events[0].status = 'failed'
3693
+ failed.events[0].attempts = 1
3694
+ const retry = await withEnv(ENV, () =>
3695
+ withFetch(
3696
+ () => ({ status: 200, body: {} }),
3697
+ async (calls) => ({
3698
+ kicked: await (
3699
+ createApp(files, failed)('lib/automations/due-check.js') as DueCheck
3700
+ ).kickWhenDue(),
3701
+ calls,
3702
+ })
3703
+ )
3704
+ )
3705
+ expect(retry.kicked).toBe(true)
3706
+ expect(retry.calls.map((call) => call.url)).toEqual([DISPATCH])
3707
+
3708
+ const building = new FakeDatabase()
3709
+ seedDueWait(building)
3710
+ const built = await withEnv({ ...ENV, NEXT_PHASE: 'phase-production-build' }, () =>
3711
+ withFetch(
3712
+ () => ({ status: 200, body: {} }),
3713
+ async () =>
3714
+ (createApp(files, building)('lib/automations/due-check.js') as DueCheck).kickWhenDue()
3715
+ )
3716
+ )
3717
+ expect(built).toBeNull()
3718
+ expect(building.statements).toEqual([])
3719
+ })
3720
+
3721
+ it('wakes the queue for what a stopped dispatcher left: a stale pending event, a stale resume', async () => {
3722
+ const kicked = async (database: FakeDatabase) =>
3723
+ withEnv(ENV, () =>
3724
+ withFetch(
3725
+ () => ({ status: 200, body: {} }),
3726
+ async (calls) => {
3727
+ const check = createApp(files, database)('lib/automations/due-check.js') as DueCheck
3728
+ return { kicked: await check.kickWhenDue(), calls: calls.length }
3729
+ }
3730
+ )
3731
+ )
3732
+ const pending = (claimedAgo: number) => {
3733
+ const pendingDb = new FakeDatabase()
3734
+ seedSignup(pendingDb)
3735
+ Object.assign(pendingDb.events[0], {
3736
+ status: 'pending',
3737
+ attempts: 1,
3738
+ created_at: ago(claimedAgo + MINUTE_MS),
3739
+ claimed_at: ago(claimedAgo),
3740
+ })
3741
+ return pendingDb
3742
+ }
3743
+ expect(await kicked(pending(STALE_MS + MINUTE_MS))).toEqual({ kicked: true, calls: 1 })
3744
+ // Claimed a minute ago: another dispatcher is still on it.
3745
+ expect(await kicked(pending(MINUTE_MS))).toEqual({ kicked: false, calls: 0 })
3746
+
3747
+ const resuming = (claimedAgo: number) => {
3748
+ const resumingDb = new FakeDatabase()
3749
+ seedDueWait(resumingDb)
3750
+ resumingDb.events[0].status = 'delivered'
3751
+ Object.assign(resumingDb.waits[0], { status: 'resuming', run_at: ago(claimedAgo) })
3752
+ return resumingDb
3753
+ }
3754
+ expect(await kicked(resuming(STALE_MS + MINUTE_MS))).toEqual({ kicked: true, calls: 1 })
3755
+ expect(await kicked(resuming(MINUTE_MS))).toEqual({ kicked: false, calls: 0 })
3756
+
3757
+ // The very fragments the dispatcher claims with.
3758
+ const db = resuming(MINUTE_MS)
3759
+ await kicked(db)
3760
+ const due = db.statements.find((text) => text.startsWith('SELECT (EXISTS'))!
3761
+ const claims = sources(await generate(AUTOMATIONS)).get('lib/automations/dispatcher.js')!
3762
+ expect(due).toContain(
3763
+ "OR (w.status = 'resuming' AND w.run_at < now() - interval '15 minutes'))"
3764
+ )
3765
+ const stale =
3766
+ "(GREATEST(COALESCE(e.claimed_at, e.created_at), COALESCE((SELECT max(d.started_at) FROM teleport_event_deliveries d WHERE d.event_id = e.id), e.created_at)) < now() - interval '15 minutes' AND NOT EXISTS (SELECT 1 FROM teleport_event_deliveries d WHERE d.event_id = e.id AND d.status = 'waiting' AND d.workflow_id = ANY("
3767
+ expect(due).toContain(stale)
3768
+ expect(claims).toContain(stale)
3769
+ })
3770
+
3771
+ it("is kept alive by Vercel's waitUntil there, off the request either way", async () => {
3772
+ const db = new FakeDatabase()
3773
+ seedDueWait(db)
3774
+ const scope = globalThis as Record<string, unknown>
3775
+ const held: Array<Promise<unknown>> = []
3776
+ scope.__tqWaitUntil = (promise: Promise<unknown>) => {
3777
+ held.push(promise)
3778
+ }
3779
+ try {
3780
+ await withEnv({ ...ENV, VERCEL: '1' }, () =>
3781
+ withFetch(
3782
+ () => ({ status: 200, body: {} }),
3783
+ async () => {
3784
+ const check = createApp(files, db)('lib/automations/due-check.js') as DueCheck
3785
+ const pending = check.kickWhenDue()
3786
+ // Handed to the platform before the request goes on.
3787
+ expect(held).toHaveLength(1)
3788
+ await pending
3789
+ await Promise.all(held)
3790
+ }
3791
+ )
3792
+ )
3793
+ } finally {
3794
+ delete scope.__tqWaitUntil
3795
+ }
3796
+ // The check, then the queue itself in the same waitUntil.
3797
+ expect(held.length).toBeGreaterThanOrEqual(2)
3798
+ expect(db.waits.find((w) => w.id === 900)!.status).toBe('done')
3799
+ })
3800
+
3801
+ it('starts from every server read of the TeleportHQ database and from the data API, nowhere else', () => {
3802
+ for (const path of TELEPORT_READS) {
3803
+ const source = files.get(path)!
3804
+ expect(
3805
+ source.startsWith(
3806
+ "import { kickWhenDue as __tqKickDueAutomations } from '../../lib/automations/due-check'\n"
3807
+ )
3808
+ ).toBe(true)
3809
+ expect(source).toContain(
3810
+ 'async function handler(req, res) {\n // Activity on the site: due automation work (a Wait, a retry) starts off this request.\n __tqKickDueAutomations(req)\n'
3811
+ )
3812
+ }
3813
+ for (const path of [REEXPORT_ROUTE, OWN_ITEMS_MODULE]) {
3814
+ expect(files.get(path)).not.toContain('due-check')
3815
+ }
3816
+ expect(files.get(DATA_API_ROUTE)).toContain(
3817
+ "require('../../../lib/automations/due-check').kickWhenDue(req);"
3818
+ )
3819
+ })
3820
+ })