release: v0.6.0 — 完全移除 IndexedDB,自研 KVStore 事务存储引擎(多key原子写/快照日志恢复/CRC自愈)+ KVStoreEngine + 旧库迁移工具 + 10万级压力验证 + 崩溃注入e2e
CI / test (20.x) (push) Successful in 10m53s
CI / test (22.x) (push) Successful in 10m47s
CI / test (24.x) (push) Successful in 10m42s
CI / e2e (push) Successful in 10m27s
CI / test (18.x) (push) Successful in 11m1s

This commit is contained in:
thzxx
2026-08-10 13:56:04 +08:00
parent 2668bd47cf
commit 24b3ca1079
74 changed files with 8016 additions and 7688 deletions
+4 -4
View File
@@ -10,8 +10,8 @@
/** 存储模式 */
export type StorageMode = 'memory' | 'disk' | 'hybrid' | 'aria';
/** 磁盘引擎类型 */
export type DiskEngine = 'indexeddb' | 'opfs';
/** 磁盘引擎类型v0.6.0: IndexedDB 已移除,'memory' 供 aria 内存后端) */
export type DiskEngine = 'opfs' | 'memory';
// ---------------------------------------------------------------------------
// 字段类型
@@ -100,7 +100,7 @@ export interface DatabaseConfig {
export const DB_DEFAULTS: Readonly<Required<Omit<DatabaseConfig, 'plugins' | 'onReady' | 'onError' | 'aria'>>> & { aria: undefined } = Object.freeze({
name: 'metona-sqlark',
mode: 'hybrid' as const,
diskEngine: 'indexeddb' as const,
diskEngine: 'opfs' as const,
version: 1,
maxRowsPerQuery: 0, // 0 = 不限制
debug: false,
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
// 版本
// ---------------------------------------------------------------------------
export const VERSION = '0.5.1';
export const VERSION = '0.6.0';
+591 -591
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -21,7 +21,7 @@ import { SegmentedWALStore } from './wal/segmented_store';
import { DatabaseLock } from './locks';
import { WALRecordType, type WALRecord } from './types';
import { CheckpointManager } from './wal/checkpoint';
import { IndexedDBBackend, MemoryBackend, type IStorageBackend } from './store/backend';
import { MemoryBackend, type IStorageBackend } from './store/backend';
import { OPFSBackend } from './store/opfs_backend';
import { EncryptedBackend } from './store/encrypted_backend';
import { PageSSTableStore } from './store/page_sstable_store';
@@ -117,8 +117,6 @@ export class AriaEngine implements IStorageEngine {
let baseBackend: IStorageBackend;
if (this.config.storageBackend === 'opfs') {
baseBackend = new OPFSBackend();
} else if (this.config.storageBackend === 'indexeddb') {
baseBackend = new IndexedDBBackend();
} else {
baseBackend = new MemoryBackend();
}
@@ -300,10 +298,10 @@ export class AriaEngine implements IStorageEngine {
*/
async repair(): Promise<void> {
this.ensureOpen();
// v0.6.0-fix: 先清页面缓存再校验 — 缓存中的"完好页面"会掩盖磁盘损坏
await this.bufferPool.clear();
// 1. 校验全部 SSTable,移除残缺项(打开时已做一次,此处兜底运行期损坏)
const removed = await this.lsm.validateAll();
// v0.4.5: 清空页面缓存(损坏数据可能驻留 BufferPool,重新加载)
await this.bufferPool.clear();
// 2. 将 WAL 残留数据落盘并截断,避免无限重放(含空洞截断落地)
await this.lsm.flush();
await this.wal.checkpoint();
+5 -3
View File
@@ -683,12 +683,14 @@ export class LSM {
private async dropInvalidSSTable(meta: SSTableMeta): Promise<void> {
// eslint-disable-next-line no-console
console.warn(`[AriaEngine LSM] Skipping corrupted SSTable id=${meta.id} (level=${meta.level})`);
try {
await this.sstableStore.deleteMeta(meta.id);
} catch { /* 清理失败不阻塞打开 */ }
// v0.6.0-fix: 先删数据文件再删 meta — 页面化存储的 delete 依赖 meta.pageIds
// 定位页面文件;先删 meta 会丢失 pageIds 导致孤儿页面残留
try {
await this.sstableStore.delete(meta.id);
} catch { /* 清理失败不阻塞打开 */ }
try {
await this.sstableStore.deleteMeta(meta.id);
} catch { /* 清理失败不阻塞打开 */ }
}
private unwrapTombstone(value: Record<string, unknown> | null): Record<string, unknown> | null {
+108 -241
View File
@@ -1,241 +1,108 @@
/**
* AriaEngine Storage Backend — 存储后端抽象层
* @module engine/aria/store/backend
*
* 封装底层浏览器存储 APIIndexedDB / OPFS / Memory 回退),
* 供 Buffer Pool 的 PageIO 和 WAL 的 WALStore 使用。
*/
import { DatabaseError } from '../../../constants';
// ---------------------------------------------------------------------------
// StorageBackend 接口
// ---------------------------------------------------------------------------
export interface IStorageBackend {
/** 打开存储 */
open(name: string): Promise<void>;
/** 关闭存储 */
close(): Promise<void>;
/** 是否已打开 */
isOpen(): boolean;
/** 读取数据块 */
read(key: string): Promise<ArrayBuffer | null>;
/** 写入数据块 */
write(key: string, data: ArrayBuffer): Promise<void>;
/**
* 追加写入(v0.4.5 WAL 分片用,可选):
* - OPFS 后端实现真追加(createWritable keepExistingData + seekO(chunk)
* - 未实现的后端由调用方回退 read+writeEncryptedBackend 包装时整体重写保正确性)
* 语义:在 key 现有内容末尾追加 data;key 不存在时等同 write。
*/
append?(key: string, data: ArrayBuffer): Promise<void>;
/**
* 批量原子写入(v0.4.2-fix):多个 key 在单个底层事务中提交,
* 中断时整体回滚,不留半写状态。WAL count 与记录同事务保证一致性。
*/
writeMany(entries: Record<string, ArrayBuffer>): Promise<void>;
/** 删除数据块 */
delete(key: string): Promise<void>;
/**
* 批量原子删除(v0.4.2-fix):多个 key 在单个底层事务中提交。
*/
deleteMany(keys: string[]): Promise<void>;
/** 列出所有 key */
listKeys(): Promise<string[]>;
/** 检查 key 是否存在 */
exists(key: string): Promise<boolean>;
/** 清空所有数据 */
clear(): Promise<void>;
}
// =======================================================================
// IndexedDB Backend
// =======================================================================
export class IndexedDBBackend implements IStorageBackend {
private db: IDBDatabase | null = null;
private dbName = '';
private storeName = 'data';
async open(name: string): Promise<void> {
this.dbName = `aria-${name}`;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onerror = () => reject(new DatabaseError('Failed to open AriaEngine IndexedDB', 'ARIA_IDB_OPEN_ERROR'));
});
}
async close(): Promise<void> {
if (this.db) {
this.db.close();
this.db = null;
}
}
isOpen(): boolean {
return this.db !== null;
}
async read(key: string): Promise<ArrayBuffer | null> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const req = tx.objectStore(this.storeName).get(key);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(new DatabaseError('Failed to read from AriaEngine store', 'ARIA_READ_ERROR'));
});
}
async write(key: string, data: ArrayBuffer): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
tx.objectStore(this.storeName).put(data, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError('Failed to write to AriaEngine store', 'ARIA_WRITE_ERROR'));
});
}
/**
* v0.4.2-fix: 批量原子写入 — 单个 IDB 事务内写入多个 key。
* 中断时事务整体回滚,WAL 记录与 count 计数不会出现"记录在、计数丢"或反之的半写状态。
*/
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
const keys = Object.keys(entries);
if (keys.length === 0) return;
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
for (const key of keys) {
store.put(entries[key], key);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError('Failed to batch write to AriaEngine store', 'ARIA_WRITE_ERROR'));
});
}
async delete(key: string): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
tx.objectStore(this.storeName).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError('Failed to delete from AriaEngine store', 'ARIA_DELETE_ERROR'));
});
}
/** v0.4.2-fix: 批量原子删除 — 单个 IDB 事务内删除多个 key */
async deleteMany(keys: string[]): Promise<void> {
if (keys.length === 0) return;
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
for (const key of keys) {
store.delete(key);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError('Failed to batch delete from AriaEngine store', 'ARIA_DELETE_ERROR'));
});
}
async listKeys(): Promise<string[]> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const req = tx.objectStore(this.storeName).getAllKeys();
req.onsuccess = () => resolve((req.result ?? []) as string[]);
req.onerror = () => reject(new DatabaseError('Failed to list keys', 'ARIA_LIST_ERROR'));
});
}
async exists(key: string): Promise<boolean> {
const result = await this.read(key);
return result !== null;
}
async clear(): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readwrite');
tx.objectStore(this.storeName).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError('Failed to clear AriaEngine store', 'ARIA_CLEAR_ERROR'));
});
}
private ensureDB(): IDBDatabase {
if (!this.db) throw new DatabaseError('AriaEngine storage not opened', 'ARIA_DB_NOT_OPEN');
return this.db;
}
}
// =======================================================================
// Memory Backend(回退 / 测试用)
// =======================================================================
export class MemoryBackend implements IStorageBackend {
private store: Map<string, ArrayBuffer> = new Map();
private opened = false;
async open(_name: string): Promise<void> {
this.opened = true;
}
async close(): Promise<void> {
this.store.clear();
this.opened = false;
}
isOpen(): boolean {
return this.opened;
}
async read(key: string): Promise<ArrayBuffer | null> {
return this.store.get(key) ?? null;
}
async write(key: string, data: ArrayBuffer): Promise<void> {
this.store.set(key, data);
}
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
for (const [key, data] of Object.entries(entries)) {
this.store.set(key, data);
}
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
async deleteMany(keys: string[]): Promise<void> {
for (const key of keys) {
this.store.delete(key);
}
}
async listKeys(): Promise<string[]> {
return Array.from(this.store.keys());
}
async exists(key: string): Promise<boolean> {
return this.store.has(key);
}
async clear(): Promise<void> {
this.store.clear();
}
}
/**
* AriaEngine Storage Backend — 存储后端抽象层
* @module engine/aria/store/backend
*
* 封装底层浏览器存储 APIIndexedDB / OPFS / Memory 回退),
* 供 Buffer Pool 的 PageIO 和 WAL 的 WALStore 使用。
*/
import { DatabaseError } from '../../../constants';
// ---------------------------------------------------------------------------
// StorageBackend 接口
// ---------------------------------------------------------------------------
export interface IStorageBackend {
/** 打开存储 */
open(name: string): Promise<void>;
/** 关闭存储 */
close(): Promise<void>;
/** 是否已打开 */
isOpen(): boolean;
/** 读取数据块 */
read(key: string): Promise<ArrayBuffer | null>;
/** 写入数据块 */
write(key: string, data: ArrayBuffer): Promise<void>;
/**
* 追加写入(v0.4.5 WAL 分片用,可选):
* - OPFS 后端实现真追加(createWritable keepExistingData + seekO(chunk)
* - 未实现的后端由调用方回退 read+writeEncryptedBackend 包装时整体重写保正确性)
* 语义:在 key 现有内容末尾追加 data;key 不存在时等同 write。
*/
append?(key: string, data: ArrayBuffer): Promise<void>;
/**
* 批量原子写入(v0.4.2-fix):多个 key 在单个底层事务中提交,
* 中断时整体回滚,不留半写状态。WAL count 与记录同事务保证一致性。
*/
writeMany(entries: Record<string, ArrayBuffer>): Promise<void>;
/** 删除数据块 */
delete(key: string): Promise<void>;
/**
* 批量原子删除(v0.4.2-fix):多个 key 在单个底层事务中提交。
*/
deleteMany(keys: string[]): Promise<void>;
/** 列出所有 key */
listKeys(): Promise<string[]>;
/** 检查 key 是否存在 */
exists(key: string): Promise<boolean>;
/** 清空所有数据 */
clear(): Promise<void>;
}
// =======================================================================
// Memory Backend(回退 / 测试用)
// =======================================================================
export class MemoryBackend implements IStorageBackend {
private store: Map<string, ArrayBuffer> = new Map();
private opened = false;
async open(_name: string): Promise<void> {
this.opened = true;
}
async close(): Promise<void> {
this.store.clear();
this.opened = false;
}
isOpen(): boolean {
return this.opened;
}
async read(key: string): Promise<ArrayBuffer | null> {
return this.store.get(key) ?? null;
}
async write(key: string, data: ArrayBuffer): Promise<void> {
this.store.set(key, data);
}
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
for (const [key, data] of Object.entries(entries)) {
this.store.set(key, data);
}
}
async delete(key: string): Promise<void> {
this.store.delete(key);
}
async deleteMany(keys: string[]): Promise<void> {
for (const key of keys) {
this.store.delete(key);
}
}
async listKeys(): Promise<string[]> {
return Array.from(this.store.keys());
}
async exists(key: string): Promise<boolean> {
return this.store.has(key);
}
async clear(): Promise<void> {
this.store.clear();
}
}
+2 -2
View File
@@ -207,7 +207,7 @@ export interface AriaEngineConfig {
/** 是否启用页面压缩(默认 false) */
compression?: boolean;
/** 存储后端 */
storageBackend?: 'indexeddb' | 'opfs' | 'memory';
storageBackend?: 'opfs' | 'memory';
/** WAL 大小阈值(字节,超过则强制 checkpoint,默认 16MB */
walSizeThreshold?: number;
/** 最大内存预算(MB,默认 64) */
@@ -241,7 +241,7 @@ export const DEFAULT_ARIA_CONFIG: Required<Omit<AriaEngineConfig, 'encryption' |
walSyncMode: 'full',
checkpointInterval: 1000,
compression: false,
storageBackend: 'indexeddb',
storageBackend: 'opfs',
walSizeThreshold: 16 * 1024 * 1024, // 16MB
maxMemoryMB: 64,
encryption: undefined,
+10 -11
View File
@@ -1,11 +1,10 @@
/**
* metona-sqlark Engine — 存储引擎层
* @module engine
*/
export type { IStorageEngine } from './interface';
export { MemoryEngine } from './memory';
export { IndexedDBEngine } from './indexeddb';
export { OPFSEngine } from './opfs';
export { AriaEngine } from './aria/index';
export type { AriaEngineConfig } from './aria/types';
/**
* metona-sqlark Engine — 存储引擎层
* @module engine
*/
export type { IStorageEngine } from './interface';
export { MemoryEngine } from './memory';
export { KVStoreEngine } from './kvstore_engine';
export { AriaEngine } from './aria/index';
export type { AriaEngineConfig } from './aria/types';
-840
View File
@@ -1,840 +0,0 @@
/**
* metona-sqlark IndexedDB Engine — 基于 IndexedDB 的持久化存储引擎
* @module engine/indexeddb
*
* v0.1.13: 支持事务 — beginTransaction 延迟 IDB 写入,commit 批量刷盘,rollback 恢复快照。
*/
import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema } from '../constants';
import { DatabaseError } from '../constants';
import { MemoryEngine } from './memory';
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
export class IndexedDBEngine implements IStorageEngine {
readonly name = 'indexeddb';
private db: IDBDatabase | null = null;
private dbName = '';
private version = 1;
private memoryCache: MemoryEngine = new MemoryEngine();
// ---- 事务状态 ----
private txActive = false;
async open(dbName: string, version: number): Promise<void> {
// v0.4.2-fix (P0-3): version < 1 归一化为 1indexedDB.open(name, 0) 抛原生 TypeError
const normalizedVersion = version >= 1 ? Math.floor(version) : 1;
this.dbName = dbName;
this.version = normalizedVersion;
await this.memoryCache.open(dbName, normalizedVersion);
// v0.4.2-fix (P0-2/P2-8): 版本自适应打开 + blocked 重试
this.db = await this.openDatabaseWithRetry(dbName, normalizedVersion);
this.setupVersionChangeHandler();
// v0.4.2-fix (P2-7): 确保 schema/meta 持久化 store 存在(新库或旧库升级时创建),
// 否则迁移版本等库内元数据无处落盘
await this.ensureSchemaStore();
try {
// v0.3.2: reopen 后从 IDB 重建 schemaschema 此前只存内存缓存,重开连接即丢失)
await this.rebuildSchemaFromIDB();
} catch (error) {
throw new DatabaseError(`Failed to restore schema for "${dbName}"`, 'IDB_SCHEMA_RESTORE_ERROR', error);
}
}
/** v0.4.2-fix: 多标签页冲突处理 — 其他标签页升级版本时自动关闭当前连接 */
private setupVersionChangeHandler(): void {
if (!this.db) return;
this.db.onversionchange = () => {
if (this.db) {
this.db.close();
this.db = null;
// eslint-disable-next-line no-console
console.warn(`[metona-sqlark] Database "${this.dbName}" was upgraded in another tab. Connection closed. Please re-open.`);
}
};
}
/**
* v0.4.2-fix: 打开 IndexedDB 连接。
* - P0-2: 请求版本低于库实际版本(VersionError)时,先无版本参数探测库当前版本,
* 再以实际版本重开(建表每张表版本号 +1,config.version 会过期)
* - P2-8: onblocked 为瞬时状态(另一连接短暂持有),等待后重试多次,超时才抛 IDB_BLOCKED
*/
private async openDatabaseWithRetry(dbName: string, requestedVersion: number): Promise<IDBDatabase> {
const BLOCKED_RETRIES = 10;
let effectiveVersion = requestedVersion;
for (let attempt = 0; attempt < BLOCKED_RETRIES; attempt++) {
try {
return await this.openRequest(dbName, effectiveVersion, 200 + attempt * 150);
} catch (error) {
const err = error as { name?: string };
if (err && err.name === 'VersionError') {
const currentVersion = await this.resolveCurrentVersion(dbName);
if (currentVersion >= 1 && currentVersion !== effectiveVersion) {
effectiveVersion = currentVersion;
this.version = currentVersion;
continue;
}
throw new DatabaseError(
`Failed to open IndexedDB "${dbName}": version mismatch`,
'IDB_VERSION_ERROR',
error,
);
}
if (err && err.name === 'BlockedError') {
// 另一连接短暂持有 → 等待后重试
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
continue;
}
throw new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', error);
}
}
throw new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED');
}
/**
* 发起一次 indexedDB.open 请求(success/error/blocked 三态收敛)。
* onblocked 不立即失败:阻塞解除后 success 仍会触发,仅超时兜底判失败,
* 避免"拒绝后连接迟到成功"泄漏未关闭的数据库连接。
*/
private openRequest(dbName: string, version: number, timeoutMs: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
// 兼容无 DOMException 构造环境
const blockedError = typeof DOMException !== 'undefined'
? new DOMException('IndexedDB open is blocked', 'BlockedError')
: Object.assign(new Error('IndexedDB open is blocked'), { name: 'BlockedError' });
reject(blockedError);
}, timeoutMs);
request.onsuccess = () => {
if (settled) {
// 超时判失败后连接迟到成功:立即关闭,避免阻塞后续版本升级
request.result.close();
return;
}
settled = true;
clearTimeout(timeout);
resolve(request.result);
};
request.onerror = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(request.error ?? new Error('Unknown IndexedDB open error'));
};
request.onblocked = () => {
// 保持等待,不拒绝(阻塞解除后 success 会触发;超时由 timer 兜底)
};
});
}
/** 无版本参数打开库,解析其当前实际版本号(随后立即关闭) */
private resolveCurrentVersion(dbName: string): Promise<number> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName);
request.onsuccess = () => {
const actualVersion = request.result.version;
request.result.close();
resolve(actualVersion);
};
request.onerror = () => {
reject(request.error ?? new Error('Failed to resolve IndexedDB version'));
};
});
}
/**
* v0.4.2-fix (P2-7): 确保 __metona_schema store 存在。
* 新库(或版本升级前创建的旧库)没有该 store 时,通过一次版本升级创建,
* 使 getMeta/setMeta(迁移版本持久化)始终可用。
*/
private async ensureSchemaStore(): Promise<void> {
if (!this.db) return;
if (this.db.objectStoreNames.contains('__metona_schema')) return;
const newVersion = this.db.version + 1;
this.db.close();
this.db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = () => {
const idb = request.result;
if (!idb.objectStoreNames.contains('__metona_schema')) {
idb.createObjectStore('__metona_schema', { keyPath: 'name' });
}
};
request.onsuccess = () => {
this.db = request.result;
this.setupVersionChangeHandler();
resolve(request.result);
};
request.onerror = () => reject(
new DatabaseError('Failed to create schema store', 'IDB_UPGRADE_ERROR', request.error),
);
});
}
/**
* 从 IDB 恢复内存 schema
* 1. 优先读取持久化的 schema 记录('__metona_schema' storev0.3.2
* 2. 旧数据回退:从 objectStore 主键 / 索引 / 样例数据推断
*/
private async rebuildSchemaFromIDB(): Promise<void> {
const db = this.ensureDB();
// 1. 持久化 schema
if (db.objectStoreNames.contains('__metona_schema')) {
const records: { name: string; schema: string }[] = await new Promise((resolve, reject) => {
const req = db.transaction('__metona_schema', 'readonly').objectStore('__metona_schema').getAll();
req.onsuccess = () => resolve((req.result ?? []) as { name: string; schema: string }[]);
req.onerror = () => reject(req.error);
});
for (const rec of records) {
try {
const schema = JSON.parse(rec.schema) as TableSchema;
if (!(await this.memoryCache.getTableSchema(schema.name))) {
await this.memoryCache.createTable(schema);
}
} catch {
// 损坏的 schema 记录忽略
}
}
}
// 2. 回退:无持久化 schema 的表从 IDB 结构推断
const storeNames = Array.from(db.objectStoreNames).filter((n) => n !== '__metona_schema');
for (const tableName of storeNames) {
// 已有 schema(持久化恢复或连续 open)则跳过
const existing = await this.memoryCache.getTableSchema(tableName);
if (existing) continue;
const columns: Record<string, import('../constants').ColumnDef> = {};
const tx = db.transaction(tableName, 'readonly');
const store = tx.objectStore(tableName);
// 主键列
const pk = store.keyPath as string;
columns[pk] = { type: 'string', primaryKey: true };
// 索引列(idx_ 前缀约定)
for (const idxName of Array.from(store.indexNames)) {
if (idxName.startsWith('idx_')) {
const col = idxName.slice(4);
if (!columns[col]) {
columns[col] = { type: 'string', index: true };
}
}
}
// 从样例数据推断其余列的类型
const rows: Record<string, unknown>[] = await new Promise((resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => resolve((req.result ?? []) as Record<string, unknown>[]);
req.onerror = () => reject(req.error);
});
if (rows.length > 0) {
for (const [key, value] of Object.entries(rows[0])) {
if (!columns[key]) {
columns[key] = { type: inferFieldType(value) };
}
}
}
await this.memoryCache.createTable({ name: tableName, columns });
}
}
async close(): Promise<void> {
// v0.4.3-fix: 活跃事务时先回滚(避免 commit 对已关闭连接报错)
if (this.txActive) {
try {
await this.rollbackTransaction();
} catch { /* 回滚失败不阻塞关闭 */ }
}
if (this.db) {
this.db.onversionchange = null; // 清理监听器
this.db.close();
this.db = null;
}
await this.memoryCache.close();
}
/**
* v0.4.2-fix: 自愈 — 从磁盘重建内存 schema 与数据(schema 丢失/内存不一致时调用)。
* 无删库需求即可恢复可用的库。
*/
async repair(): Promise<void> {
if (!this.db) throw new DatabaseError('Database not opened', 'DB_NOT_OPEN');
await this.memoryCache.close();
await this.memoryCache.open(this.dbName, this.version);
await this.rebuildSchemaFromIDB();
}
/**
* v0.4.2-fix: 清空全部数据与表结构(含持久化 schema 记录),保留库本身。
* 单个版本升级事务内原子完成。
*/
async clearAll(): Promise<void> {
const db = this.ensureDB();
// 先清内存缓存
const tableNames = await this.memoryCache.getTableNames();
for (const name of tableNames) {
await this.memoryCache.dropTable(name);
}
// 重置 IDB:删除所有表 store + 清空 schema/meta store
const newVersion = db.version + 1;
db.close();
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const idb = (event.target as IDBOpenDBRequest).result;
const toDelete = Array.from(idb.objectStoreNames).filter((n) => n !== '__metona_schema');
for (const name of toDelete) {
idb.deleteObjectStore(name);
}
if (!idb.objectStoreNames.contains('__metona_schema')) {
idb.createObjectStore('__metona_schema', { keyPath: 'name' });
} else {
// 保留 store 但清空内容(含持久化 schema 与 meta 记录)
const tx = (event.target as IDBOpenDBRequest).transaction!;
tx.objectStore('__metona_schema').clear();
}
};
request.onsuccess = () => {
this.db = request.result;
this.setupVersionChangeHandler();
resolve();
};
request.onerror = () => reject(new DatabaseError('Failed to clear database', 'IDB_CLEAR_ERROR', request.error));
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${this.dbName}" is blocked`, 'IDB_BLOCKED'));
});
}
// ---- 库内元数据(v0.4.2-fix:迁移版本持久化用,复用 __metona_schema store ----
async getMeta(key: string): Promise<string | null> {
const db = this.ensureDB();
if (!db.objectStoreNames.contains('__metona_schema')) return null;
return new Promise((resolve, reject) => {
const req = db.transaction('__metona_schema', 'readonly')
.objectStore('__metona_schema').get(`__meta:${key}`);
req.onsuccess = () => {
const rec = req.result as { schema?: unknown } | undefined;
resolve(rec && typeof rec.schema === 'string' ? rec.schema : null);
};
req.onerror = () => reject(req.error);
});
}
async setMeta(key: string, value: string): Promise<void> {
const db = this.ensureDB();
if (!db.objectStoreNames.contains('__metona_schema')) return;
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('__metona_schema', 'readwrite');
tx.objectStore('__metona_schema').put({ name: `__meta:${key}`, schema: value });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Failed to persist meta "${key}"`, 'IDB_META_ERROR', tx.error));
});
}
isOpen(): boolean { return this.db !== null; }
// ---- 表管理 ----
async createTable(schema: TableSchema): Promise<void> {
await this.memoryCache.createTable(schema);
if (this.txActive) return; // 事务中延迟 IDB 操作
await this.idbCreateTable(schema);
}
async dropTable(tableName: string): Promise<void> {
await this.memoryCache.dropTable(tableName);
if (this.txActive) return;
await this.idbDropTable(tableName);
}
async hasTable(tableName: string): Promise<boolean> { return this.ensureDB().objectStoreNames.contains(tableName); }
async getTableNames(): Promise<string[]> { return Array.from(this.ensureDB().objectStoreNames); }
async getTableSchema(tableName: string): Promise<TableSchema | null> { return this.memoryCache.getTableSchema(tableName); }
/**
* v0.4.2-fix: 引擎级 ALTER TABLE — schema 持久化到 __metona_schema store
* 重启后 ALTER 不丢失(此前通用路径只改内存引用,重启回退;DROP 的行数据也没真正删)。
*/
async alterTable(
tableName: string,
action: 'ADD' | 'DROP',
column: import('../constants').ColumnDef & { name: string },
): Promise<void> {
await this.memoryCache.alterTable(tableName, action, column);
if (this.txActive) return; // 事务中:commit 时统一 flushToIDB 同步
const schema = await this.memoryCache.getTableSchema(tableName);
if (schema) {
await this.persistSchema(schema);
}
if (action === 'DROP') {
// 重写 IDB 存储行:移除该列键(store.put 经 keyPath 自动覆盖原行)
const db = this.ensureDB();
const rows = await this.idbFind(tableName, { table: tableName });
const rewritten = rows.map((row) => {
if (column.name in row) {
const copy = { ...row };
delete (copy as Record<string, unknown>)[column.name];
return copy;
}
return row;
});
if (rewritten.length > 0) {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
for (const row of rewritten) {
store.put(row);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Failed to rewrite "${tableName}" after DROP COLUMN`, 'IDB_TX_ERROR', tx.error));
});
}
}
}
// ---- CRUD ----
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
const pks = await this.memoryCache.insert(tableName, rows);
if (this.txActive) return pks; // 事务中延迟写入
await this.idbInsert(tableName, rows);
return pks;
}
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
// 事务中从内存缓存读取(保证读到未提交的变更),否则走 IDB
if (this.txActive) return this.memoryCache.find(tableName, query);
return this.idbFind(tableName, query);
}
/** v0.4.0: 流式查询 — IDB 批量读入后逐行回调(保持接口一致性) */
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
if (this.txActive) {
return this.memoryCache.findStream(tableName, query, onRow);
}
const rows = await this.idbFind(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined });
let count = 0;
for (const row of rows) {
onRow(row);
count++;
}
return count;
}
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
const count = await this.memoryCache.update(tableName, query, updates);
if (this.txActive) return count;
await this.idbUpdate(tableName, query, updates);
return count;
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
const count = await this.memoryCache.delete(tableName, query);
if (this.txActive) return count;
await this.idbDelete(tableName, query);
return count;
}
async count(tableName: string, query?: QueryPlan): Promise<number> {
if (this.txActive) return this.memoryCache.count(tableName, query);
const results = await this.idbFind(tableName, { table: tableName, where: query?.where ?? {} });
return results.length;
}
async clear(tableName: string): Promise<void> {
await this.memoryCache.clear(tableName);
if (this.txActive) return;
await this.idbClear(tableName);
}
// ---- 动态索引(v0.3.0):通过版本升级创建/删除 IDB 索引 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
await this.memoryCache.createIndex(tableName, column, unique);
if (this.txActive) return;
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const newVersion = db.version + 1; db.close();
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const idb = (event.target as IDBOpenDBRequest).result;
const tx = idb.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
if (!store.indexNames.contains(`idx_${column}`)) {
store.createIndex(`idx_${column}`, column, { unique: unique ?? false });
}
};
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onerror = () => reject(new DatabaseError(`Failed to create index "${tableName}.${column}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
await this.memoryCache.dropIndex(tableName, column);
if (this.txActive) return;
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const newVersion = db.version + 1; db.close();
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const idb = (event.target as IDBOpenDBRequest).result;
const tx = idb.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
if (store.indexNames.contains(`idx_${column}`)) {
store.deleteIndex(`idx_${column}`);
}
};
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onerror = () => reject(new DatabaseError(`Failed to drop index "${tableName}.${column}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
// ---- 事务 ----
async beginTransaction(): Promise<void> {
if (this.txActive) throw new DatabaseError('Transaction already in progress', 'TX_ACTIVE');
this.txActive = true;
// 保存内存快照到 MemoryEngine 内部的 beginTransaction
await this.memoryCache.beginTransaction();
}
async commitTransaction(): Promise<void> {
if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE');
// v0.4.2-fix: 先刷盘后提交内存快照 — 此前先 memoryCache.commitTransaction()
// 再 flushToIDBflush 失败时 snapshot 已丢,回滚报 TX_NONE 且内存数据已确认
await this.flushToIDB();
await this.memoryCache.commitTransaction();
this.txActive = false;
}
async rollbackTransaction(): Promise<void> {
if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE');
// 恢复内存层到快照状态
await this.memoryCache.rollbackTransaction();
this.txActive = false;
}
// ---- IDB 原生操作 ----
private async idbCreateTable(schema: TableSchema): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const newVersion = db.version + 1; db.close();
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const pkColumn = Object.entries(schema.columns).find(([, c]) => c.primaryKey)?.[0] ?? Object.keys(schema.columns)[0];
const store = db.createObjectStore(schema.name, { keyPath: pkColumn });
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (colDef.index && colName !== pkColumn) {
store.createIndex(`idx_${colName}`, colName, { unique: colDef.unique ?? false });
}
}
// v0.3.2: schema 持久化 store(记录在升级完成后的 onsuccess 写入)
if (!db.objectStoreNames.contains('__metona_schema')) {
db.createObjectStore('__metona_schema', { keyPath: 'name' });
}
};
request.onsuccess = () => {
this.db = request.result;
// v0.3.2: 升级完成后持久化 schema(upgrade 事务内异步写会失败)
const schemaTx = this.db.transaction('__metona_schema', 'readwrite');
schemaTx.objectStore('__metona_schema').put({ name: schema.name, schema: JSON.stringify(schema) });
schemaTx.oncomplete = () => resolve();
schemaTx.onerror = () => reject(new DatabaseError(`Failed to persist schema for "${schema.name}"`, 'IDB_SCHEMA_ERROR', schemaTx.error));
};
request.onerror = () => reject(new DatabaseError(`Failed to create table "${schema.name}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
private async idbDropTable(tableName: string): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const newVersion = db.version + 1; db.close();
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (db.objectStoreNames.contains(tableName)) db.deleteObjectStore(tableName);
};
request.onsuccess = () => {
this.db = request.result;
// v0.3.2: 清理持久化 schema 记录(upgrade 后执行,失败不阻塞删除)
if (this.db.objectStoreNames.contains('__metona_schema')) {
const schemaTx = this.db.transaction('__metona_schema', 'readwrite');
schemaTx.objectStore('__metona_schema').delete(tableName);
schemaTx.onerror = () => { /* 忽略:旧库可能无此记录 */ };
}
resolve();
};
request.onerror = () => reject(new DatabaseError(`Failed to drop table "${tableName}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
private async idbInsert(tableName: string, rows: Record<string, unknown>[]): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
for (const row of rows) store.add(row);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Insert failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
private async idbFind(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
const db = this.ensureDB();
// 尝试使用 IDB 索引进行等值查询
if (query.where) {
const indexResult = await this.tryIDBIndexLookup(db, tableName, query);
if (indexResult !== null) return indexResult;
}
// 回退到全量 getAll + 内存过滤
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readonly');
const req = tx.objectStore(tableName).getAll();
req.onsuccess = () => {
let results: Record<string, unknown>[] = req.result ?? [];
if (query.where && Object.keys(query.where).length > 0) {
results = results.filter((row) => matchWhere(row, query.where!));
}
if (query.orderBy && query.orderBy.length > 0) {
results = applyOrderBy(results, query.orderBy);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? results.length;
results = results.slice(offset, offset + limit);
if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
results = results.map((r) => projectColumns(r, query.columns!));
}
resolve(results);
};
req.onerror = () => reject(new DatabaseError(`Find failed for "${tableName}"`, 'IDB_READ_ERROR', req.error));
});
}
/** 尝试使用 IDB 索引进行等值查询,成功返回结果,不适用返回 null */
private async tryIDBIndexLookup(
db: IDBDatabase,
tableName: string,
query: QueryPlan,
): Promise<Record<string, unknown>[] | null> {
if (!query.where) return null;
for (const [col, condition] of Object.entries(query.where)) {
// 跳过逻辑组合符
if (col === '$and' || col === '$or' || col === '$not') continue;
// 只处理等值查询
let targetValue: unknown;
if (typeof condition !== 'object' || condition === null) {
targetValue = condition;
} else if ('$eq' in (condition as Record<string, unknown>)) {
targetValue = (condition as Record<string, unknown>).$eq;
} else {
continue;
}
// 检查是否有对应的 IDB 索引
const indexName = `idx_${col}`;
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readonly');
const store = tx.objectStore(tableName);
if (!store.indexNames.contains(indexName)) {
resolve(null); // 没有索引,回退
return;
}
const index = store.index(indexName);
const req = index.getAll(targetValue as IDBValidKey);
req.onsuccess = () => {
let results: Record<string, unknown>[] = req.result ?? [];
// 如果有其他 WHERE 条件,继续过滤
const otherKeys = Object.keys(query.where!).filter(
(k) => k !== col && k !== '$and' && k !== '$or' && k !== '$not',
);
if (otherKeys.length > 0) {
results = results.filter((row) => matchWhere(row, query.where!));
}
if (query.orderBy && query.orderBy.length > 0) {
results = applyOrderBy(results, query.orderBy);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? results.length;
results = results.slice(offset, offset + limit);
if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
results = results.map((r) => projectColumns(r, query.columns!));
}
resolve(results);
};
req.onerror = () => reject(new DatabaseError(`Index lookup failed for "${tableName}.${col}"`, 'IDB_READ_ERROR', req.error));
});
} catch {
return null; // 索引不可用,回退
}
}
return null;
}
private async idbUpdate(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
const getAllReq = store.getAll();
getAllReq.onsuccess = () => {
for (const row of getAllReq.result ?? []) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
Object.assign(row, updates); store.put(row);
}
}
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Update failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
private async idbDelete(tableName: string, query: QueryPlan): Promise<void> {
const db = this.ensureDB();
const schema = await this.memoryCache.getTableSchema(tableName);
if (!schema) throw new DatabaseError(`Table "${tableName}" not found`, 'TABLE_NOT_FOUND');
const pkColumn = Object.entries(schema.columns).find(([, c]) => c.primaryKey)?.[0] ?? Object.keys(schema.columns)[0];
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
const getAllReq = store.getAll();
getAllReq.onsuccess = () => {
for (const row of getAllReq.result ?? []) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
store.delete(row[pkColumn] as IDBValidKey);
}
}
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Delete failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
private async idbClear(tableName: string): Promise<void> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
tx.objectStore(tableName).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Clear failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
/** 持久化单个表 schema 到 __metona_schema storev0.4.2-fix: ALTER TABLE 用) */
private async persistSchema(schema: TableSchema): Promise<void> {
const db = this.ensureDB();
if (!db.objectStoreNames.contains('__metona_schema')) return;
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('__metona_schema', 'readwrite');
tx.objectStore('__metona_schema').put({ name: schema.name, schema: JSON.stringify(schema) });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Failed to persist schema for "${schema.name}"`, 'IDB_SCHEMA_ERROR', tx.error));
});
}
/** 将内存缓存中的所有表数据原子性刷新到 IndexedDB */
private async flushToIDB(): Promise<void> {
const tableNames = await this.memoryCache.getTableNames();
let db = this.ensureDB();
// v0.4.2-fix: 事务内 DDL 只更新内存,commit 时同步 IDB 的 objectStore 结构:
// 缺失的表 store 创建(并持久化 schema)、已删除的 store 移除(防止重启幽灵表)
const idbStores = Array.from(db.objectStoreNames);
const missing = tableNames.filter((t) => !idbStores.includes(t));
const stale = idbStores.filter((s) => s !== '__metona_schema' && !tableNames.includes(s));
if (missing.length > 0 || stale.length > 0) {
// 升级事务内是同步上下文,先异步收集缺失表的 schema(主键列定义)
const schemaMap = new Map<string, TableSchema>();
for (const name of missing) {
const s = await this.memoryCache.getTableSchema(name);
if (s) schemaMap.set(name, s);
}
const newVersion = db.version + 1;
db.close();
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const idb = (event.target as IDBOpenDBRequest).result;
for (const name of stale) {
idb.deleteObjectStore(name);
}
for (const name of missing) {
const schema = schemaMap.get(name);
const pkColumn = schema
? (Object.entries(schema.columns).find(([, c]) => c.primaryKey)?.[0] ?? Object.keys(schema.columns)[0])
: undefined;
idb.createObjectStore(name, { keyPath: pkColumn });
}
// 事务内 drop 的表:同步清理持久化 schema 记录(防止重启后幽灵表恢复)
if (idb.objectStoreNames.contains('__metona_schema') && stale.length > 0) {
const tx = (event.target as IDBOpenDBRequest).transaction!;
const store = tx.objectStore('__metona_schema');
for (const name of stale) {
store.delete(name);
}
}
};
request.onsuccess = () => {
this.db = request.result;
this.setupVersionChangeHandler();
resolve();
};
request.onerror = () => reject(new DatabaseError('Failed to sync stores after transaction', 'IDB_UPGRADE_ERROR', request.error));
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${this.dbName}" is blocked`, 'IDB_BLOCKED'));
});
// 事务内新建表的 schema 一并持久化(此前只建 store 不存 schema → 重启后约束推断丢失)
for (const name of missing) {
const schema = await this.memoryCache.getTableSchema(name);
if (schema) await this.persistSchema(schema);
}
// v0.4.2-fix: DDL 升级会 close 旧连接并重新 open — 重新取 db 引用,
// 否则下方数据 flush 用已关闭的连接抛 InvalidStateError
db = this.ensureDB();
}
// 每个表在一个单独的 IDB 事务中完成 clear+insert,保证原子性
for (const tableName of tableNames) {
const rows = await this.memoryCache.find(tableName, { table: tableName });
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
store.clear(); // 清空
for (const row of rows) store.add(row); // 批量写入
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Flush failed for "${tableName}"`, 'IDB_FLUSH_ERROR', tx.error));
});
}
}
private ensureDB(): IDBDatabase {
if (!this.db) throw new DatabaseError('Database not opened', 'DB_NOT_OPEN');
return this.db;
}
}
/** 从存储值推断字段类型(schema 重建用,v0.3.2 */
function inferFieldType(value: unknown): import('../constants').FieldType {
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'object' && value !== null) return 'json';
if (typeof value === 'string') {
return isNaN(Date.parse(value)) ? 'string' : 'string';
}
return 'string';
}
+394
View File
@@ -0,0 +1,394 @@
/**
* KVStore — 自研 KV 事务存储引擎(替代 IndexedDB)
* @module engine/kvstore/index
*
* v0.6.0: 在浏览器文件系统(OPFS)之上实现 IndexedDB 级能力:
* - 多 key 原子事务:putMany/deleteMany 写入单条日志记录(单文件 COW 原子追加)→
* 崩溃时记录全有或全无(IndexedDB 事务同等的原子性,但完全自研)
* - 持久化与崩溃恢复:快照(checkpoint)+ 追加日志(WAL 式),两阶段恢复
* - 自愈:快照损坏回退全量日志重放;日志损坏截断至损坏处(丢弃未确认尾部)
* - 容错时序:checkpoint = 写快照 → 写 meta → 清空日志(meta 先于截断,
* 任何崩溃窗口数据不丢)
*
* 介质层为 IStorageBackendOPFSBackend / SharedMemoryBackend):
* - 浏览器:自动选择 OPFSnavigator.storage
* - Node/测试:SharedMemoryBackend(跨实例共享,模拟持久化)
*
* 可靠性设计:
* - 所有写操作与 checkpoint 经内部串行队列(快照与日志水位一致,无交错窗口)
* - 日志记录与快照均有标准 CRC-32 校验
* - 内存索引为热路径(get O(1)),checkpoint 后日志截断
*/
import type { IStorageBackend } from '../aria/store/backend';
import { OPFSBackend } from '../aria/store/opfs_backend';
import { SharedMemoryBackend } from './shared_memory_medium';
import { DatabaseError } from '../../constants';
import { encodeLogRecord, parseLogRecords, KVLogOp } from './log';
import { encodeSnapshot, decodeSnapshot } from './snapshot';
import { crc32 } from '../aria/crc32';
/** 存储键 */
const LOG_KEY = '__kv_log';
const SNAPSHOT_KEY = '__kv_snapshot';
const META_KEY = '__kv_meta';
/** checkpoint 自动触发阈值(日志字节数,0=不自动) */
const DEFAULT_CHECKPOINT_THRESHOLD = 16 * 1024 * 1024;
interface KVStoreMeta {
/** 当前日志水位(快照内嵌;无快照时 0) */
seq: number;
}
function defaultMedium(): IStorageBackend {
const nav = (globalThis as { navigator?: { storage?: { getDirectory?: unknown } } }).navigator;
if (typeof nav !== 'undefined' && nav.storage && typeof nav.storage.getDirectory === 'function') {
return new OPFSBackend();
}
return new SharedMemoryBackend();
}
export class KVStore {
private medium: IStorageBackend;
private dbName = '';
private opened = false;
/** 内存索引(热路径权威视图) */
private index = new Map<string, ArrayBuffer>();
/** 日志水位(最后一条已应用日志记录序号) */
private seq = 0;
/** 日志累计字节数(checkpoint 阈值) */
private logBytes = 0;
/** checkpoint 自动触发阈值(字节) */
private checkpointThreshold: number;
/** 写操作串行队列(checkpoint 与写入无交错窗口) */
private opQueue: Promise<unknown> = Promise.resolve();
/** 最近一次后台操作失败(checkpoint 时报告) */
private lastBackgroundError: unknown = null;
constructor(medium?: IStorageBackend, checkpointThreshold: number = DEFAULT_CHECKPOINT_THRESHOLD) {
this.medium = medium ?? defaultMedium();
this.checkpointThreshold = checkpointThreshold;
}
/** 底层介质(测试/诊断用) */
getMedium(): IStorageBackend {
return this.medium;
}
isOpen(): boolean {
return this.opened;
}
// =======================================================================
// 生命周期
// =======================================================================
/** 打开(加载快照 + 重放日志) */
async open(dbName: string): Promise<void> {
if (this.opened) return;
this.dbName = dbName;
await this.medium.open(dbName);
this.index = new Map();
this.seq = 0;
this.logBytes = 0;
// 1. 读 meta(可能缺失/损坏)
let metaSeq = 0;
const metaRaw = await this.medium.read(META_KEY);
if (metaRaw) {
try {
const meta = JSON.parse(new TextDecoder().decode(metaRaw)) as KVStoreMeta;
metaSeq = Number(meta.seq) || 0;
} catch { /* meta 损坏:回退全量日志 */ }
}
// 2. 加载快照(损坏则全量日志重放)
let snapshotSeq = 0;
const snapshotRaw = await this.medium.read(SNAPSHOT_KEY);
if (snapshotRaw) {
const snap = decodeSnapshot(new Uint8Array(snapshotRaw));
if (snap) {
this.index = new Map(snap.entries);
this.seq = snap.seq;
snapshotSeq = snap.seq;
} else {
// 快照损坏:从空索引 + 全量日志重放
this.index = new Map();
this.seq = 0;
snapshotSeq = 0;
}
}
// 3. 重放日志(seq > 快照水位的记录)
const logRaw = await this.medium.read(LOG_KEY);
if (logRaw && logRaw.byteLength > 0) {
const log = new Uint8Array(logRaw);
const baseSeq = Math.max(metaSeq, snapshotSeq);
const corruptOffsets: number[] = [];
const applied = parseLogRecords(log, (record) => {
if (record.seq <= baseSeq) return; // 快照已包含,跳过(幂等)
this.applyRecord(record.entries);
this.seq = record.seq;
}, (offset) => {
corruptOffsets.push(offset);
return true; // 记录损坏位置后停止(日志是顺序流,无法跳过继续)
});
if (applied > 0 || corruptOffsets.length > 0) {
this.logBytes = log.byteLength;
}
if (corruptOffsets.length > 0) {
// 损坏尾部:截断日志(丢弃未确认记录),下次 checkpoint 落盘
await this.truncateLog();
}
}
this.opened = true;
}
/**
* v0.6.0: 从介质重新加载(多标签页同步/外部写入可见用)。
* KVStore 的内存索引非跨实例共享,重新 open 读取介质最新数据。
*/
async reload(): Promise<void> {
if (!this.opened) return;
try { await this.opQueue; } catch { /* ignore */ }
this.index = new Map();
this.seq = 0;
this.logBytes = 0;
this.opened = false;
await this.open(this.dbName);
}
/** 关闭(不丢弃数据;下次 open 同名库恢复) */
async close(): Promise<void> {
if (!this.opened) return;
// 排空写队列
try { await this.opQueue; } catch { /* 写失败已返回 */ }
await this.medium.close();
this.index.clear();
this.seq = 0;
this.logBytes = 0;
this.opened = false;
}
// =======================================================================
// 读写(内存热路径)
// =======================================================================
async get(key: string): Promise<ArrayBuffer | null> {
return this.index.get(key) ?? null;
}
async getAll(): Promise<[string, ArrayBuffer][]> {
return Array.from(this.index.entries());
}
async listKeys(): Promise<string[]> {
return Array.from(this.index.keys());
}
async exists(key: string): Promise<boolean> {
return this.index.has(key);
}
size(): number {
return this.index.size;
}
getSeq(): number {
return this.seq;
}
// =======================================================================
// 写入(原子事务)
// =======================================================================
/** 单 key 写入(原子) */
async put(key: string, value: ArrayBuffer): Promise<void> {
await this.enqueue(async () => {
await this.appendRecord({ [key]: value }, []);
});
}
/** 多 key 原子写入(单条日志记录,崩溃全有或全无) */
async putMany(entries: Record<string, ArrayBuffer>): Promise<void> {
if (Object.keys(entries).length === 0) return;
await this.enqueue(async () => {
await this.appendRecord(entries, []);
});
}
/** 单 key 删除(原子) */
async delete(key: string): Promise<void> {
await this.enqueue(async () => {
await this.appendRecord({}, [key]);
});
}
/** 多 key 原子删除(单条日志记录) */
async deleteMany(keys: string[]): Promise<void> {
if (keys.length === 0) return;
await this.enqueue(async () => {
await this.appendRecord({}, keys);
});
}
// =======================================================================
// 维护
// =======================================================================
/** checkpoint:快照 → meta → 截断日志(时序保证任何崩溃窗口不丢数据) */
async checkpoint(): Promise<void> {
await this.enqueue(async () => {
// 报告上次后台失败
if (this.lastBackgroundError !== null) {
const error = this.lastBackgroundError;
this.lastBackgroundError = null;
throw new DatabaseError('KVStore background write failed', 'KV_BACKGROUND_ERROR', error);
}
if (this.logBytes === 0 && this.index.size === 0) return;
// 1. 写快照(COW 原子)
const snapBytes = encodeSnapshot(this.seq, this.index);
await this.medium.write(SNAPSHOT_KEY, snapBytes.buffer as ArrayBuffer);
// 2. 写 meta(指向新水位)
const meta: KVStoreMeta = { seq: this.seq };
await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer);
// 3. 截断日志(meta 已更新 → 截断安全)
await this.truncateLog();
});
}
/** 清空全部数据(保留库本身) */
async clear(): Promise<void> {
await this.enqueue(async () => {
await this.medium.clear();
this.index.clear();
this.seq = 0;
this.logBytes = 0;
// 写空 meta(下次 open 正常初始化)
const meta: KVStoreMeta = { seq: 0 };
await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer);
});
}
/**
* 自愈:校验快照/日志完整性,清理损坏数据。
* @returns 丢弃的损坏日志字节数(0 = 无损坏)
*/
async repair(): Promise<number> {
return this.enqueue(async () => {
let discarded = 0;
// 1. 校验快照:损坏则删除(下次打开全量日志重放)
const snapRaw = await this.medium.read(SNAPSHOT_KEY);
if (snapRaw && !decodeSnapshot(new Uint8Array(snapRaw))) {
await this.medium.delete(SNAPSHOT_KEY);
discarded++;
}
// 2. 校验日志:损坏尾部截断
const logRaw = await this.medium.read(LOG_KEY);
if (logRaw && logRaw.byteLength > 0) {
const log = new Uint8Array(logRaw);
const validBytes = this.findValidLogLength(log);
if (validBytes < log.byteLength) {
discarded += log.byteLength - validBytes;
const truncated = log.subarray(0, validBytes).slice().buffer as ArrayBuffer;
await this.medium.write(LOG_KEY, truncated);
}
}
return discarded;
});
}
// =======================================================================
// 内部
// =======================================================================
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
const run = this.opQueue.then(fn, fn);
this.opQueue = run.then(() => undefined, () => undefined);
return run;
}
/** 追加一条日志记录并更新内存索引(队列内调用,无并发) */
private async appendRecord(puts: Record<string, ArrayBuffer>, deletes: string[]): Promise<void> {
this.seq++;
const record = encodeLogRecord(this.seq, puts, deletes);
try {
// 日志追加:介质 append(真追加)或回退读+拼+写
const data = record.buffer.slice(record.byteOffset, record.byteOffset + record.byteLength) as ArrayBuffer;
if (typeof this.medium.append === 'function') {
await this.medium.append(LOG_KEY, data);
} else {
const existing = await this.medium.read(LOG_KEY);
if (existing) {
const combined = new Uint8Array(existing.byteLength + data.byteLength);
combined.set(new Uint8Array(existing), 0);
combined.set(new Uint8Array(data), existing.byteLength);
await this.medium.write(LOG_KEY, combined.buffer as ArrayBuffer);
} else {
await this.medium.write(LOG_KEY, data);
}
}
} catch (error) {
// 记录写入失败:内存索引不更新(原子性),记录后台错误
this.seq--; // 回滚水位
this.lastBackgroundError = error;
throw new DatabaseError('KVStore log append failed', 'KV_LOG_ERROR', error);
}
// 日志成功:更新内存索引(原子语义)
for (const [key, value] of Object.entries(puts)) {
this.index.set(key, value);
}
for (const key of deletes) {
this.index.delete(key);
}
this.logBytes += record.byteLength;
// 自动 checkpoint(日志超阈值)
if (this.checkpointThreshold > 0 && this.logBytes >= this.checkpointThreshold) {
await this.medium.write(SNAPSHOT_KEY, encodeSnapshot(this.seq, this.index).buffer as ArrayBuffer);
const meta: KVStoreMeta = { seq: this.seq };
await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer);
await this.truncateLog();
}
}
/** 截断日志(清空文件) */
private async truncateLog(): Promise<void> {
try {
await this.medium.write(LOG_KEY, new ArrayBuffer(0));
} catch { /* 截断失败:下次 checkpoint 重试 */ }
this.logBytes = 0;
}
/** 应用记录条目到内存索引 */
private applyRecord(entries: { op: KVLogOp; key: string; value: ArrayBuffer }[]): void {
for (const e of entries) {
if (e.op === KVLogOp.PUT) {
this.index.set(e.key, e.value);
} else {
this.index.delete(e.key);
}
}
}
/** 确定日志中有效字节长度(从 0 开始连续解析到第一条损坏/残缺记录) */
private findValidLogLength(log: Uint8Array): number {
let offset = 0;
const view = new DataView(log.buffer, log.byteOffset, log.byteLength);
while (offset + 4 <= log.byteLength) {
const recordLen = view.getUint32(offset, false);
if (recordLen < 12 || offset + 4 + recordLen > log.byteLength) break;
const raw = log.subarray(offset, offset + 4 + recordLen);
const storedCrc = view.getUint32(offset + recordLen, false);
if (crc32(raw.subarray(0, recordLen)) !== storedCrc) break;
offset += 4 + recordLen;
}
return offset;
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* KVStore Log — 追加式事务日志编解码
* @module engine/kvstore/log
*
* v0.6.0: 自研 KV 引擎的原子写载体。
* 每条日志记录 = 一个原子事务(putMany 多键写入 / deleteMany 多键删除)。
* 单文件追加(介质 append,COW 原子)→ 崩溃时记录全有或全无。
*
* 记录格式(大端序):
* [recordLen u32] — 本条记录长度(含自身,不含 CRC)
* [seq u32] — 日志序号(递增,恢复时与快照水位比对去重)
* [entryCount u32] — 条目数
* 每条 entry:
* [op u8] — 1=PUT, 2=DELETE
* [keyLen u32][key bytes]
* [valueLen u32][value bytes] DELETE 时 valueLen=0
* [crc u32] — 覆盖本条记录除 CRC 外全部字节的标准 CRC-32
*/
import { crc32 } from '../aria/crc32';
/** 日志操作类型 */
export const enum KVLogOp {
PUT = 1,
DELETE = 2,
}
/** 解析后的日志记录 */
export interface KVLogRecord {
/** 日志序号 */
seq: number;
/** 条目列表(op, key, value */
entries: { op: KVLogOp; key: string; value: ArrayBuffer }[];
/** 记录原始字节(CRC 校验用) */
raw: Uint8Array;
}
/**
* 编码一条日志记录。
* @param seq 日志序号
* @param puts key → value 写入条目
* @param deletes 删除 key 列表
*/
export function encodeLogRecord(
seq: number,
puts: Record<string, ArrayBuffer>,
deletes: string[] = [],
): Uint8Array {
const encoder = new TextEncoder();
const entries: { op: KVLogOp; key: string; value: ArrayBuffer }[] = [];
for (const [key, value] of Object.entries(puts)) {
entries.push({ op: KVLogOp.PUT, key, value });
}
for (const key of deletes) {
entries.push({ op: KVLogOp.DELETE, key, value: new ArrayBuffer(0) });
}
// 预编码 key 字节,计算总长度
const entryBytes: { op: KVLogOp; key: Uint8Array; value: Uint8Array }[] = [];
let total = 4 + 4 + 4; // recordLen + seq + entryCount
for (const e of entries) {
const kb = encoder.encode(e.key);
const vb = new Uint8Array(e.value);
entryBytes.push({ op: e.op, key: kb, value: vb });
total += 1 + 4 + kb.byteLength + 4 + vb.byteLength;
}
total += 4; // crc
const buf = new Uint8Array(total);
const view = new DataView(buf.buffer);
let offset = 0;
view.setUint32(offset, total - 4, false); offset += 4; // recordLen(不含 CRC
view.setUint32(offset, seq, false); offset += 4;
view.setUint32(offset, entryBytes.length, false); offset += 4;
for (const e of entryBytes) {
view.setUint8(offset, e.op); offset += 1;
view.setUint32(offset, e.key.byteLength, false); offset += 4;
buf.set(e.key, offset); offset += e.key.byteLength;
view.setUint32(offset, e.value.byteLength, false); offset += 4;
buf.set(e.value, offset); offset += e.value.byteLength;
}
const crc = crc32(buf.subarray(0, total - 4));
view.setUint32(total - 4, crc, false);
return buf;
}
/**
* 解析日志中的全部记录(顺序扫描)。
* @param data 日志字节流
* @param onRecord 每条有效记录回调(CRC 通过)
* @param onCorrupt 损坏记录位置回调(返回 false 停止扫描,或继续尝试下一条)
* @returns 有效记录数
*/
export function parseLogRecords(
data: Uint8Array,
onRecord: (record: KVLogRecord) => void,
onCorrupt?: (offset: number) => boolean,
): number {
let offset = 0;
let count = 0;
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const decoder = new TextDecoder();
while (offset + 4 <= data.byteLength) {
const recordLen = view.getUint32(offset, false);
if (recordLen < 12 || offset + 4 + recordLen > data.byteLength) {
// 尾部残缺记录(最后一批写入被截断):损坏
if (onCorrupt) {
if (!onCorrupt(offset)) break;
}
break;
}
const recordStart = offset;
const recordEnd = offset + 4 + recordLen;
const raw = data.subarray(recordStart, recordEnd);
const recView = new DataView(data.buffer, data.byteOffset + recordStart, recordLen + 4);
const storedCrc = recView.getUint32(recordLen, false);
const computedCrc = crc32(raw.subarray(0, recordLen));
if (storedCrc !== computedCrc) {
if (onCorrupt) {
if (!onCorrupt(recordStart)) break;
}
break;
}
// 解析条目
let p = 4;
const seq = recView.getUint32(p, false); p += 4;
const entryCount = recView.getUint32(p, false); p += 4;
const entries: { op: KVLogOp; key: string; value: ArrayBuffer }[] = [];
let valid = true;
for (let i = 0; i < entryCount; i++) {
if (p + 1 + 4 > recordLen + 4) { valid = false; break; }
const op = recView.getUint8(p) as KVLogOp; p += 1;
const keyLen = recView.getUint32(p, false); p += 4;
if (p + keyLen + 4 > recordLen + 4) { valid = false; break; }
const key = decoder.decode(raw.subarray(p, p + keyLen)); p += keyLen;
const valueLen = recView.getUint32(p, false); p += 4;
if (p + valueLen > recordLen + 4) { valid = false; break; }
const value = raw.slice(p, p + valueLen).buffer as ArrayBuffer; p += valueLen;
entries.push({ op, key, value });
}
if (!valid) {
if (onCorrupt) {
if (!onCorrupt(recordStart)) break;
}
break;
}
onRecord({ seq, entries, raw });
count++;
offset = recordEnd;
}
return count;
}
@@ -0,0 +1,99 @@
/**
* KVStore SharedMemory Medium — 跨实例共享的内存介质
* @module engine/kvstore/shared_memory_medium
*
* v0.6.0: 替代 fake-indexeddb 的测试/Node 环境介质。
* 与 MemoryBackend 的区别:数据按库名存于全局注册表,close() 不清除
* (模拟"磁盘持久化"语义——重新 open 同名库可读到上次写入的数据)。
*
* 仅用于测试与 Node 环境;浏览器使用 OPFS 介质(KVStore 默认自动选择)。
*/
import type { IStorageBackend } from '../aria/store/backend';
/** 全局注册表:dbName → key → ArrayBuffer(跨实例共享,模拟持久化) */
const registry = new Map<string, Map<string, ArrayBuffer>>();
export class SharedMemoryBackend implements IStorageBackend {
private dbName = '';
private store: Map<string, ArrayBuffer> | null = null;
/** 清空全局注册表(测试隔离用) */
static clearRegistry(): void {
registry.clear();
}
/** 注册表中的库数量(测试诊断用) */
static registrySize(): number {
return registry.size;
}
async open(name: string): Promise<void> {
this.dbName = name;
if (!registry.has(name)) {
registry.set(name, new Map());
}
this.store = registry.get(name)!;
}
/** close 不清除数据(持久化语义:重开同名库数据仍在) */
async close(): Promise<void> {
this.store = null;
}
isOpen(): boolean {
return this.store !== null;
}
async read(key: string): Promise<ArrayBuffer | null> {
return this.store?.get(key) ?? null;
}
async write(key: string, data: ArrayBuffer): Promise<void> {
this.store?.set(key, data);
}
async append(key: string, data: ArrayBuffer): Promise<void> {
if (!this.store) return;
const existing = this.store.get(key);
if (existing) {
const combined = new Uint8Array(existing.byteLength + data.byteLength);
combined.set(new Uint8Array(existing), 0);
combined.set(new Uint8Array(data), existing.byteLength);
this.store.set(key, combined.buffer as ArrayBuffer);
} else {
this.store.set(key, data);
}
}
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
if (!this.store) return;
// 同步批量写入 = 原子(JS 单线程,无中间 await 点)
for (const [key, data] of Object.entries(entries)) {
this.store.set(key, data);
}
}
async delete(key: string): Promise<void> {
this.store?.delete(key);
}
async deleteMany(keys: string[]): Promise<void> {
if (!this.store) return;
for (const key of keys) {
this.store.delete(key);
}
}
async listKeys(): Promise<string[]> {
return this.store ? Array.from(this.store.keys()) : [];
}
async exists(key: string): Promise<boolean> {
return this.store?.has(key) ?? false;
}
async clear(): Promise<void> {
this.store?.clear();
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* KVStore Snapshot — 快照序列化/反序列化
* @module engine/kvstore/snapshot
*
* v0.6.0: checkpoint 时把全部 key-value 序列化为快照文件(COW 原子写),
* 快照内嵌"日志水位 seq"(快照包含的最后一条日志序号),恢复时只重放 seq > 水位 的记录。
*
* 格式(大端序):
* [magic u32] — 0x4B56534E ("KVSN")
* [seq u32] — 日志水位(快照包含的数据对应的日志序号)
* [entryCount u32]
* 每条: [keyLen u32][key bytes][valueLen u32][value bytes]
* [crc u32] — 覆盖除 CRC 外全部字节的标准 CRC-32
*/
import { crc32 } from '../aria/crc32';
const SNAPSHOT_MAGIC = 0x4b56534e; // "KVSN"
/** 快照内容 */
export interface KVSsnapshot {
/** 日志水位 */
seq: number;
/** key → value */
entries: Map<string, ArrayBuffer>;
}
/** 序列化快照 */
export function encodeSnapshot(seq: number, entries: Map<string, ArrayBuffer>): Uint8Array {
const encoder = new TextEncoder();
const keys = Array.from(entries.keys());
// 预编码
const encoded: { key: Uint8Array; value: Uint8Array }[] = [];
let total = 4 + 4 + 4; // magic + seq + entryCount
for (const key of keys) {
const kb = encoder.encode(key);
const vb = new Uint8Array(entries.get(key)!);
encoded.push({ key: kb, value: vb });
total += 4 + kb.byteLength + 4 + vb.byteLength;
}
total += 4; // crc
const buf = new Uint8Array(total);
const view = new DataView(buf.buffer);
let offset = 0;
view.setUint32(offset, SNAPSHOT_MAGIC, false); offset += 4;
view.setUint32(offset, seq, false); offset += 4;
view.setUint32(offset, encoded.length, false); offset += 4;
for (const e of encoded) {
view.setUint32(offset, e.key.byteLength, false); offset += 4;
buf.set(e.key, offset); offset += e.key.byteLength;
view.setUint32(offset, e.value.byteLength, false); offset += 4;
buf.set(e.value, offset); offset += e.value.byteLength;
}
const crc = crc32(buf.subarray(0, total - 4));
view.setUint32(total - 4, crc, false);
return buf;
}
/**
* 解析快照。
* @returns 快照内容;损坏(magic 错误/CRC 失败/越界)返回 null
*/
export function decodeSnapshot(data: Uint8Array): KVSsnapshot | null {
if (data.byteLength < 16) return null;
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
if (view.getUint32(0, false) !== SNAPSHOT_MAGIC) return null;
const storedCrc = view.getUint32(data.byteLength - 4, false);
const computedCrc = crc32(data.subarray(0, data.byteLength - 4));
if (storedCrc !== computedCrc) return null;
const decoder = new TextDecoder();
const entries = new Map<string, ArrayBuffer>();
let p = 4;
const seq = view.getUint32(p, false); p += 4;
const entryCount = view.getUint32(p, false); p += 4;
for (let i = 0; i < entryCount; i++) {
if (p + 4 > data.byteLength - 4) return null;
const keyLen = view.getUint32(p, false); p += 4;
if (p + keyLen + 4 > data.byteLength - 4) return null;
const key = decoder.decode(data.subarray(p, p + keyLen)); p += keyLen;
const valueLen = view.getUint32(p, false); p += 4;
if (p + valueLen > data.byteLength - 4) return null;
const value = data.slice(p, p + valueLen).buffer as ArrayBuffer; p += valueLen;
entries.set(key, value);
}
return { seq, entries };
}
+492
View File
@@ -0,0 +1,492 @@
/**
* KVStoreEngine — 基于自研 KVStore 的磁盘存储引擎(替代 IndexedDBEngine / OPFSEngine
* @module engine/kvstore_engine
*
* v0.6.0: 完全移除 IndexedDB 后的 disk 模式引擎。
*
* 架构:MemoryEngine(内存热路径 + 事务快照)+ KVStore(持久化 + 原子写)
* - 读:始终走内存(写路径同步落盘,重启从 KVStore 恢复)
* - 写:内存先行 + KVStore 增量持久化(insert 增量 putManyupdate/delete 受影响行重写;
* 主键变更/级联场景整表 diff;全部原子)
* - 事务:内存快照 + commit 时受影响表原子 flushputMany 单记录 = 真原子,
* 此前 IndexedDBEngine 依赖 IDB 事务,现在完全自研)
*
* 数据布局(KVStore keys:
* `__schema` — JSON { tableName: TableSchema }
* `__meta:{key}` — 库内元数据(迁移版本等)
* `t:{table}:{pk}` — 行数据(JSON
*/
import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema } from '../constants';
import { DatabaseError } from '../constants';
import { MemoryEngine } from './memory';
import { KVStore } from './kvstore/index';
import { SharedMemoryBackend } from './kvstore/shared_memory_medium';
import type { IStorageBackend } from './aria/store/backend';
const SCHEMA_KEY = '__schema';
const ROW_PREFIX = 't:';
const enc = (s: string) => new TextEncoder().encode(s).buffer as ArrayBuffer;
const dec = (b: ArrayBuffer) => new TextDecoder().decode(b);
export class KVStoreEngine implements IStorageEngine {
readonly name = 'kv';
private kv!: KVStore;
private memory: MemoryEngine = new MemoryEngine();
private dbName = '';
private version = 1;
private opened = false;
/** 活跃事务标记 */
private txActive = false;
/** 事务中写过的表(commit 时只 flush 这些表) */
private txDirtyTables: Set<string> = new Set();
constructor(medium?: IStorageBackend, checkpointThreshold?: number) {
this.kv = new KVStore(medium, checkpointThreshold);
}
// ---- 行 key 编解码 ----
private rowKey(table: string, pk: string): string {
return `${ROW_PREFIX}${table}:${pk}`;
}
private rowPrefix(table: string): string {
return `${ROW_PREFIX}${table}:`;
}
// ---- 生命周期 ----
async open(dbName: string, version: number): Promise<void> {
if (this.opened) return;
this.dbName = dbName;
this.version = version;
await this.kv.open(dbName);
await this.memory.open(dbName, version);
// 恢复 schema
const schemaRaw = await this.kv.get(SCHEMA_KEY);
if (schemaRaw) {
try {
const schemas = JSON.parse(dec(schemaRaw)) as Record<string, TableSchema>;
for (const schema of Object.values(schemas)) {
await this.memory.createTable(schema);
}
} catch {
throw new DatabaseError('Corrupted schema in KVStore', 'KV_SCHEMA_ERROR');
}
}
// 恢复行数据 + 重建索引
const all = await this.kv.getAll();
for (const [key, value] of all) {
if (!key.startsWith(ROW_PREFIX)) continue;
const sep = key.indexOf(':', ROW_PREFIX.length);
if (sep < 0) continue;
const table = key.slice(ROW_PREFIX.length, sep);
if (!(await this.memory.hasTable(table))) continue;
try {
const row = JSON.parse(dec(value));
await this.memory.insert(table, [row]);
} catch {
// 单行损坏跳过(repair 可清理)
}
}
// 重建二级索引(schema 标记的索引列)
const tables = await this.memory.getTableNames();
for (const table of tables) {
const schema = await this.memory.getTableSchema(table);
if (!schema) continue;
for (const [col, colDef] of Object.entries(schema.columns)) {
if (colDef.index || colDef.unique) {
await this.memory.createIndex(table, col, colDef.unique);
}
}
}
this.opened = true;
}
async close(): Promise<void> {
if (!this.opened) return;
// 活跃事务先回滚
if (this.txActive) {
try { await this.rollbackTransaction(); } catch { /* ignore */ }
}
await this.kv.close();
await this.memory.close();
this.opened = false;
}
isOpen(): boolean {
return this.opened;
}
/**
* v0.6.0: 从 KVStore 重新加载全部数据到内存(多标签页同步重载用)。
* Hybrid 引擎的 reloadMemoryFromDisk 依赖磁盘引擎"读穿透"
* KVStoreEngine 读内存 → 提供 reload 重新加载磁盘最新数据。
*/
async reload(): Promise<void> {
if (!this.opened) return;
// 1. KVStore 重新从介质加载(外部写入可见)
await this.kv.reload();
// 2. 内存缓存重载
await this.memory.close();
await this.memory.open(this.dbName, this.version);
this.opened = false;
await this.open(this.dbName, this.version);
}
/** v0.4.2-fix: 自愈 — 校验 KVStore 日志/快照完整性并重建内存 */
async repair(): Promise<void> {
this.ensureOpen();
await this.kv.repair();
await this.memory.close();
await this.memory.open(this.dbName, this.version);
// 重新恢复(复用 open 的恢复逻辑)
this.opened = false;
await this.open(this.dbName, this.version);
}
async clearAll(): Promise<void> {
this.ensureOpen();
await this.kv.clear();
await this.memory.clearAll();
}
async getMeta(key: string): Promise<string | null> {
const raw = await this.kv.get(`__meta:${key}`);
return raw ? dec(raw) : null;
}
async setMeta(key: string, value: string): Promise<void> {
await this.kv.put(`__meta:${key}`, enc(value));
}
// ---- 表管理 ----
async createTable(schema: TableSchema): Promise<void> {
this.ensureOpen();
await this.memory.createTable(schema);
if (this.txActive) {
this.txDirtyTables.add(schema.name);
return;
}
await this.persistSchema();
}
async dropTable(tableName: string): Promise<void> {
this.ensureOpen();
await this.memory.dropTable(tableName);
if (this.txActive) {
this.txDirtyTables.add(tableName);
return;
}
await this.persistSchema();
// 删除该表全部行(KV 中残留清理)
await this.flushTable(tableName);
}
async hasTable(tableName: string): Promise<boolean> {
return this.memory.hasTable(tableName);
}
async getTableNames(): Promise<string[]> {
return this.memory.getTableNames();
}
async getTableSchema(tableName: string): Promise<TableSchema | null> {
return this.memory.getTableSchema(tableName);
}
async alterTable(
tableName: string,
action: 'ADD' | 'DROP',
column: import('../constants').ColumnDef & { name: string },
): Promise<void> {
this.ensureOpen();
await this.memory.alterTable(tableName, action, column);
if (this.txActive) {
this.txDirtyTables.add(tableName);
return;
}
await this.persistSchema();
if (action === 'DROP') {
// 重写存储行(移除该列)
await this.flushTable(tableName);
}
}
// ---- CRUD ----
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
this.ensureOpen();
const pks = await this.memory.insert(tableName, rows);
if (this.txActive) {
this.txDirtyTables.add(tableName);
return pks;
}
// 增量持久化(原子 putMany
const schema = await this.memory.getTableSchema(tableName);
if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
const pkCol = this.getPK(schema);
const puts: Record<string, ArrayBuffer> = {};
rows.forEach((row, i) => {
puts[this.rowKey(tableName, String(pks[i] ?? row[pkCol]))] = enc(JSON.stringify(row));
});
await this.kv.putMany(puts);
return pks;
}
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
this.ensureOpen();
return this.memory.find(tableName, query);
}
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
this.ensureOpen();
return this.memory.findStream(tableName, query, onRow);
}
async update(
tableName: string,
query: QueryPlan,
updates: Record<string, unknown>,
): Promise<number> {
this.ensureOpen();
const schema = await this.memory.getTableSchema(tableName);
if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
const pkCol = this.getPK(schema);
const pkChanged = pkCol in updates;
// 收集受影响旧主键(内存匹配)
const affected = pkChanged ? [] : await this.collectMatchingPks(tableName, query);
const count = await this.memory.update(tableName, query, updates);
if (this.txActive) {
this.txDirtyTables.add(tableName);
return count;
}
if (pkChanged) {
// 主键变更:相关表整表 diff(罕见操作,可靠性优先)
for (const t of await this.affectedTables(tableName)) {
await this.flushTable(t);
}
} 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) {
puts[this.rowKey(tableName, pk)] = enc(JSON.stringify(row[0]));
} else {
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);
}
}
return count;
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
this.ensureOpen();
// 收集受影响主键(内存匹配)
const pks = await this.collectMatchingPks(tableName, query);
const count = await this.memory.delete(tableName, query);
if (this.txActive) {
this.txDirtyTables.add(tableName);
return count;
}
const deletes = pks.map((pk) => this.rowKey(tableName, pk));
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: string, query?: QueryPlan): Promise<number> {
this.ensureOpen();
return this.memory.count(tableName, query);
}
async clear(tableName: string): Promise<void> {
this.ensureOpen();
await this.memory.clear(tableName);
if (this.txActive) {
this.txDirtyTables.add(tableName);
return;
}
await this.flushTable(tableName);
}
// ---- 动态索引 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
this.ensureOpen();
await this.memory.createIndex(tableName, column, unique);
if (this.txActive) {
this.txDirtyTables.add(tableName);
return;
}
await this.persistSchema();
}
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
this.ensureOpen();
await this.memory.dropIndex(tableName, column, indexName);
if (this.txActive) {
this.txDirtyTables.add(tableName);
return;
}
await this.persistSchema();
}
// ---- 事务(原子 flush ----
async beginTransaction(): Promise<void> {
this.ensureOpen();
await this.memory.beginTransaction();
this.txActive = true;
this.txDirtyTables = new Set();
}
async commitTransaction(): Promise<void> {
this.ensureOpen();
if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE');
// 先持久化(原子),再提交内存快照(失败可回滚)
for (const table of this.txDirtyTables) {
if (await this.memory.hasTable(table)) {
await this.flushTable(table);
} 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);
}
}
// v0.6.0-fix: 事务内 DDLcreate/drop/alter)的 schema 一并持久化
await this.persistSchema();
await this.kv.checkpoint();
await this.memory.commitTransaction();
this.txActive = false;
this.txDirtyTables = new Set();
}
async rollbackTransaction(): Promise<void> {
this.ensureOpen();
if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE');
await this.memory.rollbackTransaction();
this.txActive = false;
this.txDirtyTables = new Set();
}
// ---- 内部 ----
private ensureOpen(): void {
if (!this.opened) throw new DatabaseError('Database not opened', 'DB_NOT_OPEN');
}
private getPK(schema: TableSchema): string {
for (const [name, col] of Object.entries(schema.columns)) {
if (col.primaryKey) return name;
}
return Object.keys(schema.columns)[0];
}
/** 收集匹配查询的内存行主键(持久化差异计算用) */
private async collectMatchingPks(tableName: string, query: QueryPlan): Promise<string[]> {
const schema = await this.memory.getTableSchema(tableName);
if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
const pkCol = this.getPK(schema);
const rows = await this.memory.find(tableName, query);
return rows.map((r) => String(r[pkCol]));
}
/**
* 计算外键级联影响的表集合(传递闭包:A 被 B 引用,B 被 C 引用 → {A, B, C})。
* 级联操作(delete/update 主键)需要把这些表一并重写持久化。
*/
private async affectedTables(tableName: string): Promise<Set<string>> {
const set = new Set<string>([tableName]);
let changed = true;
while (changed) {
changed = false;
for (const table of await this.memory.getTableNames()) {
if (set.has(table)) continue;
const schema = await this.memory.getTableSchema(table);
if (!schema) continue;
for (const col of Object.values(schema.columns)) {
if (col.references) {
const ref = col.references.split('.')[0];
if (set.has(ref)) {
set.add(table);
changed = true;
break;
}
}
}
}
}
return set;
}
/** 持久化 schema(全部表) */
private async persistSchema(): Promise<void> {
const schemas: Record<string, TableSchema> = {};
for (const table of await this.memory.getTableNames()) {
const schema = await this.memory.getTableSchema(table);
if (schema) schemas[table] = schema;
}
await this.kv.put(SCHEMA_KEY, enc(JSON.stringify(schemas)));
}
/**
* 整表 diff 持久化:内存行全部 put + KV 残留行删除(原子 putMany + deleteMany)。
* 用于主键变更 / 级联 / dropTable / clear / alterTable DROP / 事务 commit。
*/
private async flushTable(tableName: string): Promise<void> {
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);
}
}
if (Object.keys(puts).length > 0) await this.kv.putMany(puts);
if (deletes.length > 0) await this.kv.deleteMany(deletes);
}
}
-363
View File
@@ -1,363 +0,0 @@
/**
* metona-sqlark OPFS Engine — 基于 Origin Private File System 的持久化存储引擎
* @module engine/opfs
*
* 使用 JSON-per-table 文件存储方案。
* 目录结构:{dbName}/tables/{tableName}.json
*/
import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema } from '../constants';
import { DatabaseError } from '../constants';
import { MemoryEngine } from './memory';
// ---------------------------------------------------------------------------
// OPFSEngine
// ---------------------------------------------------------------------------
export class OPFSEngine implements IStorageEngine {
readonly name = 'opfs';
private root: FileSystemDirectoryHandle | null = null;
private tablesDir: FileSystemDirectoryHandle | null = null;
private dbName = '';
// 运行时内存缓存(OPFS 文件读写有延迟)
private memoryCache: MemoryEngine = new MemoryEngine();
/**
* v0.4.3-fix: 写操作串行队列 — 内存写 + 快照 + 文件持久化整体排队执行,
* close() 等待队列排空后再释放目录句柄(避免 close 后挂起写泄漏/读旧数据)。
* 前一个操作失败不阻塞后续(错误仍返回给调用方)。
*/
private opQueue: Promise<unknown> = Promise.resolve();
/** 将写操作加入串行队列(快照在队列内取,始终最新) */
private enqueueOp<T>(fn: () => Promise<T>): Promise<T> {
const run = this.opQueue.then(fn, fn);
this.opQueue = run.then(() => undefined, () => undefined);
return run;
}
// ---- 生命周期 ----
async open(dbName: string, version: number): Promise<void> {
this.dbName = dbName;
await this.memoryCache.open(dbName, version);
// 获取 OPFS 根目录
this.root = await navigator.storage.getDirectory();
// 创建/打开数据库目录
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
// 从 OPFS 恢复已有表数据到内存缓存
await this.loadExistingTables();
}
async close(): Promise<void> {
// v0.4.3-fix: 等待所有挂起写操作完成(否则 close 后写仍在进行 → 重启读旧数据)
try {
await this.opQueue;
} catch { /* 写失败已返回给调用方 */ }
this.root = null;
this.tablesDir = null;
await this.memoryCache.close();
}
isOpen(): boolean {
return this.tablesDir !== null;
}
// ---- v0.4.2-fix: 自愈 / 重置 / 元数据 ----
/** 自愈:重置内存缓存后从 OPFS 重新加载(单文件损坏不影响其他表) */
async repair(): Promise<void> {
// v0.4.3-fix: 先等写队列排空(避免与挂起写竞态)
try { await this.opQueue; } catch { /* ignore */ }
await this.memoryCache.close();
await this.memoryCache.open(this.dbName, 1);
await this.loadExistingTables();
}
/** 清空全部数据与表结构(删除目录内全部文件) */
async clearAll(): Promise<void> {
// v0.4.3-fix: 先等写队列排空
try { await this.opQueue; } catch { /* ignore */ }
await this.memoryCache.close();
await this.memoryCache.open(this.dbName, 1);
if (this.tablesDir) {
const dir = this.tablesDir as any;
for await (const [name] of dir.entries()) {
try { await this.tablesDir!.removeEntry(name); } catch { /* ignore */ }
}
}
}
async getMeta(key: string): Promise<string | null> {
if (!this.tablesDir) return null;
try {
const fh = await this.tablesDir.getFileHandle(`__metona_${key}.meta`);
const file = await fh.getFile();
return await file.text();
} catch {
return null;
}
}
async setMeta(key: string, value: string): Promise<void> {
if (!this.tablesDir) return;
const fh = await this.tablesDir.getFileHandle(`__metona_${key}.meta`, { create: true });
const writable = await fh.createWritable();
await writable.write(value);
await writable.close();
}
// ---- 表管理 ----
async createTable(schema: TableSchema): Promise<void> {
return this.enqueueOp(async () => {
await this.memoryCache.createTable(schema);
// v0.4.2-fix: schema 持久化(此前仅写空数据文件 → 空表重启后消失、索引标记丢失)
await this.setMeta(`schema_${schema.name}`, JSON.stringify(schema));
// OPFS 中表以空 JSON 数组文件形式存在
await this.writeTableData(schema.name, []);
});
}
async dropTable(tableName: string): Promise<void> {
return this.enqueueOp(async () => {
await this.memoryCache.dropTable(tableName);
// v0.4.2-fix: 清理 schema meta(否则重启恢复幽灵表)
if (this.tablesDir) {
try {
await this.tablesDir.removeEntry(`__metona_schema_${tableName}.meta`);
} catch {
// 文件不存在则忽略
}
try {
await this.tablesDir.removeEntry(`${tableName}.json`);
} catch {
// 文件不存在则忽略
}
}
});
}
async hasTable(tableName: string): Promise<boolean> {
if (!this.tablesDir) return false;
try {
await this.tablesDir.getFileHandle(`${tableName}.json`);
return true;
} catch {
return false;
}
}
async getTableNames(): Promise<string[]> {
if (!this.tablesDir) return [];
const names: string[] = [];
for await (const [name] of (this.tablesDir as any).entries()) {
if (name.endsWith('.json')) {
names.push(name.replace('.json', ''));
}
}
return names;
}
async getTableSchema(tableName: string): Promise<TableSchema | null> {
return this.memoryCache.getTableSchema(tableName);
}
/** v0.4.2-fix: 引擎级 ALTER TABLE — 内存 + schema 持久化 + 整表文件重写 */
async alterTable(
tableName: string,
action: 'ADD' | 'DROP',
column: import('../constants').ColumnDef & { name: string },
): Promise<void> {
return this.enqueueOp(async () => {
await this.memoryCache.alterTable(tableName, action, column);
const schema = await this.memoryCache.getTableSchema(tableName);
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
const rows = await this.memoryCache.find(tableName, { table: tableName });
await this.writeTableData(tableName, rows);
});
}
// ---- CRUD ----
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
return this.enqueueOp(async () => {
const pks = await this.memoryCache.insert(tableName, rows);
// 持久化到 OPFS(快照在队列内取,始终最新)
const allRows = await this.memoryCache.find(tableName, { table: tableName });
await this.writeTableData(tableName, allRows);
return pks;
});
}
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
return this.memoryCache.find(tableName, query);
}
/** v0.4.0: 流式查询(委托内存缓存) */
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
return this.memoryCache.findStream(tableName, query, onRow);
}
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
return this.enqueueOp(async () => {
const count = await this.memoryCache.update(tableName, query, updates);
const allRows = await this.memoryCache.find(tableName, { table: tableName });
await this.writeTableData(tableName, allRows);
return count;
});
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
return this.enqueueOp(async () => {
const count = await this.memoryCache.delete(tableName, query);
const allRows = await this.memoryCache.find(tableName, { table: tableName });
await this.writeTableData(tableName, allRows);
return count;
});
}
async count(tableName: string, query?: QueryPlan): Promise<number> {
return this.memoryCache.count(tableName, query);
}
async clear(tableName: string): Promise<void> {
return this.enqueueOp(async () => {
await this.memoryCache.clear(tableName);
await this.writeTableData(tableName, []);
});
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
return this.enqueueOp(async () => {
await this.memoryCache.createIndex(tableName, column, unique);
// v0.4.2-fix: 索引标记持久化(重启后索引结构恢复)
const schema = await this.memoryCache.getTableSchema(tableName);
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
});
}
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
return this.enqueueOp(async () => {
await this.memoryCache.dropIndex(tableName, column, indexName);
const schema = await this.memoryCache.getTableSchema(tableName);
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
});
}
// ---- 事务 ----
async beginTransaction(): Promise<void> {
await this.memoryCache.beginTransaction();
}
async commitTransaction(): Promise<void> {
return this.enqueueOp(async () => {
await this.memoryCache.commitTransaction();
// 将内存数据刷到 OPFS
const tableNames = await this.memoryCache.getTableNames();
for (const tableName of tableNames) {
const rows = await this.memoryCache.find(tableName, { table: tableName });
await this.writeTableData(tableName, rows);
}
});
}
async rollbackTransaction(): Promise<void> {
await this.memoryCache.rollbackTransaction();
}
// ---- 内部辅助 ----
private ensureDir(): FileSystemDirectoryHandle {
if (!this.tablesDir) {
throw new DatabaseError('Database not opened', 'DB_NOT_OPEN');
}
return this.tablesDir;
}
private async writeTableData(tableName: string, data: Record<string, unknown>[]): Promise<void> {
// 由 enqueueOp 串行化调用,此处直接写文件
const dir = this.ensureDir();
const fileName = `${tableName}.json`;
const fileHandle = await dir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data));
await writable.close();
}
private async readTableData(tableName: string): Promise<Record<string, unknown>[]> {
const dir = this.ensureDir();
const fileName = `${tableName}.json`;
try {
const fileHandle = await dir.getFileHandle(fileName);
const file = await fileHandle.getFile();
const text = await file.text();
return JSON.parse(text);
} catch {
return [];
}
}
/**
* 从 OPFS 加载已有表到内存缓存。
* v0.4.2-fix: 优先从持久化 schema__metona_schema_*.meta)恢复 —
* 空表不再消失、索引标记/主键/约束完整;无 schema 记录的旧库从数据推断(兼容)。
*/
private async loadExistingTables(): Promise<void> {
if (!this.tablesDir) return;
const dir = this.tablesDir as any;
const fileNames: string[] = [];
for await (const [name] of dir.entries()) {
if (name.endsWith('.json')) fileNames.push(name);
}
for (const fileName of fileNames) {
const tableName = fileName.replace('.json', '');
try {
// 1. 优先:持久化 schema
const schemaRaw = await this.getMeta(`schema_${tableName}`);
if (schemaRaw) {
const schema = JSON.parse(schemaRaw) as TableSchema;
await this.memoryCache.createTable(schema);
const rows = await this.readTableData(tableName);
for (const row of rows) {
try {
await this.memoryCache.insert(tableName, [row]);
} catch {
// 单行损坏不影响整表恢复
}
}
continue;
}
// 2. 兼容旧库:从数据推断 schema(空表且无 schema 记录 → 跳过)
const data = await this.readTableData(tableName);
if (data.length > 0) {
const firstRow = data[0];
const columns: Record<string, any> = {};
for (const key of Object.keys(firstRow)) {
const val = firstRow[key];
const type = typeof val === 'number' ? 'number' :
typeof val === 'boolean' ? 'boolean' :
typeof val === 'object' ? 'json' : 'string';
columns[key] = { type, primaryKey: key === 'id' };
}
await this.memoryCache.createTable({ name: tableName, columns });
for (const row of data) {
await this.memoryCache.insert(tableName, [row]);
}
}
} catch {
// 单个文件损坏不影响其他表
}
}
}
}
+10 -4
View File
@@ -12,8 +12,7 @@ import type { IStorageEngine } from '../engine/interface';
import type { QueryPlan, TableSchema, DiskEngine } from '../constants';
import { DatabaseError } from '../constants';
import { MemoryEngine } from '../engine/memory';
import { IndexedDBEngine } from '../engine/indexeddb';
import { OPFSEngine } from '../engine/opfs';
import { KVStoreEngine } from '../engine/kvstore_engine';
// ---------------------------------------------------------------------------
// HybridEngine
@@ -28,10 +27,11 @@ export class HybridEngine implements IStorageEngine {
private dbName = '';
private version = 1;
constructor(diskEngine: DiskEngine = 'indexeddb') {
constructor(diskEngine: DiskEngine = 'opfs') {
this.memoryEngine = new MemoryEngine();
this.diskEngineType = diskEngine;
this.diskEngine = diskEngine === 'opfs' ? new OPFSEngine() : new IndexedDBEngine();
// v0.6.0: 磁盘层统一为自研 KVStoreEngine
this.diskEngine = new KVStoreEngine();
}
// ---- 生命周期 ----
@@ -57,6 +57,12 @@ export class HybridEngine implements IStorageEngine {
await this.memoryEngine.close();
await this.memoryEngine.open(this.dbName, this.version);
// v0.6.0: KVStoreEngine 读内存 → 先重载磁盘最新数据
const disk = this.diskEngine as IStorageEngine & { reload?: () => Promise<void> };
if (typeof disk.reload === 'function') {
await disk.reload();
}
const tableNames = await this.diskEngine.getTableNames();
for (const tableName of tableNames) {
const schema = await this.diskEngine.getTableSchema(tableName);
+90 -91
View File
@@ -1,91 +1,90 @@
/**
* metona-sqlark — 入口文件
* @module metona-sqlark
* @version 0.4.1
*
* 前端关系型数据库,内存与磁盘双模式。
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
*/
import { MetonaSqlark } from './core';
import type { DatabaseConfig } from './constants';
import { VERSION } from './constants';
// 连接池管理器(side-effect: 注入 MetonaSqlark.connect / disconnect 等静态方法)
import './connection-manager';
// ---------------------------------------------------------------------------
// 工厂函数
// ---------------------------------------------------------------------------
/**
* 创建数据库实例并初始化
*
* @example
* ```ts
* const db = await MetonaSqlark.create({
* name: 'my-app',
* mode: 'hybrid',
* });
*
* await db.defineTable('users', {
* id: { type: 'string', primaryKey: true },
* name: { type: 'string', required: true },
* });
*
* await db.table('users').insert({ id: '1', name: 'Alice' });
* const results = await db.query('SELECT * FROM users');
* ```
*/
async function create(config: DatabaseConfig): Promise<MetonaSqlark> {
const db = new MetonaSqlark(config);
await db.init();
return db;
}
// ---------------------------------------------------------------------------
// 全局 API
// ---------------------------------------------------------------------------
const api = {
VERSION,
version: VERSION,
create,
MetonaSqlark,
MeSqlark: MetonaSqlark,
};
// 浏览器全局挂载
declare global { interface Window { MetonaSqlark: typeof api; MeSqlark: typeof api; } }
if (typeof window !== 'undefined') {
window.MetonaSqlark = api;
window.MeSqlark = api;
}
export default api;
export {
api,
VERSION,
create,
MetonaSqlark,
};
// 别名
export const MeSqlark = MetonaSqlark;
// 类型导出
export type { DatabaseConfig, TableSchema, ColumnDef, FieldType, StorageMode, DiskEngine } from './constants';
export type { IStorageEngine } from './engine/interface';
export type { Statement, SelectStatement, InsertStatement, UpdateStatement, DeleteStatement } from './query/ast';
export { MemoryEngine } from './engine/memory';
export { IndexedDBEngine } from './engine/indexeddb';
export { OPFSEngine } from './engine/opfs';
export { AriaEngine } from './engine/aria/index';
export { HybridEngine } from './hybrid/index';
export { Table } from './table/table';
export { parse, parseAll } from './sql/parser';
export { tokenize } from './sql/lexer';
// AriaEngine 类型 & 后端
export type { AriaEngineConfig } from './engine/aria/types';
export { OPFSBackend } from './engine/aria/store/opfs_backend';
/**
* metona-sqlark — 入口文件
* @module metona-sqlark
* @version 0.4.1
*
* 前端关系型数据库,内存与磁盘双模式。
* 支持 Query Builder 链式 API 和 SQL 字符串查询。
*/
import { MetonaSqlark } from './core';
import type { DatabaseConfig } from './constants';
import { VERSION } from './constants';
// 连接池管理器(side-effect: 注入 MetonaSqlark.connect / disconnect 等静态方法)
import './connection-manager';
// ---------------------------------------------------------------------------
// 工厂函数
// ---------------------------------------------------------------------------
/**
* 创建数据库实例并初始化
*
* @example
* ```ts
* const db = await MetonaSqlark.create({
* name: 'my-app',
* mode: 'hybrid',
* });
*
* await db.defineTable('users', {
* id: { type: 'string', primaryKey: true },
* name: { type: 'string', required: true },
* });
*
* await db.table('users').insert({ id: '1', name: 'Alice' });
* const results = await db.query('SELECT * FROM users');
* ```
*/
async function create(config: DatabaseConfig): Promise<MetonaSqlark> {
const db = new MetonaSqlark(config);
await db.init();
return db;
}
// ---------------------------------------------------------------------------
// 全局 API
// ---------------------------------------------------------------------------
const api = {
VERSION,
version: VERSION,
create,
MetonaSqlark,
MeSqlark: MetonaSqlark,
};
// 浏览器全局挂载
declare global { interface Window { MetonaSqlark: typeof api; MeSqlark: typeof api; } }
if (typeof window !== 'undefined') {
window.MetonaSqlark = api;
window.MeSqlark = api;
}
export default api;
export {
api,
VERSION,
create,
MetonaSqlark,
};
// 别名
export const MeSqlark = MetonaSqlark;
// 类型导出
export type { DatabaseConfig, TableSchema, ColumnDef, FieldType, StorageMode, DiskEngine } from './constants';
export type { IStorageEngine } from './engine/interface';
export type { Statement, SelectStatement, InsertStatement, UpdateStatement, DeleteStatement } from './query/ast';
export { MemoryEngine } from './engine/memory';
export { KVStoreEngine } from './engine/kvstore_engine';
export { AriaEngine } from './engine/aria/index';
export { HybridEngine } from './hybrid/index';
export { Table } from './table/table';
export { parse, parseAll } from './sql/parser';
export { tokenize } from './sql/lexer';
// AriaEngine 类型 & 后端
export type { AriaEngineConfig } from './engine/aria/types';
export { OPFSBackend } from './engine/aria/store/opfs_backend';
+170
View File
@@ -0,0 +1,170 @@
/**
* migrateFromIndexedDB — 旧 IndexedDB 数据迁移到自研 KV 引擎
* @module migration/index
*
* v0.6.0: IndexedDB 从引擎中完全移除后,提供一次性迁移工具把旧库数据
* 导入新引擎(KVStoreEngine disk 模式 / AriaEngine)。
*
* 旧库命名:
* - disk 模式(IndexedDBEngine):库名 = dbName
* - aria 模式(IndexedDBBackend):库名 = `aria-${dbName}`
*
* 仅此模块保留原生 IndexedDB 读取代码(一次性迁移用途,不参与运行时)。
*/
import type { MetonaSqlark } from '../core';
import type { TableSchema, ColumnDef, FieldType } from '../constants';
export interface MigrationOptions {
/** 旧库名(业务名,不含 aria- 前缀) */
dbName: string;
/**
* 旧引擎类型:仅支持 diskIndexedDBEngine,每表一个 objectStore,行数据可直接读取)。
* aria 旧库(IndexedDBBackend)数据为引擎私有格式(SSTable/WAL),无法按行迁移。
*/
engine: 'disk';
/** 目标数据库实例(已初始化,新引擎) */
target: MetonaSqlark;
/** 进度回调 */
onProgress?: (done: number, total: number, table?: string) => void;
}
export interface MigrationResult {
/** 已迁移的表 */
migratedTables: string[];
/** 迁移的行总数 */
rowCount: number;
/** 跳过(无 schema 且无数据)的表 */
skippedTables: string[];
}
/** 旧库中持久化 schema 的 store 名(IndexedDBEngine v0.3.2+ */
const SCHEMA_STORE = '__metona_schema';
function inferFieldType(value: unknown): FieldType {
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'object' && value !== null) return 'json';
return 'string';
}
/** 从样例行推断 schema(旧库无持久化 schema 时回退) */
function inferSchema(tableName: string, rows: Record<string, unknown>[]): TableSchema {
const columns: Record<string, ColumnDef> = {};
if (rows.length === 0) return { name: tableName, columns };
const first = rows[0];
for (const key of Object.keys(first)) {
columns[key] = {
type: inferFieldType(first[key]),
primaryKey: key === 'id',
};
}
return { name: tableName, columns };
}
/** 打开旧 IndexedDB 库(只读) */
function openLegacyDB(idbName: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(idbName);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error(`Failed to open legacy IndexedDB "${idbName}"`));
});
}
/** 读取 object store 全部记录 */
function readAllRecords(store: IDBObjectStore): Promise<Record<string, unknown>[]> {
return new Promise((resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => resolve((req.result ?? []) as Record<string, unknown>[]);
req.onerror = () => reject(req.error);
});
}
/** 读取持久化 schema 记录 */
function readSchemas(db: IDBDatabase): Promise<Record<string, TableSchema>> {
if (!db.objectStoreNames.contains(SCHEMA_STORE)) {
return Promise.resolve({});
}
return new Promise((resolve, reject) => {
const req = db.transaction(SCHEMA_STORE, 'readonly').objectStore(SCHEMA_STORE).getAll();
req.onsuccess = () => {
const result: Record<string, TableSchema> = {};
for (const rec of (req.result ?? []) as { name: string; schema?: string }[]) {
if (!rec.schema) continue;
try {
const schema = JSON.parse(rec.schema) as TableSchema;
result[schema.name] = schema;
} catch { /* 损坏记录跳过 */ }
}
resolve(result);
};
req.onerror = () => reject(req.error);
});
}
/**
* 将旧 IndexedDB 库迁移到目标引擎。
* @returns 迁移结果(表/行数统计)
*/
export async function migrateFromIndexedDB(opts: MigrationOptions): Promise<MigrationResult> {
// v0.6.0: aria 旧库为引擎私有格式(SSTable/WAL),不支持按行迁移(运行时防御)
if ((opts.engine as string) === 'aria') {
throw new Error(
'Migration from AriaEngine IndexedDB backend is not supported ' +
'(data is stored in engine-private SSTable/WAL format). ' +
'Only disk-mode IndexedDBEngine databases can be migrated.',
);
}
const idbName = opts.dbName;
let db: IDBDatabase | null = null;
try {
db = await openLegacyDB(idbName);
} catch (error) {
throw new Error(
`Legacy IndexedDB database "${idbName}" not found or unreadable: ${(error as Error).message}`,
);
}
const result: MigrationResult = { migratedTables: [], rowCount: 0, skippedTables: [] };
const schemas = await readSchemas(db);
try {
const storeNames = Array.from(db.objectStoreNames).filter((n) => n !== SCHEMA_STORE);
for (let i = 0; i < storeNames.length; i++) {
const tableName = storeNames[i];
opts.onProgress?.(i, storeNames.length, tableName);
const rows = await readAllRecords(db.transaction(tableName, 'readonly').objectStore(tableName));
// 表已存在于目标库 → 跳过(避免覆盖)
const names = await opts.target.getTableNames();
if (names.includes(tableName)) {
result.skippedTables.push(tableName);
continue;
}
// schema:持久化优先,否则从数据推断(空表且无 schema → 跳过)
let schema = schemas[tableName];
if (!schema) {
if (rows.length === 0) {
result.skippedTables.push(tableName);
continue;
}
schema = inferSchema(tableName, rows);
}
// 写入目标引擎
await opts.target.defineTable(tableName, schema.columns);
if (rows.length > 0) {
await opts.target.table(tableName).insertMany(rows);
}
result.migratedTables.push(tableName);
result.rowCount += rows.length;
}
} finally {
db.close();
}
return result;
}