experimental-a2 0.0.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 +128 -0
- package/dist/ai-server.browser.d.ts +1 -0
- package/dist/ai-server.browser.js +4 -0
- package/dist/ai-server.d.ts +65 -0
- package/dist/ai-server.js +494 -0
- package/dist/ai.d.ts +282 -0
- package/dist/ai.js +922 -0
- package/dist/cache-indexeddb.d.ts +1 -0
- package/dist/cache-indexeddb.js +0 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.js +410 -0
- package/dist/contract-B0kAXoaL.js +60 -0
- package/dist/contract-DL8btVd9.d.ts +161 -0
- package/dist/devtools-server.browser.d.ts +1 -0
- package/dist/devtools-server.browser.js +4 -0
- package/dist/devtools-server.d.ts +22 -0
- package/dist/devtools-server.js +1087 -0
- package/dist/errors-BJRMd-h6.js +23 -0
- package/dist/errors-xL_JTXsY.d.ts +20 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +119 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/inspection-E7qbD0Xj.js +10 -0
- package/dist/internal-Dm8Ejnud.js +36 -0
- package/dist/log-Dg1I8NRr.d.ts +245 -0
- package/dist/log-memory.d.ts +11 -0
- package/dist/log-memory.js +345 -0
- package/dist/log-polling-RO7kclzR.js +83 -0
- package/dist/log-postgres.d.ts +40 -0
- package/dist/log-postgres.js +628 -0
- package/dist/log-redis.d.ts +31 -0
- package/dist/log-redis.js +711 -0
- package/dist/log-sqlite.d.ts +17 -0
- package/dist/log-sqlite.js +450 -0
- package/dist/log-yJbXUf72.js +5 -0
- package/dist/otel.d.ts +12 -0
- package/dist/otel.js +41 -0
- package/dist/react.d.ts +54 -0
- package/dist/react.js +85 -0
- package/dist/recovery-vercel.d.ts +60 -0
- package/dist/recovery-vercel.js +120 -0
- package/dist/retryable-lazy-DZWmHpii.js +19 -0
- package/dist/server-DYsnKTTy.js +780 -0
- package/dist/server.browser.d.ts +1 -0
- package/dist/server.browser.js +11 -0
- package/dist/server.d.ts +136 -0
- package/dist/server.js +2 -0
- package/dist/telemetry-C78al20p.d.ts +32 -0
- package/dist/validate-XKT4FSNn.js +28 -0
- package/dist/wire-2QpU1EtJ.js +62 -0
- package/docs/01-quickstart.mdx +214 -0
- package/docs/concepts/01-contracts.mdx +138 -0
- package/docs/concepts/02-handlers.mdx +146 -0
- package/docs/concepts/03-durability.mdx +230 -0
- package/docs/concepts/04-state.mdx +133 -0
- package/docs/guides/01-timers.mdx +85 -0
- package/docs/guides/02-cancellation.mdx +107 -0
- package/docs/guides/03-react.mdx +234 -0
- package/docs/guides/04-local-first.mdx +88 -0
- package/docs/guides/05-production.mdx +179 -0
- package/docs/guides/06-ai-agents.mdx +659 -0
- package/docs/guides/07-devtools.mdx +101 -0
- package/docs/guides/08-application-data.mdx +114 -0
- package/docs/index.mdx +282 -0
- package/docs/reference/01-api.mdx +637 -0
- package/docs/reference/02-errors.mdx +77 -0
- package/package.json +111 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/server.browser.ts
|
|
2
|
+
/**
|
|
3
|
+
* The browser build of a2/server. There isn't one — on purpose.
|
|
4
|
+
* Contracts and reducers (from `a2`) and the session client (from
|
|
5
|
+
* `a2/client` / `a2/react`) are the isomorphic surface; the server is
|
|
6
|
+
* where handlers and storage live. This module existing in a client
|
|
7
|
+
* bundle means a `'use client'` file (or something it imports)
|
|
8
|
+
* value-imported your server module.
|
|
9
|
+
*/
|
|
10
|
+
throw new Error("a2/server is server-only — a client bundle imported it. Import the contract (a2) or the session client (a2/client, a2/react) in browser code instead.");
|
|
11
|
+
//#endregion
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { i as EventDefs, n as Contract, r as ContractEvent, s as Reducer, t as AppendInput } from "./contract-DL8btVd9.js";
|
|
2
|
+
import { a as Event, c as IdSource, d as LogStateRead, f as StoredEvent, h as StoredSnapshot, i as Clock, l as LogClaimResult, m as StoredSessionSummary, n as A2LogInspection, o as EventCause, p as StoredSessionPage, r as AppendEvent, s as FailAttemptResult, t as A2Log, u as LogHandoffResult } from "./log-Dg1I8NRr.js";
|
|
3
|
+
import { i as A2Telemetry } from "./telemetry-C78al20p.js";
|
|
4
|
+
//#region src/server.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Events that arrived over the wire through `parsePushBody` — already
|
|
7
|
+
* envelope-validated, headed for schema validation inside `append`.
|
|
8
|
+
* The brand lets the documented push route hand them straight to
|
|
9
|
+
* `session.append` without weakening typed appends for app code: a
|
|
10
|
+
* hand-written `{ type: string }` literal still fails to compile.
|
|
11
|
+
*/
|
|
12
|
+
type PushedEvent = {
|
|
13
|
+
type: string;
|
|
14
|
+
payload: unknown;
|
|
15
|
+
id?: string;
|
|
16
|
+
readonly "~a2.pushed": true;
|
|
17
|
+
};
|
|
18
|
+
/** What every handler receives. */
|
|
19
|
+
type HandlerContext<D extends EventDefs, K extends keyof D & string = keyof D & string> = {
|
|
20
|
+
/** The triggering event. */
|
|
21
|
+
event: ContractEvent<D, K>;
|
|
22
|
+
/** Durable, 1-based dispatch ordinal for this event. */
|
|
23
|
+
attempt: number;
|
|
24
|
+
/** Append what happens next to this session's log. Atomic per call. */
|
|
25
|
+
append: (...events: AppendInput<D>[]) => Promise<ContractEvent<D>[]>;
|
|
26
|
+
/** Every past event in this session, oldest first. Always the raw log. */
|
|
27
|
+
history: () => Promise<ContractEvent<D>[]>;
|
|
28
|
+
/** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
};
|
|
31
|
+
type Handler<D extends EventDefs, K extends keyof D & string = keyof D & string> = (ctx: HandlerContext<D, K>) => Promise<void>;
|
|
32
|
+
/** A handle on one instance of the machine. Creating it does no I/O. */
|
|
33
|
+
type Session<D extends EventDefs> = {
|
|
34
|
+
readonly sessionId: string;
|
|
35
|
+
append: {
|
|
36
|
+
(...events: AppendInput<D>[]): Promise<ContractEvent<D>[]>;
|
|
37
|
+
/** The push-route path: events from `parsePushBody`. */
|
|
38
|
+
(...events: PushedEvent[]): Promise<ContractEvent<D>[]>;
|
|
39
|
+
};
|
|
40
|
+
history(): Promise<ContractEvent<D>[]>;
|
|
41
|
+
state<S>(reducer: Reducer<D, S>): Promise<{
|
|
42
|
+
state: S;
|
|
43
|
+
index: number;
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* A live feed of this session's events, starting after `startAt`
|
|
47
|
+
* (exclusive). Server-side only — expose it over SSE with
|
|
48
|
+
* `sseResponse` from a2/http.
|
|
49
|
+
*/
|
|
50
|
+
stream(opts?: {
|
|
51
|
+
startAt?: number;
|
|
52
|
+
}): AsyncIterable<ContractEvent<D>>;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* What recovery needs from a server — structural, so servers of any
|
|
56
|
+
* contract mix in one `handler(...)` call. Deliberately tiny: recovery
|
|
57
|
+
* consults the log through `drain` and nothing else.
|
|
58
|
+
*/
|
|
59
|
+
type RecoverableServer = {
|
|
60
|
+
readonly contract: {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
};
|
|
63
|
+
drain(sessionId: string): Promise<{
|
|
64
|
+
settled: boolean;
|
|
65
|
+
}>;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The recovery seam (a2-implementation.md §7, §9). `arm` puts a
|
|
69
|
+
* delayed, coalesced "drain this session" message on durable
|
|
70
|
+
* infrastructure while inline work starts; `handler` returns the route
|
|
71
|
+
* the transport delivers to. Lease holders move the watchdog alongside
|
|
72
|
+
* their renewable execution window. Implementations ship as
|
|
73
|
+
* entry points (`a2/recovery-vercel`); core never imports a transport.
|
|
74
|
+
*/
|
|
75
|
+
type A2Recovery = {
|
|
76
|
+
arm(opts: {
|
|
77
|
+
contract: string;
|
|
78
|
+
sessionId: string;
|
|
79
|
+
/** Earliest useful delivery as epoch milliseconds. */
|
|
80
|
+
dueAt?: number;
|
|
81
|
+
}): Promise<void>;
|
|
82
|
+
handler(...servers: RecoverableServer[]): (req: Request) => Promise<Response>;
|
|
83
|
+
};
|
|
84
|
+
type A2Server<D extends EventDefs> = {
|
|
85
|
+
/** The contract this server implements. */
|
|
86
|
+
readonly contract: Contract<D>;
|
|
87
|
+
session(id: string): Session<D>;
|
|
88
|
+
/**
|
|
89
|
+
* Process the session's pending events in order. `settled` means the
|
|
90
|
+
* drain left nothing actionable: every event processed, or the
|
|
91
|
+
* session is stalled at a dead-lettered event (which recovery
|
|
92
|
+
* transports treat as "stop redelivering").
|
|
93
|
+
*/
|
|
94
|
+
drain(sessionId: string): Promise<{
|
|
95
|
+
settled: boolean;
|
|
96
|
+
}>;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Which events fire `ctx.signal` while a handler runs — the preemption
|
|
100
|
+
* channel for user cancellation. The array form matches by type; the
|
|
101
|
+
* object form takes per-type predicates for targeted cancellation
|
|
102
|
+
* (`(event, trigger) => event.payload.of === trigger.id`). Handlers
|
|
103
|
+
* without `abortOn` pay nothing. An aborted handler should catch and
|
|
104
|
+
* return normally; throwing means "retry me".
|
|
105
|
+
*/
|
|
106
|
+
type AbortSpec<D extends EventDefs, K extends keyof D & string> = Array<keyof D & string> | { [T in keyof D & string]?: true | ((event: ContractEvent<D, T>, trigger: ContractEvent<D, K>) => boolean); };
|
|
107
|
+
type HandlerEntry<D extends EventDefs, K extends keyof D & string = keyof D & string> = Handler<D, K> | {
|
|
108
|
+
abortOn: AbortSpec<D, K>;
|
|
109
|
+
handler: Handler<D, K>;
|
|
110
|
+
};
|
|
111
|
+
type ServerOptions<D extends EventDefs> = {
|
|
112
|
+
/** The contract this server implements (see `a2.contract`). */
|
|
113
|
+
contract: Contract<D>;
|
|
114
|
+
/** Where events live. Defaults: sqlite in dev, memory in tests, required in prod. */
|
|
115
|
+
log?: A2Log;
|
|
116
|
+
/**
|
|
117
|
+
* Queue-backed recovery — e.g. `vercelQueues()` from
|
|
118
|
+
* `a2/recovery-vercel`. Absent means append-driven healing only: a
|
|
119
|
+
* working configuration, but a clockless one. Recommended in
|
|
120
|
+
* production.
|
|
121
|
+
*/
|
|
122
|
+
recovery?: A2Recovery;
|
|
123
|
+
/** Optional instrumentation — e.g. `otel()` from `a2/otel`. */
|
|
124
|
+
telemetry?: A2Telemetry;
|
|
125
|
+
/**
|
|
126
|
+
* The reactions, keyed by event type — all present at construction,
|
|
127
|
+
* so a handler can never be silently missing because its module
|
|
128
|
+
* wasn't imported. Compose across files by spreading objects (note:
|
|
129
|
+
* a duplicate key under spread silently last-wins).
|
|
130
|
+
*/
|
|
131
|
+
handlers?: { [K in keyof D & string]?: HandlerEntry<D, K>; };
|
|
132
|
+
};
|
|
133
|
+
/** Implement a contract: bind its vocabulary to storage and reactions. */
|
|
134
|
+
declare function createServer<D extends EventDefs>(options: ServerOptions<D>): A2Server<D>;
|
|
135
|
+
//#endregion
|
|
136
|
+
export { type A2Log, type A2LogInspection, A2Recovery, A2Server, AbortSpec, type AppendEvent, type AppendInput, type Clock, type Contract, type ContractEvent, type Event, type EventCause, type EventDefs, type FailAttemptResult, Handler, HandlerContext, HandlerEntry, type IdSource, type LogClaimResult, type LogHandoffResult, type LogStateRead, PushedEvent, RecoverableServer, ServerOptions, Session, type StoredEvent, type StoredSessionPage, type StoredSessionSummary, type StoredSnapshot, createServer };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/telemetry.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The A2Telemetry interface — the instrumentation seam. Same philosophy
|
|
4
|
+
* as log backends: the interface lives in core, implementations ship as
|
|
5
|
+
* entry points (`a2/otel` adapts it to OpenTelemetry). Without one,
|
|
6
|
+
* every operation runs through a no-op wrapper.
|
|
7
|
+
*/
|
|
8
|
+
/** Attribute values a2 emits. */
|
|
9
|
+
type A2AttributeValue = string | number | boolean;
|
|
10
|
+
/** The spans a2 emits today. The catalogue grows with the surface. */
|
|
11
|
+
type A2SpanName = "a2.append" | "a2.drain" | "a2.event" | "a2.state";
|
|
12
|
+
type A2SpanHandle = {
|
|
13
|
+
/** Attach or update an attribute mid-span (e.g. the outcome). */
|
|
14
|
+
setAttribute(key: string, value: A2AttributeValue): void;
|
|
15
|
+
/**
|
|
16
|
+
* Mark the span failed without a2's control flow throwing — used for
|
|
17
|
+
* handler failures, which a2 swallows by design (they go into the
|
|
18
|
+
* retry machinery, not up the stack) but telemetry must still see.
|
|
19
|
+
*/
|
|
20
|
+
recordError(error: unknown): void;
|
|
21
|
+
};
|
|
22
|
+
type A2Telemetry = {
|
|
23
|
+
/**
|
|
24
|
+
* Wrap one unit of a2 work. Implementations should time it, record a
|
|
25
|
+
* thrown error as a failure, propagate context so nested spans tree
|
|
26
|
+
* up, and always return `fn`'s result (or rethrow its error) —
|
|
27
|
+
* telemetry observes, it never alters behavior.
|
|
28
|
+
*/
|
|
29
|
+
span<T>(name: A2SpanName, attributes: Record<string, A2AttributeValue>, fn: (span: A2SpanHandle) => Promise<T>): Promise<T>;
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { A2Telemetry as i, A2SpanHandle as n, A2SpanName as r, A2AttributeValue as t };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/validate.ts
|
|
2
|
+
/**
|
|
3
|
+
* Run a Standard Schema validator, requiring a synchronous result.
|
|
4
|
+
* Validators must be synchronous (specs/a2-api.md §1) — an async one is
|
|
5
|
+
* a definition bug, rejected at definition time where possible and here
|
|
6
|
+
* as a backstop. `what` names the schema in error messages, e.g.
|
|
7
|
+
* "event 'created'" or "the stateSchema".
|
|
8
|
+
*/
|
|
9
|
+
function validateSync(schema, value, what) {
|
|
10
|
+
const result = schema["~standard"].validate(value);
|
|
11
|
+
if (result instanceof Promise) throw new TypeError(`the schema for ${what} is asynchronous — a2 schemas must validate synchronously`);
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Probe a schema to reject always-async validators early. The probe
|
|
16
|
+
* value is deliberately garbage — we only care whether the result is a
|
|
17
|
+
* Promise, not whether it passes.
|
|
18
|
+
*/
|
|
19
|
+
function assertSyncSchema(schema, what) {
|
|
20
|
+
const probe = Symbol("a2.schema-probe");
|
|
21
|
+
const result = schema["~standard"].validate(probe);
|
|
22
|
+
if (result instanceof Promise) {
|
|
23
|
+
result.catch(() => {});
|
|
24
|
+
throw new TypeError(`the schema for ${what} is asynchronous — a2 schemas must validate synchronously`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { validateSync as n, assertSyncSchema as t };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { t as A2Error } from "./errors-BJRMd-h6.js";
|
|
2
|
+
//#region src/wire.ts
|
|
3
|
+
/**
|
|
4
|
+
* The wire format shared by a2/http (server side) and a2/client
|
|
5
|
+
* (browser side): events as JSON with ISO timestamps, and the A2Error
|
|
6
|
+
* envelope. Internal module — both entry points re-export what's
|
|
7
|
+
* public.
|
|
8
|
+
*/
|
|
9
|
+
function eventToWire(event) {
|
|
10
|
+
return {
|
|
11
|
+
id: event.id,
|
|
12
|
+
type: event.type,
|
|
13
|
+
payload: event.payload,
|
|
14
|
+
index: event.index,
|
|
15
|
+
sessionId: event.sessionId,
|
|
16
|
+
createdAt: event.createdAt.toISOString()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function eventFromWire(wire) {
|
|
20
|
+
return {
|
|
21
|
+
id: wire.id,
|
|
22
|
+
type: wire.type,
|
|
23
|
+
payload: wire.payload,
|
|
24
|
+
index: wire.index,
|
|
25
|
+
sessionId: wire.sessionId,
|
|
26
|
+
createdAt: new Date(wire.createdAt)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function isWireEvent(value) {
|
|
30
|
+
if (value === null || typeof value !== "object") return false;
|
|
31
|
+
const v = value;
|
|
32
|
+
return typeof v["id"] === "string" && typeof v["type"] === "string" && typeof v["index"] === "number" && typeof v["sessionId"] === "string" && typeof v["createdAt"] === "string";
|
|
33
|
+
}
|
|
34
|
+
const ERROR_STATUS = {
|
|
35
|
+
INVALID_PAYLOAD: 400,
|
|
36
|
+
UNKNOWN_EVENT_TYPE: 400,
|
|
37
|
+
PARTIAL_DUPLICATE_BATCH: 400,
|
|
38
|
+
LOG_UNAVAILABLE: 503,
|
|
39
|
+
LOG_NOT_CONFIGURED: 500
|
|
40
|
+
};
|
|
41
|
+
function errorStatus(code) {
|
|
42
|
+
return ERROR_STATUS[code];
|
|
43
|
+
}
|
|
44
|
+
function errorToWire(error) {
|
|
45
|
+
const body = { error: {
|
|
46
|
+
code: error.code,
|
|
47
|
+
message: error.message
|
|
48
|
+
} };
|
|
49
|
+
if (error.details !== void 0) body.error.details = error.details;
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
/** Rebuild an A2Error from a wire body; null if the body isn't one. */
|
|
53
|
+
function errorFromWire(body) {
|
|
54
|
+
if (body === null || typeof body !== "object") return null;
|
|
55
|
+
const err = body.error;
|
|
56
|
+
if (err === null || typeof err !== "object") return null;
|
|
57
|
+
const { code, message, details } = err;
|
|
58
|
+
if (typeof code !== "string" || !(code in ERROR_STATUS)) return null;
|
|
59
|
+
return new A2Error(code, String(message ?? code), { details });
|
|
60
|
+
}
|
|
61
|
+
//#endregion
|
|
62
|
+
export { eventToWire as a, eventFromWire as i, errorStatus as n, isWireEvent as o, errorToWire as r, errorFromWire as t };
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Quickstart
|
|
3
|
+
description: Define one lifecycle, implement its reactions, and make your first durable append in about five minutes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## What you'll build
|
|
7
|
+
|
|
8
|
+
The smallest useful lifecycle: an order that notifies a shop when it arrives.
|
|
9
|
+
One contract, one session, one handler, two routes, and at the end a log you
|
|
10
|
+
can read back.
|
|
11
|
+
|
|
12
|
+
## 1. Install
|
|
13
|
+
|
|
14
|
+
```package-install
|
|
15
|
+
npm i experimental-a2 zod
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 2. Define a contract
|
|
19
|
+
|
|
20
|
+
A contract is a name plus the events it understands: each key an event
|
|
21
|
+
name, each value a schema. Zod here, though any
|
|
22
|
+
[Standard Schema](https://standardschema.dev) validator works, and
|
|
23
|
+
that's the entire upfront declaration. No state list, no transition
|
|
24
|
+
table.
|
|
25
|
+
|
|
26
|
+
```ts server/orders.ts
|
|
27
|
+
import { z } from 'zod'
|
|
28
|
+
import * as a2 from 'experimental-a2'
|
|
29
|
+
|
|
30
|
+
export const orders = a2.contract({
|
|
31
|
+
name: 'orders',
|
|
32
|
+
events: {
|
|
33
|
+
'created': z.object({ shopId: z.string(), items: z.array(z.string()) }),
|
|
34
|
+
'shop.notified': z.object({}),
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The contract is a plain, importable, isomorphic value. The server
|
|
40
|
+
implements it below, the reducer derives from it in step 6, and when a
|
|
41
|
+
browser enters the picture it types the client too.
|
|
42
|
+
|
|
43
|
+
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.
|
|
45
|
+
Production wants an explicit backend; see
|
|
46
|
+
[Going to production](/guides/production).
|
|
47
|
+
|
|
48
|
+
## 3. Implement it
|
|
49
|
+
|
|
50
|
+
Handlers run where the contract is served. `createServer` binds the
|
|
51
|
+
vocabulary to storage and reactions; each handler gets the triggering
|
|
52
|
+
event and can append what happens next. Same file, below the contract
|
|
53
|
+
(they can split later, when a browser needs the contract alone):
|
|
54
|
+
|
|
55
|
+
```ts server/orders.ts
|
|
56
|
+
import { createServer } from 'experimental-a2/server'
|
|
57
|
+
|
|
58
|
+
export const ordersServer = createServer({
|
|
59
|
+
contract: orders,
|
|
60
|
+
handlers: {
|
|
61
|
+
created: async ({ event, append }) => {
|
|
62
|
+
// your side effect, e.g. email the shop, idempotent via event.id:
|
|
63
|
+
// await sendEmailToShop(event.payload, { idempotencyKey: event.id })
|
|
64
|
+
await append({ type: 'shop.notified', payload: {} })
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
All handlers are declared at construction: one table of reactions, so a
|
|
71
|
+
handler can never be silently missing because the module that registered
|
|
72
|
+
it wasn't imported.
|
|
73
|
+
|
|
74
|
+
Two things worth noticing:
|
|
75
|
+
|
|
76
|
+
- The side effect takes an idempotency key. Handlers can run more than
|
|
77
|
+
once (that's what at-least-once delivery means), so external calls
|
|
78
|
+
should be safe to repeat. `event.id` is stable across re-runs. Use it.
|
|
79
|
+
- The `append` inside a handler is already deduplicated. If the handler
|
|
80
|
+
crashes after appending and runs again, the second append is a no-op,
|
|
81
|
+
[here's how](/concepts/handlers#handlers-can-run-twice).
|
|
82
|
+
|
|
83
|
+
## 4. Append the first event
|
|
84
|
+
|
|
85
|
+
`append` is the entire public surface for moving a session forward. Call it
|
|
86
|
+
from a route:
|
|
87
|
+
|
|
88
|
+
```ts app/api/orders/route.ts
|
|
89
|
+
import { ordersServer } from '@/server/orders'
|
|
90
|
+
|
|
91
|
+
export async function POST(req: Request) {
|
|
92
|
+
const { orderId, shopId, items } = await req.json()
|
|
93
|
+
|
|
94
|
+
await ordersServer.session(orderId).append({
|
|
95
|
+
type: 'created',
|
|
96
|
+
payload: { shopId, items },
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return Response.json({ ok: true })
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A session is one instance of the contract. `ordersServer.session('order-42')`
|
|
104
|
+
is order 42's lifecycle: the unit A2 orders, recovers, folds, and streams.
|
|
105
|
+
Creating the handle does no I/O, and the id is yours to choose.
|
|
106
|
+
|
|
107
|
+
Try it:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
curl -X POST localhost:3000/api/orders \
|
|
111
|
+
-H 'content-type: application/json' \
|
|
112
|
+
-d '{ "orderId": "order-42", "shopId": "shop-7", "items": ["flat white"] }'
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The payload is validated against the schema before anything is written;
|
|
116
|
+
send garbage and you get `INVALID_PAYLOAD` back, with the issues.
|
|
117
|
+
|
|
118
|
+
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
|
|
120
|
+
work after a failure or process death. See [Durability](/concepts/durability)
|
|
121
|
+
for the exact contract.
|
|
122
|
+
|
|
123
|
+
## 5. Read the log back
|
|
124
|
+
|
|
125
|
+
Add a route that returns the session's history:
|
|
126
|
+
|
|
127
|
+
```ts app/api/orders/[orderId]/route.ts
|
|
128
|
+
import { ordersServer } from '@/server/orders'
|
|
129
|
+
|
|
130
|
+
export async function GET(
|
|
131
|
+
_req: Request,
|
|
132
|
+
{ params }: { params: Promise<{ orderId: string }> },
|
|
133
|
+
) {
|
|
134
|
+
const { orderId } = await params
|
|
135
|
+
const events = await ordersServer.session(orderId).history()
|
|
136
|
+
return Response.json(events)
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
curl localhost:3000/api/orders/order-42
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
[
|
|
146
|
+
{ "index": 1, "type": "created", "payload": { "shopId": "shop-7", "items": ["flat white"] } },
|
|
147
|
+
{ "index": 2, "type": "shop.notified", "payload": {} }
|
|
148
|
+
]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
(Trimmed for brevity; each event also carries `id`, `sessionId`, and
|
|
152
|
+
`createdAt`.)
|
|
153
|
+
|
|
154
|
+
That second event is the payoff. You appended one event; the handler
|
|
155
|
+
appended the next; the log shows the whole story, in order. This is what
|
|
156
|
+
debugging looks like in A2: you read.
|
|
157
|
+
|
|
158
|
+
## 6. Fold state out of the log
|
|
159
|
+
|
|
160
|
+
Raw session history is truth, but UIs usually want a view. Define a reducer
|
|
161
|
+
once:
|
|
162
|
+
|
|
163
|
+
```ts reducer.ts
|
|
164
|
+
import { orders } from '@/server/orders'
|
|
165
|
+
|
|
166
|
+
export const ordersReducer = orders
|
|
167
|
+
.reducer({ name: 'order-status', initialState: { status: 'new' } })
|
|
168
|
+
.fold((state, event) => {
|
|
169
|
+
switch (event.type) {
|
|
170
|
+
case 'created':
|
|
171
|
+
return { ...state, status: 'created' }
|
|
172
|
+
case 'shop.notified':
|
|
173
|
+
return { ...state, status: 'notified' }
|
|
174
|
+
default:
|
|
175
|
+
return state
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
A reducer is *derived from* the contract, in two steps by design: the
|
|
181
|
+
first call fixes the state and event types, so `.fold()` receives fully
|
|
182
|
+
concrete ones. No type arguments, no annotations, and literal unions in
|
|
183
|
+
your state survive the fold.
|
|
184
|
+
|
|
185
|
+
And fold anywhere you need the current picture:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
// anywhere on the server:
|
|
189
|
+
import { ordersServer } from '@/server/orders'
|
|
190
|
+
import { ordersReducer } from '@/reducer'
|
|
191
|
+
|
|
192
|
+
const { state } = await ordersServer.session('order-42').state(ordersReducer)
|
|
193
|
+
// state → { status: 'notified' }
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The session's state is not a second record to update. It is a fold over the
|
|
197
|
+
log. More in [Reading state](/concepts/state).
|
|
198
|
+
|
|
199
|
+
## That's it
|
|
200
|
+
|
|
201
|
+
You have a durable lifecycle, implemented: a validated write path, a handler,
|
|
202
|
+
a log you can read, and a view folded from it.
|
|
203
|
+
Everything else in these docs is the same two moves, append an event and
|
|
204
|
+
react to it, applied to timers, cancellation, live UIs, and production
|
|
205
|
+
infrastructure.
|
|
206
|
+
|
|
207
|
+
<CardGroup cols={2}>
|
|
208
|
+
<Card title="Handlers" href="/concepts/handlers" icon="file">
|
|
209
|
+
The context object, chaining, ordering, and re-runs.
|
|
210
|
+
</Card>
|
|
211
|
+
<Card title="Going to production" href="/guides/production" icon="rocket">
|
|
212
|
+
Postgres, queue-backed recovery, and dead-letter resolution.
|
|
213
|
+
</Card>
|
|
214
|
+
</CardGroup>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Contracts and sessions
|
|
3
|
+
description: A contract names one kind of lifecycle. A session is one instance, with its own history, ordering, state, and live stream.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Contracts
|
|
7
|
+
|
|
8
|
+
A2 has two nouns. A **contract** defines what can happen in one kind of
|
|
9
|
+
lifecycle. A **session** is one instance of it.
|
|
10
|
+
|
|
11
|
+
```ts contracts.ts
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import * as a2 from 'experimental-a2'
|
|
14
|
+
|
|
15
|
+
export const orders = a2.contract({
|
|
16
|
+
name: 'orders',
|
|
17
|
+
events: {
|
|
18
|
+
created: z.object({ shopId: z.string(), items: z.array(z.string()) }),
|
|
19
|
+
'shop.notified': z.object({}),
|
|
20
|
+
'shop.started': z.object({}),
|
|
21
|
+
'driver.notified': z.object({ driverId: z.string() }),
|
|
22
|
+
expired: z.object({}),
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The name identifies the contract everywhere: in storage, in queue
|
|
28
|
+
messages, in logs. It's what keeps an app with several contracts
|
|
29
|
+
unambiguous.
|
|
30
|
+
|
|
31
|
+
`events` is the contract's full vocabulary. Each key is an event name;
|
|
32
|
+
each value is a schema for its payload. Zod in these docs, but any
|
|
33
|
+
[Standard Schema](https://standardschema.dev) library works: Valibot,
|
|
34
|
+
ArkType, your own. Every append is validated against it: a bad payload
|
|
35
|
+
throws `INVALID_PAYLOAD` before anything is written, an unknown type
|
|
36
|
+
throws `UNKNOWN_EVENT_TYPE`. What lands in the log is the validated
|
|
37
|
+
output, with defaults applied and transforms run.
|
|
38
|
+
|
|
39
|
+
The contract is a plain, importable, **isomorphic** value. It carries
|
|
40
|
+
nothing but schemas. The server implements it, [reducers](/concepts/state)
|
|
41
|
+
derive from it, and the browser types its pushes off it. One artifact,
|
|
42
|
+
shared by every side of the wire.
|
|
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.
|
|
48
|
+
|
|
49
|
+
:::tip
|
|
50
|
+
Events are facts, so name them in past tense: `created`, `expired`,
|
|
51
|
+
`shop.notified`. If a name reads like a command (`notifyShop`), it's
|
|
52
|
+
describing what a handler should do, not what happened. Dots are a naming
|
|
53
|
+
convention, not a namespace.
|
|
54
|
+
:::
|
|
55
|
+
|
|
56
|
+
## Serving a contract
|
|
57
|
+
|
|
58
|
+
Handlers and storage bind to the contract with `createServer` from
|
|
59
|
+
`experimental-a2/server`, the entry point that is allowed to touch a log backend
|
|
60
|
+
(and the one that fails loudly if a browser bundle ever imports it):
|
|
61
|
+
|
|
62
|
+
```ts server/orders.ts
|
|
63
|
+
import { createServer } from 'experimental-a2/server'
|
|
64
|
+
import { postgres } from 'experimental-a2/log-postgres'
|
|
65
|
+
import { orders } from '@/contracts'
|
|
66
|
+
|
|
67
|
+
export const ordersServer = createServer({
|
|
68
|
+
contract: orders,
|
|
69
|
+
log: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
70
|
+
handlers: {
|
|
71
|
+
created: async ({ event, append }) => {
|
|
72
|
+
// your side effect, then:
|
|
73
|
+
await append({ type: 'shop.notified', payload: {} })
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Handlers are one table of reactions, declared at construction, so a
|
|
80
|
+
handler can never be silently missing because the module that would
|
|
81
|
+
have registered it wasn't imported. Compose across files by spreading
|
|
82
|
+
objects into `handlers`.
|
|
83
|
+
|
|
84
|
+
| Entry point | Backend | Use it for |
|
|
85
|
+
| ----------------- | --------- | ----------------------------------------- |
|
|
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` |
|
|
89
|
+
|
|
90
|
+
When you omit `log`, development gets SQLite (state survives `next dev`
|
|
91
|
+
restarts) and tests get memory. Production gets an error: the server
|
|
92
|
+
throws `LOG_NOT_CONFIGURED` at startup rather than guess where your
|
|
93
|
+
events should live. A failed boot beats events written to a filesystem
|
|
94
|
+
that evaporates.
|
|
95
|
+
|
|
96
|
+
## Sessions
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// anywhere on the server: a route, a handler, a script
|
|
100
|
+
const order = ordersServer.session('order-42')
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A session is one instance of the contract: one order, one signup, one
|
|
104
|
+
chat, one approval, one import. It has its own log, its own ordering, its own
|
|
105
|
+
state, and its own live stream. The id is yours: use whatever identifies the
|
|
106
|
+
subject in your system.
|
|
107
|
+
|
|
108
|
+
Creating a session does no I/O. It's a handle; nothing loads until you
|
|
109
|
+
append, read, or stream.
|
|
110
|
+
|
|
111
|
+
Sessions are also the unit of ordering: events within one session are
|
|
112
|
+
processed serially, in log order. Different sessions never wait for each
|
|
113
|
+
other. More in [Handlers](/concepts/handlers#ordering).
|
|
114
|
+
|
|
115
|
+
## Pick the boundary
|
|
116
|
+
|
|
117
|
+
The session is the boundary A2 keeps durable and synchronized. Put events in
|
|
118
|
+
the same session when they must be ordered, folded, recovered, or viewed
|
|
119
|
+
together. Split things that should progress independently.
|
|
120
|
+
|
|
121
|
+
A contract is not a database table, and a session is not a table or a general
|
|
122
|
+
query boundary. One contract serves every instance of that lifecycle. An
|
|
123
|
+
`orders` contract may serve millions of order sessions through the same server
|
|
124
|
+
and handler table.
|
|
125
|
+
|
|
126
|
+
This boundary works well when someone opens a thing by id and watches it
|
|
127
|
+
progress. Collections, joins, search, and constraints across many records
|
|
128
|
+
usually belong in an application database. See
|
|
129
|
+
[A2 and your database](/guides/application-data).
|
|
130
|
+
|
|
131
|
+
## One more option: recovery
|
|
132
|
+
|
|
133
|
+
Servers accept a `recovery` option, which arms a durable, delayed drain
|
|
134
|
+
for every top-level append, giving crash recovery and handler retries in one
|
|
135
|
+
mechanism. The handler starts without waiting for recovery, and a successful
|
|
136
|
+
initial arm is durable before `append` returns. It changes how fast A2 heals,
|
|
137
|
+
not how it works. Everything in these concept pages holds with or without it.
|
|
138
|
+
Covered in [Going to production](/guides/production).
|