@kehtus/proportion-sdk 1.2.0 → 1.4.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.
- package/dist/abi/challengeAccount.d.ts +25 -21
- package/dist/abi/challengeAccount.d.ts.map +1 -1
- package/dist/abi/challengeAccount.js +33 -27
- package/dist/abi/challengeAccount.js.map +1 -1
- package/dist/challenge/account.d.ts +6 -2
- package/dist/challenge/account.d.ts.map +1 -1
- package/dist/challenge/account.js +11 -9
- package/dist/challenge/account.js.map +1 -1
- package/dist/challenge/api.d.ts +27 -2
- package/dist/challenge/api.d.ts.map +1 -1
- package/dist/challenge/api.js +93 -1
- package/dist/challenge/api.js.map +1 -1
- package/dist/challenge/factory.d.ts.map +1 -1
- package/dist/challenge/factory.js +9 -2
- package/dist/challenge/factory.js.map +1 -1
- package/dist/challenge/intents.d.ts +25 -3
- package/dist/challenge/intents.d.ts.map +1 -1
- package/dist/challenge/intents.js +39 -7
- package/dist/challenge/intents.js.map +1 -1
- package/dist/challenge/types.d.ts +41 -1
- package/dist/challenge/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/indexer.d.ts +71 -2
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +348 -36
- package/dist/indexer.js.map +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
package/dist/indexer.js
CHANGED
|
@@ -7,11 +7,39 @@ export class IndexerModule {
|
|
|
7
7
|
subscriptions = new Map();
|
|
8
8
|
reconnectTimer = null;
|
|
9
9
|
reconnectDelay = 1000;
|
|
10
|
+
// "Connected" = socket open AND Hasura acked connection_init — only then are
|
|
11
|
+
// subscriptions actually live (graphql-transport-ws forbids subscribe before ack).
|
|
12
|
+
wsConnected = false;
|
|
13
|
+
connStateHandlers = new Set();
|
|
14
|
+
subErrorHandlers = new Set();
|
|
15
|
+
// Liveness watchdog: Hasura pings every few seconds, so a healthy socket is never
|
|
16
|
+
// silent for long. A half-open TCP path (sleep/wake, NAT rebind) keeps readyState
|
|
17
|
+
// OPEN with no close event for minutes — the watchdog declares it dead instead.
|
|
18
|
+
watchdogTimer = null;
|
|
19
|
+
lastInboundAt = 0;
|
|
20
|
+
// Per-op resubscribe after a server-terminated operation (error/complete frame).
|
|
21
|
+
// The socket stays alive in that case, so the reconnect-ack flush alone would
|
|
22
|
+
// never retry the op — these timers do, with backoff, on the SAME connection.
|
|
23
|
+
opRetryTimers = new Map();
|
|
24
|
+
opRetryAttempts = new Map();
|
|
25
|
+
static WS_SILENCE_TIMEOUT_MS = 45_000;
|
|
26
|
+
static WS_WATCHDOG_INTERVAL_MS = 15_000;
|
|
27
|
+
static OP_RETRY_BASE_DELAY_MS = 5_000;
|
|
28
|
+
static OP_RETRY_MAX_DELAY_MS = 60_000;
|
|
10
29
|
cachedProtocol; // undefined = not fetched yet
|
|
11
30
|
constructor(graphqlUrl, graphqlWsUrl, transports = {}) {
|
|
12
31
|
this.graphqlUrl = graphqlUrl;
|
|
13
32
|
this.graphqlWsUrl = graphqlWsUrl;
|
|
14
33
|
this.transports = transports;
|
|
34
|
+
// Derive the subscription URL from the HTTP URL when omitted (http→ws, https→wss) so a
|
|
35
|
+
// missing graphqlWsUrl doesn't silently disable subscriptions; then validate the scheme so
|
|
36
|
+
// a wrong-but-valid URL fails loud instead of looping in silent reconnects while HTTP works.
|
|
37
|
+
if (!this.graphqlWsUrl) {
|
|
38
|
+
this.graphqlWsUrl = this.graphqlUrl.replace(/^http(s?):\/\//i, "ws$1://");
|
|
39
|
+
}
|
|
40
|
+
if (!/^wss?:\/\//i.test(this.graphqlWsUrl)) {
|
|
41
|
+
throw new Error(`graphqlWsUrl must be a ws:// or wss:// URL (got: ${this.graphqlWsUrl})`);
|
|
42
|
+
}
|
|
15
43
|
}
|
|
16
44
|
static PAGE_SIZE = 1000;
|
|
17
45
|
static VAULT_FIELDS = `id address totalDeposits totalWithdrawals totalActivationFees cumulativeActivationFeesToVault totalAllocated totalShares cumulativeProfitShares cumulativeCancelledProfit cumulativeSubscriptionFees cumulativeProtocolFeesRouted cumulativeHypercoreFees cumulativeAccountGains cumulativeAccountLosses cumulativeRescuedFunds totalAccountsActivated totalAccountsActive totalAccountsClosed paused subscriptionFeeMultiplier protocolFeeBps protocolFeeReceiver updatedAt`;
|
|
@@ -24,9 +52,10 @@ export class IndexerModule {
|
|
|
24
52
|
static VAULT_DAY_DATA_FIELDS = `id date dayNumber evmBalance totalAllocated totalAssets totalShares sharePrice dailyDeposits dailyWithdrawals dailyNetLpFlow dailyActivationFees dailyActivationFeesToVault dailyProfitShares dailyCancelledProfit dailySubscriptionFees dailyProtocolFeesRouted dailyHypercoreFees dailyRescuedFunds dailyAccountReturnAmount dailyAccountPrincipalReturned dailyAccountGains dailyAccountLosses dailyPerformancePnl dailyClosedAccountNetPnl cumulativeRealizedPnl totalAccountsActivated totalAccountsActive totalAccountsClosed accountsActivatedToday accountsClosedToday navChange24h sharePriceChange24h return24hBps realizedReturn24hBps paused updatedAt`;
|
|
25
53
|
static LP_DAY_DATA_FIELDS = `id lp { id } date dayNumber shares sharesValue dailyDeposits dailyWithdrawals depositCount withdrawCount`;
|
|
26
54
|
// ── Challenge / B-book field sets (envio-unified schema) ──
|
|
27
|
-
static CHALLENGE_FIELDS = `id trader { id } builder { id address } payer vaultAddress allocation6 initialBalance6 phase failReason currentPhase reduceOnly totalPhases targetProfitBps finalTargetProfitBps drawdownBps dailyDrawdownEnabled tradingDaysEnabled profitableDaysRequired maxLeverageBps maxNotional6 maxOpenPositions minHoldBlocks freeBalance6 realizedPnl6 totalTradingFees6 equity6 highWaterMark6 dayStartEquity6 profitableDaysCount positionCount openPositionCount liquidationCount tradeCount totalVolume6 challengeFee6 builderAmount6 quoteHash quoteNonce quoteDeadline totalPayoutGross6 payoutCount promotedFundedAccount { id } activationFeePaid6 activatedAt challengeExpiry passedAt paperFundedAt failedAt closedAt promotedAt createdAt updatedAt`;
|
|
55
|
+
static CHALLENGE_FIELDS = `id trader { id } builder { id address } payer vaultAddress allocation6 initialBalance6 phase failReason currentPhase reduceOnly totalPhases targetProfitBps finalTargetProfitBps drawdownBps dailyDrawdownEnabled tradingDaysEnabled profitableDaysRequired maxLeverageBps maxNotional6 maxOpenPositions minHoldBlocks freeBalance6 realizedPnl6 totalTradingFees6 equity6 highWaterMark6 dayStartEquity6 profitableDaysCount positionCount openPositionCount liquidationCount tradeCount totalVolume6 challengeFee6 builderAmount6 quoteHash quoteNonce quoteDeadline totalPayoutGross6 payoutCount promotedFundedAccount { id } activationFeePaid6 activatedAt challengeExpiry passedAt paperFundedAt failedAt closedAt promotedAt createdAt updatedAt lastUpdatedBlock`;
|
|
28
56
|
static CHALLENGE_BUILDER_FIELDS = `id address balance6 pendingPayouts6 delinquentSince isDelinquent activeChallengeExposure6 passedExposure6 paperFundedExposure6 queuedPayoutExposure6 pendingPassedAccounts pendingPayoutCount quoteEpoch totalFeesCollected6 totalDeposited6 totalWithdrawn6 totalInstantPaid6 totalQueuedPaid6 totalActivationFeesDebited6 challengesSold challengesPassed challengesPromoted registeredAt updatedAt`;
|
|
29
|
-
static CHALLENGE_POSITION_FIELDS = `id challenge { id } positionId market isLong status entryPrice marginOpen6 notionalOpen6 marginCurrent6 notionalCurrent6 cumulativeReducedNotional6 realizedPnl6 openFee6 closeFee6 exitPrice stopLossPrice takeProfitPrice exitOrderExecutedKind exitOrderTriggerPrice exitOrderRiskPrice exitOrderExecutedAt openedAtBlock openTx closeTx openedAt closedAt updatedAt`;
|
|
57
|
+
static CHALLENGE_POSITION_FIELDS = `id challenge { id } positionId market isLong status entryPrice marginOpen6 notionalOpen6 marginCurrent6 notionalCurrent6 cumulativeReducedNotional6 realizedPnl6 openFee6 closeFee6 exitPrice stopLossPrice takeProfitPrice exitOrderExecutedKind exitOrderTriggerPrice exitOrderRiskPrice exitOrderExecutedAt openedAtBlock openTx closeTx openedAt closedAt updatedAt lastUpdatedBlock`;
|
|
58
|
+
static CHALLENGE_SESSION_KEY_FIELDS = `id challenge { id } key expiry permissions active registeredAt revokedAt updatedAt`;
|
|
30
59
|
static CHALLENGE_TRADE_FIELDS = `id challenge { id } positionId kind market isLong margin6 notional6 price pnl6 fee6 freeBalanceAfter6 blockNumber timestamp txHash`;
|
|
31
60
|
static CHALLENGE_CHECKPOINT_FIELDS = `id challenge { id } kind equity6 highWaterMark6 dayStartEquity6 profitableDaysCount blockNumber timestamp txHash`;
|
|
32
61
|
static CHALLENGE_PAYOUT_FIELDS = `id challenge { id } trader { id } builderAddress requestedGross6 instantPaid6 queuedGross6 queuedPaid6 queueId fullyPaid requestedAt blockNumber txHash`;
|
|
@@ -442,26 +471,31 @@ export class IndexerModule {
|
|
|
442
471
|
return data.AllowedPerp;
|
|
443
472
|
}
|
|
444
473
|
// ── CHALLENGE / B-BOOK (envio-unified) ────────────
|
|
474
|
+
// NB: challenge-family ids (Challenge.id, Trader.id, ChallengeBuilder.id and the
|
|
475
|
+
// FK columns derived from them) are canonically LOWERCASE in envio-unified — so
|
|
476
|
+
// these paths lowercase the argument and filter with `_eq`, which lets Postgres
|
|
477
|
+
// use the FK btree indexes. `_ilike` (still used on funded/checksummed columns)
|
|
478
|
+
// defeats those indexes — fatal for the ~1s Hasura live-query refetch loop.
|
|
445
479
|
async getChallenge(address) {
|
|
446
480
|
const data = await this.query(`
|
|
447
|
-
query($id: String!) { Challenge(where: { id: {
|
|
448
|
-
`, { id: address });
|
|
481
|
+
query($id: String!) { Challenge(where: { id: { _eq: $id } }) { ${IndexerModule.CHALLENGE_FIELDS} } }
|
|
482
|
+
`, { id: address.toLowerCase() });
|
|
449
483
|
return data.Challenge[0] ?? null;
|
|
450
484
|
}
|
|
451
485
|
async getChallengesByTrader(trader, p) {
|
|
452
486
|
const data = await this.query(`
|
|
453
487
|
query($trader: String!, $limit: Int, $offset: Int) {
|
|
454
|
-
Challenge(where: { trader_id: {
|
|
488
|
+
Challenge(where: { trader_id: { _eq: $trader } }, order_by: [{ createdAt: desc }, { id: desc }], limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_FIELDS} }
|
|
455
489
|
}
|
|
456
|
-
`, { trader, limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
490
|
+
`, { trader: trader.toLowerCase(), limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
457
491
|
return data.Challenge;
|
|
458
492
|
}
|
|
459
493
|
async getChallengesByBuilder(builder, p) {
|
|
460
494
|
const data = await this.query(`
|
|
461
495
|
query($builder: String!, $limit: Int, $offset: Int) {
|
|
462
|
-
Challenge(where: { builder_id: {
|
|
496
|
+
Challenge(where: { builder_id: { _eq: $builder } }, order_by: [{ createdAt: desc }, { id: desc }], limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_FIELDS} }
|
|
463
497
|
}
|
|
464
|
-
`, { builder, limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
498
|
+
`, { builder: builder.toLowerCase(), limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
465
499
|
return data.Challenge;
|
|
466
500
|
}
|
|
467
501
|
async getChallengesByPhase(phase, p) {
|
|
@@ -474,35 +508,52 @@ export class IndexerModule {
|
|
|
474
508
|
}
|
|
475
509
|
async getChallengeBuilder(address) {
|
|
476
510
|
const data = await this.query(`
|
|
477
|
-
query($id: String!) { ChallengeBuilder(where: { id: {
|
|
478
|
-
`, { id: address });
|
|
511
|
+
query($id: String!) { ChallengeBuilder(where: { id: { _eq: $id } }) { ${IndexerModule.CHALLENGE_BUILDER_FIELDS} } }
|
|
512
|
+
`, { id: address.toLowerCase() });
|
|
479
513
|
return data.ChallengeBuilder[0] ?? null;
|
|
480
514
|
}
|
|
515
|
+
// positionId tiebreaker: block-second timestamps tie on netting flips (Close+Open
|
|
516
|
+
// in one tx) — without it Postgres may permute equal-key rows between refetches.
|
|
481
517
|
async getChallengePositions(challenge, p) {
|
|
482
518
|
const openFilter = p?.openOnly ? `status: { _eq: Open }` : "";
|
|
483
519
|
const data = await this.query(`
|
|
484
520
|
query($challenge: String!, $limit: Int, $offset: Int) {
|
|
485
|
-
ChallengePosition(where: { challenge_id: {
|
|
521
|
+
ChallengePosition(where: { challenge_id: { _eq: $challenge } ${openFilter} }, order_by: [{ openedAt: desc }, { positionId: desc }], limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_POSITION_FIELDS} }
|
|
486
522
|
}
|
|
487
|
-
`, { challenge, limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
523
|
+
`, { challenge: challenge.toLowerCase(), limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
488
524
|
return data.ChallengePosition;
|
|
489
525
|
}
|
|
490
526
|
async getChallengeTrades(challenge, p) {
|
|
491
527
|
const data = await this.query(`
|
|
492
528
|
query($challenge: String!, $limit: Int, $offset: Int) {
|
|
493
|
-
ChallengeTrade(where: { challenge_id: {
|
|
529
|
+
ChallengeTrade(where: { challenge_id: { _eq: $challenge } }, order_by: [{ timestamp: desc }, { id: desc }], limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_TRADE_FIELDS} }
|
|
494
530
|
}
|
|
495
|
-
`, { challenge, limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
531
|
+
`, { challenge: challenge.toLowerCase(), limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
496
532
|
return data.ChallengeTrade;
|
|
497
533
|
}
|
|
498
534
|
async getChallengeCheckpoints(challenge, p) {
|
|
499
535
|
const data = await this.query(`
|
|
500
536
|
query($challenge: String!, $limit: Int, $offset: Int) {
|
|
501
|
-
ChallengeCheckpoint(where: { challenge_id: {
|
|
537
|
+
ChallengeCheckpoint(where: { challenge_id: { _eq: $challenge } }, order_by: [{ timestamp: desc }, { id: desc }], limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_CHECKPOINT_FIELDS} }
|
|
502
538
|
}
|
|
503
|
-
`, { challenge, limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
539
|
+
`, { challenge: challenge.toLowerCase(), limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
504
540
|
return data.ChallengeCheckpoint;
|
|
505
541
|
}
|
|
542
|
+
/**
|
|
543
|
+
* Session keys registered on a challenge clone. The indexer is the ONLY
|
|
544
|
+
* enumeration source (on-chain `getSessionKey(key)` needs the key upfront);
|
|
545
|
+
* per-key caps/marketMask are NOT in events by contract design — enrich a
|
|
546
|
+
* selected key via RPC `challenge.getSessionKey` when displaying details.
|
|
547
|
+
*/
|
|
548
|
+
async getChallengeSessionKeys(challenge, opts) {
|
|
549
|
+
const activeFilter = opts?.activeOnly ? ` active: { _eq: true }` : "";
|
|
550
|
+
const data = await this.query(`
|
|
551
|
+
query($challenge: String!, $limit: Int, $offset: Int) {
|
|
552
|
+
ChallengeSessionKey(where: { challenge_id: { _eq: $challenge }${activeFilter} }, order_by: [{ registeredAt: desc }, { id: desc }], limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_SESSION_KEY_FIELDS} }
|
|
553
|
+
}
|
|
554
|
+
`, { challenge: challenge.toLowerCase(), limit: opts?.limit ?? 50, offset: opts?.offset ?? 0 });
|
|
555
|
+
return data.ChallengeSessionKey;
|
|
556
|
+
}
|
|
506
557
|
async getChallengePayouts(filter, p) {
|
|
507
558
|
const clauses = [];
|
|
508
559
|
const variableDefs = ["$limit: Int", "$offset: Int"];
|
|
@@ -534,9 +585,9 @@ export class IndexerModule {
|
|
|
534
585
|
async getPayoutQueue(builder, p) {
|
|
535
586
|
const data = await this.query(`
|
|
536
587
|
query($builder: String!, $limit: Int, $offset: Int) {
|
|
537
|
-
ChallengePayoutQueueEntry(where: { builder_id: {
|
|
588
|
+
ChallengePayoutQueueEntry(where: { builder_id: { _eq: $builder } fullyPaid: { _eq: false } }, order_by: { queueId: asc }, limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_QUEUE_ENTRY_FIELDS} }
|
|
538
589
|
}
|
|
539
|
-
`, { builder, limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
590
|
+
`, { builder: builder.toLowerCase(), limit: p?.limit ?? 50, offset: p?.offset ?? 0 });
|
|
540
591
|
return data.ChallengePayoutQueueEntry;
|
|
541
592
|
}
|
|
542
593
|
async getChallengeDayData(p) {
|
|
@@ -622,23 +673,175 @@ export class IndexerModule {
|
|
|
622
673
|
return data.FounderWeeklyBoost;
|
|
623
674
|
}
|
|
624
675
|
// ── SUBSCRIPTIONS (Hasura graphql-transport-ws) ───
|
|
676
|
+
setWsConnected(connected) {
|
|
677
|
+
if (this.wsConnected === connected)
|
|
678
|
+
return;
|
|
679
|
+
this.wsConnected = connected;
|
|
680
|
+
for (const cb of this.connStateHandlers) {
|
|
681
|
+
try {
|
|
682
|
+
cb(connected);
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
// A throwing listener must not break the WS pump or other listeners.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
detachSocket(ws) {
|
|
690
|
+
ws.onopen = null;
|
|
691
|
+
ws.onmessage = null;
|
|
692
|
+
ws.onclose = null;
|
|
693
|
+
ws.onerror = null;
|
|
694
|
+
}
|
|
695
|
+
stopWatchdog() {
|
|
696
|
+
if (this.watchdogTimer) {
|
|
697
|
+
clearInterval(this.watchdogTimer);
|
|
698
|
+
this.watchdogTimer = null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
clearOpRetry(id) {
|
|
702
|
+
const timer = this.opRetryTimers.get(id);
|
|
703
|
+
if (timer) {
|
|
704
|
+
clearTimeout(timer);
|
|
705
|
+
this.opRetryTimers.delete(id);
|
|
706
|
+
}
|
|
707
|
+
this.opRetryAttempts.delete(id);
|
|
708
|
+
}
|
|
709
|
+
clearAllOpRetries() {
|
|
710
|
+
for (const timer of this.opRetryTimers.values())
|
|
711
|
+
clearTimeout(timer);
|
|
712
|
+
this.opRetryTimers.clear();
|
|
713
|
+
this.opRetryAttempts.clear();
|
|
714
|
+
}
|
|
715
|
+
/** Resubscribe a server-terminated op on the live socket, with backoff. */
|
|
716
|
+
scheduleOpRetry(id) {
|
|
717
|
+
if (!this.subscriptions.has(id) || this.opRetryTimers.has(id))
|
|
718
|
+
return;
|
|
719
|
+
const attempts = this.opRetryAttempts.get(id) ?? 0;
|
|
720
|
+
this.opRetryAttempts.set(id, attempts + 1);
|
|
721
|
+
const delay = Math.min(IndexerModule.OP_RETRY_BASE_DELAY_MS * 2 ** attempts, IndexerModule.OP_RETRY_MAX_DELAY_MS);
|
|
722
|
+
const timer = setTimeout(() => {
|
|
723
|
+
this.opRetryTimers.delete(id);
|
|
724
|
+
const sub = this.subscriptions.get(id);
|
|
725
|
+
if (!sub)
|
|
726
|
+
return;
|
|
727
|
+
const WebSocketCtor = (this.transports.webSocketCtor ?? globalThis.WebSocket);
|
|
728
|
+
// Same socket, same id — the server freed the id when it terminated the op.
|
|
729
|
+
// If the socket is down/pre-ack instead, the ack flush re-sends it anyway.
|
|
730
|
+
if (this.wsConnected && this.ws && WebSocketCtor && this.ws.readyState === WebSocketCtor.OPEN) {
|
|
731
|
+
this.ws.send(JSON.stringify({
|
|
732
|
+
id: String(id),
|
|
733
|
+
type: "subscribe",
|
|
734
|
+
payload: { query: sub.query, variables: sub.variables },
|
|
735
|
+
}));
|
|
736
|
+
}
|
|
737
|
+
}, delay);
|
|
738
|
+
this.opRetryTimers.set(id, timer);
|
|
739
|
+
}
|
|
740
|
+
scheduleReconnect() {
|
|
741
|
+
if (this.reconnectTimer)
|
|
742
|
+
clearTimeout(this.reconnectTimer);
|
|
743
|
+
// NB: ±20% reconnect jitter (anti-thundering-herd after a shared CF/Hasura blip) is
|
|
744
|
+
// deliberately NOT added here — it would need a seedable RNG to keep the fake-timer
|
|
745
|
+
// reconnect tests deterministic. The load-bearing fix is resetting the backoff on
|
|
746
|
+
// connection_ack (not socket OPEN), which caps the reconnect storm on its own.
|
|
747
|
+
this.reconnectTimer = setTimeout(() => {
|
|
748
|
+
this.reconnectTimer = null;
|
|
749
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
|
750
|
+
// Nothing to serve and nobody watching → stay closed; the next subscribe()
|
|
751
|
+
// re-opens the socket on demand.
|
|
752
|
+
if (this.subscriptions.size > 0 || this.connStateHandlers.size > 0)
|
|
753
|
+
this.ensureWs();
|
|
754
|
+
}, this.reconnectDelay);
|
|
755
|
+
}
|
|
756
|
+
/** Single exit path of a socket. Identity-guarded: superseded sockets are inert. */
|
|
757
|
+
handleSocketDown(ws, reconnect) {
|
|
758
|
+
if (this.ws !== ws)
|
|
759
|
+
return;
|
|
760
|
+
this.detachSocket(ws);
|
|
761
|
+
this.ws = null;
|
|
762
|
+
this.stopWatchdog();
|
|
763
|
+
this.clearAllOpRetries();
|
|
764
|
+
this.setWsConnected(false);
|
|
765
|
+
if (reconnect)
|
|
766
|
+
this.scheduleReconnect();
|
|
767
|
+
}
|
|
768
|
+
startWatchdog(ws) {
|
|
769
|
+
this.stopWatchdog();
|
|
770
|
+
this.lastInboundAt = Date.now();
|
|
771
|
+
this.watchdogTimer = setInterval(() => {
|
|
772
|
+
if (this.ws !== ws) {
|
|
773
|
+
this.stopWatchdog();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (Date.now() - this.lastInboundAt <= IndexerModule.WS_SILENCE_TIMEOUT_MS)
|
|
777
|
+
return;
|
|
778
|
+
// No inbound frames past the silence budget (Hasura pings every few seconds,
|
|
779
|
+
// and a CONNECTING socket that never opens lands here too): half-open/dead.
|
|
780
|
+
// The close event may never fire on its own — tear down and reconnect now.
|
|
781
|
+
this.handleSocketDown(ws, true);
|
|
782
|
+
try {
|
|
783
|
+
ws.close();
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
// Socket may be beyond closing — state is already torn down above.
|
|
787
|
+
}
|
|
788
|
+
}, IndexerModule.WS_WATCHDOG_INTERVAL_MS);
|
|
789
|
+
}
|
|
625
790
|
ensureWs() {
|
|
626
791
|
const WebSocketCtor = (this.transports.webSocketCtor ?? globalThis.WebSocket);
|
|
627
792
|
if (!WebSocketCtor)
|
|
628
793
|
throw new Error("WebSocket is not available; pass webSocketCtor in SDKConfig");
|
|
629
|
-
|
|
794
|
+
// CONNECTING(0) or OPEN(1) — reuse the in-flight socket instead of racing a
|
|
795
|
+
// second one against it (readyState numeric order is WHATWG-fixed).
|
|
796
|
+
if (this.ws && this.ws.readyState <= WebSocketCtor.OPEN)
|
|
630
797
|
return this.ws;
|
|
631
798
|
if (!this.graphqlWsUrl)
|
|
632
799
|
throw new Error("graphqlWsUrl required for subscriptions");
|
|
800
|
+
if (this.ws) {
|
|
801
|
+
// Superseding a CLOSING/CLOSED socket: detach its handlers so late events on
|
|
802
|
+
// it can't mutate the replacement's state machine (identity guards in the
|
|
803
|
+
// handlers are the backstop for already-queued events).
|
|
804
|
+
this.detachSocket(this.ws);
|
|
805
|
+
this.ws = null;
|
|
806
|
+
}
|
|
633
807
|
const ws = new WebSocketCtor(this.graphqlWsUrl, "graphql-transport-ws");
|
|
634
808
|
this.ws = ws;
|
|
809
|
+
// Not acked on the new socket by definition — subscribe() must defer its frame
|
|
810
|
+
// to the ack flush (guards the pre-ack window against a stale `true`). Emitted
|
|
811
|
+
// AFTER installing the replacement: a reentrant subscribe() from a conn-state
|
|
812
|
+
// listener then reuses this CONNECTING socket instead of racing a second one.
|
|
813
|
+
this.setWsConnected(false);
|
|
814
|
+
this.startWatchdog(ws);
|
|
635
815
|
ws.onopen = () => {
|
|
636
|
-
this.
|
|
816
|
+
if (this.ws !== ws)
|
|
817
|
+
return;
|
|
818
|
+
// NB: backoff is reset on connection_ack, NOT here. Resetting on OPEN would pin an
|
|
819
|
+
// accept-then-drop endpoint (a redeploying/at-capacity Hasura that completes the WS
|
|
820
|
+
// upgrade but closes before acking) at a ~1s reconnect storm with no exponential relief.
|
|
637
821
|
ws.send(JSON.stringify({ type: "connection_init" }));
|
|
638
822
|
};
|
|
639
823
|
ws.onmessage = (event) => {
|
|
640
|
-
|
|
824
|
+
if (this.ws !== ws)
|
|
825
|
+
return;
|
|
826
|
+
this.lastInboundAt = Date.now();
|
|
827
|
+
let msg;
|
|
828
|
+
try {
|
|
829
|
+
msg = JSON.parse(String(event.data));
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
// Non-JSON / binary / truncated frame (e.g. a proxy error page injected into the
|
|
833
|
+
// stream). Ignore rather than throw out of the handler, which would surface as an
|
|
834
|
+
// uncaught exception in a Node consumer and can terminate the process. The
|
|
835
|
+
// lastInboundAt bump above already keeps the watchdog honest.
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
641
838
|
if (msg.type === "connection_ack") {
|
|
839
|
+
// A usable graphql-transport-ws session now exists → reset the reconnect backoff
|
|
840
|
+
// here (not on socket OPEN), so an accept-then-drop endpoint keeps backing off.
|
|
841
|
+
this.reconnectDelay = 1000;
|
|
842
|
+
// Pending per-op retries would double-send on top of this flush (duplicate
|
|
843
|
+
// ids ⇒ server closes with 4409) — the flush IS the retry for all of them.
|
|
844
|
+
this.clearAllOpRetries();
|
|
642
845
|
for (const [id, sub] of this.subscriptions) {
|
|
643
846
|
ws.send(JSON.stringify({
|
|
644
847
|
id: String(id),
|
|
@@ -646,30 +849,67 @@ export class IndexerModule {
|
|
|
646
849
|
payload: { query: sub.query, variables: sub.variables },
|
|
647
850
|
}));
|
|
648
851
|
}
|
|
852
|
+
// Flip AFTER resubscribing: a conn-state listener reacting to `true` may
|
|
853
|
+
// rely on every pre-existing subscription already being live.
|
|
854
|
+
this.setWsConnected(true);
|
|
649
855
|
}
|
|
650
|
-
if (msg.type === "next" && msg.id
|
|
856
|
+
if (msg.type === "next" && msg.id) {
|
|
857
|
+
// Data flows ⇒ the op recovered: reset its retry backoff.
|
|
858
|
+
this.opRetryAttempts.delete(Number(msg.id));
|
|
859
|
+
const data = msg.payload?.data;
|
|
651
860
|
const sub = this.subscriptions.get(Number(msg.id));
|
|
652
|
-
if (sub)
|
|
653
|
-
sub.cb(
|
|
861
|
+
if (sub && data)
|
|
862
|
+
sub.cb(data);
|
|
863
|
+
}
|
|
864
|
+
// Server terminated ONE operation (`error`: invalid/rejected op, e.g. after a
|
|
865
|
+
// Hasura metadata redeploy; `complete`: out-of-band end of stream). The socket
|
|
866
|
+
// stays up, so connection-state fallbacks won't fire — surface it. The entry
|
|
867
|
+
// is kept and retried: with backoff on this same socket (scheduleOpRetry) and
|
|
868
|
+
// by the resubscribe flush on the next reconnect.
|
|
869
|
+
if ((msg.type === "error" || msg.type === "complete") && msg.id) {
|
|
870
|
+
const sub = this.subscriptions.get(Number(msg.id));
|
|
871
|
+
if (sub) {
|
|
872
|
+
const info = {
|
|
873
|
+
query: sub.query,
|
|
874
|
+
variables: sub.variables,
|
|
875
|
+
errors: msg.type === "error" ? msg.payload : undefined,
|
|
876
|
+
};
|
|
877
|
+
for (const cb of this.subErrorHandlers) {
|
|
878
|
+
try {
|
|
879
|
+
cb(info);
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
// Listener errors are the listener's problem.
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
this.scheduleOpRetry(Number(msg.id));
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// graphql-transport-ws keepalive: Hasura pings at the protocol level (JSON
|
|
889
|
+
// frames, not WS opcodes) and the spec requires a pong, else the server may
|
|
890
|
+
// drop the socket as unresponsive.
|
|
891
|
+
if (msg.type === "ping") {
|
|
892
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
654
893
|
}
|
|
655
894
|
};
|
|
656
|
-
ws.onclose = () =>
|
|
657
|
-
this.reconnectTimer = setTimeout(() => {
|
|
658
|
-
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000);
|
|
659
|
-
this.ensureWs();
|
|
660
|
-
}, this.reconnectDelay);
|
|
661
|
-
};
|
|
895
|
+
ws.onclose = () => this.handleSocketDown(ws, true);
|
|
662
896
|
ws.onerror = () => {
|
|
897
|
+
// close() routes through onclose → handleSocketDown; on a superseded socket
|
|
898
|
+
// the handlers are detached, so this is inert.
|
|
663
899
|
ws.close();
|
|
664
900
|
};
|
|
665
901
|
return ws;
|
|
666
902
|
}
|
|
667
903
|
subscribe(query, variables, cb) {
|
|
668
904
|
const id = ++this.wsId;
|
|
669
|
-
|
|
905
|
+
// ensureWs BEFORE registering: in no-WS environments it throws, and a phantom
|
|
906
|
+
// map entry with no returned unsubscriber would leak on every attempt.
|
|
670
907
|
const ws = this.ensureWs();
|
|
908
|
+
this.subscriptions.set(id, { query, variables, cb: cb });
|
|
671
909
|
const WebSocketCtor = (this.transports.webSocketCtor ?? globalThis.WebSocket);
|
|
672
|
-
|
|
910
|
+
// Send only after connection_ack (graphql-transport-ws forbids earlier);
|
|
911
|
+
// pre-ack subscriptions are flushed by the ack handler above.
|
|
912
|
+
if (this.wsConnected && WebSocketCtor && ws.readyState === WebSocketCtor.OPEN) {
|
|
673
913
|
ws.send(JSON.stringify({
|
|
674
914
|
id: String(id),
|
|
675
915
|
type: "subscribe",
|
|
@@ -678,6 +918,7 @@ export class IndexerModule {
|
|
|
678
918
|
}
|
|
679
919
|
return () => {
|
|
680
920
|
this.subscriptions.delete(id);
|
|
921
|
+
this.clearOpRetry(id);
|
|
681
922
|
const WebSocketCtor = (this.transports.webSocketCtor ?? globalThis.WebSocket);
|
|
682
923
|
if (this.ws && WebSocketCtor && this.ws.readyState === WebSocketCtor.OPEN) {
|
|
683
924
|
this.ws.send(JSON.stringify({ id: String(id), type: "complete" }));
|
|
@@ -700,25 +941,96 @@ export class IndexerModule {
|
|
|
700
941
|
subscribeAllowedPerps(cb) {
|
|
701
942
|
return this.subscribe(`subscription { AllowedPerp(where: { allowed: { _eq: true } }) { id vaultAddress perpIndex allowed } }`, undefined, (data) => cb(data.AllowedPerp));
|
|
702
943
|
}
|
|
944
|
+
/** True once the socket is open AND Hasura acked connection_init (subscriptions are live). */
|
|
945
|
+
isWsConnected() {
|
|
946
|
+
return this.wsConnected;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Watch the subscription-socket state. Fires immediately with the current state on
|
|
950
|
+
* register, then on every transition (connection_ack → true, close → false).
|
|
951
|
+
* Reconnect + resubscribe is automatic; consumers typically fall back to HTTP
|
|
952
|
+
* polling while `connected === false` and rely on the first post-reconnect
|
|
953
|
+
* emission of each live query (a full snapshot) to self-heal.
|
|
954
|
+
*/
|
|
955
|
+
onConnectionStateChange(cb) {
|
|
956
|
+
this.connStateHandlers.add(cb);
|
|
957
|
+
try {
|
|
958
|
+
cb(this.wsConnected);
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
// Symmetric with setWsConnected: listener errors are the listener's problem.
|
|
962
|
+
}
|
|
963
|
+
return () => {
|
|
964
|
+
this.connStateHandlers.delete(cb);
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Watch for server-terminated subscription operations (graphql-transport-ws
|
|
969
|
+
* `error` frames or out-of-band `complete`). The socket usually STAYS connected
|
|
970
|
+
* in this case, so poll fallbacks keyed on `onConnectionStateChange` alone never
|
|
971
|
+
* fire — consumers should degrade the affected dataset to polling when this
|
|
972
|
+
* fires. The subscription entry is kept and retried on the next reconnect ack.
|
|
973
|
+
*/
|
|
974
|
+
onSubscriptionError(cb) {
|
|
975
|
+
this.subErrorHandlers.add(cb);
|
|
976
|
+
return () => {
|
|
977
|
+
this.subErrorHandlers.delete(cb);
|
|
978
|
+
};
|
|
979
|
+
}
|
|
703
980
|
subscribeChallenge(address, cb) {
|
|
704
|
-
return this.subscribe(`subscription($id: String!) { Challenge(where: { id: {
|
|
981
|
+
return this.subscribe(`subscription($id: String!) { Challenge(where: { id: { _eq: $id } }) { ${IndexerModule.CHALLENGE_FIELDS} } }`, { id: address.toLowerCase() }, (data) => { if (data.Challenge[0])
|
|
705
982
|
cb(data.Challenge[0]); });
|
|
706
983
|
}
|
|
984
|
+
/**
|
|
985
|
+
* Live query over a challenge's positions: Hasura pushes the FULL current list on
|
|
986
|
+
* every relevant indexed block — replace state, don't merge. Filter/select set
|
|
987
|
+
* mirror `getChallengePositions`, so rows are shape-identical to the poll path.
|
|
988
|
+
*/
|
|
989
|
+
subscribeChallengePositions(challenge, cb, opts) {
|
|
990
|
+
const openFilter = opts?.openOnly ? ` status: { _eq: Open }` : "";
|
|
991
|
+
return this.subscribe(`subscription($challenge: String!) { ChallengePosition(where: { challenge_id: { _eq: $challenge }${openFilter} }, order_by: [{ openedAt: desc }, { positionId: desc }], limit: 100) { ${IndexerModule.CHALLENGE_POSITION_FIELDS} } }`, { challenge: challenge.toLowerCase() }, (data) => cb(data.ChallengePosition));
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Newest trades of a challenge (live query pushing the full `limit` window, newest
|
|
995
|
+
* first — mirrors `getChallengeTrades`). ChallengeTrade is append-only, so a Hasura
|
|
996
|
+
* streaming subscription (`ChallengeTrade_stream`, cursor on timestamp) pushing only
|
|
997
|
+
* deltas is the designated upgrade path once needed (see arch.md).
|
|
998
|
+
*/
|
|
999
|
+
subscribeChallengeTrades(challenge, cb, opts) {
|
|
1000
|
+
return this.subscribe(`subscription($challenge: String!, $limit: Int!) { ChallengeTrade(where: { challenge_id: { _eq: $challenge } }, order_by: [{ timestamp: desc }, { id: desc }], limit: $limit) { ${IndexerModule.CHALLENGE_TRADE_FIELDS} } }`, { challenge: challenge.toLowerCase(), limit: opts?.limit ?? 100 }, (data) => cb(data.ChallengeTrade));
|
|
1001
|
+
}
|
|
1002
|
+
/** Live list of a trader's challenges (newest first) — push variant of `getChallengesByTrader`. */
|
|
1003
|
+
subscribeChallengesByTrader(trader, cb) {
|
|
1004
|
+
return this.subscribe(`subscription($trader: String!) { Challenge(where: { trader_id: { _eq: $trader } }, order_by: [{ createdAt: desc }, { id: desc }], limit: 100) { ${IndexerModule.CHALLENGE_FIELDS} } }`, { trader: trader.toLowerCase() }, (data) => cb(data.Challenge));
|
|
1005
|
+
}
|
|
707
1006
|
/** Live view of a builder's unpaid FIFO payout queue. */
|
|
708
1007
|
subscribePayoutQueue(builder, cb) {
|
|
709
|
-
return this.subscribe(`subscription($builder: String!) { ChallengePayoutQueueEntry(where: { builder_id: {
|
|
1008
|
+
return this.subscribe(`subscription($builder: String!) { ChallengePayoutQueueEntry(where: { builder_id: { _eq: $builder } fullyPaid: { _eq: false } }, order_by: { queueId: asc }) { ${IndexerModule.CHALLENGE_QUEUE_ENTRY_FIELDS} } }`, { builder: builder.toLowerCase() }, (data) => cb(data.ChallengePayoutQueueEntry));
|
|
710
1009
|
}
|
|
711
1010
|
disconnect() {
|
|
712
1011
|
if (this.reconnectTimer) {
|
|
713
1012
|
clearTimeout(this.reconnectTimer);
|
|
714
1013
|
this.reconnectTimer = null;
|
|
715
1014
|
}
|
|
1015
|
+
this.stopWatchdog();
|
|
1016
|
+
this.clearAllOpRetries();
|
|
716
1017
|
this.subscriptions.clear();
|
|
717
1018
|
if (this.ws) {
|
|
718
|
-
|
|
719
|
-
|
|
1019
|
+
const ws = this.ws;
|
|
1020
|
+
// Full detach (not just onclose): no event on this socket may reach the
|
|
1021
|
+
// state machine after disconnect.
|
|
1022
|
+
this.detachSocket(ws);
|
|
720
1023
|
this.ws = null;
|
|
1024
|
+
try {
|
|
1025
|
+
ws.close();
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
// Already dead — nothing to close.
|
|
1029
|
+
}
|
|
721
1030
|
}
|
|
1031
|
+
// Handlers are detached — notify conn-state listeners explicitly. They stay
|
|
1032
|
+
// registered: a later subscribe() re-opens the socket and they keep working.
|
|
1033
|
+
this.setWsConnected(false);
|
|
722
1034
|
}
|
|
723
1035
|
}
|
|
724
1036
|
//# sourceMappingURL=indexer.js.map
|