Local Storage
This commit is contained in:
@@ -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]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user