From f2e5cbd5dcf199f2f2a412a2116819446175652f Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:19:58 +0800 Subject: [PATCH 1/6] feat(errors): typed error hierarchy + isConduitError guard (#624) --- src/errors.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/errors.ts b/src/errors.ts index 33ad0ca..3f7979a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -161,6 +161,16 @@ export class UnsupportedChainError extends Error { } } +/** + * The root error for any failure that originated from a Conduit smart + * contract. The `contract` + `code` pair uniquely identifies the failure, + * and `isKnown` tells you whether the SDK recognises the code in its + * catalogue for that contract. + * + * Use {@link isConduitError} when you need a type guard instead of an + * `instanceof` check (e.g. across bundle boundaries or when the error may + * have been serialized). + */ export class ConduitError extends Error { readonly contract: ConduitContract; readonly code: number; @@ -447,3 +457,66 @@ export class IndexerTimeoutError extends Error { Object.setPrototypeOf(this, new.target.prototype); } } + +// ── Operation aborted ───────────────────────────────────────────────────────── + +/** + * Thrown when an in-flight SDK operation is cancelled by the caller through + * an `AbortSignal`. This gives consumers a typed, SDK-native way to + * distinguish "the user cancelled" from network, contract, or indexer errors, + * instead of relying on the generic DOM `AbortError`. + * + * @example + * ```ts + * const controller = new AbortController(); + * const promise = client.streams.create({ ... }, { signal: controller.signal }); + * controller.abort(); + * try { await promise; } catch (err) { + * if (err instanceof OperationAbortedError) { + console.log('Cancelled by user:', err.operation); + } + } + ``` + */ +export class OperationAbortedError extends Error { + /** Human-readable name of the operation that was aborted (e.g. "submitBatch"). */ + readonly operation: string; + + constructor(operation: string) { + super(`Operation aborted: ${operation}`); + this.name = 'OperationAbortedError'; + this.operation = operation; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// ── Type guard ──────────────────────────────────────────────────────────────── + +/** + * Returns `true` when `value` is a Conduit SDK error instance. Useful in + * catch blocks, logging, and transport boundaries where you cannot rely on + * `instanceof` across realms or bundled chunks. + * + * Recognised error classes: + * - {@link ConduitError} + * - {@link UnsupportedChainError} + * - {@link StreamFiNetworkError} + * - {@link InsufficientBalanceError} + * - {@link RateLimitError} + * - {@link RpcServiceUnavailableError} + * - {@link IndexerTimeoutError} + * - {@link OperationAbortedError} + */ +export function isConduitError(value: unknown): value is Error { + if (!(value instanceof Error)) return false; + return [ + 'ConduitError', + 'UnsupportedChainError', + 'StreamFiNetworkError', + 'InsufficientBalanceError', + 'RateLimitError', + 'RpcServiceUnavailableError', + 'IndexerTimeoutError', + 'OperationAbortedError', + ].includes(value.name); +} From f6140ea0cf12bfbb00e15b221cd0ba5cedde0f4b Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:19:59 +0800 Subject: [PATCH 2/6] feat(errors): typed error hierarchy + isConduitError guard (#624) --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 0600c69..fd96b74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,8 @@ export { RateLimitError, RpcServiceUnavailableError, IndexerTimeoutError, + OperationAbortedError, + isConduitError, SUPPORTED_NETWORKS, CAIP2_TO_NETWORK, UNKNOWN_CONTRACT_ERROR_CODE, From 704a1358a456c5b81b1c510f85cccba70a940a87 Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:20:00 +0800 Subject: [PATCH 3/6] feat(errors): typed error hierarchy + isConduitError guard (#624) --- src/builder.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/builder.ts b/src/builder.ts index d1f2c02..08f4948 100644 --- a/src/builder.ts +++ b/src/builder.ts @@ -7,6 +7,7 @@ import { paramToScVal, validateContext, } from './batch-tx.js'; +import { OperationAbortedError } from './errors.js'; import type { BatchTransactionContext, BuiltBatchTransaction, ScValType } from './batch-tx.js'; export interface SubmitOptions { @@ -312,7 +313,7 @@ export class StreamBuilder { const { signal } = options; if (signal?.aborted) { - throw new DOMException('Aborted', 'AbortError'); + throw new OperationAbortedError('submit'); } // Backpressure: reject if queue is full @@ -340,7 +341,7 @@ export class StreamBuilder { } if (signal?.aborted) { - throw new DOMException('Aborted', 'AbortError'); + throw new OperationAbortedError('submit'); } try { @@ -352,7 +353,7 @@ export class StreamBuilder { return result; } catch (err) { if (signal?.aborted) { - throw new DOMException('Aborted', 'AbortError'); + throw new OperationAbortedError('submit'); } lastError = err; attempt++; @@ -371,7 +372,7 @@ export class StreamBuilder { clearTimeout(timer); this.activeTimers.delete(timer); signal.removeEventListener('abort', onAbort); - reject(new DOMException('Aborted', 'AbortError')); + reject(new OperationAbortedError('submit')); }; signal.addEventListener('abort', onAbort, { once: true }); } From e99eca018b1e03064d79372d2ae1ad23fd919f45 Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:20:02 +0800 Subject: [PATCH 4/6] feat(errors): typed error hierarchy + isConduitError guard (#624) --- src/indexer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/indexer.ts b/src/indexer.ts index bf87424..3311f0f 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -1,5 +1,5 @@ import { ConduitError, UNKNOWN_CONTRACT_ERROR_CODE } from './errors.js'; -import { IndexerTimeoutError } from './errors.js'; +import { IndexerTimeoutError, OperationAbortedError } from './errors.js'; export interface GraphQLQueryOptions { query: string; @@ -160,7 +160,7 @@ export class GraphQLIndexer { // If the caller already aborted, fail fast without issuing the request. if (callerSignal?.aborted) { - throw new DOMException('The operation was aborted.', 'AbortError'); + throw new OperationAbortedError('GraphQLIndexer.query'); } const controller = new AbortController(); From 29a510c52c2a1b1dd2a9abdf5b64ce57b3636a63 Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:26:24 +0800 Subject: [PATCH 5/6] feat(indexer): Automatic Persisted Queries support (#629) --- src/indexer.ts | 105 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/src/indexer.ts b/src/indexer.ts index 3311f0f..62f09fa 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -20,6 +20,14 @@ export interface GraphQLQueryOptions { * `Infinity` to disable the SDK timeout entirely. */ timeoutMs?: number; + /** + * Whether to use Automatic Persisted Queries (APQ). When `true` (default), + * the client first sends a SHA-256 hash of the query and only falls back + * to sending the full query string if the server reports + * `PERSISTED_QUERY_NOT_FOUND`. Set to `false` to always send the full + * query, e.g. for one-off queries the server has never seen. + */ + persist?: boolean; } export interface GraphQLSubscriptionOptions { @@ -44,6 +52,17 @@ export interface IndexerSubscription { * hands back `any`, which would silently defeat `noImplicitAny` for every * property read below. */ +/** + * APQ extension payload sent in place of the full query string when the + * indexer supports Automatic Persisted Queries. + */ +interface PersistedQueryExtensions { + persistedQuery: { + version: 1; + sha256Hash: string; + }; +} + interface GraphQLServerMessage { type?: unknown; payload?: unknown; @@ -57,6 +76,37 @@ interface GraphQLServerMessage { * `dashboard/transaction-history.ts` and `examples/dashboard/.../apollo-client.ts`). */ export const DEFAULT_INDEXER_TIMEOUT_MS = 15_000; +/** + * Computes the SHA-256 hex digest of `input` using the Web Crypto API, + * falling back to Node's `crypto` module when `crypto.subtle` is not + * available. Required for APQ hash generation. + */ +async function sha256hex(input: string): Promise { + const data = new TextEncoder().encode(input); + if (typeof crypto !== 'undefined' && crypto.subtle) { + const buffer = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } + // Node.js < 19 fallback: the dynamic import is only evaluated when the + // Web Crypto API is absent, so bundlers targeting modern browsers can elide it. + const { createHash } = await import('node:crypto'); + return createHash('sha256').update(input).digest('hex'); +} + +function isPersistedQueryNotFound(body: unknown): boolean { + if (!body || typeof body !== 'object') return false; + const errors = (body as { errors?: unknown }).errors; + if (!Array.isArray(errors)) return false; + return errors.some( + (error) => + error && + typeof error === 'object' && + ((error as { message?: string }).message ?? '').includes('PERSISTED_QUERY_NOT_FOUND'), + ); +} + export class GraphQLIndexer { private endpoint: string; @@ -98,24 +148,31 @@ export class GraphQLIndexer { throw new Error('Fetch API is not available in the current environment'); } - const response = await this.fetchWithTimeout( + const persist = options.persist !== false; + const extensions: PersistedQueryExtensions | undefined = persist + ? { persistedQuery: { version: 1, sha256Hash: await sha256hex(options.query) } } + : undefined; + + let body = await this.executeGraphQLRequest( fetchFn, - this.endpoint, headers, - JSON.stringify({ query: options.query, variables }), + { query: persist ? null : options.query, variables, extensions }, options.timeoutMs, options.signal, ); - if (!response.ok) { - throw new Error(`GraphQL query failed with status ${response.status}: ${response.statusText}`); + // APQ fallback: the server has not seen this query hash yet, so replay + // with the full query string plus the hash so the server can cache it. + if (persist && isPersistedQueryNotFound(body)) { + body = await this.executeGraphQLRequest( + fetchFn, + headers, + { query: options.query, variables, extensions }, + options.timeoutMs, + options.signal, + ); } - const body = (await response.json()) as { - data?: unknown; - errors?: unknown[]; - }; - if (Array.isArray(body?.errors) && body.errors.length > 0) { const messages = body.errors .map((error) => { @@ -134,6 +191,34 @@ export class GraphQLIndexer { } /** + * Issues a single GraphQL POST and parses the JSON response. Extracted so + * APQ can retry with the full query string without duplicating fetch, + * timeout, and error-handling logic. + */ + private async executeGraphQLRequest( + fetchFn: typeof fetch, + headers: Record, + payload: { query: string | null; variables: Record; extensions?: PersistedQueryExtensions }, + timeoutMs: number | undefined, + callerSignal?: AbortSignal, + ): Promise<{ data?: unknown; errors?: unknown[] }> { + const response = await this.fetchWithTimeout( + fetchFn, + this.endpoint, + headers, + JSON.stringify(payload), + timeoutMs, + callerSignal, + ); + + if (!response.ok) { + throw new Error(`GraphQL query failed with status ${response.status}: ${response.statusText}`); + } + + return (await response.json()) as { data?: unknown; errors?: unknown[] }; + } + + /** * Runs a single GraphQL POST against the indexer, combining the SDK's * default timeout (or the caller's `timeoutMs`) with any caller-supplied * `AbortSignal`. If the request does not complete within the time window From 0692f68c167885858d5038d7c63aaf79b1e31ee3 Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:26:25 +0800 Subject: [PATCH 6/6] test(indexer): APQ request shapes and fallback (#629) --- src/tests/indexer.test.ts | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/tests/indexer.test.ts diff --git a/src/tests/indexer.test.ts b/src/tests/indexer.test.ts new file mode 100644 index 0000000..c43bd55 --- /dev/null +++ b/src/tests/indexer.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GraphQLIndexer } from '../indexer.js'; + +describe('GraphQLIndexer APQ', () => { + const endpoint = 'https://indexer.example/graphql'; + let indexer: GraphQLIndexer; + + beforeEach(() => { + indexer = new GraphQLIndexer(endpoint); + }); + + afterEach(() => { + indexer.cleanup(); + }); + + it('sends a persisted query hash on the first request when persist is enabled', async () => { + const fetchFn = vi.fn(); + fetchFn.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { streamCount: 5 } }), + }); + + // Expose the private executeGraphQLRequest by calling query and stubbing fetch globally. + globalThis.fetch = fetchFn as unknown as typeof fetch; + const result = await indexer.query({ query: 'query { streamCount }' }); + + expect(result).toEqual({ streamCount: 5 }); + expect(fetchFn).toHaveBeenCalledTimes(1); + const body = JSON.parse(fetchFn.mock.calls[0][1].body); + expect(body.query).toBeNull(); + expect(body.extensions).toMatchObject({ + persistedQuery: { version: 1, sha256Hash: expect.stringMatching(/^[a-f0-9]{64}$/) }, + }); + }); + + it('falls back to the full query when the server reports PERSISTED_QUERY_NOT_FOUND', async () => { + const fetchFn = vi.fn(); + fetchFn.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errors: [{ message: 'PERSISTED_QUERY_NOT_FOUND' }], + }), + }); + fetchFn.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { streamCount: 3 } }), + }); + + globalThis.fetch = fetchFn as unknown as typeof fetch; + const result = await indexer.query({ query: 'query { streamCount }' }); + + expect(result).toEqual({ streamCount: 3 }); + expect(fetchFn).toHaveBeenCalledTimes(2); + const fallbackBody = JSON.parse(fetchFn.mock.calls[1][1].body); + expect(fallbackBody.query).toBe('query { streamCount }'); + expect(fallbackBody.extensions).toBeDefined(); + }); + + it('skips APQ when persist is false', async () => { + const fetchFn = vi.fn(); + fetchFn.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { streamCount: 1 } }), + }); + + globalThis.fetch = fetchFn as unknown as typeof fetch; + await indexer.query({ query: 'query { streamCount }', persist: false }); + + const body = JSON.parse(fetchFn.mock.calls[0][1].body); + expect(body.query).toBe('query { streamCount }'); + expect(body.extensions).toBeUndefined(); + }); +});