experimental-a2 0.2.0 → 0.4.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 (92) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
  56. package/dist/store-redis-http.d.ts +21 -0
  57. package/dist/store-redis-http.js +70 -0
  58. package/dist/store-redis.d.ts +37 -0
  59. package/dist/store-redis.js +298 -0
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +4 -5
  69. package/docs/concepts/01-contracts.mdx +21 -17
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +26 -29
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -54
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +321 -60
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +22 -46
  84. package/docs/reference/01-api.mdx +751 -102
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -6
  87. package/dist/internal-D6wNxTck.js +0 -36
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/log-redis.d.ts +0 -31
  90. package/dist/recovery-vercel.js +0 -119
  91. package/dist/server-DJgD2YWP.js +0 -877
  92. package/dist/wire-BVsgR8o9.js +0 -62
@@ -0,0 +1,284 @@
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
+ presence: {
52
+ cursor: z.object({ x: z.number(), y: z.number() }),
53
+ dragging: z.object({ shapeId: z.string(), x: z.number(), y: z.number() }),
54
+ },
55
+ })
56
+ ```
57
+
58
+ The field is the unit of replication: last writer wins per participant
59
+ per field. Setting `cursor` does not touch `dragging`. Fields are absent
60
+ until a participant sets them, and clear when set to `null` or when
61
+ their backend TTL lapses (a closed tab disappears by silence, no
62
+ explicit leave required).
63
+
64
+ ## The route
65
+
66
+ The same two handlers as [Live UI](/guides/react), with one option and
67
+ one branch. `stream({ presence: true })` interleaves presence patches
68
+ with events on the SSE response, starting with a snapshot of the current
69
+ map. The push body grows an optional `presence` sibling to `events`.
70
+
71
+ ```ts app/api/canvas-events/route.ts
72
+ import { A2Error } from 'experimental-a2'
73
+ import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
74
+ import { canvasServer } from '@/server/canvas'
75
+
76
+ export async function GET(req: Request) {
77
+ const { searchParams } = new URL(req.url)
78
+ const sessionId = searchParams.get('sessionId')
79
+ if (!sessionId) {
80
+ return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
81
+ }
82
+ const startAfter = Number(searchParams.get('index')) || 0
83
+
84
+ // here's where you'd do auth, or any other checks
85
+
86
+ return sseResponse(
87
+ canvasServer.session(sessionId).stream({ startAfter, presence: true }),
88
+ )
89
+ }
90
+
91
+ export async function POST(req: Request) {
92
+ try {
93
+ const { sessionId, events, presence } = await parsePushBody(req)
94
+ const session = canvasServer.session(sessionId)
95
+
96
+ // here's where you'd do auth, or any other checks; the participant
97
+ // id is caller-supplied, so authorize it like you authorize events.
98
+ // createServer's validatePush is the same seam: on the presence
99
+ // plane it receives { sessionId, events: [], presence }, the whole
100
+ // patch, participant included
101
+
102
+ if (presence) await session.setPresence(presence)
103
+ if (events.length === 0) return Response.json([])
104
+ return Response.json(await session.append(...events))
105
+ } catch (err) {
106
+ return errorResponse(err)
107
+ }
108
+ }
109
+ ```
110
+
111
+ `setPresence` validates each field against the contract, then
112
+ broadcasts. No append transaction, no dispatch, no scheduler arm, no log
113
+ row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
114
+ `UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either.
115
+
116
+ ## The browser
117
+
118
+ The provider takes a `participant` id (yours to mint: a user id, a tab
119
+ nonce, the playground's guest name). The hook grows two members. The
120
+ session module is the usual pair from
121
+ [Live UI](/guides/react#the-session-module), bound to a reducer that
122
+ carries the canvas contract:
123
+
124
+ ```ts app/canvas/[canvasId]/session.ts
125
+ 'use client'
126
+ import { createClient } from 'experimental-a2/client'
127
+ import { createReact } from 'experimental-a2/react'
128
+ import { canvas } from '@/contracts'
129
+
130
+ // a minimal scene fold; the real one lives with your reducers
131
+ const sceneReducer = canvas
132
+ .reducer({ name: 'scene', initialState: {} })
133
+ .fold((state) => state)
134
+
135
+ export const canvasClient = createClient({
136
+ reducer: sceneReducer,
137
+ api: '/api/canvas-events',
138
+ })
139
+ export const { SessionProvider, useSession } = createReact({
140
+ client: canvasClient,
141
+ })
142
+ ```
143
+
144
+ ```tsx app/canvas/[canvasId]/canvas-client.tsx
145
+ 'use client'
146
+ import { useSession } from './session'
147
+
148
+ export function CanvasClient() {
149
+ const { presence, setPresence } = useSession()
150
+
151
+ return (
152
+ <div
153
+ onPointerMove={(e) => setPresence({ cursor: { x: e.clientX, y: e.clientY } })}
154
+ >
155
+ {Object.entries(presence).map(([participant, fields]) =>
156
+ // render-time expiry: a cursor silent for a few seconds is gone
157
+ fields.cursor && Date.now() - fields.cursor.at.getTime() < 5_000 ? (
158
+ <Cursor key={participant} {...fields.cursor.value} />
159
+ ) : null,
160
+ )}
161
+ </div>
162
+ )
163
+ }
164
+
165
+ // your cursor sprite:
166
+ // function Cursor({ x, y }: { x: number; y: number }) { ... }
167
+ const Cursor = (props: { x: number; y: number }) => null
168
+ ```
169
+
170
+ - **`setPresence(values)`** is fire-and-forget: validated locally,
171
+ merged into the local map immediately, coalesced on the wire to a
172
+ fixed cadence. The first set sends immediately; within the interval,
173
+ later values merge field by field (latest wins, `null` included) and
174
+ one trailing send carries the merged batch, so a 60Hz pointer becomes
175
+ about fifteen sends per second and losing one is repainted by the
176
+ next. Sends may overlap in flight; that is safe because merges are
177
+ last-writer-wins by `at` per field. No ack, no `confirmed`, no retry,
178
+ no optimistic rollback: there is nothing to roll back, because
179
+ nothing was ever true.
180
+ - **`presence`** is the replicated map,
181
+ `Record<participantId, { [field]: { value, seen, at } }>`, including
182
+ your own participant. `at` is the sender's stamp for when the value
183
+ was set, and the last-writer-wins comparator; the client stamps its
184
+ sends monotonically, so overlapping sends can never invert. How long
185
+ a cursor stays painted is view logic, decided at render time. Wall
186
+ clocks never enter folds, and presence is not folded state.
187
+
188
+ Set a field to `null` to clear it (pointer leaves the canvas, blur,
189
+ unmount). Everything else expires by TTL when a tab goes silent: 60
190
+ seconds by default, set `createServer({ presence: { ttlMs } })` to
191
+ change it. Expiry counts on the backend's clock, not the sender's
192
+ stamp.
193
+
194
+ Both members are typed from the contract's `presence` map, through the
195
+ reducer, with no type arguments: field names, payloads, and `null`
196
+ clears all autocomplete. On a contract without `presence` the members
197
+ do not exist, so reaching for them is a compile error, not a silent
198
+ no-op. One symptom worth knowing: if the map holds only your own
199
+ echo while others are active, the GET route forgot `presence: true`.
200
+
201
+ ## Reconciling the planes: `seen`
202
+
203
+ Every presence value carries `seen`, the sender's stream frontier at the
204
+ moment it was set. The client stamps it automatically. This is what lets
205
+ the two planes agree on an order without wall clocks.
206
+
207
+ The classic race: a participant drags a shape (presence), drops it
208
+ (event), and a stale `dragging` patch arrives after the durable
209
+ `shape.moved`. Without a shared coordinate system the shape flickers
210
+ backwards. With `seen`, superseding is one render-time rule:
211
+
212
+ ```ts
213
+ // in render code: a ghost is dead once the log has moved past it.
214
+ // show fields.dragging only if
215
+ // fields.dragging.seen >= state.shapes[fields.dragging.value.shapeId].movedAt
216
+ // where movedAt is the index your reducer recorded for the last shape.moved
217
+ ```
218
+
219
+ The durable event at index 214 beats any presence stamped 213 or lower,
220
+ deterministically. A2 carries the stamp and exposes the frontiers; the
221
+ comparison is yours, in view code, next to the pixels it protects.
222
+
223
+ ## Handlers broadcast too
224
+
225
+ `ctx.session.setPresence` is the same session operation. A handler
226
+ grinding through work can publish live progress without writing
227
+ non-facts to the log:
228
+
229
+ ```ts server/canvas.ts
230
+ import { createServer } from 'experimental-a2/server'
231
+ import { canvas } from '@/contracts'
232
+
233
+ export const canvasServer = createServer({
234
+ contract: canvas,
235
+ handlers: {
236
+ 'shape.created': async (ctx) => {
237
+ // long work; broadcast progress as it goes:
238
+ // await ctx.session.setPresence({
239
+ // participant: 'system:thumbnails',
240
+ // values: { progress: { step: 2, of: 5 } },
241
+ // })
242
+ },
243
+ },
244
+ })
245
+ ```
246
+
247
+ The participant id is the handler's choice; a `system:` prefix keeps
248
+ robots and humans apart in the map. Append the durable outcome as an
249
+ event when the work completes; the progress was never a fact, the result
250
+ is.
251
+
252
+ ## What presence never does
253
+
254
+ - Never appears in `history()`, `state()`, or a reducer's input type.
255
+ The separation is structural: presence fields are not events, so a
256
+ fold cannot reach them even by accident.
257
+ - Never arms the scheduler, takes a claim, or retries. A crashed presence
258
+ update is a non-event, literally.
259
+ - Never enters the IndexedDB cache. Cache the log, never the moment.
260
+ - Never blocks an append. The planes share a route, not a transaction.
261
+
262
+ ## Backends
263
+
264
+ Presence needs a broadcast primitive, and backends differ. Declaring
265
+ `presence` in a contract requires a store with the presence capability;
266
+ `createServer` throws `PRESENCE_NOT_SUPPORTED` at construction
267
+ otherwise. All built-in stores have it, at two tiers:
268
+
269
+ | Backend | Delivery |
270
+ | --- | --- |
271
+ | `store-redis` | push: TTL'd hash plus pub/sub patch, single-digit ms to parked subscribers |
272
+ | `store-memory` | in-process, immediate |
273
+ | `store-postgres`, `store-sqlite`, `store-redis-http` | degraded: bounded TTL'd rows, read on the live feed's poll cadence |
274
+
275
+ Degraded means later, not lost while watched: presence-only traffic
276
+ surfaces on a fixed 250ms re-read tick plus event wakes; the 25ms
277
+ adaptive floor engages only while events flow. The push tier never
278
+ broadcasts TTL expiry: a participant that departs silently keeps their
279
+ last values in connected clients' maps until those clients reconnect,
280
+ which is why the render-time expiry above is the guard. Cursors want
281
+ the push tier; typing indicators and progress read fine on either. The presence rows are
282
+ bounded per session and participant, so this is not log growth in
283
+ disguise, but on metered backends every patch is still a network
284
+ operation. Presence makes ephemeral state correct, not free.
@@ -0,0 +1,131 @@
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, the presence throttle and re-send. `api` picks the wire
11
+ underneath it:
12
+
13
+ ```ts
14
+ // in a 'use client' session module:
15
+ api: '/api/order-events'
16
+ api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
17
+ api: { type: 'ws', url: '/api/order-events' }
18
+ ```
19
+
20
+ - **The string** is the default and the recommendation: one route
21
+ serving GET (SSE stream) and POST (push), one file, one auth surface.
22
+ Everything else in these docs uses it.
23
+ - **`http` split** serves the two verbs from two routes. Use it when
24
+ platform duration limits differ per verb: the stream is a long-lived
25
+ read that wants a high `maxDuration`, the push is a short write that
26
+ doesn't.
27
+ - **`ws`** rides everything over one WebSocket: the stream comes down
28
+ it, pushes and presence go up it. Use it when latency or per-message
29
+ cost matters; at a presence cadence of fifteen sends a second, each
30
+ send is a socket frame instead of a route invocation.
31
+
32
+ A split socket is unrepresentable on purpose. The socket is one
33
+ connection in both directions; there is nothing left to split.
34
+
35
+ ## The WebSocket route
36
+
37
+ The same route can serve both transports by branching on the upgrade
38
+ header. Auth runs before the upgrade, while the request is still a
39
+ request.
40
+
41
+ ```ts app/api/order-events/route.ts
42
+ import { experimental_upgradeWebSocket } from '@vercel/functions'
43
+ import { A2Error } from 'experimental-a2'
44
+ import { errorResponse, sessionSocket, sseResponse } from 'experimental-a2/http'
45
+ import { ordersServer } from '@/server/orders'
46
+
47
+ export async function GET(req: Request) {
48
+ const { searchParams } = new URL(req.url)
49
+ const sessionId = searchParams.get('sessionId')
50
+ if (!sessionId) {
51
+ return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
52
+ }
53
+ const startAfter = Number(searchParams.get('index')) || 0
54
+
55
+ // here's where you'd do auth, or any other checks
56
+
57
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
58
+ return experimental_upgradeWebSocket(
59
+ (ws) => sessionSocket(ordersServer.session(sessionId), ws, { startAfter }),
60
+ // ws defaults to 100 MiB per frame; POST bodies cap at about
61
+ // 4.5 MB on the platform. Keep the two ingress paths at parity.
62
+ { maxPayload: 4 * 1024 * 1024 },
63
+ )
64
+ }
65
+ return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
66
+ }
67
+ ```
68
+
69
+ `sessionSocket` speaks the whole protocol against any `ws`-shaped
70
+ socket: it pumps the session's stream down as JSON frames, accepts push
71
+ and presence frames up, validates them exactly as `parsePushBody` does
72
+ (same provenance brand, same `validatePush` calls, once per plane), and
73
+ answers each push with its ack. POST keeps working unchanged next to
74
+ it; a `ws` client never calls it, an `http` client never upgrades. The
75
+ transports are additive.
76
+
77
+ Contracts that declare presence pass `{ presence: true }` in the
78
+ options, the same opt-in as `stream()`.
79
+
80
+ ## The client
81
+
82
+ ```ts app/orders/[orderId]/session.ts
83
+ 'use client'
84
+ import { createClient } from 'experimental-a2/client'
85
+ import { createReact } from 'experimental-a2/react'
86
+ import { ordersReducer } from '@/reducer'
87
+
88
+ export const ordersClient = createClient({
89
+ reducer: ordersReducer,
90
+ api: { type: 'ws', url: '/api/order-events' },
91
+ })
92
+ export const { SessionProvider, useSession } = createReact({
93
+ client: ordersClient,
94
+ })
95
+ ```
96
+
97
+ Nothing else changes. `useSession` is identical, `push` returns the
98
+ same ack and `confirmed`, presence coalesces on the same cadence. The
99
+ connection status, the stall watchdog (two missed heartbeats), and
100
+ reconnection with backoff all behave as they do over SSE, because they
101
+ are the same machinery above the wire seam.
102
+
103
+ ## Lifecycle
104
+
105
+ A socket closes when the platform ends the function invocation, or
106
+ when the server closes it deliberately ahead of a known deadline. The
107
+ client treats every close the same way it treats a dropped SSE stream:
108
+ reconnect with backoff, resume from the current frontier, receive a
109
+ fresh presence snapshot, re-send its own presence fields set since the
110
+ disconnect. A push whose socket died before the ack rejects as
111
+ retryable: nothing was acknowledged, and if the append had already
112
+ committed, the client-generated event ids make the retry an idempotent
113
+ replay (you get the original events back). Push retries wait for the
114
+ restored socket rather than a fixed clock, capped by the reconnect
115
+ backoff, so a push caught in a routine blip resolves the way it would
116
+ have over HTTP, where pushes never depended on the stream.
117
+
118
+ On Vercel, WebSocket connections pin to one function instance and new
119
+ connections may land anywhere. A2 never relies on instance affinity:
120
+ the log is the shared state, so two clients on two instances converge
121
+ through it, exactly as they do over HTTP.
122
+
123
+ ## Choosing
124
+
125
+ SSE plus POST is the right default: simplest route, ordinary HTTP
126
+ semantics, and one auth surface. Reach for `ws` when the send rate
127
+ makes per-request overhead visible (live cursors, drag ghosts) or when
128
+ round-trip latency is the product. Reach for the `http` split only when
129
+ per-route duration configuration forces it. The protocol underneath is
130
+ the same in every case: the log replays, presence repaints, and the
131
+ wire is an implementation detail.
package/docs/index.mdx CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Introduction
3
- description: A2 is durable sync and reactions for things with a lifecycle. Append events, run handlers, and keep one live view from server to browser.
3
+ description: A2 is durable sync and reactions for things with a lifecycle. Append or schedule events, run handlers, and keep one live view from server to browser.
4
4
  ---
5
5
 
6
6
  ```bash
@@ -14,8 +14,8 @@ Handlers react to them, and usually return the next one. The browser follows
14
14
  the same log and folds the same state as the server. That's the whole model;
15
15
  the rest of this page is it happening.
16
16
 
17
- There is no declared state machine. Events record facts, reducers compute the
18
- current view, and handlers perform the reactions.
17
+ Events record facts, reducers compute the current view, and handlers perform
18
+ the reactions.
19
19
 
20
20
  ## Define a contract
21
21
 
@@ -39,8 +39,7 @@ export const orders = a2.contract({
39
39
 
40
40
  ## React to events
41
41
 
42
- Handlers are plain async functions: no determinism rules, no replay, no
43
- wrappers around side effects. Each one reacts to a fact and usually
42
+ Handlers are plain async functions. Each one reacts to a fact and usually
44
43
  returns the next one.
45
44
 
46
45
  ```ts server/orders.ts
@@ -87,9 +86,11 @@ export async function POST(req: Request) {
87
86
  }
88
87
  ```
89
88
 
90
- That append is durable. The event hits the log before anything else happens,
91
- then A2 starts its handler inline. In production, queue-backed recovery wakes
92
- pending work after a crash or timeout.
89
+ A2 commits the event before its handler can start. The handler may begin before
90
+ `append` returns; the return confirms durability, not handler completion.
91
+ A queue-backed scheduler wakes pending work after a crash or timeout. The same
92
+ scheduler lets a session append a typed event later with
93
+ `session.schedule(name, { delay: '30s' }, event)`.
93
94
 
94
95
  ## State is a fold
95
96
 
@@ -168,8 +169,8 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
168
169
 
169
170
  - **Handlers are plain async functions.** They run concurrently by default.
170
171
  Use a lane for selective FIFO execution.
171
- - **Handlers are optional per event type.** A log-only event settles during
172
- append and creates no reaction or recovery work.
172
+ - **Handlers are optional per event type.** A history-only event settles during
173
+ append and creates no reaction or scheduler work.
173
174
  - **History is real.** [`history()`](/concepts/state) returns what
174
175
  actually happened, in order. Debugging is reading, not reconstructing.
175
176
  - **Nothing inside a session is sacred except its log.** Snapshots, claims, queue
@@ -178,38 +179,12 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
178
179
 
179
180
  ## FAQ
180
181
 
181
- <details>
182
- <summary>Why not a workflow engine?</summary>
183
-
184
- Workflow engines replay your code from the top on every wake-up. So the
185
- code has to be deterministic, so every side effect gets wrapped in a step
186
- function, and `sleep()` becomes something magical instead of something
187
- you'd never call in a serverless function.
188
-
189
- A2's answer is older and simpler: write everything down. Every meaningful
190
- thing that happens is an event in a log. Handlers are stateless functions
191
- that react to one event at a time. State isn't stored. It's computed, by
192
- folding over the log whenever you need it. There's no orchestrator to
193
- operate.
194
-
195
- </details>
196
-
197
- <details>
198
- <summary>Isn't this just event sourcing?</summary>
199
-
200
- It's the useful core of it. A log of facts, state as a fold: the idea is
201
- decades old, and it's a good one. A2 cuts the ceremony that made it a big
202
- commitment. No command bus, no projection cluster, no upcasting
203
- framework. A contract, a log, handlers, reducers.
204
-
205
- </details>
206
-
207
182
  <details>
208
183
  <summary>Can I use A2 without handlers?</summary>
209
184
 
210
185
  Yes. Omit `handlers` and use `append`, `history`, reducers, and live streams as
211
186
  a durable event log. Events settle in their append transaction, with no drain,
212
- claim, or queue message. A contract can also mix handled and log-only event
187
+ claim, or queue message. A contract can also mix handled and history-only event
213
188
  types. A2 decides per event type.
214
189
 
215
190
  See [Events without handlers](/concepts/handlers#events-without-handlers).
@@ -217,12 +192,12 @@ See [Events without handlers](/concepts/handlers#events-without-handlers).
217
192
  </details>
218
193
 
219
194
  <details>
220
- <summary>How do I wait five days?</summary>
195
+ <summary>How do I schedule an event for later?</summary>
221
196
 
222
- You don't sleep. You schedule an event. Anything that can deliver an
223
- HTTP call later (QStash, a cron, a payment provider's webhook) hits a
224
- route that appends. The scheduled thing is data, a session id plus an
225
- event, not a suspended function. See [Timers](/guides/timers).
197
+ Configure Vercel Queues or QStash, then call
198
+ `session.schedule(name, { delay: '30s' }, event)` or use `{ at: Date }`. The
199
+ same scheduler route handles recovery and the delayed append. Names and omitted
200
+ event ids stay stable across retries. See [Timers](/guides/timers).
226
201
 
227
202
  </details>
228
203
 
@@ -248,10 +223,11 @@ recovery story behind them.
248
223
  <details>
249
224
  <summary>What does production need?</summary>
250
225
 
251
- A durable log backend, such as Postgres or Redis. Add queue-backed recovery
252
- when the contract has handlers. A log-only contract needs no reaction
253
- infrastructure. Development needs neither: SQLite appears under `.a2/` and
254
- state survives restarts. See [Going to production](/guides/production).
226
+ A durable store backend, such as Postgres or Redis. Add a queue-backed scheduler
227
+ when the contract has handlers or calls `session.schedule`. A history-only
228
+ contract with no timers needs no reaction infrastructure. Development needs
229
+ neither: SQLite appears under `.a2/` and state survives restarts. See
230
+ [Going to production](/guides/production).
255
231
 
256
232
  </details>
257
233