experimental-a2 0.4.0 → 0.5.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.
- package/CHANGELOG.md +51 -0
- package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +4 -3
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +4 -2
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +3 -1
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +2 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.js +2 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -0
- package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/contract-48bUMgcL.js +2 -0
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/contract-jIfaR085.d.ts +2 -1
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +2 -1
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +2 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +2 -0
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +2 -1
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +2 -0
- package/dist/devtools-server.js.map +1 -0
- package/dist/errors-BQuJpe82.js +2 -0
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/errors-W6nwJ-fm.d.ts +2 -1
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +121 -72
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +503 -178
- package/dist/http.js.map +1 -0
- package/dist/idempotent-replay-DuqEkYA7.js +2 -0
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspection-DaxB5jM2.js +2 -0
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +2 -1
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +2 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +12 -3
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +4 -3
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +4 -2
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +2 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -3
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +4 -2
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js +2 -0
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +2 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +3 -2
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +19 -11
- package/dist/store-memory.js.map +1 -0
- package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/store-postgres.d.ts +3 -2
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/store-postgres.js +57 -1
- package/dist/store-postgres.js.map +1 -0
- package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/store-redis-http.d.ts +3 -2
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/store-redis-http.js +4 -2
- package/dist/store-redis-http.js.map +1 -0
- package/dist/store-redis.d.ts +3 -2
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/store-redis.js +5 -3
- package/dist/store-redis.js.map +1 -0
- package/dist/store-sqlite.d.ts +3 -2
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/store-sqlite.js +3 -1
- package/dist/store-sqlite.js.map +1 -0
- package/dist/telemetry-BjYHTfh2.d.ts +2 -1
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.js +2 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +2 -1
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/guides/03-react.mdx +59 -39
- package/docs/guides/06-ai-agents.mdx +5 -27
- package/docs/guides/09-presence.mdx +19 -40
- package/docs/guides/10-transports.mdx +49 -40
- package/docs/reference/01-api.mdx +107 -26
- package/docs/reference/02-errors.mdx +4 -2
- package/package.json +2 -1
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
|
@@ -0,0 +1,1494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared Redis store core — every operation except `stream` and
|
|
3
|
+
* `close`, expressed over one injected `call` function so both
|
|
4
|
+
* transports reuse it: `store-redis` (Redis protocol via `ioredis`) and
|
|
5
|
+
* `store-redis-http` (provider REST APIs).
|
|
6
|
+
*
|
|
7
|
+
* The stream IS the store: each event is a stream entry whose ID is its
|
|
8
|
+
* dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read.
|
|
9
|
+
* Mutable drain/failure bookkeeping lives in a side hash (stream
|
|
10
|
+
* entries are immutable); every write path is one atomic Lua script.
|
|
11
|
+
* The conformance suite in test/conformance is the executable
|
|
12
|
+
* contract, run against a real spawned `redis-server`.
|
|
13
|
+
*
|
|
14
|
+
* Timestamps and claim expiry come from the injected clock so tests
|
|
15
|
+
* can time-travel against a real server. Cluster mode is out: the
|
|
16
|
+
* atomic scripts span keys.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { PresencePatch } from './contract.ts'
|
|
20
|
+
import { A2Error } from './errors.ts'
|
|
21
|
+
import { idempotentReplay } from './idempotent-replay.ts'
|
|
22
|
+
import { nullProtoRecord } from './internal.ts'
|
|
23
|
+
import { decodeReturnedEventIds } from './store-codec.ts'
|
|
24
|
+
import type {
|
|
25
|
+
A2Store,
|
|
26
|
+
A2StoreInspection,
|
|
27
|
+
Clock,
|
|
28
|
+
CompleteAttemptResult,
|
|
29
|
+
Event,
|
|
30
|
+
EventCause,
|
|
31
|
+
IdSource,
|
|
32
|
+
StoreClaimAvailableResult,
|
|
33
|
+
PresenceRow,
|
|
34
|
+
StoredEvent,
|
|
35
|
+
} from './store.ts'
|
|
36
|
+
|
|
37
|
+
/** Issues any Redis command; the seam both transports implement. */
|
|
38
|
+
export type RedisCall = (
|
|
39
|
+
command: string,
|
|
40
|
+
...args: Array<string | number>
|
|
41
|
+
) => Promise<unknown>
|
|
42
|
+
|
|
43
|
+
export type RedisStoreCoreOptions = {
|
|
44
|
+
call: RedisCall
|
|
45
|
+
clock: Clock
|
|
46
|
+
ids: IdSource
|
|
47
|
+
keyPrefix: string
|
|
48
|
+
/**
|
|
49
|
+
* Disposable wake-up fired after a write appends new events to a
|
|
50
|
+
* watched session — `count` is the session's new event count. The
|
|
51
|
+
* watched check runs inside the write script, so unwatched sessions
|
|
52
|
+
* cost no wake-up command at all. Losing one is harmless: live
|
|
53
|
+
* feeds re-read on a safety cadence, so notifications only affect
|
|
54
|
+
* latency, never delivery.
|
|
55
|
+
*/
|
|
56
|
+
notify?: (sessionId: string, count: number) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type RedisStoreCore = Omit<
|
|
60
|
+
A2Store,
|
|
61
|
+
'stream' | 'inspect' | 'presence'
|
|
62
|
+
> & {
|
|
63
|
+
inspect: A2StoreInspection
|
|
64
|
+
/**
|
|
65
|
+
* The storage half of the presence capability, shared by both
|
|
66
|
+
* transports. `set` returns the applied patch — null when every
|
|
67
|
+
* field lost the LWW race — so a push-capable transport broadcasts
|
|
68
|
+
* exactly what `read` now reflects; delivery is its business.
|
|
69
|
+
*/
|
|
70
|
+
presence: {
|
|
71
|
+
set(
|
|
72
|
+
ns: string,
|
|
73
|
+
participant: string,
|
|
74
|
+
values: Record<string, unknown | null>,
|
|
75
|
+
meta: { seen: number; at: Date; ttlMs: number },
|
|
76
|
+
): Promise<PresencePatch | null>
|
|
77
|
+
read(ns: string): Promise<PresenceRow[]>
|
|
78
|
+
}
|
|
79
|
+
/** XRANGE + meta merge — the full-row catch-up read. */
|
|
80
|
+
readRange(
|
|
81
|
+
sessionId: string,
|
|
82
|
+
afterIndex: number,
|
|
83
|
+
throughIndex?: number,
|
|
84
|
+
): Promise<StoredEvent[]>
|
|
85
|
+
/** XRANGE only — the light catch-up read live feeds use. */
|
|
86
|
+
readEvents(sessionId: string, afterIndex: number): Promise<Event[]>
|
|
87
|
+
/**
|
|
88
|
+
* Declare the session watched for `ttlMs` — writes publish wake-ups
|
|
89
|
+
* only while this presence marker lives. Live feeds refresh it
|
|
90
|
+
* ahead of expiry; an expired marker degrades latency to the safety
|
|
91
|
+
* cadence, never delivery.
|
|
92
|
+
*/
|
|
93
|
+
markWatched(sessionId: string, ttlMs: number): Promise<void>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const READ_PAGE_SIZE = 256
|
|
97
|
+
|
|
98
|
+
// ── Lua scripts (each runs atomically) ───────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* KEYS: store stream, counter, global id hash, session index, metadata,
|
|
102
|
+
* pending, ready, watched.
|
|
103
|
+
* ARGV: sessionId, nowMs, n, then per event:
|
|
104
|
+
* id, type, payloadJson, causeJson, lane, settled.
|
|
105
|
+
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |
|
|
106
|
+
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
107
|
+
* ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
|
|
108
|
+
*/
|
|
109
|
+
const APPEND_LUA = `
|
|
110
|
+
local function lane_field(lane, suffix)
|
|
111
|
+
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
112
|
+
end
|
|
113
|
+
local function enqueue(field, idx, lane)
|
|
114
|
+
redis.call('ZADD', KEYS[6], idx, field)
|
|
115
|
+
if lane == '' then
|
|
116
|
+
redis.call('ZADD', KEYS[7], idx, field)
|
|
117
|
+
return
|
|
118
|
+
end
|
|
119
|
+
local tail_key = lane_field(lane, 'tail')
|
|
120
|
+
local tail = redis.call('HGET', KEYS[5], tail_key)
|
|
121
|
+
if tail then
|
|
122
|
+
redis.call('HSET', KEYS[5], tail .. ':next', field)
|
|
123
|
+
else
|
|
124
|
+
redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
|
|
125
|
+
redis.call('ZADD', KEYS[7], idx, field)
|
|
126
|
+
end
|
|
127
|
+
redis.call('HSET', KEYS[5], tail_key, field)
|
|
128
|
+
end
|
|
129
|
+
local n = tonumber(ARGV[3])
|
|
130
|
+
local base = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
131
|
+
local has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
|
|
132
|
+
if n == 0 then return { 'empty', has_pending } end
|
|
133
|
+
local dups = {}
|
|
134
|
+
for i = 1, n do
|
|
135
|
+
local id = ARGV[3 + (i - 1) * 6 + 1]
|
|
136
|
+
local existing = redis.call('HGET', KEYS[3], id)
|
|
137
|
+
if existing then
|
|
138
|
+
local rec = cjson.decode(existing)
|
|
139
|
+
if rec[1] ~= ARGV[1] then return { 'foreign', id } end
|
|
140
|
+
dups[#dups + 1] = tostring(rec[2])
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
if #dups == n then
|
|
144
|
+
local res = { 'dup', has_pending }
|
|
145
|
+
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
146
|
+
return res
|
|
147
|
+
end
|
|
148
|
+
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
149
|
+
for i = 1, n do
|
|
150
|
+
local cause = ARGV[3 + (i - 1) * 6 + 4]
|
|
151
|
+
if cause ~= '' then
|
|
152
|
+
local parsed = cjson.decode(cause)
|
|
153
|
+
local pidx = tonumber(parsed.index)
|
|
154
|
+
if pidx < 1 or pidx > base then
|
|
155
|
+
return { 'no_parent', tostring(parsed.index) }
|
|
156
|
+
end
|
|
157
|
+
local field = tostring(pidx)
|
|
158
|
+
local dispatches = tonumber(redis.call('HGET', KEYS[5], field .. ':d') or '0')
|
|
159
|
+
if dispatches ~= tonumber(parsed.attempt) or
|
|
160
|
+
redis.call('HEXISTS', KEYS[5], field .. ':f') == 1 then
|
|
161
|
+
return { 'superseded', field }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
for i = 1, n do
|
|
166
|
+
local id = ARGV[3 + (i - 1) * 6 + 1]
|
|
167
|
+
local typ = ARGV[3 + (i - 1) * 6 + 2]
|
|
168
|
+
local payload = ARGV[3 + (i - 1) * 6 + 3]
|
|
169
|
+
local cause = ARGV[3 + (i - 1) * 6 + 4]
|
|
170
|
+
local lane = ARGV[3 + (i - 1) * 6 + 5]
|
|
171
|
+
local settled = ARGV[3 + (i - 1) * 6 + 6]
|
|
172
|
+
local idx = base + i
|
|
173
|
+
redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
|
|
174
|
+
'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
|
|
175
|
+
'cause', cause, 'lane', lane)
|
|
176
|
+
redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
|
|
177
|
+
redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
|
|
178
|
+
if settled == '1' then
|
|
179
|
+
redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
|
|
180
|
+
else
|
|
181
|
+
enqueue(tostring(idx), idx, lane)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
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'
|
|
187
|
+
return { 'ok', tostring(base + 1), has_pending,
|
|
188
|
+
tostring(redis.call('EXISTS', KEYS[8])) }
|
|
189
|
+
`
|
|
190
|
+
|
|
191
|
+
/** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
|
|
192
|
+
const FAIL_ATTEMPT_LUA = `
|
|
193
|
+
local function lane_field(lane, suffix)
|
|
194
|
+
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
195
|
+
end
|
|
196
|
+
local function settle(field)
|
|
197
|
+
redis.call('ZREM', KEYS[3], field)
|
|
198
|
+
redis.call('ZREM', KEYS[4], field)
|
|
199
|
+
local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
|
|
200
|
+
if lane == '' then return end
|
|
201
|
+
local head_key = lane_field(lane, 'head')
|
|
202
|
+
if redis.call('HGET', KEYS[2], head_key) ~= field then return end
|
|
203
|
+
local next_field = redis.call('HGET', KEYS[2], field .. ':next')
|
|
204
|
+
while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
|
|
205
|
+
next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
|
|
206
|
+
end
|
|
207
|
+
if next_field then
|
|
208
|
+
redis.call('HSET', KEYS[2], head_key, next_field)
|
|
209
|
+
if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
|
|
210
|
+
redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
local idx = tonumber(ARGV[1])
|
|
217
|
+
if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
|
|
218
|
+
return { 'missing', '0' }
|
|
219
|
+
end
|
|
220
|
+
local field = ARGV[1]
|
|
221
|
+
local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
|
|
222
|
+
local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
|
|
223
|
+
if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
|
|
224
|
+
return { 'superseded', tostring(failures) }
|
|
225
|
+
end
|
|
226
|
+
if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
|
|
227
|
+
return { 'dead_lettered', tostring(failures) }
|
|
228
|
+
end
|
|
229
|
+
if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
|
|
230
|
+
tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
|
|
231
|
+
redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
|
|
232
|
+
return { 'failed', tostring(failures) }
|
|
233
|
+
end
|
|
234
|
+
if redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
|
|
235
|
+
return { 'superseded', tostring(failures) }
|
|
236
|
+
end
|
|
237
|
+
failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
|
|
238
|
+
redis.call('HSET', KEYS[2],
|
|
239
|
+
field .. ':e', ARGV[3],
|
|
240
|
+
field .. ':lf', ARGV[5],
|
|
241
|
+
field .. ':lfa', ARGV[2])
|
|
242
|
+
redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
|
|
243
|
+
if failures >= tonumber(ARGV[4]) then
|
|
244
|
+
redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
|
|
245
|
+
redis.call('ZREM', KEYS[4], field)
|
|
246
|
+
return { 'dead_lettered', tostring(failures) }
|
|
247
|
+
end
|
|
248
|
+
return { 'failed', tostring(failures) }
|
|
249
|
+
`
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* KEYS: store, counter, meta, pending, ready.
|
|
253
|
+
* ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
|
|
254
|
+
*/
|
|
255
|
+
const CLAIM_AVAILABLE_LUA = `
|
|
256
|
+
if redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end
|
|
257
|
+
local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
|
|
258
|
+
local excluded = {}
|
|
259
|
+
for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
|
|
260
|
+
local claimed = {}
|
|
261
|
+
local retry_at = nil
|
|
262
|
+
for _, field in ipairs(ready) do
|
|
263
|
+
if not excluded[field] then
|
|
264
|
+
local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')
|
|
265
|
+
if expires > tonumber(ARGV[2]) then
|
|
266
|
+
if not retry_at or expires < retry_at then retry_at = expires end
|
|
267
|
+
else
|
|
268
|
+
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
269
|
+
if #entries == 0 then return { 'missing', field } end
|
|
270
|
+
local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)
|
|
271
|
+
redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
|
|
272
|
+
redis.call('HSET', KEYS[3],
|
|
273
|
+
field .. ':lc', ARGV[2],
|
|
274
|
+
field .. ':ch', ARGV[1],
|
|
275
|
+
field .. ':ce', ARGV[3])
|
|
276
|
+
claimed[#claimed + 1] = {
|
|
277
|
+
tostring(attempt),
|
|
278
|
+
redis.call('HGET', KEYS[3], field .. ':a') or '0',
|
|
279
|
+
redis.call('HGET', KEYS[3], field .. ':e') or false,
|
|
280
|
+
redis.call('HGET', KEYS[3], field .. ':fc') or false,
|
|
281
|
+
ARGV[2],
|
|
282
|
+
redis.call('HGET', KEYS[3], field .. ':lf') or false,
|
|
283
|
+
redis.call('HGET', KEYS[3], field .. ':lfa') or false,
|
|
284
|
+
ARGV[1],
|
|
285
|
+
ARGV[3],
|
|
286
|
+
entries[1]
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
if #claimed > 0 then
|
|
292
|
+
local result = { 'claimed', tostring(#claimed) }
|
|
293
|
+
for _, claim in ipairs(claimed) do
|
|
294
|
+
for _, value in ipairs(claim) do result[#result + 1] = value end
|
|
295
|
+
end
|
|
296
|
+
return result
|
|
297
|
+
end
|
|
298
|
+
if retry_at then return { 'busy', tostring(retry_at) } end
|
|
299
|
+
return { 'settled' }
|
|
300
|
+
`
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* KEYS: counter, meta.
|
|
304
|
+
* ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
|
|
305
|
+
* Returns [renewedCount, ...renewed, ...superseded].
|
|
306
|
+
*/
|
|
307
|
+
const RENEW_CLAIMS_LUA = `
|
|
308
|
+
local total = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
309
|
+
local renewed = {}
|
|
310
|
+
local superseded = {}
|
|
311
|
+
for i = 1, tonumber(ARGV[4]) do
|
|
312
|
+
local field = ARGV[4 + (i - 1) * 2 + 1]
|
|
313
|
+
local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
|
|
314
|
+
local idx = tonumber(field)
|
|
315
|
+
if idx >= 1 and idx <= total then
|
|
316
|
+
local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
|
|
317
|
+
if dispatches > attempt then
|
|
318
|
+
superseded[#superseded + 1] = field
|
|
319
|
+
else
|
|
320
|
+
local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
|
|
321
|
+
if tonumber(ARGV[3]) > tonumber(ARGV[2]) and
|
|
322
|
+
redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
|
|
323
|
+
redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
|
|
324
|
+
redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
|
|
325
|
+
expires > tonumber(ARGV[2]) then
|
|
326
|
+
redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
|
|
327
|
+
renewed[#renewed + 1] = field
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
local result = { tostring(#renewed) }
|
|
333
|
+
for i = 1, #renewed do result[#result + 1] = renewed[i] end
|
|
334
|
+
for i = 1, #superseded do result[#result + 1] = superseded[i] end
|
|
335
|
+
return result
|
|
336
|
+
`
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* KEYS: store, counter, ids, session index, meta, pending, ready, watched.
|
|
340
|
+
* ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
|
|
341
|
+
* id, type, payloadJson, lane, settled.
|
|
342
|
+
*/
|
|
343
|
+
const COMPLETE_ATTEMPT_LUA = `
|
|
344
|
+
local function lane_field(lane, suffix)
|
|
345
|
+
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
346
|
+
end
|
|
347
|
+
local function settle(field)
|
|
348
|
+
redis.call('ZREM', KEYS[6], field)
|
|
349
|
+
redis.call('ZREM', KEYS[7], field)
|
|
350
|
+
local lane = redis.call('HGET', KEYS[5], field .. ':l') or ''
|
|
351
|
+
if lane == '' then return end
|
|
352
|
+
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')
|
|
357
|
+
end
|
|
358
|
+
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)
|
|
362
|
+
end
|
|
363
|
+
else
|
|
364
|
+
redis.call('HDEL', KEYS[5], head_key, lane_field(lane, 'tail'))
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
local function enqueue(field, idx, lane)
|
|
368
|
+
redis.call('ZADD', KEYS[6], idx, field)
|
|
369
|
+
if lane == '' then
|
|
370
|
+
redis.call('ZADD', KEYS[7], idx, field)
|
|
371
|
+
return
|
|
372
|
+
end
|
|
373
|
+
local tail_key = lane_field(lane, 'tail')
|
|
374
|
+
local tail = redis.call('HGET', KEYS[5], tail_key)
|
|
375
|
+
if tail then
|
|
376
|
+
redis.call('HSET', KEYS[5], tail .. ':next', field)
|
|
377
|
+
else
|
|
378
|
+
redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
|
|
379
|
+
redis.call('ZADD', KEYS[7], idx, field)
|
|
380
|
+
end
|
|
381
|
+
redis.call('HSET', KEYS[5], tail_key, field)
|
|
382
|
+
end
|
|
383
|
+
local parent = ARGV[3]
|
|
384
|
+
local attempt = tonumber(ARGV[4])
|
|
385
|
+
local n = tonumber(ARGV[5])
|
|
386
|
+
local total = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
387
|
+
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')
|
|
391
|
+
if processed then
|
|
392
|
+
if processed_attempt ~= attempt then return { 'superseded' } end
|
|
393
|
+
local raw_recorded_ids = redis.call('HGET', KEYS[5], parent .. ':ri')
|
|
394
|
+
if not raw_recorded_ids then return { 'partial' } end
|
|
395
|
+
local recorded_ids = cjson.decode(raw_recorded_ids)
|
|
396
|
+
if #recorded_ids ~= n then return { 'partial' } end
|
|
397
|
+
local indexes = {}
|
|
398
|
+
for i = 1, n do
|
|
399
|
+
local id = ARGV[5 + (i - 1) * 5 + 1]
|
|
400
|
+
if recorded_ids[i] ~= id then return { 'partial' } end
|
|
401
|
+
local existing = redis.call('HGET', KEYS[3], id)
|
|
402
|
+
if not existing then return { 'partial' } end
|
|
403
|
+
local rec = cjson.decode(existing)
|
|
404
|
+
if rec[1] ~= ARGV[1] then return { 'partial' } end
|
|
405
|
+
local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
|
|
406
|
+
if #entries == 0 then return { 'partial' } end
|
|
407
|
+
local values = entries[1][2]
|
|
408
|
+
local cause = nil
|
|
409
|
+
for j = 1, #values, 2 do
|
|
410
|
+
if values[j] == 'cause' then cause = values[j + 1] break end
|
|
411
|
+
end
|
|
412
|
+
if not cause or cause == '' then return { 'partial' } end
|
|
413
|
+
local decoded = cjson.decode(cause)
|
|
414
|
+
if tonumber(decoded['index']) ~= tonumber(parent) or
|
|
415
|
+
tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end
|
|
416
|
+
indexes[#indexes + 1] = tostring(rec[2])
|
|
417
|
+
end
|
|
418
|
+
local result = { 'duplicate' }
|
|
419
|
+
for _, idx in ipairs(indexes) do result[#result + 1] = idx end
|
|
420
|
+
return result
|
|
421
|
+
end
|
|
422
|
+
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
|
|
426
|
+
return { 'superseded' }
|
|
427
|
+
end
|
|
428
|
+
for i = 1, n do
|
|
429
|
+
local id = ARGV[5 + (i - 1) * 5 + 1]
|
|
430
|
+
if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
|
|
431
|
+
end
|
|
432
|
+
local returned_ids = {}
|
|
433
|
+
for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
|
|
434
|
+
local returned_json = '[]'
|
|
435
|
+
if n > 0 then returned_json = cjson.encode(returned_ids) end
|
|
436
|
+
redis.call('HSET', KEYS[5],
|
|
437
|
+
parent .. ':p', ARGV[2],
|
|
438
|
+
parent .. ':pa', ARGV[4],
|
|
439
|
+
parent .. ':ri', returned_json)
|
|
440
|
+
redis.call('HDEL', KEYS[5], parent .. ':ch', parent .. ':ce')
|
|
441
|
+
settle(parent)
|
|
442
|
+
local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
|
|
443
|
+
for i = 1, n do
|
|
444
|
+
local id = ARGV[5 + (i - 1) * 5 + 1]
|
|
445
|
+
local typ = ARGV[5 + (i - 1) * 5 + 2]
|
|
446
|
+
local payload = ARGV[5 + (i - 1) * 5 + 3]
|
|
447
|
+
local lane = ARGV[5 + (i - 1) * 5 + 4]
|
|
448
|
+
local settled = ARGV[5 + (i - 1) * 5 + 5]
|
|
449
|
+
local idx = total + i
|
|
450
|
+
redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
|
|
451
|
+
'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
|
|
452
|
+
'cause', cause, 'lane', lane)
|
|
453
|
+
redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
|
|
454
|
+
redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
|
|
455
|
+
if settled == '1' then
|
|
456
|
+
redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
|
|
457
|
+
else
|
|
458
|
+
enqueue(tostring(idx), idx, lane)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
redis.call('SET', KEYS[2], tostring(total + n))
|
|
462
|
+
redis.call('ZADD', KEYS[4], 0, ARGV[1])
|
|
463
|
+
return { 'ok', tostring(total + 1),
|
|
464
|
+
tostring(redis.call('EXISTS', KEYS[8])) }
|
|
465
|
+
`
|
|
466
|
+
|
|
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
|
|
472
|
+
end
|
|
473
|
+
redis.call('SET', KEYS[1], ARGV[2])
|
|
474
|
+
return 1
|
|
475
|
+
`
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
|
|
479
|
+
* hashField, valueJson ('' deletes). Field-wise LWW by the sender's
|
|
480
|
+
* `at`: a stored entry with a strictly newer stamp survives; ties go
|
|
481
|
+
* to the incoming write (memory-tier parity). Returns the applied
|
|
482
|
+
* 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
|
|
483
|
+
* sound because core sends one uniform ttlMs; the per-entry `e` stamp
|
|
484
|
+
* is the fine bound (the injected storage clock at the applied set
|
|
485
|
+
* plus ttlMs — never the sender stamp), pruned on read against the
|
|
486
|
+
* same clock.
|
|
487
|
+
*/
|
|
488
|
+
const PRESENCE_SET_LUA = `
|
|
489
|
+
local at = tonumber(ARGV[1])
|
|
490
|
+
local applied = {}
|
|
491
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
492
|
+
local field = ARGV[3 + (i - 1) * 2 + 1]
|
|
493
|
+
local value = ARGV[3 + (i - 1) * 2 + 2]
|
|
494
|
+
local existing = redis.call('HGET', KEYS[1], field)
|
|
495
|
+
if not existing or tonumber(cjson.decode(existing)['a']) <= at then
|
|
496
|
+
if value == '' then
|
|
497
|
+
redis.call('HDEL', KEYS[1], field)
|
|
498
|
+
else
|
|
499
|
+
redis.call('HSET', KEYS[1], field, value)
|
|
500
|
+
end
|
|
501
|
+
applied[#applied + 1] = tostring(i)
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
if #applied > 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
|
505
|
+
return applied
|
|
506
|
+
`
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
|
|
510
|
+
* time-travel expiry against a real server). Returns the live
|
|
511
|
+
* [field, value, ...] pairs; entries at or past their fine-bound
|
|
512
|
+
* expiry are HDELed in the same atomic step, so a stale reader can
|
|
513
|
+
* never sweep a concurrently refreshed field.
|
|
514
|
+
*/
|
|
515
|
+
const PRESENCE_READ_LUA = `
|
|
516
|
+
local now = tonumber(ARGV[1])
|
|
517
|
+
local rows = redis.call('HGETALL', KEYS[1])
|
|
518
|
+
local live = {}
|
|
519
|
+
for i = 1, #rows, 2 do
|
|
520
|
+
if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
|
|
521
|
+
redis.call('HDEL', KEYS[1], rows[i])
|
|
522
|
+
else
|
|
523
|
+
live[#live + 1] = rows[i]
|
|
524
|
+
live[#live + 1] = rows[i + 1]
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
return live
|
|
528
|
+
`
|
|
529
|
+
|
|
530
|
+
/** KEYS: snapshot, event counter. Returns one atomic cache-plus-frontier read. */
|
|
531
|
+
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 }
|
|
535
|
+
`
|
|
536
|
+
|
|
537
|
+
// ── wire helpers ─────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
type StreamEntry = [id: string, fields: string[]]
|
|
540
|
+
|
|
541
|
+
const fieldMap = (fields: string[]): Record<string, string> => {
|
|
542
|
+
const map: Record<string, string> = {}
|
|
543
|
+
for (let i = 0; i + 1 < fields.length; i += 2) {
|
|
544
|
+
map[fields[i]!] = fields[i + 1]!
|
|
545
|
+
}
|
|
546
|
+
return map
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const STORED_EVENT_META_SUFFIXES = [
|
|
550
|
+
'p',
|
|
551
|
+
'pa',
|
|
552
|
+
'ri',
|
|
553
|
+
'ch',
|
|
554
|
+
'ce',
|
|
555
|
+
'f',
|
|
556
|
+
'fc',
|
|
557
|
+
'lc',
|
|
558
|
+
'lf',
|
|
559
|
+
'lfa',
|
|
560
|
+
'd',
|
|
561
|
+
'a',
|
|
562
|
+
'e',
|
|
563
|
+
] as const
|
|
564
|
+
|
|
565
|
+
const entryIndex = (entryId: string): number =>
|
|
566
|
+
Number(entryId.slice(0, entryId.indexOf('-')))
|
|
567
|
+
|
|
568
|
+
const toEvent = (sessionId: string, entry: StreamEntry): Event => {
|
|
569
|
+
const fields = fieldMap(entry[1])
|
|
570
|
+
return {
|
|
571
|
+
id: fields['id']!,
|
|
572
|
+
type: fields['type']!,
|
|
573
|
+
payload: JSON.parse(fields['payload']!),
|
|
574
|
+
index: entryIndex(entry[0]),
|
|
575
|
+
sessionId,
|
|
576
|
+
createdAt: new Date(Number(fields['created_at'])),
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const json = (value: unknown): string => JSON.stringify(value) ?? 'null'
|
|
581
|
+
|
|
582
|
+
const escapeGlob = (value: string): string =>
|
|
583
|
+
value
|
|
584
|
+
.replaceAll('\\', '\\\\')
|
|
585
|
+
.replaceAll('*', '\\*')
|
|
586
|
+
.replaceAll('?', '\\?')
|
|
587
|
+
.replaceAll('[', '\\[')
|
|
588
|
+
.replaceAll(']', '\\]')
|
|
589
|
+
|
|
590
|
+
const toCause = (raw: string | undefined): EventCause | null => {
|
|
591
|
+
if (raw === '' || raw === undefined) return null
|
|
592
|
+
const value = JSON.parse(raw) as {
|
|
593
|
+
index?: unknown
|
|
594
|
+
attempt?: unknown
|
|
595
|
+
batchSize?: unknown
|
|
596
|
+
}
|
|
597
|
+
if (
|
|
598
|
+
!Number.isInteger(value.index) ||
|
|
599
|
+
Number(value.index) < 1 ||
|
|
600
|
+
!Number.isInteger(value.attempt) ||
|
|
601
|
+
Number(value.attempt) < 1
|
|
602
|
+
) {
|
|
603
|
+
throw new TypeError('stored event has an invalid cause')
|
|
604
|
+
}
|
|
605
|
+
if (
|
|
606
|
+
value.batchSize !== undefined &&
|
|
607
|
+
(!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)
|
|
608
|
+
) {
|
|
609
|
+
throw new TypeError('stored event has an invalid cause')
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
index: Number(value.index),
|
|
613
|
+
attempt: Number(value.attempt),
|
|
614
|
+
...(value.batchSize === undefined
|
|
615
|
+
? {}
|
|
616
|
+
: { batchSize: Number(value.batchSize) }),
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const toClaimAvailableResult = (
|
|
621
|
+
sessionId: string,
|
|
622
|
+
reply: unknown[],
|
|
623
|
+
): StoreClaimAvailableResult => {
|
|
624
|
+
const outcome = String(reply[0])
|
|
625
|
+
if (outcome === 'claimed') {
|
|
626
|
+
const count = Number(reply[1])
|
|
627
|
+
const events: StoredEvent[] = []
|
|
628
|
+
for (let i = 0; i < count; i++) {
|
|
629
|
+
const offset = 2 + i * 10
|
|
630
|
+
const entry = reply[offset + 9] as StreamEntry
|
|
631
|
+
const event = toEvent(sessionId, entry)
|
|
632
|
+
const fields = fieldMap(entry[1])
|
|
633
|
+
events.push(
|
|
634
|
+
Object.assign(event, {
|
|
635
|
+
cause: toCause(fields['cause']),
|
|
636
|
+
lane: fields['lane'] === '' ? null : fields['lane']!,
|
|
637
|
+
processedAt: null,
|
|
638
|
+
processedByAttempt: null,
|
|
639
|
+
returnedEventIds: null,
|
|
640
|
+
firstClaimedAt:
|
|
641
|
+
reply[offset + 3] === null
|
|
642
|
+
? null
|
|
643
|
+
: new Date(Number(reply[offset + 3])),
|
|
644
|
+
lastClaimedAt: new Date(Number(reply[offset + 4])),
|
|
645
|
+
attemptCount: Number(reply[offset]),
|
|
646
|
+
claimHolder: String(reply[offset + 7]),
|
|
647
|
+
claimExpiresAt: new Date(Number(reply[offset + 8])),
|
|
648
|
+
failureCount: Number(reply[offset + 1]),
|
|
649
|
+
lastFailedAt:
|
|
650
|
+
reply[offset + 5] === null
|
|
651
|
+
? null
|
|
652
|
+
: new Date(Number(reply[offset + 5])),
|
|
653
|
+
lastFailedAttempt:
|
|
654
|
+
reply[offset + 6] === null ? null : Number(reply[offset + 6]),
|
|
655
|
+
lastError:
|
|
656
|
+
reply[offset + 2] === null ? null : String(reply[offset + 2]),
|
|
657
|
+
failedAt: null,
|
|
658
|
+
}),
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
outcome: 'claimed',
|
|
663
|
+
events,
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (outcome === 'busy') {
|
|
667
|
+
return { outcome, retryAt: new Date(Number(reply[1])) }
|
|
668
|
+
}
|
|
669
|
+
if (outcome === 'settled') return { outcome }
|
|
670
|
+
throw new TypeError(
|
|
671
|
+
`claim referenced a missing event in session '${sessionId}'`,
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
676
|
+
try {
|
|
677
|
+
return await fn()
|
|
678
|
+
} catch (err) {
|
|
679
|
+
if (err instanceof A2Error || err instanceof TypeError) throw err
|
|
680
|
+
throw new A2Error('STORE_UNAVAILABLE', 'redis store operation failed', {
|
|
681
|
+
cause: err,
|
|
682
|
+
})
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function createRedisStoreCore(
|
|
687
|
+
options: RedisStoreCoreOptions,
|
|
688
|
+
): RedisStoreCore {
|
|
689
|
+
const { call, clock, notify } = options
|
|
690
|
+
const generateId = options.ids
|
|
691
|
+
const prefix = options.keyPrefix
|
|
692
|
+
|
|
693
|
+
const storeKey = (s: string): string => `${prefix}:${s}:log`
|
|
694
|
+
const countKey = (s: string): string => `${prefix}:${s}:count`
|
|
695
|
+
const metaKey = (s: string): string => `${prefix}:${s}:meta`
|
|
696
|
+
const pendingKey = (s: string): string => `${prefix}:${s}:pending`
|
|
697
|
+
const readyKey = (s: string): string => `${prefix}:${s}:ready`
|
|
698
|
+
const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
|
|
699
|
+
const watchedKey = (s: string): string => `${prefix}:${s}:watched`
|
|
700
|
+
const presenceKey = (s: string): string => `${prefix}:${s}:presence`
|
|
701
|
+
const idsKey = `${prefix}:ids`
|
|
702
|
+
const sessionsKey = `${prefix}:sessions`
|
|
703
|
+
|
|
704
|
+
const evalScript = (
|
|
705
|
+
script: string,
|
|
706
|
+
keys: string[],
|
|
707
|
+
args: Array<string | number>,
|
|
708
|
+
): Promise<unknown> => call('EVAL', script, keys.length, ...keys, ...args)
|
|
709
|
+
|
|
710
|
+
const readFrontier = async (sessionId: string): Promise<number> => {
|
|
711
|
+
const frontier = Number((await call('GET', countKey(sessionId))) ?? 0)
|
|
712
|
+
if (!Number.isSafeInteger(frontier) || frontier < 0) {
|
|
713
|
+
throw new A2Error(
|
|
714
|
+
'STORE_UNAVAILABLE',
|
|
715
|
+
`redis event count for session '${sessionId}' is invalid`,
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
return frontier
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const readRangePage = async (
|
|
722
|
+
sessionId: string,
|
|
723
|
+
afterIndex: number,
|
|
724
|
+
throughIndex: number,
|
|
725
|
+
limit: number,
|
|
726
|
+
): Promise<StoredEvent[]> => {
|
|
727
|
+
if (throughIndex <= afterIndex) return []
|
|
728
|
+
const entries = (await call(
|
|
729
|
+
'XRANGE',
|
|
730
|
+
storeKey(sessionId),
|
|
731
|
+
`${afterIndex + 1}-0`,
|
|
732
|
+
`${throughIndex}-0`,
|
|
733
|
+
'COUNT',
|
|
734
|
+
limit,
|
|
735
|
+
)) as StreamEntry[]
|
|
736
|
+
if (entries.length === 0) return []
|
|
737
|
+
|
|
738
|
+
const metaFields = entries.flatMap((entry) => {
|
|
739
|
+
const index = entryIndex(entry[0])
|
|
740
|
+
return STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`)
|
|
741
|
+
})
|
|
742
|
+
const metaValues =
|
|
743
|
+
((await call('HMGET', metaKey(sessionId), ...metaFields)) as Array<
|
|
744
|
+
string | null
|
|
745
|
+
> | null) ?? []
|
|
746
|
+
const meta: Record<string, string> = {}
|
|
747
|
+
for (let i = 0; i < metaFields.length; i += 1) {
|
|
748
|
+
const value = metaValues[i]
|
|
749
|
+
if (value !== null && value !== undefined) {
|
|
750
|
+
meta[metaFields[i]!] = value
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return entries.map((entry) => {
|
|
754
|
+
const event = toEvent(sessionId, entry)
|
|
755
|
+
const idx = event.index
|
|
756
|
+
const fields = fieldMap(entry[1])
|
|
757
|
+
const processedAt = meta[`${idx}:p`]
|
|
758
|
+
const processedByAttempt = meta[`${idx}:pa`]
|
|
759
|
+
const returnedEventIds = meta[`${idx}:ri`]
|
|
760
|
+
const claimHolder = meta[`${idx}:ch`]
|
|
761
|
+
const claimExpiresAt = meta[`${idx}:ce`]
|
|
762
|
+
const failedAt = meta[`${idx}:f`]
|
|
763
|
+
const firstClaimedAt = meta[`${idx}:fc`]
|
|
764
|
+
const lastClaimedAt = meta[`${idx}:lc`]
|
|
765
|
+
const lastFailedAt = meta[`${idx}:lf`]
|
|
766
|
+
const lastFailedAttempt = meta[`${idx}:lfa`]
|
|
767
|
+
return Object.assign(event, {
|
|
768
|
+
cause: toCause(fields['cause']),
|
|
769
|
+
lane: fields['lane'] === '' ? null : fields['lane']!,
|
|
770
|
+
processedAt:
|
|
771
|
+
processedAt === undefined ? null : new Date(Number(processedAt)),
|
|
772
|
+
processedByAttempt:
|
|
773
|
+
processedByAttempt === undefined ? null : Number(processedByAttempt),
|
|
774
|
+
returnedEventIds:
|
|
775
|
+
returnedEventIds === undefined
|
|
776
|
+
? null
|
|
777
|
+
: decodeReturnedEventIds(returnedEventIds),
|
|
778
|
+
firstClaimedAt:
|
|
779
|
+
firstClaimedAt === undefined
|
|
780
|
+
? null
|
|
781
|
+
: new Date(Number(firstClaimedAt)),
|
|
782
|
+
lastClaimedAt:
|
|
783
|
+
lastClaimedAt === undefined ? null : new Date(Number(lastClaimedAt)),
|
|
784
|
+
attemptCount: Number(meta[`${idx}:d`] ?? 0),
|
|
785
|
+
claimHolder: claimHolder ?? null,
|
|
786
|
+
claimExpiresAt:
|
|
787
|
+
claimExpiresAt === undefined
|
|
788
|
+
? null
|
|
789
|
+
: new Date(Number(claimExpiresAt)),
|
|
790
|
+
failureCount: Number(meta[`${idx}:a`] ?? 0),
|
|
791
|
+
lastFailedAt:
|
|
792
|
+
lastFailedAt === undefined ? null : new Date(Number(lastFailedAt)),
|
|
793
|
+
lastFailedAttempt:
|
|
794
|
+
lastFailedAttempt === undefined ? null : Number(lastFailedAttempt),
|
|
795
|
+
lastError: meta[`${idx}:e`] ?? null,
|
|
796
|
+
failedAt: failedAt === undefined ? null : new Date(Number(failedAt)),
|
|
797
|
+
})
|
|
798
|
+
})
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const readEventRange = async (
|
|
802
|
+
sessionId: string,
|
|
803
|
+
afterIndex: number,
|
|
804
|
+
throughIndex: number,
|
|
805
|
+
): Promise<Event[]> => {
|
|
806
|
+
if (throughIndex <= afterIndex) return []
|
|
807
|
+
const events: Event[] = []
|
|
808
|
+
let nextIndex = afterIndex + 1
|
|
809
|
+
while (nextIndex <= throughIndex) {
|
|
810
|
+
// oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range
|
|
811
|
+
const entries = (await call(
|
|
812
|
+
'XRANGE',
|
|
813
|
+
storeKey(sessionId),
|
|
814
|
+
`${nextIndex}-0`,
|
|
815
|
+
`${throughIndex}-0`,
|
|
816
|
+
'COUNT',
|
|
817
|
+
READ_PAGE_SIZE,
|
|
818
|
+
)) as StreamEntry[]
|
|
819
|
+
if (entries.length === 0) {
|
|
820
|
+
throw new A2Error(
|
|
821
|
+
'STORE_UNAVAILABLE',
|
|
822
|
+
`redis event range for session '${sessionId}' ended before index ${throughIndex}`,
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
826
|
+
const index = entryIndex(entries[position]![0])
|
|
827
|
+
if (index !== nextIndex + position || index > throughIndex) {
|
|
828
|
+
throw new A2Error(
|
|
829
|
+
'STORE_UNAVAILABLE',
|
|
830
|
+
`redis event range for session '${sessionId}' is not contiguous`,
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
events.push(...entries.map((entry) => toEvent(sessionId, entry)))
|
|
835
|
+
nextIndex = events.at(-1)!.index + 1
|
|
836
|
+
}
|
|
837
|
+
return events
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Bounded XRANGE pages restore the full-read contract on capped providers. */
|
|
841
|
+
const readRange = async (
|
|
842
|
+
sessionId: string,
|
|
843
|
+
afterIndex: number,
|
|
844
|
+
throughIndex?: number,
|
|
845
|
+
): Promise<StoredEvent[]> => {
|
|
846
|
+
if (throughIndex !== undefined && throughIndex <= afterIndex) return []
|
|
847
|
+
const currentFrontier = await readFrontier(sessionId)
|
|
848
|
+
const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier)
|
|
849
|
+
if (frontier <= afterIndex) return []
|
|
850
|
+
|
|
851
|
+
const rows: StoredEvent[] = []
|
|
852
|
+
let nextIndex = afterIndex + 1
|
|
853
|
+
while (nextIndex <= frontier) {
|
|
854
|
+
// oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range
|
|
855
|
+
const page = await readRangePage(
|
|
856
|
+
sessionId,
|
|
857
|
+
nextIndex - 1,
|
|
858
|
+
frontier,
|
|
859
|
+
READ_PAGE_SIZE,
|
|
860
|
+
)
|
|
861
|
+
if (page.length === 0) {
|
|
862
|
+
throw new A2Error(
|
|
863
|
+
'STORE_UNAVAILABLE',
|
|
864
|
+
`redis event range for session '${sessionId}' ended before index ${frontier}`,
|
|
865
|
+
)
|
|
866
|
+
}
|
|
867
|
+
for (let position = 0; position < page.length; position += 1) {
|
|
868
|
+
if (page[position]!.index !== nextIndex + position) {
|
|
869
|
+
throw new A2Error(
|
|
870
|
+
'STORE_UNAVAILABLE',
|
|
871
|
+
`redis event range for session '${sessionId}' is not contiguous`,
|
|
872
|
+
)
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const lastIndex = page.at(-1)!.index
|
|
876
|
+
if (lastIndex < nextIndex || lastIndex > frontier) {
|
|
877
|
+
throw new A2Error(
|
|
878
|
+
'STORE_UNAVAILABLE',
|
|
879
|
+
`redis event range for session '${sessionId}' did not advance`,
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
rows.push(...page)
|
|
883
|
+
nextIndex = lastIndex + 1
|
|
884
|
+
}
|
|
885
|
+
return rows
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
async append(sessionId, events) {
|
|
890
|
+
return wrap(async () => {
|
|
891
|
+
const supplied = events.filter((e) => e.id !== undefined)
|
|
892
|
+
const suppliedIds = supplied.map((e) => e.id as string)
|
|
893
|
+
if (new Set(suppliedIds).size !== suppliedIds.length) {
|
|
894
|
+
throw new A2Error(
|
|
895
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
896
|
+
'batch contains the same event id more than once',
|
|
897
|
+
)
|
|
898
|
+
}
|
|
899
|
+
const now = clock.now().getTime()
|
|
900
|
+
const withIds = events.map((e) => ({
|
|
901
|
+
id: e.id ?? generateId(),
|
|
902
|
+
type: e.type,
|
|
903
|
+
payload: e.payload,
|
|
904
|
+
cause: e.cause ? { ...e.cause } : null,
|
|
905
|
+
lane: e.lane ?? null,
|
|
906
|
+
settled: e.settled === true,
|
|
907
|
+
}))
|
|
908
|
+
const args: Array<string | number> = [
|
|
909
|
+
sessionId,
|
|
910
|
+
now,
|
|
911
|
+
withIds.length,
|
|
912
|
+
...withIds.flatMap((e) => [
|
|
913
|
+
e.id,
|
|
914
|
+
e.type,
|
|
915
|
+
json(e.payload),
|
|
916
|
+
e.cause ? json(e.cause) : '',
|
|
917
|
+
e.lane ?? '',
|
|
918
|
+
e.settled ? '1' : '0',
|
|
919
|
+
]),
|
|
920
|
+
]
|
|
921
|
+
const reply = (await evalScript(
|
|
922
|
+
APPEND_LUA,
|
|
923
|
+
[
|
|
924
|
+
storeKey(sessionId),
|
|
925
|
+
countKey(sessionId),
|
|
926
|
+
idsKey,
|
|
927
|
+
sessionsKey,
|
|
928
|
+
metaKey(sessionId),
|
|
929
|
+
pendingKey(sessionId),
|
|
930
|
+
readyKey(sessionId),
|
|
931
|
+
watchedKey(sessionId),
|
|
932
|
+
],
|
|
933
|
+
args,
|
|
934
|
+
)) as string[]
|
|
935
|
+
|
|
936
|
+
const [verdict] = reply
|
|
937
|
+
if (verdict === 'empty') {
|
|
938
|
+
return { events: [], hasPending: reply[1] === '1' }
|
|
939
|
+
}
|
|
940
|
+
if (verdict === 'foreign') {
|
|
941
|
+
throw new A2Error(
|
|
942
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
943
|
+
`event id '${reply[1]}' already exists in another session`,
|
|
944
|
+
)
|
|
945
|
+
}
|
|
946
|
+
if (verdict === 'no_parent') {
|
|
947
|
+
throw new TypeError(
|
|
948
|
+
`no event at index ${reply[1]} in session '${sessionId}'`,
|
|
949
|
+
)
|
|
950
|
+
}
|
|
951
|
+
if (verdict === 'superseded') {
|
|
952
|
+
throw new A2Error(
|
|
953
|
+
'SUPERSEDED_ATTEMPT',
|
|
954
|
+
`a newer attempt owns event ${reply[1]} in session '${sessionId}'`,
|
|
955
|
+
)
|
|
956
|
+
}
|
|
957
|
+
if (verdict === 'partial') {
|
|
958
|
+
throw new A2Error(
|
|
959
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
960
|
+
`batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`,
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
if (verdict === 'dup') {
|
|
964
|
+
// A retry of a committed batch whose ack was lost — return
|
|
965
|
+
// the original rows, ordered by index like every adapter.
|
|
966
|
+
const hasPending = reply[1] === '1'
|
|
967
|
+
const wanted = new Set(reply.slice(2).map(Number))
|
|
968
|
+
const min = Math.min(...wanted)
|
|
969
|
+
const rows = await readRange(sessionId, min - 1)
|
|
970
|
+
const existing = rows.filter((row) => wanted.has(row.index))
|
|
971
|
+
return {
|
|
972
|
+
events: idempotentReplay(events, existing),
|
|
973
|
+
hasPending,
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const base = Number(reply[1]) - 1
|
|
977
|
+
if (reply[3] === '1') notify?.(sessionId, base + withIds.length)
|
|
978
|
+
return {
|
|
979
|
+
events: withIds.map((e, i) => ({
|
|
980
|
+
id: e.id,
|
|
981
|
+
type: e.type,
|
|
982
|
+
payload: structuredClone(e.payload),
|
|
983
|
+
index: base + 1 + i,
|
|
984
|
+
sessionId,
|
|
985
|
+
createdAt: new Date(now),
|
|
986
|
+
cause: e.cause,
|
|
987
|
+
lane: e.lane,
|
|
988
|
+
processedAt: e.settled ? new Date(now) : null,
|
|
989
|
+
processedByAttempt: null,
|
|
990
|
+
returnedEventIds: null,
|
|
991
|
+
firstClaimedAt: null,
|
|
992
|
+
lastClaimedAt: null,
|
|
993
|
+
attemptCount: 0,
|
|
994
|
+
claimHolder: null,
|
|
995
|
+
claimExpiresAt: null,
|
|
996
|
+
failureCount: 0,
|
|
997
|
+
lastFailedAt: null,
|
|
998
|
+
lastFailedAttempt: null,
|
|
999
|
+
lastError: null,
|
|
1000
|
+
failedAt: null,
|
|
1001
|
+
})),
|
|
1002
|
+
hasPending: reply[2] === '1',
|
|
1003
|
+
}
|
|
1004
|
+
})
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
async read(sessionId, opts) {
|
|
1008
|
+
return wrap(async () => {
|
|
1009
|
+
const rows = await readRange(
|
|
1010
|
+
sessionId,
|
|
1011
|
+
opts?.afterIndex ?? 0,
|
|
1012
|
+
opts?.throughIndex,
|
|
1013
|
+
)
|
|
1014
|
+
return rows
|
|
1015
|
+
})
|
|
1016
|
+
},
|
|
1017
|
+
|
|
1018
|
+
async claimAvailable({
|
|
1019
|
+
sessionId,
|
|
1020
|
+
holder,
|
|
1021
|
+
ttlMs,
|
|
1022
|
+
expiresAtMs,
|
|
1023
|
+
excludeIndexes,
|
|
1024
|
+
}) {
|
|
1025
|
+
return wrap(async () => {
|
|
1026
|
+
const now = clock.now().getTime()
|
|
1027
|
+
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
1028
|
+
const excluded = excludeIndexes ?? []
|
|
1029
|
+
const reply = (await evalScript(
|
|
1030
|
+
CLAIM_AVAILABLE_LUA,
|
|
1031
|
+
[
|
|
1032
|
+
storeKey(sessionId),
|
|
1033
|
+
countKey(sessionId),
|
|
1034
|
+
metaKey(sessionId),
|
|
1035
|
+
pendingKey(sessionId),
|
|
1036
|
+
readyKey(sessionId),
|
|
1037
|
+
],
|
|
1038
|
+
[holder, now, expiresAt, excluded.length, ...excluded],
|
|
1039
|
+
)) as unknown[]
|
|
1040
|
+
return toClaimAvailableResult(sessionId, reply)
|
|
1041
|
+
})
|
|
1042
|
+
},
|
|
1043
|
+
|
|
1044
|
+
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
1045
|
+
return wrap(async () => {
|
|
1046
|
+
const now = clock.now().getTime()
|
|
1047
|
+
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
1048
|
+
const reply = (await evalScript(
|
|
1049
|
+
RENEW_CLAIMS_LUA,
|
|
1050
|
+
[countKey(sessionId), metaKey(sessionId)],
|
|
1051
|
+
[
|
|
1052
|
+
holder,
|
|
1053
|
+
now,
|
|
1054
|
+
expiresAt,
|
|
1055
|
+
claims.length,
|
|
1056
|
+
...claims.flatMap((claim) => [claim.index, claim.attempt]),
|
|
1057
|
+
],
|
|
1058
|
+
)) as string[]
|
|
1059
|
+
const renewedCount = Number(reply[0])
|
|
1060
|
+
const values = reply.slice(1).map(Number)
|
|
1061
|
+
return {
|
|
1062
|
+
renewed: values.slice(0, renewedCount).toSorted((a, b) => a - b),
|
|
1063
|
+
superseded: values.slice(renewedCount).toSorted((a, b) => a - b),
|
|
1064
|
+
}
|
|
1065
|
+
})
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1068
|
+
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
1069
|
+
return wrap(async () => {
|
|
1070
|
+
const ids = events.map((event) => event.id)
|
|
1071
|
+
if (new Set(ids).size !== ids.length) {
|
|
1072
|
+
throw new A2Error(
|
|
1073
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
1074
|
+
'batch contains the same event id more than once',
|
|
1075
|
+
)
|
|
1076
|
+
}
|
|
1077
|
+
const now = clock.now().getTime()
|
|
1078
|
+
const reply = (await evalScript(
|
|
1079
|
+
COMPLETE_ATTEMPT_LUA,
|
|
1080
|
+
[
|
|
1081
|
+
storeKey(sessionId),
|
|
1082
|
+
countKey(sessionId),
|
|
1083
|
+
idsKey,
|
|
1084
|
+
sessionsKey,
|
|
1085
|
+
metaKey(sessionId),
|
|
1086
|
+
pendingKey(sessionId),
|
|
1087
|
+
readyKey(sessionId),
|
|
1088
|
+
watchedKey(sessionId),
|
|
1089
|
+
],
|
|
1090
|
+
[
|
|
1091
|
+
sessionId,
|
|
1092
|
+
now,
|
|
1093
|
+
index,
|
|
1094
|
+
attempt,
|
|
1095
|
+
events.length,
|
|
1096
|
+
...events.flatMap((event) => [
|
|
1097
|
+
event.id,
|
|
1098
|
+
event.type,
|
|
1099
|
+
json(event.payload),
|
|
1100
|
+
event.lane ?? '',
|
|
1101
|
+
event.settled === true ? '1' : '0',
|
|
1102
|
+
]),
|
|
1103
|
+
],
|
|
1104
|
+
)) as string[]
|
|
1105
|
+
const outcome = reply[0]
|
|
1106
|
+
if (outcome === 'missing') {
|
|
1107
|
+
throw new TypeError(
|
|
1108
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
1109
|
+
)
|
|
1110
|
+
}
|
|
1111
|
+
if (outcome === 'partial') {
|
|
1112
|
+
throw new A2Error(
|
|
1113
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
1114
|
+
'returned event ids do not match the completed attempt',
|
|
1115
|
+
)
|
|
1116
|
+
}
|
|
1117
|
+
if (outcome === 'superseded') return { outcome }
|
|
1118
|
+
|
|
1119
|
+
const cause = { index, attempt }
|
|
1120
|
+
if (outcome === 'ok') {
|
|
1121
|
+
const base = Number(reply[1]) - 1
|
|
1122
|
+
if (events.length > 0 && reply[2] === '1') {
|
|
1123
|
+
notify?.(sessionId, base + events.length)
|
|
1124
|
+
}
|
|
1125
|
+
return {
|
|
1126
|
+
outcome: 'completed',
|
|
1127
|
+
events: events.map((event, i) => ({
|
|
1128
|
+
id: event.id,
|
|
1129
|
+
type: event.type,
|
|
1130
|
+
payload: structuredClone(event.payload),
|
|
1131
|
+
index: base + 1 + i,
|
|
1132
|
+
sessionId,
|
|
1133
|
+
createdAt: new Date(now),
|
|
1134
|
+
cause,
|
|
1135
|
+
lane: event.lane ?? null,
|
|
1136
|
+
processedAt: event.settled === true ? new Date(now) : null,
|
|
1137
|
+
processedByAttempt: null,
|
|
1138
|
+
returnedEventIds: null,
|
|
1139
|
+
firstClaimedAt: null,
|
|
1140
|
+
lastClaimedAt: null,
|
|
1141
|
+
attemptCount: 0,
|
|
1142
|
+
claimHolder: null,
|
|
1143
|
+
claimExpiresAt: null,
|
|
1144
|
+
failureCount: 0,
|
|
1145
|
+
lastFailedAt: null,
|
|
1146
|
+
lastFailedAttempt: null,
|
|
1147
|
+
lastError: null,
|
|
1148
|
+
failedAt: null,
|
|
1149
|
+
})),
|
|
1150
|
+
} satisfies CompleteAttemptResult
|
|
1151
|
+
}
|
|
1152
|
+
if (outcome === 'duplicate') {
|
|
1153
|
+
if (events.length === 0) {
|
|
1154
|
+
return { outcome: 'completed', events: [] }
|
|
1155
|
+
}
|
|
1156
|
+
const wanted = new Set(reply.slice(1).map(Number))
|
|
1157
|
+
const min = Math.min(...wanted)
|
|
1158
|
+
const rows = await readRange(sessionId, min - 1)
|
|
1159
|
+
const byIndex = new Map(rows.map((row) => [row.index, row]))
|
|
1160
|
+
const existing = reply
|
|
1161
|
+
.slice(1)
|
|
1162
|
+
.map((value) => byIndex.get(Number(value))!)
|
|
1163
|
+
return {
|
|
1164
|
+
outcome: 'completed',
|
|
1165
|
+
events: idempotentReplay(events, existing),
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
throw new TypeError(`unexpected completion outcome '${outcome}'`)
|
|
1169
|
+
})
|
|
1170
|
+
},
|
|
1171
|
+
|
|
1172
|
+
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
1173
|
+
return wrap(async () => {
|
|
1174
|
+
const reply = (await evalScript(
|
|
1175
|
+
FAIL_ATTEMPT_LUA,
|
|
1176
|
+
[
|
|
1177
|
+
countKey(sessionId),
|
|
1178
|
+
metaKey(sessionId),
|
|
1179
|
+
pendingKey(sessionId),
|
|
1180
|
+
readyKey(sessionId),
|
|
1181
|
+
],
|
|
1182
|
+
[index, attempt, error, maxFailures, clock.now().getTime()],
|
|
1183
|
+
)) as string[]
|
|
1184
|
+
const outcome = reply[0]
|
|
1185
|
+
if (outcome === 'missing') {
|
|
1186
|
+
throw new TypeError(
|
|
1187
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
1188
|
+
)
|
|
1189
|
+
}
|
|
1190
|
+
return {
|
|
1191
|
+
outcome: outcome as 'failed' | 'dead_lettered' | 'superseded',
|
|
1192
|
+
failureCount: Number(reply[1]),
|
|
1193
|
+
}
|
|
1194
|
+
})
|
|
1195
|
+
},
|
|
1196
|
+
|
|
1197
|
+
async readState(sessionId, reducerName) {
|
|
1198
|
+
return wrap(async () => {
|
|
1199
|
+
const reply = (await evalScript(
|
|
1200
|
+
READ_STATE_LUA,
|
|
1201
|
+
[snapKey(sessionId, reducerName), countKey(sessionId)],
|
|
1202
|
+
[],
|
|
1203
|
+
)) as [string, string]
|
|
1204
|
+
const raw = reply[0]
|
|
1205
|
+
const parsed =
|
|
1206
|
+
raw === ''
|
|
1207
|
+
? null
|
|
1208
|
+
: (JSON.parse(raw) as { index: number; state: unknown })
|
|
1209
|
+
const frontier = Number(reply[1])
|
|
1210
|
+
if (
|
|
1211
|
+
!Number.isSafeInteger(frontier) ||
|
|
1212
|
+
frontier < 0 ||
|
|
1213
|
+
(parsed !== null &&
|
|
1214
|
+
(!Number.isSafeInteger(parsed.index) || parsed.index < 0))
|
|
1215
|
+
) {
|
|
1216
|
+
throw new A2Error(
|
|
1217
|
+
'STORE_UNAVAILABLE',
|
|
1218
|
+
`redis state frontier for session '${sessionId}' is invalid`,
|
|
1219
|
+
)
|
|
1220
|
+
}
|
|
1221
|
+
const events = await readEventRange(
|
|
1222
|
+
sessionId,
|
|
1223
|
+
parsed?.index ?? 0,
|
|
1224
|
+
frontier,
|
|
1225
|
+
)
|
|
1226
|
+
return {
|
|
1227
|
+
snapshot: parsed
|
|
1228
|
+
? { index: parsed.index, state: parsed.state }
|
|
1229
|
+
: null,
|
|
1230
|
+
events,
|
|
1231
|
+
}
|
|
1232
|
+
})
|
|
1233
|
+
},
|
|
1234
|
+
|
|
1235
|
+
async putSnapshot(sessionId, reducerName, index, state) {
|
|
1236
|
+
await wrap(async () => {
|
|
1237
|
+
await evalScript(
|
|
1238
|
+
PUT_SNAPSHOT_LUA,
|
|
1239
|
+
[snapKey(sessionId, reducerName)],
|
|
1240
|
+
[index, json({ index, state, updatedAt: clock.now().getTime() })],
|
|
1241
|
+
)
|
|
1242
|
+
})
|
|
1243
|
+
},
|
|
1244
|
+
|
|
1245
|
+
presence: {
|
|
1246
|
+
async set(ns, participant, values, meta) {
|
|
1247
|
+
return wrap(async () => {
|
|
1248
|
+
const entries = Object.entries(values)
|
|
1249
|
+
if (entries.length === 0) return null
|
|
1250
|
+
const atMs = meta.at.getTime()
|
|
1251
|
+
// Expiry anchors on the injected storage clock — the sender's
|
|
1252
|
+
// `a` stamp orders writes, the `e` stamp bounds their
|
|
1253
|
+
// lifetime, and the two clocks never mix.
|
|
1254
|
+
const expiresAtMs = clock.now().getTime() + meta.ttlMs
|
|
1255
|
+
// The hash field is JSON `[participant, field]`: both are
|
|
1256
|
+
// caller strings, so no separator character is
|
|
1257
|
+
// collision-safe — the JSON array is.
|
|
1258
|
+
const applied = (await evalScript(
|
|
1259
|
+
PRESENCE_SET_LUA,
|
|
1260
|
+
[presenceKey(ns)],
|
|
1261
|
+
[
|
|
1262
|
+
atMs,
|
|
1263
|
+
meta.ttlMs,
|
|
1264
|
+
entries.length,
|
|
1265
|
+
...entries.flatMap(([field, value]) => [
|
|
1266
|
+
JSON.stringify([participant, field]),
|
|
1267
|
+
value === null
|
|
1268
|
+
? ''
|
|
1269
|
+
: json({
|
|
1270
|
+
v: value,
|
|
1271
|
+
s: meta.seen,
|
|
1272
|
+
a: atMs,
|
|
1273
|
+
e: expiresAtMs,
|
|
1274
|
+
}),
|
|
1275
|
+
]),
|
|
1276
|
+
],
|
|
1277
|
+
)) as string[]
|
|
1278
|
+
if (applied.length === 0) return null
|
|
1279
|
+
// Field-keyed and caller-named — null-prototype, like every
|
|
1280
|
+
// presence map (see `nullProtoRecord`).
|
|
1281
|
+
const appliedValues: Record<string, unknown> = nullProtoRecord()
|
|
1282
|
+
for (const position of applied) {
|
|
1283
|
+
const [field, value] = entries[Number(position) - 1]!
|
|
1284
|
+
appliedValues[field] = structuredClone(value)
|
|
1285
|
+
}
|
|
1286
|
+
return {
|
|
1287
|
+
participant,
|
|
1288
|
+
values: appliedValues,
|
|
1289
|
+
seen: meta.seen,
|
|
1290
|
+
at: new Date(atMs),
|
|
1291
|
+
}
|
|
1292
|
+
})
|
|
1293
|
+
},
|
|
1294
|
+
|
|
1295
|
+
async read(ns) {
|
|
1296
|
+
return wrap(async () => {
|
|
1297
|
+
const reply = (await evalScript(
|
|
1298
|
+
PRESENCE_READ_LUA,
|
|
1299
|
+
[presenceKey(ns)],
|
|
1300
|
+
[clock.now().getTime()],
|
|
1301
|
+
)) as string[]
|
|
1302
|
+
const rows: PresenceRow[] = []
|
|
1303
|
+
for (let i = 0; i + 1 < reply.length; i += 2) {
|
|
1304
|
+
const [participant, field] = JSON.parse(reply[i]!) as [
|
|
1305
|
+
string,
|
|
1306
|
+
string,
|
|
1307
|
+
]
|
|
1308
|
+
const entry = JSON.parse(reply[i + 1]!) as {
|
|
1309
|
+
v: unknown
|
|
1310
|
+
s: number
|
|
1311
|
+
a: number
|
|
1312
|
+
e: number
|
|
1313
|
+
}
|
|
1314
|
+
rows.push({
|
|
1315
|
+
participant,
|
|
1316
|
+
field,
|
|
1317
|
+
value: entry.v,
|
|
1318
|
+
seen: entry.s,
|
|
1319
|
+
at: new Date(entry.a),
|
|
1320
|
+
expiresAt: new Date(entry.e),
|
|
1321
|
+
})
|
|
1322
|
+
}
|
|
1323
|
+
return rows
|
|
1324
|
+
})
|
|
1325
|
+
},
|
|
1326
|
+
},
|
|
1327
|
+
|
|
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
|
+
async readRange(sessionId, afterIndex, throughIndex) {
|
|
1475
|
+
return wrap(() => readRange(sessionId, afterIndex, throughIndex))
|
|
1476
|
+
},
|
|
1477
|
+
|
|
1478
|
+
async readEvents(sessionId, afterIndex) {
|
|
1479
|
+
return wrap(async () => {
|
|
1480
|
+
const entries = (await call(
|
|
1481
|
+
'XRANGE',
|
|
1482
|
+
storeKey(sessionId),
|
|
1483
|
+
`${afterIndex + 1}-0`,
|
|
1484
|
+
'+',
|
|
1485
|
+
)) as StreamEntry[]
|
|
1486
|
+
return entries.map((entry) => toEvent(sessionId, entry))
|
|
1487
|
+
})
|
|
1488
|
+
},
|
|
1489
|
+
|
|
1490
|
+
async markWatched(sessionId, ttlMs) {
|
|
1491
|
+
await wrap(() => call('SET', watchedKey(sessionId), '1', 'PX', ttlMs))
|
|
1492
|
+
},
|
|
1493
|
+
}
|
|
1494
|
+
}
|