experimental-a2 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +53 -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 -202
  24. package/dist/client.js +2 -1026
  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-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
  45. package/dist/server-DwPrMqHB.d.ts.map +1 -0
  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 +85 -22
  80. package/docs/reference/01-api.mdx +90 -28
  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 +577 -175
  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-fetch.ts +51 -23
  100. package/src/server.ts +146 -19
  101. package/src/session-socket.ts +241 -88
  102. package/src/sse.ts +17 -0
  103. package/src/store-polling.ts +32 -12
  104. package/src/store-postgres.ts +292 -75
  105. package/src/store-presence-polling.ts +125 -0
  106. package/src/store-redis-core.ts +37 -30
  107. package/src/store-redis-http.ts +51 -45
  108. package/src/store-redis-notify.ts +464 -0
  109. package/src/store-redis.ts +9 -364
  110. package/src/store.ts +3 -4
  111. package/src/stream-activity.ts +32 -0
  112. package/src/wire.ts +39 -15
  113. package/dist/actor-shared-BACubf4x.d.ts +0 -136
  114. package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
  115. package/dist/actor.d.ts.map +0 -1
  116. package/dist/client.d.ts.map +0 -1
  117. package/dist/client.js.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/server-CBET-jSz.js.map +0 -1
  120. package/dist/server-CKY3_lbw.d.ts.map +0 -1
  121. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  122. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  123. package/dist/wire--yji6mO3.js.map +0 -1
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * The A2 socket protocol's server half (specs/a2-api.md §13): the
3
3
  * per-socket shell — teardown, backpressure, heartbeat, the malformed
4
- * budget, the in-flight bound — and `sessionsSocket`, the multiplexed
5
- * frame handler riding it. Internal module: `handle`'s upgrade lane is
6
- * the sanctioned mount; only `A2Socket` (the structural socket the
7
- * platform hands over) is re-exported as public surface.
4
+ * budget, and the in-flight bound. `sessionSocket` binds one session to
5
+ * one socket; `sessionsSocket` is the opt-in multiplexed handler. Only
6
+ * `A2Socket`, the structural socket the platform hands over, is public.
8
7
  */
9
8
 
10
9
  import { A2Error } from './errors.ts'
11
10
  import { SOCKET_TIMINGS, STREAM_TIMINGS } from './internal.ts'
11
+ import { streamActivities } from './stream-activity.ts'
12
12
  import type { Event } from './store.ts'
13
13
  import type { EventDefs, PresencePatch, PresenceSnapshot } from './contract.ts'
14
14
  import type { ParsedPushEvent, ParsedPushPresence } from './push-envelope.ts'
@@ -256,17 +256,214 @@ export type SessionsSocketOptions = {
256
256
  ): A2Error | null | Promise<A2Error | null>
257
257
  }
258
258
 
259
+ export type SessionSocketOptions = SessionsSocketOptions & {
260
+ startAfter?: number
261
+ }
262
+
259
263
  type SocketSubscription = {
260
264
  target: SocketSession
261
265
  iterator: AsyncIterator<Event | PresencePatch | PresenceSnapshot>
262
266
  stopped: boolean
263
267
  }
264
268
 
269
+ const socketSubscription = (
270
+ target: SocketSession,
271
+ startAfter: number,
272
+ presence: boolean,
273
+ ): SocketSubscription => ({
274
+ target,
275
+ iterator: (presence
276
+ ? target.stream({ startAfter, presence: true })
277
+ : target.stream({ startAfter }))[Symbol.asyncIterator](),
278
+ stopped: false,
279
+ })
280
+
265
281
  const stopSubscription = (subscription: SocketSubscription): void => {
266
282
  subscription.stopped = true
267
283
  void subscription.iterator.return?.()?.catch(() => {})
268
284
  }
269
285
 
286
+ const pumpSubscription = async (
287
+ shell: SocketShell,
288
+ subscription: SocketSubscription,
289
+ frameFor: (item: Event | PresencePatch | PresenceSnapshot) => string,
290
+ ): Promise<void> => {
291
+ for (;;) {
292
+ while (
293
+ !shell.torn() &&
294
+ !subscription.stopped &&
295
+ shell.buffered() > SOCKET_TIMINGS.highWaterMarkBytes
296
+ ) {
297
+ // oxlint-disable-next-line no-await-in-loop -- backpressure park
298
+ await new Promise((wake) => setTimeout(wake, SOCKET_TIMINGS.resumePollMs))
299
+ }
300
+ if (shell.torn() || subscription.stopped) return
301
+ // oxlint-disable-next-line no-await-in-loop -- stream pump
302
+ const { value, done } = await subscription.iterator.next()
303
+ if (shell.torn() || subscription.stopped || done) return
304
+ shell.send(frameFor(value))
305
+ }
306
+ }
307
+
308
+ const handleSocketPush = async (
309
+ shell: SocketShell,
310
+ target: SocketSession,
311
+ sessionId: string,
312
+ frame: Record<string, unknown>,
313
+ req: number,
314
+ ackSessionId: string | undefined,
315
+ gatePush: SessionsSocketOptions['gatePush'],
316
+ activate: () => void,
317
+ ): Promise<void> => {
318
+ try {
319
+ const events = parsePushEvents(frame['events'])
320
+ const denial = (await gatePush?.(sessionId, events, undefined)) ?? null
321
+ if (denial !== null) {
322
+ shell.send(socketErrorAckFor(req, denial, ackSessionId))
323
+ return
324
+ }
325
+ activate()
326
+ // oxlint-disable-next-line a2/await-handler-append -- this transport append is awaited before its ack
327
+ const appended = await target.append(...events)
328
+ activate()
329
+ shell.send(socketAckFor(req, appended, ackSessionId))
330
+ } catch (error) {
331
+ shell.send(socketErrorAckFor(req, asA2Error(error), ackSessionId))
332
+ }
333
+ }
334
+
335
+ const applySocketPresence = async (
336
+ shell: SocketShell,
337
+ target: SocketSession,
338
+ sessionId: string,
339
+ frame: Record<string, unknown>,
340
+ gatePush: SessionsSocketOptions['gatePush'],
341
+ ): Promise<void> => {
342
+ if (typeof target.setPresence !== 'function') {
343
+ shell.malformedFrame('presence frame on a presence-less session')
344
+ return
345
+ }
346
+ let patch: ParsedPushPresence | undefined
347
+ try {
348
+ patch = parsePresenceSibling(frame)
349
+ } catch {
350
+ shell.malformedFrame('invalid presence frame')
351
+ return
352
+ }
353
+ if (!patch) return
354
+ try {
355
+ const denial = (await gatePush?.(sessionId, [], patch)) ?? null
356
+ if (denial !== null) return
357
+ await target.setPresence(patch)
358
+ } catch (error) {
359
+ const failure = asA2Error(error)
360
+ if (
361
+ failure.code === 'INVALID_PAYLOAD' ||
362
+ failure.code === 'UNKNOWN_PRESENCE_FIELD'
363
+ ) {
364
+ shell.malformedFrame('invalid presence frame')
365
+ return
366
+ }
367
+ shell.shutdown(1011, 'internal error')
368
+ }
369
+ }
370
+
371
+ export function sessionSocket<D extends EventDefs>(
372
+ sessionId: string,
373
+ session: Pick<Session<D>, 'stream'>,
374
+ socket: A2Socket,
375
+ options?: SessionSocketOptions,
376
+ ): void {
377
+ const target = session as unknown as SocketSession
378
+ const subscription = socketSubscription(
379
+ target,
380
+ options?.startAfter ?? 0,
381
+ options?.presence === true,
382
+ )
383
+
384
+ const handleFrame = (frame: Record<string, unknown>): void => {
385
+ switch (frame['kind']) {
386
+ case 'push': {
387
+ const req = frame['req']
388
+ if (typeof req !== 'number') {
389
+ shell.malformedFrame('push frame without a numeric req')
390
+ return
391
+ }
392
+ if (frame['sessionId'] !== undefined) {
393
+ shell.send(
394
+ socketErrorAckFor(
395
+ req,
396
+ new A2Error(
397
+ 'INVALID_PAYLOAD',
398
+ 'sessionId is not allowed on a single-session socket',
399
+ ),
400
+ ),
401
+ )
402
+ return
403
+ }
404
+ if (shell.overCap()) {
405
+ shell.send(
406
+ socketErrorAckFor(
407
+ req,
408
+ new A2Error(
409
+ 'STORE_UNAVAILABLE',
410
+ 'push shed: too many in flight on this socket',
411
+ ),
412
+ ),
413
+ )
414
+ return
415
+ }
416
+ shell.track(
417
+ handleSocketPush(
418
+ shell,
419
+ target,
420
+ sessionId,
421
+ frame,
422
+ req,
423
+ undefined,
424
+ options?.gatePush,
425
+ () => streamActivities.get(subscription.iterator)?.activate(),
426
+ ),
427
+ )
428
+ return
429
+ }
430
+ case 'presence':
431
+ if (frame['sessionId'] !== undefined) {
432
+ shell.malformedFrame(
433
+ 'sessionId is not allowed on a single-session socket',
434
+ )
435
+ return
436
+ }
437
+ if (!shell.overCap()) {
438
+ shell.track(
439
+ applySocketPresence(
440
+ shell,
441
+ target,
442
+ sessionId,
443
+ frame,
444
+ options?.gatePush,
445
+ ),
446
+ )
447
+ }
448
+ return
449
+ default:
450
+ return
451
+ }
452
+ }
453
+
454
+ const shell = socketShell(
455
+ socket,
456
+ () => stopSubscription(subscription),
457
+ handleFrame,
458
+ options?.deadline,
459
+ )
460
+
461
+ void pumpSubscription(shell, subscription, socketFrameFor).then(
462
+ () => shell.shutdown(1000),
463
+ () => shell.shutdown(1011, 'stream failed'),
464
+ )
465
+ }
466
+
270
467
  const parseSubscribeEntries = (
271
468
  value: unknown,
272
469
  ): Array<{ id: string; index: number }> | null => {
@@ -308,45 +505,21 @@ export function sessionsSocket<D extends EventDefs>(
308
505
  if (subscriptions.get(id) === subscription) subscriptions.delete(id)
309
506
  }
310
507
 
311
- const pump = async (
312
- id: string,
313
- subscription: SocketSubscription,
314
- ): Promise<void> => {
315
- for (;;) {
316
- for (;;) {
317
- if (
318
- shell.torn() ||
319
- subscription.stopped ||
320
- shell.buffered() <= SOCKET_TIMINGS.highWaterMarkBytes
321
- )
322
- break
323
- // oxlint-disable-next-line no-await-in-loop -- backpressure park
324
- await new Promise((wake) =>
325
- setTimeout(wake, SOCKET_TIMINGS.resumePollMs),
326
- )
327
- }
328
- if (shell.torn() || subscription.stopped) return
329
- // oxlint-disable-next-line no-await-in-loop -- stream pump
330
- const { value, done } = await subscription.iterator.next()
331
- if (shell.torn() || subscription.stopped) return
332
- if (done) return
333
- shell.send(socketFrameFor(value, id))
334
- }
335
- }
336
-
337
508
  const runSubscription = async (
338
509
  id: string,
339
510
  subscription: SocketSubscription,
340
511
  ): Promise<void> => {
341
512
  try {
342
- await pump(id, subscription)
513
+ await pumpSubscription(shell, subscription, (item) =>
514
+ socketFrameFor(item, id),
515
+ )
343
516
  if (shell.torn() || subscription.stopped) return
344
517
  retire(id, subscription)
345
518
  shell.send(socketUnsubscribedFor(id))
346
- } catch {
519
+ } catch (error) {
347
520
  if (shell.torn() || subscription.stopped) return
348
521
  retire(id, subscription)
349
- shell.send(socketUnsubscribedFor(id, 'stream failed'))
522
+ shell.send(socketUnsubscribedFor(id, 'stream failed', asA2Error(error)))
350
523
  }
351
524
  }
352
525
 
@@ -354,33 +527,33 @@ export function sessionsSocket<D extends EventDefs>(
354
527
  id: string,
355
528
  index: number,
356
529
  ): Promise<void> => {
530
+ const existing = subscriptions.get(id)
531
+ if (existing) {
532
+ stopSubscription(existing)
533
+ subscriptions.delete(id)
534
+ }
357
535
  let session: Pick<Session<D>, 'stream'> | null
358
536
  try {
359
537
  session = await resolve(id, index)
360
- } catch {
538
+ } catch (error) {
361
539
  if (!shell.torn()) {
362
- shell.send(socketUnsubscribedFor(id, 'stream failed'))
540
+ shell.send(socketUnsubscribedFor(id, 'stream failed', asA2Error(error)))
363
541
  }
364
542
  return
365
543
  }
366
544
  if (shell.torn()) return
367
545
  if (session === null) {
368
- shell.send(socketUnsubscribedFor(id, 'subscribe rejected'))
546
+ shell.send(
547
+ socketUnsubscribedFor(
548
+ id,
549
+ 'subscribe rejected',
550
+ new A2Error('FORBIDDEN', 'the subscription is not authorized'),
551
+ ),
552
+ )
369
553
  return
370
554
  }
371
555
  const target = session as unknown as SocketSession
372
- const existing = subscriptions.get(id)
373
- if (existing) stopSubscription(existing)
374
- const iterator = (
375
- presence
376
- ? target.stream({ startAfter: index, presence: true })
377
- : target.stream({ startAfter: index })
378
- )[Symbol.asyncIterator]()
379
- const subscription: SocketSubscription = {
380
- target,
381
- iterator,
382
- stopped: false,
383
- }
556
+ const subscription = socketSubscription(target, index, presence)
384
557
  subscriptions.set(id, subscription)
385
558
  shell.send(socketSubscribedFor(id))
386
559
  void runSubscription(id, subscription)
@@ -415,19 +588,19 @@ export function sessionsSocket<D extends EventDefs>(
415
588
  )
416
589
  return
417
590
  }
418
- try {
419
- const events = parsePushEvents(frame['events'])
420
- const denial =
421
- (await options?.gatePush?.(sessionId, events, undefined)) ?? null
422
- if (denial !== null) {
423
- shell.send(socketErrorAckFor(req, denial, sessionId))
424
- return
425
- }
426
- const appended = await subscription.target.append(...events)
427
- shell.send(socketAckFor(req, appended, sessionId))
428
- } catch (err) {
429
- shell.send(socketErrorAckFor(req, asA2Error(err), sessionId))
430
- }
591
+ await handleSocketPush(
592
+ shell,
593
+ subscription.target,
594
+ sessionId,
595
+ frame,
596
+ req,
597
+ sessionId,
598
+ options?.gatePush,
599
+ () => {
600
+ const current = subscriptions.get(sessionId)
601
+ if (current) streamActivities.get(current.iterator)?.activate()
602
+ },
603
+ )
431
604
  }
432
605
 
433
606
  const handlePresence = async (
@@ -437,33 +610,13 @@ export function sessionsSocket<D extends EventDefs>(
437
610
  const subscription = subscriptions.get(sessionId)
438
611
  // An unsubscribe race, not malformedness — the plane repaints.
439
612
  if (subscription === undefined) return
440
- if (typeof subscription.target.setPresence !== 'function') {
441
- shell.malformedFrame('presence frame on a presence-less session')
442
- return
443
- }
444
- let patch: ParsedPushPresence | undefined
445
- try {
446
- patch = parsePresenceSibling(frame)
447
- } catch {
448
- shell.malformedFrame('invalid presence frame')
449
- return
450
- }
451
- if (!patch) return
452
- try {
453
- const denial = (await options?.gatePush?.(sessionId, [], patch)) ?? null
454
- if (denial !== null) return
455
- await subscription.target.setPresence(patch)
456
- } catch (error) {
457
- const failure = asA2Error(error)
458
- if (
459
- failure.code === 'INVALID_PAYLOAD' ||
460
- failure.code === 'UNKNOWN_PRESENCE_FIELD'
461
- ) {
462
- shell.malformedFrame('invalid presence frame')
463
- return
464
- }
465
- shell.shutdown(1011, 'internal error')
466
- }
613
+ await applySocketPresence(
614
+ shell,
615
+ subscription.target,
616
+ sessionId,
617
+ frame,
618
+ options?.gatePush,
619
+ )
467
620
  }
468
621
 
469
622
  const handleFrame = (frame: Record<string, unknown>): void => {
package/src/sse.ts CHANGED
@@ -10,6 +10,7 @@ import { STREAM_TIMINGS } from './internal.ts'
10
10
  import type { Event } from './store.ts'
11
11
  import type { PresencePatch, PresenceSnapshot } from './contract.ts'
12
12
  import { invocationDeadlineMs } from './platform.ts'
13
+ import { streamActivities } from './stream-activity.ts'
13
14
  import {
14
15
  eventToWire,
15
16
  presencePatchToWire,
@@ -41,7 +42,10 @@ export function sseResponse(
41
42
  let deadlineTimer: ReturnType<typeof setTimeout> | undefined
42
43
  let closed = false
43
44
  let iteratorReturned = false
45
+ let stopActivity: (() => void) | undefined
44
46
  const stopTimers = (): void => {
47
+ stopActivity?.()
48
+ stopActivity = undefined
45
49
  if (heartbeat !== undefined) clearInterval(heartbeat)
46
50
  if (deadlineTimer !== undefined) clearTimeout(deadlineTimer)
47
51
  heartbeat = undefined
@@ -61,6 +65,19 @@ export function sseResponse(
61
65
  // early proof this is a stream. Comment frames are invisible to
62
66
  // SSE consumers.
63
67
  controller.enqueue(encoder.encode(': connected\n\n'))
68
+ const activity = streamActivities.get(iterator)
69
+ if (activity) {
70
+ const announce = (idle: boolean): void => {
71
+ if (closed) return
72
+ controller.enqueue(
73
+ encoder.encode(
74
+ `event: a2-activity\ndata: ${idle ? 'idle' : 'active'}\n\n`,
75
+ ),
76
+ )
77
+ }
78
+ stopActivity = activity.subscribe(announce)
79
+ if (activity.idle) announce(true)
80
+ }
64
81
  // Heartbeat: comment frames are ignored by SSE parsers but are
65
82
  // bytes on the wire — liveness proof for the client's watchdog
66
83
  // and for idle-timeout-happy intermediaries.
@@ -14,7 +14,8 @@
14
14
  */
15
15
 
16
16
  import { POLL_TIMINGS } from './internal.ts'
17
- import type { StoredEvent } from './store.ts'
17
+ import type { Event } from './store.ts'
18
+ import { createStreamActivity, streamActivities } from './stream-activity.ts'
18
19
 
19
20
  /** One cancellable sleep — the injection seam for cadence tests. */
20
21
  export type Sleeper = { promise: Promise<void>; cancel: () => void }
@@ -56,47 +57,66 @@ export type PollingStreamOptions = {
56
57
  * SSE client), waking any pending sleep immediately.
57
58
  */
58
59
  export function pollingStream(
59
- readAfter: (afterIndex: number) => StoredEvent[] | Promise<StoredEvent[]>,
60
+ readAfter: (afterIndex: number) => Event[] | Promise<Event[]>,
60
61
  options: PollingStreamOptions,
61
- ): AsyncIterable<StoredEvent> {
62
+ ): AsyncIterable<Event> {
62
63
  const startAfter = options.startAfter ?? 0
63
64
  const sleep = options.sleep ?? defaultSleep
64
65
 
65
66
  return {
66
- [Symbol.asyncIterator](): AsyncIterator<StoredEvent> {
67
+ [Symbol.asyncIterator](): AsyncIterator<Event> {
67
68
  let last = startAfter
68
- let buffer: StoredEvent[] = []
69
+ let buffer: Event[] = []
69
70
  let closed = false
70
71
  let pending: Sleeper | null = null
71
72
  let interval = floor()
73
+ let activityVersion = 0
74
+ const activity = createStreamActivity(() => {
75
+ if (closed) return
76
+ activityVersion += 1
77
+ interval = floor()
78
+ pending?.cancel()
79
+ })
72
80
 
73
- return {
74
- async next(): Promise<IteratorResult<StoredEvent>> {
81
+ const iterator: AsyncIterator<Event> = {
82
+ async next(): Promise<IteratorResult<Event>> {
75
83
  for (;;) {
76
84
  if (closed) return { value: undefined, done: true }
77
- // oxlint-disable-next-line no-await-in-loop -- poll loop
78
- if (buffer.length === 0) buffer = await readAfter(last)
85
+ try {
86
+ // oxlint-disable-next-line no-await-in-loop -- poll loop
87
+ if (buffer.length === 0) buffer = await readAfter(last)
88
+ } catch (error) {
89
+ if (closed) return { value: undefined, done: true }
90
+ throw error
91
+ }
92
+ if (closed) return { value: undefined, done: true }
79
93
  const row = buffer.shift()
80
94
  if (row) {
81
95
  // Activity: tighten back to the floor.
82
- interval = floor()
96
+ activity.activate()
83
97
  last = row.index
84
98
  return { value: row, done: false }
85
99
  }
86
100
  // Empty poll: wait, then back off toward the idle ceiling.
101
+ activity.setIdle(interval === ceiling())
102
+ const version = activityVersion
87
103
  pending = sleep(interval)
88
104
  // oxlint-disable-next-line no-await-in-loop -- poll loop
89
105
  await pending.promise
90
106
  pending = null
91
- interval = Math.min(interval * 2, ceiling())
107
+ if (version === activityVersion) {
108
+ interval = Math.min(interval * 2, ceiling())
109
+ }
92
110
  }
93
111
  },
94
- async return(): Promise<IteratorResult<StoredEvent>> {
112
+ async return(): Promise<IteratorResult<Event>> {
95
113
  closed = true
96
114
  pending?.cancel()
97
115
  return { value: undefined, done: true }
98
116
  },
99
117
  }
118
+ streamActivities.set(iterator, activity)
119
+ return iterator
100
120
  },
101
121
  }
102
122
  }