483 lines
19 KiB
TypeScript
483 lines
19 KiB
TypeScript
/**
|
||
* 生产级异常场景测试 — 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();
|
||
});
|
||
});
|