@ixo/oracle-runtime-workers 0.33.2 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- untilAborted,
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: (part: Part) => Record<string, unknown>,
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: More | undefined = !rest
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
- // The account is named in `more` only when it is not the user's (the
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 on its own turn's abort only and
941
- * throws its own abort reason. It reads again itself when the shared read
942
- * ended in the starting turn's abort, or when that read ran out of time
943
- * and the joining call still has budget. Every call gets its own copy.
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 Map<string, Promise<OverviewAnswer>>();
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.get(key);
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 untilAborted(running, call.signal);
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
- if (timedOut(shared) && hasBudget(call)) return this.start(key, read);
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 pending = read();
986
- this.inflight.set(key, pending);
987
- try {
988
- const answer = await pending;
989
- if (complete(answer)) this.remember(key, answer);
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 {