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
This commit is contained in:
parent
5db9d024ae
commit
bbb7839ef5
|
|
@ -10,6 +10,14 @@
|
||||||
"last_synced": "2026-03-19T02:00:00+08:00"
|
"last_synced": "2026-03-19T02:00:00+08:00"
|
||||||
},
|
},
|
||||||
"recent_events": [
|
"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",
|
"date": "2026-03-26",
|
||||||
"type": "system_infrastructure",
|
"type": "system_infrastructure",
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,38 @@
|
||||||
"execution_layer": "路由 = 选墨水。计量 = 量墨水。降级 = 墨水不够了换一种写法。",
|
"execution_layer": "路由 = 选墨水。计量 = 量墨水。降级 = 墨水不够了换一种写法。",
|
||||||
"user_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": "笔在纸上写字。笔向纸写入执行日志、读取上下文。纸为笔提供查询接口。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,6 @@ writing-platform/backend/dist/
|
||||||
|
|
||||||
# EXE-Engine runtime logs
|
# EXE-Engine runtime logs
|
||||||
exe-engine/logs/
|
exe-engine/logs/
|
||||||
|
|
||||||
|
# Grid-DB runtime data (engine-generated)
|
||||||
|
grid-db/data/
|
||||||
|
|
|
||||||
|
|
@ -109,3 +109,17 @@ Notion 主脑更新
|
||||||
| 资源计量器 | `exe-engine/src/meter/resource-meter.js` | Token 消耗记录 + 成本统计 |
|
| 资源计量器 | `exe-engine/src/meter/resource-meter.js` | Token 消耗记录 + 成本统计 |
|
||||||
| 上下文缓存 | `exe-engine/src/cache/context-cache.js` | Agent 上下文 LRU 缓存 |
|
| 上下文缓存 | `exe-engine/src/cache/context-cache.js` | Agent 上下文 LRU 缓存 |
|
||||||
| Agent 调度器 | `exe-engine/src/controller/agent-controller.js` | Agent→模型偏好映射 |
|
| 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` | 事件溯源:不可变事件流 + 审计日志 |
|
||||||
|
|
|
||||||
|
|
@ -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 生产就绪 | 性能基准 + 资源计量 + 全文索引 + 文档 | ⚪ 待开始 |
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue