# DPMIDX v2 —— 插件市场索引格式规范

**状态**：v2，实现随 `dsh-plugin-hub` 0.2.6 提供
**目标**：让「插件市场」成为一种**可多方生产、可离线分发、可瞬间加载**的数据格式，而不是某个中心服务的附属物。

---

## 1. 为什么需要它

现有做法（官方与我们的旧实现都类似）是把市场索引做成一份 JSON 文档：

- Host 必须**整份读进内存**再 `JSON.parse`；
- 官方 README 自述的量级：2 万插件约 **130 MiB 堆**；10 万插件的 38 MiB 索引反序列化约 **190 ms**，且在 Host 事件循环上阻塞；
- 市场之间无法合并，每种产品一套格式。

DPMIDX v2 要解决三件事：

1. **加载即用**：解析只做「读头 + 计算段起点」，不解码任何字符串；
2. **体积小**：字符串驻留 + 定长记录，同样数据比 JSON 小约 1.37×；
3. **可统一**：任何一方都能生成一份索引，客户端把任意多份合并成**一个市场**（无需中心服务、无需签名链）。

---

## 2. 文件布局

所有整数为**小端无符号**。文件头部固定 40 字节：

| 偏移 | 长度 | 字段 | 说明 |
|---:|---:|---|---|
| 0 | 6 | `magic` | ASCII `DPMIDX` |
| 6 | 1 | `magicVersion` | 当前为 `0x03`（v3 起支持署名元数据） |
| 7 | 1 | **`kind`** | **`0` = 插件列表，`1` = 整合包**（见第 9 节） |
| 8 | 4 | `version` | 头部版本，当前为 `3` |
| 12 | 4 | `count` | 记录数 |
| 16 | 4 | `strCount` | 字符串表中的字符串数 |
| 20 | 4 | `depsCount` | 依赖索引项总数 |
| 24 | 4 | `strBytes` | 字符串表字节数 |
| 28 | 4 | `depsBytes` | 依赖数组字节数（`= depsCount × 4`） |
| 32 | 4 | `metaBytes` | 元数据 JSON 的字节数（v2 时此处置 0） |
| 36 | 4 | `reserved` | 置 0 |

随后四段依次排列：

```
[元数据]    40                                   长度 metaBytes
[字符串表]  40 + metaBytes                       长度 strBytes
[依赖数组]  40 + metaBytes + strBytes            长度 depsBytes
[记录区]    40 + metaBytes + strBytes + depsBytes 长度 count × 28
```

**元数据（v3 新增）**：UTF-8 JSON，字段均可选：

```json
{ "name": "插件表", "publisher": "云幻天",
  "description": "…", "intro": "多行详细简介…",
  "createdAt": "2026-10-26T00:00:00.000Z",
  "certification": "certified" }
```

| 字段 | 用途 |
|---|---|
| `name` | 来源展示名。导入时不填 `label` 就用它 |
| `publisher` | 署名（作者 / 发布者） |
| `description` | **一句话**说明，列表卡片高度里只放得下这一行 |
| `intro` | **详细简介**（可含换行）。整合包页展示，作者用来交代定位、选品理由、使用提示 |
| `createdAt` | 生成时间（ISO 8601） |
| `certification` | 文件自称的等级，**仅展示**，不参与信任判断 |

`description` 与 `intro` 是两层信息：一句话负责「要不要点进来」，`intro` 负责
「点进来之后凭什么装它」。整段都会被原样显示（`white-space: pre-wrap`），
建议每行 40 字以内、总长 300 字以内，超过会被截断在折叠区里。

⚠️ `publisher` 是**署名**（作者名字），不是**证明**：任何文件都能自称作者。
信任必须由消费端用签名或签发记录校验 —— 这也是为什么认证结果不写进文件本身。

**向后兼容**：v2 文件的 `metaBytes` 位置是保留位（全 0），同一个读取路径对 v2
自然得到 `metaBytes = 0`，不需要额外分支。读取方对 `magicVersion > 3` 必须拒绝，
并给出「请更新插件商店」这类可操作的提示。

**字符串表**：重复 `(u32 len, utf8 bytes)`，共 `strCount` 条。相同字符串只存一次（驻留）。

**依赖数组**：`depsCount` 个 `u32`，每个是字符串表下标。

**记录区**：每条 28 字节，7 个 `u32`：

| 序号 | 字段 | 说明 |
|---:|---|---|
| 0 | `nameId` | 包名在字符串表中的下标 |
| 1 | `urlId` | 下载/安装链接的下标 |
| 2 | `categoryId` | 分类键的下标（空串 = 未分类） |
| 3 | `descId` | **一句话介绍**的下标（空串 = 未提供） |
| 4 | `depsStart` | 该条依赖在依赖数组中的起始位置 |
| 5 | `depsLen` | 依赖数量 |
| 6 | `flags` | 安装语义位图，见下 |

### `flags` 位定义

索引比 JSON 多出来的价值就在这里：**它知道链接指向的是成品还是源码**，UI 才能直接给出正确的按钮。

| 位 | 常量 | 含义 | UI 表现 |
|---:|---|---|---|
| `0x01` | `FLAG_ARTIFACT` | 链接直指**成品**（`.tgz` / `.tar.gz` / `.zip`） | 「一键安装」：下载 + 解压，无需构建 |
| `0x02` | `FLAG_SOURCE` | 链接是**源码仓库** | 「安装（需构建）」：装依赖 + 构建 |
| `0x04` | `FLAG_OFFICIAL` | 平台官方条目 | 官方标记 |
| `0x08` | `FLAG_VERIFIED` | 经过校验 | 信任标记 |

`ARTIFACT` 与 `SOURCE` 互斥；都未置位时表示**未知**，消费者不得假设是成品。

---

## 3. 读取契约（消费者）

1. 读入文件为一个 `Buffer`（或内存映射）；
2. 校验 `magic` 与 `version`；
3. 按头部计算三段起点，**不解码字符串**；
4. 遍历字符串表建一张 `strCount` 长度的偏移数组（只做指针算术，不拷贝）；
5. 名称/链接/分类按需解码并记忆化。

实现见 `src/host/index-format.ts` 的 `openMarketIndex()`。

**失败必须显式**：magic 不匹配、版本不支持、长度不足/被截断，都要抛错，不允许静默降级。

---

## 4. 写入契约（生产者）

提供字段：`name`（必填）、`url`（必填）、`description`（可省，别名 `desc` / `intro`）、`category`（可省）、`deps`（可省）、`flags`（可省）。

`flags` 未提供时，工具会**按链接后缀推断**：`.tgz`/`.tar.gz`/`.zip` → `FLAG_ARTIFACT`；GitHub/GitLab/Gitee 仓库 → `FLAG_SOURCE`；其余 → `0`（未知）。

- 同名条目**只保留第一条**（保证确定性）；
- 缺失 `name` 或 `url` 的行**跳过并计数**，不写入；
- 依赖名同样驻留，重复依赖名在数组里会出现多次（读取方自行去重）。

工具：`node scripts/build-market-index.mjs <输入.json|ndjson> [输出.dpmidx]`

---

## 5. 统一市场：多源合并规则

`mergeMarketIndexes([{ source, index }, …])` 产生一个合并视图。规则是**确定性**的：

| 情况 | 结果 |
|---|---|
| 同一包名出现在多份索引 | 按**传入顺序**，先传入者决定 `url` / `category` / `flags` |
| 依赖 | 取**并集**（去重，保持先出现顺序） |
| 来源 | `sources` 累积所有提供方，可用于展示"来自哪些市场" |
| 统计 | `stats()` 给出每份来源的条目数 |

这意味着：**格式可以承载多个互相独立的市场**，客户端把它们并成一个列表；不需要任何一方获得"官方认可"，也不需要签名链。谁来保证条目可信，由消费者自己用健康度/信任策略决定（这正是本项目的另一层）。

---

## 6. 实测（`node --expose-gc scripts/bench-index-format.mjs`）

同一份数据（名称 / 链接 / 分类 / 依赖 / flags）：

| 条数 | JSON（整份 parse） | DPMIDX v2 | 提升 |
|---:|---|---|---|
| 20,000 | 5.5 MB · 读+parse 46.1 ms · 堆 +8.3 MiB | 4.5 MB · 读+open 9.3 ms · 堆 +0.5 MiB | 体积 1.24× · 加载 5.0× · 堆 17× |
| 100,000 | 27.9 MB · 219.3 ms · 堆 +41.3 MiB | 22.5 MB · 16.3 ms · 堆 +2.3 MiB | 体积 1.24× · 加载 13.4× · 堆 18× |

口径：**从磁盘读入 → 得到可查询结构**，两侧都把输入缓冲计入堆（即「下载索引后导入」的真实成本），
不含生产时间。数据含名称 / 链接 / 介绍 / 分类 / 依赖 / flags。

**已知权衡（如实记录）**：逐条解码字符串比 JSON 的批量解析**更慢**
（2 万条约 7.4 ms vs 2.1 ms），因为 UTF-8 解码被摊到按需访问上。
若消费者会立刻遍历全部名称，总量仍然更快（11.4 ms vs 14.8 ms），但优势收窄。
需要极致首屏时可加"预热"：在 Worker 中一次解码全部字符串后再交给 UI。

---

## 7. 版本与扩展

- `version` 只增不减；不兼容改动必须升 `version`，消费者对未知版本**必须拒绝**。
- 新字段优先用 `flags` 位图和 `reserved` 段，避免频繁升版。
- 若将来需要超过 4 GB 或需要增量更新，用 v3 定义分片/可追加结构，不要修改 v2 的语义。

---

## 8. 与官方索引的关系

官方（DSH 0.2.x）的「自定义插件市场」是一套**JSON 索引 + Host 解析**的机制，且**未提供官方索引**。
DPMIDX v2 不与之竞争：它是我们自己的格式与加载路径，目的正是让商店**不依赖**官方市场。
若将来需要互通，可由任一方向做一次格式转换（JSON ↔ DPMIDX），两者字段是等价的。

---

## 9. 同一种格式，两种容器：列表与整合包

列表（插件目录）与整合包（一组插件的组合声明）**共用同一个容器与编解码器**，
只在魔数第 8 个字节上用 `kind` 区分 —— 读取方用 8 个字节就能判断"这是什么"，
不必解析整份文件；同时两者共享字符串驻留与定长记录的全部实现。

| `kind` | 含义 | 消费者 |
|---:|---|---|
| `0` | **插件列表** | 商店的「自动识别 / 认证列表」两区 |
| `1` | **整合包** | 单独的整合包页；**商店必须拒绝** |

**为什么必须分开**：列表里的每条都是"可安装的插件"，整合包是"应该一起装的一组"。
若整合包被当成列表导入，用户会在商店里看到一堆装不了的条目 —— 所以导入器读 `kind`
为 `1` 时必须报错并提示"这是整合包，不是列表"。

**商店的两区**（由本地注册信息而非文件本身决定）：

- **自动识别**：用户添加的任意来源（含第三方 JSON 转换而来的），标记为 `derived`；
- **认证列表**：经过我们认证流程签发的列表。认证结果不写进文件本身，
  而是本地注册表里的签发记录 —— 因为文件可被任意复制，签名必须由消费者校验。

### 9.1 来源的三种产生方式

| 方式 | 输入 | `format` | 说明 |
|---|---|---|---|
| 添加列表 | 列表 URL / 上传文件 / 粘贴文本 | `dpmidx` / `json` / `ndjson` | 导入时嗅探；JSON 当场转成 DPMIDX 并落盘 |
| 自动爬取 | GitHub 用户 / 组织 / 仓库 / topic | `crawl` | 限定范围的 GitHub 搜索（与内置镜像同一套接口） |
| 内置镜像 | 无（随同步计划运行） | — | 直接写 `plugins` 表，不经过来源注册表 |

**为什么要保留自动爬取**：用户手里往往没有现成的列表文件，只有一个"我信这个人"的判断。
给他一个 `github.com/owner` 就能把这个人的插件收进「自动识别」，比要求他先去做一份列表现实得多。
爬取只做一次、结果固化成 `.dpmidx`，所以浏览时不依赖网络。

### 9.2 重复内容的处理

- **同名** → 视为同一插件，合并；
- **同一产物地址**（`/archive/`、`/releases/download/`、`-/{name}-{ver}.tgz` 等）→ 合并，被合并的名字进 `aliases`；
- **同一仓库根地址**（monorepo 常见）→ **不合并**，只报告为"同仓库分组"，避免把不同插件错并成一个。
