experimental-a2 0.16.0 → 0.17.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 (334) hide show
  1. package/CHANGELOG.md +360 -0
  2. package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
  3. package/dist/actor-DBbe5xeE.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +2 -5
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +13 -4
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -3
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +20 -10
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
  13. package/dist/actor-shared-t7w8xIKP.js.map +1 -0
  14. package/dist/actor.d.ts +1 -1
  15. package/dist/actor.js +42 -23
  16. package/dist/actor.js.map +1 -1
  17. package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
  18. package/dist/ai-UrWfKeuv.js.map +1 -0
  19. package/dist/ai-client.d.ts +27 -0
  20. package/dist/ai-client.d.ts.map +1 -0
  21. package/dist/ai-client.js +29 -0
  22. package/dist/ai-client.js.map +1 -0
  23. package/dist/ai-react.d.ts +96 -0
  24. package/dist/ai-react.d.ts.map +1 -0
  25. package/dist/ai-react.js +142 -0
  26. package/dist/ai-react.js.map +1 -0
  27. package/dist/ai-server.browser.js +2 -2
  28. package/dist/ai-server.browser.js.map +1 -1
  29. package/dist/ai-server.d.ts +80 -36
  30. package/dist/ai-server.d.ts.map +1 -1
  31. package/dist/ai-server.js +466 -265
  32. package/dist/ai-server.js.map +1 -1
  33. package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
  34. package/dist/ai-vpGtcaCB.d.ts.map +1 -0
  35. package/dist/ai.d.ts +2 -2
  36. package/dist/ai.js +2 -2
  37. package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
  38. package/dist/client-COaiaDub.js.map +1 -0
  39. package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
  40. package/dist/client-qRnRUzBS.d.ts.map +1 -0
  41. package/dist/client.d.ts +4 -2
  42. package/dist/client.js +1 -1
  43. package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
  44. package/dist/contract-BYKASlCV.js.map +1 -0
  45. package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
  46. package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
  47. package/dist/index.d.ts +6 -5
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -3
  50. package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
  51. package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
  52. package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
  53. package/dist/internal-CbVvEwad.js.map +1 -0
  54. package/dist/otel.d.ts +1 -1
  55. package/dist/react-BbJ-g6Uu.d.ts +86 -0
  56. package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
  57. package/dist/react.d.ts +2 -122
  58. package/dist/react.js +107 -100
  59. package/dist/react.js.map +1 -1
  60. package/dist/reducer-BAXc8JyF.js +87 -0
  61. package/dist/reducer-BAXc8JyF.js.map +1 -0
  62. package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
  63. package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
  64. package/dist/reducer-runtime-DQwz_J59.js +28 -0
  65. package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +3 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +33 -10
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
  71. package/dist/scheduler-task-X1SToDjP.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -9
  73. package/dist/scheduler-vercel.d.ts.map +1 -1
  74. package/dist/scheduler-vercel.js +25 -44
  75. package/dist/scheduler-vercel.js.map +1 -1
  76. package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
  77. package/dist/server-CGM8WzRG.d.ts.map +1 -0
  78. package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
  79. package/dist/server-Rj64fNvX.js.map +1 -0
  80. package/dist/server.d.ts +4 -4
  81. package/dist/server.js +2 -2
  82. package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
  83. package/dist/session-clear-DGI4jTQ5.js.map +1 -0
  84. package/dist/session-clear-Dshpuhff.d.ts +12 -0
  85. package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
  86. package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
  87. package/dist/store-Gpkq7WBo.d.ts.map +1 -0
  88. package/dist/store-N8PXxDAS.js.map +1 -1
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +182 -69
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
  94. package/dist/store-polling-4_y8lETm.js.map +1 -0
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +399 -108
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
  100. package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.d.ts.map +1 -1
  103. package/dist/store-redis-http.js +293 -11
  104. package/dist/store-redis-http.js.map +1 -1
  105. package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
  106. package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
  107. package/dist/store-redis.d.ts +1 -1
  108. package/dist/store-redis.d.ts.map +1 -1
  109. package/dist/store-redis.js +11 -11
  110. package/dist/store-redis.js.map +1 -1
  111. package/dist/store-sqlite.d.ts +1 -1
  112. package/dist/store-sqlite.d.ts.map +1 -1
  113. package/dist/store-sqlite.js +166 -32
  114. package/dist/store-sqlite.js.map +1 -1
  115. package/dist/stream-buffer-Cx7AAPGq.js +23 -0
  116. package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
  117. package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
  118. package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
  119. package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
  120. package/dist/wire-DjfHOvdW.js.map +1 -0
  121. package/docs/01-quickstart.mdx +2 -2
  122. package/docs/actors/01-introduction.mdx +4 -0
  123. package/docs/actors/04-routes.mdx +14 -1
  124. package/docs/concepts/01-contracts.mdx +95 -0
  125. package/docs/concepts/04-state.mdx +3 -3
  126. package/docs/guides/01-timers.mdx +22 -14
  127. package/docs/guides/02-cancellation.mdx +1 -1
  128. package/docs/guides/03-react.mdx +268 -328
  129. package/docs/guides/04-local-first.mdx +32 -73
  130. package/docs/guides/05-production.mdx +40 -27
  131. package/docs/guides/06-ai-agents.mdx +680 -580
  132. package/docs/guides/09-presence.mdx +3 -7
  133. package/docs/guides/10-transports.mdx +5 -5
  134. package/docs/index.mdx +10 -18
  135. package/docs/reference/01-api.mdx +662 -195
  136. package/docs/reference/02-errors.mdx +2 -1
  137. package/examples/playground/DEPLOY.md +52 -6
  138. package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
  139. package/examples/playground/app/a0/[appId]/page.tsx +48 -0
  140. package/examples/playground/app/a0/a0.css +818 -0
  141. package/examples/playground/app/a0/app-list.tsx +211 -0
  142. package/examples/playground/app/a0/app-view.tsx +55 -0
  143. package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
  145. package/examples/playground/app/a0/apps.test.ts +116 -0
  146. package/examples/playground/app/a0/apps.ts +87 -0
  147. package/examples/playground/app/a0/chat.tsx +364 -0
  148. package/examples/playground/app/a0/columns.tsx +109 -0
  149. package/examples/playground/app/a0/context-menu.tsx +116 -0
  150. package/examples/playground/app/a0/cookies.ts +46 -0
  151. package/examples/playground/app/a0/crash-on-request.ts +72 -0
  152. package/examples/playground/app/a0/crashes/route.ts +3 -0
  153. package/examples/playground/app/a0/crashes.test.ts +33 -0
  154. package/examples/playground/app/a0/crashes.ts +28 -0
  155. package/examples/playground/app/a0/devtools.tsx +164 -0
  156. package/examples/playground/app/a0/events/route.ts +6 -0
  157. package/examples/playground/app/a0/flags.ts +30 -0
  158. package/examples/playground/app/a0/layout.tsx +38 -0
  159. package/examples/playground/app/a0/list/route.ts +10 -0
  160. package/examples/playground/app/a0/model.test.ts +56 -0
  161. package/examples/playground/app/a0/model.ts +58 -0
  162. package/examples/playground/app/a0/page.tsx +14 -0
  163. package/examples/playground/app/a0/preview-pane.tsx +145 -0
  164. package/examples/playground/app/a0/preview.test.ts +53 -0
  165. package/examples/playground/app/a0/preview.ts +26 -0
  166. package/examples/playground/app/a0/sandbox-log.ts +69 -0
  167. package/examples/playground/app/a0/sandbox.ts +191 -0
  168. package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
  169. package/examples/playground/app/a0/scheduler/route.ts +7 -0
  170. package/examples/playground/app/a0/server.ts +158 -0
  171. package/examples/playground/app/a0/session.ts +47 -0
  172. package/examples/playground/app/a0/shell.tsx +150 -0
  173. package/examples/playground/app/a0/short/events/route.ts +9 -0
  174. package/examples/playground/app/a0/splitter.tsx +120 -0
  175. package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
  176. package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
  177. package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
  178. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
  179. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
  180. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
  181. package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
  182. package/examples/playground/app/agent/events/route.ts +5 -2
  183. package/examples/playground/app/agent/ingress.test.ts +98 -0
  184. package/examples/playground/app/agent/model-input.test.tsx +58 -0
  185. package/examples/playground/app/agent/model-input.tsx +115 -0
  186. package/examples/playground/app/agent/model.test.ts +73 -0
  187. package/examples/playground/app/agent/model.ts +58 -26
  188. package/examples/playground/app/agent/new-agent-session.tsx +37 -19
  189. package/examples/playground/app/agent/page.tsx +4 -4
  190. package/examples/playground/app/agent/server.ts +15 -14
  191. package/examples/playground/app/agent/session.ts +5 -6
  192. package/examples/playground/app/agent/telemetry.test.ts +168 -0
  193. package/examples/playground/app/agent/telemetry.ts +30 -0
  194. package/examples/playground/app/agent/trace.test.ts +23 -0
  195. package/examples/playground/app/agent/trace.ts +18 -0
  196. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
  197. package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
  198. package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
  199. package/examples/playground/app/canvas/model.ts +53 -53
  200. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
  201. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
  202. package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
  203. package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
  204. package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
  205. package/examples/playground/app/chat/[chatId]/session.ts +2 -19
  206. package/examples/playground/app/chat/agent/route.ts +9 -0
  207. package/examples/playground/app/chat/model.test.ts +46 -119
  208. package/examples/playground/app/chat/model.ts +82 -128
  209. package/examples/playground/app/chat/scheduler/route.ts +2 -2
  210. package/examples/playground/app/chat/server.test.ts +108 -0
  211. package/examples/playground/app/chat/server.ts +69 -73
  212. package/examples/playground/app/components/activity-feed.tsx +30 -1
  213. package/examples/playground/app/components/activity-history.test.tsx +35 -0
  214. package/examples/playground/app/components/demo-icons.tsx +89 -0
  215. package/examples/playground/app/components/demos.tsx +33 -0
  216. package/examples/playground/app/components/fast-link.test.tsx +58 -0
  217. package/examples/playground/app/components/fast-link.tsx +55 -0
  218. package/examples/playground/app/components/site-header.tsx +96 -0
  219. package/examples/playground/app/counter/counter-client.tsx +3 -3
  220. package/examples/playground/app/counter/model.test.ts +3 -3
  221. package/examples/playground/app/counter/model.ts +11 -21
  222. package/examples/playground/app/counter/page.tsx +2 -15
  223. package/examples/playground/app/counter/session.ts +2 -2
  224. package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
  225. package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
  226. package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
  227. package/examples/playground/app/documents/[documentId]/session.ts +2 -2
  228. package/examples/playground/app/documents/model.ts +27 -27
  229. package/examples/playground/app/globals.css +420 -207
  230. package/examples/playground/app/initial-state.test.tsx +54 -0
  231. package/examples/playground/app/layout.tsx +7 -23
  232. package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
  233. package/examples/playground/app/orders/[orderId]/session.ts +2 -2
  234. package/examples/playground/app/orders/model.ts +49 -49
  235. package/examples/playground/app/page.tsx +13 -102
  236. package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
  237. package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
  238. package/examples/playground/app/recovery/model.ts +30 -30
  239. package/examples/playground/instrumentation.ts +5 -0
  240. package/examples/playground/package.json +10 -2
  241. package/examples/playground/scripts/a0-template.ts +77 -0
  242. package/examples/playground/vercel.json +18 -0
  243. package/package.json +3 -1
  244. package/src/actor-client.ts +16 -4
  245. package/src/actor-react.ts +25 -11
  246. package/src/actor-shared.ts +21 -6
  247. package/src/actor.ts +76 -31
  248. package/src/ai-agent-actions.ts +219 -0
  249. package/src/ai-agent.ts +2343 -0
  250. package/src/ai-app-state.ts +91 -0
  251. package/src/ai-client-state.ts +39 -2
  252. package/src/ai-client.ts +62 -0
  253. package/src/ai-context-schema.ts +25 -5
  254. package/src/ai-context.ts +90 -40
  255. package/src/ai-control-server.ts +94 -55
  256. package/src/ai-control.ts +2 -0
  257. package/src/ai-coordinator.ts +23 -22
  258. package/src/ai-message-base.ts +209 -0
  259. package/src/ai-message-projection.ts +382 -58
  260. package/src/ai-progress-batches.ts +26 -0
  261. package/src/ai-projector.ts +95 -14
  262. package/src/ai-react.ts +146 -0
  263. package/src/ai-server.browser.ts +2 -2
  264. package/src/ai-server.ts +20 -1951
  265. package/src/ai-stored-state.ts +48 -163
  266. package/src/ai.ts +343 -276
  267. package/src/bootstrap-stream.ts +72 -0
  268. package/src/bounded-cache.ts +55 -0
  269. package/src/client-render-session.ts +49 -0
  270. package/src/client.ts +688 -151
  271. package/src/contract.ts +92 -42
  272. package/src/deterministic-id.ts +37 -0
  273. package/src/equal-json.ts +32 -0
  274. package/src/errors.ts +13 -1
  275. package/src/index.ts +5 -1
  276. package/src/initial-snapshot.ts +39 -0
  277. package/src/internal.ts +45 -0
  278. package/src/postgres-notification-scope.ts +14 -3
  279. package/src/react.ts +171 -206
  280. package/src/redis-http-reply.ts +256 -0
  281. package/src/redis-http-stream.ts +73 -0
  282. package/src/redis-http-subscriptions.ts +13 -5
  283. package/src/reducer-runtime.ts +52 -0
  284. package/src/reducer.ts +92 -10
  285. package/src/scheduler-qstash.ts +49 -20
  286. package/src/scheduler-task.ts +34 -2
  287. package/src/scheduler-vercel.ts +32 -84
  288. package/src/server-fetch.ts +139 -35
  289. package/src/server.ts +957 -237
  290. package/src/session-clear.ts +23 -0
  291. package/src/session-socket.ts +154 -49
  292. package/src/sse.ts +71 -4
  293. package/src/store-memory.ts +220 -78
  294. package/src/store-polling.ts +18 -4
  295. package/src/store-postgres.ts +461 -114
  296. package/src/store-redis-core.ts +1260 -434
  297. package/src/store-redis-http.ts +18 -2
  298. package/src/store-redis-notify.ts +260 -85
  299. package/src/store-redis.ts +19 -9
  300. package/src/store-sqlite.ts +259 -45
  301. package/src/store.ts +46 -1
  302. package/src/stream-buffer.ts +43 -0
  303. package/src/telemetry.ts +13 -1
  304. package/src/wire.ts +241 -13
  305. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  306. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  307. package/dist/ai-CrLYwNEx.js.map +0 -1
  308. package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
  309. package/dist/client-BYzHjkwU.js.map +0 -1
  310. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  311. package/dist/contract-CKRg_E4q.js.map +0 -1
  312. package/dist/errors-DCk6ch5n.js.map +0 -1
  313. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  314. package/dist/internal-Dq2qYxou.js.map +0 -1
  315. package/dist/react.d.ts.map +0 -1
  316. package/dist/reducer-DEMjEY_O.js +0 -29
  317. package/dist/reducer-DEMjEY_O.js.map +0 -1
  318. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  319. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  320. package/dist/server-BD5ckxZb.js.map +0 -1
  321. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  322. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  323. package/dist/store-polling-CmxUbV93.js.map +0 -1
  324. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  325. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  326. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  327. package/dist/validate-XKT4FSNn.js +0 -30
  328. package/dist/validate-XKT4FSNn.js.map +0 -1
  329. package/dist/wire-BO5wWCb1.js.map +0 -1
  330. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  331. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  332. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  333. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  334. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
@@ -0,0 +1,256 @@
1
+ /* oxlint-disable no-await-in-loop -- response bytes and entries must be consumed in order */
2
+
3
+ type Scan = { depth: number; quoted: boolean; escaped: boolean }
4
+
5
+ /**
6
+ * Index just past the array or string `scan` is inside, or -1 when `text`
7
+ * ends first; `scan` then carries the state into the next chunk. Strings
8
+ * are skipped with `indexOf`, so the per-character loop only walks
9
+ * structure. A quote is escaped when an odd run of backslashes precedes
10
+ * it; a run is counted back only to where the string scan resumed, and
11
+ * `escaped` carries an odd trailing run across the chunk boundary.
12
+ */
13
+ function scanValue(text: string, from: number, scan: Scan): number {
14
+ let at = from
15
+ while (at < text.length) {
16
+ if (scan.quoted) {
17
+ if (scan.escaped) {
18
+ scan.escaped = false
19
+ at++
20
+ continue
21
+ }
22
+ const quote = text.indexOf('"', at)
23
+ const end = quote < 0 ? text.length : quote
24
+ let run = 0
25
+ while (end - run - 1 >= at && text.charCodeAt(end - run - 1) === 92) run++
26
+ if (quote < 0) {
27
+ scan.escaped = run % 2 === 1
28
+ return -1
29
+ }
30
+ at = quote + 1
31
+ if (run % 2 === 1) continue
32
+ scan.quoted = false
33
+ if (scan.depth === 0) return at
34
+ continue
35
+ }
36
+ const code = text.charCodeAt(at++)
37
+ if (code === 34) scan.quoted = true
38
+ else if (code === 91 || code === 123) scan.depth++
39
+ else if ((code === 93 || code === 125) && --scan.depth === 0) return at
40
+ }
41
+ return -1
42
+ }
43
+
44
+ class JsonReply {
45
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>
46
+ private readonly decoder = new TextDecoder('utf-8', { fatal: true })
47
+ private readonly signal: AbortSignal
48
+ private text = ''
49
+ private position = 0
50
+ private done = false
51
+ private readonly abort: () => void
52
+ private array: 'first' | 'value' | 'entry' | 'separator' | 'closed' = 'first'
53
+ private scan: Scan = { depth: 0, quoted: false, escaped: false }
54
+ private fragments: string[] = []
55
+ private failure: unknown
56
+
57
+ constructor(options: {
58
+ body: ReadableStream<Uint8Array>
59
+ signal: AbortSignal
60
+ }) {
61
+ const { body, signal } = options
62
+ this.reader = body.getReader()
63
+ this.signal = signal
64
+ this.abort = () => {
65
+ void this.reader.cancel(signal.reason).catch(() => {})
66
+ }
67
+ signal.addEventListener('abort', this.abort, { once: true })
68
+ if (signal.aborted) this.abort()
69
+ }
70
+
71
+ private async available(): Promise<boolean> {
72
+ while (this.position === this.text.length && !this.done) {
73
+ this.signal.throwIfAborted()
74
+ const chunk = await this.reader.read()
75
+ this.signal.throwIfAborted()
76
+ this.done = Boolean(chunk.done)
77
+ this.text = chunk.done
78
+ ? this.decoder.decode()
79
+ : this.decoder.decode(chunk.value, { stream: true })
80
+ this.position = 0
81
+ }
82
+ return this.position < this.text.length
83
+ }
84
+
85
+ async peek(): Promise<string | undefined> {
86
+ while (await this.available()) {
87
+ while (this.position < this.text.length) {
88
+ const value = this.text[this.position]!
89
+ if (value !== ' ' && value !== '\n' && value !== '\r' && value !== '\t')
90
+ return value
91
+ this.position++
92
+ }
93
+ }
94
+ return undefined
95
+ }
96
+
97
+ async expect(value: string): Promise<void> {
98
+ if ((await this.peek()) !== value)
99
+ throw new Error('Invalid or truncated Redis HTTP reply')
100
+ this.position++
101
+ }
102
+
103
+ async value(): Promise<unknown> {
104
+ const first = await this.peek()
105
+ if (first !== '[' && first !== '"')
106
+ throw new Error('Invalid Redis HTTP reply value')
107
+ const scan: Scan = { depth: 0, quoted: false, escaped: false }
108
+ const fragments: string[] = []
109
+ while (await this.available()) {
110
+ const start = this.position
111
+ const end = scanValue(this.text, start, scan)
112
+ this.position = end < 0 ? this.text.length : end
113
+ fragments.push(this.text.slice(start, this.position))
114
+ if (end >= 0) return JSON.parse(fragments.join('')) as unknown
115
+ }
116
+ throw new Error('Truncated Redis HTTP reply value')
117
+ }
118
+
119
+ /**
120
+ * The next entries of an open array, parsed together: every entry that
121
+ * completes in the current transport chunk, reading further chunks only
122
+ * until one has completed. Entries before a malformed one are returned
123
+ * first; the malformed one fails the next call. `undefined` once the
124
+ * array has closed.
125
+ */
126
+ async entries(): Promise<unknown[] | undefined> {
127
+ if (this.failure !== undefined) throw this.failure
128
+ const texts: string[] = []
129
+ const fail = (message: string): void => {
130
+ const error = new Error(message)
131
+ if (texts.length === 0) throw error
132
+ this.failure = error
133
+ }
134
+ while (this.array !== 'closed') {
135
+ if (this.position === this.text.length) {
136
+ if (texts.length > 0) break
137
+ if (!(await this.available()))
138
+ throw new Error(
139
+ this.array === 'entry'
140
+ ? 'Truncated Redis HTTP reply value'
141
+ : this.array === 'separator'
142
+ ? 'Invalid or truncated Redis HTTP reply'
143
+ : 'Invalid Redis HTTP reply value',
144
+ )
145
+ continue
146
+ }
147
+ if (this.array === 'entry') {
148
+ const start = this.position
149
+ const end = scanValue(this.text, start, this.scan)
150
+ this.position = end < 0 ? this.text.length : end
151
+ this.fragments.push(this.text.slice(start, this.position))
152
+ if (end < 0) continue
153
+ texts.push(
154
+ this.fragments.length === 1
155
+ ? this.fragments[0]!
156
+ : this.fragments.join(''),
157
+ )
158
+ this.fragments = []
159
+ this.array = 'separator'
160
+ continue
161
+ }
162
+ const value = this.text[this.position]!
163
+ if (value === ' ' || value === '\n' || value === '\r' || value === '\t') {
164
+ this.position++
165
+ continue
166
+ }
167
+ if (
168
+ this.array === 'separator' ||
169
+ (this.array === 'first' && value === ']')
170
+ ) {
171
+ if (value === ']') this.array = 'closed'
172
+ else if (value === ',') this.array = 'value'
173
+ else {
174
+ fail('Invalid or truncated Redis HTTP reply')
175
+ break
176
+ }
177
+ this.position++
178
+ continue
179
+ }
180
+ if (value !== '[' && value !== '"') {
181
+ fail('Invalid Redis HTTP reply value')
182
+ break
183
+ }
184
+ this.scan = { depth: 0, quoted: false, escaped: false }
185
+ this.array = 'entry'
186
+ }
187
+ return texts.length > 0 ? this.parse(texts) : undefined
188
+ }
189
+
190
+ private parse(texts: string[]): unknown[] {
191
+ try {
192
+ return JSON.parse(`[${texts.join(',')}]`) as unknown[]
193
+ } catch {
194
+ // The joined parse fails only if one entry does: keep those before it.
195
+ const parsed: unknown[] = []
196
+ for (const text of texts) {
197
+ try {
198
+ parsed.push(JSON.parse(text))
199
+ } catch (error) {
200
+ this.failure = error
201
+ break
202
+ }
203
+ }
204
+ if (parsed.length === 0) throw this.failure
205
+ return parsed
206
+ }
207
+ }
208
+
209
+ async end(): Promise<void> {
210
+ await this.expect('}')
211
+ if ((await this.peek()) !== undefined)
212
+ throw new Error('Trailing data in Redis HTTP reply')
213
+ }
214
+
215
+ async close(): Promise<void> {
216
+ this.signal.removeEventListener('abort', this.abort)
217
+ try {
218
+ await this.reader.cancel()
219
+ } finally {
220
+ this.reader.releaseLock()
221
+ this.text = ''
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * The `result` array of an Upstash REST reply, in batches of the entries
228
+ * that complete in each transport chunk.
229
+ */
230
+ export async function* redisHttpReply(options: {
231
+ body: ReadableStream<Uint8Array>
232
+ signal: AbortSignal
233
+ }): AsyncGenerator<unknown[]> {
234
+ const reply = new JsonReply(options)
235
+ try {
236
+ await reply.expect('{')
237
+ const key = await reply.value()
238
+ await reply.expect(':')
239
+ if (key === 'error') {
240
+ const error = await reply.value()
241
+ if (typeof error !== 'string') throw new Error('Invalid Redis HTTP error')
242
+ await reply.end()
243
+ throw new Error(`Redis HTTP command failed: ${String(error)}`)
244
+ }
245
+ if (key !== 'result') throw new Error('Invalid Redis HTTP reply envelope')
246
+ await reply.expect('[')
247
+ for (;;) {
248
+ const entries = await reply.entries()
249
+ if (entries === undefined) break
250
+ yield entries
251
+ }
252
+ await reply.end()
253
+ } finally {
254
+ await reply.close()
255
+ }
256
+ }
@@ -0,0 +1,73 @@
1
+ import { A2Error } from './errors.ts'
2
+ import { redisHttpReply } from './redis-http-reply.ts'
3
+ import type { StreamEntry } from './store-redis-core.ts'
4
+
5
+ export const REDIS_HTTP_STREAM_READ_COUNT = 1000
6
+
7
+ export async function* readRedisStreamPage({
8
+ key,
9
+ from,
10
+ signal,
11
+ url,
12
+ token,
13
+ count,
14
+ }: {
15
+ key: string
16
+ from: string
17
+ signal: AbortSignal
18
+ url: string
19
+ token: string
20
+ count: number
21
+ }): AsyncGenerator<StreamEntry[], void> {
22
+ signal.throwIfAborted()
23
+ const response = await fetch(url, {
24
+ method: 'POST',
25
+ headers: {
26
+ authorization: `Bearer ${token}`,
27
+ 'content-type': 'application/json',
28
+ },
29
+ body: JSON.stringify(['XRANGE', key, from, '+', 'COUNT', String(count)]),
30
+ signal,
31
+ })
32
+ if (!response.ok || !response.body) {
33
+ const body = (await response.json().catch(() => null)) as {
34
+ error?: unknown
35
+ } | null
36
+ throw new A2Error(
37
+ 'STORE_UNAVAILABLE',
38
+ `redis rest command failed: ${
39
+ typeof body?.error === 'string'
40
+ ? body.error
41
+ : `status ${response.status}`
42
+ }`,
43
+ )
44
+ }
45
+ let last = Number(from.slice(0, -2)) - 1
46
+ for await (const batch of redisHttpReply({ body: response.body, signal })) {
47
+ const entries: StreamEntry[] = []
48
+ try {
49
+ for (const entry of batch) {
50
+ if (
51
+ !Array.isArray(entry) ||
52
+ entry.length !== 2 ||
53
+ typeof entry[0] !== 'string' ||
54
+ !Array.isArray(entry[1]) ||
55
+ entry[1].length % 2 !== 0 ||
56
+ !entry[1].every((field) => typeof field === 'string')
57
+ )
58
+ throw new Error('Invalid Redis stream entry')
59
+ const index = /^\d+-0$/.test(entry[0])
60
+ ? Number(entry[0].slice(0, -2))
61
+ : NaN
62
+ if (!Number.isSafeInteger(index) || index <= last)
63
+ throw new Error('Redis stream entry did not advance')
64
+ last = index
65
+ entries.push(entry as StreamEntry)
66
+ }
67
+ } catch (error) {
68
+ if (entries.length > 0) yield entries
69
+ throw error
70
+ }
71
+ yield entries
72
+ }
73
+ }
@@ -12,7 +12,11 @@ export function redisHttpSubscriptions({
12
12
  url: string
13
13
  token: string
14
14
  onReconnect: (channel: string) => void
15
- fallback: { enabled(): boolean; enable(): Promise<void> }
15
+ fallback: {
16
+ enabled(): boolean
17
+ checkCommands(): Promise<void>
18
+ enable(): void
19
+ }
16
20
  }): RedisConnection {
17
21
  const subscriptions = new Map<
18
22
  string,
@@ -75,10 +79,14 @@ export function redisHttpSubscriptions({
75
79
  // oxlint-disable-next-line no-await-in-loop -- finish capability detection before falling back
76
80
  await response.body?.cancel()
77
81
  // oxlint-disable-next-line no-await-in-loop -- confirm that ordinary commands work
78
- await fallback.enable()
79
- for (const active of subscriptions.values()) active.stop()
80
- subscriptions.clear()
81
- return
82
+ await fallback.checkCommands()
83
+ if (stopped) return
84
+ if (!supported) {
85
+ fallback.enable()
86
+ for (const active of subscriptions.values()) active.stop()
87
+ subscriptions.clear()
88
+ return
89
+ }
82
90
  }
83
91
  if (
84
92
  !response.ok ||
@@ -0,0 +1,52 @@
1
+ const runtimeKey: unique symbol = Symbol('a2.reducerRuntime')
2
+
3
+ type StateStorage<S> = {
4
+ encode(state: S): unknown
5
+ decode(stored: unknown): S
6
+ }
7
+
8
+ type ReducerRuntime<S> = {
9
+ storage?: StateStorage<S>
10
+ project?: (state: S) => S
11
+ }
12
+
13
+ type Carrier = { [runtimeKey]?: ReducerRuntime<unknown> }
14
+
15
+ export function withReducerRuntime<R extends object, S>(options: {
16
+ reducer: R
17
+ storage?: StateStorage<S>
18
+ project?: (state: S) => S
19
+ }): R {
20
+ const { reducer, ...runtime } = options
21
+ return {
22
+ ...reducer,
23
+ [runtimeKey]: {
24
+ ...(reducer as Carrier)[runtimeKey],
25
+ ...runtime,
26
+ },
27
+ }
28
+ }
29
+
30
+ export function encodeState(options: {
31
+ reducer: object
32
+ state: unknown
33
+ }): unknown {
34
+ const storage = (options.reducer as Carrier)[runtimeKey]?.storage
35
+ return storage ? storage.encode(options.state) : options.state
36
+ }
37
+
38
+ export function decodeState(options: {
39
+ reducer: object
40
+ state: unknown
41
+ }): unknown {
42
+ const storage = (options.reducer as Carrier)[runtimeKey]?.storage
43
+ return storage ? storage.decode(options.state) : options.state
44
+ }
45
+
46
+ export function projectClientState<S>(options: {
47
+ reducer: object
48
+ state: S
49
+ }): S {
50
+ const project = (options.reducer as Carrier)[runtimeKey]?.project
51
+ return project ? (project(options.state) as S) : options.state
52
+ }
package/src/reducer.ts CHANGED
@@ -1,14 +1,62 @@
1
- import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
1
+ import type {
2
+ ContractEvent,
3
+ EventDefs,
4
+ PresenceDefs,
5
+ ReducerOptions,
6
+ } from './contract.ts'
7
+ import { isSessionCleared, type SessionCleared } from './session-clear.ts'
2
8
  import type { StandardSchemaV1 } from './standard-schema.ts'
3
9
  import { validateSync } from './validate.ts'
4
10
 
5
- export const clientStateProjection: unique symbol = Symbol(
6
- 'a2.clientStateProjection',
7
- )
11
+ export type StateSnapshot<S> = {
12
+ state: S
13
+ index: number
14
+ /** The session's latest clear marker, once it has been cleared. */
15
+ clearIndex?: number | undefined
16
+ }
17
+
18
+ /**
19
+ * A fresh copy of a reducer's seed, so a fold that mutates in place
20
+ * cannot corrupt the shared `initialState`. Non-cloneable seeds
21
+ * (functions, class instances, proxies) fall back to the original.
22
+ */
23
+ export function cloneInitial<S>(initial: S): S {
24
+ try {
25
+ return structuredClone(initial)
26
+ } catch {
27
+ return initial
28
+ }
29
+ }
30
+
31
+ const markerFolds = new WeakSet<object>()
32
+
33
+ /**
34
+ * Mark a fold that receives clear markers itself instead of resetting to its
35
+ * `initialState`. The actor follower has no seed of its own; its stream carries
36
+ * the reset state in the marker.
37
+ */
38
+ export function foldsClearMarkers<
39
+ F extends (state: never, event: never) => unknown,
40
+ >(fold: F): F {
41
+ markerFolds.add(fold)
42
+ return fold
43
+ }
44
+
45
+ /** One fold step; a clear marker resets to the seed on server and client alike. */
46
+ export function foldEvent<D extends EventDefs, S>(
47
+ source: Pick<Reducer<D, S>, 'fold' | 'initialState'>,
48
+ state: S,
49
+ event: ContractEvent<D> | SessionCleared,
50
+ ): S {
51
+ if (!isSessionCleared(event)) return source.fold(state, event)
52
+ return markerFolds.has(source.fold)
53
+ ? source.fold(state, event as unknown as ContractEvent<D>)
54
+ : cloneInitial(source.initialState)
55
+ }
8
56
 
9
57
  /**
10
58
  * A pure fold over a session's events — `(state, event) => state` —
11
- * derived from a contract via `contract.reducer(options).fold(fn)`.
59
+ * published inline in a contract or built with `reducer(options).fold(fn)`.
12
60
  *
13
61
  * The name is the reducer's identity. Cached folds (server snapshots,
14
62
  * the browser cache) are keyed by it, which makes it the invalidation
@@ -20,7 +68,6 @@ export type Reducer<
20
68
  D extends EventDefs = EventDefs,
21
69
  S = unknown,
22
70
  P extends PresenceDefs = Record<never, never>,
23
- V = S,
24
71
  > = {
25
72
  /** Identity and cache key. Rename when the fold's logic changes. */
26
73
  readonly name: string
@@ -34,8 +81,6 @@ export type Reducer<
34
81
  readonly presence: Readonly<P>
35
82
  readonly fold: (state: S, event: ContractEvent<D>) => S
36
83
  readonly initialState: S
37
- /** Internal read-side projection. Never used as a fold input or persisted. */
38
- readonly [clientStateProjection]?: (state: S) => V
39
84
  /**
40
85
  * When present: `initialState` was validated against it at
41
86
  * definition, and any cached fold a2 rehydrates (server snapshots,
@@ -45,7 +90,7 @@ export type Reducer<
45
90
  readonly stateSchema: StandardSchemaV1<unknown, S> | undefined
46
91
  }
47
92
 
48
- /** The second step of `contract.reducer` — see the method's doc. */
93
+ /** The second step of `reducer` — see the method's doc. */
49
94
  export type ReducerBuilder<
50
95
  D extends EventDefs,
51
96
  S,
@@ -54,7 +99,7 @@ export type ReducerBuilder<
54
99
  fold(fold: (state: S, event: ContractEvent<D>) => S): Reducer<D, S, P>
55
100
  }
56
101
 
57
- /** Internal — reducers are created through `contract.reducer(...).fold(...)`. */
102
+ /** Internal — shared validation for inline and standalone reducer definitions. */
58
103
  export function makeReducerBuilder<
59
104
  D extends EventDefs,
60
105
  P extends PresenceDefs,
@@ -94,6 +139,19 @@ export function makeReducerBuilder<
94
139
  )
95
140
  }
96
141
  initialState = result.value as S
142
+ const restored = validateSync(
143
+ options.stateSchema,
144
+ initialState,
145
+ 'the stateSchema',
146
+ )
147
+ if (
148
+ restored.issues ||
149
+ JSON.stringify(restored.value) !== JSON.stringify(initialState)
150
+ ) {
151
+ throw new TypeError(
152
+ 'stateSchema must accept and preserve its own output so snapshots can be restored',
153
+ )
154
+ }
97
155
  }
98
156
 
99
157
  return {
@@ -113,3 +171,27 @@ export function makeReducerBuilder<
113
171
  },
114
172
  }
115
173
  }
174
+
175
+ export type RegisteredReducer<
176
+ D extends EventDefs,
177
+ P extends PresenceDefs,
178
+ > = Omit<Reducer<D, unknown, P>, 'fold'> & {
179
+ readonly fold: (state: never, event: never) => unknown
180
+ }
181
+
182
+ export function reducer<
183
+ D extends EventDefs,
184
+ P extends PresenceDefs,
185
+ Seed,
186
+ S = Seed,
187
+ >(
188
+ options: ReducerOptions<Seed, S> & {
189
+ contract: { events: Readonly<D>; presence: Readonly<P> }
190
+ },
191
+ ): ReducerBuilder<D, S, P> {
192
+ return makeReducerBuilder(
193
+ options.contract.events,
194
+ options.contract.presence,
195
+ options,
196
+ )
197
+ }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * QStash publishes one signed HTTP delivery per scheduler task. Drain tasks
5
5
  * use an absolute, whole-second `notBefore` and a fixed-size deduplication id;
6
- * append tasks hash their stable task id with the callback URL.
6
+ * append and clear tasks hash their stable task id with the callback URL, each
7
+ * under its own version tag.
7
8
  * Store-outage successors carry a signed private generation and back off to
8
9
  * one day without deriving their deduplication identity from the local clock.
9
10
  * `handler(...servers)` verifies the signature over the raw request body
@@ -28,6 +29,7 @@ import { retryableLazy } from './retryable-lazy.ts'
28
29
  import { normalizeSchedulerTask, type SchedulerTask } from './scheduler-task.ts'
29
30
  import {
30
31
  deliverSchedulerAppend,
32
+ deliverSchedulerClear,
31
33
  type A2Scheduler,
32
34
  type DrainableServer,
33
35
  } from './server.ts'
@@ -37,7 +39,7 @@ type QStashStoreOutage = {
37
39
  version: 1
38
40
  /** Zero is the first successor created for a store outage. */
39
41
  generation: number
40
- /** The first failing drain task's due time. Append identity lives on the task. */
42
+ /** The first failing drain task's due time. Append and clear identity lives on the task. */
41
43
  rootDueAt?: number
42
44
  }
43
45
 
@@ -658,6 +660,16 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
658
660
  task: SchedulerTask,
659
661
  outage: QStashStoreOutage,
660
662
  ): Promise<string> => {
663
+ if (task.kind === 'clear') {
664
+ return digestId([
665
+ 'a2:scheduler-qstash:store-outage:clear:v1',
666
+ url,
667
+ task.contract,
668
+ task.sessionId,
669
+ task.id,
670
+ outage.generation,
671
+ ])
672
+ }
661
673
  if (task.kind === 'append') {
662
674
  return digestId([
663
675
  'a2:scheduler-qstash:store-outage:append:v1',
@@ -708,14 +720,27 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
708
720
  sessionId: task.sessionId,
709
721
  dueAt,
710
722
  events: task.events,
723
+ ...(task.originIndex === undefined
724
+ ? {}
725
+ : { originIndex: task.originIndex }),
711
726
  }
712
- : {
713
- version: 1,
714
- kind: 'drain',
715
- contract: task.contract,
716
- sessionId: task.sessionId,
717
- dueAt,
718
- }
727
+ : task.kind === 'clear'
728
+ ? {
729
+ version: 1,
730
+ kind: 'clear',
731
+ id: task.id,
732
+ contract: task.contract,
733
+ sessionId: task.sessionId,
734
+ dueAt,
735
+ ifLastIndex: task.ifLastIndex,
736
+ }
737
+ : {
738
+ version: 1,
739
+ kind: 'drain',
740
+ contract: task.contract,
741
+ sessionId: task.sessionId,
742
+ dueAt,
743
+ }
719
744
  const message: QStashMessage = {
720
745
  ...normalizedTask,
721
746
  ...(outage === undefined ? {} : { _a2QStash: outage }),
@@ -725,7 +750,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
725
750
  ? {
726
751
  ...normalizedTask,
727
752
  _a2QStash:
728
- normalizedTask.kind === 'append'
753
+ normalizedTask.kind !== 'drain'
729
754
  ? { version: 1, generation: Number.MAX_SAFE_INTEGER }
730
755
  : {
731
756
  version: 1,
@@ -753,13 +778,15 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
753
778
  ? await storeOutageDeduplicationId(task, outage)
754
779
  : task.kind === 'append'
755
780
  ? await digestId(['a2:scheduler-qstash:append:v1', url, task.id])
756
- : await digestId([
757
- 'a2:scheduler-qstash:v1',
758
- url,
759
- task.contract,
760
- task.sessionId,
761
- dueAt,
762
- ])
781
+ : task.kind === 'clear'
782
+ ? await digestId(['a2:scheduler-qstash:clear:v1', url, task.id])
783
+ : await digestId([
784
+ 'a2:scheduler-qstash:v1',
785
+ url,
786
+ task.contract,
787
+ task.sessionId,
788
+ dueAt,
789
+ ])
763
790
  const trustedSource =
764
791
  inferredVercelPreview === true
765
792
  ? previewTrustedSource(Math.max(nowMs, dueAt))
@@ -793,7 +820,7 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
793
820
  const nowMs = clock.now().getTime()
794
821
  const dueAt = Math.ceil((nowMs + delayMs) / 1_000) * 1_000
795
822
  const nextOutage: QStashStoreOutage =
796
- task.kind === 'append'
823
+ task.kind !== 'drain'
797
824
  ? { version: 1, generation }
798
825
  : { version: 1, generation, rootDueAt: outage?.rootDueAt ?? task.dueAt }
799
826
  await sendTask({ ...task, dueAt }, nextOutage)
@@ -855,9 +882,11 @@ export function qstash(options: QStashSchedulerOptions = {}): A2Scheduler {
855
882
  )
856
883
  }
857
884
  const internals = serverInternals.get(server)
858
- if (task.kind === 'append') {
885
+ if (task.kind !== 'drain') {
859
886
  try {
860
- await deliverSchedulerAppend(server, task)
887
+ if (task.kind === 'append')
888
+ await deliverSchedulerAppend(server, task)
889
+ else await deliverSchedulerClear(server, task)
861
890
  } catch (error) {
862
891
  if (!storeUnavailable(error)) throw error
863
892
  await sendStoreOutageSuccessor(task, outage)