Local Storage

This commit is contained in:
2026-02-25 13:32:37 +11:00
parent 0e32d4eb64
commit 648f186903
2 changed files with 281 additions and 0 deletions

View File

@@ -1,3 +1,4 @@
export * from './storage-memory.js'; export * from './storage-memory.js';
export * from './storage-memory-synced.js'; export * from './storage-memory-synced.js';
export * from './storage-localstorage.js';
export * from './encrypted-storage.js'; export * from './encrypted-storage.js';

View File

@@ -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<string, any> = Record<string, any>,
> extends BaseStorage<T> {
/**
* Convenience factory that mirrors the pattern used by StorageMemory.
*/
static from<T extends Record<string, any>>(
prefix = 'qs',
): StorageLocalStorage<T> {
return new StorageLocalStorage<T>(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<string>;
/** Map of derived child instances, lazily created and cached. */
private children: Map<string, StorageLocalStorage<any>>;
constructor(private readonly prefix: string = 'qs') {
super();
this.children = new Map();
this.manifest = this.loadManifest();
}
// ---------------------------------------------------------------------------
// Abstract method implementations
// ---------------------------------------------------------------------------
async insertMany(documents: Array<T>): Promise<void> {
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<T>, options?: FindOptions): Promise<T[]> {
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<T>,
update: Partial<T>,
options: Partial<FindOptions> = {},
): Promise<number> {
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<T>,
options: Partial<FindOptions> = {},
): Promise<number> {
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<C>(path: string): BaseStorage<C> {
if (!this.children.has(path)) {
const childPrefix = `${this.prefix}${KEY_SEP}${path}`;
this.children.set(path, new StorageLocalStorage<C>(childPrefix));
}
return this.children.get(path) as StorageLocalStorage<C>;
}
// ---------------------------------------------------------------------------
// 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<T>): 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<string, 1 | -1>,
): 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<string> {
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]),
);
}
}