/** * Base Collection Module * * Provides shared functionality for all collection types to reduce code duplication. * This module extracts common patterns used across QueryCollection, SyncCollection, * and PGLiteCollection. */ import type { BaseRecord, SyncState } from '../types' /** * Configuration for subscriber management */ export interface SubscriberConfig { /** Maximum number of subscribers before warning (helps detect memory leaks) */ maxSubscribers?: number /** Whether to log warnings when subscriber threshold is exceeded */ warnOnThresholdExceeded?: boolean } /** * Default sync state for new collections */ export const DEFAULT_SYNC_STATE: SyncState = { connected: false, initialized: false, pendingCount: 0, } /** * Generate a unique ID with optional prefix * * @param prefix - Optional prefix for the ID (e.g., collection ID) * @returns A unique string ID */ export function generateUniqueId(prefix?: string): string { const timestamp = Date.now() const random = Math.random().toString(36).slice(2, 9) return prefix ? `${prefix}-${timestamp}-${random}` : `${timestamp}-${random}` } /** * Generate a mutation ID for tracking optimistic updates * * @param prefix - Optional prefix (defaults to 'mut') * @returns A unique mutation ID */ export function generateMutationId(prefix: string = 'mut'): string { return generateUniqueId(prefix) } /** * Subscriber management mixin * * Provides type-safe subscriber management with optional threshold warnings * to help detect memory leaks from components not unsubscribing. */ export class SubscriberManager { private subscribers: Set<(data: T) => void> = new Set() private config: SubscriberConfig private collectionId: string constructor(collectionId: string, config: SubscriberConfig = {}) { this.collectionId = collectionId this.config = { maxSubscribers: config.maxSubscribers ?? 100, warnOnThresholdExceeded: config.warnOnThresholdExceeded ?? true, } } /** * Add a subscriber * * @param callback - Function to call when data changes * @returns Unsubscribe function */ subscribe(callback: (data: T) => void): () => void { this.subscribers.add(callback) if ( this.config.warnOnThresholdExceeded && this.config.maxSubscribers && this.subscribers.size > this.config.maxSubscribers ) { console.warn( `[Collection:${this.collectionId}] Subscriber threshold exceeded (${this.subscribers.size}). ` + `This may indicate a memory leak. Ensure components properly unsubscribe on unmount.` ) } return () => { this.subscribers.delete(callback) } } /** * Notify all subscribers with new data * * @param data - Data to send to subscribers */ notify(data: T): void { for (const callback of this.subscribers) { try { callback(data) } catch (error) { console.error(`[Collection:${this.collectionId}] Subscriber callback error:`, error) } } } /** * Get current subscriber count */ getCount(): number { return this.subscribers.size } /** * Clear all subscribers */ clear(): void { this.subscribers.clear() } /** * Check if there are any active subscribers */ hasSubscribers(): boolean { return this.subscribers.size > 0 } /** * Detect orphaned subscriptions (for debugging) */ detectOrphans(): { count: number; warning: string } { const count = this.subscribers.size const warning = count > 0 ? `${count} active subscriber(s) detected. Ensure all subscriptions are properly cleaned up.` : 'No active subscribers.' return { count, warning } } } /** * Update sync state immutably, removing lastError if not provided * * @param current - Current sync state * @param updates - Partial updates to apply * @returns New sync state object */ export function updateSyncState( current: SyncState, updates: Partial & { clearError?: boolean } ): SyncState { const { clearError, ...rest } = updates if (clearError) { const { lastError: _unused, ...stateCopy } = current return { ...stateCopy, ...rest } } return { ...current, ...rest } } /** * Create a defensive copy of sync state for external consumers * * @param state - Internal sync state * @returns A copy safe for external use */ export function copySyncState(state: SyncState): SyncState { return { ...state } } /** * Type guard for checking if an ID is a string */ export function isStringId(id: string | number): id is string { return typeof id === 'string' } /** * Type guard for checking if an ID is a number */ export function isNumberId(id: string | number): id is number { return typeof id === 'number' } /** * Determine ID type from a collection of existing IDs * * @param existingIds - Array of existing IDs * @returns 'string' or 'number' based on the majority type */ export function inferIdType(existingIds: Array): 'string' | 'number' { if (existingIds.length === 0) return 'number' return typeof existingIds[0] === 'string' ? 'string' : 'number' } /** * Auto-increment ID generator for collections using numeric IDs */ export class NumericIdGenerator { private nextId: number = 1 /** * Get the next ID and increment the counter */ next(): number { return this.nextId++ } /** * Update the counter if a higher ID is encountered * * @param id - ID to check against current counter */ track(id: number): void { if (id >= this.nextId) { this.nextId = id + 1 } } /** * Reset the counter */ reset(): void { this.nextId = 1 } } /** * Timer manager for handling intervals and timeouts with proper cleanup */ export class TimerManager { private intervals: Set> = new Set() private timeouts: Set> = new Set() /** * Create a managed interval */ setInterval(callback: () => void, ms: number): ReturnType { const id = setInterval(callback, ms) this.intervals.add(id) return id } /** * Clear a managed interval */ clearInterval(id: ReturnType): void { clearInterval(id) this.intervals.delete(id) } /** * Create a managed timeout */ setTimeout(callback: () => void, ms: number): ReturnType { const id = setTimeout(() => { this.timeouts.delete(id) callback() }, ms) this.timeouts.add(id) return id } /** * Clear a managed timeout */ clearTimeout(id: ReturnType): void { clearTimeout(id) this.timeouts.delete(id) } /** * Get total count of active timers */ getActiveCount(): number { return this.intervals.size + this.timeouts.size } /** * Clear all managed timers */ clearAll(): void { for (const id of this.intervals) { clearInterval(id) } this.intervals.clear() for (const id of this.timeouts) { clearTimeout(id) } this.timeouts.clear() } } /** * Item store with optional LRU eviction support */ export class ItemStore { private items: Map = new Map() private accessOrder: Map = new Map() private accessCounter = 0 private maxSize: number | undefined private evictionPolicy: 'lru' | 'fifo' constructor(options: { maxSize?: number; evictionPolicy?: 'lru' | 'fifo' } = {}) { this.maxSize = options.maxSize ?? undefined this.evictionPolicy = options.evictionPolicy ?? 'lru' } /** * Get an item by ID, updating access order for LRU */ get(id: string | number): T | undefined { const item = this.items.get(id) if (item && this.evictionPolicy === 'lru') { this.accessOrder.set(id, ++this.accessCounter) } return item } /** * Set an item, evicting if necessary */ set(id: string | number, item: T): void { if (this.maxSize && !this.items.has(id) && this.items.size >= this.maxSize) { this.evictOne() } this.items.set(id, item) this.accessOrder.set(id, ++this.accessCounter) } /** * Delete an item */ delete(id: string | number): boolean { this.accessOrder.delete(id) return this.items.delete(id) } /** * Check if an item exists */ has(id: string | number): boolean { return this.items.has(id) } /** * Get all items as an array */ getAll(): T[] { return Array.from(this.items.values()) } /** * Get all IDs */ keys(): IterableIterator { return this.items.keys() } /** * Get the current size */ get size(): number { return this.items.size } /** * Clear all items */ clear(): void { this.items.clear() this.accessOrder.clear() this.accessCounter = 0 } /** * Evict one item based on the eviction policy */ private evictOne(): void { if (this.items.size === 0) return let idToEvict: string | number | undefined if (this.evictionPolicy === 'lru') { let oldestOrder = Infinity for (const [id, order] of this.accessOrder) { if (order < oldestOrder) { oldestOrder = order idToEvict = id } } } else { // FIFO - evict the first inserted item idToEvict = this.items.keys().next().value } if (idToEvict !== undefined) { this.items.delete(idToEvict) this.accessOrder.delete(idToEvict) } } }