release: v0.4.2 — 生产就绪与崩溃自愈 + 问题清单修复 + 版本迭代
This commit is contained in:
+312
-39
@@ -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: 引擎内部错误统一包装为 DatabaseError(ARIA_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
@@ -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 刷盘为 SSTable(id 由 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
|
||||
// =======================================================================
|
||||
|
||||
/** 执行 Compaction(public,供 VACUUM 等外部调用) */
|
||||
/** 执行 Compaction(public,供 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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: 记录本事务写过的 key(commit/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
@@ -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 归一化为 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);
|
||||
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 重建 schema(schema 此前只存内存缓存,重开连接即丢失)
|
||||
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()
|
||||
// 再 flushToIDB,flush 失败时 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 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();
|
||||
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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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> = {};
|
||||
|
||||
Reference in New Issue
Block a user