281 lines
7.6 KiB
TypeScript
281 lines
7.6 KiB
TypeScript
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]),
|
|
);
|
|
}
|
|
}
|