release: v0.2.1 生产加固 — WAL CRC/约束激活/Hybrid提交顺序/RESTRICT外键/浏览器兼容表/debug模式/onError回调
CI / test (20.x) (push) Successful in 9m58s
CI / test (18.x) (push) Successful in 10m0s
CI / test (22.x) (push) Successful in 10m0s
CI / test (24.x) (push) Successful in 9m56s

This commit is contained in:
thzxx
2026-07-27 20:47:30 +08:00
parent 620cd11521
commit e6efa39d48
18 changed files with 357 additions and 27 deletions
+6
View File
@@ -89,6 +89,10 @@ export interface DatabaseConfig {
onReady?: (db: unknown) => void;
/** 错误回调 */
onError?: (error: Error) => void;
/** 查询结果行数上限(默认 10000,0 表示不限制) */
maxRowsPerQuery?: number;
/** 调试模式(启用后输出详细操作日志) */
debug?: boolean;
}
/** 数据库默认配置 */
@@ -97,6 +101,8 @@ export const DB_DEFAULTS: Readonly<Required<Omit<DatabaseConfig, 'plugins' | 'on
mode: 'hybrid' as const,
diskEngine: 'indexeddb' as const,
version: 1,
maxRowsPerQuery: 0, // 0 = 不限制
debug: false,
});
// ---------------------------------------------------------------------------
+20
View File
@@ -47,6 +47,12 @@ export class MetonaSqlark {
private tableCache: Map<string, Table> = new Map();
/** 查询结果行数上限 */
get maxRowsPerQuery(): number { return this.config.maxRowsPerQuery ?? 0; }
/** 调试模式 */
get debug(): boolean { return this.config.debug ?? false; }
constructor(config: DatabaseConfig) {
this.config = config;
this.name = config.name ?? DB_DEFAULTS.name;
@@ -273,4 +279,18 @@ export class MetonaSqlark {
throw new DatabaseError('Database not initialized. Call await db.init() first.', 'DB_NOT_READY');
}
}
/** 错误回调分发 */
private _onError(error: Error): void {
if (this.config.onError) {
try { this.config.onError(error); } catch { /* 避免回调自身异常影响主流程 */ }
}
}
/** 调试日志 */
private _debug(msg: string, ...args: unknown[]): void {
if (this.debug) {
console.debug(`[MetonaSqlark:${this.name}] ${msg}`, ...args);
}
}
}
+2
View File
@@ -74,8 +74,10 @@ export class SSTableReader {
endKey: string,
callback: (key: string, value: Record<string, unknown>) => void,
): void {
if (this.indexEntries.length === 0) return;
const startBlockIdx = Math.max(0, this.locateBlockGE(startKey));
const endBlockIdx = Math.min(this.indexEntries.length - 1, this.locateBlockLE(endKey));
if (startBlockIdx < 0 || endBlockIdx < 0 || startBlockIdx > endBlockIdx) return;
for (let bi = startBlockIdx; bi <= endBlockIdx && bi >= 0; bi++) {
const entry = this.indexEntries[bi];
+17 -2
View File
@@ -216,6 +216,7 @@ export class WAL {
while (offset + 15 <= data.byteLength) {
try {
const recordStart = offset;
const lsn = view.getUint32(offset, false);
offset += 4;
const type = view.getUint8(offset) as WALRecordType;
@@ -225,16 +226,19 @@ export class WAL {
const tableLen = view.getUint16(offset, false);
offset += 2;
if (offset + tableLen > data.byteLength) break;
const tableName = new TextDecoder().decode(data.slice(offset, offset + tableLen));
offset += tableLen;
const keyLen = view.getUint16(offset, false);
offset += 2;
if (offset + keyLen > data.byteLength) break;
const key = new TextDecoder().decode(data.slice(offset, offset + keyLen));
offset += keyLen;
const jsonLen = view.getUint32(offset, false);
offset += 4;
if (offset + jsonLen > data.byteLength) break;
let recordData: Record<string, unknown> | undefined;
if (jsonLen > 0) {
const json = new TextDecoder().decode(data.slice(offset, offset + jsonLen));
@@ -244,8 +248,19 @@ export class WAL {
}
offset += jsonLen;
// 跳过 CRC
// 验证 CRC(跨记录数据计算,不含 CRC 自身)
const storedCrc = view.getUint32(offset, false);
offset += 4;
const recordBytes = data.slice(recordStart, offset - 4);
let computedCrc = 0;
for (let i = 0; i < recordBytes.length; i++) {
computedCrc = ((computedCrc << 5) - computedCrc + recordBytes[i]) | 0;
}
if ((computedCrc >>> 0) !== storedCrc) {
// CRC 不匹配,跳过此损坏记录
console.warn(`[AriaEngine WAL] CRC mismatch at record LSN=${lsn}, skipping`);
continue;
}
records.push({
lsn,
@@ -254,7 +269,7 @@ export class WAL {
tableName,
key,
data: recordData,
checksum: 0,
checksum: storedCrc,
});
} catch {
break;
+9 -1
View File
@@ -295,7 +295,7 @@ export class MemoryEngine implements IStorageEngine {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete || colDef.onDelete === 'RESTRICT') continue;
if (!colDef.references || !colDef.onDelete) continue;
const [refTable, refCol] = colDef.references.split('.');
if (refTable !== tableName) continue;
@@ -312,6 +312,14 @@ export class MemoryEngine implements IStorageEngine {
}
}
// RESTRICT: 存在引用行时禁止删除
if (colDef.onDelete === 'RESTRICT' && toDelete.length > 0) {
throw new DatabaseError(
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
if (colDef.onDelete === 'CASCADE') {
// 递归级联
for (const refPk of toDelete) {
+9 -1
View File
@@ -10,6 +10,7 @@
import type { IStorageEngine } from '../engine/interface';
import type { QueryPlan, TableSchema, DiskEngine } from '../constants';
import { DatabaseError } from '../constants';
import { MemoryEngine } from '../engine/memory';
import { IndexedDBEngine } from '../engine/indexeddb';
import { OPFSEngine } from '../engine/opfs';
@@ -140,8 +141,15 @@ export class HybridEngine implements IStorageEngine {
}
async commitTransaction(): Promise<void> {
await this.memoryEngine.commitTransaction();
// 先写磁盘,保证持久化优先;磁盘失败则回滚内存
await this.diskEngine.commitTransaction();
try {
await this.memoryEngine.commitTransaction();
} catch {
// 内存提交失败时回滚磁盘
await this.diskEngine.rollbackTransaction();
throw new DatabaseError('Hybrid commit failed: memory engine error after disk commit', 'TX_COMMIT_ERROR');
}
}
async rollbackTransaction(): Promise<void> {
+20 -2
View File
@@ -87,13 +87,13 @@ export function validateRow(schema: TableSchema, row: Record<string, unknown>):
return validated;
}
/** 检查字段类型 */
/** 检查字段类型(含约束校验) */
export function checkFieldType(
tableName: string,
colName: string,
type: FieldType,
value: unknown,
_colDef?: ColumnDef,
colDef?: ColumnDef,
): void {
const jsType = typeof value;
@@ -105,6 +105,12 @@ export function checkFieldType(
'TYPE_ERROR',
);
}
if (colDef?.maxLength !== undefined && (value as string).length > colDef.maxLength) {
throw new DatabaseError(
`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`,
'VALIDATION_ERROR',
);
}
break;
case 'number':
@@ -114,6 +120,18 @@ export function checkFieldType(
'TYPE_ERROR',
);
}
if (colDef?.min !== undefined && (value as number) < colDef.min) {
throw new DatabaseError(
`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`,
'VALIDATION_ERROR',
);
}
if (colDef?.max !== undefined && (value as number) > colDef.max) {
throw new DatabaseError(
`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`,
'VALIDATION_ERROR',
);
}
break;
case 'boolean':