release: v0.6.0 — 完全移除 IndexedDB,自研 KVStore 事务存储引擎(多key原子写/快照日志恢复/CRC自愈)+ KVStoreEngine + 旧库迁移工具 + 10万级压力验证 + 崩溃注入e2e
This commit is contained in:
+4
-4
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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
@@ -1,241 +1,108 @@
|
||||
/**
|
||||
* AriaEngine Storage Backend — 存储后端抽象层
|
||||
* @module engine/aria/store/backend
|
||||
*
|
||||
* 封装底层浏览器存储 API(IndexedDB / 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 + seek,O(chunk))
|
||||
* - 未实现的后端由调用方回退 read+write(EncryptedBackend 包装时整体重写保正确性)
|
||||
* 语义:在 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
|
||||
*
|
||||
* 封装底层浏览器存储 API(IndexedDB / 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 + seek,O(chunk))
|
||||
* - 未实现的后端由调用方回退 read+write(EncryptedBackend 包装时整体重写保正确性)
|
||||
* 语义:在 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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 归一化为 1(indexedDB.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 重建 schema(schema 此前只存内存缓存,重开连接即丢失)
|
||||
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' store,v0.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()
|
||||
// 再 flushToIDB,flush 失败时 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 store(v0.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';
|
||||
}
|
||||
@@ -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 先于截断,
|
||||
* 任何崩溃窗口数据不丢)
|
||||
*
|
||||
* 介质层为 IStorageBackend(OPFSBackend / SharedMemoryBackend):
|
||||
* - 浏览器:自动选择 OPFS(navigator.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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* KVStoreEngine — 基于自研 KVStore 的磁盘存储引擎(替代 IndexedDBEngine / OPFSEngine)
|
||||
* @module engine/kvstore_engine
|
||||
*
|
||||
* v0.6.0: 完全移除 IndexedDB 后的 disk 模式引擎。
|
||||
*
|
||||
* 架构:MemoryEngine(内存热路径 + 事务快照)+ KVStore(持久化 + 原子写)
|
||||
* - 读:始终走内存(写路径同步落盘,重启从 KVStore 恢复)
|
||||
* - 写:内存先行 + KVStore 增量持久化(insert 增量 putMany;update/delete 受影响行重写;
|
||||
* 主键变更/级联场景整表 diff;全部原子)
|
||||
* - 事务:内存快照 + commit 时受影响表原子 flush(putMany 单记录 = 真原子,
|
||||
* 此前 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: 事务内 DDL(create/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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
* 旧引擎类型:仅支持 disk(IndexedDBEngine,每表一个 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;
|
||||
}
|
||||
Reference in New Issue
Block a user