experimental-a2 0.17.0 → 0.18.1
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 +32 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
- package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +692 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
- package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +950 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +47 -25
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +113 -40
- package/dist/ai-server.js.map +1 -1
- package/dist/ai-tool-input-DmSqMUQi.js +40 -0
- package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
- package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
- package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
- package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
- package/dist/internal-CscSY7K_.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
- package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/reducer-BAXc8JyF.js.map +1 -1
- package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
- package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +7 -7
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +9 -10
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +1 -1
- package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
- package/dist/store-CyLUy86v.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +56 -11
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +106 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
- package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +66 -21
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/01-quickstart.mdx +49 -83
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -86
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -150
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +109 -145
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +860 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +731 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +142 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +323 -0
- package/docs/server/06-guarantees.mdx +132 -0
- package/examples/playground/DEPLOY.md +3 -5
- package/examples/playground/app/a0/[appId]/page.tsx +12 -13
- package/examples/playground/app/a0/app-list.tsx +9 -2
- package/examples/playground/app/a0/app-view.tsx +9 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
- package/examples/playground/app/a0/apps.test.ts +12 -0
- package/examples/playground/app/a0/apps.ts +22 -7
- package/examples/playground/app/a0/chat.tsx +47 -1
- package/examples/playground/app/a0/devtools.tsx +2 -8
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/model.test.ts +18 -1
- package/examples/playground/app/a0/model.ts +47 -10
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/sandbox-log.ts +10 -4
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/scheduler/route.ts +2 -2
- package/examples/playground/app/a0/server.ts +98 -54
- package/examples/playground/app/a0/session.ts +17 -5
- package/examples/playground/app/a0/shell.tsx +11 -3
- package/examples/playground/app/agent/ingress.test.ts +6 -3
- package/examples/playground/app/agent/server.ts +9 -6
- package/examples/playground/app/agent/telemetry.test.ts +71 -50
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +30 -26
- package/examples/playground/app/chat/server.ts +46 -46
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +4 -1
- package/examples/playground/vercel.json +18 -0
- package/package.json +27 -3
- package/src/ai-agent.ts +271 -90
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +912 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1322 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/ai-tool-input.ts +45 -0
- package/src/internal.ts +13 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +14 -15
- package/src/server.ts +233 -29
- package/src/store-memory.ts +92 -16
- package/src/store-postgres.ts +156 -25
- package/src/store-redis-core.ts +322 -110
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +121 -28
- package/src/store.ts +55 -3
- package/src/telemetry.ts +1 -0
- package/dist/internal-CbVvEwad.js.map +0 -1
- package/dist/server-CGM8WzRG.d.ts.map +0 -1
- package/dist/server-Rj64fNvX.js.map +0 -1
- package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
- package/dist/store-Gpkq7WBo.d.ts.map +0 -1
- package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
- package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -237
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -193
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -324
- package/docs/guides/04-local-first.mdx +0 -47
- package/docs/guides/05-production.mdx +0 -534
- package/docs/guides/06-ai-agents.mdx +0 -1408
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -262
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -2358
- package/docs/reference/02-errors.mdx +0 -150
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Server
|
|
3
|
+
description: "`experimental-a2/server`: `createServer`, its route, the store and scheduler adapter interfaces, and every session method."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## `experimental-a2/server`
|
|
7
|
+
|
|
8
|
+
### `handler(entry)`
|
|
9
|
+
|
|
10
|
+
Normalizes a handler entry so you can compose an existing handler without
|
|
11
|
+
branching on its shape. For ordinary use, define handlers inline in
|
|
12
|
+
`createServer({ handlers })`.
|
|
13
|
+
|
|
14
|
+
- Accepts a function or `{ handler, lane?, abortOn? }` and returns the object
|
|
15
|
+
shape: a function becomes `{ handler: entry }`, an object is returned
|
|
16
|
+
unchanged, and both keep their original types.
|
|
17
|
+
- Does not bind a contract; `createServer({ handlers })` checks the match.
|
|
18
|
+
Annotate a new handler's context, as below, or pass an already typed
|
|
19
|
+
function.
|
|
20
|
+
- Spreading the result keeps `lane` and `abortOn`; set either after the spread
|
|
21
|
+
to override it. Changing a built-in AI handler's lane can separate it from
|
|
22
|
+
the handlers it coordinates with.
|
|
23
|
+
- A composition runs in one handler attempt. Work after the original resolves
|
|
24
|
+
still runs before its returned events commit, a throw fails the composed
|
|
25
|
+
attempt, and a retry can run both again.
|
|
26
|
+
|
|
27
|
+
```ts server/composed-orders.ts
|
|
28
|
+
import * as a2 from 'experimental-a2'
|
|
29
|
+
import { createServer, handler, type HandlerContext } from 'experimental-a2/server'
|
|
30
|
+
import { z } from 'zod'
|
|
31
|
+
|
|
32
|
+
const orders = a2.contract({
|
|
33
|
+
name: 'composed-orders',
|
|
34
|
+
events: {
|
|
35
|
+
created: z.object({ shopId: z.string() }),
|
|
36
|
+
notified: z.object({ shopId: z.string() }),
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const created = handler({
|
|
41
|
+
lane: 'orders',
|
|
42
|
+
handler: async (ctx: HandlerContext<typeof orders.events, 'created'>) => {
|
|
43
|
+
return orders.batch({ type: 'notified', payload: ctx.event.payload })
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export const ordersServer = createServer({
|
|
48
|
+
contract: orders,
|
|
49
|
+
handlers: {
|
|
50
|
+
created: {
|
|
51
|
+
...created,
|
|
52
|
+
handler: async (ctx) => {
|
|
53
|
+
const result = await created.handler(ctx)
|
|
54
|
+
// Your additional work goes here:
|
|
55
|
+
// await notifyShop(ctx.event.payload, { idempotencyKey: ctx.event.id })
|
|
56
|
+
return result
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `createServer(options)`
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
createServer(options: {
|
|
67
|
+
contract: Contract // the vocabulary this server implements
|
|
68
|
+
store?: A2Store // default: sqlite in dev, memory in tests, required in prod
|
|
69
|
+
scheduler?: A2Scheduler
|
|
70
|
+
telemetry?: A2Telemetry // optional instrumentation; see experimental-a2/otel
|
|
71
|
+
presence?: { ttlMs?: number } // presence expiry policy; default 60s
|
|
72
|
+
handlers?: {
|
|
73
|
+
[type]:
|
|
74
|
+
| Handler
|
|
75
|
+
| {
|
|
76
|
+
abortOn?: AbortSpec
|
|
77
|
+
lane?: string | ((ctx: LaneContext) => string)
|
|
78
|
+
handler: Handler
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}): A2Server
|
|
82
|
+
|
|
83
|
+
type Handler = (
|
|
84
|
+
ctx: Context,
|
|
85
|
+
) => Promise<void | AppendInput | readonly AppendInput[]>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Implements a contract: binds its vocabulary to storage and reactions.
|
|
89
|
+
`experimental-a2/server` is the only entry point that can reach a store
|
|
90
|
+
backend; under the browser condition its exports resolve to a loud error. See
|
|
91
|
+
[Handlers](/server/handlers).
|
|
92
|
+
|
|
93
|
+
| Option | Behavior |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `handlers` | One table, complete at construction. Compose files by spreading objects in; a duplicate key silently last-wins. |
|
|
96
|
+
| `lane` | Within one session, same-lane events run one at a time in log order; all others run concurrently. A resolver receives `{ sessionId, event }`. The value is resolved and stored at append, so a later deployment can't reinterpret pending work. See [Lanes](/server/handlers#lanes). |
|
|
97
|
+
| `abortOn` | Events that fire `ctx.signal` while the handler runs: an array matches by type; an object maps types to predicates `(event, trigger, { attempt })`, whose `attempt` is the trigger's durable attempt, so a cancellation can fence one recovered run. See [Cancellation](/server/cancellation). |
|
|
98
|
+
| `presence.ttlMs` | How long a presence value survives without a refreshing set: a positive integer of milliseconds, default 60 seconds, counted on the storage's clock, never the sender's stamp. Only for contracts that declare `presence`; elsewhere construction throws a `TypeError`. |
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
generate: {
|
|
102
|
+
abortOn: {
|
|
103
|
+
cancelled: (event, trigger, { attempt }) =>
|
|
104
|
+
event.payload.of === `${trigger.id}:${attempt}`,
|
|
105
|
+
},
|
|
106
|
+
handler: async (ctx) => { /* ... */ },
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- Handlers are concurrent by default. A2 starts every eligible event once its
|
|
111
|
+
append commits.
|
|
112
|
+
- A handler returns nothing, one event, or an array. A2 marks the trigger
|
|
113
|
+
processed and appends the returned batch in one atomic store operation; when
|
|
114
|
+
the handler throws, the returned events never exist.
|
|
115
|
+
- Handlers are optional per event type. An event type without one settles in
|
|
116
|
+
its append transaction with no dispatch attempt, and starts no drain or
|
|
117
|
+
scheduler arm when the session has no older pending handler work. See
|
|
118
|
+
[Events without handlers](/server/handlers#events-without-handlers).
|
|
119
|
+
|
|
120
|
+
The context every handler receives:
|
|
121
|
+
|
|
122
|
+
| Property | Meaning |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `ctx.event` | `Event`: the triggering event |
|
|
125
|
+
| `ctx.attempt` | durable 1-based dispatch claim ordinal; increments on every durable claim and may skip when a process dies before handler entry |
|
|
126
|
+
| `ctx.session` | this session: `id` (equal to `ctx.event.sessionId`), `append`, `schedule`, `clear`, `history`, `state`, `stream`, `setPresence` |
|
|
127
|
+
| `ctx.signal` | `AbortSignal`: fires on lease lapse (`CLAIM_EXPIRED`), proven supersession (`SUPERSEDED_ATTEMPT`), and matching `abortOn` events |
|
|
128
|
+
|
|
129
|
+
`ctx.session` shares the `server.session(id)` surface; handler-specific
|
|
130
|
+
behavior is noted under each method. Its `state(reducer)` folds through the
|
|
131
|
+
trigger's index by default (see
|
|
132
|
+
[`session.state`](#sessionstatereducer-options)), and its `append` takes a
|
|
133
|
+
name:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
ctx.session.append(name: string, ...events: AppendInput[]): Promise<Event[]>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- It commits immediately, so its events may run while the handler is still
|
|
140
|
+
active unless a lane orders them. See
|
|
141
|
+
[Append what should happen now](/server/handlers#append-what-should-happen-now).
|
|
142
|
+
- The name is the idempotency key, scoped to the trigger, for events whose
|
|
143
|
+
`id` is omitted: each such id derives from the trigger id, the name, and the
|
|
144
|
+
event's position. The same name and generated-id batch returns the existing
|
|
145
|
+
rows on retry; a changed batch conflicts with them.
|
|
146
|
+
- An explicit `id` wins, so different triggering events can converge on one
|
|
147
|
+
fact. Root `server.session(id).append(...events)` takes no name.
|
|
148
|
+
|
|
149
|
+
### `server.fetch(request, options?)`
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
server.fetch(
|
|
153
|
+
request: Request,
|
|
154
|
+
options?: {
|
|
155
|
+
authorize?: (operation: A2Operation) => boolean | Promise<boolean>
|
|
156
|
+
upgradeWebSocket?: (
|
|
157
|
+
attach: (socket: A2Socket) => void,
|
|
158
|
+
) => Response | Promise<Response>
|
|
159
|
+
multiplexWebSocket?: boolean
|
|
160
|
+
},
|
|
161
|
+
): Promise<Response>
|
|
162
|
+
|
|
163
|
+
type A2Operation =
|
|
164
|
+
| {
|
|
165
|
+
type: 'stream'
|
|
166
|
+
sessionId: string
|
|
167
|
+
startAfter: number
|
|
168
|
+
transport: 'http' | 'websocket'
|
|
169
|
+
}
|
|
170
|
+
| {
|
|
171
|
+
type: 'history'
|
|
172
|
+
sessionId: string
|
|
173
|
+
gte: number
|
|
174
|
+
lte: number
|
|
175
|
+
transport: 'http'
|
|
176
|
+
}
|
|
177
|
+
| {
|
|
178
|
+
type: 'push'
|
|
179
|
+
sessionId: string
|
|
180
|
+
events: readonly {
|
|
181
|
+
type: ContractEventType | string
|
|
182
|
+
payload: unknown
|
|
183
|
+
id?: string
|
|
184
|
+
}[]
|
|
185
|
+
presence?: {
|
|
186
|
+
participant: string
|
|
187
|
+
values: Readonly<Record<string, unknown>>
|
|
188
|
+
seen?: number
|
|
189
|
+
at?: number
|
|
190
|
+
}
|
|
191
|
+
transport: 'http' | 'websocket'
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The bound Fetch API route for this server: an unprotected route exports it
|
|
196
|
+
directly as `GET` and `POST`. See [Routes](/server/routes).
|
|
197
|
+
|
|
198
|
+
| Request | Response |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| `GET` | streams one session over SSE, resumed after the `index` query parameter |
|
|
201
|
+
| `GET` with `gte` and `lte` | the closed history range as JSON; `a2-history-covered` is `true` when every requested index was returned. If the range includes the latest clear marker, `a2-clear-index` is set and coverage counts only the requested indexes after it; a range entirely below the marker returns `[]` |
|
|
202
|
+
| `GET` with `Upgrade: websocket` | calls `upgradeWebSocket`, or returns 426 without it |
|
|
203
|
+
| `POST` | accepts `{ sessionId, events, presence? }` and returns the appended events |
|
|
204
|
+
|
|
205
|
+
A contract that declares presence streams it and accepts presence pushes
|
|
206
|
+
automatically.
|
|
207
|
+
|
|
208
|
+
| Option | Behavior |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `authorize` | Runs after the request or socket frame is parsed, before schema validation, presence validation, or I/O. Event and presence field names are typed from the contract; values stay `unknown`. Operation objects, push events, event arrays, and presence patches are frozen: authorize them, don't rewrite them. `false` denies with `FORBIDDEN`; a throw becomes `STORE_UNAVAILABLE`. |
|
|
211
|
+
| `upgradeWebSocket` | receives an `attach` callback for the platform socket and returns the upgrade response |
|
|
212
|
+
| `multiplexWebSocket` | `true` shares one socket across sessions, with `multiplex: true` in the client's `ws` API |
|
|
213
|
+
|
|
214
|
+
- Authentication stays in the route, where headers, cookies, and framework
|
|
215
|
+
context are available. Capture the authenticated principal in `authorize`.
|
|
216
|
+
- `authorize` is an ingress policy for this `fetch` call, not a global server
|
|
217
|
+
policy: direct `session.append`, handler appends, scheduled appends, and
|
|
218
|
+
server-side `setPresence` bypass it.
|
|
219
|
+
- By default the `sessionId` and `index` query parameters bind one session to
|
|
220
|
+
the socket. A2 authorizes the stream before upgrading, and frames can't
|
|
221
|
+
select another session.
|
|
222
|
+
- A multiplexed socket runs `authorize` for each session subscribe and each
|
|
223
|
+
event or presence push. Authenticate the physical WebSocket GET before
|
|
224
|
+
calling `server.fetch`, and validate `Origin` when browser cookies
|
|
225
|
+
authenticate it (handshakes skip the CORS preflight). Enable multiplexing
|
|
226
|
+
only when that request context may access every session `authorize` accepts.
|
|
227
|
+
- A denied subscribe receives `unsubscribed` and leaves other sessions alone. A
|
|
228
|
+
denied event push receives a `FORBIDDEN` ack. Denied presence is silently
|
|
229
|
+
dropped, because it is fire-and-forget.
|
|
230
|
+
- SSE and WebSocket lifetimes use the platform's ambient invocation deadline,
|
|
231
|
+
and background work uses its ambient `waitUntil`. Neither is an option.
|
|
232
|
+
|
|
233
|
+
### `server.session(id)`
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
server.session(id: string): Session
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
A handle on one instance of the contract: the unit of log ordering, lane keys,
|
|
240
|
+
recovery, state, and live sync. Creating it does no I/O; nothing loads until
|
|
241
|
+
you append, read, or stream. `session.id` is the `id` passed in. See
|
|
242
|
+
[Sessions](/contract/sessions).
|
|
243
|
+
|
|
244
|
+
It has every [session method](#session), plus
|
|
245
|
+
[`deadLetters()`](#sessiondeadletters) and
|
|
246
|
+
[`redrive()`](#sessionredriveindex-options), which `ctx.session` doesn't.
|
|
247
|
+
|
|
248
|
+
### `server.drain(sessionId)`
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
server.drain(sessionId: string): Promise<{ settled: boolean }>
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Claims every eligible event and runs their handlers concurrently.
|
|
255
|
+
`settled` means no actionable or live-claimed work remains. Past the
|
|
256
|
+
invocation's deadline it claims nothing and returns `settled: false`. A dead-lettered
|
|
257
|
+
event can block later work in its lane while other lanes continue, until
|
|
258
|
+
[`session.redrive()`](#sessionredriveindex-options) resolves it. Scheduler
|
|
259
|
+
callbacks use it; you'll rarely call it yourself. The public result stays this
|
|
260
|
+
simple boolean.
|
|
261
|
+
|
|
262
|
+
### `A2Store`
|
|
263
|
+
|
|
264
|
+
Custom store adapters implement these atomic drain methods:
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// Custom server store adapter:
|
|
268
|
+
type EventCause = {
|
|
269
|
+
index: number
|
|
270
|
+
attempt: number
|
|
271
|
+
batchSize?: number // present on named handler appends
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
type AppendEvent = {
|
|
275
|
+
type: string
|
|
276
|
+
payload: unknown
|
|
277
|
+
id?: string
|
|
278
|
+
cause?: EventCause
|
|
279
|
+
lane?: string
|
|
280
|
+
settled?: true // internal: no handler is registered for this event type
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type ReturnedEvent = AppendEvent & { id: string }
|
|
284
|
+
|
|
285
|
+
type StoredEvent = Event & {
|
|
286
|
+
cause: EventCause | null
|
|
287
|
+
lane: string | null
|
|
288
|
+
processedAt: Date | null
|
|
289
|
+
processedByAttempt: number | null
|
|
290
|
+
returnedEventIds: string[] | null
|
|
291
|
+
firstClaimedAt: Date | null
|
|
292
|
+
lastClaimedAt: Date | null
|
|
293
|
+
attemptCount: number
|
|
294
|
+
claimHolder: string | null
|
|
295
|
+
claimExpiresAt: Date | null
|
|
296
|
+
failureCount: number
|
|
297
|
+
lastFailedAt: Date | null
|
|
298
|
+
lastFailedAttempt: number | null
|
|
299
|
+
lastError: string | null
|
|
300
|
+
failedAt: Date | null
|
|
301
|
+
clearIndex?: number // on claimed events, once the session has been cleared
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
type StoreAppendResult = {
|
|
305
|
+
events: StoredEvent[]
|
|
306
|
+
hasPending: boolean
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
type StoreClaimAvailableResult =
|
|
310
|
+
| { outcome: 'claimed'; events: StoredEvent[] }
|
|
311
|
+
| { outcome: 'busy'; retryAt: Date }
|
|
312
|
+
| { outcome: 'settled' }
|
|
313
|
+
|
|
314
|
+
type CompleteAttemptResult =
|
|
315
|
+
| { outcome: 'completed'; events: StoredEvent[] }
|
|
316
|
+
| { outcome: 'superseded' }
|
|
317
|
+
|
|
318
|
+
interface A2Store {
|
|
319
|
+
append(
|
|
320
|
+
sessionId: string,
|
|
321
|
+
events: AppendEvent[],
|
|
322
|
+
options?: { originIndex?: number },
|
|
323
|
+
): Promise<StoreAppendResult>
|
|
324
|
+
|
|
325
|
+
// optional together; required for session.clear()
|
|
326
|
+
clear?(options: {
|
|
327
|
+
sessionId: string
|
|
328
|
+
ifLastIndex?: number
|
|
329
|
+
cause?: EventCause
|
|
330
|
+
}): Promise<{ cleared: boolean; index: number; clearIndex: number }>
|
|
331
|
+
reclaim?(sessionId: string): Promise<{ deleted: number; done: boolean }>
|
|
332
|
+
|
|
333
|
+
// optional together; required for session.deadLetters() and redrive()
|
|
334
|
+
deadLetters?(sessionId: string): Promise<StoredEvent[]>
|
|
335
|
+
redrive?(options: {
|
|
336
|
+
sessionId: string
|
|
337
|
+
index: number
|
|
338
|
+
action: 'retry' | 'skip'
|
|
339
|
+
}): Promise<{ redriven: boolean; hasPending: boolean }>
|
|
340
|
+
|
|
341
|
+
claimAvailable(options: {
|
|
342
|
+
sessionId: string
|
|
343
|
+
holder: string
|
|
344
|
+
ttlMs: number
|
|
345
|
+
expiresAtMs?: number
|
|
346
|
+
excludeIndexes?: readonly number[]
|
|
347
|
+
maxFailures?: number
|
|
348
|
+
}): Promise<StoreClaimAvailableResult>
|
|
349
|
+
|
|
350
|
+
renewClaims(options: {
|
|
351
|
+
sessionId: string
|
|
352
|
+
holder: string
|
|
353
|
+
claims: readonly { index: number; attempt: number }[]
|
|
354
|
+
ttlMs: number
|
|
355
|
+
expiresAtMs?: number
|
|
356
|
+
}): Promise<{ renewed: number[]; superseded: number[] }>
|
|
357
|
+
|
|
358
|
+
completeAttempt(options: {
|
|
359
|
+
sessionId: string
|
|
360
|
+
index: number
|
|
361
|
+
attempt: number
|
|
362
|
+
events: ReturnedEvent[]
|
|
363
|
+
}): Promise<CompleteAttemptResult>
|
|
364
|
+
|
|
365
|
+
failAttempt(options: {
|
|
366
|
+
sessionId: string
|
|
367
|
+
index: number
|
|
368
|
+
attempt: number
|
|
369
|
+
error: string
|
|
370
|
+
maxFailures: number
|
|
371
|
+
retryAfterMs?: number
|
|
372
|
+
}): Promise<{
|
|
373
|
+
outcome: 'failed' | 'dead_lettered' | 'superseded'
|
|
374
|
+
failureCount: number
|
|
375
|
+
}>
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
| Method | Atomic effect |
|
|
380
|
+
| --- | --- |
|
|
381
|
+
| `append` | Write a consecutive batch and report session-wide pending handler work as `hasPending`. Events with the internal `settled` flag get `processedAt` in the same transaction, with no dispatch attempt. An event with a `cause` commits only while `cause.attempt` is the parent's latest attempt and the parent isn't dead-lettered; otherwise throw `SUPERSEDED_ATTEMPT`. The check serializes against a concurrent claim, after the idempotent-replay path. |
|
|
382
|
+
| `claimAvailable` | Claim every eligible event. All unlaned events are independent; only the lowest-index unfinished event in each lane is eligible. A claim records its holder, expiry, timestamps, and next `attemptCount`. Taking over a lapsed claim, one its holder never completed or failed, adds one to `failureCount`; when that reaches `maxFailures`, dead-letter the event instead of claiming it: advance `attemptCount` to fence the lapsed attempt, leave `lastFailedAttempt` unchanged, and return it with `failedAt` set. A failure's retry hold has no holder, so it is no lapsed claim. |
|
|
383
|
+
| `renewClaims` | Extend the listed live claims the holder still owns. `renewed` lists those; `superseded` lists claims whose event's `attemptCount` passed the listed attempt. An expired claim no successor took is in neither and may still complete; renewal never revives it. |
|
|
384
|
+
| `completeAttempt` | In one transaction: fence on the current attempt, mark the parent processed, store its exact ordered `returnedEventIds`, and append the returned batch. A same-attempt retry returns the committed children; a stale attempt returns `superseded`. |
|
|
385
|
+
| `failAttempt` | Record one current caught failure, clear its claim, and dead-letter at `maxFailures`. Short of that, with `retryAfterMs` the event stays unclaimable for that long: set its claim expiry with no holder, so `claimAvailable` reports it `busy` until then. A repeated acknowledgment is idempotent; a stale attempt returns `superseded`. |
|
|
386
|
+
| `clear` | Serialized with `append`. Check `ifLastIndex` against the head and the latest marker, then append a settled clear marker at the next index, touching no existing rows. A `cause` is fenced like a handler append. |
|
|
387
|
+
| `reclaim` | Delete one bounded batch of rows below the latest clear marker, with their bookkeeping, event-id records, lane pointers, and cached state. Report how many rows went, and `done` when none are left. Never wait on a lock other methods hold. |
|
|
388
|
+
| `deadLetters` | Return the unprocessed events with `failedAt` set above the latest clear marker, oldest first. |
|
|
389
|
+
| `redrive` | Only for an unprocessed event with `failedAt` set above the latest clear marker. `retry` clears `failedAt` and `failureCount`, leaving claims and the last failure. `skip` sets `processedAt` with no `processedByAttempt` or `returnedEventIds` and exposes the next event in its lane. Anything else reports `redriven: false` and writes nothing. Both serialize with claims and handler appends, `skip` also with lane promotion; report `hasPending` like `append`. |
|
|
390
|
+
|
|
391
|
+
`readHistory(sessionId, options)` is an optional capability with the same
|
|
392
|
+
exclusive `afterIndex` and inclusive `throughIndex` bounds as `read()`. Without
|
|
393
|
+
it, `session.history()` falls back to `read()`.
|
|
394
|
+
|
|
395
|
+
A store that supports clearing treats every event at or below the latest
|
|
396
|
+
marker as superseded, whether or not its row still exists. Each check belongs
|
|
397
|
+
inside the operation's existing atomic step:
|
|
398
|
+
|
|
399
|
+
- `claimAvailable` never claims it; lane readiness at append and completion
|
|
400
|
+
ignores it, so a dead-lettered lane head stops blocking.
|
|
401
|
+
- `renewClaims` reports it in `superseded`; `completeAttempt` and
|
|
402
|
+
`failAttempt` return `superseded`.
|
|
403
|
+
- An `append` whose `cause` names it throws `SUPERSEDED_ATTEMPT`; one whose
|
|
404
|
+
`originIndex` is at or below the marker throws `SESSION_CLEARED`.
|
|
405
|
+
- Reads start at the marker; `readState` returns its index as `clearIndex`,
|
|
406
|
+
and claimed events carry it as `clearIndex`.
|
|
407
|
+
- In `append` and `completeAttempt`, an event id whose row is below this
|
|
408
|
+
session's marker is free to reuse.
|
|
409
|
+
|
|
410
|
+
Without `clear` and `reclaim`, `session.clear()` and `session.schedule.clear()`
|
|
411
|
+
throw a `TypeError` before writing anything. Without `deadLetters` and
|
|
412
|
+
`redrive`, so do `session.deadLetters()` and `session.redrive()`. All built-in
|
|
413
|
+
stores support both.
|
|
414
|
+
|
|
415
|
+
Stored fields:
|
|
416
|
+
|
|
417
|
+
- `attemptCount` counts claims, including abandoned ones. `failureCount`
|
|
418
|
+
counts failed attempts: caught failures, and lapsed claims a later claim
|
|
419
|
+
took over. Ten dead-letter the event. See
|
|
420
|
+
[Guarantees](/server/guarantees#one-event-many-attempts).
|
|
421
|
+
- `cause` names the event and handler attempt whose `ctx.session.append` or
|
|
422
|
+
return value first persisted the child; `null` means a top-level event.
|
|
423
|
+
- `lane` is the session-scoped serialized group, resolved before append.
|
|
424
|
+
- `returnedEventIds` makes a lost completion acknowledgment recoverable without
|
|
425
|
+
accepting a partial child batch.
|
|
426
|
+
- Lifecycle timestamps are adapter clock values for their atomic operations,
|
|
427
|
+
not exact database commit times. Built-in adapters persist all these fields
|
|
428
|
+
inside their existing atomic operations, with no extra round trip.
|
|
429
|
+
|
|
430
|
+
### `A2Scheduler`
|
|
431
|
+
|
|
432
|
+
Custom scheduler adapters implement two methods:
|
|
433
|
+
|
|
434
|
+
| Method | Effect |
|
|
435
|
+
| --- | --- |
|
|
436
|
+
| `schedule(task: SchedulerTask): Promise<void>` | put one versioned task on durable delivery infrastructure |
|
|
437
|
+
| `handler(...servers)` | return the route that receives drain, append, and clear tasks; requires at least one drainable server and rejects duplicate contract names synchronously |
|
|
438
|
+
|
|
439
|
+
Application routes call `scheduler.handler(...servers)` on the configured
|
|
440
|
+
adapter. See
|
|
441
|
+
[Production](/server/production#2-add-a-scheduler-for-handlers-and-timers).
|
|
442
|
+
|
|
443
|
+
`SchedulerTask` is `SchedulerDrainTask | SchedulerAppendTask |
|
|
444
|
+
SchedulerClearTask`. These task types, plus `ScheduledEvent`, are exported
|
|
445
|
+
from `experimental-a2/server`.
|
|
446
|
+
|
|
447
|
+
| Task | Fields |
|
|
448
|
+
| --- | --- |
|
|
449
|
+
| `SchedulerDrainTask` | `version: 1`, `kind: 'drain'`, `contract`, `sessionId`, required epoch-ms `dueAt` |
|
|
450
|
+
| `SchedulerAppendTask` | `version: 1`, `kind: 'append'`, stable `id`, `contract`, `sessionId`, required epoch-ms `dueAt`, readonly `events`, optional `originIndex` |
|
|
451
|
+
| `SchedulerClearTask` | `version: 1`, `kind: 'clear'`, stable `id`, `contract`, `sessionId`, required epoch-ms `dueAt`, `ifLastIndex` |
|
|
452
|
+
| `ScheduledEvent` | fixed `id`, `type`, and JSON `payload` |
|
|
453
|
+
|
|
454
|
+
Drain tasks wake pending work and carry no application payload. Append tasks
|
|
455
|
+
carry the events for `session.schedule`; `originIndex` is the scheduling
|
|
456
|
+
handler's event, absent for root schedules. Clear tasks carry the condition for
|
|
457
|
+
`session.schedule.clear`.
|
|
458
|
+
|
|
459
|
+
Deliver an authenticated, normalized append or clear task through the server
|
|
460
|
+
helpers:
|
|
461
|
+
|
|
462
|
+
```ts custom-scheduler.ts
|
|
463
|
+
import {
|
|
464
|
+
deliverSchedulerAppend,
|
|
465
|
+
deliverSchedulerClear,
|
|
466
|
+
type DrainableServer,
|
|
467
|
+
type SchedulerAppendTask,
|
|
468
|
+
type SchedulerClearTask,
|
|
469
|
+
} from 'experimental-a2/server'
|
|
470
|
+
|
|
471
|
+
export async function deliver(
|
|
472
|
+
server: DrainableServer,
|
|
473
|
+
task: SchedulerAppendTask | SchedulerClearTask,
|
|
474
|
+
) {
|
|
475
|
+
if (task.kind === 'clear') await deliverSchedulerClear(server, task)
|
|
476
|
+
else await deliverSchedulerAppend(server, task)
|
|
477
|
+
}
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Authenticate the transport and validate the task envelope first. Each helper
|
|
481
|
+
verifies that the task and server contract match.
|
|
482
|
+
|
|
483
|
+
- `deliverSchedulerAppend` revalidates the carried event inputs and uses the
|
|
484
|
+
ordinary top-level append. The target server must have a scheduler. When
|
|
485
|
+
the append leaves pending handler work, it resolves only after a recovery
|
|
486
|
+
watchdog is durable, so a send failure keeps the delivery retryable. A task
|
|
487
|
+
whose `originIndex` was cleared resolves without appending.
|
|
488
|
+
- `deliverSchedulerClear` runs `clear({ ifLastIndex })` and resolves after its
|
|
489
|
+
deletion finishes. A store failure rejects, so the provider redelivers and
|
|
490
|
+
the retry finishes deleting.
|
|
491
|
+
|
|
492
|
+
## Session
|
|
493
|
+
|
|
494
|
+
### `session.append(...events)`
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
session.append(
|
|
498
|
+
...events: Array<{ type: string; payload: unknown; id?: string }>
|
|
499
|
+
): Promise<Event[]>
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The only way to move a session forward. See [Guarantees](/server/guarantees).
|
|
503
|
+
|
|
504
|
+
- Validates payloads against the contract's schemas before writing anything.
|
|
505
|
+
- A multi-event append is atomic: all-or-nothing, consecutive positions, one
|
|
506
|
+
transaction.
|
|
507
|
+
- An explicit `id` makes it idempotent across retries; re-sending an identical
|
|
508
|
+
batch returns the original rows.
|
|
509
|
+
- Events pushed through `server.fetch` arrive through this same append.
|
|
510
|
+
- Only event types with registered handlers are dispatched; the append itself
|
|
511
|
+
settles the rest. An unhandled append still starts session healing when
|
|
512
|
+
older handled work is pending. The store reports that pending state in the
|
|
513
|
+
atomic append, so the decision needs no follow-up read.
|
|
514
|
+
|
|
515
|
+
### `session.append.dispatch(...events)`
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
session.append.dispatch(
|
|
519
|
+
...events: Array<{ type: string; payload: unknown; id?: string }>
|
|
520
|
+
): Promise<Event[]>
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Commits the same atomic batch, then sends pending work to the configured
|
|
524
|
+
scheduler instead of starting an inline drain. See
|
|
525
|
+
[Dispatch in a fresh invocation](/server/production#dispatch-in-a-fresh-invocation).
|
|
526
|
+
|
|
527
|
+
- Awaits acceptance of an immediate scheduler message. A batch that leaves no
|
|
528
|
+
pending handler work sends none.
|
|
529
|
+
- Throws before writing when the server has no scheduler.
|
|
530
|
+
- If the send fails, the events are already durable: give them explicit IDs
|
|
531
|
+
and retry the same dispatch.
|
|
532
|
+
- It picks how the session wakes, not which worker runs: an already active
|
|
533
|
+
drain may claim the new work first.
|
|
534
|
+
- Exists only on a server session's top-level append. `ctx.session.append`
|
|
535
|
+
runs inside an active drain, where its immediate and returned children
|
|
536
|
+
become eligible, so it has no `dispatch`.
|
|
537
|
+
|
|
538
|
+
### `session.schedule(name, timing, ...events)`
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
session.schedule(
|
|
542
|
+
name: string,
|
|
543
|
+
timing: ScheduleTiming, // { delay: ScheduleDelay } | { at: Date }
|
|
544
|
+
...events: Array<{ type: string; payload: unknown; id?: string }>
|
|
545
|
+
): Promise<void>
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Appends the events later through the configured scheduler, from root and
|
|
549
|
+
handler sessions. Resolves after provider acceptance, not after append. `name`
|
|
550
|
+
must be non-empty, with at least one event. `ScheduleDelay`, `ScheduleTiming`,
|
|
551
|
+
and `SessionSchedule` are exported from `experimental-a2/server`. See
|
|
552
|
+
[Timers](/server/timers) for stale-timer guards, provider limits, and
|
|
553
|
+
deployment compatibility.
|
|
554
|
+
|
|
555
|
+
| Timing | Meaning |
|
|
556
|
+
| --- | --- |
|
|
557
|
+
| `{ delay: '30s' }` | relative delay with unit `ms`, `s`, `m`, `h`, or `d`; from the call's wall clock on a root session, from the trigger's durable `createdAt` in a handler, so handler retries keep the delivery time |
|
|
558
|
+
| `{ at: Date }` | absolute delivery time; use it when a retried root call needs one target |
|
|
559
|
+
|
|
560
|
+
- The template type checks the unit. At runtime the number must be an
|
|
561
|
+
unsigned base-10 decimal without leading zeros, finite, and above zero; `0s`,
|
|
562
|
+
signs, exponents, malformed durations, invalid dates, and both fields at
|
|
563
|
+
once are rejected.
|
|
564
|
+
- The target is the earliest useful delivery time. Adapters encode it in
|
|
565
|
+
whole-second slots; an already-due target is sent immediately.
|
|
566
|
+
- The task id derives from the contract, session, and name. Handler tasks add
|
|
567
|
+
the trigger id and, once the session has been cleared, the clear, so a
|
|
568
|
+
trigger id reused in a new lifetime schedules a new task. Omitted event ids
|
|
569
|
+
derive from the task id and position; explicit ids win.
|
|
570
|
+
- Reuse a name only for identical timing and events. Provider deduplication is
|
|
571
|
+
an optimization; stable event ids make later duplicate deliveries converge in
|
|
572
|
+
the store.
|
|
573
|
+
- Payloads are snapshotted into plain JSON, then validated before sending.
|
|
574
|
+
Encode dates as ISO strings and classes as plain data. `undefined`, bigint,
|
|
575
|
+
functions, symbols, cycles, accessors, and non-plain prototypes are rejected;
|
|
576
|
+
shared references become independent values.
|
|
577
|
+
- Delivery validates the snapshotted schema input through an ordinary
|
|
578
|
+
top-level append, outside any handler's completion transaction, so a schema
|
|
579
|
+
transform produces the stored payload from the original input. The events
|
|
580
|
+
have `cause: null` and run through handlers, lanes, state, and streams
|
|
581
|
+
normally.
|
|
582
|
+
- A handler task belongs to its trigger's lifetime: if the session is cleared
|
|
583
|
+
before delivery, it is acknowledged without appending. A root task joins
|
|
584
|
+
whichever lifetime exists at delivery, and its name identifies one task
|
|
585
|
+
across lifetimes, so put something from the new lifetime in the name when
|
|
586
|
+
scheduling again after a clear.
|
|
587
|
+
- There is no cancellation, reschedule, cron, or timer-listing API.
|
|
588
|
+
|
|
589
|
+
### `session.clear(options?)`
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
session.clear(options?: { ifLastIndex?: number }): Promise<{
|
|
593
|
+
cleared: boolean
|
|
594
|
+
index: number
|
|
595
|
+
}>
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Ends the session's current lifetime: one atomic step checks the condition and
|
|
599
|
+
appends a reserved clear marker at the next index. The session id stays usable
|
|
600
|
+
and indexes never restart. See
|
|
601
|
+
[Clearing a session](/contract/sessions#clearing-a-session).
|
|
602
|
+
|
|
603
|
+
`ifLastIndex` is a non-negative safe integer. The first matching rule decides,
|
|
604
|
+
and `index` is always the head after the call:
|
|
605
|
+
|
|
606
|
+
| When | Result |
|
|
607
|
+
| --- | --- |
|
|
608
|
+
| the latest marker is at `ifLastIndex + 1` | `{ cleared: true, index }`, a retry of that clear |
|
|
609
|
+
| `ifLastIndex` is given and is not the head | `{ cleared: false, index }`, nothing changes |
|
|
610
|
+
| the head is `0` or already a clear marker | `{ cleared: true, index }`, nothing changes |
|
|
611
|
+
| otherwise | `{ cleared: true, index }`, the new marker |
|
|
612
|
+
|
|
613
|
+
- Every event at or below the marker is superseded: never dispatched again,
|
|
614
|
+
never blocking its lane. Its running handler's appends throw
|
|
615
|
+
`SUPERSEDED_ATTEMPT` and its state reads throw `SESSION_CLEARED` rather than
|
|
616
|
+
fold the new lifetime. Exception: after a clear from another instance, a
|
|
617
|
+
read bounded at the handler's own event may return that event's state from
|
|
618
|
+
memory until its `ctx.signal` aborts.
|
|
619
|
+
- Pushes and appends after the marker start the new lifetime.
|
|
620
|
+
- `clear()` then deletes the old events, their bookkeeping and event-id
|
|
621
|
+
records, and cached state below the marker in short batches, resolving when
|
|
622
|
+
they're gone. Other work on the session runs between batches. There is no
|
|
623
|
+
background sweep.
|
|
624
|
+
- If deletion fails, the call rejects with the session already cleared; any
|
|
625
|
+
later clear of the session, including a retry, finishes the deletion.
|
|
626
|
+
- Old event ids can be used again. Presence is not cleared.
|
|
627
|
+
- On `ctx.session`, the clear is fenced like the handler's appends and ends the
|
|
628
|
+
handler's own attempt: `ctx.signal` aborts with `SUPERSEDED_ATTEMPT` and
|
|
629
|
+
returned events are discarded. Its deletion keeps running under the
|
|
630
|
+
platform's `waitUntil`.
|
|
631
|
+
- All built-in stores support clearing.
|
|
632
|
+
|
|
633
|
+
### `session.schedule.clear(name, options)`
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
session.schedule.clear(
|
|
637
|
+
name: string,
|
|
638
|
+
options: ({ delay: ScheduleDelay } | { at: Date }) & {
|
|
639
|
+
ifLastIndex: number
|
|
640
|
+
},
|
|
641
|
+
): Promise<void>
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Publishes a conditional clear to the configured scheduler and resolves after
|
|
645
|
+
provider acceptance. `name` is non-empty and `ifLastIndex` is required; timing
|
|
646
|
+
works as in `session.schedule`, with the same provider limits. See
|
|
647
|
+
[Scheduling a clear](/contract/sessions#scheduling-a-clear).
|
|
648
|
+
|
|
649
|
+
- The task id derives from the contract, session, name, and condition, plus
|
|
650
|
+
the trigger id and any earlier clear inside a handler. Reuse a name only for
|
|
651
|
+
the same timing.
|
|
652
|
+
- Delivery runs `clear({ ifLastIndex })` and acknowledges after the deletion
|
|
653
|
+
finishes. No event is appended; a stale or duplicate delivery changes
|
|
654
|
+
nothing. Indexes never repeat, so a condition from an older lifetime never
|
|
655
|
+
matches a newer one.
|
|
656
|
+
- `ifLastIndex: ctx.event.index` matches only while the trigger is still the
|
|
657
|
+
head. The handler's own appends and returned events move the head, so a
|
|
658
|
+
handler that writes should use the index of its last `ctx.session.append`.
|
|
659
|
+
Actor handlers always commit state, so they can't use the trigger's index.
|
|
660
|
+
- A delay short enough to deliver while the handler still runs can match
|
|
661
|
+
before its writes land; the clear then ends the handler's attempt and
|
|
662
|
+
discards its returned events.
|
|
663
|
+
- Each delivery attempt resumes the deletion. Vercel Queues redelivers until
|
|
664
|
+
retention ends. QStash retries up to its `retries` budget; a deletion that
|
|
665
|
+
outlasts them leaves the message in QStash's dead-letter queue, the rest of
|
|
666
|
+
the old history still invisible, and any later clear of the session finishes
|
|
667
|
+
it.
|
|
668
|
+
|
|
669
|
+
### `session.deadLetters()`
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
session.deadLetters(): Promise<DeadLetter[]>
|
|
673
|
+
|
|
674
|
+
type DeadLetter = Event & {
|
|
675
|
+
lane: string | null
|
|
676
|
+
attemptCount: number
|
|
677
|
+
failureCount: number
|
|
678
|
+
lastError: string | null
|
|
679
|
+
failedAt: Date
|
|
680
|
+
}
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
The session's dead-lettered events, oldest first, each with why it failed. See
|
|
684
|
+
[When an event dead-letters](/server/production#3-when-an-event-dead-letters).
|
|
685
|
+
|
|
686
|
+
- `failureCount` counts the attempts that spent its budget: caught failures,
|
|
687
|
+
and attempts that ended before they finished. `attemptCount` counts every
|
|
688
|
+
claim. `lastError` is the last error a handler threw, stringified, or null
|
|
689
|
+
when every failed attempt ended before it finished.
|
|
690
|
+
- Events at or below the latest clear marker are never listed.
|
|
691
|
+
- Only on `server.session(id)`. `DeadLetter` is exported from
|
|
692
|
+
`experimental-a2/server`.
|
|
693
|
+
|
|
694
|
+
### `session.redrive(index, options)`
|
|
695
|
+
|
|
696
|
+
```ts
|
|
697
|
+
session.redrive(
|
|
698
|
+
index: number,
|
|
699
|
+
options: { action: 'retry' | 'skip' },
|
|
700
|
+
): Promise<{ redriven: boolean }>
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
Resolves the dead-lettered event at `index` in one atomic store operation.
|
|
704
|
+
|
|
705
|
+
| Action | Effect |
|
|
706
|
+
| --- | --- |
|
|
707
|
+
| `retry` | The handler runs again as a new attempt, with a fresh ten-failure budget. Once it completes, its lane continues. |
|
|
708
|
+
| `skip` | The event is marked processed without running its handler, and the next event in its lane runs. Its effects never happen. |
|
|
709
|
+
|
|
710
|
+
- `index` is a positive safe integer and `action` is required; anything else
|
|
711
|
+
throws a `TypeError` before any I/O.
|
|
712
|
+
- `redriven` is `false`, with nothing written, when the event isn't
|
|
713
|
+
dead-lettered: still pending, already processed, already redriven, missing,
|
|
714
|
+
or at or below the latest clear marker. Calling it again is safe.
|
|
715
|
+
- Afterwards, unfinished work wakes the session as an append does: an inline
|
|
716
|
+
drain, plus a scheduler watchdog when one is configured, joined for up to
|
|
717
|
+
`schedulerArmTimeoutMs`. A failed send doesn't reject the call; it marks the
|
|
718
|
+
`a2.redrive` span and leaves later appends, `server.drain()`, or another
|
|
719
|
+
`redrive` to wake the session.
|
|
720
|
+
- A retried event keeps its `attemptCount` and last failure, so the next run's
|
|
721
|
+
`ctx.attempt` keeps counting. A skipped event keeps `failedAt` and has no
|
|
722
|
+
`processedByAttempt`.
|
|
723
|
+
- Only on `server.session(id)`. `SessionRedriveOptions` and
|
|
724
|
+
`SessionRedriveResult` are exported from `experimental-a2/server`.
|
|
725
|
+
|
|
726
|
+
### `session.history()`
|
|
727
|
+
|
|
728
|
+
```ts
|
|
729
|
+
session.history(options?: { gte?: number; lte?: number }): Promise<Event[]>
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
Raw events, oldest first, without dispatch metadata. See
|
|
733
|
+
[History](/contract/state#history).
|
|
734
|
+
|
|
735
|
+
- `gte` and `lte` are inclusive, non-negative safe-integer indexes; omit one to
|
|
736
|
+
leave that end open. `lte: 0` returns `[]`; `gte > lte` throws.
|
|
737
|
+
- After a clear, history starts after the marker; the marker itself is not
|
|
738
|
+
returned.
|
|
739
|
+
|
|
740
|
+
### `session.state(reducer, options?)`
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
session.state(
|
|
744
|
+
reducer: Reducer<S>,
|
|
745
|
+
options?: { through?: number | 'latest' },
|
|
746
|
+
): Promise<{ state: S; index: number; clearIndex?: number }>
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
The log folded through one committed prefix. `index` is its exact boundary, an
|
|
750
|
+
append-order cursor rather than a handler-settlement marker; hand it to the
|
|
751
|
+
client to resume there. See [State](/contract/state).
|
|
752
|
+
|
|
753
|
+
- Omitting `through` reads the latest committed frontier on a root session,
|
|
754
|
+
and through `ctx.event.index` on `ctx.session`, inclusive and stable across
|
|
755
|
+
concurrent appends and retries. In a handler, use `{ through: 'latest' }`
|
|
756
|
+
only when later committed events are intentionally part of the decision.
|
|
757
|
+
- A numeric `through` is an inclusive, non-negative safe integer;
|
|
758
|
+
`through: 0` returns the reducer's initial state.
|
|
759
|
+
- A latest read and a following append are not atomic. See
|
|
760
|
+
[State](/contract/state#reducers) for joins that converge.
|
|
761
|
+
- Never dispatches handlers or waits for pending work. Snapshot write-back
|
|
762
|
+
uses platform `waitUntil`, and its failures never fail the completed read:
|
|
763
|
+
they go to `a2.snapshot` telemetry or the default console sink, without
|
|
764
|
+
snapshot contents.
|
|
765
|
+
- After a clear, the result includes `clearIndex`, the latest marker, and
|
|
766
|
+
state folds from there; forward the whole result when hydrating a client. A
|
|
767
|
+
positive `through` below `clearIndex` throws `SESSION_CLEARED`; `through: 0`
|
|
768
|
+
returns the seed without `clearIndex`.
|
|
769
|
+
- On `ctx.session`, every state read throws `SESSION_CLEARED` once the trigger
|
|
770
|
+
has been cleared, including `{ through: 'latest' }`. Handler `history()` and
|
|
771
|
+
`stream()` are not fenced.
|
|
772
|
+
- Default and numeric handler reads retain their exact folded checkpoint until
|
|
773
|
+
the trigger completes or dead-letters and no other unfinished handler uses
|
|
774
|
+
it. Handler `{ through: 'latest' }` reads and root reads retain nothing.
|
|
775
|
+
There are no retention options. A numeric read behind the latest snapshot
|
|
776
|
+
can use the greatest eligible retained checkpoint. See
|
|
777
|
+
[Snapshots are a cache](/contract/state#snapshots-are-a-cache).
|
|
778
|
+
- Operational snapshot-read failures keep existing A2 errors and turn others
|
|
779
|
+
into `STORE_UNAVAILABLE` with the original error as `cause`, without falling
|
|
780
|
+
back to individual reads or raw history. Cache misses and `stateSchema`
|
|
781
|
+
rejections rebuild from the log.
|
|
782
|
+
|
|
783
|
+
### `session.stream(options?)`
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
session.stream(options?: { startAfter?: number }): AsyncIterable<Event | SessionCleared>
|
|
787
|
+
session.stream(options: {
|
|
788
|
+
startAfter?: number
|
|
789
|
+
presence: true
|
|
790
|
+
}): AsyncIterable<Event | SessionCleared | PresencePatch | PresenceSnapshot>
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
A live feed of the session's events. Server-side only; `server.fetch` exposes
|
|
794
|
+
it over SSE and WebSocket. Subscribing never dispatches handlers.
|
|
795
|
+
|
|
796
|
+
- `startAfter` is a non-negative safe integer; `startAfter: 20` begins with
|
|
797
|
+
event 21.
|
|
798
|
+
- A clear arrives in order as a `SessionCleared` marker (`type: 'a2.cleared'`).
|
|
799
|
+
A stream that opens below the latest marker begins with it. A running stream
|
|
800
|
+
delivers the log in order, so events from before the clear can arrive ahead
|
|
801
|
+
of the marker. Reset when it arrives. Clearing never closes a stream.
|
|
802
|
+
- With `presence: true`, the feed first yields one `PresenceSnapshot`,
|
|
803
|
+
`{ snapshot }`: the current pruned map with each field's `value`, `seen`, and
|
|
804
|
+
`at`. Live `PresencePatch` values, `{ participant, values, seen, at }`, then
|
|
805
|
+
interleave with events; `values` is typed by the contract's presence schemas,
|
|
806
|
+
and `null` marks a cleared field. Over SSE both are named frames, which
|
|
807
|
+
clients that don't know them skip.
|
|
808
|
+
- The return type widens only under the literal `presence: true`. The option
|
|
809
|
+
exists only on sessions of contracts that declare `presence`; elsewhere it is
|
|
810
|
+
a type error, not a runtime throw.
|
|
811
|
+
|
|
812
|
+
### `session.setPresence(patch)`
|
|
813
|
+
|
|
814
|
+
```ts
|
|
815
|
+
session.setPresence(patch: {
|
|
816
|
+
participant: string
|
|
817
|
+
values: { [field]: payload | null } // partial; null clears a field
|
|
818
|
+
seen?: number
|
|
819
|
+
at?: number // the sender's LWW stamp, epoch ms; receipt time when absent
|
|
820
|
+
}): Promise<void>
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
Validates each field against the contract's `presence` schemas, then
|
|
824
|
+
broadcasts. No log row, no dispatch, no scheduler arm; `history()` and
|
|
825
|
+
`state()` never see it. See [Presence](/client/presence).
|
|
826
|
+
|
|
827
|
+
- Last writer wins per participant per field, ordered by `at`, the sender's
|
|
828
|
+
epoch-ms stamp; the browser client stamps its sends automatically. Without
|
|
829
|
+
`at`, receipt time stands in: a stampless caller is a single writer, so
|
|
830
|
+
receipt order is its send order.
|
|
831
|
+
- A bad payload throws `INVALID_PAYLOAD`, an unknown field throws
|
|
832
|
+
`UNKNOWN_PRESENCE_FIELD`, and a non-finite or negative `at` throws a
|
|
833
|
+
`TypeError`.
|
|
834
|
+
- `seen` is the sender's stream frontier, which view code uses to reconcile
|
|
835
|
+
presence with the fold. See
|
|
836
|
+
[Reconciling the planes](/client/presence#reconciling-the-planes-seen).
|
|
837
|
+
- Values expire by backend TTL when a participant goes silent (default 60
|
|
838
|
+
seconds; see [`presence.ttlMs`](#createserveroptions)). Expiry counts on the
|
|
839
|
+
storage clock, so a hostile stamp can only vandalize its own field, which
|
|
840
|
+
still expires on schedule.
|
|
841
|
+
- The patch is the push envelope's `presence` sibling; `server.fetch`'s push
|
|
842
|
+
lane forwards it whole to `session.setPresence(presence)`.
|
|
843
|
+
- `ctx.session.setPresence(...)` is the same operation; `seen` defaults to the
|
|
844
|
+
triggering event's `index`.
|
|
845
|
+
|
|
846
|
+
### `session.presence()`
|
|
847
|
+
|
|
848
|
+
```ts
|
|
849
|
+
session.presence(): Promise<{
|
|
850
|
+
[participant: string]: {
|
|
851
|
+
[field: string]: { value: unknown; seen: number; at: Date }
|
|
852
|
+
}
|
|
853
|
+
}>
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
The current presence map, expired values pruned: a point-in-time read for
|
|
857
|
+
server renders. Live consumers use `stream({ presence: true })`. Each entry's
|
|
858
|
+
`at` is the sender's stamp for when the value was set, the last-writer-wins
|
|
859
|
+
comparator; how long a value
|
|
860
|
+
stays painted is view logic, decided at render time.
|