mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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).
120 lines
4.4 KiB
TypeScript
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)
|
|
}
|