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