experimental-a2 0.20.0 → 0.22.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 (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  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 +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  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 +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -53,6 +53,9 @@ type Feed = {
53
53
  inbox: string[]
54
54
  bytes: number
55
55
  dirty: boolean
56
+ /** Wake-ups past a gap, waiting up to NOTIFY_TIMINGS.reorderMs for it. */
57
+ held: Array<{ wake: Wake; bytes: number }>
58
+ gapSince: number | undefined
56
59
  wake: (() => void) | null
57
60
  stopRead: () => Promise<void>
58
61
  }
@@ -61,15 +64,14 @@ type Feed = {
61
64
  const INBOX_BYTES = 512 * 1024
62
65
 
63
66
  type WakeEntry = [index: number, id: string, type: string, payload: unknown]
67
+ type Wake = { frontier: number; createdAt: number; entries: WakeEntry[] }
64
68
 
65
69
  /**
66
70
  * Parses a wake-up message: `{"f":frontier}` or a bare frontier number
67
71
  * announces events without carrying them; `{"f","t","e"}` carries them.
68
72
  * Returns null for anything else, which callers treat as a hint to catch up.
69
73
  */
70
- const parseWake = (
71
- message: string,
72
- ): { frontier: number; createdAt: number; entries: WakeEntry[] } | null => {
74
+ const parseWake = (message: string): Wake | null => {
73
75
  let parsed: unknown
74
76
  try {
75
77
  parsed = JSON.parse(message)
@@ -187,6 +189,8 @@ export function createRedisNotifyStore(options: {
187
189
  feed.bytes += message.length
188
190
  } else {
189
191
  feed.inbox.length = 0
192
+ feed.held.length = 0
193
+ feed.gapSince = undefined
190
194
  feed.bytes = 0
191
195
  feed.dirty = true
192
196
  }
@@ -282,6 +286,7 @@ export function createRedisNotifyStore(options: {
282
286
 
283
287
  const store: A2Store & { close(): Promise<void> } = {
284
288
  append: core.append,
289
+ cursors: core.cursors,
285
290
  read: core.read,
286
291
  readHistory: core.readHistory!,
287
292
  claimAvailable: core.claimAvailable,
@@ -294,11 +299,13 @@ export function createRedisNotifyStore(options: {
294
299
  reclaim: core.reclaim!,
295
300
  deadLetters: core.deadLetters!,
296
301
  redrive: core.redrive!,
302
+ wakeLog: core.wakeLog,
297
303
 
298
304
  presence: {
299
305
  async set(ns, participant, values, meta) {
300
- const patch = await core.presence.set(ns, participant, values, meta)
301
- if (!patch) return
306
+ const applied = await core.presence.set(ns, participant, values, meta)
307
+ if (!applied) return
308
+ const { patch } = applied
302
309
  const channel = presenceChannel(ns)
303
310
  const subscription = presenceSubscriptions.get(channel)
304
311
  if (subscription) {
@@ -306,13 +313,16 @@ export function createRedisNotifyStore(options: {
306
313
  subscription.recovery?.update(patch)
307
314
  for (const listener of subscription.listeners) listener(patch)
308
315
  }
316
+ await applied.published
309
317
  },
310
318
 
311
319
  async read(ns) {
312
320
  const channel = presenceChannel(ns)
313
321
  await subscriptions.get(channel)?.ready
322
+ const subscription = presenceSubscriptions.get(channel)
314
323
  return (
315
- presenceSubscriptions.get(channel)?.recovery?.read() ??
324
+ subscription?.recovery?.read() ??
325
+ subscription?.poll?.snapshot(() => core.presence.read(ns)) ??
316
326
  core.presence.read(ns)
317
327
  )
318
328
  },
@@ -415,6 +425,8 @@ export function createRedisNotifyStore(options: {
415
425
  inbox: [],
416
426
  bytes: 0,
417
427
  dirty: true,
428
+ held: [],
429
+ gapSince: undefined,
418
430
  wake: null,
419
431
  stopRead,
420
432
  }
@@ -439,36 +451,51 @@ export function createRedisNotifyStore(options: {
439
451
 
440
452
  /**
441
453
  * Moves carried events that continue this feed's sequence into
442
- * the buffer. A gap, a message without events, or an unreadable
443
- * message marks the feed dirty so it catches up with XRANGE.
454
+ * the buffer. Writers publish after their scripts, so concurrent
455
+ * writers' wake-ups can arrive out of order: they apply in index
456
+ * order, and one past a gap is held until the gap fills or
457
+ * NOTIFY_TIMINGS.reorderMs passes. A message without events past
458
+ * the buffer, or an unreadable one, marks the feed dirty so it
459
+ * catches up with XRANGE.
444
460
  */
445
461
  const applyInbox = (): void => {
446
- feed.bytes = 0
462
+ const carried = feed.held.splice(0)
463
+ let announced = 0
447
464
  for (const message of feed.inbox.splice(0)) {
448
465
  const wake = parseWake(message)
449
- if (!wake) {
450
- feed.dirty = true
466
+ if (!wake) feed.dirty = true
467
+ else if (wake.entries.length === 0)
468
+ announced = Math.max(announced, wake.frontier)
469
+ else carried.push({ wake, bytes: message.length })
470
+ }
471
+ carried.sort(
472
+ (a, b) => a.wake.entries[0]![0] - b.wake.entries[0]![0],
473
+ )
474
+ feed.bytes = 0
475
+ for (const held of carried) {
476
+ if (
477
+ held.wake.entries[0]![0] >
478
+ (buffer.at(-1)?.index ?? last) + 1
479
+ ) {
480
+ feed.held.push(held)
481
+ feed.bytes += held.bytes
451
482
  continue
452
483
  }
453
- for (const [index, id, type, payload] of wake.entries) {
454
- const expected = (buffer.at(-1)?.index ?? last) + 1
455
- if (index < expected) continue
456
- if (index > expected) {
457
- feed.dirty = true
458
- break
459
- }
484
+ for (const [index, id, type, payload] of held.wake.entries) {
485
+ if (index <= (buffer.at(-1)?.index ?? last)) continue
460
486
  buffer.push({
461
487
  id,
462
488
  type,
463
489
  payload,
464
490
  index,
465
491
  sessionId,
466
- createdAt: new Date(wake.createdAt),
492
+ createdAt: new Date(held.wake.createdAt),
467
493
  })
468
494
  }
469
- if (wake.frontier > (buffer.at(-1)?.index ?? last))
470
- feed.dirty = true
471
495
  }
496
+ if (announced > (buffer.at(-1)?.index ?? last)) feed.dirty = true
497
+ if (feed.held.length === 0) feed.gapSince = undefined
498
+ else feed.gapSince ??= Date.now()
472
499
  }
473
500
 
474
501
  const iterator: AsyncIterator<Event> = {
@@ -515,8 +542,14 @@ export function createRedisNotifyStore(options: {
515
542
  feeds.set(channel, set)
516
543
  }
517
544
  set.add(feed)
545
+ // A feed starts at the latest clear at the earliest:
546
+ // entries below it await reclaim.
518
547
  // oxlint-disable-next-line no-await-in-loop -- one-time setup
519
- await acquireChannel(channel)
548
+ const [, clearIndex] = await Promise.all([
549
+ acquireChannel(channel),
550
+ core.clearIndex(sessionId),
551
+ ])
552
+ if (clearIndex - 1 > last) last = clearIndex - 1
520
553
  if (closed || severed) continue
521
554
  }
522
555
  if (options.polling?.()) {
@@ -552,8 +585,16 @@ export function createRedisNotifyStore(options: {
552
585
  }
553
586
  applyInbox()
554
587
  if (buffer.length > 0) continue
588
+ if (
589
+ feed.gapSince !== undefined &&
590
+ Date.now() - feed.gapSince >= NOTIFY_TIMINGS.reorderMs
591
+ )
592
+ feed.dirty = true
555
593
  if (feed.dirty) {
556
594
  feed.dirty = false
595
+ // A read covers whatever the held wake-ups carry.
596
+ feed.held.length = 0
597
+ feed.gapSince = undefined
557
598
  if (core.streamEvents) {
558
599
  // oxlint-disable-next-line no-await-in-loop -- begin an incremental catch-up
559
600
  const result = await nextRead(last)
@@ -566,12 +607,21 @@ export function createRedisNotifyStore(options: {
566
607
  if (buffer.length >= FEED_PAGE_SIZE) feed.dirty = true
567
608
  continue
568
609
  }
569
- // Caught up: park until a message, a reconnect, or the
570
- // safety read that heals a lost message.
610
+ // Caught up: park until a message, a reconnect, the end of
611
+ // a held gap, or the safety read that heals a lost message.
571
612
  const woken = new Promise<'wake'>((resolve) => {
572
613
  feed.wake = () => resolve('wake')
573
614
  })
574
- pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
615
+ const holding = feed.gapSince !== undefined
616
+ pending = defaultSleep(
617
+ holding
618
+ ? Math.max(
619
+ 0,
620
+ NOTIFY_TIMINGS.reorderMs -
621
+ (Date.now() - feed.gapSince!),
622
+ )
623
+ : NOTIFY_TIMINGS.safetyReadMs,
624
+ )
575
625
  // oxlint-disable-next-line no-await-in-loop -- live-feed loop
576
626
  const outcome = await Promise.race([
577
627
  woken,
@@ -579,7 +629,7 @@ export function createRedisNotifyStore(options: {
579
629
  ])
580
630
  pending.cancel()
581
631
  pending = null
582
- if (outcome === 'timer') feed.dirty = true
632
+ if (outcome === 'timer' && !holding) feed.dirty = true
583
633
  } catch (err) {
584
634
  release()
585
635
  if (closed) return { value: undefined, done: true }
@@ -15,9 +15,8 @@
15
15
  *
16
16
  * Works with any Redis-protocol server on a single instance or a
17
17
  * non-cluster provider (Upstash — durable by default — Redis, Valkey).
18
- * Cluster mode is out: the atomic scripts span keys. `ioredis` is an
19
- * optional peer dependency; pass `url`, or inject any client exposing
20
- * `call`/`duplicate`/`on`/`disconnect`.
18
+ * `ioredis` is an optional peer dependency; pass `url`, or inject any client
19
+ * exposing `call`/`duplicate`/`on`/`disconnect`.
21
20
  */
22
21
 
23
22
  import { A2Error } from './errors.ts'