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
package/src/client.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  // oxlint-disable no-await-in-loop -- retry/backoff/stream loops are sequential by nature
17
- import { A2Error } from './errors.ts'
17
+ import { A2ClientError, asClientError } from './client-errors.ts'
18
18
  import {
19
19
  PRESENCE_TIMINGS,
20
20
  RESERVED_PARTICIPANT_IDS,
@@ -51,7 +51,17 @@ import {
51
51
  type WirePresenceSnapshot,
52
52
  } from './wire.ts'
53
53
 
54
- export type ConnectionStatus = 'idle' | 'connecting' | 'live' | 'closed'
54
+ export { A2ClientError } from './client-errors.ts'
55
+ export type { A2ClientErrorOptions } from './client-errors.ts'
56
+
57
+ export type ReconnectPolicy = (failure: {
58
+ sessionId: string
59
+ error: A2ClientError | null
60
+ attempt: number
61
+ }) => number | false
62
+
63
+ export type ConnectionStatus =
64
+ 'idle' | 'connecting' | 'paused' | 'live' | 'closed'
55
65
 
56
66
  /**
57
67
  * The connection, as a discriminated union — impossible states are
@@ -63,11 +73,12 @@ export type Connection =
63
73
  | { status: 'idle' }
64
74
  | {
65
75
  status: 'connecting'
66
- /** Drops of an established stream so far. `0` = first connect. */
76
+ /** Stream restarts so far. `0` = first connect. */
67
77
  reconnects: number
68
78
  /** Why the last connection ended; `null` on the first connect. */
69
- error: Error | null
79
+ error: A2ClientError | null
70
80
  }
81
+ | { status: 'paused'; reconnects: number; error: A2ClientError | null }
71
82
  | { status: 'live'; reconnects: number }
72
83
  | { status: 'closed' }
73
84
 
@@ -123,7 +134,7 @@ export type SessionSnapshot<
123
134
  * (provisional indexes past the frontier). `state` never folds
124
135
  * backscrolled events; they are display data below the frontier. */
125
136
  events: ContractEvent<D>[]
126
- /** The stream frontier: the last server-confirmed index. This is the
137
+ /** The latest server-confirmed state index. This is the
127
138
  * `lastSeenIndex` cancellation wants. */
128
139
  index: number
129
140
  /** Backscroll progress — `loadHistory` moves it. */
@@ -161,7 +172,7 @@ export type SessionClient<
161
172
  * Optimistic append: validates locally against the reducer's event
162
173
  * schemas (instant `INVALID_PAYLOAD`, no flicker), applies to the
163
174
  * local fold, POSTs, swaps in the ack, rolls back on rejection.
164
- * Auto-retries only `STORE_UNAVAILABLE`. Resolves with the appended
175
+ * Auto-retries `STORE_UNAVAILABLE` and transport failures. Resolves with the appended
165
176
  * events as the server recorded them. Overlapping calls on this
166
177
  * handle enter the transport in invocation order without delaying
167
178
  * their optimistic folds.
@@ -184,13 +195,17 @@ export type SessionClient<
184
195
  * closes — so independent consumers of one identity-mapped handle
185
196
  * (two hooks, a hook plus vanilla code) never fight over the
186
197
  * stream. Releasing twice is a no-op. While any lease is held the
187
- * stream reconnects with backoff and resumes from the frontier.
198
+ * stream follows the client's reconnection policy and resumes after
199
+ * its last streamed event.
188
200
  */
189
201
  connect(): () => void
202
+ /** Restart a leased subscription immediately after its last streamed event.
203
+ * Preserves state, pending writes, and leases. No-op without a lease. */
204
+ reconnect(): void
190
205
  /**
191
206
  * The hard stop: drops every outstanding lease and closes the
192
207
  * stream now. Not terminal — a later `connect()` starts fresh from
193
- * the current frontier.
208
+ * the last streamed event.
194
209
  */
195
210
  close(): void
196
211
  }
@@ -264,6 +279,8 @@ export type CreateClientOptions<
264
279
  reducer: Reducer<D, S, P>
265
280
  /** The route (or routes) the client speaks — see `ClientApi`. */
266
281
  api: ClientApi
282
+ /** Delay the next connection attempt in milliseconds, or pause with false. */
283
+ reconnect?: ReconnectPolicy
267
284
  /** Injectable fetch — defaults to the global. */
268
285
  fetch?: typeof globalThis.fetch
269
286
  /** Injectable WebSocket constructor for the `ws` api — defaults to
@@ -283,6 +300,13 @@ export type CreateClientOptions<
283
300
  }
284
301
 
285
302
  const PUSH_ATTEMPTS = 3
303
+ const RETRYABLE_PUSH_CODES = new Set([
304
+ 'STORE_UNAVAILABLE',
305
+ 'TIMEOUT',
306
+ 'HTTP_ERROR',
307
+ 'CONNECTION_FAILED',
308
+ 'UNKNOWN',
309
+ ])
286
310
  const DEFAULT_GC_TIME_MS = 5 * 60 * 1_000
287
311
  const DEFAULT_HISTORY_LIMIT = 50
288
312
 
@@ -297,6 +321,7 @@ type TransportFrame =
297
321
  | { kind: 'event'; event: WireEvent }
298
322
  | { kind: 'presence'; patch: WirePresencePatch }
299
323
  | { kind: 'presence-snapshot'; snapshot: WirePresenceSnapshot }
324
+ | { kind: 'activity'; idle: boolean }
300
325
  | { kind: 'ping' }
301
326
 
302
327
  type TransportPushBody = {
@@ -327,7 +352,7 @@ type Transport = {
327
352
  signal: AbortSignal
328
353
  }): AsyncIterable<TransportFrame>
329
354
  /** One attempt (the retry loop lives above the seam): resolves with
330
- * the acked wire events, throws the wire-deserialized A2Error. */
355
+ * the acked wire events, throws A2ClientError. */
331
356
  push(body: TransportPushBody): Promise<WireEvent[]>
332
357
  /** One bounded cold read of the log, oldest first — the lane
333
358
  * `loadHistory` rides. Absent on transports without a
@@ -357,6 +382,23 @@ const sessionUrl = (url: SessionUrl, sessionId: string): string => {
357
382
  return resolved
358
383
  }
359
384
 
385
+ const httpError = async (response: Response): Promise<A2ClientError> => {
386
+ const error = errorFromWire(await response.json().catch(() => null))
387
+ return error === null
388
+ ? new A2ClientError(
389
+ 'HTTP_ERROR',
390
+ `request failed with ${response.status}`,
391
+ {
392
+ status: response.status,
393
+ },
394
+ )
395
+ : new A2ClientError(error.code, error.message, {
396
+ details: error.details,
397
+ status: response.status,
398
+ cause: error,
399
+ })
400
+ }
401
+
360
402
  const httpTransport = (
361
403
  routes: { push: SessionUrl; stream: SessionUrl },
362
404
  fetchImpl: typeof globalThis.fetch,
@@ -371,8 +413,11 @@ const httpTransport = (
371
413
  `${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
372
414
  { headers: { accept: 'text/event-stream' }, signal },
373
415
  )
374
- if (!res.ok || !res.body) {
375
- throw new Error(`stream failed with ${res.status}`)
416
+ if (!res.ok) throw await httpError(res)
417
+ if (!res.body) {
418
+ throw new A2ClientError('UNKNOWN', 'stream response has no body', {
419
+ status: res.status,
420
+ })
376
421
  }
377
422
  yield { kind: 'ping' } // connected — headers in hand, before any bytes
378
423
  const decoder = new TextDecoder()
@@ -416,6 +461,11 @@ const httpTransport = (
416
461
  if (isWirePresencePatch(parsed)) {
417
462
  yield { kind: 'presence', patch: parsed }
418
463
  }
464
+ } else if (
465
+ eventName === 'a2-activity' &&
466
+ (data === 'idle' || data === 'active')
467
+ ) {
468
+ yield { kind: 'activity', idle: data === 'idle' }
419
469
  }
420
470
  // Other named frames: skipped — old clients stay compatible
421
471
  // with future lanes the same way.
@@ -435,17 +485,14 @@ const httpTransport = (
435
485
  if (res.ok) {
436
486
  const rows = (await res.json()) as unknown
437
487
  if (!Array.isArray(rows) || !rows.every(isWireEvent)) {
438
- throw new A2Error(
488
+ throw new A2ClientError(
439
489
  'STORE_UNAVAILABLE',
440
490
  'push ack was not a list of events',
441
491
  )
442
492
  }
443
493
  return rows
444
494
  }
445
- throw (
446
- errorFromWire(await res.json().catch(() => null)) ??
447
- new A2Error('STORE_UNAVAILABLE', `push failed with ${res.status}`)
448
- )
495
+ throw await httpError(res)
449
496
  },
450
497
 
451
498
  async history({ sessionId, gte, lte }) {
@@ -456,14 +503,11 @@ const httpTransport = (
456
503
  { headers: { accept: 'application/json' } },
457
504
  )
458
505
  if (!res.ok) {
459
- throw (
460
- errorFromWire(await res.json().catch(() => null)) ??
461
- new A2Error('STORE_UNAVAILABLE', `history failed with ${res.status}`)
462
- )
506
+ throw await httpError(res)
463
507
  }
464
508
  const rows = (await res.json()) as unknown
465
509
  if (!Array.isArray(rows) || !rows.every(isWireEvent)) {
466
- throw new A2Error(
510
+ throw new A2ClientError(
467
511
  'STORE_UNAVAILABLE',
468
512
  'history response was not a list of events',
469
513
  )
@@ -495,7 +539,7 @@ const WS_OPEN = 1
495
539
 
496
540
  type PendingAck = {
497
541
  resolve: (events: WireEvent[]) => void
498
- reject: (error: A2Error) => void
542
+ reject: (error: A2ClientError) => void
499
543
  }
500
544
 
501
545
  const webSocketUrl = (value: string): URL => {
@@ -553,7 +597,7 @@ const socketReadyWaiters = () => {
553
597
 
554
598
  const rejectPendingAcks = (pending: Map<number, PendingAck>): void => {
555
599
  if (pending.size === 0) return
556
- const error = new A2Error(
600
+ const error = new A2ClientError(
557
601
  'STORE_UNAVAILABLE',
558
602
  'socket closed with the ack outstanding',
559
603
  )
@@ -634,7 +678,7 @@ const sessionWsTransport = (
634
678
  frame.events.some((row) => row.sessionId !== sessionId))
635
679
  ) {
636
680
  waiter.reject(
637
- new A2Error(
681
+ new A2ClientError(
638
682
  'STORE_UNAVAILABLE',
639
683
  'ack does not belong to the socket session',
640
684
  ),
@@ -642,7 +686,7 @@ const sessionWsTransport = (
642
686
  return
643
687
  }
644
688
  if ('events' in frame) waiter.resolve(frame.events)
645
- else waiter.reject(frame.error)
689
+ else waiter.reject(asClientError(frame.error))
646
690
  return
647
691
  }
648
692
  case 'event':
@@ -675,13 +719,15 @@ const sessionWsTransport = (
675
719
  finish(
676
720
  event.code === 1000
677
721
  ? null
678
- : new Error(
722
+ : new A2ClientError(
723
+ 'CONNECTION_FAILED',
679
724
  `socket closed (${event.code}${event.reason ? `: ${event.reason}` : ''})`,
725
+ { closeCode: event.code, closeReason: event.reason },
680
726
  ),
681
727
  )
682
728
  })
683
729
  socket.addEventListener('error', () => {
684
- finish(new Error('socket error'))
730
+ finish(new A2ClientError('CONNECTION_FAILED', 'socket error'))
685
731
  })
686
732
 
687
733
  const onAbort = (): void => {
@@ -727,7 +773,10 @@ const sessionWsTransport = (
727
773
  connection === undefined ||
728
774
  connection.socket.readyState !== WS_OPEN
729
775
  ) {
730
- throw new A2Error('STORE_UNAVAILABLE', 'no open socket for the session')
776
+ throw new A2ClientError(
777
+ 'STORE_UNAVAILABLE',
778
+ 'no open socket for the session',
779
+ )
731
780
  }
732
781
  const req = nextReq
733
782
  nextReq += 1
@@ -743,7 +792,9 @@ const sessionWsTransport = (
743
792
  } catch (cause) {
744
793
  connection.pending.delete(req)
745
794
  reject(
746
- new A2Error('STORE_UNAVAILABLE', 'socket send failed', { cause }),
795
+ new A2ClientError('STORE_UNAVAILABLE', 'socket send failed', {
796
+ cause,
797
+ }),
747
798
  )
748
799
  }
749
800
  })
@@ -898,8 +949,10 @@ const multiplexedWsTransport = (
898
949
  watchdog = setTimeout(() => {
899
950
  if (socket !== created) return
900
951
  failSocket(
901
- new Error(
952
+ new A2ClientError(
953
+ 'TIMEOUT',
902
954
  `socket stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
955
+ { phase: 'stream', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
903
956
  ),
904
957
  )
905
958
  }, STREAM_TIMINGS.stallTimeoutMs)
@@ -932,7 +985,11 @@ const multiplexedWsTransport = (
932
985
  channel.dropped = true
933
986
  settleChannel(
934
987
  channel,
935
- frame.reason === undefined ? null : new Error(frame.reason),
988
+ frame.error
989
+ ? asClientError(frame.error)
990
+ : frame.reason === undefined
991
+ ? null
992
+ : new A2ClientError('CONNECTION_FAILED', frame.reason),
936
993
  )
937
994
  return
938
995
  }
@@ -941,7 +998,7 @@ const multiplexedWsTransport = (
941
998
  if (waiter === undefined) return
942
999
  pending.delete(frame.req)
943
1000
  if ('events' in frame) waiter.resolve(frame.events)
944
- else waiter.reject(frame.error)
1001
+ else waiter.reject(asClientError(frame.error))
945
1002
  return
946
1003
  }
947
1004
  case 'event': {
@@ -1013,14 +1070,16 @@ const multiplexedWsTransport = (
1013
1070
  failSocket(
1014
1071
  event.code === 1000
1015
1072
  ? null // deliberate server close — reconnect, but not an error
1016
- : new Error(
1073
+ : new A2ClientError(
1074
+ 'CONNECTION_FAILED',
1017
1075
  `socket closed (${event.code}${event.reason ? `: ${event.reason}` : ''})`,
1076
+ { closeCode: event.code, closeReason: event.reason },
1018
1077
  ),
1019
1078
  )
1020
1079
  })
1021
1080
  created.addEventListener('error', () => {
1022
1081
  if (socket !== created) return
1023
- failSocket(new Error('socket error'))
1082
+ failSocket(new A2ClientError('CONNECTION_FAILED', 'socket error'))
1024
1083
  })
1025
1084
  }
1026
1085
 
@@ -1069,8 +1128,10 @@ const multiplexedWsTransport = (
1069
1128
  if (channel.live) return
1070
1129
  settleChannel(
1071
1130
  channel,
1072
- new Error(
1131
+ new A2ClientError(
1132
+ 'TIMEOUT',
1073
1133
  'subscribe not confirmed: is the ws route calling server.fetch with upgradeWebSocket?',
1134
+ { phase: 'subscribe', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
1074
1135
  ),
1075
1136
  )
1076
1137
  }, STREAM_TIMINGS.stallTimeoutMs)
@@ -1109,7 +1170,10 @@ const multiplexedWsTransport = (
1109
1170
  ) {
1110
1171
  // Retryable by contract: the reconnect loop restores the
1111
1172
  // socket and the retry rides the same event ids.
1112
- throw new A2Error('STORE_UNAVAILABLE', 'no open socket for the session')
1173
+ throw new A2ClientError(
1174
+ 'STORE_UNAVAILABLE',
1175
+ 'no open socket for the session',
1176
+ )
1113
1177
  }
1114
1178
  const req = nextReq
1115
1179
  nextReq += 1
@@ -1126,7 +1190,9 @@ const multiplexedWsTransport = (
1126
1190
  } catch (cause) {
1127
1191
  pending.delete(req)
1128
1192
  reject(
1129
- new A2Error('STORE_UNAVAILABLE', 'socket send failed', { cause }),
1193
+ new A2ClientError('STORE_UNAVAILABLE', 'socket send failed', {
1194
+ cause,
1195
+ }),
1130
1196
  )
1131
1197
  }
1132
1198
  })
@@ -1217,12 +1283,19 @@ export function createClient<
1217
1283
  const presenceDefs: Readonly<PresenceDefs> = reducer.presence
1218
1284
  const declaresPresence = Object.keys(presenceDefs).length > 0
1219
1285
  const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
1286
+ const clientFetch: typeof globalThis.fetch = async (input, init) => {
1287
+ try {
1288
+ return await fetchImpl(input, init)
1289
+ } catch (error) {
1290
+ throw asClientError(error, { code: 'CONNECTION_FAILED' })
1291
+ }
1292
+ }
1220
1293
  const webSocketImpl =
1221
1294
  options.webSocket ??
1222
1295
  (globalThis as { WebSocket?: ClientWebSocketConstructor }).WebSocket
1223
1296
  const transport = transportFor(
1224
1297
  api,
1225
- fetchImpl,
1298
+ clientFetch,
1226
1299
  webSocketImpl,
1227
1300
  declaresPresence,
1228
1301
  )
@@ -1260,7 +1333,11 @@ export function createClient<
1260
1333
  sessionOptions?: SessionOptions<D, S>,
1261
1334
  ): SessionRuntime<D, S, P> => {
1262
1335
  // ── server truth ─────────────────────────────────────────────
1263
- let frontier = sessionOptions?.initialIndex ?? 0
1336
+ let stateIndex = sessionOptions?.initialIndex ?? 0
1337
+ let streamIndex =
1338
+ sessionOptions?.initialState === undefined
1339
+ ? sessionOptions?.initialIndex
1340
+ : stateIndex
1264
1341
  let foldedState: S =
1265
1342
  sessionOptions?.initialState !== undefined
1266
1343
  ? sessionOptions.initialState
@@ -1268,7 +1345,7 @@ export function createClient<
1268
1345
  const serverEvents: ContractEvent<D>[] = (
1269
1346
  sessionOptions?.initialEvents ?? []
1270
1347
  )
1271
- .filter((event) => event.index <= frontier)
1348
+ .filter((event) => event.index <= stateIndex)
1272
1349
  .toSorted((a, b) => a.index - b.index)
1273
1350
 
1274
1351
  // ── optimistic overlay ───────────────────────────────────────
@@ -1299,7 +1376,7 @@ export function createClient<
1299
1376
  let snapshot: SessionSnapshot<D, S> | null = null
1300
1377
  let status: ConnectionStatus = 'idle'
1301
1378
  let reconnects = 0
1302
- let lastError: Error | null = null
1379
+ let lastError: A2ClientError | null = null
1303
1380
  let inFlightPushes = 0
1304
1381
  let pushWireTail: Promise<void> = Promise.resolve()
1305
1382
  let deferredHydration: SessionOptions<D, S> | undefined
@@ -1357,7 +1434,7 @@ export function createClient<
1357
1434
  .filter((p) => p.acked)
1358
1435
  .map((p) => p.acked!)
1359
1436
  .toSorted((a, b) => a.index - b.index)
1360
- const maxKnown = acked.at(-1)?.index ?? frontier
1437
+ const maxKnown = acked.at(-1)?.index ?? stateIndex
1361
1438
  const unacked = pending
1362
1439
  .filter((p) => !p.acked)
1363
1440
  .map(
@@ -1383,7 +1460,8 @@ export function createClient<
1383
1460
  case 'live':
1384
1461
  return { status: 'live', reconnects }
1385
1462
  case 'connecting':
1386
- return { status: 'connecting', reconnects, error: lastError }
1463
+ case 'paused':
1464
+ return { status, reconnects, error: lastError }
1387
1465
  }
1388
1466
  }
1389
1467
 
@@ -1391,9 +1469,9 @@ export function createClient<
1391
1469
  const oldestLoaded = serverEvents[0]?.index ?? null
1392
1470
  return {
1393
1471
  loading: historyLoads > 0,
1394
- // Index 1 loaded — or a frontier of 0, an empty log as far as
1472
+ // Index 1 loaded — or a state index of 0, an empty log as far as
1395
1473
  // this client knows — means nothing older is left to fetch.
1396
- complete: oldestLoaded === 1 || frontier === 0,
1474
+ complete: oldestLoaded === 1 || stateIndex === 0,
1397
1475
  oldestLoaded,
1398
1476
  }
1399
1477
  }
@@ -1405,7 +1483,7 @@ export function createClient<
1405
1483
  return {
1406
1484
  state,
1407
1485
  events: [...serverEvents, ...overlay],
1408
- index: frontier,
1486
+ index: stateIndex,
1409
1487
  history: historyState(),
1410
1488
  connection: connection(),
1411
1489
  ...(declaresPresence
@@ -1486,7 +1564,7 @@ export function createClient<
1486
1564
  const at = new Date(atMs)
1487
1565
  for (const [field, value] of Object.entries(values)) {
1488
1566
  if (value === null) delete entry[field]
1489
- else entry[field] = { value, seen: frontier, at }
1567
+ else entry[field] = { value, seen: stateIndex, at }
1490
1568
  }
1491
1569
  const next = Object.assign(nullProtoRecord<PresenceMap>(), presenceState)
1492
1570
  if (Object.keys(entry).length === 0) delete next[id]
@@ -1499,7 +1577,7 @@ export function createClient<
1499
1577
  // dropped silently — the next send repaints. Sends are NOT
1500
1578
  // serialized on the ack and may overlap in flight: safe, because
1501
1579
  // merges are LWW by `at` per (participant, field) and one
1502
- // sender's stamps are monotonic. `seen` is the frontier at send
1580
+ // sender's stamps are monotonic. `seen` is the state index at send
1503
1581
  // time, not at set time; `at` is the sender's monotonic stamp.
1504
1582
  const sendPresence = (
1505
1583
  values: Record<string, unknown>,
@@ -1510,7 +1588,7 @@ export function createClient<
1510
1588
  sessionId,
1511
1589
  // Every path here starts at setPresence, which requires the
1512
1590
  // participant.
1513
- presence: { participant: participant!, values, seen: frontier, at },
1591
+ presence: { participant: participant!, values, seen: stateIndex, at },
1514
1592
  })
1515
1593
  touch(sessionId)
1516
1594
  }
@@ -1566,7 +1644,7 @@ export function createClient<
1566
1644
  ? undefined
1567
1645
  : presenceDefs['*']
1568
1646
  if (!schema) {
1569
- throw new A2Error(
1647
+ throw new A2ClientError(
1570
1648
  'UNKNOWN_PRESENCE_FIELD',
1571
1649
  `no presence field '${field}' in the reducer's vocabulary`,
1572
1650
  )
@@ -1577,7 +1655,7 @@ export function createClient<
1577
1655
  }
1578
1656
  const result = validateSync(schema, value, `presence field '${field}'`)
1579
1657
  if (result.issues) {
1580
- throw new A2Error(
1658
+ throw new A2ClientError(
1581
1659
  'INVALID_PAYLOAD',
1582
1660
  `invalid value for presence field '${field}'`,
1583
1661
  { details: result.issues },
@@ -1598,20 +1676,25 @@ export function createClient<
1598
1676
  }
1599
1677
 
1600
1678
  /** Pushes awaiting stream confirmation — resolved by `ingest` the
1601
- * moment the frontier passes their batch. */
1679
+ * moment the state index passes their batch. */
1602
1680
  let confirmWatchers: Array<{ index: number; resolve: () => void }> = []
1603
1681
 
1604
1682
  /** The single ingest point: every server-confirmed event, in log
1605
1683
  * order, from the stream. */
1606
1684
  const ingest = (event: ContractEvent<D>): void => {
1607
- if (event.index <= frontier) return
1608
- frontier = event.index
1609
- serverEvents.push(event)
1610
- foldedState = reducer.fold(foldedState, event)
1685
+ if (event.index <= (streamIndex ?? 0)) return
1686
+ streamIndex = event.index
1687
+ if (event.index > stateIndex) {
1688
+ stateIndex = event.index
1689
+ serverEvents.push(event)
1690
+ foldedState = reducer.fold(foldedState, event)
1691
+ } else {
1692
+ mergeServerEvents([event], stateIndex)
1693
+ }
1611
1694
  pending = pending.filter((p) => p.id !== event.id)
1612
- if (confirmWatchers.some((w) => w.index <= frontier)) {
1613
- const due = confirmWatchers.filter((w) => w.index <= frontier)
1614
- confirmWatchers = confirmWatchers.filter((w) => w.index > frontier)
1695
+ if (confirmWatchers.some((w) => w.index <= stateIndex)) {
1696
+ const due = confirmWatchers.filter((w) => w.index <= stateIndex)
1697
+ confirmWatchers = confirmWatchers.filter((w) => w.index > stateIndex)
1615
1698
  for (const watcher of due) watcher.resolve()
1616
1699
  }
1617
1700
  notify()
@@ -1627,7 +1710,7 @@ export function createClient<
1627
1710
  ? reducer.events[event.type]
1628
1711
  : undefined
1629
1712
  if (!schema) {
1630
- throw new A2Error(
1713
+ throw new A2ClientError(
1631
1714
  'UNKNOWN_EVENT_TYPE',
1632
1715
  `no event type '${String(event.type)}' in the reducer's vocabulary`,
1633
1716
  )
@@ -1638,7 +1721,7 @@ export function createClient<
1638
1721
  `event '${String(event.type)}'`,
1639
1722
  )
1640
1723
  if (result.issues) {
1641
- throw new A2Error(
1724
+ throw new A2ClientError(
1642
1725
  'INVALID_PAYLOAD',
1643
1726
  `invalid payload for event '${String(event.type)}'`,
1644
1727
  { details: result.issues },
@@ -1654,7 +1737,7 @@ export function createClient<
1654
1737
  const post = async (
1655
1738
  body: TransportPushBody,
1656
1739
  ): Promise<ContractEvent<D>[]> => {
1657
- let lastPushError: A2Error = new A2Error(
1740
+ let lastPushError: A2ClientError = new A2ClientError(
1658
1741
  'STORE_UNAVAILABLE',
1659
1742
  'push failed',
1660
1743
  )
@@ -1663,16 +1746,9 @@ export function createClient<
1663
1746
  const rows = await transport.push(body)
1664
1747
  return rows.map((row) => eventFromWire(row) as ContractEvent<D>)
1665
1748
  } catch (err) {
1666
- lastPushError =
1667
- err instanceof A2Error
1668
- ? err
1669
- : new A2Error('STORE_UNAVAILABLE', 'push request failed', {
1670
- cause: err,
1671
- })
1749
+ lastPushError = asClientError(err)
1672
1750
  }
1673
- // Only the documented retryable code retries; identical ids
1674
- // make the retry idempotent on the server.
1675
- if (lastPushError.code !== 'STORE_UNAVAILABLE') throw lastPushError
1751
+ if (!RETRYABLE_PUSH_CODES.has(lastPushError.code)) throw lastPushError
1676
1752
  if (attempt < PUSH_ATTEMPTS) {
1677
1753
  const sleepMs = 250 * attempt
1678
1754
  // Wait for the clock AND, when the transport can tell us,
@@ -1707,7 +1783,7 @@ export function createClient<
1707
1783
  (acked) =>
1708
1784
  new Promise<ContractEvent<D>[]>((resolve) => {
1709
1785
  const last = acked.at(-1)?.index ?? 0
1710
- if (frontier >= last) {
1786
+ if (stateIndex >= last) {
1711
1787
  resolve(acked)
1712
1788
  return
1713
1789
  }
@@ -1746,6 +1822,7 @@ export function createClient<
1746
1822
  inFlightPushes += 1
1747
1823
 
1748
1824
  try {
1825
+ refreshIdleStream()
1749
1826
  notify() // optimistic — the view updates before the network moves
1750
1827
  await waitForPriorPush
1751
1828
  const acked = await post({
@@ -1760,6 +1837,8 @@ export function createClient<
1760
1837
  const entry = pending.find((p) => p.id === event.id)
1761
1838
  if (entry) entry.acked = event // stream retires it at its index
1762
1839
  }
1840
+ if ((acked.at(-1)?.index ?? 0) > (streamIndex ?? stateIndex))
1841
+ refreshIdleStream()
1763
1842
  notify()
1764
1843
  return acked
1765
1844
  } catch (err) {
@@ -1783,7 +1862,7 @@ export function createClient<
1783
1862
  }
1784
1863
 
1785
1864
  // ── backscroll ───────────────────────────────────────────────
1786
- // Cold reads of the log below the frontier. Fetched events merge
1865
+ // Cold reads of the log below the state index. Fetched events merge
1787
1866
  // into `serverEvents` only — never the fold, never the overlay.
1788
1867
  let historyLoads = 0
1789
1868
  /** Calls chain per session: each resolves its bounds after the
@@ -1833,11 +1912,11 @@ export function createClient<
1833
1912
  }
1834
1913
  touch(sessionId)
1835
1914
  const run = historyTail.then(async () => {
1836
- const upper = before ?? serverEvents[0]?.index ?? frontier + 1
1837
- // Strictly at or below the frontier: the live stream owns
1915
+ const upper = before ?? serverEvents[0]?.index ?? stateIndex + 1
1916
+ // Strictly at or below the state index: the live stream owns
1838
1917
  // everything above it, and the optimistic overlay must never
1839
1918
  // collide with a backscrolled row.
1840
- const lte = Math.min(upper - 1, frontier)
1919
+ const lte = Math.min(upper - 1, stateIndex)
1841
1920
  const gte = Math.max(1, upper - (limit ?? DEFAULT_HISTORY_LIMIT))
1842
1921
  if (gte > lte) return []
1843
1922
  const missing = missingBetween(gte, lte)
@@ -1848,9 +1927,11 @@ export function createClient<
1848
1927
  const rows = await readHistory({ sessionId, ...missing })
1849
1928
  mergeServerEvents(
1850
1929
  rows.map((row) => eventFromWire(row) as ContractEvent<D>),
1851
- frontier,
1930
+ stateIndex,
1852
1931
  )
1853
1932
  return eventsBetween(gte, lte)
1933
+ } catch (error) {
1934
+ throw asClientError(error)
1854
1935
  } finally {
1855
1936
  historyLoads -= 1
1856
1937
  notify()
@@ -1872,6 +1953,7 @@ export function createClient<
1872
1953
  // `leaseEpoch` makes the outstanding releases inert).
1873
1954
  let generation = 0
1874
1955
  let active = false
1956
+ let streamIdle = false
1875
1957
  let abort: AbortController | null = null
1876
1958
  let leases = 0
1877
1959
  let leaseEpoch = 0
@@ -1880,6 +1962,7 @@ export function createClient<
1880
1962
  touch(sessionId)
1881
1963
  if (!active) return
1882
1964
  active = false
1965
+ streamIdle = false
1883
1966
  generation += 1 // the running loop notices and exits
1884
1967
  abort?.abort()
1885
1968
  abort = null
@@ -1895,9 +1978,11 @@ export function createClient<
1895
1978
 
1896
1979
  const runStream = async (run: number): Promise<void> => {
1897
1980
  let backoff = STREAM_TIMINGS.reconnectBaseMs
1981
+ let attempt = 0
1898
1982
  // oxlint-disable no-await-in-loop -- sequential reconnect loop
1899
1983
  // oxlint-disable-next-line no-unmodified-loop-condition -- close() bumps `generation`
1900
1984
  while (generation === run) {
1985
+ streamIdle = false
1901
1986
  const controller = new AbortController()
1902
1987
  abort = controller
1903
1988
  // The stall watchdog: the server heartbeats the stream, so a
@@ -1921,7 +2006,7 @@ export function createClient<
1921
2006
  try {
1922
2007
  const frames = transport.connect({
1923
2008
  sessionId,
1924
- startAfter: frontier,
2009
+ startAfter: (streamIndex ??= stateIndex),
1925
2010
  signal: controller.signal,
1926
2011
  })
1927
2012
  for await (const frame of frames) {
@@ -1933,10 +2018,12 @@ export function createClient<
1933
2018
  // The first frame is the connection signal: the transport
1934
2019
  // yields it the moment the stream is established.
1935
2020
  backoff = STREAM_TIMINGS.reconnectBaseMs
2021
+ attempt = 0
1936
2022
  wasLive = true
1937
2023
  status = 'live'
1938
2024
  lastError = null
1939
2025
  notify()
2026
+ if (generation !== run) break
1940
2027
  if (presenceResend) {
1941
2028
  const values = presenceResend
1942
2029
  presenceResend = null
@@ -1944,7 +2031,20 @@ export function createClient<
1944
2031
  }
1945
2032
  }
1946
2033
  if (frame.kind === 'event') {
2034
+ streamIdle = false
1947
2035
  ingest(eventFromWire(frame.event) as ContractEvent<D>)
2036
+ } else if (frame.kind === 'activity') {
2037
+ streamIdle = frame.idle
2038
+ if (
2039
+ streamIdle &&
2040
+ (inFlightPushes > 0 ||
2041
+ pending.some(
2042
+ (entry) =>
2043
+ (entry.acked?.index ?? 0) > (streamIndex ?? stateIndex),
2044
+ ))
2045
+ ) {
2046
+ refreshIdleStream()
2047
+ }
1948
2048
  } else if (frame.kind === 'presence') {
1949
2049
  applyPresencePatch(presencePatchFromWire(frame.patch))
1950
2050
  } else if (frame.kind === 'presence-snapshot') {
@@ -1961,38 +2061,91 @@ export function createClient<
1961
2061
  } catch (err) {
1962
2062
  if (generation === run) {
1963
2063
  lastError = stalled
1964
- ? new Error(
2064
+ ? new A2ClientError(
2065
+ 'TIMEOUT',
1965
2066
  `stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
2067
+ { phase: 'stream', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
1966
2068
  )
1967
- : err instanceof Error
1968
- ? err
1969
- : new Error(String(err))
2069
+ : asClientError(err)
1970
2070
  }
1971
2071
  } finally {
1972
2072
  clearTimeout(stall)
1973
2073
  }
1974
2074
  if (generation !== run) break
2075
+ streamIdle = false
1975
2076
  if (wasLive) reconnects += 1
2077
+ attempt += 1
2078
+ let delay: number | false
2079
+ try {
2080
+ delay = options.reconnect
2081
+ ? options.reconnect({ sessionId, error: lastError, attempt })
2082
+ : backoff
2083
+ if (
2084
+ delay !== false &&
2085
+ (!Number.isFinite(delay) || delay < 0 || delay > 2_147_483_647)
2086
+ ) {
2087
+ throw new RangeError(
2088
+ 'reconnect must return false or a delay from 0 to 2147483647 ms',
2089
+ )
2090
+ }
2091
+ } catch (error) {
2092
+ if (generation !== run) break
2093
+ lastError = asClientError(error)
2094
+ delay = false
2095
+ }
2096
+ if (generation !== run) break
2097
+ if (delay === false) {
2098
+ abort = null
2099
+ status = 'paused'
2100
+ notify()
2101
+ return
2102
+ }
2103
+ const waiting = new AbortController()
2104
+ abort = waiting
1976
2105
  status = 'connecting'
1977
2106
  notify()
2107
+ if (generation !== run) break
1978
2108
  await new Promise<void>((resolve) => {
1979
2109
  let settled = false
1980
2110
  const finish = (): void => {
1981
2111
  if (settled) return
1982
2112
  settled = true
1983
2113
  clearTimeout(timer)
2114
+ waiting.signal.removeEventListener('abort', finish)
1984
2115
  // oxlint-disable-next-line no-multiple-resolved -- guarded above
1985
2116
  resolve()
1986
2117
  }
1987
- const timer = setTimeout(finish, backoff)
2118
+ const timer = setTimeout(finish, delay)
1988
2119
  ;(timer as { unref?: () => void }).unref?.()
1989
- abort?.signal.addEventListener('abort', finish)
2120
+ if (waiting.signal.aborted) finish()
2121
+ else waiting.signal.addEventListener('abort', finish, { once: true })
1990
2122
  })
1991
2123
  backoff = Math.min(backoff * 2, STREAM_TIMINGS.reconnectMaxMs)
1992
2124
  }
1993
2125
  // oxlint-enable no-await-in-loop
1994
2126
  }
1995
2127
 
2128
+ const refreshIdleStream = (): void => {
2129
+ if (streamIdle && status !== 'paused') reconnectStream()
2130
+ }
2131
+
2132
+ const reconnectStream = (): void => {
2133
+ touch(sessionId)
2134
+ if (!active) return
2135
+ streamIdle = false
2136
+ generation += 1
2137
+ const run = generation
2138
+ const previous = abort
2139
+ abort = null
2140
+ reconnects += 1
2141
+ status = 'connecting'
2142
+ lastError = null
2143
+ notify()
2144
+ // Replace the lane before aborting so a multiplexed session keeps its socket.
2145
+ if (active && generation === run) void runStream(run)
2146
+ previous?.abort()
2147
+ }
2148
+
1996
2149
  const runtime: SessionRuntime<D, S, P> = {
1997
2150
  sessionId,
1998
2151
  // Only on presence-declaring contracts — the type surface
@@ -2012,6 +2165,7 @@ export function createClient<
2012
2165
  },
2013
2166
  push,
2014
2167
  loadHistory,
2168
+ reconnect: reconnectStream,
2015
2169
  connect() {
2016
2170
  touch(sessionId)
2017
2171
  leases += 1
@@ -2019,8 +2173,10 @@ export function createClient<
2019
2173
  if (!active) {
2020
2174
  active = true
2021
2175
  status = 'connecting'
2176
+ lastError = null
2177
+ const run = generation
2022
2178
  notify()
2023
- void runStream(generation)
2179
+ if (active && generation === run) void runStream(run)
2024
2180
  }
2025
2181
  let released = false
2026
2182
  return () => {
@@ -2043,7 +2199,7 @@ export function createClient<
2043
2199
  if (!next) return
2044
2200
  participant ??= next.participant
2045
2201
  const nextIndex = next.initialIndex ?? 0
2046
- if (nextIndex > frontier && inFlightPushes > 0) {
2202
+ if (nextIndex > stateIndex && inFlightPushes > 0) {
2047
2203
  if (
2048
2204
  !deferredHydration ||
2049
2205
  nextIndex > (deferredHydration.initialIndex ?? 0)
@@ -2055,28 +2211,29 @@ export function createClient<
2055
2211
 
2056
2212
  const historyChanged = mergeServerEvents(
2057
2213
  next.initialEvents,
2058
- Math.min(nextIndex, frontier),
2214
+ Math.min(nextIndex, stateIndex),
2059
2215
  )
2060
- if (next.initialState === undefined || nextIndex <= frontier) {
2216
+ if (next.initialState !== undefined) streamIndex ??= nextIndex
2217
+ if (next.initialState === undefined || nextIndex <= stateIndex) {
2061
2218
  if (historyChanged) notifyHydrated()
2062
2219
  return
2063
2220
  }
2064
2221
 
2065
2222
  // The server fold subsumes everything observed through the old
2066
- // frontier. Keep later optimistic work overlaid, retiring acked
2067
- // entries the new frontier proves are already durable.
2068
- frontier = nextIndex
2223
+ // state index. Keep later optimistic work overlaid, retiring acked
2224
+ // entries the new state index proves are already durable.
2225
+ stateIndex = nextIndex
2069
2226
  foldedState = next.initialState
2070
- mergeServerEvents(next.initialEvents, frontier)
2227
+ mergeServerEvents(next.initialEvents, stateIndex)
2071
2228
  pending = pending.filter(
2072
- (entry) => !entry.acked || entry.acked.index > frontier,
2229
+ (entry) => !entry.acked || entry.acked.index > stateIndex,
2073
2230
  )
2074
- if (confirmWatchers.some((watcher) => watcher.index <= frontier)) {
2231
+ if (confirmWatchers.some((watcher) => watcher.index <= stateIndex)) {
2075
2232
  const due = confirmWatchers.filter(
2076
- (watcher) => watcher.index <= frontier,
2233
+ (watcher) => watcher.index <= stateIndex,
2077
2234
  )
2078
2235
  confirmWatchers = confirmWatchers.filter(
2079
- (watcher) => watcher.index > frontier,
2236
+ (watcher) => watcher.index > stateIndex,
2080
2237
  )
2081
2238
  for (const watcher of due) watcher.resolve()
2082
2239
  }