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.
- package/CHANGELOG.md +169 -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-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- 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.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- 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 +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- 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 +321 -60
- 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 +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -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 };
|
package/dist/testing.js
ADDED
|
@@ -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 };
|
package/docs/01-quickstart.mdx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
things look like right now
|
|
47
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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/
|
|
87
|
-
| `experimental-a2/
|
|
88
|
-
| `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` |
|
|
89
93
|
|
|
90
|
-
When you omit `
|
|
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 `
|
|
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:
|
|
135
|
+
## One more option: a scheduler
|
|
132
136
|
|
|
133
|
-
Servers accept a `
|
|
134
|
-
pending handler work,
|
|
135
|
-
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
|
|
136
140
|
before `append` returns. Event types without handlers settle during append and
|
|
137
|
-
create no
|
|
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
|
|
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
|
|
|
@@ -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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
163
|
+
## Append and scheduling
|
|
165
164
|
|
|
166
|
-
A top-level append commits first.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
`ctx.session.append` rides the active
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
306
|
-
|
|
|
307
|
-
|
|
|
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-
|
|
314
|
+
[Going to production](/guides/production#2-add-a-scheduler-for-handlers).
|