UNPKG

@furystack/filesystem-store

Version:

Simple File System store implementation for FuryStack

184 lines (164 loc) 6.25 kB
import type { Constructable, defineStore, FilterType, FindOptions, PhysicalStore } from '@furystack/core' import { InMemoryStore } from '@furystack/core' import { EventHub, type ListenerErrorPayload } from '@furystack/utils' import type { FSWatcher } from 'fs' 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<T, TPrimaryKey extends keyof T> extends EventHub<{ onEntityAdded: { entity: T } onEntityUpdated: { id: T[TPrimaryKey]; change: Partial<T> } onEntityRemoved: { key: T[TPrimaryKey] } onWatcherError: { error: unknown } onLoadError: { error: unknown } onListenerError: ListenerErrorPayload }> implements PhysicalStore<T, TPrimaryKey, T> { private readonly watcher?: FSWatcher public readonly model: Constructable<T> public readonly primaryKey: TPrimaryKey private readonly inMemoryStore: InMemoryStore<T, TPrimaryKey> private get cache() { return this.inMemoryStore.cache } public async remove(...keys: Array<T[TPrimaryKey]>): Promise<void> { await this.inMemoryStore.remove(...keys) this._hasChanges = true } private tick: ReturnType<typeof setInterval> private _hasChanges = false /** Whether the in-memory cache has unflushed mutations. Read-only externally. */ public get hasChanges(): boolean { return this._hasChanges } public async get(key: T[TPrimaryKey], select?: Array<keyof T>) { return await this.inMemoryStore.get(key, select) } public async add(...entries: T[]) { const result = await this.inMemoryStore.add(...entries) this._hasChanges = true return result } public async find<TFields extends Array<keyof T>>(filter: FindOptions<T, TFields>) { return this.inMemoryStore.find(filter) } public async count(filter?: FilterType<T>) { 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. */ public async saveChanges() { if (!this.hasChanges) { return } const values: T[] = [] for (const key of this.cache.keys()) { values.push(this.cache.get(key) as T) } 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. */ public 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. */ public async reloadData() { try { const data = await this.readFile(this.options.fileName) const json = data ? (JSON.parse(data.toString()) as T[]) : [] 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 as { code?: string }).code !== 'ENOENT') { throw err } } } public async update(id: T[TPrimaryKey], data: T) { 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. */ public readFile = promises.readFile /** * Test seam — overridable to fault-inject the write path. Defaults to * `fs.promises.writeFile`. Production code should not reassign. */ public writeFile = promises.writeFile constructor( private readonly options: { fileName: string primaryKey: TPrimaryKey tickMs?: number model: Constructable<T> }, ) { super() 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: unknown) => { this.emit('onLoadError', { error }) }) try { this.watcher = watch(this.options.fileName, { encoding: 'buffer' }, () => { void this.reloadData().catch((error: unknown) => { this.emit('onLoadError', { error }) }) }) } catch (error) { this.emit('onWatcherError', { error }) } } }