A9 db.subscribe 对本地写入永不触发
全库唯一调用 emit 的地方在 BroadcastChannel 收到**其它标签页**消息的分支里,
于是 README:232「订阅表变更」与 site/docs.html:667-679 的示例
(event.type: 'insert'|'update'|'delete'、event.row)全部不成立。
根治方式:新增 src/engine/change-notifier.ts —— IStorageEngine 装饰器,
把变更通知收敛到**引擎接口**这一个位置(三个写入入口 SQL/Table/Builder 与
事务内写入都必须经过它),避免在三条路径上各写一份变更描述逻辑。
事件语义(兑现文档承诺):INSERT 逐行带 row+key;UPDATE/DELETE 写入前快照
受影响行、成功后逐行发事件并带更新后/删除前的行;CLEAR/DDL 表级事件。
订阅者返回 Promise 时被 await;订阅者抛错不影响写入结果(只上报 onError)。
两个实现细节值得记录:
1. 引擎被装饰后,core 里 `this.engine instanceof HybridEngine` 恒为 false
→ Hybrid 跨标签页重载静默失效。新增 unwrapEngine() 对**内层**引擎做能力探测。
2. 外部事件(external)绝不能重新广播 —— 否则 A↔B 互相转发形成无限循环
(实测 8 次以上且不终止)。已分离 emitExternal 路径。
A10 列对列比较与关联 IN 子查询静默空结果
1) `WHERE t.x = t.y`(唯一可解析的列对列写法)返回 []:
- 引擎层 matchWhere 无 $col 上下文,把 `{ $col: ... }` 当普通对象比较;
- executor.filterCorrelated 调用 matchWhere 时**没传** `{ $col: true }`。
修复:engine 层遇到未解析操作数($col/$subquery)时**放行**而非判假 ——
引擎的过滤只允许缩小候选集,最终判定始终由带上下文的 executor 完成;
executor 侧补上 `{ $col: true }`。
同时修正 MemoryEngine/AriaEngine 的索引下推:非原始值(对象)不走索引,
否则 String({...}) 得到无意义键、查找为空并短路全表扫描 → 静默空结果。
2) `WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` 返回 []:
子查询执行**不传外层行上下文**,`u.id` 绑定为 null → 子查询空集 → `$in: []`。
(结构相同的 EXISTS 走另一条分支、结果正确 —— 又一处"同一语义两条路径"。)
修复:resolveOperatorSubqueries 接收并传递 contextRow;bindColumnRefs 递归
进入 $subquery 绑定外层引用;新增 lookupOuterValue 先剥外层表名/别名前缀
再取值(外层行键不带前缀,否则 `u.id` 取 undefined 被 `?? null` 静默成 null)。
ChangeNotifierEngine 能力转发
装饰器只实现 IStorageEngine 声明的成员,导致:
- 可选能力缺失时抛原生 Error,破坏 `NOT_SUPPORTED` 错误码契约(14 个用例失败)
→ 新增 requireCapability,统一抛 NOT_SUPPORTED 并保留方法名;
- 接口外方法(analyzeTable/reindexTable/vacuum)在被包装后静默消失
→ 新增 requireOptionalMethod 显式转发(ANALYZE/REINDEX/VACUUM 恢复可用)。
新增 tests/v080-subscribe.test.ts(5 用例,四引擎 × 三种入口)、
tests/v080-correlated.test.ts(4 用例,含"关联 IN 与等价 EXISTS 结果一致"护栏)。
137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
/**
|
|
* Core 全覆盖测试 — 导入导出、迁移、发布订阅、钩子
|
|
*/
|
|
import { MetonaSqlark } from '../src/core';
|
|
|
|
describe('Core 全覆盖', () => {
|
|
let db: MetonaSqlark;
|
|
|
|
beforeEach(async () => {
|
|
db = new MetonaSqlark({ name: 'test-coverage', mode: 'memory' });
|
|
await db.init();
|
|
await db.defineTable('users', {
|
|
id: { type: 'string', primaryKey: true },
|
|
name: { type: 'string', required: true },
|
|
});
|
|
await db.table('users').insertMany([
|
|
{ id: '1', name: 'Alice' },
|
|
{ id: '2', name: 'Bob' },
|
|
]);
|
|
});
|
|
|
|
afterEach(async () => { await db.close(); });
|
|
|
|
// ---- 导入导出 ----
|
|
describe('导入导出', () => {
|
|
it('exportTable 导出单表', async () => {
|
|
const data = await db.exportTable('users');
|
|
expect(data).toHaveLength(2);
|
|
expect(data[0].name).toBe('Alice');
|
|
});
|
|
|
|
it('exportAll 导出全库', async () => {
|
|
const all = await db.exportAll();
|
|
expect(all.users).toHaveLength(2);
|
|
});
|
|
|
|
it('importTable 导入数据', async () => {
|
|
await db.defineTable('temp', { id: { type: 'string', primaryKey: true }, val: { type: 'number' } });
|
|
await db.importTable('temp', [{ id: 'a', val: 1 }, { id: 'b', val: 2 }]);
|
|
expect(await db.table('temp').count()).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ---- 发布订阅 ----
|
|
describe('发布订阅', () => {
|
|
it('subscribe 注册监听', () => {
|
|
const fn = jest.fn();
|
|
const unsub = db.subscribe('users', fn);
|
|
expect(typeof unsub).toBe('function');
|
|
});
|
|
|
|
it('unsubscribe 取消监听', async () => {
|
|
const fn = jest.fn();
|
|
const unsub = db.subscribe('users', fn);
|
|
unsub();
|
|
await db.emit('users', { type: 'insert', row: { id: '3' } });
|
|
expect(fn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('emit 触发监听', async () => {
|
|
const fn = jest.fn();
|
|
db.subscribe('users', fn);
|
|
// v0.8.0: emit 现在会补全 table 字段(事件语义统一),且返回 Promise
|
|
await db.emit('users', { type: 'insert', row: { id: '3', name: 'Charlie' } });
|
|
// 事件现在带 table 字段(与引擎层变更事件语义一致)
|
|
expect(fn).toHaveBeenCalledWith({ table: 'users', type: 'insert', row: { id: '3', name: 'Charlie' } });
|
|
});
|
|
});
|
|
|
|
// ---- 迁移 ----
|
|
describe('迁移系统', () => {
|
|
it('addMigration + migrateTo', async () => {
|
|
let migrated = false;
|
|
db.addMigration(2, async (d) => {
|
|
migrated = true;
|
|
await d.defineTable('new_table', { id: { type: 'string', primaryKey: true } });
|
|
});
|
|
await db.migrateTo(2);
|
|
expect(migrated).toBe(true);
|
|
const tables = await db.getTableNames();
|
|
expect(tables).toContain('new_table');
|
|
});
|
|
|
|
it('不执行已过期的迁移', async () => {
|
|
let count = 0;
|
|
db.addMigration(1, async () => { count++; }); // version <= current
|
|
db.addMigration(2, async () => { count++; });
|
|
await db.migrateTo(2);
|
|
// version 1 <= current version 1, should be skipped
|
|
expect(count).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---- 钩子 ----
|
|
describe('钩子系统', () => {
|
|
it('on 注册钩子 (通过 pluginManager)', () => {
|
|
const fn = jest.fn();
|
|
db.getPluginManager().on('beforeInsert', fn);
|
|
db.getPluginManager().trigger('beforeInsert', { id: '3' });
|
|
expect(fn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('getPluginManager 返回插件管理器', () => {
|
|
const pm = db.getPluginManager();
|
|
expect(pm).toBeDefined();
|
|
expect(typeof pm.on).toBe('function');
|
|
});
|
|
});
|
|
|
|
// ---- 生命周期 ----
|
|
describe('生命周期', () => {
|
|
it('close 关闭数据库', async () => {
|
|
await db.close();
|
|
expect(db.isReady()).toBe(false);
|
|
});
|
|
|
|
it('getEngine 返回引擎', () => {
|
|
const engine = db.getEngine();
|
|
expect(engine).toBeDefined();
|
|
expect(engine.name).toBe('memory');
|
|
});
|
|
|
|
it('未初始化时操作抛出错误', () => {
|
|
const db2 = new MetonaSqlark({ name: 'x', mode: 'memory' });
|
|
expect(() => db2.table('x')).toThrow('not initialized');
|
|
});
|
|
});
|
|
|
|
// ---- 别名 ----
|
|
describe('MeSqlark 别名', () => {
|
|
it('MeSqlark === MetonaSqlark', () => {
|
|
const { MeSqlark } = require('../src/index');
|
|
expect(MeSqlark).toBe(MetonaSqlark);
|
|
});
|
|
});
|
|
});
|