experimental-a2 0.3.0 → 0.5.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 (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
@@ -1,80 +1,185 @@
1
1
  ---
2
2
  title: Timers and delays
3
- description: A delayed action is an event delivered later by anything that can make an HTTP call.
3
+ description: Schedule a typed session event for later with the same adapter that handles recovery.
4
4
  ---
5
5
 
6
- ## Schedule delayed events
6
+ ## Schedule from a handler
7
7
 
8
- If something should happen in five days, schedule an HTTP call that appends
9
- the event then. The scheduler stores the session id and event until delivery.
10
-
11
- ## Schedule an event
12
-
13
- Use any scheduler that can deliver an HTTP call later: QStash, a cron, your
14
- payment provider's webhook. It hits a route; the route appends.
8
+ Configure one scheduler for the server, then call
9
+ `session.schedule(name, timing, ...events)`. This schedules an `expired` event
10
+ for five days after the durable `created` event:
15
11
 
16
12
  ```ts server/orders.ts
17
13
  import { createServer } from 'experimental-a2/server'
14
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
18
15
  import { orders } from '@/contracts'
19
16
 
17
+ export const scheduler = vercelQueues()
18
+
20
19
  export const ordersServer = createServer({
21
20
  contract: orders,
21
+ scheduler,
22
22
  handlers: {
23
23
  created: async ({ session }) => {
24
- // schedule with anything that can deliver an HTTP call later:
25
- // QStash, cron, a provider webhook:
26
- //
27
- // await scheduleHttpCall({
28
- // delay: '5d',
29
- // url: '/api/append',
30
- // body: { sessionId: session.id, type: 'expired', payload: {} },
31
- // })
24
+ await session.schedule(
25
+ 'expire-order',
26
+ { delay: '5d' },
27
+ { type: 'expired', payload: {} },
28
+ )
29
+ },
30
+ expired: async ({ session }) => {
31
+ const events = await session.history()
32
+ if (events.some((event) => event.type === 'shop.started')) return
33
+ // your expiry side effect goes here
32
34
  },
33
35
  },
34
36
  })
35
37
  ```
36
38
 
37
- ```ts app/api/append/route.ts
39
+ The same scheduler route handles recovery watchdogs and scheduled events. Mount
40
+ it once as shown in
41
+ [Going to production](/guides/production#2-add-a-scheduler-for-handlers-and-timers).
42
+
43
+ `timing` is exactly one of these shapes:
44
+
45
+ ```ts
46
+ // anywhere on the server:
47
+ type ScheduleTiming =
48
+ | { delay: `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`; at?: never }
49
+ | { at: Date; delay?: never }
50
+ ```
51
+
52
+ The template type checks the unit suffix. At runtime, the number must be an
53
+ unsigned base-10 decimal without leading zeros, finite, and greater than zero.
54
+ A2 rejects `0s`, signs, exponent notation, malformed strings, and invalid
55
+ dates.
56
+
57
+ The requested target is the earliest useful delivery time, not an exact
58
+ execution deadline. Adapters encode provider timing in whole-second slots. A
59
+ target that is already due publishes immediately.
60
+
61
+ The call awaits provider acceptance and returns `void`. No event enters the
62
+ store until the scheduler delivers the task.
63
+
64
+ ## Schedule from ordinary server code
65
+
66
+ Root sessions expose the same method. Use `{ at }` when a caller may retry and
67
+ the target time must stay fixed:
68
+
69
+ ```ts app/api/trials/route.ts
70
+ import { z } from 'zod'
38
71
  import { ordersServer } from '@/server/orders'
39
72
 
40
- export async function POST(req: Request) {
41
- const { sessionId, type, payload } = await req.json()
42
- await ordersServer.session(sessionId).append({ type, payload })
43
- return Response.json({ ok: true })
73
+ const scheduleTrial = z.object({
74
+ orderId: z.string(),
75
+ expiresAt: z.iso.datetime().transform((value) => new Date(value)),
76
+ })
77
+
78
+ export async function POST(request: Request) {
79
+ const body = scheduleTrial.parse(await request.json())
80
+ await ordersServer.session(body.orderId).schedule(
81
+ 'expire-order',
82
+ { at: body.expiresAt },
83
+ { type: 'expired', payload: {} },
84
+ )
85
+ return Response.json({ scheduled: true }, { status: 202 })
44
86
  }
45
87
  ```
46
88
 
47
- :::warning
48
- Auth this route like any other. Anything that can call it can move your
49
- sessions forward.
50
- :::
89
+ A root `{ delay: '30s' }` starts from the wall clock captured by that call. A
90
+ retry 10 seconds later calculates a delivery 10 seconds later too. An absolute
91
+ `{ at }` preserves the original target. Inside a handler, relative delays are
92
+ already retry-stable: A2 anchors them to the triggering event's durable
93
+ `createdAt`, not the retry's clock.
94
+
95
+ ## Identity and retries
96
+
97
+ `name` identifies one logical schedule. On a root session its scope is the
98
+ contract and session. In a handler it also includes the triggering event id, so
99
+ different events can both use a local name such as `expire-order` while retries
100
+ of one event converge.
101
+
102
+ Keep the same name, timing, and events on every retry. A2 derives stable ids for
103
+ events whose `id` you omit. Provider deduplication folds repeated sends while
104
+ its deduplication window is open. After that window, duplicate deliveries
105
+ still append once because they carry the same event ids. A changed input under
106
+ the same identity conflicts when it produces a different validated event at
107
+ delivery.
108
+
109
+ A2 does not store a timer-intent registry, so it cannot compare every retry
110
+ with the first call before sending. The stable provider task and event ids are
111
+ the durable convergence mechanism.
112
+
113
+ ## What arrives later
114
+
115
+ Scheduled payloads travel inside the provider message. Before validation, A2
116
+ snapshots each payload into the same plain JSON tree the provider will deliver.
117
+ Encode a `Date` as an ISO string and a custom class as explicit JSON data. A2
118
+ rejects `undefined`, functions, symbols, bigint values, cycles, accessors, and
119
+ non-plain prototypes. Shared object references become independent JSON values;
120
+ reference identity is not part of a scheduled payload. The task carries that
121
+ snapshotted schema input. At delivery, A2 validates it through
122
+ an ordinary top-level append with fixed ids. A schema transform produces the
123
+ stored payload from that original input, rather than receiving its earlier
124
+ validation output as new input.
125
+
126
+ That means scheduled events:
127
+
128
+ - enter the log only when delivered;
129
+ - have `cause: null`, like an ordinary server append;
130
+ - run handlers and lanes normally;
131
+ - update reducers, streams, and live clients normally; and
132
+ - tolerate at-least-once delivery through their stable ids.
133
+
134
+ If the deployment receiving a timer removes or changes its event schema,
135
+ delivery can fail validation and remain visible as a retrying scheduler error.
136
+ Vercel Queues pins timers to their publishing deployment by default. This
137
+ cross-deployment compatibility concern applies when
138
+ `scheduledAppends: 'deploymentless'` is selected and to adapters that target a
139
+ stable URL, such as QStash.
51
140
 
52
141
  ## Stale timers
53
142
 
54
- The order shipped on day two. The expiry still fires on day five. Now what?
143
+ The order shipped on day two. Its expiry event still arrives on day five. The
144
+ recommended pattern is the guard in the first example: let the event land, read
145
+ the session, and return when it no longer applies. The log still records the
146
+ fact that the timer fired.
55
147
 
56
- You could hunt down the scheduled call and cancel it. The simpler default,
57
- and the recommended one, is to let the timer fire and have the handler
58
- check whether it still applies. Guard on read:
148
+ A2 has no cancellation, reschedule, cron, or timer-listing API. `name` is an
149
+ idempotency key, not a handle. Model repeated schedules as session events and
150
+ have a handler schedule the next occurrence explicitly.
59
151
 
60
- ```ts
61
- // server/orders.ts, the expiry handler, guarding on read:
62
- import { createServer } from 'experimental-a2/server'
63
- import { orders } from '@/contracts'
152
+ ## Provider limits
64
153
 
65
- export const ordersServer = createServer({
66
- contract: orders,
67
- handlers: {
68
- expired: async ({ session }) => {
69
- const events = await session.history()
70
- if (events.some((e) => e.type === 'shop.started')) return // stale, ignore
71
- // ...actually expire the order
72
- },
73
- },
74
- })
75
- ```
154
+ One scheduled task must fit the active provider's maximum delay:
155
+
156
+ | Adapter | Maximum one-shot delay |
157
+ | --- | --- |
158
+ | Vercel Queues | 6 days |
159
+ | QStash free | 7 days |
160
+ | QStash usage-based | 1 year |
161
+ | QStash fixed | no fixed maximum |
76
162
 
77
- This works because history is truth. The timer doesn't need to know what
78
- happened after it was scheduled; the handler can just look. The stale
79
- `expired` event still lands in the log, and that's fine: it's a fact ("the
80
- timer fired"), and the handler decided it was moot.
163
+ These are provider limits. A2 does not silently split a longer delay into
164
+ several tasks. Vercel Queues retains the message for seven days; A2 reserves
165
+ the final day for delivery and retries instead of making the delay equal the
166
+ message's lifetime. Leave at least one second of margin at a QStash maximum
167
+ because its `notBefore` timestamp uses whole Unix seconds.
168
+
169
+ ## Deploy safely
170
+
171
+ Vercel Queues pins scheduled appends and recovery watchdogs to their publishing
172
+ deployment by default. `scheduledAppends: 'deploymentless'` removes affinity
173
+ only for timers. Select it only when every eligible consumer for the topic can
174
+ decode pending tasks and validate their events. Recovery remains pinned.
175
+
176
+ New scheduler routes accept older recovery messages, which supports adapter
177
+ upgrades for schedulers that deliberately cross deployments.
178
+
179
+ :::warning
180
+ When using deploymentless Vercel timers or a stable callback URL, do not roll
181
+ back to an A2 version without timer delivery while timer tasks are still
182
+ outstanding. An older route can acknowledge one as a recovery message without
183
+ appending its events. Roll forward, or first wait for the tasks to deliver or
184
+ remove them through the provider.
185
+ :::
@@ -41,10 +41,36 @@ export const chatServer = createServer({
41
41
 
42
42
  While the handler runs, A2 watches the session's log. The moment a
43
43
  `cancelled` event lands (or one already landed before the handler
44
- started), `ctx.signal` fires. Handlers without `abortOn` pay nothing.
45
-
46
- One rule: an aborted handler should catch and return normally. Throwing
47
- means "retry me", exactly the wrong response to someone pressing stop.
44
+ started), `ctx.signal` fires. Handlers without `abortOn` pay nothing
45
+ for the watch: no subscription is held for them.
46
+
47
+ Every handler's signal, with or without `abortOn`, also guards the run's
48
+ durable claim. While a handler runs, A2 renews its claim on a heartbeat,
49
+ and a failed renewal retries promptly while the lease can still be saved.
50
+ A short stall is free: blocking the event loop for less than the lease's
51
+ slack costs nothing, because an overdue beat fires the moment the loop
52
+ wakes. If no renewal lands before the lease lapses, the signal fires with
53
+ an `A2Error` reason of code `CLAIM_EXPIRED`. That decision is local (no
54
+ store round trip), so it works even while the store is unreachable, and
55
+ it means: assume a recovery run is taking over, stop external work now.
56
+ If a successor has provably claimed the event, the reason is
57
+ `SUPERSEDED_ATTEMPT` instead; by then a lapsed run's signal has usually
58
+ already fired. A superseded run can no longer write to the log or
59
+ complete; wiring `ctx.signal` into your external calls is what stops it
60
+ from racing its successor outside A2.
61
+
62
+ The right response depends on the reason, and the default wiring gets
63
+ every case correct: pass `ctx.signal` to your I/O and let the abort
64
+ propagate. A propagated lease abort (`CLAIM_EXPIRED` or
65
+ `SUPERSEDED_ATTEMPT`, which is exactly what `fetch(url, { signal })`
66
+ rejects with) makes A2 surrender the attempt: no completion recorded,
67
+ no failure budget spent, and the event recovers normally. A user
68
+ cancellation (`abortOn`) is the one case to catch and return normally,
69
+ because the stop is the outcome and the completion should count.
70
+ Return normally after a lease abort only when the work is actually
71
+ done: a lapsed run that finished, with no successor claimed yet, still
72
+ completes and its completion counts. Any other throw is a real failure
73
+ and spends the retry budget.
48
74
 
49
75
  When one session multiplexes work (several generations over its
50
76
  lifetime), match by *instance*, not just type, with a predicate:
@@ -10,9 +10,9 @@ paint, a stream keeps it fresh, and writes apply instantly. One reducer
10
10
  produces every one of those views; server and client fold the same log
11
11
  with the same function, so they can't disagree.
12
12
 
13
- That's the trick, really. The client doesn't sync state. It syncs events,
14
- and folds them locally. State sync is a hard problem. An append-only log
15
- with positions is not.
13
+ That's the trick, really. The client receives one state snapshot and its log
14
+ position, then syncs later events and folds them locally. It does not keep
15
+ synchronizing state. An append-only log with positions is simpler.
16
16
 
17
17
  This is session sync. The client follows one known session id; it does not
18
18
  subscribe to database tables or queries across many sessions. Keep those
@@ -23,52 +23,57 @@ reducer, and the provider + hook it returns.
23
23
 
24
24
  ## The API route
25
25
 
26
- One file exposes a session over HTTP: `GET` streams events, `POST` appends.
27
- These are ordinary route handlers; put whatever checks you like in front.
26
+ One call exposes a session over HTTP: `GET` streams events (and serves
27
+ history slices), `POST` appends. `handle` parses each request into an
28
+ intent, runs your hooks, then acts.
28
29
 
29
30
  ```ts app/api/order-events/route.ts
30
- import { A2Error } from 'experimental-a2'
31
+ import { handle } from 'experimental-a2/http'
31
32
  import { ordersServer } from '@/server/orders'
32
- import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
33
33
 
34
- export async function GET(req: Request) {
35
- const { searchParams } = new URL(req.url)
36
- const sessionId = searchParams.get('sessionId')
37
- if (!sessionId) {
38
- return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
39
- }
40
- const startAt = Number(searchParams.get('index')) || 0
41
-
42
- // here's where you'd do auth, or any other checks
43
-
44
- return sseResponse(ordersServer.session(sessionId).stream({ startAt }))
45
- }
46
-
47
- export async function POST(req: Request) {
48
- try {
49
- const { sessionId, events } = await parsePushBody(req)
50
-
51
- // here's where you'd do auth, or any other checks
52
-
53
- const result = await ordersServer.session(sessionId).append(...events)
54
- return Response.json(result)
55
- } catch (err) {
56
- return errorResponse(err)
57
- }
58
- }
34
+ export const { GET, POST } = handle(ordersServer, {
35
+ before({ request, intent }) {
36
+ // here's where you'd do auth, or any other checks. Every lane
37
+ // arrives parsed: intent.type is 'stream', 'history', 'push', or
38
+ // 'ws-upgrade'. Return a Response to refuse, e.g.:
39
+ // if (!canRead(request, intent)) return new Response(null, { status: 403 })
40
+ },
41
+ })
59
42
  ```
60
43
 
61
- `GET` is the read path. `stream({ startAt })` is a live `AsyncIterable` of
62
- one session's events starting after a given position, and `sseResponse`
63
- pipes it into a server-sent events response. Clients pass `index` to resume
64
- exactly where they left off, after a first paint or a dropped
65
- connection.
44
+ `GET` is the read path. A plain `GET` is the live stream: a server-sent
45
+ events response of one session's events, resumed after the `index` query
46
+ parameter, so clients pick up exactly where they left off after a first
47
+ paint or a dropped connection. A `GET` carrying `gte`/`lte` query
48
+ parameters is a history slice instead: the bounded log range as JSON,
49
+ the cold read [`loadHistory`](#the-client-component) rides.
50
+
51
+ `POST` is the write path. The push envelope is validated (garbage
52
+ answers `INVALID_PAYLOAD` before your hooks run), then `append` does the
53
+ rest. The response is the appended events: an ack, not a stream. Thrown
54
+ [`A2Error`s](/reference/errors#over-the-wire) serialize onto the wire so
55
+ the client can branch on the same codes.
56
+
57
+ Parsing is protocol, hooks are policy. `before` sees every parsed
58
+ intent and short-circuits by returning a Response. `after` runs when the
59
+ library produced an HTTP response and can decorate or replace it. That
60
+ is where caching policy lives, if you want any: `outcome.covered` on a
61
+ history read means the closed range came back fully covered, an
62
+ immutable slice of an append-only log.
63
+
64
+ ```ts
65
+ // app/api/order-events/route.ts, now with response decoration:
66
+ import { handle } from 'experimental-a2/http'
67
+ import { ordersServer } from '@/server/orders'
66
68
 
67
- `POST` is the write path. `parsePushBody` validates the envelope (and
68
- throws `INVALID_PAYLOAD` on garbage), then `append` does the rest. The
69
- response is the appended events: an ack, not a stream. `errorResponse`
70
- serializes any thrown [`A2Error`](/reference/errors#over-the-wire) so the
71
- client can branch on the same codes.
69
+ export const { GET, POST } = handle(ordersServer, {
70
+ after({ outcome, response }) {
71
+ if (outcome.type === 'history' && outcome.covered) {
72
+ response.headers.set('cache-control', 'private, max-age=31536000')
73
+ }
74
+ },
75
+ })
76
+ ```
72
77
 
73
78
  ## The session module
74
79
 
@@ -114,16 +119,12 @@ export default async function OrderPage({
114
119
  const { orderId } = await params
115
120
  const session = ordersServer.session(orderId)
116
121
  const { state, index } = await session.state(ordersReducer)
117
- const initialEvents = (await session.history()).filter(
118
- (event) => event.index <= index,
119
- )
120
122
 
121
123
  return (
122
124
  <SessionProvider
123
125
  sessionId={orderId}
124
126
  initialState={state}
125
127
  initialIndex={index}
126
- initialEvents={initialEvents}
127
128
  >
128
129
  <OrderClient />
129
130
  </SessionProvider>
@@ -131,12 +132,10 @@ export default async function OrderPage({
131
132
  }
132
133
  ```
133
134
 
134
- Three props do the heavy lifting. `initialState` is the fold; `initialIndex`
135
- is its frontier, the log position the fold reflects; `initialEvents` makes
136
- the raw feed available in the server render too. Reading state before history
137
- and filtering at the state frontier gives both values one consistent boundary.
138
- The client opens its stream at exactly that position. Nothing missed, nothing
139
- folded twice.
135
+ Two props do the heavy lifting. `initialState` is the fold. `initialIndex` is
136
+ its frontier, the log position the fold reflects. The client opens its stream
137
+ after that position. The stream catches up any events appended after the server
138
+ fold, then stays live. No history read is needed to hydrate state.
140
139
  (No `reducer` or `api` prop: the provider got both from the shared client.)
141
140
 
142
141
  ## The client component
@@ -146,7 +145,7 @@ folded twice.
146
145
  import { useSession } from './session'
147
146
 
148
147
  export function OrderClient() {
149
- const { state, push, events, index } = useSession()
148
+ const { state, push } = useSession()
150
149
 
151
150
  return (
152
151
  <button onClick={() => push({ type: 'shop.started', payload: {} })}>
@@ -172,9 +171,29 @@ What the hook gives you:
172
171
  live stream delivers the batch back and the view shows server truth.
173
172
  `await push(...).confirmed` is "continue once this is real"; two
174
173
  `performance.now()` calls around the two awaits are a complete
175
- push→ack→stream latency meter.
176
- - **`events`**: the raw feed `state` is folded from, for UI that wants
177
- the log itself: an activity feed, a debug panel.
174
+ push→ack→stream latency meter. Overlapping calls on the same
175
+ session still apply immediately, then enter the transport in call
176
+ order. Controls do not need to wait or disable themselves to preserve
177
+ that order.
178
+ - **`events`**: the raw events this client has observed or was explicitly
179
+ seeded with. With only the server snapshot, it begins after `initialIndex`.
180
+ Use it for UI that wants the log itself: an activity feed, a debug panel.
181
+ Earlier events are not needed to hydrate `state`.
182
+ - **`loadHistory`**: backscroll. `loadHistory({ before?, limit? })`
183
+ fetches a bounded slice of the log from below the frontier (the same
184
+ route, `gte`/`lte` query parameters) and merges it into `events`:
185
+ deduped, ordered, shared across every handle of the session. By
186
+ default each call walks backward 50 events at a time from the oldest
187
+ one loaded. After a hydrate jump (returning to a session whose
188
+ frontier advanced while away), default paging still continues from
189
+ the oldest loaded event; pass an explicit `before` to fill the gap
190
+ between the old feed and the new frontier. It never touches `state` or the optimistic overlay;
191
+ backscrolled events are display data. Calls serialize per session, so
192
+ a double-tap never fetches the same range twice. The `ws` api has no
193
+ history lane; `loadHistory` throws a `TypeError` there.
194
+ - **`history`**: backscroll progress, `{ loading, complete,
195
+ oldestLoaded }`. `complete` means the feed reaches index 1 (or the
196
+ log is empty): nothing older is left, hide the "load older" button.
178
197
  - **`index`**: the stream frontier, the last server-confirmed log
179
198
  position. This is the `lastSeenIndex` that makes
180
199
  [cancellation](/guides/cancellation) exact.
@@ -204,7 +223,7 @@ is the L2: it survives reloads; the memory runtime does not.
204
223
  One detail worth knowing: every push carries a client-generated event id.
205
224
  That id is how the ack finds its optimistic entry, and it makes retrying
206
225
  a failed `POST` idempotent for free (`push` auto-retries only
207
- `LOG_UNAVAILABLE`).
226
+ `STORE_UNAVAILABLE`).
208
227
 
209
228
  Optimistic pushes are also what make [cancellation](/guides/cancellation)
210
229
  feel instant: the `cancelled` event folds locally before the server ever
@@ -39,7 +39,7 @@ Local-first is usually hard because it means syncing state, and state
39
39
  needs merging. A2 caches an append-only log with server-assigned positions
40
40
  instead. A cached event can never be *wrong*; this browser can only be
41
41
  *behind*. Catching up is fetching events after an index, which is what
42
- `stream({ startAt })` does anyway. There's no merge function because
42
+ `stream({ startAfter })` does anyway. There's no merge function because
43
43
  there's nothing to merge.
44
44
 
45
45
  ## What gets stored