experimental-a2 0.3.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 (91) hide show
  1. package/CHANGELOG.md +156 -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-DZ1MiKLg.js → store-polling-DgrrAE3d.js} +5 -5
  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-core-CyJ5L8yR.js → store-redis-core-DWqx3F47.js} +269 -66
  56. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +7 -7
  57. package/dist/{log-redis-http.js → store-redis-http.js} +20 -12
  58. package/dist/{log-redis.d.ts → store-redis.d.ts} +6 -6
  59. package/dist/{log-redis.js → store-redis.js} +99 -39
  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 +3 -3
  69. package/docs/concepts/01-contracts.mdx +18 -13
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +21 -21
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -49
  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 +310 -61
  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 +18 -14
  84. package/docs/reference/01-api.mdx +751 -103
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -7
  87. package/dist/internal-gCd5qMry.js +0 -44
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/recovery-vercel.js +0 -119
  90. package/dist/server-BcLa4RFL.js +0 -877
  91. 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
@@ -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