experimental-a2 0.9.0 → 0.11.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 +53 -0
- package/dist/actor-D_54lz_1.d.ts +310 -0
- package/dist/actor-D_54lz_1.d.ts.map +1 -0
- package/dist/actor-client.d.ts +13 -4
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +63 -7
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -4
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +16 -2
- package/dist/actor-react.js.map +1 -1
- package/dist/actor.d.ts +2 -176
- package/dist/actor.js +13 -2
- package/dist/actor.js.map +1 -1
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/client-Bf6uSEAk.js +1342 -0
- package/dist/client-Bf6uSEAk.js.map +1 -0
- package/dist/client-P_NNNRM-.d.ts +243 -0
- package/dist/client-P_NNNRM-.d.ts.map +1 -0
- package/dist/client.d.ts +2 -202
- package/dist/client.js +2 -1026
- package/dist/errors-DCk6ch5n.js.map +1 -1
- package/dist/errors-DvhSXnxk.d.ts +28 -0
- package/dist/errors-DvhSXnxk.d.ts.map +1 -0
- package/dist/index.d.ts +3 -35
- package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
- package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -2
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
- package/dist/server-DwPrMqHB.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
- package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +1 -1
- package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
- package/dist/store-polling-CmxUbV93.js.map +1 -0
- package/dist/store-postgres.d.ts +6 -4
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +701 -53
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-C7-XZyW9.js +94 -0
- package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
- package/dist/store-redis-http.d.ts +2 -2
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +184 -38
- package/dist/store-redis-http.js.map +1 -1
- package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
- package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
- package/dist/store-redis.d.ts +5 -13
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +27 -271
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +1 -1
- package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
- package/dist/wire-BO5wWCb1.js.map +1 -0
- package/docs/actors/04-routes.mdx +40 -8
- package/docs/guides/03-react.mdx +52 -10
- package/docs/guides/05-production.mdx +54 -5
- package/docs/guides/09-presence.mdx +12 -7
- package/docs/guides/10-transports.mdx +85 -22
- package/docs/reference/01-api.mdx +90 -28
- package/docs/reference/02-errors.mdx +45 -11
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/actor-client.ts +114 -14
- package/src/actor-react.ts +18 -3
- package/src/actor.ts +16 -1
- package/src/client-errors.ts +51 -0
- package/src/client.ts +577 -175
- package/src/errors.ts +4 -9
- package/src/index.ts +1 -1
- package/src/internal.ts +1 -1
- package/src/postgres-notification-scope.ts +134 -0
- package/src/postgres-notifications.ts +244 -0
- package/src/postgres-pool.ts +65 -0
- package/src/postgres-resources.ts +185 -0
- package/src/presence-recovery.ts +120 -0
- package/src/react.ts +3 -0
- package/src/redis-http-subscriptions.ts +199 -0
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +146 -19
- package/src/session-socket.ts +241 -88
- package/src/sse.ts +17 -0
- package/src/store-polling.ts +32 -12
- package/src/store-postgres.ts +292 -75
- package/src/store-presence-polling.ts +125 -0
- package/src/store-redis-core.ts +37 -30
- package/src/store-redis-http.ts +51 -45
- package/src/store-redis-notify.ts +464 -0
- package/src/store-redis.ts +9 -364
- package/src/store.ts +3 -4
- package/src/stream-activity.ts +32 -0
- package/src/wire.ts +39 -15
- package/dist/actor-shared-BACubf4x.d.ts +0 -136
- package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
- package/dist/actor.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server-CBET-jSz.js.map +0 -1
- package/dist/server-CKY3_lbw.d.ts.map +0 -1
- package/dist/store-polling-6DW7F1DT.js.map +0 -1
- package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
- package/dist/wire--yji6mO3.js.map +0 -1
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import type { PresencePatch } from './contract.ts'
|
|
2
|
+
import { A2Error } from './errors.ts'
|
|
3
|
+
import { NOTIFY_TIMINGS } from './internal.ts'
|
|
4
|
+
import { defaultSleep, pollingStream, type Sleeper } from './store-polling.ts'
|
|
5
|
+
import { pollPresence } from './store-presence-polling.ts'
|
|
6
|
+
import { presenceRecovery } from './presence-recovery.ts'
|
|
7
|
+
import { createStreamActivity, streamActivities } from './stream-activity.ts'
|
|
8
|
+
import { createRedisStoreCore } from './store-redis-core.ts'
|
|
9
|
+
import type { A2Store, Clock, Event, IdSource } from './store.ts'
|
|
10
|
+
|
|
11
|
+
export type RedisConnection = {
|
|
12
|
+
call(command: string, ...args: Array<string | number>): Promise<unknown>
|
|
13
|
+
duplicate(): RedisConnection
|
|
14
|
+
on(
|
|
15
|
+
event: 'message',
|
|
16
|
+
listener: (channel: string, message: string) => void,
|
|
17
|
+
): unknown
|
|
18
|
+
disconnect(): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type PresenceWireMessage = {
|
|
22
|
+
src: string
|
|
23
|
+
participant: string
|
|
24
|
+
values: Record<string, unknown>
|
|
25
|
+
seen: number
|
|
26
|
+
at: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type PresenceSubscription = {
|
|
30
|
+
listeners: Set<(patch: PresencePatch) => void>
|
|
31
|
+
poll?: ReturnType<typeof pollPresence> | undefined
|
|
32
|
+
recovery?: ReturnType<typeof presenceRecovery> | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createRedisNotifyStore(options: {
|
|
36
|
+
client: () => Promise<RedisConnection>
|
|
37
|
+
closeClient: () => Promise<void>
|
|
38
|
+
clock: Clock
|
|
39
|
+
ids: IdSource
|
|
40
|
+
keyPrefix: string
|
|
41
|
+
onReconnect?: (listener: (channel: string) => void) => void
|
|
42
|
+
polling?: () => boolean
|
|
43
|
+
}): A2Store & { close(): Promise<void> } {
|
|
44
|
+
const { client, clock, ids, keyPrefix: prefix } = options
|
|
45
|
+
const notifyChannel = (sessionId: string): string =>
|
|
46
|
+
`${prefix}:${sessionId}:notify`
|
|
47
|
+
const presenceChannel = (ns: string): string => `${prefix}:${ns}:presence`
|
|
48
|
+
|
|
49
|
+
const instanceId = crypto.randomUUID()
|
|
50
|
+
const core = createRedisStoreCore({
|
|
51
|
+
call: async (command, ...args) => {
|
|
52
|
+
const c = await client()
|
|
53
|
+
return c.call(command, ...args)
|
|
54
|
+
},
|
|
55
|
+
clock,
|
|
56
|
+
ids,
|
|
57
|
+
keyPrefix: prefix,
|
|
58
|
+
presenceSource: instanceId,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
let severed = false
|
|
62
|
+
let subscriber: Promise<RedisConnection> | null = null
|
|
63
|
+
/** channel → parked-feed wake-ups, armed before each catch-up read. */
|
|
64
|
+
const wakers = new Map<string, Set<() => void>>()
|
|
65
|
+
/** channel → refcounted subscription held for each live iterator. */
|
|
66
|
+
const subscriptions = new Map<
|
|
67
|
+
string,
|
|
68
|
+
{ refs: number; ready: Promise<unknown> }
|
|
69
|
+
>()
|
|
70
|
+
/**
|
|
71
|
+
* channel → last presence-marker refresh, throttling the SET.
|
|
72
|
+
* Deliberately real time (`Date.now()`), not the injected clock:
|
|
73
|
+
* the marker's PX expiry runs on server real time, and the throttle
|
|
74
|
+
* must tick with the TTL it refreshes or time-traveling tests would
|
|
75
|
+
* desync the two. Process-local and never stored, so the injected
|
|
76
|
+
* clock's every-stored-timestamp pledge is untouched.
|
|
77
|
+
*/
|
|
78
|
+
const watched = new Map<string, { at: number; ready: Promise<void> }>()
|
|
79
|
+
const presenceSubscriptions = new Map<string, PresenceSubscription>()
|
|
80
|
+
const pollingStreams = new Set<AsyncIterator<Event>>()
|
|
81
|
+
|
|
82
|
+
options.onReconnect?.((channel) => {
|
|
83
|
+
for (const wake of wakers.get(channel) ?? []) wake()
|
|
84
|
+
void presenceSubscriptions
|
|
85
|
+
.get(channel)
|
|
86
|
+
?.recovery?.reconnect()
|
|
87
|
+
.catch(() => {})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const deliverRemotePresence = ({
|
|
91
|
+
subscription,
|
|
92
|
+
message,
|
|
93
|
+
}: {
|
|
94
|
+
subscription: PresenceSubscription
|
|
95
|
+
message: string
|
|
96
|
+
}): void => {
|
|
97
|
+
let parsed: PresenceWireMessage
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(message) as PresenceWireMessage
|
|
100
|
+
} catch {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (parsed.src === instanceId) return
|
|
104
|
+
const patch: PresencePatch = {
|
|
105
|
+
participant: parsed.participant,
|
|
106
|
+
values: parsed.values,
|
|
107
|
+
seen: parsed.seen,
|
|
108
|
+
at: new Date(parsed.at),
|
|
109
|
+
}
|
|
110
|
+
subscription.recovery?.update(patch)
|
|
111
|
+
for (const listener of subscription.listeners) listener(patch)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const getSubscriber = (): Promise<RedisConnection> => {
|
|
115
|
+
subscriber ??= (async () => {
|
|
116
|
+
const conn = (await client()).duplicate()
|
|
117
|
+
conn.on('message', (channel, message) => {
|
|
118
|
+
// wake() only resolves a promise; removal happens later via
|
|
119
|
+
// disarm(), so iterating the live set is safe.
|
|
120
|
+
const set = wakers.get(channel)
|
|
121
|
+
if (set) {
|
|
122
|
+
for (const wake of set) wake()
|
|
123
|
+
}
|
|
124
|
+
const subscription = presenceSubscriptions.get(channel)
|
|
125
|
+
if (subscription) deliverRemotePresence({ subscription, message })
|
|
126
|
+
})
|
|
127
|
+
return conn
|
|
128
|
+
})()
|
|
129
|
+
return subscriber
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The subscriber, gated on connection readiness — for channel
|
|
134
|
+
* commands only (`close` uses the ungated promise, so it can never
|
|
135
|
+
* hang on an unreachable server). SUBSCRIBE carries redis's
|
|
136
|
+
* ok-loading flag, so ioredis writes it mid-handshake, ahead of the
|
|
137
|
+
* ready check's INFO — which then finds the connection already in
|
|
138
|
+
* subscriber mode and fails the check (a spurious error event plus
|
|
139
|
+
* a reconnect). ECHO has no such flag: it resolves only once the
|
|
140
|
+
* connection is ready, so every channel command chained behind it
|
|
141
|
+
* lands after the check.
|
|
142
|
+
*/
|
|
143
|
+
let subscriberGate: Promise<RedisConnection> | null = null
|
|
144
|
+
const gatedSubscriber = (): Promise<RedisConnection> => {
|
|
145
|
+
subscriberGate ??= getSubscriber().then(async (conn) => {
|
|
146
|
+
await conn.call('echo', 'ready')
|
|
147
|
+
return conn
|
|
148
|
+
})
|
|
149
|
+
return subscriberGate
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const acquireChannel = async (channel: string): Promise<void> => {
|
|
153
|
+
const existing = subscriptions.get(channel)
|
|
154
|
+
if (existing) {
|
|
155
|
+
existing.refs += 1
|
|
156
|
+
try {
|
|
157
|
+
await existing.ready
|
|
158
|
+
} catch (err) {
|
|
159
|
+
existing.refs -= 1
|
|
160
|
+
throw err
|
|
161
|
+
}
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const lease: { refs: number; ready: Promise<void> } = {
|
|
165
|
+
refs: 1,
|
|
166
|
+
// Lowercase on purpose: ioredis keys its subscriber-mode
|
|
167
|
+
// bookkeeping (and reconnect resubscription) on the exact
|
|
168
|
+
// command name.
|
|
169
|
+
ready: gatedSubscriber().then(async (conn) => {
|
|
170
|
+
await conn.call('subscribe', channel)
|
|
171
|
+
if (subscriptions.get(channel) !== lease || severed) return
|
|
172
|
+
const presence = presenceSubscriptions.get(channel)
|
|
173
|
+
if (options.polling?.() && presence) {
|
|
174
|
+
presence.recovery = undefined
|
|
175
|
+
const ns = channel.slice(prefix.length + 1, -':presence'.length)
|
|
176
|
+
presence.poll = pollPresence({
|
|
177
|
+
read: () => core.presence.read(ns),
|
|
178
|
+
deliver: (patch) => {
|
|
179
|
+
for (const listener of presence.listeners) listener(patch)
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
await presence.poll.ready
|
|
183
|
+
}
|
|
184
|
+
return undefined
|
|
185
|
+
}),
|
|
186
|
+
}
|
|
187
|
+
subscriptions.set(channel, lease)
|
|
188
|
+
try {
|
|
189
|
+
await lease.ready
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (subscriptions.get(channel) === lease) {
|
|
192
|
+
subscriptions.delete(channel)
|
|
193
|
+
const presence = presenceSubscriptions.get(channel)
|
|
194
|
+
presence?.poll?.stop()
|
|
195
|
+
if (presence) presence.poll = undefined
|
|
196
|
+
}
|
|
197
|
+
throw err
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const releaseChannel = (channel: string): void => {
|
|
202
|
+
const lease = subscriptions.get(channel)
|
|
203
|
+
if (!lease) return
|
|
204
|
+
lease.refs -= 1
|
|
205
|
+
if (lease.refs > 0) return
|
|
206
|
+
subscriptions.delete(channel)
|
|
207
|
+
watched.delete(channel)
|
|
208
|
+
void gatedSubscriber()
|
|
209
|
+
.then((conn) => conn.call('unsubscribe', channel))
|
|
210
|
+
.catch(() => {})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const armWaker = (
|
|
214
|
+
channel: string,
|
|
215
|
+
): { wakeup: Promise<void>; wake: () => void; disarm: () => void } => {
|
|
216
|
+
let wake!: () => void
|
|
217
|
+
const wakeup = new Promise<void>((resolve) => {
|
|
218
|
+
wake = resolve // assigned synchronously by the executor
|
|
219
|
+
})
|
|
220
|
+
let set = wakers.get(channel)
|
|
221
|
+
if (!set) {
|
|
222
|
+
set = new Set()
|
|
223
|
+
wakers.set(channel, set)
|
|
224
|
+
}
|
|
225
|
+
set.add(wake)
|
|
226
|
+
return {
|
|
227
|
+
wakeup,
|
|
228
|
+
wake,
|
|
229
|
+
disarm: () => {
|
|
230
|
+
set.delete(wake)
|
|
231
|
+
if (set.size === 0) wakers.delete(channel)
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
append: core.append,
|
|
238
|
+
read: core.read,
|
|
239
|
+
claimAvailable: core.claimAvailable,
|
|
240
|
+
renewClaims: core.renewClaims,
|
|
241
|
+
completeAttempt: core.completeAttempt,
|
|
242
|
+
failAttempt: core.failAttempt,
|
|
243
|
+
readState: core.readState,
|
|
244
|
+
putSnapshots: core.putSnapshots,
|
|
245
|
+
|
|
246
|
+
presence: {
|
|
247
|
+
async set(ns, participant, values, meta) {
|
|
248
|
+
const patch = await core.presence.set(ns, participant, values, meta)
|
|
249
|
+
if (!patch) return
|
|
250
|
+
const channel = presenceChannel(ns)
|
|
251
|
+
const subscription = presenceSubscriptions.get(channel)
|
|
252
|
+
if (subscription) {
|
|
253
|
+
subscription.poll?.update(patch)
|
|
254
|
+
subscription.recovery?.update(patch)
|
|
255
|
+
for (const listener of subscription.listeners) listener(patch)
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async read(ns) {
|
|
260
|
+
const channel = presenceChannel(ns)
|
|
261
|
+
await subscriptions.get(channel)?.ready
|
|
262
|
+
return (
|
|
263
|
+
presenceSubscriptions.get(channel)?.recovery?.read() ??
|
|
264
|
+
core.presence.read(ns)
|
|
265
|
+
)
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
subscribe(ns, onPatch) {
|
|
269
|
+
const channel = presenceChannel(ns)
|
|
270
|
+
let subscription = presenceSubscriptions.get(channel)
|
|
271
|
+
if (!subscription) {
|
|
272
|
+
subscription = { listeners: new Set() }
|
|
273
|
+
presenceSubscriptions.set(channel, subscription)
|
|
274
|
+
if (options.onReconnect && !options.polling?.()) {
|
|
275
|
+
const current = subscription
|
|
276
|
+
subscription.recovery = presenceRecovery({
|
|
277
|
+
read: () => core.presence.read(ns),
|
|
278
|
+
deliver: (patch) => {
|
|
279
|
+
for (const listener of current.listeners) listener(patch)
|
|
280
|
+
},
|
|
281
|
+
isActive: () =>
|
|
282
|
+
!severed && presenceSubscriptions.get(channel) === current,
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
subscription.listeners.add(onPatch)
|
|
287
|
+
// Fire-and-forget on the shared refcounted subscription: it
|
|
288
|
+
// only carries remote patches, so settling late (or failing)
|
|
289
|
+
// degrades cross-instance latency, never same-process
|
|
290
|
+
// delivery.
|
|
291
|
+
void acquireChannel(channel).catch(() => {})
|
|
292
|
+
let stopped = false
|
|
293
|
+
return () => {
|
|
294
|
+
if (stopped) return
|
|
295
|
+
stopped = true
|
|
296
|
+
subscription.listeners.delete(onPatch)
|
|
297
|
+
if (subscription.listeners.size === 0) {
|
|
298
|
+
subscription.poll?.stop()
|
|
299
|
+
presenceSubscriptions.delete(channel)
|
|
300
|
+
}
|
|
301
|
+
releaseChannel(channel)
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
stream(sessionId, opts) {
|
|
307
|
+
const startAfter = opts?.startAfter ?? 0
|
|
308
|
+
const channel = notifyChannel(sessionId)
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
[Symbol.asyncIterator](): AsyncIterator<Event> {
|
|
312
|
+
let last = startAfter
|
|
313
|
+
let buffer: Event[] = []
|
|
314
|
+
let closed = false
|
|
315
|
+
let subscribed = false
|
|
316
|
+
let pending: Sleeper | null = null
|
|
317
|
+
let interrupt: (() => void) | null = null
|
|
318
|
+
let poller: AsyncIterator<Event> | undefined
|
|
319
|
+
let stopActivity: (() => void) | undefined
|
|
320
|
+
const activity = createStreamActivity(() => {
|
|
321
|
+
if (poller) streamActivities.get(poller)?.activate()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const release = (): void => {
|
|
325
|
+
stopActivity?.()
|
|
326
|
+
if (poller) {
|
|
327
|
+
void poller.return?.()
|
|
328
|
+
pollingStreams.delete(poller)
|
|
329
|
+
}
|
|
330
|
+
if (!subscribed) return
|
|
331
|
+
subscribed = false
|
|
332
|
+
releaseChannel(channel)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const iterator: AsyncIterator<Event> = {
|
|
336
|
+
async next(): Promise<IteratorResult<Event>> {
|
|
337
|
+
for (;;) {
|
|
338
|
+
if (closed) {
|
|
339
|
+
release()
|
|
340
|
+
return { value: undefined, done: true }
|
|
341
|
+
}
|
|
342
|
+
const row = buffer.shift()
|
|
343
|
+
if (row) {
|
|
344
|
+
last = row.index
|
|
345
|
+
return { value: row, done: false }
|
|
346
|
+
}
|
|
347
|
+
if (severed) {
|
|
348
|
+
release()
|
|
349
|
+
throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
|
|
350
|
+
}
|
|
351
|
+
const waker = armWaker(channel)
|
|
352
|
+
interrupt = waker.wake
|
|
353
|
+
try {
|
|
354
|
+
if (!subscribed) {
|
|
355
|
+
// Subscribe before reading: anything appended
|
|
356
|
+
// after the read lands as a wake-up, so nothing
|
|
357
|
+
// can slip between catch-up and park.
|
|
358
|
+
subscribed = true
|
|
359
|
+
// oxlint-disable-next-line no-await-in-loop -- one-time setup
|
|
360
|
+
await acquireChannel(channel)
|
|
361
|
+
if (closed || severed) continue
|
|
362
|
+
}
|
|
363
|
+
if (options.polling?.()) {
|
|
364
|
+
if (!poller) {
|
|
365
|
+
poller = pollingStream(
|
|
366
|
+
(after) => core.readEvents(sessionId, after),
|
|
367
|
+
{ startAfter: last },
|
|
368
|
+
)[Symbol.asyncIterator]()
|
|
369
|
+
pollingStreams.add(poller)
|
|
370
|
+
stopActivity = streamActivities
|
|
371
|
+
.get(poller)
|
|
372
|
+
?.subscribe(activity.setIdle)
|
|
373
|
+
}
|
|
374
|
+
// oxlint-disable-next-line no-await-in-loop -- delegate the fallback to the shared poller
|
|
375
|
+
const result = await poller.next()
|
|
376
|
+
if (!result.done)
|
|
377
|
+
presenceSubscriptions
|
|
378
|
+
.get(presenceChannel(sessionId))
|
|
379
|
+
?.poll?.wake()
|
|
380
|
+
if (severed)
|
|
381
|
+
throw new A2Error(
|
|
382
|
+
'STORE_UNAVAILABLE',
|
|
383
|
+
'the store was closed',
|
|
384
|
+
)
|
|
385
|
+
return result
|
|
386
|
+
}
|
|
387
|
+
// Mark watched before reading, for the same reason:
|
|
388
|
+
// a write after this read sees the marker and
|
|
389
|
+
// publishes. Throttled — one SET per safety period
|
|
390
|
+
// per session per process, shared across feeds.
|
|
391
|
+
let marker = watched.get(channel)
|
|
392
|
+
if (
|
|
393
|
+
!marker ||
|
|
394
|
+
Date.now() - marker.at >= NOTIFY_TIMINGS.safetyReadMs
|
|
395
|
+
) {
|
|
396
|
+
marker = {
|
|
397
|
+
at: Date.now(),
|
|
398
|
+
ready: core.markWatched(
|
|
399
|
+
sessionId,
|
|
400
|
+
NOTIFY_TIMINGS.safetyReadMs * 3,
|
|
401
|
+
),
|
|
402
|
+
}
|
|
403
|
+
watched.set(channel, marker)
|
|
404
|
+
}
|
|
405
|
+
// Every viewer waits until writers can observe the marker.
|
|
406
|
+
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
407
|
+
await marker.ready
|
|
408
|
+
if (closed || severed) continue
|
|
409
|
+
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
410
|
+
buffer = await core.readEvents(sessionId, last)
|
|
411
|
+
if (buffer.length === 0 && !closed && !severed) {
|
|
412
|
+
pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
|
|
413
|
+
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
414
|
+
await Promise.race([waker.wakeup, pending.promise])
|
|
415
|
+
pending.cancel()
|
|
416
|
+
pending = null
|
|
417
|
+
}
|
|
418
|
+
} catch (err) {
|
|
419
|
+
release()
|
|
420
|
+
if (closed) return { value: undefined, done: true }
|
|
421
|
+
if (err instanceof A2Error) throw err
|
|
422
|
+
throw new A2Error(
|
|
423
|
+
'STORE_UNAVAILABLE',
|
|
424
|
+
'redis store operation failed',
|
|
425
|
+
{ cause: err },
|
|
426
|
+
)
|
|
427
|
+
} finally {
|
|
428
|
+
waker.disarm()
|
|
429
|
+
interrupt = null
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
async return(): Promise<IteratorResult<Event>> {
|
|
434
|
+
closed = true
|
|
435
|
+
pending?.cancel()
|
|
436
|
+
interrupt?.() // wakes an in-flight next() immediately
|
|
437
|
+
release()
|
|
438
|
+
return { value: undefined, done: true }
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
streamActivities.set(iterator, activity)
|
|
442
|
+
return iterator
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
async close() {
|
|
448
|
+
severed = true
|
|
449
|
+
for (const subscription of presenceSubscriptions.values())
|
|
450
|
+
subscription.poll?.stop()
|
|
451
|
+
presenceSubscriptions.clear()
|
|
452
|
+
for (const poller of pollingStreams) void poller.return?.()
|
|
453
|
+
pollingStreams.clear()
|
|
454
|
+
for (const set of wakers.values()) {
|
|
455
|
+
for (const wake of set) wake()
|
|
456
|
+
}
|
|
457
|
+
if (subscriber) {
|
|
458
|
+
const sub = await subscriber.catch(() => null)
|
|
459
|
+
sub?.disconnect()
|
|
460
|
+
}
|
|
461
|
+
await options.closeClient()
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
}
|