@cotal-ai/core 0.40.0 → 0.41.1

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/dist/endpoint.js CHANGED
@@ -26,6 +26,26 @@ export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
26
26
  const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
27
27
  /** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
28
28
  export const DEFAULT_SPACE = "main";
29
+ /** How many channel filters one multi-filter consumer create may carry.
30
+ *
31
+ * A create names every requested channel in one request, so the request grows with the channel
32
+ * count and the CLIENT's request timeout is what gives way, not the broker: the create is never
33
+ * refused, it just does not answer. Measured on an isolated broker, one message per channel,
34
+ * `limit=5`: 70 filters answer in 43ms, 1,000 in 246ms, 5,000 in 5,345ms, and 10,000 does not
35
+ * answer at all, failing `timeout` after 5,023ms. Note 5,000 SUCCEEDED while taking longer than
36
+ * 10,000 took to fail, which is what says the ceiling is on the create request rather than on the
37
+ * read: past roughly 5s the create itself is what times out.
38
+ *
39
+ * 1,000 is chosen from that sweep rather than from the failure point: it is a fifth of the largest
40
+ * count that still answered, and it answers in a quarter second, so a batch stays far away from
41
+ * both the timeout and the response-deadline budget the dashboard has to share with its DM read.
42
+ * A space with the 69 chat channels this was built for is still ONE read, so the round-trip claim
43
+ * in #1210 is unchanged at that size. */
44
+ export const MULTI_FILTER_BATCH = 1_000;
45
+ /** How many filter batches may be in flight at once. Bounded because the point of #1210 was to stop
46
+ * issuing one read per channel: a space large enough to need batches must not get the fan-out back
47
+ * under another name. */
48
+ export const MULTI_FILTER_READ_CONCURRENCY = 4;
29
49
  /**
30
50
  * Events: "message" (CotalMessage), "presence" (PresenceEvent), "roster" (Presence[]), "error" (Error),
31
51
  * "connection" ({ connected: boolean }) — true on every successful (re)bind (initial start, manual
@@ -185,6 +205,8 @@ export class CotalEndpoint extends EventEmitter {
185
205
  roster = new Map();
186
206
  /** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
187
207
  presenceSnapshot = Promise.resolve();
208
+ /** False from connection reset until the watch marks the last entry in its initial replay. */
209
+ presenceSnapshotPopulated = false;
188
210
  /**
189
211
  * Observer-local age of the last presence-KV delivery (any key, including DEL/PURGE). Distinct
190
212
  * from each peer's `ts`: that is the publisher's heartbeat. Whole-bucket silence past TTL is
@@ -192,8 +214,8 @@ export class CotalEndpoint extends EventEmitter {
192
214
  * latter (#1045).
193
215
  */
194
216
  lastPresenceWatchAt = 0;
195
- /** Last emitted presence-view freshness. Suppresses duplicate `presence-view` events. */
196
- presenceViewFresh = true;
217
+ /** Last emitted presence-view state. Suppresses duplicate `presence-view` events. */
218
+ presenceViewState = "unpopulated";
197
219
  status = "idle";
198
220
  activity;
199
221
  /** Mirror of the connector's authoritative attention state, published in presence (advisory). The
@@ -830,7 +852,8 @@ export class CotalEndpoint extends EventEmitter {
830
852
  this.confirmingChatSubs.clear();
831
853
  this.roster.clear();
832
854
  this.lastPresenceWatchAt = 0;
833
- this.presenceViewFresh = true;
855
+ this.presenceSnapshotPopulated = false;
856
+ this.emitPresenceViewIfChanged();
834
857
  this.joinSeq.clear();
835
858
  this.channelConfigs.clear();
836
859
  this.channelDefaults = {};
@@ -1608,29 +1631,31 @@ export class CotalEndpoint extends EventEmitter {
1608
1631
  return [...this.roster.values()].sort((a, b) => a.card.name.localeCompare(b.card.name));
1609
1632
  }
1610
1633
  /**
1611
- * Freshness of THIS observer's presence watch, not of any peer. `fresh: false` means the
1612
- * whole bucket has been silent past the liveness window the view is stale as of
1613
- * `staleSince`, and {@link getRoster} is last-known rather than a current offline verdict.
1614
- * A watch that has not yet delivered anything is not stale (there is no T to name).
1634
+ * Trust state of THIS observer's presence watch, not of any peer. `unpopulated` means the
1635
+ * current watch has not completed its initial snapshot, so {@link getRoster} may be partial and
1636
+ * cannot support an absence verdict. `stale` means the whole bucket has been silent past the
1637
+ * liveness window, so the roster is last-known as of `staleSince`. `fresh` is false for both
1638
+ * unsafe states so consumers written before `state` was added degrade in the safe direction.
1615
1639
  */
1616
1640
  presenceView() {
1617
1641
  if (!this.doWatch)
1618
- return { fresh: true };
1619
- if (this.lastPresenceWatchAt === 0)
1620
- return { fresh: true };
1642
+ return { state: "current", fresh: true };
1643
+ if (!this.presenceSnapshotPopulated)
1644
+ return { state: "unpopulated", fresh: false };
1621
1645
  const staleSince = this.lastPresenceWatchAt + this.ttlMs;
1622
1646
  if (Date.now() < staleSince)
1623
- return { fresh: true };
1624
- return { fresh: false, staleSince };
1647
+ return { state: "current", fresh: true };
1648
+ return { state: "stale", fresh: false, staleSince };
1625
1649
  }
1626
1650
  /** Wait until the current presence watch has consumed its initial KV snapshot. An empty bucket
1627
- * emits no watch entry, so the timeout keeps a genuinely empty mesh bounded. */
1651
+ * emits no watch entry, so the timeout keeps a genuinely empty mesh bounded and is reported
1652
+ * distinctly from snapshot completion. */
1628
1653
  async waitForPresenceSnapshot(timeoutMs = 1_000) {
1629
1654
  let timer;
1630
1655
  try {
1631
- await Promise.race([
1632
- this.presenceSnapshot,
1633
- new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs); }),
1656
+ return await Promise.race([
1657
+ this.presenceSnapshot.then(() => "snapshot"),
1658
+ new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), timeoutMs); }),
1634
1659
  ]);
1635
1660
  }
1636
1661
  finally {
@@ -2099,7 +2124,107 @@ export class CotalEndpoint extends EventEmitter {
2099
2124
  * reclaims its ephemeral consumer before the promise rejects. */
2100
2125
  async channelHistory(channel, opts) {
2101
2126
  // history from any sender
2102
- return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100, undefined, opts?.signal);
2127
+ return (await this.streamHistory(chatStream(this.space), [chatSubject(this.space, "*", "*", channel)], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
2128
+ }
2129
+ /**
2130
+ * The newest `limit` chat messages across MANY channels at once, oldest-first within the page,
2131
+ * each tagged with the channel the BROKER delivered it on.
2132
+ *
2133
+ * **One read, not one per channel.** The CHAT stream already interleaves every channel into one
2134
+ * sequence space, so "the newest N across these channels" is the tail of ONE stream, and a
2135
+ * consumer takes a SET of filter subjects. The dashboard's activity feed used to answer this by
2136
+ * calling {@link channelHistory} once per channel and merging: each of those is a widening probe
2137
+ * loop, so the cost carried two multipliers (a probe loop per channel, and a fan-out across every
2138
+ * channel). Counted on the wire over a seeded corpus of 69 chat channels and 24 event channels at
2139
+ * limit 200: 2524 broker requests and about 8.0 MB transferred to return a 143,401-byte page,
2140
+ * against 143 requests and about 0.91 MB here. With no link cost the counts and the page size
2141
+ * repeat exactly across runs; the byte totals move by tens of bytes. `pnpm smoke:web-activity-read-cost` reproduces the
2142
+ * second column and a frozen copy of the fan-out shape; the first is that same suite run against
2143
+ * `544a974b7` (Cotal #1210).
2144
+ *
2145
+ * **The broker does the filtering, so the wire carries only what is asked for.** A channel left
2146
+ * out of `channels` costs nothing: its messages are never delivered, so a space whose volume is
2147
+ * dominated by channels the caller does not want stays cheap. That is the same "filter before the
2148
+ * fetch" property the per-channel fan-out had, kept rather than traded away.
2149
+ *
2150
+ * **The channel comes from the SUBJECT, never from the payload.** A message claims a `channel`
2151
+ * field, and this method ignores it: the tag is derived from the subject the broker routed the
2152
+ * message on, the same derivation {@link listChannels} uses to name a channel in the first place.
2153
+ *
2154
+ * **Concrete channels only.** Filter subjects on one consumer may not overlap, and a wildcard
2155
+ * channel subsumes its own subtree, so a wildcard here is refused rather than silently dropped or
2156
+ * silently double-counted.
2157
+ *
2158
+ * **Observer/admin credentials only, and the broker is what says so.** A multi-filter create
2159
+ * cannot encode its filter in the API subject, so it rides the bare
2160
+ * `$JS.API.CONSUMER.CREATE.<CHAT>` row that only the read-only dashboard profiles hold. An agent
2161
+ * credential pins the filter into the subject per channel and is denied here by the broker, which
2162
+ * is the correct answer: this method reads across channels, and an agent's read ACL is per
2163
+ * channel.
2164
+ */
2165
+ async multiChannelHistory(channels, opts) {
2166
+ const subjects = [...new Set(channels.map((channel) => {
2167
+ if (!isConcreteChannel(channel))
2168
+ throw new Error(`multiChannelHistory: "${channel}" is a wildcard channel - one consumer's filter subjects may not overlap, so name the concrete channels`);
2169
+ // `chatSubject` builds the filter through `token()`, which REWRITES rather than refuses: it
2170
+ // maps a character a subject may not carry to `_`, trims each segment, and drops empty ones.
2171
+ // So "foo/bar" would filter on `foo_bar`, ".lead" on `lead`, and "team..b" on `team.b` - the
2172
+ // caller names one channel and the broker returns another, which is the exact promise this
2173
+ // method makes ("a channel left out of the list never crosses the link") inverted. This is the
2174
+ // same aliasing `assertValidChannel` was written for on the policy path; the read path needs
2175
+ // it too, and it fails loud rather than serving a channel nobody asked for.
2176
+ assertValidChannel(channel);
2177
+ return chatSubject(this.space, "*", "*", channel);
2178
+ }))];
2179
+ // No channels is not an empty stream, but it IS an empty answer, and asking the broker for a
2180
+ // consumer with no filter would read the WHOLE stream instead of none of it.
2181
+ if (subjects.length === 0)
2182
+ return [];
2183
+ const limit = opts?.limit ?? 100;
2184
+ // ONE CREATE CANNOT CARRY AN UNBOUNDED FILTER LIST. The create names every subject in one
2185
+ // request and the client's request timeout is what gives way, so past roughly 5,000 filters the
2186
+ // read does not answer at all and the route loses the whole chat source: measured on an
2187
+ // isolated broker, 10,000 channels failed `timeout` after 5,023ms while the fan-out this
2188
+ // replaced still returned 2,739 messages on the same corpus. Reading in batches keeps the
2189
+ // single-read cost at the sizes this was built for (69 channels is one batch, so #1210's
2190
+ // round-trip numbers are unchanged) and degrades to a few reads instead of none above that.
2191
+ // `batch` is a knob rather than a constant so the batched path can be compared against the
2192
+ // single-create path on ONE corpus: forcing a small batch makes a space that would otherwise be
2193
+ // one read take many, which is the only way to assert the two select the same messages.
2194
+ const size = Math.max(1, Math.trunc(opts?.batch ?? MULTI_FILTER_BATCH));
2195
+ const batches = [];
2196
+ for (let i = 0; i < subjects.length; i += size)
2197
+ batches.push(subjects.slice(i, i + size));
2198
+ const pages = new Array(batches.length);
2199
+ let next = 0;
2200
+ const readBatch = async () => {
2201
+ for (;;) {
2202
+ const i = next++;
2203
+ if (i >= batches.length)
2204
+ return;
2205
+ pages[i] = await this.streamHistory(chatStream(this.space), batches[i], limit, undefined, opts?.signal);
2206
+ }
2207
+ };
2208
+ // A BATCH THAT FAILS FAILS THE READ. Returning the batches that answered would be the newest
2209
+ // `limit` across SOME of the channels asked for while looking like the newest across all of
2210
+ // them, which is a wrong page presented as a right one. Throwing keeps the caller's existing
2211
+ // envelope: the dashboard marks `chat` missing and says the page is partial, which is what it
2212
+ // already did when this method was one read.
2213
+ await Promise.all(Array.from({ length: Math.min(MULTI_FILTER_READ_CONCURRENCY, batches.length) }, readBatch));
2214
+ // MERGE ON ARRIVAL, NOT ON `ts`. Each batch returns the newest `limit` within its own subjects,
2215
+ // and the newest `limit` overall is a subset of their union, so re-selecting by stream sequence
2216
+ // reproduces exactly what one create over the whole list would have selected. Sorting by the
2217
+ // payload's `ts` here instead would change which messages the page holds, which is the
2218
+ // selection question this pull request already had to answer once.
2219
+ const rows = pages.flat().sort((a, b) => a.seq - b.seq).slice(-limit);
2220
+ return rows.map(({ subject, msg }) => {
2221
+ const p = parseSubject(subject);
2222
+ // The filter set is built from chat subjects, so this cannot fire against a healthy broker.
2223
+ // It is here because the alternative to raising is tagging a message with a guess.
2224
+ if (p?.kind !== "chat")
2225
+ throw new Error(`multiChannelHistory: the broker delivered ${subject}, which is not a chat subject on this space`);
2226
+ return { channel: p.rest, msg };
2227
+ });
2103
2228
  }
2104
2229
  /** Read a channel's recent history THROUGH THE DELIVERY DAEMON instead of through a consumer this
2105
2230
  * connection creates itself — the mediated read of SPEC's "Mediated reads (normative)" rule (no raw
@@ -2146,7 +2271,7 @@ export class CotalEndpoint extends EventEmitter {
2146
2271
  * skips for them — only an `admin`-profile cred can read it. */
2147
2272
  async dmHistory(opts) {
2148
2273
  // every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
2149
- return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100, undefined, opts?.signal);
2274
+ return (await this.streamHistory(dmStream(this.space), [`${spacePrefix(this.space)}.inst.>`], opts?.limit ?? 100, undefined, opts?.signal)).map((r) => r.msg);
2150
2275
  }
2151
2276
  /**
2152
2277
  * The `limit` MOST RECENT messages matching `subject`, oldest-first within the page.
@@ -2171,7 +2296,7 @@ export class CotalEndpoint extends EventEmitter {
2171
2296
  *
2172
2297
  * `before` pages toward the past: pass the `seq` of the oldest message you already have.
2173
2298
  */
2174
- async streamHistory(stream, subject, limit, before, signal) {
2299
+ async streamHistory(stream, subjects, limit, before, signal) {
2175
2300
  if (!this.nc)
2176
2301
  throw new Error("endpoint not started");
2177
2302
  signal?.throwIfAborted();
@@ -2210,7 +2335,7 @@ export class CotalEndpoint extends EventEmitter {
2210
2335
  // Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
2211
2336
  // needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
2212
2337
  // shipped once from this function and turned every non-admin history read into an empty list.
2213
- const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subject, signal);
2338
+ const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subjects, signal);
2214
2339
  if (ceiling < 1)
2215
2340
  return [];
2216
2341
  // Widen from the exact ceiling until a window holds a full page, or until the window IS the
@@ -2219,6 +2344,25 @@ export class CotalEndpoint extends EventEmitter {
2219
2344
  // Geometric growth keeps the number of attempts logarithmic, so total wasted transfer is a
2220
2345
  // small multiple of a page.
2221
2346
  //
2347
+ // THE FIRST WINDOW IS ONE PAGE WIDE, NOT FOUR. It used to open at `limit * 4` on the reasoning
2348
+ // that a filtered subject is sparse inside its stream and a wider first look would land the
2349
+ // page in one drain. That is true for one channel of a busy stream and false for a subject
2350
+ // that IS most of its stream, and the second case is the one a whole-backlog read takes: the
2351
+ // window succeeds on the first attempt and drains four pages to keep one, because
2352
+ // `drainWindow` delivers everything in the window and keeps the tail. Measured on `/api/dms`
2353
+ // at limit 500 against a 2500-message backlog: 1,995,854 to 1,995,859 bytes moved across
2354
+ // four runs to return a 346,001-byte page, and 8161ms to 8753ms on that link, so all four
2355
+ // missed the dashboard's 8000ms deadline with nothing else on the connection (Cotal #1210).
2356
+ // That cost does not keep growing with the backlog, and saying it did was wrong: the old first
2357
+ // span was `max(limit * 4, 64)`, so at limit 500 it is 2000 messages, and ANY backlog of 2000
2358
+ // or more drains the same 2000-message window. The point is that the window, not the backlog,
2359
+ // sets the cost, and four pages to return one is already past the deadline on this link for
2360
+ // every such deployment. One page wide makes the
2361
+ // SUCCESSFUL drain
2362
+ // obey the same bound the failed ones already promised: it moves at most one page. A sparse
2363
+ // subject pays one more widening step for that, which is four round trips against a transfer
2364
+ // several times the size of the answer.
2365
+ //
2222
2366
  // A SHORT PAGE is either "the channel is exhausted" or "the window is still above the
2223
2367
  // first match". Sequence 1 is the wrong floor for the first of those: three matches at
2224
2368
  // the high end of a busy stream are exhausted as soon as the window's lower edge passes
@@ -2227,17 +2371,17 @@ export class CotalEndpoint extends EventEmitter {
2227
2371
  // drain, full) never pays for it. The remaining unbounded-looking case is a subject
2228
2372
  // whose FIRST match really is near sequence 1; that span is the channel's own, not the
2229
2373
  // stream's.
2230
- let span = Math.max(limit * 4, 64);
2374
+ let span = Math.max(limit, 64);
2231
2375
  let floor = 1;
2232
2376
  let floorKnown = false;
2233
2377
  for (;;) {
2234
2378
  signal?.throwIfAborted();
2235
2379
  const start = Math.max(floor, ceiling - span + 1);
2236
- const page = await this.drainWindow(js, stream, subject, start, ceiling, limit, signal);
2380
+ const page = await this.drainWindow(js, stream, subjects, start, ceiling, limit, signal);
2237
2381
  if (page.length >= limit)
2238
2382
  return page.slice(-limit);
2239
2383
  if (!floorKnown) {
2240
- floor = Math.max(1, await this.firstMatchingSeq(js, stream, subject, signal));
2384
+ floor = Math.max(1, await this.firstMatchingSeq(js, stream, subjects, signal));
2241
2385
  floorKnown = true;
2242
2386
  }
2243
2387
  if (start <= floor)
@@ -2268,19 +2412,13 @@ export class CotalEndpoint extends EventEmitter {
2268
2412
  throw e;
2269
2413
  }
2270
2414
  }
2271
- /** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
2272
- *
2273
- * One ordered consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
2274
- * (available from the create, before anything is delivered) is 0 for an empty subject, and
2275
- * otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
2276
- * already uses, so no broker authority is added. */
2277
2415
  /** The oldest stream sequence matching `subject`, or 0 when the subject has no messages.
2278
2416
  * Mirror of {@link lastMatchingSeq}: same CREATE/INFO/NEXT/DELETE surface, `DeliverPolicy.All`
2279
2417
  * instead of `Last`, first delivered seq instead of last. Read credentials already hold this. */
2280
- async firstMatchingSeq(js, stream, subject, signal) {
2418
+ async firstMatchingSeq(js, stream, subjects, signal) {
2281
2419
  signal?.throwIfAborted();
2282
2420
  const consumer = await js.consumers.get(stream, {
2283
- filter_subjects: [subject],
2421
+ filter_subjects: subjects,
2284
2422
  deliver_policy: DeliverPolicy.All,
2285
2423
  });
2286
2424
  try {
@@ -2299,7 +2437,7 @@ export class CotalEndpoint extends EventEmitter {
2299
2437
  signal?.removeEventListener("abort", stop);
2300
2438
  iter.stop();
2301
2439
  }
2302
- throw new Error(`history: the broker reported messages on ${subject} but delivered none - the read was cut short, not empty`);
2440
+ throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
2303
2441
  }
2304
2442
  finally {
2305
2443
  try {
@@ -2317,10 +2455,10 @@ export class CotalEndpoint extends EventEmitter {
2317
2455
  * (available from the create, before anything is delivered) is 0 for an empty subject, and
2318
2456
  * otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
2319
2457
  * already uses, so no broker authority is added. */
2320
- async lastMatchingSeq(js, stream, subject, signal) {
2458
+ async lastMatchingSeq(js, stream, subjects, signal) {
2321
2459
  signal?.throwIfAborted();
2322
2460
  const consumer = await js.consumers.get(stream, {
2323
- filter_subjects: [subject],
2461
+ filter_subjects: subjects,
2324
2462
  deliver_policy: DeliverPolicy.Last,
2325
2463
  });
2326
2464
  try {
@@ -2345,7 +2483,7 @@ export class CotalEndpoint extends EventEmitter {
2345
2483
  // dropped link looks like from here. Returning 0 would make the caller report an empty
2346
2484
  // channel, which is the same "could not read means no history" lie the narrowed catch above
2347
2485
  // exists to stop.
2348
- throw new Error(`history: the broker reported messages on ${subject} but delivered none - the read was cut short, not empty`);
2486
+ throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
2349
2487
  }
2350
2488
  finally {
2351
2489
  try {
@@ -2361,10 +2499,10 @@ export class CotalEndpoint extends EventEmitter {
2361
2499
  * One ephemeral ordered consumer, one batched pull — `AckPolicy.None`, so no per-message ack
2362
2500
  * round trip. Fetches exactly the pending count so it returns as soon as the window is
2363
2501
  * delivered rather than blocking for the pull's full expiry. */
2364
- async drainWindow(js, stream, subject, start, ceiling, limit, signal) {
2502
+ async drainWindow(js, stream, subjects, start, ceiling, limit, signal) {
2365
2503
  signal?.throwIfAborted();
2366
2504
  const out = [];
2367
- const consumer = await js.consumers.get(stream, { filter_subjects: [subject], opt_start_seq: start });
2505
+ const consumer = await js.consumers.get(stream, { filter_subjects: subjects, opt_start_seq: start });
2368
2506
  try {
2369
2507
  // A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
2370
2508
  // explicit uncached `info()` this used to call was a round trip for data we already had.
@@ -2393,7 +2531,7 @@ export class CotalEndpoint extends EventEmitter {
2393
2531
  if (m.seq >= ceiling) { // reached the page's upper bound
2394
2532
  if (m.seq === ceiling) {
2395
2533
  try {
2396
- out.push(m.json());
2534
+ out.push({ seq: m.seq, subject: m.subject, msg: m.json() });
2397
2535
  }
2398
2536
  catch { /* skip undecodable */ }
2399
2537
  }
@@ -2401,7 +2539,7 @@ export class CotalEndpoint extends EventEmitter {
2401
2539
  break;
2402
2540
  }
2403
2541
  try {
2404
- out.push(m.json());
2542
+ out.push({ seq: m.seq, subject: m.subject, msg: m.json() });
2405
2543
  if (out.length > limit)
2406
2544
  out.shift();
2407
2545
  }
@@ -2417,13 +2555,15 @@ export class CotalEndpoint extends EventEmitter {
2417
2555
  iter.stop();
2418
2556
  }
2419
2557
  if (!complete)
2420
- throw new Error(`history: read ${delivered} of ${pending} messages on ${subject} before the stream ended early - the window was cut short, not empty`);
2558
+ throw new Error(`history: read ${delivered} of ${pending} messages on ${subjectLabel(subjects)} before the stream ended early - the window was cut short, not empty`);
2421
2559
  return out;
2422
2560
  }
2423
2561
  finally {
2424
2562
  // DELETE THE EPHEMERAL CONSUMER. The pinned client gives an ordered consumer a 5-minute
2425
2563
  // inactive threshold, so leaving them behind is not free: the widening search below can make
2426
- // up to eight per call, the dashboard makes one call per channel, and a reload repeats it.
2564
+ // up to eight per call, and a reload repeats every call the page makes. The dashboard used to
2565
+ // make one per channel; since #1210 its activity feed makes one for all of chat and one for
2566
+ // DMs, and the single-channel routes still make one each.
2427
2567
  // Left alone that accumulates consumers on the broker until the thresholds expire, and the
2428
2568
  // resulting resource exhaustion would land in streamHistory's catch and read as empty history.
2429
2569
  try {
@@ -4370,7 +4510,9 @@ export class CotalEndpoint extends EventEmitter {
4370
4510
  // @nats-io/kv marks the final initial replay entry isUpdate=true. Later updates stay true.
4371
4511
  if (!ready && e.isUpdate) {
4372
4512
  ready = true;
4513
+ this.presenceSnapshotPopulated = true;
4373
4514
  hydrated();
4515
+ this.emitPresenceViewIfChanged();
4374
4516
  }
4375
4517
  }
4376
4518
  hydrated();
@@ -4451,7 +4593,8 @@ export class CotalEndpoint extends EventEmitter {
4451
4593
  this.emit("roster", this.getRoster());
4452
4594
  return;
4453
4595
  }
4454
- this.setPresenceViewFresh(true);
4596
+ if (this.presenceSnapshotPopulated)
4597
+ this.emitPresenceViewIfChanged();
4455
4598
  // Any offline materialization (a stale snapshot OR a graceful-leave record) drops the advisory
4456
4599
  // attention fields — an offline peer must not carry a stale `[focus]`/`locally muted` hint.
4457
4600
  const p = stale || raw.status === "offline" ? this.toOffline(raw) : raw;
@@ -4505,11 +4648,12 @@ export class CotalEndpoint extends EventEmitter {
4505
4648
  this.emit("presence", { type: "offline", presence: offline });
4506
4649
  this.emit("roster", this.getRoster());
4507
4650
  }
4508
- setPresenceViewFresh(fresh) {
4509
- if (fresh === this.presenceViewFresh)
4651
+ emitPresenceViewIfChanged() {
4652
+ const view = this.presenceView();
4653
+ if (view.state === this.presenceViewState)
4510
4654
  return;
4511
- this.presenceViewFresh = fresh;
4512
- this.emit("presence-view", this.presenceView());
4655
+ this.presenceViewState = view.state;
4656
+ this.emit("presence-view", view);
4513
4657
  }
4514
4658
  sweep() {
4515
4659
  const now = Date.now();
@@ -4518,7 +4662,7 @@ export class CotalEndpoint extends EventEmitter {
4518
4662
  // sidebar with nothing saying the window went blind (#1045). Gate the per-peer age-out on
4519
4663
  // watch freshness; surface the view as stale instead.
4520
4664
  if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
4521
- this.setPresenceViewFresh(false);
4665
+ this.emitPresenceViewIfChanged();
4522
4666
  return;
4523
4667
  }
4524
4668
  let changed = false;
@@ -4541,6 +4685,12 @@ export class CotalEndpoint extends EventEmitter {
4541
4685
  * never absent and never a non-string. An absent or non-string id is a malformed envelope under
4542
4686
  * SPEC sec 5; each delivery pump handles it per its own class (durable term, live drop, history
4543
4687
  * skip) so it never reaches the receiver's id-keyed machinery as `undefined`. */
4688
+ /** What a history read failure NAMES when it could not finish. One filter subject is the useful
4689
+ * thing to print; a set of sixty-nine of them is a wall of text in a message a human has to read,
4690
+ * so a set says its size and the stream it was read from instead. */
4691
+ function subjectLabel(subjects) {
4692
+ return subjects.length === 1 ? subjects[0] : `${subjects.length} filtered subjects`;
4693
+ }
4544
4694
  function isUsableMessageId(id) {
4545
4695
  return typeof id === "string";
4546
4696
  }