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
@@ -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')
@@ -19,6 +19,11 @@ export function pollPresence({
19
19
  sleep?: (ms: number) => Sleeper
20
20
  }): {
21
21
  ready: Promise<void>
22
+ /**
23
+ * Runs a foreground snapshot read and counts it as a poll, so a stamp a
24
+ * viewer paints from it is one later deletes carry.
25
+ */
26
+ snapshot(read: () => Promise<PresenceRow[]>): Promise<PresenceRow[]>
22
27
  update(patch: PresencePatch): void
23
28
  wake(): void
24
29
  stop(): void
@@ -45,6 +50,46 @@ export function pollPresence({
45
50
  // Seeding is best effort; foreground snapshots still report read errors.
46
51
  .catch(() => undefined)
47
52
 
53
+ /** Delivers how `rows` differ from what was seen; true when they do. */
54
+ const reconcile = (rows: PresenceRow[]): boolean => {
55
+ const live = new Set<string>()
56
+ let changed = false
57
+ for (const row of rows) {
58
+ const id = key(row)
59
+ live.add(id)
60
+ const prior = seen.get(id)
61
+ seen.set(id, row)
62
+ if (
63
+ prior?.seen === row.seen &&
64
+ prior.at.getTime() === row.at.getTime() &&
65
+ JSON.stringify(prior.value) === JSON.stringify(row.value)
66
+ )
67
+ continue
68
+ changed = true
69
+ deliver({
70
+ participant: row.participant,
71
+ values: { [row.field]: row.value },
72
+ seen: row.seen,
73
+ at: row.at,
74
+ })
75
+ }
76
+ for (const [id, row] of seen) {
77
+ if (live.has(id)) continue
78
+ seen.delete(id)
79
+ changed = true
80
+ deliver({
81
+ participant: row.participant,
82
+ values: { [row.field]: null },
83
+ seen: row.seen,
84
+ // The last seen stamp, never this poller's clock: only
85
+ // senders' clocks order the plane, and an observer-stamped
86
+ // delete could outrank the sender's next set.
87
+ at: row.at,
88
+ })
89
+ }
90
+ return changed
91
+ }
92
+
48
93
  const run = async (): Promise<void> => {
49
94
  await ready
50
95
  for (;;) {
@@ -60,39 +105,7 @@ export function pollPresence({
60
105
  const rows = await read()
61
106
  if (stopped) return
62
107
  if (version !== reading) continue
63
- const live = new Set<string>()
64
- let changed = false
65
- for (const row of rows) {
66
- const id = key(row)
67
- live.add(id)
68
- const prior = seen.get(id)
69
- seen.set(id, row)
70
- if (
71
- prior?.seen === row.seen &&
72
- prior.at.getTime() === row.at.getTime() &&
73
- JSON.stringify(prior.value) === JSON.stringify(row.value)
74
- )
75
- continue
76
- changed = true
77
- deliver({
78
- participant: row.participant,
79
- values: { [row.field]: row.value },
80
- seen: row.seen,
81
- at: row.at,
82
- })
83
- }
84
- for (const [id, row] of seen) {
85
- if (live.has(id)) continue
86
- seen.delete(id)
87
- changed = true
88
- deliver({
89
- participant: row.participant,
90
- values: { [row.field]: null },
91
- seen: row.seen,
92
- at: new Date(),
93
- })
94
- }
95
- if (changed) activate()
108
+ if (reconcile(rows)) activate()
96
109
  else if (version === before)
97
110
  interval = Math.min(interval * 2, POLL_TIMINGS.idleCeilingMs)
98
111
  } catch {
@@ -103,10 +116,22 @@ export function pollPresence({
103
116
  void run()
104
117
  return {
105
118
  ready,
119
+ async snapshot(readSnapshot) {
120
+ const reading = version
121
+ const rows = await readSnapshot()
122
+ if (!stopped && version === reading && reconcile(rows)) activate()
123
+ return rows
124
+ },
106
125
  wake: activate,
107
126
  update(patch) {
108
127
  for (const [field, value] of Object.entries(patch.values)) {
109
- seen.set(key({ participant: patch.participant, field }), {
128
+ const id = key({ participant: patch.participant, field })
129
+ // A deleted field is one the next read should not find.
130
+ if (value === null) {
131
+ seen.delete(id)
132
+ continue
133
+ }
134
+ seen.set(id, {
110
135
  participant: patch.participant,
111
136
  field,
112
137
  value,