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 -15
View File
@@ -14070,6 +14070,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);
@@ -15365,23 +15393,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) {
@@ -16896,8 +16934,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();
@@ -17229,7 +17273,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);
};
@@ -17294,6 +17338,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
@@ -17362,6 +17555,7 @@ exports.api = api;
exports.bindParameters = bindParameters;
exports.create = create;
exports.default = api;
exports.migrateFromIndexedDB = migrateFromIndexedDB;
exports.parse = parse;
exports.parseAll = parseAll;
exports.parseWhereCondition = parseWhereCondition;
+1 -1
View File
File diff suppressed because one or more lines are too long
+67 -3
View File
@@ -819,6 +819,13 @@ declare class QueryExecutor {
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
* 别名列在引擎层投影前不存在,需投影后重新排序。
*/
/**
* v0.8.0B-5):SELECT 列表产出的**别名集合**`AS x` 与 `CASE ... AS x`)。
*
* 与 `orderByUsesSelectAlias` 共用同一套识别规则 —— 两处若各写一份,
* 会出现"排序认为它是别名、校验认为它是列"的矛盾(本项目反复出现的漂移模式)。
*/
private selectAliasNames;
private orderByUsesSelectAlias;
/** WHERE 是否包含 CASE WHEN 表达式键 */
private whereHasCase;
@@ -1187,6 +1194,16 @@ interface ChangeEvent {
declare class MetonaSqlark {
/** 数据库名称 */
readonly name: string;
/**
* v0.8.0:释放一个连接引用(引用计数 -1,归零时自动关闭)。
*
* 由 `MetonaSqlark.connect()` 注入实现 —— 此前该方法是**运行时注入、类型上不存在**:
* README 与示例都在用 `await db.disconnect()`,但 `db` 的声明里没有它,
* TypeScript 使用者会直接编译失败(只能 `as any` 绕过)。
* 普通 `create()` 得到的实例没有这个方法,因此为可选:
* 只有经 `connect()` 取得的实例才有,直接调用会抛错(而不是静默无操作)。
*/
disconnect?: () => Promise<void>;
/**
* v0.7.1: 静态工厂(与 connect/disconnect 同一入口风格)。
* 此前 create 仅存在于 api 对象 / window 挂载 —— README/站点示例的
@@ -1292,8 +1309,14 @@ declare class MetonaSqlark {
/** 导出整个数据库为 JSON */
exportAll(): Promise<Record<string, Record<string, unknown>[]>>;
/**
* v0.5.1: 在线备份 — 导出全库一致性快照
* Aria 引擎走引擎级 backup()(MVCC 一致性视图);其余引擎回退 exportAll()。
* v0.5.1: 在线备份 — 导出全库数据
*
* v0.8.0 修正表述:此前注释与 README 宣称"全库**一致性**快照",但引擎层
* 并没有跨表快照原语 —— 实现是**逐表读取**(Aria 走引擎级 `backup()`
* 其余引擎回退 `exportAll()`)。备份过程中的并发写入会让不同表来自不同
* 时间点(单表内部仍是一致的)。需要强一致时先 `close()`,或用
* `db.transaction()` 包住调用(事务期间并发写被 `TX_ACTIVE` 拒绝)。
* 真正的跨表快照需要 COW 行所有权改造,列入后续版本。
*/
backup(): Promise<Record<string, Record<string, unknown>[]>>;
/** 变更通知引擎(init 后可用);未初始化时为 null */
@@ -2085,6 +2108,47 @@ declare class OPFSBackend implements IStorageBackend {
clear(): Promise<void>;
}
/**
* migrateFromIndexedDB — 旧 IndexedDB 数据迁移到自研 KV 引擎
* @module migration/index
*
* v0.6.0: IndexedDB 从引擎中完全移除后,提供一次性迁移工具把旧库数据
* 导入新引擎(KVStoreEngine disk 模式 / AriaEngine)。
*
* 旧库命名:
* - disk 模式(IndexedDBEngine):库名 = dbName
* - aria 模式(IndexedDBBackend):库名 = `aria-${dbName}`
*
* 仅此模块保留原生 IndexedDB 读取代码(一次性迁移用途,不参与运行时)。
*/
interface MigrationOptions {
/** 旧库名(业务名,不含 aria- 前缀) */
dbName: string;
/**
* 旧引擎类型:仅支持 diskIndexedDBEngine,每表一个 objectStore,行数据可直接读取)。
* aria 旧库(IndexedDBBackend)数据为引擎私有格式(SSTable/WAL),无法按行迁移。
*/
engine: 'disk';
/** 目标数据库实例(已初始化,新引擎) */
target: MetonaSqlark;
/** 进度回调 */
onProgress?: (done: number, total: number, table?: string) => void;
}
interface MigrationResult {
/** 已迁移的表 */
migratedTables: string[];
/** 迁移的行总数 */
rowCount: number;
/** 跳过(无 schema 且无数据)的表 */
skippedTables: string[];
}
/**
* 将旧 IndexedDB 库迁移到目标引擎。
* @returns 迁移结果(表/行数统计)
*/
declare function migrateFromIndexedDB(opts: MigrationOptions): Promise<MigrationResult>;
/**
* metona-sqlark — 入口文件
* @module metona-sqlark
@@ -2130,4 +2194,4 @@ declare global {
declare const MeSqlark: typeof MetonaSqlark;
export { AriaEngine, AriaEngineConfig, ColumnDef, DatabaseConfig, DatabaseError, DeleteStatement, DiskEngine, FieldType, HookCallback, HookName, HybridEngine, IStorageEngine, InsertStatement, KVStoreEngine, MeSqlark, MemoryEngine, MetonaPlugin, MetonaSqlark, OPFSBackend, OrderBy, PluginManager, QueryPlan, SelectStatement, Statement, StorageMode, Table, TableSchema, Transaction, TransactionManager, UpdateStatement, VERSION, WhereCondition, WhereOperator, api, bindParameters, create, api as default, parse, parseAll, parseWhereCondition, tokenize };
export { AriaEngine, AriaEngineConfig, ColumnDef, DatabaseConfig, DatabaseError, DeleteStatement, DiskEngine, FieldType, HookCallback, HookName, HybridEngine, IStorageEngine, InsertStatement, KVStoreEngine, MeSqlark, MemoryEngine, MetonaPlugin, MetonaSqlark, MigrationOptions, MigrationResult, OPFSBackend, OrderBy, PluginManager, QueryPlan, SelectStatement, Statement, StorageMode, Table, TableSchema, Transaction, TransactionManager, UpdateStatement, VERSION, WhereCondition, WhereOperator, api, bindParameters, create, api as default, migrateFromIndexedDB, parse, parseAll, parseWhereCondition, tokenize };
+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
+1 -1
View File
File diff suppressed because one or more lines are too long
+209 -15
View File
@@ -14072,6 +14072,34 @@
// 三条都会静默返回空集(`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);
@@ -15367,23 +15395,33 @@
* 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) {
@@ -16898,8 +16936,14 @@
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();
@@ -17231,7 +17275,7 @@
await db.init();
this.connections.set(name, db);
this.refCount.set(name, 1);
// 注入 disconnect 方法
// 注入 disconnect 方法(类型已在 MetonaSqlark 上声明,无需 any 断言)
db.disconnect = async () => {
await this.release(name);
};
@@ -17296,6 +17340,155 @@
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
@@ -17364,6 +17557,7 @@
exports.bindParameters = bindParameters;
exports.create = create;
exports.default = api;
exports.migrateFromIndexedDB = migrateFromIndexedDB;
exports.parse = parse;
exports.parseAll = parseAll;
exports.parseWhereCondition = parseWhereCondition;
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+17293
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
Vendored
+17291
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long