@cotal-ai/core 0.85.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.
- package/dist/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +10 -3
- package/dist/agent-file.js.map +1 -1
- package/dist/base64url.d.ts +5 -0
- package/dist/base64url.d.ts.map +1 -0
- package/dist/base64url.js +14 -0
- package/dist/base64url.js.map +1 -0
- package/dist/browser-control.d.ts +149 -0
- package/dist/browser-control.d.ts.map +1 -0
- package/dist/browser-control.js +167 -0
- package/dist/browser-control.js.map +1 -0
- package/dist/browser-validators-build.d.ts +23 -0
- package/dist/browser-validators-build.d.ts.map +1 -0
- package/dist/browser-validators-build.js +149 -0
- package/dist/browser-validators-build.js.map +1 -0
- package/dist/canonical-json.d.ts +14 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +121 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/canonical.d.ts +2 -10
- package/dist/canonical.d.ts.map +1 -1
- package/dist/canonical.js +2 -108
- package/dist/canonical.js.map +1 -1
- package/dist/channels.d.ts +4 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +6 -2
- package/dist/channels.js.map +1 -1
- package/dist/checkpoint-answer.d.ts.map +1 -1
- package/dist/checkpoint-answer.js +3 -4
- package/dist/checkpoint-answer.js.map +1 -1
- package/dist/connector-setup.d.ts +10 -1
- package/dist/connector-setup.d.ts.map +1 -1
- package/dist/digest-id.d.ts +4 -0
- package/dist/digest-id.d.ts.map +1 -0
- package/dist/digest-id.js +7 -0
- package/dist/digest-id.js.map +1 -0
- package/dist/endpoint-action.d.ts +2 -2
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +6 -44
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-auth-digest.d.ts +7 -0
- package/dist/endpoint-auth-digest.d.ts.map +1 -0
- package/dist/endpoint-auth-digest.js +13 -0
- package/dist/endpoint-auth-digest.js.map +1 -0
- package/dist/endpoint-call.d.ts +176 -0
- package/dist/endpoint-call.d.ts.map +1 -0
- package/dist/endpoint-call.js +490 -0
- package/dist/endpoint-call.js.map +1 -0
- package/dist/endpoint-caller.d.ts +113 -0
- package/dist/endpoint-caller.d.ts.map +1 -0
- package/dist/endpoint-caller.js +260 -0
- package/dist/endpoint-caller.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -1
- package/dist/endpoint-checkpoint.js +5 -14
- package/dist/endpoint-checkpoint.js.map +1 -1
- package/dist/endpoint-envelope.d.ts +0 -6
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +17 -18
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-goal-follow.d.ts +37 -0
- package/dist/endpoint-goal-follow.d.ts.map +1 -0
- package/dist/endpoint-goal-follow.js +328 -0
- package/dist/endpoint-goal-follow.js.map +1 -0
- package/dist/endpoint-grants.d.ts +0 -4
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +1 -7
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-guard.d.ts.map +1 -1
- package/dist/endpoint-guard.js +2 -10
- package/dist/endpoint-guard.js.map +1 -1
- package/dist/endpoint-invoke.d.ts +37 -143
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +60 -605
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-publish-denial.d.ts +1 -1
- package/dist/endpoint-publish-denial.d.ts.map +1 -1
- package/dist/endpoint-publish-denial.js +1 -1
- package/dist/endpoint-publish-denial.js.map +1 -1
- package/dist/endpoint-receipt.d.ts.map +1 -1
- package/dist/endpoint-receipt.js +2 -16
- package/dist/endpoint-receipt.js.map +1 -1
- package/dist/endpoint-signing.d.ts +1 -1
- package/dist/endpoint-signing.d.ts.map +1 -1
- package/dist/endpoint-signing.js +1 -1
- package/dist/endpoint-signing.js.map +1 -1
- package/dist/endpoint-subjects.d.ts +4 -0
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +6 -0
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint-traits.d.ts.map +1 -1
- package/dist/endpoint-traits.js +3 -20
- package/dist/endpoint-traits.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +18 -151
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +4 -500
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint-work.d.ts.map +1 -1
- package/dist/endpoint-work.js +2 -17
- package/dist/endpoint-work.js.map +1 -1
- package/dist/endpoint.d.ts +113 -24
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +330 -78
- package/dist/endpoint.js.map +1 -1
- package/dist/goal-result-fact.d.ts +13 -0
- package/dist/goal-result-fact.d.ts.map +1 -0
- package/dist/goal-result-fact.js +40 -0
- package/dist/goal-result-fact.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/issued-accepted.d.ts +27 -0
- package/dist/issued-accepted.d.ts.map +1 -0
- package/dist/issued-accepted.js +62 -0
- package/dist/issued-accepted.js.map +1 -0
- package/dist/issued-authority.d.ts +3 -18
- package/dist/issued-authority.d.ts.map +1 -1
- package/dist/issued-authority.js +4 -55
- package/dist/issued-authority.js.map +1 -1
- package/dist/jetstream-missing.d.ts +4 -0
- package/dist/jetstream-missing.d.ts.map +1 -1
- package/dist/jetstream-missing.js +6 -0
- package/dist/jetstream-missing.js.map +1 -1
- package/dist/kv-scan.d.ts +15 -0
- package/dist/kv-scan.d.ts.map +1 -1
- package/dist/kv-scan.js +32 -0
- package/dist/kv-scan.js.map +1 -1
- package/dist/launch-material.d.ts.map +1 -1
- package/dist/launch-material.js +6 -2
- package/dist/launch-material.js.map +1 -1
- package/dist/lease.d.ts +50 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js +71 -0
- package/dist/lease.js.map +1 -1
- package/dist/lifecycle-state.d.ts +3 -0
- package/dist/lifecycle-state.d.ts.map +1 -1
- package/dist/lifecycle-state.js +7 -0
- package/dist/lifecycle-state.js.map +1 -1
- package/dist/manager-caller.d.ts.map +1 -1
- package/dist/manager-caller.js +2 -1
- package/dist/manager-caller.js.map +1 -1
- package/dist/one-line.d.ts +6 -0
- package/dist/one-line.d.ts.map +1 -0
- package/dist/one-line.js +8 -0
- package/dist/one-line.js.map +1 -0
- package/dist/peer-label.d.ts +16 -0
- package/dist/peer-label.d.ts.map +1 -0
- package/dist/peer-label.js +15 -0
- package/dist/peer-label.js.map +1 -0
- package/dist/permission-denied.d.ts +8 -0
- package/dist/permission-denied.d.ts.map +1 -0
- package/dist/permission-denied.js +15 -0
- package/dist/permission-denied.js.map +1 -0
- package/dist/presence-text.d.ts +6 -0
- package/dist/presence-text.d.ts.map +1 -0
- package/dist/presence-text.js +25 -0
- package/dist/presence-text.js.map +1 -0
- package/dist/private-tool-results.d.ts +253 -0
- package/dist/private-tool-results.d.ts.map +1 -0
- package/dist/private-tool-results.js +141 -0
- package/dist/private-tool-results.js.map +1 -0
- package/dist/provision.d.ts +5 -0
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +40 -16
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +17 -0
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +40 -1
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/resolve.d.ts +2 -13
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +2 -12
- package/dist/resolve.js.map +1 -1
- package/dist/run-driver-grants.d.ts +5 -0
- package/dist/run-driver-grants.d.ts.map +1 -1
- package/dist/run-driver-grants.js +16 -4
- package/dist/run-driver-grants.js.map +1 -1
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +1 -6
- package/dist/run-journal.js.map +1 -1
- package/dist/run-migration.d.ts +21 -0
- package/dist/run-migration.d.ts.map +1 -1
- package/dist/run-migration.js +2 -27
- package/dist/run-migration.js.map +1 -1
- package/dist/run-notice.d.ts +20 -0
- package/dist/run-notice.d.ts.map +1 -1
- package/dist/run-notice.js +3 -28
- package/dist/run-notice.js.map +1 -1
- package/dist/schema-bounds.d.ts +24 -0
- package/dist/schema-bounds.d.ts.map +1 -0
- package/dist/schema-bounds.js +74 -0
- package/dist/schema-bounds.js.map +1 -0
- package/dist/schema-profile.d.ts +11 -24
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +16 -73
- package/dist/schema-profile.js.map +1 -1
- package/dist/subjects.d.ts +17 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +30 -2
- package/dist/subjects.js.map +1 -1
- package/dist/turn-contract.d.ts.map +1 -1
- package/dist/turn-contract.js +14 -4
- package/dist/turn-contract.js.map +1 -1
- package/package.json +14 -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,9 @@ 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
|
+
import { isPermissionDenied } from "./permission-denied.js";
|
|
33
|
+
export { isPermissionDenied };
|
|
32
34
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
33
35
|
const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
|
|
34
36
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
@@ -53,6 +55,11 @@ export const MULTI_FILTER_BATCH = 1_000;
|
|
|
53
55
|
* issuing one read per channel: a space large enough to need batches must not get the fan-out back
|
|
54
56
|
* under another name. */
|
|
55
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;
|
|
56
63
|
/** A presence bucket that has refused consecutive writes for at least one full presence TTL.
|
|
57
64
|
* The condition is non-transient: ordinary heartbeat retry has already failed for the whole
|
|
58
65
|
* liveness window, so callers should report the roster as last-known until a write succeeds. */
|
|
@@ -159,6 +166,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
159
166
|
doRegister;
|
|
160
167
|
doWatch;
|
|
161
168
|
doWatchChannels;
|
|
169
|
+
readOnce;
|
|
162
170
|
doConsume;
|
|
163
171
|
ackWaitMs;
|
|
164
172
|
inactiveThresholdMs;
|
|
@@ -178,6 +186,14 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
178
186
|
* live iterator. See {@link watchDeliveryLease}. */
|
|
179
187
|
leaseWatchIntent;
|
|
180
188
|
leaseWatchIter;
|
|
189
|
+
/** Consumer deletes of retired lease watches still in flight. The stop handle cannot await its
|
|
190
|
+
* delete, so {@link stop} and a live-epoch rebuild await these before they close the connection
|
|
191
|
+
* that carries them (#3966). */
|
|
192
|
+
leaseWatchDeletes = new Set();
|
|
193
|
+
/** Lease-watch binds still awaiting the broker. One that lands after {@link stop} or a rebuild
|
|
194
|
+
* moved the epoch retires its watch and starts that delete itself, so the same two paths wait for
|
|
195
|
+
* these first. Each is bounded by the JetStream request timeout. */
|
|
196
|
+
leaseWatchBinds = new Set();
|
|
181
197
|
/** Plane-3 durable-membership registry KV — lazily opened by the privileged delivery daemon (or a
|
|
182
198
|
* short-lived provisioner). */
|
|
183
199
|
membersKv;
|
|
@@ -252,9 +268,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
252
268
|
* EXPECTED async permission violation that joinChannel turns into a clean throw, so watchStatus
|
|
253
269
|
* suppresses it rather than surfacing a spurious connection error. */
|
|
254
270
|
confirmingChatSubs = new Set();
|
|
255
|
-
/** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches
|
|
256
|
-
* running membership scans
|
|
271
|
+
/** `$JS.API.CONSUMER.DELETE.<stream>.<prefix>_` for each of this connection's own KV watches and
|
|
272
|
+
* running membership scans (see {@link recordOwnWatchConsumer}). */
|
|
257
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();
|
|
258
277
|
/** True until the first successful connect completes its boot backfill — distinguishes first-connect
|
|
259
278
|
* (backfill the boot channels' history) from a reconnect (reopen the core-subs, no re-backfill).
|
|
260
279
|
* Persists across reconnect (NOT connection-scoped). Replaces the legacy chat-durable consumed-cursor
|
|
@@ -294,9 +313,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
294
313
|
presenceViewState = "unpopulated";
|
|
295
314
|
/** A presence-watch rebind in flight (see {@link rebindStalePresenceWatch}); one at a time. */
|
|
296
315
|
presenceRebind;
|
|
297
|
-
/** Bumped by every connection-scoped teardown and by {@link stop}. A presence
|
|
298
|
-
* awaiting the broker when the epoch moved belongs to a retired epoch: it releases the
|
|
299
|
-
* iterator it got and installs nothing (see {@link startPresenceWatch}
|
|
316
|
+
/** Bumped by every connection-scoped teardown and by {@link stop}. A presence or lease-watch bind
|
|
317
|
+
* that was awaiting the broker when the epoch moved belongs to a retired epoch: it releases the
|
|
318
|
+
* iterator it got and installs nothing (see {@link startPresenceWatch} and
|
|
319
|
+
* {@link bindDeliveryLeaseWatch}). */
|
|
300
320
|
presenceEpoch = 0;
|
|
301
321
|
/** The current watch was bound onto a bucket with NO keys (see {@link markPresenceBucketEmpty}).
|
|
302
322
|
* Such a watch cannot deliver until someone writes, so its silence is not staleness. */
|
|
@@ -536,6 +556,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
536
556
|
this.doWatch = opts.watchPresence ?? true;
|
|
537
557
|
this.doWatchChannels = opts.watchChannels ?? true;
|
|
538
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");
|
|
539
562
|
// Seed the presence mirror so file-default channel modes are visible from the first publish
|
|
540
563
|
// (not only after the first runtime toggle). Mirror only — delivery reads the connector's state.
|
|
541
564
|
this.channelModes = opts.channelModes && Object.keys(opts.channelModes).length ? opts.channelModes : undefined;
|
|
@@ -1071,7 +1094,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1071
1094
|
? await kvm.open(presenceBucket(this.space))
|
|
1072
1095
|
: await kvm.create(presenceBucket(this.space), { ttl: this.ttlMs, storage: PRESENCE_STORAGE });
|
|
1073
1096
|
}
|
|
1074
|
-
if (this.doWatch)
|
|
1097
|
+
if (this.doWatch && this.readOnce)
|
|
1098
|
+
await this.readPresenceOnce();
|
|
1099
|
+
else if (this.doWatch) {
|
|
1075
1100
|
await this.startPresenceWatch();
|
|
1076
1101
|
this.sweepTimer = setInterval(() => this.sweep(), Math.max(500, Math.floor(this.ttlMs / 3)));
|
|
1077
1102
|
}
|
|
@@ -1084,12 +1109,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1084
1109
|
if (watchChannels || this.doConsume) {
|
|
1085
1110
|
this.channelKv = await openChannelRegistry(this.nc, this.space, { create: !this.authed });
|
|
1086
1111
|
if (watchChannels)
|
|
1087
|
-
await this.startChannelWatch();
|
|
1112
|
+
await (this.readOnce ? this.readChannelsOnce() : this.startChannelWatch());
|
|
1088
1113
|
}
|
|
1089
1114
|
// Rebind the daemon's lease-loss watch onto the fresh connection (#1596). The intent survived
|
|
1090
|
-
// clearConnectionScoped; a bind that completes after a stop or another rebuild
|
|
1091
|
-
// and
|
|
1092
|
-
// in the null window still reaches the trigger.
|
|
1115
|
+
// clearConnectionScoped; a bind that completes after a stop or another rebuild finds the epoch
|
|
1116
|
+
// moved and retires itself. The replay re-delivers the row's current state, so a change that
|
|
1117
|
+
// landed in the null window still reaches the trigger.
|
|
1093
1118
|
if (this.leaseWatchIntent)
|
|
1094
1119
|
await this.bindDeliveryLeaseWatch();
|
|
1095
1120
|
// FAIL BEFORE PRESENCE (SPEC 13.1): an AUTHED endpoint that will register on the roster or
|
|
@@ -1432,7 +1457,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1432
1457
|
// Manual reconnect still has a live old epoch: complete broker-consumer cleanup before close.
|
|
1433
1458
|
// Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup.
|
|
1434
1459
|
if (oldNc && !oldNc.isClosed())
|
|
1435
|
-
await Promise.all([...this.membershipFeedWatches].map((watch) => watch.arm));
|
|
1460
|
+
await Promise.all([...[...this.membershipFeedWatches].map((watch) => watch.arm), this.settleDeliveryLeaseWatches()]);
|
|
1436
1461
|
this.nc = undefined;
|
|
1437
1462
|
this.js = undefined;
|
|
1438
1463
|
this.jsm = undefined;
|
|
@@ -1629,6 +1654,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1629
1654
|
catch {
|
|
1630
1655
|
/* best-effort graceful leave */
|
|
1631
1656
|
}
|
|
1657
|
+
await this.settleDeliveryLeaseWatches();
|
|
1632
1658
|
await this.closeWithoutLibraryReconnect(this.nc);
|
|
1633
1659
|
// #1356: stop() does its own teardown and never runs clearConnectionScoped, so clear here too —
|
|
1634
1660
|
// AFTER the best-effort offline publish above, which would otherwise re-record a refusal on its
|
|
@@ -2397,6 +2423,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2397
2423
|
this.card.meta = meta;
|
|
2398
2424
|
await this.publishPresence();
|
|
2399
2425
|
}
|
|
2426
|
+
/** Overlay the session's working directory onto the card's display-only `meta.cwd`, then
|
|
2427
|
+
* republish presence. For connectors that learn the directory from the harness after launch (e.g.
|
|
2428
|
+
* Claude Code's `SessionStart` hook) rather than from their own process. Advisory display data,
|
|
2429
|
+
* never a routing or access input. The pathname is kept byte for byte: a directory name may hold
|
|
2430
|
+
* or end in whitespace, so it is never trimmed or normalized. An empty string names no directory
|
|
2431
|
+
* and leaves the card unchanged. */
|
|
2432
|
+
async setCardCwd(cwd) {
|
|
2433
|
+
if (!cwd || this.card.meta?.cwd === cwd)
|
|
2434
|
+
return;
|
|
2435
|
+
this.card.meta = { ...(this.card.meta ?? {}), cwd };
|
|
2436
|
+
await this.publishPresence();
|
|
2437
|
+
}
|
|
2400
2438
|
// ---- channel discovery ---------------------------------------------------
|
|
2401
2439
|
/** This channel's registry config from the live local cache (undefined if unset). */
|
|
2402
2440
|
getChannelConfig(channel) {
|
|
@@ -2677,8 +2715,6 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2677
2715
|
watch.arm = watch.arm.catch(() => { }).then(async () => {
|
|
2678
2716
|
await this.disarmMembershipWatch(watch);
|
|
2679
2717
|
await this.deleteRetainedMembershipConsumer(watch);
|
|
2680
|
-
if (watch.consumerStream || watch.consumerName)
|
|
2681
|
-
return;
|
|
2682
2718
|
this.finishMembershipWatchStop(watch);
|
|
2683
2719
|
}).catch((err) => {
|
|
2684
2720
|
// A real authorization/server failure remains loud. A terminal close is retained rather than
|
|
@@ -2752,16 +2788,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2752
2788
|
watch.resolveStop = undefined;
|
|
2753
2789
|
watch.rejectStop = undefined;
|
|
2754
2790
|
}
|
|
2755
|
-
/** Record the consumer behind one of this connection's own KV watches
|
|
2756
|
-
*
|
|
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,
|
|
2757
2793
|
* rebuilding it after a stall or a sequence gap, deletes the predecessor itself with no hook before
|
|
2758
2794
|
* the send, so a profile without the delete row (#691) has that delete refused. A stalled link can
|
|
2759
2795
|
* time that delete out before its refusal arrives, and nats.js then hands the refusal to no request,
|
|
2760
2796
|
* so {@link trackRequestDenials} cannot own it and the consumer's name has to. A watch's prefix
|
|
2761
2797
|
* stays recorded for the connection because a retired watch's last rebuild can be refused after
|
|
2762
|
-
* the watch has stopped; {@link readMembership}
|
|
2763
|
-
*
|
|
2764
|
-
*
|
|
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. */
|
|
2765
2801
|
recordOwnWatchConsumer({ stream_name, name }) {
|
|
2766
2802
|
const prefix = `$JS.API.CONSUMER.DELETE.${stream_name}.${name.slice(0, name.lastIndexOf("_") + 1)}`;
|
|
2767
2803
|
this.ownWatchDeletePrefixes.add(prefix);
|
|
@@ -2772,10 +2808,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2772
2808
|
const cut = subject.lastIndexOf("_") + 1;
|
|
2773
2809
|
return /^\d+$/.test(subject.slice(cut)) && this.ownWatchDeletePrefixes.has(subject.slice(0, cut));
|
|
2774
2810
|
}
|
|
2775
|
-
/** Delete
|
|
2776
|
-
*
|
|
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
|
|
2777
2813
|
* consumer at its inactive threshold. */
|
|
2778
|
-
async
|
|
2814
|
+
async deleteOwnConsumer(consumer) {
|
|
2779
2815
|
try {
|
|
2780
2816
|
await consumer.delete();
|
|
2781
2817
|
}
|
|
@@ -3068,8 +3104,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3068
3104
|
throw new Error(`history limit must be a whole number of messages this server can count exactly, received ${limit}`);
|
|
3069
3105
|
if (limit <= 0)
|
|
3070
3106
|
return [];
|
|
3071
|
-
const
|
|
3107
|
+
const nc = this.nc;
|
|
3108
|
+
let reader;
|
|
3072
3109
|
try {
|
|
3110
|
+
reader = await this.acquireHistoryReader(nc, this.jsm ?? (this.jsm = await jetstreamManager(nc)), stream, signal);
|
|
3073
3111
|
// THE EXACT CEILING, on the already-granted surface. A one-shot consumer with
|
|
3074
3112
|
// `DeliverPolicy.Last` plus this subject's filter reports the newest MATCHING sequence, and its
|
|
3075
3113
|
// `num_pending` of zero means the channel is genuinely empty. Using the STREAM's last sequence
|
|
@@ -3080,7 +3118,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3080
3118
|
// Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
|
|
3081
3119
|
// needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
|
|
3082
3120
|
// shipped once from this function and turned every non-admin history read into an empty list.
|
|
3083
|
-
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(
|
|
3121
|
+
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(reader, subjects, signal);
|
|
3084
3122
|
if (ceiling < 1)
|
|
3085
3123
|
return [];
|
|
3086
3124
|
// Widen from the exact ceiling until a window holds a full page, or until the window IS the
|
|
@@ -3122,11 +3160,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3122
3160
|
for (;;) {
|
|
3123
3161
|
signal?.throwIfAborted();
|
|
3124
3162
|
const start = Math.max(floor, ceiling - span + 1);
|
|
3125
|
-
const page = await this.drainWindow(
|
|
3163
|
+
const page = await this.drainWindow(reader, subjects, start, ceiling, limit, signal);
|
|
3126
3164
|
if (page.length >= limit)
|
|
3127
3165
|
return page.slice(-limit);
|
|
3128
3166
|
if (!floorKnown) {
|
|
3129
|
-
floor = Math.max(1, await this.firstMatchingSeq(
|
|
3167
|
+
floor = Math.max(1, await this.firstMatchingSeq(reader, subjects, signal));
|
|
3130
3168
|
floorKnown = true;
|
|
3131
3169
|
}
|
|
3132
3170
|
if (start <= floor)
|
|
@@ -3156,13 +3194,175 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3156
3194
|
return [];
|
|
3157
3195
|
throw e;
|
|
3158
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`);
|
|
3159
3359
|
}
|
|
3160
3360
|
/** The oldest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
3161
3361
|
* Mirror of {@link lastMatchingSeq}: same CREATE/INFO/NEXT/DELETE surface, `DeliverPolicy.All`
|
|
3162
3362
|
* instead of `Last`, first delivered seq instead of last. Read credentials already hold this. */
|
|
3163
|
-
async firstMatchingSeq(
|
|
3363
|
+
async firstMatchingSeq(reader, subjects, signal) {
|
|
3164
3364
|
signal?.throwIfAborted();
|
|
3165
|
-
const consumer = await
|
|
3365
|
+
const consumer = await this.openHistoryReader(reader, {
|
|
3166
3366
|
filter_subjects: subjects,
|
|
3167
3367
|
deliver_policy: DeliverPolicy.All,
|
|
3168
3368
|
});
|
|
@@ -3185,18 +3385,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3185
3385
|
throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
|
|
3186
3386
|
}
|
|
3187
3387
|
finally {
|
|
3188
|
-
await this.
|
|
3388
|
+
await this.closeHistoryReader(reader);
|
|
3189
3389
|
}
|
|
3190
3390
|
}
|
|
3191
3391
|
/** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
3192
3392
|
*
|
|
3193
|
-
* One
|
|
3393
|
+
* One reader consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
|
|
3194
3394
|
* (available from the create, before anything is delivered) is 0 for an empty subject, and
|
|
3195
3395
|
* otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
|
|
3196
3396
|
* already uses, so no broker authority is added. */
|
|
3197
|
-
async lastMatchingSeq(
|
|
3397
|
+
async lastMatchingSeq(reader, subjects, signal) {
|
|
3198
3398
|
signal?.throwIfAborted();
|
|
3199
|
-
const consumer = await
|
|
3399
|
+
const consumer = await this.openHistoryReader(reader, {
|
|
3200
3400
|
filter_subjects: subjects,
|
|
3201
3401
|
deliver_policy: DeliverPolicy.Last,
|
|
3202
3402
|
});
|
|
@@ -3225,19 +3425,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3225
3425
|
throw new Error(`history: the broker reported messages on ${subjectLabel(subjects)} but delivered none - the read was cut short, not empty`);
|
|
3226
3426
|
}
|
|
3227
3427
|
finally {
|
|
3228
|
-
await this.
|
|
3428
|
+
await this.closeHistoryReader(reader);
|
|
3229
3429
|
}
|
|
3230
3430
|
}
|
|
3231
3431
|
/** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
|
|
3232
|
-
* One
|
|
3432
|
+
* One reader consumer, one batched pull — `AckPolicy.None`, so no per-message ack
|
|
3233
3433
|
* round trip. Fetches exactly the pending count so it returns as soon as the window is
|
|
3234
3434
|
* delivered rather than blocking for the pull's full expiry. */
|
|
3235
|
-
async drainWindow(
|
|
3435
|
+
async drainWindow(reader, subjects, start, ceiling, limit, signal) {
|
|
3236
3436
|
signal?.throwIfAborted();
|
|
3237
3437
|
const out = [];
|
|
3238
|
-
const consumer = await
|
|
3239
|
-
|
|
3240
|
-
|
|
3438
|
+
const consumer = await this.openHistoryReader(reader, {
|
|
3439
|
+
filter_subjects: subjects,
|
|
3440
|
+
deliver_policy: DeliverPolicy.StartSequence,
|
|
3441
|
+
opt_start_seq: start,
|
|
3442
|
+
});
|
|
3241
3443
|
try {
|
|
3242
3444
|
// A freshly created consumer already carries its ConsumerInfo, so read the CACHED copy: the
|
|
3243
3445
|
// explicit uncached `info()` this used to call was a round trip for data we already had.
|
|
@@ -3263,6 +3465,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3263
3465
|
signal?.throwIfAborted();
|
|
3264
3466
|
for await (const m of iter) {
|
|
3265
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`);
|
|
3266
3472
|
if (m.seq >= ceiling) { // reached the page's upper bound
|
|
3267
3473
|
if (m.seq === ceiling) {
|
|
3268
3474
|
const msg = historyMessageFromDelivery(m);
|
|
@@ -3293,16 +3499,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3293
3499
|
return out;
|
|
3294
3500
|
}
|
|
3295
3501
|
finally {
|
|
3296
|
-
// DELETE THE
|
|
3297
|
-
//
|
|
3298
|
-
//
|
|
3299
|
-
//
|
|
3300
|
-
//
|
|
3301
|
-
|
|
3302
|
-
// resulting resource exhaustion would land in streamHistory's catch and read as empty history.
|
|
3303
|
-
// The observer and admin profiles may not delete (#691), so the dashboard's readers do live out
|
|
3304
|
-
// that threshold; only a profile holding the delete row reclaims them here.
|
|
3305
|
-
await this.deleteReaderConsumer(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);
|
|
3306
3508
|
}
|
|
3307
3509
|
}
|
|
3308
3510
|
// ---- internals -----------------------------------------------------------
|
|
@@ -3700,20 +3902,49 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3700
3902
|
};
|
|
3701
3903
|
}
|
|
3702
3904
|
stopDeliveryLeaseWatch() {
|
|
3905
|
+
if (this.leaseWatchIter)
|
|
3906
|
+
this.retireDeliveryLeaseWatch(this.leaseWatchIter);
|
|
3907
|
+
this.leaseWatchIter = undefined;
|
|
3908
|
+
}
|
|
3909
|
+
/** Stop a lease watch and delete its ordered consumer, which the stop alone leaves on the broker
|
|
3910
|
+
* until its five-minute inactive threshold (#3966). A connection that closed on its own cannot
|
|
3911
|
+
* carry the delete, so that consumer is left to the threshold. A failed delete is a warning: the
|
|
3912
|
+
* broker still reaps the consumer, and the daemon's synchronous stop handle has no caller to
|
|
3913
|
+
* reject. */
|
|
3914
|
+
retireDeliveryLeaseWatch(iter) {
|
|
3703
3915
|
try {
|
|
3704
|
-
|
|
3916
|
+
iter.stop();
|
|
3705
3917
|
}
|
|
3706
3918
|
catch { /* already closed with its connection */ }
|
|
3707
|
-
this.
|
|
3708
|
-
|
|
3709
|
-
|
|
3919
|
+
if (!this.nc || this.nc.isClosed())
|
|
3920
|
+
return;
|
|
3921
|
+
const deleting = this.deleteOwnConsumer(kvWatchConsumer(iter)).catch((e) => this.emitRecoverable(new Error(`a retired delivery lease watch consumer was not deleted (${e.message}); the broker reaps it at its inactive threshold`)));
|
|
3922
|
+
this.leaseWatchDeletes.add(deleting);
|
|
3923
|
+
void deleting.then(() => this.leaseWatchDeletes.delete(deleting));
|
|
3924
|
+
}
|
|
3925
|
+
/** Wait for the lease-watch binds still in flight, then for every retired watch's consumer delete.
|
|
3926
|
+
* {@link stop} and a live-epoch rebuild run this before they close the connection those deletes
|
|
3927
|
+
* need (#3966). */
|
|
3928
|
+
async settleDeliveryLeaseWatches() {
|
|
3929
|
+
await Promise.allSettled(this.leaseWatchBinds);
|
|
3930
|
+
await Promise.all(this.leaseWatchDeletes);
|
|
3931
|
+
}
|
|
3932
|
+
/** Bind the lease watch on the current connection. A bind that lands after a newer watch replaced
|
|
3933
|
+
* its intent, or after a stop or connection teardown moved the epoch, retires its watch instead of
|
|
3934
|
+
* installing it: the epoch it was bound for is gone, and a rebuilt one binds its own. */
|
|
3935
|
+
bindDeliveryLeaseWatch() {
|
|
3936
|
+
const binding = this.bindDeliveryLeaseWatchOnce();
|
|
3937
|
+
const settled = () => { this.leaseWatchBinds.delete(binding); };
|
|
3938
|
+
this.leaseWatchBinds.add(binding);
|
|
3939
|
+
void binding.then(settled, settled);
|
|
3940
|
+
return binding;
|
|
3941
|
+
}
|
|
3942
|
+
async bindDeliveryLeaseWatchOnce() {
|
|
3710
3943
|
const intent = this.leaseWatchIntent;
|
|
3944
|
+
const epoch = this.presenceEpoch;
|
|
3711
3945
|
const iter = await (await this.deliveryRegistry()).watch({ key: leaseKey(intent.shardIndex) });
|
|
3712
|
-
if (this.leaseWatchIntent !== intent) {
|
|
3713
|
-
|
|
3714
|
-
iter.stop();
|
|
3715
|
-
}
|
|
3716
|
-
catch { /* already closed */ }
|
|
3946
|
+
if (this.leaseWatchIntent !== intent || epoch !== this.presenceEpoch) {
|
|
3947
|
+
this.retireDeliveryLeaseWatch(iter);
|
|
3717
3948
|
return;
|
|
3718
3949
|
}
|
|
3719
3950
|
this.leaseWatchIter = iter;
|
|
@@ -3894,12 +4125,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3894
4125
|
* `undefined` means the key IS NOT THERE — a definite absence, established by a completed read.
|
|
3895
4126
|
* A read that could not be completed THROWS instead, so a caller can tell "it is gone" from "I could
|
|
3896
4127
|
* not find out". That distinction is the whole point of the method: a renew that got no answer has
|
|
3897
|
-
* 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. */
|
|
3898
4132
|
async readOwnManagerLease(instanceId) {
|
|
3899
4133
|
const e = await (await this.managerLeaseRegistry()).get(managerLeaseKey(instanceId));
|
|
3900
4134
|
if (!e || e.operation !== "PUT")
|
|
3901
4135
|
return undefined;
|
|
3902
|
-
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() };
|
|
3903
4137
|
}
|
|
3904
4138
|
/** Release THIS instance's key on clean shutdown so a same-id restart re-acquires immediately. CAS-guarded
|
|
3905
4139
|
* by `revision`: if we already LOST it (renew gap) the stored revision has moved, the conditional delete
|
|
@@ -6145,6 +6379,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6145
6379
|
})().catch((e) => this.emit("error", e));
|
|
6146
6380
|
return info;
|
|
6147
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
|
+
}
|
|
6148
6396
|
/**
|
|
6149
6397
|
* Replace a presence watch that has gone silent past TTL while the connection is up. The new
|
|
6150
6398
|
* ordered consumer starts from the bucket's current last-per-subject state, so a peer that is
|
|
@@ -6219,6 +6467,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6219
6467
|
this.handleChannelEntry(e);
|
|
6220
6468
|
})().catch((e) => this.emit("error", e));
|
|
6221
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
|
+
}
|
|
6222
6477
|
handleChannelEntry(e) {
|
|
6223
6478
|
// Cleared before the decode, so an entry that does not decode resolves to the default as the
|
|
6224
6479
|
// fresh registry reads do.
|
|
@@ -6311,7 +6566,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
6311
6566
|
}
|
|
6312
6567
|
if (!isUsablePresence(p)) {
|
|
6313
6568
|
this.presenceBindingDrops++;
|
|
6314
|
-
this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: not a record with a string card.name, a known status and a
|
|
6569
|
+
this.emitRecoverable(new Error(`dropped presence entry for key ${JSON.stringify(e.key)}: not a record with a string card.name, a known status, a finite ts, string display fields and a condition with a string code`));
|
|
6315
6570
|
return;
|
|
6316
6571
|
}
|
|
6317
6572
|
this.applyPresence(e.key, p);
|
|
@@ -6738,12 +6993,22 @@ const PRESENCE_STATUSES = { idle: true, waiting: true, working: true, offline: t
|
|
|
6738
6993
|
* such as `"nope"` makes the liveness arithmetic NaN, and a JSON `1e400` parses as `Infinity`;
|
|
6739
6994
|
* neither age is ever greater than the TTL, so the row would stay live after its key expired
|
|
6740
6995
|
* (#2612). `card.name` is the roster sort key: a non-string throws inside the watch loop and
|
|
6741
|
-
* ends the watch. `status` decides offline and is what roster consumers read. `card.
|
|
6742
|
-
*
|
|
6996
|
+
* ends the watch. `status` decides offline and is what roster consumers read. `card.role`,
|
|
6997
|
+
* `activity`, `attention` and `condition.code` are printed as text, and the peer writes its own
|
|
6998
|
+
* row: an object such as `{"toString":null,"valueOf":null}` throws on conversion and would fail
|
|
6999
|
+
* every reader's roster. A present `condition` must be an object with a string `code`, which SPEC
|
|
7000
|
+
* §6 makes required: `null`, a bare string or an object with no code is dropped too. `card.id` is
|
|
7001
|
+
* left to the KV-key check, which rejects anything but the key string. */
|
|
6743
7002
|
function isUsablePresence(value) {
|
|
6744
7003
|
return isRecord(value) && isRecord(value.card) && typeof value.card.name === "string" &&
|
|
7004
|
+
isOptionalString(value.card.role) &&
|
|
6745
7005
|
typeof value.status === "string" && Object.hasOwn(PRESENCE_STATUSES, value.status) &&
|
|
6746
|
-
typeof value.ts === "number" && Number.isFinite(value.ts)
|
|
7006
|
+
typeof value.ts === "number" && Number.isFinite(value.ts) &&
|
|
7007
|
+
isOptionalString(value.activity) && isOptionalString(value.attention) &&
|
|
7008
|
+
(value.condition === undefined || (isRecord(value.condition) && typeof value.condition.code === "string"));
|
|
7009
|
+
}
|
|
7010
|
+
function isOptionalString(value) {
|
|
7011
|
+
return value === undefined || typeof value === "string";
|
|
6747
7012
|
}
|
|
6748
7013
|
/** Shallow-equal two per-channel-mode maps (presence dedup): a change must re-emit, so an attention
|
|
6749
7014
|
* toggle isn't swallowed as a quiet heartbeat. Absent and empty compare equal. */
|
|
@@ -6837,19 +7102,6 @@ function describeStatusError(err) {
|
|
|
6837
7102
|
}
|
|
6838
7103
|
return err;
|
|
6839
7104
|
}
|
|
6840
|
-
/** True when a failure is a NATS *permission denial* — the subject is forbidden to this
|
|
6841
|
-
* endpoint's creds — rather than a missing responder or a timeout. The two need opposite
|
|
6842
|
-
* fixes (grant the capability vs. start/await the service), so callers (e.g. a control
|
|
6843
|
-
* request that can't reach the manager) must tell them apart instead of defaulting to
|
|
6844
|
-
* "service down". Unwraps a wrapped `cause` and falls back to the server's error text, since
|
|
6845
|
-
* a denied publish can surface either as the typed error or inside a request rejection. */
|
|
6846
|
-
export function isPermissionDenied(e) {
|
|
6847
|
-
if (e instanceof PermissionViolationError)
|
|
6848
|
-
return true;
|
|
6849
|
-
if (e?.cause instanceof PermissionViolationError)
|
|
6850
|
-
return true;
|
|
6851
|
-
return /permissions?\s+violation/i.test(String(e?.message ?? ""));
|
|
6852
|
-
}
|
|
6853
7105
|
/** The ordered consumer behind a KV watch, which nats.js keeps on the iterator and the KV types hide. */
|
|
6854
7106
|
function kvWatchConsumer(iter) {
|
|
6855
7107
|
const consumer = iter._data;
|