@shware/workflow 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/README.md +96 -2
  2. package/dist/apns-relay.cjs +4 -17
  3. package/dist/apns-relay.cjs.map +1 -1
  4. package/dist/apns-relay.mjs +4 -17
  5. package/dist/apns-relay.mjs.map +1 -1
  6. package/dist/aws/engine-step.cjs +104 -0
  7. package/dist/aws/engine-step.cjs.map +1 -0
  8. package/dist/aws/engine-step.d.cts +28 -0
  9. package/dist/aws/engine-step.d.cts.map +1 -0
  10. package/dist/aws/engine-step.d.mts +28 -0
  11. package/dist/aws/engine-step.d.mts.map +1 -0
  12. package/dist/aws/engine-step.mjs +102 -0
  13. package/dist/aws/engine-step.mjs.map +1 -0
  14. package/dist/aws/function-url.cjs +62 -0
  15. package/dist/aws/function-url.cjs.map +1 -0
  16. package/dist/aws/function-url.d.cts +38 -0
  17. package/dist/aws/function-url.d.cts.map +1 -0
  18. package/dist/aws/function-url.d.mts +38 -0
  19. package/dist/aws/function-url.d.mts.map +1 -0
  20. package/dist/aws/function-url.mjs +59 -0
  21. package/dist/aws/function-url.mjs.map +1 -0
  22. package/dist/aws/index.cjs +160 -0
  23. package/dist/aws/index.cjs.map +1 -0
  24. package/dist/aws/index.d.cts +62 -0
  25. package/dist/aws/index.d.cts.map +1 -0
  26. package/dist/aws/index.d.mts +62 -0
  27. package/dist/aws/index.d.mts.map +1 -0
  28. package/dist/aws/index.mjs +148 -0
  29. package/dist/aws/index.mjs.map +1 -0
  30. package/dist/aws/instances.cjs +176 -0
  31. package/dist/aws/instances.cjs.map +1 -0
  32. package/dist/aws/instances.d.cts +75 -0
  33. package/dist/aws/instances.d.cts.map +1 -0
  34. package/dist/aws/instances.d.mts +75 -0
  35. package/dist/aws/instances.d.mts.map +1 -0
  36. package/dist/aws/instances.mjs +172 -0
  37. package/dist/aws/instances.mjs.map +1 -0
  38. package/dist/aws/lambda-api.cjs +89 -0
  39. package/dist/aws/lambda-api.cjs.map +1 -0
  40. package/dist/aws/lambda-api.d.cts +43 -0
  41. package/dist/aws/lambda-api.d.cts.map +1 -0
  42. package/dist/aws/lambda-api.d.mts +43 -0
  43. package/dist/aws/lambda-api.d.mts.map +1 -0
  44. package/dist/aws/lambda-api.mjs +88 -0
  45. package/dist/aws/lambda-api.mjs.map +1 -0
  46. package/dist/client.d.cts +1 -1
  47. package/dist/client.d.mts +1 -1
  48. package/dist/cloudflare/bindings.cjs +19 -0
  49. package/dist/cloudflare/bindings.cjs.map +1 -1
  50. package/dist/cloudflare/bindings.d.cts +37 -0
  51. package/dist/cloudflare/bindings.d.cts.map +1 -1
  52. package/dist/cloudflare/bindings.d.mts +37 -0
  53. package/dist/cloudflare/bindings.d.mts.map +1 -1
  54. package/dist/cloudflare/bindings.mjs +18 -1
  55. package/dist/cloudflare/bindings.mjs.map +1 -1
  56. package/dist/cloudflare/index.cjs +32 -28
  57. package/dist/cloudflare/index.d.cts +8 -6
  58. package/dist/cloudflare/index.d.mts +8 -6
  59. package/dist/cloudflare/index.mjs +12 -10
  60. package/dist/cloudflare/runner.cjs +27 -76
  61. package/dist/cloudflare/runner.cjs.map +1 -1
  62. package/dist/cloudflare/runner.d.cts +7 -2
  63. package/dist/cloudflare/runner.d.cts.map +1 -1
  64. package/dist/cloudflare/runner.d.mts +7 -2
  65. package/dist/cloudflare/runner.d.mts.map +1 -1
  66. package/dist/cloudflare/runner.mjs +26 -75
  67. package/dist/cloudflare/runner.mjs.map +1 -1
  68. package/dist/cloudflare/worker.cjs +22 -203
  69. package/dist/cloudflare/worker.cjs.map +1 -1
  70. package/dist/cloudflare/worker.d.cts +10 -56
  71. package/dist/cloudflare/worker.d.cts.map +1 -1
  72. package/dist/cloudflare/worker.d.mts +10 -56
  73. package/dist/cloudflare/worker.d.mts.map +1 -1
  74. package/dist/cloudflare/worker.mjs +20 -199
  75. package/dist/cloudflare/worker.mjs.map +1 -1
  76. package/dist/config.cjs +2 -2
  77. package/dist/config.cjs.map +1 -1
  78. package/dist/config.d.cts +1 -1
  79. package/dist/config.d.mts +1 -1
  80. package/dist/config.mjs +2 -2
  81. package/dist/config.mjs.map +1 -1
  82. package/dist/contract.cjs.map +1 -1
  83. package/dist/contract.d.cts +1 -1
  84. package/dist/contract.d.cts.map +1 -1
  85. package/dist/contract.d.mts +1 -1
  86. package/dist/contract.d.mts.map +1 -1
  87. package/dist/contract.mjs.map +1 -1
  88. package/dist/dsl/action.cjs.map +1 -1
  89. package/dist/dsl/action.d.cts +2 -2
  90. package/dist/dsl/action.d.cts.map +1 -1
  91. package/dist/dsl/action.d.mts +2 -2
  92. package/dist/dsl/action.d.mts.map +1 -1
  93. package/dist/dsl/action.mjs.map +1 -1
  94. package/dist/dsl/bundle.cjs.map +1 -1
  95. package/dist/dsl/bundle.d.cts +1 -1
  96. package/dist/dsl/bundle.d.mts +1 -1
  97. package/dist/dsl/bundle.mjs.map +1 -1
  98. package/dist/dsl/flow.cjs.map +1 -1
  99. package/dist/dsl/flow.d.cts +1 -1
  100. package/dist/dsl/flow.d.cts.map +1 -1
  101. package/dist/dsl/flow.d.mts +1 -1
  102. package/dist/dsl/flow.d.mts.map +1 -1
  103. package/dist/dsl/flow.mjs.map +1 -1
  104. package/dist/dsl/refs.cjs.map +1 -1
  105. package/dist/dsl/refs.d.cts +5 -4
  106. package/dist/dsl/refs.d.cts.map +1 -1
  107. package/dist/dsl/refs.d.mts +5 -4
  108. package/dist/dsl/refs.d.mts.map +1 -1
  109. package/dist/dsl/refs.mjs.map +1 -1
  110. package/dist/dsl/template.cjs.map +1 -1
  111. package/dist/dsl/template.d.cts +2 -2
  112. package/dist/dsl/template.d.cts.map +1 -1
  113. package/dist/dsl/template.d.mts +2 -2
  114. package/dist/dsl/template.d.mts.map +1 -1
  115. package/dist/dsl/template.mjs.map +1 -1
  116. package/dist/email-sender.cjs +62 -0
  117. package/dist/email-sender.cjs.map +1 -0
  118. package/dist/email-sender.d.cts +70 -0
  119. package/dist/email-sender.d.cts.map +1 -0
  120. package/dist/email-sender.d.mts +70 -0
  121. package/dist/email-sender.d.mts.map +1 -0
  122. package/dist/email-sender.mjs +60 -0
  123. package/dist/email-sender.mjs.map +1 -0
  124. package/dist/email.cjs +21 -0
  125. package/dist/email.cjs.map +1 -1
  126. package/dist/email.d.cts +6 -0
  127. package/dist/email.d.cts.map +1 -1
  128. package/dist/email.d.mts +6 -0
  129. package/dist/email.d.mts.map +1 -1
  130. package/dist/email.mjs +21 -1
  131. package/dist/email.mjs.map +1 -1
  132. package/dist/engine/index.cjs +1 -1
  133. package/dist/engine/index.mjs +1 -1
  134. package/dist/engine/interpreter.cjs +1 -1
  135. package/dist/engine/interpreter.cjs.map +1 -1
  136. package/dist/engine/interpreter.d.cts.map +1 -1
  137. package/dist/engine/interpreter.d.mts.map +1 -1
  138. package/dist/engine/interpreter.mjs +1 -1
  139. package/dist/engine/interpreter.mjs.map +1 -1
  140. package/dist/engine/ports.d.cts +6 -1
  141. package/dist/engine/ports.d.cts.map +1 -1
  142. package/dist/engine/ports.d.mts +6 -1
  143. package/dist/engine/ports.d.mts.map +1 -1
  144. package/dist/{cloudflare → host}/channels.cjs +13 -11
  145. package/dist/host/channels.cjs.map +1 -0
  146. package/dist/{cloudflare → host}/channels.d.cts +8 -3
  147. package/dist/host/channels.d.cts.map +1 -0
  148. package/dist/{cloudflare → host}/channels.d.mts +8 -3
  149. package/dist/host/channels.d.mts.map +1 -0
  150. package/dist/{cloudflare → host}/channels.mjs +9 -7
  151. package/dist/host/channels.mjs.map +1 -0
  152. package/dist/host/execute.cjs +82 -0
  153. package/dist/host/execute.cjs.map +1 -0
  154. package/dist/host/execute.d.cts +41 -0
  155. package/dist/host/execute.d.cts.map +1 -0
  156. package/dist/host/execute.d.mts +41 -0
  157. package/dist/host/execute.d.mts.map +1 -0
  158. package/dist/host/execute.mjs +81 -0
  159. package/dist/host/execute.mjs.map +1 -0
  160. package/dist/host/journey-host.cjs +214 -0
  161. package/dist/host/journey-host.cjs.map +1 -0
  162. package/dist/host/journey-host.d.cts +105 -0
  163. package/dist/host/journey-host.d.cts.map +1 -0
  164. package/dist/host/journey-host.d.mts +105 -0
  165. package/dist/host/journey-host.d.mts.map +1 -0
  166. package/dist/host/journey-host.mjs +210 -0
  167. package/dist/host/journey-host.mjs.map +1 -0
  168. package/dist/{cloudflare → host}/router.cjs +28 -9
  169. package/dist/host/router.cjs.map +1 -0
  170. package/dist/{cloudflare → host}/router.d.cts +2 -2
  171. package/dist/host/router.d.cts.map +1 -0
  172. package/dist/{cloudflare → host}/router.d.mts +2 -2
  173. package/dist/host/router.d.mts.map +1 -0
  174. package/dist/{cloudflare → host}/router.mjs +28 -9
  175. package/dist/host/router.mjs.map +1 -0
  176. package/dist/host/senders.cjs +63 -0
  177. package/dist/host/senders.cjs.map +1 -0
  178. package/dist/host/senders.d.cts +31 -0
  179. package/dist/host/senders.d.cts.map +1 -0
  180. package/dist/host/senders.d.mts +31 -0
  181. package/dist/host/senders.d.mts.map +1 -0
  182. package/dist/host/senders.mjs +59 -0
  183. package/dist/host/senders.mjs.map +1 -0
  184. package/dist/http2-client.cjs +88 -0
  185. package/dist/http2-client.cjs.map +1 -0
  186. package/dist/http2-client.d.cts +44 -0
  187. package/dist/http2-client.d.cts.map +1 -0
  188. package/dist/http2-client.d.mts +44 -0
  189. package/dist/http2-client.d.mts.map +1 -0
  190. package/dist/http2-client.mjs +86 -0
  191. package/dist/http2-client.mjs.map +1 -0
  192. package/dist/index.cjs +1 -0
  193. package/dist/index.d.cts +2 -2
  194. package/dist/index.d.mts +2 -2
  195. package/dist/index.mjs +2 -2
  196. package/dist/react-email.cjs.map +1 -1
  197. package/dist/react-email.d.cts +1 -2
  198. package/dist/react-email.d.cts.map +1 -1
  199. package/dist/react-email.d.mts +1 -2
  200. package/dist/react-email.d.mts.map +1 -1
  201. package/dist/react-email.mjs.map +1 -1
  202. package/dist/reporting/tracking.cjs +5 -1
  203. package/dist/reporting/tracking.cjs.map +1 -1
  204. package/dist/reporting/tracking.d.cts.map +1 -1
  205. package/dist/reporting/tracking.d.mts.map +1 -1
  206. package/dist/reporting/tracking.mjs +5 -1
  207. package/dist/reporting/tracking.mjs.map +1 -1
  208. package/dist/store/d1.cjs +1 -0
  209. package/dist/store/d1.cjs.map +1 -1
  210. package/dist/store/d1.d.cts.map +1 -1
  211. package/dist/store/d1.d.mts.map +1 -1
  212. package/dist/store/d1.mjs +1 -0
  213. package/dist/store/d1.mjs.map +1 -1
  214. package/dist/store/index.cjs.map +1 -1
  215. package/dist/store/index.d.cts +63 -0
  216. package/dist/store/index.d.cts.map +1 -1
  217. package/dist/store/index.d.mts +63 -0
  218. package/dist/store/index.d.mts.map +1 -1
  219. package/dist/store/index.mjs.map +1 -1
  220. package/dist/store/postgres-reporting.cjs +5 -5
  221. package/dist/store/postgres-reporting.cjs.map +1 -1
  222. package/dist/store/postgres-reporting.mjs +5 -5
  223. package/dist/store/postgres-reporting.mjs.map +1 -1
  224. package/dist/store/postgres.cjs +143 -2
  225. package/dist/store/postgres.cjs.map +1 -1
  226. package/dist/store/postgres.d.cts +4 -1
  227. package/dist/store/postgres.d.cts.map +1 -1
  228. package/dist/store/postgres.d.mts +4 -1
  229. package/dist/store/postgres.d.mts.map +1 -1
  230. package/dist/store/postgres.mjs +143 -2
  231. package/dist/store/postgres.mjs.map +1 -1
  232. package/dist/store/tables.cjs +26 -0
  233. package/dist/store/tables.cjs.map +1 -1
  234. package/dist/store/tables.d.cts +1 -0
  235. package/dist/store/tables.d.cts.map +1 -1
  236. package/dist/store/tables.d.mts +1 -0
  237. package/dist/store/tables.d.mts.map +1 -1
  238. package/dist/store/tables.mjs +26 -0
  239. package/dist/store/tables.mjs.map +1 -1
  240. package/dist/templates.cjs.map +1 -1
  241. package/dist/templates.mjs.map +1 -1
  242. package/dist/transports/cloudflare-email.cjs +59 -0
  243. package/dist/transports/cloudflare-email.cjs.map +1 -0
  244. package/dist/transports/cloudflare-email.d.cts +38 -0
  245. package/dist/transports/cloudflare-email.d.cts.map +1 -0
  246. package/dist/transports/cloudflare-email.d.mts +38 -0
  247. package/dist/transports/cloudflare-email.d.mts.map +1 -0
  248. package/dist/transports/cloudflare-email.mjs +57 -0
  249. package/dist/transports/cloudflare-email.mjs.map +1 -0
  250. package/dist/transports/ses.cjs +91 -0
  251. package/dist/transports/ses.cjs.map +1 -0
  252. package/dist/transports/ses.d.cts +39 -0
  253. package/dist/transports/ses.d.cts.map +1 -0
  254. package/dist/transports/ses.d.mts +39 -0
  255. package/dist/transports/ses.d.mts.map +1 -0
  256. package/dist/transports/ses.mjs +89 -0
  257. package/dist/transports/ses.mjs.map +1 -0
  258. package/dist/transports/sigv4.cjs +83 -0
  259. package/dist/transports/sigv4.cjs.map +1 -0
  260. package/dist/transports/sigv4.d.cts +27 -0
  261. package/dist/transports/sigv4.d.cts.map +1 -0
  262. package/dist/transports/sigv4.d.mts +27 -0
  263. package/dist/transports/sigv4.d.mts.map +1 -0
  264. package/dist/transports/sigv4.mjs +82 -0
  265. package/dist/transports/sigv4.mjs.map +1 -0
  266. package/package.json +53 -1
  267. package/dist/cloudflare/channels.cjs.map +0 -1
  268. package/dist/cloudflare/channels.d.cts.map +0 -1
  269. package/dist/cloudflare/channels.d.mts.map +0 -1
  270. package/dist/cloudflare/channels.mjs.map +0 -1
  271. package/dist/cloudflare/router.cjs.map +0 -1
  272. package/dist/cloudflare/router.d.cts.map +0 -1
  273. package/dist/cloudflare/router.d.mts.map +0 -1
  274. package/dist/cloudflare/router.mjs.map +0 -1
  275. package/dist/cloudflare/senders.cjs +0 -121
  276. package/dist/cloudflare/senders.cjs.map +0 -1
  277. package/dist/cloudflare/senders.d.cts +0 -72
  278. package/dist/cloudflare/senders.d.cts.map +0 -1
  279. package/dist/cloudflare/senders.d.mts +0 -72
  280. package/dist/cloudflare/senders.d.mts.map +0 -1
  281. package/dist/cloudflare/senders.mjs +0 -115
  282. package/dist/cloudflare/senders.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"email.mjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe link\n * its layout carries. Each `emailTemplate` declares its own `from` (and `replyTo`, if replies should\n * go elsewhere); the unsubscribe link is the template's `unsubscribeUrl`, else the config's\n * `email.unsubscribeUrl`, else none. `resolveEmail` is that rule, and the sender, the renderer,\n * the Worker's preview and the studio all go through it. No React and no I/O here, so a browser\n * can import it.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /** Absent when neither the template nor the config names one: the email has no unsubscribe link. */\n unsubscribeUrl?: string;\n}\n\n/** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl'>,\n config?: EmailConfig\n): ResolvedEmail {\n const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;\n return {\n from: template.from,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n };\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertUnsubscribeUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates)\n assertEmailMetadata(template, `email template '${template.id}'`);\n}\n\n/** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */\nexport function assertEmailMetadata(\n template: { from?: unknown; replyTo?: unknown; unsubscribeUrl?: unknown },\n where: string\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":";;AAeA,SAAgB,mBAAmB,SAA+B;CAChE,IAAI,OAAO,YAAY,UAAU,OAAO;CACxC,OAAO,QAAQ,SAAS,KAAA,IAAY,QAAQ,QAAQ,GAAG,QAAQ,KAAK,IAAI,QAAQ,MAAM;AACxF;;AA+BA,SAAgB,aACd,UACA,QACe;CACf,MAAM,iBAAiB,SAAS,kBAAkB,QAAQ;CAC1D,OAAO;EACL,MAAM,SAAS;EACf,GAAI,SAAS,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,SAAS,QAAQ;EACtE,GAAI,mBAAmB,KAAA,IAAY,CAAC,IAAI,EAAE,eAAe;CAC3D;AACF;AAEA,MAAM,UAAU;;AAGhB,SAAgB,mBAAmB,SAAkB,OAAqB;CACxE,MAAM,QACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAAgC,QACjC;CACN,MAAM,OACJ,OAAO,YAAY,YAAY,YAAY,OACtC,QAA+B,OAChC,KAAA;CACN,IAAI,OAAO,UAAU,YAAY,CAAC,QAAQ,KAAK,KAAK,GAClD,MAAM,IAAI,UAAU,GAAG,MAAM,8CAA8C;CAE7E,IAAI,SAAS,KAAA,KAAa,OAAO,SAAS,UACxC,MAAM,IAAI,UAAU,GAAG,MAAM,uBAAuB;AAExD;;AAGA,SAAgB,qBAAqB,KAAc,OAAqB;CACtE,IAAI;CACJ,IAAI;EACF,SAAS,OAAO,QAAQ,WAAW,IAAI,IAAI,GAAG,IAAI,KAAA;CACpD,QAAQ;EACN,SAAS,KAAA;CACX;CACA,IAAI,WAAW,KAAA,KAAc,OAAO,aAAa,YAAY,OAAO,aAAa,SAC/E,MAAM,IAAI,UAAU,GAAG,MAAM,gDAAgD;AAEjF;;AAGA,SAAgB,kBACd,QACA,WACM;CACN,IAAI,OAAO,OAAO,QAAQ,QAAQ,GAChC,MAAM,IAAI,UACR,qJAEF;CAEF,MAAM,QAAQ,OAAO;CACrB,IAAI,UAAU,KAAA,GAAW;EACvB,IAAI,MAAM,mBAAmB,KAAA,GAC3B,qBAAqB,MAAM,gBAAgB,sBAAsB;EAEnE,MAAM,QAAQ,MAAM,UAAU;EAC9B,IACE,UAAU,KAAA,KACV,EAAE,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,KAAK,SAAS,IAElE,MAAM,IAAI,UAAU,2DAA2D;CAEnF;CACA,KAAK,MAAM,YAAY,WACrB,oBAAoB,UAAU,mBAAmB,SAAS,GAAG,EAAE;AACnE;;AAGA,SAAgB,oBACd,UACA,OACM;CACN,mBAAmB,SAAS,MAAM,GAAG,MAAM,OAAO;CAClD,IAAI,SAAS,YAAY,KAAA,GAAW,mBAAmB,SAAS,SAAS,GAAG,MAAM,UAAU;CAC5F,IAAI,SAAS,mBAAmB,KAAA,GAC9B,qBAAqB,SAAS,gBAAgB,GAAG,MAAM,iBAAiB;AAE5E;;AAGA,SAAgB,kBAAkB,KAAa,gBAA6C;CAC1F,IAAI,mBAAmB,KAAA,GAAW,OAAO;CACzC,IAAI;EACF,MAAM,IAAI,IAAI,IAAI,GAAG;EACrB,MAAM,IAAI,IAAI,IAAI,cAAc;EAChC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE;CACnD,QAAQ;EACN,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"email.mjs","names":[],"sources":["../src/email.ts"],"sourcesContent":["import type { EmailTemplate } from './templates';\n\n/**\n * Email metadata: who a template's email comes from, where replies go, and the unsubscribe link\n * its layout carries. Each `emailTemplate` declares its own `from` (and `replyTo`, if replies should\n * go elsewhere); the unsubscribe link is the template's `unsubscribeUrl`, else the config's\n * `email.unsubscribeUrl`, else none. `resolveEmail` is that rule, and the sender, the renderer,\n * the Worker's preview and the studio all go through it. No React and no I/O here, so a browser\n * can import it.\n */\n\n/** An address as the transports take it: bare, or with a display name. */\nexport type EmailAddress = string | { email: string; name?: string };\n\n/** `Name <email>`, or the bare address. */\nexport function formatEmailAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n return address.name === undefined ? address.email : `${address.name} <${address.email}>`;\n}\n\nconst ASCII = /^[\\x20-\\x7e]*$/;\nconst SPECIALS = /[()<>[\\]:;@\\\\,.\"]/;\n\n/**\n * An address as a mail API reads it (RFC 5322): a display name with specials is quoted, one\n * outside ASCII is a MIME encoded-word — raw non-ASCII names are refused (SES), and an unquoted\n * comma splits the address in two.\n */\nexport function mimeAddress(address: EmailAddress): string {\n if (typeof address === 'string') return address;\n const { email, name } = address;\n if (name === undefined || name === '') return email;\n if (!ASCII.test(name)) {\n const bytes = new TextEncoder().encode(name);\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return `=?UTF-8?B?${btoa(binary)}?= <${email}>`;\n }\n if (SPECIALS.test(name)) return `\"${name.replace(/([\"\\\\])/g, '\\\\$1')}\" <${email}>`;\n return `${name} <${email}>`;\n}\n\n/** The config's `email`: what every email template shares. */\nexport interface EmailConfig {\n /**\n * The unsubscribe link of every email template that names none of its own. The layout reads it\n * with `useEmailContext()`; sent, it is tracked like every link, but following it shows a\n * confirmation page, and only the confirmation (a POST no link scanner sends) unsubscribes.\n */\n unsubscribeUrl?: string;\n /** How the Worker tells a person's opens and clicks from a machine's (see the reporting classifier). */\n tracking?: {\n /**\n * An open or click this many milliseconds after delivery is a weak machine signal — machine\n * only together with another (a second link within seconds); 1000 by default. A person\n * tapping a mail notification can click within a few seconds, so keep it short.\n */\n quickClickMs?: number;\n };\n}\n\n/** One template's email metadata, resolved against the config. */\nexport interface ResolvedEmail {\n from: EmailAddress;\n /** Absent when the template names none: the email goes out without a Reply-To. */\n replyTo?: EmailAddress;\n /** Absent when neither the template nor the config names one: the email has no unsubscribe link. */\n unsubscribeUrl?: string;\n}\n\n/** The template's metadata, its `unsubscribeUrl` falling back to the config's. Leaves both untouched. */\nexport function resolveEmail(\n template: Pick<EmailTemplate, 'from' | 'replyTo' | 'unsubscribeUrl'>,\n config?: EmailConfig\n): ResolvedEmail {\n const unsubscribeUrl = template.unsubscribeUrl ?? config?.unsubscribeUrl;\n return {\n from: template.from,\n ...(template.replyTo === undefined ? {} : { replyTo: template.replyTo }),\n ...(unsubscribeUrl === undefined ? {} : { unsubscribeUrl }),\n };\n}\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+$/;\n\n/** Throws naming `where` when `address` is neither `user@host` nor `{ email: 'user@host', name? }`. */\nexport function assertEmailAddress(address: unknown, where: string): void {\n const email =\n typeof address === 'object' && address !== null\n ? (address as { email?: unknown }).email\n : address;\n const name =\n typeof address === 'object' && address !== null\n ? (address as { name?: unknown }).name\n : undefined;\n if (typeof email !== 'string' || !ADDRESS.test(email)) {\n throw new TypeError(`${where} must be an email address or { email, name? }`);\n }\n if (name !== undefined && typeof name !== 'string') {\n throw new TypeError(`${where}.name must be a string`);\n }\n}\n\n/** Throws naming `where` unless `url` is an absolute http(s) URL; '' and null are not a way to leave it out. */\nexport function assertUnsubscribeUrl(url: unknown, where: string): void {\n let parsed: URL | undefined;\n try {\n parsed = typeof url === 'string' ? new URL(url) : undefined;\n } catch {\n parsed = undefined;\n }\n if (parsed === undefined || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {\n throw new TypeError(`${where} must be an http(s) URL (leave it out for none)`);\n }\n}\n\n/** The config's `email` and every registered template's metadata; throws on the first invalid value. */\nexport function assertEmailConfig(\n config: { email?: EmailConfig },\n templates: Iterable<EmailTemplate>\n): void {\n if (Object.hasOwn(config, 'emails')) {\n throw new TypeError(\n \"the config's `emails` is gone: `from` and `replyTo` are declared on each emailTemplate, \" +\n 'and the rest moved to `email: { unsubscribeUrl, tracking }`'\n );\n }\n const email = config.email;\n if (email !== undefined) {\n if (email.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(email.unsubscribeUrl, 'email.unsubscribeUrl');\n }\n const quick = email.tracking?.quickClickMs;\n if (\n quick !== undefined &&\n !(typeof quick === 'number' && Number.isFinite(quick) && quick >= 0)\n ) {\n throw new TypeError('email.tracking.quickClickMs must be a non-negative number');\n }\n }\n for (const template of templates)\n assertEmailMetadata(template, `email template '${template.id}'`);\n}\n\n/** A template's own `from` / `replyTo` / `unsubscribeUrl`, as `emailTemplate` checks them. */\nexport function assertEmailMetadata(\n template: { from?: unknown; replyTo?: unknown; unsubscribeUrl?: unknown },\n where: string\n): void {\n assertEmailAddress(template.from, `${where}: from`);\n if (template.replyTo !== undefined) assertEmailAddress(template.replyTo, `${where}: replyTo`);\n if (template.unsubscribeUrl !== undefined) {\n assertUnsubscribeUrl(template.unsubscribeUrl, `${where}: unsubscribeUrl`);\n }\n}\n\n/** The unsubscribe link may carry its own query (a token, a list): the path names it. */\nexport function isUnsubscribeLink(url: string, unsubscribeUrl: string | undefined): boolean {\n if (unsubscribeUrl === undefined) return false;\n try {\n const a = new URL(url);\n const b = new URL(unsubscribeUrl);\n return a.origin === b.origin && a.pathname === b.pathname;\n } catch {\n return false;\n }\n}\n"],"mappings":"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"}
@@ -1,10 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_engine_bucket = require("./bucket.cjs");
3
3
  const require_engine_condition = require("./condition.cjs");
4
- const require_engine_actions = require("./actions.cjs");
5
4
  const require_engine_time_window = require("./time-window.cjs");
6
5
  const require_engine_interpreter = require("./interpreter.cjs");
7
6
  const require_engine_time_scale = require("./time-scale.cjs");
7
+ const require_engine_actions = require("./actions.cjs");
8
8
  exports.DEVICES_UPDATED_EVENT = require_engine_condition.DEVICES_UPDATED_EVENT;
9
9
  exports.PROFILE_UPDATED_EVENT = require_engine_condition.PROFILE_UPDATED_EVENT;
10
10
  exports.RegistryActionInvoker = require_engine_actions.RegistryActionInvoker;
@@ -1,7 +1,7 @@
1
1
  import { hashToBucket, murmur3 } from "./bucket.mjs";
2
2
  import { DEVICES_UPDATED_EVENT, PROFILE_UPDATED_EVENT, evaluateCondition, matchesWhere, relevantEvents } from "./condition.mjs";
3
- import { RegistryActionInvoker } from "./actions.mjs";
4
3
  import { nextWindowStart, resolveTimeZone } from "./time-window.mjs";
5
4
  import { runJourney } from "./interpreter.mjs";
6
5
  import { scaleDurations, scaleSegmentWindows } from "./time-scale.mjs";
6
+ import { RegistryActionInvoker } from "./actions.mjs";
7
7
  export { DEVICES_UPDATED_EVENT, PROFILE_UPDATED_EVENT, RegistryActionInvoker, evaluateCondition, hashToBucket, matchesWhere, murmur3, nextWindowStart, relevantEvents, resolveTimeZone, runJourney, scaleDurations, scaleSegmentWindows };
@@ -405,7 +405,7 @@ async function awaitTail(ir, ctx) {
405
405
  */
406
406
  async function waitLoop(id, ctx, deadline, events, check) {
407
407
  for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {
408
- await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events);
408
+ await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });
409
409
  const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));
410
410
  if (met !== null && met !== false) return met;
411
411
  const remaining = await ctx.step.do(`${id}:remaining:${attempt}`, async () => deadline - Date.now());
@@ -1 +1 @@
1
- {"version":3,"file":"interpreter.cjs","names":["evaluateCondition","RECIPIENT_PROPERTY","nextWindowStart","hashToBucket","relevantEvents"],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type { FactSource, JourneyContext, JourneyOutcome, OutboundMessage } from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations: beyond it we treat the wait as timed out,\n * so a wake-up storm cannot exhaust the platform's step budget. Each loop\n * iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer on both sides: it may attach\n * tracking before, and hears how the transport took the copy after. A send that throws is\n * not reported — the step retries it, and reports when it settles. A report that fails after\n * the send is logged, never thrown: the step would retry and the transport — a webhook, a web\n * push, with no idempotency key of its own — would deliver the copy again.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const result = await ctx.messages.send(copy);\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n // A checkpoint from before conversions carried a time held `goalMet` instead: met at entry's anchor\n const legacy = result as { goalMet?: boolean };\n const goalAt =\n typeof result.goalAt === 'number'\n ? result.goalAt\n : legacy.goalMet === true\n ? ctx.enteredAtMs\n : null;\n if (goalAt !== null) {\n state.convertedAt ??= goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and the senders drop the repeats —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n if (await ctx.facts.isSuppressed?.(node.channel)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the channel is opted out. Undefined: a checkpoint from before per-device steps, whose\n // single step already sent to every device.\n if (plan === null || plan === undefined) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: ctx.userId,\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await ctx.facts.isSuppressed?.(node.channel)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: ctx.userId,\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n await ctx.step.sleepUntil(`${node.id}:sleep`, target);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. The explicit\n // experiment key (when set) keeps the assignment stable across workflow\n // versions; the structural id fallback is fine for one-off splits.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? node.id}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n codeHash: node.codeHash,\n args,\n userId: ctx.userId,\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n // A checkpoint from before the tail watched exitWhen recorded a plain `true`: the goal\n if ((hit as unknown) === true || hit.kind === 'goal') {\n state.convertedAt ??= (hit as { at?: number }).at ?? deadline;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\".\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events);\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n // `false` is a checkpoint from before checks answered null for \"not yet\"\n if (met !== null && (met as unknown) !== false) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, remaining);\n if (wake === 'timeout') {\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n const final = await ctx.step.do(`${id}:final`, async () => check(Date.now()));\n return (final as unknown) === false ? null : final;\n }\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n return null; // check budget exhausted → treated as timed out (see MAX_WAIT_CHECKS)\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"interpreter.cjs","names":["evaluateCondition","RECIPIENT_PROPERTY","nextWindowStart","hashToBucket","relevantEvents"],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type {\n FactSource,\n JourneyContext,\n JourneyOutcome,\n OutboundMessage,\n PushDevice,\n} from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations: beyond it we treat the wait as timed out,\n * so a wake-up storm cannot exhaust the platform's step budget. Each loop\n * iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer on both sides: it may attach\n * tracking before, and hears how the transport took the copy after. A send that throws is\n * not reported — the step retries it, and reports when it settles. A report that fails after\n * the send is logged, never thrown: the step would retry and the transport — a webhook, a web\n * push, with no idempotency key of its own — would deliver the copy again.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const result = await ctx.messages.send(copy);\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n // A checkpoint from before conversions carried a time held `goalMet` instead: met at entry's anchor\n const legacy = result as { goalMet?: boolean };\n const goalAt =\n typeof result.goalAt === 'number'\n ? result.goalAt\n : legacy.goalMet === true\n ? ctx.enteredAtMs\n : null;\n if (goalAt !== null) {\n state.convertedAt ??= goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\ntype PushPlan = { props: Record<string, ScalarIR | undefined>; devices: PushDevice[] };\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and the senders drop the repeats —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do<PushPlan | null | undefined>(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n if (await ctx.facts.isSuppressed?.(node.channel)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the channel is opted out. Undefined: a checkpoint from before per-device steps, whose\n // single step already sent to every device.\n if (plan === null || plan === undefined) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: ctx.userId,\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await ctx.facts.isSuppressed?.(node.channel)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: ctx.userId,\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n await ctx.step.sleepUntil(`${node.id}:sleep`, target);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. The explicit\n // experiment key (when set) keeps the assignment stable across workflow\n // versions; the structural id fallback is fine for one-off splits.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? node.id}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n codeHash: node.codeHash,\n args,\n userId: ctx.userId,\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n // A checkpoint from before the tail watched exitWhen recorded a plain `true`: the goal\n if ((hit as unknown) === true || hit.kind === 'goal') {\n state.convertedAt ??= (hit as { at?: number }).at ?? deadline;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\".\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n // `false` is a checkpoint from before checks answered null for \"not yet\"\n if (met !== null && (met as unknown) !== false) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, remaining);\n if (wake === 'timeout') {\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n const final = await ctx.step.do(`${id}:final`, async () => check(Date.now()));\n return (final as unknown) === false ? null : final;\n }\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n return null; // check budget exhausted → treated as timed out (see MAX_WAIT_CHECKS)\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"interpreter.d.cts","names":[],"sources":["../../src/engine/interpreter.ts"],"mappings":";;;wBA0FsB,WAAW,IAAI,YAAY,KAAK,iBAAiB,QAAQ"}
1
+ {"version":3,"file":"interpreter.d.cts","names":[],"sources":["../../src/engine/interpreter.ts"],"mappings":";;;wBAgGsB,WAAW,IAAI,YAAY,KAAK,iBAAiB,QAAQ"}
@@ -1 +1 @@
1
- {"version":3,"file":"interpreter.d.mts","names":[],"sources":["../../src/engine/interpreter.ts"],"mappings":";;;wBA0FsB,WAAW,IAAI,YAAY,KAAK,iBAAiB,QAAQ"}
1
+ {"version":3,"file":"interpreter.d.mts","names":[],"sources":["../../src/engine/interpreter.ts"],"mappings":";;;wBAgGsB,WAAW,IAAI,YAAY,KAAK,iBAAiB,QAAQ"}
@@ -404,7 +404,7 @@ async function awaitTail(ir, ctx) {
404
404
  */
405
405
  async function waitLoop(id, ctx, deadline, events, check) {
406
406
  for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {
407
- await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events);
407
+ await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });
408
408
  const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));
409
409
  if (met !== null && met !== false) return met;
410
410
  const remaining = await ctx.step.do(`${id}:remaining:${attempt}`, async () => deadline - Date.now());
@@ -1 +1 @@
1
- {"version":3,"file":"interpreter.mjs","names":[],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type { FactSource, JourneyContext, JourneyOutcome, OutboundMessage } from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations: beyond it we treat the wait as timed out,\n * so a wake-up storm cannot exhaust the platform's step budget. Each loop\n * iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer on both sides: it may attach\n * tracking before, and hears how the transport took the copy after. A send that throws is\n * not reported — the step retries it, and reports when it settles. A report that fails after\n * the send is logged, never thrown: the step would retry and the transport — a webhook, a web\n * push, with no idempotency key of its own — would deliver the copy again.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const result = await ctx.messages.send(copy);\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n // A checkpoint from before conversions carried a time held `goalMet` instead: met at entry's anchor\n const legacy = result as { goalMet?: boolean };\n const goalAt =\n typeof result.goalAt === 'number'\n ? result.goalAt\n : legacy.goalMet === true\n ? ctx.enteredAtMs\n : null;\n if (goalAt !== null) {\n state.convertedAt ??= goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and the senders drop the repeats —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n if (await ctx.facts.isSuppressed?.(node.channel)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the channel is opted out. Undefined: a checkpoint from before per-device steps, whose\n // single step already sent to every device.\n if (plan === null || plan === undefined) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: ctx.userId,\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await ctx.facts.isSuppressed?.(node.channel)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: ctx.userId,\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n await ctx.step.sleepUntil(`${node.id}:sleep`, target);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. The explicit\n // experiment key (when set) keeps the assignment stable across workflow\n // versions; the structural id fallback is fine for one-off splits.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? node.id}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n codeHash: node.codeHash,\n args,\n userId: ctx.userId,\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n // A checkpoint from before the tail watched exitWhen recorded a plain `true`: the goal\n if ((hit as unknown) === true || hit.kind === 'goal') {\n state.convertedAt ??= (hit as { at?: number }).at ?? deadline;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\".\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events);\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n // `false` is a checkpoint from before checks answered null for \"not yet\"\n if (met !== null && (met as unknown) !== false) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, remaining);\n if (wake === 'timeout') {\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n const final = await ctx.step.do(`${id}:final`, async () => check(Date.now()));\n return (final as unknown) === false ? null : final;\n }\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n return null; // check budget exhausted → treated as timed out (see MAX_WAIT_CHECKS)\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"interpreter.mjs","names":[],"sources":["../../src/engine/interpreter.ts"],"sourcesContent":["import type { NodeIR, PropValueIR, ScalarIR, WorkflowIR } from '../ir';\nimport { hashToBucket } from './bucket';\nimport { evaluateCondition, relevantEvents } from './condition';\nimport type {\n FactSource,\n JourneyContext,\n JourneyOutcome,\n OutboundMessage,\n PushDevice,\n} from './ports';\nimport { RECIPIENT_PROPERTY } from './recipient';\nimport { nextWindowStart } from './time-window';\n\n/**\n * IR interpreter: the execution core for one user's journey instance.\n *\n * Pure logic — it imports nothing platform-specific; every external effect\n * arrives through the JourneyContext ports, and durable semantics\n * (checkpoint/replay) are the EngineStep adapter's job.\n *\n * Step naming: an IR node id *is* the step name (derived from the structural\n * path, so it is deterministic and unique). When one node needs several steps,\n * a suffix is appended (`${id}:guard`, `${id}:check:${n}`).\n *\n * Guard semantics: goal / exitWhen are checked at journey entry and after\n * every suspension point (delay / random_delay / time_window / wait_until) —\n * the moments where wall-clock time passed and the user's state may have\n * moved. Between two synchronous nodes no meaningful time passes, so checking\n * there would only burn step budget (each checkpoint costs a state write).\n */\n\n/**\n * Cap on wait_until re-evaluations: beyond it we treat the wait as timed out,\n * so a wake-up storm cannot exhaust the platform's step budget. Each loop\n * iteration costs a handful of steps, so 64 checks stay well inside the\n * platform step budgets while absorbing far more wake churn than 16 did.\n */\nconst MAX_WAIT_CHECKS = 64;\n\n/** The user property holding an IANA timezone id, read when a time_window says tz: 'user'. Same convention-table status as RECIPIENT_PROPERTY. */\nconst TIMEZONE_PROPERTY = 'timezone';\n\ntype FlowSignal =\n | { kind: 'fall_through' }\n | { kind: 'exit'; reason?: string }\n | { kind: 'goal' }\n | { kind: 'exit_when' };\n\nconst FALL_THROUGH: FlowSignal = { kind: 'fall_through' };\n\n/**\n * Whether and when the instance converted — a fact of its own, apart from how it ends: with\n * `exitOnMatch: false` a conversion lets the flow go on, and the instance still ends `completed`\n * (or `exited`, `exit_when`) carrying it. Set only from checkpointed step results, so a replay\n * rebuilds the same state.\n */\ninterface GoalState {\n convertedAt?: number;\n}\n\nconst goalStates = new WeakMap<JourneyContext, GoalState>();\n\nfunction goalState(ctx: JourneyContext): GoalState {\n let state = goalStates.get(ctx);\n if (state === undefined) {\n state = {};\n goalStates.set(ctx, state);\n }\n return state;\n}\n\n/** When the goal's attribution window closes, measured from entry; none without `within`. */\nfunction goalDeadline(ir: WorkflowIR, ctx: JourneyContext): number | undefined {\n return ir.goal?.within === undefined ? undefined : ctx.enteredAtMs + ir.goal.within.ms;\n}\n\n/**\n * Whether the goal holds for events inside its window: after entry, and not after the window\n * closed — an instance resumed past the deadline must not count a purchase made after it. The\n * conversion time is when the check saw it, capped at the deadline.\n */\nasync function goalReached(\n ir: WorkflowIR,\n ctx: JourneyContext,\n now: number\n): Promise<{ at: number } | null> {\n const goal = ir.goal;\n if (goal === undefined) return null;\n const deadline = goalDeadline(ir, ctx);\n const met = await evaluateCondition(goal.condition, ctx.facts, now, {\n anchorMs: ctx.enteredAtMs,\n ...(deadline === undefined ? {} : { untilMs: deadline }),\n });\n return met ? { at: deadline === undefined ? now : Math.min(now, deadline) } : null;\n}\n\nexport async function runJourney(ir: WorkflowIR, ctx: JourneyContext): Promise<JourneyOutcome> {\n await started(ir, ctx);\n const entry = await checkGuards('entry', ir, ctx);\n let signal = entry ?? (await runFlow(ir.flow, ir, ctx));\n // The flow is done but the conversion window is not: keep watching it (see awaitTail)\n if (signal.kind === 'fall_through') signal = (await awaitTail(ir, ctx)) ?? FALL_THROUGH;\n const { convertedAt } = goalState(ctx);\n const converted = convertedAt === undefined ? {} : { convertedAt };\n switch (signal.kind) {\n case 'fall_through':\n return { status: 'completed', ...converted };\n case 'exit':\n return signal.reason !== undefined\n ? { status: 'exited', reason: signal.reason, ...converted }\n : { status: 'exited', ...converted };\n case 'goal':\n return { status: 'goal', ...converted };\n case 'exit_when':\n return { status: 'exit_when', ...converted };\n }\n}\n\nasync function runFlow(nodes: NodeIR[], ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n for (const node of nodes) {\n await reached(node, ir, ctx);\n const signal = await runNode(node, ir, ctx);\n if (signal.kind !== 'fall_through') return signal;\n }\n return FALL_THROUGH;\n}\n\n/**\n * Tell the observer the instance entered — before the entry guards, which can end it before the\n * first node: an instance is counted from its entry, not from its first node.\n */\nasync function started(ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.started === undefined) return;\n await ctx.step.do('entry:started', async () => {\n await observer.started?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n at: ctx.enteredAtMs,\n });\n });\n}\n\n/**\n * Tell the observer the instance is at `node`. A step of its own, so a replay does not report\n * the nodes it passes on the way back again, and the arrival time stays the first one.\n */\nasync function reached(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n if (observer?.nodeReached === undefined) return;\n await ctx.step.do(`${node.id}:reached`, async () => {\n await observer.nodeReached?.({\n workflow: ir.id,\n instanceId: ctx.instanceId,\n userId: ctx.userId,\n nodeId: node.id,\n at: Date.now(),\n });\n });\n}\n\n/**\n * One copy out through the host's sender, with the observer on both sides: it may attach\n * tracking before, and hears how the transport took the copy after. A send that throws is\n * not reported — the step retries it, and reports when it settles. A report that fails after\n * the send is logged, never thrown: the step would retry and the transport — a webhook, a web\n * push, with no idempotency key of its own — would deliver the copy again.\n */\nasync function deliver(message: OutboundMessage, ctx: JourneyContext): Promise<void> {\n const observer = ctx.observer;\n const tracking = await observer?.messagePrepared?.(message);\n const copy = tracking === undefined ? message : { ...message, tracking };\n const result = await ctx.messages.send(copy);\n try {\n await observer?.messageSent?.(copy, result ?? { delivered: true });\n } catch (error) {\n console.error(`[workflow] reporting ${copy.idempotencyKey} as sent failed`, error);\n }\n}\n\n/**\n * Goal / exitWhen guards (see the header for when they run).\n *\n * The goal condition is anchored at workflow entry: a `performed` inside it\n * only counts events after the user entered, so pre-existing history cannot\n * register as a conversion. exitWhen is deliberately unanchored — \"user\n * unsubscribed\" should exit no matter when it happened.\n */\nasync function checkGuards(\n stepId: string,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<FlowSignal | null> {\n const goal = ir.goal;\n const exitWhen = ir.exitWhen;\n if (goal === undefined && exitWhen === undefined) return null;\n\n const state = goalState(ctx);\n const result = await ctx.step.do(`${stepId}:guard`, async () => {\n const now = Date.now();\n // Already converted (exitOnMatch: false): the conversion stands, no need to look again\n const goalHit =\n goal !== undefined && state.convertedAt === undefined\n ? await goalReached(ir, ctx, now)\n : null;\n const exitMet = exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now));\n return { goalAt: goalHit?.at ?? null, exitMet };\n });\n\n // A checkpoint from before conversions carried a time held `goalMet` instead: met at entry's anchor\n const legacy = result as { goalMet?: boolean };\n const goalAt =\n typeof result.goalAt === 'number'\n ? result.goalAt\n : legacy.goalMet === true\n ? ctx.enteredAtMs\n : null;\n if (goalAt !== null) {\n state.convertedAt ??= goalAt;\n if (goal?.exitOnMatch ?? true) return { kind: 'goal' };\n }\n if (result.exitMet) return { kind: 'exit_when' };\n return null;\n}\n\n/**\n * The host's template check, with the instance and node added to the message:\n * a refused message must point at the journey that produced it. The error\n * keeps its class (and `name`), so adapters can stop retrying it.\n */\nfunction validate(\n node: Extract<NodeIR, { type: 'message' }>,\n props: Record<string, ScalarIR | undefined>,\n ctx: JourneyContext\n): Record<string, ScalarIR | undefined> {\n if (ctx.validator === undefined) return props;\n try {\n return ctx.validator.validate({ channel: node.channel, template: node.template, props });\n } catch (error) {\n if (error instanceof Error) {\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n }\n throw error;\n }\n}\n\ntype PushPlan = { props: Record<string, ScalarIR | undefined>; devices: PushDevice[] };\n\n/**\n * A push goes to devices, not to a profile address: one copy per device the\n * host's directory lists (or only the most recent, `devices: 'latest'`). Each\n * copy's idempotency key carries its token, so a retried step that already\n * reached some devices re-sends to the rest and the senders drop the repeats —\n * keyed by token rather than position, because the list can change between\n * attempts. A user with no device is skipped, not failed: being unreachable is\n * a state of the user, and `reachable('push')` is how a journey branches on it.\n */\n/**\n * A push: one step settles what goes out and to which devices, then each device gets a step of\n * its own — a failure on one device retries that device alone, and the copies already delivered\n * are not sent again (APNs and FCM carry the idempotency key but do not de-duplicate on it).\n */\nasync function sendPush(\n node: Extract<NodeIR, { type: 'message' }>,\n ir: WorkflowIR,\n ctx: JourneyContext\n): Promise<void> {\n const plan = await ctx.step.do<PushPlan | null | undefined>(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n if (await ctx.facts.isSuppressed?.(node.channel)) return null;\n const listed = await ctx.facts.listDevices();\n const devices = node.devices === 'latest' ? listed.slice(0, 1) : listed;\n return { props, devices };\n });\n // null: the channel is opted out. Undefined: a checkpoint from before per-device steps, whose\n // single step already sent to every device.\n if (plan === null || plan === undefined) return;\n if (plan.devices.length === 0) {\n console.log(`[journey] push ${node.template} skipped: no device for user ${ctx.userId}`);\n return;\n }\n for (const [index, device] of plan.devices.entries()) {\n await ctx.step.do(`${node.id}:send:${index}`, async () => {\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: 'push',\n template: node.template,\n props: plan.props,\n userId: ctx.userId,\n recipient: device.token,\n device,\n idempotencyKey: `${ctx.instanceId}:${node.id}:${device.token}`,\n },\n ctx\n );\n });\n }\n}\n\nasync function runNode(node: NodeIR, ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal> {\n switch (node.type) {\n case 'message': {\n if (node.channel === 'push') {\n await sendPush(node, ir, ctx);\n return FALL_THROUGH;\n }\n await ctx.step.do(`${node.id}:send`, async () => {\n const props = validate(node, await resolveValues(node.props, ctx.facts), ctx);\n // An opted-out channel (an email unsubscribe) is skipped, like a push with no device\n if (await ctx.facts.isSuppressed?.(node.channel)) return;\n const recipientPath = RECIPIENT_PROPERTY[node.channel];\n const recipient =\n recipientPath === undefined ? undefined : await ctx.facts.getProperty(recipientPath);\n await deliver(\n {\n workflow: ir.id,\n instanceId: ctx.instanceId,\n nodeId: node.id,\n channel: node.channel,\n template: node.template,\n props,\n userId: ctx.userId,\n recipient: recipient === undefined ? undefined : String(recipient),\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n },\n ctx\n );\n });\n return FALL_THROUGH;\n }\n\n case 'delay': {\n await ctx.step.sleep(node.id, node.duration.ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'random_delay': {\n // The random value is drawn and persisted inside the step, so replay does not re-roll it\n const ms = await ctx.step.do(`${node.id}:roll`, async () => {\n const span = node.max.ms - node.min.ms;\n return node.min.ms + Math.floor(Math.random() * Math.max(span, 0));\n });\n await ctx.step.sleep(`${node.id}:sleep`, ms);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'time_window': {\n const target = await ctx.step.do(`${node.id}:target`, async () => {\n const tz =\n node.tz === 'user'\n ? String((await ctx.facts.getProperty(TIMEZONE_PROPERTY)) ?? 'UTC')\n : node.tz;\n return nextWindowStart(Date.now(), node.days, node.between, tz);\n });\n if (target === null) return FALL_THROUGH; // already inside the window: no suspension, no guard\n await ctx.step.sleepUntil(`${node.id}:sleep`, target);\n return (await checkGuards(node.id, ir, ctx)) ?? FALL_THROUGH;\n }\n\n case 'wait_until': {\n const met = await waitUntil(node, ctx);\n // Time passed while waiting — guard before acting on the outcome (goal wins over the wait result)\n const guard = await checkGuards(node.id, ir, ctx);\n if (guard) return guard;\n if (met) return FALL_THROUGH;\n const onTimeout = node.onTimeout;\n if (onTimeout === 'continue') return FALL_THROUGH;\n if (onTimeout === 'exit') return { kind: 'exit', reason: `${node.id}:timeout` };\n return runFlow(onTimeout, ir, ctx);\n }\n\n case 'branch': {\n const matched = await ctx.step.do(`${node.id}:eval`, async () => {\n const now = Date.now();\n for (const [i, branchCase] of node.cases.entries()) {\n if (await evaluateCondition(branchCase.condition, ctx.facts, now)) {\n return i;\n }\n }\n return -1; // no match → default arm, or simply continue\n });\n const flow = matched >= 0 ? node.cases[matched]?.flow : node.otherwise;\n if (flow === undefined) return FALL_THROUGH;\n const signal = await runFlow(flow, ir, ctx);\n // Structural rejoin: once an arm falls through, execution resumes after the branch\n return signal;\n }\n\n case 'filter': {\n const pass = await ctx.step.do(`${node.id}:eval`, async () =>\n evaluateCondition(node.condition, ctx.facts, Date.now())\n );\n return pass ? FALL_THROUGH : { kind: 'exit', reason: node.reason ?? `${node.id}:filtered` };\n }\n\n case 'cohort': {\n // Deterministic bucketing, so replay needs no checkpoint. The explicit\n // experiment key (when set) keeps the assignment stable across workflow\n // versions; the structural id fallback is fine for one-off splits.\n const bucket = hashToBucket(`${ctx.userId}:${node.key ?? node.id}`);\n // Integer basis points on both sides (the DSL guarantees weights sit on\n // the 0.01% grid), so no float ever meets the comparison.\n let cumulative = 0;\n for (const arm of node.arms) {\n cumulative += Math.round(arm.weight * 100);\n if (bucket < cumulative) return runFlow(arm.flow, ir, ctx);\n }\n return FALL_THROUGH;\n }\n\n case 'exit':\n return node.reason !== undefined ? { kind: 'exit', reason: node.reason } : { kind: 'exit' };\n\n case 'send_event': {\n await ctx.step.do(`${node.id}:emit`, async () => {\n const payload = await resolveValues(node.payload, ctx.facts);\n try {\n await ctx.events.emit(node.event, payload, `${ctx.instanceId}:${node.id}`);\n } catch (error) {\n // A refused event must point at the journey that emitted it\n if (error instanceof Error)\n error.message = `[${ctx.instanceId} ${node.id}] ${error.message}`;\n throw error;\n }\n });\n return FALL_THROUGH;\n }\n\n case 'action': {\n // Custom code runs inside step.do: retried on throw, checkpointed on success\n const invoker = ctx.actions;\n if (invoker === undefined) {\n throw new Error(\n `action '${node.action}': this runtime has no ActionInvoker configured — register the action on the adapter (see engine/actions.ts)`\n );\n }\n await ctx.step.do(`${node.id}:run`, async () => {\n const args = await resolveValues(node.args, ctx.facts);\n await invoker.invoke({\n action: node.action,\n codeHash: node.codeHash,\n args,\n userId: ctx.userId,\n idempotencyKey: `${ctx.instanceId}:${node.id}`,\n });\n });\n return FALL_THROUGH;\n }\n }\n}\n\nasync function waitUntil(\n node: Extract<NodeIR, { type: 'wait_until' }>,\n ctx: JourneyContext\n): Promise<boolean> {\n const deadline = await ctx.step.do(\n `${node.id}:deadline`,\n async () => Date.now() + node.timeout.ms\n );\n const events = await ctx.step.do(`${node.id}:events`, async () =>\n relevantEvents(node.condition, ctx.facts)\n );\n // Anchor at the moment the wait began (deadline is checkpointed, so this is\n // replay-stable): `performed` inside the condition only counts events that\n // happen during the wait — pre-existing history must not satisfy it.\n const anchorMs = deadline - node.timeout.ms;\n const met = await waitLoop(node.id, ctx, deadline, events, async (now) =>\n (await evaluateCondition(node.condition, ctx.facts, now, { anchorMs })) ? true : null\n );\n await ctx.step.unsubscribe(`${node.id}:unsubscribe`);\n return met === true;\n}\n\n/**\n * The conversion window outlives the flow: a journey whose steps are done while its goal's\n * `within` is still open keeps watching until the window closes — the purchase that follows the\n * last offer email is that email's conversion, and ending the instance at the email would report\n * it as a plain completion. `exitWhen` still applies while it waits. Nothing to wait for without\n * a window, or once the instance has converted.\n */\nasync function awaitTail(ir: WorkflowIR, ctx: JourneyContext): Promise<FlowSignal | null> {\n const deadline = goalDeadline(ir, ctx);\n const goal = ir.goal;\n if (goal === undefined || deadline === undefined) return null;\n const state = goalState(ctx);\n if (state.convertedAt !== undefined) return null;\n const open = await ctx.step.do('goal:open', async () => deadline > Date.now());\n if (!open) return null;\n const exitWhen = ir.exitWhen;\n const events = await ctx.step.do('goal:events', async () => [\n ...new Set([\n ...(await relevantEvents(goal.condition, ctx.facts)),\n ...(exitWhen === undefined ? [] : await relevantEvents(exitWhen, ctx.facts)),\n ]),\n ]);\n const hit = await waitLoop('goal', ctx, deadline, events, async (now) => {\n const reached = await goalReached(ir, ctx, now);\n if (reached !== null) return { kind: 'goal' as const, at: reached.at };\n if (exitWhen !== undefined && (await evaluateCondition(exitWhen, ctx.facts, now))) {\n return { kind: 'exit_when' as const, at: now };\n }\n return null;\n });\n await ctx.step.unsubscribe('goal:unsubscribe');\n if (hit === null) return null;\n // A checkpoint from before the tail watched exitWhen recorded a plain `true`: the goal\n if ((hit as unknown) === true || hit.kind === 'goal') {\n state.convertedAt ??= (hit as { at?: number }).at ?? deadline;\n return { kind: 'goal' };\n }\n return { kind: 'exit_when' };\n}\n\n/**\n * Wait until `check` finds something or the deadline passes: subscribe, check, sleep until a\n * relevant event wakes the instance or time runs out. `check` runs inside a step, so its result\n * must be serializable; null means \"not yet\".\n */\nasync function waitLoop<R>(\n id: string,\n ctx: JourneyContext,\n deadline: number,\n events: string[],\n check: (now: number) => Promise<R | null>\n): Promise<R | null> {\n for (let attempt = 0; attempt < MAX_WAIT_CHECKS; attempt++) {\n // Subscribe before the check, every attempt: one subscribe arms at least\n // one wake-up (AWS callbacks are consumed on delivery, so each attempt\n // needs a fresh arm; Cloudflare rows make the repeat call idempotent), and\n // arming first closes the race where the event lands between the check and\n // the registration.\n await ctx.step.subscribe(`${id}:subscribe:${attempt}`, events, { deadlineMs: deadline });\n\n const met = await ctx.step.do(`${id}:check:${attempt}`, async () => check(Date.now()));\n // `false` is a checkpoint from before checks answered null for \"not yet\"\n if (met !== null && (met as unknown) !== false) return met;\n\n // Checkpointed: replay re-runs this control flow with wall-clock time far\n // ahead of the original run, so the branch must come from a persisted value.\n const remaining = await ctx.step.do(\n `${id}:remaining:${attempt}`,\n async () => deadline - Date.now()\n );\n // The condition was checked just above — expiring here needs no extra final check\n if (remaining <= 0) return null;\n\n const wake = await ctx.step.waitForWake(`${id}:wait:${attempt}`, remaining);\n if (wake === 'timeout') {\n // Final check after timeout: in a tight race the event may land exactly as the timer fires\n const final = await ctx.step.do(`${id}:final`, async () => check(Date.now()));\n return (final as unknown) === false ? null : final;\n }\n // Woken → back to the top of the loop to re-evaluate (at-least-once wake-up semantics)\n }\n return null; // check budget exhausted → treated as timed out (see MAX_WAIT_CHECKS)\n}\n\nasync function resolveValues(\n values: Record<string, PropValueIR>,\n facts: FactSource\n): Promise<Record<string, ScalarIR | undefined>> {\n const resolved: Record<string, ScalarIR | undefined> = {};\n for (const [key, value] of Object.entries(values)) {\n resolved[key] = typeof value === 'object' ? await facts.getProperty(value.path) : value;\n }\n return resolved;\n}\n"],"mappings":"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"}
@@ -31,8 +31,13 @@ export interface EngineStep {
31
31
  * delivery) satisfy it with exactly one. Callers therefore re-subscribe on
32
32
  * every wait attempt; adapters where registrations persist (Cloudflare rows)
33
33
  * make the repeat call idempotent.
34
+ *
35
+ * `deadlineMs` is when the wait this arms gives up: an adapter whose wake handle carries its
36
+ * own timeout (an AWS callback) sets it here, where the handle is created. Others ignore it.
34
37
  */
35
- subscribe(name: string, events: readonly string[]): Promise<void>;
38
+ subscribe(name: string, events: readonly string[], options?: {
39
+ deadlineMs?: number;
40
+ }): Promise<void>;
36
41
  /** Durable: drop every registration this instance holds. */
37
42
  unsubscribe(name: string): Promise<void>;
38
43
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"ports.d.cts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;EAGnD,MAAM,cAAc,aAAa;;EAGjC,WAAW,cAAc,sBAAsB;;;;;;;;;;;;EAa/C,UAAU,cAAc,4BAA4B;;EAGpD,YAAY,eAAe;;;;;EAM3B,YAAY,cAAc,oBAAoB;;;iBAI/B;EACf;EACA;;;iBAIe;;;;;;EAMf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;EAEvB,cAAc,SAAS,YAAY;;iBAGpB;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;;;;;EAMT;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;EAKf,QAAQ,aAAa,SAAS;;EAE9B;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;EAEJ,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;EACA,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
1
+ {"version":3,"file":"ports.d.cts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;EAGnD,MAAM,cAAc,aAAa;;EAGjC,WAAW,cAAc,sBAAsB;;;;;;;;;;;;;;;EAgB/C,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;EAM3B,YAAY,cAAc,oBAAoB;;;iBAI/B;EACf;EACA;;;iBAIe;;;;;;EAMf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;EAEvB,cAAc,SAAS,YAAY;;iBAGpB;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;;;;;EAMT;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;EAKf,QAAQ,aAAa,SAAS;;EAE9B;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;EAEJ,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;EACA,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
@@ -31,8 +31,13 @@ export interface EngineStep {
31
31
  * delivery) satisfy it with exactly one. Callers therefore re-subscribe on
32
32
  * every wait attempt; adapters where registrations persist (Cloudflare rows)
33
33
  * make the repeat call idempotent.
34
+ *
35
+ * `deadlineMs` is when the wait this arms gives up: an adapter whose wake handle carries its
36
+ * own timeout (an AWS callback) sets it here, where the handle is created. Others ignore it.
34
37
  */
35
- subscribe(name: string, events: readonly string[]): Promise<void>;
38
+ subscribe(name: string, events: readonly string[], options?: {
39
+ deadlineMs?: number;
40
+ }): Promise<void>;
36
41
  /** Durable: drop every registration this instance holds. */
37
42
  unsubscribe(name: string): Promise<void>;
38
43
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"ports.d.mts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;EAGnD,MAAM,cAAc,aAAa;;EAGjC,WAAW,cAAc,sBAAsB;;;;;;;;;;;;EAa/C,UAAU,cAAc,4BAA4B;;EAGpD,YAAY,eAAe;;;;;EAM3B,YAAY,cAAc,oBAAoB;;;iBAI/B;EACf;EACA;;;iBAIe;;;;;;EAMf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;EAEvB,cAAc,SAAS,YAAY;;iBAGpB;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;;;;;EAMT;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;EAKf,QAAQ,aAAa,SAAS;;EAE9B;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;EAEJ,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;EACA,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
1
+ {"version":3,"file":"ports.d.mts","names":[],"sources":["../../src/engine/ports.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;;EAGnD,MAAM,cAAc,aAAa;;EAGjC,WAAW,cAAc,sBAAsB;;;;;;;;;;;;;;;EAgB/C,UACE,cACA,2BACA;IAAY;MACX;;EAGH,YAAY,eAAe;;;;;EAM3B,YAAY,cAAc,oBAAoB;;;iBAI/B;EACf;EACA;;;iBAIe;;;;;;EAMf,YACE,eACA;IAAS;IAAkB;IAAkB,QAAQ;MACpD;EACH,YAAY,eAAe,QAAQ;;EAEnC,oBAAoB,eAAe,QAAQ;;EAE3C,eAAe,QAAQ;;EAEvB,cAAc,SAAS,YAAY;;iBAGpB;;EAEf;EACA;EACA;EACA,SAAS;EACT;;EAEA,OAAO,eAAe;EACtB;;;;;;EAMA;;EAEA,SAAS;;;;;;EAMT;;EAEA,WAAW;;;YAID;;;;;;iBAOK;;;;;EAKf,QAAQ,aAAa,SAAS;;EAE9B;;EAEA,OAAO;;;;;;EAMP,eAAe;;;;;;;iBAQA;EACf;EACA;;iBAGe;EACf,KAAK,SAAS,kBAAkB,eAAe;;;;;;;;;iBAUhC;;EAEf,SAAS;IACP;IACA;IACA;IACA;MACE;;EAEJ,aAAa;IACX;IACA;IACA;IACA;IACA;MACE;;EAEJ,iBAAiB,SAAS,kBAAkB,QAAQ;;EAEpD,aAAa,SAAS,iBAAiB;IAAU;MAAoB,aAAa;;EAElF,UAAU;IACR;IACA;IACA;IACA,QAAQ;IACR;;IAEA;MACE;;;;;;;;;iBAUW;EACf,SAAS;IACP,SAAS;IACT;IACA,OAAO,eAAe;MACpB,eAAe;;;iBAIJ;;;;;;;;EAQf,KAAK,eAAe,SAAS,eAAe,uBAAuB,aAAa;;;iBAIjE;;EAEf;;EAEA;;EAEA,MAAM,eAAe;EACrB;;EAEA;;;;;;;iBAQe;EACf,OAAO,YAAY,mBAAmB;;iBAGvB;EACf;;EAEA;;;;;;;EAOA;EACA,MAAM;EACN,OAAO;EACP,UAAU;EACV,QAAQ;;EAER,UAAU;;EAEV,YAAY;;EAEZ,WAAW;;;;;;;YAQD;EACN;;EACA;EAAkB;;EAClB;;EACA;;EACA"}
@@ -2,18 +2,19 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_apns = require("../apns.cjs");
3
3
  const require_templates = require("../templates.cjs");
4
4
  const require_config = require("../config.cjs");
5
+ const require_email_sender = require("../email-sender.cjs");
5
6
  const require_fcm = require("../fcm.cjs");
6
7
  const require_runtime = require("../runtime.cjs");
7
8
  const require_push = require("../push.cjs");
8
9
  const require_react_email = require("../react-email.cjs");
9
10
  const require_webhooks = require("../webhooks.cjs");
10
11
  const require_webpush = require("../webpush.cjs");
11
- const require_cloudflare_router = require("./router.cjs");
12
- const require_cloudflare_senders = require("./senders.cjs");
13
- //#region src/cloudflare/channels.ts
12
+ const require_host_router = require("./router.cjs");
13
+ const require_host_senders = require("./senders.cjs");
14
+ //#region src/host/channels.ts
14
15
  /**
15
16
  * The outlets a `JourneyConfig` describes, assembled against the environment:
16
- * email through the `EMAIL` binding with the configured addresses, iOS pushes
17
+ * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes
17
18
  * straight to APNs with the configured key (the `.p8` from
18
19
  * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service
19
20
  * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config
@@ -57,9 +58,10 @@ function channelsFromConfig(config, env, options = {}) {
57
58
  const email = () => {
58
59
  const registry = config.templates?.email;
59
60
  if (registry === void 0) return unavailable("no email templates are registered");
60
- if (env.EMAIL === void 0) return unavailable("no EMAIL (send_email) binding");
61
- return new require_cloudflare_senders.CfEmailSender({
62
- binding: env.EMAIL,
61
+ const transport = options.email;
62
+ if (transport === void 0) return unavailable("no email transport is configured (the host's `email` option)");
63
+ return new require_email_sender.EmailSender({
64
+ transport,
63
65
  render: require_react_email.registryRenderer(registry, config.email)
64
66
  });
65
67
  };
@@ -68,7 +70,7 @@ function channelsFromConfig(config, env, options = {}) {
68
70
  if (registry === void 0) return unavailable("no push templates are registered");
69
71
  const render = require_push.registryPushRenderer(registry);
70
72
  const onUnregistered = async (message) => {
71
- if (message.device !== void 0 && options.store !== void 0) await require_cloudflare_router.removeDevice(env, message.device.token, options.store);
73
+ if (message.device !== void 0 && options.store !== void 0) await require_host_router.removeDevice(env, message.device.token, options.store);
72
74
  };
73
75
  const platforms = {};
74
76
  const apns = config.apns;
@@ -81,7 +83,7 @@ function channelsFromConfig(config, env, options = {}) {
81
83
  render,
82
84
  onUnregistered,
83
85
  ...env.APNS_RELAY_ORIGIN === void 0 ? {} : { origin: env.APNS_RELAY_ORIGIN },
84
- ...options.fetch === void 0 ? {} : { fetch: options.fetch }
86
+ ...(options.apnsFetch ?? options.fetch) === void 0 ? {} : { fetch: options.apnsFetch ?? options.fetch }
85
87
  }));
86
88
  const fcm = config.fcm;
87
89
  if (fcm !== void 0) platforms.android = lazy(() => new require_fcm.FcmPushSender({
@@ -103,7 +105,7 @@ function channelsFromConfig(config, env, options = {}) {
103
105
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
104
106
  }));
105
107
  if (Object.keys(platforms).length === 0) return unavailable("none of apns, fcm or webPush is configured");
106
- return require_cloudflare_senders.routeByPlatform(platforms);
108
+ return require_host_senders.routeByPlatform(platforms);
107
109
  };
108
110
  /**
109
111
  * A webhook template's URL, read from the environment the Worker validated at startup (its
@@ -144,7 +146,7 @@ function channelsFromConfig(config, env, options = {}) {
144
146
  ...options.fetch === void 0 ? {} : { fetch: options.fetch }
145
147
  });
146
148
  };
147
- return require_cloudflare_senders.routeByChannel({
149
+ return require_host_senders.routeByChannel({
148
150
  email: email(),
149
151
  push: push(),
150
152
  discord: discord(),
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channels.cjs","names":["resolveRuntime","EmailSender","registryRenderer","registryPushRenderer","removeDevice","ApnsPushSender","apnsKeyFromBase64","apnsEnvironment","FcmPushSender","serviceAccountFromBase64","WebPushSender","routeByPlatform","indexTemplates","DiscordWebhookSender","lookupTemplate","renderDiscordContent","SlackWebhookSender","renderSlackContent","routeByChannel"],"sources":["../../src/host/channels.ts"],"sourcesContent":["import { ApnsPushSender, apnsKeyFromBase64 } from '../apns';\nimport type { JourneyEnv } from '../cloudflare/bindings';\nimport { type JourneyConfig, apnsEnvironment } from '../config';\nimport { EmailSender, type EmailTransport } from '../email-sender';\nimport type { MessageSender, OutboundMessage } from '../engine/ports';\nimport { FcmPushSender, serviceAccountFromBase64 } from '../fcm';\nimport { registryPushRenderer } from '../push';\nimport { registryRenderer } from '../react-email';\nimport { type JourneyMode, type JourneyRuntimeOptions, resolveRuntime } from '../runtime';\nimport type { JourneyStore } from '../store/index';\nimport {\n type DiscordTemplate,\n type SlackTemplate,\n indexTemplates,\n lookupTemplate,\n renderDiscordContent,\n renderSlackContent,\n} from '../templates';\nimport { DiscordWebhookSender, SlackWebhookSender } from '../webhooks';\nimport { WebPushSender } from '../webpush';\nimport { removeDevice } from './router';\nimport { routeByChannel, routeByPlatform } from './senders';\n\n/**\n * The outlets a `JourneyConfig` describes, assembled against the environment:\n * email through the host's transport (SES, Cloudflare Email, ...) with each template's addresses, iOS pushes\n * straight to APNs with the configured key (the `.p8` from\n * `APNS_PRIVATE_KEY_BASE64`), Android pushes through FCM with the service\n * account from `GOOGLE_APPLICATION_CREDENTIALS_BASE64`. A channel the config\n * leaves out is simply absent, and a journey that sends on it fails that step\n * with a message saying so — rather than the host having to spell out every\n * sender by hand.\n */\n\n/** Which config entry applies: wrangler's `ENVIRONMENT` var, production when unset. */\nexport function journeyMode(env: JourneyEnv): JourneyMode {\n return env.ENVIRONMENT ?? 'production';\n}\n\n/** The runtime options for this environment's mode. */\nexport function runtimeFromConfig(\n config: Pick<JourneyConfig, 'runtime'> | undefined,\n env: JourneyEnv\n): Required<JourneyRuntimeOptions> {\n return resolveRuntime(config?.runtime, journeyMode(env));\n}\n\n/** A sender that fails every message with the reason the channel cannot deliver. */\nfunction unavailable(reason: string): MessageSender {\n return {\n async send(message) {\n throw new Error(`cannot send '${message.template}' on ${message.channel}: ${reason}`);\n },\n };\n}\n\nfunction requireSecret(\n env: JourneyEnv,\n name:\n | 'APNS_PRIVATE_KEY_BASE64'\n | 'GOOGLE_APPLICATION_CREDENTIALS_BASE64'\n | 'VAPID_PUBLIC_KEY'\n | 'VAPID_PRIVATE_KEY'\n): string {\n const value = env[name];\n if (typeof value !== 'string' || value.length === 0) {\n throw new Error(`${name} is not set: the environment must hold it for the configured channel`);\n }\n return value;\n}\n\n/**\n * A sender built on its first message. A platform's secret is only required\n * when something is actually sent there: a journey that only emails must not\n * fail because the APNs key is missing from a local `.dev.vars`.\n */\nfunction lazy(build: () => MessageSender): MessageSender {\n let sender: MessageSender | undefined;\n return {\n async send(message) {\n sender ??= build();\n return sender.send(message);\n },\n };\n}\n\n/** The channels slice of the config — what `channelsFromConfig` reads. */\nexport type ChannelsConfig = Pick<\n JourneyConfig,\n 'templates' | 'email' | 'apns' | 'fcm' | 'webPush' | 'runtime'\n>;\n\nexport function channelsFromConfig(\n config: ChannelsConfig,\n env: JourneyEnv,\n options: {\n /** The store a token APNs / FCM / a push service reports as gone is removed from. */\n store?: JourneyStore | undefined;\n /** Where finished emails go — the host's choice, independent of where the workflow runs. */\n email?: EmailTransport | undefined;\n fetch?: typeof fetch;\n /** APNs is HTTP/2 only: a Node host passes an HTTP/2 fetch here (its own fetch cannot). */\n apnsFetch?: typeof fetch | undefined;\n } = {}\n): MessageSender {\n const mode = journeyMode(env);\n\n const email = (): MessageSender => {\n const registry = config.templates?.email;\n if (registry === undefined) return unavailable('no email templates are registered');\n const transport = options.email;\n if (transport === undefined) {\n return unavailable(\"no email transport is configured (the host's `email` option)\");\n }\n // Each template names its own sender: there is no global one to require\n return new EmailSender({ transport, render: registryRenderer(registry, config.email) });\n };\n\n const push = (): MessageSender => {\n const registry = config.templates?.push;\n if (registry === undefined) return unavailable('no push templates are registered');\n const render = registryPushRenderer(registry);\n // A token the provider reports as gone is forgotten (nothing left to deliver to), waking the\n // waits that read devices, as a removal through the API does\n const onUnregistered = async (message: OutboundMessage) => {\n if (message.device !== undefined && options.store !== undefined) {\n await removeDevice(env, message.device.token, options.store);\n }\n };\n const platforms: Record<string, MessageSender> = {};\n const apns = config.apns;\n if (apns !== undefined) {\n platforms.ios = lazy(\n () =>\n new ApnsPushSender({\n keyId: apns.keyId,\n teamId: apns.teamId,\n privateKey: apnsKeyFromBase64(requireSecret(env, 'APNS_PRIVATE_KEY_BASE64')),\n topic: apns.topic,\n environment: apnsEnvironment(apns, mode),\n render,\n onUnregistered,\n ...(env.APNS_RELAY_ORIGIN === undefined ? {} : { origin: env.APNS_RELAY_ORIGIN }),\n ...((options.apnsFetch ?? options.fetch) === undefined\n ? {}\n : { fetch: options.apnsFetch ?? options.fetch }),\n })\n );\n }\n const fcm = config.fcm;\n if (fcm !== undefined) {\n platforms.android = lazy(\n () =>\n new FcmPushSender({\n serviceAccount: serviceAccountFromBase64(\n requireSecret(env, 'GOOGLE_APPLICATION_CREDENTIALS_BASE64')\n ),\n render,\n onUnregistered,\n ...(fcm.androidChannelId === undefined\n ? {}\n : { android: { channel_id: fcm.androidChannelId } }),\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n const webPush = config.webPush;\n if (webPush !== undefined) {\n platforms.web = lazy(\n () =>\n new WebPushSender({\n vapid: {\n subject: webPush.subject,\n publicKey: requireSecret(env, 'VAPID_PUBLIC_KEY'),\n privateKey: requireSecret(env, 'VAPID_PRIVATE_KEY'),\n },\n render,\n onUnregistered,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n })\n );\n }\n if (Object.keys(platforms).length === 0)\n return unavailable('none of apns, fcm or webPush is configured');\n return routeByPlatform(platforms);\n };\n\n /**\n * A webhook template's URL, read from the environment the Worker validated at startup (its\n * `env` schema): a template whose variable is unset fails the step, naming the template.\n */\n const webhookUrl = (channel: 'discord' | 'slack', template: DiscordTemplate | SlackTemplate) => {\n const url = template.webhook(env as unknown as Record<string, unknown>);\n if (typeof url !== 'string' || url === '') {\n throw new Error(\n `${channel} template '${template.id}': its webhook URL is not set in the environment`\n );\n }\n return url;\n };\n\n const discord = (): MessageSender => {\n const registry = config.templates?.discord;\n if (registry === undefined) return unavailable('no discord templates are registered');\n const index = indexTemplates('discord', registry);\n return new DiscordWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'discord', id);\n return {\n url: webhookUrl('discord', template),\n content: renderDiscordContent(template, props),\n };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n const slack = (): MessageSender => {\n const registry = config.templates?.slack;\n if (registry === undefined) return unavailable('no slack templates are registered');\n const index = indexTemplates('slack', registry);\n return new SlackWebhookSender({\n render: (id, props) => {\n const template = lookupTemplate(index, 'slack', id);\n return { url: webhookUrl('slack', template), content: renderSlackContent(template, props) };\n },\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n };\n\n return routeByChannel({ email: email(), push: push(), discord: discord(), slack: slack() });\n}\n"],"mappings":"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"}