release: v0.2.1 生产加固 — WAL CRC/约束激活/Hybrid提交顺序/RESTRICT外键/浏览器兼容表/debug模式/onError回调
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user