experimental-a2 0.17.0 → 0.18.1

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 (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /package/docs/{guides → server}/meta.ts +0 -0
@@ -123,6 +123,23 @@ const READ_CONCURRENCY = 16
123
123
 
124
124
  const META_CHUNK_SIZE = 1000
125
125
 
126
+ /** The metadata columns of a full stored row. */
127
+ const STORED_EVENT_META_SUFFIXES = [
128
+ 'p',
129
+ 'pa',
130
+ 'ri',
131
+ 'ch',
132
+ 'ce',
133
+ 'f',
134
+ 'fc',
135
+ 'lc',
136
+ 'lf',
137
+ 'lfa',
138
+ 'd',
139
+ 'a',
140
+ 'e',
141
+ ] as const
142
+
126
143
  /**
127
144
  * Upstash bills every call a script makes, so scripts read the counter and
128
145
  * split with one MGET, read metadata with one HMGET per hash (`read_fields`),
@@ -278,6 +295,33 @@ local function flush_queue()
278
295
  end
279
296
  `
280
297
 
298
+ /**
299
+ * Settles one unfinished event for scripts that include ENQUEUE_LUA: it
300
+ * leaves the pending and ready sets, and a lane head hands its lane to the
301
+ * next unfinished event. A dead-lettered successor stays out of the ready set.
302
+ */
303
+ const SETTLE_LUA = `
304
+ local function settle(field, lane, next_field)
305
+ redis.call('ZREM', PENDING, field)
306
+ redis.call('ZREM', READY, field)
307
+ if lane == '' then return end
308
+ local head_key = lane_field(lane, 'head')
309
+ if redis.call('HGET', META, head_key) ~= field then return end
310
+ while next_field do
311
+ local row = read_fields({ next_field }, { 'p', 'f', 'next' })[next_field]
312
+ if not row[1] then
313
+ redis.call('HSET', META, head_key, next_field)
314
+ if not row[2] then
315
+ redis.call('ZADD', READY, tonumber(next_field), next_field)
316
+ end
317
+ return
318
+ end
319
+ next_field = row[3]
320
+ end
321
+ clear_lane(lane)
322
+ end
323
+ `
324
+
281
325
  /**
282
326
  * Event-id ownership for scripts that add events. Each script reads the
283
327
  * counter, split, clear boundary, and per-id keys with one MGET and applies
@@ -450,7 +494,7 @@ return { 'ok', tostring(base + 1), pending_after }
450
494
  /**
451
495
  * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts,
452
496
  * metadata split.
453
- * ARGV: idx, attempt, error, maxFailures, nowMs.
497
+ * ARGV: idx, attempt, error, maxFailures, nowMs, retryAtMs ('' for none).
454
498
  */
455
499
  const FAIL_ATTEMPT_LUA = `
456
500
  local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[7]
@@ -482,8 +526,14 @@ put(meta_key, field .. ':e', ARGV[3])
482
526
  put(meta_key, field .. ':lf', ARGV[5])
483
527
  put(meta_key, field .. ':lfa', ARGV[2])
484
528
  if dead then put(meta_key, field .. ':f', ARGV[5]) end
529
+ local hold = not dead and ARGV[6] ~= ''
530
+ if hold then put(meta_key, field .. ':ce', ARGV[6]) end
485
531
  flush()
486
- redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
532
+ if hold then
533
+ redis.call('HDEL', meta_key, field .. ':ch')
534
+ else
535
+ redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
536
+ end
487
537
  if dead then
488
538
  redis.call('ZREM', KEYS[4], field)
489
539
  release_snapshot_pins(KEYS[5], KEYS[6])
@@ -493,23 +543,29 @@ return { 'failed', tostring(failures) }
493
543
  `
494
544
 
495
545
  /**
496
- * KEYS: store, counter, meta, pending, ready, metadata split.
497
- * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
546
+ * KEYS: store, counter, meta, pending, ready, metadata split, snapshot pin
547
+ * counts.
548
+ * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes, then
549
+ * maxFailures ('' for none) and the event snapshot-pins key prefix.
498
550
  * The ready set is a subset of the pending set, so an empty ready set is
499
551
  * enough to report 'settled'. A claimed result ends with the clear boundary.
500
552
  */
501
553
  const CLAIM_AVAILABLE_LUA = `
502
554
  local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[6]
503
555
  ${META_LUA}
556
+ ${RELEASE_PINS_LUA}
504
557
  local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
505
558
  local excluded = {}
559
+ local tail = 5 + tonumber(ARGV[4])
506
560
  for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
561
+ local max_failures = tonumber(ARGV[tail])
507
562
  local candidates = {}
508
563
  for _, field in ipairs(ready) do
509
564
  if not excluded[field] then candidates[#candidates + 1] = field end
510
565
  end
511
566
  local rows = read_fields(candidates, { 'ce', 'd', 'fc', 'a', 'e', 'lf', 'lfa' })
512
567
  local claimed = {}
568
+ local dead = {}
513
569
  local retry_at = nil
514
570
  for _, field in ipairs(candidates) do
515
571
  local row = rows[field]
@@ -519,29 +575,63 @@ for _, field in ipairs(candidates) do
519
575
  else
520
576
  local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
521
577
  if #entries == 0 then return { 'missing', field } end
522
- local attempt = tostring(tonumber(row[2] or '0') + 1)
523
- local first_claimed = row[3] or ARGV[2]
524
578
  local meta_key = mkey(field)
579
+ local attempt = tostring(tonumber(row[2] or '0') + 1)
580
+ -- An expiry that is not the current attempt's retry hold is a lapsed
581
+ -- claim: its attempt never finished.
582
+ local lapsed = row[1] and row[7] ~= row[2]
583
+ local failures = tonumber(row[4] or '0')
584
+ if lapsed then
585
+ failures = failures + 1
586
+ put(meta_key, field .. ':a', tostring(failures))
587
+ end
525
588
  put(meta_key, field .. ':d', attempt)
526
- put(meta_key, field .. ':fc', first_claimed)
527
- put(meta_key, field .. ':lc', ARGV[2])
528
- put(meta_key, field .. ':ch', ARGV[1])
529
- put(meta_key, field .. ':ce', ARGV[3])
530
- claimed[#claimed + 1] = {
531
- attempt,
532
- row[4] or '0',
533
- row[5] or false,
534
- first_claimed,
535
- ARGV[2],
536
- row[6] or false,
537
- row[7] or false,
538
- ARGV[1],
539
- ARGV[3],
540
- entries[1]
541
- }
589
+ if lapsed and max_failures and failures >= max_failures then
590
+ -- That takeover spent the last failed attempt: dead-letter, unclaimed.
591
+ -- Advancing the attempt fences the lapsed one; nothing runs.
592
+ put(meta_key, field .. ':f', ARGV[2])
593
+ dead[#dead + 1] = field
594
+ claimed[#claimed + 1] = {
595
+ attempt,
596
+ tostring(failures),
597
+ row[5] or false,
598
+ row[3] or false,
599
+ read_fields({ field }, { 'lc' })[field][1] or false,
600
+ row[6] or false,
601
+ row[7] or false,
602
+ false,
603
+ false,
604
+ entries[1],
605
+ ARGV[2]
606
+ }
607
+ else
608
+ local first_claimed = row[3] or ARGV[2]
609
+ put(meta_key, field .. ':fc', first_claimed)
610
+ put(meta_key, field .. ':lc', ARGV[2])
611
+ put(meta_key, field .. ':ch', ARGV[1])
612
+ put(meta_key, field .. ':ce', ARGV[3])
613
+ claimed[#claimed + 1] = {
614
+ attempt,
615
+ tostring(failures),
616
+ row[5] or false,
617
+ first_claimed,
618
+ ARGV[2],
619
+ row[6] or false,
620
+ row[7] or false,
621
+ ARGV[1],
622
+ ARGV[3],
623
+ entries[1],
624
+ false
625
+ }
626
+ end
542
627
  end
543
628
  end
544
629
  flush()
630
+ for _, field in ipairs(dead) do
631
+ redis.call('HDEL', mkey(field), field .. ':ch', field .. ':ce')
632
+ redis.call('ZREM', KEYS[5], field)
633
+ release_snapshot_pins(ARGV[tail + 1] .. field, KEYS[7])
634
+ end
545
635
  if #claimed > 0 then
546
636
  local result = { 'claimed', tostring(#claimed) }
547
637
  for _, claim in ipairs(claimed) do
@@ -611,25 +701,7 @@ ${ENQUEUE_LUA}
611
701
  ${ID_OWNERS_LUA}
612
702
  ${RELEASE_PINS_LUA}
613
703
  ${WAKE_LUA}
614
- local function settle(field, lane, next_field)
615
- redis.call('ZREM', PENDING, field)
616
- redis.call('ZREM', READY, field)
617
- if lane == '' then return end
618
- local head_key = lane_field(lane, 'head')
619
- if redis.call('HGET', META, head_key) ~= field then return end
620
- while next_field do
621
- local row = read_fields({ next_field }, { 'p', 'f', 'next' })[next_field]
622
- if not row[1] then
623
- redis.call('HSET', META, head_key, next_field)
624
- if not row[2] then
625
- redis.call('ZADD', READY, tonumber(next_field), next_field)
626
- end
627
- return
628
- end
629
- next_field = row[3]
630
- end
631
- clear_lane(lane)
632
- end
704
+ ${SETTLE_LUA}
633
705
  local parent = ARGV[3]
634
706
  local attempt = tonumber(ARGV[4])
635
707
  local n = tonumber(ARGV[5])
@@ -715,6 +787,78 @@ if n > 0 then wake(ARGV[6 + n * 5], tostring(total + n), ARGV[2]) end
715
787
  return { 'ok', tostring(total + 1) }
716
788
  `
717
789
 
790
+ /**
791
+ * KEYS: counter, meta, pending, ready, metadata split.
792
+ * ARGV: idx, action ('retry' | 'skip'), nowMs.
793
+ * Returns [redriven, hasPending]. Acts only on an unfinished, dead-lettered
794
+ * event above the clear boundary. A retry keeps the event pending and makes
795
+ * it ready again when it is unlaned or its lane's head.
796
+ */
797
+ const REDRIVE_LUA = `
798
+ local COUNTER, META, SPLIT, PENDING, READY = KEYS[1], KEYS[2], KEYS[5], KEYS[3], KEYS[4]
799
+ ${META_LUA}
800
+ ${ENQUEUE_LUA}
801
+ ${SETTLE_LUA}
802
+ local function result(redriven)
803
+ return { redriven, redis.call('ZCARD', PENDING) > 0 and '1' or '0' }
804
+ end
805
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
806
+ local total = tonumber(counts[1] or '0')
807
+ use_split(counts[2], counts[1], counts[3])
808
+ local idx = tonumber(ARGV[1])
809
+ if idx <= boundary or idx > total then return result('0') end
810
+ local field = tostring(idx)
811
+ local row = read_fields({ field }, { 'p', 'f', 'l', 'next' })[field]
812
+ if row[1] or not row[2] then return result('0') end
813
+ local meta_key = mkey(field)
814
+ local lane = row[3] or ''
815
+ if ARGV[2] == 'skip' then
816
+ put(meta_key, field .. ':p', ARGV[3])
817
+ settle(field, lane, row[4])
818
+ flush_queue()
819
+ flush()
820
+ else
821
+ redis.call('HDEL', meta_key, field .. ':f', field .. ':a')
822
+ if lane == '' or redis.call('HGET', META, lane_field(lane, 'head')) == field then
823
+ redis.call('ZADD', READY, idx, field)
824
+ end
825
+ end
826
+ return result('1')
827
+ `
828
+
829
+ /**
830
+ * KEYS: store, counter, meta, pending, metadata split.
831
+ * Returns the dead-lettered events above the clear boundary, oldest first,
832
+ * as pairs of a stream entry and its STORED_EVENT_META_SUFFIXES columns.
833
+ * Every unfinished event is in the pending set, so the scan is bounded by
834
+ * unfinished work, not history; only dead letters read their full columns.
835
+ */
836
+ const DEAD_LETTERS_LUA = `
837
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[5]
838
+ ${META_LUA}
839
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
840
+ use_split(counts[2], counts[1], counts[3])
841
+ local candidates = {}
842
+ for _, field in ipairs(redis.call('ZRANGE', KEYS[4], 0, -1)) do
843
+ if tonumber(field) > boundary then candidates[#candidates + 1] = field end
844
+ end
845
+ local status = read_fields(candidates, { 'p', 'f' })
846
+ local dead = {}
847
+ for _, field in ipairs(candidates) do
848
+ if status[field][2] and not status[field][1] then dead[#dead + 1] = field end
849
+ end
850
+ local rows = read_fields(dead, { ${STORED_EVENT_META_SUFFIXES.map((suffix) => `'${suffix}'`).join(', ')} })
851
+ local result = {}
852
+ for _, field in ipairs(dead) do
853
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
854
+ if #entries > 0 then
855
+ result[#result + 1] = entries[1]
856
+ result[#result + 1] = rows[field]
857
+ end
858
+ end
859
+ return result
860
+ `
861
+
718
862
  /**
719
863
  * KEYS: head, history, event counter, event meta, pin counts, metadata split.
720
864
  * ARGV: nowMs, event pin key prefix, write count, then per write:
@@ -1117,22 +1261,6 @@ const fieldMap = (fields: string[]): Record<string, string> => {
1117
1261
  return map
1118
1262
  }
1119
1263
 
1120
- const STORED_EVENT_META_SUFFIXES = [
1121
- 'p',
1122
- 'pa',
1123
- 'ri',
1124
- 'ch',
1125
- 'ce',
1126
- 'f',
1127
- 'fc',
1128
- 'lc',
1129
- 'lf',
1130
- 'lfa',
1131
- 'd',
1132
- 'a',
1133
- 'e',
1134
- ] as const
1135
-
1136
1264
  const entryIndex = (entryId: string): number =>
1137
1265
  Number(entryId.slice(0, entryId.indexOf('-')))
1138
1266
 
@@ -1240,6 +1368,46 @@ const toCause = (raw: string | undefined): EventCause | null => {
1240
1368
  }
1241
1369
  }
1242
1370
 
1371
+ type MetaSuffix = (typeof STORED_EVENT_META_SUFFIXES)[number]
1372
+
1373
+ /** A stream entry joined with its metadata columns; absent columns are unset. */
1374
+ const toStoredEvent = (
1375
+ sessionId: string,
1376
+ entry: StreamEntry,
1377
+ column: (suffix: MetaSuffix) => string | undefined,
1378
+ ): StoredEvent => {
1379
+ const fields = fieldMap(entry[1])
1380
+ const date = (suffix: MetaSuffix): Date | null => {
1381
+ const value = column(suffix)
1382
+ return value === undefined ? null : new Date(Number(value))
1383
+ }
1384
+ const count = (suffix: MetaSuffix): number | null => {
1385
+ const value = column(suffix)
1386
+ return value === undefined ? null : Number(value)
1387
+ }
1388
+ const returnedEventIds = column('ri')
1389
+ return Object.assign(toEvent(sessionId, entry), {
1390
+ cause: toCause(fields['cause']),
1391
+ lane: fields['lane'] === '' ? null : fields['lane']!,
1392
+ processedAt: date('p'),
1393
+ processedByAttempt: count('pa'),
1394
+ returnedEventIds:
1395
+ returnedEventIds === undefined
1396
+ ? null
1397
+ : decodeReturnedEventIds(returnedEventIds),
1398
+ firstClaimedAt: date('fc'),
1399
+ lastClaimedAt: date('lc'),
1400
+ attemptCount: count('d') ?? 0,
1401
+ claimHolder: column('ch') ?? null,
1402
+ claimExpiresAt: date('ce'),
1403
+ failureCount: count('a') ?? 0,
1404
+ lastFailedAt: date('lf'),
1405
+ lastFailedAttempt: count('lfa'),
1406
+ lastError: column('e') ?? null,
1407
+ failedAt: date('f'),
1408
+ })
1409
+ }
1410
+
1243
1411
  const toClaimAvailableResult = (
1244
1412
  sessionId: string,
1245
1413
  reply: unknown[],
@@ -1249,7 +1417,7 @@ const toClaimAvailableResult = (
1249
1417
  const count = Number(reply[1])
1250
1418
  const events: StoredEvent[] = []
1251
1419
  for (let i = 0; i < count; i++) {
1252
- const offset = 2 + i * 10
1420
+ const offset = 2 + i * 11
1253
1421
  const entry = reply[offset + 9] as StreamEntry
1254
1422
  const event = toEvent(sessionId, entry)
1255
1423
  const fields = fieldMap(entry[1])
@@ -1264,10 +1432,17 @@ const toClaimAvailableResult = (
1264
1432
  reply[offset + 3] === null
1265
1433
  ? null
1266
1434
  : new Date(Number(reply[offset + 3])),
1267
- lastClaimedAt: new Date(Number(reply[offset + 4])),
1435
+ lastClaimedAt:
1436
+ reply[offset + 4] === null
1437
+ ? null
1438
+ : new Date(Number(reply[offset + 4])),
1268
1439
  attemptCount: Number(reply[offset]),
1269
- claimHolder: String(reply[offset + 7]),
1270
- claimExpiresAt: new Date(Number(reply[offset + 8])),
1440
+ claimHolder:
1441
+ reply[offset + 7] === null ? null : String(reply[offset + 7]),
1442
+ claimExpiresAt:
1443
+ reply[offset + 8] === null
1444
+ ? null
1445
+ : new Date(Number(reply[offset + 8])),
1271
1446
  failureCount: Number(reply[offset + 1]),
1272
1447
  lastFailedAt:
1273
1448
  reply[offset + 5] === null
@@ -1277,11 +1452,14 @@ const toClaimAvailableResult = (
1277
1452
  reply[offset + 6] === null ? null : Number(reply[offset + 6]),
1278
1453
  lastError:
1279
1454
  reply[offset + 2] === null ? null : String(reply[offset + 2]),
1280
- failedAt: null,
1455
+ failedAt:
1456
+ reply[offset + 10] === null
1457
+ ? null
1458
+ : new Date(Number(reply[offset + 10])),
1281
1459
  }),
1282
1460
  )
1283
1461
  }
1284
- const clearIndex = Number(reply[2 + count * 10] ?? 0)
1462
+ const clearIndex = Number(reply[2 + count * 11] ?? 0)
1285
1463
  if (clearIndex > 0)
1286
1464
  for (const event of events) event.clearIndex = clearIndex
1287
1465
  return {
@@ -1471,50 +1649,12 @@ export function createRedisStoreCore(
1471
1649
  }
1472
1650
  }
1473
1651
  return entries.map((entry) => {
1474
- const event = toEvent(sessionId, entry)
1475
- const idx = event.index
1476
- const fields = fieldMap(entry[1])
1477
- const processedAt = meta[`${idx}:p`]
1478
- const processedByAttempt = meta[`${idx}:pa`]
1479
- const returnedEventIds = meta[`${idx}:ri`]
1480
- const claimHolder = meta[`${idx}:ch`]
1481
- const claimExpiresAt = meta[`${idx}:ce`]
1482
- const failedAt = meta[`${idx}:f`]
1483
- const firstClaimedAt = meta[`${idx}:fc`]
1484
- const lastClaimedAt = meta[`${idx}:lc`]
1485
- const lastFailedAt = meta[`${idx}:lf`]
1486
- const lastFailedAttempt = meta[`${idx}:lfa`]
1487
- return Object.assign(event, {
1488
- cause: toCause(fields['cause']),
1489
- lane: fields['lane'] === '' ? null : fields['lane']!,
1490
- processedAt:
1491
- processedAt === undefined ? null : new Date(Number(processedAt)),
1492
- processedByAttempt:
1493
- processedByAttempt === undefined ? null : Number(processedByAttempt),
1494
- returnedEventIds:
1495
- returnedEventIds === undefined
1496
- ? null
1497
- : decodeReturnedEventIds(returnedEventIds),
1498
- firstClaimedAt:
1499
- firstClaimedAt === undefined
1500
- ? null
1501
- : new Date(Number(firstClaimedAt)),
1502
- lastClaimedAt:
1503
- lastClaimedAt === undefined ? null : new Date(Number(lastClaimedAt)),
1504
- attemptCount: Number(meta[`${idx}:d`] ?? 0),
1505
- claimHolder: claimHolder ?? null,
1506
- claimExpiresAt:
1507
- claimExpiresAt === undefined
1508
- ? null
1509
- : new Date(Number(claimExpiresAt)),
1510
- failureCount: Number(meta[`${idx}:a`] ?? 0),
1511
- lastFailedAt:
1512
- lastFailedAt === undefined ? null : new Date(Number(lastFailedAt)),
1513
- lastFailedAttempt:
1514
- lastFailedAttempt === undefined ? null : Number(lastFailedAttempt),
1515
- lastError: meta[`${idx}:e`] ?? null,
1516
- failedAt: failedAt === undefined ? null : new Date(Number(failedAt)),
1517
- })
1652
+ const idx = entryIndex(entry[0])
1653
+ return toStoredEvent(
1654
+ sessionId,
1655
+ entry,
1656
+ (suffix) => meta[`${idx}:${suffix}`],
1657
+ )
1518
1658
  })
1519
1659
  }
1520
1660
 
@@ -1858,6 +1998,7 @@ export function createRedisStoreCore(
1858
1998
  ttlMs,
1859
1999
  expiresAtMs,
1860
2000
  excludeIndexes,
2001
+ maxFailures,
1861
2002
  }) {
1862
2003
  return wrap(async () => {
1863
2004
  const now = clock.now().getTime()
@@ -1872,8 +2013,17 @@ export function createRedisStoreCore(
1872
2013
  pendingKey(sessionId),
1873
2014
  readyKey(sessionId),
1874
2015
  metaSplitKey(sessionId),
2016
+ snapshotPinCountsKey(sessionId),
2017
+ ],
2018
+ [
2019
+ holder,
2020
+ now,
2021
+ expiresAt,
2022
+ excluded.length,
2023
+ ...excluded,
2024
+ maxFailures ?? '',
2025
+ snapshotEventPinsPrefix(sessionId),
1875
2026
  ],
1876
- [holder, now, expiresAt, excluded.length, ...excluded],
1877
2027
  )) as unknown[]
1878
2028
  return toClaimAvailableResult(sessionId, reply)
1879
2029
  })
@@ -2009,8 +2159,16 @@ export function createRedisStoreCore(
2009
2159
  })
2010
2160
  },
2011
2161
 
2012
- async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
2162
+ async failAttempt({
2163
+ sessionId,
2164
+ index,
2165
+ attempt,
2166
+ error,
2167
+ maxFailures,
2168
+ retryAfterMs,
2169
+ }) {
2013
2170
  return wrap(async () => {
2171
+ const now = clock.now().getTime()
2014
2172
  const reply = (await evalScript(
2015
2173
  FAIL_ATTEMPT_LUA,
2016
2174
  [
@@ -2022,7 +2180,14 @@ export function createRedisStoreCore(
2022
2180
  snapshotPinCountsKey(sessionId),
2023
2181
  metaSplitKey(sessionId),
2024
2182
  ],
2025
- [index, attempt, error, maxFailures, clock.now().getTime()],
2183
+ [
2184
+ index,
2185
+ attempt,
2186
+ error,
2187
+ maxFailures,
2188
+ now,
2189
+ retryAfterMs === undefined ? '' : now + retryAfterMs,
2190
+ ],
2026
2191
  )) as string[]
2027
2192
  const outcome = reply[0]
2028
2193
  if (outcome === 'cleared')
@@ -2039,6 +2204,53 @@ export function createRedisStoreCore(
2039
2204
  })
2040
2205
  },
2041
2206
 
2207
+ async deadLetters(sessionId) {
2208
+ return wrap(async () => {
2209
+ const reply = (await evalScript(
2210
+ DEAD_LETTERS_LUA,
2211
+ [
2212
+ storeKey(sessionId),
2213
+ countKey(sessionId),
2214
+ metaKey(sessionId),
2215
+ pendingKey(sessionId),
2216
+ metaSplitKey(sessionId),
2217
+ ],
2218
+ [],
2219
+ )) as unknown[]
2220
+ const events: StoredEvent[] = []
2221
+ for (let i = 0; i + 1 < reply.length; i += 2) {
2222
+ const columns = reply[i + 1] as Array<string | null>
2223
+ events.push(
2224
+ toStoredEvent(
2225
+ sessionId,
2226
+ reply[i] as StreamEntry,
2227
+ (suffix) =>
2228
+ columns[STORED_EVENT_META_SUFFIXES.indexOf(suffix)] ??
2229
+ undefined,
2230
+ ),
2231
+ )
2232
+ }
2233
+ return events
2234
+ })
2235
+ },
2236
+
2237
+ async redrive({ sessionId, index, action }) {
2238
+ return wrap(async () => {
2239
+ const [redriven, hasPending] = (await evalScript(
2240
+ REDRIVE_LUA,
2241
+ [
2242
+ countKey(sessionId),
2243
+ metaKey(sessionId),
2244
+ pendingKey(sessionId),
2245
+ readyKey(sessionId),
2246
+ metaSplitKey(sessionId),
2247
+ ],
2248
+ [index, action, clock.now().getTime()],
2249
+ )) as [string, string]
2250
+ return { redriven: redriven === '1', hasPending: hasPending === '1' }
2251
+ })
2252
+ },
2253
+
2042
2254
  async readState(sessionId, reducerName, stateOptions) {
2043
2255
  return wrap(() =>
2044
2256
  retryAfterGap(async () => {
@@ -292,6 +292,8 @@ export function createRedisNotifyStore(options: {
292
292
  putSnapshots: core.putSnapshots,
293
293
  clear: core.clear!,
294
294
  reclaim: core.reclaim!,
295
+ deadLetters: core.deadLetters!,
296
+ redrive: core.redrive!,
295
297
 
296
298
  presence: {
297
299
  async set(ns, participant, values, meta) {