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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/store-redis-http — the same Redis Streams store over a
|
|
3
|
+
* provider REST API (Upstash-shaped: POST one command as a JSON array,
|
|
4
|
+
* receive `{ result }` or `{ error }`). The storage semantics live in
|
|
5
|
+
* store-redis-core.ts, shared with experimental-a2/store-redis.
|
|
6
|
+
*
|
|
7
|
+
* Fully connectionless: every command is one `fetch`, so it works
|
|
8
|
+
* where a Redis-protocol connection cannot exist or cannot be spared.
|
|
9
|
+
* Without a connection there is no push and no pub/sub, so `stream()`
|
|
10
|
+
* is the shared activity-adaptive poll loop (25ms while events flow,
|
|
11
|
+
* backing off to a 250ms idle ceiling), and presence is the degraded
|
|
12
|
+
* tier: `set`/`read` from the shared core, no `subscribe`. No peer
|
|
13
|
+
* dependencies.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { A2Error } from './errors.ts'
|
|
17
|
+
import { pollingStream } from './store-polling.ts'
|
|
18
|
+
import { createRedisStoreCore } from './store-redis-core.ts'
|
|
19
|
+
import {
|
|
20
|
+
RANDOM_IDS,
|
|
21
|
+
SYSTEM_CLOCK,
|
|
22
|
+
type A2Store,
|
|
23
|
+
type Clock,
|
|
24
|
+
type IdSource,
|
|
25
|
+
} from './store.ts'
|
|
26
|
+
|
|
27
|
+
export type RedisHttpStoreOptions = {
|
|
28
|
+
/** REST endpoint, e.g. `process.env.UPSTASH_REDIS_REST_URL`. */
|
|
29
|
+
url: string
|
|
30
|
+
/** Bearer token, e.g. `process.env.UPSTASH_REDIS_REST_TOKEN`. */
|
|
31
|
+
token: string
|
|
32
|
+
/** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */
|
|
33
|
+
keyPrefix?: string
|
|
34
|
+
/** Injectable clock — every stored timestamp comes from here. */
|
|
35
|
+
clock?: Clock
|
|
36
|
+
/** Injectable id source for generated event ids. */
|
|
37
|
+
ids?: IdSource
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type RedisHttpStore = A2Store & {
|
|
41
|
+
/** Nothing to disconnect; present so callers can treat stores uniformly. */
|
|
42
|
+
close(): Promise<void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
|
|
46
|
+
if (!options.url || !options.token) {
|
|
47
|
+
throw new TypeError('redisHttp() needs a url and a token')
|
|
48
|
+
}
|
|
49
|
+
const { url, token } = options
|
|
50
|
+
|
|
51
|
+
const call = async (
|
|
52
|
+
command: string,
|
|
53
|
+
...args: Array<string | number>
|
|
54
|
+
): Promise<unknown> => {
|
|
55
|
+
let response: Response
|
|
56
|
+
try {
|
|
57
|
+
response = await fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
authorization: `Bearer ${token}`,
|
|
61
|
+
'content-type': 'application/json',
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify([command, ...args.map(String)]),
|
|
64
|
+
})
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new A2Error('STORE_UNAVAILABLE', 'redis rest request failed', {
|
|
67
|
+
cause: err,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
const body = (await response.json().catch(() => null)) as {
|
|
71
|
+
result?: unknown
|
|
72
|
+
error?: string
|
|
73
|
+
} | null
|
|
74
|
+
if (body === null || !response.ok || body.error !== undefined) {
|
|
75
|
+
throw new A2Error(
|
|
76
|
+
'STORE_UNAVAILABLE',
|
|
77
|
+
`redis rest command failed: ${body?.error ?? `status ${response.status}`}`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return body.result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const core = createRedisStoreCore({
|
|
84
|
+
call,
|
|
85
|
+
clock: options.clock ?? SYSTEM_CLOCK,
|
|
86
|
+
ids: options.ids ?? RANDOM_IDS,
|
|
87
|
+
keyPrefix: options.keyPrefix ?? 'a2',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
append: core.append,
|
|
92
|
+
read: core.read,
|
|
93
|
+
claimAvailable: core.claimAvailable,
|
|
94
|
+
renewClaims: core.renewClaims,
|
|
95
|
+
completeAttempt: core.completeAttempt,
|
|
96
|
+
failAttempt: core.failAttempt,
|
|
97
|
+
readState: core.readState,
|
|
98
|
+
putSnapshot: core.putSnapshot,
|
|
99
|
+
presence: {
|
|
100
|
+
async set(ns, participant, values, meta) {
|
|
101
|
+
await core.presence.set(ns, participant, values, meta)
|
|
102
|
+
},
|
|
103
|
+
read: core.presence.read,
|
|
104
|
+
},
|
|
105
|
+
inspect: core.inspect,
|
|
106
|
+
|
|
107
|
+
stream(sessionId, opts) {
|
|
108
|
+
return pollingStream(
|
|
109
|
+
(afterIndex) => core.readRange(sessionId, afterIndex),
|
|
110
|
+
opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},
|
|
111
|
+
)
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async close() {},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/store-redis — the Redis-protocol store backend, on Redis
|
|
3
|
+
* Streams. The storage semantics live in store-redis-core.ts (shared
|
|
4
|
+
* with experimental-a2/store-redis-http); this module owns the connection and
|
|
5
|
+
* the live feed.
|
|
6
|
+
*
|
|
7
|
+
* `stream()` is notify-driven: writes to watched sessions fire a
|
|
8
|
+
* disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the
|
|
9
|
+
* write script checks, so unwatched sessions cost no wake-up), one
|
|
10
|
+
* shared subscriber connection per backend serves every local feed,
|
|
11
|
+
* and each wake triggers an `XRANGE` catch-up read. The notification
|
|
12
|
+
* only decides when to read, never what — a lost one is healed by a
|
|
13
|
+
* safety re-read (NOTIFY_TIMINGS), so delivery never depends on
|
|
14
|
+
* pub/sub. Connections scale with processes (one command client plus
|
|
15
|
+
* one subscriber), not with concurrent viewers.
|
|
16
|
+
*
|
|
17
|
+
* Works with any Redis-protocol server on a single instance or a
|
|
18
|
+
* non-cluster provider (Upstash — durable by default — Redis, Valkey).
|
|
19
|
+
* Cluster mode is out: the atomic scripts span keys. `ioredis` is an
|
|
20
|
+
* optional peer dependency; pass `url`, or inject any client exposing
|
|
21
|
+
* `call`/`duplicate`/`on`/`disconnect`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { PresencePatch } from './contract.ts'
|
|
25
|
+
import { A2Error } from './errors.ts'
|
|
26
|
+
import { NOTIFY_TIMINGS } from './internal.ts'
|
|
27
|
+
import { defaultSleep, type Sleeper } from './store-polling.ts'
|
|
28
|
+
import { createRedisStoreCore } from './store-redis-core.ts'
|
|
29
|
+
import { retryableLazy } from './retryable-lazy.ts'
|
|
30
|
+
import {
|
|
31
|
+
RANDOM_IDS,
|
|
32
|
+
SYSTEM_CLOCK,
|
|
33
|
+
type A2Store,
|
|
34
|
+
type Clock,
|
|
35
|
+
type Event,
|
|
36
|
+
type IdSource,
|
|
37
|
+
} from './store.ts'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The minimal client this backend needs — `ioredis` matches it
|
|
41
|
+
* structurally. `call` issues any command; `duplicate` opens the one
|
|
42
|
+
* shared subscriber connection; `on` delivers its pub/sub messages.
|
|
43
|
+
*
|
|
44
|
+
* The client must restore its subscriptions after a reconnect
|
|
45
|
+
* (`ioredis` does). One that doesn't stays correct — the safety
|
|
46
|
+
* re-read delivers everything — but every live feed silently degrades
|
|
47
|
+
* to safety-read latency from that point on.
|
|
48
|
+
*/
|
|
49
|
+
export type RedisConnection = {
|
|
50
|
+
call(command: string, ...args: Array<string | number>): Promise<unknown>
|
|
51
|
+
duplicate(): RedisConnection
|
|
52
|
+
on(
|
|
53
|
+
event: 'message',
|
|
54
|
+
listener: (channel: string, message: string) => void,
|
|
55
|
+
): unknown
|
|
56
|
+
disconnect(): void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type RedisStoreOptions = {
|
|
60
|
+
/** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */
|
|
61
|
+
url?: string | undefined
|
|
62
|
+
/** Bring your own client — anything `call`/`duplicate`/`on`/`disconnect`. */
|
|
63
|
+
client?: RedisConnection
|
|
64
|
+
/** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */
|
|
65
|
+
keyPrefix?: string
|
|
66
|
+
/** Injectable clock — every stored timestamp comes from here. */
|
|
67
|
+
clock?: Clock
|
|
68
|
+
/** Injectable id source for generated event ids. */
|
|
69
|
+
ids?: IdSource
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type RedisStore = A2Store & {
|
|
73
|
+
/** Disconnect the command client and the shared subscriber. */
|
|
74
|
+
close(): Promise<void>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A published presence patch: the patch fields plus the publishing
|
|
78
|
+
* instance's tag, so its own loopback delivery is not repeated. */
|
|
79
|
+
type PresenceWireMessage = {
|
|
80
|
+
src: string
|
|
81
|
+
participant: string
|
|
82
|
+
values: Record<string, unknown>
|
|
83
|
+
seen: number
|
|
84
|
+
at: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function redis(options: RedisStoreOptions = {}): RedisStore {
|
|
88
|
+
const clock = options.clock ?? SYSTEM_CLOCK
|
|
89
|
+
const ids = options.ids ?? RANDOM_IDS
|
|
90
|
+
const prefix = options.keyPrefix ?? 'a2'
|
|
91
|
+
|
|
92
|
+
if (!options.client && !options.url) {
|
|
93
|
+
throw new TypeError('redis() needs a url or an injected client')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Lazy init — constructing the backend does no I/O.
|
|
97
|
+
const connection = retryableLazy(async () => {
|
|
98
|
+
if (options.client) return options.client
|
|
99
|
+
const mod = await import('ioredis').catch(() => {
|
|
100
|
+
throw new A2Error(
|
|
101
|
+
'STORE_NOT_CONFIGURED',
|
|
102
|
+
"experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client",
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
return new mod.default(options.url as string) as RedisConnection
|
|
106
|
+
})
|
|
107
|
+
const client = connection.get
|
|
108
|
+
|
|
109
|
+
const notifyChannel = (sessionId: string): string =>
|
|
110
|
+
`${prefix}:${sessionId}:notify`
|
|
111
|
+
const presenceChannel = (ns: string): string => `${prefix}:${ns}:presence`
|
|
112
|
+
|
|
113
|
+
const core = createRedisStoreCore({
|
|
114
|
+
call: async (command, ...args) => {
|
|
115
|
+
const c = await client()
|
|
116
|
+
return c.call(command, ...args)
|
|
117
|
+
},
|
|
118
|
+
clock,
|
|
119
|
+
ids,
|
|
120
|
+
keyPrefix: prefix,
|
|
121
|
+
// Fire-and-forget: the wake-up is disposable, so a failed PUBLISH
|
|
122
|
+
// must never fail the write it follows.
|
|
123
|
+
notify: (sessionId, count) => {
|
|
124
|
+
void client()
|
|
125
|
+
.then((c) => c.call('publish', notifyChannel(sessionId), count))
|
|
126
|
+
.catch(() => {})
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ── the shared subscriber ──────────────────────────────────────────
|
|
131
|
+
// One duplicated connection serves every local feed: subscriber mode
|
|
132
|
+
// monopolizes a connection, so it cannot be the command client.
|
|
133
|
+
|
|
134
|
+
let severed = false
|
|
135
|
+
let subscriber: Promise<RedisConnection> | null = null
|
|
136
|
+
/** channel → parked-feed wake-ups, armed before each catch-up read. */
|
|
137
|
+
const wakers = new Map<string, Set<() => void>>()
|
|
138
|
+
/** channel → refcounted subscription held for each live iterator. */
|
|
139
|
+
const subscriptions = new Map<
|
|
140
|
+
string,
|
|
141
|
+
{ refs: number; ready: Promise<unknown> }
|
|
142
|
+
>()
|
|
143
|
+
/**
|
|
144
|
+
* channel → last presence-marker refresh, throttling the SET.
|
|
145
|
+
* Deliberately real time (`Date.now()`), not the injected clock:
|
|
146
|
+
* the marker's PX expiry runs on server real time, and the throttle
|
|
147
|
+
* must tick with the TTL it refreshes or time-traveling tests would
|
|
148
|
+
* desync the two. Process-local and never stored, so the injected
|
|
149
|
+
* clock's every-stored-timestamp pledge is untouched.
|
|
150
|
+
*/
|
|
151
|
+
const watchedRefreshedAt = new Map<string, number>()
|
|
152
|
+
/**
|
|
153
|
+
* `:presence` channel → live listeners. Same-instance patches are
|
|
154
|
+
* delivered here synchronously by `presence.set`, so subscribe/set
|
|
155
|
+
* ordering never depends on the SUBSCRIBE command settling; the
|
|
156
|
+
* channel carries only other instances' patches, with own echoes
|
|
157
|
+
* skipped by `src`.
|
|
158
|
+
*/
|
|
159
|
+
const presenceListeners = new Map<
|
|
160
|
+
string,
|
|
161
|
+
Set<(patch: PresencePatch) => void>
|
|
162
|
+
>()
|
|
163
|
+
const instanceId = crypto.randomUUID()
|
|
164
|
+
|
|
165
|
+
const deliverRemotePresence = (
|
|
166
|
+
listeners: Set<(patch: PresencePatch) => void>,
|
|
167
|
+
message: string,
|
|
168
|
+
): void => {
|
|
169
|
+
let parsed: PresenceWireMessage
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(message) as PresenceWireMessage
|
|
172
|
+
} catch {
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (parsed.src === instanceId) return
|
|
176
|
+
const patch: PresencePatch = {
|
|
177
|
+
participant: parsed.participant,
|
|
178
|
+
values: parsed.values,
|
|
179
|
+
seen: parsed.seen,
|
|
180
|
+
at: new Date(parsed.at),
|
|
181
|
+
}
|
|
182
|
+
for (const listener of listeners) listener(patch)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const getSubscriber = (): Promise<RedisConnection> => {
|
|
186
|
+
subscriber ??= (async () => {
|
|
187
|
+
const conn = (await client()).duplicate()
|
|
188
|
+
conn.on('message', (channel, message) => {
|
|
189
|
+
// wake() only resolves a promise; removal happens later via
|
|
190
|
+
// disarm(), so iterating the live set is safe.
|
|
191
|
+
const set = wakers.get(channel)
|
|
192
|
+
if (set) {
|
|
193
|
+
for (const wake of set) wake()
|
|
194
|
+
}
|
|
195
|
+
const listeners = presenceListeners.get(channel)
|
|
196
|
+
if (listeners) deliverRemotePresence(listeners, message)
|
|
197
|
+
})
|
|
198
|
+
return conn
|
|
199
|
+
})()
|
|
200
|
+
return subscriber
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The subscriber, gated on connection readiness — for channel
|
|
205
|
+
* commands only (`close` uses the ungated promise, so it can never
|
|
206
|
+
* hang on an unreachable server). SUBSCRIBE carries redis's
|
|
207
|
+
* ok-loading flag, so ioredis writes it mid-handshake, ahead of the
|
|
208
|
+
* ready check's INFO — which then finds the connection already in
|
|
209
|
+
* subscriber mode and fails the check (a spurious error event plus
|
|
210
|
+
* a reconnect). ECHO has no such flag: it resolves only once the
|
|
211
|
+
* connection is ready, so every channel command chained behind it
|
|
212
|
+
* lands after the check.
|
|
213
|
+
*/
|
|
214
|
+
let subscriberGate: Promise<RedisConnection> | null = null
|
|
215
|
+
const gatedSubscriber = (): Promise<RedisConnection> => {
|
|
216
|
+
subscriberGate ??= getSubscriber().then(async (conn) => {
|
|
217
|
+
await conn.call('echo', 'ready')
|
|
218
|
+
return conn
|
|
219
|
+
})
|
|
220
|
+
return subscriberGate
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const acquireChannel = async (channel: string): Promise<void> => {
|
|
224
|
+
const existing = subscriptions.get(channel)
|
|
225
|
+
if (existing) {
|
|
226
|
+
existing.refs += 1
|
|
227
|
+
try {
|
|
228
|
+
await existing.ready
|
|
229
|
+
} catch (err) {
|
|
230
|
+
existing.refs -= 1
|
|
231
|
+
throw err
|
|
232
|
+
}
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
const lease = {
|
|
236
|
+
refs: 1,
|
|
237
|
+
// Lowercase on purpose: ioredis keys its subscriber-mode
|
|
238
|
+
// bookkeeping (and reconnect resubscription) on the exact
|
|
239
|
+
// command name.
|
|
240
|
+
ready: gatedSubscriber().then((conn) => conn.call('subscribe', channel)),
|
|
241
|
+
}
|
|
242
|
+
subscriptions.set(channel, lease)
|
|
243
|
+
try {
|
|
244
|
+
await lease.ready
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (subscriptions.get(channel) === lease) subscriptions.delete(channel)
|
|
247
|
+
throw err
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const releaseChannel = (channel: string): void => {
|
|
252
|
+
const lease = subscriptions.get(channel)
|
|
253
|
+
if (!lease) return
|
|
254
|
+
lease.refs -= 1
|
|
255
|
+
if (lease.refs > 0) return
|
|
256
|
+
subscriptions.delete(channel)
|
|
257
|
+
watchedRefreshedAt.delete(channel)
|
|
258
|
+
void gatedSubscriber()
|
|
259
|
+
.then((conn) => conn.call('unsubscribe', channel))
|
|
260
|
+
.catch(() => {})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const armWaker = (
|
|
264
|
+
channel: string,
|
|
265
|
+
): { wakeup: Promise<void>; wake: () => void; disarm: () => void } => {
|
|
266
|
+
let wake!: () => void
|
|
267
|
+
const wakeup = new Promise<void>((resolve) => {
|
|
268
|
+
wake = resolve // assigned synchronously by the executor
|
|
269
|
+
})
|
|
270
|
+
let set = wakers.get(channel)
|
|
271
|
+
if (!set) {
|
|
272
|
+
set = new Set()
|
|
273
|
+
wakers.set(channel, set)
|
|
274
|
+
}
|
|
275
|
+
set.add(wake)
|
|
276
|
+
return {
|
|
277
|
+
wakeup,
|
|
278
|
+
wake,
|
|
279
|
+
disarm: () => {
|
|
280
|
+
set.delete(wake)
|
|
281
|
+
if (set.size === 0) wakers.delete(channel)
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
append: core.append,
|
|
288
|
+
read: core.read,
|
|
289
|
+
claimAvailable: core.claimAvailable,
|
|
290
|
+
renewClaims: core.renewClaims,
|
|
291
|
+
completeAttempt: core.completeAttempt,
|
|
292
|
+
failAttempt: core.failAttempt,
|
|
293
|
+
readState: core.readState,
|
|
294
|
+
putSnapshot: core.putSnapshot,
|
|
295
|
+
inspect: core.inspect,
|
|
296
|
+
|
|
297
|
+
presence: {
|
|
298
|
+
async set(ns, participant, values, meta) {
|
|
299
|
+
const patch = await core.presence.set(ns, participant, values, meta)
|
|
300
|
+
if (!patch) return
|
|
301
|
+
const channel = presenceChannel(ns)
|
|
302
|
+
const listeners = presenceListeners.get(channel)
|
|
303
|
+
if (listeners) {
|
|
304
|
+
for (const listener of listeners) listener(patch)
|
|
305
|
+
}
|
|
306
|
+
// Broadcast for other instances — best-effort like the notify
|
|
307
|
+
// PUBLISH (a failed one must never fail the applied write),
|
|
308
|
+
// but awaited so a patch can never reach a subscribe() that
|
|
309
|
+
// starts after this set resolved.
|
|
310
|
+
const message = JSON.stringify({
|
|
311
|
+
src: instanceId,
|
|
312
|
+
participant: patch.participant,
|
|
313
|
+
values: patch.values,
|
|
314
|
+
seen: patch.seen,
|
|
315
|
+
at: patch.at.getTime(),
|
|
316
|
+
} satisfies PresenceWireMessage)
|
|
317
|
+
await client()
|
|
318
|
+
.then((c) => c.call('publish', channel, message))
|
|
319
|
+
.catch(() => {})
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
read: core.presence.read,
|
|
323
|
+
|
|
324
|
+
subscribe(ns, onPatch) {
|
|
325
|
+
const channel = presenceChannel(ns)
|
|
326
|
+
let listeners = presenceListeners.get(channel)
|
|
327
|
+
if (!listeners) {
|
|
328
|
+
listeners = new Set()
|
|
329
|
+
presenceListeners.set(channel, listeners)
|
|
330
|
+
}
|
|
331
|
+
listeners.add(onPatch)
|
|
332
|
+
// Fire-and-forget on the shared refcounted subscription: it
|
|
333
|
+
// only carries remote patches, so settling late (or failing)
|
|
334
|
+
// degrades cross-instance latency, never same-process
|
|
335
|
+
// delivery.
|
|
336
|
+
void acquireChannel(channel).catch(() => {})
|
|
337
|
+
let stopped = false
|
|
338
|
+
return () => {
|
|
339
|
+
if (stopped) return
|
|
340
|
+
stopped = true
|
|
341
|
+
listeners.delete(onPatch)
|
|
342
|
+
if (listeners.size === 0) presenceListeners.delete(channel)
|
|
343
|
+
releaseChannel(channel)
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
stream(sessionId, opts) {
|
|
349
|
+
const startAfter = opts?.startAfter ?? 0
|
|
350
|
+
const channel = notifyChannel(sessionId)
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
[Symbol.asyncIterator](): AsyncIterator<Event> {
|
|
354
|
+
let last = startAfter
|
|
355
|
+
let buffer: Event[] = []
|
|
356
|
+
let closed = false
|
|
357
|
+
let subscribed = false
|
|
358
|
+
let pending: Sleeper | null = null
|
|
359
|
+
let interrupt: (() => void) | null = null
|
|
360
|
+
|
|
361
|
+
const release = (): void => {
|
|
362
|
+
if (!subscribed) return
|
|
363
|
+
subscribed = false
|
|
364
|
+
releaseChannel(channel)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
async next(): Promise<IteratorResult<Event>> {
|
|
369
|
+
for (;;) {
|
|
370
|
+
if (closed) {
|
|
371
|
+
release()
|
|
372
|
+
return { value: undefined, done: true }
|
|
373
|
+
}
|
|
374
|
+
const row = buffer.shift()
|
|
375
|
+
if (row) {
|
|
376
|
+
last = row.index
|
|
377
|
+
return { value: row, done: false }
|
|
378
|
+
}
|
|
379
|
+
if (severed) {
|
|
380
|
+
release()
|
|
381
|
+
throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
|
|
382
|
+
}
|
|
383
|
+
const waker = armWaker(channel)
|
|
384
|
+
interrupt = waker.wake
|
|
385
|
+
try {
|
|
386
|
+
if (!subscribed) {
|
|
387
|
+
// Subscribe before reading: anything appended
|
|
388
|
+
// after the read lands as a wake-up, so nothing
|
|
389
|
+
// can slip between catch-up and park.
|
|
390
|
+
// oxlint-disable-next-line no-await-in-loop -- one-time setup
|
|
391
|
+
await acquireChannel(channel)
|
|
392
|
+
subscribed = true
|
|
393
|
+
}
|
|
394
|
+
// Mark watched before reading, for the same reason:
|
|
395
|
+
// a write after this read sees the marker and
|
|
396
|
+
// publishes. Throttled — one SET per safety period
|
|
397
|
+
// per session per process, shared across feeds.
|
|
398
|
+
const markedAt = watchedRefreshedAt.get(channel) ?? 0
|
|
399
|
+
if (Date.now() - markedAt >= NOTIFY_TIMINGS.safetyReadMs) {
|
|
400
|
+
watchedRefreshedAt.set(channel, Date.now())
|
|
401
|
+
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
402
|
+
await core.markWatched(
|
|
403
|
+
sessionId,
|
|
404
|
+
NOTIFY_TIMINGS.safetyReadMs * 3,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
408
|
+
buffer = await core.readEvents(sessionId, last)
|
|
409
|
+
if (buffer.length === 0 && !closed && !severed) {
|
|
410
|
+
pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
|
|
411
|
+
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
412
|
+
await Promise.race([waker.wakeup, pending.promise])
|
|
413
|
+
pending.cancel()
|
|
414
|
+
pending = null
|
|
415
|
+
}
|
|
416
|
+
} catch (err) {
|
|
417
|
+
release()
|
|
418
|
+
if (closed) return { value: undefined, done: true }
|
|
419
|
+
if (err instanceof A2Error) throw err
|
|
420
|
+
throw new A2Error(
|
|
421
|
+
'STORE_UNAVAILABLE',
|
|
422
|
+
'redis store operation failed',
|
|
423
|
+
{ cause: err },
|
|
424
|
+
)
|
|
425
|
+
} finally {
|
|
426
|
+
waker.disarm()
|
|
427
|
+
interrupt = null
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
async return(): Promise<IteratorResult<Event>> {
|
|
432
|
+
closed = true
|
|
433
|
+
pending?.cancel()
|
|
434
|
+
interrupt?.() // wakes an in-flight next() immediately
|
|
435
|
+
release()
|
|
436
|
+
return { value: undefined, done: true }
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
async close() {
|
|
444
|
+
severed = true
|
|
445
|
+
for (const set of wakers.values()) {
|
|
446
|
+
for (const wake of set) wake()
|
|
447
|
+
}
|
|
448
|
+
if (subscriber) {
|
|
449
|
+
const sub = await subscriber.catch(() => null)
|
|
450
|
+
sub?.disconnect()
|
|
451
|
+
}
|
|
452
|
+
const current = connection.peek()
|
|
453
|
+
if (!current) return
|
|
454
|
+
const c = await current.catch(() => null)
|
|
455
|
+
c?.disconnect()
|
|
456
|
+
},
|
|
457
|
+
}
|
|
458
|
+
}
|