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,780 @@
|
|
|
1
|
+
import { n as validateSync } from "./validate-XKT4FSNn.js";
|
|
2
|
+
import { n as asLogUnavailable, t as A2Error } from "./errors-BJRMd-h6.js";
|
|
3
|
+
import { i as serverInternals, t as DRAIN_TIMINGS } from "./internal-Dm8Ejnud.js";
|
|
4
|
+
import { n as serverInspection } from "./inspection-E7qbD0Xj.js";
|
|
5
|
+
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
6
|
+
//#region src/deterministic-id.ts
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic default ids for `ctx.append` (specs/a2-implementation.md
|
|
9
|
+
* §5). A handler re-run after a crash calls `ctx.append` again; giving
|
|
10
|
+
* each call a deterministic id — derived from the triggering event's id
|
|
11
|
+
* and the call's position — makes the re-run a no-op via the log's
|
|
12
|
+
* unique index on event ids.
|
|
13
|
+
*
|
|
14
|
+
* The id is a UUIDv5-shaped SHA-1 over
|
|
15
|
+
* `(namespace, triggerEventId, callOrdinal, itemOrdinal)`, computed with
|
|
16
|
+
* WebCrypto so it runs on any platform a2 core targets.
|
|
17
|
+
*/
|
|
18
|
+
const NAMESPACE = "a2:ctx-append:v1";
|
|
19
|
+
async function deterministicEventId(triggerEventId, callOrdinal, itemOrdinal) {
|
|
20
|
+
const input = [
|
|
21
|
+
NAMESPACE,
|
|
22
|
+
triggerEventId,
|
|
23
|
+
callOrdinal,
|
|
24
|
+
itemOrdinal
|
|
25
|
+
].join("\0");
|
|
26
|
+
const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(input));
|
|
27
|
+
const bytes = new Uint8Array(digest).slice(0, 16);
|
|
28
|
+
bytes[6] = (bytes[6] ?? 0) & 15 | 80;
|
|
29
|
+
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
30
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
31
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/platform.ts
|
|
35
|
+
const SYMBOL_FOR_REQ_CONTEXT = Symbol.for("@vercel/request-context");
|
|
36
|
+
function requestContext() {
|
|
37
|
+
try {
|
|
38
|
+
return Reflect.get(globalThis, SYMBOL_FOR_REQ_CONTEXT)?.get?.() ?? {};
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function platformWaitUntil(promise) {
|
|
44
|
+
try {
|
|
45
|
+
requestContext().waitUntil?.(promise);
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
/** The invocation's termination time in epoch ms, or null if unknowable. */
|
|
49
|
+
function invocationDeadlineMs() {
|
|
50
|
+
const deadline = requestContext().deadline;
|
|
51
|
+
if (deadline === void 0 || deadline === null) return null;
|
|
52
|
+
const ms = new Date(deadline).getTime();
|
|
53
|
+
return Number.isNaN(ms) ? null : ms;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/telemetry.ts
|
|
57
|
+
const NOOP_HANDLE = {
|
|
58
|
+
setAttribute: () => {},
|
|
59
|
+
recordError: () => {}
|
|
60
|
+
};
|
|
61
|
+
/** The zero-cost default when no telemetry is configured. */
|
|
62
|
+
const NOOP_TELEMETRY = { span: (_name, _attributes, fn) => fn(NOOP_HANDLE) };
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/server.ts
|
|
65
|
+
const MAX_FAILURES = 10;
|
|
66
|
+
/**
|
|
67
|
+
* Deadlines tighten the lease/watchdog window; they never decide whether a
|
|
68
|
+
* handler starts. A platform timeout therefore follows the same path as any
|
|
69
|
+
* other process death: the in-flight event remains pending and is retried.
|
|
70
|
+
*/
|
|
71
|
+
function nextLeaseWindow() {
|
|
72
|
+
const nowMs = Date.now();
|
|
73
|
+
const deadlineMs = invocationDeadlineMs();
|
|
74
|
+
const deadlineTtlMs = deadlineMs === null ? DRAIN_TIMINGS.leaseTtlMs : deadlineMs - nowMs;
|
|
75
|
+
const deadlineCapped = deadlineMs !== null && deadlineTtlMs <= DRAIN_TIMINGS.leaseTtlMs;
|
|
76
|
+
const ttlMs = Math.max(1, Math.min(DRAIN_TIMINGS.leaseTtlMs, deadlineTtlMs));
|
|
77
|
+
const expiresAtMs = nowMs + ttlMs;
|
|
78
|
+
return {
|
|
79
|
+
ttlMs,
|
|
80
|
+
expiresAtMs,
|
|
81
|
+
recoveryAtMs: expiresAtMs + DRAIN_TIMINGS.recoveryGraceMs,
|
|
82
|
+
deadlineCapped
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function recoverySlot(dueAt) {
|
|
86
|
+
return Math.ceil(dueAt / 1e3) * 1e3;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The namespace separator between machine name and session id in
|
|
90
|
+
* storage. Machines sharing one log backend (the dev-default sqlite
|
|
91
|
+
* file, a shared Postgres) must not collide on session ids; the machine
|
|
92
|
+
* name is what makes multi-machine apps unambiguous (a2-api.md §1), so
|
|
93
|
+
* it prefixes every storage key.
|
|
94
|
+
*/
|
|
95
|
+
const NS = "";
|
|
96
|
+
const devDefaultLog = retryableLazy(() => import("./log-sqlite.js").then((m) => m.sqlite()));
|
|
97
|
+
function environment() {
|
|
98
|
+
const env = typeof process === "undefined" ? void 0 : process.env?.["NODE_ENV"];
|
|
99
|
+
if (env === "test") return "test";
|
|
100
|
+
if (env === "production") return "production";
|
|
101
|
+
return "development";
|
|
102
|
+
}
|
|
103
|
+
function describeError(err) {
|
|
104
|
+
if (err instanceof Error) return err.stack ?? `${err.name}: ${err.message}`;
|
|
105
|
+
return String(err);
|
|
106
|
+
}
|
|
107
|
+
/** Implement a contract: bind its vocabulary to storage and reactions. */
|
|
108
|
+
function createServer(options) {
|
|
109
|
+
const serverContract = options?.contract;
|
|
110
|
+
if (serverContract === null || typeof serverContract !== "object" || typeof serverContract.name !== "string" || serverContract.events === null) throw new TypeError("createServer expects options with a contract (see a2.contract)");
|
|
111
|
+
const name = serverContract.name;
|
|
112
|
+
const defs = serverContract.events;
|
|
113
|
+
let makeLog;
|
|
114
|
+
if (options.log) {
|
|
115
|
+
const explicit = options.log;
|
|
116
|
+
makeLog = () => Promise.resolve(explicit);
|
|
117
|
+
} else {
|
|
118
|
+
const env = environment();
|
|
119
|
+
if (env === "production") {
|
|
120
|
+
const error = () => new A2Error("LOG_NOT_CONFIGURED", `the server for '${name}' has no log configured and NODE_ENV is 'production' — pass an explicit log backend (e.g. postgres from 'a2/log-postgres')`);
|
|
121
|
+
if (process.env["NEXT_PHASE"] !== "phase-production-build") throw error();
|
|
122
|
+
makeLog = () => Promise.reject(error());
|
|
123
|
+
} else makeLog = env === "test" ? () => import("./log-memory.js").then((m) => m.memory()) : devDefaultLog.get;
|
|
124
|
+
}
|
|
125
|
+
const resolveLog = retryableLazy(makeLog).get;
|
|
126
|
+
const telemetry = options.telemetry ?? NOOP_TELEMETRY;
|
|
127
|
+
const recovery = options.recovery;
|
|
128
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
129
|
+
for (const [type, entry] of Object.entries(options.handlers ?? {})) {
|
|
130
|
+
if (entry === void 0) continue;
|
|
131
|
+
if (!Object.hasOwn(defs, type)) throw new TypeError(`contract '${name}' has no event type '${type}' — cannot register a handler for it`);
|
|
132
|
+
const handler = typeof entry === "function" ? entry : entry?.handler;
|
|
133
|
+
if (typeof handler !== "function") throw new TypeError(`handler for '${type}' must be a function`);
|
|
134
|
+
let abortOn = null;
|
|
135
|
+
const spec = typeof entry === "function" ? void 0 : entry.abortOn;
|
|
136
|
+
if (spec !== void 0) {
|
|
137
|
+
abortOn = /* @__PURE__ */ new Map();
|
|
138
|
+
const pairs = Array.isArray(spec) ? spec.map((abortType) => [abortType, true]) : Object.entries(spec);
|
|
139
|
+
for (const [abortType, matcher] of pairs) {
|
|
140
|
+
if (matcher === void 0) continue;
|
|
141
|
+
if (!Object.hasOwn(defs, abortType)) throw new TypeError(`contract '${name}' has no event type '${String(abortType)}' — cannot abort on it`);
|
|
142
|
+
abortOn.set(String(abortType), matcher);
|
|
143
|
+
}
|
|
144
|
+
if (abortOn.size === 0) abortOn = null;
|
|
145
|
+
}
|
|
146
|
+
handlers.set(type, {
|
|
147
|
+
handler,
|
|
148
|
+
abortOn
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const dormantSignal = new AbortController().signal;
|
|
152
|
+
const prefix = `${name}${NS}`;
|
|
153
|
+
const nsId = (sessionId) => `${prefix}${sessionId}`;
|
|
154
|
+
const stripNs = (stored) => stored.slice(prefix.length);
|
|
155
|
+
const toPublic = (row) => ({
|
|
156
|
+
id: row.id,
|
|
157
|
+
type: row.type,
|
|
158
|
+
payload: row.payload,
|
|
159
|
+
index: row.index,
|
|
160
|
+
sessionId: stripNs(row.sessionId),
|
|
161
|
+
createdAt: row.createdAt
|
|
162
|
+
});
|
|
163
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
164
|
+
const track = (p) => {
|
|
165
|
+
inFlight.add(p);
|
|
166
|
+
const drop = () => {
|
|
167
|
+
inFlight.delete(p);
|
|
168
|
+
};
|
|
169
|
+
p.then(drop, drop);
|
|
170
|
+
};
|
|
171
|
+
const recoveryArmSlots = /* @__PURE__ */ new Map();
|
|
172
|
+
/**
|
|
173
|
+
* Start an optional recovery arm without putting it on the execution path.
|
|
174
|
+
* The returned raw promise lets the initial append report failure; the
|
|
175
|
+
* tracked copy is always rejection-safe for heartbeat callers.
|
|
176
|
+
*/
|
|
177
|
+
const startRecoveryArm = (sessionId, dueAt) => {
|
|
178
|
+
if (!recovery) return null;
|
|
179
|
+
const slottedDueAt = recoverySlot(dueAt);
|
|
180
|
+
const slotKey = JSON.stringify([sessionId, slottedDueAt]);
|
|
181
|
+
const existing = recoveryArmSlots.get(slotKey);
|
|
182
|
+
if (existing) return existing.promise;
|
|
183
|
+
const raw = Promise.resolve().then(() => recovery.arm({
|
|
184
|
+
contract: name,
|
|
185
|
+
sessionId,
|
|
186
|
+
dueAt: slottedDueAt
|
|
187
|
+
}));
|
|
188
|
+
const forget = setTimeout(() => {
|
|
189
|
+
if (recoveryArmSlots.get(slotKey)?.promise === raw) recoveryArmSlots.delete(slotKey);
|
|
190
|
+
}, Math.max(0, slottedDueAt - Date.now()) + 1e3);
|
|
191
|
+
forget.unref?.();
|
|
192
|
+
recoveryArmSlots.set(slotKey, {
|
|
193
|
+
promise: raw,
|
|
194
|
+
forget
|
|
195
|
+
});
|
|
196
|
+
raw.then(void 0, () => {
|
|
197
|
+
const current = recoveryArmSlots.get(slotKey);
|
|
198
|
+
if (current?.promise !== raw) return;
|
|
199
|
+
clearTimeout(current.forget);
|
|
200
|
+
recoveryArmSlots.delete(slotKey);
|
|
201
|
+
});
|
|
202
|
+
const safe = raw.catch(() => {});
|
|
203
|
+
track(safe);
|
|
204
|
+
platformWaitUntil(safe);
|
|
205
|
+
return raw;
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Schedule the fire-and-forget inline drain for an append. The drain
|
|
209
|
+
* is tree-scoped: it owns the append's causal tree — everything up to
|
|
210
|
+
* `frontier` (which ordering forces it through anyway) plus whatever
|
|
211
|
+
* its handlers append — and stops there. Later events from other
|
|
212
|
+
* callers belong to those callers' drains.
|
|
213
|
+
*/
|
|
214
|
+
const scheduleDrain = (sessionId, frontier, recoveryDueAt) => {
|
|
215
|
+
const p = drainSession(sessionId, {
|
|
216
|
+
full: false,
|
|
217
|
+
frontier
|
|
218
|
+
}, recoveryDueAt).catch(() => {});
|
|
219
|
+
track(p);
|
|
220
|
+
platformWaitUntil(p);
|
|
221
|
+
};
|
|
222
|
+
const inProcessHolds = /* @__PURE__ */ new Map();
|
|
223
|
+
const inProcessWaiters = /* @__PURE__ */ new Map();
|
|
224
|
+
const acquireSlot = async (ns, opts) => {
|
|
225
|
+
if (!opts.wait && inProcessHolds.has(ns)) return null;
|
|
226
|
+
for (;;) {
|
|
227
|
+
const current = inProcessHolds.get(ns);
|
|
228
|
+
if (!current) break;
|
|
229
|
+
inProcessWaiters.set(ns, (inProcessWaiters.get(ns) ?? 0) + 1);
|
|
230
|
+
try {
|
|
231
|
+
await current;
|
|
232
|
+
} finally {
|
|
233
|
+
const waiting = (inProcessWaiters.get(ns) ?? 1) - 1;
|
|
234
|
+
if (waiting === 0) inProcessWaiters.delete(ns);
|
|
235
|
+
else inProcessWaiters.set(ns, waiting);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
let release;
|
|
239
|
+
const done = new Promise((resolve) => {
|
|
240
|
+
release = resolve;
|
|
241
|
+
});
|
|
242
|
+
inProcessHolds.set(ns, done);
|
|
243
|
+
return () => {
|
|
244
|
+
if (inProcessHolds.get(ns) === done) inProcessHolds.delete(ns);
|
|
245
|
+
release();
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
const hasInProcessActivity = (ns) => inProcessHolds.has(ns) || (inProcessWaiters.get(ns) ?? 0) > 0;
|
|
249
|
+
const validateEvents = (events) => {
|
|
250
|
+
if (events.length === 0) throw new TypeError("append requires at least one event");
|
|
251
|
+
return events.map((e) => {
|
|
252
|
+
const schema = Object.hasOwn(defs, e.type) ? defs[e.type] : void 0;
|
|
253
|
+
if (!schema) throw new A2Error("UNKNOWN_EVENT_TYPE", `contract '${name}' has no event type '${String(e.type)}'`);
|
|
254
|
+
const result = validateSync(schema, e.payload, `event '${e.type}'`);
|
|
255
|
+
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid payload for event '${e.type}' on machine '${name}'`, { details: result.issues });
|
|
256
|
+
const validated = {
|
|
257
|
+
type: e.type,
|
|
258
|
+
payload: result.value
|
|
259
|
+
};
|
|
260
|
+
if (e.id !== void 0) validated.id = e.id;
|
|
261
|
+
return validated;
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* Validate + write. `onAppended` runs inside the telemetry span, so
|
|
266
|
+
* the drain it schedules is created in the append's active context —
|
|
267
|
+
* with a real tracer, the whole causal tree lands in one trace.
|
|
268
|
+
*/
|
|
269
|
+
const appendCore = (sessionId, events, source, onAppended, cause) => telemetry.span("a2.append", {
|
|
270
|
+
"a2.contract": name,
|
|
271
|
+
"a2.session_id": sessionId,
|
|
272
|
+
"a2.append.source": source,
|
|
273
|
+
"a2.append.types": events.map((e) => String(e.type)).join(","),
|
|
274
|
+
"a2.append.count": events.length
|
|
275
|
+
}, async (span) => {
|
|
276
|
+
assertSessionId(sessionId);
|
|
277
|
+
const validated = validateEvents(events);
|
|
278
|
+
if (cause) for (const event of validated) event.cause = cause;
|
|
279
|
+
const log = await resolveLog();
|
|
280
|
+
let rows;
|
|
281
|
+
try {
|
|
282
|
+
rows = await log.append(nsId(sessionId), validated);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
throw asLogUnavailable(err);
|
|
285
|
+
}
|
|
286
|
+
const appended = rows.map(toPublic);
|
|
287
|
+
const initialRecoveryAt = recovery && source === "external" ? recoverySlot(nextLeaseWindow().recoveryAtMs) : void 0;
|
|
288
|
+
const initialArm = initialRecoveryAt !== void 0 ? startRecoveryArm(sessionId, initialRecoveryAt) : null;
|
|
289
|
+
onAppended(appended, initialRecoveryAt);
|
|
290
|
+
if (initialArm) {
|
|
291
|
+
let timeout = null;
|
|
292
|
+
try {
|
|
293
|
+
await Promise.race([initialArm, new Promise((_, reject) => {
|
|
294
|
+
timeout = setTimeout(() => {
|
|
295
|
+
reject(/* @__PURE__ */ new Error(`a2 recovery arm timed out after ${DRAIN_TIMINGS.recoveryArmTimeoutMs}ms`));
|
|
296
|
+
}, DRAIN_TIMINGS.recoveryArmTimeoutMs);
|
|
297
|
+
})]);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
span.setAttribute("a2.append.armed", false);
|
|
300
|
+
span.recordError(err);
|
|
301
|
+
} finally {
|
|
302
|
+
if (timeout) clearTimeout(timeout);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return appended;
|
|
306
|
+
});
|
|
307
|
+
const readAll = async (sessionId) => {
|
|
308
|
+
const log = await resolveLog();
|
|
309
|
+
try {
|
|
310
|
+
return await log.read(nsId(sessionId));
|
|
311
|
+
} catch (err) {
|
|
312
|
+
throw asLogUnavailable(err);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const makeCtx = (trigger, attempt, onCtxAppend, signal) => {
|
|
316
|
+
let callOrdinal = 0;
|
|
317
|
+
const publicSessionId = stripNs(trigger.sessionId);
|
|
318
|
+
return {
|
|
319
|
+
event: toPublic(trigger),
|
|
320
|
+
attempt,
|
|
321
|
+
append: async (...events) => {
|
|
322
|
+
const call = callOrdinal++;
|
|
323
|
+
const withIds = await Promise.all(events.map(async (e, item) => {
|
|
324
|
+
if (e.id !== void 0) return e;
|
|
325
|
+
const id = await deterministicEventId(trigger.id, call, item);
|
|
326
|
+
return {
|
|
327
|
+
...e,
|
|
328
|
+
id
|
|
329
|
+
};
|
|
330
|
+
}));
|
|
331
|
+
return appendCore(publicSessionId, withIds, "handler", (rows) => {
|
|
332
|
+
const maxIndex = rows.at(-1)?.index ?? 0;
|
|
333
|
+
if (!onCtxAppend(maxIndex)) scheduleDrain(publicSessionId, maxIndex);
|
|
334
|
+
}, {
|
|
335
|
+
index: trigger.index,
|
|
336
|
+
attempt
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
history: async () => (await readAll(publicSessionId)).map(toPublic),
|
|
340
|
+
signal
|
|
341
|
+
};
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* Run a handler registered with `abortOn` (a2-api.md §7): before
|
|
345
|
+
* invoking, check events already appended past the trigger (the
|
|
346
|
+
* abort-landed-before-we-started case); then hold a live subscription
|
|
347
|
+
* for the handler's duration and fire the signal on a match. Closed
|
|
348
|
+
* when the handler settles. Handlers without `abortOn` never get
|
|
349
|
+
* here — no subscription, zero cost.
|
|
350
|
+
*/
|
|
351
|
+
const runAbortable = async (log, ns, trigger, attempt, registration, onCtxAppend) => {
|
|
352
|
+
const controller = new AbortController();
|
|
353
|
+
const publicTrigger = toPublic(trigger);
|
|
354
|
+
const matches = (event) => {
|
|
355
|
+
const matcher = registration.abortOn.get(event.type);
|
|
356
|
+
if (matcher === void 0) return false;
|
|
357
|
+
if (matcher === true) return true;
|
|
358
|
+
return matcher(toPublic(event), publicTrigger);
|
|
359
|
+
};
|
|
360
|
+
const already = await log.read(ns, { afterIndex: trigger.index });
|
|
361
|
+
if (already.some((e) => matches(e))) controller.abort();
|
|
362
|
+
let watcher = null;
|
|
363
|
+
let watch = null;
|
|
364
|
+
if (!controller.signal.aborted) {
|
|
365
|
+
const tail = already.at(-1)?.index ?? trigger.index;
|
|
366
|
+
watcher = log.stream(ns, { startAt: tail })[Symbol.asyncIterator]();
|
|
367
|
+
const iterator = watcher;
|
|
368
|
+
watch = (async () => {
|
|
369
|
+
for (;;) {
|
|
370
|
+
const { value, done } = await iterator.next();
|
|
371
|
+
if (done || value === void 0) return;
|
|
372
|
+
if (matches(value)) {
|
|
373
|
+
controller.abort();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
})().catch(() => {});
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
await registration.handler(makeCtx(trigger, attempt, onCtxAppend, controller.signal));
|
|
381
|
+
} finally {
|
|
382
|
+
await watcher?.return?.();
|
|
383
|
+
await watch;
|
|
384
|
+
}
|
|
385
|
+
return { aborted: controller.signal.aborted };
|
|
386
|
+
};
|
|
387
|
+
const drainHoldingLease = async (log, ns, scope, recoveryDueAt) => {
|
|
388
|
+
let processed = 0;
|
|
389
|
+
const holder = crypto.randomUUID();
|
|
390
|
+
const publicSessionId = stripNs(ns);
|
|
391
|
+
const alignRecoveryAt = (minimumDueAt) => {
|
|
392
|
+
if (recoveryDueAt === void 0) return minimumDueAt;
|
|
393
|
+
return recoveryDueAt + Math.max(0, Math.ceil((minimumDueAt - recoveryDueAt) / DRAIN_TIMINGS.leaseHeartbeatMs)) * DRAIN_TIMINGS.leaseHeartbeatMs;
|
|
394
|
+
};
|
|
395
|
+
let armInFlight = null;
|
|
396
|
+
let pendingRecoveryAt = 0;
|
|
397
|
+
const pumpRecoveryArm = () => {
|
|
398
|
+
if (armInFlight || pendingRecoveryAt === 0) return armInFlight;
|
|
399
|
+
const dueAt = pendingRecoveryAt;
|
|
400
|
+
pendingRecoveryAt = 0;
|
|
401
|
+
const raw = startRecoveryArm(publicSessionId, dueAt);
|
|
402
|
+
if (!raw) return null;
|
|
403
|
+
armInFlight = raw;
|
|
404
|
+
const finished = () => {
|
|
405
|
+
if (armInFlight === raw) armInFlight = null;
|
|
406
|
+
pumpRecoveryArm();
|
|
407
|
+
};
|
|
408
|
+
raw.then(finished, finished);
|
|
409
|
+
return raw;
|
|
410
|
+
};
|
|
411
|
+
const requestRecoveryArm = (dueAt) => {
|
|
412
|
+
pendingRecoveryAt = Math.max(pendingRecoveryAt, dueAt);
|
|
413
|
+
return pumpRecoveryArm();
|
|
414
|
+
};
|
|
415
|
+
let leaseLost = false;
|
|
416
|
+
let stopRenewing = false;
|
|
417
|
+
let renewalInFlight = null;
|
|
418
|
+
let heartbeat = null;
|
|
419
|
+
const stopHeartbeat = () => {
|
|
420
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
421
|
+
heartbeat = null;
|
|
422
|
+
};
|
|
423
|
+
const renewLease = () => {
|
|
424
|
+
if (stopRenewing || leaseLost) return Promise.resolve();
|
|
425
|
+
if (renewalInFlight) return renewalInFlight;
|
|
426
|
+
const window = nextLeaseWindow();
|
|
427
|
+
requestRecoveryArm(alignRecoveryAt(window.recoveryAtMs));
|
|
428
|
+
const renewal = log.lease.acquire({
|
|
429
|
+
sessionId: ns,
|
|
430
|
+
holder,
|
|
431
|
+
ttlMs: window.ttlMs,
|
|
432
|
+
...window.deadlineCapped ? { expiresAtMs: window.expiresAtMs } : {}
|
|
433
|
+
}).then((renewed) => {
|
|
434
|
+
if (!renewed) leaseLost = true;
|
|
435
|
+
if (window.deadlineCapped) {
|
|
436
|
+
stopRenewing = true;
|
|
437
|
+
stopHeartbeat();
|
|
438
|
+
}
|
|
439
|
+
}, () => {});
|
|
440
|
+
let tracked;
|
|
441
|
+
tracked = renewal.finally(() => {
|
|
442
|
+
if (renewalInFlight === tracked) renewalInFlight = null;
|
|
443
|
+
});
|
|
444
|
+
renewalInFlight = tracked;
|
|
445
|
+
return tracked;
|
|
446
|
+
};
|
|
447
|
+
const initialWindow = nextLeaseWindow();
|
|
448
|
+
const initialClaim = await log.claimNext({
|
|
449
|
+
sessionId: ns,
|
|
450
|
+
holder,
|
|
451
|
+
ttlMs: initialWindow.ttlMs,
|
|
452
|
+
...initialWindow.deadlineCapped ? { expiresAtMs: initialWindow.expiresAtMs } : {},
|
|
453
|
+
...scope.full ? {} : { maxIndex: scope.frontier }
|
|
454
|
+
});
|
|
455
|
+
if (initialClaim.outcome === "settled") return {
|
|
456
|
+
outcome: "settled",
|
|
457
|
+
processed
|
|
458
|
+
};
|
|
459
|
+
const initialRecoveryArm = requestRecoveryArm(alignRecoveryAt(initialWindow.recoveryAtMs));
|
|
460
|
+
if (initialClaim.outcome === "busy") return {
|
|
461
|
+
outcome: "busy",
|
|
462
|
+
processed,
|
|
463
|
+
...initialRecoveryArm ? { recoveryArm: initialRecoveryArm } : {}
|
|
464
|
+
};
|
|
465
|
+
stopRenewing = initialWindow.deadlineCapped;
|
|
466
|
+
if (!stopRenewing) {
|
|
467
|
+
heartbeat = setInterval(() => {
|
|
468
|
+
renewLease();
|
|
469
|
+
}, DRAIN_TIMINGS.leaseHeartbeatMs);
|
|
470
|
+
heartbeat.unref?.();
|
|
471
|
+
}
|
|
472
|
+
let inHold = true;
|
|
473
|
+
const onCtxAppend = (maxIndex) => {
|
|
474
|
+
if (!inHold) return false;
|
|
475
|
+
if (!scope.full && maxIndex > scope.frontier) scope.frontier = maxIndex;
|
|
476
|
+
return true;
|
|
477
|
+
};
|
|
478
|
+
let next = initialClaim.event;
|
|
479
|
+
try {
|
|
480
|
+
for (;;) {
|
|
481
|
+
const registration = handlers.get(next.type);
|
|
482
|
+
const step = await telemetry.span("a2.event", {
|
|
483
|
+
"a2.contract": name,
|
|
484
|
+
"a2.session_id": stripNs(ns),
|
|
485
|
+
"a2.event.type": next.type,
|
|
486
|
+
"a2.event.index": next.index,
|
|
487
|
+
"a2.event.id": next.id,
|
|
488
|
+
"a2.event.attempt": next.attemptCount,
|
|
489
|
+
"a2.event.handled": registration !== void 0
|
|
490
|
+
}, async (span) => {
|
|
491
|
+
try {
|
|
492
|
+
if (registration) try {
|
|
493
|
+
if (registration.abortOn) {
|
|
494
|
+
const { aborted } = await runAbortable(log, ns, next, next.attemptCount, {
|
|
495
|
+
handler: registration.handler,
|
|
496
|
+
abortOn: registration.abortOn
|
|
497
|
+
}, onCtxAppend);
|
|
498
|
+
if (aborted) span.setAttribute("a2.event.aborted", true);
|
|
499
|
+
} else await registration.handler(makeCtx(next, next.attemptCount, onCtxAppend, dormantSignal));
|
|
500
|
+
} catch (err) {
|
|
501
|
+
span.recordError(err);
|
|
502
|
+
const failure = await log.failAttempt({
|
|
503
|
+
sessionId: ns,
|
|
504
|
+
index: next.index,
|
|
505
|
+
attempt: next.attemptCount,
|
|
506
|
+
error: describeError(err),
|
|
507
|
+
maxFailures: MAX_FAILURES
|
|
508
|
+
});
|
|
509
|
+
if (failure.outcome === "superseded") leaseLost = true;
|
|
510
|
+
span.setAttribute("a2.event.outcome", failure.outcome);
|
|
511
|
+
return { outcome: failure.outcome };
|
|
512
|
+
}
|
|
513
|
+
await renewalInFlight;
|
|
514
|
+
const claimed = await log.completeAndClaimNext({
|
|
515
|
+
sessionId: ns,
|
|
516
|
+
holder,
|
|
517
|
+
completedIndex: next.index,
|
|
518
|
+
attempt: next.attemptCount,
|
|
519
|
+
...scope.full ? {} : { maxIndex: scope.frontier }
|
|
520
|
+
});
|
|
521
|
+
if (claimed.outcome === "superseded") {
|
|
522
|
+
leaseLost = true;
|
|
523
|
+
span.setAttribute("a2.event.outcome", "superseded");
|
|
524
|
+
return { outcome: "superseded" };
|
|
525
|
+
}
|
|
526
|
+
if (claimed.outcome === "claimed") leaseLost = false;
|
|
527
|
+
else if (claimed.outcome === "busy") leaseLost = true;
|
|
528
|
+
span.setAttribute("a2.event.outcome", "processed");
|
|
529
|
+
return {
|
|
530
|
+
outcome: "processed",
|
|
531
|
+
next: claimed
|
|
532
|
+
};
|
|
533
|
+
} finally {
|
|
534
|
+
if (leaseLost) span.setAttribute("a2.event.lease_lost", true);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
if (step.outcome === "dead_lettered") return {
|
|
538
|
+
outcome: "settled",
|
|
539
|
+
processed
|
|
540
|
+
};
|
|
541
|
+
if (step.outcome === "failed") return {
|
|
542
|
+
outcome: "stalled",
|
|
543
|
+
processed
|
|
544
|
+
};
|
|
545
|
+
if (step.outcome === "superseded") return {
|
|
546
|
+
outcome: "busy",
|
|
547
|
+
processed
|
|
548
|
+
};
|
|
549
|
+
processed += 1;
|
|
550
|
+
if (step.next.outcome === "claimed") {
|
|
551
|
+
next = step.next.event;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
outcome: step.next.outcome,
|
|
556
|
+
processed
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
} finally {
|
|
560
|
+
inHold = false;
|
|
561
|
+
stopRenewing = true;
|
|
562
|
+
stopHeartbeat();
|
|
563
|
+
await renewalInFlight;
|
|
564
|
+
await log.lease.release({
|
|
565
|
+
sessionId: ns,
|
|
566
|
+
holder
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
const drainSession = (sessionId, scope, recoveryDueAt) => telemetry.span("a2.drain", {
|
|
571
|
+
"a2.contract": name,
|
|
572
|
+
"a2.session_id": sessionId,
|
|
573
|
+
"a2.drain.scope": scope.full ? "full" : "tree"
|
|
574
|
+
}, async (span) => {
|
|
575
|
+
const log = await resolveLog();
|
|
576
|
+
const ns = nsId(sessionId);
|
|
577
|
+
const releaseSlot = await acquireSlot(ns, { wait: !scope.full });
|
|
578
|
+
if (!releaseSlot) {
|
|
579
|
+
span.setAttribute("a2.drain.outcome", "busy");
|
|
580
|
+
return {
|
|
581
|
+
settled: false,
|
|
582
|
+
outcome: "busy",
|
|
583
|
+
processed: 0
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
let outcome;
|
|
587
|
+
let processed;
|
|
588
|
+
let recoveryArm;
|
|
589
|
+
try {
|
|
590
|
+
({outcome, processed, recoveryArm} = await drainHoldingLease(log, ns, scope, recoveryDueAt));
|
|
591
|
+
} finally {
|
|
592
|
+
releaseSlot();
|
|
593
|
+
}
|
|
594
|
+
span.setAttribute("a2.drain.processed", processed);
|
|
595
|
+
if (outcome === "busy" || outcome === "stalled") {
|
|
596
|
+
span.setAttribute("a2.drain.outcome", outcome);
|
|
597
|
+
return {
|
|
598
|
+
settled: false,
|
|
599
|
+
outcome,
|
|
600
|
+
processed,
|
|
601
|
+
...recoveryArm ? { recoveryArm } : {}
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
const leftover = await log.read(ns, { unprocessedOnly: true });
|
|
605
|
+
if (leftover.length === 0 || leftover[0]?.failedAt) {
|
|
606
|
+
span.setAttribute("a2.drain.outcome", "settled");
|
|
607
|
+
return {
|
|
608
|
+
settled: true,
|
|
609
|
+
outcome: "settled",
|
|
610
|
+
processed
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
if (!hasInProcessActivity(ns)) scheduleDrain(sessionId, leftover.at(-1)?.index ?? 0, recoveryDueAt);
|
|
614
|
+
span.setAttribute("a2.drain.outcome", "handed_off");
|
|
615
|
+
return {
|
|
616
|
+
settled: false,
|
|
617
|
+
outcome: "handed_off",
|
|
618
|
+
processed
|
|
619
|
+
};
|
|
620
|
+
});
|
|
621
|
+
const self = {
|
|
622
|
+
contract: serverContract,
|
|
623
|
+
session(id) {
|
|
624
|
+
assertSessionId(id);
|
|
625
|
+
return {
|
|
626
|
+
sessionId: id,
|
|
627
|
+
append: (...events) => appendCore(id, events, "external", (rows, recoveryDueAt) => {
|
|
628
|
+
scheduleDrain(id, rows.at(-1)?.index ?? 0, recoveryDueAt);
|
|
629
|
+
}),
|
|
630
|
+
history: async () => (await readAll(id)).map(toPublic),
|
|
631
|
+
state: async (reducer) => {
|
|
632
|
+
const log = await resolveLog();
|
|
633
|
+
return telemetry.span("a2.state", {
|
|
634
|
+
"a2.contract": name,
|
|
635
|
+
"a2.session_id": id,
|
|
636
|
+
"a2.state.reducer": reducer.name
|
|
637
|
+
}, async (span) => {
|
|
638
|
+
let state = cloneInitial(reducer.initialState);
|
|
639
|
+
let index = 0;
|
|
640
|
+
let snapshotOutcome = "miss";
|
|
641
|
+
let stateRead;
|
|
642
|
+
try {
|
|
643
|
+
stateRead = await log.readState(nsId(id), reducer.name);
|
|
644
|
+
} catch {
|
|
645
|
+
try {
|
|
646
|
+
stateRead = {
|
|
647
|
+
snapshot: null,
|
|
648
|
+
events: await log.read(nsId(id))
|
|
649
|
+
};
|
|
650
|
+
} catch (err) {
|
|
651
|
+
throw asLogUnavailable(err);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const snap = stateRead.snapshot;
|
|
655
|
+
let rows = stateRead.events;
|
|
656
|
+
if (snap) {
|
|
657
|
+
if (reducer.stateSchema) {
|
|
658
|
+
const result = validateSync(reducer.stateSchema, snap.state, "the stateSchema");
|
|
659
|
+
if (result.issues) snapshotOutcome = "rejected";
|
|
660
|
+
else {
|
|
661
|
+
state = result.value;
|
|
662
|
+
index = snap.index;
|
|
663
|
+
snapshotOutcome = "hit";
|
|
664
|
+
}
|
|
665
|
+
} else {
|
|
666
|
+
state = snap.state;
|
|
667
|
+
index = snap.index;
|
|
668
|
+
snapshotOutcome = "hit";
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
span.setAttribute("a2.state.snapshot", snapshotOutcome);
|
|
672
|
+
if (snapshotOutcome === "rejected") try {
|
|
673
|
+
rows = await log.read(nsId(id));
|
|
674
|
+
} catch (err) {
|
|
675
|
+
throw asLogUnavailable(err);
|
|
676
|
+
}
|
|
677
|
+
for (const row of rows) {
|
|
678
|
+
state = reducer.fold(state, toPublic(row));
|
|
679
|
+
index = row.index;
|
|
680
|
+
}
|
|
681
|
+
span.setAttribute("a2.state.folded", rows.length);
|
|
682
|
+
span.setAttribute("a2.state.index", index);
|
|
683
|
+
if (rows.length > 0) {
|
|
684
|
+
const snapshotState = cloneInitial(state);
|
|
685
|
+
const write = Promise.resolve().then(() => log.putSnapshot(nsId(id), reducer.name, index, snapshotState)).catch(() => {});
|
|
686
|
+
track(write);
|
|
687
|
+
platformWaitUntil(write);
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
state,
|
|
691
|
+
index
|
|
692
|
+
};
|
|
693
|
+
});
|
|
694
|
+
},
|
|
695
|
+
stream: (opts) => {
|
|
696
|
+
const log = resolveLog();
|
|
697
|
+
const startAt = opts?.startAt ?? 0;
|
|
698
|
+
const outer = async function* () {
|
|
699
|
+
const inner = (await log).stream(nsId(id), { startAt });
|
|
700
|
+
for await (const event of inner) yield {
|
|
701
|
+
id: event.id,
|
|
702
|
+
type: event.type,
|
|
703
|
+
payload: event.payload,
|
|
704
|
+
index: event.index,
|
|
705
|
+
sessionId: id,
|
|
706
|
+
createdAt: event.createdAt
|
|
707
|
+
};
|
|
708
|
+
};
|
|
709
|
+
return outer();
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
},
|
|
713
|
+
async drain(sessionId) {
|
|
714
|
+
assertSessionId(sessionId);
|
|
715
|
+
const { settled } = await drainSession(sessionId, {
|
|
716
|
+
full: true,
|
|
717
|
+
frontier: 0
|
|
718
|
+
});
|
|
719
|
+
return { settled };
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
serverInternals.set(self, {
|
|
723
|
+
settle: async () => {
|
|
724
|
+
while (inFlight.size > 0) await Promise.allSettled(inFlight);
|
|
725
|
+
},
|
|
726
|
+
recoveryDrain: (sessionId, opts) => {
|
|
727
|
+
assertSessionId(sessionId);
|
|
728
|
+
return drainSession(sessionId, {
|
|
729
|
+
full: true,
|
|
730
|
+
frontier: 0
|
|
731
|
+
}, opts?.recoveryDueAt);
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
serverInspection.set(self, {
|
|
735
|
+
async listSessions(inspectionOptions) {
|
|
736
|
+
const log = await resolveLog();
|
|
737
|
+
if (!log.inspect) throw new TypeError("this log backend does not support inspection");
|
|
738
|
+
const page = await log.inspect.listSessions({
|
|
739
|
+
prefix: `${name}${NS}`,
|
|
740
|
+
limit: inspectionOptions.limit,
|
|
741
|
+
...inspectionOptions.cursor !== void 0 ? { cursor: inspectionOptions.cursor } : {}
|
|
742
|
+
});
|
|
743
|
+
return {
|
|
744
|
+
cursor: page.cursor,
|
|
745
|
+
sessions: page.sessions.map((session) => ({
|
|
746
|
+
...session,
|
|
747
|
+
sessionId: stripNs(session.sessionId)
|
|
748
|
+
}))
|
|
749
|
+
};
|
|
750
|
+
},
|
|
751
|
+
async readSession(sessionId) {
|
|
752
|
+
assertSessionId(sessionId);
|
|
753
|
+
const log = await resolveLog();
|
|
754
|
+
if (!log.inspect) throw new TypeError("this log backend does not support inspection");
|
|
755
|
+
const ns = nsId(sessionId);
|
|
756
|
+
const [events, snapshots] = await Promise.all([log.read(ns), log.inspect.listSnapshots(ns)]);
|
|
757
|
+
return {
|
|
758
|
+
events: events.map((event) => ({
|
|
759
|
+
...event,
|
|
760
|
+
sessionId
|
|
761
|
+
})),
|
|
762
|
+
snapshots
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
return self;
|
|
767
|
+
}
|
|
768
|
+
function assertSessionId(id) {
|
|
769
|
+
if (typeof id !== "string" || id.length === 0) throw new TypeError("session id must be a non-empty string");
|
|
770
|
+
if (id.includes(NS)) throw new TypeError("session id contains a reserved control character");
|
|
771
|
+
}
|
|
772
|
+
function cloneInitial(initial) {
|
|
773
|
+
try {
|
|
774
|
+
return structuredClone(initial);
|
|
775
|
+
} catch {
|
|
776
|
+
return initial;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
//#endregion
|
|
780
|
+
export { createServer as t };
|