import { BaseStorage, FindOptions } from './base-storage.js'; /** * Key prefix separator used to namespace documents within localStorage. */ const KEY_SEP = ':'; /** * Suffix appended to the namespace prefix to form the manifest key. * The manifest tracks all document IDs belonging to this storage instance, * avoiding expensive full-scan iterations over the entire localStorage. */ const MANIFEST_SUFFIX = '__keys__'; /** * Implementation of BaseStorage using the browser's localStorage API. * * @remarks * Documents are stored individually in localStorage, keyed by a namespaced * prefix combined with the document's `id` field. A separate manifest key * tracks all known document IDs so that read operations avoid scanning every * key in localStorage. * * Because localStorage is synchronous and string-only, all values are * JSON-serialised on write and parsed on read. */ export class StorageLocalStorage< T extends Record = Record, > extends BaseStorage { /** * Convenience factory that mirrors the pattern used by StorageMemory. */ static from>( prefix = 'qs', ): StorageLocalStorage { return new StorageLocalStorage(prefix); } /** * In-memory copy of the document ID set. * Re-read from localStorage at the start of every public method so that * cross-tab mutations are always visible. */ private manifest: Set; /** Map of derived child instances, lazily created and cached. */ private children: Map>; constructor(private readonly prefix: string = 'qs') { super(); this.children = new Map(); this.manifest = this.loadManifest(); } // --------------------------------------------------------------------------- // Abstract method implementations // --------------------------------------------------------------------------- async insertMany(documents: Array): Promise { this.refreshManifest(); for (const document of documents) { const key = this.docKey(document.id); localStorage.setItem(key, JSON.stringify(document)); this.manifest.add(document.id); this.emit('insert', { value: document }); } this.persistManifest(); } async find(filter?: Partial, options?: FindOptions): Promise { this.refreshManifest(); let results: T[] = []; for (const id of this.manifest) { const raw = localStorage.getItem(this.docKey(id)); if (raw === null) continue; const doc = JSON.parse(raw) as T; if (this.matchesFilter(doc, filter)) { results.push(doc); } } // Apply sort before skip/limit so the window is deterministic. if (options?.sort) { results = this.applySorting(results, options.sort); } const startIndex = options?.skip ?? 0; const endIndex = options?.limit ? startIndex + options.limit : results.length; return results.slice(startIndex, endIndex); } async updateMany( filter: Partial, update: Partial, options: Partial = {}, ): Promise { this.refreshManifest(); const candidates: T[] = []; for (const id of this.manifest) { const raw = localStorage.getItem(this.docKey(id)); if (raw === null) continue; const doc = JSON.parse(raw) as T; if (this.matchesFilter(doc, filter)) { candidates.push(doc); } } const startIndex = options.skip ?? 0; const endIndex = options.limit ? startIndex + options.limit : candidates.length; const toProcess = candidates.slice(startIndex, endIndex); for (const doc of toProcess) { const updatedDoc = { ...doc, ...update } as T; localStorage.setItem(this.docKey(doc.id), JSON.stringify(updatedDoc)); // If the id itself changed, update the manifest accordingly. if (update.id !== undefined && update.id !== doc.id) { localStorage.removeItem(this.docKey(doc.id)); this.manifest.delete(doc.id); this.manifest.add(updatedDoc.id); } this.emit('update', { value: updatedDoc }); } if (toProcess.length > 0) { this.persistManifest(); } return toProcess.length; } async deleteMany( filter: Partial, options: Partial = {}, ): Promise { this.refreshManifest(); const candidates: T[] = []; for (const id of this.manifest) { const raw = localStorage.getItem(this.docKey(id)); if (raw === null) continue; const doc = JSON.parse(raw) as T; if (this.matchesFilter(doc, filter)) { candidates.push(doc); } } const startIndex = options.skip ?? 0; const endIndex = options.limit ? startIndex + options.limit : candidates.length; const toProcess = candidates.slice(startIndex, endIndex); for (const doc of toProcess) { localStorage.removeItem(this.docKey(doc.id)); this.manifest.delete(doc.id); this.emit('delete', { value: doc }); } if (toProcess.length > 0) { this.persistManifest(); } return toProcess.length; } deriveChild(path: string): BaseStorage { if (!this.children.has(path)) { const childPrefix = `${this.prefix}${KEY_SEP}${path}`; this.children.set(path, new StorageLocalStorage(childPrefix)); } return this.children.get(path) as StorageLocalStorage; } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- /** * Checks whether a document satisfies every field in the filter. * An empty or undefined filter matches everything. */ private matchesFilter(item: T, filter?: Partial): boolean { if (!filter || Object.keys(filter).length === 0) { return true; } for (const [key, value] of Object.entries(filter)) { if (item[key] !== value) { return false; } } return true; } /** * Sort an array of documents according to a sort specification. * Keys map to `1` (ascending) or `-1` (descending). */ private applySorting( items: T[], sort: Record, ): T[] { const sortEntries = Object.entries(sort); return [...items].sort((a, b) => { for (const [key, direction] of sortEntries) { if (a[key] < b[key]) return -1 * direction; if (a[key] > b[key]) return 1 * direction; } return 0; }); } /** * Build the full localStorage key for a given document ID. */ private docKey(id: string): string { return `${this.prefix}${KEY_SEP}${id}`; } /** * Build the localStorage key used to persist the manifest. */ private manifestKey(): string { return `${this.prefix}${KEY_SEP}${MANIFEST_SUFFIX}`; } /** * Re-read the manifest from localStorage into memory. * Called at the start of every public method so that cross-tab writes * are always reflected before we operate. */ private refreshManifest(): void { this.manifest = this.loadManifest(); } /** * Load the manifest (set of document IDs) from localStorage. * Falls back to an empty set if nothing is stored yet. */ private loadManifest(): Set { const raw = localStorage.getItem(this.manifestKey()); if (raw === null) return new Set(); try { const ids: string[] = JSON.parse(raw); return new Set(ids); } catch { return new Set(); } } /** * Persist the current in-memory manifest to localStorage. */ private persistManifest(): void { localStorage.setItem( this.manifestKey(), JSON.stringify([...this.manifest]), ); } }