@cotal-ai/core 0.86.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/browser-control.d.ts +53 -1
  2. package/dist/browser-control.d.ts.map +1 -1
  3. package/dist/browser-control.js +102 -1
  4. package/dist/browser-control.js.map +1 -1
  5. package/dist/channels.d.ts +4 -0
  6. package/dist/channels.d.ts.map +1 -1
  7. package/dist/channels.js +6 -2
  8. package/dist/channels.js.map +1 -1
  9. package/dist/endpoint-action.d.ts +2 -2
  10. package/dist/endpoint-action.d.ts.map +1 -1
  11. package/dist/endpoint-action.js +4 -28
  12. package/dist/endpoint-action.js.map +1 -1
  13. package/dist/endpoint-goal-follow.d.ts +37 -0
  14. package/dist/endpoint-goal-follow.d.ts.map +1 -0
  15. package/dist/endpoint-goal-follow.js +328 -0
  16. package/dist/endpoint-goal-follow.js.map +1 -0
  17. package/dist/endpoint-grants.d.ts +0 -4
  18. package/dist/endpoint-grants.d.ts.map +1 -1
  19. package/dist/endpoint-grants.js +1 -7
  20. package/dist/endpoint-grants.js.map +1 -1
  21. package/dist/endpoint-invoke.d.ts +4 -27
  22. package/dist/endpoint-invoke.d.ts.map +1 -1
  23. package/dist/endpoint-invoke.js +6 -307
  24. package/dist/endpoint-invoke.js.map +1 -1
  25. package/dist/endpoint-signing.d.ts +1 -1
  26. package/dist/endpoint-signing.d.ts.map +1 -1
  27. package/dist/endpoint-signing.js +1 -1
  28. package/dist/endpoint-signing.js.map +1 -1
  29. package/dist/endpoint-subjects.d.ts +4 -0
  30. package/dist/endpoint-subjects.d.ts.map +1 -1
  31. package/dist/endpoint-subjects.js +6 -0
  32. package/dist/endpoint-subjects.js.map +1 -1
  33. package/dist/endpoint.d.ts +78 -13
  34. package/dist/endpoint.d.ts.map +1 -1
  35. package/dist/endpoint.js +247 -43
  36. package/dist/endpoint.js.map +1 -1
  37. package/dist/goal-result-fact.d.ts +13 -0
  38. package/dist/goal-result-fact.d.ts.map +1 -0
  39. package/dist/goal-result-fact.js +40 -0
  40. package/dist/goal-result-fact.js.map +1 -0
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/jetstream-missing.d.ts +4 -0
  46. package/dist/jetstream-missing.d.ts.map +1 -1
  47. package/dist/jetstream-missing.js +6 -0
  48. package/dist/jetstream-missing.js.map +1 -1
  49. package/dist/kv-scan.d.ts +15 -0
  50. package/dist/kv-scan.d.ts.map +1 -1
  51. package/dist/kv-scan.js +32 -0
  52. package/dist/kv-scan.js.map +1 -1
  53. package/dist/launch-material.d.ts.map +1 -1
  54. package/dist/launch-material.js +6 -2
  55. package/dist/launch-material.js.map +1 -1
  56. package/dist/lease.d.ts +50 -0
  57. package/dist/lease.d.ts.map +1 -1
  58. package/dist/lease.js +71 -0
  59. package/dist/lease.js.map +1 -1
  60. package/dist/private-tool-results.d.ts +253 -0
  61. package/dist/private-tool-results.d.ts.map +1 -0
  62. package/dist/private-tool-results.js +141 -0
  63. package/dist/private-tool-results.js.map +1 -0
  64. package/dist/provision.d.ts +5 -0
  65. package/dist/provision.d.ts.map +1 -1
  66. package/dist/provision.js +32 -14
  67. package/dist/provision.js.map +1 -1
  68. package/dist/remote-manager-authority.d.ts +17 -0
  69. package/dist/remote-manager-authority.d.ts.map +1 -1
  70. package/dist/remote-manager-authority.js +40 -1
  71. package/dist/remote-manager-authority.js.map +1 -1
  72. package/dist/run-journal.d.ts.map +1 -1
  73. package/dist/run-journal.js +1 -6
  74. package/dist/run-journal.js.map +1 -1
  75. package/dist/subjects.d.ts +17 -0
  76. package/dist/subjects.d.ts.map +1 -1
  77. package/dist/subjects.js +29 -0
  78. package/dist/subjects.js.map +1 -1
  79. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -14,8 +14,8 @@ import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, replyTa
14
14
  import { isRepeatSafeCommand } from "./endpoint-grants.js";
15
15
  import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
16
16
  import { readAcceptedRow } from "./issued-authority.js";
17
- import { liveKvEntries } from "./kv-scan.js";
18
- import { isJetStreamMissing } from "./jetstream-missing.js";
17
+ import { lastKvEntries, liveKvEntries } from "./kv-scan.js";
18
+ import { isConsumerAlreadyExists, isConsumerNotFound, isJetStreamMissing } from "./jetstream-missing.js";
19
19
  import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
20
20
  import { assertValidName } from "./resolve.js";
21
21
  import { EVICT_PRINCIPALS_MAX } from "./evict.js";
@@ -28,7 +28,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
28
28
  import { openAclRegistry, readAcl, readAclForAlias, aclRetired, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
29
29
  import { openDeliveryRegistry } from "./lease.js";
30
30
  import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, decodeChannelEntry, } from "./channels.js";
31
- import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
31
+ import { anycastSubject, assertValidRole, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, historyReaderConsumer, calloutReaderId, HISTORY_READERS, controlServiceSubject, livenessSubject, livenessServeFilter, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
32
32
  import { isPermissionDenied } from "./permission-denied.js";
33
33
  export { isPermissionDenied };
34
34
  export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
@@ -55,6 +55,11 @@ export const MULTI_FILTER_BATCH = 1_000;
55
55
  * issuing one read per channel: a space large enough to need batches must not get the fan-out back
56
56
  * under another name. */
57
57
  export const MULTI_FILTER_READ_CONCURRENCY = 4;
58
+ /** A history reader's inactive threshold. It bounds only a reader whose delete never ran (its link
59
+ * dropped, or its process stopped), because every read deletes its readers when it ends. Five
60
+ * minutes is what the nats.js ordered consumers it replaces carried, and it keeps a reader alive
61
+ * across the gap between pulls on a slow link. */
62
+ const HISTORY_READER_INACTIVE_MS = 5 * 60_000;
58
63
  /** A presence bucket that has refused consecutive writes for at least one full presence TTL.
59
64
  * The condition is non-transient: ordinary heartbeat retry has already failed for the whole
60
65
  * liveness window, so callers should report the roster as last-known until a write succeeds. */
@@ -161,6 +166,7 @@ export class CotalEndpoint extends EventEmitter {
161
166
  doRegister;
162
167
  doWatch;
163
168
  doWatchChannels;
169
+ readOnce;
164
170
  doConsume;
165
171
  ackWaitMs;
166
172
  inactiveThresholdMs;
@@ -262,9 +268,12 @@ export class CotalEndpoint extends EventEmitter {
262
268
  * EXPECTED async permission violation that joinChannel turns into a clean throw, so watchStatus
263
269
  * suppresses it rather than surfacing a spurious connection error. */
264
270
  confirmingChatSubs = new Set();
265
- /** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches,
266
- * running membership scans and running history reads (see {@link recordOwnWatchConsumer}). */
271
+ /** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches and
272
+ * running membership scans (see {@link recordOwnWatchConsumer}). */
267
273
  ownWatchDeletePrefixes = new Set();
274
+ /** Per stream, the history-reader names of this connection's current reader id and the reads
275
+ * waiting for one (see {@link acquireHistoryReader}). */
276
+ historyReaderSlots = new Map();
268
277
  /** True until the first successful connect completes its boot backfill — distinguishes first-connect
269
278
  * (backfill the boot channels' history) from a reconnect (reopen the core-subs, no re-backfill).
270
279
  * Persists across reconnect (NOT connection-scoped). Replaces the legacy chat-durable consumed-cursor
@@ -547,6 +556,9 @@ export class CotalEndpoint extends EventEmitter {
547
556
  this.doWatch = opts.watchPresence ?? true;
548
557
  this.doWatchChannels = opts.watchChannels ?? true;
549
558
  this.doConsume = opts.consume ?? true;
559
+ this.readOnce = opts.readOnce ?? false;
560
+ if (this.readOnce && (this.doConsume || this.doRegister))
561
+ throw new Error("EndpointOptions.readOnce requires consume and registerPresence false: a one-time read cannot keep a consumer's channel cache or its own presence current");
550
562
  // Seed the presence mirror so file-default channel modes are visible from the first publish
551
563
  // (not only after the first runtime toggle). Mirror only — delivery reads the connector's state.
552
564
  this.channelModes = opts.channelModes && Object.keys(opts.channelModes).length ? opts.channelModes : undefined;
@@ -1082,7 +1094,9 @@ export class CotalEndpoint extends EventEmitter {
1082
1094
  ? await kvm.open(presenceBucket(this.space))
1083
1095
  : await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs, storage: PRESENCE_STORAGE });
1084
1096
  }
1085
- if (this.doWatch) {
1097
+ if (this.doWatch && this.readOnce)
1098
+ await this.readPresenceOnce();
1099
+ else if (this.doWatch) {
1086
1100
  await this.startPresenceWatch();
1087
1101
  this.sweepTimer = setInterval(() => this.sweep(), Math.max(500, Math.floor(this.ttlMs / 3)));
1088
1102
  }
@@ -1095,7 +1109,7 @@ export class CotalEndpoint extends EventEmitter {
1095
1109
  if (watchChannels || this.doConsume) {
1096
1110
  this.channelKv = await openChannelRegistry(this.nc, this.space, { create: !this.authed });
1097
1111
  if (watchChannels)
1098
- await this.startChannelWatch();
1112
+ await (this.readOnce ? this.readChannelsOnce() : this.startChannelWatch());
1099
1113
  }
1100
1114
  // Rebind the daemon's lease-loss watch onto the fresh connection (#1596). The intent survived
1101
1115
  // clearConnectionScoped; a bind that completes after a stop or another rebuild finds the epoch
@@ -2774,16 +2788,16 @@ export class CotalEndpoint extends EventEmitter {
2774
2788
  watch.resolveStop = undefined;
2775
2789
  watch.rejectStop = undefined;
2776
2790
  }
2777
- /** Record the consumer behind one of this connection's own KV watches, membership scans or history
2778
- * reads, and return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
2791
+ /** Record the consumer behind one of this connection's own KV watches or membership scans, and
2792
+ * return the delete-subject prefix it recorded. nats.js names it `<prefix>_<serial>` and,
2779
2793
  * rebuilding it after a stall or a sequence gap, deletes the predecessor itself with no hook before
2780
2794
  * the send, so a profile without the delete row (#691) has that delete refused. A stalled link can
2781
2795
  * time that delete out before its refusal arrives, and nats.js then hands the refusal to no request,
2782
2796
  * so {@link trackRequestDenials} cannot own it and the consumer's name has to. A watch's prefix
2783
2797
  * stays recorded for the connection because a retired watch's last rebuild can be refused after
2784
- * the watch has stopped; {@link readMembership} and {@link drainWindow} retire theirs after their own
2785
- * last delete, which goes out once nats.js has stopped the consumer and so is answered after every
2786
- * predecessor delete. */
2798
+ * the watch has stopped; {@link readMembership} retires its scan's after its own last delete,
2799
+ * which goes out once nats.js has stopped the consumer and so is answered after every predecessor
2800
+ * delete. */
2787
2801
  recordOwnWatchConsumer({ stream_name, name }) {
2788
2802
  const prefix = `$JS.API.CONSUMER.DELETE.${stream_name}.${name.slice(0, name.lastIndexOf("_") + 1)}`;
2789
2803
  this.ownWatchDeletePrefixes.add(prefix);
@@ -2794,9 +2808,9 @@ export class CotalEndpoint extends EventEmitter {
2794
2808
  const cut = subject.lastIndexOf("_") + 1;
2795
2809
  return /^\d+$/.test(subject.slice(cut)) && this.ownWatchDeletePrefixes.has(subject.slice(0, cut));
2796
2810
  }
2797
- /** Delete one of this connection's own ephemeral consumers, a history reader's or a KV watch's. A
2798
- * refused delete is the designed outcome for a profile without a stream-wide CONSUMER.DELETE
2799
- * (#691): the broker reaps the consumer at its inactive threshold. */
2811
+ /** Delete one of this connection's own KV-watch consumers. A refused delete is the designed
2812
+ * outcome for a profile without a stream-wide CONSUMER.DELETE (#691): the broker reaps the
2813
+ * consumer at its inactive threshold. */
2800
2814
  async deleteOwnConsumer(consumer) {
2801
2815
  try {
2802
2816
  await consumer.delete();
@@ -3090,8 +3104,10 @@ export class CotalEndpoint extends EventEmitter {
3090
3104
  throw new Error(`history limit must be a whole number of messages this server can count exactly, received ${limit}`);
3091
3105
  if (limit <= 0)
3092
3106
  return [];
3093
- const js = jetstream(this.nc);
3107
+ const nc = this.nc;
3108
+ let reader;
3094
3109
  try {
3110
+ reader = await this.acquireHistoryReader(nc, this.jsm ?? (this.jsm = await jetstreamManager(nc)), stream, signal);
3095
3111
  // THE EXACT CEILING, on the already-granted surface. A one-shot consumer with
3096
3112
  // `DeliverPolicy.Last` plus this subject's filter reports the newest MATCHING sequence, and its
3097
3113
  // `num_pending` of zero means the channel is genuinely empty. Using the STREAM's last sequence
@@ -3102,7 +3118,7 @@ export class CotalEndpoint extends EventEmitter {
3102
3118
  // Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
3103
3119
  // needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
3104
3120
  // shipped once from this function and turned every non-admin history read into an empty list.
3105
- const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subjects, signal);
3121
+ const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(reader, subjects, signal);
3106
3122
  if (ceiling < 1)
3107
3123
  return [];
3108
3124
  // Widen from the exact ceiling until a window holds a full page, or until the window IS the
@@ -3144,11 +3160,11 @@ export class CotalEndpoint extends EventEmitter {
3144
3160
  for (;;) {
3145
3161
  signal?.throwIfAborted();
3146
3162
  const start = Math.max(floor, ceiling - span + 1);
3147
- const page = await this.drainWindow(js, stream, subjects, start, ceiling, limit, signal);
3163
+ const page = await this.drainWindow(reader, subjects, start, ceiling, limit, signal);
3148
3164
  if (page.length >= limit)
3149
3165
  return page.slice(-limit);
3150
3166
  if (!floorKnown) {
3151
- floor = Math.max(1, await this.firstMatchingSeq(js, stream, subjects, signal));
3167
+ floor = Math.max(1, await this.firstMatchingSeq(reader, subjects, signal));
3152
3168
  floorKnown = true;
3153
3169
  }
3154
3170
  if (start <= floor)
@@ -3178,13 +3194,175 @@ export class CotalEndpoint extends EventEmitter {
3178
3194
  return [];
3179
3195
  throw e;
3180
3196
  }
3197
+ finally {
3198
+ if (reader)
3199
+ this.releaseHistoryReader(reader);
3200
+ }
3201
+ }
3202
+ /** This connection's history-reader id, the key of its credential's reader deletes
3203
+ * ({@link historyReaderConsumer}). A static credential's is its connId, the nkey it signs with.
3204
+ * Under the auth callout the connId is the inbox nonce this client chose, so the callout keys the
3205
+ * deletes on {@link calloutReaderId} instead: the server's id and the client id it assigned this
3206
+ * connection, which the client reads from the server's INFO and does not choose. */
3207
+ historyReaderId(nc) {
3208
+ if (!this.userMode)
3209
+ return this.connId;
3210
+ const info = nc.info;
3211
+ if (!info)
3212
+ throw new Error("history: the server has not sent its INFO, so this connection has no history reader id yet");
3213
+ return calloutReaderId(info.server_id, info.client_id);
3214
+ }
3215
+ /** Take one of this connection's history-reader names on `stream` ({@link historyReaderConsumer})
3216
+ * for one read. The read holds the name for its whole walk and creates and deletes a consumer
3217
+ * under it at each step, so this connection keeps at most {@link HISTORY_READERS} reader consumers
3218
+ * on a stream (#4123). A read past that many waits for a name, and `signal` ends the wait. A
3219
+ * reconnect under the auth callout changes the reader id, and reads from then on take the new
3220
+ * id's names. */
3221
+ async acquireHistoryReader(nc, jsm, stream, signal) {
3222
+ for (;;) {
3223
+ signal?.throwIfAborted();
3224
+ const readerId = this.historyReaderId(nc);
3225
+ let pool = this.historyReaderSlots.get(stream);
3226
+ if (pool?.readerId !== readerId) {
3227
+ pool = { readerId, free: Array.from({ length: HISTORY_READERS }, (_, i) => HISTORY_READERS - 1 - i), held: 0, waiting: [], refusedUntil: 0 };
3228
+ this.historyReaderSlots.set(stream, pool);
3229
+ }
3230
+ // A read here would leave one more reader it may not delete, so it fails before creating one.
3231
+ if (pool.refusedUntil > Date.now())
3232
+ throw this.readerDeleteRefused(stream);
3233
+ const slot = pool.free.pop();
3234
+ if (slot !== undefined) {
3235
+ pool.held++;
3236
+ return { nc, js: jetstream(nc), jsm, stream, pool, slot, name: historyReaderConsumer(readerId, slot), held: true, left: false };
3237
+ }
3238
+ // No read here holds a name, so none will come back from one: every name is set aside (see
3239
+ // openHistoryReader and closeHistoryReader). Waiting would hang until those expire.
3240
+ if (pool.held === 0)
3241
+ throw new Error(`history: every history reader name this connection has on ${stream} is set aside, held by a consumer it did not create (another process using the same credential, or one that stopped mid-read) or by a reader it could not confirm deleted; they expire ${HISTORY_READER_INACTIVE_MS / 60_000} minutes after their last use`);
3242
+ const waitOn = pool;
3243
+ await new Promise((resolve, reject) => {
3244
+ const wake = () => { signal?.removeEventListener("abort", stop); resolve(); };
3245
+ const stop = () => { waitOn.waiting.splice(waitOn.waiting.indexOf(wake), 1); reject(abortReason(signal)); };
3246
+ waitOn.waiting.push(wake);
3247
+ signal?.addEventListener("abort", stop, { once: true });
3248
+ });
3249
+ }
3250
+ }
3251
+ /** Give a read's history-reader name back and wake one read waiting for a name. A name whose
3252
+ * delete failed is set aside instead. */
3253
+ releaseHistoryReader(reader) {
3254
+ if (!reader.held)
3255
+ return;
3256
+ reader.held = false;
3257
+ const pool = reader.pool;
3258
+ pool.held--;
3259
+ if (!reader.left) {
3260
+ pool.free.push(reader.slot);
3261
+ pool.waiting.shift()?.();
3262
+ return;
3263
+ }
3264
+ this.setAsideHistoryReader(pool, reader.slot);
3265
+ // Every waiter checks again, so one that no running read can now serve fails instead of hanging.
3266
+ if (pool.held === 0)
3267
+ for (const wake of pool.waiting.splice(0))
3268
+ wake();
3269
+ }
3270
+ /** Keep `slot` out of use until a consumer under it would have expired, then free it. */
3271
+ setAsideHistoryReader(pool, slot) {
3272
+ setTimeout(() => { pool.free.push(slot); pool.waiting.shift()?.(); }, HISTORY_READER_INACTIVE_MS).unref();
3273
+ }
3274
+ /** Create the reader consumer for one step of a read, under the read's name. It is a plain pull
3275
+ * consumer: an nats.js ordered consumer renames itself on every rebuild, and the observer and admin
3276
+ * credentials may delete only these exact names.
3277
+ *
3278
+ * Every create carries a fresh description. NATS answers a create whose config equals the existing
3279
+ * consumer's with that consumer, delivery state and all (measured on nats-server 2.15), and a read
3280
+ * repeats the same config on the same name. Without the description a reader left under the name
3281
+ * would come back as if new: a `DeliverPolicy.Last` reader that already delivered its message
3282
+ * reports nothing pending, which reads as an empty channel. With it, a create on a name in use is
3283
+ * refused, and a consumer that does not carry this step's description is never used.
3284
+ *
3285
+ * A consumer already under the name is never deleted, whoever left it: another holder of this
3286
+ * credential, a process that stopped mid-read, or this endpoint when a delete's answer was lost and
3287
+ * the name was taken again since. The read moves to a free name, and the taken one stays out of use
3288
+ * until such a consumer would have expired. */
3289
+ async openHistoryReader(reader, config) {
3290
+ const pool = reader.pool;
3291
+ for (;;) {
3292
+ // A reconnect under the auth callout moved this connection to new reader names, and its
3293
+ // credential may no longer delete this one. Creating under it would leave the reader behind.
3294
+ if (this.historyReaderId(reader.nc) !== pool.readerId)
3295
+ throw new Error(`history: the connection was re-established during the read on ${reader.stream}, so its history reader names changed; the read is not served`);
3296
+ const description = `cotal history read ${randomUUID()}`;
3297
+ let info;
3298
+ try {
3299
+ info = await reader.jsm.consumers.add(reader.stream, {
3300
+ ...config,
3301
+ name: reader.name,
3302
+ description,
3303
+ ack_policy: AckPolicy.None,
3304
+ max_deliver: 1,
3305
+ mem_storage: true,
3306
+ num_replicas: 1,
3307
+ inactive_threshold: nanos(HISTORY_READER_INACTIVE_MS),
3308
+ });
3309
+ }
3310
+ catch (e) {
3311
+ if (!isConsumerAlreadyExists(e))
3312
+ throw e;
3313
+ }
3314
+ if (info?.config.description === description)
3315
+ return reader.js.consumers.getConsumerFromInfo(info);
3316
+ this.setAsideHistoryReader(pool, reader.slot);
3317
+ const next = pool.free.pop();
3318
+ if (next === undefined) {
3319
+ // The read ends holding no name. Every waiter checks again, so one that no running read can
3320
+ // now serve fails instead of hanging.
3321
+ reader.held = false;
3322
+ pool.held--;
3323
+ for (const wake of pool.waiting.splice(0))
3324
+ wake();
3325
+ throw new Error(`history: reader ${reader.name} on ${reader.stream} is held by a consumer this read did not create, and no other reader name is free`);
3326
+ }
3327
+ reader.slot = next;
3328
+ reader.name = historyReaderConsumer(pool.readerId, next);
3329
+ }
3330
+ }
3331
+ /** Delete the read's reader consumer; already gone is the same outcome. A failed delete may still
3332
+ * have run with its answer lost, and the name may be taken again before this endpoint could tell,
3333
+ * so the name is set aside when the read gives it back rather than deleted again later. A refused
3334
+ * delete means the credential was minted before the observer and admin profiles held their reader
3335
+ * deletes. Each read would then leave a reader for the inactive threshold, which is how a polling
3336
+ * dashboard filled the stream's consumer limit (#4123), so the read fails and says to remint
3337
+ * rather than leak quietly, and reads on the stream fail at once until that reader expires. */
3338
+ async closeHistoryReader(reader) {
3339
+ try {
3340
+ await reader.jsm.consumers.delete(reader.stream, reader.name);
3341
+ }
3342
+ catch (e) {
3343
+ if (isConsumerNotFound(e))
3344
+ return;
3345
+ reader.left = true;
3346
+ // No `cause`: a permission-shaped cause would let streamHistory read this as the admin-only
3347
+ // DM denial and return an empty page.
3348
+ if (isPublishPermissionDenied(e)) {
3349
+ if (this.historyReaderId(reader.nc) !== reader.pool.readerId)
3350
+ throw new Error(`history: the connection was re-established during the read on ${reader.stream}, so it may no longer delete its reader ${reader.name}, which expires on its own; the read is not served`);
3351
+ reader.pool.refusedUntil = Date.now() + HISTORY_READER_INACTIVE_MS;
3352
+ throw this.readerDeleteRefused(reader.stream);
3353
+ }
3354
+ throw e;
3355
+ }
3356
+ }
3357
+ readerDeleteRefused(stream) {
3358
+ return new Error(`history: this credential may not delete its own history readers on ${stream}; it predates the observer and admin reader-delete grant, so remint it`);
3181
3359
  }
3182
3360
  /** The oldest stream sequence matching `subject`, or 0 when the subject has no messages.
3183
3361
  * Mirror of {@link lastMatchingSeq}: same CREATE/INFO/NEXT/DELETE surface, `DeliverPolicy.All`
3184
3362
  * instead of `Last`, first delivered seq instead of last. Read credentials already hold this. */
3185
- async firstMatchingSeq(js, stream, subjects, signal) {
3363
+ async firstMatchingSeq(reader, subjects, signal) {
3186
3364
  signal?.throwIfAborted();
3187
- const consumer = await js.consumers.get(stream, {
3365
+ const consumer = await this.openHistoryReader(reader, {
3188
3366
  filter_subjects: subjects,
3189
3367
  deliver_policy: DeliverPolicy.All,
3190
3368
  });
@@ -3207,18 +3385,18 @@ export class CotalEndpoint extends EventEmitter {
3207
3385
  throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
3208
3386
  }
3209
3387
  finally {
3210
- await this.deleteOwnConsumer(consumer);
3388
+ await this.closeHistoryReader(reader);
3211
3389
  }
3212
3390
  }
3213
3391
  /** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
3214
3392
  *
3215
- * One ordered consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
3393
+ * One reader consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
3216
3394
  * (available from the create, before anything is delivered) is 0 for an empty subject, and
3217
3395
  * otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
3218
3396
  * already uses, so no broker authority is added. */
3219
- async lastMatchingSeq(js, stream, subjects, signal) {
3397
+ async lastMatchingSeq(reader, subjects, signal) {
3220
3398
  signal?.throwIfAborted();
3221
- const consumer = await js.consumers.get(stream, {
3399
+ const consumer = await this.openHistoryReader(reader, {
3222
3400
  filter_subjects: subjects,
3223
3401
  deliver_policy: DeliverPolicy.Last,
3224
3402
  });
@@ -3247,19 +3425,21 @@ export class CotalEndpoint extends EventEmitter {
3247
3425
  throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
3248
3426
  }
3249
3427
  finally {
3250
- await this.deleteOwnConsumer(consumer);
3428
+ await this.closeHistoryReader(reader);
3251
3429
  }
3252
3430
  }
3253
3431
  /** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
3254
- * One ephemeral ordered consumer, one batched pull — `AckPolicy.None`, so no per-message ack
3432
+ * One reader consumer, one batched pull — `AckPolicy.None`, so no per-message ack
3255
3433
  * round trip. Fetches exactly the pending count so it returns as soon as the window is
3256
3434
  * delivered rather than blocking for the pull's full expiry. */
3257
- async drainWindow(js, stream, subjects, start, ceiling, limit, signal) {
3435
+ async drainWindow(reader, subjects, start, ceiling, limit, signal) {
3258
3436
  signal?.throwIfAborted();
3259
3437
  const out = [];
3260
- const consumer = await js.consumers.get(stream, { filter_subjects: subjects, opt_start_seq: start });
3261
- // A delivery-sequence gap makes nats.js rebuild this consumer and delete the predecessor itself.
3262
- const readerPrefix = this.recordOwnWatchConsumer(await consumer.info(true));
3438
+ const consumer = await this.openHistoryReader(reader, {
3439
+ filter_subjects: subjects,
3440
+ deliver_policy: DeliverPolicy.StartSequence,
3441
+ opt_start_seq: start,
3442
+ });
3263
3443
  try {
3264
3444
  // A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
3265
3445
  // explicit uncached `info()` this used to call was a round trip for data we already had.
@@ -3285,6 +3465,10 @@ export class CotalEndpoint extends EventEmitter {
3285
3465
  signal?.throwIfAborted();
3286
3466
  for await (const m of iter) {
3287
3467
  delivered++;
3468
+ // A plain consumer does not rebuild itself the way an ordered one did, so a gap in its
3469
+ // delivery sequence is a message this page would silently lack.
3470
+ if (m.info.deliverySequence !== delivered)
3471
+ throw new Error(`history: delivery ${m.info.deliverySequence} arrived where ${delivered} was due on ${subjectLabel(subjects)} - the window has a gap, so the page is not served`);
3288
3472
  if (m.seq >= ceiling) { // reached the page's upper bound
3289
3473
  if (m.seq === ceiling) {
3290
3474
  const msg = historyMessageFromDelivery(m);
@@ -3315,16 +3499,12 @@ export class CotalEndpoint extends EventEmitter {
3315
3499
  return out;
3316
3500
  }
3317
3501
  finally {
3318
- // DELETE THE EPHEMERAL CONSUMER. The pinned client gives an ordered consumer a 5-minute
3319
- // inactive threshold, so leaving them behind is not free: the widening search below can make
3320
- // up to eight per call, and a reload repeats every call the page makes. The dashboard used to
3321
- // make one per channel; since #1210 its activity feed makes one for all of chat and one for
3322
- // DMs, and the single-channel routes still make one each.
3323
- // Left alone that accumulates consumers on the broker until the thresholds expire, and the
3324
- // resulting resource exhaustion would land in streamHistory's catch and read as empty history.
3325
- // The observer and admin profiles may not delete (#691), so the dashboard's readers do live out
3326
- // that threshold; only a profile holding the delete row reclaims them here.
3327
- await this.deleteOwnConsumer(consumer).finally(() => { this.ownWatchDeletePrefixes.delete(readerPrefix); });
3502
+ // DELETE THE READER. Left alone it would stay for its inactive threshold, and the widening
3503
+ // search can run several steps per call while a polling dashboard repeats every call on each
3504
+ // poll. The observer and admin profiles used to hold no delete at all (#691), so a dashboard
3505
+ // polling every ten seconds filled the stream's consumer limit and every later read failed
3506
+ // (#4123). Each reader now goes when its step ends.
3507
+ await this.closeHistoryReader(reader);
3328
3508
  }
3329
3509
  }
3330
3510
  // ---- internals -----------------------------------------------------------
@@ -3945,12 +4125,15 @@ export class CotalEndpoint extends EventEmitter {
3945
4125
  * `undefined` means the key IS NOT THERE — a definite absence, established by a completed read.
3946
4126
  * A read that could not be completed THROWS instead, so a caller can tell "it is gone" from "I could
3947
4127
  * not find out". That distinction is the whole point of the method: a renew that got no answer has
3948
- * proved nothing, and only a definite answer here may be acted on. */
4128
+ * proved nothing, and only a definite answer here may be acted on.
4129
+ *
4130
+ * `created` is the broker's write time of the row (epoch ms), which is what the bucket TTL counts
4131
+ * from. */
3949
4132
  async readOwnManagerLease(instanceId) {
3950
4133
  const e = await (await this.managerLeaseRegistry()).get(managerLeaseKey(instanceId));
3951
4134
  if (!e || e.operation !== "PUT")
3952
4135
  return undefined;
3953
- return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision };
4136
+ return { info: JSON.parse(new TextDecoder().decode(e.value)), revision: e.revision, created: e.created.getTime() };
3954
4137
  }
3955
4138
  /** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
3956
4139
  * by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
@@ -6196,6 +6379,20 @@ export class CotalEndpoint extends EventEmitter {
6196
6379
  })().catch((e) => this.emit("error", e));
6197
6380
  return info;
6198
6381
  }
6382
+ /** {@link EndpointOptions.readOnce}: fill the roster from one read of the presence bucket. Stale
6383
+ * and offline records resolve as they do in a watch's initial replay. */
6384
+ async readPresenceOnce() {
6385
+ if (!this.kv)
6386
+ return;
6387
+ for (const e of await lastKvEntries(this.kv))
6388
+ this.handleKvEntry(e);
6389
+ this.presenceSnapshotPopulated = true;
6390
+ // No record reached the roster (an empty bucket, or only the TTL canary): nobody is present.
6391
+ if (this.lastPresenceWatchAt === 0)
6392
+ this.markPresenceBucketEmpty();
6393
+ else
6394
+ this.emitPresenceViewIfChanged();
6395
+ }
6199
6396
  /**
6200
6397
  * Replace a presence watch that has gone silent past TTL while the connection is up. The new
6201
6398
  * ordered consumer starts from the bucket's current last-per-subject state, so a peer that is
@@ -6270,6 +6467,13 @@ export class CotalEndpoint extends EventEmitter {
6270
6467
  this.handleChannelEntry(e);
6271
6468
  })().catch((e) => this.emit("error", e));
6272
6469
  }
6470
+ /** {@link EndpointOptions.readOnce}: fill the channel cache from one read of the registry. */
6471
+ async readChannelsOnce() {
6472
+ if (!this.channelKv)
6473
+ return;
6474
+ for (const e of await lastKvEntries(this.channelKv))
6475
+ this.handleChannelEntry(e);
6476
+ }
6273
6477
  handleChannelEntry(e) {
6274
6478
  // Cleared before the decode, so an entry that does not decode resolves to the default as the
6275
6479
  // fresh registry reads do.