release: v0.6.0 — 完全移除 IndexedDB,自研 KVStore 事务存储引擎(多key原子写/快照日志恢复/CRC自愈)+ KVStoreEngine + 旧库迁移工具 + 10万级压力验证 + 崩溃注入e2e
This commit is contained in:
+80
-117
@@ -15,11 +15,15 @@ import { AriaEngine } from '../src/engine/aria/index';
|
||||
import { createSchema } from '../src/table/schema';
|
||||
import { SSTableBuilder } from '../src/engine/aria/index/sstable_builder';
|
||||
import { SSTableReader } from '../src/engine/aria/index/sstable';
|
||||
import { IndexedDBEngine } from '../src/engine/indexeddb';
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
import { IndexedDBBackend } from '../src/engine/aria/store/backend';
|
||||
import { OPFSBackend } from '../src/engine/aria/store/opfs_backend';
|
||||
import { KVStoreEngine } from '../src/engine/kvstore_engine';
|
||||
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
|
||||
import type { SSTableMeta } from '../src/engine/aria/types';
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { installOPFSMock } from './helpers/opfs-mock';
|
||||
|
||||
beforeEach(() => { installOPFSMock(new Map()); });
|
||||
|
||||
let idbCounter = 0;
|
||||
function uniqueDB(): string {
|
||||
@@ -133,7 +137,7 @@ describe('P0-1a — SSTableReader 残缺数据防御', () => {
|
||||
describe('P0-1b — AriaEngine 打开时完整性校验', () => {
|
||||
it('meta 引用残缺文件 → 打开跳过损坏 SSTable 不崩溃,库可用', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 1024 });
|
||||
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
@@ -145,17 +149,18 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
|
||||
await (engine as any).lsm.flush();
|
||||
await engine.close();
|
||||
|
||||
// 篡改存储:把第一个 sst_ 文件写成残缺内容(meta 仍引用它)
|
||||
const backend = new IndexedDBBackend();
|
||||
// 篡改存储:把第一个 SSTable 的第一个页面写成残缺内容(meta 仍引用它)
|
||||
const backend = new OPFSBackend();
|
||||
await backend.open(dbName);
|
||||
const sstKeys = (await backend.listKeys())
|
||||
.filter((k) => k.startsWith('sst_') && !k.startsWith('sst_idx_'));
|
||||
expect(sstKeys.length).toBeGreaterThan(0);
|
||||
await backend.write(sstKeys[0], new TextEncoder().encode('truncated-garbage').buffer);
|
||||
const metas = JSON.parse(new TextDecoder().decode(
|
||||
await backend.read('__aria_lsm_meta') as ArrayBuffer)) as { id: number; pageIds: number[] }[];
|
||||
expect(metas.length).toBeGreaterThan(0);
|
||||
const pageId = metas[0].pageIds[0];
|
||||
await backend.write(`pg_${pageId}`, new TextEncoder().encode('truncated-garbage').buffer);
|
||||
await backend.close();
|
||||
|
||||
// 重开:不得崩溃(此前抛 RangeError 打不开库)
|
||||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 1024 });
|
||||
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||||
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
|
||||
expect(engine2.isOpen()).toBe(true);
|
||||
// schema 完整,损坏的 SSTable 已被清理
|
||||
@@ -165,19 +170,21 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
|
||||
await engine2.close();
|
||||
|
||||
// 清理 meta 已验证:损坏文件被删除
|
||||
const backend2 = new IndexedDBBackend();
|
||||
const backend2 = new OPFSBackend();
|
||||
await backend2.open(dbName);
|
||||
const remaining = (await backend2.listKeys())
|
||||
.filter((k) => k.startsWith('sst_') && !k.startsWith('sst_idx_'));
|
||||
const remaining = (await backend2.listKeys()).filter((k) => k.startsWith('pg_'));
|
||||
const metaRaw = await backend2.read('__aria_lsm_meta');
|
||||
const metaList = JSON.parse(new TextDecoder().decode(metaRaw ?? new Uint8Array())) as { id: number }[];
|
||||
expect(remaining.length).toBe(metaList.length); // 无孤儿文件 / 无悬空 meta
|
||||
const metaList = JSON.parse(new TextDecoder().decode(metaRaw ?? new Uint8Array())) as { pageIds?: number[] }[];
|
||||
const livePages = new Set<number>();
|
||||
for (const m of metaList) if (m.pageIds) for (const pid of m.pageIds) livePages.add(pid);
|
||||
const orphan = remaining.filter((k) => !livePages.has(Number(k.slice(3))));
|
||||
expect(orphan.length).toBe(0); // 无孤儿页面
|
||||
await backend2.close();
|
||||
});
|
||||
|
||||
it('meta 引用缺失文件 → 打开清理 meta 不崩溃', async () => {
|
||||
it('meta 引用缺失页面 → 打开清理 meta 不崩溃', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 1024 });
|
||||
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
@@ -187,15 +194,14 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
|
||||
await (engine as any).lsm.flush();
|
||||
await engine.close();
|
||||
|
||||
// 删除全部 sst_ 文件(保留 meta → 悬空引用)
|
||||
const backend = new IndexedDBBackend();
|
||||
// 删除全部 pg_ 页面(保留 meta → 悬空引用)
|
||||
const backend = new OPFSBackend();
|
||||
await backend.open(dbName);
|
||||
const sstKeys = (await backend.listKeys())
|
||||
.filter((k) => k.startsWith('sst_') && !k.startsWith('sst_idx_'));
|
||||
await backend.deleteMany(sstKeys);
|
||||
const pgKeys = (await backend.listKeys()).filter((k) => k.startsWith('pg_'));
|
||||
await backend.deleteMany(pgKeys);
|
||||
await backend.close();
|
||||
|
||||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 1024 });
|
||||
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||||
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
|
||||
expect(engine2.isOpen()).toBe(true);
|
||||
expect(await engine2.getTableNames()).toEqual(['t']);
|
||||
@@ -210,7 +216,7 @@ describe('P0-1b — AriaEngine 打开时完整性校验', () => {
|
||||
describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
|
||||
it('IndexedDBBackend.writeMany/deleteMany 单事务原子批量操作', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const be = new IndexedDBBackend();
|
||||
const be = new OPFSBackend();
|
||||
await be.open(dbName);
|
||||
await be.writeMany({
|
||||
a: new TextEncoder().encode('1').buffer,
|
||||
@@ -226,7 +232,7 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
|
||||
|
||||
it('P1-4: 连续快速写入 200 条 → close+重开 数据完整(实测曾丢 4 条)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
@@ -237,7 +243,7 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
|
||||
}
|
||||
await engine.close();
|
||||
|
||||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
const engine2 = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('t')).toBe(200);
|
||||
await engine2.close();
|
||||
@@ -245,7 +251,7 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
|
||||
|
||||
it('P1-4: count 键丢失(模拟崩溃竞态)→ 按 key 扫描恢复不丢记录', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
@@ -256,12 +262,12 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
|
||||
}
|
||||
|
||||
// 模拟异常退出(不 close):直接删掉 count 键,制造 count 与记录不一致
|
||||
const backend = new IndexedDBBackend();
|
||||
const backend = new OPFSBackend();
|
||||
await backend.open(dbName);
|
||||
await backend.delete('__wal_count');
|
||||
await backend.close();
|
||||
|
||||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
const engine2 = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('t')).toBe(20);
|
||||
await engine2.close();
|
||||
@@ -275,7 +281,7 @@ describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
|
||||
describe('P1-5 — AriaEngine.close 截断 WAL', () => {
|
||||
it('close 后 WAL 记录键全部清空(不无限重放)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
@@ -284,7 +290,7 @@ describe('P1-5 — AriaEngine.close 截断 WAL', () => {
|
||||
await engine.insert('t', [{ id: '1', v: 1 }, { id: '2', v: 2 }]);
|
||||
await engine.close();
|
||||
|
||||
const backend = new IndexedDBBackend();
|
||||
const backend = new OPFSBackend();
|
||||
await backend.open(dbName);
|
||||
// __wal_count 计数键合法保留(作为 append 序号分配器),WAL 记录键必须清空
|
||||
const walKeys = (await backend.listKeys())
|
||||
@@ -294,65 +300,6 @@ describe('P1-5 — AriaEngine.close 截断 WAL', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// P0-2 / P0-3 / P2-8: IndexedDB 版本管理与参数校验
|
||||
// ===================================================================
|
||||
|
||||
describe('P0-2 / P0-3 — IndexedDB 版本管理', () => {
|
||||
it('P0-2: 建表提升版本后以旧 version 重开成功(VersionError 自适应)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new IndexedDBEngine();
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable({ name: 'a', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await engine.createTable({ name: 'b', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await engine.createTable({ name: 'c', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await engine.close();
|
||||
|
||||
// 用旧版本 1 重开:此前必报 IDB_OPEN_ERROR(VersionError)
|
||||
const engine2 = new IndexedDBEngine();
|
||||
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
|
||||
const names = await engine2.getTableNames();
|
||||
expect(names).toContain('a');
|
||||
expect(names).toContain('b');
|
||||
expect(names).toContain('c');
|
||||
await engine2.close();
|
||||
});
|
||||
|
||||
it('P0-2: MetonaSqlark hybrid 二次启动不报 VersionError(MarkLite 场景)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const db1 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 1 });
|
||||
await db1.init();
|
||||
await db1.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
||||
await db1.table('users').insert({ id: '1', name: 'Alice' });
|
||||
await db1.close();
|
||||
|
||||
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 1 });
|
||||
await expect(db2.init()).resolves.toBeUndefined();
|
||||
expect(await db2.table('users').count()).toBe(1);
|
||||
await db2.close();
|
||||
});
|
||||
|
||||
it('P0-3: IndexedDBEngine open(version=0) 归一化为 1,不抛 TypeError', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new IndexedDBEngine();
|
||||
await expect(engine.open(dbName, 0)).resolves.toBeUndefined();
|
||||
expect(engine.isOpen()).toBe(true);
|
||||
await engine.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await engine.insert('t', [{ id: '1' }]);
|
||||
expect(await engine.find('t', { table: 't' })).toHaveLength(1);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('P0-3: MetonaSqlark version=0 初始化正常', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 0 });
|
||||
await expect(db.init()).resolves.toBeUndefined();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.table('t').insert({ id: '1' });
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
// P1-6: 引擎错误包装 DatabaseError
|
||||
// ===================================================================
|
||||
@@ -362,24 +309,36 @@ describe('P1-6 — 引擎内部错误统一包装 DatabaseError', () => {
|
||||
// 构造一个无法打开的 backend 场景:直接调用 openInternal 模拟底层异常不可行,
|
||||
// 这里验证损坏库重开时抛的是 DatabaseError 而非原生错误(不崩溃路径已由 P0-1b 覆盖)
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({ storageBackend: 'indexeddb' });
|
||||
const engine = new AriaEngine({ storageBackend: 'opfs' });
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
|
||||
await engine.insert('t', [{ id: '1' }]);
|
||||
await engine.close();
|
||||
|
||||
const engine2 = new AriaEngine({ storageBackend: 'indexeddb' });
|
||||
const engine2 = new AriaEngine({ storageBackend: 'opfs' });
|
||||
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
|
||||
expect(engine2.isOpen()).toBe(true);
|
||||
await engine2.close();
|
||||
});
|
||||
|
||||
it('open 未初始化 IndexedDB 环境时抛 DatabaseError 而非原生错误', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new IndexedDBEngine();
|
||||
await engine.open(dbName, 1);
|
||||
expect(engine.isOpen()).toBe(true);
|
||||
await engine.close();
|
||||
it('底层介质故障时抛 DatabaseError 而非原生错误', async () => {
|
||||
const failing = {
|
||||
open: async () => { throw new Error('disk failure'); },
|
||||
close: async () => {},
|
||||
isOpen: () => false,
|
||||
read: async () => null,
|
||||
write: async () => { throw new Error('disk failure'); },
|
||||
append: async () => {},
|
||||
writeMany: async () => {},
|
||||
delete: async () => {},
|
||||
deleteMany: async () => {},
|
||||
listKeys: async () => [],
|
||||
exists: async () => false,
|
||||
clear: async () => {},
|
||||
};
|
||||
const engine = new KVStoreEngine(failing as never);
|
||||
// 介质故障如实传播(不吞错),AriaEngine 路径由 ARIA_OPEN_ERROR 包装(上方测试覆盖)
|
||||
await expect(engine.open(uniqueDB(), 1)).rejects.toThrow('disk failure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,7 +352,7 @@ describe('P2-7 — 迁移版本持久化', () => {
|
||||
const runs: number[] = [];
|
||||
|
||||
// version: 0 表示"无 schema 起点",migration 1 可执行
|
||||
const db1 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 0 });
|
||||
const db1 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs', version: 0 });
|
||||
await db1.init();
|
||||
db1.addMigration(1, async () => { runs.push(1); });
|
||||
db1.addMigration(2, async () => { runs.push(2); });
|
||||
@@ -402,7 +361,7 @@ describe('P2-7 — 迁移版本持久化', () => {
|
||||
await db1.close();
|
||||
|
||||
// 重启:version 又重置为 config.version=0(此前会重跑 migration 1/2)
|
||||
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb', version: 0 });
|
||||
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs', version: 0 });
|
||||
await db2.init();
|
||||
db2.addMigration(1, async () => { runs.push(1); });
|
||||
db2.addMigration(2, async () => { runs.push(2); });
|
||||
@@ -440,35 +399,39 @@ describe('P2-9 — 统一自愈接口 repair / clearAll', () => {
|
||||
|
||||
it('AriaEngine.repair 清理损坏 SSTable 后引擎可用', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({ storageBackend: 'indexeddb', memtableSizeThreshold: 4096 });
|
||||
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
v: { type: 'number' },
|
||||
}));
|
||||
for (let i = 0; i < 100; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
|
||||
for (let i = 0; i < 50; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
|
||||
await (engine as any).lsm.flush();
|
||||
for (let i = 50; i < 100; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
|
||||
await (engine as any).lsm.flush();
|
||||
|
||||
// 篡改一个 sst 文件
|
||||
const backend = new IndexedDBBackend();
|
||||
// 篡改一个 SSTable 的页面文件
|
||||
const backend = new OPFSBackend();
|
||||
await backend.open(dbName);
|
||||
const sstKeys = (await backend.listKeys())
|
||||
.filter((k) => k.startsWith('sst_') && !k.startsWith('sst_idx_'));
|
||||
expect(sstKeys.length).toBeGreaterThan(0);
|
||||
await backend.write(sstKeys[0], new TextEncoder().encode('garbage').buffer);
|
||||
const metas = JSON.parse(new TextDecoder().decode(
|
||||
await backend.read('__aria_lsm_meta') as ArrayBuffer)) as { id: number; pageIds: number[] }[];
|
||||
expect(metas.length).toBeGreaterThan(0);
|
||||
const victimPage = metas[0].pageIds[0];
|
||||
const raw = new Uint8Array(await backend.read(`pg_${victimPage}`) as ArrayBuffer);
|
||||
raw[50] ^= 0xff;
|
||||
await backend.write(`pg_${victimPage}`, raw.buffer as ArrayBuffer);
|
||||
await backend.close();
|
||||
|
||||
await expect(engine.repair()).resolves.toBeUndefined();
|
||||
const rows = await engine.find('t', { table: 't' });
|
||||
expect(Array.isArray(rows)).toBe(true);
|
||||
expect(rows.length).toBeLessThanOrEqual(100);
|
||||
expect(engine.isOpen()).toBe(true);
|
||||
// repair:校验清理损坏 SSTable,其余数据可用
|
||||
await (engine as any).repair();
|
||||
expect(await engine.count('t')).toBeGreaterThan(0);
|
||||
expect(await engine.count('t')).toBeLessThan(100);
|
||||
await engine.close();
|
||||
});
|
||||
|
||||
it('MetonaSqlark.clearAll 统一接口(hybrid)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
|
||||
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.table('t').insert({ id: '1' });
|
||||
@@ -479,7 +442,7 @@ describe('P2-9 — 统一自愈接口 repair / clearAll', () => {
|
||||
|
||||
it('MetonaSqlark.repair 统一接口(aria)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const db = new MetonaSqlark({ name: dbName, mode: 'aria', diskEngine: 'indexeddb' });
|
||||
const db = new MetonaSqlark({ name: dbName, mode: 'aria', diskEngine: 'opfs' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||
await db.table('t').insert({ id: '1', v: 42 });
|
||||
@@ -490,7 +453,7 @@ describe('P2-9 — 统一自愈接口 repair / clearAll', () => {
|
||||
|
||||
it('MetonaSqlark.repair 统一接口(hybrid)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'indexeddb' });
|
||||
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.table('t').insert({ id: '1' });
|
||||
|
||||
Reference in New Issue
Block a user