experimental-a2 0.10.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 +39 -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 -208
- package/dist/client.js +2 -1206
- 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-DlLyvaSH.js → server-Dkz2a84E.js} +165 -62
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-DgCrSuhB.d.ts → server-DwPrMqHB.d.ts} +2 -2
- package/dist/{server-DgCrSuhB.d.ts.map → server-DwPrMqHB.d.ts.map} +1 -1
- 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 +15 -2
- package/docs/reference/01-api.mdx +60 -16
- 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 +253 -96
- 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.ts +146 -19
- package/src/session-socket.ts +25 -7
- 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-USo5MyuF.d.ts +0 -136
- package/dist/actor-shared-USo5MyuF.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-DlLyvaSH.js.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
package/src/store-redis.ts
CHANGED
|
@@ -21,18 +21,17 @@
|
|
|
21
21
|
* `call`/`duplicate`/`on`/`disconnect`.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import type { PresencePatch } from './contract.ts'
|
|
25
24
|
import { A2Error } from './errors.ts'
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
import {
|
|
26
|
+
createRedisNotifyStore,
|
|
27
|
+
type RedisConnection,
|
|
28
|
+
} from './store-redis-notify.ts'
|
|
29
29
|
import { retryableLazy } from './retryable-lazy.ts'
|
|
30
30
|
import {
|
|
31
31
|
RANDOM_IDS,
|
|
32
32
|
SYSTEM_CLOCK,
|
|
33
33
|
type A2Store,
|
|
34
34
|
type Clock,
|
|
35
|
-
type Event,
|
|
36
35
|
type IdSource,
|
|
37
36
|
} from './store.ts'
|
|
38
37
|
|
|
@@ -46,15 +45,7 @@ import {
|
|
|
46
45
|
* re-read delivers everything — but every live feed silently degrades
|
|
47
46
|
* to safety-read latency from that point on.
|
|
48
47
|
*/
|
|
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
|
-
}
|
|
48
|
+
export type { RedisConnection } from './store-redis-notify.ts'
|
|
58
49
|
|
|
59
50
|
export type RedisStoreOptions = {
|
|
60
51
|
/** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */
|
|
@@ -74,16 +65,6 @@ export type RedisStore = A2Store & {
|
|
|
74
65
|
close(): Promise<void>
|
|
75
66
|
}
|
|
76
67
|
|
|
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
68
|
export function redis(options: RedisStoreOptions = {}): RedisStore {
|
|
88
69
|
const clock = options.clock ?? SYSTEM_CLOCK
|
|
89
70
|
const ids = options.ids ?? RANDOM_IDS
|
|
@@ -106,352 +87,16 @@ export function redis(options: RedisStoreOptions = {}): RedisStore {
|
|
|
106
87
|
})
|
|
107
88
|
const client = connection.get
|
|
108
89
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
},
|
|
90
|
+
return createRedisNotifyStore({
|
|
91
|
+
client,
|
|
118
92
|
clock,
|
|
119
93
|
ids,
|
|
120
94
|
keyPrefix: prefix,
|
|
121
|
-
|
|
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
|
-
putSnapshots: core.putSnapshots,
|
|
295
|
-
|
|
296
|
-
presence: {
|
|
297
|
-
async set(ns, participant, values, meta) {
|
|
298
|
-
const patch = await core.presence.set(ns, participant, values, meta)
|
|
299
|
-
if (!patch) return
|
|
300
|
-
const channel = presenceChannel(ns)
|
|
301
|
-
const listeners = presenceListeners.get(channel)
|
|
302
|
-
if (listeners) {
|
|
303
|
-
for (const listener of listeners) listener(patch)
|
|
304
|
-
}
|
|
305
|
-
// Broadcast for other instances — best-effort like the notify
|
|
306
|
-
// PUBLISH (a failed one must never fail the applied write),
|
|
307
|
-
// but awaited so a patch can never reach a subscribe() that
|
|
308
|
-
// starts after this set resolved.
|
|
309
|
-
const message = JSON.stringify({
|
|
310
|
-
src: instanceId,
|
|
311
|
-
participant: patch.participant,
|
|
312
|
-
values: patch.values,
|
|
313
|
-
seen: patch.seen,
|
|
314
|
-
at: patch.at.getTime(),
|
|
315
|
-
} satisfies PresenceWireMessage)
|
|
316
|
-
await client()
|
|
317
|
-
.then((c) => c.call('publish', channel, message))
|
|
318
|
-
.catch(() => {})
|
|
319
|
-
},
|
|
320
|
-
|
|
321
|
-
read: core.presence.read,
|
|
322
|
-
|
|
323
|
-
subscribe(ns, onPatch) {
|
|
324
|
-
const channel = presenceChannel(ns)
|
|
325
|
-
let listeners = presenceListeners.get(channel)
|
|
326
|
-
if (!listeners) {
|
|
327
|
-
listeners = new Set()
|
|
328
|
-
presenceListeners.set(channel, listeners)
|
|
329
|
-
}
|
|
330
|
-
listeners.add(onPatch)
|
|
331
|
-
// Fire-and-forget on the shared refcounted subscription: it
|
|
332
|
-
// only carries remote patches, so settling late (or failing)
|
|
333
|
-
// degrades cross-instance latency, never same-process
|
|
334
|
-
// delivery.
|
|
335
|
-
void acquireChannel(channel).catch(() => {})
|
|
336
|
-
let stopped = false
|
|
337
|
-
return () => {
|
|
338
|
-
if (stopped) return
|
|
339
|
-
stopped = true
|
|
340
|
-
listeners.delete(onPatch)
|
|
341
|
-
if (listeners.size === 0) presenceListeners.delete(channel)
|
|
342
|
-
releaseChannel(channel)
|
|
343
|
-
}
|
|
344
|
-
},
|
|
345
|
-
},
|
|
346
|
-
|
|
347
|
-
stream(sessionId, opts) {
|
|
348
|
-
const startAfter = opts?.startAfter ?? 0
|
|
349
|
-
const channel = notifyChannel(sessionId)
|
|
350
|
-
|
|
351
|
-
return {
|
|
352
|
-
[Symbol.asyncIterator](): AsyncIterator<Event> {
|
|
353
|
-
let last = startAfter
|
|
354
|
-
let buffer: Event[] = []
|
|
355
|
-
let closed = false
|
|
356
|
-
let subscribed = false
|
|
357
|
-
let pending: Sleeper | null = null
|
|
358
|
-
let interrupt: (() => void) | null = null
|
|
359
|
-
|
|
360
|
-
const release = (): void => {
|
|
361
|
-
if (!subscribed) return
|
|
362
|
-
subscribed = false
|
|
363
|
-
releaseChannel(channel)
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
return {
|
|
367
|
-
async next(): Promise<IteratorResult<Event>> {
|
|
368
|
-
for (;;) {
|
|
369
|
-
if (closed) {
|
|
370
|
-
release()
|
|
371
|
-
return { value: undefined, done: true }
|
|
372
|
-
}
|
|
373
|
-
const row = buffer.shift()
|
|
374
|
-
if (row) {
|
|
375
|
-
last = row.index
|
|
376
|
-
return { value: row, done: false }
|
|
377
|
-
}
|
|
378
|
-
if (severed) {
|
|
379
|
-
release()
|
|
380
|
-
throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
|
|
381
|
-
}
|
|
382
|
-
const waker = armWaker(channel)
|
|
383
|
-
interrupt = waker.wake
|
|
384
|
-
try {
|
|
385
|
-
if (!subscribed) {
|
|
386
|
-
// Subscribe before reading: anything appended
|
|
387
|
-
// after the read lands as a wake-up, so nothing
|
|
388
|
-
// can slip between catch-up and park.
|
|
389
|
-
// oxlint-disable-next-line no-await-in-loop -- one-time setup
|
|
390
|
-
await acquireChannel(channel)
|
|
391
|
-
subscribed = true
|
|
392
|
-
}
|
|
393
|
-
// Mark watched before reading, for the same reason:
|
|
394
|
-
// a write after this read sees the marker and
|
|
395
|
-
// publishes. Throttled — one SET per safety period
|
|
396
|
-
// per session per process, shared across feeds.
|
|
397
|
-
const markedAt = watchedRefreshedAt.get(channel) ?? 0
|
|
398
|
-
if (Date.now() - markedAt >= NOTIFY_TIMINGS.safetyReadMs) {
|
|
399
|
-
watchedRefreshedAt.set(channel, Date.now())
|
|
400
|
-
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
401
|
-
await core.markWatched(
|
|
402
|
-
sessionId,
|
|
403
|
-
NOTIFY_TIMINGS.safetyReadMs * 3,
|
|
404
|
-
)
|
|
405
|
-
}
|
|
406
|
-
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
407
|
-
buffer = await core.readEvents(sessionId, last)
|
|
408
|
-
if (buffer.length === 0 && !closed && !severed) {
|
|
409
|
-
pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
|
|
410
|
-
// oxlint-disable-next-line no-await-in-loop -- live-feed loop
|
|
411
|
-
await Promise.race([waker.wakeup, pending.promise])
|
|
412
|
-
pending.cancel()
|
|
413
|
-
pending = null
|
|
414
|
-
}
|
|
415
|
-
} catch (err) {
|
|
416
|
-
release()
|
|
417
|
-
if (closed) return { value: undefined, done: true }
|
|
418
|
-
if (err instanceof A2Error) throw err
|
|
419
|
-
throw new A2Error(
|
|
420
|
-
'STORE_UNAVAILABLE',
|
|
421
|
-
'redis store operation failed',
|
|
422
|
-
{ cause: err },
|
|
423
|
-
)
|
|
424
|
-
} finally {
|
|
425
|
-
waker.disarm()
|
|
426
|
-
interrupt = null
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
},
|
|
430
|
-
async return(): Promise<IteratorResult<Event>> {
|
|
431
|
-
closed = true
|
|
432
|
-
pending?.cancel()
|
|
433
|
-
interrupt?.() // wakes an in-flight next() immediately
|
|
434
|
-
release()
|
|
435
|
-
return { value: undefined, done: true }
|
|
436
|
-
},
|
|
437
|
-
}
|
|
438
|
-
},
|
|
439
|
-
}
|
|
440
|
-
},
|
|
441
|
-
|
|
442
|
-
async close() {
|
|
443
|
-
severed = true
|
|
444
|
-
for (const set of wakers.values()) {
|
|
445
|
-
for (const wake of set) wake()
|
|
446
|
-
}
|
|
447
|
-
if (subscriber) {
|
|
448
|
-
const sub = await subscriber.catch(() => null)
|
|
449
|
-
sub?.disconnect()
|
|
450
|
-
}
|
|
95
|
+
async closeClient() {
|
|
451
96
|
const current = connection.peek()
|
|
452
97
|
if (!current) return
|
|
453
98
|
const c = await current.catch(() => null)
|
|
454
99
|
c?.disconnect()
|
|
455
100
|
},
|
|
456
|
-
}
|
|
101
|
+
})
|
|
457
102
|
}
|
package/src/store.ts
CHANGED
|
@@ -321,10 +321,9 @@ export interface A2Store {
|
|
|
321
321
|
read(ns: string): Promise<PresenceRow[]>
|
|
322
322
|
|
|
323
323
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* existing poll cadence.
|
|
324
|
+
* Adapter-managed patch delivery. Without it, live feeds re-read
|
|
325
|
+
* presence on their poll cadence. HTTP Redis uses adaptive polling
|
|
326
|
+
* here when its endpoint does not support subscriptions.
|
|
328
327
|
*/
|
|
329
328
|
subscribe?(ns: string, onPatch: (patch: PresencePatch) => void): () => void
|
|
330
329
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type StreamActivity = {
|
|
2
|
+
readonly idle: boolean
|
|
3
|
+
setIdle(idle: boolean): void
|
|
4
|
+
activate(): void
|
|
5
|
+
subscribe(listener: (idle: boolean) => void): () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const streamActivities: WeakMap<object, StreamActivity> = new WeakMap()
|
|
9
|
+
|
|
10
|
+
export function createStreamActivity(wake: () => void): StreamActivity {
|
|
11
|
+
let idle = false
|
|
12
|
+
const listeners = new Set<(idle: boolean) => void>()
|
|
13
|
+
const setIdle = (next: boolean): void => {
|
|
14
|
+
if (idle === next) return
|
|
15
|
+
idle = next
|
|
16
|
+
for (const listener of listeners) listener(idle)
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
get idle() {
|
|
20
|
+
return idle
|
|
21
|
+
},
|
|
22
|
+
setIdle,
|
|
23
|
+
activate() {
|
|
24
|
+
setIdle(false)
|
|
25
|
+
wake()
|
|
26
|
+
},
|
|
27
|
+
subscribe(listener) {
|
|
28
|
+
listeners.add(listener)
|
|
29
|
+
return () => listeners.delete(listener)
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/wire.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* public.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { A2Error, type A2ErrorCode } from './errors.ts'
|
|
8
|
+
import { A2Error, type A2ErrorCode, type A2ServerErrorCode } from './errors.ts'
|
|
9
9
|
import { nullProtoRecord } from './internal.ts'
|
|
10
10
|
import type { Event } from './store.ts'
|
|
11
11
|
import type {
|
|
@@ -171,10 +171,10 @@ export function isWirePresenceSnapshot(
|
|
|
171
171
|
// ── the A2Error envelope ─────────────────────────────────────────────
|
|
172
172
|
|
|
173
173
|
export type WireError = {
|
|
174
|
-
error: { code:
|
|
174
|
+
error: { code: A2ServerErrorCode; message: string; details?: unknown }
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
const ERROR_STATUS: Record<
|
|
177
|
+
const ERROR_STATUS: Record<A2ServerErrorCode, number> = {
|
|
178
178
|
INVALID_PAYLOAD: 400,
|
|
179
179
|
FORBIDDEN: 403,
|
|
180
180
|
UNKNOWN_EVENT_TYPE: 400,
|
|
@@ -188,23 +188,27 @@ const ERROR_STATUS: Record<A2ErrorCode, number> = {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
export function errorStatus(code: A2ErrorCode): number {
|
|
191
|
-
return ERROR_STATUS[code]
|
|
191
|
+
return ERROR_STATUS[isServerErrorCode(code) ? code : 'STORE_UNAVAILABLE']
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
export function errorToWire(error: A2Error): WireError {
|
|
195
|
+
const failure = asA2Error(error)
|
|
195
196
|
const body: WireError = {
|
|
196
|
-
error: {
|
|
197
|
+
error: {
|
|
198
|
+
code: failure.code as A2ServerErrorCode,
|
|
199
|
+
message: failure.message,
|
|
200
|
+
},
|
|
197
201
|
}
|
|
198
|
-
if (
|
|
202
|
+
if (failure.details !== undefined) body.error.details = failure.details
|
|
199
203
|
return body
|
|
200
204
|
}
|
|
201
205
|
|
|
202
|
-
/** Wrap an arbitrary thrown value for the wire:
|
|
206
|
+
/** Wrap an arbitrary thrown value for the wire: known server codes pass through,
|
|
203
207
|
* anything else becomes STORE_UNAVAILABLE — from the client's
|
|
204
208
|
* perspective an unknown server failure is retryable-once, not a
|
|
205
209
|
* protocol contract. */
|
|
206
210
|
export function asA2Error(error: unknown): A2Error {
|
|
207
|
-
return error instanceof A2Error
|
|
211
|
+
return error instanceof A2Error && isServerErrorCode(error.code)
|
|
208
212
|
? error
|
|
209
213
|
: new A2Error('STORE_UNAVAILABLE', 'internal error', { cause: error })
|
|
210
214
|
}
|
|
@@ -215,13 +219,15 @@ export function errorFromWire(body: unknown): A2Error | null {
|
|
|
215
219
|
const err = (body as { error?: unknown }).error
|
|
216
220
|
if (err === null || typeof err !== 'object') return null
|
|
217
221
|
const { code, message, details } = err as Record<string, unknown>
|
|
218
|
-
if (typeof code !== 'string' || !
|
|
219
|
-
|
|
220
|
-
return new A2Error(code as A2ErrorCode, String(message ?? code), {
|
|
222
|
+
if (typeof code !== 'string' || !isServerErrorCode(code)) return null
|
|
223
|
+
return new A2Error(code, String(message ?? code), {
|
|
221
224
|
details,
|
|
222
225
|
})
|
|
223
226
|
}
|
|
224
227
|
|
|
228
|
+
const isServerErrorCode = (code: string): code is A2ServerErrorCode =>
|
|
229
|
+
Object.hasOwn(ERROR_STATUS, code)
|
|
230
|
+
|
|
225
231
|
// ── the ws frame layer ───────────────────────────────────────────────
|
|
226
232
|
// The SSE lanes reframed for a socket (specs/a2-api.md §13): every
|
|
227
233
|
// message is one JSON text frame, and a frame is its wire payload plus
|
|
@@ -254,7 +260,12 @@ export type SocketDownFrame =
|
|
|
254
260
|
| { kind: 'ack'; req: number; events: WireEvent[]; sessionId?: string }
|
|
255
261
|
| { kind: 'ack'; req: number; error: A2Error; sessionId?: string }
|
|
256
262
|
| { kind: 'subscribed'; sessionId: string }
|
|
257
|
-
| {
|
|
263
|
+
| {
|
|
264
|
+
kind: 'unsubscribed'
|
|
265
|
+
sessionId: string
|
|
266
|
+
reason?: string
|
|
267
|
+
error?: A2Error
|
|
268
|
+
}
|
|
258
269
|
| { kind: 'ping' }
|
|
259
270
|
|
|
260
271
|
/**
|
|
@@ -361,11 +372,13 @@ export function socketSubscribedFor(sessionId: string): string {
|
|
|
361
372
|
export function socketUnsubscribedFor(
|
|
362
373
|
sessionId: string,
|
|
363
374
|
reason?: string,
|
|
375
|
+
error?: A2Error,
|
|
364
376
|
): string {
|
|
365
377
|
return JSON.stringify({
|
|
366
378
|
kind: 'unsubscribed',
|
|
367
379
|
sessionId,
|
|
368
380
|
...(reason === undefined ? {} : { reason }),
|
|
381
|
+
...(error === undefined ? {} : errorToWire(error)),
|
|
369
382
|
})
|
|
370
383
|
}
|
|
371
384
|
|
|
@@ -417,9 +430,20 @@ export function parseSocketFrame(data: string): SocketDownFrame | null {
|
|
|
417
430
|
const sessionId = frame['sessionId']
|
|
418
431
|
if (typeof sessionId !== 'string') return null
|
|
419
432
|
const reason = frame['reason']
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
433
|
+
const error =
|
|
434
|
+
errorFromWire(parsed) ??
|
|
435
|
+
('error' in frame
|
|
436
|
+
? new A2Error(
|
|
437
|
+
'STORE_UNAVAILABLE',
|
|
438
|
+
'unintelligible subscription error',
|
|
439
|
+
)
|
|
440
|
+
: null)
|
|
441
|
+
return {
|
|
442
|
+
kind: 'unsubscribed',
|
|
443
|
+
sessionId,
|
|
444
|
+
...(typeof reason === 'string' ? { reason } : {}),
|
|
445
|
+
...(error === null ? {} : { error }),
|
|
446
|
+
}
|
|
423
447
|
}
|
|
424
448
|
case 'ack': {
|
|
425
449
|
const req = frame['req']
|