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..f63c053 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -306,7 +306,9 @@ export class QueryInstance { const ctx = this._executionCtx; this.unsubscribe = subscribeFn.call(ctx, (event: import('./types.js').MutationEvent) => { - event.__eventSource = this.queryKey; + // 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 new file mode 100644 index 0000000..2445ffb --- /dev/null +++ b/packages/fetchium/src/__tests__/live-array-event-source-routing.test.ts @@ -0,0 +1,205 @@ +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 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 { + __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); + }; + }, + }; + } +} + +/** 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 => { + 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']); + }); + }); + + 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']); + }); + }); +});