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
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: A2 and your database
|
|
3
|
-
description: Use A2 for things with a lifecycle. Keep collections, relationships, and queries in your application database.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## Start with ownership
|
|
7
|
-
|
|
8
|
-
A2 does not replace your application database. It synchronizes one known
|
|
9
|
-
session at a time: its events, its derived state, and optimistic writes from
|
|
10
|
-
the browser. It does not synchronize tables or database queries.
|
|
11
|
-
|
|
12
|
-
Use your database for the records and relationships that the rest of the
|
|
13
|
-
application queries.
|
|
14
|
-
|
|
15
|
-
Decide which system owns each fact:
|
|
16
|
-
|
|
17
|
-
- If A2 owns it, append an event and derive the session's state with a reducer.
|
|
18
|
-
- If your database owns it, write it with the database's normal transaction.
|
|
19
|
-
|
|
20
|
-
Do not make both paths authoritative for the same fact.
|
|
21
|
-
|
|
22
|
-
## What belongs in A2
|
|
23
|
-
|
|
24
|
-
A2 fits something with a lifecycle: an order moving through fulfillment, an
|
|
25
|
-
agent producing a response, an approval waiting for a decision, or an import
|
|
26
|
-
that can fail and resume.
|
|
27
|
-
|
|
28
|
-
A **contract** names the events in one kind of lifecycle. A **session** is one
|
|
29
|
-
instance of it:
|
|
30
|
-
|
|
31
|
-
```text
|
|
32
|
-
orders / order-42
|
|
33
|
-
orders / order-43
|
|
34
|
-
agents / run-abc
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
The session is the boundary A2 logs, recovers, folds, and streams. Put events
|
|
38
|
-
in the same session when they form one history or view. Handler execution is
|
|
39
|
-
concurrent unless events share a lane.
|
|
40
|
-
|
|
41
|
-
A contract is not a table. A session is not a table either. One contract
|
|
42
|
-
usually serves many sessions, and one session may describe changes that touch
|
|
43
|
-
several records elsewhere in an application.
|
|
44
|
-
|
|
45
|
-
## What belongs in the database
|
|
46
|
-
|
|
47
|
-
Use your application database for collections and relationships: accounts,
|
|
48
|
-
projects, issue lists, labels, permissions, search, joins, pagination, and
|
|
49
|
-
reporting. Use its transactions for constraints that must hold across several
|
|
50
|
-
rows before a request returns.
|
|
51
|
-
|
|
52
|
-
The Postgres adapter in `experimental-a2/store-postgres` stores A2's own event logs. It does not
|
|
53
|
-
watch your application tables, turn their changes into events, or combine an
|
|
54
|
-
A2 append with your SQL transaction. The A2 log and your tables may share one
|
|
55
|
-
Postgres database, but they remain separate data models and transaction
|
|
56
|
-
boundaries.
|
|
57
|
-
|
|
58
|
-
## An issue tracker
|
|
59
|
-
|
|
60
|
-
An issue tracker is mostly a collection and query product. Postgres should
|
|
61
|
-
usually own its core data:
|
|
62
|
-
|
|
63
|
-
| Part | Owner |
|
|
64
|
-
| --- | --- |
|
|
65
|
-
| issues, projects, labels, comments | Postgres |
|
|
66
|
-
| permissions, board filters, search | Postgres |
|
|
67
|
-
| an AI investigation attached to an issue | one A2 session |
|
|
68
|
-
| an import or automation run | one A2 session |
|
|
69
|
-
|
|
70
|
-
The A2 session can store the investigation's messages, progress, tool calls,
|
|
71
|
-
approval, and result. It refers to the issue id, but it does not become a
|
|
72
|
-
second owner of the issue.
|
|
73
|
-
|
|
74
|
-
An individual issue also has a lifecycle, so A2 can own it when the issue's
|
|
75
|
-
ordered history and live state are the center of the product. That choice has
|
|
76
|
-
a cost: boards and search span many sessions and need another query model. If
|
|
77
|
-
those collection views are the center of the product, let the database lead.
|
|
78
|
-
|
|
79
|
-
Do not put an entire workspace in one session just to make cross-issue queries
|
|
80
|
-
possible. Every event joins the same growing log and every client follows it.
|
|
81
|
-
|
|
82
|
-
## Database writes from handlers
|
|
83
|
-
|
|
84
|
-
Handlers may call your database like any other side effect. They are
|
|
85
|
-
at-least-once, so make the write idempotent with `event.id`. The event is
|
|
86
|
-
durable before the handler runs, and the SQL write may happen later or be
|
|
87
|
-
retried.
|
|
88
|
-
|
|
89
|
-
That is a good fit for a reaction to a lifecycle event. It is not one atomic
|
|
90
|
-
write across A2 and your tables. If a relational change must commit before the
|
|
91
|
-
request succeeds, make that change through the database's write path.
|
|
92
|
-
|
|
93
|
-
## Cross-session indexes
|
|
94
|
-
|
|
95
|
-
Sometimes an A2-owned lifecycle needs a small database index, such as a list of
|
|
96
|
-
active imports. A handler can maintain that index. Treat it as an advanced
|
|
97
|
-
integration:
|
|
98
|
-
|
|
99
|
-
- it is eventually consistent with the session logs;
|
|
100
|
-
- writes must be idempotent;
|
|
101
|
-
- A2 assigns log order within one session, not across sessions;
|
|
102
|
-
- A2 does not expose one API that scans and replays every session to rebuild
|
|
103
|
-
the index.
|
|
104
|
-
|
|
105
|
-
Back up and migrate that index like application data. If search, joins, and
|
|
106
|
-
cross-session reporting are central rather than incidental, the database
|
|
107
|
-
should probably own that domain.
|
|
108
|
-
|
|
109
|
-
## The short version
|
|
110
|
-
|
|
111
|
-
Use A2 when people open something by id and watch it progress. Use your
|
|
112
|
-
database when people list, filter, join, and edit many records. In an
|
|
113
|
-
application that needs both, split ownership along that line.
|
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Presence
|
|
3
|
-
description: "Ephemeral participant state on a durable session: cursors, typing, live progress. Presence repaints; the log replays."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## Two planes, one session
|
|
7
|
-
|
|
8
|
-
Everything in the log is a fact: stored forever, folded by reducers,
|
|
9
|
-
replayed on demand. Presence is not a fact. "Her cursor is at (312, 88)"
|
|
10
|
-
has a useful lifetime of about two seconds, no replay value, and no
|
|
11
|
-
durability requirement; a lost update is repainted by the next one.
|
|
12
|
-
|
|
13
|
-
The test that separates the planes:
|
|
14
|
-
|
|
15
|
-
> Will anyone, human or reducer, care about this datum after the moment
|
|
16
|
-
> passes? Yes: event, in the log. No: presence.
|
|
17
|
-
|
|
18
|
-
Messages, document updates, cancellations: events. Cursors, selections,
|
|
19
|
-
typing, "who's online", a handler's live progress: presence.
|
|
20
|
-
|
|
21
|
-
A2 gives presence its own plane on the session you already have. Same
|
|
22
|
-
contract, same route, same auth, same stream. Each participant owns a
|
|
23
|
-
small ephemeral state; A2 replicates the latest value of each field to
|
|
24
|
-
everyone subscribed and stores none of it in the log. Reducers never see
|
|
25
|
-
it, `history()` never returns it, and the browser cache never persists
|
|
26
|
-
it. One invariant covers the whole design:
|
|
27
|
-
|
|
28
|
-
> **Presence repaints; the log replays.** Anything that decides (a fold,
|
|
29
|
-
> a retry, an abort match) reads the log. Presence only hurries pixels.
|
|
30
|
-
|
|
31
|
-
## Declare it in the contract
|
|
32
|
-
|
|
33
|
-
`presence` is a second vocabulary next to `events`: each key is a field
|
|
34
|
-
of one participant's ephemeral state, each value a schema for it.
|
|
35
|
-
|
|
36
|
-
```ts contracts.ts
|
|
37
|
-
import { z } from 'zod'
|
|
38
|
-
import * as a2 from 'experimental-a2'
|
|
39
|
-
|
|
40
|
-
export const canvas = a2.contract({
|
|
41
|
-
name: 'canvas',
|
|
42
|
-
events: {
|
|
43
|
-
'shape.created': z.object({
|
|
44
|
-
id: z.string(),
|
|
45
|
-
kind: z.enum(['rect', 'ellipse']),
|
|
46
|
-
x: z.number(),
|
|
47
|
-
y: z.number(),
|
|
48
|
-
}),
|
|
49
|
-
'shape.moved': z.object({ id: z.string(), x: z.number(), y: z.number() }),
|
|
50
|
-
},
|
|
51
|
-
reducer: { name: 'scene', initialState: {}, fold: (state) => state },
|
|
52
|
-
presence: {
|
|
53
|
-
cursor: z.object({ x: z.number(), y: z.number() }),
|
|
54
|
-
dragging: z.object({ shapeId: z.string(), x: z.number(), y: z.number() }),
|
|
55
|
-
},
|
|
56
|
-
})
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
The field is the unit of replication: last writer wins per participant
|
|
60
|
-
per field. Setting `cursor` does not touch `dragging`. Fields are absent
|
|
61
|
-
until a participant sets them, and clear when set to `null` or when
|
|
62
|
-
their backend TTL lapses (a closed tab disappears by silence, no
|
|
63
|
-
explicit leave required).
|
|
64
|
-
|
|
65
|
-
## The route
|
|
66
|
-
|
|
67
|
-
The same `server.fetch` route as [Live UI](/guides/react). Presence is
|
|
68
|
-
inferred from the contract. A contract with presence fields streams
|
|
69
|
-
presence patches with events, starting with a snapshot of the current
|
|
70
|
-
map. Its push body also accepts an optional `presence` sibling to
|
|
71
|
-
`events`, forwarded to `setPresence`.
|
|
72
|
-
|
|
73
|
-
```ts app/api/canvas-events/route.ts
|
|
74
|
-
import { canvasServer } from '@/server/canvas'
|
|
75
|
-
|
|
76
|
-
export const GET = canvasServer.fetch
|
|
77
|
-
export const POST = canvasServer.fetch
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
Wrap `server.fetch` when the route needs authentication. Pass
|
|
81
|
-
`authorize` to check the typed A2 operation before field validation or
|
|
82
|
-
I/O. A push operation carries the complete presence patch, including
|
|
83
|
-
its caller-supplied participant id. The same hook runs for HTTP and
|
|
84
|
-
WebSocket presence.
|
|
85
|
-
|
|
86
|
-
`setPresence` validates each field against the contract, then
|
|
87
|
-
broadcasts. No append transaction, no dispatch, no scheduler arm, no log
|
|
88
|
-
row. A bad field throws `INVALID_PAYLOAD`; an unknown field throws
|
|
89
|
-
`UNKNOWN_PRESENCE_FIELD`; nothing is broadcast on either. A
|
|
90
|
-
presence-only push acks `[]`.
|
|
91
|
-
|
|
92
|
-
## The browser
|
|
93
|
-
|
|
94
|
-
Presence needs a `participant` id (yours to mint: a user id, a tab
|
|
95
|
-
nonce, the playground's guest name). State it once on
|
|
96
|
-
`createClient({ participant })` when the app knows it at module scope,
|
|
97
|
-
or pass it to the provider, whose `participant` prop overrides the
|
|
98
|
-
client's. The hook grows two members. The
|
|
99
|
-
session module is the usual pair from
|
|
100
|
-
[Live UI](/guides/react#bind-react-once), bound to a reducer that
|
|
101
|
-
carries the canvas contract:
|
|
102
|
-
|
|
103
|
-
```ts app/canvas/[canvasId]/session.ts
|
|
104
|
-
'use client'
|
|
105
|
-
import { createClient } from 'experimental-a2/client'
|
|
106
|
-
import { createReact } from 'experimental-a2/react'
|
|
107
|
-
import { canvas } from '@/contracts'
|
|
108
|
-
|
|
109
|
-
export const canvasClient = createClient({
|
|
110
|
-
contract: canvas,
|
|
111
|
-
api: '/api/canvas-events',
|
|
112
|
-
})
|
|
113
|
-
export const { SessionProvider, useSession } = createReact({
|
|
114
|
-
client: canvasClient,
|
|
115
|
-
})
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
```tsx app/canvas/[canvasId]/canvas-client.tsx
|
|
119
|
-
'use client'
|
|
120
|
-
import { useSession } from './session'
|
|
121
|
-
|
|
122
|
-
export function CanvasClient() {
|
|
123
|
-
const { presence, setPresence } = useSession()
|
|
124
|
-
|
|
125
|
-
return (
|
|
126
|
-
<div
|
|
127
|
-
onPointerMove={(e) => setPresence({ cursor: { x: e.clientX, y: e.clientY } })}
|
|
128
|
-
>
|
|
129
|
-
{Object.entries(presence).map(([participant, fields]) =>
|
|
130
|
-
// render-time expiry: a cursor silent for a few seconds is gone
|
|
131
|
-
fields.cursor && Date.now() - fields.cursor.at.getTime() < 5_000 ? (
|
|
132
|
-
<Cursor key={participant} {...fields.cursor.value} />
|
|
133
|
-
) : null,
|
|
134
|
-
)}
|
|
135
|
-
</div>
|
|
136
|
-
)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// your cursor sprite:
|
|
140
|
-
// function Cursor({ x, y }: { x: number; y: number }) { ... }
|
|
141
|
-
const Cursor = (props: { x: number; y: number }) => null
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
- **`setPresence(values)`** is fire-and-forget: validated locally,
|
|
145
|
-
merged into the local map immediately, coalesced on the wire to a
|
|
146
|
-
fixed cadence. The first set sends immediately; within the interval,
|
|
147
|
-
later values merge field by field (latest wins, `null` included) and
|
|
148
|
-
one trailing send carries the merged batch, so a 60Hz pointer becomes
|
|
149
|
-
about fifteen sends per second and losing one is repainted by the
|
|
150
|
-
next. Sends may overlap in flight; that is safe because merges are
|
|
151
|
-
last-writer-wins by `at` per field. No ack, no `confirmed`, no retry,
|
|
152
|
-
no optimistic rollback: there is nothing to roll back, because
|
|
153
|
-
nothing was ever true.
|
|
154
|
-
- **`presence`** is the replicated map,
|
|
155
|
-
`Record<participantId, { [field]: { value, seen, at } }>`, including
|
|
156
|
-
your own participant. `at` is the sender's stamp for when the value
|
|
157
|
-
was set, and the last-writer-wins comparator; the client stamps its
|
|
158
|
-
sends monotonically, so overlapping sends can never invert. How long
|
|
159
|
-
a cursor stays painted is view logic, decided at render time. Wall
|
|
160
|
-
clocks never enter folds, and presence is not folded state.
|
|
161
|
-
|
|
162
|
-
Set a field to `null` to clear it (pointer leaves the canvas, blur,
|
|
163
|
-
unmount). Everything else expires by TTL when a tab goes silent: 60
|
|
164
|
-
seconds by default, set `createServer({ presence: { ttlMs } })` to
|
|
165
|
-
change it. Expiry counts on the backend's clock, not the sender's
|
|
166
|
-
stamp.
|
|
167
|
-
|
|
168
|
-
Both members are typed from the contract's `presence` map, through the
|
|
169
|
-
reducer, with no type arguments: field names, payloads, and `null`
|
|
170
|
-
clears all autocomplete. On a contract without `presence` the members
|
|
171
|
-
do not exist, so reaching for them is a compile error, not a silent
|
|
172
|
-
no-op.
|
|
173
|
-
|
|
174
|
-
## Reconciling the planes: `seen`
|
|
175
|
-
|
|
176
|
-
Every presence value carries `seen`, the sender's stream frontier at the
|
|
177
|
-
moment it was set. The client stamps it automatically. This is what lets
|
|
178
|
-
the two planes agree on an order without wall clocks.
|
|
179
|
-
|
|
180
|
-
The classic race: a participant drags a shape (presence), drops it
|
|
181
|
-
(event), and a stale `dragging` patch arrives after the durable
|
|
182
|
-
`shape.moved`. Without a shared coordinate system the shape flickers
|
|
183
|
-
backwards. With `seen`, superseding is one render-time rule:
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
// in render code: a ghost is dead once the log has moved past it.
|
|
187
|
-
// show fields.dragging only if
|
|
188
|
-
// fields.dragging.seen >= state.shapes[fields.dragging.value.shapeId].movedAt
|
|
189
|
-
// where movedAt is the index your reducer recorded for the last shape.moved
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
The durable event at index 214 beats any presence stamped 213 or lower,
|
|
193
|
-
deterministically. A2 carries the stamp and exposes the frontiers; the
|
|
194
|
-
comparison is yours, in view code, next to the pixels it protects.
|
|
195
|
-
|
|
196
|
-
## Handlers broadcast too
|
|
197
|
-
|
|
198
|
-
`ctx.session.setPresence` is the same session operation. A handler
|
|
199
|
-
grinding through work can publish live progress without writing
|
|
200
|
-
non-facts to the log:
|
|
201
|
-
|
|
202
|
-
```ts server/canvas.ts
|
|
203
|
-
import { createServer } from 'experimental-a2/server'
|
|
204
|
-
import { canvas } from '@/contracts'
|
|
205
|
-
|
|
206
|
-
export const canvasServer = createServer({
|
|
207
|
-
contract: canvas,
|
|
208
|
-
handlers: {
|
|
209
|
-
'shape.created': async (ctx) => {
|
|
210
|
-
// long work; broadcast progress as it goes:
|
|
211
|
-
// await ctx.session.setPresence({
|
|
212
|
-
// participant: 'system:thumbnails',
|
|
213
|
-
// values: { progress: { step: 2, of: 5 } },
|
|
214
|
-
// })
|
|
215
|
-
},
|
|
216
|
-
},
|
|
217
|
-
})
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
The participant id is the handler's choice; a `system:` prefix keeps
|
|
221
|
-
robots and humans apart in the map. Append the durable outcome as an
|
|
222
|
-
event when the work completes; the progress was never a fact, the result
|
|
223
|
-
is.
|
|
224
|
-
|
|
225
|
-
## What presence never does
|
|
226
|
-
|
|
227
|
-
- Never appears in `history()`, `state()`, or a reducer's input type.
|
|
228
|
-
The separation is structural: presence fields are not events, so a
|
|
229
|
-
fold cannot reach them even by accident.
|
|
230
|
-
- Never arms the scheduler, takes a claim, or retries. A crashed presence
|
|
231
|
-
update is a non-event, literally.
|
|
232
|
-
- Never enters the IndexedDB cache. Cache the log, never the moment.
|
|
233
|
-
- Never blocks an append. The planes share a route, not a transaction.
|
|
234
|
-
|
|
235
|
-
## Backends
|
|
236
|
-
|
|
237
|
-
Presence needs a broadcast primitive, and backends differ. Declaring
|
|
238
|
-
`presence` in a contract requires a store with the presence capability;
|
|
239
|
-
`createServer` throws `PRESENCE_NOT_SUPPORTED` at construction
|
|
240
|
-
otherwise. All built-in stores have it, at two tiers:
|
|
241
|
-
|
|
242
|
-
| Backend | Delivery |
|
|
243
|
-
| --- | --- |
|
|
244
|
-
| `store-redis` | push: TTL'd hash plus pub/sub patches |
|
|
245
|
-
| `store-redis-http` | push over HTTP subscriptions; adaptive polling when unsupported |
|
|
246
|
-
| `store-memory` | in-process, immediate |
|
|
247
|
-
| `store-postgres` | native notifications with `listenConnectionString`; adaptive polling by default |
|
|
248
|
-
| `store-sqlite` | degraded: bounded TTL'd rows, read on the live feed's poll cadence |
|
|
249
|
-
|
|
250
|
-
Degraded means later, not lost while watched: presence-only traffic
|
|
251
|
-
uses an adaptive re-read cadence, starting at 25ms and backing off to two
|
|
252
|
-
seconds while unchanged. New presence patches or durable events restore the
|
|
253
|
-
fast cadence. An idle remote viewer may wait up to two seconds plus query
|
|
254
|
-
latency for the first change. Redis Pub/Sub does not proactively broadcast
|
|
255
|
-
TTL expiry. Silent departures keep their last values in connected clients'
|
|
256
|
-
maps until a snapshot reconciles them, so use the render-time expiry above.
|
|
257
|
-
Native Postgres subscriptions reconcile missing or expired fields on their
|
|
258
|
-
ten-second safety reads. Cursors want
|
|
259
|
-
the push tier; typing indicators and progress read fine on either. The presence rows are
|
|
260
|
-
bounded per session and participant, so this is not log growth in
|
|
261
|
-
disguise, but on metered backends every patch is still a network
|
|
262
|
-
operation. Presence makes ephemeral state correct, not free.
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Transports
|
|
3
|
-
description: "One client, three wires: a single HTTP route, split routes for per-route timeouts, or one WebSocket for both directions."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## One wire seam, same client
|
|
7
|
-
|
|
8
|
-
Everything the client does is transport-agnostic: optimistic pushes
|
|
9
|
-
with ack and `confirmed`, frontier-resumed live reads, reconnection
|
|
10
|
-
with backoff, and presence throttling. `api` picks the wire underneath
|
|
11
|
-
it:
|
|
12
|
-
|
|
13
|
-
```ts
|
|
14
|
-
// in a 'use client' session module:
|
|
15
|
-
api: '/api/order-events'
|
|
16
|
-
api: (sessionId) => `/api/orders/${encodeURIComponent(sessionId)}/events`
|
|
17
|
-
api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
|
|
18
|
-
api: { type: 'ws', url: '/api/order-events' }
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
- **One route** is the default and the recommendation. Pass a fixed URL or a
|
|
22
|
-
session URL function. It serves GET (SSE stream and history) and POST (push).
|
|
23
|
-
- **`http` split** serves the two verbs from separate routes. Use it
|
|
24
|
-
when platform duration limits differ per verb.
|
|
25
|
-
- **`ws`** rides streams, pushes, and presence over WebSocket. It opens
|
|
26
|
-
one socket per session. Set `multiplex: true` on the client and
|
|
27
|
-
`multiplexWebSocket: true` on the route to share one socket across the
|
|
28
|
-
client's sessions.
|
|
29
|
-
|
|
30
|
-
A split socket is unrepresentable on purpose. The socket is one
|
|
31
|
-
connection in both directions.
|
|
32
|
-
|
|
33
|
-
Every session-scoped URL may instead be a `(sessionId) => string` function.
|
|
34
|
-
A2 resolves it for each request or reconnect, then adds its standard query
|
|
35
|
-
parameters. This supports paths scoped to a session. Encode the ID when placing
|
|
36
|
-
it in a path segment. The function chooses a route, not authority: if a path or
|
|
37
|
-
connection token is scoped to one session, `authorize` must compare that scope
|
|
38
|
-
to `operation.sessionId`. Bind URL tokens to the same session and keep them
|
|
39
|
-
short-lived because URLs can appear in infrastructure logs. Multiplexed
|
|
40
|
-
WebSockets take one plain URL because the connection carries more than one
|
|
41
|
-
session.
|
|
42
|
-
|
|
43
|
-
One wire is missing from `ws` by design: the history lane.
|
|
44
|
-
[`loadHistory`](/guides/react#events-and-history) is a bounded cold
|
|
45
|
-
read and rides plain HTTP. On a `ws` API it throws a `TypeError`.
|
|
46
|
-
|
|
47
|
-
## The HTTP route
|
|
48
|
-
|
|
49
|
-
`server.fetch` is bound, so an unprotected route is two exports:
|
|
50
|
-
|
|
51
|
-
```ts app/api/public-order-events/route.ts
|
|
52
|
-
import { ordersServer } from '@/server/orders'
|
|
53
|
-
|
|
54
|
-
export const GET = ordersServer.fetch
|
|
55
|
-
export const POST = ordersServer.fetch
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
A plain GET streams one session over SSE. A GET with `gte` and `lte`
|
|
59
|
-
returns a history slice. POST parses the push envelope and appends its
|
|
60
|
-
events. If the contract declares presence, the same route streams and
|
|
61
|
-
accepts presence automatically.
|
|
62
|
-
|
|
63
|
-
Wrap the call when the route needs authentication or authorization:
|
|
64
|
-
|
|
65
|
-
```ts app/api/protected-order-events/route.ts
|
|
66
|
-
import { ordersServer } from '@/server/orders'
|
|
67
|
-
|
|
68
|
-
async function fetchOrders(request: Request): Promise<Response> {
|
|
69
|
-
// your authentication:
|
|
70
|
-
const user = { id: 'user-1' }
|
|
71
|
-
if (!user) return new Response(null, { status: 401 })
|
|
72
|
-
|
|
73
|
-
return ordersServer.fetch(request, {
|
|
74
|
-
authorize(operation) {
|
|
75
|
-
// your per-session and per-operation authorization:
|
|
76
|
-
// return canAccess(user, operation)
|
|
77
|
-
return true
|
|
78
|
-
},
|
|
79
|
-
})
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export const GET = fetchOrders
|
|
83
|
-
export const POST = fetchOrders
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
`authorize` runs after A2 parses the operation and before schema
|
|
87
|
-
validation or durable I/O. It receives `stream`, `history`, and `push`
|
|
88
|
-
operations. Push operations include parsed event names and payloads,
|
|
89
|
-
plus the complete presence patch when present. Return `false` to deny.
|
|
90
|
-
A2 answers `FORBIDDEN` with status 403. If the hook throws, A2 treats
|
|
91
|
-
that as an infrastructure failure and answers `STORE_UNAVAILABLE`.
|
|
92
|
-
|
|
93
|
-
The hook is per `fetch` call. Authentication stays in the route, where
|
|
94
|
-
request headers, cookies, and framework context are available. The
|
|
95
|
-
captured user can then authorize strongly typed A2 operations without
|
|
96
|
-
repeating request parsing.
|
|
97
|
-
|
|
98
|
-
## The WebSocket route
|
|
99
|
-
|
|
100
|
-
Pass the platform's upgrade capability on GET. POST can stay a direct
|
|
101
|
-
export for HTTP clients:
|
|
102
|
-
|
|
103
|
-
```ts app/api/socket-order-events/route.ts
|
|
104
|
-
import { experimental_upgradeWebSocket } from '@vercel/functions'
|
|
105
|
-
import { ordersServer } from '@/server/orders'
|
|
106
|
-
|
|
107
|
-
export const POST = ordersServer.fetch
|
|
108
|
-
|
|
109
|
-
export function GET(request: Request): Promise<Response> {
|
|
110
|
-
return ordersServer.fetch(request, {
|
|
111
|
-
upgradeWebSocket: (attach) =>
|
|
112
|
-
experimental_upgradeWebSocket(attach, {
|
|
113
|
-
maxPayload: 4 * 1024 * 1024,
|
|
114
|
-
}),
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
The socket is bound to the `sessionId` in its upgrade URL. The route
|
|
120
|
-
authorizes that session before upgrading, and frames cannot select a
|
|
121
|
-
different session afterward. Each connected session has its own socket.
|
|
122
|
-
|
|
123
|
-
Authenticate the physical upgrade before calling `server.fetch`. For browser
|
|
124
|
-
sockets authenticated by cookies, validate the request's `Origin` too. A
|
|
125
|
-
WebSocket handshake does not use a CORS preflight.
|
|
126
|
-
Then pass `authorize` to check the session stream and every push or
|
|
127
|
-
presence frame. There is no separate upgrade operation: request policy
|
|
128
|
-
belongs to the route, while A2 operation policy belongs to `authorize`.
|
|
129
|
-
|
|
130
|
-
To multiplex, opt in on both sides:
|
|
131
|
-
|
|
132
|
-
```ts app/api/multiplexed-order-events/route.ts
|
|
133
|
-
import { experimental_upgradeWebSocket } from '@vercel/functions'
|
|
134
|
-
import { ordersServer } from '@/server/orders'
|
|
135
|
-
|
|
136
|
-
export function GET(request: Request): Promise<Response> {
|
|
137
|
-
return ordersServer.fetch(request, {
|
|
138
|
-
multiplexWebSocket: true,
|
|
139
|
-
upgradeWebSocket: (attach) =>
|
|
140
|
-
experimental_upgradeWebSocket(attach, {
|
|
141
|
-
maxPayload: 4 * 1024 * 1024,
|
|
142
|
-
}),
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
```ts app/orders/multiplexed-session.ts
|
|
148
|
-
'use client'
|
|
149
|
-
import { createClient } from 'experimental-a2/client'
|
|
150
|
-
import { orders } from '@/contracts'
|
|
151
|
-
|
|
152
|
-
export const ordersClient = createClient({
|
|
153
|
-
contract: orders,
|
|
154
|
-
api: {
|
|
155
|
-
type: 'ws',
|
|
156
|
-
url: '/api/multiplexed-order-events',
|
|
157
|
-
multiplex: true,
|
|
158
|
-
},
|
|
159
|
-
})
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Only enable multiplexing when the request's authentication context may
|
|
163
|
-
open more than one session and `authorize` checks each supplied session
|
|
164
|
-
ID. Subscribe frames open the lanes after the physical upgrade.
|
|
165
|
-
|
|
166
|
-
On a multiplexed socket, a denied subscribe receives an `unsubscribed`
|
|
167
|
-
notice with a structured `FORBIDDEN` error and leaves other sessions
|
|
168
|
-
connected. A failed stream or thrown authorization check carries
|
|
169
|
-
`STORE_UNAVAILABLE`. The client exposes these as `A2ClientError` instances
|
|
170
|
-
to its reconnection policy. On either socket shape, a
|
|
171
|
-
denied event push receives a non-retryable `FORBIDDEN` ack. Denied
|
|
172
|
-
presence is silently dropped because presence is fire-and-forget and
|
|
173
|
-
repaints on the next update.
|
|
174
|
-
|
|
175
|
-
A2 reads the platform's ambient invocation deadline and closes the
|
|
176
|
-
socket cleanly before it. `waitUntil` and deadline discovery remain
|
|
177
|
-
platform capabilities used internally, not options on `server.fetch`.
|
|
178
|
-
|
|
179
|
-
## The client
|
|
180
|
-
|
|
181
|
-
```ts app/orders/[orderId]/session.ts
|
|
182
|
-
'use client'
|
|
183
|
-
import { createClient } from 'experimental-a2/client'
|
|
184
|
-
import { createReact } from 'experimental-a2/react'
|
|
185
|
-
import { orders } from '@/contracts'
|
|
186
|
-
|
|
187
|
-
export const ordersClient = createClient({
|
|
188
|
-
contract: orders,
|
|
189
|
-
api: { type: 'ws', url: '/api/order-events' },
|
|
190
|
-
})
|
|
191
|
-
export const { SessionProvider, useSession } = createReact({
|
|
192
|
-
client: ordersClient,
|
|
193
|
-
})
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
Nothing else changes. `useSession` is identical, `push` returns the
|
|
197
|
-
same ack and `confirmed`, and presence coalesces on the same cadence.
|
|
198
|
-
The connection status, stall watchdog, and reconnection behavior are
|
|
199
|
-
shared above the wire seam.
|
|
200
|
-
|
|
201
|
-
## Lifecycle
|
|
202
|
-
|
|
203
|
-
When a socket closes, the client follows its reconnection policy at the current
|
|
204
|
-
frontier, receives a fresh presence snapshot, and re-sends its own latest
|
|
205
|
-
presence values. A multiplexed socket re-subscribes every active session
|
|
206
|
-
at its own frontier. Event pushes keep their client-generated ids, so
|
|
207
|
-
retry remains idempotent.
|
|
208
|
-
|
|
209
|
-
Configure `createClient({ reconnect })` to choose a delay or pause by
|
|
210
|
-
returning `false`. A paused session keeps its state and leases; call
|
|
211
|
-
`session.reconnect()` to resume it. The default policy reconnects with
|
|
212
|
-
backoff indefinitely. A clean close also runs the policy, with `error: null`.
|
|
213
|
-
|
|
214
|
-
Pre-upgrade authorization remains in the HTTP route. Browser WebSockets do
|
|
215
|
-
not expose a rejected upgrade's HTTP status or body, so those failures remain
|
|
216
|
-
opaque `CONNECTION_FAILED` errors. Structured subscription errors are
|
|
217
|
-
available after a multiplexed socket has opened.
|
|
218
|
-
|
|
219
|
-
SSE and WebSocket are transport choices, not different consistency
|
|
220
|
-
models. Both resume from the durable event frontier and treat presence
|
|
221
|
-
as ephemeral repaintable state.
|