holon-sh 0.0.1-dev.12 → 0.0.1-dev.14

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/src/ext/events.ts CHANGED
@@ -1,19 +1,14 @@
1
1
  // The server side of MCP Events, as the Triggers & Events Working Group's design sketch defines it:
2
2
  // https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md
3
- // The draft is experimental, so this lives under ext and may change with it.
4
- // It serves the poll and push delivery modes, and webhook delivery when given a webhookTtl, from a module of its own
5
- // that reaches the event types through the internals below. Names follow the sketch, and types are the MCP SDK's own
6
- // wherever it has them.
3
+ // The draft is experimental, so this lives under ext and may change with it. It serves the poll and push delivery
4
+ // modes. Names follow the sketch, and types are the MCP SDK's own wherever it has them.
7
5
  import { ProtocolError, ProtocolErrorCode, SUBSCRIPTION_ID_META_KEY } from "@modelcontextprotocol/server";
8
6
  import type { AuthInfo, JSONRPCErrorResponse, McpServer, Server, StandardSchemaWithJSON } from "@modelcontextprotocol/server";
9
- import { webhooks, type WebhookOptions } from "./webhooks.js";
10
-
11
- export type { WebhookOptions } from "./webhooks.js";
12
7
 
13
8
  /** The request a subscription came in, as Holon admitted it: read its caller with getContext. */
14
9
  export type SubscriptionContext = { authInfo?: AuthInfo | undefined } | undefined;
15
10
  /** How a subscription receives its events. */
16
- export type DeliveryModeName = "poll" | "push" | (string & {});
11
+ export type DeliveryModeName = "poll" | "push";
17
12
  /** A JSON-RPC error, as a subscription that is terminated or fails is told it. */
18
13
  export type EventError = JSONRPCErrorResponse["error"];
19
14
  /** A subscription as it begins: who asked, for what, and by which delivery mode. */
@@ -39,27 +34,7 @@ export interface EventOccurrence<D = unknown> {
39
34
  /** When it happened, in ISO 8601. */
40
35
  timestamp: string;
41
36
  data: D;
42
- cursor?: string | null;
43
- _meta?: Record<string, unknown>;
44
- }
45
- /**
46
- * What happened after a cursor, as a check function finds it: the sketch's EventResult. Each event is an
47
- * EventOccurrence whose name is the type's, and whose `eventId` (the source's own, so a client drops one it has seen)
48
- * and `timestamp` are made up when not given. An event's `cursor`, the position just after it, lets a batch be cut
49
- * short; without it a batch is delivered whole.
50
- */
51
- export interface EventResult<D> {
52
- /**
53
- * Empty when the cursor was null: a null cursor asks only for the current position. A source without positions
54
- * answers with a null cursor, is asked with null each time, and answers each time with what is new.
55
- */
56
- events: Array<Omit<EventOccurrence<D>, "name" | "eventId" | "timestamp" | "cursor"> & { eventId?: string; timestamp?: string; cursor?: string }>;
57
- /** The position after these events, or null when the source cannot be resumed from a position. */
58
- cursor: string | null;
59
- /** Events between the cursor and these were skipped, for instance because the source no longer keeps them. */
60
- truncated?: boolean;
61
- /** More events are ready at once after this batch. */
62
- hasMore?: boolean;
37
+ cursor?: string;
63
38
  }
64
39
  /** An event type: the arguments a subscription gives, and the data each occurrence carries. */
65
40
  export interface EventDefinition<A, D> {
@@ -79,14 +54,7 @@ export interface EventDefinition<A, D> {
79
54
  match?: (data: D, args: A, context: SubscriptionContext) => boolean;
80
55
  /** The data a subscription with these arguments receives of an emitted event. Without it, the data as emitted. */
81
56
  transform?: (data: D, args: A, context: SubscriptionContext) => unknown;
82
- /**
83
- * What happened after a cursor, for a source that keeps its own history: polls are answered by it, and streams ask
84
- * it every second. The cursor is the source's own, so it outlives the process. A null cursor asks for the
85
- * current position. The function filters and shapes for the arguments itself; `match` and `transform` apply only to
86
- * what is emitted. Without it, emitted events are kept in memory and polls and streams are served from there.
87
- */
88
- check?: (cursor: string | null, args: A, context: SubscriptionContext) => EventResult<D> | Promise<EventResult<D>>;
89
- /** The delivery modes this event type offers. Every mode served unless given, except webhook for a type with a check function, which webhook delivery does not read. */
57
+ /** The delivery modes this event type offers: poll and push unless given. */
90
58
  delivery?: DeliveryModeName[];
91
59
  /**
92
60
  * Whether the caller may subscribe with these arguments. A subscription is refused with Forbidden when it may not,
@@ -107,86 +75,83 @@ export interface EventDefinition<A, D> {
107
75
  onUnsubscribe?: (subscription: Subscription<A>) => void | Promise<void>;
108
76
  }
109
77
  /** Emits occurrences of one event type to the subscriptions they match, and ends its subscriptions. */
110
- export interface Emitter<D> {
78
+ export interface Emitter<D, A = unknown> {
111
79
  /**
112
80
  * `eventId` lets a client drop an occurrence it has seen. Give the source's own id when it has one. With
113
- * `subscriptionId`, the sketch's targeted emit, the occurrence goes to that subscription alone, whatever its arguments; it is kept like any
114
- * other, so a subscription that reconnects or polls again from its cursor still has it, or is told of a gap. An
115
- * event type with a check function pushes what it emits to open streams at once and does not keep it: polls and
116
- * reconnecting streams learn of it from the check function, and one emitted to a subscription that does not get it
117
- * on a stream is lost, so that subscription's next poll or stream is told of a gap.
81
+ * `subscriptionId`, the sketch's targeted emit, the occurrence goes to that subscription alone, whatever its
82
+ * arguments; it is kept like any other, so a subscription that reconnects or polls again from its cursor still has
83
+ * it, or is told of a gap.
118
84
  *
119
85
  * Emitting is synchronous. Occurrences emitted one after another in the same synchronous run reach each
120
86
  * subscription in that order, with no other occurrence of this type between them, whether polled or pushed. So a
121
87
  * group of changes that nothing may come between is either emitted in one run or sent as one occurrence holding a
122
88
  * list. A subscription skips part of a group only when it is told of a gap: the group outgrew the events kept, or
123
- * the subscription fell behind them. For a type with a check function the order holds among what is emitted, but
124
- * not between what is emitted and what the check function finds, which may arrive later or again.
89
+ * the subscription fell behind them.
125
90
  *
126
91
  * The data is serialised when it is emitted, so data that cannot be sent as JSON throws here, before anything is
127
92
  * kept or sent.
128
93
  */
129
94
  emit(data: D, options?: { eventId?: string; timestamp?: string; subscriptionId?: string }): void;
130
95
  /**
131
- * The position after the last occurrence emitted, for a type without a check function. Return it with the state
132
- * a tool reads, in the same synchronous run, and a client that subscribes from it hears every change after that
133
- * state and none before. A type with a check function has no such position here: its cursor is the source's own,
134
- * so read the source's head instead. This throws for such a type.
96
+ * The position after the last occurrence emitted. Return it with the state a tool reads, in the same synchronous
97
+ * run, and a client that subscribes from it hears every change after that state and none before.
135
98
  */
136
99
  cursor(): string;
137
100
  /**
138
- * Terminates one subscription, or all of this type's: a stream is sent `notifications/events/terminated` with
139
- * `error` and closed, and the next poll is answered with it. Forbidden unless given.
101
+ * Terminates one subscription, or all of this type's: a stream is sent what was emitted to it before, then
102
+ * `notifications/events/terminated` with `error`, and closed. The next poll is answered with the error. Forbidden
103
+ * unless given, which ends delivery at once, since access is lost.
140
104
  */
141
105
  terminate(subscriptionId?: string, error?: EventError): void;
142
106
  /**
143
107
  * Stops offering this event type: its subscriptions end with NotFound.
144
108
  */
145
109
  remove(): void;
146
- }
147
- export interface EventsOptions {
148
110
  /**
149
- * The sketch's webhook_ttl, which turns webhook delivery on: the longest lifetime granted to a webhook
150
- * subscription, in milliseconds. A client's suggestion is held to it, and every grant expires. Without it, webhook
151
- * delivery is not offered.
111
+ * Keeps a value for each subscriber, such as its status, for as long as it subscribes. A subscriber is named by its
112
+ * subscription's arguments, so give each one its own, such as an id its client makes: subscriptions with the same
113
+ * arguments, such as one client's overlapping streams, are one subscriber. `told` makes the data emitted when a
114
+ * value is set, and, given none, when it leaves.
152
115
  */
153
- webhookTtl?: number;
154
- /** For tests of webhook delivery. */
155
- webhook?: WebhookOptions;
116
+ presence<V>(told: (args: A, value: V | undefined) => D): Presence<A, V>;
156
117
  }
157
- /** A delivery mode besides poll and push: webhook. It registers its requests on each server and is listed by events/list. Not for applications. */
158
- export interface DeliveryMode {
159
- readonly name: string;
160
- serve(server: object, internals: Internals): void;
118
+ /** Each subscriber's value, kept while it subscribes. */
119
+ export interface Presence<A, V> {
120
+ /**
121
+ * Sets the value of the subscriber these arguments name, and emits it. It stays while a subscription with these
122
+ * arguments is open, by either delivery mode, and leaves `linger` milliseconds after the last one ends or after it is
123
+ * last set, whichever is later. So a subscriber that reconnects within the linger keeps it, and a caller that cannot
124
+ * subscribe keeps it by setting it again.
125
+ */
126
+ set(args: A, value: V, linger: number): void;
127
+ /** The values a subscription with these arguments is shown: those whose data `match` lets it receive, asked with no context. */
128
+ seen(args: A): V[];
161
129
  }
162
130
  /** The event types of a process, served by each MCP server it creates. */
163
131
  export interface Events {
164
132
  /** Declares an event type. Emit its occurrences with the returned emitter. */
165
- event<A, D>(name: string, definition: EventDefinition<A, D>): Emitter<D>;
133
+ event<A, D>(name: string, definition: EventDefinition<A, D>): Emitter<D, A>;
166
134
  /** Serves events/list, events/poll and events/stream on an McpServer or a Server, and declares the extension. */
167
135
  serve(server: McpServer | Server): void;
168
- /** What a delivery mode works with. Not for applications. */
169
- readonly [INTERNALS]: Internals;
170
136
  }
171
137
 
172
- /** An emitted event: kept, at a sequence, for a type without a check function; pushed only, for one with. */
173
- export interface Kept { sequence?: number; at?: number; target?: string | undefined; occurrence: EventOccurrence }
174
- /** What a subscription is owed after a cursor. */
175
- export interface Owed {
176
- events: EventOccurrence[];
177
- /** Events that could not be shaped for it, and were skipped. */
178
- failures?: EventError[];
179
- /** Where delivery starts. */
180
- start: string | null;
181
- cursor: string | null;
182
- truncated: boolean;
183
- hasMore: boolean;
138
+ /** The identifier a server declares the Events extension under, in capabilities.extensions. */
139
+ export const EVENTS_EXTENSION = "io.modelcontextprotocol/events";
140
+
141
+ // The parts of an MCP server and of a request's context this module uses.
142
+ interface Served {
143
+ registerCapabilities(capabilities: object): void;
144
+ setRequestHandler(method: string, schemas: object, handler: (request: any, ctx: Context) => unknown): void;
184
145
  }
185
- /** A subscription as a delivery mode holds it. */
186
- export interface Held {
146
+ interface Context {
147
+ mcpReq: { id: string | number; signal: AbortSignal; notify(notification: { method: string; params: object }): Promise<void> };
148
+ http?: SubscriptionContext & object;
149
+ }
150
+ /** An emitted event, kept at its sequence for polls and reconnecting streams. */
151
+ interface Kept { sequence: number; at: number; target: string | undefined; occurrence: EventOccurrence }
152
+ /** A subscription held by a push stream or a poll lease. */
153
+ interface Held {
187
154
  readonly id: string;
188
- readonly name: string;
189
- readonly delivery: DeliveryModeName;
190
155
  readonly arguments: unknown;
191
156
  readonly context: SubscriptionContext;
192
157
  /** Nothing is emitted to it alone: a poll lease shared by callers without an identity. */
@@ -196,58 +161,18 @@ export interface Held {
196
161
  /** Says the hold is over. */
197
162
  close(): void;
198
163
  }
164
+ /** Who an emitted event is shaped for. */
165
+ type Reader = Pick<Held, "id" | "arguments" | "context" | "untargetable">;
199
166
  /** The subscription a request asks for, checked. */
200
- export interface Requested { id: string; lease: string; arguments: unknown; context: SubscriptionContext; untargetable?: boolean }
201
- /** A declared event type, as the delivery modes see it. */
202
- export interface EventTypeState {
167
+ interface Requested { id: string; lease: string; arguments: unknown; context: SubscriptionContext }
168
+ interface Type<A = any, D = any> {
203
169
  readonly name: string;
204
- readonly delivery: DeliveryModeName[];
205
- readonly interval: number;
206
- readonly check?: EventDefinition<unknown, unknown>["check"];
207
- /** Whether the caller may hold the subscription, which a mode that holds subscriptions long asks again. */
208
- authorize?(args: unknown, context: SubscriptionContext): boolean | Promise<boolean>;
209
- /** Told each event as it is emitted: a push stream is one, and a delivery mode adds its own. */
210
- readonly sinks: Set<(kept: Kept) => void>;
211
- /** The subscriptions held by every mode, by id. */
212
- readonly subscriptions: Map<string, { held: Set<Held>; ready: Promise<unknown> }>;
213
- }
214
- /** What a delivery mode works with. */
215
- export interface Internals {
216
- readonly types: Map<string, EventTypeState>;
217
- /** Checks a request, names its event type and the subscription's id. */
218
- readonly subscription: (request: Record<string, unknown>, context: SubscriptionContext, delivery?: DeliveryModeName) => Promise<{ type: EventTypeState; subscription: Requested }>;
219
- /** Begins holding a subscription. */
220
- readonly open: (type: EventTypeState, subscription: { id: string; delivery: DeliveryModeName; arguments: unknown; context: SubscriptionContext }, end: (error: EventError) => void) => Promise<Held>;
221
- /** What a subscription is owed after a cursor. */
222
- readonly read: (type: EventTypeState, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, cursor: string | null | undefined, options?: { maxAgeMs?: number; maxEvents?: number }) => Owed | Promise<Owed>;
223
- /** What a subscription is owed of the events emitted. */
224
- readonly since: (type: EventTypeState, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, cursor: string | null | undefined, maxAgeMs?: number, limit?: number) => Owed;
225
- /** An emitted event as one subscription receives it, or nothing when it is not for it. */
226
- readonly shape: (type: EventTypeState, kept: Kept, subscription: Pick<Held, "id" | "arguments" | "context" | "untargetable">, failed?: (error: EventError) => void) => EventOccurrence | undefined;
227
- /** A type's current cursor. */
228
- readonly position: (type: EventTypeState, sequence?: number) => string;
229
- }
230
-
231
- /** The identifier a server declares the Events extension under, in capabilities.extensions. */
232
- export const EVENTS_EXTENSION = "io.modelcontextprotocol/events";
233
- /** Reaches what a delivery mode needs of the events it serves. Not for applications. */
234
- export const INTERNALS: unique symbol = Symbol.for("holon-sh.events.internals");
235
-
236
- // The parts of an MCP server and of a request's context this module uses.
237
- interface Served {
238
- registerCapabilities(capabilities: object): void;
239
- setRequestHandler(method: string, schemas: object, handler: (request: any, ctx: Context) => unknown): void;
240
- }
241
- interface Context {
242
- mcpReq: { id: string | number; signal: AbortSignal; notify(notification: { method: string; params: object }): Promise<void> };
243
- http?: SubscriptionContext & object;
244
- }
245
- interface Type<A = any, D = any> extends EventTypeState {
246
170
  readonly order: number;
247
171
  readonly description?: string | undefined;
248
172
  readonly inputSchema: StandardSchemaWithJSON<unknown, A>;
249
173
  readonly payloadSchema: StandardSchemaWithJSON;
250
174
  readonly _meta?: Record<string, unknown> | undefined;
175
+ readonly delivery: DeliveryModeName[];
251
176
  readonly match?: EventDefinition<A, D>["match"];
252
177
  readonly transform?: EventDefinition<A, D>["transform"];
253
178
  readonly authorize?: EventDefinition<A, D>["authorize"];
@@ -255,11 +180,17 @@ interface Type<A = any, D = any> extends EventTypeState {
255
180
  readonly onUnsubscribe?: EventDefinition<A, D>["onUnsubscribe"];
256
181
  sequence: number;
257
182
  dropped: number;
258
- kept: Array<Kept & { sequence: number; at: number }>;
259
- /** Subscriptions owed word of a gap, for events emitted to them alone that they could not be given. */
260
- owed: Map<string, true>;
183
+ kept: Kept[];
184
+ /** Woken by each emitted event: one for each push stream. */
185
+ readonly sinks: Set<() => void>;
186
+ /** The subscriptions held by streams and poll leases, by id. */
187
+ readonly subscriptions: Map<string, { held: Set<Held>; ready: Promise<Subscription<A>> }>;
188
+ /** How many streams and poll leases are open, by the subscriber their arguments name. */
189
+ readonly holds: Map<string, number>;
190
+ /** Told when a subscriber's holds change: one for each presence. */
191
+ readonly present: Set<(subscriber: string) => void>;
261
192
  }
262
- interface Lease { type: Type; opening?: Promise<Held>; ended?: EventError; release?: () => void; timer?: ReturnType<typeof setTimeout> }
193
+ interface Lease { type: Type; opening: Promise<Held>; ended?: EventError; release?: () => void; timer?: ReturnType<typeof setTimeout> }
263
194
 
264
195
  const INVALID_PARAMS = ProtocolErrorCode.InvalidParams;
265
196
  const INTERNAL_ERROR = ProtocolErrorCode.InternalError;
@@ -294,22 +225,30 @@ const caller = (context: SubscriptionContext): string | null => {
294
225
  };
295
226
  const hex = (bytes: Uint8Array) => [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join("");
296
227
  // A subscription's id is the same for the same caller, event type and arguments, so it survives reconnects and poll
297
- // leases, as a webhook subscription's does. It is made with the global crypto, which needs no node:crypto at load.
228
+ // leases. It is made with the global crypto, which needs no node:crypto at load.
298
229
  const identify = async (who: string | null, name: string, args: unknown) => {
299
230
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([who, name, canonical(args)])));
300
231
  return `sub_${hex(new Uint8Array(digest, 0, 16))}`;
301
232
  };
302
233
  // A caller without an identity gets an id of its own for each stream.
303
234
  const unique = () => `sub_${hex(crypto.getRandomValues(new Uint8Array(16)))}`;
235
+ // The subscriber a subscription's arguments name, whoever the caller.
236
+ const named = (args: unknown) => JSON.stringify(canonical(args));
237
+ // Counts up or down under a key, and forgets a key that reaches none.
238
+ const count = (counts: Map<string, number>, key: string, by: number) => {
239
+ const total = (counts.get(key) ?? 0) + by;
240
+ if (total) counts.set(key, total);
241
+ else counts.delete(key);
242
+ };
304
243
  // A teardown that fails has nobody to tell, and must not take the process down.
305
244
  const settle = (run: () => unknown) => { Promise.resolve().then(run).catch(() => {}); };
306
245
  const integer = (value: unknown, minimum: number) => value === undefined || Number.isInteger(value) && (value as number) >= minimum;
307
246
 
308
- // What each type without a check function keeps of what it emits, for polls and reconnecting streams: the sketch's
309
- // emit buffer.
247
+ const OFFERED: DeliveryModeName[] = ["poll", "push"];
248
+ // What each type keeps of what it emits, for polls and reconnecting streams: the sketch's emit buffer.
310
249
  const KEPT_EVENTS = 10_000;
311
250
  const KEPT_AGE = 10 * 60_000;
312
- // How long a poll is told to wait before the next, and how often a stream asks a check function.
251
+ // How long a poll is told to wait before the next.
313
252
  const INTERVAL = 1000;
314
253
  // How often a stream hears from the server when nothing happens, as the sketch recommends.
315
254
  const HEARTBEAT = 30_000;
@@ -332,13 +271,10 @@ const STREAM_BUDGET = 2 ** 20;
332
271
  * Event types a process emits and the MCP servers it creates serve, in the poll and push delivery modes of MCP Events,
333
272
  * the draft extension of the Triggers & Events Working Group. Each type keeps the last ten thousand events it emits,
334
273
  * for ten minutes, for polls and for streams that reconnect: those cursors last as long as the process, and after a
335
- * restart a client is told that events were skipped. A type with a check function uses its source's cursors instead,
336
- * which outlive the process. A type is asked again, and a poll told to come back, every second. A stream hears from
337
- * the server every thirty seconds.
274
+ * restart a client is told that events were skipped. A poll is told to come back every second, and a stream hears
275
+ * from the server every thirty seconds.
338
276
  */
339
- export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Events {
340
- const modes = webhookTtl === undefined ? [] : [webhooks({ ...webhook, ttl: webhookTtl, heartbeat: HEARTBEAT })];
341
- const offered = ["poll", "push", ...modes.map(mode => mode.name)];
277
+ export function createEvents(): Events {
342
278
  // Cursors are positions in this process's memory. One from another process names nothing here. The name is made
343
279
  // without node:crypto, which is not there when a server is evaluated for its contract.
344
280
  const epoch = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
@@ -347,33 +283,23 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
347
283
  let subscribed = 0;
348
284
  const callers = new Map<string, number>();
349
285
 
350
- const position = (type: EventTypeState, sequence: number = (type as Type).sequence) => `${epoch}.${sequence}`;
286
+ const position = (type: Type, sequence = type.sequence) => `${epoch}.${sequence}`;
351
287
 
352
- // An event emitted to one subscription that it could not be given is owed to it as a gap, on its next poll or
353
- // stream. The ids owed one are as many as the events a type keeps.
354
- const owe = (type: Type, id: string) => {
355
- type.owed.delete(id);
356
- type.owed.set(id, true);
357
- if (type.owed.size > KEPT_EVENTS) type.owed.delete(type.owed.keys().next().value!);
358
- };
359
-
360
- // What a subscription is owed after a cursor from the events emitted: the ones for it, in order, where delivery
361
- // starts, and where it now stands. A null cursor starts from now, and so does one from another process. One past
362
- // the events still kept starts from the oldest kept, and says that events were skipped.
363
- const since: Internals["since"] = (state, subscription, cursor, maxAgeMs, limit = Infinity) => {
364
- const type = state as Type;
365
- const now = { events: [], start: position(type), cursor: position(type), hasMore: false };
366
- if (cursor == null) return { ...now, truncated: false };
367
- const [process, text] = cursor.split(".");
368
- const sequence = Number(text);
369
- if (process !== epoch || !Number.isInteger(sequence) || sequence > type.sequence) return { ...now, truncated: true };
370
- const floor = maxAgeMs === undefined ? -Infinity : Date.now() - maxAgeMs;
371
- const later = type.kept.filter(kept => kept.sequence > sequence);
288
+ // What a subscription is owed after a cursor, up to `until`: the events for it, in order, where delivery starts,
289
+ // and where it now stands. A null cursor starts where the subscription was opened, and so does one from another
290
+ // process, which says that events were skipped. One past the events still kept starts from the oldest kept, and
291
+ // says so too.
292
+ const since = (type: Type, subscription: Reader, cursor: string | null | undefined, { maxAgeMs, maxEvents: limit = Infinity, opened = type.sequence, until = type.sequence }: { maxAgeMs?: number; maxEvents?: number; opened?: number; until?: number } = {}) => {
293
+ const [process, text] = cursor?.split(".") ?? [];
294
+ const resumed = cursor != null && process === epoch && Number.isInteger(Number(text)) && Number(text) <= type.sequence;
295
+ const sequence = resumed ? Number(text) : opened;
296
+ const floor = maxAgeMs === undefined || !resumed ? -Infinity : Date.now() - maxAgeMs;
297
+ const later = type.kept.filter(kept => kept.sequence > sequence && kept.sequence <= until);
372
298
  const recent = later.filter(kept => kept.at >= floor);
373
- const truncated = sequence < type.dropped || recent.length < later.length;
299
+ const truncated = cursor != null && !resumed || sequence < type.dropped || recent.length < later.length;
374
300
  const events: EventOccurrence[] = [];
375
301
  const failures: EventError[] = [];
376
- let reached = recent.length ? recent[0]!.sequence - 1 : type.sequence;
302
+ let reached = recent.length ? recent[0]!.sequence - 1 : until;
377
303
  const start = position(type, reached);
378
304
  for (const kept of recent) {
379
305
  if (events.length === limit) return { events, failures, start, cursor: position(type, reached), truncated, hasMore: true };
@@ -381,82 +307,36 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
381
307
  const event = shape(type, kept, subscription, error => failures.push(error));
382
308
  if (event) events.push(event);
383
309
  }
384
- return { events, failures, start, cursor: position(type), truncated, hasMore: false };
385
- };
386
-
387
- // What a subscription is owed after a cursor from the type's check function. The cursor is the author's own. Each
388
- // event is placed at its own cursor when it has one, so a batch can be cut short; otherwise at the cursor before
389
- // the batch, which is safe to resume from. After a gap, delivery starts from the position the check reached.
390
- const checked = async (type: Type, { arguments: args, context }: Pick<Held, "arguments" | "context">, cursor: string | null | undefined, maxAgeMs?: number, limit = Infinity): Promise<Owed> => {
391
- const result = await type.check!(cursor ?? null, args, context);
392
- const next = result?.cursor ?? null;
393
- // A null cursor asks for the current position and nothing before it, except of a source that has no positions:
394
- // each check of that is asked with null, and answers with what is new.
395
- if (cursor == null && next !== null) return { events: [], start: next, cursor: next, truncated: false, hasMore: false };
396
- const items = result?.events ?? [];
397
- const floor = maxAgeMs === undefined || cursor == null ? -Infinity : Date.now() - maxAgeMs;
398
- let truncated = next !== null && Boolean(result.truncated);
399
- const events: EventOccurrence[] = [];
400
- const failures: EventError[] = [];
401
- let reached = cursor ?? null;
402
- let cuttable = false;
403
- const answer = (end: string | null, hasMore: boolean): Owed => ({ events, failures, start: truncated || !events.length ? end : cursor ?? null, cursor: end, truncated, hasMore });
404
- for (const [index, item] of items.entries()) {
405
- if (events.length === limit && cuttable) return answer(reached, true);
406
- const after = index === items.length - 1 ? next : item.cursor ?? reached;
407
- const { eventId = crypto.randomUUID(), timestamp = new Date().toISOString(), data, _meta } = item;
408
- if (Date.parse(timestamp) < floor) truncated = next !== null;
409
- else if (serialisable(type, eventId, { data, _meta }, error => failures.push(error))) events.push({ eventId, name: type.name, timestamp, data, ...(_meta && { _meta }), cursor: after });
410
- reached = after;
411
- cuttable = item.cursor != null;
412
- }
413
- return answer(next, Boolean(result.hasMore));
310
+ return { events, failures, start, cursor: position(type, until), truncated, hasMore: false };
414
311
  };
415
312
 
416
- // What a subscription is owed after a cursor, whichever way its type learns of events.
417
- const read: Internals["read"] = (type, subscription, cursor, { maxAgeMs, maxEvents } = {}) =>
418
- type.check ? checked(type as Type, subscription, cursor, maxAgeMs, maxEvents) : since(type, subscription, cursor, maxAgeMs, maxEvents);
419
-
420
313
  // The occurrence a subscription receives, or nothing when it is not for it. An event emitted to one subscription
421
314
  // is for that one alone, and its arguments are not asked.
422
315
  // An event that cannot be shaped for a subscription, because `match` or `transform` throws or what `transform`
423
316
  // returns cannot be sent as JSON, is skipped for that subscription alone, so its cursor moves past it. It is
424
317
  // logged, and `failed` hears of it so a stream can say so once.
425
- const shape: Internals["shape"] = (state, { sequence, target, occurrence }, { id, untargetable, arguments: args, context }, failed) => {
426
- const type = state as Type;
318
+ const shape = (type: Type, { sequence, target, occurrence }: Kept, { id, untargetable, arguments: args, context }: Reader, failed: (error: EventError) => void): EventOccurrence | undefined => {
427
319
  let data: unknown;
428
320
  try {
429
321
  if (target !== undefined ? untargetable || target !== id : type.match && !type.match(occurrence.data, args, context)) return undefined;
430
322
  data = type.transform ? type.transform(occurrence.data, args, context) : occurrence.data;
431
323
  if (type.transform) JSON.stringify(data);
432
- } catch (error) {
433
- skipped(type, occurrence.eventId, error, failed);
324
+ } catch (error: any) {
325
+ const { eventId } = occurrence;
326
+ console.error(`holon-sh/ext/events: event ${eventId} of ${type.name} was skipped for a subscription:`, error);
327
+ failed({ code: INTERNAL_ERROR, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
434
328
  return undefined;
435
329
  }
436
- return { ...occurrence, data, ...(sequence !== undefined && { cursor: position(type, sequence) }) };
437
- };
438
- const skipped = (type: Type, eventId: string, error: any, failed?: (error: EventError) => void) => {
439
- console.error(`holon-sh/ext/events: event ${eventId} of ${type.name} was skipped for a subscription:`, error);
440
- failed?.({ code: INTERNAL_ERROR, message: `Event ${eventId} could not be delivered and was skipped`, data: { eventId, reason: String(error?.message ?? error) } });
441
- };
442
- // What a check function found is skipped in the same way when it cannot be sent as JSON.
443
- const serialisable = (type: Type, eventId: string, value: unknown, failed: (error: EventError) => void) => {
444
- try {
445
- JSON.stringify(value);
446
- return true;
447
- } catch (error) {
448
- skipped(type, eventId, error, failed);
449
- return false;
450
- }
330
+ return { ...occurrence, data, cursor: position(type, sequence) };
451
331
  };
452
332
 
453
333
  // The event type and arguments a request names, checked as the sketch's errors require, in its order: the type,
454
334
  // the delivery mode, the request's own fields, the arguments, and then whether the caller may subscribe. A caller
455
335
  // refused loses what it holds of that subscription at once.
456
- const subscription: Internals["subscription"] = async ({ name, arguments: args = {}, cursor, maxAgeMs, maxEvents }, context, delivery) => {
336
+ const subscription = async ({ name, arguments: args = {}, cursor, maxAgeMs, maxEvents }: Record<string, unknown>, context: SubscriptionContext, delivery: DeliveryModeName): Promise<{ type: Type; subscription: Requested }> => {
457
337
  const type = types.get(name as string);
458
338
  if (!type) throw refusal(removed);
459
- if (delivery && !type.delivery.includes(delivery)) throw refusal({ code: UNSUPPORTED, message: "Unsupported", data: { feature: "deliveryMode", value: delivery } });
339
+ if (!type.delivery.includes(delivery)) throw refusal({ code: UNSUPPORTED, message: "Unsupported", data: { feature: "deliveryMode", value: delivery } });
460
340
  if (cursor != null && typeof cursor !== "string") throw new ProtocolError(INVALID_PARAMS, "cursor must be a string or null");
461
341
  if (!integer(maxAgeMs, 0)) throw new ProtocolError(INVALID_PARAMS, "maxAgeMs must be a non-negative integer");
462
342
  if (!integer(maxEvents, 1)) throw new ProtocolError(INVALID_PARAMS, "maxEvents must be a positive integer");
@@ -474,23 +354,29 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
474
354
  return { type, subscription: { id: who === null ? unique() : lease, lease, arguments: checked.value, context } };
475
355
  };
476
356
 
477
- const endSubscription = (type: EventTypeState, id: string, error: EventError) => {
357
+ const endSubscription = (type: Type, id: string, error: EventError) => {
478
358
  for (const held of [...type.subscriptions.get(id)?.held ?? []]) held.end(error);
479
359
  };
480
360
 
481
- // Begins holding a subscription, by a stream, a poll lease or another delivery mode: counted against the limits
482
- // first, so a refused one provisions nothing. The type's onSubscribe is called when the first hold of an id
483
- // begins, and its onUnsubscribe when the last ends, so two streams of one subscription set it up once. `end` is how
484
- // the delivery mode is told the app terminated it; `close` is how the mode says its hold is over.
485
- const open: Internals["open"] = async (state, { id, delivery, arguments: args, context }, end) => {
486
- const type = state as Type;
361
+ // Begins holding a subscription, by a stream or a poll lease: counted against the limits first, so a refused one
362
+ // provisions nothing. The type's onSubscribe is called when the first hold of an id begins, and its onUnsubscribe
363
+ // when the last ends, so two streams of one subscription set it up once. `end` is how the holder is told the app
364
+ // terminated it; `close` is how the holder says its hold is over.
365
+ const open = async (type: Type, { id, delivery, arguments: args, context }: { id: string; delivery: DeliveryModeName; arguments: unknown; context: SubscriptionContext }, end: (error: EventError) => void): Promise<Held> => {
487
366
  if (types.get(type.name) !== type) throw refusal(removed);
488
367
  // Callers without an identity cannot be told apart, so they count together as one.
489
368
  const who = caller(context) ?? "anonymous";
490
369
  if (subscribed >= MAX_SUBSCRIPTIONS) throw refusal({ code: RESOURCE_EXHAUSTED, message: "ResourceExhausted", data: { limit: "subscriptions", max: MAX_SUBSCRIPTIONS } });
491
370
  if ((callers.get(who) ?? 0) >= MAX_SUBSCRIPTIONS_PER_CALLER) throw refusal({ code: RESOURCE_EXHAUSTED, message: "ResourceExhausted", data: { limit: "subscriptionsPerCaller", max: MAX_SUBSCRIPTIONS_PER_CALLER } });
492
371
  subscribed++;
493
- callers.set(who, (callers.get(who) ?? 0) + 1);
372
+ count(callers, who, 1);
373
+ // A presence keeps a subscriber's value while any of its streams or leases is open.
374
+ const subscriber = named(args);
375
+ const hold = (by: number) => {
376
+ count(type.holds, subscriber, by);
377
+ for (const keep of type.present) keep(subscriber);
378
+ };
379
+ hold(1);
494
380
  let entry = type.subscriptions.get(id);
495
381
  if (!entry) {
496
382
  const subscription = { subscriptionId: id, name: type.name, delivery, arguments: args, context };
@@ -501,18 +387,17 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
501
387
  }
502
388
  const current = entry;
503
389
  let closed = false;
504
- const held: Held = { id, name: type.name, delivery, arguments: args, context, end, close() {
390
+ const held: Held = { id, arguments: args, context, end, close() {
505
391
  if (closed) return;
506
392
  closed = true;
507
393
  subscribed--;
508
- const count = callers.get(who) ?? 1;
509
- if (count === 1) callers.delete(who);
510
- else callers.set(who, count - 1);
394
+ count(callers, who, -1);
395
+ hold(-1);
511
396
  current.held.delete(held);
512
397
  if (current.held.size) return;
513
398
  if (type.subscriptions.get(id) === current) type.subscriptions.delete(id);
514
399
  // Only a subscription that was set up is told it ended.
515
- current.ready.then(subscription => { if (type.onUnsubscribe) settle(() => type.onUnsubscribe!(subscription as Subscription<unknown>)); }, () => {});
400
+ current.ready.then(subscription => { if (type.onUnsubscribe) settle(() => type.onUnsubscribe!(subscription)); }, () => {});
516
401
  } };
517
402
  current.held.add(held);
518
403
  try {
@@ -545,13 +430,12 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
545
430
  lent = undefined;
546
431
  }
547
432
  if (!lent) {
548
- const fresh: Lease = { type };
549
- lent = fresh;
550
- leases.set(key, fresh);
551
- fresh.opening = open(type, { ...subscribed, id: key, delivery: "poll" }, error => {
433
+ const fresh: Lease = { type, opening: open(type, { ...subscribed, id: key, delivery: "poll" }, error => {
552
434
  fresh.ended = error;
553
435
  fresh.release?.();
554
- });
436
+ }) };
437
+ lent = fresh;
438
+ leases.set(key, fresh);
555
439
  fresh.opening.then(held => {
556
440
  if (shared) held.untargetable = true;
557
441
  fresh.release = () => held.close();
@@ -559,7 +443,7 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
559
443
  }, () => { if (leases.get(key) === fresh) leases.delete(key); });
560
444
  }
561
445
  const current = lent;
562
- const held = await current.opening!;
446
+ const held = await current.opening;
563
447
  // Ended while it was being set up.
564
448
  if (current.ended) {
565
449
  leases.delete(key);
@@ -574,50 +458,32 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
574
458
  return held;
575
459
  };
576
460
 
577
- // What a delivery mode works with: `subscription` checks a request, names its event type and the subscription's
578
- // id, `open` begins holding a subscription, `read` gives a subscription what it is owed after a cursor (`since` and
579
- // `shape` for emitted events alone), `position` is a type's cursor, a type's `sinks` hear what is emitted, and its
580
- // `subscriptions` are the ones held by every mode, by id.
581
- const internals: Internals = { types, subscription, open, read, since, shape, position };
582
-
583
461
  return {
584
- [INTERNALS]: internals,
585
462
  /**
586
463
  * Declares an event type and returns the function that emits it. `match` decides which subscriptions receive an
587
- * event from their arguments, and `transform` shapes its data for each. `check` returns what happened after a
588
- * cursor, for event types whose source keeps its own history.
464
+ * event from their arguments, and `transform` shapes its data for each.
589
465
  */
590
- event<A, D>(name: string, { description, inputSchema, payloadSchema, _meta, match, transform, check, delivery = offered.filter(mode => !check || mode !== "webhook"), authorize, onSubscribe, onUnsubscribe }: EventDefinition<A, D>): Emitter<D> {
466
+ event<A, D>(name: string, { description, inputSchema, payloadSchema, _meta, match, transform, delivery = OFFERED, authorize, onSubscribe, onUnsubscribe }: EventDefinition<A, D>): Emitter<D, A> {
591
467
  if (types.has(name)) throw new Error(`Event ${name} is already declared.`);
592
- const unknown = delivery.filter(mode => !offered.includes(mode));
593
- if (unknown.length || !delivery.length) throw new Error(`Event ${name} is delivered by ${delivery.join(", ") || "nothing"}, but only ${offered.join(", ")} are served.`);
594
- // Webhook delivery sends what is emitted, and does not ask a check function.
595
- if (check && delivery.includes("webhook")) throw new Error(`Event ${name} has a check function, which webhook delivery does not read.`);
468
+ const unknown = delivery.filter(mode => !OFFERED.includes(mode));
469
+ if (unknown.length || !delivery.length) throw new Error(`Event ${name} is delivered by ${delivery.join(", ") || "nothing"}, but only ${OFFERED.join(", ")} are served.`);
596
470
  const type: Type<A, D> = {
597
- name, order: ++declared, description, inputSchema, payloadSchema, _meta, match, transform, check: check as Type["check"], interval: INTERVAL, delivery, authorize, onSubscribe, onUnsubscribe,
598
- sequence: 0, dropped: 0, kept: [], sinks: new Set(), subscriptions: new Map(), owed: new Map(),
471
+ name, order: ++declared, description, inputSchema, payloadSchema, _meta, delivery, match, transform, authorize, onSubscribe, onUnsubscribe,
472
+ sequence: 0, dropped: 0, kept: [], sinks: new Set(), subscriptions: new Map(), holds: new Map(), present: new Set(),
599
473
  };
600
474
  types.set(name, type);
601
- return {
475
+ const emitter: Emitter<D, A> = {
602
476
  emit(data, { eventId = crypto.randomUUID(), timestamp = new Date().toISOString(), subscriptionId: target } = {}) {
603
477
  const occurrence = { eventId, name, timestamp, data };
604
478
  // Data that cannot be sent as JSON is refused here, before anything is kept or sent.
605
479
  JSON.stringify(occurrence);
606
- // A type with a check function is asked for what polls are owed, so what it emits is pushed and not kept.
607
- if (type.check) {
608
- // An event for one subscription that has no stream to take it is lost.
609
- if (target !== undefined && ![...type.subscriptions.get(target)?.held ?? []].some(held => held.delivery !== "poll")) owe(type, target);
610
- for (const sink of type.sinks) sink({ target, occurrence });
611
- return;
612
- }
613
480
  // An event for one subscription is kept like any other, so the subscription has it when it comes back.
614
481
  const kept = { sequence: ++type.sequence, at: Date.now(), target, occurrence };
615
482
  type.kept.push(kept);
616
483
  while (type.kept.length > KEPT_EVENTS || type.kept[0]!.at < kept.at - KEPT_AGE) type.dropped = type.kept.shift()!.sequence;
617
- for (const sink of type.sinks) sink(kept);
484
+ for (const sink of type.sinks) sink();
618
485
  },
619
486
  cursor() {
620
- if (type.check) throw new Error(`Event ${name} has a check function, whose cursor is its source's own.`);
621
487
  return position(type);
622
488
  },
623
489
  terminate(id, error = forbidden) {
@@ -629,14 +495,43 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
629
495
  types.delete(name);
630
496
  for (const each of [...type.subscriptions.keys()]) endSubscription(type, each, removed);
631
497
  },
498
+ presence<V>(told: (args: A, value: V | undefined) => D): Presence<A, V> {
499
+ const values = new Map<string, { args: A; value: V; linger: number; leaving?: ReturnType<typeof setTimeout> }>();
500
+ // A value has no timer while its subscriber holds a subscription, and leaves its linger after it stops.
501
+ const keep = (subscriber: string) => {
502
+ const shown = values.get(subscriber);
503
+ if (!shown) return;
504
+ clearTimeout(shown.leaving);
505
+ if (type.holds.has(subscriber)) return;
506
+ shown.leaving = setTimeout(() => {
507
+ values.delete(subscriber);
508
+ emitter.emit(told(shown.args, undefined));
509
+ }, shown.linger);
510
+ shown.leaving.unref?.();
511
+ };
512
+ type.present.add(keep);
513
+ return {
514
+ set(args, value, linger) {
515
+ const subscriber = named(args);
516
+ values.set(subscriber, { args, value, linger, leaving: values.get(subscriber)?.leaving });
517
+ keep(subscriber);
518
+ emitter.emit(told(args, value));
519
+ },
520
+ seen(args) {
521
+ return [...values.values()]
522
+ .filter(shown => !type.match || type.match(told(shown.args, shown.value), args, undefined))
523
+ .map(({ value }) => value);
524
+ },
525
+ };
526
+ },
632
527
  };
528
+ return emitter;
633
529
  },
634
530
 
635
531
  /** Serves the declared event types on an MCP server: events/list, events/poll and events/stream. */
636
532
  serve(mcp) {
637
- const underlying: Server = "server" in mcp ? mcp.server : mcp;
638
533
  // The custom methods are registered through the parts of the server this module uses.
639
- const server = underlying as unknown as Served;
534
+ const server = ("server" in mcp ? mcp.server : mcp) as unknown as Served;
640
535
  // The sketch declares capabilities.events. The MCP SDK's client drops top-level capabilities it does not know,
641
536
  // so the extension is declared under capabilities.extensions too, where SDK clients read it.
642
537
  // Clients are never told the event types changed: over Holon's stateless HTTP, the notification reaches none.
@@ -664,33 +559,43 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
664
559
  ...(start + PAGE_SIZE < all.length && { nextCursor: String(page.at(-1)!.order) }),
665
560
  };
666
561
  });
667
- for (const mode of modes) mode.serve(underlying, internals);
668
562
 
669
563
  server.setRequestHandler("events/poll", { params }, async (request, ctx) => {
670
564
  const { type, subscription: subscribed } = await subscription(request, ctx.http, "poll");
671
- const held = await lease(type as Type, subscribed);
565
+ // Taken before the lease, which waits for onSubscribe. The sketch's poll from null returns no events and a
566
+ // fresh cursor: this one is where the subscription opened, so the next poll has what onSubscribe emitted.
567
+ const opened = type.sequence;
568
+ const held = await lease(type, subscribed);
672
569
  // Each poll is answered for its own caller, with the lease's id.
673
- const { events, start, failures, truncated, ...rest } = await read(type, { ...subscribed, id: held.id, untargetable: held.untargetable }, request.cursor, request);
674
- const lost = !held.untargetable && (type as Type).owed.delete(held.id);
570
+ const { events, start, failures, ...rest } = since(type, { ...subscribed, id: held.id, untargetable: held.untargetable }, request.cursor, { maxAgeMs: request.maxAgeMs, maxEvents: request.maxEvents, opened, ...(request.cursor == null && { until: opened }) });
675
571
  // A poll carries its cursor once, for the whole response.
676
- return { events: events.map(({ cursor, ...event }) => event), ...rest, truncated: truncated || lost, nextPollMs: type.interval };
572
+ return { events: events.map(({ cursor, ...event }) => event), ...rest, nextPollMs: INTERVAL };
677
573
  });
678
574
 
679
575
  server.setRequestHandler("events/stream", { params }, async (request, ctx) => {
680
- const { type: state, subscription: subscribed } = await subscription(request, ctx.http, "push");
681
- const type = state as Type;
576
+ const { type, subscription: subscribed } = await subscription(request, ctx.http, "push");
682
577
  const { signal } = ctx.mcpReq;
683
578
  const meta = { [SUBSCRIPTION_ID_META_KEY]: ctx.mcpReq.id };
684
579
  let done = false;
685
580
  let ending: EventError | undefined;
581
+ // Set when the app terminates the stream: the last event emitted before, which the stream still sends.
582
+ let until: number | undefined;
686
583
  let wake = () => {};
687
584
  const finish = () => {
688
585
  done = true;
689
586
  wake();
690
587
  };
588
+ // What is emitted from here on is the stream's, so it has what onSubscribe emits to it.
589
+ const opened = type.sequence;
691
590
  const held = await open(type, { ...subscribed, delivery: "push" }, error => {
591
+ // Lost access ends delivery at once, even while what came before is still being sent. Otherwise only the
592
+ // first end counts, and the stream first sends what was emitted before it.
593
+ const lost = error.code === FORBIDDEN;
594
+ if (ending && !lost) return;
692
595
  ending = error;
693
- finish();
596
+ if (lost) return finish();
597
+ until = type.sequence;
598
+ wake();
694
599
  });
695
600
  // A client that left while the subscription was being set up is not waited for.
696
601
  if (signal.aborted) {
@@ -715,19 +620,15 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
715
620
  }
716
621
  if ((sent += JSON.stringify(params).length) > STREAM_BUDGET) finish();
717
622
  };
718
- // The stream follows its cursor: emitted events wake it, and a type with a check function is asked again
719
- // every interval. What it is owed waits in the buffer or the source, not here, so a slow client is not owed
720
- // more memory than one batch; when what it is owed is no longer kept, it is told of the gap.
623
+ // The stream follows its cursor, and emitted events wake it. What it is owed waits in the buffer, not here, so
624
+ // a slow client is not owed more memory than one batch; when what it is owed is no longer kept, it is told of
625
+ // the gap.
721
626
  let cursor: string | null = request.cursor ?? null;
722
627
  let behind = true;
723
- // What a type with a check function emitted and the stream has yet to send. The check function finds it
724
- // too, so when too much waits the oldest is let go.
725
- const pushed: Kept[] = [];
726
- const sink = (kept: Kept) => {
727
- if (type.check) {
728
- pushed.push(kept);
729
- if (pushed.length > KEPT_EVENTS) pushed.shift();
730
- } else behind = true;
628
+ const sink = () => {
629
+ // Nothing emitted after the app terminated the stream is for it.
630
+ if (until !== undefined) return;
631
+ behind = true;
731
632
  wake();
732
633
  };
733
634
  type.sinks.add(sink);
@@ -748,58 +649,29 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
748
649
  }, HEARTBEAT);
749
650
  let first = true;
750
651
  try {
751
- let due = 0;
752
652
  while (!done) {
753
- if (type.check ? Date.now() >= due : behind) {
653
+ if (behind) {
754
654
  behind = false;
755
- let result: Owed | undefined;
756
- try {
757
- result = await read(type, held, cursor, { ...(first && request.maxAgeMs !== undefined && { maxAgeMs: request.maxAgeMs }) });
758
- } catch (error) {
759
- // Without a first answer there is no stream to tell, and the request fails.
760
- if (first) throw error;
761
- await notify("error", { error: described(error) });
762
- }
763
- due = Date.now() + type.interval;
764
- if (result) {
765
- // A check function's gap is told after the events that follow it, at the position after them, since
766
- // the position before them is not known and the client's cursor must not go back. The stream is
767
- // confirmed before anything else is said on it.
768
- const after = type.check && result.truncated && !first;
769
- const truncated = result.truncated || first && type.owed.delete(held.id);
770
- if (first || truncated && !after) await notify("active", { cursor: type.check && result.truncated ? cursor : result.start, truncated });
771
- if (first) beat = heartbeats();
772
- first = false;
773
- for (const error of result.failures ?? []) await notify("error", { error });
774
- for (const event of result.events) {
775
- if (done) break;
776
- await notify("event", event);
777
- cursor = event.cursor ?? null;
778
- }
655
+ const result = since(type, held, cursor, { ...(first && request.maxAgeMs !== undefined && { maxAgeMs: request.maxAgeMs }), opened, until });
656
+ // The stream is confirmed before anything else is said on it.
657
+ if (first || result.truncated) await notify("active", { cursor: result.start, truncated: result.truncated });
658
+ if (first) beat = heartbeats();
659
+ first = false;
660
+ for (const error of result.failures) await notify("error", { error });
661
+ for (const event of result.events) {
779
662
  if (done) break;
780
- cursor = result.cursor;
781
- if (after) await notify("active", { cursor, truncated: true });
782
- if (result.hasMore) {
783
- due = 0;
784
- behind = true;
785
- }
663
+ await notify("event", event);
664
+ cursor = event.cursor ?? null;
786
665
  }
666
+ if (done) break;
667
+ cursor = result.cursor;
787
668
  }
788
- // What a type with a check function emits is sent at once, at the cursor the stream has reached.
789
- while (pushed.length && !done) {
790
- let failure: EventError | undefined;
791
- const event = shape(type, pushed.shift()!, held, error => { failure = error; });
792
- if (failure) await notify("error", { error: failure });
793
- if (event) await notify("event", { ...event, cursor });
794
- }
795
- if (done) break;
796
- let timer: ReturnType<typeof setTimeout> | undefined;
669
+ // Terminated by the app, the stream ends once it has sent what was emitted before.
670
+ if (done || until !== undefined && !behind) break;
797
671
  await new Promise<void>(resolve => {
798
672
  wake = resolve;
799
- if (done || pushed.length || (!type.check && behind)) resolve();
800
- else if (type.check) timer = setTimeout(resolve, Math.max(0, due - Date.now()));
673
+ if (done || behind) resolve();
801
674
  });
802
- clearTimeout(timer);
803
675
  wake = () => {};
804
676
  }
805
677
  if (ending) {
@@ -809,8 +681,6 @@ export function createEvents({ webhookTtl, webhook }: EventsOptions = {}): Event
809
681
  } finally {
810
682
  clearInterval(beat);
811
683
  type.sinks.delete(sink);
812
- // What was emitted to this subscription alone and not sent is owed to it as a gap when it comes back.
813
- if (held.id === subscribed.lease && pushed.some(kept => kept.target === held.id)) owe(type, held.id);
814
684
  held.close();
815
685
  }
816
686
  // The final frame, sent when the server ends the stream; when the client did, nobody is listening.