Files
deepseek-harness/packages/storage/storage-json/src/index.ts
T
_Kerman 501f387b46 feat(storage): add the per-record layout to the json backend
The json backend now serves two layouts. single (the default) keeps the
whole unit as one document at <root>/<name>.json; per-record keeps one
version-stamped document per record at <root>/<name>/<table>/<key>.json
(plus global.json), so one write rewrites one record instead of the whole
unit. The per-record unit is stateless — the directory is the state,
loadAll re-reads the tree, and every write is a single durable file
operation — while single keeps its authoritative in-memory state and
whole-file publish. Records keys must be path-safe ([a-zA-Z0-9_-]+);
an unsafe key rejects. A record document that is malformed or stamped
with another version reads as an absent record: one bad or stale file
never bricks the unit, and a version bump discards stale records instead
of migrating them. DomainSpec and KvUnitDescriptor gain the optional
layout field (defineDomain validates it, descriptorOf projects it).
2026-08-20 13:47:39 +08:00

120 lines
4.4 KiB
TypeScript

/**
* JSON storage backend: one human-readable document per unit under a
* configured root — a whole-unit file (`single` layout) or one document per
* record (`per-record` layout), published by atomic rewrite. Registers as
* backend `json` on the storage hub.
* @module @deepseek-ai/dsh-storage-json
*/
import { mkdir } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
import { openSingleUnit } from './single-unit.ts'
import { openPerRecordUnit } from './per-record-unit.ts'
/** Cordis plugin name. */
export const name = 'storage-json'
/** The hub must exist before the backend can register. */
export const inject = ['storage']
/**
* Plugin configuration.
* `root` has NO default on purpose: a `process.cwd()` fallback would scatter
* unit files wherever the process happens to start; assemblies state the
* location explicitly.
*/
export interface Config {
/** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
root: string
}
/** Config schema. */
export const Config: z<Config> = z.object({
root: z.string().required(),
})
/** JSON backend: owns the file-tree root and serves the `kv` facet. */
export class JsonStorageBackend implements StorageBackend {
private readonly open = new Map<string, KvUnit>()
// Reserved synchronously at open() entry so a concurrent open of the same
// unit fails, and close() can await opens still in flight.
private readonly opening = new Map<string, Promise<KvUnit>>()
private closed = false
constructor(private readonly root: string) {}
readonly kv: KvFacet = {
// The body up to the first await runs synchronously, so the opening-slot
// reservation below still excludes a concurrent open of the same unit.
open: async (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
if (this.closed) throw new StorageError('closed', 'json backend is closed')
validateDescriptor(descriptor)
if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) {
// Double-open is a caller bug, not a medium condition.
throw new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`)
}
const opening = this.openUnit(descriptor)
this.opening.set(descriptor.name, opening)
return opening.finally(() => this.opening.delete(descriptor.name))
},
}
private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
await mkdir(this.root, { recursive: true, mode: 0o700 })
// The two layouts differ in medium shape only; each opener owns its own
// path convention under the shared root.
const onClose = () => this.open.delete(descriptor.name)
const unit = descriptor.layout === 'per-record'
? await openPerRecordUnit(descriptor, this.root, onClose)
: await openSingleUnit(descriptor, this.root, onClose)
if (this.closed) {
// The backend closed while this open was in flight: do not hand out a
// live unit past close().
await unit.close()
throw new StorageError('closed', 'json backend is closed')
}
this.open.set(descriptor.name, unit)
return unit
}
async close(): Promise<void> {
if (!this.closed) {
this.closed = true
}
await Promise.allSettled([...this.opening.values()])
for (const unit of [...this.open.values()]) {
await unit.close()
}
}
}
function validateDescriptor(descriptor: KvUnitDescriptor): void {
if (!UNIT_NAME_RE.test(descriptor.name)) {
throw new StorageError('malformed-medium', `invalid unit name '${descriptor.name}'`)
}
for (const table of descriptor.tables) {
if (!UNIT_NAME_RE.test(table)) {
throw new StorageError('malformed-medium', `invalid table name '${table}' in unit '${descriptor.name}'`)
}
}
}
/**
* Register the `json` backend on the storage hub.
* @param ctx - Plugin context.
* @param config - Validated configuration.
*/
export function apply(ctx: Context, config: Config) {
const backend = new JsonStorageBackend(config.root)
ctx.effect(() => {
const unregister = ctx.storage.backend.register('json', backend)
return async () => {
unregister()
await backend.close()
}
})
ctx.provide(storageBackendServiceKey('json'), backend)
}