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.
- package/CHANGELOG.md +169 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +321 -60
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- 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)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
|
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:
|
|
3
|
+
description: Schedule a typed session event for later with the same adapter that handles recovery.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
##
|
|
6
|
+
## Schedule from a handler
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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:
|
package/docs/guides/03-react.mdx
CHANGED
|
@@ -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
|
|
14
|
-
and folds them locally.
|
|
15
|
-
with positions is
|
|
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
|
|
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({
|
|
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({
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
`
|
|
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({
|
|
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
|