From 6d2c18822241d9c7c416f561f7ae340737ba063f Mon Sep 17 00:00:00 2001 From: ranjeet2063 Date: Sun, 6 Sep 2026 02:57:12 +0545 Subject: [PATCH] feat(indexer): document GraphQLIndexer.query return contract and add generic return typing (closes #599) --- docs/api.md | 15 ++- src/indexer.ts | 26 +++- ...phql-indexer-query-return-contract.test.ts | 114 ++++++++++++++++++ 3 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 src/tests/graphql-indexer-query-return-contract.test.ts diff --git a/docs/api.md b/docs/api.md index fe65ed3..b6b7d67 100644 --- a/docs/api.md +++ b/docs/api.md @@ -555,9 +555,16 @@ new GraphQLIndexer(endpoint: string) Throws if `endpoint` is empty. -### `query(options) → Promise` +### `query(options) → Promise` -Issues a single GraphQL query as an HTTP POST and returns the parsed JSON response. +Issues a single GraphQL query as an HTTP POST and returns the unwrapped `data` payload. + +> [!NOTE] +> **Return Contract (Unwrapped Data)**: Unlike standard GraphQL clients that return the raw +> `{ data, errors }` envelope, `query()` automatically unpacks the response and returns `body.data` +> directly. If the GraphQL endpoint returns any errors in `errors[]`, `query()` throws a +> `ConduitError` containing the aggregated error messages. Pass a type parameter +> `query()` to strongly type the returned data. | Field | Type | Required | |-------|------|----------| @@ -574,8 +581,8 @@ timed out). Pass `timeoutMs: 0`/`Infinity` to disable the SDK timeout. A caller- aborts the in-flight request and rejects with the underlying `AbortError` — use it to cancel on unmount or navigation. -**Throws** if `query` is empty, if the HTTP response is not `ok`, or with an -`IndexerTimeoutError` when the request exceeds `timeoutMs`. +**Throws** if `query` is empty, if the HTTP response is not `ok`, if GraphQL errors are returned +(as a `ConduitError`), or with an `IndexerTimeoutError` when the request exceeds `timeoutMs`. ### `subscribe(options) → IndexerSubscription` diff --git a/src/indexer.ts b/src/indexer.ts index 1a451ab..6a8669c 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -122,7 +122,29 @@ export class GraphQLIndexer { this.endpoint = endpoint; } - async query(options: GraphQLQueryOptions): Promise { + /** + * Issues a single GraphQL query as an HTTP POST and returns the unwrapped data payload. + * + * **Return Contract (Unwrapped Data)**: + * Unlike raw GraphQL HTTP clients that return the `{ data, errors }` envelope, + * `GraphQLIndexer.query()` automatically unwraps the payload and returns `body.data` + * directly. If the GraphQL endpoint returns any errors in `errors[]`, `query()` + * aggregates them and throws a {@link ConduitError}. + * + * Supply the generic type parameter `` to strongly type the returned data: + * ```typescript + * interface StreamCountResponse { + * streamCount: number; + * } + * const data = await indexer.query({ query: 'query { streamCount }' }); + * console.log(data.streamCount); + * ``` + * + * @template T The expected type of the unwrapped `data` payload (defaults to `unknown`). + * @param options Query configuration including query string, variables, headers, timeoutMs, and signal. + * @returns The unwrapped `body.data` payload typed as `T`. + */ + async query(options: GraphQLQueryOptions): Promise { if (this.isDestroyed) { throw new Error('GraphQLIndexer has been destroyed'); } @@ -188,7 +210,7 @@ export class GraphQLIndexer { throw new ConduitError('stream', UNKNOWN_CONTRACT_ERROR_CODE, messages.join('; ')); } - return body?.data; + return (body?.data) as T; } /** diff --git a/src/tests/graphql-indexer-query-return-contract.test.ts b/src/tests/graphql-indexer-query-return-contract.test.ts new file mode 100644 index 0000000..036d034 --- /dev/null +++ b/src/tests/graphql-indexer-query-return-contract.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GraphQLIndexer } from '../indexer.js'; +import { ConduitError } from '../errors.js'; + +describe('GraphQLIndexer.query() — unwrapped return contract (#599)', () => { + const endpoint = 'https://indexer.example.com/graphql'; + let indexer: GraphQLIndexer; + let originalFetch: typeof fetch; + + beforeEach(() => { + indexer = new GraphQLIndexer(endpoint); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + indexer.cleanup(); + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('unwraps body.data directly and supports generic type parameters', async () => { + interface StreamStats { + streamCount: number; + activeStreamIds: string[]; + } + + const payload: StreamStats = { + streamCount: 42, + activeStreamIds: ['stream-1', 'stream-2'], + }; + + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: payload }), + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await indexer.query({ + query: 'query GetStats { streamCount activeStreamIds }', + }); + + // The return contract guarantees body.data is directly returned, NOT { data: ... } + expect(result).toEqual(payload); + expect(result.streamCount).toBe(42); + expect(result.activeStreamIds).toEqual(['stream-1', 'stream-2']); + expect((result as unknown as Record).data).toBeUndefined(); + }); + + it('returns null when endpoint returns { data: null }', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: null }), + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await indexer.query({ + query: 'query NonExistent { stream(id: "none") { id } }', + }); + + expect(result).toBeNull(); + }); + + it('throws ConduitError when endpoint returns errors in response body', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + errors: [{ message: 'Field "unknownField" does not exist on type "Query"' }], + }), + }); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + await expect( + indexer.query({ query: 'query Invalid { unknownField }' }), + ).rejects.toThrow(ConduitError); + + await expect( + indexer.query({ query: 'query Invalid { unknownField }' }), + ).rejects.toThrow('Field "unknownField" does not exist on type "Query"'); + }); + + it('correctly unwraps data payload during APQ hash miss retry', async () => { + interface AccountData { + address: string; + balance: string; + } + + const accountData: AccountData = { + address: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + balance: '5000000', + }; + + const fetchSpy = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errors: [{ message: 'PERSISTED_QUERY_NOT_FOUND' }] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: accountData }), + }); + + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const result = await indexer.query({ + query: 'query GetAccount { account { address balance } }', + persist: true, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(result).toEqual(accountData); + expect(result.balance).toBe('5000000'); + }); +});