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.
Files changed (121) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/actor-D_54lz_1.d.ts +310 -0
  3. package/dist/actor-D_54lz_1.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +13 -4
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +63 -7
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -4
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +16 -2
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/actor.d.ts +2 -176
  13. package/dist/actor.js +13 -2
  14. package/dist/actor.js.map +1 -1
  15. package/dist/ai-server.d.ts +1 -1
  16. package/dist/ai-server.js +2 -2
  17. package/dist/ai.d.ts +1 -1
  18. package/dist/ai.js +1 -1
  19. package/dist/client-Bf6uSEAk.js +1342 -0
  20. package/dist/client-Bf6uSEAk.js.map +1 -0
  21. package/dist/client-P_NNNRM-.d.ts +243 -0
  22. package/dist/client-P_NNNRM-.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -208
  24. package/dist/client.js +2 -1206
  25. package/dist/errors-DCk6ch5n.js.map +1 -1
  26. package/dist/errors-DvhSXnxk.d.ts +28 -0
  27. package/dist/errors-DvhSXnxk.d.ts.map +1 -0
  28. package/dist/index.d.ts +3 -35
  29. package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
  30. package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
  31. package/dist/platform-B4TnJtWu.js +34 -0
  32. package/dist/platform-B4TnJtWu.js.map +1 -0
  33. package/dist/react.d.ts +3 -1
  34. package/dist/react.d.ts.map +1 -1
  35. package/dist/react.js +2 -1
  36. package/dist/react.js.map +1 -1
  37. package/dist/scheduler-qstash.d.ts +2 -2
  38. package/dist/scheduler-qstash.js +3 -2
  39. package/dist/scheduler-qstash.js.map +1 -1
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +2 -2
  42. package/dist/{server-DlLyvaSH.js → server-Dkz2a84E.js} +165 -62
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-DgCrSuhB.d.ts → server-DwPrMqHB.d.ts} +2 -2
  45. package/dist/{server-DgCrSuhB.d.ts.map → server-DwPrMqHB.d.ts.map} +1 -1
  46. package/dist/server.d.ts +2 -2
  47. package/dist/server.js +1 -1
  48. package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
  49. package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
  50. package/dist/store-N8PXxDAS.js.map +1 -1
  51. package/dist/store-memory.d.ts +1 -1
  52. package/dist/store-memory.js +1 -1
  53. package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
  54. package/dist/store-polling-CmxUbV93.js.map +1 -0
  55. package/dist/store-postgres.d.ts +6 -4
  56. package/dist/store-postgres.d.ts.map +1 -1
  57. package/dist/store-postgres.js +701 -53
  58. package/dist/store-postgres.js.map +1 -1
  59. package/dist/store-presence-polling-C7-XZyW9.js +94 -0
  60. package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
  61. package/dist/store-redis-http.d.ts +2 -2
  62. package/dist/store-redis-http.d.ts.map +1 -1
  63. package/dist/store-redis-http.js +184 -38
  64. package/dist/store-redis-http.js.map +1 -1
  65. package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
  66. package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
  67. package/dist/store-redis.d.ts +5 -13
  68. package/dist/store-redis.d.ts.map +1 -1
  69. package/dist/store-redis.js +27 -271
  70. package/dist/store-redis.js.map +1 -1
  71. package/dist/store-sqlite.d.ts +1 -1
  72. package/dist/store-sqlite.js +1 -1
  73. package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
  74. package/dist/wire-BO5wWCb1.js.map +1 -0
  75. package/docs/actors/04-routes.mdx +40 -8
  76. package/docs/guides/03-react.mdx +52 -10
  77. package/docs/guides/05-production.mdx +54 -5
  78. package/docs/guides/09-presence.mdx +12 -7
  79. package/docs/guides/10-transports.mdx +15 -2
  80. package/docs/reference/01-api.mdx +60 -16
  81. package/docs/reference/02-errors.mdx +45 -11
  82. package/examples/playground/package.json +1 -1
  83. package/package.json +1 -1
  84. package/src/actor-client.ts +114 -14
  85. package/src/actor-react.ts +18 -3
  86. package/src/actor.ts +16 -1
  87. package/src/client-errors.ts +51 -0
  88. package/src/client.ts +253 -96
  89. package/src/errors.ts +4 -9
  90. package/src/index.ts +1 -1
  91. package/src/internal.ts +1 -1
  92. package/src/postgres-notification-scope.ts +134 -0
  93. package/src/postgres-notifications.ts +244 -0
  94. package/src/postgres-pool.ts +65 -0
  95. package/src/postgres-resources.ts +185 -0
  96. package/src/presence-recovery.ts +120 -0
  97. package/src/react.ts +3 -0
  98. package/src/redis-http-subscriptions.ts +199 -0
  99. package/src/server.ts +146 -19
  100. package/src/session-socket.ts +25 -7
  101. package/src/sse.ts +17 -0
  102. package/src/store-polling.ts +32 -12
  103. package/src/store-postgres.ts +292 -75
  104. package/src/store-presence-polling.ts +125 -0
  105. package/src/store-redis-core.ts +37 -30
  106. package/src/store-redis-http.ts +51 -45
  107. package/src/store-redis-notify.ts +464 -0
  108. package/src/store-redis.ts +9 -364
  109. package/src/store.ts +3 -4
  110. package/src/stream-activity.ts +32 -0
  111. package/src/wire.ts +39 -15
  112. package/dist/actor-shared-USo5MyuF.d.ts +0 -136
  113. package/dist/actor-shared-USo5MyuF.d.ts.map +0 -1
  114. package/dist/actor.d.ts.map +0 -1
  115. package/dist/client.d.ts.map +0 -1
  116. package/dist/client.js.map +0 -1
  117. package/dist/index.d.ts.map +0 -1
  118. package/dist/server-DlLyvaSH.js.map +0 -1
  119. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  120. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  121. package/dist/wire--yji6mO3.js.map +0 -1
@@ -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 { NOTIFY_TIMINGS } from './internal.ts'
27
- import { defaultSleep, type Sleeper } from './store-polling.ts'
28
- import { createRedisStoreCore } from './store-redis-core.ts'
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
- 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
- },
90
+ return createRedisNotifyStore({
91
+ client,
118
92
  clock,
119
93
  ids,
120
94
  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
- 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
- * Push-tier patch delivery; present only on backends with a real
325
- * broadcast primitive. Without it the backend is the degraded
326
- * tier: live feeds surface presence by re-reading on their
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: A2ErrorCode; message: string; details?: unknown }
174
+ error: { code: A2ServerErrorCode; message: string; details?: unknown }
175
175
  }
176
176
 
177
- const ERROR_STATUS: Record<A2ErrorCode, number> = {
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: { code: error.code, message: error.message },
197
+ error: {
198
+ code: failure.code as A2ServerErrorCode,
199
+ message: failure.message,
200
+ },
197
201
  }
198
- if (error.details !== undefined) body.error.details = error.details
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: A2Errors pass through,
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' || !Object.hasOwn(ERROR_STATUS, code))
219
- return null
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
- | { kind: 'unsubscribed'; sessionId: string; reason?: string }
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
- return typeof reason === 'string'
421
- ? { kind: 'unsubscribed', sessionId, reason }
422
- : { kind: 'unsubscribed', sessionId }
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']