@poa-box/core 0.1.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 +201 -0
- package/dist/abis/DirectDemocracyVotingNew.d.ts +749 -0
- package/dist/abis/DirectDemocracyVotingNew.js +961 -0
- package/dist/abis/ERC20.d.ts +90 -0
- package/dist/abis/ERC20.js +125 -0
- package/dist/abis/EducationHubNew.d.ts +590 -0
- package/dist/abis/EducationHubNew.js +762 -0
- package/dist/abis/EligibilityModuleNew.d.ts +1615 -0
- package/dist/abis/EligibilityModuleNew.js +2082 -0
- package/dist/abis/Executor.d.ts +619 -0
- package/dist/abis/Executor.js +796 -0
- package/dist/abis/HybridVotingNew.d.ts +898 -0
- package/dist/abis/HybridVotingNew.js +1149 -0
- package/dist/abis/ImplementationRegistry.d.ts +270 -0
- package/dist/abis/ImplementationRegistry.js +355 -0
- package/dist/abis/OrgDeployerNew.d.ts +1275 -0
- package/dist/abis/OrgDeployerNew.js +1630 -0
- package/dist/abis/OrgRegistry.d.ts +670 -0
- package/dist/abis/OrgRegistry.js +868 -0
- package/dist/abis/ParticipationToken.d.ts +1086 -0
- package/dist/abis/ParticipationToken.js +1415 -0
- package/dist/abis/PasskeyAccount.d.ts +788 -0
- package/dist/abis/PasskeyAccount.js +1019 -0
- package/dist/abis/PasskeyAccountFactory.d.ts +344 -0
- package/dist/abis/PasskeyAccountFactory.js +449 -0
- package/dist/abis/PaymasterHub.d.ts +1603 -0
- package/dist/abis/PaymasterHub.js +2048 -0
- package/dist/abis/PaymentManager.d.ts +520 -0
- package/dist/abis/PaymentManager.js +671 -0
- package/dist/abis/PoaManager.d.ts +344 -0
- package/dist/abis/PoaManager.js +449 -0
- package/dist/abis/QuickJoinNew.d.ts +855 -0
- package/dist/abis/QuickJoinNew.js +1098 -0
- package/dist/abis/TaskManagerNew.d.ts +1236 -0
- package/dist/abis/TaskManagerNew.js +1573 -0
- package/dist/abis/ToggleModule.d.ts +193 -0
- package/dist/abis/ToggleModule.js +255 -0
- package/dist/abis/UniversalAccountRegistry.d.ts +577 -0
- package/dist/abis/UniversalAccountRegistry.js +750 -0
- package/dist/abis/ZkEmailInvites.d.ts +823 -0
- package/dist/abis/ZkEmailInvites.js +1060 -0
- package/dist/abis/external/AaveGovernanceV2.d.ts +128 -0
- package/dist/abis/external/AaveGovernanceV2.js +177 -0
- package/dist/abis/external/AaveGovernanceV3.d.ts +160 -0
- package/dist/abis/external/AaveGovernanceV3.js +220 -0
- package/dist/abis/external/AragonVoting.d.ts +107 -0
- package/dist/abis/external/AragonVoting.js +150 -0
- package/dist/abis/external/CurveGaugeController.d.ts +95 -0
- package/dist/abis/external/CurveGaugeController.js +130 -0
- package/dist/abis/external/CurveVotingEscrow.d.ts +86 -0
- package/dist/abis/external/CurveVotingEscrow.js +116 -0
- package/dist/abis/external/GovernorAlpha.d.ts +172 -0
- package/dist/abis/external/GovernorAlpha.js +228 -0
- package/dist/abis/external/MakerDAOChief.d.ts +92 -0
- package/dist/abis/external/MakerDAOChief.js +129 -0
- package/dist/abis/external/OZGovernor.d.ts +227 -0
- package/dist/abis/external/OZGovernor.js +317 -0
- package/dist/abis/external/SolidlyVotingEscrow.d.ts +196 -0
- package/dist/abis/external/SolidlyVotingEscrow.js +260 -0
- package/dist/abis/index.d.ts +62 -0
- package/dist/abis/index.js +98 -0
- package/dist/chains.d.ts +150 -0
- package/dist/chains.js +320 -0
- package/dist/context.d.ts +48 -0
- package/dist/context.js +38 -0
- package/dist/contracts.d.ts +24 -0
- package/dist/contracts.js +41 -0
- package/dist/encoding.d.ts +58 -0
- package/dist/encoding.js +243 -0
- package/dist/env.d.ts +10 -0
- package/dist/env.js +4 -0
- package/dist/error-catalog.d.ts +41 -0
- package/dist/error-catalog.js +941 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.js +60 -0
- package/dist/execute/ethers.d.ts +93 -0
- package/dist/execute/ethers.js +323 -0
- package/dist/execute/index.d.ts +7 -0
- package/dist/execute/index.js +23 -0
- package/dist/execute/sponsored.d.ts +68 -0
- package/dist/execute/sponsored.js +277 -0
- package/dist/exit-codes.d.ts +18 -0
- package/dist/exit-codes.js +21 -0
- package/dist/format.d.ts +21 -0
- package/dist/format.js +89 -0
- package/dist/graph/client.d.ts +298 -0
- package/dist/graph/client.js +702 -0
- package/dist/graph/documents/activity.d.ts +9 -0
- package/dist/graph/documents/activity.js +164 -0
- package/dist/graph/documents/beacons.d.ts +172 -0
- package/dist/graph/documents/beacons.js +345 -0
- package/dist/graph/documents/index.d.ts +21 -0
- package/dist/graph/documents/index.js +57 -0
- package/dist/graph/documents/infrastructure.d.ts +30 -0
- package/dist/graph/documents/infrastructure.js +34 -0
- package/dist/graph/documents/org.d.ts +15 -0
- package/dist/graph/documents/org.js +249 -0
- package/dist/graph/documents/paymaster.d.ts +95 -0
- package/dist/graph/documents/paymaster.js +101 -0
- package/dist/graph/documents/role.d.ts +6 -0
- package/dist/graph/documents/role.js +41 -0
- package/dist/graph/documents/roles.d.ts +50 -0
- package/dist/graph/documents/roles.js +112 -0
- package/dist/graph/documents/task.d.ts +84 -0
- package/dist/graph/documents/task.js +236 -0
- package/dist/graph/documents/token.d.ts +13 -0
- package/dist/graph/documents/token.js +126 -0
- package/dist/graph/documents/treasury.d.ts +39 -0
- package/dist/graph/documents/treasury.js +136 -0
- package/dist/graph/documents/user.d.ts +120 -0
- package/dist/graph/documents/user.js +302 -0
- package/dist/graph/documents/voting-classes.d.ts +69 -0
- package/dist/graph/documents/voting-classes.js +149 -0
- package/dist/graph/documents/voting.d.ts +28 -0
- package/dist/graph/documents/voting.js +163 -0
- package/dist/graph/documents/vouch.d.ts +79 -0
- package/dist/graph/documents/vouch.js +171 -0
- package/dist/graph/documents/zkemail.d.ts +63 -0
- package/dist/graph/documents/zkemail.js +188 -0
- package/dist/graph/index.d.ts +2 -0
- package/dist/graph/index.js +41 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.js +92 -0
- package/dist/ipfs.d.ts +39 -0
- package/dist/ipfs.js +214 -0
- package/dist/label-aliases.d.ts +31 -0
- package/dist/label-aliases.js +70 -0
- package/dist/metadata/education.d.ts +30 -0
- package/dist/metadata/education.js +31 -0
- package/dist/metadata/index.d.ts +12 -0
- package/dist/metadata/index.js +48 -0
- package/dist/metadata/org.d.ts +92 -0
- package/dist/metadata/org.js +93 -0
- package/dist/metadata/proposal.d.ts +44 -0
- package/dist/metadata/proposal.js +45 -0
- package/dist/metadata/role.d.ts +38 -0
- package/dist/metadata/role.js +35 -0
- package/dist/metadata/task.d.ts +108 -0
- package/dist/metadata/task.js +81 -0
- package/dist/metadata/token.d.ts +32 -0
- package/dist/metadata/token.js +36 -0
- package/dist/metadata/user.d.ts +63 -0
- package/dist/metadata/user.js +70 -0
- package/dist/multicall.d.ts +32 -0
- package/dist/multicall.js +96 -0
- package/dist/payout.d.ts +59 -0
- package/dist/payout.js +96 -0
- package/dist/perms.d.ts +47 -0
- package/dist/perms.js +120 -0
- package/dist/preflight.d.ts +72 -0
- package/dist/preflight.js +269 -0
- package/dist/reads/education.d.ts +79 -0
- package/dist/reads/education.js +122 -0
- package/dist/reads/eligibility.d.ts +377 -0
- package/dist/reads/eligibility.js +687 -0
- package/dist/reads/index.d.ts +16 -0
- package/dist/reads/index.js +55 -0
- package/dist/reads/org.d.ts +258 -0
- package/dist/reads/org.js +220 -0
- package/dist/reads/paymaster.d.ts +147 -0
- package/dist/reads/paymaster.js +235 -0
- package/dist/reads/project.d.ts +33 -0
- package/dist/reads/project.js +62 -0
- package/dist/reads/resolve.d.ts +34 -0
- package/dist/reads/resolve.js +64 -0
- package/dist/reads/task.d.ts +286 -0
- package/dist/reads/task.js +280 -0
- package/dist/reads/token.d.ts +94 -0
- package/dist/reads/token.js +75 -0
- package/dist/reads/treasury.d.ts +222 -0
- package/dist/reads/treasury.js +241 -0
- package/dist/reads/user.d.ts +229 -0
- package/dist/reads/user.js +155 -0
- package/dist/reads/vote.d.ts +296 -0
- package/dist/reads/vote.js +392 -0
- package/dist/reads/zkemail.d.ts +117 -0
- package/dist/reads/zkemail.js +217 -0
- package/dist/similarity.d.ts +36 -0
- package/dist/similarity.js +67 -0
- package/dist/sponsorship-config.d.ts +47 -0
- package/dist/sponsorship-config.js +41 -0
- package/dist/stats.d.ts +11 -0
- package/dist/stats.js +27 -0
- package/dist/task-lens.d.ts +113 -0
- package/dist/task-lens.js +210 -0
- package/dist/tx/education.d.ts +169 -0
- package/dist/tx/education.js +344 -0
- package/dist/tx/eligibility.d.ts +511 -0
- package/dist/tx/eligibility.js +818 -0
- package/dist/tx/governance.d.ts +85 -0
- package/dist/tx/governance.js +87 -0
- package/dist/tx/index.d.ts +17 -0
- package/dist/tx/index.js +56 -0
- package/dist/tx/intent.d.ts +67 -0
- package/dist/tx/intent.js +48 -0
- package/dist/tx/org.d.ts +321 -0
- package/dist/tx/org.js +544 -0
- package/dist/tx/paymaster.d.ts +140 -0
- package/dist/tx/paymaster.js +251 -0
- package/dist/tx/project.d.ts +146 -0
- package/dist/tx/project.js +217 -0
- package/dist/tx/task.d.ts +480 -0
- package/dist/tx/task.js +1149 -0
- package/dist/tx/token.d.ts +96 -0
- package/dist/tx/token.js +165 -0
- package/dist/tx/treasury.d.ts +474 -0
- package/dist/tx/treasury.js +911 -0
- package/dist/tx/user.d.ts +177 -0
- package/dist/tx/user.js +292 -0
- package/dist/tx/vote.d.ts +332 -0
- package/dist/tx/vote.js +762 -0
- package/dist/tx/zkemail.d.ts +90 -0
- package/dist/tx/zkemail.js +210 -0
- package/dist/validation.d.ts +8 -0
- package/dist/validation.js +39 -0
- package/dist/version.d.ts +142 -0
- package/dist/version.js +257 -0
- package/dist/zkemail.d.ts +161 -0
- package/dist/zkemail.js +299 -0
- package/package.json +130 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Subgraph Client — tiered transport (browser-pure port of src/lib/subgraph.ts)
|
|
4
|
+
*
|
|
5
|
+
* Two transports can serve the same subgraph:
|
|
6
|
+
*
|
|
7
|
+
* FREE ("Studio") `api.studio.thegraph.com/...` — no key, capped at
|
|
8
|
+
* 3K queries/day (rolling window, see x-ratelimit-reset).
|
|
9
|
+
* PAID ("gateway") `gateway.thegraph.com/api/subgraphs/id/<ID>` — billed
|
|
10
|
+
* against GRAPH_API_KEY, sent as an Authorization header.
|
|
11
|
+
*
|
|
12
|
+
* Routing is by AVAILABILITY, controlled by POP_SUBGRAPH_TIER=free|paid|auto
|
|
13
|
+
* (default `auto`):
|
|
14
|
+
*
|
|
15
|
+
* both free first, switch to paid when free is exhausted, and REMEMBER
|
|
16
|
+
* the switch via the injected TierStateStore so the next process
|
|
17
|
+
* does not repeat the doomed free round-trip
|
|
18
|
+
* paid only straight to the gateway, never probe Studio
|
|
19
|
+
* free only Studio; on exhaustion, fail with an actionable error naming
|
|
20
|
+
* GRAPH_API_KEY and the gateway URL to set
|
|
21
|
+
* neither PRECONDITION error (this chain has no POP subgraph)
|
|
22
|
+
*
|
|
23
|
+
* ## Injection seams (what differs from the CLI original)
|
|
24
|
+
*
|
|
25
|
+
* The logic is identical to the CLI's src/lib/subgraph.ts; only the
|
|
26
|
+
* environment touchpoints are injected:
|
|
27
|
+
*
|
|
28
|
+
* env EnvSource instead of process.env (POP_SUBGRAPH_TIER,
|
|
29
|
+
* GRAPH_API_KEY, POP_<NET>_* endpoint overrides)
|
|
30
|
+
* stateStore TierStateStore instead of ~/.pop/subgraph-tier-state.json —
|
|
31
|
+
* in-memory by default; the CLI injects a file-backed store,
|
|
32
|
+
* a browser host can inject localStorage
|
|
33
|
+
* fetch transport (defaults to globalThis.fetch)
|
|
34
|
+
* onWarn per-chain sweep failures (the CLI injects process.stderr)
|
|
35
|
+
* now clock, for tests
|
|
36
|
+
*
|
|
37
|
+
* The transport is plain fetch rather than graphql-request, but thrown errors
|
|
38
|
+
* carry the same observable shape (`.response.{status,headers,errors,error}`,
|
|
39
|
+
* `GraphQL Error (Code: NNN)` messages for non-JSON bodies) so the error
|
|
40
|
+
* classifiers below behave identically.
|
|
41
|
+
*
|
|
42
|
+
* ## Why exhaustion is persisted
|
|
43
|
+
*
|
|
44
|
+
* The CLI is a fresh process per command. A per-process flag meant that once
|
|
45
|
+
* Studio's daily quota was gone EVERY command burned a doomed Studio
|
|
46
|
+
* round-trip first, and queryWithFieldFallback repeated that once per tier.
|
|
47
|
+
*
|
|
48
|
+
* ## Why the pin is SHORT
|
|
49
|
+
*
|
|
50
|
+
* A burst limit and a spent daily quota look identical from a single 429, so
|
|
51
|
+
* exhaustion is pinned for FREE_BACKOFF_SECONDS (15 min) and no longer — never
|
|
52
|
+
* "until UTC midnight". Worst case we re-probe Studio ~96 times a day instead
|
|
53
|
+
* of thousands of times; best case a transient burst limit does not push a
|
|
54
|
+
* whole day of traffic onto the paid key. A `x-ratelimit-reset` sooner than the
|
|
55
|
+
* backoff shortens the pin but never extends it.
|
|
56
|
+
*
|
|
57
|
+
* The state store is advisory: a corrupt or unreadable one is ignored, never
|
|
58
|
+
* fatal.
|
|
59
|
+
*/
|
|
60
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
61
|
+
exports.GraphClient = exports.GraphRequestError = exports.InMemoryTierStateStore = exports.FREE_BACKOFF_SECONDS = void 0;
|
|
62
|
+
exports.emptyTierState = emptyTierState;
|
|
63
|
+
exports.redactSubgraphUrl = redactSubgraphUrl;
|
|
64
|
+
exports.isQuotaError = isQuotaError;
|
|
65
|
+
exports.isAuthError = isAuthError;
|
|
66
|
+
exports.isUnknownFieldError = isUnknownFieldError;
|
|
67
|
+
const chains_1 = require("../chains");
|
|
68
|
+
const errors_1 = require("../errors");
|
|
69
|
+
const exit_codes_1 = require("../exit-codes");
|
|
70
|
+
const env_1 = require("../env");
|
|
71
|
+
/** How long a free-tier quota/rate error suppresses the free transport. */
|
|
72
|
+
exports.FREE_BACKOFF_SECONDS = 15 * 60;
|
|
73
|
+
function emptyTierState() {
|
|
74
|
+
return { version: 1, chains: {} };
|
|
75
|
+
}
|
|
76
|
+
/** Default store: per-client-instance memory. */
|
|
77
|
+
class InMemoryTierStateStore {
|
|
78
|
+
state;
|
|
79
|
+
load() { return this.state; }
|
|
80
|
+
save(state) { this.state = state; }
|
|
81
|
+
clear() { this.state = undefined; }
|
|
82
|
+
}
|
|
83
|
+
exports.InMemoryTierStateStore = InMemoryTierStateStore;
|
|
84
|
+
class GraphRequestError extends Error {
|
|
85
|
+
response;
|
|
86
|
+
status;
|
|
87
|
+
constructor(message, response) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.name = 'GraphRequestError';
|
|
90
|
+
this.response = response;
|
|
91
|
+
this.status = response.status;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
exports.GraphRequestError = GraphRequestError;
|
|
95
|
+
function redactSubgraphUrl(url) {
|
|
96
|
+
// Mask anything that could be a credential before a URL is printed or put in
|
|
97
|
+
// an error. The modern gateway form carries the key in a header, but the
|
|
98
|
+
// LEGACY form embeds it in the path (`/api/<KEY>/subgraphs/id/<ID>`), and
|
|
99
|
+
// some hosts accept `?api_key=`. Never let either reach stdout.
|
|
100
|
+
if (!url)
|
|
101
|
+
return url;
|
|
102
|
+
return url
|
|
103
|
+
.replace(/(\/api\/)(?!subgraphs\/)[^/?#]+/i, '$1<redacted>')
|
|
104
|
+
.replace(/([?&](?:api[-_]?key|access[-_]?token)=)[^&#]+/gi, '$1<redacted>');
|
|
105
|
+
}
|
|
106
|
+
function isGatewayUrl(url) {
|
|
107
|
+
return /(^|\/\/)([\w-]+\.)?gateway[\w-]*\.thegraph\.com/i.test(url);
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Error classification
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
/**
|
|
113
|
+
* Server-supplied strings only. Deliberately NOT `error.message` — for a
|
|
114
|
+
* transport error that embeds a dump of the whole request, matching on it
|
|
115
|
+
* would let a query that merely CONTAINS "429" or the word "quota" fake a
|
|
116
|
+
* rate limit and burn paid quota.
|
|
117
|
+
*/
|
|
118
|
+
function serverMessages(error) {
|
|
119
|
+
const out = [];
|
|
120
|
+
const res = error?.response;
|
|
121
|
+
if (!res)
|
|
122
|
+
return out;
|
|
123
|
+
if (Array.isArray(res.errors)) {
|
|
124
|
+
for (const e of res.errors)
|
|
125
|
+
if (e?.message)
|
|
126
|
+
out.push(String(e.message));
|
|
127
|
+
}
|
|
128
|
+
if (typeof res.error === 'string')
|
|
129
|
+
out.push(res.error);
|
|
130
|
+
else if (res.error?.message)
|
|
131
|
+
out.push(String(res.error.message));
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function httpStatus(error) {
|
|
135
|
+
const raw = error?.response?.status ?? error?.status;
|
|
136
|
+
const n = Number(raw);
|
|
137
|
+
return Number.isFinite(n) ? n : undefined;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Quota/rate exhaustion, in whatever shape the host actually uses.
|
|
141
|
+
*
|
|
142
|
+
* Confirmed live (2026-07): Studio answers a healthy query HTTP 200 with
|
|
143
|
+
* `x-ratelimit-limit: 3000` / `-remaining` / `-reset`, and the gateway answers
|
|
144
|
+
* an unauthenticated query HTTP **200** with `{"errors":[{"message":"auth
|
|
145
|
+
* error: ..."}]}` — i.e. HTTP status alone is not sufficient in either
|
|
146
|
+
* direction. So: match the status when it is one of the unambiguous
|
|
147
|
+
* exhaustion codes, otherwise match only the SERVER's message text.
|
|
148
|
+
*/
|
|
149
|
+
function isQuotaError(error) {
|
|
150
|
+
const status = httpStatus(error);
|
|
151
|
+
if (status === 429 || status === 402)
|
|
152
|
+
return true;
|
|
153
|
+
const msgs = serverMessages(error);
|
|
154
|
+
const QUOTA_RE = /(rate[\s-]?limit|too many requests|quota|out of credits|payment required|free (?:tier|plan) limit|exceeded[^.]{0,40}(?:limit|budget))/i;
|
|
155
|
+
if (msgs.some(m => QUOTA_RE.test(m)))
|
|
156
|
+
return true;
|
|
157
|
+
// Non-JSON bodies (Studio serves a plain-text 429) become
|
|
158
|
+
// "GraphQL Error (Code: 429): {...}". Anchored so only the code matches.
|
|
159
|
+
if (typeof error?.message === 'string' && /^GraphQL Error \(Code: (?:429|402)\)/.test(error.message))
|
|
160
|
+
return true;
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
/** Missing/invalid/unauthorised API key on the paid transport. */
|
|
164
|
+
function isAuthError(error) {
|
|
165
|
+
if (isQuotaError(error))
|
|
166
|
+
return false;
|
|
167
|
+
const msgs = serverMessages(error);
|
|
168
|
+
const AUTH_RE = /(auth error|api key|authorization|unauthorized|forbidden)/i;
|
|
169
|
+
if (msgs.some(m => AUTH_RE.test(m)))
|
|
170
|
+
return true;
|
|
171
|
+
const status = httpStatus(error);
|
|
172
|
+
return status === 401;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Detect a GraphQL validation error caused by querying a field the
|
|
176
|
+
* deployed schema doesn't have (older subgraph version). Network/HTTP
|
|
177
|
+
* failures deliberately do NOT match — those should propagate.
|
|
178
|
+
*/
|
|
179
|
+
function isUnknownFieldError(error) {
|
|
180
|
+
const messages = [];
|
|
181
|
+
const gqlErrors = error?.response?.errors;
|
|
182
|
+
if (Array.isArray(gqlErrors)) {
|
|
183
|
+
for (const e of gqlErrors) {
|
|
184
|
+
if (e?.extensions?.code === 'GRAPHQL_VALIDATION_FAILED')
|
|
185
|
+
return true;
|
|
186
|
+
if (e?.message)
|
|
187
|
+
messages.push(String(e.message));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (error?.message)
|
|
191
|
+
messages.push(String(error.message));
|
|
192
|
+
return messages.some(m => /cannot query field/i.test(m)
|
|
193
|
+
|| /has no field/i.test(m)
|
|
194
|
+
|| /unknown field/i.test(m)
|
|
195
|
+
|| /unknown argument/i.test(m)
|
|
196
|
+
|| /undefined field/i.test(m));
|
|
197
|
+
}
|
|
198
|
+
/** Case-insensitive header read across Headers instances and plain objects. */
|
|
199
|
+
function readHeader(headers, name) {
|
|
200
|
+
try {
|
|
201
|
+
if (!headers)
|
|
202
|
+
return undefined;
|
|
203
|
+
if (typeof headers.get === 'function')
|
|
204
|
+
return headers.get(name) ?? undefined;
|
|
205
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
206
|
+
if (k.toLowerCase() === name.toLowerCase())
|
|
207
|
+
return String(v);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch { /* headers shape is host-dependent; never let this throw */ }
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
class GraphClient {
|
|
214
|
+
env;
|
|
215
|
+
defaultChainId;
|
|
216
|
+
store;
|
|
217
|
+
providedFetch;
|
|
218
|
+
onWarn;
|
|
219
|
+
nowMs;
|
|
220
|
+
constructor(options = {}) {
|
|
221
|
+
this.env = options.env ?? env_1.EMPTY_ENV;
|
|
222
|
+
this.defaultChainId = options.defaultChainId;
|
|
223
|
+
this.store = options.stateStore ?? new InMemoryTierStateStore();
|
|
224
|
+
this.providedFetch = options.fetch;
|
|
225
|
+
this.onWarn = options.onWarn ?? (() => { });
|
|
226
|
+
this.nowMs = options.now ?? (() => Date.now());
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Resolve the transport LAZILY, per request. Capturing globalThis.fetch at
|
|
230
|
+
* construction would freeze whatever implementation existed then — test
|
|
231
|
+
* stubs and framework fetch-patchers (Next.js, MSW) replace it later.
|
|
232
|
+
* Bind: a bare `fetch` reference loses `this` in browsers ("Illegal
|
|
233
|
+
* invocation").
|
|
234
|
+
*/
|
|
235
|
+
resolveFetch() {
|
|
236
|
+
if (this.providedFetch)
|
|
237
|
+
return this.providedFetch;
|
|
238
|
+
const f = globalThis.fetch;
|
|
239
|
+
return f ? f.bind(globalThis) : undefined;
|
|
240
|
+
}
|
|
241
|
+
// -- state ----------------------------------------------------------------
|
|
242
|
+
nowSeconds() {
|
|
243
|
+
return Math.floor(this.nowMs() / 1000);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Never throws. A missing or hand-mangled state entry is treated as "no
|
|
247
|
+
* knowledge" — the cost is one wasted free-tier probe, which is strictly
|
|
248
|
+
* better than failing a read command over advisory cache state.
|
|
249
|
+
*/
|
|
250
|
+
loadState() {
|
|
251
|
+
try {
|
|
252
|
+
const s = this.store.load();
|
|
253
|
+
if (s && s.version === 1 && s.chains && typeof s.chains === 'object' && !Array.isArray(s.chains)) {
|
|
254
|
+
return s;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch { /* advisory only */ }
|
|
258
|
+
return emptyTierState();
|
|
259
|
+
}
|
|
260
|
+
saveState(state) {
|
|
261
|
+
try {
|
|
262
|
+
this.store.save(state);
|
|
263
|
+
}
|
|
264
|
+
catch { /* advisory only */ }
|
|
265
|
+
}
|
|
266
|
+
/** unix-seconds until which the free transport is considered spent, or undefined. */
|
|
267
|
+
getFreeExhaustedUntil(chainId) {
|
|
268
|
+
const entry = this.loadState().chains[String(chainId)];
|
|
269
|
+
if (!entry || typeof entry.freeExhaustedUntil !== 'number')
|
|
270
|
+
return undefined;
|
|
271
|
+
return entry.freeExhaustedUntil > this.nowSeconds() ? entry.freeExhaustedUntil : undefined;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Record that the free transport is spent. `resetAtSeconds` (from
|
|
275
|
+
* x-ratelimit-reset) may SHORTEN the pin but never extend it past
|
|
276
|
+
* FREE_BACKOFF_SECONDS — see the module header on why the pin stays short.
|
|
277
|
+
*/
|
|
278
|
+
markFreeExhausted(chainId, reason, resetAtSeconds) {
|
|
279
|
+
const now = this.nowSeconds();
|
|
280
|
+
const cap = now + exports.FREE_BACKOFF_SECONDS;
|
|
281
|
+
const until = resetAtSeconds && resetAtSeconds > now ? Math.min(resetAtSeconds, cap) : cap;
|
|
282
|
+
const state = this.loadState();
|
|
283
|
+
state.chains[String(chainId)] = { freeExhaustedUntil: until, recordedAt: now, reason };
|
|
284
|
+
this.saveState(state);
|
|
285
|
+
}
|
|
286
|
+
clearFreeExhausted(chainId) {
|
|
287
|
+
const state = this.loadState();
|
|
288
|
+
if (!state.chains[String(chainId)])
|
|
289
|
+
return; // avoid a pointless store write
|
|
290
|
+
delete state.chains[String(chainId)];
|
|
291
|
+
this.saveState(state);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Proactive exhaustion: Studio tells us how much of the free quota is left on
|
|
295
|
+
* every response, so record "spent" at remaining === 0 rather than waiting for
|
|
296
|
+
* the next command to eat a 429.
|
|
297
|
+
*/
|
|
298
|
+
observeRateLimit(chainId, headers) {
|
|
299
|
+
try {
|
|
300
|
+
const remainingRaw = readHeader(headers, 'x-ratelimit-remaining');
|
|
301
|
+
if (remainingRaw === undefined)
|
|
302
|
+
return;
|
|
303
|
+
const remaining = Number(remainingRaw);
|
|
304
|
+
if (!Number.isFinite(remaining))
|
|
305
|
+
return;
|
|
306
|
+
if (remaining > 0)
|
|
307
|
+
return;
|
|
308
|
+
const resetRaw = readHeader(headers, 'x-ratelimit-reset');
|
|
309
|
+
const resetAt = Number(resetRaw);
|
|
310
|
+
this.markFreeExhausted(chainId, 'x-ratelimit-remaining=0', Number.isFinite(resetAt) ? resetAt : undefined);
|
|
311
|
+
}
|
|
312
|
+
catch { /* observation only */ }
|
|
313
|
+
}
|
|
314
|
+
// -- transport ------------------------------------------------------------
|
|
315
|
+
getApiKey() {
|
|
316
|
+
return (this.env.GRAPH_API_KEY || '').trim();
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* One GraphQL POST. Errors carry `.response.{status,headers,errors,error}`
|
|
320
|
+
* and graphql-request-style messages so the classifiers above keep working.
|
|
321
|
+
*/
|
|
322
|
+
async request(url, gqlQuery, variables, opts) {
|
|
323
|
+
// The Authorization header follows the PLANNED TIER, not hostname sniffing:
|
|
324
|
+
// POP_GRAPH_GATEWAY_URL / POP_<NET>_SUBGRAPH_GATEWAY overrides allow
|
|
325
|
+
// self-hosted and regional gateway hosts that a hostname regex misses —
|
|
326
|
+
// keying on the hostname sent those requests keyless and then blamed the
|
|
327
|
+
// operator's valid key.
|
|
328
|
+
const headers = { 'content-type': 'application/json' };
|
|
329
|
+
const key = this.getApiKey();
|
|
330
|
+
if (opts.paid && key)
|
|
331
|
+
headers['Authorization'] = `Bearer ${key}`;
|
|
332
|
+
const fetchImpl = this.resolveFetch();
|
|
333
|
+
if (!fetchImpl) {
|
|
334
|
+
throw new errors_1.CliError('No fetch implementation available for the subgraph client.', exit_codes_1.EXIT.INFRA, 'Pass GraphClientOptions.fetch (Node <18 needs a fetch polyfill).');
|
|
335
|
+
}
|
|
336
|
+
let response;
|
|
337
|
+
try {
|
|
338
|
+
response = await fetchImpl(url, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers,
|
|
341
|
+
body: JSON.stringify({ query: gqlQuery, variables }),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
// Network-level failure — no response. Propagate with the original
|
|
346
|
+
// message; classifiers treat it as neither quota nor auth.
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
// Studio publishes x-ratelimit-remaining / -reset on every response.
|
|
350
|
+
// Reading them lets us record exhaustion BEFORE a request fails, so the
|
|
351
|
+
// next query skips the free transport instead of learning the hard way.
|
|
352
|
+
if (opts.chainId !== undefined && !opts.paid) {
|
|
353
|
+
this.observeRateLimit(opts.chainId, response.headers);
|
|
354
|
+
}
|
|
355
|
+
const text = await response.text();
|
|
356
|
+
let body;
|
|
357
|
+
try {
|
|
358
|
+
body = text ? JSON.parse(text) : undefined;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Non-JSON body (Studio serves a plain-text 429; CDNs/WAFs serve HTML).
|
|
362
|
+
// Message format is load-bearing: isQuotaError anchors on
|
|
363
|
+
// "GraphQL Error (Code: NNN)". The raw text ALSO travels in
|
|
364
|
+
// response.error — graphql-request put text bodies there, and
|
|
365
|
+
// serverMessages() reads only server-supplied response fields, so
|
|
366
|
+
// without this the QUOTA_RE/AUTH_RE text classification would go dead
|
|
367
|
+
// for plain-text bodies on unusual statuses (a proxy 403 saying
|
|
368
|
+
// "rate limit exceeded" must still pin the free tier).
|
|
369
|
+
throw new GraphRequestError(`GraphQL Error (Code: ${response.status}): ${text.slice(0, 300)}`, { status: response.status, headers: response.headers, error: text.slice(0, 1000) });
|
|
370
|
+
}
|
|
371
|
+
// Mirror graphql-request v6's success gate exactly: an HTTP-ok response
|
|
372
|
+
// is a success ONLY when it has array errors empty/absent AND a truthy
|
|
373
|
+
// `data`. A 200 with `{}`, `{"data":null}`, an empty body, or a non-array
|
|
374
|
+
// `errors` value ("auth error") previously threw ClientError — resolving
|
|
375
|
+
// undefined here instead turned transport failures into "no rows", the
|
|
376
|
+
// exact silent-empty failure ChainQueryResult.error exists to prevent
|
|
377
|
+
// (and cleared still-valid free-tier pins via the success path).
|
|
378
|
+
const gqlErrors = body?.errors;
|
|
379
|
+
const hasGqlErrors = Array.isArray(gqlErrors) ? gqlErrors.length > 0 : gqlErrors != null;
|
|
380
|
+
if (!response.ok || hasGqlErrors || !body?.data) {
|
|
381
|
+
const first = Array.isArray(gqlErrors) && gqlErrors[0]?.message
|
|
382
|
+
? String(gqlErrors[0].message)
|
|
383
|
+
: `GraphQL Error (Code: ${response.status})`;
|
|
384
|
+
throw new GraphRequestError(first, {
|
|
385
|
+
status: response.status,
|
|
386
|
+
headers: response.headers,
|
|
387
|
+
errors: Array.isArray(gqlErrors) ? gqlErrors : undefined,
|
|
388
|
+
error: body?.error ?? (typeof gqlErrors === 'string' ? gqlErrors : undefined),
|
|
389
|
+
data: body?.data,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return body.data;
|
|
393
|
+
}
|
|
394
|
+
// -- planning -------------------------------------------------------------
|
|
395
|
+
resolveMode() {
|
|
396
|
+
const raw = (this.env.POP_SUBGRAPH_TIER || '').trim().toLowerCase();
|
|
397
|
+
if (!raw || raw === 'auto')
|
|
398
|
+
return { mode: 'auto' };
|
|
399
|
+
if (raw === 'free' || raw === 'paid')
|
|
400
|
+
return { mode: raw };
|
|
401
|
+
return { mode: 'auto', ignored: raw };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Work out which transports exist for a chain and in what order to try them.
|
|
405
|
+
* Pure w.r.t. the network: reads env + config + the injected state store only.
|
|
406
|
+
*/
|
|
407
|
+
resolveTransportPlan(chainId) {
|
|
408
|
+
// Per-call chainId wins; the client's default fills the gap. Env
|
|
409
|
+
// (POP_DEFAULT_CHAIN inside resolveNetworkConfig) remains the last resort,
|
|
410
|
+
// preserving the CLI's precedence exactly (its client sets no default).
|
|
411
|
+
const config = (0, chains_1.resolveNetworkConfig)(chainId ?? this.defaultChainId, this.env);
|
|
412
|
+
const effectiveChainId = config.chainId;
|
|
413
|
+
const { mode, ignored } = this.resolveMode();
|
|
414
|
+
const primary = (config.resolvedSubgraph || '').trim();
|
|
415
|
+
const gateway = (0, chains_1.getGatewaySubgraphUrl)(effectiveChainId, this.env);
|
|
416
|
+
// Classify by URL, not by which env var supplied it: an operator who points
|
|
417
|
+
// POP_<NET>_SUBGRAPH straight at the gateway has a paid primary, not a free one.
|
|
418
|
+
const freeUrl = primary && !isGatewayUrl(primary) ? primary : undefined;
|
|
419
|
+
const paidUrl = gateway || (primary && isGatewayUrl(primary) ? primary : undefined);
|
|
420
|
+
const hasApiKey = !!this.getApiKey();
|
|
421
|
+
// A gateway URL without a key is not a transport: it answers HTTP 200 with
|
|
422
|
+
// {"errors":[{"message":"auth error: missing authorization header"}]}.
|
|
423
|
+
const paidUsable = !!paidUrl && hasApiKey;
|
|
424
|
+
const paidKeyMissing = !!paidUrl && !hasApiKey;
|
|
425
|
+
const availability = freeUrl && paidUsable
|
|
426
|
+
? 'both'
|
|
427
|
+
: paidUsable ? 'paid-only'
|
|
428
|
+
: freeUrl ? 'free-only'
|
|
429
|
+
: 'none';
|
|
430
|
+
const freeExhaustedUntil = freeUrl ? this.getFreeExhaustedUntil(effectiveChainId) : undefined;
|
|
431
|
+
const attempts = [];
|
|
432
|
+
let softenedOverride;
|
|
433
|
+
if (mode === 'paid') {
|
|
434
|
+
if (paidUsable) {
|
|
435
|
+
attempts.push({ tier: 'paid', url: paidUrl });
|
|
436
|
+
}
|
|
437
|
+
else if (freeUrl && !paidKeyMissing) {
|
|
438
|
+
// POP_SUBGRAPH_TIER is one global env var but paid transports are
|
|
439
|
+
// per-chain: Arbitrum has no gateway deployment, so honouring the
|
|
440
|
+
// override literally would make the whole chain unreadable while its
|
|
441
|
+
// Studio endpoint is demonstrably alive (queryAllChains then reports
|
|
442
|
+
// "no results" instead of data). Treat the override as prefer-paid:
|
|
443
|
+
// chains without a gateway fall back to free, and the softening is
|
|
444
|
+
// surfaced via modeOverrideIgnored so `pop config show` explains it.
|
|
445
|
+
// A missing API KEY is not softened — that is a misconfiguration the
|
|
446
|
+
// operator asked us to surface, not a per-chain gap.
|
|
447
|
+
attempts.push({ tier: 'free', url: freeUrl });
|
|
448
|
+
softenedOverride = `paid (no gateway configured for ${config.name} — using the free endpoint)`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
else if (mode === 'free') {
|
|
452
|
+
if (freeUrl)
|
|
453
|
+
attempts.push({ tier: 'free', url: freeUrl });
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
// auto
|
|
457
|
+
//
|
|
458
|
+
// A live free-tier pin DEMOTES the free transport, it never drops it. The
|
|
459
|
+
// pin exists to skip a doomed Studio round-trip on the happy path, not to
|
|
460
|
+
// make the command depend solely on the gateway for the whole pin window:
|
|
461
|
+
// a revoked/unbilled GRAPH_API_KEY or a transient gateway outage must still
|
|
462
|
+
// fall through to a healthy Studio endpoint. Keeping free in the plan also
|
|
463
|
+
// keeps the self-healing path reachable — a free success clears a pin that
|
|
464
|
+
// was really just a transient burst limit (see executePlan).
|
|
465
|
+
const demoteFree = !!freeExhaustedUntil && paidUsable;
|
|
466
|
+
if (freeUrl && !demoteFree)
|
|
467
|
+
attempts.push({ tier: 'free', url: freeUrl });
|
|
468
|
+
if (paidUsable)
|
|
469
|
+
attempts.push({ tier: 'paid', url: paidUrl });
|
|
470
|
+
if (freeUrl && demoteFree)
|
|
471
|
+
attempts.push({ tier: 'free', url: freeUrl });
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
chainId: effectiveChainId,
|
|
475
|
+
networkName: config.name,
|
|
476
|
+
mode,
|
|
477
|
+
...(ignored || softenedOverride ? { modeOverrideIgnored: ignored || softenedOverride } : {}),
|
|
478
|
+
availability,
|
|
479
|
+
hasApiKey,
|
|
480
|
+
paidKeyMissing,
|
|
481
|
+
freeUrl,
|
|
482
|
+
paidUrl,
|
|
483
|
+
attempts,
|
|
484
|
+
...(freeExhaustedUntil ? { freeExhaustedUntil } : {}),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
gatewayEnvHint(chainId) {
|
|
488
|
+
const infix = (0, chains_1.getEnvInfixByChainId)(chainId) || 'GNOSIS';
|
|
489
|
+
return `Set GRAPH_API_KEY and POP_${infix}_SUBGRAPH_GATEWAY=<gateway url> (or POP_${infix}_SUBGRAPH_ID=<subgraph id>).`;
|
|
490
|
+
}
|
|
491
|
+
/** The error to raise when a plan has no usable transport at all. */
|
|
492
|
+
planError(plan) {
|
|
493
|
+
const supported = (0, chains_1.getAllSubgraphUrls)().map(n => `${n.name} (${n.chainId})`).join(', ');
|
|
494
|
+
if (plan.mode === 'paid') {
|
|
495
|
+
return new errors_1.CliError(plan.paidKeyMissing
|
|
496
|
+
? `POP_SUBGRAPH_TIER=paid but GRAPH_API_KEY is not set, so the gateway cannot be used.`
|
|
497
|
+
: `POP_SUBGRAPH_TIER=paid but no gateway subgraph is configured for ${plan.networkName} (chain ${plan.chainId}).`, exit_codes_1.EXIT.PRECONDITION, `${this.gatewayEnvHint(plan.chainId)} Or unset POP_SUBGRAPH_TIER to fall back to the free Studio endpoint.`);
|
|
498
|
+
}
|
|
499
|
+
if (plan.mode === 'free' && plan.availability !== 'none') {
|
|
500
|
+
return new errors_1.CliError(`POP_SUBGRAPH_TIER=free but no free (Studio) subgraph is configured for ${plan.networkName} (chain ${plan.chainId}).`, exit_codes_1.EXIT.PRECONDITION, `Unset POP_SUBGRAPH_TIER to use the configured gateway endpoint instead.`);
|
|
501
|
+
}
|
|
502
|
+
if (plan.paidKeyMissing) {
|
|
503
|
+
return new errors_1.CliError(`POP has no free subgraph on ${plan.networkName} (chain ${plan.chainId}) and the configured gateway needs an API key.`, exit_codes_1.EXIT.PRECONDITION, `${this.gatewayEnvHint(plan.chainId)}`);
|
|
504
|
+
}
|
|
505
|
+
// Some configured chains are RPC-only (no POP subgraph deployed, or the
|
|
506
|
+
// deployment was removed). Without this guard the raw transport failure
|
|
507
|
+
// surfaces as an opaque blob containing an internal Graph deployment id and
|
|
508
|
+
// the whole serialized query, with no indication of WHICH chain failed.
|
|
509
|
+
return new errors_1.CliError(`POP has no subgraph on ${plan.networkName} (chain ${plan.chainId}), so this command cannot read org data.`, exit_codes_1.EXIT.PRECONDITION,
|
|
510
|
+
// getEnvInfixByChainId, not toUpperCase(): the camelCase names need the
|
|
511
|
+
// underscore, or the hint names a variable that does not exist
|
|
512
|
+
// (POP_BASESEPOLIA_SUBGRAPH instead of POP_BASE_SEPOLIA_SUBGRAPH).
|
|
513
|
+
`Chains with a subgraph: ${supported}. Override with POP_${(0, chains_1.getEnvInfixByChainId)(plan.chainId)}_SUBGRAPH if you host your own.`);
|
|
514
|
+
}
|
|
515
|
+
/** Free quota gone and there is no paid transport to fall back to. */
|
|
516
|
+
freeExhaustedError(plan, cause, paidFailure) {
|
|
517
|
+
const tail = paidFailure
|
|
518
|
+
? ` and the configured paid gateway also failed (${String(paidFailure?.message || paidFailure).slice(0, 120)}).`
|
|
519
|
+
: plan.paidKeyMissing
|
|
520
|
+
? ` and the configured paid gateway has no API key.`
|
|
521
|
+
: ` and no paid gateway is configured.`;
|
|
522
|
+
const err = new errors_1.CliError(`The free Graph Studio subgraph for ${plan.networkName} (chain ${plan.chainId}) is rate-limited (3K queries/day)`
|
|
523
|
+
+ tail, exit_codes_1.EXIT.INFRA, paidFailure
|
|
524
|
+
? `The gateway failure is likely transient — retry, and check the gateway status if it persists. Studio's quota resets on a rolling 24h window.`
|
|
525
|
+
: plan.paidKeyMissing
|
|
526
|
+
? `Add GRAPH_API_KEY=<your gateway key> to your .env — the gateway URL is already set. Studio's quota resets on a rolling 24h window, so retrying later also works.`
|
|
527
|
+
: `${this.gatewayEnvHint(plan.chainId)} Studio's quota resets on a rolling 24h window, so retrying later also works.`);
|
|
528
|
+
err.cause = cause;
|
|
529
|
+
err.response = cause?.response;
|
|
530
|
+
return err;
|
|
531
|
+
}
|
|
532
|
+
paidAuthError(plan, cause) {
|
|
533
|
+
const err = new errors_1.CliError(`The Graph gateway rejected the API key for ${plan.networkName} (chain ${plan.chainId}).`, exit_codes_1.EXIT.INFRA, `Check GRAPH_API_KEY is a valid gateway key with this subgraph enabled (${redactSubgraphUrl(plan.paidUrl)}).`);
|
|
534
|
+
err.cause = cause;
|
|
535
|
+
err.response = cause?.response;
|
|
536
|
+
return err;
|
|
537
|
+
}
|
|
538
|
+
paidQuotaError(plan, cause) {
|
|
539
|
+
const err = new errors_1.CliError(`The Graph gateway is out of query budget for ${plan.networkName} (chain ${plan.chainId}).`, exit_codes_1.EXIT.INFRA, `Top up the billing balance for GRAPH_API_KEY, or raise its per-query budget in Subgraph Studio.`);
|
|
540
|
+
err.cause = cause;
|
|
541
|
+
err.response = cause?.response;
|
|
542
|
+
return err;
|
|
543
|
+
}
|
|
544
|
+
// -- execution ------------------------------------------------------------
|
|
545
|
+
async executePlan(plan, gqlQuery, variables) {
|
|
546
|
+
if (!plan.attempts.length)
|
|
547
|
+
throw this.planError(plan);
|
|
548
|
+
// When a paid attempt fails and we fall through to a demoted free attempt,
|
|
549
|
+
// remember why: if free then quota-fails, the error must state BOTH facts —
|
|
550
|
+
// "no paid gateway is configured" is a lie when one just returned a 500.
|
|
551
|
+
let paidFailure;
|
|
552
|
+
for (let i = 0; i < plan.attempts.length; i++) {
|
|
553
|
+
const attempt = plan.attempts[i];
|
|
554
|
+
const isLast = i === plan.attempts.length - 1;
|
|
555
|
+
try {
|
|
556
|
+
const data = await this.request(attempt.url, gqlQuery, variables, {
|
|
557
|
+
paid: attempt.tier === 'paid',
|
|
558
|
+
chainId: plan.chainId,
|
|
559
|
+
});
|
|
560
|
+
// A success on free means an earlier pin was a transient burst limit.
|
|
561
|
+
if (attempt.tier === 'free' && plan.freeExhaustedUntil)
|
|
562
|
+
this.clearFreeExhausted(plan.chainId);
|
|
563
|
+
return data;
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
if (attempt.tier === 'free' && isQuotaError(error)) {
|
|
567
|
+
this.markFreeExhausted(plan.chainId, 'quota-error', Number(readHeader(error?.response?.headers, 'x-ratelimit-reset')) || undefined);
|
|
568
|
+
if (!isLast)
|
|
569
|
+
continue; // switch to the paid transport
|
|
570
|
+
throw this.freeExhaustedError(plan, error, paidFailure);
|
|
571
|
+
}
|
|
572
|
+
if (attempt.tier === 'paid') {
|
|
573
|
+
// A broken gateway (revoked key, exhausted budget, DNS/5xx) must not
|
|
574
|
+
// sink the command when a demoted free transport is still queued —
|
|
575
|
+
// that availability guarantee predates the tiering work. Schema and
|
|
576
|
+
// validation errors are excluded: they are a property of the query,
|
|
577
|
+
// not the transport, so retrying elsewhere only wastes a round-trip
|
|
578
|
+
// (queryWithFieldFallback owns that case).
|
|
579
|
+
if (!isLast && !isUnknownFieldError(error)) {
|
|
580
|
+
paidFailure = error;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (isAuthError(error))
|
|
584
|
+
throw this.paidAuthError(plan, error);
|
|
585
|
+
if (isQuotaError(error))
|
|
586
|
+
throw this.paidQuotaError(plan, error);
|
|
587
|
+
}
|
|
588
|
+
// Schema/validation/network errors are NOT a reason to spend paid quota.
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/* istanbul ignore next — loop always returns or throws */
|
|
593
|
+
throw this.planError(plan);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Query a subgraph on the specified chain, routed through the tier plan
|
|
597
|
+
* (see the module header).
|
|
598
|
+
*/
|
|
599
|
+
async query(gqlQuery, variables, chainId) {
|
|
600
|
+
return this.executePlan(this.resolveTransportPlan(chainId), gqlQuery, variables);
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Try query tiers in order, falling through to the next tier when the
|
|
604
|
+
* deployed schema rejects a field (validation error). Any other error
|
|
605
|
+
* (network, HTTP, rate limit without fallback) is rethrown immediately.
|
|
606
|
+
* Returns the first successful result plus the tier index that served it.
|
|
607
|
+
*
|
|
608
|
+
* Each tier re-plans, so once tier 0 has recorded free-tier exhaustion the
|
|
609
|
+
* remaining tiers go straight to the gateway instead of repeating the probe.
|
|
610
|
+
*/
|
|
611
|
+
async queryWithFieldFallback(tiers, opts) {
|
|
612
|
+
if (!tiers.length) {
|
|
613
|
+
throw new Error('queryWithFieldFallback requires at least one query tier');
|
|
614
|
+
}
|
|
615
|
+
let lastValidationError;
|
|
616
|
+
for (let tierIndex = 0; tierIndex < tiers.length; tierIndex++) {
|
|
617
|
+
try {
|
|
618
|
+
const data = await this.query(tiers[tierIndex].query, tiers[tierIndex].variables, opts?.chainId);
|
|
619
|
+
return { data, tierIndex };
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
if (!isUnknownFieldError(error))
|
|
623
|
+
throw error;
|
|
624
|
+
lastValidationError = error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
throw lastValidationError;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Query a specific subgraph URL directly, still tier-aware: if `url` is a free
|
|
631
|
+
* (Studio) endpoint belonging to a known chain and it rate-limits, the request
|
|
632
|
+
* is retried against that chain's gateway rather than simply failing.
|
|
633
|
+
*/
|
|
634
|
+
async queryUrl(url, gqlQuery, variables) {
|
|
635
|
+
const owner = (0, chains_1.getAllSubgraphUrls)().find(n => n.url === url);
|
|
636
|
+
if (owner)
|
|
637
|
+
return this.executePlan(this.resolveTransportPlan(owner.chainId), gqlQuery, variables);
|
|
638
|
+
// Unknown URL (self-hosted, or a one-off endpoint): no chain to plan for, so
|
|
639
|
+
// issue it directly. Hostname sniffing decides whether the API key rides
|
|
640
|
+
// along, as a fallback for direct calls that carry no tier.
|
|
641
|
+
return this.request(url, gqlQuery, variables, { paid: isGatewayUrl(url) });
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Query all non-testnet subgraphs in parallel, each through its own tier plan.
|
|
645
|
+
* Returns results keyed by chainId. Failures are reported per-chain rather
|
|
646
|
+
* than swallowed: see ChainQueryResult.error.
|
|
647
|
+
*/
|
|
648
|
+
async queryAllChains(gqlQuery, variables) {
|
|
649
|
+
const endpoints = (0, chains_1.getAllSubgraphUrls)();
|
|
650
|
+
const results = await Promise.allSettled(endpoints.map(async (ep) => {
|
|
651
|
+
const data = await this.query(gqlQuery, variables, ep.chainId);
|
|
652
|
+
return { chainId: ep.chainId, name: ep.name, data };
|
|
653
|
+
}));
|
|
654
|
+
return results.map((result, i) => {
|
|
655
|
+
if (result.status === 'fulfilled')
|
|
656
|
+
return result.value;
|
|
657
|
+
const reason = result.reason;
|
|
658
|
+
const message = (reason?.message && String(reason.message)) || 'subgraph query failed';
|
|
659
|
+
// Reported, not printed: the CLI wires onWarn to stderr so a human sees
|
|
660
|
+
// it without corrupting --json output. Silent per-chain failure
|
|
661
|
+
// previously rendered as "no orgs found".
|
|
662
|
+
try {
|
|
663
|
+
this.onWarn(`warn: subgraph query failed on ${endpoints[i].name} (chain ${endpoints[i].chainId}): ${message.slice(0, 200)}`);
|
|
664
|
+
}
|
|
665
|
+
catch { /* reporter failed */ }
|
|
666
|
+
return { chainId: endpoints[i].chainId, name: endpoints[i].name, data: null, error: message };
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Describe the transport that will serve the next query on `chainId`.
|
|
671
|
+
* Purely local — issues no network request. URLs are redacted.
|
|
672
|
+
*/
|
|
673
|
+
getTransportStatus(chainId) {
|
|
674
|
+
const plan = this.resolveTransportPlan(chainId);
|
|
675
|
+
const activeTier = plan.attempts[0]?.tier ?? null;
|
|
676
|
+
const parts = [];
|
|
677
|
+
parts.push(activeTier ? `${activeTier} tier active` : 'no transport available');
|
|
678
|
+
parts.push(plan.mode === 'auto' ? `mode auto (${plan.availability})` : `mode ${plan.mode} (forced)`);
|
|
679
|
+
parts.push(plan.hasApiKey ? 'GRAPH_API_KEY detected' : 'no GRAPH_API_KEY');
|
|
680
|
+
if (plan.paidKeyMissing)
|
|
681
|
+
parts.push('gateway configured but unusable without a key');
|
|
682
|
+
if (plan.freeExhaustedUntil) {
|
|
683
|
+
const mins = Math.max(1, Math.ceil((plan.freeExhaustedUntil - this.nowSeconds()) / 60));
|
|
684
|
+
parts.push(`free quota spent, re-probing in ~${mins}m`);
|
|
685
|
+
}
|
|
686
|
+
if (plan.modeOverrideIgnored)
|
|
687
|
+
parts.push(`tier override softened: ${plan.modeOverrideIgnored}`);
|
|
688
|
+
return {
|
|
689
|
+
mode: plan.mode,
|
|
690
|
+
availability: plan.availability,
|
|
691
|
+
activeTier,
|
|
692
|
+
hasApiKey: plan.hasApiKey,
|
|
693
|
+
paidKeyMissing: plan.paidKeyMissing,
|
|
694
|
+
freeUrl: redactSubgraphUrl(plan.freeUrl),
|
|
695
|
+
paidUrl: redactSubgraphUrl(plan.paidUrl),
|
|
696
|
+
...(plan.freeExhaustedUntil ? { freeExhaustedUntil: plan.freeExhaustedUntil } : {}),
|
|
697
|
+
...(plan.modeOverrideIgnored ? { modeOverrideIgnored: plan.modeOverrideIgnored } : {}),
|
|
698
|
+
summary: parts.join('; '),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
exports.GraphClient = GraphClient;
|