Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/topicquery-adapter-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"fetchium": patch
---

`TopicQuery` subclasses now inherit their adapter from the base, and `QueryClient.getAdapter()` resolves an abstract adapter class on a query to a consumer-registered concrete subclass. Generated and hand-authored `TopicQuery` classes no longer need a per-class `static adapter` override. In dev, ambiguous registrations (more than one adapter that matches the same lookup) throw with a clear error.
16 changes: 7 additions & 9 deletions docs/src/app/core/streaming/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ A topic query extends `TopicQuery` and provides a `topic` field and a `result` s
import { t } from 'fetchium';
import { TopicQuery } from 'fetchium/topic';

class GetPrices extends MyTopicQuery {
class GetPrices extends TopicQuery {
topic = 'prices:live';

result = {
Expand All @@ -151,7 +151,7 @@ class GetPrices extends MyTopicQuery {
Topics can be parameterized using `this.params`, just like paths in `RESTQuery`:

```tsx
class GetBalances extends MyTopicQuery {
class GetBalances extends TopicQuery {
params = { walletId: t.string };

topic = `balances:${this.params.walletId}`;
Expand Down Expand Up @@ -231,13 +231,11 @@ const queryClient = new QueryClient({
});
```

Then make your topic query classes reference the adapter:
Topic query classes that extend `TopicQuery` directly resolve to the registered `MyStreamAdapter` automatically. Internally, `TopicQuery` declares `static adapter = TopicQueryAdapter` (the abstract base), and `QueryClient` looks up registered adapters by `instanceof` match, so any subclass of `TopicQueryAdapter` you register fulfills the lookup.

```tsx
abstract class MyTopicQuery extends TopicQuery {
static override adapter = MyStreamAdapter;
}
```
{% callout title="One streaming adapter per QueryClient" %}
Register at most one `TopicQueryAdapter` subclass on a given `QueryClient`. If your app needs multiple streaming protocols, create a separate `QueryClient` for each. Dev builds throw if more than one registered adapter satisfies the same lookup.
{% /callout %}

### Pre-fulfillment

Expand Down Expand Up @@ -515,7 +513,7 @@ In practice, most applications combine multiple real-time strategies:

```tsx
// Topic-based streaming for live market data
class GetPrices extends MyTopicQuery {
class GetPrices extends TopicQuery {
topic = 'prices:live';
result = { prices: t.liveArray(Price) };
}
Expand Down
62 changes: 46 additions & 16 deletions packages/fetchium/src/QueryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,26 +124,56 @@ export class QueryClient {

/**
* Returns the registered adapter instance for the given adapter class.
* Throws if no adapter of that class has been registered.
*
* Resolution order:
* 1. Exact class match in the registered adapters.
* 2. Subclass match — if any registered adapter is an `instanceof adapterClass`,
* return it. This lets queries declare an abstract base adapter (e.g.
* `TopicQueryAdapter`) and have the consumer-supplied concrete subclass
* (e.g. a `WebSocket`-backed adapter) resolve to it.
* 3. Auto-instantiate via the no-arg constructor (for adapters like
* `RESTQueryAdapter` that default to `globalThis.fetch`).
*
* In dev builds, step 2 verifies that at most one registered adapter
* matches the lookup and throws otherwise. The dev-only check is stripped
* from production builds.
*
* Throws if none of those succeed.
*/
getAdapter(adapterClass: QueryAdapterClass): QueryAdapter {
let adapter = this.adapters.get(adapterClass);
if (!adapter) {
// Auto-instantiate with no-arg constructor as fallback.
// Works for adapters like RESTQueryAdapter that default to globalThis.fetch.
// Adapters that require explicit configuration will throw here, prompting
// the user to register an instance explicitly.
try {
adapter = new (adapterClass as new () => QueryAdapter)();
} catch {
throw new Error(
`No adapter registered for ${adapterClass.name} and auto-instantiation failed. ` +
`Pass an instance via QueryClient config: new QueryClient({ store, adapters: [new ${adapterClass.name}(...)] })`,
);
const exact = this.adapters.get(adapterClass);
if (exact) return exact;

let match: QueryAdapter | undefined;
for (const registered of this.adapters.values()) {
if (registered instanceof adapterClass) {
if (IS_DEV && match !== undefined) {
throw new Error(
`Adapter lookup for ${adapterClass.name} matches multiple registered adapters: ` +
`${match.constructor.name} and ${registered.constructor.name}. ` +
`Register only one adapter per lookup base on a single QueryClient, ` +
`or split into separate QueryClients.`,
);
}
match ??= registered;
}
this.adapters.set(adapterClass, adapter);
adapter.register(this);
}
if (match !== undefined) {
this.adapters.set(adapterClass, match);
return match;
}

let adapter: QueryAdapter;
try {
adapter = new (adapterClass as new () => QueryAdapter)();
} catch {
throw new Error(
`No adapter registered for ${adapterClass.name} and auto-instantiation failed. ` +
`Pass an instance via QueryClient config: new QueryClient({ store, adapters: [new ${adapterClass.name}(...)] })`,
);
}
this.adapters.set(adapterClass, adapter);
adapter.register(this);
return adapter;
}

Expand Down
69 changes: 69 additions & 0 deletions packages/fetchium/src/__tests__/topic-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2071,4 +2071,73 @@ describe('TopicQuery', () => {
});
});
});

// ============================================================
// Section 6: Generated TopicQuery (no static adapter override)
// ============================================================

describe('Generated TopicQuery without static adapter override', () => {
it('should resolve via subclass-aware adapter lookup when subclass inherits TopicQueryAdapter from base', async () => {
// GetPricesGenerated extends TopicQuery directly and does NOT set
// `static adapter`, so it inherits `adapter = TopicQueryAdapter` (the
// abstract base) from TopicQuery. The outer beforeEach above registers
// a MockTopicQueryAdapter instance on the QueryClient — which extends
// TopicQueryAdapter — and QueryClient.getAdapter() resolves the lookup
// for TopicQueryAdapter to that instance via its `instanceof` scan.
class GetPricesGenerated extends TopicQuery {
topic = 'prices:generated';
result = {
items: t.array(t.entity(TopicPrice)),
};
}

mockStream.pushTopicData('prices:generated', {
items: [{ __typename: 'TopicPrice', id: '1', token: 'BTC', value: 50000, change24h: 2.5 }],
});

await testWithClient(client, async () => {
const relay = fetchQuery(GetPricesGenerated);
await relay;

expect(relay.isResolved).toBe(true);
expect(relay.value!.items).toHaveLength(1);
expect(relay.value!.items[0].token).toBe('BTC');
});
});

it('should expose TopicQueryAdapter as the inherited static adapter on the base class', () => {
expect(TopicQuery.adapter).toBe(TopicQueryAdapter);

class GetPricesGenerated extends TopicQuery {
topic = 'prices:generated';
result = {
items: t.array(t.entity(TopicPrice)),
};
}

expect(GetPricesGenerated.adapter).toBe(TopicQueryAdapter);
});

it('should throw in dev when multiple registered adapters match the same lookup', () => {
// Two distinct TopicQueryAdapter subclasses registered on one QueryClient
// creates an ambiguous lookup for `getAdapter(TopicQueryAdapter)` — the
// instanceof scan would pick whichever was registered first. The dev-mode
// check catches this misconfiguration up front.
class SecondTopicAdapter extends TopicQueryAdapter {
subscribe(_topic: string): void {}
unsubscribe(_topic: string): void {}
}

const ambiguousClient = new QueryClient({
store: new SyncQueryStore(new MemoryPersistentStore()),
adapters: [new MockTopicQueryAdapter(mockStream, mockFetch as any), new SecondTopicAdapter()],
} as any);

try {
expect(() => ambiguousClient.getAdapter(TopicQueryAdapter)).toThrow(/matches multiple registered adapters/);
} finally {
ambiguousClient.destroy();
}
});
});
});
5 changes: 3 additions & 2 deletions packages/fetchium/src/topic/TopicQuery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Query } from '../query.js';
import type { TopicQueryAdapter } from './TopicQueryAdapter.js';
import { TopicQueryAdapter } from './TopicQueryAdapter.js';
import type { QueryAdapterClass } from '../QueryAdapter.js';
import type { QueryConfigOptions } from '../query-types.js';

Expand All @@ -8,7 +8,8 @@ import type { QueryConfigOptions } from '../query-types.js';
// ================================

export abstract class TopicQuery extends Query {
static override adapter: QueryAdapterClass<TopicQueryAdapter>;
// Explicit type lets subclasses override with adapters that take constructor args.
static override adapter: QueryAdapterClass<TopicQueryAdapter> = TopicQueryAdapter;

abstract topic: string;

Expand Down
Loading