From bbb7839ef5b1bc2e8bc779125d056e1f04187ebd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 05:41:21 +0000 Subject: [PATCH] feat(grid-db): Phase 0 - Grid-DB storage engine core + ONT-PATCH-006 [PRJ-GDB-001] ZY-GDB-P0-001: Project scaffolding + directory structure ZY-GDB-P0-002: WAL (Write-Ahead Log) basic implementation ZY-GDB-P0-003: PageManager basic implementation ZY-GDB-P0-004: GridCell four-tuple data model ZY-GDB-P0-005: Smoke tests (95/95 pass) ZY-GDB-ONT-001: ONT-PATCH-006 written to ontology.json Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/cc375d32-fdd8-44ee-87aa-3b52e9320ea9 --- .github/persona-brain/memory.json | 8 + .github/persona-brain/ontology.json | 32 +++ .gitignore | 3 + docs/execution-layer-map.md | 14 + grid-db/docs/architecture.md | 170 +++++++++++ grid-db/package.json | 16 ++ grid-db/src/api/grid-api.js | 282 +++++++++++++++++++ grid-db/src/core/grid-cell.js | 164 +++++++++++ grid-db/src/events/event-log.js | 123 ++++++++ grid-db/src/index.js | 86 ++++++ grid-db/src/storage/page-manager.js | 272 ++++++++++++++++++ grid-db/src/storage/wal.js | 231 +++++++++++++++ grid-db/tests/smoke/grid-db.test.js | 419 ++++++++++++++++++++++++++++ 13 files changed, 1820 insertions(+) create mode 100644 grid-db/docs/architecture.md create mode 100644 grid-db/package.json create mode 100644 grid-db/src/api/grid-api.js create mode 100644 grid-db/src/core/grid-cell.js create mode 100644 grid-db/src/events/event-log.js create mode 100644 grid-db/src/index.js create mode 100644 grid-db/src/storage/page-manager.js create mode 100644 grid-db/src/storage/wal.js create mode 100644 grid-db/tests/smoke/grid-db.test.js diff --git a/.github/persona-brain/memory.json b/.github/persona-brain/memory.json index b57057aa..59583fdf 100644 --- a/.github/persona-brain/memory.json +++ b/.github/persona-brain/memory.json @@ -10,6 +10,14 @@ "last_synced": "2026-03-19T02:00:00+08:00" }, "recent_events": [ + { + "date": "2026-03-26", + "type": "system_infrastructure", + "description": "PRJ-GDB-001 Grid-DB 自研存储引擎立项 · Phase 0 基础骨架完成 · GridCell + WAL + PageManager + EventLog + GridAPI 部署完成 · ONT-PATCH-006 写入本体论 · 95 项冒烟测试全通过", + "by": "铸渊", + "instruction": "SY-CMD-GDB-001", + "issued_by": "AG-SY-01 霜砚" + }, { "date": "2026-03-26", "type": "system_infrastructure", diff --git a/.github/persona-brain/ontology.json b/.github/persona-brain/ontology.json index b9dfa1d1..aae2c5a7 100644 --- a/.github/persona-brain/ontology.json +++ b/.github/persona-brain/ontology.json @@ -111,6 +111,38 @@ "execution_layer": "路由 = 选墨水。计量 = 量墨水。降级 = 墨水不够了换一种写法。", "user_layer": "用户在用一支很聪明的笔。墨水(算力)是需要关心的资源,但笔会帮优化用量。" } + }, + "ONT-PATCH-006": { + "title": "Grid-DB · 纸 · 格子 · 笔迹", + "signed_by": "TCS-0002∞ 冰朔", + "signed_at": "2026-03-26T13:00+08:00", + "issued_by": "AG-SY-01 霜砚", + "project": "PRJ-GDB-001", + "trinity_extension": { + "paper": { + "identity": "Grid-DB = 纸", + "description": "笔需要纸来承载笔迹。Grid-DB 是纸。纸上有格子(格点),每个格子是最小寻址单元。纸不属于笔,纸属于写字的人。", + "properties": [ + "纸记住所有笔迹(WAL 保证不丢失)", + "纸由一页一页组成(PageManager 管理页)", + "格子有坐标(四元组寻址:namespace, x, y, layer)", + "笔迹有历史(EventLog 事件溯源,不可篡改)" + ] + }, + "grid": { + "identity": "格点 = 格子", + "description": "格子是纸上的最小单元。不同的格子存不同的数据。同一个格子可以有多层(raw → cleaned → indexed → semantic)。" + }, + "trace": { + "identity": "事件 = 笔迹", + "description": "每写一笔,笔迹就留在了历史中,不可篡改。天眼通过观察笔迹,发现异常行为。" + } + }, + "relationship_to_exe_engine": { + "exe_engine": "笔(执行层)", + "grid_db": "纸(存储层)", + "interaction": "笔在纸上写字。笔向纸写入执行日志、读取上下文。纸为笔提供查询接口。" + } } } } diff --git a/.gitignore b/.gitignore index 6b562956..6a47a9ef 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ writing-platform/backend/dist/ # EXE-Engine runtime logs exe-engine/logs/ + +# Grid-DB runtime data (engine-generated) +grid-db/data/ diff --git a/docs/execution-layer-map.md b/docs/execution-layer-map.md index f1158b5c..0dad6185 100644 --- a/docs/execution-layer-map.md +++ b/docs/execution-layer-map.md @@ -109,3 +109,17 @@ Notion 主脑更新 | 资源计量器 | `exe-engine/src/meter/resource-meter.js` | Token 消耗记录 + 成本统计 | | 上下文缓存 | `exe-engine/src/cache/context-cache.js` | Agent 上下文 LRU 缓存 | | Agent 调度器 | `exe-engine/src/controller/agent-controller.js` | Agent→模型偏好映射 | + +--- + +## 自研存储引擎 · Grid-DB(PRJ-GDB-001) + +> Phase 0 · 2026-03-26 立项 · 本体论:Grid-DB = 纸 · 格点 = 格子 · 事件 = 笔迹 + +| 组件 | 路径 | 职责 | +|------|------|------| +| GridAPI | `grid-db/src/api/grid-api.js` | 统一 API:put/get/delete/scan/subscribe | +| GridCell | `grid-db/src/core/grid-cell.js` | 格点数据模型:四元组寻址 (ns, x, y, layer) | +| WAL | `grid-db/src/storage/wal.js` | Write-Ahead Log:崩溃恢复保证 | +| PageManager | `grid-db/src/storage/page-manager.js` | 页管理器:固定大小页分配/读写/释放 | +| EventLog | `grid-db/src/events/event-log.js` | 事件溯源:不可变事件流 + 审计日志 | diff --git a/grid-db/docs/architecture.md b/grid-db/docs/architecture.md new file mode 100644 index 00000000..f38eb613 --- /dev/null +++ b/grid-db/docs/architecture.md @@ -0,0 +1,170 @@ +# Grid-DB 架构设计文档 + +> 项目编号:PRJ-GDB-001 +> 版权:国作登字-2026-A-00037559 +> 版本:v0.1.0 · Phase 0 +> 签发:AG-SY-01 霜砚 · 执行:AG-ZY-01 铸渊 + +--- + +## 1. 本体论锚定 [ONT-PATCH-006] + +### Grid-DB · 纸 · 格子 · 笔迹 + +**Grid-DB = 纸** + +笔需要纸来承载笔迹。Grid-DB 是纸。纸上有格子(格点),每个格子是最小寻址单元。 +纸不属于笔,纸属于写字的人。 + +**格点 = 格子** + +格子是纸上的最小单元。不同的格子存不同的数据。 +同一个格子可以有多层(raw → cleaned → indexed → semantic)。 + +**事件 = 笔迹** + +每写一笔,笔迹就留在了历史中,不可篡改。 +天眼通过观察笔迹,发现异常行为。 + +### 与 EXE-Engine 的关系 + +| 层 | 组件 | 职责 | 本体论 | +|---|---|---|---| +| 执行层 | EXE-Engine | 模型调度、任务编排、算力路由 | 笔 | +| 存储层 | Grid-DB | 数据持久化、格点索引、事件流 | 纸 | +| 采集层 | DC v1.0 | 外部数据采集与清洗 | 墨水收集器 | + +--- + +## 2. 核心组件 + +### 2.1 GridCell — 格点数据模型 + +文件:`src/core/grid-cell.js` + +四元组寻址:`(namespace, grid_x, grid_y, layer)` + +- **namespace** — 隔离不同 Agent / 项目的数据域 +- **grid_x / grid_y** — 坐标轴 +- **layer** — 数据层级:raw → cleaned → indexed → semantic + +支持: +- JSON 序列化 / 反序列化 +- 二进制 Buffer 编解码(用于页存储) +- 键生成(`toKey()` → `ns:x:y:layer`) +- 曼哈顿距离计算 + +### 2.2 WAL — Write-Ahead Log + +文件:`src/storage/wal.js` + +所有写操作先追加到 WAL 文件,崩溃后可从 WAL 恢复。 + +记录格式: +``` +[length:4][seqNo:4][op:1][keyLen:2][key:N][dataLen:4][data:M][checksum:4] +``` + +操作码: +- `0x01` = PUT +- `0x02` = DELETE + +特性: +- 追加写入 + fsync 保证持久化 +- XOR 校验和验证完整性 +- 支持截断(checkpoint 后清除已持久化记录) +- 崩溃恢复:重新打开时自动读取并恢复 + +### 2.3 PageManager — 页管理器 + +文件:`src/storage/page-manager.js` + +以固定大小页(默认 4096 字节)为最小 I/O 单位。 + +文件结构: +``` +[Header: 1 page] [Page 1] [Page 2] ... [Page N] +``` + +Header 页:`[magic:4][version:2][pageSize:4][pageCount:4][freeListHead:4]` +数据页:`[flags:1][nextFree:4][dataLen:4][data:N]` + +特性: +- 空闲链表管理:释放的页自动回收复用 +- 持久化:重新打开文件后数据完好 +- 文件格式验证(magic number `0x47444230` = 'GDB0') + +### 2.4 EventLog — 事件溯源日志 + +文件:`src/events/event-log.js` + +Phase 0:内存事件流(保留最近 1000 条) +Phase 1:持久化事件流 + 回放 + 订阅 + +每次写操作生成事件: +```json +{ + "eventId": "evt-xxxx", + "seqNo": 1, + "timestamp": "2026-03-26T13:00:00Z", + "namespace": "exe-engine", + "operation": "put", + "gridCellKey": "exe-engine:1:2:raw", + "payload": { "dataSize": 256 } +} +``` + +支持订阅回调,可被天眼系统监听。 + +### 2.5 GridAPI — 统一接口 + +文件:`src/api/grid-api.js` + +协调 WAL、PageManager、EventLog 协同工作。 + +```javascript +const { open } = require('./grid-db/src/index'); +const db = open({ dataDir: './my-data' }); + +// put +db.put('my-ns', { gridX: 1, gridY: 2, layer: 'raw' }, { hello: 'world' }); + +// get +const data = db.get('my-ns', { gridX: 1, gridY: 2, layer: 'raw' }); + +// scan +const results = db.scan('my-ns', { xRange: [0, 10], yRange: [0, 10], layer: 'raw' }); + +// delete +db.delete('my-ns', { gridX: 1, gridY: 2, layer: 'raw' }); + +// subscribe +db.subscribe('auditor', (event) => console.log(event)); + +// checkpoint (truncate WAL) +db.checkpoint(); + +db.close(); +``` + +--- + +## 3. 技术栈 + +| 层 | 选型 | 理由 | +|---|---|---| +| 语言 | Node.js 20 | 与 EXE-Engine 同栈 | +| 持久化 | 自研 (.gdb/.wal) | 零外部依赖,嵌入式 | +| 序列化 | JSON + Binary Buffer | JSON 调试友好,Buffer 高性能 | +| 测试 | 自定义 assert runner | 与 EXE-Engine 测试框架统一 | + +--- + +## 4. Phase 路线 + +| Phase | 目标 | 状态 | +|---|---|---| +| P0 基础骨架 | GridCell + WAL + PageManager + EventLog + GridAPI | 🟢 完成 | +| P1 索引与查询 | B+Tree + Namespace 隔离 + 范围扫描 + Event 持久化 | ⚪ 待开始 | +| P2 高级特性 | HNSW 向量索引 + EXE-Engine 对接 + DC 迁移 + 天眼审计 | ⚪ 待开始 | +| P3 生产就绪 | 性能基准 + 资源计量 + 全文索引 + 文档 | ⚪ 待开始 | diff --git a/grid-db/package.json b/grid-db/package.json new file mode 100644 index 00000000..75abec96 --- /dev/null +++ b/grid-db/package.json @@ -0,0 +1,16 @@ +{ + "name": "grid-db", + "version": "0.1.0", + "description": "AGE OS · 自研逻辑格点数据库 (Grid-DB) · PRJ-GDB-001", + "main": "src/index.js", + "scripts": { + "test": "node tests/smoke/grid-db.test.js", + "status": "node src/index.js status" + }, + "keywords": ["age-os", "grid-db", "embedded-database", "storage-engine"], + "license": "UNLICENSED", + "private": true, + "engines": { + "node": ">=20" + } +} diff --git a/grid-db/src/api/grid-api.js b/grid-db/src/api/grid-api.js new file mode 100644 index 00000000..372e6ec3 --- /dev/null +++ b/grid-db/src/api/grid-api.js @@ -0,0 +1,282 @@ +// grid-db/src/api/grid-api.js +// Grid-DB · API 层 +// 统一 CRUD / Query 接口 +// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-005 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const GridCell = require('../core/grid-cell'); +const WAL = require('../storage/wal'); +const PageManager = require('../storage/page-manager'); +const EventLog = require('../events/event-log'); + +/** + * GridAPI — Grid-DB 统一接口 + * + * 提供 put / get / delete / scan 等操作, + * 内部协调 WAL、PageManager、EventLog 协同工作。 + * + * 本体论锚定:API = 笔的握持接口。 + * 无论笔内部结构如何,写字的人只需要握住笔就能写字。 + */ +class GridAPI { + /** + * @param {object} config + * @param {string} config.dataDir 数据目录 + * @param {number} [config.pageSize] 页大小 + */ + constructor(config) { + this._dataDir = config.dataDir; + this._pageSize = config.pageSize || PageManager.DEFAULT_PAGE_SIZE; + + // 核心组件 + this._wal = new WAL(`${this._dataDir}/grid.wal`); + this._pageManager = new PageManager(`${this._dataDir}/grid.gdb`, { + pageSize: this._pageSize + }); + this._eventLog = new EventLog(); + + // 内存索引:key → pageId + this._index = new Map(); + + // 从 WAL 恢复 + this._recoverFromWAL(); + } + + /** + * 写入格点数据 + * @param {string} namespace 命名空间 + * @param {GridCell|object} gridCell 格点(或 { gridX, gridY, layer }) + * @param {*} data 数据 + * @returns {object} { key, seqNo, event } + */ + put(namespace, gridCell, data) { + const cell = this._resolveCell(namespace, gridCell); + const key = cell.toKey(); + const dataBuf = Buffer.from(JSON.stringify(data)); + + // 1. 先写 WAL + const seqNo = this._wal.appendPut(key, dataBuf); + + // 2. 写入 PageManager + let pageId = this._index.get(key); + if (pageId !== undefined) { + // 更新现有页 + this._pageManager.writePage(pageId, dataBuf); + } else { + // 分配新页 + pageId = this._pageManager.allocPage(); + this._pageManager.writePage(pageId, dataBuf); + this._index.set(key, pageId); + } + + // 3. 记录事件 + const event = this._eventLog.append(namespace, 'put', key, { + dataSize: dataBuf.length + }); + + return { key, seqNo, event }; + } + + /** + * 读取格点数据 + * @param {string} namespace 命名空间 + * @param {GridCell|object} gridCell 格点 + * @returns {*|null} 数据,不存在返回 null + */ + get(namespace, gridCell) { + const cell = this._resolveCell(namespace, gridCell); + const key = cell.toKey(); + const pageId = this._index.get(key); + + if (pageId === undefined) { + return null; + } + + const dataBuf = this._pageManager.readPage(pageId); + if (!dataBuf) { + return null; + } + + try { + return JSON.parse(dataBuf.toString('utf-8')); + } catch { + return dataBuf; + } + } + + /** + * 删除格点数据 + * @param {string} namespace 命名空间 + * @param {GridCell|object} gridCell 格点 + * @returns {boolean} 是否删除成功 + */ + delete(namespace, gridCell) { + const cell = this._resolveCell(namespace, gridCell); + const key = cell.toKey(); + const pageId = this._index.get(key); + + if (pageId === undefined) { + return false; + } + + // 1. 先写 WAL + this._wal.appendDelete(key); + + // 2. 释放页 + this._pageManager.freePage(pageId); + this._index.delete(key); + + // 3. 记录事件 + this._eventLog.append(namespace, 'delete', key); + + return true; + } + + /** + * 范围扫描(Phase 0 简版:基于内存索引) + * @param {string} namespace + * @param {object} range { xRange: [min, max], yRange: [min, max], layer } + * @returns {Array<{ cell: GridCell, data: * }>} + */ + scan(namespace, range = {}) { + const results = []; + + for (const [key, pageId] of this._index) { + try { + const cell = GridCell.fromKey(key); + + if (cell.namespace !== namespace) continue; + if (range.layer && cell.layer !== range.layer) continue; + + if (range.xRange) { + if (cell.gridX < range.xRange[0] || cell.gridX > range.xRange[1]) continue; + } + if (range.yRange) { + if (cell.gridY < range.yRange[0] || cell.gridY > range.yRange[1]) continue; + } + + const dataBuf = this._pageManager.readPage(pageId); + if (dataBuf) { + let data; + try { data = JSON.parse(dataBuf.toString('utf-8')); } catch { data = dataBuf; } + results.push({ cell, data }); + } + } catch { + // 跳过无效键 + } + } + + this._eventLog.append(namespace, 'scan', `scan:${namespace}`, { + resultCount: results.length + }); + + return results; + } + + /** + * 获取命名空间统计 + * @param {string} [namespace] 不传则统计全部 + * @returns {object} + */ + stats(namespace) { + let count = 0; + for (const key of this._index.keys()) { + if (!namespace || key.startsWith(namespace + ':')) { + count++; + } + } + + return { + namespace: namespace || '*', + cellCount: count, + wal: this._wal.getStatus(), + pageManager: this._pageManager.getStatus(), + eventLog: this._eventLog.getStatus() + }; + } + + /** + * 订阅事件流 + * @param {string} subscriberId + * @param {Function} handler + * @returns {Function} 取消订阅函数 + */ + subscribe(subscriberId, handler) { + return this._eventLog.subscribe(subscriberId, handler); + } + + /** + * 执行 checkpoint:截断 WAL + */ + checkpoint() { + this._wal.truncate(); + } + + /** + * 关闭数据库 + */ + close() { + this._wal.close(); + this._pageManager.close(); + } + + // ── 内部方法 ── + + /** + * 解析格点参数 + * @param {string} namespace + * @param {GridCell|object} input + * @returns {GridCell} + */ + _resolveCell(namespace, input) { + if (input instanceof GridCell) { + return input; + } + return new GridCell( + namespace, + input.gridX ?? input.grid_x ?? 0, + input.gridY ?? input.grid_y ?? 0, + input.layer || 'raw' + ); + } + + /** + * 从 WAL 恢复数据到内存索引 + */ + _recoverFromWAL() { + const entries = this._wal.recover(); + + for (const entry of entries) { + if (entry.op === WAL.OP_PUT) { + // 检查是否已有页 + let pageId = this._index.get(entry.key); + if (pageId !== undefined) { + // 页已存在,更新 + try { + this._pageManager.writePage(pageId, entry.data); + } catch { + // 页可能已损坏,分配新页 + pageId = this._pageManager.allocPage(); + this._pageManager.writePage(pageId, entry.data); + this._index.set(entry.key, pageId); + } + } else { + // 分配新页并写入 + pageId = this._pageManager.allocPage(); + this._pageManager.writePage(pageId, entry.data); + this._index.set(entry.key, pageId); + } + } else if (entry.op === WAL.OP_DELETE) { + const pageId = this._index.get(entry.key); + if (pageId !== undefined) { + this._pageManager.freePage(pageId); + this._index.delete(entry.key); + } + } + } + } +} + +module.exports = GridAPI; diff --git a/grid-db/src/core/grid-cell.js b/grid-db/src/core/grid-cell.js new file mode 100644 index 00000000..5818e846 --- /dev/null +++ b/grid-db/src/core/grid-cell.js @@ -0,0 +1,164 @@ +// grid-db/src/core/grid-cell.js +// Grid-DB · 格点数据模型 +// 四元组寻址:(namespace, grid_x, grid_y, layer) +// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-004 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +/** + * GridCell — 逻辑格点 + * + * 数据的最小寻址单元,由四元组定位: + * namespace — 隔离不同 Agent / 项目的数据域 + * grid_x — X 轴坐标 + * grid_y — Y 轴坐标 + * layer — 同一坐标下的数据层级 (raw → cleaned → indexed → semantic) + * + * 本体论锚定:格点 = 纸上的一个格子。 + * 笔在纸上写字,每个字都有坐标。格点是纸的最小单元。 + */ + +const VALID_LAYERS = ['raw', 'cleaned', 'indexed', 'semantic']; + +class GridCell { + /** + * @param {string} namespace 命名空间 + * @param {number} gridX X 坐标 + * @param {number} gridY Y 坐标 + * @param {string} [layer] 数据层级,默认 'raw' + */ + constructor(namespace, gridX, gridY, layer = 'raw') { + if (!namespace || typeof namespace !== 'string') { + throw new Error('GridCell: namespace 必须是非空字符串'); + } + if (typeof gridX !== 'number' || !Number.isFinite(gridX)) { + throw new Error('GridCell: grid_x 必须是有限数值'); + } + if (typeof gridY !== 'number' || !Number.isFinite(gridY)) { + throw new Error('GridCell: grid_y 必须是有限数值'); + } + if (!VALID_LAYERS.includes(layer)) { + throw new Error(`GridCell: layer 必须是 ${VALID_LAYERS.join('/')} 之一,收到: ${layer}`); + } + + this.namespace = namespace; + this.gridX = gridX; + this.gridY = gridY; + this.layer = layer; + } + + /** + * 生成格点唯一键(用于存储索引) + * @returns {string} + */ + toKey() { + return `${this.namespace}:${this.gridX}:${this.gridY}:${this.layer}`; + } + + /** + * 从唯一键解析格点 + * @param {string} key + * @returns {GridCell} + */ + static fromKey(key) { + const parts = key.split(':'); + if (parts.length !== 4) { + throw new Error(`GridCell.fromKey: 无效键格式: ${key}`); + } + return new GridCell(parts[0], Number(parts[1]), Number(parts[2]), parts[3]); + } + + /** + * 序列化为 JSON 可存储对象 + * @returns {object} + */ + serialize() { + return { + namespace: this.namespace, + grid_x: this.gridX, + grid_y: this.gridY, + layer: this.layer, + key: this.toKey() + }; + } + + /** + * 从序列化对象还原 + * @param {object} obj + * @returns {GridCell} + */ + static deserialize(obj) { + return new GridCell(obj.namespace, obj.grid_x, obj.grid_y, obj.layer); + } + + /** + * 序列化为二进制 Buffer(用于页存储) + * 格式:[nsLen:2][ns:N][x:8][y:8][layerIdx:1] + * @returns {Buffer} + */ + toBuffer() { + const nsBytes = Buffer.from(this.namespace, 'utf-8'); + const layerIdx = VALID_LAYERS.indexOf(this.layer); + // 2 (nsLen) + nsLen + 8 (x float64) + 8 (y float64) + 1 (layerIdx) + const buf = Buffer.alloc(2 + nsBytes.length + 8 + 8 + 1); + let offset = 0; + + buf.writeUInt16BE(nsBytes.length, offset); offset += 2; + nsBytes.copy(buf, offset); offset += nsBytes.length; + buf.writeDoubleBE(this.gridX, offset); offset += 8; + buf.writeDoubleBE(this.gridY, offset); offset += 8; + buf.writeUInt8(layerIdx, offset); + + return buf; + } + + /** + * 从二进制 Buffer 还原格点 + * @param {Buffer} buf + * @param {number} [offset] + * @returns {{ cell: GridCell, bytesRead: number }} + */ + static fromBuffer(buf, offset = 0) { + const start = offset; + const nsLen = buf.readUInt16BE(offset); offset += 2; + const namespace = buf.toString('utf-8', offset, offset + nsLen); offset += nsLen; + const gridX = buf.readDoubleBE(offset); offset += 8; + const gridY = buf.readDoubleBE(offset); offset += 8; + const layerIdx = buf.readUInt8(offset); offset += 1; + + return { + cell: new GridCell(namespace, gridX, gridY, VALID_LAYERS[layerIdx]), + bytesRead: offset - start + }; + } + + /** + * 检查两个格点是否相等 + * @param {GridCell} other + * @returns {boolean} + */ + equals(other) { + return this.namespace === other.namespace && + this.gridX === other.gridX && + this.gridY === other.gridY && + this.layer === other.layer; + } + + /** + * 计算到另一个格点的曼哈顿距离(同 namespace + layer) + * @param {GridCell} other + * @returns {number} + */ + distanceTo(other) { + return Math.abs(this.gridX - other.gridX) + Math.abs(this.gridY - other.gridY); + } + + toString() { + return `GridCell(${this.toKey()})`; + } +} + +GridCell.VALID_LAYERS = VALID_LAYERS; + +module.exports = GridCell; diff --git a/grid-db/src/events/event-log.js b/grid-db/src/events/event-log.js new file mode 100644 index 00000000..8a36a65f --- /dev/null +++ b/grid-db/src/events/event-log.js @@ -0,0 +1,123 @@ +// grid-db/src/events/event-log.js +// Grid-DB · 事件溯源日志 +// 不可变事件流 + 审计日志 +// PRJ-GDB-001 · Phase 0(基础结构,P1 完善回放与订阅) +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const { randomUUID } = require('crypto'); + +/** + * EventLog — 事件溯源日志 + * + * 每次写操作生成不可变 Event,可被天眼订阅实现实时审计。 + * + * 本体论锚定:事件日志 = 笔的笔迹记录。 + * 每写一笔,笔迹就留在了历史中,不可篡改。 + * 天眼通过观察笔迹,发现异常行为。 + * + * Phase 0:内存事件流(保留最近 1000 条) + * Phase 1:持久化事件流 + 回放 + 订阅 + */ +class EventLog { + /** + * @param {object} [options] + * @param {number} [options.maxEvents] 最大保留事件数 + */ + constructor(options = {}) { + this._maxEvents = options.maxEvents || 1000; + this._events = []; + this._subscribers = []; + this._seqNo = 0; + } + + /** + * 记录事件 + * @param {string} namespace 命名空间 + * @param {string} operation 操作类型 (put | delete | scan) + * @param {string} gridCellKey 格点键 + * @param {object} [payload] 额外数据 + * @returns {object} 事件对象 + */ + append(namespace, operation, gridCellKey, payload = null) { + this._seqNo++; + const event = { + eventId: `evt-${randomUUID().slice(0, 12)}`, + seqNo: this._seqNo, + timestamp: new Date().toISOString(), + namespace, + operation, + gridCellKey, + payload + }; + + this._events.push(event); + + // 容量控制 + if (this._events.length > this._maxEvents) { + this._events = this._events.slice(-this._maxEvents); + } + + // 通知订阅者 + for (const sub of this._subscribers) { + try { + sub.handler(event); + } catch { + // 订阅者错误不阻断主流程 + } + } + + return event; + } + + /** + * 获取最近 N 条事件 + * @param {number} [n] + * @returns {object[]} + */ + getRecent(n = 20) { + return this._events.slice(-n); + } + + /** + * 按命名空间过滤事件 + * @param {string} namespace + * @param {number} [limit] + * @returns {object[]} + */ + getByNamespace(namespace, limit = 50) { + return this._events + .filter(e => e.namespace === namespace) + .slice(-limit); + } + + /** + * 订阅事件流(Phase 0 简版,P1 支持持久订阅) + * @param {string} subscriberId + * @param {Function} handler + * @returns {Function} 取消订阅函数 + */ + subscribe(subscriberId, handler) { + const sub = { id: subscriberId, handler }; + this._subscribers.push(sub); + return () => { + this._subscribers = this._subscribers.filter(s => s.id !== subscriberId); + }; + } + + /** + * 获取事件日志状态 + * @returns {object} + */ + getStatus() { + return { + totalEvents: this._events.length, + maxEvents: this._maxEvents, + seqNo: this._seqNo, + subscriberCount: this._subscribers.length + }; + } +} + +module.exports = EventLog; diff --git a/grid-db/src/index.js b/grid-db/src/index.js new file mode 100644 index 00000000..27ffb134 --- /dev/null +++ b/grid-db/src/index.js @@ -0,0 +1,86 @@ +// grid-db/src/index.js +// Grid-DB · 自研逻辑格点数据库 · 主入口 +// PRJ-GDB-001 · Phase 0 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +const GridCell = require('./core/grid-cell'); +const WAL = require('./storage/wal'); +const PageManager = require('./storage/page-manager'); +const EventLog = require('./events/event-log'); +const GridAPI = require('./api/grid-api'); + +/** + * Grid-DB 初始化器 + * + * 本体论锚定 [ONT-PATCH-006]: + * 笔需要纸来承载笔迹。 + * Grid-DB = 纸。纸上有格子(格点),每个格子是最小寻址单元。 + * 笔在格子里写字,格子记住了所有笔迹。 + * 纸不属于笔,纸属于写字的人。 + */ + +/** + * 打开或创建一个 Grid-DB 实例 + * + * @param {object} [config] + * @param {string} [config.dataDir] 数据目录(默认 grid-db/data) + * @param {number} [config.pageSize] 页大小(默认 4096) + * @returns {GridAPI} + */ +function open(config = {}) { + const dataDir = config.dataDir || path.resolve(__dirname, '../data'); + + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + + return new GridAPI({ + dataDir, + pageSize: config.pageSize || PageManager.DEFAULT_PAGE_SIZE + }); +} + +// ── CLI 入口 ── + +if (require.main === module) { + const cmd = process.argv[2]; + + if (cmd === 'status') { + const db = open(); + const status = db.stats(); + // eslint-disable-next-line no-console + console.log(JSON.stringify(status, null, 2)); + db.close(); + } else { + // eslint-disable-next-line no-console + console.log([ + '🗄️ Grid-DB v0.1.0 · PRJ-GDB-001 · Phase 0', + '', + '本体论锚定:Grid-DB = 纸 · 格点 = 格子 · WAL = 草稿本', + '', + '用法:', + ' node grid-db/src/index.js status 查看数据库状态', + '', + '组件:', + ' GridAPI 统一 API 接口', + ' GridCell 格点数据模型', + ' WAL Write-Ahead Log', + ' PageManager 页管理器', + ' EventLog 事件溯源日志', + ].join('\n')); + } +} + +module.exports = { + open, + GridAPI, + GridCell, + WAL, + PageManager, + EventLog +}; diff --git a/grid-db/src/storage/page-manager.js b/grid-db/src/storage/page-manager.js new file mode 100644 index 00000000..86c0d81c --- /dev/null +++ b/grid-db/src/storage/page-manager.js @@ -0,0 +1,272 @@ +// grid-db/src/storage/page-manager.js +// Grid-DB · 页管理器 +// 固定大小页分配、读写与释放 +// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-003 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +/** + * PageManager — 页管理器 + * + * 以固定大小的"页"为最小 I/O 单位管理磁盘数据。 + * + * 文件结构: + * [Header: 1 page] [Page 1] [Page 2] ... [Page N] + * + * Header 页格式(前 64 字节): + * [magic:4][version:2][pageSize:4][pageCount:4][freeListHead:4]...[reserved] + * + * 数据页格式: + * [flags:1][nextFree:4][dataLen:4][data:pageSize-9] + * flags: 0x00=free, 0x01=used + * + * 本体论锚定:页 = 纸的一页。 + * 格子画在纸上,纸由一页一页组成。页管理器管理每一页的分配和回收。 + */ + +const MAGIC = 0x47444230; // 'GDB0' +const VERSION = 1; +const DEFAULT_PAGE_SIZE = 4096; +const HEADER_FIXED_SIZE = 64; + +// 页标志 +const FLAG_FREE = 0x00; +const FLAG_USED = 0x01; + +// 页头部大小(flags + nextFree + dataLen) +const PAGE_HEADER_SIZE = 9; + +class PageManager { + /** + * @param {string} filePath 数据文件路径 + * @param {object} [options] + * @param {number} [options.pageSize] 页大小(字节),默认 4096 + */ + constructor(filePath, options = {}) { + this._filePath = filePath; + this._pageSize = options.pageSize || DEFAULT_PAGE_SIZE; + this._pageCount = 0; + this._freeListHead = 0; // 0 表示无空闲页 + this._fd = null; + this._init(); + } + + /** + * 初始化:打开或创建数据文件 + */ + _init() { + const dir = path.dirname(this._filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(this._filePath)) { + this._fd = fs.openSync(this._filePath, 'r+'); + this._readHeader(); + } else { + this._fd = fs.openSync(this._filePath, 'w+'); + this._pageCount = 0; + this._freeListHead = 0; + this._writeHeader(); + } + } + + /** + * 分配一个新页 + * @returns {number} 页号(从 1 开始,0 是 header) + */ + allocPage() { + let pageId; + + if (this._freeListHead > 0) { + // 从空闲链表取一页 + pageId = this._freeListHead; + const pageBuf = this._readPageRaw(pageId); + this._freeListHead = pageBuf.readUInt32BE(1); // nextFree + this._writeHeader(); + } else { + // 追加新页 + this._pageCount++; + pageId = this._pageCount; + + // 扩展文件 + const emptyPage = Buffer.alloc(this._pageSize); + const fileOffset = this._pageOffset(pageId); + fs.writeSync(this._fd, emptyPage, 0, this._pageSize, fileOffset); + + this._writeHeader(); + } + + // 标记为已使用 + const pageBuf = Buffer.alloc(this._pageSize); + pageBuf.writeUInt8(FLAG_USED, 0); + pageBuf.writeUInt32BE(0, 1); // nextFree = 0 + pageBuf.writeUInt32BE(0, 5); // dataLen = 0 + fs.writeSync(this._fd, pageBuf, 0, this._pageSize, this._pageOffset(pageId)); + + return pageId; + } + + /** + * 写入数据到指定页 + * @param {number} pageId 页号 + * @param {Buffer} data 数据(不能超过 pageSize - PAGE_HEADER_SIZE) + */ + writePage(pageId, data) { + if (pageId < 1 || pageId > this._pageCount) { + throw new Error(`PageManager: 无效页号 ${pageId}`); + } + + const maxDataSize = this._pageSize - PAGE_HEADER_SIZE; + if (data.length > maxDataSize) { + throw new Error(`PageManager: 数据太大 (${data.length} > ${maxDataSize})`); + } + + const pageBuf = Buffer.alloc(this._pageSize); + pageBuf.writeUInt8(FLAG_USED, 0); + pageBuf.writeUInt32BE(0, 1); // nextFree + pageBuf.writeUInt32BE(data.length, 5); // dataLen + data.copy(pageBuf, PAGE_HEADER_SIZE); + + fs.writeSync(this._fd, pageBuf, 0, this._pageSize, this._pageOffset(pageId)); + } + + /** + * 读取指定页的数据 + * @param {number} pageId 页号 + * @returns {Buffer|null} 数据,如果页是空闲的返回 null + */ + readPage(pageId) { + if (pageId < 1 || pageId > this._pageCount) { + return null; + } + + const pageBuf = this._readPageRaw(pageId); + const flags = pageBuf.readUInt8(0); + + if (flags !== FLAG_USED) { + return null; + } + + const dataLen = pageBuf.readUInt32BE(5); + return Buffer.from(pageBuf.subarray(PAGE_HEADER_SIZE, PAGE_HEADER_SIZE + dataLen)); + } + + /** + * 释放页(加入空闲链表) + * @param {number} pageId 页号 + */ + freePage(pageId) { + if (pageId < 1 || pageId > this._pageCount) { + throw new Error(`PageManager: 无效页号 ${pageId}`); + } + + const pageBuf = Buffer.alloc(this._pageSize); + pageBuf.writeUInt8(FLAG_FREE, 0); + pageBuf.writeUInt32BE(this._freeListHead, 1); // nextFree = 旧链表头 + pageBuf.writeUInt32BE(0, 5); // dataLen = 0 + fs.writeSync(this._fd, pageBuf, 0, this._pageSize, this._pageOffset(pageId)); + + this._freeListHead = pageId; + this._writeHeader(); + } + + /** + * 获取页管理器状态 + * @returns {object} + */ + getStatus() { + let fileSize = 0; + try { + fileSize = fs.fstatSync(this._fd).size; + } catch { + // fd 不可用 + } + return { + filePath: this._filePath, + pageSize: this._pageSize, + pageCount: this._pageCount, + freeListHead: this._freeListHead, + maxDataPerPage: this._pageSize - PAGE_HEADER_SIZE, + fileSize + }; + } + + /** + * 关闭数据文件 + */ + close() { + if (this._fd !== null) { + fs.closeSync(this._fd); + this._fd = null; + } + } + + // ── 内部方法 ── + + /** + * 计算页在文件中的偏移量 + * @param {number} pageId + * @returns {number} + */ + _pageOffset(pageId) { + // 第 0 页是 header,数据页从 1 开始 + return pageId * this._pageSize; + } + + /** + * 写入文件头 + */ + _writeHeader() { + const headerBuf = Buffer.alloc(this._pageSize); + headerBuf.writeUInt32BE(MAGIC, 0); + headerBuf.writeUInt16BE(VERSION, 4); + headerBuf.writeUInt32BE(this._pageSize, 6); + headerBuf.writeUInt32BE(this._pageCount, 10); + headerBuf.writeUInt32BE(this._freeListHead, 14); + fs.writeSync(this._fd, headerBuf, 0, this._pageSize, 0); + } + + /** + * 读取文件头 + */ + _readHeader() { + const headerBuf = Buffer.alloc(HEADER_FIXED_SIZE); + fs.readSync(this._fd, headerBuf, 0, HEADER_FIXED_SIZE, 0); + + const magic = headerBuf.readUInt32BE(0); + if (magic !== MAGIC) { + throw new Error(`PageManager: 无效文件格式 (magic=0x${magic.toString(16)})`); + } + + const version = headerBuf.readUInt16BE(4); + if (version !== VERSION) { + throw new Error(`PageManager: 不支持的版本 ${version}`); + } + + this._pageSize = headerBuf.readUInt32BE(6); + this._pageCount = headerBuf.readUInt32BE(10); + this._freeListHead = headerBuf.readUInt32BE(14); + } + + /** + * 读取原始页数据 + * @param {number} pageId + * @returns {Buffer} + */ + _readPageRaw(pageId) { + const buf = Buffer.alloc(this._pageSize); + fs.readSync(this._fd, buf, 0, this._pageSize, this._pageOffset(pageId)); + return buf; + } +} + +PageManager.PAGE_HEADER_SIZE = PAGE_HEADER_SIZE; +PageManager.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE; +PageManager.MAGIC = MAGIC; + +module.exports = PageManager; diff --git a/grid-db/src/storage/wal.js b/grid-db/src/storage/wal.js new file mode 100644 index 00000000..59ff120b --- /dev/null +++ b/grid-db/src/storage/wal.js @@ -0,0 +1,231 @@ +// grid-db/src/storage/wal.js +// Grid-DB · Write-Ahead Log +// 所有写操作先写 WAL,崩溃恢复保证 +// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-002 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +/** + * WAL(Write-Ahead Log) + * + * 所有写操作先追加到 WAL 文件,确保崩溃后可从 WAL 恢复。 + * + * 日志格式(每条记录): + * [length:4][seqNo:4][op:1][keyLen:2][key:N][dataLen:4][data:M][checksum:4] + * + * 操作码: + * 0x01 = PUT + * 0x02 = DELETE + * + * 本体论锚定:WAL = 笔的草稿本。 + * 正式写到纸上之前,先在草稿本上记一笔。 + * 万一出了意外,草稿本里有记录,可以恢复。 + */ + +const OP_PUT = 0x01; +const OP_DELETE = 0x02; + +class WAL { + /** + * @param {string} walPath WAL 文件路径 + */ + constructor(walPath) { + this._path = walPath; + this._seqNo = 0; + this._fd = null; + this._init(); + } + + /** + * 初始化 WAL 文件 + */ + _init() { + const dir = path.dirname(this._path); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(this._path)) { + // 从现有 WAL 文件中恢复最大序列号 + const entries = this._readAll(); + if (entries.length > 0) { + this._seqNo = entries[entries.length - 1].seqNo; + } + } + + // 追加模式打开 + this._fd = fs.openSync(this._path, 'a'); + } + + /** + * 追加一条 PUT 记录 + * @param {string} key 格点键 + * @param {Buffer} data 数据 + * @returns {number} 序列号 + */ + appendPut(key, data) { + return this._append(OP_PUT, key, data); + } + + /** + * 追加一条 DELETE 记录 + * @param {string} key 格点键 + * @returns {number} 序列号 + */ + appendDelete(key) { + return this._append(OP_DELETE, key, Buffer.alloc(0)); + } + + /** + * 内部:追加记录 + * @param {number} op 操作码 + * @param {string} key 键 + * @param {Buffer} data 数据 + * @returns {number} 序列号 + */ + _append(op, key, data) { + this._seqNo++; + const keyBuf = Buffer.from(key, 'utf-8'); + const dataBuf = data instanceof Buffer ? data : Buffer.from(JSON.stringify(data)); + + // 构建记录 + // [length:4][seqNo:4][op:1][keyLen:2][key:N][dataLen:4][data:M][checksum:4] + const recordLen = 4 + 1 + 2 + keyBuf.length + 4 + dataBuf.length + 4; + const buf = Buffer.alloc(4 + recordLen); + let offset = 0; + + buf.writeUInt32BE(recordLen, offset); offset += 4; // length + buf.writeUInt32BE(this._seqNo, offset); offset += 4; // seqNo + buf.writeUInt8(op, offset); offset += 1; // op + buf.writeUInt16BE(keyBuf.length, offset); offset += 2; // keyLen + keyBuf.copy(buf, offset); offset += keyBuf.length; // key + buf.writeUInt32BE(dataBuf.length, offset); offset += 4; // dataLen + dataBuf.copy(buf, offset); offset += dataBuf.length; // data + + // 计算简单校验和(所有字节异或) + let checksum = 0; + for (let i = 0; i < offset; i++) { + checksum = (checksum ^ buf[i]) & 0xFFFFFFFF; + } + buf.writeUInt32BE(checksum >>> 0, offset); + + fs.writeSync(this._fd, buf); + fs.fsyncSync(this._fd); + + return this._seqNo; + } + + /** + * 读取所有 WAL 条目(用于崩溃恢复) + * @returns {Array<{ seqNo: number, op: number, key: string, data: Buffer }>} + */ + recover() { + return this._readAll(); + } + + /** + * 内部:读取 WAL 文件的所有记录 + * @returns {Array<{ seqNo: number, op: number, key: string, data: Buffer }>} + */ + _readAll() { + if (!fs.existsSync(this._path)) { + return []; + } + + const fileBuf = fs.readFileSync(this._path); + const entries = []; + let offset = 0; + + while (offset < fileBuf.length) { + // 至少需要 4 字节读长度 + if (offset + 4 > fileBuf.length) break; + + const recordLen = fileBuf.readUInt32BE(offset); + const totalLen = 4 + recordLen; + + // 完整性检查 + if (offset + totalLen > fileBuf.length) break; + + const recordStart = offset + 4; + let rOffset = recordStart; + + const seqNo = fileBuf.readUInt32BE(rOffset); rOffset += 4; + const op = fileBuf.readUInt8(rOffset); rOffset += 1; + const keyLen = fileBuf.readUInt16BE(rOffset); rOffset += 2; + const key = fileBuf.toString('utf-8', rOffset, rOffset + keyLen); rOffset += keyLen; + const dataLen = fileBuf.readUInt32BE(rOffset); rOffset += 4; + const data = fileBuf.subarray(rOffset, rOffset + dataLen); rOffset += dataLen; + const storedChecksum = fileBuf.readUInt32BE(rOffset); + + // 校验 + let checksum = 0; + for (let i = offset; i < rOffset; i++) { + checksum = (checksum ^ fileBuf[i]) & 0xFFFFFFFF; + } + + if ((checksum >>> 0) === storedChecksum) { + entries.push({ seqNo, op, key, data: Buffer.from(data) }); + } + // 校验失败的记录跳过(部分写入的残余) + + offset += totalLen; + } + + return entries; + } + + /** + * 截断 WAL(在 checkpoint 后调用,清除已持久化的记录) + */ + truncate() { + this.close(); + fs.writeFileSync(this._path, Buffer.alloc(0)); + this._seqNo = 0; + this._fd = fs.openSync(this._path, 'a'); + } + + /** + * 获取当前序列号 + * @returns {number} + */ + getSeqNo() { + return this._seqNo; + } + + /** + * 关闭 WAL 文件 + */ + close() { + if (this._fd !== null) { + fs.closeSync(this._fd); + this._fd = null; + } + } + + /** + * 获取 WAL 状态 + * @returns {object} + */ + getStatus() { + let fileSize = 0; + try { + fileSize = fs.statSync(this._path).size; + } catch { + // 文件不存在 + } + return { + path: this._path, + seqNo: this._seqNo, + fileSize + }; + } +} + +WAL.OP_PUT = OP_PUT; +WAL.OP_DELETE = OP_DELETE; + +module.exports = WAL; diff --git a/grid-db/tests/smoke/grid-db.test.js b/grid-db/tests/smoke/grid-db.test.js new file mode 100644 index 00000000..5960ad8f --- /dev/null +++ b/grid-db/tests/smoke/grid-db.test.js @@ -0,0 +1,419 @@ +// grid-db/tests/smoke/grid-db.test.js +// Grid-DB · 冒烟测试 +// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-005 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const { + open, + GridAPI, + GridCell, + WAL, + PageManager, + EventLog +} = require('../../src/index'); + +let passed = 0; +let failed = 0; + +function assert(condition, message) { + if (condition) { + passed++; + // eslint-disable-next-line no-console + console.log(` ✅ ${message}`); + } else { + failed++; + // eslint-disable-next-line no-console + console.error(` ❌ ${message}`); + } +} + +/** + * 创建临时测试目录 + * @param {string} suffix + * @returns {string} + */ +function makeTempDir(suffix) { + const dir = path.join(os.tmpdir(), `griddb-test-${suffix}-${Date.now()}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * 递归删除目录 + * @param {string} dir + */ +function cleanDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // 忽略清理错误 + } +} + +// eslint-disable-next-line no-console +console.log('🗄️ Grid-DB 冒烟测试 · Phase 0\n'); + +// ── 测试 1: 模块导出 ── +// eslint-disable-next-line no-console +console.log('── 测试 1: 模块导出完整性 ──'); +assert(typeof open === 'function', 'open 是函数'); +assert(typeof GridAPI === 'function', 'GridAPI 是构造函数'); +assert(typeof GridCell === 'function', 'GridCell 是构造函数'); +assert(typeof WAL === 'function', 'WAL 是构造函数'); +assert(typeof PageManager === 'function', 'PageManager 是构造函数'); +assert(typeof EventLog === 'function', 'EventLog 是构造函数'); + +// ── 测试 2: GridCell 四元组寻址 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 2: GridCell 四元组寻址 ──'); +const cell1 = new GridCell('agent-zy', 10, 20, 'raw'); +assert(cell1.namespace === 'agent-zy', 'namespace 正确'); +assert(cell1.gridX === 10, 'gridX 正确'); +assert(cell1.gridY === 20, 'gridY 正确'); +assert(cell1.layer === 'raw', 'layer 正确'); +assert(cell1.toKey() === 'agent-zy:10:20:raw', 'toKey() 正确'); + +// fromKey 还原 +const cell2 = GridCell.fromKey('test-ns:5:15:semantic'); +assert(cell2.namespace === 'test-ns', 'fromKey namespace 正确'); +assert(cell2.gridX === 5, 'fromKey gridX 正确'); +assert(cell2.gridY === 15, 'fromKey gridY 正确'); +assert(cell2.layer === 'semantic', 'fromKey layer 正确'); + +// 序列化 / 反序列化 +const serialized = cell1.serialize(); +assert(serialized.namespace === 'agent-zy', 'serialize namespace'); +assert(serialized.grid_x === 10, 'serialize grid_x'); +assert(serialized.grid_y === 20, 'serialize grid_y'); +assert(serialized.layer === 'raw', 'serialize layer'); + +const cell3 = GridCell.deserialize(serialized); +assert(cell3.equals(cell1), 'deserialize → equals 原始格点'); + +// 二进制序列化 +const buf = cell1.toBuffer(); +assert(buf instanceof Buffer, 'toBuffer 返回 Buffer'); +const { cell: cell4, bytesRead } = GridCell.fromBuffer(buf); +assert(cell4.equals(cell1), 'fromBuffer → equals 原始格点'); +assert(bytesRead === buf.length, 'bytesRead 等于 buffer 长度'); + +// 距离计算 +assert(cell1.distanceTo(cell2) === 10, 'distanceTo 曼哈顿距离正确'); + +// 无效参数 +let errThrown = false; +try { new GridCell('', 0, 0); } catch { errThrown = true; } +assert(errThrown, '空 namespace 抛出错误'); + +errThrown = false; +try { new GridCell('ns', 0, 0, 'invalid'); } catch { errThrown = true; } +assert(errThrown, '无效 layer 抛出错误'); + +// ── 测试 3: WAL 基础功能 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 3: WAL 基础功能 ──'); +const walDir = makeTempDir('wal'); +const wal = new WAL(path.join(walDir, 'test.wal')); + +const seq1 = wal.appendPut('ns:1:2:raw', Buffer.from('{"hello":"world"}')); +assert(seq1 === 1, 'appendPut 返回序列号 1'); + +const seq2 = wal.appendPut('ns:3:4:raw', Buffer.from('{"foo":"bar"}')); +assert(seq2 === 2, 'appendPut 返回序列号 2'); + +const seq3 = wal.appendDelete('ns:1:2:raw'); +assert(seq3 === 3, 'appendDelete 返回序列号 3'); + +assert(wal.getSeqNo() === 3, 'getSeqNo 正确'); + +// 恢复 +const entries = wal.recover(); +assert(entries.length === 3, '恢复 3 条记录'); +assert(entries[0].op === WAL.OP_PUT, '记录 1 是 PUT'); +assert(entries[0].key === 'ns:1:2:raw', '记录 1 键正确'); +assert(entries[1].op === WAL.OP_PUT, '记录 2 是 PUT'); +assert(entries[2].op === WAL.OP_DELETE, '记录 3 是 DELETE'); + +// 截断 +wal.truncate(); +assert(wal.getSeqNo() === 0, '截断后序列号归零'); +const afterTrunc = wal.recover(); +assert(afterTrunc.length === 0, '截断后无记录'); + +wal.close(); + +// 崩溃恢复测试:写入 → 关闭 → 重新打开 → 验证 +const wal2 = new WAL(path.join(walDir, 'crash.wal')); +wal2.appendPut('crash:1:1:raw', Buffer.from('{"data":"survive"}')); +wal2.appendPut('crash:2:2:raw', Buffer.from('{"data":"also"}')); +wal2.close(); + +const wal3 = new WAL(path.join(walDir, 'crash.wal')); +const recovered = wal3.recover(); +assert(recovered.length === 2, '崩溃恢复:2 条记录'); +assert(recovered[0].key === 'crash:1:1:raw', '崩溃恢复:键正确'); +assert(wal3.getSeqNo() === 2, '崩溃恢复:序列号正确'); +wal3.close(); +cleanDir(walDir); + +// ── 测试 4: PageManager 基础功能 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 4: PageManager 基础功能 ──'); +const pmDir = makeTempDir('pm'); +const pm = new PageManager(path.join(pmDir, 'test.gdb')); + +// 分配页 +const p1 = pm.allocPage(); +assert(p1 === 1, '首页号 = 1'); + +const p2 = pm.allocPage(); +assert(p2 === 2, '第二页号 = 2'); + +// 写入数据 +const testData = Buffer.from('Hello Grid-DB!'); +pm.writePage(p1, testData); + +// 读取数据 +const readData = pm.readPage(p1); +assert(readData !== null, '读取非 null'); +assert(readData.toString() === 'Hello Grid-DB!', '读取数据正确'); + +// 写入更长数据 +const longData = Buffer.from(JSON.stringify({ msg: '格点数据库测试', num: 42, arr: [1, 2, 3] })); +pm.writePage(p2, longData); +const readLong = pm.readPage(p2); +assert(readLong !== null, '读取长数据非 null'); +assert(JSON.parse(readLong.toString()).num === 42, '长数据内容正确'); + +// 释放页 +pm.freePage(p1); +const freedData = pm.readPage(p1); +assert(freedData === null, '释放后读取返回 null'); + +// 释放的页被复用 +const p3 = pm.allocPage(); +assert(p3 === 1, '释放的页号被复用'); + +// 状态 +const pmStatus = pm.getStatus(); +assert(pmStatus.pageSize === 4096, '页大小 = 4096'); +assert(pmStatus.pageCount === 2, '页数 = 2'); + +pm.close(); + +// 重新打开验证持久化 +const pm2 = new PageManager(path.join(pmDir, 'test.gdb')); +const rereadLong = pm2.readPage(2); +assert(rereadLong !== null, '重新打开后读取非 null'); +assert(JSON.parse(rereadLong.toString()).num === 42, '持久化数据正确'); +pm2.close(); +cleanDir(pmDir); + +// ── 测试 5: EventLog 基础功能 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 5: EventLog 基础功能 ──'); +const eventLog = new EventLog({ maxEvents: 5 }); + +const evt1 = eventLog.append('ns1', 'put', 'ns1:1:1:raw', { size: 100 }); +assert(evt1.eventId.startsWith('evt-'), '事件 ID 前缀正确'); +assert(evt1.seqNo === 1, '事件序号 = 1'); +assert(evt1.namespace === 'ns1', '事件命名空间正确'); + +eventLog.append('ns1', 'put', 'ns1:2:2:raw'); +eventLog.append('ns2', 'delete', 'ns2:3:3:raw'); + +const recent = eventLog.getRecent(10); +assert(recent.length === 3, '最近事件 = 3'); + +const ns1Events = eventLog.getByNamespace('ns1'); +assert(ns1Events.length === 2, 'ns1 事件 = 2'); + +// 订阅测试 +let received = null; +const unsub = eventLog.subscribe('test-sub', (evt) => { received = evt; }); +eventLog.append('ns1', 'put', 'ns1:4:4:raw'); +assert(received !== null, '订阅回调收到事件'); +assert(received.operation === 'put', '订阅事件操作正确'); + +unsub(); +received = null; +eventLog.append('ns1', 'put', 'ns1:5:5:raw'); +assert(received === null, '取消订阅后不再收到事件'); + +// 容量控制 +eventLog.append('ns1', 'put', 'ns1:6:6:raw'); +eventLog.append('ns1', 'put', 'ns1:7:7:raw'); +const status = eventLog.getStatus(); +assert(status.totalEvents === 5, '容量控制:最多保留 5 条'); + +// ── 测试 6: GridAPI · put → get → delete 全链路 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 6: GridAPI · put → get → delete 全链路 ──'); +const apiDir = makeTempDir('api'); +const db = open({ dataDir: apiDir }); + +// put +const putResult = db.put('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }, { + agentId: 'AG-ZY-01', + context: { repo: 'guanghulab', branch: 'main' }, + timestamp: '2026-03-26T13:00:00Z' +}); +assert(putResult.key === 'exe-engine:1:2:raw', 'put 返回正确 key'); +assert(putResult.seqNo === 1, 'put 返回序列号 1'); +assert(putResult.event.operation === 'put', 'put 生成事件'); + +// get +const getData = db.get('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }); +assert(getData !== null, 'get 返回数据'); +assert(getData.agentId === 'AG-ZY-01', 'get 数据内容正确'); +assert(getData.context.repo === 'guanghulab', 'get 嵌套数据正确'); + +// get 不存在的格点 +const noData = db.get('exe-engine', { gridX: 99, gridY: 99, layer: 'raw' }); +assert(noData === null, 'get 不存在的格点返回 null'); + +// 更新 +db.put('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }, { + agentId: 'AG-ZY-01', + context: { repo: 'guanghulab', branch: 'feature' }, + timestamp: '2026-03-26T14:00:00Z' +}); +const updatedData = db.get('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }); +assert(updatedData.context.branch === 'feature', '更新后数据正确'); + +// 多命名空间隔离 +db.put('dc-v1', { gridX: 1, gridY: 2, layer: 'raw' }, { source: 'notion' }); +const dcData = db.get('dc-v1', { gridX: 1, gridY: 2, layer: 'raw' }); +assert(dcData.source === 'notion', '不同命名空间数据隔离'); + +const exeData = db.get('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }); +assert(exeData.agentId === 'AG-ZY-01', '原命名空间数据不受影响'); + +// delete +const deleted = db.delete('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }); +assert(deleted === true, 'delete 返回 true'); + +const afterDelete = db.get('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }); +assert(afterDelete === null, '删除后 get 返回 null'); + +// delete 不存在的 +const deleteMissing = db.delete('exe-engine', { gridX: 99, gridY: 99, layer: 'raw' }); +assert(deleteMissing === false, 'delete 不存在的格点返回 false'); + +// ── 测试 7: 范围扫描 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 7: 范围扫描 ──'); +db.put('scan-ns', { gridX: 1, gridY: 1, layer: 'raw' }, { id: 'a' }); +db.put('scan-ns', { gridX: 2, gridY: 2, layer: 'raw' }, { id: 'b' }); +db.put('scan-ns', { gridX: 3, gridY: 3, layer: 'raw' }, { id: 'c' }); +db.put('scan-ns', { gridX: 5, gridY: 5, layer: 'indexed' }, { id: 'd' }); + +const scanAll = db.scan('scan-ns'); +assert(scanAll.length === 4, '扫描全部 = 4'); + +const scanRange = db.scan('scan-ns', { xRange: [1, 3], yRange: [1, 3] }); +assert(scanRange.length === 3, '范围扫描 [1,3] = 3 条'); + +const scanLayer = db.scan('scan-ns', { layer: 'indexed' }); +assert(scanLayer.length === 1, '按 layer 过滤 = 1 条'); +assert(scanLayer[0].data.id === 'd', '过滤结果正确'); + +// ── 测试 8: stats ── +// eslint-disable-next-line no-console +console.log('\n── 测试 8: stats 统计 ──'); +const dbStats = db.stats('scan-ns'); +assert(dbStats.namespace === 'scan-ns', 'stats 命名空间正确'); +assert(dbStats.cellCount === 4, 'scan-ns 格点数 = 4'); + +const allStats = db.stats(); +assert(allStats.namespace === '*', 'stats 全局'); +assert(allStats.cellCount >= 5, '全局格点数 >= 5'); +assert(allStats.wal !== undefined, 'stats 含 wal'); +assert(allStats.pageManager !== undefined, 'stats 含 pageManager'); +assert(allStats.eventLog !== undefined, 'stats 含 eventLog'); + +// ── 测试 9: checkpoint ── +// eslint-disable-next-line no-console +console.log('\n── 测试 9: checkpoint ──'); +db.checkpoint(); +const afterCheckpoint = db.stats(); +assert(afterCheckpoint.wal.seqNo === 0, 'checkpoint 后 WAL seqNo = 0'); + +// 数据仍然可读 +const postCheckpoint = db.get('dc-v1', { gridX: 1, gridY: 2, layer: 'raw' }); +assert(postCheckpoint !== null, 'checkpoint 后数据仍可读'); +assert(postCheckpoint.source === 'notion', 'checkpoint 后数据正确'); + +// ── 测试 10: 事件订阅 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 10: 事件订阅 ──'); +let subscribedEvent = null; +const unsubscribe = db.subscribe('test-audit', (evt) => { + subscribedEvent = evt; +}); + +db.put('audit-ns', { gridX: 0, gridY: 0, layer: 'raw' }, { audit: true }); +assert(subscribedEvent !== null, '事件订阅收到事件'); +assert(subscribedEvent.namespace === 'audit-ns', '订阅事件命名空间正确'); + +unsubscribe(); +subscribedEvent = null; +db.put('audit-ns', { gridX: 1, gridY: 1, layer: 'raw' }, { audit: false }); +assert(subscribedEvent === null, '取消订阅后不再收到'); + +db.close(); +cleanDir(apiDir); + +// ── 测试 11: open() 便捷函数 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 11: open() 便捷函数 ──'); +const openDir = makeTempDir('open'); +const db2 = open({ dataDir: openDir }); +assert(db2 instanceof GridAPI, 'open 返回 GridAPI 实例'); + +db2.put('test', { gridX: 0, gridY: 0, layer: 'raw' }, 'hello'); +const result = db2.get('test', { gridX: 0, gridY: 0, layer: 'raw' }); +assert(result === 'hello', 'open → put → get 正确'); + +db2.close(); +cleanDir(openDir); + +// ── 测试 12: GridCell 使用 GridCell 对象调用 API ── +// eslint-disable-next-line no-console +console.log('\n── 测试 12: GridCell 对象直接调用 API ──'); +const gcDir = makeTempDir('gc'); +const db3 = open({ dataDir: gcDir }); + +const myCell = new GridCell('my-ns', 42, 84, 'cleaned'); +db3.put('my-ns', myCell, { value: 'direct cell' }); + +const directResult = db3.get('my-ns', myCell); +assert(directResult !== null, 'GridCell 对象直接调用 get'); +assert(directResult.value === 'direct cell', '数据正确'); + +db3.close(); +cleanDir(gcDir); + +// ── 测试结果汇总 ── +// eslint-disable-next-line no-console +console.log('\n══════════════════════════════════════'); +// eslint-disable-next-line no-console +console.log('🗄️ Grid-DB 冒烟测试完成'); +// eslint-disable-next-line no-console +console.log(` ✅ 通过: ${passed}`); +// eslint-disable-next-line no-console +console.log(` ❌ 失败: ${failed}`); +// eslint-disable-next-line no-console +console.log(` 📊 总计: ${passed + failed}`); +// eslint-disable-next-line no-console +console.log('══════════════════════════════════════\n'); + +if (failed > 0) { + process.exit(1); +}