From aa7480bfd10712a997b43bc7367b8e714355c897 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 3 Aug 2026 17:28:12 -0700 Subject: [PATCH 1/3] fix: route query-subscription events into unconstrained live collections Live collections register under their parent entity's key, but a query's `config.subscribe` stamped the query key as the event source. The two are derived from different inputs and never matched, so `create` and `update` for an unseen id never inserted and `delete` never removed. Field updates to rows already present took the entity merge path and worked, which made the gap silent. Stamp the result root entity's key instead, matching how entity `__subscribe` already stamps its own key. Entity-subscription routing and constrained collections are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../query-subscription-collection-routing.md | 5 + packages/fetchium/src/QueryResult.ts | 6 +- .../live-array-event-source-routing.test.ts | 162 ++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 .changeset/query-subscription-collection-routing.md create mode 100644 packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts diff --git a/.changeset/query-subscription-collection-routing.md b/.changeset/query-subscription-collection-routing.md new file mode 100644 index 0000000..d9a396b --- /dev/null +++ b/.changeset/query-subscription-collection-routing.md @@ -0,0 +1,5 @@ +--- +"fetchium": patch +--- + +Route membership events from a query's own `config.subscribe` into unconstrained live collections in its result. Query subscriptions stamped the query key as the event source, but live collections register under their parent entity's key, so `create`/`update` for an unseen id never inserted and `delete` never removed. Field updates on rows already present were unaffected, which made the gap silent. Entity `__subscribe` routing and constrained collections are unchanged. diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index ea5b55d..4a683b8 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -306,7 +306,11 @@ export class QueryInstance { const ctx = this._executionCtx; this.unsubscribe = subscribeFn.call(ctx, (event: import('./types.js').MutationEvent) => { - event.__eventSource = this.queryKey; + // Live collections register under their parent entity's key, so provenance + // must carry the root entity's key rather than the query key for an + // unconstrained collection to match. Undefined before the first apply, + // when there is no collection to route into yet. + event.__eventSource = this.rootEntity?.key; this.queryClient.applyMutationEvent(event); }); } diff --git a/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts b/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts new file mode 100644 index 0000000..347c1d7 --- /dev/null +++ b/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { testWithClient, sleep, setupTestClient } from './utils.js'; +import type { MutationEvent } from '../types.js'; + +/** + * An unconstrained liveArray in a query result must receive membership events + * from that query's own `config.subscribe`. + * + * An unconstrained liveArray registers under the default constraint + * `[[EVENT_SOURCE_FIELD, parent.key]]`, where parent is the query result's root + * entity, so a query subscription has to stamp that same key as the event + * source. Stamping the query key instead routes nowhere, which silently leaves + * field updates working (they take the entity merge path) while inserts and + * removals are dropped. + */ + +class Balance extends Entity { + __typename = t.typename('Balance'); + id = t.id; + name = t.string; + valueUsdString = t.string; +} + +function compareValueUsdString(a: unknown, b: unknown) { + const left = a as { id: string; valueUsdString: string }; + const right = b as { id: string; valueUsdString: string }; + + return Number(right.valueUsdString) - Number(left.valueUsdString) || String(left.id).localeCompare(String(right.id)); +} + +const emitters = new Map void>(); + +class LiveBalances extends RESTQuery { + params = { walletAddresses: t.array(t.string) }; + path = '/balances'; + searchParams = { walletAddresses: this.params.walletAddresses }; + result = { + items: t.liveArray(Balance, { sort: compareValueUsdString }), + cursor: t.optional(t.string), + }; + + getConfig() { + return { + staleTime: 60_000, + subscribe: (onEvent: (event: MutationEvent) => void) => { + const wallet = (this.params.walletAddresses as unknown as string[])[0]; + emitters.set(wallet, onEvent); + return () => { + emitters.delete(wallet); + }; + }, + }; + } +} + +/** Emit through the query's captured onEvent outside any reactive context. */ +async function emit(wallet: string, event: MutationEvent): Promise { + await new Promise(resolve => { + setTimeout(() => { + const send = emitters.get(wallet); + if (send === undefined) throw new Error(`no active subscription for ${wallet}`); + send(event); + resolve(); + }, 0); + }); + await sleep(10); +} + +function ids(relayValue: unknown): string[] { + const value = relayValue as { items: Array<{ id: string }> }; + return value.items.map(item => String(item.id)); +} + +describe('live array event-source routing', () => { + const getClient = setupTestClient(); + + it('applies field updates and membership events from the query subscription', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/balances', { + items: [ + { __typename: 'Balance', id: 'a', name: 'Alpha', valueUsdString: '300' }, + { __typename: 'Balance', id: 'b', name: 'Beta', valueUsdString: '100' }, + ], + }); + + await testWithClient(client, async () => { + const relay = fetchQuery(LiveBalances, { walletAddresses: ['w1'] }); + await relay; + + expect(ids(relay.value)).toEqual(['a', 'b']); + + await emit('w1', { + type: 'update', + typename: 'Balance', + data: { id: 'a', name: 'Alpha Renamed', valueUsdString: '300' }, + }); + const items = (relay.value as { items: Array<{ name: string }> }).items; + expect(items[0].name).toBe('Alpha Renamed'); + + // An update for an id the array has not seen inserts it. + await emit('w1', { + type: 'update', + typename: 'Balance', + data: { id: 'c', name: 'Gamma', valueUsdString: '200' }, + }); + expect(ids(relay.value)).toEqual(['a', 'c', 'b']); + + await emit('w1', { + type: 'create', + typename: 'Balance', + data: { id: 'k', name: 'Kappa', valueUsdString: '250' }, + }); + expect(ids(relay.value)).toEqual(['a', 'k', 'c', 'b']); + + await emit('w1', { + type: 'delete', + typename: 'Balance', + id: 'b', + data: 'b', + } as MutationEvent); + expect(ids(relay.value)).toEqual(['a', 'k', 'c']); + + await emit('w1', { + type: 'update', + typename: 'Balance', + data: { id: 'a', valueUsdString: '50' }, + }); + expect(ids(relay.value)).toEqual(['k', 'c', 'a']); + }); + }); + + it('scopes membership events to the emitting query', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/balances', { + items: [{ __typename: 'Balance', id: 'a', name: 'Alpha', valueUsdString: '300' }], + }); + + await testWithClient(client, async () => { + // Distinct wallets per test: `emitters` is module scope, and a prior + // test's teardown would otherwise delete a key registered here. + const first = fetchQuery(LiveBalances, { walletAddresses: ['w3'] }); + const second = fetchQuery(LiveBalances, { walletAddresses: ['w4'] }); + await first; + await second; + + await emit('w3', { + type: 'create', + typename: 'Balance', + data: { id: 'c', name: 'Gamma', valueUsdString: '200' }, + }); + + expect(ids(first.value)).toEqual(['a', 'c']); + expect(ids(second.value)).toEqual(['a']); + }); + }); +}); From 8d4a4175fc13e3da626934c89a9103a3eda0ec7c Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 3 Aug 2026 19:48:26 -0700 Subject: [PATCH 2/3] test: cover cross-query scoping for collection provenance Collections are keyed to their root entity, whose identity is method, path, and extracted params. Two queries on different paths with identical params therefore resolve to separate root entities, and an event from one subscription must not reach the other's collection. Co-Authored-By: Claude Opus 5 (1M context) --- .../live-array-event-source-routing.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts b/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts index 347c1d7..0e3a3cb 100644 --- a/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts +++ b/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts @@ -57,6 +57,29 @@ class LiveBalances extends RESTQuery { } } +/** Same result shape on a different path, so it gets its own root entity. */ +class OtherLiveBalances extends RESTQuery { + params = { walletAddresses: t.array(t.string) }; + path = '/other-balances'; + searchParams = { walletAddresses: this.params.walletAddresses }; + result = { + items: t.liveArray(Balance, { sort: compareValueUsdString }), + }; + + getConfig() { + return { + staleTime: 60_000, + subscribe: (onEvent: (event: MutationEvent) => void) => { + const wallet = (this.params.walletAddresses as unknown as string[])[0]; + emitters.set(`other:${wallet}`, onEvent); + return () => { + emitters.delete(`other:${wallet}`); + }; + }, + }; + } +} + /** Emit through the query's captured onEvent outside any reactive context. */ async function emit(wallet: string, event: MutationEvent): Promise { await new Promise(resolve => { @@ -159,4 +182,28 @@ describe('live array event-source routing', () => { expect(ids(second.value)).toEqual(['a']); }); }); + + it('scopes membership events to the emitting query across separate queries', async () => { + const { client, mockFetch } = getClient(); + + const items = [{ __typename: 'Balance', id: 'a', name: 'Alpha', valueUsdString: '300' }]; + mockFetch.get('/balances', { items }); + mockFetch.get('/other-balances', { items }); + + await testWithClient(client, async () => { + const balances = fetchQuery(LiveBalances, { walletAddresses: ['w5'] }); + const other = fetchQuery(OtherLiveBalances, { walletAddresses: ['w5'] }); + await balances; + await other; + + await emit('w5', { + type: 'create', + typename: 'Balance', + data: { id: 'c', name: 'Gamma', valueUsdString: '200' }, + }); + + expect(ids(balances.value)).toEqual(['a', 'c']); + expect(ids(other.value)).toEqual(['a']); + }); + }); }); From acba1b325d28cd14d4fafca18736fc6d99dce011 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Mon, 3 Aug 2026 19:55:59 -0700 Subject: [PATCH 3/3] docs: trim redundancy from the routing comments Both blocks restated their own first clause. Keep the two facts a reader needs at the assignment (why not the query key, why undefined is fine) and drop the repetition. Co-Authored-By: Claude Opus 5 (1M context) --- packages/fetchium/src/QueryResult.ts | 6 ++---- .../live-array-event-source-routing.test.ts | 14 +++++--------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 4a683b8..f63c053 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -306,10 +306,8 @@ export class QueryInstance { const ctx = this._executionCtx; this.unsubscribe = subscribeFn.call(ctx, (event: import('./types.js').MutationEvent) => { - // Live collections register under their parent entity's key, so provenance - // must carry the root entity's key rather than the query key for an - // unconstrained collection to match. Undefined before the first apply, - // when there is no collection to route into yet. + // Collections register under their parent entity's key, so the query key + // matches nothing. Undefined until the first apply: no collection yet. event.__eventSource = this.rootEntity?.key; this.queryClient.applyMutationEvent(event); }); diff --git a/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts b/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts index 0e3a3cb..2445ffb 100644 --- a/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts +++ b/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts @@ -7,15 +7,11 @@ import { testWithClient, sleep, setupTestClient } from './utils.js'; import type { MutationEvent } from '../types.js'; /** - * An unconstrained liveArray in a query result must receive membership events - * from that query's own `config.subscribe`. - * - * An unconstrained liveArray registers under the default constraint - * `[[EVENT_SOURCE_FIELD, parent.key]]`, where parent is the query result's root - * entity, so a query subscription has to stamp that same key as the event - * source. Stamping the query key instead routes nowhere, which silently leaves - * field updates working (they take the entity merge path) while inserts and - * removals are dropped. + * An unconstrained liveArray must receive membership events from its own query's + * `config.subscribe`. It registers under the result root entity's key, so the + * subscription has to stamp that key; stamping the query key routes nowhere and + * fails silently, since field updates still apply through the entity merge path + * while inserts and removals are dropped. */ class Balance extends Entity {