@cotal-ai/core 0.115.4 → 0.116.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.
Files changed (48) hide show
  1. package/dist/channels.d.ts +18 -2
  2. package/dist/channels.d.ts.map +1 -1
  3. package/dist/channels.js +39 -18
  4. package/dist/channels.js.map +1 -1
  5. package/dist/endpoint-action.d.ts +2 -1
  6. package/dist/endpoint-action.d.ts.map +1 -1
  7. package/dist/endpoint-action.js +9 -6
  8. package/dist/endpoint-action.js.map +1 -1
  9. package/dist/endpoint-checkpoint.d.ts.map +1 -1
  10. package/dist/endpoint-checkpoint.js +5 -2
  11. package/dist/endpoint-checkpoint.js.map +1 -1
  12. package/dist/endpoint-contract-store.d.ts.map +1 -1
  13. package/dist/endpoint-contract-store.js +2 -1
  14. package/dist/endpoint-contract-store.js.map +1 -1
  15. package/dist/endpoint-receipt.d.ts.map +1 -1
  16. package/dist/endpoint-receipt.js +2 -2
  17. package/dist/endpoint-receipt.js.map +1 -1
  18. package/dist/endpoint-work.d.ts.map +1 -1
  19. package/dist/endpoint-work.js +3 -5
  20. package/dist/endpoint-work.js.map +1 -1
  21. package/dist/endpoint.d.ts +51 -14
  22. package/dist/endpoint.d.ts.map +1 -1
  23. package/dist/endpoint.js +202 -78
  24. package/dist/endpoint.js.map +1 -1
  25. package/dist/manager-caller.d.ts +1 -0
  26. package/dist/manager-caller.d.ts.map +1 -1
  27. package/dist/manager-caller.js +3 -1
  28. package/dist/manager-caller.js.map +1 -1
  29. package/dist/peer-label.d.ts +11 -0
  30. package/dist/peer-label.d.ts.map +1 -1
  31. package/dist/peer-label.js +11 -0
  32. package/dist/peer-label.js.map +1 -1
  33. package/dist/provision.d.ts.map +1 -1
  34. package/dist/provision.js +12 -0
  35. package/dist/provision.js.map +1 -1
  36. package/dist/resolve.d.ts +1 -0
  37. package/dist/resolve.d.ts.map +1 -1
  38. package/dist/resolve.js +3 -1
  39. package/dist/resolve.js.map +1 -1
  40. package/dist/run-driver-grants.d.ts.map +1 -1
  41. package/dist/run-driver-grants.js +4 -1
  42. package/dist/run-driver-grants.js.map +1 -1
  43. package/dist/streams.d.ts.map +1 -1
  44. package/dist/streams.js +2 -2
  45. package/dist/streams.js.map +1 -1
  46. package/dist/types.d.ts +20 -0
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
package/dist/endpoint.js CHANGED
@@ -162,6 +162,31 @@ export function fitHistoryPage(items, budget) {
162
162
  }
163
163
  return first === items.length ? [] : items.slice(first);
164
164
  }
165
+ /** One feed entry, decoded the way {@link CotalEndpoint.readMembership} decodes it: the reserved
166
+ * heartbeat key carries `asOf`, every other key is one agent's record, and an undecodable record
167
+ * is a member change with no record (never a crash in the consumer callback). */
168
+ function membershipChange(e, update) {
169
+ const operation = e.operation;
170
+ if (e.key === MEMBERSHIP_FEED_KEY) {
171
+ let asOf;
172
+ if (operation === "PUT") {
173
+ try {
174
+ asOf = e.json().observedAt;
175
+ }
176
+ catch { /* heartbeat garbled; leave undefined */ }
177
+ }
178
+ return { type: "heartbeat", revision: e.revision, update, asOf };
179
+ }
180
+ let member;
181
+ if (operation === "PUT") {
182
+ try {
183
+ const rec = e.json();
184
+ member = { id: e.key, live: rec.live ?? [], durable: rec.durable ?? [], observedAt: rec.observedAt };
185
+ }
186
+ catch { /* undecodable: delivered as a change with no record */ }
187
+ }
188
+ return { type: "member", id: e.key, operation, revision: e.revision, update, member };
189
+ }
165
190
  export class CotalEndpoint extends EventEmitter {
166
191
  card;
167
192
  space;
@@ -226,6 +251,15 @@ export class CotalEndpoint extends EventEmitter {
226
251
  * `DrainingConnectionError` out of `reset()` on a timer nothing awaits. */
227
252
  presenceWatchIter;
228
253
  channelWatchIter;
254
+ /** Consumer deletes of retired presence and channel-registry watches still in flight. A stop
255
+ * alone leaves each watch's ordered consumer on the broker for its five-minute inactive
256
+ * threshold (#3914), so {@link stop} and a live-epoch rebuild await these before they close the
257
+ * connection that carries them, as they do for the lease watch. */
258
+ kvWatchDeletes = new Set();
259
+ /** Presence and channel-registry watch binds still awaiting the broker. One that lands after
260
+ * {@link stop} or a rebuild moved the epoch retires its watch and starts that delete itself, so
261
+ * the same two paths wait for these first. */
262
+ kvWatchBinds = new Set();
229
263
  /** The daemon's lease-loss watch (#1596): the INTENT that survives a connection rebuild, plus the
230
264
  * live iterator. See {@link watchDeliveryLease}. */
231
265
  leaseWatchIntent;
@@ -1413,19 +1447,11 @@ export class CotalEndpoint extends EventEmitter {
1413
1447
  this.streamMsgs.length = 0;
1414
1448
  this.presenceEpoch++;
1415
1449
  this.presenceRebind = undefined;
1416
- try {
1417
- this.presenceWatchIter?.stop();
1418
- }
1419
- catch {
1420
- /* already closed with the connection */
1421
- }
1450
+ if (this.presenceWatchIter)
1451
+ this.retireKvWatch(this.presenceWatchIter);
1422
1452
  this.presenceWatchIter = undefined;
1423
- try {
1424
- this.channelWatchIter?.stop();
1425
- }
1426
- catch {
1427
- /* already closed with the connection */
1428
- }
1453
+ if (this.channelWatchIter)
1454
+ this.retireKvWatch(this.channelWatchIter);
1429
1455
  this.channelWatchIter = undefined;
1430
1456
  this.stopDeliveryLeaseWatch();
1431
1457
  for (const sub of this.chatSubs.values()) {
@@ -1635,9 +1661,10 @@ export class CotalEndpoint extends EventEmitter {
1635
1661
  try {
1636
1662
  this.clearConnectionScoped();
1637
1663
  // Manual reconnect still has a live old epoch: complete broker-consumer cleanup before close.
1638
- // Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup.
1664
+ // Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup,
1665
+ // and the KV watch consumers that closed with it are left to the broker's inactive threshold.
1639
1666
  if (oldNc && !oldNc.isClosed())
1640
- await Promise.all([...[...this.membershipFeedWatches].map((watch) => watch.arm), this.settleDeliveryLeaseWatches()]);
1667
+ await Promise.all([...[...this.membershipFeedWatches].map((watch) => watch.arm), this.settleDeliveryLeaseWatches(), this.settleKvWatches()]);
1641
1668
  this.nc = undefined;
1642
1669
  this.js = undefined;
1643
1670
  this.jsm = undefined;
@@ -1818,19 +1845,11 @@ export class CotalEndpoint extends EventEmitter {
1818
1845
  /* already closed */
1819
1846
  }
1820
1847
  }
1821
- try {
1822
- this.presenceWatchIter?.stop();
1823
- }
1824
- catch {
1825
- /* already closed */
1826
- }
1848
+ if (this.presenceWatchIter)
1849
+ this.retireKvWatch(this.presenceWatchIter);
1827
1850
  this.presenceWatchIter = undefined;
1828
- try {
1829
- this.channelWatchIter?.stop();
1830
- }
1831
- catch {
1832
- /* already closed */
1833
- }
1851
+ if (this.channelWatchIter)
1852
+ this.retireKvWatch(this.channelWatchIter);
1834
1853
  this.channelWatchIter = undefined;
1835
1854
  this.stopDeliveryLeaseWatch();
1836
1855
  try {
@@ -1842,7 +1861,7 @@ export class CotalEndpoint extends EventEmitter {
1842
1861
  catch {
1843
1862
  /* best-effort graceful leave */
1844
1863
  }
1845
- await this.settleDeliveryLeaseWatches();
1864
+ await Promise.all([this.settleDeliveryLeaseWatches(), this.settleKvWatches()]);
1846
1865
  // DM-only: the pull above is stopped, so no new copy reaches the handler. Let a running handler
1847
1866
  // settle while the connection is still open, so its ack can reach the broker; close either way.
1848
1867
  let dmOnlyUnsettled = false;
@@ -2320,18 +2339,20 @@ export class CotalEndpoint extends EventEmitter {
2320
2339
  }
2321
2340
  }
2322
2341
  /** The resolved, digest-verified command surface of `endpoint` as this connection's caller sees
2323
- * it: the same resolve and the same per-name cache {@link invokeService} uses, so a describe
2324
- * followed by an invoke binds both to one incarnation. `refresh` drops the cached entry first. */
2342
+ * it. Unpinned calls share {@link invokeService}'s per-name cache, and `refresh` drops that entry.
2343
+ * `instanceId` selects one instance without reading or populating the class cache, as a pinned
2344
+ * invoke does. The caller's existing credential must permit that route. */
2325
2345
  async describeService(endpoint, opts = {}) {
2326
2346
  if (!this.nc)
2327
2347
  throw new Error(this.notLiveMsg());
2328
2348
  if (opts.refresh)
2329
2349
  this.resolvedServices.delete(endpoint);
2330
- const cached = this.resolvedServices.get(endpoint);
2350
+ const cached = opts.instanceId === undefined ? this.resolvedServices.get(endpoint) : undefined;
2331
2351
  if (cached)
2332
2352
  return cached;
2333
- const svc = await resolveService(this.nc, this.space, endpoint, this.serviceCaller(), { deadlineMs: opts.deadlineMs ?? 10_000, signal: opts.signal });
2334
- this.resolvedServices.set(endpoint, svc);
2353
+ const svc = await resolveService(this.nc, this.space, endpoint, this.serviceCaller(), { deadlineMs: opts.deadlineMs ?? 10_000, signal: opts.signal, ...(opts.instanceId !== undefined ? { instanceId: opts.instanceId } : {}) });
2354
+ if (opts.instanceId === undefined)
2355
+ this.resolvedServices.set(endpoint, svc);
2335
2356
  return svc;
2336
2357
  }
2337
2358
  /** GENERIC v0.4 service invoke over this endpoint's own connection: resolve
@@ -3016,19 +3037,20 @@ export class CotalEndpoint extends EventEmitter {
3016
3037
  const kv = await this.membershipFeedRegistry();
3017
3038
  const members = [];
3018
3039
  let asOf;
3019
- // ONE pass. This was `kv.keys()` followed by a sequential `kv.get()` per key — O(N) round trips,
3020
- // measured at 30-34s for 89 entries against a mesh at 534ms RTT. `liveKvEntries` is ~3 round
3021
- // trips regardless of N, and (unlike the old loop) refuses to return a truncated view rather
3022
- // than reporting a partial roster as the whole one.
3023
- let scanPrefix;
3024
- const entries = await liveKvEntries(kv, {
3025
- onConsumer: (info) => { scanPrefix = this.recordOwnWatchConsumer(info); },
3026
- }).finally(() => {
3027
- // The scan's own last delete goes out after nats.js closed its consumer, so every predecessor
3028
- // delete the library sent is answered ahead of it on this connection.
3029
- if (scanPrefix)
3030
- this.ownWatchDeletePrefixes.delete(scanPrefix);
3031
- });
3040
+ // Paged batched Direct Gets, as the on-demand presence read: a few round trips regardless of N,
3041
+ // a short page refused rather than reported as the whole feed, and no consumer bound. The scan
3042
+ // this replaced bound an ordered consumer per read whose delete the observer and admin profiles
3043
+ // may not send (#691), so every dashboard poll left a consumer to the broker's inactive threshold
3044
+ // and a refused `CONSUMER.DELETE` line in its log (#4123).
3045
+ let entries;
3046
+ try {
3047
+ entries = await lastKvEntries(kv);
3048
+ }
3049
+ catch (e) {
3050
+ if (!isPublishPermissionDenied(e))
3051
+ throw e;
3052
+ throw new Error(`membership read refused for ${this.card.id}: this credential may not publish $JS.API.DIRECT.GET.KV_${membershipBucket(this.space)}, the membership feed read (SPEC Appendix B). A credential minted by an older issuer can lack this grant even when newly minted; upgrade the manager or auth service that issues credentials, then mint a new credential. Reusing credentials or reconnecting does not add the grant.`, { cause: e });
3053
+ }
3032
3054
  for (const e of entries) {
3033
3055
  if (e.key === MEMBERSHIP_FEED_KEY) {
3034
3056
  try {
@@ -3045,10 +3067,12 @@ export class CotalEndpoint extends EventEmitter {
3045
3067
  }
3046
3068
  return { asOf, members };
3047
3069
  }
3048
- /** Watch the membership feed for changes (admin/observer): `onChange` fires on every KV entry,
3049
- * including the initial replay — the caller debounces + re-reads {@link readMembership}. The async
3050
- * stop handle resolves only after its ordered broker consumer is deleted. Best-effort: a feed the
3051
- * cred can't read (or absent) surfaces as an `error` event and the dashboard keeps its last snapshot. */
3070
+ /** Watch the membership feed for changes (admin/observer): `onChange` receives every KV entry as a
3071
+ * {@link MembershipChange} — the decoded member record (or its deletion), or the feed heartbeat's
3072
+ * `asOf` — so a caller keeps its snapshot current without re-reading the bucket; the entries replayed
3073
+ * when the watch (re)arms carry `update: false` and end with a `replayed` change. The async stop
3074
+ * handle resolves only after its ordered broker consumer is deleted. Best-effort: a feed the cred
3075
+ * can't read (or absent) surfaces as an `error` event and the dashboard keeps its last snapshot. */
3052
3076
  async watchMembership(onChange, onClosed) {
3053
3077
  if (this.stopped)
3054
3078
  throw new Error("endpoint stopped - cannot watch membership");
@@ -3102,11 +3126,20 @@ export class CotalEndpoint extends EventEmitter {
3102
3126
  watch.consumer = consumer;
3103
3127
  watch.consumerStream = info.stream_name;
3104
3128
  watch.consumerName = info.name;
3129
+ // The replay is the bucket's last value per key, bound at `info`; it ends with the entry that
3130
+ // leaves nothing pending (the same rule as nats.js `watch()`'s `initializedFn`).
3131
+ let replaying = info.num_pending > 0;
3105
3132
  let iter;
3106
3133
  try {
3107
- iter = await consumer.consume({ callback: () => {
3108
- if (!watch.stopped && watch.consumer === consumer)
3109
- watch.onChange();
3134
+ iter = await consumer.consume({ callback: (jm) => {
3135
+ if (watch.stopped || watch.consumer !== consumer)
3136
+ return;
3137
+ const entry = kv.jmToWatchEntry(jm, !replaying);
3138
+ watch.onChange(membershipChange(entry, !replaying));
3139
+ if (replaying && jm.info.pending === 0) {
3140
+ replaying = false;
3141
+ watch.onChange({ type: "replayed", revision: entry.revision });
3142
+ }
3110
3143
  } });
3111
3144
  }
3112
3145
  catch (err) {
@@ -3118,6 +3151,8 @@ export class CotalEndpoint extends EventEmitter {
3118
3151
  await this.disarmMembershipWatch(watch);
3119
3152
  return;
3120
3153
  }
3154
+ if (!replaying)
3155
+ watch.onChange({ type: "replayed", revision: 0 }); // an empty feed replays nothing
3121
3156
  iter.closed().then(() => {
3122
3157
  if (watch.stopped || watch.consumer !== consumer)
3123
3158
  return;
@@ -4363,6 +4398,52 @@ export class CotalEndpoint extends EventEmitter {
4363
4398
  await Promise.allSettled(this.leaseWatchBinds);
4364
4399
  await Promise.all(this.leaseWatchDeletes);
4365
4400
  }
4401
+ /** Stop a presence or channel-registry watch and delete its ordered consumer, which the stop
4402
+ * alone leaves on the broker until its five-minute inactive threshold (#3914). A connection
4403
+ * that closed on its own cannot carry the delete, so that consumer is left to the threshold. A
4404
+ * failed delete is a warning: the broker still reaps the consumer, and a rejected {@link stop}
4405
+ * would read as unproved consumer custody. */
4406
+ retireKvWatch(iter) {
4407
+ try {
4408
+ iter.stop();
4409
+ }
4410
+ catch { /* already closed with its connection */ }
4411
+ if (!this.nc || this.nc.isClosed())
4412
+ return;
4413
+ const deleting = this.deleteOwnConsumer(kvWatchConsumer(iter)).catch((e) => this.emitRecoverable(new Error(`a retired KV watch consumer was not deleted (${e.message}); the broker reaps it at its inactive threshold`)));
4414
+ this.kvWatchDeletes.add(deleting);
4415
+ void deleting.then(() => this.kvWatchDeletes.delete(deleting));
4416
+ }
4417
+ /** Wait for the presence and channel-registry watch binds still in flight, then for every retired
4418
+ * watch's consumer delete. {@link stop} and a live-epoch rebuild run this before they close the
4419
+ * connection those deletes need (#3914). */
4420
+ async settleKvWatches() {
4421
+ await Promise.allSettled(this.kvWatchBinds);
4422
+ await Promise.all(this.kvWatchDeletes);
4423
+ }
4424
+ /** Bind a watch on `kv` for the current epoch and record its consumer as this connection's own.
4425
+ * A bind that {@link stop} or a rebuild overtook retires its late watch, consumer delete
4426
+ * included, and resolves undefined: the epoch that asked for it is gone, and the one that
4427
+ * replaced it binds its own through {@link connectAndBind}. A stopped endpoint binds nothing,
4428
+ * because its teardown may already have settled the flights it waits for. */
4429
+ bindKvWatch(kv) {
4430
+ if (this.stopped)
4431
+ return Promise.resolve(undefined);
4432
+ const epoch = this.presenceEpoch;
4433
+ const binding = (async () => {
4434
+ const iter = await kv.watch();
4435
+ const info = await kvWatchConsumer(iter).info(true);
4436
+ this.recordOwnWatchConsumer(info);
4437
+ if (epoch === this.presenceEpoch && !this.stopped)
4438
+ return { iter, info };
4439
+ this.retireKvWatch(iter);
4440
+ return undefined;
4441
+ })();
4442
+ const settled = () => { this.kvWatchBinds.delete(binding); };
4443
+ this.kvWatchBinds.add(binding);
4444
+ void binding.then(settled, settled);
4445
+ return binding;
4446
+ }
4366
4447
  /** Bind the lease watch on the current connection. A bind that lands after a newer watch replaced
4367
4448
  * its intent, or after a stop or connection teardown moved the epoch, retires its watch instead of
4368
4449
  * installing it: the epoch it was bound for is gone, and a rebuilt one binds its own. */
@@ -5021,6 +5102,18 @@ export class CotalEndpoint extends EventEmitter {
5021
5102
  return { ok: false, error: e.message };
5022
5103
  }
5023
5104
  }
5105
+ /** Whether `lifecycleUid`'s exact ACL row still allows reads, so an inventory of it came from a
5106
+ * caller that did not fence. A row narrowed to no reads, or retired or absent, is fenced. A read
5107
+ * that fails answers true: the caller keeps its in-memory mark, which only ever refuses. */
5108
+ async lifecycleAclUnfenced(principal, lifecycleUid) {
5109
+ try {
5110
+ const row = await readAcl(await this.aclRegistry(), principal, lifecycleUid);
5111
+ return row !== undefined && row.record.allowSubscribe.length > 0;
5112
+ }
5113
+ catch {
5114
+ return true;
5115
+ }
5116
+ }
5024
5117
  /** Run one durable join or leave that writes `principal`'s rows of `lifecycleUid`, refused once that
5025
5118
  * lifecycle's inventory has been asked for ({@link retiringLifecycles}). The check and the
5026
5119
  * registration have no await between them, so an inventory either finds this write running and
@@ -5643,7 +5736,13 @@ export class CotalEndpoint extends EventEmitter {
5643
5736
  for (const earlier of [...marked])
5644
5737
  if (earlier !== uid && await aclRetired(await this.aclRegistry(), principal, earlier).catch(() => false))
5645
5738
  marked.delete(earlier);
5646
- marked.add(uid);
5739
+ // A mark is kept only for an inventory whose caller did not fence (#4996): a lifecycle whose ACL
5740
+ // row is narrowed to no reads, or already retired, needs none, since every join re-reads that
5741
+ // row around its write. A read that fails proves nothing, so the mark is kept, which only refuses.
5742
+ if (await this.lifecycleAclUnfenced(principal, uid))
5743
+ marked.add(uid);
5744
+ else if (marked.size === 0)
5745
+ this.retiringLifecycles.delete(principal);
5647
5746
  await Promise.allSettled(this.membershipWrites.get(`${principal}/${uid}`) ?? []);
5648
5747
  try {
5649
5748
  const channels = await listLifecycleMemberChannels(await this.membersRegistry(), principal, uid);
@@ -5859,6 +5958,20 @@ export class CotalEndpoint extends EventEmitter {
5859
5958
  })().catch((e) => { if (!this.stopped)
5860
5959
  this.emit("error", e); });
5861
5960
  }
5961
+ /** Remove a retired lifecycle's member row for `channel`, when one exists. Best effort: the row
5962
+ * authorizes nothing (its ACL row is retired), and the next entry fan-out writes against it
5963
+ * brings the reader back here. */
5964
+ async removeRetiredMemberRow(channel, owner, lifecycleUid) {
5965
+ try {
5966
+ const kv = await this.membersRegistry();
5967
+ if (await readMember(kv, channel, owner, lifecycleUid) === undefined)
5968
+ return;
5969
+ await deleteMember(kv, channel, owner, lifecycleUid);
5970
+ }
5971
+ catch (e) {
5972
+ this.emit("warning", new Error(`plane-3 reader: could not remove retired lifecycle ${owner}.${lifecycleUid}'s "${channel}" member row: ${e.message}`));
5973
+ }
5974
+ }
5862
5975
  /** Re-authorize ONE mixed-inbox entry and transfer it to the owner's DELIVER store. Deny (drop) on a
5863
5976
  * revoked/narrowed ACL or out-of-interval seq; on transfer success, ack the mixed entry (durability
5864
5977
  * has moved to DLV — an §8 equivalent per-member at-least-once mechanism). The agent acks DLV. */
@@ -5924,6 +6037,15 @@ export class CotalEndpoint extends EventEmitter {
5924
6037
  m.nak(2000);
5925
6038
  return;
5926
6039
  }
6040
+ // The member row that addressed this entry is one a daemon left behind when it died between
6041
+ // its create and its post-write ACL read (#4994). It authorizes nothing, but fan-out copies
6042
+ // every later message on the channel into this inbox until it goes, and this reader then
6043
+ // removes each copy. Remove the row where that cost is paid, by its exact key, only when it
6044
+ // exists: a purge of an absent key would write a marker per dropped entry.
6045
+ if (entry.reason === "durable-channel")
6046
+ await this.removeRetiredMemberRow(entry.channel, owner, pr.lifecycleUid);
6047
+ if (!this.plane3MayAct())
6048
+ return;
5927
6049
  m.ack();
5928
6050
  return;
5929
6051
  }
@@ -6991,28 +7113,22 @@ export class CotalEndpoint extends EventEmitter {
6991
7113
  }
6992
7114
  /** Bind a presence watch on the current connection. Resolves the bound consumer's info when
6993
7115
  * the watch was installed, undefined when the endpoint stopped or rebuilt while the bind was
6994
- * in flight: that bind's iterator is released here and nothing is installed, because the
6995
- * epoch that asked for it is gone and the epoch that replaced it binds its own watch through
6996
- * {@link connectAndBind}. Without this fence a bind that completes after {@link stop} would
6997
- * resurrect a watch on a stopped endpoint, and one that completes after a rebuild would
6998
- * overwrite the fresh epoch's watch with a dead-connection iterator. */
7116
+ * in flight: that bind's watch is retired by {@link bindKvWatch} and nothing is installed,
7117
+ * because the epoch that asked for it is gone and the epoch that replaced it binds its own
7118
+ * watch through {@link connectAndBind}. Without this fence a bind that completes after
7119
+ * {@link stop} would resurrect a watch on a stopped endpoint, and one that completes after a
7120
+ * rebuild would overwrite the fresh epoch's watch with a dead-connection iterator. */
6999
7121
  async startPresenceWatch() {
7000
7122
  if (!this.kv)
7001
7123
  return undefined;
7002
- const epoch = this.presenceEpoch;
7003
7124
  let hydrated;
7004
7125
  this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
7005
- const iter = await this.kv.watch();
7006
- const info = await kvWatchConsumer(iter).info(true);
7007
- this.recordOwnWatchConsumer(info);
7008
- if (epoch !== this.presenceEpoch) {
7009
- try {
7010
- iter.stop();
7011
- }
7012
- catch { /* its connection may already be gone */ }
7126
+ const bound = await this.bindKvWatch(this.kv);
7127
+ if (!bound) {
7013
7128
  hydrated();
7014
7129
  return undefined;
7015
7130
  }
7131
+ const { iter, info } = bound;
7016
7132
  this.presenceWatchIter = iter;
7017
7133
  // A bucket with no keys replays nothing, so no entry carries the end-of-replay marker. For an
7018
7134
  // observer that does not register, that empty replay is the whole snapshot: nobody is present.
@@ -7064,7 +7180,7 @@ export class CotalEndpoint extends EventEmitter {
7064
7180
  * next sweep exactly as if the watch had never stalled. Rate-limited to one attempt per TTL
7065
7181
  * per observer, never overlapping, never on a stopped or rebuilding endpoint (those own their
7066
7182
  * watch through {@link connectAndBind}). A stop or rebuild that lands while the bind is in
7067
- * flight retires it: {@link startPresenceWatch} releases the late iterator and reports
7183
+ * flight retires it: {@link startPresenceWatch} retires the late watch and reports
7068
7184
  * nothing, since the epoch that was silent no longer exists. A failed bind is reported and
7069
7185
  * the view stays stale.
7070
7186
  */
@@ -7084,17 +7200,15 @@ export class CotalEndpoint extends EventEmitter {
7084
7200
  // watch is the one that recovers by itself, and its replay is still guarded against
7085
7201
  // expired PUTs. Only a successfully bound watch retires its predecessor.
7086
7202
  const info = await this.startPresenceWatch();
7087
- // Retired mid-bind (stop or rebuild moved the epoch): the late iterator is already
7088
- // released and the old watch was torn down by whoever moved the epoch. Nothing to
7203
+ // Retired mid-bind (stop or rebuild moved the epoch): the late watch is already
7204
+ // retired and the old watch was torn down by whoever moved the epoch. Nothing to
7089
7205
  // retire, nothing to report.
7090
7206
  if (!info)
7091
7207
  return;
7092
- if (old && old !== this.presenceWatchIter) {
7093
- try {
7094
- old.stop();
7095
- }
7096
- catch { /* already closed with its consumer */ }
7097
- }
7208
+ // The predecessor's consumer goes with it: left alone, every rebind of a silent bucket
7209
+ // would add one idle consumer to the presence stream per TTL (#3914).
7210
+ if (old && old !== this.presenceWatchIter)
7211
+ this.retireKvWatch(old);
7098
7212
  // A bucket with no keys replays nothing, so the new watch cannot refresh
7099
7213
  // `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. The
7100
7214
  // bind's consumer info carries the initial pending count for that one fact; nats.js's KV
@@ -7123,8 +7237,10 @@ export class CotalEndpoint extends EventEmitter {
7123
7237
  async startChannelWatch() {
7124
7238
  if (!this.channelKv)
7125
7239
  return;
7126
- const iter = await this.channelKv.watch();
7127
- this.recordOwnWatchConsumer(await kvWatchConsumer(iter).info(true));
7240
+ const bound = await this.bindKvWatch(this.channelKv);
7241
+ if (!bound)
7242
+ return;
7243
+ const { iter } = bound;
7128
7244
  this.channelWatchIter = iter;
7129
7245
  void (async () => {
7130
7246
  for await (const e of iter)
@@ -7408,6 +7524,8 @@ function authenticatedMessage(msg, parsed) {
7408
7524
  return authenticatedChannelMessage(msg, parsed.rest);
7409
7525
  if (parsed.kind === "inst")
7410
7526
  return authenticatedDmMessage(msg, parsed.rest);
7527
+ if (parsed.kind === "svc")
7528
+ return authenticatedAnycastMessage(msg, parsed.rest);
7411
7529
  return msg;
7412
7530
  }
7413
7531
  function authenticatedChannelMessage(msg, channel) {
@@ -7422,6 +7540,12 @@ function authenticatedDmMessage(msg, to) {
7422
7540
  const { channel: _channel, toService: _toService, ...base } = msg;
7423
7541
  return { ...base, to };
7424
7542
  }
7543
+ function authenticatedAnycastMessage(msg, toService) {
7544
+ if (msg.toService === toService && msg.to === undefined && msg.channel === undefined)
7545
+ return msg;
7546
+ const { to: _to, channel: _channel, ...base } = msg;
7547
+ return { ...base, toService };
7548
+ }
7425
7549
  /** The ack wait the broker reports for a bound consumer: how long a copy it delivers stays with
7426
7550
  * its reader without an ack. */
7427
7551
  async function ackWaitOf(consumer) {