@vercel/board 0.16.0 → 0.18.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/README.md +27 -27
- package/dist/{a2-C41hD8SF.mjs → a2-ChRLRupa.mjs} +969 -25
- package/dist/access.d.mts +1 -1
- package/dist/access.mjs +3 -3
- package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
- package/dist/attempts-CLG840Oj.mjs +20 -0
- package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
- package/dist/backend-B-SJ7p6p.d.mts +680 -0
- package/dist/backend-a2/contract.d.mts +873 -2
- package/dist/backend-a2/contract.mjs +2 -2
- package/dist/backend-a2/scheduler-local.d.mts +1 -1
- package/dist/backend-a2/scheduler-local.mjs +1 -1
- package/dist/backend-a2/scheduler-qstash.d.mts +78 -1
- package/dist/backend-a2/scheduler-qstash.mjs +521 -1
- package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
- package/dist/backend-a2/scheduler-vercel.mjs +203 -1
- package/dist/backend-a2/store-memory.d.mts +2 -2
- package/dist/backend-a2/store-memory.mjs +3 -3
- package/dist/backend-a2/store-postgres.d.mts +1 -1
- package/dist/backend-a2/store-postgres.mjs +2850 -2
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +490 -2
- package/dist/backend-a2/store-redis.d.mts +10 -2
- package/dist/backend-a2/store-redis.mjs +51 -2
- package/dist/backend-a2/store-sqlite.d.mts +1 -1
- package/dist/backend-a2/store-sqlite.mjs +3 -3
- package/dist/backend-a2.d.mts +2706 -3
- package/dist/backend-a2.mjs +1 -1
- package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
- package/dist/{backend-http-0roEmjl0.mjs → backend-http-CMlL3yq3.mjs} +2 -2
- package/dist/{backend-http-BVRno-ca.mjs → backend-http-Ddzd5kTS.mjs} +32 -3
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
- package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
- package/dist/cli.mjs +30 -10
- package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
- package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
- package/dist/conformance.d.mts +1 -1
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
- package/dist/events-CewD4kw7.mjs +75 -0
- package/dist/{exports-Dvc1o52I.mjs → exports-CgjfMrQ-.mjs} +35 -32
- package/dist/exports-ZsgjzEnM.d.mts +4 -0
- package/dist/external-BV_cUncs.mjs +151 -0
- package/dist/folds-CRYY14kc.d.mts +534 -0
- package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
- package/dist/ids-C6AYo1bu.mjs +31 -0
- package/dist/index.d.mts +4 -4
- package/dist/{index.default-Bkg77Pxn.mjs → index.default-C5Cpz3sa.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.node.d.mts +4 -4
- package/dist/index.node.mjs +3 -3
- package/dist/inspect/app.js +31 -31
- package/dist/inspect/engine.js +5 -5
- package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
- package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
- package/dist/mentions-CB6ShY4R.mjs +72 -0
- package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
- package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
- package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
- package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
- package/dist/provider-B3DpYSDq.mjs +220 -0
- package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
- package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
- package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
- package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
- package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
- package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
- package/dist/{serve-BsOOc-6_.mjs → serve-BGYw0uNL.mjs} +5 -5
- package/dist/{server-jwPY7_wi.mjs → server-DPxBSogx.mjs} +4 -4
- package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
- package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
- package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
- package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
- package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
- package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
- package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
- package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
- package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
- package/dist/store-memory-Bas9Uq5C.mjs +872 -0
- package/dist/store-memory-ZLiZbH3R.mjs +2 -0
- package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
- package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
- package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
- package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
- package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
- package/dist/sync-github.d.mts +24 -17
- package/dist/sync-github.mjs +89 -22
- package/dist/sync-kapso.d.mts +15 -13
- package/dist/sync-kapso.mjs +17 -14
- package/dist/sync-linear.d.mts +219 -0
- package/dist/sync-linear.mjs +311 -0
- package/dist/sync-slack.d.mts +12 -17
- package/dist/sync-slack.mjs +54 -42
- package/dist/sync.chat.d.mts +1 -1
- package/dist/sync.chat.mjs +1 -1
- package/dist/sync.d.mts +3 -3
- package/dist/sync.mjs +5 -4
- package/dist/targets-C-wdR5wP.mjs +33 -0
- package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
- package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
- package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
- package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-RLtnSF-t.mjs} +233 -28
- package/docs/01-board/01-mounting.md +1 -1
- package/docs/02-sync/01-overview.md +72 -48
- package/docs/02-sync/02-functions.md +398 -0
- package/docs/02-sync/03-slack.md +30 -25
- package/docs/02-sync/04-github.md +62 -26
- package/docs/02-sync/05-whatsapp.md +23 -19
- package/docs/02-sync/06-providers.md +60 -27
- package/docs/02-sync/07-linear.md +129 -0
- package/docs/03-guides/04-triage.md +1 -1
- package/docs/04-reference/01-cli.md +26 -6
- package/docs/04-reference/02-board.md +18 -5
- package/docs/04-reference/03-http.md +5 -2
- package/docs/04-reference/05-conformance.md +1 -1
- package/docs/index.md +5 -10
- package/package.json +31 -6
- package/dist/a2-D24cvwpN.d.mts +0 -3359
- package/dist/access-CJU2-vaa.mjs +0 -226
- package/dist/attempts-Bxp9KgPf.mjs +0 -919
- package/dist/exports-DLiE7fLY.d.mts +0 -4
- package/dist/provider-CuewcDNZ.mjs +0 -205
- package/dist/redis-tables-B35vv4CZ.mjs +0 -842
- package/dist/reducers-B_vUGQRp.d.mts +0 -1172
- package/dist/watchers-hash-CLjsa6od.mjs +0 -225
- package/docs/02-sync/02-callbacks.md +0 -265
|
@@ -0,0 +1,3602 @@
|
|
|
1
|
+
import { a as isSessionCleared, i as asStoreUnavailable, o as sessionClearedError, r as NonRetriableError, t as A2Error } from "./session-clear-wHd5GCpZ-BnKSxReK.mjs";
|
|
2
|
+
import { n as cloneInitial, o as validateSync, r as foldEvent } from "./reducer-CjmvZSAb-D_OSISmj.mjs";
|
|
3
|
+
import { _ as serverInternals, a as RESERVED_PARTICIPANT_IDS, c as STREAM_TIMINGS, d as drainSettledHooks, f as failedPreviousAttempt, g as runtimeConsumers, h as runtimeConsumerFactory, m as nullProtoRecord, o as SOCKET_TIMINGS, p as markSchedulerSendFailure, r as POLL_TIMINGS, s as STATE_CACHE, t as DRAIN_TIMINGS, v as stateTailReaders } from "./internal-CvsrprnU-CRY89I-b.mjs";
|
|
4
|
+
import { a as takeBuffered, i as presenceTombstones, n as isSerializedEvent, r as parseSerializedEvent, t as bufferedEvents } from "./stream-buffer-DrTyiIuo-BKy-vI14.mjs";
|
|
5
|
+
import { i as streamActivities, n as defaultSleep, t as createStreamActivity } from "./store-polling-BZt2Rf3D-BiEM6_HB.mjs";
|
|
6
|
+
import { r as platformWaitUntil, t as invocationDeadlineMs } from "./platform-B4TnJtWu-CwcTyKg2.mjs";
|
|
7
|
+
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii-Bye-W8q7.mjs";
|
|
8
|
+
//#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/reducer-runtime-DQwz_J59.js
|
|
9
|
+
const runtimeKey = Symbol("a2.reducerRuntime");
|
|
10
|
+
function encodeState(options) {
|
|
11
|
+
const storage = options.reducer[runtimeKey]?.storage;
|
|
12
|
+
return storage ? storage.encode(options.state) : options.state;
|
|
13
|
+
}
|
|
14
|
+
function decodeState(options) {
|
|
15
|
+
const storage = options.reducer[runtimeKey]?.storage;
|
|
16
|
+
return storage ? storage.decode(options.state) : options.state;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/bounded-cache-CPHWF-8h.js
|
|
20
|
+
function boundedCache({ maxEntries, maxBytes }) {
|
|
21
|
+
const entries = /* @__PURE__ */ new Map();
|
|
22
|
+
let total = 0;
|
|
23
|
+
const remove = (key) => {
|
|
24
|
+
const entry = entries.get(key);
|
|
25
|
+
if (!entry) return;
|
|
26
|
+
entries.delete(key);
|
|
27
|
+
total -= entry.bytes;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
get(key) {
|
|
31
|
+
const entry = entries.get(key);
|
|
32
|
+
if (!entry) return void 0;
|
|
33
|
+
entries.delete(key);
|
|
34
|
+
entries.set(key, entry);
|
|
35
|
+
return entry.value;
|
|
36
|
+
},
|
|
37
|
+
set(key, value, bytes) {
|
|
38
|
+
remove(key);
|
|
39
|
+
if (bytes > maxBytes / 4) return;
|
|
40
|
+
entries.set(key, {
|
|
41
|
+
value,
|
|
42
|
+
bytes
|
|
43
|
+
});
|
|
44
|
+
total += bytes;
|
|
45
|
+
for (const oldest of entries.keys()) {
|
|
46
|
+
if (entries.size <= maxEntries && total <= maxBytes) break;
|
|
47
|
+
remove(oldest);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
delete: remove,
|
|
51
|
+
deleteWhere(match) {
|
|
52
|
+
for (const key of entries.keys()) if (match(key)) remove(key);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/wire-CV-Z7wz-.js
|
|
58
|
+
/**
|
|
59
|
+
* The wire format shared by server.fetch and experimental-a2/client
|
|
60
|
+
* (browser side): events as JSON with ISO timestamps, and the A2Error
|
|
61
|
+
* envelope. Internal module — both entry points re-export what's
|
|
62
|
+
* public.
|
|
63
|
+
*/
|
|
64
|
+
function eventToWire(event) {
|
|
65
|
+
return {
|
|
66
|
+
id: event.id,
|
|
67
|
+
type: event.type,
|
|
68
|
+
payload: event.payload,
|
|
69
|
+
index: event.index,
|
|
70
|
+
sessionId: event.sessionId,
|
|
71
|
+
createdAt: event.createdAt.toISOString()
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const lastEncoding = (encode) => {
|
|
75
|
+
let last;
|
|
76
|
+
let text = "";
|
|
77
|
+
return (value) => {
|
|
78
|
+
if (value !== last) {
|
|
79
|
+
text = encode(value);
|
|
80
|
+
last = value;
|
|
81
|
+
}
|
|
82
|
+
return text;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const isoJson = lastEncoding((ms) => JSON.stringify(new Date(ms).toISOString()));
|
|
86
|
+
const sessionJson = lastEncoding((id) => JSON.stringify(id));
|
|
87
|
+
const typeJson = lastEncoding((type) => JSON.stringify(type));
|
|
88
|
+
/**
|
|
89
|
+
* `JSON.stringify(eventToWire(event))` for the parsed event, built around the
|
|
90
|
+
* stored payload text instead of parsing and re-serializing it.
|
|
91
|
+
*/
|
|
92
|
+
function serializedEventToWireJson(event) {
|
|
93
|
+
return `{"id":${JSON.stringify(event.id)},"type":${typeJson(event.type)},"payload":${event.payloadJson},"index":${event.index},"sessionId":${sessionJson(event.sessionId)},"createdAt":${isoJson(event.createdAtMs)}}`;
|
|
94
|
+
}
|
|
95
|
+
function wireVersion(value) {
|
|
96
|
+
return value === "2" ? 2 : 1;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Events already held by a stream join one write while it is shorter than
|
|
100
|
+
* this many characters, so a write can exceed it by the event that crosses it.
|
|
101
|
+
*/
|
|
102
|
+
const EVENT_WRITE_CHARS = 65536;
|
|
103
|
+
/** Stream items that are events, as opposed to snapshot or presence frames. */
|
|
104
|
+
function isStreamEvent(item) {
|
|
105
|
+
return !("kind" in item) && !("snapshot" in item) && !("participant" in item);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* `first` and the events `iterator` already holds, encoded, until the batch
|
|
109
|
+
* reaches `EVENT_WRITE_CHARS`; `last` is the batch's final event.
|
|
110
|
+
*/
|
|
111
|
+
function heldEventBatch(options) {
|
|
112
|
+
const { iterator, encode } = options;
|
|
113
|
+
let last = options.first;
|
|
114
|
+
const events = [encode(last)];
|
|
115
|
+
let chars = events[0].length;
|
|
116
|
+
while (chars < EVENT_WRITE_CHARS) {
|
|
117
|
+
const held = takeBuffered(iterator);
|
|
118
|
+
if (!held) break;
|
|
119
|
+
const json = encode(held);
|
|
120
|
+
events.push(json);
|
|
121
|
+
chars += json.length + 1;
|
|
122
|
+
last = held;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
events,
|
|
126
|
+
last
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** One version 2 event as JSON, splicing a held row's stored payload. */
|
|
130
|
+
function compactEventJson(event) {
|
|
131
|
+
if (!isSerializedEvent(event)) return JSON.stringify({
|
|
132
|
+
id: event.id,
|
|
133
|
+
type: event.type,
|
|
134
|
+
payload: event.payload,
|
|
135
|
+
index: event.index,
|
|
136
|
+
createdAt: event.createdAt.getTime()
|
|
137
|
+
});
|
|
138
|
+
return `{"id":${JSON.stringify(event.id)},"type":${typeJson(event.type)},"payload":${event.payloadJson},"index":${event.index},"createdAt":${event.createdAtMs}}`;
|
|
139
|
+
}
|
|
140
|
+
function presencePatchToWire(patch) {
|
|
141
|
+
return {
|
|
142
|
+
participant: patch.participant,
|
|
143
|
+
values: patch.values,
|
|
144
|
+
seen: patch.seen,
|
|
145
|
+
at: patch.at.toISOString()
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function presenceSnapshotToWire(snapshot) {
|
|
149
|
+
const wire = nullProtoRecord();
|
|
150
|
+
for (const [participant, fields] of Object.entries(snapshot.snapshot)) {
|
|
151
|
+
const wireFields = nullProtoRecord();
|
|
152
|
+
for (const [field, entry] of Object.entries(fields)) {
|
|
153
|
+
if (entry === void 0) continue;
|
|
154
|
+
wireFields[field] = {
|
|
155
|
+
value: entry.value,
|
|
156
|
+
seen: entry.seen,
|
|
157
|
+
at: entry.at.toISOString()
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
wire[participant] = wireFields;
|
|
161
|
+
}
|
|
162
|
+
return { snapshot: wire };
|
|
163
|
+
}
|
|
164
|
+
const ERROR_STATUS = {
|
|
165
|
+
INVALID_PAYLOAD: 400,
|
|
166
|
+
FORBIDDEN: 403,
|
|
167
|
+
UNKNOWN_EVENT_TYPE: 400,
|
|
168
|
+
PARTIAL_DUPLICATE_BATCH: 400,
|
|
169
|
+
SUPERSEDED_ATTEMPT: 409,
|
|
170
|
+
SESSION_CLEARED: 409,
|
|
171
|
+
CLAIM_EXPIRED: 409,
|
|
172
|
+
STORE_UNAVAILABLE: 503,
|
|
173
|
+
STORE_NOT_CONFIGURED: 500,
|
|
174
|
+
UNKNOWN_PRESENCE_FIELD: 400,
|
|
175
|
+
PRESENCE_NOT_SUPPORTED: 500,
|
|
176
|
+
CONSUMER_DEAD_LETTERED: 500
|
|
177
|
+
};
|
|
178
|
+
function errorStatus(code) {
|
|
179
|
+
return ERROR_STATUS[isServerErrorCode(code) ? code : "STORE_UNAVAILABLE"];
|
|
180
|
+
}
|
|
181
|
+
function errorToWire(error) {
|
|
182
|
+
const failure = asA2Error(error);
|
|
183
|
+
const body = { error: {
|
|
184
|
+
code: failure.code,
|
|
185
|
+
message: failure.message
|
|
186
|
+
} };
|
|
187
|
+
if (failure.details !== void 0) body.error.details = failure.details;
|
|
188
|
+
return body;
|
|
189
|
+
}
|
|
190
|
+
/** Wrap an arbitrary thrown value for the wire: known server codes pass through,
|
|
191
|
+
* anything else becomes STORE_UNAVAILABLE — from the client's
|
|
192
|
+
* perspective an unknown server failure is retryable-once, not a
|
|
193
|
+
* protocol contract. */
|
|
194
|
+
function asA2Error(error) {
|
|
195
|
+
return error instanceof A2Error && isServerErrorCode(error.code) ? error : new A2Error("STORE_UNAVAILABLE", "internal error", { cause: error });
|
|
196
|
+
}
|
|
197
|
+
const isServerErrorCode = (code) => Object.hasOwn(ERROR_STATUS, code);
|
|
198
|
+
function stateSnapshotToWire(frame) {
|
|
199
|
+
return frame.state === void 0 ? {
|
|
200
|
+
...frame,
|
|
201
|
+
state: null,
|
|
202
|
+
stateUndefined: true
|
|
203
|
+
} : frame;
|
|
204
|
+
}
|
|
205
|
+
const SOCKET_PING_FRAME = JSON.stringify({ kind: "ping" });
|
|
206
|
+
/** One stream item as a socket frame — `sseResponse`'s framing over
|
|
207
|
+
* the same codecs, with `kind` instead of an SSE event name. On a
|
|
208
|
+
* multiplexed socket presence frames carry the `sessionId` tag; events
|
|
209
|
+
* already carry theirs in the wire event. */
|
|
210
|
+
function socketFrameFor(item, sessionId) {
|
|
211
|
+
const tag = sessionId === void 0 ? {} : { sessionId };
|
|
212
|
+
if ("kind" in item) return JSON.stringify(stateSnapshotToWire(item));
|
|
213
|
+
if ("snapshot" in item) return JSON.stringify({
|
|
214
|
+
kind: "presence-snapshot",
|
|
215
|
+
...presenceSnapshotToWire(item),
|
|
216
|
+
...tag
|
|
217
|
+
});
|
|
218
|
+
if ("participant" in item) return JSON.stringify({
|
|
219
|
+
kind: "presence",
|
|
220
|
+
...presencePatchToWire(item),
|
|
221
|
+
...tag
|
|
222
|
+
});
|
|
223
|
+
return JSON.stringify({
|
|
224
|
+
kind: "event",
|
|
225
|
+
...eventToWire(item)
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** A version 2 batch frame from compact event JSON; a multiplexed
|
|
229
|
+
* socket names the batch's session once. */
|
|
230
|
+
function socketEventsFor(events, sessionId) {
|
|
231
|
+
return `{"kind":"events",${sessionId === void 0 ? "" : `"sessionId":${JSON.stringify(sessionId)},`}"events":[${events.join(",")}]}`;
|
|
232
|
+
}
|
|
233
|
+
function socketAckFor(req, events, sessionId) {
|
|
234
|
+
return JSON.stringify({
|
|
235
|
+
kind: "ack",
|
|
236
|
+
req,
|
|
237
|
+
events: events.map(eventToWire),
|
|
238
|
+
...sessionId === void 0 ? {} : { sessionId }
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function socketErrorAckFor(req, error, sessionId) {
|
|
242
|
+
return JSON.stringify({
|
|
243
|
+
kind: "ack",
|
|
244
|
+
req,
|
|
245
|
+
...errorToWire(error),
|
|
246
|
+
...sessionId === void 0 ? {} : { sessionId }
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function socketSubscribedFor(options) {
|
|
250
|
+
const { sessionId, attempt } = options;
|
|
251
|
+
return JSON.stringify({
|
|
252
|
+
kind: "subscribed",
|
|
253
|
+
sessionId,
|
|
254
|
+
...attempt === void 0 ? {} : { attempt }
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** The subscription-over notice: with a `reason` the server rejected
|
|
258
|
+
* or lost the session's stream, without one it ended cleanly. */
|
|
259
|
+
function socketUnsubscribedFor(options) {
|
|
260
|
+
const { sessionId, attempt, reason, error } = options;
|
|
261
|
+
return JSON.stringify({
|
|
262
|
+
kind: "unsubscribed",
|
|
263
|
+
sessionId,
|
|
264
|
+
...attempt === void 0 ? {} : { attempt },
|
|
265
|
+
...reason === void 0 ? {} : { reason },
|
|
266
|
+
...error === void 0 ? {} : errorToWire(error)
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/server-DtFrWxi3.js
|
|
271
|
+
/**
|
|
272
|
+
* The push envelope's trust boundary for `{ sessionId, events, presence? }`,
|
|
273
|
+
* shared verbatim by the HTTP
|
|
274
|
+
* POST lane and the socket's push/presence frames so INVALID_PAYLOAD
|
|
275
|
+
* shapes are one implementation. Internal module, not public API.
|
|
276
|
+
*/
|
|
277
|
+
const invalidPushBody = (message) => new A2Error("INVALID_PAYLOAD", `malformed push body: ${message}`);
|
|
278
|
+
const invalid = invalidPushBody;
|
|
279
|
+
function parsePresenceSibling(value) {
|
|
280
|
+
if (value === void 0) return void 0;
|
|
281
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw invalid("presence must be an object when present");
|
|
282
|
+
const { participant, values, seen, at } = value;
|
|
283
|
+
if (typeof participant !== "string" || participant.length === 0) throw invalid("presence.participant must be a non-empty string");
|
|
284
|
+
if (RESERVED_PARTICIPANT_IDS.has(participant)) throw invalid(`presence.participant must not be '${participant}'`);
|
|
285
|
+
if (values === null || typeof values !== "object" || Array.isArray(values)) throw invalid("presence.values must be an object");
|
|
286
|
+
if (seen !== void 0 && (typeof seen !== "number" || !Number.isFinite(seen) || seen < 0)) throw invalid("presence.seen must be a non-negative number when present");
|
|
287
|
+
if (at !== void 0 && (typeof at !== "number" || !Number.isFinite(at) || at < 0 || at > 864e13)) throw invalid("presence.at must be a non-negative epoch-milliseconds timestamp within the Date range when present");
|
|
288
|
+
const out = {
|
|
289
|
+
participant,
|
|
290
|
+
values
|
|
291
|
+
};
|
|
292
|
+
if (seen !== void 0) out.seen = seen;
|
|
293
|
+
if (at !== void 0) out.at = at;
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
/** The longest event id a push may carry: ids are indexed, and Postgres's index rows have a limit. */
|
|
297
|
+
const MAX_PUSHED_ID = 256;
|
|
298
|
+
/** A UUIDv5, the shape of the ids A2 derives (deterministic-id.ts). */
|
|
299
|
+
const DERIVED_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
300
|
+
/** The events half of the envelope, shared by the POST body and socket
|
|
301
|
+
* `push` frames so INVALID_PAYLOAD shapes have one implementation. */
|
|
302
|
+
function parsePushEvents(events) {
|
|
303
|
+
if (!Array.isArray(events) || events.length === 0) throw invalid("events must be a non-empty array");
|
|
304
|
+
return events.map((event, i) => {
|
|
305
|
+
if (event === null || typeof event !== "object") throw invalid(`events[${i}] must be an object`);
|
|
306
|
+
const { type, payload, id } = event;
|
|
307
|
+
if (typeof type !== "string" || type.length === 0) throw invalid(`events[${i}].type must be a non-empty string`);
|
|
308
|
+
if (id !== void 0 && typeof id !== "string") throw invalid(`events[${i}].id must be a string when present`);
|
|
309
|
+
if (id !== void 0 && (id.length === 0 || id.length > MAX_PUSHED_ID)) throw invalid(`events[${i}].id must be 1-${MAX_PUSHED_ID} characters when present`);
|
|
310
|
+
if (id !== void 0 && DERIVED_ID.test(id)) throw invalid(`events[${i}].id is shaped like the ids A2 derives for consumers (a UUIDv5); use crypto.randomUUID() or idempotentId()`);
|
|
311
|
+
const out = {
|
|
312
|
+
type,
|
|
313
|
+
payload
|
|
314
|
+
};
|
|
315
|
+
if (id !== void 0) out.id = id;
|
|
316
|
+
return out;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Validate the push envelope — `{ sessionId, events, presence? }` —
|
|
321
|
+
* throwing `INVALID_PAYLOAD` on a malformed body. Payload validation
|
|
322
|
+
* against the machine's schemas happens in `append` and `setPresence`,
|
|
323
|
+
* not here. Either plane may appear alone: a presence-only push has no
|
|
324
|
+
* (or empty) `events`.
|
|
325
|
+
*/
|
|
326
|
+
async function parsePushBody(req) {
|
|
327
|
+
let body;
|
|
328
|
+
try {
|
|
329
|
+
body = await req.json();
|
|
330
|
+
} catch (cause) {
|
|
331
|
+
throw new A2Error("INVALID_PAYLOAD", "push body is not valid JSON", { cause });
|
|
332
|
+
}
|
|
333
|
+
if (body === null || typeof body !== "object") throw invalid("expected an object");
|
|
334
|
+
const { sessionId, events, presence } = body;
|
|
335
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) throw invalid("sessionId must be a non-empty string");
|
|
336
|
+
const parsedPresence = parsePresenceSibling(presence);
|
|
337
|
+
if (parsedPresence !== void 0 && (events === void 0 || Array.isArray(events) && events.length === 0)) return {
|
|
338
|
+
sessionId,
|
|
339
|
+
events: [],
|
|
340
|
+
presence: parsedPresence
|
|
341
|
+
};
|
|
342
|
+
const parsed = parsePushEvents(events);
|
|
343
|
+
return parsedPresence !== void 0 ? {
|
|
344
|
+
sessionId,
|
|
345
|
+
events: parsed,
|
|
346
|
+
presence: parsedPresence
|
|
347
|
+
} : {
|
|
348
|
+
sessionId,
|
|
349
|
+
events: parsed
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The A2 socket protocol's server half (specs/a2-api.md §13): the
|
|
354
|
+
* per-socket shell — teardown, backpressure, heartbeat, the malformed
|
|
355
|
+
* budget, and the in-flight bound. `sessionSocket` binds one session to
|
|
356
|
+
* one socket; `sessionsSocket` is the opt-in multiplexed handler. Only
|
|
357
|
+
* `A2Socket`, the structural socket the platform hands over, is public.
|
|
358
|
+
*/
|
|
359
|
+
const messageText = (data) => {
|
|
360
|
+
if (typeof data === "string") return data;
|
|
361
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
|
362
|
+
if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data);
|
|
363
|
+
if (Array.isArray(data) && data.every(ArrayBuffer.isView)) {
|
|
364
|
+
const decoder = new TextDecoder();
|
|
365
|
+
let text = "";
|
|
366
|
+
for (const part of data) text += decoder.decode(part, { stream: true });
|
|
367
|
+
return text + decoder.decode();
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
};
|
|
371
|
+
/**
|
|
372
|
+
* How many unparseable or invalid frames a socket may send before it
|
|
373
|
+
* is closed (1008): the budget absorbs isolated client bugs without
|
|
374
|
+
* letting a broken peer spin the validation path forever. Push frames
|
|
375
|
+
* with a usable `req` never count — their failures are answered with
|
|
376
|
+
* error acks, exactly like the POST path's 4xx bodies.
|
|
377
|
+
*/
|
|
378
|
+
const MAX_MALFORMED_FRAMES = 5;
|
|
379
|
+
function socketShell(socket, release, onFrame, deadlineAt) {
|
|
380
|
+
let torn = false;
|
|
381
|
+
let malformed = 0;
|
|
382
|
+
let heartbeat;
|
|
383
|
+
let deadline;
|
|
384
|
+
const teardown = () => {
|
|
385
|
+
if (torn) return;
|
|
386
|
+
torn = true;
|
|
387
|
+
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
388
|
+
if (deadline !== void 0) clearTimeout(deadline);
|
|
389
|
+
release();
|
|
390
|
+
};
|
|
391
|
+
const shutdown = (code, reason) => {
|
|
392
|
+
if (torn) return;
|
|
393
|
+
teardown();
|
|
394
|
+
try {
|
|
395
|
+
socket.close(code, reason);
|
|
396
|
+
} catch {}
|
|
397
|
+
};
|
|
398
|
+
const send = (data) => {
|
|
399
|
+
if (torn) return;
|
|
400
|
+
if (typeof socket.bufferedAmount === "number" && socket.bufferedAmount > SOCKET_TIMINGS.disconnectWaterMarkBytes) {
|
|
401
|
+
shutdown(1008, "backpressure: peer not reading");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
socket.send(data);
|
|
406
|
+
} catch {
|
|
407
|
+
teardown();
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const malformedFrame = (reason) => {
|
|
411
|
+
malformed += 1;
|
|
412
|
+
if (malformed >= MAX_MALFORMED_FRAMES) shutdown(1008, `too many malformed frames (last: ${reason})`);
|
|
413
|
+
};
|
|
414
|
+
let inFlight = 0;
|
|
415
|
+
const track = (work) => {
|
|
416
|
+
inFlight += 1;
|
|
417
|
+
work.finally(() => {
|
|
418
|
+
inFlight -= 1;
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
const handleMessage = (data) => {
|
|
422
|
+
if (torn) return;
|
|
423
|
+
const text = messageText(data);
|
|
424
|
+
if (text === null) {
|
|
425
|
+
malformedFrame("non-text frame");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
let parsed;
|
|
429
|
+
try {
|
|
430
|
+
parsed = JSON.parse(text);
|
|
431
|
+
} catch {
|
|
432
|
+
malformedFrame("frame is not valid JSON");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
436
|
+
malformedFrame("frame is not an object");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
onFrame(parsed);
|
|
440
|
+
};
|
|
441
|
+
socket.on("message", handleMessage);
|
|
442
|
+
socket.on("close", teardown);
|
|
443
|
+
socket.on("error", teardown);
|
|
444
|
+
heartbeat = setInterval(() => send(SOCKET_PING_FRAME), STREAM_TIMINGS.sseHeartbeatMs);
|
|
445
|
+
heartbeat.unref?.();
|
|
446
|
+
if (deadlineAt !== void 0) {
|
|
447
|
+
deadline = setTimeout(() => shutdown(1e3, "deadline"), Math.max(0, deadlineAt - Date.now()));
|
|
448
|
+
deadline.unref?.();
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
torn: () => torn,
|
|
452
|
+
send,
|
|
453
|
+
shutdown,
|
|
454
|
+
malformedFrame,
|
|
455
|
+
buffered: () => typeof socket.bufferedAmount === "number" ? socket.bufferedAmount : 0,
|
|
456
|
+
overCap: () => inFlight >= SOCKET_TIMINGS.maxInFlightMessages,
|
|
457
|
+
track
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const socketSubscription = (options) => {
|
|
461
|
+
const { target, startAfter, presence, attempt } = options;
|
|
462
|
+
return {
|
|
463
|
+
target,
|
|
464
|
+
iterator: (presence ? target.stream({
|
|
465
|
+
startAfter,
|
|
466
|
+
presence: true
|
|
467
|
+
}) : target.stream({ startAfter }))[Symbol.asyncIterator](),
|
|
468
|
+
stopped: false,
|
|
469
|
+
...attempt === void 0 ? {} : { attempt }
|
|
470
|
+
};
|
|
471
|
+
};
|
|
472
|
+
const stopSubscription = (subscription) => {
|
|
473
|
+
subscription.stopped = true;
|
|
474
|
+
subscription.iterator.return?.()?.catch(() => {});
|
|
475
|
+
};
|
|
476
|
+
/**
|
|
477
|
+
* Sends a subscription's items until it ends. With `batch`, an event and
|
|
478
|
+
* the events its iterator already holds go out as one frame.
|
|
479
|
+
*/
|
|
480
|
+
const pumpSubscription = async (options) => {
|
|
481
|
+
const { shell, subscription, frameFor, batch } = options;
|
|
482
|
+
for (;;) {
|
|
483
|
+
while (!shell.torn() && !subscription.stopped && shell.buffered() > SOCKET_TIMINGS.highWaterMarkBytes) await new Promise((wake) => setTimeout(wake, SOCKET_TIMINGS.resumePollMs));
|
|
484
|
+
if (shell.torn() || subscription.stopped) return;
|
|
485
|
+
const { value, done } = await subscription.iterator.next();
|
|
486
|
+
if (shell.torn() || subscription.stopped || done) return;
|
|
487
|
+
if (!batch || !isStreamEvent(value)) {
|
|
488
|
+
shell.send(frameFor(value));
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const { events } = heldEventBatch({
|
|
492
|
+
first: value,
|
|
493
|
+
iterator: subscription.iterator,
|
|
494
|
+
encode: compactEventJson
|
|
495
|
+
});
|
|
496
|
+
shell.send(batch(events));
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
const handleSocketPush = async (shell, target, sessionId, frame, req, ackSessionId, gatePush, activate) => {
|
|
500
|
+
try {
|
|
501
|
+
const events = parsePushEvents(frame["events"]);
|
|
502
|
+
const denial = await gatePush?.(sessionId, events, void 0) ?? null;
|
|
503
|
+
if (denial !== null) {
|
|
504
|
+
shell.send(socketErrorAckFor(req, denial, ackSessionId));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
activate();
|
|
508
|
+
const appended = await target.append(...events);
|
|
509
|
+
activate();
|
|
510
|
+
shell.send(socketAckFor(req, appended, ackSessionId));
|
|
511
|
+
} catch (error) {
|
|
512
|
+
shell.send(socketErrorAckFor(req, asA2Error(error), ackSessionId));
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
const applySocketPresence = async (shell, target, sessionId, frame, gatePush) => {
|
|
516
|
+
if (typeof target.setPresence !== "function") {
|
|
517
|
+
shell.malformedFrame("presence frame on a presence-less session");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
let patch;
|
|
521
|
+
try {
|
|
522
|
+
patch = parsePresenceSibling(frame);
|
|
523
|
+
} catch {
|
|
524
|
+
shell.malformedFrame("invalid presence frame");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (!patch) return;
|
|
528
|
+
try {
|
|
529
|
+
if ((await gatePush?.(sessionId, [], patch) ?? null) !== null) return;
|
|
530
|
+
await target.setPresence(patch);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
const failure = asA2Error(error);
|
|
533
|
+
if (failure.code === "INVALID_PAYLOAD" || failure.code === "UNKNOWN_PRESENCE_FIELD") {
|
|
534
|
+
shell.malformedFrame("invalid presence frame");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
shell.shutdown(1011, "internal error");
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
function sessionSocket(sessionId, session, socket, options) {
|
|
541
|
+
const target = session;
|
|
542
|
+
const subscription = socketSubscription({
|
|
543
|
+
target,
|
|
544
|
+
startAfter: options?.startAfter ?? 0,
|
|
545
|
+
presence: options?.presence === true
|
|
546
|
+
});
|
|
547
|
+
const handleFrame = (frame) => {
|
|
548
|
+
switch (frame["kind"]) {
|
|
549
|
+
case "push": {
|
|
550
|
+
const req = frame["req"];
|
|
551
|
+
if (typeof req !== "number") {
|
|
552
|
+
shell.malformedFrame("push frame without a numeric req");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (frame["sessionId"] !== void 0) {
|
|
556
|
+
shell.send(socketErrorAckFor(req, new A2Error("INVALID_PAYLOAD", "sessionId is not allowed on a single-session socket")));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (shell.overCap()) {
|
|
560
|
+
shell.send(socketErrorAckFor(req, new A2Error("STORE_UNAVAILABLE", "push shed: too many in flight on this socket")));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
shell.track(handleSocketPush(shell, target, sessionId, frame, req, void 0, options?.gatePush, () => streamActivities.get(subscription.iterator)?.activate()));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
case "presence":
|
|
567
|
+
if (frame["sessionId"] !== void 0) {
|
|
568
|
+
shell.malformedFrame("sessionId is not allowed on a single-session socket");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (!shell.overCap()) shell.track(applySocketPresence(shell, target, sessionId, frame, options?.gatePush));
|
|
572
|
+
return;
|
|
573
|
+
default: return;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
const shell = socketShell(socket, () => stopSubscription(subscription), handleFrame, options?.deadline);
|
|
577
|
+
pumpSubscription({
|
|
578
|
+
shell,
|
|
579
|
+
subscription,
|
|
580
|
+
frameFor: socketFrameFor,
|
|
581
|
+
...options?.wire === 2 ? { batch: (events) => socketEventsFor(events) } : {}
|
|
582
|
+
}).then(() => shell.shutdown(1e3), () => shell.shutdown(1011, "stream failed"));
|
|
583
|
+
}
|
|
584
|
+
const parseSubscribeEntries = (value) => {
|
|
585
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
586
|
+
const entries = [];
|
|
587
|
+
for (const entry of value) {
|
|
588
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
589
|
+
const { id, attempt, index, bootstrap, reducer } = entry;
|
|
590
|
+
if (typeof id !== "string" || id.length === 0) return null;
|
|
591
|
+
if (attempt !== void 0 && (typeof attempt !== "number" || !Number.isSafeInteger(attempt) || attempt < 0)) return null;
|
|
592
|
+
if (reducer !== void 0 && (typeof reducer !== "string" || reducer.length === 0)) return null;
|
|
593
|
+
const lane = {
|
|
594
|
+
id,
|
|
595
|
+
...attempt === void 0 ? {} : { attempt }
|
|
596
|
+
};
|
|
597
|
+
if (bootstrap === true) {
|
|
598
|
+
if (index !== void 0 || typeof reducer !== "string") return null;
|
|
599
|
+
entries.push({
|
|
600
|
+
...lane,
|
|
601
|
+
bootstrap: true,
|
|
602
|
+
reducer
|
|
603
|
+
});
|
|
604
|
+
} else {
|
|
605
|
+
if (bootstrap !== void 0 || typeof index !== "number" || !Number.isSafeInteger(index) || index < 0) return null;
|
|
606
|
+
entries.push({
|
|
607
|
+
...lane,
|
|
608
|
+
index,
|
|
609
|
+
...reducer === void 0 ? {} : { reducer }
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return entries;
|
|
614
|
+
};
|
|
615
|
+
/**
|
|
616
|
+
* Speak the multiplexed A2 socket protocol (specs/a2-api.md §13)
|
|
617
|
+
* against many sessions on one socket. `subscribe` frames open a
|
|
618
|
+
* per-session pump at that session's own resume frontier; push and
|
|
619
|
+
* presence frames route by `sessionId` through the same validation
|
|
620
|
+
* seams as the POST lane; every down frame carries the `sessionId` it
|
|
621
|
+
* belongs to. One heartbeat, one
|
|
622
|
+
* malformed budget, one in-flight bound, one backpressure gauge — per
|
|
623
|
+
* socket, shared by all sessions. A session's stream ending or failing
|
|
624
|
+
* answers `unsubscribed` for that session; only the peer, the
|
|
625
|
+
* deadline, the malformed budget, or backpressure closes the socket.
|
|
626
|
+
*/
|
|
627
|
+
function sessionsSocket(resolve, socket, options) {
|
|
628
|
+
const presence = options?.presence === true;
|
|
629
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
630
|
+
const retire = (id, subscription) => {
|
|
631
|
+
if (subscriptions.get(id) === subscription) subscriptions.delete(id);
|
|
632
|
+
};
|
|
633
|
+
const runSubscription = async (id, subscription) => {
|
|
634
|
+
const { attempt } = subscription;
|
|
635
|
+
try {
|
|
636
|
+
await pumpSubscription({
|
|
637
|
+
shell,
|
|
638
|
+
subscription,
|
|
639
|
+
frameFor: (item) => socketFrameFor(item, id),
|
|
640
|
+
...options?.wire === 2 ? { batch: (events) => socketEventsFor(events, id) } : {}
|
|
641
|
+
});
|
|
642
|
+
if (shell.torn() || subscription.stopped) return;
|
|
643
|
+
retire(id, subscription);
|
|
644
|
+
shell.send(socketUnsubscribedFor({
|
|
645
|
+
sessionId: id,
|
|
646
|
+
attempt
|
|
647
|
+
}));
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (shell.torn() || subscription.stopped) return;
|
|
650
|
+
retire(id, subscription);
|
|
651
|
+
shell.send(socketUnsubscribedFor({
|
|
652
|
+
sessionId: id,
|
|
653
|
+
attempt,
|
|
654
|
+
reason: "stream failed",
|
|
655
|
+
error: asA2Error(error)
|
|
656
|
+
}));
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
const startSubscription = async (entry) => {
|
|
660
|
+
const { id, attempt } = entry;
|
|
661
|
+
const index = entry.index ?? 0;
|
|
662
|
+
const existing = subscriptions.get(id);
|
|
663
|
+
if (existing) {
|
|
664
|
+
stopSubscription(existing);
|
|
665
|
+
subscriptions.delete(id);
|
|
666
|
+
}
|
|
667
|
+
let session;
|
|
668
|
+
try {
|
|
669
|
+
session = await resolve(id, index, entry);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (!shell.torn()) shell.send(socketUnsubscribedFor({
|
|
672
|
+
sessionId: id,
|
|
673
|
+
attempt,
|
|
674
|
+
reason: "stream failed",
|
|
675
|
+
error: asA2Error(error)
|
|
676
|
+
}));
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (shell.torn()) return;
|
|
680
|
+
if (session === null) {
|
|
681
|
+
shell.send(socketUnsubscribedFor({
|
|
682
|
+
sessionId: id,
|
|
683
|
+
attempt,
|
|
684
|
+
reason: "subscribe rejected",
|
|
685
|
+
error: new A2Error("FORBIDDEN", "the subscription is not authorized")
|
|
686
|
+
}));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const subscription = socketSubscription({
|
|
690
|
+
target: session,
|
|
691
|
+
startAfter: index,
|
|
692
|
+
presence,
|
|
693
|
+
attempt
|
|
694
|
+
});
|
|
695
|
+
subscriptions.set(id, subscription);
|
|
696
|
+
shell.send(socketSubscribedFor({
|
|
697
|
+
sessionId: id,
|
|
698
|
+
attempt
|
|
699
|
+
}));
|
|
700
|
+
runSubscription(id, subscription);
|
|
701
|
+
};
|
|
702
|
+
let controlTail = Promise.resolve();
|
|
703
|
+
const control = (work) => {
|
|
704
|
+
controlTail = controlTail.then(work).catch(() => {});
|
|
705
|
+
};
|
|
706
|
+
const handlePush = async (frame, req, sessionId) => {
|
|
707
|
+
const subscription = subscriptions.get(sessionId);
|
|
708
|
+
if (subscription === void 0) {
|
|
709
|
+
shell.send(socketErrorAckFor(req, new A2Error("STORE_UNAVAILABLE", "session is not subscribed on this socket"), sessionId));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
await handleSocketPush(shell, subscription.target, sessionId, frame, req, sessionId, options?.gatePush, () => {
|
|
713
|
+
const current = subscriptions.get(sessionId);
|
|
714
|
+
if (current) streamActivities.get(current.iterator)?.activate();
|
|
715
|
+
});
|
|
716
|
+
};
|
|
717
|
+
const handlePresence = async (frame, sessionId) => {
|
|
718
|
+
const subscription = subscriptions.get(sessionId);
|
|
719
|
+
if (subscription === void 0) return;
|
|
720
|
+
await applySocketPresence(shell, subscription.target, sessionId, frame, options?.gatePush);
|
|
721
|
+
};
|
|
722
|
+
const handleFrame = (frame) => {
|
|
723
|
+
switch (frame["kind"]) {
|
|
724
|
+
case "subscribe": {
|
|
725
|
+
const entries = parseSubscribeEntries(frame["sessions"]);
|
|
726
|
+
if (entries === null) {
|
|
727
|
+
shell.malformedFrame("malformed subscribe frame");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
for (const entry of entries) control(() => startSubscription(entry));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
case "unsubscribe": {
|
|
734
|
+
const ids = frame["sessions"];
|
|
735
|
+
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === "string")) {
|
|
736
|
+
shell.malformedFrame("malformed unsubscribe frame");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
for (const id of ids) control(() => {
|
|
740
|
+
const subscription = subscriptions.get(id);
|
|
741
|
+
if (subscription === void 0) return;
|
|
742
|
+
stopSubscription(subscription);
|
|
743
|
+
subscriptions.delete(id);
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
case "push": {
|
|
748
|
+
const req = frame["req"];
|
|
749
|
+
const sessionId = frame["sessionId"];
|
|
750
|
+
if (shell.overCap()) {
|
|
751
|
+
if (typeof req === "number") shell.send(socketErrorAckFor(req, new A2Error("STORE_UNAVAILABLE", "push shed: too many in flight on this socket"), typeof sessionId === "string" ? sessionId : void 0));
|
|
752
|
+
else shell.malformedFrame("push frame without a numeric req");
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (typeof req !== "number") {
|
|
756
|
+
shell.malformedFrame("push frame without a numeric req");
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
760
|
+
shell.send(socketErrorAckFor(req, new A2Error("INVALID_PAYLOAD", "push frame without a sessionId")));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
shell.track(handlePush(frame, req, sessionId));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
case "presence": {
|
|
767
|
+
if (shell.overCap()) return;
|
|
768
|
+
const sessionId = frame["sessionId"];
|
|
769
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
770
|
+
shell.malformedFrame("presence frame without a sessionId");
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
shell.track(handlePresence(frame, sessionId));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
default: return;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
const shell = socketShell(socket, () => {
|
|
780
|
+
for (const subscription of subscriptions.values()) stopSubscription(subscription);
|
|
781
|
+
subscriptions.clear();
|
|
782
|
+
}, (frame) => handleFrame(frame), options?.deadline);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* The SSE response protocol — framing, heartbeat, deadline, and
|
|
786
|
+
* teardown for the live stream lane. Internal module: `server.fetch` is
|
|
787
|
+
* the sanctioned caller; the framing rules stay
|
|
788
|
+
* documented on `sseResponse` because the client's parser (and the
|
|
789
|
+
* stall watchdog) depend on them.
|
|
790
|
+
*/
|
|
791
|
+
/**
|
|
792
|
+
* Pipe a live event iterable into an SSE `Response`. Each event is one
|
|
793
|
+
* frame — `id:` carries the event-log index, `data:` the JSON event.
|
|
794
|
+
* Frames for events the iterable already holds are written together. With
|
|
795
|
+
* `wire: 2` those events share one frame instead: `data:` is a JSON array of
|
|
796
|
+
* `CompactWireEvent`s and `id:` the last one's index. A
|
|
797
|
+
* `stream({ presence: true })` iterable also yields presence items,
|
|
798
|
+
* which ride as named frames old clients skip: `event:
|
|
799
|
+
* presence-snapshot` first (the map, ISO dates), then `event: presence`
|
|
800
|
+
* per patch — no `id:`, presence never advances the resume frontier. A
|
|
801
|
+
* disconnecting client cancels the stream, which closes the underlying
|
|
802
|
+
* subscription. Two kinds of comment frames ride along: a `: connected`
|
|
803
|
+
* prelude that flushes headers immediately, and a `: ping` heartbeat
|
|
804
|
+
* every 15 seconds so clients (and proxies) can tell a quiet stream
|
|
805
|
+
* from a dead connection — the session client's stall watchdog counts
|
|
806
|
+
* on it. When the platform exposes an invocation deadline, the response
|
|
807
|
+
* closes cleanly one second before it so the client reconnects without
|
|
808
|
+
* a platform-timeout failure.
|
|
809
|
+
*/
|
|
810
|
+
function sseResponse(iterable, options = {}) {
|
|
811
|
+
const write = options.wire === 2 ? batchedFrames : eventFrames;
|
|
812
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
813
|
+
const encoder = new TextEncoder();
|
|
814
|
+
let heartbeat;
|
|
815
|
+
let deadlineTimer;
|
|
816
|
+
let closed = false;
|
|
817
|
+
let iteratorReturned = false;
|
|
818
|
+
let stopActivity;
|
|
819
|
+
const stopTimers = () => {
|
|
820
|
+
stopActivity?.();
|
|
821
|
+
stopActivity = void 0;
|
|
822
|
+
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
823
|
+
if (deadlineTimer !== void 0) clearTimeout(deadlineTimer);
|
|
824
|
+
heartbeat = void 0;
|
|
825
|
+
deadlineTimer = void 0;
|
|
826
|
+
};
|
|
827
|
+
const returnIterator = async () => {
|
|
828
|
+
if (iteratorReturned) return;
|
|
829
|
+
iteratorReturned = true;
|
|
830
|
+
await iterator.return?.();
|
|
831
|
+
};
|
|
832
|
+
const stream = new ReadableStream({
|
|
833
|
+
start(controller) {
|
|
834
|
+
controller.enqueue(encoder.encode(": connected\n\n"));
|
|
835
|
+
const activity = streamActivities.get(iterator);
|
|
836
|
+
if (activity) {
|
|
837
|
+
const announce = (idle) => {
|
|
838
|
+
if (closed) return;
|
|
839
|
+
controller.enqueue(encoder.encode(`event: a2-activity\ndata: ${idle ? "idle" : "active"}\n\n`));
|
|
840
|
+
};
|
|
841
|
+
stopActivity = activity.subscribe(announce);
|
|
842
|
+
if (activity.idle) announce(true);
|
|
843
|
+
}
|
|
844
|
+
heartbeat = setInterval(() => {
|
|
845
|
+
try {
|
|
846
|
+
controller.enqueue(encoder.encode(": ping\n\n"));
|
|
847
|
+
} catch {
|
|
848
|
+
stopTimers();
|
|
849
|
+
}
|
|
850
|
+
}, STREAM_TIMINGS.sseHeartbeatMs);
|
|
851
|
+
heartbeat.unref?.();
|
|
852
|
+
const invocationDeadline = invocationDeadlineMs();
|
|
853
|
+
if (invocationDeadline !== null) {
|
|
854
|
+
const delay = Math.max(0, invocationDeadline - Date.now() - STREAM_TIMINGS.sseDeadlineGraceMs);
|
|
855
|
+
deadlineTimer = setTimeout(() => {
|
|
856
|
+
if (closed) return;
|
|
857
|
+
closed = true;
|
|
858
|
+
stopTimers();
|
|
859
|
+
returnIterator().catch(() => {});
|
|
860
|
+
try {
|
|
861
|
+
controller.close();
|
|
862
|
+
} catch {}
|
|
863
|
+
}, delay);
|
|
864
|
+
deadlineTimer.unref?.();
|
|
865
|
+
}
|
|
866
|
+
},
|
|
867
|
+
async pull(controller) {
|
|
868
|
+
try {
|
|
869
|
+
const { value, done } = await iterator.next();
|
|
870
|
+
if (closed) return;
|
|
871
|
+
if (done) {
|
|
872
|
+
closed = true;
|
|
873
|
+
stopTimers();
|
|
874
|
+
controller.close();
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
controller.enqueue(encoder.encode(write(value, iterator)));
|
|
878
|
+
} catch (error) {
|
|
879
|
+
if (closed) return;
|
|
880
|
+
closed = true;
|
|
881
|
+
stopTimers();
|
|
882
|
+
returnIterator().catch(() => {});
|
|
883
|
+
throw error;
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
async cancel() {
|
|
887
|
+
if (closed) return;
|
|
888
|
+
closed = true;
|
|
889
|
+
stopTimers();
|
|
890
|
+
await returnIterator();
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
return new Response(stream, {
|
|
894
|
+
status: 200,
|
|
895
|
+
headers: {
|
|
896
|
+
"content-type": "text/event-stream",
|
|
897
|
+
"cache-control": "no-cache, no-transform",
|
|
898
|
+
connection: "keep-alive"
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
/** Version 1: one frame per event, then each event the iterator holds. */
|
|
903
|
+
function eventFrames(first, iterator) {
|
|
904
|
+
let frames = frameFor(first);
|
|
905
|
+
while (frames.length < EVENT_WRITE_CHARS) {
|
|
906
|
+
const event = takeBuffered(iterator);
|
|
907
|
+
if (!event) break;
|
|
908
|
+
frames += frameFor(event);
|
|
909
|
+
}
|
|
910
|
+
return frames;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Version 2: events this write carries share one frame, a JSON array whose
|
|
914
|
+
* `id:` is its last index. Held events are always events, so only `first`
|
|
915
|
+
* can be a snapshot or presence frame.
|
|
916
|
+
*/
|
|
917
|
+
function batchedFrames(first, iterator) {
|
|
918
|
+
const lead = isStreamEvent(first) ? "" : frameFor(first);
|
|
919
|
+
const head = isStreamEvent(first) ? first : takeBuffered(iterator);
|
|
920
|
+
if (!head) return lead;
|
|
921
|
+
const { events, last } = heldEventBatch({
|
|
922
|
+
first: head,
|
|
923
|
+
iterator,
|
|
924
|
+
encode: compactJsonLine
|
|
925
|
+
});
|
|
926
|
+
return `${lead}id: ${last.index}\ndata: [${events.join(",")}]\n\n`;
|
|
927
|
+
}
|
|
928
|
+
const compactJsonLine = (event) => isSerializedEvent(event) && /[\r\n]/.test(event.payloadJson) ? compactEventJson(parseSerializedEvent(event)) : compactEventJson(event);
|
|
929
|
+
function frameFor(item) {
|
|
930
|
+
if ("kind" in item) return `event: a2-snapshot\ndata: ${JSON.stringify(stateSnapshotToWire(item))}\n\n`;
|
|
931
|
+
if ("snapshot" in item) return `event: presence-snapshot\ndata: ${JSON.stringify(presenceSnapshotToWire(item))}\n\n`;
|
|
932
|
+
if ("participant" in item) return `event: presence\ndata: ${JSON.stringify(presencePatchToWire(item))}\n\n`;
|
|
933
|
+
if (isSerializedEvent(item)) return /[\r\n]/.test(item.payloadJson) ? `id: ${item.index}\ndata: ${JSON.stringify(eventToWire(parseSerializedEvent(item)))}\n\n` : `id: ${item.index}\ndata: ${serializedEventToWireJson(item)}\n\n`;
|
|
934
|
+
return `id: ${item.index}\ndata: ${JSON.stringify(eventToWire(item))}\n\n`;
|
|
935
|
+
}
|
|
936
|
+
function bootstrapStream(options) {
|
|
937
|
+
return { [Symbol.asyncIterator]() {
|
|
938
|
+
let closed = false;
|
|
939
|
+
let index;
|
|
940
|
+
let source;
|
|
941
|
+
let stopActivity;
|
|
942
|
+
const abort = new AbortController();
|
|
943
|
+
const activity = createStreamActivity(() => {
|
|
944
|
+
if (source) streamActivities.get(source)?.activate();
|
|
945
|
+
});
|
|
946
|
+
const iterator = {
|
|
947
|
+
async next() {
|
|
948
|
+
if (closed) return {
|
|
949
|
+
done: true,
|
|
950
|
+
value: void 0
|
|
951
|
+
};
|
|
952
|
+
if (index === void 0) {
|
|
953
|
+
let cancel;
|
|
954
|
+
const cancelled = new Promise((resolve) => {
|
|
955
|
+
cancel = () => resolve(void 0);
|
|
956
|
+
abort.signal.addEventListener("abort", cancel, { once: true });
|
|
957
|
+
});
|
|
958
|
+
try {
|
|
959
|
+
const snapshot = await Promise.race([options.snapshot(), cancelled]);
|
|
960
|
+
if (closed || snapshot === void 0) return {
|
|
961
|
+
done: true,
|
|
962
|
+
value: void 0
|
|
963
|
+
};
|
|
964
|
+
index = snapshot.index;
|
|
965
|
+
return {
|
|
966
|
+
done: false,
|
|
967
|
+
value: snapshot
|
|
968
|
+
};
|
|
969
|
+
} finally {
|
|
970
|
+
if (cancel) abort.signal.removeEventListener("abort", cancel);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (!source) {
|
|
974
|
+
source = options.stream(index)[Symbol.asyncIterator]();
|
|
975
|
+
const sourceActivity = streamActivities.get(source);
|
|
976
|
+
if (sourceActivity) {
|
|
977
|
+
activity.setIdle(sourceActivity.idle);
|
|
978
|
+
stopActivity = sourceActivity.subscribe(activity.setIdle);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const result = await source.next();
|
|
982
|
+
return closed ? {
|
|
983
|
+
done: true,
|
|
984
|
+
value: void 0
|
|
985
|
+
} : result;
|
|
986
|
+
},
|
|
987
|
+
async return() {
|
|
988
|
+
if (closed) return {
|
|
989
|
+
done: true,
|
|
990
|
+
value: void 0
|
|
991
|
+
};
|
|
992
|
+
closed = true;
|
|
993
|
+
abort.abort();
|
|
994
|
+
stopActivity?.();
|
|
995
|
+
await source?.return?.();
|
|
996
|
+
return {
|
|
997
|
+
done: true,
|
|
998
|
+
value: void 0
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
streamActivities.set(iterator, activity);
|
|
1003
|
+
bufferedEvents.set(iterator, () => closed || !source ? void 0 : takeBuffered(source));
|
|
1004
|
+
return iterator;
|
|
1005
|
+
} };
|
|
1006
|
+
}
|
|
1007
|
+
const serverFetchHooks = /* @__PURE__ */ new WeakMap();
|
|
1008
|
+
const parseHistoryBounds = (gte, lte) => {
|
|
1009
|
+
if (gte === null || lte === null) throw new A2Error("INVALID_PAYLOAD", "a history read takes both gte and lte");
|
|
1010
|
+
const bounds = {
|
|
1011
|
+
gte: Number(gte),
|
|
1012
|
+
lte: Number(lte)
|
|
1013
|
+
};
|
|
1014
|
+
for (const [name, value] of Object.entries(bounds)) if (!Number.isSafeInteger(value) || value < 0) throw new A2Error("INVALID_PAYLOAD", `history ${name} must be a non-negative safe integer`);
|
|
1015
|
+
if (bounds.gte > bounds.lte) throw new A2Error("INVALID_PAYLOAD", "history gte must be less than or equal to lte");
|
|
1016
|
+
return bounds;
|
|
1017
|
+
};
|
|
1018
|
+
const parseResumeIndex = (raw) => {
|
|
1019
|
+
if (raw === null) return 0;
|
|
1020
|
+
const index = Number(raw);
|
|
1021
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new A2Error("INVALID_PAYLOAD", "stream index must be a non-negative safe integer");
|
|
1022
|
+
return index;
|
|
1023
|
+
};
|
|
1024
|
+
const parseSubscription = (params) => {
|
|
1025
|
+
const reducer = params.get("reducer");
|
|
1026
|
+
const bootstrap = params.get("bootstrap");
|
|
1027
|
+
const index = params.get("index");
|
|
1028
|
+
if (reducer === "") throw new A2Error("INVALID_PAYLOAD", "empty reducer name");
|
|
1029
|
+
if (bootstrap !== null) {
|
|
1030
|
+
if (bootstrap !== "1" || index !== null || reducer === null) throw new A2Error("INVALID_PAYLOAD", "bootstrap requires a reducer and no index");
|
|
1031
|
+
return {
|
|
1032
|
+
bootstrap: true,
|
|
1033
|
+
reducer
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
index: parseResumeIndex(index),
|
|
1038
|
+
...reducer === null ? {} : { reducer }
|
|
1039
|
+
};
|
|
1040
|
+
};
|
|
1041
|
+
const errorResponse = (error) => {
|
|
1042
|
+
const a2error = asA2Error(error);
|
|
1043
|
+
return Response.json(errorToWire(a2error), { status: errorStatus(a2error.code) });
|
|
1044
|
+
};
|
|
1045
|
+
const forbidden = () => new A2Error("FORBIDDEN", "the operation is not authorized");
|
|
1046
|
+
const freezePush = (sessionId, events, presence, transport) => {
|
|
1047
|
+
for (const event of events) Object.freeze(event);
|
|
1048
|
+
Object.freeze(events);
|
|
1049
|
+
if (presence !== void 0) {
|
|
1050
|
+
Object.freeze(presence.values);
|
|
1051
|
+
Object.freeze(presence);
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
type: "push",
|
|
1055
|
+
sessionId,
|
|
1056
|
+
events,
|
|
1057
|
+
...presence === void 0 ? {} : { presence },
|
|
1058
|
+
transport
|
|
1059
|
+
};
|
|
1060
|
+
};
|
|
1061
|
+
function createServerFetch(server) {
|
|
1062
|
+
const presence = Object.keys(server.contract.presence).length > 0;
|
|
1063
|
+
const fetch = async (request, options) => {
|
|
1064
|
+
const authorized = async (operation) => {
|
|
1065
|
+
try {
|
|
1066
|
+
return await options?.authorize?.(Object.freeze(operation)) !== false;
|
|
1067
|
+
} catch (cause) {
|
|
1068
|
+
throw new A2Error("STORE_UNAVAILABLE", "authorization failed", { cause });
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
const authorize = async (operation) => {
|
|
1072
|
+
if (!await authorized(operation)) throw forbidden();
|
|
1073
|
+
};
|
|
1074
|
+
const validateIngress = async (context) => {
|
|
1075
|
+
await serverFetchHooks.get(fetch)?.validateIngress?.(context);
|
|
1076
|
+
};
|
|
1077
|
+
const gateSocketPush = async (sessionId, events, pushedPresence) => {
|
|
1078
|
+
try {
|
|
1079
|
+
await authorize(freezePush(sessionId, events, pushedPresence, "websocket"));
|
|
1080
|
+
await validateIngress({
|
|
1081
|
+
sessionId,
|
|
1082
|
+
events,
|
|
1083
|
+
...pushedPresence === void 0 ? {} : { presence: pushedPresence }
|
|
1084
|
+
});
|
|
1085
|
+
return null;
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
return asA2Error(error);
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
const selectedSession = (sessionId, selection) => {
|
|
1091
|
+
const reducer = server.contract.reducer;
|
|
1092
|
+
if (selection.reducer !== void 0 && reducer !== void 0 && reducer.name !== selection.reducer) throw new A2Error("INVALID_PAYLOAD", "client reducer does not match the public contract reducer");
|
|
1093
|
+
const target = server.session(sessionId);
|
|
1094
|
+
const streamTarget = target;
|
|
1095
|
+
if (!selection.bootstrap) return streamTarget;
|
|
1096
|
+
if (!reducer) throw new A2Error("INVALID_PAYLOAD", "contract has no public reducer");
|
|
1097
|
+
return {
|
|
1098
|
+
...streamTarget,
|
|
1099
|
+
stream: (streamOptions) => bootstrapStream({
|
|
1100
|
+
snapshot: async () => {
|
|
1101
|
+
const initial = await target.state(reducer);
|
|
1102
|
+
return {
|
|
1103
|
+
kind: "snapshot",
|
|
1104
|
+
sessionId,
|
|
1105
|
+
name: reducer.name,
|
|
1106
|
+
index: initial.index,
|
|
1107
|
+
state: encodeState({
|
|
1108
|
+
reducer,
|
|
1109
|
+
state: initial.state
|
|
1110
|
+
}),
|
|
1111
|
+
...initial.clearIndex === void 0 ? {} : { clearIndex: initial.clearIndex }
|
|
1112
|
+
};
|
|
1113
|
+
},
|
|
1114
|
+
stream: (index) => {
|
|
1115
|
+
const bounds = {
|
|
1116
|
+
startAfter: index,
|
|
1117
|
+
presence: streamOptions.presence === true
|
|
1118
|
+
};
|
|
1119
|
+
return target.stream(bounds);
|
|
1120
|
+
}
|
|
1121
|
+
})
|
|
1122
|
+
};
|
|
1123
|
+
};
|
|
1124
|
+
const attachMultiplexed = (wire) => (socket) => {
|
|
1125
|
+
const deadline = invocationDeadlineMs();
|
|
1126
|
+
sessionsSocket(async (sessionId, startAfter, selection = { index: startAfter }) => {
|
|
1127
|
+
const operation = {
|
|
1128
|
+
type: "stream",
|
|
1129
|
+
sessionId,
|
|
1130
|
+
startAfter,
|
|
1131
|
+
...selection.bootstrap ? { bootstrap: true } : {},
|
|
1132
|
+
transport: "websocket"
|
|
1133
|
+
};
|
|
1134
|
+
return await authorized(operation) ? selectedSession(sessionId, selection) : null;
|
|
1135
|
+
}, socket, {
|
|
1136
|
+
presence,
|
|
1137
|
+
wire,
|
|
1138
|
+
...deadline === null ? {} : { deadline },
|
|
1139
|
+
gatePush: gateSocketPush
|
|
1140
|
+
});
|
|
1141
|
+
};
|
|
1142
|
+
try {
|
|
1143
|
+
if (request.method === "GET") {
|
|
1144
|
+
if (request.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
1145
|
+
if (options?.upgradeWebSocket === void 0) return new Response("WebSocket upgrade requested, but server.fetch() has no upgrade implementation", { status: 426 });
|
|
1146
|
+
const { searchParams } = new URL(request.url);
|
|
1147
|
+
const wire = wireVersion(searchParams.get("wire"));
|
|
1148
|
+
if (options.multiplexWebSocket === true) return await options.upgradeWebSocket(attachMultiplexed(wire));
|
|
1149
|
+
const sessionId = searchParams.get("sessionId");
|
|
1150
|
+
if (sessionId === null || sessionId.length === 0) throw new A2Error("INVALID_PAYLOAD", "missing sessionId");
|
|
1151
|
+
const selection = parseSubscription(searchParams);
|
|
1152
|
+
const startAfter = selection.index ?? 0;
|
|
1153
|
+
await authorize({
|
|
1154
|
+
type: "stream",
|
|
1155
|
+
sessionId,
|
|
1156
|
+
startAfter,
|
|
1157
|
+
...selection.bootstrap ? { bootstrap: true } : {},
|
|
1158
|
+
transport: "websocket"
|
|
1159
|
+
});
|
|
1160
|
+
const target = selectedSession(sessionId, selection);
|
|
1161
|
+
const attach = (socket) => {
|
|
1162
|
+
const deadline = invocationDeadlineMs();
|
|
1163
|
+
sessionSocket(sessionId, target, socket, {
|
|
1164
|
+
startAfter,
|
|
1165
|
+
presence,
|
|
1166
|
+
wire,
|
|
1167
|
+
...deadline === null ? {} : { deadline },
|
|
1168
|
+
gatePush: gateSocketPush
|
|
1169
|
+
});
|
|
1170
|
+
};
|
|
1171
|
+
return await options.upgradeWebSocket(attach);
|
|
1172
|
+
}
|
|
1173
|
+
const { searchParams } = new URL(request.url);
|
|
1174
|
+
const sessionId = searchParams.get("sessionId");
|
|
1175
|
+
if (sessionId === null || sessionId.length === 0) throw new A2Error("INVALID_PAYLOAD", "missing sessionId");
|
|
1176
|
+
const gteRaw = searchParams.get("gte");
|
|
1177
|
+
const lteRaw = searchParams.get("lte");
|
|
1178
|
+
if (gteRaw !== null || lteRaw !== null) {
|
|
1179
|
+
const bounds = parseHistoryBounds(gteRaw, lteRaw);
|
|
1180
|
+
await authorize({
|
|
1181
|
+
type: "history",
|
|
1182
|
+
sessionId,
|
|
1183
|
+
...bounds,
|
|
1184
|
+
transport: "http"
|
|
1185
|
+
});
|
|
1186
|
+
const { events, clearIndex } = await server.historyPage(sessionId, bounds);
|
|
1187
|
+
const first = Math.max(bounds.gte, (clearIndex ?? 0) + 1);
|
|
1188
|
+
const covered = events.length === Math.max(0, bounds.lte - first + 1);
|
|
1189
|
+
return Response.json(events.map(eventToWire), { headers: {
|
|
1190
|
+
"a2-history-covered": String(covered),
|
|
1191
|
+
...clearIndex === void 0 ? {} : { "a2-clear-index": String(clearIndex) }
|
|
1192
|
+
} });
|
|
1193
|
+
}
|
|
1194
|
+
const selection = parseSubscription(searchParams);
|
|
1195
|
+
const startAfter = selection.index ?? 0;
|
|
1196
|
+
await authorize({
|
|
1197
|
+
type: "stream",
|
|
1198
|
+
sessionId,
|
|
1199
|
+
startAfter,
|
|
1200
|
+
...selection.bootstrap ? { bootstrap: true } : {},
|
|
1201
|
+
transport: "http"
|
|
1202
|
+
});
|
|
1203
|
+
const target = selectedSession(sessionId, selection);
|
|
1204
|
+
return sseResponse(presence ? target.stream({
|
|
1205
|
+
startAfter,
|
|
1206
|
+
presence: true
|
|
1207
|
+
}) : target.stream({ startAfter }), { wire: wireVersion(searchParams.get("wire")) });
|
|
1208
|
+
}
|
|
1209
|
+
if (request.method === "POST") {
|
|
1210
|
+
const body = await parsePushBody(request);
|
|
1211
|
+
await authorize(freezePush(body.sessionId, body.events, body.presence, "http"));
|
|
1212
|
+
await validateIngress({
|
|
1213
|
+
sessionId: body.sessionId,
|
|
1214
|
+
events: body.events,
|
|
1215
|
+
...body.presence === void 0 ? {} : { presence: body.presence }
|
|
1216
|
+
});
|
|
1217
|
+
const session = server.session(body.sessionId);
|
|
1218
|
+
if (body.presence !== void 0) {
|
|
1219
|
+
const target = session;
|
|
1220
|
+
if (typeof target.setPresence !== "function") throw invalidPushBody("presence is not declared by this contract");
|
|
1221
|
+
await target.setPresence(body.presence);
|
|
1222
|
+
}
|
|
1223
|
+
const appended = body.events.length === 0 ? [] : await session.append(...body.events);
|
|
1224
|
+
return Response.json(appended.map(eventToWire));
|
|
1225
|
+
}
|
|
1226
|
+
return new Response("Method Not Allowed", {
|
|
1227
|
+
status: 405,
|
|
1228
|
+
headers: { allow: "GET, POST" }
|
|
1229
|
+
});
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
return errorResponse(error);
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
return fetch;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Deterministic default ids for consumer `session.append` calls. A consumer
|
|
1238
|
+
* re-run after a crash calls `session.append` again; giving
|
|
1239
|
+
* each call a deterministic id — derived from the triggering event's id
|
|
1240
|
+
* and the caller's stable name — makes the re-run a no-op via the store's
|
|
1241
|
+
* per-session unique index on event ids.
|
|
1242
|
+
*
|
|
1243
|
+
* The id is a UUIDv5-shaped SHA-1 over
|
|
1244
|
+
* `(namespace, triggerEventId, name, itemOrdinal)`, computed with
|
|
1245
|
+
* WebCrypto so it runs on any platform a2 core targets.
|
|
1246
|
+
*/
|
|
1247
|
+
const APPEND_NAMESPACE = "a2:handler-append:v2";
|
|
1248
|
+
const RETURN_NAMESPACE = "a2:handler-return:v1";
|
|
1249
|
+
const SCHEDULE_NAMESPACE = "a2:schedule:v1";
|
|
1250
|
+
const SCHEDULED_EVENT_NAMESPACE = "a2:scheduled-event:v1";
|
|
1251
|
+
const SCHEDULE_CLEAR_NAMESPACE = "a2:schedule-clear:v1";
|
|
1252
|
+
async function deterministicIdFromInput(input) {
|
|
1253
|
+
const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(input));
|
|
1254
|
+
const bytes = new Uint8Array(digest).slice(0, 16);
|
|
1255
|
+
bytes[6] = (bytes[6] ?? 0) & 15 | 80;
|
|
1256
|
+
bytes[8] = (bytes[8] ?? 0) & 63 | 128;
|
|
1257
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
1258
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
1259
|
+
}
|
|
1260
|
+
async function deterministicId(parts) {
|
|
1261
|
+
return deterministicIdFromInput(parts.join("\0"));
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* `consumer` keys the id on the consumer that appends, so two consumers of
|
|
1265
|
+
* one event never collide. A2's own consumers pass none: their types are
|
|
1266
|
+
* disjoint, so the event alone keys them.
|
|
1267
|
+
*/
|
|
1268
|
+
async function deterministicEventId(triggerEventId, name, itemOrdinal, consumer) {
|
|
1269
|
+
if (name.length === 0) throw new TypeError("append name must not be empty");
|
|
1270
|
+
return deterministicId([
|
|
1271
|
+
APPEND_NAMESPACE,
|
|
1272
|
+
triggerEventId,
|
|
1273
|
+
name,
|
|
1274
|
+
itemOrdinal,
|
|
1275
|
+
...consumer === void 0 ? [] : [consumer]
|
|
1276
|
+
]);
|
|
1277
|
+
}
|
|
1278
|
+
async function deterministicReturnedEventId(triggerEventId, itemOrdinal, consumer) {
|
|
1279
|
+
return deterministicId([
|
|
1280
|
+
RETURN_NAMESPACE,
|
|
1281
|
+
triggerEventId,
|
|
1282
|
+
itemOrdinal,
|
|
1283
|
+
...consumer === void 0 ? [] : [consumer]
|
|
1284
|
+
]);
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* The id of a scheduled append. `clearIndex` is the lifetime the trigger was
|
|
1288
|
+
* claimed in; it joins the input only when positive, so a trigger id reused
|
|
1289
|
+
* after a clear schedules a distinct task while sessions never cleared keep
|
|
1290
|
+
* the ids they always had.
|
|
1291
|
+
*/
|
|
1292
|
+
async function deterministicScheduleId(contract, sessionId, triggerEventId, name, clearIndex = 0, consumer) {
|
|
1293
|
+
return deterministicIdFromInput(JSON.stringify([
|
|
1294
|
+
SCHEDULE_NAMESPACE,
|
|
1295
|
+
contract,
|
|
1296
|
+
sessionId,
|
|
1297
|
+
triggerEventId,
|
|
1298
|
+
name,
|
|
1299
|
+
...clearIndex > 0 ? [clearIndex] : [],
|
|
1300
|
+
...consumer === void 0 ? [] : [{ consumer }]
|
|
1301
|
+
]));
|
|
1302
|
+
}
|
|
1303
|
+
async function deterministicScheduleClearId({ contract, sessionId, triggerEventId, clearIndex, name, ifLastIndex, consumer }) {
|
|
1304
|
+
return deterministicIdFromInput(JSON.stringify([
|
|
1305
|
+
SCHEDULE_CLEAR_NAMESPACE,
|
|
1306
|
+
contract,
|
|
1307
|
+
sessionId,
|
|
1308
|
+
triggerEventId,
|
|
1309
|
+
clearIndex,
|
|
1310
|
+
name,
|
|
1311
|
+
ifLastIndex,
|
|
1312
|
+
...consumer === void 0 ? [] : [{ consumer }]
|
|
1313
|
+
]));
|
|
1314
|
+
}
|
|
1315
|
+
async function deterministicScheduledEventId(scheduleId, itemOrdinal) {
|
|
1316
|
+
return deterministicId([
|
|
1317
|
+
SCHEDULED_EVENT_NAMESPACE,
|
|
1318
|
+
scheduleId,
|
|
1319
|
+
itemOrdinal
|
|
1320
|
+
]);
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* The default when no telemetry is configured. Spans cost nothing, but
|
|
1324
|
+
* `recordError` prints: it only ever receives errors a2 swallows by
|
|
1325
|
+
* design, so without a configured sink the console is the one place
|
|
1326
|
+
* they can surface. Passing `telemetry` replaces this sink wholesale.
|
|
1327
|
+
*/
|
|
1328
|
+
const CONSOLE_TELEMETRY = { span: (name, attributes, fn) => fn({
|
|
1329
|
+
setAttribute: () => {},
|
|
1330
|
+
recordError: (error) => {
|
|
1331
|
+
console.error(`[a2] error in ${name}`, attributes, error);
|
|
1332
|
+
}
|
|
1333
|
+
}) };
|
|
1334
|
+
/**
|
|
1335
|
+
* Deliver one authenticated scheduler append through an A2 server's ordinary
|
|
1336
|
+
* top-level append path. Custom scheduler adapters call this after validating
|
|
1337
|
+
* their transport envelope; application code normally uses `session.schedule`.
|
|
1338
|
+
*/
|
|
1339
|
+
async function deliverSchedulerAppend(server, task) {
|
|
1340
|
+
if (server.contract.name !== task.contract) throw new TypeError(`a2 scheduler: append task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`);
|
|
1341
|
+
const { originIndex } = task;
|
|
1342
|
+
if (originIndex !== void 0 && (!Number.isSafeInteger(originIndex) || originIndex < 1)) throw new TypeError("a2 scheduler: append task originIndex must be a positive safe integer");
|
|
1343
|
+
const internals = serverInternals.get(server);
|
|
1344
|
+
if (!internals) throw new TypeError(`a2 scheduler: server for contract '${task.contract}' cannot receive scheduled appends`);
|
|
1345
|
+
await internals.schedulerAppend(task.sessionId, task.events, originIndex);
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Deliver one authenticated scheduler clear: a conditional
|
|
1349
|
+
* `clear({ ifLastIndex })` that resolves after its deletion finishes, so a
|
|
1350
|
+
* provider retry resumes an interrupted deletion. Custom scheduler adapters
|
|
1351
|
+
* call this after validating their transport envelope; application code uses
|
|
1352
|
+
* `session.schedule.clear`.
|
|
1353
|
+
*/
|
|
1354
|
+
async function deliverSchedulerClear(server, task) {
|
|
1355
|
+
if (server.contract.name !== task.contract) throw new TypeError(`a2 scheduler: clear task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`);
|
|
1356
|
+
if (task.kind !== "clear" || !Number.isSafeInteger(task.ifLastIndex) || task.ifLastIndex < 0) throw new TypeError("a2 scheduler: a clear task needs kind \"clear\" and a non-negative safe integer ifLastIndex");
|
|
1357
|
+
const internals = serverInternals.get(server);
|
|
1358
|
+
if (!internals) throw new TypeError(`a2 scheduler: server for contract '${task.contract}' cannot receive scheduled clears`);
|
|
1359
|
+
await internals.schedulerClear(task.sessionId, task.ifLastIndex);
|
|
1360
|
+
}
|
|
1361
|
+
const CONSUMER_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
|
|
1362
|
+
/** Define a consumer: a named reader of each session's log. */
|
|
1363
|
+
function consumer(options) {
|
|
1364
|
+
return defineConsumer(options, false);
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* `consumer()`, and for A2's own runtimes (`runtime`) a name under `a2.` and
|
|
1368
|
+
* child ids without the name: their types are disjoint, so the event alone
|
|
1369
|
+
* keys them, and regrouping A2's consumers never changes an id.
|
|
1370
|
+
*/
|
|
1371
|
+
function defineConsumer(options, runtime) {
|
|
1372
|
+
const contract = options?.contract;
|
|
1373
|
+
if (!contract || typeof contract.name !== "string") throw new TypeError("consumer expects options with a contract");
|
|
1374
|
+
const consumerName = options.name;
|
|
1375
|
+
if (typeof consumerName !== "string" || !CONSUMER_NAME.test(consumerName)) throw new TypeError("consumer name must be 1-128 letters, digits, and . _ : - characters");
|
|
1376
|
+
if (consumerName.startsWith("a2.") !== runtime) throw new TypeError(runtime ? `A2's own consumers are named under 'a2.'` : `consumer names starting with 'a2.' are reserved`);
|
|
1377
|
+
const types = options.types === void 0 ? null : [...options.types];
|
|
1378
|
+
if (types?.length === 0) throw new TypeError(`consumer '${consumerName}' lists no types; leave types out to take every type`);
|
|
1379
|
+
for (const type of types ?? []) if (!Object.hasOwn(contract.events, type)) throw new TypeError(`contract '${contract.name}' has no event type '${String(type)}'`);
|
|
1380
|
+
const lane = options.lane ?? null;
|
|
1381
|
+
if (lane !== null && typeof lane !== "function" && (typeof lane !== "string" || lane.length === 0)) throw new TypeError(`lane for consumer '${consumerName}' must be a non-empty string or a function`);
|
|
1382
|
+
const backfill = options.backfill ?? false;
|
|
1383
|
+
if (backfill && typeof lane !== "string") throw new TypeError(`consumer '${consumerName}' backfills, so its lane must be a string`);
|
|
1384
|
+
if (backfill && options.abortOn !== void 0) throw new TypeError(`consumer '${consumerName}' backfills, so it can't use abortOn`);
|
|
1385
|
+
return { run(fn) {
|
|
1386
|
+
if (typeof fn !== "function") throw new TypeError(`consumer '${consumerName}' needs a run function`);
|
|
1387
|
+
const defined = {
|
|
1388
|
+
name: consumerName,
|
|
1389
|
+
contract: { name: contract.name },
|
|
1390
|
+
spec: {
|
|
1391
|
+
types,
|
|
1392
|
+
lane,
|
|
1393
|
+
backfill,
|
|
1394
|
+
abortOn: options.abortOn ?? null,
|
|
1395
|
+
run: fn
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
if (runtime) runtimeConsumers.add(defined);
|
|
1399
|
+
return defined;
|
|
1400
|
+
} };
|
|
1401
|
+
}
|
|
1402
|
+
runtimeConsumerFactory.define = (options) => defineConsumer(options, true);
|
|
1403
|
+
/** A registered consumer as the store needs to know it. */
|
|
1404
|
+
const storeConsumerOf = (registered) => registered.log ? {
|
|
1405
|
+
name: registered.name,
|
|
1406
|
+
log: registered.log
|
|
1407
|
+
} : { name: registered.name };
|
|
1408
|
+
const MAX_FAILURES = 10;
|
|
1409
|
+
/** How long `consume` waits before looking again at a head it can't run. */
|
|
1410
|
+
const CONSUME_WAIT_MIN_MS = 25;
|
|
1411
|
+
const CONSUME_WAIT_MAX_MS = 1e3;
|
|
1412
|
+
/**
|
|
1413
|
+
* `task`, or a rejection with `signal`'s reason once it aborts. The task runs
|
|
1414
|
+
* on either way, and its own failure is caught, so nothing goes unhandled.
|
|
1415
|
+
*/
|
|
1416
|
+
const untilAborted = (task, signal) => {
|
|
1417
|
+
if (!signal) return task;
|
|
1418
|
+
task.catch(() => {});
|
|
1419
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
1420
|
+
return new Promise((resolve, reject) => {
|
|
1421
|
+
const abort = () => reject(signal.reason);
|
|
1422
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1423
|
+
task.then(resolve, reject).finally(() => {
|
|
1424
|
+
signal.removeEventListener("abort", abort);
|
|
1425
|
+
});
|
|
1426
|
+
});
|
|
1427
|
+
};
|
|
1428
|
+
/** How long an event waits after its `failureCount`th failed attempt. */
|
|
1429
|
+
const failureRetryDelayMs = (failureCount) => Math.min(DRAIN_TIMINGS.failureRetryBaseMs * 2 ** Math.max(failureCount - 1, 0), DRAIN_TIMINGS.failureRetryMaxMs);
|
|
1430
|
+
/** Abort a lost attempt's consumer and release its drain slot. */
|
|
1431
|
+
function endSuperseded(entry, message) {
|
|
1432
|
+
entry.supersede.abort(new A2Error("SUPERSEDED_ATTEMPT", message));
|
|
1433
|
+
entry.fence({
|
|
1434
|
+
status: "superseded",
|
|
1435
|
+
opensLane: false,
|
|
1436
|
+
appendsEvents: false
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
/** Pause between clear-deletion batches that found only locked rows. */
|
|
1440
|
+
const RECLAIM_STALL_MS = 20;
|
|
1441
|
+
const RECLAIM_STALL_LIMIT = 50;
|
|
1442
|
+
/** Default: how long a presence value survives without a refreshing set. */
|
|
1443
|
+
const PRESENCE_TTL_MS = 6e4;
|
|
1444
|
+
function createDrainSignal() {
|
|
1445
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1446
|
+
return {
|
|
1447
|
+
version: 0,
|
|
1448
|
+
closed: false,
|
|
1449
|
+
notify() {
|
|
1450
|
+
this.version += 1;
|
|
1451
|
+
for (const listener of listeners) listener();
|
|
1452
|
+
listeners.clear();
|
|
1453
|
+
},
|
|
1454
|
+
wait(version) {
|
|
1455
|
+
if (this.version !== version) return {
|
|
1456
|
+
promise: Promise.resolve(),
|
|
1457
|
+
cancel: () => {}
|
|
1458
|
+
};
|
|
1459
|
+
let resolve;
|
|
1460
|
+
const promise = new Promise((done) => {
|
|
1461
|
+
resolve = done;
|
|
1462
|
+
});
|
|
1463
|
+
listeners.add(resolve);
|
|
1464
|
+
return {
|
|
1465
|
+
promise,
|
|
1466
|
+
cancel: () => listeners.delete(resolve)
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
function dispatchAbortEvent(hub, event) {
|
|
1472
|
+
let relevant = false;
|
|
1473
|
+
for (const subscription of hub.subscriptions) {
|
|
1474
|
+
if (!subscription.types.has(event.type)) continue;
|
|
1475
|
+
relevant = true;
|
|
1476
|
+
if (subscription.matches(event)) subscription.controller.abort();
|
|
1477
|
+
}
|
|
1478
|
+
if (relevant) hub.seen.set(event.index, event);
|
|
1479
|
+
}
|
|
1480
|
+
function pruneAbortHub(hub) {
|
|
1481
|
+
let minimum = Infinity;
|
|
1482
|
+
const types = /* @__PURE__ */ new Set();
|
|
1483
|
+
for (const subscription of hub.subscriptions) {
|
|
1484
|
+
minimum = Math.min(minimum, subscription.triggerIndex);
|
|
1485
|
+
for (const type of subscription.types) types.add(type);
|
|
1486
|
+
}
|
|
1487
|
+
for (const [index, event] of hub.seen) if (index <= minimum || !types.has(event.type)) hub.seen.delete(index);
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Deadlines tighten the claim/watchdog window, and a passed deadline ends
|
|
1491
|
+
* claiming: a claim then could not run, only lapse and be claimed again until
|
|
1492
|
+
* the platform ends the process. A platform timeout otherwise follows the
|
|
1493
|
+
* same path as any other process death: the in-flight event remains pending
|
|
1494
|
+
* and a fresh invocation retries it.
|
|
1495
|
+
*/
|
|
1496
|
+
function nextClaimWindow() {
|
|
1497
|
+
const nowMs = Date.now();
|
|
1498
|
+
const deadlineMs = invocationDeadlineMs();
|
|
1499
|
+
const deadlineTtlMs = deadlineMs === null ? DRAIN_TIMINGS.claimTtlMs : deadlineMs - nowMs;
|
|
1500
|
+
const deadlineCapped = deadlineMs !== null && deadlineTtlMs <= DRAIN_TIMINGS.claimTtlMs;
|
|
1501
|
+
const ttlMs = Math.max(1, Math.min(DRAIN_TIMINGS.claimTtlMs, deadlineTtlMs));
|
|
1502
|
+
const expiresAtMs = nowMs + ttlMs;
|
|
1503
|
+
return {
|
|
1504
|
+
ttlMs,
|
|
1505
|
+
expiresAtMs,
|
|
1506
|
+
watchdogAtMs: expiresAtMs + DRAIN_TIMINGS.watchdogGraceMs,
|
|
1507
|
+
deadlineCapped,
|
|
1508
|
+
pastDeadline: deadlineMs !== null && deadlineTtlMs <= 0
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function schedulerSlot(dueAt) {
|
|
1512
|
+
return Math.ceil(dueAt / 1e3) * 1e3;
|
|
1513
|
+
}
|
|
1514
|
+
function currentSchedulerSlot(now) {
|
|
1515
|
+
return Math.floor(now / 1e3) * 1e3;
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* The namespace separator between machine name and session id in
|
|
1519
|
+
* storage. Machines sharing one store backend (the dev-default sqlite
|
|
1520
|
+
* file, a shared Postgres) must not collide on session ids; the machine
|
|
1521
|
+
* name is what makes multi-machine apps unambiguous (a2-api.md §1), so
|
|
1522
|
+
* it prefixes every storage key.
|
|
1523
|
+
*/
|
|
1524
|
+
const NS = "";
|
|
1525
|
+
const devDefaultStore = retryableLazy(() => import("./store-sqlite-BmwL3pUR.mjs").then((m) => m.sqlite()));
|
|
1526
|
+
/** Waits for a scheduler arm for at most `schedulerArmTimeoutMs`. */
|
|
1527
|
+
async function joinSchedulerArm(arm) {
|
|
1528
|
+
let timeout = null;
|
|
1529
|
+
try {
|
|
1530
|
+
await Promise.race([arm, new Promise((_, reject) => {
|
|
1531
|
+
timeout = setTimeout(() => {
|
|
1532
|
+
reject(/* @__PURE__ */ new Error(`a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`));
|
|
1533
|
+
}, DRAIN_TIMINGS.schedulerArmTimeoutMs);
|
|
1534
|
+
})]);
|
|
1535
|
+
} finally {
|
|
1536
|
+
if (timeout) clearTimeout(timeout);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
function environment() {
|
|
1540
|
+
const env = typeof process === "undefined" ? void 0 : process.env?.["NODE_ENV"];
|
|
1541
|
+
if (env === "test") return "test";
|
|
1542
|
+
if (env === "production") return "production";
|
|
1543
|
+
return "development";
|
|
1544
|
+
}
|
|
1545
|
+
function describeError(err) {
|
|
1546
|
+
if (err instanceof Error) return err.stack ?? `${err.name}: ${err.message}`;
|
|
1547
|
+
return String(err);
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Comparison key for a presence value in the degraded-tier diff — an
|
|
1551
|
+
* LWW tie can replace a value without moving its `seen`/`at` stamp.
|
|
1552
|
+
*/
|
|
1553
|
+
function fingerprint(value) {
|
|
1554
|
+
return JSON.stringify(value) ?? "";
|
|
1555
|
+
}
|
|
1556
|
+
function foldPresenceRows(rows) {
|
|
1557
|
+
const map = nullProtoRecord();
|
|
1558
|
+
for (const row of rows) (map[row.participant] ??= nullProtoRecord())[row.field] = {
|
|
1559
|
+
value: row.value,
|
|
1560
|
+
seen: row.seen,
|
|
1561
|
+
at: row.at
|
|
1562
|
+
};
|
|
1563
|
+
return map;
|
|
1564
|
+
}
|
|
1565
|
+
/** One cursor head, as a map key. */
|
|
1566
|
+
const headKey = (ref) => JSON.stringify([
|
|
1567
|
+
ref.consumer,
|
|
1568
|
+
ref.lane,
|
|
1569
|
+
ref.index
|
|
1570
|
+
]);
|
|
1571
|
+
const headRefOf = (head) => ({
|
|
1572
|
+
consumer: head.consumer,
|
|
1573
|
+
lane: head.lane,
|
|
1574
|
+
index: head.index
|
|
1575
|
+
});
|
|
1576
|
+
const addSnapshotWrite = (writes, index, state, pin) => {
|
|
1577
|
+
let write = writes.get(index);
|
|
1578
|
+
if (!write) {
|
|
1579
|
+
write = {
|
|
1580
|
+
state: cloneInitial(state),
|
|
1581
|
+
pins: /* @__PURE__ */ new Map()
|
|
1582
|
+
};
|
|
1583
|
+
writes.set(index, write);
|
|
1584
|
+
}
|
|
1585
|
+
if (pin !== void 0) write.pins.set(headKey(pin), pin);
|
|
1586
|
+
};
|
|
1587
|
+
/** Whether a registered consumer runs events of this type. */
|
|
1588
|
+
const takes = (registered, type) => registered.types === null || registered.types.has(type);
|
|
1589
|
+
/** Implement a contract: bind its vocabulary to storage and reactions. */
|
|
1590
|
+
function createServer(options) {
|
|
1591
|
+
const serverContract = options?.contract;
|
|
1592
|
+
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)");
|
|
1593
|
+
if (Object.hasOwn(options, "handlers")) throw new TypeError("createServer no longer takes handlers: write each as a consumer in `consumers` (see https://www.a2.dev/upgrades/0-21)");
|
|
1594
|
+
const name = serverContract.name;
|
|
1595
|
+
const defs = serverContract.events;
|
|
1596
|
+
const presenceDefs = serverContract.presence;
|
|
1597
|
+
const declaresPresence = Object.keys(presenceDefs).length > 0;
|
|
1598
|
+
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)`);
|
|
1599
|
+
if (options.presence !== void 0) {
|
|
1600
|
+
if (!declaresPresence) throw new TypeError(`the presence option requires a contract that declares presence fields — '${name}' has none`);
|
|
1601
|
+
const { ttlMs } = options.presence;
|
|
1602
|
+
if (ttlMs !== void 0 && (!Number.isInteger(ttlMs) || ttlMs <= 0)) throw new TypeError("presence.ttlMs must be a positive integer of milliseconds");
|
|
1603
|
+
}
|
|
1604
|
+
const presenceTtlMs = options.presence?.ttlMs ?? PRESENCE_TTL_MS;
|
|
1605
|
+
let makeStore;
|
|
1606
|
+
if (options.store) {
|
|
1607
|
+
const explicit = options.store;
|
|
1608
|
+
if (declaresPresence && !explicit.presence) throw presenceNotSupported();
|
|
1609
|
+
makeStore = () => Promise.resolve(explicit);
|
|
1610
|
+
} else {
|
|
1611
|
+
const env = environment();
|
|
1612
|
+
if (env === "production") {
|
|
1613
|
+
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')`);
|
|
1614
|
+
if (process.env["NEXT_PHASE"] !== "phase-production-build") throw error();
|
|
1615
|
+
makeStore = () => Promise.reject(error());
|
|
1616
|
+
} else makeStore = env === "test" ? () => import("./store-memory-ZLiZbH3R.mjs").then((m) => m.memory()) : devDefaultStore.get;
|
|
1617
|
+
}
|
|
1618
|
+
if (declaresPresence && !options.store) {
|
|
1619
|
+
const inner = makeStore;
|
|
1620
|
+
makeStore = async () => {
|
|
1621
|
+
const store = await inner();
|
|
1622
|
+
if (!store.presence) throw presenceNotSupported();
|
|
1623
|
+
return store;
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
const resolveStore = retryableLazy(makeStore).get;
|
|
1627
|
+
const telemetry = options.telemetry ?? CONSOLE_TELEMETRY;
|
|
1628
|
+
const scheduler = options.scheduler;
|
|
1629
|
+
const abortMatcherOf = (spec) => {
|
|
1630
|
+
let abortOn = null;
|
|
1631
|
+
if (spec !== void 0 && spec !== null) {
|
|
1632
|
+
abortOn = /* @__PURE__ */ new Map();
|
|
1633
|
+
const pairs = Array.isArray(spec) ? spec.map((abortType) => [abortType, true]) : Object.entries(spec);
|
|
1634
|
+
for (const [abortType, matcher] of pairs) {
|
|
1635
|
+
if (matcher === void 0) continue;
|
|
1636
|
+
if (!Object.hasOwn(defs, abortType)) throw new TypeError(`contract '${name}' has no event type '${String(abortType)}' — cannot abort on it`);
|
|
1637
|
+
abortOn.set(String(abortType), matcher);
|
|
1638
|
+
}
|
|
1639
|
+
if (abortOn.size === 0) abortOn = null;
|
|
1640
|
+
}
|
|
1641
|
+
return abortOn;
|
|
1642
|
+
};
|
|
1643
|
+
const laneContext = (sessionId, event) => ({
|
|
1644
|
+
sessionId,
|
|
1645
|
+
event: {
|
|
1646
|
+
type: event.type,
|
|
1647
|
+
payload: structuredClone(event.payload),
|
|
1648
|
+
...event.id === void 0 ? {} : { id: event.id }
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
const registeredConsumers = /* @__PURE__ */ new Map();
|
|
1652
|
+
/** A consumer definition as the server runs it. */
|
|
1653
|
+
const registrationOf = (defined) => {
|
|
1654
|
+
const { types, lane, backfill, abortOn, run } = defined.spec;
|
|
1655
|
+
const matcher = abortMatcherOf(abortOn);
|
|
1656
|
+
return {
|
|
1657
|
+
name: defined.name,
|
|
1658
|
+
types: types === null ? null : new Set(types),
|
|
1659
|
+
laneOf: typeof lane === "string" ? null : lane === null ? () => null : (sessionId, event) => {
|
|
1660
|
+
const resolved = lane(laneContext(sessionId, event));
|
|
1661
|
+
if (resolved !== null && (typeof resolved !== "string" || resolved.length === 0)) throw new TypeError(`lane for consumer '${defined.name}' must resolve to a non-empty string or null`);
|
|
1662
|
+
return resolved;
|
|
1663
|
+
},
|
|
1664
|
+
log: typeof lane === "string" ? {
|
|
1665
|
+
lane,
|
|
1666
|
+
types: types === null ? null : [...types],
|
|
1667
|
+
backfill
|
|
1668
|
+
} : void 0,
|
|
1669
|
+
abortOn: () => matcher,
|
|
1670
|
+
run,
|
|
1671
|
+
anonymousIds: runtimeConsumers.has(defined)
|
|
1672
|
+
};
|
|
1673
|
+
};
|
|
1674
|
+
/** The definitions the server runs, so `consume` can tell its own from another. */
|
|
1675
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
1676
|
+
/** A consumer written inline is `consumer()`'s options and `run`, on this contract. */
|
|
1677
|
+
const definitionOf = (entry) => {
|
|
1678
|
+
if (entry === null || typeof entry !== "object" || "spec" in entry || !("run" in entry)) return entry;
|
|
1679
|
+
const { run, ...rest } = entry;
|
|
1680
|
+
return consumer({
|
|
1681
|
+
...rest,
|
|
1682
|
+
contract: serverContract
|
|
1683
|
+
}).run(run);
|
|
1684
|
+
};
|
|
1685
|
+
for (const entry of options.consumers ?? []) {
|
|
1686
|
+
const defined = definitionOf(entry);
|
|
1687
|
+
if (defined?.contract?.name !== name || !defined.spec) throw new TypeError(`createServer for '${name}' got a consumer of another contract`);
|
|
1688
|
+
if (registeredConsumers.has(defined.name)) throw new TypeError(`two consumers are named '${defined.name}'`);
|
|
1689
|
+
registeredConsumers.set(defined.name, registrationOf(defined));
|
|
1690
|
+
definitions.set(defined.name, defined);
|
|
1691
|
+
}
|
|
1692
|
+
const anonymousTypes = /* @__PURE__ */ new Map();
|
|
1693
|
+
for (const registered of registeredConsumers.values()) {
|
|
1694
|
+
if (!registered.anonymousIds) continue;
|
|
1695
|
+
for (const type of registered.types ?? Object.keys(defs)) {
|
|
1696
|
+
const other = anonymousTypes.get(type);
|
|
1697
|
+
if (other !== void 0) throw new TypeError(`A2's consumers '${other}' and '${registered.name}' both take '${type}'`);
|
|
1698
|
+
anonymousTypes.set(type, registered.name);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
/** The consumers this server serves, as the store needs to know them. */
|
|
1702
|
+
const storeConsumers = [...registeredConsumers.values()].map(storeConsumerOf);
|
|
1703
|
+
/** Whether an event starts cursor work for any consumer. */
|
|
1704
|
+
const startsWork = (event) => (event.targets?.length ?? 0) > 0 || [...registeredConsumers.values()].some((registered) => registered.log && takes(registered, event.type));
|
|
1705
|
+
const prefix = `${name}${NS}`;
|
|
1706
|
+
const nsId = (sessionId) => `${prefix}${sessionId}`;
|
|
1707
|
+
const stripNs = (stored) => stored.slice(prefix.length);
|
|
1708
|
+
const toPublic = (row) => ({
|
|
1709
|
+
id: row.id,
|
|
1710
|
+
type: row.type,
|
|
1711
|
+
payload: row.payload,
|
|
1712
|
+
index: row.index,
|
|
1713
|
+
sessionId: stripNs(row.sessionId),
|
|
1714
|
+
createdAt: row.createdAt
|
|
1715
|
+
});
|
|
1716
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
1717
|
+
const track = (p) => {
|
|
1718
|
+
inFlight.add(p);
|
|
1719
|
+
const drop = () => {
|
|
1720
|
+
inFlight.delete(p);
|
|
1721
|
+
};
|
|
1722
|
+
p.then(drop, drop);
|
|
1723
|
+
};
|
|
1724
|
+
const schedulerArmSlots = /* @__PURE__ */ new Map();
|
|
1725
|
+
/**
|
|
1726
|
+
* Start an optional scheduler arm without putting it on the execution path.
|
|
1727
|
+
* The returned raw promise lets the initial append report failure; the
|
|
1728
|
+
* tracked copy is always rejection-safe for heartbeat callers.
|
|
1729
|
+
*/
|
|
1730
|
+
const startSchedulerArm = (sessionId, dueAt) => {
|
|
1731
|
+
if (!scheduler) return null;
|
|
1732
|
+
const slottedDueAt = schedulerSlot(dueAt);
|
|
1733
|
+
const slotKey = JSON.stringify([sessionId, slottedDueAt]);
|
|
1734
|
+
const existing = schedulerArmSlots.get(slotKey);
|
|
1735
|
+
if (existing) return existing.promise;
|
|
1736
|
+
const raw = Promise.resolve().then(() => scheduler.schedule({
|
|
1737
|
+
version: 1,
|
|
1738
|
+
kind: "drain",
|
|
1739
|
+
contract: name,
|
|
1740
|
+
sessionId,
|
|
1741
|
+
dueAt: slottedDueAt
|
|
1742
|
+
}));
|
|
1743
|
+
const forget = setTimeout(() => {
|
|
1744
|
+
if (schedulerArmSlots.get(slotKey)?.promise === raw) schedulerArmSlots.delete(slotKey);
|
|
1745
|
+
}, Math.max(0, slottedDueAt - Date.now()) + 1e3);
|
|
1746
|
+
forget.unref?.();
|
|
1747
|
+
schedulerArmSlots.set(slotKey, {
|
|
1748
|
+
promise: raw,
|
|
1749
|
+
forget
|
|
1750
|
+
});
|
|
1751
|
+
raw.then(void 0, () => {
|
|
1752
|
+
const current = schedulerArmSlots.get(slotKey);
|
|
1753
|
+
if (current?.promise !== raw) return;
|
|
1754
|
+
clearTimeout(current.forget);
|
|
1755
|
+
schedulerArmSlots.delete(slotKey);
|
|
1756
|
+
});
|
|
1757
|
+
const safe = raw.catch(() => {});
|
|
1758
|
+
track(safe);
|
|
1759
|
+
platformWaitUntil(safe);
|
|
1760
|
+
return raw;
|
|
1761
|
+
};
|
|
1762
|
+
const scheduledDrains = /* @__PURE__ */ new Map();
|
|
1763
|
+
const scheduleDrain = (sessionId, watchdogDueAt) => {
|
|
1764
|
+
const existing = scheduledDrains.get(sessionId);
|
|
1765
|
+
if (existing && !existing.signal.closed) {
|
|
1766
|
+
existing.signal.notify();
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
if (existing) scheduledDrains.delete(sessionId);
|
|
1770
|
+
const signal = createDrainSignal();
|
|
1771
|
+
const p = new Promise((resolve) => {
|
|
1772
|
+
queueMicrotask(() => {
|
|
1773
|
+
drainSession(sessionId, {
|
|
1774
|
+
watchdogDueAt,
|
|
1775
|
+
signal
|
|
1776
|
+
}).then(() => resolve()).catch(() => resolve());
|
|
1777
|
+
});
|
|
1778
|
+
}).finally(() => {
|
|
1779
|
+
if (scheduledDrains.get(sessionId)?.promise === p) scheduledDrains.delete(sessionId);
|
|
1780
|
+
});
|
|
1781
|
+
scheduledDrains.set(sessionId, {
|
|
1782
|
+
signal,
|
|
1783
|
+
promise: p
|
|
1784
|
+
});
|
|
1785
|
+
track(p);
|
|
1786
|
+
platformWaitUntil(p);
|
|
1787
|
+
};
|
|
1788
|
+
const validateEvents = (sessionId, events, resolveDispatchMetadata = true) => {
|
|
1789
|
+
if (events.length === 0) throw new TypeError("append requires at least one event");
|
|
1790
|
+
return events.map((e) => {
|
|
1791
|
+
const schema = Object.hasOwn(defs, e.type) ? defs[e.type] : void 0;
|
|
1792
|
+
if (!schema) throw new A2Error("UNKNOWN_EVENT_TYPE", `contract '${name}' has no event type '${String(e.type)}'`);
|
|
1793
|
+
const result = validateSync(schema, e.payload, `event '${e.type}'`);
|
|
1794
|
+
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid payload for event '${e.type}' on machine '${name}'`, { details: result.issues });
|
|
1795
|
+
const validated = {
|
|
1796
|
+
type: e.type,
|
|
1797
|
+
payload: result.value
|
|
1798
|
+
};
|
|
1799
|
+
if (e.id !== void 0) validated.id = e.id;
|
|
1800
|
+
if (!resolveDispatchMetadata) return validated;
|
|
1801
|
+
const targets = [];
|
|
1802
|
+
for (const registered of registeredConsumers.values()) {
|
|
1803
|
+
if (!registered.laneOf || !takes(registered, e.type)) continue;
|
|
1804
|
+
targets.push({
|
|
1805
|
+
consumer: registered.name,
|
|
1806
|
+
lane: registered.laneOf(sessionId, validated)
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
if (targets.length > 0) validated.targets = targets;
|
|
1810
|
+
return validated;
|
|
1811
|
+
});
|
|
1812
|
+
};
|
|
1813
|
+
/**
|
|
1814
|
+
* Validate + write. `onAppended` runs inside the telemetry span, so
|
|
1815
|
+
* the drain it schedules is created in the append's active context —
|
|
1816
|
+
* with a real tracer, causal work remains connected.
|
|
1817
|
+
*/
|
|
1818
|
+
const appendCore = (sessionId, events, source, mode, onAppended, cause, generatedIds, requireDurableInitialArm = false, originIndex) => telemetry.span("a2.append", {
|
|
1819
|
+
"a2.contract": name,
|
|
1820
|
+
"a2.session_id": sessionId,
|
|
1821
|
+
"a2.append.source": source,
|
|
1822
|
+
"a2.append.mode": mode,
|
|
1823
|
+
"a2.append.types": events.map((e) => String(e.type)).join(","),
|
|
1824
|
+
"a2.append.count": events.length
|
|
1825
|
+
}, async (span) => {
|
|
1826
|
+
if (mode === "dispatch" && !scheduler) throw new TypeError("append.dispatch requires a configured scheduler adapter");
|
|
1827
|
+
assertSessionId(sessionId);
|
|
1828
|
+
const validated = validateEvents(sessionId, events);
|
|
1829
|
+
if (cause) for (const [index, event] of validated.entries()) event.cause = generatedIds?.[index] ? {
|
|
1830
|
+
...cause,
|
|
1831
|
+
batchSize: events.length
|
|
1832
|
+
} : cause;
|
|
1833
|
+
const store = await resolveStore();
|
|
1834
|
+
let rows;
|
|
1835
|
+
let shouldHealSession;
|
|
1836
|
+
try {
|
|
1837
|
+
const result = await store.append(nsId(sessionId), validated, {
|
|
1838
|
+
consumers: storeConsumers,
|
|
1839
|
+
...originIndex === void 0 ? {} : { originIndex }
|
|
1840
|
+
});
|
|
1841
|
+
rows = result.events;
|
|
1842
|
+
shouldHealSession = result.hasPending && !(source === "consumer" && !validated.some(startsWork));
|
|
1843
|
+
} catch (err) {
|
|
1844
|
+
throw asStoreUnavailable(err);
|
|
1845
|
+
}
|
|
1846
|
+
const appended = rows.map(toPublic);
|
|
1847
|
+
const initialWatchdogAt = shouldHealSession && scheduler && source === "external" && mode === "inline" ? schedulerSlot(nextClaimWindow().watchdogAtMs) : void 0;
|
|
1848
|
+
const initialArm = initialWatchdogAt !== void 0 ? startSchedulerArm(sessionId, initialWatchdogAt) : null;
|
|
1849
|
+
const dispatchArm = shouldHealSession && mode === "dispatch" ? startSchedulerArm(sessionId, currentSchedulerSlot(Date.now())) : null;
|
|
1850
|
+
if (shouldHealSession && mode === "inline") onAppended(appended, initialWatchdogAt);
|
|
1851
|
+
if (dispatchArm) await dispatchArm;
|
|
1852
|
+
if (initialArm) try {
|
|
1853
|
+
await joinSchedulerArm(initialArm);
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
span.setAttribute("a2.append.armed", false);
|
|
1856
|
+
span.recordError(err);
|
|
1857
|
+
if (requireDurableInitialArm) throw err;
|
|
1858
|
+
}
|
|
1859
|
+
return appended;
|
|
1860
|
+
});
|
|
1861
|
+
const readHistory = async (sessionId, bounds) => {
|
|
1862
|
+
const gte = bounds?.gte;
|
|
1863
|
+
const lte = bounds?.lte;
|
|
1864
|
+
assertHistoryIndex("gte", gte);
|
|
1865
|
+
assertHistoryIndex("lte", lte);
|
|
1866
|
+
if (gte !== void 0 && lte !== void 0 && gte > lte) throw new RangeError("history.gte must be less than or equal to history.lte");
|
|
1867
|
+
if (lte === 0) return [];
|
|
1868
|
+
const store = await resolveStore();
|
|
1869
|
+
try {
|
|
1870
|
+
const readOptions = gte === void 0 && lte === void 0 ? void 0 : {
|
|
1871
|
+
...gte === void 0 ? {} : { afterIndex: Math.max(0, gte - 1) },
|
|
1872
|
+
...lte === void 0 ? {} : { throughIndex: lte }
|
|
1873
|
+
};
|
|
1874
|
+
return (await (store.readHistory ? store.readHistory(nsId(sessionId), readOptions) : store.read(nsId(sessionId), readOptions))).filter((row) => (gte === void 0 || row.index >= gte) && (lte === void 0 || row.index <= lte));
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
throw asStoreUnavailable(err);
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
const readStoreState = async (store, sessionId, reducerName, throughIndex, snapshotThroughIndex) => {
|
|
1880
|
+
try {
|
|
1881
|
+
return await store.readState(nsId(sessionId), reducerName, throughIndex === void 0 && snapshotThroughIndex === void 0 ? void 0 : {
|
|
1882
|
+
...throughIndex === void 0 ? {} : { throughIndex },
|
|
1883
|
+
...snapshotThroughIndex === void 0 ? {} : { snapshotThroughIndex }
|
|
1884
|
+
});
|
|
1885
|
+
} catch (err) {
|
|
1886
|
+
throw asStoreUnavailable(err);
|
|
1887
|
+
}
|
|
1888
|
+
};
|
|
1889
|
+
const planStateReads = (batch) => {
|
|
1890
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
1891
|
+
for (const entry of batch) {
|
|
1892
|
+
const entries = bySession.get(entry.sessionId);
|
|
1893
|
+
if (entries) entries.push(entry);
|
|
1894
|
+
else bySession.set(entry.sessionId, [entry]);
|
|
1895
|
+
}
|
|
1896
|
+
const plans = [];
|
|
1897
|
+
for (const [sessionId, entries] of bySession) {
|
|
1898
|
+
const boundaries = entries.flatMap((entry) => entry.throughIndex === void 0 ? [] : [entry.throughIndex]);
|
|
1899
|
+
const plan = {
|
|
1900
|
+
sessionId,
|
|
1901
|
+
entries
|
|
1902
|
+
};
|
|
1903
|
+
if (boundaries.length === entries.length) plan.throughIndex = Math.max(...boundaries);
|
|
1904
|
+
if (boundaries.length > 0) plan.snapshotThroughIndex = Math.min(...boundaries);
|
|
1905
|
+
plans.push(plan);
|
|
1906
|
+
}
|
|
1907
|
+
return plans;
|
|
1908
|
+
};
|
|
1909
|
+
const persistSnapshots = ({ store, reducer, sessionId, snapshots }) => {
|
|
1910
|
+
const persistence = Promise.resolve().then(() => telemetry.span("a2.snapshot", {
|
|
1911
|
+
"a2.contract": name,
|
|
1912
|
+
"a2.session_id": sessionId,
|
|
1913
|
+
"a2.snapshot.reducer": reducer.name,
|
|
1914
|
+
"a2.snapshot.index": snapshots.at(-1).index,
|
|
1915
|
+
"a2.snapshot.count": snapshots.length
|
|
1916
|
+
}, async (span) => {
|
|
1917
|
+
try {
|
|
1918
|
+
await store.putSnapshots(nsId(sessionId), reducer.name, snapshots);
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
try {
|
|
1921
|
+
span.recordError(error);
|
|
1922
|
+
} catch (telemetryError) {
|
|
1923
|
+
throw new AggregateError([error, telemetryError], "Snapshot write and error reporting failed", { cause: telemetryError });
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
})).catch((error) => {
|
|
1927
|
+
console.error("[a2] error in a2.snapshot telemetry", {
|
|
1928
|
+
sessionId,
|
|
1929
|
+
reducer: reducer.name,
|
|
1930
|
+
index: snapshots.at(-1).index
|
|
1931
|
+
}, error);
|
|
1932
|
+
});
|
|
1933
|
+
track(persistence);
|
|
1934
|
+
platformWaitUntil(persistence);
|
|
1935
|
+
};
|
|
1936
|
+
const stateCache = boundedCache(STATE_CACHE);
|
|
1937
|
+
const cacheState = (key, next) => {
|
|
1938
|
+
const current = stateCache.get(key);
|
|
1939
|
+
if (current?.reducer === next.reducer && (current.index > next.index || current.index === next.index && current.persistedIndex >= next.persistedIndex)) return;
|
|
1940
|
+
stateCache.set(key, next, next.bytes);
|
|
1941
|
+
};
|
|
1942
|
+
const writeSnapshots = ({ store, reducer, sessionId, writes }) => {
|
|
1943
|
+
const snapshots = [...writes].toSorted(([a], [b]) => a - b).map(([index, write]) => {
|
|
1944
|
+
const snapshot = {
|
|
1945
|
+
index,
|
|
1946
|
+
state: encodeState({
|
|
1947
|
+
reducer,
|
|
1948
|
+
state: write.state
|
|
1949
|
+
})
|
|
1950
|
+
};
|
|
1951
|
+
if (write.pins.size > 0) snapshot.pins = [...write.pins.values()];
|
|
1952
|
+
return snapshot;
|
|
1953
|
+
});
|
|
1954
|
+
if (snapshots.length > 0) persistSnapshots({
|
|
1955
|
+
store,
|
|
1956
|
+
reducer,
|
|
1957
|
+
sessionId,
|
|
1958
|
+
snapshots
|
|
1959
|
+
});
|
|
1960
|
+
return snapshots;
|
|
1961
|
+
};
|
|
1962
|
+
/**
|
|
1963
|
+
* Folds `rows` from `position` in bound order, resolving each entry with a
|
|
1964
|
+
* clone once the rows through its bound are folded. `visit` sees each
|
|
1965
|
+
* entry's state before later rows are folded into it.
|
|
1966
|
+
*/
|
|
1967
|
+
const foldEntries = ({ reducer, entries, rows, position, state, index, clearIndex, snapshot, visit }) => {
|
|
1968
|
+
const start = position;
|
|
1969
|
+
const ordered = entries.toSorted((a, b) => (a.throughIndex ?? Number.POSITIVE_INFINITY) - (b.throughIndex ?? Number.POSITIVE_INFINITY));
|
|
1970
|
+
for (const entry of ordered) {
|
|
1971
|
+
const target = entry.throughIndex ?? Number.POSITIVE_INFINITY;
|
|
1972
|
+
while (position < rows.length && rows[position].index <= target) {
|
|
1973
|
+
const row = rows[position];
|
|
1974
|
+
state = foldEvent(reducer, state, toPublic(row));
|
|
1975
|
+
index = row.index;
|
|
1976
|
+
position += 1;
|
|
1977
|
+
}
|
|
1978
|
+
if (entry.triggerIndex !== void 0 && entry.triggerIndex <= clearIndex || entry.throughIndex !== void 0 && entry.throughIndex > 0 && entry.throughIndex < clearIndex) {
|
|
1979
|
+
entry.reject(sessionClearedError(`session '${entry.sessionId}' was cleared at index ${clearIndex}`));
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
entry.span.setAttribute("a2.state.snapshot", snapshot);
|
|
1983
|
+
entry.span.setAttribute("a2.state.folded", position - start);
|
|
1984
|
+
entry.span.setAttribute("a2.state.index", index);
|
|
1985
|
+
visit(entry, state, index, position - start);
|
|
1986
|
+
entry.resolve({
|
|
1987
|
+
state: cloneInitial(state),
|
|
1988
|
+
index,
|
|
1989
|
+
...clearIndex > 0 && index >= clearIndex ? { clearIndex } : {}
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
return {
|
|
1993
|
+
state,
|
|
1994
|
+
index,
|
|
1995
|
+
position
|
|
1996
|
+
};
|
|
1997
|
+
};
|
|
1998
|
+
/**
|
|
1999
|
+
* Events from `fromIndex` (inclusive) through `throughIndex` or the newest
|
|
2000
|
+
* event, or null when the tail is too long to be worth folding in memory.
|
|
2001
|
+
*/
|
|
2002
|
+
const readCachedTail = async (store, sessionId, fromIndex, throughIndex) => {
|
|
2003
|
+
const readTail = stateTailReaders.get(store);
|
|
2004
|
+
if (readTail) try {
|
|
2005
|
+
return await readTail(nsId(sessionId), fromIndex, throughIndex);
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
throw asStoreUnavailable(err);
|
|
2008
|
+
}
|
|
2009
|
+
const limit = fromIndex + STATE_CACHE.maxTail;
|
|
2010
|
+
const capped = throughIndex === void 0 || throughIndex > limit;
|
|
2011
|
+
const rows = await readHistory(sessionId, {
|
|
2012
|
+
gte: fromIndex,
|
|
2013
|
+
lte: capped ? limit : throughIndex
|
|
2014
|
+
});
|
|
2015
|
+
return capped && rows.at(-1)?.index === limit ? null : rows;
|
|
2016
|
+
};
|
|
2017
|
+
/**
|
|
2018
|
+
* Serves a plan from the cached state when every read is at or past the
|
|
2019
|
+
* cached index. A read bounded at the cached index by an event whose id
|
|
2020
|
+
* matches the cached one (a consumer's own trigger) needs no store request;
|
|
2021
|
+
* any other read fetches the tail from the cached event onward and checks
|
|
2022
|
+
* that event's id. Returns false when the store must answer instead.
|
|
2023
|
+
*/
|
|
2024
|
+
const foldCachedPlan = async (store, reducer, plan) => {
|
|
2025
|
+
const key = stateCacheKey(plan.sessionId, reducer.name);
|
|
2026
|
+
const cached = stateCache.get(key);
|
|
2027
|
+
if (cached?.reducer !== reducer) return false;
|
|
2028
|
+
if (plan.entries.some((entry) => entry.throughIndex !== void 0 && entry.throughIndex < cached.index)) return false;
|
|
2029
|
+
let rows = [];
|
|
2030
|
+
if (plan.entries.some((entry) => entry.throughIndex !== cached.index || entry.throughEventId !== cached.lastId)) {
|
|
2031
|
+
const tail = await readCachedTail(store, plan.sessionId, cached.index, plan.throughIndex);
|
|
2032
|
+
if (!tail) return false;
|
|
2033
|
+
if (tail[0]?.index !== cached.index || tail[0].id !== cached.lastId || tail.some((row, position) => position > 0 && isSessionCleared(row))) {
|
|
2034
|
+
stateCache.delete(key);
|
|
2035
|
+
return false;
|
|
2036
|
+
}
|
|
2037
|
+
rows = tail;
|
|
2038
|
+
}
|
|
2039
|
+
const writes = /* @__PURE__ */ new Map();
|
|
2040
|
+
const folded = foldEntries({
|
|
2041
|
+
reducer,
|
|
2042
|
+
entries: plan.entries,
|
|
2043
|
+
rows,
|
|
2044
|
+
position: 1,
|
|
2045
|
+
state: rows.length > 1 ? cloneInitial(cached.state) : cached.state,
|
|
2046
|
+
index: cached.index,
|
|
2047
|
+
clearIndex: cached.clearIndex,
|
|
2048
|
+
snapshot: "memory",
|
|
2049
|
+
visit: (entry, state, index) => {
|
|
2050
|
+
if (entry.pin !== void 0) addSnapshotWrite(writes, index, state, entry.pin);
|
|
2051
|
+
}
|
|
2052
|
+
});
|
|
2053
|
+
const added = Math.max(0, folded.position - 1);
|
|
2054
|
+
const rewritesHead = folded.index - cached.persistedIndex >= STATE_CACHE.snapshotEvery;
|
|
2055
|
+
if (rewritesHead && !writes.has(folded.index)) writes.set(folded.index, {
|
|
2056
|
+
state: folded.state,
|
|
2057
|
+
pins: /* @__PURE__ */ new Map()
|
|
2058
|
+
});
|
|
2059
|
+
const snapshots = writeSnapshots({
|
|
2060
|
+
store,
|
|
2061
|
+
reducer,
|
|
2062
|
+
sessionId: plan.sessionId,
|
|
2063
|
+
writes
|
|
2064
|
+
});
|
|
2065
|
+
const measure = rewritesHead || cached.unmeasured + added >= STATE_CACHE.measureEvery;
|
|
2066
|
+
let grown = 0;
|
|
2067
|
+
if (!measure) for (let position = 1; position < folded.position; position += 1) grown += JSON.stringify(rows[position].payload)?.length ?? 0;
|
|
2068
|
+
cacheState(key, {
|
|
2069
|
+
reducer,
|
|
2070
|
+
index: folded.index,
|
|
2071
|
+
lastId: added > 0 ? rows[folded.position - 1].id : cached.lastId,
|
|
2072
|
+
state: folded.state,
|
|
2073
|
+
bytes: measure ? measuredBytes(reducer, folded.state, folded.index, snapshots) : cached.bytes + grown,
|
|
2074
|
+
unmeasured: measure ? 0 : cached.unmeasured + added,
|
|
2075
|
+
persistedIndex: Math.max(cached.persistedIndex, snapshots.at(-1)?.index ?? 0),
|
|
2076
|
+
clearIndex: cached.clearIndex
|
|
2077
|
+
});
|
|
2078
|
+
return true;
|
|
2079
|
+
};
|
|
2080
|
+
const foldStatePlan = async (store, reducer, plan, stateRead) => {
|
|
2081
|
+
let state = cloneInitial(reducer.initialState);
|
|
2082
|
+
let index = 0;
|
|
2083
|
+
let snapshotOutcome = "miss";
|
|
2084
|
+
const snap = stateRead.snapshot;
|
|
2085
|
+
let rows = stateRead.events;
|
|
2086
|
+
let clearIndex = stateRead.clearIndex ?? 0;
|
|
2087
|
+
if (snap) {
|
|
2088
|
+
let decoded;
|
|
2089
|
+
try {
|
|
2090
|
+
decoded = decodeState({
|
|
2091
|
+
reducer,
|
|
2092
|
+
state: snap.state
|
|
2093
|
+
});
|
|
2094
|
+
} catch {
|
|
2095
|
+
snapshotOutcome = "rejected";
|
|
2096
|
+
}
|
|
2097
|
+
if (snapshotOutcome !== "rejected" && reducer.stateSchema) {
|
|
2098
|
+
const result = validateSync(reducer.stateSchema, decoded, "the stateSchema");
|
|
2099
|
+
if (result.issues) snapshotOutcome = "rejected";
|
|
2100
|
+
else {
|
|
2101
|
+
state = result.value;
|
|
2102
|
+
index = snap.index;
|
|
2103
|
+
snapshotOutcome = "hit";
|
|
2104
|
+
}
|
|
2105
|
+
} else if (snapshotOutcome !== "rejected") {
|
|
2106
|
+
state = decoded;
|
|
2107
|
+
index = snap.index;
|
|
2108
|
+
snapshotOutcome = "hit";
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (snapshotOutcome === "rejected") {
|
|
2112
|
+
try {
|
|
2113
|
+
rows = await store.read(nsId(plan.sessionId), plan.throughIndex === void 0 ? void 0 : { throughIndex: plan.throughIndex });
|
|
2114
|
+
} catch (err) {
|
|
2115
|
+
throw asStoreUnavailable(err);
|
|
2116
|
+
}
|
|
2117
|
+
if (rows[0] && isSessionCleared(rows[0])) clearIndex = Math.max(clearIndex, rows[0].index);
|
|
2118
|
+
}
|
|
2119
|
+
const writes = /* @__PURE__ */ new Map();
|
|
2120
|
+
const folded = foldEntries({
|
|
2121
|
+
reducer,
|
|
2122
|
+
entries: plan.entries,
|
|
2123
|
+
rows,
|
|
2124
|
+
position: 0,
|
|
2125
|
+
state,
|
|
2126
|
+
index,
|
|
2127
|
+
clearIndex,
|
|
2128
|
+
snapshot: snapshotOutcome,
|
|
2129
|
+
visit: (entry, entryState, entryIndex, count) => {
|
|
2130
|
+
if (entryIndex === 0) return;
|
|
2131
|
+
if (count === 0 && entry.pin === void 0) return;
|
|
2132
|
+
const advancesHead = stateRead.headIndex === null || entryIndex > stateRead.headIndex;
|
|
2133
|
+
const repairsHead = snapshotOutcome === "rejected" && entryIndex === stateRead.headIndex;
|
|
2134
|
+
if (!advancesHead && !repairsHead && entry.pin === void 0) return;
|
|
2135
|
+
addSnapshotWrite(writes, entryIndex, entryState, entry.pin);
|
|
2136
|
+
}
|
|
2137
|
+
});
|
|
2138
|
+
const snapshots = writeSnapshots({
|
|
2139
|
+
store,
|
|
2140
|
+
reducer,
|
|
2141
|
+
sessionId: plan.sessionId,
|
|
2142
|
+
writes
|
|
2143
|
+
});
|
|
2144
|
+
if (folded.position > 0 && STATE_CACHE.maxEntries > 0) cacheState(stateCacheKey(plan.sessionId, reducer.name), {
|
|
2145
|
+
reducer,
|
|
2146
|
+
index: folded.index,
|
|
2147
|
+
lastId: rows[folded.position - 1].id,
|
|
2148
|
+
state: folded.state,
|
|
2149
|
+
bytes: measuredBytes(reducer, folded.state, folded.index, snapshots),
|
|
2150
|
+
unmeasured: 0,
|
|
2151
|
+
persistedIndex: Math.min(Math.max(stateRead.headIndex ?? 0, ...writes.keys()), folded.index),
|
|
2152
|
+
clearIndex
|
|
2153
|
+
});
|
|
2154
|
+
};
|
|
2155
|
+
const pendingStateReads = /* @__PURE__ */ new Map();
|
|
2156
|
+
const flushStateReads = async (store, reducer, batch) => {
|
|
2157
|
+
const plans = [];
|
|
2158
|
+
await Promise.all(planStateReads(batch).map(async (plan) => {
|
|
2159
|
+
try {
|
|
2160
|
+
if (!await foldCachedPlan(store, reducer, plan)) plans.push(plan);
|
|
2161
|
+
} catch (error) {
|
|
2162
|
+
for (const entry of plan.entries) entry.reject(error);
|
|
2163
|
+
}
|
|
2164
|
+
}));
|
|
2165
|
+
if (plans.length === 0) return;
|
|
2166
|
+
let reads;
|
|
2167
|
+
try {
|
|
2168
|
+
reads = store.readStates ? await store.readStates(plans.map((plan) => ({
|
|
2169
|
+
sessionId: nsId(plan.sessionId),
|
|
2170
|
+
...plan.throughIndex === void 0 ? {} : { throughIndex: plan.throughIndex },
|
|
2171
|
+
...plan.snapshotThroughIndex === void 0 ? {} : { snapshotThroughIndex: plan.snapshotThroughIndex }
|
|
2172
|
+
})), reducer.name) : plans.map((plan) => readStoreState(store, plan.sessionId, reducer.name, plan.throughIndex, plan.snapshotThroughIndex));
|
|
2173
|
+
if (reads.length !== plans.length) throw new TypeError("store.readStates must return one result per request");
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
const failure = asStoreUnavailable(error);
|
|
2176
|
+
for (const plan of plans) for (const entry of plan.entries) entry.reject(failure);
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
await Promise.all(plans.map(async (plan, position) => {
|
|
2180
|
+
try {
|
|
2181
|
+
await foldStatePlan(store, reducer, plan, await reads[position]);
|
|
2182
|
+
} catch (error) {
|
|
2183
|
+
for (const entry of plan.entries) entry.reject(error);
|
|
2184
|
+
}
|
|
2185
|
+
}));
|
|
2186
|
+
};
|
|
2187
|
+
const enqueueStateRead = (store, sessionId, reducer, bounds, span) => new Promise((resolve, reject) => {
|
|
2188
|
+
const reducerName = reducer.name;
|
|
2189
|
+
let batch = pendingStateReads.get(reducerName);
|
|
2190
|
+
if (!batch) {
|
|
2191
|
+
const opened = [];
|
|
2192
|
+
pendingStateReads.set(reducerName, opened);
|
|
2193
|
+
queueMicrotask(() => {
|
|
2194
|
+
pendingStateReads.delete(reducerName);
|
|
2195
|
+
flushStateReads(store, reducer, opened);
|
|
2196
|
+
});
|
|
2197
|
+
batch = opened;
|
|
2198
|
+
}
|
|
2199
|
+
batch.push({
|
|
2200
|
+
sessionId,
|
|
2201
|
+
...bounds,
|
|
2202
|
+
span,
|
|
2203
|
+
resolve,
|
|
2204
|
+
reject
|
|
2205
|
+
});
|
|
2206
|
+
});
|
|
2207
|
+
const readState = async (sessionId, reducer, bounds) => {
|
|
2208
|
+
const store = await resolveStore();
|
|
2209
|
+
return telemetry.span("a2.state", {
|
|
2210
|
+
"a2.contract": name,
|
|
2211
|
+
"a2.session_id": sessionId,
|
|
2212
|
+
"a2.state.reducer": reducer.name
|
|
2213
|
+
}, (span) => enqueueStateRead(store, sessionId, reducer, bounds, span));
|
|
2214
|
+
};
|
|
2215
|
+
const makeSchedule = (sessionId, trigger) => {
|
|
2216
|
+
const triggerEventId = trigger?.id ?? null;
|
|
2217
|
+
/** Timers a consumer sets key on its name, except A2's own. */
|
|
2218
|
+
const triggerConsumer = trigger && !registeredConsumers.get(trigger.consumer)?.anonymousIds ? trigger.consumer : void 0;
|
|
2219
|
+
const triggerCreatedAt = trigger?.createdAt.getTime();
|
|
2220
|
+
const clearIndex = trigger?.clearIndex ?? 0;
|
|
2221
|
+
const schedule = async (scheduleName, timing, ...events) => {
|
|
2222
|
+
if (!scheduler) throw new TypeError("session.schedule requires a configured scheduler adapter");
|
|
2223
|
+
assertScheduleName(scheduleName);
|
|
2224
|
+
if (events.length === 0) throw new TypeError("schedule requires at least one event");
|
|
2225
|
+
const dueAt = scheduleDueAt(timing, triggerCreatedAt ?? Date.now());
|
|
2226
|
+
const payloads = events.map((event) => cloneScheduledPayload(event.payload));
|
|
2227
|
+
const snapshottedEvents = events.map((event, index) => event.id === void 0 ? {
|
|
2228
|
+
type: event.type,
|
|
2229
|
+
payload: payloads[index]
|
|
2230
|
+
} : {
|
|
2231
|
+
type: event.type,
|
|
2232
|
+
payload: payloads[index],
|
|
2233
|
+
id: event.id
|
|
2234
|
+
});
|
|
2235
|
+
const validated = validateEvents(sessionId, structuredClone(snapshottedEvents), false);
|
|
2236
|
+
const scheduleId = await deterministicScheduleId(name, sessionId, triggerEventId, scheduleName, clearIndex, triggerConsumer);
|
|
2237
|
+
const scheduledEvents = await Promise.all(validated.map(async (event, item) => ({
|
|
2238
|
+
id: event.id ?? await deterministicScheduledEventId(scheduleId, item),
|
|
2239
|
+
type: event.type,
|
|
2240
|
+
payload: payloads[item]
|
|
2241
|
+
})));
|
|
2242
|
+
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");
|
|
2243
|
+
try {
|
|
2244
|
+
await scheduler.schedule({
|
|
2245
|
+
version: 1,
|
|
2246
|
+
kind: "append",
|
|
2247
|
+
id: scheduleId,
|
|
2248
|
+
contract: name,
|
|
2249
|
+
sessionId,
|
|
2250
|
+
dueAt,
|
|
2251
|
+
events: scheduledEvents,
|
|
2252
|
+
...trigger ? { originIndex: trigger.index } : {}
|
|
2253
|
+
});
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
throw markSchedulerSendFailure(error);
|
|
2256
|
+
}
|
|
2257
|
+
};
|
|
2258
|
+
const clear = async (scheduleName, clearOptions) => {
|
|
2259
|
+
if (!scheduler) throw new TypeError("session.schedule.clear requires a configured scheduler adapter");
|
|
2260
|
+
assertScheduleName(scheduleName);
|
|
2261
|
+
if (clearOptions === null || typeof clearOptions !== "object") throw new TypeError("schedule.clear requires an ifLastIndex");
|
|
2262
|
+
const { ifLastIndex, ...timing } = clearOptions;
|
|
2263
|
+
assertClearIndex(ifLastIndex);
|
|
2264
|
+
const dueAt = scheduleDueAt(timing, triggerCreatedAt ?? Date.now());
|
|
2265
|
+
const store = await resolveStore();
|
|
2266
|
+
if (!store.clear || !store.reclaim) throw new TypeError("session.schedule.clear requires a store that supports clearing");
|
|
2267
|
+
const id = await deterministicScheduleClearId({
|
|
2268
|
+
contract: name,
|
|
2269
|
+
sessionId,
|
|
2270
|
+
triggerEventId,
|
|
2271
|
+
clearIndex,
|
|
2272
|
+
name: scheduleName,
|
|
2273
|
+
ifLastIndex,
|
|
2274
|
+
...triggerConsumer === void 0 ? {} : { consumer: triggerConsumer }
|
|
2275
|
+
});
|
|
2276
|
+
try {
|
|
2277
|
+
await scheduler.schedule({
|
|
2278
|
+
version: 1,
|
|
2279
|
+
kind: "clear",
|
|
2280
|
+
id,
|
|
2281
|
+
contract: name,
|
|
2282
|
+
sessionId,
|
|
2283
|
+
dueAt,
|
|
2284
|
+
ifLastIndex
|
|
2285
|
+
});
|
|
2286
|
+
} catch (error) {
|
|
2287
|
+
throw markSchedulerSendFailure(error);
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
return Object.assign(schedule, { clear });
|
|
2291
|
+
};
|
|
2292
|
+
const toSessionEvent = (id, event) => ({
|
|
2293
|
+
id: event.id,
|
|
2294
|
+
type: event.type,
|
|
2295
|
+
payload: event.payload,
|
|
2296
|
+
index: event.index,
|
|
2297
|
+
sessionId: id,
|
|
2298
|
+
createdAt: event.createdAt
|
|
2299
|
+
});
|
|
2300
|
+
const streamEvents = (id, startAfter) => {
|
|
2301
|
+
let closed = false;
|
|
2302
|
+
let inner;
|
|
2303
|
+
let stopActivity;
|
|
2304
|
+
const activity = createStreamActivity(() => {
|
|
2305
|
+
if (inner && !closed) streamActivities.get(inner)?.activate();
|
|
2306
|
+
});
|
|
2307
|
+
let closing;
|
|
2308
|
+
const close = () => {
|
|
2309
|
+
closed = true;
|
|
2310
|
+
closing ??= (async () => {
|
|
2311
|
+
stopActivity?.();
|
|
2312
|
+
await inner?.return?.();
|
|
2313
|
+
})();
|
|
2314
|
+
return closing;
|
|
2315
|
+
};
|
|
2316
|
+
const outer = async function* () {
|
|
2317
|
+
const store = await resolveStore();
|
|
2318
|
+
if (closed) return;
|
|
2319
|
+
inner = store.stream(nsId(id), { startAfter })[Symbol.asyncIterator]();
|
|
2320
|
+
const sourceActivity = streamActivities.get(inner);
|
|
2321
|
+
if (sourceActivity) {
|
|
2322
|
+
stopActivity = sourceActivity.subscribe(activity.setIdle);
|
|
2323
|
+
activity.setIdle(sourceActivity.idle);
|
|
2324
|
+
}
|
|
2325
|
+
try {
|
|
2326
|
+
for (;;) {
|
|
2327
|
+
if (closed) return;
|
|
2328
|
+
const result = await inner.next();
|
|
2329
|
+
if (closed || result.done) return;
|
|
2330
|
+
yield toSessionEvent(id, result.value);
|
|
2331
|
+
}
|
|
2332
|
+
} finally {
|
|
2333
|
+
await close();
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
const iterator = outer();
|
|
2337
|
+
return { [Symbol.asyncIterator]() {
|
|
2338
|
+
const stream = {
|
|
2339
|
+
next: () => iterator.next(),
|
|
2340
|
+
async return() {
|
|
2341
|
+
await close();
|
|
2342
|
+
return iterator.return(void 0);
|
|
2343
|
+
},
|
|
2344
|
+
async throw(error) {
|
|
2345
|
+
await close();
|
|
2346
|
+
return iterator.throw(error);
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
streamActivities.set(stream, activity);
|
|
2350
|
+
bufferedEvents.set(stream, () => {
|
|
2351
|
+
const event = closed || !inner ? void 0 : takeBuffered(inner);
|
|
2352
|
+
if (!event) return void 0;
|
|
2353
|
+
if (!isSerializedEvent(event)) return toSessionEvent(id, event);
|
|
2354
|
+
event.sessionId = id;
|
|
2355
|
+
return event;
|
|
2356
|
+
});
|
|
2357
|
+
return stream;
|
|
2358
|
+
} };
|
|
2359
|
+
};
|
|
2360
|
+
const requirePresence = (store) => {
|
|
2361
|
+
if (!store.presence) throw presenceNotSupported();
|
|
2362
|
+
return store.presence;
|
|
2363
|
+
};
|
|
2364
|
+
const setPresence = async (sessionId, patch, fallbackSeen) => {
|
|
2365
|
+
if (typeof patch.participant !== "string" || patch.participant.length === 0) throw new TypeError("presence participant must be a non-empty string");
|
|
2366
|
+
if (RESERVED_PARTICIPANT_IDS.has(patch.participant)) throw new TypeError(`presence participant must not be '${patch.participant}'`);
|
|
2367
|
+
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");
|
|
2368
|
+
const validated = nullProtoRecord();
|
|
2369
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
2370
|
+
if (value === void 0) continue;
|
|
2371
|
+
const schema = Object.hasOwn(presenceDefs, field) ? presenceDefs[field] : RESERVED_PARTICIPANT_IDS.has(field) ? void 0 : presenceDefs["*"];
|
|
2372
|
+
if (!schema) throw new A2Error("UNKNOWN_PRESENCE_FIELD", `contract '${name}' has no presence field '${field}'`);
|
|
2373
|
+
if (value === null) {
|
|
2374
|
+
validated[field] = null;
|
|
2375
|
+
continue;
|
|
2376
|
+
}
|
|
2377
|
+
const result = validateSync(schema, value, `presence field '${field}'`);
|
|
2378
|
+
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid value for presence field '${field}' on machine '${name}'`, { details: result.issues });
|
|
2379
|
+
validated[field] = result.value;
|
|
2380
|
+
}
|
|
2381
|
+
const store = await resolveStore();
|
|
2382
|
+
try {
|
|
2383
|
+
await requirePresence(store).set(nsId(sessionId), patch.participant, validated, {
|
|
2384
|
+
seen: patch.seen ?? fallbackSeen,
|
|
2385
|
+
at: new Date(patch.at ?? Date.now()),
|
|
2386
|
+
ttlMs: presenceTtlMs
|
|
2387
|
+
});
|
|
2388
|
+
} catch (err) {
|
|
2389
|
+
throw asStoreUnavailable(err);
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
const readPresenceMap = async (sessionId) => {
|
|
2393
|
+
const store = await resolveStore();
|
|
2394
|
+
let rows;
|
|
2395
|
+
try {
|
|
2396
|
+
rows = await requirePresence(store).read(nsId(sessionId));
|
|
2397
|
+
} catch (err) {
|
|
2398
|
+
throw asStoreUnavailable(err);
|
|
2399
|
+
}
|
|
2400
|
+
return foldPresenceRows(rows);
|
|
2401
|
+
};
|
|
2402
|
+
/**
|
|
2403
|
+
* The two-plane feed. Patches ride `subscribe` when the adapter has
|
|
2404
|
+
* the push tier; without it the map is re-read and diffed whenever
|
|
2405
|
+
* the event feed wakes, and at the poll idle ceiling while it is
|
|
2406
|
+
* silent — degraded means later, not lost while watched.
|
|
2407
|
+
*/
|
|
2408
|
+
const streamWithPresence = (id, startAfter) => {
|
|
2409
|
+
const ns = nsId(id);
|
|
2410
|
+
let closed = false;
|
|
2411
|
+
let events;
|
|
2412
|
+
let pendingEvent = null;
|
|
2413
|
+
let unsubscribe;
|
|
2414
|
+
let sleeper = null;
|
|
2415
|
+
let wakeQueue = null;
|
|
2416
|
+
let presenceInterval = Math.min(POLL_TIMINGS.activeFloorMs, POLL_TIMINGS.idleCeilingMs);
|
|
2417
|
+
let presenceActivityVersion = 0;
|
|
2418
|
+
let stopActivity;
|
|
2419
|
+
const activity = createStreamActivity(() => {
|
|
2420
|
+
if (closed) return;
|
|
2421
|
+
presenceActivityVersion += 1;
|
|
2422
|
+
presenceInterval = Math.min(POLL_TIMINGS.activeFloorMs, POLL_TIMINGS.idleCeilingMs);
|
|
2423
|
+
sleeper?.cancel();
|
|
2424
|
+
if (events) streamActivities.get(events)?.activate();
|
|
2425
|
+
});
|
|
2426
|
+
let closing;
|
|
2427
|
+
const close = () => {
|
|
2428
|
+
closed = true;
|
|
2429
|
+
sleeper?.cancel();
|
|
2430
|
+
wakeQueue?.();
|
|
2431
|
+
closing ??= (async () => {
|
|
2432
|
+
try {
|
|
2433
|
+
stopActivity?.();
|
|
2434
|
+
unsubscribe?.();
|
|
2435
|
+
} finally {
|
|
2436
|
+
await events?.return?.();
|
|
2437
|
+
}
|
|
2438
|
+
})();
|
|
2439
|
+
return closing;
|
|
2440
|
+
};
|
|
2441
|
+
const outer = async function* () {
|
|
2442
|
+
const store = await resolveStore();
|
|
2443
|
+
if (closed) return;
|
|
2444
|
+
const api = requirePresence(store);
|
|
2445
|
+
const readRows = async () => {
|
|
2446
|
+
try {
|
|
2447
|
+
return await api.read(ns);
|
|
2448
|
+
} catch (err) {
|
|
2449
|
+
throw asStoreUnavailable(err);
|
|
2450
|
+
}
|
|
2451
|
+
};
|
|
2452
|
+
const painted = /* @__PURE__ */ new Map();
|
|
2453
|
+
const paintedDeletes = presenceTombstones();
|
|
2454
|
+
const diffRows = (rows) => {
|
|
2455
|
+
const patches = [];
|
|
2456
|
+
const live = /* @__PURE__ */ new Map();
|
|
2457
|
+
for (const row of rows) {
|
|
2458
|
+
let liveFields = live.get(row.participant);
|
|
2459
|
+
if (!liveFields) {
|
|
2460
|
+
liveFields = /* @__PURE__ */ new Set();
|
|
2461
|
+
live.set(row.participant, liveFields);
|
|
2462
|
+
}
|
|
2463
|
+
liveFields.add(row.field);
|
|
2464
|
+
const stamp = {
|
|
2465
|
+
seen: row.seen,
|
|
2466
|
+
atMs: row.at.getTime(),
|
|
2467
|
+
value: fingerprint(row.value)
|
|
2468
|
+
};
|
|
2469
|
+
const prior = painted.get(row.participant)?.get(row.field);
|
|
2470
|
+
if (prior && prior.seen === stamp.seen && prior.atMs === stamp.atMs && prior.value === stamp.value) continue;
|
|
2471
|
+
let paintedFields = painted.get(row.participant);
|
|
2472
|
+
if (!paintedFields) {
|
|
2473
|
+
paintedFields = /* @__PURE__ */ new Map();
|
|
2474
|
+
painted.set(row.participant, paintedFields);
|
|
2475
|
+
}
|
|
2476
|
+
paintedFields.set(row.field, stamp);
|
|
2477
|
+
patches.push({
|
|
2478
|
+
participant: row.participant,
|
|
2479
|
+
values: { [row.field]: row.value },
|
|
2480
|
+
seen: row.seen,
|
|
2481
|
+
at: row.at
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
for (const [participant, fields] of painted) {
|
|
2485
|
+
for (const [field, prior] of fields) {
|
|
2486
|
+
if (live.get(participant)?.has(field)) continue;
|
|
2487
|
+
fields.delete(field);
|
|
2488
|
+
patches.push({
|
|
2489
|
+
participant,
|
|
2490
|
+
values: { [field]: null },
|
|
2491
|
+
seen: prior.seen,
|
|
2492
|
+
at: new Date(prior.atMs)
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
if (fields.size === 0) painted.delete(participant);
|
|
2496
|
+
}
|
|
2497
|
+
return patches;
|
|
2498
|
+
};
|
|
2499
|
+
const paint = (patch) => {
|
|
2500
|
+
let fields = painted.get(patch.participant);
|
|
2501
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
2502
|
+
const write = {
|
|
2503
|
+
participant: patch.participant,
|
|
2504
|
+
field,
|
|
2505
|
+
at: patch.at
|
|
2506
|
+
};
|
|
2507
|
+
if (value === null || value === void 0) {
|
|
2508
|
+
fields?.delete(field);
|
|
2509
|
+
if (value === null) paintedDeletes.keep(write);
|
|
2510
|
+
continue;
|
|
2511
|
+
}
|
|
2512
|
+
paintedDeletes.drop(write);
|
|
2513
|
+
if (!fields) {
|
|
2514
|
+
fields = /* @__PURE__ */ new Map();
|
|
2515
|
+
painted.set(patch.participant, fields);
|
|
2516
|
+
}
|
|
2517
|
+
fields.set(field, {
|
|
2518
|
+
seen: patch.seen,
|
|
2519
|
+
atMs: patch.at.getTime(),
|
|
2520
|
+
value: fingerprint(value)
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
if (fields?.size === 0) painted.delete(patch.participant);
|
|
2524
|
+
};
|
|
2525
|
+
const queue = /* @__PURE__ */ new Map();
|
|
2526
|
+
const enqueue = (patch) => {
|
|
2527
|
+
if (closed) return;
|
|
2528
|
+
activity.activate();
|
|
2529
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
2530
|
+
if (value === void 0) continue;
|
|
2531
|
+
let fields = queue.get(patch.participant);
|
|
2532
|
+
if (!fields) {
|
|
2533
|
+
fields = /* @__PURE__ */ new Map();
|
|
2534
|
+
queue.set(patch.participant, fields);
|
|
2535
|
+
}
|
|
2536
|
+
const queued = fields.get(field);
|
|
2537
|
+
if (queued && queued.at.getTime() > patch.at.getTime()) continue;
|
|
2538
|
+
fields.set(field, {
|
|
2539
|
+
value,
|
|
2540
|
+
seen: patch.seen,
|
|
2541
|
+
at: patch.at
|
|
2542
|
+
});
|
|
2543
|
+
}
|
|
2544
|
+
wakeQueue?.();
|
|
2545
|
+
};
|
|
2546
|
+
const outdated = (write) => {
|
|
2547
|
+
const prior = painted.get(write.participant)?.get(write.field);
|
|
2548
|
+
return prior !== void 0 && prior.atMs > write.at.getTime() || paintedDeletes.outranks(write);
|
|
2549
|
+
};
|
|
2550
|
+
const dequeue = () => {
|
|
2551
|
+
for (const [participant, fields] of queue) {
|
|
2552
|
+
for (const [field, stamp] of fields) {
|
|
2553
|
+
fields.delete(field);
|
|
2554
|
+
if (fields.size === 0) queue.delete(participant);
|
|
2555
|
+
if (outdated({
|
|
2556
|
+
participant,
|
|
2557
|
+
field,
|
|
2558
|
+
at: stamp.at
|
|
2559
|
+
})) continue;
|
|
2560
|
+
return {
|
|
2561
|
+
participant,
|
|
2562
|
+
values: { [field]: stamp.value },
|
|
2563
|
+
seen: stamp.seen,
|
|
2564
|
+
at: stamp.at
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
queue.delete(participant);
|
|
2568
|
+
}
|
|
2569
|
+
return null;
|
|
2570
|
+
};
|
|
2571
|
+
try {
|
|
2572
|
+
unsubscribe = api.subscribe?.(ns, enqueue);
|
|
2573
|
+
events = store.stream(ns, { startAfter })[Symbol.asyncIterator]();
|
|
2574
|
+
const sourceActivity = streamActivities.get(events);
|
|
2575
|
+
if (sourceActivity) {
|
|
2576
|
+
stopActivity = sourceActivity.subscribe(activity.setIdle);
|
|
2577
|
+
activity.setIdle(sourceActivity.idle);
|
|
2578
|
+
}
|
|
2579
|
+
const snapshotRows = await readRows();
|
|
2580
|
+
if (closed) return;
|
|
2581
|
+
for (const row of snapshotRows) {
|
|
2582
|
+
let fields = painted.get(row.participant);
|
|
2583
|
+
if (!fields) {
|
|
2584
|
+
fields = /* @__PURE__ */ new Map();
|
|
2585
|
+
painted.set(row.participant, fields);
|
|
2586
|
+
}
|
|
2587
|
+
fields.set(row.field, {
|
|
2588
|
+
seen: row.seen,
|
|
2589
|
+
atMs: row.at.getTime(),
|
|
2590
|
+
value: fingerprint(row.value)
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
yield { snapshot: foldPresenceRows(snapshotRows) };
|
|
2594
|
+
for (;;) {
|
|
2595
|
+
if (closed) return;
|
|
2596
|
+
for (;;) {
|
|
2597
|
+
const patch = dequeue();
|
|
2598
|
+
if (!patch) break;
|
|
2599
|
+
if (closed) return;
|
|
2600
|
+
paint(patch);
|
|
2601
|
+
yield patch;
|
|
2602
|
+
}
|
|
2603
|
+
pendingEvent ??= events.next();
|
|
2604
|
+
let woke;
|
|
2605
|
+
if (unsubscribe) {
|
|
2606
|
+
const arrival = new Promise((resolve) => {
|
|
2607
|
+
wakeQueue = resolve;
|
|
2608
|
+
});
|
|
2609
|
+
woke = await Promise.race([pendingEvent.then(() => "event"), arrival.then(() => "patch")]);
|
|
2610
|
+
wakeQueue = null;
|
|
2611
|
+
if (closed) return;
|
|
2612
|
+
if (woke === "patch") continue;
|
|
2613
|
+
} else {
|
|
2614
|
+
sleeper = defaultSleep(presenceInterval);
|
|
2615
|
+
woke = await Promise.race([pendingEvent.then(() => "event"), sleeper.promise.then(() => "tick")]);
|
|
2616
|
+
sleeper.cancel();
|
|
2617
|
+
sleeper = null;
|
|
2618
|
+
}
|
|
2619
|
+
if (closed) return;
|
|
2620
|
+
if (woke === "event") {
|
|
2621
|
+
const result = await pendingEvent;
|
|
2622
|
+
pendingEvent = null;
|
|
2623
|
+
if (closed || result.done) return;
|
|
2624
|
+
activity.activate();
|
|
2625
|
+
yield toSessionEvent(id, result.value);
|
|
2626
|
+
}
|
|
2627
|
+
if (!closed && !unsubscribe) {
|
|
2628
|
+
const version = presenceActivityVersion;
|
|
2629
|
+
const rows = await readRows();
|
|
2630
|
+
if (closed) return;
|
|
2631
|
+
const patches = diffRows(rows);
|
|
2632
|
+
if (patches.length > 0) activity.activate();
|
|
2633
|
+
else if (version === presenceActivityVersion) presenceInterval = Math.min(presenceInterval * 2, POLL_TIMINGS.idleCeilingMs);
|
|
2634
|
+
for (const patch of patches) {
|
|
2635
|
+
if (closed) return;
|
|
2636
|
+
yield patch;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
} finally {
|
|
2641
|
+
pendingEvent?.catch(() => {});
|
|
2642
|
+
await close();
|
|
2643
|
+
wakeQueue = null;
|
|
2644
|
+
}
|
|
2645
|
+
};
|
|
2646
|
+
const iterator = outer();
|
|
2647
|
+
return { [Symbol.asyncIterator]() {
|
|
2648
|
+
const stream = {
|
|
2649
|
+
next: () => iterator.next(),
|
|
2650
|
+
async return() {
|
|
2651
|
+
await close();
|
|
2652
|
+
return iterator.return(void 0);
|
|
2653
|
+
},
|
|
2654
|
+
async throw(error) {
|
|
2655
|
+
await close();
|
|
2656
|
+
return iterator.throw(error);
|
|
2657
|
+
}
|
|
2658
|
+
};
|
|
2659
|
+
streamActivities.set(stream, activity);
|
|
2660
|
+
bufferedEvents.set(stream, () => {
|
|
2661
|
+
if (closed || !events || pendingEvent) return void 0;
|
|
2662
|
+
const event = takeBuffered(events);
|
|
2663
|
+
if (!event) return void 0;
|
|
2664
|
+
if (!isSerializedEvent(event)) return toSessionEvent(id, event);
|
|
2665
|
+
event.sessionId = id;
|
|
2666
|
+
return event;
|
|
2667
|
+
});
|
|
2668
|
+
return stream;
|
|
2669
|
+
} };
|
|
2670
|
+
};
|
|
2671
|
+
const reclaimAll = async (store, ns) => {
|
|
2672
|
+
for (let stalls = 0;;) {
|
|
2673
|
+
let batch;
|
|
2674
|
+
try {
|
|
2675
|
+
batch = await store.reclaim(ns);
|
|
2676
|
+
} catch (err) {
|
|
2677
|
+
throw asStoreUnavailable(err);
|
|
2678
|
+
}
|
|
2679
|
+
if (batch.done) return;
|
|
2680
|
+
if (batch.deleted > 0) {
|
|
2681
|
+
stalls = 0;
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
2684
|
+
stalls += 1;
|
|
2685
|
+
if (stalls === RECLAIM_STALL_LIMIT) throw new A2Error("STORE_UNAVAILABLE", `deleting the cleared history of session '${stripNs(ns)}' made no progress`);
|
|
2686
|
+
await defaultSleep(RECLAIM_STALL_MS).promise;
|
|
2687
|
+
}
|
|
2688
|
+
};
|
|
2689
|
+
const clearSession = async (sessionId, clearOptions, attempt) => {
|
|
2690
|
+
const ifLastIndex = clearOptions?.ifLastIndex;
|
|
2691
|
+
if (ifLastIndex !== void 0) assertClearIndex(ifLastIndex);
|
|
2692
|
+
const store = await resolveStore();
|
|
2693
|
+
if (!store.clear || !store.reclaim) throw new TypeError("session.clear requires a store that supports clearing");
|
|
2694
|
+
const ns = nsId(sessionId);
|
|
2695
|
+
let result;
|
|
2696
|
+
try {
|
|
2697
|
+
result = await store.clear({
|
|
2698
|
+
sessionId: ns,
|
|
2699
|
+
...ifLastIndex === void 0 ? {} : { ifLastIndex },
|
|
2700
|
+
...attempt ? { cause: attempt.cause } : {}
|
|
2701
|
+
});
|
|
2702
|
+
} catch (err) {
|
|
2703
|
+
throw asStoreUnavailable(err);
|
|
2704
|
+
}
|
|
2705
|
+
if (result.cleared) {
|
|
2706
|
+
const cachePrefix = stateCacheKey(sessionId, "");
|
|
2707
|
+
stateCache.deleteWhere((key) => key.startsWith(cachePrefix));
|
|
2708
|
+
}
|
|
2709
|
+
const deletion = reclaimAll(store, ns);
|
|
2710
|
+
if (attempt) {
|
|
2711
|
+
if (attempt.cause.index <= result.clearIndex) attempt.end();
|
|
2712
|
+
const kept = deletion.catch(() => void 0);
|
|
2713
|
+
track(kept);
|
|
2714
|
+
platformWaitUntil(kept);
|
|
2715
|
+
}
|
|
2716
|
+
await deletion;
|
|
2717
|
+
return {
|
|
2718
|
+
cleared: result.cleared,
|
|
2719
|
+
index: result.index
|
|
2720
|
+
};
|
|
2721
|
+
};
|
|
2722
|
+
const redrivingStore = async (method) => {
|
|
2723
|
+
const store = await resolveStore();
|
|
2724
|
+
if (!store.deadLetters || !store.redrive) throw new TypeError(`session.${method} requires a store that supports redriving dead letters`);
|
|
2725
|
+
return store;
|
|
2726
|
+
};
|
|
2727
|
+
const listDeadLetters = async (sessionId) => {
|
|
2728
|
+
const store = await redrivingStore("deadLetters");
|
|
2729
|
+
let rows;
|
|
2730
|
+
try {
|
|
2731
|
+
rows = await store.deadLetters(nsId(sessionId));
|
|
2732
|
+
} catch (err) {
|
|
2733
|
+
throw asStoreUnavailable(err);
|
|
2734
|
+
}
|
|
2735
|
+
return rows.flatMap((row) => row.failedAt === null ? [] : [{
|
|
2736
|
+
...toPublic(row),
|
|
2737
|
+
consumer: row.consumer,
|
|
2738
|
+
lane: row.lane,
|
|
2739
|
+
attemptCount: row.attemptCount,
|
|
2740
|
+
failureCount: row.failureCount,
|
|
2741
|
+
lastError: row.lastError,
|
|
2742
|
+
failedAt: row.failedAt
|
|
2743
|
+
}]);
|
|
2744
|
+
};
|
|
2745
|
+
const redriveEvent = async (sessionId, target, redriveOptions) => {
|
|
2746
|
+
const index = target?.index;
|
|
2747
|
+
const redriven = target?.consumer;
|
|
2748
|
+
assertRedriveIndex(index);
|
|
2749
|
+
if (typeof redriven !== "string" || redriven.length === 0) throw new TypeError("redrive requires a consumer name");
|
|
2750
|
+
const action = redriveOptions?.action;
|
|
2751
|
+
if (action !== "retry" && action !== "skip") throw new TypeError("redrive requires action 'retry' or 'skip'");
|
|
2752
|
+
return telemetry.span("a2.redrive", {
|
|
2753
|
+
"a2.contract": name,
|
|
2754
|
+
"a2.session_id": sessionId,
|
|
2755
|
+
"a2.redrive.index": index,
|
|
2756
|
+
"a2.redrive.consumer": redriven,
|
|
2757
|
+
"a2.redrive.action": action
|
|
2758
|
+
}, async (span) => {
|
|
2759
|
+
const store = await redrivingStore("redrive");
|
|
2760
|
+
let result;
|
|
2761
|
+
try {
|
|
2762
|
+
result = await store.redrive({
|
|
2763
|
+
sessionId: nsId(sessionId),
|
|
2764
|
+
consumer: redriven,
|
|
2765
|
+
index,
|
|
2766
|
+
action,
|
|
2767
|
+
consumers: storeConsumers
|
|
2768
|
+
});
|
|
2769
|
+
} catch (err) {
|
|
2770
|
+
throw asStoreUnavailable(err);
|
|
2771
|
+
}
|
|
2772
|
+
span.setAttribute("a2.redrive.redriven", result.redriven);
|
|
2773
|
+
if (result.hasPending) {
|
|
2774
|
+
const watchdogAt = scheduler ? schedulerSlot(nextClaimWindow().watchdogAtMs) : void 0;
|
|
2775
|
+
const arm = watchdogAt === void 0 ? null : startSchedulerArm(sessionId, watchdogAt);
|
|
2776
|
+
scheduleDrain(sessionId, watchdogAt);
|
|
2777
|
+
if (arm) try {
|
|
2778
|
+
await joinSchedulerArm(arm);
|
|
2779
|
+
} catch (err) {
|
|
2780
|
+
span.setAttribute("a2.redrive.armed", false);
|
|
2781
|
+
span.recordError(err);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
return { redriven: result.redriven };
|
|
2785
|
+
});
|
|
2786
|
+
};
|
|
2787
|
+
/**
|
|
2788
|
+
* `session.consume`: wakes the consumer's cursor, then drains only that
|
|
2789
|
+
* consumer until its head lies past `through`. A head another invocation
|
|
2790
|
+
* holds, or one waiting out a failure, is waited for; a dead-lettered head
|
|
2791
|
+
* throws.
|
|
2792
|
+
*/
|
|
2793
|
+
/** Per session, which consumer's running head is waiting in `consume` on which: a cycle never ends. */
|
|
2794
|
+
const consumeWaits = /* @__PURE__ */ new Map();
|
|
2795
|
+
const consumeSession = (sessionId, defined, consumeOptions = {}, trigger = null) => {
|
|
2796
|
+
assertSessionId(sessionId);
|
|
2797
|
+
if (defined?.contract?.name !== name || !defined.spec) throw new TypeError(`session.consume for '${name}' got a consumer of another contract`);
|
|
2798
|
+
const own = definitions.get(defined.name);
|
|
2799
|
+
if (own !== void 0 && own !== defined) throw new TypeError(`this server runs a different consumer named '${defined.name}'`);
|
|
2800
|
+
if (defined.name === trigger?.consumer) throw new TypeError(`consumer '${defined.name}' can't consume itself: its own head is running`);
|
|
2801
|
+
const registered = own ? registeredConsumers.get(defined.name) : registrationOf(defined);
|
|
2802
|
+
const log = registered.log;
|
|
2803
|
+
if (!log) throw new TypeError(`consumer '${defined.name}' needs a string lane to be consumed: only then does it have one position`);
|
|
2804
|
+
const { through: requested, signal } = consumeOptions;
|
|
2805
|
+
if (requested !== void 0) assertConsumeIndex(requested);
|
|
2806
|
+
const waiter = trigger?.consumer;
|
|
2807
|
+
const waits = waiter === void 0 ? void 0 : consumeWaits.get(sessionId) ?? consumeWaits.set(sessionId, /* @__PURE__ */ new Map()).get(sessionId);
|
|
2808
|
+
let cycle = false;
|
|
2809
|
+
if (waiter !== void 0 && waits) {
|
|
2810
|
+
for (let next = defined.name; next !== void 0 && !cycle; next = waits.get(next)) cycle = next === waiter;
|
|
2811
|
+
if (!cycle) waits.set(waiter, defined.name);
|
|
2812
|
+
}
|
|
2813
|
+
const storeConsumer = {
|
|
2814
|
+
name: registered.name,
|
|
2815
|
+
log
|
|
2816
|
+
};
|
|
2817
|
+
const consumed = telemetry.span("a2.consume", {
|
|
2818
|
+
"a2.contract": name,
|
|
2819
|
+
"a2.session_id": sessionId,
|
|
2820
|
+
"a2.consume.consumer": registered.name
|
|
2821
|
+
}, async (span) => {
|
|
2822
|
+
const store = await resolveStore();
|
|
2823
|
+
const ns = nsId(sessionId);
|
|
2824
|
+
let woken = await store.wakeLog({
|
|
2825
|
+
sessionId: ns,
|
|
2826
|
+
consumer: storeConsumer
|
|
2827
|
+
});
|
|
2828
|
+
const through = Math.min(requested ?? woken.head, woken.head);
|
|
2829
|
+
span.setAttribute("a2.consume.through", through);
|
|
2830
|
+
const done = () => woken.cursor.head === null || woken.cursor.head > through;
|
|
2831
|
+
if (cycle && !done()) throw new TypeError(`consumer '${waiter}' can't consume '${defined.name}' through ${through}: '${defined.name}' is waiting on it in session '${sessionId}'`);
|
|
2832
|
+
let drains = 0;
|
|
2833
|
+
let wait = CONSUME_WAIT_MIN_MS;
|
|
2834
|
+
while (!done()) {
|
|
2835
|
+
signal?.throwIfAborted();
|
|
2836
|
+
const { cursor } = woken;
|
|
2837
|
+
if (cursor.failedAt !== null) throw new A2Error("CONSUMER_DEAD_LETTERED", `consumer '${registered.name}' is dead-lettered at event ${cursor.head} in session '${sessionId}': ${cursor.lastError ?? "every attempt ended before it finished"}`);
|
|
2838
|
+
drains += 1;
|
|
2839
|
+
const drained = drainSession(sessionId, {
|
|
2840
|
+
only: registered,
|
|
2841
|
+
through
|
|
2842
|
+
});
|
|
2843
|
+
try {
|
|
2844
|
+
await untilAborted(drained, signal);
|
|
2845
|
+
} catch (error) {
|
|
2846
|
+
if (signal?.aborted) drained.then(() => scheduleDrain(sessionId), () => {});
|
|
2847
|
+
throw error;
|
|
2848
|
+
}
|
|
2849
|
+
const before = cursor;
|
|
2850
|
+
woken = await store.wakeLog({
|
|
2851
|
+
sessionId: ns,
|
|
2852
|
+
consumer: storeConsumer
|
|
2853
|
+
});
|
|
2854
|
+
const moved = woken.cursor.head !== before.head || woken.cursor.attemptCount !== before.attemptCount;
|
|
2855
|
+
if (done() || moved) {
|
|
2856
|
+
wait = CONSUME_WAIT_MIN_MS;
|
|
2857
|
+
continue;
|
|
2858
|
+
}
|
|
2859
|
+
const sleep = defaultSleep(wait);
|
|
2860
|
+
const abort = () => sleep.cancel();
|
|
2861
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2862
|
+
await sleep.promise;
|
|
2863
|
+
signal?.removeEventListener("abort", abort);
|
|
2864
|
+
wait = Math.min(wait * 2, CONSUME_WAIT_MAX_MS);
|
|
2865
|
+
}
|
|
2866
|
+
if (drains > 0 || own && woken.cursor.head !== null) scheduleDrain(sessionId);
|
|
2867
|
+
const index = woken.cursor.head === null ? woken.head : woken.cursor.head - 1;
|
|
2868
|
+
span.setAttribute("a2.consume.index", index);
|
|
2869
|
+
span.setAttribute("a2.consume.drains", drains);
|
|
2870
|
+
return { index };
|
|
2871
|
+
});
|
|
2872
|
+
return waiter === void 0 || cycle ? consumed : consumed.finally(() => {
|
|
2873
|
+
waits?.delete(waiter);
|
|
2874
|
+
if (waits?.size === 0) consumeWaits.delete(sessionId);
|
|
2875
|
+
});
|
|
2876
|
+
};
|
|
2877
|
+
const makeSession = (id, append, trigger, attempt) => ({
|
|
2878
|
+
id,
|
|
2879
|
+
append,
|
|
2880
|
+
consume: (defined, consumeOptions) => consumeSession(id, defined, consumeOptions, trigger),
|
|
2881
|
+
schedule: makeSchedule(id, trigger),
|
|
2882
|
+
clear: (clearOptions) => clearSession(id, clearOptions, attempt),
|
|
2883
|
+
history: async (bounds) => (await readHistory(id, bounds)).filter((row) => !isSessionCleared(row)).map(toPublic),
|
|
2884
|
+
state: (reducer = serverContract.reducer, stateOptions) => {
|
|
2885
|
+
if (!reducer) throw new A2Error("INVALID_PAYLOAD", "contract has no public reducer");
|
|
2886
|
+
const through = stateOptions?.through ?? trigger?.index;
|
|
2887
|
+
if (through !== void 0 && through !== "latest") assertStateIndex(through);
|
|
2888
|
+
const bounds = {};
|
|
2889
|
+
if (through !== void 0 && through !== "latest") {
|
|
2890
|
+
bounds.throughIndex = through;
|
|
2891
|
+
if (trigger) bounds.pin = headRefOf(trigger);
|
|
2892
|
+
if (through === trigger?.index && !attempt?.signal.aborted) bounds.throughEventId = trigger.id;
|
|
2893
|
+
}
|
|
2894
|
+
if (trigger) bounds.triggerIndex = trigger.index;
|
|
2895
|
+
return readState(id, reducer, bounds);
|
|
2896
|
+
},
|
|
2897
|
+
stream: (opts) => {
|
|
2898
|
+
const startAfter = opts?.startAfter ?? 0;
|
|
2899
|
+
assertStreamIndex(startAfter);
|
|
2900
|
+
return opts?.presence === true ? streamWithPresence(id, startAfter) : streamEvents(id, startAfter);
|
|
2901
|
+
},
|
|
2902
|
+
...declaresPresence ? {
|
|
2903
|
+
setPresence: (patch) => setPresence(id, patch, trigger?.index ?? 0),
|
|
2904
|
+
presence: () => readPresenceMap(id)
|
|
2905
|
+
} : {}
|
|
2906
|
+
});
|
|
2907
|
+
/** The consumer name a trigger's child ids key on, if any. */
|
|
2908
|
+
const idConsumerOf = (trigger) => registeredConsumers.get(trigger.consumer)?.anonymousIds ? void 0 : trigger.consumer;
|
|
2909
|
+
const makeCtx = (trigger, attempt, signal, endAttempt) => {
|
|
2910
|
+
const publicSessionId = stripNs(trigger.sessionId);
|
|
2911
|
+
const idConsumer = idConsumerOf(trigger);
|
|
2912
|
+
const cause = {
|
|
2913
|
+
consumer: trigger.consumer,
|
|
2914
|
+
lane: trigger.lane,
|
|
2915
|
+
index: trigger.index,
|
|
2916
|
+
attempt
|
|
2917
|
+
};
|
|
2918
|
+
const append = async (appendName, ...events) => {
|
|
2919
|
+
assertAppendName(appendName);
|
|
2920
|
+
const generatedIds = events.map((event) => event.id === void 0);
|
|
2921
|
+
const withIds = await Promise.all(events.map(async (event, item) => {
|
|
2922
|
+
if (event.id !== void 0) return event;
|
|
2923
|
+
const id = await deterministicEventId(trigger.id, appendName, item, idConsumer);
|
|
2924
|
+
return {
|
|
2925
|
+
...event,
|
|
2926
|
+
id
|
|
2927
|
+
};
|
|
2928
|
+
}));
|
|
2929
|
+
return appendCore(publicSessionId, withIds, "consumer", "inline", (_rows, watchdogDueAt) => scheduleDrain(publicSessionId, watchdogDueAt), cause, generatedIds);
|
|
2930
|
+
};
|
|
2931
|
+
const ctx = {
|
|
2932
|
+
event: toPublic(trigger),
|
|
2933
|
+
attempt,
|
|
2934
|
+
session: makeSession(publicSessionId, append, trigger, {
|
|
2935
|
+
cause,
|
|
2936
|
+
end: endAttempt,
|
|
2937
|
+
signal
|
|
2938
|
+
}),
|
|
2939
|
+
signal
|
|
2940
|
+
};
|
|
2941
|
+
if (attempt > 1 && trigger.lastFailedAttempt === attempt - 1) failedPreviousAttempt.add(ctx);
|
|
2942
|
+
return ctx;
|
|
2943
|
+
};
|
|
2944
|
+
const abortHubs = /* @__PURE__ */ new Map();
|
|
2945
|
+
const createAbortHub = (store, ns, startAfter) => {
|
|
2946
|
+
const hub = {
|
|
2947
|
+
subscriptions: /* @__PURE__ */ new Set(),
|
|
2948
|
+
seen: /* @__PURE__ */ new Map(),
|
|
2949
|
+
startAfter,
|
|
2950
|
+
ready: Promise.resolve(),
|
|
2951
|
+
watch: null,
|
|
2952
|
+
error: null,
|
|
2953
|
+
iterator: null,
|
|
2954
|
+
stopped: false
|
|
2955
|
+
};
|
|
2956
|
+
hub.ready = (async () => {
|
|
2957
|
+
const existing = await store.read(ns, { afterIndex: startAfter });
|
|
2958
|
+
for (const event of existing) dispatchAbortEvent(hub, event);
|
|
2959
|
+
const tail = existing.at(-1)?.index ?? startAfter;
|
|
2960
|
+
const iterator = store.stream(ns, { startAfter: tail })[Symbol.asyncIterator]();
|
|
2961
|
+
hub.iterator = iterator;
|
|
2962
|
+
hub.watch = (async () => {
|
|
2963
|
+
for (;;) {
|
|
2964
|
+
const { value, done } = await iterator.next();
|
|
2965
|
+
if (done || value === void 0 || hub.stopped) return;
|
|
2966
|
+
dispatchAbortEvent(hub, value);
|
|
2967
|
+
}
|
|
2968
|
+
})().catch((error) => {
|
|
2969
|
+
hub.error = error;
|
|
2970
|
+
});
|
|
2971
|
+
})().catch((error) => {
|
|
2972
|
+
hub.error = error;
|
|
2973
|
+
if (abortHubs.get(ns) === hub) abortHubs.delete(ns);
|
|
2974
|
+
throw error;
|
|
2975
|
+
});
|
|
2976
|
+
abortHubs.set(ns, hub);
|
|
2977
|
+
return hub;
|
|
2978
|
+
};
|
|
2979
|
+
const subscribeAbort = async (store, ns, trigger, abortOn, attempt) => {
|
|
2980
|
+
let hub = abortHubs.get(ns);
|
|
2981
|
+
const created = hub === void 0;
|
|
2982
|
+
if (!hub) hub = createAbortHub(store, ns, trigger.index);
|
|
2983
|
+
const activeTypes = /* @__PURE__ */ new Set();
|
|
2984
|
+
for (const active of hub.subscriptions) for (const type of active.types) activeTypes.add(type);
|
|
2985
|
+
const publicTrigger = toPublic(trigger);
|
|
2986
|
+
const subscription = {
|
|
2987
|
+
controller: new AbortController(),
|
|
2988
|
+
triggerIndex: trigger.index,
|
|
2989
|
+
types: new Set(abortOn.keys()),
|
|
2990
|
+
matches: (event) => {
|
|
2991
|
+
if (event.index <= trigger.index) return false;
|
|
2992
|
+
const matcher = abortOn.get(event.type);
|
|
2993
|
+
if (matcher === void 0) return false;
|
|
2994
|
+
return matcher === true ? true : matcher(toPublic(event), publicTrigger, { attempt });
|
|
2995
|
+
}
|
|
2996
|
+
};
|
|
2997
|
+
hub.subscriptions.add(subscription);
|
|
2998
|
+
if (!created && (trigger.index < hub.startAfter || [...subscription.types].some((type) => !activeTypes.has(type)))) {
|
|
2999
|
+
const earlier = await store.read(ns, { afterIndex: trigger.index });
|
|
3000
|
+
hub.startAfter = Math.min(hub.startAfter, trigger.index);
|
|
3001
|
+
for (const event of earlier) dispatchAbortEvent(hub, event);
|
|
3002
|
+
}
|
|
3003
|
+
for (const event of hub.seen.values()) if (subscription.matches(event)) subscription.controller.abort();
|
|
3004
|
+
await hub.ready;
|
|
3005
|
+
if (hub.error !== null) throw hub.error;
|
|
3006
|
+
return {
|
|
3007
|
+
signal: subscription.controller.signal,
|
|
3008
|
+
monitor: async (operation) => {
|
|
3009
|
+
const outcome = await Promise.race([operation.then((value) => ({
|
|
3010
|
+
type: "completed",
|
|
3011
|
+
value
|
|
3012
|
+
}), (error) => ({
|
|
3013
|
+
type: "run-failed",
|
|
3014
|
+
error
|
|
3015
|
+
})), hub.watch.then(() => hub.error === null ? { type: "monitor-closed" } : {
|
|
3016
|
+
type: "monitor-failed",
|
|
3017
|
+
error: hub.error
|
|
3018
|
+
})]);
|
|
3019
|
+
if (outcome.type === "completed") return outcome.value;
|
|
3020
|
+
if (outcome.type === "run-failed") throw outcome.error;
|
|
3021
|
+
if (outcome.type === "monitor-failed") {
|
|
3022
|
+
for (const active of hub.subscriptions) active.controller.abort(outcome.error);
|
|
3023
|
+
await operation.catch(() => {});
|
|
3024
|
+
throw outcome.error;
|
|
3025
|
+
}
|
|
3026
|
+
await operation.catch(() => {});
|
|
3027
|
+
throw new Error("abort monitor closed while consumers were active");
|
|
3028
|
+
},
|
|
3029
|
+
close: async () => {
|
|
3030
|
+
hub.subscriptions.delete(subscription);
|
|
3031
|
+
if (hub.subscriptions.size > 0) {
|
|
3032
|
+
pruneAbortHub(hub);
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
hub.stopped = true;
|
|
3036
|
+
abortHubs.delete(ns);
|
|
3037
|
+
await hub.ready;
|
|
3038
|
+
await hub.iterator?.return?.();
|
|
3039
|
+
await hub.watch;
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
};
|
|
3043
|
+
const returnedEvents = async (sessionId, trigger, result) => {
|
|
3044
|
+
if (result === void 0) return [];
|
|
3045
|
+
const events = Array.isArray(result) ? [...result] : [result];
|
|
3046
|
+
if (events.length === 0) return [];
|
|
3047
|
+
const idConsumer = idConsumerOf(trigger);
|
|
3048
|
+
const withIds = await Promise.all(events.map(async (event, item) => {
|
|
3049
|
+
const withId = { ...event };
|
|
3050
|
+
withId.id = event.id ?? await deterministicReturnedEventId(trigger.id, item, idConsumer);
|
|
3051
|
+
return withId;
|
|
3052
|
+
}));
|
|
3053
|
+
return validateEvents(sessionId, withIds).map((event) => Object.assign(event, {
|
|
3054
|
+
id: event.id,
|
|
3055
|
+
cause: {
|
|
3056
|
+
consumer: trigger.consumer,
|
|
3057
|
+
lane: trigger.lane,
|
|
3058
|
+
index: trigger.index,
|
|
3059
|
+
attempt: trigger.attemptCount
|
|
3060
|
+
}
|
|
3061
|
+
}));
|
|
3062
|
+
};
|
|
3063
|
+
const drainSession = async (sessionId, drainOptions = {}) => {
|
|
3064
|
+
const result = await runDrain(sessionId, drainOptions);
|
|
3065
|
+
if (result.settled && drainOptions.only === void 0) drainSettledHooks.get(self)?.(sessionId);
|
|
3066
|
+
return result;
|
|
3067
|
+
};
|
|
3068
|
+
const runDrain = (sessionId, { watchdogDueAt, signal, handOffFailure = false, only, through } = {}) => telemetry.span("a2.drain", {
|
|
3069
|
+
"a2.contract": name,
|
|
3070
|
+
"a2.session_id": sessionId
|
|
3071
|
+
}, async (span) => {
|
|
3072
|
+
const store = await resolveStore();
|
|
3073
|
+
const ns = nsId(sessionId);
|
|
3074
|
+
const holder = crypto.randomUUID();
|
|
3075
|
+
/** Running heads, keyed by `headKey`: one event can run for several consumers. */
|
|
3076
|
+
const active = /* @__PURE__ */ new Map();
|
|
3077
|
+
const excluded = /* @__PURE__ */ new Map();
|
|
3078
|
+
let processed = 0;
|
|
3079
|
+
let sawFailure = false;
|
|
3080
|
+
let schedulerArm;
|
|
3081
|
+
let heartbeat = null;
|
|
3082
|
+
let lapseTimer = null;
|
|
3083
|
+
let retryTimer = null;
|
|
3084
|
+
let renewal = null;
|
|
3085
|
+
let stopRenewing = false;
|
|
3086
|
+
const lapseDue = () => {
|
|
3087
|
+
lapseTimer = null;
|
|
3088
|
+
const nowMs = Date.now();
|
|
3089
|
+
for (const entry of active.values()) {
|
|
3090
|
+
if (entry.lapsed || entry.expiresAtMs > nowMs) continue;
|
|
3091
|
+
entry.lapsed = true;
|
|
3092
|
+
entry.supersede.abort(new A2Error("CLAIM_EXPIRED", `the claim on event ${entry.ref.index} for consumer '${entry.ref.consumer}' in session '${sessionId}' lapsed without renewal`));
|
|
3093
|
+
}
|
|
3094
|
+
scheduleLapse();
|
|
3095
|
+
};
|
|
3096
|
+
const scheduleLapse = () => {
|
|
3097
|
+
if (lapseTimer) {
|
|
3098
|
+
clearTimeout(lapseTimer);
|
|
3099
|
+
lapseTimer = null;
|
|
3100
|
+
}
|
|
3101
|
+
let next = Number.POSITIVE_INFINITY;
|
|
3102
|
+
for (const entry of active.values()) if (!entry.lapsed) next = Math.min(next, entry.expiresAtMs);
|
|
3103
|
+
if (next === Number.POSITIVE_INFINITY) return;
|
|
3104
|
+
lapseTimer = setTimeout(lapseDue, Math.max(0, next - Date.now()));
|
|
3105
|
+
lapseTimer.unref?.();
|
|
3106
|
+
};
|
|
3107
|
+
const scheduleRenewRetry = () => {
|
|
3108
|
+
if (stopRenewing || retryTimer) return;
|
|
3109
|
+
const nowMs = Date.now();
|
|
3110
|
+
if (![...active.values()].some((entry) => !entry.lapsed && entry.expiresAtMs > nowMs)) return;
|
|
3111
|
+
const delay = DRAIN_TIMINGS.renewRetryMs * (.5 + Math.random() * .5);
|
|
3112
|
+
retryTimer = setTimeout(() => {
|
|
3113
|
+
retryTimer = null;
|
|
3114
|
+
renew();
|
|
3115
|
+
}, delay);
|
|
3116
|
+
retryTimer.unref?.();
|
|
3117
|
+
};
|
|
3118
|
+
const alignWatchdogAt = (minimumDueAt) => {
|
|
3119
|
+
if (watchdogDueAt === void 0) return minimumDueAt;
|
|
3120
|
+
return watchdogDueAt + Math.max(0, Math.ceil((minimumDueAt - watchdogDueAt) / DRAIN_TIMINGS.claimHeartbeatMs)) * DRAIN_TIMINGS.claimHeartbeatMs;
|
|
3121
|
+
};
|
|
3122
|
+
const requestWatchdog = (dueAt) => {
|
|
3123
|
+
const arm = startSchedulerArm(sessionId, alignWatchdogAt(dueAt));
|
|
3124
|
+
if (arm) schedulerArm = arm;
|
|
3125
|
+
};
|
|
3126
|
+
const renew = () => {
|
|
3127
|
+
if (stopRenewing || renewal || active.size === 0) return renewal ?? Promise.resolve();
|
|
3128
|
+
const window = nextClaimWindow();
|
|
3129
|
+
requestWatchdog(window.watchdogAtMs);
|
|
3130
|
+
const claims = [...active.values()].map((entry) => ({
|
|
3131
|
+
...entry.ref,
|
|
3132
|
+
attempt: entry.attempt
|
|
3133
|
+
}));
|
|
3134
|
+
const operation = store.renewClaims({
|
|
3135
|
+
sessionId: ns,
|
|
3136
|
+
holder,
|
|
3137
|
+
claims,
|
|
3138
|
+
ttlMs: window.ttlMs,
|
|
3139
|
+
...window.deadlineCapped ? { expiresAtMs: window.expiresAtMs } : {}
|
|
3140
|
+
}).then((result) => {
|
|
3141
|
+
for (const ref of result.renewed) {
|
|
3142
|
+
const entry = active.get(headKey(ref));
|
|
3143
|
+
if (entry && !entry.lapsed) entry.expiresAtMs = window.expiresAtMs;
|
|
3144
|
+
}
|
|
3145
|
+
for (const ref of result.superseded) {
|
|
3146
|
+
const entry = active.get(headKey(ref));
|
|
3147
|
+
if (!entry || entry.completing) continue;
|
|
3148
|
+
endSuperseded(entry, `a newer attempt owns event ${ref.index} for consumer '${ref.consumer}' in session '${sessionId}'`);
|
|
3149
|
+
}
|
|
3150
|
+
if (window.deadlineCapped) stopRenewing = true;
|
|
3151
|
+
scheduleLapse();
|
|
3152
|
+
}, () => {
|
|
3153
|
+
scheduleRenewRetry();
|
|
3154
|
+
});
|
|
3155
|
+
let tracked;
|
|
3156
|
+
tracked = operation.finally(() => {
|
|
3157
|
+
if (renewal === tracked) renewal = null;
|
|
3158
|
+
});
|
|
3159
|
+
renewal = tracked;
|
|
3160
|
+
return tracked;
|
|
3161
|
+
};
|
|
3162
|
+
/** Records a head the store dead-lettered on takeover; it never runs. */
|
|
3163
|
+
const reportExhausted = (event) => telemetry.span("a2.event", {
|
|
3164
|
+
"a2.contract": name,
|
|
3165
|
+
"a2.session_id": sessionId,
|
|
3166
|
+
"a2.event.type": event.type,
|
|
3167
|
+
"a2.event.index": event.index,
|
|
3168
|
+
"a2.event.id": event.id,
|
|
3169
|
+
"a2.event.consumer": event.consumer,
|
|
3170
|
+
"a2.event.attempt": event.attemptCount,
|
|
3171
|
+
"a2.event.handled": registeredConsumers.has(event.consumer),
|
|
3172
|
+
...event.lane === null ? {} : { "a2.event.lane": event.lane }
|
|
3173
|
+
}, async (eventSpan) => {
|
|
3174
|
+
eventSpan.setAttribute("a2.event.outcome", "dead_lettered");
|
|
3175
|
+
});
|
|
3176
|
+
const runEvent = async (event, supersedeSignal, endAttempt, beginCompleting, sessionHead) => {
|
|
3177
|
+
const registration = only?.name === event.consumer ? only : registeredConsumers.get(event.consumer);
|
|
3178
|
+
const abortOn = registration?.abortOn(event.type) ?? null;
|
|
3179
|
+
return telemetry.span("a2.event", {
|
|
3180
|
+
"a2.contract": name,
|
|
3181
|
+
"a2.session_id": sessionId,
|
|
3182
|
+
"a2.event.type": event.type,
|
|
3183
|
+
"a2.event.index": event.index,
|
|
3184
|
+
"a2.event.id": event.id,
|
|
3185
|
+
"a2.event.consumer": event.consumer,
|
|
3186
|
+
"a2.event.attempt": event.attemptCount,
|
|
3187
|
+
"a2.event.handled": registration !== void 0,
|
|
3188
|
+
"a2.event.age_ms": Math.max(0, Date.now() - event.createdAt.getTime()),
|
|
3189
|
+
...sessionHead === void 0 ? {} : { "a2.event.lag": Math.max(0, sessionHead - event.index) },
|
|
3190
|
+
...event.lane === null ? {} : { "a2.event.lane": event.lane }
|
|
3191
|
+
}, async (eventSpan) => {
|
|
3192
|
+
let subscription;
|
|
3193
|
+
let runSignal = supersedeSignal;
|
|
3194
|
+
let completion;
|
|
3195
|
+
let completing = false;
|
|
3196
|
+
try {
|
|
3197
|
+
if (abortOn) {
|
|
3198
|
+
subscription = await subscribeAbort(store, ns, event, abortOn, event.attemptCount);
|
|
3199
|
+
runSignal = AbortSignal.any([supersedeSignal, subscription.signal]);
|
|
3200
|
+
}
|
|
3201
|
+
const operation = registration?.run(makeCtx(event, event.attemptCount, runSignal, endAttempt));
|
|
3202
|
+
const result = operation ? await (subscription ? subscription.monitor(operation) : operation) : void 0;
|
|
3203
|
+
if (subscription) {
|
|
3204
|
+
await subscription.close();
|
|
3205
|
+
subscription = void 0;
|
|
3206
|
+
}
|
|
3207
|
+
const returned = await returnedEvents(sessionId, event, result);
|
|
3208
|
+
await renewal;
|
|
3209
|
+
completing = true;
|
|
3210
|
+
beginCompleting();
|
|
3211
|
+
completion = await telemetry.span("a2.commit", {
|
|
3212
|
+
"a2.contract": name,
|
|
3213
|
+
"a2.session_id": sessionId,
|
|
3214
|
+
"a2.commit.operation": "complete",
|
|
3215
|
+
"a2.commit.index": event.index,
|
|
3216
|
+
"a2.commit.attempt": event.attemptCount
|
|
3217
|
+
}, async (commitSpan) => {
|
|
3218
|
+
const committed = await store.completeAttempt({
|
|
3219
|
+
sessionId: ns,
|
|
3220
|
+
consumer: event.consumer,
|
|
3221
|
+
lane: event.lane,
|
|
3222
|
+
index: event.index,
|
|
3223
|
+
attempt: event.attemptCount,
|
|
3224
|
+
events: returned,
|
|
3225
|
+
consumers: storeConsumers
|
|
3226
|
+
});
|
|
3227
|
+
commitSpan.setAttribute("a2.commit.outcome", committed.outcome);
|
|
3228
|
+
return committed;
|
|
3229
|
+
});
|
|
3230
|
+
} catch (err) {
|
|
3231
|
+
if (supersedeSignal.aborted && err === supersedeSignal.reason) {
|
|
3232
|
+
eventSpan.setAttribute("a2.event.outcome", "surrendered");
|
|
3233
|
+
return {
|
|
3234
|
+
status: "surrendered",
|
|
3235
|
+
opensLane: false,
|
|
3236
|
+
appendsEvents: false
|
|
3237
|
+
};
|
|
3238
|
+
}
|
|
3239
|
+
const refused = completing && err instanceof A2Error && err.code === "PARTIAL_DUPLICATE_BATCH";
|
|
3240
|
+
if (completing && !refused) throw err;
|
|
3241
|
+
eventSpan.recordError(err);
|
|
3242
|
+
const failure = await telemetry.span("a2.commit", {
|
|
3243
|
+
"a2.contract": name,
|
|
3244
|
+
"a2.session_id": sessionId,
|
|
3245
|
+
"a2.commit.operation": "fail",
|
|
3246
|
+
"a2.commit.index": event.index,
|
|
3247
|
+
"a2.commit.attempt": event.attemptCount
|
|
3248
|
+
}, async (commitSpan) => {
|
|
3249
|
+
const result = await store.failAttempt({
|
|
3250
|
+
sessionId: ns,
|
|
3251
|
+
consumer: event.consumer,
|
|
3252
|
+
lane: event.lane,
|
|
3253
|
+
index: event.index,
|
|
3254
|
+
attempt: event.attemptCount,
|
|
3255
|
+
error: describeError(err),
|
|
3256
|
+
maxFailures: err instanceof NonRetriableError || refused ? 1 : MAX_FAILURES,
|
|
3257
|
+
retryAfterMs: failureRetryDelayMs(event.failureCount + 1)
|
|
3258
|
+
});
|
|
3259
|
+
commitSpan.setAttribute("a2.commit.outcome", result.outcome);
|
|
3260
|
+
return result;
|
|
3261
|
+
});
|
|
3262
|
+
eventSpan.setAttribute("a2.event.outcome", failure.outcome);
|
|
3263
|
+
return {
|
|
3264
|
+
status: failure.outcome,
|
|
3265
|
+
opensLane: false,
|
|
3266
|
+
appendsEvents: false
|
|
3267
|
+
};
|
|
3268
|
+
} finally {
|
|
3269
|
+
await subscription?.close();
|
|
3270
|
+
}
|
|
3271
|
+
eventSpan.setAttribute("a2.event.outcome", completion.outcome === "completed" ? "processed" : completion.outcome);
|
|
3272
|
+
if (completion.outcome === "superseded") return {
|
|
3273
|
+
status: "superseded",
|
|
3274
|
+
opensLane: false,
|
|
3275
|
+
appendsEvents: false
|
|
3276
|
+
};
|
|
3277
|
+
if (runSignal.aborted) eventSpan.setAttribute("a2.event.aborted", true);
|
|
3278
|
+
return {
|
|
3279
|
+
status: "processed",
|
|
3280
|
+
opensLane: event.lane !== null,
|
|
3281
|
+
appendsEvents: completion.events.length > 0
|
|
3282
|
+
};
|
|
3283
|
+
});
|
|
3284
|
+
};
|
|
3285
|
+
let claimAgain = false;
|
|
3286
|
+
const start = (event, expiresAtMs, sessionHead) => {
|
|
3287
|
+
const ref = headRefOf(event);
|
|
3288
|
+
const key = headKey(ref);
|
|
3289
|
+
const previous = active.get(key);
|
|
3290
|
+
if (previous) endSuperseded(previous, `a newer attempt owns event ${event.index} for consumer '${event.consumer}' in session '${sessionId}'`);
|
|
3291
|
+
const supersede = new AbortController();
|
|
3292
|
+
let fence;
|
|
3293
|
+
const fenced = new Promise((resolve) => {
|
|
3294
|
+
fence = resolve;
|
|
3295
|
+
});
|
|
3296
|
+
const endAttempt = () => endSuperseded({
|
|
3297
|
+
supersede,
|
|
3298
|
+
fence
|
|
3299
|
+
}, `event ${event.index} for consumer '${event.consumer}' in session '${sessionId}' was cleared`);
|
|
3300
|
+
let entry;
|
|
3301
|
+
const execution = Promise.race([runEvent(event, supersede.signal, endAttempt, () => {
|
|
3302
|
+
entry.completing = true;
|
|
3303
|
+
}, sessionHead), fenced]).then((eventOutcome) => {
|
|
3304
|
+
if (active.get(key) === entry) active.delete(key);
|
|
3305
|
+
if (eventOutcome.status === "processed") processed += 1;
|
|
3306
|
+
if (eventOutcome.status === "failed") {
|
|
3307
|
+
excluded.set(key, ref);
|
|
3308
|
+
sawFailure = true;
|
|
3309
|
+
}
|
|
3310
|
+
claimAgain ||= eventOutcome.opensLane || eventOutcome.appendsEvents;
|
|
3311
|
+
return eventOutcome;
|
|
3312
|
+
});
|
|
3313
|
+
entry = {
|
|
3314
|
+
ref,
|
|
3315
|
+
attempt: event.attemptCount,
|
|
3316
|
+
execution,
|
|
3317
|
+
supersede,
|
|
3318
|
+
fence,
|
|
3319
|
+
expiresAtMs,
|
|
3320
|
+
lapsed: false,
|
|
3321
|
+
completing: false
|
|
3322
|
+
};
|
|
3323
|
+
active.set(key, entry);
|
|
3324
|
+
scheduleLapse();
|
|
3325
|
+
if (!heartbeat && !stopRenewing) {
|
|
3326
|
+
heartbeat = setInterval(() => void renew(), DRAIN_TIMINGS.claimHeartbeatMs);
|
|
3327
|
+
heartbeat.unref?.();
|
|
3328
|
+
}
|
|
3329
|
+
};
|
|
3330
|
+
let outcome = "settled";
|
|
3331
|
+
let shouldClaim = true;
|
|
3332
|
+
try {
|
|
3333
|
+
for (;;) {
|
|
3334
|
+
const signalVersion = signal?.version ?? 0;
|
|
3335
|
+
let claim;
|
|
3336
|
+
if (shouldClaim) {
|
|
3337
|
+
const window = nextClaimWindow();
|
|
3338
|
+
if (window.pastDeadline) {
|
|
3339
|
+
outcome = "busy";
|
|
3340
|
+
break;
|
|
3341
|
+
}
|
|
3342
|
+
claim = await telemetry.span("a2.claim", {
|
|
3343
|
+
"a2.contract": name,
|
|
3344
|
+
"a2.session_id": sessionId
|
|
3345
|
+
}, async (claimSpan) => {
|
|
3346
|
+
const result = await store.claimAvailable({
|
|
3347
|
+
sessionId: ns,
|
|
3348
|
+
holder,
|
|
3349
|
+
ttlMs: window.ttlMs,
|
|
3350
|
+
maxFailures: MAX_FAILURES,
|
|
3351
|
+
...window.deadlineCapped ? { expiresAtMs: window.expiresAtMs } : {},
|
|
3352
|
+
consumers: only ? [storeConsumerOf(only)] : storeConsumers,
|
|
3353
|
+
...through === void 0 ? {} : { through },
|
|
3354
|
+
...excluded.size === 0 ? {} : { exclude: [...excluded.values()] }
|
|
3355
|
+
});
|
|
3356
|
+
claimSpan.setAttribute("a2.claim.outcome", result.outcome);
|
|
3357
|
+
claimSpan.setAttribute("a2.claim.count", result.outcome === "claimed" ? result.events.length : 0);
|
|
3358
|
+
return result;
|
|
3359
|
+
});
|
|
3360
|
+
if (claim.outcome === "claimed") {
|
|
3361
|
+
const runnable = claim.events.filter((event) => event.failedAt === null);
|
|
3362
|
+
for (const event of claim.events) if (event.failedAt !== null) await reportExhausted(event);
|
|
3363
|
+
if (runnable.length > 0) requestWatchdog(window.watchdogAtMs);
|
|
3364
|
+
if (window.deadlineCapped) stopRenewing = true;
|
|
3365
|
+
for (const event of runnable) start(event, window.expiresAtMs, claim.head);
|
|
3366
|
+
shouldClaim = false;
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
if (claim?.outcome === "claimed" && [...active.values()].some((entry) => entry.lapsed)) {
|
|
3370
|
+
shouldClaim = true;
|
|
3371
|
+
continue;
|
|
3372
|
+
}
|
|
3373
|
+
if (active.size > 0) {
|
|
3374
|
+
const wake = signal?.wait(signalVersion);
|
|
3375
|
+
const hasLapsed = [...active.values()].some((entry) => entry.lapsed);
|
|
3376
|
+
const retry = claim?.outcome === "busy" && hasLapsed ? defaultSleep(Math.max(0, claim.retryAt.getTime() - Date.now())) : void 0;
|
|
3377
|
+
const retryDue = await Promise.race([
|
|
3378
|
+
...[...active.values()].map((entry) => entry.execution.then(() => false)),
|
|
3379
|
+
...wake ? [wake.promise.then(() => false)] : [],
|
|
3380
|
+
...retry ? [retry.promise.then(() => true)] : []
|
|
3381
|
+
]);
|
|
3382
|
+
wake?.cancel();
|
|
3383
|
+
retry?.cancel();
|
|
3384
|
+
shouldClaim = retryDue || claimAgain || active.size === 0 || hasLapsed || signal !== void 0 && signal.version !== signalVersion;
|
|
3385
|
+
claimAgain = false;
|
|
3386
|
+
continue;
|
|
3387
|
+
}
|
|
3388
|
+
if (!shouldClaim) {
|
|
3389
|
+
shouldClaim = true;
|
|
3390
|
+
continue;
|
|
3391
|
+
}
|
|
3392
|
+
if (claim === void 0) continue;
|
|
3393
|
+
if (signal && signal.version !== signalVersion) continue;
|
|
3394
|
+
if (claimAgain) {
|
|
3395
|
+
claimAgain = false;
|
|
3396
|
+
continue;
|
|
3397
|
+
}
|
|
3398
|
+
if (claim.outcome === "busy") {
|
|
3399
|
+
requestWatchdog(claim.retryAt.getTime() + DRAIN_TIMINGS.watchdogGraceMs);
|
|
3400
|
+
outcome = "busy";
|
|
3401
|
+
} else if (sawFailure) outcome = "stalled";
|
|
3402
|
+
if (signal) signal.closed = true;
|
|
3403
|
+
break;
|
|
3404
|
+
}
|
|
3405
|
+
} catch (error) {
|
|
3406
|
+
if (!handOffFailure || schedulerArm === void 0) throw error;
|
|
3407
|
+
span.recordError(error);
|
|
3408
|
+
outcome = "failed";
|
|
3409
|
+
} finally {
|
|
3410
|
+
if (signal) signal.closed = true;
|
|
3411
|
+
stopRenewing = true;
|
|
3412
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
3413
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
3414
|
+
await renewal;
|
|
3415
|
+
await Promise.allSettled([...active.values()].map((entry) => entry.execution));
|
|
3416
|
+
if (lapseTimer) clearTimeout(lapseTimer);
|
|
3417
|
+
}
|
|
3418
|
+
span.setAttribute("a2.drain.processed", processed);
|
|
3419
|
+
span.setAttribute("a2.drain.outcome", outcome);
|
|
3420
|
+
return {
|
|
3421
|
+
settled: outcome === "settled",
|
|
3422
|
+
outcome,
|
|
3423
|
+
processed,
|
|
3424
|
+
...schedulerArm ? { schedulerArm } : {}
|
|
3425
|
+
};
|
|
3426
|
+
});
|
|
3427
|
+
const appendExternal = async (sessionId, mode, events, requireDurableInitialArm = false, originIndex) => {
|
|
3428
|
+
return appendCore(sessionId, events, "external", mode, (_rows, watchdogDueAt) => scheduleDrain(sessionId, watchdogDueAt), void 0, void 0, requireDurableInitialArm, originIndex);
|
|
3429
|
+
};
|
|
3430
|
+
const session = (id) => {
|
|
3431
|
+
assertSessionId(id);
|
|
3432
|
+
const appendInline = async (...events) => appendExternal(id, "inline", events);
|
|
3433
|
+
const appendDispatch = async (...events) => appendExternal(id, "dispatch", events);
|
|
3434
|
+
const append = Object.assign(appendInline, { dispatch: appendDispatch });
|
|
3435
|
+
return Object.assign(makeSession(id, append, null), {
|
|
3436
|
+
deadLetters: () => listDeadLetters(id),
|
|
3437
|
+
redrive: (target, redriveOptions) => redriveEvent(id, target, redriveOptions)
|
|
3438
|
+
});
|
|
3439
|
+
};
|
|
3440
|
+
const historyPage = async (sessionId, bounds) => {
|
|
3441
|
+
assertSessionId(sessionId);
|
|
3442
|
+
const rows = await readHistory(sessionId, bounds);
|
|
3443
|
+
const marker = rows[0] && isSessionCleared(rows[0]) ? rows[0] : void 0;
|
|
3444
|
+
return {
|
|
3445
|
+
events: (marker ? rows.slice(1) : rows).map(toPublic),
|
|
3446
|
+
...marker ? { clearIndex: marker.index } : {}
|
|
3447
|
+
};
|
|
3448
|
+
};
|
|
3449
|
+
const self = {
|
|
3450
|
+
contract: serverContract,
|
|
3451
|
+
fetch: createServerFetch({
|
|
3452
|
+
contract: serverContract,
|
|
3453
|
+
session,
|
|
3454
|
+
historyPage
|
|
3455
|
+
}),
|
|
3456
|
+
session,
|
|
3457
|
+
async drain(sessionId) {
|
|
3458
|
+
assertSessionId(sessionId);
|
|
3459
|
+
const { settled } = await drainSession(sessionId);
|
|
3460
|
+
return { settled };
|
|
3461
|
+
}
|
|
3462
|
+
};
|
|
3463
|
+
serverInternals.set(self, {
|
|
3464
|
+
settle: async () => {
|
|
3465
|
+
while (inFlight.size > 0) await Promise.allSettled(inFlight);
|
|
3466
|
+
},
|
|
3467
|
+
schedulerDrain: (sessionId, opts) => {
|
|
3468
|
+
assertSessionId(sessionId);
|
|
3469
|
+
return drainSession(sessionId, {
|
|
3470
|
+
watchdogDueAt: opts?.watchdogDueAt,
|
|
3471
|
+
handOffFailure: true
|
|
3472
|
+
});
|
|
3473
|
+
},
|
|
3474
|
+
schedulerAppend: async (sessionId, events, originIndex) => {
|
|
3475
|
+
if (!scheduler) throw new TypeError("scheduled append delivery requires a configured scheduler adapter");
|
|
3476
|
+
assertSessionId(sessionId);
|
|
3477
|
+
try {
|
|
3478
|
+
await appendExternal(sessionId, "inline", [...events], true, originIndex);
|
|
3479
|
+
} catch (error) {
|
|
3480
|
+
if (originIndex !== void 0 && error instanceof A2Error && error.code === "SESSION_CLEARED") return;
|
|
3481
|
+
throw error;
|
|
3482
|
+
}
|
|
3483
|
+
},
|
|
3484
|
+
historyWithMarkers: async (sessionId, gte) => (await readHistory(sessionId, { gte })).map(toPublic),
|
|
3485
|
+
schedulerClear: async (sessionId, ifLastIndex) => {
|
|
3486
|
+
assertSessionId(sessionId);
|
|
3487
|
+
assertClearIndex(ifLastIndex);
|
|
3488
|
+
await clearSession(sessionId, { ifLastIndex }, void 0);
|
|
3489
|
+
}
|
|
3490
|
+
});
|
|
3491
|
+
return self;
|
|
3492
|
+
}
|
|
3493
|
+
function assertSessionId(id) {
|
|
3494
|
+
if (typeof id !== "string" || id.length === 0) throw new TypeError("session id must be a non-empty string");
|
|
3495
|
+
if (id.includes(NS)) throw new TypeError("session id contains a reserved control character");
|
|
3496
|
+
}
|
|
3497
|
+
function assertHistoryIndex(name, value) {
|
|
3498
|
+
if (value !== void 0 && (!Number.isSafeInteger(value) || value < 0)) throw new TypeError(`history.${name} must be a non-negative safe integer`);
|
|
3499
|
+
}
|
|
3500
|
+
function assertStreamIndex(value) {
|
|
3501
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("stream.startAfter must be a non-negative safe integer");
|
|
3502
|
+
}
|
|
3503
|
+
function assertStateIndex(value) {
|
|
3504
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("state.through must be a non-negative safe integer");
|
|
3505
|
+
}
|
|
3506
|
+
function assertConsumeIndex(value) {
|
|
3507
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("consume through must be a non-negative safe integer");
|
|
3508
|
+
}
|
|
3509
|
+
function assertRedriveIndex(value) {
|
|
3510
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError("redrive requires a positive safe-integer index");
|
|
3511
|
+
}
|
|
3512
|
+
function assertClearIndex(value) {
|
|
3513
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError("ifLastIndex must be a non-negative safe integer");
|
|
3514
|
+
}
|
|
3515
|
+
function assertAppendName(name) {
|
|
3516
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError("consumer append name must be a non-empty string");
|
|
3517
|
+
}
|
|
3518
|
+
function assertScheduleName(name) {
|
|
3519
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError("schedule name must be a non-empty string");
|
|
3520
|
+
}
|
|
3521
|
+
const SCHEDULE_DELAY = /^(?:0|[1-9]\d*)(?:\.\d+)?(ms|s|m|h|d)$/;
|
|
3522
|
+
const SCHEDULE_DELAY_MULTIPLIER = {
|
|
3523
|
+
ms: 1,
|
|
3524
|
+
s: 1e3,
|
|
3525
|
+
m: 6e4,
|
|
3526
|
+
h: 36e5,
|
|
3527
|
+
d: 864e5
|
|
3528
|
+
};
|
|
3529
|
+
function scheduleDueAt(timing, baseTime) {
|
|
3530
|
+
if (timing === null || typeof timing !== "object" || Array.isArray(timing)) throw new TypeError("schedule timing must contain exactly one of 'delay' or 'at'");
|
|
3531
|
+
const record = timing;
|
|
3532
|
+
const hasDelay = Object.hasOwn(record, "delay");
|
|
3533
|
+
const hasAt = Object.hasOwn(record, "at");
|
|
3534
|
+
if (hasDelay === hasAt) throw new TypeError("schedule timing must contain exactly one of 'delay' or 'at'");
|
|
3535
|
+
let dueAt;
|
|
3536
|
+
if (hasAt) {
|
|
3537
|
+
const at = record["at"];
|
|
3538
|
+
if (!(at instanceof Date) || !Number.isFinite(at.getTime())) throw new TypeError("schedule at must be a valid Date");
|
|
3539
|
+
dueAt = at.getTime();
|
|
3540
|
+
} else {
|
|
3541
|
+
const delay = record["delay"];
|
|
3542
|
+
if (typeof delay !== "string") throw new TypeError("schedule delay must use ms, s, m, h, or d (for example '30s')");
|
|
3543
|
+
const match = SCHEDULE_DELAY.exec(delay);
|
|
3544
|
+
const amount = match ? Number(delay.slice(0, -match[1].length)) : NaN;
|
|
3545
|
+
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')");
|
|
3546
|
+
dueAt = baseTime + amount * SCHEDULE_DELAY_MULTIPLIER[match[1]];
|
|
3547
|
+
}
|
|
3548
|
+
if (!Number.isFinite(dueAt)) throw new TypeError("schedule due time must be a finite epoch time");
|
|
3549
|
+
return dueAt;
|
|
3550
|
+
}
|
|
3551
|
+
function assertScheduledPayload(payload) {
|
|
3552
|
+
try {
|
|
3553
|
+
if (isJsonValue(payload, /* @__PURE__ */ new WeakSet())) return;
|
|
3554
|
+
} catch {}
|
|
3555
|
+
throw new TypeError("session.schedule event payload must be JSON-serializable");
|
|
3556
|
+
}
|
|
3557
|
+
function cloneScheduledPayload(payload) {
|
|
3558
|
+
assertScheduledPayload(payload);
|
|
3559
|
+
try {
|
|
3560
|
+
const cloned = structuredClone(payload);
|
|
3561
|
+
const encoded = JSON.stringify(cloned);
|
|
3562
|
+
if (encoded === void 0) throw new TypeError();
|
|
3563
|
+
return JSON.parse(encoded);
|
|
3564
|
+
} catch {
|
|
3565
|
+
throw new TypeError("session.schedule event payload must be JSON-serializable");
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
function isJsonValue(value, ancestors) {
|
|
3569
|
+
if (value === null) return true;
|
|
3570
|
+
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
3571
|
+
if (typeof value === "number") return Number.isFinite(value) && !Object.is(value, -0);
|
|
3572
|
+
if (typeof value !== "object") return false;
|
|
3573
|
+
if (ancestors.has(value)) return false;
|
|
3574
|
+
ancestors.add(value);
|
|
3575
|
+
try {
|
|
3576
|
+
if (Array.isArray(value)) {
|
|
3577
|
+
const keys = Reflect.ownKeys(value);
|
|
3578
|
+
if (keys.length !== value.length + 1 || !keys.includes("length")) return false;
|
|
3579
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
3580
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
3581
|
+
if (!descriptor || !descriptor.enumerable || !("value" in descriptor) || !isJsonValue(descriptor.value, ancestors)) return false;
|
|
3582
|
+
}
|
|
3583
|
+
return true;
|
|
3584
|
+
}
|
|
3585
|
+
if (Object.getPrototypeOf(value) !== Object.prototype) return false;
|
|
3586
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
3587
|
+
if (typeof key !== "string") return false;
|
|
3588
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
3589
|
+
if (!descriptor || !descriptor.enumerable || !("value" in descriptor) || !isJsonValue(descriptor.value, ancestors)) return false;
|
|
3590
|
+
}
|
|
3591
|
+
return true;
|
|
3592
|
+
} finally {
|
|
3593
|
+
ancestors.delete(value);
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
const measuredBytes = (reducer, state, index, snapshots) => JSON.stringify(snapshots.find((snapshot) => snapshot.index === index)?.state ?? encodeState({
|
|
3597
|
+
reducer,
|
|
3598
|
+
state
|
|
3599
|
+
}))?.length ?? 0;
|
|
3600
|
+
const stateCacheKey = (sessionId, reducerName) => `${sessionId}\u0000${reducerName}`;
|
|
3601
|
+
//#endregion
|
|
3602
|
+
export { deliverSchedulerClear as i, createServer as n, deliverSchedulerAppend as r, consumer as t };
|