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
@@ -13,7 +13,8 @@ const events = await ordersServer.session(orderId).history()
13
13
  Everything that happened in this session, oldest first. Always the raw log,
14
14
  never a summary, never a snapshot. This is the session's audit trail and
15
15
  debugging story, and it is also fine to use inside handlers for questions like
16
- "did the shop already start?"
16
+ "did the shop already start?" Pass inclusive `gte` or `lte` indexes to read a
17
+ bounded slice.
17
18
 
18
19
  ## Reducers
19
20
 
@@ -86,24 +87,20 @@ export const ordersServer = createServer({
86
87
  })
87
88
  ```
88
89
 
89
- A reducer is a name (its identity, more on that below) plus the values that
90
- anchor it: `initialState` (the seed), an optional `stateSchema`, and the
91
- pure `fold`, `(state, event) => state`. It's derived *from* the
92
- contract, so the events type themselves; the two-step shape is
93
- deliberate: the first call fixes the state and event types, and
94
- `.fold()` receives fully concrete ones. No type arguments, no
95
- annotations, and literal unions (like the `z.enum` status above) survive
96
- the fold intact. `state()` folds the session's events through it and
97
- returns the result, along with `index`: the log position the state
98
- reflects. The browser uses that index to resume a live stream exactly
99
- where server-rendered state left off. See [Live UI](/guides/react).
100
-
101
- The snapshot and its remaining event tail come back in one consistent log
102
- operation. A missing snapshot reads the full log. A snapshot rejected by
103
- `stateSchema`, or a failed cache read, falls back to the full log and rebuilds
104
- from truth. `state()` is observational: it never runs handlers or waits for
105
- pending work to finish. Inside a handler, the returned index includes the
106
- triggering event and may include later events that committed before the read.
90
+ A reducer is a name (its identity, more on that below), `initialState`, an
91
+ optional `stateSchema`, and the pure `fold`, `(state, event) => state`. It is
92
+ derived from the contract, so the events type themselves; the two-step shape
93
+ fixes the state and event types before `.fold()` receives them. No type
94
+ arguments or annotations are needed, and literal unions survive the fold.
95
+ `state()` returns the result with `index`, the last log position included. The
96
+ fold includes every event through that index and none after it. The browser
97
+ resumes its live stream from that boundary. See [Live UI](/guides/react).
98
+
99
+ The snapshot and its remaining event tail come back in one consistent store
100
+ operation. A missing, invalid, or unreadable snapshot rebuilds from the full log.
101
+ `state()` is observational: it never runs handlers or waits for pending work.
102
+ Its index marks committed history, not handler completion. Inside a handler it
103
+ includes the trigger and may include later events committed before the read.
107
104
 
108
105
  The read and a following `ctx.session.append(name, ...events)` are separate
109
106
  operations. Concurrent appends and retries can move the frontier between them.
@@ -121,7 +118,7 @@ cache](/guides/local-first): a cached fold that fails the schema is
121
118
  discarded and refolded, catching shape drift a stale `name` can't.
122
119
 
123
120
  Note what these modules import: schemas and `experimental-a2`. Never `experimental-a2/server`,
124
- never a log backend. Contract and reducer are isomorphic by
121
+ never a store backend. Contract and reducer are isomorphic by
125
122
  construction; the browser runs the same reducer. More on the split in
126
123
  [Live UI](/guides/react#keep-the-backend-out-of-the-bundle). (In a
127
124
  server-only app you can keep the contract next to `createServer`
@@ -129,7 +126,7 @@ instead of in its own file.)
129
126
 
130
127
  ## Snapshots are a cache
131
128
 
132
- Folding a long session on every read would get slow, so the log backend
129
+ Folding a long session on every read would get slow, so the store backend
133
130
  caches folded state as a snapshot. You never interact with it, except for
134
131
  one string.
135
132
 
@@ -1,85 +1,185 @@
1
1
  ---
2
2
  title: Timers and delays
3
- description: There is no sleep(). 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
- ## There is no `sleep()`
6
+ ## Schedule from a handler
7
7
 
8
- Workflow engines let you sleep inside a function, then perform heroics to
9
- make that survive a serverless platform. A2 doesn't. If something should
10
- happen in five days, then in five days, something should append an event.
11
- That's scheduling.
12
-
13
- The scheduled thing is data (a session id and an event), not a suspended
14
- function. No closure has to survive the gap.
15
-
16
- ## Schedule an event
17
-
18
- Use any scheduler that can deliver an HTTP call later: QStash, a cron, your
19
- 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:
20
11
 
21
12
  ```ts server/orders.ts
22
13
  import { createServer } from 'experimental-a2/server'
14
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
23
15
  import { orders } from '@/contracts'
24
16
 
17
+ export const scheduler = vercelQueues()
18
+
25
19
  export const ordersServer = createServer({
26
20
  contract: orders,
21
+ scheduler,
27
22
  handlers: {
28
23
  created: async ({ session }) => {
29
- // schedule with anything that can deliver an HTTP call later:
30
- // QStash, cron, a provider webhook:
31
- //
32
- // await scheduleHttpCall({
33
- // delay: '5d',
34
- // url: '/api/append',
35
- // body: { sessionId: session.id, type: 'expired', payload: {} },
36
- // })
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
37
34
  },
38
35
  },
39
36
  })
40
37
  ```
41
38
 
42
- ```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'
43
71
  import { ordersServer } from '@/server/orders'
44
72
 
45
- export async function POST(req: Request) {
46
- const { sessionId, type, payload } = await req.json()
47
- await ordersServer.session(sessionId).append({ type, payload })
48
- 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 })
49
86
  }
50
87
  ```
51
88
 
52
- :::warning
53
- Auth this route like any other. Anything that can call it can move your
54
- sessions forward.
55
- :::
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.
56
140
 
57
141
  ## Stale timers
58
142
 
59
- 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.
60
147
 
61
- You could hunt down the scheduled call and cancel it. The simpler default,
62
- and the recommended one, is to let the timer fire and have the handler
63
- 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.
64
151
 
65
- ```ts
66
- // server/orders.ts, the expiry handler, guarding on read:
67
- import { createServer } from 'experimental-a2/server'
68
- import { orders } from '@/contracts'
152
+ ## Provider limits
69
153
 
70
- export const ordersServer = createServer({
71
- contract: orders,
72
- handlers: {
73
- expired: async ({ session }) => {
74
- const events = await session.history()
75
- if (events.some((e) => e.type === 'shop.started')) return // stale, ignore
76
- // ...actually expire the order
77
- },
78
- },
79
- })
80
- ```
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 |
162
+
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.
81
168
 
82
- This works because history is truth. The timer doesn't need to know what
83
- happened after it was scheduled; the handler can just look. The stale
84
- `expired` event still lands in the log, and that's fine: it's a fact ("the
85
- timer fired"), and the handler decided it was moot.
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
@@ -37,11 +37,11 @@ export async function GET(req: Request) {
37
37
  if (!sessionId) {
38
38
  return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
39
39
  }
40
- const startAt = Number(searchParams.get('index')) || 0
40
+ const startAfter = Number(searchParams.get('index')) || 0
41
41
 
42
42
  // here's where you'd do auth, or any other checks
43
43
 
44
- return sseResponse(ordersServer.session(sessionId).stream({ startAt }))
44
+ return sseResponse(ordersServer.session(sessionId).stream({ startAfter }))
45
45
  }
46
46
 
47
47
  export async function POST(req: Request) {
@@ -58,7 +58,7 @@ export async function POST(req: Request) {
58
58
  }
59
59
  ```
60
60
 
61
- `GET` is the read path. `stream({ startAt })` is a live `AsyncIterable` of
61
+ `GET` is the read path. `stream({ startAfter })` is a live `AsyncIterable` of
62
62
  one session's events starting after a given position, and `sseResponse`
63
63
  pipes it into a server-sent events response. Clients pass `index` to resume
64
64
  exactly where they left off, after a first paint or a dropped
@@ -114,16 +114,12 @@ export default async function OrderPage({
114
114
  const { orderId } = await params
115
115
  const session = ordersServer.session(orderId)
116
116
  const { state, index } = await session.state(ordersReducer)
117
- const initialEvents = (await session.history()).filter(
118
- (event) => event.index <= index,
119
- )
120
117
 
121
118
  return (
122
119
  <SessionProvider
123
120
  sessionId={orderId}
124
121
  initialState={state}
125
122
  initialIndex={index}
126
- initialEvents={initialEvents}
127
123
  >
128
124
  <OrderClient />
129
125
  </SessionProvider>
@@ -131,12 +127,10 @@ export default async function OrderPage({
131
127
  }
132
128
  ```
133
129
 
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.
130
+ Two props do the heavy lifting. `initialState` is the fold. `initialIndex` is
131
+ its frontier, the log position the fold reflects. The client opens its stream
132
+ after that position. The stream catches up any events appended after the server
133
+ fold, then stays live. No history read is needed to hydrate state.
140
134
  (No `reducer` or `api` prop: the provider got both from the shared client.)
141
135
 
142
136
  ## The client component
@@ -146,7 +140,7 @@ folded twice.
146
140
  import { useSession } from './session'
147
141
 
148
142
  export function OrderClient() {
149
- const { state, push, events, index } = useSession()
143
+ const { state, push } = useSession()
150
144
 
151
145
  return (
152
146
  <button onClick={() => push({ type: 'shop.started', payload: {} })}>
@@ -172,9 +166,14 @@ What the hook gives you:
172
166
  live stream delivers the batch back and the view shows server truth.
173
167
  `await push(...).confirmed` is "continue once this is real"; two
174
168
  `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.
169
+ push→ack→stream latency meter. Overlapping calls on the same
170
+ session still apply immediately, then enter the transport in call
171
+ order. Controls do not need to wait or disable themselves to preserve
172
+ that order.
173
+ - **`events`**: the raw events this client has observed or was explicitly
174
+ seeded with. With only the server snapshot, it begins after `initialIndex`.
175
+ Use it for UI that wants the log itself: an activity feed, a debug panel.
176
+ Earlier events are not needed to hydrate `state`.
178
177
  - **`index`**: the stream frontier, the last server-confirmed log
179
178
  position. This is the `lastSeenIndex` that makes
180
179
  [cancellation](/guides/cancellation) exact.
@@ -204,7 +203,7 @@ is the L2: it survives reloads; the memory runtime does not.
204
203
  One detail worth knowing: every push carries a client-generated event id.
205
204
  That id is how the ack finds its optimistic entry, and it makes retrying
206
205
  a failed `POST` idempotent for free (`push` auto-retries only
207
- `LOG_UNAVAILABLE`).
206
+ `STORE_UNAVAILABLE`).
208
207
 
209
208
  Optimistic pushes are also what make [cancellation](/guides/cancellation)
210
209
  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