@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
@@ -10,7 +10,6 @@ import { JourneyReporting } from "../store/index.cjs";
10
10
  * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm
11
11
  * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click
12
12
  * unsubscribe (RFC 8058: a mail client's button posts here)
13
- * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose
14
13
  * POST /t/d/<message> a push the device received (iOS's notification service
15
14
  * extension, a browser's service worker)
16
15
  * POST /t/p/<message> a push opened, reported by a browser's service worker
@@ -22,17 +21,8 @@ import { JourneyReporting } from "../store/index.cjs";
22
21
  * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,
23
22
  * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or
24
23
  * removing the template, changes nothing for emails already sent.
25
- *
26
- * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the
27
- * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL
28
- * those emails were all sent with), as it was then.
29
24
  */
30
25
  export interface TrackingOptions {
31
- /**
32
- * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`
33
- * by default): following such a link to it asks for confirmation instead of redirecting.
34
- */
35
- legacyUnsubscribeUrl?: string | undefined;
36
26
  /** The name the confirmation pages show (the config's `title`). */
37
27
  product?: string | undefined;
38
28
  /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */
@@ -43,6 +33,16 @@ export interface TrackingOptions {
43
33
  userId: string;
44
34
  at: number;
45
35
  }) => Promise<void>) | undefined;
36
+ /**
37
+ * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email
38
+ * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is
39
+ * logged, never a reason to fail the unsubscribe.
40
+ */
41
+ onUnsubscribed?: ((unsubscribe: {
42
+ messageId: string;
43
+ userId: string;
44
+ at: number;
45
+ }) => Promise<void>) | undefined;
46
46
  }
47
47
  /** A `/t/...` request's response, or undefined when the path is not a tracking route. */
48
48
  export declare function handleTracking(request: Request, reporting: JourneyReporting, options?: TrackingOptions): Promise<Response | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCiB;;;;;EAKf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBA8EW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
1
+ {"version":3,"file":"tracking.d.cts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;iBA0BiB;;EAEf;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;;;;EAOlE,mBACM;IAAe;IAAmB;IAAgB;QAAiB;;;wBAgCrD,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAoFW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
@@ -10,7 +10,6 @@ import { JourneyReporting } from "../store/index.mjs";
10
10
  * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm
11
11
  * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click
12
12
  * unsubscribe (RFC 8058: a mail client's button posts here)
13
- * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose
14
13
  * POST /t/d/<message> a push the device received (iOS's notification service
15
14
  * extension, a browser's service worker)
16
15
  * POST /t/p/<message> a push opened, reported by a browser's service worker
@@ -22,17 +21,8 @@ import { JourneyReporting } from "../store/index.mjs";
22
21
  * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,
23
22
  * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or
24
23
  * removing the template, changes nothing for emails already sent.
25
- *
26
- * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the
27
- * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL
28
- * those emails were all sent with), as it was then.
29
24
  */
30
25
  export interface TrackingOptions {
31
- /**
32
- * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`
33
- * by default): following such a link to it asks for confirmation instead of redirecting.
34
- */
35
- legacyUnsubscribeUrl?: string | undefined;
36
26
  /** The name the confirmation pages show (the config's `title`). */
37
27
  product?: string | undefined;
38
28
  /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */
@@ -43,6 +33,16 @@ export interface TrackingOptions {
43
33
  userId: string;
44
34
  at: number;
45
35
  }) => Promise<void>) | undefined;
36
+ /**
37
+ * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email
38
+ * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is
39
+ * logged, never a reason to fail the unsubscribe.
40
+ */
41
+ onUnsubscribed?: ((unsubscribe: {
42
+ messageId: string;
43
+ userId: string;
44
+ at: number;
45
+ }) => Promise<void>) | undefined;
46
46
  }
47
47
  /** A `/t/...` request's response, or undefined when the path is not a tracking route. */
48
48
  export declare function handleTracking(request: Request, reporting: JourneyReporting, options?: TrackingOptions): Promise<Response | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCiB;;;;;EAKf;;EAEA;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;wBAgC9C,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBA8EW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
1
+ {"version":3,"file":"tracking.d.mts","names":[],"sources":["../../src/reporting/tracking.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;iBA0BiB;;EAEf;;EAEA;;EAEA,eACM;IAAQ;IAAmB;IAAgB;QAAiB;;;;;;EAOlE,mBACM;IAAe;IAAmB;IAAgB;QAAiB;;;wBAgCrD,eACpB,SAAS,SACT,WAAW,kBACX,UAAS,kBACR,QAAQ;;;;;;wBAoFW,eACpB,WAAW,kBACX,gBACA,mBACA,cACC"}
@@ -1,4 +1,3 @@
1
- import { isUnsubscribeLink } from "../email.mjs";
2
1
  import { BURST_WINDOW_MS, classifyClick, classifyOpen } from "./classify.mjs";
3
2
  //#region src/reporting/tracking.ts
4
3
  const GIF = Uint8Array.from(atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"), (char) => char.charCodeAt(0));
@@ -30,10 +29,10 @@ async function handleTracking(request, reporting, options = {}) {
30
29
  "cache-control": "no-store, max-age=0"
31
30
  } });
32
31
  }
33
- if ((kind === "l" || kind === "u" || kind === "c") && position !== void 0 && read) {
32
+ if ((kind === "l" || kind === "u") && position !== void 0 && read) {
34
33
  const target = await recordClick(reporting, id, Number(position), who, options);
35
34
  if (target === void 0) return page(options, "This link has expired.", 404);
36
- if (kind === "u" || kind === "c" && isUnsubscribeLink(target, options.legacyUnsubscribeUrl)) return confirmUnsubscribe(options, id);
35
+ if (kind === "u") return confirmUnsubscribe(options, id);
37
36
  return Response.redirect(target, 302);
38
37
  }
39
38
  if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(options, id);
@@ -65,7 +64,18 @@ async function handleTracking(request, reporting, options = {}) {
65
64
  return new Response(null, { status: known ? 204 : 404 });
66
65
  }
67
66
  if (kind === "u" && who.method === "POST") {
68
- if (!await reporting.recordUnsubscribe(id, Date.now())) return page(options, "You won't get these emails anymore.");
67
+ const at = Date.now();
68
+ const unsubscribed = await reporting.recordUnsubscribe(id, at);
69
+ if (unsubscribed === void 0) return page(options, "You won't get these emails anymore.");
70
+ if (options.onUnsubscribed !== void 0) try {
71
+ await options.onUnsubscribed({
72
+ messageId: id,
73
+ userId: unsubscribed.userId,
74
+ at
75
+ });
76
+ } catch (error) {
77
+ console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);
78
+ }
69
79
  return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
70
80
  }
71
81
  }
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.mjs","names":[],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import { isUnsubscribeLink } from '../email';\nimport type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * GET /t/c/<message>/<position> a link of an email sent before links carried their purpose\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n *\n * Emails sent before that carry `/t/c/` links, which say nothing of their purpose: one is the\n * unsubscribe link when its recorded URL is `legacyUnsubscribeUrl` (the global unsubscribe URL\n * those emails were all sent with), as it was then.\n */\n\nexport interface TrackingOptions {\n /**\n * The unsubscribe URL emails sent with `/t/c/` links carried (the config's `email.unsubscribeUrl`\n * by default): following such a link to it asks for confirmation instead of redirecting.\n */\n legacyUnsubscribeUrl?: string | undefined;\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u' || kind === 'c') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n const unsubscribe =\n kind === 'u' || (kind === 'c' && isUnsubscribeLink(target, options.legacyUnsubscribeUrl));\n if (unsubscribe) return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const known = await reporting.recordUnsubscribe(id, Date.now());\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (!known) return page(options, \"You won't get these emails anymore.\");\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tracking.mjs","names":[],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n */\n\nexport interface TrackingOptions {\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n /**\n * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email\n * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is\n * logged, never a reason to fail the unsubscribe.\n */\n onUnsubscribed?:\n | ((unsubscribe: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (kind === 'u') return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const at = Date.now();\n const unsubscribed = await reporting.recordUnsubscribe(id, at);\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (unsubscribed === undefined) return page(options, \"You won't get these emails anymore.\");\n if (options.onUnsubscribed !== undefined) {\n try {\n await options.onUnsubscribed({ messageId: id, userId: unsubscribed.userId, at });\n } catch (error) {\n console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);\n }\n }\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
package/dist/store/d1.cjs CHANGED
@@ -1,21 +1,42 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_ir = require("../ir.cjs");
3
+ const require_store_erasure = require("./erasure.cjs");
3
4
  //#region src/store/d1.ts
4
5
  /**
5
- * The original data plane: D1 for the tables (see ../cloudflare/schema.sql)
6
- * and KV for the content-addressed workflow bodies (`wf:${contentHash}`).
7
- * The SQL below is exactly what the router and fact source used to inline —
8
- * the in-memory FakeD1 in the tests dispatches on these strings verbatim.
6
+ * The condition every write about a user carries, as the last bound parameter: no erasure
7
+ * tombstone for the user. Inside the statement, so the check and the write share one snapshot.
8
+ */
9
+ const NOT_ERASED = "WHERE NOT EXISTS (SELECT 1 FROM erasures WHERE user_id = ?)";
10
+ /**
11
+ * The D1 data plane: one database for everything (see ../cloudflare/schema.sql),
12
+ * the content-addressed workflow bodies included — beside the routes that name
13
+ * them, so a deploy is read-your-writes (KV's eventual consistency would let a
14
+ * route name a body that is not readable yet). The in-memory FakeD1 in the
15
+ * tests dispatches on these statements.
16
+ *
17
+ * Every write about a user is one statement conditional on their erasure tombstone not
18
+ * existing (`INSERT ... SELECT ... WHERE NOT EXISTS`): a write that changed nothing is then
19
+ * read back once to tell a refusal (`UserErasedError`) from a plain no-op. D1 has one writer,
20
+ * so a statement is atomic against every other: the runner's admission is one conditional
21
+ * UPDATE, and a tombstone either precedes it (refused) or follows it (the erasure finds the
22
+ * instance admitted) — no lock needed.
9
23
  */
10
24
  var D1JourneyStore = class {
11
25
  db;
12
- kv;
13
- constructor(db, kv) {
26
+ constructor(db) {
14
27
  this.db = db;
15
- this.kv = kv;
16
28
  }
17
29
  async insertEvent(input) {
18
- return ((await this.db.prepare("INSERT OR IGNORE INTO events (id, user_id, name, ts, payload) VALUES (?, ?, ?, ?, ?)").bind(input.id, input.userId, input.event, input.ts, JSON.stringify(input.payload)).run()).meta?.changes ?? 1) > 0;
30
+ if (((await this.db.prepare(`INSERT OR IGNORE INTO events (id, user_id, name, ts, payload) SELECT ?, ?, ?, ?, ? ${NOT_ERASED}`).bind(input.id, input.userId, input.event, input.ts, JSON.stringify(input.payload), input.userId).run()).meta?.changes ?? 1) > 0) return true;
31
+ await this.refuseIfErased(input.userId);
32
+ return false;
33
+ }
34
+ /**
35
+ * A conditional write changed nothing: on that path only, read why. Throws when the tombstone
36
+ * refused it; returns when the statement had another reason (a duplicate, an entry taken).
37
+ */
38
+ async refuseIfErased(userId) {
39
+ if (await this.erasureState(userId) !== "none") throw new require_store_erasure.UserErasedError(userId);
19
40
  }
20
41
  /** The event rows of one user and name, within the window's bounds (both inclusive). */
21
42
  eventWindow(userId, event, window) {
@@ -49,11 +70,26 @@ var D1JourneyStore = class {
49
70
  }
50
71
  async mergeProfile(userId, props) {
51
72
  const patch = JSON.stringify(props);
52
- const row = await this.db.prepare("INSERT INTO profiles (user_id, props) VALUES (?, json_patch('{}', ?)) ON CONFLICT(user_id) DO UPDATE SET props = json_patch(profiles.props, ?) RETURNING props").bind(userId, patch, patch).first();
53
- return row === null ? stripNulls(props) : JSON.parse(row.props);
54
- }
55
- async listUserIds() {
56
- const { results } = await this.db.prepare("SELECT user_id FROM profiles UNION SELECT DISTINCT user_id FROM events").bind().all();
73
+ const replaced = Object.entries(props).filter(([, value]) => typeof value === "object" && value !== null).map(([key]) => key);
74
+ const stored = replaced.length === 0 ? "profiles.props" : `json_remove(profiles.props, ${replaced.map(() => "?").join(", ")})`;
75
+ const row = await this.db.prepare(`INSERT INTO profiles (user_id, props) SELECT ?, json_patch('{}', ?) ${NOT_ERASED} ON CONFLICT(user_id) DO UPDATE SET props = json_patch(${stored}, ?) RETURNING props`).bind(userId, patch, userId, ...replaced.map(jsonPath), patch).first();
76
+ if (row !== null) return JSON.parse(row.props);
77
+ await this.refuseIfErased(userId);
78
+ return stripNulls(props);
79
+ }
80
+ async listUserIds(page) {
81
+ const params = [];
82
+ let where = "";
83
+ if (page?.after !== void 0) {
84
+ where = " WHERE user_id > ?";
85
+ params.push(page.after);
86
+ }
87
+ let limit = "";
88
+ if (page?.limit !== void 0) {
89
+ limit = " LIMIT ?";
90
+ params.push(page.limit);
91
+ }
92
+ const { results } = await this.db.prepare(`SELECT user_id FROM (SELECT user_id FROM profiles UNION SELECT user_id FROM events)${where} ORDER BY user_id${limit}`).bind(...params).all();
57
93
  return results.map((row) => row.user_id);
58
94
  }
59
95
  async listDevices(userId) {
@@ -61,30 +97,40 @@ var D1JourneyStore = class {
61
97
  return results;
62
98
  }
63
99
  async upsertDevice(device) {
64
- await this.db.prepare("INSERT INTO devices (token, user_id, platform, ts) VALUES (?, ?, ?, ?) ON CONFLICT (token) DO UPDATE SET user_id = excluded.user_id, platform = excluded.platform, ts = excluded.ts").bind(device.token, device.userId, device.platform, device.ts).run();
100
+ if (((await this.db.prepare(`INSERT INTO devices (token, user_id, platform, ts) SELECT ?, ?, ?, ? ${NOT_ERASED} ON CONFLICT (token) DO UPDATE SET user_id = excluded.user_id, platform = excluded.platform, ts = excluded.ts`).bind(device.token, device.userId, device.platform, device.ts, device.userId).run()).meta?.changes ?? 1) === 0) throw new require_store_erasure.UserErasedError(device.userId);
65
101
  }
66
102
  async requestErasure(userId, at) {
67
103
  await this.db.prepare("INSERT OR IGNORE INTO erasures (user_id, ts) VALUES (?, ?)").bind(userId, at).run();
68
104
  }
69
105
  async pendingErasures() {
70
- const { results } = await this.db.prepare("SELECT user_id FROM erasures ORDER BY ts").bind().all();
106
+ const { results } = await this.db.prepare("SELECT user_id FROM erasures WHERE erased_at IS NULL ORDER BY ts").bind().all();
71
107
  return results.map((row) => row.user_id);
72
108
  }
109
+ async erasureState(userId) {
110
+ const row = await this.db.prepare("SELECT erased_at FROM erasures WHERE user_id = ?").bind(userId).first();
111
+ if (row === null) return "none";
112
+ return row.erased_at === null ? "erasing" : "erased";
113
+ }
73
114
  async runningInstances(userId) {
74
- const { results } = await this.db.prepare("SELECT instance_id FROM entries WHERE user_id = ? AND status = 'running'").bind(userId).all();
75
- return results.map((row) => row.instance_id);
115
+ const { results } = await this.db.prepare("SELECT instance_id, admitted_at IS NOT NULL AS admitted FROM entries WHERE user_id = ? AND status = 'running'").bind(userId).all();
116
+ return results.map((row) => ({
117
+ instanceId: row.instance_id,
118
+ admitted: Boolean(row.admitted)
119
+ }));
76
120
  }
77
- async forgetUser(userId) {
78
- await this.db.batch([
121
+ async admitInstance(instanceId, userId) {
122
+ return await this.db.prepare("UPDATE entries SET admitted_at = COALESCE(admitted_at, ?) WHERE instance_id = ? AND user_id = ? AND (status = 'running' OR admitted_at IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM erasures WHERE user_id = ?) RETURNING instance_id").bind(Date.now(), instanceId, userId, userId).first() !== null;
123
+ }
124
+ async forgetUser(userId, at) {
125
+ await this.db.batch([...[
79
126
  "entries",
80
127
  "subscriptions",
81
128
  "segment_starts",
82
129
  "segment_members",
83
130
  "devices",
84
131
  "events",
85
- "profiles",
86
- "erasures"
87
- ].map((table) => this.db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).bind(userId)));
132
+ "profiles"
133
+ ].map((table) => this.db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).bind(userId)), this.db.prepare("UPDATE erasures SET erased_at = COALESCE(erased_at, ?) WHERE user_id = ?").bind(at, userId)]);
88
134
  }
89
135
  async removeDevice(token) {
90
136
  return (await this.db.prepare("DELETE FROM devices WHERE token = ? RETURNING user_id").bind(token).first())?.user_id ?? void 0;
@@ -119,11 +165,11 @@ var D1JourneyStore = class {
119
165
  return results;
120
166
  }
121
167
  async putWorkflowIR(workflow) {
122
- await this.kv.put(`wf:${workflow.contentHash}`, JSON.stringify(workflow));
168
+ await this.db.prepare("INSERT OR REPLACE INTO workflow_ir (content_hash, ir) VALUES (?, ?)").bind(workflow.contentHash, JSON.stringify(workflow)).run();
123
169
  }
124
170
  async getWorkflowIR(contentHash) {
125
- const raw = await this.kv.get(`wf:${contentHash}`);
126
- return raw === null ? void 0 : require_ir.WorkflowIR.parse(JSON.parse(raw));
171
+ const row = await this.db.prepare("SELECT ir FROM workflow_ir WHERE content_hash = ?").bind(contentHash).first();
172
+ return row === null ? void 0 : require_ir.WorkflowIR.parse(JSON.parse(row.ir));
127
173
  }
128
174
  async replaceRoutes(bundle) {
129
175
  await this.db.batch([
@@ -142,7 +188,8 @@ var D1JourneyStore = class {
142
188
  return await this.db.prepare("SELECT 1 AS x FROM segment_members WHERE segment = ? AND user_id = ?").bind(segment, userId).first() !== null;
143
189
  }
144
190
  async addSegmentMember(segment, userId, ts, workflows) {
145
- await this.db.batch([this.db.prepare("INSERT OR IGNORE INTO segment_members (segment, user_id, ts) VALUES (?, ?, ?)").bind(segment, userId, ts), ...workflows.map((workflow) => this.db.prepare("INSERT OR IGNORE INTO segment_starts (segment, workflow, user_id, ts) VALUES (?, ?, ?, ?)").bind(segment, workflow, userId, ts))]);
191
+ const [member] = await this.db.batch([this.db.prepare(`INSERT OR IGNORE INTO segment_members (segment, user_id, ts) SELECT ?, ?, ? ${NOT_ERASED}`).bind(segment, userId, ts, userId), ...workflows.map((workflow) => this.db.prepare(`INSERT OR IGNORE INTO segment_starts (segment, workflow, user_id, ts) SELECT ?, ?, ?, ? ${NOT_ERASED}`).bind(segment, workflow, userId, ts, userId))]);
192
+ if ((member.meta?.changes ?? 1) === 0) await this.refuseIfErased(userId);
146
193
  }
147
194
  async pendingSegmentStarts(segment, userId) {
148
195
  const { results } = await this.db.prepare("SELECT workflow FROM segment_starts WHERE segment = ? AND user_id = ?").bind(segment, userId).all();
@@ -156,8 +203,10 @@ var D1JourneyStore = class {
156
203
  }
157
204
  async enterJourney(entry) {
158
205
  if (entry.execution !== void 0) throw new Error("D1JourneyStore keeps no execution starts: this runtime needs Postgres");
159
- if (((await this.db.prepare("INSERT OR IGNORE INTO entries (workflow, user_id, instance_id, hash, status, ts) VALUES (?, ?, ?, ?, ?, ?)").bind(entry.workflow, entry.userId, entry.instanceId, entry.hash, "running", entry.ts).run()).meta?.changes ?? 1) !== 0) return "entered";
160
- return ((await this.db.prepare("UPDATE entries SET instance_id = ?, hash = ?, status = 'running', ts = ? WHERE workflow = ? AND user_id = ? AND status = 'failed'").bind(entry.instanceId, entry.hash, entry.ts, entry.workflow, entry.userId).run()).meta?.changes ?? 0) > 0 ? "reclaimed" : null;
206
+ if (((await this.db.prepare(`INSERT OR IGNORE INTO entries (workflow, user_id, instance_id, hash, status, ts) SELECT ?, ?, ?, ?, ?, ? ${NOT_ERASED}`).bind(entry.workflow, entry.userId, entry.instanceId, entry.hash, "running", entry.ts, entry.userId).run()).meta?.changes ?? 1) !== 0) return "entered";
207
+ if (((await this.db.prepare("UPDATE entries SET instance_id = ?, hash = ?, status = 'running', ts = ?, admitted_at = NULL WHERE workflow = ? AND user_id = ? AND status = 'failed' AND NOT EXISTS (SELECT 1 FROM erasures WHERE user_id = ?)").bind(entry.instanceId, entry.hash, entry.ts, entry.workflow, entry.userId, entry.userId).run()).meta?.changes ?? 0) > 0) return "reclaimed";
208
+ await this.refuseIfErased(entry.userId);
209
+ return null;
161
210
  }
162
211
  async removeEntry(instanceId) {
163
212
  await this.db.prepare("DELETE FROM entries WHERE instance_id = ?").bind(instanceId).run();
@@ -170,7 +219,9 @@ var D1JourneyStore = class {
170
219
  return results.map((row) => row.handle);
171
220
  }
172
221
  async subscribe(handle, userId, events, ts) {
173
- for (const event of events) await this.db.prepare("INSERT OR IGNORE INTO subscriptions (user_id, event, wake_handle, ts) VALUES (?, ?, ?, ?)").bind(userId, event, handle, ts).run();
222
+ let skipped = false;
223
+ for (const event of events) if (((await this.db.prepare(`INSERT OR IGNORE INTO subscriptions (user_id, event, wake_handle, ts) SELECT ?, ?, ?, ? ${NOT_ERASED}`).bind(userId, event, handle, ts, userId).run()).meta?.changes ?? 1) === 0) skipped = true;
224
+ if (skipped) await this.refuseIfErased(userId);
174
225
  }
175
226
  async unsubscribe(handle) {
176
227
  await this.db.prepare("DELETE FROM subscriptions WHERE wake_handle = ?").bind(handle).run();
@@ -180,6 +231,14 @@ var D1JourneyStore = class {
180
231
  function stripNulls(props) {
181
232
  return Object.fromEntries(Object.entries(props).filter(([, value]) => value !== null));
182
233
  }
234
+ /**
235
+ * The SQLite JSON path of a top-level key. Quoted, so dots and brackets in the key are literal;
236
+ * a double quote has no escape in SQLite's path syntax, so such a key cannot be addressed.
237
+ */
238
+ function jsonPath(key) {
239
+ if (key.includes("\"")) throw new Error(`profile property "${key}" cannot hold an object: its name contains a quote`);
240
+ return `$."${key}"`;
241
+ }
183
242
  //#endregion
184
243
  exports.D1JourneyStore = D1JourneyStore;
185
244
 
@@ -1 +1 @@
1
- {"version":3,"file":"d1.cjs","names":["ConditionIR","WorkflowIR"],"sources":["../../src/store/d1.ts"],"sourcesContent":["import type { D1DatabaseLike, KVNamespaceLike } from '../cloudflare/bindings';\nimport type { PushDevice } from '../engine/ports';\nimport { type BundleIR, ConditionIR, WorkflowIR } from '../ir';\nimport type {\n DeviceInput,\n EntryInput,\n EntryOutcome,\n EventInput,\n EventWindow,\n JourneyStore,\n ProfileProps,\n SegmentTriggerRoute,\n TriggerRoute,\n} from './index';\n\n/**\n * The original data plane: D1 for the tables (see ../cloudflare/schema.sql)\n * and KV for the content-addressed workflow bodies (`wf:${contentHash}`).\n * The SQL below is exactly what the router and fact source used to inline —\n * the in-memory FakeD1 in the tests dispatches on these strings verbatim.\n */\nexport class D1JourneyStore implements JourneyStore {\n constructor(\n private readonly db: D1DatabaseLike,\n private readonly kv: KVNamespaceLike\n ) {}\n\n async insertEvent(input: EventInput): Promise<boolean> {\n const written = await this.db\n .prepare(\n 'INSERT OR IGNORE INTO events (id, user_id, name, ts, payload) VALUES (?, ?, ?, ?, ?)'\n )\n .bind(input.id, input.userId, input.event, input.ts, JSON.stringify(input.payload))\n .run();\n return (written.meta?.changes ?? 1) > 0;\n }\n\n /** The event rows of one user and name, within the window's bounds (both inclusive). */\n private eventWindow(userId: string, event: string, window?: EventWindow) {\n const params: unknown[] = [userId, event];\n let where = 'user_id = ? AND name = ?';\n if (window?.sinceMs !== undefined) {\n where += ' AND ts >= ?';\n params.push(window.sinceMs);\n }\n if (window?.untilMs !== undefined) {\n where += ' AND ts <= ?';\n params.push(window.untilMs);\n }\n return { where, params };\n }\n\n async countEvents(userId: string, event: string, window?: EventWindow): Promise<number> {\n const { where, params } = this.eventWindow(userId, event, window);\n const row = await this.db\n .prepare(`SELECT COUNT(*) AS c FROM events WHERE ${where}`)\n .bind(...params)\n .first<{ c: number }>();\n return row?.c ?? 0;\n }\n\n async listEventPayloads(\n userId: string,\n event: string,\n window?: EventWindow\n ): Promise<Record<string, unknown>[]> {\n const { where, params } = this.eventWindow(userId, event, window);\n const { results } = await this.db\n .prepare(`SELECT payload FROM events WHERE ${where}`)\n .bind(...params)\n .all<{ payload: string }>();\n return results.map((row) => JSON.parse(row.payload) as Record<string, unknown>);\n }\n\n async getProfile(userId: string): Promise<ProfileProps | undefined> {\n const row = await this.db\n .prepare('SELECT props FROM profiles WHERE user_id = ?')\n .bind(userId)\n .first<{ props: string }>();\n return row ? (JSON.parse(row.props) as ProfileProps) : undefined;\n }\n\n async mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps> {\n // SQLite's json_patch is RFC 7386 merge-patch: a null removes its key. The\n // merge happens in the statement, so two concurrent identifies cannot lose\n // each other's fields the way a read-modify-write did.\n const patch = JSON.stringify(props);\n const row = await this.db\n .prepare(\n \"INSERT INTO profiles (user_id, props) VALUES (?, json_patch('{}', ?)) ON CONFLICT(user_id) DO UPDATE SET props = json_patch(profiles.props, ?) RETURNING props\"\n )\n .bind(userId, patch, patch)\n .first<{ props: string }>();\n // RETURNING always yields the row it just wrote; the fallback only covers a\n // driver that declines to return one, and reports what was asked for.\n return row === null ? stripNulls(props) : (JSON.parse(row.props) as ProfileProps);\n }\n\n async listUserIds(): Promise<string[]> {\n const { results } = await this.db\n .prepare('SELECT user_id FROM profiles UNION SELECT DISTINCT user_id FROM events')\n .bind()\n .all<{ user_id: string }>();\n return results.map((row) => row.user_id);\n }\n\n async listDevices(userId: string): Promise<PushDevice[]> {\n const { results } = await this.db\n .prepare('SELECT token, platform FROM devices WHERE user_id = ? ORDER BY ts DESC')\n .bind(userId)\n .all<PushDevice>();\n return results;\n }\n\n async upsertDevice(device: DeviceInput): Promise<void> {\n await this.db\n .prepare(\n 'INSERT INTO devices (token, user_id, platform, ts) VALUES (?, ?, ?, ?) ON CONFLICT (token) DO UPDATE SET user_id = excluded.user_id, platform = excluded.platform, ts = excluded.ts'\n )\n .bind(device.token, device.userId, device.platform, device.ts)\n .run();\n }\n\n async requestErasure(userId: string, at: number): Promise<void> {\n await this.db\n .prepare('INSERT OR IGNORE INTO erasures (user_id, ts) VALUES (?, ?)')\n .bind(userId, at)\n .run();\n }\n\n async pendingErasures(): Promise<string[]> {\n const { results } = await this.db\n .prepare('SELECT user_id FROM erasures ORDER BY ts')\n .bind()\n .all<{ user_id: string }>();\n return results.map((row) => row.user_id);\n }\n\n async runningInstances(userId: string): Promise<string[]> {\n const { results } = await this.db\n .prepare(\"SELECT instance_id FROM entries WHERE user_id = ? AND status = 'running'\")\n .bind(userId)\n .all<{ instance_id: string }>();\n return results.map((row) => row.instance_id);\n }\n\n async forgetUser(userId: string): Promise<void> {\n // One batch: D1 runs it as a transaction; the request goes last, with the data it erases\n await this.db.batch(\n [\n 'entries',\n 'subscriptions',\n 'segment_starts',\n 'segment_members',\n 'devices',\n 'events',\n 'profiles',\n 'erasures',\n ].map((table) => this.db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).bind(userId))\n );\n }\n\n async removeDevice(token: string): Promise<string | undefined> {\n const row = await this.db\n .prepare('DELETE FROM devices WHERE token = ? RETURNING user_id')\n .bind(token)\n .first<{ user_id: string | null }>();\n return row?.user_id ?? undefined;\n }\n\n async unbindDevice(token: string): Promise<string | undefined> {\n const row = await this.db\n .prepare('SELECT user_id FROM devices WHERE token = ?')\n .bind(token)\n .first<{ user_id: string | null }>();\n await this.db.prepare('UPDATE devices SET user_id = NULL WHERE token = ?').bind(token).run();\n return row?.user_id ?? undefined;\n }\n\n async listSegments(): Promise<{ id: string; condition: ConditionIR }[]> {\n const { results } = await this.db\n .prepare('SELECT id, condition FROM segments ORDER BY id')\n .bind()\n .all<{ id: string; condition: string }>();\n return results.map((row) => ({\n id: row.id,\n condition: ConditionIR.parse(JSON.parse(row.condition)),\n }));\n }\n\n async getSegmentCondition(id: string): Promise<ConditionIR | undefined> {\n const row = await this.db\n .prepare('SELECT condition FROM segments WHERE id = ?')\n .bind(id)\n .first<{ condition: string }>();\n return row ? ConditionIR.parse(JSON.parse(row.condition)) : undefined;\n }\n\n async findTriggers(event: string): Promise<TriggerRoute[]> {\n const { results } = await this.db\n .prepare(\n 'SELECT workflow, hash, \"where\" AS whereClause, filter FROM triggers WHERE event = ?'\n )\n .bind(event)\n .all<{ workflow: string; hash: string; whereClause: string | null; filter: string | null }>();\n return results.map((row) => ({\n workflow: row.workflow,\n hash: row.hash,\n where: row.whereClause === null ? null : ConditionIR.parse(JSON.parse(row.whereClause)),\n filter: row.filter === null ? null : ConditionIR.parse(JSON.parse(row.filter)),\n }));\n }\n\n async listSegmentTriggers(): Promise<SegmentTriggerRoute[]> {\n const { results } = await this.db\n .prepare('SELECT workflow, hash, segment FROM segment_triggers')\n .all<SegmentTriggerRoute>();\n return results;\n }\n\n async putWorkflowIR(workflow: WorkflowIR): Promise<void> {\n await this.kv.put(`wf:${workflow.contentHash}`, JSON.stringify(workflow));\n }\n\n async getWorkflowIR(contentHash: string): Promise<WorkflowIR | undefined> {\n const raw = await this.kv.get(`wf:${contentHash}`);\n return raw === null ? undefined : WorkflowIR.parse(JSON.parse(raw));\n }\n\n async replaceRoutes(bundle: BundleIR): Promise<void> {\n // One atomic batch: all-or-nothing, and a concurrent ingest never observes\n // the half-empty routing table a delete-then-insert loop would expose.\n await this.db.batch([\n this.db.prepare('DELETE FROM triggers'),\n this.db.prepare('DELETE FROM segment_triggers'),\n this.db.prepare('DELETE FROM segments'),\n ...bundle.segments.map((segment) =>\n this.db\n .prepare('INSERT INTO segments (id, condition, hash) VALUES (?, ?, ?)')\n .bind(segment.id, JSON.stringify(segment.condition), segment.contentHash)\n ),\n ...bundle.workflows.flatMap((workflow) => {\n if (workflow.trigger.type === 'event') {\n return [\n this.db\n .prepare(\n 'INSERT INTO triggers (workflow, hash, event, \"where\", filter) VALUES (?, ?, ?, ?, ?)'\n )\n .bind(\n workflow.id,\n workflow.contentHash,\n workflow.trigger.event,\n workflow.trigger.where !== undefined\n ? JSON.stringify(workflow.trigger.where)\n : null,\n workflow.trigger.filter !== undefined\n ? JSON.stringify(workflow.trigger.filter)\n : null\n ),\n ];\n }\n if (workflow.trigger.type === 'segment') {\n return [\n this.db\n .prepare('INSERT INTO segment_triggers (workflow, hash, segment) VALUES (?, ?, ?)')\n .bind(workflow.id, workflow.contentHash, workflow.trigger.segment),\n ];\n }\n return [];\n }),\n ]);\n }\n\n async isSegmentMember(segment: string, userId: string): Promise<boolean> {\n const row = await this.db\n .prepare('SELECT 1 AS x FROM segment_members WHERE segment = ? AND user_id = ?')\n .bind(segment, userId)\n .first<{ x: number }>();\n return row !== null;\n }\n\n async addSegmentMember(\n segment: string,\n userId: string,\n ts: number,\n workflows: readonly string[]\n ): Promise<void> {\n await this.db.batch([\n this.db\n .prepare('INSERT OR IGNORE INTO segment_members (segment, user_id, ts) VALUES (?, ?, ?)')\n .bind(segment, userId, ts),\n ...workflows.map((workflow) =>\n this.db\n .prepare(\n 'INSERT OR IGNORE INTO segment_starts (segment, workflow, user_id, ts) VALUES (?, ?, ?, ?)'\n )\n .bind(segment, workflow, userId, ts)\n ),\n ]);\n }\n\n async pendingSegmentStarts(segment: string, userId: string): Promise<string[]> {\n const { results } = await this.db\n .prepare('SELECT workflow FROM segment_starts WHERE segment = ? AND user_id = ?')\n .bind(segment, userId)\n .all<{ workflow: string }>();\n return results.map((row) => row.workflow);\n }\n\n async completeSegmentStart(segment: string, userId: string, workflow: string): Promise<void> {\n await this.db\n .prepare('DELETE FROM segment_starts WHERE segment = ? AND user_id = ? AND workflow = ?')\n .bind(segment, userId, workflow)\n .run();\n }\n\n async removeSegmentMember(segment: string, userId: string): Promise<void> {\n await this.db.batch([\n this.db\n .prepare('DELETE FROM segment_starts WHERE segment = ? AND user_id = ?')\n .bind(segment, userId),\n this.db\n .prepare('DELETE FROM segment_members WHERE segment = ? AND user_id = ?')\n .bind(segment, userId),\n ]);\n }\n\n async enterJourney(entry: EntryInput): Promise<EntryOutcome> {\n if (entry.execution !== undefined) {\n throw new Error('D1JourneyStore keeps no execution starts: this runtime needs Postgres');\n }\n const inserted = await this.db\n .prepare(\n 'INSERT OR IGNORE INTO entries (workflow, user_id, instance_id, hash, status, ts) VALUES (?, ?, ?, ?, ?, ?)'\n )\n .bind(entry.workflow, entry.userId, entry.instanceId, entry.hash, 'running', entry.ts)\n .run();\n if ((inserted.meta?.changes ?? 1) !== 0) return 'entered';\n\n // A row already there blocks the entry, unless it records an instance that\n // died: 'failed' is reclaimed rather than barring the user forever over one\n // outage. The status test lives inside the UPDATE, so of two concurrent\n // retries exactly one sees a change.\n const claimed = await this.db\n .prepare(\n \"UPDATE entries SET instance_id = ?, hash = ?, status = 'running', ts = ? WHERE workflow = ? AND user_id = ? AND status = 'failed'\"\n )\n .bind(entry.instanceId, entry.hash, entry.ts, entry.workflow, entry.userId)\n .run();\n return (claimed.meta?.changes ?? 0) > 0 ? 'reclaimed' : null;\n }\n\n async removeEntry(instanceId: string): Promise<void> {\n await this.db.prepare('DELETE FROM entries WHERE instance_id = ?').bind(instanceId).run();\n }\n\n async setEntryStatus(instanceId: string, status: string): Promise<void> {\n await this.db\n .prepare('UPDATE entries SET status = ? WHERE instance_id = ?')\n .bind(status, instanceId)\n .run();\n }\n\n async findWakeHandles(userId: string, event: string): Promise<string[]> {\n const { results } = await this.db\n .prepare(\n 'SELECT DISTINCT wake_handle AS handle FROM subscriptions WHERE user_id = ? AND event = ?'\n )\n .bind(userId, event)\n .all<{ handle: string }>();\n return results.map((row) => row.handle);\n }\n\n async subscribe(\n handle: string,\n userId: string,\n events: readonly string[],\n ts: number\n ): Promise<void> {\n for (const event of events) {\n // OR IGNORE on the (wake_handle, event) PK: the interpreter re-subscribes on\n // every wait attempt (one-shot-callback contract) and rows persist here.\n await this.db\n .prepare(\n 'INSERT OR IGNORE INTO subscriptions (user_id, event, wake_handle, ts) VALUES (?, ?, ?, ?)'\n )\n .bind(userId, event, handle, ts)\n .run();\n }\n }\n\n async unsubscribe(handle: string): Promise<void> {\n await this.db.prepare('DELETE FROM subscriptions WHERE wake_handle = ?').bind(handle).run();\n }\n}\n\n/** What a merge-patch of `props` into an empty profile yields: the patch minus its nulls. */\nfunction stripNulls(props: ProfileProps): ProfileProps {\n return Object.fromEntries(Object.entries(props).filter(([, value]) => value !== null));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"d1.cjs","names":["UserErasedError","ConditionIR","WorkflowIR"],"sources":["../../src/store/d1.ts"],"sourcesContent":["import type { D1DatabaseLike, D1RunResultLike } from '../cloudflare/bindings';\nimport type { PushDevice } from '../engine/ports';\nimport { type BundleIR, ConditionIR, WorkflowIR } from '../ir';\nimport { type ErasureState, UserErasedError } from './erasure';\nimport type {\n DeviceInput,\n EntryInput,\n EntryOutcome,\n EventInput,\n EventWindow,\n JourneyStore,\n ProfileProps,\n RunningInstance,\n SegmentTriggerRoute,\n TriggerRoute,\n} from './index';\n\n/**\n * The condition every write about a user carries, as the last bound parameter: no erasure\n * tombstone for the user. Inside the statement, so the check and the write share one snapshot.\n */\nconst NOT_ERASED = 'WHERE NOT EXISTS (SELECT 1 FROM erasures WHERE user_id = ?)';\n\n/**\n * The D1 data plane: one database for everything (see ../cloudflare/schema.sql),\n * the content-addressed workflow bodies included — beside the routes that name\n * them, so a deploy is read-your-writes (KV's eventual consistency would let a\n * route name a body that is not readable yet). The in-memory FakeD1 in the\n * tests dispatches on these statements.\n *\n * Every write about a user is one statement conditional on their erasure tombstone not\n * existing (`INSERT ... SELECT ... WHERE NOT EXISTS`): a write that changed nothing is then\n * read back once to tell a refusal (`UserErasedError`) from a plain no-op. D1 has one writer,\n * so a statement is atomic against every other: the runner's admission is one conditional\n * UPDATE, and a tombstone either precedes it (refused) or follows it (the erasure finds the\n * instance admitted) — no lock needed.\n */\nexport class D1JourneyStore implements JourneyStore {\n constructor(private readonly db: D1DatabaseLike) {}\n\n async insertEvent(input: EventInput): Promise<boolean> {\n // The key is (user_id, id): the sender's occurrence id, per user\n const written = await this.db\n .prepare(\n `INSERT OR IGNORE INTO events (id, user_id, name, ts, payload) SELECT ?, ?, ?, ?, ? ${NOT_ERASED}`\n )\n .bind(\n input.id,\n input.userId,\n input.event,\n input.ts,\n JSON.stringify(input.payload),\n input.userId\n )\n .run();\n if ((written.meta?.changes ?? 1) > 0) return true;\n // Nothing written: a duplicate, unless the tombstone refused it\n await this.refuseIfErased(input.userId);\n return false;\n }\n\n /**\n * A conditional write changed nothing: on that path only, read why. Throws when the tombstone\n * refused it; returns when the statement had another reason (a duplicate, an entry taken).\n */\n private async refuseIfErased(userId: string): Promise<void> {\n if ((await this.erasureState(userId)) !== 'none') throw new UserErasedError(userId);\n }\n\n /** The event rows of one user and name, within the window's bounds (both inclusive). */\n private eventWindow(userId: string, event: string, window?: EventWindow) {\n const params: unknown[] = [userId, event];\n let where = 'user_id = ? AND name = ?';\n if (window?.sinceMs !== undefined) {\n where += ' AND ts >= ?';\n params.push(window.sinceMs);\n }\n if (window?.untilMs !== undefined) {\n where += ' AND ts <= ?';\n params.push(window.untilMs);\n }\n return { where, params };\n }\n\n async countEvents(userId: string, event: string, window?: EventWindow): Promise<number> {\n const { where, params } = this.eventWindow(userId, event, window);\n const row = await this.db\n .prepare(`SELECT COUNT(*) AS c FROM events WHERE ${where}`)\n .bind(...params)\n .first<{ c: number }>();\n return row?.c ?? 0;\n }\n\n async listEventPayloads(\n userId: string,\n event: string,\n window?: EventWindow\n ): Promise<Record<string, unknown>[]> {\n const { where, params } = this.eventWindow(userId, event, window);\n const { results } = await this.db\n .prepare(`SELECT payload FROM events WHERE ${where}`)\n .bind(...params)\n .all<{ payload: string }>();\n return results.map((row) => JSON.parse(row.payload) as Record<string, unknown>);\n }\n\n async getProfile(userId: string): Promise<ProfileProps | undefined> {\n const row = await this.db\n .prepare('SELECT props FROM profiles WHERE user_id = ?')\n .bind(userId)\n .first<{ props: string }>();\n return row ? (JSON.parse(row.props) as ProfileProps) : undefined;\n }\n\n async mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps> {\n // SQLite's json_patch is RFC 7386 merge-patch: a null removes its key. The\n // merge happens in the statement, so two concurrent identifies cannot lose\n // each other's fields the way a read-modify-write did.\n //\n // The merge is top-level only, as Postgres's `||` is: a key whose value is\n // an object replaces the stored value whole instead of being deep-merged\n // into it (json_patch's own behaviour), so the two stores agree. Those keys\n // are removed first, in the same statement; a patch of scalars — all the\n // API admits — needs no removal and is the plain json_patch.\n const patch = JSON.stringify(props);\n // Typed as scalars, but a caller outside the API may still hand an object: replace it whole\n const replaced = Object.entries(props as Record<string, unknown>)\n .filter(([, value]) => typeof value === 'object' && value !== null)\n .map(([key]) => key);\n const stored =\n replaced.length === 0\n ? 'profiles.props'\n : `json_remove(profiles.props, ${replaced.map(() => '?').join(', ')})`;\n // The SELECT keeps a WHERE clause, which is also what disambiguates the upsert for SQLite\n const row = await this.db\n .prepare(\n `INSERT INTO profiles (user_id, props) SELECT ?, json_patch('{}', ?) ${NOT_ERASED} ON CONFLICT(user_id) DO UPDATE SET props = json_patch(${stored}, ?) RETURNING props`\n )\n .bind(userId, patch, userId, ...replaced.map(jsonPath), patch)\n .first<{ props: string }>();\n if (row !== null) return JSON.parse(row.props) as ProfileProps;\n // No row: the tombstone refused the write — or a driver declined to return one, in which\n // case the merge went through and the answer is what was asked for\n await this.refuseIfErased(userId);\n return stripNulls(props);\n }\n\n async listUserIds(page?: {\n after?: string | undefined;\n limit?: number | undefined;\n }): Promise<string[]> {\n // Ascending user_id with a keyset bound, so a sweep can resume past the last id it saw\n const params: unknown[] = [];\n let where = '';\n if (page?.after !== undefined) {\n where = ' WHERE user_id > ?';\n params.push(page.after);\n }\n let limit = '';\n if (page?.limit !== undefined) {\n limit = ' LIMIT ?';\n params.push(page.limit);\n }\n const { results } = await this.db\n .prepare(\n `SELECT user_id FROM (SELECT user_id FROM profiles UNION SELECT user_id FROM events)${where} ORDER BY user_id${limit}`\n )\n .bind(...params)\n .all<{ user_id: string }>();\n return results.map((row) => row.user_id);\n }\n\n async listDevices(userId: string): Promise<PushDevice[]> {\n const { results } = await this.db\n .prepare('SELECT token, platform FROM devices WHERE user_id = ? ORDER BY ts DESC')\n .bind(userId)\n .all<PushDevice>();\n return results;\n }\n\n async upsertDevice(device: DeviceInput): Promise<void> {\n const written = await this.db\n .prepare(\n `INSERT INTO devices (token, user_id, platform, ts) SELECT ?, ?, ?, ? ${NOT_ERASED} ON CONFLICT (token) DO UPDATE SET user_id = excluded.user_id, platform = excluded.platform, ts = excluded.ts`\n )\n .bind(device.token, device.userId, device.platform, device.ts, device.userId)\n .run();\n // The upsert always changes its row — unless the tombstone refused it\n if ((written.meta?.changes ?? 1) === 0) throw new UserErasedError(device.userId);\n }\n\n async requestErasure(userId: string, at: number): Promise<void> {\n await this.db\n .prepare('INSERT OR IGNORE INTO erasures (user_id, ts) VALUES (?, ?)')\n .bind(userId, at)\n .run();\n }\n\n async pendingErasures(): Promise<string[]> {\n const { results } = await this.db\n .prepare('SELECT user_id FROM erasures WHERE erased_at IS NULL ORDER BY ts')\n .bind()\n .all<{ user_id: string }>();\n return results.map((row) => row.user_id);\n }\n\n async erasureState(userId: string): Promise<ErasureState> {\n const row = await this.db\n .prepare('SELECT erased_at FROM erasures WHERE user_id = ?')\n .bind(userId)\n .first<{ erased_at: number | null }>();\n if (row === null) return 'none';\n return row.erased_at === null ? 'erasing' : 'erased';\n }\n\n async runningInstances(userId: string): Promise<RunningInstance[]> {\n const { results } = await this.db\n .prepare(\n \"SELECT instance_id, admitted_at IS NOT NULL AS admitted FROM entries WHERE user_id = ? AND status = 'running'\"\n )\n .bind(userId)\n .all<{ instance_id: string; admitted: number | boolean }>();\n return results.map((row) => ({ instanceId: row.instance_id, admitted: Boolean(row.admitted) }));\n }\n\n async admitInstance(instanceId: string, userId: string): Promise<boolean> {\n // One statement. A first admission needs a running entry; an instance once admitted may\n // replay its finalisation after the entry reached a terminal status, under its own instance\n // id (a reclaim resets admitted_at, so a superseded attempt cannot). The first admission's\n // time is kept, the status never touched, and the tombstone refuses both\n const row = await this.db\n .prepare(\n \"UPDATE entries SET admitted_at = COALESCE(admitted_at, ?) WHERE instance_id = ? AND user_id = ? AND (status = 'running' OR admitted_at IS NOT NULL) AND NOT EXISTS (SELECT 1 FROM erasures WHERE user_id = ?) RETURNING instance_id\"\n )\n .bind(Date.now(), instanceId, userId, userId)\n .first<{ instance_id: string }>();\n return row !== null;\n }\n\n async forgetUser(userId: string, at: number): Promise<void> {\n // One batch: D1 runs it as a transaction. The tombstone stays, marked erased last; a repeat\n // keeps the first erase's time\n await this.db.batch([\n ...[\n 'entries',\n 'subscriptions',\n 'segment_starts',\n 'segment_members',\n 'devices',\n 'events',\n 'profiles',\n ].map((table) => this.db.prepare(`DELETE FROM ${table} WHERE user_id = ?`).bind(userId)),\n this.db\n .prepare('UPDATE erasures SET erased_at = COALESCE(erased_at, ?) WHERE user_id = ?')\n .bind(at, userId),\n ]);\n }\n\n async removeDevice(token: string): Promise<string | undefined> {\n const row = await this.db\n .prepare('DELETE FROM devices WHERE token = ? RETURNING user_id')\n .bind(token)\n .first<{ user_id: string | null }>();\n return row?.user_id ?? undefined;\n }\n\n async unbindDevice(token: string): Promise<string | undefined> {\n const row = await this.db\n .prepare('SELECT user_id FROM devices WHERE token = ?')\n .bind(token)\n .first<{ user_id: string | null }>();\n await this.db.prepare('UPDATE devices SET user_id = NULL WHERE token = ?').bind(token).run();\n return row?.user_id ?? undefined;\n }\n\n async listSegments(): Promise<{ id: string; condition: ConditionIR }[]> {\n const { results } = await this.db\n .prepare('SELECT id, condition FROM segments ORDER BY id')\n .bind()\n .all<{ id: string; condition: string }>();\n return results.map((row) => ({\n id: row.id,\n condition: ConditionIR.parse(JSON.parse(row.condition)),\n }));\n }\n\n async getSegmentCondition(id: string): Promise<ConditionIR | undefined> {\n const row = await this.db\n .prepare('SELECT condition FROM segments WHERE id = ?')\n .bind(id)\n .first<{ condition: string }>();\n return row ? ConditionIR.parse(JSON.parse(row.condition)) : undefined;\n }\n\n async findTriggers(event: string): Promise<TriggerRoute[]> {\n const { results } = await this.db\n .prepare(\n 'SELECT workflow, hash, \"where\" AS whereClause, filter FROM triggers WHERE event = ?'\n )\n .bind(event)\n .all<{ workflow: string; hash: string; whereClause: string | null; filter: string | null }>();\n return results.map((row) => ({\n workflow: row.workflow,\n hash: row.hash,\n where: row.whereClause === null ? null : ConditionIR.parse(JSON.parse(row.whereClause)),\n filter: row.filter === null ? null : ConditionIR.parse(JSON.parse(row.filter)),\n }));\n }\n\n async listSegmentTriggers(): Promise<SegmentTriggerRoute[]> {\n const { results } = await this.db\n .prepare('SELECT workflow, hash, segment FROM segment_triggers')\n .all<SegmentTriggerRoute>();\n return results;\n }\n\n async putWorkflowIR(workflow: WorkflowIR): Promise<void> {\n // Content-addressed: a hash already there names the same body, so replacing is harmless\n await this.db\n .prepare('INSERT OR REPLACE INTO workflow_ir (content_hash, ir) VALUES (?, ?)')\n .bind(workflow.contentHash, JSON.stringify(workflow))\n .run();\n }\n\n async getWorkflowIR(contentHash: string): Promise<WorkflowIR | undefined> {\n const row = await this.db\n .prepare('SELECT ir FROM workflow_ir WHERE content_hash = ?')\n .bind(contentHash)\n .first<{ ir: string }>();\n return row === null ? undefined : WorkflowIR.parse(JSON.parse(row.ir));\n }\n\n async replaceRoutes(bundle: BundleIR): Promise<void> {\n // One atomic batch: all-or-nothing, and a concurrent ingest never observes\n // the half-empty routing table a delete-then-insert loop would expose.\n await this.db.batch([\n this.db.prepare('DELETE FROM triggers'),\n this.db.prepare('DELETE FROM segment_triggers'),\n this.db.prepare('DELETE FROM segments'),\n ...bundle.segments.map((segment) =>\n this.db\n .prepare('INSERT INTO segments (id, condition, hash) VALUES (?, ?, ?)')\n .bind(segment.id, JSON.stringify(segment.condition), segment.contentHash)\n ),\n ...bundle.workflows.flatMap((workflow) => {\n if (workflow.trigger.type === 'event') {\n return [\n this.db\n .prepare(\n 'INSERT INTO triggers (workflow, hash, event, \"where\", filter) VALUES (?, ?, ?, ?, ?)'\n )\n .bind(\n workflow.id,\n workflow.contentHash,\n workflow.trigger.event,\n workflow.trigger.where !== undefined\n ? JSON.stringify(workflow.trigger.where)\n : null,\n workflow.trigger.filter !== undefined\n ? JSON.stringify(workflow.trigger.filter)\n : null\n ),\n ];\n }\n if (workflow.trigger.type === 'segment') {\n return [\n this.db\n .prepare('INSERT INTO segment_triggers (workflow, hash, segment) VALUES (?, ?, ?)')\n .bind(workflow.id, workflow.contentHash, workflow.trigger.segment),\n ];\n }\n return [];\n }),\n ]);\n }\n\n async isSegmentMember(segment: string, userId: string): Promise<boolean> {\n const row = await this.db\n .prepare('SELECT 1 AS x FROM segment_members WHERE segment = ? AND user_id = ?')\n .bind(segment, userId)\n .first<{ x: number }>();\n return row !== null;\n }\n\n async addSegmentMember(\n segment: string,\n userId: string,\n ts: number,\n workflows: readonly string[]\n ): Promise<void> {\n const [member] = (await this.db.batch([\n this.db\n .prepare(\n `INSERT OR IGNORE INTO segment_members (segment, user_id, ts) SELECT ?, ?, ? ${NOT_ERASED}`\n )\n .bind(segment, userId, ts, userId),\n ...workflows.map((workflow) =>\n this.db\n .prepare(\n `INSERT OR IGNORE INTO segment_starts (segment, workflow, user_id, ts) SELECT ?, ?, ?, ? ${NOT_ERASED}`\n )\n .bind(segment, workflow, userId, ts, userId)\n ),\n ])) as D1RunResultLike[];\n // Already a member, or refused: either way the starts wrote nothing new\n if ((member.meta?.changes ?? 1) === 0) await this.refuseIfErased(userId);\n }\n\n async pendingSegmentStarts(segment: string, userId: string): Promise<string[]> {\n const { results } = await this.db\n .prepare('SELECT workflow FROM segment_starts WHERE segment = ? AND user_id = ?')\n .bind(segment, userId)\n .all<{ workflow: string }>();\n return results.map((row) => row.workflow);\n }\n\n async completeSegmentStart(segment: string, userId: string, workflow: string): Promise<void> {\n await this.db\n .prepare('DELETE FROM segment_starts WHERE segment = ? AND user_id = ? AND workflow = ?')\n .bind(segment, userId, workflow)\n .run();\n }\n\n async removeSegmentMember(segment: string, userId: string): Promise<void> {\n await this.db.batch([\n this.db\n .prepare('DELETE FROM segment_starts WHERE segment = ? AND user_id = ?')\n .bind(segment, userId),\n this.db\n .prepare('DELETE FROM segment_members WHERE segment = ? AND user_id = ?')\n .bind(segment, userId),\n ]);\n }\n\n async enterJourney(entry: EntryInput): Promise<EntryOutcome> {\n if (entry.execution !== undefined) {\n throw new Error('D1JourneyStore keeps no execution starts: this runtime needs Postgres');\n }\n const inserted = await this.db\n .prepare(\n `INSERT OR IGNORE INTO entries (workflow, user_id, instance_id, hash, status, ts) SELECT ?, ?, ?, ?, ?, ? ${NOT_ERASED}`\n )\n .bind(\n entry.workflow,\n entry.userId,\n entry.instanceId,\n entry.hash,\n 'running',\n entry.ts,\n entry.userId\n )\n .run();\n if ((inserted.meta?.changes ?? 1) !== 0) return 'entered';\n\n // A row already there blocks the entry, unless it records an instance that\n // died: 'failed' is reclaimed rather than barring the user forever over one\n // outage. The status test lives inside the UPDATE, so of two concurrent\n // retries exactly one sees a change. The reclaim is a new attempt: it starts\n // unadmitted, even under the same instance id, so the previous attempt's\n // admission is not inherited.\n const claimed = await this.db\n .prepare(\n \"UPDATE entries SET instance_id = ?, hash = ?, status = 'running', ts = ?, admitted_at = NULL WHERE workflow = ? AND user_id = ? AND status = 'failed' AND NOT EXISTS (SELECT 1 FROM erasures WHERE user_id = ?)\"\n )\n .bind(entry.instanceId, entry.hash, entry.ts, entry.workflow, entry.userId, entry.userId)\n .run();\n if ((claimed.meta?.changes ?? 0) > 0) return 'reclaimed';\n // Taken by a live instance, or refused by the tombstone: tell them apart\n await this.refuseIfErased(entry.userId);\n return null;\n }\n\n async removeEntry(instanceId: string): Promise<void> {\n await this.db.prepare('DELETE FROM entries WHERE instance_id = ?').bind(instanceId).run();\n }\n\n async setEntryStatus(instanceId: string, status: string): Promise<void> {\n await this.db\n .prepare('UPDATE entries SET status = ? WHERE instance_id = ?')\n .bind(status, instanceId)\n .run();\n }\n\n async findWakeHandles(userId: string, event: string): Promise<string[]> {\n const { results } = await this.db\n .prepare(\n 'SELECT DISTINCT wake_handle AS handle FROM subscriptions WHERE user_id = ? AND event = ?'\n )\n .bind(userId, event)\n .all<{ handle: string }>();\n return results.map((row) => row.handle);\n }\n\n async subscribe(\n handle: string,\n userId: string,\n events: readonly string[],\n ts: number\n ): Promise<void> {\n let skipped = false;\n for (const event of events) {\n // OR IGNORE on the (wake_handle, event) PK: the interpreter re-subscribes on\n // every wait attempt (one-shot-callback contract) and rows persist here.\n const written = await this.db\n .prepare(\n `INSERT OR IGNORE INTO subscriptions (user_id, event, wake_handle, ts) SELECT ?, ?, ?, ? ${NOT_ERASED}`\n )\n .bind(userId, event, handle, ts, userId)\n .run();\n if ((written.meta?.changes ?? 1) === 0) skipped = true;\n }\n // A registration already there, or refused: one read tells which\n if (skipped) await this.refuseIfErased(userId);\n }\n\n async unsubscribe(handle: string): Promise<void> {\n await this.db.prepare('DELETE FROM subscriptions WHERE wake_handle = ?').bind(handle).run();\n }\n}\n\n/** What a merge-patch of `props` into an empty profile yields: the patch minus its nulls. */\nfunction stripNulls(props: ProfileProps): ProfileProps {\n return Object.fromEntries(Object.entries(props).filter(([, value]) => value !== null));\n}\n\n/**\n * The SQLite JSON path of a top-level key. Quoted, so dots and brackets in the key are literal;\n * a double quote has no escape in SQLite's path syntax, so such a key cannot be addressed.\n */\nfunction jsonPath(key: string): string {\n if (key.includes('\"')) {\n throw new Error(`profile property \"${key}\" cannot hold an object: its name contains a quote`);\n }\n return `$.\"${key}\"`;\n}\n"],"mappings":"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"}
@@ -1,32 +1,50 @@
1
1
  import { BundleIR, ConditionIR, WorkflowIR } from "../ir.cjs";
2
2
  import { PushDevice } from "../engine/ports.cjs";
3
- import { D1DatabaseLike, KVNamespaceLike } from "../cloudflare/bindings.cjs";
4
- import { DeviceInput, EntryInput, EntryOutcome, EventInput, EventWindow, JourneyStore, ProfileProps, SegmentTriggerRoute, TriggerRoute } from "./index.cjs";
3
+ import { ErasureState } from "./erasure.cjs";
4
+ import { D1DatabaseLike } from "../cloudflare/bindings.cjs";
5
+ import { DeviceInput, EntryInput, EntryOutcome, EventInput, EventWindow, JourneyStore, ProfileProps, RunningInstance, SegmentTriggerRoute, TriggerRoute } from "./index.cjs";
5
6
  //#region src/store/d1.d.ts
6
7
  /**
7
- * The original data plane: D1 for the tables (see ../cloudflare/schema.sql)
8
- * and KV for the content-addressed workflow bodies (`wf:${contentHash}`).
9
- * The SQL below is exactly what the router and fact source used to inline —
10
- * the in-memory FakeD1 in the tests dispatches on these strings verbatim.
8
+ * The D1 data plane: one database for everything (see ../cloudflare/schema.sql),
9
+ * the content-addressed workflow bodies included — beside the routes that name
10
+ * them, so a deploy is read-your-writes (KV's eventual consistency would let a
11
+ * route name a body that is not readable yet). The in-memory FakeD1 in the
12
+ * tests dispatches on these statements.
13
+ *
14
+ * Every write about a user is one statement conditional on their erasure tombstone not
15
+ * existing (`INSERT ... SELECT ... WHERE NOT EXISTS`): a write that changed nothing is then
16
+ * read back once to tell a refusal (`UserErasedError`) from a plain no-op. D1 has one writer,
17
+ * so a statement is atomic against every other: the runner's admission is one conditional
18
+ * UPDATE, and a tombstone either precedes it (refused) or follows it (the erasure finds the
19
+ * instance admitted) — no lock needed.
11
20
  */
12
21
  export declare class D1JourneyStore implements JourneyStore {
13
22
  private readonly db;
14
- private readonly kv;
15
- constructor(db: D1DatabaseLike, kv: KVNamespaceLike);
23
+ constructor(db: D1DatabaseLike);
16
24
  insertEvent(input: EventInput): Promise<boolean>;
25
+ /**
26
+ * A conditional write changed nothing: on that path only, read why. Throws when the tombstone
27
+ * refused it; returns when the statement had another reason (a duplicate, an entry taken).
28
+ */
29
+ private refuseIfErased;
17
30
  /** The event rows of one user and name, within the window's bounds (both inclusive). */
18
31
  private eventWindow;
19
32
  countEvents(userId: string, event: string, window?: EventWindow): Promise<number>;
20
33
  listEventPayloads(userId: string, event: string, window?: EventWindow): Promise<Record<string, unknown>[]>;
21
34
  getProfile(userId: string): Promise<ProfileProps | undefined>;
22
35
  mergeProfile(userId: string, props: ProfileProps): Promise<ProfileProps>;
23
- listUserIds(): Promise<string[]>;
36
+ listUserIds(page?: {
37
+ after?: string | undefined;
38
+ limit?: number | undefined;
39
+ }): Promise<string[]>;
24
40
  listDevices(userId: string): Promise<PushDevice[]>;
25
41
  upsertDevice(device: DeviceInput): Promise<void>;
26
42
  requestErasure(userId: string, at: number): Promise<void>;
27
43
  pendingErasures(): Promise<string[]>;
28
- runningInstances(userId: string): Promise<string[]>;
29
- forgetUser(userId: string): Promise<void>;
44
+ erasureState(userId: string): Promise<ErasureState>;
45
+ runningInstances(userId: string): Promise<RunningInstance[]>;
46
+ admitInstance(instanceId: string, userId: string): Promise<boolean>;
47
+ forgetUser(userId: string, at: number): Promise<void>;
30
48
  removeDevice(token: string): Promise<string | undefined>;
31
49
  unbindDevice(token: string): Promise<string | undefined>;
32
50
  listSegments(): Promise<{
@@ -1 +1 @@
1
- {"version":3,"file":"d1.d.cts","names":[],"sources":["../../src/store/d1.ts"],"mappings":";;;;;;;;;;;qBAqBa,0BAA0B;mBAElB;mBACA;EAFnB,YACmB,IAAI,gBACJ,IAAI;EAGjB,YAAY,OAAO,aAAa;;UAW9B;EAcF,YAAY,gBAAgB,eAAe,SAAS,cAAc;EASlE,kBACJ,gBACA,eACA,SAAS,cACR,QAAQ;EASL,WAAW,iBAAiB,QAAQ;EAQpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;EAgB3D,eAAe;EAQf,YAAY,iBAAiB,QAAQ;EAQrC,aAAa,QAAQ,cAAc;EASnC,eAAe,gBAAgB,aAAa;EAO5C,mBAAmB;EAQnB,iBAAiB,iBAAiB;EAQlC,WAAW,iBAAiB;EAgB5B,aAAa,gBAAgB;EAQ7B,aAAa,gBAAgB;EAS7B,gBAAgB;IAAU;IAAY,WAAW;;EAWjD,oBAAoB,aAAa,QAAQ;EAQzC,aAAa,gBAAgB,QAAQ;EAerC,uBAAuB,QAAQ;EAO/B,cAAc,UAAU,aAAa;EAIrC,cAAc,sBAAsB,QAAQ;EAK5C,cAAc,QAAQ,WAAW;EA4CjC,gBAAgB,iBAAiB,iBAAiB;EAQlD,iBACJ,iBACA,gBACA,YACA,+BACC;EAeG,qBAAqB,iBAAiB,iBAAiB;EAQvD,qBAAqB,iBAAiB,gBAAgB,mBAAmB;EAOzE,oBAAoB,iBAAiB,iBAAiB;EAWtD,aAAa,OAAO,aAAa,QAAQ;EAyBzC,YAAY,qBAAqB;EAIjC,eAAe,oBAAoB,iBAAiB;EAOpD,gBAAgB,gBAAgB,gBAAgB;EAUhD,UACJ,gBACA,gBACA,2BACA,aACC;EAaG,YAAY,iBAAiB"}
1
+ {"version":3,"file":"d1.d.cts","names":[],"sources":["../../src/store/d1.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;qBAqCa,0BAA0B;mBACR;EAA7B,YAA6B,IAAI;EAE3B,YAAY,OAAO,aAAa;;;;;UAyBxB;;UAKN;EAcF,YAAY,gBAAgB,eAAe,SAAS,cAAc;EASlE,kBACJ,gBACA,eACA,SAAS,cACR,QAAQ;EASL,WAAW,iBAAiB,QAAQ;EAQpC,aAAa,gBAAgB,OAAO,eAAe,QAAQ;EAiC3D,YAAY;IAChB;IACA;MACE;EAsBE,YAAY,iBAAiB,QAAQ;EAQrC,aAAa,QAAQ,cAAc;EAWnC,eAAe,gBAAgB,aAAa;EAO5C,mBAAmB;EAQnB,aAAa,iBAAiB,QAAQ;EAStC,iBAAiB,iBAAiB,QAAQ;EAU1C,cAAc,oBAAoB,iBAAiB;EAcnD,WAAW,gBAAgB,aAAa;EAmBxC,aAAa,gBAAgB;EAQ7B,aAAa,gBAAgB;EAS7B,gBAAgB;IAAU;IAAY,WAAW;;EAWjD,oBAAoB,aAAa,QAAQ;EAQzC,aAAa,gBAAgB,QAAQ;EAerC,uBAAuB,QAAQ;EAO/B,cAAc,UAAU,aAAa;EAQrC,cAAc,sBAAsB,QAAQ;EAQ5C,cAAc,QAAQ,WAAW;EA4CjC,gBAAgB,iBAAiB,iBAAiB;EAQlD,iBACJ,iBACA,gBACA,YACA,+BACC;EAmBG,qBAAqB,iBAAiB,iBAAiB;EAQvD,qBAAqB,iBAAiB,gBAAgB,mBAAmB;EAOzE,oBAAoB,iBAAiB,iBAAiB;EAWtD,aAAa,OAAO,aAAa,QAAQ;EAsCzC,YAAY,qBAAqB;EAIjC,eAAe,oBAAoB,iBAAiB;EAOpD,gBAAgB,gBAAgB,gBAAgB;EAUhD,UACJ,gBACA,gBACA,2BACA,aACC;EAiBG,YAAY,iBAAiB"}