diff --git a/.changeset/topicquery-adapter-resolution.md b/.changeset/topicquery-adapter-resolution.md new file mode 100644 index 0000000..47c5f05 --- /dev/null +++ b/.changeset/topicquery-adapter-resolution.md @@ -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. diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index d8fa892..f19bca1 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -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 = { @@ -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}`; @@ -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 @@ -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) }; } diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 514c25a..2f309c0 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -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; } diff --git a/packages/fetchium/src/__tests__/topic-query.test.ts b/packages/fetchium/src/__tests__/topic-query.test.ts index 302e7ff..ecff868 100644 --- a/packages/fetchium/src/__tests__/topic-query.test.ts +++ b/packages/fetchium/src/__tests__/topic-query.test.ts @@ -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(); + } + }); + }); }); diff --git a/packages/fetchium/src/topic/TopicQuery.ts b/packages/fetchium/src/topic/TopicQuery.ts index a39b6e5..03688f1 100644 --- a/packages/fetchium/src/topic/TopicQuery.ts +++ b/packages/fetchium/src/topic/TopicQuery.ts @@ -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'; @@ -8,7 +8,8 @@ import type { QueryConfigOptions } from '../query-types.js'; // ================================ export abstract class TopicQuery extends Query { - static override adapter: QueryAdapterClass; + // Explicit type lets subclasses override with adapters that take constructor args. + static override adapter: QueryAdapterClass = TopicQueryAdapter; abstract topic: string;