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.
Files changed (182) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +4 -3
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +4 -2
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +1 -1
  11. package/dist/ai.js +3 -1
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +2 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.js +2 -0
  16. package/dist/cli-bin.js.map +1 -0
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
  20. package/dist/client-BKlyLiOU.js.map +1 -0
  21. package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
  22. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -2
  24. package/dist/client.js +1 -1
  25. package/dist/contract-48bUMgcL.js +2 -0
  26. package/dist/contract-48bUMgcL.js.map +1 -0
  27. package/dist/contract-jIfaR085.d.ts +2 -1
  28. package/dist/contract-jIfaR085.d.ts.map +1 -0
  29. package/dist/devtools-J_jZ2vQf.d.ts +2 -1
  30. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  31. package/dist/devtools-kJJaORn-.js +2 -0
  32. package/dist/devtools-kJJaORn-.js.map +1 -0
  33. package/dist/devtools-server.browser.js +2 -0
  34. package/dist/devtools-server.browser.js.map +1 -0
  35. package/dist/devtools-server.d.ts +2 -1
  36. package/dist/devtools-server.d.ts.map +1 -0
  37. package/dist/devtools-server.js +2 -0
  38. package/dist/devtools-server.js.map +1 -0
  39. package/dist/errors-BQuJpe82.js +2 -0
  40. package/dist/errors-BQuJpe82.js.map +1 -0
  41. package/dist/errors-W6nwJ-fm.d.ts +2 -1
  42. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  43. package/dist/http.d.ts +121 -72
  44. package/dist/http.d.ts.map +1 -0
  45. package/dist/http.js +503 -178
  46. package/dist/http.js.map +1 -0
  47. package/dist/idempotent-replay-DuqEkYA7.js +2 -0
  48. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/inspection-DaxB5jM2.js +2 -0
  51. package/dist/inspection-DaxB5jM2.js.map +1 -0
  52. package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
  53. package/dist/internal-DstsI6Re.js.map +1 -0
  54. package/dist/otel.d.ts +2 -1
  55. package/dist/otel.d.ts.map +1 -0
  56. package/dist/otel.js +2 -0
  57. package/dist/otel.js.map +1 -0
  58. package/dist/platform-B4TnJtWu.js +2 -0
  59. package/dist/platform-B4TnJtWu.js.map +1 -0
  60. package/dist/react.d.ts +12 -3
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +5 -1
  63. package/dist/react.js.map +1 -0
  64. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  65. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +4 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -0
  68. package/dist/scheduler-qstash.js +4 -2
  69. package/dist/scheduler-qstash.js.map +1 -0
  70. package/dist/scheduler-task-BpzhPnRS.js +2 -0
  71. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -3
  73. package/dist/scheduler-vercel.d.ts.map +1 -0
  74. package/dist/scheduler-vercel.js +4 -2
  75. package/dist/scheduler-vercel.js.map +1 -0
  76. package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
  77. package/dist/server-C72KOw51.js.map +1 -0
  78. package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
  79. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  80. package/dist/server.browser.js +2 -0
  81. package/dist/server.browser.js.map +1 -0
  82. package/dist/server.d.ts +2 -2
  83. package/dist/server.js +1 -1
  84. package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
  85. package/dist/store-DysUkTH3.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js +2 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -0
  88. package/dist/store-codec-DTG0Ftek.js +2 -0
  89. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  90. package/dist/store-memory.d.ts +3 -2
  91. package/dist/store-memory.d.ts.map +1 -0
  92. package/dist/store-memory.js +19 -11
  93. package/dist/store-memory.js.map +1 -0
  94. package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
  95. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  96. package/dist/store-postgres.d.ts +3 -2
  97. package/dist/store-postgres.d.ts.map +1 -0
  98. package/dist/store-postgres.js +57 -1
  99. package/dist/store-postgres.js.map +1 -0
  100. package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
  101. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  102. package/dist/store-redis-http.d.ts +3 -2
  103. package/dist/store-redis-http.d.ts.map +1 -0
  104. package/dist/store-redis-http.js +4 -2
  105. package/dist/store-redis-http.js.map +1 -0
  106. package/dist/store-redis.d.ts +3 -2
  107. package/dist/store-redis.d.ts.map +1 -0
  108. package/dist/store-redis.js +5 -3
  109. package/dist/store-redis.js.map +1 -0
  110. package/dist/store-sqlite.d.ts +3 -2
  111. package/dist/store-sqlite.d.ts.map +1 -0
  112. package/dist/store-sqlite.js +3 -1
  113. package/dist/store-sqlite.js.map +1 -0
  114. package/dist/telemetry-BjYHTfh2.d.ts +2 -1
  115. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  116. package/dist/testing.browser.js +2 -0
  117. package/dist/testing.browser.js.map +1 -0
  118. package/dist/testing.d.ts +2 -1
  119. package/dist/testing.d.ts.map +1 -0
  120. package/dist/testing.js +2 -0
  121. package/dist/testing.js.map +1 -0
  122. package/dist/validate-XKT4FSNn.js +2 -0
  123. package/dist/validate-XKT4FSNn.js.map +1 -0
  124. package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
  125. package/dist/wire-BFQmSJ-9.js.map +1 -0
  126. package/docs/guides/03-react.mdx +59 -39
  127. package/docs/guides/06-ai-agents.mdx +5 -27
  128. package/docs/guides/09-presence.mdx +19 -40
  129. package/docs/guides/10-transports.mdx +49 -40
  130. package/docs/reference/01-api.mdx +107 -26
  131. package/docs/reference/02-errors.mdx +4 -2
  132. package/package.json +2 -1
  133. package/src/ai-coordinator.ts +358 -0
  134. package/src/ai-projector.ts +524 -0
  135. package/src/ai-sdk-step.ts +261 -0
  136. package/src/ai-server.browser.ts +5 -0
  137. package/src/ai-server.ts +1719 -0
  138. package/src/ai.ts +2155 -0
  139. package/src/cache-indexeddb.ts +10 -0
  140. package/src/cli-bin.ts +5 -0
  141. package/src/cli.ts +1046 -0
  142. package/src/client.ts +1826 -0
  143. package/src/contract.ts +206 -0
  144. package/src/deterministic-id.ts +72 -0
  145. package/src/devtools-app.ts +989 -0
  146. package/src/devtools-server.browser.ts +5 -0
  147. package/src/devtools-server.ts +604 -0
  148. package/src/devtools.ts +716 -0
  149. package/src/errors.ts +50 -0
  150. package/src/http.ts +394 -0
  151. package/src/idempotent-replay.ts +53 -0
  152. package/src/index.ts +37 -0
  153. package/src/inspection.ts +39 -0
  154. package/src/internal.ts +426 -0
  155. package/src/otel.ts +59 -0
  156. package/src/platform.ts +60 -0
  157. package/src/push-envelope.ts +137 -0
  158. package/src/react.ts +284 -0
  159. package/src/reducer.ts +108 -0
  160. package/src/retryable-lazy.ts +27 -0
  161. package/src/scheduler-qstash.ts +915 -0
  162. package/src/scheduler-task.ts +106 -0
  163. package/src/scheduler-vercel.ts +437 -0
  164. package/src/server.browser.ts +12 -0
  165. package/src/server.ts +2700 -0
  166. package/src/session-socket.ts +548 -0
  167. package/src/sse.ts +141 -0
  168. package/src/standard-schema.ts +77 -0
  169. package/src/store-codec.ts +10 -0
  170. package/src/store-memory.ts +788 -0
  171. package/src/store-polling.ts +102 -0
  172. package/src/store-postgres.ts +1212 -0
  173. package/src/store-redis-core.ts +1494 -0
  174. package/src/store-redis-http.ts +116 -0
  175. package/src/store-redis.ts +458 -0
  176. package/src/store-sqlite.ts +1108 -0
  177. package/src/store.ts +385 -0
  178. package/src/telemetry.ts +47 -0
  179. package/src/testing.browser.ts +5 -0
  180. package/src/testing.ts +185 -0
  181. package/src/validate.ts +39 -0
  182. 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
+ }