diff --git a/src/shared/lib/fetch/collectionCache.test.ts b/src/shared/lib/fetch/collectionCache.test.ts deleted file mode 100644 index 090c951..0000000 --- a/src/shared/lib/fetch/collectionCache.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { get } from 'svelte/store'; -import { - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; -import { - type CacheOptions, - createCollectionCache, -} from './collectionCache'; - -describe('createCollectionCache', () => { - let cache: ReturnType>; - - beforeEach(() => { - cache = createCollectionCache(); - }); - - describe('initialization', () => { - it('initializes with empty cache', () => { - const data = get(cache.data); - expect(data).toEqual({}); - }); - - it('initializes with default options', () => { - const stats = cache.getStats(); - expect(stats.total).toBe(0); - expect(stats.cached).toBe(0); - expect(stats.fetching).toBe(0); - expect(stats.errors).toBe(0); - expect(stats.hits).toBe(0); - expect(stats.misses).toBe(0); - }); - - it('accepts custom cache options', () => { - const options: CacheOptions = { - defaultTTL: 10 * 60 * 1000, // 10 minutes - maxSize: 500, - }; - const customCache = createCollectionCache(options); - expect(customCache).toBeDefined(); - }); - }); - - describe('set and get', () => { - it('sets a value in cache', () => { - cache.set('key1', 100); - const value = cache.get('key1'); - expect(value).toBe(100); - }); - - it('sets multiple values in cache', () => { - cache.set('key1', 100); - cache.set('key2', 200); - cache.set('key3', 300); - - expect(cache.get('key1')).toBe(100); - expect(cache.get('key2')).toBe(200); - expect(cache.get('key3')).toBe(300); - }); - - it('updates existing value', () => { - cache.set('key1', 100); - cache.set('key1', 150); - expect(cache.get('key1')).toBe(150); - }); - - it('returns undefined for non-existent key', () => { - const value = cache.get('non-existent'); - expect(value).toBeUndefined(); - }); - - it('marks item as ready after set', () => { - cache.set('key1', 100); - const internalState = cache.getInternalState('key1'); - expect(internalState?.ready).toBe(true); - expect(internalState?.fetching).toBe(false); - }); - }); - - describe('has and hasFresh', () => { - it('returns false for non-existent key', () => { - expect(cache.has('non-existent')).toBe(false); - expect(cache.hasFresh('non-existent')).toBe(false); - }); - - it('returns true after setting value', () => { - cache.set('key1', 100); - expect(cache.has('key1')).toBe(true); - expect(cache.hasFresh('key1')).toBe(true); - }); - - it('returns false for fetching items', () => { - cache.markFetching('key1'); - expect(cache.has('key1')).toBe(false); - expect(cache.hasFresh('key1')).toBe(false); - }); - - it('returns false for failed items', () => { - cache.markFailed('key1', 'Network error'); - expect(cache.has('key1')).toBe(false); - expect(cache.hasFresh('key1')).toBe(false); - }); - }); - - describe('remove', () => { - it('removes a value from cache', () => { - cache.set('key1', 100); - cache.set('key2', 200); - - cache.remove('key1'); - - expect(cache.get('key1')).toBeUndefined(); - expect(cache.get('key2')).toBe(200); - }); - - it('removes internal state', () => { - cache.set('key1', 100); - cache.remove('key1'); - const state = cache.getInternalState('key1'); - expect(state).toBeUndefined(); - }); - - it('does nothing for non-existent key', () => { - expect(() => cache.remove('non-existent')).not.toThrow(); - }); - }); - - describe('clear', () => { - it('clears all values from cache', () => { - cache.set('key1', 100); - cache.set('key2', 200); - cache.set('key3', 300); - - cache.clear(); - - expect(cache.get('key1')).toBeUndefined(); - expect(cache.get('key2')).toBeUndefined(); - expect(cache.get('key3')).toBeUndefined(); - }); - - it('clears internal state', () => { - cache.set('key1', 100); - cache.clear(); - - const state = cache.getInternalState('key1'); - expect(state).toBeUndefined(); - }); - - it('resets cache statistics', () => { - cache.set('key1', 100); // This increments hits - const _statsBefore = cache.getStats(); - - cache.clear(); - const statsAfter = cache.getStats(); - - expect(statsAfter.hits).toBe(0); - expect(statsAfter.misses).toBe(0); - }); - }); - - describe('markFetching', () => { - it('marks item as fetching', () => { - cache.markFetching('key1'); - - expect(cache.isFetching('key1')).toBe(true); - - const state = cache.getInternalState('key1'); - expect(state?.fetching).toBe(true); - expect(state?.ready).toBe(false); - expect(state?.startTime).toBeDefined(); - }); - - it('updates existing state when called again', () => { - cache.markFetching('key1'); - const startTime1 = cache.getInternalState('key1')?.startTime; - - // Wait a bit to ensure different timestamp - vi.useFakeTimers(); - vi.advanceTimersByTime(100); - - cache.markFetching('key1'); - const startTime2 = cache.getInternalState('key1')?.startTime; - - expect(startTime2).toBeGreaterThan(startTime1!); - vi.useRealTimers(); - }); - - it('sets endTime to undefined', () => { - cache.markFetching('key1'); - const state = cache.getInternalState('key1'); - expect(state?.endTime).toBeUndefined(); - }); - }); - - describe('markFailed', () => { - it('marks item as failed with error message', () => { - cache.markFailed('key1', 'Network error'); - - expect(cache.isFetching('key1')).toBe(false); - - const error = cache.getError('key1'); - expect(error).toBe('Network error'); - - const state = cache.getInternalState('key1'); - expect(state?.fetching).toBe(false); - expect(state?.ready).toBe(false); - expect(state?.error).toBe('Network error'); - }); - - it('preserves start time from fetching state', () => { - cache.markFetching('key1'); - const startTime = cache.getInternalState('key1')?.startTime; - - cache.markFailed('key1', 'Error'); - - const state = cache.getInternalState('key1'); - expect(state?.startTime).toBe(startTime); - }); - - it('sets end time', () => { - cache.markFailed('key1', 'Error'); - const state = cache.getInternalState('key1'); - expect(state?.endTime).toBeDefined(); - }); - - it('increments error counter', () => { - const statsBefore = cache.getStats(); - - cache.markFailed('key1', 'Error1'); - const statsAfter1 = cache.getStats(); - expect(statsAfter1.errors).toBe(statsBefore.errors + 1); - - cache.markFailed('key2', 'Error2'); - const statsAfter2 = cache.getStats(); - expect(statsAfter2.errors).toBe(statsAfter1.errors + 1); - }); - }); - - describe('markMiss', () => { - it('increments miss counter', () => { - const statsBefore = cache.getStats(); - - cache.markMiss(); - - const statsAfter = cache.getStats(); - expect(statsAfter.misses).toBe(statsBefore.misses + 1); - }); - - it('increments miss counter multiple times', () => { - const statsBefore = cache.getStats(); - - cache.markMiss(); - cache.markMiss(); - cache.markMiss(); - - const statsAfter = cache.getStats(); - expect(statsAfter.misses).toBe(statsBefore.misses + 3); - }); - }); - - describe('statistics', () => { - it('tracks total number of items', () => { - expect(cache.getStats().total).toBe(0); - - cache.set('key1', 100); - expect(cache.getStats().total).toBe(1); - - cache.set('key2', 200); - expect(cache.getStats().total).toBe(2); - - cache.remove('key1'); - expect(cache.getStats().total).toBe(1); - }); - - it('tracks number of cached (ready) items', () => { - expect(cache.getStats().cached).toBe(0); - - cache.set('key1', 100); - expect(cache.getStats().cached).toBe(1); - - cache.set('key2', 200); - expect(cache.getStats().cached).toBe(2); - - cache.markFetching('key3'); - expect(cache.getStats().cached).toBe(2); - }); - - it('tracks number of fetching items', () => { - expect(cache.getStats().fetching).toBe(0); - - cache.markFetching('key1'); - expect(cache.getStats().fetching).toBe(1); - - cache.markFetching('key2'); - expect(cache.getStats().fetching).toBe(2); - - cache.set('key1', 100); - expect(cache.getStats().fetching).toBe(1); - }); - - it('tracks cache hits', () => { - const statsBefore = cache.getStats(); - - cache.set('key1', 100); - const statsAfter1 = cache.getStats(); - expect(statsAfter1.hits).toBe(statsBefore.hits + 1); - - cache.set('key2', 200); - const statsAfter2 = cache.getStats(); - expect(statsAfter2.hits).toBe(statsAfter1.hits + 1); - }); - - it('provides derived stats store', () => { - cache.set('key1', 100); - cache.markFetching('key2'); - - const stats = get(cache.stats); - expect(stats.total).toBe(1); - expect(stats.cached).toBe(1); - expect(stats.fetching).toBe(1); - }); - }); - - describe('store reactivity', () => { - it('updates data store reactively', () => { - let dataUpdates = 0; - const unsubscribe = cache.data.subscribe(() => { - dataUpdates++; - }); - - cache.set('key1', 100); - cache.set('key2', 200); - - expect(dataUpdates).toBeGreaterThan(0); - unsubscribe(); - }); - - it('updates internal state store reactively', () => { - let internalUpdates = 0; - const unsubscribe = cache.internal.subscribe(() => { - internalUpdates++; - }); - - cache.markFetching('key1'); - cache.set('key1', 100); - cache.markFailed('key2', 'Error'); - - expect(internalUpdates).toBeGreaterThan(0); - unsubscribe(); - }); - - it('updates stats store reactively', () => { - let statsUpdates = 0; - const unsubscribe = cache.stats.subscribe(() => { - statsUpdates++; - }); - - cache.set('key1', 100); - cache.markMiss(); - - expect(statsUpdates).toBeGreaterThan(0); - unsubscribe(); - }); - }); - - describe('edge cases', () => { - it('handles complex types', () => { - interface ComplexType { - id: string; - value: number; - tags: string[]; - } - - const complexCache = createCollectionCache(); - const item: ComplexType = { - id: '1', - value: 42, - tags: ['a', 'b', 'c'], - }; - - complexCache.set('item1', item); - const retrieved = complexCache.get('item1'); - - expect(retrieved).toEqual(item); - expect(retrieved?.tags).toEqual(['a', 'b', 'c']); - }); - - it('handles special characters in keys', () => { - cache.set('key with spaces', 1); - cache.set('key/with/slashes', 2); - cache.set('key-with-dashes', 3); - - expect(cache.get('key with spaces')).toBe(1); - expect(cache.get('key/with/slashes')).toBe(2); - expect(cache.get('key-with-dashes')).toBe(3); - }); - - it('handles rapid set and remove operations', () => { - for (let i = 0; i < 100; i++) { - cache.set(`key${i}`, i); - } - - for (let i = 0; i < 100; i += 2) { - cache.remove(`key${i}`); - } - - expect(cache.getStats().total).toBe(50); - expect(cache.get('key0')).toBeUndefined(); - expect(cache.get('key1')).toBe(1); - }); - }); - - describe('error handling', () => { - it('handles concurrent markFetching for same key', () => { - cache.markFetching('key1'); - cache.markFetching('key1'); - - const state = cache.getInternalState('key1'); - expect(state?.fetching).toBe(true); - expect(state?.startTime).toBeDefined(); - }); - - it('handles marking failed without prior fetching', () => { - cache.markFailed('key1', 'Error'); - - const state = cache.getInternalState('key1'); - expect(state?.fetching).toBe(false); - expect(state?.ready).toBe(false); - expect(state?.error).toBe('Error'); - }); - - it('handles operations on removed keys', () => { - cache.set('key1', 100); - cache.remove('key1'); - - expect(() => cache.set('key1', 200)).not.toThrow(); - expect(() => cache.remove('key1')).not.toThrow(); - expect(() => cache.getError('key1')).not.toThrow(); - }); - }); -}); diff --git a/src/shared/lib/fetch/collectionCache.ts b/src/shared/lib/fetch/collectionCache.ts deleted file mode 100644 index 4182d1e..0000000 --- a/src/shared/lib/fetch/collectionCache.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Collection cache manager - * - * Provides key-based caching, deduplication, and request tracking - * for any collection type. Integrates with Svelte stores for reactive updates. - * - * Key features: - * - Key-based caching (any ID, query hash) - * - Request deduplication (prevents concurrent requests for same key) - * - Request state tracking (fetching, ready, error) - * - TTL/staleness management - * - Performance timing tracking - */ - -import type { - Readable, - Writable, -} from 'svelte/store'; -import { - derived, - get, - writable, -} from 'svelte/store'; - -/** - * Internal state for a cached item - * Tracks request lifecycle (fetching → ready/error) - */ -export interface CacheItemInternalState { - /** Whether a fetch is currently in progress */ - fetching: boolean; - /** Whether data is ready and cached */ - ready: boolean; - /** Error message if fetch failed */ - error?: string; - /** Request start timestamp (performance tracking) */ - startTime?: number; - /** Request end timestamp (performance tracking) */ - endTime?: number; -} - -/** - * Cache configuration options - */ -export interface CacheOptions { - /** Default time-to-live for cached items (in milliseconds) */ - defaultTTL?: number; - /** Maximum number of items to cache (LRU eviction) */ - maxSize?: number; -} - -/** - * Statistics about cache performance - */ -export interface CacheStats { - /** Total number of items in cache */ - total: number; - /** Number of items marked as ready */ - cached: number; - /** Number of items currently fetching */ - fetching: number; - /** Number of items with errors */ - errors: number; - /** Total cache hits (data returned from cache) */ - hits: number; - /** Total cache misses (data fetched from API) */ - misses: number; -} - -/** - * Cache manager interface - * Type-safe interface for collection caching operations - */ -export interface CollectionCacheManager { - /** Get an item from cache by key */ - get: (key: string) => T | undefined; - /** Check if item exists in cache and is ready */ - has: (key: string) => boolean; - /** Check if item exists and is not stale */ - hasFresh: (key: string) => boolean; - /** Set an item in cache (manual cache write) */ - set: (key: string, value: T, ttl?: number) => void; - /** Remove item from cache */ - remove: (key: string) => void; - /** Clear all items from cache */ - clear: () => void; - /** Check if key is currently being fetched */ - isFetching: (key: string) => boolean; - /** Get error for a key */ - getError: (key: string) => string | undefined; - /** Get internal state for a key (for debugging) */ - getInternalState: (key: string) => CacheItemInternalState | undefined; - /** Get cache statistics */ - getStats: () => CacheStats; - /** Mark item as fetching (used when starting API request) */ - markFetching: (key: string) => void; - /** Mark item as failed (used when API request fails) */ - markFailed: (key: string, error: string) => void; - /** Increment cache miss counter */ - markMiss: () => void; - /** Store containing cached data */ - data: Writable>; - /** Store containing internal state (fetching, ready, error) */ - internal: Writable>; - /** Derived store containing cache statistics */ - stats: Readable; -} - -/** - * Creates a collection cache manager - * - * @typeParam T - Type of data being cached (e.g., UnifiedFont, Product, User) - * @param options - Cache configuration options - * @returns Cache manager instance - * - * @example - * ```ts - * const fontCache = createCollectionCache({ - * defaultTTL: 5 * 60 * 1000, // 5 minutes - * maxSize: 1000 - * }); - * - * // Set font in cache - * fontCache.set('Roboto', robotoFont); - * - * // Get font from cache - * const font = fontCache.get('Roboto'); - * if (fontCache.hasFresh('Roboto')) { - * // Use cached font - * } - * ``` - */ -export function createCollectionCache(_options: CacheOptions = {}): CollectionCacheManager { - // const { defaultTTL = 5 * 60 * 1000, maxSize = 1000 } = options; - - // Stores for reactive data - const data: Writable> = writable({}); - const internal: Writable> = writable({}); - - // Cache statistics store - const statsState = writable({ - total: 0, - cached: 0, - fetching: 0, - errors: 0, - hits: 0, - misses: 0, - }); - - // Derived stats store for reactive updates - const stats = derived([data, internal, statsState], ([$data, $internal, $statsState]) => ({ - ...$statsState, - total: Object.keys($data).length, - cached: Object.values($internal).filter(s => s.ready).length, - fetching: Object.values($internal).filter(s => s.fetching).length, - errors: Object.values($internal).filter(s => s.error).length, - })); - - return { - /** - * Get cached data by key - * Returns undefined if not found - */ - get: (key: string) => { - const currentData = get(data); - return currentData[key]; - }, - - /** - * Check if key exists in cache and is ready - */ - has: (key: string) => { - const currentInternal = get(internal); - const state = currentInternal[key]; - return state?.ready === true; - }, - - /** - * Check if key exists and is not stale (still within TTL) - */ - hasFresh: (key: string) => { - const currentInternal = get(internal); - const currentData = get(data); - - const state = currentInternal[key]; - if (!state?.ready) { - return false; - } - - // Check if item exists in data store - if (!currentData[key]) { - return false; - } - - // TODO: Implement TTL check with cachedAt timestamps - // For now, just check ready state - return true; - }, - - /** - * Set data in cache - * Marks entry as ready and stops fetching state - */ - set: (key: string, value: T, _ttl?: number) => { - data.update(d => ({ - ...d, - [key]: value, - })); - - internal.update(i => { - const existingState = i[key]; - return { - ...i, - [key]: { - fetching: false, - ready: true, - error: undefined, - startTime: existingState?.startTime, - endTime: Date.now(), - }, - }; - }); - - // Update statistics (cache hit) - statsState.update(s => ({ ...s, hits: s.hits + 1 })); - }, - - /** - * Remove item from cache - */ - remove: (key: string) => { - data.update(d => { - const { [key]: _, ...rest } = d; - return rest; - }); - - internal.update(i => { - const { [key]: _, ...rest } = i; - return rest; - }); - }, - - /** - * Clear all items from cache - */ - clear: () => { - data.set({}); - internal.set({}); - statsState.update(s => ({ ...s, hits: 0, misses: 0 })); - }, - - /** - * Check if key is currently being fetched - */ - isFetching: (key: string) => { - const currentInternal = get(internal); - return currentInternal[key]?.fetching === true; - }, - - /** - * Get error for a key - */ - getError: (key: string) => { - const currentInternal = get(internal); - return currentInternal[key]?.error; - }, - - /** - * Get internal state for debugging - */ - getInternalState: (key: string) => { - const currentInternal = get(internal); - return currentInternal[key]; - }, - - /** - * Get current cache statistics - */ - getStats: () => { - return get(stats); - }, - - /** - * Mark item as fetching (used when starting API request) - */ - markFetching: (key: string) => { - internal.update(internal => ({ - ...internal, - [key]: { - fetching: true, - ready: false, - error: undefined, - startTime: Date.now(), - endTime: undefined, - }, - })); - }, - - /** - * Mark item as failed (used when API request fails) - */ - markFailed: (key: string, error: string) => { - internal.update(internal => { - const existingState = internal[key]; - return { - ...internal, - [key]: { - fetching: false, - ready: false, - error, - startTime: existingState?.startTime, - endTime: Date.now(), - }, - }; - }); - - // Update statistics - const currentStats = get(stats); - statsState.update(s => ({ ...s, errors: currentStats.errors + 1 })); - }, - - /** - * Increment cache miss counter - */ - markMiss: () => { - statsState.update(s => ({ ...s, misses: s.misses + 1 })); - }, - - // Expose stores for reactive binding - data, - internal, - stats, - }; -} diff --git a/src/shared/lib/fetch/index.ts b/src/shared/lib/fetch/index.ts deleted file mode 100644 index b123ac0..0000000 --- a/src/shared/lib/fetch/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Shared fetch layer exports - * - * Exports collection caching utilities and reactive patterns for Svelte 5 - */ - -export { createCollectionCache } from './collectionCache'; -export type { - CacheItemInternalState, - CacheOptions, - CacheStats, - CollectionCacheManager, -} from './collectionCache'; -export { reactiveQueryArgs } from './reactiveQueryArgs'; diff --git a/src/shared/lib/fetch/reactiveQueryArgs.ts b/src/shared/lib/fetch/reactiveQueryArgs.ts deleted file mode 100644 index 4095ead..0000000 --- a/src/shared/lib/fetch/reactiveQueryArgs.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Readable } from 'svelte/store'; -import { writable } from 'svelte/store'; - -/** - * Creates a reactive store that maintains stable references for query arguments - * - * This function wraps a callback in a Svelte store that updates via `$effect.pre()`, - * ensuring that the callback is called before DOM updates while maintaining object - * reference stability. - * - * @typeParam T - Type of query arguments (e.g., CreateQueryOptions) - * @param cb - Callback function that computes query arguments - * @returns Readable store containing current query arguments - * - * @example - * ```ts - * const queryArgsStore = reactiveQueryArgs(() => ({ - * queryKey: ['fonts', search], - * queryFn: fetchFonts, - * staleTime: 5000 - * })); - * - * // Use in component with TanStack Query - * const query = createQuery(queryArgsStore); - * ``` - */ -export const reactiveQueryArgs = (cb: () => T): Readable => { - const store = writable(); - - // Use $effect.pre() to run before DOM updates - // This ensures stable references while staying reactive - $effect.pre(() => { - store.set(cb()); - }); - - return store; -};