experimental-a2 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-DgrrAE3d.js} +5 -5
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-DWqx3F47.js} +269 -66
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +7 -7
- package/dist/{log-redis-http.js → store-redis-http.js} +20 -12
- package/dist/{log-redis.d.ts → store-redis.d.ts} +6 -6
- package/dist/{log-redis.js → store-redis.js} +99 -39
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +751 -103
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -7
- package/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { t as A2Error } from "./errors-BQuJpe82.js";
|
|
2
|
+
import { g as nullProtoRecord } from "./internal-aEotMzu_.js";
|
|
3
|
+
//#region src/wire.ts
|
|
4
|
+
/**
|
|
5
|
+
* The wire format shared by experimental-a2/http (server side) and experimental-a2/client
|
|
6
|
+
* (browser side): events as JSON with ISO timestamps, and the A2Error
|
|
7
|
+
* envelope. Internal module — both entry points re-export what's
|
|
8
|
+
* public.
|
|
9
|
+
*/
|
|
10
|
+
function eventToWire(event) {
|
|
11
|
+
return {
|
|
12
|
+
id: event.id,
|
|
13
|
+
type: event.type,
|
|
14
|
+
payload: event.payload,
|
|
15
|
+
index: event.index,
|
|
16
|
+
sessionId: event.sessionId,
|
|
17
|
+
createdAt: event.createdAt.toISOString()
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function eventFromWire(wire) {
|
|
21
|
+
return {
|
|
22
|
+
id: wire.id,
|
|
23
|
+
type: wire.type,
|
|
24
|
+
payload: wire.payload,
|
|
25
|
+
index: wire.index,
|
|
26
|
+
sessionId: wire.sessionId,
|
|
27
|
+
createdAt: new Date(wire.createdAt)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function isWireEvent(value) {
|
|
31
|
+
if (value === null || typeof value !== "object") return false;
|
|
32
|
+
const v = value;
|
|
33
|
+
return typeof v["id"] === "string" && typeof v["type"] === "string" && typeof v["index"] === "number" && typeof v["sessionId"] === "string" && typeof v["createdAt"] === "string";
|
|
34
|
+
}
|
|
35
|
+
function presencePatchToWire(patch) {
|
|
36
|
+
return {
|
|
37
|
+
participant: patch.participant,
|
|
38
|
+
values: patch.values,
|
|
39
|
+
seen: patch.seen,
|
|
40
|
+
at: patch.at.toISOString()
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function presencePatchFromWire(wire) {
|
|
44
|
+
return {
|
|
45
|
+
participant: wire.participant,
|
|
46
|
+
values: wire.values,
|
|
47
|
+
seen: wire.seen,
|
|
48
|
+
at: new Date(wire.at)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function presenceSnapshotToWire(snapshot) {
|
|
52
|
+
const wire = nullProtoRecord();
|
|
53
|
+
for (const [participant, fields] of Object.entries(snapshot.snapshot)) {
|
|
54
|
+
const wireFields = nullProtoRecord();
|
|
55
|
+
for (const [field, entry] of Object.entries(fields)) {
|
|
56
|
+
if (entry === void 0) continue;
|
|
57
|
+
wireFields[field] = {
|
|
58
|
+
value: entry.value,
|
|
59
|
+
seen: entry.seen,
|
|
60
|
+
at: entry.at.toISOString()
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
wire[participant] = wireFields;
|
|
64
|
+
}
|
|
65
|
+
return { snapshot: wire };
|
|
66
|
+
}
|
|
67
|
+
function presenceSnapshotFromWire(wire) {
|
|
68
|
+
const map = nullProtoRecord();
|
|
69
|
+
for (const [participant, fields] of Object.entries(wire.snapshot)) {
|
|
70
|
+
const revived = nullProtoRecord();
|
|
71
|
+
for (const [field, entry] of Object.entries(fields)) revived[field] = {
|
|
72
|
+
value: entry.value,
|
|
73
|
+
seen: entry.seen,
|
|
74
|
+
at: new Date(entry.at)
|
|
75
|
+
};
|
|
76
|
+
map[participant] = revived;
|
|
77
|
+
}
|
|
78
|
+
return { snapshot: map };
|
|
79
|
+
}
|
|
80
|
+
function isWirePresencePatch(value) {
|
|
81
|
+
if (value === null || typeof value !== "object") return false;
|
|
82
|
+
const v = value;
|
|
83
|
+
return typeof v["participant"] === "string" && v["values"] !== null && typeof v["values"] === "object" && !Array.isArray(v["values"]) && typeof v["seen"] === "number" && typeof v["at"] === "string";
|
|
84
|
+
}
|
|
85
|
+
function isWirePresenceSnapshot(value) {
|
|
86
|
+
if (value === null || typeof value !== "object") return false;
|
|
87
|
+
const v = value;
|
|
88
|
+
return v["snapshot"] !== null && typeof v["snapshot"] === "object" && !Array.isArray(v["snapshot"]);
|
|
89
|
+
}
|
|
90
|
+
const ERROR_STATUS = {
|
|
91
|
+
INVALID_PAYLOAD: 400,
|
|
92
|
+
UNKNOWN_EVENT_TYPE: 400,
|
|
93
|
+
PARTIAL_DUPLICATE_BATCH: 400,
|
|
94
|
+
SUPERSEDED_ATTEMPT: 409,
|
|
95
|
+
CLAIM_EXPIRED: 409,
|
|
96
|
+
STORE_UNAVAILABLE: 503,
|
|
97
|
+
STORE_NOT_CONFIGURED: 500,
|
|
98
|
+
UNKNOWN_PRESENCE_FIELD: 400,
|
|
99
|
+
PRESENCE_NOT_SUPPORTED: 500
|
|
100
|
+
};
|
|
101
|
+
function errorStatus(code) {
|
|
102
|
+
return ERROR_STATUS[code];
|
|
103
|
+
}
|
|
104
|
+
function errorToWire(error) {
|
|
105
|
+
const body = { error: {
|
|
106
|
+
code: error.code,
|
|
107
|
+
message: error.message
|
|
108
|
+
} };
|
|
109
|
+
if (error.details !== void 0) body.error.details = error.details;
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
112
|
+
/** Rebuild an A2Error from a wire body; null if the body isn't one. */
|
|
113
|
+
function errorFromWire(body) {
|
|
114
|
+
if (body === null || typeof body !== "object") return null;
|
|
115
|
+
const err = body.error;
|
|
116
|
+
if (err === null || typeof err !== "object") return null;
|
|
117
|
+
const { code, message, details } = err;
|
|
118
|
+
if (typeof code !== "string" || !Object.hasOwn(ERROR_STATUS, code)) return null;
|
|
119
|
+
return new A2Error(code, String(message ?? code), { details });
|
|
120
|
+
}
|
|
121
|
+
const SOCKET_PING_FRAME = JSON.stringify({ kind: "ping" });
|
|
122
|
+
/** One stream item as a socket frame — `sseResponse`'s framing over
|
|
123
|
+
* the same codecs, with `kind` instead of an SSE event name. */
|
|
124
|
+
function socketFrameFor(item) {
|
|
125
|
+
if ("snapshot" in item) return JSON.stringify({
|
|
126
|
+
kind: "presence-snapshot",
|
|
127
|
+
...presenceSnapshotToWire(item)
|
|
128
|
+
});
|
|
129
|
+
if ("participant" in item) return JSON.stringify({
|
|
130
|
+
kind: "presence",
|
|
131
|
+
...presencePatchToWire(item)
|
|
132
|
+
});
|
|
133
|
+
return JSON.stringify({
|
|
134
|
+
kind: "event",
|
|
135
|
+
...eventToWire(item)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function socketAckFor(req, events) {
|
|
139
|
+
return JSON.stringify({
|
|
140
|
+
kind: "ack",
|
|
141
|
+
req,
|
|
142
|
+
events: events.map(eventToWire)
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function socketErrorAckFor(req, error) {
|
|
146
|
+
return JSON.stringify({
|
|
147
|
+
kind: "ack",
|
|
148
|
+
req,
|
|
149
|
+
...errorToWire(error)
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function parseSocketFrame(data) {
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(data);
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
160
|
+
const frame = parsed;
|
|
161
|
+
switch (frame["kind"]) {
|
|
162
|
+
case "ping": return { kind: "ping" };
|
|
163
|
+
case "event": {
|
|
164
|
+
if (!isWireEvent(parsed)) return null;
|
|
165
|
+
const { id, type, payload, index, sessionId, createdAt } = parsed;
|
|
166
|
+
return {
|
|
167
|
+
kind: "event",
|
|
168
|
+
event: {
|
|
169
|
+
id,
|
|
170
|
+
type,
|
|
171
|
+
payload,
|
|
172
|
+
index,
|
|
173
|
+
sessionId,
|
|
174
|
+
createdAt
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
case "presence": {
|
|
179
|
+
if (!isWirePresencePatch(parsed)) return null;
|
|
180
|
+
const { participant, values, seen, at } = parsed;
|
|
181
|
+
return {
|
|
182
|
+
kind: "presence",
|
|
183
|
+
patch: {
|
|
184
|
+
participant,
|
|
185
|
+
values,
|
|
186
|
+
seen,
|
|
187
|
+
at
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
case "presence-snapshot":
|
|
192
|
+
if (!isWirePresenceSnapshot(parsed)) return null;
|
|
193
|
+
return {
|
|
194
|
+
kind: "presence-snapshot",
|
|
195
|
+
snapshot: { snapshot: parsed.snapshot }
|
|
196
|
+
};
|
|
197
|
+
case "ack": {
|
|
198
|
+
const req = frame["req"];
|
|
199
|
+
if (typeof req !== "number") return null;
|
|
200
|
+
const events = frame["events"];
|
|
201
|
+
if (Array.isArray(events) && events.every(isWireEvent)) return {
|
|
202
|
+
kind: "ack",
|
|
203
|
+
req,
|
|
204
|
+
events
|
|
205
|
+
};
|
|
206
|
+
const error = errorFromWire(parsed);
|
|
207
|
+
if (error) return {
|
|
208
|
+
kind: "ack",
|
|
209
|
+
req,
|
|
210
|
+
error
|
|
211
|
+
};
|
|
212
|
+
return {
|
|
213
|
+
kind: "ack",
|
|
214
|
+
req,
|
|
215
|
+
error: new A2Error("STORE_UNAVAILABLE", "unintelligible ack")
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
default: return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
export { socketFrameFor as _, eventFromWire as a, isWirePresencePatch as c, presencePatchFromWire as d, presencePatchToWire as f, socketErrorAckFor as g, socketAckFor as h, errorToWire as i, isWirePresenceSnapshot as l, presenceSnapshotToWire as m, errorFromWire as n, eventToWire as o, presenceSnapshotFromWire as p, errorStatus as r, isWireEvent as s, SOCKET_PING_FRAME as t, parseSocketFrame as u };
|
package/docs/01-quickstart.mdx
CHANGED
|
@@ -40,7 +40,7 @@ implements it below, the reducer derives from it in step 6, and when a
|
|
|
40
40
|
browser enters the picture it types the client too.
|
|
41
41
|
|
|
42
42
|
There's no database setup in development: A2 stores events in SQLite at
|
|
43
|
-
`.a2/dev.db`, so state survives restarts. Tests get an in-memory
|
|
43
|
+
`.a2/dev.db`, so state survives restarts. Tests get an in-memory store.
|
|
44
44
|
Production wants an explicit backend; see
|
|
45
45
|
[Going to production](/guides/production).
|
|
46
46
|
|
|
@@ -115,7 +115,7 @@ The payload is validated against the schema before anything is written;
|
|
|
115
115
|
send garbage and you get `INVALID_PAYLOAD` back, with the issues.
|
|
116
116
|
|
|
117
117
|
When the append returns, the event is durably in the log and A2 has started the
|
|
118
|
-
`created` handler inline. In production, queue-backed
|
|
118
|
+
`created` handler inline. In production, a queue-backed scheduler wakes pending
|
|
119
119
|
work after a failure or process death. See [Durability](/concepts/durability)
|
|
120
120
|
for the exact contract.
|
|
121
121
|
|
|
@@ -208,6 +208,6 @@ infrastructure.
|
|
|
208
208
|
The context object, chaining, ordering, and re-runs.
|
|
209
209
|
</Card>
|
|
210
210
|
<Card title="Going to production" href="/guides/production" icon="rocket">
|
|
211
|
-
Postgres, queue-backed
|
|
211
|
+
Postgres, a queue-backed scheduler, and dead-letter resolution.
|
|
212
212
|
</Card>
|
|
213
213
|
</CardGroup>
|
|
@@ -45,6 +45,11 @@ The contract names the complete vocabulary. What happens on each event is
|
|
|
45
45
|
defined where the contract is served. [Reducers](/concepts/state) define what
|
|
46
46
|
things look like right now.
|
|
47
47
|
|
|
48
|
+
A contract can also declare `presence`, a second vocabulary for ephemeral
|
|
49
|
+
participant state (cursors, typing, live progress) that is replicated but
|
|
50
|
+
never stored in the log. Events are facts; presence is the moment. See
|
|
51
|
+
[Presence](/guides/presence).
|
|
52
|
+
|
|
48
53
|
:::tip
|
|
49
54
|
Events are facts, so name them in past tense: `created`, `expired`,
|
|
50
55
|
`shop.notified`. If a name reads like a command (`notifyShop`), it's
|
|
@@ -55,17 +60,17 @@ convention, not a namespace.
|
|
|
55
60
|
## Serving a contract
|
|
56
61
|
|
|
57
62
|
Storage and optional handlers bind to the contract with `createServer` from
|
|
58
|
-
`experimental-a2/server`, the entry point that is allowed to touch a
|
|
63
|
+
`experimental-a2/server`, the entry point that is allowed to touch a store backend
|
|
59
64
|
(and the one that fails loudly if a browser bundle ever imports it):
|
|
60
65
|
|
|
61
66
|
```ts server/orders.ts
|
|
62
67
|
import { createServer } from 'experimental-a2/server'
|
|
63
|
-
import { postgres } from 'experimental-a2/
|
|
68
|
+
import { postgres } from 'experimental-a2/store-postgres'
|
|
64
69
|
import { orders } from '@/contracts'
|
|
65
70
|
|
|
66
71
|
export const ordersServer = createServer({
|
|
67
72
|
contract: orders,
|
|
68
|
-
|
|
73
|
+
store: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
69
74
|
handlers: {
|
|
70
75
|
created: async ({ event }) => {
|
|
71
76
|
// your side effect, then:
|
|
@@ -82,13 +87,13 @@ objects into `handlers`.
|
|
|
82
87
|
|
|
83
88
|
| Entry point | Backend | Use it for |
|
|
84
89
|
| ----------------- | --------- | ----------------------------------------- |
|
|
85
|
-
| `experimental-a2/
|
|
86
|
-
| `experimental-a2/
|
|
87
|
-
| `experimental-a2/
|
|
90
|
+
| `experimental-a2/store-postgres` | Postgres | Production |
|
|
91
|
+
| `experimental-a2/store-sqlite` | SQLite | Development: the default, at `.a2/dev.db` |
|
|
92
|
+
| `experimental-a2/store-memory` | In-memory | Tests: the default when `NODE_ENV=test` |
|
|
88
93
|
|
|
89
|
-
When you omit `
|
|
94
|
+
When you omit `store`, development gets SQLite (state survives `next dev`
|
|
90
95
|
restarts) and tests get memory. Production gets an error: the server
|
|
91
|
-
throws `
|
|
96
|
+
throws `STORE_NOT_CONFIGURED` at startup rather than guess where your
|
|
92
97
|
events should live. A failed boot beats events written to a filesystem
|
|
93
98
|
that evaporates.
|
|
94
99
|
|
|
@@ -127,11 +132,11 @@ progress. Collections, joins, search, and constraints across many records
|
|
|
127
132
|
usually belong in an application database. See
|
|
128
133
|
[A2 and your database](/guides/application-data).
|
|
129
134
|
|
|
130
|
-
## One more option:
|
|
135
|
+
## One more option: a scheduler
|
|
131
136
|
|
|
132
|
-
Servers accept a `
|
|
133
|
-
pending handler work,
|
|
134
|
-
starts without waiting for
|
|
137
|
+
Servers accept a `scheduler` option. When a top-level append leaves or finds
|
|
138
|
+
pending handler work, the scheduler arms a durable, delayed drain. The handler
|
|
139
|
+
starts without waiting for the scheduler, and a successful initial arm is durable
|
|
135
140
|
before `append` returns. Event types without handlers settle during append and
|
|
136
|
-
create no
|
|
141
|
+
create no scheduler work when the session has no older backlog. Covered in
|
|
137
142
|
[Going to production](/guides/production).
|
|
@@ -32,16 +32,16 @@ module that registered it was never imported.
|
|
|
32
32
|
Handlers are optional per event type. An event without one still gets its full
|
|
33
33
|
place in the log. You can read it with `history()`, fold it into state, and
|
|
34
34
|
stream it to a live client. A2 settles it in the append transaction, with no
|
|
35
|
-
dispatch claim or
|
|
35
|
+
dispatch claim or scheduler message.
|
|
36
36
|
|
|
37
|
-
```ts server/order-
|
|
37
|
+
```ts server/order-history.ts
|
|
38
38
|
import { createServer } from 'experimental-a2/server'
|
|
39
|
-
import { postgres } from 'experimental-a2/
|
|
39
|
+
import { postgres } from 'experimental-a2/store-postgres'
|
|
40
40
|
import { orders } from '@/contracts'
|
|
41
41
|
|
|
42
|
-
export const
|
|
42
|
+
export const orderHistory = createServer({
|
|
43
43
|
contract: orders,
|
|
44
|
-
|
|
44
|
+
store: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
45
45
|
})
|
|
46
46
|
```
|
|
47
47
|
|
|
@@ -58,7 +58,7 @@ Every handler receives one argument:
|
|
|
58
58
|
| `ctx.event` | The triggering event: `{ id, type, payload, index, sessionId, createdAt }`. |
|
|
59
59
|
| `ctx.attempt` | The durable 1-based dispatch claim for this event. |
|
|
60
60
|
| `ctx.session` | This session's handle: `id`, `append`, `history`, `state`, and `stream`. |
|
|
61
|
-
| `ctx.signal` | An `AbortSignal
|
|
61
|
+
| `ctx.signal` | An `AbortSignal`. Fires when the attempt's lease lapses (`CLAIM_EXPIRED`), when a recovered run provably supersedes it (`SUPERSEDED_ATTEMPT`), and on matching `abortOn` events. See [Cancellation](/guides/cancellation). |
|
|
62
62
|
|
|
63
63
|
`ctx.attempt` starts at `1` and increments on every durable claim. It may skip
|
|
64
64
|
when a process dies between the claim and handler entry.
|
|
@@ -239,7 +239,7 @@ export const drivers = createServer({
|
|
|
239
239
|
})
|
|
240
240
|
```
|
|
241
241
|
|
|
242
|
-
The resolved lane is stored with the event when it is appended.
|
|
242
|
+
The resolved lane is stored with the event when it is appended. The scheduler uses
|
|
243
243
|
that stored value, so a deployment cannot reinterpret pending work. Lane keys
|
|
244
244
|
are scoped to one session. They are not distributed locks across sessions.
|
|
245
245
|
|
|
@@ -5,13 +5,13 @@ description: Appends are effectively-once, handlers are at-least-once, and retri
|
|
|
5
5
|
|
|
6
6
|
## The contract
|
|
7
7
|
|
|
8
|
-
- **Appends are effectively-once.** Event ids deduplicate writes.
|
|
9
|
-
|
|
8
|
+
- **Appends are effectively-once.** Event ids deduplicate writes. A batch commits
|
|
9
|
+
before its handlers start; they may start before `append` resolves.
|
|
10
10
|
- **Handlers are at-least-once.** They may run again after a crash, so
|
|
11
11
|
[side effects use idempotency keys](/concepts/handlers#handlers-can-run-twice).
|
|
12
|
-
- **The log stays ordered.** Every session has one immutable
|
|
13
|
-
- **Handlers are concurrent by default.**
|
|
14
|
-
|
|
12
|
+
- **The log stays ordered.** Every session has one immutable append order.
|
|
13
|
+
- **Handlers are concurrent by default.** Log order does not serialize them.
|
|
14
|
+
Only events in the same lane run one at a time in log order.
|
|
15
15
|
|
|
16
16
|
## The correctness model
|
|
17
17
|
|
|
@@ -44,7 +44,7 @@ processed marker says what finished. The watchdog says when to look again.
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
A queue message identifies a session. Every wakeup runs the same drain, and
|
|
47
|
-
the log decides what remains.
|
|
47
|
+
the log decides what remains. The scheduler and explicit `server.drain()` inspect
|
|
48
48
|
the full session. Reads never dispatch handlers.
|
|
49
49
|
|
|
50
50
|
:::note[Recovery is not an event]
|
|
@@ -160,25 +160,25 @@ limit or priority queue. Use lanes for ordered mutual exclusion. Put capacity
|
|
|
160
160
|
limits near the constrained resource, such as an API client, connection pool,
|
|
161
161
|
or execution runtime.
|
|
162
162
|
|
|
163
|
-
## Append and
|
|
163
|
+
## Append and scheduling
|
|
164
164
|
|
|
165
|
-
A top-level append commits first.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
`ctx.session.append` rides the active
|
|
170
|
-
|
|
165
|
+
A top-level append commits first. Only then does A2 start the inline drain and
|
|
166
|
+
optional watchdog arm in parallel. A handler may start before `append` returns;
|
|
167
|
+
the call joins the arm for at most two seconds, not the handler. The drain never
|
|
168
|
+
waits for the queue. Arm failure records `a2.append.armed = false`, while
|
|
169
|
+
`ctx.session.append` rides the active drain and adds no arm. Returned events
|
|
170
|
+
enter the log with completion, then become eligible immediately.
|
|
171
171
|
|
|
172
172
|
`session.append.dispatch(...events)` chooses the other execution path. It
|
|
173
173
|
commits first, skips the current invocation's inline drain, and awaits an
|
|
174
|
-
immediate
|
|
174
|
+
immediate scheduler send. It requires a configured scheduler. Queue delivery then
|
|
175
175
|
claims the same durable events through the normal drain path. If the send
|
|
176
176
|
fails, retry with the same explicit event IDs; the append itself may already
|
|
177
|
-
have committed. The
|
|
177
|
+
have committed. The scheduler message is a wakeup, not worker affinity. An
|
|
178
178
|
already-active drain may claim the new work first.
|
|
179
179
|
|
|
180
180
|
An event type without a handler settles in the append transaction with no
|
|
181
|
-
dispatch attempt. It starts no drain or
|
|
181
|
+
dispatch attempt. It starts no drain or scheduler arm when the session has no
|
|
182
182
|
older pending work. If older handled work is pending, the append still wakes
|
|
183
183
|
the session-wide healing path.
|
|
184
184
|
|
|
@@ -194,7 +194,7 @@ The edge is part of the existing append batch and survives process restarts.
|
|
|
194
194
|
The stored event also carries `lane`, `firstClaimedAt`, `lastClaimedAt`,
|
|
195
195
|
`claimHolder`, `claimExpiresAt`, `lastFailedAt`, `lastFailedAttempt`,
|
|
196
196
|
`processedAt`, `processedByAttempt`, and `returnedEventIds`. Timestamps are
|
|
197
|
-
adapter clock values captured for atomic
|
|
197
|
+
adapter clock values captured for atomic store operations, not exact database
|
|
198
198
|
commit times. A stale worker cannot rewrite a newer completion. The durable
|
|
199
199
|
log can rebuild a causal forest and useful lifecycle boundaries without one
|
|
200
200
|
row per attempt.
|
|
@@ -299,9 +299,9 @@ Resolution is manual: fix and retry, or skip.
|
|
|
299
299
|
| Situation | Result |
|
|
300
300
|
| --- | --- |
|
|
301
301
|
| Process dies before the first arm is durable | Retry with the same ids, append again later, or call `drain()`. |
|
|
302
|
-
| No
|
|
303
|
-
|
|
|
304
|
-
|
|
|
302
|
+
| No scheduler configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
|
|
303
|
+
| Scheduler invocation dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
|
|
304
|
+
| Store backend unavailable | Safe progress stops until it returns. |
|
|
305
305
|
| External effect succeeds before A2 completion | The handler may repeat it. Use idempotency or external fencing. |
|
|
306
306
|
|
|
307
307
|
## What append never throws for
|
|
@@ -311,4 +311,4 @@ identical batch retry returns rows with the same ids. A mixed batch throws
|
|
|
311
311
|
[`PARTIAL_DUPLICATE_BATCH`](/reference/errors).
|
|
312
312
|
|
|
313
313
|
For the queue route and deployment configuration, see
|
|
314
|
-
[Going to production](/guides/production#2-add-
|
|
314
|
+
[Going to production](/guides/production#2-add-a-scheduler-for-handlers).
|
|
@@ -13,7 +13,8 @@ const events = await ordersServer.session(orderId).history()
|
|
|
13
13
|
Everything that happened in this session, oldest first. Always the raw log,
|
|
14
14
|
never a summary, never a snapshot. This is the session's audit trail and
|
|
15
15
|
debugging story, and it is also fine to use inside handlers for questions like
|
|
16
|
-
"did the shop already start?"
|
|
16
|
+
"did the shop already start?" Pass inclusive `gte` or `lte` indexes to read a
|
|
17
|
+
bounded slice.
|
|
17
18
|
|
|
18
19
|
## Reducers
|
|
19
20
|
|
|
@@ -86,24 +87,20 @@ export const ordersServer = createServer({
|
|
|
86
87
|
})
|
|
87
88
|
```
|
|
88
89
|
|
|
89
|
-
A reducer is a name (its identity, more on that below)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
`stateSchema`, or a failed cache read, falls back to the full log and rebuilds
|
|
104
|
-
from truth. `state()` is observational: it never runs handlers or waits for
|
|
105
|
-
pending work to finish. Inside a handler, the returned index includes the
|
|
106
|
-
triggering event and may include later events that committed before the read.
|
|
90
|
+
A reducer is a name (its identity, more on that below), `initialState`, an
|
|
91
|
+
optional `stateSchema`, and the pure `fold`, `(state, event) => state`. It is
|
|
92
|
+
derived from the contract, so the events type themselves; the two-step shape
|
|
93
|
+
fixes the state and event types before `.fold()` receives them. No type
|
|
94
|
+
arguments or annotations are needed, and literal unions survive the fold.
|
|
95
|
+
`state()` returns the result with `index`, the last log position included. The
|
|
96
|
+
fold includes every event through that index and none after it. The browser
|
|
97
|
+
resumes its live stream from that boundary. See [Live UI](/guides/react).
|
|
98
|
+
|
|
99
|
+
The snapshot and its remaining event tail come back in one consistent store
|
|
100
|
+
operation. A missing, invalid, or unreadable snapshot rebuilds from the full log.
|
|
101
|
+
`state()` is observational: it never runs handlers or waits for pending work.
|
|
102
|
+
Its index marks committed history, not handler completion. Inside a handler it
|
|
103
|
+
includes the trigger and may include later events committed before the read.
|
|
107
104
|
|
|
108
105
|
The read and a following `ctx.session.append(name, ...events)` are separate
|
|
109
106
|
operations. Concurrent appends and retries can move the frontier between them.
|
|
@@ -121,7 +118,7 @@ cache](/guides/local-first): a cached fold that fails the schema is
|
|
|
121
118
|
discarded and refolded, catching shape drift a stale `name` can't.
|
|
122
119
|
|
|
123
120
|
Note what these modules import: schemas and `experimental-a2`. Never `experimental-a2/server`,
|
|
124
|
-
never a
|
|
121
|
+
never a store backend. Contract and reducer are isomorphic by
|
|
125
122
|
construction; the browser runs the same reducer. More on the split in
|
|
126
123
|
[Live UI](/guides/react#keep-the-backend-out-of-the-bundle). (In a
|
|
127
124
|
server-only app you can keep the contract next to `createServer`
|
|
@@ -129,7 +126,7 @@ instead of in its own file.)
|
|
|
129
126
|
|
|
130
127
|
## Snapshots are a cache
|
|
131
128
|
|
|
132
|
-
Folding a long session on every read would get slow, so the
|
|
129
|
+
Folding a long session on every read would get slow, so the store backend
|
|
133
130
|
caches folded state as a snapshot. You never interact with it, except for
|
|
134
131
|
one string.
|
|
135
132
|
|