experimental-a2 0.21.1 → 0.23.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 (148) hide show
  1. package/CHANGELOG.md +32 -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 +234 -62
  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-CBDVJxA_.js} +545 -399
  73. package/dist/store-redis-notify-CBDVJxA_.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/store-valkey.d.ts +33 -0
  83. package/dist/store-valkey.d.ts.map +1 -0
  84. package/dist/store-valkey.js +218 -0
  85. package/dist/store-valkey.js.map +1 -0
  86. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  87. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  88. package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
  89. package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
  90. package/docs/actors/01-introduction.mdx +5 -2
  91. package/docs/agents/03-conversations.mdx +9 -8
  92. package/docs/agents/04-harnesses.mdx +7 -4
  93. package/docs/client/03-presence.mdx +1 -0
  94. package/docs/reference/01-core.mdx +4 -2
  95. package/docs/reference/02-server.mdx +5 -1
  96. package/docs/reference/04-ai.mdx +13 -4
  97. package/docs/reference/06-errors.mdx +1 -1
  98. package/docs/server/01-consumers.mdx +4 -1
  99. package/docs/server/06-guarantees.mdx +7 -4
  100. package/docs/stores/01-overview.mdx +1 -0
  101. package/docs/stores/02-redis.mdx +18 -10
  102. package/docs/stores/03-redis-http.mdx +6 -4
  103. package/docs/stores/04-postgres.mdx +2 -2
  104. package/docs/stores/07-valkey.mdx +97 -0
  105. package/docs/upgrades/01-overview.mdx +1 -0
  106. package/docs/upgrades/78-0-22.mdx +115 -0
  107. package/examples/playground/app/a0/sandbox.ts +58 -13
  108. package/examples/playground/app/a2slack/db/a2.ts +7 -4
  109. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  110. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  111. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  112. package/examples/playground/app/chat/server.ts +2 -2
  113. package/examples/playground/lib/store.ts +1 -1
  114. package/examples/playground/package.json +1 -1
  115. package/package.json +8 -2
  116. package/sql/postgres/drizzle-schema.ts +7 -4
  117. package/sql/postgres/schema.sql +24 -3
  118. package/src/actor-shared.ts +10 -2
  119. package/src/ai-agent.ts +13 -0
  120. package/src/ai-fx.ts +365 -78
  121. package/src/ai-harness.ts +7 -0
  122. package/src/client.ts +19 -2
  123. package/src/deterministic-id.ts +1 -1
  124. package/src/internal.ts +42 -11
  125. package/src/libfx.d.ts +2 -0
  126. package/src/presence-recovery.ts +18 -11
  127. package/src/presence-tombstones.ts +52 -0
  128. package/src/push-envelope.ts +2 -2
  129. package/src/server.ts +49 -15
  130. package/src/store-memory.ts +24 -32
  131. package/src/store-postgres.ts +43 -53
  132. package/src/store-presence-polling.ts +59 -34
  133. package/src/store-redis-core.ts +595 -439
  134. package/src/store-redis-notify.ts +82 -28
  135. package/src/store-redis.ts +2 -3
  136. package/src/store-sqlite.ts +34 -40
  137. package/src/store-valkey.ts +376 -0
  138. package/src/store.ts +14 -5
  139. package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
  140. package/dist/client-9IeTsiAs.js.map +0 -1
  141. package/dist/server-DvIB8kj5.js.map +0 -1
  142. package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
  143. package/dist/store-Z2qjw50n.d.ts.map +0 -1
  144. package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
  145. package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
  146. package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
  147. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  148. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
@@ -53,6 +53,9 @@ type Feed = {
53
53
  inbox: string[]
54
54
  bytes: number
55
55
  dirty: boolean
56
+ /** Wake-ups past a gap, waiting up to NOTIFY_TIMINGS.reorderMs for it. */
57
+ held: Array<{ wake: Wake; bytes: number }>
58
+ gapSince: number | undefined
56
59
  wake: (() => void) | null
57
60
  stopRead: () => Promise<void>
58
61
  }
@@ -61,15 +64,14 @@ type Feed = {
61
64
  const INBOX_BYTES = 512 * 1024
62
65
 
63
66
  type WakeEntry = [index: number, id: string, type: string, payload: unknown]
67
+ type Wake = { frontier: number; createdAt: number; entries: WakeEntry[] }
64
68
 
65
69
  /**
66
70
  * Parses a wake-up message: `{"f":frontier}` or a bare frontier number
67
71
  * announces events without carrying them; `{"f","t","e"}` carries them.
68
72
  * Returns null for anything else, which callers treat as a hint to catch up.
69
73
  */
70
- const parseWake = (
71
- message: string,
72
- ): { frontier: number; createdAt: number; entries: WakeEntry[] } | null => {
74
+ const parseWake = (message: string): Wake | null => {
73
75
  let parsed: unknown
74
76
  try {
75
77
  parsed = JSON.parse(message)
@@ -187,6 +189,8 @@ export function createRedisNotifyStore(options: {
187
189
  feed.bytes += message.length
188
190
  } else {
189
191
  feed.inbox.length = 0
192
+ feed.held.length = 0
193
+ feed.gapSince = undefined
190
194
  feed.bytes = 0
191
195
  feed.dirty = true
192
196
  }
@@ -213,11 +217,17 @@ export function createRedisNotifyStore(options: {
213
217
  */
214
218
  let subscriberGate: Promise<RedisConnection> | null = null
215
219
  const gatedSubscriber = (): Promise<RedisConnection> => {
216
- subscriberGate ??= getSubscriber().then(async (conn) => {
220
+ if (subscriberGate) return subscriberGate
221
+ const gate = getSubscriber().then(async (conn) => {
217
222
  await conn.call('echo', 'ready')
218
223
  return conn
219
224
  })
220
- return subscriberGate
225
+ subscriberGate = gate
226
+ // A failed check is retried by the next channel command.
227
+ void gate.catch(() => {
228
+ if (subscriberGate === gate) subscriberGate = null
229
+ })
230
+ return gate
221
231
  }
222
232
 
223
233
  const acquireChannel = async (channel: string): Promise<void> => {
@@ -299,8 +309,9 @@ export function createRedisNotifyStore(options: {
299
309
 
300
310
  presence: {
301
311
  async set(ns, participant, values, meta) {
302
- const patch = await core.presence.set(ns, participant, values, meta)
303
- if (!patch) return
312
+ const applied = await core.presence.set(ns, participant, values, meta)
313
+ if (!applied) return
314
+ const { patch } = applied
304
315
  const channel = presenceChannel(ns)
305
316
  const subscription = presenceSubscriptions.get(channel)
306
317
  if (subscription) {
@@ -308,13 +319,16 @@ export function createRedisNotifyStore(options: {
308
319
  subscription.recovery?.update(patch)
309
320
  for (const listener of subscription.listeners) listener(patch)
310
321
  }
322
+ await applied.published
311
323
  },
312
324
 
313
325
  async read(ns) {
314
326
  const channel = presenceChannel(ns)
315
327
  await subscriptions.get(channel)?.ready
328
+ const subscription = presenceSubscriptions.get(channel)
316
329
  return (
317
- presenceSubscriptions.get(channel)?.recovery?.read() ??
330
+ subscription?.recovery?.read() ??
331
+ subscription?.poll?.snapshot(() => core.presence.read(ns)) ??
318
332
  core.presence.read(ns)
319
333
  )
320
334
  },
@@ -417,6 +431,8 @@ export function createRedisNotifyStore(options: {
417
431
  inbox: [],
418
432
  bytes: 0,
419
433
  dirty: true,
434
+ held: [],
435
+ gapSince: undefined,
420
436
  wake: null,
421
437
  stopRead,
422
438
  }
@@ -441,36 +457,51 @@ export function createRedisNotifyStore(options: {
441
457
 
442
458
  /**
443
459
  * Moves carried events that continue this feed's sequence into
444
- * the buffer. A gap, a message without events, or an unreadable
445
- * message marks the feed dirty so it catches up with XRANGE.
460
+ * the buffer. Writers publish after their scripts, so concurrent
461
+ * writers' wake-ups can arrive out of order: they apply in index
462
+ * order, and one past a gap is held until the gap fills or
463
+ * NOTIFY_TIMINGS.reorderMs passes. A message without events past
464
+ * the buffer, or an unreadable one, marks the feed dirty so it
465
+ * catches up with XRANGE.
446
466
  */
447
467
  const applyInbox = (): void => {
448
- feed.bytes = 0
468
+ const carried = feed.held.splice(0)
469
+ let announced = 0
449
470
  for (const message of feed.inbox.splice(0)) {
450
471
  const wake = parseWake(message)
451
- if (!wake) {
452
- feed.dirty = true
472
+ if (!wake) feed.dirty = true
473
+ else if (wake.entries.length === 0)
474
+ announced = Math.max(announced, wake.frontier)
475
+ else carried.push({ wake, bytes: message.length })
476
+ }
477
+ carried.sort(
478
+ (a, b) => a.wake.entries[0]![0] - b.wake.entries[0]![0],
479
+ )
480
+ feed.bytes = 0
481
+ for (const held of carried) {
482
+ if (
483
+ held.wake.entries[0]![0] >
484
+ (buffer.at(-1)?.index ?? last) + 1
485
+ ) {
486
+ feed.held.push(held)
487
+ feed.bytes += held.bytes
453
488
  continue
454
489
  }
455
- for (const [index, id, type, payload] of wake.entries) {
456
- const expected = (buffer.at(-1)?.index ?? last) + 1
457
- if (index < expected) continue
458
- if (index > expected) {
459
- feed.dirty = true
460
- break
461
- }
490
+ for (const [index, id, type, payload] of held.wake.entries) {
491
+ if (index <= (buffer.at(-1)?.index ?? last)) continue
462
492
  buffer.push({
463
493
  id,
464
494
  type,
465
495
  payload,
466
496
  index,
467
497
  sessionId,
468
- createdAt: new Date(wake.createdAt),
498
+ createdAt: new Date(held.wake.createdAt),
469
499
  })
470
500
  }
471
- if (wake.frontier > (buffer.at(-1)?.index ?? last))
472
- feed.dirty = true
473
501
  }
502
+ if (announced > (buffer.at(-1)?.index ?? last)) feed.dirty = true
503
+ if (feed.held.length === 0) feed.gapSince = undefined
504
+ else feed.gapSince ??= Date.now()
474
505
  }
475
506
 
476
507
  const iterator: AsyncIterator<Event> = {
@@ -517,8 +548,14 @@ export function createRedisNotifyStore(options: {
517
548
  feeds.set(channel, set)
518
549
  }
519
550
  set.add(feed)
551
+ // A feed starts at the latest clear at the earliest:
552
+ // entries below it await reclaim.
520
553
  // oxlint-disable-next-line no-await-in-loop -- one-time setup
521
- await acquireChannel(channel)
554
+ const [, clearIndex] = await Promise.all([
555
+ acquireChannel(channel),
556
+ core.clearIndex(sessionId),
557
+ ])
558
+ if (clearIndex - 1 > last) last = clearIndex - 1
522
559
  if (closed || severed) continue
523
560
  }
524
561
  if (options.polling?.()) {
@@ -554,8 +591,16 @@ export function createRedisNotifyStore(options: {
554
591
  }
555
592
  applyInbox()
556
593
  if (buffer.length > 0) continue
594
+ if (
595
+ feed.gapSince !== undefined &&
596
+ Date.now() - feed.gapSince >= NOTIFY_TIMINGS.reorderMs
597
+ )
598
+ feed.dirty = true
557
599
  if (feed.dirty) {
558
600
  feed.dirty = false
601
+ // A read covers whatever the held wake-ups carry.
602
+ feed.held.length = 0
603
+ feed.gapSince = undefined
559
604
  if (core.streamEvents) {
560
605
  // oxlint-disable-next-line no-await-in-loop -- begin an incremental catch-up
561
606
  const result = await nextRead(last)
@@ -568,12 +613,21 @@ export function createRedisNotifyStore(options: {
568
613
  if (buffer.length >= FEED_PAGE_SIZE) feed.dirty = true
569
614
  continue
570
615
  }
571
- // Caught up: park until a message, a reconnect, or the
572
- // safety read that heals a lost message.
616
+ // Caught up: park until a message, a reconnect, the end of
617
+ // a held gap, or the safety read that heals a lost message.
573
618
  const woken = new Promise<'wake'>((resolve) => {
574
619
  feed.wake = () => resolve('wake')
575
620
  })
576
- pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
621
+ const holding = feed.gapSince !== undefined
622
+ pending = defaultSleep(
623
+ holding
624
+ ? Math.max(
625
+ 0,
626
+ NOTIFY_TIMINGS.reorderMs -
627
+ (Date.now() - feed.gapSince!),
628
+ )
629
+ : NOTIFY_TIMINGS.safetyReadMs,
630
+ )
577
631
  // oxlint-disable-next-line no-await-in-loop -- live-feed loop
578
632
  const outcome = await Promise.race([
579
633
  woken,
@@ -581,7 +635,7 @@ export function createRedisNotifyStore(options: {
581
635
  ])
582
636
  pending.cancel()
583
637
  pending = null
584
- if (outcome === 'timer') feed.dirty = true
638
+ if (outcome === 'timer' && !holding) feed.dirty = true
585
639
  } catch (err) {
586
640
  release()
587
641
  if (closed) return { value: undefined, done: true }
@@ -15,9 +15,8 @@
15
15
  *
16
16
  * Works with any Redis-protocol server on a single instance or a
17
17
  * non-cluster provider (Upstash — durable by default — Redis, Valkey).
18
- * Cluster mode is out: the atomic scripts span keys. `ioredis` is an
19
- * optional peer dependency; pass `url`, or inject any client exposing
20
- * `call`/`duplicate`/`on`/`disconnect`.
18
+ * `ioredis` is an optional peer dependency; pass `url`, or inject any client
19
+ * exposing `call`/`duplicate`/`on`/`disconnect`.
21
20
  */
22
21
 
23
22
  import { A2Error } from './errors.ts'
@@ -75,7 +75,9 @@ create table if not exists a2_events (
75
75
  primary key (session_id, idx)
76
76
  ) strict;
77
77
 
78
- create unique index if not exists a2_events_event_id on a2_events (event_id);
78
+ create unique index if not exists a2_events_session_event_id
79
+ on a2_events (session_id, event_id);
80
+ drop index if exists a2_events_event_id;
79
81
  create index if not exists a2_events_types on a2_events (session_id, event_type, idx);
80
82
 
81
83
  create table if not exists a2_cursors (
@@ -485,6 +487,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
485
487
  set completed_idx = ?, completed_attempt = ?, returned_event_ids = ?
486
488
  where session_id = ? and consumer = ? and cursor_key = ?`,
487
489
  )
490
+ // A JSON null value is a tombstone: a delete's `at`, kept until it
491
+ // expires so an older set arriving after it still loses.
488
492
  const upsertPresence = db.prepare(
489
493
  `insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
490
494
  values (?, ?, ?, ?, ?, ?, ?)
@@ -493,11 +497,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
493
497
  seen = excluded.seen,
494
498
  at = excluded.at,
495
499
  expires_at = excluded.expires_at
496
- where a2_presence.at <= excluded.at`,
497
- )
498
- const deletePresence = db.prepare(
499
- `delete from a2_presence
500
- where ns = ? and participant = ? and field = ? and at <= ?`,
500
+ where a2_presence.at <= excluded.at or a2_presence.expires_at <= ?`,
501
501
  )
502
502
  const sweepPresence = db.prepare(
503
503
  'delete from a2_presence where ns = ? and expires_at <= ?',
@@ -505,13 +505,13 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
505
505
  const selectPresence = db.prepare(
506
506
  `select participant, field, value, seen, at, expires_at
507
507
  from a2_presence
508
- where ns = ? and expires_at > ?
508
+ where ns = ? and expires_at > ? and value != 'null'
509
509
  order by participant, field`,
510
510
  )
511
511
  const selectByIds = (count: number) =>
512
512
  db.prepare(
513
513
  `select * from a2_events
514
- where event_id in (${placeholders(count)})
514
+ where session_id = ? and event_id in (${placeholders(count)})
515
515
  order by idx`,
516
516
  )
517
517
 
@@ -561,8 +561,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
561
561
  }
562
562
 
563
563
  /**
564
- * The rows owning `ids`. This session's rows below its clear marker do
565
- * not own their ids: they are deleted here so the ids can be reused.
564
+ * The session's rows owning `ids`. Rows below its clear marker do not
565
+ * own their ids: they are deleted here so the ids can be reused.
566
566
  */
567
567
  const liveOwnersOf = (
568
568
  sessionId: string,
@@ -570,10 +570,12 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
570
570
  ids: readonly string[],
571
571
  ): EventRow[] => {
572
572
  if (ids.length === 0) return []
573
- const rows = selectByIds(ids.length).all(...ids) as unknown as EventRow[]
573
+ const rows = selectByIds(ids.length).all(
574
+ sessionId,
575
+ ...ids,
576
+ ) as unknown as EventRow[]
574
577
  return rows.filter((row) => {
575
- if (row.session_id !== sessionId || Number(row.idx) >= clearIndex)
576
- return true
578
+ if (Number(row.idx) >= clearIndex) return true
577
579
  deleteEventById.run(sessionId, row.event_id)
578
580
  return false
579
581
  })
@@ -851,15 +853,6 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
851
853
  if (suppliedIds.length > 0) {
852
854
  const existing = liveOwnersOf(sessionId, clearIndex, suppliedIds)
853
855
  if (existing.length > 0) {
854
- const foreign = existing.find(
855
- (row) => row.session_id !== sessionId,
856
- )
857
- if (foreign) {
858
- throw new A2Error(
859
- 'PARTIAL_DUPLICATE_BATCH',
860
- `event id '${foreign.event_id}' already exists in another session`,
861
- )
862
- }
863
856
  if (existing.length === events.length) {
864
857
  return {
865
858
  events: idempotentReplay(events, existing.map(toStored)),
@@ -1244,7 +1237,10 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1244
1237
  const existing =
1245
1238
  ids.length === 0
1246
1239
  ? []
1247
- : (selectByIds(ids.length).all(...ids) as unknown as EventRow[])
1240
+ : (selectByIds(ids.length).all(
1241
+ sessionId,
1242
+ ...ids,
1243
+ ) as unknown as EventRow[])
1248
1244
  const byId = new Map(existing.map((row) => [row.event_id, row]))
1249
1245
  const ordered = ids.map((id) => byId.get(id))
1250
1246
  if (
@@ -1252,7 +1248,6 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1252
1248
  const cause = row ? decodeCause(row.cause) : null
1253
1249
  return (
1254
1250
  !row ||
1255
- row.session_id !== sessionId ||
1256
1251
  cause?.consumer !== consumer ||
1257
1252
  cause.lane !== lane ||
1258
1253
  cause.index !== index ||
@@ -1681,23 +1676,22 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1681
1676
  const atMs = meta.at.getTime()
1682
1677
  // Expiry anchors on the storage clock — the sender's `at`
1683
1678
  // orders writes but never anchors their lifetime.
1684
- const expiresAtMs = clock.now().getTime() + meta.ttlMs
1679
+ const nowMs = clock.now().getTime()
1680
+ const expiresAtMs = nowMs + meta.ttlMs
1685
1681
  for (const [field, value] of entries) {
1686
- // Field-wise LWW: a stored row with a strictly newer `at`
1687
- // survives; ties go to the incoming write (memory parity).
1688
- if (value === null) {
1689
- deletePresence.run(ns, participant, field, atMs)
1690
- } else {
1691
- upsertPresence.run(
1692
- ns,
1693
- participant,
1694
- field,
1695
- JSON.stringify(value) ?? 'null',
1696
- meta.seen,
1697
- atMs,
1698
- expiresAtMs,
1699
- )
1700
- }
1682
+ // Field-wise LWW against values and tombstones alike: an
1683
+ // unexpired stored row with a strictly newer `at` survives;
1684
+ // ties go to the incoming write (memory parity).
1685
+ upsertPresence.run(
1686
+ ns,
1687
+ participant,
1688
+ field,
1689
+ JSON.stringify(value) ?? 'null',
1690
+ meta.seen,
1691
+ atMs,
1692
+ expiresAtMs,
1693
+ nowMs,
1694
+ )
1701
1695
  }
1702
1696
  }),
1703
1697
  )