From 1d81cd37d06a2229673217a991bd15e3f797f64c Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 27 Apr 2026 17:52:29 -0700 Subject: [PATCH 01/13] fix(fetchium): resolve TopicQuery adapter via subclass-aware lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TopicQuery declared `static adapter` as a type-only annotation with no runtime value, so subclasses had to set it explicitly. That breaks two patterns: 1. Generated TopicQuery classes that don't know which concrete adapter a consumer will use — they can only target the abstract base. 2. Hand-authored subclasses had to write `static adapter = MyAdapter as unknown as typeof TopicQueryAdapter` to satisfy the override. This change makes TopicQuery assign `static adapter = TopicQueryAdapter` (mirroring the RESTQuery pattern) and teaches `QueryClient.getAdapter()` to fall back to a subclass `instanceof` scan before auto-instantiating. Why both changes are needed: RESTQueryAdapter is concrete, so the registered instance's `.constructor` equals the static-adapter key on the query and exact-match lookup hits. TopicQueryAdapter is abstract, so consumers must register a subclass — the registered constructor is the subclass, not the base, and exact-match misses. The new instanceof fallback also makes the REST path correct in the (previously undefined) case where a consumer registers a subclass of RESTQueryAdapter. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/fetchium/src/QueryClient.ts | 46 ++++++++++++------- .../src/__tests__/topic-query.test.ts | 45 ++++++++++++++++++ packages/fetchium/src/topic/TopicQuery.ts | 9 +++- 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 514c25a..7c3d1e8 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -124,26 +124,40 @@ 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`). + * + * 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; + + for (const registered of this.adapters.values()) { + if (registered instanceof adapterClass) { + this.adapters.set(adapterClass, registered); + return registered; } - this.adapters.set(adapterClass, adapter); - adapter.register(this); } + + 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..0eabda4 100644 --- a/packages/fetchium/src/__tests__/topic-query.test.ts +++ b/packages/fetchium/src/__tests__/topic-query.test.ts @@ -2071,4 +2071,49 @@ 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 () => { + // Generated TopicQuery classes (e.g. from @phantom/fetchium-client codegen) + // do not set `static adapter`. They rely on inheriting `TopicQueryAdapter` + // from the TopicQuery base, and on the QueryClient resolving a registered + // concrete subclass (e.g. MockTopicQueryAdapter) via instanceof match. + 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); + }); + }); }); diff --git a/packages/fetchium/src/topic/TopicQuery.ts b/packages/fetchium/src/topic/TopicQuery.ts index a39b6e5..4ef2ac7 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,12 @@ import type { QueryConfigOptions } from '../query-types.js'; // ================================ export abstract class TopicQuery extends Query { - static override adapter: QueryAdapterClass; + // The type is widened to `QueryAdapterClass` so subclasses + // can override with concrete adapters whose constructors require arguments + // (e.g. `new (url, token) => WebSocketTopicAdapter`). The value defaults to + // the abstract base, which `QueryClient.getAdapter()` resolves via + // subclass-aware lookup against any registered concrete subclass. + static override adapter: QueryAdapterClass = TopicQueryAdapter; abstract topic: string; From 5c5623ddfe6be62bee0f24fc58d1e3101629a36e Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 27 Apr 2026 18:02:16 -0700 Subject: [PATCH 02/13] test(fetchium): clarify how the generated-TopicQuery test resolves its adapter The new test in `topic-query.test.ts` relies on the outer `beforeEach` (~1800 lines above) registering a `MockTopicQueryAdapter` instance on the QueryClient. Without that context the test reads as magic. Replace the comment with one that names the inheritance + instanceof-scan path explicitly and points at the outer registration. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/fetchium/src/__tests__/topic-query.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/fetchium/src/__tests__/topic-query.test.ts b/packages/fetchium/src/__tests__/topic-query.test.ts index 0eabda4..f4084f0 100644 --- a/packages/fetchium/src/__tests__/topic-query.test.ts +++ b/packages/fetchium/src/__tests__/topic-query.test.ts @@ -2078,10 +2078,12 @@ describe('TopicQuery', () => { describe('Generated TopicQuery without static adapter override', () => { it('should resolve via subclass-aware adapter lookup when subclass inherits TopicQueryAdapter from base', async () => { - // Generated TopicQuery classes (e.g. from @phantom/fetchium-client codegen) - // do not set `static adapter`. They rely on inheriting `TopicQueryAdapter` - // from the TopicQuery base, and on the QueryClient resolving a registered - // concrete subclass (e.g. MockTopicQueryAdapter) via instanceof match. + // 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 = { From ed52736b5591e3ea5536bda5949d718b3402ff90 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 27 Apr 2026 18:20:04 -0700 Subject: [PATCH 03/13] chore(fetchium): add changeset for TopicQuery adapter resolution Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/topicquery-adapter-resolution.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/topicquery-adapter-resolution.md diff --git a/.changeset/topicquery-adapter-resolution.md b/.changeset/topicquery-adapter-resolution.md new file mode 100644 index 0000000..58e17f2 --- /dev/null +++ b/.changeset/topicquery-adapter-resolution.md @@ -0,0 +1,5 @@ +--- +"fetchium": patch +--- + +Resolve `TopicQuery` adapter via subclass-aware lookup. `TopicQuery` now assigns `static adapter = TopicQueryAdapter` so subclasses inherit a runtime value without per-class overrides, and `QueryClient.getAdapter()` falls back to an `instanceof` scan over registered adapters before auto-instantiating, so an abstract base on a query resolves to the consumer-registered concrete subclass. From 79dc6d27bbc910b526447516652aa902cd2c20bf Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 27 Apr 2026 18:23:34 -0700 Subject: [PATCH 04/13] docs(streaming): update TopicQuery examples to drop unneeded adapter overrides After the subclass-aware adapter resolution change, individual TopicQuery subclasses no longer need to declare `static override adapter` for the common case of one registered streaming adapter. Update the streaming page so: - Inline examples extend `TopicQuery` directly instead of an intermediate `MyTopicQuery` base. - The "Registering the adapter" section explains that the resolution happens via inheritance + the QueryClient's instanceof lookup, with an opt-in note for disambiguating between multiple registered subclasses. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/src/app/core/streaming/page.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index d8fa892..de7e83d 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,14 +231,22 @@ const queryClient = new QueryClient({ }); ``` -Then make your topic query classes reference the adapter: +That's it --- topic query classes that extend `TopicQuery` directly will resolve to the registered `MyStreamAdapter` automatically. Internally, `TopicQuery` has `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. + +If you register **multiple** `TopicQueryAdapter` subclasses (for example, one WebSocket adapter and one SSE adapter) and need different queries to use different ones, declare `static override adapter` on each query (or on a shared abstract base) to disambiguate: ```tsx -abstract class MyTopicQuery extends TopicQuery { +abstract class WebSocketTopicQuery extends TopicQuery { static override adapter = MyStreamAdapter; } + +abstract class SSETopicQuery extends TopicQuery { + static override adapter = MySSEAdapter; +} ``` +For the common single-adapter case, the override is unnecessary. + ### Pre-fulfillment A powerful feature of the adapter is that `fulfillTopic` can be called _before_ the query activates. If your message bus proactively sends data for topics it knows the page will need, the adapter can buffer that data: @@ -515,7 +523,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) }; } From 3974eb6d26e7c607ccc6c9098f3e121d95f97834 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 27 Apr 2026 18:27:31 -0700 Subject: [PATCH 05/13] docs(streaming): drop clunky em-dashed phrasing in adapter resolution note Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/src/app/core/streaming/page.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index de7e83d..0776674 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -231,7 +231,7 @@ const queryClient = new QueryClient({ }); ``` -That's it --- topic query classes that extend `TopicQuery` directly will resolve to the registered `MyStreamAdapter` automatically. Internally, `TopicQuery` has `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. +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. If you register **multiple** `TopicQueryAdapter` subclasses (for example, one WebSocket adapter and one SSE adapter) and need different queries to use different ones, declare `static override adapter` on each query (or on a shared abstract base) to disambiguate: From 0810f8e47411caa347e28fdfa11e40616ee6fe59 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 27 Apr 2026 18:31:09 -0700 Subject: [PATCH 06/13] docs(streaming): trim hypothetical multi-adapter disambiguation guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In practice, apps register one streaming adapter per QueryClient — the multi-adapter override pattern was over-engineering. Replace with a one-liner pointing at "use a separate QueryClient" if you ever hit the case. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/src/app/core/streaming/page.md | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index 0776674..ce93c85 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -231,21 +231,7 @@ const queryClient = new QueryClient({ }); ``` -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. - -If you register **multiple** `TopicQueryAdapter` subclasses (for example, one WebSocket adapter and one SSE adapter) and need different queries to use different ones, declare `static override adapter` on each query (or on a shared abstract base) to disambiguate: - -```tsx -abstract class WebSocketTopicQuery extends TopicQuery { - static override adapter = MyStreamAdapter; -} - -abstract class SSETopicQuery extends TopicQuery { - static override adapter = MySSEAdapter; -} -``` - -For the common single-adapter case, the override is unnecessary. +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. If your app needs more than one streaming protocol, use a separate `QueryClient` for each. ### Pre-fulfillment From 641c9c2758d18f09c46930fb37cd370dd4733d71 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 27 Apr 2026 18:33:26 -0700 Subject: [PATCH 07/13] docs(streaming): drop the dangling separate-QueryClient hint The only justification for "use a separate QueryClient" is the adapter resolution ambiguity case we already trimmed as hypothetical. Without that reasoning, the sentence dangles. Remove it; the common-case explanation stands on its own. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/src/app/core/streaming/page.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index ce93c85..27a3482 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -231,7 +231,7 @@ const queryClient = new QueryClient({ }); ``` -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. If your app needs more than one streaming protocol, use a separate `QueryClient` for each. +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. ### Pre-fulfillment From 096d9d7c1beb2cb42d100f3d53dc829eb1d6ba87 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Tue, 28 Apr 2026 08:33:17 -0700 Subject: [PATCH 08/13] feat(fetchium): throw in dev when adapter lookup is ambiguous `QueryClient.getAdapter()`'s subclass-aware lookup picks the first `instanceof` match in registration order. If a consumer registers two adapters that both satisfy the same lookup base (e.g. two `TopicQueryAdapter` subclasses on one client), the resolution is silent and brittle. In dev builds, scan all registered adapters and throw on more than one match, naming the conflicting classes. In production, keep the original fast path: first match wins, exit early. The dev-only branch is gated behind `if (IS_DEV)` so it tree-shakes out of production bundles. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/topicquery-adapter-resolution.md | 2 +- packages/fetchium/src/QueryClient.ts | 22 +++++++++++++++-- .../src/__tests__/topic-query.test.ts | 24 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/.changeset/topicquery-adapter-resolution.md b/.changeset/topicquery-adapter-resolution.md index 58e17f2..41ee6cd 100644 --- a/.changeset/topicquery-adapter-resolution.md +++ b/.changeset/topicquery-adapter-resolution.md @@ -2,4 +2,4 @@ "fetchium": patch --- -Resolve `TopicQuery` adapter via subclass-aware lookup. `TopicQuery` now assigns `static adapter = TopicQueryAdapter` so subclasses inherit a runtime value without per-class overrides, and `QueryClient.getAdapter()` falls back to an `instanceof` scan over registered adapters before auto-instantiating, so an abstract base on a query resolves to the consumer-registered concrete subclass. +Resolve `TopicQuery` adapter via subclass-aware lookup. `TopicQuery` now assigns `static adapter = TopicQueryAdapter` so subclasses inherit a runtime value without per-class overrides, and `QueryClient.getAdapter()` falls back to an `instanceof` scan over registered adapters before auto-instantiating, so an abstract base on a query resolves to the consumer-registered concrete subclass. In dev builds, the lookup throws when more than one registered adapter would match the same lookup, surfacing ambiguous registrations early; the check is stripped in production builds. diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 7c3d1e8..6c11485 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -134,18 +134,36 @@ export class QueryClient { * 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 { 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) { - this.adapters.set(adapterClass, registered); - return registered; + if (match === undefined) { + match = registered; + if (!IS_DEV) break; + } else if (IS_DEV) { + 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.`, + ); + } } } + if (match !== undefined) { + this.adapters.set(adapterClass, match); + return match; + } let adapter: QueryAdapter; try { diff --git a/packages/fetchium/src/__tests__/topic-query.test.ts b/packages/fetchium/src/__tests__/topic-query.test.ts index f4084f0..151f8e3 100644 --- a/packages/fetchium/src/__tests__/topic-query.test.ts +++ b/packages/fetchium/src/__tests__/topic-query.test.ts @@ -2117,5 +2117,29 @@ describe('TopicQuery', () => { 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(); + } + }); }); }); From e56d1be8cd53907cced831b3f0dcb268bfe8003f Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Tue, 28 Apr 2026 08:38:14 -0700 Subject: [PATCH 09/13] chore(fetchium): trim changeset to user-visible behavior Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/topicquery-adapter-resolution.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/topicquery-adapter-resolution.md b/.changeset/topicquery-adapter-resolution.md index 41ee6cd..47c5f05 100644 --- a/.changeset/topicquery-adapter-resolution.md +++ b/.changeset/topicquery-adapter-resolution.md @@ -2,4 +2,4 @@ "fetchium": patch --- -Resolve `TopicQuery` adapter via subclass-aware lookup. `TopicQuery` now assigns `static adapter = TopicQueryAdapter` so subclasses inherit a runtime value without per-class overrides, and `QueryClient.getAdapter()` falls back to an `instanceof` scan over registered adapters before auto-instantiating, so an abstract base on a query resolves to the consumer-registered concrete subclass. In dev builds, the lookup throws when more than one registered adapter would match the same lookup, surfacing ambiguous registrations early; the check is stripped in production builds. +`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. From b6232cd849494fc293dea12e76014dbc1cf37c26 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Tue, 28 Apr 2026 08:48:07 -0700 Subject: [PATCH 10/13] refactor(fetchium): simplify getAdapter loop and trim TopicQuery comment - `getAdapter()`: collapse the four-case branch in the instanceof scan to a single dev-mode ambiguity check plus `match ??= registered` for first-match-wins. Also drop a now-redundant inline comment on the auto-instantiate fallback (the JSDoc enumerates step 3 and the catch's error message covers the failure mode). - `TopicQuery.ts`: trim the five-line comment on `static override adapter` to a single-line note about why the explicit type annotation is load-bearing. No behavior change. All topic-query tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/fetchium/src/QueryClient.ts | 6 ++---- packages/fetchium/src/topic/TopicQuery.ts | 6 +----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 6c11485..2f309c0 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -147,10 +147,7 @@ export class QueryClient { let match: QueryAdapter | undefined; for (const registered of this.adapters.values()) { if (registered instanceof adapterClass) { - if (match === undefined) { - match = registered; - if (!IS_DEV) break; - } else if (IS_DEV) { + if (IS_DEV && match !== undefined) { throw new Error( `Adapter lookup for ${adapterClass.name} matches multiple registered adapters: ` + `${match.constructor.name} and ${registered.constructor.name}. ` + @@ -158,6 +155,7 @@ export class QueryClient { `or split into separate QueryClients.`, ); } + match ??= registered; } } if (match !== undefined) { diff --git a/packages/fetchium/src/topic/TopicQuery.ts b/packages/fetchium/src/topic/TopicQuery.ts index 4ef2ac7..03688f1 100644 --- a/packages/fetchium/src/topic/TopicQuery.ts +++ b/packages/fetchium/src/topic/TopicQuery.ts @@ -8,11 +8,7 @@ import type { QueryConfigOptions } from '../query-types.js'; // ================================ export abstract class TopicQuery extends Query { - // The type is widened to `QueryAdapterClass` so subclasses - // can override with concrete adapters whose constructors require arguments - // (e.g. `new (url, token) => WebSocketTopicAdapter`). The value defaults to - // the abstract base, which `QueryClient.getAdapter()` resolves via - // subclass-aware lookup against any registered concrete subclass. + // Explicit type lets subclasses override with adapters that take constructor args. static override adapter: QueryAdapterClass = TopicQueryAdapter; abstract topic: string; From 293da70ef0aa30e7c2cb0333d95e6f0ae420e425 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Tue, 28 Apr 2026 08:49:22 -0700 Subject: [PATCH 11/13] docs(streaming): add callout warning about multi-adapter registration Captures the "one TopicQueryAdapter subclass per QueryClient" design intent and pre-warns about the dev-mode ambiguity error so users structure their setup correctly the first time. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/src/app/core/streaming/page.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index 27a3482..cf8d580 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -233,6 +233,10 @@ const queryClient = new QueryClient({ 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. +{% 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 a clear error if more than one registered adapter satisfies the same lookup; in production, the first registered match wins, which is brittle to register order. +{% /callout %} + ### Pre-fulfillment A powerful feature of the adapter is that `fulfillTopic` can be called _before_ the query activates. If your message bus proactively sends data for topics it knows the page will need, the adapter can buffer that data: From 62b03fb39a78e8bc5cf7412258f389b5f7690b61 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Tue, 28 Apr 2026 08:51:08 -0700 Subject: [PATCH 12/13] docs(streaming): trim multi-adapter callout to the load-bearing facts Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/src/app/core/streaming/page.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/app/core/streaming/page.md b/docs/src/app/core/streaming/page.md index cf8d580..f19bca1 100644 --- a/docs/src/app/core/streaming/page.md +++ b/docs/src/app/core/streaming/page.md @@ -234,7 +234,7 @@ const queryClient = new QueryClient({ 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. {% 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 a clear error if more than one registered adapter satisfies the same lookup; in production, the first registered match wins, which is brittle to register order. +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 From bd811bf4c140256aeab82596a80cae368ebad316 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Tue, 28 Apr 2026 08:55:15 -0700 Subject: [PATCH 13/13] chore(fetchium): apply prettier formatting Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/fetchium/src/__tests__/topic-query.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/fetchium/src/__tests__/topic-query.test.ts b/packages/fetchium/src/__tests__/topic-query.test.ts index 151f8e3..ecff868 100644 --- a/packages/fetchium/src/__tests__/topic-query.test.ts +++ b/packages/fetchium/src/__tests__/topic-query.test.ts @@ -2134,9 +2134,7 @@ describe('TopicQuery', () => { } as any); try { - expect(() => ambiguousClient.getAdapter(TopicQueryAdapter)).toThrow( - /matches multiple registered adapters/, - ); + expect(() => ambiguousClient.getAdapter(TopicQueryAdapter)).toThrow(/matches multiple registered adapters/); } finally { ambiguousClient.destroy(); }