@shware/workflow 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/README.md +91 -29
  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 +47 -6
  19. package/dist/aws/index.cjs.map +1 -1
  20. package/dist/aws/index.d.cts +2 -2
  21. package/dist/aws/index.d.cts.map +1 -1
  22. package/dist/aws/index.d.mts +2 -2
  23. package/dist/aws/index.d.mts.map +1 -1
  24. package/dist/aws/index.mjs +47 -6
  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 +28 -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 +21 -0
  178. package/dist/host/auth.d.cts.map +1 -0
  179. package/dist/host/auth.d.mts +21 -0
  180. package/dist/host/auth.d.mts.map +1 -0
  181. package/dist/host/auth.mjs +47 -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 +321 -0
  208. package/dist/host/http.cjs.map +1 -0
  209. package/dist/host/http.d.cts +54 -0
  210. package/dist/host/http.d.cts.map +1 -0
  211. package/dist/host/http.d.mts +54 -0
  212. package/dist/host/http.d.mts.map +1 -0
  213. package/dist/host/http.mjs +317 -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 +65 -93
  224. package/dist/host/journey-host.cjs.map +1 -1
  225. package/dist/host/journey-host.d.cts +19 -26
  226. package/dist/host/journey-host.d.cts.map +1 -1
  227. package/dist/host/journey-host.d.mts +19 -26
  228. package/dist/host/journey-host.d.mts.map +1 -1
  229. package/dist/host/journey-host.mjs +65 -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
package/dist/apns.d.mts CHANGED
@@ -14,13 +14,18 @@ import { PushRenderer } from "./push.mjs";
14
14
  * workerd under `wrangler dev` does not (the connection is dropped), so a local
15
15
  * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.
16
16
  */
17
- export interface ApnsPushOptions {
17
+ /** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */
18
+ export interface ApnsProviderTokenOptions {
18
19
  /** The key's 10-character id, shown next to it in the developer portal. */
19
20
  keyId: string;
20
21
  /** The Apple Developer team id. */
21
22
  teamId: string;
22
23
  /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */
23
24
  privateKey: string;
25
+ }
26
+ export interface ApnsPushOptions {
27
+ /** The provider token the requests are signed with — shared by every sender on the same key. */
28
+ credentials: ApnsProviderToken;
24
29
  /** The app's bundle id — APNs's `apns-topic`. */
25
30
  topic: string;
26
31
  /**
@@ -39,24 +44,65 @@ export interface ApnsPushOptions {
39
44
  origin?: string;
40
45
  /** Fetch implementation (defaults to the global one). */
41
46
  fetch?: typeof fetch;
42
- /** Called for a token APNs reports as gone (`Unregistered`, `BadDeviceToken`); `channelsFromConfig` passes it on to the DeviceDirectory's `disable`. */
43
- onUnregistered?: (message: OutboundMessage) => Promise<void> | void;
44
47
  }
45
48
  export declare const APNS_HOSTS: {
46
49
  readonly sandbox: 'https://api.sandbox.push.apple.com';
47
50
  readonly production: 'https://api.push.apple.com';
48
51
  };
52
+ /**
53
+ * How an APNs answer is classified, after Apple's response table
54
+ * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):
55
+ *
56
+ * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the
57
+ * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles
58
+ * undelivered and the host forgets the token.
59
+ * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but
60
+ * just as well for a token of the other environment (a sandbox build pushed through the
61
+ * production host), which would answer for every device at once; the copy settles with the
62
+ * reason, so the report shows it, and nothing is deleted on a guess.
63
+ * - configuration: the topic, certificate or provider key is wrong. Every device on this app
64
+ * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,
65
+ * the deployment is not.
66
+ * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,
67
+ * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.
68
+ * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,
69
+ * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.
70
+ */
71
+ export declare const DEAD_TOKEN_REASONS: ReadonlySet<string>;
72
+ /**
73
+ * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the
74
+ * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the
75
+ * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —
76
+ * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose
77
+ * the node id at its end, and two nodes of one instance would collapse into one notification.
78
+ */
79
+ export declare function apnsIdFromKey(idempotencyKey: string): Promise<string>;
80
+ /**
81
+ * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple
82
+ * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent
83
+ * callers on a fresh or aged token share the one signing in flight, and a signing that failed is
84
+ * not kept, so the next call tries again. One instance serves every sender built on the key — a
85
+ * host keeps it across runs, and builds the senders per run.
86
+ */
87
+ export declare class ApnsProviderToken {
88
+ private readonly options;
89
+ private signed;
90
+ private signing;
91
+ constructor(options: ApnsProviderTokenOptions);
92
+ /** The current token, signed now if there is none or it has aged out. */
93
+ token(now?: number): Promise<string>;
94
+ /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */
95
+ invalidate(): void;
96
+ }
49
97
  export declare class ApnsPushSender implements MessageSender {
50
98
  private readonly options;
51
- private token;
52
99
  constructor(options: ApnsPushOptions);
53
100
  send(message: OutboundMessage): Promise<void | SendResult>;
54
- private providerToken;
55
101
  }
56
102
  /** APNs error bodies are `{ "reason": "…" }`. */
57
103
  export declare function apnsReason(body: string): string;
58
104
  /** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */
59
- export declare function signApnsJwt(key: Pick<ApnsPushOptions, 'keyId' | 'teamId' | 'privateKey'>, nowMs?: number): Promise<string>;
105
+ export declare function signApnsJwt(key: ApnsProviderTokenOptions, nowMs?: number): Promise<string>;
60
106
  /** The `.p8` as it is usually shipped in a secret: base64 of the file. */
61
107
  export declare function apnsKeyFromBase64(value: string): string;
62
108
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"apns.d.mts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;iBAiBiB;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;EAOA;EACA,QAAQ;;;;;;EAMR;;EAEA,eAAe;;EAEf,kBAAkB,SAAS,oBAAoB;;qBAGpC;WACX;WACA;;qBAcW,0BAA0B;mBAGR;UAFrB;EAER,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;UAsDvC;;;wBAYA,WAAW;;wBAUL,YACpB,KAAK,KAAK,qDACV,iBACC;;wBAoBa,kBAAkB"}
1
+ {"version":3,"file":"apns.d.mts","names":[],"sources":["../src/apns.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAmBiB;;EAEf;;EAEA;;EAEA;;iBAGe;;EAEf,aAAa;;EAEb;;;;;;;EAOA;EACA,QAAQ;;;;;;EAMR;;EAEA,eAAe;;qBAGJ;WACX;WACA;;;;;;;;;;;;;;;;;;;;;qBAsBW,oBAAoB;;;;;;;;wBAsBX,cAAc,yBAAyB;;;;;;;;qBAqBhD;mBAIkB;UAHrB;UACA;EAER,YAA6B,SAAS;;EAGhC,MAAM,eAAmB;;EAgB/B;;qBAKW,0BAA0B;mBACR;EAA7B,YAA6B,SAAS;EAEhC,KAAK,SAAS,kBAAkB,eAAe;;;wBAkFvC,WAAW;;wBAUL,YACpB,KAAK,0BACL,iBACC;;wBAoBa,kBAAkB"}
package/dist/apns.mjs CHANGED
@@ -1,21 +1,93 @@
1
+ import { PermanentError, isRetryableStatus } from "./engine/retry.mjs";
1
2
  import { base64url, pemToDer } from "./jwt.mjs";
2
3
  //#region src/apns.ts
3
4
  const APNS_HOSTS = {
4
5
  sandbox: "https://api.sandbox.push.apple.com",
5
6
  production: "https://api.push.apple.com"
6
7
  };
7
- /** Reasons APNs gives for a token that will never deliver again — not worth a retry. */
8
- const DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
9
- "Unregistered",
10
- "BadDeviceToken",
8
+ /**
9
+ * How an APNs answer is classified, after Apple's response table
10
+ * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):
11
+ *
12
+ * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the
13
+ * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles
14
+ * undelivered and the host forgets the token.
15
+ * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but
16
+ * just as well for a token of the other environment (a sandbox build pushed through the
17
+ * production host), which would answer for every device at once; the copy settles with the
18
+ * reason, so the report shows it, and nothing is deleted on a guess.
19
+ * - configuration: the topic, certificate or provider key is wrong. Every device on this app
20
+ * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,
21
+ * the deployment is not.
22
+ * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,
23
+ * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.
24
+ * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,
25
+ * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.
26
+ */
27
+ const DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set(["Unregistered", "ExpiredToken"]);
28
+ /** The app's topic, certificate or provider key is wrong: the request is refused for every device. */
29
+ const CONFIGURATION_REASONS = /* @__PURE__ */ new Set([
11
30
  "DeviceTokenNotForTopic",
12
- "ExpiredToken"
31
+ "TopicDisallowed",
32
+ "BadTopic",
33
+ "MissingTopic",
34
+ "BadCertificate",
35
+ "BadCertificateEnvironment",
36
+ "Forbidden",
37
+ "InvalidProviderToken",
38
+ "MissingProviderToken"
13
39
  ]);
40
+ /**
41
+ * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the
42
+ * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the
43
+ * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —
44
+ * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose
45
+ * the node id at its end, and two nodes of one instance would collapse into one notification.
46
+ */
47
+ async function apnsIdFromKey(idempotencyKey) {
48
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(idempotencyKey))).slice(0, 16);
49
+ bytes[6] = bytes[6] & 15 | 128;
50
+ bytes[8] = bytes[8] & 63 | 128;
51
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
52
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
53
+ }
14
54
  /** Apple: reuse a provider token for at least 20 minutes, never past 60. */
15
55
  const TOKEN_TTL_MS = 3e6;
56
+ /**
57
+ * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple
58
+ * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent
59
+ * callers on a fresh or aged token share the one signing in flight, and a signing that failed is
60
+ * not kept, so the next call tries again. One instance serves every sender built on the key — a
61
+ * host keeps it across runs, and builds the senders per run.
62
+ */
63
+ var ApnsProviderToken = class {
64
+ options;
65
+ signed;
66
+ signing;
67
+ constructor(options) {
68
+ this.options = options;
69
+ }
70
+ /** The current token, signed now if there is none or it has aged out. */
71
+ async token(now = Date.now()) {
72
+ if (this.signed !== void 0 && now - this.signed.issuedAt < TOKEN_TTL_MS) return this.signed.value;
73
+ this.signing ??= signApnsJwt(this.options, now).then((value) => {
74
+ this.signed = {
75
+ value,
76
+ issuedAt: now
77
+ };
78
+ return value;
79
+ }).finally(() => {
80
+ this.signing = void 0;
81
+ });
82
+ return this.signing;
83
+ }
84
+ /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */
85
+ invalidate() {
86
+ this.signed = void 0;
87
+ }
88
+ };
16
89
  var ApnsPushSender = class {
17
90
  options;
18
- token;
19
91
  constructor(options) {
20
92
  this.options = options;
21
93
  }
@@ -26,13 +98,17 @@ var ApnsPushSender = class {
26
98
  const mutable = image !== void 0 || message.tracking?.data?.delivered_url !== void 0;
27
99
  const upstream = APNS_HOSTS[this.options.environment];
28
100
  const origin = this.options.origin ?? upstream;
29
- const response = await (this.options.fetch ?? fetch)(`${origin}/3/device/${message.recipient}`, {
101
+ const doFetch = this.options.fetch ?? fetch;
102
+ const copyId = await apnsIdFromKey(message.idempotencyKey);
103
+ const response = await doFetch(`${origin}/3/device/${message.recipient}`, {
30
104
  method: "POST",
31
105
  headers: {
32
- authorization: `bearer ${await this.providerToken()}`,
106
+ authorization: `bearer ${await this.options.credentials.token()}`,
33
107
  "apns-topic": this.options.topic,
34
108
  "apns-push-type": "alert",
35
109
  "apns-priority": "10",
110
+ "apns-id": copyId,
111
+ "apns-collapse-id": copyId,
36
112
  "content-type": "application/json",
37
113
  ...this.options.origin === void 0 ? {} : { "apns-relay-upstream": upstream }
38
114
  },
@@ -54,26 +130,29 @@ var ApnsPushSender = class {
54
130
  });
55
131
  if (response.ok) return;
56
132
  const text = await response.text();
57
- const reason = apnsReason(text);
58
- if (response.status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
59
- console.warn(`[apns] token gone for user ${message.userId}: ${reason || response.status}`);
60
- await this.options.onUnregistered?.(message);
133
+ const { status } = response;
134
+ const reason = apnsReason(text) || (status === 410 ? "Unregistered" : "");
135
+ if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {
136
+ console.warn(`[apns] token gone for user ${message.userId}: ${reason}`);
61
137
  return {
62
138
  delivered: false,
63
- reason: reason || `HTTP ${response.status}`
139
+ reason
64
140
  };
65
141
  }
66
- throw new Error(`APNs send failed: ${response.status} ${text}`);
67
- }
68
- async providerToken() {
69
- const now = Date.now();
70
- if (this.token !== void 0 && now - this.token.issuedAt < TOKEN_TTL_MS) return this.token.value;
71
- const value = await signApnsJwt(this.options, now);
72
- this.token = {
73
- value,
74
- issuedAt: now
75
- };
76
- return value;
142
+ if (reason === "BadDeviceToken") {
143
+ console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);
144
+ return {
145
+ delivered: false,
146
+ reason
147
+ };
148
+ }
149
+ if (reason === "ExpiredProviderToken") {
150
+ this.options.credentials.invalidate();
151
+ throw new Error(`APNs send failed: ${status} ${reason} (provider token renewed for the retry)`);
152
+ }
153
+ if (CONFIGURATION_REASONS.has(reason)) throw new PermanentError(`APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`);
154
+ if (isRetryableStatus(status)) throw new Error(`APNs send failed: ${status} ${reason || text}`);
155
+ throw new PermanentError(`APNs refused the request: ${status} ${reason || text}`);
77
156
  }
78
157
  };
79
158
  /** APNs error bodies are `{ "reason": "…" }`. */
@@ -106,11 +185,16 @@ async function signApnsJwt(key, nowMs = Date.now()) {
106
185
  }
107
186
  /** The `.p8` as it is usually shipped in a secret: base64 of the file. */
108
187
  function apnsKeyFromBase64(value) {
109
- const pem = atob(value);
110
- if (!pem.includes("-----BEGIN PRIVATE KEY-----")) throw new Error("APNs key is not a PKCS #8 PEM (.p8) file");
188
+ let pem;
189
+ try {
190
+ pem = atob(value);
191
+ } catch (error) {
192
+ throw new PermanentError("APNs key is not base64 (expected the .p8 file, base64-encoded)", { cause: error });
193
+ }
194
+ if (!pem.includes("-----BEGIN PRIVATE KEY-----")) throw new PermanentError("APNs key is not a PKCS #8 PEM (.p8) file");
111
195
  return pem;
112
196
  }
113
197
  //#endregion
114
- export { APNS_HOSTS, ApnsPushSender, apnsKeyFromBase64, apnsReason, signApnsJwt };
198
+ export { APNS_HOSTS, ApnsProviderToken, ApnsPushSender, DEAD_TOKEN_REASONS, apnsIdFromKey, apnsKeyFromBase64, apnsReason, signApnsJwt };
115
199
 
116
200
  //# sourceMappingURL=apns.mjs.map
package/dist/apns.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"apns.mjs","names":[],"sources":["../src/apns.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\nexport interface ApnsPushOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n /** Called for a token APNs reports as gone (`Unregistered`, `BadDeviceToken`); `channelsFromConfig` passes it on to the DeviceDirectory's `disable`. */\n onUnregistered?: (message: OutboundMessage) => Promise<void> | void;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/** Reasons APNs gives for a token that will never deliver again — not worth a retry. */\nconst DEAD_TOKEN_REASONS = new Set([\n 'Unregistered',\n 'BadDeviceToken',\n 'DeviceTokenNotForTopic',\n 'ExpiredToken',\n]);\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\nexport class ApnsPushSender implements MessageSender {\n private token: { value: string; issuedAt: number } | undefined;\n\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.providerToken()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const reason = apnsReason(text);\n if (response.status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[apns] token gone for user ${message.userId}: ${reason || response.status}`);\n await this.options.onUnregistered?.(message);\n return { delivered: false, reason: reason || `HTTP ${response.status}` };\n }\n throw new Error(`APNs send failed: ${response.status} ${text}`);\n }\n\n private async providerToken(): Promise<string> {\n const now = Date.now();\n if (this.token !== undefined && now - this.token.issuedAt < TOKEN_TTL_MS) {\n return this.token.value;\n }\n const value = await signApnsJwt(this.options, now);\n this.token = { value, issuedAt: now };\n return value;\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: Pick<ApnsPushOptions, 'keyId' | 'teamId' | 'privateKey'>,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n const pem = atob(value);\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new Error('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"apns.mjs","names":[],"sources":["../src/apns.ts"],"sourcesContent":["import type { MessageSender, OutboundMessage, SendResult } from './engine';\nimport { PermanentError, isRetryableStatus } from './engine/retry';\nimport { base64url, pemToDer } from './jwt';\nimport type { PushRenderer } from './push';\n\n/**\n * `@shware/workflow/apns` — iOS push delivery straight to the Apple Push\n * Notification service, token-based (an APNs auth key, the `.p8` file from the\n * developer portal). No Firebase in between: the app registers the APNs device\n * token in its own device table, and the host's DeviceDirectory hands it to the\n * engine, which sends one message per device.\n * No dependencies: the provider JWT is ES256-signed with WebCrypto.\n *\n * APNs speaks HTTP/2 only. A deployed Worker's `fetch` negotiates it, but\n * workerd under `wrangler dev` does not (the connection is dropped), so a local\n * host points `origin` at the Node relay in `@shware/workflow/apns-relay`.\n */\n\n/** The APNs auth key — the `.p8` file from the developer portal, and the ids that name it. */\nexport interface ApnsProviderTokenOptions {\n /** The key's 10-character id, shown next to it in the developer portal. */\n keyId: string;\n /** The Apple Developer team id. */\n teamId: string;\n /** The `.p8` file's contents: PEM, `-----BEGIN PRIVATE KEY-----` (PKCS #8, P-256). */\n privateKey: string;\n}\n\nexport interface ApnsPushOptions {\n /** The provider token the requests are signed with — shared by every sender on the same key. */\n credentials: ApnsProviderToken;\n /** The app's bundle id — APNs's `apns-topic`. */\n topic: string;\n /**\n * Which APNs environment the device tokens come from: `sandbox` for builds\n * signed with `aps-environment: development` (Xcode, the simulator),\n * `production` for App Store and TestFlight builds. A token from one is\n * `BadDeviceToken` on the other.\n */\n environment: 'sandbox' | 'production';\n render: PushRenderer;\n /**\n * POST here instead of Apple's host — the dev relay, which forwards over\n * HTTP/2. The real host still travels along (`apns-relay-upstream`), so the\n * relay needs no configuration of its own.\n */\n origin?: string;\n /** Fetch implementation (defaults to the global one). */\n fetch?: typeof fetch;\n}\n\nexport const APNS_HOSTS = {\n sandbox: 'https://api.sandbox.push.apple.com',\n production: 'https://api.push.apple.com',\n} as const;\n\n/**\n * How an APNs answer is classified, after Apple's response table\n * (https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns):\n *\n * - dead token: HTTP 410 `Unregistered` (the app was removed, or the token is inactive for the\n * topic) and 400 `ExpiredToken`. The device will never deliver again, so the copy settles\n * undelivered and the host forgets the token.\n * - undelivered, device kept: 400 `BadDeviceToken`. Apple gives it for a malformed token but\n * just as well for a token of the other environment (a sandbox build pushed through the\n * production host), which would answer for every device at once; the copy settles with the\n * reason, so the report shows it, and nothing is deleted on a guess.\n * - configuration: the topic, certificate or provider key is wrong. Every device on this app\n * would answer the same, so it is a `PermanentError` and the devices are kept — they are fine,\n * the deployment is not.\n * - transient: 429 (`TooManyRequests`, `TooManyProviderTokenUpdates`), 5xx (`InternalServerError`,\n * `ServiceUnavailable`, `Shutdown`) and 403 `ExpiredProviderToken`, which a fresh token fixes.\n * - everything else (400 `BadMessageId`, `BadPriority`, `PayloadEmpty`, 413 `PayloadTooLarge`,\n * 404 `BadPath`, 405 `MethodNotAllowed`, …) is this request itself: a `PermanentError`.\n */\nexport const DEAD_TOKEN_REASONS: ReadonlySet<string> = new Set(['Unregistered', 'ExpiredToken']);\n\n/** The app's topic, certificate or provider key is wrong: the request is refused for every device. */\nconst CONFIGURATION_REASONS: ReadonlySet<string> = new Set([\n 'DeviceTokenNotForTopic',\n 'TopicDisallowed',\n 'BadTopic',\n 'MissingTopic',\n 'BadCertificate',\n 'BadCertificateEnvironment',\n 'Forbidden',\n 'InvalidProviderToken',\n 'MissingProviderToken',\n]);\n\n/**\n * The copy's identity on the wire: a canonical UUID derived from the idempotency key (SHA-256, the\n * first 16 bytes laid out as an RFC 9562 v8 UUID). The same on every attempt, so a resend of the\n * same copy carries the same `apns-id` and — as `apns-collapse-id`, which is limited to 64 bytes —\n * collapses onto the first notification. The whole key is hashed: cut to 64 bytes it would lose\n * the node id at its end, and two nodes of one instance would collapse into one notification.\n */\nexport async function apnsIdFromKey(idempotencyKey: string): Promise<string> {\n const digest = new Uint8Array(\n await crypto.subtle.digest('SHA-256', new TextEncoder().encode(idempotencyKey))\n );\n const bytes = digest.slice(0, 16);\n bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8: custom, deterministic\n bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\n/** Apple: reuse a provider token for at least 20 minutes, never past 60. */\nconst TOKEN_TTL_MS = 50 * 60_000;\n\n/**\n * The provider JWT for one APNs auth key, signed once and reused for `TOKEN_TTL_MS`: Apple\n * answers a sender that re-signs on every push with 429 `TooManyProviderTokenUpdates`. Concurrent\n * callers on a fresh or aged token share the one signing in flight, and a signing that failed is\n * not kept, so the next call tries again. One instance serves every sender built on the key — a\n * host keeps it across runs, and builds the senders per run.\n */\nexport class ApnsProviderToken {\n private signed: { value: string; issuedAt: number } | undefined;\n private signing: Promise<string> | undefined;\n\n constructor(private readonly options: ApnsProviderTokenOptions) {}\n\n /** The current token, signed now if there is none or it has aged out. */\n async token(now = Date.now()): Promise<string> {\n if (this.signed !== undefined && now - this.signed.issuedAt < TOKEN_TTL_MS) {\n return this.signed.value;\n }\n this.signing ??= signApnsJwt(this.options, now)\n .then((value) => {\n this.signed = { value, issuedAt: now };\n return value;\n })\n .finally(() => {\n this.signing = undefined;\n });\n return this.signing;\n }\n\n /** Forget the token: APNs refused it (`ExpiredProviderToken`), so the next call signs a new one. */\n invalidate(): void {\n this.signed = undefined;\n }\n}\n\nexport class ApnsPushSender implements MessageSender {\n constructor(private readonly options: ApnsPushOptions) {}\n\n async send(message: OutboundMessage): Promise<void | SendResult> {\n if (message.channel !== 'push') {\n throw new Error(`ApnsPushSender: unsupported channel '${message.channel}'`);\n }\n if (message.recipient === undefined) {\n throw new Error(`ApnsPushSender: no push token for user '${message.userId}'`);\n }\n const { title, body, image, url } = await this.options.render(message.template, message.props);\n // A notification service extension runs only for a mutable push: it attaches the image and\n // reports the device delivery (`delivered_url`)\n const mutable = image !== undefined || message.tracking?.data?.delivered_url !== undefined;\n const upstream = APNS_HOSTS[this.options.environment];\n const origin = this.options.origin ?? upstream;\n const doFetch = this.options.fetch ?? fetch;\n\n const copyId = await apnsIdFromKey(message.idempotencyKey);\n const response = await doFetch(`${origin}/3/device/${message.recipient}`, {\n method: 'POST',\n headers: {\n authorization: `bearer ${await this.options.credentials.token()}`,\n 'apns-topic': this.options.topic,\n 'apns-push-type': 'alert',\n 'apns-priority': '10',\n // A resend of the same copy (an at-least-once retry after APNs accepted the first attempt)\n // carries the same id and collapses onto the first notification instead of showing twice\n 'apns-id': copyId,\n 'apns-collapse-id': copyId,\n 'content-type': 'application/json',\n ...(this.options.origin === undefined ? {} : { 'apns-relay-upstream': upstream }),\n },\n body: JSON.stringify({\n aps: {\n alert: { title, body },\n sound: 'default',\n ...(mutable ? { 'mutable-content': 1 } : {}),\n },\n // The template key travels so the app can route a tap; the idempotency\n // key lets the app de-duplicate a redelivered push.\n template: message.template,\n idempotencyKey: message.idempotencyKey,\n ...(image === undefined ? {} : { image }),\n ...(url === undefined ? {} : { url }),\n ...message.tracking?.data,\n }),\n });\n if (response.ok) return;\n\n const text = await response.text();\n const { status } = response;\n // A 410 without a body is still \"unregistered\": settle it under that reason\n const reason = apnsReason(text) || (status === 410 ? 'Unregistered' : '');\n if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) {\n // A dead token is a fact about the recipient, not a transient failure:\n // report it and move on, so the step does not retry into the same wall.\n console.warn(`[apns] token gone for user ${message.userId}: ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'BadDeviceToken') {\n // The token or the environment: settled so the report says which device, nothing deleted\n console.warn(`[apns] refused for user ${message.userId}: ${status} ${reason}`);\n return { delivered: false, reason };\n }\n if (reason === 'ExpiredProviderToken') {\n // The token aged out (clock skew, a long-lived isolate): sign a new one on the retry\n this.options.credentials.invalidate();\n throw new Error(\n `APNs send failed: ${status} ${reason} (provider token renewed for the retry)`\n );\n }\n if (CONFIGURATION_REASONS.has(reason)) {\n throw new PermanentError(\n `APNs refused the request for topic '${this.options.topic}' (${this.options.environment}): ${status} ${reason}`\n );\n }\n if (isRetryableStatus(status)) {\n throw new Error(`APNs send failed: ${status} ${reason || text}`);\n }\n throw new PermanentError(`APNs refused the request: ${status} ${reason || text}`);\n }\n}\n\n/** APNs error bodies are `{ \"reason\": \"…\" }`. */\nexport function apnsReason(body: string): string {\n try {\n const parsed = JSON.parse(body) as { reason?: string };\n return parsed.reason ?? '';\n } catch {\n return '';\n }\n}\n\n/** ES256-sign the APNs provider token: header `kid`, claims `iss` (team) and `iat`. */\nexport async function signApnsJwt(\n key: ApnsProviderTokenOptions,\n nowMs = Date.now()\n): Promise<string> {\n const header = base64url(JSON.stringify({ alg: 'ES256', kid: key.keyId }));\n const claims = base64url(JSON.stringify({ iss: key.teamId, iat: Math.floor(nowMs / 1000) }));\n const cryptoKey = await crypto.subtle.importKey(\n 'pkcs8',\n pemToDer(key.privateKey),\n { name: 'ECDSA', namedCurve: 'P-256' },\n false,\n ['sign']\n );\n // WebCrypto's ECDSA signature is the raw r‖s pair, which is exactly JWS's ES256 encoding.\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n cryptoKey,\n new TextEncoder().encode(`${header}.${claims}`)\n );\n return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;\n}\n\n/** The `.p8` as it is usually shipped in a secret: base64 of the file. */\nexport function apnsKeyFromBase64(value: string): string {\n let pem: string;\n try {\n pem = atob(value);\n } catch (error) {\n // A secret that is not base64 is the same on every attempt: not worth a retry\n throw new PermanentError('APNs key is not base64 (expected the .p8 file, base64-encoded)', {\n cause: error,\n });\n }\n if (!pem.includes('-----BEGIN PRIVATE KEY-----')) {\n throw new PermanentError('APNs key is not a PKCS #8 PEM (.p8) file');\n }\n return pem;\n}\n"],"mappings":"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"}
@@ -44,10 +44,6 @@ var AwsEngineStep = class {
44
44
  if (!(ms > 0)) return;
45
45
  await this.context.wait(name, { seconds: Math.ceil(ms / 1e3) });
46
46
  }
47
- async sleepUntil(name, timestampMs) {
48
- const remaining = await this.context.step(`${name}:remaining`, async () => timestampMs - Date.now());
49
- await this.sleep(name, remaining);
50
- }
51
47
  async subscribe(name, events, options = {}) {
52
48
  const generation = ++this.generation;
53
49
  const { deadlineMs } = options;
@@ -82,7 +78,11 @@ var AwsEngineStep = class {
82
78
  if (armed !== void 0) armed.settled = true;
83
79
  this.armed = void 0;
84
80
  }
85
- async waitForWake(name, _timeoutMs) {
81
+ /**
82
+ * The timeout is ignored on purpose: the callback's own timeout was set from the deadline
83
+ * when it was armed (`subscribe`), and only its expiry says the wait timed out.
84
+ */
85
+ async waitForWake(name, _wait) {
86
86
  const armed = this.armed;
87
87
  if (armed === void 0) throw new Error(`waitForWake '${name}' without a subscribe: nothing is armed to wake it`);
88
88
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"engine-step.cjs","names":["isRetryable","CallbackTimeoutError"],"sources":["../../src/aws/engine-step.ts"],"sourcesContent":["import {\n CallbackTimeoutError,\n type DurableContext,\n type DurablePromise,\n type RetryDecision,\n} from '@aws/durable-execution-sdk-js';\nimport type { EngineStep } from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport type { ExecutionRegistry, JourneyStore } from '../store/index';\nimport type { LambdaApi } from './lambda-api';\n\n/**\n * EngineStep over a Lambda durable execution.\n *\n * Steps, sleeps and wakes map onto the SDK's `step`, `wait` and callbacks. A wake handle is a\n * callback, which is one-shot, so every wait arms a fresh one (the interpreter subscribes on\n * every attempt): created where the durable context allows it — outside any step, so its id\n * replays — then recorded in Postgres together with the subscription rows, numbered by a\n * generation so a late wake or cleanup of an older wait cannot touch a newer one. The callback\n * is only a hint; the facts the wait re-checks are Postgres'.\n */\n\n/** What the step retries, bounded: 6 attempts with exponential backoff up to a minute. */\nexport function stepRetry(error: Error, attempt: number): RetryDecision {\n if (!isRetryable(error) || attempt >= 6) return { shouldRetry: false };\n return { shouldRetry: true, delay: { seconds: Math.min(60, 2 ** attempt) } };\n}\n\ninterface Armed {\n promise: DurablePromise<string>;\n callbackId: string;\n generation: number;\n /** The wait consumed it (woken or timed out): there is nothing left to close. */\n settled: boolean;\n}\n\nexport class AwsEngineStep implements EngineStep {\n private generation = 0;\n private armed: Armed | undefined;\n\n constructor(\n private readonly context: DurableContext,\n private readonly store: JourneyStore,\n private readonly executions: ExecutionRegistry,\n private readonly lambda: LambdaApi,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.context.step(name, () => fn(), { retryStrategy: stepRetry });\n }\n\n async sleep(name: string, ms: number): Promise<void> {\n // A wait is whole seconds: rounded up, so it never ends early; nothing to wait at zero\n if (!(ms > 0)) return;\n await this.context.wait(name, { seconds: Math.ceil(ms / 1000) });\n }\n\n async sleepUntil(name: string, timestampMs: number): Promise<void> {\n // The remaining time is checkpointed: a replay must not recompute it from a later clock\n const remaining = await this.context.step(\n `${name}:remaining`,\n async () => timestampMs - Date.now()\n );\n await this.sleep(name, remaining);\n }\n\n async subscribe(\n name: string,\n events: readonly string[],\n options: { deadlineMs?: number } = {}\n ): Promise<void> {\n const generation = ++this.generation;\n const { deadlineMs } = options;\n // The callback's own timeout is the wait's deadline: fixed when it is created\n const seconds =\n deadlineMs === undefined\n ? undefined\n : await this.context.step(`${name}:timeout`, async () =>\n Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))\n );\n const [promise, callbackId] = await this.context.createCallback(\n `${name}:callback`,\n seconds === undefined ? {} : { timeout: { seconds } }\n );\n const previous = this.armed;\n this.armed = { promise, callbackId, generation, settled: false };\n await this.context.step(\n name,\n async () => {\n // Rows first, then the callback: a wake that lands in between finds the rows and the\n // callback of the last wait — its completion is refused, and this wait checks the facts\n await this.store.subscribe(this.instanceId, this.userId, events, Date.now());\n await this.executions.armCallback(this.instanceId, {\n id: callbackId,\n generation,\n deadlineAt: deadlineMs,\n });\n if (previous !== undefined && !previous.settled) {\n await this.lambda.completeCallback(previous.callbackId, '{}');\n }\n },\n { retryStrategy: stepRetry }\n );\n if (previous !== undefined) previous.settled = true;\n }\n\n async unsubscribe(name: string): Promise<void> {\n const armed = this.armed;\n await this.context.step(\n name,\n async () => {\n await this.store.unsubscribe(this.instanceId);\n if (armed === undefined) return;\n await this.executions.clearCallback(this.instanceId, armed.generation);\n // The condition held before the wait parked: its callback is closed through the public\n // API (a closed one answers false), rather than left open until it times out\n if (!armed.settled) await this.lambda.completeCallback(armed.callbackId, '{}');\n },\n { retryStrategy: stepRetry }\n );\n if (armed !== undefined) armed.settled = true;\n this.armed = undefined;\n }\n\n async waitForWake(name: string, _timeoutMs: number): Promise<'event' | 'timeout'> {\n const armed = this.armed;\n if (armed === undefined) {\n throw new Error(`waitForWake '${name}' without a subscribe: nothing is armed to wake it`);\n }\n try {\n await armed.promise;\n return 'event';\n } catch (error) {\n // Only the callback's own expiry is a timeout; any other failure is a failure\n if (error instanceof CallbackTimeoutError) return 'timeout';\n throw error;\n } finally {\n armed.settled = true;\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAuBA,SAAgB,UAAU,OAAc,SAAgC;CACtE,IAAI,CAACA,qBAAAA,YAAY,KAAK,KAAK,WAAW,GAAG,OAAO,EAAE,aAAa,MAAM;CACrE,OAAO;EAAE,aAAa;EAAM,OAAO,EAAE,SAAS,KAAK,IAAI,IAAI,KAAK,OAAO,EAAE;CAAE;AAC7E;AAUA,IAAa,gBAAb,MAAiD;CAK5B;CACA;CACA;CACA;CACA;CACA;CATnB,aAAqB;CACrB;CAEA,YACE,SACA,OACA,YACA,QACA,QACA,YACA;EANiB,KAAA,UAAA;EACA,KAAA,QAAA;EACA,KAAA,aAAA;EACA,KAAA,SAAA;EACA,KAAA,SAAA;EACA,KAAA,aAAA;CAChB;CAEH,GAAM,MAAc,IAAkC;EACpD,OAAO,KAAK,QAAQ,KAAK,YAAY,GAAG,GAAG,EAAE,eAAe,UAAU,CAAC;CACzE;CAEA,MAAM,MAAM,MAAc,IAA2B;EAEnD,IAAI,EAAE,KAAK,IAAI;EACf,MAAM,KAAK,QAAQ,KAAK,MAAM,EAAE,SAAS,KAAK,KAAK,KAAK,GAAI,EAAE,CAAC;CACjE;CAEA,MAAM,WAAW,MAAc,aAAoC;EAEjE,MAAM,YAAY,MAAM,KAAK,QAAQ,KACnC,GAAG,KAAK,aACR,YAAY,cAAc,KAAK,IAAI,CACrC;EACA,MAAM,KAAK,MAAM,MAAM,SAAS;CAClC;CAEA,MAAM,UACJ,MACA,QACA,UAAmC,CAAC,GACrB;EACf,MAAM,aAAa,EAAE,KAAK;EAC1B,MAAM,EAAE,eAAe;EAEvB,MAAM,UACJ,eAAe,KAAA,IACX,KAAA,IACA,MAAM,KAAK,QAAQ,KAAK,GAAG,KAAK,WAAW,YACzC,KAAK,IAAI,GAAG,KAAK,MAAM,aAAa,KAAK,IAAI,KAAK,GAAI,CAAC,CACzD;EACN,MAAM,CAAC,SAAS,cAAc,MAAM,KAAK,QAAQ,eAC/C,GAAG,KAAK,YACR,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CACtD;EACA,MAAM,WAAW,KAAK;EACtB,KAAK,QAAQ;GAAE;GAAS;GAAY;GAAY,SAAS;EAAM;EAC/D,MAAM,KAAK,QAAQ,KACjB,MACA,YAAY;GAGV,MAAM,KAAK,MAAM,UAAU,KAAK,YAAY,KAAK,QAAQ,QAAQ,KAAK,IAAI,CAAC;GAC3E,MAAM,KAAK,WAAW,YAAY,KAAK,YAAY;IACjD,IAAI;IACJ;IACA,YAAY;GACd,CAAC;GACD,IAAI,aAAa,KAAA,KAAa,CAAC,SAAS,SACtC,MAAM,KAAK,OAAO,iBAAiB,SAAS,YAAY,IAAI;EAEhE,GACA,EAAE,eAAe,UAAU,CAC7B;EACA,IAAI,aAAa,KAAA,GAAW,SAAS,UAAU;CACjD;CAEA,MAAM,YAAY,MAA6B;EAC7C,MAAM,QAAQ,KAAK;EACnB,MAAM,KAAK,QAAQ,KACjB,MACA,YAAY;GACV,MAAM,KAAK,MAAM,YAAY,KAAK,UAAU;GAC5C,IAAI,UAAU,KAAA,GAAW;GACzB,MAAM,KAAK,WAAW,cAAc,KAAK,YAAY,MAAM,UAAU;GAGrE,IAAI,CAAC,MAAM,SAAS,MAAM,KAAK,OAAO,iBAAiB,MAAM,YAAY,IAAI;EAC/E,GACA,EAAE,eAAe,UAAU,CAC7B;EACA,IAAI,UAAU,KAAA,GAAW,MAAM,UAAU;EACzC,KAAK,QAAQ,KAAA;CACf;CAEA,MAAM,YAAY,MAAc,YAAkD;EAChF,MAAM,QAAQ,KAAK;EACnB,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,gBAAgB,KAAK,mDAAmD;EAE1F,IAAI;GACF,MAAM,MAAM;GACZ,OAAO;EACT,SAAS,OAAO;GAEd,IAAI,iBAAiBC,8BAAAA,sBAAsB,OAAO;GAClD,MAAM;EACR,UAAU;GACR,MAAM,UAAU;EAClB;CACF;AACF"}
1
+ {"version":3,"file":"engine-step.cjs","names":["isRetryable","CallbackTimeoutError"],"sources":["../../src/aws/engine-step.ts"],"sourcesContent":["import {\n CallbackTimeoutError,\n type DurableContext,\n type DurablePromise,\n type RetryDecision,\n} from '@aws/durable-execution-sdk-js';\nimport type { EngineStep } from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport type { ExecutionRegistry, JourneyStore } from '../store/index';\nimport type { LambdaApi } from './lambda-api';\n\n/**\n * EngineStep over a Lambda durable execution.\n *\n * Steps, sleeps and wakes map onto the SDK's `step`, `wait` and callbacks. A wake handle is a\n * callback, which is one-shot, so every wait arms a fresh one (the interpreter subscribes on\n * every attempt): created where the durable context allows it — outside any step, so its id\n * replays — then recorded in Postgres together with the subscription rows, numbered by a\n * generation so a late wake or cleanup of an older wait cannot touch a newer one. The callback\n * is only a hint; the facts the wait re-checks are Postgres'.\n */\n\n/** What the step retries, bounded: 6 attempts with exponential backoff up to a minute. */\nexport function stepRetry(error: Error, attempt: number): RetryDecision {\n if (!isRetryable(error) || attempt >= 6) return { shouldRetry: false };\n return { shouldRetry: true, delay: { seconds: Math.min(60, 2 ** attempt) } };\n}\n\ninterface Armed {\n promise: DurablePromise<string>;\n callbackId: string;\n generation: number;\n /** The wait consumed it (woken or timed out): there is nothing left to close. */\n settled: boolean;\n}\n\nexport class AwsEngineStep implements EngineStep {\n private generation = 0;\n private armed: Armed | undefined;\n\n constructor(\n private readonly context: DurableContext,\n private readonly store: JourneyStore,\n private readonly executions: ExecutionRegistry,\n private readonly lambda: LambdaApi,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.context.step(name, () => fn(), { retryStrategy: stepRetry });\n }\n\n async sleep(name: string, ms: number): Promise<void> {\n // A wait is whole seconds: rounded up, so it never ends early; nothing to wait at zero\n if (!(ms > 0)) return;\n await this.context.wait(name, { seconds: Math.ceil(ms / 1000) });\n }\n\n async subscribe(\n name: string,\n events: readonly string[],\n options: { deadlineMs?: number } = {}\n ): Promise<void> {\n const generation = ++this.generation;\n const { deadlineMs } = options;\n // The callback's own timeout is the wait's deadline: fixed when it is created\n const seconds =\n deadlineMs === undefined\n ? undefined\n : await this.context.step(`${name}:timeout`, async () =>\n Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))\n );\n const [promise, callbackId] = await this.context.createCallback(\n `${name}:callback`,\n seconds === undefined ? {} : { timeout: { seconds } }\n );\n const previous = this.armed;\n this.armed = { promise, callbackId, generation, settled: false };\n await this.context.step(\n name,\n async () => {\n // Rows first, then the callback: a wake that lands in between finds the rows and the\n // callback of the last wait — its completion is refused, and this wait checks the facts\n await this.store.subscribe(this.instanceId, this.userId, events, Date.now());\n await this.executions.armCallback(this.instanceId, {\n id: callbackId,\n generation,\n deadlineAt: deadlineMs,\n });\n if (previous !== undefined && !previous.settled) {\n await this.lambda.completeCallback(previous.callbackId, '{}');\n }\n },\n { retryStrategy: stepRetry }\n );\n if (previous !== undefined) previous.settled = true;\n }\n\n async unsubscribe(name: string): Promise<void> {\n const armed = this.armed;\n await this.context.step(\n name,\n async () => {\n await this.store.unsubscribe(this.instanceId);\n if (armed === undefined) return;\n await this.executions.clearCallback(this.instanceId, armed.generation);\n // The condition held before the wait parked: its callback is closed through the public\n // API (a closed one answers false), rather than left open until it times out\n if (!armed.settled) await this.lambda.completeCallback(armed.callbackId, '{}');\n },\n { retryStrategy: stepRetry }\n );\n if (armed !== undefined) armed.settled = true;\n this.armed = undefined;\n }\n\n /**\n * The timeout is ignored on purpose: the callback's own timeout was set from the deadline\n * when it was armed (`subscribe`), and only its expiry says the wait timed out.\n */\n async waitForWake(\n name: string,\n _wait: { timeoutMs: number; deadlineMs: number }\n ): Promise<'event' | 'timeout'> {\n const armed = this.armed;\n if (armed === undefined) {\n throw new Error(`waitForWake '${name}' without a subscribe: nothing is armed to wake it`);\n }\n try {\n await armed.promise;\n return 'event';\n } catch (error) {\n // Only the callback's own expiry is a timeout; any other failure is a failure\n if (error instanceof CallbackTimeoutError) return 'timeout';\n throw error;\n } finally {\n armed.settled = true;\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAuBA,SAAgB,UAAU,OAAc,SAAgC;CACtE,IAAI,CAACA,qBAAAA,YAAY,KAAK,KAAK,WAAW,GAAG,OAAO,EAAE,aAAa,MAAM;CACrE,OAAO;EAAE,aAAa;EAAM,OAAO,EAAE,SAAS,KAAK,IAAI,IAAI,KAAK,OAAO,EAAE;CAAE;AAC7E;AAUA,IAAa,gBAAb,MAAiD;CAK5B;CACA;CACA;CACA;CACA;CACA;CATnB,aAAqB;CACrB;CAEA,YACE,SACA,OACA,YACA,QACA,QACA,YACA;EANiB,KAAA,UAAA;EACA,KAAA,QAAA;EACA,KAAA,aAAA;EACA,KAAA,SAAA;EACA,KAAA,SAAA;EACA,KAAA,aAAA;CAChB;CAEH,GAAM,MAAc,IAAkC;EACpD,OAAO,KAAK,QAAQ,KAAK,YAAY,GAAG,GAAG,EAAE,eAAe,UAAU,CAAC;CACzE;CAEA,MAAM,MAAM,MAAc,IAA2B;EAEnD,IAAI,EAAE,KAAK,IAAI;EACf,MAAM,KAAK,QAAQ,KAAK,MAAM,EAAE,SAAS,KAAK,KAAK,KAAK,GAAI,EAAE,CAAC;CACjE;CAEA,MAAM,UACJ,MACA,QACA,UAAmC,CAAC,GACrB;EACf,MAAM,aAAa,EAAE,KAAK;EAC1B,MAAM,EAAE,eAAe;EAEvB,MAAM,UACJ,eAAe,KAAA,IACX,KAAA,IACA,MAAM,KAAK,QAAQ,KAAK,GAAG,KAAK,WAAW,YACzC,KAAK,IAAI,GAAG,KAAK,MAAM,aAAa,KAAK,IAAI,KAAK,GAAI,CAAC,CACzD;EACN,MAAM,CAAC,SAAS,cAAc,MAAM,KAAK,QAAQ,eAC/C,GAAG,KAAK,YACR,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CACtD;EACA,MAAM,WAAW,KAAK;EACtB,KAAK,QAAQ;GAAE;GAAS;GAAY;GAAY,SAAS;EAAM;EAC/D,MAAM,KAAK,QAAQ,KACjB,MACA,YAAY;GAGV,MAAM,KAAK,MAAM,UAAU,KAAK,YAAY,KAAK,QAAQ,QAAQ,KAAK,IAAI,CAAC;GAC3E,MAAM,KAAK,WAAW,YAAY,KAAK,YAAY;IACjD,IAAI;IACJ;IACA,YAAY;GACd,CAAC;GACD,IAAI,aAAa,KAAA,KAAa,CAAC,SAAS,SACtC,MAAM,KAAK,OAAO,iBAAiB,SAAS,YAAY,IAAI;EAEhE,GACA,EAAE,eAAe,UAAU,CAC7B;EACA,IAAI,aAAa,KAAA,GAAW,SAAS,UAAU;CACjD;CAEA,MAAM,YAAY,MAA6B;EAC7C,MAAM,QAAQ,KAAK;EACnB,MAAM,KAAK,QAAQ,KACjB,MACA,YAAY;GACV,MAAM,KAAK,MAAM,YAAY,KAAK,UAAU;GAC5C,IAAI,UAAU,KAAA,GAAW;GACzB,MAAM,KAAK,WAAW,cAAc,KAAK,YAAY,MAAM,UAAU;GAGrE,IAAI,CAAC,MAAM,SAAS,MAAM,KAAK,OAAO,iBAAiB,MAAM,YAAY,IAAI;EAC/E,GACA,EAAE,eAAe,UAAU,CAC7B;EACA,IAAI,UAAU,KAAA,GAAW,MAAM,UAAU;EACzC,KAAK,QAAQ,KAAA;CACf;;;;;CAMA,MAAM,YACJ,MACA,OAC8B;EAC9B,MAAM,QAAQ,KAAK;EACnB,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,gBAAgB,KAAK,mDAAmD;EAE1F,IAAI;GACF,MAAM,MAAM;GACZ,OAAO;EACT,SAAS,OAAO;GAEd,IAAI,iBAAiBC,8BAAAA,sBAAsB,OAAO;GAClD,MAAM;EACR,UAAU;GACR,MAAM,UAAU;EAClB;CACF;AACF"}
@@ -27,12 +27,18 @@ export declare class AwsEngineStep implements EngineStep {
27
27
  constructor(context: DurableContext, store: JourneyStore, executions: ExecutionRegistry, lambda: LambdaApi, userId: string, instanceId: string);
28
28
  do<T>(name: string, fn: () => Promise<T>): Promise<T>;
29
29
  sleep(name: string, ms: number): Promise<void>;
30
- sleepUntil(name: string, timestampMs: number): Promise<void>;
31
30
  subscribe(name: string, events: readonly string[], options?: {
32
31
  deadlineMs?: number;
33
32
  }): Promise<void>;
34
33
  unsubscribe(name: string): Promise<void>;
35
- waitForWake(name: string, _timeoutMs: number): Promise<'event' | 'timeout'>;
34
+ /**
35
+ * The timeout is ignored on purpose: the callback's own timeout was set from the deadline
36
+ * when it was armed (`subscribe`), and only its expiry says the wait timed out.
37
+ */
38
+ waitForWake(name: string, _wait: {
39
+ timeoutMs: number;
40
+ deadlineMs: number;
41
+ }): Promise<'event' | 'timeout'>;
36
42
  }
37
43
  //#endregion
38
44
  //# sourceMappingURL=engine-step.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"engine-step.d.cts","names":[],"sources":["../../src/aws/engine-step.ts"],"mappings":";;;;;;;;;;;;;;;;wBAuBgB,UAAU,OAAO,OAAO,kBAAkB;qBAa7C,yBAAyB;mBAKjB;mBACA;mBACA;mBACA;mBACA;mBACA;UATX;UACA;EAER,YACmB,SAAS,gBACT,OAAO,cACP,YAAY,mBACZ,QAAQ,WACR,gBACA;EAGnB,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;EAI7C,MAAM,cAAc,aAAa;EAMjC,WAAW,cAAc,sBAAsB;EAS/C,UACJ,cACA,2BACA;IAAW;MACV;EAoCG,YAAY,eAAe;EAkB3B,YAAY,cAAc,qBAAqB"}
1
+ {"version":3,"file":"engine-step.d.cts","names":[],"sources":["../../src/aws/engine-step.ts"],"mappings":";;;;;;;;;;;;;;;;wBAuBgB,UAAU,OAAO,OAAO,kBAAkB;qBAa7C,yBAAyB;mBAKjB;mBACA;mBACA;mBACA;mBACA;mBACA;UATX;UACA;EAER,YACmB,SAAS,gBACT,OAAO,cACP,YAAY,mBACZ,QAAQ,WACR,gBACA;EAGnB,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;EAI7C,MAAM,cAAc,aAAa;EAMjC,UACJ,cACA,2BACA;IAAW;MACV;EAoCG,YAAY,eAAe;;;;;EAsB3B,YACJ,cACA;IAAS;IAAmB;MAC3B"}
@@ -27,12 +27,18 @@ export declare class AwsEngineStep implements EngineStep {
27
27
  constructor(context: DurableContext, store: JourneyStore, executions: ExecutionRegistry, lambda: LambdaApi, userId: string, instanceId: string);
28
28
  do<T>(name: string, fn: () => Promise<T>): Promise<T>;
29
29
  sleep(name: string, ms: number): Promise<void>;
30
- sleepUntil(name: string, timestampMs: number): Promise<void>;
31
30
  subscribe(name: string, events: readonly string[], options?: {
32
31
  deadlineMs?: number;
33
32
  }): Promise<void>;
34
33
  unsubscribe(name: string): Promise<void>;
35
- waitForWake(name: string, _timeoutMs: number): Promise<'event' | 'timeout'>;
34
+ /**
35
+ * The timeout is ignored on purpose: the callback's own timeout was set from the deadline
36
+ * when it was armed (`subscribe`), and only its expiry says the wait timed out.
37
+ */
38
+ waitForWake(name: string, _wait: {
39
+ timeoutMs: number;
40
+ deadlineMs: number;
41
+ }): Promise<'event' | 'timeout'>;
36
42
  }
37
43
  //#endregion
38
44
  //# sourceMappingURL=engine-step.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"engine-step.d.mts","names":[],"sources":["../../src/aws/engine-step.ts"],"mappings":";;;;;;;;;;;;;;;;wBAuBgB,UAAU,OAAO,OAAO,kBAAkB;qBAa7C,yBAAyB;mBAKjB;mBACA;mBACA;mBACA;mBACA;mBACA;UATX;UACA;EAER,YACmB,SAAS,gBACT,OAAO,cACP,YAAY,mBACZ,QAAQ,WACR,gBACA;EAGnB,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;EAI7C,MAAM,cAAc,aAAa;EAMjC,WAAW,cAAc,sBAAsB;EAS/C,UACJ,cACA,2BACA;IAAW;MACV;EAoCG,YAAY,eAAe;EAkB3B,YAAY,cAAc,qBAAqB"}
1
+ {"version":3,"file":"engine-step.d.mts","names":[],"sources":["../../src/aws/engine-step.ts"],"mappings":";;;;;;;;;;;;;;;;wBAuBgB,UAAU,OAAO,OAAO,kBAAkB;qBAa7C,yBAAyB;mBAKjB;mBACA;mBACA;mBACA;mBACA;mBACA;UATX;UACA;EAER,YACmB,SAAS,gBACT,OAAO,cACP,YAAY,mBACZ,QAAQ,WACR,gBACA;EAGnB,GAAG,GAAG,cAAc,UAAU,QAAQ,KAAK,QAAQ;EAI7C,MAAM,cAAc,aAAa;EAMjC,UACJ,cACA,2BACA;IAAW;MACV;EAoCG,YAAY,eAAe;;;;;EAsB3B,YACJ,cACA;IAAS;IAAmB;MAC3B"}
@@ -43,10 +43,6 @@ var AwsEngineStep = class {
43
43
  if (!(ms > 0)) return;
44
44
  await this.context.wait(name, { seconds: Math.ceil(ms / 1e3) });
45
45
  }
46
- async sleepUntil(name, timestampMs) {
47
- const remaining = await this.context.step(`${name}:remaining`, async () => timestampMs - Date.now());
48
- await this.sleep(name, remaining);
49
- }
50
46
  async subscribe(name, events, options = {}) {
51
47
  const generation = ++this.generation;
52
48
  const { deadlineMs } = options;
@@ -81,7 +77,11 @@ var AwsEngineStep = class {
81
77
  if (armed !== void 0) armed.settled = true;
82
78
  this.armed = void 0;
83
79
  }
84
- async waitForWake(name, _timeoutMs) {
80
+ /**
81
+ * The timeout is ignored on purpose: the callback's own timeout was set from the deadline
82
+ * when it was armed (`subscribe`), and only its expiry says the wait timed out.
83
+ */
84
+ async waitForWake(name, _wait) {
85
85
  const armed = this.armed;
86
86
  if (armed === void 0) throw new Error(`waitForWake '${name}' without a subscribe: nothing is armed to wake it`);
87
87
  try {
@@ -1 +1 @@
1
- {"version":3,"file":"engine-step.mjs","names":[],"sources":["../../src/aws/engine-step.ts"],"sourcesContent":["import {\n CallbackTimeoutError,\n type DurableContext,\n type DurablePromise,\n type RetryDecision,\n} from '@aws/durable-execution-sdk-js';\nimport type { EngineStep } from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport type { ExecutionRegistry, JourneyStore } from '../store/index';\nimport type { LambdaApi } from './lambda-api';\n\n/**\n * EngineStep over a Lambda durable execution.\n *\n * Steps, sleeps and wakes map onto the SDK's `step`, `wait` and callbacks. A wake handle is a\n * callback, which is one-shot, so every wait arms a fresh one (the interpreter subscribes on\n * every attempt): created where the durable context allows it — outside any step, so its id\n * replays — then recorded in Postgres together with the subscription rows, numbered by a\n * generation so a late wake or cleanup of an older wait cannot touch a newer one. The callback\n * is only a hint; the facts the wait re-checks are Postgres'.\n */\n\n/** What the step retries, bounded: 6 attempts with exponential backoff up to a minute. */\nexport function stepRetry(error: Error, attempt: number): RetryDecision {\n if (!isRetryable(error) || attempt >= 6) return { shouldRetry: false };\n return { shouldRetry: true, delay: { seconds: Math.min(60, 2 ** attempt) } };\n}\n\ninterface Armed {\n promise: DurablePromise<string>;\n callbackId: string;\n generation: number;\n /** The wait consumed it (woken or timed out): there is nothing left to close. */\n settled: boolean;\n}\n\nexport class AwsEngineStep implements EngineStep {\n private generation = 0;\n private armed: Armed | undefined;\n\n constructor(\n private readonly context: DurableContext,\n private readonly store: JourneyStore,\n private readonly executions: ExecutionRegistry,\n private readonly lambda: LambdaApi,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.context.step(name, () => fn(), { retryStrategy: stepRetry });\n }\n\n async sleep(name: string, ms: number): Promise<void> {\n // A wait is whole seconds: rounded up, so it never ends early; nothing to wait at zero\n if (!(ms > 0)) return;\n await this.context.wait(name, { seconds: Math.ceil(ms / 1000) });\n }\n\n async sleepUntil(name: string, timestampMs: number): Promise<void> {\n // The remaining time is checkpointed: a replay must not recompute it from a later clock\n const remaining = await this.context.step(\n `${name}:remaining`,\n async () => timestampMs - Date.now()\n );\n await this.sleep(name, remaining);\n }\n\n async subscribe(\n name: string,\n events: readonly string[],\n options: { deadlineMs?: number } = {}\n ): Promise<void> {\n const generation = ++this.generation;\n const { deadlineMs } = options;\n // The callback's own timeout is the wait's deadline: fixed when it is created\n const seconds =\n deadlineMs === undefined\n ? undefined\n : await this.context.step(`${name}:timeout`, async () =>\n Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))\n );\n const [promise, callbackId] = await this.context.createCallback(\n `${name}:callback`,\n seconds === undefined ? {} : { timeout: { seconds } }\n );\n const previous = this.armed;\n this.armed = { promise, callbackId, generation, settled: false };\n await this.context.step(\n name,\n async () => {\n // Rows first, then the callback: a wake that lands in between finds the rows and the\n // callback of the last wait — its completion is refused, and this wait checks the facts\n await this.store.subscribe(this.instanceId, this.userId, events, Date.now());\n await this.executions.armCallback(this.instanceId, {\n id: callbackId,\n generation,\n deadlineAt: deadlineMs,\n });\n if (previous !== undefined && !previous.settled) {\n await this.lambda.completeCallback(previous.callbackId, '{}');\n }\n },\n { retryStrategy: stepRetry }\n );\n if (previous !== undefined) previous.settled = true;\n }\n\n async unsubscribe(name: string): Promise<void> {\n const armed = this.armed;\n await this.context.step(\n name,\n async () => {\n await this.store.unsubscribe(this.instanceId);\n if (armed === undefined) return;\n await this.executions.clearCallback(this.instanceId, armed.generation);\n // The condition held before the wait parked: its callback is closed through the public\n // API (a closed one answers false), rather than left open until it times out\n if (!armed.settled) await this.lambda.completeCallback(armed.callbackId, '{}');\n },\n { retryStrategy: stepRetry }\n );\n if (armed !== undefined) armed.settled = true;\n this.armed = undefined;\n }\n\n async waitForWake(name: string, _timeoutMs: number): Promise<'event' | 'timeout'> {\n const armed = this.armed;\n if (armed === undefined) {\n throw new Error(`waitForWake '${name}' without a subscribe: nothing is armed to wake it`);\n }\n try {\n await armed.promise;\n return 'event';\n } catch (error) {\n // Only the callback's own expiry is a timeout; any other failure is a failure\n if (error instanceof CallbackTimeoutError) return 'timeout';\n throw error;\n } finally {\n armed.settled = true;\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"engine-step.mjs","names":[],"sources":["../../src/aws/engine-step.ts"],"sourcesContent":["import {\n CallbackTimeoutError,\n type DurableContext,\n type DurablePromise,\n type RetryDecision,\n} from '@aws/durable-execution-sdk-js';\nimport type { EngineStep } from '../engine/ports';\nimport { isRetryable } from '../engine/retry';\nimport type { ExecutionRegistry, JourneyStore } from '../store/index';\nimport type { LambdaApi } from './lambda-api';\n\n/**\n * EngineStep over a Lambda durable execution.\n *\n * Steps, sleeps and wakes map onto the SDK's `step`, `wait` and callbacks. A wake handle is a\n * callback, which is one-shot, so every wait arms a fresh one (the interpreter subscribes on\n * every attempt): created where the durable context allows it — outside any step, so its id\n * replays — then recorded in Postgres together with the subscription rows, numbered by a\n * generation so a late wake or cleanup of an older wait cannot touch a newer one. The callback\n * is only a hint; the facts the wait re-checks are Postgres'.\n */\n\n/** What the step retries, bounded: 6 attempts with exponential backoff up to a minute. */\nexport function stepRetry(error: Error, attempt: number): RetryDecision {\n if (!isRetryable(error) || attempt >= 6) return { shouldRetry: false };\n return { shouldRetry: true, delay: { seconds: Math.min(60, 2 ** attempt) } };\n}\n\ninterface Armed {\n promise: DurablePromise<string>;\n callbackId: string;\n generation: number;\n /** The wait consumed it (woken or timed out): there is nothing left to close. */\n settled: boolean;\n}\n\nexport class AwsEngineStep implements EngineStep {\n private generation = 0;\n private armed: Armed | undefined;\n\n constructor(\n private readonly context: DurableContext,\n private readonly store: JourneyStore,\n private readonly executions: ExecutionRegistry,\n private readonly lambda: LambdaApi,\n private readonly userId: string,\n private readonly instanceId: string\n ) {}\n\n do<T>(name: string, fn: () => Promise<T>): Promise<T> {\n return this.context.step(name, () => fn(), { retryStrategy: stepRetry });\n }\n\n async sleep(name: string, ms: number): Promise<void> {\n // A wait is whole seconds: rounded up, so it never ends early; nothing to wait at zero\n if (!(ms > 0)) return;\n await this.context.wait(name, { seconds: Math.ceil(ms / 1000) });\n }\n\n async subscribe(\n name: string,\n events: readonly string[],\n options: { deadlineMs?: number } = {}\n ): Promise<void> {\n const generation = ++this.generation;\n const { deadlineMs } = options;\n // The callback's own timeout is the wait's deadline: fixed when it is created\n const seconds =\n deadlineMs === undefined\n ? undefined\n : await this.context.step(`${name}:timeout`, async () =>\n Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))\n );\n const [promise, callbackId] = await this.context.createCallback(\n `${name}:callback`,\n seconds === undefined ? {} : { timeout: { seconds } }\n );\n const previous = this.armed;\n this.armed = { promise, callbackId, generation, settled: false };\n await this.context.step(\n name,\n async () => {\n // Rows first, then the callback: a wake that lands in between finds the rows and the\n // callback of the last wait — its completion is refused, and this wait checks the facts\n await this.store.subscribe(this.instanceId, this.userId, events, Date.now());\n await this.executions.armCallback(this.instanceId, {\n id: callbackId,\n generation,\n deadlineAt: deadlineMs,\n });\n if (previous !== undefined && !previous.settled) {\n await this.lambda.completeCallback(previous.callbackId, '{}');\n }\n },\n { retryStrategy: stepRetry }\n );\n if (previous !== undefined) previous.settled = true;\n }\n\n async unsubscribe(name: string): Promise<void> {\n const armed = this.armed;\n await this.context.step(\n name,\n async () => {\n await this.store.unsubscribe(this.instanceId);\n if (armed === undefined) return;\n await this.executions.clearCallback(this.instanceId, armed.generation);\n // The condition held before the wait parked: its callback is closed through the public\n // API (a closed one answers false), rather than left open until it times out\n if (!armed.settled) await this.lambda.completeCallback(armed.callbackId, '{}');\n },\n { retryStrategy: stepRetry }\n );\n if (armed !== undefined) armed.settled = true;\n this.armed = undefined;\n }\n\n /**\n * The timeout is ignored on purpose: the callback's own timeout was set from the deadline\n * when it was armed (`subscribe`), and only its expiry says the wait timed out.\n */\n async waitForWake(\n name: string,\n _wait: { timeoutMs: number; deadlineMs: number }\n ): Promise<'event' | 'timeout'> {\n const armed = this.armed;\n if (armed === undefined) {\n throw new Error(`waitForWake '${name}' without a subscribe: nothing is armed to wake it`);\n }\n try {\n await armed.promise;\n return 'event';\n } catch (error) {\n // Only the callback's own expiry is a timeout; any other failure is a failure\n if (error instanceof CallbackTimeoutError) return 'timeout';\n throw error;\n } finally {\n armed.settled = true;\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAuBA,SAAgB,UAAU,OAAc,SAAgC;CACtE,IAAI,CAAC,YAAY,KAAK,KAAK,WAAW,GAAG,OAAO,EAAE,aAAa,MAAM;CACrE,OAAO;EAAE,aAAa;EAAM,OAAO,EAAE,SAAS,KAAK,IAAI,IAAI,KAAK,OAAO,EAAE;CAAE;AAC7E;AAUA,IAAa,gBAAb,MAAiD;CAK5B;CACA;CACA;CACA;CACA;CACA;CATnB,aAAqB;CACrB;CAEA,YACE,SACA,OACA,YACA,QACA,QACA,YACA;EANiB,KAAA,UAAA;EACA,KAAA,QAAA;EACA,KAAA,aAAA;EACA,KAAA,SAAA;EACA,KAAA,SAAA;EACA,KAAA,aAAA;CAChB;CAEH,GAAM,MAAc,IAAkC;EACpD,OAAO,KAAK,QAAQ,KAAK,YAAY,GAAG,GAAG,EAAE,eAAe,UAAU,CAAC;CACzE;CAEA,MAAM,MAAM,MAAc,IAA2B;EAEnD,IAAI,EAAE,KAAK,IAAI;EACf,MAAM,KAAK,QAAQ,KAAK,MAAM,EAAE,SAAS,KAAK,KAAK,KAAK,GAAI,EAAE,CAAC;CACjE;CAEA,MAAM,UACJ,MACA,QACA,UAAmC,CAAC,GACrB;EACf,MAAM,aAAa,EAAE,KAAK;EAC1B,MAAM,EAAE,eAAe;EAEvB,MAAM,UACJ,eAAe,KAAA,IACX,KAAA,IACA,MAAM,KAAK,QAAQ,KAAK,GAAG,KAAK,WAAW,YACzC,KAAK,IAAI,GAAG,KAAK,MAAM,aAAa,KAAK,IAAI,KAAK,GAAI,CAAC,CACzD;EACN,MAAM,CAAC,SAAS,cAAc,MAAM,KAAK,QAAQ,eAC/C,GAAG,KAAK,YACR,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CACtD;EACA,MAAM,WAAW,KAAK;EACtB,KAAK,QAAQ;GAAE;GAAS;GAAY;GAAY,SAAS;EAAM;EAC/D,MAAM,KAAK,QAAQ,KACjB,MACA,YAAY;GAGV,MAAM,KAAK,MAAM,UAAU,KAAK,YAAY,KAAK,QAAQ,QAAQ,KAAK,IAAI,CAAC;GAC3E,MAAM,KAAK,WAAW,YAAY,KAAK,YAAY;IACjD,IAAI;IACJ;IACA,YAAY;GACd,CAAC;GACD,IAAI,aAAa,KAAA,KAAa,CAAC,SAAS,SACtC,MAAM,KAAK,OAAO,iBAAiB,SAAS,YAAY,IAAI;EAEhE,GACA,EAAE,eAAe,UAAU,CAC7B;EACA,IAAI,aAAa,KAAA,GAAW,SAAS,UAAU;CACjD;CAEA,MAAM,YAAY,MAA6B;EAC7C,MAAM,QAAQ,KAAK;EACnB,MAAM,KAAK,QAAQ,KACjB,MACA,YAAY;GACV,MAAM,KAAK,MAAM,YAAY,KAAK,UAAU;GAC5C,IAAI,UAAU,KAAA,GAAW;GACzB,MAAM,KAAK,WAAW,cAAc,KAAK,YAAY,MAAM,UAAU;GAGrE,IAAI,CAAC,MAAM,SAAS,MAAM,KAAK,OAAO,iBAAiB,MAAM,YAAY,IAAI;EAC/E,GACA,EAAE,eAAe,UAAU,CAC7B;EACA,IAAI,UAAU,KAAA,GAAW,MAAM,UAAU;EACzC,KAAK,QAAQ,KAAA;CACf;;;;;CAMA,MAAM,YACJ,MACA,OAC8B;EAC9B,MAAM,QAAQ,KAAK;EACnB,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MAAM,gBAAgB,KAAK,mDAAmD;EAE1F,IAAI;GACF,MAAM,MAAM;GACZ,OAAO;EACT,SAAS,OAAO;GAEd,IAAI,iBAAiB,sBAAsB,OAAO;GAClD,MAAM;EACR,UAAU;GACR,MAAM,UAAU;EAClB;CACF;AACF"}
@@ -1,9 +1,11 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_http2_client = require("../http2-client.cjs");
3
+ const require_engine_retry = require("../engine/retry.cjs");
3
4
  const require_aws_engine_step = require("./engine-step.cjs");
4
5
  const require_aws_function_url = require("./function-url.cjs");
5
6
  const require_engine_duration = require("../engine/duration.cjs");
6
- const require_host_router = require("../host/router.cjs");
7
+ const require_host_fresh_read = require("../host/fresh-read.cjs");
8
+ const require_host_ingest = require("../host/ingest.cjs");
7
9
  const require_host_execute = require("../host/execute.cjs");
8
10
  const require_host_journey_host = require("../host/journey-host.cjs");
9
11
  const require_aws_instances = require("./instances.cjs");
@@ -100,6 +102,11 @@ function journeyLambda(options) {
100
102
  const run = async (input, context) => {
101
103
  const { store, env, executions } = open(context);
102
104
  const { instanceId, params } = input;
105
+ const record = await require_host_fresh_read.readWithRetry(() => executions.get(instanceId), {
106
+ what: "run entry",
107
+ instanceId
108
+ });
109
+ if (record === void 0 || record.stoppedAt !== void 0 || record.input !== JSON.stringify(input)) throw new require_engine_retry.PermanentError("journeyLambda: run input is not a recorded start");
103
110
  await context.step("$workflow:started", async () => {
104
111
  await executions.markSubmitted(instanceId, context.executionContext.durableExecutionArn, Date.now());
105
112
  });
@@ -128,11 +135,45 @@ function journeyLambda(options) {
128
135
  if (method === "GET" || method === "HEAD") return serve();
129
136
  return context.step("http", serve, { retryStrategy: () => ({ shouldRetry: false }) });
130
137
  };
131
- const sweep = (context) => context.step("sweep", async () => {
132
- const { store, env } = open(context);
133
- await host.applyOwnBundle(env, bundleVersion());
134
- return require_host_router.sweepSegments(env, store);
135
- }, { retryStrategy: () => ({ shouldRetry: false }) });
138
+ /**
139
+ * The sweep as durable steps, one per page of users: an invocation cut short (the function's
140
+ * timeout) is re-invoked, and the re-invocation replays the pages already checkpointed
141
+ * instead of evaluating everyone again. No step retries on its own — a page that failed is
142
+ * evaluated again by the next tick.
143
+ */
144
+ const sweep = async (context) => {
145
+ const once = { retryStrategy: () => ({ shouldRetry: false }) };
146
+ await context.step("sweep:maintain", async () => {
147
+ const { store, env } = open(context);
148
+ await host.applyOwnBundle(env, bundleVersion());
149
+ await require_host_ingest.sweepMaintenance(env, store);
150
+ }, once);
151
+ let users = 0;
152
+ const started = [];
153
+ let after;
154
+ for (let n = 0;; n++) {
155
+ const page = await context.step(`sweep:page:${n}`, async () => {
156
+ const { store, env } = open(context);
157
+ return require_host_ingest.sweepSegmentsPaged(env, store, {
158
+ after,
159
+ limit: 500
160
+ });
161
+ }, once);
162
+ users += page.users;
163
+ started.push(...page.started);
164
+ if (!page.more || page.lastUserId === void 0) break;
165
+ after = page.lastUserId;
166
+ }
167
+ const sizes = await context.step("sweep:snapshot", async () => {
168
+ const { store } = open(context);
169
+ return await store.reporting?.snapshotSegments(Date.now()) ?? [];
170
+ }, once);
171
+ return {
172
+ users,
173
+ started,
174
+ sizes
175
+ };
176
+ };
136
177
  const durable = (0, _aws_durable_execution_sdk_js.withDurableExecution)(async (input, context) => {
137
178
  if (require_aws_function_url.isFunctionUrlEvent(input)) return http(input, context);
138
179
  if (require_aws_instances.isRunInput(input)) return run(input, context);