Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,9 +555,16 @@ new GraphQLIndexer(endpoint: string)

Throws if `endpoint` is empty.

### `query(options) → Promise<unknown>`
### `query<T = unknown>(options) → Promise<T>`

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<T>()` to strongly type the returned data.

| Field | Type | Required |
|-------|------|----------|
Expand All @@ -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`

Expand Down
26 changes: 24 additions & 2 deletions src/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,29 @@ export class GraphQLIndexer {
this.endpoint = endpoint;
}

async query(options: GraphQLQueryOptions): Promise<unknown> {
/**
* 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 `<T = unknown>` to strongly type the returned data:
* ```typescript
* interface StreamCountResponse {
* streamCount: number;
* }
* const data = await indexer.query<StreamCountResponse>({ 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<T = unknown>(options: GraphQLQueryOptions): Promise<T> {
if (this.isDestroyed) {
throw new Error('GraphQLIndexer has been destroyed');
}
Expand Down Expand Up @@ -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;
}

/**
Expand Down
114 changes: 114 additions & 0 deletions src/tests/graphql-indexer-query-return-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<StreamStats>({
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<string, unknown>).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<AccountData>({
query: 'query GetAccount { account { address balance } }',
persist: true,
});

expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(result).toEqual(accountData);
expect(result.balance).toBe('5000000');
});
});