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
+115
View File
@@ -2,6 +2,121 @@
All notable changes to MetonaSqlark will be documented in this file.
## [0.8.0] - 2026-08-16
### 根治性迭代 —— 统一语义 / 消灭复发结构 / 验证基础设施
> 依据 `PLAN-v0.7.5.md` 的三条并行工作流(A 缺陷修复 / B 结构根治 / C 验证基础设施)
> 完成的一次系统性迭代。**这一版的重点不是"再修一批 bug",而是砍掉让同类 bug
> 必然复发的结构**:多处并存的语义实现被收敛为唯一实现,并第一次让崩溃语义、
> 错误码一致性、入口等价性变成可机器验证的门禁。
>
> 测试规模 1304 → **187290 套件)+ 14 项 e2e**(另 4 个重型套件在独立 CI
> job 串行运行)。
### 工作流 B · 结构根治(消除整类缺陷)
- **B-1 唯一校验 choke point** — 此前有**三份**行校验实现,覆盖面各不相同
memory 一份缺 `maxLength`/`min`/`max`Aria 一份有,schema.ts 第三份):
同一份 schema、同一条 INSERT 是否报错取决于选了哪个引擎(A12);四份实现
对未知列一律静默丢弃(A17:INSERT 报成功、`SELECT nope` 报 COLUMN_NOT_FOUND)。
现收敛为 `src/table/validation.ts#compileValidator` 唯一实现,四个引擎新增
`validatePayload` 契约,校验先于任何副作用;未知列、`NaN`/±Infinity
(JSON 无法表示,落盘会变 null)显式拒绝。
- **B-2 唯一值比较与编码** — `sqlCompare`/`sqlCompareOrder`/`encodeValueKey`
成为唯一原语;GROUP BY 键、DISTINCT 键、UNION 去重、聚合去重全部改用它
(此前四份编码并存,对 null/undefined 处理各不相同)。
- **B-3 单管线** — QueryBuilder 此前**自己执行**:无 JOIN 时直通 `engine.find`
写操作直通 `engine.update/delete`,于是"同一条语义"在 TABLE API 与 SQL API
两条路径上规则各写一份(投影、列校验、LIMIT 下推、`maxRowsPerQuery` 全缺失;
`$subquery` 无人解析 → 静默影响 0 行)。现在 builder 只产出 AST,执行一律经
Executor;生命周期钩子由 `Table` 注入、顺序与传参不变。
→ 新增 `tests/v080-single-pipeline.test.ts`(两入口逐值等价,四引擎)。
- **B-4 统一表达式求值** — CASE 此前用**正则**切分 WHEN/THEN/ELSE,不认字符串
字面量与嵌套:嵌套 CASE 返回字符串残片 `"big' END ELSE 'small"`;条件引用
不存在的列时静默把整列变成 ELSE 值;`GROUP BY CASE ... END` 完全不可用
(报"未知列 CASE WHEN ...")。现复用 `sql/lexer` 的 token 流做递归下降,
条件交给与 WHERE 相同的解析器,无法识别的表达式显式报错。
→ 顺带修正 `Token.position` 语义按类型不一致的缺陷(字符串 token 指向引号之内,
导致按位置切片少一个字符)。
- **B-5 输出列序号 + 分隔标识符** — `ORDER BY 1` / `GROUP BY 2` 此前直接
`PARSE_ERROR``SELECT "1"`(列名就叫 1)被当成**常量 1**(与 `SELECT *` 结论相反)。
现支持输出列序号(越界、`ORDER BY 0``GROUP BY <聚合列>` 各自显式报错),
并统一分隔标识符语义:引号只在"解析→执行"边界脱去。
顺带补上 **ORDER BY 的列存在性/歧义校验**(此前 JOIN 里裸写两表同名列既不报错
也不确定按哪列排)。
- **B-6 存储提交点(KVStore** — 两处 P0:① `open()` 遇损坏日志尾部会**清空整个
日志**(写 3 条 → 第 4 条撕裂 → 重开可见 → 再重开全空);② 自动 checkpoint
失败会让**已确认写入**报错(而该写入已在 WAL 中,报错与事实相反)。另修陈旧实例
的 checkpoint 会**静默抹掉**新实例写入(现抛 `STALE_INSTANCE` 拒绝提交)。
### 工作流 A · 缺陷修复(24 项,含 6 项事故级)
- **A15 三值逻辑** — `= NULL` 命中 NULL 行、`!= NULL` 返回所有非 NULL 行、
`NOT LIKE` 把 NULL 判真、**`NOT BETWEEN 1 AND 2` 恒空集**(字段级 `$or` 递归进了
where 子句级求值器)。现 WHERE 只有**一个**递归求值器;`IS NULL`/`IS NOT NULL`
是与比较不同的**谓词**(此前与 `= NULL` 共用同一 AST,语义无法区分)。
- **A9/A10 发布订阅与关联子查询** — `subscribe()` 对本地写入永不触发(仅跨标签页
广播);`WHERE t.x = t.y``WHERE id IN (SELECT ... WHERE o.user_id = u.id)`
静默空结果(引擎层预过滤把逐行谓词判 UNKNOWN → 候选行 0)。
- **A13 自引用外键** — `parent_id REFERENCES node(id)` 的级联被整体跳过:
`DELETE root` 只删根,子树**永久悬挂**(父行已不在,再也无法级联清理)。
`ON DELETE SET NULL` / `ON UPDATE CASCADE` / RESTRICT 预检同样失效。
- **A22/A23/A25/A26/A27/A29/A30/A36 查询层** — GROUP BY 别名、HAVING 未选中聚合、
带前缀聚合参数恒 0、UNION 尾部子句归属、DISTINCT 作用于输出列、
`maxRowsPerQuery` 静默截断写入、INSERT 值多于列、派生表别名引用。
- **A37 列引用** — 未限定列不能作比较操作数(`WHERE x = y` 报 PARSE_ERROR);
`$col` 引用不存在的列**静默返回空集**。
- **A38/A39 Aria 存储** — `compression` 在页面化路径(默认)被静默忽略;
`compressLZ4` 匹配搜索 O(n²)60KB 伪随机 2345ms → 6ms**390×**)。
- **A41 DDL 原子性** — DDL 的 WAL 意图记录写在生效**之后**:`dropTable` 后崩溃 →
重开表又回来了(DROP 被静默撤销);`alterTable` 完全不写 WAL,崩溃丢失结构变更。
现统一为"先写 WAL 意图并刷盘 → 再改内存 → 最后落盘 schema"。
### 工作流 C · 验证基础设施(让门禁真的能拦)
- **真崩溃注入(PC-2** — e2e 的 `crashPage()` 此前只是 `page.close()`
(**优雅关闭**),所有"崩溃恢复"用例测的其实是"正常关闭后重开"。现改用 CDP
`Page.crash` 终止渲染进程,并新增两个真实窗口:`createWritable().write()` 中途、
`close()` 原子替换前(copy-on-write 的核心不变量)。
- **覆盖率门禁真正生效** — `collectCoverageFrom` 不再排除实现文件
(此前 `!src/**/index.ts` 把 2282 行的 AriaEngine 整文件排除在统计外,
于是"90.1% 行覆盖率"是虚高的口径);新增 `coverageThreshold`
statements 90 / branches 82 / functions 94 / lines 93);CI 常规 job 带
`--coverage`lint 去掉 `continue-on-error`;新增 `tests/` 类型检查
(修复 **103 个**被 babel 剥离类型掩盖的测试类型错误);CI 校验 dist 与源码同步。
→ 实测 Statements 90.43% / Branches 82.21% / Functions 94.27% / Lines 93.44%
(命令与 CI 常规 job 完全一致,可复现)。
- **测试介质忠实性修正**(两处同源缺陷,此前让所有多实例/多库验证跑在错误语义上)
- `SharedMemoryBackend` 的读缓存是每实例私有的 → 介质退化为"每实例一份快照",
跨实例写入不可见(这正是陈旧实例覆盖新实例写入那条 bug 起初查不出来的原因);
- OPFS mock 把 `getDirectoryHandle(name)` 的库名**丢弃** → 所有库共用一棵文件树
`open('db-beta')` 能看到 `db-alpha` 的表)。
- **变异验证成为回归套件的标准做法** — 把修复回退到修复前的行为,对应用例必须
失败。本版 A15 / B-6(①②③) / A13 / A37 / A38 / A39 / A41 / B-4 / B-5 全部通过该检查
——这是"测试真能拦住回归"与"测试只是陪跑"的分界线。
### 文档与宣称同步(G6
- 修正 README 全部**不成立的能力宣称**:覆盖率数字改为**四个准确数字 + 明确口径**;
测试规模与套件数更新为实测值;"5 种存储引擎"改为"4 种模式 + 3 种后端"
`backup()` 由"在线一致性快照"改为"全库导出(逐表读取)"并写入已知限制
(引擎层没有跨表快照原语);`db.disconnect()` 真正进入类型系统
(此前仅运行时注入,TypeScript 使用者编译失败)。
- **打包缺陷修复**`package.json``./migration` 子路径此前指向的产物里
**没有** `migrateFromIndexedDB`(主入口未导出该函数)→ 现主入口导出 + 子路径可用;
`./react` / `./vue` 此前指向**裸 TS 源码**且声明类型为 `any`
→ 现构建 `dist/react.js` / `dist/vue.js` 并配套**手写精确类型声明**
`peerDependencies`react / vue,均可选)。
### 已知限制(v0.8.0 新增/变更)
- `backup()` 不是跨表一致性快照(逐表读取)
- 复合主键仍不支持(建表时 `SCHEMA_ERROR`
- 简单 CASE 形式(`CASE <表达式> WHEN <值>`)不支持,仅支持搜索式
`CASE WHEN <条件> THEN ...`
## [0.7.4] - 2026-08-15
### 写语句子查询 / 约束硬化 / 真惰性流式
+13 -2
View File
@@ -134,7 +134,16 @@ MetonaSqlark follows a layered architecture:
2. **Write-Through Hybrid Strategy**: When using `mode: 'hybrid'`, all writes go to both memory and disk simultaneously. Reads always hit memory for maximum speed.
3. **Plugin Hook Pipeline**: 14 lifecycle hooks allow intercepting database operations without modifying core code.
3. **Plugin Hook Pipeline**: 14 lifecycle hooks let plugins observe and adjust database
operations without modifying core code. **Hook contract (v0.8.0)**:
- return values are **ignored** (you cannot cancel an operation by returning `false`,
nor rewrite the SQL by returning a string);
- **mutate the argument object in place** to change it — this works on the Table API
path (`db.table(...).insert(rows)`);
- **throw** to abort the operation (the error propagates to the caller);
- the SQL path passes a **copy** for `beforeInsert`, so mutating it there has no effect.
Use the Table API when you need to transform rows.
These rules are covered by tests; changing them requires updating this section.
4. **Hand-Written SQL Parser**: No dependencies on parser generators — a recursive-descent parser keeps the bundle size minimal.
@@ -163,7 +172,9 @@ const myPlugin: MetonaPlugin = {
name: 'myPlugin',
version: '1.0.0',
description: 'Description of my plugin',
priority: 50, // higher = executed first
// higher = executed firstv0.8.0 起真正生效:install 与钩子都按优先级降序;
// 同优先级保持 plugins 数组顺序)
priority: 50,
install(db) {
// Use db.on() to subscribe to hooks
+66 -27
View File
@@ -1,14 +1,16 @@
# MetonaSqlark
<p align="center">
<img src="https://img.shields.io/badge/version-0.7.4-blue?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-0.8.0-blue?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
<img src="https://img.shields.io/badge/coverage-90.1%25-brightgreen?style=flat-square" alt="coverage">
<img src="https://img.shields.io/badge/tests-1304%20passed-success?style=flat-square" alt="tests">
<img src="https://img.shields.io/badge/coverage-90.43%25%20stmts-brightgreen?style=flat-square" alt="coverage">
<img src="https://img.shields.io/badge/tests-1872%20passed-success?style=flat-square" alt="tests">
</p>
> 基于 TypeScript 的**前端关系型数据库**:完整 SQL + Query Builder 双 API
> 5 种存储引擎可选,内置自研 LSM-Tree 存储引擎(AriaEngine)。
> 4 种存储模式(memory / disk / hybrid / aria+ 3 种后端(OPFS / KVStore / Memory)可选,
> `aria` 模式内置自研 LSM-Tree 存储引擎(AriaEngineWAL + 页面化 + 可选压缩/加密,
> 事务用未提交快照回滚)。
> 零运行时依赖,浏览器 / Node.js 开箱即用。
---
@@ -35,18 +37,21 @@
- **完整 SQL** — SELECTJOIN / 子查询 / 派生表 / UNION / GROUP BY / HAVING / DISTINCT / CASE WHEN / EXISTS / BETWEEN / NULLS 排序)、INSERT...SELECT、UPDATE/DELETE 子查询、ALTER TABLE、TRUNCATE TABLE、CREATE INDEX、事务语句(BEGIN / COMMIT / ROLLBACK / SAVEPOINT)、维护语句(EXPLAIN / ANALYZE / REINDEX / VACUUM
- **Query Builder** — 链式 `.select().where().innerJoin().orderBy().limit().execute()`,类型安全
- **流式查询** — `queryStream` / `table().stream()` 逐行回调,Aria 引擎真惰性扫描(limit 提前终止,不物化结果集)
- **事务** — 四引擎事务原子性 + 自动回滚;Aria 用 MVCC 快照隔离,事务读写不互斥
- **事务** — 四引擎事务原子性 + 自动回滚;Aria 用引擎级事务 + 未提交快照(`txnSnapshot`)回滚。**同一实例同时只允许一个事务**(并发 `beginTransaction``TX_ACTIVE`)—— 不是 MVCC 快照隔离
- **外键级联** — `ON DELETE` / `ON UPDATE` 支持 `CASCADE` / `SET NULL` / `RESTRICT`(含主键变更级联、级联环路保护)
- **数据迁移** — 版本化迁移(持久化到库内,重启不重跑)、导入导出、在线一致性备份
- **数据迁移** — 版本化迁移(持久化到库内,重启不重跑)、单表/全库导入导出、`backup()` 全库导出(逐表读取,非跨表快照 —— 见「已知限制」)
- **连接池** — `MetonaSqlark.connect()` 单例复用,引用计数自动关闭
**存储引擎(5 种)**
**存储模式(4 种)+ 存储后端(3 种)**
- `memory` — 纯内存,测试 / 缓存
- `disk` — 自研 **KVStore 事务引擎**v0.6.0 起替代 IndexedDB):多 key 原子写、快照 + 日志崩溃恢复
- `hybrid` — write-through 双写,读走内存
- `aria` — 自研 **AriaEngine**LSM-Tree + WAL + MVCC),后端可选 OPFS / KVStore / Memory
- **KVStore** — 日志结构化事务 KV 引擎,可独立用作 aria 后端
- `aria` — 自研 **AriaEngine**LSM-Tree + WAL + 页面化 + 可选压缩/加密),
后端可选 `diskEngine: 'opfs' | 'kv' | 'memory'`
> `disk` 与 `hybrid` 的磁盘侧**恒用自研 KVStore**`diskEngine` 项只对 `mode: 'aria'`
> 生效,其余模式忽略该配置 —— 已在类型注释中写明)。
**生产级可靠性**
@@ -58,8 +63,11 @@
**生态**
- **React / Vue 集成** — `useQuery` / `useSqlarkQuery`开箱即用 hooks
- **插件系统** — 14 种生命周期钩子(beforeInsert / afterQuery / ...),按优先级注册
- **React / Vue 集成** — `useQuery` / `useSqlarkQuery` 等 hooks(构建为 `dist/react.js` /
`dist/vue.js`,配套手写精确类型声明;`react` / `vue` 为**可选 peer dependency**
不装也不影响核心库使用)
- **插件系统** — 14 种生命周期钩子(beforeInsert / afterQuery / ...),
`priority` 越大越先执行(同优先级保持注册顺序)
- **旧库迁移** — `migrateFromIndexedDB()` 一键把旧 IndexedDB 库导入新引擎
- **浏览器兼容** — Chrome 102+ / Firefox 111+ / Safari 15.2+ / Edge 102+ / Node.js 16+
@@ -96,7 +104,8 @@ import { MetonaSqlark } from '@metona-team/metona-sqlark';
const db = await MetonaSqlark.create({
name: 'my-app',
mode: 'hybrid', // 'memory' | 'disk' | 'hybrid' | 'aria'
diskEngine: 'opfs', // 'opfs' | 'kv'(自研 KVStore 后端)| 'memory'
// 仅 mode:'aria' 时生效(Aria 的存储后端);disk/hybrid 恒用自研 KVStore
diskEngine: 'opfs', // 'opfs' | 'kv' | 'memory'
});
// 定义表 — 支持约束与外键级联
@@ -178,7 +187,7 @@ await db2.disconnect(); // 引用计数 -1
|------|------|------|------|
| `name` | `string` | `'metona-sqlark'` | 数据库名称 |
| `mode` | `'memory' \| 'disk' \| 'hybrid' \| 'aria'` | `'hybrid'` | 存储模式 |
| `diskEngine` | `'opfs' \| 'memory' \| 'kv'` | `'opfs'` | 磁盘引擎(aria 模式下为存储后端;`'kv'` = 自研 KVStore |
| `diskEngine` | `'opfs' \| 'memory' \| 'kv'` | `'opfs'` | **仅 `mode: 'aria'` 生效**Aria 的存储后端;`'kv'` = 自研 KVStore)。`disk`/`hybrid` 恒用 KVStore,忽略此项 |
| `version` | `number` | `1` | 版本号 |
| `maxRowsPerQuery` | `number` | `0` | 查询结果行数上限(0 = 不限) |
| `debug` | `boolean` | `false` | 调试模式 |
@@ -227,7 +236,7 @@ await db2.disconnect(); // 引用计数 -1
| `db.clearAll()` | 清空全部数据与表结构(保留库本身) |
| `db.exportTable(name)` / `db.exportAll()` | 导出数据 JSON |
| `db.importTable(name, data)` | 导入数据 |
| `db.backup()` | 在线备份:全库一致性快照 |
| `db.backup()` | 导出全库数据(**逐表读取**,见下方"已知限制" |
| `db.addMigration(v, fn)` / `db.migrateTo(v)` | 版本化数据迁移(版本持久化,重启不重跑) |
| `db.subscribe(table, fn)` | 订阅表变更(返回退订函数) |
| `db.on(hook, fn)` | 注册生命周期钩子(14 种) |
@@ -240,7 +249,7 @@ await db2.disconnect(); // 引用计数 -1
| `EXPLAIN SELECT ...` | 输出查询计划(type/table/where/usingIndex/estimatedRows/actualTimeMs | 全部 |
| `ANALYZE [TABLE] name` | 收集表统计信息(行数/行大小/索引深度/列基数) | Aria |
| `REINDEX [TABLE] name` | 重建表二级索引 | Aria |
| `VACUUM` | 压缩 LSM + 清理 MVCC 碎片 | Aria |
| `VACUUM` | 压缩 LSM + 清理版本碎片 | Aria |
| `SAVEPOINT name` / `ROLLBACK TO name` / `RELEASE name` | 嵌套事务保存点 | Aria |
> 不支持的引擎执行维护语句抛 `NOT_SUPPORTED`。
@@ -250,7 +259,9 @@ await db2.disconnect(); // 引用计数 -1
> IndexedDB 已从引擎中完全移除。旧版本(v0.5.x 及更早)的 disk 模式用户可通过一次性迁移工具导入:
```typescript
// 两种等价写法(v0.8.0 起主入口也导出,避免深路径依赖)
import { migrateFromIndexedDB } from '@metona-team/metona-sqlark/migration';
// import { migrateFromIndexedDB } from '@metona-team/metona-sqlark';
const target = await MetonaSqlark.create({ name: 'my-app-new', mode: 'disk' });
const result = await migrateFromIndexedDB({
@@ -273,6 +284,12 @@ const result = await migrateFromIndexedDB({
| `MetonaSqlark.disconnectAll()` | 强制关闭所有连接 |
| `MetonaSqlark.getActiveConnections()` | 获取活跃连接列表 |
> **实现说明**:这四个静态方法由 `connection-manager` 模块在**模块加载时注入**
> (主入口 `src/index.ts` 以 side-effect 方式 `import './connection-manager'`),
> 因此从包入口引入即可用,无需额外操作。类型上(v0.8.0 起)声明为可选静态成员;
> 若在**未加载该模块**的自定义构建里调用,其值为 `undefined` 并抛 `TypeError`
> —— 不会静默无效。
---
## 存储引擎
@@ -280,7 +297,7 @@ const result = await migrateFromIndexedDB({
| 特性 | Memory | Disk (KVStore) | Hybrid | Aria |
|------|--------|----------------|--------|------|
| **持久化** | ❌ 重启丢失 | ✅ KVStore(OPFS / 内存介质) | ✅ 内存 + 磁盘 | ✅ 后端决定 |
| **事务** | ✅ 快照回滚 | ✅ 单日志记录原子写 | ✅ 双引擎(磁盘优先) | ✅ MVCC 快照隔离 |
| **事务** | ✅ 快照回滚 | ✅ 单日志记录原子写 | ✅ 双引擎(磁盘优先) | ✅ 快照回滚(事务串行,非 MVCC 隔离 |
| **二级索引** | ✅ Hash | ✅ Hash(重启恢复) | ✅ Hash | ✅ LSM(重启恢复) |
| **查询性能** | O(1) PK | O(1) PK(内存热路径) | O(1) PK | O(log n) |
| **数据上限** | 内存 | 磁盘可用 | 磁盘可用 | 内存 |
@@ -294,14 +311,14 @@ const result = await migrateFromIndexedDB({
- **临时数据 / 单元测试** → `memory`
- **标准前端持久化**(替代 IndexedDB)→ `disk`KVStore,多 key 原子事务,10 万级验证)
- **内存速度 + 磁盘持久化** → `hybrid`write-through,读走内存)
- **大规模 / 需要自研引擎可控性** → `aria`LSM-Tree + WAL + MVCC + 加密 + 页面化存储)
- **大规模 / 需要自研引擎可控性** → `aria`LSM-Tree + WAL + 加密 + 页面化存储)
---
## AriaEngine 自研存储引擎
AriaEngine 是内置的页面式存储引擎,对标 SQLite 的设计理念:
**LSM-Tree 索引 + WAL 崩溃恢复 + MVCC 事务 + 页面化物理存储 + 全库加密**
**LSM-Tree 索引 + WAL 崩溃恢复 + 快照回滚事务 + 页面化物理存储 + 全库加密**
```typescript
const db = await MetonaSqlark.create({
@@ -335,7 +352,7 @@ const rows = await db.query('SELECT * FROM users');
│ +SSTable │ +FileManager │ +CRC-32 │
│ (4KB 页面) │ │ +空洞检测 │
├────────────────┼───────────────┼────────────┤
MVCC 事务 │ Bloom Filter │ LZ4 压缩 │
事务/版本管理 │ Bloom Filter │ LZ4 压缩 │
│ 快照隔离 │ 二级索引 LSM │ +大小头 │
│ +Savepoint │ (每列独立) │ │
├────────────────┴───────────────┴────────────┤
@@ -355,11 +372,11 @@ const rows = await db.query('SELECT * FROM users');
| **WAL** | 分片文件 `__wal_%06d.bin` + 真追加;标准 CRC32 记录校验;full/batch/none 三模式;空洞检测截断;16MB 阈值自动 checkpoint(活跃事务期间不截断) |
| **崩溃恢复** | 打开时完整性校验(损坏 SSTable 自愈清理 + 整文件 CRC-32)、WAL 恢复、恢复后自动重建二级索引;`repair()` 清理孤儿页面与残留 |
| **全库加密** | `encryption.password` → EncryptedBackend 透明加解密(WAL/SSTable/Schema/元数据全密文);PBKDF2 派生 + salt 持久化;密码错误/篡改 → `ARIA_DECRYPT_ERROR` |
| **MVCC** | 版本链 + 快照隔离,事务读写不互斥,自动 GC |
| **MVCC** | 版本链仅作事务内 undo(提交即清理,**无快照隔离**;事务串行);自动 GC |
| **二级索引** | 每列独立 LSM Tree,支持等值/范围扫描,跨重启恢复,WAL 恢复后自动重建 |
| **Bloom Filter** | FNV-1a + Murmur 双哈希,SSTable footer 序列化,查询时快速否定 |
| **多标签页锁** | Web Locks 库级独占锁,第二个标签页抛 `ARIA_LOCKED`;不支持的环境降级无锁并告警 |
| **维护语句** | ANALYZE(表统计)/ REINDEX(重建索引)/ VACUUM(压缩 + MVCC GC/ EXPLAIN(查询计划) |
| **维护语句** | ANALYZE(表统计)/ REINDEX(重建索引)/ VACUUM(压缩 + 版本 GC/ EXPLAIN(查询计划) |
### AriaEngine 配置项
@@ -401,7 +418,7 @@ const { data, loading, error, refresh } = useSqlarkQuery(db, 'SELECT * FROM user
npm install # 安装依赖
npm run dev # 开发模式(localhost:3001
npm run build # 生产构建(生成 dist/
npm test # 运行测试(1304 用例 · 76 套件)
npm test # 运行测试(1872 用例 · 90 套件;+4 个重型套件)
npm run test:e2e # Playwright e2e(真实 Chromium + OPFS + 崩溃注入,需先 build
npm run lint # 代码检查
npm run typecheck # 类型检查
@@ -413,17 +430,39 @@ npm run typecheck # 类型检查
| 指标 | 数值 |
|------|------|
| 测试用例 | 1304+12 Playwright e2e |
| 测试套件 | 76 |
| 覆盖率 | 90.1% |
| 测试用例 | 187290 套件)+ 14 Playwright e2e,另 4 个重型套件在独立 CI job 串行运行 |
| 语句覆盖率 | 90.43%7835/8664 |
| 分支覆盖率 | 82.21%4092/4977 |
| 函数覆盖率 | 94.27%1103/1170 |
| 行覆盖率 | 93.44%7103/7601 |
| SQL 关键字 | 72 |
| 存储引擎 | 5Memory / KVStore / OPFS / Hybrid / Aria |
| 存储模式 | 4`memory` / `disk` / `hybrid` / `aria` |
| 存储后端 | 3OPFS / KVStore / Memory),Aria 引擎另有 LSM-Tree + WAL + 页面化 |
| 运行时依赖 | 0 |
### 已知限制(v0.7.4
> **覆盖率口径**`collectCoverageFrom = src/**/*.ts`,仅排除两个**纯类型声明**文件
> `engine/interface.ts`、`query/ast.ts` —— 它们只有 interface/type,可执行语句为 0
> 纳入统计只会稀释分母)。CI 常规 job 带 `--coverage` 运行,
> `jest.config.cjs` 的 `coverageThreshold` 为 statements 90 / branches 82 /
> functions 94 / lines 93,任一项不达标即失败 —— **门槛不达标不允许发版**。
>
> 上述四个数字由**与 CI 常规 job 完全相同的命令**产出(可复现):
> ```bash
> npx jest --coverage --testPathIgnorePatterns='/node_modules/|/tests/e2e/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race'
> ```
> 修改覆盖范围、阈值或测试选择时**必须同步更新本表**(G5 门禁要求
> README 数字与 CI 产出一致)。
### 已知限制(v0.8.0
- **单列主键** — 复合主键暂不支持(建表时显式 `SCHEMA_ERROR`),列入 v0.8 路线图
- **写语句关联引用** — UPDATE/DELETE 的 WHERE 支持非关联子查询(`IN (SELECT)` / 标量子查询),关联引用(`$col` / 关联 EXISTS)显式抛 `NOT_SUPPORTED`(不静默)
- **`backup()` 不是跨表一致性快照** — 实现为**逐表读取**(Aria 走引擎级 `backup()`
其余引擎回退 `exportAll()`)。若在备份过程中有并发写入,不同表之间可能来自
不同时间点(单表内部是一致的)。需要强一致备份时请先 `close()` 或用
`db.transaction()` 包住调用(事务期间并发写会被 `TX_ACTIVE` 拒绝)。
* 说明:此前文档宣称"在线一致性快照",但引擎层没有实现跨表快照原语 ——
与其保留一个不成立的宣称,这里按实际行为描述(真快照列入后续版本)。
- **建表 UNIQUE 约束** — 不可经 `DROP INDEX` 解除(对齐 SQLite,需重建表);仅 `CREATE UNIQUE INDEX` 添加的约束可随索引删除
- **唯一值交换更新** — 同一语句内两行互换唯一列值(A:x→y, B:y→x)保守拒绝(最终状态合法但报 `UNIQUE_VIOLATION`
- **主键非空** — 主键列强制非空(SQL 语义 PK 隐含 NOT NULL),`INSERT`/`UPDATE` 置 null/undefined 抛 `VALIDATION_ERROR`
+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
+21 -4
View File
@@ -14,13 +14,18 @@
"require": "./dist/metona-sqlark.cjs",
"types": "./dist/metona-sqlark.d.ts"
},
"./react": {
"import": "./src/integrations/react.ts",
"./migration": {
"import": "./dist/metona-sqlark.esm.js",
"require": "./dist/metona-sqlark.cjs",
"types": "./dist/metona-sqlark.d.ts"
},
"./react": {
"import": "./dist/react.js",
"types": "./src/integrations/react.d.ts"
},
"./vue": {
"import": "./src/integrations/vue.ts",
"types": "./dist/metona-sqlark.d.ts"
"import": "./dist/vue.js",
"types": "./src/integrations/vue.d.ts"
}
},
"files": [
@@ -65,6 +70,18 @@
"url": "https://git.metona.cn/MetonaTeam/MetonaSqlark/issues"
},
"homepage": "https://git.metona.cn/MetonaTeam/MetonaSqlark#readme",
"peerDependencies": {
"react": ">=17",
"vue": ">=3"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"vue": {
"optional": true
}
},
"devDependencies": {
"@babel/core": "^7.22.0",
"@babel/plugin-transform-modules-commonjs": "^7.22.0",
+18
View File
@@ -104,5 +104,23 @@ export default [
},
plugins: [dts()],
},
// v0.8.0: React / Vue 集成产物。
// 此前 package.json 的 "./react" / "./vue" 直接指向 **src/ 下的裸 TS 源码**
// 而这两个文件带 `// @ts-nocheck` → 使用者拿到的是未编译代码 + 类型为 any
// 的声明("开箱即用 hooks"这一宣称不成立)。现在构建成真实 ESM 产物,
// 配套手写精确声明(见 src/integrations/*.d.ts 的说明)。
// react / vue 保持 externalpeer dependency),不打包进产物。
{
input: 'src/integrations/react.ts',
output: { file: 'dist/react.js', format: 'es', sourcemap: true },
external: ['react'],
plugins: basePlugins,
},
{
input: 'src/integrations/vue.ts',
output: { file: 'dist/vue.js', format: 'es', sourcemap: true },
external: ['vue'],
plugins: basePlugins,
},
]),
];
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📖 API 文档 — MetonaSqlark v0.7.4</title>
<title>📖 API 文档 — MetonaSqlark v0.8.0</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><text x='16' y='22' text-anchor='middle' font-size='20' fill='white'>◈</text></svg>">
<style>
:root {
+5 -5
View File
@@ -153,7 +153,7 @@
<!-- Hero -->
<section class="hero">
<div class="container">
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.7.4 写语句子查询 + 约束硬化 + 真惰性流式 — 1304测试 76套件 · 90.1% 覆盖率 · UPDATE/DELETE 子查询正确执行 · 主键非空强制 · DROP INDEX 保留 UNIQUE 约束 · findStream 真惰性 · 参数化查询 · 自研 KVStore 事务引擎 · AriaEngine LSM+WAL+MVCC · 崩溃恢复</div>
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.8.0 根治性迭代 — 1872 测试 90 套件 · 语句/分支/函数/行覆盖率 90.43% / 82.21% / 94.27% / 93.44% · UPDATE/DELETE 子查询正确执行 · 主键非空强制 · DROP INDEX 保留 UNIQUE 约束 · findStream 真惰性 · 参数化查询 · 自研 KVStore 事务引擎 · AriaEngine LSM+WAL+MVCC · 崩溃恢复</div>
<h1>前端的 <span class="gradient-text">SQL 数据库</span></h1>
<p>TypeScript 原生构建,5 种存储引擎,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。AriaEngine 自研引擎:LSM-Tree + WAL 同步 + MVCC。</p>
<div class="actions">
@@ -243,7 +243,7 @@ npm install @metona-team/metona-sqlark
</div>
<div class="feature-card">
<div class="icon">🌲</div>
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.7.4</span></h3>
<h3>AriaEngine <span style="font-size:0.65rem;color:var(--accent);vertical-align:super;">v0.8.0</span></h3>
<p>自研 LSM-Tree 存储引擎:SSTable 4KB 页面化物理存储(BufferPool LRU)、WAL 分片文件原子写入、全库 AES-GCM 透明加密、标准 CRC-32 完整性校验、MVCC 快照隔离、二级索引跨重启恢复、ON UPDATE/DELETE 外键级联、崩溃恢复自愈。</p>
</div>
<div class="feature-card">
@@ -299,7 +299,7 @@ npm install @metona-team/metona-sqlark
<div class="feature-card">
<div class="icon">🔄</div>
<h3>数据迁移</h3>
<p>内置版本迁移系统,addMigration + migrateTo API。导入导出支持单表/全库 JSON 序列化,db.backup() 在线一致性备份</p>
<p>内置版本迁移系统,addMigration + migrateTo API。导入导出支持单表/全库 JSON 序列化,db.backup() 全库导出(逐表读取)</p>
</div>
<div class="feature-card">
<div class="icon">📡</div>
@@ -414,8 +414,8 @@ npm install @metona-team/metona-sqlark
<p>MetonaSqlark 的核心指标</p>
</div>
<div class="stats">
<div class="stat-card"><div class="num">1304</div><div class="label">测试用例</div></div>
<div class="stat-card"><div class="num">90.1%</div><div class="label">行覆盖率</div></div>
<div class="stat-card"><div class="num">1872</div><div class="label">测试用例+14 e2e</div></div>
<div class="stat-card"><div class="num">93.44%</div><div class="label">行覆盖率</div></div>
<div class="stat-card"><div class="num">~27KB</div><div class="label">gzip 体积</div></div>
<div class="stat-card"><div class="num">5</div><div class="label">存储引擎</div></div>
<div class="stat-card"><div class="num">72</div><div class="label">SQL 关键字</div></div>
+7 -8
View File
@@ -51,8 +51,8 @@ class ConnectionManager {
this.connections.set(name, db);
this.refCount.set(name, 1);
// 注入 disconnect 方法
(db as MetonaSqlark & { disconnect: () => Promise<void> }).disconnect = async () => {
// 注入 disconnect 方法(类型已在 MetonaSqlark 上声明,无需 any 断言)
db.disconnect = async () => {
await this.release(name);
};
@@ -114,12 +114,11 @@ class ConnectionManager {
const manager = new ConnectionManager();
// 挂载到 MetonaSqlark 静态方法(通过 any 绕过 TS 类型检查
const M = MetonaSqlark as unknown as Record<string, unknown>;
M.connect = (config: DatabaseConfig) => manager.connect(config);
M.disconnect = (dbName: string) => manager.release(dbName);
M.disconnectAll = () => manager.closeAll();
M.getActiveConnections = () => manager.getActiveConnections();
// 挂载到 MetonaSqlark 静态方法(v0.8.0:类型已在类上声明,无需 any 断言
MetonaSqlark.connect = (config: DatabaseConfig) => manager.connect(config);
MetonaSqlark.disconnect = (dbName: string) => manager.release(dbName);
MetonaSqlark.disconnectAll = () => manager.closeAll();
MetonaSqlark.getActiveConnections = () => manager.getActiveConnections();
export { manager as connectionManager };
export default manager;
+16 -2
View File
@@ -73,7 +73,14 @@ export interface DatabaseConfig {
name: string;
/** 存储模式 */
mode?: StorageMode;
/** 磁盘引擎(仅 mode='disk'|'hybrid' 时生效) */
/**
*
*
* v0.8.0 ** `mode: 'aria'` ** Aria
* core.ts aria `disk` KVStoreEngine
* `hybrid` + KVStoreEngine
*"disk 模式生效"
*/
diskEngine?: DiskEngine;
/** 版本号 */
version?: number;
@@ -186,7 +193,14 @@ export interface MetonaPlugin {
version: string;
/** 描述 */
description?: string;
/** 优先级,越大越先执行 */
/**
* **** `install()`
*
* v0.8.0 Core priority
* config register()
* install() register = config
*/
priority?: number;
/** 安装 */
install(db: unknown): void;
+55 -4
View File
@@ -31,6 +31,38 @@ export 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.8.0 API
*
* `src/connection-manager.ts` ****`MetonaSqlark.connect = ...`
* `as unknown as Record<string, unknown>`
* README `MetonaSqlark.connect(...)` /
* `MetonaSqlark.disconnectAll()` TypeScript TS2339
*"属性不存在"使 `as any`
*
* `?` ** import connection-manager **
* import
* `MetonaSqlark.create()`
*/
static connect?: (config: DatabaseConfig) => Promise<MetonaSqlark>;
/** 按库名释放一个连接引用(等价于实例上的 `disconnect()` */
static disconnect?: (dbName: string) => Promise<void>;
/** 关闭全部连接 */
static disconnectAll?: () => Promise<void>;
/** 当前活跃连接名列表 */
static getActiveConnections?: () => string[];
/**
* v0.7.1: 静态工厂 connect/disconnect
* create api / window README/
@@ -148,9 +180,22 @@ export class MetonaSqlark {
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) {
for (const plugin of 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);
}
}
@@ -489,8 +534,14 @@ export class MetonaSqlark {
}
/**
* 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(): Promise<Record<string, Record<string, unknown>[]>> {
this.ensureReady();
+6
View File
@@ -97,3 +97,9 @@ export { bindParameters } from './sql/params';
// AriaEngine 类型 & 后端
export type { AriaEngineConfig } from './engine/aria/types';
export { OPFSBackend } from './engine/aria/store/opfs_backend';
// v0.8.0: 迁移工具 —— 此前只在 src/migration/index.ts 定义,主入口未导出,
// 于是 package.json 的 "./migration" 子路径**指向的产物里根本没有这个函数**
//`import { migrateFromIndexedDB } from '.../migration'` 会得到 undefined)。
export { migrateFromIndexedDB } from './migration/index';
export type { MigrationOptions, MigrationResult } from './migration/index';
+63
View File
@@ -0,0 +1,63 @@
/**
* metona-sqlark React v0.8.0
*
* `react.ts` `// @ts-nocheck`hooks
* React React peer dependency
* rollup-plugin-dts `any` 使
* ****package.json `./react`
*
* `react` peer dependency import React
* 使使 React
*/
import type { MetonaSqlark, DatabaseConfig } from '../constants';
/** `useQuery` 的返回结构 */
export interface UseQueryResult<T = Record<string, unknown>> {
/** 查询结果(初次渲染时为空数组) */
data: T[];
/** 是否正在查询 */
loading: boolean;
/** 查询错误(成功时为 null) */
error: Error | null;
/** 重新执行查询 */
refresh: () => void;
}
/** `useTable` 的返回结构 */
export interface UseTableResult<T = Record<string, unknown>> {
data: T[];
loading: boolean;
refresh: () => void;
}
/** `useDatabase` 的返回结构 */
export interface UseDatabaseResult {
/** 初始化完成的数据库实例;未就绪时为 null */
db: MetonaSqlark | null;
ready: boolean;
error: Error | null;
}
/**
* SQL
* @param db
* @param sql SQL
* @param deps React useEffect
*/
export function useQuery<T = Record<string, unknown>>(
db: MetonaSqlark,
sql: string,
deps?: unknown[],
): UseQueryResult<T>;
/** 查询整张表(`SELECT * FROM <table>`),表名会做标识符校验 */
export function useTable<T = Record<string, unknown>>(
db: MetonaSqlark,
tableName: string,
): UseTableResult<T>;
/**
* init close
* `config`
*/
export function useDatabase(config: DatabaseConfig): UseDatabaseResult;
+56
View File
@@ -0,0 +1,56 @@
/**
* metona-sqlark Vue v0.8.0
*
* `react.d.ts` `vue.ts` `// @ts-nocheck` `any`
* `vue` peer dependency
* `Ref` import Vue
*/
import type { MetonaSqlark, DatabaseConfig } from '../constants';
/** 最小响应式引用结构(与 Vue 的 `Ref<T>` 兼容,但不依赖 vue 包) */
export interface Ref<T> {
value: T;
}
/** `useSqlarkQuery` 的返回结构 */
export interface UseSqlarkQueryResult<T = Record<string, unknown>> {
data: Ref<T[]>;
loading: Ref<boolean>;
error: Ref<Error | null>;
refresh: () => void;
}
/** `useSqlarkTable` 的返回结构 */
export interface UseSqlarkTableResult<T = Record<string, unknown>> {
data: Ref<T[]>;
loading: Ref<boolean>;
refresh: () => void;
}
/** `useSqlarkDatabase` 的返回结构 */
export interface UseSqlarkDatabaseResult {
db: Ref<MetonaSqlark | null>;
ready: Ref<boolean>;
error: Ref<Error | null>;
}
/**
* SQL
* @param db
* @param sql SQL
* @param deps
*/
export function useSqlarkQuery<T = Record<string, unknown>>(
db: MetonaSqlark,
sql: string,
deps?: Ref<unknown>[],
): UseSqlarkQueryResult<T>;
/** 查询整张表(`SELECT * FROM <table>`),表名会做标识符校验 */
export function useSqlarkTable<T = Record<string, unknown>>(
db: MetonaSqlark,
tableName: string,
): UseSqlarkTableResult<T>;
/** 创建并管理数据库实例:`onMounted` 时 init */
export function useSqlarkDatabase(config: DatabaseConfig): UseSqlarkDatabaseResult;
+91
View File
@@ -0,0 +1,91 @@
/**
* v0.8.0 priority G6
* ============================================================================
* `PluginManager.register()` ** priority **
* `install()` `register()` **** install
* `config.plugins`
* priority low=1 / high=100 / mid=50 low,high,mid
* lowhighmid `getPlugins()` high,mid,low
* README / CONTRIBUTING / `constants.ts` "priority 越大越先执行"
* ****priority
*
*
* Core priority ****
* `install()`
*/
import { describe, it, expect } from '@jest/globals';
import { MetonaSqlark } from '../src/core';
import { PluginManager } from '../src/plugin/index';
import type { MetonaPlugin } from '../src/constants';
function makePlugin(name: string, priority: number, log: string[]): MetonaPlugin {
return {
name,
version: '1.0.0',
priority,
install: (db?: unknown) => {
log.push(`install:${name}`);
// register(plugin) 不带 db 时(纯 PluginManager 用法)没有 db 实例可挂钩子
if (db) (db as MetonaSqlark).on('afterQuery', () => { log.push(`hook:${name}`); });
},
destroy: () => log.push(`destroy:${name}`),
} as MetonaPlugin;
}
describe('[v0.8.0] 插件 priority', () => {
it('install 与钩子都按 priority 降序执行(同优先级保持数组顺序)', async () => {
const log: string[] = [];
const db = await MetonaSqlark.create({
name: 'prio-order',
mode: 'memory',
plugins: [makePlugin('low', 1, log), makePlugin('high', 100, log), makePlugin('mid', 50, log)],
});
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1')");
expect(log.filter((l) => l.startsWith('install:'))).toEqual([
'install:high', 'install:mid', 'install:low',
]);
expect(log.filter((l) => l.startsWith('hook:'))).toEqual([
'hook:high', 'hook:mid', 'hook:low',
]);
await db.close();
});
it('同优先级保持 config 数组顺序(稳定排序)', async () => {
const log: string[] = [];
const db = await MetonaSqlark.create({
name: 'prio-stable',
mode: 'memory',
plugins: [makePlugin('a', 5, log), makePlugin('b', 5, log), makePlugin('c', 5, log)],
});
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1')");
expect(log.filter((l) => l.startsWith('install:'))).toEqual(['install:a', 'install:b', 'install:c']);
await db.close();
});
it('缺省 priority 视为 0(排在显式正优先级之后)', async () => {
const log: string[] = [];
const noPriority = { ...makePlugin('none', 0, log) } as MetonaPlugin & { priority?: number };
delete noPriority.priority;
const db = await MetonaSqlark.create({
name: 'prio-default',
mode: 'memory',
plugins: [noPriority, makePlugin('explicit', 10, log)],
});
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1')");
expect(log.filter((l) => l.startsWith('install:'))).toEqual(['install:explicit', 'install:none']);
await db.close();
});
it('PluginManager.getPlugins() 按 priority 降序(回归护栏)', () => {
const log: string[] = [];
const pm = new PluginManager();
pm.register(makePlugin('low', 1, log));
pm.register(makePlugin('high', 100, log));
pm.register(makePlugin('mid', 50, log));
expect(pm.getPlugins().map((p) => p.name)).toEqual(['high', 'mid', 'low']);
});
});