experimental-a2 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
  56. package/dist/store-redis-http.d.ts +21 -0
  57. package/dist/store-redis-http.js +70 -0
  58. package/dist/store-redis.d.ts +37 -0
  59. package/dist/store-redis.js +298 -0
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +4 -5
  69. package/docs/concepts/01-contracts.mdx +21 -17
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +26 -29
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -54
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +321 -60
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +22 -46
  84. package/docs/reference/01-api.mdx +751 -102
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -6
  87. package/dist/internal-D6wNxTck.js +0 -36
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/log-redis.d.ts +0 -31
  90. package/dist/recovery-vercel.js +0 -119
  91. package/dist/server-DJgD2YWP.js +0 -877
  92. package/dist/wire-BVsgR8o9.js +0 -62
@@ -1,877 +0,0 @@
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-D6wNxTck.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 handler `session.append` calls. A handler
9
- * re-run after a crash calls `session.append` again; giving
10
- * each call a deterministic id — derived from the triggering event's id
11
- * and the caller's stable name — 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, name, itemOrdinal)`, computed with
16
- * WebCrypto so it runs on any platform a2 core targets.
17
- */
18
- const APPEND_NAMESPACE = "a2:handler-append:v2";
19
- const RETURN_NAMESPACE = "a2:handler-return:v1";
20
- async function deterministicId(parts) {
21
- const input = parts.join("\0");
22
- const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(input));
23
- const bytes = new Uint8Array(digest).slice(0, 16);
24
- bytes[6] = (bytes[6] ?? 0) & 15 | 80;
25
- bytes[8] = (bytes[8] ?? 0) & 63 | 128;
26
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
27
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
28
- }
29
- async function deterministicEventId(triggerEventId, name, itemOrdinal) {
30
- if (name.length === 0) throw new TypeError("append name must not be empty");
31
- return deterministicId([
32
- APPEND_NAMESPACE,
33
- triggerEventId,
34
- name,
35
- itemOrdinal
36
- ]);
37
- }
38
- async function deterministicReturnedEventId(triggerEventId, itemOrdinal) {
39
- return deterministicId([
40
- RETURN_NAMESPACE,
41
- triggerEventId,
42
- itemOrdinal
43
- ]);
44
- }
45
- //#endregion
46
- //#region src/platform.ts
47
- const SYMBOL_FOR_REQ_CONTEXT = Symbol.for("@vercel/request-context");
48
- function requestContext() {
49
- try {
50
- return Reflect.get(globalThis, SYMBOL_FOR_REQ_CONTEXT)?.get?.() ?? {};
51
- } catch {
52
- return {};
53
- }
54
- }
55
- function platformWaitUntil(promise) {
56
- try {
57
- requestContext().waitUntil?.(promise);
58
- } catch {}
59
- }
60
- /** The invocation's termination time in epoch ms, or null if unknowable. */
61
- function invocationDeadlineMs() {
62
- const deadline = requestContext().deadline;
63
- if (deadline === void 0 || deadline === null) return null;
64
- const ms = new Date(deadline).getTime();
65
- return Number.isNaN(ms) ? null : ms;
66
- }
67
- //#endregion
68
- //#region src/telemetry.ts
69
- const NOOP_HANDLE = {
70
- setAttribute: () => {},
71
- recordError: () => {}
72
- };
73
- /** The zero-cost default when no telemetry is configured. */
74
- const NOOP_TELEMETRY = { span: (_name, _attributes, fn) => fn(NOOP_HANDLE) };
75
- //#endregion
76
- //#region src/server.ts
77
- const MAX_FAILURES = 10;
78
- function createDrainSignal() {
79
- const listeners = /* @__PURE__ */ new Set();
80
- return {
81
- version: 0,
82
- closed: false,
83
- notify() {
84
- this.version += 1;
85
- for (const listener of listeners) listener();
86
- listeners.clear();
87
- },
88
- wait(version) {
89
- if (this.version !== version) return {
90
- promise: Promise.resolve(),
91
- cancel: () => {}
92
- };
93
- let resolve;
94
- const promise = new Promise((done) => {
95
- resolve = done;
96
- });
97
- listeners.add(resolve);
98
- return {
99
- promise,
100
- cancel: () => listeners.delete(resolve)
101
- };
102
- }
103
- };
104
- }
105
- function dispatchAbortEvent(hub, event) {
106
- let relevant = false;
107
- for (const subscription of hub.subscriptions) {
108
- if (!subscription.types.has(event.type)) continue;
109
- relevant = true;
110
- if (subscription.matches(event)) subscription.controller.abort();
111
- }
112
- if (relevant) hub.seen.set(event.index, event);
113
- }
114
- function pruneAbortHub(hub) {
115
- let minimum = Infinity;
116
- const types = /* @__PURE__ */ new Set();
117
- for (const subscription of hub.subscriptions) {
118
- minimum = Math.min(minimum, subscription.triggerIndex);
119
- for (const type of subscription.types) types.add(type);
120
- }
121
- for (const [index, event] of hub.seen) if (index <= minimum || !types.has(event.type)) hub.seen.delete(index);
122
- }
123
- /**
124
- * Deadlines tighten the claim/watchdog window; they never decide whether a
125
- * handler starts. A platform timeout therefore follows the same path as any
126
- * other process death: the in-flight event remains pending and is retried.
127
- */
128
- function nextClaimWindow() {
129
- const nowMs = Date.now();
130
- const deadlineMs = invocationDeadlineMs();
131
- const deadlineTtlMs = deadlineMs === null ? DRAIN_TIMINGS.claimTtlMs : deadlineMs - nowMs;
132
- const deadlineCapped = deadlineMs !== null && deadlineTtlMs <= DRAIN_TIMINGS.claimTtlMs;
133
- const ttlMs = Math.max(1, Math.min(DRAIN_TIMINGS.claimTtlMs, deadlineTtlMs));
134
- const expiresAtMs = nowMs + ttlMs;
135
- return {
136
- ttlMs,
137
- expiresAtMs,
138
- recoveryAtMs: expiresAtMs + DRAIN_TIMINGS.recoveryGraceMs,
139
- deadlineCapped
140
- };
141
- }
142
- function recoverySlot(dueAt) {
143
- return Math.ceil(dueAt / 1e3) * 1e3;
144
- }
145
- function currentRecoverySlot(now) {
146
- return Math.floor(now / 1e3) * 1e3;
147
- }
148
- /**
149
- * The namespace separator between machine name and session id in
150
- * storage. Machines sharing one log backend (the dev-default sqlite
151
- * file, a shared Postgres) must not collide on session ids; the machine
152
- * name is what makes multi-machine apps unambiguous (a2-api.md §1), so
153
- * it prefixes every storage key.
154
- */
155
- const NS = "";
156
- const devDefaultLog = retryableLazy(() => import("./log-sqlite.js").then((m) => m.sqlite()));
157
- function environment() {
158
- const env = typeof process === "undefined" ? void 0 : process.env?.["NODE_ENV"];
159
- if (env === "test") return "test";
160
- if (env === "production") return "production";
161
- return "development";
162
- }
163
- function describeError(err) {
164
- if (err instanceof Error) return err.stack ?? `${err.name}: ${err.message}`;
165
- return String(err);
166
- }
167
- /** Implement a contract: bind its vocabulary to storage and reactions. */
168
- function createServer(options) {
169
- const serverContract = options?.contract;
170
- 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)");
171
- const name = serverContract.name;
172
- const defs = serverContract.events;
173
- let makeLog;
174
- if (options.log) {
175
- const explicit = options.log;
176
- makeLog = () => Promise.resolve(explicit);
177
- } else {
178
- const env = environment();
179
- if (env === "production") {
180
- 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 'experimental-a2/log-postgres')`);
181
- if (process.env["NEXT_PHASE"] !== "phase-production-build") throw error();
182
- makeLog = () => Promise.reject(error());
183
- } else makeLog = env === "test" ? () => import("./log-memory.js").then((m) => m.memory()) : devDefaultLog.get;
184
- }
185
- const resolveLog = retryableLazy(makeLog).get;
186
- const telemetry = options.telemetry ?? NOOP_TELEMETRY;
187
- const recovery = options.recovery;
188
- const handlers = /* @__PURE__ */ new Map();
189
- for (const [type, entry] of Object.entries(options.handlers ?? {})) {
190
- if (entry === void 0) continue;
191
- if (!Object.hasOwn(defs, type)) throw new TypeError(`contract '${name}' has no event type '${type}' — cannot register a handler for it`);
192
- const handler = typeof entry === "function" ? entry : entry?.handler;
193
- if (typeof handler !== "function") throw new TypeError(`handler for '${type}' must be a function`);
194
- let abortOn = null;
195
- const spec = typeof entry === "function" ? void 0 : entry.abortOn;
196
- if (spec !== void 0) {
197
- abortOn = /* @__PURE__ */ new Map();
198
- const pairs = Array.isArray(spec) ? spec.map((abortType) => [abortType, true]) : Object.entries(spec);
199
- for (const [abortType, matcher] of pairs) {
200
- if (matcher === void 0) continue;
201
- if (!Object.hasOwn(defs, abortType)) throw new TypeError(`contract '${name}' has no event type '${String(abortType)}' — cannot abort on it`);
202
- abortOn.set(String(abortType), matcher);
203
- }
204
- if (abortOn.size === 0) abortOn = null;
205
- }
206
- const lane = typeof entry === "function" ? null : entry.lane ?? null;
207
- if (lane !== null && typeof lane !== "function" && (typeof lane !== "string" || lane.length === 0)) throw new TypeError(`lane for '${type}' must be a non-empty string or a function`);
208
- handlers.set(type, {
209
- handler,
210
- abortOn,
211
- lane
212
- });
213
- }
214
- const dormantSignal = new AbortController().signal;
215
- const prefix = `${name}${NS}`;
216
- const nsId = (sessionId) => `${prefix}${sessionId}`;
217
- const stripNs = (stored) => stored.slice(prefix.length);
218
- const toPublic = (row) => ({
219
- id: row.id,
220
- type: row.type,
221
- payload: row.payload,
222
- index: row.index,
223
- sessionId: stripNs(row.sessionId),
224
- createdAt: row.createdAt
225
- });
226
- const inFlight = /* @__PURE__ */ new Set();
227
- const track = (p) => {
228
- inFlight.add(p);
229
- const drop = () => {
230
- inFlight.delete(p);
231
- };
232
- p.then(drop, drop);
233
- };
234
- const recoveryArmSlots = /* @__PURE__ */ new Map();
235
- /**
236
- * Start an optional recovery arm without putting it on the execution path.
237
- * The returned raw promise lets the initial append report failure; the
238
- * tracked copy is always rejection-safe for heartbeat callers.
239
- */
240
- const startRecoveryArm = (sessionId, dueAt) => {
241
- if (!recovery) return null;
242
- const slottedDueAt = recoverySlot(dueAt);
243
- const slotKey = JSON.stringify([sessionId, slottedDueAt]);
244
- const existing = recoveryArmSlots.get(slotKey);
245
- if (existing) return existing.promise;
246
- const raw = Promise.resolve().then(() => recovery.arm({
247
- contract: name,
248
- sessionId,
249
- dueAt: slottedDueAt
250
- }));
251
- const forget = setTimeout(() => {
252
- if (recoveryArmSlots.get(slotKey)?.promise === raw) recoveryArmSlots.delete(slotKey);
253
- }, Math.max(0, slottedDueAt - Date.now()) + 1e3);
254
- forget.unref?.();
255
- recoveryArmSlots.set(slotKey, {
256
- promise: raw,
257
- forget
258
- });
259
- raw.then(void 0, () => {
260
- const current = recoveryArmSlots.get(slotKey);
261
- if (current?.promise !== raw) return;
262
- clearTimeout(current.forget);
263
- recoveryArmSlots.delete(slotKey);
264
- });
265
- const safe = raw.catch(() => {});
266
- track(safe);
267
- platformWaitUntil(safe);
268
- return raw;
269
- };
270
- const scheduledDrains = /* @__PURE__ */ new Map();
271
- const scheduleDrain = (sessionId, recoveryDueAt) => {
272
- const existing = scheduledDrains.get(sessionId);
273
- if (existing && !existing.signal.closed) {
274
- existing.signal.notify();
275
- return;
276
- }
277
- if (existing) scheduledDrains.delete(sessionId);
278
- const signal = createDrainSignal();
279
- const p = new Promise((resolve) => {
280
- queueMicrotask(() => {
281
- drainSession(sessionId, recoveryDueAt, signal).then(() => resolve()).catch(() => resolve());
282
- });
283
- }).finally(() => {
284
- if (scheduledDrains.get(sessionId)?.promise === p) scheduledDrains.delete(sessionId);
285
- });
286
- scheduledDrains.set(sessionId, {
287
- signal,
288
- promise: p
289
- });
290
- track(p);
291
- platformWaitUntil(p);
292
- };
293
- const validateEvents = (sessionId, events) => {
294
- if (events.length === 0) throw new TypeError("append requires at least one event");
295
- return events.map((e) => {
296
- const schema = Object.hasOwn(defs, e.type) ? defs[e.type] : void 0;
297
- if (!schema) throw new A2Error("UNKNOWN_EVENT_TYPE", `contract '${name}' has no event type '${String(e.type)}'`);
298
- const result = validateSync(schema, e.payload, `event '${e.type}'`);
299
- if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid payload for event '${e.type}' on machine '${name}'`, { details: result.issues });
300
- const validated = {
301
- type: e.type,
302
- payload: result.value
303
- };
304
- if (e.id !== void 0) validated.id = e.id;
305
- const registration = handlers.get(e.type);
306
- if (!registration) validated.settled = true;
307
- else if (registration.lane !== null) {
308
- const lane = typeof registration.lane === "function" ? registration.lane({
309
- sessionId,
310
- event: {
311
- type: e.type,
312
- payload: structuredClone(result.value),
313
- ...e.id === void 0 ? {} : { id: e.id }
314
- }
315
- }) : registration.lane;
316
- if (typeof lane !== "string" || lane.length === 0) throw new TypeError(`lane for '${e.type}' must resolve to a non-empty string`);
317
- validated.lane = lane;
318
- }
319
- return validated;
320
- });
321
- };
322
- /**
323
- * Validate + write. `onAppended` runs inside the telemetry span, so
324
- * the drain it schedules is created in the append's active context —
325
- * with a real tracer, causal work remains connected.
326
- */
327
- const appendCore = (sessionId, events, source, mode, onAppended, cause, generatedIds) => telemetry.span("a2.append", {
328
- "a2.contract": name,
329
- "a2.session_id": sessionId,
330
- "a2.append.source": source,
331
- "a2.append.mode": mode,
332
- "a2.append.types": events.map((e) => String(e.type)).join(","),
333
- "a2.append.count": events.length
334
- }, async (span) => {
335
- if (mode === "dispatch" && !recovery) throw new TypeError("append.dispatch requires a configured recovery adapter");
336
- assertSessionId(sessionId);
337
- const validated = validateEvents(sessionId, events);
338
- if (cause) for (const [index, event] of validated.entries()) event.cause = generatedIds?.[index] ? {
339
- ...cause,
340
- batchSize: events.length
341
- } : cause;
342
- const log = await resolveLog();
343
- let rows;
344
- let shouldHealSession;
345
- try {
346
- const result = await log.append(nsId(sessionId), validated);
347
- rows = result.events;
348
- shouldHealSession = result.hasPending;
349
- } catch (err) {
350
- throw asLogUnavailable(err);
351
- }
352
- const appended = rows.map(toPublic);
353
- const initialRecoveryAt = shouldHealSession && recovery && source === "external" && mode === "inline" ? recoverySlot(nextClaimWindow().recoveryAtMs) : void 0;
354
- const initialArm = initialRecoveryAt !== void 0 ? startRecoveryArm(sessionId, initialRecoveryAt) : null;
355
- const dispatchArm = shouldHealSession && mode === "dispatch" ? startRecoveryArm(sessionId, currentRecoverySlot(Date.now())) : null;
356
- if (shouldHealSession && mode === "inline") onAppended(appended, initialRecoveryAt);
357
- if (dispatchArm) await dispatchArm;
358
- if (initialArm) {
359
- let timeout = null;
360
- try {
361
- await Promise.race([initialArm, new Promise((_, reject) => {
362
- timeout = setTimeout(() => {
363
- reject(/* @__PURE__ */ new Error(`a2 recovery arm timed out after ${DRAIN_TIMINGS.recoveryArmTimeoutMs}ms`));
364
- }, DRAIN_TIMINGS.recoveryArmTimeoutMs);
365
- })]);
366
- } catch (err) {
367
- span.setAttribute("a2.append.armed", false);
368
- span.recordError(err);
369
- } finally {
370
- if (timeout) clearTimeout(timeout);
371
- }
372
- }
373
- return appended;
374
- });
375
- const readAll = async (sessionId) => {
376
- const log = await resolveLog();
377
- try {
378
- return await log.read(nsId(sessionId));
379
- } catch (err) {
380
- throw asLogUnavailable(err);
381
- }
382
- };
383
- const readState = async (sessionId, reducer) => {
384
- const log = await resolveLog();
385
- return telemetry.span("a2.state", {
386
- "a2.contract": name,
387
- "a2.session_id": sessionId,
388
- "a2.state.reducer": reducer.name
389
- }, async (span) => {
390
- let state = cloneInitial(reducer.initialState);
391
- let index = 0;
392
- let snapshotOutcome = "miss";
393
- let stateRead;
394
- try {
395
- stateRead = await log.readState(nsId(sessionId), reducer.name);
396
- } catch {
397
- try {
398
- stateRead = {
399
- snapshot: null,
400
- events: await log.read(nsId(sessionId))
401
- };
402
- } catch (err) {
403
- throw asLogUnavailable(err);
404
- }
405
- }
406
- const snap = stateRead.snapshot;
407
- let rows = stateRead.events;
408
- if (snap) {
409
- if (reducer.stateSchema) {
410
- const result = validateSync(reducer.stateSchema, snap.state, "the stateSchema");
411
- if (result.issues) snapshotOutcome = "rejected";
412
- else {
413
- state = result.value;
414
- index = snap.index;
415
- snapshotOutcome = "hit";
416
- }
417
- } else {
418
- state = snap.state;
419
- index = snap.index;
420
- snapshotOutcome = "hit";
421
- }
422
- }
423
- span.setAttribute("a2.state.snapshot", snapshotOutcome);
424
- if (snapshotOutcome === "rejected") try {
425
- rows = await log.read(nsId(sessionId));
426
- } catch (err) {
427
- throw asLogUnavailable(err);
428
- }
429
- for (const row of rows) {
430
- state = reducer.fold(state, toPublic(row));
431
- index = row.index;
432
- }
433
- span.setAttribute("a2.state.folded", rows.length);
434
- span.setAttribute("a2.state.index", index);
435
- if (rows.length > 0) {
436
- const snapshotState = cloneInitial(state);
437
- const write = Promise.resolve().then(() => log.putSnapshot(nsId(sessionId), reducer.name, index, snapshotState)).catch(() => {});
438
- track(write);
439
- platformWaitUntil(write);
440
- }
441
- return {
442
- state,
443
- index
444
- };
445
- });
446
- };
447
- const makeSession = (id, append) => ({
448
- id,
449
- append,
450
- history: async () => (await readAll(id)).map(toPublic),
451
- state: (reducer) => readState(id, reducer),
452
- stream: (opts) => {
453
- const log = resolveLog();
454
- const startAt = opts?.startAt ?? 0;
455
- const outer = async function* () {
456
- const inner = (await log).stream(nsId(id), { startAt });
457
- for await (const event of inner) yield {
458
- id: event.id,
459
- type: event.type,
460
- payload: event.payload,
461
- index: event.index,
462
- sessionId: id,
463
- createdAt: event.createdAt
464
- };
465
- };
466
- return outer();
467
- }
468
- });
469
- const makeCtx = (trigger, attempt, signal) => {
470
- const publicSessionId = stripNs(trigger.sessionId);
471
- const append = async (appendName, ...events) => {
472
- assertAppendName(appendName);
473
- const generatedIds = events.map((event) => event.id === void 0);
474
- const withIds = await Promise.all(events.map(async (event, item) => {
475
- if (event.id !== void 0) return event;
476
- const id = await deterministicEventId(trigger.id, appendName, item);
477
- return {
478
- ...event,
479
- id
480
- };
481
- }));
482
- return appendCore(publicSessionId, withIds, "handler", "inline", (_rows, recoveryDueAt) => scheduleDrain(publicSessionId, recoveryDueAt), {
483
- index: trigger.index,
484
- attempt
485
- }, generatedIds);
486
- };
487
- return {
488
- event: toPublic(trigger),
489
- attempt,
490
- session: makeSession(publicSessionId, append),
491
- signal
492
- };
493
- };
494
- const abortHubs = /* @__PURE__ */ new Map();
495
- const createAbortHub = (log, ns, startAt) => {
496
- const hub = {
497
- subscriptions: /* @__PURE__ */ new Set(),
498
- seen: /* @__PURE__ */ new Map(),
499
- startAt,
500
- ready: Promise.resolve(),
501
- watch: null,
502
- error: null,
503
- iterator: null,
504
- stopped: false
505
- };
506
- hub.ready = (async () => {
507
- const existing = await log.read(ns, { afterIndex: startAt });
508
- for (const event of existing) dispatchAbortEvent(hub, event);
509
- const tail = existing.at(-1)?.index ?? startAt;
510
- const iterator = log.stream(ns, { startAt: tail })[Symbol.asyncIterator]();
511
- hub.iterator = iterator;
512
- hub.watch = (async () => {
513
- for (;;) {
514
- const { value, done } = await iterator.next();
515
- if (done || value === void 0 || hub.stopped) return;
516
- dispatchAbortEvent(hub, value);
517
- }
518
- })().catch((error) => {
519
- hub.error = error;
520
- });
521
- })().catch((error) => {
522
- hub.error = error;
523
- if (abortHubs.get(ns) === hub) abortHubs.delete(ns);
524
- throw error;
525
- });
526
- abortHubs.set(ns, hub);
527
- return hub;
528
- };
529
- const subscribeAbort = async (log, ns, trigger, abortOn, attempt) => {
530
- let hub = abortHubs.get(ns);
531
- if (!hub) hub = createAbortHub(log, ns, trigger.index);
532
- const activeTypes = /* @__PURE__ */ new Set();
533
- for (const active of hub.subscriptions) for (const type of active.types) activeTypes.add(type);
534
- const publicTrigger = toPublic(trigger);
535
- const subscription = {
536
- controller: new AbortController(),
537
- triggerIndex: trigger.index,
538
- types: new Set(abortOn.keys()),
539
- matches: (event) => {
540
- if (event.index <= trigger.index) return false;
541
- const matcher = abortOn.get(event.type);
542
- if (matcher === void 0) return false;
543
- return matcher === true ? true : matcher(toPublic(event), publicTrigger, { attempt });
544
- }
545
- };
546
- hub.subscriptions.add(subscription);
547
- if (trigger.index < hub.startAt || [...subscription.types].some((type) => !activeTypes.has(type))) {
548
- const earlier = await log.read(ns, { afterIndex: trigger.index });
549
- hub.startAt = Math.min(hub.startAt, trigger.index);
550
- for (const event of earlier) dispatchAbortEvent(hub, event);
551
- }
552
- for (const event of hub.seen.values()) if (subscription.matches(event)) subscription.controller.abort();
553
- await hub.ready;
554
- if (hub.error !== null) throw hub.error;
555
- return {
556
- signal: subscription.controller.signal,
557
- monitor: async (operation) => {
558
- const outcome = await Promise.race([operation.then((value) => ({
559
- type: "completed",
560
- value
561
- }), (error) => ({
562
- type: "handler-failed",
563
- error
564
- })), hub.watch.then(() => hub.error === null ? { type: "monitor-closed" } : {
565
- type: "monitor-failed",
566
- error: hub.error
567
- })]);
568
- if (outcome.type === "completed") return outcome.value;
569
- if (outcome.type === "handler-failed") throw outcome.error;
570
- if (outcome.type === "monitor-failed") {
571
- for (const active of hub.subscriptions) active.controller.abort(outcome.error);
572
- await operation.catch(() => {});
573
- throw outcome.error;
574
- }
575
- await operation.catch(() => {});
576
- throw new Error("abort monitor closed while handlers were active");
577
- },
578
- close: async () => {
579
- hub.subscriptions.delete(subscription);
580
- if (hub.subscriptions.size > 0) {
581
- pruneAbortHub(hub);
582
- return;
583
- }
584
- hub.stopped = true;
585
- abortHubs.delete(ns);
586
- await hub.ready;
587
- await hub.iterator?.return?.();
588
- await hub.watch;
589
- }
590
- };
591
- };
592
- const returnedEvents = async (sessionId, trigger, result) => {
593
- if (result === void 0) return [];
594
- const events = Array.isArray(result) ? [...result] : [result];
595
- if (events.length === 0) return [];
596
- const withIds = await Promise.all(events.map(async (event, item) => ({
597
- ...event,
598
- id: event.id ?? await deterministicReturnedEventId(trigger.id, item)
599
- })));
600
- return validateEvents(sessionId, withIds).map((event) => ({
601
- ...event,
602
- id: event.id,
603
- cause: {
604
- index: trigger.index,
605
- attempt: trigger.attemptCount
606
- }
607
- }));
608
- };
609
- const drainSession = (sessionId, recoveryDueAt, signal) => telemetry.span("a2.drain", {
610
- "a2.contract": name,
611
- "a2.session_id": sessionId
612
- }, async (span) => {
613
- const log = await resolveLog();
614
- const ns = nsId(sessionId);
615
- const holder = crypto.randomUUID();
616
- const active = /* @__PURE__ */ new Map();
617
- const excluded = /* @__PURE__ */ new Set();
618
- let processed = 0;
619
- let sawFailure = false;
620
- let recoveryArm;
621
- let heartbeat = null;
622
- let renewal = null;
623
- let stopRenewing = false;
624
- const alignRecoveryAt = (minimumDueAt) => {
625
- if (recoveryDueAt === void 0) return minimumDueAt;
626
- return recoveryDueAt + Math.max(0, Math.ceil((minimumDueAt - recoveryDueAt) / DRAIN_TIMINGS.claimHeartbeatMs)) * DRAIN_TIMINGS.claimHeartbeatMs;
627
- };
628
- const requestRecovery = (dueAt) => {
629
- const arm = startRecoveryArm(sessionId, alignRecoveryAt(dueAt));
630
- if (arm) recoveryArm = arm;
631
- };
632
- const renew = () => {
633
- if (stopRenewing || renewal || active.size === 0) return renewal ?? Promise.resolve();
634
- const window = nextClaimWindow();
635
- requestRecovery(window.recoveryAtMs);
636
- const indexes = [...active.keys()];
637
- const operation = log.renewClaims({
638
- sessionId: ns,
639
- holder,
640
- indexes,
641
- ttlMs: window.ttlMs,
642
- ...window.deadlineCapped ? { expiresAtMs: window.expiresAtMs } : {}
643
- }).then(() => {
644
- if (window.deadlineCapped) stopRenewing = true;
645
- }, () => void 0);
646
- let tracked;
647
- tracked = operation.finally(() => {
648
- if (renewal === tracked) renewal = null;
649
- });
650
- renewal = tracked;
651
- return tracked;
652
- };
653
- const runEvent = async (event) => {
654
- const registration = handlers.get(event.type);
655
- return telemetry.span("a2.event", {
656
- "a2.contract": name,
657
- "a2.session_id": sessionId,
658
- "a2.event.type": event.type,
659
- "a2.event.index": event.index,
660
- "a2.event.id": event.id,
661
- "a2.event.attempt": event.attemptCount,
662
- "a2.event.handled": registration !== void 0,
663
- ...event.lane === null ? {} : { "a2.event.lane": event.lane }
664
- }, async (eventSpan) => {
665
- let subscription;
666
- let handlerSignal = dormantSignal;
667
- let returned;
668
- try {
669
- if (registration?.abortOn) {
670
- subscription = await subscribeAbort(log, ns, event, registration.abortOn, event.attemptCount);
671
- handlerSignal = subscription.signal;
672
- }
673
- const operation = registration?.handler(makeCtx(event, event.attemptCount, handlerSignal));
674
- const result = operation ? await (subscription ? subscription.monitor(operation) : operation) : void 0;
675
- if (subscription) {
676
- await subscription.close();
677
- subscription = void 0;
678
- }
679
- returned = await returnedEvents(sessionId, event, result);
680
- } catch (err) {
681
- eventSpan.recordError(err);
682
- const failure = await log.failAttempt({
683
- sessionId: ns,
684
- index: event.index,
685
- attempt: event.attemptCount,
686
- error: describeError(err),
687
- maxFailures: MAX_FAILURES
688
- });
689
- eventSpan.setAttribute("a2.event.outcome", failure.outcome);
690
- return {
691
- status: failure.outcome,
692
- opensLane: false,
693
- appendsEvents: false
694
- };
695
- } finally {
696
- await subscription?.close();
697
- }
698
- await renewal;
699
- const completion = await log.completeAttempt({
700
- sessionId: ns,
701
- index: event.index,
702
- attempt: event.attemptCount,
703
- events: returned
704
- });
705
- eventSpan.setAttribute("a2.event.outcome", completion.outcome === "completed" ? "processed" : completion.outcome);
706
- if (completion.outcome === "superseded") return {
707
- status: "superseded",
708
- opensLane: false,
709
- appendsEvents: false
710
- };
711
- if (handlerSignal.aborted) eventSpan.setAttribute("a2.event.aborted", true);
712
- return {
713
- status: "processed",
714
- opensLane: event.lane !== null,
715
- appendsEvents: returned.length > 0
716
- };
717
- });
718
- };
719
- let claimAgain = false;
720
- const start = (event) => {
721
- const execution = runEvent(event).then((eventOutcome) => {
722
- active.delete(event.index);
723
- if (eventOutcome.status === "processed") processed += 1;
724
- if (eventOutcome.status === "failed") {
725
- excluded.add(event.index);
726
- sawFailure = true;
727
- }
728
- claimAgain ||= eventOutcome.opensLane || eventOutcome.appendsEvents;
729
- return eventOutcome;
730
- });
731
- active.set(event.index, execution);
732
- if (!heartbeat && !stopRenewing) {
733
- heartbeat = setInterval(() => void renew(), DRAIN_TIMINGS.claimHeartbeatMs);
734
- heartbeat.unref?.();
735
- }
736
- };
737
- let outcome = "settled";
738
- let shouldClaim = true;
739
- try {
740
- for (;;) {
741
- const signalVersion = signal?.version ?? 0;
742
- let claim;
743
- if (shouldClaim) {
744
- const window = nextClaimWindow();
745
- claim = await log.claimAvailable({
746
- sessionId: ns,
747
- holder,
748
- ttlMs: window.ttlMs,
749
- ...window.deadlineCapped ? { expiresAtMs: window.expiresAtMs } : {},
750
- ...excluded.size === 0 ? {} : { excludeIndexes: [...excluded] }
751
- });
752
- if (claim.outcome === "claimed") {
753
- requestRecovery(window.recoveryAtMs);
754
- if (window.deadlineCapped) stopRenewing = true;
755
- for (const event of claim.events) start(event);
756
- shouldClaim = false;
757
- }
758
- }
759
- if (active.size > 0) {
760
- const wake = signal?.wait(signalVersion);
761
- await Promise.race([...active.values(), ...wake ? [wake.promise] : []]);
762
- wake?.cancel();
763
- shouldClaim = claimAgain || active.size === 0 || signal !== void 0 && signal.version !== signalVersion;
764
- claimAgain = false;
765
- continue;
766
- }
767
- if (!shouldClaim) {
768
- shouldClaim = true;
769
- continue;
770
- }
771
- if (claim === void 0) continue;
772
- if (signal && signal.version !== signalVersion) continue;
773
- if (claim.outcome === "busy") {
774
- requestRecovery(claim.retryAt.getTime() + DRAIN_TIMINGS.recoveryGraceMs);
775
- outcome = "busy";
776
- } else if (sawFailure) outcome = "stalled";
777
- if (signal) signal.closed = true;
778
- break;
779
- }
780
- } finally {
781
- if (signal) signal.closed = true;
782
- stopRenewing = true;
783
- if (heartbeat) clearInterval(heartbeat);
784
- await renewal;
785
- await Promise.allSettled(active.values());
786
- }
787
- span.setAttribute("a2.drain.processed", processed);
788
- span.setAttribute("a2.drain.outcome", outcome);
789
- return {
790
- settled: outcome === "settled",
791
- outcome,
792
- processed,
793
- ...recoveryArm ? { recoveryArm } : {}
794
- };
795
- });
796
- const appendExternal = async (sessionId, mode, events) => {
797
- const pushed = events.filter((event) => Reflect.get(event, "~a2.pushed") === true);
798
- if (pushed.length > 0) await options.validatePush?.({
799
- sessionId,
800
- events: pushed
801
- });
802
- return appendCore(sessionId, events, "external", mode, (_rows, recoveryDueAt) => scheduleDrain(sessionId, recoveryDueAt));
803
- };
804
- const self = {
805
- contract: serverContract,
806
- session(id) {
807
- assertSessionId(id);
808
- const appendInline = async (...events) => appendExternal(id, "inline", events);
809
- const appendDispatch = async (...events) => appendExternal(id, "dispatch", events);
810
- const append = Object.assign(appendInline, { dispatch: appendDispatch });
811
- return makeSession(id, append);
812
- },
813
- async drain(sessionId) {
814
- assertSessionId(sessionId);
815
- const { settled } = await drainSession(sessionId);
816
- return { settled };
817
- }
818
- };
819
- serverInternals.set(self, {
820
- settle: async () => {
821
- while (inFlight.size > 0) await Promise.allSettled(inFlight);
822
- },
823
- recoveryDrain: (sessionId, opts) => {
824
- assertSessionId(sessionId);
825
- return drainSession(sessionId, opts?.recoveryDueAt);
826
- }
827
- });
828
- serverInspection.set(self, {
829
- async listSessions(inspectionOptions) {
830
- const log = await resolveLog();
831
- if (!log.inspect) throw new TypeError("this log backend does not support inspection");
832
- const page = await log.inspect.listSessions({
833
- prefix: `${name}${NS}`,
834
- limit: inspectionOptions.limit,
835
- ...inspectionOptions.cursor !== void 0 ? { cursor: inspectionOptions.cursor } : {}
836
- });
837
- return {
838
- cursor: page.cursor,
839
- sessions: page.sessions.map((session) => ({
840
- ...session,
841
- sessionId: stripNs(session.sessionId)
842
- }))
843
- };
844
- },
845
- async readSession(sessionId) {
846
- assertSessionId(sessionId);
847
- const log = await resolveLog();
848
- if (!log.inspect) throw new TypeError("this log backend does not support inspection");
849
- const ns = nsId(sessionId);
850
- const [events, snapshots] = await Promise.all([log.read(ns), log.inspect.listSnapshots(ns)]);
851
- return {
852
- events: events.map((event) => ({
853
- ...event,
854
- sessionId
855
- })),
856
- snapshots
857
- };
858
- }
859
- });
860
- return self;
861
- }
862
- function assertSessionId(id) {
863
- if (typeof id !== "string" || id.length === 0) throw new TypeError("session id must be a non-empty string");
864
- if (id.includes(NS)) throw new TypeError("session id contains a reserved control character");
865
- }
866
- function assertAppendName(name) {
867
- if (typeof name !== "string" || name.length === 0) throw new TypeError("handler append name must be a non-empty string");
868
- }
869
- function cloneInitial(initial) {
870
- try {
871
- return structuredClone(initial);
872
- } catch {
873
- return initial;
874
- }
875
- }
876
- //#endregion
877
- export { createServer as t };