# 设计取舍（为什么是这样，而不是那样）

> English summary: the registry is an **append-only JSONL log** (not a database), deletions are **archived and
> restorable** (never physical), rewrites are **atomic with a `.bak`**, concurrent writes are **detected and
> refused** (no locks), the page is **rendered per request** (no build step), and the routes are **not
> authenticated** (they are exactly as reachable as the DSH UI itself). Every section below states the
> alternative that was rejected and the price actually paid.

这份文档集中回答"为什么这么写"。取舍散落在 CHANGELOG 的各轮里，这里按**决策**归拢，每条都写清
**选了什么 / 为什么 / 代价是什么** —— 代价那条最重要，没有代价的方案通常意味着还没想清楚。

## 1. 存储：只追加的 JSONL，不是数据库

**选了什么**：一条登记 = 一行 JSON，追加到 `index.jsonl`。

**为什么**：这是给人看的文件。出问题时用记事本就能查、能改、能对比、能进版本库；
不需要为了"看一眼里面有什么"而装任何东西。它也天然抗"写一半崩了"——最坏情况是最后一行残缺，
而读的时候**坏行只跳过自己**（`readJsonl` 记进 `errors`），不会带走整份清单。

**代价**：没有索引、没有事务、没有并发控制（见 §4）。条目上万以后，筛选是线性扫描。
这个量级下（个人成果清单）不值得为此引入数据库依赖 —— 而且一旦引入，插件就再也不是"零依赖"。

## 2. 删除：进留档，可放回

**选了什么**：`remove` 把条目**追加**到 `deleted.jsonl`（带 `removedAt` 与 `why`），从清单里移走；
`restore` 把它放回来。**任何情况下都不动那个文件本身。**

**为什么**：这个工具管的是"登记"，不是"数据"。用户说"这条不算成果了"和"把这个文件删掉"是两件事，
把它们混在一起会造成不可逆的误伤。留档让"手滑"变成可撤销的操作。

**代价**：清单与留档两个文件要一起维护；`restore` 是"两处改动"，因此有了 §4 里那个
"提交前复核"和那条 `entry-restored-deleted-log-unchanged` 的诚实报错。

## 3. 重写：`.bak` → `.tmp` → `rename`

**选了什么**：任何重写都先复制一份 `.bak`，把新内容写进 `.tmp`，最后 `rename` 覆盖。

**为什么**：`rename` 在同一文件系统内是原子替换 —— 要么旧内容、要么新内容，不会出现半份。
`.bak` 是"上一步"的快照，用于人肉回滚。

**代价**：每次重写多两次 I/O，还会常驻一个 `.bak`。对几千行的文件无所谓。

## 4. 并发：复核 + 拒绝，而不是加锁

**选了什么**：`update` / `remove` / `restore` 都是「读—改—写」。读的时候连**内容哈希**一起记下
（`readJsonl()` 返回 `snapshot`），提交前复核哈希有没有变；变了就**不动文件**，返回
`{ ok: false, error: 'concurrent-modification' }`，并删掉 `.tmp`。

**为什么不是文件锁**：锁要处理持有时长、进程崩溃后残留、以及"等多久算超时"。**任何一处没处理对，
调用方就会挂住** —— 对一个跑在宿主进程里的插件来说，"卡住用户的会话"比"报一个错"严重得多。
复核 + 拒绝没有这些失效模式：它要么成功，要么立刻给出明确失败。

**代价**：冲突时用户要自己重试（错误里带着 id，能直接重跑那条命令）。
极小的窗口（复核之后、`rename` 之前）仍有理论上的竞态，`restore` 因此会在那种情况下返回
`note: 'entry-restored-deleted-log-unchanged'` —— **如实说清发生了什么，而不是假报成功**。

**为什么 `remove` 的顺序是先重写、再留档**：反过来的话，一旦复核失败，留档里已经多了一条**指向
"其实还在用"的条目**的记录，比原来的问题更难收拾。现在的顺序在冲突时"什么都不写"；
两次操作之间若崩溃，下一次重写的自愈会把它记成 `auto-archived`（有记录，不静默丢）。

## 5. 自愈：重写时发现"消失且没留档"就补记

**选了什么**：重写前后对比，凡是**这次消失、删除留档里也没有**的 id，补一条
`why: 'auto-archived'` 的留档。

**为什么**：清单是人的资产。任何"条目不见了但没人知道为什么"的情况都应该留下痕迹 ——
哪怕是别的进程、别的工具、或用户手工编辑造成的。

**代价**：留档里会出现用户没主动删过的记录。这是**故意的**：宁可多一条能解释的记录，
也不要一条查不到去向的消失。

## 6. 页面：请求期渲染，不做构建

**选了什么**：管理页由 `lib/page.js` 在**请求时**拼出完整 HTML（内联样式与脚本、中英双语文案一起打包）。

**为什么**：插件不该要求用户装构建工具链。没有 `dist/`、没有 watch、没有"忘了 build 所以改动没生效"。
改一行 `page.js`、刷新浏览器就能看到。也因此**包里没有生成物**，`npm pack` 出来的就是源码。

**代价**：页面代码写在一段模板字符串里 —— 这是个真实的坑：模板里再出现反引号会把脚本提前截断
（第 3 轮真犯过）。所以现在有 `test/page.test.mjs` 把渲染结果里的 `<script>` 抠出来跑 `node --check`，
并断言没有未替换的 `__XXX__` 占位符。

## 7. 客户端半部：手写、无打包器

**选了什么**：`lib/client.js` 手写，用宿主提供的 `window.__ModuleLoader__.load({ id: <包名>, ... })`。

**为什么**：设置里那一个分节不值得一条构建链。**代价**是它必须精确遵守宿主的装载契约
（`id` 必须**等于包名**，否则**静默不加载**）—— 所以 `test/client.test.mjs` 把它钉死，
`docs/CLIENT-HALF.md` 写清了契约。

## 8. 文件服务：按扩展名决定"内联还是下载"

**选了什么**：`GET /file/<id>/<index>` 只认 **id + 序号**（URL 里没有路径，也就没有穿越面）；
决定 `inline` 还是 `attachment` 的是**文件的扩展名**，不是条目类型；内联的 HTML 额外带沙箱 CSP。

**为什么**：按**条目类型**判断曾经放过一个真 bug —— 一条"网页"成果里混放 csv 时，csv 会被内联。
按扩展名判断与"这个文件是什么"直接对应。

**SVG 一律当附件**：它是图片，但能携带脚本 —— 宁可让用户多点一下"打开"。

**代价**：内联白名单要维护；生僻类型会落到"下载"，体验略差但更安全。

## 9. 鉴权：不加；只挡跨站误写

**选了什么**：路由与宿主 UI **同等可达**，不做自己的鉴权。非 GET 请求要求带自定义头
`x-dsh-outcomes: 1`（浏览器跨站表单发不出自定义头），否则 403。

**为什么**：加一层"插件自己的登录"只会制造**虚假的安全感** —— 真正的边界是宿主怎么暴露。
与其做半套，不如把这件事写进 `SECURITY.md` 让用户自己决定（例如在反代上加认证）。

**代价**：能访问到宿主端口的人就能改清单。这是**明说的取舍**，不是疏忽。

## 10. 不做内容索引

**选了什么**：搜索只覆盖标题／来源／备注／类型／路径，**不读文件正文**。

**为什么**：读正文意味着要把用户的报告、数据、代码读进来 —— 能力面立刻大一圈，还会拖慢页面。
"找那个文件"和"在文件里找词"是两件事，后者交给专门的工具。

## 11. 配置：config → 环境变量 → 设置文件 → 默认

**选了什么**：`lib/paths.js` 按这个顺序解析，并**如实报告每一层来自哪里**（`/api/status` 里可见 `sources`）。

**为什么**：越"具体"的来源越优先（profile 的 config 比机器级的环境变量更具体）。
把来源暴露出来是因为**这类错误的症状是"清单悄悄写到别处去了"**，不显示来源就很难查。
`test/paths.test.mjs` 把四层优先级与非数值回落都钉住。

**代价**：多了一层理解成本；但换来了"同一份代码在别人机器上跑得起来"——
这也是"代码里没有个人路径"能被扫描器检查的前提。

## 12. 工具面：6 个，`remove` 走审批

**选了什么**：`outcome_add` / `list` / `update` / `check` / `remove` / `restore`；其中 `remove`
挂宿主的 `tools/pre-execute` 审批闸门。

**为什么**：**写要留痕，删要确认**。`update` 是第 6 轮补的 —— 在它之前，模型把标题写错只能再登记一条，
而重复登记比"改一条"糟得多。

## 测试策略：防的是"漂移"，不是"崩溃"

这套测试的重心不在覆盖率，而在**实现与说明书之间的漂移**（这类错误是静默的）：

- `docs.test.mjs`：工具名序列、配置键、README 里引用的图，都要和实现/磁盘对得上；
- `page.test.mjs`：渲染结果里不留占位符、内联脚本能过 `node --check`、中英词典 key 一致；
- `shipped.test.mjs`：`files` 白名单里的每个文件都要存在，且随包文件里不能有本机专有内容；
- `test/e2e.test.mjs`：真的起 HTTP 服务打一遍（才发现过"按条目类型决定 disposition"那个 bug）。
- `tools/acceptance.mjs`：把 **npm pack 出来的 tgz** 装进隔离 profile 真起一次，
  验的是"会发布出去的那个东西"能装载、能跑。

## 已知代价清单（汇总）

| 代价 | 来源 | 现状 |
|---|---|---|
| 上万条后筛选变慢 | 只追加 JSONL、无索引 | 已知；服务端有 `limit`，必要时再谈分页 |
| 冲突时需人工重试 | 乐观并发 | 错误里带 id，可直接重跑 |
| 一个常驻 `.bak` | 原子重写 | 有意保留，可人工回滚 |
| 页面脚本写在模板字符串里 | 无构建步骤 | 有测试兜底（`page.test.mjs`） |
| 路由不做鉴权 | 与宿主同边界 | 写进 `SECURITY.md`，请用户在暴露时自行加认证 |
| 客户端半部加载失败是静默的 | 宿主既有行为 | `id` 必须等于包名；测试 + `docs/CLIENT-HALF.md` 钉住 |
