import { useState, useCallback, useEffect, useRef } from 'react'; /** * metona-sqlark Constants — 类型定义 / 默认配置 / 枚举 * @module constants */ /** 所有字段类型 */ const FIELD_TYPES = ['string', 'number', 'boolean', 'date', 'json']; /** 数据库默认配置 */ const DB_DEFAULTS = Object.freeze({ name: 'metona-sqlark', mode: 'hybrid', diskEngine: 'opfs', version: 1, maxRowsPerQuery: 0, // 0 = 不限制 debug: false, multiTabSync: false, aria: undefined, }); // --------------------------------------------------------------------------- // 错误类型 // --------------------------------------------------------------------------- /** 数据库错误 */ class DatabaseError extends Error { constructor(message, code, details) { super(message); this.code = code; this.details = details; this.name = 'DatabaseError'; // v0.8.0(review 修复):把底层错误同时挂到**标准** `Error.cause` 上。 // // 修复前第三个参数只存进 `details`,`err.cause` 恒为 undefined —— 而文档与 // 代码注释多处写的是"底层错误作为 cause 保留"(实测被打脸:探针打印 // `e.cause?.code` 得到 undefined)。`details` 语义保持不变(向后兼容), // `cause` 让标准错误链工具(日志/监控/Node 的 error.cause 约定)能看到根因。 // // 注意:target 是 ES2020(lib 无 ES2022 的 ErrorOptions),因此用赋值而不是 // `super(message, { cause })`;运行时所有现代引擎都支持该属性。 if (details !== undefined) { this.cause = details; } } } /** * metona-sqlark Engine Change Notifier(v0.8.0) * @module engine/change-notifier * * ============================================================================ * 为什么需要它(PLAN-v0.7.5.md A9) * ============================================================================ * `db.subscribe(table, fn)` 此前对**本地写入永不触发**:全库只有一处调用 `emit`, * 而那一处在 BroadcastChannel 收到**其它标签页**消息时才执行。于是: * - README:232「订阅表变更」、site/docs.html:667-679 的示例 * (`event.type: 'insert' | 'update' | 'delete'`、`event.row`)全部不成立; * - 唯一的实际触发条件是 `multiTabSync: true` 且收到 `external` 事件。 * * 更麻烦的是原因:写入路径有**三条**(SQL 语句、Table API、QueryBuilder),各自 * 只调用 `broadcastChange(tableName)`,谁都不知道"改了哪些行"。所以此前即便想接线, * 也要在三处分别实现一遍变更描述逻辑 —— 那正是本项目反复出问题的模式 * (同一语义多份实现 → 漂移)。 * * ============================================================================ * 本实现的做法 * ============================================================================ * 用一个 `IStorageEngine` **装饰器**把变更通知收敛到唯一位置:所有写入都必须经过 * 引擎接口,因此在这里拦截一次即可覆盖全部入口(SQL / Table / Builder / 事务内)。 * * 事件语义(对文档承诺的兑现): * - INSERT:逐行一个事件,携带该行(`row` 与主键 `key`); * - UPDATE:先按 WHERE 查出**将被修改的行**(快照),写入成功后逐行发事件并 * 携带**更新后的行**;查不到(例如 WHERE 无法在写入前求值)时退化为表级事件, * 只带 `count`; * - DELETE:同理,逐行发事件并携带**被删除前的行**; * - CLEAR / DDL:表级事件(`count` 可选)。 * * 订阅方回调返回 Promise 时会被 await(保证"写完通知完"的顺序), * 但**订阅方的异常不会影响写入结果** —— 写入已经成功,通知失败只上报 onError。 */ /** * 变更通知引擎装饰器。 * * 只读方法(find/count/findStream/getTableSchema/...)直接透传; * 写入方法在成功后产生事件并同步派发给监听者。 */ class ChangeNotifierEngine { constructor(inner, onListenerError = () => { }, onBroadcast = () => { }) { this.listeners = new Set(); this.inner = inner; this.onListenerError = onListenerError; this.onBroadcast = onBroadcast; } /** 暴露内层引擎(需要能力探测或特化逻辑时使用) */ getInner() { return this.inner; } /** 注册变更监听(返回退订函数) */ addListener(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); } /** 派发事件(供外部事件如跨标签页 `external` 复用同一通道) */ async dispatch(event) { // 跨标签页广播与本地订阅走同一出口,保证"写入即通知"只有一条路径 try { this.onBroadcast(event.table); } catch { // 广播失败不影响本地通知与写入结果 } for (const listener of [...this.listeners]) { try { await listener(event); } catch (error) { // 订阅者异常不得影响写入结果(写入已成功),只上报 this.onListenerError(error); } } } // ------------------------------------------------------------------------- // 写入路径:产生事件 // ------------------------------------------------------------------------- async insert(tableName, rows) { const pks = await this.inner.insert(tableName, rows); // 逐行事件;用引擎返回的主键对齐(resolved 行带 default 值,恢复时可对照) for (let i = 0; i < pks.length; i++) { await this.dispatch({ type: 'insert', table: tableName, key: pks[i], row: i < rows.length ? rows[i] : undefined, count: 1, }); } return pks; } async update(tableName, query, updates) { // 写入前快照将被影响的行(用于提供 row 内容)。失败不阻塞写入 —— 快照只是尽力而为。 const affected = await this.snapshotAffected(tableName, query); const count = await this.inner.update(tableName, query, updates); if (count > 0 && affected.length > 0) { for (const before of affected) { const after = { ...before, ...stripUndefined(updates) }; await this.dispatch({ type: 'update', table: tableName, row: after, count: 1, }); } } else if (count > 0) { await this.dispatch({ type: 'update', table: tableName, count }); } return count; } async delete(tableName, query) { const affected = await this.snapshotAffected(tableName, query); const count = await this.inner.delete(tableName, query); if (count > 0 && affected.length > 0) { for (const before of affected) { await this.dispatch({ type: 'delete', table: tableName, row: before, count: 1 }); } } else if (count > 0) { await this.dispatch({ type: 'delete', table: tableName, count }); } return count; } async clear(tableName) { await this.inner.clear(tableName); await this.dispatch({ type: 'clear', table: tableName }); } async createTable(schema) { await this.inner.createTable(schema); await this.dispatch({ type: 'ddl', table: schema.name }); } async dropTable(tableName) { await this.inner.dropTable(tableName); await this.dispatch({ type: 'ddl', table: tableName }); } async alterTable(tableName, action, column) { await this.requireCapability('alterTable')(tableName, action, column); await this.dispatch({ type: 'ddl', table: tableName }); } async createIndex(tableName, column, unique) { await this.requireCapability('createIndex')(tableName, column, unique); await this.dispatch({ type: 'ddl', table: tableName }); } async dropIndex(tableName, column, indexName) { await this.requireCapability('dropIndex')(tableName, column, indexName); await this.dispatch({ type: 'ddl', table: tableName }); } // ------------------------------------------------------------------------- // 只读路径:透传 // ------------------------------------------------------------------------- get name() { return this.inner.name; } open(dbName, version) { return this.inner.open(dbName, version); } close() { return this.inner.close(); } isOpen() { return this.inner.isOpen(); } hasTable(tableName) { return this.inner.hasTable(tableName); } getTableNames() { return this.inner.getTableNames(); } getTableSchema(tableName) { return this.inner.getTableSchema(tableName); } find(tableName, query) { return this.inner.find(tableName, query); } count(tableName, query) { return this.inner.count(tableName, query); } async findStream(tableName, query, onRow) { return this.requireCapability('findStream')(tableName, query, onRow); } // ---- 事务:透传(事务内的写入由底层引擎统一处理,事件在语句层产生) ---- beginTransaction() { return this.inner.beginTransaction(); } commitTransaction() { return this.inner.commitTransaction(); } rollbackTransaction() { return this.inner.rollbackTransaction(); } savepoint(name) { return this.requireCapability('savepoint')(name); } rollbackToSavepoint(name) { return this.requireCapability('rollbackToSavepoint')(name); } releaseSavepoint(name) { return this.requireCapability('releaseSavepoint')(name); } backup() { return this.requireCapability('backup')(); } repair() { return this.requireCapability('repair')(); } clearAll() { return this.requireCapability('clearAll')(); } // ---- 维护能力(可选,AriaEngine 专有;只读/维护语义,不产生变更事件) ---- /** * v0.8.0:转发引擎专有的维护方法。 * * 为什么必须显式转发:executor 用 `typeof engine.analyzeTable === 'function'` * 做能力探测。装饰器只实现 `IStorageEngine` 声明的成员,于是这些"接口外方法" * 在被包装后会**静默消失** → ANALYZE/REINDEX 报 NOT_SUPPORTED(实测 3 个用例失败)。 * 这正是审计指出的"用 typeof/instanceof 做能力探测"的脆弱之处; * 在装饰器里显式补齐是当前最直接的修法。 */ analyzeTable(tableName) { return this.requireOptionalMethod('analyzeTable')(tableName); } reindexTable(tableName) { return this.requireOptionalMethod('reindexTable')(tableName); } vacuum() { return this.requireOptionalMethod('vacuum')(); } getMeta(key) { const method = this.inner.getMeta; if (typeof method !== 'function') return Promise.resolve(null); return method.call(this.inner, key); } setMeta(key, value) { const method = this.inner.setMeta; if (typeof method !== 'function') return Promise.resolve(); return method.call(this.inner, key, value); } // ------------------------------------------------------------------------- /** * v0.8.0: 取一个**可选能力**方法;内层未实现时抛 `NOT_SUPPORTED`。 * * 为什么不能直接抛普通 Error:项目对"能力缺失"有明确契约 —— * `NOT_SUPPORTED` 错误码(README「维护语句」、executor 的 savepoint/backup * 分支、以及既有测试都依赖它)。装饰器如果抛原生 Error,调用方按 code 分类 * 就会失效(实测让 6 个套件里的 14 个用例失败)。 */ requireCapability(name) { const method = this.inner[name]; if (typeof method !== 'function') { return (() => { throw new DatabaseError(`Engine "${this.inner.name}" does not support ${String(name)}`, 'NOT_SUPPORTED'); }); } return method.bind(this.inner); } /** * v0.8.0: 取**接口之外**的可选方法(如 AriaEngine.analyzeTable / reindexTable / vacuum)。 * * 与 requireCapability 的区别:这些方法不在 IStorageEngine 契约里,属于引擎专有能力, * 但 executor 会用 `typeof engine.x === 'function'` 探测它们。装饰器必须显式转发, * 否则能力在被包装后静默消失(实测 ANALYZE/REINDEX 报 NOT_SUPPORTED)。 */ requireOptionalMethod(name) { const method = this.inner[name]; if (typeof method !== 'function') { return () => { throw new DatabaseError(`Engine "${this.inner.name}" does not support ${name}`, 'NOT_SUPPORTED'); }; } return method.bind(this.inner); } /** * 尽力而为地取"将被影响的行"快照。 * * 用途:让 UPDATE/DELETE 事件能携带行内容(文档承诺 `event.row`)。 * 失败时返回空数组 —— 事件退化为表级通知(只带 count),**不影响写入本身**。 */ async snapshotAffected(tableName, query) { try { return await this.inner.find(tableName, { table: tableName, where: query.where }); } catch { return []; } } } /** 与引擎/executor 一致的 undefined 语义:不更新该列 */ function stripUndefined(updates) { const out = {}; for (const [k, v] of Object.entries(updates)) { if (v !== undefined) out[k] = v; } return out; } /** * metona-sqlark — 行所有权(row ownership)工具 * @module engine/row_clone * * v0.8.0(审查修复):这三个函数此前放在 `engine/interface.ts`(被文档描述为 * "纯类型声明、可执行语句为 0、因此不纳入覆盖率统计")。它们其实是**运行时实现** * 且被 Memory 引擎与 AriaEngine 的读路径调用 —— 放在纯类型文件里等于让真实实现 * 代码逃过覆盖率口径(同一类"统计口径虚高"问题正是 G5 门禁要根治的)。 * 现在实现搬到本模块,`interface.ts` 回到纯类型。 */ /** * 深拷贝一行,使调用方**无法通过修改返回值改写存储**。 * * 为什么必须做(审计实测):Memory/KVStore/Hybrid 三个引擎此前把内部行对象 * **直接**交给调用方: * const rows = await db.query('SELECT * FROM t'); * rows[0].tag = 'HACKED'; // 存储被改写 * 再查 WHERE tag='HACKED' → 0 行;WHERE tag='x' → 0 行 * 即调用方一次无意的原地修改就能让索引与行失配、该行永久查不出来(Aria 因为是 * 反序列化路径反而幸免,于是又成了跨引擎行为差异)。 * * 约定(所有引擎必须遵守): * **读出的行是副本,写入接收的行也是副本** —— 引擎不得把内部行对象暴露给外部, * 也不得持有调用方传入的行对象引用。 * * 实现说明:结构化克隆可用时优先使用;退化路径(`cloneRowFallback`)逐层复制 * 普通对象/数组,并额外保留 Date、Uint8Array、ArrayBuffer。 * * 前提:存储层写入的行已经过 `validateRow` 的 JSON 安全性检查 —— 例如 `date` * 列要求的是**合法日期字符串**而不是 Date 实例(`table/validation.ts`),因此 * 行内永远是 JSON 安全值,"环境提供的 structuredClone 是否保留 Date"不影响 * 引擎行为(jsdom 的 structuredClone 是 JSON 化 polyfill,会把 Date 变字符串; * 真实浏览器与 Node 的原生实现会保留)。 */ function cloneRow(row) { if (row === null || typeof row !== 'object') return row; if (typeof structuredClone === 'function') { try { return structuredClone(row); } catch { // 含不可克隆值(函数/Proxy)时退化为逐层复制 } } return cloneRowFallback(row); } /** 退化实现:递归复制普通对象与数组(保留 Date) */ function cloneRowFallback(value) { if (value === null || typeof value !== 'object') return value; if (value instanceof Date) return new Date(value.getTime()); if (Array.isArray(value)) return value.map((v) => cloneRowFallback(v)); if (value instanceof Uint8Array) return new Uint8Array(value); if (value instanceof ArrayBuffer) return value.slice(0); const out = {}; for (const [k, v] of Object.entries(value)) { out[k] = cloneRowFallback(v); } return out; } /** * metona-sqlark SQL 值语义 — 三值逻辑与统一编码(v0.8.0) * @module query/sql-compare * * ============================================================================ * 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2) * ============================================================================ * 此前项目里并存**五套**值相等语义: * 1. `where-matcher` 的 `===` * 2. 哈希连接的 `String(v ?? '\0')` * 3. 分组键 `encodeGroupKey` * 4. `COUNT(DISTINCT)` 的 `String(v)` * 5. Aria 二级索引键的 `String(v)` * 它们对 NULL 的处理各不相同,于是出现一批"静默错值": * - `WHERE a = NULL` 命中 NULL 行(SQL 标准应为空集,比较结果是 UNKNOWN); * - `WHERE a != NULL` 返回所有非 NULL 行(标准同样为空集); * - `WHERE a NOT BETWEEN 1 AND 2` 会把 NULL 行**排除**(标准应包含, * 因为 UNKNOWN 经过 NOT 仍是 UNKNOWN,而 WHERE 只保留 TRUE……此处需按下文语义); * - `WHERE a NOT IN (1, 2)` 把 NULL 行当作"不在列表"返回; * - JOIN 的 NULL 键:嵌套循环认为 NULL = NULL 成立、哈希连接不成立 → * **结果取决于右表有没有索引**。 * * ============================================================================ * 本模块提供的语义 * ============================================================================ * `sqlCompare(a, b)` / `sqlEquals(a, b)` 返回三值:TRUE / FALSE / UNKNOWN。 * 任一操作数为 NULL/undefined → UNKNOWN(SQL 标准)。 * `matchWhere` 只在结果为 TRUE 时保留该行。 * * 注意 `IS NULL` / `IS NOT NULL` **不走比较**:它们是谓词,直接对 NULL 求值, * 由 parser 生成为独立的 `$isNull` / `$isNotNull` 标记(见 SQL_LOGICAL 说明)。 */ /** SQL 三值逻辑 */ const SQL_LOGICAL = Object.freeze({ TRUE: 'TRUE', FALSE: 'FALSE', /** 未知(任一操作数为 NULL,或存在无法确定的引用) */ UNKNOWN: 'UNKNOWN', }); /** 是否为 NULL 语义值(undefined 与 null 等价为 SQL NULL) */ function isSqlNull(value) { return value === null || value === undefined; } /** * 未解析的列引用标记。 * * 用于区分两件必须分开处理的事: * - `row[col]` 取到 `undefined` 可能是"该行此列为 NULL", * 也可能是"这一列根本不在当前行里"(例如子查询引用了外层列)。 * 前者应判 UNKNOWN,后者应让调用方转交带上下文的求值器(不能静默当 NULL)。 * 用 Unique Symbol 保证不会与真实数据冲突。 */ const UNRESOLVED = Symbol('sqlark.unresolved'); /** 判定某值是否为"未解析引用" */ function isUnresolved(value) { return value === UNRESOLVED; } /** * 统一的类型化值编码(用于分组键 / DISTINCT 键 / 索引键)。 * * 为什么不能直接 `String(v)`: * - `String(null)` = `'null'` 与字符串 `'null'` 冲突; * - `String(1)` = `'1'` 与字符串 `'1'` 冲突(v0.7.4 已修 DISTINCT, * 但 COUNT(DISTINCT) 与 Aria 索引键仍是 `String()`)。 * 这里用类型前缀 + 长度前缀,保证不同类型、不同值必然编码不同, * 且不会出现 `\x1f` 之类分隔符被数据内容伪造的问题。 */ function encodeValueKey(value) { if (value === null || value === undefined) return 'z'; const t = typeof value; switch (t) { case 'string': { const s = value; return `s${s.length}:${s}`; } case 'number': { const n = value; // -0 与 0 在 SQL 中相等;NaN 单独编码(避免与任何值相等) if (Number.isNaN(n)) return 'nNaN'; return `n${Object.is(n, -0) ? 0 : n}`; } case 'boolean': return `b${value ? 1 : 0}`; case 'bigint': return `i${String(value)}`; case 'object': { // 日期按时间戳比较;数组/对象按 JSON(键序由 JSON.stringify 决定, // 对同构数据稳定;异构键序在上层已由 schema 约束) if (value instanceof Date) return `d${value.getTime()}`; if (Array.isArray(value)) return `a${JSON.stringify(value)}`; return `o${JSON.stringify(value)}`; } default: return `x${String(value)}`; } } /** * SQL 比较:返回三值。 * * 与 JavaScript 运算符的关键差异: * - 任一操作数为 NULL → UNKNOWN(而不是 false); * - 非数值字符串与数值比较 → UNKNOWN(而不是 JS 的强制转换结果); * - 未解析引用 → UNKNOWN(调用方据此转交上下文求值)。 */ function sqlCompare(a, b) { const ord = sqlCompareOrder(a, b); if (ord === null) return SQL_LOGICAL.UNKNOWN; return ord === 0 ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE; } /** 相等比较(三值) */ function sqlEquals(a, b) { return sqlCompare(a, b); } /** * 三路排序比较:返回 -1 / 0 / 1,无法比较(含 NULL、类型不可比)返回 null。 * * **这是本模块唯一的排序/比较原语** —— `sqlCompare`(相等)与所有有序比较 * ($gt/$gte/$lt/$lte、ORDER BY)都必须建立在它之上。 * * 为什么要把"排序"与"相等"分开:初版实现里 `sqlCompare` 直接返回三值, * 于是"a < b"与"a === b"都被编码成 TRUE,`$gte` 误把"小于"当成"大于等于" * (实测 `WHERE n >= 2` 在 1..3 上返回 3 而不是 2 与 3)。 * 排序是三路的(<, =, >),相等是二值的(=, ≠)—— 把前者硬塞进后者必然出错。 */ function sqlCompareOrder(a, b) { if (isUnresolved(a) || isUnresolved(b)) return null; if (isSqlNull(a) || isSqlNull(b)) return null; // 数字 vs 数字 if (typeof a === 'number' && typeof b === 'number') { if (Number.isNaN(a) || Number.isNaN(b)) return null; if (a === b) return 0; return a < b ? -1 : 1; } // 布尔 vs 布尔 if (typeof a === 'boolean' && typeof b === 'boolean') { if (a === b) return 0; return a === false ? -1 : 1; } // 字符串 vs 字符串:字典序 if (typeof a === 'string' && typeof b === 'string') { if (a === b) return 0; return a < b ? -1 : 1; } // 日期 vs 日期 if (a instanceof Date && b instanceof Date) { const ta = a.getTime(); const tb = b.getTime(); if (Number.isNaN(ta) || Number.isNaN(tb)) return null; if (ta === tb) return 0; return ta < tb ? -1 : 1; } // 类型不同:本项目保持严格类型(不做静默强转),判定为不可比(null) // —— 这样 `WHERE numCol = 'abc'` 不会意外命中,也不会因 `'5' = 5` 的 JS // 行为产生跨引擎差异。唯一例外:数值与"纯数值字符串"按数值比较 // (与既有「$in 列表含字符串」行为兼容)。 const na = toNumericIfPossible(a); const nb = toNumericIfPossible(b); if (na !== null && nb !== null) { if (na === nb) return 0; return na < nb ? -1 : 1; } return null; } /** 三值 AND */ function sqlAnd(a, b) { if (a === SQL_LOGICAL.FALSE || b === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE; if (a === SQL_LOGICAL.UNKNOWN || b === SQL_LOGICAL.UNKNOWN) return SQL_LOGICAL.UNKNOWN; return SQL_LOGICAL.TRUE; } /** 三值 OR */ function sqlOr(a, b) { if (a === SQL_LOGICAL.TRUE || b === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE; if (a === SQL_LOGICAL.UNKNOWN || b === SQL_LOGICAL.UNKNOWN) return SQL_LOGICAL.UNKNOWN; return SQL_LOGICAL.FALSE; } /** 三值 NOT(UNKNOWN 取反仍为 UNKNOWN) */ function sqlNot(a) { if (a === SQL_LOGICAL.TRUE) return SQL_LOGICAL.FALSE; if (a === SQL_LOGICAL.FALSE) return SQL_LOGICAL.TRUE; return SQL_LOGICAL.UNKNOWN; } /** * 把值转为数值;无法安全转换时返回 null。 * 只有 number 与"纯数值字符串"参与数值比较,避免 `'abc'` 之类被 Number() 变成 NaN。 */ function toNumericIfPossible(value) { if (typeof value === 'number') return Number.isNaN(value) ? null : value; if (typeof value === 'boolean') return value ? 1 : 0; if (typeof value === 'string') { const trimmed = value.trim(); if (trimmed === '') return null; const n = Number(trimmed); return Number.isNaN(n) ? null : n; } return null; } /** * SQL `IN` 列表求值(三值)。 * * 标准语义:`x IN (a, b, c)` 等价于 `x = a OR x = b OR x = c`。 * 因此: * - 命中任一 → TRUE; * - 都未命中但列表含 NULL(或 x 为 NULL)→ UNKNOWN; * - 都未命中且列表无 NULL 且 x 非 NULL → FALSE。 */ function sqlIn(value, list) { if (isUnresolved(value)) return SQL_LOGICAL.UNKNOWN; let sawUnknown = false; for (const item of list) { const t = sqlEquals(value, item); if (t === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE; if (t === SQL_LOGICAL.UNKNOWN) sawUnknown = true; } return sawUnknown ? SQL_LOGICAL.UNKNOWN : SQL_LOGICAL.FALSE; } /** * metona-sqlark Shared WHERE Matcher —— 统一的条件求值器 * @module query/where-matcher * * ============================================================================ * v0.8.0 根治:为什么这里只剩**一个**递归求值器(PLAN-v0.7.5.md 根因 1/7、PB-2) * ============================================================================ * 历史上有两套并存的实现: * - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`); * - 三值求值 —— 为 UNKNOWN 传播而新增。 * 两者对**字段级** `$or` / `$not` 的处理不同,于是同一条 SQL 的语义取决于调用点: * - `{ s: { $not: { $like: 'x' } } }` 经布尔取反会把 NULL 行判真; * - `{ n: { $or: [...] } }`(`NOT BETWEEN` 生成)经布尔分支在 NULL 行上判假; * - 更严重的是字段级 `$or` 递归回了"**where 子句级**"求值器: * `{ n: { $or: [ { $lt: 1 }, { $gt: 2 } ] } }` 里的 `{ $lt: 1 }` 被当成 * "查询字段 `$lt`",于是每一行都求值 UNKNOWN → `NOT BETWEEN` 恒空集。 * * 现在只有一条代码路径:**一个递归求值器**,同时理解 where 子句级(键是列名或 * 逻辑连接词)与操作符级(键是 `$gt` 之类)。区别由**位置**参数承载, * 而不是由另一个函数承载: * - `evalWhere(ctx, where)` —— where 子句(`$and`/`$or` 子项是 where 子句) * - `evalOperatorObject(ctx, ...)` —— 操作符对象(`$and`/`$or` 子项是操作符对象) * 因此 `$or` 的两种含义都在同一个函数里显式分派,不可能再漂移。 * * `matchWhere` 保留为对外入口,语义定义为"是否保留该行" = 三值结果恰为 TRUE。 */ // --------------------------------------------------------------------------- // LIKE 正则缓存 // --------------------------------------------------------------------------- const likeCache = new Map(); function compileLikeRegex(pattern) { const cached = likeCache.get(pattern); if (cached) return cached; const escaped = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/%/g, '.*') .replace(/_/g, '.'); const regex = new RegExp(`^${escaped}$`, 'i'); likeCache.set(pattern, regex); return regex; } // --------------------------------------------------------------------------- // WHERE 匹配(对外入口) // --------------------------------------------------------------------------- /** * 匹配完整 WHERE 条件(布尔口径:仅 TRUE 保留该行)。 * * @param row 当前数据行 * @param where WHERE 条件对象 * @param options `$col` 是否启用列引用解析 */ function matchWhere(row, where, options = {}) { return evalWhere({ row, options }, where) === SQL_LOGICAL.TRUE; } /** * v0.7.4: 检测 WHERE 中未解析的子查询/列引用标记($subquery / $col / $exists)。 * * QueryBuilder 等直通引擎的写路径(update/delete)不经 Executor 解析子查询, * 写路径预检阶段据此显式拒绝(否则这些标记在引擎层恒 UNKNOWN → 静默影响 0 行)。 */ function containsUnresolvedSubqueries(where) { if (!where) return false; for (const [k, v] of Object.entries(where)) { if (isLogicalKey(k)) { const subs = (Array.isArray(v) ? v : [v]); if (subs.some((sub) => containsUnresolvedSubqueries(sub))) return true; continue; } if (k === '$exists') return true; if (isPlainObject$1(v) && operatorObjectHasUnresolved(v)) return true; } return false; } /** 递归检测操作符对象里是否存在未解析引用(含 `$and`/`$or`/`$not` 内部) */ function operatorObjectHasUnresolved(ops) { for (const [op, operand] of Object.entries(ops)) { if (op === '$and' || op === '$or') { const subs = (Array.isArray(operand) ? operand : [operand]); if (subs.some((sub) => isPlainObject$1(sub) && operatorObjectHasUnresolved(sub))) return true; continue; } if (op === '$not') { if (isPlainObject$1(operand) && operatorObjectHasUnresolved(operand)) return true; continue; } if (op === '$subquery' || op === '$col') return true; if (isPlainObject$1(operand) && ('$subquery' in operand || '$col' in operand)) return true; } return false; } // --------------------------------------------------------------------------- // 统一递归求值器 // --------------------------------------------------------------------------- /** 逻辑连接词(where 子句级) */ function isLogicalKey(key) { return key === '$and' || key === '$or' || key === '$not'; } /** 纯对象(非 null、非数组) */ function isPlainObject$1(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } /** * 求值一个 WHERE 子句(where 级)。 * * 顶层键要么是逻辑连接词,要么是列名: * - `$and: [where...]` / `$or: [where...]` —— 子项是**完整 where 子句**; * - `$not: where` —— 子项是完整 where 子句; * - `$exists: boolean` —— 关联 EXISTS 解析结果标记; * - `$caseResult` —— CASE WHEN 解析结果标记; * - 其余键 = 列名,其值是**操作符对象**或裸值(裸值等价 `$eq`)。 */ function evalWhere(ctx, where) { let acc = SQL_LOGICAL.TRUE; for (const [key, condition] of Object.entries(where)) { let one; if (key === '$and') { one = evalWhereLogical(ctx, condition, 'and'); } else if (key === '$or') { one = evalWhereLogical(ctx, condition, 'or'); } else if (key === '$not') { one = sqlNot(evalWhere(ctx, condition)); } else if (key === '$exists' || key === '$caseResult') { one = condition === true ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE; } else { one = evalValueCondition(ctx, resolveField(ctx, key), condition); } acc = sqlAnd(acc, one); if (acc === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE; } return acc; } /** `$and` / `$or`(where 级):子项是完整 where 子句 */ function evalWhereLogical(ctx, condition, kind) { const subs = (Array.isArray(condition) ? condition : [condition]); let acc = kind === 'and' ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE; for (const sub of subs) { const one = evalWhere(ctx, sub); acc = kind === 'and' ? sqlAnd(acc, one) : sqlOr(acc, one); // FALSE 在 AND 下、TRUE 在 OR 下都无法被后续子项改变 if (kind === 'and' && acc === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE; if (kind === 'or' && acc === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE; } return acc; } /** * 取字段值。 * * 行里的键可能是 `t.id` 形式(JOIN / 子查询合并行),而 WHERE 键可能写作 * `id`。既有行为是"精确命中优先,否则回退到唯一的后缀匹配";此处保持一致, * 但**不再**像旧实现那样在多个别名同名字段间静默取第一个 —— 歧义由 Executor * 的列解析阶段(`bindColumnRefs` / `assertProjectionColumnsExist`)负责报错。 */ function resolveField(ctx, field) { if (field in ctx.row) return ctx.row[field]; let found = UNRESOLVED; let hits = 0; for (const key of Object.keys(ctx.row)) { if (key.endsWith(`.${field}`)) { found = ctx.row[key]; hits += 1; } } if (hits === 1) return found; if (hits > 1) return UNRESOLVED; return UNRESOLVED; } /** * 求值"某个值是否满足条件"—— 字段条件与嵌套操作数条件共用的核心。 * * 位置无关性是本模块的关键:`{ $or: [ { $gt: 1 } ] }` 无论出现在字段位置 * 还是顶层(历史单元测试的写法),子项都按**操作数条件**求值 —— 因为判别式 * 相同:键以 `$` 开头且不是逻辑连接词,就是操作符;否则是列名。 */ function evalValueCondition(ctx, value, condition) { if (!isPlainObject$1(condition)) { // 裸值 = `$eq`;数组 = `$in` 列表(与既有 Mongo 风格 WHERE 兼容) if (Array.isArray(condition)) return sqlIn(value, condition); return compareEquality(value, condition); } return evalOperatorObject(ctx, value, condition); } /** * 求值一个操作符对象(`{ $gt: 1 }` / `{ $and: [...] }` / `{ $col: 'y' }` …)。 * * `$and` / `$or` 的每个子项按"操作数条件"递归 —— 这正是 `NOT BETWEEN` * 生成的 `{ n: { $or: [ { $lt: 1 }, { $gt: 2 } ] } }` 能正确求值的原因。 * 若子项实际是**完整 where 子句**(键是列名),则转交 `evalWhere`, * 与当前行合并后求值(`$col` 上下文因此仍然有效)。 */ function evalOperatorObject(ctx, value, ops) { // 纯列引用:{ $col: 'y' } = 与当前行的 y 列比较 const keys = Object.keys(ops); if (keys.length === 1 && keys[0] === '$col' && ctx.options.$col) { return compareEquality(value, resolveField(ctx, String(ops.$col))); } let acc = SQL_LOGICAL.TRUE; for (const [op, operand] of Object.entries(ops)) { let one; if (op === '$and') { one = evalOperatorLogical(ctx, value, operand, 'and'); } else if (op === '$or') { one = evalOperatorLogical(ctx, value, operand, 'or'); } else if (op === '$not') { one = sqlNot(evalOperandSlot(ctx, value, operand)); } else { one = evalOperator(ctx, value, op, operand); } acc = sqlAnd(acc, one); if (acc === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE; } return acc; } /** `$and` / `$or`(操作符级):子项是操作数条件(操作符对象或裸值) */ function evalOperatorLogical(ctx, value, condition, kind) { const items = Array.isArray(condition) ? condition : [condition]; let acc = kind === 'and' ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE; for (const item of items) { const one = evalOperandSlot(ctx, value, item); acc = kind === 'and' ? sqlAnd(acc, one) : sqlOr(acc, one); if (kind === 'and' && acc === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE; if (kind === 'or' && acc === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE; } return acc; } /** * 单个嵌套子项(`$and`/`$or`/`$not` 的操作数)的求值。 * * 两种合法形态,按**键的形状**判别,不做猜测性兜底: * - 含非 `$` 开头(或逻辑连接词)的键 → 完整 where 子句,交由 `evalWhere`; * - 其余 → 操作符对象,交由 `evalOperatorObject`。 * * 注意 `$not` 的子项**不会**走到这里被判为 where 子句:`evalOperatorObject` * 对 `$not` 直接调用本函数,而 `{ $gte: 1 }` 全部键以 `$` 开头 → 操作符对象 ✓。 * 若调用方写成 `{ $not: { n: { $gte: 1 } } }`(字段级 `$not` 包了 where 子句), * 则按 where 子句解释 —— 这正是 `evalNestedSlot` 的判别分支,语义为 * "NOT (该行满足 n >= 1)",与顶层 `$not` 一致。 */ function evalOperandSlot(ctx, value, item) { if (!isPlainObject$1(item)) return evalValueCondition(ctx, value, item); const isWhereClause = Object.keys(item).some((k) => !k.startsWith('$') || isLogicalKey(k) || k === '$exists' || k === '$caseResult'); return isWhereClause ? evalWhere(ctx, item) : evalOperatorObject(ctx, value, item); } /** * 操作符求值(三值语义)。 * * - 比较类操作数含 NULL → UNKNOWN(此前 `$eq: null` 命中 NULL 行、`$ne: null` * 命中所有非 NULL 行,两者都不符合 SQL 标准); * - `$in` / `$nin` 用 `sqlIn` 的列表语义(列表含 NULL → 未命中时为 UNKNOWN); * - `$like` 对 NULL 操作数返回 UNKNOWN(`String(null)` 会得到 "null" 去匹配, * 属于静默错值); * - `$isNull` / `$isNotNull` 是**谓词**,直接对 NULL 求值,不走比较。 */ function evalOperator(ctx, value, op, operand) { const actual = resolveOperand(ctx, operand); switch (op) { case '$eq': return compareEquality(value, actual); case '$ne': return sqlNot(compareEquality(value, actual)); case '$gt': return compareOrdered(value, actual, (c) => c > 0); case '$gte': return compareOrdered(value, actual, (c) => c >= 0); case '$lt': return compareOrdered(value, actual, (c) => c < 0); case '$lte': return compareOrdered(value, actual, (c) => c <= 0); case '$in': return Array.isArray(actual) ? sqlIn(value, actual) : SQL_LOGICAL.FALSE; case '$nin': return Array.isArray(actual) ? sqlNot(sqlIn(value, actual)) : SQL_LOGICAL.FALSE; case '$like': { if (isSqlNull(value) || isSqlNull(actual) || isUnresolved(value) || isUnresolved(actual)) { return SQL_LOGICAL.UNKNOWN; } return compileLikeRegex(String(actual)).test(String(value)) ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE; } // 谓词(不参与三值比较) case '$isNull': return isSqlNull(value) ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE; case '$isNotNull': return isSqlNull(value) ? SQL_LOGICAL.FALSE : SQL_LOGICAL.TRUE; // `$col` 出现在操作符位置但未启用列上下文 → 无法求值 case '$col': return ctx.options.$col ? compareEquality(value, resolveField(ctx, String(operand))) : SQL_LOGICAL.UNKNOWN; // `$subquery` 必须由 Executor 先行解析(`resolveSubqueries`)。 // 走到这里说明调用方跳过了 Executor:返回 UNKNOWN 让该行被排除, // 而不是静默当 NULL 比较。 case '$subquery': return SQL_LOGICAL.UNKNOWN; // v0.7.2: 未知操作符显式报错 —— 此前静默返回 true(所有行匹配), // 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示 default: throw new DatabaseError(`Unknown where operator "${op}"`, 'QUERY_ERROR'); } } /** * 解析操作数槽位中的引用标记。 * * - `{ $col: 'y' }` → 当前行的 y 列值(仅在 `options.$col` 启用时); * - `{ $subquery: [...] }` → UNRESOLVED,交由上层识别为"未解析"。 * * 这两种形态都是**对象**,而非"恰好等于某个值",所以必须在这里显式解引用, * 否则 `sqlCompare(5, { $col: 'y' })` 会按不可比返回 UNKNOWN —— 恰好也是 * UNKNOWN,但那会掩盖"调用方忘了传 `$col`"这一真正的配置错误。 */ function resolveOperand(ctx, operand) { if (!isPlainObject$1(operand)) return operand; const keys = Object.keys(operand); if (keys.length !== 1) return operand; if (keys[0] === '$col') { return ctx.options.$col ? resolveField(ctx, String(operand.$col)) : UNRESOLVED; } if (keys[0] === '$subquery') return UNRESOLVED; return operand; } /** 相等比较(三值) */ function compareEquality(value, operand) { const truth = sqlCompare(value, operand); if (truth === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE; if (isSqlNull(value) || isSqlNull(operand) || isUnresolved(value) || isUnresolved(operand)) { return SQL_LOGICAL.UNKNOWN; } return SQL_LOGICAL.FALSE; } /** 有序比较(三值):任一操作数 NULL/未解析/不可比 → UNKNOWN */ function compareOrdered(value, operand, accept) { const ord = sqlCompareOrder(value, operand); if (ord === null) return SQL_LOGICAL.UNKNOWN; return accept(ord) ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE; } // --------------------------------------------------------------------------- // 排序 // --------------------------------------------------------------------------- function applyOrderBy(rows, orderBy) { return [...rows].sort((a, b) => { for (const { column, direction, nulls } of orderBy) { const aNull = a[column] === null || a[column] === undefined; const bNull = b[column] === null || b[column] === undefined; // v0.4.0: NULLS FIRST/LAST 时 NULL 位置固定,不受升降序反转 if (nulls && (aNull || bNull)) { if (aNull && bNull) continue; const cmp = nulls === 'first' ? (aNull ? -1 : 1) : (aNull ? 1 : -1); return cmp; } const cmp = compare(a[column], b[column]); if (cmp !== 0) return direction === 'desc' ? -cmp : cmp; } return 0; }); } function compare(a, b) { if (a === b) return 0; if (a === null || a === undefined) return 1; if (b === null || b === undefined) return -1; if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); if (typeof a === 'number' && typeof b === 'number') return a - b; return String(a).localeCompare(String(b)); } // --------------------------------------------------------------------------- // 列投影 // --------------------------------------------------------------------------- /** * 列投影。 * * v0.8.0(B-5):支持**分隔标识符**(`"1"`)与 JOIN 行的 `别名.列` 键。 * 此前只做"精确命中,否则找 `endsWith('.col')`",于是: * - `SELECT "1" FROM q`(列名就叫 1)取不到值 → 输出 `{}` * (校验用裸名、投影用带引号的名,两套规则 —— 典型漂移); * - 找不到时**不产出键**,行形状随列是否存在而变。 * 现在统一:脱引号 → 精确命中 → 唯一后缀命中;仍找不到则不产出该键 *(是否"未知列"由 executor 的 assertProjectionColumnsExist 判定并报错, * 投影层不做静默兜底)。 */ function projectColumns(row, columns) { const projected = {}; for (const col of columns) { const name = unquoteIdentifier$1(col); if (name in row) { projected[name] = row[name]; continue; } // JOIN 行键形如 `t.col`:唯一后缀匹配(多个命中视为歧义,取第一个与 // executor 的校验口径一致 —— 那里已对歧义报错,能走到这里说明唯一) let found; let hits = 0; for (const key of Object.keys(row)) { if (key.endsWith(`.${name}`) || key === name) { found = row[key]; hits += 1; } } if (hits >= 1) projected[name] = found; } return projected; } /** 脱去分隔标识符的引号(`"1"` → `1`,`"a""b"` → `a"b`) */ function unquoteIdentifier$1(text) { const trimmed = text.trim(); if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { return trimmed.slice(1, -1).replace(/""/g, '"'); } return trimmed; } /** * metona-sqlark 统一行校验 —— 存储写入的**唯一**验证与规范化入口(v0.8.0) * @module table/validation * * ============================================================================ * 为什么必须合并(PLAN-v0.7.5.md 根因 1:关系语义在引擎间重复实现) * ============================================================================ * 修复前项目里存在**三份**行校验实现,覆盖范围各不相同: * * | 位置 | 类型检查 | required | PK 非空 | maxLength | min/max | 未知列 | * |---|---|---|---|---|---|---| * | `engine/memory.ts#validateRow`(disk/hybrid 继承) | ✓ | ✓ | ✓ | ✗ | ✗ | 静默丢弃 | * | `engine/aria/index.ts#validateRow` → `checkFieldType` | ✓ | ✓ | ✓ | ✓ | ✓ | 静默丢弃 | * | `table/schema.ts#validateRow` | ✓ | ✓ | ✓ | ✓ | ✓ | 静默丢弃 | * * 于是**同一份 schema、同一条 INSERT** 在 Aria 上抛 `maxLength` 错误,在 * memory/disk/hybrid 上静默写入超长值(缺陷 A12)—— 用户的约束是否生效 * 取决于他选了哪个引擎,且没有任何提示。 * * 更糟的是"未知列":`INSERT INTO t (id, nope) VALUES ('1', 2)` 在四个引擎上 * 都**静默丢弃 `nope`**、插入成功。用户以为写进去了,`SELECT nope` 又报 * COLUMN_NOT_FOUND —— 写路径与读路径对同一列名给出相反结论(缺陷 A17)。 * * 本模块把校验收敛成**一个定义**(`compileValidator`),所有引擎与 QueryBuilder * 都从它取校验器: * - 约束覆盖面是"并集",不可能再出现"Aria 报错、memory 不报"; * - 未知列变成**显式错误**(`COLUMN_NOT_FOUND`,与读路径同一错误码); * - 校验与**规范化**在同一处完成(`__proto__` 防污染、`undefined` 跳过、 * `default` 填充),引擎只负责存储,不再各自解释 schema。 * * ============================================================================ * 三种载荷形态,为什么不能合成一个函数 * ============================================================================ * - `validateRow` INSERT 语义:`default` 生效、缺列合法、`required` 按最终值判; * - `validatePartial` UPDATE 语义:**只校验出现的列**(`{ a: undefined }` 表示 * "不更新 a",不能被 required/min/max 判失败); * - `assertNoUnknownColumns` 独立可复用的"列名存在性"检查(写路径预检)。 * * 把它们混成一个带 options 的函数会让"required 是否生效"取决于调用方参数, * 从而重新引入跨路径差异 —— 本模块刻意保持三个显式入口。 */ // --------------------------------------------------------------------------- // 编译 // --------------------------------------------------------------------------- /** * 把 schema 编译成可复用的行校验器。 * * 为什么"编译"而不是每次都遍历 schema:校验处于每次 INSERT/UPDATE 的热路径上, * 引擎在一次批量写入里会对成百上千行调用它。预先把列定义拆成列表 + 集合, * 既避免重复的 `Object.entries`,也让"哪些键允许出现"成为可哈希的集合判断。 * **不缓存**编译结果:schema 可被 `alterTable` 原地修改,长期缓存会用到过期列定义。 */ function compileValidator(schema) { const table = schema.name; const entries = Object.entries(schema.columns); const columnNames = new Set(entries.map(([name]) => name)); function assertNoUnknownColumns(row, knownColumns) { const allowed = knownColumns ? new Set(knownColumns) : columnNames; // 先收集未知列再报错:一次列出全部,避免用户"改一个报一个" const unknown = []; for (const key of Object.keys(row)) { if (key === '__proto__') continue; // 由 sanitize 阶段统一拒绝(消息不同) if (!allowed.has(key)) unknown.push(key); } if (unknown.length > 0) { throw new DatabaseError(`Unknown column${unknown.length > 1 ? 's' : ''} ${unknown .map((c) => `"${c}"`) .join(', ')} in table "${table}". Known columns: ${[...allowed].join(', ')}`, 'COLUMN_NOT_FOUND'); } } function validateRow(row, knownColumns) { assertNoUnknownColumns(row, knownColumns); const validated = {}; for (const [colName, colDef] of entries) { // INSERT 语义:缺列时 default 生效 let value = row[colName]; if (value === undefined && colDef.default !== undefined) value = colDef.default; assertNotNullConstraints(table, colName, colDef, value); if (value !== undefined && value !== null) { assertJsonSafeNumber(table, colName, value); checkFieldType(table, colName, colDef.type, value, colDef); } // v0.8.0(B-1):缺列且无 default → 显式写入 NULL,**不能省略键**。 // // 此前 `if (value !== undefined) validated[colName] = value;` 会把这个列整个 // 从行里删掉,于是存储行只含"有值的列",行形状取决于写入方式: // INSERT INTO t (id, g) VALUES ('9','z') -- 行里没有 n 键 // SELECT id, g, n FROM t WHERE id = '9' -- 抛 COLUMN_NOT_FOUND: n // 而 `SELECT * FROM t` 却能正常返回(少一列而已)—— 同一行"有没有 n 列" // 在读路径上给出相反结论。SQL 语义中"未提供值"就是 NULL,故统一补 null: // 行始终包含全部 schema 列,投影/排序/三值比较才有统一前提。 validated[colName] = value === undefined ? null : value; } return validated; } function validatePartial(row) { const values = {}; const unknown = []; for (const [colName, value] of Object.entries(row)) { if (colName === '__proto__') { throw new DatabaseError('Column name "__proto__" is not allowed', 'VALIDATION_ERROR'); } const colDef = schema.columns[colName]; if (!colDef) { unknown.push(colName); continue; } // UPDATE 语义:`undefined` 已由 stripUndefinedUpdates 过滤; // 这里再挡一次,保证"未提供的列"绝不会被判 required 失败。 if (value === undefined) continue; assertNotNullConstraints(table, colName, colDef, value); if (value !== null) { assertJsonSafeNumber(table, colName, value); checkFieldType(table, colName, colDef.type, value, colDef); } values[colName] = value; } return { values, unknown }; } return { table, columns: columnNames, validateRow, validatePartial }; } // --------------------------------------------------------------------------- // 共享约束 // --------------------------------------------------------------------------- /** * 检查字段类型(含约束校验)。 * * v0.8.0(B-1):在 `checkFieldType`(table/schema.ts)之外**额外**拒绝 * `NaN` 与 `±Infinity`。为什么必须有这一层: * - JSON 无法表示它们 —— `JSON.stringify({ v: NaN })` 得到 `{"v":null}`, * 于是 `INSERT ... VALUES (NaN)` 在内存引擎里是 NaN,落盘再读回来变成 null; * 同一个库在"写后立即查"与"重启后查"得到不同结果,且没有任何提示。 * - KVStore / Aria 的持久化路径都是 JSON,因此这是**所有**磁盘引擎的共性问题。 * - 用户能构造出 NaN:`Number('abc')`、`0/0`、`parseFloat('x')` 等, * 经由参数绑定进入写入路径。 * 显式拒绝(`VALIDATION_ERROR`)让问题在写入时暴露,而不是变成读出来的 null。 */ function assertJsonSafeNumber(table, colName, value) { if (typeof value !== 'number') return; if (Number.isFinite(value)) return; throw new DatabaseError(`Column "${colName}" in table "${table}" cannot store ${Number.isNaN(value) ? 'NaN' : String(value)}:` + ' it is not representable in JSON and would be silently read back as null', 'VALIDATION_ERROR'); } /** * NOT NULL 类约束。 * * 两条规则合并在一个函数里按顺序判断,是为了让错误消息稳定: * - `required` → "is required"(用户声明的业务约束); * - `primaryKey` → 隐含 NOT NULL(SQL 语义)。 * 在此之前两者分散在两个引擎的 `validateRow` 里各写一遍,消息略有差异 * (一个有表名后缀一个没有),依赖消息文本的测试只能各测各的引擎。 */ function assertNotNullConstraints(table, colName, colDef, value) { if (colDef.required && (value === undefined || value === null)) { throw new DatabaseError(`Column "${colName}" is required in table "${table}"`, 'VALIDATION_ERROR'); } // v0.7.4:主键列强制非空 —— 此前 null/undefined 主键被 String() 化为 // "null"/"undefined" 静默入库,行再也无法按主键取回。 if (colDef.primaryKey && (value === undefined || value === null)) { throw new DatabaseError(`Primary key column "${colName}" in table "${table}" cannot be null or undefined`, 'VALIDATION_ERROR'); } } // --------------------------------------------------------------------------- // 字段类型与约束检查(唯一实现) // --------------------------------------------------------------------------- /** 检查字段类型(含约束校验) */ function checkFieldType(tableName, colName, type, value, colDef) { const jsType = typeof value; switch (type) { case 'string': if (jsType !== 'string') { throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR'); } if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) { throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR'); } break; case 'number': if (jsType !== 'number') { throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR'); } if (colDef?.min !== undefined && value < colDef.min) { throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR'); } if (colDef?.max !== undefined && value > colDef.max) { throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR'); } break; case 'boolean': if (jsType !== 'boolean') { throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR'); } break; case 'date': if (jsType !== 'string' || isNaN(Date.parse(value))) { throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR'); } break; case 'json': if (jsType !== 'object') { throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR'); } break; } } /** * metona-sqlark Schema — 表结构定义与校验 * @module table/schema */ // --------------------------------------------------------------------------- // Schema 工具 // --------------------------------------------------------------------------- /** 从列定义创建 TableSchema */ function createSchema(name, columns) { validateColumns(columns); return { name, columns }; } /** 校验列定义 */ function validateColumns(columns) { const colNames = Object.keys(columns); if (colNames.length === 0) { throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR'); } let primaryKeyCount = 0; for (const [colName, colDef] of Object.entries(columns)) { // v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失); // 显式拒绝避免原型污染类攻击面 if (colName === '__proto__') { throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR'); } // 类型校验 if (!FIELD_TYPES.includes(colDef.type)) { throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR'); } // 主键计数 if (colDef.primaryKey) { primaryKeyCount++; } } // 至少需要一个主键 if (primaryKeyCount === 0) { throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR'); } // v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与 // 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。 // 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。 if (primaryKeyCount > 1) { throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` + 'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR'); } } /** * v0.7.2: 更新载荷清洗 —— undefined 值视为"不更新该列"(保留旧值)。 * 此前 `update({ col: undefined })` 会把 undefined 写入行(覆盖旧值、列键丢失)。 * null 保留(显式置空语义)。 */ function stripUndefinedUpdates(updates) { const clean = {}; for (const [key, value] of Object.entries(updates)) { if (value !== undefined) clean[key] = value; } return clean; } /** 将 AST 列定义转换为 ColumnDef */ function astColumnToColumnDef(astCol) { return { type: astCol.type, primaryKey: astCol.primaryKey, unique: astCol.unique, required: astCol.required, default: astCol.default, index: astCol.index, maxLength: astCol.maxLength, min: astCol.min, max: astCol.max, references: astCol.references, onDelete: astCol.onDelete, onUpdate: astCol.onUpdate, }; } /** * metona-sqlark Memory Engine — 基于 Map 的内存存储引擎 * @module engine/memory */ class MemoryEngine { constructor() { this.name = 'memory'; this.tables = new Map(); this.schemas = new Map(); this.indexes = new Map(); this.opened = false; /** v0.4.2-fix: 库内元数据(迁移版本持久化用) */ this.metaStore = new Map(); /** * v0.7.4: 由 CREATE UNIQUE INDEX 添加的 unique 列(table:col)。 * 与建表 UNIQUE 约束区分:DROP INDEX 只允许解除索引来源的 unique, * 建表约束需重建表(对齐 SQLite 语义,此前静默解除且不可恢复)。 */ this.uniqueIndexCols = new Set(); // ---- 事务快照 ---- this.snapshot = null; } // ---- 生命周期 ---- async open(_dbName, _version) { if (this.opened) { // 幂等:已打开则忽略 return; } this.opened = true; } async close() { this.tables.clear(); this.schemas.clear(); this.indexes.clear(); this.metaStore.clear(); this.opened = false; } isOpen() { return this.opened; } // ---- v0.4.2-fix: 自愈 / 重置 / 元数据 ---- /** 内存引擎无需修复(无持久化损坏概念) */ async repair() { return; } /** 清空全部数据与表结构 */ async clearAll() { const names = Array.from(this.schemas.keys()); for (const name of names) { await this.dropTable(name); } this.metaStore.clear(); } async getMeta(key) { return this.metaStore.get(key) ?? null; } async setMeta(key, value) { this.metaStore.set(key, value); } // ---- 表管理 ---- async createTable(schema) { if (this.schemas.has(schema.name)) throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS'); // v0.4.2-fix: 存储 schema 深拷贝 — 此前 Hybrid.reloadMemoryFromDisk 直接存入 // disk 引擎的 schema 引用,内存/磁盘引擎共享同一对象,任一引擎 ALTER 都会污染对方 const copy = { name: schema.name, columns: {} }; for (const [colName, colDef] of Object.entries(schema.columns)) { copy.columns[colName] = { ...colDef }; } this.schemas.set(schema.name, copy); this.tables.set(schema.name, new Map()); const tableIndexes = new Map(); for (const [colName, colDef] of Object.entries(copy.columns)) { if (colDef.index || colDef.unique) tableIndexes.set(colName, new Map()); } this.indexes.set(schema.name, tableIndexes); } async dropTable(tableName) { this.ensureTable(tableName); this.schemas.delete(tableName); this.tables.delete(tableName); this.indexes.delete(tableName); // v0.7.4: 清理该表的 unique 索引来源标记(重建同名表不残留) const prefix = `${tableName}:`; for (const key of this.uniqueIndexCols) { if (key.startsWith(prefix)) this.uniqueIndexCols.delete(key); } } async hasTable(tableName) { return this.schemas.has(tableName); } async getTableNames() { return Array.from(this.schemas.keys()); } async getTableSchema(tableName) { return this.schemas.get(tableName) ?? null; } /** * v0.4.2-fix: 引擎级 ALTER TABLE — 直接修改内存 schema 引用并清理行数据。 * (此前走 executor 通用路径,行为相同;统一到引擎层保证 Hybrid/IndexedDB 委托一致性) */ async alterTable(tableName, action, column) { // v0.7.2: 事务内 DDL 显式拒绝(与 AriaEngine 对齐)。此前事务快照对 schema // 是浅拷贝,alterTable 直接修改共享 columns 对象 → ROLLBACK 后结构变更残留 // (三引擎行为不一致:Aria 拒绝 / Memory、KVStore 静默残留) if (this.snapshot) { throw new DatabaseError(`ALTER TABLE is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED'); } this.ensureTable(tableName); const schema = this.schemas.get(tableName); if (action === 'ADD') { if (schema.columns[column.name]) { throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS'); } schema.columns[column.name] = column; // v0.8.0 根治:ALTER ADD 必须建立二级索引桶。 // // 此前只写 schema.columns 而不建桶,而唯一性预检完全依赖索引桶 // (`tableIndexes.get(colName)` 缺失即整段跳过)—— 于是 // `ALTER TABLE t ADD COLUMN email STRING UNIQUE` 之后插入重复 email // **不会报错**;close/reopen 时 createTable 依 schema 建桶、回灌第 2 行 // 触发 UNIQUE_VIOLATION 而异常被引擎 open 路径吞掉 → **行静默消失**。 if (column.index || column.unique) { if (!this.indexes.has(tableName)) this.indexes.set(tableName, new Map()); const tableIndexes = this.indexes.get(tableName); if (!tableIndexes.has(column.name)) tableIndexes.set(column.name, new Map()); // 已存在行:先校验存量唯一性(重复则回滚本次 ALTER),再回填索引桶 const colIndex = tableIndexes.get(column.name); const table = this.tables.get(tableName); const seen = new Set(); for (const [pk, row] of table) { const value = row[column.name]; if (value === null || value === undefined) continue; // null 不受唯一约束 if (column.unique && seen.has(value)) { tableIndexes.delete(column.name); delete schema.columns[column.name]; throw new DatabaseError(`Duplicate value "${String(value)}" for UNIQUE column "${column.name}" in table "${tableName}"`, 'UNIQUE_VIOLATION'); } seen.add(value); let pks = colIndex.get(value); if (!pks) { pks = new Set(); colIndex.set(value, pks); } pks.add(pk); } } // v0.8.0(B-1):ADD 列在**已有行**上物化为 NULL。 // // 行校验契约(table/validation.ts)保证"行含全部 schema 列",若 ALTER ADD // 不补齐,新列在旧行上就是**键不存在**:内存里 `{id,name}`、而同一行经 // 落盘再读回(KVStore/Aria 的恢复路径会走 validateRow)变成 // `{id,name,phone:null}` —— 同一行的形状取决于"是否重启过"。 // 显式物化后,内存视图与持久化视图一致。 const addTable = this.tables.get(tableName); for (const row of addTable.values()) { if (!(column.name in row)) row[column.name] = null; } return; } if (!schema.columns[column.name]) { throw new DatabaseError(`Column "${column.name}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND'); } // v0.7.3: 被删列是索引列 → 同步清理索引 Map —— 此前残留旧索引: // 查询已删列仍走旧索引(不含新行)→ 结果不完整(对齐 AriaEngine cleanupTableIndexes) if (schema.columns[column.name].index || schema.columns[column.name].unique) { this.indexes.get(tableName)?.delete(column.name); } delete schema.columns[column.name]; // 清理已有行中该列的值(find 返回行引用,直接删除生效) const table = this.tables.get(tableName); for (const row of table.values()) { if (column.name in row) delete row[column.name]; } } // ---- CRUD ---- async insert(tableName, rows) { this.ensureTable(tableName); const schema = this.schemas.get(tableName); const table = this.tables.get(tableName); const pkColumn = this.getPrimaryKey(schema); const pks = []; // v0.7.3: 语句级原子性 —— 两阶段(先全量预检,后执行)。 // 此前逐行"校验+写入":第 N 行主键重复/唯一冲突抛错时,前 N-1 行已提交 // (无事务下语句级部分提交,与 v0.7.2 修复的 UPDATE 同类问题)。 const validated = []; const pkSet = new Set(); const batchUnique = new Map(); // 阶段 1:全量预检(任何一行失败 → 整条语句不执行) for (const row of rows) { const validatedRow = this.validateRow(schema, row); const pkValue = String(validatedRow[pkColumn]); // 批内主键互查(内存表尚未反映本批写入) if (table.has(pkValue) || pkSet.has(pkValue)) { throw new DatabaseError(`Duplicate primary key "${pkValue}" in table "${tableName}"`, 'DUPLICATE_KEY'); } pkSet.add(pkValue); // v0.7.3: 批内唯一互查 + 索引查(此前两行同批写入同一唯一值时, // 第一行已写入索引 → 第二行 checkUniqueness 抛错 → 第一行残留) this.checkInsertUniqueness(schema, tableName, validatedRow, batchUnique); validated.push(validatedRow); } // 阶段 2:执行(预检已通过,此阶段不再抛校验类错误) for (const validatedRow of validated) { const pkValue = String(validatedRow[pkColumn]); table.set(pkValue, validatedRow); this.updateIndexes(tableName, validatedRow, pkValue); pks.push(pkValue); } return pks; } /** v0.7.3: 按主键取已验证行(KVStoreEngine 持久化 validated 行用,含 default/类型归一) */ getRow(tableName, pkValue) { // v0.8.0: 返回副本(调用方用于持久化,不得持有内部引用) const table = this.tables.get(tableName); if (!table) return null; const row = table.get(pkValue); return row ? cloneRow(row) : null; } async find(tableName, query) { this.ensureTable(tableName); const table = this.tables.get(tableName); let results = this.tryIndexLookup(tableName, table, query); if (query.where && Object.keys(query.where).length > 0) { results = results.filter((row) => matchWhere(row, query.where)); } if (query.orderBy && query.orderBy.length > 0) { results = applyOrderBy(results, query.orderBy); } const offset = query.offset ?? 0; const limit = query.limit ?? results.length; results = results.slice(offset, offset + limit); if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') { results = results.map((row) => projectColumns(row, query.columns)); } // v0.8.0: 返回副本 —— 此前直接交出内部行对象,调用方原地修改即改写存储 // 并让索引与行失配(该行从此查不出来)。见 engine/interface.ts 的行所有权约定。 return results.map((row) => cloneRow(row)); } /** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */ async findStream(tableName, query, onRow) { this.ensureTable(tableName); const table = this.tables.get(tableName); const hasWhere = !!(query.where && Object.keys(query.where).length > 0); const limit = query.limit ?? Infinity; const offset = query.offset ?? 0; const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*' ? (row) => projectColumns(row, query.columns) : null; let count = 0; let skipped = 0; for (const row of table.values()) { if (hasWhere && !matchWhere(row, query.where)) continue; if (skipped < offset) { skipped++; continue; } onRow(project ? project(row) : cloneRow(row)); count++; if (count >= limit) break; } return count; } async update(tableName, query, updates) { this.ensureTable(tableName); const schema = this.schemas.get(tableName); const table = this.tables.get(tableName); const pkCol = this.getPrimaryKey(schema); // v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空 const cleanUpdates = stripUndefinedUpdates(updates); // v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。 // // 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析, // 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。 // B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能 // 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的 // 补救方向:用户没有做错任何事。 // // 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉, // 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。 // v0.7.4: 未知列显式报错 —— 此前 SET nonexistent = ... 被静默写入存储行 // (validateRow 只遍历 schema 列,脏列残留在行内并随持久化落盘) for (const col of Object.keys(cleanUpdates)) { if (!schema.columns[col]) { throw new DatabaseError(`Column "${col}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND'); } } // v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。 // 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入 // → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。 const planned = []; const batchUnique = new Map(); /** * v0.8.0 根治:批内新主键互查。 * * 此前阶段 1 只用 `table.has(newPk)` 与**语句执行前的表**比对,看不到同一语句内 * 其它行即将写入的新主键。于是 `UPDATE t SET id = 'X'`(匹配 2 行)在阶段 2 * 逐行 `table.set(newPk, ...)` 相互覆盖 —— 返回 affected=2,表中却只剩 1 行 * (静默丢行)。INSERT 路径在 v0.7.3 已做批内 PK Set 互查,UPDATE 漏了。 * * 保守拒绝策略:同一语句内两行改到同一新主键必然互相覆盖,直接报错。 * 注意这也会拒绝"两行互换主键"(A:x→y, B:y→x)这种最终状态合法的写法 —— * 那属于需要基于最终状态判定的场景,宁可显式报错也不静默丢行 * (与既有的"唯一值交换更新保守拒绝"语义一致)。 */ const batchNewPks = new Set(); // 阶段 1:全量预检(任何一行失败 → 整条语句不执行) for (const [pk, row] of table) { if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where)) continue; const updated = { ...row, ...cleanUpdates }; this.validateRow(schema, updated); this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique); const newPk = String(updated[pkCol]); // v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据) if (newPk !== pk && table.has(newPk)) { throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY'); } // v0.8.0: 批内互查 —— 同一语句内两行改到同一新主键 → 整体拒绝(不得静默覆盖) if (newPk !== pk) { if (batchNewPks.has(newPk)) { throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (multiple rows in the same statement update to the same key)`, 'DUPLICATE_KEY'); } batchNewPks.add(newPk); } planned.push({ pk, row, updated, newPk }); } // 阶段 1b:主键变更 RESTRICT 预检(引用表依赖行检查,任何修改前) for (const p of planned) { if (p.newPk !== p.pk) this.checkUpdateRestrict(tableName, p.pk); } // 阶段 2:执行(预检已通过,此阶段不再抛校验类错误) let count = 0; for (const { pk, row, updated, newPk } of planned) { // v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行) this.removeIndexEntries(tableName, row, pk); // v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表 if (newPk !== pk) { await this.applyUpdateCascade(tableName, pk, newPk); } table.delete(pk); table.set(newPk, updated); this.updateIndexes(tableName, updated, newPk); count++; } return count; } /** * v0.7.3: 插入唯一性预检 —— 批内互查(本批前几行写入同一唯一值) * + 索引查(表中已有行)。与 update 的 checkUpdateUniqueness 对称, * 两阶段 insert 预检阶段调用(索引尚未反映本批写入)。 */ checkInsertUniqueness(schema, tableName, row, batchUnique) { const tableIndexes = this.indexes.get(tableName); for (const [colName, colDef] of Object.entries(schema.columns)) { if (!colDef.unique) continue; const value = row[colName]; if (value === undefined || value === null) continue; let seen = batchUnique.get(colName); if (!seen) { seen = new Set(); batchUnique.set(colName, seen); } if (seen.has(value)) { throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION'); } seen.add(value); if (!tableIndexes) continue; const colIndex = tableIndexes.get(colName); if (colIndex && colIndex.has(value)) { throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION'); } } } /** * v0.7.3: 更新唯一性预检 — 批内互查(多条行更新到同一唯一值)+ 索引查 * (排除自身旧条目)。阶段 1 中索引尚未更新,批内互查避免"两行同时改到 * 同一新值"绕过唯一约束。 */ checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique) { const tableIndexes = this.indexes.get(tableName); for (const [colName, colDef] of Object.entries(schema.columns)) { if (!colDef.unique) continue; const value = updated[colName]; if (value === undefined || value === null) continue; let seen = batchUnique.get(colName); if (!seen) { seen = new Set(); batchUnique.set(colName, seen); } if (seen.has(value)) { throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION'); } seen.add(value); if (!tableIndexes) continue; const colIndex = tableIndexes.get(colName); if (colIndex && colIndex.has(value)) { const pks = colIndex.get(value); // 值未变(新值 = 旧值)且索引中只有自身 → 允许 if (!(pks.size === 1 && pks.has(pk))) { throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION'); } } } } /** * v0.7.2: ON UPDATE RESTRICT 预检 — 从 applyUpdateCascade 提取, * 两阶段 update 在任何修改前调用(整体拒绝语义)。 */ checkUpdateRestrict(tableName, oldPk) { for (const [refTableName, refSchema] of this.schemas) { // v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。 // // 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`) // 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。 // 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机: // 删除路径必须先递归子树再删父行,且**先收集引用者再处理** //(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。 for (const [colName, colDef] of Object.entries(refSchema.columns)) { if (!colDef.references || !colDef.onUpdate) continue; const [refTable] = colDef.references.split('.'); if (refTable !== tableName) continue; const refTableData = this.tables.get(refTableName); if (!refTableData) continue; let hasDependents = false; for (const [, refRow] of refTableData) { if (String(refRow[colName]) !== oldPk) continue; hasDependents = true; if (colDef.onUpdate === 'RESTRICT') { throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION'); } } // v0.7.2: SET NULL 到 required 列违反约束 —— 与 RESTRICT 同样整体拒绝 // (此前级联直写 null 绕过 validateRow,required 列被静默置空) if (hasDependents && colDef.onUpdate === 'SET NULL' && colDef.required) { throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION'); } } } } /** * v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表: * RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。 * v0.7.3-perf: 删除冗余的阶段 1 RESTRICT 扫描 —— checkUpdateRestrict 已在 * 两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required, * 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。 */ async applyUpdateCascade(tableName, oldPk, newPk) { for (const [refTableName, refSchema] of this.schemas) { // v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。 // // 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`) // 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。 // 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机: // 删除路径必须先递归子树再删父行,且**先收集引用者再处理** //(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。 for (const [colName, colDef] of Object.entries(refSchema.columns)) { if (!colDef.references || !colDef.onUpdate) continue; const [refTable] = colDef.references.split('.'); if (refTable !== tableName) continue; const refTableData = this.tables.get(refTableName); if (!refTableData) continue; if (colDef.onUpdate !== 'CASCADE' && colDef.onUpdate !== 'SET NULL') continue; // v0.8.0(B-1):级联写入也必须过统一校验。 // // 此前这里**直接赋值**绕过校验:`onUpdate: 'CASCADE'` 把新主键写入引用列时, // 若该列有 maxLength / min / max 约束(新主键更长或超出范围), // 约束被静默绕过 —— 与 A12 是同一类"校验只在部分写入路径生效"的问题。 // SET NULL 到 required/非空列的检查由 checkUpdateRestrict 在任何修改前完成, // 此处再校验可同时覆盖 maxLength/min/max 这类"具体值相关"的约束。 const validator = this.rowValidator(refSchema); for (const [refPk, refRow] of refTableData) { if (String(refRow[colName]) !== oldPk) continue; const nextValue = colDef.onUpdate === 'CASCADE' ? newPk : null; const { values } = validator.validatePartial({ [colName]: nextValue }); this.removeIndexEntries(refTableName, refRow, refPk); refRow[colName] = values[colName]; this.updateIndexes(refTableName, refRow, refPk); } } } } async delete(tableName, query) { this.ensureTable(tableName); // v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。 // // 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析, // 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。 // B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能 // 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的 // 补救方向:用户没有做错任何事。 // // 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉, // 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。 const table = this.tables.get(tableName); const toDelete = []; for (const [pk, row] of table) { if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) { toDelete.push({ pk, row }); } } // v0.6.3-fix: 级联两阶段 —— 先对全部待删行做 RESTRICT 预检(沿 CASCADE 链递归), // 任何一行违规则整体拒绝。此前逐行执行:第 N 行 RESTRICT 抛错时,前 N-1 行的 // 级联子行已被删除、父行未删 → 无事务下部分级联(数据不一致) // // v0.7.3-fix: 索引清理移到预检之后 —— 此前 removeIndexEntries 在收集阶段执行, // RESTRICT 预检抛错时行未删但索引条目已删 → 唯一约束失效、索引查询丢行 const restrictVisited = new Set(); for (const { pk } of toDelete) { this.checkCascadeRestrict(tableName, pk, restrictVisited); } // 预检通过:清理索引 + 级联删除(此阶段不再抛校验类错误) let cascadeCount = 0; for (const { pk, row } of toDelete) { // v0.3.3: 删除行前清理其索引条目(修复删除后索引残留) this.removeIndexEntries(tableName, row, pk); cascadeCount += await this.cascadeDelete(tableName, pk, row); } for (const { pk } of toDelete) table.delete(pk); return toDelete.length + cascadeCount; } /** * v0.6.3: RESTRICT 预检(delete 级联两阶段之一)。 * 递归沿 CASCADE 链检查引用表:RESTRICT 引用存在依赖行则抛 FOREIGN_KEY_VIOLATION。 */ checkCascadeRestrict(tableName, pkValue, visited) { const visitKey = `${tableName}:${pkValue}`; if (visited.has(visitKey)) return; visited.add(visitKey); for (const [refTableName, refSchema] of this.schemas) { // v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。 // // 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`) // 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。 // 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机: // 删除路径必须先递归子树再删父行,且**先收集引用者再处理** //(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。 for (const [colName, colDef] of Object.entries(refSchema.columns)) { if (!colDef.references || !colDef.onDelete) continue; const [refTable] = colDef.references.split('.'); if (refTable !== tableName) continue; const refTableData = this.tables.get(refTableName); if (!refTableData) continue; const refPks = []; for (const [refPk, refRow] of refTableData) { if (String(refRow[colName]) === pkValue) refPks.push(refPk); } if (colDef.onDelete === 'RESTRICT' && refPks.length > 0) { throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION'); } // v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝 if (colDef.onDelete === 'SET NULL' && colDef.required && refPks.length > 0) { throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION'); } if (colDef.onDelete === 'CASCADE') { for (const refPk of refPks) { this.checkCascadeRestrict(refTableName, refPk, visited); } } } } } async count(tableName, query) { this.ensureTable(tableName); const table = this.tables.get(tableName); if (!query?.where || Object.keys(query.where).length === 0) return table.size; let result = 0; for (const row of table.values()) { if (matchWhere(row, query.where)) result++; } return result; } async clear(tableName) { this.ensureTable(tableName); this.tables.get(tableName).clear(); const tableIndexes = this.indexes.get(tableName); if (tableIndexes) for (const colIndex of tableIndexes.values()) colIndex.clear(); } // ---- 动态索引(v0.3.0) ---- async createIndex(tableName, column, unique) { // v0.7.2: 事务内修改列级标志(colDef.index/unique)会写入共享列对象, // 事务快照无法回滚 → 与 alterTable 同样显式拒绝 if (this.snapshot) { throw new DatabaseError(`CREATE INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED'); } this.ensureTable(tableName); const schema = this.schemas.get(tableName); const colDef = schema.columns[column]; if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND'); if (colDef.index || colDef.unique) return; // 已存在 const tableIndexes = this.indexes.get(tableName); if (!tableIndexes.has(column)) tableIndexes.set(column, new Map()); const colIndex = tableIndexes.get(column); const table = this.tables.get(tableName); try { for (const [pk, row] of table) { const value = row[column]; if (value !== undefined && value !== null) { // v0.7.3: UNIQUE 索引回填校验存量唯一性 —— 此前重复数据静默建索引 // (SQLite 语义应报错),且此后该列唯一约束永远无法满足 if (unique && colIndex.has(value)) { throw new DatabaseError(`Unique index on column "${column}" in table "${tableName}" cannot be created: duplicate value "${String(value)}"`, 'UNIQUE_VIOLATION'); } if (!colIndex.has(value)) colIndex.set(value, new Set()); colIndex.get(value).add(pk); } } } catch (error) { // 回填失败(唯一冲突):清理半初始化索引,标志未落,保持原子语义 tableIndexes.delete(column); throw error; } colDef.index = true; if (unique) { colDef.unique = true; // v0.7.4: 记录唯一约束来源(DROP INDEX 时可解除;建表约束不可) this.uniqueIndexCols.add(`${tableName}:${column}`); } } async dropIndex(tableName, column, _indexName) { // v0.7.2: 同 createIndex —— 列级标志修改无法通过事务快照回滚,显式拒绝 if (this.snapshot) { throw new DatabaseError(`DROP INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED'); } this.ensureTable(tableName); const schema = this.schemas.get(tableName); const colDef = schema.columns[column]; if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND'); // v0.4.1: DROP 不存在的索引应报错(此前静默成功) if (!colDef.index && !colDef.unique) { throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND'); } // v0.7.4: 建表 UNIQUE 约束不可通过 DROP INDEX 解除 —— 此前 colDef.unique = false // 静默解除约束(后续唯一性检查失效、重复数据入库)。对齐 SQLite 语义: // 约束随建表存在,解除需重建表;仅 CREATE UNIQUE INDEX 添加的约束可随索引删除。 const uniqueKey = `${tableName}:${column}`; if (colDef.unique && !this.uniqueIndexCols.has(uniqueKey)) { throw new DatabaseError(`Cannot drop index on column "${column}" in table "${tableName}": ` + 'UNIQUE constraint defined at table creation must be removed by recreating the table', 'NOT_SUPPORTED'); } colDef.index = false; colDef.unique = false; this.uniqueIndexCols.delete(uniqueKey); const tableIndexes = this.indexes.get(tableName); if (tableIndexes) tableIndexes.delete(column); } // ---- 事务 ---- async beginTransaction() { if (this.snapshot) throw new DatabaseError('Transaction already in progress', 'TX_ACTIVE'); this.snapshot = { tables: this.deepCloneMapMap(this.tables), schemas: new Map(this.schemas), indexes: this.deepCloneIndexes(this.indexes), }; } async commitTransaction() { if (!this.snapshot) throw new DatabaseError('No active transaction', 'TX_NONE'); this.snapshot = null; } async rollbackTransaction() { if (!this.snapshot) throw new DatabaseError('No active transaction', 'TX_NONE'); this.tables = this.snapshot.tables; this.schemas = this.snapshot.schemas; this.indexes = this.snapshot.indexes; this.snapshot = null; } // ---- 事务快照辅助 ---- deepCloneMapMap(source) { const clone = new Map(); for (const [k, v] of source) { const innerClone = new Map(); for (const [ik, iv] of v) innerClone.set(ik, { ...iv }); clone.set(k, innerClone); } return clone; } deepCloneIndexes(source) { const clone = new Map(); for (const [tableName, tableIndexes] of source) { const tableClone = new Map(); for (const [col, colIndex] of tableIndexes) { const colClone = new Map(); for (const [val, pkSet] of colIndex) colClone.set(val, new Set(pkSet)); tableClone.set(col, colClone); } clone.set(tableName, tableClone); } return clone; } // ---- 内部辅助 ---- ensureTable(tableName) { if (!this.tables.has(tableName)) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND'); } getPrimaryKey(schema) { for (const [name, col] of Object.entries(schema.columns)) { if (col.primaryKey) return name; } return Object.keys(schema.columns)[0]; } validateRow(schema, row) { // v0.8.0(B-1):委托给**唯一**的行校验实现(table/validation.ts)。 // // 此前这里是第三份独立实现:只做类型检查,**没有** maxLength / min / max // 约束(Aria 有)—— 于是同一份 schema、同一条 INSERT 是否报错取决于引擎 //(缺陷 A12)。同时它对未知列静默丢弃(A17)。 return this.rowValidator(schema).validateRow(row); } /** * 取该 schema 的行校验器(每次调用重新编译)。 * * 不缓存在引擎字段上:`alterTable` 会原地修改 schema 对象, * 长期缓存会继续用过期列定义("加了列却仍被当未知列"这类难查问题)。 * 编译本身只是 `Object.entries` + Set 构造,相对一次 INSERT 的索引维护可忽略。 */ rowValidator(schema) { return compileValidator(schema); } /** * v0.8.0(B-1):写入前置校验(见 `IStorageEngine.validatePayload` 契约)。 * * 引擎在 `insert` / `update` 内部**同样**会校验 —— 本方法只是让 Executor 与 * QueryBuilder 能在"开始写入之前"拿到同一套判定结果,从而: * - 多行 INSERT 的预检发生在任何副作用之前(错误信息带列名清单); * - 直通路径与 SQL 路径不可能给出不同结论(同一个 `compileValidator`)。 */ async validatePayload(tableName, rows, mode = 'insert') { this.ensureTable(tableName); const schema = this.schemas.get(tableName); const validator = this.rowValidator(schema); for (const row of rows) { if (mode === 'update') validator.validatePartial(stripUndefinedUpdates(row)); else validator.validateRow(row); } } /** 索引查找 */ tryIndexLookup(tableName, table, query) { const tableIndexes = this.indexes.get(tableName); if (!tableIndexes || !query.where) return Array.from(table.values()); // v0.7.3: 递归展开 $and 中的等值条件 —— 此前仅顶层键, // `WHERE a AND b`(解析为顶层 $and)永远全表扫描,索引形同虚设。 // $or/$not 语义不适用单索引下推,保守跳过。命中索引后 find 仍以 // 全条件 matchWhere 过滤(子集语义安全)。 const flat = []; const collect = (w) => { for (const [k, v] of Object.entries(w)) { if (k === '$and') { for (const sub of v) collect(sub); continue; } if (k === '$or' || k === '$not') continue; flat.push([k, v]); } }; collect(query.where); for (const [col, condition] of flat) { // v0.4.1: 支持 { $eq: value } 形式(SQL 解析器生成的等值条件)走索引 let targetValue; if (typeof condition !== 'object' || condition === null) { targetValue = condition; } else if ('$eq' in condition && Object.keys(condition).length === 1) { targetValue = condition.$eq; } else { continue; } // v0.7.3: null/undefined 条件不走索引 —— 索引不含 null 条目, // colIndex.get(null) 恒 undefined → return [] 短路全表扫描 → 索引列 // IS NULL 恒空(对齐 AriaEngine v0.6.2 修复) if (targetValue === null || targetValue === undefined) continue; // v0.8.0 根治(与 AriaEngine 同步):**非原始值**(对象/数组)一律不走索引。 // // 索引键只存原始值,因此 colIndex.get({...}) 恒 undefined → 下面 `return []` // 会短路全表扫描 → 结果静默为空。真实触发场景正是"未解析的操作数": // { $eq: { $col: 'y' } } ← 列对列比较(t.x = t.y) // { $in: { $subquery: ... } } ← 关联 IN 子查询 // 它们本该由 executor 逐行求值,却先在这里被索引路径吞成空集。 if (typeof targetValue === 'object') continue; const colIndex = tableIndexes.get(col); if (colIndex) { const pks = colIndex.get(targetValue); if (pks) { const result = []; for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); } return result; } return []; } } return Array.from(table.values()); } /** 更新索引 */ updateIndexes(tableName, row, pk) { const tableIndexes = this.indexes.get(tableName); if (!tableIndexes) return; for (const [colName, colIndex] of tableIndexes) { const value = row[colName]; if (value !== undefined && value !== null) { if (!colIndex.has(value)) colIndex.set(value, new Set()); colIndex.get(value).add(pk); } } } /** v0.3.3: 从所有索引中移除一行的条目(update/delete 前调用,修复索引过期/残留) */ removeIndexEntries(tableName, row, pk) { const tableIndexes = this.indexes.get(tableName); if (!tableIndexes) return; for (const [colName, colIndex] of tableIndexes) { const value = row[colName]; if (value !== undefined && value !== null) { const pks = colIndex.get(value); if (pks) { pks.delete(pk); if (pks.size === 0) colIndex.delete(value); } } } } // ---- 外键级联 ---- /** * 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。 * v0.6.1-fix: 环路保护(A→B→A 级联环不再无限递归栈溢出,AriaEngine 同语义)。 * @returns 级联删除的行数 */ async cascadeDelete(tableName, pkValue, _row, visited = new Set()) { const visitKey = `${tableName}:${pkValue}`; if (visited.has(visitKey)) return 0; visited.add(visitKey); let totalCascade = 0; for (const [refTableName, refSchema] of this.schemas) { // v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。 // // 此前这里 `continue`,于是 `parent_id REFERENCES node(id)` 这种树形自引用 // 完全不做级联。实测(本提交的用例锁定): // INSERT node: root <- a <- b <- c // DELETE root → 只删掉 root,a/b/c 全部残留且 parent_id 指向已删除的行 // (且因为父行已删,它们之后**再也无法通过级联清理** —— 永久悬挂) // 这是"删除留下悬挂引用"的静默数据不一致,比报错更糟。 for (const [colName, colDef] of Object.entries(refSchema.columns)) { if (!colDef.references || !colDef.onDelete) continue; const [refTable] = colDef.references.split('.'); if (refTable !== tableName) continue; const refTableData = this.tables.get(refTableName); if (!refTableData) continue; // 查找所有引用此主键的行。 // // v0.8.0(A13):**先完整收集再处理**。自引用场景下 `refTableData` // 与当前遍历的表是同一个 Map,边遍历边删除会跳过条目(Map 迭代器 // 对已删除键的行为取决于删除位置)。收集成数组后处理即与迭代解耦。 const referrers = []; for (const [refPk, refRow] of refTableData) { if (String(refRow[colName]) === pkValue) referrers.push(refPk); } // RESTRICT: 存在引用行时禁止删除 if (colDef.onDelete === 'RESTRICT' && referrers.length > 0) { throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION'); } if (colDef.onDelete === 'CASCADE') { // 递归级联 for (const refPk of referrers) { const refRow = refTableData.get(refPk); if (refRow) { // v0.3.3: 级联删除前清理索引条目 this.removeIndexEntries(refTableName, refRow, refPk); // v0.8.0(A13):自引用时**先递归再删自己** —— 子树必须先被清掉, // 否则删掉父行后子行的 parent_id 就再也匹配不上(悬挂)。 totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited); } if (refTableData.has(refPk)) { refTableData.delete(refPk); totalCascade++; } } } else if (colDef.onDelete === 'SET NULL') { for (const refPk of referrers) { const refRow = refTableData.get(refPk); if (refRow) { // v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动 // `pks.get(v)?.delete(pk)` 后遗留空 Set → checkUniqueness 对旧值 // 永久误报 UNIQUE_VIOLATION(外键列带 unique 约束时) this.removeIndexEntries(refTableName, refRow, refPk); refRow[colName] = null; this.updateIndexes(refTableName, refRow, refPk); } } } } } return totalCascade; } } /** 崩溃残留临时文件后缀(createWritable 底层实现可能留下) */ const STALE_SUFFIXES = ['.crswap', '.tmp']; class OPFSBackend { constructor() { this.root = null; this.dbDir = null; this.dbName = ''; this.writeQueue = Promise.resolve(); } async open(name) { this.dbName = name; // v0.8.0(review 修复):OPFS 不可用时给出**可操作的**错误,而不是让上层 // 只能报一句 "Failed to open AriaEngine database"。 // // 真实场景(实测):用 file:// 直接打开页面时 Chromium 认为本地文件"不适合 // Web 应用访问",`navigator.storage.getDirectory()` 抛 SecurityError —— 而 // `isSecureContext` 仍是 true、API 也**存在**,所以只有真正调用才会发现。 // 修复前这个 SecurityError 被 `ARIA_OPEN_ERROR` 吞成一句无从下手的消息 //(cause 没往上带),用户不知道是"环境不支持"还是"库坏了"。 if (typeof navigator === 'undefined' || !navigator.storage || typeof navigator.storage.getDirectory !== 'function') { throw new DatabaseError('OPFS is not available in this environment: navigator.storage.getDirectory is missing. ' + "Use the http(s) protocol (file:// is rejected by browsers) or switch to mode: 'memory' / a KVStore backend.", 'ARIA_OPFS_UNAVAILABLE'); } try { this.root = await navigator.storage.getDirectory(); } catch (error) { throw new DatabaseError(`OPFS is not accessible here (${error.name}: ${error.message}). ` + 'Chromium blocks OPFS for file:// pages — serve the page over http(s) ' + "(e.g. `npx serve .` / `node tests/e2e/server.cjs`) or use mode: 'memory'.", 'ARIA_OPFS_UNAVAILABLE', error); } this.dbDir = await this.root.getDirectoryHandle(name, { create: true }); // v0.4.5: 清理崩溃残留临时文件(不阻塞打开) await this.cleanupStaleFiles(); } async close() { // v0.4.5-fix: 等待所有挂起写完成(否则 close 后挂起写被静默丢弃/读旧数据) try { await this.writeQueue; } catch { /* 写失败已返回给调用方 */ } this.dbDir = null; this.root = null; } isOpen() { return this.dbDir !== null; } /** 清理崩溃残留的临时文件(open 时自动调用,repair 也可调用) */ async cleanupStaleFiles() { if (!this.dbDir) return; try { const dir = this.dbDir; const stale = []; for await (const [name] of dir.entries()) { if (STALE_SUFFIXES.some((s) => name.endsWith(s))) { stale.push(name); } } for (const name of stale) { try { await this.dbDir.removeEntry(name); } catch { /* ignore */ } } } catch { /* 清理失败不阻塞 */ } } async read(key) { if (!this.dbDir) return null; try { const fh = await this.dbDir.getFileHandle(key); const file = await fh.getFile(); return await file.arrayBuffer(); } catch { return null; } } /** * 单文件原子写:createWritable 为 copy-on-write,close 后原子替换; * 写入期间崩溃 → 旧文件保持(原子性由浏览器 OPFS 实现保证)。 */ async write(key, data) { if (!this.dbDir) return; const run = this.writeQueue.then(async () => { const fh = await this.dbDir.getFileHandle(key, { create: true }); const writable = await fh.createWritable(); await writable.write(data); await writable.close(); }); // v0.4.5-fix: 单次任务失败不中断队列链(错误仍返回给本次调用方) this.writeQueue = run.then(() => undefined, () => undefined); return run; } /** * v0.4.5: 真追加写 — createWritable(keepExistingData) + seek 到文件末尾。 * 单文件 COW 原子(close 前崩溃旧文件保持),无需读旧内容即实现 O(chunk) 追加 * (WAL 分片高频写入用)。 */ async append(key, data) { if (!this.dbDir) return; const run = this.writeQueue.then(async () => { const fh = await this.dbDir.getFileHandle(key, { create: true }); const existing = await fh.getFile(); const writable = await fh.createWritable({ keepExistingData: true }); await writable.write({ type: 'write', position: existing.size, data }); await writable.close(); }); this.writeQueue = run.then(() => undefined, () => undefined); return run; } /** v0.4.2-fix: 批量写入 — 串行队列内逐个落盘(OPFS 无跨文件事务,顺序保证一致) */ async writeMany(entries) { if (!this.dbDir) return; const run = this.writeQueue.then(async () => { for (const [key, data] of Object.entries(entries)) { const fh = await this.dbDir.getFileHandle(key, { create: true }); const writable = await fh.createWritable(); await writable.write(data); await writable.close(); } }); this.writeQueue = run.then(() => undefined, () => undefined); return run; } async delete(key) { if (!this.dbDir) return; const run = this.writeQueue.then(async () => { try { await this.dbDir.removeEntry(key); } catch { /* ignore */ } }); this.writeQueue = run.then(() => undefined, () => undefined); return run; } /** v0.4.2-fix: 批量删除 — 串行队列内逐个删除 */ async deleteMany(keys) { if (!this.dbDir) return; const run = this.writeQueue.then(async () => { for (const key of keys) { try { await this.dbDir.removeEntry(key); } catch { /* ignore */ } } }); this.writeQueue = run.then(() => undefined, () => undefined); return run; } async listKeys() { if (!this.dbDir) return []; const keys = []; // FileSystemDirectoryHandle.entries() 返回 AsyncIterable,使用 any 绕过 dts 生成限制 const dir = this.dbDir; for await (const [name] of dir.entries()) { keys.push(name); } return keys; } async exists(key) { if (!this.dbDir) return false; try { await this.dbDir.getFileHandle(key); return true; } catch { return false; } } async clear() { if (!this.dbDir) return; const dir = this.dbDir; for await (const [name] of dir.entries()) { try { await this.dbDir.removeEntry(name); } catch { /* ignore */ } } } } /** 全局注册表:dbName(+ 库 id)→ 共享存储(跨实例共享,模拟持久化) */ const registry = new Map(); /** * 库 id 计数器:用于 `clearRegistry()` 后让"同名但属于新会话"的库拿到 * **新的** DbStore,而不是复用上一会话残留的对象引用。 */ let registryEpoch = 0; /** 当前会话里 dbName → 本次会话的 DbStore(换代后失效) */ let liveStores = new Map(); function storeFor(name) { const live = liveStores.get(name); if (live && live.epoch === registryEpoch) return live.store; const existing = registry.get(name); if (existing) { // 同一会话内复用(多个 backend 实例共享同一份字节) liveStores.set(name, { epoch: registryEpoch, store: existing }); return existing; } const created = { chunks: new Map(), materialized: new Map() }; registry.set(name, created); liveStores.set(name, { epoch: registryEpoch, store: created }); return created; } class SharedMemoryBackend { constructor() { this.dbName = ''; this.store = null; } /** * 清空全局注册表(测试隔离用)。 * * 同时让"当前会话"失效:后续 open 会为同名库创建全新的存储 —— * 否则 `clearRegistry()` 之后新建的实例可能仍指向上一用例的 DbStore *(注册表被清空但对象引用还活在旧实例里),出现跨用例数据泄漏。 */ static clearRegistry() { registry.clear(); registryEpoch += 1; liveStores = new Map(); } async open(name) { this.dbName = name; this.store = storeFor(name); } /** close 不清除数据(持久化语义:重开同名库数据仍在) */ async close() { this.store = null; } isOpen() { return this.dbName !== '' && this.store !== null; } /** * 取当前共享存储。 * * v0.8.0:`close()` 之后**不再**惰性重新绑定到共享存储 —— 那会让 * "close 后读不到数据"变成"close 后又读到了"(实测该行为被 * `tests/production-abnormal.test.ts` 的 close 契约用例逮到)。 * * 正确语义(与既有契约一致,见该用例注释): * - close 后读 → `null`(本实例已不持有数据); * - close 后写/删/清空 → **不抛错**,但作用在游离内存上、**不持久化** * - 跨实例持久化语义不受影响:另一个实例 open 同名库仍能读到 close 前的数据。 */ db() { if (!this.store) { // 游离存储:仅供 close 后的调用"安全落地",不进入注册表 this.store = { chunks: new Map(), materialized: new Map() }; } return this.store; } async read(key) { const db = this.db(); if (db.materialized.has(key)) return db.materialized.get(key) ?? null; const list = db.chunks.get(key); if (!list || list.length === 0) { db.materialized.set(key, null); return null; } if (list.length === 1) { db.materialized.set(key, list[0]); return list[0]; } const total = list.reduce((sum, c) => sum + c.byteLength, 0); const combined = new Uint8Array(total); let off = 0; for (const c of list) { combined.set(new Uint8Array(c), off); off += c.byteLength; } const buf = combined.buffer; db.materialized.set(key, buf); return buf; } async write(key, data) { const db = this.db(); db.chunks.set(key, [data]); db.materialized.set(key, data); } async append(key, data) { // O(1) 追加:只记录 chunk,read 时惰性拼接。 // 缓存按库共享,因此这里清掉的缓存对所有实例都生效 —— 这正是 // "另一个实例的写入必须对自己可见"这一语义的实现点。 const db = this.db(); const list = db.chunks.get(key); if (list) list.push(data); else db.chunks.set(key, [data]); db.materialized.delete(key); } async writeMany(entries) { // 同步批量写入 = 原子(JS 单线程,无中间 await 点) const db = this.db(); for (const [key, data] of Object.entries(entries)) { db.chunks.set(key, [data]); db.materialized.set(key, data); } } async delete(key) { const db = this.db(); db.chunks.delete(key); db.materialized.set(key, null); } async deleteMany(keys) { const db = this.db(); for (const key of keys) { db.chunks.delete(key); db.materialized.set(key, null); } } async listKeys() { return Array.from(this.db().chunks.keys()); } async exists(key) { return this.db().chunks.has(key); } async clear() { const db = this.db(); db.chunks.clear(); db.materialized.clear(); } } /** * AriaEngine CRC32 — 标准 CRC-32(IEEE 802.3,多项式 0xEDB88320) * @module engine/aria/crc32 * * 查表法实现。 * 分段计算约定: * crc32(head + tail) === crc32Finalize(crc32Update(crc32Update(0xFFFFFFFF, head), tail)) * === crc32(tail, crc32(head)) * 用于 SSTable 文件校验和与 WAL 记录完整性校验。 */ /** CRC-32 查找表(0xEDB88320 反射多项式) */ const CRC32_TABLE = (() => { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let c = i; for (let k = 0; k < 8; k++) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } table[i] = c >>> 0; } return table; })(); /** CRC-32 初始累加器状态 */ const CRC32_INIT = 0xffffffff; /** * 更新内部 CRC 累加状态(供分段计算使用,接收中间状态返回中间状态)。 */ function crc32Update(state, data) { let crc = state >>> 0; for (let i = 0; i < data.byteLength; i++) { crc = (CRC32_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8)) >>> 0; } return crc >>> 0; } /** 将中间状态转换为最终校验和(终止计算) */ function crc32Finalize(state) { return (state ^ CRC32_INIT) >>> 0; } /** * 计算标准 CRC-32 校验和。 * @param data 输入字节 * @param seed 前序片段计算的最终校验和(首段传 0 或不传) * @returns 32 位无符号校验和 */ function crc32(data, seed = 0) { const state = crc32Update(seed ^ CRC32_INIT, data); return crc32Finalize(state); } /** * KVStore Log — 追加式事务日志编解码 * @module engine/kvstore/log * * v0.6.0: 自研 KV 引擎的原子写载体。 * 每条日志记录 = 一个原子事务(putMany 多键写入 / deleteMany 多键删除)。 * 单文件追加(介质 append,COW 原子)→ 崩溃时记录全有或全无。 * * 记录格式(大端序): * [recordLen u32] — 本条记录长度(含自身,不含 CRC) * [seq u32] — 日志序号(递增,恢复时与快照水位比对去重) * [entryCount u32] — 条目数 * 每条 entry: * [op u8] — 1=PUT, 2=DELETE * [keyLen u32][key bytes] * [valueLen u32][value bytes] (DELETE 时 valueLen=0) * [crc u32] — 覆盖本条记录除 CRC 外全部字节的标准 CRC-32 */ /** 日志操作类型 */ var KVLogOp; (function (KVLogOp) { KVLogOp[KVLogOp["PUT"] = 1] = "PUT"; KVLogOp[KVLogOp["DELETE"] = 2] = "DELETE"; /** v0.6.1: 追加写入(value 拼接语义,恢复时按序 concat;aria WAL 分片用) */ KVLogOp[KVLogOp["APPEND"] = 3] = "APPEND"; })(KVLogOp || (KVLogOp = {})); /** * 编码一条日志记录。 * @param seq 日志序号 * @param puts key → value 写入条目 * @param deletes 删除 key 列表 * @param appends key → 追加块列表(APPEND 语义,恢复时拼接) */ function encodeLogRecord(seq, puts, deletes = [], appends = {}) { const encoder = new TextEncoder(); const entries = []; for (const [key, value] of Object.entries(puts)) { entries.push({ op: KVLogOp.PUT, key, value }); } for (const key of deletes) { entries.push({ op: KVLogOp.DELETE, key, value: new ArrayBuffer(0) }); } for (const [key, value] of Object.entries(appends)) { entries.push({ op: KVLogOp.APPEND, key, value }); } // 预编码 key 字节,计算总长度 const entryBytes = []; let total = 4 + 4 + 4; // recordLen + seq + entryCount for (const e of entries) { const kb = encoder.encode(e.key); const vb = new Uint8Array(e.value); entryBytes.push({ op: e.op, key: kb, value: vb }); total += 1 + 4 + kb.byteLength + 4 + vb.byteLength; } total += 4; // crc const buf = new Uint8Array(total); const view = new DataView(buf.buffer); let offset = 0; view.setUint32(offset, total - 4, false); offset += 4; // recordLen(不含 CRC) view.setUint32(offset, seq, false); offset += 4; view.setUint32(offset, entryBytes.length, false); offset += 4; for (const e of entryBytes) { view.setUint8(offset, e.op); offset += 1; view.setUint32(offset, e.key.byteLength, false); offset += 4; buf.set(e.key, offset); offset += e.key.byteLength; view.setUint32(offset, e.value.byteLength, false); offset += 4; buf.set(e.value, offset); offset += e.value.byteLength; } const crc = crc32(buf.subarray(0, total - 4)); view.setUint32(total - 4, crc, false); return buf; } /** * 解析日志中的全部记录(顺序扫描)。 * @param data 日志字节流 * @param onRecord 每条有效记录回调(CRC 通过) * @param onCorrupt 损坏记录位置回调(返回 false 停止扫描,或继续尝试下一条) * @returns 有效记录数 */ function parseLogRecords(data, onRecord, onCorrupt) { let offset = 0; let count = 0; const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const decoder = new TextDecoder(); while (offset + 4 <= data.byteLength) { const recordLen = view.getUint32(offset, false); if (recordLen < 12 || offset + 4 + recordLen > data.byteLength) { // 尾部残缺记录(最后一批写入被截断):损坏 if (onCorrupt) { if (!onCorrupt(offset)) break; } break; } const recordStart = offset; const recordEnd = offset + 4 + recordLen; const raw = data.subarray(recordStart, recordEnd); const recView = new DataView(data.buffer, data.byteOffset + recordStart, recordLen + 4); const storedCrc = recView.getUint32(recordLen, false); const computedCrc = crc32(raw.subarray(0, recordLen)); if (storedCrc !== computedCrc) { if (onCorrupt) { if (!onCorrupt(recordStart)) break; } break; } // 解析条目 let p = 4; const seq = recView.getUint32(p, false); p += 4; const entryCount = recView.getUint32(p, false); p += 4; const entries = []; let valid = true; for (let i = 0; i < entryCount; i++) { if (p + 1 + 4 > recordLen + 4) { valid = false; break; } const op = recView.getUint8(p); p += 1; const keyLen = recView.getUint32(p, false); p += 4; if (p + keyLen + 4 > recordLen + 4) { valid = false; break; } const key = decoder.decode(raw.subarray(p, p + keyLen)); p += keyLen; const valueLen = recView.getUint32(p, false); p += 4; if (p + valueLen > recordLen + 4) { valid = false; break; } const value = raw.slice(p, p + valueLen).buffer; p += valueLen; entries.push({ op, key, value }); } if (!valid) { if (onCorrupt) { if (!onCorrupt(recordStart)) break; } break; } onRecord({ seq, entries, raw }); count++; offset = recordEnd; } return count; } /** * KVStore Snapshot — 快照序列化/反序列化 * @module engine/kvstore/snapshot * * v0.6.0: checkpoint 时把全部 key-value 序列化为快照文件(COW 原子写), * 快照内嵌"日志水位 seq"(快照包含的最后一条日志序号),恢复时只重放 seq > 水位 的记录。 * * 格式(大端序): * [magic u32] — 0x4B56534E ("KVSN") * [seq u32] — 日志水位(快照包含的数据对应的日志序号) * [entryCount u32] * 每条: [keyLen u32][key bytes][valueLen u32][value bytes] * [crc u32] — 覆盖除 CRC 外全部字节的标准 CRC-32 */ const SNAPSHOT_MAGIC = 0x4b56534e; // "KVSN" /** 序列化快照 */ function encodeSnapshot(seq, entries) { const encoder = new TextEncoder(); const keys = Array.from(entries.keys()); // 预编码 const encoded = []; let total = 4 + 4 + 4; // magic + seq + entryCount for (const key of keys) { const kb = encoder.encode(key); const vb = new Uint8Array(entries.get(key)); encoded.push({ key: kb, value: vb }); total += 4 + kb.byteLength + 4 + vb.byteLength; } total += 4; // crc const buf = new Uint8Array(total); const view = new DataView(buf.buffer); let offset = 0; view.setUint32(offset, SNAPSHOT_MAGIC, false); offset += 4; view.setUint32(offset, seq, false); offset += 4; view.setUint32(offset, encoded.length, false); offset += 4; for (const e of encoded) { view.setUint32(offset, e.key.byteLength, false); offset += 4; buf.set(e.key, offset); offset += e.key.byteLength; view.setUint32(offset, e.value.byteLength, false); offset += 4; buf.set(e.value, offset); offset += e.value.byteLength; } const crc = crc32(buf.subarray(0, total - 4)); view.setUint32(total - 4, crc, false); return buf; } /** * 解析快照。 * @returns 快照内容;损坏(magic 错误/CRC 失败/越界)返回 null */ function decodeSnapshot(data) { if (data.byteLength < 16) return null; const view = new DataView(data.buffer, data.byteOffset, data.byteLength); if (view.getUint32(0, false) !== SNAPSHOT_MAGIC) return null; const storedCrc = view.getUint32(data.byteLength - 4, false); const computedCrc = crc32(data.subarray(0, data.byteLength - 4)); if (storedCrc !== computedCrc) return null; const decoder = new TextDecoder(); const entries = new Map(); let p = 4; const seq = view.getUint32(p, false); p += 4; const entryCount = view.getUint32(p, false); p += 4; for (let i = 0; i < entryCount; i++) { if (p + 4 > data.byteLength - 4) return null; const keyLen = view.getUint32(p, false); p += 4; if (p + keyLen + 4 > data.byteLength - 4) return null; const key = decoder.decode(data.subarray(p, p + keyLen)); p += keyLen; const valueLen = view.getUint32(p, false); p += 4; if (p + valueLen > data.byteLength - 4) return null; const value = data.slice(p, p + valueLen).buffer; p += valueLen; entries.set(key, value); } return { seq, entries }; } /** * KVStore — 自研 KV 事务存储引擎(替代 IndexedDB) * @module engine/kvstore/index * * v0.6.0: 在浏览器文件系统(OPFS)之上实现 IndexedDB 级能力: * - 多 key 原子事务:putMany/deleteMany 写入单条日志记录(单文件 COW 原子追加)→ * 崩溃时记录全有或全无(IndexedDB 事务同等的原子性,但完全自研) * - 持久化与崩溃恢复:快照(checkpoint)+ 追加日志(WAL 式),两阶段恢复 * - 自愈:快照损坏回退全量日志重放;日志损坏截断至损坏处(丢弃未确认尾部) * - 容错时序:checkpoint = 写快照 → 写 meta → 清空日志(meta 先于截断, * 任何崩溃窗口数据不丢) * * 介质层为 IStorageBackend(OPFSBackend / SharedMemoryBackend): * - 浏览器:自动选择 OPFS(navigator.storage) * - Node/测试:SharedMemoryBackend(跨实例共享,模拟持久化) * * 可靠性设计: * - 所有写操作与 checkpoint 经内部串行队列(快照与日志水位一致,无交错窗口) * - 日志记录与快照均有标准 CRC-32 校验 * - 内存索引为热路径(get O(1)),checkpoint 后日志截断 */ /** 存储键 */ const LOG_KEY = '__kv_log'; const SNAPSHOT_KEY = '__kv_snapshot'; const META_KEY = '__kv_meta'; /** checkpoint 自动触发阈值(日志字节数,0=不自动) */ const DEFAULT_CHECKPOINT_THRESHOLD = 16 * 1024 * 1024; function defaultMedium() { const nav = globalThis.navigator; if (typeof nav !== 'undefined' && nav.storage && typeof nav.storage.getDirectory === 'function') { return new OPFSBackend(); } return new SharedMemoryBackend(); } class KVStore { constructor(medium, checkpointThreshold = DEFAULT_CHECKPOINT_THRESHOLD) { this.dbName = ''; this.opened = false; /** 内存索引(热路径权威视图) */ this.index = new Map(); /** 日志水位(最后一条已应用日志记录序号) */ this.seq = 0; /** 日志累计字节数(checkpoint 阈值) */ this.logBytes = 0; /** 写操作串行队列(checkpoint 与写入无交错窗口) */ this.opQueue = Promise.resolve(); /** 最近一次后台操作失败(checkpoint 时报告) */ this.lastBackgroundError = null; /** * v0.8.0(B-6):本实例的提交所有权标识。 * * 每次 `open()` 领取一个新的随机 id 并写入 meta(见 `claimOwnership`)。 * checkpoint 前比对 `meta.owner`:不等于自己 → 介质已被更新的实例接管, * 本实例的索引可能落后,**不得**再提交快照(否则会静默抹掉对方的写入)。 */ this.instanceId = ''; /** 是否已被更新的实例接管(进入该状态后所有写入与提交都被拒绝) */ this.stale = false; this.medium = medium ?? defaultMedium(); this.checkpointThreshold = checkpointThreshold; } isOpen() { return this.opened; } // ======================================================================= // 生命周期 // ======================================================================= /** 打开(加载快照 + 重放日志) */ async open(dbName) { if (this.opened) return; this.dbName = dbName; // v0.7.4: 打开时同样清理后台错误状态(防 close/reopen 残留) this.lastBackgroundError = null; this.stale = false; await this.medium.open(dbName); // v0.8.0(B-6):领取提交所有权(写回 meta.owner)。 // 打开是唯一"接管"介质的时机;此前的实例之后会在 checkpoint 时发现 // owner 已变而拒绝提交,从而不会用陈旧索引覆盖本实例的数据。 await this.claimOwnership(); this.index = new Map(); this.seq = 0; this.logBytes = 0; // 1. 加载快照(损坏则全量日志重放;快照内嵌 seq 为日志水位权威) // meta(checkpoint 写入)仅标记库存在,不参与水位判断 let snapshotSeq = 0; const snapshotRaw = await this.medium.read(SNAPSHOT_KEY); if (snapshotRaw) { const snap = decodeSnapshot(new Uint8Array(snapshotRaw)); if (snap) { this.index = new Map(snap.entries); this.seq = snap.seq; snapshotSeq = snap.seq; } else { // 快照损坏:从空索引 + 全量日志重放 this.index = new Map(); this.seq = 0; snapshotSeq = 0; } } // 3. 重放日志(seq > 快照水位的记录) // v0.6.1-fix: 快照水位只信任快照内嵌 seq(snapshotSeq)。 // 此前 baseSeq = max(metaSeq, snapshotSeq):快照损坏回退全量重放时, // meta.seq(最后一次 checkpoint 水位)会错误跳过日志中 checkpoint 后 // 的有效记录(日志被截断过,metaSeq 不代表日志内容水位)→ 丢数据。 // meta 仅在库存在性标记,不参与水位判断。 const logRaw = await this.medium.read(LOG_KEY); if (logRaw && logRaw.byteLength > 0) { const log = new Uint8Array(logRaw); const baseSeq = snapshotSeq; const corruptOffsets = []; const applied = parseLogRecords(log, (record) => { if (record.seq <= baseSeq) return; // 快照已包含,跳过(幂等) this.applyRecord(record.entries); this.seq = record.seq; }, (offset) => { corruptOffsets.push(offset); return true; // 记录损坏位置后停止(日志是顺序流,无法跳过继续) }); if (applied > 0 || corruptOffsets.length > 0) { this.logBytes = log.byteLength; } if (corruptOffsets.length > 0) { // v0.8.0(B-6)根治:损坏尾部只能**截断到最后一条有效记录**, // 绝不能清空整个日志。 // // 修复前这里调用 `truncateLog()`(写空文件),于是"尾部一个字节损坏" // 导致**全部已确认写入消失**: // 写 3 条记录 → 第 4 条只写了一半(崩溃)→ 重开 // → 前 3 条被上面的重放读到内存,随后被 truncateLog() 从介质上抹掉 // → 再重开一次,数据全部为空。 // 这是本项目最严重的一类缺陷:把"损坏尾部"放大成"整库丢失"。 // 正确做法(与 repair() 一致):保留 [0, validBytes) 前缀。 await this.truncateLogTo(this.findValidLogLength(log)); } } this.opened = true; } /** * v0.6.0: 从介质重新加载(多标签页同步/外部写入可见用)。 * KVStore 的内存索引非跨实例共享,重新 open 读取介质最新数据。 */ async reload() { if (!this.opened) return; try { await this.opQueue; } catch { /* ignore */ } this.index = new Map(); this.seq = 0; this.logBytes = 0; this.opened = false; await this.open(this.dbName); } /** 关闭(不丢弃数据;下次 open 同名库恢复) */ async close() { if (!this.opened) return; // 排空写队列 try { await this.opQueue; } catch { /* 写失败已返回 */ } await this.medium.close(); this.index.clear(); this.seq = 0; this.logBytes = 0; // v0.7.4: 清理后台错误状态 —— 此前跨 close/reopen 残留, // 重开后首次 checkpoint 会抛出上一次生命周期的旧错误 this.lastBackgroundError = null; this.instanceId = ''; this.stale = false; this.opened = false; } // ======================================================================= // 读写(内存热路径) // ======================================================================= async get(key) { return this.index.get(key) ?? null; } async getAll() { return Array.from(this.index.entries()); } async listKeys() { return Array.from(this.index.keys()); } async exists(key) { return this.index.has(key); } size() { return this.index.size; } // ======================================================================= // 写入(原子事务) // ======================================================================= /** 单 key 写入(原子) */ async put(key, value) { await this.enqueue(async () => { await this.appendRecord({ [key]: value }, []); }); } /** 多 key 原子写入(单条日志记录,崩溃全有或全无) */ async putMany(entries) { if (Object.keys(entries).length === 0) return; await this.enqueue(async () => { await this.appendRecord(entries, []); }); } /** 单 key 删除(原子) */ async delete(key) { await this.enqueue(async () => { await this.appendRecord({}, [key]); }); } /** 多 key 原子删除(单条日志记录) */ async deleteMany(keys) { if (keys.length === 0) return; await this.enqueue(async () => { await this.appendRecord({}, keys); }); } /** * v0.6.3: 多 key 混合原子写(put + delete 编码进同一条日志记录)。 * 此前 KVStoreEngine 的 delete/update 主键变更等路径 putMany 与 deleteMany * 分两次调用 = 两条记录:崩溃在两条记录之间 → 新旧行并存(重复行/脏数据), * 与"一条日志记录 = 真原子"宣称不符。此方法保证混合操作全有或全无。 */ async writeBatch(puts, deletes) { if (Object.keys(puts).length === 0 && deletes.length === 0) return; await this.enqueue(async () => { await this.appendRecord(puts, deletes); }); } /** * v0.6.1: 追加写入(value 拼接语义)— aria WAL 分片等追加型数据用。 * 日志记录 APPEND 类型(O(chunk) 高效),恢复时按 seq 顺序拼接, * checkpoint 后快照含最终值。崩溃时该次追加全有或全无(单记录原子)。 */ async appendValue(key, chunk) { if (chunk.byteLength === 0) return; await this.enqueue(async () => { await this.appendRecord({}, [], { [key]: chunk }); }); } // ======================================================================= // 维护 // ======================================================================= /** checkpoint:快照 → meta → 截断日志(时序保证任何崩溃窗口不丢数据) */ async checkpoint() { await this.enqueue(async () => { // v0.8.0(B-6):提交前确认所有权 —— 拒绝用陈旧索引覆盖介质 await this.assertOwnership(); // 报告上次后台失败 if (this.lastBackgroundError !== null) { const error = this.lastBackgroundError; this.lastBackgroundError = null; throw new DatabaseError('KVStore background write failed', 'KV_BACKGROUND_ERROR', error); } if (this.logBytes === 0 && this.index.size === 0) return; // 1. 写快照(COW 原子) const snapBytes = encodeSnapshot(this.seq, this.index); await this.medium.write(SNAPSHOT_KEY, snapBytes.buffer); // 2. 写 meta(指向新水位 + 续期所有权) await this.writeMeta(); // 3. 截断日志(meta 已更新 → 截断安全) await this.truncateLog(); }); } /** 清空全部数据(保留库本身) */ async clear() { await this.enqueue(async () => { await this.medium.clear(); this.index.clear(); this.seq = 0; this.logBytes = 0; // 写空 meta(下次 open 正常初始化) const meta = { seq: 0 }; await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer); }); } /** * 自愈:校验快照/日志完整性,清理损坏数据。 * @returns 丢弃的损坏日志字节数(0 = 无损坏) */ async repair() { return this.enqueue(async () => { let discarded = 0; // 1. 校验快照:损坏则删除(下次打开全量日志重放) const snapRaw = await this.medium.read(SNAPSHOT_KEY); if (snapRaw && !decodeSnapshot(new Uint8Array(snapRaw))) { await this.medium.delete(SNAPSHOT_KEY); discarded++; } // 2. 校验日志:损坏尾部截断 const logRaw = await this.medium.read(LOG_KEY); if (logRaw && logRaw.byteLength > 0) { const log = new Uint8Array(logRaw); const validBytes = this.findValidLogLength(log); if (validBytes < log.byteLength) { discarded += log.byteLength - validBytes; const truncated = log.subarray(0, validBytes).slice().buffer; await this.medium.write(LOG_KEY, truncated); } } return discarded; }); } // ======================================================================= // 内部 // ======================================================================= enqueue(fn) { const run = this.opQueue.then(fn, fn); this.opQueue = run.then(() => undefined, () => undefined); return run; } /** 追加一条日志记录并更新内存索引(队列内调用,无并发) */ async appendRecord(puts, deletes, appends = {}) { // v0.8.0(B-6):已被接管 → 拒绝写入(否则写入只会进 WAL 而永远无法提交, // 或者在下一次 checkpoint 时把对方的提交覆盖掉) if (this.stale) { throw new DatabaseError('KVStore instance has been superseded by another instance on the same database;' + ' re-open the database to continue', 'STALE_INSTANCE'); } this.seq++; const record = encodeLogRecord(this.seq, puts, deletes, appends); try { // 日志追加:介质 append(真追加)或回退读+拼+写 const data = record.buffer.slice(record.byteOffset, record.byteOffset + record.byteLength); if (typeof this.medium.append === 'function') { await this.medium.append(LOG_KEY, data); } else { const existing = await this.medium.read(LOG_KEY); if (existing) { const combined = new Uint8Array(existing.byteLength + data.byteLength); combined.set(new Uint8Array(existing), 0); combined.set(new Uint8Array(data), existing.byteLength); await this.medium.write(LOG_KEY, combined.buffer); } else { await this.medium.write(LOG_KEY, data); } } } catch (error) { // 记录写入失败:内存索引不更新(原子性),记录后台错误 this.seq--; // 回滚水位 this.lastBackgroundError = error; throw new DatabaseError('KVStore log append failed', 'KV_LOG_ERROR', error); } // 日志成功:更新内存索引(原子语义) for (const [key, value] of Object.entries(puts)) { this.index.set(key, value); } for (const key of deletes) { this.index.delete(key); } for (const [key, chunk] of Object.entries(appends)) { const existing = this.index.get(key); if (existing) { const combined = new Uint8Array(existing.byteLength + chunk.byteLength); combined.set(new Uint8Array(existing), 0); combined.set(new Uint8Array(chunk), existing.byteLength); this.index.set(key, combined.buffer); } else { this.index.set(key, chunk); } } this.logBytes += record.byteLength; // 自动 checkpoint(日志超阈值) if (this.checkpointThreshold > 0 && this.logBytes >= this.checkpointThreshold) { await this.autoCheckpoint(); } } /** * v0.8.0(B-6)根治:自动 checkpoint 的失败语义。 * * 此前的顺序是"WAL 追加成功 → 更新内存索引 → 自动 checkpoint",而自动 * checkpoint **没有 try/catch**:快照/元数据写失败会把异常抛出 `appendRecord`, * 一路冒泡到调用方 —— 但那条写入**已经持久化在 WAL 里了**(WAL 是权威来源, * 崩溃后一定能重放出来)。于是用户看到 `put()` 失败、以为数据没进去, * 实际数据已经落盘 —— 报错与事实相反,属于最有害的一类不一致 * (调用方据此重试会写入两次,或据"失败"丢弃业务状态)。 * * 现在的语义(与 PLAN-v0.7.5.md 的 B-6 止血方案一致): * - 已确认的写入**不得**因为后台失败而报错; * - 失败被记录为 `lastBackgroundError`,由**下一次** `checkpoint()` * 显式报告(那是用户主动要求把数据压实到快照的时机,此时失败是真实问题); * - 内存索引与 WAL 仍然一致(两者都已包含这条写入),不产生半状态。 */ async autoCheckpoint() { try { await this.assertOwnership(); await this.medium.write(SNAPSHOT_KEY, encodeSnapshot(this.seq, this.index).buffer); await this.writeMeta(); await this.truncateLog(); } catch (error) { // 陈旧实例的自动 checkpoint 失败**不**计入 lastBackgroundError: // 那会把它伪装成"介质故障",而真实原因是本实例已被接管,用户需要的是 // 立即的、明确的 STALE_INSTANCE 错误(由 assertOwnership 在显式路径给出)。 if (!this.stale) this.lastBackgroundError = error; } } /** 截断日志(清空文件)—— 仅用于 checkpoint 之后:快照已覆盖全部数据 */ async truncateLog() { try { await this.medium.write(LOG_KEY, new ArrayBuffer(0)); } catch { /* 截断失败:下次 checkpoint 重试 */ } this.logBytes = 0; } /** * v0.8.0(B-6):把日志截断到 `keepBytes` 长度(保留有效前缀)。 * * 与 `truncateLog()` 的区别:checkpoint 后日志内容已被快照覆盖,可以清空; * 而崩溃恢复时日志里**前面的记录是唯一的数据来源**(快照可能落后很多个 * checkpoint),只能丢弃损坏的尾部。两者语义完全不同,因此是两个方法。 */ async truncateLogTo(keepBytes) { try { const raw = await this.medium.read(LOG_KEY); if (!raw) return; if (keepBytes >= raw.byteLength) return; // 无需截断(损坏判定与读取之间无变化) const kept = new Uint8Array(raw).subarray(0, keepBytes).slice(); await this.medium.write(LOG_KEY, kept.buffer); this.logBytes = keepBytes; } catch { // 截断失败不影响本次恢复的内存状态:日志文件多出的损坏尾部会在 // 下次 open 时被同样识别并跳过(解析在损坏处停止),因此不会读到脏数据。 } } /** 应用记录条目到内存索引 */ applyRecord(entries) { for (const e of entries) { if (e.op === KVLogOp.PUT) { this.index.set(e.key, e.value); } else if (e.op === KVLogOp.APPEND) { const existing = this.index.get(e.key); if (existing) { const combined = new Uint8Array(existing.byteLength + e.value.byteLength); combined.set(new Uint8Array(existing), 0); combined.set(new Uint8Array(e.value), existing.byteLength); this.index.set(e.key, combined.buffer); } else { this.index.set(e.key, e.value); } } else { this.index.delete(e.key); } } } // ======================================================================= // 提交所有权(v0.8.0 / B-6) // ======================================================================= /** 生成实例 id(无需密码学强度,只要同期实例间不碰撞) */ newInstanceId() { const rand = Math.random().toString(36).slice(2); return `kv-${Date.now().toString(36)}-${rand}`; } /** 读取 meta(缺失或损坏时返回 null) */ async readMeta() { try { const raw = await this.medium.read(META_KEY); if (!raw || raw.byteLength === 0) return null; return JSON.parse(new TextDecoder().decode(raw)); } catch { return null; } } /** 写入 meta(水位 + 所有权续期) */ async writeMeta() { const meta = { seq: this.seq, owner: this.instanceId }; await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer); } /** open() 时领取所有权:写入本实例 id,使此前打开的实例在提交时发现自己已过期 */ async claimOwnership() { this.instanceId = this.newInstanceId(); await this.writeMeta(); } /** * 提交前校验所有权。 * * 只有当介质上的 owner 存在、且**不是**本实例时才算过期: * - owner 缺失(旧版本库 / meta 被清理)→ 视为无主,允许提交; * - owner === 自己 → 正常。 * 一旦判定过期,本实例进入 `stale` 状态:后续写入与提交都显式报错 —— * 显式失败远好于静默丢数据(这是本 wound 的全部意义)。 */ async assertOwnership() { const meta = await this.readMeta(); if (meta?.owner && this.instanceId && meta.owner !== this.instanceId) { this.stale = true; throw new DatabaseError('KVStore instance has been superseded by another instance on the same database' + ' (refusing to commit a stale snapshot, which would discard the newer instance\'s writes);' + ' re-open the database to continue', 'STALE_INSTANCE'); } } /** 本实例是否已被更新实例接管(诊断用) */ isStale() { return this.stale; } /** 确定日志中有效字节长度(从 0 开始连续解析到第一条损坏/残缺记录) */ findValidLogLength(log) { let offset = 0; const view = new DataView(log.buffer, log.byteOffset, log.byteLength); while (offset + 4 <= log.byteLength) { const recordLen = view.getUint32(offset, false); if (recordLen < 12 || offset + 4 + recordLen > log.byteLength) break; const raw = log.subarray(offset, offset + 4 + recordLen); const storedCrc = view.getUint32(offset + recordLen, false); if (crc32(raw.subarray(0, recordLen)) !== storedCrc) break; offset += 4 + recordLen; } return offset; } } /** * KVStoreEngine — 基于自研 KVStore 的磁盘存储引擎(替代 IndexedDBEngine / OPFSEngine) * @module engine/kvstore_engine * * v0.6.0: 完全移除 IndexedDB 后的 disk 模式引擎。 * * 架构:MemoryEngine(内存热路径 + 事务快照)+ KVStore(持久化 + 原子写) * - 读:始终走内存(写路径同步落盘,重启从 KVStore 恢复) * - 写:内存先行 + KVStore 增量持久化(insert 增量 putMany;update/delete 受影响行重写; * 主键变更/级联场景整表 diff;全部原子) * - 事务:内存快照 + commit 时受影响表原子 flush(writeBatch 单记录 = 真原子, * 此前 IndexedDBEngine 依赖 IDB 事务,现在完全自研) * * 数据布局(KVStore keys): * `__schema` — JSON { tableName: TableSchema } * `__meta:{key}` — 库内元数据(迁移版本等) * `t:{table}:{pk}` — 行数据(JSON) */ const SCHEMA_KEY = '__schema'; const ROW_PREFIX = 't:'; const enc = (s) => new TextEncoder().encode(s).buffer; const dec = (b) => new TextDecoder().decode(b); class KVStoreEngine { constructor(medium, checkpointThreshold) { this.name = 'kv'; this.memory = new MemoryEngine(); this.dbName = ''; this.version = 1; this.opened = false; /** 活跃事务标记 */ this.txActive = false; /** 事务中写过的表(commit 时只 flush 这些表) */ this.txDirtyTables = new Set(); /** 事务中发生 schema 变更(DDL)—— commit 时持久化 schema */ this.txSchemaChanged = false; /** * v0.7.0: 事务行级变更记录(table → pk → put/delete)。 * commit 时按行增量 flush(此前整表 diff:大表事务改 1 行也重写全表)。 */ this.txChanges = new Map(); /** v0.7.0: 无法行级追踪的表(主键变更/级联影响表)→ commit 时整表 diff */ this.txFullTables = new Set(); /** v0.7.0: 事务内 clear 的表 → commit 时清空 KV 行 */ this.txClearedTables = new Set(); this.kv = new KVStore(medium, checkpointThreshold); } // ---- 行 key 编解码 ---- rowKey(table, pk) { return `${ROW_PREFIX}${table}:${pk}`; } rowPrefix(table) { return `${ROW_PREFIX}${table}:`; } // ---- 生命周期 ---- async open(dbName, version) { if (this.opened) return; this.dbName = dbName; this.version = version; await this.kv.open(dbName); await this.memory.open(dbName, version); // 恢复 schema const schemaRaw = await this.kv.get(SCHEMA_KEY); if (schemaRaw) { try { const schemas = JSON.parse(dec(schemaRaw)); for (const schema of Object.values(schemas)) { await this.memory.createTable(schema); } } catch { throw new DatabaseError('Corrupted schema in KVStore', 'KV_SCHEMA_ERROR'); } } // 恢复行数据 + 重建索引 const all = await this.kv.getAll(); for (const [key, value] of all) { if (!key.startsWith(ROW_PREFIX)) continue; const sep = key.indexOf(':', ROW_PREFIX.length); if (sep < 0) continue; const table = key.slice(ROW_PREFIX.length, sep); if (!(await this.memory.hasTable(table))) continue; try { const row = JSON.parse(dec(value)); await this.memory.insert(table, [row]); } catch { // 单行损坏跳过(repair 可清理) } } // 重建二级索引(schema 标记的索引列) const tables = await this.memory.getTableNames(); for (const table of tables) { const schema = await this.memory.getTableSchema(table); if (!schema) continue; for (const [col, colDef] of Object.entries(schema.columns)) { if (colDef.index || colDef.unique) { await this.memory.createIndex(table, col, colDef.unique); } } } this.opened = true; } async close() { if (!this.opened) return; // 活跃事务先回滚 if (this.txActive) { try { await this.rollbackTransaction(); } catch { /* ignore */ } } // v0.7.0: 关闭前 checkpoint(截断日志,重开更快)。 // 失败不阻塞关闭(日志已持久,重开可全量重放)。 try { await this.kv.checkpoint(); } catch { /* 数据在日志中,重开放心重放 */ } await this.kv.close(); await this.memory.close(); this.opened = false; } isOpen() { return this.opened; } /** * v0.6.0: 从 KVStore 重新加载全部数据到内存(多标签页同步重载用)。 * Hybrid 引擎的 reloadMemoryFromDisk 依赖磁盘引擎"读穿透", * KVStoreEngine 读内存 → 提供 reload 重新加载磁盘最新数据。 */ async reload() { if (!this.opened) return; // 1. KVStore 重新从介质加载(外部写入可见) await this.kv.reload(); // 2. 内存缓存重载 await this.memory.close(); await this.memory.open(this.dbName, this.version); this.opened = false; await this.open(this.dbName, this.version); } /** v0.4.2-fix: 自愈 — 校验 KVStore 日志/快照完整性并重建内存 */ async repair() { this.ensureOpen(); await this.kv.repair(); await this.memory.close(); await this.memory.open(this.dbName, this.version); // 重新恢复(复用 open 的恢复逻辑) this.opened = false; await this.open(this.dbName, this.version); } async clearAll() { this.ensureOpen(); await this.kv.clear(); await this.memory.clearAll(); } async getMeta(key) { const raw = await this.kv.get(`__meta:${key}`); return raw ? dec(raw) : null; } async setMeta(key, value) { await this.kv.put(`__meta:${key}`, enc(value)); } // ---- 表管理 ---- async createTable(schema) { this.ensureOpen(); await this.memory.createTable(schema); if (this.txActive) { this.txDirtyTables.add(schema.name); this.txSchemaChanged = true; return; } await this.persistSchema(); } async dropTable(tableName) { this.ensureOpen(); await this.memory.dropTable(tableName); if (this.txActive) { this.txDirtyTables.add(tableName); this.txSchemaChanged = true; return; } await this.persistSchema(); // 删除该表全部行(KV 中残留清理) const diff = await this.collectTableDiff(tableName); await this.kv.writeBatch(diff.puts, diff.deletes); } async hasTable(tableName) { this.ensureOpen(); return this.memory.hasTable(tableName); } async getTableNames() { this.ensureOpen(); return this.memory.getTableNames(); } async getTableSchema(tableName) { this.ensureOpen(); return this.memory.getTableSchema(tableName); } async alterTable(tableName, action, column) { this.ensureOpen(); // v0.7.2: 事务内 ALTER 显式拒绝(与 AriaEngine/MemoryEngine 对齐)—— // memory.alterTable 直接修改共享 columns 对象,事务快照无法回滚 // (此前 ROLLBACK 后新增列残留) if (this.txActive) { throw new DatabaseError(`ALTER TABLE is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED'); } await this.memory.alterTable(tableName, action, column); if (this.txActive) { this.txDirtyTables.add(tableName); this.txSchemaChanged = true; return; } await this.persistSchema(); if (action === 'DROP') { // 重写存储行(移除该列) const diff = await this.collectTableDiff(tableName); await this.kv.writeBatch(diff.puts, diff.deletes); } } // ---- CRUD ---- /** * v0.8.0(B-1):写入前置校验 —— 委托给内存引擎(两者共享同一 schema 表)。 * * KVStore/Hybrid 的行校验一直"继承"自 MemoryEngine,这正是 A12 的成因: * 三者共用一份**缺 maxLength/min/max** 的实现。现在共享的是 * `table/validation.ts` 的规范实现,继承关系不再影响约束覆盖面。 */ async validatePayload(tableName, rows, mode = 'insert') { this.ensureOpen(); return this.memory.validatePayload(tableName, rows, mode); } async insert(tableName, rows) { this.ensureOpen(); const pks = await this.memory.insert(tableName, rows); if (this.txActive) { this.txDirtyTables.add(tableName); // v0.7.0: 事务内 clear 后又写入 → 清空语义被覆盖,整表 diff 兜底 if (this.txClearedTables.has(tableName)) { this.txClearedTables.delete(tableName); this.txFullTables.add(tableName); return pks; } // v0.7.0: 行级变更记录(增量 flush) let changes = this.txChanges.get(tableName); if (!changes) { changes = new Map(); this.txChanges.set(tableName, changes); } for (const pk of pks) changes.set(pk, 'put'); return pks; } // 增量持久化(原子 putMany) const schema = await this.memory.getTableSchema(tableName); if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND'); // v0.7.3: 持久化内存中的 validated 行(含 default 值/类型归一/列投影)—— // 此前写原始入参 row:default 不落盘、schema 外列被持久化,重启后行不一致 const puts = {}; for (const pk of pks) { const row = this.memory.getRow(tableName, pk); if (row) puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row)); } await this.kv.putMany(puts); return pks; } async find(tableName, query) { this.ensureOpen(); return this.memory.find(tableName, query); } async findStream(tableName, query, onRow) { this.ensureOpen(); return this.memory.findStream(tableName, query, onRow); } async update(tableName, query, updates) { this.ensureOpen(); const schema = await this.memory.getTableSchema(tableName); if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND'); const pkCol = this.getPK(schema); // v0.7.2: undefined 值视为"不更新该列"(与 memory.update 语义对齐) const cleanUpdates = stripUndefinedUpdates(updates); const pkChanged = pkCol in cleanUpdates; // 收集受影响旧主键(内存匹配) const affected = pkChanged ? [] : await this.collectMatchingPks(tableName, query); const count = await this.memory.update(tableName, query, cleanUpdates); if (this.txActive) { this.txDirtyTables.add(tableName); // v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联), // 相关表整表 diff 兜底;普通更新记录受影响行 if (pkChanged) { for (const t of await this.affectedTables(tableName)) { this.txFullTables.add(t); this.txDirtyTables.add(t); } } else { let changes = this.txChanges.get(tableName); if (!changes) { changes = new Map(); this.txChanges.set(tableName, changes); } for (const pk of affected) changes.set(pk, 'put'); // 级联影响表(理论上非主键更新不级联,防御性兜底) for (const t of await this.affectedTables(tableName)) { if (t !== tableName) { this.txFullTables.add(t); this.txDirtyTables.add(t); } } } return count; } const puts = {}; const deletes = []; if (pkChanged) { // 主键变更:相关表整表 diff(罕见操作,可靠性优先) for (const t of await this.affectedTables(tableName)) { const diff = await this.collectTableDiff(t); Object.assign(puts, diff.puts); deletes.push(...diff.deletes); } } else { // v0.6.2-fix(P0): 受影响主键经 String() 化后按 `where { [pkCol]: pk }` 回查内存行, // 数值型主键(123 !== "123")不命中 → 行被误判删除 → 重启丢数据。 // 改为单次全表扫描 + 受影响集合过滤(同时消除此前 O(N×M) 逐主键回查开销)。 const affectedSet = new Set(affected); const allRows = await this.memory.find(tableName, { table: tableName }); for (const row of allRows) { const pkStr = String(row[pkCol]); if (affectedSet.has(pkStr)) { puts[this.rowKey(tableName, pkStr)] = enc(JSON.stringify(row)); affectedSet.delete(pkStr); } } // 剩余主键(内存中已不存在,如被级联移除)→ 删除对应 KV 行 for (const pk of affectedSet) { deletes.push(this.rowKey(tableName, pk)); } // 级联影响表(SET NULL/CASCADE 外键)整表 diff for (const t of await this.affectedTables(tableName)) { if (t === tableName) continue; const diff = await this.collectTableDiff(t); Object.assign(puts, diff.puts); deletes.push(...diff.deletes); } } // 单次原子写(一条日志记录 = 真原子,v0.6.1) await this.kv.writeBatch(puts, deletes); return count; } async delete(tableName, query) { this.ensureOpen(); // 收集受影响主键(内存匹配) const pks = await this.collectMatchingPks(tableName, query); const count = await this.memory.delete(tableName, query); if (this.txActive) { this.txDirtyTables.add(tableName); // v0.7.0: 行级变更记录 —— 删除行记录 delete;级联影响表整表 diff 兜底 let changes = this.txChanges.get(tableName); if (!changes) { changes = new Map(); this.txChanges.set(tableName, changes); } for (const pk of pks) changes.set(pk, 'delete'); for (const t of await this.affectedTables(tableName)) { if (t !== tableName) { this.txFullTables.add(t); this.txDirtyTables.add(t); } } return count; } const puts = {}; const deletes = pks.map((pk) => this.rowKey(tableName, pk)); // 级联影响表整表 diff(合并到单次原子写,v0.6.1) for (const t of await this.affectedTables(tableName)) { if (t === tableName) continue; const diff = await this.collectTableDiff(t); Object.assign(puts, diff.puts); deletes.push(...diff.deletes); } await this.kv.writeBatch(puts, deletes); return count; } async count(tableName, query) { this.ensureOpen(); return this.memory.count(tableName, query); } async clear(tableName) { this.ensureOpen(); await this.memory.clear(tableName); if (this.txActive) { this.txDirtyTables.add(tableName); // v0.7.0: 事务内清空 → commit 时删除全部 KV 行(比整表 diff 更高效) this.txClearedTables.add(tableName); this.txChanges.delete(tableName); return; } const diff = await this.collectTableDiff(tableName); await this.kv.writeBatch(diff.puts, diff.deletes); } // ---- 动态索引 ---- async createIndex(tableName, column, unique) { this.ensureOpen(); if (this.txActive) { throw new DatabaseError(`CREATE INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED'); } await this.memory.createIndex(tableName, column, unique); if (this.txActive) { this.txDirtyTables.add(tableName); this.txSchemaChanged = true; return; } await this.persistSchema(); } async dropIndex(tableName, column, indexName) { this.ensureOpen(); if (this.txActive) { throw new DatabaseError(`DROP INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED'); } await this.memory.dropIndex(tableName, column, indexName); if (this.txActive) { this.txDirtyTables.add(tableName); this.txSchemaChanged = true; return; } await this.persistSchema(); } // ---- 事务(原子 flush) ---- async beginTransaction() { this.ensureOpen(); await this.memory.beginTransaction(); this.txActive = true; this.txDirtyTables = new Set(); this.txSchemaChanged = false; this.txChanges = new Map(); this.txFullTables = new Set(); this.txClearedTables = new Set(); } async commitTransaction() { this.ensureOpen(); if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE'); // v0.6.1: 全部 dirty 表合并为单次原子 flush(一条日志记录 = 真原子, // 多表事务中途崩溃/失败不会出现"部分表已提交")。 // v0.7.0: 行级增量 flush —— 普通 insert/update/delete 仅写事务内改动的行 // (此前 collectTableDiff 整表重写:大表事务改 1 行也 O(表大小)); // 主键变更/级联影响表整表 diff 兜底;clear/drop 表只删 KV 行。 const puts = {}; const deletes = []; for (const table of this.txDirtyTables) { if (!(await this.memory.hasTable(table))) { // 事务内 drop 的表:清理 KV 残留行 const all = await this.kv.getAll(); const prefix = this.rowPrefix(table); for (const [key] of all) { if (key.startsWith(prefix)) deletes.push(key); } continue; } if (this.txClearedTables.has(table)) { // 事务内 clear 的表:删除全部 KV 行 const all = await this.kv.getAll(); const prefix = this.rowPrefix(table); for (const [key] of all) { if (key.startsWith(prefix)) deletes.push(key); } continue; } if (this.txFullTables.has(table)) { const diff = await this.collectTableDiff(table); Object.assign(puts, diff.puts); deletes.push(...diff.deletes); continue; } const changes = this.txChanges.get(table); if (changes && changes.size > 0) { // v0.7.0: 增量 flush —— 单次全表扫描 + 变更集合过滤 const schema = await this.memory.getTableSchema(table); if (!schema) continue; const pkCol = this.getPK(schema); const pending = new Map(changes); const rows = await this.memory.find(table, { table: table }); for (const row of rows) { const pkStr = String(row[pkCol]); const kind = pending.get(pkStr); if (kind !== undefined) { if (kind === 'put') puts[this.rowKey(table, pkStr)] = enc(JSON.stringify(row)); else deletes.push(this.rowKey(table, pkStr)); pending.delete(pkStr); } } // 内存中已不存在的行(后续操作删除)→ KV 行删除 for (const [pk, kind] of pending) { if (kind === 'delete') deletes.push(this.rowKey(table, pk)); } } } await this.kv.writeBatch(puts, deletes); // 事务内 DDL 的 schema 一并持久化 if (this.txSchemaChanged) { await this.persistSchema(); } // v0.7.0-perf: 移除每次 commit 的强制全量 checkpoint —— KVStore 按日志阈值 // 自动 checkpoint(日志重放保证崩溃恢复正确),大库高频事务不再 O(库大小)。 // close() 时统一 checkpoint(截断日志,重开更快)。 await this.memory.commitTransaction(); this.txActive = false; this.txDirtyTables = new Set(); this.txChanges = new Map(); this.txFullTables = new Set(); this.txClearedTables = new Set(); } async rollbackTransaction() { this.ensureOpen(); if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE'); await this.memory.rollbackTransaction(); this.txActive = false; this.txDirtyTables = new Set(); this.txSchemaChanged = false; this.txChanges = new Map(); this.txFullTables = new Set(); this.txClearedTables = new Set(); } // ---- 内部 ---- ensureOpen() { if (!this.opened) throw new DatabaseError('Database not opened', 'DB_NOT_OPEN'); } getPK(schema) { for (const [name, col] of Object.entries(schema.columns)) { if (col.primaryKey) return name; } return Object.keys(schema.columns)[0]; } /** 收集匹配查询的内存行主键(持久化差异计算用) */ async collectMatchingPks(tableName, query) { const schema = await this.memory.getTableSchema(tableName); if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND'); const pkCol = this.getPK(schema); const rows = await this.memory.find(tableName, query); return rows.map((r) => String(r[pkCol])); } /** * 计算外键级联影响的表集合(传递闭包:A 被 B 引用,B 被 C 引用 → {A, B, C})。 * 级联操作(delete/update 主键)需要把这些表一并重写持久化。 */ async affectedTables(tableName) { const set = new Set([tableName]); let changed = true; while (changed) { changed = false; for (const table of await this.memory.getTableNames()) { if (set.has(table)) continue; const schema = await this.memory.getTableSchema(table); if (!schema) continue; for (const col of Object.values(schema.columns)) { if (col.references) { const ref = col.references.split('.')[0]; if (set.has(ref)) { set.add(table); changed = true; break; } } } } } return set; } /** 持久化 schema(全部表) */ async persistSchema() { const schemas = {}; for (const table of await this.memory.getTableNames()) { const schema = await this.memory.getTableSchema(table); if (schema) schemas[table] = schema; } await this.kv.put(SCHEMA_KEY, enc(JSON.stringify(schemas))); } /** * v0.6.1: 整表 diff 收集(不落盘):内存行全部 put + KV 残留行删除。 * 调用方合并到单次原子 writeBatch(put+delete 同一条日志记录,多表操作真原子)。 */ async collectTableDiff(tableName) { const prefix = this.rowPrefix(tableName); const puts = {}; const deletes = []; // 表已删除:仅收集 KV 残留行删除 const schema = await this.memory.getTableSchema(tableName); if (schema) { const pkCol = this.getPK(schema); const rows = await this.memory.find(tableName, { table: tableName }); const current = new Set(); for (const row of rows) { const key = this.rowKey(tableName, String(row[pkCol])); current.add(key); puts[key] = enc(JSON.stringify(row)); } // KV 残留行(内存中已不存在) const all = await this.kv.getAll(); for (const [key] of all) { if (key.startsWith(prefix) && !current.has(key)) deletes.push(key); } } else { const all = await this.kv.getAll(); for (const [key] of all) { if (key.startsWith(prefix)) deletes.push(key); } } return { puts, deletes }; } } /** * AriaEngine Types — 内部类型定义 * @module engine/aria/types * * 页面式存储引擎的所有内部枚举、接口和常量。 */ // ============================================================================= // 页面常量 // ============================================================================= /** 页面大小:4KB */ const PAGE_SIZE = 4096; /** 页面头大小:16 字节 */ const PAGE_HEADER_SIZE = 16; // ============================================================================= // 页面类型 // ============================================================================= var PageType; (function (PageType) { /** 数据页面:存储 SSTable 字节切片 */ PageType[PageType["DATA"] = 1] = "DATA"; /** 索引页面:存储索引节点 */ PageType[PageType["INDEX"] = 2] = "INDEX"; /** 溢出页面:存储大字段 */ PageType[PageType["OVERFLOW"] = 3] = "OVERFLOW"; /** 元数据页面:存储表/库元信息 */ PageType[PageType["META"] = 4] = "META"; })(PageType || (PageType = {})); // ============================================================================= // 列类型(内部二进制编码用) // ============================================================================= // ============================================================================= // LSM-Tree // ============================================================================= /** MemTable 最大大小(默认 4MB) */ const DEFAULT_MEMTABLE_SIZE = 4 * 1024 * 1024; /** Bloom Filter 每 key 的默认位数 */ const DEFAULT_BLOOM_BITS_PER_KEY = 10; /** SSTable 最大层级 */ const MAX_LSM_LEVELS = 7; /** 每层之间的大小倍数 */ const DEFAULT_LEVEL_SIZE_MULTIPLIER = 10; // ============================================================================= // WAL (Write-Ahead Log) // ============================================================================= /** WAL 记录类型 */ var WALRecordType; (function (WALRecordType) { WALRecordType[WALRecordType["INSERT"] = 1] = "INSERT"; WALRecordType[WALRecordType["UPDATE"] = 2] = "UPDATE"; WALRecordType[WALRecordType["DELETE"] = 3] = "DELETE"; WALRecordType[WALRecordType["BEGIN"] = 4] = "BEGIN"; WALRecordType[WALRecordType["COMMIT"] = 5] = "COMMIT"; WALRecordType[WALRecordType["ROLLBACK"] = 6] = "ROLLBACK"; WALRecordType[WALRecordType["CREATE_TABLE"] = 7] = "CREATE_TABLE"; WALRecordType[WALRecordType["DROP_TABLE"] = 8] = "DROP_TABLE"; /** * v0.8.0: 回滚到保存点。 * * 为什么必须有这条记录:`ROLLBACK TO ` 此前只改内存快照、**不写 WAL**, * 而 COMMIT 会把整个 txnId 标记为已提交,恢复时按"该事务的全部记录"重放 —— * 于是被 savepoint 回滚掉的行在崩溃重启后**复活**(实测:实时只剩 a, * 崩溃重开变成 a+b)。反向也有问题:跨事务复用的陈旧 savepoint 会让当前事务的 * 写入被上一事务的快照静默替换。 * * 记录语义:该事务在此之前的写入都应被丢弃 —— 恢复时只应用该事务**最后一条** * SAVEPOINT_ROLLBACK 之后的记录。 */ WALRecordType[WALRecordType["SAVEPOINT_ROLLBACK"] = 9] = "SAVEPOINT_ROLLBACK"; /** * v0.8.0(A41):ALTER TABLE 的**意图**记录。 * * 此前 `alterTable` 完全不写 WAL:它先改内存 schema、必要时建索引,最后才 * `persistSchemas()`。中间任何一步抛错(如"ALTER ADD UNIQUE 撞存量重复值") * 都会留下**内存已变、磁盘未变**的分裂状态 —— 同进程里 `getTableSchema` * 看到新列,重开后新列又消失(用户视角:ALTER 时好时坏、结果取决于是否重启)。 * 加上意图记录后,恢复可以按记录把"内存里已经生效"的结构变更补齐。 * * 记录语义:`data.schema` 为变更后的完整 schema JSON;回放时**覆盖**该表 schema *(ALTER 是结构权威描述,不是增量),并对新增的 index/unique 列重建索引。 */ WALRecordType[WALRecordType["ALTER_TABLE"] = 10] = "ALTER_TABLE"; })(WALRecordType || (WALRecordType = {})); // ============================================================================= // MVCC // ============================================================================= /** 事务状态 */ var TransactionState; (function (TransactionState) { TransactionState[TransactionState["ACTIVE"] = 1] = "ACTIVE"; TransactionState[TransactionState["COMMITTED"] = 2] = "COMMITTED"; TransactionState[TransactionState["ABORTED"] = 3] = "ABORTED"; })(TransactionState || (TransactionState = {})); // ============================================================================= // Buffer Pool // ============================================================================= /** Buffer Pool 默认容量:256 页 ≈ 1MB */ const DEFAULT_BUFFER_POOL_PAGES = 256; const DEFAULT_ARIA_CONFIG = { pageSize: PAGE_SIZE, bufferPoolPages: DEFAULT_BUFFER_POOL_PAGES, memtableSizeThreshold: DEFAULT_MEMTABLE_SIZE, levelSizeMultiplier: DEFAULT_LEVEL_SIZE_MULTIPLIER, bloomFilterBitsPerKey: DEFAULT_BLOOM_BITS_PER_KEY, walEnabled: true, walSyncMode: 'full', checkpointInterval: 1000, compression: false, storageBackend: 'opfs', walSizeThreshold: 16 * 1024 * 1024, // 16MB maxMemoryMB: 64, encryption: undefined, pageStorage: undefined, testBackend: undefined, }; /** * AriaEngine MemTable — 基于红黑树的内存表 * @module engine/aria/index/memtable * * 写操作先进入 MemTable,达到阈值后冻结并 flush 成 SSTable。 */ // --------------------------------------------------------------------------- // RB-Tree Node // --------------------------------------------------------------------------- var Color; (function (Color) { Color[Color["RED"] = 0] = "RED"; Color[Color["BLACK"] = 1] = "BLACK"; })(Color || (Color = {})); class RBNode { constructor(key, value) { this.color = Color.RED; this.left = null; this.right = null; this.parent = null; this.key = key; this.value = value; } } // --------------------------------------------------------------------------- // Red-Black Tree // --------------------------------------------------------------------------- class RedBlackTree { constructor() { this.root = null; this._size = 0; } get size() { return this._size; } // ---- 插入 ---- insert(key, value) { const node = new RBNode(key, value); if (!this.root) { this.root = node; node.color = Color.BLACK; this._size++; return; } let parent = null; let current = this.root; while (current) { parent = current; if (key < current.key) { current = current.left; } else if (key > current.key) { current = current.right; } else { // 更新已存在的 key current.value = value; return; } } node.parent = parent; if (key < parent.key) { parent.left = node; } else { parent.right = node; } this._size++; this.fixInsert(node); } // ---- 查找 ---- find(key) { let current = this.root; while (current) { if (key < current.key) { current = current.left; } else if (key > current.key) { current = current.right; } else { return current.value; } } return null; } // ---- 删除 ---- delete(key) { // 简化实现:标记删除(实际改为找到并调整树) const node = this.findNode(key); if (!node) return false; this.deleteNode(node); this._size--; return true; } // ---- 遍历 ---- /** 中序遍历(有序) */ inorder(callback) { this._inorder(this.root, callback); } /** 范围遍历 */ rangeScan(startKey, endKey, callback) { this._rangeScan(this.root, startKey, endKey, callback); } /** * v0.7.4: 惰性范围遍历(显式栈中序迭代 + 边界剪枝)。 * 真流式扫描:生成器按需产出,提前终止(limit 达成)时剩余子树不再遍历。 */ *scanLazy(startKey, endKey) { const stack = []; // 定位到 >= startKey 的最左节点(沿路入栈) let cur = this.root; while (cur) { if (cur.key >= startKey) { stack.push(cur); cur = cur.left; } else { cur = cur.right; } } while (stack.length > 0) { const node = stack.pop(); // 中序递增:越过 endKey 后所有剩余节点均越界 if (node.key > endKey) break; if (node.key >= startKey) yield [node.key, node.value]; cur = node.right; while (cur) { stack.push(cur); cur = cur.left; } } } /** 获取所有条目 */ getAllEntries() { const entries = []; this.inorder((k, v) => entries.push([k, v])); return entries; } /** 清空 */ clear() { this.root = null; this._size = 0; } // ---- 内部方法 ---- findNode(key) { let current = this.root; while (current) { if (key < current.key) { current = current.left; } else if (key > current.key) { current = current.right; } else { return current; } } return null; } deleteNode(node) { // 简化:用左子树最大或右子树最小替换 // 完整实现较复杂,这里采用简化策略 if (!node.left && !node.right) { this.transplant(node, null); if (node.color === Color.BLACK) this.fixDelete(null, node.parent); } else if (!node.left) { this.transplant(node, node.right); if (node.color === Color.BLACK) this.fixDelete(node.right, node.right.parent); } else if (!node.right) { this.transplant(node, node.left); if (node.color === Color.BLACK) this.fixDelete(node.left, node.left.parent); } else { const successor = this.minimum(node.right); // v0.7.4: 在 transplant 重连前捕获 successor 原右子与父 —— // x(双黑修复起点)= successor 原右子(占位在 successor 原位置)。 // 此前 `successor.right?.parent ?? null` 在重赋值后取值:x 指向 node 右子树, // 且 null 时 parent 为 null → fixDelete 直接跳过修复(删除黑色节点后失衡)。 const successorRight = successor.right; const successorParent = successor.parent; if (successorParent !== node) { this.transplant(successor, successorRight); successor.right = node.right; successor.right.parent = successor; } this.transplant(node, successor); successor.left = node.left; successor.left.parent = successor; const origColor = successor.color; successor.color = node.color; if (origColor === Color.BLACK) { const x = successorRight; // x 为 null 占位:直接右子时其父为 successor(已移到 node 位置), // 间接右子时其父为 successor 原父(transplant 已把 x 接到其下) const xParent = x ? x.parent : (successorParent === node ? successor : successorParent); this.fixDelete(x, xParent); } } } transplant(u, v) { if (!u.parent) { this.root = v; } else if (u === u.parent.left) { u.parent.left = v; } else { u.parent.right = v; } if (v) v.parent = u.parent; } minimum(node) { while (node.left) node = node.left; return node; } fixInsert(node) { while (node.parent && node.parent.color === Color.RED) { const parent = node.parent; const grandparent = parent.parent; if (!grandparent) break; if (parent === grandparent.left) { const uncle = grandparent.right; if (uncle && uncle.color === Color.RED) { parent.color = Color.BLACK; uncle.color = Color.BLACK; grandparent.color = Color.RED; node = grandparent; } else { if (node === parent.right) { node = parent; this.rotateLeft(node); } if (node.parent) node.parent.color = Color.BLACK; if (node.parent?.parent) node.parent.parent.color = Color.RED; if (node.parent?.parent) this.rotateRight(node.parent.parent); } } else { const uncle = grandparent.left; if (uncle && uncle.color === Color.RED) { parent.color = Color.BLACK; uncle.color = Color.BLACK; grandparent.color = Color.RED; node = grandparent; } else { if (node === parent.left) { node = parent; this.rotateRight(node); } if (node.parent) node.parent.color = Color.BLACK; if (node.parent?.parent) node.parent.parent.color = Color.RED; if (node.parent?.parent) this.rotateLeft(node.parent.parent); } } } if (this.root) this.root.color = Color.BLACK; } fixDelete(x, parent) { // 标准 RB-Tree 删除修复(修复"双黑"问题) let node = x; let nodeParent = parent; while ((!node || node.color === Color.BLACK) && node !== this.root) { if (!nodeParent) break; if (node === nodeParent.left) { let sibling = nodeParent.right; if (!sibling) break; // Case 1: 兄弟是红色 if (sibling.color === Color.RED) { sibling.color = Color.BLACK; nodeParent.color = Color.RED; this.rotateLeft(nodeParent); sibling = nodeParent.right; if (!sibling) break; } // Case 2: 兄弟的两个子节点都是黑色 const sibLeft = sibling.left; const sibRight = sibling.right; if ((!sibLeft || sibLeft.color === Color.BLACK) && (!sibRight || sibRight.color === Color.BLACK)) { sibling.color = Color.RED; node = nodeParent; nodeParent = node.parent; } else { // Case 3: 兄弟右子黑色(左子红色) if (!sibRight || sibRight.color === Color.BLACK) { if (sibLeft) sibLeft.color = Color.BLACK; sibling.color = Color.RED; this.rotateRight(sibling); sibling = nodeParent.right; if (!sibling) break; } // Case 4: 兄弟右子红色 sibling.color = nodeParent.color; nodeParent.color = Color.BLACK; if (sibling.right) sibling.right.color = Color.BLACK; this.rotateLeft(nodeParent); node = this.root; } } else { // 镜像:node 是父节点的右子 let sibling = nodeParent.left; if (!sibling) break; if (sibling.color === Color.RED) { sibling.color = Color.BLACK; nodeParent.color = Color.RED; this.rotateRight(nodeParent); sibling = nodeParent.left; if (!sibling) break; } const sibLeft = sibling.left; const sibRight = sibling.right; if ((!sibLeft || sibLeft.color === Color.BLACK) && (!sibRight || sibRight.color === Color.BLACK)) { sibling.color = Color.RED; node = nodeParent; nodeParent = node.parent; } else { if (!sibLeft || sibLeft.color === Color.BLACK) { if (sibRight) sibRight.color = Color.BLACK; sibling.color = Color.RED; this.rotateLeft(sibling); sibling = nodeParent.left; if (!sibling) break; } sibling.color = nodeParent.color; nodeParent.color = Color.BLACK; if (sibling.left) sibling.left.color = Color.BLACK; this.rotateRight(nodeParent); node = this.root; } } } if (node) node.color = Color.BLACK; } rotateLeft(x) { const y = x.right; if (!y) return; x.right = y.left; if (y.left) y.left.parent = x; y.parent = x.parent; if (!x.parent) { this.root = y; } else if (x === x.parent.left) { x.parent.left = y; } else { x.parent.right = y; } y.left = x; x.parent = y; } rotateRight(x) { const y = x.left; if (!y) return; x.left = y.right; if (y.right) y.right.parent = x; y.parent = x.parent; if (!x.parent) { this.root = y; } else if (x === x.parent.right) { x.parent.right = y; } else { x.parent.left = y; } y.right = x; x.parent = y; } _inorder(node, cb) { if (!node) return; this._inorder(node.left, cb); cb(node.key, node.value); this._inorder(node.right, cb); } _rangeScan(node, start, end, cb) { if (!node) return; if (node.key > start) this._rangeScan(node.left, start, end, cb); if (node.key >= start && node.key <= end) cb(node.key, node.value); if (node.key < end) this._rangeScan(node.right, start, end, cb); } } // --------------------------------------------------------------------------- // MemTable // --------------------------------------------------------------------------- class MemTable { constructor(maxSize = 4 * 1024 * 1024) { this._estimatedSize = 0; this.tree = new RedBlackTree(); this.maxSize = maxSize; } /** 插入或更新 */ put(key, value) { const oldSize = this.estimateEntrySize(key, this.tree.find(key)); const newSize = this.estimateEntrySize(key, value); this.tree.insert(key, value); this._estimatedSize += newSize - oldSize; } /** 获取 */ get(key) { return this.tree.find(key); } /** 删除 */ delete(key) { const oldVal = this.tree.find(key); if (oldVal) { this._estimatedSize -= this.estimateEntrySize(key, oldVal); } return this.tree.delete(key); } /** 是否应刷盘 */ shouldFlush() { return this._estimatedSize >= this.maxSize; } /** 获取所有有序条目 */ getAllEntries() { return this.tree.getAllEntries(); } /** 范围扫描 */ rangeScan(startKey, endKey) { const entries = []; this.tree.rangeScan(startKey, endKey, (k, v) => entries.push([k, v])); return entries; } /** v0.7.4: 惰性范围扫描(真流式,逐条产出) */ scanLazy(startKey, endKey) { return this.tree.scanLazy(startKey, endKey); } /** 条目数 */ getEntryCount() { return this.tree.size; } /** 估计大小(字节) */ getEstimatedSize() { return this._estimatedSize; } /** 清空 */ clear() { this.tree.clear(); this._estimatedSize = 0; } // ----------------------------------------------------------------------- // 内部 // ----------------------------------------------------------------------- estimateEntrySize(key, value) { if (!value) return 0; let size = key.length * 2; // UTF-16 for (const entry of Object.entries(value)) { size += entry[0].length * 2; const v = entry[1]; if (typeof v === 'string') size += v.length * 2; else if (typeof v === 'number') size += 8; else if (typeof v === 'boolean') size += 1; else if (v === null || v === undefined) size += 1; else size += 16; // rough estimate } return size; } } /** * AriaEngine Bloom Filter — 快速判定 key 是否可能存在 * @module engine/aria/index/bloom * * 使用双哈希函数 + Kirsch-Mitzenmacher 优化生成 k 个哈希值。 */ // --------------------------------------------------------------------------- // BloomFilter // --------------------------------------------------------------------------- class BloomFilter { /** * @param numKeys 预期插入的 key 数量 * @param bitsPerKey 每个 key 的位数(默认 10,误报率约 1%) */ constructor(numKeys, bitsPerKey = DEFAULT_BLOOM_BITS_PER_KEY) { this._inserted = 0; // ceil(numKeys * bitsPerKey / 8),最少 64 位 const numBits = Math.max(64, numKeys * bitsPerKey); const numBytes = Math.ceil(numBits / 8); this.bits = new Uint8Array(numBytes); // k = bitsPerKey * ln(2) ≈ bitsPerKey * 0.69 this.numHashes = Math.max(1, Math.floor(bitsPerKey * 0.69)); } /** 从现有数据恢复 */ static fromData(data, numHashes) { const bf = new BloomFilter(1); // dummy bf.bits = data; bf.numHashes = numHashes; return bf; } /** 插入 key */ insert(key) { const hashes = this.getHashes(key); for (const h of hashes) { const byteIdx = Math.floor(h / 8); const bitIdx = h % 8; this.bits[byteIdx] |= (1 << bitIdx); } this._inserted++; } /** 检查 key 可能存在(false positive 可能,false negative 不可能) */ mayContain(key) { const hashes = this.getHashes(key); for (const h of hashes) { const byteIdx = Math.floor(h / 8); const bitIdx = h % 8; if ((this.bits[byteIdx] & (1 << bitIdx)) === 0) { return false; // 确定不存在 } } return true; // 可能存在 } /** 获取序列化数据 */ serialize() { return this.bits; } /** hash 函数数量 */ getHashCount() { return this.numHashes; } // ----------------------------------------------------------------------- // 哈希 // ----------------------------------------------------------------------- getHashes(key) { const bits = this.bits.byteLength * 8; const h1 = this.fnv1a(key); const h2 = this.murmurSimple(key); const hashes = []; for (let i = 0; i < this.numHashes; i++) { // Kirsch-Mitzenmacher: h_i = h1 + i * h2 const h = Math.abs((h1 + i * h2) % bits); hashes.push(h); } return hashes; } /** FNV-1a 哈希 */ fnv1a(str) { let hash = 0x811c9dc5; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash = (hash * 0x01000193) >>> 0; } return hash; } /** 简化的 Murmur-like 哈希 */ murmurSimple(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const ch = str.charCodeAt(i); hash = ((hash << 5) - hash + ch) | 0; hash = (hash ^ (hash >>> 16)) >>> 0; } return Math.abs(hash); } } /** * AriaEngine SSTable Builder — 构建有序字符串表 * @module engine/aria/index/sstable_builder * * 将排序后的 key-value 数据写入 SSTable 格式。 * * v0.4.4 格式 v2(magic "SSTC")修复: * - 块大小估算改用 UTF-8 字节长度(TextEncoder 预编码), * 此前用字符串 .length(UTF-16 码元)估算而实际写入 UTF-8 字节, * 中文内容(1 字 3 字节)导致缓冲区低估 → 写入越界崩溃 * - keyLen/valueLen 从 u16 升级为 u32(此前 >64KB 的 value 长度被截断, * 线性格式整体错乱) * - 大 value 单条独立成块(切分逻辑基于字节估算) * * SSTable 文件布局 (v2): * ┌──────────────────────────────────────────────┐ * │ Data Block 0 │ * │ Data Block 1 │ * │ ... │ * │ Index Block (block offset → key range) │ * │ Bloom Filter │ * │ Footer (32 bytes) │ * │ - index_offset (u32) │ * │ - index_size (u32) │ * │ - bloom_offset (u32) │ * │ - bloom_size (u32) │ * │ - bloom_hash_count (u32) │ * │ - entry_count (u32) │ * │ - magic_number (u32, 0x53535443 ="SSTC")│ * │ - checksum (u32) │ * └──────────────────────────────────────────────┘ * 数据块条目: entryCount(u32) + [keyLen(u32) + key + valueLen(u32) + value] * 索引块条目: [keyLen(u32) + key + blockOffset(u32) + blockSize(u32)] */ /** v1 格式魔数("SSTB",u16 长度字段,兼容旧文件读取) */ const SSTABLE_MAGIC_V1 = 0x53535442; /** v2 格式魔数("SSTC",u32 长度字段 + 字节精确估算,v0.4.4) */ const SSTABLE_MAGIC_V2 = 0x53535443; const SSTABLE_FOOTER_SIZE = 32; class SSTableBuilder { /** * @param blockSizeLimit 块大小上限(字节) * @param bloomBitsPerKey Bloom Filter 每 key 位数(v0.8.0 审查修复:此前 * 无论 `bloomFilterBitsPerKey` 配成多少,这里都写死用默认值 —— * 配置项被接受却完全不起作用) */ constructor(blockSizeLimit = 4096, bloomBitsPerKey = DEFAULT_BLOOM_BITS_PER_KEY) { this.entries = []; this.blockSizeLimit = blockSizeLimit; this.bloomBitsPerKey = Number.isFinite(bloomBitsPerKey) && bloomBitsPerKey > 0 ? Math.floor(bloomBitsPerKey) : DEFAULT_BLOOM_BITS_PER_KEY; } /** 添加一个 key-value 条目(必须按键排序添加) */ add(key, value) { this.entries.push([key, value]); } /** * 构建 SSTable 文件的二进制数据(v2 格式)。 * 返回 { data: Uint8Array, indexEntries: IndexEntry[] } */ build() { // v0.4.4-fix: 预编码全部条目 — 块大小估算必须基于 UTF-8 字节长度, // 字符串 .length 是 UTF-16 码元(中文 1 字 3 字节 vs 1 码元)→ 缓冲区低估越界 const encoder = new TextEncoder(); const encoded = this.entries.map(([key, value]) => ({ key, keyBytes: encoder.encode(key), valueBytes: encoder.encode(JSON.stringify(value)), })); const blocks = this.splitIntoBlocks(encoded); const bloomFilter = new BloomFilter(this.entries.length, this.bloomBitsPerKey); // 预计算总大小(字节) let totalSize = 0; const blockOffsets = []; for (const block of blocks) { blockOffsets.push(totalSize); totalSize += this.computeBlockSize(block); } // 索引块(块内最后一个 key 作为索引键) const indexEntries = []; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; indexEntries.push({ key: block[block.length - 1].key, blockOffset: blockOffsets[i], blockSize: this.computeBlockSize(block), }); } const indexBlockSize = this.estimateIndexBlockSize(indexEntries); // 序列化 bloom filter 以获取其大小 const bloomData = bloomFilter.serialize(); const bloomSize = bloomData.byteLength; // 写入到 buffer(包含 bloom block) const finalSize = totalSize + indexBlockSize + bloomSize + SSTABLE_FOOTER_SIZE; const buf = new ArrayBuffer(finalSize); const view = new DataView(buf); let offset = 0; // ---- Data Blocks ---- for (const block of blocks) { offset = this.writeDataBlock(view, offset, block, bloomFilter); } // ---- Index Block ---- const indexOffset = offset; offset = this.writeIndexBlock(view, offset, indexEntries); // ---- Bloom Filter Block ---- const bloomOffset = offset; new Uint8Array(view.buffer).set(bloomData, offset); offset += bloomSize; // ---- Footer ---- const footerOffset = offset; view.setUint32(footerOffset, indexOffset, false); // index_offset view.setUint32(footerOffset + 4, indexBlockSize, false); // index_size view.setUint32(footerOffset + 8, bloomOffset, false); // bloom_offset view.setUint32(footerOffset + 12, bloomSize, false); // bloom_size view.setUint32(footerOffset + 16, bloomFilter.getHashCount(), false); view.setUint32(footerOffset + 20, this.entries.length, false); view.setUint32(footerOffset + 24, SSTABLE_MAGIC_V2, false); // checksum 字段先写 0,全部字节就绪后计算整文件 CRC32 再回填 view.setUint32(footerOffset + 28, 0, false); // v0.4.5: 真实 CRC-32 校验和 — 覆盖除自身(最后 4 字节)外的全部内容。 // checksum 永远非 0(计算结果为 0 时用 1 代替),读取端以 0 识别旧版无校验文件 const all = new Uint8Array(buf); let checksum = crc32(all.subarray(0, all.byteLength - 4)); if (checksum === 0) checksum = 1; view.setUint32(footerOffset + 28, checksum, false); return { sstableData: new Uint8Array(buf), indexEntries, }; } /** 获取条目数 */ getEntryCount() { return this.entries.length; } // ----------------------------------------------------------------------- // 内部 // ----------------------------------------------------------------------- /** 按 UTF-8 字节大小切分数据块;大 value 单条独立成块 */ splitIntoBlocks(encoded) { const blocks = []; let current = []; for (const entry of encoded) { current.push(entry); // v0.4.4-fix: 基于字节估算;单条超大条目(length===1)独立成块不强行切分 if (this.computeBlockSize(current) >= this.blockSizeLimit && current.length > 1) { blocks.push(current.slice(0, -1)); current = [entry]; } } if (current.length > 0) blocks.push(current); return blocks; } /** 块字节大小:entryCount(u32) + 每对 [keyLen(u32) + key + valueLen(u32) + value] */ computeBlockSize(block) { let size = 4; for (const e of block) { size += 4 + e.keyBytes.length + 4 + e.valueBytes.length; } return size; } writeDataBlock(view, offset, block, bloomFilter) { // entry count view.setUint32(offset, block.length, false); offset += 4; for (const e of block) { // v0.4.4-fix: 长度字段 u32(此前 u16 截断 >64KB 的 value) if (e.keyBytes.length > 0xFFFFFFFF || e.valueBytes.length > 0xFFFFFFFF) { throw new Error('SSTable entry too large (exceeds u32 length field)'); } view.setUint32(offset, e.keyBytes.length, false); offset += 4; new Uint8Array(view.buffer).set(e.keyBytes, offset); offset += e.keyBytes.length; view.setUint32(offset, e.valueBytes.length, false); offset += 4; new Uint8Array(view.buffer).set(e.valueBytes, offset); offset += e.valueBytes.length; // 插入 bloom filter bloomFilter.insert(e.key); } return offset; } estimateIndexBlockSize(entries) { // entryCount(u32) + each: keyLen(u32)+key+blockOffset(u32)+blockSize(u32) let size = 4; const encoder = new TextEncoder(); for (const entry of entries) { size += 4 + encoder.encode(entry.key).byteLength + 8; } return size; } writeIndexBlock(view, offset, entries) { view.setUint32(offset, entries.length, false); offset += 4; for (const entry of entries) { const encoder = new TextEncoder(); const keyBytes = encoder.encode(entry.key); view.setUint32(offset, keyBytes.length, false); offset += 4; new Uint8Array(view.buffer).set(keyBytes, offset); offset += keyBytes.length; view.setUint32(offset, entry.blockOffset, false); offset += 4; view.setUint32(offset, entry.blockSize, false); offset += 4; } return offset; } } /** * AriaEngine SSTable Reader — 从 SSTable 二进制数据中读取 * @module engine/aria/index/sstable * * v0.4.4: 支持 v1("SSTB",u16 长度字段)与 v2("SSTC",u32 长度字段)双格式, * 旧库 v1 文件仍可读(小 value 场景无缺陷),新写入使用 v2。 */ // --------------------------------------------------------------------------- // SSTableReader // --------------------------------------------------------------------------- class SSTableReader { constructor(data, meta) { this.indexEntries = []; this.entryCount = 0; this.bloomFilter = null; /** 格式版本:1 = u16 长度字段(旧),2 = u32 长度字段(v0.4.4) */ this.format = 2; /** footer 中存储的 checksum(0 = 旧版无校验文件) */ this.storedChecksum = 0; this.data = data; this.view = new DataView(data.buffer, data.byteOffset, data.byteLength); this.meta = meta; this.parseFooter(); } /** * 校验整文件 CRC-32(覆盖除 checksum 字段外的全部字节)。 * checksum === 0 表示旧版文件(v1 / v0.4.4 及更早的 v2),跳过校验返回 true(兼容)。 */ verifyChecksum() { if (this.storedChecksum === 0) return true; if (this.data.byteLength < 4) return false; const computed = crc32(this.data.subarray(0, this.data.byteLength - 4)); return computed === this.storedChecksum; } /** 长度字段宽度:v2 = 4 字节 u32,v1 = 2 字节 u16 */ lenFieldSize() { return this.format === 2 ? 4 : 2; } readLen(offset) { return this.format === 2 ? this.view.getUint32(offset, false) : this.view.getUint16(offset, false); } // ----------------------------------------------------------------------- // 查询 // ----------------------------------------------------------------------- /** 精确查找 key */ get(targetKey) { // Bloom Filter 快速否定 if (this.bloomFilter && !this.bloomFilter.mayContain(targetKey)) return null; const blockIdx = this.locateBlock(targetKey); if (blockIdx < 0) return null; for (const [key, value] of this.iterEntries(blockIdx, blockIdx)) { if (key === targetKey) return value; } return null; } /** 范围扫描 */ rangeScan(startKey, endKey, callback) { // v0.7.4: 包装惰性生成器(行为一致,消除双份解析循环) for (const [key, value] of this.scanLazy(startKey, endKey)) { callback(key, value); } } /** * v0.7.4: 惰性范围扫描 —— 生成器逐块逐条产出(真流式)。 * 提前终止时未消费的块不再解析,大表流式内存 O(1)。 */ *scanLazy(startKey, endKey) { if (this.indexEntries.length === 0) return; const startBlockIdx = Math.max(0, this.locateBlockGE(startKey)); // v0.6.1-fix(P0): 索引键是"块内最后一个 key"(builder 约定), // locateBlockLE 返回最后一个 tail <= endKey 的块,但下一个块(tail > endKey) // 可能仍包含 < endKey 的条目(如末块 t5:k49425..k49995 尾 key 是 t6 前缀)→ // 被排除导致范围扫描漏读尾部数据。多扫一个块,条目级过滤保证不丢。 const endBlockIdx = Math.min(this.indexEntries.length - 1, this.locateBlockLE(endKey) + 1); if (startBlockIdx < 0 || endBlockIdx < 0 || startBlockIdx > endBlockIdx) return; for (const [key, value] of this.iterEntries(startBlockIdx, endBlockIdx)) { if (key >= startKey && key <= endKey) yield [key, value]; } } /** 扫描所有条目 */ scanAll(callback) { for (const [key, value] of this.iterEntries(0, this.indexEntries.length - 1)) { callback(key, value); } } // ----------------------------------------------------------------------- // 统一解析(v0.8.0) // ----------------------------------------------------------------------- /** * v0.8.0(B-6):**唯一一份**块内条目解析实现。 * * 修复前这段解析被抄成三份(`get` / `scanLazy` / `scanAll`),并且三处的 * 越界策略不一致:点查与扫描对同一个损坏文件可能给出不同结论 *(审计:`sstable.ts:80-100/145-166/182-201`)。三份实现里只要有一处漏改, * 就会重新出现"同一文件在不同路径下读出不同数据"。 * * 现在的统一策略(对三个调用点完全一致): * - 块缺失/越界 → 跳过该块,继续后续块(不抛异常); * - 块内任一条目的长度字段越界 → 该块**剩余条目整体放弃**(截断块),继续后续块; * - 条目的 JSON 解析失败 → 跳过该条目(视为不存在),不中断其他条目。 * * @param fromBlock 起始块下标(含) * @param toBlock 结束块下标(含) */ *iterEntries(fromBlock, toBlock) { const lenSize = this.lenFieldSize(); const start = Math.max(0, fromBlock); const end = Math.min(toBlock, this.indexEntries.length - 1); const decoder = new TextDecoder(); for (let bi = start; bi <= end; bi++) { const blockData = this.getBlockData(this.indexEntries[bi]); if (!blockData) continue; const blockView = new DataView(blockData.buffer, blockData.byteOffset, blockData.byteLength); const blockEntryCount = blockView.getUint32(0, false); let offset = 4; for (let i = 0; i < blockEntryCount; i++) { if (offset + lenSize > blockData.byteLength) break; const keyLen = this.format === 2 ? blockView.getUint32(offset, false) : blockView.getUint16(offset, false); offset += lenSize; if (offset + keyLen + lenSize > blockData.byteLength) break; const key = decoder.decode(blockData.slice(offset, offset + keyLen)); offset += keyLen; const valLen = this.format === 2 ? blockView.getUint32(offset, false) : blockView.getUint16(offset, false); offset += lenSize; if (offset + valLen > blockData.byteLength) break; const valBytes = blockData.slice(offset, offset + valLen); offset += valLen; try { const value = JSON.parse(decoder.decode(valBytes)); yield [key, value]; } catch { // 损坏条目跳过(与三处调用点此前的"跳过损坏条目"策略一致) } } } } // ----------------------------------------------------------------------- // 内部 // ----------------------------------------------------------------------- parseFooter() { if (this.data.byteLength < 32) { throw new Error('SSTable too small: missing footer'); } const footerOffset = this.data.byteLength - 32; // 验证魔数(v1 "SSTB" / v2 "SSTC") const magic = this.view.getUint32(footerOffset + 24, false); if (magic === SSTABLE_MAGIC_V1) { this.format = 1; } else if (magic === SSTABLE_MAGIC_V2) { this.format = 2; } else { throw new Error(`Invalid SSTable magic: expected ${SSTABLE_MAGIC_V1} or ${SSTABLE_MAGIC_V2}, got ${magic}`); } const indexOffset = this.view.getUint32(footerOffset, false); const indexSize = this.view.getUint32(footerOffset + 4, false); const bloomOffset = this.view.getUint32(footerOffset + 8, false); const bloomSize = this.view.getUint32(footerOffset + 12, false); const bloomHashCount = this.view.getUint32(footerOffset + 16, false); this.entryCount = this.view.getUint32(footerOffset + 20, false); this.storedChecksum = this.view.getUint32(footerOffset + 28, false); // v0.4.1-fix: 完整性校验 — 索引块必须完全落在文件内,否则视为残缺文件跳过 if (indexOffset + 4 > this.data.byteLength || indexOffset + indexSize > this.data.byteLength) { return; // 残缺文件:无索引块可读,get/rangeScan 均返回空 } // 解析索引块 this.parseIndexBlock(indexOffset, indexSize); // 加载 Bloom Filter if (bloomOffset > 0 && bloomSize > 0 && bloomOffset + bloomSize <= this.data.byteLength) { try { const bloomBytes = this.data.slice(bloomOffset, bloomOffset + bloomSize); this.bloomFilter = BloomFilter.fromData(bloomBytes, bloomHashCount || 10); } catch { // 损坏的 bloom filter 不影响读取(仅跳过快速否定优化) } } } parseIndexBlock(offset, _size) { const entryCount = this.view.getUint32(offset, false); offset += 4; const lenSize = this.lenFieldSize(); for (let i = 0; i < entryCount; i++) { // v0.4.1-fix: 索引条目越界(keyLen/blockOffset/blockSize 超过文件长度)时中止解析, // 已解析的有效条目仍可用于查询 if (offset + lenSize > this.data.byteLength) break; const keyLen = this.readLen(offset); offset += lenSize; if (offset + keyLen + 8 > this.data.byteLength) break; const key = new TextDecoder().decode(this.data.slice(offset, offset + keyLen)); offset += keyLen; const blockOffset = this.view.getUint32(offset, false); offset += 4; const blockSize = this.view.getUint32(offset, false); offset += 4; // 跳过指向文件外的块(残缺写入产物),不抛异常 if (blockSize === 0 || blockOffset + blockSize > this.data.byteLength) continue; this.indexEntries.push({ key, blockOffset, blockSize }); } } /** * v0.4.1-fix: 获取索引条目对应的数据块。 * 块偏移/大小越界(残缺 SSTable)时返回 null,由调用方跳过而非抛 RangeError。 */ getBlockData(entry) { if (entry.blockSize <= 0 || entry.blockOffset < 0) return null; if (entry.blockOffset + entry.blockSize > this.data.byteLength) return null; return new Uint8Array(this.data.buffer, this.data.byteOffset + entry.blockOffset, entry.blockSize); } /** 二分查找某 key 所在的 block 索引 */ locateBlock(key) { let lo = 0; let hi = this.indexEntries.length - 1; while (lo <= hi) { const mid = Math.floor((lo + hi) / 2); const entry = this.indexEntries[mid]; if (key <= entry.key) { // 检查是否在此 block 范围内 const firstKey = mid === 0 ? '' : this.indexEntries[mid - 1].key; if (key > firstKey) return mid; hi = mid - 1; } else { lo = mid + 1; } } return -1; } locateBlockGE(key) { let lo = 0, hi = this.indexEntries.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (this.indexEntries[mid].key < key) lo = mid + 1; else hi = mid; } return lo < this.indexEntries.length ? lo : this.indexEntries.length - 1; } locateBlockLE(key) { let lo = 0, hi = this.indexEntries.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (this.indexEntries[mid].key <= key) lo = mid + 1; else hi = mid; } return lo > 0 ? lo - 1 : 0; } } /** * AriaEngine Merge Iterator — 多路归并迭代器 * @module engine/aria/index/merge_iterator * * 对多个有序 SSTable 或 MemTable 的结果进行归并去重(保留最新值)。 */ /** 数组数据源的迭代器 */ class ArrayEntrySource { constructor(entries) { this.index = 0; this.entries = entries; } next() { if (this.index >= this.entries.length) return null; return this.entries[this.index++]; } reset() { this.index = 0; } } /** * v0.7.4: 生成器数据源 —— 惰性迭代(真流式扫描)。 * MergeIterator 的 next() 逐条拉取,生成器按需产出(findStream 提前终止时 * 未消费部分不再物化,大表流式内存 O(1))。 */ class GeneratorEntrySource { constructor(iter) { this.iter = iter; } next() { const r = this.iter.next(); return r.done ? null : r.value; } reset() { // 生成器不可重置;MergeIterator 无 reset 消费方,接口保留 } } /** 最小堆 */ class MinHeap { constructor() { this.heap = []; } push(node) { this.heap.push(node); this.bubbleUp(this.heap.length - 1); } pop() { if (this.heap.length === 0) return null; if (this.heap.length === 1) return this.heap.pop(); const result = this.heap[0]; this.heap[0] = this.heap.pop(); this.bubbleDown(0); return result; } peek() { return this.heap.length > 0 ? this.heap[0] : null; } get size() { return this.heap.length; } bubbleUp(idx) { while (idx > 0) { const parent = Math.floor((idx - 1) / 2); if (this.heap[idx].key >= this.heap[parent].key) break; [this.heap[idx], this.heap[parent]] = [this.heap[parent], this.heap[idx]]; idx = parent; } } bubbleDown(idx) { const n = this.heap.length; while (true) { let smallest = idx; const left = 2 * idx + 1; const right = 2 * idx + 2; if (left < n && this.heap[left].key < this.heap[smallest].key) smallest = left; if (right < n && this.heap[right].key < this.heap[smallest].key) smallest = right; if (smallest === idx) break; [this.heap[idx], this.heap[smallest]] = [this.heap[smallest], this.heap[idx]]; idx = smallest; } } } // --------------------------------------------------------------------------- // MergeIterator // --------------------------------------------------------------------------- /** * 对多个有序数据源进行归并,重复 key 保留最新(后出现的)。 * 数据源按新鲜度排序:越新的数据源在下标越小(如 MemTable 在 SSTable 之前)。 */ class MergeIterator { constructor() { /** * v0.8.0(B-6/47):上一次返回的条目所属来源 —— 它的下一条**推迟到下次 next()** * 才拉取。 * * 修复前是"弹出堆顶后立刻补充该来源的下一条",于是**消费者只取 N 条, * 底层生成器却已经产出 N+1 条**(审计实测:limit=5 的流式扫描多算 1 条)。 * 在真惰性扫描里这不是纯性能问题:多拉的那一条会解析一整个 SSTable 块, * 也让"提前终止时未消费部分不再解析"的宣称不完全成立。 * * 延迟补充是安全的:单个来源内部 key 唯一且有序,因此被弹出条目的后续 * key 必然大于当前 key,不可能参与本次的重复 key 归并。 */ this.pendingRefill = null; this.sources = []; this.heap = new MinHeap(); } /** 添加数据源 */ addSource(source) { this.sources.push(source); this.seedFromSource(this.sources.length - 1); } /** 获取下一个归并后的条目 */ next() { // 上一轮被延迟的补充:现在才真正拉取(见 pendingRefill 说明) if (this.pendingRefill !== null) { const sourceIndex = this.pendingRefill; this.pendingRefill = null; this.seedFromSource(sourceIndex); } if (this.heap.size === 0) return null; const first = this.heap.pop(); const key = first.key; let best = first; // 跳过重复 key:在多个来源中保留 sourceIndex 最小(最新)的条目。 // 重复条目必须**立即**从各自来源补充(否则它们会永久占住堆顶)。 while (this.heap.peek() && this.heap.peek().key === key) { const dup = this.heap.pop(); this.seedFromSource(dup.sourceIndex); if (dup.sourceIndex < best.sourceIndex) { best = dup; } } // 胜出来源的补充推迟到下一次 next()(消费者只取 N 条 → 底层只产出 N 条) this.pendingRefill = first.sourceIndex; return [best.key, best.value]; } /** 耗尽管道,返回所有归并结果 */ drain() { const result = []; let entry = this.next(); while (entry) { result.push(entry); entry = this.next(); } return result; } seedFromSource(sourceIndex) { const entry = this.sources[sourceIndex].next(); if (entry) { this.heap.push({ key: entry[0], value: entry[1], sourceIndex, }); } } } /** * AriaEngine LSM-Tree — 日志结构合并树 * @module engine/aria/index/lsm * * 管理 MemTable + 多级 SSTable 的读写和 Compaction。 * * v0.2.1: 完整持久化 — SSTable 元数据和数据均存入存储后端, * 启动时自动扫描并加载所有 SSTable。 * * v0.8.0(B-6)本层根治的四类结构缺陷: * 1. **冻结表没有重试路径**(审计 44):后台 flush 失败后数据只存在于内存, * 而 `flush()` 又因"后台错误检查在入链之前"直接抛错跳过本次刷盘 → 崩溃即丢。 * 现在冻结表是一等状态(`FrozenTable`),失败后保留并可由后续 `flush()` * 重新入链;错误在**完成入链之后**才报告。 * 2. **compaction 整层摘除**(审计 50):`splice()` 先摘掉整层再去加载/合并, * 长 await 窗口内该层对读者不可见(静默少行)。现在旧 meta 在合并提交前 * 一直留在 `levels` 中,提交点之后再原子替换。 * 3. **在途读者与物理删除竞态**:读路径是"取 meta 快照 → 加载文件", * 中间可插入 compaction。现在用**读者 epoch** 延迟物理删除: * 只要有更早进入的读者仍在进行,被取代的文件就不删。 * 4. **墓碑/历史版本永不回收**(审计 51):底部层新增原地合并(drop tombstones), * 删除密集场景的空间不再无界增长;同时 `compacting` 由单 boolean 改为 * **按层集合**(审计 49),跨层触发不再被静默丢弃。 * * 另外把"读路径必须先 prefetch"这条隐式约定彻底删除(审计 55 的另一半): * 读取自洽(未命中即回源 + CRC 校验)后,引擎层的 `drainChain()+prefetch*` * 全部消失,checkpoint 也不必再等完整 compaction。 */ /** 单次 flush 的最大尝试轮数(每轮把所有未落盘冻结表重新入链一次) */ const MAX_FLUSH_RETRY_ROUNDS = 3; /** 触发 compaction 的文件数门槛(自动调度) */ const COMPACT_TRIGGER_FILES = 4; /** 单层积压过多时的背压门槛 */ const BACKPRESSURE_FILES = 8; /** * 读路径"结构版本一致"的最大重试次数(v0.8.0)。 * 超过则退回到"等后台链静默"的保守读取(见 collectReaders)。 */ const MAX_READ_STRUCTURE_RETRIES = 16; /** 保留的后台故障诊断条数上限(v0.8.0 review 修复:防止无界增长) */ const MAX_BACKGROUND_WARNINGS = 64; // --------------------------------------------------------------------------- // LSM // --------------------------------------------------------------------------- class LSM { constructor(config) { this.immutableMemtable = null; /** 所有 pending flush 的 frozen memtable(含 immutableMemtable,旧→新) */ this.frozenMemtables = []; this.frozenIdCounter = 0; this.levels = []; this.sstableCache = new Map(); this.cacheSize = 0; /** v0.8.0: 超过缓存上限、不进入 LRU 的 SSTable(每次读取按需加载) */ this.oversizedSSTables = new Set(); this.operationCount = 0; this.initialized = false; /** v0.8.0(审计 49):按层的 compaction 进行集合(此前单 boolean 会静默丢弃跨层触发) */ this.compacting = new Set(); /** 串行化 memtable flush 链:保证持久化顺序与 id 分配顺序一致 */ this.flushChain = Promise.resolve(); /** * v0.8.0(审计 55):compaction 独立于 flush 的维护链。 * * 修复前二者共用一条链,于是"每 1000 次操作触发的 checkpoint"必须等 * 完整 compaction(含级联)跑完 —— 这就是 v0.6.1 记录的"8~11s 悬崖"。 * 现在 flush 只等自己的任务;只有 close/repair 这类需要完全静默的路径 * 才等待维护链。 */ this.maintenanceChain = Promise.resolve(); /** * v0.4.3-fix: 最近一次后台 flush/compaction 失败。 * 后台失败不卡死链(吞错防死锁),但在显式 flush()/close() 时报告(不静默)。 */ this.lastBackgroundError = null; /** v0.8.0:已被重试修复的后台故障(不阻塞 flush,但要可见) */ this.backgroundWarnings = []; /** v0.8.0:读者 epoch —— 用于延迟物理删除被 compaction 取代的文件 */ this.readEpoch = 0; /** v0.8.0:读路径结构版本重试计数(诊断 + 回归断言用) */ this.readStructureRetries = 0; this.activeReaders = new Map(); /** v0.8.0:已从 levels 摘除、等待"没有更早读者"后再物理删除的 SSTable */ this.retired = []; /** * v0.8.0:`levels` 结构版本 —— 每次有 SSTable 发布/摘除就 +1。 * * 为什么必须有它:读路径要"取 meta 快照 → await 加载文件",而 flush 可以在 * 任意 await 点把新 SSTable 发布到 `levels[0]` **并同时**把它从 * `frozenMemtables` 摘掉。此时本次读既没有在快照里看到新 SSTable、 * 又不再能从前台冻结表读到那批数据 —— **刚写入的行在这一次扫描里凭空消失** *(实测:缓存驱逐场景下 300 行全部改名,紧接着的全表扫描有 25 行仍是旧值; * 几毫秒后同一 key 又能读出新值)。 * * 旧实现靠"读之前先 drainChain + prefetch"回避了这个窗口(代价是每次读都要 * 等后台链)。现在改为**乐观重试**:加载完读取器后若结构版本变了就重来; * 极端情况下(写入持续不断)才退回到"等 flush/维护链静默"的保守路径。 */ this.structureVersion = 0; this.memtableSizeThreshold = config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE; this.memtable = new MemTable(this.memtableSizeThreshold); this.levelSizeMultiplier = config.levelSizeMultiplier ?? DEFAULT_LEVEL_SIZE_MULTIPLIER; this.blockSize = config.blockSize ?? 4096; this.sstableStore = config.sstableStore; this.cacheLimitBytes = config.cacheLimitBytes ?? 64 * 1024 * 1024; this.namespace = config.namespace ?? 'main'; this.walLsnProvider = config.walLsnProvider ?? (() => 0); this.requireDurableCoverage = config.requireDurableCoverage ?? false; this.bloomBitsPerKey = Number.isFinite(config.bloomBitsPerKey) && config.bloomBitsPerKey > 0 ? Math.floor(config.bloomBitsPerKey) : DEFAULT_BLOOM_BITS_PER_KEY; this.recoveryReport = { namespace: this.namespace, droppedSSTables: [], dataLossSuspected: false, }; for (let i = 0; i < MAX_LSM_LEVELS; i++) { this.levels.push([]); } } // ======================================================================= // 初始化:从存储后端加载 SSTable 元数据 // ======================================================================= async init() { if (this.initialized) return; const metas = await this.sstableStore.listMeta(); // v0.4.2-fix: 打开时完整性校验 — 验证每个 meta 引用的文件存在、可解析, // 残缺/损坏的 SSTable 忽略并清理 meta,避免后续读取抛 RangeError 崩溃。 // v0.8.0(B-6):清理动作进恢复报告;WAL 已被截断(requireDurableCoverage)时 // 标记 dataLossSuspected —— 数据真的没了,必须让上层看得见。 const validMetas = []; for (const meta of metas) { if (await this.validateSSTable(meta)) { validMetas.push(meta); } } // 按层级分组 for (const meta of validMetas) { if (meta.level >= 0 && meta.level < MAX_LSM_LEVELS) { this.levels[meta.level].push(meta); } } // 各层级按 id 降序排列:id 越大越新,保证读取时"最新优先" // (flush/compaction 产物均插入数组头部,数组头部即最新) for (let i = 0; i < MAX_LSM_LEVELS; i++) { this.levels[i].sort((a, b) => b.id - a.id); } // v0.8.0:此时已不再需要"先 prefetch 再读"的隐式约定 —— // 读取自洽(缓存未命中即回源),因此打开时不预加载任何 SSTable。 this.initialized = true; this.structureVersion++; } // ======================================================================= // 写入 // ======================================================================= put(key, value) { // 写背压:level 0 SSTable 过多时排队 compaction 缓解压力 if (this.levels[0].length >= BACKPRESSURE_FILES) { this.enqueueCompact(0); } this.memtable.put(key, value); this.operationCount++; if (this.memtable.shouldFlush()) { this.freezeMemtable(); } } delete(key) { if (this.levels[0].length >= BACKPRESSURE_FILES) { this.enqueueCompact(0); } this.memtable.put(key, { __tombstone: true }); this.operationCount++; if (this.memtable.shouldFlush()) { this.freezeMemtable(); } } /** v0.8.0: 当前 SSTable 缓存占用字节数(公开访问器,替代测试直接读私有字段) */ getCacheSize() { return this.cacheSize; } /** v0.8.0: 当前缓存上限(字节) */ getCacheLimit() { return this.cacheLimitBytes; } /** v0.8.0: 常驻(超过缓存上限、不参与驱逐)的 SSTable 数量 */ getOversizedCount() { return this.oversizedSSTables.size; } /** v0.8.0: 运行期调整缓存上限(测试与内存预算调优用),立即裁剪到新上限 */ setCacheLimit(bytes) { this.cacheLimitBytes = Math.max(0, bytes); this.trimCache(); } /** 获取估算内存使用(字节) */ getEstimatedMemory() { let mem = this.memtable.getEstimatedSize(); for (const frozen of this.frozenMemtables) mem += frozen.memtable.getEstimatedSize(); mem += this.cacheSize; return mem; } /** * 冻结当前 MemTable 为 immutable,并在串行链上排队异步刷盘。 * 冻结的 MemTable 通过闭包捕获,避免链中前一个 flush 错误处理后续冻结的表。 * 所有 pending frozen 记录在 frozenMemtables 中,flush 完成前读取路径仍可访问。 * * v0.4.2-fix: 链上任务失败时吞错恢复链(否则 flushChain 永久 rejected, * 后续所有 flush/compaction 挂起,写路径卡死)。 */ freezeMemtable() { if (this.immutableMemtable) { this.enqueueFlush(this.immutableMemtable); } const frozen = { id: ++this.frozenIdCounter, memtable: this.memtable, lsnAtFreeze: this.walLsnProvider(), queued: false, committing: false, attempts: 0, lastError: null, }; this.immutableMemtable = frozen; this.frozenMemtables.push(frozen); // v0.4.2-fix: 新 memtable 用配置阈值(此前传旧表已用大小 → 阈值逐次衰减 → 频繁小文件 flush) this.memtable = new MemTable(this.memtableSizeThreshold); // v0.8.0:**前台变化也算结构变化**。读路径的"结构版本一致"必须覆盖 // memtable/frozen 这一侧:冻结会把数据从活跃 memtable 挪到 frozen 列表, // 若版本号不变,在途读者(已经做过前台检查、正在加载 SSTable)会认为 // "什么都没变"→ 既不重试、又读不到刚挪走的数据 → 返回旧值/漏行。 this.structureVersion++; } /** * v0.8.0:把一张冻结表入链(幂等 —— 已在链上的表不会重复入链)。 * * 失败语义:任务失败时把错误记在冻结表上(`lastError`)并继续抛给链的 * catch(记录 `lastBackgroundError`),**冻结表本身保留** —— 于是下一次 * `flush()` 可以把它重新入链重试(修复前失败即永久失去落盘机会)。 */ enqueueFlush(frozen) { if (frozen.queued) return; frozen.queued = true; this.flushChain = this.enqueueOnChain(async () => { frozen.attempts++; try { await this.flushImmutableAsync(frozen); frozen.lastError = null; } catch (error) { frozen.lastError = error; throw error; } finally { frozen.queued = false; } }); } /** v0.4.2-fix: 在串行链上排队任务;任务失败吞错并记录,保证链不被单次失败卡死 */ enqueueOnChain(task) { return this.flushChain .then(task) .catch((error) => { // v0.4.3-fix: 记录失败(flush()/close() 时报告),不再完全静默吞错 this.lastBackgroundError = error; // eslint-disable-next-line no-console console.warn('[AriaEngine LSM] background flush/compaction failed:', error); // catch 返回 undefined → 链恢复为 resolved,后续任务继续 }); } /** v0.8.0:维护(compaction)链的入链(失败同样不卡死链) */ enqueueMaintenance(task) { this.maintenanceChain = this.maintenanceChain .then(task) .catch((error) => { this.lastBackgroundError = error; // eslint-disable-next-line no-console console.warn('[AriaEngine LSM] background compaction failed:', error); }); } /** * v0.4.3-fix: 排空后台链 — 循环等待 flushChain 直到稳定。 * 任务完成时可能级联调度新任务(compaction 多级触发),单次 await 等不到。 * close/flush/clear 必须等待全部后台任务完成后才能安全关闭底层存储。 */ async drainChain() { while (true) { const chain = this.flushChain; await chain; if (this.flushChain === chain) break; } } /** v0.8.0:排空维护链(compaction) */ async drainMaintenance() { while (true) { const chain = this.maintenanceChain; await chain; if (this.maintenanceChain === chain) break; } } /** 将指定冻结表刷盘为 SSTable(id 由 store 按命名空间分配) */ async flushImmutableAsync(frozen) { const entries = frozen.memtable.getAllEntries(); if (entries.length === 0) { if (this.immutableMemtable === frozen) this.immutableMemtable = null; this.removeFrozen(frozen); return; } const id = await this.sstableStore.allocateId(); const builder = new SSTableBuilder(this.blockSize, this.bloomBitsPerKey); for (const [key, value] of entries) { builder.add(key, value); } const { sstableData, indexEntries } = builder.build(); const meta = { id, level: 0, minKey: entries[0][0], maxKey: entries[entries.length - 1][0], blockCount: indexEntries.length, // v0.8.0(A38):先留 0,落盘后用实际存储长度回填(见下) totalSize: sstableData.byteLength, bloomData: null, }; // 持久化:先存数据(落盘完成才返回),再提交元数据(manifest 提交点) // v0.8.0:缓存放到落盘成功**之后** —— 修复前先缓存再落盘,失败重试会留下 // 永不使用却占内存的缓存条目。 const stored = await this.sstableStore.save(id, sstableData); if (!stored || typeof stored.storedSize !== 'number') { throw new DatabaseError(`SSTableStore.save() returned ${JSON.stringify(stored)} for namespace "${this.namespace}" ` + `(id=${id}, bytes=${sstableData.byteLength}) — 存储实现违反了 save() 契约`, 'ARIA_SSTABLE_SAVE_CONTRACT'); } meta.totalSize = stored.storedSize; // 提交窗口:本表的数据由这次 manifest 提交负责 → 不再出现在 frozen 意图里 frozen.committing = true; try { await this.sstableStore.saveMeta(meta); } finally { frozen.committing = false; } // 提交成功后才在内存中可见 → 读路径不会看到"元数据已提交但文件未落盘" this.levels[0].unshift(meta); this.structureVersion++; this.tryCacheSSTable(id, sstableData); this.trimCache(); if (this.immutableMemtable === frozen) this.immutableMemtable = null; this.removeFrozen(frozen); // 异步触发 compaction(不阻塞当前写入) if (this.levels[0].length >= COMPACT_TRIGGER_FILES) { this.scheduleCompact(0); } } /** * 从 pending 列表移除冻结表。 * * 这里**不**单独 bump 结构版本:调用方只有两处 —— * ① 落盘成功(同一步里 `levels[0].unshift(meta)` 已经 bump); * ② 空表直接丢弃(没有任何数据,读者观察不到差异)。 * 多余的版本号增长只会让读路径白白重试。 */ removeFrozen(frozen) { this.frozenMemtables = this.frozenMemtables.filter((f) => f !== frozen); } // ======================================================================= // Compaction // ======================================================================= /** * 执行 Compaction(public,供 VACUUM 等外部调用;默认 2 个文件即可压缩)。 * * @returns 是否**真的发生了一次合并**(VACUUM 用它统计真实压缩层数; * 修复前 VACUUM 无论是否合并都硬编码报告 6 层) */ async compactLevel(level, minFiles = 2) { // VACUUM 是显式维护操作:直接等待(与后台调度区分) return this.compactLevelAsync(level, minFiles); } /** * 异步调度 compaction。 * v0.4.3-fix: 去掉 setTimeout 分片 — 此前未触发的定时器在 close 后执行, * 用已关闭的 backend 写存储(错误被吞),或 close 后 reopen 时旧闭包引用新 backend 交叉污染。 * 现在直接挂在维护链上:串行执行、close 的 drainMaintenance 能等到全部完成。 */ scheduleCompact(level) { if (level > MAX_LSM_LEVELS - 1 || this.compacting.has(level)) return; this.compacting.add(level); this.enqueueMaintenance(async () => { try { await this.compactLevelAsync(level); } finally { this.compacting.delete(level); // 连续触发:如果 compaction 后仍然超标,继续调度 if (this.levels[level].length >= COMPACT_TRIGGER_FILES) { this.scheduleCompact(level); } // 检查下一级是否需要 compaction(底部层的"原地合并"同样允许) if (level + 1 <= MAX_LSM_LEVELS - 1 && this.levels[level + 1].length >= COMPACT_TRIGGER_FILES) { this.scheduleCompact(level + 1); } } }); } /** * v0.8.0(review 修复):显式维护(VACUUM)的逐层压缩 —— **与后台 compaction 串行**。 * * 为什么必须串行:compaction 产物一律 `unshift` 到目标层队首,层内数组顺序即 * "新旧顺序"。若 VACUUM 直接 `await compactLevel()`(绕过维护链),它可能与 * 后台 compaction 同时向**同一层**写产物 —— 旧数据的产物被插到新数据之前, * 读路径取层内第一个命中 → 读到旧值;底部层还会丢墓碑 → 已删除的行复活。 * * 实现:每个层的压缩都作为维护链上的一个任务执行并等待其完成(复用 * `compacting` 集合防重入),因此永远不会与后台 compaction 交错。 * * @returns 真实发生合并的层数 */ async vacuumLevels() { let compacted = 0; for (let level = 0; level < MAX_LSM_LEVELS; level++) { // 底部层即使只有 1 个文件也要合并:那正是"墓碑/历史版本回收"的唯一时机 const minFiles = level === MAX_LSM_LEVELS - 1 ? 1 : 2; // 该层已有 compaction 在跑 → 先等维护链静默(不打断、不并入) if (this.compacting.has(level)) await this.drainMaintenance(); if (this.levels[level].length < minFiles) continue; this.compacting.add(level); await new Promise((resolve) => { this.enqueueMaintenance(async () => { try { if (await this.compactLevelAsync(level, minFiles)) compacted++; } finally { this.compacting.delete(level); resolve(); } }); }); } return compacted; } /** 背压场景下排队 compaction(写入路径调用) */ enqueueCompact(level) { if (level > MAX_LSM_LEVELS - 1 || this.compacting.has(level)) return; this.compacting.add(level); this.enqueueMaintenance(async () => { try { await this.compactLevelAsync(level); } finally { this.compacting.delete(level); } }); } // ======================================================================= // 读取 // ======================================================================= /** v0.8.0:登记一次读操作(epoch),返回 epoch */ enterRead() { const epoch = ++this.readEpoch; this.activeReaders.set(epoch, (this.activeReaders.get(epoch) ?? 0) + 1); return epoch; } /** v0.8.0:读操作结束;顺带回收已无在途读者的退休文件 */ exitRead(epoch) { const count = this.activeReaders.get(epoch); if (count === undefined) return; if (count <= 1) this.activeReaders.delete(epoch); else this.activeReaders.set(epoch, count - 1); this.reclaimRetired(); } /** v0.8.0:在**没有任何 await** 的同步片段里取 meta 快照。 * * 必须同步取快照的原因:读路径后面要 `await` 加载 SSTable,而 compaction * 可以在任意 await 点改变 `levels`。若边遍历边 await,遍历会看到半更新的 * 层数组(漏文件/重复文件)。快照 + epoch 保护把这件事变成显式的: * 快照期间的层内容由 `retired` 机制保证文件不会被删。 * * 注意:快照本身不足以保证"完整"(并发 flush 可能在加载期间发布新 SSTable), * 因此调用方必须配合 `structureVersion` 做乐观重试 —— 见 `collectReaders`。 */ snapshotMetas(startKey, endKey) { const out = []; for (let level = 0; level < MAX_LSM_LEVELS; level++) { for (const meta of this.levels[level]) { if (endKey < meta.minKey || startKey > meta.maxKey) continue; out.push(meta); } } return out; } /** * v0.8.0:取一个**自洽**的读取器集合(含并发 flush/compaction 的正确性)。 * * 算法:快照 → 加载 → 若期间结构变过则重来。重试只由"确有新产物发布"触发, * 因此正常情况一次成功;写入极端密集时退回到"等 flush/维护链静默"的保守路径 * (等价于旧实现的行为,但只在极少数情况下付出这个代价)。 */ async collectReaders(startKey, endKey) { for (let attempt = 0; attempt < MAX_READ_STRUCTURE_RETRIES; attempt++) { const version = this.structureVersion; const metas = this.snapshotMetas(startKey, endKey); const readers = []; for (const meta of metas) { const reader = await this.loadSSTableReader(meta); if (reader) readers.push(reader); } if (version === this.structureVersion) return readers; this.readStructureRetries++; } // 保守回退:等两条链静默后再取一次(此时不可能再有结构变化) await this.drainChain(); await this.drainMaintenance(); const readers = []; for (const meta of this.snapshotMetas(startKey, endKey)) { const reader = await this.loadSSTableReader(meta); if (reader) readers.push(reader); } return readers; } /** v0.8.0:回收退休 SSTable —— 只有当"可能持有它们的读者"全部退出后才物理删除 */ reclaimRetired() { if (this.retired.length === 0) return; let minActive = Infinity; for (const epoch of this.activeReaders.keys()) { if (epoch < minActive) minActive = epoch; } const keep = []; const toDelete = []; for (const group of this.retired) { // 该组在 epoch=group.epoch 时被摘除:所有更早进入的读者都可能持有它 if (group.epoch < minActive) toDelete.push(...group.metas); else keep.push(group); } this.retired = keep; if (toDelete.length === 0) return; void (async () => { for (const meta of toDelete) { this.sstableCache.delete(meta.id); this.oversizedSSTables.delete(meta.id); try { await this.sstableStore.delete(meta.id); } catch (error) { // 物理删除失败只造成空间残留(repair 可回收),不影响正确性 // eslint-disable-next-line no-console console.warn(`[AriaEngine LSM] retired SSTable id=${meta.id} deletion failed:`, error); } } })(); } /** * v0.8.0(review 修复):当前是否有在途读者。 * * 用途:维护路径(repair 的强制回收 / 孤儿清理)必须在没有读者时才能删文件 —— * 读者的快照可能正持有已被 compaction 取代的"退休"文件。 */ hasActiveReaders() { return this.activeReaders.size > 0; } /** v0.8.0:退休但尚未物理删除的 SSTable 所占用的页面 id(孤儿页回收必须把它们算作"在用") */ getRetiredPageIds() { const out = []; for (const group of this.retired) { for (const meta of group.metas) { if (meta.pageIds) out.push(...meta.pageIds); } } return out; } /** v0.8.0:退休但尚未物理删除的 SSTable id(孤儿整 value 文件回收必须排除它们) */ getRetiredSstableIds() { const out = []; for (const group of this.retired) { for (const meta of group.metas) out.push(meta.id); } return out; } /** * v0.8.0:等待两条链静默(诊断与测试用)。 * * 生产语义上等价于"当前没有在跑的后台 flush/compaction";测试用它替代 * `setTimeout(N)` 这类墙钟等待(sleep 不够会误报成产品缺陷)。 */ async whenIdle() { await this.drainChain(); await this.drainMaintenance(); } /** v0.8.0(测试/诊断):退休但尚未物理删除的 SSTable 数量 */ getRetiredCount() { return this.retired.reduce((sum, g) => sum + g.metas.length, 0); } /** * v0.8.0:**强制**回收全部退休 SSTable(不等更早的读者退出)。 * * 只允许在"确定没有在途读者"的维护路径上调用(`repair()` 会先排空两条链)。 * 语义:把这些文件当作已无引用的垃圾删除,并清空退休登记。 */ reclaimRetiredNow() { if (this.retired.length === 0) return; // v0.8.0(review 修复):有在途读者时**不得**强制删除 —— 读者的快照可能正持有 // 这些文件,删掉会让它读不到数据(而且会被误判为"文件损坏")。此时退化为 // 延迟回收(等读者退出后由 reclaimRetired 处理)。 if (this.activeReaders.size > 0) { this.reclaimRetired(); return; } const all = this.retired.flatMap((g) => g.metas); this.retired = []; void (async () => { for (const meta of all) { this.sstableCache.delete(meta.id); this.oversizedSSTables.delete(meta.id); try { await this.sstableStore.delete(meta.id); } catch { /* 残留由 repair 的孤儿回收处理 */ } } })(); } /** * v0.8.0: 改为 async —— SSTable 部分未命中缓存时会 `await` 回源, * 因此读取不再依赖调用方的 prefetch(消除"缓存未命中即静默丢数据")。 * * v0.8.0:整体包在"结构版本一致"的重试里(见 collectReaders)—— * 前台(memtable/frozen)与后台(SSTable)两侧必须在同一个结构版本下读, * 否则并发 flush 会让刚写的数据在这一刻既不在前台也不在快照里。 */ async get(key) { for (let attempt = 0; attempt < MAX_READ_STRUCTURE_RETRIES; attempt++) { // 1. 活跃 MemTable let result = this.memtable.get(key); if (result !== null) return this.unwrapTombstone(result); // 2. pending frozen memtables(从新到旧) for (let i = this.frozenMemtables.length - 1; i >= 0; i--) { result = this.frozenMemtables[i].memtable.get(key); if (result !== null) return this.unwrapTombstone(result); } // 3. SSTable(从 Level 0 到 Level N-1)—— 快照 + 加载 + 版本校验 const version = this.structureVersion; const epoch = this.enterRead(); try { const metas = this.snapshotMetas(key, key); const readers = []; for (const meta of metas) { const reader = await this.loadSSTableReader(meta); if (reader) readers.push(reader); } if (version !== this.structureVersion) { this.readStructureRetries++; continue; // 期间有结构变化(新产物发布 / 前台冻结)→ 重来 } for (const reader of readers) { const found = reader.get(key); if (found !== null) return this.unwrapTombstone(found); } return null; } finally { this.exitRead(epoch); } } // 保守回退:等两条链静默后按稳定结构读一次 await this.drainChain(); await this.drainMaintenance(); let result = this.memtable.get(key); if (result !== null) return this.unwrapTombstone(result); for (let i = this.frozenMemtables.length - 1; i >= 0; i--) { result = this.frozenMemtables[i].memtable.get(key); if (result !== null) return this.unwrapTombstone(result); } for (const meta of this.snapshotMetas(key, key)) { const reader = await this.loadSSTableReader(meta); if (!reader) continue; const found = reader.get(key); if (found !== null) return this.unwrapTombstone(found); } return null; } async rangeScan(startKey, endKey) { const result = []; await this.rangeScanLazy(startKey, endKey, (k, v) => { result.push([k, v]); }); return result; } /** * 惰性范围扫描:通过回调逐条返回,不一次性物化。 * v0.7.4: 真惰性 —— 各源(MemTable/frozen/SSTable)以生成器接入 MergeIterator, * 逐条拉取;回调返回 false 时提前终止(未消费部分不再解析/物化)。 * v0.8.0: 快照式读者 epoch —— 扫描期间被 compaction 取代的文件保持可读, * 因此"扫到一半 compaction 完成"不会让结果少行。 */ async rangeScanLazy(startKey, endKey, callback) { const epoch = this.enterRead(); try { // 「前台与后台必须来自同一个结构版本」这条不变量由 `collectReaders` 负责 //(它内部做快照 → 加载 → 版本校验 → 重试)。这里**不再重复校验一次**: // 重复的检查在语义上是冗余的(从 collectReaders 返回到源添加之间没有 await, // 结构不可能变化),而"看起来需要但实际不生效"的代码只会误导后来者。 const readers = await this.collectReaders(startKey, endKey); // 从这里到源添加完成**不得有 await**(否则前台列表可能在中间变化) const mergeIter = new MergeIterator(); mergeIter.addSource(new GeneratorEntrySource(this.memtable.scanLazy(startKey, endKey))); // pending frozen memtables(从新到旧,新数据 sourceIndex 更小) for (let i = this.frozenMemtables.length - 1; i >= 0; i--) { mergeIter.addSource(new GeneratorEntrySource(this.frozenMemtables[i].memtable.scanLazy(startKey, endKey))); } for (const reader of readers) { mergeIter.addSource(new GeneratorEntrySource(reader.scanLazy(startKey, endKey))); } let entry = mergeIter.next(); while (entry) { const [k, v] = entry; if (!v.__tombstone) { const cont = callback(k, v); // v0.7.4: 提前终止(流式 limit 达成) if (cont === false) return; } entry = mergeIter.next(); } } finally { this.exitRead(epoch); } } // ======================================================================= // Compaction 实现 // ======================================================================= /** * 串行执行 Compaction。 * @param minFiles 触发压缩的文件数门槛(自动调度用 4,VACUUM 用 2) * * v0.4.2-fix: 读取从存储兜底(不依赖缓存)——此前仅从缓存读, * 缓存未命中(LRU 驱逐/单文件超缓存上限)时跳过全部文件并从 levels 移除, * 运行中数据全部不可见。 * * v0.8.0(审计 50/51)两处结构改动: * - **不再 `splice` 整层**:合并期间旧 meta 留在 `levels`(读者始终看得到完整数据), * 合并提交后才按 id 原子摘除,同窗口内新 flush 进来的产物不受影响; * - **底部层原地合并 + 回收墓碑**:`level === MAX_LSM_LEVELS - 1` 时输出回同层, * 并丢弃墓碑(底部层没有更老的数据,丢墓碑不会复活已删除行)。 */ async compactLevelAsync(level, minFiles = COMPACT_TRIGGER_FILES) { if (level > MAX_LSM_LEVELS - 1) return false; if (this.levels[level].length < minFiles) return false; const isBottomLevel = level === MAX_LSM_LEVELS - 1; const targetLevel = isBottomLevel ? level : level + 1; // 选择源文件:**整层**合并(保持"层内键范围完整"这一不变量)。 // 用快照 + 提交后按 id 摘除,而不是先 splice 再慢慢加载。 const selected = [...this.levels[level]]; const selectedIds = new Set(selected.map((m) => m.id)); const mergeIter = new MergeIterator(); const loadedMetas = []; for (const meta of selected) { // 优先缓存,未命中则从存储加载(残缺文件经校验清理,跳过) let data = this.sstableCache.get(meta.id) ?? null; if (!data) { try { data = await this.sstableStore.load(meta.id); } catch (error) { // v0.8.0:介质读故障 != 文件不存在。宁可让 compaction 失败(可重试), // 也不能把读故障当成"文件损坏"从而丢弃整层数据。 throw new DatabaseError(`AriaEngine compaction failed to read SSTable id=${meta.id}: ${error.message}`, 'ARIA_SSTABLE_READ_FAILED', error); } } if (!data || data.byteLength < 32) { await this.dropInvalidSSTable(meta, 'missing or truncated during compaction'); continue; } let reader; try { reader = new SSTableReader(data, meta); if (!reader.verifyChecksum()) { await this.dropInvalidSSTable(meta, 'checksum mismatch during compaction'); continue; } } catch (error) { await this.dropInvalidSSTable(meta, `unparseable during compaction: ${error.message}`); continue; } const entries = []; reader.scanAll((k, v) => entries.push([k, v])); mergeIter.addSource(new ArrayEntrySource(entries)); loadedMetas.push(meta); } const mergedRaw = mergeIter.drain(); // v0.8.0(审计 51):底部层丢弃墓碑(该 key 在底部层之外没有更老的数据) const merged = isBottomLevel ? mergedRaw.filter(([, value]) => !value.__tombstone) : mergedRaw; if (merged.length === 0) { if (mergedRaw.length === 0) { // 没有有效数据(全部损坏):把有效 meta 放回 levels, // 避免文件从读取路径消失(数据仍在磁盘,重启可恢复) for (const meta of loadedMetas) { if (!this.levels[level].some((m) => m.id === meta.id)) this.levels[level].push(meta); } this.levels[level].sort((a, b) => b.id - a.id); this.structureVersion++; return false; } // 底部层合并后只剩墓碑:整层都是已删除行 → 产物为空。 // 这是"删空整张表后再合并"的必然路径:必须按**退休**处理(摘除 meta + // 延迟物理删除),而不是继续走"写入空 SSTable"(merged[0] 不存在 → // 会抛 TypeError,compaction 永远失败、墓碑永远回收不掉)。 await this.retireMetas(level, this.removeFromLevel(level, selectedIds)); return true; } const id = await this.sstableStore.allocateId(); const builder = new SSTableBuilder(this.blockSize, this.bloomBitsPerKey); for (const [key, value] of merged) { builder.add(key, value); } const { sstableData, indexEntries } = builder.build(); const meta = { id, level: targetLevel, minKey: merged[0][0], maxKey: merged[merged.length - 1][0], blockCount: indexEntries.length, // v0.8.0(A38):落盘后用实际存储长度回填(见下) totalSize: sstableData.byteLength, bloomData: null, }; const stored = await this.sstableStore.save(id, sstableData); if (!stored || typeof stored.storedSize !== 'number') { throw new DatabaseError(`SSTableStore.save() returned ${JSON.stringify(stored)} for namespace "${this.namespace}" ` + `(id=${id}, bytes=${sstableData.byteLength}) — 存储实现违反了 save() 契约`, 'ARIA_SSTABLE_SAVE_CONTRACT'); } meta.totalSize = stored.storedSize; // 提交点 1:先提交合并产物(崩溃在摘除旧 meta 之前 → manifest 同时含新旧 meta, // 二者内容等价/新者更新,读路径仍然正确) await this.sstableStore.saveMeta(meta); this.tryCacheSSTable(id, sstableData); this.trimCache(); this.levels[targetLevel].unshift(meta); this.structureVersion++; // 提交点 2:摘除被取代的旧 meta(一次提交)。摘除后仍留在 retired 中—— // 在途读者还能读到它们的数据,直到所有更早的读者退出才物理删除。 await this.retireMetas(level, this.removeFromLevel(level, selectedIds)); return true; } /** * v0.8.0:从某一层**实际**摘除给定 id 的 meta(同步),返回被摘除的那些。 * * 只摘除"此刻确实还在这一层"的条目:合并窗口内可能有并发 compaction/clear * 已经处理过同一批文件,重复退休会让物理删除做两次(无害但会掩盖真实状态)。 */ removeFromLevel(level, ids) { const removed = []; this.levels[level] = this.levels[level].filter((m) => { if (ids.has(m.id)) { removed.push(m); return false; } return true; }); return removed; } /** * v0.8.0:把被取代的 SSTable 从某一层摘除并进入"退休"状态。 * * 顺序固定为:先从 levels 摘除(同步)→ 登记 retired(延迟物理删除)→ * 再由 manifest 一次提交摘除 meta。三个动作共同保证: * - 在途读者仍能读到旧数据(文件在 retired 里活着); * - 崩溃在摘除 meta 之前时 manifest 同时含新旧 meta(内容等价/新者更新); * - 崩溃在摘除之后时,新的合并产物已提交(数据完整)。 */ async retireMetas(level, metas) { if (metas.length === 0) return; this.structureVersion++; this.retired.push({ epoch: this.readEpoch, metas }); this.sstableStore.retire?.(metas.map((m) => m.id)); const ids = metas.map((m) => m.id); if (typeof this.sstableStore.deleteManyMetas === 'function') { await this.sstableStore.deleteManyMetas(ids); } else { for (const oldId of ids) await this.sstableStore.deleteMeta(oldId); } // 摘除动作完成后再尝试回收(正常情况下还有在途读者,会推迟到读者退出时) this.reclaimRetired(); } /** * v0.8.0(B-6/44+45):把所有 pending 数据刷盘。 * * 与修复前的区别(这两个行为此前都是缺陷): * 1. **先把当前 memtable 入链,再报告后台错误** —— 修复前错误检查在入链之前, * 于是一次后台失败会让本次 flush 完全不执行(数据继续只存在于内存里); * 2. **失败可重试** —— 修复前失败的冻结表虽然还留在 `frozenMemtables` 里可读, * 却没有任何重新入链的机会(唯一入链点是 freeze/flush 当时那一次), * 下次 `flush()` 又因第 1 点直接抛错,冻结数据永远等不到落盘。 * * 返回时的保证:**所有已确认写入(put/delete 已返回)的数据都在已提交的 * SSTable 中**;做不到则抛 `ARIA_BACKGROUND_ERROR`(不静默)。 */ async flush() { this.enqueuePendingMemtables(); const reported = this.consumeBackgroundError(); await this.drainChain(); await this.retryPendingFlushes(); // close/repair 语义:等维护链(compaction)也静默下来 await this.drainMaintenance(); this.assertNoPendingFrozen(reported); } /** * v0.8.0(B-6/55):只把 memtable 落盘,**不等 compaction**。 * * checkpoint 需要的是"WAL 覆盖的数据已落盘",而 compaction 只是重排已经 * 落盘的数据 —— 因此 checkpoint 不必(也不应)等它。这就是 v0.6.1 记录的 * "8~11s 悬崖"的根治:写路径的周期 checkpoint 不再被 compaction 拖住。 */ async flushMemtablesOnly() { this.enqueuePendingMemtables(); const reported = this.consumeBackgroundError(); await this.drainChain(); await this.retryPendingFlushes(); this.assertNoPendingFrozen(reported); } /** 把当前 memtable(若有数据)入链 */ enqueuePendingMemtables() { if (this.immutableMemtable) { const frozen = this.immutableMemtable; this.immutableMemtable = null; this.enqueueFlush(frozen); } if (this.memtable.getEntryCount() > 0) { this.freezeMemtable(); if (this.immutableMemtable) { const frozen = this.immutableMemtable; this.immutableMemtable = null; this.enqueueFlush(frozen); } } } /** 失败的冻结表重新入链(有界重试) */ async retryPendingFlushes() { let rounds = 0; while (this.frozenMemtables.some((f) => !f.queued && f.memtable.getEntryCount() > 0)) { if (rounds >= MAX_FLUSH_RETRY_ROUNDS) break; rounds++; for (const frozen of this.frozenMemtables) { if (frozen.memtable.getEntryCount() > 0) this.enqueueFlush(frozen); } await this.drainChain(); } } /** 消费一次后台错误(返回后 `lastBackgroundError` 已清空) */ consumeBackgroundError() { if (this.lastBackgroundError === null) return null; const error = this.lastBackgroundError; this.lastBackgroundError = null; return error; } /** flush 结束后的断言:不得还有未落盘的冻结表 */ assertNoPendingFrozen(reported) { // flush 过程中新产生的后台错误同样算"本次 flush 的错误"(不能在下次 flush // 才冒出来打断一次本来完全成功的调用) const late = this.consumeBackgroundError(); const effective = reported ?? late; const pending = this.frozenMemtables.filter((f) => f.memtable.getEntryCount() > 0); if (pending.length > 0) { const lastError = pending[pending.length - 1].lastError; throw new DatabaseError(`AriaEngine LSM flush could not persist ${pending.length} frozen memtable(s) after ` + `${MAX_FLUSH_RETRY_ROUNDS} retry round(s) — data is still only in memory (WAL is the only other copy)`, 'ARIA_BACKGROUND_ERROR', lastError ?? effective); } if (effective !== null && effective !== undefined) { // 失败已被重试修复:不阻塞 flush(数据确实已落盘),但必须可见。 // // 为什么不在下一次 flush 抛错:那会让"上一次的瞬时故障"随机打断一次 // 本来完全成功的 flush(调用方会据此回滚已经落盘的数据)。可见性由 // `getBackgroundWarnings()` 与告警日志提供,错误语义只保留 // "数据没落盘才算失败"。 this.backgroundWarnings.push(effective); // v0.8.0(review 修复):诊断数组必须有界 —— 长时间运行 + 反复瞬时故障下 // 无界增长等于内存泄漏(只保留最近若干条,足够定位问题) if (this.backgroundWarnings.length > MAX_BACKGROUND_WARNINGS) { this.backgroundWarnings.splice(0, this.backgroundWarnings.length - MAX_BACKGROUND_WARNINGS); } // eslint-disable-next-line no-console console.warn('[AriaEngine LSM] background failure recovered by retry:', effective); } } /** v0.8.0:被重试修复的后台故障(诊断/测试) */ getBackgroundWarnings() { return [...this.backgroundWarnings]; } /** v0.8.0:是否还有未落盘的 memtable 数据(manifest 水位不得越过它) */ hasPendingFlushData() { if (this.memtable.getEntryCount() > 0) return true; return this.frozenMemtables.some((f) => f.memtable.getEntryCount() > 0); } /** * v0.8.0:待落盘冻结表意图(写入 manifest)。 * `lsnAtFreeze` 是 WAL 水位下限 —— 它保证"只存在于内存的冻结数据"不会被 * 当成已落盘而截断掉。 */ getFrozenIntents() { const intents = []; for (const frozen of this.frozenMemtables) { // 正在提交的表由当前这次 manifest 提交负责(见 FrozenTable.committing) if (frozen.committing) continue; const entries = frozen.memtable.getAllEntries(); if (entries.length === 0) continue; intents.push({ ns: this.namespace, id: frozen.id, entryCount: entries.length, minKey: entries[0][0], maxKey: entries[entries.length - 1][0], lsnAtFreeze: frozen.lsnAtFreeze, }); } if (this.immutableMemtable && !this.immutableMemtable.committing) { const entries = this.immutableMemtable.memtable.getAllEntries(); if (entries.length > 0 && !intents.some((i) => i.id === this.immutableMemtable.id)) { intents.push({ ns: this.namespace, id: this.immutableMemtable.id, entryCount: entries.length, minKey: entries[0][0], maxKey: entries[entries.length - 1][0], lsnAtFreeze: this.immutableMemtable.lsnAtFreeze, }); } } return intents; } /** v0.8.0:恢复诊断(被丢弃的 SSTable / 是否怀疑数据丢失) */ getRecoveryReport() { return { namespace: this.recoveryReport.namespace, droppedSSTables: [...this.recoveryReport.droppedSSTables], dataLossSuspected: this.recoveryReport.dataLossSuspected, }; } async clear() { // v0.4.3-fix: 清空前排空后台任务(避免 compaction 在清空后写回残留 meta/数据) await this.drainChain(); await this.drainMaintenance(); this.memtable.clear(); this.immutableMemtable = null; this.frozenMemtables = []; for (const level of this.levels) { for (const meta of level) { this.sstableCache.delete(meta.id); this.sstableStore.delete(meta.id).catch(() => { }); this.sstableStore.deleteMeta(meta.id).catch(() => { }); } } this.levels = []; for (let i = 0; i < MAX_LSM_LEVELS; i++) { this.levels.push([]); } this.sstableCache.clear(); this.cacheSize = 0; this.oversizedSSTables.clear(); this.retired = []; this.structureVersion++; } getStats() { return { memtableSize: this.memtable.getEntryCount(), sstableCount: this.levels.reduce((sum, l) => sum + l.length, 0), levelCounts: this.levels.map((l) => l.length), frozenTables: this.frozenMemtables.filter((f) => f.memtable.getEntryCount() > 0).length, retiredTables: this.getRetiredCount(), compactingLevels: [...this.compacting].sort((a, b) => a - b), readStructureRetries: this.readStructureRetries, }; } // ======================================================================= // 内部 // ======================================================================= /** * v0.4.2-fix: 重新校验全部已加载 SSTable,移除损坏项(repair 自愈用)。 * @returns 移除的损坏 SSTable 数量 */ async validateAll() { const epoch = this.enterRead(); try { let removed = 0; for (let level = 0; level < MAX_LSM_LEVELS; level++) { const valid = []; for (const meta of this.levels[level]) { if (await this.validateSSTable(meta)) { valid.push(meta); } else { this.sstableCache.delete(meta.id); removed++; } } this.levels[level] = valid; } this.structureVersion++; return removed; } finally { this.exitRead(epoch); } } /** * v0.4.2-fix: 校验单个 SSTable 的完整性。 * - 文件不存在 → 清理 meta,返回 false * - 文件过小/魔数错误/索引越界(残缺写入产物)→ 清理 meta,返回 false * - v0.4.5: 整文件 CRC-32 校验失败(数据腐坏)→ 清理 meta,返回 false * v0.8.0: 介质读故障(抛错)**不**当作"文件不存在",直接向上抛 —— * 否则一次瞬时读错误就会让整个 SSTable 的 meta 被删掉(不可逆)。 */ async validateSSTable(meta) { const data = await this.sstableStore.load(meta.id); if (!data) { await this.dropInvalidSSTable(meta, 'file missing at open'); return false; } if (data.byteLength < 32) { await this.dropInvalidSSTable(meta, `file too small (${data.byteLength} bytes)`); return false; } try { const reader = new SSTableReader(data, meta); if (!reader.verifyChecksum()) { await this.dropInvalidSSTable(meta, 'checksum mismatch at open'); return false; } } catch (error) { await this.dropInvalidSSTable(meta, `unparseable at open: ${error.message}`); return false; } return true; } /** 清理无效 SSTable 的 meta 与文件(打开自愈路径)+ 记录恢复诊断 */ async dropInvalidSSTable(meta, reason) { this.recoveryReport.droppedSSTables.push({ id: meta.id, level: meta.level, reason }); // v0.8.0(review 修复):meta 被丢弃后必须同时从内存层数组移除。 // 否则 `levels` 会保留一个"manifest 里已不存在、文件也已删除"的幽灵条目 —— // stats 虚高、每次读都要扫它一遍,且掩盖"内存与 manifest 应当一致"这一不变量。 if (this.levels[meta.level]?.some((m) => m.id === meta.id)) { this.levels[meta.level] = this.levels[meta.level].filter((m) => m.id !== meta.id); this.structureVersion++; } if (this.requireDurableCoverage) { // manifest 已推进 WAL 水位(startLsn > 0)→ 被丢弃的 SSTable 没有 WAL 兜底 this.recoveryReport.dataLossSuspected = true; } // eslint-disable-next-line no-console console.warn(`[AriaEngine LSM] Dropping unusable SSTable id=${meta.id} (level=${meta.level}): ${reason}`); this.sstableCache.delete(meta.id); this.oversizedSSTables.delete(meta.id); // v0.6.0-fix: 先删数据文件再删 meta — 页面化存储的 delete 依赖 meta.pageIds // 定位页面文件;先删 meta 会丢失 pageIds 导致孤儿页面残留 try { await this.sstableStore.delete(meta.id); } catch { /* 清理失败不阻塞打开 */ } try { await this.sstableStore.deleteMeta(meta.id); } catch { /* 清理失败不阻塞打开 */ } } unwrapTombstone(value) { if (!value) return null; if (value.__tombstone) return null; return value; } /** * v0.8.0 根治:读取器加载**自洽**(缓存未命中即回源),不再依赖调用方预先 prefetch。 * * 此前的实现是"缓存未命中返回 null",而所有调用方都是 * const reader = this.loadSSTableReader(meta); * if (!reader) continue; * 于是**缓存未命中 = 静默跳过整个 SSTable = 查询结果少数据**。审计实测: * 小缓存下 300 行只能查回 59 行(且不报错)。同时"读路径必须先 prefetch" * 这个隐式约定,也是每次读都要 drainChain + prefetch 的原因(性能悬崖的另一半)。 * * 语义区分(v0.8.0 补齐第三态): * - 读到了 → 返回 reader; * - **确定不存在**且仍被 manifest 引用 → 自愈清理该 meta 并进恢复报告; * - **确定不存在但已被 compaction 摘除**(退休)→ 静默返回 null * (在途读者的快照必然也已看到了合并产物,只是顺序上还没轮到); * - **读取抛错(介质故障)** → 向上抛,绝不折叠成"不存在"。 */ async loadSSTableReader(meta) { let data = this.sstableCache.get(meta.id); if (!data) { let loaded; try { loaded = await this.sstableStore.load(meta.id); } catch (error) { throw new DatabaseError(`AriaEngine failed to read SSTable id=${meta.id} from storage: ${error.message}`, 'ARIA_SSTABLE_READ_FAILED', error); } if (!loaded) { // 数据确实读不到:区分"仍被引用的缺失"(损坏,需自愈 + 报告) // 与"已被 compaction 取代"(正常退休,静默跳过) const referenced = (await this.sstableStore.listMeta()).some((m) => m.id === meta.id); if (referenced) { await this.dropInvalidSSTable(meta, 'file missing during read'); } return null; } // 运行期回源同样校验整文件 CRC-32(与 preloadSSTable 一致) try { const probe = new SSTableReader(loaded, meta); if (!probe.verifyChecksum()) { await this.dropInvalidSSTable(meta, 'checksum mismatch during read'); return null; } } catch (error) { await this.dropInvalidSSTable(meta, `unparseable during read: ${error.message}`); return null; } this.tryCacheSSTable(meta.id, loaded); data = loaded; } // 刷新 LRU 顺序(近似:删除后重新插入使其成为最近使用) if (this.sstableCache.has(meta.id)) { const cached = this.sstableCache.get(meta.id); this.sstableCache.delete(meta.id); this.sstableCache.set(meta.id, cached); } try { return new SSTableReader(data, meta); } catch { return null; } } /** * v0.8.0: 单文件大于缓存上限时的处理 —— **不缓存**,且不牵连其他条目。 * * 此前 `preloadSSTable` 会把超大文件塞进缓存,随后 `trimCache()` 因 * `cacheSize > cacheLimitBytes` 把**全部**缓存条目一并驱逐(含刚装入的目标文件 * 与其它仍需使用的文件)。后果有两层: * 1. "缓存上限"形同虚设(内存峰值不受约束); * 2. 缓存被整体清空后,若某次读取没有紧跟一次 prefetch,就会静默读到空 * (`loadSSTableReader` 未命中返回 null,"不存在"与"不可用"不可区分)。 * 现在的语义明确:装不进缓存的文件不装,但**不影响**其他条目。 */ tryCacheSSTable(id, data) { if (data.byteLength > this.cacheLimitBytes) { // 单个文件就超过整个缓存上限:**必须常驻**。 // 若把它驱逐,`loadSSTableReader` 缓存未命中会回源(正确但慢), // 而 pinned 语义让热点大文件避免反复解码。 this.oversizedSSTables.add(id); } else { this.oversizedSSTables.delete(id); } this.cacheSSTable(id, data); } /** 写入缓存。注意:不在加载时立即驱逐,避免破坏正在进行的同步扫描 */ cacheSSTable(id, data) { // 已存在则先移除(保持"最近使用"语义) if (this.sstableCache.has(id)) { this.cacheSize -= this.sstableCache.get(id).byteLength; this.sstableCache.delete(id); } this.sstableCache.set(id, data); this.cacheSize += data.byteLength; } /** * LRU 裁剪:从最早插入的条目开始驱逐,直到缓存总字节数不超过 cacheLimitBytes。 * 仅在查询/写入开始前调用,保证本次查询所需数据在同步扫描期间全部存活。 * 查询结束后由引擎调用一次,回收查询期间的临时超限。 */ trimCache() { // v0.8.0: 超过缓存上限的单个文件被 pin 住(它们只是不参与 LRU, // 读取路径已自洽:驱逐后回源即可),因此这里只驱逐未 pin 的条目。 let scanned = 0; const total = this.sstableCache.size; let id = this.sstableCache.keys().next().value; while (this.cacheSize > this.cacheLimitBytes && scanned < total && id !== undefined) { const nextId = this.nextCacheKey(id); scanned++; if (!this.oversizedSSTables.has(id)) { const evicted = this.sstableCache.get(id); if (evicted) { this.cacheSize -= evicted.byteLength; this.sstableCache.delete(id); } } id = nextId; } } /** 取缓存中 id 的下一个键(Map 插入序),用于跳过 pinned 条目 */ nextCacheKey(id) { let seen = false; for (const key of this.sstableCache.keys()) { if (seen) return key; if (key === id) seen = true; } return undefined; } } /** * AriaEngine WAL — Write-Ahead Log * @module engine/aria/wal/log * * 崩溃恢复前的写操作持久化日志。 * * WAL 文件格式: * ┌──────────┬──────────────┬──────────┐ * │ Record 1│ Record 2 │ ... │ * │ 4B LSN │ │ │ * │ 1B type │ │ │ * │ 4B txnId│ │ │ * │ 2B tblLen│ │ │ * │ N table│ │ │ * │ 2B keyLen│ │ │ * │ N key │ │ │ * │ 4B jsonLen│ │ │ * │ N json │ │ │ * │ 4B CRC │ │ │ * └──────────┴──────────────┴──────────┘ */ // --------------------------------------------------------------------------- // WAL // --------------------------------------------------------------------------- class WAL { constructor(store, enabled = true, syncMode = 'batch') { this.lsn = 0; this.buffer = []; /** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */ this.bufferedBytes = 0; /** v0.8.0: 最近一次恢复诊断 */ this.lastRecoveryInfo = null; this.store = store; this.enabled = enabled; this.syncMode = syncMode; } // ======================================================================= // 写入 // ======================================================================= /** 追加一条 WAL 记录(full 模式同步等待写入完成) */ async append(record) { if (!this.enabled) return; this.lsn++; const fullRecord = { ...record, lsn: this.lsn, checksum: 0, // 稍后计算 }; const bytes = this.encodeRecord(fullRecord); if (this.syncMode === 'full') { // v0.6.3-fix: 写入失败必须抛给调用方 —— 此前仅 console.warn 吞错: // 内存已提交而 WAL 缺失,崩溃即丢且调用方无感知。 await this.store.append(bytes); this.bufferedBytes += bytes.byteLength; } else if (this.syncMode === 'batch') { this.buffer.push(bytes); this.bufferedBytes += bytes.byteLength; } // 'none' mode: 不写 WAL } /** 批量追加多条 WAL 记录(组提交:合并为一次底层写入,v0.3.1) */ async appendBatch(records) { if (!this.enabled || records.length === 0) return; const chunks = []; for (const record of records) { this.lsn++; chunks.push(this.encodeRecord({ ...record, lsn: this.lsn, checksum: 0 })); } const combined = this.mergeChunks(chunks); if (this.syncMode === 'full') { // v0.6.3-fix: 同 append —— 批量写入失败抛给调用方(不再吞错) await this.store.append(combined); this.bufferedBytes += combined.byteLength; } else if (this.syncMode === 'batch') { this.buffer.push(combined); this.bufferedBytes += combined.byteLength; } // 'none' mode: 不写 WAL } /** 批量刷新缓冲的 WAL 记录 */ async flush() { if (!this.enabled || this.buffer.length === 0) return; const combined = this.mergeChunks(this.buffer); await this.store.append(combined); this.buffer = []; } /** 合并多个字节块为一个连续缓冲区 */ mergeChunks(chunks) { if (chunks.length === 1) return chunks[0]; const totalLen = chunks.reduce((sum, b) => sum + b.byteLength, 0); const combined = new Uint8Array(totalLen); let offset = 0; for (const buf of chunks) { combined.set(buf, offset); offset += buf.byteLength; } return combined; } // ======================================================================= // 恢复 // ======================================================================= /** 从 WAL 恢复未提交的事务数据 */ async recover(applyRecord, opts = {}) { if (!this.enabled) return 0; const fromSegment = opts.fromSegment ?? 0; const fromLsn = opts.fromLsn ?? 0; let data; let gaps = []; let missingPrefix = []; if (typeof this.store.readAllFrom === 'function') { const result = await this.store.readAllFrom(fromSegment, fromLsn); data = result.data; gaps = result.gaps; missingPrefix = result.missingPrefix ?? []; } else { const exists = await this.store.exists(); if (!exists) return 0; data = await this.store.readAll(); } if (gaps.length > 0 && !opts.allowGaps) { throw new DatabaseError(`WAL segment gap detected in live range (missing segment(s): ${gaps.join(', ')}) — ` + 'records after the gap cannot be verified; refusing to continue silently', 'ARIA_WAL_GAP'); } const decoded = data.byteLength === 0 ? { records: [], corrupt: 0 } : this.decodeAllRecords(data); const records = decoded.records; let applied = 0; let skipped = 0; let maxLsn = 0; for (const record of records) { if (record.lsn > maxLsn) maxLsn = record.lsn; // v0.8.0: lsn <= startLsn 的记录已由 manifest 证明落盘,跳过(不再重复重放) if (record.lsn <= fromLsn) { skipped++; continue; } applyRecord(record); applied++; } this.lsn = Math.max(this.lsn, maxLsn); // 磁盘上的全部记录已经读出来了,缓冲区里不再有待落盘记录 this.buffer = []; this.bufferedBytes = 0; this.lastRecoveryInfo = { applied, skipped, maxLsn, gaps, missingPrefix, corruptRecords: decoded.corrupt, fromSegment, fromLsn, }; return applied; } /** v0.8.0: 最近一次恢复诊断(applied/skipped/gaps) */ getLastRecoveryInfo() { return this.lastRecoveryInfo ? { ...this.lastRecoveryInfo, gaps: [...this.lastRecoveryInfo.gaps] } : null; } /** v0.8.0: 当前 LSN 高水位(manifest 记录它以保证重启后 LSN 继续单调) */ getLsn() { return this.lsn; } /** * v0.8.0: 从 manifest 的高水位继续 LSN(重启后 LSN 全库单调,永不复用)。 * 只允许上调,不允许回退。 */ setLsn(lsn) { if (Number.isFinite(lsn) && lsn > this.lsn) this.lsn = lsn; } // ======================================================================= // Checkpoint // ======================================================================= /** Checkpoint 后清空 WAL */ async checkpoint() { if (!this.enabled) return; await this.flush(); await this.store.truncate(); this.bufferedBytes = 0; // v0.8.0: **不再把 lsn 归零**。LSN 是 manifest 记录的全库单调水位 // (`wal.nextLsn`),归零会让"跳过 lsn <= startLsn"的判定与历史分片冲突: // 同一段 LSN 区间会对应两批完全不同的记录。 } /** v0.8.0:整库清空后重置分片编号(供 `clearAll` 使用) */ reset() { if (typeof this.store.reset === 'function') this.store.reset(); } /** * v0.8.0(B-6):查询"提交之后可以删到哪个分片"(无副作用)。 * @returns 仍需保留的最小分片号(未实现分片能力的 store 返回 0 = 全部保留) */ async planKeepFromSegment(durableLsn) { if (!this.enabled) return 0; if (typeof this.store.planKeepFrom === 'function') { return this.store.planKeepFrom(durableLsn, this.lsn); } return 0; } /** * v0.8.0(B-6):**按落盘水位**截断 WAL —— manifest 提交之后的清理动作。 * * 与 `checkpoint()` 的区别:这里只删除"整段记录都 <= durableLsn"的前缀分片, * 因此可以安全地在**后台 compaction 仍在进行**时调用。 * * @returns 仍需保留的最小分片号(调用方应写入 manifest 的 `wal.startSegment`) */ async checkpointBefore(durableLsn) { if (!this.enabled) return 0; // 先把缓冲记录落盘:否则"边界分片"的判定会漏掉刚写进缓冲的记录 await this.flush(); if (typeof this.store.truncateBefore === 'function') { return this.store.truncateBefore(durableLsn, this.lsn); } // 回退(无分片能力的 store):只有确认"全部记录都已落盘"才整体截断 if (durableLsn >= this.lsn) { await this.store.truncate(); this.bufferedBytes = 0; } return 0; } // ======================================================================= // 统计 // ======================================================================= getBufferedCount() { return this.buffer.length; } /** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */ getBufferedBytes() { return this.bufferedBytes; } // ----------------------------------------------------------------------- // 编解码 // ----------------------------------------------------------------------- /** 旧版弱滚动校验(v0.4.4 及更早写入的 WAL 记录使用,双算法探测兼容) */ legacyChecksum(data) { let crc = 0; for (let i = 0; i < data.length; i++) { crc = ((crc << 5) - crc + data[i]) | 0; } return crc >>> 0; } encodeRecord(record) { const encoder = new TextEncoder(); const tableBytes = encoder.encode(record.tableName); const keyBytes = encoder.encode(record.key); const jsonStr = record.data ? JSON.stringify(record.data) : ''; const jsonBytes = encoder.encode(jsonStr); const size = 4 + // LSN 1 + // type 4 + // txnId 2 + tableBytes.length + // table 2 + keyBytes.length + // key 4 + jsonBytes.length + // json 4; // CRC const buf = new ArrayBuffer(size); const view = new DataView(buf); let offset = 0; view.setUint32(offset, record.lsn, false); offset += 4; view.setUint8(offset, record.type); offset += 1; view.setUint32(offset, record.txnId, false); offset += 4; view.setUint16(offset, tableBytes.length, false); offset += 2; new Uint8Array(buf).set(tableBytes, offset); offset += tableBytes.length; view.setUint16(offset, keyBytes.length, false); offset += 2; new Uint8Array(buf).set(keyBytes, offset); offset += keyBytes.length; view.setUint32(offset, jsonBytes.length, false); offset += 4; new Uint8Array(buf).set(jsonBytes, offset); offset += jsonBytes.length; // v0.4.5: 标准 CRC-32 校验(此前为弱滚动校验,误检率更高) const u8 = new Uint8Array(buf, 0, offset); const crc = crc32(u8); view.setUint32(offset, crc, false); return new Uint8Array(buf); } decodeAllRecords(data) { const records = []; let corrupt = 0; const view = new DataView(data.buffer, data.byteOffset, data.byteLength); let offset = 0; while (offset + 15 <= data.byteLength) { try { const recordStart = offset; const lsn = view.getUint32(offset, false); offset += 4; const type = view.getUint8(offset); offset += 1; const txnId = view.getUint32(offset, false); offset += 4; const tableLen = view.getUint16(offset, false); offset += 2; if (offset + tableLen > data.byteLength) break; const tableName = new TextDecoder().decode(data.slice(offset, offset + tableLen)); offset += tableLen; const keyLen = view.getUint16(offset, false); offset += 2; if (offset + keyLen > data.byteLength) break; const key = new TextDecoder().decode(data.slice(offset, offset + keyLen)); offset += keyLen; const jsonLen = view.getUint32(offset, false); offset += 4; if (offset + jsonLen > data.byteLength) break; let recordData; if (jsonLen > 0) { const json = new TextDecoder().decode(data.slice(offset, offset + jsonLen)); try { recordData = JSON.parse(json); } catch { /* ok */ } } offset += jsonLen; // 验证 CRC:先标准 CRC-32,失败再尝试旧版弱滚动校验(兼容旧库 WAL 记录) const storedCrc = view.getUint32(offset, false); offset += 4; const recordBytes = data.slice(recordStart, offset - 4); const computedNew = crc32(recordBytes); const computedLegacy = this.legacyChecksum(recordBytes); if ((computedNew >>> 0) !== storedCrc && (computedLegacy >>> 0) !== storedCrc) { // CRC 不匹配,跳过此损坏记录(长度字段链完整时后续好记录仍可恢复, // 行为由 aria-wal-crc 测试锁定)。 // v0.8.0(review 修复):跳过必须**被计数**并上报给恢复方 —— // 否则"少了一条已提交写入"在引擎层完全不可观测(静默丢数据)。 corrupt++; // eslint-disable-next-line no-console console.warn(`[AriaEngine WAL] CRC mismatch at record LSN=${lsn}, skipping`); continue; } records.push({ lsn, type, txnId, tableName, key, data: recordData, checksum: storedCrc, }); } catch { break; } } return { records, corrupt }; } } /** * AriaEngine Segmented WAL Store — 分片式 WAL 持久化存储 * @module engine/aria/wal/segmented_store * * v0.4.5: 取代"每条记录一个 key"的旧结构: * - 分片文件 `__wal_%06d.bin`,达到阈值(默认 4MB)切新分片 → 文件数量可控 * - append 追加写当前分片(后端支持真追加则 O(chunk),否则回退 read+write) * - WAL 序号(LSN)内嵌于记录字节流,无需独立 count 键 → append 单文件原子写 * - readAll 检测分片序号空洞:序号不连续 → 丢弃空洞之后的分片(保守截断, * 空洞仅可能来自 truncate 部分完成——此时数据已落盘,丢弃无害) * - 兼容旧格式 `__wal_N`(每条记录一个键)+ `__wal_count`:读取时迁移重放, * checkpoint 时一并清空 */ /** 分片文件名:__wal_%06d.bin */ const WAL_SEGMENT_PREFIX = '__wal_'; // v0.8.0:分片号只增不减(见 truncateBefore),因此不能假设一定是 6 位 —— // 超过 999999 之后若仍按 {6} 匹配,分片会突然"消失"(静默丢日志)。 const SEGMENT_REGEX = /^__wal_(\d{6,})\.bin$/; const LEGACY_RECORD_REGEX = /^__wal_(\d+)$/; const LEGACY_COUNT_KEY = '__wal_count'; /** 默认分片阈值 */ const DEFAULT_WAL_SEGMENT_SIZE = 4 * 1024 * 1024; // --------------------------------------------------------------------------- // SegmentedWALStore // --------------------------------------------------------------------------- class SegmentedWALStore { constructor(backend, segmentSize = DEFAULT_WAL_SEGMENT_SIZE) { this.backend = backend; /** 当前分片序号(append 定位) */ this.currentSegment = 0; /** 当前分片字节数(内存跟踪,append 切分片判断) */ this.currentSize = 0; /** * v0.8.0:分片 → 该分片**首条记录**的 LSN。 * * 用途:manifest 提交后需要知道"哪些分片整体已落盘可以删除"。 * 记录格式里 LSN 是每条记录的第 1 个字段,因此取分片前 4 字节即可定位; * 无需改变 WAL 二进制格式(旧库分片同样适用)。 */ this.segmentFirstLsn = new Map(); this.segmentSize = segmentSize; } /** 分片 key 生成 */ segmentKey(seq) { return `${WAL_SEGMENT_PREFIX}${String(seq).padStart(6, '0')}.bin`; } async append(data) { if (data.byteLength === 0) return; if (this.currentSize + data.byteLength > this.segmentSize) { // 当前分片放不下 → 切新分片 this.currentSegment++; this.currentSize = 0; } const seq = this.currentSegment; const key = this.segmentKey(seq); const copy = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); // 记录该分片首条记录的 LSN(分片内记录按写入顺序追加,首条即最小 LSN) if (!this.segmentFirstLsn.has(seq) && data.byteLength >= 4) { this.segmentFirstLsn.set(seq, new DataView(data.buffer, data.byteOffset, 4).getUint32(0, false)); } if (typeof this.backend.append === 'function') { await this.backend.append(key, copy); } else { // 回退:读旧 + 拼接 + 写(单文件原子写) const existing = await this.backend.read(key); if (existing) { const combined = new ArrayBuffer(existing.byteLength + copy.byteLength); new Uint8Array(combined).set(new Uint8Array(existing), 0); new Uint8Array(combined).set(new Uint8Array(copy), existing.byteLength); await this.backend.write(key, combined); } else { await this.backend.write(key, copy); } } this.currentSize += data.byteLength; } /** 列出存储上的分片(升序) */ async listSegments() { const keys = await this.backend.listKeys(); return keys .filter((k) => SEGMENT_REGEX.test(k)) .map((k) => ({ seq: Number(k.match(SEGMENT_REGEX)[1]), key: k })) .sort((a, b) => a.seq - b.seq); } /** * v0.8.0:从 `fromSegment` 开始读取,并把空洞**如实返回**(不再静默丢弃尾部)。 * * 为什么必须报告空洞:`truncateBefore` 只会删除**前缀**分片,因此活跃区间内 * 出现空洞只可能来自介质损坏/外部删除 —— 此时空洞之后的记录无法确认是否属于 * 同一个连续历史。修复前 `readAll` 遇到空洞直接丢弃空洞之后的全部记录, * 调用方(引擎恢复)完全无法感知"少了一批已提交事务"。 */ async readAllFrom(fromSegment = 0, fromLsn = 0) { const keys = await this.backend.listKeys(); const allSegments = await this.listSegments(); const segments = allSegments.filter((s) => s.seq >= fromSegment); // ---- 前缀缺失(fromSegment .. segments[0].seq - 1)---- // v0.8.0(review 修复):以前这里把前缀缺失也算作空洞,于是"回退到上一代 // manifest"时(上一代的 startSegment 比现存最小分片更小)会命中 // `kept = segments.filter(s => s.seq < firstGap)` = 空 —— **整段活 WAL 被丢掉**。 // 正确语义:前缀缺失只影响"本就被水位跳过"的那一段,后缀分片必须照常读取。 const missingPrefix = []; if (segments.length > 0) { for (let missing = fromSegment; missing < segments[0].seq; missing++) missingPrefix.push(missing); } // ---- 内部空洞(segments[i-1] 与 segments[i] 之间)---- const gaps = []; for (let i = 1; i < segments.length; i++) { for (let missing = segments[i - 1].seq + 1; missing < segments[i].seq; missing++) { gaps.push(missing); } } // 从未推进过水位(fromLsn === 0)时,分片本应从 fromSegment 连续存在 → // 此时前缀缺失同样是异常(介质丢了一段记录),并入 gaps。 if (fromLsn === 0 && missingPrefix.length > 0) { gaps.unshift(...missingPrefix); missingPrefix.length = 0; } gaps.sort((a, b) => a - b); // 内部空洞之后的分片整体丢弃(长度字段链无法跨空洞验证); // 前缀缺失**不**丢弃任何后缀分片。 let kept = segments; if (gaps.length > 0) { const firstGap = gaps[0]; kept = segments.filter((s) => s.seq < firstGap); } // ---- 旧格式兼容:__wal_N 单记录键(迁移前数据) ---- const legacyKeys = keys .filter((k) => LEGACY_RECORD_REGEX.test(k)) .map((k) => ({ seq: Number(k.match(LEGACY_RECORD_REGEX)[1]), key: k })) .sort((a, b) => a.seq - b.seq); const parts = []; // 旧格式在前(它们是最早的记录) for (const { key } of legacyKeys) { const raw = await this.backend.read(key); if (raw) parts.push(new Uint8Array(raw)); } // 新格式分片在后 const readSegments = []; for (const { seq, key } of kept) { const raw = await this.backend.read(key); if (!raw) continue; const bytes = new Uint8Array(raw); if (!this.segmentFirstLsn.has(seq) && bytes.byteLength >= 4) { this.segmentFirstLsn.set(seq, new DataView(bytes.buffer, bytes.byteOffset, 4).getUint32(0, false)); } readSegments.push(seq); parts.push(bytes); } // 同步当前分片状态(追加定位) if (segments.length > 0) { const lastSeq = segments[segments.length - 1].seq; this.currentSegment = lastSeq; const lastRaw = await this.backend.read(this.segmentKey(lastSeq)); this.currentSize = lastRaw ? lastRaw.byteLength : 0; // 旧格式键存在时(迁移中),下一条记录另起分片,避免与旧键序号冲突 if (legacyKeys.length > 0) { this.currentSegment++; this.currentSize = 0; } } else if (legacyKeys.length > 0) { // 仅有旧格式:迁移中,新写入从分片 0 开始(checkpoint 会清空旧键) this.currentSegment = 0; this.currentSize = 0; } else if (fromSegment > 0) { // 活跃区间的分片已被清理(例如上一次 truncateBefore 的删除生效): // 新写入从当前分片号继续,避免复用已删除的历史分片号 this.currentSegment = fromSegment; this.currentSize = 0; } const total = parts.reduce((s, c) => s + c.byteLength, 0); const combined = new Uint8Array(total); let off = 0; for (const c of parts) { combined.set(c, off); off += c.byteLength; } return { data: combined, fromSegment, segments: readSegments, gaps, missingPrefix, missingTail: 0, }; } async readAll() { return (await this.readAllFrom(0)).data; } async truncate() { const keys = await this.backend.listKeys(); const walKeys = keys.filter((k) => k.startsWith(WAL_SEGMENT_PREFIX) || k === LEGACY_COUNT_KEY); if (walKeys.length > 0) { await this.backend.deleteMany(walKeys); } // 分片号**只降不增**是禁止的(同 truncateBefore 的说明)。这里的准确语义是: // 序号绝不回退,且绝不小于 manifest 记录的水位下限(`open(fromSegment)` 会把它 // 抬到 fromSegment);整体清空后允许复用**最后用过的那个号** —— 这是安全的, // 因为记录自带 LSN,`lsn <= startLsn` 的旧世代记录在恢复时一律跳过。 // // 反例(修复前的真实缺陷):整段清空后把号重置为 0,而 manifest 的 // startSegment 已经是 K>0 → 新记录写进分片 0,恢复时被 `seq >= K` 过滤掉 → // 已确认写入静默消失。因此这里绝不允许回退到 0。 const maxSeq = (await this.listSegments()).reduce((max, s) => Math.max(max, s.seq), -1); this.currentSegment = Math.max(this.currentSegment, maxSeq + 1); this.currentSize = 0; this.segmentFirstLsn.clear(); } /** * v0.8.0:**整库清空**后重置分片编号(只有 `clearAll` 这种"介质被整体抹掉、 * manifest 也重新从 0 开始"的场景才允许调用)。 */ reset() { this.currentSegment = 0; this.currentSize = 0; this.segmentFirstLsn.clear(); } /** * v0.8.0:删除**整体 LSN 都 <= durableLsn** 的分片(前缀删除)。 * * 语义约束(调用方必须保证):`durableLsn` 之前的记录已存在于已提交的 * manifest/SSTable 中。返回仍需保留的最小分片号 —— 调用方应把它写进 * manifest 的 `wal.startSegment`,这样即便删除只完成了一半(崩溃/介质错误), * 恢复也会忽略那些残留的旧世代分片。 * * @param durableLsn 落盘水位(lsn <= 它的记录已确认存在于 SSTable 中) * @param latestLsn 当前 LSN 高水位(调用方 flush 之后的 `wal.getLsn()`): * 用于判断**最后一个分片**是否也已被水位完整覆盖。 * WAL 记录里只有"每条记录的 LSN",分片的**末条** LSN 无法 * 从分片首字节推出,而"最后一个分片之后没有分片"这一点只有 * 调用方知道 —— 缺了它就会出现"分片永远删不掉"(实测: * close 之后 `__wal_000000.bin` 仍在,每次打开都要重读一遍)。 */ /** * v0.8.0:**只计算**"还需要保留的最小分片号"(无副作用)。 * * 为什么需要"先算后删":调用方必须**先把这个值提交进 manifest**,再删除分片。 * 顺序反了(先删后记录)会留下一个窗口:崩溃后 manifest 里的 `startSegment` * 比介质上实际存在的分片更小,恢复时看到"前缀缺失"就无法区分 * "正常清理过的前缀"与"介质丢了一段记录"—— 前者无害,后者是数据丢失。 */ async planKeepFrom(durableLsn, latestLsn) { const segments = await this.listSegments(); // v0.8.0(review 修复):介质上没有分片时**返回当前分片号**(= 下一条记录将写入 // 的号),而不是 0。返回 0 会让 manifest 的 `startSegment` 回退,与 // "分片号只增不减" 的声明冲突(一旦将来前缀缺失被当作异常,就会误判成丢数据)。 if (segments.length === 0) return this.currentSegment; // 判定每个分片是否"整段已被水位覆盖": // - 非末分片:下一分片的首条记录 LSN <= durableLsn ⇒ 本分片全部记录都 <= 水位 // - 末分片:已知当前 LSN 高水位 <= durableLsn ⇒ 本分片全部记录都 <= 水位 for (let i = 0; i < segments.length; i++) { const seg = segments[i]; const isLast = i === segments.length - 1; const firstLsn = this.segmentFirstLsn.get(seg.seq); // 未知边界:保守地从这里开始保留 if (firstLsn === undefined) return seg.seq; const fullyCovered = isLast ? (typeof latestLsn === 'number' && latestLsn > 0 && latestLsn <= durableLsn) : (() => { const nextFirst = this.segmentFirstLsn.get(segments[i + 1].seq); return nextFirst !== undefined && nextFirst <= durableLsn; })(); if (!fullyCovered) return seg.seq; } // 全部分片都被水位覆盖 return segments[segments.length - 1].seq + 1; } async truncateBefore(durableLsn, latestLsn) { // v0.8.0:旧格式(__wal_N 每条一个 key)同样按水位清理 —— // 它们是"迁移前"的记录,与分片一样只在 lsn <= durableLsn 时才可删。 // 修复前这些键只在整个 WAL 被 truncate() 时才清,导致已落盘的旧记录 // 永远留在介质上(每次打开都会被 readAll 读出来再按 lsn 跳过)。 // // 注意:必须在"没有新格式分片"的早退**之前**处理 —— 只含旧格式的库 //(v0.4.4 升级现场)恰恰是这条路径最常见的输入。 const legacyKeys = (await this.backend.listKeys()).filter((k) => LEGACY_RECORD_REGEX.test(k)); if (legacyKeys.length > 0) { const obsoleteLegacy = []; for (const key of legacyKeys) { const raw = await this.backend.read(key); if (!raw || raw.byteLength < 4) { obsoleteLegacy.push(key); // 空/残缺:无记录可保留 continue; } const firstLsn = new DataView(raw, 0, 4).getUint32(0, false); if (firstLsn <= durableLsn) obsoleteLegacy.push(key); } if (obsoleteLegacy.length > 0) { try { await this.backend.deleteMany([...obsoleteLegacy, LEGACY_COUNT_KEY]); } catch { /* 同上:残留无害(恢复按 lsn 跳过) */ } } } const segments = await this.listSegments(); if (segments.length === 0) { // 无分片可删:分片号保持不变(只增不减),返回下一条记录将写入的号 this.currentSize = 0; return this.currentSegment; } const keepFrom = await this.planKeepFrom(durableLsn, latestLsn); const obsolete = segments.filter((s) => s.seq < keepFrom); if (obsolete.length > 0) { try { await this.backend.deleteMany(obsolete.map((s) => s.key)); } catch { /* 删除失败:残留分片由 manifest.startSegment 在恢复时忽略 */ } for (const s of obsolete) this.segmentFirstLsn.delete(s.seq); } // 全部删除(含当前分片)→ **分片号继续往后走,绝不重置回 0**。 // // 这是 v0.8.0 的一处关键修正:修复前这里是 `currentSegment = 0`。而 manifest // 里的 `startSegment` 是**删除前**就算好并提交的(= 最大分片号 + 1),于是 // 重置之后新记录写进分片 0,恢复时从 startSegment 开始读 → 整批新记录被跳过 // (实测随机压力用例:删掉的行复活 / 已确认写入丢失,两种方向都出现过)。 // 更糟的是"旧世代排在最新记录之后":分片号复用会让同一序号对应两代记录, // 恢复按序号排序时旧 INSERT 会排在一次 DELETE 之后被重放。 // // 现在分片号只增不减:删除只回收空间,不复用标识。 if (obsolete.length === segments.length) { const maxSeq = segments[segments.length - 1].seq; const leftover = await this.listSegments(); if (leftover.length === 0) { this.currentSegment = Math.max(maxSeq + 1, keepFrom); this.currentSize = 0; this.segmentFirstLsn.clear(); return keepFrom; } // 删除失败留下残片:它们都在 startSegment 之前(恢复时被忽略), // 新记录必须继续用**更大的**分片号,避免与残片混进同一个文件。 this.currentSegment = Math.max(leftover[leftover.length - 1].seq + 1, keepFrom); this.currentSize = 0; return keepFrom; } return keepFrom; } async exists() { const keys = await this.backend.listKeys(); return keys.some((k) => k.startsWith(WAL_SEGMENT_PREFIX) || k === LEGACY_COUNT_KEY); } } /** * AriaEngine Database Lock — 多标签页独占锁(Web Locks API) * @module engine/aria/locks * * v0.4.5: OPFS 等无事务后端缺乏多标签页并发协调,多个标签页同时打开同一库 * 会导致写竞态与数据损坏。用 Web Locks API(Chrome 69+ / Firefox 96+ / Safari 15.4+) * 获取库级排他锁: * - ifAvailable 模式:锁被其他标签页持有 → 立即抛 ARIA_LOCKED(不排队挂起) * - 持锁期间回调挂起,close() 时释放 * - 浏览器不支持 navigator.locks → 返回 false(如实降级:无并发保护,文档注明) */ /** Web Locks 锁名(库级排他) */ function lockName(dbName) { return `metona-sqlark:${dbName}`; } class DatabaseLock { constructor() { this.acquired = false; this.supported = false; this.releaseResolve = null; this.releasePromise = null; } /** * 尝试获取独占锁。 * @returns true = 已持锁;false = 环境不支持 Web Locks(无并发保护,调用方可警告) * @throws ARIA_LOCKED 锁被其他标签页持有 */ async acquire(dbName) { const nav = globalThis.navigator; const lockManager = nav?.locks; if (!lockManager || typeof lockManager.request !== 'function') { this.supported = false; return false; } this.supported = true; await new Promise((resolve, reject) => { // 注意:必须直接调用(不能解构 request —— LockManager 方法依赖 this 绑定) const request = lockManager.request.bind(lockManager); request(lockName(dbName), { ifAvailable: true, mode: 'exclusive' }, async (lock) => { if (!lock) { reject(new DatabaseError(`Database "${dbName}" is already open in another tab (locked)`, 'ARIA_LOCKED')); return; } this.acquired = true; const releasePromise = new Promise((res) => { this.releaseResolve = res; }); this.releasePromise = releasePromise; // 锁已获取:acquire 返回(open 流程继续) resolve(); // 回调挂起:保持锁直到 release() 触发 await releasePromise; }); }); return true; } /** 释放锁(等待回调真正结束,保证锁已归还) */ async release() { if (!this.acquired) return; if (this.releaseResolve) { const res = this.releaseResolve; const p = this.releasePromise; this.releaseResolve = null; this.releasePromise = null; res(); try { await p; } catch { /* 释放过程异常不阻塞 */ } } this.acquired = false; } /** 是否已持锁 */ isAcquired() { return this.acquired; } /** 环境是否支持 Web Locks */ isSupported() { return this.supported; } } /** * AriaEngine Checkpoint — 检查点机制 * @module engine/aria/wal/checkpoint */ // --------------------------------------------------------------------------- // CheckpointManager // --------------------------------------------------------------------------- class CheckpointManager { constructor(lsm, wal, flushable = null, interval = 1000, walSizeThreshold = 16 * 1024 * 1024) { this.opCount = 0; this.lsm = lsm; this.wal = wal; this.flushable = flushable; this.interval = interval; this.walSizeThreshold = walSizeThreshold; } async tick() { this.opCount++; // 检查操作计数或 WAL 大小是否超阈值 if (this.opCount >= this.interval || this.getWALEstimatedSize() >= this.walSizeThreshold) { await this.checkpoint(); } } /** 估算 WAL 大小(优先真实字节数,回退到缓冲计数估算) */ getWALEstimatedSize() { const wal = this.wal; if (typeof wal.getBufferedBytes === 'function') { const bytes = wal.getBufferedBytes(); if (bytes > 0) return bytes; } const count = typeof wal.getBufferedCount === 'function' ? wal.getBufferedCount() : 0; return count * 200; } async checkpoint() { if (this.flushable && typeof this.flushable.flushMemtables === 'function') { // v0.8.0(B-6/55):只等 memtable 落盘;compaction 继续在后台跑 await this.flushable.flushMemtables(); } else { // 兼容路径(测试替身 / 未实现新接口的调用方):旧语义 await this.lsm.flush(); if (this.flushable) { await this.flushable.flushAll(); } } await this.wal.checkpoint(); this.opCount = 0; } } /** * AriaEngine Storage Backend — 存储后端抽象层 * @module engine/aria/store/backend * * 封装底层浏览器存储 API(IndexedDB / OPFS / Memory 回退), * 供 Buffer Pool 的 PageIO 和 WAL 的 WALStore 使用。 */ // ======================================================================= // Memory Backend(回退 / 测试用) // ======================================================================= class MemoryBackend { constructor() { this.store = new Map(); this.opened = false; } async open(_name) { this.opened = true; } async close() { this.store.clear(); this.opened = false; } isOpen() { return this.opened; } async read(key) { return this.store.get(key) ?? null; } async write(key, data) { this.store.set(key, data); } async writeMany(entries) { for (const [key, data] of Object.entries(entries)) { this.store.set(key, data); } } async delete(key) { this.store.delete(key); } async deleteMany(keys) { for (const key of keys) { this.store.delete(key); } } async listKeys() { return Array.from(this.store.keys()); } async exists(key) { return this.store.has(key); } async clear() { this.store.clear(); } } /** * KVStoreBackend — 基于自研 KVStore 的 AriaEngine 存储后端 * @module engine/aria/store/kvstore_backend * * v0.6.1: AriaEngine 可选后端(storageBackend: 'kv'),完全跑在自研 KVStore 上: * - write/read/delete/listKeys/exists/clear → KVStore 直接映射 * - append → KVStore.appendValue(日志 APPEND 类型,O(chunk) 高效,aria WAL 分片用) * - writeMany/deleteMany → KVStore.putMany/deleteMany(单日志记录真原子, * 此前 OPFS 逐文件写靠空洞检测兜底,KV 后端原生原子) * - 崩溃恢复:KVStore 快照+日志恢复 → aria 打开重放自己的 WAL(双层恢复) * * 介质:浏览器 OPFS(KVStore 默认)或 Node SharedMemory——aria 不再依赖浏览器 OPFS API。 */ class KVStoreBackend { constructor(medium, checkpointThreshold) { this.kv = new KVStore(medium, checkpointThreshold); } /** 底层 KVStore(测试/诊断用) */ getKV() { return this.kv; } async open(name) { await this.kv.open(name); } async close() { await this.kv.close(); } isOpen() { return this.kv.isOpen(); } async read(key) { return this.kv.get(key); } async write(key, data) { await this.kv.put(key, data); } /** 追加写入(KVStore APPEND 日志,O(chunk)) */ async append(key, data) { await this.kv.appendValue(key, data); } /** 多 key 原子写入(单日志记录) */ async writeMany(entries) { await this.kv.putMany(entries); } async delete(key) { await this.kv.delete(key); } /** 多 key 原子删除(单日志记录) */ async deleteMany(keys) { await this.kv.deleteMany(keys); } async listKeys() { return this.kv.listKeys(); } async exists(key) { return this.kv.exists(key); } async clear() { await this.kv.clear(); } } /** * AriaEngine Crypto — 页面级 AES-GCM 加密 * @module engine/aria/crypto * * v0.2.5: 改为实例化 CryptoManager,避免多实例共享全局状态。 * 保留全局函数兼容旧代码(委托给全局单例)。 */ const ALGO = 'AES-GCM'; const IV_LENGTH$1 = 12; /** * CryptoManager — 实例级加密管理器 * 每个 AriaEngine 实例可拥有独立的加密配置。 */ class CryptoManager { constructor() { this.cryptoKey = null; this._enabled = false; } get enabled() { return this._enabled; } async init(password, salt) { const enc = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']); const actualSalt = salt || crypto.getRandomValues(new Uint8Array(16)); this.cryptoKey = await crypto.subtle.deriveKey({ name: 'PBKDF2', salt: actualSalt, iterations: 100000, hash: 'SHA-256' }, keyMaterial, { name: ALGO, length: 256 }, false, ['encrypt', 'decrypt']); this._enabled = true; return actualSalt; } async encryptPage(data) { if (!this.cryptoKey) throw new Error('Crypto not initialized'); const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH$1)); // 传 TypedArray 视图而非裸 ArrayBuffer:SubtleCrypto 通过 ArrayBuffer.isView 检查, // 对跨 realm / 跨 vm 环境的 ArrayBuffer 兼容(Node 18/20 的 webcrypto 对裸 ArrayBuffer 检查严格) const plain = (data instanceof Uint8Array ? data : new Uint8Array(data)); const ciphertext = await crypto.subtle.encrypt({ name: ALGO, iv }, this.cryptoKey, plain); return { iv: iv, data: ciphertext }; } async decryptPage(iv, data) { if (!this.cryptoKey) throw new Error('Crypto not initialized'); const ciphertext = (data instanceof Uint8Array ? data : new Uint8Array(data)); return crypto.subtle.decrypt({ name: ALGO, iv }, this.cryptoKey, ciphertext); } close() { this.cryptoKey = null; this._enabled = false; } } /** * AriaEngine Encrypted Backend — 全库透明 AES-256-GCM 加密后端 * @module engine/aria/store/encrypted_backend * * 装饰器模式包装底层 IStorageBackend: * - 写入时加密(每个 value 独立随机 IV:格式 [12B IV][AES-GCM ciphertext]) * - 读取时解密(GCM 认证标签同时保证完整性) * - WAL / SSTable / Schema / 元数据 全部密文存储(除密钥元数据本身) * * 密钥管理: * - PBKDF2-SHA256(100000 迭代)从密码派生 AES-256-GCM 密钥 * - `__aria_keymeta` 明文保存 { salt, verifier }: * - salt:PBKDF2 盐(重启后用同一密码重新派生密钥) * - verifier:对固定明文加密的密文(打开时解密验证密码正确性) * - 密码错误 → GCM 认证失败 → 抛 ARIA_DECRYPT_ERROR * * 依赖浏览器/Node 的 WebCrypto(jest.setup.js 已提供 polyfill)。 */ const IV_LENGTH = 12; const KEYMETA_KEY = '__aria_keymeta'; /** verifier 固定明文(无数据泄露风险) */ const VERIFIER_PLAIN = 'metona-sqlark-encryption-verifier'; // --------------------------------------------------------------------------- // Base64 工具(浏览器 btoa/atob 与 Node Buffer 双环境) // --------------------------------------------------------------------------- function bytesToBase64(bytes) { if (typeof Buffer !== 'undefined') { return Buffer.from(bytes).toString('base64'); } let bin = ''; for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin); } function base64ToBytes(b64) { if (typeof Buffer !== 'undefined') { return new Uint8Array(Buffer.from(b64, 'base64')); } const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } // --------------------------------------------------------------------------- // EncryptedBackend // --------------------------------------------------------------------------- class EncryptedBackend { constructor(inner, password) { this.inner = inner; this.password = password; this.crypto = new CryptoManager(); this.opened = false; if (!password || password.length === 0) { throw new DatabaseError('Encryption password must not be empty', 'ARIA_ENCRYPT_CONFIG_ERROR'); } } /** 底层后端(测试/调试用) */ getInner() { return this.inner; } /** 加密是否已初始化(打开并验证/创建密钥后为 true) */ isCryptoReady() { return this.crypto.enabled; } async open(name) { if (!this.inner.isOpen()) { await this.inner.open(name); } const raw = await this.inner.read(KEYMETA_KEY); if (raw) { // 已有密钥元数据:用持久化 salt 重新派生并验证密码 let meta; try { meta = JSON.parse(new TextDecoder().decode(raw)); } catch { throw new DatabaseError('Corrupted encryption key metadata', 'ARIA_DECRYPT_ERROR'); } if (!meta.salt || !meta.verifier) { throw new DatabaseError('Corrupted encryption key metadata', 'ARIA_DECRYPT_ERROR'); } const salt = base64ToBytes(meta.salt); await this.crypto.init(this.password, salt); const verifierBytes = base64ToBytes(meta.verifier); if (verifierBytes.byteLength <= IV_LENGTH) { throw new DatabaseError('Corrupted encryption key metadata', 'ARIA_DECRYPT_ERROR'); } const iv = verifierBytes.subarray(0, IV_LENGTH); const ciphertext = verifierBytes.subarray(IV_LENGTH); try { await this.crypto.decryptPage(iv, ciphertext); } catch { // GCM 认证失败:密码错误(或密钥元数据被篡改) throw new DatabaseError('Decryption failed: wrong password or corrupted key metadata', 'ARIA_DECRYPT_ERROR'); } } else { // 无 keymeta:若库中已存在其他数据 → 明文旧库或 keymeta 丢失,拒绝以加密模式打开 const keys = await this.inner.listKeys(); if (keys.some((k) => k !== KEYMETA_KEY)) { throw new DatabaseError('Cannot open with encryption: existing database has no key metadata ' + '(database was created without encryption, or key metadata was lost)', 'ARIA_ENCRYPT_CONFIG_ERROR'); } // 新库:生成随机 salt + 派生密钥 + 写入 verifier const salt = await this.crypto.init(this.password); const enc = await this.crypto.encryptPage(new TextEncoder().encode(VERIFIER_PLAIN).buffer); const verifier = new Uint8Array(IV_LENGTH + enc.data.byteLength); verifier.set(enc.iv, 0); verifier.set(new Uint8Array(enc.data), IV_LENGTH); const keymeta = JSON.stringify({ salt: bytesToBase64(salt), verifier: bytesToBase64(verifier), }); await this.inner.write(KEYMETA_KEY, new TextEncoder().encode(keymeta).buffer); } this.opened = true; } async close() { await this.inner.close(); this.crypto.close(); this.opened = false; } isOpen() { return this.opened; } async read(key) { this.ensureReady(); const raw = await this.inner.read(key); if (raw === null) return null; return this.decrypt(raw); } async write(key, data) { this.ensureReady(); await this.inner.write(key, await this.encrypt(data)); } async writeMany(entries) { this.ensureReady(); const encrypted = {}; for (const [key, data] of Object.entries(entries)) { encrypted[key] = await this.encrypt(data); } await this.inner.writeMany(encrypted); } async delete(key) { this.ensureReady(); await this.inner.delete(key); } async deleteMany(keys) { this.ensureReady(); await this.inner.deleteMany(keys); } async listKeys() { this.ensureReady(); return this.inner.listKeys(); } async exists(key) { this.ensureReady(); return this.inner.exists(key); } async clear() { // 清空全部数据但保留密钥元数据 —— clearAll 语义:库本身保留,密码不失效 const keys = await this.inner.listKeys(); const toDelete = keys.filter((k) => k !== KEYMETA_KEY); if (toDelete.length > 0) { await this.inner.deleteMany(toDelete); } } // ----------------------------------------------------------------------- // 内部 // ----------------------------------------------------------------------- ensureReady() { if (!this.opened) throw new DatabaseError('EncryptedBackend not opened', 'ARIA_DB_NOT_OPEN'); if (!this.crypto.enabled) throw new DatabaseError('EncryptedBackend key not initialized', 'ARIA_DECRYPT_ERROR'); } /** 加密单块数据:[IV(12)][ciphertext] */ async encrypt(data) { const enc = await this.crypto.encryptPage(data); const out = new ArrayBuffer(IV_LENGTH + enc.data.byteLength); const outBytes = new Uint8Array(out); outBytes.set(enc.iv, 0); outBytes.set(new Uint8Array(enc.data), IV_LENGTH); return out; } /** 解密单块数据(GCM 认证失败抛错) */ async decrypt(data) { const bytes = new Uint8Array(data); if (bytes.byteLength <= IV_LENGTH) { throw new DatabaseError('Corrupted encrypted data block (too short)', 'ARIA_DECRYPT_ERROR'); } const iv = bytes.subarray(0, IV_LENGTH); const ciphertext = bytes.subarray(IV_LENGTH); try { return await this.crypto.decryptPage(iv, ciphertext); } catch (error) { if (error instanceof DatabaseError) throw error; throw new DatabaseError('Decryption failed (data corrupted or wrong key)', 'ARIA_DECRYPT_ERROR', error); } } } /** * AriaEngine LZ4 Compression — 简化 LZ4 压缩/解压 * @module engine/aria/compression/lz4 * * v0.4.5 格式 v2:压缩流前增加 4 字节原始大小头(LE u32), * 解压不再依赖外部估算(高压缩率数据下 buf.length*2 估算不足会截断)。 * 旧版压缩数据(无头)视为损坏(compression 选项自 v0.2.6 起已声明不向后兼容)。 * * Token 格式(1 字节): * hi 4bit = litLen (0-15) * lo 4bit = matchField (1-15, 实际匹配 = field+4) * * 字面量-匹配序列: [token] [litLen bytes] [2B LE offset] * 末尾纯字面量: [token with lo=0] [litLen bytes] ← 仅在流末尾出现 */ const MIN_MATCH = 4; const MAX_MATCH = MIN_MATCH + 15; // 19,匹配长度上限 /** 原始大小头字节数 */ const HEADER_SIZE = 4; /** 最大匹配搜索链长(限制单点探测次数,保证最坏情况有界) */ const MAX_CHAIN = 32; /** 匹配窗口(offset 编码为 2 字节 LE) */ const WINDOW_SIZE = 65535; /** 哈希表大小(4 字节序列 → 桶;2^16 桶在内存与冲突率之间取平衡) */ const HASH_BITS = 16; const HASH_SIZE = 1 << HASH_BITS; /** * LZ4 压缩(v0.8.0 重写匹配搜索)。 * * **修复的性能缺陷(A39)**:此前每个输入字节都向前扫描最多 65535 个位置, * 每个位置再逐字节比较 —— 最坏 O(n × 窗口 × 匹配长度),即在"看似随机、 * 实际不存在长匹配"的数据上退化为**二次复杂度**。而 LZ4 的典型使用场景 *(SSTable 页、日志段,都是几百 KB 到几 MB)正好会触发这个最坏情况。 * * 现在改为 LZ4 的标准做法:**4 字节哈希链**。 * - `head[h]` = 最近的、4 字节哈希为 h 的位置; * - `prev[p]` = p 之前的同哈希位置(链); * - 每个位置最多探测 `MAX_CHAIN` 个候选 → 单点代价有界, * 整体接近线性(实践中远快于旧的逐位置扫描)。 * * **输出格式完全不变**(token/字面量/offset 编码与 v0.4.5 一致), * 因此既有压缩数据不需要迁移 —— 本函数只改变"去哪里找匹配", * 不改变"匹配如何编码"。等价性由 tests/engine/aria-compress.test.ts 的 * 往返用例与"新旧实现输出一致"用例共同锁定。 * * 旧的线性扫描实现保留在 `findBestMatchLinear`,**仅供测试对照**, * 运行时不再调用(保留它是有意的:等价性测试需要它作为参照物)。 */ function compressLZ4(input) { try { return compressWithHashChain(input); } catch { // 兜底:任何异常都退化为"全字面量"输出 —— 格式合法、可正确解压, // 只是没有压缩收益。宁可慢一点、大一点,也绝不产出损坏的流。 // (注意这不是"静默掩盖错误":解压结果与输入**逐字节相同**, // 即数据正确性不受影响;仅压缩率下降。) return encodeAllLiterals(input); } } /** 全字面量编码(格式合法、无压缩收益) */ function encodeAllLiterals(input) { const chunks = Math.ceil(input.byteLength / 15); const bodyLen = Math.max(chunks, 0) + input.byteLength; const combined = new Uint8Array(HEADER_SIZE + bodyLen); new DataView(combined.buffer).setUint32(0, input.byteLength, true); let di = HEADER_SIZE; let si = 0; while (si < input.byteLength) { const chunk = Math.min(15, input.byteLength - si); combined[di++] = (chunk & 0x0F) << 4; // lo=0:纯字面量 token for (let j = 0; j < chunk; j++) combined[di++] = input[si + j]; si += chunk; } return di === combined.byteLength ? combined : combined.slice(0, di); } function compressWithHashChain(input) { if (input.byteLength === 0) { const empty = new Uint8Array(HEADER_SIZE); new DataView(empty.buffer).setUint32(0, 0, true); return empty; } const n = input.byteLength; const maxOut = n + Math.ceil(n / 15) + 8; const out = new Uint8Array(maxOut); let di = 0; const head = new Int32Array(HASH_SIZE).fill(-1); const prev = new Int32Array(n).fill(-1); const hashAt = (pos) => { // 4 字节乘法哈希(LZ4 常用形式),结果落在 [0, HASH_SIZE) const v = (input[pos] | (input[pos + 1] << 8) | (input[pos + 2] << 16) | (input[pos + 3] << 24)) >>> 0; return (Math.imul(v, 2654435761) >>> (32 - HASH_BITS)) & (HASH_SIZE - 1); }; const insert = (pos) => { if (pos + 4 > n) return; const h = hashAt(pos); prev[pos] = head[h]; head[h] = pos; }; let si = 0; let litStart = 0; /** 结清 [litStart, si) 的字面量(每块最多 15 字节,lo=0 表示无匹配) */ const flushLiterals = () => { let remaining = si - litStart; while (remaining > 0) { const chunk = Math.min(remaining, 15); out[di++] = (chunk & 0x0F) << 4; for (let j = 0; j < chunk; j++) out[di++] = input[litStart + j]; remaining -= chunk; litStart += chunk; } }; while (si < n) { // ---- 在哈希链上找最长匹配(最多 MAX_CHAIN 次探测) ---- let bestLen = 0; let bestOff = 0; if (si + 4 <= n) { let cand = head[hashAt(si)]; let probes = 0; while (cand >= 0 && probes < MAX_CHAIN) { const off = si - cand; if (off > 0 && off <= WINDOW_SIZE && input[cand] === input[si]) { let ml = 0; while (ml < MAX_MATCH && si + ml < n && input[cand + ml] === input[si + ml]) ml++; if (ml > bestLen) { bestLen = ml; bestOff = off; if (ml === MAX_MATCH) break; } } cand = prev[cand]; probes++; } } // ---- 输出:组合 token(仅当匹配可完整编码且字面量不超 15) ---- if (bestLen > MIN_MATCH && (si - litStart) <= 15) { const litLen = si - litStart; out[di++] = ((litLen & 0x0F) << 4) | ((bestLen - MIN_MATCH) & 0x0F); for (let j = 0; j < litLen; j++) out[di++] = input[litStart + j]; out[di++] = bestOff & 0xFF; out[di++] = (bestOff >> 8) & 0xFF; // 匹配区间内的每个位置都要进链,否则后续匹配会漏掉这些候选 for (let k = 0; k < bestLen; k++) insert(si + k); si += bestLen; litStart = si; } else { insert(si); si++; // 字面量达到 15 字节上限即结清(token 的字面量字段只有 4 bit) if (si - litStart >= 15) flushLiterals(); } } flushLiterals(); const combined = new Uint8Array(HEADER_SIZE + di); new DataView(combined.buffer).setUint32(0, n, true); combined.set(out.subarray(0, di), HEADER_SIZE); return combined; } function decompressLZ4(input, _originalSize) { if (input.byteLength < HEADER_SIZE) { throw new Error('LZ4 stream too short: missing header'); } const view = new DataView(input.buffer, input.byteOffset, input.byteLength); const originalSize = view.getUint32(0, true); if (originalSize === 0 && input.byteLength === HEADER_SIZE) { return new Uint8Array(0); // 空输入 } if (originalSize <= 0 || originalSize > 0x3fffffff) { throw new Error('Invalid LZ4 header: bad original size'); } const stream = input.subarray(HEADER_SIZE); const out = new Uint8Array(originalSize); let si = 0, di = 0; while (si < stream.byteLength && di < originalSize) { const token = stream[si++]; const litLen = (token >> 4) & 0x0F; const matchField = token & 0x0F; // 复制字面量 for (let i = 0; i < litLen && si < stream.byteLength && di < originalSize; i++) { out[di++] = stream[si++]; } // matchField=0:纯字面量 token(无 offset 无匹配)。 // 可能出现在流中任意位置(超长字面量分块输出),不能 break if (matchField === 0) continue; // 组合 token:读取 offset + 复制匹配(可能自重叠) if (si + 1 >= stream.byteLength) break; const offset = stream[si++] | (stream[si++] << 8); const matchLen = matchField + MIN_MATCH; for (let i = 0; i < matchLen && di < originalSize; i++) { out[di] = out[di - offset]; di++; } } return out; } /** * AriaEngine Page SSTable Store — SSTable 页面化物理存储 * @module engine/aria/store/page_sstable_store * * v0.4.5: 让 BufferPool/FileManager 真正接入 LSM 读写路径。 * SSTable 不再整体存为一个 backend value,而是切分为 4KB 页面: * - 页面由 FileManager 分配 pageId,经 BufferPool 缓存(LRU 驱逐,脏页写回) * - save 语义 = 数据已落盘:页面写入后逐个 flushPage(await 底层写)才返回, * 保证 WAL checkpoint(截断)前 SSTable 数据真实持久化 * - 页面 ID 列表经 SSTableMeta.pageIds 持久化;旧 meta(无 pageIds)走整 value 读取 * * 与 LSM 的 sstableCache(整文件 LRU)双层缓存并存: * - BufferPool 缓存物理页面(跨 SSTable 共享、受 bufferPoolPages 上限约束) * - LSM 缓存解析后的整文件字节(查询热点复用) */ class PageSSTableStore { constructor(fileManager, bufferPool, /** * v0.8.0(A38):是否压缩。 * * 修复前 `config.compression` 只作用于"整 value 存一个 backend value"的旧路径, * 而 `save()` 在页面化路径上**提前 return**,压缩分支根本走不到 —— 于是 * `pageStorage: true`(默认)时 `compression: true` 被完全忽略, * 用户打开了压缩却没有任何压缩效果,且没有任何提示。 * * 为什么在页面化里压缩整个流而不是逐页压缩: * - 压缩率取决于"连续数据的重复窗口";4KB 页各自压缩会丢失跨页匹配, * 压缩率显著低于整体压缩; * - 整体压缩后仍是**字节流**,切页照旧,页面布局与 pageIds 语义不变 —— * 对 meta/文件布局零影响。 */ compression = false) { this.fileManager = fileManager; this.bufferPool = bufferPool; this.compression = compression; /** SSTable id → 页面 ID 列表(save 时记录,saveMeta 时注入 meta) */ this.pageIds = new Map(); /** * v0.8.0(B-6):已退休(被 compaction 取代 / 已被 manifest 摘除)但仍可能有 * 在途读者持有引用的 SSTable → 页面 ID 列表。 * * 为什么必须保留:读路径是"先取 meta 快照、再按 id 加载数据",快照与加载之间 * 可以插入一次 compaction。若退休时立刻忘掉 pageIds,在途读者的 `load()` 就 * 找不到页面,只能把"文件已退休"误判为"数据缺失"(旧代码会顺手删掉 meta 并 * 打一条损坏告警)。保留到物理删除为止,语义才是自洽的。 */ this.retiredPageIds = new Map(); /** SSTable id → 落盘字节数(load 时截断最后一页 0 填充) */ this.storedSizes = new Map(); } /** * 保存数据:切页 → 写入 BufferPool → 逐页落盘 → 记录 pageIds。 * * @returns `storedSize` = **实际落盘字节数**(压缩后)。调用方写入 * `SSTableMeta.totalSize` 时应使用它 —— totalSize 的语义是 * "页面里有多少字节",加载时按它截断;若仍写未压缩长度, * 压缩后的数据会被 0 填充撑大(静默损坏)。 */ async save(id, data) { // v0.8.0(A38):压缩先于切页(整体压缩,压缩率优于逐页) const payload = this.compression ? compressLZ4(data) : data; const pageCount = Math.max(1, Math.ceil(payload.byteLength / PAGE_SIZE)); const handles = await this.bufferPool.newPages(pageCount, PageType.DATA); const ids = []; for (let i = 0; i < pageCount; i++) { const page = handles[i]; ids.push(page.pageId); const dest = new Uint8Array(page.data); dest.fill(0); // 清空(最后一页可能不满) const slice = payload.subarray(i * PAGE_SIZE, Math.min((i + 1) * PAGE_SIZE, payload.byteLength)); dest.set(slice, 0); page.dirty = true; // save 语义 = 已持久化:立即落盘(WAL checkpoint 截断依赖此保证) await this.bufferPool.flushPage(page.pageId); this.bufferPool.unpin(page); } this.pageIds.set(id, ids); this.retiredPageIds.delete(id); this.storedSizes.set(id, payload.byteLength); return { storedSize: payload.byteLength }; } /** 获取指定 SSTable 的页面 ID 列表(saveMeta 注入用;未注册返回 undefined) */ getPageIds(id) { return this.pageIds.get(id) ?? this.retiredPageIds.get(id); } /** v0.8.0:打开时从 manifest 把已有 SSTable 的页面映射注册进来(load 不再依赖调用方传参) */ registerPageIds(id, ids, storedSize) { if (this.pageIds.has(id)) return; this.pageIds.set(id, [...ids]); if (typeof storedSize === 'number') this.storedSizes.set(id, storedSize); } /** * v0.8.0:标记某 SSTable 已退休(被合并产物取代)。 * 页面映射保留,在途读者仍能读到旧数据;物理删除由 `delete()` 完成。 */ retirePageIds(id) { const ids = this.pageIds.get(id); if (!ids) return; this.pageIds.delete(id); this.retiredPageIds.set(id, ids); } /** * 按页面 ID 列表读取并拼接为完整字节流。 * @param pageIds 页面 ID 列表(缺省时用内部注册的映射) * @param totalSize 页面中**实际存储**的字节数(缺省时用内部记录) * @returns 缺失页面/读取失败返回 null(调用方视为损坏并清理) */ async load(id, pageIds, totalSize) { const ids = pageIds ?? this.pageIds.get(id) ?? this.retiredPageIds.get(id); if (!ids || ids.length === 0) return null; const size = totalSize ?? this.storedSizes.get(id); if (typeof size !== 'number') return null; const chunks = []; for (const pageId of ids) { const page = await this.bufferPool.getPage(pageId); if (!page) return null; // 立即复制(后续驱逐安全) chunks.push(new Uint8Array(page.data)); this.bufferPool.unpin(page); } const total = chunks.reduce((s, c) => s + c.byteLength, 0); const out = new Uint8Array(Math.min(total, size)); let off = 0; for (const c of chunks) { const take = Math.min(c.byteLength, out.byteLength - off); if (take <= 0) break; out.set(c.subarray(0, take), off); off += take; } // v0.8.0:**不再**在这里丢掉 pageIds —— 退休 SSTable 的在途读者仍会调用 load, // 丢掉映射会让它们把"已退休"误判成"数据缺失"。物理删除由 delete() 负责。 // v0.8.0(A38):解压(与 save 的加密/压缩顺序对称) return this.compression ? decompressLZ4(out) : out; } /** 释放页面(删除物理页面文件 + 移出 BufferPool;同时清掉活跃与退休映射) */ async delete(id, pageIds) { const ids = pageIds ?? this.pageIds.get(id) ?? this.retiredPageIds.get(id) ?? []; for (const pageId of ids) { this.bufferPool.removePage(pageId); try { await this.fileManager.freePageId(pageId); } catch { /* 清理失败不阻塞 */ } } this.pageIds.delete(id); this.retiredPageIds.delete(id); this.storedSizes.delete(id); } } /** * AriaEngine File Manager — 页面文件管理 + PageIO 实现 * @module engine/aria/store/file_manager * * 负责管理页面文件的生命周期:分配/释放页面 ID,读写页面。 */ // --------------------------------------------------------------------------- // FileManager (implements PageIO) // --------------------------------------------------------------------------- class FileManager { constructor(backend) { this.nextPageId = 0; this.metaLoaded = false; this.dbName = ''; this.backend = backend; } /** 初始化:从存储中读取元数据 */ async init(dbName, watermarkFloor = 1) { this.dbName = dbName; const meta = await this.backend.read('__aria_meta'); let nextPageId = 1; if (meta && meta instanceof ArrayBuffer && meta.byteLength >= 4) { nextPageId = new DataView(meta).getUint32(0, false); } // v0.6.1-fix(P0): 崩溃一致性 —— allocatePageIds 的 saveMeta 可能在分配 // 页面后被崩溃中断(nextPageId 回退)。恢复时若按回退值继续分配, // 页面 id 复用会覆盖旧页面;随后 compaction 按 meta.pageIds 删除"旧"SSTable // 时误删被复用的新数据 → 崩溃恢复后静默丢数据(kv 后端 2 万行丢 75%)。 // 修复:以"现存最大页面 id + 1"为准(单调不回退,绝不复用已存在页面)。 const keys = await this.backend.listKeys(); for (const k of keys) { if (k.startsWith('pg_')) { const id = Number.parseInt(k.slice(3), 10); if (!Number.isNaN(id) && id + 1 > nextPageId) nextPageId = id + 1; } } // v0.8.0(B-6):manifest 的 pageId 水位是**权威下限**(单调推进、永不复用), // 与"现存最大页面 id + 1"、"旧 __aria_meta" 三者取最大 —— 任何单一来源被 // 截断/回退都不会导致页面 id 复用。 if (Number.isFinite(watermarkFloor) && watermarkFloor > nextPageId) { nextPageId = Math.floor(watermarkFloor); } this.nextPageId = nextPageId; this.metaLoaded = true; // v0.8.0(B-6):`__aria_meta` 降级为**兼容/诊断提示**,不再作为提交点: // 页面水位只由 manifest 提交(单一提交点),这里仅在旧值落后时补写一次, // 且写入失败不影响引擎(旧版本读到的只是"落后但单调"的提示值)。 const legacyValue = meta && meta instanceof ArrayBuffer && meta.byteLength >= 4 ? new DataView(meta).getUint32(0, false) : -1; if (legacyValue !== nextPageId) { try { await this.writeLegacyWatermark(nextPageId); } catch { /* 兼容提示写失败不影响正确性 */ } } } /** v0.8.0: 当前页面 id 水位(下一个可分配 id)—— manifest 提交时记录 */ getNextPageId() { return this.nextPageId; } // ---- PageIO ---- async readPage(pageId) { const key = `pg_${pageId}`; const data = await this.backend.read(key); if (!data) { // v0.4.5: 页面总是先分配(allocatePageId 持久化)后写入 —— 读取缺失页面视为损坏 // (此前返回空页面会静默掩盖页面丢失,页面化 SSTable 依赖 null 触发自愈清理) return null; } // 确保大小正确 if (data.byteLength < PAGE_SIZE) { const padded = new ArrayBuffer(PAGE_SIZE); new Uint8Array(padded).set(new Uint8Array(data)); return padded; } return data; } async writePage(pageId, data) { const key = `pg_${pageId}`; await this.backend.write(key, data); } async allocatePageId() { const id = this.nextPageId++; await this.persistWatermarkHint(); return id; } /** v0.4.5: 批量分配页面 ID(一次提示写,避免页面化 SSTable 保存时逐页写 meta) */ async allocatePageIds(count) { if (count <= 0) return []; const ids = []; const start = this.nextPageId; this.nextPageId += count; for (let i = 0; i < count; i++) ids.push(start + i); await this.persistWatermarkHint(); return ids; } async freePageId(_pageId) { // 简化实现:不回收 pageId const key = `pg_${_pageId}`; await this.backend.delete(key); } // ---- 辅助 ---- /** * v0.8.0(B-6):`__aria_meta` 只是**兼容提示**(旧版本/人工诊断用), * 失败不抛错 —— 真正的提交点是 manifest 的 `pageIdWatermark`。 */ async persistWatermarkHint() { if (!this.metaLoaded) return; try { await this.writeLegacyWatermark(this.nextPageId); } catch { /* 提示写失败不影响正确性(manifest 才是权威) */ } } async writeLegacyWatermark(nextPageId) { const buf = new ArrayBuffer(8); new DataView(buf).setUint32(0, nextPageId, false); await this.backend.write('__aria_meta', buf); } /** 清空所有数据 */ async clearAll() { await this.backend.clear(); this.nextPageId = 1; try { await this.writeLegacyWatermark(1); } catch { /* 提示写失败不影响正确性 */ } } } /** * AriaEngine Manifest — 存储层**单一提交点** * @module engine/aria/store/manifest * * v0.8.0(B-6):把原先"四处独立落盘、靠推理保持一致"的元状态收敛成 * **一份带 CRC 的原子提交记录**: * * ``` * ┌──────────────────────────────────────────────────────────────┐ * │ 数据落盘(SSTable 页面/文件) │ * │ ↓ │ * │ __aria_manifest_ 提交(单文件 COW 原子写 + CRC) │ * │ ↓ │ * │ 才允许截断 WAL / 删除旧分片 │ * └──────────────────────────────────────────────────────────────┘ * ``` * * 修复前的问题(全部是本模块要消除的根因 4 类): * - `__aria_lsm_meta*` 是**裸 JSON**:`JSON.parse` 失败时 `readMetaList()` 返回 * `[]` —— 损坏的元数据 = **静默空库**,随后 `repair()` 的孤儿页面清理会 * 把"没人引用"的活页全部删掉(不可逆); * - 页面 id 水位(`__aria_meta`)、WAL 起始位置、各命名空间 meta 各自独立落盘, * 崩溃窗口内三者可以互相矛盾; * - WAL 截断只看内存状态:**没有任何持久记录**能证明"被截断的记录已落盘"; * - 陈旧实例(多标签页/多实例)可以直接覆盖新一代的 meta。 * * 本模块的语义约定: * 1. **只认最后一份 CRC 通过的世代**;若存在 manifest 文件但全部世代都无效, * `load()` **抛错**而不是返回空状态(宁可打不开,也不能静默当空库); * 2. 提交是**先写后验**:写完立刻回读校验(CRC + 世代号一致)才认为提交成功; * 3. 至少保留**两代**(当前 + 上一代)用于回退;只有当回退窗口安全时才删除更早的世代; * 4. 任何"引用不到的东西"在**恢复路径**上一律不删除(删除只发生在显式 repair / * 已经过提交点确认的 compaction 之后); * 5. 所有计数器(pageId 水位、各命名空间 SSTable id)**单调推进、永不复用**。 */ // --------------------------------------------------------------------------- // 常量 // --------------------------------------------------------------------------- /** manifest 文件 key 前缀(世代号 8 位十进制) */ const MANIFEST_KEY_PREFIX = '__aria_manifest_'; /** 魔数 'M' 'S' 'M' 'F'(MetonaSqlark Manifest) */ const MANIFEST_MAGIC = 0x4d534d46; /** 当前格式版本 */ const MANIFEST_FORMAT_VERSION = 1; /** 头部字节数:magic(4) + version(2) + headerSize(2) + generation(4) + payloadLen(4) + payloadCrc(4) + headerCrc(4) */ const MANIFEST_HEADER_SIZE = 24; /** 至少保留的世代数(当前 + 上一代) */ const MANIFEST_RETAIN_GENERATIONS = 2; /** * 认领所有权时一次跨过的世代数(v0.8.0)。 * * 为什么需要"跨一段"而不是简单地 +1:多实例(浏览器多标签页 / 无 Web Locks 环境) * 下,旧实例的可能**已经在途**的提交会落在 +1 这个号上,从而覆盖新实例刚认领的 * 世代 —— 新实例随后的提交就会看到"别人的更高世代"而被判成陈旧实例, * 两个实例互相拒绝(实测:第二个打开者 open 直接失败)。 * 一次跨过一段之后,旧实例的在途提交落在更低的号上,既不会覆盖认领, * 也会在下一次提交时被正常拒绝。 */ const MANIFEST_TAKEOVER_STRIDE = 1000; const MANIFEST_KEY_REGEX = /^__aria_manifest_(\d{8})$/; // --------------------------------------------------------------------------- // 空 manifest / 编解码 // --------------------------------------------------------------------------- /** 创建一份空 manifest(全新库;或作为旧格式迁移的起点) */ function createEmptyManifest(opts) { return { formatVersion: MANIFEST_FORMAT_VERSION, generation: 0, pageIdWatermark: 1, namespaces: {}, schemas: {}, wal: { startSegment: 0, startLsn: 0, nextLsn: 0 }, frozen: [], owner: { instanceId: opts.instanceId, epoch: 0, openedAt: opts.now ?? Date.now() }, committedAt: opts.now ?? Date.now(), }; } /** manifest 文件 key */ function manifestKey(generation) { return `${MANIFEST_KEY_PREFIX}${String(generation).padStart(8, '0')}`; } /** 从 key 解析世代号(非 manifest key 返回 null) */ function generationFromKey(key) { const m = MANIFEST_KEY_REGEX.exec(key); return m ? Number(m[1]) : null; } /** 把 manifest 编码为字节(头部 + JSON 载荷) */ function encodeManifest(manifest) { const payload = new TextEncoder().encode(JSON.stringify(serializeManifest(manifest))); const buf = new ArrayBuffer(MANIFEST_HEADER_SIZE + payload.byteLength); const view = new DataView(buf); view.setUint32(0, MANIFEST_MAGIC, false); view.setUint16(4, manifest.formatVersion, false); view.setUint16(6, MANIFEST_HEADER_SIZE, false); view.setUint32(8, manifest.generation >>> 0, false); view.setUint32(12, payload.byteLength, false); view.setUint32(16, payload.byteLength > 0 ? crc32(payload) : 0, false); // 头部自身也带 CRC(generation/长度被篡改时不会误判为有效世代) view.setUint32(20, crc32(new Uint8Array(buf, 0, 20)), false); new Uint8Array(buf, MANIFEST_HEADER_SIZE).set(payload); return new Uint8Array(buf); } /** 从字节解码 manifest(任何异常都转成结构化失败原因) */ function decodeManifest(bytes) { try { if (bytes.byteLength < MANIFEST_HEADER_SIZE) { return { ok: false, reason: `too small (${bytes.byteLength} < ${MANIFEST_HEADER_SIZE})` }; } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const magic = view.getUint32(0, false); if (magic !== MANIFEST_MAGIC) { return { ok: false, reason: `bad magic 0x${magic.toString(16)}` }; } const headerCrc = view.getUint32(20, false); const computedHeaderCrc = crc32(bytes.subarray(0, 20)); if (headerCrc !== computedHeaderCrc) { return { ok: false, reason: `header CRC mismatch (stored=${headerCrc} computed=${computedHeaderCrc})` }; } const version = view.getUint16(4, false); if (version !== MANIFEST_FORMAT_VERSION) { return { ok: false, reason: `unsupported format version ${version}` }; } const headerSize = view.getUint16(6, false); if (headerSize !== MANIFEST_HEADER_SIZE) { return { ok: false, reason: `unexpected header size ${headerSize}` }; } const generation = view.getUint32(8, false); const payloadLength = view.getUint32(12, false); if (payloadLength === 0 || payloadLength > bytes.byteLength - MANIFEST_HEADER_SIZE) { return { ok: false, reason: `invalid payload length ${payloadLength}` }; } const payload = bytes.subarray(MANIFEST_HEADER_SIZE, MANIFEST_HEADER_SIZE + payloadLength); const payloadCrc = view.getUint32(16, false); const computedPayloadCrc = crc32(payload); if (payloadCrc !== computedPayloadCrc) { return { ok: false, reason: `payload CRC mismatch (stored=${payloadCrc} computed=${computedPayloadCrc})` }; } let parsed; try { parsed = JSON.parse(new TextDecoder().decode(payload)); } catch (error) { return { ok: false, reason: `payload is not valid JSON: ${error.message}` }; } const validated = validateManifestShape(parsed, generation); if (!validated.ok) return validated; return { ok: true, manifest: validated.manifest }; } catch (error) { return { ok: false, reason: `decode threw: ${error.message}` }; } } // --------------------------------------------------------------------------- // 序列化 / 校验 // --------------------------------------------------------------------------- /** 序列化:只保留有意义的字段,并把 Uint8Array 类字段规整掉(manifest 必须是 JSON 可表示的) */ function serializeManifest(manifest) { const namespaces = {}; for (const [ns, state] of Object.entries(manifest.namespaces)) { namespaces[ns] = { nextSstableId: state.nextSstableId, sstables: state.sstables.map((m) => ({ id: m.id, level: m.level, minKey: m.minKey, maxKey: m.maxKey, blockCount: m.blockCount, totalSize: m.totalSize, // bloom 数据当前恒为 null;若被塞入 Uint8Array 则由 LSM 侧保证不为 manifest 内容 bloomData: null, ...(m.pageIds && m.pageIds.length > 0 ? { pageIds: [...m.pageIds] } : {}), })), }; } return { formatVersion: manifest.formatVersion, generation: manifest.generation, pageIdWatermark: manifest.pageIdWatermark, namespaces, schemas: manifest.schemas, wal: { startSegment: manifest.wal.startSegment, startLsn: manifest.wal.startLsn, nextLsn: manifest.wal.nextLsn, }, frozen: manifest.frozen.map((f) => ({ ...f })), owner: { ...manifest.owner }, committedAt: manifest.committedAt, }; } function isPlainObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } function toNonNegativeInt(value, field) { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || !Number.isInteger(value)) { throw new Error(`manifest field "${field}" must be a non-negative integer, got ${JSON.stringify(value)}`); } return value; } function toNonEmptyString(value, field) { if (typeof value !== 'string') { throw new Error(`manifest field "${field}" must be a string, got ${JSON.stringify(value)}`); } return value; } /** 严格形状校验:任何不符合的类型都判该世代无效(而不是"部分采用") */ function validateManifestShape(value, generation) { try { if (!isPlainObject(value)) return { ok: false, reason: 'payload is not an object' }; const formatVersion = toNonNegativeInt(value.formatVersion, 'formatVersion'); if (formatVersion !== MANIFEST_FORMAT_VERSION) { return { ok: false, reason: `payload formatVersion ${formatVersion}` }; } const payloadGeneration = toNonNegativeInt(value.generation, 'generation'); if (payloadGeneration !== generation) { return { ok: false, reason: `generation mismatch (header=${generation} payload=${payloadGeneration})` }; } const pageIdWatermark = toNonNegativeInt(value.pageIdWatermark, 'pageIdWatermark'); // ---- namespaces ---- if (!isPlainObject(value.namespaces)) { return { ok: false, reason: 'namespaces is not an object' }; } const namespaces = {}; for (const [ns, raw] of Object.entries(value.namespaces)) { if (!isPlainObject(raw)) return { ok: false, reason: `namespace "${ns}" is not an object` }; const nextSstableId = toNonNegativeInt(raw.nextSstableId, `namespaces.${ns}.nextSstableId`); if (!Array.isArray(raw.sstables)) { return { ok: false, reason: `namespaces.${ns}.sstables is not an array` }; } const sstables = []; for (const item of raw.sstables) { if (!isPlainObject(item)) return { ok: false, reason: `namespace "${ns}" has a non-object sstable` }; const id = toNonNegativeInt(item.id, `namespaces.${ns}.sstables[].id`); const level = toNonNegativeInt(item.level, `namespaces.${ns}.sstables[].level`); const blockCount = toNonNegativeInt(item.blockCount, `namespaces.${ns}.sstables[].blockCount`); const totalSize = toNonNegativeInt(item.totalSize, `namespaces.${ns}.sstables[].totalSize`); const minKey = toNonEmptyString(item.minKey, `namespaces.${ns}.sstables[].minKey`); const maxKey = toNonEmptyString(item.maxKey, `namespaces.${ns}.sstables[].maxKey`); let pageIds; if (item.pageIds !== undefined) { if (!Array.isArray(item.pageIds)) { return { ok: false, reason: `namespaces.${ns}.sstables[].pageIds is not an array` }; } pageIds = item.pageIds.map((pid, i) => toNonNegativeInt(pid, `namespaces.${ns}.sstables[].pageIds[${i}]`)); } sstables.push({ id, level, minKey, maxKey, blockCount, totalSize, bloomData: null, ...(pageIds ? { pageIds } : {}), }); } namespaces[ns] = { nextSstableId, sstables }; } // ---- schemas ---- if (!isPlainObject(value.schemas)) { return { ok: false, reason: 'schemas is not an object' }; } const schemas = {}; for (const [table, cols] of Object.entries(value.schemas)) { if (!isPlainObject(cols)) return { ok: false, reason: `schemas.${table} is not an object` }; schemas[table] = cols; } // ---- wal ---- if (!isPlainObject(value.wal)) return { ok: false, reason: 'wal is not an object' }; const wal = { startSegment: toNonNegativeInt(value.wal.startSegment, 'wal.startSegment'), startLsn: toNonNegativeInt(value.wal.startLsn, 'wal.startLsn'), nextLsn: toNonNegativeInt(value.wal.nextLsn, 'wal.nextLsn'), }; if (wal.startLsn > wal.nextLsn) { return { ok: false, reason: `wal.startLsn ${wal.startLsn} > wal.nextLsn ${wal.nextLsn}` }; } // ---- frozen ---- if (!Array.isArray(value.frozen)) return { ok: false, reason: 'frozen is not an array' }; const frozen = []; for (const item of value.frozen) { if (!isPlainObject(item)) return { ok: false, reason: 'frozen has a non-object entry' }; frozen.push({ ns: toNonEmptyString(item.ns, 'frozen[].ns'), id: toNonNegativeInt(item.id, 'frozen[].id'), entryCount: toNonNegativeInt(item.entryCount, 'frozen[].entryCount'), minKey: toNonEmptyString(item.minKey, 'frozen[].minKey'), maxKey: toNonEmptyString(item.maxKey, 'frozen[].maxKey'), lsnAtFreeze: toNonNegativeInt(item.lsnAtFreeze, 'frozen[].lsnAtFreeze'), }); } // ---- owner ---- if (!isPlainObject(value.owner)) return { ok: false, reason: 'owner is not an object' }; const owner = { instanceId: toNonEmptyString(value.owner.instanceId, 'owner.instanceId'), epoch: toNonNegativeInt(value.owner.epoch, 'owner.epoch'), openedAt: toNonNegativeInt(value.owner.openedAt, 'owner.openedAt'), }; const committedAt = toNonNegativeInt(value.committedAt, 'committedAt'); return { ok: true, manifest: { formatVersion, generation, pageIdWatermark, namespaces, schemas, wal, frozen, owner, committedAt, }, }; } catch (error) { return { ok: false, reason: error.message }; } } let instanceCounter = 0; /** * manifest 读写器:加载最新有效世代、串行提交、维护回退窗口。 * * 注意:本类**不做数据落盘**,它只负责"提交点"。调用方必须先让数据真实落盘 * (SSTable 页面 flush 完成),再 `commit()`。 */ class ManifestStore { constructor(opts) { this.loaded = false; this.hadInvalidGenerations = false; this.skipped = []; /** 提交串行链:把 manifest 写入序列化成严格顺序 */ this.commitChain = Promise.resolve(); /** 已提交的世代号(= 最新有效世代) */ this.generation = 0; this.backend = opts.backend; this.now = opts.now ?? (() => Date.now()); this.state = createEmptyManifest({ instanceId: opts.instanceId ?? `aria-${++instanceCounter}-${Math.random().toString(36).slice(2, 10)}`, now: this.now(), }); } /** 本实例 id */ get instanceId() { return this.state.owner.instanceId; } /** 当前内存态(调用方可直接修改;提交通过 `commit()` 串行化) */ get current() { return this.state; } /** 最近一次加载/提交后的世代号 */ get currentGeneration() { return this.generation; } /** * 加载最新有效世代。 * * - 没有任何 manifest 文件 → `{ manifest: null }`(全新库); * - 有文件且最新世代有效 → 采用它(更早的损坏世代只记录、不删除); * - 有文件但**全部世代无效** → 抛 `ARIA_MANIFEST_CORRUPT` * (绝不返回空状态:那会让上层把"元数据全坏"当成"空库", * 随后 repair 还会把没人引用的活页删干净 —— 不可逆)。 */ async load() { const keys = await this.backend.listKeys(); const generations = keys .map((k) => generationFromKey(k)) .filter((g) => g !== null) .sort((a, b) => b - a); const skipped = []; for (const gen of generations) { const raw = await this.backend.read(manifestKey(gen)); if (!raw) { skipped.push({ generation: gen, reason: 'manifest file disappeared during load' }); continue; } const decoded = decodeManifest(new Uint8Array(raw)); if (!decoded.ok) { skipped.push({ generation: gen, reason: decoded.reason }); continue; } if (decoded.manifest.generation !== gen) { skipped.push({ generation: gen, reason: `file name generation ${gen} != payload generation ${decoded.manifest.generation}`, }); continue; } this.state = decoded.manifest; this.generation = gen; this.skipped = skipped; this.hadInvalidGenerations = skipped.length > 0; this.loaded = true; return { manifest: decoded.manifest, generation: gen, hadInvalidGenerations: this.hadInvalidGenerations, skipped }; } if (generations.length > 0) { // 文件存在但一代都读不出来:这是元数据损坏,不是空库 throw new DatabaseError(`AriaEngine manifest is corrupt: ${generations.length} generation(s) present, none passed validation ` + `(${skipped.map((s) => `gen ${s.generation}: ${s.reason}`).join('; ')})`, 'ARIA_MANIFEST_CORRUPT'); } this.loaded = true; return { manifest: null, generation: 0, hadInvalidGenerations: false, skipped: [] }; } /** 用一份外部状态替换内存态(旧格式迁移 / 测试用),不落盘 */ adopt(manifest) { this.state = manifest; this.generation = manifest.generation; } /** * 认领所有权:把当前内存态提交为新世代(owner 换成本实例)。 * * 与普通 `commit()` 的区别:这里**允许**接手别人提交的世代 * (多标签页的互斥由 Web Locks 负责;即便没有 Web Locks,接手者也只会让 * 旧实例后续提交被拒绝,而不是让旧实例静默覆盖新数据)。 */ async claimOwnership() { return this.commitInternal({ allowTakeover: true }); } /** * 提交当前内存态为新世代。 * * 提交前会**重新读取磁盘上的最新世代号**:若它已经超过本实例上次提交的世代, * 说明另一个实例在我们不知情的情况下提交过(陈旧实例)—— 此时抛 * `STALE_INSTANCE`,而不是用陈旧的内存态覆盖新一代(修复前 KVStore 快照被 * 静默覆盖、Aria 侧无任何保护)。 */ async commit() { return this.commitInternal({ allowTakeover: false }); } async commitInternal(opts) { let result; let failure = null; const run = this.commitChain.then(async () => { try { result = await this.doCommit(opts); } catch (error) { failure = error; } }); this.commitChain = run; await run; if (failure) throw failure; return result; } async doCommit(opts) { if (!this.loaded) { throw new DatabaseError('ManifestStore.commit() before load()', 'ARIA_MANIFEST_NOT_LOADED'); } const onDisk = await this.newestGenerationOnDisk(); // 陈旧实例判定要**跳过已知损坏的世代**:损坏世代(撕裂写/介质坏块)不是 // "另一个实例提交的状态",把它算进来会让库永远无法再提交(实测: // 一个损坏的更高世代把后续所有提交都拦成 STALE_INSTANCE)。 const knownInvalid = new Set(this.skipped.map((s) => s.generation)); let newestForeign = 0; for (const gen of await this.listGenerationNumbers()) { if (gen > this.generation && !knownInvalid.has(gen)) newestForeign = Math.max(newestForeign, gen); } if (!opts.allowTakeover && newestForeign > this.generation) { throw new DatabaseError(`Refusing to commit: another instance committed generation ${newestForeign} ` + `(this instance last committed ${this.generation}) — stale instance`, 'STALE_INSTANCE'); } // 但世代号必须大于**任何**已存在的文件(含损坏世代),否则会覆盖它。 // 认领(takeover)时一次跨过一段:见 MANIFEST_TAKEOVER_STRIDE 的说明。 const nextGeneration = opts.allowTakeover ? onDisk + MANIFEST_TAKEOVER_STRIDE : onDisk + 1; const next = { ...this.state, formatVersion: MANIFEST_FORMAT_VERSION, generation: nextGeneration, namespaces: this.state.namespaces, schemas: this.state.schemas, wal: { ...this.state.wal }, frozen: this.state.frozen.map((f) => ({ ...f })), owner: { instanceId: this.state.owner.instanceId, epoch: this.state.owner.epoch + 1, openedAt: this.state.owner.openedAt, }, committedAt: this.now(), }; const encoded = encodeManifest(next); await this.backend.write(manifestKey(nextGeneration), encoded.buffer.slice(encoded.byteOffset, encoded.byteOffset + encoded.byteLength)); // 先写后验:回读并校验,只有真正可读回的新世代才算提交成功 const readBack = await this.backend.read(manifestKey(nextGeneration)); if (!readBack) { throw new DatabaseError(`Manifest commit verification failed: generation ${nextGeneration} not readable after write`, 'ARIA_MANIFEST_WRITE_FAILED'); } const verified = decodeManifest(new Uint8Array(readBack)); if (!verified.ok || verified.manifest.generation !== nextGeneration) { throw new DatabaseError(`Manifest commit verification failed: generation ${nextGeneration} ` + `(${verified.ok ? 'generation mismatch' : verified.reason})`, 'ARIA_MANIFEST_WRITE_FAILED'); } this.state = next; this.generation = nextGeneration; // 回退窗口之外的历史世代:只有在**没有损坏世代**时才清理 //(存在损坏世代时保留全部,绝不因为"读不出来"就删掉可能是唯一副本的东西) if (!this.hadInvalidGenerations) { await this.pruneOldGenerations(nextGeneration); } return next; } /** 磁盘上最大的 manifest 世代号(乐观并发检查;只读 key 名单) */ async newestGenerationOnDisk() { return (await this.listGenerationNumbers()).reduce((max, g) => Math.max(max, g), 0); } /** 磁盘上全部 manifest 世代号(升序无关,仅用于判定) */ async listGenerationNumbers() { const keys = await this.backend.listKeys(); const out = []; for (const k of keys) { const gen = generationFromKey(k); if (gen !== null) out.push(gen); } return out; } /** 删除回退窗口之外的世代(当前 + 上一代保留) */ async pruneOldGenerations(currentGeneration) { const keys = await this.backend.listKeys(); const stale = keys .map((k) => generationFromKey(k)) .filter((g) => g !== null && g < currentGeneration - (MANIFEST_RETAIN_GENERATIONS - 1)); if (stale.length === 0) return; try { await this.backend.deleteMany(stale.map((g) => manifestKey(g))); } catch { // 删除失败只是残留文件(下次提交再清),不影响正确性 } } } /** * AriaEngine MVCC — 多版本并发控制 * @module engine/aria/transaction/mvcc * * v0.8.0(review 修正文档):本模块提供**行版本链**,用途是"事务内的 undo", * 提交即清理。它**不提供快照隔离** —— 引擎的隔离语义是"事务串行"(同一实例 * 同时只允许一个事务,并发 `beginTransaction` 抛 `TX_ACTIVE`),读取走 * `txnSnapshot` 未提交快照。旧注释声称 "实现快照隔离 (Snapshot Isolation)" * 与实现不符(全量审查发现)。 */ // --------------------------------------------------------------------------- // MVCCManager // --------------------------------------------------------------------------- class MVCCManager { constructor() { /** 所有行版本的存储:tableName.key → 版本链 */ this.versionStore = new Map(); /** 活跃事务表:txnId → TxnEntry */ this.activeTxns = new Map(); /** 事务 ID 计数器 */ this.nextTxnId = 1; /** 全局提交序列号(用于可见性判断) */ this.globalCommitLsn = 0; /** * v0.4.2-fix: 每个事务写入的 tableKey 集合 — * commit/rollback 只遍历本事务写过的 key,避免全库版本链扫描(大表事务 O(N) → O(写入数)) */ this.txnWriteKeys = new Map(); } // ======================================================================= // 事务管理 // ======================================================================= /** 开始一个事务,返回事务 ID */ beginTransaction() { const txnId = this.nextTxnId++; this.activeTxns.set(txnId, { txnId, state: TransactionState.ACTIVE, snapshotLsn: this.globalCommitLsn, startTime: Date.now(), }); this.txnWriteKeys.set(txnId, new Set()); return txnId; } /** 提交事务 */ commitTransaction(txnId) { const txn = this.activeTxns.get(txnId); if (!txn) throw new Error(`Transaction ${txnId} not found`); txn.state = TransactionState.COMMITTED; this.globalCommitLsn++; // v0.6.3-fix: 已提交版本直接清理 —— 快照读取已移除(v0.5.1),版本链仅作 // 事务内 undo 记录(rollback/savepoint 用),提交后 LSM 持有权威数据。 // 此前 commit 仅标记 committed → versionStore 随写入量无限增长(行数据双份常驻)。 const writeKeys = this.txnWriteKeys.get(txnId); if (writeKeys) { for (const tableKey of writeKeys) { const versions = this.versionStore.get(tableKey); if (!versions) continue; const filtered = versions.filter((v) => v.txnId !== txnId); if (filtered.length === 0) { this.versionStore.delete(tableKey); } else { this.versionStore.set(tableKey, filtered); } } } // 清理已提交事务的记录 this.activeTxns.delete(txnId); this.txnWriteKeys.delete(txnId); } /** 回滚事务 */ rollbackTransaction(txnId) { const txn = this.activeTxns.get(txnId); if (!txn) throw new Error(`Transaction ${txnId} not found`); txn.state = TransactionState.ABORTED; // v0.4.2-fix: 仅移除本事务写入的版本(此前遍历全库 versionStore) const writeKeys = this.txnWriteKeys.get(txnId); if (writeKeys) { for (const tableKey of writeKeys) { const versions = this.versionStore.get(tableKey); if (!versions) continue; const filtered = versions.filter((v) => v.txnId !== txnId); if (filtered.length === 0) { this.versionStore.delete(tableKey); } else { this.versionStore.set(tableKey, filtered); } } } this.activeTxns.delete(txnId); this.txnWriteKeys.delete(txnId); } // ======================================================================= // 版本读写 // ======================================================================= /** * 写入一行(创建新版本)。 */ writeVersion(tableName, key, data, txnId) { const tableKey = `${tableName}.${key}`; const versions = this.versionStore.get(tableKey) ?? []; const newVersion = { txnId, data, prevVersion: versions.length > 0 ? versions[versions.length - 1] : null, committed: false, }; versions.push(newVersion); this.versionStore.set(tableKey, versions); // v0.4.2-fix: 记录本事务写过的 key(commit/rollback 精准清理) this.txnWriteKeys.get(txnId)?.add(tableKey); } /** * 删除一行(创建墓碑版本)。 */ deleteVersion(tableName, key, txnId) { this.writeVersion(tableName, key, { __mvcc_tombstone: true }, txnId); } /** * v0.3.3: 丢弃指定事务的所有版本记录,但保留事务登记(Savepoint 回滚用)。 * 快照数据由调用方(引擎 txnSnapshot)负责恢复。 * v0.4.2-fix: 仅遍历本事务写过的 key(此前全库扫描)。 */ discardVersions(txnId) { const writeKeys = this.txnWriteKeys.get(txnId); if (!writeKeys) return; for (const tableKey of writeKeys) { const versions = this.versionStore.get(tableKey); if (!versions) continue; const filtered = versions.filter((v) => v.txnId !== txnId); if (filtered.length === 0) { this.versionStore.delete(tableKey); } else { this.versionStore.set(tableKey, filtered); } } } /** * 清理过旧版本(GC)。 * 保留每个 key 的最新 N 个已提交版本。 */ gc(maxVersionsPerKey = 100) { for (const [tableKey, versions] of this.versionStore) { if (versions.length <= maxVersionsPerKey) continue; // 保留最新的 maxVersionsPerKey 个版本 const pruned = versions.slice(versions.length - maxVersionsPerKey); this.versionStore.set(tableKey, pruned); } } /** * 获取全局 LSN。 */ getGlobalLSN() { return this.globalCommitLsn; } } /** * AriaEngine Page Header — 页面头初始化 * @module engine/aria/page/header * * v0.4.5: 仅保留生产代码实际使用的 initPageHeader。 * 页面头部布局(大端序): * [0-3] page_id u32 * [4] type u8 * [5-6] free_start u16 * [7-8] free_end u16 * [9-10] slot_count u16 * [11-14] checksum u32 * [15] reserved u8 * * 注:free_start/free_end/slot_count/checksum 字段为历史行级页面格式遗留, * 页面化 SSTable 使用页面原始字节区(跳过头部),字段保留以维持 16 字节头部对齐。 */ /** * 初始化新页面的 Header。 */ function initPageHeader(buf, pageId, type) { const view = new DataView(buf); view.setUint32(0, pageId, false); view.setUint8(4, type); view.setUint16(5, PAGE_HEADER_SIZE, false); // freeStart = header 之后 view.setUint16(7, buf.byteLength, false); // freeEnd = 页面末尾 view.setUint16(9, 0, false); // slotCount = 0 view.setUint32(11, 0, false); // checksum = 0 view.setUint8(15, 0); } /** * AriaEngine Page Format — 页面创建 * @module engine/aria/page/format * * v0.4.5: 仅保留生产代码实际使用的页面创建逻辑。 * (Slot Directory / Tuple 编解码曾为行级页面存储设计,但从未接入 LSM 主路径, * 属"宣称页面式但未实现"的半成品,已删除 —— SSTable 以 4KB 页面承载, * 页面内容为原始字节切片,由 PageSSTableStore 管理) */ /** 创建一个新的空页面 */ function createPage(pageId, type) { const data = new ArrayBuffer(PAGE_SIZE); initPageHeader(data, pageId, type); return { pageId, type, data, dirty: true, pins: 0, prev: null, next: null, lastAccess: Date.now(), }; } /** * AriaEngine Buffer Pool Eviction — LRU 驱逐策略 * @module engine/aria/buffer/eviction */ // --------------------------------------------------------------------------- // LRU 双向链表 // --------------------------------------------------------------------------- /** * LRU 链表管理器 — 双向链表,头部是 most recently used,尾部是 least recently used。 */ class LRUList { constructor() { this.head = null; this.tail = null; this._size = 0; } get size() { return this._size; } /** 将页面移到链表头部。如果是新页面则插入,已存在则移动。 */ moveToHead(page) { // 如果已经在头部,无需操作 if (this.head === page) return; // 检测是否在链表中 const inList = page.prev !== null || page.next !== null || this.head === page || this.tail === page; if (inList) { // 先从当前位置移除 this.detach(page); } else { this._size++; } // 插入头部 page.prev = null; page.next = this.head; if (this.head) { this.head.prev = page; } this.head = page; if (!this.tail) { this.tail = page; } } /** 从链表中移除页面 */ remove(page) { const inList = page.prev !== null || page.next !== null || this.head === page || this.tail === page; if (!inList) return; this.detach(page); this._size = Math.max(0, this._size - 1); } /** 内部:只调整指针,不修改 _size */ detach(page) { if (page.prev) { page.prev.next = page.next; } else if (this.head === page) { this.head = page.next; } if (page.next) { page.next.prev = page.prev; } else if (this.tail === page) { this.tail = page.prev; } page.prev = null; page.next = null; } /** 清空链表 */ clear() { this.head = null; this.tail = null; this._size = 0; } /** 获取 LRU 尾部(最久未使用的页面) */ getLRU() { return this.tail; } } /** * 驱逐管理器 — 当 Buffer Pool 满时驱逐页面。 */ class EvictionManager { /** * @param onEvict 驱逐脏页时的写回回调 * @param onRemove v0.6.3: 页面被驱逐时的回调(BufferPool 用于同步清理 pages Map; * 此前驱逐只清 LRU 链表,pages Map 无限增长 → 1MB 内存预算形同虚设) */ constructor(capacity, onEvict, onRemove) { this.lru = new LRUList(); this.capacity = capacity; this.onEvict = onEvict; this.onRemove = onRemove; } /** 访问页面,更新 LRU */ access(page) { page.lastAccess = Date.now(); this.lru.moveToHead(page); } /** 添加新页面到池中 */ add(page) { this.access(page); } /** 移除指定页面 */ remove(page) { this.lru.remove(page); } /** * 驱逐页面直到池中有足够空间。 * 只驱逐未 pin 的干净页面(dirty=false)。 * 如果没有干净页面可驱逐,尝试刷脏页。 */ async evictIfNeeded(count) { let evicted = 0; while (this.lru.size + count > this.capacity && this.lru.size > 0) { // 找到可驱逐的页面 const victim = this.findEvictionCandidate(); if (!victim) break; // 脏页先刷盘 if (victim.dirty) { await this.onEvict(victim); victim.dirty = false; } this.lru.remove(victim); this.onRemove?.(victim); evicted++; } return evicted; } /** 查找驱逐候选(优先干净页面,然后最久未用的脏页) */ findEvictionCandidate() { // 先从尾部找未 pin 的干净页面 let current = this.lru.getLRU(); while (current) { if (current.pins === 0 && !current.dirty) return current; current = current.prev; } // 没有干净页,找未 pin 的脏页 current = this.lru.getLRU(); while (current) { if (current.pins === 0) return current; current = current.prev; } return null; } /** 清空 */ clear() { this.lru.clear(); } } /** * AriaEngine Buffer Pool — 页面缓存池 * @module engine/aria/buffer/pool * * 管理固定数量页面的 LRU 缓存,减少对底层储存的访问。 */ // --------------------------------------------------------------------------- // Buffer Pool // --------------------------------------------------------------------------- class BufferPool { constructor(pageIO, capacity = DEFAULT_BUFFER_POOL_PAGES) { this.pages = new Map(); this.nextPageId = 0; this.pageIO = pageIO; this.eviction = new EvictionManager(capacity, async (page) => { if (page.dirty) { await this.pageIO.writePage(page.pageId, page.data); page.dirty = false; } }, (page) => { // v0.6.3-fix: 驱逐时同步从 pages Map 移除 —— 此前仅清 LRU 链表, // pages Map 保留全部历史页面 → 内存无限增长(1MB 预算失效) this.pages.delete(page.pageId); }); } // ----------------------------------------------------------------------- // 页面获取 // ----------------------------------------------------------------------- /** * 获取页面(必要时从磁盘读取)。 * 返回 pin 的页面,使用完成后必须调用 unpin()。 */ async getPage(pageId) { // 已在池中 let page = this.pages.get(pageId); if (page) { this.eviction.access(page); page.pins++; return page; } // 需要从磁盘加载 const buffer = await this.pageIO.readPage(pageId); if (!buffer) return null; // 确保有空间 await this.eviction.evictIfNeeded(1); const type = new DataView(buffer).getUint8(4); page = { pageId, type, data: buffer, dirty: false, pins: 1, prev: null, next: null, lastAccess: Date.now(), }; this.pages.set(pageId, page); this.eviction.add(page); return page; } /** * v0.4.5: 批量创建新页面(一次页面 ID 分配,页面化 SSTable 保存用)。 * 返回的页面均 pin 且 dirty=false(调用方写入后需 markDirty + flushPage)。 */ async newPages(count, type = PageType.DATA) { if (count <= 0) return []; let pageIds; if (typeof this.pageIO.allocatePageIds === 'function') { pageIds = await this.pageIO.allocatePageIds(count); } else { pageIds = []; for (let i = 0; i < count; i++) pageIds.push(await this.pageIO.allocatePageId()); } await this.eviction.evictIfNeeded(count); const handles = []; for (const pageId of pageIds) { const page = createPage(pageId, type); page.pins = 1; this.pages.set(pageId, page); this.eviction.add(page); handles.push(page); } return handles; } /** * 释放页面的 pin。 */ unpin(page) { if (page.pins > 0) { page.pins--; } } /** * 标记页面为脏(需要写回)。 */ markDirty(page) { page.dirty = true; } /** * 将脏页面刷新到磁盘。 */ async flushPage(pageId) { const page = this.pages.get(pageId); if (page && page.dirty) { await this.pageIO.writePage(pageId, page.data); page.dirty = false; } } /** * 刷新所有脏页面。 */ async flushAll() { for (const [, page] of this.pages) { if (page.dirty) { await this.pageIO.writePage(page.pageId, page.data); page.dirty = false; } } } /** * 从缓存中删除指定页面(不刷盘)。 */ removePage(pageId) { const page = this.pages.get(pageId); if (page) { this.eviction.remove(page); this.pages.delete(pageId); } } /** * 清空缓存池(先刷脏页)。 */ async clear() { await this.flushAll(); this.pages.clear(); this.eviction.clear(); } } // --------------------------------------------------------------------------- // AriaEngine // --------------------------------------------------------------------------- class AriaEngine { constructor(config = {}) { this.name = 'aria'; this.opened = false; this.dbName = ''; // v0.4.5: 多标签页独占锁(Web Locks API,OPFS 等无事务后端防并发写) this.dbLock = null; /** * v0.8.0:已确认落盘的 WAL 水位(LSN)。 * * 只在"所有 LSM 都没有未落盘数据"时推进到当前 LSN —— 于是 * `lsn <= durableLsn` 的记录必然已存在于已提交的 SSTable 中, * 恢复时可以安全跳过(也就允许删除对应分片)。 */ this.durableLsn = 0; /** * v0.8.0:仍需保留的最小 WAL 分片号(每次 `checkpointBefore` 的返回值)。 * 写进 manifest 的 `wal.startSegment`:即便分片删除只完成一半,恢复也只从 * 这个分片开始读,不会把上一世代的旧记录排到新记录之后重放。 */ this.walStartSegment = 0; /** v0.8.0:恢复诊断 */ this.recoveryReport = { droppedSSTables: [], dataLossSuspected: false, walGaps: [], droppedWALRecords: 0, legacyImported: false, manifestFallback: false, }; // 表结构 this.schemas = new Map(); this.tablePKs = new Map(); this.opCounter = 0; // 二级索引:table.colKey → LSM this.secondaryIndexes = new Map(); /** * v0.7.4: 由 CREATE UNIQUE INDEX 添加的 unique 列(table:col)。 * 与建表 UNIQUE 约束区分:DROP INDEX 只允许解除索引来源的 unique, * 建表约束需重建表(对齐 SQLite 语义,此前静默解除且重启后永久消失)。 * 注:重启后无法区分历史来源,schema 中的 unique 一律按建表约束保护(保守)。 */ this.uniqueIndexCols = new Set(); // MVCC 事务 this.mvcc = new MVCCManager(); this.currentTxnId = null; this.txnSnapshot = null; this.gcCounter = 0; // ---- Savepoint 嵌套事务 ---- this.savepoints = new Map(); /** * v0.8.0: 当前事务在 WAL 中已成功追加的记录条数。 * * 用途:保存点需要记录"回滚后应保留到哪一条",否则恢复时无法区分 * "保存点之前的写入"(应保留)与"保存点之后的写入"(应丢弃)。 */ this.txnWalRecordCount = 0; /** 保存点 → 该保存点时事务的 WAL 记录边界 */ this.savepointWalBoundary = new Map(); this.config = { ...DEFAULT_ARIA_CONFIG, ...config }; } // ======================================================================= // 生命周期 // ======================================================================= async open(dbName, _version) { if (this.opened) return; // v0.4.2-fix: 引擎内部错误统一包装为 DatabaseError(ARIA_OPEN_ERROR), // 应用层可拿到 code 分类处理,不再抛出原生 RangeError/TypeError try { await this.openInternal(dbName); } catch (error) { // 打开失败:释放已获取的锁(避免锁泄漏阻塞其他标签页) if (this.dbLock) { try { await this.dbLock.release(); } catch { /* ignore */ } this.dbLock = null; } if (error instanceof DatabaseError) throw error; throw new DatabaseError(`Failed to open AriaEngine database "${dbName}"`, 'ARIA_OPEN_ERROR', error); } } /** open 内部实现(错误包装在 open 外层) */ async openInternal(dbName) { this.dbName = dbName; // v0.4.5: 多标签页独占锁(Web Locks)— 不支持的环境降级为无锁(文档注明) const lock = new DatabaseLock(); this.dbLock = lock; const lockAcquired = await lock.acquire(dbName); if (!lockAcquired) { // eslint-disable-next-line no-console console.warn(`[AriaEngine] Web Locks API unavailable: no multi-tab protection for "${dbName}" ` + '(open the same database in multiple tabs may corrupt data)'); } // 1. 存储后端(可选全库加密包装) let baseBackend; // v0.8.0:测试可注入后端(见 AriaEngineConfig.testBackend 的说明)—— // 崩溃语义必须让 WAL/FileManager/SSTableStore 都走同一个被测后端。 if (this.config.testBackend) { baseBackend = this.config.testBackend; } else if (this.config.storageBackend === 'opfs') { baseBackend = new OPFSBackend(); } else if (this.config.storageBackend === 'kv') { // v0.6.1: 自研 KVStore 后端(aria 完全跑在自研存储栈上,不依赖浏览器 OPFS) baseBackend = new KVStoreBackend(); } else { baseBackend = new MemoryBackend(); } await baseBackend.open(dbName); // v0.4.5: encryption 配置 → 透明加密封装(密码错误/数据损坏在 open 或首次读取时暴露) if (this.config.encryption?.password) { this.backend = new EncryptedBackend(baseBackend, this.config.encryption.password); } else { this.backend = baseBackend; // 反向检测:库中存在密钥元数据但未提供密码 → 拒绝打开(避免密文被当明文解析成空库) if (await baseBackend.exists('__aria_keymeta')) { await baseBackend.close(); throw new DatabaseError('Database is encrypted: provide encryption.password to open it', 'ARIA_ENCRYPT_REQUIRED'); } } await this.backend.open(dbName); // --------------------------------------------------------------------- // 2. manifest(单一提交点):加载 → 旧格式迁移 → 认领所有权 // --------------------------------------------------------------------- this.manifestStore = new ManifestStore({ backend: this.backend }); const loaded = await this.manifestStore.load(); if (loaded.hadInvalidGenerations) { // 有损坏世代被跳过:记录 + 告警(但仍可用更早的有效世代打开) this.recoveryReport.manifestFallback = true; // eslint-disable-next-line no-console console.warn(`[AriaEngine] manifest: skipped invalid generation(s): ` + loaded.skipped.map((s) => `${s.generation} (${s.reason})`).join('; ')); } if (loaded.manifest === null) { // 全新库或 v0.8.0 之前的旧格式库:先把旧格式状态**完整导入**, // 再进行第一次提交 —— 顺序反了会写出"空 manifest"覆盖旧状态(静默空库)。 this.manifest = createEmptyManifest({ instanceId: this.manifestStore.instanceId }); this.manifestStore.adopt(this.manifest); await this.importLegacyState(); } else { this.manifest = loaded.manifest; } // 认领所有权(世代 +1;允许接手别的实例 —— 之后旧实例的提交会被拒绝) this.manifest = await this.manifestStore.claimOwnership(); this.durableLsn = this.manifest.wal.startLsn; this.walStartSegment = this.manifest.wal.startSegment; // --------------------------------------------------------------------- // 3. WAL(LSN 从 manifest 高水位续接,全库单调) // --------------------------------------------------------------------- // v0.4.5: 分片式 WAL 存储(__wal_%06d.bin),序号内嵌记录字节流无需 count 键, // append 单文件原子写;空洞检测截断;兼容旧格式 __wal_N + __wal_count this.wal = new WAL(new SegmentedWALStore(this.backend), this.config.walEnabled, this.config.walSyncMode); this.wal.setLsn(this.manifest.wal.nextLsn); // --------------------------------------------------------------------- // 4. FileManager (PageIO 实现) + Buffer Pool(页面水位下限来自 manifest) // --------------------------------------------------------------------- this.fileManager = new FileManager(this.backend); await this.fileManager.init(dbName, this.manifest.pageIdWatermark); this.bufferPool = new BufferPool(this.fileManager, this.config.bufferPoolPages); // --------------------------------------------------------------------- // 5. 主 LSM(PK 索引)+ 二级索引 LSM // --------------------------------------------------------------------- this.lsm = this.createLSM('main'); // 5a. 恢复 Schema(manifest 权威;旧格式已在迁移阶段导入) await this.loadSchemas(); // v0.4.2-fix: 为 schema 中带 index/unique 标记的列重建二级索引 LSM。 // 此前重开只恢复 schema 不恢复索引 LSM → 索引查询静默回退全表、 // createIndex 因 colDef 已有标记直接 return → 索引永久缺失。 // 索引数据已持久化在独立命名空间(manifest.namespaces),init() 直接加载。 for (const [tableName, schema] of this.schemas) { const pkCol = this.tablePKs.get(tableName); for (const [colName, colDef] of Object.entries(schema.columns)) { if ((colDef.index || colDef.unique) && colName !== pkCol) { const idxKey = `${tableName}:idx:${colName}`; if (!this.secondaryIndexes.has(idxKey)) { const idxLsm = this.createLSM(`idx_${tableName}_${colName}`); await idxLsm.init(); this.secondaryIndexes.set(idxKey, idxLsm); } } } } await this.lsm.init(); // --------------------------------------------------------------------- // 6. WAL 恢复(两阶段:先扫描事务边界,仅回放已提交事务) // --------------------------------------------------------------------- const committedTxns = new Set(); const allRecords = []; await this.wal.recover((r) => allRecords.push(r), { // manifest 权威位置:小于 startLsn 的记录已确认落盘 → 跳过 //(否则旧记录会把已删除的行复活);分片读取从 startSegment 起。 fromSegment: this.manifest.wal.startSegment, fromLsn: this.manifest.wal.startLsn, // 空洞由引擎显式上报(恢复报告 + 告警),而不是静默丢弃尾部 allowGaps: true, }); const walInfo = this.wal.getLastRecoveryInfo(); if (walInfo && walInfo.corruptRecords > 0) { // v0.8.0(review 修复):记录级 CRC 损坏此前只 console.warn —— 恢复完全 // 不感知"少了几条记录",与"静默丢数据必须显式化"的目标不符。 this.recoveryReport.droppedWALRecords = walInfo.corruptRecords; this.recoveryReport.dataLossSuspected = true; // eslint-disable-next-line no-console console.warn(`[AriaEngine] WAL: ${walInfo.corruptRecords} record(s) failed CRC and were skipped — ` + 'the writes they carried are missing (recovery report records this)'); } if (walInfo && walInfo.gaps.length > 0) { this.recoveryReport.walGaps = [...walInfo.gaps]; this.recoveryReport.dataLossSuspected = true; // eslint-disable-next-line no-console console.warn(`[AriaEngine] WAL segment gap detected: missing segment(s) ${walInfo.gaps.join(', ')} — ` + 'transactions in the missing range are lost (recovery report records this)'); } // 第一遍:确定已提交事务 for (const r of allRecords) { if (r.type === WALRecordType.COMMIT) committedTxns.add(r.txnId); if (r.type === WALRecordType.ROLLBACK) committedTxns.delete(r.txnId); } // v0.8.0 根治:确定每个事务的**回放起始下标**。 // // `ROLLBACK TO ` 只回滚内存快照,日志里仍留有 savepoint 之前 // 写入的记录;COMMIT 又把整个 txnId 标记为已提交 —— 于是那些被回滚掉的写入 // 在重启时被重新应用,**已回滚的行复活**(实测:实时只剩 a,崩溃重开变成 a+b)。 // // 现在 savepoint 回滚会写入 SAVEPOINT_ROLLBACK 记录;恢复时该事务只应用 // **最后一条** SAVEPOINT_ROLLBACK 之后的记录 —— 等价于"回到该保存点", // 与实时态严格一致(这样也就不需要 undo 崩溃前已落盘的旧值)。 // 每个事务:{ 保留起点, 丢弃终点 } —— 区间 [start, end) 保留。 // // 关键:`replayFromIndex` 是**事务内**的记录序号(写入方按事务计数), // 因此这里必须用"该事务的第几条记录"来比较,不能直接拿全局下标 —— // 全局下标里还混着 txnId=0 的非事务记录(CREATE_TABLE 等)以及其它事务。 const globalIndexToTxnIndex = new Map(); const txnRecordCount = new Map(); for (let i = 0; i < allRecords.length; i++) { const r = allRecords[i]; if (r.txnId === 0) continue; const n = txnRecordCount.get(r.txnId) ?? 0; globalIndexToTxnIndex.set(i, n); txnRecordCount.set(r.txnId, n + 1); } // 边界语义(必须显式定义,否则差一错误就在这里): // 写入侧记录的 `replayFromIndex = N` 表示"保存点建立时,本事务已成功追加了 N 条记录", // 即事务的第 0..N-1 条记录必须保留(BEGIN 是第 0 条)。 // 因此恢复侧应保留的**事务内下标区间**是 [0, N),丢弃 [N, 标记位置)。 // 等价地:只丢弃"事务内下标 >= N"且"在最后一个标记之前"的记录。 const txnReplayWindow = new Map(); for (let i = 0; i < allRecords.length; i++) { const r = allRecords[i]; if (r.type !== WALRecordType.SAVEPOINT_ROLLBACK) continue; const declared = r.data?.replayFromIndex; const keepUpTo = typeof declared === 'number' ? declared : 0; const txnIdx = globalIndexToTxnIndex.get(i) ?? 0; const prev = txnReplayWindow.get(r.txnId); // 多个保存点回滚:保留上界取**最早**的(回到最早的保存点), // 丢弃起点取**最后一个**标记的事务内下标。 txnReplayWindow.set(r.txnId, { keepUpTo: prev ? Math.min(prev.keepUpTo, keepUpTo) : keepUpTo, dropFrom: txnIdx, }); } // 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据 for (let i = 0; i < allRecords.length; i++) { const r = allRecords[i]; if (r.txnId === 0 || committedTxns.has(r.txnId)) { if (r.type === WALRecordType.SAVEPOINT_ROLLBACK) continue; // 标记记录,无数据 // 事务内落在 [start, end) 之外的记录:属于被 savepoint 回滚掉的部分,丢弃 const win = txnReplayWindow.get(r.txnId); if (win !== undefined) { const txnIdx = globalIndexToTxnIndex.get(i) ?? 0; // 保留 [0, keepUpTo);丢弃 [keepUpTo, dropFrom);标记之后的记录照常保留 if (txnIdx >= win.keepUpTo && txnIdx < win.dropFrom) continue; } if (r.type === WALRecordType.DROP_TABLE) { // v0.3.3: DROP_TABLE 回放(异步:需预加载 SSTable 后清除残留数据) await this.applyDropTableRecovery(r.tableName); } else { this.applyWALRecord(r); } } } // v0.3.3: 恢复完成后将回放数据落盘并截断 WAL, // 避免每次重启重复回放 + WAL 无限膨胀。 // // v0.8.0(B-6)顺序固定为:**数据落盘 → manifest 提交 → 才允许截断 WAL**。 // 每一步都有明确的失败语义: // - flush 失败 → `ARIA_BACKGROUND_ERROR` 抛出(不截断 WAL,下次打开还能重放); // - manifest 提交失败 → 同样不截断(回放数据仍可从 WAL 重建)。 // 另外:二级索引数据**不在 WAL 里**(回放只写主 LSM),因此重建索引后必须 // 先把索引 LSM 也落盘并提交,才能截断 WAL —— 否则崩溃后索引 memtable 丢失、 // WAL 又为空(不触发重建)→ 索引静默变空(v0.4.2 的同类缺陷)。 if (allRecords.length > 0) { await this.flushAllLsms(); // v0.4.2-fix: WAL 回放只更新主 LSM,二级索引 LSM 未同步 → // 崩溃前最后一批写入的索引缺失,重开时索引查询丢行。 // 恢复后全量重建所有表的二级索引(幂等)。 for (const tableName of this.schemas.keys()) { await this.reindexTableInternal(tableName); } await this.flushAllLsms(); await this.advanceWalCheckpoint(); } else if (this.hasPendingFlushData()) { // 没有回放记录但有未落盘数据(例如 manifest 已提交、内存态来自迁移): // 同样走完整顺序,避免"内存有数据而 WAL 已被截断" await this.flushAllLsms(); await this.advanceWalCheckpoint(); } // 7. 冻结表意图校验:manifest 声称有未落盘冻结表,但 WAL 已被截断/丢失 → // 这些"已确认写入"真的没了,必须报错而不是安静地少一批行。 this.verifyFrozenIntentsAfterRecovery(allRecords.length); // 8. Checkpoint Manager(接入 WAL 大小阈值) // v0.4.2-fix: 事务活跃时 checkpoint 不得截断 WAL — // 否则 BEGIN/INSERT 记录被截断,COMMIT 后崩溃恢复丢失整个事务数据 // // v0.8.0(B-6/55):checkpoint 只等 **memtable 落盘**,不再等 compaction。 // 此前 `lsm.flush()` 会排空整条链(含 compaction 级联),写路径每 1000 次操作 // 就要等完整 compaction —— 这正是 v0.6.1 记录的"8~11s 悬崖"的另一半。 this.checkpointManager = new CheckpointManager(this.lsm, { checkpoint: async () => { if (this.currentTxnId) return; // WAL 只在"数据已落盘 + manifest 已提交"之后才截断(见 advanceWalCheckpoint) await this.advanceWalCheckpoint(); }, flush: async () => { if (this.currentTxnId) return; await this.wal.flush(); }, getBufferedBytes: () => this.wal.getBufferedBytes(), getBufferedCount: () => this.wal.getBufferedCount(), }, { flushAll: async () => { // v0.6.1-fix: checkpoint 必须同时落盘二级索引 LSM — // 此前只 flush 主 LSM,checkpoint 截断 WAL 后崩溃时索引 memtable 未落盘、 // WAL 为空跳过重建 → 二级索引静默丢失最后一批条目(生产数据一致性问题) await this.flushAllLsms(); }, // v0.8.0(B-6/55):周期 checkpoint 只落 memtable,不等 compaction flushMemtables: async () => { await this.flushAllLsms(true); }, }, this.config.checkpointInterval, this.config.walSizeThreshold); this.opened = true; } async close() { if (!this.opened) return; // v0.8.0(B-6):关闭顺序 = 全部数据落盘 → manifest 提交 → 才截断 WAL。 // 每一步失败都不会导致"WAL 被截断而数据没落盘"。 // // v0.8.0:整体加 try/finally —— 落盘失败(如介质故障)时**仍然要**关闭后端、 // 释放 Web Locks 并清空运行期状态,然后把错误抛给调用方。修复前只要 flush // 抛错,后面每一步都不会执行:库锁永久占着、后端不关、内存状态残留 //(计划"生命周期与 API 面"清单里的 `close()` 无 try/finally 项)。 let failure = null; try { await this.flushAllLsms(); // v0.4.5: 页面化存储 — 落盘全部脏页(save 已逐页落盘,此处兜底) await this.bufferPool.flushAll(); await this.advanceWalCheckpoint(); } catch (error) { failure = error; } finally { try { await this.backend.close(); } catch (error) { if (failure === null) failure = error; } // v0.4.5: 释放多标签页独占锁(等待锁真正归还) if (this.dbLock) { try { await this.dbLock.release(); } catch { /* 锁释放失败不覆盖已有错误 */ } this.dbLock = null; } // v0.4.2-fix: 清空运行期状态(此前 close 后 mvcc/txn 残留, // 重开时 beginTransaction 报 TX_ACTIVE 或读到陈旧快照) this.schemas.clear(); this.tablePKs.clear(); this.secondaryIndexes.clear(); this.uniqueIndexCols.clear(); this.mvcc = new MVCCManager(); this.currentTxnId = null; this.txnSnapshot = null; this.savepoints.clear(); this.opCounter = 0; this.opened = false; } if (failure !== null) throw failure; } /** * v0.4.2-fix: 崩溃恢复/自愈 — 校验并移除损坏 SSTable、截断 WAL、重建二级索引。 * v0.4.5 增强:清理 OPFS 残留临时文件、清理孤儿页面(meta 未引用的 pg_ 文件)。 * 应用层检测到异常后调用,无需删库重建。 * * v0.8.0(B-6):孤儿回收**必须**以"manifest 健康"为前提。 * 修复前 `cleanupOrphanPages` 只读裸 JSON meta,读不出来就当"没有任何引用", * 于是元数据损坏时 repair 会把全部活页删掉(不可逆)。现在: * - 只要本次打开出现过损坏世代/被丢弃的 SSTable → 直接跳过回收并告警; * - 引用集合来自 manifest 的权威 meta 列表。 */ async repair() { this.ensureOpen(); // v0.6.0-fix: 先清页面缓存再校验 — 缓存中的"完好页面"会掩盖磁盘损坏 await this.bufferPool.clear(); // 1. 校验全部 SSTable,移除残缺项(打开时已做一次,此处兜底运行期损坏) let removed = 0; for (const lsm of this.allLsms()) { removed += await lsm.validateAll(); } // 2. 将 WAL 残留数据落盘并提交,之后才允许截断 await this.flushAllLsms(); await this.advanceWalCheckpoint(); // 3. 重建所有表的二级索引(修复索引与主数据不一致) for (const tableName of this.schemas.keys()) { await this.reindexTable(tableName); } await this.flushAllLsms(); await this.commitManifest(); // v0.8.0(B-6):此时两条链都已静默 → 没有在途读者 → 强制回收退休文件, // 避免它们被下一步的孤儿页回收"顺带"删掉(结果相同,但状态更干净) for (const lsm of this.allLsms()) lsm.reclaimRetiredNow(); // v0.4.5: 4. 清理 OPFS 残留临时文件(.crswap/.tmp) const backendAny = this.backend; if (typeof backendAny.cleanupStaleFiles === 'function') { try { await backendAny.cleanupStaleFiles(); } catch { /* 清理失败不阻塞 */ } } // v0.4.5: 5. 清理孤儿页面(manifest 全部命名空间均未引用的 pg_ 文件) await this.cleanupOrphanPages(); if (removed > 0) { // eslint-disable-next-line no-console console.warn(`[AriaEngine] repair: removed ${removed} corrupted SSTable(s)`); } } /** * v0.4.5: 清理孤儿页面 — 扫描全部 pg_* 文件,未被任何命名空间 meta 引用的删除。 * 孤儿页面来自:崩溃中断的 compaction/删除流程(旧 SSTable 页面残留)。 * * v0.8.0(B-6):引用集合取自 manifest;且**只有在没有损坏迹象时才执行** —— * "任何引用不到的东西一律保留而非删除"在恢复路径上是不变量,只有显式 repair * 且 manifest 完整可信时才允许回收空间。 */ async cleanupOrphanPages() { const damage = this.describeRecoveryDamage(); if (damage.length > 0) { // eslint-disable-next-line no-console console.warn(`[AriaEngine] repair: skipping orphan reclamation — recovery shows damage (${damage.join('; ')}); ` + 'unreferenced data is kept, never deleted blindly'); return; } // v0.8.0(review 修复):**有在途读者时一律不回收**。 // 读者的快照可能持有已被 compaction 取代("退休")的文件;那些文件的页面 // 既不在 manifest、也不在 levels 里 —— 按"没人引用"删掉它们会让正在进行中的 // 扫描静默少数据。回收只能在没有读者时做。 if (this.allLsms().some((lsm) => lsm.hasActiveReaders())) { // eslint-disable-next-line no-console console.warn('[AriaEngine] repair: skipping orphan reclamation — readers are active'); return; } const keys = await this.backend.listKeys(); // ---- 被引用的页面 id:manifest + 各层内存视图 + 退休表 ---- const used = new Set(); for (const state of Object.values(this.manifest.namespaces)) { for (const m of state.sstables) { if (m.pageIds) for (const pid of m.pageIds) used.add(pid); } } for (const lsm of this.allLsms()) { // 未落盘的页面(正在写入的 SSTable)不能删 for (const level of this.getLsmLevels(lsm)) { for (const meta of level) { if (meta.pageIds) for (const pid of meta.pageIds) used.add(pid); } } // 退休但尚未物理删除的 SSTable 的页面同样不能删 for (const pid of lsm.getRetiredPageIds()) used.add(pid); } const pgKeys = keys.filter((k) => /^pg_\d+$/.test(k)); const orphanIds = pgKeys .map((k) => Number(k.slice('pg_'.length))) .filter((pid) => !used.has(pid)); if (orphanIds.length > 0) { await this.backend.deleteMany(orphanIds.map((pid) => `pg_${pid}`)); // eslint-disable-next-line no-console console.warn(`[AriaEngine] repair: reclaimed ${orphanIds.length} orphan page file(s)`); } // ---- 孤儿 SSTable 文件(整 value 路径:sst__ / sst_)---- // 退休 SSTable 的物理删除是"尽力而为":删除失败/崩溃会留下既不被 manifest // 引用、也不在任何层里的文件。页面化路径由上面的 pg_ 回收覆盖; // 整 value 路径(pageStorage:false / 旧库 / 迁移数据)此前**没有任何回收路径**。 const usedSstIds = new Set(); for (const state of Object.values(this.manifest.namespaces)) { for (const m of state.sstables) usedSstIds.add(m.id); } for (const lsm of this.allLsms()) { for (const level of this.getLsmLevels(lsm)) { for (const meta of level) usedSstIds.add(meta.id); } for (const id of lsm.getRetiredSstableIds()) usedSstIds.add(id); } const orphanSstKeys = []; for (const [ns, prefix] of this.sstableKeyPrefixes()) { const re = new RegExp(`^${prefix}(\\d+)$`); for (const key of keys) { const m = re.exec(key); if (!m) continue; if (!usedSstIds.has(Number(m[1]))) orphanSstKeys.push(key); } } if (orphanSstKeys.length > 0) { await this.backend.deleteMany(orphanSstKeys); // eslint-disable-next-line no-console console.warn(`[AriaEngine] repair: reclaimed ${orphanSstKeys.length} orphan SSTable file(s)`); } } /** v0.8.0:命名空间 → SSTable 文件 key 前缀(与 createSSTableStore 保持一致) */ sstableKeyPrefixes() { const out = [['main', 'sst_']]; for (const ns of Object.keys(this.manifest.namespaces)) { if (ns !== 'main') out.push([ns, `sst_${ns}_`]); } return out; } /** v0.8.0: 读取某个 LSM 当前引用的层结构(诊断/孤儿回收用) */ getLsmLevels(lsm) { return lsm.levels ?? []; } /** * v0.4.1: 重置数据库 — 清空全部数据与表结构(演示页刷新/重新初始化用)。 * 清空存储后端、LSM、WAL、MVCC 与二级索引,后续可继续使用本实例。 */ async clearAll() { this.ensureOpen(); // 清空存储后端(页面文件 / WAL 记录 / schema 记录 / 元数据) await this.backend.clear(); // v0.4.5: 清空页面缓存与页面 ID 分配状态 await this.bufferPool.clear(); await this.fileManager.clearAll(); this.schemas.clear(); this.tablePKs.clear(); this.secondaryIndexes.clear(); this.uniqueIndexCols.clear(); this.lsm.clear(); this.mvcc = new MVCCManager(); this.currentTxnId = null; this.txnSnapshot = null; this.savepoints.clear(); this.opCounter = 0; // 持久化空 schema(防止旧 schema 记录残留) await this.persistSchemas(); // 重置 WAL 状态(backend.clear 已清记录,同步内存计数) // v0.8.0(B-6):清空后 manifest 里不能再残留任何 meta —— 否则重开会引用 // 已被删除的页面(幽灵数据)。这里把命名空间状态整表清掉并提交。 this.manifest.namespaces = {}; this.manifest.frozen = []; this.manifest.pageIdWatermark = Math.max(this.manifest.pageIdWatermark, this.fileManager.getNextPageId()); this.durableLsn = this.wal.getLsn(); this.walStartSegment = 0; // 整库清空:介质被整体抹掉、manifest 也重新从 0 开始 —— 此时才允许 // 把 WAL 分片编号也重置(其它任何路径都不允许复用分片号) this.wal.reset(); await this.commitManifest(); await this.wal.checkpoint(); } isOpen() { return this.opened; } // ---- v0.4.2-fix: 库内元数据(迁移版本持久化用) ---- async getMeta(key) { const raw = await this.backend.read(`__meta_${key}`); return raw ? new TextDecoder().decode(raw) : null; } async setMeta(key, value) { await this.backend.write(`__meta_${key}`, new TextEncoder().encode(value).buffer); } // ======================================================================= // 表管理 // ======================================================================= async createTable(schema) { this.ensureOpen(); // v0.4.2-fix: Aria 事务中 DDL 显式拒绝(事务快照只覆盖行数据, // 结构变更无法回滚;Memory/IndexedDB 引擎快照可回滚,行为不一致 → 明确报错而非静默) this.ensureNoDDLInTransaction('CREATE TABLE'); if (this.schemas.has(schema.name)) { throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS'); } // v0.8.0(A41):**先写 WAL 意图,再改内存/落盘**。 // // 此前顺序是"改内存 → persistSchemas → 追加 WAL",中间任何一步失败或崩溃, // 这次 DDL 都只留下一半状态。DROP 侧的顺序问题已实测确认: // dropTable 删完 LSM 与 schema、但 WAL 记录未写成时崩溃 → // 重开后表**又回来了**(数据也还在),DROP 被静默撤销。 // // WAL 是权威来源,且回放是幂等的(`applyWALRecord` 对已存在的表跳过、 // `applyDropTableRecovery` 对不存在的表是空操作),因此"先写 WAL"总能收敛: // - 崩溃于 WAL 之后、生效之前 → 恢复时重放,DDL 生效 ✓ // - 崩溃于生效之后 → 恢复时重放,幂等 ✓ await this.appendDDLRecord({ type: WALRecordType.CREATE_TABLE, txnId: 0, tableName: schema.name, key: '', data: { schema: JSON.stringify(schema) }, }); this.schemas.set(schema.name, schema); this.tablePKs.set(schema.name, this.getPK(schema)); // 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突) // v0.3.3: 主键列不建冗余二级索引(主 LSM 本身就是 PK 索引,范围查询走前缀扫描) for (const [colName, colDef] of Object.entries(schema.columns)) { if (colDef.index || colDef.unique) { const idxKey = `${schema.name}:idx:${colName}`; if (!this.secondaryIndexes.has(idxKey)) { const idxLsm = new LSM({ memtableSizeThreshold: this.config.memtableSizeThreshold, levelSizeMultiplier: this.config.levelSizeMultiplier, blockSize: this.config.pageSize, bloomBitsPerKey: this.config.bloomFilterBitsPerKey, cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize, sstableStore: this.createSSTableStore(`idx_${schema.name}_${colName}`), }); await idxLsm.init(); this.secondaryIndexes.set(idxKey, idxLsm); } } } await this.persistSchemas(); } /** * v0.8.0(A41):追加一条 DDL 意图记录并立即刷盘。 * * 为什么独立成函数:两条 DDL 路径必须共用同一套顺序与刷盘策略, * 否则将来只改一处又会漂移 —— 这正是本项目反复出现的缺陷模式。 * * DDL 不参与事务(`ensureNoDDLInTransaction` 已保证),因此 txnId 恒为 0, * 不需要提交/回滚语义;但**必须先于生效**写入,否则崩溃会静默丢失 DDL。 * DDL 是低频操作,这里同步刷盘,避免"崩溃丢失 DDL"的窗口过大。 */ async appendDDLRecord(record) { await this.wal.append(record); await this.wal.flush(); } async dropTable(tableName) { this.ensureOpen(); this.ensureNoDDLInTransaction('DROP TABLE'); this.ensureTable(tableName); // v0.8.0(A41):同 createTable —— **先写 WAL 意图**。 // 实测修复前:dropTable('other') 之后崩溃 → 重开 `tables = ["t","other"]` // 且 other 的行数据完好,DROP 被静默撤销(用户以为删掉了)。 await this.appendDDLRecord({ type: WALRecordType.DROP_TABLE, txnId: 0, tableName, key: '', }); // 删除表中所有行 const rows = await this.getAllRows(tableName); for (const row of rows) { const pkCol = this.tablePKs.get(tableName); this.lsm.delete(`${tableName}:${row[pkCol]}`); } // v0.4.2-fix: 清理该表的全部二级索引 LSM 与持久化文件 — // 此前残留孤儿索引,重建同名表后旧索引数据污染新表(索引查询返回错误行) await this.cleanupTableIndexes(tableName); // v0.7.4: 清理该表的 unique 索引来源标记 const uPrefix = `${tableName}:`; for (const k of this.uniqueIndexCols) { if (k.startsWith(uPrefix)) this.uniqueIndexCols.delete(k); } this.schemas.delete(tableName); this.tablePKs.delete(tableName); await this.persistSchemas(); } /** * v0.4.2-fix: 清理指定表的全部二级索引 LSM(内存 + 存储文件 + meta)。 * dropTable / DROP_TABLE 恢复 / alterTable DROP 索引列 共用。 */ async cleanupTableIndexes(tableName) { const prefix = `${tableName}:idx:`; const toDelete = []; for (const [idxKey, idxLsm] of this.secondaryIndexes) { if (!idxKey.startsWith(prefix)) continue; toDelete.push(idxKey); try { await idxLsm.clear(); } catch { /* 清理失败不阻塞 */ } } for (const idxKey of toDelete) { this.secondaryIndexes.delete(idxKey); } } async hasTable(tableName) { // v0.7.1: 未 open 防护统一(此前与 KVStoreEngine 不一致:返回空而非报错) this.ensureOpen(); return this.schemas.has(tableName); } async getTableNames() { this.ensureOpen(); return Array.from(this.schemas.keys()); } async getTableSchema(tableName) { this.ensureOpen(); return this.schemas.get(tableName) ?? null; } // ======================================================================= // CRUD // ======================================================================= async insert(tableName, rows) { this.ensureOpen(); this.ensureTable(tableName); const schema = this.schemas.get(tableName); const pkCol = this.tablePKs.get(tableName); const pks = []; // v0.3.1: 批量 WAL 写入(组提交),一次 insert 合并为一次落盘 const walRecords = []; // v0.6.1-perf: 批量预加载本批 PK 涉及的 SSTable(一次 drainChain)。 // 此前循环内逐行 prefetchKeys —— 每行 await drainChain 排空后台链, // 后台 compaction 在链上数秒时每行阻塞数秒 → 大数据量插入性能悬崖 // (10 万行 kv 后端从 5ms/批暴跌到 8~11s/批)。批内新数据在 memtable // 或 flush 产物(自动入缓存),循环内 lsm.get 始终完整。 // // v0.6.2: 整批预校验(验证失败整批不落库,语义更原子)+ 唯一约束检查。 const uniqueCols = this.uniqueColumns(tableName, schema); const validatedRows = []; for (const row of rows) { const validated = this.validateRow(schema, row); const pkValue = String(validated[pkCol]); validatedRows.push({ row: validated, pkValue, key: `${tableName}:${pkValue}` }); } // v0.7.3: 主键批内互查 + 预检 —— 此前 PK 重复检查在写入循环内: // 第 N 行重复抛错时,前 N-1 行已 put LSM 且其 WAL 记录随 appendBatch 一起 // 丢失 → 语句级部分提交 + 内存/WAL 不一致(与 v0.6.2 的 unique 预检同一阶段)。 const pkSet = new Set(); for (const { pkValue, key } of validatedRows) { if (pkSet.has(pkValue)) { throw new DatabaseError(`Duplicate primary key "${pkValue}" in table "${tableName}"`, 'DUPLICATE_KEY'); } pkSet.add(pkValue); // v0.8.0: 事务快照优先,否则回源 LSM(lsm.get 现为 async) const existing = this.currentTxnId ? (this.txnSnapshot?.get(key) ?? await this.lsm.get(key)) : await this.lsm.get(key); if (existing && !existing.__txn_deleted) { throw new DatabaseError(`Duplicate primary key "${pkValue}" in table "${tableName}"`, 'DUPLICATE_KEY'); } } // v0.8.0(B-6/55):唯一约束预检不再需要"批量预加载索引范围"。 // // 修复前这里要遍历本批所有唯一列值、把涉及的范围全部 prefetch 进缓存, // 只为满足"读之前必须先把数据读进缓存"这条隐式约定(LSM 缓存未命中会 // 静默跳过整个 SSTable)。现在读取自洽(未命中即回源 + CRC 校验), // 预加载这一步连同它带来的 drainChain 等待一起删除。 // v0.6.2: 唯一性整批预检(批内互查 + 索引查)—— 失败整批不落库(原子语义) const batchUnique = new Map(); for (const { row: validated, pkValue } of validatedRows) { for (const colName of uniqueCols) { const val = validated[colName]; if (val === undefined || val === null) continue; const v = String(val); let seen = batchUnique.get(colName); if (!seen) { seen = new Set(); batchUnique.set(colName, seen); } if (seen.has(v)) { throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION'); } seen.add(v); await this.checkUnique(tableName, [colName], validated, pkValue); } } for (const { row: validated, pkValue, key } of validatedRows) { // PK 重复已在批预检阶段检查(v0.7.3),此处不再重复查询 if (this.currentTxnId && this.txnSnapshot) { // Within transaction: buffer to snapshot + MVCC version chain this.txnSnapshot.set(key, validated); this.mvcc.writeVersion(tableName, pkValue, validated, this.currentTxnId); } else { // Direct write to LSM (PK index) this.lsm.put(key, validated); } // 更新二级索引 this.updateSecondaryIndexes(tableName, pkValue, validated, null); pks.push(pkValue); walRecords.push({ type: WALRecordType.INSERT, txnId: this.currentTxnId ?? 0, tableName, key: pkValue, data: validated, }); } await this.wal.appendBatch(walRecords); this.txnWalRecordCount += walRecords.length; this.opCounter += rows.length; this.checkMemoryBudget(); await this.checkpointManager.tick(); this.tryGC(); return pks; } async find(tableName, query) { this.ensureOpen(); this.ensureTable(tableName); let rows; // Try index lookup const fastPath = await this.tryIndexLookup(tableName, query); if (fastPath !== null) { rows = fastPath; } else { rows = await this.getAllRows(tableName); } // v0.3.3: 事务内合并未提交快照(统一在 mergeTxnSnapshot 处理) rows = this.mergeTxnSnapshot(tableName, rows); // WHERE filter if (query.where && Object.keys(query.where).length > 0) { rows = rows.filter((row) => matchWhere(row, query.where)); } // ORDER if (query.orderBy && query.orderBy.length > 0) { rows = applyOrderBy(rows, query.orderBy); } // LIMIT/OFFSET const offset = query.offset ?? 0; const limit = query.limit ?? rows.length; rows = rows.slice(offset, offset + limit); // Column projection if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') { rows = rows.map((row) => projectColumns(row, query.columns)); } // 查询完成,回收查询期间的临时缓存超限 this.trimAllCaches(); // v0.8.0: 行所有权 —— 返回副本,调用方不得改写存储(见 engine/interface.ts 约定) return rows.map((row) => cloneRow(row)); } async update(tableName, query, updates) { this.ensureOpen(); this.ensureTable(tableName); // v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析, // 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行 // v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。 // // 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析, // 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。 // B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能 // 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的 // 补救方向:用户没有做错任何事。 // // 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉, // 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。 const schema = this.schemas.get(tableName); const rows = await this.getAllRows(tableName); let count = 0; // v0.3.1: 批量 WAL 写入(组提交) const walRecords = []; // v0.4.2-fix: ON UPDATE 级联环路保护 const visited = new Set(); // v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空 const cleanUpdates = stripUndefinedUpdates(updates); // v0.7.4: 未知列显式报错 —— 此前 SET nonexistent = ... 被静默写入存储行 // (validateRow 只遍历 schema 列,脏列残留在行内并随 SSTable 持久化) for (const col of Object.keys(cleanUpdates)) { if (!schema.columns[col]) { throw new DatabaseError(`Column "${col}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND'); } } // v0.8.0(B-6/55):唯一约束检查不再需要预加载索引范围(读取自洽,见 insert) const uniqueCols = this.uniqueColumns(tableName, schema); // v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。 // 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入 // 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误 // (无事务下语句级部分提交 + 崩溃后进一步不一致)。 const planned = []; const batchUnique = new Map(); /** * v0.8.0 根治:批内新主键互查(与 MemoryEngine 对齐)。 * * 此前只检查"新主键是否已存在于**语句执行前**的表",看不到同一语句内其它行 * 即将写入的新主键。于是 `UPDATE t SET id = 'X'`(匹配 3 行)在阶段 2 逐行 * 覆盖同一 LSM key —— 返回 affected=3,表中却只剩 1 行(静默丢行,实测)。 */ const batchNewPks = new Set(); // 阶段 1:全量预检(任何一行失败 → 整条语句不执行) for (const row of rows) { const pkCol = this.tablePKs.get(tableName); const key = `${tableName}:${row[pkCol]}`; if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where)) continue; const updated = { ...row, ...cleanUpdates }; this.validateRow(schema, updated); // 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底) this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique); // v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在) await this.checkUnique(tableName, uniqueCols, updated, String(row[pkCol])); // v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录 const newPk = String(updated[pkCol]); const pkChanged = newPk !== String(row[pkCol]); // v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY // (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐) if (pkChanged) { const newKey = `${tableName}:${newPk}`; const existing = this.currentTxnId ? (this.txnSnapshot?.get(newKey) ?? await this.lsm.get(newKey)) : await this.lsm.get(newKey); if (existing && !existing.__txn_deleted) { throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY'); } // v0.8.0: 批内互查 —— 同一语句内两行改到同一新主键 → 整体拒绝(不得静默覆盖) if (batchNewPks.has(newPk)) { throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (multiple rows in the same statement update to the same key)`, 'DUPLICATE_KEY'); } batchNewPks.add(newPk); } planned.push({ row, pk: String(row[pkCol]), key, updated, newPk, pkChanged }); } // 阶段 1b:主键变更 RESTRICT / SET NULL+required 预检(任何修改前) for (const p of planned) { if (p.pkChanged) { await this.checkForeignKeyUpdateRestrict(tableName, p.pk, p.newPk); } } // 阶段 2:执行(预检已通过,此阶段不再抛校验类错误) for (const { row, pk, key, updated, newPk, pkChanged } of planned) { if (pkChanged) { // ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL) await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited); } if (this.currentTxnId && this.txnSnapshot) { if (pkChanged) { this.txnSnapshot.set(key, { __txn_deleted: true }); this.mvcc.deleteVersion(tableName, pk, this.currentTxnId); } this.txnSnapshot.set(`${tableName}:${newPk}`, updated); this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId); } else { if (pkChanged) this.lsm.delete(key); this.lsm.put(`${tableName}:${newPk}`, updated); } count++; if (pkChanged) { walRecords.push({ type: WALRecordType.DELETE, txnId: this.currentTxnId ?? 0, tableName, key: pk, }); } walRecords.push({ type: WALRecordType.UPDATE, txnId: this.currentTxnId ?? 0, tableName, key: newPk, data: updated, }); // 更新二级索引(主键变更时旧索引条目一并清理) // v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留 // (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值 this.updateSecondaryIndexes(tableName, newPk, updated, row); } await this.wal.appendBatch(walRecords); this.txnWalRecordCount += walRecords.length; this.opCounter += count; await this.checkpointManager.tick(); this.trimAllCaches(); return count; } /** * v0.7.2: 批内唯一互查 — 两条行在同一语句中更新到同一唯一值时的兜底检查 * (阶段 1 中索引尚未反映本语句的变更)。 */ checkBatchUnique(tableName, uniqueCols, updated, batchUnique) { for (const colName of uniqueCols) { const value = updated[colName]; if (value === undefined || value === null) continue; let seen = batchUnique.get(colName); if (!seen) { seen = new Set(); batchUnique.set(colName, seen); } if (seen.has(value)) { throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION'); } seen.add(value); } } /** * v0.7.2: ON UPDATE 外键预检 — 从 applyForeignKeyUpdateRules 提取(两阶段 update 用): * RESTRICT 存在依赖行抛错;SET NULL 撞 required 列同样整体拒绝。 */ async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) { for (const [refTableName, refSchema] of this.schemas) { // v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。 // // 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`) // 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。 // 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机: // 删除路径必须先递归子树再删父行,且**先收集引用者再处理** //(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。 for (const [colName, colDef] of Object.entries(refSchema.columns)) { if (!colDef.references || !colDef.onUpdate) continue; const [refTable] = colDef.references.split('.'); if (refTable !== tableName) continue; if (colDef.onUpdate === 'RESTRICT' || (colDef.onUpdate === 'SET NULL' && colDef.required)) { const refRows = await this.getAllRows(refTableName); for (const refRow of refRows) { if (String(refRow[colName]) === oldPk) { const reason = colDef.onUpdate === 'RESTRICT' ? `foreign key "${colName}" in "${refTableName}" has dependent rows` : `foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`; throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": ${reason}`, 'FOREIGN_KEY_VIOLATION'); } } } } } } /** * v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。 * RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。 * 两阶段:先全量 RESTRICT 检查,再执行级联。 */ async applyForeignKeyUpdateRules(tableName, oldPk, newPk, walRecords, visited) { const visitKey = `${tableName}:${oldPk}`; if (visited.has(visitKey)) return; visited.add(visitKey); // v0.7.3-perf: 删除冗余的阶段 1 RESTRICT 扫描 —— checkForeignKeyUpdateRestrict // 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required, // 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。 for (const [refTableName, refSchema] of this.schemas) { // v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。 // // 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`) // 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。 // 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机: // 删除路径必须先递归子树再删父行,且**先收集引用者再处理** //(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。 for (const [colName, colDef] of Object.entries(refSchema.columns)) { if (!colDef.references || !colDef.onUpdate) continue; const [refTable] = colDef.references.split('.'); if (refTable !== tableName) continue; if (colDef.onUpdate !== 'CASCADE' && colDef.onUpdate !== 'SET NULL') continue; const refRows = await this.getAllRows(refTableName); for (const refRow of refRows) { if (String(refRow[colName]) !== oldPk) continue; const refPkCol = this.tablePKs.get(refTableName); const refPk = String(refRow[refPkCol]); const updatedRef = { ...refRow, [colName]: colDef.onUpdate === 'CASCADE' ? newPk : null }; const refKey = `${refTableName}:${refPk}`; if (this.currentTxnId && this.txnSnapshot) { this.txnSnapshot.set(refKey, updatedRef); this.mvcc.writeVersion(refTableName, refPk, updatedRef, this.currentTxnId); } else { this.lsm.put(refKey, updatedRef); } this.updateSecondaryIndexes(refTableName, refPk, updatedRef, refRow); walRecords.push({ type: WALRecordType.UPDATE, txnId: this.currentTxnId ?? 0, tableName: refTableName, key: refPk, data: updatedRef, }); } } } } async delete(tableName, query) { this.ensureOpen(); this.ensureTable(tableName); // v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析, // 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行 // v0.8.0(B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。 // // 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析, // 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。 // B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能 // 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的 // 补救方向:用户没有做错任何事。 // // 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉, // 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。 const rows = await this.getAllRows(tableName); let count = 0; // v0.3.1: 批量 WAL 写入(组提交) const walRecords = []; // v0.4.1: 外键级联(环路保护) const visited = new Set(); // v0.6.3-fix: 级联两阶段 —— 先对全部匹配行做 RESTRICT 预检(沿 CASCADE 链递归), // 否则第 N 行 RESTRICT 抛错时前 N-1 行的级联已执行 → 无事务部分级联(数据不一致) const matchedPks = []; for (const row of rows) { if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) { matchedPks.push(String(row[this.tablePKs.get(tableName)])); } } const restrictVisited = new Set(); for (const pkValue of matchedPks) { await this.checkCascadeRestrict(tableName, pkValue, restrictVisited); } for (const row of rows) { const pkCol = this.tablePKs.get(tableName); const key = `${tableName}:${row[pkCol]}`; if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) { // v0.4.1: 外键规则(RESTRICT 抛错 / CASCADE 递归删 / SET NULL 置空) count += await this.applyForeignKeyRules(tableName, String(row[pkCol]), walRecords, visited); if (this.currentTxnId && this.txnSnapshot) { // Buffer delete in snapshot + MVCC tombstone this.txnSnapshot.set(key, { __txn_deleted: true }); this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId); } else { this.lsm.delete(key); } count++; walRecords.push({ type: WALRecordType.DELETE, txnId: this.currentTxnId ?? 0, tableName, key: String(row[pkCol]), }); // 移除二级索引 this.updateSecondaryIndexes(tableName, String(row[pkCol]), null, row); } } await this.wal.appendBatch(walRecords); this.txnWalRecordCount += walRecords.length; this.opCounter += count; await this.checkpointManager.tick(); this.trimAllCaches(); return count; } /** * v0.6.3: RESTRICT 预检(delete 级联两阶段之一,与 MemoryEngine 对齐)。 * 递归沿 CASCADE 链检查引用表:RESTRICT 引用存在依赖行则抛 FOREIGN_KEY_VIOLATION。 */ async checkCascadeRestrict(tableName, pkValue, visited) { const visitKey = `${tableName}:${pkValue}`; if (visited.has(visitKey)) return; visited.add(visitKey); for (const [refTableName, refSchema] of this.schemas) { // v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。 // // 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`) // 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。 // 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机: // 删除路径必须先递归子树再删父行,且**先收集引用者再处理** //(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。 for (const [colName, colDef] of Object.entries(refSchema.columns)) { if (!colDef.references || !colDef.onDelete) continue; const [refTable] = colDef.references.split('.'); if (refTable !== tableName) continue; const refRows = await this.getAllRows(refTableName); const matched = refRows.filter((r) => String(r[colName]) === pkValue); if (colDef.onDelete === 'RESTRICT' && matched.length > 0) { throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION'); } // v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝 if (colDef.onDelete === 'SET NULL' && colDef.required && matched.length > 0) { throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION'); } if (colDef.onDelete === 'CASCADE') { const refPkCol = this.tablePKs.get(refTableName); for (const refRow of matched) { await this.checkCascadeRestrict(refTableName, String(refRow[refPkCol]), visited); } } } } } /** * v0.4.1: 外键级联规则 — 对齐 MemoryEngine.cascadeDelete 行为。 * 删除 tableName 主键为 pkValue 的行前,检查引用它的所有表: * - RESTRICT: 存在引用行 → 抛 FOREIGN_KEY_VIOLATION * - CASCADE: 递归删除引用行(含索引/WAL) * - SET NULL: 引用行外键列置 null(含索引/WAL) * @returns 级联影响的行数(CASCADE 删除行数 + SET NULL 更新行数) */ async applyForeignKeyRules(tableName, pkValue, walRecords, visited) { let total = 0; const visitKey = `${tableName}:${pkValue}`; if (visited.has(visitKey)) return 0; visited.add(visitKey); for (const [refTableName, refSchema] of this.schemas) { // v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。 // // 此前这里 `continue`:`parent_id REFERENCES node(id)` 的树形自引用完全不做 // 级联 → `DELETE root` 只删 root,子树全部残留且 parent_id 指向已删除行 //(父行已不在,之后再也无法通过级联清理 —— 永久悬挂)。 // 与 MemoryEngine 的修复同源(两个引擎此前的跳过条件逐字相同)。 for (const [colName, colDef] of Object.entries(refSchema.columns)) { if (!colDef.references || !colDef.onDelete) continue; const [refTable] = colDef.references.split('.'); if (refTable !== tableName) continue; // 自引用场景下 `getAllRows` 返回的是当前表的快照副本(cloneRow), // 因此循环内的删除不会改变 `matched` —— 这正是这里能安全递归的原因。 const refRows = await this.getAllRows(refTableName); const matched = refRows.filter((r) => String(r[colName]) === pkValue); if (colDef.onDelete === 'RESTRICT' && matched.length > 0) { throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION'); } if (colDef.onDelete === 'CASCADE') { const refPkCol = this.tablePKs.get(refTableName); for (const refRow of matched) { const refPk = String(refRow[refPkCol]); // 递归级联(先处理更深层引用) total += await this.applyForeignKeyRules(refTableName, refPk, walRecords, visited); // 删除引用行 const refKey = `${refTableName}:${refPk}`; if (this.currentTxnId && this.txnSnapshot) { this.txnSnapshot.set(refKey, { __txn_deleted: true }); this.mvcc.deleteVersion(refTableName, refPk, this.currentTxnId); } else { this.lsm.delete(refKey); } this.updateSecondaryIndexes(refTableName, refPk, null, refRow); walRecords.push({ type: WALRecordType.DELETE, txnId: this.currentTxnId ?? 0, tableName: refTableName, key: refPk, }); total++; } } else if (colDef.onDelete === 'SET NULL') { const refPkCol = this.tablePKs.get(refTableName); for (const refRow of matched) { const refPk = String(refRow[refPkCol]); const updated = { ...refRow, [colName]: null }; const refKey = `${refTableName}:${refPk}`; if (this.currentTxnId && this.txnSnapshot) { this.txnSnapshot.set(refKey, updated); this.mvcc.writeVersion(refTableName, refPk, updated, this.currentTxnId); } else { this.lsm.put(refKey, updated); } this.updateSecondaryIndexes(refTableName, refPk, updated, refRow); walRecords.push({ type: WALRecordType.UPDATE, txnId: this.currentTxnId ?? 0, tableName: refTableName, key: refPk, data: updated, }); // 对齐 Memory 语义:SET NULL 不影响返回的删除行数 } } } } return total; } /** * v0.4.0: 流式查询 — 逐行回调,不物化结果数组。 * 全表路径走 LSM rangeScanLazy 惰性扫描;索引等值/范围路径复用 tryIndexLookup。 * 事务中回退物化(快照合并需要全量行集)。 */ async findStream(tableName, query, onRow) { this.ensureOpen(); this.ensureTable(tableName); const hasWhere = !!(query.where && Object.keys(query.where).length > 0); const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*' ? (row) => projectColumns(row, query.columns) : null; const limit = query.limit ?? Infinity; const offset = query.offset ?? 0; const pkCol = this.tablePKs.get(tableName); const prefix = `${tableName}:`; let count = 0; let skipped = 0; const emit = (row) => { if (hasWhere && !matchWhere(row, query.where)) return true; if (skipped < offset) { skipped++; return true; } onRow(project ? project(row) : cloneRow(row)); count++; return count < limit; }; if (this.currentTxnId && this.txnSnapshot) { // 事务中:物化后逐行回调(快照合并需要全量行集) const rows = await this.find(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined }); for (const row of rows) { onRow(project ? project(row) : cloneRow(row)); } return rows.length; } // 索引路径:等值/范围查找(结果行已过滤,直接回调) const fastPath = await this.tryIndexLookup(tableName, query); if (fastPath !== null) { for (const row of fastPath) { if (!emit(row)) break; } return count; } // 全表惰性扫描(含 WHERE 过滤,不物化;v0.7.4: callback 返回 false 提前终止, // 未消费的 SSTable 块 / 子树不再解析 —— 真流式,大表 limit 内存 O(1)) await this.lsm.rangeScanLazy(prefix, `${prefix}\uffff`, (key, value) => { if (count >= limit) return false; const row = { ...value }; row[pkCol] = key.slice(prefix.length); return emit(row); }); return count; } async count(tableName, query) { this.ensureOpen(); this.ensureTable(tableName); const rows = await this.getAllRows(tableName); this.trimAllCaches(); if (!query?.where || Object.keys(query.where).length === 0) return rows.length; return rows.filter((row) => matchWhere(row, query.where)).length; } async clear(tableName) { this.ensureOpen(); this.ensureTable(tableName); const rows = await this.getAllRows(tableName); // v0.3.3: 事务内清空走快照(删除标记),提交时生效;并写入 WAL const walRecords = []; for (const row of rows) { const pkCol = this.tablePKs.get(tableName); const key = `${tableName}:${row[pkCol]}`; if (this.currentTxnId && this.txnSnapshot) { this.txnSnapshot.set(key, { __txn_deleted: true }); this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId); } else { this.lsm.delete(key); } walRecords.push({ type: WALRecordType.DELETE, txnId: this.currentTxnId ?? 0, tableName, key: String(row[pkCol]), }); // 移除二级索引 this.updateSecondaryIndexes(tableName, String(row[pkCol]), null, row); } await this.wal.appendBatch(walRecords); this.txnWalRecordCount += walRecords.length; this.opCounter += rows.length; await this.checkpointManager.tick(); this.tryGC(); } // ---- ALTER TABLE(v0.4.1) ---- /** * v0.4.1: ALTER TABLE — 结构变更真正生效于存储: * - ADD: 持久化 schema(persistSchemas),行无需修改 * - DROP: 持久化 schema + 遍历主 LSM 重写所有行(移除该列键)+ WAL UPDATE 记录 * (通用路径 getTableSchema 返回副本,Executor 的引用修改对 Aria 无效) */ async alterTable(tableName, action, column) { this.ensureOpen(); this.ensureNoDDLInTransaction('ALTER TABLE'); this.ensureTable(tableName); const schema = this.schemas.get(tableName); if (action === 'ADD') { if (schema.columns[column.name]) { throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS'); } // v0.8.0(A41):先写 ALTER 意图(含**变更后**的完整 schema),再改内存/索引。 // // 此前完全不写 WAL:先改内存 schema → 建索引(可能因存量重复值抛错)→ // persistSchemas。中间抛错就留下"内存已加列、磁盘没加"的分裂状态 —— // 同进程 `getTableSchema` 看到新列,重开后新列消失,用户看到的是 // "ALTER 有时生效有时不生效,取决于是否重启"。 // 有了意图记录,崩溃/失败后恢复会按它把结构补齐(幂等覆盖)。 const intendedSchema = { name: schema.name, columns: { ...schema.columns, [column.name]: column }, }; await this.appendDDLRecord({ type: WALRecordType.ALTER_TABLE, txnId: 0, tableName, key: column.name, data: { schema: JSON.stringify(intendedSchema), action: 'ADD' }, }); schema.columns[column.name] = column; // v0.8.0 根治:ALTER ADD 的索引/唯一列必须真正建立索引 LSM 并回填。 // // 此前只写 schema + persistSchemas,索引 LSM 从未创建 → `unique` 标记形同虚设, // 重复值可任意写入(实测四个引擎全部接受);重启时索引才被建出来,与 Memory // "重启静默丢行"是同一问题的另一半。 // // 复用 createIndex:它已经实现了"回填 + 存量唯一性校验 + 失败时原子清理" // (v0.6.2/v0.7.3 的修复成果),此处不重复实现以避免再次漂移。 if (column.index || column.unique) { // createIndex 会读取 schema.columns[column],上面的赋值已满足 await this.createIndex(tableName, column.name, column.unique === true); } await this.persistSchemas(); return; } // DROP if (!schema.columns[column.name]) { throw new DatabaseError(`Column "${column.name}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND'); } // v0.4.2-fix: 被删列是索引列 → 先清理索引 LSM(残留会导致后续同名列索引脏数据) if (schema.columns[column.name].index || schema.columns[column.name].unique) { const idxKey = `${tableName}:idx:${column.name}`; const idxLsm = this.secondaryIndexes.get(idxKey); if (idxLsm) { try { await idxLsm.clear(); } catch { /* 清理失败不阻塞 */ } this.secondaryIndexes.delete(idxKey); } } // v0.8.0(A41):DROP 同样先写意图(变更后的完整 schema) { const intendedSchema = { name: schema.name, columns: Object.fromEntries(Object.entries(schema.columns).filter(([col]) => col !== column.name)), }; await this.appendDDLRecord({ type: WALRecordType.ALTER_TABLE, txnId: 0, tableName, key: column.name, data: { schema: JSON.stringify(intendedSchema), action: 'DROP' }, }); } delete schema.columns[column.name]; await this.persistSchemas(); // 重写主 LSM:移除所有行的该列键(find 副本无法就地删除,必须重写存储) const prefix = `${tableName}:`; const endKey = `${prefix}\uffff`; const entries = await this.lsm.rangeScan(prefix, endKey); const walRecords = []; for (const [key, value] of entries) { if (!(column.name in value)) continue; const updated = { ...value }; delete updated[column.name]; this.lsm.put(key, updated); // 二级索引列被删时同步清理索引 const pk = key.slice(prefix.length); this.updateSecondaryIndexes(tableName, pk, updated, value); walRecords.push({ type: WALRecordType.UPDATE, txnId: this.currentTxnId ?? 0, tableName, key: pk, data: updated, }); } await this.wal.appendBatch(walRecords); this.txnWalRecordCount += walRecords.length; this.opCounter += walRecords.length; await this.checkpointManager.tick(); this.trimAllCaches(); } async createIndex(tableName, column, unique) { this.ensureOpen(); this.ensureNoDDLInTransaction('CREATE INDEX'); this.ensureTable(tableName); const schema = this.schemas.get(tableName); const colDef = schema.columns[column]; if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND'); const idxKey = `${tableName}:idx:${column}`; // v0.4.2-fix: 以索引 LSM 是否已建为准(schema 标记可能因重启恢复而存在, // 但索引 LSM 未恢复 → 此前静默 return 导致索引永久缺失) if (this.secondaryIndexes.has(idxKey)) return; const idxLsm = new LSM({ memtableSizeThreshold: this.config.memtableSizeThreshold, levelSizeMultiplier: this.config.levelSizeMultiplier, blockSize: this.config.pageSize, bloomBitsPerKey: this.config.bloomFilterBitsPerKey, cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize, sstableStore: this.createSSTableStore(`idx_${tableName}_${column}`), }); await idxLsm.init(); this.secondaryIndexes.set(idxKey, idxLsm); try { // 从主 LSM 重建索引数据 const pkCol = this.tablePKs.get(tableName); const rows = await this.getAllRows(tableName); const seen = new Set(); for (const row of rows) { const value = row[column]; if (value !== undefined && value !== null) { const v = String(value); // v0.7.3: UNIQUE 索引回填校验存量唯一性 —— 此前重复数据静默建索引 // (SQLite 语义应报错),与 MemoryEngine 对齐 if (unique && seen.has(v)) { throw new DatabaseError(`Unique index on column "${column}" in table "${tableName}" cannot be created: duplicate value "${v}"`, 'UNIQUE_VIOLATION'); } seen.add(v); idxLsm.put(`${v}:${row[pkCol]}`, { pk: row[pkCol] }); } } await idxLsm.flush(); } catch (error) { // 回填失败(唯一冲突):清理半初始化索引(内存 + 存储),标志未落,保持原子语义 this.secondaryIndexes.delete(idxKey); try { await idxLsm.clear(); } catch { /* 清理失败不阻塞 */ } throw error; } colDef.index = true; if (unique) { colDef.unique = true; // v0.7.4: 记录唯一约束来源(DROP INDEX 时可解除;建表约束不可) this.uniqueIndexCols.add(`${tableName}:${column}`); } await this.persistSchemas(); } async dropIndex(tableName, column, _indexName) { this.ensureOpen(); this.ensureNoDDLInTransaction('DROP INDEX'); this.ensureTable(tableName); const schema = this.schemas.get(tableName); const colDef = schema.columns[column]; if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND'); // 主键索引不可删除(PK 查找依赖主 LSM) if (colDef.primaryKey) { throw new DatabaseError(`Cannot drop primary key index on column "${column}"`, 'NOT_SUPPORTED'); } // v0.4.1: DROP 不存在的索引应报错(此前静默成功) if (!colDef.index && !colDef.unique && !this.secondaryIndexes.has(`${tableName}:idx:${column}`)) { throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND'); } // v0.7.4: 建表 UNIQUE 约束不可通过 DROP INDEX 解除 —— 此前 colDef.unique = false // 静默解除约束(重启后 persistSchemas 使约束永久消失)。对齐 SQLite 语义: // 约束随建表存在,解除需重建表;仅 CREATE UNIQUE INDEX 添加的约束可随索引删除。 const uniqueKey = `${tableName}:${column}`; if (colDef.unique && !this.uniqueIndexCols.has(uniqueKey)) { throw new DatabaseError(`Cannot drop index on column "${column}" in table "${tableName}": ` + 'UNIQUE constraint defined at table creation must be removed by recreating the table', 'NOT_SUPPORTED'); } colDef.index = false; colDef.unique = false; this.uniqueIndexCols.delete(uniqueKey); const idxKey = `${tableName}:idx:${column}`; const idxLsm = this.secondaryIndexes.get(idxKey); if (idxLsm) { await idxLsm.clear(); this.secondaryIndexes.delete(idxKey); } await this.persistSchemas(); } // ======================================================================= // 事务 // ======================================================================= async beginTransaction() { if (this.currentTxnId) throw new DatabaseError('Transaction already in progress', 'TX_ACTIVE'); this.currentTxnId = this.mvcc.beginTransaction(); this.txnSnapshot = new Map(); // v0.8.0: 事务内 WAL 记录计数从 0 开始(保存点边界依赖它) this.txnWalRecordCount = 0; // v0.7.3-fix: WAL BEGIN 写失败回滚内存事务状态 —— 此前 append 抛错(full 模式) // 时 currentTxnId 已设置 → TX_ACTIVE 永久泄漏(后续无法开始新事务)。 // 回滚 mvcc 登记 + 快照后重抛,调用方可重试。 try { await this.wal.append({ type: WALRecordType.BEGIN, txnId: this.currentTxnId, tableName: '', key: '', }); this.txnWalRecordCount = 1; // BEGIN 本身占一条 } catch (error) { this.mvcc.rollbackTransaction(this.currentTxnId); this.currentTxnId = null; this.txnSnapshot = null; throw error; } } async commitTransaction() { if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE'); // v0.4.3-fix: 先持久化 WAL COMMIT,再合并快照到 LSM — // 崩溃在 WAL 提交后、快照合并前:恢复时 WAL 重放数据,重启一致; // 崩溃在 WAL 提交前:commitTransaction 尚未返回,事务视为未提交(可回滚) await this.wal.append({ type: WALRecordType.COMMIT, txnId: this.currentTxnId, tableName: '', key: '', }); await this.wal.flush(); if (this.txnSnapshot) { for (const [key, value] of this.txnSnapshot) { if (value.__txn_deleted) { this.lsm.delete(key); } else { this.lsm.put(key, value); } } } this.mvcc.commitTransaction(this.currentTxnId); // v0.8.0 根治:事务结束时清空 savepoint 表。 // 此前 commit/rollback **都不清理** savepoints,于是: // 1. 上一个事务的名字仍被占用 → 新事务 SAVEPOINT 同名报 "already exists"; // 2. 新事务 ROLLBACK TO 陈旧名字会拿到旧事务快照,把当前事务的写入静默替换。 this.savepoints.clear(); this.savepointWalBoundary.clear(); this.txnWalRecordCount = 0; this.currentTxnId = null; this.txnSnapshot = null; } async rollbackTransaction() { if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE'); const txnId = this.currentTxnId; // v0.7.3-fix: 先持久化 WAL ROLLBACK,再回滚内存 —— 与 commitTransaction 的 // "WAL 领先内存"(v0.4.3-fix)对齐。此前内存先回滚、ROLLBACK 记录后写: // full 模式写失败时崩溃重放无 ROLLBACK 记录 → 已回滚事务的数据复活。 // 现在写失败 → 内存未回滚、事务仍活跃(调用方可重试),崩溃后重放 // 看到 ROLLBACK 记录同样不会复活数据。 await this.wal.append({ type: WALRecordType.ROLLBACK, txnId, tableName: '', key: '', }); // v0.3.3: 记录事务涉及的表(用于回滚后重建索引,消除索引残留) const affectedTables = new Set(); if (this.txnSnapshot) { for (const key of this.txnSnapshot.keys()) { const idx = key.indexOf(':'); if (idx > 0) affectedTables.add(key.slice(0, idx)); } } this.mvcc.rollbackTransaction(txnId); this.txnSnapshot = null; // v0.8.0:事务结束清空 savepoint(理由同 commitTransaction) this.savepoints.clear(); this.savepointWalBoundary.clear(); this.txnWalRecordCount = 0; this.currentTxnId = null; // v0.3.3: 事务内直接写入了二级索引 LSM,回滚后全量重建受影响表的索引 for (const tableName of affectedTables) { if (this.schemas.has(tableName)) { await this.reindexTable(tableName); } } } async savepoint(name) { if (!this.currentTxnId) throw new DatabaseError('No active transaction for savepoint', 'TX_NONE'); if (this.savepoints.has(name)) throw new DatabaseError(`Savepoint "${name}" already exists`, 'SAVEPOINT_EXISTS'); // 保存当前事务快照 this.savepoints.set(name, { txnId: this.currentTxnId, snapshot: this.txnSnapshot ? new Map(this.txnSnapshot) : null, }); // v0.8.0: 记录边界(该事务已追加的记录条数)—— 见 txnWalRecordCount 说明 this.savepointWalBoundary.set(`${this.currentTxnId}:${name}`, this.txnWalRecordCount); } async rollbackToSavepoint(name) { const sp = this.savepoints.get(name); if (!sp) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND'); // v0.8.0 根治:savepoint 归属校验。 // 此前只按名字查找,而 savepoints 在 commit/rollback 时**从不清空** —— // 上一个事务遗留的 savepoint 会让当前事务的写入被旧事务快照静默替换 // (实测:本事务 update 后 ROLLBACK TO 陈旧名 + COMMIT,写入凭空消失)。 if (sp.txnId !== this.currentTxnId) { throw new DatabaseError(`Savepoint "${name}" belongs to a different transaction (stale savepoint)`, 'SAVEPOINT_NOT_FOUND'); } // v0.8.0: 先写 WAL 标记再改内存(与 commit/rollback 的"WAL 领先内存"一致)—— // 否则崩溃恢复会把该事务在 savepoint 之前写入的记录重新应用,已回滚的行复活。 const boundary = this.savepointWalBoundary.get(`${this.currentTxnId}:${name}`) ?? 0; await this.wal.append({ type: WALRecordType.SAVEPOINT_ROLLBACK, txnId: this.currentTxnId, tableName: '', key: '', data: { replayFromIndex: boundary }, }); this.txnWalRecordCount++; this.savepointWalBoundary.set(`${this.currentTxnId}:${name}`, boundary); // v0.6.3-fix: 记录当前快照涉及的表(回滚后重建索引)—— 此前事务内直写 // 索引 LSM,savepoint 回滚只还原快照 → savepoint 之后的索引条目残留 const affectedTables = new Set(); if (this.txnSnapshot) { for (const key of this.txnSnapshot.keys()) { const idx = key.indexOf(':'); if (idx > 0) affectedTables.add(key.slice(0, idx)); } } // 恢复到 savepoint 时的快照 this.txnSnapshot = sp.snapshot ? new Map(sp.snapshot) : null; // v0.3.3: 清理该事务在 MVCC 版本链中的全部记录(快照已含正确数据, // 版本链仅作 undo 记录,清空后 commit 时 LSM 写入与快照保持一致) this.mvcc.discardVersions(this.currentTxnId); // 清除此 savepoint 之后的所有 savepoint let found = false; for (const [k] of this.savepoints) { if (k === name) { found = true; continue; } if (found) this.savepoints.delete(k); } // v0.6.3-fix: 重建受影响表二级索引(消除 savepoint 之后的过期索引条目) for (const tableName of affectedTables) { if (this.schemas.has(tableName)) { await this.reindexTable(tableName); } } } async releaseSavepoint(name) { if (!this.savepoints.has(name)) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND'); this.savepoints.delete(name); } // ---- 在线备份 ---- async backup() { this.ensureOpen(); const result = {}; for (const tableName of this.schemas.keys()) { result[tableName] = await this.getAllRows(tableName); } return result; } // ======================================================================= // 内部 // ======================================================================= async getAllRows(tableName) { const pkCol = this.tablePKs.get(tableName); const prefix = `${tableName}:`; // 预加载范围内涉及的 SSTable,避免 rangeScan 时缓存未命中静默丢数据 const entries = await this.lsm.rangeScan(prefix, `${prefix}\uffff`); const rows = entries.map(([key, value]) => { // v0.8.0: 深拷贝(此前 `{ ...value }` 只做浅拷贝,嵌套 json 值仍与 LSM // 内部对象共享引用 —— 调用方改 rows[0].nested.a 会改写存储) const row = cloneRow(value); row[pkCol] = key.slice(prefix.length); return row; }); // v0.3.3: 事务内合并未提交快照(update/delete/count/clear 也能看到本事务的写入) return this.mergeTxnSnapshot(tableName, rows); } /** * v0.3.3: 将事务未提交快照的变更合并到行列表(新增/更新/删除标记)。 * 幂等操作:行已是最新时不重复修改。 */ mergeTxnSnapshot(tableName, rows) { if (!this.currentTxnId || !this.txnSnapshot) return rows; const pkCol = this.tablePKs.get(tableName); const prefix = `${tableName}:`; for (const [key, value] of this.txnSnapshot) { if (!key.startsWith(prefix)) continue; const pk = key.slice(prefix.length); const del = value.__txn_deleted; const idx = rows.findIndex((r) => r[pkCol] === pk); if (del) { if (idx >= 0) rows.splice(idx, 1); } else { const row = { ...value, [pkCol]: pk }; if (idx >= 0) rows[idx] = row; else rows.push(row); } } return rows; } getPK(schema) { for (const [name, col] of Object.entries(schema.columns)) { if (col.primaryKey) return name; } return Object.keys(schema.columns)[0]; } validateRow(schema, row) { // v0.8.0(B-1):委托给**唯一**的行校验实现(table/validation.ts)。 // // 此前这里是第二份独立实现(经由 checkFieldType 恰好覆盖了 maxLength/min/max, // 而 Memory 那份没有)—— 于是同一份 schema、同一条 INSERT 是否报约束错误 // 取决于选了哪个引擎(缺陷 A12),且两者对未知列都静默丢弃(A17)。 return compileValidator(schema).validateRow(row); } /** * v0.8.0(B-1):写入前置校验 —— 见 `IStorageEngine.validatePayload` 契约。 */ async validatePayload(tableName, rows, mode = 'insert') { this.ensureTable(tableName); const schema = this.schemas.get(tableName); const validator = compileValidator(schema); for (const row of rows) { if (mode === 'update') validator.validatePartial(stripUndefinedUpdates(row)); else validator.validateRow(row); } } // ======================================================================= // Schema 持久化 // ======================================================================= /** * v0.8.0(B-6):表结构**随 manifest 一起提交**(单一提交点)。 * * 修复前 DDL 结束时会单独写一份 `__aria_schemas`:结构变更与存储状态 * (SSTable meta / 页面 / WAL 水位)各自落盘,中间崩溃就会留下"结构说加过列、 * 数据里没有"或反之的分裂状态。现在两者在同一次原子提交里生效。 */ async persistSchemas() { await this.commitManifest(); } /** * v0.8.0(review 修复):表结构记录的**唯一**解析实现。 * * 为什么必须集中且严格:结构记录有两种坏法 —— JSON 本身就坏了,或 JSON 合法但 * 形状不对(数组 / null / 表名映射到非对象 / 列定义不是对象)。修复前只有 * "JSON 坏"这一种会抛错,形状不对则被**静默忽略** → 打开后看不到任何表, * 表现为"库是空的"(与审计里"静默空库"同一类缺陷)。 * * 判定原则:只要记录存在却不可用,就抛 ARIA_LEGACY_META_CORRUPT —— 宁可让 * 调用方看到明确的损坏错误,也不假装这是一个没有表的空库。 */ parseSchemaRecord(raw, source) { let parsed; try { parsed = JSON.parse(new TextDecoder().decode(raw)); } catch (error) { throw new DatabaseError(`${source} is corrupt and cannot be loaded: ${error.message}`, 'ARIA_LEGACY_META_CORRUPT', error); } return this.validateSchemaShape(parsed, source); } /** 形状校验(与 parseSchemaRecord 分离:便于直接喂各种形状做单测) */ validateSchemaShape(parsed, source) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new DatabaseError(`${source} is not a table-name → columns object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`, 'ARIA_LEGACY_META_CORRUPT'); } const out = {}; for (const [tableName, columns] of Object.entries(parsed)) { if (!columns || typeof columns !== 'object' || Array.isArray(columns)) { throw new DatabaseError(`${source} entry "${tableName}" is not a column map (got ${Array.isArray(columns) ? 'array' : typeof columns})`, 'ARIA_LEGACY_META_CORRUPT'); } for (const [colName, def] of Object.entries(columns)) { if (!def || typeof def !== 'object' || Array.isArray(def)) { throw new DatabaseError(`${source} entry "${tableName}.${colName}" is not a column definition ` + `(got ${Array.isArray(def) ? 'array' : typeof def})`, 'ARIA_LEGACY_META_CORRUPT'); } } out[tableName] = columns; } return out; } async loadSchemas() { // 权威来源:manifest(旧格式已在 importLegacyState 阶段导入) let data = this.manifest.schemas ?? {}; // 兼容回退:manifest 中没有任何表结构,但旧格式记录存在 //(例如上一次是被旧版本打开的库)→ 读进来并纳入 manifest if (Object.keys(data).length === 0) { const raw = await this.backend.read('__aria_schemas'); if (raw) { // 修复前这里 `catch {}` 静默忽略 → 坏 schema = 看不到任何表(静默空库) data = this.parseSchemaRecord(raw, 'Schema record "__aria_schemas"'); this.manifest.schemas = data; } } for (const [tableName, columns] of Object.entries(data)) { const schema = { name: tableName, columns }; this.schemas.set(tableName, schema); this.tablePKs.set(tableName, this.getPK(schema)); } } // ======================================================================= // v0.8.0(B-6):单一提交点与命名空间工厂 // ======================================================================= /** * v0.8.0(review 修复): 本次打开/修复过程中出现过的**一切损坏迹象**。 * * 为什么需要它而不是只看 `this.recoveryReport.dataLossSuspected`: * SSTable 被丢弃这一事实记录在**各 LSM** 的报告里,引擎层的 `dataLossSuspected` * 只在"WAL 水位已推进、被丢的数据没有 WAL 兜底"时才置位。于是"manifest 已被 * 推进 + 某个 SSTable 因文件损坏被丢"这类**真损坏**会在引擎层看不到 —— 孤儿页 * 回收就会照常执行,把在途/退休文件按"没人引用"删掉。 * * 判定原则:只要有任何"曾经自愈/丢失/回退"的迹象,就一律不回收任何未被引用 * 的文件(宁可留空间,也不可逆地删数据)。 */ describeRecoveryDamage() { const reasons = []; if (this.recoveryReport.manifestFallback) reasons.push('manifest fallback to previous generation'); if (this.recoveryReport.dataLossSuspected) reasons.push('engine-level data loss suspected'); if (this.recoveryReport.walGaps.length > 0) { reasons.push(`WAL segment gap(s) ${this.recoveryReport.walGaps.join(',')}`); } if (this.recoveryReport.droppedWALRecords > 0) { reasons.push(`${this.recoveryReport.droppedWALRecords} corrupt WAL record(s)`); } for (const lsm of this.allLsms()) { const r = lsm.getRecoveryReport(); if (r.droppedSSTables.length > 0) { reasons.push(`${r.namespace}: ${r.droppedSSTables.length} dropped SSTable(s)`); } if (r.dataLossSuspected) reasons.push(`${r.namespace}: LSM data loss suspected`); } return reasons; } /** * v0.8.0: 恢复诊断(打开时被丢弃的 SSTable、WAL 空洞、是否怀疑数据丢失)。 * * 数据来自两处:引擎层(WAL 空洞 / 迁移 / manifest 回退)与各 LSM(被丢弃的 * SSTable + 其 `dataLossSuspected`),这里合并成一份对外的报告 —— 于是 * "这次打开到底自愈了什么、有没有真丢数据"是**可读的返回值**而不是只能翻日志。 */ getRecoveryReport() { const dropped = this.recoveryReport.droppedSSTables.map((d) => ({ ...d })); let dataLoss = this.recoveryReport.dataLossSuspected; for (const lsm of this.allLsms()) { const r = lsm.getRecoveryReport(); for (const d of r.droppedSSTables) { dropped.push({ namespace: r.namespace, ...d }); } if (r.dataLossSuspected) dataLoss = true; } return { droppedSSTables: dropped, dataLossSuspected: dataLoss, walGaps: [...this.recoveryReport.walGaps], droppedWALRecords: this.recoveryReport.droppedWALRecords, legacyImported: this.recoveryReport.legacyImported, manifestFallback: this.recoveryReport.manifestFallback, }; } /** * v0.8.0: LSM 的唯一构造点。 * * 为什么集中:本项目最反复的缺陷模式就是"同一语义在多处实现、只修一处" * (审计结论的原话)。主 LSM 与二级索引 LSM 的配置必须完全一致地带上 * 命名空间、WAL LSN 提供者与 durable-coverage 语义,因此只能有一个工厂。 */ createLSM(ns) { return new LSM({ memtableSizeThreshold: this.config.memtableSizeThreshold, levelSizeMultiplier: this.config.levelSizeMultiplier, blockSize: this.config.pageSize, bloomBitsPerKey: this.config.bloomFilterBitsPerKey, // SSTable 缓存上限 = BufferPool 页数 × 页面大小(默认 256 页 ≈ 1MB 可控内存) cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize, sstableStore: this.createSSTableStore(ns), namespace: ns, // 冻结时刻的 WAL LSN → manifest 的冻结表意图(WAL 水位下限) walLsnProvider: () => this.wal?.getLsn() ?? 0, // WAL 已被截断(startLsn > 0)时,SSTable 缺失/损坏 = 已确认数据真的丢了 requireDurableCoverage: (this.manifest?.wal.startLsn ?? 0) > 0, }); } /** 全部 LSM(主 + 二级索引) */ allLsms() { return [this.lsm, ...this.secondaryIndexes.values()]; } /** * v0.8.0: 把全部 LSM 的数据落盘。 * @param memtablesOnly true = 只落 memtable,不等 compaction(checkpoint 用) */ async flushAllLsms(memtablesOnly = false) { for (const lsm of this.allLsms()) { if (memtablesOnly) await lsm.flushMemtablesOnly(); else await lsm.flush(); } } /** v0.8.0: 是否存在任何未落盘的 LSM 数据(决定 WAL 水位能否推进) */ hasPendingFlushData() { return this.allLsms().some((lsm) => lsm.hasPendingFlushData()); } /** schema 的持久化形态(与旧 `__aria_schemas` 同形) */ serializeSchemas() { const data = {}; for (const [name, schema] of this.schemas) { data[name] = schema.columns; } return data; } /** 所有 LSM 的待落盘冻结表意图(manifest 记录,阻止 WAL 水位越过它们) */ collectFrozenIntents() { const intents = []; for (const lsm of this.allLsms()) { intents.push(...lsm.getFrozenIntents()); } return intents; } /** * v0.8.0(B-6):**唯一的 manifest 提交入口**。 * * 每次提交都重新计算权威字段,因此并发/交错场景下"最后一次提交"总是包含 * 完整的最新状态: * - `pageIdWatermark`:单调推进、永不复用; * - `schemas`:表结构的权威描述(DDL 不再依赖独立的 `__aria_schemas` 提交); * - `frozen`:待落盘冻结表意图; * - `wal.startLsn`:**只有全部数据已落盘时才推进**(否则保持原值), * 这是"截断 WAL 前必须先提交 manifest"的可验证形式。 */ async commitManifest(targetDurableLsn) { if (!this.opened && !this.manifestStore) return; const intents = this.collectFrozenIntents(); this.manifest.pageIdWatermark = Math.max(this.manifest.pageIdWatermark, this.fileManager.getNextPageId()); this.manifest.schemas = this.serializeSchemas(); this.manifest.frozen = intents; const computed = this.computeDurableLsn(intents); // 允许调用方给一个**更保守**的目标水位(如"先算分片边界、再提交"的清理路径)。 // 只取更小者:水位永远不允许超过"当前真实可保证"的值。 this.durableLsn = typeof targetDurableLsn === 'number' ? Math.min(computed, Math.max(this.durableLsn, targetDurableLsn)) : computed; this.manifest.wal = { startSegment: this.walStartSegment, startLsn: this.durableLsn, nextLsn: Math.max(this.manifest.wal.nextLsn, this.wal.getLsn()), }; this.manifest = await this.manifestStore.commit(); } /** * v0.8.0:计算"当前可以保证的落盘水位"(纯函数,不改状态)。 * * 三条规则: * - 有冻结表意图 → 水位不得越过最早的冻结表内容起点(它们的记录只在内存+WAL); * - 有未落盘 memtable → 维持原水位; * - 全部落盘 → 推进到当前 LSN(这些记录已存在于已提交的 SSTable 中)。 */ computeDurableLsn(intents) { // v0.8.0(review 修复 P0):**事务进行中一律不得推进水位**。 // // 事务内的写入只落在 `txnSnapshot`(内存)+ WAL 里,**不进 LSM memtable** // —— 因此 `hasPendingFlushData()`(只看 memtable/frozen)会说"没有未落盘数据", // 水位就被推到当前 LSN 并按该水位删掉旧分片。而此时事务记录既不在 SSTable // 也不在 memtable:随后 `COMMIT`(返回成功)→ 崩溃 → 重开时那些 INSERT 记录 // 因 `lsn <= startLsn` 被跳过、只剩 COMMIT 记录 → **已确认提交的事务整批消失**, // 且恢复报告是"干净"的(实测复现)。 // // 水位是"这些 LSN 已存在于已提交 SSTable 中"的断言,而活跃事务的数据不满足它。 if (this.currentTxnId !== null) return this.durableLsn; if (intents.length > 0) { return Math.min(this.durableLsn, Math.min(...intents.map((i) => i.lsnAtFreeze))); } if (this.hasPendingFlushData()) return this.durableLsn; return Math.max(this.durableLsn, this.wal.getLsn()); } /** * v0.8.0(B-6):WAL 检查点的**唯一实现** —— "先算边界 → 提交 manifest → 再删除"。 * * 顺序不可交换(见 `SegmentedWALStore.planKeepFrom` 的说明)。所有需要回收 WAL * 空间的路径(打开恢复后、close、repair、周期 checkpoint)都必须走这里, * 否则又会出现"同一语义多处实现、只改一处"的老问题。 */ async advanceWalCheckpoint() { // v0.8.0(review 修复 P0):事务活跃时整条水位推进 + 分片回收都不做。 // 与 `CheckpointManager` 的两个回调同一守卫;这里放在入口处, // 使 repair()/close()/周期 checkpoint 三条路径全部覆盖(它们此前只有后两条有守卫)。 if (this.currentTxnId !== null) { // eslint-disable-next-line no-console console.warn('[AriaEngine] WAL checkpoint deferred: an active transaction may hold data ' + 'that exists only in memory + WAL (advancing the durable watermark would drop it)'); return; } await this.wal.flush(); // 1. 先算:以"如果没有未落盘数据,水位会到哪里"为基准 const target = this.hasPendingFlushData() ? this.durableLsn : Math.max(this.durableLsn, this.wal.getLsn()); // 2. 再提交(manifest 记录 startSegment + startLsn) // 注意:先算边界是为了让 manifest 里的 startSegment **不小于**介质上真实存在的 // 分片号 —— 否则恢复时无法区分"正常清理过的前缀"与"介质丢了一段记录"。 this.walStartSegment = await this.wal.planKeepFromSegment(target); await this.commitManifest(target); // 3. 最后才允许删除分片 await this.wal.checkpointBefore(target); } /** * v0.8.0:旧格式(v0.8.0 之前)状态导入。 * * 导入必须是**全有或全无**的: * - 旧 meta 存在但无法解析 → 抛错(`ARIA_LEGACY_META_CORRUPT`)。 * 修复前 `readMetaList()` 遇到坏 JSON 返回 `[]`,于是"元数据损坏"直接 * 表现为"空库",随后 repair 还会把没人引用的活页全部删掉(不可逆)。 */ async importLegacyState() { const keys = await this.backend.listKeys(); let imported = false; // 1. 各命名空间 SSTable meta(__aria_lsm_meta / __aria_lsm_meta_) const metaKeys = keys.filter((k) => k === '__aria_lsm_meta' || k.startsWith('__aria_lsm_meta_')); for (const key of metaKeys) { const ns = key === '__aria_lsm_meta' ? 'main' : key.slice('__aria_lsm_meta_'.length); const raw = await this.backend.read(key); if (!raw) continue; let parsed; try { parsed = JSON.parse(new TextDecoder().decode(raw)); } catch (error) { throw new DatabaseError(`Legacy SSTable metadata "${key}" is corrupt and cannot be migrated: ${error.message}`, 'ARIA_LEGACY_META_CORRUPT', error); } if (!Array.isArray(parsed)) { throw new DatabaseError(`Legacy SSTable metadata "${key}" is not an array`, 'ARIA_LEGACY_META_CORRUPT'); } const sstables = []; for (const item of parsed) { const m = item; if (typeof m?.id !== 'number' || typeof m?.level !== 'number' || typeof m?.minKey !== 'string' || typeof m?.maxKey !== 'string') { throw new DatabaseError(`Legacy SSTable metadata "${key}" has an entry with unexpected shape: ${JSON.stringify(item)}`, 'ARIA_LEGACY_META_CORRUPT'); } sstables.push({ id: m.id, level: m.level, minKey: m.minKey, maxKey: m.maxKey, blockCount: typeof m.blockCount === 'number' ? m.blockCount : 0, totalSize: typeof m.totalSize === 'number' ? m.totalSize : 0, bloomData: null, ...(Array.isArray(m.pageIds) ? { pageIds: [...m.pageIds] } : {}), }); } sstables.sort((a, b) => b.id - a.id); this.manifest.namespaces[ns] = { nextSstableId: sstables.reduce((max, m) => Math.max(max, m.id), 0), sstables, }; imported = true; } // 2. 表结构(__aria_schemas) const schemaRaw = await this.backend.read('__aria_schemas'); if (schemaRaw) { // 与 loadSchemas 走**同一个**校验实现(形状不对同样抛错,绝不静默空库) this.manifest.schemas = this.parseSchemaRecord(schemaRaw, 'Legacy schema record "__aria_schemas"'); imported = true; } // 3. 页面水位(__aria_meta,仅作为单调下限) const pageMeta = await this.backend.read('__aria_meta'); if (pageMeta && pageMeta instanceof ArrayBuffer && pageMeta.byteLength >= 4) { const watermark = new DataView(pageMeta).getUint32(0, false); if (watermark > this.manifest.pageIdWatermark) { this.manifest.pageIdWatermark = watermark; imported = true; } } if (imported) { this.recoveryReport.legacyImported = true; // eslint-disable-next-line no-console console.warn('[AriaEngine] migrated legacy storage layout into __aria_manifest ' + '(old keys are kept untouched as a fallback)'); } } /** * v0.8.0:恢复后校验冻结表意图。 * * 语义:manifest 记录了"某张冻结表还没落盘"(意图),说明它的数据要么在 WAL 里, * 要么已经在 SSTable 里。若本次打开**一条 WAL 记录都没有重放到**,而 manifest * 又声称有未落盘数据,那么这些"已确认写入"就是真的丢了(WAL 被截断/介质丢失)。 * 此时抛错 —— 修复前这种丢失完全不可观测。 */ verifyFrozenIntentsAfterRecovery(replayedRecordCount) { const intents = this.manifest.frozen; if (intents.length === 0) return; if (replayedRecordCount > 0) return; // WAL 覆盖到了这些数据(重放会重建) if (!this.config.walEnabled) return; // 未启用 WAL:本来就没有日志兜底(配置语义) const summary = intents.map((i) => `${i.ns}#${i.id}(${i.entryCount} 项)`).join(', '); throw new DatabaseError(`AriaEngine manifest declares ${intents.length} un-flushed frozen table(s) [${summary}] ` + 'but no WAL record was replayed — confirmed writes are missing ' + '(WAL truncated or lost, and the data is not in any committed SSTable)', 'ARIA_WRITE_LOST'); } /** * 创建命名空间隔离的 SSTableStore(**manifest 权威**)。 * * 主 LSM 与每个二级索引 LSM 各持有独立实例: * - 文件 key 前缀隔离(sst_ / sst_idx_${table}_${col}_) * - 元数据在 manifest 的 `namespaces[ns]` 中隔离(不再各写一份裸 JSON) * - id 序列独立且**单调推进**(`nextSstableId` 记在 manifest 里, * 即便某一代 SSTable 全部被删除,id 也不会被复用) * * v0.8.0(B-6)两处结构变化: * 1. **meta 不再走裸 JSON**:`saveMeta`/`deleteMeta` 直接改 manifest 并提交。 * 修复前 `JSON.parse` 失败 → 返回 `[]` → 元数据损坏 = 静默空库 * (随后 repair 还会把没人引用的活页删掉); * 2. `load/delete` 的页面映射改由 PageSSTableStore 自己维护 * (活跃 + 退休两张表)—— 于是"compaction 摘除 meta"与 * "在途读者按 id 读取"不再互相矛盾。 */ createSSTableStore(ns) { const filePrefix = ns === 'main' ? 'sst_' : `sst_${ns}_`; // v0.4.5: 页面化物理存储(OPFS 后端默认启用)— SSTable 存为 4KB 页面,BufferPool 缓存 const usePages = this.isPageStorage(); // v0.8.0(A38):compression 必须传给 pageStore —— 页面化是默认路径, // 不传就等于"默认配置下 compression 被静默忽略"(修复前的实际状态)。 const pageStore = usePages ? new PageSSTableStore(this.fileManager, this.bufferPool, this.config.compression) : null; // 打开时把 manifest 中已有的页面映射注册进 pageStore, // 使 load(id) 不再依赖"调用方传 pageIds"(退休 SSTable 也要能读) if (pageStore) { const existing = this.manifest.namespaces[ns]?.sstables ?? []; for (const meta of existing) { if (meta.pageIds && meta.pageIds.length > 0) { pageStore.registerPageIds(meta.id, meta.pageIds, meta.totalSize); } } } const nsState = () => { let state = this.manifest.namespaces[ns]; if (!state) { state = { nextSstableId: 0, sstables: [] }; this.manifest.namespaces[ns] = state; } return state; }; return { save: async (id, data) => { if (pageStore) { // 页面化:切页写入 BufferPool 并逐页落盘(save 语义 = 已持久化) // 压缩由 pageStore 内部完成(整体压缩后再切页,压缩率优于逐页) return pageStore.save(id, data); } let buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); // 压缩(若启用)— 加密由 EncryptedBackend 在 backend 层透明处理(v0.4.5) if (this.config.compression) { const compressed = compressLZ4(new Uint8Array(buf)); buf = compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength); } await this.backend.write(`${filePrefix}${id}`, buf); // 整 value 路径:落盘长度即压缩后长度(与页面化路径语义一致) return { storedSize: buf.byteLength }; }, load: async (id) => { // 页面化读取:pageStore 自己维护 id → pageIds(含退休表) if (pageStore) { const paged = await pageStore.load(id); if (paged !== null) return paged; } // 旧数据(页面化之前写入的整 value)或页面缺失 → 回退整 value 读取 const raw = await this.backend.read(`${filePrefix}${id}`); if (!raw) return null; let buf = new Uint8Array(raw); // 解压(若启用)— 解密由 EncryptedBackend 在 backend 层透明处理(v0.4.5) if (this.config.compression && !pageStore) { // v0.4.5: 压缩流自带原始大小头,无需外部估算 buf = decompressLZ4(buf); } return buf; }, delete: async (id) => { if (pageStore) await pageStore.delete(id); await this.backend.delete(`${filePrefix}${id}`); }, allocateId: async () => { // 单调推进、永不复用:id 序列记在 manifest 里(不依赖"现存最大 id") const state = nsState(); const maxExisting = state.sstables.reduce((max, m) => Math.max(max, m.id), 0); state.nextSstableId = Math.max(state.nextSstableId, maxExisting) + 1; return state.nextSstableId; }, listMeta: async () => nsState().sstables.map((m) => ({ ...m })), saveMeta: async (meta) => { const state = nsState(); // v0.4.5: 页面化时把页面 ID 列表注入 meta(save 后、saveMeta 前由 LSM 顺序调用) const pageIds = pageStore?.getPageIds(meta.id); const metaWithPages = pageIds && pageIds.length > 0 ? { ...meta, pageIds: [...pageIds] } : { ...meta, bloomData: null }; const idx = state.sstables.findIndex((m) => m.id === meta.id); if (idx >= 0) state.sstables[idx] = metaWithPages; else state.sstables.push(metaWithPages); state.nextSstableId = Math.max(state.nextSstableId, meta.id); // ★ 单一提交点:数据已落盘(save 返回)→ 现在提交元数据 await this.commitManifest(); }, deleteMeta: async (id) => { const state = nsState(); state.sstables = state.sstables.filter((m) => m.id !== id); // manifest 摘除后,页面映射转入"退休表":在途读者仍可按 id 读取 pageStore?.retirePageIds(id); await this.commitManifest(); }, deleteManyMetas: async (ids) => { if (ids.length === 0) return; const state = nsState(); const removing = new Set(ids); state.sstables = state.sstables.filter((m) => !removing.has(m.id)); for (const id of ids) pageStore?.retirePageIds(id); await this.commitManifest(); }, retire: (ids) => { for (const id of ids) pageStore?.retirePageIds(id); }, }; } /** v0.4.5: 是否启用页面化物理存储(默认 OPFS / KVStore 后端启用,显式配置可覆盖) */ isPageStorage() { if (this.config.pageStorage === true) return true; if (this.config.pageStorage === false) return false; // v0.6.1: kv 后端同样页面化 — 整 value SSTable(4MB)超过 1MB 页面缓存时 // 每次读取全量重载;拆 4KB 页面后缓存按页命中,大数据量读放大消除 return this.config.storageBackend === 'opfs' || this.config.storageBackend === 'kv'; } // ======================================================================= // WAL 恢复 // ======================================================================= applyWALRecord(record) { switch (record.type) { case WALRecordType.INSERT: case WALRecordType.UPDATE: if (record.data) { this.lsm.put(`${record.tableName}:${record.key}`, record.data); } break; case WALRecordType.DELETE: this.lsm.delete(`${record.tableName}:${record.key}`); break; case WALRecordType.CREATE_TABLE: if (record.data?.schema) { try { const s = JSON.parse(record.data.schema); if (!this.schemas.has(s.name)) { this.schemas.set(s.name, s); this.tablePKs.set(s.name, this.getPK(s)); } } catch { /* skip */ } } break; case WALRecordType.ALTER_TABLE: // v0.8.0(A41):ALTER 是结构权威描述 → **覆盖**该表 schema(不是增量合并)。 // 幂等:重复回放同一记录结果相同。索引列的重建在恢复末尾由 // reindexTableInternal 统一完成(与 CREATE_TABLE 的处理一致)。 if (record.data?.schema) { try { const s = JSON.parse(record.data.schema); this.schemas.set(s.name, s); this.tablePKs.set(s.name, this.getPK(s)); } catch { /* skip */ } } break; case WALRecordType.COMMIT: case WALRecordType.ROLLBACK: case WALRecordType.BEGIN: break; } } /** * v0.3.3: DROP_TABLE 恢复 — 删除 schema 并清除主 LSM 中该表的所有残留数据。 * * 此前 DROP_TABLE 在恢复时被忽略,而 CREATE_TABLE 回放会重建 schema, * 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。 */ async applyDropTableRecovery(tableName) { if (!tableName) return; // v0.4.2-fix: 清理该表二级索引(崩溃恢复路径同样不留孤儿索引) await this.cleanupTableIndexes(tableName); this.schemas.delete(tableName); this.tablePKs.delete(tableName); // 清除主 LSM 中该表前缀的所有数据(含 SSTable 中的旧数据) const prefix = `${tableName}:`; const endKey = `${prefix}\uffff`; const entries = await this.lsm.rangeScan(prefix, endKey); for (const [key] of entries) { this.lsm.delete(key); } } // ======================================================================= // 二级索引 // ======================================================================= /** v0.6.2: 表中有 unique 约束且索引 LSM 已建的列(唯一性检查范围) */ uniqueColumns(tableName, schema) { const cols = []; for (const [colName, colDef] of Object.entries(schema.columns)) { if (!colDef.unique) continue; if (this.secondaryIndexes.has(`${tableName}:idx:${colName}`)) cols.push(colName); } return cols; } /** * 唯一性检查。 * * v0.8.0: 由 `checkUniqueSync` 改名并改为 async —— 此前命名为 "Sync" 是因为它 * 依赖"批次级 prefetchPrefixRanges 之后索引数据已在缓存中"这一约定。现在 * LSM 读取自洽(未命中即回源),因此这里可以、也必须 await。 * 索引不含 null 条目(null 值不受唯一约束,与 MemoryEngine 语义一致)。 * @param currentPk 当前行主键(更新路径用于排除自身旧索引条目;插入路径无自身条目) */ async checkUnique(tableName, uniqueCols, row, currentPk) { for (const colName of uniqueCols) { const val = row[colName]; if (val === undefined || val === null) continue; const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`); if (!idxLsm) continue; const prefix = `${String(val)}:`; const entries = await idxLsm.rangeScan(prefix, `${prefix}\uffff`); for (const [, entry] of entries) { const pk = entry.pk; if (pk !== undefined && pk !== currentPk) { throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION'); } } } } /** 更新行的二级索引条目 */ updateSecondaryIndexes(tableName, pkValue, newRow, oldRow) { const schema = this.schemas.get(tableName); if (!schema) return; for (const [colName, colDef] of Object.entries(schema.columns)) { // v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引) if (!colDef.index && !colDef.unique) continue; const idxKey = `${tableName}:idx:${colName}`; const idxLsm = this.secondaryIndexes.get(idxKey); if (!idxLsm) continue; // 删除旧值 if (oldRow) { const oldVal = oldRow[colName]; if (oldVal !== undefined && oldVal !== null) { idxLsm.delete(`${String(oldVal)}:${pkValue}`); } } // 插入新值 if (newRow) { const newVal = newRow[colName]; if (newVal !== undefined && newVal !== null) { idxLsm.put(`${String(newVal)}:${pkValue}`, { pk: pkValue }); } } } } /** 通过二级索引快速查找 */ async tryIndexLookup(tableName, query) { if (!query.where) return null; const schema = this.schemas.get(tableName); if (!schema) return null; const pkCol = this.tablePKs.get(tableName); // v0.7.3: 递归展开 $and 中的等值条件 —— 此前仅顶层键, // `WHERE a AND b`(解析为顶层 $and)永远全表扫描,索引形同虚设。 // $or/$not 语义不适用单索引下推,保守跳过。命中索引后 find 仍以 // 全条件 matchWhere 过滤(子集语义安全)。 const flat = []; const collect = (w) => { for (const [k, v] of Object.entries(w)) { if (k === '$and') { for (const sub of v) collect(sub); continue; } if (k === '$or' || k === '$not' || k === '$exists') continue; flat.push([k, v]); } }; collect(query.where); for (const [col, condition] of flat) { const colDef = schema.columns[col]; const hasIndex = colDef && (colDef.index || colDef.unique || colDef.primaryKey); if (!hasIndex && col !== pkCol) continue; // PK 等值 → 主 LSM 精确查找 if (col === pkCol) { if (typeof condition !== 'object' || condition === null) { const key = `${tableName}:${condition}`; const value = await this.lsm.get(key); return value ? [{ ...value, [pkCol]: condition }] : []; } const cond = condition; if ('$eq' in cond) { const key = `${tableName}:${cond.$eq}`; const value = await this.lsm.get(key); return value ? [{ ...value, [pkCol]: cond.$eq }] : []; } // v0.3.3: PK $in → 主 LSM 多次精确查找(替代冗余 PK 二级索引) if ('$in' in cond && Array.isArray(cond.$in)) { const rows = []; const seen = new Set(); // v0.4.1: IN 子查询可能含重复值,按 pk 去重 for (const v of cond.$in) { const pk = String(v); if (seen.has(pk)) continue; const value = await this.lsm.get(`${tableName}:${pk}`); if (value) { seen.add(pk); rows.push({ ...value, [pkCol]: pk }); } } return rows; } // v0.3.3: PK 范围查询 → 主 LSM 前缀扫描 + 条件过滤(修复字符串算术 bug) if ('$gt' in cond || '$gte' in cond || '$lt' in cond || '$lte' in cond) { const prefix = `${tableName}:`; const entries = await this.lsm.rangeScan(prefix, `${prefix}\uffff`); const rows = []; for (const [key, value] of entries) { const candidate = { ...value, [pkCol]: key.slice(prefix.length) }; if (matchWhere(candidate, { [pkCol]: condition })) rows.push(candidate); } return rows; } } // 二级索引查找 const idxKey = `${tableName}:idx:${col}`; const idxLsm = this.secondaryIndexes.get(idxKey); if (!idxLsm) continue; // $eq → 精确查找 if (typeof condition !== 'object' || condition === null) { // v0.6.2-fix(P1): IS NULL(条件为 null)不走索引 —— 索引不含 null 条目, // String(null)="null" 查找返回空并短路全表 → 索引列 IS NULL 恒空 if (condition === null) continue; return this.indexScanToRows(tableName, pkCol, idxLsm, String(condition), String(condition)); } const c = condition; if ('$eq' in c) { // v0.6.2-fix(P1): 同上,$eq: null(IS NULL)不走索引 if (c.$eq === null) continue; // v0.8.0 根治(与 MemoryEngine 同步):非原始值一律不走索引。 // `String({...})` 得到 "[object Object]" 这类无意义键,索引查找必然为空 // 并短路全表扫描 → 结果静默为空。真实触发场景是"未解析的操作数": // { $eq: { $col: 'y' } } ← 列对列比较(t.x = t.y) if (typeof c.$eq === 'object') continue; const v = String(c.$eq); return this.indexScanToRows(tableName, pkCol, idxLsm, v, v); } // $in → 多次精确查找 if ('$in' in c && Array.isArray(c.$in)) { // v0.6.2-fix(P1): IN 列表含 null 不走索引(索引不含 null 条目,会漏匹配 null 行) if (c.$in.some((v) => v === null)) continue; // v0.8.0: IN 列表含非原始值(未解析的 $subquery / $col)不走索引 —— // String() 会得到无意义键,查找为空并短路全表扫描 → 静默空结果 if (c.$in.some((v) => typeof v === 'object' && v !== null)) continue; // v0.7.3-perf: 批级预加载全部值的索引范围 + 主表行(各一次 drainChain)—— // 此前逐值 indexScanToRows:每个值一次 prefetchRange + prefetchKeys, // 后台 compaction 长耗时时 N 倍放大(与 v0.6.1 修的 insert 批量预加载 // 性能悬崖同类)。批级预加载后循环内同步 rangeScan/get。 const values = c.$in.map((v) => String(v)); const results = []; const seenPks = new Set(); // v0.4.1: IN 值可能重复,按 pk 去重 const pks = []; for (const val of values) { const entries = await idxLsm.rangeScan(val, `${val}\uffff`); for (const [, idxEntry] of entries) { const pk = idxEntry.pk; if (pk && !seenPks.has(pk)) { seenPks.add(pk); pks.push(pk); } } } for (const pk of pks) { const row = await this.lsm.get(`${tableName}:${pk}`); if (row) results.push({ ...row, [pkCol]: pk }); } return results; } // $gt / $gte / $lt / $lte → 范围扫描 if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) { // v0.6.2-fix(P1): 此前用 Number(v)±1 构造边界 key —— 小数数值 // ($gt:2 → "3:",漏 2.5)与字符串("NaN:" 前缀错位,数字/大写开头值被漏) // 静默丢数据。改为全索引扫描 + 行级 matchWhere 过滤(与主键范围路径同方案), // 边界语义与 where-matcher 完全一致。 const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, '', '\uffff'); return rows.filter((row) => matchWhere(row, { [col]: condition })); } } return null; } /** 从索引扫描结果恢复完整行 */ async indexScanToRows(tableName, pkCol, idxLsm, startKey, endKey) { // 使用前缀扫描:endKey 需要包含 \uffff 以匹配所有带后缀的 key const actualEndKey = endKey.includes('\uffff') ? endKey : `${endKey}\uffff`; // 预加载索引 LSM 与主 LSM 涉及的 SSTable const entries = await idxLsm.rangeScan(startKey, actualEndKey); const pks = []; for (const [, idxEntry] of entries) { const pk = idxEntry.pk; if (pk) pks.push(pk); } const rows = []; for (const pk of pks) { const row = await this.lsm.get(`${tableName}:${pk}`); if (row) rows.push({ ...row, [pkCol]: pk }); } return rows; } // ======================================================================= // 辅助 // ======================================================================= /** 每 10 次 gc 计数器触发一次 MVCC 垃圾回收 */ tryGC() { this.gcCounter++; if (this.gcCounter >= 10) { this.mvcc.gc(100); this.gcCounter = 0; } } /** 回收主 LSM 与所有二级索引 LSM 的临时缓存超限 */ trimAllCaches() { this.lsm.trimCache(); for (const idxLsm of this.secondaryIndexes.values()) { idxLsm.trimCache(); } } /** 检查内存预算,超出时强制 flush + GC */ checkMemoryBudget() { const maxBytes = this.config.maxMemoryMB * 1024 * 1024; const used = this.lsm.getEstimatedMemory(); if (used > maxBytes) { this.lsm.flush().catch(() => { }); this.mvcc.gc(50); } } /** * ANALYZE: 收集表统计信息 * 返回行数、平均行大小、索引深度等 */ async analyzeTable(tableName) { this.ensureOpen(); this.ensureTable(tableName); const rows = await this.getAllRows(tableName); // v0.7.3: 统计汇总主 LSM + 该表全部二级索引 LSM —— 此前只统计主 LSM, // 表带多个索引时索引深度/SSTable 数量严重低估 let sstableCount = this.lsm.getStats().sstableCount; let memtableSize = this.lsm.getStats().memtableSize; let indexDepth = this.lsm.getStats().levelCounts.filter((c) => c > 0).length; for (const [idxKey, idxLsm] of this.secondaryIndexes) { if (!idxKey.startsWith(`${tableName}:idx:`)) continue; const s = idxLsm.getStats(); sstableCount += s.sstableCount; memtableSize += s.memtableSize; indexDepth = Math.max(indexDepth, s.levelCounts.filter((c) => c > 0).length); } const stats = { table: tableName, rowCount: rows.length, avgRowSize: rows.length > 0 ? Math.round(rows.reduce((s, r) => s + JSON.stringify(r).length, 0) / rows.length) : 0, indexDepth, sstableCount, memtableSize, estimatedMemory: this.lsm.getEstimatedMemory(), }; // 列基数统计 const schema = this.schemas.get(tableName); if (schema && rows.length > 0) { const columnStats = {}; for (const colName of Object.keys(schema.columns)) { const values = new Set(rows.map((r) => String(r[colName]))); columnStats[colName] = { distinctValues: values.size }; } stats.columnStats = columnStats; } return stats; } /** * REINDEX: 重建指定表的所有二级索引 */ async reindexTable(tableName) { this.ensureOpen(); this.ensureTable(tableName); return this.reindexTableInternal(tableName); } /** v0.4.2-fix: 重建索引内部实现(不校验 opened,供 open 恢复流程调用) */ async reindexTableInternal(tableName) { const schema = this.schemas.get(tableName); if (!schema) return 0; let rebuiltCount = 0; // v0.7.4-perf: 单次全表扫描重建全部索引列 —— 此前每个索引列各做一次 // getAllRows(N 列 × M 行全表扫描 + 每次 prefetchRange drainChain), // 多索引大表 REINDEX/崩溃恢复按索引列数线性放大 const pkCol = this.tablePKs.get(tableName); const idxCols = Object.entries(schema.columns).filter(([, colDef]) => colDef.index || colDef.unique); if (idxCols.length === 0) return 0; const rows = await this.getAllRows(tableName); for (const [colName] of idxCols) { const idxKey = `${tableName}:idx:${colName}`; const idxLsm = this.secondaryIndexes.get(idxKey); if (!idxLsm) continue; // 清空旧索引 await idxLsm.clear(); rebuiltCount++; // 从主 LSM 重建索引 for (const row of rows) { const val = row[colName]; if (val !== undefined && val !== null) { idxLsm.put(`${String(val)}:${row[pkCol]}`, { pk: row[pkCol] }); } } } return rebuiltCount; } /** * VACUUM: 压缩 LSM + 清理碎片 */ async vacuum() { this.ensureOpen(); // 强制 flush memtable(vacuum 的语义是"把可回收的空间收掉",必须先把内存数据落盘) await this.lsm.flush(); // v0.8.0(B-6):压缩**真实发生过的**层,并返回真实计数。 // // 修复前:循环 `level < 6`(写死,MAX_LSM_LEVELS = 7,**底部层永远不压缩** // → 墓碑与历史版本在最底层永久累积),且无论是否真的合并过都返回 // `compactedLevels: 6`("报告的数字与事实无关",审计 item 52)。 // 现在逐层尝试(含底部层的原地合并 —— 它会回收墓碑),只统计真正合并了的层。 // 逐层压缩交给 LSM:它会把这些任务挂到**维护链**上串行执行 —— // 直接 `await compactLevel()` 会与后台 compaction 并发写同一层的产物, // 而产物一律 unshift 到队首(层内顺序 = 新旧顺序)→ 旧数据可能排到新数据 // 之前(读到旧值),底部层还会因丢墓碑让已删除的行复活。 const compactedLevels = await this.lsm.vacuumLevels(); // GC MVCC 版本(保留最新 10 个) const beforeGC = this.mvcc.getGlobalLSN(); this.mvcc.gc(10); return { compactedLevels, gcVersions: beforeGC }; } ensureOpen() { if (!this.opened) throw new DatabaseError('AriaEngine not opened', 'DB_NOT_OPEN'); } /** v0.4.2-fix: Aria 事务中 DDL 显式拒绝(结构变更无法通过行快照回滚) */ ensureNoDDLInTransaction(op) { if (this.currentTxnId) { throw new DatabaseError(`${op} is not supported inside a transaction (AriaEngine DDL is not transactional)`, 'NOT_SUPPORTED'); } } ensureTable(tableName) { if (!this.schemas.has(tableName)) { throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND'); } } } /** * metona-sqlark Hybrid Engine — 内存 + 磁盘混合存储引擎 * @module hybrid/index * * 采用 write-through 策略: * - 所有写操作同时写入内存和磁盘 * - 所有读操作直接从内存返回 * - 数据库打开时从磁盘加载数据到内存 */ // --------------------------------------------------------------------------- // HybridEngine // --------------------------------------------------------------------------- class HybridEngine { constructor(diskEngine = 'opfs') { this.name = 'hybrid'; this.dbName = ''; this.version = 1; this.memoryEngine = new MemoryEngine(); this.diskEngineType = diskEngine; // v0.6.0: 磁盘层统一为自研 KVStoreEngine this.diskEngine = new KVStoreEngine(); } // ---- 生命周期 ---- async open(dbName, version) { this.dbName = dbName; this.version = version; // 先打开磁盘引擎 await this.diskEngine.open(dbName, version); // 再打开内存引擎 await this.memoryEngine.open(dbName, version); // 从磁盘加载现存表 await this.reloadMemoryFromDisk(); } /** * 从磁盘重载内存缓存(v0.3.2:多标签页同步)。 * 其他标签页写入磁盘后调用,使本标签页读到最新数据。 */ async reloadMemoryFromDisk() { await this.memoryEngine.close(); await this.memoryEngine.open(this.dbName, this.version); // v0.6.0: KVStoreEngine 读内存 → 先重载磁盘最新数据 const disk = this.diskEngine; if (typeof disk.reload === 'function') { await disk.reload(); } const tableNames = await this.diskEngine.getTableNames(); for (const tableName of tableNames) { const schema = await this.diskEngine.getTableSchema(tableName); if (!schema) continue; // 在内存中创建表 await this.memoryEngine.createTable(schema); // 从磁盘加载数据到内存 const rows = await this.diskEngine.find(tableName, { table: tableName }); if (rows.length > 0) { try { await this.memoryEngine.insert(tableName, rows); } catch (e) { // eslint-disable-next-line no-console console.warn(`[metona-sqlark] Failed to load table "${tableName}" data from disk:`, e); } } } } async close() { await this.memoryEngine.close(); await this.diskEngine.close(); } isOpen() { return this.memoryEngine.isOpen() && this.diskEngine.isOpen(); } // ---- v0.4.2-fix: 自愈 / 重置 / 元数据(委托双引擎) ---- /** 自愈:修复磁盘引擎后重载内存缓存 */ async repair() { if (typeof this.diskEngine.repair === 'function') { await this.diskEngine.repair(); } await this.reloadMemoryFromDisk(); } /** 清空全部数据与表结构 */ async clearAll() { if (typeof this.diskEngine.clearAll === 'function') { await this.diskEngine.clearAll(); } else { const names = await this.diskEngine.getTableNames(); for (const name of names) { await this.diskEngine.dropTable(name); } } if (typeof this.memoryEngine.clearAll === 'function') { await this.memoryEngine.clearAll(); } else { const names = await this.memoryEngine.getTableNames(); for (const name of names) { await this.memoryEngine.dropTable(name); } } } async getMeta(key) { if (typeof this.diskEngine.getMeta === 'function') { return this.diskEngine.getMeta(key); } return null; } async setMeta(key, value) { if (typeof this.diskEngine.setMeta === 'function') { await this.diskEngine.setMeta(key, value); } } // ---- 表管理 ---- async createTable(schema) { await this.memoryEngine.createTable(schema); try { await this.diskEngine.createTable(schema); } catch (error) { await this.recoverMemoryAfterDiskError(error); } } async dropTable(tableName) { await this.memoryEngine.dropTable(tableName); try { await this.diskEngine.dropTable(tableName); } catch (error) { await this.recoverMemoryAfterDiskError(error); } } async hasTable(tableName) { return this.memoryEngine.hasTable(tableName); } async getTableNames() { return this.memoryEngine.getTableNames(); } async getTableSchema(tableName) { return this.memoryEngine.getTableSchema(tableName); } /** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */ async alterTable(tableName, action, column) { await this.memoryEngine.alterTable(tableName, action, column); try { if (typeof this.diskEngine.alterTable === 'function') { await this.diskEngine.alterTable(tableName, action, column); } else { // 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准) const schema = await this.diskEngine.getTableSchema(tableName); if (schema && action === 'DROP') delete schema.columns[column.name]; } } catch (error) { await this.recoverMemoryAfterDiskError(error); } } // ---- CRUD(write-through 策略) ---- /** * v0.7.2: 磁盘写失败补偿 — 内存已先行写入、磁盘失败 → 内存与磁盘不一致 * (重启后数据丢失且调用方已收到错误)。从磁盘重载内存对齐真实状态 * (内存=磁盘),再重新抛出原始错误。事务路径由双引擎快照回滚保证, * 无需此补偿。 */ async recoverMemoryAfterDiskError(error) { try { await this.reloadMemoryFromDisk(); } catch { // 磁盘本身不可用(错误根源)时重载可能失败:错误已抛给调用方, // 内存保持失败前状态,repair()/重试可恢复 // eslint-disable-next-line no-console console.warn('[metona-sqlark] Hybrid: failed to reload memory after disk write error'); } throw error; } /** * v0.8.0(B-1):写入前置校验 —— 委托给内存引擎(与磁盘引擎同 schema)。 * * 关键点:**只判定一次**。Hybrid 的 write-through 会把同一批行先写内存再写磁盘, * 两个引擎各自校验会给出同一结论(现在共享同一个 `compileValidator`), * 但由本方法统一前置,可保证多行批量在任何副作用之前整体失败。 */ async validatePayload(tableName, rows, mode = 'insert') { return this.memoryEngine.validatePayload(tableName, rows, mode); } async insert(tableName, rows) { const pks = await this.memoryEngine.insert(tableName, rows); // write-through: 同步写入磁盘 try { await this.diskEngine.insert(tableName, rows); } catch (error) { await this.recoverMemoryAfterDiskError(error); } return pks; } async find(tableName, query) { // 直接从内存读取 return this.memoryEngine.find(tableName, query); } /** v0.4.0: 流式查询(内存引擎逐行回调) */ async findStream(tableName, query, onRow) { return this.memoryEngine.findStream(tableName, query, onRow); } async update(tableName, query, updates) { const count = await this.memoryEngine.update(tableName, query, updates); // write-through: 同步更新磁盘 try { await this.diskEngine.update(tableName, query, updates); } catch (error) { await this.recoverMemoryAfterDiskError(error); } return count; } async delete(tableName, query) { const count = await this.memoryEngine.delete(tableName, query); // write-through: 同步删除磁盘 try { await this.diskEngine.delete(tableName, query); } catch (error) { await this.recoverMemoryAfterDiskError(error); } return count; } async count(tableName, query) { return this.memoryEngine.count(tableName, query); } async clear(tableName) { await this.memoryEngine.clear(tableName); try { await this.diskEngine.clear(tableName); } catch (error) { await this.recoverMemoryAfterDiskError(error); } } // ---- 动态索引(v0.3.0) ---- async createIndex(tableName, column, unique) { await this.memoryEngine.createIndex(tableName, column, unique); try { if (typeof this.diskEngine.createIndex === 'function') { await this.diskEngine.createIndex(tableName, column, unique); } } catch (error) { await this.recoverMemoryAfterDiskError(error); } } async dropIndex(tableName, column, indexName) { await this.memoryEngine.dropIndex(tableName, column, indexName); try { if (typeof this.diskEngine.dropIndex === 'function') { await this.diskEngine.dropIndex(tableName, column, indexName); } } catch (error) { await this.recoverMemoryAfterDiskError(error); } } // ---- 事务 ---- async beginTransaction() { await this.memoryEngine.beginTransaction(); // v0.7.4: 磁盘 begin 失败时补偿回滚内存快照 —— 此前内存已 begin、 // 磁盘抛错 → 内存快照泄漏(后续所有事务报 TX_ACTIVE) try { await this.diskEngine.beginTransaction(); } catch (error) { await this.memoryEngine.rollbackTransaction(); throw error; } } async commitTransaction() { // 先写磁盘,保证持久化优先;磁盘失败则回滚内存 await this.diskEngine.commitTransaction(); try { await this.memoryEngine.commitTransaction(); } catch (error) { // v0.4.2-fix: 磁盘已提交无法回滚(此前调 diskEngine.rollbackTransaction() // 会抛 TX_NONE 掩盖原错误)。如实上报内存提交失败,磁盘数据保持已提交状态。 throw new DatabaseError('Hybrid commit failed: memory engine error after disk commit (disk data is committed)', 'TX_COMMIT_ERROR', error); } } async rollbackTransaction() { await this.memoryEngine.rollbackTransaction(); await this.diskEngine.rollbackTransaction(); } // ---- 引擎信息 ---- /** 获取磁盘引擎类型 */ getDiskEngineType() { return this.diskEngineType; } /** 获取内存引擎(供内部使用) */ getMemoryEngine() { return this.memoryEngine; } } /** * metona-sqlark Query Builder — 链式查询构建器 * @module query/builder * * ============================================================================ * v0.8.0(B-3):**只产出 AST,执行一律交给 Executor** * ============================================================================ * 修复前每个 builder 的 `execute()` 都有自己的执行动作: * - `SelectQueryBuilder`:**无 JOIN 时直接调 `engine.find`**,只有 JOIN 才走 * executor; * - `UpdateQueryBuilder` / `DeleteQueryBuilder`:直接调 `engine.update/delete`。 * * 于是"同一条语义"在 builder 路径与 SQL 路径上走两条管线,规则各写一份: * - SELECT 的常量列/别名/CASE 投影在 builder 路径不存在 * (`db.table('t').select(['t.n'])` 的输出键、`SELECT 1` 之类行为不同); * - `maxRowsPerQuery`、`distinct`、`groupBy`/`having`、`fromSubquery` 等阶段 * 在直通路径上完全不经过; * - 未解析的 `$subquery`/`$col` 在直通路径上无人解析 → 引擎判 UNKNOWN → * **静默 0 行**(这正是 v0.7.4 要为此加"显式拒绝"防御的原因)。 * * 现在 builder 只负责"拼 AST",执行统一走 `executor.execute(ast)`; * 需要直通时由 executor 内部判断(它本来就是唯一知道"能不能下推"的地方)。 */ // --------------------------------------------------------------------------- // SelectQueryBuilder // --------------------------------------------------------------------------- class SelectQueryBuilder { constructor(tableName, executor, _columns = ['*']) { this.tableName = tableName; this._columns = _columns; this._where = {}; this._orderBy = []; this._joins = []; this._executor = executor; } /** 主表别名 */ as(alias) { this._alias = alias; return this; } /** INNER JOIN */ innerJoin(table, on, alias) { return this._addJoin('INNER', table, on, alias); } /** LEFT JOIN */ leftJoin(table, on, alias) { return this._addJoin('LEFT', table, on, alias); } /** RIGHT JOIN */ rightJoin(table, on, alias) { return this._addJoin('RIGHT', table, on, alias); } /** CROSS JOIN */ crossJoin(table, alias) { return this._addJoin('CROSS', table, {}, alias); } /** 通用 JOIN */ join(table, on, alias) { return this._addJoin('INNER', table, on, alias); } _addJoin(type, table, on, alias) { this._joins.push({ type, table, on, alias }); return this; } /** 添加过滤条件 */ where(condition) { this._where = { ...this._where, ...condition }; return this; } /** 排序 */ orderBy(column, direction = 'asc') { this._orderBy.push({ column, direction }); return this; } /** 限制返回条数 */ limit(n) { this._limit = n; return this; } /** 偏移量 */ offset(n) { this._offset = n; return this; } /** * 执行查询 —— 拼出 AST 后交给 Executor(**唯一**执行管线)。 * * 注意不要再为"无 JOIN"加一条 `engine.find` 快路:那条路会绕过 * 投影/列校验/LIMIT 下推判定/maxRowsPerQuery,从而与 `db.query()` 给出不同结果 * (B-3 修复前的实际状态)。executor 自己会在安全时下推到引擎,不需要 builder 代劳。 */ async execute() { return this._executor.execute(this.toAST()); } /** 获取 AST */ toAST() { return { type: 'SELECT', columns: this._columns, from: this.tableName, alias: this._alias, joins: this._joins.length > 0 ? [...this._joins] : undefined, where: this._where, orderBy: this._orderBy.length > 0 ? this._orderBy : undefined, limit: this._limit, offset: this._offset, }; } } // --------------------------------------------------------------------------- // UpdateQueryBuilder // --------------------------------------------------------------------------- class UpdateQueryBuilder { constructor(tableName, _updates, executor, /** * TABLE API 的生命周期回调(`beforeUpdate` / `afterUpdate` / onWrite 广播)。 * * 为什么由 `Table` 注入而不是 builder 自己触发:builder 的职责是**产出 AST**, * 它不该知道钩子/广播的存在(否则又会像修复前那样"builder 顺带把写入也做了", * 从而绕过 Executor)。钩子被包裹在**唯一管线**之外, * 顺序与修复前完全一致:before → executor → onWrite → after。 * * 回调接收**实际执行的语句**(含 builder 上累积的 where), * 而不是构造 builder 时的空 where —— 后者会让 `beforeUpdate` 的 * `query.where` 永远是 `{}`(钩子拿不到过滤条件,等于信息缺失)。 */ hooks) { this.tableName = tableName; this._updates = _updates; this.executor = executor; this.hooks = hooks; this._where = {}; } where(condition) { this._where = { ...this._where, ...condition }; return this; } /** * 执行更新 —— 走 Executor(唯一的写管线)。 * * 修复前这里直接调 `engine.update`:`$subquery` / `$col` / `$exists` 无人解析, * 引擎层 matchWhere 判 UNKNOWN → **静默影响 0 行**(返回 0 且无报错)。 * 引擎层为此加过"检测未解析标记就抛 NOT_SUPPORTED"的防御 —— 那是把 * "管线缺失"暴露成用户错误;正确做法是把请求送进唯一管线。 */ async execute() { const stmt = this.toAST(); await this.hooks?.before?.(stmt); const count = await this.executor.execute(stmt); await this.hooks?.after?.(count, stmt); return count; } toAST() { return { type: 'UPDATE', table: this.tableName, sets: this._updates, where: this._where }; } } // --------------------------------------------------------------------------- // DeleteQueryBuilder // --------------------------------------------------------------------------- class DeleteQueryBuilder { constructor(tableName, executor, /** TABLE API 生命周期回调,语义见 `UpdateQueryBuilder` 的说明 */ hooks) { this.tableName = tableName; this.executor = executor; this.hooks = hooks; this._where = {}; } where(condition) { this._where = { ...this._where, ...condition }; return this; } /** 执行删除 —— 走 Executor(同 UpdateQueryBuilder.execute 的理由) */ async execute() { const stmt = this.toAST(); await this.hooks?.before?.(stmt); const count = await this.executor.execute(stmt); await this.hooks?.after?.(count, stmt); return count; } toAST() { return { type: 'DELETE', from: this.tableName, where: this._where }; } } /** * metona-sqlark Table — 表操作 API * @module table/table */ // --------------------------------------------------------------------------- // Table // --------------------------------------------------------------------------- class Table { constructor(engine, tableName, executor, onWrite, onHooks) { this.schema = null; this.engine = engine; this.name = tableName; this.executor = executor; this.onWrite = onWrite; this.onHooks = onHooks; } // ---- Schema ---- async getSchema() { if (!this.schema) { const s = await this.engine.getTableSchema(this.name); if (!s) throw new DatabaseError(`Table "${this.name}" does not exist`, 'TABLE_NOT_FOUND'); this.schema = s; } return this.schema; } // ---- 插入 ---- async insert(row) { // beforeInsert 参数约定:rows[](单行时也是数组) await this.onHooks?.('beforeInsert', [[row]]); const pks = await this.engine.insert(this.name, [row]); this.onWrite?.(this.name); await this.onHooks?.('afterInsert', [[row], pks]); return pks[0]; } async insertMany(rows) { await this.onHooks?.('beforeInsert', [rows]); const pks = await this.engine.insert(this.name, rows); this.onWrite?.(this.name); await this.onHooks?.('afterInsert', [rows, pks]); return pks; } // ---- 查询 ---- select(columns = ['*']) { return new SelectQueryBuilder(this.name, this.requireExecutor('select'), columns); } /** * v0.8.0(B-3):取执行器;缺失即**明确失败**。 * * 修复前 builder 在拿不到 executor 时会退化为"直接调引擎" —— 于是 * `db.table('t')` 与 `db.query()` 两条路径的语义不同(投影/列校验/LIMIT 下推/ * maxRowsPerQuery 在直通路径上全部缺失)。现在只保留一条管线: * 没有 executor 就没有可用的查询 API,显式报错而不是悄悄降级。 */ requireExecutor(operation) { if (!this.executor) { throw new DatabaseError(`Table.${operation}() requires a query executor (obtain the table via db.table())`, 'NOT_SUPPORTED'); } return this.executor; } /** v0.4.0: 流式查询 — 逐行回调,不物化全部结果 */ async stream(onRow, query = {}) { if (typeof this.engine.findStream !== 'function') { const rows = await this.engine.find(this.name, { table: this.name, where: query.where, limit: query.limit, offset: query.offset, columns: query.columns, }); for (const row of rows) onRow(row); return rows.length; } return this.engine.findStream(this.name, { table: this.name, where: query.where, limit: query.limit, offset: query.offset, columns: query.columns, }, onRow); } // ---- 更新 ---- /** * v0.8.0(B-3):写操作也走**唯一管线**(Executor),生命周期钩子由本方法注入。 * * 修复前 `UpdateQueryBuilder` 直接调 `engine.update`:`$subquery`/`$col` 无人解析 * → 引擎判 UNKNOWN → 静默影响 0 行;`beforeUpdate`/`afterUpdate` 的触发点也因此 * 与 SQL 路径不同(一条在 builder 里、一条在 core 里)。 */ update(updates) { return new UpdateQueryBuilder(this.name, updates, this.requireExecutor('update'), { // 钩子接收实际语句(含 builder 累积的 where),与 SQL 路径的 // `triggerStatementHooks` 传参形状一致:{ table, where } before: async (stmt) => { await this.onHooks?.('beforeUpdate', [{ table: stmt.table, where: stmt.where }, updates]); }, after: async (count, stmt) => { this.onWrite?.(this.name); await this.onHooks?.('afterUpdate', [{ table: stmt.table, where: stmt.where }, updates, count]); }, }); } // ---- 删除 ---- delete() { return new DeleteQueryBuilder(this.name, this.requireExecutor('delete'), { before: async (stmt) => { await this.onHooks?.('beforeDelete', [{ table: stmt.from, where: stmt.where }]); }, after: async (count, stmt) => { this.onWrite?.(this.name); await this.onHooks?.('afterDelete', [{ table: stmt.from, where: stmt.where }, count]); }, }); } // ---- 聚合 ---- async count(where) { return this.engine.count(this.name, where ? { table: this.name, where } : undefined); } // ---- 管理 ---- async clear() { await this.engine.clear(this.name); this.onWrite?.(this.name); } async drop() { await this.engine.dropTable(this.name); this.onWrite?.(this.name); } } /** * metona-sqlark Query Compiler — AST → 查询计划 * @module query/compiler * * 将 AST 语句编译为引擎可执行的 QueryPlan。 * v0.0.1: 简单直接映射,未来可加入索引选择、过滤下推等优化。 */ // --------------------------------------------------------------------------- // 编译 AST → QueryPlan // --------------------------------------------------------------------------- /** * 编译 SELECT / DELETE / UPDATE 语句为 QueryPlan。 * INSERT 和 DDL 语句不需要 QueryPlan。 */ function compileStatement(stmt) { switch (stmt.type) { case 'SELECT': return compileSelect(stmt); case 'DELETE': return compileDelete(stmt); case 'UPDATE': return compileUpdate(stmt); default: throw new DatabaseError(`Cannot compile statement type "${stmt.type}" to QueryPlan`, 'COMPILE_ERROR'); } } function compileSelect(stmt) { return { table: stmt.from, columns: stmt.columns, where: stmt.where, orderBy: stmt.orderBy?.length ? stmt.orderBy : undefined, limit: stmt.limit, offset: stmt.offset, }; } function compileDelete(stmt) { return { table: stmt.from, where: stmt.where, }; } function compileUpdate(stmt) { return { table: stmt.table, where: stmt.where, }; } /** * metona-sqlark SQL Token Types — 词法单元定义 * @module sql/tokens */ // --------------------------------------------------------------------------- // Token 类型枚举 // --------------------------------------------------------------------------- var TokenType; (function (TokenType) { // 关键字 TokenType["SELECT"] = "SELECT"; TokenType["FROM"] = "FROM"; TokenType["WHERE"] = "WHERE"; TokenType["INSERT"] = "INSERT"; TokenType["INTO"] = "INTO"; TokenType["VALUES"] = "VALUES"; TokenType["UPDATE"] = "UPDATE"; TokenType["SET"] = "SET"; TokenType["DELETE"] = "DELETE"; TokenType["CREATE"] = "CREATE"; TokenType["TABLE"] = "TABLE"; TokenType["DROP"] = "DROP"; TokenType["ORDER"] = "ORDER"; TokenType["BY"] = "BY"; TokenType["ASC"] = "ASC"; TokenType["DESC"] = "DESC"; TokenType["LIMIT"] = "LIMIT"; TokenType["OFFSET"] = "OFFSET"; TokenType["AND"] = "AND"; TokenType["OR"] = "OR"; TokenType["NOT"] = "NOT"; TokenType["LIKE"] = "LIKE"; TokenType["IN"] = "IN"; TokenType["PRIMARY"] = "PRIMARY"; TokenType["KEY"] = "KEY"; TokenType["UNIQUE"] = "UNIQUE"; TokenType["DEFAULT"] = "DEFAULT"; TokenType["NULL"] = "NULL"; TokenType["TRUE"] = "TRUE"; TokenType["REFERENCES"] = "REFERENCES"; TokenType["CASCADE"] = "CASCADE"; TokenType["BETWEEN"] = "BETWEEN"; TokenType["IF"] = "IF"; TokenType["EXISTS"] = "EXISTS"; TokenType["FALSE"] = "FALSE"; TokenType["ALTER"] = "ALTER"; TokenType["ADD"] = "ADD"; TokenType["TRUNCATE"] = "TRUNCATE"; // JOIN 相关 TokenType["INNER"] = "INNER"; TokenType["LEFT"] = "LEFT"; TokenType["RIGHT"] = "RIGHT"; TokenType["CROSS"] = "CROSS"; TokenType["JOIN"] = "JOIN"; TokenType["ON"] = "ON"; TokenType["AS"] = "AS"; TokenType["OUTER"] = "OUTER"; // 聚合 TokenType["GROUP"] = "GROUP"; TokenType["HAVING"] = "HAVING"; TokenType["COUNT"] = "COUNT"; TokenType["SUM"] = "SUM"; TokenType["AVG"] = "AVG"; TokenType["MIN"] = "MIN"; TokenType["MAX"] = "MAX"; TokenType["DISTINCT"] = "DISTINCT"; // v0.3.0: 事务 / UNION / EXISTS / 动态索引 TokenType["BEGIN"] = "BEGIN"; TokenType["COMMIT"] = "COMMIT"; TokenType["ROLLBACK"] = "ROLLBACK"; TokenType["UNION"] = "UNION"; TokenType["ALL"] = "ALL"; TokenType["INDEX"] = "INDEX"; // v0.3.1: CASE WHEN 表达式 TokenType["CASE"] = "CASE"; TokenType["WHEN"] = "WHEN"; TokenType["THEN"] = "THEN"; TokenType["ELSE"] = "ELSE"; TokenType["END"] = "END"; // v0.5.1: 维护语句(EXPLAIN / ANALYZE / REINDEX / VACUUM / SAVEPOINT) TokenType["EXPLAIN"] = "EXPLAIN"; TokenType["ANALYZE"] = "ANALYZE"; TokenType["REINDEX"] = "REINDEX"; TokenType["VACUUM"] = "VACUUM"; TokenType["SAVEPOINT"] = "SAVEPOINT"; TokenType["RELEASE"] = "RELEASE"; TokenType["TO"] = "TO"; // 标识符 & 字面量 TokenType["IDENTIFIER"] = "IDENTIFIER"; /** * v0.8.0: 分隔标识符(双引号包裹,SQL 标准 `"name"`)。 * * 此前双引号被当作字符串定界符处理,`SELECT "name" FROM t` 会静默产出一个名为 * `'name'` 的**常量列**(行数正确、值全错、无任何报错),且该行为被 * tests/sql/lexer.test.ts 钉死为期望。现按 SQL 标准区分: * 'x' → STRING(字符串字面量) * "x" → QUOTED_IDENTIFIER(标识符,用于含特殊字符/保留字/大小写敏感的列名) * 双引号内以 "" 表示一个双引号。 */ TokenType["QUOTED_IDENTIFIER"] = "QUOTED_IDENTIFIER"; TokenType["STRING"] = "STRING"; TokenType["NUMBER"] = "NUMBER"; // 运算符 & 分隔符 TokenType["COMMA"] = "COMMA"; TokenType["LPAREN"] = "LPAREN"; TokenType["RPAREN"] = "RPAREN"; TokenType["SEMICOLON"] = "SEMICOLON"; TokenType["EQ"] = "EQ"; TokenType["NEQ"] = "NEQ"; TokenType["GT"] = "GT"; TokenType["GTE"] = "GTE"; TokenType["LT"] = "LT"; TokenType["LTE"] = "LTE"; TokenType["STAR"] = "STAR"; TokenType["DOT"] = "DOT"; // 特殊 TokenType["EOF"] = "EOF"; TokenType["ILLEGAL"] = "ILLEGAL"; })(TokenType || (TokenType = {})); // --------------------------------------------------------------------------- // 关键字映射 // --------------------------------------------------------------------------- const KEYWORDS = { 'SELECT': TokenType.SELECT, 'FROM': TokenType.FROM, 'WHERE': TokenType.WHERE, 'INSERT': TokenType.INSERT, 'INTO': TokenType.INTO, 'VALUES': TokenType.VALUES, 'UPDATE': TokenType.UPDATE, 'SET': TokenType.SET, 'DELETE': TokenType.DELETE, 'CREATE': TokenType.CREATE, 'TABLE': TokenType.TABLE, 'DROP': TokenType.DROP, 'ORDER': TokenType.ORDER, 'BY': TokenType.BY, 'ASC': TokenType.ASC, 'DESC': TokenType.DESC, 'LIMIT': TokenType.LIMIT, 'OFFSET': TokenType.OFFSET, 'AND': TokenType.AND, 'OR': TokenType.OR, 'NOT': TokenType.NOT, 'LIKE': TokenType.LIKE, 'IN': TokenType.IN, 'PRIMARY': TokenType.PRIMARY, 'KEY': TokenType.KEY, 'UNIQUE': TokenType.UNIQUE, 'DEFAULT': TokenType.DEFAULT, 'NULL': TokenType.NULL, 'TRUE': TokenType.TRUE, 'FALSE': TokenType.FALSE, 'REFERENCES': TokenType.REFERENCES, 'CASCADE': TokenType.CASCADE, 'BETWEEN': TokenType.BETWEEN, 'IF': TokenType.IF, 'EXISTS': TokenType.EXISTS, 'ALTER': TokenType.ALTER, 'ADD': TokenType.ADD, 'TRUNCATE': TokenType.TRUNCATE, // JOIN 'INNER': TokenType.INNER, 'LEFT': TokenType.LEFT, 'RIGHT': TokenType.RIGHT, 'CROSS': TokenType.CROSS, 'JOIN': TokenType.JOIN, 'ON': TokenType.ON, 'AS': TokenType.AS, 'OUTER': TokenType.OUTER, // 聚合 'GROUP': TokenType.GROUP, 'HAVING': TokenType.HAVING, 'COUNT': TokenType.COUNT, 'SUM': TokenType.SUM, 'AVG': TokenType.AVG, 'MIN': TokenType.MIN, 'MAX': TokenType.MAX, 'DISTINCT': TokenType.DISTINCT, // v0.3.0 'BEGIN': TokenType.BEGIN, 'COMMIT': TokenType.COMMIT, 'ROLLBACK': TokenType.ROLLBACK, 'UNION': TokenType.UNION, 'ALL': TokenType.ALL, 'INDEX': TokenType.INDEX, // v0.3.1 'CASE': TokenType.CASE, 'WHEN': TokenType.WHEN, 'THEN': TokenType.THEN, 'ELSE': TokenType.ELSE, 'END': TokenType.END, // v0.5.1 'EXPLAIN': TokenType.EXPLAIN, 'ANALYZE': TokenType.ANALYZE, 'REINDEX': TokenType.REINDEX, 'VACUUM': TokenType.VACUUM, 'SAVEPOINT': TokenType.SAVEPOINT, 'RELEASE': TokenType.RELEASE, 'TO': TokenType.TO, }; /** * metona-sqlark SQL Lexer — 词法分析器 * @module sql/lexer * * 将 SQL 字符串切分为 Token 流。 */ // --------------------------------------------------------------------------- // Lexer // --------------------------------------------------------------------------- class Lexer { constructor(input) { this.position = 0; this.readPosition = 0; this.ch = ''; this.input = input; this.readChar(); } /** 读取下一个 Token */ nextToken() { this.skipWhitespaceAndComments(); /** * v0.8.0:token 的**起始位置**在读到任何字符之前记录。 * * 为什么必须在这里记:此前的 position 由各 reader 自行回推,语义**按 token * 类型不一致** —— `readString` 用 `start = this.position + 1`(指向引号**之内**), * 而 `readIdentifier`/`readNumber` 用 `this.position - len`(指向首字符)。 * 于是 `'big'` 报 position 23 而实际从 24 开始,任何"按 position 切片"的 * 调用方都会多切一个字符(实测:CASE 的 THEN 值被切成 `"big'"`)。 * 这里统一为"token 首字符在源码中的下标",所有类型一致。 */ const tokenStart = this.position; let tok; switch (this.ch) { case ',': tok = this.makeToken(TokenType.COMMA, ',', tokenStart); break; case '(': tok = this.makeToken(TokenType.LPAREN, '(', tokenStart); break; case ')': tok = this.makeToken(TokenType.RPAREN, ')', tokenStart); break; case ';': tok = this.makeToken(TokenType.SEMICOLON, ';'); break; case '*': tok = this.makeToken(TokenType.STAR, '*', tokenStart); break; case '.': tok = this.makeToken(TokenType.DOT, '.', tokenStart); break; case '=': tok = this.makeToken(TokenType.EQ, '=', tokenStart); break; case '!': if (this.peekChar() === '=') { this.readChar(); tok = this.makeToken(TokenType.NEQ, '!=', tokenStart); } else { tok = this.makeToken(TokenType.ILLEGAL, '!', tokenStart); } break; case '>': if (this.peekChar() === '=') { this.readChar(); tok = this.makeToken(TokenType.GTE, '>=', tokenStart); } else { tok = this.makeToken(TokenType.GT, '>', tokenStart); } break; case '<': if (this.peekChar() === '=') { this.readChar(); tok = this.makeToken(TokenType.LTE, '<=', tokenStart); } else if (this.peekChar() === '>') { this.readChar(); tok = this.makeToken(TokenType.NEQ, '<>', tokenStart); } else { tok = this.makeToken(TokenType.LT, '<', tokenStart); } break; case "'": tok = this.readString(); break; case '"': // v0.8.0: 双引号 = 分隔标识符(SQL 标准),不再当作字符串字面量 tok = this.readQuotedIdentifier(); break; case '': tok = { type: TokenType.EOF, value: '', position: this.position }; break; default: if (this.isLetter(this.ch)) { const ident = this.readIdentifier(); const keyword = KEYWORDS[ident.toUpperCase()]; // v0.8.0: 用 tokenStart(首字符下标),不再用 position - len 回推 tok = { type: keyword ?? TokenType.IDENTIFIER, value: ident, position: tokenStart }; return tok; // 已读取完毕,不需要再 readChar } else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) { const num = this.readNumber(); tok = { type: TokenType.NUMBER, value: num, position: tokenStart }; return tok; } else { tok = this.makeToken(TokenType.ILLEGAL, this.ch, tokenStart); } break; } this.readChar(); return tok; } // ---- 内部 ---- readChar() { if (this.readPosition >= this.input.length) { this.ch = ''; } else { this.ch = this.input[this.readPosition]; } this.position = this.readPosition; this.readPosition++; } peekChar() { if (this.readPosition >= this.input.length) return ''; return this.input[this.readPosition]; } /** * 跳过空白与注释(v0.8.0:改为**循环**而非递归)。 * * 此前在 default 分支里用 `return this.nextToken()` 递归跳过注释, * 递归深度 = 连续注释个数:两万个连续的块注释就会直接 * `RangeError: Maximum call stack size exceeded`(且是原生错误而非 DatabaseError, * 调用方无法按 code 分类)。现在统一在一个循环里消费空白与注释。 */ skipWhitespaceAndComments() { for (;;) { // 空白 while (this.ch === ' ' || this.ch === '\t' || this.ch === '\n' || this.ch === '\r') { this.readChar(); } // -- 行注释 if (this.ch === '-' && this.peekChar() === '-') { this.skipLineComment(); continue; } // /* 块注释 */ if (this.ch === '/' && this.peekChar() === '*') { this.skipBlockComment(); continue; } return; } } /** 跳过 -- 行注释到行尾 */ skipLineComment() { while (this.ch !== '\n' && this.ch !== '\r' && this.ch !== '') { this.readChar(); } } /** * 跳过块注释 slash-star ... star-slash * * v0.8.0 根治:未闭合的块注释必须**报错**。 * 此前循环到 EOF 就直接返回、不抛错,于是 `DELETE FROM t WHERE id = '4' /*` * 会**照常执行删除**(审计实测真的删掉了 1 行);SQLite 会报 * `unterminated /* comment`。任何被截断/拼接的 SQL 都会因此静默改变语义。 */ skipBlockComment() { const start = this.position; this.readChar(); // skip '/' this.readChar(); // skip '*' while (this.ch !== '' && !(this.ch === '*' && this.peekChar() === '/')) { this.readChar(); } if (this.ch === '') { throw new DatabaseError(`Unterminated block comment at position ${start}`, 'PARSE_ERROR'); } this.readChar(); // skip '*' this.readChar(); // skip '/' } readIdentifier() { const start = this.position; while (this.isLetter(this.ch) || this.isDigit(this.ch) || this.ch === '_') { this.readChar(); } return this.input.slice(start, this.position); } readNumber() { const start = this.position; // 负号 if (this.ch === '-') this.readChar(); while (this.isDigit(this.ch)) { this.readChar(); } // 小数点 if (this.ch === '.' && this.isDigit(this.peekChar())) { this.readChar(); while (this.isDigit(this.ch)) { this.readChar(); } } return this.input.slice(start, this.position); } /** * 读取单引号字符串字面量。 * * v0.8.0 根治:**移除反斜杠转义**(此前只支持单引号前的反斜杠)。 * * 此前只识别「反斜杠 + 单引号」(MySQL 方言的半个实现):双反斜杠不解转义,于是 * - 单引号前的反斜杠被静默吞掉; * - 以反斜杠结尾的 Windows 路径会**吞掉闭引号**,抛出一条 * 与用户输入无关的 "Unterminated string literal"; * - 更严重的是,参数绑定器 params.ts 只把单引号翻倍、不处理反斜杠, * 两份词法规则不一致 —— "参数不可能改变 SQL 结构"这条不变量在文本层已不成立。 * * SQL 标准(以及 SQLite / PostgreSQL,本项目对齐的方言)中反斜杠是普通字符, * 反斜杠本身是普通字符、引号靠两个连续单引号表达。移除方言转义后,词法器与绑定器的字符串边界判定 * 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。 */ readString() { // v0.8.0: start = **开引号**的位置(此前 +1 指向引号之内,导致所有按 // position 切片的调用方都多切一个字符)。未闭合错误消息里用的 start 仍取 // 引号之后的位置,便于用户定位到内容起点。 const start = this.position; const contentStart = this.position + 1; this.readChar(); // 跳过开始引号 let value = ''; while (this.ch !== '') { if (this.ch === "'") { // SQL 标准 '' 转义(两个连续引号 = 一个引号) if (this.peekChar() === "'") { value += "'"; this.readChar(); // 跳过第二个引号 this.readChar(); continue; } break; // 结束引号(由 nextToken 的 readChar 跳过) } value += this.ch; this.readChar(); } // v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token, // 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列) if (this.ch === '') { throw new DatabaseError(`Unterminated string literal at position ${contentStart}`, 'PARSE_ERROR'); } return { type: TokenType.STRING, value, position: start, }; } /** * v0.8.0: 读取双引号分隔标识符(SQL 标准 `"column"`)。 * * 双引号内以 `""` 表示一个双引号字符(与单引号字符串的 `''` 规则对称)。 * 未闭合同样显式报错,与字符串字面量保持一致。 */ readQuotedIdentifier() { // v0.8.0: start = 开引号位置(position 语义统一为 token 首字符) const start = this.position; this.readChar(); // 跳过开始引号 let value = ''; while (this.ch !== '') { if (this.ch === '"') { if (this.peekChar() === '"') { value += '"'; this.readChar(); this.readChar(); continue; } break; } value += this.ch; this.readChar(); } if (this.ch === '') { throw new DatabaseError(`Unterminated quoted identifier at position ${start}`, 'PARSE_ERROR'); } if (value.length === 0) { throw new DatabaseError(`Empty quoted identifier at position ${start}`, 'PARSE_ERROR'); } return { type: TokenType.QUOTED_IDENTIFIER, value, // v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片 // 才能取到完整的分隔标识符文本) position: start, }; } isLetter(ch) { return /[a-zA-Z_]/.test(ch); } isDigit(ch) { return /[0-9]/.test(ch); } makeToken(type, value, start = this.position) { return { type, value, position: start }; } } // --------------------------------------------------------------------------- // 便捷方法:一次性词法分析 // --------------------------------------------------------------------------- /** 将 SQL 字符串解析为 Token 列表 */ function tokenize(sql) { const lexer = new Lexer(sql); const tokens = []; let tok = lexer.nextToken(); while (tok.type !== TokenType.EOF) { tokens.push(tok); tok = lexer.nextToken(); } tokens.push(tok); // EOF return tokens; } /** * metona-sqlark SQL Parser — 递归下降语法分析器 * @module sql/parser * * Token 流 → AST Statement。 * 支持的语法是标准 SQL 的子集。 */ // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- class Parser { constructor(sql) { this.sql = sql; this.lexer = new Lexer(sql); // 预读两个 token this.nextToken(); this.nextToken(); } /** 解析完整 SQL 语句 */ parseStatement() { switch (this.curToken.type) { case TokenType.SELECT: return this.parseSelect(); case TokenType.INSERT: return this.parseInsert(); case TokenType.UPDATE: return this.parseUpdate(); case TokenType.DELETE: return this.parseDelete(); case TokenType.CREATE: return this.parseCreateStatement(); case TokenType.DROP: return this.parseDropStatement(); case TokenType.ALTER: return this.parseAlterTable(); case TokenType.TRUNCATE: return this.parseTruncateTable(); case TokenType.BEGIN: return this.parseBegin(); case TokenType.COMMIT: return this.parseCommit(); case TokenType.ROLLBACK: return this.parseRollback(); // v0.5.1: 维护语句入口 case TokenType.EXPLAIN: return this.parseExplain(); case TokenType.ANALYZE: return this.parseAnalyze(); case TokenType.REINDEX: return this.parseReindex(); case TokenType.VACUUM: return this.parseVacuum(); case TokenType.SAVEPOINT: return this.parseSavepoint(); case TokenType.RELEASE: return this.parseSavepoint(); default: throw this.error(`Unexpected token "${this.curToken.value}"`); } } /** 解析所有语句(分号分隔的多语句支持) */ parseAllStatements() { const statements = []; while (!this.curTokenIs(TokenType.EOF)) { // 跳过多余的分号 while (this.curTokenIs(TokenType.SEMICOLON)) this.nextToken(); if (this.curTokenIs(TokenType.EOF)) break; statements.push(this.parseStatement()); // 语句后应紧跟分号或 EOF if (this.curTokenIs(TokenType.SEMICOLON)) { this.nextToken(); } else if (!this.curTokenIs(TokenType.EOF)) { throw this.error(`Expected ';' after statement, got "${this.curToken.value}"`); } } return statements; } // ---- 维护语句(v0.5.1) ---- /** EXPLAIN — 输出查询计划 */ parseExplain() { this.expect(TokenType.EXPLAIN); if (this.curTokenIs(TokenType.EXPLAIN)) { throw this.error('Nested EXPLAIN is not allowed'); } const query = this.parseStatement(); return { type: 'EXPLAIN', query }; } /** ANALYZE [TABLE] name — 收集表统计信息 */ parseAnalyze() { this.expect(TokenType.ANALYZE); if (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TABLE') { this.nextToken(); } return { type: 'ANALYZE', table: this.expectIdentifier('table name') }; } /** REINDEX [TABLE] name — 重建表二级索引 */ parseReindex() { this.expect(TokenType.REINDEX); if (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TABLE') { this.nextToken(); } return { type: 'REINDEX', table: this.expectIdentifier('table name') }; } /** VACUUM — 压缩 LSM + 清理碎片 */ parseVacuum() { this.expect(TokenType.VACUUM); return { type: 'VACUUM' }; } /** SAVEPOINT name | RELEASE [SAVEPOINT] name */ parseSavepoint() { let action; if (this.curTokenIs(TokenType.RELEASE)) { action = 'RELEASE'; this.nextToken(); } else { action = 'SAVE'; this.expect(TokenType.SAVEPOINT); } // 可选 SAVEPOINT 关键字(RELEASE SAVEPOINT name) if (this.curTokenIs(TokenType.SAVEPOINT)) this.nextToken(); return { type: 'SAVEPOINT', name: this.expectIdentifier('savepoint name'), action }; } // ---- 事务语句 ---- parseBegin() { this.expect(TokenType.BEGIN); // 可选 TRANSACTION 关键字 if (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TRANSACTION') { this.nextToken(); } return { type: 'BEGIN' }; } parseCommit() { this.expect(TokenType.COMMIT); if (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TRANSACTION') { this.nextToken(); } return { type: 'COMMIT' }; } /** ROLLBACK [TRANSACTION] | ROLLBACK TO [SAVEPOINT] name(v0.5.1) */ parseRollback() { this.expect(TokenType.ROLLBACK); if (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TRANSACTION') { this.nextToken(); return { type: 'ROLLBACK' }; } // ROLLBACK TO [SAVEPOINT] name if (this.curTokenIs(TokenType.TO) || (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'TO')) { this.nextToken(); if (this.curTokenIs(TokenType.SAVEPOINT) || (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'SAVEPOINT')) { this.nextToken(); } return { type: 'SAVEPOINT', name: this.expectIdentifier('savepoint name'), action: 'ROLLBACK' }; } return { type: 'ROLLBACK' }; } // ---- CREATE TABLE / CREATE INDEX ---- parseCreateStatement() { this.expect(TokenType.CREATE); if (this.curTokenIs(TokenType.TABLE)) { return this.parseCreateTable(); } if (this.curTokenIs(TokenType.INDEX) || (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'INDEX')) { return this.parseCreateIndex(); } if (this.curTokenIs(TokenType.UNIQUE)) { // CREATE UNIQUE INDEX this.nextToken(); if (this.curTokenIs(TokenType.INDEX) || (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'INDEX')) { const stmt = this.parseCreateIndex(); stmt.unique = true; return stmt; } } throw this.error(`Expected TABLE or INDEX after CREATE, got "${this.curToken.value}"`); } parseCreateIndex() { this.expect(TokenType.INDEX); const name = this.expectIdentifier('index name'); this.expect(TokenType.ON); const table = this.expectIdentifier('table name'); this.expect(TokenType.LPAREN); const column = this.expectIdentifier('column name'); this.expect(TokenType.RPAREN); return { type: 'CREATE_INDEX', name, table, column }; } // ---- DROP TABLE / DROP INDEX ---- parseDropStatement() { this.expect(TokenType.DROP); if (this.curTokenIs(TokenType.TABLE)) { return this.parseDropTable(); } if (this.curTokenIs(TokenType.INDEX) || (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'INDEX')) { return this.parseDropIndex(); } throw this.error(`Expected TABLE or INDEX after DROP, got "${this.curToken.value}"`); } parseDropIndex() { this.expect(TokenType.INDEX); const name = this.expectIdentifier('index name'); // SQLite 风格:DROP INDEX idx_name [ON table] let table = ''; let column = ''; if (this.curTokenIs(TokenType.ON)) { this.nextToken(); table = this.expectIdentifier('table name'); if (this.curTokenIs(TokenType.LPAREN)) { this.nextToken(); column = this.expectIdentifier('column name'); this.expect(TokenType.RPAREN); } } return { type: 'DROP_INDEX', name, table, column }; } // =================================================================== // SELECT // =================================================================== parseSelect() { this.expect(TokenType.SELECT); // DISTINCT(可选) let distinct = false; if (this.curTokenIs(TokenType.DISTINCT)) { distinct = true; this.nextToken(); } // 列 const columns = []; if (this.curTokenIs(TokenType.STAR)) { columns.push('*'); this.nextToken(); // v0.7.3: `SELECT *, col [AS alias], ...` —— '*' 后可继续列列表 // (此前 '*' 独占分支,逗号后直接 PARSE_ERROR;executor 侧投影已支持混合) while (this.curTokenIs(TokenType.COMMA)) { this.nextToken(); columns.push(this.parseColumnWithAlias()); } } else { columns.push(...this.parseColumnList()); } // FROM(v0.4.0 可选:SELECT 1 / SELECT 'lit' 无表查询) let fromSubquery; let tableName = ''; let alias; if (this.curTokenIs(TokenType.FROM)) { this.nextToken(); // v0.4.0: FROM (SELECT ...) AS alias 派生表 if (this.curTokenIs(TokenType.LPAREN)) { this.nextToken(); fromSubquery = this.parseSelect(); this.expect(TokenType.RPAREN); if (this.curTokenIs(TokenType.AS)) { this.nextToken(); alias = this.expectIdentifier('alias'); } else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) { alias = this.curToken.value; this.nextToken(); } } else { tableName = this.expectIdentifier('table name'); // 表别名(可选) if (this.curTokenIs(TokenType.AS)) { this.nextToken(); alias = this.expectIdentifier('alias'); } else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom()) { alias = this.curToken.value; this.nextToken(); } } } const stmt = { type: 'SELECT', columns, distinct: distinct || undefined, from: tableName, alias, where: {}, }; if (fromSubquery) { stmt.fromSubquery = fromSubquery; } // JOIN 子句(可选,支持多个) const joins = this.parseJoinClauses(); if (joins.length > 0) { stmt.joins = joins; } // WHERE(可选) if (this.curTokenIs(TokenType.WHERE)) { this.nextToken(); stmt.where = this.parseCondition(); } // GROUP BY(可选) if (this.curTokenIs(TokenType.GROUP)) { this.nextToken(); this.expect(TokenType.BY); stmt.groupBy = this.parseGroupByList(); } // HAVING(可选) if (this.curTokenIs(TokenType.HAVING)) { this.nextToken(); stmt.having = this.parseCondition(); } // ORDER BY(可选) if (this.curTokenIs(TokenType.ORDER)) { this.nextToken(); this.expect(TokenType.BY); stmt.orderBy = this.parseOrderByList(); } // LIMIT(可选) if (this.curTokenIs(TokenType.LIMIT)) { this.nextToken(); stmt.limit = this.expectNumber('LIMIT value'); } // OFFSET(可选) if (this.curTokenIs(TokenType.OFFSET)) { this.nextToken(); stmt.offset = this.expectNumber('OFFSET value'); } // UNION / UNION ALL(可选,v0.3.0) if (this.curTokenIs(TokenType.UNION)) { return this.parseUnion(stmt); } return stmt; } /** 解析 UNION / UNION ALL 组合(支持链式) */ parseUnion(left) { this.expect(TokenType.UNION); let all = false; if (this.curTokenIs(TokenType.ALL)) { all = true; this.nextToken(); } const right = this.parseSelect(); const unionStmt = { type: 'SELECT_UNION', left, right, all: all || undefined }; this.adoptTrailingClauses(unionStmt, right); // 链式 UNION if (this.curTokenIs(TokenType.UNION)) { return this.parseUnionChain(unionStmt); } return unionStmt; } /** 链式 UNION:左侧是已组合的 UNION 语句 */ parseUnionChain(left) { this.expect(TokenType.UNION); let all = false; if (this.curTokenIs(TokenType.ALL)) { all = true; this.nextToken(); } const right = this.parseSelect(); const unionStmt = { type: 'SELECT_UNION', left, right, all: all || undefined }; this.adoptTrailingClauses(unionStmt, right); if (this.curTokenIs(TokenType.UNION)) { return this.parseUnionChain(unionStmt); } return unionStmt; } /** * v0.8.0(A26):把"最后一个 SELECT 上的 ORDER BY / LIMIT / OFFSET"上移到 * 复合查询节点,并把这些子句从该 SELECT 上**移除**。 * * 为什么必须"移动"而不是"复制": * - 语法上它们写在最后一个 SELECT 之后,但 SQL 语义作用于整个 UNION * (`A UNION B LIMIT 3` 是"合并去重后取前 3 行",不是"B 取前 3 行"); * - 若只复制不移除,LIMIT 会**应用两次** —— 正是 A5/A6 那类"两处都生效" * 缺陷的同一个坑(B 先被截断,再对合并结果截断,结果可能少行)。 * * 由于 `parseSelect` 无法预知后面有没有 UNION(它在返回后才知道), * 只能先让它照常解析、发现 UNION 时再回收 —— 这比"预读 UNION"简单且无回溯。 */ adoptTrailingClauses(unionStmt, right) { // 链式 UNION 时右侧可能已是 UNION 节点,其尾部子句在创建时已上移 if (right.type !== 'SELECT') return; if (right.orderBy) { unionStmt.orderBy = right.orderBy; delete right.orderBy; } if (right.limit !== undefined) { unionStmt.limit = right.limit; delete right.limit; } if (right.offset !== undefined) { unionStmt.offset = right.offset; delete right.offset; } } /** 解析 JOIN 子句列表 */ parseJoinClauses() { const joins = []; while (this._isJoinKeyword()) { joins.push(this.parseJoinClause()); } return joins; } _isJoinKeyword() { return (this.curTokenIs(TokenType.INNER) || this.curTokenIs(TokenType.LEFT) || this.curTokenIs(TokenType.RIGHT) || this.curTokenIs(TokenType.CROSS) || this.curTokenIs(TokenType.JOIN)); } /** 解析单个 JOIN 子句 */ parseJoinClause() { let type = 'INNER'; if (this.curTokenIs(TokenType.INNER)) { type = 'INNER'; this.nextToken(); } else if (this.curTokenIs(TokenType.LEFT)) { type = 'LEFT'; this.nextToken(); if (this.curTokenIs(TokenType.OUTER)) this.nextToken(); // 可选 OUTER } else if (this.curTokenIs(TokenType.RIGHT)) { type = 'RIGHT'; this.nextToken(); if (this.curTokenIs(TokenType.OUTER)) this.nextToken(); } else if (this.curTokenIs(TokenType.CROSS)) { type = 'CROSS'; this.nextToken(); } this.expect(TokenType.JOIN); const tableName = this.expectIdentifier('table name'); // JOIN 表别名(可选) let alias; if (this.curTokenIs(TokenType.AS)) { this.nextToken(); alias = this.expectIdentifier('alias'); } else if (this.curToken.type === TokenType.IDENTIFIER && !this._isJoinReserved()) { alias = this.curToken.value; this.nextToken(); } // ON 条件(CROSS JOIN 不需要 ON) let on = {}; if (type !== 'CROSS' && this.curTokenIs(TokenType.ON)) { this.nextToken(); on = this.parseCondition(); } return { type, table: tableName, alias, on }; } /** 判断当前 token 是否为 FROM 之后的保留字 */ _isReservedAfterFrom() { return (this.curTokenIs(TokenType.WHERE) || this.curTokenIs(TokenType.ORDER) || this.curTokenIs(TokenType.LIMIT) || this.curTokenIs(TokenType.OFFSET) || this.curTokenIs(TokenType.GROUP) || this._isJoinKeyword()); } _isJoinReserved() { return (this.curTokenIs(TokenType.ON) || this.curTokenIs(TokenType.WHERE) || this.curTokenIs(TokenType.ORDER) || this.curTokenIs(TokenType.LIMIT) || this._isJoinKeyword()); } // =================================================================== // INSERT // =================================================================== parseInsert() { this.expect(TokenType.INSERT); this.expect(TokenType.INTO); const tableName = this.expectIdentifier('table name'); // 列名(可选) let columns; if (this.curTokenIs(TokenType.LPAREN)) { this.nextToken(); columns = this.parseIdentifierList(); this.expect(TokenType.RPAREN); } // INSERT INTO ... SELECT ...(v0.3.0) if (this.curTokenIs(TokenType.SELECT)) { return { type: 'INSERT', into: tableName, columns, select: this.parseSelect(), }; } // VALUES this.expect(TokenType.VALUES); // 值列表 const values = []; do { if (this.curTokenIs(TokenType.COMMA)) { this.nextToken(); } this.expect(TokenType.LPAREN); const rowValues = this.parseValueList(); this.expect(TokenType.RPAREN); values.push(rowValues); } while (this.curTokenIs(TokenType.COMMA)); // v0.8.0 根治:显式列名时校验每行值的个数与列数一致。 // // 此前完全不校验 arity,实测: // INSERT INTO t (id, name) VALUES ('4','z',9) → 多余的 9 被**静默丢弃** // INSERT INTO t VALUES ('3') → 静默写入半行(其余列缺失) // SQLite / MySQL 都会报错。静默丢弃/截断属于"静默数据丢失", // 必须在解析期拦下(此时无需 schema,只要有显式列名即可判断)。 // // 未显式给列名时(INSERT INTO t VALUES (...))需要 schema 才能判断个数, // 由 executor 在拿到 schema 后校验(见 validateInsertArity)。 if (columns) { for (let i = 0; i < values.length; i++) { if (values[i].length !== columns.length) { throw this.error(`INSERT column/value count mismatch: ${columns.length} column(s) but row ${i + 1} has ${values[i].length} value(s)`); } } } return { type: 'INSERT', into: tableName, columns, values, }; } // =================================================================== // UPDATE // =================================================================== /** * v0.8.0: 创建**无原型**对象,用于以用户提供的列名为键的映射。 * * 背景:`obj['__proto__'] = v` 在普通对象上会触发原型 setter 而不是新增属性, * 于是 `UPDATE t SET __proto__ = 'x'` 的 sets 变成 `{}` —— 既没写进去、也不会被 * v0.7.4 新增的"未知列显式报错"预检看到,表现为"返回成功但什么都没发生"。 * 建表路径在 v0.7.1 已用 Object.create(null) 防护,此处补齐其余路径。 */ newColumnMap() { return Object.create(null); } parseUpdate() { this.expect(TokenType.UPDATE); const tableName = this.expectIdentifier('table name'); this.expect(TokenType.SET); // SET col=val, ...(v0.8.0: 无原型对象,防 __proto__ 列名静默吞掉赋值) const sets = this.newColumnMap(); do { if (this.curTokenIs(TokenType.COMMA)) this.nextToken(); const col = this.expectIdentifier('column name'); this.expect(TokenType.EQ); sets[col] = this.parseValue(); } while (this.curTokenIs(TokenType.COMMA)); let where = this.newColumnMap(); if (this.curTokenIs(TokenType.WHERE)) { this.nextToken(); where = this.parseCondition(); } return { type: 'UPDATE', table: tableName, sets, where }; } // =================================================================== // DELETE // =================================================================== parseDelete() { this.expect(TokenType.DELETE); this.expect(TokenType.FROM); const tableName = this.expectIdentifier('table name'); let where = this.newColumnMap(); if (this.curTokenIs(TokenType.WHERE)) { this.nextToken(); where = this.parseCondition(); } return { type: 'DELETE', from: tableName, where }; } // =================================================================== // CREATE TABLE // =================================================================== parseCreateTable() { this.expect(TokenType.TABLE); // IF NOT EXISTS(可选) let ifNotExists = false; if (this.curTokenIs(TokenType.IF)) { this.nextToken(); this.expect(TokenType.NOT); this.expect(TokenType.EXISTS); ifNotExists = true; } const tableName = this.expectIdentifier('table name'); this.expect(TokenType.LPAREN); const columns = []; do { if (this.curTokenIs(TokenType.COMMA)) this.nextToken(); columns.push(this.parseColumnDef()); } while (this.curTokenIs(TokenType.COMMA)); this.expect(TokenType.RPAREN); return { type: 'CREATE_TABLE', name: tableName, columns, ifNotExists: ifNotExists || undefined }; } parseColumnDef() { const name = this.expectIdentifier('column name'); const type = this.expectIdentifier('column type').toLowerCase(); const col = { name, type }; // 修饰符 while (this.curTokenIs(TokenType.PRIMARY) || this.curTokenIs(TokenType.UNIQUE) || this.curTokenIs(TokenType.NOT) || this.curTokenIs(TokenType.DEFAULT) || this.curTokenIs(TokenType.REFERENCES)) { if (this.curTokenIs(TokenType.PRIMARY)) { this.nextToken(); this.expect(TokenType.KEY); col.primaryKey = true; } else if (this.curTokenIs(TokenType.UNIQUE)) { this.nextToken(); col.unique = true; } else if (this.curTokenIs(TokenType.NOT)) { this.nextToken(); this.expect(TokenType.NULL); col.required = true; } else if (this.curTokenIs(TokenType.DEFAULT)) { this.nextToken(); col.default = this.parseValue(); } else if (this.curTokenIs(TokenType.REFERENCES)) { this.nextToken(); const refTable = this.expectIdentifier('referenced table'); this.expect(TokenType.LPAREN); const refCol = this.expectIdentifier('referenced column'); this.expect(TokenType.RPAREN); col.references = `${refTable}.${refCol}`; // ON DELETE / ON UPDATE while (this.curTokenIs(TokenType.ON)) { this.nextToken(); if (this.curTokenIs(TokenType.DELETE)) { this.nextToken(); col.onDelete = this.parseCascadeAction(); } else if (this.curTokenIs(TokenType.UPDATE)) { this.nextToken(); col.onUpdate = this.parseCascadeAction(); } else { break; } } } else { break; } } return col; } /** 解析 CASCADE | SET NULL | RESTRICT */ parseCascadeAction() { if (this.curTokenIs(TokenType.CASCADE)) { this.nextToken(); return 'CASCADE'; } if (this.curTokenIs(TokenType.SET)) { this.nextToken(); this.expect(TokenType.NULL); return 'SET NULL'; } // RESTRICT 或默认 if (this.curToken.type === TokenType.IDENTIFIER && this.curToken.value.toUpperCase() === 'RESTRICT') { this.nextToken(); return 'RESTRICT'; } return 'RESTRICT'; } // =================================================================== // ALTER TABLE // =================================================================== parseAlterTable() { this.expect(TokenType.ALTER); this.expect(TokenType.TABLE); const tableName = this.expectIdentifier('table name'); // ADD COLUMN / DROP COLUMN let action; if (this.curTokenIs(TokenType.ADD)) { action = 'ADD'; this.nextToken(); // Optional COLUMN keyword if (this.curToken.type === TokenType.IDENTIFIER && this.curToken.value.toUpperCase() === 'COLUMN') { this.nextToken(); } const col = this.parseColumnDef(); return { type: 'ALTER_TABLE', name: tableName, action, column: col }; } else if (this.curTokenIs(TokenType.DROP) || (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'DROP')) { action = 'DROP'; this.nextToken(); // Optional COLUMN keyword if (this.curToken.type === TokenType.IDENTIFIER && this.curToken.value.toUpperCase() === 'COLUMN') { this.nextToken(); } const colName = this.expectIdentifier('column name'); return { type: 'ALTER_TABLE', name: tableName, action, column: { name: colName, type: 'string' } }; } else { throw this.error('Expected ADD or DROP in ALTER TABLE'); } } // =================================================================== // TRUNCATE TABLE // =================================================================== parseTruncateTable() { this.expect(TokenType.TRUNCATE); this.expect(TokenType.TABLE); const tableName = this.expectIdentifier('table name'); return { type: 'TRUNCATE_TABLE', name: tableName }; } // =================================================================== // DROP TABLE // =================================================================== parseDropTable() { this.expect(TokenType.TABLE); // IF EXISTS(可选) let ifExists = false; if (this.curTokenIs(TokenType.IF)) { this.nextToken(); this.expect(TokenType.EXISTS); ifExists = true; } const tableName = this.expectIdentifier('table name'); return { type: 'DROP_TABLE', name: tableName, ifExists: ifExists || undefined }; } // =================================================================== // 条件表达式 // =================================================================== /** * condition → or_expr * * v0.8.0 根治:AND 的优先级必须高于 OR(SQL 标准)。 * * 此前实现是**纯左折叠**的单层循环: * `a = 1 OR a = 2 AND b = 3` → `(a = 1 OR a = 2) AND b = 3` ← 错 * 标准语义应为: * `a = 1 OR a = 2 AND b = 3` → `a = 1 OR (a = 2 AND b = 3)` ← 对 * * 影响面:任何"权限条件 OR 业务条件 AND 软删标记"的写法都会静默返回错误行集 * (审计实测:4 行表上返回 1 行而非 3 行)。这是本层影响面最大、改动最小的缺陷。 * * 现在按标准文法分层:or_expr → and_expr (OR and_expr)* * and_expr → unary (AND unary)* * unary → [NOT] primary * 并且只在**确实有多个操作数**时才包 $and/$or,避免生成 {$and:[x]} 这种冗余节点 * (否则 `WHERE a = 1` 的结构会从 `{a:{$eq:1}}` 变成 `{$and:[{a:{$eq:1}}]}`, * 破坏既有 AST 契约与下游引擎的索引下推识别)。 */ parseCondition() { return this.parseOrExpression(); } /** or_expr → and_expr (OR and_expr)* */ parseOrExpression() { const operands = [this.parseAndExpression()]; while (this.curTokenIs(TokenType.OR)) { this.nextToken(); operands.push(this.parseAndExpression()); } return operands.length === 1 ? operands[0] : { $or: operands }; } /** and_expr → simple_cond (AND simple_cond)* */ parseAndExpression() { const operands = [this.parseSimpleCondition()]; while (this.curTokenIs(TokenType.AND)) { this.nextToken(); operands.push(this.parseSimpleCondition()); } return operands.length === 1 ? operands[0] : { $and: operands }; } /** 公共 WHERE 条件入口(供 CASE WHEN 求值等外部场景,v0.3.1) */ parseWhere() { return this.parseCondition(); } /** simple_cond → column op value | column IS [NOT] NULL | column [NOT] LIKE pattern * | column [NOT] IN (values) | NOT condition | (condition) * | [NOT] EXISTS (SELECT ...) ← v0.3.0 */ parseSimpleCondition() { // [NOT] EXISTS (SELECT ...) if (this.curTokenIs(TokenType.EXISTS) || (this._isKeywordAsIdent() && this.curToken.value.toUpperCase() === 'EXISTS')) { this.nextToken(); return this.parseExistsCondition(false); } if (this.curTokenIs(TokenType.NOT) && this._peekIsExists()) { this.nextToken(); // 跳过 NOT this.nextToken(); // 跳过 EXISTS return this.parseExistsCondition(true); } // NOT expr(注意 NOT IN / NOT LIKE 不作为通用 NOT) if (this.curTokenIs(TokenType.NOT) && !this._isNotInOrLike()) { this.nextToken(); const inner = this.parseSimpleCondition(); return { $not: inner }; } // (condition) if (this.curTokenIs(TokenType.LPAREN)) { this.nextToken(); const inner = this.parseCondition(); this.expect(TokenType.RPAREN); return inner; } // column const column = this.parseColumnRef(); // IS NULL / IS NOT NULL if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'IS') { this.nextToken(); const isNot = this.curTokenIs(TokenType.NOT); if (isNot) this.nextToken(); this.expect(TokenType.NULL); const result = this.newColumnMap(); // v0.8.0 三值语义:IS NULL / IS NOT NULL 是**谓词**,不是等值比较。 // 此前生成为 { $eq: null } / { $ne: null } —— 在 SQL 标准里 // `x = NULL` 恒为 UNKNOWN(不保留任何行),而 IS NULL 要保留 NULL 行。 // 两者语义不同,必须用不同标记(见 query/sql-compare.ts 的谓词说明)。 result[column] = isNot ? { $isNotNull: true } : { $isNull: true }; return result; } // BETWEEN val1 AND val2 if (this.curTokenIs(TokenType.BETWEEN)) { this.nextToken(); const low = this.parseValue(); this.expect(TokenType.AND); const high = this.parseValue(); // v0.8.0 根治:BETWEEN 必须是**范围**条件。 // 此前把同一个对象同时当成"操作符对象"和"操作数"传给 `$eq` // (`{ $eq: { $gte, $lte } }`),matchOperator 的 `$eq` 收到一个对象再去比较, // 结果只对"值恰好等于该对象"的行成立 —— 实测 `WHERE n BETWEEN 1 AND 2` // 在 (1,2,NULL,3) 上只返回 1 行(应 2 行),静默错值。 const result = this.newColumnMap(); result[column] = { $gte: low, $lte: high }; return result; } // NOT BETWEEN val1 AND val2 if (this.curTokenIs(TokenType.NOT) && this.peekTokenIs(TokenType.BETWEEN)) { this.nextToken(); // skip NOT this.nextToken(); // skip BETWEEN const low = this.parseValue(); this.expect(TokenType.AND); const high = this.parseValue(); const result = this.newColumnMap(); // v0.8.0:NOT BETWEEN ≡ (x < low) OR (x > high),直接展开为字段级 `$or`。 // // 此前生成 `{ $not: { $gte, $lte } }`。这**曾经**恒为空集:字段级 `$not` // 把内层对象当"单个条件对象",key `$gte`/`$lte` 被当成列名去取 // `row['$gte']` → undefined → 整条恒 UNKNOWN → 取反仍 UNKNOWN → 全部排除。 // // 求值器已修正(内层按操作符对象解释),但这里仍选择展开为 `$or`: // - `$or` 的三值行为(含 NULL 时 UNKNOWN)是显式可读的; // - 避免依赖"$not 作用于比较"与"$not 作用于谓词"(如 `$isNull`)的差别。 // 两条路径都有测试锁定(tests/v080-sql-three-valued.test.ts 与 // tests/sql/where-matcher.test.ts 的 `$not` 用例)。 result[column] = { $or: [{ $lt: low }, { $gt: high }] }; return result; } // NOT LIKE / NOT IN(NOT 后紧跟 LIKE 或 IN) if (this.curTokenIs(TokenType.NOT)) { if (this.peekTokenIs(TokenType.IN)) { // NOT IN this.nextToken(); // skip NOT this.nextToken(); // skip IN this.expect(TokenType.LPAREN); if (this.curTokenIs(TokenType.SELECT)) { const subquery = this.parseSelect(); this.expect(TokenType.RPAREN); const result = this.newColumnMap(); result[column] = { $nin: { $subquery: subquery } }; return result; } const values = this.parseValueList(); this.expect(TokenType.RPAREN); const result = this.newColumnMap(); result[column] = { $nin: values }; return result; } else if (this.peekTokenIs(TokenType.LIKE)) { // NOT LIKE this.nextToken(); // skip NOT this.nextToken(); // skip LIKE const pattern = this.parseValue(); const result = this.newColumnMap(); result[column] = { $not: { $like: pattern } }; return result; } } // LIKE if (this.curTokenIs(TokenType.LIKE)) { this.nextToken(); const pattern = this.parseValue(); const result = this.newColumnMap(); result[column] = { $like: pattern }; return result; } // IN if (this.curTokenIs(TokenType.IN)) { this.nextToken(); this.expect(TokenType.LPAREN); // 子查询: IN (SELECT ...) if (this.curTokenIs(TokenType.SELECT)) { const subquery = this.parseSelect(); this.expect(TokenType.RPAREN); const result = this.newColumnMap(); result[column] = { $in: { $subquery: subquery } }; return result; } const values = this.parseValueList(); this.expect(TokenType.RPAREN); const result = this.newColumnMap(); result[column] = { $in: values }; return result; } // v0.4.1: 裸布尔列条件(WHERE done / CASE WHEN done THEN)— 列后直接是终止符时视为真值判断 if (this.curTokenIs(TokenType.AND) || this.curTokenIs(TokenType.OR) || this.curTokenIs(TokenType.RPAREN) || this.curTokenIs(TokenType.EOF) || (this.curToken.type === TokenType.IDENTIFIER && ['THEN', 'END', 'ELSE', 'NULLS', 'LIMIT', 'OFFSET', 'ORDER', 'GROUP', 'HAVING', 'UNION', 'WHERE'].includes(this.curToken.value.toUpperCase()))) { const result = this.newColumnMap(); result[column] = { $eq: true }; return result; } // 比较运算符 const op = this.parseComparisonOp(); // 子查询: op (SELECT ...) if (this.curTokenIs(TokenType.LPAREN) && this.peekTokenIs(TokenType.SELECT)) { this.nextToken(); // skip ( const subquery = this.parseSelect(); this.expect(TokenType.RPAREN); const result = this.newColumnMap(); result[column] = { [op]: { $subquery: subquery } }; return result; } // 解析比较运算符右侧的操作数(v0.8.0: 支持列引用) // // 此前只有 `a.b`(带点)形态被当作列引用,裸标识符一律走 parseValue → // `WHERE x = y` 抛 PARSE_ERROR("Expected value, got \"y\"")。于是列对列比较 // 必须写成 `WHERE t.x = t.y`,而多表 JOIN 里未限定列恰恰是最自然的写法 // (`ON k = k` 同样报错)。 // // 判定依据是**位置**而不是猜测:能走到这里说明运算符已被消费,当前 token // 就是右侧操作数。这个位置上除了列名只可能是字面量,而字面量有各自的 // token 类型(NUMBER / STRING / TRUE / FALSE / NULL)—— 因此 // **IDENTIFIER 在操作数位置必然是列引用**,没有别的可能: // `id = 5` / `id = 'a'` / `active = TRUE` / `x = NULL` → 都不是 IDENTIFIER ✓ // `x = y` → y 是 IDENTIFIER → 列引用 ✓ // // 拼错的列名(`WHERE id = nmae`)因此变成 **COLUMN_NOT_FOUND**(列不存在)而不是 // PARSE_ERROR —— 这是更准确的诊断:问题不是"语法不对",而是"没有这一列"。 let value; if (this.curToken.type === TokenType.QUOTED_IDENTIFIER) { // 分隔标识符("col")在操作数位置同样是列引用 value = { $col: this.expectColumnReference() }; } else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedKeywordToken()) { value = { $col: this.expectColumnReference() }; } else { value = this.parseValue(); } const result = this.newColumnMap(); result[column] = { [op]: value }; return result; } /** * v0.8.0:当前 token 是否"看起来像标识符但其实是关键字"。 * * 操作数位置上的 IDENTIFIER 必然是列引用(见上),但 `_isKeywordAsIdent()` 为真 * 的那些 token 是**关键字**(它们可能作为列名出现在别处,但不会作为比较的右操作数 * 出现)—— 例如 `WHERE a = AND` 之类的非法输入应当继续报 PARSE_ERROR, * 而不是被当成列名再去查表。 */ _isReservedKeywordToken() { return this.curToken.type !== TokenType.IDENTIFIER && this._isKeywordAsIdent(); } /** 解析 EXISTS (SELECT ...) / NOT EXISTS (SELECT ...) */ parseExistsCondition(negate) { this.expect(TokenType.LPAREN); const subquery = this.parseSelect(); this.expect(TokenType.RPAREN); // $exists 键由 Executor.resolveSubqueries 解析为 boolean,where-matcher 消费 return { $exists: { $subquery: subquery, $negate: negate || undefined } }; } /** 判断当前 NOT 后是否紧跟 EXISTS */ _peekIsExists() { return this.peekToken.type === TokenType.EXISTS || (this.peekToken.type === TokenType.IDENTIFIER && this.peekToken.value.toUpperCase() === 'EXISTS'); } /** 判断当前 NOT 是否为 NOT IN / NOT LIKE 的一部分(不应作为通用 NOT 处理) */ _isNotInOrLike() { return this.peekTokenIs(TokenType.IN) || this.peekTokenIs(TokenType.LIKE); } peekTokenIs(type) { return this.peekToken.type === type; } /** * 解析一个列引用:`a` 或 `a.b`(v0.8.0 支持未限定形态)。 * 与 `parseColumnRef` 的区别:后者还接受 CASE/数字/字符串字面量(SELECT 列表用), * 这里只接受真正的列名。 */ expectColumnReference() { const col = this.expectIdentifier('column reference'); if (this.curTokenIs(TokenType.DOT)) { this.nextToken(); return `${col}.${this.expectIdentifier('column name after "."')}`; } return col; } parseComparisonOp() { switch (this.curToken.type) { case TokenType.EQ: this.nextToken(); return '$eq'; case TokenType.NEQ: this.nextToken(); return '$ne'; case TokenType.GT: this.nextToken(); return '$gt'; case TokenType.GTE: this.nextToken(); return '$gte'; case TokenType.LT: this.nextToken(); return '$lt'; case TokenType.LTE: this.nextToken(); return '$lte'; default: throw this.error(`Expected comparison operator, got "${this.curToken.value}"`); } } // =================================================================== // 辅助解析 // =================================================================== parseColumnList() { const cols = []; cols.push(this.parseColumnWithAlias()); while (this.curTokenIs(TokenType.COMMA)) { this.nextToken(); cols.push(this.parseColumnWithAlias()); } return cols; } /** v0.3.3: 解析列(支持 `col AS alias` 显式别名与 `col alias` 隐式别名) */ parseColumnWithAlias() { let col = this.parseColumnRef(); if (this.curTokenIs(TokenType.AS)) { this.nextToken(); const alias = this.expectIdentifier('alias'); col = `${col} AS ${alias}`; } else if (this.curToken.type === TokenType.IDENTIFIER && !this._isReservedAfterFrom() && !this._isJoinKeyword()) { const alias = this.curToken.value; this.nextToken(); col = `${col} AS ${alias}`; } return col; } /** 解析列引用:支持 'col'、'table.col'、'COUNT(*)'/'SUM(col)'、数字常量列(SELECT 1)、字符串常量列(SELECT 'x',v0.4.0)和 CASE WHEN 表达式(v0.3.1) */ parseColumnRef() { // CASE WHEN 表达式(v0.3.1) if (this.curTokenIs(TokenType.CASE)) { return this.parseCaseExpressionText(); } // 数字常量列:SELECT 1 FROM t(常见于 EXISTS 子查询) // v0.8.0: 只返回常量文本,别名交给调用方 parseColumnWithAlias 处理 //(此前这里直接 return,导致 `SELECT 1 AS one` 的别名被丢弃, // 投影时 row['1'] → undefined → 整行变成 {})。 if (this.curTokenIs(TokenType.NUMBER)) { const value = this.curToken.value; this.nextToken(); return value; } // v0.4.0: 字符串常量列:SELECT 'value' FROM t // v0.8.0: 同样只返回字面量文本,别名由 parseColumnWithAlias 叠加 if (this.curTokenIs(TokenType.STRING)) { const value = this.curToken.value; this.nextToken(); return `'${value.replace(/'/g, "''")}'`; } // 聚合函数? if (this.curTokenIs(TokenType.COUNT) || this.curTokenIs(TokenType.SUM) || this.curTokenIs(TokenType.AVG) || this.curTokenIs(TokenType.MIN) || this.curTokenIs(TokenType.MAX)) { return this.parseAggregateCall(); } /** * v0.8.0(B-5):分隔标识符必须**保留引号**。 * * 此前 `SELECT "1" FROM q`(列名就叫 `1`,建表时用引号声明)被解析成裸 `1`, * 而投影阶段把裸数字当**常量**列 → 返回 `{"1": 1}`(字面量 1), * 而 `SELECT *` 返回正确的 `{"1": "z"}` —— 同一列两种结论(实测)。 * 保留引号后,下游能区分"名为 1 的列"与"常量 1"。 */ if (this.curTokenIs(TokenType.QUOTED_IDENTIFIER)) { const name = this.curToken.value; this.nextToken(); if (this.curTokenIs(TokenType.DOT)) { this.nextToken(); const second = this.expectIdentifier('column name after "."'); return `"${name.replace(/"/g, '""')}".${second}`; } return `"${name.replace(/"/g, '""')}"`; } const first = this.expectIdentifier('column name'); if (this.curTokenIs(TokenType.DOT)) { this.nextToken(); const second = this.expectIdentifier('column name'); return `${first}.${second}`; } return first; } /** * 解析 CASE WHEN 表达式,返回原文(含可选 AS 别名)。 * 例:CASE WHEN age > 30 THEN 'senior' ELSE 'junior' END AS status */ parseCaseExpressionText() { const start = this.curToken.position; this.nextToken(); // 跳过 CASE let depth = 1; let end = start + 'CASE'.length; while (!this.curTokenIs(TokenType.EOF) && depth > 0) { if (this.curTokenIs(TokenType.CASE)) depth++; if (this.curTokenIs(TokenType.END)) { depth--; end = this.curToken.position + 'END'.length; this.nextToken(); if (depth === 0) break; } end = this.curToken.position + this.curToken.value.length; this.nextToken(); } let text = this.sql.slice(start, end); // 可选 AS 别名 if (this.curTokenIs(TokenType.AS)) { this.nextToken(); text += ` AS ${this.expectIdentifier('alias')}`; } else if (this.curToken.type === TokenType.IDENTIFIER && !this.curTokenIs(TokenType.COMMA) && !this._isReservedAfterFrom()) { text += ` AS ${this.curToken.value}`; this.nextToken(); } return text; } /** 解析聚合函数调用: COUNT(*), SUM(col), AVG(col), MIN(col), MAX(col),v0.4.0 支持 COUNT(DISTINCT col) */ parseAggregateCall() { const func = this.curToken.value.toUpperCase(); this.nextToken(); this.expect(TokenType.LPAREN); // v0.4.0: COUNT(DISTINCT col) 等去重聚合 let distinct = false; if (this.curTokenIs(TokenType.DISTINCT)) { distinct = true; this.nextToken(); } let arg; if (this.curTokenIs(TokenType.STAR)) { arg = '*'; this.nextToken(); } else { arg = this.parseColumnRef(); } this.expect(TokenType.RPAREN); // 可选别名: AS alias let alias = ''; if (this.curTokenIs(TokenType.AS)) { this.nextToken(); alias = this.expectIdentifier('alias'); } else if (this.curToken.type === TokenType.IDENTIFIER && this._isAggregateAlias()) { alias = this.curToken.value; this.nextToken(); } const inner = distinct ? `DISTINCT ${arg}` : arg; if (alias) { return `${func}(${inner}) AS ${alias}`; } return `${func}(${inner})`; } _isAggregateAlias() { return !this._isReservedAfterFrom() && !this._isJoinKeyword(); } parseIdentifierList() { const ids = []; ids.push(this.parseIdentifierWithDot()); while (this.curTokenIs(TokenType.COMMA)) { this.nextToken(); ids.push(this.parseIdentifierWithDot()); } return ids; } parseValueList() { const vals = []; vals.push(this.parseValue()); while (this.curTokenIs(TokenType.COMMA)) { this.nextToken(); vals.push(this.parseValue()); } return vals; } /** * v0.8.0(B-5):ORDER BY / GROUP BY 的键可以是**输出列序号**(SQL 标准)。 * * `ORDER BY 1` 表示"按第 1 个输出列排序",`GROUP BY 2` 同理 —— * 这在手写 SQL 与 UNION 里非常常用(各分支输出列名可能不同,只能按序号引用)。 * * 此前 parser 的 `parseIdentifierWithDot` 只接受标识符,于是 `ORDER BY 1` * 直接 `PARSE_ERROR: Expected identifier, got "1"`(实测)。 * * 这里把序号**原样保留为数字字符串**(AST 形状不变),由 executor 在拿到 * SELECT 列表后再解析成对应表达式 —— 序号的含义依赖 SELECT 列表, * parser 层无从判断。 */ parseOrderOrGroupKey() { if (this.curTokenIs(TokenType.NUMBER)) { const value = this.curToken.value; // 序号必须是正整数(`ORDER BY 0` / `ORDER BY 1.5` 非法) if (!/^\d+$/.test(value) || Number(value) < 1) { throw this.error(`Invalid output column ordinal "${value}" (must be a positive integer)`); } this.nextToken(); return value; } return this.parseIdentifierWithDot(); } parseGroupByList() { const list = []; list.push(this.parseOrderOrGroupKey()); while (this.curTokenIs(TokenType.COMMA)) { this.nextToken(); list.push(this.parseOrderOrGroupKey()); } return list; } parseOrderByList() { const list = []; list.push(this.parseOrderBy()); while (this.curTokenIs(TokenType.COMMA)) { this.nextToken(); list.push(this.parseOrderBy()); } return list; } parseOrderBy() { const column = this.parseOrderOrGroupKey(); let direction = 'asc'; if (this.curTokenIs(TokenType.ASC)) { this.nextToken(); } else if (this.curTokenIs(TokenType.DESC)) { direction = 'desc'; this.nextToken(); } // v0.4.0: NULLS FIRST / NULLS LAST let nulls; if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'NULLS') { this.nextToken(); if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'FIRST') { nulls = 'first'; this.nextToken(); } else if (this.curTokenIs(TokenType.IDENTIFIER) && this.curToken.value.toUpperCase() === 'LAST') { nulls = 'last'; this.nextToken(); } } return { column, direction, ...(nulls ? { nulls } : {}) }; } /** v0.4.0: 标识符(支持 'table.column' 带表前缀引用,用于 ORDER BY / GROUP BY) */ parseIdentifierWithDot() { const first = this.expectIdentifier('identifier'); if (this.curTokenIs(TokenType.DOT)) { this.nextToken(); return `${first}.${this.expectIdentifier('identifier')}`; } return first; } /** 解析字面量值 */ parseValue() { switch (this.curToken.type) { case TokenType.STRING: { const val = this.curToken.value; this.nextToken(); return val; } case TokenType.NUMBER: { const val = Number(this.curToken.value); this.nextToken(); return val; } case TokenType.TRUE: this.nextToken(); return true; case TokenType.FALSE: this.nextToken(); return false; case TokenType.NULL: this.nextToken(); return null; default: throw this.error(`Expected value, got "${this.curToken.value}"`); } } // =================================================================== // Token 操作 // =================================================================== nextToken() { this.curToken = this.peekToken; this.peekToken = this.lexer.nextToken(); } curTokenIs(type) { return this.curToken.type === type; } expect(type) { if (this.curTokenIs(type)) { this.nextToken(); return; } throw this.error(`Expected ${type}, got "${this.curToken.value}"`); } expectIdentifier(context) { // v0.8.0: 分隔标识符 "col" 与普通标识符等价(但不参与关键字识别, // 因此可以用它引用保留字列名,如 "order" / "select") if (this.curToken.type === TokenType.QUOTED_IDENTIFIER) { const val = this.curToken.value; this.nextToken(); return val; } if (this.curToken.type === TokenType.IDENTIFIER || this._isKeywordAsIdent()) { const val = this.curToken.value; this.nextToken(); return val; } throw this.error(`Expected ${context}, got "${this.curToken.value}"`); } /** 关键字可以作为标识符(如列名等于关键字) */ _isKeywordAsIdent() { return (this.curToken.type !== TokenType.EOF && this.curToken.type !== TokenType.ILLEGAL && this.curToken.type !== TokenType.STRING && // v0.8.0: 分隔标识符由 expectIdentifier 的显式分支处理,不走"关键字当标识符"兜底 this.curToken.type !== TokenType.QUOTED_IDENTIFIER && this.curToken.type !== TokenType.NUMBER && this.curToken.type !== TokenType.COMMA && this.curToken.type !== TokenType.LPAREN && this.curToken.type !== TokenType.RPAREN && this.curToken.type !== TokenType.SEMICOLON && this.curToken.type !== TokenType.EQ && this.curToken.type !== TokenType.NEQ && this.curToken.type !== TokenType.GT && this.curToken.type !== TokenType.GTE && this.curToken.type !== TokenType.LT && this.curToken.type !== TokenType.LTE && this.curToken.type !== TokenType.DOT && this.curToken.type !== TokenType.STAR); } expectNumber(context) { if (this.curToken.type === TokenType.NUMBER) { const val = Number(this.curToken.value); this.nextToken(); return val; } throw this.error(`Expected ${context}, got "${this.curToken.value}"`); } error(msg) { return new DatabaseError(`Parse error at position ${this.curToken.position}: ${msg}`, 'PARSE_ERROR'); } } /** 解析 SQL 字符串为 AST Statement 数组(分号分隔的多语句支持,v0.3.0) */ function parseAll(sql) { const parser = new Parser(sql); return parser.parseAllStatements(); } /** 解析独立 WHERE 条件表达式(CASE WHEN 求值等场景,v0.3.1) */ function parseWhereCondition(sql) { const parser = new Parser(sql); return parser.parseWhere(); } /** * metona-sqlark 列引用取值 —— WHERE / 投影 / 聚合 / 表达式**共用**的唯一实现 * @module query/column-value * * ============================================================================ * 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1) * ============================================================================ * "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同: * * | 位置 | 别名前缀 | 后缀回退 | 未知列 | * |---|---|---|---| * | `projectColumns`(where-matcher) | 否 | 唯一后缀 | 静默丢键 | * | `resolveAliasSource`(executor) | **否** | 否 | 静默 undefined | * | 聚合参数(executor) | 否 | 否 | **静默计 0**(A25) | * | `matchWhere` 的 `resolveField` | 否 | 唯一后缀(多个则 UNRESOLVED) | UNRESOLVED | * * 于是 `COUNT(t.n)` 返回 0(A25)、`SELECT d.id FROM (...) AS d` 返回空集(A36)、 * CASE 的 THEN 分支引用列名时行为随调用点变化(B-4)。 * * 现在四处都调用本模块的 `resolveColumnValue`: * - JOIN 行的键是 `alias.col`,单表行的键是 `col` —— 两种形态都要支持; * - 取不到值时**由调用方**决定是抛错还是返回 UNRESOLVED(`strict` 选项), * 因为"WHERE 里的未解析引用"与"表达式里的未解析引用"需要不同的上层处理。 */ /** * 从行里取一个列引用。 * * 解析顺序(与 ORDER BY 的 `stripAlias` 语义一致,保证同一引用在各子句里等价): * 1. 精确命中(行键与引用完全一致,含 `alias.col` 形态); * 2. 剥离别名前缀(`t.n` → `n`); * 3. 唯一后缀匹配(行键是 `t.n` 而引用写作 `n`); * 4. 以上都不中 → 依 `strict` 抛错或返回 `UNRESOLVED`。 * * 多个后缀命中视为**歧义**(JOIN 里两表同名列),`strict` 下抛错 —— * 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。 */ function resolveColumnValue(row, reference, opts) { let text = reference.trim(); // v0.8.0:分隔标识符(`"col"`)在比较/取值时要脱去引号 —— // 保留引号是为了让投影阶段能区分"名为 1 的列"与"常量 1"(见 parser 说明), // 但比较/取值必须用真实列名。`""` 是引号自身的转义。 if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) { text = text.slice(1, -1).replace(/""/g, '"'); } if (text in row) return row[text]; // 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀 if (text.includes('.')) { const bare = text.split('.').pop(); if (bare in row) return row[bare]; } // 唯一后缀匹配:行键 `t.n` 而引用写作 `n` let found; let hits = 0; for (const key of Object.keys(row)) { if (key.endsWith(`.${text}`)) { found = row[key]; hits += 1; } } if (hits === 1) return found; if (opts.strict) { throw new DatabaseError(hits > 1 ? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables` : `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text }); } return UNRESOLVED; } /** * metona-sqlark 表达式解析与求值 —— CASE WHEN 的结构化实现(v0.8.0 / B-4) * @module query/expression * * ============================================================================ * 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列) * ============================================================================ * 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE: * * ```ts * /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi * ``` * * 它不认字符串字面量、不认嵌套结构,于是实测出三类错误结果: * * | 输入 | 实测(修复前) | 应有 | * |---|---|---| * | `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END` | `"big' END ELSE 'small"` / `null` | `huge`/`big`/`small` | * | `CASE WHEN s='WHEN' THEN 'hit' ELSE 'miss' END` | 依赖切分点,可能错 | `miss/hit/miss` | * * 更严重的是**静默错值**:条件解析失败(如引用不存在的列)时 `cond = null`, * 求值直接跳过该分支 —— 整列变成 ELSE 值,没有任何提示;而同一列名出现在 * WHERE 里会正常报错。同一语义两套行为。 * * ============================================================================ * 本模块的做法 * ============================================================================ * 1. **复用 `sql/lexer` 的 token 流**(带 `position`),不另写一套词法规则 —— * 字符串里的 `WHEN`/`ELSE`、转义引号、注释都由它正确处理, * 因此"切分点"不再可能落在字面量内部。 * 2. **递归下降**解析 CASE(天然支持嵌套),并把每个片段按**源码位置切片**, * 交给既有的 `parseWhereCondition` / 字面量解析器处理 —— * 条件语义与 WHERE 完全同源,不再各写一份。 * 3. **解析失败即报错**(`PARSE_ERROR` / `COLUMN_NOT_FOUND`),不静默降级为 * 某一个分支的值。 * 4. 解析结果**缓存**:`parseCaseExpression` 是纯函数,可安全记忆化; * 聚合与逐行投影会对同一表达式反复求值(N 行 × M 次),缓存把解析开销 * 从 O(行数) 降到 O(1)。 * * 注意:本模块只负责 **CASE** 表达式。普通列引用/字面量/聚合由 * `query/executor` 的 `resolveColumnValue` 与 `parseAggregateExpression` * 处理(B-4 的另一半已由 A25 统一)。 */ // --------------------------------------------------------------------------- // 解析 // --------------------------------------------------------------------------- /** 解析缓存:同一段文本只解析一次(纯函数,可安全记忆化) */ const caseParseCache = new Map(); /** 缓存上限(防御性:避免长生命周期进程里无界增长) */ const CASE_CACHE_LIMIT = 512; /** * 解析 CASE WHEN 表达式文本。 * * @param expr 形如 `CASE WHEN a > 1 THEN 'x' ELSE 'y' END AS band` 的片段 * @returns 结构化表达式;**不是** CASE 表达式时返回 null(调用方据此走其它分支) * @throws DatabaseError 结构不完整(缺 THEN/END)或条件无法解析 */ function parseCaseExpression(expr) { const text = expr.trim(); if (!/^\s*CASE\b/i.test(text)) return null; const cached = caseParseCache.get(text); if (cached) return cached; const parsed = parseCaseExpressionUncached(text); // 先清理再写入:条目数达到上限时整表清空,避免无界增长。 // 用 LRU 会引入额外状态;解析本身是纯函数且调用点集中在少数表达式上, // "清空重建"足够且没有正确性风险。 if (caseParseCache.size >= CASE_CACHE_LIMIT) caseParseCache.clear(); caseParseCache.set(text, parsed); return parsed; } /** * 真正的解析实现。 * * 用 `tokenize` 得到带位置的 token 流后按源码切片 —— 这样"条件/值片段"与 * 原始 SQL 逐字符一致(含引号与转义),可以安全地交给 `parseWhereCondition`。 */ function parseCaseExpressionUncached(text) { const tokens = tokenize(text); let i = 0; const fail = (message, position) => { throw new DatabaseError(`${message} (at offset ${position} in "${text}")`, 'PARSE_ERROR'); }; // CASE if (tokens[i]?.type !== TokenType.CASE) fail('Expected CASE', tokens[i]?.position ?? 0); i++; const branches = []; let elseText = null; /** * 按源码位置取片段。 * * 边界必须用"**下一个 token 的起始位置**"作为右开区间: * STRING token 的 `position` 指向**引号之内**(lexer 的 readString 里 * `start = position + 1`),用 `position + value.length` 之类算术会把 * 结尾引号切掉(实测:`'small'` 被切成 `"small'"`,CASE 全部报 * NOT_SUPPORTED —— 这正是本函数必须存在的理由)。 * 同理 NUMBER/IDENTIFIER 的 position 也由各自 reader 回推,语义不完全统一; * 只有"下一个 token 的起点"是对所有 token 类型都成立的边界。 */ const slice = (startIndex, endIndexExclusive) => { const startTok = tokens[startIndex]; if (!startTok) return ''; const endTok = tokens[endIndexExclusive]; const endPos = endTok ? endTok.position : text.length; return text.slice(startTok.position, endPos).trim(); }; while (i < tokens.length) { const tok = tokens[i]; if (tok.type === TokenType.WHEN) { const condStart = i + 1; // 找与之配对的 THEN:跳过嵌套的括号(CASE 内的子查询/括号表达式) let depth = 0; let thenIndex = -1; for (let j = condStart; j < tokens.length; j++) { const t = tokens[j]; if (t.type === TokenType.LPAREN) depth++; else if (t.type === TokenType.RPAREN) depth--; else if (depth === 0 && t.type === TokenType.THEN) { thenIndex = j; break; } else if (depth === 0 && (t.type === TokenType.ELSE || t.type === TokenType.END)) break; } if (thenIndex < 0) fail('CASE WHEN without matching THEN', tok.position); const valueStart = thenIndex + 1; // 值的结束点:下一个同级 WHEN / ELSE / END let depth2 = 0; let valueEnd = tokens.length; for (let j = valueStart; j < tokens.length; j++) { const t = tokens[j]; if (t.type === TokenType.LPAREN) depth2++; else if (t.type === TokenType.RPAREN) depth2--; else if (depth2 === 0 && (t.type === TokenType.WHEN || t.type === TokenType.ELSE || t.type === TokenType.END)) { valueEnd = j; break; } else if (depth2 === 0 && t.type === TokenType.CASE) { // 嵌套 CASE 作为一个整体:跳到与它配对的 END let nestedDepth = 1; for (let k = j + 1; k < tokens.length; k++) { if (tokens[k].type === TokenType.CASE) nestedDepth++; else if (tokens[k].type === TokenType.END) { nestedDepth--; if (nestedDepth === 0) { j = k; break; } } } } } const conditionText = slice(condStart, thenIndex); const resultText = slice(valueStart, valueEnd); if (!conditionText) fail('CASE WHEN has an empty condition', tok.position); if (!resultText) fail('CASE THEN has an empty result', tokens[valueStart]?.position ?? tok.position); // 条件在**解析期**交给与 WHERE 完全相同的解析器 —— 语义同源, // 且"引用不存在的列"之类问题按 WHERE 的口径处理(不再静默跳过分支)。 let condition; try { condition = parseWhereCondition(conditionText); } catch (error) { throw new DatabaseError(`Invalid CASE WHEN condition "${conditionText}": ${error.message}`, 'PARSE_ERROR', { condition: conditionText }); } branches.push({ conditionText, resultText, condition }); i = valueEnd; continue; } if (tok.type === TokenType.ELSE) { const elseStart = i + 1; let depth = 0; let elseEnd = tokens.length; for (let j = elseStart; j < tokens.length; j++) { const t = tokens[j]; if (t.type === TokenType.LPAREN) depth++; else if (t.type === TokenType.RPAREN) depth--; else if (depth === 0 && t.type === TokenType.END) { elseEnd = j; break; } else if (depth === 0 && t.type === TokenType.CASE) { let nestedDepth = 1; for (let k = j + 1; k < tokens.length; k++) { if (tokens[k].type === TokenType.CASE) nestedDepth++; else if (tokens[k].type === TokenType.END) { nestedDepth--; if (nestedDepth === 0) { j = k; break; } } } } } elseText = slice(elseStart, elseEnd); if (!elseText) fail('CASE ELSE has an empty result', tok.position); i = elseEnd; continue; } if (tok.type === TokenType.END) { i++; break; } fail(`Unexpected token "${tok.value}" in CASE`, tok.position); } if (branches.length === 0) { throw new DatabaseError(`CASE expression has no WHEN branch: "${text}"`, 'PARSE_ERROR'); } // 可选别名:END AS alias / END alias let alias = null; const rest = tokens.slice(i).filter((t) => t.type !== TokenType.EOF && t.type !== TokenType.SEMICOLON); if (rest.length > 0) { const first = rest[0]; if (first.type === TokenType.AS) { const aliasTok = rest[1]; if (aliasTok && (aliasTok.type === TokenType.IDENTIFIER || aliasTok.type === TokenType.QUOTED_IDENTIFIER)) { alias = aliasTok.value; } } else if (first.type === TokenType.IDENTIFIER || first.type === TokenType.QUOTED_IDENTIFIER) { alias = first.value; } } return { branches, elseText, alias, source: text }; } // --------------------------------------------------------------------------- // 求值 // --------------------------------------------------------------------------- /** * 对一行求值 CASE 表达式。 * * 求值顺序即声明顺序:第一个条件为 TRUE 的分支胜出; * 无分支命中时取 ELSE(未写 ELSE 则为 NULL)。 * * 条件用 `matchWhere`(三值逻辑)判定:UNKNOWN **不**算命中 * (与 WHERE 只保留 TRUE 的语义一致)。 */ function evaluateCase(expr, row) { for (const branch of expr.branches) { if (matchWhere(row, branch.condition, { $col: true })) { return evaluateExpressionValue(branch.resultText, row); } } return expr.elseText !== null ? evaluateExpressionValue(expr.elseText, row) : null; } /** * 校验 CASE 条件里引用的列在行源中存在(v0.8.0 / B-4)。 * * 为什么必须有:条件用 `matchWhere` 求值时,**引用不存在的列**只会得到 * UNKNOWN(三值逻辑的正确行为 —— 引擎层拿不到 schema),于是该分支永不命中, * 整列静默变成 ELSE 值。实测修复前: * `CASE WHEN nope > 1 THEN 'x' ELSE 'y' END` → 每行都是 'y',无任何报错; * 而同一个 `nope` 写在 WHERE 里会正常抛 COLUMN_NOT_FOUND。 * 同一语义两套行为,且失败方向是"静默错值"。 * * 与 `assertWhereColumnsExist` 的关系:那条路径校验的是 SQL 的 WHERE 子句, * 它拿得到表名与别名;CASE 出现在 SELECT/GROUP BY/HAVING 里,调用点更分散, * 因此这里做**独立的、可复用的**校验,由调用方在有 schema 时调用。 * * @param available 该作用域内可见的列名集合(含 `alias.col` 形态;JOIN 时是两表并集) */ function assertCaseColumnsExist(expr, available, context) { const missing = []; const checkRef = (ref) => { const text = ref.trim(); if (!text) return; if (available.has(text)) return; const bare = text.includes('.') ? text.split('.').pop() : text; if (available.has(bare)) return; missing.push(text); }; // 条件里的列引用:键位(`n > 1`)与 `$col` 值位(`a = b`) const walkCondition = (cond) => { for (const [key, value] of Object.entries(cond)) { if (key === '$and' || key === '$or') { for (const sub of (Array.isArray(value) ? value : [value])) walkCondition(sub); continue; } if (key === '$not') { walkCondition(value); continue; } if (key === '$exists' || /^\s*CASE\b/i.test(key)) continue; checkRef(key); if (value !== null && typeof value === 'object') { for (const operand of Object.values(value)) { if (operand !== null && typeof operand === 'object' && !Array.isArray(operand) && '$col' in operand) { checkRef(String(operand.$col)); } } } } }; for (const branch of expr.branches) walkCondition(branch.condition); // 结果片段里的列引用(嵌套 CASE 递归) const checkResult = (text) => { const trimmed = text.trim(); if (/^'/.test(trimmed) || /^-?\d/.test(trimmed) || /^(NULL|TRUE|FALSE)$/i.test(trimmed)) return; const nested = parseCaseExpression(trimmed); if (nested) { for (const b of nested.branches) walkCondition(b.condition); for (const b of nested.branches) checkResult(b.resultText); if (nested.elseText) checkResult(nested.elseText); return; } if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed)) checkRef(trimmed); }; for (const branch of expr.branches) checkResult(branch.resultText); if (expr.elseText) checkResult(expr.elseText); if (missing.length > 0) { throw new DatabaseError(`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}` + ` in ${context}`, 'COLUMN_NOT_FOUND', { columns: missing }); } } /** * 求值一个"值表达式"片段(THEN/ELSE 的操作数)。 * * 支持的形态(覆盖既有全部用例,不引入静默降级): * - 字符串字面量(含 `''` 转义)、数字、TRUE/FALSE/NULL; * - 列引用(裸列名或 `表.列`); * - **嵌套 CASE**(递归求值); * - 其它无法识别的文本 → 抛 `NOT_SUPPORTED`,而不是"当字符串返回" * (原实现把无法识别的文本原样返回,于是嵌套 CASE 的残片 * `"big' END ELSE 'small"` 变成了用户可见的返回值)。 */ function evaluateExpressionValue(text, row) { const trimmed = text.trim(); if (trimmed === '') { throw new DatabaseError('Empty expression value', 'PARSE_ERROR'); } // 字符串字面量(SQL 标准 '' 转义) const strLit = trimmed.match(/^'(.*)'$/s); if (strLit) return strLit[1].replace(/''/g, "'"); // NULL / 布尔 if (/^NULL$/i.test(trimmed)) return null; if (/^TRUE$/i.test(trimmed)) return true; if (/^FALSE$/i.test(trimmed)) return false; // 数字常量(含负号与小数) if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); // 嵌套 CASE const nested = parseCaseExpression(trimmed); if (nested) return evaluateCase(nested, row); // 列引用(含 `表.列`)—— 未知列抛 COLUMN_NOT_FOUND(与投影路径同口径) if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed)) { const value = resolveColumnValue(row, trimmed, { strict: false, context: 'CASE result' }); if (isUnresolved(value)) { throw new DatabaseError(`Unknown column "${trimmed}" in expression`, 'COLUMN_NOT_FOUND', { column: trimmed, }); } return value; } throw new DatabaseError(`Unsupported expression in CASE result: "${trimmed}"`, 'NOT_SUPPORTED', { expression: trimmed }); } /** * metona-sqlark Query Executor — AST 执行器 * @module query/executor * * JOIN / GROUP BY / DISTINCT 逻辑在此层处理。 */ // --------------------------------------------------------------------------- // 分组 / 去重键编码(v0.7.4) // --------------------------------------------------------------------------- // 分组 / 去重键 // --------------------------------------------------------------------------- // v0.8.0(B-2):`encodeValueKey` 已删除 —— 它曾是**第四份**值编码实现 // (`sql-compare.ts` 的 `encodeValueKey`、Aria 索引键、COUNT(DISTINCT) 各有其一)。 // 四份编码对 null/undefined 的处理各不相同,于是"同两个值在 GROUP BY 相等、 // 在 DISTINCT 不等"这类跨路径矛盾无法根除。现在统一使用 `encodeValueKey`: // 类型前缀 + 长度前缀,null 与 undefined 同为 SQL NULL(合并), // 且编码不会与数据内容冲突。 // --------------------------------------------------------------------------- // CASE WHEN 表达式 // --------------------------------------------------------------------------- // // v0.8.0(B-4):解析与求值已迁移到 `query/expression.ts`。 // // 为什么必须搬走:原实现用**正则**在 SQL 文本上切分 WHEN/THEN/ELSE,不认字符串 // 字面量与嵌套结构,实测出静默错值: // `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END` // → 返回字符串 "big' END ELSE 'small"(正则把嵌套 CASE 的 ELSE 当成自己的分支 // 边界,残片被原样返回给用户);条件解析失败时还 `cond = null` 静默跳过该 // 分支 → 整列变成 ELSE 值而不报错。 // 新实现复用 `sql/lexer` 的 token 流(带 position)做递归下降,天然支持嵌套与 // 字面量内的关键字,并把条件交给与 WHERE **完全相同**的解析器。 /** * 解析聚合函数表达式 —— **唯一**的聚合识别实现。 * * 匹配形态:`FUNC( [DISTINCT] arg ) [AS alias]`,且整个表达式必须被聚合调用 * 完整覆盖(`COUNT(1) + 1` 之类复合表达式**不**匹配,由调用方另行处理)。 * * 为什么把"输出键"与"参数"一起返回:聚合在 GROUP BY 与单行聚合两条路径上 * 都要用同一个 (函数, 参数, 输出键) 三元组,此前两处各写一个正则、 * 各算一次输出键,是 A25 那类漂移的温床。 */ function parseAggregateExpression(expr) { const m = expr.trim().match(/^(COUNT|SUM|AVG|MIN|MAX)\s*\(\s*([\s\S]+?)\s*\)\s*(?:AS\s+([A-Za-z_][A-Za-z0-9_]*))?$/i); if (!m) return null; const func = m[1].toUpperCase(); let arg = m[2].trim(); const alias = m[3]; const distinct = /^DISTINCT\s+/i.test(arg); if (distinct) arg = arg.replace(/^\s*DISTINCT\s+/i, '').trim(); const exprKey = `${func}(${distinct ? 'DISTINCT ' : ''}${arg})`; return { func, arg, outputKey: alias || expr.trim(), distinct, exprKey }; } /** * 判断 SELECT 列表达式是否为聚合表达式(含别名)。 * 与 `parseAggregateExpression` 共用同一正则 —— 此前 `analyzeSelect` 用 * `/^(COUNT|SUM|AVG|MIN|MAX)\(/` 单独判断,而执行路径用另一条正则: * `COUNT (n)`(函数名与括号间有空格)会被前者判为"有聚合"、被后者判为 * "不是聚合" → 走非聚合分支、输出 null。 */ function isAggregateExpression(expr) { return parseAggregateExpression(expr) !== null; } /** * 从 WHERE/HAVING 条件里提取聚合表达式原文(A23)。 * * 条件里的聚合可能出现在两个位置: * - **键位**:`HAVING SUM(n) > 25` 解析为 `{ 'SUM(n)': { $gt: 25 } }` * —— 表达式在键上,这是 parser 当前的形态; * - **值位**:`HAVING 25 < SUM(n)` 之类的反向写法会把聚合放在操作数里, * 这里一并扫描(`$gt: 'SUM(n)'` 不会出现,但 `{$eq:'SUM(n)'}` 可能出现)。 * * 只做**字符串级**提取(把找到的片段交给 parseAggregateExpression 校验), * 不做表达式求值 —— 求值由调用方在拿到分组行之后进行。 */ function collectAggregateExpressionsInWhere(where) { const found = []; if (!where) return found; const scanText = (text) => { const re = /(?:COUNT|SUM|AVG|MIN|MAX)\s*\([^()]*(?:\([^()]*\)[^()]*)*\)/gi; let m; while ((m = re.exec(text)) !== null) found.push(m[0]); }; const walk = (cond) => { for (const [key, value] of Object.entries(cond)) { if (key === '$and' || key === '$or') { for (const sub of (Array.isArray(value) ? value : [value])) walk(sub); continue; } if (key === '$not') { walk(value); continue; } // 键位:表达式原文作键 scanText(key); // 值位:操作数可能是字符串形态的表达式 if (typeof value === 'string') scanText(value); else if (Array.isArray(value)) { for (const item of value) if (typeof item === 'string') scanText(item); } else if (value !== null && typeof value === 'object') { for (const operand of Object.values(value)) { if (typeof operand === 'string') scanText(operand); } } } }; walk(where); return found; } /** * 列引用 → 输出键:剥离表别名前缀(`t.n` → `n`)。 * * 单表查询的行键不带前缀,而 SELECT 列表可以写 `t.n`;输出键使用裸列名 * 与 `projectRow` 的 `projectColumns` 行为一致(否则 `SELECT t.n FROM t` * 在聚合路径输出 `t.n`、在普通路径输出 `n`,同一查询两种行形状)。 */ function bareReference(reference) { const text = reference.trim(); if (!text.includes('.')) return text; return text.split('.').pop(); } /** * 求值一个 GROUP BY 分组项(v0.8.0 / B-4)。 * * 分组项有两种形态: * - **列引用**(含 `表.列`)→ 走共享的 `resolveColumnValue`; * - **CASE 表达式** → 逐行求值(`GROUP BY CASE WHEN ... END`)。 * * 之所以要这个包装而不是在调用点内联判断:分组键在两处用到 *(建组时逐行、输出分组行时取首行),两处必须用**完全相同**的求值规则, * 否则会再次出现"键相同但输出值不同"的漂移。 */ function resolveGroupKeyValue(row, item) { const caseExpr = parseCaseExpression(item); if (caseExpr) return evaluateCase(caseExpr, row); // 脱引号后再取值:解析层保留引号是为了区分"列 vs 常量",执行期必须用真实列名 //(否则 `GROUP BY "1"` 会在行里产出重复的 `1` 与 `"1"` 两个键 —— 实测) return resolveColumnValue(row, unquoteIdentifier(item), { strict: true, context: 'GROUP BY' }); } /** * v0.8.0(B-5):脱去分隔标识符的引号 —— `"1"` → `1`,`"a""b"` → `a"b`。 * * 为什么 parser 保留引号、这里再脱:parser 必须保留才能区分 * "名为 1 的列"(`"1"`)与"常量 1"(`1`);而一旦进入执行期,列名就是 * schema 里的裸名字。**统一在这一个边界脱引号**,避免"投影用带引号的名字、 * 取值用裸名字"这类两套规则(那正是本项目反复出现的缺陷模式)。 */ function unquoteIdentifier(text) { const trimmed = text.trim(); if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { return trimmed.slice(1, -1).replace(/""/g, '"'); } // `"alias"."col"` 形态:两侧都脱引号 if (trimmed.includes('.')) { const parts = trimmed.split('.'); return parts.map((part) => unquoteIdentifier(part)).join('.'); } return trimmed; } function resolveAliasSource(source, row) { const text = source.trim(); // 字符串常量(含 SQL 标准 '' 转义还原) const strLit = text.match(/^'(.*)'$/s); if (strLit) return strLit[1].replace(/''/g, "'"); // 数字常量(含负号与小数) if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text); if (/^TRUE$/i.test(text)) return true; if (/^FALSE$/i.test(text)) return false; if (/^NULL$/i.test(text)) return null; // 列引用:走统一解析(此前只做 row[text],`t.n` 形态取不到值) return resolveColumnValue(row, text, { strict: false, context: 'SELECT list' }); } /** * v0.8.0: 数值型聚合的单次遍历实现。 * * 为什么替换 `Math.min(...arr)` / `Math.max(...arr)`: * 展开实参会把整个数组压进调用栈,20 万行同组即 RangeError(栈溢出); * 而这是普通查询就能触发的崩溃,不是边界场景。 * * 空集语义(SQL 标准):SUM/AVG/MIN/MAX 对**空集或全 NULL** 返回 NULL。 * 此前统一返回 0,使 `SUM(x) = 0` 与"没有数据"不可区分。 * 注意 COUNT 不在此列 —— COUNT 对空集返回 0(由调用方处理)。 */ function reduceNumeric(values, op) { if (values.length === 0) return null; let acc = op === 'SUM' || op === 'AVG' ? 0 : values[0]; for (let i = 0; i < values.length; i++) { const v = values[i]; switch (op) { case 'SUM': case 'AVG': acc += v; break; case 'MIN': if (i > 0 && v < acc) acc = v; break; case 'MAX': if (i > 0 && v > acc) acc = v; break; } } return op === 'AVG' ? acc / values.length : acc; } // --------------------------------------------------------------------------- // Executor // --------------------------------------------------------------------------- class QueryExecutor { constructor(engine, maxRowsPerQuery = 0) { this.engine = engine; this.maxRowsPerQuery = maxRowsPerQuery; } /** * 执行一条语句。 * * v0.8.0:**同步抛错也必须表现为 rejected promise**。 * * `db.query()` 是 async 的,但 `async` 只把**函数体内**的同步抛错转成 rejection; * 这里 `return this.executeXxx(stmt)` 的调用发生在 async 函数的同步前导段, * 若被调方法在**进入第一个 await 之前**就抛错(例如 INSERT 的 arity 校验、 * VALIDATION_ERROR),异常会穿过 async 边界成为**同步抛出**: * await expect(db.query(...)).rejects.toMatchObject(...) // 断言不生效,测试崩 * db.query(...).catch(...) // 永远不执行 * 对调用方而言这是不可预期的 —— 同一个 API 有的错误走 catch、有的走 try。 * 因此这里显式包一层 try/catch 统一成 rejection(`async` 方法里 `throw` * 一定产出 rejected promise)。 */ async execute(stmt) { try { return await this.dispatch(stmt); } catch (error) { throw error instanceof Error ? error : new DatabaseError(String(error), 'QUERY_ERROR'); } } dispatch(stmt) { switch (stmt.type) { case 'SELECT': return this.executeSelect(stmt); case 'SELECT_UNION': return this.executeSelectUnion(stmt); case 'EXPLAIN': return this.executeExplain(stmt); case 'INSERT': return this.executeInsert(stmt); case 'UPDATE': return this.executeUpdate(stmt); case 'DELETE': return this.executeDelete(stmt); case 'CREATE_TABLE': return this.executeCreateTable(stmt); case 'DROP_TABLE': return this.executeDropTable(stmt); case 'ALTER_TABLE': return this.executeAlterTable(stmt); case 'TRUNCATE_TABLE': return this.executeTruncateTable(stmt); case 'CREATE_INDEX': return this.executeCreateIndex(stmt); case 'DROP_INDEX': return this.executeDropIndex(stmt); case 'BEGIN': return this.executeBegin(); case 'COMMIT': return this.executeCommit(); case 'ROLLBACK': return this.executeRollback(); // v0.5.1: 维护语句 case 'SAVEPOINT': return this.executeSavepoint(stmt); case 'ANALYZE': return this.executeAnalyze(stmt); case 'REINDEX': return this.executeReindex(stmt); case 'VACUUM': return this.executeVacuum(); default: throw new DatabaseError('Unknown statement type', 'UNKNOWN_STATEMENT'); } } // =================================================================== // UNION(v0.3.0) // =================================================================== /** * 递归执行 UNION / UNION ALL,返回合并结果。 * * v0.8.0(A26):尾部 ORDER BY / LIMIT / OFFSET 作用于**整个**复合结果。 * * 此前 `SelectUnionStatement` 没有这三个字段,parser 把它们挂在右侧 SELECT 上 * —— 于是 `A UNION B ORDER BY id DESC` 只排 B(实测返回 1,2,4,3), * `A UNION B LIMIT 3` 只截断 B(实测返回 4 行)。现在由本方法在合并 + 去重 * **之后**统一排序与截断(顺序:合并 → 去重 → 排序 → OFFSET/LIMIT)。 */ async executeSelectUnion(stmt, purpose = 'result') { const leftRows = await this.executeSelectPart(stmt.left, purpose); const rightRows = await this.executeSelectPart(stmt.right, purpose); // 列名以左侧为准,右侧只取值 const leftCols = leftRows.length > 0 ? Object.keys(leftRows[0]) : []; const normalized = leftRows.map((row) => row); let result; if (stmt.all) { for (const row of rightRows) normalized.push(this.projectUnionRow(row, leftCols)); result = normalized; } else { // UNION 去重(与 DISTINCT 相同的列值拼接键) const seen = new Set(); result = []; for (const row of normalized) { const key = Object.values(row).map(encodeValueKey).join('\x1f'); if (!seen.has(key)) { seen.add(key); result.push(row); } } for (const row of rightRows) { const projected = this.projectUnionRow(row, leftCols); const key = Object.values(projected).map(encodeValueKey).join('\x1f'); if (!seen.has(key)) { seen.add(key); result.push(projected); } } } // 尾部子句(作用于整个复合结果)。 // 排序键可能引用**输出列序号**(`ORDER BY 1`)或输出列名 —— 复合结果没有表 // 上下文,因此这里只按结果行的键解析,不做 schema 校验。 if (stmt.orderBy && stmt.orderBy.length > 0) { result = this.orderCompoundResult(result, stmt.orderBy); } const offset = stmt.offset ?? 0; if (offset > 0 || stmt.limit !== undefined) { const limit = stmt.limit ?? result.length; result = result.slice(offset, offset + limit); } return result; } /** * 复合结果排序:把 ORDER BY 项解析为结果行的键。 * * 支持两种写法(与单表 SELECT 一致): * - 输出列序号:`ORDER BY 1` → 第 1 个输出列(SQL 标准,UNION 场景最常见, * 因为各分支的输出列名可能不同); * - 输出列名:`ORDER BY id` → 结果行的 `id` 键。 * 引用不存在的列时返回原序(不静默丢弃排序 —— 排序键缺失本身不改变行集合, * 但会让用户以为已排序;故此处抛 COLUMN_NOT_FOUND,与 SELECT 路径口径一致)。 */ orderCompoundResult(rows, orderBy) { const firstRow = rows[0]; const outputColumns = firstRow ? Object.keys(firstRow) : []; const resolved = orderBy.map((item) => { const asOrdinal = /^\d+$/.test(item.column.trim()) ? Number(item.column.trim()) : null; if (asOrdinal !== null) { if (asOrdinal < 1 || asOrdinal > outputColumns.length) { throw new DatabaseError(`ORDER BY position ${asOrdinal} is out of range: compound query has ${outputColumns.length} output column(s)`, 'QUERY_ERROR'); } return { ...item, column: outputColumns[asOrdinal - 1] }; } // 允许带别名前缀(`ORDER BY t.id` → `id`) const bare = item.column.includes('.') ? item.column.split('.').pop() : item.column; if (outputColumns.length > 0 && !(item.column in firstRow) && !(bare in firstRow)) { throw new DatabaseError(`Unknown column "${item.column}" in ORDER BY of compound query. Output columns: ${outputColumns.join(', ')}`, 'COLUMN_NOT_FOUND'); } return { ...item, column: item.column in firstRow ? item.column : bare }; }); return applyOrderBy(rows, resolved); } /** * 执行一个 SELECT 部件(含 UNION)。 * * @param purpose 透传给 `executeSelect` —— 作为写语句的输入行源时必须传 * `'source'`,否则 `maxRowsPerQuery` 会在写入前静默截断行源(A29)。 */ async executeSelectPart(part, purpose = 'result') { if (part.type === 'SELECT_UNION') return this.executeSelectUnion(part, purpose); return this.executeSelect(part, purpose); } /** 将 UNION 右侧行投影为左侧列结构(按位置取值) */ projectUnionRow(row, leftCols) { if (leftCols.length === 0) return row; const values = Object.values(row); const projected = {}; for (let i = 0; i < leftCols.length; i++) { projected[leftCols[i]] = i < values.length ? values[i] : null; } return projected; } /** EXPLAIN: 输出查询计划 */ async executeExplain(stmt) { const startTime = Date.now(); let result = null; let rows = 0; // v0.6.2-fix: EXPLAIN 不得真实执行写语句 —— 此前 EXPLAIN DELETE/UPDATE 会产生 // 真实副作用(删/改数据)。仅 SELECT 类语句执行(只读);UPDATE/DELETE 用 // count 估算影响行数(无副作用);INSERT/DDL 仅输出计划不执行。 if (stmt.query.type === 'SELECT' || stmt.query.type === 'SELECT_UNION') { try { result = await this.execute(stmt.query); } catch { /* explain 即使执行失败也返回计划 */ } rows = Array.isArray(result) ? result.length : 0; } else if (stmt.query.type === 'UPDATE' || stmt.query.type === 'DELETE') { try { // v0.7.4: 子查询解析后估算 —— 此前 $subquery 未解析使 count 恒 0 await this.resolveWriteWhere(stmt.query); const plan = compileStatement(stmt.query); plan.where = stmt.query.where; rows = await this.engine.count(plan.table, plan); } catch { rows = 0; } } const elapsed = Date.now() - startTime; // v0.5.1: 仅 SELECT/DELETE/UPDATE 有引擎查询计划;其他语句输出基本信息 let plan = null; try { plan = compileStatement(stmt.query); } catch { /* 非查询语句无 QueryPlan */ } // v0.7.0: 真实索引命中信息(此前 usingIndex 恒为 'auto' 占位)。 // 引擎无关启发式:WHERE 中存在主键/索引/唯一列条件 → 对应引擎索引路径。 // v0.7.3: 递归识别 $and 嵌套等值条件(与 Memory/Aria 的 $and 下推行为对齐; // $or/$not 不下推,保持 none)。 let usingIndex = plan?.table ? 'none' : 'none'; if (plan && plan.table && plan.where && Object.keys(plan.where).length > 0) { try { const schema = await this.engine.getTableSchema(plan.table); if (schema) { const findIndex = (w) => { for (const [k, v] of Object.entries(w)) { if (k === '$and') { for (const sub of v) { const hit = findIndex(sub); if (hit) return hit; } continue; } if (k === '$or' || k === '$not') continue; const colDef = schema.columns[k]; if (!colDef) continue; if (colDef.primaryKey) return 'pk'; if (colDef.index || colDef.unique) return `index:${k}`; } return null; }; usingIndex = findIndex(plan.where) ?? 'none'; } } catch { /* schema 读取失败保持 none */ } } return { type: stmt.query.type, table: plan?.table, columns: plan?.columns, where: plan?.where || {}, orderBy: plan?.orderBy || [], limit: plan?.limit, offset: plan?.offset, usingIndex, estimatedRows: rows, actualTimeMs: elapsed, }; } // =================================================================== // SELECT // =================================================================== /** * v0.8.0: SELECT 语句的**执行形态分析**(单一事实来源)。 * * 为什么把它独立出来:core.queryStream 此前在 core.ts 里**自己重新推导**了一遍 * "这条 SELECT 能不能走引擎快路径、列投影怎么算",与 executor 的规则各写一份, * 于是两者漂移出四类静默不一致(实测): * SELECT id AS x FROM t query=[{x}] stream=[{id,v}](全列 + 原列名) * SELECT t.id FROM t query=[{id}] stream=[{}](空对象) * ... LIMIT 2 OFFSET 1 query=1 行 stream=2 行(引擎与 executor 各切一次) * ... LIMIT 0 query=[] stream=1 行 * * 现在由 executor 提供唯一判定,core 只消费结论,不再复制规则。 */ analyzeSelect(stmt) { const hasGroupBy = !!(stmt.groupBy && stmt.groupBy.length > 0); const hasAggregate = !hasGroupBy && this._hasAggregateColumn(stmt.columns); const isJoinQuery = !!(stmt.joins && stmt.joins.length > 0); const needsRawRows = this.hasCaseColumn(stmt.columns) || (!!stmt.where && this.whereHasCase(stmt.where)); const orderByAlias = this.orderByUsesSelectAlias(stmt); const hasSelectAlias = stmt.columns.some((c) => /\s+AS\s+\w+$/i.test(c)); // LIMIT/OFFSET 下推安全性:只有"引擎返回的行 == LIMIT 应当作用其上的行"时才可下推。 // 此前 compileSelect 无条件下推、executor 末尾又切一次 → LIMIT 被应用两遍 // (4 行表上 LIMIT 2 OFFSET 1 只返回 1 行)。 const limitPushdownSafe = !hasGroupBy && !hasAggregate && !isJoinQuery && !stmt.fromSubquery && !stmt.distinct && !(stmt.having && Object.keys(stmt.having).length > 0) && !orderByAlias && !needsRawRows && !hasSelectAlias; // 引擎层投影是否与 executor 语义等价:仅当全部列都是裸 * 或简单列引用 //(可带 table. 前缀)时成立。出现 AS 别名/常量/CASE/聚合就交给 executor 投影。 let engineEquivalentProjection = stmt.columns.length > 0; for (const c of stmt.columns) { if (c === '*') continue; if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(c)) continue; engineEquivalentProjection = false; break; } // 真流式(引擎 findStream 直连)的充分条件:所有会改变行集合/行序/行形状的 // 阶段都不存在,且 WHERE 已解析(无子查询、无 $col 关联引用)。 const streamable = engineEquivalentProjection && !hasGroupBy && !hasAggregate && !isJoinQuery && !stmt.fromSubquery && !stmt.distinct && !(stmt.having && Object.keys(stmt.having).length > 0) && !(stmt.orderBy && stmt.orderBy.length > 0) && !needsRawRows && !orderByAlias && !hasSelectAlias && !this.hasCorrelatedRefs(stmt.where) && !containsUnresolvedSubqueries(stmt.where); return { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe, engineEquivalentProjection, streamable, }; } /** * @param purpose `'result'`(默认)= 结果交付给用户,受 `maxRowsPerQuery` 截断; * `'source'` = 作为写语句的输入行源,**不得**截断(见下方说明)。 */ async executeSelect(stmt, purpose = 'result') { const shape = this.analyzeSelect(stmt); const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape; let rows; // v0.8.0(B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。 // // 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在, // 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期 // 校验会报 Unknown column "n")。这里按**schema** 建可见列集合, // 因此与行形状无关,天然正确。 await this.assertCaseColumnsExist(stmt, isJoinQuery); // v0.8.0(A37):WHERE 里的列引用也必须校验存在性。 // // 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直 // 没有 —— 于是 `$col`(列对列比较)引用到不存在的列时,求值器取不到值 → // 返回 UNRESOLVED → 比较判 UNKNOWN → **所有行被过滤且不报错**。实测修复前: // SELECT id FROM t WHERE id = oops → [] // SELECT id FROM t WHERE id = t.oops → [] // SELECT id FROM t WHERE x = nope → [] // 三条都会静默返回空集(`oops` 在 SQL 里是列引用,因为没有别的字面量形态)。 // 这正是"未解析引用静默变 false"这一整类缺陷(PLAN §3 根因 7)的最后一块。 await this.assertWhereColumnsExist(stmt, isJoinQuery); // v0.8.0(B-5):ORDER BY 的键同样要校验存在性与歧义 —— // JOIN 里两表同名列裸写时(`ORDER BY tag`),此前既不报错也不确定按哪一列排, // 结果取决于行键插入顺序(难查的"顺序偶尔不对")。与 WHERE 同一口径: // 裸名歧义 → COLUMN_NOT_FOUND 并要求限定。 if (stmt.orderBy && stmt.orderBy.length > 0 && !stmt.fromSubquery) { // SELECT 别名(`SELECT n AS num ... ORDER BY num`)是**输出列名**, // 不是行源里的列 —— 必须豁免,否则合法查询会被判未知列(实测)。 // 派生表(`FROM (SELECT ...) AS d`)的列来自子查询投影、不在本层 schema, // 因此整段跳过(与 assertWhereColumnsExist 的守卫一致)。 const selectAliases = this.selectAliasNames(stmt); const orderWhere = {}; for (const item of stmt.orderBy) { const key = unquoteIdentifier(item.column.trim()); if (!key || /^\d+$/.test(key)) continue; // 序号已在 resolveOutputOrdinals 处理 if (parseCaseExpression(item.column)) continue; // CASE 已由 assertCaseColumnsExist 校验 if (selectAliases.has(key)) continue; // 输出别名 orderWhere[key] = { $exists: true }; } if (Object.keys(orderWhere).length > 0) { await this.validateWhereColumns(stmt, orderWhere, { context: 'ORDER BY', rejectAmbiguous: isJoinQuery, }); } } if (stmt.fromSubquery) { // v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源 const subRows = await this.executeSelectPart(stmt.fromSubquery); rows = isJoinQuery ? await this.executeJoinSelect(stmt, subRows.map((row) => this.prefixRow(row, stmt.alias ?? ''))) : subRows; if (!isJoinQuery) { // v0.8.0(A36):派生表行源不带任何别名前缀,因此 `d.id` 形态的引用 // 必须剥离前缀(与非 JOIN 单表路径同一规则)。 // // 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d` // 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`; // 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。 await this.resolveOutputOrdinals(stmt); this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]); if (stmt.where && Object.keys(stmt.where).length > 0) { // 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎) stmt.where = await this.resolveSubqueries(stmt.where); rows = rows.filter((row) => matchWhere(row, stmt.where)); } } } else if (!stmt.from && !isJoinQuery) { // v0.4.0: 无表查询(SELECT 1 / SELECT 'lit')— 单行空上下文,常量列投影 rows = [{}]; } else if (isJoinQuery) { // JOIN 路径:行带表别名前缀(如 'd.id'),WHERE 保持原名不剥离 rows = await this.executeJoinSelect(stmt); } else { // v0.8.0(B-5):先把 ORDER BY / GROUP BY 的输出列序号解析为输出列名。 // 必须在归一化之前:序号 → 列名的映射依赖 SELECT 列表原文 //(`SELECT u.n FROM t u ORDER BY 1` → `u.n`,归一化后再解析会丢前缀)。 await this.resolveOutputOrdinals(stmt); // 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用 const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean); this.normalizeUnprefixedReferences(stmt, mainAliases); // WHERE 含关联子查询($col 引用外层行)→ 逐行绑定上下文求值 if (stmt.where && this.hasCorrelatedRefs(stmt.where)) { const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt); // v0.4.0 修复: 关联子查询需要完整外层行(SELECT 列可能不含被 $col 引用的列,如 EXISTS 绑定的主键) plan.columns = ['*']; if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; } // v0.8.0:引擎层只做**可下推部分**的预过滤,逐行谓词整体交给 filterCorrelated。 // 此前把原始 where(含 `$col`)直接交给引擎,引擎判 UNKNOWN → 候选行 0 → // `WHERE t.x = t.y` 静默空结果(详见 enginePreFilter 文档)。 rows = await this.engine.find(plan.table, { ...plan, where: this.enginePreFilter(stmt.where) }); rows = await this.filterCorrelated(rows, stmt.where, mainAliases); } else { // 先解析子查询 if (stmt.where && Object.keys(stmt.where).length > 0) { stmt.where = await this.resolveSubqueries(stmt.where); } const plan = compileStatement(hasGroupBy || hasAggregate ? { ...stmt, columns: ['*'] } : stmt); if (needsRawRows || hasSelectAlias) plan.columns = ['*']; if (!limitPushdownSafe) { // 不安全:不把 LIMIT/OFFSET 交给引擎,由末尾统一应用(只应用一次) plan.limit = undefined; plan.offset = undefined; } if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; } rows = await this.engine.find(plan.table, plan); } } // 无 GROUP BY 但有聚合 → 计算单行聚合结果 if (hasAggregate) { rows = [this.computeSingleAggregate(rows, stmt)]; } if (hasGroupBy) rows = this.executeGroupBy(rows, stmt); // v0.8.0(A27):DISTINCT 作用于**输出列**,因此必须发生在投影之后。 // // 此前顺序是 DISTINCT → 投影,于是 `SELECT DISTINCT dept AS d FROM e` // 对 `{id, dept, v}` 原始行去重(4 行互不相同)→ 再投影成 `{d}` → 返回 4 行 // `a,a,b,b`;而 `SELECT DISTINCT dept FROM e` 返回 2 行 —— 加一个别名就改变了 // 去重语义。SQL 标准中 DISTINCT 作用于 SELECT 的输出列。 // // 例外(保留投影前位置):`SELECT DISTINCT dept FROM e ORDER BY v` —— `v` // 不在输出列里,去重后它就不存在了,无法再排序。SQL 标准禁止这种写法, // 但既有实现支持它(语义是"排序后按输出列去重"),因此保留: // 此时按 `DISTINCT → ORDER BY` 的顺序执行(见下方分支)。 const distinctBeforeProjection = !!stmt.distinct && this.distinctNeedsPreProjectionSort(stmt); if (distinctBeforeProjection) rows = this.executeDistinct(rows); if (stmt.having && Object.keys(stmt.having).length > 0) { // v0.4.0 修复: HAVING 中的标量子查询(HAVING SUM(o.amount) > (SELECT AVG(...)))需先解析 stmt.having = await this.resolveSubqueries(stmt.having); // v0.4.0: HAVING 引用聚合表达式键(如 SUM(o.amount))时归一为别名键(如 spent) const aliasMap = stmt._aggAliasMap; if (aliasMap && aliasMap.size > 0) { const normalized = {}; for (const [k, v] of Object.entries(stmt.having)) { normalized[aliasMap.get(k) ?? k] = v; } stmt.having = normalized; } rows = rows.filter((row) => matchWhere(row, stmt.having)); } // v0.8.0(A23):分组行的输出投影必须在 HAVING **之后** —— HAVING 可能引用 // 未出现在 SELECT 里的聚合(`SELECT g FROM t GROUP BY g HAVING SUM(n) > 25`), // 那些内部键要到过滤完成后才能丢弃。 if (hasGroupBy) rows = this.projectGroupedRows(rows, stmt); /** * ORDER BY 的应用时机。 * * 排序必须发生在"排序列仍然存在"的阶段,因此分两种情况: * - 排序键全部是**输出列**(或 SELECT 别名,别名在投影后才存在 → 走下方 * 投影后的 `orderByAlias` 分支)→ 投影后再排序(顺序更自然,也让 * `SELECT DISTINCT ... ORDER BY <输出列>` 得到"先去重再排序"的标准语义); * - 排序键引用了**未出现在 SELECT 里的列**(`SELECT name FROM t ORDER BY id`, * 以及 `SELECT DISTINCT dept FROM e ORDER BY v`)→ 必须在投影前排序, * 否则该列已被丢弃、排序无从进行。 * * 此前无条件在投影前排序(`applyOrderBy` 一行),于是 A27 的 DISTINCT * 必然发生在投影前 —— 两者互为因果,必须一起修正。 */ const orderNeedsRawColumns = !!stmt.orderBy && stmt.orderBy.length > 0 && (distinctBeforeProjection || !this.orderByReferencesOutputColumns(stmt)); if (stmt.orderBy && stmt.orderBy.length > 0 && !orderByAlias && orderNeedsRawColumns) { rows = applyOrderBy(rows, stmt.orderBy); } // v0.8.0 根治:投影前校验列引用存在性(此前未知列静默产出 {} 行)。 // // 实测缺陷:`SELECT bogus FROM t`(4 行表)返回 `[{},{},{},{}]`, // `SELECT NAME FROM t`(列名是 name)同样返回 `[{},{}]` —— 行数正确、内容全空、 // 无任何报错;`GROUP BY bogus` 会把全表并成一组,`ORDER BY bogus` 顺序随机。 // SQLite/MySQL 三处都报 "no such column"。 // // 校验时机选在 JOIN/子查询合并完成后(此时是最终行形态),且仅在**未发生聚合**时 // 进行 —— 聚合/分组会把行替换为计算键,普通列本就不存在(那属于另一类语义问题)。 if (!hasAggregate) { this.assertProjectionColumnsExist(rows, stmt.columns, stmt); } // v0.7.3: `SELECT *, col AS alias` —— 此前 columns[0]==='*' 直接不投影, // 别名列/常量列丢失。仅当 '*' 是唯一列时跳过投影(projectRow 对裸 '*' // 合并原行全部列,其余表达式覆盖/追加) if (!hasGroupBy && !hasAggregate && stmt.columns.length > 0 && !(stmt.columns.length === 1 && stmt.columns[0] === '*')) { rows = rows.map((row) => this.projectRow(row, stmt.columns)); } // v0.8.0(A27):DISTINCT 的规范位置 —— 投影之后(作用于输出列) if (stmt.distinct && !distinctBeforeProjection) rows = this.executeDistinct(rows); // v0.3.3: ORDER BY 别名 → 投影后才存在,需在投影后重新排序 if (orderByAlias && stmt.orderBy && stmt.orderBy.length > 0) { rows = applyOrderBy(rows, stmt.orderBy); } // v0.8.0: LIMIT/OFFSET 的应用点,两条路径互斥且**只执行一次**: // - limitPushdownSafe === true → 引擎已按同一 offset/limit 完成切片,此处不再切片; // - limitPushdownSafe === false → 引擎拿不到 limit/offset,此处是唯一应用点。 // 此前两条路径都切了一次,导致 4 行表上 `LIMIT 2 OFFSET 1` 只返回 1 行(应 2,3)。 if (!limitPushdownSafe) { const offset = stmt.offset ?? 0; const limit = stmt.limit ?? rows.length; rows = rows.slice(offset, offset + limit); } // 全局行数上限保护。 // // v0.8.0(A29 根治):只在"结果交付给用户"时截断;作为写语句的输入行源 //(`INSERT INTO dst SELECT ...`)时**不截断** —— 否则用户设置的 // `maxRowsPerQuery` 会静默减少写入行数: // maxRowsPerQuery = 2 时 `INSERT INTO dst SELECT id FROM src`(src 有 4 行) // 会只写入 2 行并返回成功(实测)。这不是"限制查询规模",而是**静默丢数据**。 // 写路径由上层的 `assertWithinRowLimit` 显式报错(而不是静默截断), // 二者配合才能同时满足"保护内存"与"不丢数据"。 if (purpose === 'result' && this.maxRowsPerQuery > 0 && rows.length > this.maxRowsPerQuery) { rows = rows.slice(0, this.maxRowsPerQuery); } return rows; } // ---- JOIN ---- async executeJoinSelect(stmt, preloadedMain) { // v0.8.0(A37):JOIN ON 的列引用也要校验存在性。 // // 修复前 `ON l.k = rn.nope` 这类拼错的列名会走到 matchWhere → $col 取不到值 // → UNKNOWN → **没有任何行能连接上**,用户看到的是空结果而不是"列不存在"。 // 这里与 SELECT 的 WHERE 校验共用同一实现(不同 context 文案与歧义策略)。 for (const join of stmt.joins ?? []) { if (join.on && Object.keys(join.on).length > 0) { await this.validateJoinOnColumns(stmt, join); } } const mainAlias = stmt.alias ?? stmt.from; // v0.4.0: 派生表行源已预加载(行带别名前缀) let mainRows; if (preloadedMain) { mainRows = preloadedMain; } else { // v0.4.1: WHERE 中主表前缀等值条件下推到引擎(走二级索引,如 WHERE o.user_id = '1') const { pushable } = this.extractPushableWhere(stmt.where ?? {}, mainAlias); mainRows = (await this.engine.find(stmt.from, { table: stmt.from, where: Object.keys(pushable).length > 0 ? pushable : undefined, })).map((row) => this.prefixRow(row, mainAlias)); } let resultRows = mainRows; for (const join of stmt.joins) { const joinAlias = join.alias ?? join.table; // v0.3.2: 等值 ON + 右列主键 → 哈希连接(一次 $in 查询替代嵌套循环) const hashJoined = await this.tryHashJoin(resultRows, join, joinAlias, mainAlias); if (hashJoined) { resultRows = hashJoined; continue; } const joinRows = (await this.engine.find(join.table, { table: join.table })) .map((row) => this.prefixRow(row, joinAlias)); resultRows = this.joinRows(resultRows, joinRows, join); } if (stmt.where && Object.keys(stmt.where).length > 0) { // v0.3.1: 关联子查询($col/EXISTS 引用外层行)→ 逐行绑定求值 if (this.hasCorrelatedRefs(stmt.where)) { resultRows = await this.filterCorrelated(resultRows, stmt.where); } else { // 非关联子查询(IN (SELECT ...) 等),字段名保持别名前缀 stmt.where = await this.resolveSubqueries(stmt.where); resultRows = resultRows.filter((row) => matchWhere(row, stmt.where)); } } return resultRows; } prefixRow(row, alias) { const prefixed = {}; for (const [key, value] of Object.entries(row)) prefixed[`${alias}.${key}`] = value; return prefixed; } /** * v0.4.1: 提取可下推的 WHERE 条件 — 主表别名前缀的普通条件(如 o.user_id = '1')。 * 下推到引擎可走二级索引;$col/$subquery/$and/$or/$not 等复杂条件保守不下推。 */ extractPushableWhere(where, mainAlias) { const pushable = {}; if (!mainAlias) return { pushable }; const prefix = `${mainAlias}.`; for (const [key, value] of Object.entries(where)) { if (!key.startsWith(prefix)) continue; const v = value; if (typeof v === 'object' && v !== null && ('$col' in v || '$subquery' in v || '$and' in v || '$or' in v || '$not' in v)) { continue; } pushable[key.slice(prefix.length)] = value; } return { pushable }; } /** * 哈希连接(v0.3.2 单等值 / v0.4.0 多列等值): * ON 为等值条件(单列或多列)且右表任一列为索引/主键时, * 收集左表连接值 → 一次 $in 查询右表 → 哈希映射匹配。 * 替代嵌套循环,大表 INNER/LEFT JOIN 复杂度 O(N + M)。 * 不适用时返回 null(回退嵌套循环)。 */ async tryHashJoin(leftRows, join, joinAlias, mainAlias) { if (join.type === 'CROSS' || join.type === 'RIGHT') return null; // 解析 ON 为 (leftCol, rightCol) 等值对列表(v0.4.0 支持多列,含顶层 $and 展开) const pairs = []; const collectPairs = (on) => { for (const [keyCol, cond] of Object.entries(on)) { if (keyCol === '$and') { if (!cond.every(collectPairs)) return false; continue; } if (keyCol === '$or' || keyCol === '$not') return false; // 非等值逻辑不适用 let refCol = null; if (typeof cond === 'object' && cond !== null) { const c = cond; if ('$eq' in c && typeof c.$eq === 'object' && c.$eq !== null && '$col' in c.$eq) { refCol = String(c.$eq.$col); } else if ('$col' in c && Object.keys(c).length === 1) { refCol = String(c.$col); } } if (!refCol) return false; // 非等值条件不适用哈希连接 const keyIsLeft = mainAlias ? keyCol.startsWith(`${mainAlias}.`) : false; pairs.push({ leftCol: keyIsLeft ? keyCol : refCol, rightCol: keyIsLeft ? refCol : keyCol, }); } return true; }; if (!collectPairs(join.on)) return null; if (pairs.length === 0) return null; // 右表列必须是主键/索引列(确保 $in 走索引)——任一列即可 const schema = await this.engine.getTableSchema(join.table); if (!schema) return null; const probePair = pairs.find((p) => { const bare = p.rightCol.split('.').pop(); const colDef = schema.columns[bare]; return colDef && (colDef.primaryKey || colDef.index || colDef.unique); }); if (!probePair) return null; // 收集左表连接值(去重)——用探测列的值缩小候选集 const probeRightBare = probePair.rightCol.split('.').pop(); const values = Array.from(new Set(leftRows.map((r) => r[probePair.leftCol]).filter((v) => v !== undefined && v !== null))); if (values.length === 0) return null; // 一次 $in 查询右表(缩小候选集) const rightRows = await this.engine.find(join.table, { table: join.table, where: { [probeRightBare]: { $in: values } }, }); // 构建复合键哈希映射:右表多列值 → 行列表 const hash = new Map(); for (const rr of rightRows) { const key = pairs.map((p) => String(rr[p.rightCol.split('.').pop()] ?? '\0')).join('\x1f'); if (!hash.has(key)) hash.set(key, []); hash.get(key).push(rr); } const nullRight = {}; for (const key of Object.keys(schema.columns)) nullRight[key] = null; const result = []; for (const l of leftRows) { const key = pairs.map((p) => String(l[p.leftCol] ?? '\0')).join('\x1f'); const matches = hash.get(key); if (matches && matches.length > 0) { for (const r of matches) { result.push({ ...l, ...this.prefixRow(r, joinAlias) }); } } else if (join.type === 'LEFT') { // LEFT JOIN 无匹配 → 右表列置 null result.push({ ...l, ...this.prefixRow(nullRight, joinAlias) }); } // INNER JOIN 无匹配 → 跳过 } return result; } /** 嵌套循环连接(优化:避免 ON 时对象扩散) */ joinRows(leftRows, rightRows, join) { if (join.type === 'CROSS') { const result = []; for (const l of leftRows) for (const r of rightRows) result.push({ ...l, ...r }); return result; } const result = []; for (const l of leftRows) { let matched = false; for (const r of rightRows) { // 合并后匹配 ON(避免创建临时对象再丢弃) const merged = { ...l, ...r }; if (matchWhere(merged, join.on, { $col: true })) { result.push(merged); matched = true; } } if (!matched && join.type === 'LEFT') { const nullRight = {}; for (const key of Object.keys(rightRows[0] ?? {})) nullRight[key] = null; result.push({ ...l, ...nullRight }); } } if (join.type === 'RIGHT') { for (const r of rightRows) { const isMatched = leftRows.some((l) => { const merged = { ...l, ...r }; return matchWhere(merged, join.on, { $col: true }); }); if (!isMatched) { const nullLeft = {}; for (const key of Object.keys(leftRows[0] ?? {})) nullLeft[key] = null; result.push({ ...nullLeft, ...r }); } } } return result; } // ---- GROUP BY ---- /** * 分组聚合。 * * v0.8.0(A22/A23)两项行为修正,都源于**分组语义只有一个定义**这一原则: * * 1. **GROUP BY 可用 SELECT 别名**(A22)。 * SQL 标准允许 `SELECT g AS grp, COUNT(*) FROM t GROUP BY grp`。 * 此前 `grp` 直接当列名去 `row['grp']` 取到 undefined → 全部行落入同一组; * 更糟的是后续投影阶段发现 `g` 不在输出行里,抛 * `COLUMN_NOT_FOUND Unknown column "g" in SELECT list` —— 用户看到的是 * 一个与真正原因(GROUP BY 引用别名)无关的错误。 * 这里先把 GROUP BY 项解析为**基列**(别名 → 其源表达式),再做分组与输出。 * * 2. **HAVING 可用未出现在 SELECT 里的聚合**(A23)。 * `SELECT g FROM t GROUP BY g HAVING SUM(n) > 25` 是标准写法。 * 此前聚合只在 `stmt.columns` 上计算,`SUM(n)` 从未被求值 → * HAVING 的键 `SUM(n)` 在行里不存在 → matchWhere 取到 undefined → * UNKNOWN → **空结果**(`HAVING SUM(n) > 25` 返回 [],而 MAX(n) > 25 同样 [])。 * 现在把 SELECT 列与 HAVING 里出现的聚合**并集**一起算进分组行,再交给 * HAVING 过滤。多算的聚合只影响中间行,最终输出仍严格按 SELECT 列投影, * 因此不会泄漏额外列。 */ executeGroupBy(rows, stmt) { // 1) GROUP BY 项 → 基列(别名解析,A22) const groupColumns = this.resolveGroupByColumns(stmt); const groups = new Map(); for (const row of rows) { // v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使 // null 与字符串 'null' 合并为一组(GROUP BY 静默少组) // // v0.8.0(B-4):分组项可以是**表达式**(CASE),此时按行求值取分组键 —— // 此前一律当列名去查,于是 `GROUP BY CASE ... END` 报 // `COLUMN_NOT_FOUND Unknown column "CASE WHEN ..."`(实测), // 而"按条件分组"是 SQL 里最常见的分析写法之一。 const key = groupColumns .map((col) => encodeValueKey(resolveGroupKeyValue(row, col))) .join('\x1f'); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(row); } // 2) 需要计算的聚合表达式 = SELECT 列 ∪ HAVING 中的聚合(A23) const aggregateExprs = this.collectAggregateExpressions(stmt); const result = []; // v0.4.0: 聚合表达式键 → 输出键 映射(HAVING SUM(...) 引用表达式时归一为别名键) const aliasMap = new Map(); for (const groupRows of groups.values()) { const first = groupRows[0]; const aggregated = {}; // 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`, // 与 SELECT 列表里 `g AS grp` 的投影可正确对应) for (const col of groupColumns) { // 分组项是 CASE 表达式时,组内该表达式的值恒定(正是分组依据), // 取组内首行求值即可;否则按列名取值。 // // 键名必须与"调用方查它时用的键"一致: // - 别名形式(`CASE ... END AS band`,经 resolveGroupByColumns 解析为原文) // → 用**别名** `band`,因为 projectGroupedRow 按别名索引; // - 裸列 → 用列名。 const caseExpr = parseCaseExpression(col); const key = caseExpr?.alias ?? unquoteIdentifier(col); aggregated[key] = resolveGroupKeyValue(first, col); } // 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入 for (const agg of aggregateExprs) { const value = agg.distinct ? this.computeDistinctAggregate(agg.func, groupRows, agg.arg) : this.computeAggregate(agg.func, groupRows, agg.arg); if (agg.outputKey !== agg.exprKey) aliasMap.set(agg.exprKey, agg.outputKey); // 同时以表达式原文与输出键写入:HAVING 既可能写 `SUM(n)` 也可能写别名 aggregated[agg.exprKey] = value; if (agg.outputKey !== agg.exprKey) aggregated[agg.outputKey] = value; } // 再按 SELECT 列表补齐非聚合列(分组列已写;非分组列取组内首行 — // 这是既有的宽松语义,SQL 标准禁止但此处保持兼容) for (const colExpr of stmt.columns) { if (colExpr === '*') continue; const agg = parseAggregateExpression(colExpr); if (agg) continue; // 已在上面写入 if (/^\s*CASE\b/i.test(colExpr)) { // v0.3.2: 非聚合的 CASE WHEN 列取组内第一行求值 const expr = parseCaseExpression(colExpr); const key = expr?.alias ?? colExpr; if (!(key in aggregated)) { aggregated[key] = expr ? evaluateCase(expr, first) : null; } continue; } const aliasMatch = colExpr.match(/^(.+?)\s+AS\s+(\w+)$/i); if (aliasMatch) continue; // 别名列由投影阶段处理 // 投影阶段用的键:优先"剥离别名前缀后的基列"(SELECT t.n 的输出键是 n) const base = bareReference(colExpr); if (base in aggregated) continue; if (colExpr in aggregated) continue; aggregated[base] = resolveColumnValue(first, colExpr, { strict: true, context: 'SELECT list' }); } // 注意:**不在这里**做输出投影。 // // 为 HAVING 多算的聚合(`SELECT g ... HAVING SUM(n) > 25` 需要 SUM(n)) // 必须在 HAVING 求值时仍然可见,而 HAVING 在 GROUP BY 之后、ORDER BY 之前 // 执行(见 executeSelect 的阶段顺序)。若在此处就把行收缩为 SELECT 输出键, // HAVING 的 `SUM(n)` 会取到 undefined → UNKNOWN → **空结果** // (实测:加投影后 `HAVING SUM(n) > 25` 又变回 [])。 // 因此投影统一由 `projectGroupedRows` 在 HAVING 之后、ORDER BY 之前完成。 result.push(aggregated); } stmt._aggAliasMap = aliasMap; return result; } /** 对全部分组行做输出投影(HAVING 之后调用) */ projectGroupedRows(rows, stmt) { const groupColumns = this.resolveGroupByColumns(stmt); return rows.map((row) => this.projectGroupedRow(row, groupColumns, stmt.columns)); } /** * 把分组行收缩为 SELECT 列表要求的输出列(保持 SELECT 顺序)。 * * GROUP BY 路径不做通用的 `projectRow`(那会丢掉聚合值),因此需要这一层显式投影。 * 处理四类列: * - `*`:保留分组行全部键(`SELECT * ... GROUP BY g` 的既有语义); * - 聚合表达式:键取 `parseAggregateExpression` 的 outputKey; * - `expr AS alias`:输出 alias,值从分组行按 expr 取(含二义性由取值函数处理); * - 裸列引用:输出剥离别名前缀后的基列名。 */ projectGroupedRow(aggregated, groupColumns, columns) { if (columns.some((c) => c === '*')) return aggregated; const output = {}; // 分组列按 SELECT 顺序先输出(它们也是各组的标识列) for (const col of groupColumns) { if (col in aggregated) output[col] = aggregated[col]; } for (const colExpr of columns) { if (colExpr === '*') continue; const agg = parseAggregateExpression(colExpr); if (agg) { // outputKey 与 exprKey 在聚合阶段都被写入,取 outputKey(别名优先) output[agg.outputKey] = aggregated[agg.outputKey]; continue; } const caseExpr = parseCaseExpression(colExpr); if (caseExpr) { const key = caseExpr.alias ?? colExpr; output[key] = aggregated[key]; continue; } const aliasMatch = colExpr.match(/^(.+?)\s+AS\s+(\w+)$/i); if (aliasMatch) { output[aliasMatch[2]] = aggregated[aliasMatch[2]]; continue; } // v0.8.0(B-5):脱引号 —— `SELECT "1" ... GROUP BY "1"` 的输出键必须与 // 行里的真实列名一致,否则会同时出现 `1` 与 `"1"` 两个键(实测)。 const base = unquoteIdentifier(bareReference(colExpr)); output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr]; } return output; } /** * GROUP BY 项解析为基列:`GROUP BY grp`(grp 是 SELECT 别名)→ `g`。 * * 只解析"SELECT 列表里带 AS 别名"与"CASE ... AS 别名"这两种可追踪形态; * 其余原样返回(真正的列名或表达式)。同名歧义时保持原样 —— 后续 * `resolveColumnValue` 的 strict 模式会给出明确的 COLUMN_NOT_FOUND/歧义错误。 */ resolveGroupByColumns(stmt) { const aliases = new Map(); for (const col of stmt.columns) { // v0.8.0(B-4):CASE 的 `AS alias` 也要进别名表 —— // `SELECT CASE ... END AS band ... GROUP BY band` 是最常见的条件分组写法。 const caseExpr = parseCaseExpression(col); if (caseExpr?.alias) { aliases.set(caseExpr.alias, col.trim()); continue; } const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i); if (m) aliases.set(m[2], m[1].trim()); } return stmt.groupBy.map((col) => { const target = aliases.get(col.trim()); if (target === undefined) return col; // 别名指向另一个聚合表达式时不能当作分组列(`SELECT COUNT(*) AS c ... GROUP BY c`) return parseAggregateExpression(target) ? col : target; }); } /** * 收集本次分组需要计算的全部聚合表达式(SELECT 列 ∪ HAVING),按 exprKey 去重。 * * 同时扫描 HAVING 是 A23 的核心:`HAVING SUM(n) > 25` 里的 `SUM(n)` 必须被求值, * 否则 HAVING 阶段取不到该键。 */ collectAggregateExpressions(stmt) { const collected = new Map(); const add = (expr) => { const agg = parseAggregateExpression(expr); if (!agg) return; const existing = collected.get(agg.exprKey); // SELECT 列优先(它带别名);HAVING 只是补充 if (!existing) collected.set(agg.exprKey, agg); }; for (const col of stmt.columns) add(col); for (const expr of collectAggregateExpressionsInWhere(stmt.having)) add(expr); return [...collected.values()]; } /** * 计算单个聚合值。 * * v0.8.0: 返回类型放宽为 unknown —— SUM/AVG/MIN/MAX 对空集返回 null(SQL 标准), * COUNT 仍返回 number。最小/最大改为单次遍历(不再展开实参,消除栈溢出)。 */ computeAggregate(func, rows, arg) { // v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END)) const caseExpr = parseCaseExpression(arg); // v0.4.0: COUNT(DISTINCT col) —— distinct 由 parseAggregateExpression 剥离后传入 const argCol = arg.trim(); // v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。 // 取不到值时按 COUNT(*) 之外的情形报 COLUMN_NOT_FOUND,而不是静默计 0: // 此前 `COUNT(t.n)` 返回 0 且无任何报错,用户会以为"表里没有非空 n"。 const rawValues = rows .map((r) => (caseExpr ? evaluateCase(caseExpr, r) : argCol === '*' ? r : resolveColumnValue(r, argCol, { strict: true, context: `aggregate ${func}(${argCol})` }))) .filter((v) => v !== null && v !== undefined); if (func === 'COUNT') { if (argCol === '*') return rows.length; return rawValues.length; } const nums = rawValues.map((v) => Number(v)); // v0.8.0: 数值型聚合一律走单次遍历归约。 // // 此前 MIN/MAX 用 `Math.min(...distinctNums)` 展开实参:20 万行同组直接 // `RangeError: Maximum call stack size exceeded`(原生错误,调用方无法按 code 分类)。 // 同时把"空集/全 NULL"的返回值从 0 改为 null —— SQL 标准中 SUM/AVG/MIN/MAX // 对空集返回 NULL,返回 0 会让 `SUM(x) = 0` 与"没有数据"不可区分。 switch (func) { case 'SUM': return reduceNumeric(nums, 'SUM'); case 'AVG': return reduceNumeric(nums, 'AVG'); case 'MIN': return reduceNumeric(nums, 'MIN'); case 'MAX': return reduceNumeric(nums, 'MAX'); default: return null; } } /** * DISTINCT 聚合(`COUNT(DISTINCT col)` / `SUM(DISTINCT col)`)。 * * v0.8.0(A25):独立成函数而不是在 computeAggregate 里加分支 —— 去重键 * 必须用 `encodeValueKey`(类型安全),而"对原始值去重"(COUNT)与 * "对数值化后去重"(SUM)用的键不同,混在一个函数里正是此前 * `String(v)` 与 `JSON.stringify(v)` 两套编码并存的原因。 */ computeDistinctAggregate(func, rows, arg) { const values = rows .map((r) => resolveColumnValue(r, arg, { strict: true, context: `aggregate ${func}(DISTINCT ${arg})` })) .filter((v) => v !== null && v !== undefined); if (func === 'COUNT') { return new Set(values.map((v) => encodeValueKey(v))).size; } const nums = values.map((v) => Number(v)); // 去重**用键判定、用原值参与计算**。 // // 不能写成 `Array.from(new Set(nums.map(encodeValueKey))).map(Number)`: // encodeValueKey 是类型前缀编码(`10` → `'n10'`),`Number('n10')` = NaN —— // 实测 `SUM(DISTINCT v)` 返回 NaN(应为 10/20)。 // 键只用于判等,参与归约的必须是原始数值。 const seen = new Set(); const distinctNums = []; for (const n of nums) { const key = encodeValueKey(n); if (seen.has(key)) continue; seen.add(key); distinctNums.push(n); } switch (func) { case 'SUM': return reduceNumeric(distinctNums, 'SUM'); case 'AVG': return reduceNumeric(distinctNums, 'AVG'); case 'MIN': return reduceNumeric(distinctNums, 'MIN'); case 'MAX': return reduceNumeric(distinctNums, 'MAX'); default: return null; } } // ---- DISTINCT(优化:列值拼接代替 JSON.stringify) ---- executeDistinct(rows) { const seen = new Set(); return rows.filter((row) => { // v0.7.4: 类型安全键编码(null 与 'null' 字符串、'\0' 分离) const key = Object.values(row).map(encodeValueKey).join('\x1f'); if (seen.has(key)) return false; seen.add(key); return true; }); } // =================================================================== // 其他语句 // =================================================================== async executeInsert(stmt) { const schema = await this.engine.getTableSchema(stmt.into); if (!schema) throw new DatabaseError(`Table "${stmt.into}" does not exist`, 'TABLE_NOT_FOUND'); const colNames = stmt.columns ?? Object.keys(schema.columns); // v0.8.0(A17): INSERT 的目标列必须存在。 // // 此前**不校验** stmt.columns:`INSERT INTO t (id, nope) VALUES ('1', 2)` 里 // `nope` 在下面的循环中被当作列名写进 row,随后引擎的 validateRow 只遍历 // schema 列 → `nope` 被静默丢弃、INSERT 报成功。用户以为写进去了, // 而 `SELECT nope` 又报 COLUMN_NOT_FOUND —— 写路径与读路径对同一列名给出 // 相反结论。这里显式报错(与读路径同一错误码),并一次列出全部未知列。 const unknownColumns = colNames.filter((col) => !(col in schema.columns)); if (unknownColumns.length > 0) { throw new DatabaseError(`Unknown column${unknownColumns.length > 1 ? 's' : ''} ${unknownColumns .map((c) => `"${c}"`) .join(', ')} in table "${stmt.into}". Known columns: ${Object.keys(schema.columns).join(', ')}`, 'COLUMN_NOT_FOUND'); } // INSERT INTO ... SELECT ...(v0.3.0) if (stmt.select) { // A29:行源不得被 maxRowsPerQuery 截断(截断即静默丢写入行) const selectRows = await this.executeSelectPart(stmt.select, 'source'); // v0.4.0 修复:源列顺序不能依赖行键(validateRow 会跳过 undefined 导致行键缺失/乱序)。 // 以 SELECT 列列表 / 源表 schema 列顺序为准,按位置对齐目标列,缺列不填。 let srcCols = []; const sel = stmt.select; if (sel.type === 'SELECT') { if (sel.columns && sel.columns.length > 0 && sel.columns[0] !== '*') { srcCols = sel.columns.map((c) => c.split('.').pop()); } else if (sel.from) { const srcSchema = await this.engine.getTableSchema(sel.from); srcCols = srcSchema ? Object.keys(srcSchema.columns) : []; } } if (srcCols.length === 0 && selectRows.length > 0) { srcCols = Object.keys(selectRows[0]); } const rows = selectRows.map((row) => { const mapped = {}; for (let i = 0; i < colNames.length; i++) { const src = i < srcCols.length ? srcCols[i] : null; if (src && src in row) mapped[colNames[i]] = row[src]; } return mapped; }); // v0.8.0(B-1): 在任何写入之前执行统一校验(未知列/类型/maxLength/min/max/required) this.assertWithinRowLimit(rows.length); await this.engine.validatePayload?.(stmt.into, rows, 'insert'); return this.engine.insert(stmt.into, rows); } const rows = (stmt.values ?? []).map((vals, tupleIndex) => { // v0.8.0(A30):值的个数不得多于目标列。 // // 此前多出来的值被**静默丢弃**:`INSERT INTO t (id, n) VALUES ('9', 1, 'extra')` // 报成功且只写入 (id,n)。用户以为第三个值进了某一列(或者至少会被提示), // 实际上它消失了 —— 与 A17 同一类"写路径静默丢数据"。反方向的"值少于列" // 是合法的(缺列走 default/NULL),因此只拒绝多于。 if (vals.length > colNames.length) { throw new DatabaseError(`INSERT has ${vals.length} value(s) for ${colNames.length} column(s) in table "${stmt.into}"` + ` (row ${tupleIndex + 1}); too many values`, 'VALIDATION_ERROR', { table: stmt.into, values: vals.length, columns: colNames.length }); } const row = {}; for (let i = 0; i < colNames.length; i++) { if (i < vals.length) row[colNames[i]] = vals[i]; } return row; }); // v0.8.0(B-1): 同上 —— 校验先于任何副作用,多行批量整体判定 this.assertWithinRowLimit(rows.length); await this.engine.validatePayload?.(stmt.into, rows, 'insert'); return this.engine.insert(stmt.into, rows); } /** * v0.8.0(A29):写路径的行数上限保护。 * * `maxRowsPerQuery` 此前只在 SELECT 的返回处生效(`executeSelect` 末尾切片), * 而 `INSERT INTO dst SELECT * FROM huge_src` 的**中间结果集**完全不受约束 —— * 它由 `executeSelectPart` 直接产出并逐行写入,既不切片也不报错。 * 于是"防止一次查询把浏览器内存打满"这一配置项在最容易打满内存的路径上失效。 * * 这里选择**报错**而不是静默截断:静默只写一部分行会让用户以为全部写完 * (又一次"写路径静默丢数据")。错误里给出上限值与来源,便于用户改配置或 * 改写查询。 */ assertWithinRowLimit(count) { if (this.maxRowsPerQuery > 0 && count > this.maxRowsPerQuery) { throw new DatabaseError(`Statement would write ${count} rows, exceeding maxRowsPerQuery (${this.maxRowsPerQuery}).` + ' Narrow the source query or raise the limit.', 'QUERY_ERROR', { rows: count, maxRowsPerQuery: this.maxRowsPerQuery }); } } async executeUpdate(stmt) { // v0.7.4: 先解析 WHERE 子查询 —— 此前直接 compileStatement 调引擎: // 引擎层 matchWhere 的 $in/$nin 遇未解析的 $subquery 对象恒 false → // 所有行不匹配,UPDATE 静默影响 0 行(与 queryStream v0.7.3 修复同类)。 await this.resolveWriteWhere(stmt); const plan = compileStatement(stmt); plan.where = stmt.where; return this.engine.update(plan.table, plan, stmt.sets); } async executeDelete(stmt) { // v0.7.4: 同 executeUpdate —— DELETE 子查询 WHERE 此前静默删除 0 行 await this.resolveWriteWhere(stmt); const plan = compileStatement(stmt); plan.where = stmt.where; return this.engine.delete(plan.table, plan); } /** * v0.7.4: 写语句(UPDATE/DELETE)WHERE 的子查询解析。 * 非关联子查询($subquery)解析为具体值列表/标量; * 关联引用($col / 关联 EXISTS)在写语句中无法逐行绑定外层上下文 * (引擎层 matchWhere 无 $col 绑定选项)→ 显式 NOT_SUPPORTED 而非静默 0 行。 */ async resolveWriteWhere(stmt) { const where = stmt.where; if (!where || Object.keys(where).length === 0) return where ?? {}; if (this.hasCorrelatedRefs(where)) { throw new DatabaseError('Correlated subqueries and column references are not supported in UPDATE/DELETE WHERE clauses', 'NOT_SUPPORTED'); } stmt.where = await this.resolveSubqueries(where); return stmt.where; } async executeCreateTable(stmt) { // IF NOT EXISTS: 表已存在时静默返回 if (stmt.ifNotExists) { const exists = await this.engine.hasTable(stmt.name); if (exists) return; } // v0.7.1: Object.create(null) —— 防止 '__proto__' 列名触发原型 setter 静默丢列 // (createSchema 校验会显式拒绝该列名) const columns = Object.create(null); for (const col of stmt.columns) columns[col.name] = astColumnToColumnDef(col); return this.engine.createTable(createSchema(stmt.name, columns)); } async executeDropTable(stmt) { if (stmt.ifExists) { const exists = await this.engine.hasTable(stmt.name); if (!exists) return; // IF EXISTS: 表不存在时静默返回 } return this.engine.dropTable(stmt.name); } async executeAlterTable(stmt) { const exists = await this.engine.hasTable(stmt.name); if (!exists) throw new DatabaseError(`Table "${stmt.name}" does not exist`, 'TABLE_NOT_FOUND'); const schema = await this.engine.getTableSchema(stmt.name); if (!schema) return; // v0.7.0: ALTER ADD 主键列防护 —— 复合主键不支持(与 createSchema 校验对齐), // 避免绕过建表校验添加第二个主键列导致语义陷阱 if (stmt.action === 'ADD' && stmt.column.primaryKey) { const hasPk = Object.values(schema.columns).some((c) => c.primaryKey); if (hasPk) { throw new DatabaseError(`Composite primary keys are not supported yet: table "${stmt.name}" already has a primary key column`, 'SCHEMA_ERROR'); } } // v0.4.1: 引擎级 alterTable(Aria 需重写存储行 + 持久化 schema;其余引擎走通用引用路径) if (typeof this.engine.alterTable === 'function') { return this.engine.alterTable(stmt.name, stmt.action, { ...astColumnToColumnDef(stmt.column), name: stmt.column.name }); } if (stmt.action === 'ADD') { if (schema.columns[stmt.column.name]) { throw new DatabaseError(`Column "${stmt.column.name}" already exists in table "${stmt.name}"`, 'COLUMN_EXISTS'); } // 直接在 schema 引用上添加列(已有行的该列值为 undefined/default) schema.columns[stmt.column.name] = astColumnToColumnDef(stmt.column); } else if (stmt.action === 'DROP') { if (!schema.columns[stmt.column.name]) { throw new DatabaseError(`Column "${stmt.column.name}" does not exist in table "${stmt.name}"`, 'COLUMN_NOT_FOUND'); } // 从 schema 引用上删除列定义(保留所有行数据) delete schema.columns[stmt.column.name]; // 清除已有行中该列的值(MemoryEngine 的 find 返回引用,delete 直接生效) const rows = await this.engine.find(stmt.name, { table: stmt.name }); const colName = stmt.column.name; for (const row of rows) { if (colName in row) delete row[colName]; } } } async executeTruncateTable(stmt) { const exists = await this.engine.hasTable(stmt.name); if (!exists) throw new DatabaseError(`Table "${stmt.name}" does not exist`, 'TABLE_NOT_FOUND'); return this.engine.clear(stmt.name); } // =================================================================== // CREATE INDEX / DROP INDEX(v0.3.0) // =================================================================== async executeCreateIndex(stmt) { const exists = await this.engine.hasTable(stmt.table); if (!exists) throw new DatabaseError(`Table "${stmt.table}" does not exist`, 'TABLE_NOT_FOUND'); const schema = await this.engine.getTableSchema(stmt.table); if (schema && !schema.columns[stmt.column]) { throw new DatabaseError(`Column "${stmt.column}" does not exist in table "${stmt.table}"`, 'COLUMN_NOT_FOUND'); } if (typeof this.engine.createIndex !== 'function') { throw new DatabaseError(`Engine "${this.engine.name}" does not support CREATE INDEX`, 'NOT_SUPPORTED'); } return this.engine.createIndex(stmt.table, stmt.column, stmt.unique); } async executeDropIndex(stmt) { if (typeof this.engine.dropIndex !== 'function') { throw new DatabaseError(`Engine "${this.engine.name}" does not support DROP INDEX`, 'NOT_SUPPORTED'); } return this.engine.dropIndex(stmt.table, stmt.column, stmt.name); } // =================================================================== // 事务语句(v0.3.0) // =================================================================== async executeBegin() { return this.engine.beginTransaction(); } async executeCommit() { return this.engine.commitTransaction(); } async executeRollback() { return this.engine.rollbackTransaction(); } // =================================================================== // 维护语句(v0.5.1) // =================================================================== /** SAVEPOINT name / ROLLBACK TO SAVEPOINT name / RELEASE SAVEPOINT name */ async executeSavepoint(stmt) { const engine = this.engine; if (stmt.action === 'SAVE') { if (typeof engine.savepoint !== 'function') { throw new DatabaseError(`Engine "${this.engine.name}" does not support SAVEPOINT`, 'NOT_SUPPORTED'); } return engine.savepoint(stmt.name); } if (stmt.action === 'ROLLBACK') { if (typeof engine.rollbackToSavepoint !== 'function') { throw new DatabaseError(`Engine "${this.engine.name}" does not support ROLLBACK TO SAVEPOINT`, 'NOT_SUPPORTED'); } return engine.rollbackToSavepoint(stmt.name); } if (typeof engine.releaseSavepoint !== 'function') { throw new DatabaseError(`Engine "${this.engine.name}" does not support RELEASE SAVEPOINT`, 'NOT_SUPPORTED'); } return engine.releaseSavepoint(stmt.name); } /** ANALYZE TABLE name — 收集表统计信息 */ async executeAnalyze(stmt) { const engine = this.engine; if (typeof engine.analyzeTable !== 'function') { throw new DatabaseError(`Engine "${this.engine.name}" does not support ANALYZE`, 'NOT_SUPPORTED'); } const exists = await this.engine.hasTable(stmt.table); if (!exists) throw new DatabaseError(`Table "${stmt.table}" does not exist`, 'TABLE_NOT_FOUND'); return engine.analyzeTable(stmt.table); } /** REINDEX TABLE name — 重建表二级索引 */ async executeReindex(stmt) { const engine = this.engine; if (typeof engine.reindexTable !== 'function') { throw new DatabaseError(`Engine "${this.engine.name}" does not support REINDEX`, 'NOT_SUPPORTED'); } const exists = await this.engine.hasTable(stmt.table); if (!exists) throw new DatabaseError(`Table "${stmt.table}" does not exist`, 'TABLE_NOT_FOUND'); return engine.reindexTable(stmt.table); } /** VACUUM — 压缩 LSM + 清理碎片 */ async executeVacuum() { const engine = this.engine; if (typeof engine.vacuum !== 'function') { throw new DatabaseError(`Engine "${this.engine.name}" does not support VACUUM`, 'NOT_SUPPORTED'); } return engine.vacuum(); } /** * SELECT 列表的**输出列名**集合(投影后行里会出现的键)。 * * 与 `projectRow` 的键规则保持一致: * - `*` → 未知(返回 `null` 表示"无法判定",调用方按"包含"处理,避免误判需要原始列); * - `expr AS alias` → `alias`; * - 聚合 `FUNC(arg) [AS alias]` → `alias` 或表达式原文; * - CASE `... AS alias` → `alias`; * - 字符串/数字常量列 → 表达式原文; * - 裸列引用 → 剥离别名前缀后的列名。 */ outputColumnNames(stmt) { const names = new Set(); for (const col of stmt.columns) { if (col === '*') return null; const agg = parseAggregateExpression(col); if (agg) { names.add(agg.outputKey); continue; } const caseExpr = parseCaseExpression(col); if (caseExpr) { names.add(caseExpr.alias ?? col); continue; } const aliasMatch = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i); if (aliasMatch) { names.add(aliasMatch[2]); continue; } names.add(bareReference(col)); } return names; } /** ORDER BY 的键是否全部能在**输出列**里找到(决定排序发生在投影前还是投影后) */ orderByReferencesOutputColumns(stmt) { if (!stmt.orderBy || stmt.orderBy.length === 0) return true; const outputs = this.outputColumnNames(stmt); if (outputs === null) return true; // SELECT * return stmt.orderBy.every((o) => outputs.has(bareReference(o.column))); } /** * DISTINCT 是否必须在投影**前**执行。 * * 仅当 ORDER BY 引用了不在输出列里的列时成立:`SELECT DISTINCT dept FROM e ORDER BY v` * 需要先按 `v` 排序、再按输出列 `dept` 去重。若把 DISTINCT 放到投影后, * `v` 已被丢弃,排序无从进行(会报 COLUMN_NOT_FOUND)。 * * SQL 标准禁止这种写法;此处保留既有语义(排序后去重),并把该例外显式记录, * 而不是让 DISTINCT 的位置在所有情况下都"碰巧"由排序决定。 */ distinctNeedsPreProjectionSort(stmt) { if (!stmt.orderBy || stmt.orderBy.length === 0) return false; return !this.orderByReferencesOutputColumns(stmt); } /** * v0.8.0(A37):校验 JOIN ON 里引用的列在**参与连接的两张表**之一存在。 * * `ON a.x = b.y` 的 `a.x` 属于主表或已有 JOIN 表,`b.y` 属于当前 JOIN 表 —— * 两侧都要能找到归属;否则报 COLUMN_NOT_FOUND(而不是让连接静默产生空结果)。 * 裸列名只要求"某一侧存在"(`ON k = k` 的既有语义是取主表列)。 */ async validateJoinOnColumns(stmt, join) { const available = new Set(); const addTable = async (table, alias) => { const schema = await this.engine.getTableSchema(table); if (!schema) return; for (const col of Object.keys(schema.columns)) { available.add(col); available.add(`${alias}.${col}`); } }; await addTable(stmt.from, stmt.alias ?? stmt.from); for (const other of stmt.joins ?? []) { await addTable(other.table, other.alias ?? other.table); if (other === join) break; } await addTable(join.table, join.alias ?? join.table); const missing = []; const check = (ref) => { const text = ref.trim(); if (!text) return; if (available.has(text)) return; const bare = text.includes('.') ? text.split('.').pop() : text; if (available.has(bare)) return; missing.push(text); }; const walkOperand = (value) => { if (typeof value !== 'object' || value === null) return; for (const [op, operand] of Object.entries(value)) { if (op === '$col') { check(String(operand)); continue; } if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) walkOperand(operand); } }; for (const [key, value] of Object.entries(join.on)) { if (key === '$and' || key === '$or' || key === '$not') continue; check(key); walkOperand(value); } if (missing.length > 0) { throw new DatabaseError(`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}` + ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from }); } } /** * v0.8.0(B-4):校验 SELECT / GROUP BY / HAVING / ORDER BY 里 CASE 表达式引用的列存在。 * * 为什么单独一个方法:CASE 可以出现在四个子句里,而每个子句的校验时机不同 * (WHERE 有 `assertWhereColumnsExist`,投影有 `assertProjectionColumnsExist`)。 * 统一在这里按 **schema** 收集可见列,与行形状解耦,避免"分组后校验不到源列"。 */ async assertCaseColumnsExist(stmt, isJoinQuery) { if (stmt.fromSubquery) return; // 派生表列来自子查询投影,需另行解析 if (!stmt.from) return; const available = new Set(); const addTable = async (table, alias) => { const schema = await this.engine.getTableSchema(table); if (!schema) return; for (const col of Object.keys(schema.columns)) { available.add(col); available.add(`${alias}.${col}`); } }; await addTable(stmt.from, stmt.alias ?? stmt.from); if (isJoinQuery) { for (const join of stmt.joins ?? []) await addTable(join.table, join.alias ?? join.table); } const check = (text, context) => { const caseExpr = parseCaseExpression(text); if (caseExpr) assertCaseColumnsExist(caseExpr, available, context); }; for (const col of stmt.columns) check(col, `CASE expression "${col.trim()}"`); for (const groupCol of stmt.groupBy ?? []) check(groupCol, `GROUP BY "${groupCol}"`); for (const order of stmt.orderBy ?? []) check(order.column, `ORDER BY "${order.column}"`); // HAVING 的 CASE 出现在键位(`HAVING CASE ... END = 1`) for (const key of Object.keys(stmt.having ?? {})) check(key, `HAVING "${key}"`); } /** * v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。 * * 覆盖两类引用: * - **键位**:`WHERE x = 1` 的 `x`; * - **`$col` 值位**:`WHERE x = y` 的 `y`(parser 生成 `{ x: { $eq: { $col: 'y' } } }`), * 含 `$and`/`$or`/`$not` 内部的操作数槽。 * * 派生表与子查询的行源:跳过(其列来自子查询投影,需要单独解析; * 由 `normalizeUnprefixedReferences` + 运行时行形状决定)。 * JOIN:两侧的表别名都要参与判定 —— `t.x` 与另一表的 `u.x` 都属于合法引用。 * * 之所以必须是**显式错误**而不是依赖运行时 UNKNOWN:`$col` 取不到值时 * 三值求值器只能返回 UNKNOWN,而 UNKNOWN 在 WHERE 里表现为"不保留该行" —— * 用户看到的是"没有匹配数据",与"列名拼错"完全无法区分。 */ async assertWhereColumnsExist(stmt, isJoinQuery) { if (!stmt.where || Object.keys(stmt.where).length === 0) return; // 派生表行源:列来自子查询投影,此处不做 schema 校验 if (stmt.fromSubquery) return; if (!stmt.from) return; await this.validateWhereColumns(stmt, stmt.where, { context: 'WHERE', rejectAmbiguous: isJoinQuery, }); } /** * 通用的 WHERE 侧列引用校验(SELECT 的 WHERE 与 JOIN 的 ON 共用)。 * * @param context 错误消息中的位置描述('WHERE' / 'JOIN ON') * @param rejectAmbiguous JOIN 场景下裸列名被多表共有 → 报歧义(SQL 标准要求限定)。 * **JOIN ON 传 false**:`ON a.x = b.x` 里两侧的限定名各自 * 合法,而 `ON k = k` 这种裸写法在 JOIN 语境下按"取主表列" * 解释(与既有 joinRows 行为一致),不应因为"两表都有 k" * 就拒绝 —— 那会把常见的等值连接写法判为错误。 */ async validateWhereColumns(stmt, where, opts) { const isJoinQuery = opts.rejectAmbiguous; const available = new Set(); const owners = new Map(); // 裸列名 → 表别名(用于歧义判定) const collectFrom = async (table, alias) => { const schema = await this.engine.getTableSchema(table); if (!schema) return; for (const col of Object.keys(schema.columns)) { available.add(col); available.add(`${alias}.${col}`); const prev = owners.get(col); if (prev === undefined) owners.set(col, alias); else if (prev !== alias) owners.set(col, '*'); // 多表共有 → 歧义 } }; await collectFrom(stmt.from, stmt.alias ?? stmt.from); if (isJoinQuery) { for (const join of stmt.joins ?? []) { await collectFrom(join.table, join.alias ?? join.table); } } const missing = []; const ambiguous = []; const check = (ref) => { // v0.8.0(B-5):校验用**真实列名** —— 分隔标识符(`"1"`)在 WHERE 里 // 同样是列引用,必须脱引号后再比对(此前会报 `Unknown column "\"1\""` // —— 错误消息里带着引号,用户看不出问题在哪)。 const text = unquoteIdentifier(ref); if (!text) return; if (available.has(text)) { // 裸列名在 JOIN 中若被多张表共有 → 歧义(SQL 标准要求限定) if (!text.includes('.') && isJoinQuery && owners.get(text) === '*') ambiguous.push(text); return; } // 唯一后缀匹配(行键可能带别名,而 where 写作裸列名;反向亦然) const bare = text.includes('.') ? text.split('.').pop() : text; if (available.has(bare)) return; missing.push(text); }; const walkOperand = (value) => { if (typeof value !== 'object' || value === null) return; const ops = value; for (const [op, operand] of Object.entries(ops)) { if (op === '$col') { check(String(operand)); continue; } if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) { walkOperand(operand); } else if (Array.isArray(operand)) { for (const item of operand) walkOperand(item); } } }; const walkWhere = (cond) => { for (const [key, value] of Object.entries(cond)) { if (key === '$and' || key === '$or') { for (const sub of (Array.isArray(value) ? value : [value])) walkWhere(sub); continue; } if (key === '$not') { walkWhere(value); continue; } if (key === '$exists') continue; // 由子查询执行结果填充 if (/^\s*CASE\b/i.test(key)) continue; // CASE 键内含表达式,另行求值 check(key); walkOperand(value); } }; walkWhere(where); if (ambiguous.length > 0) { throw new DatabaseError(`Ambiguous column${ambiguous.length > 1 ? 's' : ''} ${ambiguous.map((c) => `"${c}"`).join(', ')}` + ` in ${opts.context}: present in multiple joined tables. Qualify with a table alias.`, 'COLUMN_NOT_FOUND', { columns: ambiguous }); } if (missing.length > 0) { throw new DatabaseError(`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}` + ` in ${opts.context} of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from }); } } /** * 列列表是否包含 CASE WHEN 表达式 */ hasCaseColumn(columns) { return columns.some((col) => /^\s*CASE\b/i.test(col)); } /** * v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。 * 别名列在引擎层投影前不存在,需投影后重新排序。 */ /** * v0.8.0(B-5):SELECT 列表产出的**别名集合**(`AS x` 与 `CASE ... AS x`)。 * * 与 `orderByUsesSelectAlias` 共用同一套识别规则 —— 两处若各写一份, * 会出现"排序认为它是别名、校验认为它是列"的矛盾(本项目反复出现的漂移模式)。 */ selectAliasNames(stmt) { const aliases = new Set(); for (const col of stmt.columns) { const caseExpr = parseCaseExpression(col); if (caseExpr?.alias) { aliases.add(caseExpr.alias); continue; } const m = col.match(/\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/i); if (m) aliases.add(m[1]); } return aliases; } orderByUsesSelectAlias(stmt) { if (!stmt.orderBy || stmt.orderBy.length === 0) return false; const aliases = this.selectAliasNames(stmt); if (aliases.size === 0) return false; return stmt.orderBy.some((o) => aliases.has(unquoteIdentifier(o.column))); } /** WHERE 是否包含 CASE WHEN 表达式键 */ whereHasCase(where) { for (const [key, value] of Object.entries(where)) { if (key === '$and' || key === '$or') { if (value.some((sub) => this.whereHasCase(sub))) return true; continue; } if (key === '$not') { if (this.whereHasCase(value)) return true; continue; } if (/^\s*CASE\b/i.test(key)) return true; } return false; } getEngine() { return this.engine; } /** * v0.8.0: 校验 SELECT 列表中的**裸列引用**在结果行里确实存在,否则抛 COLUMN_NOT_FOUND。 * * 为什么必须做:`SELECT bogus FROM t` 此前返回 `[{},{},...]`(行数对、内容空、无报错), * 这是"静默错误结果"里最难被发现的一类 —— 调用方拿到的是结构正确但全空的表格。 * * 判定规则(与 projectRow 的分类保持一致): * - `*` 跳过; * - 字符串/数字常量列跳过; * - CASE 表达式跳过(其内部列引用由 evaluateCase 处理); * - `expr AS alias`:字符串/数字常量跳过,否则取 `expr` 作为被引用列; * - 其余视为裸列引用。 * 存在性检查允许两种形态:精确匹配,或**唯一**以 `.` 结尾(JOIN 行以 `alias.col` 为键)。 * 若同一个后缀出现在多个表别名下则视为歧义,同样报错(符合"未限定列名歧义应报错"的语义)。 * * 结果集为空时无法判定,此时跳过(空表 + 未知列不会误报)。 */ assertProjectionColumnsExist(rows, columns, stmt) { if (rows.length === 0 || columns.length === 0) return; const available = new Set(); for (const row of rows) { for (const key of Object.keys(row)) available.add(key); } for (const raw of columns) { const col = raw.trim(); if (col === '*') continue; // CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在 // **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被 // 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。 if (parseCaseExpression(col)) continue; let reference = col; const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i); if (aliasMatch) reference = aliasMatch[1].trim(); // v0.8.0(B-5):校验用的是**真实列名**,因此这里脱去分隔标识符的引号 //(`"1"` → `1`);保留引号只在投影期用于区分"列 vs 常量"。 reference = unquoteIdentifier(reference); // 常量列(字符串 / 数字 / 布尔 / NULL) if (/^'.*'$/s.test(reference)) continue; if (/^-?\d+(\.\d+)?$/.test(reference)) continue; if (/^(TRUE|FALSE|NULL)$/i.test(reference)) continue; // 聚合表达式(在 hasAggregate 分支已跳过,这里再兜一层防御) if (/^(COUNT|SUM|AVG|MIN|MAX)\s*\(/i.test(reference)) continue; if (available.has(reference)) continue; const suffixMatches = []; for (const key of available) { if (key.endsWith(`.${reference}`)) suffixMatches.push(key); } if (suffixMatches.length === 1) continue; if (suffixMatches.length > 1) { const owners = suffixMatches.map((k) => k.slice(0, k.length - reference.length - 1)).sort(); throw new DatabaseError(`Ambiguous column "${reference}" in SELECT list: present in ${owners.join(', ')}`, 'COLUMN_NOT_FOUND', { column: reference, tables: owners, from: stmt.from }); } throw new DatabaseError(`Unknown column "${reference}" in SELECT list`, 'COLUMN_NOT_FOUND', { column: reference, from: stmt.from, available: [...available].slice(0, 32) }); } } /** * 列投影(v0.3.1):普通列走 projectColumns,CASE WHEN 表达式逐行求值; * v0.3.3: 支持 `col AS alias` 列别名 */ projectRow(row, columns) { const plain = []; const aliasCols = []; const caseCols = []; const constCols = []; // v0.7.3: 裸 '*' 与列表达式混合(SELECT *, name AS nick)→ 原行全部列为基 let hasStar = false; for (const col of columns) { if (col === '*') { hasStar = true; continue; } const expr = parseCaseExpression(col); if (expr) { caseCols.push({ alias: expr.alias ?? col, expr }); continue; } const m = col.match(/^(.+?)\s+AS\s+(\w+)$/i); if (m) { aliasCols.push({ alias: m[2], source: m[1].trim() }); continue; } // v0.4.0: 字符串常量列 SELECT 'lit' → 常量输出 const lit = col.match(/^'(.*)'$/s); if (lit) { // v0.7.3: SQL 标准 '' 转义还原(readString 已把 '' 合并为单个 ', // 打包回列的文本中相邻两个 ' 即一个引号字面量) const value = lit[1].replace(/''/g, "'"); constCols.push({ key: col, value }); continue; } // v0.8.0: 匿名常量列(数字/布尔/NULL)—— `SELECT 1 FROM t` 此前投影成 {} // (键 '1' 在、值为 undefined,JSON 序列化后键消失)。SQLite/MySQL 用 // 表达式原文作列名,这里保持一致。 if (/^-?\d+(\.\d+)?$/.test(col) || /^(TRUE|FALSE|NULL)$/i.test(col)) { constCols.push({ key: col, value: resolveAliasSource(col, row) }); continue; } // v0.8.0(B-5):分隔标识符(`"1"`)是**列引用**,不是常量 —— 在这里脱引号 // 落到 plain 分支走正常列投影(输出键即裸列名,与 `SELECT *` 一致)。 // 脱引号必须在本函数内完成:更早脱会让 `"1"` 被上面的裸数字常量子句吃掉 // (实测 `SELECT "1" FROM q` 返回 `{"1":1}` —— 常量 1 而非列值)。 const unquoted = unquoteIdentifier(col); if (unquoted !== col) { plain.push(unquoted); continue; } plain.push(col); } // v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列) const projected = hasStar ? { ...row } : (plain.length > 0 ? projectColumns(row, plain) : {}); for (const { alias, source } of aliasCols) { if (source === '*') { Object.assign(projected, row); } else { projected[alias] = resolveAliasSource(source, row); } } for (const { key, value } of constCols) { projected[key] = value; } for (const { alias, expr } of caseCols) { projected[alias] = evaluateCase(expr, row); } return projected; } // =================================================================== // 无 GROUP BY 时的聚合计算 // =================================================================== /** 检查 SELECT 列列表中是否包含聚合函数(与执行路径共用同一解析器) */ _hasAggregateColumn(columns) { return columns.some((col) => isAggregateExpression(col)); } /** * 计算单行聚合结果(无 GROUP BY)。 * * v0.8.0(A25):聚合识别与取值改为与 GROUP BY 路径**共用** * `parseAggregateExpression` / `resolveColumnValue` —— 此前这里有第二份正则, * 于是 `COUNT (n)`(函数名后有空格)在"是否聚合"判定与"如何求值"两处结论不同。 */ computeSingleAggregate(rows, stmt) { const result = {}; for (const colExpr of stmt.columns) { if (colExpr === '*') continue; const agg = parseAggregateExpression(colExpr); if (agg) { result[agg.outputKey] = agg.distinct ? this.computeDistinctAggregate(agg.func, rows, agg.arg) : this.computeAggregate(agg.func, rows, agg.arg); continue; } // 非聚合列取第一行的值(严格取值:未知列报错而非静默 null) result[colExpr] = rows.length > 0 ? resolveColumnValue(rows[0], colExpr, { strict: false, context: 'SELECT list' }) : null; } return result; } // =================================================================== // 关联子查询 / 别名规范化(v0.3.0) // =================================================================== /** * v0.8.0(B-5):把 ORDER BY / GROUP BY 里的**输出列序号**解析为输出列名。 * * SQL 标准允许 `ORDER BY 1` / `GROUP BY 2` 按输出列位置引用(UNION 各分支 * 列名可能不同,只能按序号引用)。规则: * 1. 若存在**同名的真实列**(如列名就是 `"1"`,用双引号建表),按列名优先 —— * 显式标识符胜过位置简写; * 2. 纯数字 → 第 N 个输出列的**表达式原文**(`SELECT n AS num ... ORDER BY 1` * 解析为 `num`,因为投影后行里只有 `num`); * 3. 序号越界 → `QUERY_ERROR`(不说"未知列",因为问题出在位置而不是名字); * 4. `GROUP BY <序号>` 指向聚合表达式 → `QUERY_ERROR` * (按聚合值分组语义上不成立,SQL 标准同样禁止)。 */ async resolveOutputOrdinals(stmt) { const outputs = stmt.columns; // 真实列名集合(用于优先级判定)—— 只在确有"纯数字键"时才去取 schema let realColumns = null; const loadRealColumns = async () => { if (realColumns) return realColumns; const names = new Set(); const addTable = async (table) => { const schema = await this.engine.getTableSchema(table); if (!schema) return; for (const col of Object.keys(schema.columns)) names.add(col); }; if (stmt.from) await addTable(stmt.from); for (const join of stmt.joins ?? []) await addTable(join.table); realColumns = names; return names; }; /** * 判断一个纯数字键是否应当**按列名**解释(而不是按输出列位置)。 * * 规则(与 SQLite / SQL 标准的名称解析一致): * 1. SELECT 列表里有同名的**输出列**(裸名或别名)→ 列名引用; * 2. 行源里存在同名**真实列**,且该列在 SELECT 列表里以**未加引号的同名** * 形式出现 → 列名引用; * 3. 其余情况(含"行源有名为 `"1"` 的列,但查询里写的是裸 `1`")→ 位置序号。 * * 第 3 条是关键:`"1"`(引号标识符)与 `1`(数字字面量)在 SQL 里是**不同的 * 东西**。此前把未加引号的 `1` 拿去 schema 里查"是否存在列 1",于是 * `SELECT other FROM q ORDER BY 1 DESC` 因 q 恰好有名为 `"1"` 的列而被当成 * 普通列排序 —— **DESC 丢失**(实测)。想按那一列排序必须写 `ORDER BY "1"`。 */ const hasRealColumn = async (key) => { const trimmed = key.trim(); if (outputs.some((o) => bareReference(o) === trimmed || new RegExp(`\\bAS\\s+${trimmed}$`, 'i').test(o))) { return true; } const columns = await loadRealColumns(); if (!columns.has(trimmed)) return false; // 只有"查询里以裸名形式引用了该列"才算列名引用 return outputs.some((o) => o === trimmed); }; /** 序号 → 输出列名(投影后行里的键) */ const outputKeyAt = (position, context) => { if (position < 1 || position > outputs.length) { throw new DatabaseError(`${context} position ${position} is out of range: query has ${outputs.length} output column(s)`, 'QUERY_ERROR', { position, outputs: outputs.length }); } const raw = outputs[position - 1]; const caseExpr = parseCaseExpression(raw); if (caseExpr) return caseExpr.alias ?? raw; const agg = parseAggregateExpression(raw); if (agg) return agg.outputKey; const aliasMatch = raw.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i); if (aliasMatch) return aliasMatch[2]; return bareReference(raw); }; if (stmt.orderBy) { const resolved = []; for (const item of stmt.orderBy) { if (!/^\d+$/.test(item.column.trim()) || await hasRealColumn(item.column)) { resolved.push(item); continue; } resolved.push({ ...item, column: outputKeyAt(Number(item.column.trim()), 'ORDER BY') }); } stmt.orderBy = resolved; } if (stmt.groupBy) { const resolved = []; for (const key of stmt.groupBy) { if (!/^\d+$/.test(key.trim()) || await hasRealColumn(key)) { resolved.push(key); continue; } const position = Number(key.trim()); const raw = outputs[position - 1]; if (raw !== undefined && parseAggregateExpression(raw)) { throw new DatabaseError(`GROUP BY position ${position} refers to an aggregate expression ("${raw.trim()}")`, 'QUERY_ERROR', { position }); } resolved.push(outputKeyAt(position, 'GROUP BY')); } stmt.groupBy = resolved; } } /** * 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。 * * v0.8.0(A36):抽成单一实现,因为**两条**路径需要同一规则: * - 非 JOIN 单表(行键是裸列名,`WHERE u.age` 要变成 `age`); * - 派生表非 JOIN(`FROM (SELECT ...) AS d`,行键来自子查询投影,同样无前缀)。 * 此前只有前者做归一化,后者完全没做 → `SELECT d.id FROM (...) AS d` 静默空结果, * 而同义的 `SELECT id FROM (...) AS d` 正确。 * * 覆盖 WHERE(含 `$col` 嵌套引用)/ ORDER BY / GROUP BY / SELECT 四类引用。 * 聚合表达式与 CASE 表达式**整体跳过**(其内部的列引用由各自的求值器处理, * 而它们的求值器现在都走统一的 `resolveColumnValue`,本身支持前缀)。 */ normalizeUnprefixedReferences(stmt, aliases) { const list = aliases.filter((a) => !!a); if (stmt.where && Object.keys(stmt.where).length > 0) { stmt.where = this.normalizeWhereColumns(stmt.where, list); } // v0.8.0(B-5):ORDER BY / GROUP BY 的键也要脱引号 —— 与 WHERE 同一口径。 // 保留引号的唯一目的是让投影期区分"名为 1 的列(`"1"`)"与"常量 1(`1`)"; // 排序/分组阶段必须用**真实列名**,否则 `ORDER BY "1"` 取不到列值而静默 // 按原序返回、`GROUP BY "1"` 会在输出行里留下 `"1"` 与 `1` 两个键(均已实测)。 if (stmt.orderBy && stmt.orderBy.length > 0) { stmt.orderBy = stmt.orderBy.map((o) => ({ ...o, column: unquoteIdentifier(this.stripAlias(o.column, list)), })); } if (stmt.groupBy && stmt.groupBy.length > 0) { stmt.groupBy = stmt.groupBy.map((c) => unquoteIdentifier(this.stripAlias(c, list))); } stmt.columns = stmt.columns.map((c) => { if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c)) return c; const m = c.match(/^(.+?)\s+AS\s+(\w+)$/i); if (m) { const stripped = this.stripAlias(m[1].trim(), list); return stripped === m[1].trim() ? c : `${stripped} AS ${m[2]}`; } return this.stripAlias(c, list); }); } /** 剥离主表别名前缀:'u.id' → 'id'(键与 $col 值均处理,支持多层别名) */ normalizeWhereColumns(where, aliases) { const normalized = {}; for (const [key, value] of Object.entries(where)) { if (key === '$and' || key === '$or') { normalized[key] = value.map((sub) => this.normalizeWhereColumns(sub, aliases)); continue; } if (key === '$not') { normalized.$not = this.normalizeWhereColumns(value, aliases); continue; } if (key === '$exists') { normalized[key] = this.normalizeExistsValue(value, aliases); continue; } // v0.8.0(B-5):WHERE 键脱去分隔标识符引号(`"1" = 'y'` → `1`), // 否则 matchWhere 用裸 `1` 取值、而行键也是裸 `1`,两者因为引号不同 // 永远匹配不上 → 静默空结果(实测)。引号到此已完成"区分列 vs 常量"的使命。 const newKey = unquoteIdentifier(this.stripAlias(key, aliases)); normalized[newKey] = this.normalizeFieldValue(value, aliases); } return normalized; } normalizeExistsValue(value, aliases) { if (typeof value !== 'object' || value === null) return value; const v = value; if (v.$subquery) { const sub = v.$subquery; // 子查询 where 需同时识别:子查询自身别名 + 外层别名(关联引用) const subAliases = [sub.alias ?? sub.from, ...aliases].filter(Boolean); return { ...v, $subquery: { ...sub, where: this.normalizeWhereColumns(sub.where, subAliases) } }; } return value; } normalizeFieldValue(value, aliases) { if (typeof value !== 'object' || value === null || Array.isArray(value)) return value; const ops = {}; for (const [op, operand] of Object.entries(value)) { if (op === '$and' || op === '$or') { ops[op] = operand.map((sub) => this.normalizeWhereColumns(sub, aliases)); } else if (op === '$not' && typeof operand === 'object' && operand !== null) { ops[op] = this.normalizeFieldValue(operand, aliases); } else if (op === '$col') { ops[op] = unquoteIdentifier(this.stripAlias(String(operand), aliases)); } else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in operand) { // 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } } ops[op] = { $col: unquoteIdentifier(this.stripAlias(String(operand.$col), aliases)) }; } else { ops[op] = operand; } } return ops; } stripAlias(col, aliases) { for (const alias of aliases) { if (!alias) continue; const prefix = `${alias}.`; if (col.startsWith(prefix)) return col.slice(prefix.length); } return col; } /** WHERE 是否含关联引用($col 或关联 EXISTS)或 CASE WHEN 表达式键 */ hasCorrelatedRefs(where) { for (const [key, value] of Object.entries(where)) { if (key === '$and' || key === '$or') { if (value.some((sub) => this.hasCorrelatedRefs(sub))) return true; continue; } if (key === '$not') { if (this.hasCorrelatedRefs(value)) return true; continue; } if (key === '$exists') { // 关联 EXISTS:子查询 where 含 $col 或主 where 含 $negate 未解析标记 if (typeof value === 'object' && value !== null && '$subquery' in value) { return true; // 关联 EXISTS 统一走逐行求值 } continue; } // v0.3.2: CASE WHEN 表达式键(逐行求值) if (/^\s*CASE\b/i.test(key)) return true; if (this.fieldHasColRef(value)) return true; } return false; } fieldHasColRef(value) { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; const ops = value; if ('$col' in ops) return true; if ('$and' in ops || '$or' in ops) { const subs = (ops.$and ?? ops.$or); return subs.some((sub) => this.hasCorrelatedRefs(sub)); } if ('$not' in ops && typeof ops.$not === 'object' && ops.$not !== null) { return this.fieldHasColRef(ops.$not); } // 操作符值中嵌套的列引用:{ $eq: { $col: 'id' } } for (const [, operand] of Object.entries(ops)) { if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) { if ('$col' in operand) return true; if (this.fieldHasColRef(operand)) return true; } } return false; } /** * 构造**引擎层预过滤**用的 WHERE 子句。 * * 逐行求值的谓词必须整体移出引擎层,否则引擎的 `matchWhere`(没有外层行上下文) * 会把它们判为 UNKNOWN → **所有行被过滤掉**,逐行求值再正确也无行可算: * - 关联 `EXISTS`:`$exists` 子查询未执行,`{ $subquery: ... }` 引擎无法求值; * - CASE WHEN 表达式键:需要行上下文才能算出布尔; * - `$col` 列引用(`WHERE t.x = t.y` → `{ x: { $eq: { $col: 'y' } } }`): * 引擎层取不到"另一列"的值。 * * 移出的粒度取决于连接词 —— 这里**不是**保守兜底,而是逻辑上唯一正确的做法: * - 顶层 / `$and` 的成员:可以单独删除该谓词,其余谓词仍然安全可下推; * - `$or` / `$not` 的成员:不能单独删除。删掉 `A OR B` 中的 `B` 会得到更严的 * `A`(**漏行**);删掉 `NOT B` 中的 `B` 会得到恒真的 `NOT true`(**多行**)。 * 因此整条 `$or` / `$not` 都交给逐行求值(`$not` 的恒真情形直接丢弃该键)。 */ enginePreFilter(where) { const cleaned = {}; for (const [key, value] of Object.entries(where)) { if (key === '$and') { const subs = value.map((sub) => this.enginePreFilter(sub)); const kept = subs.filter((sub) => Object.keys(sub).length > 0); if (kept.length > 0) cleaned.$and = kept; continue; } if (key === '$or') { // $or 是故障放大器:任一分支不可判定则该 $or 整体不可下推 const subs = value; if (subs.some((sub) => !this.isEngineEvaluable(sub))) continue; cleaned.$or = subs.map((sub) => this.enginePreFilter(sub)); continue; } if (key === '$not') { const inner = value; if (!this.isEngineEvaluable(inner)) continue; cleaned.$not = this.enginePreFilter(inner); continue; } if (key === '$exists') continue; // 逐行求值时单独处理 if (/^\s*CASE\b/i.test(key)) continue; // v0.3.2: CASE 键逐行求值 if (this.fieldHasColRef(value) || this.fieldHasSubquery(value)) continue; // $col / 子查询逐行求值 cleaned[key] = value; } return cleaned; } /** * 该 WHERE 片段是否可完全交给引擎层求值(无 `$col` / 关联 `EXISTS` / CASE 键)。 * * 与 `hasCorrelatedRefs` 的区别:`hasCorrelatedRefs` 回答"是否需要逐行求值", * 本函数回答"能否整体下推"。两者互补,缺一不可 —— 后者是前者在 `$or`/`$not` * 内部传播后的结果。 */ isEngineEvaluable(where) { for (const [key, value] of Object.entries(where)) { if (key === '$and' || key === '$or') { if (value.some((sub) => !this.isEngineEvaluable(sub))) return false; continue; } if (key === '$not') { if (!this.isEngineEvaluable(value)) return false; continue; } if (key === '$exists') return false; if (/^\s*CASE\b/i.test(key)) return false; if (this.fieldHasColRef(value) || this.fieldHasSubquery(value)) return false; } return true; } /** 字段条件里是否含未解析子查询(引擎层无法执行) */ fieldHasSubquery(value) { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; const ops = value; if ('$subquery' in ops) return true; for (const [, operand] of Object.entries(ops)) { if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) { if ('$subquery' in operand) return true; } if (Array.isArray(operand)) { for (const item of operand) { if (typeof item === 'object' && item !== null && '$subquery' in item) return true; } } } return false; } /** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */ async filterCorrelated(rows, where, aliases = []) { const result = []; for (const row of rows) { // 1. CASE WHEN 键 → 布尔条件(同步) let rowWhere = this.resolveCaseKeys(where, row); // 2. $col 绑定 + 关联 EXISTS 求值(异步) rowWhere = await this.resolveSubqueries(rowWhere, row, aliases); // v0.8.0(A10):必须传 { $col: true } —— 否则 `{ $eq: { $col: 't.y' } }` // 会被当作"与一个对象相等"比较,任何行都不匹配 → 静默空结果。 // 实测:`SELECT id FROM t WHERE t.x = t.y` 返回 [](应返回 x==y 的行)。 if (matchWhere(row, rowWhere, { $col: true })) { result.push(row); } } return result; } /** 将 WHERE 中的 CASE WHEN 表达式键求值为布尔条件($caseResult) */ resolveCaseKeys(where, row) { const resolved = {}; for (const [key, value] of Object.entries(where)) { if (key === '$and' || key === '$or') { resolved[key] = value.map((sub) => this.resolveCaseKeys(sub, row)); continue; } if (key === '$not') { resolved.$not = this.resolveCaseKeys(value, row); continue; } if (/^\s*CASE\b/i.test(key)) { const expr = parseCaseExpression(key); if (!expr) continue; // 解析失败视为不满足 const val = evaluateCase(expr, row); if (this.caseConditionMatches(val, value)) { resolved.$caseResult = true; } else { return { $caseResult: false }; } continue; } resolved[key] = value; } return resolved; } /** CASE 求值结果与操作符条件比较 */ caseConditionMatches(val, condition) { if (typeof condition !== 'object' || condition === null || Array.isArray(condition)) { return val === condition; } const ops = condition; for (const [op, operand] of Object.entries(ops)) { switch (op) { case '$eq': if (val !== operand) return false; break; case '$ne': if (val === operand) return false; break; case '$gt': if (!(val > operand)) return false; break; case '$gte': if (!(val >= operand)) return false; break; case '$lt': if (!(val < operand)) return false; break; case '$lte': if (!(val <= operand)) return false; break; case '$in': if (!(Array.isArray(operand) && operand.includes(val))) return false; break; case '$nin': if (Array.isArray(operand) && operand.includes(val)) return false; break; } } return true; } /** 将 where 中的 $col 引用替换为上下文行值 */ bindColumnRefs(value, contextRow, aliases = []) { if (typeof value !== 'object' || value === null || Array.isArray(value)) return value; const ops = {}; for (const [op, operand] of Object.entries(value)) { if (op === '$col') { ops[op] = this.lookupOuterValue(contextRow, String(operand), aliases); } else if (op === '$and' || op === '$or') { ops[op] = operand.map((sub) => this.bindWhereRefs(sub, contextRow, aliases)); } else if (op === '$not' && typeof operand === 'object' && operand !== null) { ops[op] = this.bindColumnRefs(operand, contextRow, aliases); } else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in operand) { // 操作符值中嵌套的列引用:{ $eq: { $col: 'id' } } → { $eq: row['id'] } ops[op] = this.lookupOuterValue(contextRow, String(operand.$col), aliases); } else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$subquery' in operand) { // v0.8.0(A10):**递归进入子查询**,把子查询 WHERE 里的外层引用绑定为当前行值。 // // 此前这里落到 else 分支原样保留子查询对象 —— 于是 // WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id) // 里的 `u.id` 始终未绑定(求值为 null),子查询返回空集 → `$in: []` // → 所有行被过滤,**静默空结果**(而同结构的 EXISTS 走另一条分支是正常的)。 const sub = operand.$subquery; const subWhere = sub.where && this.hasCorrelatedRefs(sub.where) ? this.bindWhereRefs(sub.where, contextRow, aliases) : sub.where; ops[op] = { $subquery: { ...sub, where: subWhere } }; } else { ops[op] = operand; } } return ops; } /** * v0.8.0(A10):在外层行里取 `$col` 引用的值。 * * 关键点:引用可能带外层表名/别名前缀(`u.id`),而外层行键是**不带前缀**的 * (非 JOIN 路径会先剥离)。因此这里必须先剥前缀再取值 —— 否则 `u.id` 取到 * `undefined`,被 `?? null` 静默变成 null,子查询返回空集 * (实测 `WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` 静默空结果)。 */ lookupOuterValue(contextRow, ref, aliases) { const bare = this.stripAlias(ref, aliases); if (bare in contextRow) return contextRow[bare]; if (ref in contextRow) return contextRow[ref]; return null; } bindWhereRefs(where, contextRow, aliases = []) { const bound = {}; for (const [key, value] of Object.entries(where)) { if (key === '$and' || key === '$or') { bound[key] = value.map((sub) => this.bindWhereRefs(sub, contextRow, aliases)); } else if (key === '$not') { bound.$not = this.bindWhereRefs(value, contextRow, aliases); } else if (key === '$exists') { bound[key] = value; } else { bound[key] = this.bindColumnRefs(value, contextRow, aliases); } } return bound; } // =================================================================== // 子查询解析 // =================================================================== /** * 递归扫描 WHERE 条件,找到 $subquery 标记并执行子查询, * 将结果替换为具体值。 * @param contextRow 关联子查询的外层行上下文(用于绑定 $col 引用) */ async resolveSubqueries(where, contextRow, aliases = []) { // 关联上下文:先把字段级的 $col 引用绑定为外层行值 if (contextRow) { where = this.bindWhereRefs(where, contextRow, aliases); } const resolved = {}; for (const [key, value] of Object.entries(where)) { // 顶层 $exists(v0.3.0):执行子查询并解析为 boolean,由 where-matcher 消费 if (key === '$exists' && typeof value === 'object' && value !== null) { const v = value; const sub = v.$subquery; const negate = !!v.$negate; let subWhere = sub.where; // 子查询内的关联引用(如 o.user_id = u.id 中的 u.id)绑定外层行 if (this.hasCorrelatedRefs(subWhere)) { subWhere = this.bindWhereRefs(subWhere, contextRow ?? {}); } const rows = await this.executeSelectPart({ ...sub, where: subWhere }); resolved.$exists = rows.length > 0 !== negate; continue; } // 逻辑组合操作符 if (key === '$and' && Array.isArray(value)) { resolved.$and = await Promise.all(value.map((sub) => this.resolveSubqueries(sub, contextRow, aliases))); continue; } if (key === '$or' && Array.isArray(value)) { resolved.$or = await Promise.all(value.map((sub) => this.resolveSubqueries(sub, contextRow, aliases))); continue; } if (key === '$not' && typeof value === 'object' && value !== null) { resolved.$not = await this.resolveSubqueries(value, contextRow, aliases); continue; } // 字段条件 if (typeof value === 'object' && value !== null) { resolved[key] = await this.resolveOperatorSubqueries(value, contextRow, aliases); } else { resolved[key] = value; } } return resolved; } /** * 解析操作符值中嵌套的子查询 */ /** * 解析字段条件里的子查询。 * * v0.8.0 根治(A10):接收外层行上下文并对子查询内的关联引用做绑定。 * 此前完全不传 contextRow —— 于是 `WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` * 里的 `u.id` 绑定为 undefined,子查询返回空集,最终 `$in: []` → **静默空结果** * (而结构相同的 EXISTS 因为走另一条分支是正常的 —— 又一处"同类逻辑两条路径")。 */ async resolveOperatorSubqueries(ops, contextRow, aliases = []) { const resolved = {}; for (const [op, operand] of Object.entries(ops)) { // 处理嵌套 $and/$or(在字段级条件中) if (op === '$and' && Array.isArray(operand)) { resolved.$and = await Promise.all(operand.map((sub) => this.resolveSubqueries(sub, contextRow, aliases))); continue; } if (op === '$or' && Array.isArray(operand)) { resolved.$or = await Promise.all(operand.map((sub) => this.resolveSubqueries(sub, contextRow, aliases))); continue; } if (op === '$not') { resolved.$not = typeof operand === 'object' && operand !== null ? await this.resolveOperatorSubqueries(operand, contextRow, aliases) : operand; continue; } // 子查询检测 if (typeof operand === 'object' && operand !== null && '$subquery' in operand) { let subStmt = operand.$subquery; // 关联引用绑定(与 $exists 分支同样的处理) if (contextRow && this.hasCorrelatedRefs(subStmt.where)) { subStmt = { ...subStmt, where: this.bindWhereRefs(subStmt.where, contextRow, aliases) }; } const subResult = await this.executeSelect(subStmt); if (op === '$in' || op === '$nin') { // IN 子查询 → 提取第一列的值列表 const colName = Object.keys(subResult[0] || {})[0]; const values = subResult.map((row) => row[colName]); resolved[op] = values; } else { // 标量子查询 → 取第一行第一列 if (subResult.length === 0) { resolved[op] = null; } else { const colName = Object.keys(subResult[0])[0]; resolved[op] = subResult[0][colName]; } } } else { resolved[op] = operand; } } return resolved; } } /** * metona-sqlark SQL Parameters — 参数化查询绑定 * @module sql/params * * v0.7.0: `db.query(sql, params)` 位置参数(`?`)支持。 * 绑定在词法层面完成:仅替换字符串字面量之外的 `?`, * 值按 SQL 字面量编码(字符串 `''` 转义、数字/布尔/JSON 直出), * 从根上规避 SQL 注入(不经过字符串拼接由用户自行转义)。 * * v0.7.2: 词法扫描感知注释 —— 行注释(`--`)与块注释(slash-star 包裹)中的 `?` * 与引号不再参与占位符识别与字符串状态机(此前注释中的 `?` 计入占位符导致 * PARAM_ERROR 错位、注释中的单引号触发 "Unterminated string literal")。 */ /** 将单个参数值编码为 SQL 字面量 */ function encodeParam(value) { if (value === null || value === undefined) return 'NULL'; if (typeof value === 'number') { if (Number.isFinite(value)) return String(value); return 'NULL'; // NaN/Infinity 无 SQL 字面量 → NULL } if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`; // 对象/数组无 SQL 字面量(SQL 方言不支持 json 字面量),显式拒绝而非静默错配 throw new DatabaseError('Object/array query parameters are not supported by SQL binding (pass JSON strings explicitly)', 'PARAM_ERROR'); } /** * 将 SQL 中的位置参数 `?`(字符串字面量与注释之外)替换为编码后的字面量。 * @param sql 含 `?` 占位符的 SQL * @param params 位置参数数组 * @throws PARAM_ERROR 参数数量不匹配 */ function bindParameters(sql, params) { // undefined = 不启用绑定;[] + 含 ? 的 SQL 由循环内报 PARAM_ERROR if (!params) return sql; let out = ''; let i = 0; let pIdx = 0; let quote = null; while (i < sql.length) { const ch = sql[i]; if (quote !== null) { out += ch; if (ch === quote) { // SQL 标准 '' 转义:两个连续引号 = 一个引号(原样保留) if (sql[i + 1] === quote) { out += sql[i + 1]; i += 2; continue; } quote = null; } i++; continue; } if (ch === "'" || ch === '"') { quote = ch; out += ch; i++; continue; } // v0.7.2: 行注释 `-- ...`(含其中的 ? 与引号)原样保留、不参与绑定 if (ch === '-' && sql[i + 1] === '-') { while (i < sql.length && sql[i] !== '\n' && sql[i] !== '\r') { out += sql[i]; i++; } continue; } // v0.7.2: 块注释(slash-star 包裹)同样跳过 if (ch === '/' && sql[i + 1] === '*') { out += sql[i] + sql[i + 1]; i += 2; while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) { out += sql[i]; i++; } if (i < sql.length) { out += sql[i] + sql[i + 1]; i += 2; } continue; } if (ch === '?') { if (pIdx >= params.length) { throw new DatabaseError(`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`, 'PARAM_ERROR'); } out += encodeParam(params[pIdx]); pIdx++; i++; continue; } out += ch; i++; } if (quote !== null) { throw new DatabaseError('Unterminated string literal in SQL', 'PARSE_ERROR'); } if (pIdx < params.length) { throw new DatabaseError(`Too many query parameters: ${params.length} provided but only ${pIdx} placeholders`, 'PARAM_ERROR'); } return out; } /** * metona-sqlark Transaction — 事务管理 * @module transaction * * v0.1.13: 支持真正的回滚 — 利用引擎层 begin/commit/rollback 实现原子性。 */ // --------------------------------------------------------------------------- // Transaction // --------------------------------------------------------------------------- class Transaction { constructor(engine) { this.tables = new Map(); this.completed = false; this.engine = engine; this.executor = new QueryExecutor(engine); } /** 获取表操作对象 */ table(tableName) { let t = this.tables.get(tableName); if (!t) { t = new Table(this.engine, tableName, this.executor); this.tables.set(tableName, t); } return t; } /** 标记事务完成(由 TransactionManager 调用) */ _markCompleted() { this.completed = true; } /** 是否已完成 */ isCompleted() { return this.completed; } } // --------------------------------------------------------------------------- // TransactionManager // --------------------------------------------------------------------------- class TransactionManager { constructor(engine) { this.engine = engine; } /** 执行事务 — 支持自动回滚 */ async execute(fn) { const trx = new Transaction(this.engine); // 开始引擎层事务 await this.engine.beginTransaction(); try { const result = await fn(trx); // 成功 → 提交 await this.engine.commitTransaction(); trx._markCompleted(); return result; } catch (error) { // 失败 → 回滚。v0.7.1: 回滚自身失败不掩盖原始事务错误 // (此前 rollback 抛错会替换掉真正导致失败的异常,定位困难) try { await this.engine.rollbackTransaction(); } catch { /* 回滚失败保留原始错误 */ } if (error instanceof DatabaseError) throw error; throw new DatabaseError(`Transaction failed: ${error.message}`, 'TRANSACTION_ERROR', error); } } } /** * metona-sqlark Plugin — 插件系统 * @module plugin * * 管理插件的注册、生命周期和钩子调度。 */ // --------------------------------------------------------------------------- // PluginManager // --------------------------------------------------------------------------- class PluginManager { constructor() { this.plugins = []; this.hooks = new Map(); } /** 注册插件 */ register(plugin, db) { // 按优先级插入 const priority = plugin.priority ?? 0; const insertIndex = this.plugins.findIndex((p) => (p.priority ?? 0) < priority); if (insertIndex === -1) { this.plugins.push(plugin); } else { this.plugins.splice(insertIndex, 0, plugin); } // 安装(传入 db 实例) plugin.install(db); } /** 卸载插件 */ unregister(pluginName) { const idx = this.plugins.findIndex((p) => p.name === pluginName); if (idx !== -1) { this.plugins[idx].destroy(); this.plugins.splice(idx, 1); } } /** 获取所有已注册插件 */ getPlugins() { return [...this.plugins]; } /** 添加钩子回调 */ on(hook, callback) { const callbacks = this.hooks.get(hook) ?? []; callbacks.push(callback); this.hooks.set(hook, callbacks); } /** 移除钩子回调 */ off(hook, callback) { const callbacks = this.hooks.get(hook); if (callbacks) { const idx = callbacks.indexOf(callback); if (idx !== -1) callbacks.splice(idx, 1); } } /** 触发钩子 */ async trigger(hook, ...args) { const callbacks = this.hooks.get(hook); if (callbacks) { for (const cb of callbacks) { await cb(...args); } } } /** 销毁所有插件 */ destroy() { for (const plugin of this.plugins) { try { plugin.destroy(); } catch (e) { // eslint-disable-next-line no-console console.warn(`[metona-sqlark] Plugin "${plugin.name}" destroy error:`, e); } } this.plugins = []; this.hooks.clear(); } } /** * metona-sqlark Core — 数据库主类 * @module core * * 管理数据库生命周期、引擎调度、表操作、SQL 查询、事务和插件。 */ // --------------------------------------------------------------------------- // MetonaSqlark // --------------------------------------------------------------------------- class MetonaSqlark { /** * v0.7.1: 静态工厂(与 connect/disconnect 同一入口风格)。 * 此前 create 仅存在于 api 对象 / window 挂载 —— README/站点示例的 * `MetonaSqlark.create({...})` 在 ESM/Node 下是 undefined(TypeError)。 */ static async create(config) { const db = new MetonaSqlark(config); await db.init(); return db; } /** 获取版本号 */ get version() { return this._version; } /** 查询结果行数上限 */ get maxRowsPerQuery() { return this.config.maxRowsPerQuery ?? 0; } /** 调试模式 */ get debug() { return this.config.debug ?? false; } constructor(config) { this.ready = false; this.tableCache = new Map(); /** 多标签页同步通道(v0.3.2) */ this.channel = null; // ---- 发布订阅 ---- /** 变更通知引擎(init 后可用);未初始化时为 null */ this.notifier = null; this.listeners = new Map(); // ---- 迁移 ---- this.migrations = new Map(); this.config = config; this.name = config.name ?? DB_DEFAULTS.name; this.mode = config.mode ?? DB_DEFAULTS.mode; this._version = config.version ?? DB_DEFAULTS.version; this.pluginManager = new PluginManager(); // v0.3.2: 多标签页同步 — BroadcastChannel 广播表变更 if (config.multiTabSync && typeof BroadcastChannel !== 'undefined') { this.channel = new BroadcastChannel(`metona-sqlark:${this.name}`); this.channel.onmessage = (event) => { const msg = event.data; if (!msg || msg.type !== 'change') return; // v0.8.0: 外部事件只派发给本地订阅者,**不得再次广播** —— // 否则两个标签页会互相转发形成无限广播循环(实测 8 次以上且不终止)。 void this.emitExternal(msg.table ?? ''); // Hybrid 引擎:从磁盘重载内存,保证读到其他标签页的最新数据。 // // v0.8.0: 引擎现在被 ChangeNotifierEngine 装饰,`this.engine instanceof HybridEngine` // 恒为 false —— 因此改为对**内层**引擎做能力探测。这也是审计指出的 // "用 instanceof 做引擎特判"的隐患:装饰器一加就静默失效。 const inner = this.unwrapEngine(); if (inner instanceof HybridEngine) { inner.reloadMemoryFromDisk().catch(() => { // 重载失败不影响主流程(下次读可能短暂过期) }); } }; } } // ---- 初始化 ---- /** 初始化数据库(创建引擎、打开连接) */ async init() { // 创建引擎 this.engine = this.createEngine(); // 打开连接 await this.engine.open(this.name, this.version); // v0.8.0(A9):把引擎包进变更通知装饰器 —— **唯一**的变更事件汇聚点。 // 三个写入入口(SQL / Table API / QueryBuilder)与事务内写入都必须经过引擎接口, // 因此在这里拦一次即可全覆盖,避免在三条路径上各写一份"变更描述"逻辑。 this.engine = new ChangeNotifierEngine(this.engine, (error) => this._onError(error), (table) => this.broadcastChange(table)); this.notifier = this.engine; // 把引擎层变更事件接入 db.subscribe 的订阅表(listeners) this.notifier.addListener(async (event) => { const set = this.listeners.get(`change:${event.table}`); if (!set || set.size === 0) return; for (const cb of [...set]) { try { await cb(event); } catch (error) { this._onError(error); } } }); // v0.4.2-fix (P2-7): 从库内加载持久化的迁移版本, // 重启后 migrateTo 从持久化版本继续执行,不再每次从 config.version 重置 if (typeof this.engine.getMeta === 'function') { try { const persistedVersion = await this.engine.getMeta('__metona_version'); if (persistedVersion != null && Number(persistedVersion) >= 1) { this._version = Math.max(this._version, Math.floor(Number(persistedVersion))); } } catch { /* 读取失败回退 config.version */ } } // 初始化执行器和事务管理器 this.executor = new QueryExecutor(this.engine, this.maxRowsPerQuery); this.transactionManager = new TransactionManager(this.engine); // 注册插件。 // // v0.8.0:**先按 priority 降序排序再注册** —— 在这之前 PluginManager.register // 虽然会把插件插到正确的位置,但 `install()` 是在 register 里**立即**调用的, // 因此 install 与钩子的实际执行顺序仍等于 config 数组顺序 //(实测:priority 为 low/high/mid 的插件,钩子按 low→high→mid 触发, // 只有 getPlugins() 才是 high,mid,low)。而 README/CONTRIBUTING 一直宣称 // "priority 越大越先执行" —— 文档与实现不符。 // 这里选择**让实现符合文档**(priority 是用户可见的配置项,静默无效比没有更糟)。 // 用稳定排序:同优先级保持 config 数组中的相对顺序。 if (this.config.plugins) { const ordered = this.config.plugins .map((plugin, index) => ({ plugin, index })) .sort((a, b) => (b.plugin.priority ?? 0) - (a.plugin.priority ?? 0) || a.index - b.index) .map((entry) => entry.plugin); for (const plugin of ordered) { this.pluginManager.register(plugin, this); } } this.ready = true; // 回调 if (this.config.onReady) { this.config.onReady(this); } } /** 检查是否就绪 */ isReady() { return this.ready; } // ---- 表管理 ---- /** 创建表 */ async defineTable(name, columns) { this.ensureReady(); const schema = createSchema(name, columns); try { await this.pluginManager.trigger('beforeCreateTable', schema); await this.engine.createTable(schema); await this.pluginManager.trigger('afterCreateTable', schema); } catch (error) { this._onError(error); throw error; } // 清除缓存 this.tableCache.delete(name); } /** 获取表操作对象 */ table(name) { this.ensureReady(); let t = this.tableCache.get(name); if (!t) { // v0.3.2: 表操作写入后广播变更(多标签页同步) // v0.5.1: CRUD 生命周期钩子真实接线(beforeInsert/afterInsert/...) t = new Table(this.engine, name, this.executor, // v0.8.0: 变更事件由引擎层 ChangeNotifierEngine 统一产生, // 此处不再重复广播(Table API 与 SQL 路径曾各广播一次 → 同一次写入触发两遍) () => { }, (hook, args) => this.pluginManager.trigger(hook, ...args)); this.tableCache.set(name, t); } return t; } /** 删除表 */ async dropTable(name) { this.ensureReady(); try { await this.pluginManager.trigger('beforeDropTable', name); await this.engine.dropTable(name); await this.pluginManager.trigger('afterDropTable', name); } catch (error) { this._onError(error); throw error; } this.tableCache.delete(name); } /** 获取所有表名 */ async getTableNames() { this.ensureReady(); return this.engine.getTableNames(); } // ---- SQL 查询 ---- /** * 执行 SQL 字符串查询。 * v0.7.0: 支持位置参数(`?`)—— `db.query('SELECT * FROM t WHERE id = ?', ['1'])`。 * 参数按 SQL 字面量安全编码(字符串 '' 转义),杜绝 SQL 注入。 */ async query(sql, params) { this.ensureReady(); const startTime = this.debug ? Date.now() : 0; await this.pluginManager.trigger('beforeQuery', sql); let result; try { // v0.7.0: 参数绑定(仅替换字符串字面量之外的 ?) const boundSql = bindParameters(sql, params); // v0.3.0: 支持分号分隔的多语句,逐条顺序执行,返回最后一条的结果 const statements = parseAll(boundSql); for (const stmt of statements) { // v0.5.1: SQL 写语句触发 CRUD 生命周期钩子(与 Table API 路径一致) await this.triggerStatementHooks(stmt, 'before'); result = await this.executor.execute(stmt); await this.triggerStatementHooks(stmt, 'after', result); // v0.8.0: 变更广播与订阅事件统一由引擎层 ChangeNotifierEngine 产生, // 此处不再手工 broadcastChange(否则同一次写入会广播两次)。 } } catch (error) { this._onError(error); throw error; } await this.pluginManager.trigger('afterQuery', sql, result); if (this.debug) { const elapsed = Date.now() - startTime; const rows = Array.isArray(result) ? result.length : 0; this._debug(`query [${elapsed}ms] ${rows} rows: ${sql.slice(0, 100)}`); } return result; } // ---- 流式查询(v0.4.0) ---- /** * 流式查询:逐行回调,不一次性物化全部结果(大表友好)。 * 支持简单 SELECT(WHERE/LIMIT/OFFSET/列投影); * JOIN/GROUP BY/UNION/聚合/ORDER BY 自动回退为物化查询后逐行回调。 * * @example * ```ts * let total = 0; * await db.queryStream('SELECT * FROM logs WHERE level = \'error\'', (row) => { * total++; * processRow(row); * }); * ``` */ /** * 流式查询:逐行回调,尽量不物化全部结果(大表友好)。 * * v0.8.0 根治:**快路径与物化路径的结果必须逐值相等**。 * * 此前 core.ts 自己重写了一套"能不能走引擎快路径 / 列投影怎么算"的规则, * 与 executor 的规则各写一份并发生漂移,实测四类静默不一致: * SELECT id AS x FROM t → query 返回 [{x}],stream 返回 [{id,v}](全列 + 原列名) * SELECT t.id FROM t → query 返回 [{id}],stream 返回 [{}](空对象) * ... LIMIT 2 OFFSET 1 → query 1 行,stream 2 行 * ... LIMIT 0 → query 0 行,stream 1 行(Aria 又是 0 行,跨引擎也不同) * 另:流式路径既不触发 beforeQuery/afterQuery 钩子,也不受 maxRowsPerQuery 约束。 * * 现在的规则: * 1. 是否可流式、如何投影,全部由 `executor.analyzeSelect()` 判定(单一事实来源); * 2. 不可流式(以及任何不确定的情况)一律回退到 `query()` 物化后逐行回调 —— * 这条路径天然与 `query()` 同语义,是正确性的兜底保证; * 3. 快路径只覆盖"引擎层投影与 executor 投影语义等价"的简单 SELECT; * 4. 回调返回 Promise 时不再靠 `constructor.name` 猜(此前对普通函数返回 Promise * 的情况完全失效),而是直接检测返回值并显式报错,避免 Promise 被静默丢弃。 */ async queryStream(sql, onRow) { this.ensureReady(); // v0.7.4: 多语句显式拒绝 —— 此前 parseAll(sql)[0] 静默忽略后续语句: // 可流式时后续语句(如 DELETE)不执行,不可流式时回退 query() 却会执行 // 全部语句 → 同一条 SQL 两种语义。流式 API 要求单条 SELECT。 const statements = parseAll(sql); if (statements.length !== 1) { throw new DatabaseError('queryStream requires exactly one SELECT statement', 'PARSE_ERROR'); } const stmt = statements[0]; if (!stmt || stmt.type !== 'SELECT') { throw new DatabaseError('queryStream only supports SELECT statements', 'NOT_SUPPORTED'); } const select = stmt; // 执行形态由 executor 统一判定(与 query() 路径共用同一规则) const shape = this.executor.analyzeSelect(select); // LIMIT 0 语义:任何引擎都必须返回 0 行。 // 引擎对 `limit: 0` 的解释并不一致(Aria 返回 0 行,Memory/KVStore/Hybrid 把 0 当 // "无限制"返回全部行 —— 实测 LIMIT 0 在四种引擎下分别为 0/1/1/1 行)。 // 流式路径直接短路,避免依赖各引擎对 0 的解释。 if (select.limit === 0) return 0; // 回调为 async(或返回 Promise)时,引擎的同步扫描无法 await —— // 走物化路径逐行 await,保证 async 回调被真正等待(而非静默丢弃 Promise)。 if (this.isAsyncCallback(onRow)) { const materialized = await this.query(sql); if (!Array.isArray(materialized)) return 0; for (const row of materialized) { await onRow(row); } return materialized.length; } if (shape.streamable && typeof this.engine.findStream === 'function') { const where = this.normalizeWhereForStream(select); // 与 executor 的非 JOIN 路径一致:剥离主表别名前缀后再交给引擎 // (executor 对 `SELECT t.id FROM t` 会发 columns=['id'];此前流式路径把 // 't.id' 原样传给引擎,引擎按 't.id' 建键 → 行里取不到 → 回调收到 {})。 const mainAliases = [select.alias ?? select.from].filter(Boolean); const columns = select.columns.length > 0 ? select.columns.map((c) => this.stripAliasPrefix(c, mainAliases)) : ['*']; const maxRows = this.maxRowsPerQuery; let emitted = 0; const count = await this.engine.findStream(select.from, { table: select.from, columns, where: where && Object.keys(where).length > 0 ? where : undefined, limit: select.limit, offset: select.offset, }, (row) => { // maxRowsPerQuery 必须与物化路径一致地生效(此前流式路径完全不受约束) if (maxRows > 0 && emitted >= maxRows) return; emitted++; onRow(row); }); // 引擎返回的行数在 maxRowsPerQuery 截断时需与回调次数一致 return maxRows > 0 ? Math.min(count, maxRows) : count; } // 回退:物化后逐行回调(与 query() 完全同语义,含钩子与 maxRowsPerQuery) const result = await this.query(sql); if (Array.isArray(result)) { for (const row of result) { await onRow(row); } return result.length; } return 0; } /** * v0.8.0: 判断流式回调是否为 async(或声明返回 Promise)。 * * 此前用 `onRow.constructor.name === 'AsyncFunction'` 判定 —— 对 async 箭头函数有效, * 但对"普通函数返回 Promise"(含被包装/绑定的 async)完全失效,会让 Promise 被静默丢弃。 * 这里用**双条件**:既看是否声明为 async 函数(源码/转译后仍可识别), * 也看其返回类型标注;两者任一成立即走物化 + await 路径。 */ isAsyncCallback(onRow) { const name = onRow.constructor?.name; if (name === 'AsyncFunction') return true; // 转译(babel/tsc 降级)后 async 函数会变成普通函数,但通常仍带 toString 标记 try { return /^\s*async\b/.test(Function.prototype.toString.call(onRow)); } catch { return false; } } /** * v0.8.0: 剥离列引用上的主表别名前缀(`t.id` → `id`)。 * 与 executor 非 JOIN 路径的 `stripAlias` 语义保持一致。 */ stripAliasPrefix(col, aliases) { for (const a of aliases) { if (a && col.startsWith(`${a}.`)) return col.slice(a.length + 1); } return col; } /** 流式查询用:剥离主表别名前缀(复用 query 路径的规范化逻辑) */ normalizeWhereForStream(select) { const aliases = [select.alias ?? select.from].filter(Boolean); const strip = (col) => { for (const a of aliases) { if (col.startsWith(`${a}.`)) return col.slice(a.length + 1); } return col; }; const walk = (w) => { const out = {}; for (const [k, v] of Object.entries(w)) { if (k === '$and' || k === '$or') { out[k] = v.map(walk); } else if (k === '$not' && typeof v === 'object' && v !== null) { out.$not = walk(v); } else { out[strip(k)] = v; } } return out; }; return walk(select.where ?? {}); } // ---- 事务 ---- /** 执行事务 */ async transaction(fn) { this.ensureReady(); await this.pluginManager.trigger('beforeTransaction'); try { const result = await this.transactionManager.execute(fn); await this.pluginManager.trigger('afterTransaction'); return result; } catch (error) { this._onError(error); throw error; } } // ---- 导入导出 ---- /** 导出表数据为 JSON */ async exportTable(tableName) { this.ensureReady(); return this.engine.find(tableName, { table: tableName }); } /** 导入 JSON 数据到表 */ async importTable(tableName, data) { this.ensureReady(); try { return await this.engine.insert(tableName, data); } catch (error) { this._onError(error); throw error; } } /** 导出整个数据库为 JSON */ async exportAll() { this.ensureReady(); const result = {}; const names = await this.engine.getTableNames(); for (const name of names) { result[name] = await this.engine.find(name, { table: name }); } return result; } /** * v0.5.1: 在线备份 — 导出全库数据。 * * v0.8.0 修正表述:此前注释与 README 宣称"全库**一致性**快照",但引擎层 * 并没有跨表快照原语 —— 实现是**逐表读取**(Aria 走引擎级 `backup()`, * 其余引擎回退 `exportAll()`)。备份过程中的并发写入会让不同表来自不同 * 时间点(单表内部仍是一致的)。需要强一致时先 `close()`,或用 * `db.transaction()` 包住调用(事务期间并发写被 `TX_ACTIVE` 拒绝)。 * 真正的跨表快照需要 COW 行所有权改造,列入后续版本。 */ async backup() { this.ensureReady(); if (typeof this.engine.backup === 'function') { return this.engine.backup(); } return this.exportAll(); } /** * 订阅表变更。 * * v0.8.0 修复:此前**本地写入永不触发** —— 全库唯一调用 `emit` 的地方在 * BroadcastChannel 收到其它标签页消息的分支里,因此 README「订阅表变更」与 * site/docs.html 的 `event.type: 'insert' | 'update' | 'delete'` 示例全都不成立。 * 现在本地写入(SQL / Table API / QueryBuilder / 事务内)都会产生事件。 * * 现在返回的函数是**同步**退订函数(与既有 API 兼容)。 */ subscribe(tableName, callback) { const key = `change:${tableName}`; if (!this.listeners.has(key)) this.listeners.set(key, new Set()); this.listeners.get(key).add(callback); return () => { this.listeners.get(key)?.delete(callback); }; } /** * 手动触发变更事件(保留为公开 API:自定义写入路径可显式通知订阅者)。 * 现在也支持 await —— 订阅者的 Promise 会被等待。 */ async emit(tableName, event) { await this.dispatchChange({ table: tableName, ...event }); } /** * v0.8.0: 派发"来自其它标签页"的变更事件。 * 只走本地订阅者,不触发 onBroadcast(避免 A↔B 互相转发的无限循环)。 */ async emitExternal(tableName) { const event = { type: 'external', table: tableName }; const set = this.listeners.get(`change:${tableName}`); if (!set) return; for (const cb of [...set]) { try { await cb(event); } catch (error) { this._onError(error); } } } /** 内部:把一次变更同时派发给本地订阅者与跨标签页广播 */ async dispatchChange(event) { if (this.notifier) { // notifier.dispatch 内部已包含跨标签页广播,这里不重复调用 await this.notifier.dispatch(event); return; } // init 之前(notifier 尚未建立)也能广播 this.broadcastChange(event.table); } // ---- 多标签页同步(v0.3.2) ---- /** 广播表变更到其他标签页(多标签页同步) */ broadcastChange(tableName) { if (!this.channel) return; try { this.channel.postMessage({ type: 'change', table: tableName }); } catch { // 广播失败不影响主流程 } } /** 写语句对应的表名(多标签页广播用) */ writeStatementTable(stmt) { switch (stmt.type) { case 'INSERT': return stmt.into; case 'UPDATE': return stmt.table; case 'DELETE': return stmt.from; case 'CREATE_TABLE': case 'DROP_TABLE': case 'TRUNCATE_TABLE': return stmt.name; case 'ALTER_TABLE': return stmt.name; case 'CREATE_INDEX': case 'DROP_INDEX': return stmt.table; default: return null; } } /** * v0.5.1: SQL 写语句触发 CRUD 生命周期钩子。 * INSERT/UPDATE/DELETE 分别触发 beforeInsert/afterInsert、beforeUpdate/afterUpdate、 * beforeDelete/afterDelete(参数与 Table API 路径一致)。 */ async triggerStatementHooks(stmt, phase, result) { switch (stmt.type) { case 'INSERT': { // v0.7.3: 列映射对齐 executor —— 省略列名时按 schema 列顺序映射 // (此前用数字键 String(i),与 executor 写入的真实行键不一致) let cols = stmt.columns ?? []; if (cols.length === 0) { try { const schema = await this.engine.getTableSchema(stmt.into); cols = schema ? Object.keys(schema.columns) : []; } catch { cols = []; } } const rows = (stmt.values ?? []).map((vals) => { const row = {}; for (let i = 0; i < vals.length; i++) { row[cols[i] ?? String(i)] = vals[i]; } return row; }); if (phase === 'before') await this.pluginManager.trigger('beforeInsert', rows); else await this.pluginManager.trigger('afterInsert', rows, result); break; } case 'UPDATE': { const query = { table: stmt.table, where: stmt.where }; if (phase === 'before') await this.pluginManager.trigger('beforeUpdate', query, stmt.sets); else await this.pluginManager.trigger('afterUpdate', query, stmt.sets, result); break; } case 'DELETE': { const query = { table: stmt.from, where: stmt.where }; if (phase === 'before') await this.pluginManager.trigger('beforeDelete', query); else await this.pluginManager.trigger('afterDelete', query, result); break; } } } /** 注册迁移 */ addMigration(version, up) { this.migrations.set(version, up); } /** 执行迁移到指定版本 */ async migrateTo(targetVersion) { this.ensureReady(); for (const [version, up] of [...this.migrations.entries()].sort((a, b) => a[0] - b[0])) { if (version <= targetVersion && version > this._version) { await up(this); this._version = version; } } // v0.4.2-fix (P2-7): 迁移版本持久化到库内,重启后从持久化版本继续, // 避免"version 重置导致已执行迁移重跑(不幂等就炸)"或"版本门槛跳过迁移" if (typeof this.engine.setMeta === 'function') { try { await this.engine.setMeta('__metona_version', String(this._version)); } catch { /* 持久化失败不阻塞迁移流程 */ } } } // ---- 自愈 / 重置(v0.4.2-fix, P2-9) ---- /** * 崩溃恢复自愈 — 校验并清理损坏数据、恢复一致性。 * 检测到异常后调用,无需删库重建。 */ async repair() { this.ensureReady(); if (typeof this.engine.repair === 'function') { await this.engine.repair(); this.tableCache.clear(); return; } // 兜底:重建表缓存 this.tableCache.clear(); } /** * 清空全部数据与表结构(保留库本身)。 * 支持后续继续使用本实例重建表。 */ async clearAll() { this.ensureReady(); if (typeof this.engine.clearAll === 'function') { await this.engine.clearAll(); } else { const names = await this.engine.getTableNames(); for (const name of names) { await this.engine.dropTable(name); } } this.tableCache.clear(); } // ---- 插件 ---- /** 获取插件管理器 */ getPluginManager() { return this.pluginManager; } /** 注册钩子 */ on(hook, callback) { this.pluginManager.on(hook, callback); } // ---- 生命周期 ---- /** 关闭数据库 */ async close() { if (this.channel) { this.channel.close(); this.channel = null; } this.pluginManager.destroy(); // v0.7.1: init 失败/未调用时 close 不应崩溃(此前 this.engine undefined → TypeError) if (this.engine) { await this.engine.close(); } this.tableCache.clear(); this.ready = false; } /** * 获取底层存储引擎。 * * v0.8.0:返回**未装饰**的真实引擎。 * * 变更通知用的 ChangeNotifierEngine 只是内部接线细节;若把它暴露出去, * 调用方(以及测试)依赖的引擎特有能力(`lsm`、`secondaryIndexes`、 * `getDiskEngineType` 等)会被静默隐藏 —— 本项目既有测试与文档都按 * "getEngine() 就是那个引擎"理解。因此这里保持原语义,装饰器只在 core 内部使用。 */ getEngine() { return this.unwrapEngine(); } /** * v0.8.0: 取**未装饰**的真实存储引擎。 * * 引擎在 init 时被 ChangeNotifierEngine 包了一层,因此需要引擎特化能力 * (如 HybridEngine.reloadMemoryFromDisk)时必须先解包,否则 instanceof 恒 false。 */ unwrapEngine() { return this.notifier ? this.notifier.getInner() : this.engine; } // ---- 内部 ---- createEngine() { const mode = this.mode; const diskEngine = this.config.diskEngine ?? 'opfs'; switch (mode) { case 'memory': return new MemoryEngine(); case 'disk': // v0.6.0: 自研 KVStoreEngine(完全移除 IndexedDB) return new KVStoreEngine(); case 'aria': // v0.4.5: 透传 AriaEngine 专属配置(walSyncMode/checkpointInterval/encryption/pageStorage 等) // v0.6.1: diskEngine 'kv' → 自研 KVStore 后端 return new AriaEngine({ storageBackend: diskEngine === 'memory' ? 'memory' : diskEngine === 'kv' ? 'kv' : 'opfs', ...(this.config.aria ?? {}), }); case 'hybrid': return new HybridEngine(diskEngine); default: throw new DatabaseError(`Unknown storage mode: ${mode}`, 'CONFIG_ERROR'); } } ensureReady() { if (!this.ready) { throw new DatabaseError('Database not initialized. Call await db.init() first.', 'DB_NOT_READY'); } } /** 错误回调分发 */ _onError(error) { if (this.config.onError) { try { this.config.onError(error); } catch { /* 避免回调自身异常影响主流程 */ } } } /** 调试日志 */ _debug(msg, ...args) { if (this.debug) { // eslint-disable-next-line no-console console.debug(`[MetonaSqlark:${this.name}] ${msg}`, ...args); } } } // @ts-nocheck /** * metona-sqlark React Integration (v0.2.5) * @module integrations/react * * 轻量 React hooks,需要 react 作为 peer dependency。 * * @example * import { useQuery, useTable } from 'metona-sqlark/react'; * import { db } from './db'; * * function UserList() { * const { data, loading, refresh } = useQuery(db, 'SELECT * FROM users'); * return loading ?
Loading...
:
{data.map(u =>
{u.name}
)}
; * } */ /** useQuery: 执行 SQL 查询 */ function useQuery(db, sql, deps = []) { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const execute = useCallback(async () => { setLoading(true); try { const result = await db.query(sql); setData(result); setError(null); } catch (e) { setError(e); } finally { setLoading(false); } }, [db, sql, ...deps]); useEffect(() => { execute(); }, [execute]); return { data, loading, error, refresh: execute }; } /** 表名合法性校验(防 SQL 注入) */ function validateTableName(name) { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { throw new Error(`Invalid table name: "${name}"`); } return name; } /** useTable: 快速获取表数据 */ function useTable(db, tableName) { const { data, loading, refresh } = useQuery(db, `SELECT * FROM ${validateTableName(tableName)}`, [tableName]); return { data, loading, refresh }; } /** useDatabase: 创建/管理数据库实例 */ function useDatabase(config) { const [db, setDb] = useState(null); const [ready, setReady] = useState(false); const [error, setError] = useState(null); // v0.7.3: config 变更时重建实例 —— 此前 initRef 只建一次,配置更新永不生效 // (且旧实例残留)。以 config 序列化指纹为依赖:值不变不重建,变更时 // cleanup 关闭旧实例(close 幂等,未完成 init 亦可安全关闭)再建新实例。 const configKey = JSON.stringify(config); const prevKeyRef = useRef(null); useEffect(() => { if (prevKeyRef.current === configKey) return; prevKeyRef.current = configKey; setReady(false); setError(null); const instance = new MetonaSqlark(config); instance.init() .then(() => { setDb(instance); setReady(true); }) .catch(setError); return () => { instance.close(); }; }, [configKey]); return { db, ready, error }; } export { useDatabase, useQuery, useTable }; //# sourceMappingURL=react.js.map