experimental-a2 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
  56. package/dist/store-redis-http.d.ts +21 -0
  57. package/dist/store-redis-http.js +70 -0
  58. package/dist/store-redis.d.ts +37 -0
  59. package/dist/store-redis.js +298 -0
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +4 -5
  69. package/docs/concepts/01-contracts.mdx +21 -17
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +26 -29
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -54
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +321 -60
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +22 -46
  84. package/docs/reference/01-api.mdx +751 -102
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -6
  87. package/dist/internal-D6wNxTck.js +0 -36
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/log-redis.d.ts +0 -31
  90. package/dist/recovery-vercel.js +0 -119
  91. package/dist/server-DJgD2YWP.js +0 -877
  92. package/dist/wire-BVsgR8o9.js +0 -62
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,4 @@
1
+ //#region src/testing.browser.ts
2
+ /** experimental-a2/testing loads local capture files and is Node-only. */
3
+ throw new Error("experimental-a2/testing is Node-only. Load captures in a local test or script.");
4
+ //#endregion
@@ -0,0 +1,31 @@
1
+ import { a as EventDefs, n as Contract, p as Reducer, r as ContractEvent } from "./contract-jIfaR085.js";
2
+ import { c as DevtoolsCapture, l as DevtoolsCaptureEvent } from "./devtools-J_jZ2vQf.js";
3
+ //#region src/testing.d.ts
4
+ type CaptureSource = string | URL;
5
+ type LoadedCapture<D extends EventDefs> = {
6
+ /** The verified capture, including its footer and operational records. */
7
+ readonly capture: DevtoolsCapture;
8
+ /** The contract used to validate and type the captured event history. */
9
+ readonly contract: Contract<D>;
10
+ /** Contract-typed public events with `createdAt` revived to `Date`. */
11
+ readonly events: readonly ContractEvent<D>[];
12
+ /** Unmodified wire events, including dispatch and failure metadata. */
13
+ readonly rawEvents: readonly DevtoolsCaptureEvent[];
14
+ };
15
+ type ReplayCaptureOptions = {
16
+ /** Fold events through this log index, inclusive. Zero means no events. */
17
+ throughIndex?: number;
18
+ };
19
+ type CaptureReplay<S> = {
20
+ readonly state: S;
21
+ /** The last event index actually folded, or zero for the initial state. */
22
+ readonly index: number;
23
+ };
24
+ /** Validate an already-parsed capture against an application's contract. */
25
+ declare function prepareCapture<D extends EventDefs>(capture: DevtoolsCapture, contract: Contract<D>): Promise<LoadedCapture<D>>;
26
+ /** Load, integrity-check, and contract-type a `.a2log` file or URL. */
27
+ declare function loadCapture<D extends EventDefs>(source: CaptureSource, contract: Contract<D>): Promise<LoadedCapture<D>>;
28
+ /** Purely fold captured events through a reducer, optionally to a cutpoint. */
29
+ declare function replayCapture<D extends EventDefs, S>(loaded: LoadedCapture<D>, reducer: Reducer<D, S>, options?: ReplayCaptureOptions): CaptureReplay<S>;
30
+ //#endregion
31
+ export { CaptureReplay, CaptureSource, LoadedCapture, ReplayCaptureOptions, loadCapture, prepareCapture, replayCapture };
@@ -0,0 +1,101 @@
1
+ import { n as validateSync } from "./validate-XKT4FSNn.js";
2
+ import { l as parseDevtoolsCapture, u as verifyDevtoolsCapture } from "./devtools-kJJaORn-.js";
3
+ import { readFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ //#region src/testing.ts
6
+ /**
7
+ * Offline helpers for loading a captured A2 session and replaying its events
8
+ * through the application's current reducers. This module never constructs a
9
+ * server or dispatches handlers.
10
+ */
11
+ const captureError = (message) => /* @__PURE__ */ new TypeError(`invalid A2 capture: ${message}`);
12
+ const typedEvents = (capture, contract) => {
13
+ const events = [];
14
+ for (const event of capture.events) {
15
+ const schema = Object.hasOwn(contract.events, event.type) ? contract.events[event.type] : void 0;
16
+ if (schema === void 0) throw captureError(`contract '${contract.name}' has no event type '${event.type}' at index ${event.index}`);
17
+ const result = validateSync(schema, structuredClone(event.payload), `captured event '${event.type}'`);
18
+ if (result.issues !== void 0) throw captureError(`event #${event.index} '${event.type}' has an invalid payload: ${result.issues.map((issue) => issue.message).join("; ")}`);
19
+ events.push({
20
+ id: event.id,
21
+ type: event.type,
22
+ payload: result.value,
23
+ index: event.index,
24
+ sessionId: event.sessionId,
25
+ createdAt: new Date(event.createdAt)
26
+ });
27
+ }
28
+ return events;
29
+ };
30
+ /** Validate an already-parsed capture against an application's contract. */
31
+ async function prepareCapture(capture, contract) {
32
+ await verifyDevtoolsCapture(capture);
33
+ if (capture.contract !== contract.name) throw captureError(`expected contract '${contract.name}', received '${capture.contract}'`);
34
+ return {
35
+ capture,
36
+ contract,
37
+ events: typedEvents(capture, contract),
38
+ rawEvents: capture.events
39
+ };
40
+ }
41
+ const displayUrl = (url) => {
42
+ if (url.protocol === "data:") return "data: URL";
43
+ const safe = new URL(url);
44
+ safe.username = "";
45
+ safe.password = "";
46
+ safe.search = "";
47
+ safe.hash = "";
48
+ return safe.href;
49
+ };
50
+ const fetchCapture = async (url) => {
51
+ const location = displayUrl(url);
52
+ let response;
53
+ try {
54
+ response = await fetch(url);
55
+ } catch {
56
+ throw new Error(`failed to load A2 capture from '${location}'`);
57
+ }
58
+ if (!response.ok) throw new Error(`failed to load A2 capture from '${location}': ${response.status} ${response.statusText}`);
59
+ return new Uint8Array(await response.arrayBuffer());
60
+ };
61
+ const readCapture = async (source) => {
62
+ if (source instanceof URL) return source.protocol === "file:" ? readFile(fileURLToPath(source)) : fetchCapture(source);
63
+ if (/^[a-z]:[\\/]/i.test(source)) return readFile(source);
64
+ let url;
65
+ try {
66
+ url = new URL(source);
67
+ } catch {
68
+ return readFile(source);
69
+ }
70
+ return readCapture(url);
71
+ };
72
+ /** Load, integrity-check, and contract-type a `.a2log` file or URL. */
73
+ async function loadCapture(source, contract) {
74
+ const encoded = await readCapture(source);
75
+ return prepareCapture(parseDevtoolsCapture(encoded), contract);
76
+ }
77
+ const cloneInitial = (initialState) => {
78
+ try {
79
+ return structuredClone(initialState);
80
+ } catch {
81
+ return initialState;
82
+ }
83
+ };
84
+ /** Purely fold captured events through a reducer, optionally to a cutpoint. */
85
+ function replayCapture(loaded, reducer, options = {}) {
86
+ const { throughIndex } = options;
87
+ if (throughIndex !== void 0 && (!Number.isInteger(throughIndex) || throughIndex < 0)) throw new TypeError("throughIndex must be a non-negative integer");
88
+ let state = cloneInitial(reducer.initialState);
89
+ let index = 0;
90
+ for (const event of loaded.events) {
91
+ if (throughIndex !== void 0 && event.index > throughIndex) break;
92
+ state = reducer.fold(state, event);
93
+ index = event.index;
94
+ }
95
+ return {
96
+ state,
97
+ index
98
+ };
99
+ }
100
+ //#endregion
101
+ export { loadCapture, prepareCapture, replayCapture };
@@ -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 };
@@ -20,8 +20,7 @@ npm i experimental-a2 zod
20
20
  A contract is a name plus the events it understands: each key an event
21
21
  name, each value a schema. Zod here, though any
22
22
  [Standard Schema](https://standardschema.dev) validator works, and
23
- that's the entire upfront declaration. No state list, no transition
24
- table.
23
+ that's the contract's complete declaration.
25
24
 
26
25
  ```ts server/orders.ts
27
26
  import { z } from 'zod'
@@ -41,7 +40,7 @@ implements it below, the reducer derives from it in step 6, and when a
41
40
  browser enters the picture it types the client too.
42
41
 
43
42
  There's no database setup in development: A2 stores events in SQLite at
44
- `.a2/dev.db`, so state survives restarts. Tests get an in-memory log.
43
+ `.a2/dev.db`, so state survives restarts. Tests get an in-memory store.
45
44
  Production wants an explicit backend; see
46
45
  [Going to production](/guides/production).
47
46
 
@@ -116,7 +115,7 @@ The payload is validated against the schema before anything is written;
116
115
  send garbage and you get `INVALID_PAYLOAD` back, with the issues.
117
116
 
118
117
  When the append returns, the event is durably in the log and A2 has started the
119
- `created` handler inline. In production, queue-backed recovery wakes pending
118
+ `created` handler inline. In production, a queue-backed scheduler wakes pending
120
119
  work after a failure or process death. See [Durability](/concepts/durability)
121
120
  for the exact contract.
122
121
 
@@ -209,6 +208,6 @@ infrastructure.
209
208
  The context object, chaining, ordering, and re-runs.
210
209
  </Card>
211
210
  <Card title="Going to production" href="/guides/production" icon="rocket">
212
- Postgres, queue-backed recovery, and dead-letter resolution.
211
+ Postgres, a queue-backed scheduler, and dead-letter resolution.
213
212
  </Card>
214
213
  </CardGroup>
@@ -41,10 +41,14 @@ schemas and their type helpers. The server implements it,
41
41
  [reducers](/concepts/state) derive from it, and the browser types its pushes
42
42
  off it. One artifact, shared by every side of the wire.
43
43
 
44
- And that's the entire declaration. No states, no transition table. What
45
- happens on each event is defined where the contract is served; what
46
- things look like right now is defined by [reducers](/concepts/state). The
47
- contract just names the vocabulary.
44
+ The contract names the complete vocabulary. What happens on each event is
45
+ defined where the contract is served. [Reducers](/concepts/state) define what
46
+ things look like right now.
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).
48
52
 
49
53
  :::tip
50
54
  Events are facts, so name them in past tense: `created`, `expired`,
@@ -56,17 +60,17 @@ convention, not a namespace.
56
60
  ## Serving a contract
57
61
 
58
62
  Storage and optional handlers bind to the contract with `createServer` from
59
- `experimental-a2/server`, the entry point that is allowed to touch a log backend
63
+ `experimental-a2/server`, the entry point that is allowed to touch a store backend
60
64
  (and the one that fails loudly if a browser bundle ever imports it):
61
65
 
62
66
  ```ts server/orders.ts
63
67
  import { createServer } from 'experimental-a2/server'
64
- import { postgres } from 'experimental-a2/log-postgres'
68
+ import { postgres } from 'experimental-a2/store-postgres'
65
69
  import { orders } from '@/contracts'
66
70
 
67
71
  export const ordersServer = createServer({
68
72
  contract: orders,
69
- log: postgres({ connectionString: process.env.DATABASE_URL }),
73
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
70
74
  handlers: {
71
75
  created: async ({ event }) => {
72
76
  // your side effect, then:
@@ -83,13 +87,13 @@ objects into `handlers`.
83
87
 
84
88
  | Entry point | Backend | Use it for |
85
89
  | ----------------- | --------- | ----------------------------------------- |
86
- | `experimental-a2/log-postgres` | Postgres | Production |
87
- | `experimental-a2/log-sqlite` | SQLite | Development: the default, at `.a2/dev.db` |
88
- | `experimental-a2/log-memory` | In-memory | Tests: the default when `NODE_ENV=test` |
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` |
89
93
 
90
- When you omit `log`, development gets SQLite (state survives `next dev`
94
+ When you omit `store`, development gets SQLite (state survives `next dev`
91
95
  restarts) and tests get memory. Production gets an error: the server
92
- throws `LOG_NOT_CONFIGURED` at startup rather than guess where your
96
+ throws `STORE_NOT_CONFIGURED` at startup rather than guess where your
93
97
  events should live. A failed boot beats events written to a filesystem
94
98
  that evaporates.
95
99
 
@@ -128,11 +132,11 @@ progress. Collections, joins, search, and constraints across many records
128
132
  usually belong in an application database. See
129
133
  [A2 and your database](/guides/application-data).
130
134
 
131
- ## One more option: recovery
135
+ ## One more option: a scheduler
132
136
 
133
- Servers accept a `recovery` option. When a top-level append leaves or finds
134
- pending handler work, recovery arms a durable, delayed drain. The handler
135
- starts without waiting for recovery, and a successful initial arm is durable
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
136
140
  before `append` returns. Event types without handlers settle during append and
137
- create no recovery work when the session has no older backlog. Covered in
141
+ create no scheduler work when the session has no older backlog. Covered in
138
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 recovery message.
35
+ dispatch claim or scheduler message.
36
36
 
37
- ```ts server/order-log.ts
37
+ ```ts server/order-history.ts
38
38
  import { createServer } from 'experimental-a2/server'
39
- import { postgres } from 'experimental-a2/log-postgres'
39
+ import { postgres } from 'experimental-a2/store-postgres'
40
40
  import { orders } from '@/contracts'
41
41
 
42
- export const orderLog = createServer({
42
+ export const orderHistory = createServer({
43
43
  contract: orders,
44
- log: postgres({ connectionString: process.env.DATABASE_URL }),
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`, active only with `abortOn`. See [Cancellation](/guides/cancellation). |
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. Recovery uses
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. When
9
- `append` returns, the event is in the log.
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 event order.
13
- - **Handlers are concurrent by default.** Independent events can run at the
14
- same time. Events in the same lane run one at a time in log order.
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
 
@@ -43,10 +43,9 @@ processed marker says what finished. The watchdog says when to look again.
43
43
  +-------------+ +-------------------+
44
44
  ```
45
45
 
46
- A queue message names a session. It carries no event index or continuation
47
- state. Every wakeup runs the same drain, and the log decides what remains.
48
- Recovery and explicit `server.drain()` inspect the full session. Reads never
49
- dispatch handlers.
46
+ A queue message identifies a session. Every wakeup runs the same drain, and
47
+ the log decides what remains. The scheduler and explicit `server.drain()` inspect
48
+ the full session. Reads never dispatch handlers.
50
49
 
51
50
  :::note[Recovery is not an event]
52
51
  A2 does not append `recovered` or `continued`. Recovery retries the same event.
@@ -161,25 +160,25 @@ limit or priority queue. Use lanes for ordered mutual exclusion. Put capacity
161
160
  limits near the constrained resource, such as an API client, connection pool,
162
161
  or execution runtime.
163
162
 
164
- ## Append and recovery
163
+ ## Append and scheduling
165
164
 
166
- A top-level append commits first. When pending handler work remains, it starts
167
- the inline drain and optional watchdog arm in parallel. The drain never waits
168
- for the queue. Append joins the arm for at most two seconds; on failure, its
169
- telemetry span records `a2.append.armed = false`, but committed work continues.
170
- `ctx.session.append` rides the active session drain and adds no arm. Returned
171
- events enter the log as part of completion, then become eligible immediately.
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.
172
171
 
173
172
  `session.append.dispatch(...events)` chooses the other execution path. It
174
173
  commits first, skips the current invocation's inline drain, and awaits an
175
- immediate recovery send. It requires configured recovery. Queue delivery then
174
+ immediate scheduler send. It requires a configured scheduler. Queue delivery then
176
175
  claims the same durable events through the normal drain path. If the send
177
176
  fails, retry with the same explicit event IDs; the append itself may already
178
- have committed. The recovery message is a wakeup, not worker affinity. An
177
+ have committed. The scheduler message is a wakeup, not worker affinity. An
179
178
  already-active drain may claim the new work first.
180
179
 
181
180
  An event type without a handler settles in the append transaction with no
182
- dispatch attempt. It starts no drain or recovery arm when the session has no
181
+ dispatch attempt. It starts no drain or scheduler arm when the session has no
183
182
  older pending work. If older handled work is pending, the append still wakes
184
183
  the session-wide healing path.
185
184
 
@@ -195,7 +194,7 @@ The edge is part of the existing append batch and survives process restarts.
195
194
  The stored event also carries `lane`, `firstClaimedAt`, `lastClaimedAt`,
196
195
  `claimHolder`, `claimExpiresAt`, `lastFailedAt`, `lastFailedAttempt`,
197
196
  `processedAt`, `processedByAttempt`, and `returnedEventIds`. Timestamps are
198
- adapter clock values captured for atomic log operations, not exact database
197
+ adapter clock values captured for atomic store operations, not exact database
199
198
  commit times. A stale worker cannot rewrite a newer completion. The durable
200
199
  log can rebuild a causal forest and useful lifecycle boundaries without one
201
200
  row per attempt.
@@ -239,11 +238,9 @@ Four eight-second handlers returned one after another do not fit together in a
239
238
  | t29 | Handler 3 finishes; handler 4 starts | Events 1 through 3 processed |
240
239
  | t37 | Handler 4 finishes | Session settled |
241
240
 
242
- The retry restarts handler 3; it does not resume its old call.
243
-
244
- One handler that always exceeds a fresh invocation cannot finish this way. A2
245
- cannot checkpoint arbitrary async code. Split the work into smaller events or
246
- increase the function duration.
241
+ The retry starts handler 3 again from its entry point. Keep each handler within
242
+ a fresh invocation. Split longer work into smaller events or increase the
243
+ function duration.
247
244
 
248
245
  ## Crash around a returned batch
249
246
 
@@ -302,9 +299,9 @@ Resolution is manual: fix and retry, or skip.
302
299
  | Situation | Result |
303
300
  | --- | --- |
304
301
  | Process dies before the first arm is durable | Retry with the same ids, append again later, or call `drain()`. |
305
- | No recovery configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
306
- | Recovery dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
307
- | Log backend unavailable | Safe progress stops until it returns. |
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. |
308
305
  | External effect succeeds before A2 completion | The handler may repeat it. Use idempotency or external fencing. |
309
306
 
310
307
  ## What append never throws for
@@ -314,4 +311,4 @@ identical batch retry returns rows with the same ids. A mixed batch throws
314
311
  [`PARTIAL_DUPLICATE_BATCH`](/reference/errors).
315
312
 
316
313
  For the queue route and deployment configuration, see
317
- [Going to production](/guides/production#2-add-recovery).
314
+ [Going to production](/guides/production#2-add-a-scheduler-for-handlers).