docs(G6): 宣称与实现一致性收口 —— priority 真正生效、MVCC/backup/引擎数/打包 全部对齐

独立核验(12 条宣称逐条对源码验证)发现 5 处**硬伤**与 2 处**数字过期**,
本提交按"能改代码就让宣称成立、改不动就如实描述"的原则全部收口。

让实现符合文档(2 处):
1. **插件 priority 此前不生效** — `register()` 虽按 priority 插入数组,但
   `install()` 在 register 内**立即**执行,因此 install 与钩子顺序 = config 数组
   顺序(实测 priority low=1/high=100/mid=50 时钩子按 low→high→mid 触发,
   只有 `getPlugins()` 是 high,mid,low)。而 README/CONTRIBUTING/constants
   一直宣称"越大越先执行"。
   现在 Core 注册前按 priority **稳定降序**排序(同优先级保持数组顺序),
   install 与钩子都按优先级执行 → 宣称成立。新增
   `tests/v080-plugin-priority.test.ts` 锁定 install 顺序、钩子顺序、稳定性、缺省值。
2. **连接池静态方法不在类型系统里** — `MetonaSqlark.connect/disconnect/
   disconnectAll/getActiveConnections` 由 connection-manager 用
   `as unknown as Record<string, unknown>` 注入,README 的连接池表格在
   TypeScript 下全部 TS2339。现在在类上声明为可选静态成员,注入处去掉断言。

如实描述(3 处):
3. **MVCC 快照隔离**(README 三处 + 实现对照)— `snapshotLsn` / `prevVersion`
   只写不读,事务读走 `txnSnapshot`+LSM,commit 即清理版本链,并发
   `beginTransaction` 抛 `TX_ACTIVE`。改为"快照回滚(事务串行,非 MVCC 隔离)",
   并在 README 架构图与维护语句表里同步措辞。
4. **"存储引擎(5 种)"** — 实际是 4 种模式 + 3 种后端,引擎类只有 4 个
   (Memory / KVStore / Hybrid / Aria),OPFS 是后端而非引擎。标题与条目已改写,
   并写明"`disk`/`hybrid` 恒用 KVStore"。
5. **`diskEngine` 生效范围** — 仅 `mode:'aria'` 生效;`constants.ts` 的注释
   此前写成"仅 mode='disk'|'hybrid' 时生效"(正好写反),已改正;README 配置表、
   快速开始示例与 Aria 示例同步标注。

数字口径统一(可复现):
- 测试 1872(90 套件)+ 14 e2e,另 4 个重型套件在独立 CI job 串行运行;
- 覆盖率 语句 90.43% / 分支 82.21% / 函数 94.27% / 行 93.44%;
- README 明确写出**产出这些数字的完整命令**(与 CI 常规 job 一致),
  并要求改动覆盖范围/阈值时同步更新表格(G5)。
- CHANGELOG 0.8.0 条目与 site 首页/文档页同步。

另修 **CONTRIBUTING 的钩子契约**:明确写出"返回值被忽略(不能取消/改写)、
就地改参数在 Table API 生效、抛异常可取消、SQL 路径的 beforeInsert 收到副本"
—— 此前只写 "allow intercepting",容易被理解为返回值可改变行为。

验证:全量 90 套件 / 1872 测试通过(+4 重型套件);覆盖率四项均高于阈值;
typecheck(src+tests)、lint、build 零错误零告警;e2e 14 项通过;dist 已重建。
This commit is contained in:
thzxx
2026-09-15 08:06:23 +08:00
parent d14663ef80
commit 799560ea05
26 changed files with 35817 additions and 106 deletions
+209 -16
View File
@@ -14066,6 +14066,34 @@ class QueryExecutor {
// 三条都会静默返回空集(`oops` 在 SQL 里是列引用,因为没有别的字面量形态)。
// 这正是"未解析引用静默变 false"这一整类缺陷(PLAN §3 根因 7)的最后一块。
await this.assertWhereColumnsExist(stmt, isJoinQuery);
// v0.8.0B-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);
@@ -15361,23 +15389,33 @@ class QueryExecutor {
* v0.3.3: ORDER BY 是否引用 SELECT 别名 `SELECT name AS n ... ORDER BY n`
* 别名列在引擎层投影前不存在需投影后重新排序
*/
/**
* v0.8.0B-5SELECT 列表产出的**别名集合**`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 = new Set();
for (const col of stmt.columns) {
const m = col.match(/\s+AS\s+(\w+)$/i);
if (m)
aliases.add(m[1]);
else if (/^\s*CASE\b/i.test(col)) {
const expr = parseCaseExpression(col);
if (expr?.alias)
aliases.add(expr.alias);
}
}
const aliases = this.selectAliasNames(stmt);
if (aliases.size === 0)
return false;
return stmt.orderBy.some((o) => aliases.has(o.column));
return stmt.orderBy.some((o) => aliases.has(unquoteIdentifier(o.column)));
}
/** WHERE 是否包含 CASE WHEN 表达式键 */
whereHasCase(where) {
@@ -16892,8 +16930,14 @@ class MetonaSqlark {
return result;
}
/**
* v0.5.1: 在线备份 导出全库一致性快照
* Aria 引擎走引擎级 backup()MVCC 一致性视图其余引擎回退 exportAll()
* v0.5.1: 在线备份 导出全库数据
*
* v0.8.0 修正表述此前注释与 README 宣称"全库**一致性**快照"但引擎层
* 并没有跨表快照原语 实现是**逐表读取**Aria 走引擎级 `backup()`
* 其余引擎回退 `exportAll()`备份过程中的并发写入会让不同表来自不同
* 时间点单表内部仍是一致的需要强一致时先 `close()`或用
* `db.transaction()` 包住调用事务期间并发写被 `TX_ACTIVE` 拒绝
* 真正的跨表快照需要 COW 行所有权改造列入后续版本
*/
async backup() {
this.ensureReady();
@@ -17225,7 +17269,7 @@ class ConnectionManager {
await db.init();
this.connections.set(name, db);
this.refCount.set(name, 1);
// 注入 disconnect 方法
// 注入 disconnect 方法(类型已在 MetonaSqlark 上声明,无需 any 断言)
db.disconnect = async () => {
await this.release(name);
};
@@ -17290,6 +17334,155 @@ M.disconnect = (dbName) => manager.release(dbName);
M.disconnectAll = () => manager.closeAll();
M.getActiveConnections = () => manager.getActiveConnections();
/**
* migrateFromIndexedDB IndexedDB 数据迁移到自研 KV 引擎
* @module migration/index
*
* v0.6.0: IndexedDB 从引擎中完全移除后提供一次性迁移工具把旧库数据
* 导入新引擎KVStoreEngine disk 模式 / AriaEngine
*
* 旧库命名
* - disk 模式IndexedDBEngine库名 = dbName
* - aria 模式IndexedDBBackend库名 = `aria-${dbName}`
*
* 仅此模块保留原生 IndexedDB 读取代码一次性迁移用途不参与运行时
*/
/** 旧库中持久化 schema 的 store 名(IndexedDBEngine v0.3.2+ */
const SCHEMA_STORE = '__metona_schema';
function inferFieldType(value) {
if (typeof value === 'number')
return 'number';
if (typeof value === 'boolean')
return 'boolean';
if (typeof value === 'object' && value !== null)
return 'json';
return 'string';
}
/** 从样例行推断 schema(旧库无持久化 schema 时回退) */
function inferSchema(tableName, rows) {
const columns = {};
if (rows.length === 0)
return { name: tableName, columns };
const first = rows[0];
const keys = Object.keys(first);
// v0.7.3: 主键推断 —— 优先 id;无 id 列时取第一个非 json 类型列(json 列
// String() 化为 "[object Object]" 会致所有行主键冲突)。全 json 列无可用
// 主键 → 返回 null(调用方跳过该表),此前直接抛 SCHEMA_ERROR 中断整个迁移。
const pkKey = keys.includes('id')
? 'id'
: keys.find((k) => inferFieldType(first[k]) !== 'json');
if (!pkKey)
return null;
for (const key of keys) {
columns[key] = {
type: inferFieldType(first[key]),
primaryKey: key === pkKey,
};
}
return { name: tableName, columns };
}
/** 打开旧 IndexedDB 库(只读) */
function openLegacyDB(idbName) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(idbName);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error(`Failed to open legacy IndexedDB "${idbName}"`));
});
}
/** 读取 object store 全部记录 */
function readAllRecords(store) {
return new Promise((resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => resolve((req.result ?? []));
req.onerror = () => reject(req.error);
});
}
/** 读取持久化 schema 记录 */
function readSchemas(db) {
if (!db.objectStoreNames.contains(SCHEMA_STORE)) {
return Promise.resolve({});
}
return new Promise((resolve, reject) => {
const req = db.transaction(SCHEMA_STORE, 'readonly').objectStore(SCHEMA_STORE).getAll();
req.onsuccess = () => {
const result = {};
for (const rec of (req.result ?? [])) {
if (!rec.schema)
continue;
try {
const schema = JSON.parse(rec.schema);
result[schema.name] = schema;
}
catch { /* 损坏记录跳过 */ }
}
resolve(result);
};
req.onerror = () => reject(req.error);
});
}
/**
* 将旧 IndexedDB 库迁移到目标引擎
* @returns 迁移结果/行数统计
*/
async function migrateFromIndexedDB(opts) {
// v0.6.0: aria 旧库为引擎私有格式(SSTable/WAL),不支持按行迁移(运行时防御)
if (opts.engine === 'aria') {
throw new Error('Migration from AriaEngine IndexedDB backend is not supported ' +
'(data is stored in engine-private SSTable/WAL format). ' +
'Only disk-mode IndexedDBEngine databases can be migrated.');
}
const idbName = opts.dbName;
let db = null;
try {
db = await openLegacyDB(idbName);
}
catch (error) {
throw new Error(`Legacy IndexedDB database "${idbName}" not found or unreadable: ${error.message}`);
}
const result = { migratedTables: [], rowCount: 0, skippedTables: [] };
const schemas = await readSchemas(db);
try {
const storeNames = Array.from(db.objectStoreNames).filter((n) => n !== SCHEMA_STORE);
for (let i = 0; i < storeNames.length; i++) {
const tableName = storeNames[i];
opts.onProgress?.(i, storeNames.length, tableName);
const rows = await readAllRecords(db.transaction(tableName, 'readonly').objectStore(tableName));
// 表已存在于目标库 → 跳过(避免覆盖)
const names = await opts.target.getTableNames();
if (names.includes(tableName)) {
result.skippedTables.push(tableName);
continue;
}
// schema:持久化优先,否则从数据推断(空表且无 schema → 跳过)
let schema = schemas[tableName];
if (!schema) {
if (rows.length === 0) {
result.skippedTables.push(tableName);
continue;
}
const inferred = inferSchema(tableName, rows);
// v0.7.3: 全 json 列推断无主键 → 跳过该表(此前抛 SCHEMA_ERROR 中断迁移)
if (!inferred) {
result.skippedTables.push(tableName);
continue;
}
schema = inferred;
}
// 写入目标引擎
await opts.target.defineTable(tableName, schema.columns);
if (rows.length > 0) {
await opts.target.table(tableName).insertMany(rows);
}
result.migratedTables.push(tableName);
result.rowCount += rows.length;
}
}
finally {
db.close();
}
return result;
}
/**
* metona-sqlark 入口文件
* @module metona-sqlark
@@ -17341,5 +17534,5 @@ if (typeof window !== 'undefined') {
// 别名
const MeSqlark = MetonaSqlark;
export { AriaEngine, DatabaseError, HybridEngine, KVStoreEngine, MeSqlark, MemoryEngine, MetonaSqlark, OPFSBackend, PluginManager, Table, Transaction, TransactionManager, VERSION, api, bindParameters, create, api as default, parse, parseAll, parseWhereCondition, tokenize };
export { AriaEngine, DatabaseError, HybridEngine, KVStoreEngine, MeSqlark, MemoryEngine, MetonaSqlark, OPFSBackend, PluginManager, Table, Transaction, TransactionManager, VERSION, api, bindParameters, create, api as default, migrateFromIndexedDB, parse, parseAll, parseWhereCondition, tokenize };
//# sourceMappingURL=metona-sqlark.esm.js.map