From 648f186903a6322adeafe430c55b8e5c49f79dd6 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Wed, 25 Feb 2026 13:32:37 +1100 Subject: [PATCH] Local Storage --- src/storage/index.ts | 1 + src/storage/storage-localstorage.ts | 280 ++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) diff --git a/src/storage/index.ts b/src/storage/index.ts index eba1446..d8a660e 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,3 +1,4 @@ export * from './storage-memory.js'; export * from './storage-memory-synced.js'; +export * from './storage-localstorage.js'; export * from './encrypted-storage.js'; \ No newline at end of file diff --git a/src/storage/storage-localstorage.ts b/src/storage/storage-localstorage.ts index e69de29..373a9c5 100644 --- a/src/storage/storage-localstorage.ts +++ b/src/storage/storage-localstorage.ts @@ -0,0 +1,280 @@ +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]), + ); + } +}