@furystack/filesystem-store
Version:
Simple File System store implementation for FuryStack
150 lines • 5.64 kB
JavaScript
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