Files
MetonaSqlark/tests/v080-subscribe.test.ts
T
thzxx 674da6b7b7 fix(A9/A10): 发布订阅接线 + 列对列比较与关联子查询(静默空结果根治)
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 结果一致"护栏)。
2026-09-14 22:29:51 +08:00

120 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v0.8.0 回归 —— A9 发布订阅(本地写入必须触发订阅者)
*
* 修复前:`db.subscribe(table, fn)` 对**本地写入永不触发** —— 全库唯一调用 `emit`
* 的地方在 BroadcastChannel 收到其它标签页消息的分支里。于是 README:232
* 「订阅表变更」与 site/docs.html:667-679 的示例(`event.type: 'insert' | 'update' |
* 'delete'`、`event.row`)全部不成立;唯一实际触发条件是 `multiTabSync: true`
* 且收到 `external` 事件。
*
* 修复方式:用 `ChangeNotifierEngine` 装饰器把变更通知收敛到**引擎接口**这一个位置。
* 三个写入入口(SQL 语句 / Table API / QueryBuilder)与事务内写入都必须经过引擎,
* 因此拦一次即可全覆盖,避免在三条路径上各写一份变更描述逻辑(那正是本项目
* 反复出现"同一语义多份实现 → 漂移"的模式)。
*
* 覆盖:四个引擎 × 三种写入入口 × 事件内容(type/table/row/key/count+
* 退订 + async 订阅者 + 跨标签页广播不形成回路。
*/
import { MetonaSqlark } from '../src/core';
import type { ChangeEvent } from '../src/engine/change-notifier';
const MODES: Array<[string, Record<string, unknown>]> = [
['memory', {}],
['disk', {}],
['hybrid', {}],
['aria', { diskEngine: 'memory' }],
];
describe('[v0.8.0] A9 发布订阅 —— SQL 写入路径', () => {
test('INSERT/UPDATE/DELETE 逐行事件,携带行内容与主键', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a9-${mode}`, mode, ...extra } as never);
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
const events: ChangeEvent[] = [];
const unsub = db.subscribe('t', (e) => { events.push(e); });
await db.query("INSERT INTO t VALUES ('a',1),('b',2)");
await db.query("UPDATE t SET v = 9 WHERE id = 'a'");
await db.query("DELETE FROM t WHERE id = 'b'");
expect(events.map((e) => e.type)).toEqual(['insert', 'insert', 'update', 'delete']);
// 逐行事件带主键与行内容
expect(events.filter((e) => e.type === 'insert').map((e) => e.key).sort()).toEqual(['a', 'b']);
const update = events.find((e) => e.type === 'update')!;
expect(update.row).toMatchObject({ id: 'a', v: 9 });
expect(update.table).toBe('t');
const del = events.find((e) => e.type === 'delete')!;
expect(del.row).toMatchObject({ id: 'b', v: 2 });
// 退订后不再收到
unsub();
await db.query("INSERT INTO t VALUES ('c',3)");
expect(events).toHaveLength(4);
await db.close();
}
});
});
describe('[v0.8.0] A9 发布订阅 —— Table API / QueryBuilder 路径', () => {
test('insert / insertMany / update / delete / clear 全部产生事件', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a9b-${mode}`, mode, ...extra } as never);
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
const events: ChangeEvent[] = [];
db.subscribe('t', (e) => { events.push(e); });
await db.table('t').insert({ id: 'a', v: 1 });
await db.table('t').insertMany([{ id: 'b', v: 2 }, { id: 'c', v: 3 }]);
await db.table('t').update({ v: 9 }).where({ id: 'a' }).execute();
await db.table('t').delete().where({ id: 'b' }).execute();
await db.table('t').clear();
expect(events.map((e) => e.type))
.toEqual(['insert', 'insert', 'insert', 'update', 'delete', 'clear']);
await db.close();
}
});
});
describe('[v0.8.0] A9 发布订阅 —— 语义护栏', () => {
test('异步订阅者会被 await(写完即通知完)', async () => {
const db = await MetonaSqlark.create({ name: 'v080-a9-async', mode: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
const seen: string[] = [];
db.subscribe('t', async (e) => {
await new Promise((r) => setTimeout(r, 1));
seen.push(`${e.type}:${e.key ?? ''}`);
});
await db.query("INSERT INTO t VALUES ('x')");
expect(seen).toEqual(['insert:x']);
await db.close();
});
test('订阅者抛错不影响写入结果(写入已成功)', async () => {
const errors: Error[] = [];
const db = await MetonaSqlark.create({
name: 'v080-a9-throw',
mode: 'memory',
onError: (e: Error) => { errors.push(e); },
} as never);
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
db.subscribe('t', () => { throw new Error('subscriber boom'); });
await expect(db.query("INSERT INTO t VALUES ('x')")).resolves.toEqual(['x']);
const rows = await db.query('SELECT COUNT(*) AS c FROM t') as Array<{ c: number }>;
expect(rows[0].c).toBe(1);
expect(errors.some((e) => e.message === 'subscriber boom')).toBe(true);
await db.close();
});
test('手动 emit 仍可用,并补全 table 字段', async () => {
const db = await MetonaSqlark.create({ name: 'v080-a9-emit', mode: 'memory' });
const events: ChangeEvent[] = [];
db.subscribe('t', (e) => { events.push(e); });
await db.emit('t', { type: 'insert', row: { id: 'z' } });
expect(events).toEqual([{ table: 't', type: 'insert', row: { id: 'z' } }]);
await db.close();
});
});