experimental-a2 0.6.0 → 0.8.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 (148) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +20 -17
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +1 -1
  19. package/dist/react.js +1 -1
  20. package/dist/scheduler-qstash.d.ts +3 -3
  21. package/dist/scheduler-qstash.js +4 -5
  22. package/dist/scheduler-qstash.js.map +1 -1
  23. package/dist/scheduler-vercel.d.ts +2 -2
  24. package/dist/scheduler-vercel.js +4 -4
  25. package/dist/scheduler-vercel.js.map +1 -1
  26. package/dist/{server-Duw6MVlB.js → server-B2XNevQA.js} +830 -131
  27. package/dist/server-B2XNevQA.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DjPhHnbI.d.ts} +71 -50
  29. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  30. package/dist/server.d.ts +3 -3
  31. package/dist/server.js +1 -1
  32. package/dist/store-N8PXxDAS.js.map +1 -1
  33. package/dist/{store-DysUkTH3.d.ts → store-RJO35BMj.d.ts} +24 -62
  34. package/dist/store-RJO35BMj.d.ts.map +1 -0
  35. package/dist/store-memory.d.ts +1 -1
  36. package/dist/store-memory.d.ts.map +1 -1
  37. package/dist/store-memory.js +80 -78
  38. package/dist/store-memory.js.map +1 -1
  39. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  40. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  41. package/dist/store-postgres.d.ts +1 -1
  42. package/dist/store-postgres.d.ts.map +1 -1
  43. package/dist/store-postgres.js +231 -182
  44. package/dist/store-postgres.js.map +1 -1
  45. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DT01r4GZ.js} +213 -161
  46. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  47. package/dist/store-redis-http.d.ts +1 -1
  48. package/dist/store-redis-http.js +3 -4
  49. package/dist/store-redis-http.js.map +1 -1
  50. package/dist/store-redis.d.ts +1 -1
  51. package/dist/store-redis.js +4 -5
  52. package/dist/store-redis.js.map +1 -1
  53. package/dist/store-sqlite.d.ts +1 -1
  54. package/dist/store-sqlite.d.ts.map +1 -1
  55. package/dist/store-sqlite.js +104 -91
  56. package/dist/store-sqlite.js.map +1 -1
  57. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  58. package/dist/wire-B6te_wns.js.map +1 -0
  59. package/docs/concepts/02-handlers.mdx +4 -0
  60. package/docs/concepts/04-state.mdx +57 -9
  61. package/docs/guides/03-react.mdx +20 -28
  62. package/docs/guides/05-production.mdx +9 -11
  63. package/docs/guides/06-ai-agents.mdx +10 -15
  64. package/docs/guides/09-presence.mdx +14 -19
  65. package/docs/guides/10-transports.mdx +104 -86
  66. package/docs/reference/01-api.mdx +182 -293
  67. package/docs/reference/02-errors.mdx +5 -7
  68. package/package.json +1 -14
  69. package/src/ai-server.ts +36 -15
  70. package/src/client.ts +2 -2
  71. package/src/errors.ts +1 -0
  72. package/src/internal.ts +3 -62
  73. package/src/push-envelope.ts +24 -21
  74. package/src/scheduler-qstash.ts +3 -3
  75. package/src/scheduler-vercel.ts +2 -2
  76. package/src/server-fetch.ts +344 -0
  77. package/src/server.ts +315 -312
  78. package/src/session-socket.ts +36 -20
  79. package/src/sse.ts +2 -2
  80. package/src/store-memory.ts +138 -101
  81. package/src/store-postgres.ts +355 -238
  82. package/src/store-redis-core.ts +247 -237
  83. package/src/store-redis-http.ts +1 -2
  84. package/src/store-redis.ts +1 -2
  85. package/src/store-sqlite.ts +191 -153
  86. package/src/store.ts +24 -66
  87. package/src/wire.ts +2 -1
  88. package/dist/ai-D_PGS-JR.d.ts +0 -334
  89. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  90. package/dist/cli-B3VuxoDe.js +0 -599
  91. package/dist/cli-B3VuxoDe.js.map +0 -1
  92. package/dist/cli-bin.d.ts +0 -1
  93. package/dist/cli-bin.js +0 -7
  94. package/dist/cli-bin.js.map +0 -1
  95. package/dist/cli.d.ts +0 -20
  96. package/dist/cli.d.ts.map +0 -1
  97. package/dist/cli.js +0 -2
  98. package/dist/client-CdMqi7mC.d.ts +0 -202
  99. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  100. package/dist/client-Dj5d3SP_.js +0 -1026
  101. package/dist/client-Dj5d3SP_.js.map +0 -1
  102. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  103. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  104. package/dist/devtools-kJJaORn-.js +0 -340
  105. package/dist/devtools-kJJaORn-.js.map +0 -1
  106. package/dist/devtools-server.browser.d.ts +0 -1
  107. package/dist/devtools-server.browser.js +0 -6
  108. package/dist/devtools-server.browser.js.map +0 -1
  109. package/dist/devtools-server.d.ts +0 -23
  110. package/dist/devtools-server.d.ts.map +0 -1
  111. package/dist/devtools-server.js +0 -1270
  112. package/dist/devtools-server.js.map +0 -1
  113. package/dist/devtools.d.ts +0 -2
  114. package/dist/devtools.js +0 -2
  115. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  116. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  117. package/dist/http.d.ts +0 -151
  118. package/dist/http.d.ts.map +0 -1
  119. package/dist/http.js +0 -706
  120. package/dist/http.js.map +0 -1
  121. package/dist/inspection-DaxB5jM2.js +0 -13
  122. package/dist/inspection-DaxB5jM2.js.map +0 -1
  123. package/dist/internal-DstsI6Re.js.map +0 -1
  124. package/dist/platform-B4TnJtWu.js +0 -34
  125. package/dist/platform-B4TnJtWu.js.map +0 -1
  126. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  127. package/dist/server-Duw6MVlB.js.map +0 -1
  128. package/dist/store-DysUkTH3.d.ts.map +0 -1
  129. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  130. package/dist/testing.browser.d.ts +0 -1
  131. package/dist/testing.browser.js +0 -6
  132. package/dist/testing.browser.js.map +0 -1
  133. package/dist/testing.d.ts +0 -32
  134. package/dist/testing.d.ts.map +0 -1
  135. package/dist/testing.js +0 -103
  136. package/dist/testing.js.map +0 -1
  137. package/dist/wire-BFQmSJ-9.js.map +0 -1
  138. package/docs/guides/07-devtools.mdx +0 -229
  139. package/src/cli-bin.ts +0 -5
  140. package/src/cli.ts +0 -1046
  141. package/src/devtools-app.ts +0 -989
  142. package/src/devtools-server.browser.ts +0 -5
  143. package/src/devtools-server.ts +0 -604
  144. package/src/devtools.ts +0 -716
  145. package/src/http.ts +0 -394
  146. package/src/inspection.ts +0 -39
  147. package/src/testing.browser.ts +0 -5
  148. package/src/testing.ts +0 -185
@@ -23,7 +23,6 @@ import { nullProtoRecord } from './internal.ts'
23
23
  import { decodeReturnedEventIds } from './store-codec.ts'
24
24
  import type {
25
25
  A2Store,
26
- A2StoreInspection,
27
26
  Clock,
28
27
  CompleteAttemptResult,
29
28
  Event,
@@ -56,11 +55,7 @@ export type RedisStoreCoreOptions = {
56
55
  notify?: (sessionId: string, count: number) => void
57
56
  }
58
57
 
59
- export type RedisStoreCore = Omit<
60
- A2Store,
61
- 'stream' | 'inspect' | 'presence'
62
- > & {
63
- inspect: A2StoreInspection
58
+ export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
64
59
  /**
65
60
  * The storage half of the presence capability, shared by both
66
61
  * transports. `set` returns the applied patch — null when every
@@ -98,8 +93,8 @@ const READ_PAGE_SIZE = 256
98
93
  // ── Lua scripts (each runs atomically) ───────────────────────────────
99
94
 
100
95
  /**
101
- * KEYS: store stream, counter, global id hash, session index, metadata,
102
- * pending, ready, watched.
96
+ * KEYS: store stream, counter, global id hash, metadata, pending, ready,
97
+ * watched.
103
98
  * ARGV: sessionId, nowMs, n, then per event:
104
99
  * id, type, payloadJson, causeJson, lane, settled.
105
100
  * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |
@@ -111,24 +106,24 @@ local function lane_field(lane, suffix)
111
106
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
112
107
  end
113
108
  local function enqueue(field, idx, lane)
114
- redis.call('ZADD', KEYS[6], idx, field)
109
+ redis.call('ZADD', KEYS[5], idx, field)
115
110
  if lane == '' then
116
- redis.call('ZADD', KEYS[7], idx, field)
111
+ redis.call('ZADD', KEYS[6], idx, field)
117
112
  return
118
113
  end
119
114
  local tail_key = lane_field(lane, 'tail')
120
- local tail = redis.call('HGET', KEYS[5], tail_key)
115
+ local tail = redis.call('HGET', KEYS[4], tail_key)
121
116
  if tail then
122
- redis.call('HSET', KEYS[5], tail .. ':next', field)
117
+ redis.call('HSET', KEYS[4], tail .. ':next', field)
123
118
  else
124
- redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
125
- redis.call('ZADD', KEYS[7], idx, field)
119
+ redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
120
+ redis.call('ZADD', KEYS[6], idx, field)
126
121
  end
127
- redis.call('HSET', KEYS[5], tail_key, field)
122
+ redis.call('HSET', KEYS[4], tail_key, field)
128
123
  end
129
124
  local n = tonumber(ARGV[3])
130
125
  local base = tonumber(redis.call('GET', KEYS[2]) or '0')
131
- local has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
126
+ local has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
132
127
  if n == 0 then return { 'empty', has_pending } end
133
128
  local dups = {}
134
129
  for i = 1, n do
@@ -155,9 +150,9 @@ for i = 1, n do
155
150
  return { 'no_parent', tostring(parsed.index) }
156
151
  end
157
152
  local field = tostring(pidx)
158
- local dispatches = tonumber(redis.call('HGET', KEYS[5], field .. ':d') or '0')
153
+ local dispatches = tonumber(redis.call('HGET', KEYS[4], field .. ':d') or '0')
159
154
  if dispatches ~= tonumber(parsed.attempt) or
160
- redis.call('HEXISTS', KEYS[5], field .. ':f') == 1 then
155
+ redis.call('HEXISTS', KEYS[4], field .. ':f') == 1 then
161
156
  return { 'superseded', field }
162
157
  end
163
158
  end
@@ -174,22 +169,37 @@ for i = 1, n do
174
169
  'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
175
170
  'cause', cause, 'lane', lane)
176
171
  redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
177
- redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
172
+ redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
178
173
  if settled == '1' then
179
- redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
174
+ redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
180
175
  else
181
176
  enqueue(tostring(idx), idx, lane)
182
177
  end
183
178
  end
184
179
  redis.call('SET', KEYS[2], tostring(base + n))
185
- redis.call('ZADD', KEYS[4], 0, ARGV[1])
186
- has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
180
+ has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
187
181
  return { 'ok', tostring(base + 1), has_pending,
188
- tostring(redis.call('EXISTS', KEYS[8])) }
182
+ tostring(redis.call('EXISTS', KEYS[7])) }
189
183
  `
190
184
 
191
- /** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
185
+ /**
186
+ * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts.
187
+ * ARGV: idx, attempt, error, maxFailures, nowMs.
188
+ */
192
189
  const FAIL_ATTEMPT_LUA = `
190
+ local function release_snapshot_pins()
191
+ local refs = redis.call('SMEMBERS', KEYS[5])
192
+ for _, ref in ipairs(refs) do
193
+ local count = redis.call('HINCRBY', KEYS[6], ref, -1)
194
+ if count <= 0 then
195
+ redis.call('HDEL', KEYS[6], ref)
196
+ local checkpoint = cjson.decode(ref)
197
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
198
+ end
199
+ end
200
+ redis.call('DEL', KEYS[5])
201
+ if redis.call('HLEN', KEYS[6]) == 0 then redis.call('DEL', KEYS[6]) end
202
+ end
193
203
  local function lane_field(lane, suffix)
194
204
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
195
205
  end
@@ -243,6 +253,7 @@ redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
243
253
  if failures >= tonumber(ARGV[4]) then
244
254
  redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
245
255
  redis.call('ZREM', KEYS[4], field)
256
+ release_snapshot_pins()
246
257
  return { 'dead_lettered', tostring(failures) }
247
258
  end
248
259
  return { 'failed', tostring(failures) }
@@ -336,61 +347,75 @@ return result
336
347
  `
337
348
 
338
349
  /**
339
- * KEYS: store, counter, ids, session index, meta, pending, ready, watched.
350
+ * KEYS: store, counter, ids, meta, pending, ready, watched,
351
+ * event snapshot pins, snapshot pin counts.
340
352
  * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
341
353
  * id, type, payloadJson, lane, settled.
342
354
  */
343
355
  const COMPLETE_ATTEMPT_LUA = `
356
+ local function release_snapshot_pins()
357
+ local refs = redis.call('SMEMBERS', KEYS[8])
358
+ for _, ref in ipairs(refs) do
359
+ local count = redis.call('HINCRBY', KEYS[9], ref, -1)
360
+ if count <= 0 then
361
+ redis.call('HDEL', KEYS[9], ref)
362
+ local checkpoint = cjson.decode(ref)
363
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
364
+ end
365
+ end
366
+ redis.call('DEL', KEYS[8])
367
+ if redis.call('HLEN', KEYS[9]) == 0 then redis.call('DEL', KEYS[9]) end
368
+ end
344
369
  local function lane_field(lane, suffix)
345
370
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
346
371
  end
347
372
  local function settle(field)
373
+ redis.call('ZREM', KEYS[5], field)
348
374
  redis.call('ZREM', KEYS[6], field)
349
- redis.call('ZREM', KEYS[7], field)
350
- local lane = redis.call('HGET', KEYS[5], field .. ':l') or ''
375
+ local lane = redis.call('HGET', KEYS[4], field .. ':l') or ''
351
376
  if lane == '' then return end
352
377
  local head_key = lane_field(lane, 'head')
353
- if redis.call('HGET', KEYS[5], head_key) ~= field then return end
354
- local next_field = redis.call('HGET', KEYS[5], field .. ':next')
355
- while next_field and redis.call('HEXISTS', KEYS[5], next_field .. ':p') == 1 do
356
- next_field = redis.call('HGET', KEYS[5], next_field .. ':next')
378
+ if redis.call('HGET', KEYS[4], head_key) ~= field then return end
379
+ local next_field = redis.call('HGET', KEYS[4], field .. ':next')
380
+ while next_field and redis.call('HEXISTS', KEYS[4], next_field .. ':p') == 1 do
381
+ next_field = redis.call('HGET', KEYS[4], next_field .. ':next')
357
382
  end
358
383
  if next_field then
359
- redis.call('HSET', KEYS[5], head_key, next_field)
360
- if redis.call('HEXISTS', KEYS[5], next_field .. ':f') == 0 then
361
- redis.call('ZADD', KEYS[7], tonumber(next_field), next_field)
384
+ redis.call('HSET', KEYS[4], head_key, next_field)
385
+ if redis.call('HEXISTS', KEYS[4], next_field .. ':f') == 0 then
386
+ redis.call('ZADD', KEYS[6], tonumber(next_field), next_field)
362
387
  end
363
388
  else
364
- redis.call('HDEL', KEYS[5], head_key, lane_field(lane, 'tail'))
389
+ redis.call('HDEL', KEYS[4], head_key, lane_field(lane, 'tail'))
365
390
  end
366
391
  end
367
392
  local function enqueue(field, idx, lane)
368
- redis.call('ZADD', KEYS[6], idx, field)
393
+ redis.call('ZADD', KEYS[5], idx, field)
369
394
  if lane == '' then
370
- redis.call('ZADD', KEYS[7], idx, field)
395
+ redis.call('ZADD', KEYS[6], idx, field)
371
396
  return
372
397
  end
373
398
  local tail_key = lane_field(lane, 'tail')
374
- local tail = redis.call('HGET', KEYS[5], tail_key)
399
+ local tail = redis.call('HGET', KEYS[4], tail_key)
375
400
  if tail then
376
- redis.call('HSET', KEYS[5], tail .. ':next', field)
401
+ redis.call('HSET', KEYS[4], tail .. ':next', field)
377
402
  else
378
- redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
379
- redis.call('ZADD', KEYS[7], idx, field)
403
+ redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
404
+ redis.call('ZADD', KEYS[6], idx, field)
380
405
  end
381
- redis.call('HSET', KEYS[5], tail_key, field)
406
+ redis.call('HSET', KEYS[4], tail_key, field)
382
407
  end
383
408
  local parent = ARGV[3]
384
409
  local attempt = tonumber(ARGV[4])
385
410
  local n = tonumber(ARGV[5])
386
411
  local total = tonumber(redis.call('GET', KEYS[2]) or '0')
387
412
  if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
388
- local dispatches = tonumber(redis.call('HGET', KEYS[5], parent .. ':d') or '0')
389
- local processed = redis.call('HGET', KEYS[5], parent .. ':p')
390
- local processed_attempt = tonumber(redis.call('HGET', KEYS[5], parent .. ':pa') or '0')
413
+ local dispatches = tonumber(redis.call('HGET', KEYS[4], parent .. ':d') or '0')
414
+ local processed = redis.call('HGET', KEYS[4], parent .. ':p')
415
+ local processed_attempt = tonumber(redis.call('HGET', KEYS[4], parent .. ':pa') or '0')
391
416
  if processed then
392
417
  if processed_attempt ~= attempt then return { 'superseded' } end
393
- local raw_recorded_ids = redis.call('HGET', KEYS[5], parent .. ':ri')
418
+ local raw_recorded_ids = redis.call('HGET', KEYS[4], parent .. ':ri')
394
419
  if not raw_recorded_ids then return { 'partial' } end
395
420
  local recorded_ids = cjson.decode(raw_recorded_ids)
396
421
  if #recorded_ids ~= n then return { 'partial' } end
@@ -420,9 +445,9 @@ if processed then
420
445
  return result
421
446
  end
422
447
  if dispatches ~= attempt then return { 'superseded' } end
423
- if redis.call('HEXISTS', KEYS[5], parent .. ':ch') == 0 or
424
- redis.call('HEXISTS', KEYS[5], parent .. ':f') == 1 or
425
- tonumber(redis.call('HGET', KEYS[5], parent .. ':lfa') or '0') == attempt then
448
+ if redis.call('HEXISTS', KEYS[4], parent .. ':ch') == 0 or
449
+ redis.call('HEXISTS', KEYS[4], parent .. ':f') == 1 or
450
+ tonumber(redis.call('HGET', KEYS[4], parent .. ':lfa') or '0') == attempt then
426
451
  return { 'superseded' }
427
452
  end
428
453
  for i = 1, n do
@@ -433,12 +458,13 @@ local returned_ids = {}
433
458
  for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
434
459
  local returned_json = '[]'
435
460
  if n > 0 then returned_json = cjson.encode(returned_ids) end
436
- redis.call('HSET', KEYS[5],
461
+ redis.call('HSET', KEYS[4],
437
462
  parent .. ':p', ARGV[2],
438
463
  parent .. ':pa', ARGV[4],
439
464
  parent .. ':ri', returned_json)
440
- redis.call('HDEL', KEYS[5], parent .. ':ch', parent .. ':ce')
465
+ redis.call('HDEL', KEYS[4], parent .. ':ch', parent .. ':ce')
441
466
  settle(parent)
467
+ release_snapshot_pins()
442
468
  local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
443
469
  for i = 1, n do
444
470
  local id = ARGV[5 + (i - 1) * 5 + 1]
@@ -451,26 +477,73 @@ for i = 1, n do
451
477
  'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
452
478
  'cause', cause, 'lane', lane)
453
479
  redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
454
- redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
480
+ redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
455
481
  if settled == '1' then
456
- redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
482
+ redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
457
483
  else
458
484
  enqueue(tostring(idx), idx, lane)
459
485
  end
460
486
  end
461
487
  redis.call('SET', KEYS[2], tostring(total + n))
462
- redis.call('ZADD', KEYS[4], 0, ARGV[1])
463
488
  return { 'ok', tostring(total + 1),
464
- tostring(redis.call('EXISTS', KEYS[8])) }
489
+ tostring(redis.call('EXISTS', KEYS[7])) }
465
490
  `
466
491
 
467
- /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
468
- const PUT_SNAPSHOT_LUA = `
469
- local cur = redis.call('GET', KEYS[1])
470
- if cur and tonumber(cjson.decode(cur)['index']) >= tonumber(ARGV[1]) then
471
- return 0
492
+ /**
493
+ * KEYS: head, history, event counter, event meta, pin counts.
494
+ * ARGV: nowMs, event pin key prefix, write count, then per write:
495
+ * index, snapshot JSON, pin count, pin event indexes.
496
+ */
497
+ const PUT_SNAPSHOTS_LUA = `
498
+ local total = tonumber(redis.call('GET', KEYS[3]) or '0')
499
+ local writes = {}
500
+ local offset = 4
501
+ for i = 1, tonumber(ARGV[3]) do
502
+ local index = tonumber(ARGV[offset])
503
+ local snapshot = ARGV[offset + 1]
504
+ local pin_count = tonumber(ARGV[offset + 2])
505
+ local ref = cjson.encode({ KEYS[2], tostring(index) })
506
+ for p = 1, pin_count do
507
+ local event_index = tonumber(ARGV[offset + 2 + p])
508
+ if event_index >= 1 and event_index <= total and
509
+ redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':p') == 0 and
510
+ redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':f') == 0 then
511
+ local pin_key = ARGV[2] .. tostring(event_index)
512
+ if redis.call('SADD', pin_key, ref) == 1 then
513
+ redis.call('HINCRBY', KEYS[5], ref, 1)
514
+ end
515
+ end
516
+ end
517
+ writes[#writes + 1] = { index = index, snapshot = snapshot, ref = ref }
518
+ offset = offset + 3 + pin_count
519
+ end
520
+
521
+ local head = redis.call('GET', KEYS[1])
522
+ local head_index = nil
523
+ if head then head_index = tonumber(cjson.decode(head)['index']) end
524
+ local latest = writes[#writes]
525
+ local writes_head = not head_index or latest.index >= head_index
526
+ local advances = not head_index or latest.index > head_index
527
+ local final_head_index = advances and latest.index or head_index
528
+
529
+ if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEYS[2], tostring(head_index) })) or '0') > 0 then
530
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], head_index, head_index)
531
+ redis.call('ZADD', KEYS[2], head_index, head)
532
+ end
533
+
534
+ for _, write in ipairs(writes) do
535
+ if write.index < final_head_index then
536
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
537
+ if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
538
+ redis.call('ZADD', KEYS[2], write.index, write.snapshot)
539
+ end
540
+ end
541
+ end
542
+
543
+ if writes_head then
544
+ redis.call('SET', KEYS[1], latest.snapshot)
545
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], latest.index, latest.index)
472
546
  end
473
- redis.call('SET', KEYS[1], ARGV[2])
474
547
  return 1
475
548
  `
476
549
 
@@ -527,11 +600,26 @@ end
527
600
  return live
528
601
  `
529
602
 
530
- /** KEYS: snapshot, event counter. Returns one atomic cache-plus-frontier read. */
603
+ /**
604
+ * KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
605
+ * Returns one atomic multiversion-cache-plus-log-frontier read.
606
+ */
531
607
  const READ_STATE_LUA = `
532
- local snapshot = redis.call('GET', KEYS[1])
533
- local frontier = redis.call('GET', KEYS[2]) or '0'
534
- return { snapshot or '', frontier }
608
+ local head = redis.call('GET', KEYS[1])
609
+ local cap = ARGV[1]
610
+ local snapshot = nil
611
+ if head and (cap == '' or tonumber(cjson.decode(head)['index']) <= tonumber(cap)) then
612
+ snapshot = head
613
+ end
614
+ local upper = cap == '' and '+inf' or cap
615
+ local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, '-inf', 'LIMIT', 0, 1)
616
+ if #historical > 0 then
617
+ if not snapshot or tonumber(cjson.decode(historical[1])['index']) > tonumber(cjson.decode(snapshot)['index']) then
618
+ snapshot = historical[1]
619
+ end
620
+ end
621
+ local frontier = redis.call('GET', KEYS[3]) or '0'
622
+ return { head or '', snapshot or '', frontier }
535
623
  `
536
624
 
537
625
  // ── wire helpers ─────────────────────────────────────────────────────
@@ -579,14 +667,6 @@ const toEvent = (sessionId: string, entry: StreamEntry): Event => {
579
667
 
580
668
  const json = (value: unknown): string => JSON.stringify(value) ?? 'null'
581
669
 
582
- const escapeGlob = (value: string): string =>
583
- value
584
- .replaceAll('\\', '\\\\')
585
- .replaceAll('*', '\\*')
586
- .replaceAll('?', '\\?')
587
- .replaceAll('[', '\\[')
588
- .replaceAll(']', '\\]')
589
-
590
670
  const toCause = (raw: string | undefined): EventCause | null => {
591
671
  if (raw === '' || raw === undefined) return null
592
672
  const value = JSON.parse(raw) as {
@@ -696,10 +776,17 @@ export function createRedisStoreCore(
696
776
  const pendingKey = (s: string): string => `${prefix}:${s}:pending`
697
777
  const readyKey = (s: string): string => `${prefix}:${s}:ready`
698
778
  const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
779
+ const snapHistoryKey = (s: string, r: string): string =>
780
+ `${prefix}:${s}:snap-history:${r}`
781
+ const snapshotEventPinsKey = (s: string, index: number): string =>
782
+ `${prefix}:${s}:snapshot-pins:${index}`
783
+ const snapshotEventPinsPrefix = (s: string): string =>
784
+ `${prefix}:${s}:snapshot-pins:`
785
+ const snapshotPinCountsKey = (s: string): string =>
786
+ `${prefix}:${s}:snapshot-pin-counts`
699
787
  const watchedKey = (s: string): string => `${prefix}:${s}:watched`
700
788
  const presenceKey = (s: string): string => `${prefix}:${s}:presence`
701
789
  const idsKey = `${prefix}:ids`
702
- const sessionsKey = `${prefix}:sessions`
703
790
 
704
791
  const evalScript = (
705
792
  script: string,
@@ -924,7 +1011,6 @@ export function createRedisStoreCore(
924
1011
  storeKey(sessionId),
925
1012
  countKey(sessionId),
926
1013
  idsKey,
927
- sessionsKey,
928
1014
  metaKey(sessionId),
929
1015
  pendingKey(sessionId),
930
1016
  readyKey(sessionId),
@@ -1081,11 +1167,12 @@ export function createRedisStoreCore(
1081
1167
  storeKey(sessionId),
1082
1168
  countKey(sessionId),
1083
1169
  idsKey,
1084
- sessionsKey,
1085
1170
  metaKey(sessionId),
1086
1171
  pendingKey(sessionId),
1087
1172
  readyKey(sessionId),
1088
1173
  watchedKey(sessionId),
1174
+ snapshotEventPinsKey(sessionId, index),
1175
+ snapshotPinCountsKey(sessionId),
1089
1176
  ],
1090
1177
  [
1091
1178
  sessionId,
@@ -1178,6 +1265,8 @@ export function createRedisStoreCore(
1178
1265
  metaKey(sessionId),
1179
1266
  pendingKey(sessionId),
1180
1267
  readyKey(sessionId),
1268
+ snapshotEventPinsKey(sessionId, index),
1269
+ snapshotPinCountsKey(sessionId),
1181
1270
  ],
1182
1271
  [index, attempt, error, maxFailures, clock.now().getTime()],
1183
1272
  )) as string[]
@@ -1194,22 +1283,37 @@ export function createRedisStoreCore(
1194
1283
  })
1195
1284
  },
1196
1285
 
1197
- async readState(sessionId, reducerName) {
1286
+ async readState(sessionId, reducerName, stateOptions) {
1198
1287
  return wrap(async () => {
1199
1288
  const reply = (await evalScript(
1200
1289
  READ_STATE_LUA,
1201
- [snapKey(sessionId, reducerName), countKey(sessionId)],
1202
- [],
1203
- )) as [string, string]
1204
- const raw = reply[0]
1290
+ [
1291
+ snapKey(sessionId, reducerName),
1292
+ snapHistoryKey(sessionId, reducerName),
1293
+ countKey(sessionId),
1294
+ ],
1295
+ [
1296
+ stateOptions?.snapshotThroughIndex ??
1297
+ stateOptions?.throughIndex ??
1298
+ '',
1299
+ ],
1300
+ )) as [string, string, string]
1301
+ const rawHead = reply[0]
1302
+ const raw = reply[1]
1303
+ const head =
1304
+ rawHead === ''
1305
+ ? null
1306
+ : (JSON.parse(rawHead) as { index: number; state: unknown })
1205
1307
  const parsed =
1206
1308
  raw === ''
1207
1309
  ? null
1208
1310
  : (JSON.parse(raw) as { index: number; state: unknown })
1209
- const frontier = Number(reply[1])
1311
+ const currentFrontier = Number(reply[2])
1210
1312
  if (
1211
- !Number.isSafeInteger(frontier) ||
1212
- frontier < 0 ||
1313
+ !Number.isSafeInteger(currentFrontier) ||
1314
+ currentFrontier < 0 ||
1315
+ (head !== null &&
1316
+ (!Number.isSafeInteger(head.index) || head.index < 0)) ||
1213
1317
  (parsed !== null &&
1214
1318
  (!Number.isSafeInteger(parsed.index) || parsed.index < 0))
1215
1319
  ) {
@@ -1218,26 +1322,78 @@ export function createRedisStoreCore(
1218
1322
  `redis state frontier for session '${sessionId}' is invalid`,
1219
1323
  )
1220
1324
  }
1325
+ const frontier = Math.min(
1326
+ currentFrontier,
1327
+ stateOptions?.throughIndex ?? currentFrontier,
1328
+ )
1329
+ const snapshot = parsed
1221
1330
  const events = await readEventRange(
1222
1331
  sessionId,
1223
- parsed?.index ?? 0,
1332
+ snapshot?.index ?? 0,
1224
1333
  frontier,
1225
1334
  )
1226
1335
  return {
1227
- snapshot: parsed
1228
- ? { index: parsed.index, state: parsed.state }
1336
+ headIndex: head?.index ?? null,
1337
+ snapshot: snapshot
1338
+ ? { index: snapshot.index, state: snapshot.state }
1229
1339
  : null,
1230
1340
  events,
1231
1341
  }
1232
1342
  })
1233
1343
  },
1234
1344
 
1235
- async putSnapshot(sessionId, reducerName, index, state) {
1345
+ async putSnapshots(sessionId, reducerName, writes) {
1346
+ const byIndex = new Map<
1347
+ number,
1348
+ { index: number; state: unknown; pinEventIndexes: Set<number> }
1349
+ >()
1350
+ for (const write of writes) {
1351
+ const existing = byIndex.get(write.index)
1352
+ if (existing) {
1353
+ existing.state = write.state
1354
+ for (const eventIndex of write.pinEventIndexes ?? []) {
1355
+ existing.pinEventIndexes.add(eventIndex)
1356
+ }
1357
+ } else {
1358
+ byIndex.set(write.index, {
1359
+ index: write.index,
1360
+ state: write.state,
1361
+ pinEventIndexes: new Set(write.pinEventIndexes ?? []),
1362
+ })
1363
+ }
1364
+ }
1365
+ const normalized = [...byIndex.values()].toSorted(
1366
+ (a, b) => a.index - b.index,
1367
+ )
1368
+ if (normalized.length === 0) return
1236
1369
  await wrap(async () => {
1370
+ const args: Array<string | number> = [
1371
+ clock.now().getTime(),
1372
+ snapshotEventPinsPrefix(sessionId),
1373
+ normalized.length,
1374
+ ]
1375
+ for (const write of normalized) {
1376
+ args.push(
1377
+ write.index,
1378
+ json({
1379
+ index: write.index,
1380
+ state: write.state,
1381
+ updatedAt: clock.now().getTime(),
1382
+ }),
1383
+ write.pinEventIndexes.size,
1384
+ ...write.pinEventIndexes,
1385
+ )
1386
+ }
1237
1387
  await evalScript(
1238
- PUT_SNAPSHOT_LUA,
1239
- [snapKey(sessionId, reducerName)],
1240
- [index, json({ index, state, updatedAt: clock.now().getTime() })],
1388
+ PUT_SNAPSHOTS_LUA,
1389
+ [
1390
+ snapKey(sessionId, reducerName),
1391
+ snapHistoryKey(sessionId, reducerName),
1392
+ countKey(sessionId),
1393
+ metaKey(sessionId),
1394
+ snapshotPinCountsKey(sessionId),
1395
+ ],
1396
+ args,
1241
1397
  )
1242
1398
  })
1243
1399
  },
@@ -1325,152 +1481,6 @@ export function createRedisStoreCore(
1325
1481
  },
1326
1482
  },
1327
1483
 
1328
- inspect: {
1329
- async listSessions(inspectionOptions) {
1330
- return wrap(async () => {
1331
- const minimum =
1332
- inspectionOptions.cursor === undefined
1333
- ? `[${inspectionOptions.prefix}`
1334
- : `(${inspectionOptions.cursor}`
1335
- const candidates = (await call(
1336
- 'ZRANGEBYLEX',
1337
- sessionsKey,
1338
- minimum,
1339
- '+',
1340
- 'LIMIT',
1341
- 0,
1342
- inspectionOptions.limit + 1,
1343
- )) as string[]
1344
- const sessionIds = candidates
1345
- .filter((id) => id.startsWith(inspectionOptions.prefix))
1346
- .slice(0, inspectionOptions.limit)
1347
-
1348
- const sessions = await Promise.all(
1349
- sessionIds.map(async (sessionId) => {
1350
- const rows = await readRange(sessionId, 0)
1351
- const first = rows[0]!
1352
- let updatedAt = first.createdAt
1353
- let pendingCount = 0
1354
- let failedCount = 0
1355
- let attemptCount = 0
1356
- let failureCount = 0
1357
- for (const row of rows) {
1358
- attemptCount += row.attemptCount
1359
- failureCount += row.failureCount
1360
- if (row.processedAt === null && row.failedAt === null) {
1361
- pendingCount += 1
1362
- }
1363
- if (row.failedAt !== null) failedCount += 1
1364
- for (const timestamp of [
1365
- row.createdAt,
1366
- row.firstClaimedAt,
1367
- row.lastClaimedAt,
1368
- row.lastFailedAt,
1369
- row.processedAt,
1370
- row.failedAt,
1371
- ]) {
1372
- if (timestamp && timestamp > updatedAt) updatedAt = timestamp
1373
- }
1374
- }
1375
- return {
1376
- sessionId,
1377
- eventCount: rows.length,
1378
- pendingCount,
1379
- failedCount,
1380
- attemptCount,
1381
- failureCount,
1382
- firstEventAt: first.createdAt,
1383
- updatedAt,
1384
- }
1385
- }),
1386
- )
1387
- return {
1388
- sessions: sessions.toSorted((a, b) =>
1389
- a.sessionId.localeCompare(b.sessionId),
1390
- ),
1391
- cursor:
1392
- candidates.length > inspectionOptions.limit &&
1393
- candidates[inspectionOptions.limit]?.startsWith(
1394
- inspectionOptions.prefix,
1395
- )
1396
- ? (sessionIds.at(-1) ?? null)
1397
- : null,
1398
- }
1399
- })
1400
- },
1401
- async readEvents(sessionId, pageOptions) {
1402
- return wrap(async () => {
1403
- const currentFrontier = await readFrontier(sessionId)
1404
- const throughIndex = Math.min(
1405
- pageOptions.throughIndex ?? currentFrontier,
1406
- currentFrontier,
1407
- )
1408
- const events = await readRangePage(
1409
- sessionId,
1410
- pageOptions.afterIndex,
1411
- throughIndex,
1412
- pageOptions.limit,
1413
- )
1414
- if (throughIndex > pageOptions.afterIndex && events.length === 0) {
1415
- throw new A2Error(
1416
- 'STORE_UNAVAILABLE',
1417
- `redis event range for session '${sessionId}' ended before index ${throughIndex}`,
1418
- )
1419
- }
1420
- for (let position = 0; position < events.length; position += 1) {
1421
- if (
1422
- events[position]!.index !==
1423
- pageOptions.afterIndex + position + 1
1424
- ) {
1425
- throw new A2Error(
1426
- 'STORE_UNAVAILABLE',
1427
- `redis event range for session '${sessionId}' is not contiguous`,
1428
- )
1429
- }
1430
- }
1431
- return { events, throughIndex }
1432
- })
1433
- },
1434
- async listSnapshots(sessionId) {
1435
- return wrap(async () => {
1436
- const start = `${prefix}:${sessionId}:snap:`
1437
- let cursor = '0'
1438
- const keys: string[] = []
1439
- do {
1440
- // oxlint-disable-next-line no-await-in-loop -- each SCAN cursor comes from the prior reply
1441
- const reply = (await call(
1442
- 'SCAN',
1443
- cursor,
1444
- 'MATCH',
1445
- `${escapeGlob(start)}*`,
1446
- 'COUNT',
1447
- 100,
1448
- )) as [string, string[]]
1449
- cursor = reply[0]
1450
- keys.push(...reply[1].filter((key) => key.startsWith(start)))
1451
- } while (cursor !== '0')
1452
- const snapshots = await Promise.all(
1453
- keys.map(async (key) => {
1454
- const raw = (await call('GET', key)) as string | null
1455
- if (raw === null) return null
1456
- const parsed = JSON.parse(raw) as {
1457
- index: number
1458
- updatedAt?: number
1459
- }
1460
- return {
1461
- reducerName: key.slice(start.length),
1462
- index: parsed.index,
1463
- updatedAt: new Date(parsed.updatedAt ?? 0),
1464
- }
1465
- }),
1466
- )
1467
- return snapshots
1468
- .filter((snapshot) => snapshot !== null)
1469
- .toSorted((a, b) => a.reducerName.localeCompare(b.reducerName))
1470
- })
1471
- },
1472
- },
1473
-
1474
1484
  async readRange(sessionId, afterIndex, throughIndex) {
1475
1485
  return wrap(() => readRange(sessionId, afterIndex, throughIndex))
1476
1486
  },