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
@@ -131,8 +131,8 @@ Pure typed builders of `ai.control.requested` commands, for `append()` and
131
131
  event ID, and must. Approval, input-response, and retry builders use stable
132
132
  interaction IDs; other commands use the ID `push()` or `append()` assigns,
133
133
  kept across transport retries. Caller IDs are nonempty, never start with the
134
- reserved `a2.ai:`, and are unique across the store; identical events replay
135
- and conflicting reuse rejects before append. A2's own IDs are opaque,
134
+ reserved `a2.ai:`, and are unique within the session; identical events
135
+ replay and conflicting reuse in the session rejects before append. A2's own IDs are opaque,
136
136
  fixed-length, deterministic `a2.ai:` values; an assistant message's ID is its
137
137
  initial model-request event's ID.
138
138
  - **Passive input.** `generate: false` is valid browser input: it admits the
@@ -539,7 +539,9 @@ their `https:` or `data:` URLs; any other file fails the turn. See
539
539
 
540
540
  A harness resumes after a crash at the interrupted step. Finished model calls
541
541
  are not made again and finished tool calls do not run again. A2 rebuilds the
542
- harness for each step from the log, and stores nothing outside it. Each agent
542
+ harness for each step from the log, unless [`fx()`](#fx) continues the kernel
543
+ its previous step left in the same process, and keeps nothing durable outside
544
+ the log. Each agent
543
545
  keeps, in its process's memory, the harness's state at the end of the last
544
546
  response it ran there, FX's checkpoint or Pi's session, so a warm process reads
545
547
  and replays only the log since; a cold one reads and replays the session from
@@ -600,7 +602,14 @@ response so far from the log: recorded model responses and tool results answer
600
602
  the kernel without network calls or tool runs, then FX makes its next model
601
603
  call. Replay grows with the response, roughly 0.1 to 0.5 seconds for one of 80
602
604
  to 200 tool calls. When libfx can't take a checkpoint, A2 replays from an
603
- earlier one.
605
+ earlier one. The process that ran the step before skips the replay: it keeps
606
+ FX's kernel waiting on that step's tool calls, and the next step gives the
607
+ kernel any steering sent meanwhile, answers its tool calls from the log, and
608
+ lets it go on. A step that finds anything else first, such as changed model
609
+ settings or a model call another process committed, closes that kernel and
610
+ replays. A kept kernel holds its process open, so A2 closes it once the session
611
+ has nothing left to run in that process: however a response ends, its process
612
+ can exit.
604
613
 
605
614
  FX's model calls carry the same [AI Gateway](#agentoptions) defaults as the
606
615
  AI SDK harness: `caching: 'auto'`, under any Gateway options FX sets itself,
@@ -39,7 +39,7 @@ export async function POST(req: Request) {
39
39
  | `UNKNOWN_EVENT_TYPE` | an event type isn't in the contract's `events` map | no |
40
40
  | `UNKNOWN_PRESENCE_FIELD` | a presence field isn't in the contract's `presence` map | no |
41
41
  | `PRESENCE_NOT_SUPPORTED` | the contract declares `presence` but the store has no presence capability. Thrown at construction; for environment-resolved stores, at first store use | no |
42
- | `PARTIAL_DUPLICATE_BATCH` | a batch mixes already-appended and fresh events, contains the same id twice, or uses an id already used in a different session (event ids are globally unique). Always a caller bug; nothing was written | no |
42
+ | `PARTIAL_DUPLICATE_BATCH` | a batch mixes already-appended and fresh events, or contains the same id twice. Event ids are unique per session, so an id used in another session never conflicts. Always a caller bug; nothing was written | no |
43
43
  | `SUPERSEDED_ATTEMPT` | a consumer's append or clear comes from an attempt that a recovery claim or a session clear replaced; nothing was written. Also the abort reason on that attempt's `ctx.signal` | no |
44
44
  | `SESSION_CLEARED` | a state read reaches below the latest clear, a consumer reads state past a clear of its event, or an actor call is cleared before it receives its answer | no |
45
45
  | `CLAIM_EXPIRED` | never thrown; only the `ctx.signal` abort reason when the attempt's lease lapsed without renewal (assumed loss, decided locally, not proof) | no |
@@ -367,7 +367,10 @@ Consumers are at-least-once. A crash after your side effect but before A2
367
367
  records completion runs the event again.
368
368
 
369
369
  - **External side effects take an idempotency key.** `event.id` is stable
370
- across attempts.
370
+ across attempts and unique within its session. Ids your callers pick, and
371
+ the ids A2 derives from them, can repeat in another session: when yours can,
372
+ add the session to the key, and the contract's name if several contracts
373
+ share it, as in `` `orders:${event.sessionId}:${event.id}` ``.
371
374
  - **Named appends are deduplicated.** A retry gets the existing events back.
372
375
  - **Returned events commit with completion.** They all exist with the
373
376
  cursor's advance, or none do.
@@ -5,8 +5,9 @@ description: Appends are effectively-once, consumers are at-least-once, and retr
5
5
 
6
6
  ## In short
7
7
 
8
- - **Appends are effectively-once.** Event ids deduplicate writes. A batch commits
9
- before its consumers start; they may start before `append` resolves.
8
+ - **Appends are effectively-once.** Event ids deduplicate writes within a
9
+ session. A batch commits before its consumers start; they may start before
10
+ `append` resolves.
10
11
  - **Consumers are at-least-once.** They may run again after a crash, so
11
12
  [side effects use idempotency keys](/server/consumers#consumers-can-run-twice).
12
13
  - **The log stays ordered.** Every session has one immutable append order.
@@ -50,8 +51,10 @@ the committed rows back. A partial or changed batch is rejected.
50
51
 
51
52
  The transaction covers only A2's log. An HTTP call, database write, or other
52
53
  external effect can't join it, so a crash after the effect but before
53
- completion repeats it. Use `event.id` as an idempotency key or add fencing in
54
- the external system.
54
+ completion repeats it. Use `event.id` as an idempotency key, with the session
55
+ when ids can repeat across sessions (see
56
+ [Consumers can run twice](/server/consumers#consumers-can-run-twice)), or add
57
+ fencing in the external system.
55
58
 
56
59
  ## Lanes
57
60
 
@@ -20,6 +20,7 @@ export const ordersServer = createServer({
20
20
  | Store | Use it for |
21
21
  | --- | --- |
22
22
  | [Redis](/stores/redis) | Production (recommended) |
23
+ | [Valkey](/stores/valkey) | Production, on node-redis: rotating credentials, TLS and reconnect control |
23
24
  | [Redis over HTTP](/stores/redis-http) | Production, when your provider only offers a REST API |
24
25
  | [Postgres](/stores/postgres) | Production |
25
26
  | [SQLite](/stores/sqlite) | Development: the default |
@@ -21,9 +21,11 @@ export const ordersServer = createServer({
21
21
  npm i ioredis
22
22
  ```
23
23
 
24
- Works on a single Redis instance and on non-cluster providers such as Upstash,
25
- which is durable by default (use its TCP `rediss://` URL). There's nothing to
26
- set up: A2 creates its keys as it writes.
24
+ Works on a single Redis or Valkey instance and on non-cluster providers such as
25
+ Upstash, which is durable by default (use its TCP `rediss://` URL). There's
26
+ nothing to set up: A2 creates its keys as it writes. For rotating credentials or
27
+ custom TLS and reconnect settings, use the same store on node-redis:
28
+ [Valkey](/stores/valkey).
27
29
 
28
30
  ## Options
29
31
 
@@ -31,22 +33,28 @@ set up: A2 creates its keys as it writes.
31
33
  | --- | --- |
32
34
  | `url` | creates an `ioredis` client |
33
35
  | `client` | your own `ioredis` client instead |
34
- | `keyPrefix` | isolates several apps on one Redis. Default `'a2'` |
36
+ | `keyPrefix` | isolates several apps on one Redis. Default `'a2'`; no braces |
35
37
  | `clock`, `ids` | test seams for timestamps and generated event ids |
36
38
 
37
39
  ## Upgrade
38
40
 
39
41
  The store records its key layout in `<keyPrefix>:schema`, and refuses a prefix
40
42
  marked with another one with `STORE_NOT_CONFIGURED`. A release that changes
41
- the layout says what to do on its [upgrade page](/upgrades/overview).
42
- [0.21](/upgrades/0-21) reads what earlier releases wrote under the same
43
- prefix.
43
+ the layout says what to do on its [upgrade page](/upgrades/overview). This
44
+ release names every key differently from earlier ones, so give the store a
45
+ fresh `keyPrefix`.
44
46
 
45
47
  ## How it works
46
48
 
47
- - Each session is a Redis Stream.
48
- - Every write publishes a wake-up, so live streams are push-driven, and
49
- connections scale with your processes, not with your viewers.
49
+ - Each session is a Redis Stream, and every key of a session shares one hash
50
+ tag.
51
+ - Every write is one atomic script over its session's keys. On Upstash, which
52
+ runs a script under a database-wide lock by default, A2 marks each script to
53
+ lock only its session's keys. Redis and Valkey run one command at a time, so
54
+ A2 keeps scripts short instead.
55
+ - Every write publishes a wake-up after its script, so live streams are
56
+ push-driven, and connections scale with your processes, not with your
57
+ viewers.
50
58
  - A wake-up is disposable: a safety re-read recovers a lost one within ten
51
59
  seconds.
52
60
 
@@ -22,7 +22,8 @@ export const ordersServer = createServer({
22
22
  ```
23
23
 
24
24
  Stores sessions exactly like [Redis](/stores/redis), with no client dependency
25
- and nothing to set up. Use it when your provider only offers a REST API.
25
+ and nothing to set up. Use it when your provider only offers a REST API. On
26
+ Upstash, each script locks only its session's keys, never the whole database.
26
27
 
27
28
  :::warning
28
29
  Live streams trust the messages on the store's `:notify` channels. Browsers
@@ -37,7 +38,7 @@ credentials that can also write the store.
37
38
  | Option | |
38
39
  | --- | --- |
39
40
  | `url`, `token` | your provider's REST endpoint and token |
40
- | `keyPrefix` | isolates several apps on one Redis. Default `'a2'` |
41
+ | `keyPrefix` | isolates several apps on one Redis. Default `'a2'`; no braces |
41
42
  | `clock`, `ids` | test seams for timestamps and generated event ids |
42
43
 
43
44
  ## Live updates
@@ -46,8 +47,9 @@ credentials that can also write the store.
46
47
  same URL and token. Each active channel holds one outbound HTTP subscription
47
48
  per store instance, shared by its viewers and closed when the last one
48
49
  leaves.
49
- - Writes publish their wake-ups in the same Redis script as the append. A
50
- rejected notification doesn't fail the write.
50
+ - Each write publishes its wake-up after its script commits, so a slow
51
+ subscriber never holds the script's lock. A rejected notification doesn't
52
+ fail the write.
51
53
  - Idle streams re-read every ten seconds to recover a missed notification.
52
54
  Subscriptions reconnect on their own and catch up from the event cursor.
53
55
  - After a reconnect, A2 re-reads presence and clears fields deleted during the
@@ -38,8 +38,8 @@ another role needs `grant execute on function a2_batch(text[], jsonb)` beside
38
38
  its grants on the tables, and keeps it when you apply the file again.
39
39
 
40
40
  A2 checks nothing up front. It relies on the table and column names, the
41
- column types, the primary keys, the unique index on `a2_events.event_id`, and
42
- the `a2_batch` function; the other indexes keep its queries fast. A query that
41
+ column types, the primary keys, the unique index on `a2_events (session_id,
42
+ event_id)`, and the `a2_batch` function; the other indexes keep its queries fast. A query that
43
43
  finds a table, column, or the function missing throws `STORE_NOT_CONFIGURED`,
44
44
  naming the file. Once it's applied, the next request works, with no redeploy.
45
45
 
@@ -0,0 +1,97 @@
1
+ ---
2
+ title: Valkey
3
+ description: The Redis store on node-redis, for Valkey and Redis servers that need TLS settings, rotating credentials, or reconnect control.
4
+ ---
5
+
6
+ ```ts server/orders.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { valkey } from 'experimental-a2/store-valkey'
9
+ import { orders } from '@/contracts'
10
+
11
+ export const ordersServer = createServer({
12
+ contract: orders,
13
+ store: valkey({ url: process.env.VALKEY_URL }),
14
+ consumers: [
15
+ /* ... */
16
+ ],
17
+ })
18
+ ```
19
+
20
+ ```package-install
21
+ npm i redis
22
+ ```
23
+
24
+ Stores sessions exactly like [Redis](/stores/redis), on
25
+ [node-redis](https://github.com/redis/node-redis) connections instead of
26
+ `ioredis`. Use it with Valkey or Redis when a URL isn't enough: managed
27
+ servers such as ElastiCache that authenticate with short-lived tokens, custom
28
+ TLS settings, or your own reconnect and timeout policy. It needs a single
29
+ server or a non-cluster replication group: it holds one non-cluster
30
+ connection for commands and one for pub/sub.
31
+
32
+ ## Options
33
+
34
+ | Option | |
35
+ | --- | --- |
36
+ | `url` | a `redis://` or `rediss://` URL |
37
+ | `clientOptions` | node-redis `createClient` options for both connections, such as `socket`, `credentialsProvider`, `commandOptions`, `disableOfflineQueue` |
38
+ | `keyPrefix` | isolates several apps on one server. Default `'a2'` |
39
+ | `clock`, `ids` | test seams for timestamps and generated event ids |
40
+
41
+ Pass `url`, `clientOptions`, or both; `url` wins over `clientOptions.url`.
42
+ A2 always connects with `RESP: 2`, so `clientOptions` has no `RESP`, nor the
43
+ options that need RESP3 (`clientSideCache`, `emitInvalidate`,
44
+ `maintNotifications`).
45
+
46
+ ## Rotating credentials
47
+
48
+ An async credentials provider runs each time a connection authenticates,
49
+ including every reconnect, so each one gets a fresh token:
50
+
51
+ ```ts server/store.ts
52
+ import { valkey } from 'experimental-a2/store-valkey'
53
+
54
+ export const store = valkey({
55
+ clientOptions: {
56
+ socket: { host: 'my-cache.example.com', port: 6379, tls: true },
57
+ credentialsProvider: {
58
+ type: 'async-credentials-provider',
59
+ credentials: async () => {
60
+ // your token source, such as an ElastiCache IAM auth token:
61
+ // const password = await createAuthToken()
62
+ const password = 'token'
63
+ return { username: 'a2', password }
64
+ },
65
+ },
66
+ },
67
+ })
68
+ ```
69
+
70
+ ## Connections
71
+
72
+ A2 creates two clients from your options: one for commands, on the first
73
+ store call, and one subscriber, on the first live stream or presence
74
+ subscription. Every live stream and presence subscription in the process
75
+ shares that subscriber. `close()` destroys both.
76
+
77
+ - A new client is used once it first connects: calls wait for it, each for at
78
+ most `commandOptions.timeout` (five seconds without one), so a cold start
79
+ works with `disableOfflineQueue`.
80
+ - While a client reconnects, node-redis's own options decide what happens to
81
+ commands: they wait in its offline queue, bounded by
82
+ `commandOptions.timeout`, or fail at once with `disableOfflineQueue`. A2
83
+ applies the same rules to a new live stream's subscription, which node-redis
84
+ would otherwise queue without a bound.
85
+ - A reconnect follows your `socket.reconnectStrategy`. When the subscriber
86
+ reconnects, every live stream reads what it missed right away, without
87
+ waiting for the safety re-read.
88
+ - When a client's reconnect strategy gives up, A2 replaces it: the command
89
+ client on the next store call, the subscriber on the next new live stream or
90
+ presence subscription. Until then, live streams still deliver within the
91
+ ten-second safety re-read.
92
+ - A2 listens for each client's `'error'` events, so a dropped socket never
93
+ crashes the process. Failures surface as `STORE_UNAVAILABLE` from the call
94
+ that hit them.
95
+
96
+ The upgrade rules, key layout, and per-command costs are the same as
97
+ [Redis](/stores/redis).
@@ -7,6 +7,7 @@ description: What to do when you move to a new release. One page per release tha
7
7
 
8
8
  | Release | What you do |
9
9
  | --- | --- |
10
+ | [0.22](/upgrades/0-22) | Event ids are unique per session. Postgres: apply the new `schema.sql`, then deploy. Redis: use a new `keyPrefix`. |
10
11
  | [0.21](/upgrades/0-21) | Rewrite handlers as consumers. Let earlier deployments finish; on Postgres, then apply the new `schema.sql`. SQLite starts fresh. |
11
12
  | [0.20](/upgrades/0-20) | Postgres: apply `schema.sql` yourself, before you deploy. |
12
13
  | [0.19](/upgrades/0-19) | Start new agent sessions: earlier ones don't fold. |
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: '0.22'
3
+ description: Event ids are unique per session, not per store, and a presence clear keeps its stamp.
4
+ ---
5
+
6
+ ## Event ids are unique per session
7
+
8
+ The same event id in two sessions is now two events. Before, appending an id
9
+ that another session already used threw `PARTIAL_DUPLICATE_BATCH`. Inside one
10
+ session nothing changes: an identical retry returns the original rows, a batch
11
+ that mixes sent and fresh events throws, and a consumer's retried completion
12
+ returns the events it committed.
13
+
14
+ - **Ids you derive need only the session's scope.** An
15
+ [`idempotentId()`](/reference/core#idempotentidparts) recipe that includes
16
+ the session still works; one that leaves it out no longer collides with
17
+ another session's events.
18
+ - **Idempotency keys built from ids.** `event.id` and an actor's `ctx.id` are
19
+ unique within their session. Ids your callers pick, and the ids A2 derives
20
+ from them, can now repeat in another session, so a key for an external side
21
+ effect built from one alone can too. When yours can repeat, add the session
22
+ to the key; see
23
+ [Consumers can run twice](/server/consumers#consumers-can-run-twice).
24
+ - **Clear markers** follow the same rule.
25
+ - **Custom stores** check ids against the session's own rows only: the
26
+ idempotent-replay and partial-batch checks in `append` and
27
+ `completeAttempt` ignore other sessions. See
28
+ [`A2Store`](/reference/server#a2store).
29
+
30
+ ## Postgres
31
+
32
+ 0.22's `schema.sql` replaces the unique index on `a2_events (event_id)`,
33
+ `a2_events_event_id`, with one on `a2_events (session_id, event_id)`,
34
+ `a2_events_session_event_id`. Every row is kept, and building the new index
35
+ can't fail: the old one already keeps those pairs unique.
36
+
37
+ 1. **Apply 0.22's `schema.sql`**, as the role your app connects with:
38
+ `node_modules/experimental-a2/sql/postgres/schema.sql`.
39
+ 2. **Deploy 0.22.**
40
+
41
+ The first time it runs, the swap locks `a2_events` against every read and write
42
+ until the new index is built, then drops the old one. On a large table, make
43
+ both changes by hand instead, with nothing blocked. Run each statement on its
44
+ own, outside a transaction:
45
+
46
+ ```sql
47
+ create unique index concurrently if not exists a2_events_session_event_id
48
+ on a2_events (session_id, event_id);
49
+ ```
50
+
51
+ A concurrent build that fails leaves an invalid index of the same name behind.
52
+ Check that this returns `true` before you drop the old index; if it doesn't,
53
+ drop the new one and build it again:
54
+
55
+ ```sql
56
+ select indisvalid from pg_index
57
+ where indexrelid = 'a2_events_session_event_id'::regclass;
58
+ ```
59
+
60
+ ```sql
61
+ drop index concurrently if exists a2_events_event_id;
62
+ ```
63
+
64
+ Then apply `schema.sql`: it finds both done and waits on nothing. It drops the
65
+ old index only once the new one is valid.
66
+
67
+ With Drizzle, update your copy of `drizzle-schema.ts`:
68
+ `a2_events_session_event_id` on `(session_id, event_id)` replaces
69
+ `a2_events_event_id`. On a large table, run the statements above by hand
70
+ instead, and take them out of the migration Drizzle generates.
71
+
72
+ ## SQLite
73
+
74
+ Nothing to do. The store creates the new index and drops the old one when it
75
+ opens the file. Once 0.22 has opened a file, don't open it with an earlier
76
+ release: that release builds the old index again, and can't open the file at
77
+ all once two sessions share an id.
78
+
79
+ ## Redis
80
+
81
+ Redis names every key differently in this release, so it starts fresh:
82
+ **point the store at a new `keyPrefix`**, then deploy. Under its new prefix,
83
+ 0.22 never reads what an earlier release wrote; delete the old prefix's keys
84
+ whenever convenient. On a prefix 0.21 wrote, 0.22 refuses to start with
85
+ `STORE_NOT_CONFIGURED`. A `keyPrefix` can't contain braces.
86
+
87
+ What changes:
88
+
89
+ - **Scripts lock only their session on Upstash.** Every key of a session
90
+ shares one hash tag, so each script sets Upstash's `allow-key-locking` and
91
+ locks its session's keys instead of the whole database. The store asks the
92
+ server once whether it accepts the flag; Redis and Valkey don't, and run one
93
+ command at a time anyway.
94
+ - **No script publishes.** Each write publishes its wake-up after its script,
95
+ so a slow subscriber never holds a lock.
96
+ - **`clear()` works on Upstash.** It used to rename the log aside, which
97
+ Upstash refuses for streams. The marker now joins the same log, and the old
98
+ lifetime's entries stay below it, unread, until `clear()` finishes deleting
99
+ them. A session that stays empty after its clear keeps only its clear marker,
100
+ the log holding it, and its log consumers' positions.
101
+
102
+ ## Memory
103
+
104
+ Nothing to do: the memory store starts empty in every process.
105
+
106
+ ## Presence clears keep their stamp
107
+
108
+ A `null` from `setPresence()` now leaves a tombstone in the store, carrying the
109
+ clear's `at`, until the field's TTL runs out. An older value that reaches the
110
+ store after the clear, such as one sent through another server instance, still
111
+ loses. Before, it brought the field back. Nothing to do: Postgres and SQLite
112
+ keep tombstones in `a2_presence` as a JSON `null` value.
113
+
114
+ Custom stores with presence keep a tombstone for each `null`, compare it by
115
+ `at` like a value until it expires, and leave tombstones out of `read`.
@@ -68,6 +68,7 @@ async function openApp(appId: string): Promise<Sandbox> {
68
68
  persistent: true,
69
69
  snapshotExpiration: SNAPSHOT_EXPIRATION_MS,
70
70
  keepLastSnapshots: { count: 1 },
71
+ onResume: startDev,
71
72
  })
72
73
  } catch (error) {
73
74
  // Another instance created it first. The API holds a racing create until
@@ -81,8 +82,54 @@ async function openApp(appId: string): Promise<Sandbox> {
81
82
  return sandbox
82
83
  }
83
84
 
84
- // Parallel tool calls on a new app share one create within this instance.
85
- const opening = new Map<string, Promise<Sandbox>>()
85
+ // Each instance keeps one handle per app, so parallel tool calls share an
86
+ // open and later ones skip the lookup. A handle resumes a stopped session on
87
+ // its next call; a deleted sandbox fails with a 404 until it's forgotten.
88
+ // An empty app's idle check deletes its sandbox from whichever instance runs
89
+ // it, 10 minutes after the sandbox opened. By then the session's 15 minutes
90
+ // leave less than KEEP_ALIVE_MS, so every call extends it, and the extension's
91
+ // 404 is how a kept handle finds out.
92
+ const handles = new Map<string, Promise<Sandbox>>()
93
+ const MAX_HANDLES = 100
94
+
95
+ const forget = (appId: string): void => {
96
+ handles.delete(appId)
97
+ }
98
+
99
+ const open = (appId: string): Promise<Sandbox> => {
100
+ const opened = openApp(appId)
101
+ opened.catch(() => {
102
+ if (handles.get(appId) === opened) forget(appId)
103
+ })
104
+ return opened
105
+ }
106
+
107
+ const kept = (appId: string): Promise<Sandbox> => {
108
+ const pending = handles.get(appId) ?? open(appId)
109
+ // Most recently used last, so the cap drops the stalest.
110
+ handles.delete(appId)
111
+ handles.set(appId, pending)
112
+ const stalest = handles.keys().next().value
113
+ if (handles.size > MAX_HANDLES && stalest !== undefined) forget(stalest)
114
+ return pending
115
+ }
116
+
117
+ /**
118
+ * Whether the sandbox is still there. Extends it only by the shortfall, so
119
+ * repeated calls never stack up time.
120
+ */
121
+ const keepAlive = async (sandbox: Sandbox): Promise<boolean> => {
122
+ const shortfall =
123
+ Date.now() + KEEP_ALIVE_MS - (sandbox.expiresAt?.getTime() ?? 0)
124
+ if (shortfall <= 0) return true
125
+ try {
126
+ await sandbox.extendTimeout(shortfall)
127
+ } catch (error) {
128
+ // Throws past the plan's session limit; the sandbox still works until then.
129
+ return !notFound(error)
130
+ }
131
+ return true
132
+ }
86
133
 
87
134
  /** The app's sandbox, created or resumed, and kept up while it's used. */
88
135
  export async function appSandbox({
@@ -90,16 +137,13 @@ export async function appSandbox({
90
137
  }: {
91
138
  appId: string
92
139
  }): Promise<Sandbox> {
93
- const pending =
94
- opening.get(appId) ?? openApp(appId).finally(() => opening.delete(appId))
95
- opening.set(appId, pending)
140
+ const pending = kept(appId)
96
141
  const sandbox = await pending
97
- // Extends only by the shortfall, so repeated calls never stack up time.
98
- const shortfall =
99
- Date.now() + KEEP_ALIVE_MS - (sandbox.expiresAt?.getTime() ?? 0)
100
- // Throws past the plan's session limit; the sandbox still works until then.
101
- if (shortfall > 0) await sandbox.extendTimeout(shortfall).catch(() => {})
102
- return sandbox
142
+ if (await keepAlive(sandbox)) return sandbox
143
+ if (handles.get(appId) === pending) forget(appId)
144
+ const reopened = await kept(appId)
145
+ await keepAlive(reopened)
146
+ return reopened
103
147
  }
104
148
 
105
149
  // A server that's booting refuses connections for a moment, and a page
@@ -118,12 +162,12 @@ export async function ensureApp({
118
162
  appId: string
119
163
  }): Promise<{ up: boolean; createdAt: Date }> {
120
164
  const sandbox = await appSandbox({ appId })
121
- const probe = await sandbox.runCommand({
165
+ const answer = await sandbox.runCommand({
122
166
  cmd: 'bash',
123
167
  args: ['-c', `${START_DEV}; ${PROBE}`],
124
168
  })
125
169
  return {
126
- up: Number(await probe.stdout()) > 0,
170
+ up: Number(await answer.stdout()) > 0,
127
171
  createdAt: sandbox.createdAt,
128
172
  }
129
173
  }
@@ -145,6 +189,7 @@ export async function deleteAppSandbox({
145
189
  }: {
146
190
  appId: string
147
191
  }): Promise<void> {
192
+ forget(appId)
148
193
  try {
149
194
  const sandbox = await Sandbox.get({
150
195
  name: sandboxName(appId),
@@ -7,9 +7,9 @@
7
7
  * your schema.
8
8
  *
9
9
  * Whatever creates them, A2 relies on the table and column names, the column
10
- * types, the primary keys, and the unique index on a2_events.event_id. The
11
- * other indexes keep its queries fast. Every name is spelled out, so Drizzle's
12
- * `casing` setting changes none of them.
10
+ * types, the primary keys, and the unique index on a2_events (session_id,
11
+ * event_id). The other indexes keep its queries fast. Every name is spelled
12
+ * out, so Drizzle's `casing` setting changes none of them.
13
13
  *
14
14
  * A2's tables change only when a release's changelog says so; compare your
15
15
  * copy with that release's file. Drizzle can't declare functions or
@@ -48,7 +48,10 @@ export const a2Events = pgTable(
48
48
  name: 'a2_events_pkey',
49
49
  columns: [table.sessionId, table.idx],
50
50
  }),
51
- uniqueIndex('a2_events_event_id').on(table.eventId),
51
+ uniqueIndex('a2_events_session_event_id').on(
52
+ table.sessionId,
53
+ table.eventId,
54
+ ),
52
55
  index('a2_events_types').on(table.sessionId, table.eventType, table.idx),
53
56
  ],
54
57
  )
@@ -0,0 +1,2 @@
1
+ CREATE UNIQUE INDEX "a2_events_session_event_id" ON "a2_events" USING btree ("session_id","event_id");--> statement-breakpoint
2
+ DROP INDEX "a2_events_event_id";