experimental-a2 0.2.0 → 0.4.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 +169 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +321 -60
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -1,37 +1,17 @@
|
|
|
1
|
-
import { t as A2Error } from "./errors-
|
|
2
|
-
import {
|
|
3
|
-
import { t as idempotentReplay } from "./idempotent-replay-
|
|
4
|
-
import {
|
|
5
|
-
//#region src/
|
|
1
|
+
import { t as A2Error } from "./errors-BQuJpe82.js";
|
|
2
|
+
import { g as nullProtoRecord } from "./internal-aEotMzu_.js";
|
|
3
|
+
import { t as idempotentReplay } from "./idempotent-replay-DuqEkYA7.js";
|
|
4
|
+
import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
|
|
5
|
+
//#region src/store-redis-core.ts
|
|
6
|
+
const READ_PAGE_SIZE = 256;
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* The stream IS the log: each event is a stream entry whose ID is its
|
|
10
|
-
* dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read and
|
|
11
|
-
* `XREAD BLOCK` is a true blocking live feed — no polling anywhere.
|
|
12
|
-
* Mutable drain/failure bookkeeping lives in a side hash (stream
|
|
13
|
-
* entries are immutable); every write path is one atomic Lua script.
|
|
14
|
-
* The conformance suite in test/conformance is the executable
|
|
15
|
-
* contract, run against a real spawned `redis-server`.
|
|
16
|
-
*
|
|
17
|
-
* Timestamps and claim expiry come from the injected clock so tests can
|
|
18
|
-
* time-travel against a real server.
|
|
19
|
-
*
|
|
20
|
-
* Works with any Redis-protocol server on a single instance or a
|
|
21
|
-
* non-cluster provider (Upstash — durable by default — Redis, Valkey).
|
|
22
|
-
* Cluster mode is out: the atomic scripts span keys. `ioredis` is an
|
|
23
|
-
* optional peer dependency; pass `url`, or inject any client exposing
|
|
24
|
-
* `call`/`duplicate`/`disconnect`.
|
|
25
|
-
*/
|
|
26
|
-
/** How long each blocking read waits before re-checking for close(). */
|
|
27
|
-
const XREAD_BLOCK_MS = 1e4;
|
|
28
|
-
/**
|
|
29
|
-
* KEYS: log stream, counter, global id hash, session index, metadata, pending, ready.
|
|
8
|
+
* KEYS: store stream, counter, global id hash, session index, metadata,
|
|
9
|
+
* pending, ready, watched.
|
|
30
10
|
* ARGV: sessionId, nowMs, n, then per event:
|
|
31
11
|
* id, type, payloadJson, causeJson, lane, settled.
|
|
32
|
-
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
12
|
+
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |
|
|
33
13
|
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
34
|
-
* ['foreign', eventId].
|
|
14
|
+
* ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
|
|
35
15
|
*/
|
|
36
16
|
const APPEND_LUA = `
|
|
37
17
|
local function lane_field(lane, suffix)
|
|
@@ -73,6 +53,22 @@ if #dups == n then
|
|
|
73
53
|
return res
|
|
74
54
|
end
|
|
75
55
|
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
56
|
+
for i = 1, n do
|
|
57
|
+
local cause = ARGV[3 + (i - 1) * 6 + 4]
|
|
58
|
+
if cause ~= '' then
|
|
59
|
+
local parsed = cjson.decode(cause)
|
|
60
|
+
local pidx = tonumber(parsed.index)
|
|
61
|
+
if pidx < 1 or pidx > base then
|
|
62
|
+
return { 'no_parent', tostring(parsed.index) }
|
|
63
|
+
end
|
|
64
|
+
local field = tostring(pidx)
|
|
65
|
+
local dispatches = tonumber(redis.call('HGET', KEYS[5], field .. ':d') or '0')
|
|
66
|
+
if dispatches ~= tonumber(parsed.attempt) or
|
|
67
|
+
redis.call('HEXISTS', KEYS[5], field .. ':f') == 1 then
|
|
68
|
+
return { 'superseded', field }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
76
72
|
for i = 1, n do
|
|
77
73
|
local id = ARGV[3 + (i - 1) * 6 + 1]
|
|
78
74
|
local typ = ARGV[3 + (i - 1) * 6 + 2]
|
|
@@ -95,7 +91,8 @@ end
|
|
|
95
91
|
redis.call('SET', KEYS[2], tostring(base + n))
|
|
96
92
|
redis.call('ZADD', KEYS[4], 0, ARGV[1])
|
|
97
93
|
has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
|
|
98
|
-
return { 'ok', tostring(base + 1), has_pending
|
|
94
|
+
return { 'ok', tostring(base + 1), has_pending,
|
|
95
|
+
tostring(redis.call('EXISTS', KEYS[8])) }
|
|
99
96
|
`;
|
|
100
97
|
/** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
|
|
101
98
|
const FAIL_ATTEMPT_LUA = `
|
|
@@ -157,7 +154,7 @@ end
|
|
|
157
154
|
return { 'failed', tostring(failures) }
|
|
158
155
|
`;
|
|
159
156
|
/**
|
|
160
|
-
* KEYS:
|
|
157
|
+
* KEYS: store, counter, meta, pending, ready.
|
|
161
158
|
* ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
|
|
162
159
|
*/
|
|
163
160
|
const CLAIM_AVAILABLE_LUA = `
|
|
@@ -207,29 +204,42 @@ if retry_at then return { 'busy', tostring(retry_at) } end
|
|
|
207
204
|
return { 'settled' }
|
|
208
205
|
`;
|
|
209
206
|
/**
|
|
210
|
-
* KEYS: counter, meta.
|
|
207
|
+
* KEYS: counter, meta.
|
|
208
|
+
* ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
|
|
209
|
+
* Returns [renewedCount, ...renewed, ...superseded].
|
|
211
210
|
*/
|
|
212
211
|
const RENEW_CLAIMS_LUA = `
|
|
213
212
|
local total = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
214
213
|
local renewed = {}
|
|
214
|
+
local superseded = {}
|
|
215
215
|
for i = 1, tonumber(ARGV[4]) do
|
|
216
|
-
local field = ARGV[4 + i]
|
|
216
|
+
local field = ARGV[4 + (i - 1) * 2 + 1]
|
|
217
|
+
local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
|
|
217
218
|
local idx = tonumber(field)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
219
|
+
if idx >= 1 and idx <= total then
|
|
220
|
+
local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
|
|
221
|
+
if dispatches > attempt then
|
|
222
|
+
superseded[#superseded + 1] = field
|
|
223
|
+
else
|
|
224
|
+
local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
|
|
225
|
+
if tonumber(ARGV[3]) > tonumber(ARGV[2]) and
|
|
226
|
+
redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
|
|
227
|
+
redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
|
|
228
|
+
redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
|
|
229
|
+
expires > tonumber(ARGV[2]) then
|
|
230
|
+
redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
|
|
231
|
+
renewed[#renewed + 1] = field
|
|
232
|
+
end
|
|
233
|
+
end
|
|
227
234
|
end
|
|
228
235
|
end
|
|
229
|
-
|
|
236
|
+
local result = { tostring(#renewed) }
|
|
237
|
+
for i = 1, #renewed do result[#result + 1] = renewed[i] end
|
|
238
|
+
for i = 1, #superseded do result[#result + 1] = superseded[i] end
|
|
239
|
+
return result
|
|
230
240
|
`;
|
|
231
241
|
/**
|
|
232
|
-
* KEYS:
|
|
242
|
+
* KEYS: store, counter, ids, session index, meta, pending, ready, watched.
|
|
233
243
|
* ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
|
|
234
244
|
* id, type, payloadJson, lane, settled.
|
|
235
245
|
*/
|
|
@@ -353,7 +363,8 @@ for i = 1, n do
|
|
|
353
363
|
end
|
|
354
364
|
redis.call('SET', KEYS[2], tostring(total + n))
|
|
355
365
|
redis.call('ZADD', KEYS[4], 0, ARGV[1])
|
|
356
|
-
return { 'ok', tostring(total + 1)
|
|
366
|
+
return { 'ok', tostring(total + 1),
|
|
367
|
+
tostring(redis.call('EXISTS', KEYS[8])) }
|
|
357
368
|
`;
|
|
358
369
|
/** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
|
|
359
370
|
const PUT_SNAPSHOT_LUA = `
|
|
@@ -364,21 +375,83 @@ end
|
|
|
364
375
|
redis.call('SET', KEYS[1], ARGV[2])
|
|
365
376
|
return 1
|
|
366
377
|
`;
|
|
367
|
-
/**
|
|
378
|
+
/**
|
|
379
|
+
* KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
|
|
380
|
+
* hashField, valueJson ('' deletes). Field-wise LWW by the sender's
|
|
381
|
+
* `at`: a stored entry with a strictly newer stamp survives; ties go
|
|
382
|
+
* to the incoming write (memory-tier parity). Returns the applied
|
|
383
|
+
* 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
|
|
384
|
+
* sound because core sends one uniform ttlMs; the per-entry `e` stamp
|
|
385
|
+
* is the fine bound (the injected storage clock at the applied set
|
|
386
|
+
* plus ttlMs — never the sender stamp), pruned on read against the
|
|
387
|
+
* same clock.
|
|
388
|
+
*/
|
|
389
|
+
const PRESENCE_SET_LUA = `
|
|
390
|
+
local at = tonumber(ARGV[1])
|
|
391
|
+
local applied = {}
|
|
392
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
393
|
+
local field = ARGV[3 + (i - 1) * 2 + 1]
|
|
394
|
+
local value = ARGV[3 + (i - 1) * 2 + 2]
|
|
395
|
+
local existing = redis.call('HGET', KEYS[1], field)
|
|
396
|
+
if not existing or tonumber(cjson.decode(existing)['a']) <= at then
|
|
397
|
+
if value == '' then
|
|
398
|
+
redis.call('HDEL', KEYS[1], field)
|
|
399
|
+
else
|
|
400
|
+
redis.call('HSET', KEYS[1], field, value)
|
|
401
|
+
end
|
|
402
|
+
applied[#applied + 1] = tostring(i)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
if #applied > 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
|
406
|
+
return applied
|
|
407
|
+
`;
|
|
408
|
+
/**
|
|
409
|
+
* KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
|
|
410
|
+
* time-travel expiry against a real server). Returns the live
|
|
411
|
+
* [field, value, ...] pairs; entries at or past their fine-bound
|
|
412
|
+
* expiry are HDELed in the same atomic step, so a stale reader can
|
|
413
|
+
* never sweep a concurrently refreshed field.
|
|
414
|
+
*/
|
|
415
|
+
const PRESENCE_READ_LUA = `
|
|
416
|
+
local now = tonumber(ARGV[1])
|
|
417
|
+
local rows = redis.call('HGETALL', KEYS[1])
|
|
418
|
+
local live = {}
|
|
419
|
+
for i = 1, #rows, 2 do
|
|
420
|
+
if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
|
|
421
|
+
redis.call('HDEL', KEYS[1], rows[i])
|
|
422
|
+
else
|
|
423
|
+
live[#live + 1] = rows[i]
|
|
424
|
+
live[#live + 1] = rows[i + 1]
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
return live
|
|
428
|
+
`;
|
|
429
|
+
/** KEYS: snapshot, event counter. Returns one atomic cache-plus-frontier read. */
|
|
368
430
|
const READ_STATE_LUA = `
|
|
369
431
|
local snapshot = redis.call('GET', KEYS[1])
|
|
370
|
-
local
|
|
371
|
-
|
|
372
|
-
after = tonumber(cjson.decode(snapshot)['index'])
|
|
373
|
-
end
|
|
374
|
-
local events = redis.call('XRANGE', KEYS[2], tostring(after + 1) .. '-0', '+')
|
|
375
|
-
return { snapshot or '', events }
|
|
432
|
+
local frontier = redis.call('GET', KEYS[2]) or '0'
|
|
433
|
+
return { snapshot or '', frontier }
|
|
376
434
|
`;
|
|
377
435
|
const fieldMap = (fields) => {
|
|
378
436
|
const map = {};
|
|
379
437
|
for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
|
|
380
438
|
return map;
|
|
381
439
|
};
|
|
440
|
+
const STORED_EVENT_META_SUFFIXES = [
|
|
441
|
+
"p",
|
|
442
|
+
"pa",
|
|
443
|
+
"ri",
|
|
444
|
+
"ch",
|
|
445
|
+
"ce",
|
|
446
|
+
"f",
|
|
447
|
+
"fc",
|
|
448
|
+
"lc",
|
|
449
|
+
"lf",
|
|
450
|
+
"lfa",
|
|
451
|
+
"d",
|
|
452
|
+
"a",
|
|
453
|
+
"e"
|
|
454
|
+
];
|
|
382
455
|
const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
|
|
383
456
|
const toEvent = (sessionId, entry) => {
|
|
384
457
|
const fields = fieldMap(entry[1]);
|
|
@@ -449,40 +522,43 @@ const wrap = async (fn) => {
|
|
|
449
522
|
return await fn();
|
|
450
523
|
} catch (err) {
|
|
451
524
|
if (err instanceof A2Error || err instanceof TypeError) throw err;
|
|
452
|
-
throw new A2Error("
|
|
525
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
453
526
|
}
|
|
454
527
|
};
|
|
455
|
-
function
|
|
456
|
-
const clock = options
|
|
457
|
-
const generateId = options.ids
|
|
458
|
-
const prefix = options.keyPrefix
|
|
459
|
-
|
|
460
|
-
const logKey = (s) => `${prefix}:${s}:log`;
|
|
528
|
+
function createRedisStoreCore(options) {
|
|
529
|
+
const { call, clock, notify } = options;
|
|
530
|
+
const generateId = options.ids;
|
|
531
|
+
const prefix = options.keyPrefix;
|
|
532
|
+
const storeKey = (s) => `${prefix}:${s}:log`;
|
|
461
533
|
const countKey = (s) => `${prefix}:${s}:count`;
|
|
462
534
|
const metaKey = (s) => `${prefix}:${s}:meta`;
|
|
463
535
|
const pendingKey = (s) => `${prefix}:${s}:pending`;
|
|
464
536
|
const readyKey = (s) => `${prefix}:${s}:ready`;
|
|
465
537
|
const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
|
|
538
|
+
const watchedKey = (s) => `${prefix}:${s}:watched`;
|
|
539
|
+
const presenceKey = (s) => `${prefix}:${s}:presence`;
|
|
466
540
|
const idsKey = `${prefix}:ids`;
|
|
467
541
|
const sessionsKey = `${prefix}:sessions`;
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
});
|
|
474
|
-
const client = connection.get;
|
|
475
|
-
const evalScript = async (script, keys, args) => {
|
|
476
|
-
return (await client()).call("EVAL", script, keys.length, ...keys, ...args);
|
|
542
|
+
const evalScript = (script, keys, args) => call("EVAL", script, keys.length, ...keys, ...args);
|
|
543
|
+
const readFrontier = async (sessionId) => {
|
|
544
|
+
const frontier = Number(await call("GET", countKey(sessionId)) ?? 0);
|
|
545
|
+
if (!Number.isSafeInteger(frontier) || frontier < 0) throw new A2Error("STORE_UNAVAILABLE", `redis event count for session '${sessionId}' is invalid`);
|
|
546
|
+
return frontier;
|
|
477
547
|
};
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const readRange = async (sessionId, afterIndex) => {
|
|
482
|
-
const c = await client();
|
|
483
|
-
const entries = await c.call("XRANGE", logKey(sessionId), `${afterIndex + 1}-0`, "+");
|
|
548
|
+
const readRangePage = async (sessionId, afterIndex, throughIndex, limit) => {
|
|
549
|
+
if (throughIndex <= afterIndex) return [];
|
|
550
|
+
const entries = await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, `${throughIndex}-0`, "COUNT", limit);
|
|
484
551
|
if (entries.length === 0) return [];
|
|
485
|
-
const
|
|
552
|
+
const metaFields = entries.flatMap((entry) => {
|
|
553
|
+
const index = entryIndex(entry[0]);
|
|
554
|
+
return STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`);
|
|
555
|
+
});
|
|
556
|
+
const metaValues = await call("HMGET", metaKey(sessionId), ...metaFields) ?? [];
|
|
557
|
+
const meta = {};
|
|
558
|
+
for (let i = 0; i < metaFields.length; i += 1) {
|
|
559
|
+
const value = metaValues[i];
|
|
560
|
+
if (value !== null && value !== void 0) meta[metaFields[i]] = value;
|
|
561
|
+
}
|
|
486
562
|
return entries.map((entry) => {
|
|
487
563
|
const event = toEvent(sessionId, entry);
|
|
488
564
|
const idx = event.index;
|
|
@@ -502,7 +578,7 @@ function redis(options = {}) {
|
|
|
502
578
|
lane: fields["lane"] === "" ? null : fields["lane"],
|
|
503
579
|
processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
|
|
504
580
|
processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
|
|
505
|
-
returnedEventIds: returnedEventIds === void 0 ? null :
|
|
581
|
+
returnedEventIds: returnedEventIds === void 0 ? null : decodeReturnedEventIds(returnedEventIds),
|
|
506
582
|
firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
|
|
507
583
|
lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
|
|
508
584
|
attemptCount: Number(meta[`${idx}:d`] ?? 0),
|
|
@@ -516,6 +592,41 @@ function redis(options = {}) {
|
|
|
516
592
|
});
|
|
517
593
|
});
|
|
518
594
|
};
|
|
595
|
+
const readEventRange = async (sessionId, afterIndex, throughIndex) => {
|
|
596
|
+
if (throughIndex <= afterIndex) return [];
|
|
597
|
+
const events = [];
|
|
598
|
+
let nextIndex = afterIndex + 1;
|
|
599
|
+
while (nextIndex <= throughIndex) {
|
|
600
|
+
const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${throughIndex}-0`, "COUNT", READ_PAGE_SIZE);
|
|
601
|
+
if (entries.length === 0) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ended before index ${throughIndex}`);
|
|
602
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
603
|
+
const index = entryIndex(entries[position][0]);
|
|
604
|
+
if (index !== nextIndex + position || index > throughIndex) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
|
|
605
|
+
}
|
|
606
|
+
events.push(...entries.map((entry) => toEvent(sessionId, entry)));
|
|
607
|
+
nextIndex = events.at(-1).index + 1;
|
|
608
|
+
}
|
|
609
|
+
return events;
|
|
610
|
+
};
|
|
611
|
+
/** Bounded XRANGE pages restore the full-read contract on capped providers. */
|
|
612
|
+
const readRange = async (sessionId, afterIndex, throughIndex) => {
|
|
613
|
+
if (throughIndex !== void 0 && throughIndex <= afterIndex) return [];
|
|
614
|
+
const currentFrontier = await readFrontier(sessionId);
|
|
615
|
+
const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier);
|
|
616
|
+
if (frontier <= afterIndex) return [];
|
|
617
|
+
const rows = [];
|
|
618
|
+
let nextIndex = afterIndex + 1;
|
|
619
|
+
while (nextIndex <= frontier) {
|
|
620
|
+
const page = await readRangePage(sessionId, nextIndex - 1, frontier, READ_PAGE_SIZE);
|
|
621
|
+
if (page.length === 0) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ended before index ${frontier}`);
|
|
622
|
+
for (let position = 0; position < page.length; position += 1) if (page[position].index !== nextIndex + position) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
|
|
623
|
+
const lastIndex = page.at(-1).index;
|
|
624
|
+
if (lastIndex < nextIndex || lastIndex > frontier) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' did not advance`);
|
|
625
|
+
rows.push(...page);
|
|
626
|
+
nextIndex = lastIndex + 1;
|
|
627
|
+
}
|
|
628
|
+
return rows;
|
|
629
|
+
};
|
|
519
630
|
return {
|
|
520
631
|
async append(sessionId, events) {
|
|
521
632
|
return wrap(async () => {
|
|
@@ -544,13 +655,14 @@ function redis(options = {}) {
|
|
|
544
655
|
])
|
|
545
656
|
];
|
|
546
657
|
const reply = await evalScript(APPEND_LUA, [
|
|
547
|
-
|
|
658
|
+
storeKey(sessionId),
|
|
548
659
|
countKey(sessionId),
|
|
549
660
|
idsKey,
|
|
550
661
|
sessionsKey,
|
|
551
662
|
metaKey(sessionId),
|
|
552
663
|
pendingKey(sessionId),
|
|
553
|
-
readyKey(sessionId)
|
|
664
|
+
readyKey(sessionId),
|
|
665
|
+
watchedKey(sessionId)
|
|
554
666
|
], args);
|
|
555
667
|
const [verdict] = reply;
|
|
556
668
|
if (verdict === "empty") return {
|
|
@@ -558,6 +670,8 @@ function redis(options = {}) {
|
|
|
558
670
|
hasPending: reply[1] === "1"
|
|
559
671
|
};
|
|
560
672
|
if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
|
|
673
|
+
if (verdict === "no_parent") throw new TypeError(`no event at index ${reply[1]} in session '${sessionId}'`);
|
|
674
|
+
if (verdict === "superseded") throw new A2Error("SUPERSEDED_ATTEMPT", `a newer attempt owns event ${reply[1]} in session '${sessionId}'`);
|
|
561
675
|
if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
|
|
562
676
|
if (verdict === "dup") {
|
|
563
677
|
const hasPending = reply[1] === "1";
|
|
@@ -570,6 +684,7 @@ function redis(options = {}) {
|
|
|
570
684
|
};
|
|
571
685
|
}
|
|
572
686
|
const base = Number(reply[1]) - 1;
|
|
687
|
+
if (reply[3] === "1") notify?.(sessionId, base + withIds.length);
|
|
573
688
|
return {
|
|
574
689
|
events: withIds.map((e, i) => ({
|
|
575
690
|
id: e.id,
|
|
@@ -600,7 +715,7 @@ function redis(options = {}) {
|
|
|
600
715
|
},
|
|
601
716
|
async read(sessionId, opts) {
|
|
602
717
|
return wrap(async () => {
|
|
603
|
-
return await readRange(sessionId, opts?.afterIndex ?? 0);
|
|
718
|
+
return await readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex);
|
|
604
719
|
});
|
|
605
720
|
},
|
|
606
721
|
async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
|
|
@@ -609,7 +724,7 @@ function redis(options = {}) {
|
|
|
609
724
|
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
610
725
|
const excluded = excludeIndexes ?? [];
|
|
611
726
|
const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
|
|
612
|
-
|
|
727
|
+
storeKey(sessionId),
|
|
613
728
|
countKey(sessionId),
|
|
614
729
|
metaKey(sessionId),
|
|
615
730
|
pendingKey(sessionId),
|
|
@@ -624,17 +739,23 @@ function redis(options = {}) {
|
|
|
624
739
|
return toClaimAvailableResult(sessionId, reply);
|
|
625
740
|
});
|
|
626
741
|
},
|
|
627
|
-
async renewClaims({ sessionId, holder,
|
|
742
|
+
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
628
743
|
return wrap(async () => {
|
|
629
744
|
const now = clock.now().getTime();
|
|
630
745
|
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
631
|
-
|
|
746
|
+
const reply = await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
|
|
632
747
|
holder,
|
|
633
748
|
now,
|
|
634
749
|
expiresAt,
|
|
635
|
-
|
|
636
|
-
...
|
|
637
|
-
])
|
|
750
|
+
claims.length,
|
|
751
|
+
...claims.flatMap((claim) => [claim.index, claim.attempt])
|
|
752
|
+
]);
|
|
753
|
+
const renewedCount = Number(reply[0]);
|
|
754
|
+
const values = reply.slice(1).map(Number);
|
|
755
|
+
return {
|
|
756
|
+
renewed: values.slice(0, renewedCount).toSorted((a, b) => a - b),
|
|
757
|
+
superseded: values.slice(renewedCount).toSorted((a, b) => a - b)
|
|
758
|
+
};
|
|
638
759
|
});
|
|
639
760
|
},
|
|
640
761
|
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
@@ -643,13 +764,14 @@ function redis(options = {}) {
|
|
|
643
764
|
if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
644
765
|
const now = clock.now().getTime();
|
|
645
766
|
const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
|
|
646
|
-
|
|
767
|
+
storeKey(sessionId),
|
|
647
768
|
countKey(sessionId),
|
|
648
769
|
idsKey,
|
|
649
770
|
sessionsKey,
|
|
650
771
|
metaKey(sessionId),
|
|
651
772
|
pendingKey(sessionId),
|
|
652
|
-
readyKey(sessionId)
|
|
773
|
+
readyKey(sessionId),
|
|
774
|
+
watchedKey(sessionId)
|
|
653
775
|
], [
|
|
654
776
|
sessionId,
|
|
655
777
|
now,
|
|
@@ -674,6 +796,7 @@ function redis(options = {}) {
|
|
|
674
796
|
};
|
|
675
797
|
if (outcome === "ok") {
|
|
676
798
|
const base = Number(reply[1]) - 1;
|
|
799
|
+
if (events.length > 0 && reply[2] === "1") notify?.(sessionId, base + events.length);
|
|
677
800
|
return {
|
|
678
801
|
outcome: "completed",
|
|
679
802
|
events: events.map((event, i) => ({
|
|
@@ -743,15 +866,18 @@ function redis(options = {}) {
|
|
|
743
866
|
},
|
|
744
867
|
async readState(sessionId, reducerName) {
|
|
745
868
|
return wrap(async () => {
|
|
746
|
-
const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName),
|
|
869
|
+
const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), countKey(sessionId)], []);
|
|
747
870
|
const raw = reply[0];
|
|
748
871
|
const parsed = raw === "" ? null : JSON.parse(raw);
|
|
872
|
+
const frontier = Number(reply[1]);
|
|
873
|
+
if (!Number.isSafeInteger(frontier) || frontier < 0 || parsed !== null && (!Number.isSafeInteger(parsed.index) || parsed.index < 0)) throw new A2Error("STORE_UNAVAILABLE", `redis state frontier for session '${sessionId}' is invalid`);
|
|
874
|
+
const events = await readEventRange(sessionId, parsed?.index ?? 0, frontier);
|
|
749
875
|
return {
|
|
750
876
|
snapshot: parsed ? {
|
|
751
877
|
index: parsed.index,
|
|
752
878
|
state: parsed.state
|
|
753
879
|
} : null,
|
|
754
|
-
events
|
|
880
|
+
events
|
|
755
881
|
};
|
|
756
882
|
});
|
|
757
883
|
},
|
|
@@ -764,12 +890,63 @@ function redis(options = {}) {
|
|
|
764
890
|
})]);
|
|
765
891
|
});
|
|
766
892
|
},
|
|
893
|
+
presence: {
|
|
894
|
+
async set(ns, participant, values, meta) {
|
|
895
|
+
return wrap(async () => {
|
|
896
|
+
const entries = Object.entries(values);
|
|
897
|
+
if (entries.length === 0) return null;
|
|
898
|
+
const atMs = meta.at.getTime();
|
|
899
|
+
const expiresAtMs = clock.now().getTime() + meta.ttlMs;
|
|
900
|
+
const applied = await evalScript(PRESENCE_SET_LUA, [presenceKey(ns)], [
|
|
901
|
+
atMs,
|
|
902
|
+
meta.ttlMs,
|
|
903
|
+
entries.length,
|
|
904
|
+
...entries.flatMap(([field, value]) => [JSON.stringify([participant, field]), value === null ? "" : json({
|
|
905
|
+
v: value,
|
|
906
|
+
s: meta.seen,
|
|
907
|
+
a: atMs,
|
|
908
|
+
e: expiresAtMs
|
|
909
|
+
})])
|
|
910
|
+
]);
|
|
911
|
+
if (applied.length === 0) return null;
|
|
912
|
+
const appliedValues = nullProtoRecord();
|
|
913
|
+
for (const position of applied) {
|
|
914
|
+
const [field, value] = entries[Number(position) - 1];
|
|
915
|
+
appliedValues[field] = structuredClone(value);
|
|
916
|
+
}
|
|
917
|
+
return {
|
|
918
|
+
participant,
|
|
919
|
+
values: appliedValues,
|
|
920
|
+
seen: meta.seen,
|
|
921
|
+
at: new Date(atMs)
|
|
922
|
+
};
|
|
923
|
+
});
|
|
924
|
+
},
|
|
925
|
+
async read(ns) {
|
|
926
|
+
return wrap(async () => {
|
|
927
|
+
const reply = await evalScript(PRESENCE_READ_LUA, [presenceKey(ns)], [clock.now().getTime()]);
|
|
928
|
+
const rows = [];
|
|
929
|
+
for (let i = 0; i + 1 < reply.length; i += 2) {
|
|
930
|
+
const [participant, field] = JSON.parse(reply[i]);
|
|
931
|
+
const entry = JSON.parse(reply[i + 1]);
|
|
932
|
+
rows.push({
|
|
933
|
+
participant,
|
|
934
|
+
field,
|
|
935
|
+
value: entry.v,
|
|
936
|
+
seen: entry.s,
|
|
937
|
+
at: new Date(entry.a),
|
|
938
|
+
expiresAt: new Date(entry.e)
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
return rows;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
},
|
|
767
945
|
inspect: {
|
|
768
946
|
async listSessions(inspectionOptions) {
|
|
769
947
|
return wrap(async () => {
|
|
770
|
-
const c = await client();
|
|
771
948
|
const minimum = inspectionOptions.cursor === void 0 ? `[${inspectionOptions.prefix}` : `(${inspectionOptions.cursor}`;
|
|
772
|
-
const candidates = await
|
|
949
|
+
const candidates = await call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
|
|
773
950
|
const sessionIds = candidates.filter((id) => id.startsWith(inspectionOptions.prefix)).slice(0, inspectionOptions.limit);
|
|
774
951
|
return {
|
|
775
952
|
sessions: (await Promise.all(sessionIds.map(async (sessionId) => {
|
|
@@ -809,19 +986,31 @@ function redis(options = {}) {
|
|
|
809
986
|
};
|
|
810
987
|
});
|
|
811
988
|
},
|
|
989
|
+
async readEvents(sessionId, pageOptions) {
|
|
990
|
+
return wrap(async () => {
|
|
991
|
+
const currentFrontier = await readFrontier(sessionId);
|
|
992
|
+
const throughIndex = Math.min(pageOptions.throughIndex ?? currentFrontier, currentFrontier);
|
|
993
|
+
const events = await readRangePage(sessionId, pageOptions.afterIndex, throughIndex, pageOptions.limit);
|
|
994
|
+
if (throughIndex > pageOptions.afterIndex && events.length === 0) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ended before index ${throughIndex}`);
|
|
995
|
+
for (let position = 0; position < events.length; position += 1) if (events[position].index !== pageOptions.afterIndex + position + 1) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
|
|
996
|
+
return {
|
|
997
|
+
events,
|
|
998
|
+
throughIndex
|
|
999
|
+
};
|
|
1000
|
+
});
|
|
1001
|
+
},
|
|
812
1002
|
async listSnapshots(sessionId) {
|
|
813
1003
|
return wrap(async () => {
|
|
814
|
-
const c = await client();
|
|
815
1004
|
const start = `${prefix}:${sessionId}:snap:`;
|
|
816
1005
|
let cursor = "0";
|
|
817
1006
|
const keys = [];
|
|
818
1007
|
do {
|
|
819
|
-
const reply = await
|
|
1008
|
+
const reply = await call("SCAN", cursor, "MATCH", `${escapeGlob(start)}*`, "COUNT", 100);
|
|
820
1009
|
cursor = reply[0];
|
|
821
1010
|
keys.push(...reply[1].filter((key) => key.startsWith(start)));
|
|
822
1011
|
} while (cursor !== "0");
|
|
823
1012
|
return (await Promise.all(keys.map(async (key) => {
|
|
824
|
-
const raw = await
|
|
1013
|
+
const raw = await call("GET", key);
|
|
825
1014
|
if (raw === null) return null;
|
|
826
1015
|
const parsed = JSON.parse(raw);
|
|
827
1016
|
return {
|
|
@@ -833,69 +1022,18 @@ function redis(options = {}) {
|
|
|
833
1022
|
});
|
|
834
1023
|
}
|
|
835
1024
|
},
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
return {
|
|
844
|
-
async next() {
|
|
845
|
-
for (;;) {
|
|
846
|
-
if (closed) return {
|
|
847
|
-
value: void 0,
|
|
848
|
-
done: true
|
|
849
|
-
};
|
|
850
|
-
const row = buffer.shift();
|
|
851
|
-
if (row) {
|
|
852
|
-
last = row.index;
|
|
853
|
-
return {
|
|
854
|
-
value: row,
|
|
855
|
-
done: false
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
if (!conn) {
|
|
859
|
-
conn = (await client()).duplicate();
|
|
860
|
-
streamConnections.add(conn);
|
|
861
|
-
}
|
|
862
|
-
let reply;
|
|
863
|
-
try {
|
|
864
|
-
reply = await conn.call("XREAD", "BLOCK", XREAD_BLOCK_MS, "STREAMS", logKey(sessionId), `${last}-0`);
|
|
865
|
-
} catch (err) {
|
|
866
|
-
if (closed) return {
|
|
867
|
-
value: void 0,
|
|
868
|
-
done: true
|
|
869
|
-
};
|
|
870
|
-
throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
|
|
871
|
-
}
|
|
872
|
-
if (reply === null) continue;
|
|
873
|
-
const [[, entries]] = reply;
|
|
874
|
-
buffer = entries.map((entry) => toEvent(sessionId, entry));
|
|
875
|
-
}
|
|
876
|
-
},
|
|
877
|
-
async return() {
|
|
878
|
-
closed = true;
|
|
879
|
-
if (conn) {
|
|
880
|
-
streamConnections.delete(conn);
|
|
881
|
-
conn.disconnect();
|
|
882
|
-
}
|
|
883
|
-
return {
|
|
884
|
-
value: void 0,
|
|
885
|
-
done: true
|
|
886
|
-
};
|
|
887
|
-
}
|
|
888
|
-
};
|
|
889
|
-
} };
|
|
1025
|
+
async readRange(sessionId, afterIndex, throughIndex) {
|
|
1026
|
+
return wrap(() => readRange(sessionId, afterIndex, throughIndex));
|
|
1027
|
+
},
|
|
1028
|
+
async readEvents(sessionId, afterIndex) {
|
|
1029
|
+
return wrap(async () => {
|
|
1030
|
+
return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+")).map((entry) => toEvent(sessionId, entry));
|
|
1031
|
+
});
|
|
890
1032
|
},
|
|
891
|
-
async
|
|
892
|
-
|
|
893
|
-
streamConnections.clear();
|
|
894
|
-
const current = connection.peek();
|
|
895
|
-
if (!current) return;
|
|
896
|
-
(await current).disconnect();
|
|
1033
|
+
async markWatched(sessionId, ttlMs) {
|
|
1034
|
+
await wrap(() => call("SET", watchedKey(sessionId), "1", "PX", ttlMs));
|
|
897
1035
|
}
|
|
898
1036
|
};
|
|
899
1037
|
}
|
|
900
1038
|
//#endregion
|
|
901
|
-
export {
|
|
1039
|
+
export { createRedisStoreCore as t };
|