From 524abb4c971bddfecccd11bdbf0521fc4632dd24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:43:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(grid-db):=20implement=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20B+Tree,=20NamespaceManager,=20RangeScanner,=20Nearb?= =?UTF-8?q?yQuery,=20SecondaryIndex,=20EventLog=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZY-GDB-P1-001: B+Tree index with insert/find/delete/range/serialization ZY-GDB-P1-002: NamespaceManager with create/delete/get/list/exists/validation ZY-GDB-P1-003: RangeScanner for rectangular area queries ZY-GDB-P1-004: NearbyQuery for proximity search with Euclidean distance ZY-GDB-P1-005: EventLog replay/replayFromSeqNo/getByOperation/tianyanHook ZY-GDB-P1-006: SecondaryIndex for one-to-many field indexing All Phase 0 tests (95/95) and Phase 1 tests (101/101) pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- grid-db/src/core/namespace.js | 137 ++++++++ grid-db/src/events/event-log.js | 50 ++- grid-db/src/index.js | 12 +- grid-db/src/index/btree.js | 285 ++++++++++++++++ grid-db/src/index/secondary-index.js | 128 +++++++ grid-db/src/query/nearby.js | 116 +++++++ grid-db/src/query/scan.js | 120 +++++++ grid-db/tests/smoke/grid-db-p1.test.js | 443 +++++++++++++++++++++++++ 8 files changed, 1289 insertions(+), 2 deletions(-) create mode 100644 grid-db/src/core/namespace.js create mode 100644 grid-db/src/index/btree.js create mode 100644 grid-db/src/index/secondary-index.js create mode 100644 grid-db/src/query/nearby.js create mode 100644 grid-db/src/query/scan.js create mode 100644 grid-db/tests/smoke/grid-db-p1.test.js diff --git a/grid-db/src/core/namespace.js b/grid-db/src/core/namespace.js new file mode 100644 index 00000000..c86fd116 --- /dev/null +++ b/grid-db/src/core/namespace.js @@ -0,0 +1,137 @@ +// grid-db/src/core/namespace.js +// Grid-DB · 命名空间管理器 +// 命名空间隔离与元数据管理 +// PRJ-GDB-001 · Phase 1 · ZY-GDB-P1-002 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +/** + * NamespaceManager — 命名空间管理器 + * + * 本体论锚定:命名空间 = 纸的不同区域。 + * 同一张纸上,不同区域的格子互不干涉。 + * 每个区域有自己的名字、创建时间和格点统计。 + * + * 命名空间名称规则:非空字符串,仅允许字母、数字、连字符、下划线。 + */ + +const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; + +class NamespaceManager { + /** + * @param {object} [options] + */ + constructor(options = {}) { + /** @type {Map} */ + this._namespaces = new Map(); + } + + /** + * 创建命名空间 + * @param {string} name 命名空间名称 + * @param {object} [metadata] 元数据 + * @returns {object} 创建的命名空间信息 + */ + create(name, metadata = {}) { + this._validateName(name); + + if (this._namespaces.has(name)) { + throw new Error(`NamespaceManager: 命名空间 '${name}' 已存在`); + } + + const ns = { + name, + metadata, + createdAt: new Date().toISOString(), + cellCount: 0 + }; + + this._namespaces.set(name, ns); + return { ...ns }; + } + + /** + * 删除命名空间 + * @param {string} name + * @returns {boolean} 是否删除成功 + */ + delete(name) { + if (!this._namespaces.has(name)) { + return false; + } + this._namespaces.delete(name); + return true; + } + + /** + * 获取命名空间信息 + * @param {string} name + * @returns {object|null} + */ + get(name) { + const ns = this._namespaces.get(name); + if (!ns) return null; + return { ...ns }; + } + + /** + * 列出所有命名空间 + * @returns {object[]} + */ + list() { + return Array.from(this._namespaces.values()).map(ns => ({ ...ns })); + } + + /** + * 检查命名空间是否存在 + * @param {string} name + * @returns {boolean} + */ + exists(name) { + return this._namespaces.has(name); + } + + /** + * 增加命名空间的格点计数 + * @param {string} name + */ + incrementCellCount(name) { + const ns = this._namespaces.get(name); + if (!ns) { + throw new Error(`NamespaceManager: 命名空间 '${name}' 不存在`); + } + ns.cellCount++; + } + + /** + * 减少命名空间的格点计数 + * @param {string} name + */ + decrementCellCount(name) { + const ns = this._namespaces.get(name); + if (!ns) { + throw new Error(`NamespaceManager: 命名空间 '${name}' 不存在`); + } + if (ns.cellCount > 0) { + ns.cellCount--; + } + } + + // ── 内部方法 ── + + /** + * 验证命名空间名称 + * @param {string} name + */ + _validateName(name) { + if (!name || typeof name !== 'string') { + throw new Error('NamespaceManager: 命名空间名称必须是非空字符串'); + } + if (!NAME_PATTERN.test(name)) { + throw new Error(`NamespaceManager: 命名空间名称只能包含字母、数字、连字符和下划线,收到: '${name}'`); + } + } +} + +module.exports = NamespaceManager; diff --git a/grid-db/src/events/event-log.js b/grid-db/src/events/event-log.js index 8a36a65f..87fb18cc 100644 --- a/grid-db/src/events/event-log.js +++ b/grid-db/src/events/event-log.js @@ -1,7 +1,7 @@ // grid-db/src/events/event-log.js // Grid-DB · 事件溯源日志 // 不可变事件流 + 审计日志 -// PRJ-GDB-001 · Phase 0(基础结构,P1 完善回放与订阅) +// PRJ-GDB-001 · Phase 0 + Phase 1(回放、序列号回放、操作过滤、天眼钩子) // 版权:国作登字-2026-A-00037559 'use strict'; @@ -106,6 +106,54 @@ class EventLog { }; } + /** + * 从指定时间戳回放事件 + * @param {string} fromTimestamp ISO 8601 时间戳 + * @returns {object[]} + */ + replay(fromTimestamp) { + return this._events.filter(e => e.timestamp >= fromTimestamp); + } + + /** + * 从指定序列号回放事件 + * @param {number} seqNo 起始序列号(包含) + * @returns {object[]} + */ + replayFromSeqNo(seqNo) { + return this._events.filter(e => e.seqNo >= seqNo); + } + + /** + * 按操作类型过滤事件 + * @param {string} operation 操作类型 (put | delete | scan) + * @param {number} [limit] 最大返回数 + * @returns {object[]} + */ + getByOperation(operation, limit) { + const filtered = this._events.filter(e => e.operation === operation); + if (limit && limit > 0) { + return filtered.slice(-limit); + } + return filtered; + } + + /** + * 设置天眼钩子 — 天眼集成占位接口 + * + * 天眼通过此钩子实时接收事件流,实现全域审计。 + * Phase 1 提供接口,Phase 2 实现完整天眼协议。 + * + * @param {Function} handler 天眼事件处理函数 + */ + setTianyanHook(handler) { + this._tianyanHook = handler; + // 注册为特殊订阅者 + if (typeof handler === 'function') { + this.subscribe('__tianyan__', handler); + } + } + /** * 获取事件日志状态 * @returns {object} diff --git a/grid-db/src/index.js b/grid-db/src/index.js index 27ffb134..0ec274de 100644 --- a/grid-db/src/index.js +++ b/grid-db/src/index.js @@ -13,6 +13,11 @@ const WAL = require('./storage/wal'); const PageManager = require('./storage/page-manager'); const EventLog = require('./events/event-log'); const GridAPI = require('./api/grid-api'); +const BTree = require('./index/btree'); +const NamespaceManager = require('./core/namespace'); +const RangeScanner = require('./query/scan'); +const NearbyQuery = require('./query/nearby'); +const SecondaryIndex = require('./index/secondary-index'); /** * Grid-DB 初始化器 @@ -82,5 +87,10 @@ module.exports = { GridCell, WAL, PageManager, - EventLog + EventLog, + BTree, + NamespaceManager, + RangeScanner, + NearbyQuery, + SecondaryIndex }; diff --git a/grid-db/src/index/btree.js b/grid-db/src/index/btree.js new file mode 100644 index 00000000..3d8ad26a --- /dev/null +++ b/grid-db/src/index/btree.js @@ -0,0 +1,285 @@ +// grid-db/src/index/btree.js +// Grid-DB · B+Tree 主键索引 +// 有序键值索引,支持范围扫描 +// PRJ-GDB-001 · Phase 1 · ZY-GDB-P1-001 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +/** + * B+Tree — 主键索引 + * + * 本体论锚定:索引 = 纸的目录。 + * 目录按页码排列,可以快速定位到任意一页。 + * 叶子节点串联 = 目录页之间的连续翻阅。 + * + * 所有数据存储在叶子节点,内部节点仅存储路由键。 + * 叶子节点之间通过 next 指针串联,支持高效范围扫描。 + */ + +class BTreeNode { + /** + * @param {boolean} isLeaf 是否为叶子节点 + */ + constructor(isLeaf = false) { + this.isLeaf = isLeaf; + this.keys = []; + this.children = []; // 内部节点:子节点引用;叶子节点:对应值 + this.next = null; // 叶子节点链表指针 + } +} + +class BTree { + /** + * @param {number} [order] B+Tree 的阶数(每个节点最大子节点数),默认 32 + */ + constructor(order = 32) { + if (typeof order !== 'number' || order < 3) { + throw new Error('BTree: order 必须是 >= 3 的整数'); + } + this._order = order; + this._maxKeys = order - 1; + this._minKeys = Math.ceil(order / 2) - 1; + this._root = new BTreeNode(true); + this._size = 0; + } + + /** + * 当前索引中的条目数量 + * @returns {number} + */ + get size() { + return this._size; + } + + /** + * 插入键值对 + * @param {string} key 键 + * @param {*} value 值 + */ + insert(key, value) { + if (typeof key !== 'string') { + throw new Error('BTree.insert: key 必须是字符串'); + } + + // 检查是否已存在(更新) + const leaf = this._findLeaf(this._root, key); + const idx = leaf.keys.indexOf(key); + if (idx !== -1) { + leaf.children[idx] = value; + return; + } + + // 根节点已满,先分裂 + if (this._root.keys.length >= this._maxKeys) { + const newRoot = new BTreeNode(false); + newRoot.children.push(this._root); + this._splitChild(newRoot, 0); + this._root = newRoot; + } + + this._insertNonFull(this._root, key, value); + this._size++; + } + + /** + * 查找键对应的值 + * @param {string} key + * @returns {*|undefined} 找到返回值,否则 undefined + */ + find(key) { + const leaf = this._findLeaf(this._root, key); + const idx = leaf.keys.indexOf(key); + if (idx !== -1) { + return leaf.children[idx]; + } + return undefined; + } + + /** + * 删除键 + * @param {string} key + * @returns {boolean} 是否删除成功 + */ + delete(key) { + const leaf = this._findLeaf(this._root, key); + const idx = leaf.keys.indexOf(key); + if (idx === -1) { + return false; + } + + leaf.keys.splice(idx, 1); + leaf.children.splice(idx, 1); + this._size--; + return true; + } + + /** + * 范围查询(闭区间 [startKey, endKey]) + * + * 利用叶子节点链表高效遍历 + * + * @param {string} startKey 起始键 + * @param {string} endKey 结束键 + * @returns {Array<{key: string, value: *}>} 有序结果 + */ + range(startKey, endKey) { + const results = []; + let leaf = this._findLeaf(this._root, startKey); + + while (leaf) { + for (let i = 0; i < leaf.keys.length; i++) { + if (leaf.keys[i] >= startKey && leaf.keys[i] <= endKey) { + results.push({ key: leaf.keys[i], value: leaf.children[i] }); + } + if (leaf.keys[i] > endKey) { + return results; + } + } + leaf = leaf.next; + } + + return results; + } + + /** + * 序列化为 JSON + * @returns {object} + */ + toJSON() { + const entries = []; + let leaf = this._getFirstLeaf(); + while (leaf) { + for (let i = 0; i < leaf.keys.length; i++) { + entries.push({ key: leaf.keys[i], value: leaf.children[i] }); + } + leaf = leaf.next; + } + return { order: this._order, entries }; + } + + /** + * 从 JSON 还原 B+Tree + * @param {object} json + * @returns {BTree} + */ + static fromJSON(json) { + const tree = new BTree(json.order || 32); + for (const entry of json.entries) { + tree.insert(entry.key, entry.value); + } + return tree; + } + + /** + * 清空索引 + */ + clear() { + this._root = new BTreeNode(true); + this._size = 0; + } + + // ── 内部方法 ── + + /** + * 找到 key 应当所在的叶子节点 + * @param {BTreeNode} node + * @param {string} key + * @returns {BTreeNode} + */ + _findLeaf(node, key) { + if (node.isLeaf) { + return node; + } + + // 在内部节点中找到正确的子节点 + let i = 0; + while (i < node.keys.length && key >= node.keys[i]) { + i++; + } + return this._findLeaf(node.children[i], key); + } + + /** + * 获取最左叶子节点 + * @returns {BTreeNode} + */ + _getFirstLeaf() { + let node = this._root; + while (!node.isLeaf) { + node = node.children[0]; + } + return node; + } + + /** + * 向非满节点插入 + * @param {BTreeNode} node + * @param {string} key + * @param {*} value + */ + _insertNonFull(node, key, value) { + if (node.isLeaf) { + // 在叶子节点中找到插入位置(保持有序) + let i = 0; + while (i < node.keys.length && node.keys[i] < key) { + i++; + } + node.keys.splice(i, 0, key); + node.children.splice(i, 0, value); + return; + } + + // 内部节点:找到正确的子节点 + let i = 0; + while (i < node.keys.length && key >= node.keys[i]) { + i++; + } + + // 如果目标子节点已满,先分裂 + if (node.children[i].keys.length >= this._maxKeys) { + this._splitChild(node, i); + if (key >= node.keys[i]) { + i++; + } + } + + this._insertNonFull(node.children[i], key, value); + } + + /** + * 分裂子节点 + * @param {BTreeNode} parent 父节点 + * @param {number} childIndex 要分裂的子节点索引 + */ + _splitChild(parent, childIndex) { + const child = parent.children[childIndex]; + const mid = Math.floor(child.keys.length / 2); + const newNode = new BTreeNode(child.isLeaf); + + if (child.isLeaf) { + // 叶子节点分裂:右半部分复制到新节点,提升中间键的副本 + newNode.keys = child.keys.splice(mid); + newNode.children = child.children.splice(mid); + + // 维护叶子链表 + newNode.next = child.next; + child.next = newNode; + + // 提升第一个新键到父节点 + parent.keys.splice(childIndex, 0, newNode.keys[0]); + parent.children.splice(childIndex + 1, 0, newNode); + } else { + // 内部节点分裂:中间键提升,不保留在子节点 + const midKey = child.keys[mid]; + newNode.keys = child.keys.splice(mid + 1); + newNode.children = child.children.splice(mid + 1); + child.keys.splice(mid); // 移除中间键 + + parent.keys.splice(childIndex, 0, midKey); + parent.children.splice(childIndex + 1, 0, newNode); + } + } +} + +module.exports = BTree; diff --git a/grid-db/src/index/secondary-index.js b/grid-db/src/index/secondary-index.js new file mode 100644 index 00000000..b38257be --- /dev/null +++ b/grid-db/src/index/secondary-index.js @@ -0,0 +1,128 @@ +// grid-db/src/index/secondary-index.js +// Grid-DB · 二级索引 +// 按任意字段建立索引,支持一对多映射 +// PRJ-GDB-001 · Phase 1 · ZY-GDB-P1-006 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +/** + * SecondaryIndex — 二级索引 + * + * 本体论锚定:二级索引 = 纸的侧面标签。 + * 同一个标签可以贴在多个格子上(一对多)。 + * 通过标签可以快速找到所有相关的格子。 + * + * fieldValue → Set 的映射结构。 + * 支持范围查询(利用排序后的键列表)。 + */ +class SecondaryIndex { + /** + * @param {string} fieldName 索引的字段名 + */ + constructor(fieldName) { + if (!fieldName || typeof fieldName !== 'string') { + throw new Error('SecondaryIndex: fieldName 必须是非空字符串'); + } + this._fieldName = fieldName; + /** @type {Map>} */ + this._map = new Map(); + this._size = 0; + } + + /** + * 索引中的条目总数(所有 primaryKey 的总计) + * @returns {number} + */ + get size() { + return this._size; + } + + /** + * 添加索引条目 + * @param {string} fieldValue 字段值 + * @param {string} primaryKey 主键 + */ + add(fieldValue, primaryKey) { + const strValue = String(fieldValue); + let keys = this._map.get(strValue); + if (!keys) { + keys = new Set(); + this._map.set(strValue, keys); + } + if (!keys.has(primaryKey)) { + keys.add(primaryKey); + this._size++; + } + } + + /** + * 移除索引条目 + * @param {string} fieldValue 字段值 + * @param {string} primaryKey 主键 + * @returns {boolean} 是否移除成功 + */ + remove(fieldValue, primaryKey) { + const strValue = String(fieldValue); + const keys = this._map.get(strValue); + if (!keys) return false; + + if (keys.delete(primaryKey)) { + this._size--; + if (keys.size === 0) { + this._map.delete(strValue); + } + return true; + } + return false; + } + + /** + * 查找指定字段值的所有主键 + * @param {string} fieldValue + * @returns {string[]} 主键数组 + */ + find(fieldValue) { + const strValue = String(fieldValue); + const keys = this._map.get(strValue); + if (!keys) return []; + return Array.from(keys); + } + + /** + * 范围查询(闭区间 [startValue, endValue]) + * @param {string} startValue 起始值 + * @param {string} endValue 结束值 + * @returns {string[]} 主键数组(去重) + */ + range(startValue, endValue) { + const start = String(startValue); + const end = String(endValue); + const resultSet = new Set(); + + // 获取所有键并排序 + const sortedKeys = Array.from(this._map.keys()).sort(); + + for (const key of sortedKeys) { + if (key >= start && key <= end) { + const primaryKeys = this._map.get(key); + for (const pk of primaryKeys) { + resultSet.add(pk); + } + } + if (key > end) break; + } + + return Array.from(resultSet); + } + + /** + * 清空索引 + */ + clear() { + this._map.clear(); + this._size = 0; + } +} + +module.exports = SecondaryIndex; diff --git a/grid-db/src/query/nearby.js b/grid-db/src/query/nearby.js new file mode 100644 index 00000000..9148bcd1 --- /dev/null +++ b/grid-db/src/query/nearby.js @@ -0,0 +1,116 @@ +// grid-db/src/query/nearby.js +// Grid-DB · 近邻查询 +// 基于欧几里得距离的邻近查询 +// PRJ-GDB-001 · Phase 1 · ZY-GDB-P1-004 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const GridCell = require('../core/grid-cell'); + +/** + * NearbyQuery — 近邻查询 + * + * 本体论锚定:近邻 = 以某个格子为中心,画一个圆,找出圆内所有格子。 + * 圆由中心坐标和半径定义。 + * 结果按距离排序,最近的在前面。 + */ +class NearbyQuery { + /** + * @param {object} deps + * @param {Map|object} deps.index 索引 + * @param {object} deps.pageManager 页管理器 + */ + constructor({ index, pageManager }) { + if (!index) { + throw new Error('NearbyQuery: index 不能为空'); + } + if (!pageManager) { + throw new Error('NearbyQuery: pageManager 不能为空'); + } + this._index = index; + this._pageManager = pageManager; + } + + /** + * 近邻查询 + * + * @param {string} namespace 命名空间 + * @param {number} centerX 中心 X 坐标 + * @param {number} centerY 中心 Y 坐标 + * @param {number} radius 搜索半径(欧几里得距离) + * @param {object} [options] + * @param {string} [options.layer] 层级过滤 + * @param {number} [options.limit] 最大返回数 + * @param {string} [options.sortBy] 排序方式(默认 'distance') + * @returns {Array<{cell: GridCell, data: *, distance: number}>} + */ + nearby(namespace, centerX, centerY, radius, options = {}) { + const { layer, limit, sortBy = 'distance' } = options; + const results = []; + + const entries = this._getEntries(); + + for (const [key, pageId] of entries) { + try { + const cell = GridCell.fromKey(key); + + // 命名空间过滤 + if (cell.namespace !== namespace) continue; + + // 层级过滤 + if (layer && cell.layer !== layer) continue; + + // 欧几里得距离计算 + const dx = cell.gridX - centerX; + const dy = cell.gridY - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // 半径过滤 + if (distance > radius) 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, distance }); + } + } catch { + // 跳过无效键 + } + } + + // 按距离升序排序 + if (sortBy === 'distance') { + results.sort((a, b) => a.distance - b.distance); + } + + // limit + if (limit && limit > 0) { + return results.slice(0, limit); + } + + return results; + } + + /** + * 获取索引中的所有条目(兼容 Map 和 BTree) + * @returns {Array<[string, *]>} + */ + _getEntries() { + if (this._index instanceof Map) { + return Array.from(this._index.entries()); + } + if (typeof this._index.toJSON === 'function') { + const json = this._index.toJSON(); + return json.entries.map(e => [e.key, e.value]); + } + if (typeof this._index.entries === 'function') { + return Array.from(this._index.entries()); + } + return []; + } +} + +module.exports = NearbyQuery; diff --git a/grid-db/src/query/scan.js b/grid-db/src/query/scan.js new file mode 100644 index 00000000..4c0e868f --- /dev/null +++ b/grid-db/src/query/scan.js @@ -0,0 +1,120 @@ +// grid-db/src/query/scan.js +// Grid-DB · 范围扫描器 +// 矩形区域查询 +// PRJ-GDB-001 · Phase 1 · ZY-GDB-P1-003 +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const GridCell = require('../core/grid-cell'); + +/** + * RangeScanner — 范围扫描器 + * + * 本体论锚定:扫描 = 在纸上画一个矩形区域,找出所有落在区域内的格子。 + * 矩形由 xRange 和 yRange 定义,layer 可选过滤。 + * + * 支持 BTree 索引和 Map 索引两种后端。 + */ +class RangeScanner { + /** + * @param {object} deps + * @param {Map|object} deps.index 索引(Map 或 BTree,需支持遍历) + * @param {object} deps.pageManager 页管理器 + */ + constructor({ index, pageManager }) { + if (!index) { + throw new Error('RangeScanner: index 不能为空'); + } + if (!pageManager) { + throw new Error('RangeScanner: pageManager 不能为空'); + } + this._index = index; + this._pageManager = pageManager; + } + + /** + * 矩形区域扫描 + * + * @param {string} namespace 命名空间 + * @param {object} [options] + * @param {number[]} [options.xRange] X 范围 [min, max] + * @param {number[]} [options.yRange] Y 范围 [min, max] + * @param {string} [options.layer] 层级过滤 + * @param {number} [options.limit] 最大返回数 + * @param {number} [options.offset] 跳过前 N 条 + * @returns {Array<{cell: GridCell, data: *}>} 按 (gridX, gridY) 排序 + */ + scan(namespace, options = {}) { + const { xRange, yRange, layer, limit, offset = 0 } = options; + const results = []; + + // 遍历索引中所有条目 + const entries = this._getEntries(); + + for (const [key, pageId] of entries) { + try { + const cell = GridCell.fromKey(key); + + // 命名空间过滤 + if (cell.namespace !== namespace) continue; + + // 层级过滤 + if (layer && cell.layer !== layer) continue; + + // X 范围过滤 + if (xRange) { + if (cell.gridX < xRange[0] || cell.gridX > xRange[1]) continue; + } + + // Y 范围过滤 + if (yRange) { + if (cell.gridY < yRange[0] || cell.gridY > 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 { + // 跳过无效键 + } + } + + // 按 (gridX, gridY) 排序 + results.sort((a, b) => { + if (a.cell.gridX !== b.cell.gridX) return a.cell.gridX - b.cell.gridX; + return a.cell.gridY - b.cell.gridY; + }); + + // offset + limit + const start = offset || 0; + const end = limit ? start + limit : results.length; + return results.slice(start, end); + } + + /** + * 获取索引中的所有条目(兼容 Map 和 BTree) + * @returns {Array<[string, *]>} + */ + _getEntries() { + if (this._index instanceof Map) { + return Array.from(this._index.entries()); + } + // BTree:通过 toJSON 获取所有条目 + if (typeof this._index.toJSON === 'function') { + const json = this._index.toJSON(); + return json.entries.map(e => [e.key, e.value]); + } + // 其他可迭代对象 + if (typeof this._index.entries === 'function') { + return Array.from(this._index.entries()); + } + return []; + } +} + +module.exports = RangeScanner; diff --git a/grid-db/tests/smoke/grid-db-p1.test.js b/grid-db/tests/smoke/grid-db-p1.test.js new file mode 100644 index 00000000..0d8f45a5 --- /dev/null +++ b/grid-db/tests/smoke/grid-db-p1.test.js @@ -0,0 +1,443 @@ +// grid-db/tests/smoke/grid-db-p1.test.js +// Grid-DB · Phase 1 冒烟测试 +// PRJ-GDB-001 · Phase 1 · ZY-GDB-P1-TEST +// 版权:国作登字-2026-A-00037559 + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const { + open, + GridAPI, + GridCell, + WAL, + PageManager, + EventLog, + BTree, + NamespaceManager, + RangeScanner, + NearbyQuery, + SecondaryIndex +} = 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 { randomUUID } = require('crypto'); + const dir = path.join(os.tmpdir(), `griddb-p1-test-${suffix}-${randomUUID().slice(0, 8)}`); + 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 1 冒烟测试\n'); + +// ── 测试 1: Phase 1 模块导出完整性 ── +// eslint-disable-next-line no-console +console.log('── 测试 1: Phase 1 模块导出完整性 ──'); +assert(typeof BTree === 'function', 'BTree 是构造函数'); +assert(typeof NamespaceManager === 'function', 'NamespaceManager 是构造函数'); +assert(typeof RangeScanner === 'function', 'RangeScanner 是构造函数'); +assert(typeof NearbyQuery === 'function', 'NearbyQuery 是构造函数'); +assert(typeof SecondaryIndex === 'function', 'SecondaryIndex 是构造函数'); + +// Phase 0 模块仍然存在 +assert(typeof open === 'function', 'open 仍然导出'); +assert(typeof GridAPI === 'function', 'GridAPI 仍然导出'); +assert(typeof GridCell === 'function', 'GridCell 仍然导出'); +assert(typeof EventLog === 'function', 'EventLog 仍然导出'); + +// ── 测试 2: B+Tree 基础功能 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 2: B+Tree 基础功能 ──'); +const tree = new BTree(4); // 小阶数方便测试分裂 + +// 插入 +tree.insert('c', 3); +tree.insert('a', 1); +tree.insert('b', 2); +tree.insert('e', 5); +tree.insert('d', 4); +assert(tree.size === 5, '插入 5 条后 size = 5'); + +// 查找 +assert(tree.find('a') === 1, 'find("a") = 1'); +assert(tree.find('c') === 3, 'find("c") = 3'); +assert(tree.find('e') === 5, 'find("e") = 5'); +assert(tree.find('z') === undefined, 'find("z") = undefined'); + +// 更新 +tree.insert('a', 100); +assert(tree.find('a') === 100, '更新后 find("a") = 100'); +assert(tree.size === 5, '更新不增加 size'); + +// 删除 +const delOk = tree.delete('c'); +assert(delOk === true, 'delete("c") 返回 true'); +assert(tree.find('c') === undefined, '删除后 find("c") = undefined'); +assert(tree.size === 4, '删除后 size = 4'); + +const delFail = tree.delete('z'); +assert(delFail === false, 'delete 不存在的键返回 false'); + +// 范围查询 +tree.insert('c', 3); // 重新插入 +const rangeResult = tree.range('b', 'd'); +assert(rangeResult.length === 3, 'range("b","d") 返回 3 条'); +assert(rangeResult[0].key === 'b', 'range 第一条 key = "b"'); +assert(rangeResult[1].key === 'c', 'range 第二条 key = "c"'); +assert(rangeResult[2].key === 'd', 'range 第三条 key = "d"'); + +// 序列化 / 反序列化 +const json = tree.toJSON(); +assert(json.order === 4, 'toJSON 包含 order'); +assert(json.entries.length === 5, 'toJSON 包含 5 个条目'); + +const tree2 = BTree.fromJSON(json); +assert(tree2.size === 5, 'fromJSON 还原 size = 5'); +assert(tree2.find('a') === 100, 'fromJSON 还原后 find("a") = 100'); +assert(tree2.find('e') === 5, 'fromJSON 还原后 find("e") = 5'); + +// clear +tree.clear(); +assert(tree.size === 0, 'clear 后 size = 0'); +assert(tree.find('a') === undefined, 'clear 后 find 返回 undefined'); + +// 大量插入测试分裂逻辑 +const treeLarge = new BTree(4); +for (let i = 0; i < 50; i++) { + treeLarge.insert(`key-${String(i).padStart(3, '0')}`, i); +} +assert(treeLarge.size === 50, '大量插入后 size = 50'); +assert(treeLarge.find('key-025') === 25, '大量插入后查找正确'); +const largeRange = treeLarge.range('key-010', 'key-019'); +assert(largeRange.length === 10, '大量数据范围查询返回 10 条'); + +// 无效参数 +let errThrown = false; +try { new BTree(1); } catch { errThrown = true; } +assert(errThrown, 'order < 3 抛出错误'); + +// ── 测试 3: NamespaceManager ── +// eslint-disable-next-line no-console +console.log('\n── 测试 3: NamespaceManager ──'); +const nsManager = new NamespaceManager(); + +// 创建 +const ns1 = nsManager.create('agent-zy', { desc: '铸渊代理' }); +assert(ns1.name === 'agent-zy', '创建命名空间 name 正确'); +assert(ns1.metadata.desc === '铸渊代理', '创建命名空间 metadata 正确'); +assert(ns1.cellCount === 0, '新命名空间 cellCount = 0'); +assert(typeof ns1.createdAt === 'string', '包含 createdAt'); + +// exists +assert(nsManager.exists('agent-zy') === true, 'exists 已存在返回 true'); +assert(nsManager.exists('not-exist') === false, 'exists 不存在返回 false'); + +// get +const got = nsManager.get('agent-zy'); +assert(got !== null, 'get 返回非 null'); +assert(got.name === 'agent-zy', 'get name 正确'); + +// 创建多个 +nsManager.create('dc-v1'); +nsManager.create('exe_engine'); +const all = nsManager.list(); +assert(all.length === 3, 'list 返回 3 个命名空间'); + +// incrementCellCount / decrementCellCount +nsManager.incrementCellCount('agent-zy'); +nsManager.incrementCellCount('agent-zy'); +assert(nsManager.get('agent-zy').cellCount === 2, 'increment 后 cellCount = 2'); + +nsManager.decrementCellCount('agent-zy'); +assert(nsManager.get('agent-zy').cellCount === 1, 'decrement 后 cellCount = 1'); + +// 删除 +const delNs = nsManager.delete('dc-v1'); +assert(delNs === true, 'delete 返回 true'); +assert(nsManager.exists('dc-v1') === false, '删除后不存在'); + +const delNs2 = nsManager.delete('not-exist'); +assert(delNs2 === false, 'delete 不存在返回 false'); + +// 验证:无效名称 +errThrown = false; +try { nsManager.create(''); } catch { errThrown = true; } +assert(errThrown, '空名称抛出错误'); + +errThrown = false; +try { nsManager.create('invalid name!'); } catch { errThrown = true; } +assert(errThrown, '含特殊字符的名称抛出错误'); + +// 重复创建 +errThrown = false; +try { nsManager.create('agent-zy'); } catch { errThrown = true; } +assert(errThrown, '重复创建抛出错误'); + +// ── 测试 4: RangeScanner ── +// eslint-disable-next-line no-console +console.log('\n── 测试 4: RangeScanner ──'); +const scanDir = makeTempDir('scan'); +const scanDb = open({ dataDir: scanDir }); + +// 插入测试数据 +scanDb.put('scan-test', { gridX: 1, gridY: 1, layer: 'raw' }, { id: 'a' }); +scanDb.put('scan-test', { gridX: 2, gridY: 3, layer: 'raw' }, { id: 'b' }); +scanDb.put('scan-test', { gridX: 4, gridY: 2, layer: 'raw' }, { id: 'c' }); +scanDb.put('scan-test', { gridX: 5, gridY: 5, layer: 'indexed' }, { id: 'd' }); +scanDb.put('other-ns', { gridX: 1, gridY: 1, layer: 'raw' }, { id: 'e' }); + +const scanner = new RangeScanner({ + index: scanDb._index, + pageManager: scanDb._pageManager +}); + +// 全命名空间扫描 +const scanAll = scanner.scan('scan-test'); +assert(scanAll.length === 4, 'RangeScanner 全扫描 = 4'); + +// X/Y 范围过滤 +const scanXY = scanner.scan('scan-test', { xRange: [1, 3], yRange: [1, 3] }); +assert(scanXY.length === 2, 'X/Y 范围过滤 = 2'); + +// 层级过滤 +const scanLayer = scanner.scan('scan-test', { layer: 'indexed' }); +assert(scanLayer.length === 1, 'layer 过滤 = 1'); +assert(scanLayer[0].data.id === 'd', 'layer 过滤结果正确'); + +// 排序验证:按 gridX, gridY +const sorted = scanner.scan('scan-test'); +assert(sorted[0].cell.gridX <= sorted[1].cell.gridX, '结果按 gridX 升序'); + +// limit +const scanLimited = scanner.scan('scan-test', { limit: 2 }); +assert(scanLimited.length === 2, 'limit = 2 返回 2 条'); + +// offset +const scanOffset = scanner.scan('scan-test', { offset: 2 }); +assert(scanOffset.length === 2, 'offset = 2 跳过 2 条'); + +// 命名空间隔离 +const scanOther = scanner.scan('other-ns'); +assert(scanOther.length === 1, '不同命名空间隔离'); + +scanDb.close(); +cleanDir(scanDir); + +// ── 测试 5: NearbyQuery ── +// eslint-disable-next-line no-console +console.log('\n── 测试 5: NearbyQuery ──'); +const nearDir = makeTempDir('nearby'); +const nearDb = open({ dataDir: nearDir }); + +nearDb.put('near-ns', { gridX: 0, gridY: 0, layer: 'raw' }, { id: 'origin' }); +nearDb.put('near-ns', { gridX: 1, gridY: 1, layer: 'raw' }, { id: 'close' }); +nearDb.put('near-ns', { gridX: 3, gridY: 4, layer: 'raw' }, { id: 'mid' }); +nearDb.put('near-ns', { gridX: 10, gridY: 10, layer: 'raw' }, { id: 'far' }); +nearDb.put('near-ns', { gridX: 2, gridY: 0, layer: 'indexed' }, { id: 'layer-diff' }); + +const nearQuery = new NearbyQuery({ + index: nearDb._index, + pageManager: nearDb._pageManager +}); + +// 半径 2 内:(0,0)=0, (1,1)=√2≈1.414, (2,0)=2.0 +const near2 = nearQuery.nearby('near-ns', 0, 0, 2); +assert(near2.length === 3, '半径 2 内 = 3 个点'); +assert(near2[0].distance === 0, '最近点距离 = 0'); +assert(near2[0].data.id === 'origin', '最近点是 origin'); + +// 半径 6 内 +const near6 = nearQuery.nearby('near-ns', 0, 0, 6); +assert(near6.length === 4, '半径 6 内 = 4 个点'); + +// 距离排序验证 +assert(near6[0].distance <= near6[1].distance, '结果按距离升序'); +assert(near6[1].distance <= near6[2].distance, '第二条 ≤ 第三条'); + +// layer 过滤 +const nearLayer = nearQuery.nearby('near-ns', 0, 0, 6, { layer: 'indexed' }); +assert(nearLayer.length === 1, 'layer 过滤 = 1'); +assert(nearLayer[0].data.id === 'layer-diff', 'layer 过滤结果正确'); + +// limit +const nearLimit = nearQuery.nearby('near-ns', 0, 0, 100, { limit: 2 }); +assert(nearLimit.length === 2, 'limit = 2 返回 2 条'); + +// 距离正确性验证 +const sqrt2 = Math.sqrt(2); +assert(Math.abs(near2[1].distance - sqrt2) < 0.001, '(1,1) 距离 = √2'); + +nearDb.close(); +cleanDir(nearDir); + +// ── 测试 6: EventLog 增强功能 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 6: EventLog 增强功能 ──'); +const elog = new EventLog({ maxEvents: 100 }); + +// 插入一些事件,带时间间隔 +const t0 = new Date().toISOString(); +elog.append('ns-a', 'put', 'ns-a:1:1:raw', { size: 10 }); +elog.append('ns-a', 'put', 'ns-a:2:2:raw', { size: 20 }); +elog.append('ns-a', 'delete', 'ns-a:1:1:raw'); +elog.append('ns-b', 'put', 'ns-b:1:1:raw', { size: 30 }); +elog.append('ns-b', 'scan', 'scan:ns-b', { count: 5 }); + +// replay — 从 t0 开始应返回所有 5 条 +const replayed = elog.replay(t0); +assert(replayed.length === 5, 'replay 从 t0 返回 5 条'); + +// replayFromSeqNo +const fromSeq3 = elog.replayFromSeqNo(3); +assert(fromSeq3.length === 3, 'replayFromSeqNo(3) 返回 3 条'); +assert(fromSeq3[0].seqNo === 3, '第一条 seqNo = 3'); + +// getByOperation +const puts = elog.getByOperation('put'); +assert(puts.length === 3, 'getByOperation("put") = 3'); + +const deletes = elog.getByOperation('delete'); +assert(deletes.length === 1, 'getByOperation("delete") = 1'); + +// getByOperation with limit +const putsLimited = elog.getByOperation('put', 2); +assert(putsLimited.length === 2, 'getByOperation 带 limit = 2'); + +// 天眼钩子 +let tianyanReceived = null; +elog.setTianyanHook((evt) => { tianyanReceived = evt; }); +elog.append('ns-a', 'put', 'ns-a:3:3:raw'); +assert(tianyanReceived !== null, '天眼钩子收到事件'); +assert(tianyanReceived.namespace === 'ns-a', '天眼事件 namespace 正确'); +assert(tianyanReceived.operation === 'put', '天眼事件 operation 正确'); + +// 既有功能不受影响 +const recent = elog.getRecent(3); +assert(recent.length === 3, 'getRecent 仍然正常工作'); + +const byNs = elog.getByNamespace('ns-b'); +assert(byNs.length === 2, 'getByNamespace 仍然正常工作'); + +// ── 测试 7: SecondaryIndex ── +// eslint-disable-next-line no-console +console.log('\n── 测试 7: SecondaryIndex ──'); +const secIdx = new SecondaryIndex('layer'); + +// add +secIdx.add('raw', 'ns:1:1:raw'); +secIdx.add('raw', 'ns:2:2:raw'); +secIdx.add('indexed', 'ns:3:3:indexed'); +secIdx.add('semantic', 'ns:4:4:semantic'); +assert(secIdx.size === 4, '添加 4 条后 size = 4'); + +// find +const rawKeys = secIdx.find('raw'); +assert(rawKeys.length === 2, 'find("raw") = 2'); +assert(rawKeys.includes('ns:1:1:raw'), 'find 包含 ns:1:1:raw'); +assert(rawKeys.includes('ns:2:2:raw'), 'find 包含 ns:2:2:raw'); + +const indexedKeys = secIdx.find('indexed'); +assert(indexedKeys.length === 1, 'find("indexed") = 1'); + +const noKeys = secIdx.find('cleaned'); +assert(noKeys.length === 0, 'find 不存在值 = 空数组'); + +// remove +const removed = secIdx.remove('raw', 'ns:1:1:raw'); +assert(removed === true, 'remove 返回 true'); +assert(secIdx.size === 3, 'remove 后 size = 3'); +assert(secIdx.find('raw').length === 1, 'remove 后 find("raw") = 1'); + +const removeFail = secIdx.remove('raw', 'not:exist'); +assert(removeFail === false, 'remove 不存在的主键返回 false'); + +// 重复 add 不增加计数 +secIdx.add('raw', 'ns:2:2:raw'); +assert(secIdx.size === 3, '重复 add 不增加 size'); + +// range +secIdx.add('cleaned', 'ns:5:5:cleaned'); +const rangeKeys = secIdx.range('indexed', 'semantic'); +assert(rangeKeys.length >= 2, 'range("indexed","semantic") >= 2'); +assert(rangeKeys.includes('ns:3:3:indexed'), 'range 包含 indexed 条目'); +assert(rangeKeys.includes('ns:4:4:semantic'), 'range 包含 semantic 条目'); + +// clear +secIdx.clear(); +assert(secIdx.size === 0, 'clear 后 size = 0'); +assert(secIdx.find('raw').length === 0, 'clear 后 find 返回空'); + +// 无效参数 +errThrown = false; +try { new SecondaryIndex(''); } catch { errThrown = true; } +assert(errThrown, '空 fieldName 抛出错误'); + +// ── 测试 8: 集成验证 — Phase 0 功能仍正常 ── +// eslint-disable-next-line no-console +console.log('\n── 测试 8: 集成验证 — Phase 0 功能仍正常 ──'); +const intDir = makeTempDir('integration'); +const intDb = open({ dataDir: intDir }); + +intDb.put('int-ns', { gridX: 1, gridY: 2, layer: 'raw' }, { val: 'hello' }); +const intResult = intDb.get('int-ns', { gridX: 1, gridY: 2, layer: 'raw' }); +assert(intResult !== null, 'Phase 0 put/get 仍然正常'); +assert(intResult.val === 'hello', 'Phase 0 数据正确'); + +const intScan = intDb.scan('int-ns'); +assert(intScan.length === 1, 'Phase 0 scan 仍然正常'); + +intDb.close(); +cleanDir(intDir); + +// ── 测试结果汇总 ── +// eslint-disable-next-line no-console +console.log('\n══════════════════════════════════════'); +// eslint-disable-next-line no-console +console.log('🗄️ Grid-DB Phase 1 冒烟测试完成'); +// 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); +}