@volter/world-runtime 2.0.38 → 2.0.40
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/src/served-world.d.ts +8 -0
- package/dist/src/served-world.js +63 -5
- package/package.json +4 -4
- package/src/served-world.ts +44 -5
|
@@ -268,6 +268,10 @@ export declare class WorldDoors {
|
|
|
268
268
|
/** What this World issued, for world-access to decide a grant against: its two tokens, its named keys, its sessions. */
|
|
269
269
|
private issued;
|
|
270
270
|
private credential;
|
|
271
|
+
/** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
|
|
272
|
+
* (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
|
|
273
|
+
* this World issued. */
|
|
274
|
+
private callerOf;
|
|
271
275
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
272
276
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
273
277
|
* the path-scoped cookie a shared origin has always carried. */
|
|
@@ -352,6 +356,10 @@ export declare class WorldDoors {
|
|
|
352
356
|
* twin, then position. The World keeps no order across twins finer than its clock, and this claims
|
|
353
357
|
* none. `before` is the cursor the previous page answered; `twin` and `trace` narrow it. */
|
|
354
358
|
private timeline;
|
|
359
|
+
/** Each twin's callers by the World's span, from its request journal: its newest CALLER_SEGMENTS once, then from
|
|
360
|
+
* where the last read stopped (readTwinRequestJournalFrom follows a rotation). The newest CALLERS_KEPT are kept. */
|
|
361
|
+
private readonly callersByTwin;
|
|
362
|
+
private callersOf;
|
|
355
363
|
/** Each twin's timeline rows, kept until its logs change: a poll of an unchanged World reads no log
|
|
356
364
|
* (a twin is read again only when the size or time of its parent log, branch log or branch record
|
|
357
365
|
* moved), and the merged order is sorted again only when a twin was. */
|
package/dist/src/served-world.js
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
|
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
29
|
-
import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
|
|
29
|
+
import { CALLER_HEADER, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from "./init.js";
|
|
31
31
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
@@ -369,6 +369,11 @@ function traceIdOf(traceparent) {
|
|
|
369
369
|
const m = typeof traceparent === 'string' ? /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/.exec(traceparent) : null;
|
|
370
370
|
return m && !/^0+$/.test(m[1]) ? m[1] : null;
|
|
371
371
|
}
|
|
372
|
+
const CALLERS_KEPT = 50_000;
|
|
373
|
+
/** The closed journal segments a World reads for callers when it first answers a timeline (~1 MB each): older entries show no caller. */
|
|
374
|
+
const CALLER_SEGMENTS = 8;
|
|
375
|
+
/** A W3C traceparent's parent-id (the span the World minted for the request), or null. */
|
|
376
|
+
function spanOf(traceparent) { return parseTraceparent(traceparent)?.parentId ?? null; }
|
|
372
377
|
/** Timeline order, newest first: by the entry's instant, then twin, then position. */
|
|
373
378
|
function newerFirst(a, b) {
|
|
374
379
|
return b.at.localeCompare(a.at) || b.twin.localeCompare(a.twin) || b.position - a.position;
|
|
@@ -653,6 +658,22 @@ export class WorldDoors {
|
|
|
653
658
|
return { scope: grant.scope, via: 'session', token: null, session: grant.session, ...(grant.who ? { who: grant.who } : {}) };
|
|
654
659
|
return { scope: grant.scope, via: grant.via === 'key' ? 'key' : 'token', token: grant.presented ?? null };
|
|
655
660
|
}
|
|
661
|
+
/** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
|
|
662
|
+
* (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
|
|
663
|
+
* this World issued. */
|
|
664
|
+
callerOf(request) {
|
|
665
|
+
const cred = this.credential(request);
|
|
666
|
+
if (cred.scope === null)
|
|
667
|
+
return null;
|
|
668
|
+
if (cred.via === 'session')
|
|
669
|
+
return cred.who ? `person:${cred.who}` : 'person';
|
|
670
|
+
if (cred.via === 'key') {
|
|
671
|
+
const token = cred.token;
|
|
672
|
+
const held = token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined;
|
|
673
|
+
return held ? `key:${held.name}` : 'key';
|
|
674
|
+
}
|
|
675
|
+
return cred.scope === 'read' ? 'token:read' : 'token';
|
|
676
|
+
}
|
|
656
677
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
657
678
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
658
679
|
* the path-scoped cookie a shared origin has always carried. */
|
|
@@ -872,6 +893,14 @@ export class WorldDoors {
|
|
|
872
893
|
headers.delete(TOKEN_HEADER);
|
|
873
894
|
headers.delete(TWINS_KEY_HEADER);
|
|
874
895
|
headers.delete('host');
|
|
896
|
+
// who made it is the World's word, never the caller's: the twin's request journal records it. The World is a hop of
|
|
897
|
+
// the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
|
|
898
|
+
// request's log entries carry, so each entry is joined to its caller by an id no caller chose
|
|
899
|
+
headers.delete(CALLER_HEADER);
|
|
900
|
+
const who = this.callerOf(request);
|
|
901
|
+
if (who)
|
|
902
|
+
headers.set(CALLER_HEADER, who);
|
|
903
|
+
headers.set('traceparent', traceparentForDelivery(headers.get('traceparent')) ?? newTraceparent());
|
|
875
904
|
// at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
|
|
876
905
|
// that would write for its caller is refused too); at any other, its GET passes as it always has
|
|
877
906
|
if (enforced)
|
|
@@ -942,7 +971,7 @@ export class WorldDoors {
|
|
|
942
971
|
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
943
972
|
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
944
973
|
const caller = new Headers(request.headers);
|
|
945
|
-
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
|
|
974
|
+
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
|
|
946
975
|
caller.delete(h);
|
|
947
976
|
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
948
977
|
const authorization = caller.get('authorization');
|
|
@@ -953,7 +982,10 @@ export class WorldDoors {
|
|
|
953
982
|
// name, with the credential shape of what the caller sent on. As an audit device does, the request is
|
|
954
983
|
// written before the real credential is used, and a request whose audit cannot be written is refused.
|
|
955
984
|
const credentials = twinRequestCredentials(caller, url);
|
|
956
|
-
|
|
985
|
+
const who = this.callerOf(request);
|
|
986
|
+
const trace = parseTraceparent(request.headers.get('traceparent'))?.traceId;
|
|
987
|
+
const by = { ...(who ? { caller: who } : {}), ...(trace ? { trace } : {}) };
|
|
988
|
+
if (!journalTwinRequest(name, { phase: 'request', method: request.method, path: `/${rest}`, status: 0, credentials, ...by }, this.controlRoot(name))) {
|
|
957
989
|
return Response.json({ error: `the ${name} link's audit could not be written: nothing was sent` }, { status: 503 });
|
|
958
990
|
}
|
|
959
991
|
const startedAt = Date.now();
|
|
@@ -964,7 +996,7 @@ export class WorldDoors {
|
|
|
964
996
|
return answer;
|
|
965
997
|
}
|
|
966
998
|
finally {
|
|
967
|
-
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials }, this.controlRoot(name));
|
|
999
|
+
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials, ...by }, this.controlRoot(name));
|
|
968
1000
|
}
|
|
969
1001
|
}
|
|
970
1002
|
async forwardSealed(request, caller, url, rest, name, origin) {
|
|
@@ -1705,7 +1737,33 @@ export class WorldDoors {
|
|
|
1705
1737
|
const shown = page.slice(0, limit);
|
|
1706
1738
|
const last = shown[shown.length - 1];
|
|
1707
1739
|
const next = more && last ? Buffer.from(JSON.stringify([last.entry.occurredAt, last.twin, last.position])).toString('base64url') : null;
|
|
1708
|
-
|
|
1740
|
+
// who made each entry: the request it was written in (the World's span on it) as the twin's journal recorded it
|
|
1741
|
+
const entries = shown.map((r) => { const by = this.callersOf(r.twin).get(spanOf(r.entry.traceparent) ?? ''); return by ? { ...r, caller: by } : r; });
|
|
1742
|
+
return Response.json({ world: this.served, entries, next });
|
|
1743
|
+
}
|
|
1744
|
+
/** Each twin's callers by the World's span, from its request journal: its newest CALLER_SEGMENTS once, then from
|
|
1745
|
+
* where the last read stopped (readTwinRequestJournalFrom follows a rotation). The newest CALLERS_KEPT are kept. */
|
|
1746
|
+
callersByTwin = new Map();
|
|
1747
|
+
callersOf(twin) {
|
|
1748
|
+
const root = this.controlRoot(twin);
|
|
1749
|
+
let held = this.callersByTwin.get(twin);
|
|
1750
|
+
if (!held || held.root !== root) {
|
|
1751
|
+
held = { root, cursor: null, callers: new Map() };
|
|
1752
|
+
this.callersByTwin.set(twin, held);
|
|
1753
|
+
}
|
|
1754
|
+
const read = readTwinRequestJournalFrom(twin, root, held.cursor, { segments: CALLER_SEGMENTS });
|
|
1755
|
+
for (const e of read.entries)
|
|
1756
|
+
if (e.span && e.caller) {
|
|
1757
|
+
held.callers.delete(e.span);
|
|
1758
|
+
held.callers.set(e.span, e.caller);
|
|
1759
|
+
}
|
|
1760
|
+
held.cursor = read.cursor;
|
|
1761
|
+
for (const span of held.callers.keys()) {
|
|
1762
|
+
if (held.callers.size <= CALLERS_KEPT)
|
|
1763
|
+
break;
|
|
1764
|
+
held.callers.delete(span);
|
|
1765
|
+
}
|
|
1766
|
+
return held.callers;
|
|
1709
1767
|
}
|
|
1710
1768
|
/** Each twin's timeline rows, kept until its logs change: a poll of an unchanged World reads no log
|
|
1711
1769
|
* (a twin is read again only when the size or time of its parent log, branch log or branch record
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.40",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -67,14 +67,14 @@
|
|
|
67
67
|
"node": ">=22.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "2.0.
|
|
70
|
+
"@volter/world-core": "2.0.22",
|
|
71
71
|
"@volter/world-console": "2.0.13"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@electric-sql/pglite": "0.5.8",
|
|
75
75
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
76
|
"@volter/world-access": "2.0.2",
|
|
77
|
-
"@volter/world-core": "2.0.
|
|
77
|
+
"@volter/world-core": "2.0.22",
|
|
78
78
|
"pg-gateway": "0.3.0-beta.4",
|
|
79
79
|
"smol-toml": "^1.8.0"
|
|
80
80
|
},
|
|
@@ -84,6 +84,6 @@
|
|
|
84
84
|
}
|
|
85
85
|
},
|
|
86
86
|
"optionalDependencies": {
|
|
87
|
-
"@volter/twin-mongodb": "0.1.
|
|
87
|
+
"@volter/twin-mongodb": "0.1.22"
|
|
88
88
|
}
|
|
89
89
|
}
|
package/src/served-world.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
|
|
|
26
26
|
// to `.volter/token` and `.volter/token.read` (0600).
|
|
27
27
|
// One writer per world by construction: the doors run in this process, serialized per twin.
|
|
28
28
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
|
|
29
|
-
import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
|
|
29
|
+
import { CALLER_HEADER, newTraceparent, parseTraceparent, traceparentForDelivery, readTwinRequestJournalFrom, type TwinRequestJournalCursor, assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, twinRequestJournalFailures, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
|
|
30
30
|
import { APP_READ_ENDPOINT_ENV } from './init.ts';
|
|
31
31
|
import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
@@ -380,6 +380,11 @@ function traceIdOf(traceparent: unknown): string | null {
|
|
|
380
380
|
return m && !/^0+$/.test(m[1]!) ? m[1]! : null;
|
|
381
381
|
}
|
|
382
382
|
type TimelineRow = { twin: string; position: number; entry: Entry };
|
|
383
|
+
const CALLERS_KEPT = 50_000;
|
|
384
|
+
/** The closed journal segments a World reads for callers when it first answers a timeline (~1 MB each): older entries show no caller. */
|
|
385
|
+
const CALLER_SEGMENTS = 8;
|
|
386
|
+
/** A W3C traceparent's parent-id (the span the World minted for the request), or null. */
|
|
387
|
+
function spanOf(traceparent: unknown): string | null { return parseTraceparent(traceparent)?.parentId ?? null; }
|
|
383
388
|
/** Timeline order, newest first: by the entry's instant, then twin, then position. */
|
|
384
389
|
function newerFirst(a: { at: string; twin: string; position: number }, b: { at: string; twin: string; position: number }): number {
|
|
385
390
|
return b.at.localeCompare(a.at) || b.twin.localeCompare(a.twin) || b.position - a.position;
|
|
@@ -590,6 +595,16 @@ export class WorldDoors {
|
|
|
590
595
|
if (grant.via === 'session') return { scope: grant.scope, via: 'session', token: null, session: grant.session!, ...(grant.who ? { who: grant.who } : {}) };
|
|
591
596
|
return { scope: grant.scope, via: grant.via === 'key' ? 'key' : 'token', token: grant.presented ?? null };
|
|
592
597
|
}
|
|
598
|
+
/** Who made a request, as this World names it: `key:<name>` for a named key, `person:<who>` for a browser's session
|
|
599
|
+
* (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
|
|
600
|
+
* this World issued. */
|
|
601
|
+
private callerOf(request: Request): string | null {
|
|
602
|
+
const cred = this.credential(request);
|
|
603
|
+
if (cred.scope === null) return null;
|
|
604
|
+
if (cred.via === 'session') return cred.who ? `person:${cred.who}` : 'person';
|
|
605
|
+
if (cred.via === 'key') { const token = cred.token; const held = token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined; return held ? `key:${held.name}` : 'key'; }
|
|
606
|
+
return cred.scope === 'read' ? 'token:read' : 'token';
|
|
607
|
+
}
|
|
593
608
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
594
609
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
595
610
|
* the path-scoped cookie a shared origin has always carried. */
|
|
@@ -781,6 +796,12 @@ export class WorldDoors {
|
|
|
781
796
|
const enforced = scope === 'read' && (await this.enforcesRead(vendor!, twin, url.origin));
|
|
782
797
|
if (scope === 'read' && !reads && !enforced) return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
783
798
|
const headers = new Headers(request.headers); headers.delete(TOKEN_HEADER); headers.delete(TWINS_KEY_HEADER); headers.delete('host');
|
|
799
|
+
// who made it is the World's word, never the caller's: the twin's request journal records it. The World is a hop of
|
|
800
|
+
// the request's trace: the caller's trace-id goes on (or a new one), under a parent-id the World mints, which the
|
|
801
|
+
// request's log entries carry, so each entry is joined to its caller by an id no caller chose
|
|
802
|
+
headers.delete(CALLER_HEADER);
|
|
803
|
+
const who = this.callerOf(request); if (who) headers.set(CALLER_HEADER, who);
|
|
804
|
+
headers.set('traceparent', traceparentForDelivery(headers.get('traceparent')) ?? newTraceparent());
|
|
784
805
|
// at a twin that enforces the read scope, every request of the read token is marked read-only (a GET
|
|
785
806
|
// that would write for its caller is refused too); at any other, its GET passes as it always has
|
|
786
807
|
if (enforced) headers.set(READ_ONLY_HEADER, '1');
|
|
@@ -843,7 +864,7 @@ export class WorldDoors {
|
|
|
843
864
|
// what leaves for the vendor before the seal is applied: no World token in any header it can arrive
|
|
844
865
|
// under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
|
|
845
866
|
const caller = new Headers(request.headers);
|
|
846
|
-
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
|
|
867
|
+
for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, CALLER_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
|
|
847
868
|
// a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
|
|
848
869
|
const authorization = caller.get('authorization');
|
|
849
870
|
const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
|
|
@@ -852,7 +873,9 @@ export class WorldDoors {
|
|
|
852
873
|
// name, with the credential shape of what the caller sent on. As an audit device does, the request is
|
|
853
874
|
// written before the real credential is used, and a request whose audit cannot be written is refused.
|
|
854
875
|
const credentials = twinRequestCredentials(caller, url);
|
|
855
|
-
|
|
876
|
+
const who = this.callerOf(request); const trace = parseTraceparent(request.headers.get('traceparent'))?.traceId;
|
|
877
|
+
const by = { ...(who ? { caller: who } : {}), ...(trace ? { trace } : {}) };
|
|
878
|
+
if (!journalTwinRequest(name, { phase: 'request', method: request.method, path: `/${rest}`, status: 0, credentials, ...by }, this.controlRoot(name))) {
|
|
856
879
|
return Response.json({ error: `the ${name} link's audit could not be written: nothing was sent` }, { status: 503 });
|
|
857
880
|
}
|
|
858
881
|
const startedAt = Date.now(); let status = 500;
|
|
@@ -861,7 +884,7 @@ export class WorldDoors {
|
|
|
861
884
|
status = answer.status;
|
|
862
885
|
return answer;
|
|
863
886
|
} finally {
|
|
864
|
-
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials }, this.controlRoot(name));
|
|
887
|
+
journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials, ...by }, this.controlRoot(name));
|
|
865
888
|
}
|
|
866
889
|
}
|
|
867
890
|
|
|
@@ -1442,7 +1465,23 @@ export class WorldDoors {
|
|
|
1442
1465
|
const more = page.length > limit; const shown = page.slice(0, limit);
|
|
1443
1466
|
const last = shown[shown.length - 1];
|
|
1444
1467
|
const next = more && last ? Buffer.from(JSON.stringify([last.entry.occurredAt, last.twin, last.position])).toString('base64url') : null;
|
|
1445
|
-
|
|
1468
|
+
// who made each entry: the request it was written in (the World's span on it) as the twin's journal recorded it
|
|
1469
|
+
const entries = shown.map((r) => { const by = this.callersOf(r.twin).get(spanOf(r.entry.traceparent) ?? ''); return by ? { ...r, caller: by } : r; });
|
|
1470
|
+
return Response.json({ world: this.served, entries, next });
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Each twin's callers by the World's span, from its request journal: its newest CALLER_SEGMENTS once, then from
|
|
1474
|
+
* where the last read stopped (readTwinRequestJournalFrom follows a rotation). The newest CALLERS_KEPT are kept. */
|
|
1475
|
+
private readonly callersByTwin = new Map<string, { root: string; cursor: TwinRequestJournalCursor | null; callers: Map<string, string> }>();
|
|
1476
|
+
private callersOf(twin: string): Map<string, string> {
|
|
1477
|
+
const root = this.controlRoot(twin);
|
|
1478
|
+
let held = this.callersByTwin.get(twin);
|
|
1479
|
+
if (!held || held.root !== root) { held = { root, cursor: null, callers: new Map() }; this.callersByTwin.set(twin, held); }
|
|
1480
|
+
const read = readTwinRequestJournalFrom(twin, root, held.cursor, { segments: CALLER_SEGMENTS });
|
|
1481
|
+
for (const e of read.entries) if (e.span && e.caller) { held.callers.delete(e.span); held.callers.set(e.span, e.caller); }
|
|
1482
|
+
held.cursor = read.cursor;
|
|
1483
|
+
for (const span of held.callers.keys()) { if (held.callers.size <= CALLERS_KEPT) break; held.callers.delete(span); }
|
|
1484
|
+
return held.callers;
|
|
1446
1485
|
}
|
|
1447
1486
|
|
|
1448
1487
|
/** Each twin's timeline rows, kept until its logs change: a poll of an unchanged World reads no log
|