@deepseek-ai/dsh-storage-json 0.0.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +38 -0
- package/README.zh.md +38 -0
- package/lib/index.js +297 -0
- package/lib/invariant.js +25 -0
- package/lib/types/atomic.d.ts +20 -0
- package/lib/types/format.d.ts +28 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/unit.d.ts +18 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, DeepSeek
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
+
# after editing either side, bring the other along and re-record with:
|
|
4
|
+
# pnpm run verify-translation-pairing --write packages/storage/storage-json/README.md
|
|
5
|
+
README.md: c3417846a70f8a227a19a5abf834156d530e3597
|
|
6
|
+
README.zh.md: e66c711dca838620eb9c90a3edacd2639970505e
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-storage-json
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
JSON backend for the [storage hub](../storage/README.md): one human-readable `<unit>.json` file per unit under a configured root, registered as backend `json`. Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
|
6
|
+
|
|
7
|
+
## Model
|
|
8
|
+
|
|
9
|
+
- The in-memory unit state is authoritative; every write primitive republishes the whole file via temp-write + fsync + atomic `rename()` replace. A unit file is always the complete current net state — legibility is this backend's reason to exist; scale is the SQLite backend's job.
|
|
10
|
+
- A missing file opens as an empty unit and materializes on the first write. A foreign or unparsable file rejects with `malformed-medium`; a stored version differing from the descriptor rejects with `version-mismatch` (no migration, pre-release stance).
|
|
11
|
+
- Write ordering across calls belongs to the caller (the domain layer's write chain); each single call is atomic and durable once resolved.
|
|
12
|
+
|
|
13
|
+
## Config
|
|
14
|
+
|
|
15
|
+
| Key | Type | Default | Meaning |
|
|
16
|
+
| --- | --- | --- | --- |
|
|
17
|
+
| `root` | string | required — no default (a cwd fallback would scatter files) | Directory holding unit files; created `0o700` on demand |
|
|
18
|
+
|
|
19
|
+
## Model Experience
|
|
20
|
+
|
|
21
|
+
### Stored domain records
|
|
22
|
+
|
|
23
|
+
#### What the model sees
|
|
24
|
+
|
|
25
|
+
Nothing. This backend contributes no prompt, tool, or schema; it persists non-session domain data behind `ctx.storage` for host-side consumers only.
|
|
26
|
+
|
|
27
|
+
#### Token effect
|
|
28
|
+
|
|
29
|
+
Zero live-request tokens.
|
|
30
|
+
|
|
31
|
+
#### KV Cache effect
|
|
32
|
+
|
|
33
|
+
None — the backend never touches live request prefixes.
|
|
34
|
+
|
|
35
|
+
## Known Limitations and Deferred Work
|
|
36
|
+
|
|
37
|
+
- Windows durability relies on libuv's `rename()` (`MoveFileExW` with replacement) without an explicit write-through flag; the session-log backend's stricter Win32 write-through publish helper is planned to move down here when the append-log facet lands (see the Agent Note's migration section).
|
|
38
|
+
- No cross-process write locking: two processes writing the same root can interleave whole-file replacements (last write wins). Single-host-process deployments are the current consumer; the multi-process story is deferred per the Agent Note's out-of-scope table.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @deepseek-ai/dsh-storage-json
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
[存储中心](../storage/README.md)的 JSON 后端:配置根目录下每个单元使用一个人类可读的 `<unit>.json` 文件,注册为后端 `json`。设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
|
|
6
|
+
|
|
7
|
+
## 模型
|
|
8
|
+
|
|
9
|
+
- 内存中的单元状态具有最终决定权;每个写入原语都会通过临时文件写入 + fsync + 原子 `rename()` 替换重新发布整个文件。单元文件始终是完整的当前状态:可读性是该后端存在的理由,规模问题则属于 SQLite 后端。
|
|
10
|
+
- 缺失文件会作为空单元打开,并在第一次写入时物化。外来或无法解析的文件以 `malformed-medium` 拒绝;已存版本与描述符不同时以 `version-mismatch` 拒绝(预发布立场,不迁移)。
|
|
11
|
+
- 跨调用的写入顺序属于调用方(领域层的写入链);每次调用都具备原子性,并在完成时已达到持久状态。
|
|
12
|
+
|
|
13
|
+
## 配置
|
|
14
|
+
|
|
15
|
+
| Key | 类型 | 默认值 | 含义 |
|
|
16
|
+
| --- | --- | --- | --- |
|
|
17
|
+
| `root` | string | 必填,无默认值(cwd 回退会让文件散落各处) | 保存单元文件的目录;按需以 `0o700` 创建 |
|
|
18
|
+
|
|
19
|
+
## 模型体验
|
|
20
|
+
|
|
21
|
+
### 已存领域记录
|
|
22
|
+
|
|
23
|
+
#### 模型看到的内容
|
|
24
|
+
|
|
25
|
+
无。该后端不贡献提示词、工具或 schema;它在 `ctx.storage` 后面持久化非会话领域数据,只供宿主侧消费方使用。
|
|
26
|
+
|
|
27
|
+
#### Token 影响
|
|
28
|
+
|
|
29
|
+
实时请求 token 为零。
|
|
30
|
+
|
|
31
|
+
#### KV Cache 影响
|
|
32
|
+
|
|
33
|
+
无:该后端从不触碰实时请求前缀。
|
|
34
|
+
|
|
35
|
+
## 已知限制与暂缓事项
|
|
36
|
+
|
|
37
|
+
- Windows 持久性依赖 libuv 的 `rename()`(调用 `MoveFileExW` 并启用替换),没有显式 write-through 标志;追加日志分面落地时,计划把会话日志后端更严格的 Win32 write-through 发布辅助函数下移到此处(见 Agent Note 的迁移章节)。
|
|
38
|
+
- 没有跨进程写锁:两个进程写入同一根目录时,可能交错执行整文件替换(最后写入者胜出)。当前消费方采用单一宿主进程部署;多进程方案按 Agent Note 的范围外事项表暂缓。
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from "@deepseek-ai/dsh-storage";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
//#region lib/types/atomic.js
|
|
7
|
+
/**
|
|
8
|
+
* Atomic whole-file replacement for the JSON backend.
|
|
9
|
+
*
|
|
10
|
+
* Publish protocol: write a same-directory temp file, fsync it, then
|
|
11
|
+
* `rename()` over the target. Rename is an atomic replace on POSIX and on
|
|
12
|
+
* Windows (libuv maps it to `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`),
|
|
13
|
+
* and replacement is the intended semantic here — unlike the session-log
|
|
14
|
+
* backend's link()+unlink() no-clobber protocol, a unit file has exactly one
|
|
15
|
+
* writer per process and last-write-wins is correct. After the rename the
|
|
16
|
+
* parent directory is fsynced on POSIX so the new entry is crash-durable.
|
|
17
|
+
* @module @deepseek-ai/dsh-storage-json/src/atomic
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Durably replace `path` with `data`.
|
|
21
|
+
* @param path - Absolute target file path.
|
|
22
|
+
* @param data - Full new file content.
|
|
23
|
+
* @returns resolution after the replacement is crash-durable.
|
|
24
|
+
*/
|
|
25
|
+
async function writeAtomic(path, data) {
|
|
26
|
+
const tmp = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
27
|
+
try {
|
|
28
|
+
const handle = await open(tmp, "wx", 384);
|
|
29
|
+
try {
|
|
30
|
+
await handle.writeFile(data, "utf8");
|
|
31
|
+
await handle.sync();
|
|
32
|
+
} finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
await rename(tmp, path);
|
|
36
|
+
await fsyncDirectory(dirname(path));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
await rm(tmp, { force: true });
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** fsync a POSIX directory so a just-renamed entry is crash-durable. */
|
|
43
|
+
/* v8 ignore start -- Windows rejects O_RDONLY directory opens; POSIX coverage exercises this. */
|
|
44
|
+
async function fsyncDirectory(path) {
|
|
45
|
+
if (process.platform === "win32") return;
|
|
46
|
+
const handle = await open(path, "r");
|
|
47
|
+
try {
|
|
48
|
+
await handle.sync();
|
|
49
|
+
} finally {
|
|
50
|
+
await handle.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/* v8 ignore stop */
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region lib/types/format.js
|
|
56
|
+
/**
|
|
57
|
+
* On-disk JSON unit format: the file is always the current net state, kept
|
|
58
|
+
* human-readable (pretty-printed, stable key order from insertion) — that
|
|
59
|
+
* legibility is this backend's reason to exist.
|
|
60
|
+
* @module @deepseek-ai/dsh-storage-json/src/format
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* Serialize a unit state to file content.
|
|
64
|
+
* @param name - Unit name, stamped into the header.
|
|
65
|
+
* @param state - Authoritative in-memory state.
|
|
66
|
+
* @returns pretty-printed JSON document with a trailing newline.
|
|
67
|
+
*/
|
|
68
|
+
function serialize(name, state) {
|
|
69
|
+
const tables = {};
|
|
70
|
+
for (const [table, records] of state.tables) tables[table] = Object.fromEntries(records);
|
|
71
|
+
const document = {
|
|
72
|
+
unit: {
|
|
73
|
+
name,
|
|
74
|
+
version: state.version
|
|
75
|
+
},
|
|
76
|
+
global: state.global,
|
|
77
|
+
tables
|
|
78
|
+
};
|
|
79
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Parse file content into unit state, validating shape and version.
|
|
83
|
+
* @param text - Raw file content.
|
|
84
|
+
* @param descriptor - Expected identity; version mismatch rejects.
|
|
85
|
+
* @returns the parsed state.
|
|
86
|
+
*/
|
|
87
|
+
function parse(text, descriptor) {
|
|
88
|
+
let document;
|
|
89
|
+
try {
|
|
90
|
+
document = JSON.parse(text);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new StorageError("malformed-medium", `unit '${descriptor.name}': file is not valid JSON`, { cause: error });
|
|
93
|
+
}
|
|
94
|
+
if (typeof document !== "object" || document === null) throw new StorageError("malformed-medium", `unit '${descriptor.name}': file is not a JSON object`);
|
|
95
|
+
const { unit, global: globalValue, tables } = document;
|
|
96
|
+
if (typeof unit !== "object" || unit === null || unit["name"] !== descriptor.name || typeof unit["version"] !== "number") throw new StorageError("malformed-medium", `unit '${descriptor.name}': missing or foreign unit header`);
|
|
97
|
+
const version = unit["version"];
|
|
98
|
+
if (version !== descriptor.version) throw new StorageError("version-mismatch", `unit '${descriptor.name}': stored version ${version} != expected ${descriptor.version}`);
|
|
99
|
+
if (typeof tables !== "object" || tables === null) throw new StorageError("malformed-medium", `unit '${descriptor.name}': tables is not an object`);
|
|
100
|
+
const state = {
|
|
101
|
+
version,
|
|
102
|
+
global: globalValue ?? null,
|
|
103
|
+
tables: /* @__PURE__ */ new Map()
|
|
104
|
+
};
|
|
105
|
+
for (const table of descriptor.tables) {
|
|
106
|
+
const records = tables[table];
|
|
107
|
+
if (records === void 0) {
|
|
108
|
+
state.tables.set(table, /* @__PURE__ */ new Map());
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (typeof records !== "object" || records === null || Array.isArray(records)) throw new StorageError("malformed-medium", `unit '${descriptor.name}': table '${table}' is not an object`);
|
|
112
|
+
state.tables.set(table, new Map(Object.entries(records)));
|
|
113
|
+
}
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region lib/types/unit.js
|
|
118
|
+
/**
|
|
119
|
+
* One opened JSON unit. The in-memory state is authoritative; every write
|
|
120
|
+
* primitive mutates it and republishes the whole file atomically. Writes are
|
|
121
|
+
* NOT queued here — per the backend contract, write ordering belongs to the
|
|
122
|
+
* caller (the domain layer's write chain); this unit only guarantees that
|
|
123
|
+
* each single call publishes a complete, durable file.
|
|
124
|
+
* @module @deepseek-ai/dsh-storage-json/src/unit
|
|
125
|
+
*/
|
|
126
|
+
/**
|
|
127
|
+
* Open (load or lazily create) one unit backed by `path`.
|
|
128
|
+
* @param descriptor - Static identity and shape of the unit.
|
|
129
|
+
* @param path - Absolute unit file path under the backend root.
|
|
130
|
+
* @param onClose - Backend callback releasing the unit's open-slot.
|
|
131
|
+
* @returns the opened unit.
|
|
132
|
+
*/
|
|
133
|
+
async function openJsonUnit(descriptor, path, onClose) {
|
|
134
|
+
let text;
|
|
135
|
+
try {
|
|
136
|
+
text = await readFile(path, "utf8");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error.code !== "ENOENT") throw error;
|
|
139
|
+
}
|
|
140
|
+
return new JsonKvUnit(descriptor, path, text === void 0 ? {
|
|
141
|
+
version: descriptor.version,
|
|
142
|
+
global: null,
|
|
143
|
+
tables: new Map(descriptor.tables.map((table) => [table, /* @__PURE__ */ new Map()]))
|
|
144
|
+
} : parse(text, descriptor), onClose);
|
|
145
|
+
}
|
|
146
|
+
var JsonKvUnit = class {
|
|
147
|
+
descriptor;
|
|
148
|
+
path;
|
|
149
|
+
state;
|
|
150
|
+
onClose;
|
|
151
|
+
closed = false;
|
|
152
|
+
/** In-flight publishes; close() drains them before releasing the unit. */
|
|
153
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
154
|
+
constructor(descriptor, path, state, onClose) {
|
|
155
|
+
this.descriptor = descriptor;
|
|
156
|
+
this.path = path;
|
|
157
|
+
this.state = state;
|
|
158
|
+
this.onClose = onClose;
|
|
159
|
+
}
|
|
160
|
+
async loadAll() {
|
|
161
|
+
this.assertOpen();
|
|
162
|
+
const tables = {};
|
|
163
|
+
for (const [table, records] of this.state.tables) tables[table] = Object.fromEntries(records);
|
|
164
|
+
return {
|
|
165
|
+
tables,
|
|
166
|
+
global: this.state.global
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async putRecord(table, key, value) {
|
|
170
|
+
this.assertOpen();
|
|
171
|
+
const records = this.records(table);
|
|
172
|
+
const hadKey = records.has(key);
|
|
173
|
+
const previous = records.get(key);
|
|
174
|
+
records.set(key, value);
|
|
175
|
+
await this.publish().catch((error) => {
|
|
176
|
+
if (hadKey) records.set(key, previous);
|
|
177
|
+
else records.delete(key);
|
|
178
|
+
throw error;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async deleteRecord(table, key) {
|
|
182
|
+
this.assertOpen();
|
|
183
|
+
const records = this.records(table);
|
|
184
|
+
if (!records.has(key)) return;
|
|
185
|
+
const previous = records.get(key);
|
|
186
|
+
records.delete(key);
|
|
187
|
+
await this.publish().catch((error) => {
|
|
188
|
+
records.set(key, previous);
|
|
189
|
+
throw error;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async setGlobal(value) {
|
|
193
|
+
this.assertOpen();
|
|
194
|
+
if (!this.descriptor.hasGlobal) throw new Error(`unit '${this.descriptor.name}' does not declare a global slot`);
|
|
195
|
+
const previous = this.state.global;
|
|
196
|
+
this.state.global = value;
|
|
197
|
+
await this.publish().catch((error) => {
|
|
198
|
+
this.state.global = previous;
|
|
199
|
+
throw error;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async close() {
|
|
203
|
+
if (this.closed) {
|
|
204
|
+
await Promise.allSettled(this.inFlight);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.closed = true;
|
|
208
|
+
await Promise.allSettled(this.inFlight);
|
|
209
|
+
this.onClose();
|
|
210
|
+
}
|
|
211
|
+
assertOpen() {
|
|
212
|
+
if (this.closed) throw new StorageError("closed", `unit '${this.descriptor.name}' is closed`);
|
|
213
|
+
}
|
|
214
|
+
records(table) {
|
|
215
|
+
const records = this.state.tables.get(table);
|
|
216
|
+
if (!records) throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`);
|
|
217
|
+
return records;
|
|
218
|
+
}
|
|
219
|
+
publish() {
|
|
220
|
+
const write = writeAtomic(this.path, serialize(this.descriptor.name, this.state));
|
|
221
|
+
this.inFlight.add(write);
|
|
222
|
+
write.catch(() => {}).finally(() => this.inFlight.delete(write));
|
|
223
|
+
return write;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region lib/types/index.js
|
|
228
|
+
/**
|
|
229
|
+
* JSON storage backend: one human-readable file per unit under a configured
|
|
230
|
+
* root, published by atomic whole-file rewrite. Registers as backend `json`
|
|
231
|
+
* on the storage hub.
|
|
232
|
+
* @module @deepseek-ai/dsh-storage-json
|
|
233
|
+
*/
|
|
234
|
+
/** Cordis plugin name. */
|
|
235
|
+
const name = "storage-json";
|
|
236
|
+
/** The hub must exist before the backend can register. */
|
|
237
|
+
const inject = ["storage"];
|
|
238
|
+
/** Config schema. */
|
|
239
|
+
const Config = z.object({ root: z.string().required() });
|
|
240
|
+
/** JSON backend: owns the file-tree root and serves the `kv` facet. */
|
|
241
|
+
var JsonStorageBackend = class {
|
|
242
|
+
root;
|
|
243
|
+
open = /* @__PURE__ */ new Map();
|
|
244
|
+
opening = /* @__PURE__ */ new Map();
|
|
245
|
+
closed = false;
|
|
246
|
+
constructor(root) {
|
|
247
|
+
this.root = root;
|
|
248
|
+
}
|
|
249
|
+
kv = { open: async (descriptor) => {
|
|
250
|
+
if (this.closed) throw new StorageError("closed", "json backend is closed");
|
|
251
|
+
validateDescriptor(descriptor);
|
|
252
|
+
if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) throw new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`);
|
|
253
|
+
const opening = this.openUnit(descriptor);
|
|
254
|
+
this.opening.set(descriptor.name, opening);
|
|
255
|
+
return opening.finally(() => this.opening.delete(descriptor.name));
|
|
256
|
+
} };
|
|
257
|
+
async openUnit(descriptor) {
|
|
258
|
+
await mkdir(this.root, {
|
|
259
|
+
recursive: true,
|
|
260
|
+
mode: 448
|
|
261
|
+
});
|
|
262
|
+
const unit = await openJsonUnit(descriptor, join(this.root, `${descriptor.name}.json`), () => this.open.delete(descriptor.name));
|
|
263
|
+
if (this.closed) {
|
|
264
|
+
await unit.close();
|
|
265
|
+
throw new StorageError("closed", "json backend is closed");
|
|
266
|
+
}
|
|
267
|
+
this.open.set(descriptor.name, unit);
|
|
268
|
+
return unit;
|
|
269
|
+
}
|
|
270
|
+
async close() {
|
|
271
|
+
if (!this.closed) this.closed = true;
|
|
272
|
+
await Promise.allSettled([...this.opening.values()]);
|
|
273
|
+
for (const unit of [...this.open.values()]) await unit.close();
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
function validateDescriptor(descriptor) {
|
|
277
|
+
if (!UNIT_NAME_RE.test(descriptor.name)) throw new StorageError("malformed-medium", `invalid unit name '${descriptor.name}'`);
|
|
278
|
+
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}'`);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Register the `json` backend on the storage hub.
|
|
282
|
+
* @param ctx - Plugin context.
|
|
283
|
+
* @param config - Validated configuration.
|
|
284
|
+
*/
|
|
285
|
+
function apply(ctx, config) {
|
|
286
|
+
const backend = new JsonStorageBackend(config.root);
|
|
287
|
+
ctx.effect(() => {
|
|
288
|
+
const unregister = ctx.storage.backend.register("json", backend);
|
|
289
|
+
return async () => {
|
|
290
|
+
unregister();
|
|
291
|
+
await backend.close();
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
ctx.provide(storageBackendServiceKey("json"), backend);
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
export { Config, JsonStorageBackend, apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-storage-json`.
|
|
4
|
+
* @module @deepseek-ai/dsh-storage-json/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-storage-json";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "storage-json-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: correctness here is write-durability and
|
|
13
|
+
* publish-then-reparse equivalence, which require medium round-trip tests
|
|
14
|
+
* (the shared backend conformance suite); the backend exposes no continuously
|
|
15
|
+
* observable in-process relation.
|
|
16
|
+
*/
|
|
17
|
+
const install = () => {};
|
|
18
|
+
/**
|
|
19
|
+
* Register this package's invariant companion.
|
|
20
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
21
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
22
|
+
*/
|
|
23
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
24
|
+
//#endregion
|
|
25
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic whole-file replacement for the JSON backend.
|
|
3
|
+
*
|
|
4
|
+
* Publish protocol: write a same-directory temp file, fsync it, then
|
|
5
|
+
* `rename()` over the target. Rename is an atomic replace on POSIX and on
|
|
6
|
+
* Windows (libuv maps it to `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`),
|
|
7
|
+
* and replacement is the intended semantic here — unlike the session-log
|
|
8
|
+
* backend's link()+unlink() no-clobber protocol, a unit file has exactly one
|
|
9
|
+
* writer per process and last-write-wins is correct. After the rename the
|
|
10
|
+
* parent directory is fsynced on POSIX so the new entry is crash-durable.
|
|
11
|
+
* @module @deepseek-ai/dsh-storage-json/src/atomic
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Durably replace `path` with `data`.
|
|
15
|
+
* @param path - Absolute target file path.
|
|
16
|
+
* @param data - Full new file content.
|
|
17
|
+
* @returns resolution after the replacement is crash-durable.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeAtomic(path: string, data: string): Promise<void>;
|
|
20
|
+
//# sourceMappingURL=atomic.d.ts.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk JSON unit format: the file is always the current net state, kept
|
|
3
|
+
* human-readable (pretty-printed, stable key order from insertion) — that
|
|
4
|
+
* legibility is this backend's reason to exist.
|
|
5
|
+
* @module @deepseek-ai/dsh-storage-json/src/format
|
|
6
|
+
*/
|
|
7
|
+
import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage';
|
|
8
|
+
/** In-memory authoritative state of one unit; the file is its projection. `global` is `null` until first written. */
|
|
9
|
+
export interface UnitState {
|
|
10
|
+
version: number;
|
|
11
|
+
global: unknown;
|
|
12
|
+
tables: Map<string, Map<string, unknown>>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Serialize a unit state to file content.
|
|
16
|
+
* @param name - Unit name, stamped into the header.
|
|
17
|
+
* @param state - Authoritative in-memory state.
|
|
18
|
+
* @returns pretty-printed JSON document with a trailing newline.
|
|
19
|
+
*/
|
|
20
|
+
export declare function serialize(name: string, state: UnitState): string;
|
|
21
|
+
/**
|
|
22
|
+
* Parse file content into unit state, validating shape and version.
|
|
23
|
+
* @param text - Raw file content.
|
|
24
|
+
* @param descriptor - Expected identity; version mismatch rejects.
|
|
25
|
+
* @returns the parsed state.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parse(text: string, descriptor: KvUnitDescriptor): UnitState;
|
|
28
|
+
//# sourceMappingURL=format.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON storage backend: one human-readable file per unit under a configured
|
|
3
|
+
* root, published by atomic whole-file rewrite. Registers as backend `json`
|
|
4
|
+
* on the storage hub.
|
|
5
|
+
* @module @deepseek-ai/dsh-storage-json
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
8
|
+
import z from '@deepseek-ai/schemastery';
|
|
9
|
+
import type { KvFacet, StorageBackend } from '@deepseek-ai/dsh-storage';
|
|
10
|
+
/** Cordis plugin name. */
|
|
11
|
+
export declare const name = "storage-json";
|
|
12
|
+
/** The hub must exist before the backend can register. */
|
|
13
|
+
export declare const inject: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Plugin configuration.
|
|
16
|
+
* `root` has NO default on purpose: a `process.cwd()` fallback would scatter
|
|
17
|
+
* unit files wherever the process happens to start; assemblies state the
|
|
18
|
+
* location explicitly.
|
|
19
|
+
*/
|
|
20
|
+
export interface Config {
|
|
21
|
+
/** Directory holding one `<unit>.json` file per unit. */
|
|
22
|
+
root: string;
|
|
23
|
+
}
|
|
24
|
+
/** Config schema. */
|
|
25
|
+
export declare const Config: z<Config>;
|
|
26
|
+
/** JSON backend: owns the file-tree root and serves the `kv` facet. */
|
|
27
|
+
export declare class JsonStorageBackend implements StorageBackend {
|
|
28
|
+
private readonly root;
|
|
29
|
+
private readonly open;
|
|
30
|
+
private readonly opening;
|
|
31
|
+
private closed;
|
|
32
|
+
constructor(root: string);
|
|
33
|
+
readonly kv: KvFacet;
|
|
34
|
+
private openUnit;
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Register the `json` backend on the storage hub.
|
|
39
|
+
* @param ctx - Plugin context.
|
|
40
|
+
* @param config - Validated configuration.
|
|
41
|
+
*/
|
|
42
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
43
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@deepseek-ai/dsh-storage-json`.
|
|
3
|
+
* @module @deepseek-ai/dsh-storage-json/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "storage-json-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One opened JSON unit. The in-memory state is authoritative; every write
|
|
3
|
+
* primitive mutates it and republishes the whole file atomically. Writes are
|
|
4
|
+
* NOT queued here — per the backend contract, write ordering belongs to the
|
|
5
|
+
* caller (the domain layer's write chain); this unit only guarantees that
|
|
6
|
+
* each single call publishes a complete, durable file.
|
|
7
|
+
* @module @deepseek-ai/dsh-storage-json/src/unit
|
|
8
|
+
*/
|
|
9
|
+
import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage';
|
|
10
|
+
/**
|
|
11
|
+
* Open (load or lazily create) one unit backed by `path`.
|
|
12
|
+
* @param descriptor - Static identity and shape of the unit.
|
|
13
|
+
* @param path - Absolute unit file path under the backend root.
|
|
14
|
+
* @param onClose - Backend callback releasing the unit's open-slot.
|
|
15
|
+
* @returns the opened unit.
|
|
16
|
+
*/
|
|
17
|
+
export declare function openJsonUnit(descriptor: KvUnitDescriptor, path: string, onClose: () => void): Promise<KvUnit>;
|
|
18
|
+
//# sourceMappingURL=unit.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepseek-ai/dsh-storage-json",
|
|
3
|
+
"description": "JSON file KV storage backend for the DeepSeek Harness storage hub",
|
|
4
|
+
"version": "0.0.1-rc.1",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "restricted"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/storage/storage-json"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "BSD-3-Clause",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
36
|
+
"@deepseek-ai/dsh-storage": "^0.0.1-rc.1",
|
|
37
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@deepseek-ai/schemastery": "^3.18.1-rc.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
|
44
|
+
"@deepseek-ai/dsh-storage": "^0.0.1-rc.1",
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1"
|
|
46
|
+
}
|
|
47
|
+
}
|