@ixo/oracle-runtime-workers 0.33.1 → 0.34.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/README.md +16 -16
- package/package.json +3 -3
- package/src/hub-actions/transfer.ts +19 -1
- package/src/plugins/ixo-chain/denoms.ts +15 -12
- package/src/plugins/ixo-chain/http.ts +109 -29
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +122 -14
- package/src/plugins/ixo-chain/kinds/chain/shared.ts +15 -12
- package/src/plugins/ixo-chain/lookup.ts +66 -6
- package/src/plugins/ixo-chain/names/client.ts +456 -0
- package/src/plugins/ixo-chain/names/collect.ts +180 -0
- package/src/plugins/ixo-chain/names/types.ts +25 -0
- package/src/plugins/ixo-chain/overview.ts +92 -34
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identifiers a tool result carries, for its `names` map, and the
|
|
3
|
+
* upkeep of that map while the result is cut to size.
|
|
4
|
+
*
|
|
5
|
+
* An identifier is a whole string value in a form the IXO Names worker
|
|
6
|
+
* names: an `ixo1…` address of an account (42 characters) or a contract
|
|
7
|
+
* (62), its bech32 checksum valid; a `did:ixo:ixo1…` or `did:ixo:wasm:ixo1…`
|
|
8
|
+
* DID of such an address; an entity DID (`did:ixo:entity:<32 hex>`); an
|
|
9
|
+
* `ixovaloper1…` address; or a claim collection id (sent as
|
|
10
|
+
* `collection:<id>`) in a field named `collectionId`, or the `id` of a
|
|
11
|
+
* `claim_collection` result or of a `claim_collections` result's items.
|
|
12
|
+
* A field named `collection` is not read as one: a token's `collection` is
|
|
13
|
+
* text its minter chose. An address inside a memo or an excerpt is text,
|
|
14
|
+
* not an identifier. Denoms are not collected: balances already name their
|
|
15
|
+
* tokens. Who raised a dispute (`disputer`, `disputerDid`) is never named.
|
|
16
|
+
* A `names` map already on the result is never read as part of it.
|
|
17
|
+
*/
|
|
18
|
+
import type { UserIdentity } from '../../../core/user-identity';
|
|
19
|
+
import { isBech32 } from '../queries';
|
|
20
|
+
import { isRecord } from '../summaries';
|
|
21
|
+
import type { NameMap } from './types';
|
|
22
|
+
|
|
23
|
+
/** The most identifiers one tool result asks names for, the best placed first. */
|
|
24
|
+
export const MAX_IDENTIFIERS = 40;
|
|
25
|
+
/** An account address, and a contract address. */
|
|
26
|
+
const ADDRESS_LENGTHS: ReadonlySet<number> = new Set([42, 62]);
|
|
27
|
+
/** BIP-173's longest bech32 string. */
|
|
28
|
+
const BECH32_MAX = 90;
|
|
29
|
+
const ACCOUNT_DID_PREFIX = 'did:ixo:';
|
|
30
|
+
const CONTRACT_DID_PREFIX = 'did:ixo:wasm:';
|
|
31
|
+
const ENTITY_DID = /^did:ixo:entity:[0-9a-f]{32}$/;
|
|
32
|
+
const COLLECTION_ID = /^\d{1,12}$/;
|
|
33
|
+
/** Fields whose identifiers are never named: a dispute's filer (the `disputes` kind). */
|
|
34
|
+
const UNNAMED_FIELDS: ReadonlySet<string> = new Set([
|
|
35
|
+
'disputer',
|
|
36
|
+
'disputerDid',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The rows of a `chain_lookup` result whose own `id` is a claim collection
|
|
41
|
+
* id: the `data` of an answered `claim_collection` request, the
|
|
42
|
+
* `data.items` of an answered `claim_collections` request.
|
|
43
|
+
*/
|
|
44
|
+
function collectionRowsOf(node: Record<string, unknown>): unknown[] {
|
|
45
|
+
if (node.ok !== true || !isRecord(node.data)) return [];
|
|
46
|
+
if (node.kind === 'claim_collection') return [node.data];
|
|
47
|
+
if (node.kind === 'claim_collections' && Array.isArray(node.data.items))
|
|
48
|
+
return node.data.items;
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `value` (under the key `field`) as an identifier the Names worker names, or
|
|
54
|
+
* null; `inCollectionRow` when `field` is a key of a collection row (see
|
|
55
|
+
* {@link collectionRowsOf}).
|
|
56
|
+
*/
|
|
57
|
+
function identifierOf(
|
|
58
|
+
value: string,
|
|
59
|
+
field: string | undefined,
|
|
60
|
+
inCollectionRow: boolean,
|
|
61
|
+
): string | null {
|
|
62
|
+
if (
|
|
63
|
+
(field === 'collectionId' || (inCollectionRow && field === 'id')) &&
|
|
64
|
+
COLLECTION_ID.test(value)
|
|
65
|
+
)
|
|
66
|
+
return `collection:${value}`;
|
|
67
|
+
if (ENTITY_DID.test(value)) return value;
|
|
68
|
+
const address = value.startsWith(`${CONTRACT_DID_PREFIX}ixo1`)
|
|
69
|
+
? value.slice(CONTRACT_DID_PREFIX.length)
|
|
70
|
+
: value.startsWith(`${ACCOUNT_DID_PREFIX}ixo1`)
|
|
71
|
+
? value.slice(ACCOUNT_DID_PREFIX.length)
|
|
72
|
+
: value;
|
|
73
|
+
if (ADDRESS_LENGTHS.has(address.length) && isBech32(address, 'ixo'))
|
|
74
|
+
return value;
|
|
75
|
+
return address === value &&
|
|
76
|
+
value.length <= BECH32_MAX &&
|
|
77
|
+
isBech32(value, 'ixovaloper')
|
|
78
|
+
? value
|
|
79
|
+
: null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One walk over a tool result: where it reports, and the collection rows it has met. */
|
|
83
|
+
interface Walk {
|
|
84
|
+
found: (identifier: string, rank: number) => void;
|
|
85
|
+
collectionRows: WeakSet<object>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Calls `walk.found` for every identifier in `node` with its rank: the
|
|
90
|
+
* highest list index on its path, so an identifier in the first rows of
|
|
91
|
+
* every list ranks before one further down any of them. `inCollectionRow`:
|
|
92
|
+
* `node` is a value directly under a collection row.
|
|
93
|
+
*/
|
|
94
|
+
function visit(
|
|
95
|
+
node: unknown,
|
|
96
|
+
rank: number,
|
|
97
|
+
field: string | undefined,
|
|
98
|
+
inCollectionRow: boolean,
|
|
99
|
+
walk: Walk,
|
|
100
|
+
): void {
|
|
101
|
+
if (field !== undefined && UNNAMED_FIELDS.has(field)) return;
|
|
102
|
+
if (typeof node === 'string') {
|
|
103
|
+
const identifier = identifierOf(node, field, inCollectionRow);
|
|
104
|
+
if (identifier) walk.found(identifier, rank);
|
|
105
|
+
} else if (Array.isArray(node))
|
|
106
|
+
node.forEach((item, index) =>
|
|
107
|
+
visit(item, Math.max(rank, index), field, false, walk),
|
|
108
|
+
);
|
|
109
|
+
else if (isRecord(node)) {
|
|
110
|
+
for (const row of collectionRowsOf(node))
|
|
111
|
+
if (isRecord(row)) walk.collectionRows.add(row);
|
|
112
|
+
const isCollectionRow = walk.collectionRows.has(node);
|
|
113
|
+
for (const [key, inner] of Object.entries(node))
|
|
114
|
+
visit(inner, rank, key, isCollectionRow, walk);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** {@link visit} over a tool result, leaving out its `names` map. */
|
|
119
|
+
function eachIdentifier(
|
|
120
|
+
result: unknown,
|
|
121
|
+
found: (identifier: string, rank: number) => void,
|
|
122
|
+
): void {
|
|
123
|
+
const walk: Walk = { found, collectionRows: new WeakSet() };
|
|
124
|
+
if (!isRecord(result)) {
|
|
125
|
+
visit(result, 0, undefined, false, walk);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
for (const [key, inner] of Object.entries(result))
|
|
129
|
+
if (key !== 'names') visit(inner, 0, key, false, walk);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The identifiers to name in `result`, without the user's own address and
|
|
134
|
+
* DID (the model already knows the user): each once, those in the top rows
|
|
135
|
+
* of lists first (then in the order they appear), at most
|
|
136
|
+
* {@link MAX_IDENTIFIERS}.
|
|
137
|
+
*/
|
|
138
|
+
export function collectIdentifiers(
|
|
139
|
+
result: unknown,
|
|
140
|
+
identity: UserIdentity,
|
|
141
|
+
): string[] {
|
|
142
|
+
const own = new Set([identity.did]);
|
|
143
|
+
if (identity.address) {
|
|
144
|
+
own.add(identity.address);
|
|
145
|
+
own.add(`${ACCOUNT_DID_PREFIX}${identity.address}`);
|
|
146
|
+
}
|
|
147
|
+
// Insertion order is the order of first appearance; the sort is stable.
|
|
148
|
+
const best = new Map<string, number>();
|
|
149
|
+
eachIdentifier(result, (identifier, rank) => {
|
|
150
|
+
if (own.has(identifier)) return;
|
|
151
|
+
const known = best.get(identifier);
|
|
152
|
+
if (known === undefined || rank < known) best.set(identifier, rank);
|
|
153
|
+
});
|
|
154
|
+
return [...best]
|
|
155
|
+
.sort((a, b) => a[1] - b[1])
|
|
156
|
+
.slice(0, MAX_IDENTIFIERS)
|
|
157
|
+
.map(([identifier]) => identifier);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Leaves out the entries of `answer.names` whose identifier is no longer in `answer`. */
|
|
161
|
+
export function pruneNames(answer: { names?: NameMap }): void {
|
|
162
|
+
const { names } = answer;
|
|
163
|
+
if (!names) return;
|
|
164
|
+
const present = new Set<string>();
|
|
165
|
+
eachIdentifier(answer, (identifier) => present.add(identifier));
|
|
166
|
+
answer.names = Object.fromEntries(
|
|
167
|
+
Object.entries(names).filter(([identifier]) => present.has(identifier)),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Leaves out the last entry of `answer.names` (the map lists the best
|
|
173
|
+
* placed identifiers first); false when there is none to leave out.
|
|
174
|
+
*/
|
|
175
|
+
export function dropLastName(answer: { names?: NameMap }): boolean {
|
|
176
|
+
const entries = Object.entries(answer.names ?? {});
|
|
177
|
+
if (!entries.length) return false;
|
|
178
|
+
answer.names = Object.fromEntries(entries.slice(0, -1));
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `names` map a tool result carries: the IXO Names worker's name for
|
|
3
|
+
* each identifier in the result, keyed by the raw identifier, so a name is
|
|
4
|
+
* never mistaken for the identifier itself. The result keeps every address
|
|
5
|
+
* and DID as it was.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface NameInfo {
|
|
9
|
+
/**
|
|
10
|
+
* The text to call the identifier by, as the worker cleaned it (at most
|
|
11
|
+
* 120 characters). Still data written by anyone, never instructions.
|
|
12
|
+
*/
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* Where the worker found it: `table`, `matrix`, `entity`, `entity-alias`,
|
|
16
|
+
* `entity-account`, `dao`, `validator`, `collection`, `denom-registry`,
|
|
17
|
+
* `denom-metadata` or `ibc-trace`.
|
|
18
|
+
*/
|
|
19
|
+
source: string;
|
|
20
|
+
/** True when the holder picked the name themselves (anyone can pick a look-alike). */
|
|
21
|
+
selfChosen: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `identifier → name` for one tool result; identifiers without a name are absent. */
|
|
25
|
+
export type NameMap = Record<string, NameInfo>;
|
|
@@ -9,16 +9,19 @@
|
|
|
9
9
|
* on its own: its counts and top rows, with the request that reads the
|
|
10
10
|
* rest, or why it failed. The answer is cut to {@link MAX_RESULT_CHARS},
|
|
11
11
|
* top rows first, and an answer without any failure is kept per isolate
|
|
12
|
-
* for {@link OVERVIEW_TTL_MS}.
|
|
12
|
+
* for {@link OVERVIEW_TTL_MS}. Its `names` are not kept: each call adds
|
|
13
|
+
* its own to its own copy, cutting top rows ({@link cutTopRow}) to make
|
|
14
|
+
* room.
|
|
13
15
|
*/
|
|
14
16
|
import { z } from 'zod';
|
|
15
17
|
import type { UserIdentity } from '../../core/user-identity';
|
|
16
18
|
import { balancesKind } from './balances';
|
|
17
19
|
import {
|
|
20
|
+
ReadsInFlight,
|
|
18
21
|
hasBudget,
|
|
19
22
|
throwIfAborted,
|
|
20
23
|
tooLarge,
|
|
21
|
-
|
|
24
|
+
untilDeadline,
|
|
22
25
|
type ChainCall,
|
|
23
26
|
type Failure,
|
|
24
27
|
type Outcome,
|
|
@@ -34,6 +37,7 @@ import {
|
|
|
34
37
|
type LookupOptions,
|
|
35
38
|
type LookupResult,
|
|
36
39
|
} from './lookup';
|
|
40
|
+
import type { NameMap } from './names/types';
|
|
37
41
|
import { accountOf, type Subject } from './queries';
|
|
38
42
|
import type { Absent } from './rest';
|
|
39
43
|
import { isRecord } from './summaries';
|
|
@@ -639,6 +643,8 @@ export type OverviewAnswer = {
|
|
|
639
643
|
address: string;
|
|
640
644
|
subject: Subject;
|
|
641
645
|
sections: Partial<Record<Section, SectionAnswer>>;
|
|
646
|
+
/** What the identifiers in the answer are called (added after the cache, per call). */
|
|
647
|
+
names?: NameMap;
|
|
642
648
|
};
|
|
643
649
|
|
|
644
650
|
export type OverviewFailure = {
|
|
@@ -655,6 +661,23 @@ const LEFT_OUT = tooLarge(
|
|
|
655
661
|
'Left out to keep the answer small; ask for this section on its own.',
|
|
656
662
|
);
|
|
657
663
|
|
|
664
|
+
type MoreArgs = (part: Part) => Record<string, unknown>;
|
|
665
|
+
|
|
666
|
+
/** The account is named in `more` only when it is not the user's (the kinds' default). */
|
|
667
|
+
const moreArgsOf =
|
|
668
|
+
(account: { address: string; subject: Subject }): MoreArgs =>
|
|
669
|
+
(part) =>
|
|
670
|
+
account.subject === 'user'
|
|
671
|
+
? { ...part.args }
|
|
672
|
+
: { ...part.args, [part.account]: account.address };
|
|
673
|
+
|
|
674
|
+
/** The request that reads the rest of `part`. */
|
|
675
|
+
function moreOf(part: Part, moreArgs: MoreArgs): More {
|
|
676
|
+
return part.tool
|
|
677
|
+
? { tool: part.tool, args: moreArgs(part) }
|
|
678
|
+
: { kind: part.kind.name, args: moreArgs(part) };
|
|
679
|
+
}
|
|
680
|
+
|
|
658
681
|
/** A part that answered, with its rows. */
|
|
659
682
|
interface Answered {
|
|
660
683
|
part: Part;
|
|
@@ -712,7 +735,7 @@ function gather(
|
|
|
712
735
|
function render(
|
|
713
736
|
gathered: Gathered,
|
|
714
737
|
limit: number,
|
|
715
|
-
moreArgs:
|
|
738
|
+
moreArgs: MoreArgs,
|
|
716
739
|
): SectionAnswer {
|
|
717
740
|
const { answered, failed, tagged } = gathered;
|
|
718
741
|
const first = failed[0];
|
|
@@ -751,11 +774,7 @@ function render(
|
|
|
751
774
|
(entry, index) =>
|
|
752
775
|
entry.rows.more || entry.rows.rows.length > (shown[index] ?? 0),
|
|
753
776
|
);
|
|
754
|
-
const more
|
|
755
|
-
? undefined
|
|
756
|
-
: rest.part.tool
|
|
757
|
-
? { tool: rest.part.tool, args: moreArgs(rest.part) }
|
|
758
|
-
: { kind: rest.part.kind.name, args: moreArgs(rest.part) };
|
|
777
|
+
const more = rest ? moreOf(rest.part, moreArgs) : undefined;
|
|
759
778
|
return {
|
|
760
779
|
ok: true,
|
|
761
780
|
summary: { counts, ...(totals ? { totals } : {}), top },
|
|
@@ -814,12 +833,7 @@ async function readOverview(
|
|
|
814
833
|
return index;
|
|
815
834
|
});
|
|
816
835
|
const answers = await runRequests(call, identity, requests, options);
|
|
817
|
-
|
|
818
|
-
// kinds' default).
|
|
819
|
-
const moreArgs = (part: Part) =>
|
|
820
|
-
account.subject === 'user'
|
|
821
|
-
? { ...part.args }
|
|
822
|
-
: { ...part.args, [part.account]: account.address };
|
|
836
|
+
const moreArgs = moreArgsOf(account);
|
|
823
837
|
const state = sections.map((section) => {
|
|
824
838
|
const results = planned.flatMap((entry, index) =>
|
|
825
839
|
entry.section === section ? [answers[requestOf[index] ?? -1]] : [],
|
|
@@ -894,6 +908,48 @@ async function readOverview(
|
|
|
894
908
|
return answer;
|
|
895
909
|
}
|
|
896
910
|
|
|
911
|
+
/**
|
|
912
|
+
* One cut of a rendered answer that must make room (for its `names`): the
|
|
913
|
+
* last top row of the largest section that still has one, as a smaller
|
|
914
|
+
* `limit` renders it. The section is marked `truncated`, and its `more`
|
|
915
|
+
* names the first of its parts with rows not shown. False when no section
|
|
916
|
+
* has a top row left.
|
|
917
|
+
*/
|
|
918
|
+
export function cutTopRow(answer: OverviewAnswer): boolean {
|
|
919
|
+
let largest: Section | undefined;
|
|
920
|
+
let largestSize = -1;
|
|
921
|
+
for (const section of SECTIONS) {
|
|
922
|
+
const shown = answer.sections[section];
|
|
923
|
+
if (!shown?.ok || !shown.summary.top.length) continue;
|
|
924
|
+
const bytes = size(shown);
|
|
925
|
+
if (bytes > largestSize) {
|
|
926
|
+
largest = section;
|
|
927
|
+
largestSize = bytes;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const shown = largest ? answer.sections[largest] : undefined;
|
|
931
|
+
if (!largest || !shown?.ok) return false;
|
|
932
|
+
const row = shown.summary.top.pop();
|
|
933
|
+
const parts = PARTS[largest];
|
|
934
|
+
// A section of several parts tags each top row with its part's `list`.
|
|
935
|
+
const cut =
|
|
936
|
+
parts.length > 1
|
|
937
|
+
? parts.find((part) => isRecord(row) && row.list === part.list)
|
|
938
|
+
: parts[0];
|
|
939
|
+
if (cut) {
|
|
940
|
+
const moreArgs = moreArgsOf(answer);
|
|
941
|
+
// -1 when the section has no `more` yet.
|
|
942
|
+
const current = JSON.stringify(shown.more);
|
|
943
|
+
const named = parts.findIndex(
|
|
944
|
+
(part) => JSON.stringify(moreOf(part, moreArgs)) === current,
|
|
945
|
+
);
|
|
946
|
+
if (named < 0 || parts.indexOf(cut) < named)
|
|
947
|
+
shown.more = moreOf(cut, moreArgs);
|
|
948
|
+
}
|
|
949
|
+
shown.truncated = true;
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
|
|
897
953
|
/* ── cache ────────────────────────────────────────────────────────────── */
|
|
898
954
|
|
|
899
955
|
/**
|
|
@@ -937,17 +993,19 @@ function timedOut(answer: OverviewAnswer): boolean {
|
|
|
937
993
|
* Overview answers per isolate: each complete one kept for
|
|
938
994
|
* {@link OVERVIEW_TTL_MS}, at most {@link OVERVIEW_CACHE_LIMIT} (the
|
|
939
995
|
* oldest dropped first). A read in flight is shared by every call that
|
|
940
|
-
* asks the same; a joining call waits
|
|
941
|
-
* throws its own abort reason. It reads
|
|
942
|
-
* ended in the starting turn's abort, or
|
|
943
|
-
* and the joining call still has budget.
|
|
996
|
+
* asks the same; a joining call waits until its own deadline at most, on
|
|
997
|
+
* its own turn's abort only, and throws its own abort reason. It reads
|
|
998
|
+
* again itself when the shared read ended in the starting turn's abort, or
|
|
999
|
+
* when that read ran out of time and the joining call still has budget. A
|
|
1000
|
+
* read that died with its actor is replaced by the next caller past its
|
|
1001
|
+
* age limit ({@link ReadsInFlight}). Every call gets its own copy.
|
|
944
1002
|
*/
|
|
945
1003
|
class OverviewCache {
|
|
946
1004
|
private readonly answers = new Map<
|
|
947
1005
|
string,
|
|
948
1006
|
{ answer: OverviewAnswer; expiresAt: number }
|
|
949
1007
|
>();
|
|
950
|
-
private readonly inflight = new
|
|
1008
|
+
private readonly inflight = new ReadsInFlight<OverviewAnswer>();
|
|
951
1009
|
|
|
952
1010
|
async get(
|
|
953
1011
|
key: string,
|
|
@@ -957,19 +1015,22 @@ class OverviewCache {
|
|
|
957
1015
|
const hit = this.answers.get(key);
|
|
958
1016
|
if (hit && hit.expiresAt > Date.now()) return structuredClone(hit.answer);
|
|
959
1017
|
if (hit) this.answers.delete(key);
|
|
960
|
-
const running = this.inflight.
|
|
961
|
-
if (!running) return this.start(key, read);
|
|
962
|
-
let shared: OverviewAnswer;
|
|
1018
|
+
const running = this.inflight.joinable(key);
|
|
1019
|
+
if (!running) return this.start(key, call, read);
|
|
1020
|
+
let shared: OverviewAnswer | undefined;
|
|
963
1021
|
try {
|
|
964
|
-
shared = await
|
|
1022
|
+
shared = await untilDeadline(running, call);
|
|
965
1023
|
} catch {
|
|
966
1024
|
// This turn's abort, or the starting turn's (the only error a read
|
|
967
1025
|
// throws): only this turn's is thrown; after the other's this call
|
|
968
1026
|
// reads on its own.
|
|
969
1027
|
throwIfAborted(call.signal);
|
|
970
|
-
return this.start(key, read);
|
|
1028
|
+
return this.start(key, call, read);
|
|
971
1029
|
}
|
|
972
|
-
|
|
1030
|
+
// Its own deadline came first. With no budget left this read asks
|
|
1031
|
+
// nothing: every section is the spent budget's timeout. It is not shared.
|
|
1032
|
+
if (shared === undefined) return read();
|
|
1033
|
+
if (timedOut(shared) && hasBudget(call)) return this.start(key, call, read);
|
|
973
1034
|
return structuredClone(shared);
|
|
974
1035
|
}
|
|
975
1036
|
|
|
@@ -980,17 +1041,14 @@ class OverviewCache {
|
|
|
980
1041
|
|
|
981
1042
|
private async start(
|
|
982
1043
|
key: string,
|
|
1044
|
+
call: ChainCall,
|
|
983
1045
|
read: () => Promise<OverviewAnswer>,
|
|
984
1046
|
): Promise<OverviewAnswer> {
|
|
985
|
-
const
|
|
986
|
-
this.
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
return answer;
|
|
991
|
-
} finally {
|
|
992
|
-
if (this.inflight.get(key) === pending) this.inflight.delete(key);
|
|
993
|
-
}
|
|
1047
|
+
const answer = await this.inflight.track(key, call, read());
|
|
1048
|
+
if (complete(answer)) this.remember(key, answer);
|
|
1049
|
+
// The calls joining this read copy `answer` when they wake; the
|
|
1050
|
+
// caller adds its names to its own copy.
|
|
1051
|
+
return structuredClone(answer);
|
|
994
1052
|
}
|
|
995
1053
|
|
|
996
1054
|
private remember(key: string, answer: OverviewAnswer): void {
|