release: v0.4.1 — Aria 级联/ALTER/clearAll + 流式查询/派生表 + 正确性加固
CI / test (20.x) (push) Successful in 10m4s
CI / test (22.x) (push) Successful in 10m8s
CI / test (24.x) (push) Successful in 9m55s
CI / test (18.x) (push) Successful in 10m9s

新增:
- AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)+ clearAll() 重置 API
- 引擎级 alterTable:Aria DROP COLUMN 重写存储行 + schema 持久化
- 流式查询 queryStream / findStream(LSM 惰性扫描不物化)
- FROM 派生表 / 多列 ON 哈希连接 / COUNT(DISTINCT) / NULLS FIRST/LAST
- 普通列别名 + ORDER BY 别名 + 无表查询 + 字符串常量列
- 演示页引擎切换器(Memory/Aria)+ 预设自动重置

修复:
- Aria WAL DROP_TABLE 崩溃恢复(删表复活)+ 恢复后 WAL 截断
- Memory update/delete 索引维护(unique 约束绕过)
- 关联 EXISTS 绑定失效 / HAVING 标量子查询 / INSERT SELECT 位置错位
- 裸布尔列条件(WHERE done / CASE WHEN done)
- Aria $in 重复行 / JOIN 主表 WHERE 下推 / DROP INDEX 报错
- ORDER BY/GROUP BY/SELECT 表前缀列 + SQL '' 标准转义

质量:894 测试 · 47 套件 · 81.5% 覆盖率
This commit is contained in:
thzxx
2026-08-08 13:36:34 +08:00
parent 82ad8e93b9
commit a1e4f5071c
38 changed files with 13493 additions and 8756 deletions
+384 -28
View File
@@ -2,7 +2,7 @@
* AriaEngine — 自研页面式存储引擎主类
* @module engine/aria/index
*
* v0.2.5: WAL 同步修复 + MVCC 接入 + 版本统一 + 生产加固
* v0.4.1: 外键级联 + ALTER TABLE 重写 + clearAll 重置 + 崩溃恢复加固
*/
import type { IStorageEngine } from '../interface';
@@ -161,10 +161,22 @@ export class AriaEngine implements IStorageEngine {
// 第二遍:仅应用 txnId==0(非事务)或已提交事务的数据
for (const r of allRecords) {
if (r.txnId === 0 || committedTxns.has(r.txnId)) {
this.applyWALRecord(r);
if (r.type === WALRecordType.DROP_TABLE) {
// v0.3.3: DROP_TABLE 回放(异步:需预加载 SSTable 后清除残留数据)
await this.applyDropTableRecovery(r.tableName);
} else {
this.applyWALRecord(r);
}
}
}
// v0.3.3: 恢复完成后将回放数据落盘并截断 WAL,
// 避免每次重启重复回放 + WAL 无限膨胀
if (allRecords.length > 0) {
await this.lsm.flush();
await this.wal.checkpoint();
}
// 8. Checkpoint Manager(接入 WAL 大小阈值)
this.checkpointManager = new CheckpointManager(
this.lsm,
@@ -187,6 +199,29 @@ export class AriaEngine implements IStorageEngine {
this.opened = false;
}
/**
* v0.4.1: 重置数据库 — 清空全部数据与表结构(演示页刷新/重新初始化用)。
* 清空存储后端、LSM、WAL、MVCC 与二级索引,后续可继续使用本实例。
*/
async clearAll(): Promise<void> {
this.ensureOpen();
// 清空存储后端(页面文件 / WAL 记录 / schema 记录 / 元数据)
await this.backend.clear();
this.schemas.clear();
this.tablePKs.clear();
this.secondaryIndexes.clear();
this.lsm.clear();
this.mvcc = new MVCCManager();
this.currentTxnId = null;
this.txnSnapshot = null;
this.savepoints.clear();
this.opCounter = 0;
// 持久化空 schema(防止旧 schema 记录残留)
await this.persistSchemas();
// 重置 WAL 状态(backend.clear 已清记录,同步内存计数)
await this.wal.checkpoint();
}
isOpen(): boolean { return this.opened; }
// =======================================================================
@@ -203,8 +238,9 @@ export class AriaEngine implements IStorageEngine {
this.tablePKs.set(schema.name, this.getPK(schema));
// 为索引列创建二级索引 LSM(每个索引使用独立命名空间的 SSTableStore,避免 id/meta 冲突)
// v0.3.3: 主键列不建冗余二级索引(主 LSM 本身就是 PK 索引,范围查询走前缀扫描)
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (colDef.index || colDef.unique || colDef.primaryKey) {
if (colDef.index || colDef.unique) {
const idxKey = `${schema.name}:idx:${colName}`;
if (!this.secondaryIndexes.has(idxKey)) {
const idxLsm = new LSM({
@@ -344,24 +380,8 @@ export class AriaEngine implements IStorageEngine {
rows = await this.getAllRows(tableName);
}
// Merge transaction snapshot writes (uncommitted data visible within txn)
if (this.currentTxnId && this.txnSnapshot) {
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
for (const [key, value] of this.txnSnapshot) {
if (!key.startsWith(prefix)) continue;
const pk = key.slice(prefix.length);
const del = (value as unknown as Record<string, unknown>).__txn_deleted;
const idx = rows.findIndex((r) => r[pkCol] === pk);
if (del) {
if (idx >= 0) rows.splice(idx, 1);
} else {
const row = { ...value, [pkCol]: pk };
if (idx >= 0) rows[idx] = row;
else rows.push(row);
}
}
}
// v0.3.3: 事务内合并未提交快照(统一在 mergeTxnSnapshot 处理)
rows = this.mergeTxnSnapshot(tableName, rows);
// WHERE filter
if (query.where && Object.keys(query.where).length > 0) {
@@ -447,12 +467,16 @@ export class AriaEngine implements IStorageEngine {
let count = 0;
// v0.3.1: 批量 WAL 写入(组提交)
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
// v0.4.1: 外键级联(环路保护)
const visited = new Set<string>();
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
const key = `${tableName}:${row[pkCol]}`;
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.4.1: 外键规则(RESTRICT 抛错 / CASCADE 递归删 / SET NULL 置空)
count += await this.applyForeignKeyRules(tableName, String(row[pkCol]), walRecords, visited);
if (this.currentTxnId && this.txnSnapshot) {
// Buffer delete in snapshot + MVCC tombstone
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
@@ -482,6 +506,154 @@ export class AriaEngine implements IStorageEngine {
return count;
}
/**
* v0.4.1: 外键级联规则 — 对齐 MemoryEngine.cascadeDelete 行为。
* 删除 tableName 主键为 pkValue 的行前,检查引用它的所有表:
* - RESTRICT: 存在引用行 → 抛 FOREIGN_KEY_VIOLATION
* - CASCADE: 递归删除引用行(含索引/WAL)
* - SET NULL: 引用行外键列置 null(含索引/WAL)
* @returns 级联影响的行数(CASCADE 删除行数 + SET NULL 更新行数)
*/
private async applyForeignKeyRules(
tableName: string,
pkValue: string,
walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[],
visited: Set<string>,
): Promise<number> {
let total = 0;
const visitKey = `${tableName}:${pkValue}`;
if (visited.has(visitKey)) return 0;
visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
const refRows = await this.getAllRows(refTableName);
const matched = refRows.filter((r) => String(r[colName]) === pkValue);
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
throw new DatabaseError(
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
if (colDef.onDelete === 'CASCADE') {
const refPkCol = this.tablePKs.get(refTableName)!;
for (const refRow of matched) {
const refPk = String(refRow[refPkCol]);
// 递归级联(先处理更深层引用)
total += await this.applyForeignKeyRules(refTableName, refPk, walRecords, visited);
// 删除引用行
const refKey = `${refTableName}:${refPk}`;
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(refKey, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(refTableName, refPk, this.currentTxnId);
} else {
this.lsm.delete(refKey);
}
this.updateSecondaryIndexes(refTableName, refPk, null, refRow);
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName: refTableName,
key: refPk,
});
total++;
}
} else if (colDef.onDelete === 'SET NULL') {
const refPkCol = this.tablePKs.get(refTableName)!;
for (const refRow of matched) {
const refPk = String(refRow[refPkCol]);
const updated = { ...refRow, [colName]: null };
const refKey = `${refTableName}:${refPk}`;
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(refKey, updated);
this.mvcc.writeVersion(refTableName, refPk, updated, this.currentTxnId);
} else {
this.lsm.put(refKey, updated);
}
this.updateSecondaryIndexes(refTableName, refPk, updated, refRow);
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName: refTableName,
key: refPk,
data: updated,
});
// 对齐 Memory 语义:SET NULL 不影响返回的删除行数
}
}
}
}
return total;
}
/**
* v0.4.0: 流式查询 — 逐行回调,不物化结果数组。
* 全表路径走 LSM rangeScanLazy 惰性扫描;索引等值/范围路径复用 tryIndexLookup。
* 事务中回退物化(快照合并需要全量行集)。
*/
async findStream(
tableName: string,
query: QueryPlan,
onRow: (row: Record<string, unknown>) => void,
): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
const hasWhere = !!(query.where && Object.keys(query.where).length > 0);
const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*'
? (row: Record<string, unknown>) => projectColumns(row, query.columns!)
: null;
const limit = query.limit ?? Infinity;
const offset = query.offset ?? 0;
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
let count = 0;
let skipped = 0;
const emit = (row: Record<string, unknown>): boolean => {
if (hasWhere && !matchWhere(row, query.where!)) return true;
if (skipped < offset) { skipped++; return true; }
onRow(project ? project(row) : row);
count++;
return count < limit;
};
if (this.currentTxnId && this.txnSnapshot) {
// 事务中:物化后逐行回调(快照合并需要全量行集)
const rows = await this.find(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined });
for (const row of rows) {
onRow(project ? project(row) : row);
}
return rows.length;
}
// 索引路径:等值/范围查找(结果行已过滤,直接回调)
const fastPath = await this.tryIndexLookup(tableName, query);
if (fastPath !== null) {
for (const row of fastPath) {
if (!emit(row)) break;
}
return count;
}
// 全表惰性扫描(含 WHERE 过滤,不物化)
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
this.lsm.rangeScanLazy(prefix, `${prefix}\uffff`, (key, value) => {
if (count >= limit) return;
const row = { ...value };
row[pkCol] = key.slice(prefix.length);
emit(row);
});
return count;
}
async count(tableName: string, query?: QueryPlan): Promise<number> {
this.ensureOpen();
this.ensureTable(tableName);
@@ -495,13 +667,93 @@ export class AriaEngine implements IStorageEngine {
this.ensureOpen();
this.ensureTable(tableName);
const rows = await this.getAllRows(tableName);
// v0.3.3: 事务内清空走快照(删除标记),提交时生效;并写入 WAL
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
this.lsm.delete(`${tableName}:${row[pkCol]}`);
const key = `${tableName}:${row[pkCol]}`;
if (this.currentTxnId && this.txnSnapshot) {
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
} else {
this.lsm.delete(key);
}
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
// 移除二级索引
this.updateSecondaryIndexes(tableName, String(row[pkCol]), null, row);
}
await this.wal.appendBatch(walRecords);
this.opCounter += rows.length;
await this.checkpointManager.tick();
this.tryGC();
}
// ---- 动态索引v0.3.0 ----
// ---- ALTER TABLEv0.4.1 ----
/**
* v0.4.1: ALTER TABLE — 结构变更真正生效于存储:
* - ADD: 持久化 schemapersistSchemas),行无需修改
* - DROP: 持久化 schema + 遍历主 LSM 重写所有行(移除该列键)+ WAL UPDATE 记录
* (通用路径 getTableSchema 返回副本,Executor 的引用修改对 Aria 无效)
*/
async alterTable(
tableName: string,
action: 'ADD' | 'DROP',
column: import('../../constants').ColumnDef & { name: string },
): Promise<void> {
this.ensureOpen();
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;
await this.persistSchemas();
return;
}
// DROP
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];
await this.persistSchemas();
// 重写主 LSM:移除所有行的该列键(find 副本无法就地删除,必须重写存储)
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
const endKey = `${prefix}\uffff`;
await this.lsm.prefetchRange(prefix, endKey);
const entries = this.lsm.rangeScan(prefix, endKey);
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
for (const [key, value] of entries) {
if (!(column.name in value)) continue;
const updated = { ...value };
delete updated[column.name];
this.lsm.put(key, updated);
// 二级索引列被删时同步清理索引
const pk = key.slice(prefix.length);
this.updateSecondaryIndexes(tableName, pk, updated, value);
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
key: pk,
data: updated,
});
}
await this.wal.appendBatch(walRecords);
this.opCounter += walRecords.length;
await this.checkpointManager.tick();
this.trimAllCaches();
}
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
this.ensureOpen();
@@ -551,6 +803,10 @@ export class AriaEngine implements IStorageEngine {
if (colDef.primaryKey) {
throw new DatabaseError(`Cannot drop primary key index on column "${column}"`, 'NOT_SUPPORTED');
}
// v0.4.1: DROP 不存在的索引应报错(此前静默成功)
if (!colDef.index && !colDef.unique && !this.secondaryIndexes.has(`${tableName}:idx:${column}`)) {
throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
}
colDef.index = false;
colDef.unique = false;
@@ -610,6 +866,15 @@ export class AriaEngine implements IStorageEngine {
async rollbackTransaction(): Promise<void> {
if (!this.currentTxnId) throw new DatabaseError('No active transaction', 'TX_NONE');
// v0.3.3: 记录事务涉及的表(用于回滚后重建索引,消除索引残留)
const affectedTables = new Set<string>();
if (this.txnSnapshot) {
for (const key of this.txnSnapshot.keys()) {
const idx = key.indexOf(':');
if (idx > 0) affectedTables.add(key.slice(0, idx));
}
}
this.mvcc.rollbackTransaction(this.currentTxnId);
this.txnSnapshot = null;
@@ -621,6 +886,13 @@ export class AriaEngine implements IStorageEngine {
});
this.currentTxnId = null;
// v0.3.3: 事务内直接写入了二级索引 LSM,回滚后全量重建受影响表的索引
for (const tableName of affectedTables) {
if (this.schemas.has(tableName)) {
await this.reindexTable(tableName);
}
}
}
// ---- Savepoint 嵌套事务 ----
@@ -642,6 +914,9 @@ export class AriaEngine implements IStorageEngine {
if (!sp) throw new DatabaseError(`Savepoint "${name}" not found`, 'SAVEPOINT_NOT_FOUND');
// 恢复到 savepoint 时的快照
this.txnSnapshot = sp.snapshot ? new Map(sp.snapshot) : null;
// v0.3.3: 清理该事务在 MVCC 版本链中的全部记录(快照已含正确数据,
// 版本链仅作 undo 记录,清空后 commit 时 LSM 写入与快照保持一致)
this.mvcc.discardVersions(this.currentTxnId!);
// 清除此 savepoint 之后的所有 savepoint
let found = false;
for (const [k] of this.savepoints) {
@@ -676,11 +951,37 @@ export class AriaEngine implements IStorageEngine {
// 预加载范围内涉及的 SSTable,避免 rangeScan 时缓存未命中静默丢数据
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
const entries = this.lsm.rangeScan(prefix, `${prefix}\uffff`);
return entries.map(([key, value]) => {
const rows = entries.map(([key, value]) => {
const row = { ...value };
row[pkCol] = key.slice(prefix.length);
return row;
});
// v0.3.3: 事务内合并未提交快照(update/delete/count/clear 也能看到本事务的写入)
return this.mergeTxnSnapshot(tableName, rows);
}
/**
* v0.3.3: 将事务未提交快照的变更合并到行列表(新增/更新/删除标记)。
* 幂等操作:行已是最新时不重复修改。
*/
private mergeTxnSnapshot(tableName: string, rows: Record<string, unknown>[]): Record<string, unknown>[] {
if (!this.currentTxnId || !this.txnSnapshot) return rows;
const pkCol = this.tablePKs.get(tableName)!;
const prefix = `${tableName}:`;
for (const [key, value] of this.txnSnapshot) {
if (!key.startsWith(prefix)) continue;
const pk = key.slice(prefix.length);
const del = (value as unknown as Record<string, unknown>).__txn_deleted;
const idx = rows.findIndex((r) => r[pkCol] === pk);
if (del) {
if (idx >= 0) rows.splice(idx, 1);
} else {
const row = { ...value, [pkCol]: pk };
if (idx >= 0) rows[idx] = row;
else rows.push(row);
}
}
return rows;
}
private getPK(schema: TableSchema): string {
@@ -872,11 +1173,31 @@ export class AriaEngine implements IStorageEngine {
case WALRecordType.COMMIT:
case WALRecordType.ROLLBACK:
case WALRecordType.BEGIN:
case WALRecordType.DROP_TABLE:
break;
}
}
/**
* v0.3.3: DROP_TABLE 恢复 — 删除 schema 并清除主 LSM 中该表的所有残留数据。
*
* 此前 DROP_TABLE 在恢复时被忽略,而 CREATE_TABLE 回放会重建 schema
* 导致崩溃后"已删除的表和数据复活"(实证 P0 bug)。
*/
private async applyDropTableRecovery(tableName: string): Promise<void> {
if (!tableName) return;
this.schemas.delete(tableName);
this.tablePKs.delete(tableName);
// 清除主 LSM 中该表前缀的所有数据(含 SSTable 中的旧数据)
const prefix = `${tableName}:`;
const endKey = `${prefix}\uffff`;
await this.lsm.prefetchRange(prefix, endKey);
const entries = this.lsm.rangeScan(prefix, endKey);
for (const [key] of entries) {
this.lsm.delete(key);
}
}
// =======================================================================
// 二级索引
// =======================================================================
@@ -891,7 +1212,8 @@ export class AriaEngine implements IStorageEngine {
if (!schema) return;
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.index && !colDef.unique && !colDef.primaryKey) continue;
// v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引)
if (!colDef.index && !colDef.unique) continue;
const idxKey = `${tableName}:idx:${colName}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (!idxLsm) continue;
@@ -947,6 +1269,32 @@ export class AriaEngine implements IStorageEngine {
const value = this.lsm.get(key);
return value ? [{ ...value, [pkCol]: cond.$eq }] : [];
}
// v0.3.3: PK $in → 主 LSM 多次精确查找(替代冗余 PK 二级索引)
if ('$in' in cond && Array.isArray(cond.$in)) {
const keys = cond.$in.map((v) => `${tableName}:${v}`);
await this.lsm.prefetchKeys(keys);
const rows: Record<string, unknown>[] = [];
const seen = new Set<string>(); // v0.4.1: IN 子查询可能含重复值,按 pk 去重
for (const v of cond.$in) {
const pk = String(v);
if (seen.has(pk)) continue;
const value = this.lsm.get(`${tableName}:${pk}`);
if (value) { seen.add(pk); rows.push({ ...value, [pkCol]: pk }); }
}
return rows;
}
// v0.3.3: PK 范围查询 → 主 LSM 前缀扫描 + 条件过滤(修复字符串算术 bug)
if ('$gt' in cond || '$gte' in cond || '$lt' in cond || '$lte' in cond) {
const prefix = `${tableName}:`;
await this.lsm.prefetchRange(prefix, `${prefix}\uffff`);
const entries = this.lsm.rangeScan(prefix, `${prefix}\uffff`);
const rows: Record<string, unknown>[] = [];
for (const [key, value] of entries) {
const candidate = { ...value, [pkCol]: key.slice(prefix.length) };
if (matchWhere(candidate, { [pkCol]: condition })) rows.push(candidate);
}
return rows;
}
}
// 二级索引查找
@@ -966,9 +1314,16 @@ export class AriaEngine implements IStorageEngine {
// $in → 多次精确查找
if ('$in' in c && Array.isArray(c.$in)) {
const results: Record<string, unknown>[] = [];
const seenPks = new Set<string>(); // v0.4.1: IN 值可能重复,按 pk 去重
for (const val of c.$in) {
const rows = await this.indexScanToRows(tableName, pkCol, idxLsm, String(val), String(val));
results.push(...rows);
for (const row of rows) {
const pk = String(row[pkCol]);
if (!seenPks.has(pk)) {
seenPks.add(pk);
results.push(row);
}
}
}
return results;
}
@@ -1091,7 +1446,8 @@ export class AriaEngine implements IStorageEngine {
let rebuiltCount = 0;
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.index && !colDef.unique && !colDef.primaryKey) continue;
// v0.3.3: 主键列不建冗余二级索引(主 LSM 即 PK 索引)
if (!colDef.index && !colDef.unique) continue;
const idxKey = `${tableName}:idx:${colName}`;
const idxLsm = this.secondaryIndexes.get(idxKey);
if (!idxLsm) continue;
+15
View File
@@ -183,6 +183,21 @@ export class MVCCManager {
return result;
}
/**
* v0.3.3: 丢弃指定事务的所有版本记录,但保留事务登记(Savepoint 回滚用)。
* 快照数据由调用方(引擎 txnSnapshot)负责恢复。
*/
discardVersions(txnId: number): void {
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);
}
}
}
/**
* 清理过旧版本(GC)。
* 保留每个 key 的最新 N 个已提交版本。
+7 -2
View File
@@ -48,9 +48,14 @@ export class CheckpointManager {
}
}
/** 估算 WAL 大小 */
/** 估算 WAL 大小(优先真实字节数,回退到缓冲计数估算) */
private getWALEstimatedSize(): number {
const count = typeof this.wal.getBufferedCount === 'function' ? this.wal.getBufferedCount() : 0;
const wal = this.wal as unknown as { getBufferedBytes?: () => number; getBufferedCount?: () => number };
if (typeof wal.getBufferedBytes === 'function') {
const bytes = wal.getBufferedBytes();
if (bytes > 0) return bytes;
}
const count = typeof wal.getBufferedCount === 'function' ? wal.getBufferedCount() : 0;
return count * 200;
}
+12
View File
@@ -47,6 +47,8 @@ export class WAL {
private enabled: boolean;
private buffer: Uint8Array[] = [];
private syncMode: 'full' | 'batch' | 'none';
/** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */
private bufferedBytes = 0;
constructor(store: WALStore, enabled: boolean = true, syncMode: 'full' | 'batch' | 'none' = 'batch') {
this.store = store;
@@ -74,12 +76,14 @@ export class WAL {
if (this.syncMode === 'full') {
try {
await this.store.append(bytes);
this.bufferedBytes += bytes.byteLength;
} catch {
// eslint-disable-next-line no-console
console.warn('[AriaEngine WAL] Failed to append record');
}
} else if (this.syncMode === 'batch') {
this.buffer.push(bytes);
this.bufferedBytes += bytes.byteLength;
}
// 'none' mode: 不写 WAL
}
@@ -98,12 +102,14 @@ export class WAL {
if (this.syncMode === 'full') {
try {
await this.store.append(combined);
this.bufferedBytes += combined.byteLength;
} catch {
// eslint-disable-next-line no-console
console.warn('[AriaEngine WAL] Failed to append batch record');
}
} else if (this.syncMode === 'batch') {
this.buffer.push(combined);
this.bufferedBytes += combined.byteLength;
}
// 'none' mode: 不写 WAL
}
@@ -165,6 +171,7 @@ export class WAL {
await this.flush();
await this.store.truncate();
this.lsn = 0;
this.bufferedBytes = 0;
}
// =======================================================================
@@ -183,6 +190,11 @@ export class WAL {
return this.buffer.length;
}
/** v0.3.3: 未 checkpoint 的 WAL 累计字节数(full/batch/none 通用) */
getBufferedBytes(): number {
return this.bufferedBytes;
}
// -----------------------------------------------------------------------
// 编解码
// -----------------------------------------------------------------------
+14
View File
@@ -163,6 +163,20 @@ export class IndexedDBEngine implements IStorageEngine {
return this.idbFind(tableName, query);
}
/** v0.4.0: 流式查询 — IDB 批量读入后逐行回调(保持接口一致性) */
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
if (this.txActive) {
return this.memoryCache.findStream(tableName, query, onRow);
}
const rows = await this.idbFind(tableName, { ...query, orderBy: undefined, limit: undefined, offset: undefined });
let count = 0;
for (const row of rows) {
onRow(row);
count++;
}
return count;
}
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
const count = await this.memoryCache.update(tableName, query, updates);
if (this.txActive) return count;
+6
View File
@@ -43,6 +43,9 @@ export interface IStorageEngine {
/** 查询行 */
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
/** v0.4.0: 流式查询 — 逐行回调扫描(有 where/limit/projection,无 orderBy 语义;有 orderBy 时实现可回退物化) */
findStream?(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
/** 更新行,返回影响行数 */
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
@@ -55,6 +58,9 @@ export interface IStorageEngine {
/** 清空表数据(保留结构) */
clear(tableName: string): Promise<void>;
/** v0.4.1: ALTER TABLE(可选)— 引擎级结构变更(Aria 需重写存储行,其余引擎走 Executor 通用路径) */
alterTable?(tableName: string, action: 'ADD' | 'DROP', column: import('../constants').ColumnDef & { name: string }): Promise<void>;
// ---- 动态索引(可选,v0.3.0 ----
/** 创建二级索引(CREATE INDEX */
+74 -9
View File
@@ -97,6 +97,29 @@ export class MemoryEngine implements IStorageEngine {
return results;
}
/** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
this.ensureTable(tableName);
const table = this.tables.get(tableName)!;
const hasWhere = !!(query.where && Object.keys(query.where).length > 0);
const limit = query.limit ?? Infinity;
const offset = query.offset ?? 0;
const project = query.columns && query.columns.length > 0 && query.columns[0] !== '*'
? (row: Record<string, unknown>) => projectColumns(row, query.columns!)
: null;
let count = 0;
let skipped = 0;
for (const row of table.values()) {
if (hasWhere && !matchWhere(row, query.where!)) continue;
if (skipped < offset) { skipped++; continue; }
onRow(project ? project(row) : row);
count++;
if (count >= limit) break;
}
return count;
}
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
@@ -104,9 +127,13 @@ export class MemoryEngine implements IStorageEngine {
let count = 0;
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);
count++;
}
}
@@ -119,6 +146,8 @@ export class MemoryEngine implements IStorageEngine {
const toDelete: string[] = [];
for (const [pk, row] of table) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.3.3: 删除行前清理其索引条目(修复删除后索引残留)
this.removeIndexEntries(tableName, row, pk);
toDelete.push(pk);
}
}
@@ -177,6 +206,10 @@ export class MemoryEngine implements IStorageEngine {
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');
// v0.4.1: DROP 不存在的索引应报错(此前静默成功)
if (!colDef.index && !colDef.unique) {
throw new DatabaseError(`Index on column "${column}" does not exist in table "${tableName}"`, 'INDEX_NOT_FOUND');
}
colDef.index = false;
colDef.unique = false;
const tableIndexes = this.indexes.get(tableName);
@@ -288,17 +321,24 @@ export class MemoryEngine implements IStorageEngine {
const tableIndexes = this.indexes.get(tableName);
if (!tableIndexes || !query.where) return Array.from(table.values());
for (const [col, condition] of Object.entries(query.where)) {
// v0.4.1: 支持 { $eq: value } 形式(SQL 解析器生成的等值条件)走索引
let targetValue: unknown;
if (typeof condition !== 'object' || condition === null) {
const colIndex = tableIndexes.get(col);
if (colIndex) {
const pks = colIndex.get(condition);
if (pks) {
const result: Record<string, unknown>[] = [];
for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); }
return result;
}
return [];
targetValue = condition;
} else if ('$eq' in (condition as Record<string, unknown>) && Object.keys(condition as Record<string, unknown>).length === 1) {
targetValue = (condition as Record<string, unknown>).$eq;
} else {
continue;
}
const colIndex = tableIndexes.get(col);
if (colIndex) {
const pks = colIndex.get(targetValue);
if (pks) {
const result: Record<string, unknown>[] = [];
for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); }
return result;
}
return [];
}
}
return Array.from(table.values());
@@ -317,6 +357,22 @@ export class MemoryEngine implements IStorageEngine {
}
}
/** v0.3.3: 从所有索引中移除一行的条目(update/delete 前调用,修复索引过期/残留) */
private removeIndexEntries(tableName: string, row: Record<string, unknown>, pk: string): void {
const tableIndexes = this.indexes.get(tableName);
if (!tableIndexes) return;
for (const [colName, colIndex] of tableIndexes) {
const value = row[colName];
if (value !== undefined && value !== null) {
const pks = colIndex.get(value);
if (pks) {
pks.delete(pk);
if (pks.size === 0) colIndex.delete(value);
}
}
}
}
// ---- 外键级联 ----
/**
@@ -359,6 +415,8 @@ export class MemoryEngine implements IStorageEngine {
for (const refPk of toDelete) {
const refRow = refTableData.get(refPk);
if (refRow) {
// v0.3.3: 级联删除前清理索引条目
this.removeIndexEntries(refTableName, refRow, refPk);
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow);
}
refTableData.delete(refPk);
@@ -368,6 +426,13 @@ export class MemoryEngine implements IStorageEngine {
for (const refPk of toDelete) {
const refRow = refTableData.get(refPk);
if (refRow) {
// v0.3.3: 外键列置空后同步更新索引
if (refRow[colName] !== undefined && refRow[colName] !== null) {
const pks = this.indexes.get(refTableName)?.get(colName);
if (pks) {
pks.get(refRow[colName])?.delete(refPk);
}
}
refRow[colName] = null;
}
}
+5
View File
@@ -109,6 +109,11 @@ export class OPFSEngine implements IStorageEngine {
return this.memoryCache.find(tableName, query);
}
/** v0.4.0: 流式查询(委托内存缓存) */
async findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number> {
return this.memoryCache.findStream(tableName, query, onRow);
}
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
const count = await this.memoryCache.update(tableName, query, updates);
const allRows = await this.memoryCache.find(tableName, { table: tableName });