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:
Vendored
+209
-16
@@ -14066,6 +14066,34 @@ class QueryExecutor {
|
||||
// 三条都会静默返回空集(`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);
|
||||
@@ -15361,23 +15389,33 @@ class QueryExecutor {
|
||||
* 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 = 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
|
||||
|
||||
Reference in New Issue
Block a user