@shware/workflow 3.3.0 → 4.1.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 (350) hide show
  1. package/README.md +230 -40
  2. package/dist/apns.cjs +113 -26
  3. package/dist/apns.cjs.map +1 -1
  4. package/dist/apns.d.cts +52 -6
  5. package/dist/apns.d.cts.map +1 -1
  6. package/dist/apns.d.mts +52 -6
  7. package/dist/apns.d.mts.map +1 -1
  8. package/dist/apns.mjs +111 -27
  9. package/dist/apns.mjs.map +1 -1
  10. package/dist/aws/engine-step.cjs +5 -5
  11. package/dist/aws/engine-step.cjs.map +1 -1
  12. package/dist/aws/engine-step.d.cts +8 -2
  13. package/dist/aws/engine-step.d.cts.map +1 -1
  14. package/dist/aws/engine-step.d.mts +8 -2
  15. package/dist/aws/engine-step.d.mts.map +1 -1
  16. package/dist/aws/engine-step.mjs +5 -5
  17. package/dist/aws/engine-step.mjs.map +1 -1
  18. package/dist/aws/index.cjs +50 -6
  19. package/dist/aws/index.cjs.map +1 -1
  20. package/dist/aws/index.d.cts +4 -3
  21. package/dist/aws/index.d.cts.map +1 -1
  22. package/dist/aws/index.d.mts +4 -3
  23. package/dist/aws/index.d.mts.map +1 -1
  24. package/dist/aws/index.mjs +49 -7
  25. package/dist/aws/index.mjs.map +1 -1
  26. package/dist/aws/instances.cjs +29 -6
  27. package/dist/aws/instances.cjs.map +1 -1
  28. package/dist/aws/instances.d.cts +11 -1
  29. package/dist/aws/instances.d.cts.map +1 -1
  30. package/dist/aws/instances.d.mts +11 -1
  31. package/dist/aws/instances.d.mts.map +1 -1
  32. package/dist/aws/instances.mjs +27 -4
  33. package/dist/aws/instances.mjs.map +1 -1
  34. package/dist/client.cjs +4 -1
  35. package/dist/client.cjs.map +1 -1
  36. package/dist/client.d.cts +2 -2
  37. package/dist/client.d.cts.map +1 -1
  38. package/dist/client.d.mts +2 -2
  39. package/dist/client.d.mts.map +1 -1
  40. package/dist/client.mjs +4 -1
  41. package/dist/client.mjs.map +1 -1
  42. package/dist/cloudflare/bindings.cjs +28 -25
  43. package/dist/cloudflare/bindings.cjs.map +1 -1
  44. package/dist/cloudflare/bindings.d.cts +15 -129
  45. package/dist/cloudflare/bindings.d.cts.map +1 -1
  46. package/dist/cloudflare/bindings.d.mts +15 -129
  47. package/dist/cloudflare/bindings.d.mts.map +1 -1
  48. package/dist/cloudflare/bindings.mjs +28 -23
  49. package/dist/cloudflare/bindings.mjs.map +1 -1
  50. package/dist/cloudflare/d1-env.cjs +6 -6
  51. package/dist/cloudflare/d1-env.cjs.map +1 -1
  52. package/dist/cloudflare/d1-env.d.cts +5 -5
  53. package/dist/cloudflare/d1-env.d.mts +5 -5
  54. package/dist/cloudflare/d1-env.mjs +6 -6
  55. package/dist/cloudflare/d1-env.mjs.map +1 -1
  56. package/dist/cloudflare/index.cjs +29 -18
  57. package/dist/cloudflare/index.d.cts +8 -4
  58. package/dist/cloudflare/index.d.mts +8 -4
  59. package/dist/cloudflare/index.mjs +9 -5
  60. package/dist/cloudflare/runner.cjs +29 -14
  61. package/dist/cloudflare/runner.cjs.map +1 -1
  62. package/dist/cloudflare/runner.d.cts +9 -2
  63. package/dist/cloudflare/runner.d.cts.map +1 -1
  64. package/dist/cloudflare/runner.d.mts +9 -2
  65. package/dist/cloudflare/runner.d.mts.map +1 -1
  66. package/dist/cloudflare/runner.mjs +28 -13
  67. package/dist/cloudflare/runner.mjs.map +1 -1
  68. package/dist/cloudflare/schema.sql +27 -6
  69. package/dist/cloudflare/worker.cjs +19 -9
  70. package/dist/cloudflare/worker.cjs.map +1 -1
  71. package/dist/cloudflare/worker.d.cts +2 -2
  72. package/dist/cloudflare/worker.d.cts.map +1 -1
  73. package/dist/cloudflare/worker.d.mts +2 -2
  74. package/dist/cloudflare/worker.d.mts.map +1 -1
  75. package/dist/cloudflare/worker.mjs +19 -9
  76. package/dist/cloudflare/worker.mjs.map +1 -1
  77. package/dist/config.cjs +21 -5
  78. package/dist/config.cjs.map +1 -1
  79. package/dist/config.mjs +21 -5
  80. package/dist/config.mjs.map +1 -1
  81. package/dist/dsl/action.cjs +3 -6
  82. package/dist/dsl/action.cjs.map +1 -1
  83. package/dist/dsl/action.d.cts +12 -11
  84. package/dist/dsl/action.d.cts.map +1 -1
  85. package/dist/dsl/action.d.mts +12 -11
  86. package/dist/dsl/action.d.mts.map +1 -1
  87. package/dist/dsl/action.mjs +3 -6
  88. package/dist/dsl/action.mjs.map +1 -1
  89. package/dist/dsl/bundle.cjs +35 -31
  90. package/dist/dsl/bundle.cjs.map +1 -1
  91. package/dist/dsl/bundle.d.cts.map +1 -1
  92. package/dist/dsl/bundle.d.mts.map +1 -1
  93. package/dist/dsl/bundle.mjs +35 -31
  94. package/dist/dsl/bundle.mjs.map +1 -1
  95. package/dist/dsl/condition.cjs +13 -1
  96. package/dist/dsl/condition.cjs.map +1 -1
  97. package/dist/dsl/condition.d.cts +9 -1
  98. package/dist/dsl/condition.d.cts.map +1 -1
  99. package/dist/dsl/condition.d.mts +9 -1
  100. package/dist/dsl/condition.d.mts.map +1 -1
  101. package/dist/dsl/condition.mjs +13 -2
  102. package/dist/dsl/condition.mjs.map +1 -1
  103. package/dist/dsl/flow.cjs +33 -11
  104. package/dist/dsl/flow.cjs.map +1 -1
  105. package/dist/dsl/flow.d.cts +9 -1
  106. package/dist/dsl/flow.d.cts.map +1 -1
  107. package/dist/dsl/flow.d.mts +9 -1
  108. package/dist/dsl/flow.d.mts.map +1 -1
  109. package/dist/dsl/flow.mjs +34 -13
  110. package/dist/dsl/flow.mjs.map +1 -1
  111. package/dist/dsl/workflow.cjs +2 -3
  112. package/dist/dsl/workflow.cjs.map +1 -1
  113. package/dist/dsl/workflow.d.cts.map +1 -1
  114. package/dist/dsl/workflow.d.mts.map +1 -1
  115. package/dist/dsl/workflow.mjs +3 -4
  116. package/dist/dsl/workflow.mjs.map +1 -1
  117. package/dist/engine/actions.cjs +1 -8
  118. package/dist/engine/actions.cjs.map +1 -1
  119. package/dist/engine/actions.d.cts +1 -12
  120. package/dist/engine/actions.d.cts.map +1 -1
  121. package/dist/engine/actions.d.mts +1 -12
  122. package/dist/engine/actions.d.mts.map +1 -1
  123. package/dist/engine/actions.mjs +1 -8
  124. package/dist/engine/actions.mjs.map +1 -1
  125. package/dist/engine/condition.cjs +25 -5
  126. package/dist/engine/condition.cjs.map +1 -1
  127. package/dist/engine/condition.d.cts +12 -3
  128. package/dist/engine/condition.d.cts.map +1 -1
  129. package/dist/engine/condition.d.mts +12 -3
  130. package/dist/engine/condition.d.mts.map +1 -1
  131. package/dist/engine/condition.mjs +25 -6
  132. package/dist/engine/condition.mjs.map +1 -1
  133. package/dist/engine/index.cjs +3 -0
  134. package/dist/engine/index.d.cts +5 -5
  135. package/dist/engine/index.d.mts +5 -5
  136. package/dist/engine/index.mjs +4 -4
  137. package/dist/engine/interpreter.cjs +27 -20
  138. package/dist/engine/interpreter.cjs.map +1 -1
  139. package/dist/engine/interpreter.d.cts.map +1 -1
  140. package/dist/engine/interpreter.d.mts.map +1 -1
  141. package/dist/engine/interpreter.mjs +27 -20
  142. package/dist/engine/interpreter.mjs.map +1 -1
  143. package/dist/engine/ports.d.cts +15 -6
  144. package/dist/engine/ports.d.cts.map +1 -1
  145. package/dist/engine/ports.d.mts +15 -6
  146. package/dist/engine/ports.d.mts.map +1 -1
  147. package/dist/engine/retry.cjs +13 -0
  148. package/dist/engine/retry.cjs.map +1 -1
  149. package/dist/engine/retry.d.cts +12 -0
  150. package/dist/engine/retry.d.cts.map +1 -1
  151. package/dist/engine/retry.d.mts +12 -0
  152. package/dist/engine/retry.d.mts.map +1 -1
  153. package/dist/engine/retry.mjs +13 -1
  154. package/dist/engine/retry.mjs.map +1 -1
  155. package/dist/engine/time-scale.cjs +90 -15
  156. package/dist/engine/time-scale.cjs.map +1 -1
  157. package/dist/engine/time-scale.d.cts +9 -6
  158. package/dist/engine/time-scale.d.cts.map +1 -1
  159. package/dist/engine/time-scale.d.mts +9 -6
  160. package/dist/engine/time-scale.d.mts.map +1 -1
  161. package/dist/engine/time-scale.mjs +90 -16
  162. package/dist/engine/time-scale.mjs.map +1 -1
  163. package/dist/fcm.cjs +111 -38
  164. package/dist/fcm.cjs.map +1 -1
  165. package/dist/fcm.d.cts +45 -5
  166. package/dist/fcm.d.cts.map +1 -1
  167. package/dist/fcm.d.mts +45 -5
  168. package/dist/fcm.d.mts.map +1 -1
  169. package/dist/fcm.mjs +110 -39
  170. package/dist/fcm.mjs.map +1 -1
  171. package/dist/hash.cjs +6 -6
  172. package/dist/hash.cjs.map +1 -1
  173. package/dist/hash.mjs +6 -6
  174. package/dist/hash.mjs.map +1 -1
  175. package/dist/host/auth.cjs +49 -0
  176. package/dist/host/auth.cjs.map +1 -0
  177. package/dist/host/auth.d.cts +35 -0
  178. package/dist/host/auth.d.cts.map +1 -0
  179. package/dist/host/auth.d.mts +35 -0
  180. package/dist/host/auth.d.mts.map +1 -0
  181. package/dist/host/auth.mjs +46 -0
  182. package/dist/host/auth.mjs.map +1 -0
  183. package/dist/host/channels.cjs +118 -40
  184. package/dist/host/channels.cjs.map +1 -1
  185. package/dist/host/channels.d.cts +27 -1
  186. package/dist/host/channels.d.cts.map +1 -1
  187. package/dist/host/channels.d.mts +27 -1
  188. package/dist/host/channels.d.mts.map +1 -1
  189. package/dist/host/channels.mjs +121 -44
  190. package/dist/host/channels.mjs.map +1 -1
  191. package/dist/host/execute.cjs +14 -2
  192. package/dist/host/execute.cjs.map +1 -1
  193. package/dist/host/execute.d.cts +4 -4
  194. package/dist/host/execute.d.cts.map +1 -1
  195. package/dist/host/execute.d.mts +4 -4
  196. package/dist/host/execute.d.mts.map +1 -1
  197. package/dist/host/execute.mjs +13 -1
  198. package/dist/host/execute.mjs.map +1 -1
  199. package/dist/host/fresh-read.cjs +30 -0
  200. package/dist/host/fresh-read.cjs.map +1 -0
  201. package/dist/host/fresh-read.d.cts +18 -0
  202. package/dist/host/fresh-read.d.cts.map +1 -0
  203. package/dist/host/fresh-read.d.mts +18 -0
  204. package/dist/host/fresh-read.d.mts.map +1 -0
  205. package/dist/host/fresh-read.mjs +29 -0
  206. package/dist/host/fresh-read.mjs.map +1 -0
  207. package/dist/host/http.cjs +340 -0
  208. package/dist/host/http.cjs.map +1 -0
  209. package/dist/host/http.d.cts +58 -0
  210. package/dist/host/http.d.cts.map +1 -0
  211. package/dist/host/http.d.mts +58 -0
  212. package/dist/host/http.d.mts.map +1 -0
  213. package/dist/host/http.mjs +336 -0
  214. package/dist/host/http.mjs.map +1 -0
  215. package/dist/host/{router.cjs → ingest.cjs} +147 -175
  216. package/dist/host/ingest.cjs.map +1 -0
  217. package/dist/host/{router.d.cts → ingest.d.cts} +48 -30
  218. package/dist/host/ingest.d.cts.map +1 -0
  219. package/dist/host/{router.d.mts → ingest.d.mts} +48 -30
  220. package/dist/host/ingest.d.mts.map +1 -0
  221. package/dist/host/{router.mjs → ingest.mjs} +144 -173
  222. package/dist/host/ingest.mjs.map +1 -0
  223. package/dist/host/journey-host.cjs +66 -93
  224. package/dist/host/journey-host.cjs.map +1 -1
  225. package/dist/host/journey-host.d.cts +31 -26
  226. package/dist/host/journey-host.d.cts.map +1 -1
  227. package/dist/host/journey-host.d.mts +31 -26
  228. package/dist/host/journey-host.d.mts.map +1 -1
  229. package/dist/host/journey-host.mjs +66 -93
  230. package/dist/host/journey-host.mjs.map +1 -1
  231. package/dist/host/platform.cjs +21 -0
  232. package/dist/host/platform.cjs.map +1 -0
  233. package/dist/host/platform.d.cts +126 -0
  234. package/dist/host/platform.d.cts.map +1 -0
  235. package/dist/host/platform.d.mts +126 -0
  236. package/dist/host/platform.d.mts.map +1 -0
  237. package/dist/host/platform.mjs +19 -0
  238. package/dist/host/platform.mjs.map +1 -0
  239. package/dist/host/senders.cjs +18 -9
  240. package/dist/host/senders.cjs.map +1 -1
  241. package/dist/host/senders.d.cts +9 -4
  242. package/dist/host/senders.d.cts.map +1 -1
  243. package/dist/host/senders.d.mts +9 -4
  244. package/dist/host/senders.d.mts.map +1 -1
  245. package/dist/host/senders.mjs +18 -9
  246. package/dist/host/senders.mjs.map +1 -1
  247. package/dist/index.cjs +1 -1
  248. package/dist/index.mjs +1 -1
  249. package/dist/ir.cjs +5 -7
  250. package/dist/ir.cjs.map +1 -1
  251. package/dist/ir.d.cts +3 -12
  252. package/dist/ir.d.cts.map +1 -1
  253. package/dist/ir.d.mts +3 -12
  254. package/dist/ir.d.mts.map +1 -1
  255. package/dist/ir.mjs +5 -7
  256. package/dist/ir.mjs.map +1 -1
  257. package/dist/plan.cjs +27 -33
  258. package/dist/plan.cjs.map +1 -1
  259. package/dist/plan.d.cts +1 -1
  260. package/dist/plan.d.cts.map +1 -1
  261. package/dist/plan.d.mts +1 -1
  262. package/dist/plan.d.mts.map +1 -1
  263. package/dist/plan.mjs +27 -33
  264. package/dist/plan.mjs.map +1 -1
  265. package/dist/reporting/tracking.cjs +14 -4
  266. package/dist/reporting/tracking.cjs.map +1 -1
  267. package/dist/reporting/tracking.d.cts +10 -10
  268. package/dist/reporting/tracking.d.cts.map +1 -1
  269. package/dist/reporting/tracking.d.mts +10 -10
  270. package/dist/reporting/tracking.d.mts.map +1 -1
  271. package/dist/reporting/tracking.mjs +14 -4
  272. package/dist/reporting/tracking.mjs.map +1 -1
  273. package/dist/store/d1.cjs +88 -29
  274. package/dist/store/d1.cjs.map +1 -1
  275. package/dist/store/d1.d.cts +29 -11
  276. package/dist/store/d1.d.cts.map +1 -1
  277. package/dist/store/d1.d.mts +29 -11
  278. package/dist/store/d1.d.mts.map +1 -1
  279. package/dist/store/d1.mjs +88 -29
  280. package/dist/store/d1.mjs.map +1 -1
  281. package/dist/store/erasure.cjs +22 -0
  282. package/dist/store/erasure.cjs.map +1 -0
  283. package/dist/store/erasure.d.cts +18 -0
  284. package/dist/store/erasure.d.cts.map +1 -0
  285. package/dist/store/erasure.d.mts +18 -0
  286. package/dist/store/erasure.d.mts.map +1 -0
  287. package/dist/store/erasure.mjs +21 -0
  288. package/dist/store/erasure.mjs.map +1 -0
  289. package/dist/store/index.cjs +2 -0
  290. package/dist/store/index.cjs.map +1 -1
  291. package/dist/store/index.d.cts +67 -13
  292. package/dist/store/index.d.cts.map +1 -1
  293. package/dist/store/index.d.mts +67 -13
  294. package/dist/store/index.d.mts.map +1 -1
  295. package/dist/store/index.mjs +2 -1
  296. package/dist/store/index.mjs.map +1 -1
  297. package/dist/store/postgres-reporting.cjs +125 -62
  298. package/dist/store/postgres-reporting.cjs.map +1 -1
  299. package/dist/store/postgres-reporting.d.cts +30 -2
  300. package/dist/store/postgres-reporting.d.cts.map +1 -1
  301. package/dist/store/postgres-reporting.d.mts +30 -2
  302. package/dist/store/postgres-reporting.d.mts.map +1 -1
  303. package/dist/store/postgres-reporting.mjs +125 -62
  304. package/dist/store/postgres-reporting.mjs.map +1 -1
  305. package/dist/store/postgres.cjs +179 -60
  306. package/dist/store/postgres.cjs.map +1 -1
  307. package/dist/store/postgres.d.cts +44 -7
  308. package/dist/store/postgres.d.cts.map +1 -1
  309. package/dist/store/postgres.d.mts +44 -7
  310. package/dist/store/postgres.d.mts.map +1 -1
  311. package/dist/store/postgres.mjs +179 -60
  312. package/dist/store/postgres.mjs.map +1 -1
  313. package/dist/store/tables.cjs +7 -5
  314. package/dist/store/tables.cjs.map +1 -1
  315. package/dist/store/tables.d.cts.map +1 -1
  316. package/dist/store/tables.d.mts.map +1 -1
  317. package/dist/store/tables.mjs +7 -5
  318. package/dist/store/tables.mjs.map +1 -1
  319. package/dist/store/user-lock.cjs +43 -0
  320. package/dist/store/user-lock.cjs.map +1 -0
  321. package/dist/store/user-lock.d.cts +32 -0
  322. package/dist/store/user-lock.d.cts.map +1 -0
  323. package/dist/store/user-lock.d.mts +32 -0
  324. package/dist/store/user-lock.d.mts.map +1 -0
  325. package/dist/store/user-lock.mjs +40 -0
  326. package/dist/store/user-lock.mjs.map +1 -0
  327. package/dist/transports/cloudflare-email.cjs +1 -1
  328. package/dist/transports/cloudflare-email.mjs +1 -1
  329. package/dist/transports/ses.cjs +47 -6
  330. package/dist/transports/ses.cjs.map +1 -1
  331. package/dist/transports/ses.d.cts +9 -1
  332. package/dist/transports/ses.d.cts.map +1 -1
  333. package/dist/transports/ses.d.mts +9 -1
  334. package/dist/transports/ses.d.mts.map +1 -1
  335. package/dist/transports/ses.mjs +47 -7
  336. package/dist/transports/ses.mjs.map +1 -1
  337. package/dist/webpush.cjs +133 -47
  338. package/dist/webpush.cjs.map +1 -1
  339. package/dist/webpush.d.cts +49 -8
  340. package/dist/webpush.d.cts.map +1 -1
  341. package/dist/webpush.d.mts +49 -8
  342. package/dist/webpush.d.mts.map +1 -1
  343. package/dist/webpush.mjs +130 -47
  344. package/dist/webpush.mjs.map +1 -1
  345. package/package.json +1 -1
  346. package/src/cloudflare/schema.sql +27 -6
  347. package/dist/host/router.cjs.map +0 -1
  348. package/dist/host/router.d.cts.map +0 -1
  349. package/dist/host/router.d.mts.map +0 -1
  350. package/dist/host/router.mjs.map +0 -1
@@ -9,16 +9,21 @@
9
9
  -- interpreter's send_event step being retried after it already committed its
10
10
  -- row), so both pass an id they can reproduce.
11
11
  --
12
+ -- The id is per user: the key is (user_id, id), so one sender's id can never
13
+ -- block another user's event, and a producer only has to keep its ids unique
14
+ -- within a user.
15
+ --
12
16
  -- Only the sender knows whether two deliveries describe one occurrence or two,
13
17
  -- so the engine never invents that answer: an ingest that arrives without an id
14
18
  -- is given a fresh one, which is a truthful "this is its own occurrence" rather
15
19
  -- than a guess.
16
20
  CREATE TABLE IF NOT EXISTS events (
17
- id TEXT PRIMARY KEY NOT NULL,
21
+ id TEXT NOT NULL,
18
22
  user_id TEXT NOT NULL,
19
23
  name TEXT NOT NULL,
20
24
  ts INTEGER NOT NULL,
21
- payload TEXT NOT NULL DEFAULT '{}'
25
+ payload TEXT NOT NULL DEFAULT '{}',
26
+ PRIMARY KEY (user_id, id)
22
27
  );
23
28
  CREATE INDEX IF NOT EXISTS idx_events_user_name_ts ON events (user_id, name, ts);
24
29
 
@@ -27,6 +32,15 @@ CREATE TABLE IF NOT EXISTS profiles (
27
32
  props TEXT NOT NULL DEFAULT '{}'
28
33
  );
29
34
 
35
+ -- Workflow bodies (WorkflowIR JSON), content-addressed. They live beside the
36
+ -- routes that point at them so a deploy is read-your-writes: the trigger row
37
+ -- and the body it names are in one strongly consistent database, and a route
38
+ -- never names a body another store has yet to catch up on.
39
+ CREATE TABLE IF NOT EXISTS workflow_ir (
40
+ content_hash TEXT PRIMARY KEY,
41
+ ir TEXT NOT NULL
42
+ );
43
+
30
44
  -- Deploy artefacts: segment definitions (resolved by id during evaluation)
31
45
  -- and the trigger routing table.
32
46
  -- Push devices: one row per provider token, owned by the user who registered it last.
@@ -87,11 +101,14 @@ CREATE TABLE IF NOT EXISTS segment_starts (
87
101
  PRIMARY KEY (segment, workflow, user_id)
88
102
  );
89
103
 
90
- -- Erasures in progress (forgetUser): the user's data stays until every running
91
- -- instance is stopped, and the scheduled sweep retries what is left.
104
+ -- Erasure tombstones (forgetUser): every write about a user is conditional on
105
+ -- no row here. While erased_at is null the user's data stays until every
106
+ -- running instance is stopped, and the scheduled sweep retries what is left;
107
+ -- once set, the data is gone and the row stays so the id is never written again.
92
108
  CREATE TABLE IF NOT EXISTS erasures (
93
- user_id TEXT PRIMARY KEY,
94
- ts INTEGER NOT NULL
109
+ user_id TEXT PRIMARY KEY,
110
+ ts INTEGER NOT NULL,
111
+ erased_at INTEGER
95
112
  );
96
113
 
97
114
  -- Entry ledger: the once-per-user policy, instance addressing, and the audit
@@ -110,6 +127,10 @@ CREATE TABLE IF NOT EXISTS entries (
110
127
  hash TEXT NOT NULL,
111
128
  status TEXT NOT NULL,
112
129
  ts INTEGER NOT NULL,
130
+ -- When the runner admitted itself (admitInstance), before its first
131
+ -- observable effect; null until then, so an erasure knows nothing of the
132
+ -- instance has run yet.
133
+ admitted_at INTEGER,
113
134
  PRIMARY KEY (workflow, user_id)
114
135
  );
115
136
 
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_host_journey_host = require("../host/journey-host.cjs");
3
+ const require_cloudflare_bindings = require("./bindings.cjs");
3
4
  const require_cloudflare_runner = require("./runner.cjs");
4
5
  //#region src/cloudflare/worker.ts
5
6
  /**
@@ -27,9 +28,20 @@ function journeyWorker(options) {
27
28
  await this.leases.get(store)?.();
28
29
  }
29
30
  createRunParts(store, params) {
30
- return host.runParts(host.checkEnv(this.env), store, params);
31
+ return host.runParts(host.checkEnv(this.hostEnv()), store, params);
31
32
  }
32
33
  }
34
+ /**
35
+ * The validated environment, its Workflow binding behind the host's port (an unknown instance
36
+ * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.
37
+ */
38
+ const hostEnv = (rawEnv) => {
39
+ const env = host.checkEnv(rawEnv);
40
+ return {
41
+ ...env,
42
+ JOURNEY: require_cloudflare_bindings.cloudflareJourneyBinding(env.JOURNEY)
43
+ };
44
+ };
33
45
  /** CF's version metadata: the bundle compiled into each new version goes live by itself. */
34
46
  const ownVersion = (env) => {
35
47
  const metadata = env.CF_VERSION_METADATA;
@@ -42,7 +54,7 @@ function journeyWorker(options) {
42
54
  const fetch = async (request, rawEnv, ctx) => {
43
55
  let env;
44
56
  try {
45
- env = host.checkEnv(rawEnv);
57
+ env = hostEnv(rawEnv);
46
58
  } catch (error) {
47
59
  if (!(error instanceof require_host_journey_host.EnvValidationError)) throw error;
48
60
  console.error(`[workflow] ${error.message}`);
@@ -64,13 +76,11 @@ function journeyWorker(options) {
64
76
  else ctx.waitUntil(close());
65
77
  }
66
78
  };
67
- const sweep = (rawEnv) => host.sweep(host.checkEnv(rawEnv));
68
- const scheduled = async (_controller, env, ctx) => {
69
- ctx.waitUntil((async () => {
70
- const checked = host.checkEnv(env);
71
- await host.applyOwnBundle(checked, ownVersion(checked));
72
- await sweep(env);
73
- })());
79
+ const sweep = (rawEnv) => host.sweep(hostEnv(rawEnv));
80
+ const scheduled = async (_controller, rawEnv, _ctx) => {
81
+ const env = hostEnv(rawEnv);
82
+ await host.applyOwnBundle(env, ownVersion(env));
83
+ await host.sweep(env);
74
84
  };
75
85
  return {
76
86
  Runner,
@@ -1 +1 @@
1
- {"version":3,"file":"worker.cjs","names":["createJourneyHost","JourneyRunner","EnvValidationError"],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { SweepResult } from '../host/router';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport type { JourneyEnv, JourneyParams } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.env as Env), store, params);\n }\n }\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n // Everything below reads the validated environment (the schema's defaults applied)\n let env: Env;\n try {\n env = host.checkEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store);\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(host.checkEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, env: Env, ctx: ExecutionContextLike) => {\n // A version with no traffic yet goes live here; the sweep then reads its segments\n ctx.waitUntil(\n (async () => {\n const checked = host.checkEnv(env);\n await host.applyOwnBundle(checked, ownVersion(checked));\n await sweep(env);\n })()\n );\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAqDA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAOA,0BAAAA,kBAAkB,OAAO;CAEtC,MAAM,eAAeC,0BAAAA,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,GAAU,GAAG,OAAO,MAAM;EACpE;CACF;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EAEjF,IAAI;EACJ,IAAI;GACF,MAAM,KAAK,SAAS,MAAM;EAC5B,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiBC,0BAAAA,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,KAAK;EAC9C,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,KAAK,SAAS,MAAM,CAAC;CAE/D,MAAM,YAAY,OAAO,aAAsB,KAAU,QAA8B;EAErF,IAAI,WACD,YAAY;GACX,MAAM,UAAU,KAAK,SAAS,GAAG;GACjC,MAAM,KAAK,eAAe,SAAS,WAAW,OAAO,CAAC;GACtD,MAAM,MAAM,GAAG;EACjB,EAAA,CAAG,CACL;CACF;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
1
+ {"version":3,"file":"worker.cjs","names":["createJourneyHost","JourneyRunner","cloudflareJourneyBinding","EnvValidationError"],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport type { SweepResult } from '../host/ingest';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { JourneyEnv, JourneyParams } from '../host/platform';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport { cloudflareJourneyBinding } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.hostEnv() as Env), store, params);\n }\n }\n\n /**\n * The validated environment, its Workflow binding behind the host's port (an unknown instance\n * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.\n */\n const hostEnv = (rawEnv: Env): Env => {\n const env = host.checkEnv(rawEnv);\n return { ...env, JOURNEY: cloudflareJourneyBinding(env.JOURNEY) };\n };\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n let env: Env;\n try {\n env = hostEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store);\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(hostEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, rawEnv: Env, _ctx: ExecutionContextLike) => {\n // Awaited, not handed to waitUntil: the runtime keeps a scheduled handler alive for as long\n // as its promise runs, while a sweep only parked in waitUntil can be cut off by the\n // handler returning, and its failure would surface nowhere. A version with no traffic yet\n // goes live here; the sweep then reads its segments.\n const env = hostEnv(rawEnv);\n await host.applyOwnBundle(env, ownVersion(env));\n await host.sweep(env);\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAsDA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAOA,0BAAAA,kBAAkB,OAAO;CAEtC,MAAM,eAAeC,0BAAAA,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAQ,GAAG,OAAO,MAAM;EAC1E;CACF;;;;;CAMA,MAAM,WAAW,WAAqB;EACpC,MAAM,MAAM,KAAK,SAAS,MAAM;EAChC,OAAO;GAAE,GAAG;GAAK,SAASC,4BAAAA,yBAAyB,IAAI,OAAO;EAAE;CAClE;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,QAAQ,MAAM;EACtB,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiBC,0BAAAA,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,KAAK;EAC9C,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,QAAQ,MAAM,CAAC;CAEzD,MAAM,YAAY,OAAO,aAAsB,QAAa,SAA+B;EAKzF,MAAM,MAAM,QAAQ,MAAM;EAC1B,MAAM,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAC9C,MAAM,KAAK,MAAM,GAAG;CACtB;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
@@ -1,5 +1,5 @@
1
- import { JourneyEnv } from "./bindings.cjs";
2
- import { SweepResult } from "../host/router.cjs";
1
+ import { JourneyEnv } from "../host/platform.cjs";
2
+ import { SweepResult } from "../host/ingest.cjs";
3
3
  import { EnvValidationError, JourneyHostOptions } from "../host/journey-host.cjs";
4
4
  import { JourneyRunner } from "./runner.cjs";
5
5
  //#region src/cloudflare/worker.d.ts
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.cts","names":[],"sources":["../../src/cloudflare/worker.ts"],"mappings":";;;;;;iBAciB;EACf,UAAU,SAAS;;;;;;;YAQT,qBAAqB,YAAY,cAAc,mBAAmB;iBAE7D,cAAc,YAAY;;EAEzC,gBAAgB,kBAAkB;;EAElC,QAAQ,SAAS,SAAS,KAAK,KAAK,MAAM,yBAAyB,QAAQ;;;;;;;EAO3E,QAAQ,KAAK,QAAQ,QAAQ;;EAE7B,YAAY,qBAAqB,KAAK,KAAK,KAAK,yBAAyB;;;;;;;;;;;;;;wBAe3D,cAAc,YAAY,YACxC,SAAS,qBAAqB,OAC7B,cAAc"}
1
+ {"version":3,"file":"worker.d.cts","names":[],"sources":["../../src/cloudflare/worker.ts"],"mappings":";;;;;;iBAeiB;EACf,UAAU,SAAS;;;;;;;YAQT,qBAAqB,YAAY,cAAc,mBAAmB;iBAE7D,cAAc,YAAY;;EAEzC,gBAAgB,kBAAkB;;EAElC,QAAQ,SAAS,SAAS,KAAK,KAAK,MAAM,yBAAyB,QAAQ;;;;;;;EAO3E,QAAQ,KAAK,QAAQ,QAAQ;;EAE7B,YAAY,qBAAqB,KAAK,KAAK,KAAK,yBAAyB;;;;;;;;;;;;;;wBAe3D,cAAc,YAAY,YACxC,SAAS,qBAAqB,OAC7B,cAAc"}
@@ -1,5 +1,5 @@
1
- import { JourneyEnv } from "./bindings.mjs";
2
- import { SweepResult } from "../host/router.mjs";
1
+ import { JourneyEnv } from "../host/platform.mjs";
2
+ import { SweepResult } from "../host/ingest.mjs";
3
3
  import { EnvValidationError, JourneyHostOptions } from "../host/journey-host.mjs";
4
4
  import { JourneyRunner } from "./runner.mjs";
5
5
  //#region src/cloudflare/worker.d.ts
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.mts","names":[],"sources":["../../src/cloudflare/worker.ts"],"mappings":";;;;;;iBAciB;EACf,UAAU,SAAS;;;;;;;YAQT,qBAAqB,YAAY,cAAc,mBAAmB;iBAE7D,cAAc,YAAY;;EAEzC,gBAAgB,kBAAkB;;EAElC,QAAQ,SAAS,SAAS,KAAK,KAAK,MAAM,yBAAyB,QAAQ;;;;;;;EAO3E,QAAQ,KAAK,QAAQ,QAAQ;;EAE7B,YAAY,qBAAqB,KAAK,KAAK,KAAK,yBAAyB;;;;;;;;;;;;;;wBAe3D,cAAc,YAAY,YACxC,SAAS,qBAAqB,OAC7B,cAAc"}
1
+ {"version":3,"file":"worker.d.mts","names":[],"sources":["../../src/cloudflare/worker.ts"],"mappings":";;;;;;iBAeiB;EACf,UAAU,SAAS;;;;;;;YAQT,qBAAqB,YAAY,cAAc,mBAAmB;iBAE7D,cAAc,YAAY;;EAEzC,gBAAgB,kBAAkB;;EAElC,QAAQ,SAAS,SAAS,KAAK,KAAK,MAAM,yBAAyB,QAAQ;;;;;;;EAO3E,QAAQ,KAAK,QAAQ,QAAQ;;EAE7B,YAAY,qBAAqB,KAAK,KAAK,KAAK,yBAAyB;;;;;;;;;;;;;;wBAe3D,cAAc,YAAY,YACxC,SAAS,qBAAqB,OAC7B,cAAc"}
@@ -1,4 +1,5 @@
1
1
  import { EnvValidationError, createJourneyHost } from "../host/journey-host.mjs";
2
+ import { cloudflareJourneyBinding } from "./bindings.mjs";
2
3
  import { JourneyRunner } from "./runner.mjs";
3
4
  //#region src/cloudflare/worker.ts
4
5
  /**
@@ -26,9 +27,20 @@ function journeyWorker(options) {
26
27
  await this.leases.get(store)?.();
27
28
  }
28
29
  createRunParts(store, params) {
29
- return host.runParts(host.checkEnv(this.env), store, params);
30
+ return host.runParts(host.checkEnv(this.hostEnv()), store, params);
30
31
  }
31
32
  }
33
+ /**
34
+ * The validated environment, its Workflow binding behind the host's port (an unknown instance
35
+ * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.
36
+ */
37
+ const hostEnv = (rawEnv) => {
38
+ const env = host.checkEnv(rawEnv);
39
+ return {
40
+ ...env,
41
+ JOURNEY: cloudflareJourneyBinding(env.JOURNEY)
42
+ };
43
+ };
32
44
  /** CF's version metadata: the bundle compiled into each new version goes live by itself. */
33
45
  const ownVersion = (env) => {
34
46
  const metadata = env.CF_VERSION_METADATA;
@@ -41,7 +53,7 @@ function journeyWorker(options) {
41
53
  const fetch = async (request, rawEnv, ctx) => {
42
54
  let env;
43
55
  try {
44
- env = host.checkEnv(rawEnv);
56
+ env = hostEnv(rawEnv);
45
57
  } catch (error) {
46
58
  if (!(error instanceof EnvValidationError)) throw error;
47
59
  console.error(`[workflow] ${error.message}`);
@@ -63,13 +75,11 @@ function journeyWorker(options) {
63
75
  else ctx.waitUntil(close());
64
76
  }
65
77
  };
66
- const sweep = (rawEnv) => host.sweep(host.checkEnv(rawEnv));
67
- const scheduled = async (_controller, env, ctx) => {
68
- ctx.waitUntil((async () => {
69
- const checked = host.checkEnv(env);
70
- await host.applyOwnBundle(checked, ownVersion(checked));
71
- await sweep(env);
72
- })());
78
+ const sweep = (rawEnv) => host.sweep(hostEnv(rawEnv));
79
+ const scheduled = async (_controller, rawEnv, _ctx) => {
80
+ const env = hostEnv(rawEnv);
81
+ await host.applyOwnBundle(env, ownVersion(env));
82
+ await host.sweep(env);
73
83
  };
74
84
  return {
75
85
  Runner,
@@ -1 +1 @@
1
- {"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { SweepResult } from '../host/router';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport type { JourneyEnv, JourneyParams } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.env as Env), store, params);\n }\n }\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n // Everything below reads the validated environment (the schema's defaults applied)\n let env: Env;\n try {\n env = host.checkEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store);\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(host.checkEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, env: Env, ctx: ExecutionContextLike) => {\n // A version with no traffic yet goes live here; the sweep then reads its segments\n ctx.waitUntil(\n (async () => {\n const checked = host.checkEnv(env);\n await host.applyOwnBundle(checked, ownVersion(checked));\n await sweep(env);\n })()\n );\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;AAqDA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAO,kBAAkB,OAAO;CAEtC,MAAM,eAAe,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,GAAU,GAAG,OAAO,MAAM;EACpE;CACF;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EAEjF,IAAI;EACJ,IAAI;GACF,MAAM,KAAK,SAAS,MAAM;EAC5B,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiB,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,KAAK;EAC9C,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,KAAK,SAAS,MAAM,CAAC;CAE/D,MAAM,YAAY,OAAO,aAAsB,KAAU,QAA8B;EAErF,IAAI,WACD,YAAY;GACX,MAAM,UAAU,KAAK,SAAS,GAAG;GACjC,MAAM,KAAK,eAAe,SAAS,WAAW,OAAO,CAAC;GACtD,MAAM,MAAM,GAAG;EACjB,EAAA,CAAG,CACL;CACF;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
1
+ {"version":3,"file":"worker.mjs","names":[],"sources":["../../src/cloudflare/worker.ts"],"sourcesContent":["import type { JourneyRunParts } from '../host/execute';\nimport type { SweepResult } from '../host/ingest';\nimport {\n EnvValidationError,\n type JourneyHostOptions,\n createJourneyHost,\n} from '../host/journey-host';\nimport type { JourneyEnv, JourneyParams } from '../host/platform';\nimport type { BundleVersion, JourneyStore } from '../store/index';\nimport { cloudflareJourneyBinding } from './bindings';\nimport { JourneyRunner } from './runner';\n\nexport { EnvValidationError } from '../host/journey-host';\n\n/** The fetch handler's execution context (the subset used). */\nexport interface ExecutionContextLike {\n waitUntil(promise: Promise<unknown>): void;\n}\n\n/**\n * `journeyWorker`'s options: the host's (config, store, email transport, observer, ...). The\n * email transport is the host's choice — `cloudflareEmailTransport({ binding: env.EMAIL })` for\n * Cloudflare Email, `sesEmailTransport(...)` for SES.\n */\nexport type JourneyWorkerOptions<Env extends JourneyEnv> = JourneyHostOptions<Env>;\n\nexport interface JourneyWorker<Env extends JourneyEnv> {\n /** Export this under the `class_name` of the wrangler `workflows` binding. */\n Runner: new (...args: never[]) => JourneyRunner;\n /** The Worker's fetch handler: `/health`, `/events`, `/identify`, `/devices`, `/messages/opened`, `/segments/sweep`, `/t/*` (email tracking), `/deploy`, `/bundle`, `/bundle/deploy` and `/preview/*`. */\n fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => Promise<Response>;\n /**\n * The segment sweep (`sweepSegments`): membership of every deployed segment for every known\n * user, the journeys of segments entered by the clock alone (`not(performed(e.login, { within:\n * '7 days' }))` has no event to ride on), and each segment's size. Also served as\n * `POST /segments/sweep`.\n */\n sweep: (env: Env) => Promise<SweepResult>;\n /** The cron trigger's handler: runs the sweep. Export it beside `fetch` with a `triggers.crons` entry. */\n scheduled: (controller: unknown, env: Env, ctx: ExecutionContextLike) => Promise<void>;\n}\n\n/**\n * A complete journey host in one call: the runner class with the app's store,\n * outlets and runtime plugged in, and the fetch handler that serves the ingest\n * API around them.\n *\n * const journeys = journeyWorker<Env>({\n * config, // workflow.config.ts (defineConfig)\n * store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),\n * });\n * export const MyRunner = journeys.Runner;\n * export default { fetch: journeys.fetch };\n */\nexport function journeyWorker<Env extends JourneyEnv>(\n options: JourneyWorkerOptions<Env>\n): JourneyWorker<Env> {\n const host = createJourneyHost(options);\n\n class Runner extends JourneyRunner {\n private readonly leases = new WeakMap<JourneyStore, () => Promise<void>>();\n\n protected override createStore(): JourneyStore {\n // A run fails at its start on an invalid environment, not at the send that needs it\n const { store, close } = host.lease(host.checkEnv(this.env as Env));\n this.leases.set(store, close);\n return store;\n }\n\n protected override async closeStore(store: JourneyStore): Promise<void> {\n await this.leases.get(store)?.();\n }\n\n protected override createRunParts(store: JourneyStore, params: JourneyParams): JourneyRunParts {\n return host.runParts(host.checkEnv(this.hostEnv() as Env), store, params);\n }\n }\n\n /**\n * The validated environment, its Workflow binding behind the host's port (an unknown instance\n * answers `InstanceNotFoundError`). Everything below reads this one, never the raw env.\n */\n const hostEnv = (rawEnv: Env): Env => {\n const env = host.checkEnv(rawEnv);\n return { ...env, JOURNEY: cloudflareJourneyBinding(env.JOURNEY) };\n };\n\n /** CF's version metadata: the bundle compiled into each new version goes live by itself. */\n const ownVersion = (env: Env): BundleVersion | undefined => {\n const metadata = env.CF_VERSION_METADATA;\n if (metadata === undefined) return undefined;\n return { id: metadata.id, createdAt: Date.parse(metadata.timestamp) };\n };\n\n const fetch = async (request: Request, rawEnv: Env, ctx?: ExecutionContextLike) => {\n let env: Env;\n try {\n env = hostEnv(rawEnv);\n } catch (error) {\n if (!(error instanceof EnvValidationError)) throw error;\n console.error(`[workflow] ${error.message}`);\n return new Response(JSON.stringify({ error: error.message }), {\n status: 500,\n headers: { 'content-type': 'application/json' },\n });\n }\n const pending = host.applyOwnBundle(env, ownVersion(env));\n // In the background where the runtime has waitUntil; inline otherwise (tests)\n if (pending !== undefined) {\n if (ctx === undefined) await pending;\n else ctx.waitUntil(pending);\n }\n const { store, close } = host.lease(env);\n try {\n return await host.handle(request, env, store);\n } finally {\n // After the response when the runtime offers waitUntil, inline otherwise (tests).\n if (ctx === undefined) await close();\n else ctx.waitUntil(close());\n }\n };\n\n const sweep = (rawEnv: Env) => host.sweep(hostEnv(rawEnv));\n\n const scheduled = async (_controller: unknown, rawEnv: Env, _ctx: ExecutionContextLike) => {\n // Awaited, not handed to waitUntil: the runtime keeps a scheduled handler alive for as long\n // as its promise runs, while a sweep only parked in waitUntil can be cut off by the\n // handler returning, and its failure would surface nowhere. A version with no traffic yet\n // goes live here; the sweep then reads its segments.\n const env = hostEnv(rawEnv);\n await host.applyOwnBundle(env, ownVersion(env));\n await host.sweep(env);\n };\n\n return { Runner, fetch, sweep, scheduled };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsDA,SAAgB,cACd,SACoB;CACpB,MAAM,OAAO,kBAAkB,OAAO;CAEtC,MAAM,eAAe,cAAc;EACjC,yBAA0B,IAAI,QAA2C;EAEzE,cAA+C;GAE7C,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,KAAK,SAAS,KAAK,GAAU,CAAC;GAClE,KAAK,OAAO,IAAI,OAAO,KAAK;GAC5B,OAAO;EACT;EAEA,MAAyB,WAAW,OAAoC;GACtE,MAAM,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG;EACjC;EAEA,eAAkC,OAAqB,QAAwC;GAC7F,OAAO,KAAK,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAQ,GAAG,OAAO,MAAM;EAC1E;CACF;;;;;CAMA,MAAM,WAAW,WAAqB;EACpC,MAAM,MAAM,KAAK,SAAS,MAAM;EAChC,OAAO;GAAE,GAAG;GAAK,SAAS,yBAAyB,IAAI,OAAO;EAAE;CAClE;;CAGA,MAAM,cAAc,QAAwC;EAC1D,MAAM,WAAW,IAAI;EACrB,IAAI,aAAa,KAAA,GAAW,OAAO,KAAA;EACnC,OAAO;GAAE,IAAI,SAAS;GAAI,WAAW,KAAK,MAAM,SAAS,SAAS;EAAE;CACtE;CAEA,MAAM,QAAQ,OAAO,SAAkB,QAAa,QAA+B;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,QAAQ,MAAM;EACtB,SAAS,OAAO;GACd,IAAI,EAAE,iBAAiB,qBAAqB,MAAM;GAClD,QAAQ,MAAM,cAAc,MAAM,SAAS;GAC3C,OAAO,IAAI,SAAS,KAAK,UAAU,EAAE,OAAO,MAAM,QAAQ,CAAC,GAAG;IAC5D,QAAQ;IACR,SAAS,EAAE,gBAAgB,mBAAmB;GAChD,CAAC;EACH;EACA,MAAM,UAAU,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAExD,IAAI,YAAY,KAAA,GAAW;GACzB,IAAI,QAAQ,KAAA,GAAW,MAAM;QACxB,IAAI,UAAU,OAAO;EAC5B;EACA,MAAM,EAAE,OAAO,UAAU,KAAK,MAAM,GAAG;EACvC,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,KAAK,KAAK;EAC9C,UAAU;GAER,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;QAC9B,IAAI,UAAU,MAAM,CAAC;EAC5B;CACF;CAEA,MAAM,SAAS,WAAgB,KAAK,MAAM,QAAQ,MAAM,CAAC;CAEzD,MAAM,YAAY,OAAO,aAAsB,QAAa,SAA+B;EAKzF,MAAM,MAAM,QAAQ,MAAM;EAC1B,MAAM,KAAK,eAAe,KAAK,WAAW,GAAG,CAAC;EAC9C,MAAM,KAAK,MAAM,GAAG;CACtB;CAEA,OAAO;EAAE;EAAQ;EAAO;EAAO;CAAU;AAC3C"}
package/dist/config.cjs CHANGED
@@ -145,21 +145,37 @@ function checkMessage(workflow, node, index, config) {
145
145
  if (problems.length > 0) throw new ConfigCompileError(`${where}: ${node.channel} '${node.template}': ${problems.join("; ")}`);
146
146
  }
147
147
  /**
148
+ * Whether an object schema refuses keys it does not declare: `z.strictObject`
149
+ * sets its catchall to `never`; `z.object` strips unknown keys and
150
+ * `z.looseObject` passes them through, and neither refuses.
151
+ */
152
+ function refusesUnknownKeys(schema) {
153
+ return schema._zod.def.catchall?._zod.def.type === "never";
154
+ }
155
+ /**
148
156
  * An emitted event against the contract, as far as compile time can see: the
149
- * event is declared, literal payload values parse against their field, and no
150
- * required field is missing. Values read from the profile are checked when the
151
- * event is emitted, by the same contract.
157
+ * event is declared, literal payload values parse against their field, no
158
+ * required field is missing, and — when the event's schema is strict — no
159
+ * literal field is undeclared (the Worker would refuse the whole event at
160
+ * emit time, failing the journey where the compile could have failed the
161
+ * build). Values read from the profile are checked when the event is emitted,
162
+ * by the same contract.
152
163
  */
153
164
  function checkEmit(workflow, node, config) {
154
165
  const contract = config.contract;
155
166
  if (contract === void 0) return;
156
167
  const where = `workflow '${workflow.id}' node ${node.id}`;
157
168
  if (!contract.isEvent(node.event)) throw new ConfigCompileError(`${where}: emits '${String(node.event)}', which is not in the contract`);
158
- const shape = contract.schemas.events[node.event]._zod.def.shape;
169
+ const schema = contract.schemas.events[node.event];
170
+ const shape = schema._zod.def.shape;
171
+ const strict = refusesUnknownKeys(schema);
159
172
  const problems = [];
160
173
  for (const [field, value] of Object.entries(node.payload)) {
161
174
  if (typeof value === "object" && "type" in value) continue;
162
- if (!Object.hasOwn(shape, field)) continue;
175
+ if (!Object.hasOwn(shape, field)) {
176
+ if (strict) problems.push(`'${field}' is not a field the contract declares for the event`);
177
+ continue;
178
+ }
163
179
  if (!zod_mini.safeParse(shape[field], value).success) problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);
164
180
  }
165
181
  for (const [field, fieldSchema] of Object.entries(shape)) if (!canBeOmitted(fieldSchema) && !Object.hasOwn(node.payload, field)) problems.push(`required field '${field}' is missing`);
@@ -1 +1 @@
1
- {"version":3,"file":"config.cjs","names":["indexTemplates","compileBundle","templateUses","CHANNEL_OF","z"],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport { type EmailConfig, assertEmailConfig } from './email';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, what the email templates share, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n /** What every email template shares: the default unsubscribe link, how opens and clicks are judged. */\n email?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, an id is registered\n * once per channel, and every email template's metadata is valid, as is the\n * config's `email` (which replaced `emails`: a config still carrying it fails).\n * Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n const email = indexTemplates('email', config.templates?.email);\n assertEmailConfig(config, email.values());\n index = {\n email,\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, and no\n * required field is missing. Values read from the profile are checked when the\n * event is emitted, by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) continue; // a loose payload may carry extra fields\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"config.cjs","names":["indexTemplates","compileBundle","templateUses","CHANNEL_OF","z"],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport { type EmailConfig, assertEmailConfig } from './email';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, what the email templates share, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n /** What every email template shares: the default unsubscribe link, how opens and clicks are judged. */\n email?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, an id is registered\n * once per channel, and every email template's metadata is valid, as is the\n * config's `email` (which replaced `emails`: a config still carrying it fails).\n * Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n const email = indexTemplates('email', config.templates?.email);\n assertEmailConfig(config, email.values());\n index = {\n email,\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * Whether an object schema refuses keys it does not declare: `z.strictObject`\n * sets its catchall to `never`; `z.object` strips unknown keys and\n * `z.looseObject` passes them through, and neither refuses.\n */\nfunction refusesUnknownKeys(schema: core.$ZodType): boolean {\n return (schema._zod.def as core.$ZodObjectDef).catchall?._zod.def.type === 'never';\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, no\n * required field is missing, and — when the event's schema is strict — no\n * literal field is undeclared (the Worker would refuse the whole event at\n * emit time, failing the journey where the compile could have failed the\n * build). Values read from the profile are checked when the event is emitted,\n * by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const strict = refusesUnknownKeys(schema);\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) {\n // A loose or stripping payload may carry extra fields; a strict one refuses them\n if (strict) problems.push(`'${field}' is not a field the contract declares for the event`);\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
package/dist/config.mjs CHANGED
@@ -142,21 +142,37 @@ function checkMessage(workflow, node, index, config) {
142
142
  if (problems.length > 0) throw new ConfigCompileError(`${where}: ${node.channel} '${node.template}': ${problems.join("; ")}`);
143
143
  }
144
144
  /**
145
+ * Whether an object schema refuses keys it does not declare: `z.strictObject`
146
+ * sets its catchall to `never`; `z.object` strips unknown keys and
147
+ * `z.looseObject` passes them through, and neither refuses.
148
+ */
149
+ function refusesUnknownKeys(schema) {
150
+ return schema._zod.def.catchall?._zod.def.type === "never";
151
+ }
152
+ /**
145
153
  * An emitted event against the contract, as far as compile time can see: the
146
- * event is declared, literal payload values parse against their field, and no
147
- * required field is missing. Values read from the profile are checked when the
148
- * event is emitted, by the same contract.
154
+ * event is declared, literal payload values parse against their field, no
155
+ * required field is missing, and — when the event's schema is strict — no
156
+ * literal field is undeclared (the Worker would refuse the whole event at
157
+ * emit time, failing the journey where the compile could have failed the
158
+ * build). Values read from the profile are checked when the event is emitted,
159
+ * by the same contract.
149
160
  */
150
161
  function checkEmit(workflow, node, config) {
151
162
  const contract = config.contract;
152
163
  if (contract === void 0) return;
153
164
  const where = `workflow '${workflow.id}' node ${node.id}`;
154
165
  if (!contract.isEvent(node.event)) throw new ConfigCompileError(`${where}: emits '${String(node.event)}', which is not in the contract`);
155
- const shape = contract.schemas.events[node.event]._zod.def.shape;
166
+ const schema = contract.schemas.events[node.event];
167
+ const shape = schema._zod.def.shape;
168
+ const strict = refusesUnknownKeys(schema);
156
169
  const problems = [];
157
170
  for (const [field, value] of Object.entries(node.payload)) {
158
171
  if (typeof value === "object" && "type" in value) continue;
159
- if (!Object.hasOwn(shape, field)) continue;
172
+ if (!Object.hasOwn(shape, field)) {
173
+ if (strict) problems.push(`'${field}' is not a field the contract declares for the event`);
174
+ continue;
175
+ }
160
176
  if (!z.safeParse(shape[field], value).success) problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);
161
177
  }
162
178
  for (const [field, fieldSchema] of Object.entries(shape)) if (!canBeOmitted(fieldSchema) && !Object.hasOwn(node.payload, field)) problems.push(`required field '${field}' is missing`);
@@ -1 +1 @@
1
- {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport { type EmailConfig, assertEmailConfig } from './email';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, what the email templates share, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n /** What every email template shares: the default unsubscribe link, how opens and clicks are judged. */\n email?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, an id is registered\n * once per channel, and every email template's metadata is valid, as is the\n * config's `email` (which replaced `emails`: a config still carrying it fails).\n * Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n const email = indexTemplates('email', config.templates?.email);\n assertEmailConfig(config, email.values());\n index = {\n email,\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, and no\n * required field is missing. Values read from the profile are checked when the\n * event is emitted, by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) continue; // a loose payload may carry extra fields\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"config.mjs","names":[],"sources":["../src/config.ts"],"sourcesContent":["import * as z from 'zod/mini';\nimport type * as core from 'zod/v4/core';\nimport type { Contract } from './contract';\nimport { type ActionRef, type SegmentRef, type WorkflowBuilder, compileBundle } from './dsl';\nimport { templateUses } from './dsl/flow';\nimport { type EmailConfig, assertEmailConfig } from './email';\nimport type { BundleIR, ChannelIR, NodeIR, WorkflowIR } from './ir';\nimport type { JourneyMode, JourneyRuntimeConfig } from './runtime';\nimport {\n type AnyTemplate,\n CHANNEL_OF,\n type TemplateChannel,\n type TemplateRegistry,\n indexTemplates,\n} from './templates';\n\n/**\n * `workflow.config.ts` — the project's journey configuration, typed, flat and\n * committed with the code: what it deploys (workflows, segments, actions),\n * the template registries behind them, the contract every inbound event is\n * checked against, what the email templates share, and how the engine runs per\n * environment (time scale, APNs, FCM). The compiler (`compileConfig`), the\n * Worker (`journeyWorker({ config })`) and the Workflow Studio all read this\n * one object, so everything is registered exactly once:\n *\n * export default defineConfig({\n * title: 'Workflow Studio · Acme',\n * contract,\n * workflows, // [onboarding, notificationTest]\n * templates, // { email, push }: the definitions the workflows send\n * email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },\n * runtime: { development: { timeScale: 600, logMessages: true } },\n * apns: { topic: 'io.acme.app', keyId: 'ABC123DEFG', teamId: 'TEAM123456' },\n * });\n *\n * Secrets are the one thing that stay out: a `.p8`, a service account, the\n * ingest bearer. The host reads them from its environment under fixed names\n * (see `JourneyEnv`), so the config says *which* key without carrying it. The\n * config is a source object (schemas, components, functions); only its\n * compiled bundle and preview-safe template data may cross to a browser.\n */\n\n/** Token-based APNs, the `.p8` auth key; the key itself is `APNS_PRIVATE_KEY_BASE64` in the environment. */\nexport interface ApnsConfig {\n /** The app's bundle id (`apns-topic`). */\n topic: string;\n /** The key's 10-character id. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /**\n * Which APNs host each mode's device tokens belong to: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. Unlisted modes default\n * to `production` for the production mode and `sandbox` for every other.\n */\n environment?: Partial<Record<JourneyMode, 'sandbox' | 'production'>>;\n}\n\n/**\n * Standard Web Push for browsers (VAPID); the key pair is `VAPID_PUBLIC_KEY` /\n * `VAPID_PRIVATE_KEY` in the environment, and the site subscribes with the same public key.\n */\nexport interface WebPushConfig {\n /** Who push services may contact about this sender: `mailto:` or an `https:` URL. */\n subject: string;\n /**\n * The contract event a notification tap the browser reports (`opened_url`) is also recorded\n * as, with `{ message_id }`: the app reports its own taps as an event, and a service worker has\n * no session to do so — without this, browser opens reach the reports but not the conditions\n * (a `performed(e.push_opened)` segment). One event per copy, however often it is tapped.\n */\n openedEvent?: string;\n}\n\n/** FCM HTTP v1 for Android; the service account is `GOOGLE_APPLICATION_CREDENTIALS_BASE64` in the environment. */\nexport interface FcmConfig {\n /** Android notification channel the pushes land in (`android.notification.channel_id`). */\n androidChannelId?: string;\n}\n\n/**\n * The engine's tables live in a Postgres schema of their own, so their short names (`event`,\n * `profile`, `message`, …) never meet the app's. `workflow-ui generate` writes them as one drizzle\n * file on this schema; the host migrates it with its own tooling.\n */\nexport interface DatabaseConfig {\n /** The Postgres schema; `workflow` by default. */\n schema?: string;\n}\n\nexport interface JourneyConfigDefinition {\n /** Studio title (the browser tab names the project). */\n title?: string;\n /** The shared `defineContract(...)` the Worker validates ingest against. */\n contract?: Contract;\n workflows: readonly WorkflowBuilder[];\n segments?: readonly SegmentRef[];\n actions?: readonly ActionRef[];\n /** The template definitions, per channel; each is identified by its own `id`. */\n templates?: TemplateRegistry;\n /** What every email template shares: the default unsubscribe link, how opens and clicks are judged. */\n email?: EmailConfig;\n /** How the engine behaves per mode (`development`, `production`, …): time scale, message logging. */\n runtime?: JourneyRuntimeConfig;\n apns?: ApnsConfig;\n fcm?: FcmConfig;\n webPush?: WebPushConfig;\n /** Where a Postgres store keeps the engine's tables (the D1 store ignores it). */\n database?: DatabaseConfig;\n}\n\nexport interface JourneyConfig extends JourneyConfigDefinition {\n readonly kind: 'journey-config';\n}\n\nexport function defineConfig(definition: JourneyConfigDefinition): JourneyConfig {\n return { kind: 'journey-config', ...definition };\n}\n\nexport function isConfig(value: unknown): value is JourneyConfig {\n return (\n typeof value === 'object' &&\n value !== null &&\n (value as { kind?: unknown }).kind === 'journey-config'\n );\n}\n\n/** The APNs environment for `mode`: the configured one, else production only for production. */\nexport function apnsEnvironment(config: ApnsConfig, mode: JourneyMode): 'sandbox' | 'production' {\n return config.environment?.[mode] ?? (mode === 'production' ? 'production' : 'sandbox');\n}\n\n/** A message node the config's registries cannot serve, or whose literal props its template refuses. */\nexport class ConfigCompileError extends Error {\n override readonly name = 'ConfigCompileError';\n}\n\n/**\n * Compile the config: the bundle `compileBundle` would produce, with the\n * template manifest generated from the registry (every registered template,\n * by id, not a hand-kept list). The registry is checked first, used or not:\n * every entry is a template of its channel with an id, an id is registered\n * once per channel, and every email template's metadata is valid, as is the\n * config's `email` (which replaced `emails`: a config still carrying it fails).\n * Then every message node is checked against it — an\n * unregistered or cross-channel template fails with the workflow, node and\n * channel named, and so does a literal prop the template's schema refuses (a\n * missing required prop, an undeclared one, a wrong literal) — and every\n * definition a workflow sends must be the very object registered under its id,\n * not merely one with the same id. User-property references cannot be parsed\n * here (their values exist only at send time) — they are checked by name only,\n * and parsed before sending.\n */\nexport function compileConfig(config: JourneyConfig): BundleIR {\n let index: RegistryIndex;\n try {\n const email = indexTemplates('email', config.templates?.email);\n assertEmailConfig(config, email.values());\n index = {\n email,\n push: indexTemplates('push', config.templates?.push),\n discord: indexTemplates('discord', config.templates?.discord),\n slack: indexTemplates('slack', config.templates?.slack),\n };\n } catch (error) {\n throw new ConfigCompileError((error as Error).message);\n }\n const bundle = compileBundle({\n workflows: config.workflows,\n ...(config.segments === undefined ? {} : { segments: config.segments }),\n ...(config.actions === undefined ? {} : { actions: config.actions }),\n templates: TEMPLATE_CHANNELS.flatMap((channel) => [...index[channel].values()]),\n });\n bundle.workflows.forEach((workflow, i) => {\n checkMessages(workflow, workflow.flow, index, config);\n checkUses(workflow, config.workflows[i], index, config);\n });\n return bundle;\n}\n\nconst TEMPLATE_CHANNELS = ['email', 'push', 'discord', 'slack'] as const;\n\n/** The registry by id, per channel. */\ntype RegistryIndex = Record<TemplateChannel, ReadonlyMap<string, AnyTemplate>>;\n\n/** The channel's templates by id; undefined for a channel templates cannot be declared for. */\nfunction indexOf(\n index: RegistryIndex,\n channel: ChannelIR\n): ReadonlyMap<string, AnyTemplate> | undefined {\n return (TEMPLATE_CHANNELS as readonly ChannelIR[]).includes(channel)\n ? index[channel as TemplateChannel]\n : undefined;\n}\n\n/**\n * The definitions a workflow sends against the registered ones: same id is not enough — a\n * workflow typed by one definition and a registry holding another under that id would render\n * with a schema the call was never checked against.\n */\nfunction checkUses(\n workflow: WorkflowIR,\n builder: WorkflowBuilder,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const { template, loc } of templateUses(builder)) {\n const channel = CHANNEL_OF[template.kind];\n const registered = index[channel].get(template.id);\n if (registered === template) continue;\n const at = loc === undefined ? '' : ` (${loc.file}:${loc.line})`;\n const where = `workflow '${workflow.id}'${at}: ${channel} template '${template.id}'`;\n if (registered === undefined) throw new ConfigCompileError(`${where} is not registered`);\n const name = Object.entries(config.templates?.[channel] ?? {}).find(\n ([, entry]) => entry === registered\n )?.[0];\n throw new ConfigCompileError(\n `${where} is not the definition registered under that id (templates.${channel}.${name}) — two definitions share the id`\n );\n }\n}\n\n/** A field that may be left out: optional, or with a default the schema fills in. */\nfunction canBeOmitted(schema: core.$ZodType): boolean {\n const type = schema._zod.def.type;\n return type === 'optional' || type === 'default' || type === 'prefault';\n}\n\nfunction checkMessages(\n workflow: WorkflowIR,\n nodes: readonly NodeIR[],\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case 'message':\n checkMessage(workflow, node, index, config);\n break;\n case 'send_event':\n checkEmit(workflow, node, config);\n break;\n case 'branch':\n for (const c of node.cases) checkMessages(workflow, c.flow, index, config);\n if (node.otherwise) checkMessages(workflow, node.otherwise, index, config);\n break;\n case 'cohort':\n for (const arm of node.arms) checkMessages(workflow, arm.flow, index, config);\n break;\n case 'wait_until':\n if (Array.isArray(node.onTimeout)) checkMessages(workflow, node.onTimeout, index, config);\n break;\n default:\n break;\n }\n }\n}\n\nfunction checkMessage(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'message' }>,\n index: RegistryIndex,\n config: JourneyConfig\n): void {\n const where = `workflow '${workflow.id}' node ${node.id}`;\n const registry = indexOf(index, node.channel);\n // Channels without template definitions (sms, …) are rendered by app-supplied senders\n if (registry === undefined) return;\n const entry = registry.get(node.template);\n if (entry === undefined) {\n const other = TEMPLATE_CHANNELS.find(\n (channel) => channel !== node.channel && index[channel].has(node.template)\n );\n const hint = other === undefined ? '' : ` (it is registered as a ${other} template)`;\n throw new ConfigCompileError(\n `${where}: ${node.channel} template '${node.template}' is not registered${hint}`\n );\n }\n const shape = (entry.props._zod.def as core.$ZodObjectDef).shape;\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.props)) {\n if (!Object.hasOwn(shape, field)) {\n problems.push(`'${field}' is not a prop of the template`);\n continue;\n }\n if (typeof value === 'object' && 'type' in value) {\n // A user ref's value exists only at send time (parsed then); here its name can be checked\n const profile = config.contract?.schemas.profile._zod.def;\n if (\n profile !== undefined &&\n value.path !== 'userId' &&\n !Object.hasOwn(profile.shape, value.path)\n ) {\n problems.push(\n `'${field}' reads u.${value.path}, which the contract's profile does not declare`\n );\n }\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the schema`);\n }\n for (const [field, schema] of Object.entries(shape)) {\n const optional = canBeOmitted(schema);\n if (!optional && !Object.hasOwn(node.props, field))\n problems.push(`required prop '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(\n `${where}: ${node.channel} '${node.template}': ${problems.join('; ')}`\n );\n }\n}\n\n/**\n * Whether an object schema refuses keys it does not declare: `z.strictObject`\n * sets its catchall to `never`; `z.object` strips unknown keys and\n * `z.looseObject` passes them through, and neither refuses.\n */\nfunction refusesUnknownKeys(schema: core.$ZodType): boolean {\n return (schema._zod.def as core.$ZodObjectDef).catchall?._zod.def.type === 'never';\n}\n\n/**\n * An emitted event against the contract, as far as compile time can see: the\n * event is declared, literal payload values parse against their field, no\n * required field is missing, and — when the event's schema is strict — no\n * literal field is undeclared (the Worker would refuse the whole event at\n * emit time, failing the journey where the compile could have failed the\n * build). Values read from the profile are checked when the event is emitted,\n * by the same contract.\n */\nfunction checkEmit(\n workflow: WorkflowIR,\n node: Extract<NodeIR, { type: 'send_event' }>,\n config: JourneyConfig\n): void {\n const contract = config.contract;\n if (contract === undefined) return;\n const where = `workflow '${workflow.id}' node ${node.id}`;\n if (!contract.isEvent(node.event)) {\n throw new ConfigCompileError(\n `${where}: emits '${String(node.event)}', which is not in the contract`\n );\n }\n const schema = contract.schemas.events[node.event];\n const shape = (schema._zod.def as core.$ZodObjectDef).shape;\n const strict = refusesUnknownKeys(schema);\n const problems: string[] = [];\n for (const [field, value] of Object.entries(node.payload)) {\n if (typeof value === 'object' && 'type' in value) continue; // read at emit time\n if (!Object.hasOwn(shape, field)) {\n // A loose or stripping payload may carry extra fields; a strict one refuses them\n if (strict) problems.push(`'${field}' is not a field the contract declares for the event`);\n continue;\n }\n const result = z.safeParse(shape[field], value);\n if (!result.success)\n problems.push(`'${field}' = ${JSON.stringify(value)} is refused by the contract`);\n }\n for (const [field, fieldSchema] of Object.entries(shape)) {\n const optional = canBeOmitted(fieldSchema);\n if (!optional && !Object.hasOwn(node.payload, field))\n problems.push(`required field '${field}' is missing`);\n }\n if (problems.length > 0) {\n throw new ConfigCompileError(`${where}: event '${node.event}': ${problems.join('; ')}`);\n }\n}\n"],"mappings":"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"}
@@ -1,5 +1,4 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_hash = require("../hash.cjs");
3
2
  //#region src/dsl/action.ts
4
3
  /**
5
4
  * Define a custom action. The generic `A` types the args `.run()` accepts —
@@ -11,15 +10,13 @@ const require_hash = require("../hash.cjs");
11
10
  *
12
11
  * workflow(...).run(syncCrm, { plan: u.subscription_plan })
13
12
  *
14
- * codeHash comes from `handler.toString()`, so it changes when the code
15
- * changes and stays put when it does not. Different build pipelines can print
16
- * the same function differently (minification), which is why the default
17
- * runtime policy on mismatch is warn, not fail.
13
+ * The returned object is the action's identity at compile time: compileBundle
14
+ * checks that the one a workflow runs is the very one listed in `actions`, so
15
+ * two definitions cannot share a name by accident.
18
16
  */
19
17
  function action(name, handler) {
20
18
  return {
21
19
  name,
22
- codeHash: require_hash.sha256Hex(handler.toString()).slice(0, 32),
23
20
  handler
24
21
  };
25
22
  }