experimental-a2 0.17.0 → 0.18.1

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 (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /package/docs/{guides → server}/meta.ts +0 -0
@@ -1,113 +0,0 @@
1
- ---
2
- title: A2 and your database
3
- description: Use A2 for things with a lifecycle. Keep collections, relationships, and queries in your application database.
4
- ---
5
-
6
- ## Start with ownership
7
-
8
- A2 does not replace your application database. It synchronizes one known
9
- session at a time: its events, its derived state, and optimistic writes from
10
- the browser. It does not synchronize tables or database queries.
11
-
12
- Use your database for the records and relationships that the rest of the
13
- application queries.
14
-
15
- Decide which system owns each fact:
16
-
17
- - If A2 owns it, append an event and derive the session's state with a reducer.
18
- - If your database owns it, write it with the database's normal transaction.
19
-
20
- Do not make both paths authoritative for the same fact.
21
-
22
- ## What belongs in A2
23
-
24
- A2 fits something with a lifecycle: an order moving through fulfillment, an
25
- agent producing a response, an approval waiting for a decision, or an import
26
- that can fail and resume.
27
-
28
- A **contract** names the events in one kind of lifecycle. A **session** is one
29
- instance of it:
30
-
31
- ```text
32
- orders / order-42
33
- orders / order-43
34
- agents / run-abc
35
- ```
36
-
37
- The session is the boundary A2 logs, recovers, folds, and streams. Put events
38
- in the same session when they form one history or view. Handler execution is
39
- concurrent unless events share a lane.
40
-
41
- A contract is not a table. A session is not a table either. One contract
42
- usually serves many sessions, and one session may describe changes that touch
43
- several records elsewhere in an application.
44
-
45
- ## What belongs in the database
46
-
47
- Use your application database for collections and relationships: accounts,
48
- projects, issue lists, labels, permissions, search, joins, pagination, and
49
- reporting. Use its transactions for constraints that must hold across several
50
- rows before a request returns.
51
-
52
- The Postgres adapter in `experimental-a2/store-postgres` stores A2's own event logs. It does not
53
- watch your application tables, turn their changes into events, or combine an
54
- A2 append with your SQL transaction. The A2 log and your tables may share one
55
- Postgres database, but they remain separate data models and transaction
56
- boundaries.
57
-
58
- ## An issue tracker
59
-
60
- An issue tracker is mostly a collection and query product. Postgres should
61
- usually own its core data:
62
-
63
- | Part | Owner |
64
- | --- | --- |
65
- | issues, projects, labels, comments | Postgres |
66
- | permissions, board filters, search | Postgres |
67
- | an AI investigation attached to an issue | one A2 session |
68
- | an import or automation run | one A2 session |
69
-
70
- The A2 session can store the investigation's messages, progress, tool calls,
71
- approval, and result. It refers to the issue id, but it does not become a
72
- second owner of the issue.
73
-
74
- An individual issue also has a lifecycle, so A2 can own it when the issue's
75
- ordered history and live state are the center of the product. That choice has
76
- a cost: boards and search span many sessions and need another query model. If
77
- those collection views are the center of the product, let the database lead.
78
-
79
- Do not put an entire workspace in one session just to make cross-issue queries
80
- possible. Every event joins the same growing log and every client follows it.
81
-
82
- ## Database writes from handlers
83
-
84
- Handlers may call your database like any other side effect. They are
85
- at-least-once, so make the write idempotent with `event.id`. The event is
86
- durable before the handler runs, and the SQL write may happen later or be
87
- retried.
88
-
89
- That is a good fit for a reaction to a lifecycle event. It is not one atomic
90
- write across A2 and your tables. If a relational change must commit before the
91
- request succeeds, make that change through the database's write path.
92
-
93
- ## Cross-session indexes
94
-
95
- Sometimes an A2-owned lifecycle needs a small database index, such as a list of
96
- active imports. A handler can maintain that index. Treat it as an advanced
97
- integration:
98
-
99
- - it is eventually consistent with the session logs;
100
- - writes must be idempotent;
101
- - A2 assigns log order within one session, not across sessions;
102
- - A2 does not expose one API that scans and replays every session to rebuild
103
- the index.
104
-
105
- Back up and migrate that index like application data. If search, joins, and
106
- cross-session reporting are central rather than incidental, the database
107
- should probably own that domain.
108
-
109
- ## The short version
110
-
111
- Use A2 when people open something by id and watch it progress. Use your
112
- database when people list, filter, join, and edit many records. In an
113
- application that needs both, split ownership along that line.
@@ -1,262 +0,0 @@
1
- ---
2
- title: Presence
3
- description: "Ephemeral participant state on a durable session: cursors, typing, live progress. Presence repaints; the log replays."
4
- ---
5
-
6
- ## Two planes, one session
7
-
8
- Everything in the log is a fact: stored forever, folded by reducers,
9
- replayed on demand. Presence is not a fact. "Her cursor is at (312, 88)"
10
- has a useful lifetime of about two seconds, no replay value, and no
11
- durability requirement; a lost update is repainted by the next one.
12
-
13
- The test that separates the planes:
14
-
15
- > Will anyone, human or reducer, care about this datum after the moment
16
- > passes? Yes: event, in the log. No: presence.
17
-
18
- Messages, document updates, cancellations: events. Cursors, selections,
19
- typing, "who's online", a handler's live progress: presence.
20
-
21
- A2 gives presence its own plane on the session you already have. Same
22
- contract, same route, same auth, same stream. Each participant owns a
23
- small ephemeral state; A2 replicates the latest value of each field to
24
- everyone subscribed and stores none of it in the log. Reducers never see
25
- it, `history()` never returns it, and the browser cache never persists
26
- it. One invariant covers the whole design:
27
-
28
- > **Presence repaints; the log replays.** Anything that decides (a fold,
29
- > a retry, an abort match) reads the log. Presence only hurries pixels.
30
-
31
- ## Declare it in the contract
32
-
33
- `presence` is a second vocabulary next to `events`: each key is a field
34
- of one participant's ephemeral state, each value a schema for it.
35
-
36
- ```ts contracts.ts
37
- import { z } from 'zod'
38
- import * as a2 from 'experimental-a2'
39
-
40
- export const canvas = a2.contract({
41
- name: 'canvas',
42
- events: {
43
- 'shape.created': z.object({
44
- id: z.string(),
45
- kind: z.enum(['rect', 'ellipse']),
46
- x: z.number(),
47
- y: z.number(),
48
- }),
49
- 'shape.moved': z.object({ id: z.string(), x: z.number(), y: z.number() }),
50
- },
51
- reducer: { name: 'scene', initialState: {}, fold: (state) => state },
52
- presence: {
53
- cursor: z.object({ x: z.number(), y: z.number() }),
54
- dragging: z.object({ shapeId: z.string(), x: z.number(), y: z.number() }),
55
- },
56
- })
57
- ```
58
-
59
- The field is the unit of replication: last writer wins per participant
60
- per field. Setting `cursor` does not touch `dragging`. Fields are absent
61
- until a participant sets them, and clear when set to `null` or when
62
- their backend TTL lapses (a closed tab disappears by silence, no
63
- explicit leave required).
64
-
65
- ## The route
66
-
67
- The same `server.fetch` route as [Live UI](/guides/react). Presence is
68
- inferred from the contract. A contract with presence fields streams
69
- presence patches with events, starting with a snapshot of the current
70
- map. Its push body also accepts an optional `presence` sibling to
71
- `events`, forwarded to `setPresence`.
72
-
73
- ```ts app/api/canvas-events/route.ts
74
- import { canvasServer } from '@/server/canvas'
75
-
76
- export const GET = canvasServer.fetch
77
- export const POST = canvasServer.fetch
78
- ```
79
-
80
- Wrap `server.fetch` when the route needs authentication. Pass
81
- `authorize` to check the typed A2 operation before field validation or
82
- I/O. A push operation carries the complete presence patch, including
83
- its caller-supplied participant id. The same hook runs for HTTP and
84
- WebSocket presence.
85
-
86
- `setPresence` validates each field against the contract, then
87
- broadcasts. No append transaction, no dispatch, no scheduler arm, no log
88
- row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
89
- `UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either. A
90
- presence-only push acks `[]`.
91
-
92
- ## The browser
93
-
94
- Presence needs a `participant` id (yours to mint: a user id, a tab
95
- nonce, the playground's guest name). State it once on
96
- `createClient({ participant })` when the app knows it at module scope,
97
- or pass it to the provider, whose `participant` prop overrides the
98
- client's. The hook grows two members. The
99
- session module is the usual pair from
100
- [Live UI](/guides/react#bind-react-once), bound to a reducer that
101
- carries the canvas contract:
102
-
103
- ```ts app/canvas/[canvasId]/session.ts
104
- 'use client'
105
- import { createClient } from 'experimental-a2/client'
106
- import { createReact } from 'experimental-a2/react'
107
- import { canvas } from '@/contracts'
108
-
109
- export const canvasClient = createClient({
110
- contract: canvas,
111
- api: '/api/canvas-events',
112
- })
113
- export const { SessionProvider, useSession } = createReact({
114
- client: canvasClient,
115
- })
116
- ```
117
-
118
- ```tsx app/canvas/[canvasId]/canvas-client.tsx
119
- 'use client'
120
- import { useSession } from './session'
121
-
122
- export function CanvasClient() {
123
- const { presence, setPresence } = useSession()
124
-
125
- return (
126
- <div
127
- onPointerMove={(e) => setPresence({ cursor: { x: e.clientX, y: e.clientY } })}
128
- >
129
- {Object.entries(presence).map(([participant, fields]) =>
130
- // render-time expiry: a cursor silent for a few seconds is gone
131
- fields.cursor && Date.now() - fields.cursor.at.getTime() < 5_000 ? (
132
- <Cursor key={participant} {...fields.cursor.value} />
133
- ) : null,
134
- )}
135
- </div>
136
- )
137
- }
138
-
139
- // your cursor sprite:
140
- // function Cursor({ x, y }: { x: number; y: number }) { ... }
141
- const Cursor = (props: { x: number; y: number }) => null
142
- ```
143
-
144
- - **`setPresence(values)`** is fire-and-forget: validated locally,
145
- merged into the local map immediately, coalesced on the wire to a
146
- fixed cadence. The first set sends immediately; within the interval,
147
- later values merge field by field (latest wins, `null` included) and
148
- one trailing send carries the merged batch, so a 60Hz pointer becomes
149
- about fifteen sends per second and losing one is repainted by the
150
- next. Sends may overlap in flight; that is safe because merges are
151
- last-writer-wins by `at` per field. No ack, no `confirmed`, no retry,
152
- no optimistic rollback: there is nothing to roll back, because
153
- nothing was ever true.
154
- - **`presence`** is the replicated map,
155
- `Record<participantId, { [field]: { value, seen, at } }>`, including
156
- your own participant. `at` is the sender's stamp for when the value
157
- was set, and the last-writer-wins comparator; the client stamps its
158
- sends monotonically, so overlapping sends can never invert. How long
159
- a cursor stays painted is view logic, decided at render time. Wall
160
- clocks never enter folds, and presence is not folded state.
161
-
162
- Set a field to `null` to clear it (pointer leaves the canvas, blur,
163
- unmount). Everything else expires by TTL when a tab goes silent: 60
164
- seconds by default, set `createServer({ presence: { ttlMs } })` to
165
- change it. Expiry counts on the backend's clock, not the sender's
166
- stamp.
167
-
168
- Both members are typed from the contract's `presence` map, through the
169
- reducer, with no type arguments: field names, payloads, and `null`
170
- clears all autocomplete. On a contract without `presence` the members
171
- do not exist, so reaching for them is a compile error, not a silent
172
- no-op.
173
-
174
- ## Reconciling the planes: `seen`
175
-
176
- Every presence value carries `seen`, the sender's stream frontier at the
177
- moment it was set. The client stamps it automatically. This is what lets
178
- the two planes agree on an order without wall clocks.
179
-
180
- The classic race: a participant drags a shape (presence), drops it
181
- (event), and a stale `dragging` patch arrives after the durable
182
- `shape.moved`. Without a shared coordinate system the shape flickers
183
- backwards. With `seen`, superseding is one render-time rule:
184
-
185
- ```ts
186
- // in render code: a ghost is dead once the log has moved past it.
187
- // show fields.dragging only if
188
- // fields.dragging.seen >= state.shapes[fields.dragging.value.shapeId].movedAt
189
- // where movedAt is the index your reducer recorded for the last shape.moved
190
- ```
191
-
192
- The durable event at index 214 beats any presence stamped 213 or lower,
193
- deterministically. A2 carries the stamp and exposes the frontiers; the
194
- comparison is yours, in view code, next to the pixels it protects.
195
-
196
- ## Handlers broadcast too
197
-
198
- `ctx.session.setPresence` is the same session operation. A handler
199
- grinding through work can publish live progress without writing
200
- non-facts to the log:
201
-
202
- ```ts server/canvas.ts
203
- import { createServer } from 'experimental-a2/server'
204
- import { canvas } from '@/contracts'
205
-
206
- export const canvasServer = createServer({
207
- contract: canvas,
208
- handlers: {
209
- 'shape.created': async (ctx) => {
210
- // long work; broadcast progress as it goes:
211
- // await ctx.session.setPresence({
212
- // participant: 'system:thumbnails',
213
- // values: { progress: { step: 2, of: 5 } },
214
- // })
215
- },
216
- },
217
- })
218
- ```
219
-
220
- The participant id is the handler's choice; a `system:` prefix keeps
221
- robots and humans apart in the map. Append the durable outcome as an
222
- event when the work completes; the progress was never a fact, the result
223
- is.
224
-
225
- ## What presence never does
226
-
227
- - Never appears in `history()`, `state()`, or a reducer's input type.
228
- The separation is structural: presence fields are not events, so a
229
- fold cannot reach them even by accident.
230
- - Never arms the scheduler, takes a claim, or retries. A crashed presence
231
- update is a non-event, literally.
232
- - Never enters the IndexedDB cache. Cache the log, never the moment.
233
- - Never blocks an append. The planes share a route, not a transaction.
234
-
235
- ## Backends
236
-
237
- Presence needs a broadcast primitive, and backends differ. Declaring
238
- `presence` in a contract requires a store with the presence capability;
239
- `createServer` throws `PRESENCE_NOT_SUPPORTED` at construction
240
- otherwise. All built-in stores have it, at two tiers:
241
-
242
- | Backend | Delivery |
243
- | --- | --- |
244
- | `store-redis` | push: TTL'd hash plus pub/sub patches |
245
- | `store-redis-http` | push over HTTP subscriptions; adaptive polling when unsupported |
246
- | `store-memory` | in-process, immediate |
247
- | `store-postgres` | native notifications with `listenConnectionString`; adaptive polling by default |
248
- | `store-sqlite` | degraded: bounded TTL'd rows, read on the live feed's poll cadence |
249
-
250
- Degraded means later, not lost while watched: presence-only traffic
251
- uses an adaptive re-read cadence, starting at 25ms and backing off to two
252
- seconds while unchanged. New presence patches or durable events restore the
253
- fast cadence. An idle remote viewer may wait up to two seconds plus query
254
- latency for the first change. Redis Pub/Sub does not proactively broadcast
255
- TTL expiry. Silent departures keep their last values in connected clients'
256
- maps until a snapshot reconciles them, so use the render-time expiry above.
257
- Native Postgres subscriptions reconcile missing or expired fields on their
258
- ten-second safety reads. Cursors want
259
- the push tier; typing indicators and progress read fine on either. The presence rows are
260
- bounded per session and participant, so this is not log growth in
261
- disguise, but on metered backends every patch is still a network
262
- operation. Presence makes ephemeral state correct, not free.
@@ -1,221 +0,0 @@
1
- ---
2
- title: Transports
3
- description: "One client, three wires: a single HTTP route, split routes for per-route timeouts, or one WebSocket for both directions."
4
- ---
5
-
6
- ## One wire seam, same client
7
-
8
- Everything the client does is transport-agnostic: optimistic pushes
9
- with ack and `confirmed`, frontier-resumed live reads, reconnection
10
- with backoff, and presence throttling. `api` picks the wire underneath
11
- it:
12
-
13
- ```ts
14
- // in a 'use client' session module:
15
- api: '/api/order-events'
16
- api: (sessionId) => `/api/orders/${encodeURIComponent(sessionId)}/events`
17
- api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
18
- api: { type: 'ws', url: '/api/order-events' }
19
- ```
20
-
21
- - **One route** is the default and the recommendation. Pass a fixed URL or a
22
- session URL function. It serves GET (SSE stream and history) and POST (push).
23
- - **`http` split** serves the two verbs from separate routes. Use it
24
- when platform duration limits differ per verb.
25
- - **`ws`** rides streams, pushes, and presence over WebSocket. It opens
26
- one socket per session. Set `multiplex: true` on the client and
27
- `multiplexWebSocket: true` on the route to share one socket across the
28
- client's sessions.
29
-
30
- A split socket is unrepresentable on purpose. The socket is one
31
- connection in both directions.
32
-
33
- Every session-scoped URL may instead be a `(sessionId) => string` function.
34
- A2 resolves it for each request or reconnect, then adds its standard query
35
- parameters. This supports paths scoped to a session. Encode the ID when placing
36
- it in a path segment. The function chooses a route, not authority: if a path or
37
- connection token is scoped to one session, `authorize` must compare that scope
38
- to `operation.sessionId`. Bind URL tokens to the same session and keep them
39
- short-lived because URLs can appear in infrastructure logs. Multiplexed
40
- WebSockets take one plain URL because the connection carries more than one
41
- session.
42
-
43
- One wire is missing from `ws` by design: the history lane.
44
- [`loadHistory`](/guides/react#events-and-history) is a bounded cold
45
- read and rides plain HTTP. On a `ws` API it throws a `TypeError`.
46
-
47
- ## The HTTP route
48
-
49
- `server.fetch` is bound, so an unprotected route is two exports:
50
-
51
- ```ts app/api/public-order-events/route.ts
52
- import { ordersServer } from '@/server/orders'
53
-
54
- export const GET = ordersServer.fetch
55
- export const POST = ordersServer.fetch
56
- ```
57
-
58
- A plain GET streams one session over SSE. A GET with `gte` and `lte`
59
- returns a history slice. POST parses the push envelope and appends its
60
- events. If the contract declares presence, the same route streams and
61
- accepts presence automatically.
62
-
63
- Wrap the call when the route needs authentication or authorization:
64
-
65
- ```ts app/api/protected-order-events/route.ts
66
- import { ordersServer } from '@/server/orders'
67
-
68
- async function fetchOrders(request: Request): Promise<Response> {
69
- // your authentication:
70
- const user = { id: 'user-1' }
71
- if (!user) return new Response(null, { status: 401 })
72
-
73
- return ordersServer.fetch(request, {
74
- authorize(operation) {
75
- // your per-session and per-operation authorization:
76
- // return canAccess(user, operation)
77
- return true
78
- },
79
- })
80
- }
81
-
82
- export const GET = fetchOrders
83
- export const POST = fetchOrders
84
- ```
85
-
86
- `authorize` runs after A2 parses the operation and before schema
87
- validation or durable I/O. It receives `stream`, `history`, and `push`
88
- operations. Push operations include parsed event names and payloads,
89
- plus the complete presence patch when present. Return `false` to deny.
90
- A2 answers `FORBIDDEN` with status 403. If the hook throws, A2 treats
91
- that as an infrastructure failure and answers `STORE_UNAVAILABLE`.
92
-
93
- The hook is per `fetch` call. Authentication stays in the route, where
94
- request headers, cookies, and framework context are available. The
95
- captured user can then authorize strongly typed A2 operations without
96
- repeating request parsing.
97
-
98
- ## The WebSocket route
99
-
100
- Pass the platform's upgrade capability on GET. POST can stay a direct
101
- export for HTTP clients:
102
-
103
- ```ts app/api/socket-order-events/route.ts
104
- import { experimental_upgradeWebSocket } from '@vercel/functions'
105
- import { ordersServer } from '@/server/orders'
106
-
107
- export const POST = ordersServer.fetch
108
-
109
- export function GET(request: Request): Promise<Response> {
110
- return ordersServer.fetch(request, {
111
- upgradeWebSocket: (attach) =>
112
- experimental_upgradeWebSocket(attach, {
113
- maxPayload: 4 * 1024 * 1024,
114
- }),
115
- })
116
- }
117
- ```
118
-
119
- The socket is bound to the `sessionId` in its upgrade URL. The route
120
- authorizes that session before upgrading, and frames cannot select a
121
- different session afterward. Each connected session has its own socket.
122
-
123
- Authenticate the physical upgrade before calling `server.fetch`. For browser
124
- sockets authenticated by cookies, validate the request's `Origin` too. A
125
- WebSocket handshake does not use a CORS preflight.
126
- Then pass `authorize` to check the session stream and every push or
127
- presence frame. There is no separate upgrade operation: request policy
128
- belongs to the route, while A2 operation policy belongs to `authorize`.
129
-
130
- To multiplex, opt in on both sides:
131
-
132
- ```ts app/api/multiplexed-order-events/route.ts
133
- import { experimental_upgradeWebSocket } from '@vercel/functions'
134
- import { ordersServer } from '@/server/orders'
135
-
136
- export function GET(request: Request): Promise<Response> {
137
- return ordersServer.fetch(request, {
138
- multiplexWebSocket: true,
139
- upgradeWebSocket: (attach) =>
140
- experimental_upgradeWebSocket(attach, {
141
- maxPayload: 4 * 1024 * 1024,
142
- }),
143
- })
144
- }
145
- ```
146
-
147
- ```ts app/orders/multiplexed-session.ts
148
- 'use client'
149
- import { createClient } from 'experimental-a2/client'
150
- import { orders } from '@/contracts'
151
-
152
- export const ordersClient = createClient({
153
- contract: orders,
154
- api: {
155
- type: 'ws',
156
- url: '/api/multiplexed-order-events',
157
- multiplex: true,
158
- },
159
- })
160
- ```
161
-
162
- Only enable multiplexing when the request's authentication context may
163
- open more than one session and `authorize` checks each supplied session
164
- ID. Subscribe frames open the lanes after the physical upgrade.
165
-
166
- On a multiplexed socket, a denied subscribe receives an `unsubscribed`
167
- notice with a structured `FORBIDDEN` error and leaves other sessions
168
- connected. A failed stream or thrown authorization check carries
169
- `STORE_UNAVAILABLE`. The client exposes these as `A2ClientError` instances
170
- to its reconnection policy. On either socket shape, a
171
- denied event push receives a non-retryable `FORBIDDEN` ack. Denied
172
- presence is silently dropped because presence is fire-and-forget and
173
- repaints on the next update.
174
-
175
- A2 reads the platform's ambient invocation deadline and closes the
176
- socket cleanly before it. `waitUntil` and deadline discovery remain
177
- platform capabilities used internally, not options on `server.fetch`.
178
-
179
- ## The client
180
-
181
- ```ts app/orders/[orderId]/session.ts
182
- 'use client'
183
- import { createClient } from 'experimental-a2/client'
184
- import { createReact } from 'experimental-a2/react'
185
- import { orders } from '@/contracts'
186
-
187
- export const ordersClient = createClient({
188
- contract: orders,
189
- api: { type: 'ws', url: '/api/order-events' },
190
- })
191
- export const { SessionProvider, useSession } = createReact({
192
- client: ordersClient,
193
- })
194
- ```
195
-
196
- Nothing else changes. `useSession` is identical, `push` returns the
197
- same ack and `confirmed`, and presence coalesces on the same cadence.
198
- The connection status, stall watchdog, and reconnection behavior are
199
- shared above the wire seam.
200
-
201
- ## Lifecycle
202
-
203
- When a socket closes, the client follows its reconnection policy at the current
204
- frontier, receives a fresh presence snapshot, and re-sends its own latest
205
- presence values. A multiplexed socket re-subscribes every active session
206
- at its own frontier. Event pushes keep their client-generated ids, so
207
- retry remains idempotent.
208
-
209
- Configure `createClient({ reconnect })` to choose a delay or pause by
210
- returning `false`. A paused session keeps its state and leases; call
211
- `session.reconnect()` to resume it. The default policy reconnects with
212
- backoff indefinitely. A clean close also runs the policy, with `error: null`.
213
-
214
- Pre-upgrade authorization remains in the HTTP route. Browser WebSockets do
215
- not expose a rejected upgrade's HTTP status or body, so those failures remain
216
- opaque `CONNECTION_FAILED` errors. Structured subscription errors are
217
- available after a multiplexed socket has opened.
218
-
219
- SSE and WebSocket are transport choices, not different consistency
220
- models. Both resume from the durable event frontier and treat presence
221
- as ephemeral repaintable state.