UNPKG

@furystack/filesystem-store

Version:

Simple File System store implementation for FuryStack

150 lines 5.64 kB
import { InMemoryStore } from '@furystack/core'; import { EventHub } from '@furystack/utils'; import { promises, watch } from 'fs'; const DEFAULT_TICK_MS = 3000; /** * {@link PhysicalStore} backed by a JSON file on disk. * * Reads on construction, holds entities in an {@link InMemoryStore}, and flushes * pending writes on a `tickMs` interval (default 3000 ms). External edits to the * file are picked up via an `fs.watch` watcher that triggers {@link reloadData}. * * Disposal is async — `[Symbol.asyncDispose]` flushes pending changes, closes * the watcher, and clears the interval. Owners must `await` disposal or rely on * `await using` / {@link defineStore}'s `onDispose` hook to avoid lost writes. * * **Init race:** the constructor schedules the initial reload in the * background. Calls to `get` / `find` / `count` issued before the reload * resolves see an empty cache. Failures during the background reload (other * than `ENOENT`) are surfaced via `onLoadError` rather than thrown. * * Re-emits `onEntityAdded` / `onEntityUpdated` / `onEntityRemoved` from the * underlying in-memory store, plus `onWatcherError` (sync watcher setup * failure) and `onLoadError` (async file-load failure) for diagnostics. */ export class FileSystemStore extends EventHub { options; watcher; model; primaryKey; inMemoryStore; get cache() { return this.inMemoryStore.cache; } async remove(...keys) { await this.inMemoryStore.remove(...keys); this._hasChanges = true; } tick; _hasChanges = false; /** Whether the in-memory cache has unflushed mutations. Read-only externally. */ get hasChanges() { return this._hasChanges; } async get(key, select) { return await this.inMemoryStore.get(key, select); } async add(...entries) { const result = await this.inMemoryStore.add(...entries); this._hasChanges = true; return result; } async find(filter) { return this.inMemoryStore.find(filter); } async count(filter) { return this.inMemoryStore.count(filter); } /** * Writes the in-memory cache to disk if {@link hasChanges} is set. No-op * otherwise — the periodic tick calls this on every interval but only the * first call after a mutation actually touches the filesystem. */ async saveChanges() { if (!this.hasChanges) { return; } const values = []; for (const key of this.cache.keys()) { values.push(this.cache.get(key)); } await this.writeFile(this.options.fileName, JSON.stringify(values)); this._hasChanges = false; } /** * Flushes pending changes, closes the FS watcher and clears the tick interval. * Must be awaited — skipping `await` risks losing the final write. */ async [Symbol.asyncDispose]() { await this.saveChanges(); this.watcher?.close(); clearInterval(this.tick); super[Symbol.dispose](); } /** * Replaces the in-memory cache with the contents of the backing file. Called * on construction and on every FS watcher event. Missing file (`ENOENT`) is * silently ignored so first-run writes succeed against a fresh path. */ async reloadData() { try { const data = await this.readFile(this.options.fileName); const json = data ? JSON.parse(data.toString()) : []; this.cache.clear(); for (const entity of json) { this.cache.set(entity[this.primaryKey], entity); } } catch (err) { // ignore if file not exists yet if (err instanceof Error && err.code !== 'ENOENT') { throw err; } } } async update(id, data) { await this.inMemoryStore.update(id, data); this._hasChanges = true; } /** * Test seam — overridable to fault-inject the read path. Defaults to * `fs.promises.readFile`. Production code should not reassign. */ readFile = promises.readFile; /** * Test seam — overridable to fault-inject the write path. Defaults to * `fs.promises.writeFile`. Production code should not reassign. */ writeFile = promises.writeFile; constructor(options) { super(); this.options = options; this.primaryKey = options.primaryKey; this.model = options.model; this.inMemoryStore = new InMemoryStore({ model: this.model, primaryKey: this.primaryKey }); this.tick = setInterval(() => void this.saveChanges(), this.options.tickMs || DEFAULT_TICK_MS); this.inMemoryStore.subscribe('onEntityAdded', ({ entity }) => { this.emit('onEntityAdded', { entity }); }); this.inMemoryStore.subscribe('onEntityUpdated', ({ id, change }) => { this.emit('onEntityUpdated', { id, change }); }); this.inMemoryStore.subscribe('onEntityRemoved', ({ key }) => { this.emit('onEntityRemoved', { key }); }); void this.reloadData().catch((error) => { this.emit('onLoadError', { error }); }); try { this.watcher = watch(this.options.fileName, { encoding: 'buffer' }, () => { void this.reloadData().catch((error) => { this.emit('onLoadError', { error }); }); }); } catch (error) { this.emit('onWatcherError', { error }); } } } //# sourceMappingURL=filesystem-store.js.map