fix: v0.6.1 生产可用性深度审查 — MemoryEngine 级联环无限递归(P0)/ KVStore 多表事务与级联原子化 / 未open防护统一 / 27 个异常场景测试 + aria级联回归
This commit is contained in:
@@ -2,6 +2,50 @@
|
||||
|
||||
All notable changes to MetonaSqlark will be documented in this file.
|
||||
|
||||
## [0.6.1] - 2026-08-10
|
||||
|
||||
### 生产可用性深度审查(异常场景)
|
||||
|
||||
> 以"生产可用"标准全面审查:修复级联环无限递归、多表事务原子性缺口、
|
||||
> 统一未 open 防护,并新增 27 个异常场景测试。
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MemoryEngine 级联环无限递归(P0)** — A→B→A 循环引用 + CASCADE 删除导致栈溢出
|
||||
(RangeError: Maximum call stack size exceeded)—— MemoryEngine.cascadeDelete 无环路保护,
|
||||
AriaEngine 已有 visited 保护(v0.4.1),Memory 侧缺失。新增 visited 集合(表:主键),
|
||||
与 Aria 语义对齐;A→B→A 环 + 深链 A→B→C→D 均有回归测试
|
||||
- **KVStore 快照损坏水位 bug(P0)** — baseSeq = max(metaSeq, snapshotSeq) 在快照损坏
|
||||
回退全量日志重放时,meta.seq(最后一次 checkpoint 水位)错误跳过日志中 checkpoint 后
|
||||
的有效记录 → 静默丢数据。水位只信任快照内嵌 seq
|
||||
- **多表事务 commit 非原子(P1)** — 事务 commit 逐表 flush:中途失败/崩溃 → 部分表已提交。
|
||||
重构为全部 dirty 表合并单次 putMany/deleteMany(一条日志记录 = 真原子)
|
||||
- **级联更新/删除非原子(P1)** — update 主键变更/delete 的级联影响表逐表 diff 分多次写。
|
||||
重构 collectTableDiff 合并到单次原子写(主表 + 全部级联表同一条日志记录)
|
||||
- **未 open 防护不一致** — getTableNames/hasTable/getTableSchema 未检查 DB_NOT_OPEN
|
||||
(返回空而非报错,与其余方法不一致)。统一 ensureOpen
|
||||
- **测试盲区** — 快照损坏测试此前篡改未生效(close 后介质不可读),从未真正覆盖损坏路径;
|
||||
修复并新增 metaSeq 超前回归测试
|
||||
|
||||
### Added
|
||||
|
||||
- 27 个生产异常场景测试(`tests/production-abnormal.test.ts`):
|
||||
- KVStore 边界:空 value/空 key、1MB 大 value、中文/emoji/冒号/超长 key、未 open、
|
||||
重复 open/close 幂等、clear 后写、1 万小 key、写失败后继续可用
|
||||
- KVStoreEngine:未 open 全操作、重复 close、不存在表、事务嵌套 TX_ACTIVE、
|
||||
无事务 TX_NONE、commit 失败回滚(磁盘无部分提交)、多表事务原子性、级联环、
|
||||
深链级联、主键变更+索引一致性、空表操作、5000 行批量删除持久化
|
||||
- SharedMemoryBackend 全分支:append 拼接、writeMany/deleteMany、close 后安全、跨实例共享
|
||||
- 日志解析损坏分支:onCorrupt false 停止、keyLen 越界记录丢弃
|
||||
- AriaEngine 级联环/深链回归测试(与 Memory 对齐)
|
||||
|
||||
### Changed
|
||||
|
||||
- 测试 1022 → **1049**(65 套件);行覆盖率 89.15% → **89.33%**
|
||||
- 事务/级联写入路径:逐表 flush → 单条日志记录原子(性能与一致性双提升)
|
||||
|
||||
---
|
||||
|
||||
## [0.6.0] - 2026-08-10
|
||||
|
||||
### 里程碑:完全移除 IndexedDB,自研 KV 事务存储引擎
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.6.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-89.15%25-brightgreen?style=flat-square" alt="coverage">
|
||||
<img src="https://img.shields.io/badge/tests-1022%20passed-success?style=flat-square" alt="tests">
|
||||
<img src="https://img.shields.io/badge/coverage-89.3%25-brightgreen?style=flat-square" alt="coverage">
|
||||
<img src="https://img.shields.io/badge/tests-1049%20passed-success?style=flat-square" alt="tests">
|
||||
</p>
|
||||
|
||||
> 基于 TypeScript 的**前端关系型数据库**,支持完整 SQL 查询、Query Builder 链式 API、与 **AriaEngine 自研存储引擎**。
|
||||
@@ -34,7 +34,7 @@
|
||||
- 🌲 **RB-Tree 完整实现** — 标准红黑树插入+删除修复,O(log n) 保证
|
||||
- ⚡ **性能优化** — SSTableReader 二分查找统一、IndexedDB 索引利用、crypto 实例化避免全局状态
|
||||
- 🌐 **浏览器兼容** — Chrome 80+ / Firefox 80+ / Safari 14+ / Edge 80+ / Node.js 16+
|
||||
- 🧪 **1022 测试 · 89.15% 覆盖率** — 64 套件 + 12 个 Playwright 真实 Chromium e2e(含崩溃注入),生产级质量保证
|
||||
- 🧪 **1049 测试 · 89.3% 覆盖率** — 64 套件 + 12 个 Playwright 真实 Chromium e2e(含崩溃注入),生产级质量保证
|
||||
|
||||
---
|
||||
|
||||
@@ -459,7 +459,7 @@ npm run typecheck # 类型检查
|
||||
|------|------|
|
||||
| 测试用例 | 1022 |
|
||||
| 测试套件 | 64(+12 Playwright e2e) |
|
||||
| 行覆盖率 | 89.15% |
|
||||
| 行覆盖率 | 89.3% |
|
||||
| SQL 关键字 | 72 |
|
||||
| 存储引擎 | 5(Memory / **KVStore** / OPFS / Hybrid / **Aria**) |
|
||||
|
||||
|
||||
Vendored
+106
-55
@@ -729,9 +729,14 @@ class MemoryEngine {
|
||||
// ---- 外键级联 ----
|
||||
/**
|
||||
* 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。
|
||||
* v0.6.1-fix: 环路保护(A→B→A 级联环不再无限递归栈溢出,AriaEngine 同语义)。
|
||||
* @returns 级联删除的行数
|
||||
*/
|
||||
async cascadeDelete(tableName, pkValue, _row) {
|
||||
async cascadeDelete(tableName, pkValue, _row, visited = new Set()) {
|
||||
const visitKey = `${tableName}:${pkValue}`;
|
||||
if (visited.has(visitKey))
|
||||
return 0;
|
||||
visited.add(visitKey);
|
||||
let totalCascade = 0;
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
@@ -763,7 +768,7 @@ class MemoryEngine {
|
||||
if (refRow) {
|
||||
// v0.3.3: 级联删除前清理索引条目
|
||||
this.removeIndexEntries(refTableName, refRow, refPk);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
|
||||
}
|
||||
refTableData.delete(refPk);
|
||||
totalCascade++;
|
||||
@@ -1726,6 +1731,8 @@ class KVStoreEngine {
|
||||
this.txActive = false;
|
||||
/** 事务中写过的表(commit 时只 flush 这些表) */
|
||||
this.txDirtyTables = new Set();
|
||||
/** 事务中发生 schema 变更(DDL)—— commit 时持久化 schema */
|
||||
this.txSchemaChanged = false;
|
||||
this.kv = new KVStore(medium, checkpointThreshold);
|
||||
}
|
||||
// ---- 行 key 编解码 ----
|
||||
@@ -1850,6 +1857,7 @@ class KVStoreEngine {
|
||||
await this.memory.createTable(schema);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(schema.name);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -1859,19 +1867,27 @@ class KVStoreEngine {
|
||||
await this.memory.dropTable(tableName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
// 删除该表全部行(KV 中残留清理)
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
async hasTable(tableName) {
|
||||
this.ensureOpen();
|
||||
return this.memory.hasTable(tableName);
|
||||
}
|
||||
async getTableNames() {
|
||||
this.ensureOpen();
|
||||
return this.memory.getTableNames();
|
||||
}
|
||||
async getTableSchema(tableName) {
|
||||
this.ensureOpen();
|
||||
return this.memory.getTableSchema(tableName);
|
||||
}
|
||||
async alterTable(tableName, action, column) {
|
||||
@@ -1879,12 +1895,17 @@ class KVStoreEngine {
|
||||
await this.memory.alterTable(tableName, action, column);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
if (action === 'DROP') {
|
||||
// 重写存储行(移除该列)
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
}
|
||||
// ---- CRUD ----
|
||||
@@ -1929,16 +1950,18 @@ class KVStoreEngine {
|
||||
this.txDirtyTables.add(tableName);
|
||||
return count;
|
||||
}
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
if (pkChanged) {
|
||||
// 主键变更:相关表整表 diff(罕见操作,可靠性优先)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
await this.flushTable(t);
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 增量重写受影响行
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
for (const pk of affected) {
|
||||
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
|
||||
if (row.length > 0) {
|
||||
@@ -1948,16 +1971,20 @@ class KVStoreEngine {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
}
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName)
|
||||
await this.flushTable(t);
|
||||
if (t === tableName)
|
||||
continue;
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
}
|
||||
// 单次原子写(一条日志记录 = 真原子,v0.6.1)
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
return count;
|
||||
}
|
||||
async delete(tableName, query) {
|
||||
@@ -1969,14 +1996,20 @@ class KVStoreEngine {
|
||||
this.txDirtyTables.add(tableName);
|
||||
return count;
|
||||
}
|
||||
const puts = {};
|
||||
const deletes = pks.map((pk) => this.rowKey(tableName, pk));
|
||||
// 级联影响表整表 diff(合并到单次原子写,v0.6.1)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t === tableName)
|
||||
continue;
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 级联影响表整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName)
|
||||
await this.flushTable(t);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
async count(tableName, query) {
|
||||
@@ -1990,7 +2023,11 @@ class KVStoreEngine {
|
||||
this.txDirtyTables.add(tableName);
|
||||
return;
|
||||
}
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
// ---- 动态索引 ----
|
||||
async createIndex(tableName, column, unique) {
|
||||
@@ -1998,6 +2035,7 @@ class KVStoreEngine {
|
||||
await this.memory.createIndex(tableName, column, unique);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -2007,6 +2045,7 @@ class KVStoreEngine {
|
||||
await this.memory.dropIndex(tableName, column, indexName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -2017,27 +2056,40 @@ class KVStoreEngine {
|
||||
await this.memory.beginTransaction();
|
||||
this.txActive = true;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
}
|
||||
async commitTransaction() {
|
||||
this.ensureOpen();
|
||||
if (!this.txActive)
|
||||
throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
// 先持久化(原子),再提交内存快照(失败可回滚)
|
||||
// v0.6.1: 全部 dirty 表合并为单次原子 flush(一条日志记录 = 真原子,
|
||||
// 多表事务中途崩溃/失败不会出现"部分表已提交")
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
for (const table of this.txDirtyTables) {
|
||||
if (await this.memory.hasTable(table)) {
|
||||
await this.flushTable(table);
|
||||
const diff = await this.collectTableDiff(table);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
else {
|
||||
// 事务内 drop 的表:清理 KV 残留行
|
||||
const all = await this.kv.getAll();
|
||||
const prefix = this.rowPrefix(table);
|
||||
const deletes = all.filter(([key]) => key.startsWith(prefix)).map(([key]) => key);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// v0.6.0-fix: 事务内 DDL(create/drop/alter)的 schema 一并持久化
|
||||
await this.persistSchema();
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 事务内 DDL 的 schema 一并持久化
|
||||
if (this.txSchemaChanged) {
|
||||
await this.persistSchema();
|
||||
}
|
||||
await this.kv.checkpoint();
|
||||
await this.memory.commitTransaction();
|
||||
this.txActive = false;
|
||||
@@ -2050,6 +2102,7 @@ class KVStoreEngine {
|
||||
await this.memory.rollbackTransaction();
|
||||
this.txActive = false;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
}
|
||||
// ---- 内部 ----
|
||||
ensureOpen() {
|
||||
@@ -2112,41 +2165,39 @@ class KVStoreEngine {
|
||||
await this.kv.put(SCHEMA_KEY, enc(JSON.stringify(schemas)));
|
||||
}
|
||||
/**
|
||||
* 整表 diff 持久化:内存行全部 put + KV 残留行删除(原子 putMany + deleteMany)。
|
||||
* 用于主键变更 / 级联 / dropTable / clear / alterTable DROP / 事务 commit。
|
||||
* v0.6.1: 整表 diff 收集(不落盘):内存行全部 put + KV 残留行删除。
|
||||
* 调用方合并到单次原子 putMany/deleteMany(多表操作真原子)。
|
||||
*/
|
||||
async flushTable(tableName) {
|
||||
async collectTableDiff(tableName) {
|
||||
const prefix = this.rowPrefix(tableName);
|
||||
// 表已删除:仅清理 KV 残留行
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (!schema) {
|
||||
const all = await this.kv.getAll();
|
||||
const deletes = all.filter(([key]) => key.startsWith(prefix)).map(([key]) => key);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
return;
|
||||
}
|
||||
const pkCol = this.getPK(schema);
|
||||
const rows = await this.memory.find(tableName, { table: tableName });
|
||||
const puts = {};
|
||||
const current = new Set();
|
||||
for (const row of rows) {
|
||||
const key = this.rowKey(tableName, String(row[pkCol]));
|
||||
current.add(key);
|
||||
puts[key] = enc(JSON.stringify(row));
|
||||
}
|
||||
// KV 残留行(内存中已不存在)删除
|
||||
const all = await this.kv.getAll();
|
||||
const deletes = [];
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix) && !current.has(key)) {
|
||||
deletes.push(key);
|
||||
// 表已删除:仅收集 KV 残留行删除
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (schema) {
|
||||
const pkCol = this.getPK(schema);
|
||||
const rows = await this.memory.find(tableName, { table: tableName });
|
||||
const current = new Set();
|
||||
for (const row of rows) {
|
||||
const key = this.rowKey(tableName, String(row[pkCol]));
|
||||
current.add(key);
|
||||
puts[key] = enc(JSON.stringify(row));
|
||||
}
|
||||
// KV 残留行(内存中已不存在)
|
||||
const all = await this.kv.getAll();
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix) && !current.has(key))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
else {
|
||||
const all = await this.kv.getAll();
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
return { puts, deletes };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+6
-3
@@ -827,6 +827,7 @@ declare class MemoryEngine implements IStorageEngine {
|
||||
private removeIndexEntries;
|
||||
/**
|
||||
* 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。
|
||||
* v0.6.1-fix: 环路保护(A→B→A 级联环不再无限递归栈溢出,AriaEngine 同语义)。
|
||||
* @returns 级联删除的行数
|
||||
*/
|
||||
private cascadeDelete;
|
||||
@@ -887,6 +888,8 @@ declare class KVStoreEngine implements IStorageEngine {
|
||||
private txActive;
|
||||
/** 事务中写过的表(commit 时只 flush 这些表) */
|
||||
private txDirtyTables;
|
||||
/** 事务中发生 schema 变更(DDL)—— commit 时持久化 schema */
|
||||
private txSchemaChanged;
|
||||
constructor(medium?: IStorageBackend, checkpointThreshold?: number);
|
||||
private rowKey;
|
||||
private rowPrefix;
|
||||
@@ -936,10 +939,10 @@ declare class KVStoreEngine implements IStorageEngine {
|
||||
/** 持久化 schema(全部表) */
|
||||
private persistSchema;
|
||||
/**
|
||||
* 整表 diff 持久化:内存行全部 put + KV 残留行删除(原子 putMany + deleteMany)。
|
||||
* 用于主键变更 / 级联 / dropTable / clear / alterTable DROP / 事务 commit。
|
||||
* v0.6.1: 整表 diff 收集(不落盘):内存行全部 put + KV 残留行删除。
|
||||
* 调用方合并到单次原子 putMany/deleteMany(多表操作真原子)。
|
||||
*/
|
||||
private flushTable;
|
||||
private collectTableDiff;
|
||||
}
|
||||
|
||||
declare class AriaEngine implements IStorageEngine {
|
||||
|
||||
Vendored
+106
-55
@@ -725,9 +725,14 @@ class MemoryEngine {
|
||||
// ---- 外键级联 ----
|
||||
/**
|
||||
* 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。
|
||||
* v0.6.1-fix: 环路保护(A→B→A 级联环不再无限递归栈溢出,AriaEngine 同语义)。
|
||||
* @returns 级联删除的行数
|
||||
*/
|
||||
async cascadeDelete(tableName, pkValue, _row) {
|
||||
async cascadeDelete(tableName, pkValue, _row, visited = new Set()) {
|
||||
const visitKey = `${tableName}:${pkValue}`;
|
||||
if (visited.has(visitKey))
|
||||
return 0;
|
||||
visited.add(visitKey);
|
||||
let totalCascade = 0;
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
@@ -759,7 +764,7 @@ class MemoryEngine {
|
||||
if (refRow) {
|
||||
// v0.3.3: 级联删除前清理索引条目
|
||||
this.removeIndexEntries(refTableName, refRow, refPk);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
|
||||
}
|
||||
refTableData.delete(refPk);
|
||||
totalCascade++;
|
||||
@@ -1722,6 +1727,8 @@ class KVStoreEngine {
|
||||
this.txActive = false;
|
||||
/** 事务中写过的表(commit 时只 flush 这些表) */
|
||||
this.txDirtyTables = new Set();
|
||||
/** 事务中发生 schema 变更(DDL)—— commit 时持久化 schema */
|
||||
this.txSchemaChanged = false;
|
||||
this.kv = new KVStore(medium, checkpointThreshold);
|
||||
}
|
||||
// ---- 行 key 编解码 ----
|
||||
@@ -1846,6 +1853,7 @@ class KVStoreEngine {
|
||||
await this.memory.createTable(schema);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(schema.name);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -1855,19 +1863,27 @@ class KVStoreEngine {
|
||||
await this.memory.dropTable(tableName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
// 删除该表全部行(KV 中残留清理)
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
async hasTable(tableName) {
|
||||
this.ensureOpen();
|
||||
return this.memory.hasTable(tableName);
|
||||
}
|
||||
async getTableNames() {
|
||||
this.ensureOpen();
|
||||
return this.memory.getTableNames();
|
||||
}
|
||||
async getTableSchema(tableName) {
|
||||
this.ensureOpen();
|
||||
return this.memory.getTableSchema(tableName);
|
||||
}
|
||||
async alterTable(tableName, action, column) {
|
||||
@@ -1875,12 +1891,17 @@ class KVStoreEngine {
|
||||
await this.memory.alterTable(tableName, action, column);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
if (action === 'DROP') {
|
||||
// 重写存储行(移除该列)
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
}
|
||||
// ---- CRUD ----
|
||||
@@ -1925,16 +1946,18 @@ class KVStoreEngine {
|
||||
this.txDirtyTables.add(tableName);
|
||||
return count;
|
||||
}
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
if (pkChanged) {
|
||||
// 主键变更:相关表整表 diff(罕见操作,可靠性优先)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
await this.flushTable(t);
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 增量重写受影响行
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
for (const pk of affected) {
|
||||
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
|
||||
if (row.length > 0) {
|
||||
@@ -1944,16 +1967,20 @@ class KVStoreEngine {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
}
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName)
|
||||
await this.flushTable(t);
|
||||
if (t === tableName)
|
||||
continue;
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
}
|
||||
// 单次原子写(一条日志记录 = 真原子,v0.6.1)
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
return count;
|
||||
}
|
||||
async delete(tableName, query) {
|
||||
@@ -1965,14 +1992,20 @@ class KVStoreEngine {
|
||||
this.txDirtyTables.add(tableName);
|
||||
return count;
|
||||
}
|
||||
const puts = {};
|
||||
const deletes = pks.map((pk) => this.rowKey(tableName, pk));
|
||||
// 级联影响表整表 diff(合并到单次原子写,v0.6.1)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t === tableName)
|
||||
continue;
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 级联影响表整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName)
|
||||
await this.flushTable(t);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
async count(tableName, query) {
|
||||
@@ -1986,7 +2019,11 @@ class KVStoreEngine {
|
||||
this.txDirtyTables.add(tableName);
|
||||
return;
|
||||
}
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
// ---- 动态索引 ----
|
||||
async createIndex(tableName, column, unique) {
|
||||
@@ -1994,6 +2031,7 @@ class KVStoreEngine {
|
||||
await this.memory.createIndex(tableName, column, unique);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -2003,6 +2041,7 @@ class KVStoreEngine {
|
||||
await this.memory.dropIndex(tableName, column, indexName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -2013,27 +2052,40 @@ class KVStoreEngine {
|
||||
await this.memory.beginTransaction();
|
||||
this.txActive = true;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
}
|
||||
async commitTransaction() {
|
||||
this.ensureOpen();
|
||||
if (!this.txActive)
|
||||
throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
// 先持久化(原子),再提交内存快照(失败可回滚)
|
||||
// v0.6.1: 全部 dirty 表合并为单次原子 flush(一条日志记录 = 真原子,
|
||||
// 多表事务中途崩溃/失败不会出现"部分表已提交")
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
for (const table of this.txDirtyTables) {
|
||||
if (await this.memory.hasTable(table)) {
|
||||
await this.flushTable(table);
|
||||
const diff = await this.collectTableDiff(table);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
else {
|
||||
// 事务内 drop 的表:清理 KV 残留行
|
||||
const all = await this.kv.getAll();
|
||||
const prefix = this.rowPrefix(table);
|
||||
const deletes = all.filter(([key]) => key.startsWith(prefix)).map(([key]) => key);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// v0.6.0-fix: 事务内 DDL(create/drop/alter)的 schema 一并持久化
|
||||
await this.persistSchema();
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 事务内 DDL 的 schema 一并持久化
|
||||
if (this.txSchemaChanged) {
|
||||
await this.persistSchema();
|
||||
}
|
||||
await this.kv.checkpoint();
|
||||
await this.memory.commitTransaction();
|
||||
this.txActive = false;
|
||||
@@ -2046,6 +2098,7 @@ class KVStoreEngine {
|
||||
await this.memory.rollbackTransaction();
|
||||
this.txActive = false;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
}
|
||||
// ---- 内部 ----
|
||||
ensureOpen() {
|
||||
@@ -2108,41 +2161,39 @@ class KVStoreEngine {
|
||||
await this.kv.put(SCHEMA_KEY, enc(JSON.stringify(schemas)));
|
||||
}
|
||||
/**
|
||||
* 整表 diff 持久化:内存行全部 put + KV 残留行删除(原子 putMany + deleteMany)。
|
||||
* 用于主键变更 / 级联 / dropTable / clear / alterTable DROP / 事务 commit。
|
||||
* v0.6.1: 整表 diff 收集(不落盘):内存行全部 put + KV 残留行删除。
|
||||
* 调用方合并到单次原子 putMany/deleteMany(多表操作真原子)。
|
||||
*/
|
||||
async flushTable(tableName) {
|
||||
async collectTableDiff(tableName) {
|
||||
const prefix = this.rowPrefix(tableName);
|
||||
// 表已删除:仅清理 KV 残留行
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (!schema) {
|
||||
const all = await this.kv.getAll();
|
||||
const deletes = all.filter(([key]) => key.startsWith(prefix)).map(([key]) => key);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
return;
|
||||
}
|
||||
const pkCol = this.getPK(schema);
|
||||
const rows = await this.memory.find(tableName, { table: tableName });
|
||||
const puts = {};
|
||||
const current = new Set();
|
||||
for (const row of rows) {
|
||||
const key = this.rowKey(tableName, String(row[pkCol]));
|
||||
current.add(key);
|
||||
puts[key] = enc(JSON.stringify(row));
|
||||
}
|
||||
// KV 残留行(内存中已不存在)删除
|
||||
const all = await this.kv.getAll();
|
||||
const deletes = [];
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix) && !current.has(key)) {
|
||||
deletes.push(key);
|
||||
// 表已删除:仅收集 KV 残留行删除
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (schema) {
|
||||
const pkCol = this.getPK(schema);
|
||||
const rows = await this.memory.find(tableName, { table: tableName });
|
||||
const current = new Set();
|
||||
for (const row of rows) {
|
||||
const key = this.rowKey(tableName, String(row[pkCol]));
|
||||
current.add(key);
|
||||
puts[key] = enc(JSON.stringify(row));
|
||||
}
|
||||
// KV 残留行(内存中已不存在)
|
||||
const all = await this.kv.getAll();
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix) && !current.has(key))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
else {
|
||||
const all = await this.kv.getAll();
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
return { puts, deletes };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+106
-55
@@ -731,9 +731,14 @@
|
||||
// ---- 外键级联 ----
|
||||
/**
|
||||
* 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。
|
||||
* v0.6.1-fix: 环路保护(A→B→A 级联环不再无限递归栈溢出,AriaEngine 同语义)。
|
||||
* @returns 级联删除的行数
|
||||
*/
|
||||
async cascadeDelete(tableName, pkValue, _row) {
|
||||
async cascadeDelete(tableName, pkValue, _row, visited = new Set()) {
|
||||
const visitKey = `${tableName}:${pkValue}`;
|
||||
if (visited.has(visitKey))
|
||||
return 0;
|
||||
visited.add(visitKey);
|
||||
let totalCascade = 0;
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
@@ -765,7 +770,7 @@
|
||||
if (refRow) {
|
||||
// v0.3.3: 级联删除前清理索引条目
|
||||
this.removeIndexEntries(refTableName, refRow, refPk);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
|
||||
}
|
||||
refTableData.delete(refPk);
|
||||
totalCascade++;
|
||||
@@ -1728,6 +1733,8 @@
|
||||
this.txActive = false;
|
||||
/** 事务中写过的表(commit 时只 flush 这些表) */
|
||||
this.txDirtyTables = new Set();
|
||||
/** 事务中发生 schema 变更(DDL)—— commit 时持久化 schema */
|
||||
this.txSchemaChanged = false;
|
||||
this.kv = new KVStore(medium, checkpointThreshold);
|
||||
}
|
||||
// ---- 行 key 编解码 ----
|
||||
@@ -1852,6 +1859,7 @@
|
||||
await this.memory.createTable(schema);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(schema.name);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -1861,19 +1869,27 @@
|
||||
await this.memory.dropTable(tableName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
// 删除该表全部行(KV 中残留清理)
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
async hasTable(tableName) {
|
||||
this.ensureOpen();
|
||||
return this.memory.hasTable(tableName);
|
||||
}
|
||||
async getTableNames() {
|
||||
this.ensureOpen();
|
||||
return this.memory.getTableNames();
|
||||
}
|
||||
async getTableSchema(tableName) {
|
||||
this.ensureOpen();
|
||||
return this.memory.getTableSchema(tableName);
|
||||
}
|
||||
async alterTable(tableName, action, column) {
|
||||
@@ -1881,12 +1897,17 @@
|
||||
await this.memory.alterTable(tableName, action, column);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
if (action === 'DROP') {
|
||||
// 重写存储行(移除该列)
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
}
|
||||
// ---- CRUD ----
|
||||
@@ -1931,16 +1952,18 @@
|
||||
this.txDirtyTables.add(tableName);
|
||||
return count;
|
||||
}
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
if (pkChanged) {
|
||||
// 主键变更:相关表整表 diff(罕见操作,可靠性优先)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
await this.flushTable(t);
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 增量重写受影响行
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
for (const pk of affected) {
|
||||
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
|
||||
if (row.length > 0) {
|
||||
@@ -1950,16 +1973,20 @@
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
}
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName)
|
||||
await this.flushTable(t);
|
||||
if (t === tableName)
|
||||
continue;
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
}
|
||||
// 单次原子写(一条日志记录 = 真原子,v0.6.1)
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
return count;
|
||||
}
|
||||
async delete(tableName, query) {
|
||||
@@ -1971,14 +1998,20 @@
|
||||
this.txDirtyTables.add(tableName);
|
||||
return count;
|
||||
}
|
||||
const puts = {};
|
||||
const deletes = pks.map((pk) => this.rowKey(tableName, pk));
|
||||
// 级联影响表整表 diff(合并到单次原子写,v0.6.1)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t === tableName)
|
||||
continue;
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 级联影响表整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName)
|
||||
await this.flushTable(t);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
async count(tableName, query) {
|
||||
@@ -1992,7 +2025,11 @@
|
||||
this.txDirtyTables.add(tableName);
|
||||
return;
|
||||
}
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0)
|
||||
await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0)
|
||||
await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
// ---- 动态索引 ----
|
||||
async createIndex(tableName, column, unique) {
|
||||
@@ -2000,6 +2037,7 @@
|
||||
await this.memory.createIndex(tableName, column, unique);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -2009,6 +2047,7 @@
|
||||
await this.memory.dropIndex(tableName, column, indexName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -2019,27 +2058,40 @@
|
||||
await this.memory.beginTransaction();
|
||||
this.txActive = true;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
}
|
||||
async commitTransaction() {
|
||||
this.ensureOpen();
|
||||
if (!this.txActive)
|
||||
throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
// 先持久化(原子),再提交内存快照(失败可回滚)
|
||||
// v0.6.1: 全部 dirty 表合并为单次原子 flush(一条日志记录 = 真原子,
|
||||
// 多表事务中途崩溃/失败不会出现"部分表已提交")
|
||||
const puts = {};
|
||||
const deletes = [];
|
||||
for (const table of this.txDirtyTables) {
|
||||
if (await this.memory.hasTable(table)) {
|
||||
await this.flushTable(table);
|
||||
const diff = await this.collectTableDiff(table);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
else {
|
||||
// 事务内 drop 的表:清理 KV 残留行
|
||||
const all = await this.kv.getAll();
|
||||
const prefix = this.rowPrefix(table);
|
||||
const deletes = all.filter(([key]) => key.startsWith(prefix)).map(([key]) => key);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// v0.6.0-fix: 事务内 DDL(create/drop/alter)的 schema 一并持久化
|
||||
await this.persistSchema();
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
// 事务内 DDL 的 schema 一并持久化
|
||||
if (this.txSchemaChanged) {
|
||||
await this.persistSchema();
|
||||
}
|
||||
await this.kv.checkpoint();
|
||||
await this.memory.commitTransaction();
|
||||
this.txActive = false;
|
||||
@@ -2052,6 +2104,7 @@
|
||||
await this.memory.rollbackTransaction();
|
||||
this.txActive = false;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
}
|
||||
// ---- 内部 ----
|
||||
ensureOpen() {
|
||||
@@ -2114,41 +2167,39 @@
|
||||
await this.kv.put(SCHEMA_KEY, enc(JSON.stringify(schemas)));
|
||||
}
|
||||
/**
|
||||
* 整表 diff 持久化:内存行全部 put + KV 残留行删除(原子 putMany + deleteMany)。
|
||||
* 用于主键变更 / 级联 / dropTable / clear / alterTable DROP / 事务 commit。
|
||||
* v0.6.1: 整表 diff 收集(不落盘):内存行全部 put + KV 残留行删除。
|
||||
* 调用方合并到单次原子 putMany/deleteMany(多表操作真原子)。
|
||||
*/
|
||||
async flushTable(tableName) {
|
||||
async collectTableDiff(tableName) {
|
||||
const prefix = this.rowPrefix(tableName);
|
||||
// 表已删除:仅清理 KV 残留行
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (!schema) {
|
||||
const all = await this.kv.getAll();
|
||||
const deletes = all.filter(([key]) => key.startsWith(prefix)).map(([key]) => key);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
return;
|
||||
}
|
||||
const pkCol = this.getPK(schema);
|
||||
const rows = await this.memory.find(tableName, { table: tableName });
|
||||
const puts = {};
|
||||
const current = new Set();
|
||||
for (const row of rows) {
|
||||
const key = this.rowKey(tableName, String(row[pkCol]));
|
||||
current.add(key);
|
||||
puts[key] = enc(JSON.stringify(row));
|
||||
}
|
||||
// KV 残留行(内存中已不存在)删除
|
||||
const all = await this.kv.getAll();
|
||||
const deletes = [];
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix) && !current.has(key)) {
|
||||
deletes.push(key);
|
||||
// 表已删除:仅收集 KV 残留行删除
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (schema) {
|
||||
const pkCol = this.getPK(schema);
|
||||
const rows = await this.memory.find(tableName, { table: tableName });
|
||||
const current = new Set();
|
||||
for (const row of rows) {
|
||||
const key = this.rowKey(tableName, String(row[pkCol]));
|
||||
current.add(key);
|
||||
puts[key] = enc(JSON.stringify(row));
|
||||
}
|
||||
// KV 残留行(内存中已不存在)
|
||||
const all = await this.kv.getAll();
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix) && !current.has(key))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
if (Object.keys(puts).length > 0)
|
||||
await this.kv.putMany(puts);
|
||||
if (deletes.length > 0)
|
||||
await this.kv.deleteMany(deletes);
|
||||
else {
|
||||
const all = await this.kv.getAll();
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix))
|
||||
deletes.push(key);
|
||||
}
|
||||
}
|
||||
return { puts, deletes };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
+1
-1
@@ -767,7 +767,7 @@ db.<span class="f">broadcastChange</span>(<span class="s">'users'</span>);</pre>
|
||||
<strong>v0.4.4 SSTable 编码修复</strong> — 大段中文内容(如 300KB 笔记)写入 AriaEngine 不再崩溃:块大小估算改 UTF-8 字节精确计算(修复中文 3 字节 vs 1 码元导致的缓冲区低估越界)· 长度字段 u16 → u32(修复 >64KB value 截断)· 大 value 独立成块 · 格式 v2("SSTC")与 v1("SSTB")双格式兼容(旧库数据不丢)· 中文主键 / 大内容索引列同步支持。<br>
|
||||
<strong>v0.5.0 存储后端生产级硬化</strong> — 真实 CRC-32 完整性校验(SSTable 整文件 + WAL 记录,旧文件兼容)· 全库 AES-256-GCM 透明加密(EncryptedBackend + PBKDF2 密钥派生 + 密码验证)· WAL 分片文件重构(真追加 + 空洞检测 + 旧格式迁移)· SSTable 4KB 页面化物理存储(BufferPool/FileManager 真实接入,meta 存 pageIds 兼容旧数据)· OPFS 后端 v2(append 真追加 / 写队列健壮性 / 残留清理)· Web Locks 多标签页独占锁(ARIA_LOCKED)· LZ4 v2 原始大小头 · Playwright 真实 Chromium e2e(7 用例)· DatabaseConfig.aria 配置透传。<br>
|
||||
<strong>v0.5.1 深度审查修复</strong> — 14 个生命周期钩子全部真实接线(此前 6 个 CRUD 钩子从未触发)· EXPLAIN / ANALYZE / REINDEX / VACUUM / SAVEPOINT SQL 入口补齐(此前仅有引擎方法无法触发)· db.backup() 公共方法 · 删除全部死代码(utils.ts 整文件 / MVCC 读侧 / estimateQueryCost 未接线优化器 / 40+ 统计辅助方法)。<br>
|
||||
<strong>v0.6.0 完全移除 IndexedDB</strong> — 自研 KVStore 事务存储引擎(多 key 原子写 = 单日志记录原子追加 · 快照 checkpoint + 两阶段崩溃恢复 · CRC-32 自愈)· disk 模式切换 KVStoreEngine(替代 IndexedDBEngine + OPFSEngine)· 事务内 DDL / 外键级联 / 二级索引完整持久化 · migrateFromIndexedDB() 旧库一键迁移 · 10 万 key 压力验证 · e2e 崩溃注入 + KVStoreEngine 真实环境(12 用例)· 1022 测试 64 套件 · 89.15% 行覆盖率。</p>
|
||||
<strong>v0.6.0 完全移除 IndexedDB</strong> — 自研 KVStore 事务存储引擎(多 key 原子写 = 单日志记录原子追加 · 快照 checkpoint + 两阶段崩溃恢复 · CRC-32 自愈)· disk 模式切换 KVStoreEngine(替代 IndexedDBEngine + OPFSEngine)· 事务内 DDL / 外键级联 / 二级索引完整持久化 · migrateFromIndexedDB() 旧库一键迁移 · 10 万 key 压力验证 · e2e 崩溃注入 + KVStoreEngine 真实环境(12 用例)· 1049 测试 64 套件 · 89.3% 行覆盖率。</p>
|
||||
|
||||
<h3>存储模式对比</h3>
|
||||
<table>
|
||||
|
||||
+3
-3
@@ -153,7 +153,7 @@
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.6.0 完全移除 IndexedDB — 1022测试 64套件 · 自研 KVStore 事务引擎 · 多 key 原子写 · 崩溃恢复 · 旧库一键迁移</div>
|
||||
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.6.0 完全移除 IndexedDB — 1049测试 64套件 · 自研 KVStore 事务引擎 · 多 key 原子写 · 崩溃恢复 · 旧库一键迁移</div>
|
||||
<h1>前端的 <span class="gradient-text">SQL 数据库</span></h1>
|
||||
<p>TypeScript 原生构建,5 种存储引擎,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。AriaEngine 自研引擎:LSM-Tree + WAL 同步 + MVCC。</p>
|
||||
<div class="actions">
|
||||
@@ -414,8 +414,8 @@ npm install @metona-team/metona-sqlark
|
||||
<p>MetonaSqlark 的核心指标</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat-card"><div class="num">1022</div><div class="label">测试用例</div></div>
|
||||
<div class="stat-card"><div class="num">89.15%</div><div class="label">行覆盖率</div></div>
|
||||
<div class="stat-card"><div class="num">1049</div><div class="label">测试用例</div></div>
|
||||
<div class="stat-card"><div class="num">89.3%</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>
|
||||
|
||||
@@ -44,6 +44,8 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
private txActive = false;
|
||||
/** 事务中写过的表(commit 时只 flush 这些表) */
|
||||
private txDirtyTables: Set<string> = new Set();
|
||||
/** 事务中发生 schema 变更(DDL)—— commit 时持久化 schema */
|
||||
private txSchemaChanged = false;
|
||||
|
||||
constructor(medium?: IStorageBackend, checkpointThreshold?: number) {
|
||||
this.kv = new KVStore(medium, checkpointThreshold);
|
||||
@@ -175,6 +177,7 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
await this.memory.createTable(schema);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(schema.name);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -185,22 +188,28 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
await this.memory.dropTable(tableName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
// 删除该表全部行(KV 中残留清理)
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0) await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0) await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
|
||||
async hasTable(tableName: string): Promise<boolean> {
|
||||
this.ensureOpen();
|
||||
return this.memory.hasTable(tableName);
|
||||
}
|
||||
|
||||
async getTableNames(): Promise<string[]> {
|
||||
this.ensureOpen();
|
||||
return this.memory.getTableNames();
|
||||
}
|
||||
|
||||
async getTableSchema(tableName: string): Promise<TableSchema | null> {
|
||||
this.ensureOpen();
|
||||
return this.memory.getTableSchema(tableName);
|
||||
}
|
||||
|
||||
@@ -213,12 +222,15 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
await this.memory.alterTable(tableName, action, column);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
if (action === 'DROP') {
|
||||
// 重写存储行(移除该列)
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0) await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0) await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,15 +284,17 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
return count;
|
||||
}
|
||||
|
||||
const puts: Record<string, ArrayBuffer> = {};
|
||||
const deletes: string[] = [];
|
||||
if (pkChanged) {
|
||||
// 主键变更:相关表整表 diff(罕见操作,可靠性优先)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
await this.flushTable(t);
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
} else {
|
||||
// 增量重写受影响行
|
||||
const puts: Record<string, ArrayBuffer> = {};
|
||||
const deletes: string[] = [];
|
||||
for (const pk of affected) {
|
||||
const row = await this.memory.find(tableName, { table: tableName, where: { [pkCol]: pk } });
|
||||
if (row.length > 0) {
|
||||
@@ -289,13 +303,17 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
deletes.push(this.rowKey(tableName, pk));
|
||||
}
|
||||
}
|
||||
if (Object.keys(puts).length > 0) await this.kv.putMany(puts);
|
||||
if (deletes.length > 0) await this.kv.deleteMany(deletes);
|
||||
// 级联影响表(SET NULL/CASCADE 外键)整表 diff
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName) await this.flushTable(t);
|
||||
if (t === tableName) continue;
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
}
|
||||
// 单次原子写(一条日志记录 = 真原子,v0.6.1)
|
||||
if (Object.keys(puts).length > 0) await this.kv.putMany(puts);
|
||||
if (deletes.length > 0) await this.kv.deleteMany(deletes);
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -308,12 +326,17 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
this.txDirtyTables.add(tableName);
|
||||
return count;
|
||||
}
|
||||
const puts: Record<string, ArrayBuffer> = {};
|
||||
const deletes = pks.map((pk) => this.rowKey(tableName, pk));
|
||||
if (deletes.length > 0) await this.kv.deleteMany(deletes);
|
||||
// 级联影响表整表 diff
|
||||
// 级联影响表整表 diff(合并到单次原子写,v0.6.1)
|
||||
for (const t of await this.affectedTables(tableName)) {
|
||||
if (t !== tableName) await this.flushTable(t);
|
||||
if (t === tableName) continue;
|
||||
const diff = await this.collectTableDiff(t);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
}
|
||||
if (Object.keys(puts).length > 0) await this.kv.putMany(puts);
|
||||
if (deletes.length > 0) await this.kv.deleteMany(deletes);
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -329,7 +352,9 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
this.txDirtyTables.add(tableName);
|
||||
return;
|
||||
}
|
||||
await this.flushTable(tableName);
|
||||
const diff = await this.collectTableDiff(tableName);
|
||||
if (Object.keys(diff.puts).length > 0) await this.kv.putMany(diff.puts);
|
||||
if (diff.deletes.length > 0) await this.kv.deleteMany(diff.deletes);
|
||||
}
|
||||
|
||||
// ---- 动态索引 ----
|
||||
@@ -339,6 +364,7 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
await this.memory.createIndex(tableName, column, unique);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -349,6 +375,7 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
await this.memory.dropIndex(tableName, column, indexName);
|
||||
if (this.txActive) {
|
||||
this.txDirtyTables.add(tableName);
|
||||
this.txSchemaChanged = true;
|
||||
return;
|
||||
}
|
||||
await this.persistSchema();
|
||||
@@ -361,25 +388,36 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
await this.memory.beginTransaction();
|
||||
this.txActive = true;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
}
|
||||
|
||||
async commitTransaction(): Promise<void> {
|
||||
this.ensureOpen();
|
||||
if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE');
|
||||
// 先持久化(原子),再提交内存快照(失败可回滚)
|
||||
// v0.6.1: 全部 dirty 表合并为单次原子 flush(一条日志记录 = 真原子,
|
||||
// 多表事务中途崩溃/失败不会出现"部分表已提交")
|
||||
const puts: Record<string, ArrayBuffer> = {};
|
||||
const deletes: string[] = [];
|
||||
for (const table of this.txDirtyTables) {
|
||||
if (await this.memory.hasTable(table)) {
|
||||
await this.flushTable(table);
|
||||
const diff = await this.collectTableDiff(table);
|
||||
Object.assign(puts, diff.puts);
|
||||
deletes.push(...diff.deletes);
|
||||
} else {
|
||||
// 事务内 drop 的表:清理 KV 残留行
|
||||
const all = await this.kv.getAll();
|
||||
const prefix = this.rowPrefix(table);
|
||||
const deletes = all.filter(([key]) => key.startsWith(prefix)).map(([key]) => key);
|
||||
if (deletes.length > 0) await this.kv.deleteMany(deletes);
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix)) deletes.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// v0.6.0-fix: 事务内 DDL(create/drop/alter)的 schema 一并持久化
|
||||
await this.persistSchema();
|
||||
if (Object.keys(puts).length > 0) await this.kv.putMany(puts);
|
||||
if (deletes.length > 0) await this.kv.deleteMany(deletes);
|
||||
// 事务内 DDL 的 schema 一并持久化
|
||||
if (this.txSchemaChanged) {
|
||||
await this.persistSchema();
|
||||
}
|
||||
await this.kv.checkpoint();
|
||||
await this.memory.commitTransaction();
|
||||
this.txActive = false;
|
||||
@@ -392,6 +430,7 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
await this.memory.rollbackTransaction();
|
||||
this.txActive = false;
|
||||
this.txDirtyTables = new Set();
|
||||
this.txSchemaChanged = false;
|
||||
}
|
||||
|
||||
// ---- 内部 ----
|
||||
@@ -455,38 +494,35 @@ export class KVStoreEngine implements IStorageEngine {
|
||||
}
|
||||
|
||||
/**
|
||||
* 整表 diff 持久化:内存行全部 put + KV 残留行删除(原子 putMany + deleteMany)。
|
||||
* 用于主键变更 / 级联 / dropTable / clear / alterTable DROP / 事务 commit。
|
||||
* v0.6.1: 整表 diff 收集(不落盘):内存行全部 put + KV 残留行删除。
|
||||
* 调用方合并到单次原子 putMany/deleteMany(多表操作真原子)。
|
||||
*/
|
||||
private async flushTable(tableName: string): Promise<void> {
|
||||
private async collectTableDiff(tableName: string): Promise<{ puts: Record<string, ArrayBuffer>; deletes: string[] }> {
|
||||
const prefix = this.rowPrefix(tableName);
|
||||
// 表已删除:仅清理 KV 残留行
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (!schema) {
|
||||
const all = await this.kv.getAll();
|
||||
const deletes = all.filter(([key]) => key.startsWith(prefix)).map(([key]) => key);
|
||||
if (deletes.length > 0) await this.kv.deleteMany(deletes);
|
||||
return;
|
||||
}
|
||||
const pkCol = this.getPK(schema);
|
||||
const rows = await this.memory.find(tableName, { table: tableName });
|
||||
|
||||
const puts: Record<string, ArrayBuffer> = {};
|
||||
const current = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const key = this.rowKey(tableName, String(row[pkCol]));
|
||||
current.add(key);
|
||||
puts[key] = enc(JSON.stringify(row));
|
||||
}
|
||||
// KV 残留行(内存中已不存在)删除
|
||||
const all = await this.kv.getAll();
|
||||
const deletes: string[] = [];
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix) && !current.has(key)) {
|
||||
deletes.push(key);
|
||||
// 表已删除:仅收集 KV 残留行删除
|
||||
const schema = await this.memory.getTableSchema(tableName);
|
||||
if (schema) {
|
||||
const pkCol = this.getPK(schema);
|
||||
const rows = await this.memory.find(tableName, { table: tableName });
|
||||
const current = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const key = this.rowKey(tableName, String(row[pkCol]));
|
||||
current.add(key);
|
||||
puts[key] = enc(JSON.stringify(row));
|
||||
}
|
||||
// KV 残留行(内存中已不存在)
|
||||
const all = await this.kv.getAll();
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix) && !current.has(key)) deletes.push(key);
|
||||
}
|
||||
} else {
|
||||
const all = await this.kv.getAll();
|
||||
for (const [key] of all) {
|
||||
if (key.startsWith(prefix)) deletes.push(key);
|
||||
}
|
||||
}
|
||||
if (Object.keys(puts).length > 0) await this.kv.putMany(puts);
|
||||
if (deletes.length > 0) await this.kv.deleteMany(deletes);
|
||||
return { puts, deletes };
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -489,9 +489,18 @@ export class MemoryEngine implements IStorageEngine {
|
||||
|
||||
/**
|
||||
* 级联删除:查找引用 tableName.pkValue 的所有表的行并删除。
|
||||
* v0.6.1-fix: 环路保护(A→B→A 级联环不再无限递归栈溢出,AriaEngine 同语义)。
|
||||
* @returns 级联删除的行数
|
||||
*/
|
||||
private async cascadeDelete(tableName: string, pkValue: string, _row: Record<string, unknown>): Promise<number> {
|
||||
private async cascadeDelete(
|
||||
tableName: string,
|
||||
pkValue: string,
|
||||
_row: Record<string, unknown>,
|
||||
visited: Set<string> = new Set(),
|
||||
): Promise<number> {
|
||||
const visitKey = `${tableName}:${pkValue}`;
|
||||
if (visited.has(visitKey)) return 0;
|
||||
visited.add(visitKey);
|
||||
let totalCascade = 0;
|
||||
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
@@ -529,7 +538,7 @@ export class MemoryEngine implements IStorageEngine {
|
||||
if (refRow) {
|
||||
// v0.3.3: 级联删除前清理索引条目
|
||||
this.removeIndexEntries(refTableName, refRow, refPk);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
|
||||
}
|
||||
refTableData.delete(refPk);
|
||||
totalCascade++;
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* 生产级异常场景测试 — KVStore / KVStoreEngine 边界与故障
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. KVStore 边界:空 key/value、超大 value、特殊字符 key、未 open、重复 open/close、clear 后写
|
||||
* 2. KVStoreEngine 异常:未 open 操作、重复 close、事务嵌套、无事务 commit/rollback、
|
||||
* 级联环(A→B→A)、主键变更+索引、多表事务原子性
|
||||
* 3. SharedMemoryBackend 全分支:append 已有 key、writeMany/deleteMany、close 后安全
|
||||
* 4. 日志解析损坏分支(onCorrupt false 提前停止)
|
||||
*/
|
||||
import { KVStore } from '../src/engine/kvstore/index';
|
||||
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
|
||||
import { encodeLogRecord, parseLogRecords } from '../src/engine/kvstore/log';
|
||||
import { KVStoreEngine } from '../src/engine/kvstore_engine';
|
||||
import { createSchema } from '../src/table/schema';
|
||||
|
||||
let counter = 0;
|
||||
function uniqueDB(): string {
|
||||
return `abn-${Date.now()}-${++counter}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
const enc = (s: string) => new TextEncoder().encode(s).buffer as ArrayBuffer;
|
||||
const dec = (b: ArrayBuffer | null) => (b ? new TextDecoder().decode(b) : null);
|
||||
|
||||
beforeEach(() => {
|
||||
SharedMemoryBackend.clearRegistry();
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// KVStore 边界
|
||||
// ===================================================================
|
||||
describe('KVStore — 边界与故障', () => {
|
||||
it('空 value / 空字符串 key 可正常读写', async () => {
|
||||
const kv = new KVStore(new SharedMemoryBackend(), 0);
|
||||
await kv.open(uniqueDB());
|
||||
await kv.put('empty', new ArrayBuffer(0));
|
||||
expect((await kv.get('empty'))!.byteLength).toBe(0);
|
||||
await kv.put('', enc('empty-key'));
|
||||
expect(dec(await kv.get(''))).toBe('empty-key');
|
||||
await kv.close();
|
||||
});
|
||||
|
||||
it('超大 value(1MB)写入恢复完整', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const medium = new SharedMemoryBackend();
|
||||
const kv = new KVStore(medium, 0);
|
||||
await kv.open(dbName);
|
||||
const big = new Uint8Array(1024 * 1024);
|
||||
for (let i = 0; i < big.length; i++) big[i] = i & 0xff;
|
||||
await kv.put('big', big.buffer as ArrayBuffer);
|
||||
await kv.close();
|
||||
|
||||
const kv2 = new KVStore(medium, 0);
|
||||
await kv2.open(dbName);
|
||||
const back = await kv2.get('big');
|
||||
expect(back!.byteLength).toBe(1024 * 1024);
|
||||
const backBytes = new Uint8Array(back!);
|
||||
expect(backBytes[500000]).toBe(500000 & 0xff);
|
||||
await kv2.close();
|
||||
});
|
||||
|
||||
it('特殊字符 key(中文/emoji/含冒号/超长)读写恢复', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const medium = new SharedMemoryBackend();
|
||||
const kv = new KVStore(medium, 0);
|
||||
await kv.open(dbName);
|
||||
const keys = ['中文键', 'emoji-😀-key', 'a:b:c', 'x'.repeat(500)];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
await kv.put(keys[i], enc(`v${i}`));
|
||||
}
|
||||
await kv.checkpoint();
|
||||
await kv.close();
|
||||
|
||||
const kv2 = new KVStore(medium, 0);
|
||||
await kv2.open(dbName);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
expect(dec(await kv2.get(keys[i]))).toBe(`v${i}`);
|
||||
}
|
||||
await kv2.close();
|
||||
});
|
||||
|
||||
it('未 open 的 KVStore 介质为空(get 返回 null,操作安全)', async () => {
|
||||
const kv = new KVStore(new SharedMemoryBackend(), 0);
|
||||
expect(kv.isOpen()).toBe(false);
|
||||
expect(await kv.get('x')).toBeNull();
|
||||
expect(kv.size()).toBe(0);
|
||||
});
|
||||
|
||||
it('重复 open / 重复 close 幂等', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const kv = new KVStore(new SharedMemoryBackend(), 0);
|
||||
await kv.open(dbName);
|
||||
await kv.open(dbName); // 幂等
|
||||
await kv.put('a', enc('A'));
|
||||
await kv.close();
|
||||
await kv.close(); // 幂等
|
||||
expect(kv.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('clear 后立即写入正常,重开数据只有新写入', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const medium = new SharedMemoryBackend();
|
||||
const kv = new KVStore(medium, 0);
|
||||
await kv.open(dbName);
|
||||
await kv.put('old', enc('OLD'));
|
||||
await kv.clear();
|
||||
await kv.put('new', enc('NEW'));
|
||||
await kv.close();
|
||||
|
||||
const kv2 = new KVStore(medium, 0);
|
||||
await kv2.open(dbName);
|
||||
expect(await kv2.exists('old')).toBe(false);
|
||||
expect(dec(await kv2.get('new'))).toBe('NEW');
|
||||
await kv2.close();
|
||||
});
|
||||
|
||||
it('大量小 key(1 万)快速写入 + 恢复', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const medium = new SharedMemoryBackend();
|
||||
const kv = new KVStore(medium, 0);
|
||||
await kv.open(dbName);
|
||||
const batch: Record<string, ArrayBuffer> = {};
|
||||
for (let i = 0; i < 10000; i++) batch[`k${i}`] = enc(`v${i}`);
|
||||
await kv.putMany(batch);
|
||||
await kv.close();
|
||||
|
||||
const kv2 = new KVStore(medium, 0);
|
||||
await kv2.open(dbName);
|
||||
expect(kv2.size()).toBe(10000);
|
||||
expect(dec(await kv2.get('k9999'))).toBe('v9999');
|
||||
await kv2.close();
|
||||
}, 60000);
|
||||
|
||||
it('写入失败后 KVStore 继续可用(错误不污染后续操作)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const medium = new SharedMemoryBackend();
|
||||
const kv = new KVStore(medium, 0);
|
||||
await kv.open(dbName);
|
||||
// 注入一次失败
|
||||
const origAppend = medium.append!.bind(medium);
|
||||
medium.append = async () => { throw new Error('boom'); };
|
||||
await expect(kv.put('x', enc('X'))).rejects.toMatchObject({ code: 'KV_LOG_ERROR' });
|
||||
medium.append = origAppend;
|
||||
// 后续写入正常
|
||||
await kv.put('y', enc('Y'));
|
||||
await kv.close();
|
||||
|
||||
const kv2 = new KVStore(medium, 0);
|
||||
await kv2.open(dbName);
|
||||
expect(dec(await kv2.get('y'))).toBe('Y');
|
||||
await kv2.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// KVStoreEngine 异常场景
|
||||
// ===================================================================
|
||||
describe('KVStoreEngine — 异常与一致性', () => {
|
||||
async function createEngine(): Promise<KVStoreEngine> {
|
||||
const e = new KVStoreEngine(new SharedMemoryBackend());
|
||||
await e.open(uniqueDB(), 1);
|
||||
return e;
|
||||
}
|
||||
|
||||
it('未 open 时所有操作抛 DB_NOT_OPEN', async () => {
|
||||
const e = new KVStoreEngine(new SharedMemoryBackend());
|
||||
await expect(e.getTableNames()).rejects.toMatchObject({ code: 'DB_NOT_OPEN' });
|
||||
await expect(e.insert('t', [{ id: '1' }])).rejects.toMatchObject({ code: 'DB_NOT_OPEN' });
|
||||
await expect(e.find('t', { table: 't' })).rejects.toMatchObject({ code: 'DB_NOT_OPEN' });
|
||||
await expect(e.count('t')).rejects.toMatchObject({ code: 'DB_NOT_OPEN' });
|
||||
await expect(e.beginTransaction()).rejects.toMatchObject({ code: 'DB_NOT_OPEN' });
|
||||
});
|
||||
|
||||
it('重复 close 幂等', async () => {
|
||||
const e = await createEngine();
|
||||
await e.close();
|
||||
await e.close();
|
||||
expect(e.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('不存在的表操作抛 TABLE_NOT_FOUND', async () => {
|
||||
const e = await createEngine();
|
||||
await expect(e.insert('ghost', [{ id: '1' }])).rejects.toMatchObject({ code: 'TABLE_NOT_FOUND' });
|
||||
await expect(e.update('ghost', { table: 'ghost' }, {})).rejects.toMatchObject({ code: 'TABLE_NOT_FOUND' });
|
||||
await expect(e.delete('ghost', { table: 'ghost' })).rejects.toMatchObject({ code: 'TABLE_NOT_FOUND' });
|
||||
await expect(e.count('ghost')).rejects.toMatchObject({ code: 'TABLE_NOT_FOUND' });
|
||||
await e.close();
|
||||
});
|
||||
|
||||
it('事务嵌套 begin 两次 → TX_ACTIVE', async () => {
|
||||
const e = await createEngine();
|
||||
await e.beginTransaction();
|
||||
await expect(e.beginTransaction()).rejects.toMatchObject({ code: 'TX_ACTIVE' });
|
||||
await e.rollbackTransaction();
|
||||
await e.close();
|
||||
});
|
||||
|
||||
it('无事务 commit/rollback → TX_NONE', async () => {
|
||||
const e = await createEngine();
|
||||
await expect(e.commitTransaction()).rejects.toMatchObject({ code: 'TX_NONE' });
|
||||
await expect(e.rollbackTransaction()).rejects.toMatchObject({ code: 'TX_NONE' });
|
||||
await e.close();
|
||||
});
|
||||
|
||||
it('事务中途失败自动回滚(多表原子性:不出现部分提交)', async () => {
|
||||
const e = await createEngine();
|
||||
await e.createTable(createSchema('t1', { id: { type: 'string', primaryKey: true } }));
|
||||
await e.createTable(createSchema('t2', { id: { type: 'string', primaryKey: true } }));
|
||||
await e.beginTransaction();
|
||||
await e.insert('t1', [{ id: 'a' }]);
|
||||
await e.insert('t2', [{ id: 'b' }]);
|
||||
// 模拟 commit 失败:注入日志追加故障(KVStore 写走日志 append)
|
||||
const medium = (e as any).kv.medium;
|
||||
const origAppend = medium.append.bind(medium);
|
||||
medium.append = async () => { throw new Error('disk full'); };
|
||||
await expect(e.commitTransaction()).rejects.toMatchObject({ code: 'KV_LOG_ERROR' });
|
||||
medium.append = origAppend;
|
||||
// commit 失败后事务未完成:手动回滚 → 内存恢复
|
||||
await e.rollbackTransaction();
|
||||
expect(await e.count('t1')).toBe(0);
|
||||
expect(await e.count('t2')).toBe(0);
|
||||
await e.close();
|
||||
|
||||
// 重启:磁盘也不应有部分提交(事务内写入全部未持久化)
|
||||
const medium2 = new SharedMemoryBackend();
|
||||
const e2 = new KVStoreEngine(medium2);
|
||||
await e2.open((e as any).dbName, 1);
|
||||
expect(await e2.count('t1')).toBe(0);
|
||||
expect(await e2.count('t2')).toBe(0);
|
||||
await e2.close();
|
||||
});
|
||||
|
||||
it('多表事务 commit 单次原子:崩溃后要么全提交要么全无', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const medium = new SharedMemoryBackend();
|
||||
const e = new KVStoreEngine(medium);
|
||||
await e.open(dbName, 1);
|
||||
await e.createTable(createSchema('t1', { id: { type: 'string', primaryKey: true } }));
|
||||
await e.createTable(createSchema('t2', { id: { type: 'string', primaryKey: true } }));
|
||||
await e.beginTransaction();
|
||||
await e.insert('t1', [{ id: 'a' }]);
|
||||
await e.insert('t2', [{ id: 'b' }]);
|
||||
await e.commitTransaction();
|
||||
// commit 后的数据在日志(未 checkpoint)—— 崩溃模拟
|
||||
await e.close();
|
||||
|
||||
const e2 = new KVStoreEngine(medium);
|
||||
await e2.open(dbName, 1);
|
||||
expect(await e2.count('t1')).toBe(1);
|
||||
expect(await e2.count('t2')).toBe(1);
|
||||
await e2.close();
|
||||
});
|
||||
|
||||
it('级联环(A→B→A)删除不无限递归', async () => {
|
||||
const e = await createEngine();
|
||||
await e.createTable(createSchema('ta', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
ref_b: { type: 'string', references: 'tb.id', onDelete: 'CASCADE' },
|
||||
}));
|
||||
await e.createTable(createSchema('tb', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
ref_a: { type: 'string', references: 'ta.id', onDelete: 'CASCADE' },
|
||||
}));
|
||||
await e.insert('ta', [{ id: 'a1', ref_b: 'b1' }]);
|
||||
await e.insert('tb', [{ id: 'b1', ref_a: 'a1' }]);
|
||||
// 删除不应死循环(visited 保护)
|
||||
await e.delete('ta', { table: 'ta', where: { id: 'a1' } });
|
||||
expect(await e.count('ta')).toBe(0);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
it('主键变更后索引一致(旧值查询无结果,新值查询命中)', async () => {
|
||||
const e = await createEngine();
|
||||
await e.createTable(createSchema('items', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
tag: { type: 'string', index: true },
|
||||
}));
|
||||
await e.insert('items', [{ id: '1', tag: 'old' }]);
|
||||
await e.update('items', { table: 'items', where: { id: '1' } }, { id: '10', tag: 'new' });
|
||||
// 旧主键查询空
|
||||
expect(await e.find('items', { table: 'items', where: { id: '1' } })).toHaveLength(0);
|
||||
// 新主键命中
|
||||
expect(await e.find('items', { table: 'items', where: { id: '10' } })).toHaveLength(1);
|
||||
// 索引:旧 tag 空,新 tag 命中
|
||||
expect(await e.find('items', { table: 'items', where: { tag: 'old' } })).toHaveLength(0);
|
||||
expect(await e.find('items', { table: 'items', where: { tag: 'new' } })).toHaveLength(1);
|
||||
await e.close();
|
||||
|
||||
// 重启后仍一致
|
||||
const e2 = new KVStoreEngine(new SharedMemoryBackend());
|
||||
await e2.open((e as any).dbName, 1);
|
||||
expect(await e2.find('items', { table: 'items', where: { tag: 'new' } })).toHaveLength(1);
|
||||
expect(await e2.find('items', { table: 'items', where: { id: '10' } })).toHaveLength(1);
|
||||
await e2.close();
|
||||
});
|
||||
|
||||
it('空表操作(count/find/clear 空表)正常', async () => {
|
||||
const e = await createEngine();
|
||||
await e.createTable(createSchema('empty', { id: { type: 'string', primaryKey: true } }));
|
||||
expect(await e.count('empty')).toBe(0);
|
||||
expect(await e.find('empty', { table: 'empty' })).toHaveLength(0);
|
||||
await e.clear('empty');
|
||||
await e.close();
|
||||
});
|
||||
|
||||
it('大批量 delete(5000 行)持久化后重启正确', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const medium = new SharedMemoryBackend();
|
||||
const e = new KVStoreEngine(medium);
|
||||
await e.open(dbName, 1);
|
||||
await e.createTable(createSchema('bulk', { id: { type: 'number', primaryKey: true } }));
|
||||
const rows = [] as Record<string, unknown>[];
|
||||
for (let i = 0; i < 5000; i++) rows.push({ id: i });
|
||||
await e.insert('bulk', rows);
|
||||
await e.delete('bulk', { table: 'bulk', where: { id: { $gte: 4000 } } });
|
||||
expect(await e.count('bulk')).toBe(4000);
|
||||
await e.close();
|
||||
|
||||
const e2 = new KVStoreEngine(medium);
|
||||
await e2.open(dbName, 1);
|
||||
expect(await e2.count('bulk')).toBe(4000);
|
||||
const remaining = await e2.find('bulk', { table: 'bulk', where: { id: { $gte: 3000 } } });
|
||||
expect(remaining).toHaveLength(1000);
|
||||
await e2.close();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// SharedMemoryBackend 全分支 + 日志解析损坏分支
|
||||
// ===================================================================
|
||||
describe('SharedMemoryBackend — 全分支', () => {
|
||||
it('append 到已存在 key(拼接)与不存在 key(新建)', async () => {
|
||||
const medium = new SharedMemoryBackend();
|
||||
await medium.open('sm-1');
|
||||
await medium.append('log', enc('AAA'));
|
||||
await medium.append('log', enc('BBB'));
|
||||
expect(dec(await medium.read('log'))).toBe('AAABBB');
|
||||
await medium.close();
|
||||
});
|
||||
|
||||
it('writeMany / deleteMany 原子批量', async () => {
|
||||
const medium = new SharedMemoryBackend();
|
||||
await medium.open('sm-2');
|
||||
await medium.writeMany({ a: enc('1'), b: enc('2'), c: enc('3') });
|
||||
expect(dec(await medium.read('b'))).toBe('2');
|
||||
await medium.deleteMany(['a', 'c']);
|
||||
expect(await medium.exists('a')).toBe(false);
|
||||
expect(await medium.exists('b')).toBe(true);
|
||||
// writeMany 覆盖已有 key
|
||||
await medium.writeMany({ b: enc('B2') });
|
||||
expect(dec(await medium.read('b'))).toBe('B2');
|
||||
await medium.close();
|
||||
});
|
||||
|
||||
it('close 后操作安全(store null 不抛错)', async () => {
|
||||
const medium = new SharedMemoryBackend();
|
||||
await medium.open('sm-3');
|
||||
await medium.write('k', enc('V'));
|
||||
await medium.close();
|
||||
expect(await medium.read('k')).toBeNull();
|
||||
await medium.write('late', enc('X')); // 静默忽略
|
||||
await medium.delete('k');
|
||||
expect(await medium.listKeys()).toEqual([]);
|
||||
expect(await medium.exists('k')).toBe(false);
|
||||
await medium.clear();
|
||||
});
|
||||
|
||||
it('跨实例共享:close 后新实例可读(持久化语义)', async () => {
|
||||
const medium1 = new SharedMemoryBackend();
|
||||
await medium1.open('sm-4');
|
||||
await medium1.write('k', enc('PERSIST'));
|
||||
await medium1.close();
|
||||
|
||||
const medium2 = new SharedMemoryBackend();
|
||||
await medium2.open('sm-4');
|
||||
expect(dec(await medium2.read('k'))).toBe('PERSIST');
|
||||
await medium2.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('KVStore 日志解析 — 损坏分支', () => {
|
||||
it('onCorrupt 返回 false → 立即停止扫描', () => {
|
||||
const good1 = encodeLogRecord(1, { a: enc('1') });
|
||||
const bad = encodeLogRecord(2, { b: enc('2') });
|
||||
bad[10] ^= 0xff; // 破坏
|
||||
const good2 = encodeLogRecord(3, { c: enc('3') });
|
||||
const combined = new Uint8Array(good1.byteLength + bad.byteLength + good2.byteLength);
|
||||
combined.set(good1, 0);
|
||||
combined.set(bad, good1.byteLength);
|
||||
combined.set(good2, good1.byteLength + bad.byteLength);
|
||||
|
||||
const seqs: number[] = [];
|
||||
let corruptCalls = 0;
|
||||
const count = parseLogRecords(combined, (r) => seqs.push(r.seq), () => {
|
||||
corruptCalls++;
|
||||
return false; // 停止
|
||||
});
|
||||
expect(count).toBe(1); // 只有第一条
|
||||
expect(seqs).toEqual([1]);
|
||||
expect(corruptCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('损坏条目(keyLen 越界)→ 记录被丢弃', () => {
|
||||
// 手工构造:entry 的 keyLen 超长导致解析失败
|
||||
const buf = new ArrayBuffer(4 + 4 + 4 + 1 + 4 + 0xFFFFFF + 4);
|
||||
const view = new DataView(buf);
|
||||
let o = 0;
|
||||
const recLen = buf.byteLength - 4;
|
||||
view.setUint32(o, recLen, false); o += 4;
|
||||
view.setUint32(o, 1, false); o += 4; // seq
|
||||
view.setUint32(o, 1, false); o += 4; // entryCount
|
||||
view.setUint8(o, 1); o += 1; // op PUT
|
||||
view.setUint32(o, 0xFFFFFF, false); o += 4; // keyLen 超长
|
||||
// crc(随便填,解析会在 keyLen 越界处停止)
|
||||
view.setUint32(o, 0, false);
|
||||
const data = new Uint8Array(buf);
|
||||
|
||||
let corrupt = 0;
|
||||
const count = parseLogRecords(data, () => {}, () => { corrupt++; return false; });
|
||||
expect(count).toBe(0);
|
||||
expect(corrupt).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// AriaEngine 级联环(与 MemoryEngine 对齐)
|
||||
// ===================================================================
|
||||
describe('AriaEngine — 级联环保护', () => {
|
||||
it('A→B→A 级联环删除不无限递归(visited 保护)', async () => {
|
||||
const { AriaEngine } = require('../src/engine/aria/index');
|
||||
const engine = new AriaEngine({ storageBackend: 'memory' });
|
||||
await engine.open(`aria-loop-${Date.now()}-${Math.random()}`, 1);
|
||||
await engine.createTable(createSchema('ta', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
ref_b: { type: 'string', references: 'tb.id', onDelete: 'CASCADE' },
|
||||
}));
|
||||
await engine.createTable(createSchema('tb', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
ref_a: { type: 'string', references: 'ta.id', onDelete: 'CASCADE' },
|
||||
}));
|
||||
await engine.insert('ta', [{ id: 'a1', ref_b: 'b1' }]);
|
||||
await engine.insert('tb', [{ id: 'b1', ref_a: 'a1' }]);
|
||||
// 不应死循环(RangeError)
|
||||
await engine.delete('ta', { table: 'ta', where: { id: 'a1' } });
|
||||
expect(await engine.count('ta')).toBe(0);
|
||||
expect(await engine.count('tb')).toBe(0);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('更深级联链(A→B→C→D)删除完整', async () => {
|
||||
const { AriaEngine } = require('../src/engine/aria/index');
|
||||
const engine = new AriaEngine({ storageBackend: 'memory' });
|
||||
await engine.open(`aria-chain-${Date.now()}-${Math.random()}`, 1);
|
||||
await engine.createTable(createSchema('ta', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
ref: { type: 'string', references: 'tb.id', onDelete: 'CASCADE' },
|
||||
}));
|
||||
await engine.createTable(createSchema('tb', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
ref: { type: 'string', references: 'tc.id', onDelete: 'CASCADE' },
|
||||
}));
|
||||
await engine.createTable(createSchema('tc', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
ref: { type: 'string', references: 'td.id', onDelete: 'CASCADE' },
|
||||
}));
|
||||
await engine.createTable(createSchema('td', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
}));
|
||||
await engine.insert('td', [{ id: 'd1' }]);
|
||||
await engine.insert('tc', [{ id: 'c1', ref: 'd1' }]);
|
||||
await engine.insert('tb', [{ id: 'b1', ref: 'c1' }]);
|
||||
await engine.insert('ta', [{ id: 'a1', ref: 'b1' }]);
|
||||
|
||||
// 删除链尾 td.d1 → 级联 tc(引用 td)→ tb(引用 tc)→ ta(引用 tb)
|
||||
await engine.delete('td', { table: 'td', where: { id: 'd1' } });
|
||||
expect(await engine.count('ta')).toBe(0);
|
||||
expect(await engine.count('tb')).toBe(0);
|
||||
expect(await engine.count('tc')).toBe(0);
|
||||
expect(await engine.count('td')).toBe(0);
|
||||
await engine.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user