/** * One opened JSON unit in `single` layout: the whole unit is one document at * `/.json`. The in-memory state is authoritative; every write * primitive mutates it and republishes the whole file atomically, including a * batch delete, which changes several records but publishes once. 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. The `per-record` * layout is a separate unit class in `per-record-unit.ts`. * @module @deepseek-ai/dsh-storage-json/src/single-unit */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' 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 `single`-layout unit under `root`: the * unit file is `/.json`. * @param descriptor - Static identity and shape of the unit. * @param root - Absolute backend root directory. * @param onClose - Backend callback releasing the unit's open-slot. * @returns the opened unit. */ export async function openSingleUnit( descriptor: KvUnitDescriptor, root: string, onClose: () => void, ): Promise { const path = join(root, `${descriptor.name}.json`) 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) // Opening leaves the file untouched; the next atomic publish uses the current stamp. state.version = descriptor.version return new SingleJsonUnit(descriptor, path, state, onClose) } class SingleJsonUnit implements KvUnit { private closed = false /** In-flight publishes; close() drains them before releasing the unit. */ private readonly inFlight = new Set>() constructor( private readonly descriptor: KvUnitDescriptor, private readonly path: string, private readonly state: UnitState, private readonly onClose: () => void, ) {} // oxlint-disable-next-line typescript/require-await -- async keeps the closed guard a rejection, not a synchronous throw async loadAll(): Promise<{ tables: Record>; global: unknown }> { this.assertOpen() const tables: Record> = {} 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 { this.assertOpen() const records = this.records(table) const hadKey = records.has(key) const previous = records.get(key) records.set(key, value) // Roll back on a failed publish: memory is authoritative, so a rejected // write must not survive in memory (or ride along with the next publish). await this.publish().catch((error: unknown) => { if (hadKey) records.set(key, previous) else records.delete(key) throw error }) } deleteRecord(table: string, key: string): Promise { return this.deleteRecords(table, [key]) } async deleteRecords(table: string, keys: readonly string[]): Promise { this.assertOpen() const records = this.records(table) const removed = new Map() for (const key of new Set(keys)) { if (!records.has(key)) continue removed.set(key, records.get(key)) records.delete(key) } if (removed.size === 0) return // One atomic publish applies the whole batch; a failed publish restores // every removed entry so memory and the medium stay together. await this.publish().catch((error: unknown) => { for (const [key, value] of removed) records.set(key, value) throw error }) } async setGlobal(value: unknown): Promise { this.assertOpen() if (!this.descriptor.hasGlobal) { throw new Error(`unit '${this.descriptor.name}' does not declare a global slot`) } const previous = this.state.global this.state.global = value await this.publish().catch((error: unknown) => { this.state.global = previous throw error }) } /* jscpd:ignore-start -- the two unit classes are standalone; the drain/guard lifecycle mirrors the shared KvUnit contract */ async close(): Promise { if (this.closed) { await Promise.allSettled(this.inFlight) return } this.closed = true await Promise.allSettled(this.inFlight) this.onClose() } private assertOpen(): void { if (this.closed) { throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`) } } /* jscpd:ignore-end */ private records(table: string): Map { const records = this.state.tables.get(table) if (!records) { throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`) } return records } private async publish(): Promise { const write = writeAtomic(this.path, serialize(this.descriptor.name, this.state)) this.inFlight.add(write) // Swallow only on the tracking branch: the caller still awaits `write` // itself, so rejections stay observed exactly once. write.catch(() => {}).finally(() => this.inFlight.delete(write)) return write } }