experimental-a2 0.0.0 → 0.2.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 (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/ai-server.browser.js +2 -2
  3. package/dist/ai-server.d.ts +19 -7
  4. package/dist/ai-server.js +730 -96
  5. package/dist/ai.d.ts +32 -11
  6. package/dist/ai.js +253 -75
  7. package/dist/client.d.ts +1 -1
  8. package/dist/client.js +4 -4
  9. package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
  10. package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
  11. package/dist/devtools-server.browser.js +2 -2
  12. package/dist/devtools-server.js +1 -1
  13. package/dist/http.d.ts +1 -1
  14. package/dist/http.js +4 -3
  15. package/dist/idempotent-replay-BMyHrP0L.js +19 -0
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +1 -1
  18. package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
  19. package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
  20. package/dist/log-memory.d.ts +1 -1
  21. package/dist/log-memory.js +173 -96
  22. package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
  23. package/dist/log-postgres.d.ts +1 -1
  24. package/dist/log-postgres.js +235 -192
  25. package/dist/log-redis.d.ts +1 -1
  26. package/dist/log-redis.js +453 -263
  27. package/dist/log-sqlite.d.ts +1 -1
  28. package/dist/log-sqlite.js +216 -127
  29. package/dist/otel.d.ts +1 -1
  30. package/dist/otel.js +1 -1
  31. package/dist/react.d.ts +1 -1
  32. package/dist/react.js +1 -1
  33. package/dist/recovery-vercel.d.ts +2 -2
  34. package/dist/recovery-vercel.js +9 -10
  35. package/dist/server-DJgD2YWP.js +877 -0
  36. package/dist/server.browser.js +4 -4
  37. package/dist/server.d.ts +46 -27
  38. package/dist/server.js +1 -1
  39. package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
  40. package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
  41. package/docs/01-quickstart.mdx +7 -7
  42. package/docs/concepts/01-contracts.mdx +22 -22
  43. package/docs/concepts/02-handlers.mdx +223 -89
  44. package/docs/concepts/03-durability.mdx +199 -112
  45. package/docs/concepts/04-state.mdx +27 -1
  46. package/docs/guides/01-timers.mdx +4 -4
  47. package/docs/guides/02-cancellation.mdx +32 -4
  48. package/docs/guides/05-production.mdx +61 -27
  49. package/docs/guides/06-ai-agents.mdx +151 -70
  50. package/docs/guides/07-devtools.mdx +6 -3
  51. package/docs/guides/08-application-data.mdx +5 -6
  52. package/docs/index.mdx +30 -14
  53. package/docs/reference/01-api.mdx +305 -70
  54. package/package.json +31 -31
  55. package/dist/server-DYsnKTTy.js +0 -780
package/dist/log-redis.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { t as A2Error } from "./errors-BJRMd-h6.js";
2
2
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
+ import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
3
4
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
4
5
  //#region src/log-redis.ts
5
6
  /**
6
- * a2/log-redis — the push-native log backend, on Redis Streams.
7
+ * experimental-a2/log-redis — the push-native log backend, on Redis Streams.
7
8
  *
8
9
  * The stream IS the log: each event is a stream entry whose ID is its
9
10
  * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read and
@@ -13,10 +14,8 @@ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
13
14
  * The conformance suite in test/conformance is the executable
14
15
  * contract, run against a real spawned `redis-server`.
15
16
  *
16
- * Timestamps and lease expiry come from the injected clock the lease
17
- * stores its logical `expiresAt` and compares against `clock.now()`,
18
- * with a generous real-time `PX` purely as garbage collection — so
19
- * tests can time-travel against a real server.
17
+ * Timestamps and claim expiry come from the injected clock so tests can
18
+ * time-travel against a real server.
20
19
  *
21
20
  * Works with any Redis-protocol server on a single instance or a
22
21
  * non-cluster provider (Upstash — durable by default — Redis, Valkey).
@@ -27,17 +26,40 @@ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
27
26
  /** How long each blocking read waits before re-checking for close(). */
28
27
  const XREAD_BLOCK_MS = 1e4;
29
28
  /**
30
- * KEYS: log stream, counter, global id hash, session index.
29
+ * KEYS: log stream, counter, global id hash, session index, metadata, pending, ready.
31
30
  * ARGV: sessionId, nowMs, n, then per event:
32
- * id, type, payloadJson, causeJson (empty when no causal edge is known).
33
- * Returns ['ok', firstIndex] | ['dup', ...indexes] | ['partial', count]
34
- * | ['foreign', eventId].
31
+ * id, type, payloadJson, causeJson, lane, settled.
32
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
33
+ * ['dup', hasPending, ...indexes] | ['partial', count] |
34
+ * ['foreign', eventId].
35
35
  */
36
36
  const APPEND_LUA = `
37
+ local function lane_field(lane, suffix)
38
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
39
+ end
40
+ local function enqueue(field, idx, lane)
41
+ redis.call('ZADD', KEYS[6], idx, field)
42
+ if lane == '' then
43
+ redis.call('ZADD', KEYS[7], idx, field)
44
+ return
45
+ end
46
+ local tail_key = lane_field(lane, 'tail')
47
+ local tail = redis.call('HGET', KEYS[5], tail_key)
48
+ if tail then
49
+ redis.call('HSET', KEYS[5], tail .. ':next', field)
50
+ else
51
+ redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
52
+ redis.call('ZADD', KEYS[7], idx, field)
53
+ end
54
+ redis.call('HSET', KEYS[5], tail_key, field)
55
+ end
37
56
  local n = tonumber(ARGV[3])
57
+ local base = tonumber(redis.call('GET', KEYS[2]) or '0')
58
+ local has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
59
+ if n == 0 then return { 'empty', has_pending } end
38
60
  local dups = {}
39
61
  for i = 1, n do
40
- local id = ARGV[3 + (i - 1) * 4 + 1]
62
+ local id = ARGV[3 + (i - 1) * 6 + 1]
41
63
  local existing = redis.call('HGET', KEYS[3], id)
42
64
  if existing then
43
65
  local rec = cjson.decode(existing)
@@ -46,49 +68,60 @@ for i = 1, n do
46
68
  end
47
69
  end
48
70
  if #dups == n then
49
- local res = { 'dup' }
71
+ local res = { 'dup', has_pending }
50
72
  for i = 1, #dups do res[#res + 1] = dups[i] end
51
73
  return res
52
74
  end
53
75
  if #dups > 0 then return { 'partial', tostring(#dups) } end
54
- local base = tonumber(redis.call('GET', KEYS[2]) or '0')
55
76
  for i = 1, n do
56
- local id = ARGV[3 + (i - 1) * 4 + 1]
57
- local typ = ARGV[3 + (i - 1) * 4 + 2]
58
- local payload = ARGV[3 + (i - 1) * 4 + 3]
59
- local cause = ARGV[3 + (i - 1) * 4 + 4]
77
+ local id = ARGV[3 + (i - 1) * 6 + 1]
78
+ local typ = ARGV[3 + (i - 1) * 6 + 2]
79
+ local payload = ARGV[3 + (i - 1) * 6 + 3]
80
+ local cause = ARGV[3 + (i - 1) * 6 + 4]
81
+ local lane = ARGV[3 + (i - 1) * 6 + 5]
82
+ local settled = ARGV[3 + (i - 1) * 6 + 6]
60
83
  local idx = base + i
61
84
  redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
62
85
  'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
63
- 'cause', cause)
86
+ 'cause', cause, 'lane', lane)
64
87
  redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
88
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
89
+ if settled == '1' then
90
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
91
+ else
92
+ enqueue(tostring(idx), idx, lane)
93
+ end
65
94
  end
66
95
  redis.call('SET', KEYS[2], tostring(base + n))
67
96
  redis.call('ZADD', KEYS[4], 0, ARGV[1])
68
- return { 'ok', tostring(base + 1) }
97
+ has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
98
+ return { 'ok', tostring(base + 1), has_pending }
69
99
  `;
70
- /** KEYS: counter, meta. ARGV: idx, value, field suffix. Returns 0|1. */
71
- const MARK_LUA = `
72
- local idx = tonumber(ARGV[1])
73
- local total = tonumber(redis.call('GET', KEYS[1]) or '0')
74
- if idx < 1 or idx > total then
75
- return 0
76
- end
77
- local target = ARGV[1] .. ':' .. ARGV[3]
78
- if ARGV[3] ~= 'p' or redis.call('HEXISTS', KEYS[2], target) == 0 then
79
- redis.call('HSET', KEYS[2], target, ARGV[2])
100
+ /** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
101
+ const FAIL_ATTEMPT_LUA = `
102
+ local function lane_field(lane, suffix)
103
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
80
104
  end
81
- if ARGV[3] == 'p' then
82
- local head = tonumber(redis.call('HGET', KEYS[2], '__head') or '1')
83
- while head <= total and redis.call('HEXISTS', KEYS[2], tostring(head) .. ':p') == 1 do
84
- head = head + 1
105
+ local function settle(field)
106
+ redis.call('ZREM', KEYS[3], field)
107
+ redis.call('ZREM', KEYS[4], field)
108
+ local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
109
+ if lane == '' then return end
110
+ local head_key = lane_field(lane, 'head')
111
+ if redis.call('HGET', KEYS[2], head_key) ~= field then return end
112
+ local next_field = redis.call('HGET', KEYS[2], field .. ':next')
113
+ while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
114
+ next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
115
+ end
116
+ if next_field then
117
+ redis.call('HSET', KEYS[2], head_key, next_field)
118
+ if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
119
+ redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
120
+ end
121
+ else
122
+ redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
85
123
  end
86
- redis.call('HSET', KEYS[2], '__head', tostring(head))
87
124
  end
88
- return 1
89
- `;
90
- /** KEYS: counter, meta. ARGV: idx, attempt, error, maxFailures, nowMs. */
91
- const FAIL_ATTEMPT_LUA = `
92
125
  local idx = tonumber(ARGV[1])
93
126
  if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
94
127
  return { 'missing', '0' }
@@ -102,143 +135,225 @@ end
102
135
  if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
103
136
  return { 'dead_lettered', tostring(failures) }
104
137
  end
138
+ if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
139
+ tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
140
+ redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
141
+ return { 'failed', tostring(failures) }
142
+ end
143
+ if redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
144
+ return { 'superseded', tostring(failures) }
145
+ end
105
146
  failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
106
147
  redis.call('HSET', KEYS[2],
107
148
  field .. ':e', ARGV[3],
108
149
  field .. ':lf', ARGV[5],
109
150
  field .. ':lfa', ARGV[2])
151
+ redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
110
152
  if failures >= tonumber(ARGV[4]) then
111
153
  redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
154
+ redis.call('ZREM', KEYS[4], field)
112
155
  return { 'dead_lettered', tostring(failures) }
113
156
  end
114
157
  return { 'failed', tostring(failures) }
115
158
  `;
116
159
  /**
117
- * KEYS: log, counter, meta, lease.
118
- * ARGV: holder, nowMs, expiresAtMs, gcPxMs, maxIndex (-1 = unbounded).
160
+ * KEYS: log, counter, meta, pending, ready.
161
+ * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
119
162
  */
120
- const CLAIM_NEXT_LUA = `
121
- local total = tonumber(redis.call('GET', KEYS[2]) or '0')
122
- local rawHead = redis.call('HGET', KEYS[3], '__head')
123
- local head = tonumber(rawHead or '1')
124
- while head <= total and redis.call('HEXISTS', KEYS[3], tostring(head) .. ':p') == 1 do
125
- head = head + 1
126
- end
127
- if total > 0 or rawHead then
128
- redis.call('HSET', KEYS[3], '__head', tostring(head))
163
+ const CLAIM_AVAILABLE_LUA = `
164
+ if redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end
165
+ local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
166
+ local excluded = {}
167
+ for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
168
+ local claimed = {}
169
+ local retry_at = nil
170
+ for _, field in ipairs(ready) do
171
+ if not excluded[field] then
172
+ local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')
173
+ if expires > tonumber(ARGV[2]) then
174
+ if not retry_at or expires < retry_at then retry_at = expires end
175
+ else
176
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
177
+ if #entries == 0 then return { 'missing', field } end
178
+ local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)
179
+ redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
180
+ redis.call('HSET', KEYS[3],
181
+ field .. ':lc', ARGV[2],
182
+ field .. ':ch', ARGV[1],
183
+ field .. ':ce', ARGV[3])
184
+ claimed[#claimed + 1] = {
185
+ tostring(attempt),
186
+ redis.call('HGET', KEYS[3], field .. ':a') or '0',
187
+ redis.call('HGET', KEYS[3], field .. ':e') or false,
188
+ redis.call('HGET', KEYS[3], field .. ':fc') or false,
189
+ ARGV[2],
190
+ redis.call('HGET', KEYS[3], field .. ':lf') or false,
191
+ redis.call('HGET', KEYS[3], field .. ':lfa') or false,
192
+ ARGV[1],
193
+ ARGV[3],
194
+ entries[1]
195
+ }
196
+ end
197
+ end
129
198
  end
130
- if head > total then return { 'settled' } end
131
- local field = tostring(head)
132
- local maxIndex = tonumber(ARGV[5])
133
- if redis.call('HEXISTS', KEYS[3], field .. ':f') == 1 or
134
- (maxIndex >= 0 and head > maxIndex) then
135
- return { 'settled' }
199
+ if #claimed > 0 then
200
+ local result = { 'claimed', tostring(#claimed) }
201
+ for _, claim in ipairs(claimed) do
202
+ for _, value in ipairs(claim) do result[#result + 1] = value end
203
+ end
204
+ return result
136
205
  end
137
-
138
- local cur = redis.call('GET', KEYS[4])
139
- if cur then
140
- local rec = cjson.decode(cur)
141
- if rec[2] > tonumber(ARGV[2]) and rec[1] ~= ARGV[1] then
142
- return { 'busy' }
206
+ if retry_at then return { 'busy', tostring(retry_at) } end
207
+ return { 'settled' }
208
+ `;
209
+ /**
210
+ * KEYS: counter, meta. ARGV: holder, nowMs, expiresAtMs, n, indexes.
211
+ */
212
+ const RENEW_CLAIMS_LUA = `
213
+ local total = tonumber(redis.call('GET', KEYS[1]) or '0')
214
+ local renewed = {}
215
+ for i = 1, tonumber(ARGV[4]) do
216
+ local field = ARGV[4 + i]
217
+ local idx = tonumber(field)
218
+ local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
219
+ if idx >= 1 and idx <= total and
220
+ tonumber(ARGV[3]) > tonumber(ARGV[2]) and
221
+ redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
222
+ redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
223
+ redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
224
+ expires > tonumber(ARGV[2]) then
225
+ redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
226
+ renewed[#renewed + 1] = field
143
227
  end
144
228
  end
145
- local lease = cjson.encode({ ARGV[1], tonumber(ARGV[3]) })
146
- redis.call('SET', KEYS[4], lease, 'PX', ARGV[4])
147
-
148
- local failures = tonumber(redis.call('HGET', KEYS[3], field .. ':a') or '0')
149
- local dispatches = tonumber(redis.call('HGET', KEYS[3], field .. ':d') or '0')
150
- local attempt = dispatches + 1
151
- redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
152
- redis.call('HSET', KEYS[3], field .. ':d', tostring(attempt), field .. ':lc', ARGV[2])
153
- local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
154
- if #entries == 0 then return { 'missing' } end
155
- return {
156
- 'claimed', tostring(attempt), tostring(failures),
157
- redis.call('HGET', KEYS[3], field .. ':e') or false,
158
- redis.call('HGET', KEYS[3], field .. ':fc') or false,
159
- redis.call('HGET', KEYS[3], field .. ':lc') or false,
160
- redis.call('HGET', KEYS[3], field .. ':lf') or false,
161
- redis.call('HGET', KEYS[3], field .. ':lfa') or false,
162
- entries[1]
163
- }
229
+ return renewed
164
230
  `;
165
231
  /**
166
- * KEYS: log, counter, meta, lease.
167
- * ARGV: holder, nowMs, completedIndex, attempt, maxIndex (-1 = unbounded).
232
+ * KEYS: log, counter, ids, session index, meta, pending, ready.
233
+ * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
234
+ * id, type, payloadJson, lane, settled.
168
235
  */
169
- const COMPLETE_AND_CLAIM_NEXT_LUA = `
170
- local total = tonumber(redis.call('GET', KEYS[2]) or '0')
171
- local completed = tonumber(ARGV[3])
172
- if completed < 1 or completed > total then return { 'missing' } end
173
- local completedField = ARGV[3]
174
- local completedDispatches = tonumber(redis.call('HGET', KEYS[3], completedField .. ':d') or '0')
175
- if redis.call('HEXISTS', KEYS[3], completedField .. ':p') == 1 or
176
- completedDispatches ~= tonumber(ARGV[4]) then
177
- return { 'superseded' }
236
+ const COMPLETE_ATTEMPT_LUA = `
237
+ local function lane_field(lane, suffix)
238
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
178
239
  end
179
- redis.call('HSET', KEYS[3],
180
- completedField .. ':p', ARGV[2],
181
- completedField .. ':pa', ARGV[4])
182
-
183
- local head = tonumber(redis.call('HGET', KEYS[3], '__head') or '1')
184
- while head <= total and redis.call('HEXISTS', KEYS[3], tostring(head) .. ':p') == 1 do
185
- head = head + 1
240
+ local function settle(field)
241
+ redis.call('ZREM', KEYS[6], field)
242
+ redis.call('ZREM', KEYS[7], field)
243
+ local lane = redis.call('HGET', KEYS[5], field .. ':l') or ''
244
+ if lane == '' then return end
245
+ local head_key = lane_field(lane, 'head')
246
+ if redis.call('HGET', KEYS[5], head_key) ~= field then return end
247
+ local next_field = redis.call('HGET', KEYS[5], field .. ':next')
248
+ while next_field and redis.call('HEXISTS', KEYS[5], next_field .. ':p') == 1 do
249
+ next_field = redis.call('HGET', KEYS[5], next_field .. ':next')
250
+ end
251
+ if next_field then
252
+ redis.call('HSET', KEYS[5], head_key, next_field)
253
+ if redis.call('HEXISTS', KEYS[5], next_field .. ':f') == 0 then
254
+ redis.call('ZADD', KEYS[7], tonumber(next_field), next_field)
255
+ end
256
+ else
257
+ redis.call('HDEL', KEYS[5], head_key, lane_field(lane, 'tail'))
258
+ end
186
259
  end
187
- redis.call('HSET', KEYS[3], '__head', tostring(head))
188
- if head > total then return { 'settled' } end
189
- local field = tostring(head)
190
- local maxIndex = tonumber(ARGV[5])
191
- if redis.call('HEXISTS', KEYS[3], field .. ':f') == 1 or
192
- (maxIndex >= 0 and head > maxIndex) then
193
- return { 'settled' }
260
+ local function enqueue(field, idx, lane)
261
+ redis.call('ZADD', KEYS[6], idx, field)
262
+ if lane == '' then
263
+ redis.call('ZADD', KEYS[7], idx, field)
264
+ return
265
+ end
266
+ local tail_key = lane_field(lane, 'tail')
267
+ local tail = redis.call('HGET', KEYS[5], tail_key)
268
+ if tail then
269
+ redis.call('HSET', KEYS[5], tail .. ':next', field)
270
+ else
271
+ redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
272
+ redis.call('ZADD', KEYS[7], idx, field)
273
+ end
274
+ redis.call('HSET', KEYS[5], tail_key, field)
194
275
  end
195
-
196
- local cur = redis.call('GET', KEYS[4])
197
- if not cur then return { 'busy' } end
198
- local lease = cjson.decode(cur)
199
- if lease[1] ~= ARGV[1] or lease[2] <= tonumber(ARGV[2]) then
200
- return { 'busy' }
276
+ local parent = ARGV[3]
277
+ local attempt = tonumber(ARGV[4])
278
+ local n = tonumber(ARGV[5])
279
+ local total = tonumber(redis.call('GET', KEYS[2]) or '0')
280
+ if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
281
+ local dispatches = tonumber(redis.call('HGET', KEYS[5], parent .. ':d') or '0')
282
+ local processed = redis.call('HGET', KEYS[5], parent .. ':p')
283
+ local processed_attempt = tonumber(redis.call('HGET', KEYS[5], parent .. ':pa') or '0')
284
+ if processed then
285
+ if processed_attempt ~= attempt then return { 'superseded' } end
286
+ local raw_recorded_ids = redis.call('HGET', KEYS[5], parent .. ':ri')
287
+ if not raw_recorded_ids then return { 'partial' } end
288
+ local recorded_ids = cjson.decode(raw_recorded_ids)
289
+ if #recorded_ids ~= n then return { 'partial' } end
290
+ local indexes = {}
291
+ for i = 1, n do
292
+ local id = ARGV[5 + (i - 1) * 5 + 1]
293
+ if recorded_ids[i] ~= id then return { 'partial' } end
294
+ local existing = redis.call('HGET', KEYS[3], id)
295
+ if not existing then return { 'partial' } end
296
+ local rec = cjson.decode(existing)
297
+ if rec[1] ~= ARGV[1] then return { 'partial' } end
298
+ local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
299
+ if #entries == 0 then return { 'partial' } end
300
+ local values = entries[1][2]
301
+ local cause = nil
302
+ for j = 1, #values, 2 do
303
+ if values[j] == 'cause' then cause = values[j + 1] break end
304
+ end
305
+ if not cause or cause == '' then return { 'partial' } end
306
+ local decoded = cjson.decode(cause)
307
+ if tonumber(decoded['index']) ~= tonumber(parent) or
308
+ tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end
309
+ indexes[#indexes + 1] = tostring(rec[2])
310
+ end
311
+ local result = { 'duplicate' }
312
+ for _, idx in ipairs(indexes) do result[#result + 1] = idx end
313
+ return result
201
314
  end
202
-
203
- local failures = tonumber(redis.call('HGET', KEYS[3], field .. ':a') or '0')
204
- local dispatches = tonumber(redis.call('HGET', KEYS[3], field .. ':d') or '0')
205
- local attempt = dispatches + 1
206
- redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
207
- redis.call('HSET', KEYS[3], field .. ':d', tostring(attempt), field .. ':lc', ARGV[2])
208
- local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
209
- if #entries == 0 then return { 'missing' } end
210
- return {
211
- 'claimed', tostring(attempt), tostring(failures),
212
- redis.call('HGET', KEYS[3], field .. ':e') or false,
213
- redis.call('HGET', KEYS[3], field .. ':fc') or false,
214
- redis.call('HGET', KEYS[3], field .. ':lc') or false,
215
- redis.call('HGET', KEYS[3], field .. ':lf') or false,
216
- redis.call('HGET', KEYS[3], field .. ':lfa') or false,
217
- entries[1]
218
- }
219
- `;
220
- /**
221
- * KEYS: lease. ARGV: holder, nowMs, expiresAtMs, gcPxMs. Logical expiry is
222
- * the stored expiresAt vs now (exclusive: expiresAt <= now is free) —
223
- * the PX is garbage collection only, never the semantics.
224
- */
225
- const LEASE_ACQUIRE_LUA = `
226
- local cur = redis.call('GET', KEYS[1])
227
- if cur then
228
- local rec = cjson.decode(cur)
229
- if rec[2] > tonumber(ARGV[2]) and rec[1] ~= ARGV[1] then return 0 end
315
+ if dispatches ~= attempt then return { 'superseded' } end
316
+ if redis.call('HEXISTS', KEYS[5], parent .. ':ch') == 0 or
317
+ redis.call('HEXISTS', KEYS[5], parent .. ':f') == 1 or
318
+ tonumber(redis.call('HGET', KEYS[5], parent .. ':lfa') or '0') == attempt then
319
+ return { 'superseded' }
230
320
  end
231
- local rec = cjson.encode({ ARGV[1], tonumber(ARGV[3]) })
232
- redis.call('SET', KEYS[1], rec, 'PX', ARGV[4])
233
- return 1
234
- `;
235
- /** KEYS: lease. ARGV: holder. Only the holder may release. */
236
- const LEASE_RELEASE_LUA = `
237
- local cur = redis.call('GET', KEYS[1])
238
- if cur and cjson.decode(cur)[1] == ARGV[1] then
239
- redis.call('DEL', KEYS[1])
321
+ for i = 1, n do
322
+ local id = ARGV[5 + (i - 1) * 5 + 1]
323
+ if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
240
324
  end
241
- return 1
325
+ local returned_ids = {}
326
+ for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
327
+ local returned_json = '[]'
328
+ if n > 0 then returned_json = cjson.encode(returned_ids) end
329
+ redis.call('HSET', KEYS[5],
330
+ parent .. ':p', ARGV[2],
331
+ parent .. ':pa', ARGV[4],
332
+ parent .. ':ri', returned_json)
333
+ redis.call('HDEL', KEYS[5], parent .. ':ch', parent .. ':ce')
334
+ settle(parent)
335
+ local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
336
+ for i = 1, n do
337
+ local id = ARGV[5 + (i - 1) * 5 + 1]
338
+ local typ = ARGV[5 + (i - 1) * 5 + 2]
339
+ local payload = ARGV[5 + (i - 1) * 5 + 3]
340
+ local lane = ARGV[5 + (i - 1) * 5 + 4]
341
+ local settled = ARGV[5 + (i - 1) * 5 + 5]
342
+ local idx = total + i
343
+ redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
344
+ 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
345
+ 'cause', cause, 'lane', lane)
346
+ redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
347
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
348
+ if settled == '1' then
349
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
350
+ else
351
+ enqueue(tostring(idx), idx, lane)
352
+ end
353
+ end
354
+ redis.call('SET', KEYS[2], tostring(total + n))
355
+ redis.call('ZADD', KEYS[4], 0, ARGV[1])
356
+ return { 'ok', tostring(total + 1) }
242
357
  `;
243
358
  /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
244
359
  const PUT_SNAPSHOT_LUA = `
@@ -282,38 +397,53 @@ const toCause = (raw) => {
282
397
  if (raw === "" || raw === void 0) return null;
283
398
  const value = JSON.parse(raw);
284
399
  if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
400
+ if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
285
401
  return {
286
402
  index: Number(value.index),
287
- attempt: Number(value.attempt)
403
+ attempt: Number(value.attempt),
404
+ ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
288
405
  };
289
406
  };
290
- const toClaimResult = (sessionId, reply) => {
407
+ const toClaimAvailableResult = (sessionId, reply) => {
291
408
  const outcome = String(reply[0]);
292
409
  if (outcome === "claimed") {
293
- const entry = reply[8];
294
- const event = toEvent(sessionId, entry);
295
- const fields = fieldMap(entry[1]);
296
- return {
297
- outcome: "claimed",
298
- event: Object.assign(event, {
410
+ const count = Number(reply[1]);
411
+ const events = [];
412
+ for (let i = 0; i < count; i++) {
413
+ const offset = 2 + i * 10;
414
+ const entry = reply[offset + 9];
415
+ const event = toEvent(sessionId, entry);
416
+ const fields = fieldMap(entry[1]);
417
+ events.push(Object.assign(event, {
299
418
  cause: toCause(fields["cause"]),
419
+ lane: fields["lane"] === "" ? null : fields["lane"],
300
420
  processedAt: null,
301
421
  processedByAttempt: null,
302
- firstClaimedAt: reply[4] === null ? null : new Date(Number(reply[4])),
303
- lastClaimedAt: reply[5] === null ? null : new Date(Number(reply[5])),
304
- attemptCount: Number(reply[1]),
305
- failureCount: Number(reply[2]),
306
- lastFailedAt: reply[6] === null ? null : new Date(Number(reply[6])),
307
- lastFailedAttempt: reply[7] === null ? null : Number(reply[7]),
308
- lastError: reply[3] === null ? null : String(reply[3]),
422
+ returnedEventIds: null,
423
+ firstClaimedAt: reply[offset + 3] === null ? null : new Date(Number(reply[offset + 3])),
424
+ lastClaimedAt: new Date(Number(reply[offset + 4])),
425
+ attemptCount: Number(reply[offset]),
426
+ claimHolder: String(reply[offset + 7]),
427
+ claimExpiresAt: new Date(Number(reply[offset + 8])),
428
+ failureCount: Number(reply[offset + 1]),
429
+ lastFailedAt: reply[offset + 5] === null ? null : new Date(Number(reply[offset + 5])),
430
+ lastFailedAttempt: reply[offset + 6] === null ? null : Number(reply[offset + 6]),
431
+ lastError: reply[offset + 2] === null ? null : String(reply[offset + 2]),
309
432
  failedAt: null
310
- })
433
+ }));
434
+ }
435
+ return {
436
+ outcome: "claimed",
437
+ events
311
438
  };
312
439
  }
313
- if (outcome === "busy" || outcome === "settled") return { outcome };
440
+ if (outcome === "busy") return {
441
+ outcome,
442
+ retryAt: new Date(Number(reply[1]))
443
+ };
444
+ if (outcome === "settled") return { outcome };
314
445
  throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
315
446
  };
316
- const toHandoffResult = (sessionId, reply) => String(reply[0]) === "superseded" ? { outcome: "superseded" } : toClaimResult(sessionId, reply);
317
447
  const wrap = async (fn) => {
318
448
  try {
319
449
  return await fn();
@@ -330,14 +460,15 @@ function redis(options = {}) {
330
460
  const logKey = (s) => `${prefix}:${s}:log`;
331
461
  const countKey = (s) => `${prefix}:${s}:count`;
332
462
  const metaKey = (s) => `${prefix}:${s}:meta`;
333
- const leaseKey = (s) => `${prefix}:${s}:lease`;
463
+ const pendingKey = (s) => `${prefix}:${s}:pending`;
464
+ const readyKey = (s) => `${prefix}:${s}:ready`;
334
465
  const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
335
466
  const idsKey = `${prefix}:ids`;
336
467
  const sessionsKey = `${prefix}:sessions`;
337
468
  const connection = retryableLazy(async () => {
338
469
  if (options.client) return options.client;
339
470
  return new (await (import("ioredis").catch(() => {
340
- throw new A2Error("LOG_NOT_CONFIGURED", "a2/log-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
471
+ throw new A2Error("LOG_NOT_CONFIGURED", "experimental-a2/log-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
341
472
  }))).default(options.url);
342
473
  });
343
474
  const client = connection.get;
@@ -346,18 +477,6 @@ function redis(options = {}) {
346
477
  };
347
478
  /** Every live-feed connection, so close() can sever them. */
348
479
  const streamConnections = /* @__PURE__ */ new Set();
349
- const ensureSessionIndex = retryableLazy(() => wrap(async () => {
350
- const c = await client();
351
- let cursor = "0";
352
- const keyStart = `${prefix}:`;
353
- const keyEnd = ":count";
354
- do {
355
- const reply = await c.call("SCAN", cursor, "MATCH", `${escapeGlob(prefix)}:*:count`, "COUNT", 200);
356
- cursor = reply[0];
357
- const ids = reply[1].filter((key) => key.startsWith(keyStart) && key.endsWith(keyEnd)).map((key) => key.slice(keyStart.length, -6));
358
- if (ids.length > 0) await c.call("ZADD", sessionsKey, ...ids.flatMap((id) => [0, id]));
359
- } while (cursor !== "0");
360
- })).get;
361
480
  /** XRANGE + meta merge — the raw read both `read` and dup-returns use. */
362
481
  const readRange = async (sessionId, afterIndex) => {
363
482
  const c = await client();
@@ -370,6 +489,9 @@ function redis(options = {}) {
370
489
  const fields = fieldMap(entry[1]);
371
490
  const processedAt = meta[`${idx}:p`];
372
491
  const processedByAttempt = meta[`${idx}:pa`];
492
+ const returnedEventIds = meta[`${idx}:ri`];
493
+ const claimHolder = meta[`${idx}:ch`];
494
+ const claimExpiresAt = meta[`${idx}:ce`];
373
495
  const failedAt = meta[`${idx}:f`];
374
496
  const firstClaimedAt = meta[`${idx}:fc`];
375
497
  const lastClaimedAt = meta[`${idx}:lc`];
@@ -377,11 +499,15 @@ function redis(options = {}) {
377
499
  const lastFailedAttempt = meta[`${idx}:lfa`];
378
500
  return Object.assign(event, {
379
501
  cause: toCause(fields["cause"]),
502
+ lane: fields["lane"] === "" ? null : fields["lane"],
380
503
  processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
381
504
  processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
505
+ returnedEventIds: returnedEventIds === void 0 ? null : JSON.parse(returnedEventIds),
382
506
  firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
383
507
  lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
384
508
  attemptCount: Number(meta[`${idx}:d`] ?? 0),
509
+ claimHolder: claimHolder ?? null,
510
+ claimExpiresAt: claimExpiresAt === void 0 ? null : new Date(Number(claimExpiresAt)),
385
511
  failureCount: Number(meta[`${idx}:a`] ?? 0),
386
512
  lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
387
513
  lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
@@ -392,7 +518,6 @@ function redis(options = {}) {
392
518
  };
393
519
  return {
394
520
  async append(sessionId, events) {
395
- if (events.length === 0) return [];
396
521
  return wrap(async () => {
397
522
  const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
398
523
  if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
@@ -401,7 +526,9 @@ function redis(options = {}) {
401
526
  id: e.id ?? generateId(),
402
527
  type: e.type,
403
528
  payload: e.payload,
404
- cause: e.cause ? { ...e.cause } : null
529
+ cause: e.cause ? { ...e.cause } : null,
530
+ lane: e.lane ?? null,
531
+ settled: e.settled === true
405
532
  }));
406
533
  const args = [
407
534
  sessionId,
@@ -411,101 +538,195 @@ function redis(options = {}) {
411
538
  e.id,
412
539
  e.type,
413
540
  json(e.payload),
414
- e.cause ? json(e.cause) : ""
541
+ e.cause ? json(e.cause) : "",
542
+ e.lane ?? "",
543
+ e.settled ? "1" : "0"
415
544
  ])
416
545
  ];
417
546
  const reply = await evalScript(APPEND_LUA, [
418
547
  logKey(sessionId),
419
548
  countKey(sessionId),
420
549
  idsKey,
421
- sessionsKey
550
+ sessionsKey,
551
+ metaKey(sessionId),
552
+ pendingKey(sessionId),
553
+ readyKey(sessionId)
422
554
  ], args);
423
555
  const [verdict] = reply;
556
+ if (verdict === "empty") return {
557
+ events: [],
558
+ hasPending: reply[1] === "1"
559
+ };
424
560
  if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
425
561
  if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
426
562
  if (verdict === "dup") {
427
- const wanted = new Set(reply.slice(1).map(Number));
563
+ const hasPending = reply[1] === "1";
564
+ const wanted = new Set(reply.slice(2).map(Number));
428
565
  const min = Math.min(...wanted);
429
- return (await readRange(sessionId, min - 1)).filter((r) => wanted.has(r.index));
566
+ const existing = (await readRange(sessionId, min - 1)).filter((row) => wanted.has(row.index));
567
+ return {
568
+ events: idempotentReplay(events, existing),
569
+ hasPending
570
+ };
430
571
  }
431
572
  const base = Number(reply[1]) - 1;
432
- return withIds.map((e, i) => ({
433
- id: e.id,
434
- type: e.type,
435
- payload: structuredClone(e.payload),
436
- index: base + 1 + i,
437
- sessionId,
438
- createdAt: new Date(now),
439
- cause: e.cause,
440
- processedAt: null,
441
- processedByAttempt: null,
442
- firstClaimedAt: null,
443
- lastClaimedAt: null,
444
- attemptCount: 0,
445
- failureCount: 0,
446
- lastFailedAt: null,
447
- lastFailedAttempt: null,
448
- lastError: null,
449
- failedAt: null
450
- }));
573
+ return {
574
+ events: withIds.map((e, i) => ({
575
+ id: e.id,
576
+ type: e.type,
577
+ payload: structuredClone(e.payload),
578
+ index: base + 1 + i,
579
+ sessionId,
580
+ createdAt: new Date(now),
581
+ cause: e.cause,
582
+ lane: e.lane,
583
+ processedAt: e.settled ? new Date(now) : null,
584
+ processedByAttempt: null,
585
+ returnedEventIds: null,
586
+ firstClaimedAt: null,
587
+ lastClaimedAt: null,
588
+ attemptCount: 0,
589
+ claimHolder: null,
590
+ claimExpiresAt: null,
591
+ failureCount: 0,
592
+ lastFailedAt: null,
593
+ lastFailedAttempt: null,
594
+ lastError: null,
595
+ failedAt: null
596
+ })),
597
+ hasPending: reply[2] === "1"
598
+ };
451
599
  });
452
600
  },
453
601
  async read(sessionId, opts) {
454
602
  return wrap(async () => {
455
- const rows = await readRange(sessionId, opts?.afterIndex ?? 0);
456
- return opts?.unprocessedOnly ? rows.filter((r) => r.processedAt === null) : rows;
603
+ return await readRange(sessionId, opts?.afterIndex ?? 0);
457
604
  });
458
605
  },
459
- async claimNext({ sessionId, holder, ttlMs, expiresAtMs, maxIndex }) {
606
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
460
607
  return wrap(async () => {
461
608
  const now = clock.now().getTime();
462
609
  const expiresAt = expiresAtMs ?? now + ttlMs;
463
- const effectiveTtlMs = Math.max(1, expiresAt - now);
464
- const gcPx = Math.max(effectiveTtlMs * 10, 6e4);
465
- const reply = await evalScript(CLAIM_NEXT_LUA, [
610
+ const excluded = excludeIndexes ?? [];
611
+ const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
466
612
  logKey(sessionId),
467
613
  countKey(sessionId),
468
614
  metaKey(sessionId),
469
- leaseKey(sessionId)
615
+ pendingKey(sessionId),
616
+ readyKey(sessionId)
470
617
  ], [
471
618
  holder,
472
619
  now,
473
620
  expiresAt,
474
- gcPx,
475
- maxIndex ?? -1
621
+ excluded.length,
622
+ ...excluded
476
623
  ]);
477
- return toClaimResult(sessionId, reply);
624
+ return toClaimAvailableResult(sessionId, reply);
478
625
  });
479
626
  },
480
- async completeAndClaimNext({ sessionId, holder, completedIndex, attempt, maxIndex }) {
627
+ async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
481
628
  return wrap(async () => {
482
- const reply = await evalScript(COMPLETE_AND_CLAIM_NEXT_LUA, [
629
+ const now = clock.now().getTime();
630
+ const expiresAt = expiresAtMs ?? now + ttlMs;
631
+ return (await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
632
+ holder,
633
+ now,
634
+ expiresAt,
635
+ indexes.length,
636
+ ...indexes
637
+ ])).map(Number);
638
+ });
639
+ },
640
+ async completeAttempt({ sessionId, index, attempt, events }) {
641
+ return wrap(async () => {
642
+ const ids = events.map((event) => event.id);
643
+ if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
644
+ const now = clock.now().getTime();
645
+ const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
483
646
  logKey(sessionId),
484
647
  countKey(sessionId),
648
+ idsKey,
649
+ sessionsKey,
485
650
  metaKey(sessionId),
486
- leaseKey(sessionId)
651
+ pendingKey(sessionId),
652
+ readyKey(sessionId)
487
653
  ], [
488
- holder,
489
- clock.now().getTime(),
490
- completedIndex,
654
+ sessionId,
655
+ now,
656
+ index,
491
657
  attempt,
492
- maxIndex ?? -1
658
+ events.length,
659
+ ...events.flatMap((event) => [
660
+ event.id,
661
+ event.type,
662
+ json(event.payload),
663
+ event.lane ?? "",
664
+ event.settled === true ? "1" : "0"
665
+ ])
493
666
  ]);
494
- return toHandoffResult(sessionId, reply);
495
- });
496
- },
497
- async markProcessed(sessionId, index) {
498
- await wrap(async () => {
499
- if (await evalScript(MARK_LUA, [countKey(sessionId), metaKey(sessionId)], [
667
+ const outcome = reply[0];
668
+ if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
669
+ if (outcome === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event ids do not match the completed attempt");
670
+ if (outcome === "superseded") return { outcome };
671
+ const cause = {
500
672
  index,
501
- clock.now().getTime(),
502
- "p"
503
- ]) === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
673
+ attempt
674
+ };
675
+ if (outcome === "ok") {
676
+ const base = Number(reply[1]) - 1;
677
+ return {
678
+ outcome: "completed",
679
+ events: events.map((event, i) => ({
680
+ id: event.id,
681
+ type: event.type,
682
+ payload: structuredClone(event.payload),
683
+ index: base + 1 + i,
684
+ sessionId,
685
+ createdAt: new Date(now),
686
+ cause,
687
+ lane: event.lane ?? null,
688
+ processedAt: event.settled === true ? new Date(now) : null,
689
+ processedByAttempt: null,
690
+ returnedEventIds: null,
691
+ firstClaimedAt: null,
692
+ lastClaimedAt: null,
693
+ attemptCount: 0,
694
+ claimHolder: null,
695
+ claimExpiresAt: null,
696
+ failureCount: 0,
697
+ lastFailedAt: null,
698
+ lastFailedAttempt: null,
699
+ lastError: null,
700
+ failedAt: null
701
+ }))
702
+ };
703
+ }
704
+ if (outcome === "duplicate") {
705
+ if (events.length === 0) return {
706
+ outcome: "completed",
707
+ events: []
708
+ };
709
+ const wanted = new Set(reply.slice(1).map(Number));
710
+ const min = Math.min(...wanted);
711
+ const rows = await readRange(sessionId, min - 1);
712
+ const byIndex = new Map(rows.map((row) => [row.index, row]));
713
+ const existing = reply.slice(1).map((value) => byIndex.get(Number(value)));
714
+ return {
715
+ outcome: "completed",
716
+ events: idempotentReplay(events, existing)
717
+ };
718
+ }
719
+ throw new TypeError(`unexpected completion outcome '${outcome}'`);
504
720
  });
505
721
  },
506
722
  async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
507
723
  return wrap(async () => {
508
- const reply = await evalScript(FAIL_ATTEMPT_LUA, [countKey(sessionId), metaKey(sessionId)], [
724
+ const reply = await evalScript(FAIL_ATTEMPT_LUA, [
725
+ countKey(sessionId),
726
+ metaKey(sessionId),
727
+ pendingKey(sessionId),
728
+ readyKey(sessionId)
729
+ ], [
509
730
  index,
510
731
  attempt,
511
732
  error,
@@ -520,15 +741,6 @@ function redis(options = {}) {
520
741
  };
521
742
  });
522
743
  },
523
- async markFailed(sessionId, index) {
524
- await wrap(async () => {
525
- if (await evalScript(MARK_LUA, [countKey(sessionId), metaKey(sessionId)], [
526
- index,
527
- clock.now().getTime(),
528
- "f"
529
- ]) === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
530
- });
531
- },
532
744
  async readState(sessionId, reducerName) {
533
745
  return wrap(async () => {
534
746
  const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), logKey(sessionId)], []);
@@ -555,7 +767,6 @@ function redis(options = {}) {
555
767
  inspect: {
556
768
  async listSessions(inspectionOptions) {
557
769
  return wrap(async () => {
558
- await ensureSessionIndex();
559
770
  const c = await client();
560
771
  const minimum = inspectionOptions.cursor === void 0 ? `[${inspectionOptions.prefix}` : `(${inspectionOptions.cursor}`;
561
772
  const candidates = await c.call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
@@ -677,27 +888,6 @@ function redis(options = {}) {
677
888
  };
678
889
  } };
679
890
  },
680
- lease: {
681
- async acquire({ sessionId, holder, ttlMs, expiresAtMs }) {
682
- return wrap(async () => {
683
- const now = clock.now().getTime();
684
- const expiresAt = expiresAtMs ?? now + ttlMs;
685
- const effectiveTtlMs = Math.max(1, expiresAt - now);
686
- const gcPx = Math.max(effectiveTtlMs * 10, 6e4);
687
- return await evalScript(LEASE_ACQUIRE_LUA, [leaseKey(sessionId)], [
688
- holder,
689
- now,
690
- expiresAt,
691
- gcPx
692
- ]) === 1;
693
- });
694
- },
695
- async release({ sessionId, holder }) {
696
- await wrap(async () => {
697
- await evalScript(LEASE_RELEASE_LUA, [leaseKey(sessionId)], [holder]);
698
- });
699
- }
700
- },
701
891
  async close() {
702
892
  for (const conn of streamConnections) conn.disconnect();
703
893
  streamConnections.clear();