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:
+115
@@ -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 → **1872(90 套件)+ 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
@@ -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 first(v0.8.0 起真正生效:install 与钩子都按优先级降序;
|
||||
// 同优先级保持 plugins 数组顺序)
|
||||
priority: 50,
|
||||
|
||||
install(db) {
|
||||
// Use db.on() to subscribe to hooks
|
||||
|
||||
@@ -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 存储引擎(AriaEngine:WAL + 页面化 + 可选压缩/加密,
|
||||
> 事务用未提交快照回滚)。
|
||||
> 零运行时依赖,浏览器 / Node.js 开箱即用。
|
||||
|
||||
---
|
||||
@@ -35,18 +37,21 @@
|
||||
- **完整 SQL** — SELECT(JOIN / 子查询 / 派生表 / 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% |
|
||||
| 测试用例 | 1872(90 套件)+ 14 Playwright e2e,另 4 个重型套件在独立 CI job 串行运行 |
|
||||
| 语句覆盖率 | 90.43%(7835/8664) |
|
||||
| 分支覆盖率 | 82.21%(4092/4977) |
|
||||
| 函数覆盖率 | 94.27%(1103/1170) |
|
||||
| 行覆盖率 | 93.44%(7103/7601) |
|
||||
| SQL 关键字 | 72 |
|
||||
| 存储引擎 | 5(Memory / KVStore / OPFS / Hybrid / Aria) |
|
||||
| 存储模式 | 4(`memory` / `disk` / `hybrid` / `aria`) |
|
||||
| 存储后端 | 3(OPFS / 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`
|
||||
|
||||
Vendored
+209
-15
@@ -14070,6 +14070,34 @@ class QueryExecutor {
|
||||
// 三条都会静默返回空集(`oops` 在 SQL 里是列引用,因为没有别的字面量形态)。
|
||||
// 这正是"未解析引用静默变 false"这一整类缺陷(PLAN §3 根因 7)的最后一块。
|
||||
await this.assertWhereColumnsExist(stmt, isJoinQuery);
|
||||
// v0.8.0(B-5):ORDER BY 的键同样要校验存在性与歧义 ——
|
||||
// JOIN 里两表同名列裸写时(`ORDER BY tag`),此前既不报错也不确定按哪一列排,
|
||||
// 结果取决于行键插入顺序(难查的"顺序偶尔不对")。与 WHERE 同一口径:
|
||||
// 裸名歧义 → COLUMN_NOT_FOUND 并要求限定。
|
||||
if (stmt.orderBy && stmt.orderBy.length > 0 && !stmt.fromSubquery) {
|
||||
// SELECT 别名(`SELECT n AS num ... ORDER BY num`)是**输出列名**,
|
||||
// 不是行源里的列 —— 必须豁免,否则合法查询会被判未知列(实测)。
|
||||
// 派生表(`FROM (SELECT ...) AS d`)的列来自子查询投影、不在本层 schema,
|
||||
// 因此整段跳过(与 assertWhereColumnsExist 的守卫一致)。
|
||||
const selectAliases = this.selectAliasNames(stmt);
|
||||
const orderWhere = {};
|
||||
for (const item of stmt.orderBy) {
|
||||
const key = unquoteIdentifier(item.column.trim());
|
||||
if (!key || /^\d+$/.test(key))
|
||||
continue; // 序号已在 resolveOutputOrdinals 处理
|
||||
if (parseCaseExpression(item.column))
|
||||
continue; // CASE 已由 assertCaseColumnsExist 校验
|
||||
if (selectAliases.has(key))
|
||||
continue; // 输出别名
|
||||
orderWhere[key] = { $exists: true };
|
||||
}
|
||||
if (Object.keys(orderWhere).length > 0) {
|
||||
await this.validateWhereColumns(stmt, orderWhere, {
|
||||
context: 'ORDER BY',
|
||||
rejectAmbiguous: isJoinQuery,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (stmt.fromSubquery) {
|
||||
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
||||
@@ -15365,23 +15393,33 @@ class QueryExecutor {
|
||||
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
||||
* 别名列在引擎层投影前不存在,需投影后重新排序。
|
||||
*/
|
||||
/**
|
||||
* v0.8.0(B-5):SELECT 列表产出的**别名集合**(`AS x` 与 `CASE ... AS x`)。
|
||||
*
|
||||
* 与 `orderByUsesSelectAlias` 共用同一套识别规则 —— 两处若各写一份,
|
||||
* 会出现"排序认为它是别名、校验认为它是列"的矛盾(本项目反复出现的漂移模式)。
|
||||
*/
|
||||
selectAliasNames(stmt) {
|
||||
const aliases = new Set();
|
||||
for (const col of stmt.columns) {
|
||||
const caseExpr = parseCaseExpression(col);
|
||||
if (caseExpr?.alias) {
|
||||
aliases.add(caseExpr.alias);
|
||||
continue;
|
||||
}
|
||||
const m = col.match(/\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/i);
|
||||
if (m)
|
||||
aliases.add(m[1]);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
orderByUsesSelectAlias(stmt) {
|
||||
if (!stmt.orderBy || stmt.orderBy.length === 0)
|
||||
return false;
|
||||
const aliases = new Set();
|
||||
for (const col of stmt.columns) {
|
||||
const m = col.match(/\s+AS\s+(\w+)$/i);
|
||||
if (m)
|
||||
aliases.add(m[1]);
|
||||
else if (/^\s*CASE\b/i.test(col)) {
|
||||
const expr = parseCaseExpression(col);
|
||||
if (expr?.alias)
|
||||
aliases.add(expr.alias);
|
||||
}
|
||||
}
|
||||
const aliases = this.selectAliasNames(stmt);
|
||||
if (aliases.size === 0)
|
||||
return false;
|
||||
return stmt.orderBy.some((o) => aliases.has(o.column));
|
||||
return stmt.orderBy.some((o) => aliases.has(unquoteIdentifier(o.column)));
|
||||
}
|
||||
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
||||
whereHasCase(where) {
|
||||
@@ -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;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+67
-3
@@ -819,6 +819,13 @@ declare class QueryExecutor {
|
||||
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
||||
* 别名列在引擎层投影前不存在,需投影后重新排序。
|
||||
*/
|
||||
/**
|
||||
* v0.8.0(B-5):SELECT 列表产出的**别名集合**(`AS x` 与 `CASE ... AS x`)。
|
||||
*
|
||||
* 与 `orderByUsesSelectAlias` 共用同一套识别规则 —— 两处若各写一份,
|
||||
* 会出现"排序认为它是别名、校验认为它是列"的矛盾(本项目反复出现的漂移模式)。
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* 旧引擎类型:仅支持 disk(IndexedDBEngine,每表一个 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 };
|
||||
|
||||
Vendored
+209
-16
@@ -14066,6 +14066,34 @@ class QueryExecutor {
|
||||
// 三条都会静默返回空集(`oops` 在 SQL 里是列引用,因为没有别的字面量形态)。
|
||||
// 这正是"未解析引用静默变 false"这一整类缺陷(PLAN §3 根因 7)的最后一块。
|
||||
await this.assertWhereColumnsExist(stmt, isJoinQuery);
|
||||
// v0.8.0(B-5):ORDER BY 的键同样要校验存在性与歧义 ——
|
||||
// JOIN 里两表同名列裸写时(`ORDER BY tag`),此前既不报错也不确定按哪一列排,
|
||||
// 结果取决于行键插入顺序(难查的"顺序偶尔不对")。与 WHERE 同一口径:
|
||||
// 裸名歧义 → COLUMN_NOT_FOUND 并要求限定。
|
||||
if (stmt.orderBy && stmt.orderBy.length > 0 && !stmt.fromSubquery) {
|
||||
// SELECT 别名(`SELECT n AS num ... ORDER BY num`)是**输出列名**,
|
||||
// 不是行源里的列 —— 必须豁免,否则合法查询会被判未知列(实测)。
|
||||
// 派生表(`FROM (SELECT ...) AS d`)的列来自子查询投影、不在本层 schema,
|
||||
// 因此整段跳过(与 assertWhereColumnsExist 的守卫一致)。
|
||||
const selectAliases = this.selectAliasNames(stmt);
|
||||
const orderWhere = {};
|
||||
for (const item of stmt.orderBy) {
|
||||
const key = unquoteIdentifier(item.column.trim());
|
||||
if (!key || /^\d+$/.test(key))
|
||||
continue; // 序号已在 resolveOutputOrdinals 处理
|
||||
if (parseCaseExpression(item.column))
|
||||
continue; // CASE 已由 assertCaseColumnsExist 校验
|
||||
if (selectAliases.has(key))
|
||||
continue; // 输出别名
|
||||
orderWhere[key] = { $exists: true };
|
||||
}
|
||||
if (Object.keys(orderWhere).length > 0) {
|
||||
await this.validateWhereColumns(stmt, orderWhere, {
|
||||
context: 'ORDER BY',
|
||||
rejectAmbiguous: isJoinQuery,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (stmt.fromSubquery) {
|
||||
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
||||
@@ -15361,23 +15389,33 @@ class QueryExecutor {
|
||||
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
||||
* 别名列在引擎层投影前不存在,需投影后重新排序。
|
||||
*/
|
||||
/**
|
||||
* v0.8.0(B-5):SELECT 列表产出的**别名集合**(`AS x` 与 `CASE ... AS x`)。
|
||||
*
|
||||
* 与 `orderByUsesSelectAlias` 共用同一套识别规则 —— 两处若各写一份,
|
||||
* 会出现"排序认为它是别名、校验认为它是列"的矛盾(本项目反复出现的漂移模式)。
|
||||
*/
|
||||
selectAliasNames(stmt) {
|
||||
const aliases = new Set();
|
||||
for (const col of stmt.columns) {
|
||||
const caseExpr = parseCaseExpression(col);
|
||||
if (caseExpr?.alias) {
|
||||
aliases.add(caseExpr.alias);
|
||||
continue;
|
||||
}
|
||||
const m = col.match(/\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/i);
|
||||
if (m)
|
||||
aliases.add(m[1]);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
orderByUsesSelectAlias(stmt) {
|
||||
if (!stmt.orderBy || stmt.orderBy.length === 0)
|
||||
return false;
|
||||
const aliases = new Set();
|
||||
for (const col of stmt.columns) {
|
||||
const m = col.match(/\s+AS\s+(\w+)$/i);
|
||||
if (m)
|
||||
aliases.add(m[1]);
|
||||
else if (/^\s*CASE\b/i.test(col)) {
|
||||
const expr = parseCaseExpression(col);
|
||||
if (expr?.alias)
|
||||
aliases.add(expr.alias);
|
||||
}
|
||||
}
|
||||
const aliases = this.selectAliasNames(stmt);
|
||||
if (aliases.size === 0)
|
||||
return false;
|
||||
return stmt.orderBy.some((o) => aliases.has(o.column));
|
||||
return stmt.orderBy.some((o) => aliases.has(unquoteIdentifier(o.column)));
|
||||
}
|
||||
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
||||
whereHasCase(where) {
|
||||
@@ -16892,8 +16930,14 @@ class MetonaSqlark {
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* v0.5.1: 在线备份 — 导出全库一致性快照。
|
||||
* Aria 引擎走引擎级 backup()(MVCC 一致性视图);其余引擎回退 exportAll()。
|
||||
* v0.5.1: 在线备份 — 导出全库数据。
|
||||
*
|
||||
* v0.8.0 修正表述:此前注释与 README 宣称"全库**一致性**快照",但引擎层
|
||||
* 并没有跨表快照原语 —— 实现是**逐表读取**(Aria 走引擎级 `backup()`,
|
||||
* 其余引擎回退 `exportAll()`)。备份过程中的并发写入会让不同表来自不同
|
||||
* 时间点(单表内部仍是一致的)。需要强一致时先 `close()`,或用
|
||||
* `db.transaction()` 包住调用(事务期间并发写被 `TX_ACTIVE` 拒绝)。
|
||||
* 真正的跨表快照需要 COW 行所有权改造,列入后续版本。
|
||||
*/
|
||||
async backup() {
|
||||
this.ensureReady();
|
||||
@@ -17225,7 +17269,7 @@ class ConnectionManager {
|
||||
await db.init();
|
||||
this.connections.set(name, db);
|
||||
this.refCount.set(name, 1);
|
||||
// 注入 disconnect 方法
|
||||
// 注入 disconnect 方法(类型已在 MetonaSqlark 上声明,无需 any 断言)
|
||||
db.disconnect = async () => {
|
||||
await this.release(name);
|
||||
};
|
||||
@@ -17290,6 +17334,155 @@ M.disconnect = (dbName) => manager.release(dbName);
|
||||
M.disconnectAll = () => manager.closeAll();
|
||||
M.getActiveConnections = () => manager.getActiveConnections();
|
||||
|
||||
/**
|
||||
* migrateFromIndexedDB — 旧 IndexedDB 数据迁移到自研 KV 引擎
|
||||
* @module migration/index
|
||||
*
|
||||
* v0.6.0: IndexedDB 从引擎中完全移除后,提供一次性迁移工具把旧库数据
|
||||
* 导入新引擎(KVStoreEngine disk 模式 / AriaEngine)。
|
||||
*
|
||||
* 旧库命名:
|
||||
* - disk 模式(IndexedDBEngine):库名 = dbName
|
||||
* - aria 模式(IndexedDBBackend):库名 = `aria-${dbName}`
|
||||
*
|
||||
* 仅此模块保留原生 IndexedDB 读取代码(一次性迁移用途,不参与运行时)。
|
||||
*/
|
||||
/** 旧库中持久化 schema 的 store 名(IndexedDBEngine v0.3.2+) */
|
||||
const SCHEMA_STORE = '__metona_schema';
|
||||
function inferFieldType(value) {
|
||||
if (typeof value === 'number')
|
||||
return 'number';
|
||||
if (typeof value === 'boolean')
|
||||
return 'boolean';
|
||||
if (typeof value === 'object' && value !== null)
|
||||
return 'json';
|
||||
return 'string';
|
||||
}
|
||||
/** 从样例行推断 schema(旧库无持久化 schema 时回退) */
|
||||
function inferSchema(tableName, rows) {
|
||||
const columns = {};
|
||||
if (rows.length === 0)
|
||||
return { name: tableName, columns };
|
||||
const first = rows[0];
|
||||
const keys = Object.keys(first);
|
||||
// v0.7.3: 主键推断 —— 优先 id;无 id 列时取第一个非 json 类型列(json 列
|
||||
// String() 化为 "[object Object]" 会致所有行主键冲突)。全 json 列无可用
|
||||
// 主键 → 返回 null(调用方跳过该表),此前直接抛 SCHEMA_ERROR 中断整个迁移。
|
||||
const pkKey = keys.includes('id')
|
||||
? 'id'
|
||||
: keys.find((k) => inferFieldType(first[k]) !== 'json');
|
||||
if (!pkKey)
|
||||
return null;
|
||||
for (const key of keys) {
|
||||
columns[key] = {
|
||||
type: inferFieldType(first[key]),
|
||||
primaryKey: key === pkKey,
|
||||
};
|
||||
}
|
||||
return { name: tableName, columns };
|
||||
}
|
||||
/** 打开旧 IndexedDB 库(只读) */
|
||||
function openLegacyDB(idbName) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(idbName);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error(`Failed to open legacy IndexedDB "${idbName}"`));
|
||||
});
|
||||
}
|
||||
/** 读取 object store 全部记录 */
|
||||
function readAllRecords(store) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve((req.result ?? []));
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
/** 读取持久化 schema 记录 */
|
||||
function readSchemas(db) {
|
||||
if (!db.objectStoreNames.contains(SCHEMA_STORE)) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(SCHEMA_STORE, 'readonly').objectStore(SCHEMA_STORE).getAll();
|
||||
req.onsuccess = () => {
|
||||
const result = {};
|
||||
for (const rec of (req.result ?? [])) {
|
||||
if (!rec.schema)
|
||||
continue;
|
||||
try {
|
||||
const schema = JSON.parse(rec.schema);
|
||||
result[schema.name] = schema;
|
||||
}
|
||||
catch { /* 损坏记录跳过 */ }
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 将旧 IndexedDB 库迁移到目标引擎。
|
||||
* @returns 迁移结果(表/行数统计)
|
||||
*/
|
||||
async function migrateFromIndexedDB(opts) {
|
||||
// v0.6.0: aria 旧库为引擎私有格式(SSTable/WAL),不支持按行迁移(运行时防御)
|
||||
if (opts.engine === 'aria') {
|
||||
throw new Error('Migration from AriaEngine IndexedDB backend is not supported ' +
|
||||
'(data is stored in engine-private SSTable/WAL format). ' +
|
||||
'Only disk-mode IndexedDBEngine databases can be migrated.');
|
||||
}
|
||||
const idbName = opts.dbName;
|
||||
let db = null;
|
||||
try {
|
||||
db = await openLegacyDB(idbName);
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Legacy IndexedDB database "${idbName}" not found or unreadable: ${error.message}`);
|
||||
}
|
||||
const result = { migratedTables: [], rowCount: 0, skippedTables: [] };
|
||||
const schemas = await readSchemas(db);
|
||||
try {
|
||||
const storeNames = Array.from(db.objectStoreNames).filter((n) => n !== SCHEMA_STORE);
|
||||
for (let i = 0; i < storeNames.length; i++) {
|
||||
const tableName = storeNames[i];
|
||||
opts.onProgress?.(i, storeNames.length, tableName);
|
||||
const rows = await readAllRecords(db.transaction(tableName, 'readonly').objectStore(tableName));
|
||||
// 表已存在于目标库 → 跳过(避免覆盖)
|
||||
const names = await opts.target.getTableNames();
|
||||
if (names.includes(tableName)) {
|
||||
result.skippedTables.push(tableName);
|
||||
continue;
|
||||
}
|
||||
// schema:持久化优先,否则从数据推断(空表且无 schema → 跳过)
|
||||
let schema = schemas[tableName];
|
||||
if (!schema) {
|
||||
if (rows.length === 0) {
|
||||
result.skippedTables.push(tableName);
|
||||
continue;
|
||||
}
|
||||
const inferred = inferSchema(tableName, rows);
|
||||
// v0.7.3: 全 json 列推断无主键 → 跳过该表(此前抛 SCHEMA_ERROR 中断迁移)
|
||||
if (!inferred) {
|
||||
result.skippedTables.push(tableName);
|
||||
continue;
|
||||
}
|
||||
schema = inferred;
|
||||
}
|
||||
// 写入目标引擎
|
||||
await opts.target.defineTable(tableName, schema.columns);
|
||||
if (rows.length > 0) {
|
||||
await opts.target.table(tableName).insertMany(rows);
|
||||
}
|
||||
result.migratedTables.push(tableName);
|
||||
result.rowCount += rows.length;
|
||||
}
|
||||
}
|
||||
finally {
|
||||
db.close();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* metona-sqlark — 入口文件
|
||||
* @module metona-sqlark
|
||||
@@ -17341,5 +17534,5 @@ if (typeof window !== 'undefined') {
|
||||
// 别名
|
||||
const MeSqlark = MetonaSqlark;
|
||||
|
||||
export { AriaEngine, DatabaseError, HybridEngine, KVStoreEngine, MeSqlark, MemoryEngine, MetonaSqlark, OPFSBackend, PluginManager, Table, Transaction, TransactionManager, VERSION, api, bindParameters, create, api as default, parse, parseAll, parseWhereCondition, tokenize };
|
||||
export { AriaEngine, DatabaseError, HybridEngine, KVStoreEngine, MeSqlark, MemoryEngine, MetonaSqlark, OPFSBackend, PluginManager, Table, Transaction, TransactionManager, VERSION, api, bindParameters, create, api as default, migrateFromIndexedDB, parse, parseAll, parseWhereCondition, tokenize };
|
||||
//# sourceMappingURL=metona-sqlark.esm.js.map
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+209
-15
@@ -14072,6 +14072,34 @@
|
||||
// 三条都会静默返回空集(`oops` 在 SQL 里是列引用,因为没有别的字面量形态)。
|
||||
// 这正是"未解析引用静默变 false"这一整类缺陷(PLAN §3 根因 7)的最后一块。
|
||||
await this.assertWhereColumnsExist(stmt, isJoinQuery);
|
||||
// v0.8.0(B-5):ORDER BY 的键同样要校验存在性与歧义 ——
|
||||
// JOIN 里两表同名列裸写时(`ORDER BY tag`),此前既不报错也不确定按哪一列排,
|
||||
// 结果取决于行键插入顺序(难查的"顺序偶尔不对")。与 WHERE 同一口径:
|
||||
// 裸名歧义 → COLUMN_NOT_FOUND 并要求限定。
|
||||
if (stmt.orderBy && stmt.orderBy.length > 0 && !stmt.fromSubquery) {
|
||||
// SELECT 别名(`SELECT n AS num ... ORDER BY num`)是**输出列名**,
|
||||
// 不是行源里的列 —— 必须豁免,否则合法查询会被判未知列(实测)。
|
||||
// 派生表(`FROM (SELECT ...) AS d`)的列来自子查询投影、不在本层 schema,
|
||||
// 因此整段跳过(与 assertWhereColumnsExist 的守卫一致)。
|
||||
const selectAliases = this.selectAliasNames(stmt);
|
||||
const orderWhere = {};
|
||||
for (const item of stmt.orderBy) {
|
||||
const key = unquoteIdentifier(item.column.trim());
|
||||
if (!key || /^\d+$/.test(key))
|
||||
continue; // 序号已在 resolveOutputOrdinals 处理
|
||||
if (parseCaseExpression(item.column))
|
||||
continue; // CASE 已由 assertCaseColumnsExist 校验
|
||||
if (selectAliases.has(key))
|
||||
continue; // 输出别名
|
||||
orderWhere[key] = { $exists: true };
|
||||
}
|
||||
if (Object.keys(orderWhere).length > 0) {
|
||||
await this.validateWhereColumns(stmt, orderWhere, {
|
||||
context: 'ORDER BY',
|
||||
rejectAmbiguous: isJoinQuery,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (stmt.fromSubquery) {
|
||||
// v0.4.0: FROM (SELECT ...) 派生表 — 子查询结果作为行源
|
||||
const subRows = await this.executeSelectPart(stmt.fromSubquery);
|
||||
@@ -15367,23 +15395,33 @@
|
||||
* v0.3.3: ORDER BY 是否引用 SELECT 别名(如 `SELECT name AS n ... ORDER BY n`)。
|
||||
* 别名列在引擎层投影前不存在,需投影后重新排序。
|
||||
*/
|
||||
/**
|
||||
* v0.8.0(B-5):SELECT 列表产出的**别名集合**(`AS x` 与 `CASE ... AS x`)。
|
||||
*
|
||||
* 与 `orderByUsesSelectAlias` 共用同一套识别规则 —— 两处若各写一份,
|
||||
* 会出现"排序认为它是别名、校验认为它是列"的矛盾(本项目反复出现的漂移模式)。
|
||||
*/
|
||||
selectAliasNames(stmt) {
|
||||
const aliases = new Set();
|
||||
for (const col of stmt.columns) {
|
||||
const caseExpr = parseCaseExpression(col);
|
||||
if (caseExpr?.alias) {
|
||||
aliases.add(caseExpr.alias);
|
||||
continue;
|
||||
}
|
||||
const m = col.match(/\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/i);
|
||||
if (m)
|
||||
aliases.add(m[1]);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
orderByUsesSelectAlias(stmt) {
|
||||
if (!stmt.orderBy || stmt.orderBy.length === 0)
|
||||
return false;
|
||||
const aliases = new Set();
|
||||
for (const col of stmt.columns) {
|
||||
const m = col.match(/\s+AS\s+(\w+)$/i);
|
||||
if (m)
|
||||
aliases.add(m[1]);
|
||||
else if (/^\s*CASE\b/i.test(col)) {
|
||||
const expr = parseCaseExpression(col);
|
||||
if (expr?.alias)
|
||||
aliases.add(expr.alias);
|
||||
}
|
||||
}
|
||||
const aliases = this.selectAliasNames(stmt);
|
||||
if (aliases.size === 0)
|
||||
return false;
|
||||
return stmt.orderBy.some((o) => aliases.has(o.column));
|
||||
return stmt.orderBy.some((o) => aliases.has(unquoteIdentifier(o.column)));
|
||||
}
|
||||
/** WHERE 是否包含 CASE WHEN 表达式键 */
|
||||
whereHasCase(where) {
|
||||
@@ -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;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+17293
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+17291
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
+21
-4
@@ -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",
|
||||
|
||||
@@ -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 保持 external(peer 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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
Vendored
+63
@@ -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;
|
||||
Vendored
+56
@@ -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;
|
||||
@@ -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):
|
||||
* 钩子顺序 low→high→mid,只有 `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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user