feat(storage): json backend — one human-readable file per unit

Atomic whole-file replacement (same-dir temp + fsync + rename + parent
fsync); the in-memory unit state is authoritative and the file is always
the current net state, pretty-printed. Missing files open as empty units
and materialize on first write; foreign or unparsable files reject with
malformed-medium, stored-version drift with version-mismatch.
This commit is contained in:
imccyu
2026-07-24 19:07:07 +08:00
parent e90b0d51df
commit 1529be6fd4
9 changed files with 543 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
/**
* One opened JSON unit. The in-memory state is authoritative; every write
* primitive mutates it and republishes the whole file atomically. Writes are
* NOT queued here — per the backend contract, write ordering belongs to the
* caller (the domain layer's write chain); this unit only guarantees that
* each single call publishes a complete, durable file.
* @module @deepseek-ai/dsh-storage-json/src/unit
*/
import { readFile } from 'node:fs/promises'
import { StorageError } from '@deepseek-ai/dsh-storage'
import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
import { writeAtomic } from './atomic.ts'
import { parse, serialize } from './format.ts'
import type { UnitState } from './format.ts'
/** Open (load or lazily create) one unit backed by `path`. */
export async function openJsonUnit(
descriptor: KvUnitDescriptor,
path: string,
onClose: () => void,
): Promise<KvUnit> {
let text: string | undefined
try {
text = await readFile(path, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// Missing file = empty unit; materialization defers to the first write.
}
const state: UnitState =
text === undefined
? {
version: descriptor.version,
global: null,
tables: new Map(descriptor.tables.map((table) => [table, new Map()])),
}
: parse(text, descriptor)
return new JsonKvUnit(descriptor, path, state, onClose)
}
class JsonKvUnit implements KvUnit {
private closed = false
constructor(
private readonly descriptor: KvUnitDescriptor,
private readonly path: string,
private readonly state: UnitState,
private readonly onClose: () => void,
) {}
async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown | null }> {
this.assertOpen()
const tables: Record<string, Record<string, unknown>> = {}
for (const [table, records] of this.state.tables) {
tables[table] = Object.fromEntries(records)
}
return { tables, global: this.state.global }
}
async putRecord(table: string, key: string, value: unknown): Promise<void> {
this.assertOpen()
this.records(table).set(key, value)
await this.publish()
}
async deleteRecord(table: string, key: string): Promise<void> {
this.assertOpen()
if (this.records(table).delete(key)) {
await this.publish()
}
}
async setGlobal(value: unknown): Promise<void> {
this.assertOpen()
if (!this.descriptor.hasGlobal) {
throw new Error(`unit '${this.descriptor.name}' does not declare a global slot`)
}
this.state.global = value
await this.publish()
}
async close(): Promise<void> {
if (this.closed) return
this.closed = true
this.onClose()
}
private assertOpen(): void {
if (this.closed) {
throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`)
}
}
private records(table: string): Map<string, unknown> {
const records = this.state.tables.get(table)
if (!records) {
throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`)
}
return records
}
private publish(): Promise<void> {
return writeAtomic(this.path, serialize(this.descriptor.name, this.state))
}
}