experimental-a2 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +169 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +321 -60
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
import { n as validateSync } from "./validate-XKT4FSNn.js";
|
|
2
|
+
import { n as asStoreUnavailable, t as A2Error } from "./errors-BQuJpe82.js";
|
|
3
|
+
import { g as nullProtoRecord, h as markSchedulerSendFailure, i as POLL_TIMINGS, o as RESERVED_PARTICIPANT_IDS, t as DRAIN_TIMINGS, v as serverInternals, y as serverSchedulerBindings } from "./internal-aEotMzu_.js";
|
|
4
|
+
import { n as InspectionUnsupportedError, r as serverInspection } from "./inspection-DaxB5jM2.js";
|
|
5
|
+
import { t as defaultSleep } from "./store-polling-DgrrAE3d.js";
|
|
6
|
+
import { r as platformWaitUntil, t as invocationDeadlineMs } from "./platform-B4TnJtWu.js";
|
|
7
|
+
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
8
|
+
//#region src/deterministic-id.ts
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic default ids for handler `session.append` calls. A handler
|
|
11
|
+
* re-run after a crash calls `session.append` again; giving
|
|
12
|
+
* each call a deterministic id — derived from the triggering event's id
|
|
13
|
+
* and the caller's stable name — makes the re-run a no-op via the store's
|
|
14
|
+
* unique index on event ids.
|
|
15
|
+
*
|
|
16
|
+
* The id is a UUIDv5-shaped SHA-1 over
|
|
17
|
+
* `(namespace, triggerEventId, name, itemOrdinal)`, computed with
|
|
18
|
+
* WebCrypto so it runs on any platform a2 core targets.
|
|
19
|
+
*/
|
|
20
|
+
const APPEND_NAMESPACE = "a2:handler-append:v2";
|
|
21
|
+
const RETURN_NAMESPACE = "a2:handler-return:v1";
|
|
22
|
+
const SCHEDULE_NAMESPACE = "a2:schedule:v1";
|
|
23
|
+
const SCHEDULED_EVENT_NAMESPACE = "a2:scheduled-event:v1";
|
|
24
|
+
async function deterministicIdFromInput(input) {
|
|
25
|
+
const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(input));
|
|
26
|
+
const bytes = new Uint8Array(digest).slice(0, 16);
|
|
27
|
+
bytes[6] = (bytes[6] ?? 0) & 15 | 80;
|
|
28
|
+
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
29
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
30
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
31
|
+
}
|
|
32
|
+
async function deterministicId(parts) {
|
|
33
|
+
return deterministicIdFromInput(parts.join("\0"));
|
|
34
|
+
}
|
|
35
|
+
async function deterministicEventId(triggerEventId, name, itemOrdinal) {
|
|
36
|
+
if (name.length === 0) throw new TypeError("append name must not be empty");
|
|
37
|
+
return deterministicId([
|
|
38
|
+
APPEND_NAMESPACE,
|
|
39
|
+
triggerEventId,
|
|
40
|
+
name,
|
|
41
|
+
itemOrdinal
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
async function deterministicReturnedEventId(triggerEventId, itemOrdinal) {
|
|
45
|
+
return deterministicId([
|
|
46
|
+
RETURN_NAMESPACE,
|
|
47
|
+
triggerEventId,
|
|
48
|
+
itemOrdinal
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
async function deterministicScheduleId(contract, sessionId, triggerEventId, name) {
|
|
52
|
+
return deterministicIdFromInput(JSON.stringify([
|
|
53
|
+
SCHEDULE_NAMESPACE,
|
|
54
|
+
contract,
|
|
55
|
+
sessionId,
|
|
56
|
+
triggerEventId,
|
|
57
|
+
name
|
|
58
|
+
]));
|
|
59
|
+
}
|
|
60
|
+
async function deterministicScheduledEventId(scheduleId, itemOrdinal) {
|
|
61
|
+
return deterministicId([
|
|
62
|
+
SCHEDULED_EVENT_NAMESPACE,
|
|
63
|
+
scheduleId,
|
|
64
|
+
itemOrdinal
|
|
65
|
+
]);
|
|
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
|
+
/**
|
|
78
|
+
* Deliver one authenticated scheduler append through an A2 server's ordinary
|
|
79
|
+
* top-level append path. Custom scheduler adapters call this after validating
|
|
80
|
+
* their transport envelope; application code normally uses `session.schedule`.
|
|
81
|
+
*/
|
|
82
|
+
async function deliverSchedulerAppend(server, task) {
|
|
83
|
+
if (server.contract.name !== task.contract) throw new TypeError(`a2 scheduler: append task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`);
|
|
84
|
+
const internals = serverInternals.get(server);
|
|
85
|
+
if (!internals) throw new TypeError(`a2 scheduler: server for contract '${task.contract}' cannot receive scheduled appends`);
|
|
86
|
+
await internals.schedulerAppend(task.sessionId, task.events);
|
|
87
|
+
}
|
|
88
|
+
const MAX_FAILURES = 10;
|
|
89
|
+
/** Default: how long a presence value survives without a refreshing set. */
|
|
90
|
+
const PRESENCE_TTL_MS = 6e4;
|
|
91
|
+
function createDrainSignal() {
|
|
92
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
93
|
+
return {
|
|
94
|
+
version: 0,
|
|
95
|
+
closed: false,
|
|
96
|
+
notify() {
|
|
97
|
+
this.version += 1;
|
|
98
|
+
for (const listener of listeners) listener();
|
|
99
|
+
listeners.clear();
|
|
100
|
+
},
|
|
101
|
+
wait(version) {
|
|
102
|
+
if (this.version !== version) return {
|
|
103
|
+
promise: Promise.resolve(),
|
|
104
|
+
cancel: () => {}
|
|
105
|
+
};
|
|
106
|
+
let resolve;
|
|
107
|
+
const promise = new Promise((done) => {
|
|
108
|
+
resolve = done;
|
|
109
|
+
});
|
|
110
|
+
listeners.add(resolve);
|
|
111
|
+
return {
|
|
112
|
+
promise,
|
|
113
|
+
cancel: () => listeners.delete(resolve)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function dispatchAbortEvent(hub, event) {
|
|
119
|
+
let relevant = false;
|
|
120
|
+
for (const subscription of hub.subscriptions) {
|
|
121
|
+
if (!subscription.types.has(event.type)) continue;
|
|
122
|
+
relevant = true;
|
|
123
|
+
if (subscription.matches(event)) subscription.controller.abort();
|
|
124
|
+
}
|
|
125
|
+
if (relevant) hub.seen.set(event.index, event);
|
|
126
|
+
}
|
|
127
|
+
function pruneAbortHub(hub) {
|
|
128
|
+
let minimum = Infinity;
|
|
129
|
+
const types = /* @__PURE__ */ new Set();
|
|
130
|
+
for (const subscription of hub.subscriptions) {
|
|
131
|
+
minimum = Math.min(minimum, subscription.triggerIndex);
|
|
132
|
+
for (const type of subscription.types) types.add(type);
|
|
133
|
+
}
|
|
134
|
+
for (const [index, event] of hub.seen) if (index <= minimum || !types.has(event.type)) hub.seen.delete(index);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Deadlines tighten the claim/watchdog window; they never decide whether a
|
|
138
|
+
* handler starts. A platform timeout therefore follows the same path as any
|
|
139
|
+
* other process death: the in-flight event remains pending and is retried.
|
|
140
|
+
*/
|
|
141
|
+
function nextClaimWindow() {
|
|
142
|
+
const nowMs = Date.now();
|
|
143
|
+
const deadlineMs = invocationDeadlineMs();
|
|
144
|
+
const deadlineTtlMs = deadlineMs === null ? DRAIN_TIMINGS.claimTtlMs : deadlineMs - nowMs;
|
|
145
|
+
const deadlineCapped = deadlineMs !== null && deadlineTtlMs <= DRAIN_TIMINGS.claimTtlMs;
|
|
146
|
+
const ttlMs = Math.max(1, Math.min(DRAIN_TIMINGS.claimTtlMs, deadlineTtlMs));
|
|
147
|
+
const expiresAtMs = nowMs + ttlMs;
|
|
148
|
+
return {
|
|
149
|
+
ttlMs,
|
|
150
|
+
expiresAtMs,
|
|
151
|
+
watchdogAtMs: expiresAtMs + DRAIN_TIMINGS.watchdogGraceMs,
|
|
152
|
+
deadlineCapped
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function schedulerSlot(dueAt) {
|
|
156
|
+
return Math.ceil(dueAt / 1e3) * 1e3;
|
|
157
|
+
}
|
|
158
|
+
function currentSchedulerSlot(now) {
|
|
159
|
+
return Math.floor(now / 1e3) * 1e3;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The namespace separator between machine name and session id in
|
|
163
|
+
* storage. Machines sharing one store backend (the dev-default sqlite
|
|
164
|
+
* file, a shared Postgres) must not collide on session ids; the machine
|
|
165
|
+
* name is what makes multi-machine apps unambiguous (a2-api.md §1), so
|
|
166
|
+
* it prefixes every storage key.
|
|
167
|
+
*/
|
|
168
|
+
const NS = "";
|
|
169
|
+
const devDefaultStore = retryableLazy(() => import("./store-sqlite.js").then((m) => m.sqlite()));
|
|
170
|
+
function environment() {
|
|
171
|
+
const env = typeof process === "undefined" ? void 0 : process.env?.["NODE_ENV"];
|
|
172
|
+
if (env === "test") return "test";
|
|
173
|
+
if (env === "production") return "production";
|
|
174
|
+
return "development";
|
|
175
|
+
}
|
|
176
|
+
function describeError(err) {
|
|
177
|
+
if (err instanceof Error) return err.stack ?? `${err.name}: ${err.message}`;
|
|
178
|
+
return String(err);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Comparison key for a presence value in the degraded-tier diff — an
|
|
182
|
+
* LWW tie can replace a value without moving its `seen`/`at` stamp.
|
|
183
|
+
*/
|
|
184
|
+
function fingerprint(value) {
|
|
185
|
+
return JSON.stringify(value) ?? "";
|
|
186
|
+
}
|
|
187
|
+
function foldPresenceRows(rows) {
|
|
188
|
+
const map = nullProtoRecord();
|
|
189
|
+
for (const row of rows) (map[row.participant] ??= nullProtoRecord())[row.field] = {
|
|
190
|
+
value: row.value,
|
|
191
|
+
seen: row.seen,
|
|
192
|
+
at: row.at
|
|
193
|
+
};
|
|
194
|
+
return map;
|
|
195
|
+
}
|
|
196
|
+
/** Implement a contract: bind its vocabulary to storage and reactions. */
|
|
197
|
+
function createServer(options) {
|
|
198
|
+
const serverContract = options?.contract;
|
|
199
|
+
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)");
|
|
200
|
+
const name = serverContract.name;
|
|
201
|
+
const defs = serverContract.events;
|
|
202
|
+
const presenceDefs = serverContract.presence;
|
|
203
|
+
const declaresPresence = Object.keys(presenceDefs).length > 0;
|
|
204
|
+
const presenceNotSupported = () => new A2Error("PRESENCE_NOT_SUPPORTED", `the contract '${name}' declares presence but the store backend for its server has no presence capability (A2Store.presence)`);
|
|
205
|
+
if (options.presence !== void 0) {
|
|
206
|
+
if (!declaresPresence) throw new TypeError(`the presence option requires a contract that declares presence fields — '${name}' has none`);
|
|
207
|
+
const { ttlMs } = options.presence;
|
|
208
|
+
if (ttlMs !== void 0 && (!Number.isInteger(ttlMs) || ttlMs <= 0)) throw new TypeError("presence.ttlMs must be a positive integer of milliseconds");
|
|
209
|
+
}
|
|
210
|
+
const presenceTtlMs = options.presence?.ttlMs ?? PRESENCE_TTL_MS;
|
|
211
|
+
let makeStore;
|
|
212
|
+
if (options.store) {
|
|
213
|
+
const explicit = options.store;
|
|
214
|
+
if (declaresPresence && !explicit.presence) throw presenceNotSupported();
|
|
215
|
+
makeStore = () => Promise.resolve(explicit);
|
|
216
|
+
} else {
|
|
217
|
+
const env = environment();
|
|
218
|
+
if (env === "production") {
|
|
219
|
+
const error = () => new A2Error("STORE_NOT_CONFIGURED", `the server for '${name}' has no store configured and NODE_ENV is 'production' — pass an explicit store backend (e.g. postgres from 'experimental-a2/store-postgres')`);
|
|
220
|
+
if (process.env["NEXT_PHASE"] !== "phase-production-build") throw error();
|
|
221
|
+
makeStore = () => Promise.reject(error());
|
|
222
|
+
} else makeStore = env === "test" ? () => import("./store-memory.js").then((m) => m.memory()) : devDefaultStore.get;
|
|
223
|
+
}
|
|
224
|
+
if (declaresPresence && !options.store) {
|
|
225
|
+
const inner = makeStore;
|
|
226
|
+
makeStore = async () => {
|
|
227
|
+
const store = await inner();
|
|
228
|
+
if (!store.presence) throw presenceNotSupported();
|
|
229
|
+
return store;
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const resolveStore = retryableLazy(makeStore).get;
|
|
233
|
+
const telemetry = options.telemetry ?? NOOP_TELEMETRY;
|
|
234
|
+
const scheduler = options.scheduler;
|
|
235
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
236
|
+
for (const [type, entry] of Object.entries(options.handlers ?? {})) {
|
|
237
|
+
if (entry === void 0) continue;
|
|
238
|
+
if (!Object.hasOwn(defs, type)) throw new TypeError(`contract '${name}' has no event type '${type}' — cannot register a handler for it`);
|
|
239
|
+
const handler = typeof entry === "function" ? entry : entry?.handler;
|
|
240
|
+
if (typeof handler !== "function") throw new TypeError(`handler for '${type}' must be a function`);
|
|
241
|
+
let abortOn = null;
|
|
242
|
+
const spec = typeof entry === "function" ? void 0 : entry.abortOn;
|
|
243
|
+
if (spec !== void 0) {
|
|
244
|
+
abortOn = /* @__PURE__ */ new Map();
|
|
245
|
+
const pairs = Array.isArray(spec) ? spec.map((abortType) => [abortType, true]) : Object.entries(spec);
|
|
246
|
+
for (const [abortType, matcher] of pairs) {
|
|
247
|
+
if (matcher === void 0) continue;
|
|
248
|
+
if (!Object.hasOwn(defs, abortType)) throw new TypeError(`contract '${name}' has no event type '${String(abortType)}' — cannot abort on it`);
|
|
249
|
+
abortOn.set(String(abortType), matcher);
|
|
250
|
+
}
|
|
251
|
+
if (abortOn.size === 0) abortOn = null;
|
|
252
|
+
}
|
|
253
|
+
const lane = typeof entry === "function" ? null : entry.lane ?? null;
|
|
254
|
+
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`);
|
|
255
|
+
handlers.set(type, {
|
|
256
|
+
handler,
|
|
257
|
+
abortOn,
|
|
258
|
+
lane
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const prefix = `${name}${NS}`;
|
|
262
|
+
const nsId = (sessionId) => `${prefix}${sessionId}`;
|
|
263
|
+
const stripNs = (stored) => stored.slice(prefix.length);
|
|
264
|
+
const toPublic = (row) => ({
|
|
265
|
+
id: row.id,
|
|
266
|
+
type: row.type,
|
|
267
|
+
payload: row.payload,
|
|
268
|
+
index: row.index,
|
|
269
|
+
sessionId: stripNs(row.sessionId),
|
|
270
|
+
createdAt: row.createdAt
|
|
271
|
+
});
|
|
272
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
273
|
+
const track = (p) => {
|
|
274
|
+
inFlight.add(p);
|
|
275
|
+
const drop = () => {
|
|
276
|
+
inFlight.delete(p);
|
|
277
|
+
};
|
|
278
|
+
p.then(drop, drop);
|
|
279
|
+
};
|
|
280
|
+
const schedulerArmSlots = /* @__PURE__ */ new Map();
|
|
281
|
+
/**
|
|
282
|
+
* Start an optional scheduler arm without putting it on the execution path.
|
|
283
|
+
* The returned raw promise lets the initial append report failure; the
|
|
284
|
+
* tracked copy is always rejection-safe for heartbeat callers.
|
|
285
|
+
*/
|
|
286
|
+
const startSchedulerArm = (sessionId, dueAt) => {
|
|
287
|
+
if (!scheduler) return null;
|
|
288
|
+
const slottedDueAt = schedulerSlot(dueAt);
|
|
289
|
+
const slotKey = JSON.stringify([sessionId, slottedDueAt]);
|
|
290
|
+
const existing = schedulerArmSlots.get(slotKey);
|
|
291
|
+
if (existing) return existing.promise;
|
|
292
|
+
const raw = Promise.resolve().then(() => scheduler.schedule({
|
|
293
|
+
version: 1,
|
|
294
|
+
kind: "drain",
|
|
295
|
+
contract: name,
|
|
296
|
+
sessionId,
|
|
297
|
+
dueAt: slottedDueAt
|
|
298
|
+
}));
|
|
299
|
+
const forget = setTimeout(() => {
|
|
300
|
+
if (schedulerArmSlots.get(slotKey)?.promise === raw) schedulerArmSlots.delete(slotKey);
|
|
301
|
+
}, Math.max(0, slottedDueAt - Date.now()) + 1e3);
|
|
302
|
+
forget.unref?.();
|
|
303
|
+
schedulerArmSlots.set(slotKey, {
|
|
304
|
+
promise: raw,
|
|
305
|
+
forget
|
|
306
|
+
});
|
|
307
|
+
raw.then(void 0, () => {
|
|
308
|
+
const current = schedulerArmSlots.get(slotKey);
|
|
309
|
+
if (current?.promise !== raw) return;
|
|
310
|
+
clearTimeout(current.forget);
|
|
311
|
+
schedulerArmSlots.delete(slotKey);
|
|
312
|
+
});
|
|
313
|
+
const safe = raw.catch(() => {});
|
|
314
|
+
track(safe);
|
|
315
|
+
platformWaitUntil(safe);
|
|
316
|
+
return raw;
|
|
317
|
+
};
|
|
318
|
+
const scheduledDrains = /* @__PURE__ */ new Map();
|
|
319
|
+
const scheduleDrain = (sessionId, watchdogDueAt) => {
|
|
320
|
+
const existing = scheduledDrains.get(sessionId);
|
|
321
|
+
if (existing && !existing.signal.closed) {
|
|
322
|
+
existing.signal.notify();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (existing) scheduledDrains.delete(sessionId);
|
|
326
|
+
const signal = createDrainSignal();
|
|
327
|
+
const p = new Promise((resolve) => {
|
|
328
|
+
queueMicrotask(() => {
|
|
329
|
+
drainSession(sessionId, watchdogDueAt, signal).then(() => resolve()).catch(() => resolve());
|
|
330
|
+
});
|
|
331
|
+
}).finally(() => {
|
|
332
|
+
if (scheduledDrains.get(sessionId)?.promise === p) scheduledDrains.delete(sessionId);
|
|
333
|
+
});
|
|
334
|
+
scheduledDrains.set(sessionId, {
|
|
335
|
+
signal,
|
|
336
|
+
promise: p
|
|
337
|
+
});
|
|
338
|
+
track(p);
|
|
339
|
+
platformWaitUntil(p);
|
|
340
|
+
};
|
|
341
|
+
const validateEvents = (sessionId, events, resolveDispatchMetadata = true) => {
|
|
342
|
+
if (events.length === 0) throw new TypeError("append requires at least one event");
|
|
343
|
+
return events.map((e) => {
|
|
344
|
+
const schema = Object.hasOwn(defs, e.type) ? defs[e.type] : void 0;
|
|
345
|
+
if (!schema) throw new A2Error("UNKNOWN_EVENT_TYPE", `contract '${name}' has no event type '${String(e.type)}'`);
|
|
346
|
+
const result = validateSync(schema, e.payload, `event '${e.type}'`);
|
|
347
|
+
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid payload for event '${e.type}' on machine '${name}'`, { details: result.issues });
|
|
348
|
+
const validated = {
|
|
349
|
+
type: e.type,
|
|
350
|
+
payload: result.value
|
|
351
|
+
};
|
|
352
|
+
if (e.id !== void 0) validated.id = e.id;
|
|
353
|
+
if (!resolveDispatchMetadata) return validated;
|
|
354
|
+
const registration = handlers.get(e.type);
|
|
355
|
+
if (!registration) validated.settled = true;
|
|
356
|
+
else if (registration.lane !== null) {
|
|
357
|
+
const lane = typeof registration.lane === "function" ? registration.lane({
|
|
358
|
+
sessionId,
|
|
359
|
+
event: {
|
|
360
|
+
type: e.type,
|
|
361
|
+
payload: structuredClone(result.value),
|
|
362
|
+
...e.id === void 0 ? {} : { id: e.id }
|
|
363
|
+
}
|
|
364
|
+
}) : registration.lane;
|
|
365
|
+
if (typeof lane !== "string" || lane.length === 0) throw new TypeError(`lane for '${e.type}' must resolve to a non-empty string`);
|
|
366
|
+
validated.lane = lane;
|
|
367
|
+
}
|
|
368
|
+
return validated;
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
/**
|
|
372
|
+
* Validate + write. `onAppended` runs inside the telemetry span, so
|
|
373
|
+
* the drain it schedules is created in the append's active context —
|
|
374
|
+
* with a real tracer, causal work remains connected.
|
|
375
|
+
*/
|
|
376
|
+
const appendCore = (sessionId, events, source, mode, onAppended, cause, generatedIds, requireDurableInitialArm = false) => telemetry.span("a2.append", {
|
|
377
|
+
"a2.contract": name,
|
|
378
|
+
"a2.session_id": sessionId,
|
|
379
|
+
"a2.append.source": source,
|
|
380
|
+
"a2.append.mode": mode,
|
|
381
|
+
"a2.append.types": events.map((e) => String(e.type)).join(","),
|
|
382
|
+
"a2.append.count": events.length
|
|
383
|
+
}, async (span) => {
|
|
384
|
+
if (mode === "dispatch" && !scheduler) throw new TypeError("append.dispatch requires a configured scheduler adapter");
|
|
385
|
+
assertSessionId(sessionId);
|
|
386
|
+
const validated = validateEvents(sessionId, events);
|
|
387
|
+
if (cause) for (const [index, event] of validated.entries()) event.cause = generatedIds?.[index] ? {
|
|
388
|
+
...cause,
|
|
389
|
+
batchSize: events.length
|
|
390
|
+
} : cause;
|
|
391
|
+
const store = await resolveStore();
|
|
392
|
+
let rows;
|
|
393
|
+
let shouldHealSession;
|
|
394
|
+
try {
|
|
395
|
+
const result = await store.append(nsId(sessionId), validated);
|
|
396
|
+
rows = result.events;
|
|
397
|
+
shouldHealSession = result.hasPending;
|
|
398
|
+
} catch (err) {
|
|
399
|
+
throw asStoreUnavailable(err);
|
|
400
|
+
}
|
|
401
|
+
const appended = rows.map(toPublic);
|
|
402
|
+
const initialWatchdogAt = shouldHealSession && scheduler && source === "external" && mode === "inline" ? schedulerSlot(nextClaimWindow().watchdogAtMs) : void 0;
|
|
403
|
+
const initialArm = initialWatchdogAt !== void 0 ? startSchedulerArm(sessionId, initialWatchdogAt) : null;
|
|
404
|
+
const dispatchArm = shouldHealSession && mode === "dispatch" ? startSchedulerArm(sessionId, currentSchedulerSlot(Date.now())) : null;
|
|
405
|
+
if (shouldHealSession && mode === "inline") onAppended(appended, initialWatchdogAt);
|
|
406
|
+
if (dispatchArm) await dispatchArm;
|
|
407
|
+
if (initialArm) {
|
|
408
|
+
let timeout = null;
|
|
409
|
+
try {
|
|
410
|
+
await Promise.race([initialArm, new Promise((_, reject) => {
|
|
411
|
+
timeout = setTimeout(() => {
|
|
412
|
+
reject(/* @__PURE__ */ new Error(`a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`));
|
|
413
|
+
}, DRAIN_TIMINGS.schedulerArmTimeoutMs);
|
|
414
|
+
})]);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
span.setAttribute("a2.append.armed", false);
|
|
417
|
+
span.recordError(err);
|
|
418
|
+
if (requireDurableInitialArm) throw err;
|
|
419
|
+
} finally {
|
|
420
|
+
if (timeout) clearTimeout(timeout);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return appended;
|
|
424
|
+
});
|
|
425
|
+
const readHistory = async (sessionId, bounds) => {
|
|
426
|
+
const gte = bounds?.gte;
|
|
427
|
+
const lte = bounds?.lte;
|
|
428
|
+
assertHistoryIndex("gte", gte);
|
|
429
|
+
assertHistoryIndex("lte", lte);
|
|
430
|
+
if (gte !== void 0 && lte !== void 0 && gte > lte) throw new RangeError("history.gte must be less than or equal to history.lte");
|
|
431
|
+
if (lte === 0) return [];
|
|
432
|
+
const store = await resolveStore();
|
|
433
|
+
try {
|
|
434
|
+
const readOptions = gte === void 0 && lte === void 0 ? void 0 : {
|
|
435
|
+
...gte === void 0 ? {} : { afterIndex: Math.max(0, gte - 1) },
|
|
436
|
+
...lte === void 0 ? {} : { throughIndex: lte }
|
|
437
|
+
};
|
|
438
|
+
return (await store.read(nsId(sessionId), readOptions)).filter((row) => (gte === void 0 || row.index >= gte) && (lte === void 0 || row.index <= lte));
|
|
439
|
+
} catch (err) {
|
|
440
|
+
throw asStoreUnavailable(err);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
const readState = async (sessionId, reducer) => {
|
|
444
|
+
const store = await resolveStore();
|
|
445
|
+
return telemetry.span("a2.state", {
|
|
446
|
+
"a2.contract": name,
|
|
447
|
+
"a2.session_id": sessionId,
|
|
448
|
+
"a2.state.reducer": reducer.name
|
|
449
|
+
}, async (span) => {
|
|
450
|
+
let state = cloneInitial(reducer.initialState);
|
|
451
|
+
let index = 0;
|
|
452
|
+
let snapshotOutcome = "miss";
|
|
453
|
+
let stateRead;
|
|
454
|
+
try {
|
|
455
|
+
stateRead = await store.readState(nsId(sessionId), reducer.name);
|
|
456
|
+
} catch {
|
|
457
|
+
try {
|
|
458
|
+
stateRead = {
|
|
459
|
+
snapshot: null,
|
|
460
|
+
events: await store.read(nsId(sessionId))
|
|
461
|
+
};
|
|
462
|
+
} catch (err) {
|
|
463
|
+
throw asStoreUnavailable(err);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const snap = stateRead.snapshot;
|
|
467
|
+
let rows = stateRead.events;
|
|
468
|
+
if (snap) {
|
|
469
|
+
if (reducer.stateSchema) {
|
|
470
|
+
const result = validateSync(reducer.stateSchema, snap.state, "the stateSchema");
|
|
471
|
+
if (result.issues) snapshotOutcome = "rejected";
|
|
472
|
+
else {
|
|
473
|
+
state = result.value;
|
|
474
|
+
index = snap.index;
|
|
475
|
+
snapshotOutcome = "hit";
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
state = snap.state;
|
|
479
|
+
index = snap.index;
|
|
480
|
+
snapshotOutcome = "hit";
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
span.setAttribute("a2.state.snapshot", snapshotOutcome);
|
|
484
|
+
if (snapshotOutcome === "rejected") try {
|
|
485
|
+
rows = await store.read(nsId(sessionId));
|
|
486
|
+
} catch (err) {
|
|
487
|
+
throw asStoreUnavailable(err);
|
|
488
|
+
}
|
|
489
|
+
for (const row of rows) {
|
|
490
|
+
state = reducer.fold(state, toPublic(row));
|
|
491
|
+
index = row.index;
|
|
492
|
+
}
|
|
493
|
+
span.setAttribute("a2.state.folded", rows.length);
|
|
494
|
+
span.setAttribute("a2.state.index", index);
|
|
495
|
+
if (rows.length > 0) {
|
|
496
|
+
const snapshotState = cloneInitial(state);
|
|
497
|
+
const write = Promise.resolve().then(() => store.putSnapshot(nsId(sessionId), reducer.name, index, snapshotState)).catch(() => {});
|
|
498
|
+
track(write);
|
|
499
|
+
platformWaitUntil(write);
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
state,
|
|
503
|
+
index
|
|
504
|
+
};
|
|
505
|
+
});
|
|
506
|
+
};
|
|
507
|
+
const makeSchedule = (sessionId, trigger) => {
|
|
508
|
+
const triggerEventId = trigger?.id ?? null;
|
|
509
|
+
const triggerCreatedAt = trigger?.createdAt.getTime();
|
|
510
|
+
return async (scheduleName, timing, ...events) => {
|
|
511
|
+
if (!scheduler) throw new TypeError("session.schedule requires a configured scheduler adapter");
|
|
512
|
+
assertScheduleName(scheduleName);
|
|
513
|
+
if (events.length === 0) throw new TypeError("schedule requires at least one event");
|
|
514
|
+
const dueAt = scheduleDueAt(timing, triggerCreatedAt ?? Date.now());
|
|
515
|
+
const payloads = events.map((event) => cloneScheduledPayload(event.payload));
|
|
516
|
+
const snapshottedEvents = events.map((event, index) => ({
|
|
517
|
+
type: event.type,
|
|
518
|
+
payload: payloads[index],
|
|
519
|
+
...event.id === void 0 ? {} : { id: event.id }
|
|
520
|
+
}));
|
|
521
|
+
const validated = validateEvents(sessionId, structuredClone(snapshottedEvents), false);
|
|
522
|
+
const scheduleId = await deterministicScheduleId(name, sessionId, triggerEventId, scheduleName);
|
|
523
|
+
const scheduledEvents = await Promise.all(validated.map(async (event, item) => ({
|
|
524
|
+
id: event.id ?? await deterministicScheduledEventId(scheduleId, item),
|
|
525
|
+
type: event.type,
|
|
526
|
+
payload: payloads[item]
|
|
527
|
+
})));
|
|
528
|
+
if (new Set(scheduledEvents.map((event) => event.id)).size !== scheduledEvents.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "scheduled batch contains the same event id more than once");
|
|
529
|
+
try {
|
|
530
|
+
await scheduler.schedule({
|
|
531
|
+
version: 1,
|
|
532
|
+
kind: "append",
|
|
533
|
+
id: scheduleId,
|
|
534
|
+
contract: name,
|
|
535
|
+
sessionId,
|
|
536
|
+
dueAt,
|
|
537
|
+
events: scheduledEvents
|
|
538
|
+
});
|
|
539
|
+
} catch (error) {
|
|
540
|
+
throw markSchedulerSendFailure(error);
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
const toSessionEvent = (id, event) => ({
|
|
545
|
+
id: event.id,
|
|
546
|
+
type: event.type,
|
|
547
|
+
payload: event.payload,
|
|
548
|
+
index: event.index,
|
|
549
|
+
sessionId: id,
|
|
550
|
+
createdAt: event.createdAt
|
|
551
|
+
});
|
|
552
|
+
const streamEvents = (id, startAfter) => {
|
|
553
|
+
const store = resolveStore();
|
|
554
|
+
const outer = async function* () {
|
|
555
|
+
const inner = (await store).stream(nsId(id), { startAfter });
|
|
556
|
+
for await (const event of inner) yield toSessionEvent(id, event);
|
|
557
|
+
};
|
|
558
|
+
return outer();
|
|
559
|
+
};
|
|
560
|
+
const requirePresence = (store) => {
|
|
561
|
+
if (!store.presence) throw presenceNotSupported();
|
|
562
|
+
return store.presence;
|
|
563
|
+
};
|
|
564
|
+
const setPresence = async (sessionId, patch, fallbackSeen) => {
|
|
565
|
+
if (Reflect.get(patch, "~a2.pushed") === true) {
|
|
566
|
+
Object.freeze(patch.values);
|
|
567
|
+
await options.validatePush?.({
|
|
568
|
+
sessionId,
|
|
569
|
+
events: [],
|
|
570
|
+
presence: Object.freeze(patch)
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
if (typeof patch.participant !== "string" || patch.participant.length === 0) throw new TypeError("presence participant must be a non-empty string");
|
|
574
|
+
if (RESERVED_PARTICIPANT_IDS.has(patch.participant)) throw new TypeError(`presence participant must not be '${patch.participant}'`);
|
|
575
|
+
if (patch.at !== void 0 && (typeof patch.at !== "number" || !Number.isFinite(patch.at) || patch.at < 0 || patch.at > 864e13)) throw new TypeError("presence at must be non-negative epoch milliseconds within the Date range");
|
|
576
|
+
const validated = nullProtoRecord();
|
|
577
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
578
|
+
if (value === void 0) continue;
|
|
579
|
+
const schema = Object.hasOwn(presenceDefs, field) ? presenceDefs[field] : void 0;
|
|
580
|
+
if (!schema) throw new A2Error("UNKNOWN_PRESENCE_FIELD", `contract '${name}' has no presence field '${field}'`);
|
|
581
|
+
if (value === null) {
|
|
582
|
+
validated[field] = null;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const result = validateSync(schema, value, `presence field '${field}'`);
|
|
586
|
+
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid value for presence field '${field}' on machine '${name}'`, { details: result.issues });
|
|
587
|
+
validated[field] = result.value;
|
|
588
|
+
}
|
|
589
|
+
const store = await resolveStore();
|
|
590
|
+
try {
|
|
591
|
+
await requirePresence(store).set(nsId(sessionId), patch.participant, validated, {
|
|
592
|
+
seen: patch.seen ?? fallbackSeen,
|
|
593
|
+
at: new Date(patch.at ?? Date.now()),
|
|
594
|
+
ttlMs: presenceTtlMs
|
|
595
|
+
});
|
|
596
|
+
} catch (err) {
|
|
597
|
+
throw asStoreUnavailable(err);
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
const readPresenceMap = async (sessionId) => {
|
|
601
|
+
const store = await resolveStore();
|
|
602
|
+
let rows;
|
|
603
|
+
try {
|
|
604
|
+
rows = await requirePresence(store).read(nsId(sessionId));
|
|
605
|
+
} catch (err) {
|
|
606
|
+
throw asStoreUnavailable(err);
|
|
607
|
+
}
|
|
608
|
+
return foldPresenceRows(rows);
|
|
609
|
+
};
|
|
610
|
+
/**
|
|
611
|
+
* The two-plane feed. Patches ride `subscribe` when the adapter has
|
|
612
|
+
* the push tier; without it the map is re-read and diffed whenever
|
|
613
|
+
* the event feed wakes, and at the poll idle ceiling while it is
|
|
614
|
+
* silent — degraded means later, not lost while watched.
|
|
615
|
+
*/
|
|
616
|
+
const streamWithPresence = (id, startAfter) => {
|
|
617
|
+
const ns = nsId(id);
|
|
618
|
+
const outer = async function* () {
|
|
619
|
+
const store = await resolveStore();
|
|
620
|
+
const api = requirePresence(store);
|
|
621
|
+
const readRows = async () => {
|
|
622
|
+
try {
|
|
623
|
+
return await api.read(ns);
|
|
624
|
+
} catch (err) {
|
|
625
|
+
throw asStoreUnavailable(err);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
const painted = /* @__PURE__ */ new Map();
|
|
629
|
+
const diffRows = (rows) => {
|
|
630
|
+
const patches = [];
|
|
631
|
+
const live = /* @__PURE__ */ new Map();
|
|
632
|
+
for (const row of rows) {
|
|
633
|
+
let liveFields = live.get(row.participant);
|
|
634
|
+
if (!liveFields) {
|
|
635
|
+
liveFields = /* @__PURE__ */ new Set();
|
|
636
|
+
live.set(row.participant, liveFields);
|
|
637
|
+
}
|
|
638
|
+
liveFields.add(row.field);
|
|
639
|
+
const stamp = {
|
|
640
|
+
seen: row.seen,
|
|
641
|
+
atMs: row.at.getTime(),
|
|
642
|
+
value: fingerprint(row.value)
|
|
643
|
+
};
|
|
644
|
+
const prior = painted.get(row.participant)?.get(row.field);
|
|
645
|
+
if (prior && prior.seen === stamp.seen && prior.atMs === stamp.atMs && prior.value === stamp.value) continue;
|
|
646
|
+
let paintedFields = painted.get(row.participant);
|
|
647
|
+
if (!paintedFields) {
|
|
648
|
+
paintedFields = /* @__PURE__ */ new Map();
|
|
649
|
+
painted.set(row.participant, paintedFields);
|
|
650
|
+
}
|
|
651
|
+
paintedFields.set(row.field, stamp);
|
|
652
|
+
patches.push({
|
|
653
|
+
participant: row.participant,
|
|
654
|
+
values: { [row.field]: row.value },
|
|
655
|
+
seen: row.seen,
|
|
656
|
+
at: row.at
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
for (const [participant, fields] of painted) {
|
|
660
|
+
for (const [field, prior] of fields) {
|
|
661
|
+
if (live.get(participant)?.has(field)) continue;
|
|
662
|
+
fields.delete(field);
|
|
663
|
+
patches.push({
|
|
664
|
+
participant,
|
|
665
|
+
values: { [field]: null },
|
|
666
|
+
seen: prior.seen,
|
|
667
|
+
at: /* @__PURE__ */ new Date()
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (fields.size === 0) painted.delete(participant);
|
|
671
|
+
}
|
|
672
|
+
return patches;
|
|
673
|
+
};
|
|
674
|
+
const paint = (patch) => {
|
|
675
|
+
let fields = painted.get(patch.participant);
|
|
676
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
677
|
+
if (value === null || value === void 0) {
|
|
678
|
+
fields?.delete(field);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (!fields) {
|
|
682
|
+
fields = /* @__PURE__ */ new Map();
|
|
683
|
+
painted.set(patch.participant, fields);
|
|
684
|
+
}
|
|
685
|
+
fields.set(field, {
|
|
686
|
+
seen: patch.seen,
|
|
687
|
+
atMs: patch.at.getTime(),
|
|
688
|
+
value: fingerprint(value)
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
if (fields?.size === 0) painted.delete(patch.participant);
|
|
692
|
+
};
|
|
693
|
+
const queue = /* @__PURE__ */ new Map();
|
|
694
|
+
let wakeQueue = null;
|
|
695
|
+
const enqueue = (patch) => {
|
|
696
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
697
|
+
if (value === void 0) continue;
|
|
698
|
+
let fields = queue.get(patch.participant);
|
|
699
|
+
if (!fields) {
|
|
700
|
+
fields = /* @__PURE__ */ new Map();
|
|
701
|
+
queue.set(patch.participant, fields);
|
|
702
|
+
}
|
|
703
|
+
const queued = fields.get(field);
|
|
704
|
+
if (queued && queued.at.getTime() > patch.at.getTime()) continue;
|
|
705
|
+
fields.set(field, {
|
|
706
|
+
value,
|
|
707
|
+
seen: patch.seen,
|
|
708
|
+
at: patch.at
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
wakeQueue?.();
|
|
712
|
+
};
|
|
713
|
+
const dequeue = () => {
|
|
714
|
+
for (const [participant, fields] of queue) {
|
|
715
|
+
for (const [field, stamp] of fields) {
|
|
716
|
+
fields.delete(field);
|
|
717
|
+
if (fields.size === 0) queue.delete(participant);
|
|
718
|
+
return {
|
|
719
|
+
participant,
|
|
720
|
+
values: { [field]: stamp.value },
|
|
721
|
+
seen: stamp.seen,
|
|
722
|
+
at: stamp.at
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
queue.delete(participant);
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
};
|
|
729
|
+
const unsubscribe = api.subscribe?.(ns, enqueue);
|
|
730
|
+
const events = store.stream(ns, { startAfter })[Symbol.asyncIterator]();
|
|
731
|
+
let pendingEvent = null;
|
|
732
|
+
try {
|
|
733
|
+
const snapshotRows = await readRows();
|
|
734
|
+
for (const row of snapshotRows) {
|
|
735
|
+
let fields = painted.get(row.participant);
|
|
736
|
+
if (!fields) {
|
|
737
|
+
fields = /* @__PURE__ */ new Map();
|
|
738
|
+
painted.set(row.participant, fields);
|
|
739
|
+
}
|
|
740
|
+
fields.set(row.field, {
|
|
741
|
+
seen: row.seen,
|
|
742
|
+
atMs: row.at.getTime(),
|
|
743
|
+
value: fingerprint(row.value)
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
yield { snapshot: foldPresenceRows(snapshotRows) };
|
|
747
|
+
for (;;) {
|
|
748
|
+
for (;;) {
|
|
749
|
+
const patch = dequeue();
|
|
750
|
+
if (!patch) break;
|
|
751
|
+
paint(patch);
|
|
752
|
+
yield patch;
|
|
753
|
+
}
|
|
754
|
+
pendingEvent ??= events.next();
|
|
755
|
+
let woke;
|
|
756
|
+
if (unsubscribe) {
|
|
757
|
+
const arrival = new Promise((resolve) => {
|
|
758
|
+
wakeQueue = resolve;
|
|
759
|
+
});
|
|
760
|
+
woke = await Promise.race([pendingEvent.then(() => "event"), arrival.then(() => "patch")]);
|
|
761
|
+
wakeQueue = null;
|
|
762
|
+
if (woke === "patch") continue;
|
|
763
|
+
} else {
|
|
764
|
+
const sleeper = defaultSleep(POLL_TIMINGS.idleCeilingMs);
|
|
765
|
+
woke = await Promise.race([pendingEvent.then(() => "event"), sleeper.promise.then(() => "tick")]);
|
|
766
|
+
sleeper.cancel();
|
|
767
|
+
}
|
|
768
|
+
if (woke === "event") {
|
|
769
|
+
const result = await pendingEvent;
|
|
770
|
+
pendingEvent = null;
|
|
771
|
+
if (result.done) return;
|
|
772
|
+
yield toSessionEvent(id, result.value);
|
|
773
|
+
}
|
|
774
|
+
if (!unsubscribe) for (const patch of diffRows(await readRows())) yield patch;
|
|
775
|
+
}
|
|
776
|
+
} finally {
|
|
777
|
+
wakeQueue = null;
|
|
778
|
+
unsubscribe?.();
|
|
779
|
+
pendingEvent?.catch(() => {});
|
|
780
|
+
await events.return?.();
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
return outer();
|
|
784
|
+
};
|
|
785
|
+
const makeSession = (id, append, trigger) => ({
|
|
786
|
+
id,
|
|
787
|
+
append,
|
|
788
|
+
schedule: makeSchedule(id, trigger),
|
|
789
|
+
history: async (bounds) => (await readHistory(id, bounds)).map(toPublic),
|
|
790
|
+
state: (reducer) => readState(id, reducer),
|
|
791
|
+
stream: (opts) => {
|
|
792
|
+
const startAfter = opts?.startAfter ?? 0;
|
|
793
|
+
assertStreamIndex(startAfter);
|
|
794
|
+
return opts?.presence === true ? streamWithPresence(id, startAfter) : streamEvents(id, startAfter);
|
|
795
|
+
},
|
|
796
|
+
...declaresPresence ? {
|
|
797
|
+
setPresence: (patch) => setPresence(id, patch, trigger?.index ?? 0),
|
|
798
|
+
presence: () => readPresenceMap(id)
|
|
799
|
+
} : {}
|
|
800
|
+
});
|
|
801
|
+
const makeCtx = (trigger, attempt, signal) => {
|
|
802
|
+
const publicSessionId = stripNs(trigger.sessionId);
|
|
803
|
+
const append = async (appendName, ...events) => {
|
|
804
|
+
assertAppendName(appendName);
|
|
805
|
+
const generatedIds = events.map((event) => event.id === void 0);
|
|
806
|
+
const withIds = await Promise.all(events.map(async (event, item) => {
|
|
807
|
+
if (event.id !== void 0) return event;
|
|
808
|
+
const id = await deterministicEventId(trigger.id, appendName, item);
|
|
809
|
+
return {
|
|
810
|
+
...event,
|
|
811
|
+
id
|
|
812
|
+
};
|
|
813
|
+
}));
|
|
814
|
+
return appendCore(publicSessionId, withIds, "handler", "inline", (_rows, watchdogDueAt) => scheduleDrain(publicSessionId, watchdogDueAt), {
|
|
815
|
+
index: trigger.index,
|
|
816
|
+
attempt
|
|
817
|
+
}, generatedIds);
|
|
818
|
+
};
|
|
819
|
+
return {
|
|
820
|
+
event: toPublic(trigger),
|
|
821
|
+
attempt,
|
|
822
|
+
session: makeSession(publicSessionId, append, trigger),
|
|
823
|
+
signal
|
|
824
|
+
};
|
|
825
|
+
};
|
|
826
|
+
const abortHubs = /* @__PURE__ */ new Map();
|
|
827
|
+
const createAbortHub = (store, ns, startAfter) => {
|
|
828
|
+
const hub = {
|
|
829
|
+
subscriptions: /* @__PURE__ */ new Set(),
|
|
830
|
+
seen: /* @__PURE__ */ new Map(),
|
|
831
|
+
startAfter,
|
|
832
|
+
ready: Promise.resolve(),
|
|
833
|
+
watch: null,
|
|
834
|
+
error: null,
|
|
835
|
+
iterator: null,
|
|
836
|
+
stopped: false
|
|
837
|
+
};
|
|
838
|
+
hub.ready = (async () => {
|
|
839
|
+
const existing = await store.read(ns, { afterIndex: startAfter });
|
|
840
|
+
for (const event of existing) dispatchAbortEvent(hub, event);
|
|
841
|
+
const tail = existing.at(-1)?.index ?? startAfter;
|
|
842
|
+
const iterator = store.stream(ns, { startAfter: tail })[Symbol.asyncIterator]();
|
|
843
|
+
hub.iterator = iterator;
|
|
844
|
+
hub.watch = (async () => {
|
|
845
|
+
for (;;) {
|
|
846
|
+
const { value, done } = await iterator.next();
|
|
847
|
+
if (done || value === void 0 || hub.stopped) return;
|
|
848
|
+
dispatchAbortEvent(hub, value);
|
|
849
|
+
}
|
|
850
|
+
})().catch((error) => {
|
|
851
|
+
hub.error = error;
|
|
852
|
+
});
|
|
853
|
+
})().catch((error) => {
|
|
854
|
+
hub.error = error;
|
|
855
|
+
if (abortHubs.get(ns) === hub) abortHubs.delete(ns);
|
|
856
|
+
throw error;
|
|
857
|
+
});
|
|
858
|
+
abortHubs.set(ns, hub);
|
|
859
|
+
return hub;
|
|
860
|
+
};
|
|
861
|
+
const subscribeAbort = async (store, ns, trigger, abortOn, attempt) => {
|
|
862
|
+
let hub = abortHubs.get(ns);
|
|
863
|
+
if (!hub) hub = createAbortHub(store, ns, trigger.index);
|
|
864
|
+
const activeTypes = /* @__PURE__ */ new Set();
|
|
865
|
+
for (const active of hub.subscriptions) for (const type of active.types) activeTypes.add(type);
|
|
866
|
+
const publicTrigger = toPublic(trigger);
|
|
867
|
+
const subscription = {
|
|
868
|
+
controller: new AbortController(),
|
|
869
|
+
triggerIndex: trigger.index,
|
|
870
|
+
types: new Set(abortOn.keys()),
|
|
871
|
+
matches: (event) => {
|
|
872
|
+
if (event.index <= trigger.index) return false;
|
|
873
|
+
const matcher = abortOn.get(event.type);
|
|
874
|
+
if (matcher === void 0) return false;
|
|
875
|
+
return matcher === true ? true : matcher(toPublic(event), publicTrigger, { attempt });
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
hub.subscriptions.add(subscription);
|
|
879
|
+
if (trigger.index < hub.startAfter || [...subscription.types].some((type) => !activeTypes.has(type))) {
|
|
880
|
+
const earlier = await store.read(ns, { afterIndex: trigger.index });
|
|
881
|
+
hub.startAfter = Math.min(hub.startAfter, trigger.index);
|
|
882
|
+
for (const event of earlier) dispatchAbortEvent(hub, event);
|
|
883
|
+
}
|
|
884
|
+
for (const event of hub.seen.values()) if (subscription.matches(event)) subscription.controller.abort();
|
|
885
|
+
await hub.ready;
|
|
886
|
+
if (hub.error !== null) throw hub.error;
|
|
887
|
+
return {
|
|
888
|
+
signal: subscription.controller.signal,
|
|
889
|
+
monitor: async (operation) => {
|
|
890
|
+
const outcome = await Promise.race([operation.then((value) => ({
|
|
891
|
+
type: "completed",
|
|
892
|
+
value
|
|
893
|
+
}), (error) => ({
|
|
894
|
+
type: "handler-failed",
|
|
895
|
+
error
|
|
896
|
+
})), hub.watch.then(() => hub.error === null ? { type: "monitor-closed" } : {
|
|
897
|
+
type: "monitor-failed",
|
|
898
|
+
error: hub.error
|
|
899
|
+
})]);
|
|
900
|
+
if (outcome.type === "completed") return outcome.value;
|
|
901
|
+
if (outcome.type === "handler-failed") throw outcome.error;
|
|
902
|
+
if (outcome.type === "monitor-failed") {
|
|
903
|
+
for (const active of hub.subscriptions) active.controller.abort(outcome.error);
|
|
904
|
+
await operation.catch(() => {});
|
|
905
|
+
throw outcome.error;
|
|
906
|
+
}
|
|
907
|
+
await operation.catch(() => {});
|
|
908
|
+
throw new Error("abort monitor closed while handlers were active");
|
|
909
|
+
},
|
|
910
|
+
close: async () => {
|
|
911
|
+
hub.subscriptions.delete(subscription);
|
|
912
|
+
if (hub.subscriptions.size > 0) {
|
|
913
|
+
pruneAbortHub(hub);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
hub.stopped = true;
|
|
917
|
+
abortHubs.delete(ns);
|
|
918
|
+
await hub.ready;
|
|
919
|
+
await hub.iterator?.return?.();
|
|
920
|
+
await hub.watch;
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
};
|
|
924
|
+
const returnedEvents = async (sessionId, trigger, result) => {
|
|
925
|
+
if (result === void 0) return [];
|
|
926
|
+
const events = Array.isArray(result) ? [...result] : [result];
|
|
927
|
+
if (events.length === 0) return [];
|
|
928
|
+
const withIds = await Promise.all(events.map(async (event, item) => {
|
|
929
|
+
const withId = { ...event };
|
|
930
|
+
withId.id = event.id ?? await deterministicReturnedEventId(trigger.id, item);
|
|
931
|
+
return withId;
|
|
932
|
+
}));
|
|
933
|
+
return validateEvents(sessionId, withIds).map((event) => Object.assign(event, {
|
|
934
|
+
id: event.id,
|
|
935
|
+
cause: {
|
|
936
|
+
index: trigger.index,
|
|
937
|
+
attempt: trigger.attemptCount
|
|
938
|
+
}
|
|
939
|
+
}));
|
|
940
|
+
};
|
|
941
|
+
const drainSession = (sessionId, watchdogDueAt, signal) => telemetry.span("a2.drain", {
|
|
942
|
+
"a2.contract": name,
|
|
943
|
+
"a2.session_id": sessionId
|
|
944
|
+
}, async (span) => {
|
|
945
|
+
const store = await resolveStore();
|
|
946
|
+
const ns = nsId(sessionId);
|
|
947
|
+
const holder = crypto.randomUUID();
|
|
948
|
+
const active = /* @__PURE__ */ new Map();
|
|
949
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
950
|
+
let processed = 0;
|
|
951
|
+
let sawFailure = false;
|
|
952
|
+
let schedulerArm;
|
|
953
|
+
let heartbeat = null;
|
|
954
|
+
let lapseTimer = null;
|
|
955
|
+
let retryTimer = null;
|
|
956
|
+
let renewal = null;
|
|
957
|
+
let stopRenewing = false;
|
|
958
|
+
const lapseDue = () => {
|
|
959
|
+
lapseTimer = null;
|
|
960
|
+
const nowMs = Date.now();
|
|
961
|
+
for (const [index, entry] of active) {
|
|
962
|
+
if (entry.lapsed || entry.expiresAtMs > nowMs) continue;
|
|
963
|
+
entry.lapsed = true;
|
|
964
|
+
entry.supersede.abort(new A2Error("CLAIM_EXPIRED", `the claim on event ${index} in session '${sessionId}' lapsed without renewal`));
|
|
965
|
+
}
|
|
966
|
+
scheduleLapse();
|
|
967
|
+
};
|
|
968
|
+
const scheduleLapse = () => {
|
|
969
|
+
if (lapseTimer) {
|
|
970
|
+
clearTimeout(lapseTimer);
|
|
971
|
+
lapseTimer = null;
|
|
972
|
+
}
|
|
973
|
+
let next = Number.POSITIVE_INFINITY;
|
|
974
|
+
for (const entry of active.values()) if (!entry.lapsed) next = Math.min(next, entry.expiresAtMs);
|
|
975
|
+
if (next === Number.POSITIVE_INFINITY) return;
|
|
976
|
+
lapseTimer = setTimeout(lapseDue, Math.max(0, next - Date.now()));
|
|
977
|
+
lapseTimer.unref?.();
|
|
978
|
+
};
|
|
979
|
+
const scheduleRenewRetry = () => {
|
|
980
|
+
if (stopRenewing || retryTimer) return;
|
|
981
|
+
const nowMs = Date.now();
|
|
982
|
+
if (![...active.values()].some((entry) => !entry.lapsed && entry.expiresAtMs > nowMs)) return;
|
|
983
|
+
const delay = DRAIN_TIMINGS.renewRetryMs * (.5 + Math.random() * .5);
|
|
984
|
+
retryTimer = setTimeout(() => {
|
|
985
|
+
retryTimer = null;
|
|
986
|
+
renew();
|
|
987
|
+
}, delay);
|
|
988
|
+
retryTimer.unref?.();
|
|
989
|
+
};
|
|
990
|
+
const alignWatchdogAt = (minimumDueAt) => {
|
|
991
|
+
if (watchdogDueAt === void 0) return minimumDueAt;
|
|
992
|
+
return watchdogDueAt + Math.max(0, Math.ceil((minimumDueAt - watchdogDueAt) / DRAIN_TIMINGS.claimHeartbeatMs)) * DRAIN_TIMINGS.claimHeartbeatMs;
|
|
993
|
+
};
|
|
994
|
+
const requestWatchdog = (dueAt) => {
|
|
995
|
+
const arm = startSchedulerArm(sessionId, alignWatchdogAt(dueAt));
|
|
996
|
+
if (arm) schedulerArm = arm;
|
|
997
|
+
};
|
|
998
|
+
const renew = () => {
|
|
999
|
+
if (stopRenewing || renewal || active.size === 0) return renewal ?? Promise.resolve();
|
|
1000
|
+
const window = nextClaimWindow();
|
|
1001
|
+
requestWatchdog(window.watchdogAtMs);
|
|
1002
|
+
const claims = [...active.entries()].map(([index, entry]) => ({
|
|
1003
|
+
index,
|
|
1004
|
+
attempt: entry.attempt
|
|
1005
|
+
}));
|
|
1006
|
+
const operation = store.renewClaims({
|
|
1007
|
+
sessionId: ns,
|
|
1008
|
+
holder,
|
|
1009
|
+
claims,
|
|
1010
|
+
ttlMs: window.ttlMs,
|
|
1011
|
+
...window.deadlineCapped ? { expiresAtMs: window.expiresAtMs } : {}
|
|
1012
|
+
}).then((result) => {
|
|
1013
|
+
for (const index of result.renewed) {
|
|
1014
|
+
const entry = active.get(index);
|
|
1015
|
+
if (entry && !entry.lapsed) entry.expiresAtMs = window.expiresAtMs;
|
|
1016
|
+
}
|
|
1017
|
+
for (const index of result.superseded) {
|
|
1018
|
+
const entry = active.get(index);
|
|
1019
|
+
if (!entry) continue;
|
|
1020
|
+
entry.supersede.abort(new A2Error("SUPERSEDED_ATTEMPT", `a newer attempt owns event ${index} in session '${sessionId}'`));
|
|
1021
|
+
entry.fence({
|
|
1022
|
+
status: "superseded",
|
|
1023
|
+
opensLane: false,
|
|
1024
|
+
appendsEvents: false
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
if (window.deadlineCapped) stopRenewing = true;
|
|
1028
|
+
scheduleLapse();
|
|
1029
|
+
}, () => {
|
|
1030
|
+
scheduleRenewRetry();
|
|
1031
|
+
});
|
|
1032
|
+
let tracked;
|
|
1033
|
+
tracked = operation.finally(() => {
|
|
1034
|
+
if (renewal === tracked) renewal = null;
|
|
1035
|
+
});
|
|
1036
|
+
renewal = tracked;
|
|
1037
|
+
return tracked;
|
|
1038
|
+
};
|
|
1039
|
+
const runEvent = async (event, supersedeSignal) => {
|
|
1040
|
+
const registration = handlers.get(event.type);
|
|
1041
|
+
return telemetry.span("a2.event", {
|
|
1042
|
+
"a2.contract": name,
|
|
1043
|
+
"a2.session_id": sessionId,
|
|
1044
|
+
"a2.event.type": event.type,
|
|
1045
|
+
"a2.event.index": event.index,
|
|
1046
|
+
"a2.event.id": event.id,
|
|
1047
|
+
"a2.event.attempt": event.attemptCount,
|
|
1048
|
+
"a2.event.handled": registration !== void 0,
|
|
1049
|
+
...event.lane === null ? {} : { "a2.event.lane": event.lane }
|
|
1050
|
+
}, async (eventSpan) => {
|
|
1051
|
+
let subscription;
|
|
1052
|
+
let handlerSignal = supersedeSignal;
|
|
1053
|
+
let returned;
|
|
1054
|
+
try {
|
|
1055
|
+
if (registration?.abortOn) {
|
|
1056
|
+
subscription = await subscribeAbort(store, ns, event, registration.abortOn, event.attemptCount);
|
|
1057
|
+
handlerSignal = AbortSignal.any([supersedeSignal, subscription.signal]);
|
|
1058
|
+
}
|
|
1059
|
+
const operation = registration?.handler(makeCtx(event, event.attemptCount, handlerSignal));
|
|
1060
|
+
const result = operation ? await (subscription ? subscription.monitor(operation) : operation) : void 0;
|
|
1061
|
+
if (subscription) {
|
|
1062
|
+
await subscription.close();
|
|
1063
|
+
subscription = void 0;
|
|
1064
|
+
}
|
|
1065
|
+
returned = await returnedEvents(sessionId, event, result);
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
if (supersedeSignal.aborted && err === supersedeSignal.reason) {
|
|
1068
|
+
eventSpan.setAttribute("a2.event.outcome", "surrendered");
|
|
1069
|
+
return {
|
|
1070
|
+
status: "surrendered",
|
|
1071
|
+
opensLane: false,
|
|
1072
|
+
appendsEvents: false
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
eventSpan.recordError(err);
|
|
1076
|
+
const failure = await store.failAttempt({
|
|
1077
|
+
sessionId: ns,
|
|
1078
|
+
index: event.index,
|
|
1079
|
+
attempt: event.attemptCount,
|
|
1080
|
+
error: describeError(err),
|
|
1081
|
+
maxFailures: MAX_FAILURES
|
|
1082
|
+
});
|
|
1083
|
+
eventSpan.setAttribute("a2.event.outcome", failure.outcome);
|
|
1084
|
+
return {
|
|
1085
|
+
status: failure.outcome,
|
|
1086
|
+
opensLane: false,
|
|
1087
|
+
appendsEvents: false
|
|
1088
|
+
};
|
|
1089
|
+
} finally {
|
|
1090
|
+
await subscription?.close();
|
|
1091
|
+
}
|
|
1092
|
+
await renewal;
|
|
1093
|
+
const completion = await store.completeAttempt({
|
|
1094
|
+
sessionId: ns,
|
|
1095
|
+
index: event.index,
|
|
1096
|
+
attempt: event.attemptCount,
|
|
1097
|
+
events: returned
|
|
1098
|
+
});
|
|
1099
|
+
eventSpan.setAttribute("a2.event.outcome", completion.outcome === "completed" ? "processed" : completion.outcome);
|
|
1100
|
+
if (completion.outcome === "superseded") return {
|
|
1101
|
+
status: "superseded",
|
|
1102
|
+
opensLane: false,
|
|
1103
|
+
appendsEvents: false
|
|
1104
|
+
};
|
|
1105
|
+
if (handlerSignal.aborted) eventSpan.setAttribute("a2.event.aborted", true);
|
|
1106
|
+
return {
|
|
1107
|
+
status: "processed",
|
|
1108
|
+
opensLane: event.lane !== null,
|
|
1109
|
+
appendsEvents: returned.length > 0
|
|
1110
|
+
};
|
|
1111
|
+
});
|
|
1112
|
+
};
|
|
1113
|
+
let claimAgain = false;
|
|
1114
|
+
const start = (event, expiresAtMs) => {
|
|
1115
|
+
const previous = active.get(event.index);
|
|
1116
|
+
if (previous) {
|
|
1117
|
+
previous.supersede.abort(new A2Error("SUPERSEDED_ATTEMPT", `a newer attempt owns event ${event.index} in session '${sessionId}'`));
|
|
1118
|
+
previous.fence({
|
|
1119
|
+
status: "superseded",
|
|
1120
|
+
opensLane: false,
|
|
1121
|
+
appendsEvents: false
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
const supersede = new AbortController();
|
|
1125
|
+
let fence;
|
|
1126
|
+
const fenced = new Promise((resolve) => {
|
|
1127
|
+
fence = resolve;
|
|
1128
|
+
});
|
|
1129
|
+
let entry;
|
|
1130
|
+
const execution = Promise.race([runEvent(event, supersede.signal), fenced]).then((eventOutcome) => {
|
|
1131
|
+
if (active.get(event.index) === entry) active.delete(event.index);
|
|
1132
|
+
if (eventOutcome.status === "processed") processed += 1;
|
|
1133
|
+
if (eventOutcome.status === "failed") {
|
|
1134
|
+
excluded.add(event.index);
|
|
1135
|
+
sawFailure = true;
|
|
1136
|
+
}
|
|
1137
|
+
claimAgain ||= eventOutcome.opensLane || eventOutcome.appendsEvents;
|
|
1138
|
+
return eventOutcome;
|
|
1139
|
+
});
|
|
1140
|
+
entry = {
|
|
1141
|
+
attempt: event.attemptCount,
|
|
1142
|
+
execution,
|
|
1143
|
+
supersede,
|
|
1144
|
+
fence,
|
|
1145
|
+
expiresAtMs,
|
|
1146
|
+
lapsed: false
|
|
1147
|
+
};
|
|
1148
|
+
active.set(event.index, entry);
|
|
1149
|
+
scheduleLapse();
|
|
1150
|
+
if (!heartbeat && !stopRenewing) {
|
|
1151
|
+
heartbeat = setInterval(() => void renew(), DRAIN_TIMINGS.claimHeartbeatMs);
|
|
1152
|
+
heartbeat.unref?.();
|
|
1153
|
+
}
|
|
1154
|
+
};
|
|
1155
|
+
let outcome = "settled";
|
|
1156
|
+
let shouldClaim = true;
|
|
1157
|
+
try {
|
|
1158
|
+
for (;;) {
|
|
1159
|
+
const signalVersion = signal?.version ?? 0;
|
|
1160
|
+
let claim;
|
|
1161
|
+
if (shouldClaim) {
|
|
1162
|
+
const window = nextClaimWindow();
|
|
1163
|
+
claim = await store.claimAvailable({
|
|
1164
|
+
sessionId: ns,
|
|
1165
|
+
holder,
|
|
1166
|
+
ttlMs: window.ttlMs,
|
|
1167
|
+
...window.deadlineCapped ? { expiresAtMs: window.expiresAtMs } : {},
|
|
1168
|
+
...excluded.size === 0 ? {} : { excludeIndexes: [...excluded] }
|
|
1169
|
+
});
|
|
1170
|
+
if (claim.outcome === "claimed") {
|
|
1171
|
+
requestWatchdog(window.watchdogAtMs);
|
|
1172
|
+
if (window.deadlineCapped) stopRenewing = true;
|
|
1173
|
+
for (const event of claim.events) start(event, window.expiresAtMs);
|
|
1174
|
+
shouldClaim = false;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (active.size > 0) {
|
|
1178
|
+
const wake = signal?.wait(signalVersion);
|
|
1179
|
+
await Promise.race([...[...active.values()].map((entry) => entry.execution), ...wake ? [wake.promise] : []]);
|
|
1180
|
+
wake?.cancel();
|
|
1181
|
+
shouldClaim = claimAgain || active.size === 0 || signal !== void 0 && signal.version !== signalVersion;
|
|
1182
|
+
claimAgain = false;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
if (!shouldClaim) {
|
|
1186
|
+
shouldClaim = true;
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
if (claim === void 0) continue;
|
|
1190
|
+
if (signal && signal.version !== signalVersion) continue;
|
|
1191
|
+
if (claim.outcome === "busy") {
|
|
1192
|
+
requestWatchdog(claim.retryAt.getTime() + DRAIN_TIMINGS.watchdogGraceMs);
|
|
1193
|
+
outcome = "busy";
|
|
1194
|
+
} else if (sawFailure) outcome = "stalled";
|
|
1195
|
+
if (signal) signal.closed = true;
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
} finally {
|
|
1199
|
+
if (signal) signal.closed = true;
|
|
1200
|
+
stopRenewing = true;
|
|
1201
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
1202
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
1203
|
+
await renewal;
|
|
1204
|
+
await Promise.allSettled([...active.values()].map((entry) => entry.execution));
|
|
1205
|
+
if (lapseTimer) clearTimeout(lapseTimer);
|
|
1206
|
+
}
|
|
1207
|
+
span.setAttribute("a2.drain.processed", processed);
|
|
1208
|
+
span.setAttribute("a2.drain.outcome", outcome);
|
|
1209
|
+
return {
|
|
1210
|
+
settled: outcome === "settled",
|
|
1211
|
+
outcome,
|
|
1212
|
+
processed,
|
|
1213
|
+
...schedulerArm ? { schedulerArm } : {}
|
|
1214
|
+
};
|
|
1215
|
+
});
|
|
1216
|
+
const appendExternal = async (sessionId, mode, events, requireDurableInitialArm = false) => {
|
|
1217
|
+
const pushed = events.filter((event) => Reflect.get(event, "~a2.pushed") === true);
|
|
1218
|
+
if (pushed.length > 0) await options.validatePush?.({
|
|
1219
|
+
sessionId,
|
|
1220
|
+
events: pushed
|
|
1221
|
+
});
|
|
1222
|
+
return appendCore(sessionId, events, "external", mode, (_rows, watchdogDueAt) => scheduleDrain(sessionId, watchdogDueAt), void 0, void 0, requireDurableInitialArm);
|
|
1223
|
+
};
|
|
1224
|
+
const self = {
|
|
1225
|
+
contract: serverContract,
|
|
1226
|
+
session(id) {
|
|
1227
|
+
assertSessionId(id);
|
|
1228
|
+
const appendInline = async (...events) => appendExternal(id, "inline", events);
|
|
1229
|
+
const appendDispatch = async (...events) => appendExternal(id, "dispatch", events);
|
|
1230
|
+
const append = Object.assign(appendInline, { dispatch: appendDispatch });
|
|
1231
|
+
return makeSession(id, append, null);
|
|
1232
|
+
},
|
|
1233
|
+
async drain(sessionId) {
|
|
1234
|
+
assertSessionId(sessionId);
|
|
1235
|
+
const { settled } = await drainSession(sessionId);
|
|
1236
|
+
return { settled };
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
serverInternals.set(self, {
|
|
1240
|
+
settle: async () => {
|
|
1241
|
+
while (inFlight.size > 0) await Promise.allSettled(inFlight);
|
|
1242
|
+
},
|
|
1243
|
+
schedulerDrain: (sessionId, opts) => {
|
|
1244
|
+
assertSessionId(sessionId);
|
|
1245
|
+
return drainSession(sessionId, opts?.watchdogDueAt);
|
|
1246
|
+
},
|
|
1247
|
+
schedulerAppend: async (sessionId, events) => {
|
|
1248
|
+
if (!scheduler) throw new TypeError("scheduled append delivery requires a configured scheduler adapter");
|
|
1249
|
+
assertSessionId(sessionId);
|
|
1250
|
+
await appendExternal(sessionId, "inline", [...events], true);
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
serverSchedulerBindings.set(self, { scheduler });
|
|
1254
|
+
serverInspection.set(self, {
|
|
1255
|
+
async listSessions(inspectionOptions) {
|
|
1256
|
+
const store = await resolveStore();
|
|
1257
|
+
if (!store.inspect) throw new InspectionUnsupportedError("this store backend does not support inspection");
|
|
1258
|
+
const page = await store.inspect.listSessions({
|
|
1259
|
+
prefix: `${name}${NS}`,
|
|
1260
|
+
limit: inspectionOptions.limit,
|
|
1261
|
+
...inspectionOptions.cursor !== void 0 ? { cursor: inspectionOptions.cursor } : {}
|
|
1262
|
+
});
|
|
1263
|
+
return {
|
|
1264
|
+
cursor: page.cursor,
|
|
1265
|
+
sessions: page.sessions.map((session) => {
|
|
1266
|
+
const publicSession = { ...session };
|
|
1267
|
+
publicSession.sessionId = stripNs(session.sessionId);
|
|
1268
|
+
return publicSession;
|
|
1269
|
+
})
|
|
1270
|
+
};
|
|
1271
|
+
},
|
|
1272
|
+
async readSessionPage(sessionId, inspectionOptions) {
|
|
1273
|
+
assertSessionId(sessionId);
|
|
1274
|
+
if (!Number.isSafeInteger(inspectionOptions.afterIndex) || inspectionOptions.afterIndex < 0 || !Number.isSafeInteger(inspectionOptions.limit) || inspectionOptions.limit < 1 || inspectionOptions.throughIndex !== void 0 && (!Number.isSafeInteger(inspectionOptions.throughIndex) || inspectionOptions.throughIndex < 0)) throw new TypeError("invalid inspection event page bounds");
|
|
1275
|
+
const store = await resolveStore();
|
|
1276
|
+
if (!store.inspect) throw new InspectionUnsupportedError("this store backend does not support inspection");
|
|
1277
|
+
const ns = nsId(sessionId);
|
|
1278
|
+
const snapshots = inspectionOptions.afterIndex === 0 ? await store.inspect.listSnapshots(ns) : [];
|
|
1279
|
+
const { events, throughIndex } = store.inspect.readEvents ? await store.inspect.readEvents(ns, inspectionOptions) : await (async () => {
|
|
1280
|
+
const all = await store.read(ns);
|
|
1281
|
+
const currentFrontier = all.at(-1)?.index ?? 0;
|
|
1282
|
+
const throughIndex = Math.min(inspectionOptions.throughIndex ?? currentFrontier, currentFrontier);
|
|
1283
|
+
return {
|
|
1284
|
+
events: all.filter((event) => event.index > inspectionOptions.afterIndex && event.index <= throughIndex).slice(0, inspectionOptions.limit),
|
|
1285
|
+
throughIndex
|
|
1286
|
+
};
|
|
1287
|
+
})();
|
|
1288
|
+
if (!Number.isSafeInteger(throughIndex) || throughIndex < 0 || events.length > inspectionOptions.limit || inspectionOptions.throughIndex !== void 0 && inspectionOptions.afterIndex > 0 && throughIndex !== inspectionOptions.throughIndex) throw new A2Error("STORE_UNAVAILABLE", `inspection event page for session '${sessionId}' has invalid bounds`);
|
|
1289
|
+
for (let position = 0; position < events.length; position += 1) if (events[position].index !== inspectionOptions.afterIndex + position + 1 || events[position].index > throughIndex) throw new A2Error("STORE_UNAVAILABLE", `inspection event page for session '${sessionId}' is not contiguous`);
|
|
1290
|
+
if (inspectionOptions.afterIndex < throughIndex && events.length === 0) throw new A2Error("STORE_UNAVAILABLE", `inspection event page for session '${sessionId}' ended before index ${throughIndex}`);
|
|
1291
|
+
const lastIndex = events.at(-1)?.index ?? inspectionOptions.afterIndex;
|
|
1292
|
+
return {
|
|
1293
|
+
events: events.map((event) => {
|
|
1294
|
+
const publicEvent = { ...event };
|
|
1295
|
+
publicEvent.sessionId = sessionId;
|
|
1296
|
+
return publicEvent;
|
|
1297
|
+
}),
|
|
1298
|
+
snapshots: snapshots.filter((snapshot) => snapshot.index <= throughIndex),
|
|
1299
|
+
throughIndex,
|
|
1300
|
+
nextIndex: lastIndex < throughIndex ? lastIndex : null
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
return self;
|
|
1305
|
+
}
|
|
1306
|
+
function assertSessionId(id) {
|
|
1307
|
+
if (typeof id !== "string" || id.length === 0) throw new TypeError("session id must be a non-empty string");
|
|
1308
|
+
if (id.includes(NS)) throw new TypeError("session id contains a reserved control character");
|
|
1309
|
+
}
|
|
1310
|
+
function assertHistoryIndex(name, value) {
|
|
1311
|
+
if (value !== void 0 && (!Number.isSafeInteger(value) || value < 0)) throw new TypeError(`history.${name} must be a non-negative safe integer`);
|
|
1312
|
+
}
|
|
1313
|
+
function assertStreamIndex(value) {
|
|
1314
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("stream.startAfter must be a non-negative safe integer");
|
|
1315
|
+
}
|
|
1316
|
+
function assertAppendName(name) {
|
|
1317
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError("handler append name must be a non-empty string");
|
|
1318
|
+
}
|
|
1319
|
+
function assertScheduleName(name) {
|
|
1320
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError("schedule name must be a non-empty string");
|
|
1321
|
+
}
|
|
1322
|
+
const SCHEDULE_DELAY = /^(?:0|[1-9]\d*)(?:\.\d+)?(ms|s|m|h|d)$/;
|
|
1323
|
+
const SCHEDULE_DELAY_MULTIPLIER = {
|
|
1324
|
+
ms: 1,
|
|
1325
|
+
s: 1e3,
|
|
1326
|
+
m: 6e4,
|
|
1327
|
+
h: 36e5,
|
|
1328
|
+
d: 864e5
|
|
1329
|
+
};
|
|
1330
|
+
function scheduleDueAt(timing, baseTime) {
|
|
1331
|
+
if (timing === null || typeof timing !== "object" || Array.isArray(timing)) throw new TypeError("schedule timing must contain exactly one of 'delay' or 'at'");
|
|
1332
|
+
const record = timing;
|
|
1333
|
+
const hasDelay = Object.hasOwn(record, "delay");
|
|
1334
|
+
const hasAt = Object.hasOwn(record, "at");
|
|
1335
|
+
if (hasDelay === hasAt) throw new TypeError("schedule timing must contain exactly one of 'delay' or 'at'");
|
|
1336
|
+
let dueAt;
|
|
1337
|
+
if (hasAt) {
|
|
1338
|
+
const at = record["at"];
|
|
1339
|
+
if (!(at instanceof Date) || !Number.isFinite(at.getTime())) throw new TypeError("schedule at must be a valid Date");
|
|
1340
|
+
dueAt = at.getTime();
|
|
1341
|
+
} else {
|
|
1342
|
+
const delay = record["delay"];
|
|
1343
|
+
if (typeof delay !== "string") throw new TypeError("schedule delay must use ms, s, m, h, or d (for example '30s')");
|
|
1344
|
+
const match = SCHEDULE_DELAY.exec(delay);
|
|
1345
|
+
const amount = match ? Number(delay.slice(0, -match[1].length)) : NaN;
|
|
1346
|
+
if (!match || !Number.isFinite(amount) || amount <= 0) throw new TypeError("schedule delay must be positive and use ms, s, m, h, or d (for example '30s')");
|
|
1347
|
+
dueAt = baseTime + amount * SCHEDULE_DELAY_MULTIPLIER[match[1]];
|
|
1348
|
+
}
|
|
1349
|
+
if (!Number.isFinite(dueAt)) throw new TypeError("schedule due time must be a finite epoch time");
|
|
1350
|
+
return dueAt;
|
|
1351
|
+
}
|
|
1352
|
+
function assertScheduledPayload(payload) {
|
|
1353
|
+
try {
|
|
1354
|
+
if (isJsonValue(payload, /* @__PURE__ */ new WeakSet())) return;
|
|
1355
|
+
} catch {}
|
|
1356
|
+
throw new TypeError("session.schedule event payload must be JSON-serializable");
|
|
1357
|
+
}
|
|
1358
|
+
function cloneScheduledPayload(payload) {
|
|
1359
|
+
assertScheduledPayload(payload);
|
|
1360
|
+
try {
|
|
1361
|
+
const cloned = structuredClone(payload);
|
|
1362
|
+
const encoded = JSON.stringify(cloned);
|
|
1363
|
+
if (encoded === void 0) throw new TypeError();
|
|
1364
|
+
return JSON.parse(encoded);
|
|
1365
|
+
} catch {
|
|
1366
|
+
throw new TypeError("session.schedule event payload must be JSON-serializable");
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
function isJsonValue(value, ancestors) {
|
|
1370
|
+
if (value === null) return true;
|
|
1371
|
+
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
1372
|
+
if (typeof value === "number") return Number.isFinite(value) && !Object.is(value, -0);
|
|
1373
|
+
if (typeof value !== "object") return false;
|
|
1374
|
+
if (ancestors.has(value)) return false;
|
|
1375
|
+
ancestors.add(value);
|
|
1376
|
+
try {
|
|
1377
|
+
if (Array.isArray(value)) {
|
|
1378
|
+
const keys = Reflect.ownKeys(value);
|
|
1379
|
+
if (keys.length !== value.length + 1 || !keys.includes("length")) return false;
|
|
1380
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1381
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
1382
|
+
if (!descriptor || !descriptor.enumerable || !("value" in descriptor) || !isJsonValue(descriptor.value, ancestors)) return false;
|
|
1383
|
+
}
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) return false;
|
|
1387
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
1388
|
+
if (typeof key !== "string") return false;
|
|
1389
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1390
|
+
if (!descriptor || !descriptor.enumerable || !("value" in descriptor) || !isJsonValue(descriptor.value, ancestors)) return false;
|
|
1391
|
+
}
|
|
1392
|
+
return true;
|
|
1393
|
+
} finally {
|
|
1394
|
+
ancestors.delete(value);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
function cloneInitial(initial) {
|
|
1398
|
+
try {
|
|
1399
|
+
return structuredClone(initial);
|
|
1400
|
+
} catch {
|
|
1401
|
+
return initial;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
//#endregion
|
|
1405
|
+
export { deliverSchedulerAppend as n, createServer as t };
|