fix(v0.8.0): 全量回归审查 —— 1 处 P0 数据丢失 + 4 处 P1 + 9 处 P2 根因修复

方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 vs 实现 / 公共 API 契约 /
测试质量),每条结论要求可复现证据;逐条复核 + 探针确认 + 变异验证(40 项全部
被对应用例拦住)。

P0:事务活跃期间 repair()/close()/周期 checkpoint 推进 WAL 水位 → 已 COMMIT 的
事务整批消失且恢复报告"干净"。根因 hasPendingFlushData()/computeDurableLsn()
不看 txnSnapshot;守卫此前只在 CheckpointManager 两个回调里。修复:守卫下沉到
computeDurableLsn() 与 advanceWalCheckpoint() 入口(唯一实现)。

P1:
- WAL 前缀缺失丢弃整段活分片(回退上一代 manifest 时 kept 为空)→ 前缀缺失单独
  记录,后缀照常重放;仅 fromLsn === 0 时才算真异常
- 孤儿回收门槛只看引擎层 dataLossSuspected,漏掉 LSM 层被丢的 SSTable →
  统一 describeRecoveryDamage() 聚合判定(损坏时绝不删"引用不到"的文件)
- vacuum() 逐层压缩绕过维护链 → vacuumLevels() 每层作为维护链任务执行
- reclaimRetiredNow() 无视在途读者(读者把"已退休"读成"文件损坏")→ 有读者时
  退化为延迟回收

P2:WAL 记录级 CRC 损坏不计数不上报;旧格式表结构记录形状损坏静默当空库;
bloomFilterBitsPerKey 配置被接受却完全不生效(构建器写死默认值,实现缺陷);
幽灵 meta;介质读故障等于文件损坏的语义无用例;manifest 回读校验两条守卫无用例;
文件名≠载荷世代判定无用例;pageIdWatermark 单调性无用例;分片号两条真实不变量
无用例。

覆盖率口径(第二处漏洞):interface.ts 混着三个运行时函数(cloneRow 等)却被
描述为"纯类型、不纳入统计" → 实现搬到 src/engine/row_clone.ts;搬完门禁真的
失败(functions 93.84% < 94%),补测退化路径后通过。

测试质量:3 条空壳用例改值级断言;1 条"全损坏"用例实际只走缓存 → 拆成两条真
用例;5 秒墙钟 race 改门控 + 失败上限;setTimeout 改 whenIdle();<= 收紧为 <。

变异脚本加固:正控(干净基线必须全绿)、编译失败/0 用例单独归类、300s 超时、
逐字节 sha256 恢复校验、O_EXCL 进程锁、锚点唯一性;变异 22 → 40 项。

文档两轮订正(16 + 11 条不成立宣称):MVCC 快照隔离、backup 一致性快照、
"空洞检测截断"、体积(251,109 B / gzip 63,145 B)、测试与覆盖率数字、
"5 种存储引擎"、Tree-shakable、错误码表补 16 个码、恢复报告字段、已知限制
(回退单向 / 多实例依赖 Web Locks / manifest 体积 / 尾部 WAL 分片不可识别)。

验证:常规套件 92 套件 / 1980 用例全绿;覆盖率 90.59 / 82.59 / 94.14 / 93.50
(阈值 90/82/94/93);e2e 14/14(真实 Chromium + OPFS + CDP 崩溃);
重型套件 4 套件 / 27 用例;变异 40/40;lint + 两份 tsc 干净;dist 已重建。
This commit is contained in:
thzxx
2026-09-15 16:33:40 +08:00
parent c3757f486c
commit 0b44620721
34 changed files with 4026 additions and 641 deletions
+197 -51
View File
@@ -1,4 +1,4 @@
import { cloneRow } from '../interface';
import { cloneRow } from '../row_clone';
/**
* AriaEngine — 自研页面式存储引擎主类
* @module engine/aria/index
@@ -14,7 +14,7 @@ import { stripUndefinedUpdates } from '../../table/schema';
import { compileValidator } from '../../table/validation';
import type { AriaEngineConfig, SSTableMeta } from './types';
import { DEFAULT_ARIA_CONFIG, MAX_LSM_LEVELS } from './types';
import { DEFAULT_ARIA_CONFIG } from './types';
import { LSM } from './index/lsm';
import type { SSTableStore } from './index/lsm';
@@ -48,6 +48,8 @@ export interface AriaRecoveryReport {
dataLossSuspected: boolean;
/** WAL 活跃区间内的分片空洞(已提交事务记录缺失) */
walGaps: number[];
/** v0.8.0CRC 校验失败被跳过的 WAL 记录数(它们承载的写入已丢失) */
droppedWALRecords: number;
/** 本次打开是否从旧格式(__aria_lsm_meta/__aria_schemas)迁移而来 */
legacyImported: boolean;
/** 是否从更早的 manifest 世代回退(最新世代损坏) */
@@ -109,6 +111,7 @@ export class AriaEngine implements IStorageEngine {
droppedSSTables: [],
dataLossSuspected: false,
walGaps: [],
droppedWALRecords: 0,
legacyImported: false,
manifestFallback: false,
};
@@ -301,6 +304,17 @@ export class AriaEngine implements IStorageEngine {
allowGaps: true,
});
const walInfo = this.wal.getLastRecoveryInfo();
if (walInfo && walInfo.corruptRecords > 0) {
// v0.8.0review 修复):记录级 CRC 损坏此前只 console.warn —— 恢复完全
// 不感知"少了几条记录",与"静默丢数据必须显式化"的目标不符。
this.recoveryReport.droppedWALRecords = walInfo.corruptRecords;
this.recoveryReport.dataLossSuspected = true;
// eslint-disable-next-line no-console
console.warn(
`[AriaEngine] WAL: ${walInfo.corruptRecords} record(s) failed CRC and were skipped — ` +
'the writes they carried are missing (recovery report records this)',
);
}
if (walInfo && walInfo.gaps.length > 0) {
this.recoveryReport.walGaps = [...walInfo.gaps];
this.recoveryReport.dataLossSuspected = true;
@@ -555,35 +569,46 @@ export class AriaEngine implements IStorageEngine {
* 且 manifest 完整可信时才允许回收空间。
*/
private async cleanupOrphanPages(): Promise<void> {
if (this.recoveryReport.dataLossSuspected || this.recoveryReport.manifestFallback) {
const damage = this.describeRecoveryDamage();
if (damage.length > 0) {
// eslint-disable-next-line no-console
console.warn(
'[AriaEngine] repair: skipping orphan-page reclamation — recovery report shows ' +
'damage or manifest fallback (unreferenced pages are kept, never deleted blindly)',
`[AriaEngine] repair: skipping orphan reclamation — recovery shows damage (${damage.join('; ')}); ` +
'unreferenced data is kept, never deleted blindly',
);
return;
}
const keys = await this.backend.listKeys();
const pgKeys = keys.filter((k) => /^pg_\d+$/.test(k));
if (pgKeys.length === 0) return;
// v0.8.0review 修复):**有在途读者时一律不回收**。
// 读者的快照可能持有已被 compaction 取代("退休")的文件;那些文件的页面
// 既不在 manifest、也不在 levels 里 —— 按"没人引用"删掉它们会让正在进行中的
// 扫描静默少数据。回收只能在没有读者时做。
if (this.allLsms().some((lsm) => lsm.hasActiveReaders())) {
// eslint-disable-next-line no-console
console.warn('[AriaEngine] repair: skipping orphan reclamation — readers are active');
return;
}
const keys = await this.backend.listKeys();
// ---- 被引用的页面 idmanifest + 各层内存视图 + 退休表 ----
const used = new Set<number>();
for (const state of Object.values(this.manifest.namespaces)) {
for (const m of state.sstables) {
if (m.pageIds) {
for (const pid of m.pageIds) used.add(pid);
}
if (m.pageIds) for (const pid of m.pageIds) used.add(pid);
}
}
// 未落盘的页面(正在写入的 SSTable)也不能删
for (const lsm of this.allLsms()) {
// 未落盘的页面(正在写入的 SSTable)不能删
for (const level of this.getLsmLevels(lsm)) {
for (const meta of level) {
if (meta.pageIds) for (const pid of meta.pageIds) used.add(pid);
}
}
// 退休但尚未物理删除的 SSTable 的页面同样不能删
for (const pid of lsm.getRetiredPageIds()) used.add(pid);
}
const pgKeys = keys.filter((k) => /^pg_\d+$/.test(k));
const orphanIds = pgKeys
.map((k) => Number(k.slice('pg_'.length)))
.filter((pid) => !used.has(pid));
@@ -592,6 +617,45 @@ export class AriaEngine implements IStorageEngine {
// eslint-disable-next-line no-console
console.warn(`[AriaEngine] repair: reclaimed ${orphanIds.length} orphan page file(s)`);
}
// ---- 孤儿 SSTable 文件(整 value 路径:sst_<ns>_<id> / sst_<id>----
// 退休 SSTable 的物理删除是"尽力而为":删除失败/崩溃会留下既不被 manifest
// 引用、也不在任何层里的文件。页面化路径由上面的 pg_ 回收覆盖;
// 整 value 路径(pageStorage:false / 旧库 / 迁移数据)此前**没有任何回收路径**。
const usedSstIds = new Set<number>();
for (const state of Object.values(this.manifest.namespaces)) {
for (const m of state.sstables) usedSstIds.add(m.id);
}
for (const lsm of this.allLsms()) {
for (const level of this.getLsmLevels(lsm)) {
for (const meta of level) usedSstIds.add(meta.id);
}
for (const id of lsm.getRetiredSstableIds()) usedSstIds.add(id);
}
const orphanSstKeys: string[] = [];
for (const [ns, prefix] of this.sstableKeyPrefixes()) {
const re = new RegExp(`^${prefix}(\\d+)$`);
for (const key of keys) {
const m = re.exec(key);
if (!m) continue;
if (!usedSstIds.has(Number(m[1]))) orphanSstKeys.push(key);
}
void ns;
}
if (orphanSstKeys.length > 0) {
await this.backend.deleteMany(orphanSstKeys);
// eslint-disable-next-line no-console
console.warn(`[AriaEngine] repair: reclaimed ${orphanSstKeys.length} orphan SSTable file(s)`);
}
}
/** v0.8.0:命名空间 → SSTable 文件 key 前缀(与 createSSTableStore 保持一致) */
private sstableKeyPrefixes(): [string, string][] {
const out: [string, string][] = [['main', 'sst_']];
for (const ns of Object.keys(this.manifest.namespaces)) {
if (ns !== 'main') out.push([ns, `sst_${ns}_`]);
}
return out;
}
/** v0.8.0: 读取某个 LSM 当前引用的层结构(诊断/孤儿回收用) */
@@ -2064,6 +2128,61 @@ export class AriaEngine implements IStorageEngine {
await this.commitManifest();
}
/**
* v0.8.0review 修复):表结构记录的**唯一**解析实现。
*
* 为什么必须集中且严格:结构记录有两种坏法 —— JSON 本身就坏了,或 JSON 合法但
* 形状不对(数组 / null / 表名映射到非对象 / 列定义不是对象)。修复前只有
* "JSON 坏"这一种会抛错,形状不对则被**静默忽略** → 打开后看不到任何表,
* 表现为"库是空的"(与审计里"静默空库"同一类缺陷)。
*
* 判定原则:只要记录存在却不可用,就抛 ARIA_LEGACY_META_CORRUPT —— 宁可让
* 调用方看到明确的损坏错误,也不假装这是一个没有表的空库。
*/
private parseSchemaRecord(raw: ArrayBuffer, source: string): Record<string, Record<string, ColumnDef>> {
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(raw));
} catch (error) {
throw new DatabaseError(
`${source} is corrupt and cannot be loaded: ${(error as Error).message}`,
'ARIA_LEGACY_META_CORRUPT',
error,
);
}
return this.validateSchemaShape(parsed, source);
}
/** 形状校验(与 parseSchemaRecord 分离:便于直接喂各种形状做单测) */
private validateSchemaShape(parsed: unknown, source: string): Record<string, Record<string, ColumnDef>> {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new DatabaseError(
`${source} is not a table-name → columns object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`,
'ARIA_LEGACY_META_CORRUPT',
);
}
const out: Record<string, Record<string, ColumnDef>> = {};
for (const [tableName, columns] of Object.entries(parsed as Record<string, unknown>)) {
if (!columns || typeof columns !== 'object' || Array.isArray(columns)) {
throw new DatabaseError(
`${source} entry "${tableName}" is not a column map (got ${Array.isArray(columns) ? 'array' : typeof columns})`,
'ARIA_LEGACY_META_CORRUPT',
);
}
for (const [colName, def] of Object.entries(columns as Record<string, unknown>)) {
if (!def || typeof def !== 'object' || Array.isArray(def)) {
throw new DatabaseError(
`${source} entry "${tableName}.${colName}" is not a column definition ` +
`(got ${Array.isArray(def) ? 'array' : typeof def})`,
'ARIA_LEGACY_META_CORRUPT',
);
}
}
out[tableName] = columns as Record<string, ColumnDef>;
}
return out;
}
private async loadSchemas(): Promise<void> {
// 权威来源:manifest(旧格式已在 importLegacyState 阶段导入)
let data = this.manifest.schemas ?? {};
@@ -2073,20 +2192,9 @@ export class AriaEngine implements IStorageEngine {
if (Object.keys(data).length === 0) {
const raw = await this.backend.read('__aria_schemas');
if (raw) {
try {
const parsed = JSON.parse(new TextDecoder().decode(raw)) as Record<string, Record<string, ColumnDef>>;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
data = parsed;
this.manifest.schemas = parsed;
}
} catch (error) {
// 修复前这里 `catch {}` 静默忽略 → 坏 schema = 看不到任何表(静默空库)
throw new DatabaseError(
`Schema record is corrupt and cannot be loaded: ${(error as Error).message}`,
'ARIA_LEGACY_META_CORRUPT',
error,
);
}
// 修复前这里 `catch {}` 静默忽略 → 坏 schema = 看不到任何表(静默空库)
data = this.parseSchemaRecord(raw, 'Schema record "__aria_schemas"');
this.manifest.schemas = data;
}
}
@@ -2102,6 +2210,38 @@ export class AriaEngine implements IStorageEngine {
// v0.8.0(B-6):单一提交点与命名空间工厂
// =======================================================================
/**
* v0.8.0review 修复): 本次打开/修复过程中出现过的**一切损坏迹象**。
*
* 为什么需要它而不是只看 `this.recoveryReport.dataLossSuspected`
* SSTable 被丢弃这一事实记录在**各 LSM** 的报告里,引擎层的 `dataLossSuspected`
* 只在"WAL 水位已推进、被丢的数据没有 WAL 兜底"时才置位。于是"manifest 已被
* 推进 + 某个 SSTable 因文件损坏被丢"这类**真损坏**会在引擎层看不到 —— 孤儿页
* 回收就会照常执行,把在途/退休文件按"没人引用"删掉。
*
* 判定原则:只要有任何"曾经自愈/丢失/回退"的迹象,就一律不回收任何未被引用
* 的文件(宁可留空间,也不可逆地删数据)。
*/
private describeRecoveryDamage(): string[] {
const reasons: string[] = [];
if (this.recoveryReport.manifestFallback) reasons.push('manifest fallback to previous generation');
if (this.recoveryReport.dataLossSuspected) reasons.push('engine-level data loss suspected');
if (this.recoveryReport.walGaps.length > 0) {
reasons.push(`WAL segment gap(s) ${this.recoveryReport.walGaps.join(',')}`);
}
if (this.recoveryReport.droppedWALRecords > 0) {
reasons.push(`${this.recoveryReport.droppedWALRecords} corrupt WAL record(s)`);
}
for (const lsm of this.allLsms()) {
const r = lsm.getRecoveryReport();
if (r.droppedSSTables.length > 0) {
reasons.push(`${r.namespace}: ${r.droppedSSTables.length} dropped SSTable(s)`);
}
if (r.dataLossSuspected) reasons.push(`${r.namespace}: LSM data loss suspected`);
}
return reasons;
}
/**
* v0.8.0: 恢复诊断(打开时被丢弃的 SSTable、WAL 空洞、是否怀疑数据丢失)。
*
@@ -2123,6 +2263,7 @@ export class AriaEngine implements IStorageEngine {
droppedSSTables: dropped,
dataLossSuspected: dataLoss,
walGaps: [...this.recoveryReport.walGaps],
droppedWALRecords: this.recoveryReport.droppedWALRecords,
legacyImported: this.recoveryReport.legacyImported,
manifestFallback: this.recoveryReport.manifestFallback,
};
@@ -2236,6 +2377,17 @@ export class AriaEngine implements IStorageEngine {
* - 全部落盘 → 推进到当前 LSN(这些记录已存在于已提交的 SSTable 中)。
*/
private computeDurableLsn(intents: ManifestFrozenIntent[]): number {
// v0.8.0review 修复 P0):**事务进行中一律不得推进水位**。
//
// 事务内的写入只落在 `txnSnapshot`(内存)+ WAL 里,**不进 LSM memtable**
// —— 因此 `hasPendingFlushData()`(只看 memtable/frozen)会说"没有未落盘数据",
// 水位就被推到当前 LSN 并按该水位删掉旧分片。而此时事务记录既不在 SSTable
// 也不在 memtable:随后 `COMMIT`(返回成功)→ 崩溃 → 重开时那些 INSERT 记录
// 因 `lsn <= startLsn` 被跳过、只剩 COMMIT 记录 → **已确认提交的事务整批消失**,
// 且恢复报告是"干净"的(实测复现)。
//
// 水位是"这些 LSN 已存在于已提交 SSTable 中"的断言,而活跃事务的数据不满足它。
if (this.currentTxnId !== null) return this.durableLsn;
if (intents.length > 0) {
return Math.min(this.durableLsn, Math.min(...intents.map((i) => i.lsnAtFreeze)));
}
@@ -2251,6 +2403,17 @@ export class AriaEngine implements IStorageEngine {
* 否则又会出现"同一语义多处实现、只改一处"的老问题。
*/
private async advanceWalCheckpoint(): Promise<void> {
// v0.8.0review 修复 P0):事务活跃时整条水位推进 + 分片回收都不做。
// 与 `CheckpointManager` 的两个回调同一守卫;这里放在入口处,
// 使 repair()/close()/周期 checkpoint 三条路径全部覆盖(它们此前只有后两条有守卫)。
if (this.currentTxnId !== null) {
// eslint-disable-next-line no-console
console.warn(
'[AriaEngine] WAL checkpoint deferred: an active transaction may hold data ' +
'that exists only in memory + WAL (advancing the durable watermark would drop it)',
);
return;
}
await this.wal.flush();
// 1. 先算:以"如果没有未落盘数据,水位会到哪里"为基准
const target = this.hasPendingFlushData()
@@ -2331,19 +2494,9 @@ export class AriaEngine implements IStorageEngine {
// 2. 表结构(__aria_schemas
const schemaRaw = await this.backend.read('__aria_schemas');
if (schemaRaw) {
try {
const parsed = JSON.parse(new TextDecoder().decode(schemaRaw)) as Record<string, Record<string, ColumnDef>>;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
this.manifest.schemas = parsed;
imported = true;
}
} catch (error) {
throw new DatabaseError(
`Legacy schema record "__aria_schemas" is corrupt and cannot be migrated: ${(error as Error).message}`,
'ARIA_LEGACY_META_CORRUPT',
error,
);
}
// 与 loadSchemas 走**同一个**校验实现(形状不对同样抛错,绝不静默空库)
this.manifest.schemas = this.parseSchemaRecord(schemaRaw, 'Legacy schema record "__aria_schemas"');
imported = true;
}
// 3. 页面水位(__aria_meta,仅作为单调下限)
@@ -2379,8 +2532,6 @@ export class AriaEngine implements IStorageEngine {
if (intents.length === 0) return;
if (replayedRecordCount > 0) return; // WAL 覆盖到了这些数据(重放会重建)
if (!this.config.walEnabled) return; // 未启用 WAL:本来就没有日志兜底(配置语义)
// 全部 LSM 已落盘(意图来自上一次会话的残留)→ 数据其实已经安全
if (!this.hasPendingFlushData() && this.manifest.frozen.length === 0) return;
const summary = intents.map((i) => `${i.ns}#${i.id}(${i.entryCount} 项)`).join(', ');
throw new DatabaseError(
@@ -2975,16 +3126,11 @@ export class AriaEngine implements IStorageEngine {
// → 墓碑与历史版本在最底层永久累积),且无论是否真的合并过都返回
// `compactedLevels: 6`"报告的数字与事实无关",审计 item 52)。
// 现在逐层尝试(含底部层的原地合并 —— 它会回收墓碑),只统计真正合并了的层。
let compactedLevels = 0;
const isBottom = (level: number): boolean => level === MAX_LSM_LEVELS - 1;
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
const files = this.lsm.getStats().levelCounts[level] ?? 0;
// 底部层即使只有 1 个文件也要合并:那正是"墓碑/历史版本回收"的唯一时机
//(删除密集场景下底部层通常就是一个大文件)
const minFiles = isBottom(level) ? 1 : 2;
if (files < minFiles) continue;
if (await this.lsm.compactLevel(level, minFiles)) compactedLevels++;
}
// 逐层压缩交给 LSM:它会把这些任务挂到**维护链**上串行执行 ——
// 直接 `await compactLevel()` 会与后台 compaction 并发写同一层的产物,
// 而产物一律 unshift 到队首(层内顺序 = 新旧顺序)→ 旧数据可能排到新数据
// 之前(读到旧值),底部层还会因丢墓碑让已删除的行复活。
const compactedLevels = await this.lsm.vacuumLevels();
// GC MVCC 版本(保留最新 10 个)
const beforeGC = this.mvcc.getGlobalLSN();
this.mvcc.gc(10);
+107 -2
View File
@@ -35,6 +35,7 @@ import { DatabaseError } from '../../../constants';
import type { SSTableMeta } from '../types';
import type { ManifestFrozenIntent } from '../store/manifest';
import {
DEFAULT_BLOOM_BITS_PER_KEY,
DEFAULT_MEMTABLE_SIZE,
MAX_LSM_LEVELS,
DEFAULT_LEVEL_SIZE_MULTIPLIER,
@@ -120,6 +121,9 @@ const BACKPRESSURE_FILES = 8;
*/
const MAX_READ_STRUCTURE_RETRIES = 16;
/** 保留的后台故障诊断条数上限(v0.8.0 review 修复:防止无界增长) */
const MAX_BACKGROUND_WARNINGS = 64;
// ---------------------------------------------------------------------------
// 冻结表(pending flush 的一等状态,v0.8.0
// ---------------------------------------------------------------------------
@@ -247,6 +251,8 @@ export class LSM {
private readonly namespace: string;
private readonly walLsnProvider: () => number;
private readonly requireDurableCoverage: boolean;
/** Bloom Filter 每 key 位数(配置透传到 SSTableBuilderv0.8.0 审查修复) */
private readonly bloomBitsPerKey: number;
constructor(config: LSMConfig) {
this.memtableSizeThreshold = config.memtableSizeThreshold ?? DEFAULT_MEMTABLE_SIZE;
@@ -258,6 +264,9 @@ export class LSM {
this.namespace = config.namespace ?? 'main';
this.walLsnProvider = config.walLsnProvider ?? (() => 0);
this.requireDurableCoverage = config.requireDurableCoverage ?? false;
this.bloomBitsPerKey = Number.isFinite(config.bloomBitsPerKey) && (config.bloomBitsPerKey as number) > 0
? Math.floor(config.bloomBitsPerKey as number)
: DEFAULT_BLOOM_BITS_PER_KEY;
this.recoveryReport = {
namespace: this.namespace,
droppedSSTables: [],
@@ -476,7 +485,7 @@ export class LSM {
}
const id = await this.sstableStore.allocateId();
const builder = new SSTableBuilder(this.blockSize);
const builder = new SSTableBuilder(this.blockSize, this.bloomBitsPerKey);
for (const [key, value] of entries) {
builder.add(key, value);
}
@@ -580,6 +589,42 @@ export class LSM {
});
}
/**
* v0.8.0review 修复):显式维护(VACUUM)的逐层压缩 —— **与后台 compaction 串行**。
*
* 为什么必须串行:compaction 产物一律 `unshift` 到目标层队首,层内数组顺序即
* "新旧顺序"。若 VACUUM 直接 `await compactLevel()`(绕过维护链),它可能与
* 后台 compaction 同时向**同一层**写产物 —— 旧数据的产物被插到新数据之前,
* 读路径取层内第一个命中 → 读到旧值;底部层还会丢墓碑 → 已删除的行复活。
*
* 实现:每个层的压缩都作为维护链上的一个任务执行并等待其完成(复用
* `compacting` 集合防重入),因此永远不会与后台 compaction 交错。
*
* @returns 真实发生合并的层数
*/
async vacuumLevels(): Promise<number> {
let compacted = 0;
for (let level = 0; level < MAX_LSM_LEVELS; level++) {
// 底部层即使只有 1 个文件也要合并:那正是"墓碑/历史版本回收"的唯一时机
const minFiles = level === MAX_LSM_LEVELS - 1 ? 1 : 2;
// 该层已有 compaction 在跑 → 先等维护链静默(不打断、不并入)
if (this.compacting.has(level)) await this.drainMaintenance();
if (this.levels[level].length < minFiles) continue;
this.compacting.add(level);
await new Promise<void>((resolve) => {
this.enqueueMaintenance(async () => {
try {
if (await this.compactLevelAsync(level, minFiles)) compacted++;
} finally {
this.compacting.delete(level);
resolve();
}
});
});
}
return compacted;
}
/** 背压场景下排队 compaction(写入路径调用) */
private enqueueCompact(level: number): void {
if (level > MAX_LSM_LEVELS - 1 || this.compacting.has(level)) return;
@@ -695,6 +740,47 @@ export class LSM {
})();
}
/**
* v0.8.0(review 修复):当前是否有在途读者。
*
* 用途:维护路径(repair 的强制回收 / 孤儿清理)必须在没有读者时才能删文件 ——
* 读者的快照可能正持有已被 compaction 取代的"退休"文件。
*/
hasActiveReaders(): boolean {
return this.activeReaders.size > 0;
}
/** v0.8.0:退休但尚未物理删除的 SSTable 所占用的页面 id(孤儿页回收必须把它们算作"在用" */
getRetiredPageIds(): number[] {
const out: number[] = [];
for (const group of this.retired) {
for (const meta of group.metas) {
if (meta.pageIds) out.push(...meta.pageIds);
}
}
return out;
}
/** v0.8.0:退休但尚未物理删除的 SSTable id(孤儿整 value 文件回收必须排除它们) */
getRetiredSstableIds(): number[] {
const out: number[] = [];
for (const group of this.retired) {
for (const meta of group.metas) out.push(meta.id);
}
return out;
}
/**
* v0.8.0:等待两条链静默(诊断与测试用)。
*
* 生产语义上等价于"当前没有在跑的后台 flush/compaction";测试用它替代
* `setTimeout(N)` 这类墙钟等待(sleep 不够会误报成产品缺陷)。
*/
async whenIdle(): Promise<void> {
await this.drainChain();
await this.drainMaintenance();
}
/** v0.8.0(测试/诊断):退休但尚未物理删除的 SSTable 数量 */
getRetiredCount(): number {
return this.retired.reduce((sum, g) => sum + g.metas.length, 0);
@@ -708,6 +794,13 @@ export class LSM {
*/
reclaimRetiredNow(): void {
if (this.retired.length === 0) return;
// v0.8.0review 修复):有在途读者时**不得**强制删除 —— 读者的快照可能正持有
// 这些文件,删掉会让它读不到数据(而且会被误判为"文件损坏")。此时退化为
// 延迟回收(等读者退出后由 reclaimRetired 处理)。
if (this.activeReaders.size > 0) {
this.reclaimRetired();
return;
}
const all = this.retired.flatMap((g) => g.metas);
this.retired = [];
void (async () => {
@@ -934,7 +1027,7 @@ export class LSM {
}
const id = await this.sstableStore.allocateId();
const builder = new SSTableBuilder(this.blockSize);
const builder = new SSTableBuilder(this.blockSize, this.bloomBitsPerKey);
for (const [key, value] of merged) {
builder.add(key, value);
}
@@ -1117,6 +1210,11 @@ export class LSM {
// `getBackgroundWarnings()` 与告警日志提供,错误语义只保留
// "数据没落盘才算失败"。
this.backgroundWarnings.push(effective);
// v0.8.0review 修复):诊断数组必须有界 —— 长时间运行 + 反复瞬时故障下
// 无界增长等于内存泄漏(只保留最近若干条,足够定位问题)
if (this.backgroundWarnings.length > MAX_BACKGROUND_WARNINGS) {
this.backgroundWarnings.splice(0, this.backgroundWarnings.length - MAX_BACKGROUND_WARNINGS);
}
// eslint-disable-next-line no-console
console.warn('[AriaEngine LSM] background failure recovered by retry:', effective);
}
@@ -1283,6 +1381,13 @@ export class LSM {
/** 清理无效 SSTable 的 meta 与文件(打开自愈路径)+ 记录恢复诊断 */
private async dropInvalidSSTable(meta: SSTableMeta, reason: string): Promise<void> {
this.recoveryReport.droppedSSTables.push({ id: meta.id, level: meta.level, reason });
// v0.8.0review 修复):meta 被丢弃后必须同时从内存层数组移除。
// 否则 `levels` 会保留一个"manifest 里已不存在、文件也已删除"的幽灵条目 ——
// stats 虚高、每次读都要扫它一遍,且掩盖"内存与 manifest 应当一致"这一不变量。
if (this.levels[meta.level]?.some((m) => m.id === meta.id)) {
this.levels[meta.level] = this.levels[meta.level].filter((m) => m.id !== meta.id);
this.structureVersion++;
}
if (this.requireDurableCoverage) {
// manifest 已推进 WAL 水位(startLsn > 0)→ 被丢弃的 SSTable 没有 WAL 兜底
this.recoveryReport.dataLossSuspected = true;
+17 -2
View File
@@ -34,6 +34,7 @@
*/
import { BloomFilter } from './bloom';
import { DEFAULT_BLOOM_BITS_PER_KEY } from '../types';
import { crc32 } from '../crc32';
import type { IndexEntry } from '../types';
@@ -54,11 +55,25 @@ interface EncodedEntry {
}
export class SSTableBuilder {
/** Bloom Filter 每 key 位数(由配置透传) */
private bloomBitsPerKey: number;
private entries: [string, Record<string, unknown>][] = [];
private blockSizeLimit: number;
constructor(blockSizeLimit: number = 4096) {
/**
* @param blockSizeLimit 块大小上限(字节)
* @param bloomBitsPerKey Bloom Filter 每 key 位数(v0.8.0 审查修复:此前
* 无论 `bloomFilterBitsPerKey` 配成多少,这里都写死用默认值 ——
* 配置项被接受却完全不起作用)
*/
constructor(
blockSizeLimit: number = 4096,
bloomBitsPerKey: number = DEFAULT_BLOOM_BITS_PER_KEY,
) {
this.blockSizeLimit = blockSizeLimit;
this.bloomBitsPerKey = Number.isFinite(bloomBitsPerKey) && bloomBitsPerKey > 0
? Math.floor(bloomBitsPerKey)
: DEFAULT_BLOOM_BITS_PER_KEY;
}
/** 添加一个 key-value 条目(必须按键排序添加) */
@@ -81,7 +96,7 @@ export class SSTableBuilder {
}));
const blocks = this.splitIntoBlocks(encoded);
const bloomFilter = new BloomFilter(this.entries.length);
const bloomFilter = new BloomFilter(this.entries.length, this.bloomBitsPerKey);
// 预计算总大小(字节)
let totalSize = 0;
+5 -2
View File
@@ -2,8 +2,11 @@
* AriaEngine MVCC — 多版本并发控制
* @module engine/aria/transaction/mvcc
*
* 实现快照隔离 (Snapshot Isolation)。
* 每个事务看到数据库在事务开始时的快照。
* v0.8.0review 修正文档):本模块提供**行版本链**,用途是"事务内的 undo"
* 提交即清理。它**不提供快照隔离** —— 引擎的隔离语义是"事务串行"(同一实例
* 同时只允许一个事务,并发 `beginTransaction` 抛 `TX_ACTIVE`),读取走
* `txnSnapshot` 未提交快照。旧注释声称 "实现快照隔离 (Snapshot Isolation)"
* 与实现不符(全量审查发现)。
*/
import type { RowVersion, TxnEntry } from '../types';
+29 -7
View File
@@ -42,7 +42,7 @@ export interface WALStore {
* v0.8.0(可选,分片存储实现):从指定分片起读取,并把空洞如实返回。
* 未实现的 store 由 WAL 回退为 `readAll()`(无空洞信息)。
*/
readAllFrom?(fromSegment: number): Promise<WALReadResult>;
readAllFrom?(fromSegment: number, fromLsn?: number): Promise<WALReadResult>;
/**
* v0.8.0(可选,分片存储实现):删除整体已落盘的前缀分片,
* 返回仍需保留的最小分片号。未实现时 WAL 只在"全部已落盘"时整体截断。
@@ -90,7 +90,18 @@ export interface WALRecoveryInfo {
applied: number;
skipped: number;
maxLsn: number;
/** 活跃区间**内部**的分片空洞(这些分片之后的记录不可信,已丢弃) */
gaps: number[];
/**
* v0.8.0(review 修复):起始分片之前缺失的分片号("前缀缺失")。
*
* 与 `gaps` 区分:前缀缺失可能是**正常代价**(manifest 回退到上一代时,
* 它的 `startSegment` 比介质上现存的最小分片更小 —— 那段记录本就被水位跳过),
* 因此不能一律当错误;只有内部空洞才意味着记录真的不可信。
*/
missingPrefix: number[];
/** v0.8.0:CRC 校验失败被跳过的记录数(它们承载的写入已丢失) */
corruptRecords: number;
fromSegment: number;
fromLsn: number;
}
@@ -204,11 +215,13 @@ export class WAL {
const fromLsn = opts.fromLsn ?? 0;
let data: Uint8Array;
let gaps: number[] = [];
let missingPrefix: number[] = [];
if (typeof this.store.readAllFrom === 'function') {
const result = await this.store.readAllFrom(fromSegment);
const result = await this.store.readAllFrom(fromSegment, fromLsn);
data = result.data;
gaps = result.gaps;
missingPrefix = result.missingPrefix ?? [];
} else {
const exists = await this.store.exists();
if (!exists) return 0;
@@ -223,7 +236,8 @@ export class WAL {
);
}
const records = data.byteLength === 0 ? [] : this.decodeAllRecords(data);
const decoded = data.byteLength === 0 ? { records: [], corrupt: 0 } : this.decodeAllRecords(data);
const records = decoded.records;
let applied = 0;
let skipped = 0;
let maxLsn = 0;
@@ -242,7 +256,11 @@ export class WAL {
// 磁盘上的全部记录已经读出来了,缓冲区里不再有待落盘记录
this.buffer = [];
this.bufferedBytes = 0;
this.lastRecoveryInfo = { applied, skipped, maxLsn, gaps, fromSegment, fromLsn };
this.lastRecoveryInfo = {
applied, skipped, maxLsn, gaps, missingPrefix,
corruptRecords: decoded.corrupt,
fromSegment, fromLsn,
};
return applied;
}
@@ -395,8 +413,9 @@ export class WAL {
return new Uint8Array(buf);
}
private decodeAllRecords(data: Uint8Array): WALRecord[] {
private decodeAllRecords(data: Uint8Array): { records: WALRecord[]; corrupt: number } {
const records: WALRecord[] = [];
let corrupt = 0;
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
let offset = 0;
@@ -442,7 +461,10 @@ export class WAL {
const computedLegacy = this.legacyChecksum(recordBytes);
if ((computedNew >>> 0) !== storedCrc && (computedLegacy >>> 0) !== storedCrc) {
// CRC 不匹配,跳过此损坏记录(长度字段链完整时后续好记录仍可恢复,
// 行为由 aria-wal-crc 测试锁定)
// 行为由 aria-wal-crc 测试锁定)
// v0.8.0review 修复):跳过必须**被计数**并上报给恢复方 ——
// 否则"少了一条已提交写入"在引擎层完全不可观测(静默丢数据)。
corrupt++;
// eslint-disable-next-line no-console
console.warn(`[AriaEngine WAL] CRC mismatch at record LSN=${lsn}, skipping`);
continue;
@@ -462,6 +484,6 @@ export class WAL {
}
}
return records;
return { records, corrupt };
}
}
+45 -14
View File
@@ -45,11 +45,20 @@ export interface WALReadResult {
/** 实际读到的分片号(升序) */
segments: number[];
/**
* 活跃区间的空洞(缺失的分片号)。非空意味着"这之后的记录不可信" ——
* 活跃区间**内部**的空洞(缺失的分片号)。非空意味着"这之后的记录不可信" ——
* v0.8.0 起由调用方(WAL/引擎)**显式上报**,不再静默丢弃尾部
* (修复前:序号不连续 → 直接把空洞之后的分片全部丢掉且没有任何提示)。
*/
gaps: number[];
/**
* v0.8.0review 修复):起始分片**之前**缺失的分片号(前缀缺失)。
*
* 与 `gaps` 分开的原因:前缀缺失可能是正常代价 —— manifest 回退到上一代时,
* 它的 `startSegment` 比介质上现存最小分片更小(那段记录已被水位跳过)。
* 因此前缀缺失**不丢弃后缀分片**、也不当错误;只有 `fromLsn === 0`
*(从未推进过水位 → 分片应当从 fromSegment 连续存在)才把它计入 `gaps`。
*/
missingPrefix: number[];
/** 最后一条记录之后仍缺失的分片数(尾部截断诊断) */
missingTail: number;
}
@@ -138,26 +147,38 @@ export class SegmentedWALStore implements WALStore {
* 同一个连续历史。修复前 `readAll` 遇到空洞直接丢弃空洞之后的全部记录,
* 调用方(引擎恢复)完全无法感知"少了一批已提交事务"。
*/
async readAllFrom(fromSegment: number = 0): Promise<WALReadResult> {
async readAllFrom(fromSegment: number = 0, fromLsn: number = 0): Promise<WALReadResult> {
const keys = await this.backend.listKeys();
const allSegments = await this.listSegments();
const segments = allSegments.filter((s) => s.seq >= fromSegment);
// 空洞检测:活跃区间内分片序号必须连续。
// v0.8.0:**前缀缺失**同样算空洞 —— 调用方(manifest 的 `wal.startSegment`
// 保证"小于 startSegment 的分片已整体落盘、可以不存在",因此活跃区间内
// 第一片就缺席(segments[0].seq > fromSegment)意味着那段记录不知所踪
const gaps: number[] = [];
// ---- 前缀缺失(fromSegment .. segments[0].seq - 1----
// v0.8.0(review 修复):以前这里把前缀缺失也算作空洞,于是"回退到上一代
// manifest"时(上一代的 startSegment 比现存最小分片更小)会命中
// `kept = segments.filter(s => s.seq < firstGap)` = 空 —— **整段活 WAL 被丢掉**
// 正确语义:前缀缺失只影响"本就被水位跳过"的那一段,后缀分片必须照常读取。
const missingPrefix: number[] = [];
if (segments.length > 0) {
for (let missing = fromSegment; missing < segments[0].seq; missing++) gaps.push(missing);
for (let missing = fromSegment; missing < segments[0].seq; missing++) missingPrefix.push(missing);
}
// ---- 内部空洞(segments[i-1] 与 segments[i] 之间)----
const gaps: number[] = [];
for (let i = 1; i < segments.length; i++) {
for (let missing = segments[i - 1].seq + 1; missing < segments[i].seq; missing++) {
gaps.push(missing);
}
}
// 从未推进过水位(fromLsn === 0)时,分片本应从 fromSegment 连续存在 →
// 此时前缀缺失同样是异常(介质丢了一段记录),并入 gaps。
if (fromLsn === 0 && missingPrefix.length > 0) {
gaps.unshift(...missingPrefix);
missingPrefix.length = 0;
}
gaps.sort((a, b) => a - b);
// 空洞之后的分片整体丢弃(长度字段链无法跨空洞验证),并计入 diagnostics
// 内部空洞之后的分片整体丢弃(长度字段链无法跨空洞验证)
// 前缀缺失**不**丢弃任何后缀分片。
let kept = segments;
if (gaps.length > 0) {
const firstGap = gaps[0];
@@ -220,6 +241,7 @@ export class SegmentedWALStore implements WALStore {
fromSegment,
segments: readSegments,
gaps,
missingPrefix,
missingTail: 0,
};
}
@@ -235,8 +257,14 @@ export class SegmentedWALStore implements WALStore {
if (walKeys.length > 0) {
await this.backend.deleteMany(walKeys);
}
// v0.8.0:分片号只增不减(同 truncateBefore 的说明)—— 复用序号会让
// "同一序号对应两代记录",恢复按序号排序时旧记录可能排在新记录之后被重放。
// 分片号**只降不增**是禁止的(同 truncateBefore 的说明)。这里的准确语义是:
// 序号绝不回退,且绝不小于 manifest 记录的水位下限(`open(fromSegment)` 会把它
// 抬到 fromSegment);整体清空后允许复用**最后用过的那个号** —— 这是安全的,
// 因为记录自带 LSN`lsn <= startLsn` 的旧世代记录在恢复时一律跳过。
//
// 反例(修复前的真实缺陷):整段清空后把号重置为 0,而 manifest 的
// startSegment 已经是 K>0 → 新记录写进分片 0,恢复时被 `seq >= K` 过滤掉 →
// 已确认写入静默消失。因此这里绝不允许回退到 0。
const maxSeq = (await this.listSegments()).reduce((max, s) => Math.max(max, s.seq), -1);
this.currentSegment = Math.max(this.currentSegment, maxSeq + 1);
this.currentSize = 0;
@@ -279,7 +307,10 @@ export class SegmentedWALStore implements WALStore {
*/
async planKeepFrom(durableLsn: number, latestLsn?: number): Promise<number> {
const segments = await this.listSegments();
if (segments.length === 0) return 0;
// v0.8.0review 修复):介质上没有分片时**返回当前分片号**(= 下一条记录将写入
// 的号),而不是 0。返回 0 会让 manifest 的 `startSegment` 回退,与
// "分片号只增不减" 的声明冲突(一旦将来前缀缺失被当作异常,就会误判成丢数据)。
if (segments.length === 0) return this.currentSegment;
// 判定每个分片是否"整段已被水位覆盖":
// - 非末分片:下一分片的首条记录 LSN <= durableLsn ⇒ 本分片全部记录都 <= 水位
@@ -331,9 +362,9 @@ export class SegmentedWALStore implements WALStore {
const segments = await this.listSegments();
if (segments.length === 0) {
this.currentSegment = 0;
// 无分片可删:分片号保持不变(只增不减),返回下一条记录将写入的号
this.currentSize = 0;
return 0;
return this.currentSegment;
}
const keepFrom = await this.planKeepFrom(durableLsn, latestLsn);
+13 -49
View File
@@ -9,54 +9,12 @@ import type { QueryPlan, TableSchema } from '../constants';
// v0.8.0: 行所有权(row ownership)约定
// ---------------------------------------------------------------------------
/**
* 深拷贝一行,使调用方**无法通过修改返回值改写存储**
*
* 为什么必须做(审计实测):Memory/KVStore/Hybrid 三个引擎此前把内部行对象
* **直接**交给调用方:
* const rows = await db.query('SELECT * FROM t');
* rows[0].tag = 'HACKED'; // 存储被改写
* 再查 WHERE tag='HACKED' → 0 行;WHERE tag='x' → 0 行
* 即调用方一次无意的原地修改就能让索引与行失配、该行永久查不出来(Aria 因为是
* 反序列化路径反而幸免,于是又成了跨引擎行为差异)。
*
* 约定(写入 interface 文档,所有引擎必须遵守):
* **读出的行是副本,写入接收的行也是副本** —— 引擎不得把内部行对象暴露给外部,
* 也不得持有调用方传入的行对象引用。
*
* 实现说明:结构化克隆可用时优先使用(正确处理 Date/嵌套对象/循环引用);
* 存储层写入的行已经过 validateRow 的 JSON 安全性检查,因此退化路径也是安全的。
*/
export function cloneRow<T extends Record<string, unknown>>(row: T): T {
if (row === null || typeof row !== 'object') return row;
if (typeof structuredClone === 'function') {
try {
return structuredClone(row);
} catch {
// 含不可克隆值(函数/Proxy)时退化为逐层复制
}
}
return cloneRowFallback(row) as T;
}
/** 退化实现:递归复制普通对象与数组(保留 Date) */
function cloneRowFallback(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (value instanceof Date) return new Date(value.getTime());
if (Array.isArray(value)) return value.map((v) => cloneRowFallback(v));
if (value instanceof Uint8Array) return new Uint8Array(value);
if (value instanceof ArrayBuffer) return value.slice(0);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = cloneRowFallback(v);
}
return out;
}
/** 批量深拷贝 */
export function cloneRows<T extends Record<string, unknown>>(rows: T[]): T[] {
return rows.map((r) => cloneRow(r));
}
// 行所有权工具(cloneRow / cloneRows)见 `./row_clone` —— v0.8.0 审查修复:
// 它们是运行时实现,必须与被描述为"纯类型"的本文件分开,否则会逃过覆盖率统计
// 类型层面的约定(所有引擎必须遵守):
// **读出的行是副本,写入接收的行也是副本** —— 引擎不得把内部行对象暴露给
// 外部,也不得持有调用方传入的行对象引用。违反者会让调用方一次原地修改就
// 改写存储、造成索引与行失配(审计实测:改过的行再也查不出来)。
// ---------------------------------------------------------------------------
// IStorageEngine — 所有存储引擎必须实现的接口
@@ -172,7 +130,13 @@ export interface IStorageEngine {
// ---- 备份 (可选) ----
/** 在线备份:导出全库一致性快照 */
/**
* 在线备份:导出全库数据快照。
*
* v0.8.0review 修正文档):返回的是**调用时刻逐表读取的结果**,不构成
* "跨表一致性快照" —— 实现没有冻结全局版本(读取期间不会有并发写入时结果
* 是一致的,但并发写入下不同表可能落在不同时间点)。
*/
backup?(): Promise<Record<string, Record<string, unknown>[]>>;
// ---- 自愈/重置 (可选,v0.4.2-fix) ----
+1 -1
View File
@@ -6,7 +6,7 @@
import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema, WhereCondition } from '../constants';
import { DatabaseError } from '../constants';
import { cloneRow } from './interface';
import { cloneRow } from './row_clone';
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
import { stripUndefinedUpdates } from '../table/schema';
import { compileValidator, type RowValidator } from '../table/validation';
+65
View File
@@ -0,0 +1,65 @@
/**
* metona-sqlark — 行所有权(row ownership)工具
* @module engine/row_clone
*
* v0.8.0(审查修复):这三个函数此前放在 `engine/interface.ts`(被文档描述为
* "纯类型声明、可执行语句为 0、因此不纳入覆盖率统计")。它们其实是**运行时实现**
* 且被 Memory 引擎与 AriaEngine 的读路径调用 —— 放在纯类型文件里等于让真实实现
* 代码逃过覆盖率口径(同一类"统计口径虚高"问题正是 G5 门禁要根治的)。
* 现在实现搬到本模块,`interface.ts` 回到纯类型。
*/
/**
* 深拷贝一行,使调用方**无法通过修改返回值改写存储**。
*
* 为什么必须做(审计实测):Memory/KVStore/Hybrid 三个引擎此前把内部行对象
* **直接**交给调用方:
* const rows = await db.query('SELECT * FROM t');
* rows[0].tag = 'HACKED'; // 存储被改写
* 再查 WHERE tag='HACKED' → 0 行;WHERE tag='x' → 0 行
* 即调用方一次无意的原地修改就能让索引与行失配、该行永久查不出来(Aria 因为是
* 反序列化路径反而幸免,于是又成了跨引擎行为差异)。
*
* 约定(所有引擎必须遵守):
* **读出的行是副本,写入接收的行也是副本** —— 引擎不得把内部行对象暴露给外部,
* 也不得持有调用方传入的行对象引用。
*
* 实现说明:结构化克隆可用时优先使用;退化路径(`cloneRowFallback`)逐层复制
* 普通对象/数组,并额外保留 Date、Uint8Array、ArrayBuffer。
*
* 前提:存储层写入的行已经过 `validateRow` 的 JSON 安全性检查 —— 例如 `date`
* 列要求的是**合法日期字符串**而不是 Date 实例(`table/validation.ts`),因此
* 行内永远是 JSON 安全值,"环境提供的 structuredClone 是否保留 Date"不影响
* 引擎行为(jsdom 的 structuredClone 是 JSON 化 polyfill,会把 Date 变字符串;
* 真实浏览器与 Node 的原生实现会保留)。
*/
export function cloneRow<T extends Record<string, unknown>>(row: T): T {
if (row === null || typeof row !== 'object') return row;
if (typeof structuredClone === 'function') {
try {
return structuredClone(row);
} catch {
// 含不可克隆值(函数/Proxy)时退化为逐层复制
}
}
return cloneRowFallback(row) as T;
}
/** 退化实现:递归复制普通对象与数组(保留 Date) */
function cloneRowFallback(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (value instanceof Date) return new Date(value.getTime());
if (Array.isArray(value)) return value.map((v) => cloneRowFallback(v));
if (value instanceof Uint8Array) return new Uint8Array(value);
if (value instanceof ArrayBuffer) return value.slice(0);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = cloneRowFallback(v);
}
return out;
}
/** 批量深拷贝 */
export function cloneRows<T extends Record<string, unknown>>(rows: T[]): T[] {
return rows.map((r) => cloneRow(r));
}