release: v0.4.2 — 生产就绪与崩溃自愈 + 问题清单修复 + 版本迭代
CI / test (18.x) (push) Successful in 10m7s
CI / test (20.x) (push) Successful in 10m6s
CI / test (22.x) (push) Successful in 10m2s
CI / test (24.x) (push) Successful in 10m0s

This commit is contained in:
thzxx
2026-08-09 19:15:37 +08:00
parent a1e4f5071c
commit 22b0b1fad4
34 changed files with 6626 additions and 565 deletions
+312 -39
View File
@@ -70,6 +70,22 @@ export class AriaEngine implements IStorageEngine {
async open(dbName: string, _version: number): Promise<void> {
if (this.opened) return;
// v0.4.2-fix: 引擎内部错误统一包装为 DatabaseErrorARIA_OPEN_ERROR),
// 应用层可拿到 code 分类处理,不再抛出原生 RangeError/TypeError
try {
await this.openInternal(dbName);
} catch (error) {
if (error instanceof DatabaseError) throw error;
throw new DatabaseError(
`Failed to open AriaEngine database "${dbName}"`,
'ARIA_OPEN_ERROR',
error,
);
}
}
/** open 内部实现(错误包装在 open 外层) */
private async openInternal(dbName: string): Promise<void> {
this.dbName = dbName;
// 1. 存储后端
@@ -105,20 +121,30 @@ export class AriaEngine implements IStorageEngine {
this.wal = new WAL(
{
append: async (data) => {
// Store each record as a separate numbered key
// v0.4.2-fix: 记录写入与 count 计数在同一底层事务中原子提交,
// 中断时整体回滚,杜绝"记录在、计数丢"导致恢复漏读的丢数据问题
const idx = await this.getWALCount();
const slice = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const copy = slice.buffer.slice(slice.byteOffset, slice.byteOffset + slice.byteLength) as ArrayBuffer;
await this.backend.write(`__wal_${idx}`, copy);
await this.setWALCount(idx + 1);
await this.backend.writeMany({
[`__wal_${idx}`]: copy,
__wal_count: new TextEncoder().encode(String(idx + 1)).buffer,
});
},
readAll: async () => {
const count = await this.getWALCount();
if (count === 0) return new Uint8Array(0);
// Read all records and concatenate
// v0.4.2-fix: 不依赖 count 计数,直接扫描全部 __wal_* 键,
// 避免 count 与实际记录不一致时漏读(与 checkpoint/并发写入竞态无关)
const keys = (await this.backend.listKeys())
.filter((k) => k.startsWith('__wal_') && k !== '__wal_count')
.sort((a, b) => {
const na = parseInt(a.slice('__wal_'.length), 10);
const nb = parseInt(b.slice('__wal_'.length), 10);
return (isNaN(na) ? 0 : na) - (isNaN(nb) ? 0 : nb);
});
if (keys.length === 0) return new Uint8Array(0);
const chunks: Uint8Array[] = [];
for (let i = 0; i < count; i++) {
const d = await this.backend.read(`__wal_${i}`);
for (const key of keys) {
const d = await this.backend.read(key);
if (d) chunks.push(new Uint8Array(d));
}
const total = chunks.reduce((s, c) => s + c.byteLength, 0);
@@ -128,15 +154,17 @@ export class AriaEngine implements IStorageEngine {
return combined;
},
truncate: async () => {
const count = await this.getWALCount();
for (let i = 0; i < count; i++) {
await this.backend.delete(`__wal_${i}`);
}
// v0.4.2-fix: 扫描删除全部 WAL 记录键 + count 键(单事务原子清理)
const keys = (await this.backend.listKeys())
.filter((k) => k.startsWith('__wal_'));
await this.backend.deleteMany(keys);
await this.setWALCount(0);
},
exists: async () => {
const count = await this.getWALCount();
return count > 0;
// v0.4.2-fix: 与 readAll 一致按 key 扫描判断(count 可能因崩溃截断而滞后)
const keys = (await this.backend.listKeys())
.filter((k) => k.startsWith('__wal_') && k !== '__wal_count');
return keys.length > 0;
},
},
this.config.walEnabled,
@@ -146,6 +174,31 @@ export class AriaEngine implements IStorageEngine {
// 5. 恢复 Schema
await this.loadSchemas();
// v0.4.2-fix: 为 schema 中带 index/unique 标记的列重建二级索引 LSM。
// 此前重开只恢复 schema 不恢复索引 LSM → 索引查询静默回退全表、
// createIndex 因 colDef 已有标记直接 return → 索引永久缺失。
// 索引数据已持久化在独立命名空间(sst_idx_* / meta),init() 直接加载。
for (const [tableName, schema] of this.schemas) {
const pkCol = this.tablePKs.get(tableName)!;
for (const [colName, colDef] of Object.entries(schema.columns)) {
if ((colDef.index || colDef.unique) && colName !== pkCol) {
const idxKey = `${tableName}:idx:${colName}`;
if (!this.secondaryIndexes.has(idxKey)) {
const idxLsm = new LSM({
memtableSizeThreshold: this.config.memtableSizeThreshold,
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore: this.createSSTableStore(`idx_${tableName}_${colName}`),
});
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
}
}
}
}
// 6. 初始化 LSM(加载 SSTable 元数据)
await this.lsm.init();
@@ -175,12 +228,31 @@ export class AriaEngine implements IStorageEngine {
if (allRecords.length > 0) {
await this.lsm.flush();
await this.wal.checkpoint();
// v0.4.2-fix: WAL 回放只更新主 LSM,二级索引 LSM 未同步 →
// 崩溃前最后一批写入的索引缺失,重开时索引查询丢行。
// 恢复后全量重建所有表的二级索引(幂等)。
for (const tableName of this.schemas.keys()) {
await this.reindexTableInternal(tableName);
}
}
// 8. Checkpoint Manager(接入 WAL 大小阈值)
// v0.4.2-fix: 事务活跃时 checkpoint 不得截断 WAL —
// 否则 BEGIN/INSERT 记录被截断,COMMIT 后崩溃恢复丢失整个事务数据
this.checkpointManager = new CheckpointManager(
this.lsm,
this.wal,
{
checkpoint: async () => {
if (this.currentTxnId) return;
await this.wal.checkpoint();
},
flush: async () => {
if (this.currentTxnId) return;
await this.wal.flush();
},
getBufferedBytes: () => this.wal.getBufferedBytes(),
getBufferedCount: () => this.wal.getBufferedCount(),
} as unknown as WAL,
{ flushAll: async () => { await this.lsm.flush(); } } as any,
this.config.checkpointInterval,
this.config.walSizeThreshold,
@@ -193,12 +265,51 @@ export class AriaEngine implements IStorageEngine {
if (!this.opened) return;
await this.persistSchemas();
await this.lsm.flush();
// v0.4.2-fix: 同步落盘全部二级索引 LSM — 此前只 flush 主 LSM
// 优雅关闭后索引 memtable 未落盘 → 重开索引为空 → 索引查询返回空结果
for (const idxLsm of this.secondaryIndexes.values()) {
await idxLsm.flush();
}
await this.wal.flush();
// v0.4.2-fix: close 前 checkpoint(截断 WAL)—
// 此前只 flush 不截断,下次打开会重放全部历史 WAL 记录(含已落盘 SSTable 的数据),
// 重复解析/重复 put 拖慢启动,并与恢复后 flush+checkpoint 竞争放大丢数据
await this.wal.checkpoint();
await this.backend.close();
// v0.4.2-fix: 清空运行期状态(此前 close 后 mvcc/txn 残留,
// 重开时 beginTransaction 报 TX_ACTIVE 或读到陈旧快照)
this.schemas.clear();
this.tablePKs.clear();
this.secondaryIndexes.clear();
this.mvcc = new MVCCManager();
this.currentTxnId = null;
this.txnSnapshot = null;
this.savepoints.clear();
this.opCounter = 0;
this.opened = false;
}
/**
* v0.4.2-fix: 崩溃恢复/自愈 — 校验并移除损坏 SSTable、截断 WAL、重建二级索引。
* 应用层检测到异常后调用,无需删库重建。
*/
async repair(): Promise<void> {
this.ensureOpen();
// 1. 校验全部 SSTable,移除残缺项(打开时已做一次,此处兜底运行期损坏)
const removed = await this.lsm.validateAll();
// 2. 将 WAL 残留数据落盘并截断,避免无限重放
await this.lsm.flush();
await this.wal.checkpoint();
// 3. 重建所有表的二级索引(修复索引与主数据不一致)
for (const tableName of this.schemas.keys()) {
await this.reindexTable(tableName);
}
if (removed > 0) {
// eslint-disable-next-line no-console
console.warn(`[AriaEngine] repair: removed ${removed} corrupted SSTable(s)`);
}
}
/**
* v0.4.1: 重置数据库 — 清空全部数据与表结构(演示页刷新/重新初始化用)。
* 清空存储后端、LSM、WAL、MVCC 与二级索引,后续可继续使用本实例。
@@ -224,12 +335,26 @@ export class AriaEngine implements IStorageEngine {
isOpen(): boolean { return this.opened; }
// ---- v0.4.2-fix: 库内元数据(迁移版本持久化用) ----
async getMeta(key: string): Promise<string | null> {
const raw = await this.backend.read(`__meta_${key}`);
return raw ? new TextDecoder().decode(raw) : null;
}
async setMeta(key: string, value: string): Promise<void> {
await this.backend.write(`__meta_${key}`, new TextEncoder().encode(value).buffer);
}
// =======================================================================
// 表管理
// =======================================================================
async createTable(schema: TableSchema): Promise<void> {
this.ensureOpen();
// v0.4.2-fix: Aria 事务中 DDL 显式拒绝(事务快照只覆盖行数据,
// 结构变更无法回滚;Memory/IndexedDB 引擎快照可回滚,行为不一致 → 明确报错而非静默)
this.ensureNoDDLInTransaction('CREATE TABLE');
if (this.schemas.has(schema.name)) {
throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
}
@@ -270,6 +395,7 @@ export class AriaEngine implements IStorageEngine {
async dropTable(tableName: string): Promise<void> {
this.ensureOpen();
this.ensureNoDDLInTransaction('DROP TABLE');
this.ensureTable(tableName);
// 删除表中所有行
@@ -279,6 +405,10 @@ export class AriaEngine implements IStorageEngine {
this.lsm.delete(`${tableName}:${row[pkCol]}`);
}
// v0.4.2-fix: 清理该表的全部二级索引 LSM 与持久化文件 —
// 此前残留孤儿索引,重建同名表后旧索引数据污染新表(索引查询返回错误行)
await this.cleanupTableIndexes(tableName);
this.schemas.delete(tableName);
this.tablePKs.delete(tableName);
await this.persistSchemas();
@@ -291,6 +421,25 @@ export class AriaEngine implements IStorageEngine {
});
}
/**
* v0.4.2-fix: 清理指定表的全部二级索引 LSM(内存 + 存储文件 + meta)。
* dropTable / DROP_TABLE 恢复 / alterTable DROP 索引列 共用。
*/
private async cleanupTableIndexes(tableName: string): Promise<void> {
const prefix = `${tableName}:idx:`;
const toDelete: string[] = [];
for (const [idxKey, idxLsm] of this.secondaryIndexes) {
if (!idxKey.startsWith(prefix)) continue;
toDelete.push(idxKey);
try {
await idxLsm.clear();
} catch { /* 清理失败不阻塞 */ }
}
for (const idxKey of toDelete) {
this.secondaryIndexes.delete(idxKey);
}
}
async hasTable(tableName: string): Promise<boolean> {
return this.schemas.has(tableName);
}
@@ -421,6 +570,8 @@ export class AriaEngine implements IStorageEngine {
let count = 0;
// v0.3.1: 批量 WAL 写入(组提交)
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set<string>();
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
@@ -430,24 +581,48 @@ export class AriaEngine implements IStorageEngine {
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(
tableName, String(row[pkCol]), newPk, walRecords, visited,
);
}
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(key, updated);
this.mvcc.writeVersion(tableName, String(row[pkCol]), updated, this.currentTxnId);
if (pkChanged) {
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
} else {
this.lsm.put(key, updated);
if (pkChanged) this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
}
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
key: newPk,
data: updated,
});
// 更新二级索引
this.updateSecondaryIndexes(tableName, String(row[pkCol]), updated, row);
// 更新二级索引(主键变更时旧索引条目一并清理)
this.updateSecondaryIndexes(tableName, newPk, updated, pkChanged ? row : null);
}
}
@@ -457,6 +632,74 @@ export class AriaEngine implements IStorageEngine {
await this.checkpointManager.tick();
this.trimAllCaches();
return count;
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
* 两阶段:先全量 RESTRICT 检查,再执行级联。
*/
private async applyForeignKeyUpdateRules(
tableName: string,
oldPk: string,
newPk: string,
walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[],
visited: Set<string>,
): Promise<void> {
const visitKey = `${tableName}:${oldPk}`;
if (visited.has(visitKey)) return;
visited.add(visitKey);
// 阶段 1: RESTRICT 检查
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
if (colDef.onUpdate !== 'RESTRICT') continue;
const refRows = await this.getAllRows(refTableName);
if (refRows.some((r) => String(r[colName]) === oldPk)) {
throw new DatabaseError(
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
}
}
// 阶段 2: CASCADE / SET NULL
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
if (colDef.onUpdate !== 'CASCADE' && colDef.onUpdate !== 'SET NULL') continue;
const refRows = await this.getAllRows(refTableName);
for (const refRow of refRows) {
if (String(refRow[colName]) !== oldPk) continue;
const refPkCol = this.tablePKs.get(refTableName)!;
const refPk = String(refRow[refPkCol]);
const updatedRef = { ...refRow, [colName]: colDef.onUpdate === 'CASCADE' ? newPk : null };
const refKey = `${refTableName}:${refPk}`;
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(refKey, updatedRef);
this.mvcc.writeVersion(refTableName, refPk, updatedRef, this.currentTxnId);
} else {
this.lsm.put(refKey, updatedRef);
}
this.updateSecondaryIndexes(refTableName, refPk, updatedRef, refRow);
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName: refTableName,
key: refPk,
data: updatedRef,
});
}
}
}
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
@@ -707,6 +950,7 @@ export class AriaEngine implements IStorageEngine {
column: import('../../constants').ColumnDef & { name: string },
): Promise<void> {
this.ensureOpen();
this.ensureNoDDLInTransaction('ALTER TABLE');
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
@@ -723,11 +967,21 @@ export class AriaEngine implements IStorageEngine {
if (!schema.columns[column.name]) {
throw new DatabaseError(`Column "${column.name}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
}
// v0.4.2-fix: 被删列是索引列 → 先清理索引 LSM(残留会导致后续同名列索引脏数据)
if (schema.columns[column.name].index || schema.columns[column.name].unique) {
const idxKey = `${tableName}:idx:${column.name}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (idxLsm) {
try {
await idxLsm.clear();
} catch { /* 清理失败不阻塞 */ }
this.secondaryIndexes.delete(idxKey);
}
}
delete schema.columns[column.name];
await this.persistSchemas();
// 重写主 LSM:移除所有行的该列键(find 副本无法就地删除,必须重写存储)
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
const endKey = `${prefix}\uffff`;
await this.lsm.prefetchRange(prefix, endKey);
@@ -757,30 +1011,30 @@ export class AriaEngine implements IStorageEngine {
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
this.ensureOpen();
this.ensureNoDDLInTransaction('CREATE INDEX');
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
if (!colDef) throw new DatabaseError(`Column "${column}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
if (colDef.index || colDef.unique) return; // 已存在
const idxKey = `${tableName}:idx:${column}`;
// v0.4.2-fix: 以索引 LSM 是否已建为准(schema 标记可能因重启恢复而存在,
// 但索引 LSM 未恢复 → 此前静默 return 导致索引永久缺失)
if (this.secondaryIndexes.has(idxKey)) return;
colDef.index = true;
if (unique) colDef.unique = true;
const idxKey = `${tableName}:idx:${column}`;
if (!this.secondaryIndexes.has(idxKey)) {
const idxLsm = new LSM({
memtableSizeThreshold: this.config.memtableSizeThreshold,
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore: this.createSSTableStore(`idx_${tableName}_${column}`),
});
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
}
const idxLsm = new LSM({
memtableSizeThreshold: this.config.memtableSizeThreshold,
levelSizeMultiplier: this.config.levelSizeMultiplier,
blockSize: this.config.pageSize,
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
cacheLimitBytes: this.config.bufferPoolPages * this.config.pageSize,
sstableStore: this.createSSTableStore(`idx_${tableName}_${column}`),
});
await idxLsm.init();
this.secondaryIndexes.set(idxKey, idxLsm);
// 从主 LSM 重建索引数据
const idxLsm = this.secondaryIndexes.get(idxKey)!;
const pkCol = this.tablePKs.get(tableName)!;
const rows = await this.getAllRows(tableName);
for (const row of rows) {
@@ -795,6 +1049,7 @@ export class AriaEngine implements IStorageEngine {
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
this.ensureOpen();
this.ensureNoDDLInTransaction('DROP INDEX');
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
@@ -1185,6 +1440,8 @@ export class AriaEngine implements IStorageEngine {
*/
private async applyDropTableRecovery(tableName: string): Promise<void> {
if (!tableName) return;
// v0.4.2-fix: 清理该表二级索引(崩溃恢复路径同样不留孤儿索引)
await this.cleanupTableIndexes(tableName);
this.schemas.delete(tableName);
this.tablePKs.delete(tableName);
@@ -1442,7 +1699,13 @@ export class AriaEngine implements IStorageEngine {
async reindexTable(tableName: string): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
return this.reindexTableInternal(tableName);
}
/** v0.4.2-fix: 重建索引内部实现(不校验 opened,供 open 恢复流程调用) */
private async reindexTableInternal(tableName: string): Promise<number> {
const schema = this.schemas.get(tableName);
if (!schema) return 0;
let rebuiltCount = 0;
for (const [colName, colDef] of Object.entries(schema.columns)) {
@@ -1482,7 +1745,7 @@ export class AriaEngine implements IStorageEngine {
}
}
// GC MVCC 版本(保留最新 10 个)
const beforeGC = this.mvcc.getActiveTxnCount?.() ?? 0;
const beforeGC = this.mvcc.getGlobalLSN();
this.mvcc.gc(10);
return { compactedLevels: 6, gcVersions: beforeGC };
}
@@ -1525,6 +1788,16 @@ export class AriaEngine implements IStorageEngine {
if (!this.opened) throw new DatabaseError('AriaEngine not opened', 'DB_NOT_OPEN');
}
/** v0.4.2-fix: Aria 事务中 DDL 显式拒绝(结构变更无法通过行快照回滚) */
private ensureNoDDLInTransaction(op: string): void {
if (this.currentTxnId) {
throw new DatabaseError(
`${op} is not supported inside a transaction (AriaEngine DDL is not transactional)`,
'NOT_SUPPORTED',
);
}
}
private ensureTable(tableName: string): void {
if (!this.schemas.has(tableName)) {
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
+142 -13
View File
@@ -69,6 +69,8 @@ export class LSM {
private cacheLimitBytes: number;
private levelSizeMultiplier: number;
private blockSize: number;
/** v0.4.2-fix: 配置的 memtable 阈值(freeze 后新 memtable 用配置值,不衰减) */
private memtableSizeThreshold: number;
private sstableStore: SSTableStore;
private operationCount = 0;
private initialized = false;
@@ -77,7 +79,8 @@ export class LSM {
private flushChain: Promise<void> = Promise.resolve();
constructor(config: LSMConfig) {
this.memtable = new MemTable(config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE);
this.memtableSizeThreshold = config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE;
this.memtable = new MemTable(this.memtableSizeThreshold);
this.levelSizeMultiplier = config.levelSizeMultiplier ?? DEFAULT_LEVEL_SIZE_MULTIPLIER;
this.blockSize = config.blockSize ?? 4096;
this.sstableStore = config.sstableStore;
@@ -97,8 +100,17 @@ export class LSM {
const metas = await this.sstableStore.listMeta();
// 按层级分组
// v0.4.2-fix: 打开时完整性校验 — 验证每个 meta 引用的文件存在、可解析,
// 残缺/损坏的 SSTable 忽略并清理 meta,避免后续读取抛 RangeError 崩溃
const validMetas: SSTableMeta[] = [];
for (const meta of metas) {
if (await this.validateSSTable(meta)) {
validMetas.push(meta);
}
}
// 按层级分组
for (const meta of validMetas) {
if (meta.level >= 0 && meta.level < MAX_LSM_LEVELS) {
this.levels[meta.level].push(meta);
}
@@ -157,15 +169,30 @@ export class LSM {
* 冻结当前 MemTable 为 immutable,并在串行链上排队异步刷盘。
* 冻结的 MemTable 通过闭包捕获,避免链中前一个 flush 错误处理后续冻结的表。
* 所有 pending frozen 记录在 frozenMemtables 中,flush 完成前读取路径仍可访问。
*
* v0.4.2-fix: 链上任务失败时吞错恢复链(否则 flushChain 永久 rejected
* 后续所有 flush/compaction 挂起,写路径卡死)。
*/
freezeMemtable(): void {
if (this.immutableMemtable) {
const frozen = this.immutableMemtable;
this.flushChain = this.flushChain.then(() => this.flushImmutableAsync(frozen));
this.flushChain = this.enqueueOnChain(() => this.flushImmutableAsync(frozen));
}
this.immutableMemtable = this.memtable;
this.frozenMemtables.push(this.immutableMemtable);
this.memtable = new MemTable(this.memtable.getEstimatedSize());
// v0.4.2-fix: 新 memtable 用配置阈值(此前传旧表已用大小 → 阈值逐次衰减 → 频繁小文件 flush)
this.memtable = new MemTable(this.memtableSizeThreshold);
}
/** v0.4.2-fix: 在串行链上排队任务;任务失败吞错并记录,保证链不被单次失败卡死 */
private enqueueOnChain(task: () => Promise<void>): Promise<void> {
return this.flushChain
.then(task)
.catch((error) => {
// eslint-disable-next-line no-console
console.warn('[AriaEngine LSM] background flush/compaction failed:', error);
// catch 返回 undefined → 链恢复为 resolved,后续任务继续
});
}
/** 将指定 Immutable MemTable 刷盘为 SSTableid 由 store 按命名空间分配) */
@@ -218,7 +245,7 @@ export class LSM {
this.compacting = true;
setTimeout(() => {
try {
this.flushChain = this.flushChain.then(() => this.compactLevelAsync(level));
this.flushChain = this.enqueueOnChain(() => this.compactLevelAsync(level));
} finally {
this.compacting = false;
// 连续触发:如果 compaction 后仍然超标,继续调度
@@ -243,6 +270,10 @@ export class LSM {
} finally {
this.compacting = false;
}
}).catch((error) => {
this.compacting = false;
// eslint-disable-next-line no-console
console.warn('[AriaEngine LSM] background compaction failed:', error);
});
}
@@ -396,29 +427,64 @@ export class LSM {
// Compaction
// =======================================================================
/** 执行 Compactionpublic,供 VACUUM 等外部调用) */
/** 执行 Compactionpublic,供 VACUUM 等外部调用;VACUUM 期望 2 个文件即可压缩 */
async compactLevel(level: number): Promise<void> {
await this.compactLevelAsync(level);
await this.compactLevelAsync(level, 2);
}
/** 串行执行 Compaction(简化版,内部实现) */
private async compactLevelAsync(level: number): Promise<void> {
/**
* 串行执行 Compaction。
* @param minFiles 触发压缩的文件数门槛(自动调度用 4,VACUUM 用 2)
*
* v0.4.2-fix: 读取从存储兜底(不依赖缓存)——此前仅从缓存读,
* 缓存未命中(LRU 驱逐/单文件超缓存上限)时跳过全部文件并从 levels 移除,
* 运行中数据全部不可见。
*/
private async compactLevelAsync(level: number, minFiles: number = 4): Promise<void> {
if (level >= MAX_LSM_LEVELS - 1) return;
if (this.levels[level].length < 4) return;
if (this.levels[level].length < minFiles) return;
const sstables = this.levels[level].splice(0, this.levels[level].length);
const mergeIter = new MergeIterator();
const loadedMetas: SSTableMeta[] = [];
for (const meta of sstables) {
const reader = this.loadSSTableReader(meta);
if (!reader) continue;
// 优先缓存,未命中则从存储加载(残缺文件经校验清理,跳过)
let data: Uint8Array | null = this.sstableCache.get(meta.id) ?? null;
if (!data) {
try {
data = await this.sstableStore.load(meta.id);
} catch {
data = null;
}
}
if (!data || data.byteLength < 32) {
await this.dropInvalidSSTable(meta);
continue;
}
let reader: SSTableReader;
try {
reader = new SSTableReader(data, meta);
} catch {
await this.dropInvalidSSTable(meta);
continue;
}
const entries: [string, Record<string, unknown>][] = [];
reader.scanAll((k, v) => entries.push([k, v]));
mergeIter.addSource(new ArrayEntrySource(entries));
loadedMetas.push(meta);
}
const merged = mergeIter.drain();
if (merged.length === 0) return;
if (merged.length === 0) {
// 没有有效数据(全部损坏):把有效 meta 放回 levels
// 避免文件从读取路径消失(数据仍在磁盘,重启可恢复)
for (const meta of loadedMetas) {
this.levels[level].push(meta);
}
this.levels[level].sort((a, b) => b.id - a.id);
return;
}
const id = await this.sstableStore.allocateId();
const builder = new SSTableBuilder(this.blockSize);
@@ -505,6 +571,69 @@ export class LSM {
// 内部
// =======================================================================
/**
* v0.4.2-fix: 重新校验全部已加载 SSTable,移除损坏项(repair 自愈用)。
* @returns 移除的损坏 SSTable 数量
*/
async validateAll(): Promise<number> {
let removed = 0;
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
const valid: SSTableMeta[] = [];
for (const meta of this.levels[level]) {
if (await this.validateSSTable(meta)) {
valid.push(meta);
} else {
this.sstableCache.delete(meta.id);
removed++;
}
}
this.levels[level] = valid;
}
return removed;
}
/**
* v0.4.2-fix: 校验单个 SSTable 的完整性。
* - 文件不存在 → 清理 meta,返回 false
* - 文件过小/魔数错误/索引越界(残缺写入产物)→ 清理 meta,返回 false
* 校验通过的数据不缓存(保持内存预算),读路径按需预加载。
*/
private async validateSSTable(meta: SSTableMeta): Promise<boolean> {
try {
const data = await this.sstableStore.load(meta.id);
if (!data) {
this.dropInvalidSSTable(meta);
return false;
}
if (data.byteLength < 32) {
this.dropInvalidSSTable(meta);
return false;
}
try {
new SSTableReader(data, meta);
} catch {
this.dropInvalidSSTable(meta);
return false;
}
return true;
} catch {
this.dropInvalidSSTable(meta);
return false;
}
}
/** 清理无效 SSTable 的 meta 与文件(打开自愈路径) */
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 { /* 清理失败不阻塞打开 */ }
try {
await this.sstableStore.delete(meta.id);
} catch { /* 清理失败不阻塞打开 */ }
}
private unwrapTombstone(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (!value) return null;
if ((value as unknown as Record<string, unknown>).__tombstone) return null;
+44 -15
View File
@@ -40,11 +40,9 @@ export class SSTableReader {
if (blockIdx < 0) return null;
const entry = this.indexEntries[blockIdx];
const blockData = new Uint8Array(
this.data.buffer,
this.data.byteOffset + entry.blockOffset,
entry.blockSize,
);
const blockData = this.getBlockData(entry);
// v0.4.2-fix: 残缺文件(meta 偏移超出实际长度)跳过该块,而非抛 RangeError
if (!blockData) return null;
const blockView = new DataView(blockData.buffer, blockData.byteOffset, blockData.byteLength);
const entryCount = blockView.getUint32(0, false);
@@ -52,12 +50,15 @@ export class SSTableReader {
// 顺序扫描 block 内的条目(生产中应二分查找)
for (let i = 0; i < entryCount; i++) {
if (offset + 2 > blockData.byteLength) break;
const keyLen = blockView.getUint16(offset, false);
offset += 2;
if (offset + keyLen + 2 > blockData.byteLength) break;
const key = new TextDecoder().decode(blockData.slice(offset, offset + keyLen));
offset += keyLen;
const valLen = blockView.getUint16(offset, false);
offset += 2;
if (offset + valLen > blockData.byteLength) break;
const valBytes = blockData.slice(offset, offset + valLen);
offset += valLen;
@@ -86,23 +87,24 @@ export class SSTableReader {
for (let bi = startBlockIdx; bi <= endBlockIdx && bi >= 0; bi++) {
const entry = this.indexEntries[bi];
const blockData = new Uint8Array(
this.data.buffer,
this.data.byteOffset + entry.blockOffset,
entry.blockSize,
);
const blockData = this.getBlockData(entry);
// v0.4.2-fix: 残缺块跳过(rangeScan 继续后续块,不抛异常)
if (!blockData) continue;
const blockView = new DataView(blockData.buffer, blockData.byteOffset, blockData.byteLength);
const blockEntryCount = blockView.getUint32(0, false);
let offset = 4;
for (let i = 0; i < blockEntryCount; i++) {
if (offset + 2 > blockData.byteLength) break;
const keyLen = blockView.getUint16(offset, false);
offset += 2;
if (offset + keyLen + 2 > blockData.byteLength) break;
const key = new TextDecoder().decode(blockData.slice(offset, offset + keyLen));
offset += keyLen;
const valLen = blockView.getUint16(offset, false);
offset += 2;
if (offset + valLen > blockData.byteLength) break;
const valBytes = blockData.slice(offset, offset + valLen);
offset += valLen;
@@ -121,23 +123,24 @@ export class SSTableReader {
/** 扫描所有条目 */
scanAll(callback: (key: string, value: Record<string, unknown>) => void): void {
for (const entry of this.indexEntries) {
const blockData = new Uint8Array(
this.data.buffer,
this.data.byteOffset + entry.blockOffset,
entry.blockSize,
);
const blockData = this.getBlockData(entry);
// v0.4.2-fix: 残缺块跳过(scanAll 继续后续块,不抛异常)
if (!blockData) continue;
const blockView = new DataView(blockData.buffer, blockData.byteOffset, blockData.byteLength);
const blockEntryCount = blockView.getUint32(0, false);
let offset = 4;
for (let i = 0; i < blockEntryCount; i++) {
if (offset + 2 > blockData.byteLength) break;
const keyLen = blockView.getUint16(offset, false);
offset += 2;
if (offset + keyLen + 2 > blockData.byteLength) break;
const key = new TextDecoder().decode(blockData.slice(offset, offset + keyLen));
offset += keyLen;
const valLen = blockView.getUint16(offset, false);
offset += 2;
if (offset + valLen > blockData.byteLength) break;
const valBytes = blockData.slice(offset, offset + valLen);
offset += valLen;
@@ -185,6 +188,11 @@ export class SSTableReader {
const bloomHashCount = this.view.getUint32(footerOffset + 16, false);
this.entryCount = this.view.getUint32(footerOffset + 20, false);
// v0.4.2-fix: 完整性校验 — 索引块必须完全落在文件内,否则视为残缺文件跳过
if (indexOffset + 4 > this.data.byteLength || indexOffset + indexSize > this.data.byteLength) {
return; // 残缺文件:无索引块可读,get/rangeScan 均返回空
}
// 解析索引块
this.parseIndexBlock(indexOffset, indexSize);
@@ -204,8 +212,12 @@ export class SSTableReader {
offset += 4;
for (let i = 0; i < entryCount; i++) {
// v0.4.2-fix: 索引条目越界(keyLen/blockOffset/blockSize 超过文件长度)时中止解析,
// 已解析的有效条目仍可用于查询
if (offset + 2 > this.data.byteLength) break;
const keyLen = this.view.getUint16(offset, false);
offset += 2;
if (offset + keyLen + 8 > this.data.byteLength) break;
const key = new TextDecoder().decode(this.data.slice(offset, offset + keyLen));
offset += keyLen;
const blockOffset = this.view.getUint32(offset, false);
@@ -213,10 +225,27 @@ export class SSTableReader {
const blockSize = this.view.getUint32(offset, false);
offset += 4;
// 跳过指向文件外的块(残缺写入产物),不抛异常
if (blockSize === 0 || blockOffset + blockSize > this.data.byteLength) continue;
this.indexEntries.push({ key, blockOffset, blockSize });
}
}
/**
* v0.4.2-fix: 获取索引条目对应的数据块。
* 块偏移/大小越界(残缺 SSTable)时返回 null,由调用方跳过而非抛 RangeError。
*/
private getBlockData(entry: IndexEntry): Uint8Array | null {
if (entry.blockSize <= 0 || entry.blockOffset < 0) return null;
if (entry.blockOffset + entry.blockSize > this.data.byteLength) return null;
return new Uint8Array(
this.data.buffer,
this.data.byteOffset + entry.blockOffset,
entry.blockSize,
);
}
/** 二分查找某 key 所在的 block 索引 */
private locateBlock(key: string): number {
let lo = 0;
+55
View File
@@ -23,8 +23,17 @@ export interface IStorageBackend {
read(key: string): Promise<ArrayBuffer | null>;
/** 写入数据块 */
write(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 是否存在 */
@@ -91,6 +100,25 @@ export class IndexedDBBackend implements IStorageBackend {
});
}
/**
* 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) => {
@@ -101,6 +129,21 @@ export class IndexedDBBackend implements IStorageBackend {
});
}
/** 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) => {
@@ -161,10 +204,22 @@ export class MemoryBackend implements IStorageBackend {
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());
}
+25
View File
@@ -52,6 +52,20 @@ export class OPFSBackend implements IStorageBackend {
return this.writeQueue;
}
/** v0.4.2-fix: 批量写入 — 串行队列内逐个落盘(OPFS 无跨文件事务,顺序保证一致) */
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
if (!this.dbDir) return;
this.writeQueue = this.writeQueue.then(async () => {
for (const [key, data] of Object.entries(entries)) {
const fh = await this.dbDir!.getFileHandle(key, { create: true });
const writable = await fh.createWritable();
await writable.write(data);
await writable.close();
}
});
return this.writeQueue;
}
async delete(key: string): Promise<void> {
if (!this.dbDir) return;
this.writeQueue = this.writeQueue.then(async () => {
@@ -60,6 +74,17 @@ export class OPFSBackend implements IStorageBackend {
return this.writeQueue;
}
/** v0.4.2-fix: 批量删除 — 串行队列内逐个删除 */
async deleteMany(keys: string[]): Promise<void> {
if (!this.dbDir) return;
this.writeQueue = this.writeQueue.then(async () => {
for (const key of keys) {
try { await this.dbDir!.removeEntry(key); } catch { /* ignore */ }
}
});
return this.writeQueue;
}
async listKeys(): Promise<string[]> {
if (!this.dbDir) return [];
const keys: string[] = [];
+39 -13
View File
@@ -26,6 +26,12 @@ export class MVCCManager {
/** 全局提交序列号(用于可见性判断) */
private globalCommitLsn = 0;
/**
* v0.4.2-fix: 每个事务写入的 tableKey 集合 —
* commit/rollback 只遍历本事务写过的 key,避免全库版本链扫描(大表事务 O(N) → O(写入数))
*/
private txnWriteKeys: Map<number, Set<string>> = new Map();
// =======================================================================
// 事务管理
// =======================================================================
@@ -39,6 +45,7 @@ export class MVCCManager {
snapshotLsn: this.globalCommitLsn,
startTime: Date.now(),
});
this.txnWriteKeys.set(txnId, new Set());
return txnId;
}
@@ -50,17 +57,23 @@ export class MVCCManager {
txn.state = TransactionState.COMMITTED;
this.globalCommitLsn++;
// 标记事务写入的所有版本为已提交
for (const [, versions] of this.versionStore) {
for (const version of versions) {
if (version.txnId === txnId) {
version.committed = true;
// v0.4.2-fix: 仅标记事务写入的版本(此前遍历全库 versionStore
const writeKeys = this.txnWriteKeys.get(txnId);
if (writeKeys) {
for (const tableKey of writeKeys) {
const versions = this.versionStore.get(tableKey);
if (!versions) continue;
for (const version of versions) {
if (version.txnId === txnId) {
version.committed = true;
}
}
}
}
// 清理已提交事务的记录
this.activeTxns.delete(txnId);
this.txnWriteKeys.delete(txnId);
}
/** 回滚事务 */
@@ -70,17 +83,23 @@ export class MVCCManager {
txn.state = TransactionState.ABORTED;
// 移除事务写入的所有版本
for (const [tableKey, versions] of this.versionStore) {
const filtered = versions.filter((v) => v.txnId !== txnId);
if (filtered.length === 0) {
this.versionStore.delete(tableKey);
} else {
this.versionStore.set(tableKey, filtered);
// v0.4.2-fix: 仅移除事务写入的版本(此前遍历全库 versionStore
const writeKeys = this.txnWriteKeys.get(txnId);
if (writeKeys) {
for (const tableKey of writeKeys) {
const versions = this.versionStore.get(tableKey);
if (!versions) continue;
const filtered = versions.filter((v) => v.txnId !== txnId);
if (filtered.length === 0) {
this.versionStore.delete(tableKey);
} else {
this.versionStore.set(tableKey, filtered);
}
}
}
this.activeTxns.delete(txnId);
this.txnWriteKeys.delete(txnId);
}
/** 检查事务是否活跃 */
@@ -114,6 +133,8 @@ export class MVCCManager {
versions.push(newVersion);
this.versionStore.set(tableKey, versions);
// v0.4.2-fix: 记录本事务写过的 keycommit/rollback 精准清理)
this.txnWriteKeys.get(txnId)?.add(tableKey);
}
/**
@@ -186,9 +207,14 @@ export class MVCCManager {
/**
* v0.3.3: 丢弃指定事务的所有版本记录,但保留事务登记(Savepoint 回滚用)。
* 快照数据由调用方(引擎 txnSnapshot)负责恢复。
* v0.4.2-fix: 仅遍历本事务写过的 key(此前全库扫描)。
*/
discardVersions(txnId: number): void {
for (const [tableKey, versions] of this.versionStore) {
const writeKeys = this.txnWriteKeys.get(txnId);
if (!writeKeys) return;
for (const tableKey of writeKeys) {
const versions = this.versionStore.get(tableKey);
if (!versions) continue;
const filtered = versions.filter((v) => v.txnId !== txnId);
if (filtered.length === 0) {
this.versionStore.delete(tableKey);
+340 -25
View File
@@ -23,31 +23,160 @@ export class IndexedDBEngine implements IStorageEngine {
private txActive = false;
async open(dbName: string, version: number): Promise<void> {
this.dbName = dbName; this.version = version;
await this.memoryCache.open(dbName, version);
// v0.4.2-fix (P0-3): version < 1 归一化为 1indexedDB.open(name, 0) 抛原生 TypeError
const normalizedVersion = version >= 1 ? Math.floor(version) : 1;
this.dbName = dbName;
this.version = normalizedVersion;
await this.memoryCache.open(dbName, normalizedVersion);
// v0.4.2-fix (P0-2/P2-8): 版本自适应打开 + blocked 重试
this.db = await this.openDatabaseWithRetry(dbName, normalizedVersion);
this.setupVersionChangeHandler();
// v0.4.2-fix (P2-7): 确保 schema/meta 持久化 store 存在(新库或旧库升级时创建),
// 否则迁移版本等库内元数据无处落盘
await this.ensureSchemaStore();
try {
// v0.3.2: reopen 后从 IDB 重建 schemaschema 此前只存内存缓存,重开连接即丢失)
await this.rebuildSchemaFromIDB();
} catch (error) {
throw new DatabaseError(`Failed to restore schema for "${dbName}"`, 'IDB_SCHEMA_RESTORE_ERROR', error);
}
}
/** v0.4.2-fix: 多标签页冲突处理 — 其他标签页升级版本时自动关闭当前连接 */
private setupVersionChangeHandler(): void {
if (!this.db) return;
this.db.onversionchange = () => {
if (this.db) {
this.db.close();
this.db = null;
// eslint-disable-next-line no-console
console.warn(`[metona-sqlark] Database "${this.dbName}" was upgraded in another tab. Connection closed. Please re-open.`);
}
};
}
/**
* v0.4.2-fix: 打开 IndexedDB 连接。
* - P0-2: 请求版本低于库实际版本(VersionError)时,先无版本参数探测库当前版本,
* 再以实际版本重开(建表每张表版本号 +1,config.version 会过期)
* - P2-8: onblocked 为瞬时状态(另一连接短暂持有),等待后重试多次,超时才抛 IDB_BLOCKED
*/
private async openDatabaseWithRetry(dbName: string, requestedVersion: number): Promise<IDBDatabase> {
const BLOCKED_RETRIES = 10;
let effectiveVersion = requestedVersion;
for (let attempt = 0; attempt < BLOCKED_RETRIES; attempt++) {
try {
return await this.openRequest(dbName, effectiveVersion, 200 + attempt * 150);
} catch (error) {
const err = error as { name?: string };
if (err && err.name === 'VersionError') {
const currentVersion = await this.resolveCurrentVersion(dbName);
if (currentVersion >= 1 && currentVersion !== effectiveVersion) {
effectiveVersion = currentVersion;
this.version = currentVersion;
continue;
}
throw new DatabaseError(
`Failed to open IndexedDB "${dbName}": version mismatch`,
'IDB_VERSION_ERROR',
error,
);
}
if (err && err.name === 'BlockedError') {
// 另一连接短暂持有 → 等待后重试
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
continue;
}
throw new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', error);
}
}
throw new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED');
}
/**
* 发起一次 indexedDB.open 请求(success/error/blocked 三态收敛)。
* onblocked 不立即失败:阻塞解除后 success 仍会触发,仅超时兜底判失败,
* 避免"拒绝后连接迟到成功"泄漏未关闭的数据库连接。
*/
private openRequest(dbName: string, version: number, timeoutMs: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = async () => {
this.db = request.result;
// 多标签页冲突处理:其他标签页升级版本时自动关闭当前连接
this.db.onversionchange = () => {
if (this.db) {
this.db.close();
this.db = null;
// eslint-disable-next-line no-console
console.warn(`[metona-sqlark] Database "${dbName}" was upgraded in another tab. Connection closed. Please re-open.`);
}
};
try {
// v0.3.2: reopen 后从 IDB 重建 schemaschema 此前只存内存缓存,重开连接即丢失)
await this.rebuildSchemaFromIDB();
resolve();
} catch (error) {
reject(new DatabaseError(`Failed to restore schema for "${dbName}"`, 'IDB_SCHEMA_RESTORE_ERROR', error));
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.onerror = () => reject(new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', request.error));
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED'));
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),
);
});
}
@@ -130,6 +259,84 @@ export class IndexedDBEngine implements IStorageEngine {
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; }
// ---- 表管理 ----
@@ -149,6 +356,47 @@ export class IndexedDBEngine implements IStorageEngine {
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);
@@ -256,10 +504,10 @@ export class IndexedDBEngine implements IStorageEngine {
async commitTransaction(): Promise<void> {
if (!this.txActive) throw new DatabaseError('No active transaction', 'TX_NONE');
// 确认内存层的变更
await this.memoryCache.commitTransaction();
// 批量将内存数据刷到 IndexedDB
// v0.4.2-fix: 先刷盘后提交内存快照 — 此前先 memoryCache.commitTransaction()
// 再 flushToIDBflush 失败时 snapshot 已丢,回滚报 TX_NONE 且内存数据已确认
await this.flushToIDB();
await this.memoryCache.commitTransaction();
this.txActive = false;
}
@@ -482,10 +730,77 @@ export class IndexedDBEngine implements IStorageEngine {
});
}
/** 持久化单个表 schema 到 __metona_schema storev0.4.2-fix: ALTER TABLE 用) */
private async persistSchema(schema: TableSchema): Promise<void> {
const db = this.ensureDB();
if (!db.objectStoreNames.contains('__metona_schema')) return;
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('__metona_schema', 'readwrite');
tx.objectStore('__metona_schema').put({ name: schema.name, schema: JSON.stringify(schema) });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Failed to persist schema for "${schema.name}"`, 'IDB_SCHEMA_ERROR', tx.error));
});
}
/** 将内存缓存中的所有表数据原子性刷新到 IndexedDB */
private async flushToIDB(): Promise<void> {
const tableNames = await this.memoryCache.getTableNames();
const db = this.ensureDB();
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) {
+14
View File
@@ -95,4 +95,18 @@ export interface IStorageEngine {
/** 在线备份:导出全库一致性快照 */
backup?(): Promise<Record<string, Record<string, unknown>[]>>;
// ---- 自愈/重置 (可选,v0.4.2-fix) ----
/** 崩溃恢复自愈:校验并清理损坏数据、恢复一致性(检测到异常后调用,无需删库重建) */
repair?(): Promise<void>;
/** 清空全部数据与表结构(保留库本身,供演示页刷新/重建用) */
clearAll?(): Promise<void>;
/** 读取库内元数据(迁移版本持久化用) */
getMeta?(key: string): Promise<string | null>;
/** 写入库内元数据(迁移版本持久化用) */
setMeta?(key: string, value: string): Promise<void>;
}
+118 -6
View File
@@ -15,6 +15,8 @@ export class MemoryEngine implements IStorageEngine {
private schemas: Map<string, TableSchema> = new Map();
private indexes: Map<string, Map<string, Map<unknown, Set<string>>>> = new Map();
private opened = false;
/** v0.4.2-fix: 库内元数据(迁移版本持久化用) */
private metaStore: Map<string, string> = new Map();
// ---- 事务快照 ----
private snapshot: {
@@ -32,17 +34,45 @@ export class MemoryEngine implements IStorageEngine {
this.opened = true;
}
async close(): Promise<void> {
this.tables.clear(); this.schemas.clear(); this.indexes.clear(); this.opened = false;
this.tables.clear(); this.schemas.clear(); this.indexes.clear(); this.metaStore.clear(); this.opened = false;
}
isOpen(): boolean { return this.opened; }
// ---- v0.4.2-fix: 自愈 / 重置 / 元数据 ----
/** 内存引擎无需修复(无持久化损坏概念) */
async repair(): Promise<void> { return; }
/** 清空全部数据与表结构 */
async clearAll(): Promise<void> {
const names = Array.from(this.schemas.keys());
for (const name of names) {
await this.dropTable(name);
}
this.metaStore.clear();
}
async getMeta(key: string): Promise<string | null> {
return this.metaStore.get(key) ?? null;
}
async setMeta(key: string, value: string): Promise<void> {
this.metaStore.set(key, value);
}
// ---- 表管理 ----
async createTable(schema: TableSchema): Promise<void> {
if (this.schemas.has(schema.name)) throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
this.schemas.set(schema.name, schema);
// v0.4.2-fix: 存储 schema 深拷贝 — 此前 Hybrid.reloadMemoryFromDisk 直接存入
// disk 引擎的 schema 引用,内存/磁盘引擎共享同一对象,任一引擎 ALTER 都会污染对方
const copy: TableSchema = { name: schema.name, columns: {} };
for (const [colName, colDef] of Object.entries(schema.columns)) {
copy.columns[colName] = { ...colDef };
}
this.schemas.set(schema.name, copy);
this.tables.set(schema.name, new Map());
const tableIndexes = new Map<string, Map<unknown, Set<string>>>();
for (const [colName, colDef] of Object.entries(schema.columns)) {
for (const [colName, colDef] of Object.entries(copy.columns)) {
if (colDef.index || colDef.unique) tableIndexes.set(colName, new Map());
}
this.indexes.set(schema.name, tableIndexes);
@@ -57,6 +87,35 @@ export class MemoryEngine implements IStorageEngine {
async getTableNames(): Promise<string[]> { return Array.from(this.schemas.keys()); }
async getTableSchema(tableName: string): Promise<TableSchema | null> { return this.schemas.get(tableName) ?? null; }
/**
* v0.4.2-fix: 引擎级 ALTER TABLE — 直接修改内存 schema 引用并清理行数据。
* (此前走 executor 通用路径,行为相同;统一到引擎层保证 Hybrid/IndexedDB 委托一致性)
*/
async alterTable(
tableName: string,
action: 'ADD' | 'DROP',
column: import('../constants').ColumnDef & { name: string },
): Promise<void> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
if (action === 'ADD') {
if (schema.columns[column.name]) {
throw new DatabaseError(`Column "${column.name}" already exists in table "${tableName}"`, 'COLUMN_EXISTS');
}
schema.columns[column.name] = column;
return;
}
if (!schema.columns[column.name]) {
throw new DatabaseError(`Column "${column.name}" does not exist in table "${tableName}"`, 'COLUMN_NOT_FOUND');
}
delete schema.columns[column.name];
// 清理已有行中该列的值(find 返回行引用,直接删除生效)
const table = this.tables.get(tableName)!;
for (const row of table.values()) {
if (column.name in row) delete row[column.name];
}
}
// ---- CRUD ----
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
this.ensureTable(tableName);
@@ -124,22 +183,75 @@ export class MemoryEngine implements IStorageEngine {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const table = this.tables.get(tableName)!;
const pkCol = this.getPrimaryKey(schema);
let count = 0;
for (const [pk, row] of table) {
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
for (const [pk, row] of [...table]) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
this.checkUniqueness(schema, updated);
table.set(pk, updated);
this.updateIndexes(tableName, updated, pk);
const newPk = String(updated[pkCol]);
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
}
}
return count;
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
* 分两阶段:先全量 RESTRICT 检查(任何修改前),再执行级联(防部分修改)。
*/
private async applyUpdateCascade(tableName: string, oldPk: string, newPk: string): Promise<void> {
// 阶段 1: RESTRICT 检查(引用旧主键的行存在即拒绝)
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
const refTableData = this.tables.get(refTableName);
if (!refTableData) continue;
for (const [, refRow] of refTableData) {
if (String(refRow[colName]) === oldPk && colDef.onUpdate === 'RESTRICT') {
throw new DatabaseError(
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
}
}
}
// 阶段 2: CASCADE / SET NULL
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
const refTableData = this.tables.get(refTableName);
if (!refTableData) continue;
if (colDef.onUpdate !== 'CASCADE' && colDef.onUpdate !== 'SET NULL') continue;
for (const [refPk, refRow] of refTableData) {
if (String(refRow[colName]) !== oldPk) continue;
this.removeIndexEntries(refTableName, refRow, refPk);
refRow[colName] = colDef.onUpdate === 'CASCADE' ? newPk : null;
this.updateIndexes(refTableName, refRow, refPk);
}
}
}
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
this.ensureTable(tableName);
const table = this.tables.get(tableName)!;
+95 -6
View File
@@ -51,17 +51,65 @@ export class OPFSEngine implements IStorageEngine {
return this.tablesDir !== null;
}
// ---- v0.4.2-fix: 自愈 / 重置 / 元数据 ----
/** 自愈:重置内存缓存后从 OPFS 重新加载(单文件损坏不影响其他表) */
async repair(): Promise<void> {
await this.memoryCache.close();
await this.memoryCache.open(this.dbName, 1);
await this.loadExistingTables();
}
/** 清空全部数据与表结构(删除目录内全部文件) */
async clearAll(): Promise<void> {
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> {
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> {
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 {
@@ -95,6 +143,19 @@ export class OPFSEngine implements IStorageEngine {
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> {
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[]> {
@@ -140,11 +201,16 @@ export class OPFSEngine implements IStorageEngine {
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
return this.memoryCache.createIndex(tableName, column, unique);
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.memoryCache.dropIndex(tableName, column, indexName);
await this.memoryCache.dropIndex(tableName, column, indexName);
const schema = await this.memoryCache.getTableSchema(tableName);
if (schema) await this.setMeta(`schema_${tableName}`, JSON.stringify(schema));
}
// ---- 事务 ----
@@ -198,16 +264,39 @@ export class OPFSEngine implements IStorageEngine {
}
}
/** 从 OPFS 加载已有表数据到内存缓存 */
/**
* 从 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')) continue;
const tableName = name.replace('.json', '');
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);
// 从数据中推断 schema(简化:从第一行提取列信息)
if (data.length > 0) {
const firstRow = data[0];
const columns: Record<string, any> = {};