@furystack/filesystem-store
Version:
Simple File System store implementation for FuryStack
184 lines (164 loc) • 6.25 kB
text/typescript
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 })
}
}
}