experimental-a2 0.0.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 +128 -0
- package/dist/ai-server.browser.d.ts +1 -0
- package/dist/ai-server.browser.js +4 -0
- package/dist/ai-server.d.ts +65 -0
- package/dist/ai-server.js +494 -0
- package/dist/ai.d.ts +282 -0
- package/dist/ai.js +922 -0
- package/dist/cache-indexeddb.d.ts +1 -0
- package/dist/cache-indexeddb.js +0 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.js +410 -0
- package/dist/contract-B0kAXoaL.js +60 -0
- package/dist/contract-DL8btVd9.d.ts +161 -0
- package/dist/devtools-server.browser.d.ts +1 -0
- package/dist/devtools-server.browser.js +4 -0
- package/dist/devtools-server.d.ts +22 -0
- package/dist/devtools-server.js +1087 -0
- package/dist/errors-BJRMd-h6.js +23 -0
- package/dist/errors-xL_JTXsY.d.ts +20 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +119 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/inspection-E7qbD0Xj.js +10 -0
- package/dist/internal-Dm8Ejnud.js +36 -0
- package/dist/log-Dg1I8NRr.d.ts +245 -0
- package/dist/log-memory.d.ts +11 -0
- package/dist/log-memory.js +345 -0
- package/dist/log-polling-RO7kclzR.js +83 -0
- package/dist/log-postgres.d.ts +40 -0
- package/dist/log-postgres.js +628 -0
- package/dist/log-redis.d.ts +31 -0
- package/dist/log-redis.js +711 -0
- package/dist/log-sqlite.d.ts +17 -0
- package/dist/log-sqlite.js +450 -0
- package/dist/log-yJbXUf72.js +5 -0
- package/dist/otel.d.ts +12 -0
- package/dist/otel.js +41 -0
- package/dist/react.d.ts +54 -0
- package/dist/react.js +85 -0
- package/dist/recovery-vercel.d.ts +60 -0
- package/dist/recovery-vercel.js +120 -0
- package/dist/retryable-lazy-DZWmHpii.js +19 -0
- package/dist/server-DYsnKTTy.js +780 -0
- package/dist/server.browser.d.ts +1 -0
- package/dist/server.browser.js +11 -0
- package/dist/server.d.ts +136 -0
- package/dist/server.js +2 -0
- package/dist/telemetry-C78al20p.d.ts +32 -0
- package/dist/validate-XKT4FSNn.js +28 -0
- package/dist/wire-2QpU1EtJ.js +62 -0
- package/docs/01-quickstart.mdx +214 -0
- package/docs/concepts/01-contracts.mdx +138 -0
- package/docs/concepts/02-handlers.mdx +146 -0
- package/docs/concepts/03-durability.mdx +230 -0
- package/docs/concepts/04-state.mdx +133 -0
- package/docs/guides/01-timers.mdx +85 -0
- package/docs/guides/02-cancellation.mdx +107 -0
- package/docs/guides/03-react.mdx +234 -0
- package/docs/guides/04-local-first.mdx +88 -0
- package/docs/guides/05-production.mdx +179 -0
- package/docs/guides/06-ai-agents.mdx +659 -0
- package/docs/guides/07-devtools.mdx +101 -0
- package/docs/guides/08-application-data.mdx +114 -0
- package/docs/index.mdx +282 -0
- package/docs/reference/01-api.mdx +637 -0
- package/docs/reference/02-errors.mdx +77 -0
- package/package.json +111 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import { t as A2Error } from "./errors-BJRMd-h6.js";
|
|
2
|
+
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
3
|
+
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
|
|
4
|
+
//#region src/log-redis.ts
|
|
5
|
+
/**
|
|
6
|
+
* a2/log-redis — the push-native log backend, on Redis Streams.
|
|
7
|
+
*
|
|
8
|
+
* The stream IS the log: each event is a stream entry whose ID is its
|
|
9
|
+
* dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read and
|
|
10
|
+
* `XREAD BLOCK` is a true blocking live feed — no polling anywhere.
|
|
11
|
+
* Mutable drain/failure bookkeeping lives in a side hash (stream
|
|
12
|
+
* entries are immutable); every write path is one atomic Lua script.
|
|
13
|
+
* The conformance suite in test/conformance is the executable
|
|
14
|
+
* contract, run against a real spawned `redis-server`.
|
|
15
|
+
*
|
|
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.
|
|
20
|
+
*
|
|
21
|
+
* Works with any Redis-protocol server on a single instance or a
|
|
22
|
+
* non-cluster provider (Upstash — durable by default — Redis, Valkey).
|
|
23
|
+
* Cluster mode is out: the atomic scripts span keys. `ioredis` is an
|
|
24
|
+
* optional peer dependency; pass `url`, or inject any client exposing
|
|
25
|
+
* `call`/`duplicate`/`disconnect`.
|
|
26
|
+
*/
|
|
27
|
+
/** How long each blocking read waits before re-checking for close(). */
|
|
28
|
+
const XREAD_BLOCK_MS = 1e4;
|
|
29
|
+
/**
|
|
30
|
+
* KEYS: log stream, counter, global id hash, session index.
|
|
31
|
+
* 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].
|
|
35
|
+
*/
|
|
36
|
+
const APPEND_LUA = `
|
|
37
|
+
local n = tonumber(ARGV[3])
|
|
38
|
+
local dups = {}
|
|
39
|
+
for i = 1, n do
|
|
40
|
+
local id = ARGV[3 + (i - 1) * 4 + 1]
|
|
41
|
+
local existing = redis.call('HGET', KEYS[3], id)
|
|
42
|
+
if existing then
|
|
43
|
+
local rec = cjson.decode(existing)
|
|
44
|
+
if rec[1] ~= ARGV[1] then return { 'foreign', id } end
|
|
45
|
+
dups[#dups + 1] = tostring(rec[2])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
if #dups == n then
|
|
49
|
+
local res = { 'dup' }
|
|
50
|
+
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
51
|
+
return res
|
|
52
|
+
end
|
|
53
|
+
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
54
|
+
local base = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
55
|
+
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]
|
|
60
|
+
local idx = base + i
|
|
61
|
+
redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
|
|
62
|
+
'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
|
|
63
|
+
'cause', cause)
|
|
64
|
+
redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
|
|
65
|
+
end
|
|
66
|
+
redis.call('SET', KEYS[2], tostring(base + n))
|
|
67
|
+
redis.call('ZADD', KEYS[4], 0, ARGV[1])
|
|
68
|
+
return { 'ok', tostring(base + 1) }
|
|
69
|
+
`;
|
|
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])
|
|
80
|
+
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
|
|
85
|
+
end
|
|
86
|
+
redis.call('HSET', KEYS[2], '__head', tostring(head))
|
|
87
|
+
end
|
|
88
|
+
return 1
|
|
89
|
+
`;
|
|
90
|
+
/** KEYS: counter, meta. ARGV: idx, attempt, error, maxFailures, nowMs. */
|
|
91
|
+
const FAIL_ATTEMPT_LUA = `
|
|
92
|
+
local idx = tonumber(ARGV[1])
|
|
93
|
+
if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
|
|
94
|
+
return { 'missing', '0' }
|
|
95
|
+
end
|
|
96
|
+
local field = ARGV[1]
|
|
97
|
+
local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
|
|
98
|
+
local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
|
|
99
|
+
if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
|
|
100
|
+
return { 'superseded', tostring(failures) }
|
|
101
|
+
end
|
|
102
|
+
if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
|
|
103
|
+
return { 'dead_lettered', tostring(failures) }
|
|
104
|
+
end
|
|
105
|
+
failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
|
|
106
|
+
redis.call('HSET', KEYS[2],
|
|
107
|
+
field .. ':e', ARGV[3],
|
|
108
|
+
field .. ':lf', ARGV[5],
|
|
109
|
+
field .. ':lfa', ARGV[2])
|
|
110
|
+
if failures >= tonumber(ARGV[4]) then
|
|
111
|
+
redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
|
|
112
|
+
return { 'dead_lettered', tostring(failures) }
|
|
113
|
+
end
|
|
114
|
+
return { 'failed', tostring(failures) }
|
|
115
|
+
`;
|
|
116
|
+
/**
|
|
117
|
+
* KEYS: log, counter, meta, lease.
|
|
118
|
+
* ARGV: holder, nowMs, expiresAtMs, gcPxMs, maxIndex (-1 = unbounded).
|
|
119
|
+
*/
|
|
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))
|
|
129
|
+
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' }
|
|
136
|
+
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' }
|
|
143
|
+
end
|
|
144
|
+
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
|
+
}
|
|
164
|
+
`;
|
|
165
|
+
/**
|
|
166
|
+
* KEYS: log, counter, meta, lease.
|
|
167
|
+
* ARGV: holder, nowMs, completedIndex, attempt, maxIndex (-1 = unbounded).
|
|
168
|
+
*/
|
|
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' }
|
|
178
|
+
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
|
|
186
|
+
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' }
|
|
194
|
+
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' }
|
|
201
|
+
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
|
|
230
|
+
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])
|
|
240
|
+
end
|
|
241
|
+
return 1
|
|
242
|
+
`;
|
|
243
|
+
/** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
|
|
244
|
+
const PUT_SNAPSHOT_LUA = `
|
|
245
|
+
local cur = redis.call('GET', KEYS[1])
|
|
246
|
+
if cur and tonumber(cjson.decode(cur)['index']) >= tonumber(ARGV[1]) then
|
|
247
|
+
return 0
|
|
248
|
+
end
|
|
249
|
+
redis.call('SET', KEYS[1], ARGV[2])
|
|
250
|
+
return 1
|
|
251
|
+
`;
|
|
252
|
+
/** KEYS: snapshot, log stream. Returns one atomic cache-plus-tail read. */
|
|
253
|
+
const READ_STATE_LUA = `
|
|
254
|
+
local snapshot = redis.call('GET', KEYS[1])
|
|
255
|
+
local after = 0
|
|
256
|
+
if snapshot then
|
|
257
|
+
after = tonumber(cjson.decode(snapshot)['index'])
|
|
258
|
+
end
|
|
259
|
+
local events = redis.call('XRANGE', KEYS[2], tostring(after + 1) .. '-0', '+')
|
|
260
|
+
return { snapshot or '', events }
|
|
261
|
+
`;
|
|
262
|
+
const fieldMap = (fields) => {
|
|
263
|
+
const map = {};
|
|
264
|
+
for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
|
|
265
|
+
return map;
|
|
266
|
+
};
|
|
267
|
+
const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
|
|
268
|
+
const toEvent = (sessionId, entry) => {
|
|
269
|
+
const fields = fieldMap(entry[1]);
|
|
270
|
+
return {
|
|
271
|
+
id: fields["id"],
|
|
272
|
+
type: fields["type"],
|
|
273
|
+
payload: JSON.parse(fields["payload"]),
|
|
274
|
+
index: entryIndex(entry[0]),
|
|
275
|
+
sessionId,
|
|
276
|
+
createdAt: new Date(Number(fields["created_at"]))
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
const json = (value) => JSON.stringify(value) ?? "null";
|
|
280
|
+
const escapeGlob = (value) => value.replaceAll("\\", "\\\\").replaceAll("*", "\\*").replaceAll("?", "\\?").replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
281
|
+
const toCause = (raw) => {
|
|
282
|
+
if (raw === "" || raw === void 0) return null;
|
|
283
|
+
const value = JSON.parse(raw);
|
|
284
|
+
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");
|
|
285
|
+
return {
|
|
286
|
+
index: Number(value.index),
|
|
287
|
+
attempt: Number(value.attempt)
|
|
288
|
+
};
|
|
289
|
+
};
|
|
290
|
+
const toClaimResult = (sessionId, reply) => {
|
|
291
|
+
const outcome = String(reply[0]);
|
|
292
|
+
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, {
|
|
299
|
+
cause: toCause(fields["cause"]),
|
|
300
|
+
processedAt: null,
|
|
301
|
+
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]),
|
|
309
|
+
failedAt: null
|
|
310
|
+
})
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (outcome === "busy" || outcome === "settled") return { outcome };
|
|
314
|
+
throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
|
|
315
|
+
};
|
|
316
|
+
const toHandoffResult = (sessionId, reply) => String(reply[0]) === "superseded" ? { outcome: "superseded" } : toClaimResult(sessionId, reply);
|
|
317
|
+
const wrap = async (fn) => {
|
|
318
|
+
try {
|
|
319
|
+
return await fn();
|
|
320
|
+
} catch (err) {
|
|
321
|
+
if (err instanceof A2Error || err instanceof TypeError) throw err;
|
|
322
|
+
throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
function redis(options = {}) {
|
|
326
|
+
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
327
|
+
const generateId = options.ids ?? RANDOM_IDS;
|
|
328
|
+
const prefix = options.keyPrefix ?? "a2";
|
|
329
|
+
if (!options.client && !options.url) throw new TypeError("redis() needs a url or an injected client");
|
|
330
|
+
const logKey = (s) => `${prefix}:${s}:log`;
|
|
331
|
+
const countKey = (s) => `${prefix}:${s}:count`;
|
|
332
|
+
const metaKey = (s) => `${prefix}:${s}:meta`;
|
|
333
|
+
const leaseKey = (s) => `${prefix}:${s}:lease`;
|
|
334
|
+
const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
|
|
335
|
+
const idsKey = `${prefix}:ids`;
|
|
336
|
+
const sessionsKey = `${prefix}:sessions`;
|
|
337
|
+
const connection = retryableLazy(async () => {
|
|
338
|
+
if (options.client) return options.client;
|
|
339
|
+
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");
|
|
341
|
+
}))).default(options.url);
|
|
342
|
+
});
|
|
343
|
+
const client = connection.get;
|
|
344
|
+
const evalScript = async (script, keys, args) => {
|
|
345
|
+
return (await client()).call("EVAL", script, keys.length, ...keys, ...args);
|
|
346
|
+
};
|
|
347
|
+
/** Every live-feed connection, so close() can sever them. */
|
|
348
|
+
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
|
+
/** XRANGE + meta merge — the raw read both `read` and dup-returns use. */
|
|
362
|
+
const readRange = async (sessionId, afterIndex) => {
|
|
363
|
+
const c = await client();
|
|
364
|
+
const entries = await c.call("XRANGE", logKey(sessionId), `${afterIndex + 1}-0`, "+");
|
|
365
|
+
if (entries.length === 0) return [];
|
|
366
|
+
const meta = fieldMap(await c.call("HGETALL", metaKey(sessionId)) ?? []);
|
|
367
|
+
return entries.map((entry) => {
|
|
368
|
+
const event = toEvent(sessionId, entry);
|
|
369
|
+
const idx = event.index;
|
|
370
|
+
const fields = fieldMap(entry[1]);
|
|
371
|
+
const processedAt = meta[`${idx}:p`];
|
|
372
|
+
const processedByAttempt = meta[`${idx}:pa`];
|
|
373
|
+
const failedAt = meta[`${idx}:f`];
|
|
374
|
+
const firstClaimedAt = meta[`${idx}:fc`];
|
|
375
|
+
const lastClaimedAt = meta[`${idx}:lc`];
|
|
376
|
+
const lastFailedAt = meta[`${idx}:lf`];
|
|
377
|
+
const lastFailedAttempt = meta[`${idx}:lfa`];
|
|
378
|
+
return Object.assign(event, {
|
|
379
|
+
cause: toCause(fields["cause"]),
|
|
380
|
+
processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
|
|
381
|
+
processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
|
|
382
|
+
firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
|
|
383
|
+
lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
|
|
384
|
+
attemptCount: Number(meta[`${idx}:d`] ?? 0),
|
|
385
|
+
failureCount: Number(meta[`${idx}:a`] ?? 0),
|
|
386
|
+
lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
|
|
387
|
+
lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
|
|
388
|
+
lastError: meta[`${idx}:e`] ?? null,
|
|
389
|
+
failedAt: failedAt === void 0 ? null : new Date(Number(failedAt))
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
};
|
|
393
|
+
return {
|
|
394
|
+
async append(sessionId, events) {
|
|
395
|
+
if (events.length === 0) return [];
|
|
396
|
+
return wrap(async () => {
|
|
397
|
+
const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
|
|
398
|
+
if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
399
|
+
const now = clock.now().getTime();
|
|
400
|
+
const withIds = events.map((e) => ({
|
|
401
|
+
id: e.id ?? generateId(),
|
|
402
|
+
type: e.type,
|
|
403
|
+
payload: e.payload,
|
|
404
|
+
cause: e.cause ? { ...e.cause } : null
|
|
405
|
+
}));
|
|
406
|
+
const args = [
|
|
407
|
+
sessionId,
|
|
408
|
+
now,
|
|
409
|
+
withIds.length,
|
|
410
|
+
...withIds.flatMap((e) => [
|
|
411
|
+
e.id,
|
|
412
|
+
e.type,
|
|
413
|
+
json(e.payload),
|
|
414
|
+
e.cause ? json(e.cause) : ""
|
|
415
|
+
])
|
|
416
|
+
];
|
|
417
|
+
const reply = await evalScript(APPEND_LUA, [
|
|
418
|
+
logKey(sessionId),
|
|
419
|
+
countKey(sessionId),
|
|
420
|
+
idsKey,
|
|
421
|
+
sessionsKey
|
|
422
|
+
], args);
|
|
423
|
+
const [verdict] = reply;
|
|
424
|
+
if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
|
|
425
|
+
if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
|
|
426
|
+
if (verdict === "dup") {
|
|
427
|
+
const wanted = new Set(reply.slice(1).map(Number));
|
|
428
|
+
const min = Math.min(...wanted);
|
|
429
|
+
return (await readRange(sessionId, min - 1)).filter((r) => wanted.has(r.index));
|
|
430
|
+
}
|
|
431
|
+
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
|
+
}));
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
async read(sessionId, opts) {
|
|
454
|
+
return wrap(async () => {
|
|
455
|
+
const rows = await readRange(sessionId, opts?.afterIndex ?? 0);
|
|
456
|
+
return opts?.unprocessedOnly ? rows.filter((r) => r.processedAt === null) : rows;
|
|
457
|
+
});
|
|
458
|
+
},
|
|
459
|
+
async claimNext({ sessionId, holder, ttlMs, expiresAtMs, maxIndex }) {
|
|
460
|
+
return wrap(async () => {
|
|
461
|
+
const now = clock.now().getTime();
|
|
462
|
+
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, [
|
|
466
|
+
logKey(sessionId),
|
|
467
|
+
countKey(sessionId),
|
|
468
|
+
metaKey(sessionId),
|
|
469
|
+
leaseKey(sessionId)
|
|
470
|
+
], [
|
|
471
|
+
holder,
|
|
472
|
+
now,
|
|
473
|
+
expiresAt,
|
|
474
|
+
gcPx,
|
|
475
|
+
maxIndex ?? -1
|
|
476
|
+
]);
|
|
477
|
+
return toClaimResult(sessionId, reply);
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
async completeAndClaimNext({ sessionId, holder, completedIndex, attempt, maxIndex }) {
|
|
481
|
+
return wrap(async () => {
|
|
482
|
+
const reply = await evalScript(COMPLETE_AND_CLAIM_NEXT_LUA, [
|
|
483
|
+
logKey(sessionId),
|
|
484
|
+
countKey(sessionId),
|
|
485
|
+
metaKey(sessionId),
|
|
486
|
+
leaseKey(sessionId)
|
|
487
|
+
], [
|
|
488
|
+
holder,
|
|
489
|
+
clock.now().getTime(),
|
|
490
|
+
completedIndex,
|
|
491
|
+
attempt,
|
|
492
|
+
maxIndex ?? -1
|
|
493
|
+
]);
|
|
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)], [
|
|
500
|
+
index,
|
|
501
|
+
clock.now().getTime(),
|
|
502
|
+
"p"
|
|
503
|
+
]) === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
504
|
+
});
|
|
505
|
+
},
|
|
506
|
+
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
507
|
+
return wrap(async () => {
|
|
508
|
+
const reply = await evalScript(FAIL_ATTEMPT_LUA, [countKey(sessionId), metaKey(sessionId)], [
|
|
509
|
+
index,
|
|
510
|
+
attempt,
|
|
511
|
+
error,
|
|
512
|
+
maxFailures,
|
|
513
|
+
clock.now().getTime()
|
|
514
|
+
]);
|
|
515
|
+
const outcome = reply[0];
|
|
516
|
+
if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
517
|
+
return {
|
|
518
|
+
outcome,
|
|
519
|
+
failureCount: Number(reply[1])
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
},
|
|
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
|
+
async readState(sessionId, reducerName) {
|
|
533
|
+
return wrap(async () => {
|
|
534
|
+
const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), logKey(sessionId)], []);
|
|
535
|
+
const raw = reply[0];
|
|
536
|
+
const parsed = raw === "" ? null : JSON.parse(raw);
|
|
537
|
+
return {
|
|
538
|
+
snapshot: parsed ? {
|
|
539
|
+
index: parsed.index,
|
|
540
|
+
state: parsed.state
|
|
541
|
+
} : null,
|
|
542
|
+
events: reply[1].map((entry) => toEvent(sessionId, entry))
|
|
543
|
+
};
|
|
544
|
+
});
|
|
545
|
+
},
|
|
546
|
+
async putSnapshot(sessionId, reducerName, index, state) {
|
|
547
|
+
await wrap(async () => {
|
|
548
|
+
await evalScript(PUT_SNAPSHOT_LUA, [snapKey(sessionId, reducerName)], [index, json({
|
|
549
|
+
index,
|
|
550
|
+
state,
|
|
551
|
+
updatedAt: clock.now().getTime()
|
|
552
|
+
})]);
|
|
553
|
+
});
|
|
554
|
+
},
|
|
555
|
+
inspect: {
|
|
556
|
+
async listSessions(inspectionOptions) {
|
|
557
|
+
return wrap(async () => {
|
|
558
|
+
await ensureSessionIndex();
|
|
559
|
+
const c = await client();
|
|
560
|
+
const minimum = inspectionOptions.cursor === void 0 ? `[${inspectionOptions.prefix}` : `(${inspectionOptions.cursor}`;
|
|
561
|
+
const candidates = await c.call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
|
|
562
|
+
const sessionIds = candidates.filter((id) => id.startsWith(inspectionOptions.prefix)).slice(0, inspectionOptions.limit);
|
|
563
|
+
return {
|
|
564
|
+
sessions: (await Promise.all(sessionIds.map(async (sessionId) => {
|
|
565
|
+
const rows = await readRange(sessionId, 0);
|
|
566
|
+
const first = rows[0];
|
|
567
|
+
let updatedAt = first.createdAt;
|
|
568
|
+
let pendingCount = 0;
|
|
569
|
+
let failedCount = 0;
|
|
570
|
+
let attemptCount = 0;
|
|
571
|
+
let failureCount = 0;
|
|
572
|
+
for (const row of rows) {
|
|
573
|
+
attemptCount += row.attemptCount;
|
|
574
|
+
failureCount += row.failureCount;
|
|
575
|
+
if (row.processedAt === null && row.failedAt === null) pendingCount += 1;
|
|
576
|
+
if (row.failedAt !== null) failedCount += 1;
|
|
577
|
+
for (const timestamp of [
|
|
578
|
+
row.createdAt,
|
|
579
|
+
row.firstClaimedAt,
|
|
580
|
+
row.lastClaimedAt,
|
|
581
|
+
row.lastFailedAt,
|
|
582
|
+
row.processedAt,
|
|
583
|
+
row.failedAt
|
|
584
|
+
]) if (timestamp && timestamp > updatedAt) updatedAt = timestamp;
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
sessionId,
|
|
588
|
+
eventCount: rows.length,
|
|
589
|
+
pendingCount,
|
|
590
|
+
failedCount,
|
|
591
|
+
attemptCount,
|
|
592
|
+
failureCount,
|
|
593
|
+
firstEventAt: first.createdAt,
|
|
594
|
+
updatedAt
|
|
595
|
+
};
|
|
596
|
+
}))).toSorted((a, b) => a.sessionId.localeCompare(b.sessionId)),
|
|
597
|
+
cursor: candidates.length > inspectionOptions.limit && candidates[inspectionOptions.limit]?.startsWith(inspectionOptions.prefix) ? sessionIds.at(-1) ?? null : null
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
async listSnapshots(sessionId) {
|
|
602
|
+
return wrap(async () => {
|
|
603
|
+
const c = await client();
|
|
604
|
+
const start = `${prefix}:${sessionId}:snap:`;
|
|
605
|
+
let cursor = "0";
|
|
606
|
+
const keys = [];
|
|
607
|
+
do {
|
|
608
|
+
const reply = await c.call("SCAN", cursor, "MATCH", `${escapeGlob(start)}*`, "COUNT", 100);
|
|
609
|
+
cursor = reply[0];
|
|
610
|
+
keys.push(...reply[1].filter((key) => key.startsWith(start)));
|
|
611
|
+
} while (cursor !== "0");
|
|
612
|
+
return (await Promise.all(keys.map(async (key) => {
|
|
613
|
+
const raw = await c.call("GET", key);
|
|
614
|
+
if (raw === null) return null;
|
|
615
|
+
const parsed = JSON.parse(raw);
|
|
616
|
+
return {
|
|
617
|
+
reducerName: key.slice(start.length),
|
|
618
|
+
index: parsed.index,
|
|
619
|
+
updatedAt: new Date(parsed.updatedAt ?? 0)
|
|
620
|
+
};
|
|
621
|
+
}))).filter((snapshot) => snapshot !== null).toSorted((a, b) => a.reducerName.localeCompare(b.reducerName));
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
stream(sessionId, opts) {
|
|
626
|
+
const startAt = opts?.startAt ?? 0;
|
|
627
|
+
return { [Symbol.asyncIterator]() {
|
|
628
|
+
let last = startAt;
|
|
629
|
+
let buffer = [];
|
|
630
|
+
let closed = false;
|
|
631
|
+
let conn = null;
|
|
632
|
+
return {
|
|
633
|
+
async next() {
|
|
634
|
+
for (;;) {
|
|
635
|
+
if (closed) return {
|
|
636
|
+
value: void 0,
|
|
637
|
+
done: true
|
|
638
|
+
};
|
|
639
|
+
const row = buffer.shift();
|
|
640
|
+
if (row) {
|
|
641
|
+
last = row.index;
|
|
642
|
+
return {
|
|
643
|
+
value: row,
|
|
644
|
+
done: false
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
if (!conn) {
|
|
648
|
+
conn = (await client()).duplicate();
|
|
649
|
+
streamConnections.add(conn);
|
|
650
|
+
}
|
|
651
|
+
let reply;
|
|
652
|
+
try {
|
|
653
|
+
reply = await conn.call("XREAD", "BLOCK", XREAD_BLOCK_MS, "STREAMS", logKey(sessionId), `${last}-0`);
|
|
654
|
+
} catch (err) {
|
|
655
|
+
if (closed) return {
|
|
656
|
+
value: void 0,
|
|
657
|
+
done: true
|
|
658
|
+
};
|
|
659
|
+
throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
|
|
660
|
+
}
|
|
661
|
+
if (reply === null) continue;
|
|
662
|
+
const [[, entries]] = reply;
|
|
663
|
+
buffer = entries.map((entry) => toEvent(sessionId, entry));
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
async return() {
|
|
667
|
+
closed = true;
|
|
668
|
+
if (conn) {
|
|
669
|
+
streamConnections.delete(conn);
|
|
670
|
+
conn.disconnect();
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
value: void 0,
|
|
674
|
+
done: true
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
} };
|
|
679
|
+
},
|
|
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
|
+
async close() {
|
|
702
|
+
for (const conn of streamConnections) conn.disconnect();
|
|
703
|
+
streamConnections.clear();
|
|
704
|
+
const current = connection.peek();
|
|
705
|
+
if (!current) return;
|
|
706
|
+
(await current).disconnect();
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
//#endregion
|
|
711
|
+
export { redis };
|