diff --git a/src/shared/lib/helpers/BaseQueryStore.svelte.ts b/src/shared/lib/helpers/BaseQueryStore.svelte.ts new file mode 100644 index 0000000..1520b5a --- /dev/null +++ b/src/shared/lib/helpers/BaseQueryStore.svelte.ts @@ -0,0 +1,51 @@ +import { queryClient } from '$shared/api/queryClient'; +import { + QueryObserver, + type QueryObserverResult, + type QueryOptions, +} from '@tanstack/query-core'; + +/** + * Abstract base class for reactive Svelte 5 stores backed by TanStack Query. + * + * Provides a unified way to use TanStack Query observers within Svelte 5 classes + * using runes for reactivity. Handles subscription lifecycle automatically. + * + * @template TData - The type of data returned by the query. + * @template TError - The type of error that can be thrown. + */ +export abstract class BaseQueryStore { + #result = $state>({} as QueryObserverResult); + #observer: QueryObserver; + #unsubscribe: () => void; + + constructor(options: QueryOptions) { + this.#observer = new QueryObserver(queryClient, options); + this.#unsubscribe = this.#observer.subscribe(result => { + this.#result = result; + }); + } + + /** + * Current query result (reactive) + */ + protected get result(): QueryObserverResult { + return this.#result; + } + + /** + * Updates observer options dynamically. + * Use this when query parameters or dependencies change. + */ + protected updateOptions(options: QueryOptions): void { + this.#observer.setOptions(options); + } + + /** + * Cleans up the observer subscription. + * Should be called when the store is no longer needed. + */ + destroy(): void { + this.#unsubscribe(); + } +} diff --git a/src/shared/lib/helpers/BaseQueryStore.test.ts b/src/shared/lib/helpers/BaseQueryStore.test.ts new file mode 100644 index 0000000..224d16c --- /dev/null +++ b/src/shared/lib/helpers/BaseQueryStore.test.ts @@ -0,0 +1,85 @@ +import { queryClient } from '$shared/api/queryClient'; +import { + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { BaseQueryStore } from './BaseQueryStore.svelte'; + +class TestStore extends BaseQueryStore { + constructor(key = ['test'], fn = () => Promise.resolve('ok')) { + super({ + queryKey: key, + queryFn: fn, + retry: false, // Disable retries for faster error testing + }); + } + get data() { + return this.result.data; + } + get isLoading() { + return this.result.isLoading; + } + get isError() { + return this.result.isError; + } + + update(newKey: string[], newFn?: () => Promise) { + this.updateOptions({ + queryKey: newKey, + queryFn: newFn ?? (() => Promise.resolve('ok')), + retry: false, + }); + } +} + +import * as tq from '@tanstack/query-core'; + +// ... (TestStore remains same) + +describe('BaseQueryStore', () => { + beforeEach(() => { + queryClient.clear(); + }); + + describe('Lifecycle & Fetching', () => { + it('should transition from loading to success', async () => { + const store = new TestStore(); + expect(store.isLoading).toBe(true); + await vi.waitFor(() => expect(store.data).toBe('ok'), { timeout: 1000 }); + expect(store.isLoading).toBe(false); + }); + }); + + describe('Error Handling', () => { + it('should handle query failures', async () => { + const store = new TestStore(['fail'], () => Promise.reject(new Error('fail'))); + await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 }); + }); + }); + + describe('Reactivity', () => { + it('should refetch and update data when options change', async () => { + const store = new TestStore(['key1'], () => Promise.resolve('val1')); + await vi.waitFor(() => expect(store.data).toBe('val1'), { timeout: 1000 }); + + store.update(['key2'], () => Promise.resolve('val2')); + await vi.waitFor(() => expect(store.data).toBe('val2'), { timeout: 1000 }); + }); + }); + + describe('Cleanup', () => { + it('should unsubscribe observer on destroy', () => { + const unsubscribe = vi.fn(); + const subscribeSpy = vi.spyOn(tq.QueryObserver.prototype, 'subscribe').mockReturnValue(unsubscribe); + + const store = new TestStore(); + store.destroy(); + + expect(unsubscribe).toHaveBeenCalled(); + subscribeSpy.mockRestore(); + }); + }); +});