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
@@ -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
  }
@@ -299,8 +303,9 @@ export function createRedisNotifyStore(options: {
299
303
 
300
304
  presence: {
301
305
  async set(ns, participant, values, meta) {
302
- const patch = await core.presence.set(ns, participant, values, meta)
303
- if (!patch) return
306
+ const applied = await core.presence.set(ns, participant, values, meta)
307
+ if (!applied) return
308
+ const { patch } = applied
304
309
  const channel = presenceChannel(ns)
305
310
  const subscription = presenceSubscriptions.get(channel)
306
311
  if (subscription) {
@@ -308,13 +313,16 @@ export function createRedisNotifyStore(options: {
308
313
  subscription.recovery?.update(patch)
309
314
  for (const listener of subscription.listeners) listener(patch)
310
315
  }
316
+ await applied.published
311
317
  },
312
318
 
313
319
  async read(ns) {
314
320
  const channel = presenceChannel(ns)
315
321
  await subscriptions.get(channel)?.ready
322
+ const subscription = presenceSubscriptions.get(channel)
316
323
  return (
317
- presenceSubscriptions.get(channel)?.recovery?.read() ??
324
+ subscription?.recovery?.read() ??
325
+ subscription?.poll?.snapshot(() => core.presence.read(ns)) ??
318
326
  core.presence.read(ns)
319
327
  )
320
328
  },
@@ -417,6 +425,8 @@ export function createRedisNotifyStore(options: {
417
425
  inbox: [],
418
426
  bytes: 0,
419
427
  dirty: true,
428
+ held: [],
429
+ gapSince: undefined,
420
430
  wake: null,
421
431
  stopRead,
422
432
  }
@@ -441,36 +451,51 @@ export function createRedisNotifyStore(options: {
441
451
 
442
452
  /**
443
453
  * 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.
454
+ * the buffer. Writers publish after their scripts, so concurrent
455
+ * writers' wake-ups can arrive out of order: they apply in index
456
+ * order, and one past a gap is held until the gap fills or
457
+ * NOTIFY_TIMINGS.reorderMs passes. A message without events past
458
+ * the buffer, or an unreadable one, marks the feed dirty so it
459
+ * catches up with XRANGE.
446
460
  */
447
461
  const applyInbox = (): void => {
448
- feed.bytes = 0
462
+ const carried = feed.held.splice(0)
463
+ let announced = 0
449
464
  for (const message of feed.inbox.splice(0)) {
450
465
  const wake = parseWake(message)
451
- if (!wake) {
452
- feed.dirty = true
466
+ if (!wake) feed.dirty = true
467
+ else if (wake.entries.length === 0)
468
+ announced = Math.max(announced, wake.frontier)
469
+ else carried.push({ wake, bytes: message.length })
470
+ }
471
+ carried.sort(
472
+ (a, b) => a.wake.entries[0]![0] - b.wake.entries[0]![0],
473
+ )
474
+ feed.bytes = 0
475
+ for (const held of carried) {
476
+ if (
477
+ held.wake.entries[0]![0] >
478
+ (buffer.at(-1)?.index ?? last) + 1
479
+ ) {
480
+ feed.held.push(held)
481
+ feed.bytes += held.bytes
453
482
  continue
454
483
  }
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
- }
484
+ for (const [index, id, type, payload] of held.wake.entries) {
485
+ if (index <= (buffer.at(-1)?.index ?? last)) continue
462
486
  buffer.push({
463
487
  id,
464
488
  type,
465
489
  payload,
466
490
  index,
467
491
  sessionId,
468
- createdAt: new Date(wake.createdAt),
492
+ createdAt: new Date(held.wake.createdAt),
469
493
  })
470
494
  }
471
- if (wake.frontier > (buffer.at(-1)?.index ?? last))
472
- feed.dirty = true
473
495
  }
496
+ if (announced > (buffer.at(-1)?.index ?? last)) feed.dirty = true
497
+ if (feed.held.length === 0) feed.gapSince = undefined
498
+ else feed.gapSince ??= Date.now()
474
499
  }
475
500
 
476
501
  const iterator: AsyncIterator<Event> = {
@@ -517,8 +542,14 @@ export function createRedisNotifyStore(options: {
517
542
  feeds.set(channel, set)
518
543
  }
519
544
  set.add(feed)
545
+ // A feed starts at the latest clear at the earliest:
546
+ // entries below it await reclaim.
520
547
  // oxlint-disable-next-line no-await-in-loop -- one-time setup
521
- await acquireChannel(channel)
548
+ const [, clearIndex] = await Promise.all([
549
+ acquireChannel(channel),
550
+ core.clearIndex(sessionId),
551
+ ])
552
+ if (clearIndex - 1 > last) last = clearIndex - 1
522
553
  if (closed || severed) continue
523
554
  }
524
555
  if (options.polling?.()) {
@@ -554,8 +585,16 @@ export function createRedisNotifyStore(options: {
554
585
  }
555
586
  applyInbox()
556
587
  if (buffer.length > 0) continue
588
+ if (
589
+ feed.gapSince !== undefined &&
590
+ Date.now() - feed.gapSince >= NOTIFY_TIMINGS.reorderMs
591
+ )
592
+ feed.dirty = true
557
593
  if (feed.dirty) {
558
594
  feed.dirty = false
595
+ // A read covers whatever the held wake-ups carry.
596
+ feed.held.length = 0
597
+ feed.gapSince = undefined
559
598
  if (core.streamEvents) {
560
599
  // oxlint-disable-next-line no-await-in-loop -- begin an incremental catch-up
561
600
  const result = await nextRead(last)
@@ -568,12 +607,21 @@ export function createRedisNotifyStore(options: {
568
607
  if (buffer.length >= FEED_PAGE_SIZE) feed.dirty = true
569
608
  continue
570
609
  }
571
- // Caught up: park until a message, a reconnect, or the
572
- // safety read that heals a lost message.
610
+ // Caught up: park until a message, a reconnect, the end of
611
+ // a held gap, or the safety read that heals a lost message.
573
612
  const woken = new Promise<'wake'>((resolve) => {
574
613
  feed.wake = () => resolve('wake')
575
614
  })
576
- pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
615
+ const holding = feed.gapSince !== undefined
616
+ pending = defaultSleep(
617
+ holding
618
+ ? Math.max(
619
+ 0,
620
+ NOTIFY_TIMINGS.reorderMs -
621
+ (Date.now() - feed.gapSince!),
622
+ )
623
+ : NOTIFY_TIMINGS.safetyReadMs,
624
+ )
577
625
  // oxlint-disable-next-line no-await-in-loop -- live-feed loop
578
626
  const outcome = await Promise.race([
579
627
  woken,
@@ -581,7 +629,7 @@ export function createRedisNotifyStore(options: {
581
629
  ])
582
630
  pending.cancel()
583
631
  pending = null
584
- if (outcome === 'timer') feed.dirty = true
632
+ if (outcome === 'timer' && !holding) feed.dirty = true
585
633
  } catch (err) {
586
634
  release()
587
635
  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
  )
package/src/store.ts CHANGED
@@ -300,6 +300,8 @@ export interface A2Store {
300
300
  * lost — return the existing rows as success. If only *some* ids
301
301
  * exist, the caller mixed an already-sent batch with fresh events —
302
302
  * always a caller bug — so throw `A2Error('PARTIAL_DUPLICATE_BATCH')`.
303
+ * Ids are unique per session: the same id in another session names a
304
+ * different event and plays no part in these checks.
303
305
  *
304
306
  * In the same atomic operation, each event's `targets` start cursor work:
305
307
  * a lane target queues the event in that consumer's lane, a null lane
@@ -538,11 +540,15 @@ export interface A2Store {
538
540
  presence?: {
539
541
  /**
540
542
  * Field-wise last-writer-wins merge of one participant's values:
541
- * a field whose existing row has a strictly newer `at` (the
542
- * sender's stamp) is left untouched; `null` deletes the row. Every
543
- * applied write refreshes that field's expiry to the storage's own
543
+ * a field whose unexpired entry has a strictly newer `at` (the
544
+ * sender's stamp) is left untouched. `null` deletes the value but
545
+ * keeps a tombstone with its `at`, which takes part in the merge
546
+ * like a value until it expires, so a set older than the delete
547
+ * still loses when it arrives later. Every applied write, value or
548
+ * tombstone, refreshes that field's expiry to the storage's own
544
549
  * clock plus `ttlMs` — the sender stamp orders writes but never
545
- * anchors their lifetime.
550
+ * anchors their lifetime. `subscribe` delivers applied writes only,
551
+ * a delete as `null`.
546
552
  */
547
553
  set(
548
554
  ns: string,
@@ -551,7 +557,10 @@ export interface A2Store {
551
557
  meta: { seen: number; at: Date; ttlMs: number },
552
558
  ): Promise<void>
553
559
 
554
- /** The current map, pruned of rows at or past their `expiresAt`. */
560
+ /**
561
+ * The current map, pruned of rows at or past their `expiresAt`.
562
+ * Tombstones never appear.
563
+ */
555
564
  read(ns: string): Promise<PresenceRow[]>
556
565
 
557
566
  /**
@@ -1 +0,0 @@
1
- {"version":3,"file":"actor-HnGmrOZZ.d.ts","names":[],"sources":["../src/actor-shared.ts","../src/actor.ts"],"mappings":";;;;;;;;;;;cAiCa,0BAA0B;;WAE5B;;WAEA;EACT,YAAY,eAAe,mBAAmB;;;;;;;;;KAepC;EACV;EACA;;;;;;;EAOA;;;KAIU,gBAAgB,UAAU,iBAAiB;EACrD,gBAAgB;IAEd;;KAIQ,oBAAoB,QAC7B,WAAW,cAAc,EAAE;;;;;;KAQlB,iBAAiB;GAExB,yBAAA,WAAW;IAAgB,OAAO,EAAE;IAAI;IAAc,IAAI;;;;;;;;;;;;KAanD,UAAU,iBACV,WAAW,eAAe,EAAE,MACjC,QAAQ,EAAE,eACV,OAAO,EAAE;;;;;;;;;KAWJ,uBAAuB;EAAmB;;;;;;;;;;;;KAY1C,kBAAkB,iBAClB,WAAW,eAAe,EAAE,MACjC,OAAO,EAAE,gBAAgB,SAAS,yBAAyB,iBAC3D,OAAO,EAAE,IAAI,SAAS,yBAAyB;;KAI1C,aAAa,UAAU;;EAEjC,OAAO;;EAEP;;EAEA;;EAEA,QAAQ;;EAER,MAAM,UAAU;;EAEhB,UAAU,kBAAkB;;;;;;;;KASlB,uBAAuB,UAAU;;EAE3C;;EAEA;;EAEA,QAAQ;;EAER,SAAS;IAAU,OAAO;IAAY;;;EAEtC,MAAM,UAAU;;EAEhB,UAAU,kBAAkB;;KAGlB,kBAAkB;EAC5B,OAAO;;EAEP;;EAEA;;EAEA;;;KAWU,qBAAqB;EAC/B,kBAAkB,iBAAiB,kBAAkB;;;;;;;;;;;;;;KClE3C,aAAa,UAAU,eAAe,gBAAgB,iBAC5D,KAAK,aAAa,IAAI,OAAO,YAAY,cAAc;EAEvD;EACA,SACE,KAAK,uBAAuB,IAC5B,OAAO,YAAY,cACT;;;;;;;;;;;;;;;;iBAiBF,WAAW,KAAK,GAC9B,SAAS,KAAK,KAAK,OAAO,aAAa;EACpC;EAAkB,SAAS,KAAK,KAAK,OAAO,aAAa;;KAIlD,aAAa,UAAU;;EAEjC;;EAEA,OAAO;;;;;EAKP,aAAa,WAAW,cAAc,aAAa,GAAG;EACtD,QAAQ;EACR,YAAY;EACZ,YAAY;MACR,gBAAgB;EAChB;;;;;;;;;;;;EAYA;;;;;;;KAQM;WACG;WAAwB;;WAExB;WACA;WACA;;WAGA;WACA;WACA;WACA;WACA;;WAGA;WACA;WACA;WACA,QAAQ,SAAS;;KAGpB,kBAAkB,UAAU;;EAEtC,aAAa,WAAW,6BAA6B;;;;;;;;;;;;EAYrD,QAAQ,OAAO;;KAGL;;EAEV;;KAGU,mBAAmB;;EAE7B;;KAGU,gBAAgB;;EAE1B,OAAO;;EAEP;;;;;EAKA;;KAGG,WACH,UAAU,eACV,gBAAgB,0BACH,YAAY,MAErB,QAAQ,YAAY,IACpB,UAAU,qBACP,QAAQ,gBAAgB,gBAE3B,OAAO,YAAY,IACnB,UAAU,qBACP,QAAQ,gBAAgB;KAE5B,WACH,UAAU,eACV,gBAAgB,0BACH,YAAY,MAErB,QAAQ,YAAY,IACpB,UAAU,qBACP;EAAU;EAAY;MAEzB,OAAO,YAAY,IACnB,UAAU,qBACP;EAAU;EAAY;;;KAGnB,YAAY,UAAU;WACvB;;;;;;;;WAQA,kBAAkB,WAAW,cAAc,WAAW,GAAG;;WAEzD,kBAAkB,WAAW,cAAc,WAAW,GAAG;;WAEzD,aAAa,QAAQ,gBAAgB;;;;;;;;;;;WAWrC,QACP,SAAS,SACT,UAAU,kBAAkB,OACzB,QAAQ;;WAEJ,SAAS,QAAQ;;KAGhB,gBAAgB,UAAU;WAC3B;;;;;;WAMA,WAAW;;WAEX,UAAU,SAAS;;WAEnB,QAAQ,SAAS;;WAEjB,SAAS,QAAQ,qBAAqB,aAAa;EAC5D,MAAM,aAAa,YAAY;;;;;;;iBAsDjB,MAAM,UAAU,eAC9B,SAAS,aAAa,KACrB,gBAAgB"}