@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/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: { _ilike: $id } }) { ${IndexerModule.CHALLENGE_FIELDS} } }
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: { _ilike: $trader } }, order_by: { createdAt: desc }, limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_FIELDS} }
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: { _ilike: $builder } }, order_by: { createdAt: desc }, limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_FIELDS} }
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: { _ilike: $id } }) { ${IndexerModule.CHALLENGE_BUILDER_FIELDS} } }
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: { _ilike: $challenge } ${openFilter} }, order_by: { openedAt: desc }, limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_POSITION_FIELDS} }
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: { _ilike: $challenge } }, order_by: { timestamp: desc }, limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_TRADE_FIELDS} }
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: { _ilike: $challenge } }, order_by: { timestamp: desc }, limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_CHECKPOINT_FIELDS} }
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: { _ilike: $builder } fullyPaid: { _eq: false } }, order_by: { queueId: asc }, limit: $limit, offset: $offset) { ${IndexerModule.CHALLENGE_QUEUE_ENTRY_FIELDS} }
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
- if (this.ws && this.ws.readyState === WebSocketCtor.OPEN)
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.reconnectDelay = 1000;
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
- const msg = JSON.parse(String(event.data));
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 && msg.payload?.data) {
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(msg.payload.data);
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
- this.subscriptions.set(id, { query, variables, cb: cb });
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
- if (WebSocketCtor && ws.readyState === WebSocketCtor.OPEN) {
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: { _ilike: $id } }) { ${IndexerModule.CHALLENGE_FIELDS} } }`, { id: address }, (data) => { if (data.Challenge[0])
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: { _ilike: $builder } fullyPaid: { _eq: false } }, order_by: { queueId: asc }) { ${IndexerModule.CHALLENGE_QUEUE_ENTRY_FIELDS} } }`, { builder }, (data) => cb(data.ChallengePayoutQueueEntry));
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
- this.ws.onclose = null;
719
- this.ws.close();
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