experimental-a2 0.21.1 → 0.22.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 (139) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/{actor-HnGmrOZZ.d.ts → actor-CqibLgjQ.d.ts} +13 -5
  3. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor-shared-DFET5q16.js.map +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +3 -3
  10. package/dist/{ai-CvEnqVfo.d.ts → ai-DAu77CDr.d.ts} +2 -2
  11. package/dist/{ai-CvEnqVfo.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  12. package/dist/{ai-CzE672Q3.js → ai-EEfKyc-z.js} +2 -2
  13. package/dist/{ai-CzE672Q3.js.map → ai-EEfKyc-z.js.map} +1 -1
  14. package/dist/ai-client.d.ts +1 -1
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.d.ts +4 -2
  17. package/dist/ai-fx.d.ts.map +1 -1
  18. package/dist/ai-fx.js +211 -61
  19. package/dist/ai-fx.js.map +1 -1
  20. package/dist/{ai-harness-Bfk-Lycn.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  21. package/dist/{ai-harness-Bfk-Lycn.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  22. package/dist/ai-harness-dQeJaR1t.js.map +1 -1
  23. package/dist/ai-pi.d.ts +1 -1
  24. package/dist/ai-pi.js +1 -1
  25. package/dist/ai-react.d.ts +1 -1
  26. package/dist/ai-react.js +2 -2
  27. package/dist/ai-server.d.ts +3 -3
  28. package/dist/ai-server.d.ts.map +1 -1
  29. package/dist/ai-server.js +10 -4
  30. package/dist/ai-server.js.map +1 -1
  31. package/dist/ai.d.ts +1 -1
  32. package/dist/ai.js +1 -1
  33. package/dist/{client-9IeTsiAs.js → client-BQS4i_V9.js} +27 -4
  34. package/dist/client-BQS4i_V9.js.map +1 -0
  35. package/dist/client-CUMbS_38.d.ts.map +1 -1
  36. package/dist/client.js +1 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/{internal-Bc4PvfHk.js → internal-CvsrprnU.js} +32 -15
  39. package/dist/{internal-Bc4PvfHk.js.map → internal-CvsrprnU.js.map} +1 -1
  40. package/dist/react.js +1 -1
  41. package/dist/{runtime-consumer-BWmht60z.js → runtime-consumer-CIeL33wj.js} +2 -2
  42. package/dist/{runtime-consumer-BWmht60z.js.map → runtime-consumer-CIeL33wj.js.map} +1 -1
  43. package/dist/scheduler-qstash.d.ts +2 -2
  44. package/dist/scheduler-qstash.js +2 -2
  45. package/dist/scheduler-vercel.d.ts +2 -2
  46. package/dist/scheduler-vercel.js +2 -2
  47. package/dist/{server-DvIB8kj5.js → server-DtFrWxi3.js} +30 -8
  48. package/dist/server-DtFrWxi3.js.map +1 -0
  49. package/dist/{server-BshFZ0eL.d.ts → server-qgez0Xny.d.ts} +3 -3
  50. package/dist/{server-BshFZ0eL.d.ts.map → server-qgez0Xny.d.ts.map} +1 -1
  51. package/dist/server.d.ts +2 -2
  52. package/dist/server.js +1 -1
  53. package/dist/{session-clear-Bk_29eEw.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  54. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  55. package/dist/{store-Z2qjw50n.d.ts → store-D0MhBvg6.d.ts} +15 -6
  56. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  57. package/dist/store-N8PXxDAS.js.map +1 -1
  58. package/dist/store-memory.d.ts +1 -1
  59. package/dist/store-memory.d.ts.map +1 -1
  60. package/dist/store-memory.js +15 -17
  61. package/dist/store-memory.js.map +1 -1
  62. package/dist/{store-polling-DYNyxCev.js → store-polling-BZt2Rf3D.js} +2 -2
  63. package/dist/{store-polling-DYNyxCev.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  64. package/dist/store-postgres.d.ts +1 -1
  65. package/dist/store-postgres.d.ts.map +1 -1
  66. package/dist/store-postgres.js +31 -40
  67. package/dist/store-postgres.js.map +1 -1
  68. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  69. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  70. package/dist/store-redis-http.d.ts +1 -1
  71. package/dist/store-redis-http.js +3 -3
  72. package/dist/{store-redis-notify-IiVdlYHc.js → store-redis-notify-CsJw_hfD.js} +538 -397
  73. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  74. package/dist/store-redis.d.ts +1 -1
  75. package/dist/store-redis.d.ts.map +1 -1
  76. package/dist/store-redis.js +3 -4
  77. package/dist/store-redis.js.map +1 -1
  78. package/dist/store-sqlite.d.ts +1 -1
  79. package/dist/store-sqlite.d.ts.map +1 -1
  80. package/dist/store-sqlite.js +16 -18
  81. package/dist/store-sqlite.js.map +1 -1
  82. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  83. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  84. package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
  85. package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
  86. package/docs/actors/01-introduction.mdx +5 -2
  87. package/docs/agents/03-conversations.mdx +9 -8
  88. package/docs/agents/04-harnesses.mdx +7 -4
  89. package/docs/reference/01-core.mdx +3 -2
  90. package/docs/reference/02-server.mdx +5 -1
  91. package/docs/reference/04-ai.mdx +13 -4
  92. package/docs/reference/06-errors.mdx +1 -1
  93. package/docs/server/01-consumers.mdx +4 -1
  94. package/docs/server/06-guarantees.mdx +7 -4
  95. package/docs/stores/02-redis.mdx +16 -10
  96. package/docs/stores/03-redis-http.mdx +6 -4
  97. package/docs/stores/04-postgres.mdx +2 -2
  98. package/docs/upgrades/01-overview.mdx +1 -0
  99. package/docs/upgrades/78-0-22.mdx +115 -0
  100. package/examples/playground/app/a0/sandbox.ts +58 -13
  101. package/examples/playground/app/a2slack/db/a2.ts +7 -4
  102. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  103. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  104. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  105. package/examples/playground/app/chat/server.ts +2 -2
  106. package/examples/playground/lib/store.ts +1 -1
  107. package/examples/playground/package.json +1 -1
  108. package/package.json +1 -1
  109. package/sql/postgres/drizzle-schema.ts +7 -4
  110. package/sql/postgres/schema.sql +24 -3
  111. package/src/actor-shared.ts +10 -2
  112. package/src/ai-agent.ts +13 -0
  113. package/src/ai-fx.ts +321 -77
  114. package/src/ai-harness.ts +7 -0
  115. package/src/client.ts +19 -2
  116. package/src/deterministic-id.ts +1 -1
  117. package/src/internal.ts +42 -11
  118. package/src/presence-recovery.ts +18 -11
  119. package/src/presence-tombstones.ts +52 -0
  120. package/src/push-envelope.ts +2 -2
  121. package/src/server.ts +49 -15
  122. package/src/store-memory.ts +24 -32
  123. package/src/store-postgres.ts +43 -53
  124. package/src/store-presence-polling.ts +59 -34
  125. package/src/store-redis-core.ts +595 -439
  126. package/src/store-redis-notify.ts +74 -26
  127. package/src/store-redis.ts +2 -3
  128. package/src/store-sqlite.ts +34 -40
  129. package/src/store.ts +14 -5
  130. package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
  131. package/dist/client-9IeTsiAs.js.map +0 -1
  132. package/dist/server-DvIB8kj5.js.map +0 -1
  133. package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
  134. package/dist/store-Z2qjw50n.d.ts.map +0 -1
  135. package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
  136. package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
  137. package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
  138. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  139. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
package/src/internal.ts CHANGED
@@ -299,6 +299,21 @@ export const DRAIN_TIMINGS: {
299
299
  failureRetryMaxMs: 30_000,
300
300
  }
301
301
 
302
+ /**
303
+ * How many kernels a harness keeps, per agent, for the next step of a session
304
+ * in its process. Mutable only as a white-box test seam; production always
305
+ * uses the default.
306
+ */
307
+ export const HARNESS_PARKING: { maxKernels: number } = { maxKernels: 32 }
308
+
309
+ /**
310
+ * Per server: called when a drain of a session ends with nothing left to run
311
+ * for it in this process, so the layer above can let go of what it kept for
312
+ * that session's next step.
313
+ */
314
+ export const drainSettledHooks: WeakMap<object, (sessionId: string) => void> =
315
+ new WeakMap()
316
+
302
317
  /**
303
318
  * Stream liveness knobs — the server-side SSE heartbeat and the
304
319
  * client-side stall watchdog. Paired by design: the client declares a
@@ -323,16 +338,22 @@ export const POLL_TIMINGS: {
323
338
  }
324
339
 
325
340
  /**
326
- * Safety-read cadence for the notify-driven redis live stream. A
327
- * parked feed re-reads the store at this interval even without a
328
- * pub/sub wake-up, so a lost notification (subscriber reconnect gap,
329
- * dropped PUBLISH) costs latency, never an event. Mutable only as a
330
- * white-box test seam.
341
+ * Timings for the notify-driven redis live stream. A parked feed re-reads
342
+ * the store every `safetyReadMs` even without a pub/sub wake-up, so a lost
343
+ * notification (subscriber reconnect gap, dropped PUBLISH) costs latency,
344
+ * never an event. Writers publish after their scripts, so wake-ups from
345
+ * concurrent writers can arrive out of order: a feed holds a gap for
346
+ * `reorderMs` before catching up with a read, and a write waits at most
347
+ * `publishMs` for its PUBLISH. Mutable only as a white-box test seam.
331
348
  */
332
349
  export const NOTIFY_TIMINGS: {
333
350
  safetyReadMs: number
351
+ reorderMs: number
352
+ publishMs: number
334
353
  } = {
335
354
  safetyReadMs: 10_000,
355
+ reorderMs: 50,
356
+ publishMs: 1_000,
336
357
  }
337
358
 
338
359
  /**
@@ -422,15 +443,25 @@ export const SOCKET_TIMINGS: {
422
443
  }
423
444
 
424
445
  /**
425
- * Client presence send cadence — a leading send immediately, then at
426
- * most one merged send per interval (~15Hz). Owned, not configurable
427
- * (decision #36's rule): coupling the cadence to the POST ack tied
428
- * remote cursor smoothness to RTT, and the ack serves no correctness
429
- * purpose on a fire-and-forget LWW plane. Mutable only as a white-box
430
- * test seam.
446
+ * Presence timings. Owned, not configurable (decision #36's rule).
447
+ * Mutable only as a white-box test seam.
431
448
  */
432
449
  export const PRESENCE_TIMINGS: {
450
+ /**
451
+ * Client send cadence — a leading send immediately, then at most one
452
+ * merged send per interval (~15Hz). Coupling the cadence to the POST
453
+ * ack tied remote cursor smoothness to RTT, and the ack serves no
454
+ * correctness purpose on a fire-and-forget LWW plane.
455
+ */
433
456
  sendIntervalMs: number
457
+ /**
458
+ * How long, in local receipt time, a presence applier keeps a deleted
459
+ * field's stamp so an older set delivered after the delete still
460
+ * loses. Reorders span milliseconds; the bound keeps a long-lived
461
+ * client with churning participants from accumulating tombstones.
462
+ */
463
+ tombstoneMs: number
434
464
  } = {
435
465
  sendIntervalMs: 66,
466
+ tombstoneMs: 60_000,
436
467
  }
@@ -1,4 +1,5 @@
1
1
  import type { PresencePatch } from './contract.ts'
2
+ import { presenceTombstones } from './presence-tombstones.ts'
2
3
  import type { PresenceRow } from './store.ts'
3
4
 
4
5
  type Field = Pick<PresenceRow, 'participant' | 'field' | 'seen' | 'at'>
@@ -22,6 +23,7 @@ export function presenceRecovery({
22
23
  update(patch: PresencePatch): void
23
24
  } {
24
25
  const fields = new Map<string, Field>()
26
+ const deleted = presenceTombstones()
25
27
  const reading = new Set<Set<string>>()
26
28
  let generation = 0
27
29
  let latest: Promise<PresenceRow[]> | undefined
@@ -49,11 +51,14 @@ export function presenceRecovery({
49
51
  const rows = await read()
50
52
  if (!isActive()) return rows
51
53
  if (run !== generation) return latest!
54
+ // A row older than its field's deletion must not resurrect it.
55
+ const current = rows.filter((row) => !deleted.outranks(row))
52
56
  const live = new Set<string>()
53
- for (const row of rows) {
57
+ for (const row of current) {
54
58
  const id = key(row)
55
59
  live.add(id)
56
60
  if (changed.has(id)) continue
61
+ deleted.drop(row)
57
62
  remember(row)
58
63
  if (reconcile)
59
64
  deliver({
@@ -68,6 +73,7 @@ export function presenceRecovery({
68
73
  if (live.has(id) || changed.has(id) || fields.get(id) !== prior)
69
74
  continue
70
75
  fields.delete(id)
76
+ deleted.keep(prior)
71
77
  deliver({
72
78
  participant: prior.participant,
73
79
  values: { [prior.field]: null },
@@ -76,7 +82,7 @@ export function presenceRecovery({
76
82
  })
77
83
  }
78
84
  }
79
- return rows
85
+ return current
80
86
  } finally {
81
87
  reading.delete(changed)
82
88
  if (reading.size === 0) latest = undefined
@@ -102,18 +108,19 @@ export function presenceRecovery({
102
108
  update(patch) {
103
109
  for (const [field, value] of Object.entries(patch.values)) {
104
110
  if (value === undefined) continue
105
- const id = key({ participant: patch.participant, field })
111
+ const write = { participant: patch.participant, field, at: patch.at }
112
+ const id = key(write)
106
113
  const previous = fields.get(id)
107
114
  if (previous && previous.at.getTime() > patch.at.getTime()) continue
115
+ if (deleted.outranks(write)) continue
108
116
  for (const changed of reading) changed.add(id)
109
- if (value === null) fields.delete(id)
110
- else
111
- remember({
112
- participant: patch.participant,
113
- field,
114
- seen: patch.seen,
115
- at: patch.at,
116
- })
117
+ if (value === null) {
118
+ fields.delete(id)
119
+ deleted.keep(write)
120
+ } else {
121
+ deleted.drop(write)
122
+ remember({ ...write, seen: patch.seen })
123
+ }
117
124
  }
118
125
  },
119
126
  }
@@ -0,0 +1,52 @@
1
+ import { PRESENCE_TIMINGS } from './internal.ts'
2
+
3
+ type PresenceField = { participant: string; field: string }
4
+ type PresenceWrite = PresenceField & { at: Date }
5
+
6
+ /**
7
+ * The deletion stamps a presence applier keeps per (participant, field),
8
+ * so a null keeps beating older sets that arrive after it. Each lives
9
+ * `PRESENCE_TIMINGS.tombstoneMs` of local receipt time and is pruned
10
+ * lazily on use, without timers.
11
+ */
12
+ export type PresenceTombstones = {
13
+ /** Whether the field's deletion is strictly newer than the write. */
14
+ outranks(write: PresenceWrite): boolean
15
+ /** Keep the deletion; the caller has already ordered it past any prior one. */
16
+ keep(deletion: PresenceWrite): void
17
+ /** Forget the field's deletion once a write at or past it applies. */
18
+ drop(field: PresenceField): void
19
+ }
20
+
21
+ const key = ({ participant, field }: PresenceField): string =>
22
+ JSON.stringify([participant, field])
23
+
24
+ export function presenceTombstones(): PresenceTombstones {
25
+ // Insertion order is receipt order, so pruning stops at the first
26
+ // tombstone still inside the window.
27
+ const stamps = new Map<string, { atMs: number; keptAtMs: number }>()
28
+ const prune = (): void => {
29
+ const cutoff = Date.now() - PRESENCE_TIMINGS.tombstoneMs
30
+ for (const [id, stamp] of stamps) {
31
+ if (stamp.keptAtMs > cutoff) return
32
+ stamps.delete(id)
33
+ }
34
+ }
35
+ return {
36
+ outranks(write) {
37
+ if (stamps.size === 0) return false
38
+ prune()
39
+ const stamp = stamps.get(key(write))
40
+ return stamp !== undefined && stamp.atMs > write.at.getTime()
41
+ },
42
+ keep(deletion) {
43
+ prune()
44
+ const id = key(deletion)
45
+ stamps.delete(id)
46
+ stamps.set(id, { atMs: deletion.at.getTime(), keptAtMs: Date.now() })
47
+ },
48
+ drop(field) {
49
+ if (stamps.size > 0) stamps.delete(key(field))
50
+ },
51
+ }
52
+ }
@@ -107,8 +107,8 @@ export function parsePushEvents(events: unknown): ParsedPushEvent[] {
107
107
  )
108
108
  }
109
109
  // A2 derives the ids of what consumers append, return, and schedule as
110
- // UUIDv5s, from inputs a caller can know: one pushed first would take its
111
- // place. Callers' own ids are v4 (crypto.randomUUID) or v8 (idempotentId).
110
+ // UUIDv5s, from inputs a caller can know: one pushed first into the same
111
+ // session would take its place. Callers' own ids are v4 (crypto.randomUUID) or v8 (idempotentId).
112
112
  if (id !== undefined && DERIVED_ID.test(id)) {
113
113
  throw invalid(
114
114
  `events[${i}].id is shaped like the ids A2 derives for consumers (a UUIDv5); use crypto.randomUUID() or idempotentId()`,
package/src/server.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  runtimeConsumerFactory,
35
35
  runtimeConsumers,
36
36
  serverInternals,
37
+ drainSettledHooks,
37
38
  type DrainOutcome,
38
39
  type DrainResult,
39
40
  } from './internal.ts'
@@ -43,6 +44,7 @@ import {
43
44
  type SessionCleared,
44
45
  } from './session-clear.ts'
45
46
  import { boundedCache } from './bounded-cache.ts'
47
+ import { presenceTombstones } from './presence-tombstones.ts'
46
48
  import { defaultSleep, type Sleeper } from './store-polling.ts'
47
49
  import { createStreamActivity, streamActivities } from './stream-activity.ts'
48
50
  import {
@@ -2529,11 +2531,13 @@ export function createServer<
2529
2531
  }
2530
2532
 
2531
2533
  // What this feed has already painted, per (participant, field) —
2532
- // the comparison state for re-read diffs.
2534
+ // the comparison state for re-read diffs and for queued patches,
2535
+ // with the stamps of painted nulls kept beside it.
2533
2536
  const painted = new Map<
2534
2537
  string,
2535
2538
  Map<string, { seen: number; atMs: number; value: string }>
2536
2539
  >()
2540
+ const paintedDeletes = presenceTombstones()
2537
2541
 
2538
2542
  // One patch per (participant, field), never merged — a merged
2539
2543
  // stamp would let a stale field inherit a fresher sibling's
@@ -2582,10 +2586,12 @@ export function createServer<
2582
2586
  patches.push({
2583
2587
  participant,
2584
2588
  values: { [field]: null },
2585
- // A cleared field has no surviving row; its last painted
2586
- // `seen` and the observation time are the honest stamps.
2589
+ // A cleared field has no surviving row, so it keeps its
2590
+ // last painted stamps: only senders' clocks order the
2591
+ // plane, and an observer-stamped delete could outrank the
2592
+ // sender's next set.
2587
2593
  seen: prior.seen,
2588
- at: new Date(),
2594
+ at: new Date(prior.atMs),
2589
2595
  })
2590
2596
  }
2591
2597
  if (fields.size === 0) painted.delete(participant)
@@ -2596,10 +2602,13 @@ export function createServer<
2596
2602
  const paint = (patch: PresencePatch): void => {
2597
2603
  let fields = painted.get(patch.participant)
2598
2604
  for (const [field, value] of Object.entries(patch.values)) {
2605
+ const write = { participant: patch.participant, field, at: patch.at }
2599
2606
  if (value === null || value === undefined) {
2600
2607
  fields?.delete(field)
2608
+ if (value === null) paintedDeletes.keep(write)
2601
2609
  continue
2602
2610
  }
2611
+ paintedDeletes.drop(write)
2603
2612
  if (!fields) {
2604
2613
  fields = new Map()
2605
2614
  painted.set(patch.participant, fields)
@@ -2644,11 +2653,23 @@ export function createServer<
2644
2653
  }
2645
2654
  wakeQueue?.()
2646
2655
  }
2656
+ // A queued patch older than what this feed painted for its field —
2657
+ // the live value or its deletion — would repaint the past.
2658
+ const outdated = (
2659
+ write: Pick<PresenceRow, 'participant' | 'field' | 'at'>,
2660
+ ): boolean => {
2661
+ const prior = painted.get(write.participant)?.get(write.field)
2662
+ return (
2663
+ (prior !== undefined && prior.atMs > write.at.getTime()) ||
2664
+ paintedDeletes.outranks(write)
2665
+ )
2666
+ }
2647
2667
  const dequeue = (): PresencePatch | null => {
2648
2668
  for (const [participant, fields] of queue) {
2649
2669
  for (const [field, stamp] of fields) {
2650
2670
  fields.delete(field)
2651
2671
  if (fields.size === 0) queue.delete(participant)
2672
+ if (outdated({ participant, field, at: stamp.at })) continue
2652
2673
  return {
2653
2674
  participant,
2654
2675
  values: { [field]: stamp.value },
@@ -3430,7 +3451,29 @@ export function createServer<
3430
3451
  appendsEvents: boolean
3431
3452
  }
3432
3453
 
3433
- const drainSession = (
3454
+ type DrainOptions = {
3455
+ watchdogDueAt?: number | undefined
3456
+ signal?: DrainSignal | undefined
3457
+ /** Return a failure after arming as `failed` instead of throwing it. */
3458
+ handOffFailure?: boolean
3459
+ /** Claim and run only this consumer's heads, for `consume`. */
3460
+ only?: RegisteredConsumer | undefined
3461
+ /** With `only`: claim no head past it; what's past it is the background's. */
3462
+ through?: number | undefined
3463
+ }
3464
+
3465
+ const drainSession = async (
3466
+ sessionId: string,
3467
+ drainOptions: DrainOptions = {},
3468
+ ): Promise<DrainResult> => {
3469
+ const result = await runDrain(sessionId, drainOptions)
3470
+ // A settled `only` drain finished one consumer's heads, not the session's.
3471
+ if (result.settled && drainOptions.only === undefined)
3472
+ drainSettledHooks.get(self)?.(sessionId)
3473
+ return result
3474
+ }
3475
+
3476
+ const runDrain = (
3434
3477
  sessionId: string,
3435
3478
  {
3436
3479
  watchdogDueAt,
@@ -3438,16 +3481,7 @@ export function createServer<
3438
3481
  handOffFailure = false,
3439
3482
  only,
3440
3483
  through,
3441
- }: {
3442
- watchdogDueAt?: number | undefined
3443
- signal?: DrainSignal | undefined
3444
- /** Return a failure after arming as `failed` instead of throwing it. */
3445
- handOffFailure?: boolean
3446
- /** Claim and run only this consumer's heads, for `consume`. */
3447
- only?: RegisteredConsumer | undefined
3448
- /** With `only`: claim no head past it; what's past it is the background's. */
3449
- through?: number | undefined
3450
- } = {},
3484
+ }: DrainOptions = {},
3451
3485
  ): Promise<DrainResult> =>
3452
3486
  telemetry.span(
3453
3487
  'a2.drain',
@@ -74,6 +74,7 @@ type Cursor = {
74
74
  types: readonly string[] | null
75
75
  }
76
76
 
77
+ /** A null `value` is a tombstone: it keeps a delete's `at` until it expires. */
77
78
  type PresenceEntry = {
78
79
  value: unknown
79
80
  seen: number
@@ -88,6 +89,8 @@ type SessionLog = {
88
89
  offset: number
89
90
  /** Latest clear marker, or 0. */
90
91
  clearIndex: number
92
+ /** The session's unique index on event ids, like the SQL schemas'. */
93
+ byEventId: Map<string, Row>
91
94
  }
92
95
 
93
96
  const RECLAIM_BATCH = 256
@@ -219,8 +222,6 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
219
222
  const sessions = new Map<string, SessionLog>()
220
223
  /** Session id → cursor key → cursor. */
221
224
  const cursors = new Map<string, Map<string, Cursor>>()
222
- /** Global unique index on event ids, like the SQL schema's. */
223
- const byEventId = new Map<string, Row>()
224
225
  /** Keyed by `${sessionId}\u0000${reducerName}` — a pure cache. */
225
226
  const snapshots = new Map<
226
227
  string,
@@ -254,7 +255,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
254
255
  const logOf = (sessionId: string): SessionLog => {
255
256
  let log = sessions.get(sessionId)
256
257
  if (!log) {
257
- log = { rows: [], offset: 0, clearIndex: 0 }
258
+ log = { rows: [], offset: 0, clearIndex: 0, byEventId: new Map() }
258
259
  sessions.set(sessionId, log)
259
260
  }
260
261
  return log
@@ -270,12 +271,11 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
270
271
  const clearIndexOf = (sessionId: string): number =>
271
272
  sessions.get(sessionId)?.clearIndex ?? 0
272
273
 
273
- /** The row owning `id`, ignoring this session's rows below its clear. */
274
+ /** The session's row owning `id`, ignoring rows below its clear. */
274
275
  const ownerOf = (sessionId: string, id: string): Row | undefined => {
275
- const row = byEventId.get(id)
276
- return row?.sessionId === sessionId && row.index < clearIndexOf(sessionId)
277
- ? undefined
278
- : row
276
+ const log = sessions.get(sessionId)
277
+ const row = log?.byEventId.get(id)
278
+ return log && row && row.index >= log.clearIndex ? row : undefined
279
279
  }
280
280
 
281
281
  const cursorsOf = (sessionId: string): Map<string, Cursor> => {
@@ -437,17 +437,9 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
437
437
  'batch contains the same event id more than once',
438
438
  )
439
439
  }
440
- const existing = supplied
440
+ return supplied
441
441
  .map((candidate) => ownerOf(sessionId, candidate.id))
442
442
  .filter((row): row is Row => row !== undefined)
443
- const foreign = existing.find((row) => row.sessionId !== sessionId)
444
- if (foreign) {
445
- throw new A2Error(
446
- 'PARTIAL_DUPLICATE_BATCH',
447
- `event id '${foreign.id}' already exists in another session`,
448
- )
449
- }
450
- return existing
451
443
  }
452
444
 
453
445
  /** Records the types a declared log consumer takes on its cursor. */
@@ -576,7 +568,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
576
568
  }
577
569
  for (const row of inserted) {
578
570
  log.rows.push(row)
579
- byEventId.set(row.id, row)
571
+ log.byEventId.set(row.id, row)
580
572
  }
581
573
  enqueue(sessionId, inserted, events, base, consumers)
582
574
  return inserted
@@ -760,7 +752,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
760
752
  log.offset += removed.length
761
753
  for (const row of removed) {
762
754
  // A reused id belongs to its new row now.
763
- if (byEventId.get(row.id) === row) byEventId.delete(row.id)
755
+ if (log.byEventId.get(row.id) === row) log.byEventId.delete(row.id)
764
756
  }
765
757
  const done = log.offset === log.clearIndex - 1
766
758
  // Writes below the marker are dropped, so one sweep at the end suffices.
@@ -1226,26 +1218,25 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
1226
1218
 
1227
1219
  // Expiry anchors on the storage's own clock — the sender's
1228
1220
  // `at` orders writes but never extends or shortens a lifetime.
1229
- const expiresAtMs = clock.now().getTime() + meta.ttlMs
1221
+ const now = clock.now()
1222
+ const expiresAtMs = now.getTime() + meta.ttlMs
1230
1223
  // Field-keyed and caller-named — null-prototype, like every
1231
1224
  // presence map (see `nullProtoRecord`).
1232
1225
  const applied: Record<string, unknown> = nullProtoRecord()
1233
1226
  let appliedCount = 0
1234
1227
  for (const [field, value] of Object.entries(values)) {
1235
- // Field-wise LWW by `at`; ties go to the incoming write, so
1228
+ // Field-wise LWW by `at` against unexpired values and
1229
+ // tombstones alike; ties go to the incoming write, so
1236
1230
  // same-stamp sets keep set-then-read intuition.
1237
1231
  const existing = fields.get(field)
1238
- if (existing && existing.at > meta.at) continue
1239
- if (value === null) {
1240
- fields.delete(field)
1241
- } else {
1242
- fields.set(field, {
1243
- value: structuredClone(value),
1244
- seen: meta.seen,
1245
- at: new Date(meta.at),
1246
- expiresAt: new Date(expiresAtMs),
1247
- })
1248
- }
1232
+ if (existing && existing.expiresAt > now && existing.at > meta.at)
1233
+ continue
1234
+ fields.set(field, {
1235
+ value: structuredClone(value),
1236
+ seen: meta.seen,
1237
+ at: new Date(meta.at),
1238
+ expiresAt: new Date(expiresAtMs),
1239
+ })
1249
1240
  applied[field] = structuredClone(value)
1250
1241
  appliedCount += 1
1251
1242
  }
@@ -1279,6 +1270,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
1279
1270
  fields.delete(field)
1280
1271
  continue
1281
1272
  }
1273
+ if (entry.value === null) continue
1282
1274
  rows.push({
1283
1275
  participant,
1284
1276
  field,
@@ -570,9 +570,9 @@ const INSERTED_AFTER = `(
570
570
 
571
571
  /**
572
572
  * An append's checks, under the session lock: the session's head and pending
573
- * state, and what its supplied `ids` say. When every event's id belongs to
574
- * this session already, the batch is a retry: it stops with the committed
575
- * rows. Some owned, or one in another session, aborts, as does scheduled work
573
+ * state, and what its supplied `ids` say. Ids are unique per session. When
574
+ * every event's id belongs to this session already, the batch is a retry: it
575
+ * stops with the committed rows. Some owned aborts, as does scheduled work
576
576
  * from at or below the clear marker (`origin`). Rows below the marker own no
577
577
  * id.
578
578
  */
@@ -586,15 +586,11 @@ const APPEND_CHECKS = `
586
586
  select event.session_id, event.idx, event.event_type, event.payload,
587
587
  event.event_id, event.created_at, event.cause
588
588
  from a2_events event, boundary
589
- where event.event_id = any(${param.texts('ids')})
590
- and not (event.session_id = ${SESSION}
591
- and event.idx < boundary.clear_index)
589
+ where event.session_id = ${SESSION}
590
+ and event.event_id = any(${param.texts('ids')})
591
+ and event.idx >= boundary.clear_index
592
592
  )
593
593
  select case
594
- when exists (select 1 from owned where session_id <> ${SESSION}) then
595
- jsonb_build_object('abort', 'foreign', 'event_id', (
596
- select event_id from owned where session_id <> ${SESSION}
597
- order by idx limit 1))
598
594
  when (select count(*) from owned) = ${param.bigint('count')} then
599
595
  jsonb_build_object('stop', 'replay',
600
596
  'has_pending', boundary.has_pending,
@@ -1083,9 +1079,9 @@ const RETURN_CHECKS = `
1083
1079
  jsonb_build_object('stop', 'superseded')
1084
1080
  when exists (
1085
1081
  select 1 from a2_events event
1086
- where event.event_id = any(${param.texts('ids')})
1087
- and not (event.session_id = ${SESSION}
1088
- and event.idx < ${clearIndexOf(SESSION)})
1082
+ where event.session_id = ${SESSION}
1083
+ and event.event_id = any(${param.texts('ids')})
1084
+ and event.idx >= ${clearIndexOf(SESSION)}
1089
1085
  ) then jsonb_build_object('abort', 'owned')
1090
1086
  else jsonb_build_object('base', (
1091
1087
  select coalesce(max(idx), 0) from a2_events
@@ -1737,8 +1733,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1737
1733
  if (events.length === 0) return { outcome: 'completed', events: [] }
1738
1734
  const { rows: existing } = await query(
1739
1735
  `select session_id, idx, event_type, payload, event_id, created_at, cause
1740
- from a2_events where event_id = any($1::text[])`,
1741
- [ids],
1736
+ from a2_events where session_id = $1 and event_id = any($2::text[])`,
1737
+ [sessionId, ids],
1742
1738
  )
1743
1739
  const byId = new Map(
1744
1740
  existing.map((row) => [row['event_id'] as string, row]),
@@ -1754,7 +1750,6 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1754
1750
  const cause = row ? decodeCause(row['cause']) : null
1755
1751
  return (
1756
1752
  !row ||
1757
- row['session_id'] !== sessionId ||
1758
1753
  cause?.consumer !== consumer ||
1759
1754
  cause.lane !== lane ||
1760
1755
  cause.index !== index ||
@@ -1849,11 +1844,6 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1849
1844
  },
1850
1845
  abort: (reason) => {
1851
1846
  switch (reason['abort']) {
1852
- case 'foreign':
1853
- return new A2Error(
1854
- 'PARTIAL_DUPLICATE_BATCH',
1855
- `event id '${String(reason['event_id'])}' already exists in another session`,
1856
- )
1857
1847
  case 'mixed': {
1858
1848
  const owned = asNumber(reason['owned'])
1859
1849
  return new A2Error(
@@ -2710,40 +2700,38 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
2710
2700
  const atMs = meta.at.getTime()
2711
2701
  // Expiry anchors on the storage clock — the sender's `at`
2712
2702
  // orders writes but never anchors their lifetime.
2713
- const expiresAtMs = clock.now().getTime() + meta.ttlMs
2703
+ const nowMs = clock.now().getTime()
2704
+ const expiresAtMs = nowMs + meta.ttlMs
2714
2705
  const applied: Record<string, unknown> = nullProtoRecord()
2715
2706
  const write = async (query: Query): Promise<void> => {
2716
2707
  for (const [field, value] of entries) {
2717
- // Field-wise LWW: a stored row with a strictly newer `at`
2718
- // survives; ties go to the incoming write (memory parity).
2708
+ // Field-wise LWW against values and tombstones (a JSON null
2709
+ // value, a delete's `at` kept until it expires) alike: an
2710
+ // unexpired stored row with a strictly newer `at` survives;
2711
+ // ties go to the incoming write (memory parity).
2719
2712
  // oxlint-disable-next-line no-await-in-loop -- independent per-field registers
2720
- const result = await (value === null
2721
- ? query(
2722
- `delete from a2_presence
2723
- where ns = $1 and participant = $2 and field = $3
2724
- and at <= $4${notifications ? ' returning field' : ''}`,
2725
- [ns, participant, field, atMs],
2726
- )
2727
- : query(
2728
- `insert into a2_presence
2729
- (ns, participant, field, value, seen, at, expires_at)
2730
- values ($1, $2, $3, $4::jsonb, $5, $6, $7)
2731
- on conflict (ns, participant, field) do update set
2732
- value = excluded.value,
2733
- seen = excluded.seen,
2734
- at = excluded.at,
2735
- expires_at = excluded.expires_at
2736
- where a2_presence.at <= excluded.at${notifications ? ' returning field' : ''}`,
2737
- [
2738
- ns,
2739
- participant,
2740
- field,
2741
- jsonb(value),
2742
- meta.seen,
2743
- atMs,
2744
- expiresAtMs,
2745
- ],
2746
- ))
2713
+ const result = await query(
2714
+ `insert into a2_presence
2715
+ (ns, participant, field, value, seen, at, expires_at)
2716
+ values ($1, $2, $3, $4::jsonb, $5, $6, $7)
2717
+ on conflict (ns, participant, field) do update set
2718
+ value = excluded.value,
2719
+ seen = excluded.seen,
2720
+ at = excluded.at,
2721
+ expires_at = excluded.expires_at
2722
+ where a2_presence.at <= excluded.at
2723
+ or a2_presence.expires_at <= $8${notifications ? ' returning field' : ''}`,
2724
+ [
2725
+ ns,
2726
+ participant,
2727
+ field,
2728
+ jsonb(value),
2729
+ meta.seen,
2730
+ atMs,
2731
+ expiresAtMs,
2732
+ nowMs,
2733
+ ],
2734
+ )
2747
2735
  if (result.rows.length) applied[field] = structuredClone(value)
2748
2736
  }
2749
2737
  }
@@ -2781,7 +2769,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
2781
2769
  )
2782
2770
  select participant, field, value, seen, at, expires_at
2783
2771
  from a2_presence
2784
- where ns = $1 and expires_at > $2
2772
+ where ns = $1 and expires_at > $2 and value <> 'null'::jsonb
2785
2773
  order by participant, field`,
2786
2774
  [ns, now],
2787
2775
  )
@@ -3025,7 +3013,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
3025
3013
  const subscription = presenceSubscriptions.get(ns)
3026
3014
  await subscription?.ready
3027
3015
  await subscription?.poll.ready
3028
- return read(ns)
3016
+ return subscription
3017
+ ? subscription.poll.snapshot(() => read(ns))
3018
+ : read(ns)
3029
3019
  }
3030
3020
  presence.subscribe = (ns, onPatch) => {
3031
3021
  if (closed) throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')