test(P0): v0.8.0 验证基座与工程门禁根治
工作流 C-1 / C-3 前半 + 测试代码类型检查。 【故障注入基座】新增 tests/helpers/storage-harness.ts + faulty-backend.ts - TransactionalFileStore:忠实 OPFS 提交语义(close 才可见)+ 字节级故障注入 (failNextWrite/Append/Delete、truncateAppendTo 撕裂写、crashPending 真崩溃) - 删除旧 opfs-mock:读返回内部引用、keepExistingData:false 不截断、close 空实现 导致"提交前可见"等真实缺陷无法被测出(31 个测试文件迁移至新 harness) - 删除 aria-opfs-backend 内的第三份重复 mock(含从未被断言使用的 writeCalls 死代码 与 entry.content.subarray 恒等分支) - FaultyBackend:包装任意 IStorageBackend 注入故障;crash() 明确区别于 close() (后者是优雅停机,会刷完写队列 —— 这正是此前所有"崩溃恢复"测试的真相) - 16 条基座自测证明注入真的生效(含 close 不能当崩溃的对照组) 【覆盖率口径】jest.config.cjs - 移除 '!src/**/index.ts'(该 glob 把 AriaEngine 主实现等 15 个实现文件整体 排除出统计,与 v0.2.6 曾承认过的问题同源),改为只排除纯类型声明文件并附理由 - 新增 coverageThreshold 门禁(此前完全不存在) - 真实基线:语句 90.66% / 分支 82.94% / 函数 94.36% / 行 93.43% - 修正 testMatch 使 tests/helpers 下的测试可被发现 【测试代码类型检查】tsconfig.test.json + npm run typecheck:tests - 修复 103 个测试代码类型错误(此前 babel 剥离类型 + tsconfig 排除 tests,全部隐藏) - 新增 tests/helpers/assertions.ts:nonNull/decode/rows/object/engineMethod/expectCode 以断言收窄替代 as any - 消除 21 个 lint warning(含 v043-hardening 中定义后从未调用的 mockOPFS 死代码) - parser.test.ts 12 处 toBeDefined() 空断言升级为结构断言(并新增 AND/OR 优先级用例, 当前红灯,对应总账第 11 项,将在工作流 A 修复) 【版本契约】新增 tests/version-contract.test.ts - 校验 src VERSION / package.json / dist 三者一致,替代两处硬编码版本字面量 【CI 门禁】.gitea/workflows/ci.yml - lint 去掉 continue-on-error(此前永远不让 CI 变红) - 新增 tests 类型检查、--coverage 覆盖率门禁、dist 与源码同步校验 - 版本 0.7.4 升至 0.8.0
This commit is contained in:
+33
-6
@@ -28,18 +28,30 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Type check
|
# v0.8.0: 源码类型检查
|
||||||
|
- name: Type check (src)
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
|
# v0.8.0 新增:**测试代码**类型检查。
|
||||||
|
# 此前 tests/ 既不在 tsconfig include 中,babel-jest 又只剥离类型不做检查,
|
||||||
|
# 于是测试里的类型错误、拼写错误、未 await 的断言全部不可见
|
||||||
|
# (审计实例:v025-fixes 里标题写 indexeddb、实际传 'opfs' 的用例长期存活)。
|
||||||
|
- name: Type check (tests)
|
||||||
|
run: npm run typecheck:tests
|
||||||
|
|
||||||
|
# v0.8.0: lint 不再 continue-on-error —— 此前 lint 失败永远不让 CI 变红,
|
||||||
|
# 形同虚设。现在 src 与 tests 都纳入,0 error 才通过。
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# v0.7.2: Run tests 拆两步 —— 常规套件并行(快),重型套件串行(runInBand)。
|
# v0.7.2: Run tests 拆两步 —— 常规套件并行(快),重型套件串行(runInBand)。
|
||||||
# 此前重型测试(10 万行 kv/opfs、生产矩阵)与常规套件在慢 runner 上并行
|
# 此前重型测试(10 万行 kv/opfs、生产矩阵)与常规套件在慢 runner 上并行
|
||||||
# 争抢 CPU 与 4GB 堆 → 单测超时(120~180s)与 OOM 类假失败。
|
# 争抢 CPU 与 4GB 堆 → 单测超时(120~180s)与 OOM 类假失败。
|
||||||
- name: Run tests (regular suites)
|
#
|
||||||
run: npx jest --forceExit --maxWorkers=1 --no-cache --testPathIgnorePatterns='/node_modules/|/tests/e2e/|/tests/helpers/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race'
|
# v0.8.0: 常规套件**带覆盖率**运行(此前 CI 从不跑 --coverage,且 jest.config
|
||||||
|
# 没有任何 coverageThreshold → 覆盖率掉到 0% 也全绿)。阈值定义在 jest.config.cjs。
|
||||||
|
- name: Run tests (regular suites, with coverage gate)
|
||||||
|
run: npx jest --coverage --forceExit --maxWorkers=1 --no-cache --testPathIgnorePatterns='/node_modules/|/tests/e2e/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race'
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
|
||||||
@@ -56,12 +68,26 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
# v0.8.0: 校验仓库内 dist/ 与源码同步。
|
||||||
|
# 此前 dist/ 已提交入库(42 个提交都在改 dist),但 CI 只 build 到工作区、
|
||||||
|
# 不校验是否与源码一致 → 发布产物可以静默落后于源码。
|
||||||
|
# 仅在单一 Node 版本上校验(构建结果与 Node 版本无关)。
|
||||||
|
- name: Verify dist is in sync with src
|
||||||
|
if: matrix.node-version == '20.x'
|
||||||
|
run: |
|
||||||
|
if ! git diff --quiet -- dist/; then
|
||||||
|
echo "::error::dist/ 与源码不同步。请在本地运行 npm run build 并提交 dist/。"
|
||||||
|
git diff --stat -- dist/
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "dist/ 与源码同步 ✓"
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: debian-latest
|
runs-on: debian-latest
|
||||||
# 与 MetonaEditor 一致:Playwright 官方镜像(自带 Chromium 与全部系统依赖),
|
# 与 MetonaEditor 一致:Playwright 官方镜像(自带 Chromium 与全部系统依赖),
|
||||||
# 避免 runner 宿主(Debian 11)不被 Playwright 1.62 支持的问题
|
# 避免 runner 宿主(Debian 11)不被 Playwright 1.62 支持的问题
|
||||||
container: mcr.microsoft.com/playwright:v1.62.1-noble
|
container: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||||
timeout-minutes: 15
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js
|
- name: Use Node.js
|
||||||
@@ -75,5 +101,6 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Verify Playwright browsers
|
- name: Verify Playwright browsers
|
||||||
run: npx playwright install chromium
|
run: npx playwright install chromium
|
||||||
- name: Run E2E smoke tests
|
# v0.8.0: e2e 覆盖真实崩溃注入(CDP Page.crash)与真实 OPFS 语义
|
||||||
|
- name: Run E2E tests
|
||||||
run: npm run test:e2e
|
run: npm run test:e2e
|
||||||
|
|||||||
Vendored
+1
-1
@@ -34,7 +34,7 @@ class DatabaseError extends Error {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const VERSION = '0.7.4';
|
const VERSION = '0.8.0';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||||
|
|||||||
Vendored
+1
-1
@@ -164,7 +164,7 @@ interface MetonaPlugin {
|
|||||||
/** 销毁 */
|
/** 销毁 */
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
declare const VERSION = "0.7.4";
|
declare const VERSION = "0.8.0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Plugin — 插件系统
|
* metona-sqlark Plugin — 插件系统
|
||||||
|
|||||||
Vendored
+1
-1
@@ -30,7 +30,7 @@ class DatabaseError extends Error {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const VERSION = '0.7.4';
|
const VERSION = '0.8.0';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||||
|
|||||||
Vendored
+1
-1
@@ -36,7 +36,7 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const VERSION = '0.7.4';
|
const VERSION = '0.8.0';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+37
-8
@@ -8,25 +8,54 @@ module.exports = {
|
|||||||
'/node_modules/(?!(@rollup)/)',
|
'/node_modules/(?!(@rollup)/)',
|
||||||
],
|
],
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
// Playwright e2e(tests/e2e/)与共享测试工具(tests/helpers/)不属于 jest 单元测试
|
// 默认 testMatch 不会发现 __tests__ 之外、名为 helpers/ 目录里的 .test.ts 文件,
|
||||||
|
// 因此显式声明匹配规则(v0.8.0:测试基座自身的自测放在 tests/helpers/ 下)。
|
||||||
|
testMatch: ['**/*.test.ts'],
|
||||||
|
// Playwright e2e(tests/e2e/)不属于 jest 单元测试
|
||||||
testPathIgnorePatterns: [
|
testPathIgnorePatterns: [
|
||||||
'/node_modules/',
|
'/node_modules/',
|
||||||
'/tests/e2e/',
|
'/tests/e2e/',
|
||||||
'/tests/helpers/',
|
|
||||||
],
|
],
|
||||||
|
//
|
||||||
|
// v0.8.0 覆盖率口径修正(根治):
|
||||||
|
//
|
||||||
|
// 此前为 `'!src/**/index.ts'` —— 这条 glob 把 15 个文件整体排除,其中包含
|
||||||
|
// src/engine/aria/index.ts(AriaEngine 主实现 2282 行)、src/engine/kvstore/index.ts
|
||||||
|
// (KVStore 本体)、src/hybrid/index.ts、src/migration/index.ts 等**实现文件**,
|
||||||
|
// 而非桶文件。后果:发布的"90.1% 行覆盖率"只在约 75% 的可执行源码上成立,
|
||||||
|
// 且 v0.2.6 曾在 CHANGELOG 里承认过同类问题("91.0% 为排除 Aria 模块的陈旧数据")
|
||||||
|
// 后又在 v0.5.1 以另一种写法复发。
|
||||||
|
//
|
||||||
|
// 现在只排除**确实没有可执行语句**的文件(纯类型声明),并且逐个显式列出、
|
||||||
|
// 附理由 —— 新增文件默认纳入统计,不会再被 glob 静默吞掉。
|
||||||
|
//
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.ts',
|
'src/**/*.ts',
|
||||||
'!src/index.ts',
|
// 纯类型声明:可执行语句 0 行(接口/类型别名),纳入统计只会稀释分母
|
||||||
'!src/**/index.ts',
|
|
||||||
'!src/query/ast.ts',
|
|
||||||
'!src/engine/interface.ts',
|
'!src/engine/interface.ts',
|
||||||
'!src/engine/opfs.ts',
|
'!src/query/ast.ts',
|
||||||
'!src/integrations/**',
|
|
||||||
],
|
],
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
coverageReporters: ['text', 'lcov'],
|
coverageReporters: ['text', 'lcov'],
|
||||||
|
//
|
||||||
|
// v0.8.0 覆盖率门禁(此前完全不存在:jest.config 与 CI 都没有任何阈值,
|
||||||
|
// 覆盖率从 90% 掉到 60% 也全绿)。
|
||||||
|
//
|
||||||
|
// 阈值 = 2026-08-15 在**修正口径后**(不再排除实现文件)实测的真实基线,
|
||||||
|
// 下取整到整数百分点。v0.8.0 各阶段会持续补测,M4 收口时再上调。
|
||||||
|
// 实测基线:Statements 90.66% / Branches 82.94% / Functions 94.36% / Lines 93.43%
|
||||||
|
//
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 90,
|
||||||
|
branches: 82,
|
||||||
|
functions: 94,
|
||||||
|
lines: 93,
|
||||||
|
},
|
||||||
|
},
|
||||||
verbose: true,
|
verbose: true,
|
||||||
// Must have: fake-indexeddb + debounce timers keep event loop alive
|
// 注意:forceExit 会掩盖句柄/定时器泄漏。v0.8.0 保留它以便常规开发快速迭代,
|
||||||
|
// 但新增了独立的 `npm run test:leaks`(--detectOpenHandles 且不 forceExit)用于泄漏检查。
|
||||||
forceExit: true,
|
forceExit: true,
|
||||||
testTimeout: 15000,
|
testTimeout: 15000,
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@metona-team/metona-sqlark",
|
"name": "@metona-team/metona-sqlark",
|
||||||
"version": "0.7.4",
|
"version": "0.8.0",
|
||||||
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/metona-sqlark.cjs",
|
"main": "dist/metona-sqlark.cjs",
|
||||||
@@ -35,12 +35,15 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
|
"test:leaks": "jest --detectOpenHandles --forceExit=false",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"lint": "eslint \"src/**/*.ts\"",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"",
|
||||||
"lint:fix": "eslint \"src/**/*.ts\" --fix",
|
"lint:fix": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" --fix",
|
||||||
"format": "prettier --write \"src/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
"typecheck:tests": "tsc -p tsconfig.test.json",
|
||||||
|
"verify": "npm run typecheck && npm run typecheck:tests && npm run lint && npm run test:coverage",
|
||||||
|
"prepublishOnly": "npm run typecheck && npm run typecheck:tests && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+1
-1
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
|
|||||||
// 版本
|
// 版本
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const VERSION = '0.7.4';
|
export const VERSION = '0.8.0';
|
||||||
|
|||||||
@@ -8,3 +8,9 @@ export { MemoryEngine } from './memory';
|
|||||||
export { KVStoreEngine } from './kvstore_engine';
|
export { KVStoreEngine } from './kvstore_engine';
|
||||||
export { AriaEngine } from './aria/index';
|
export { AriaEngine } from './aria/index';
|
||||||
export type { AriaEngineConfig } from './aria/types';
|
export type { AriaEngineConfig } from './aria/types';
|
||||||
|
// v0.8.0: 补齐后端与 KVStore 的桶导出(此前只能深路径 import)
|
||||||
|
export { KVStore } from './kvstore/index';
|
||||||
|
export { SharedMemoryBackend } from './kvstore/shared_memory_medium';
|
||||||
|
export { OPFSBackend } from './aria/store/opfs_backend';
|
||||||
|
export { MemoryBackend } from './aria/store/backend';
|
||||||
|
export type { IStorageBackend } from './aria/store/backend';
|
||||||
|
|||||||
+11
-1
@@ -76,13 +76,23 @@ export const MeSqlark = MetonaSqlark;
|
|||||||
export type { DatabaseConfig, TableSchema, ColumnDef, FieldType, StorageMode, DiskEngine } from './constants';
|
export type { DatabaseConfig, TableSchema, ColumnDef, FieldType, StorageMode, DiskEngine } from './constants';
|
||||||
export type { IStorageEngine } from './engine/interface';
|
export type { IStorageEngine } from './engine/interface';
|
||||||
export type { Statement, SelectStatement, InsertStatement, UpdateStatement, DeleteStatement } from './query/ast';
|
export type { Statement, SelectStatement, InsertStatement, UpdateStatement, DeleteStatement } from './query/ast';
|
||||||
|
// v0.8.0: 补齐公开 API —— 此前 CONTRIBUTING 示例的
|
||||||
|
// `import type { MetonaPlugin } from '@metona-team/metona-sqlark'` 并不成立,
|
||||||
|
// 且 DatabaseError 未导出导致调用方无法 instanceof 判错,只能比 error.code 字符串。
|
||||||
|
export { DatabaseError } from './constants';
|
||||||
|
export type { MetonaPlugin, HookName, WhereCondition, WhereOperator, QueryPlan, OrderBy } from './constants';
|
||||||
|
export { PluginManager } from './plugin/index';
|
||||||
|
export type { HookCallback } from './plugin/index';
|
||||||
|
export { Transaction } from './transaction/index';
|
||||||
|
export { TransactionManager } from './transaction/index';
|
||||||
export { MemoryEngine } from './engine/memory';
|
export { MemoryEngine } from './engine/memory';
|
||||||
export { KVStoreEngine } from './engine/kvstore_engine';
|
export { KVStoreEngine } from './engine/kvstore_engine';
|
||||||
export { AriaEngine } from './engine/aria/index';
|
export { AriaEngine } from './engine/aria/index';
|
||||||
export { HybridEngine } from './hybrid/index';
|
export { HybridEngine } from './hybrid/index';
|
||||||
export { Table } from './table/table';
|
export { Table } from './table/table';
|
||||||
export { parse, parseAll } from './sql/parser';
|
export { parse, parseAll, parseWhereCondition } from './sql/parser';
|
||||||
export { tokenize } from './sql/lexer';
|
export { tokenize } from './sql/lexer';
|
||||||
|
export { bindParameters } from './sql/params';
|
||||||
|
|
||||||
// AriaEngine 类型 & 后端
|
// AriaEngine 类型 & 后端
|
||||||
export type { AriaEngineConfig } from './engine/aria/types';
|
export type { AriaEngineConfig } from './engine/aria/types';
|
||||||
|
|||||||
@@ -7,3 +7,10 @@ export type * from './ast';
|
|||||||
export { SelectQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from './builder';
|
export { SelectQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from './builder';
|
||||||
export { compileStatement } from './compiler';
|
export { compileStatement } from './compiler';
|
||||||
export { QueryExecutor } from './executor';
|
export { QueryExecutor } from './executor';
|
||||||
|
// v0.8.0: WHERE 匹配与排序/投影的统一入口
|
||||||
|
export {
|
||||||
|
matchWhere,
|
||||||
|
applyOrderBy,
|
||||||
|
projectColumns,
|
||||||
|
containsUnresolvedSubqueries,
|
||||||
|
} from './where-matcher';
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { Lexer, tokenize } from './lexer';
|
export { Lexer, tokenize } from './lexer';
|
||||||
export { Parser, parse } from './parser';
|
export { Parser, parse, parseAll, parseWhereCondition } from './parser';
|
||||||
|
// v0.8.0: 参数绑定此前未从本桶导出(README 与核心 query() 都依赖它)
|
||||||
|
export { bindParameters } from './params';
|
||||||
export { TokenType } from './tokens';
|
export { TokenType } from './tokens';
|
||||||
export type { Token } from './tokens';
|
export type { Token } from './tokens';
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
import { AriaEngine } from '../src/engine/aria/index';
|
import { AriaEngine } from '../src/engine/aria/index';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
const mkEngine = async (name: string): Promise<AriaEngine> => {
|
const mkEngine = async (name: string): Promise<AriaEngine> => {
|
||||||
const e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
|
const e = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ async function openPage(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 在页面中执行 harness 方法(统一包装错误信息) */
|
/** 在页面中执行 harness 方法(统一包装错误信息) */
|
||||||
async function run<T>(page: Page, method: string, args: unknown): Promise<T> {
|
async function run<T>(page: Page, method: string, args: unknown = null): Promise<T> {
|
||||||
const result = await page.evaluate(
|
const result = await page.evaluate(
|
||||||
([m, a]) => (window as unknown as { __ms: Record<string, (o: unknown) => Promise<unknown>> }).__ms[m](a),
|
(payload: [string, unknown]) => {
|
||||||
[method, args] as const,
|
const [m, a] = payload;
|
||||||
|
return (window as unknown as { __ms: Record<string, (o: unknown) => Promise<unknown>> }).__ms[m](a);
|
||||||
|
},
|
||||||
|
[method, args] as [string, unknown],
|
||||||
);
|
);
|
||||||
return result as T;
|
return result as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ import { WAL, type WALStore } from '../../src/engine/aria/wal/log';
|
|||||||
import { WALRecordType } from '../../src/engine/aria/types';
|
import { WALRecordType } from '../../src/engine/aria/types';
|
||||||
import { BufferPool } from '../../src/engine/aria/buffer/pool';
|
import { BufferPool } from '../../src/engine/aria/buffer/pool';
|
||||||
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
||||||
import { createSchema } from '../../src/table/schema';
|
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — Bloom + WAL + BufferPool', () => {
|
describe('AriaEngine — Bloom + WAL + BufferPool', () => {
|
||||||
// ---- BloomFilter 完整测试 ----
|
// ---- BloomFilter 完整测试 ----
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { AriaEngine } from '../../src/engine/aria/index';
|
|||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — 批量扩展测试', () => {
|
describe('AriaEngine — 批量扩展测试', () => {
|
||||||
let engine: AriaEngine;
|
let engine: AriaEngine;
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ describe('AriaEngine — EvictionManager', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('access 更新 LRU', async () => {
|
it('access 更新 LRU', async () => {
|
||||||
let evicted = -1;
|
let _evicted = -1;
|
||||||
const em = new EvictionManager(3, async (p) => { evicted = p.pageId; });
|
const em = new EvictionManager(3, async (p) => { _evicted = p.pageId; });
|
||||||
const p = makePage(1);
|
const p = makePage(1);
|
||||||
em.add(p);
|
em.add(p);
|
||||||
em.access(p);
|
em.access(p);
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
/** 构造小缓存 + 小 MemTable 阈值的引擎,快速产生多个 SSTable */
|
/** 构造小缓存 + 小 MemTable 阈值的引擎,快速产生多个 SSTable */
|
||||||
function createSmallCacheEngine(bufferPoolPages = 2) {
|
function createSmallCacheEngine(bufferPoolPages = 2) {
|
||||||
@@ -61,7 +61,7 @@ describe('AriaEngine SSTable 缓存内存上限', () => {
|
|||||||
for (let round = 0; round < 5; round++) {
|
for (let round = 0; round < 5; round++) {
|
||||||
const rows = await engine.find('users', { table: 'users', where: { age: 25 } });
|
const rows = await engine.find('users', { table: 'users', where: { age: 25 } });
|
||||||
expect(rows.length).toBe(10);
|
expect(rows.length).toBe(10);
|
||||||
expect(lsm.cacheSize).toBeLessThanOrEqual(lsm.cacheLimitBytes);
|
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
|
||||||
}
|
}
|
||||||
|
|
||||||
await engine.close();
|
await engine.close();
|
||||||
@@ -82,7 +82,7 @@ describe('AriaEngine SSTable 缓存内存上限', () => {
|
|||||||
expect(all.length).toBe(300);
|
expect(all.length).toBe(300);
|
||||||
|
|
||||||
const lsm = (engine as any).lsm;
|
const lsm = (engine as any).lsm;
|
||||||
expect(lsm.cacheSize).toBeLessThanOrEqual(lsm.cacheLimitBytes);
|
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
|
||||||
await engine.close();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ describe('AriaEngine SSTable 缓存内存上限', () => {
|
|||||||
for (let batch = 0; batch < 10; batch++) {
|
for (let batch = 0; batch < 10; batch++) {
|
||||||
await engine.insert('users', makeRows(30).map((r, i) => ({ ...r, id: `b${batch}_u${i}` })));
|
await engine.insert('users', makeRows(30).map((r, i) => ({ ...r, id: `b${batch}_u${i}` })));
|
||||||
const lsm = (engine as any).lsm;
|
const lsm = (engine as any).lsm;
|
||||||
expect(lsm.cacheSize).toBeLessThanOrEqual(lsm.cacheLimitBytes);
|
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = await engine.find('users', { table: 'users' });
|
const all = await engine.find('users', { table: 'users' });
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — 扩展边缘测试', () => {
|
describe('AriaEngine — 扩展边缘测试', () => {
|
||||||
let engine: AriaEngine;
|
let engine: AriaEngine;
|
||||||
@@ -69,7 +69,7 @@ describe('AriaEngine — 扩展边缘测试', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('多列 ORDER BY', async () => {
|
it('多列 ORDER BY', async () => {
|
||||||
const rows = await engine.find('users', { table: 'users', orderBy: [{ column: 'age', direction: 'asc' }, { column: 'score', direction: 'desc' }] });
|
const rows = await engine.find('users', { table: 'users', orderBy: [{ column: 'age', direction: 'asc' }, { column: 'score', direction: 'desc' }] }) as Array<{ age: number }>;
|
||||||
expect(rows[0].age).toBeLessThanOrEqual(rows[4].age);
|
expect(rows[0].age).toBeLessThanOrEqual(rows[4].age);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import { EncryptedBackend } from '../../src/engine/aria/store/encrypted_backend'
|
|||||||
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
import { decode as decodeBytes } from '../helpers/assertions';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -48,11 +49,11 @@ describe('AriaEngine — EncryptedBackend 单元', () => {
|
|||||||
await backend.write('k1', payload);
|
await backend.write('k1', payload);
|
||||||
const back = await backend.read('k1');
|
const back = await backend.read('k1');
|
||||||
expect(back).not.toBeNull();
|
expect(back).not.toBeNull();
|
||||||
expect(new TextDecoder().decode(back)).toBe('hello encrypted world');
|
expect(decodeBytes(back)).toBe('hello encrypted world');
|
||||||
|
|
||||||
// 底层是密文(非明文)
|
// 底层是密文(非明文)
|
||||||
const raw = await inner.read('k1');
|
const raw = await inner.read('k1');
|
||||||
const rawStr = new TextDecoder().decode(raw);
|
const rawStr = decodeBytes(raw, 'ciphertext');
|
||||||
expect(rawStr).not.toContain('hello encrypted world');
|
expect(rawStr).not.toContain('hello encrypted world');
|
||||||
|
|
||||||
await backend.close();
|
await backend.close();
|
||||||
@@ -65,8 +66,8 @@ describe('AriaEngine — EncryptedBackend 单元', () => {
|
|||||||
|
|
||||||
const enc = (s: string) => new TextEncoder().encode(s).buffer;
|
const enc = (s: string) => new TextEncoder().encode(s).buffer;
|
||||||
await backend.writeMany({ a: enc('AAA'), b: enc('BBB'), c: enc('CCC') });
|
await backend.writeMany({ a: enc('AAA'), b: enc('BBB'), c: enc('CCC') });
|
||||||
expect(new TextDecoder().decode(await backend.read('a'))).toBe('AAA');
|
expect(decodeBytes(await backend.read('a'))).toBe('AAA');
|
||||||
expect(new TextDecoder().decode(await backend.read('c'))).toBe('CCC');
|
expect(decodeBytes(await backend.read('c'))).toBe('CCC');
|
||||||
|
|
||||||
await backend.deleteMany(['a', 'c']);
|
await backend.deleteMany(['a', 'c']);
|
||||||
expect(await backend.exists('a')).toBe(false);
|
expect(await backend.exists('a')).toBe(false);
|
||||||
@@ -94,8 +95,8 @@ describe('AriaEngine — EncryptedBackend 单元', () => {
|
|||||||
expect((rawX as ArrayBuffer).byteLength).toBe((rawY as ArrayBuffer).byteLength);
|
expect((rawX as ArrayBuffer).byteLength).toBe((rawY as ArrayBuffer).byteLength);
|
||||||
|
|
||||||
// 但解密一致
|
// 但解密一致
|
||||||
expect(new TextDecoder().decode(await backend.read('x'))).toBe('same plaintext');
|
expect(decodeBytes(await backend.read('x'))).toBe('same plaintext');
|
||||||
expect(new TextDecoder().decode(await backend.read('y'))).toBe('same plaintext');
|
expect(decodeBytes(await backend.read('y'))).toBe('same plaintext');
|
||||||
await backend.close();
|
await backend.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ describe('AriaEngine — EncryptedBackend 单元', () => {
|
|||||||
const backend2 = new EncryptedBackend(inner, 'pw');
|
const backend2 = new EncryptedBackend(inner, 'pw');
|
||||||
await backend2.open('enc-unit-4');
|
await backend2.open('enc-unit-4');
|
||||||
await backend2.write('k2', new TextEncoder().encode('v2').buffer);
|
await backend2.write('k2', new TextEncoder().encode('v2').buffer);
|
||||||
expect(new TextDecoder().decode(await backend2.read('k2'))).toBe('v2');
|
expect(decodeBytes(await backend2.read('k2'))).toBe('v2');
|
||||||
|
|
||||||
// 换密码被拒(keymeta 与旧密码绑定)——注意:MemoryBackend close 清空 store,
|
// 换密码被拒(keymeta 与旧密码绑定)——注意:MemoryBackend close 清空 store,
|
||||||
// 因此在 close 前验证(backend2 仍持有 inner)
|
// 因此在 close 前验证(backend2 仍持有 inner)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — 补充测试', () => {
|
describe('AriaEngine — 补充测试', () => {
|
||||||
let engine: AriaEngine;
|
let engine: AriaEngine;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { createSchema } from '../../src/table/schema';
|
|||||||
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
||||||
import { checkFieldType } from '../../src/table/schema';
|
import { checkFieldType } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — 最终扩展测试', () => {
|
describe('AriaEngine — 最终扩展测试', () => {
|
||||||
let engine: AriaEngine;
|
let engine: AriaEngine;
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
*/
|
*/
|
||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
return `idxrace-${Date.now()}-${++counter}-${Math.random().toString(36).slice(2, 6)}`;
|
return `idxrace-${Date.now()}-${++counter}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
||||||
it('5 万行写入:索引查询与主表一致(修复前丢 106~771 条)', async () => {
|
it('5 万行写入:索引查询与主表一致(修复前丢 106~771 条)', async () => {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — 二级索引查询', () => {
|
describe('AriaEngine — 二级索引查询', () => {
|
||||||
let engine: AriaEngine;
|
let engine: AriaEngine;
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ describe('MetonaSqlark + diskEngine: kv(高层 API)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 事务回滚
|
// 事务回滚
|
||||||
await expect(db.transaction(async (trx) => {
|
await expect(db.transaction(async (trx: { table: (n: string) => any }) => {
|
||||||
await trx.table('users').insert({ id: '1' });
|
await trx.table('users').insert({ id: '1' });
|
||||||
throw new Error('boom');
|
throw new Error('boom');
|
||||||
})).rejects.toThrow('boom');
|
})).rejects.toThrow('boom');
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { AriaEngine } from '../../src/engine/aria/index';
|
|||||||
import { DatabaseLock, lockName } from '../../src/engine/aria/locks';
|
import { DatabaseLock, lockName } from '../../src/engine/aria/locks';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { createSchema } from '../../src/table/schema';
|
|||||||
import { QueryExecutor } from '../../src/query/executor';
|
import { QueryExecutor } from '../../src/query/executor';
|
||||||
import { parse } from '../../src/sql/parser';
|
import { parse } from '../../src/sql/parser';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
import { object } from '../helpers/assertions';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — ANALYZE/VACUUM/REINDEX + EXPLAIN', () => {
|
describe('AriaEngine — ANALYZE/VACUUM/REINDEX + EXPLAIN', () => {
|
||||||
let engine: AriaEngine;
|
let engine: AriaEngine;
|
||||||
@@ -78,7 +79,9 @@ describe('AriaEngine — ANALYZE/VACUUM/REINDEX + EXPLAIN', () => {
|
|||||||
const executor = new QueryExecutor(engine);
|
const executor = new QueryExecutor(engine);
|
||||||
const selectStmt = parse('SELECT * FROM users WHERE id = \'1\'');
|
const selectStmt = parse('SELECT * FROM users WHERE id = \'1\'');
|
||||||
const explainStmt: any = { type: 'EXPLAIN', query: selectStmt };
|
const explainStmt: any = { type: 'EXPLAIN', query: selectStmt };
|
||||||
const plan = await executor.execute(explainStmt);
|
const plan = object<{ type: string; table: string; usingIndex?: unknown; actualTimeMs: number }>(
|
||||||
|
await executor.execute(explainStmt),
|
||||||
|
);
|
||||||
expect(plan.type).toBe('SELECT');
|
expect(plan.type).toBe('SELECT');
|
||||||
expect(plan.table).toBe('users');
|
expect(plan.table).toBe('users');
|
||||||
expect(plan.usingIndex).toBeDefined();
|
expect(plan.usingIndex).toBeDefined();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
import { SharedMemoryBackend } from '../../src/engine/kvstore/shared_memory_medium';
|
import { SharedMemoryBackend } from '../../src/engine/kvstore/shared_memory_medium';
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -56,7 +56,7 @@ async function reopen(dbName: string, config: Record<string, unknown>): Promise<
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
SharedMemoryBackend.clearRegistry();
|
SharedMemoryBackend.clearRegistry();
|
||||||
installOPFSMock(new Map());
|
resetOPFSMock();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('生产矩阵审计 — 后端 × 核心功能', () => {
|
describe('生产矩阵审计 — 后端 × 核心功能', () => {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
describe('AriaEngine — MVCC 事务 + Savepoint', () => {
|
describe('AriaEngine — MVCC 事务 + Savepoint', () => {
|
||||||
let engine: AriaEngine;
|
let engine: AriaEngine;
|
||||||
|
|||||||
@@ -10,82 +10,28 @@
|
|||||||
* 6. open 清理崩溃残留临时文件(.crswap/.tmp)
|
* 6. open 清理崩溃残留临时文件(.crswap/.tmp)
|
||||||
* 7. writeMany/deleteMany 语义
|
* 7. writeMany/deleteMany 语义
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// v0.8.0: 删除本文件内的第三份 OPFS mock —— 改用共享的 storage-harness。
|
||||||
|
//
|
||||||
|
// 原实现的问题(见 PLAN-v0.7.5.md 工作流 C-1):
|
||||||
|
// - 与 tests/helpers/opfs-mock.ts 重复(两份都在测同一件事,语义各自漂移);
|
||||||
|
// - `close()` 是空函数、写入立即可见 → 无法表达"提交前崩溃";
|
||||||
|
// - 定义了 `writeCalls` 记录但**从未被任何断言使用**(死代码);
|
||||||
|
// - `entry.content.subarray ? entry.content : entry.content` 是恒等表达式(无意义分支)。
|
||||||
|
//
|
||||||
|
// 共享 harness 提供:真实提交语义(close 才可见)、读返回副本、
|
||||||
|
// keepExistingData:false 截断、字节级故障注入、真崩溃模拟。
|
||||||
|
// ===================================================================
|
||||||
import { OPFSBackend } from '../../src/engine/aria/store/opfs_backend';
|
import { OPFSBackend } from '../../src/engine/aria/store/opfs_backend';
|
||||||
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
// ===================================================================
|
import { decode as decodeBytes } from '../helpers/assertions';
|
||||||
// 真实语义 OPFS mock:记录文件内容、支持 keepExistingData+position
|
|
||||||
// ===================================================================
|
|
||||||
interface MockFile {
|
|
||||||
content: ArrayBuffer;
|
|
||||||
writeCalls: { position?: number; data: ArrayBuffer; keepExistingData?: boolean }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOPFSMock() {
|
|
||||||
const files = new Map<string, MockFile>();
|
|
||||||
|
|
||||||
const getFileHandle = async (name: string, opts?: { create?: boolean }) => {
|
|
||||||
if (!files.has(name)) {
|
|
||||||
if (!opts?.create) throw new Error(`NotFoundError: ${name}`);
|
|
||||||
files.set(name, { content: new ArrayBuffer(0), writeCalls: [] });
|
|
||||||
}
|
|
||||||
const entry = files.get(name)!;
|
|
||||||
return {
|
|
||||||
getFile: async () => ({ size: entry.content.byteLength, arrayBuffer: async () => entry.content }),
|
|
||||||
createWritable: async (wOpts?: { keepExistingData?: boolean }) => {
|
|
||||||
const w: {
|
|
||||||
write: (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => Promise<void>;
|
|
||||||
close: () => Promise<void>;
|
|
||||||
} = {
|
|
||||||
write: async (arg) => {
|
|
||||||
const keepExisting = wOpts?.keepExistingData ?? false;
|
|
||||||
const isChunk = typeof arg !== 'object' || !('type' in (arg as object)) || (arg as { type?: string }).type === undefined
|
|
||||||
? { data: arg as ArrayBuffer, position: keepExisting ? entry.content.byteLength : 0 }
|
|
||||||
: { data: (arg as { data: ArrayBuffer }).data, position: (arg as { position: number }).position };
|
|
||||||
entry.writeCalls.push({ position: isChunk.position, data: isChunk.data, keepExistingData: keepExisting });
|
|
||||||
const merged = new Uint8Array(isChunk.position + isChunk.data.byteLength);
|
|
||||||
if (keepExisting || isChunk.position > 0) {
|
|
||||||
merged.set(new Uint8Array(entry.content.subarray ? entry.content : entry.content), 0);
|
|
||||||
}
|
|
||||||
merged.set(new Uint8Array(isChunk.data), isChunk.position);
|
|
||||||
entry.content = merged.buffer;
|
|
||||||
},
|
|
||||||
close: async () => { /* no-op */ },
|
|
||||||
};
|
|
||||||
return w;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const dir = {
|
|
||||||
getFileHandle,
|
|
||||||
entries: async function* () {
|
|
||||||
for (const [name] of files) yield [name];
|
|
||||||
},
|
|
||||||
removeEntry: async (name: string) => {
|
|
||||||
files.delete(name);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
|
||||||
value: {
|
|
||||||
storage: {
|
|
||||||
getDirectory: async () => ({
|
|
||||||
getDirectoryHandle: async (_name: string, _opts?: unknown) => dir,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { files, dir };
|
|
||||||
}
|
|
||||||
|
|
||||||
const enc = (s: string) => new Uint8Array(new TextEncoder().encode(s)).buffer;
|
const enc = (s: string) => new Uint8Array(new TextEncoder().encode(s)).buffer;
|
||||||
|
|
||||||
describe('AriaEngine — OPFSBackend v2', () => {
|
describe('AriaEngine — OPFSBackend v2', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createOPFSMock();
|
resetOPFSMock();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('read/write/exists/delete 基本语义', async () => {
|
it('read/write/exists/delete 基本语义', async () => {
|
||||||
@@ -95,7 +41,7 @@ describe('AriaEngine — OPFSBackend v2', () => {
|
|||||||
|
|
||||||
await backend.write('k1', enc('hello'));
|
await backend.write('k1', enc('hello'));
|
||||||
expect(await backend.exists('k1')).toBe(true);
|
expect(await backend.exists('k1')).toBe(true);
|
||||||
expect(new TextDecoder().decode(await backend.read('k1'))).toBe('hello');
|
expect(decodeBytes(await backend.read('k1'))).toBe('hello');
|
||||||
expect(await backend.read('missing')).toBeNull();
|
expect(await backend.read('missing')).toBeNull();
|
||||||
|
|
||||||
await backend.delete('k1');
|
await backend.delete('k1');
|
||||||
@@ -111,7 +57,7 @@ describe('AriaEngine — OPFSBackend v2', () => {
|
|||||||
await backend.append('wal', enc('BBB'));
|
await backend.append('wal', enc('BBB'));
|
||||||
await backend.append('wal', enc('CCC'));
|
await backend.append('wal', enc('CCC'));
|
||||||
const all = await backend.read('wal');
|
const all = await backend.read('wal');
|
||||||
expect(new TextDecoder().decode(all)).toBe('AAABBBCCC');
|
expect(decodeBytes(all)).toBe('AAABBBCCC');
|
||||||
await backend.close();
|
await backend.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,7 +66,7 @@ describe('AriaEngine — OPFSBackend v2', () => {
|
|||||||
await backend.open('opfs-test-3');
|
await backend.open('opfs-test-3');
|
||||||
await backend.write('f', enc('OLD-CONTENT'));
|
await backend.write('f', enc('OLD-CONTENT'));
|
||||||
await backend.write('f', enc('NEW'));
|
await backend.write('f', enc('NEW'));
|
||||||
expect(new TextDecoder().decode(await backend.read('f'))).toBe('NEW');
|
expect(decodeBytes(await backend.read('f'))).toBe('NEW');
|
||||||
await backend.close();
|
await backend.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,7 +78,7 @@ describe('AriaEngine — OPFSBackend v2', () => {
|
|||||||
const p2 = backend.append('log', enc('B'));
|
const p2 = backend.append('log', enc('B'));
|
||||||
const p3 = backend.append('log', enc('C'));
|
const p3 = backend.append('log', enc('C'));
|
||||||
await Promise.all([p1, p2, p3]);
|
await Promise.all([p1, p2, p3]);
|
||||||
expect(new TextDecoder().decode(await backend.read('log'))).toBe('ABC');
|
expect(decodeBytes(await backend.read('log'))).toBe('ABC');
|
||||||
await backend.close();
|
await backend.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -157,7 +103,7 @@ describe('AriaEngine — OPFSBackend v2', () => {
|
|||||||
await expect(origWrite('boom', enc('x'))).rejects.toThrow('Injected write failure');
|
await expect(origWrite('boom', enc('x'))).rejects.toThrow('Injected write failure');
|
||||||
// 队列链恢复:后续写成功
|
// 队列链恢复:后续写成功
|
||||||
await backend.write('ok', enc('fine'));
|
await backend.write('ok', enc('fine'));
|
||||||
expect(new TextDecoder().decode(await backend.read('ok'))).toBe('fine');
|
expect(decodeBytes(await backend.read('ok'))).toBe('fine');
|
||||||
await backend.close();
|
await backend.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,7 +135,7 @@ describe('AriaEngine — OPFSBackend v2', () => {
|
|||||||
expect(keys).toEqual(['data']);
|
expect(keys).toEqual(['data']);
|
||||||
expect(keys.some((k) => k.endsWith('.crswap') || k.endsWith('.tmp'))).toBe(false);
|
expect(keys.some((k) => k.endsWith('.crswap') || k.endsWith('.tmp'))).toBe(false);
|
||||||
// 正常数据不受影响
|
// 正常数据不受影响
|
||||||
expect(new TextDecoder().decode(await backend2.read('data'))).toBe('real');
|
expect(decodeBytes(await backend2.read('data'))).toBe('real');
|
||||||
await backend2.close();
|
await backend2.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,8 +143,8 @@ describe('AriaEngine — OPFSBackend v2', () => {
|
|||||||
const backend = new OPFSBackend();
|
const backend = new OPFSBackend();
|
||||||
await backend.open('opfs-test-8');
|
await backend.open('opfs-test-8');
|
||||||
await backend.writeMany({ a: enc('AAA'), b: enc('BBB') });
|
await backend.writeMany({ a: enc('AAA'), b: enc('BBB') });
|
||||||
expect(new TextDecoder().decode(await backend.read('a'))).toBe('AAA');
|
expect(decodeBytes(await backend.read('a'))).toBe('AAA');
|
||||||
expect(new TextDecoder().decode(await backend.read('b'))).toBe('BBB');
|
expect(decodeBytes(await backend.read('b'))).toBe('BBB');
|
||||||
await backend.deleteMany(['a']);
|
await backend.deleteMany(['a']);
|
||||||
expect(await backend.exists('a')).toBe(false);
|
expect(await backend.exists('a')).toBe(false);
|
||||||
expect(await backend.exists('b')).toBe(true);
|
expect(await backend.exists('b')).toBe(true);
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { PAGE_SIZE } from '../../src/engine/aria/types';
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
// OPFS mock(共享工具)
|
// OPFS mock(共享工具)
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
import { decode as decodeBytes } from '../helpers/assertions';
|
||||||
|
|
||||||
const SCHEMA = () => createSchema('users', {
|
const SCHEMA = () => createSchema('users', {
|
||||||
id: { type: 'string', primaryKey: true },
|
id: { type: 'string', primaryKey: true },
|
||||||
@@ -47,7 +48,7 @@ describe('AriaEngine — PageSSTableStore 单元', () => {
|
|||||||
|
|
||||||
const back = await store.load(1, pageIds, data.byteLength);
|
const back = await store.load(1, pageIds, data.byteLength);
|
||||||
expect(back).not.toBeNull();
|
expect(back).not.toBeNull();
|
||||||
expect(new TextDecoder().decode(back)).toBe('hello page store');
|
expect(decodeBytes(back)).toBe('hello page store');
|
||||||
|
|
||||||
// 页面已落盘(backend 有 pg_ 键)
|
// 页面已落盘(backend 有 pg_ 键)
|
||||||
expect(await backend.exists('pg_1')).toBe(true);
|
expect(await backend.exists('pg_1')).toBe(true);
|
||||||
@@ -130,7 +131,7 @@ describe('AriaEngine — PageSSTableStore 单元', () => {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
||||||
it('写入 → flush → close → reopen 数据完整(页面模式默认启用)', async () => {
|
it('写入 → flush → close → reopen 数据完整(页面模式默认启用)', async () => {
|
||||||
const files = installOPFSMock(new Map());
|
resetOPFSMock();
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
storageBackend: 'opfs',
|
storageBackend: 'opfs',
|
||||||
memtableSizeThreshold: 64 * 1024 * 1024,
|
memtableSizeThreshold: 64 * 1024 * 1024,
|
||||||
@@ -173,7 +174,7 @@ describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('多级 compaction 后页面化数据仍完整', async () => {
|
it('多级 compaction 后页面化数据仍完整', async () => {
|
||||||
const files = installOPFSMock(new Map());
|
resetOPFSMock();
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
storageBackend: 'opfs',
|
storageBackend: 'opfs',
|
||||||
memtableSizeThreshold: 16 * 1024,
|
memtableSizeThreshold: 16 * 1024,
|
||||||
@@ -204,7 +205,7 @@ describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('页面损坏(篡改 pg_ 文件)→ 打开自愈清理,其余数据可读', async () => {
|
it('页面损坏(篡改 pg_ 文件)→ 打开自愈清理,其余数据可读', async () => {
|
||||||
const files = installOPFSMock(new Map());
|
resetOPFSMock();
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
storageBackend: 'opfs',
|
storageBackend: 'opfs',
|
||||||
memtableSizeThreshold: 64 * 1024 * 1024,
|
memtableSizeThreshold: 64 * 1024 * 1024,
|
||||||
@@ -245,7 +246,7 @@ describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('页面模式与非页面模式混合兼容(pageIds 缺失 → 整 value 读取)', async () => {
|
it('页面模式与非页面模式混合兼容(pageIds 缺失 → 整 value 读取)', async () => {
|
||||||
const { files } = installOPFSMock(new Map());
|
const opfs = resetOPFSMock();
|
||||||
// 阶段 1:非页面模式写入(pageStorage: false → 整 value SSTable)
|
// 阶段 1:非页面模式写入(pageStorage: false → 整 value SSTable)
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
storageBackend: 'opfs',
|
storageBackend: 'opfs',
|
||||||
@@ -259,7 +260,7 @@ describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
|||||||
await (engine as any).lsm.flush();
|
await (engine as any).lsm.flush();
|
||||||
await engine.close();
|
await engine.close();
|
||||||
|
|
||||||
const backendKeys1 = Array.from(files.keys());
|
const backendKeys1 = await opfs.listKeys();
|
||||||
expect(backendKeys1.some((k) => k.startsWith('sst_'))).toBe(true);
|
expect(backendKeys1.some((k) => k.startsWith('sst_'))).toBe(true);
|
||||||
|
|
||||||
// 阶段 2:页面模式打开(默认 opfs → 启用),读旧数据 + 写新数据
|
// 阶段 2:页面模式打开(默认 opfs → 启用),读旧数据 + 写新数据
|
||||||
@@ -285,7 +286,7 @@ describe('AriaEngine — 页面化 SSTable 集成(OPFS)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('页面化 + 加密 + 压缩组合:往返完整', async () => {
|
it('页面化 + 加密 + 压缩组合:往返完整', async () => {
|
||||||
const files = installOPFSMock(new Map());
|
resetOPFSMock();
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
storageBackend: 'opfs',
|
storageBackend: 'opfs',
|
||||||
compression: true,
|
compression: true,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
import { SharedMemoryBackend } from '../../src/engine/kvstore/shared_memory_medium';
|
import { SharedMemoryBackend } from '../../src/engine/kvstore/shared_memory_medium';
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -30,7 +30,7 @@ const SCHEMA = () => createSchema('big', {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
SharedMemoryBackend.clearRegistry();
|
SharedMemoryBackend.clearRegistry();
|
||||||
installOPFSMock(new Map());
|
resetOPFSMock();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AriaEngine — 生产负载验证', () => {
|
describe('AriaEngine — 生产负载验证', () => {
|
||||||
@@ -317,6 +317,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
// 修复后本机 ~12.5s。CI(debian runner 慢 2~3 倍、重型套件串行)下
|
// 修复后本机 ~12.5s。CI(debian runner 慢 2~3 倍、重型套件串行)下
|
||||||
// 健康耗时约 30~80s;护栏放宽到 240s —— 仍能拦截性能悬崖回归(353s >> 240s),
|
// 健康耗时约 30~80s;护栏放宽到 240s —— 仍能拦截性能悬崖回归(353s >> 240s),
|
||||||
// 不误报健康慢环境。
|
// 不误报健康慢环境。
|
||||||
|
// eslint-disable-next-line no-console -- 性能护栏需要输出实测耗时
|
||||||
console.log(`10万行 kv 插入耗时: ${insertMs}ms`);
|
console.log(`10万行 kv 插入耗时: ${insertMs}ms`);
|
||||||
expect(insertMs).toBeLessThan(240000);
|
expect(insertMs).toBeLessThan(240000);
|
||||||
expect(await engine.count('big')).toBe(TOTAL);
|
expect(await engine.count('big')).toBe(TOTAL);
|
||||||
@@ -364,6 +365,7 @@ describe('AriaEngine — 生产负载验证', () => {
|
|||||||
}
|
}
|
||||||
const insertMs = Date.now() - t0;
|
const insertMs = Date.now() - t0;
|
||||||
// 同上:CI 慢环境护栏放宽(本机 ~25s;悬崖回归仍会被拦截)
|
// 同上:CI 慢环境护栏放宽(本机 ~25s;悬崖回归仍会被拦截)
|
||||||
|
// eslint-disable-next-line no-console -- 性能护栏需要输出实测耗时
|
||||||
console.log(`10万行 opfs 插入耗时: ${insertMs}ms`);
|
console.log(`10万行 opfs 插入耗时: ${insertMs}ms`);
|
||||||
expect(insertMs).toBeLessThan(300000);
|
expect(insertMs).toBeLessThan(300000);
|
||||||
expect(await engine.count('big')).toBe(TOTAL);
|
expect(await engine.count('big')).toBe(TOTAL);
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -65,20 +65,20 @@ describe('AriaEngine — repair 自愈增强', () => {
|
|||||||
|
|
||||||
it('清理 OPFS 残留临时文件(.crswap/.tmp)', async () => {
|
it('清理 OPFS 残留临时文件(.crswap/.tmp)', async () => {
|
||||||
// 用 OPFS mock 后端验证 cleanupStaleFiles 被调用
|
// 用 OPFS mock 后端验证 cleanupStaleFiles 被调用
|
||||||
const { files, dir } = installOPFSMock(new Map());
|
const opfs = resetOPFSMock();
|
||||||
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
const engine = new AriaEngine({ storageBackend: 'opfs', checkpointInterval: 100000 });
|
||||||
await engine.open('repair-opfs-1', 1);
|
await engine.open('repair-opfs-1', 1);
|
||||||
await engine.createTable(SCHEMA());
|
await engine.createTable(SCHEMA());
|
||||||
await engine.insert('items', [{ id: 'a', val: 1, tag: 'x' }]);
|
await engine.insert('items', [{ id: 'a', val: 1, tag: 'x' }]);
|
||||||
|
|
||||||
// 制造残留
|
// 制造残留(模拟 createWritable 中断留下的临时文件)
|
||||||
await dir.getFileHandle('junk.crswap', { create: true });
|
await opfs.createFile('junk.crswap');
|
||||||
await dir.getFileHandle('junk2.tmp', { create: true });
|
await opfs.createFile('junk2.tmp');
|
||||||
expect(Array.from(files.keys()).some((k) => k.endsWith('.crswap'))).toBe(true);
|
expect((await opfs.listKeys()).some((k) => k.endsWith('.crswap'))).toBe(true);
|
||||||
|
|
||||||
await (engine as any).repair();
|
await (engine as any).repair();
|
||||||
|
|
||||||
const after = Array.from(files.keys());
|
const after = await opfs.listKeys();
|
||||||
expect(after.some((k) => k.endsWith('.crswap'))).toBe(false);
|
expect(after.some((k) => k.endsWith('.crswap'))).toBe(false);
|
||||||
expect(after.some((k) => k.endsWith('.tmp'))).toBe(false);
|
expect(after.some((k) => k.endsWith('.tmp'))).toBe(false);
|
||||||
expect(await engine.count('items')).toBe(1);
|
expect(await engine.count('items')).toBe(1);
|
||||||
@@ -193,7 +193,7 @@ describe('AriaEngine — 随机操作压力 + 模拟崩溃', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('随机操作 + 页面化 + 模拟崩溃 → 重开验证', async () => {
|
it('随机操作 + 页面化 + 模拟崩溃 → 重开验证', async () => {
|
||||||
const files = installOPFSMock(new Map());
|
resetOPFSMock();
|
||||||
const dbName = `rand-opfs-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
const dbName = `rand-opfs-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
const engine = new AriaEngine({
|
const engine = new AriaEngine({
|
||||||
storageBackend: 'opfs',
|
storageBackend: 'opfs',
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import { MemoryBackend } from '../../src/engine/aria/store/backend';
|
|||||||
import { AriaEngine } from '../../src/engine/aria/index';
|
import { AriaEngine } from '../../src/engine/aria/index';
|
||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { AriaEngine } from '../../src/engine/aria/index';
|
|||||||
import { createSchema } from '../../src/table/schema';
|
import { createSchema } from '../../src/table/schema';
|
||||||
import { MetonaSqlark } from '../../src/core';
|
import { MetonaSqlark } from '../../src/core';
|
||||||
|
|
||||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
import { resetOPFSMock } from '../helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// AriaEngine 引擎级测试 (Memory Backend)
|
// AriaEngine 引擎级测试 (Memory Backend)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function uniqueDB(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enc = (s: string) => new TextEncoder().encode(s).buffer as ArrayBuffer;
|
const enc = (s: string) => new TextEncoder().encode(s).buffer as ArrayBuffer;
|
||||||
const dec = (b: ArrayBuffer | null) => (b ? new TextDecoder().decode(b) : null);
|
const dec = (b: ArrayBuffer | null | undefined) => (b ? new TextDecoder().decode(b) : null);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
SharedMemoryBackend.clearRegistry();
|
SharedMemoryBackend.clearRegistry();
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ describe('v0.1.13 外键级联 — ON DELETE CASCADE', () => {
|
|||||||
|
|
||||||
it('删除没有订单的用户 → 不会级联删除', async () => {
|
it('删除没有订单的用户 → 不会级联删除', async () => {
|
||||||
// 先查总数
|
// 先查总数
|
||||||
const userCount = await db.table('users').count();
|
const _userCount = await db.table('users').count();
|
||||||
const orderCount = await db.table('orders').count();
|
const orderCount = await db.table('orders').count();
|
||||||
|
|
||||||
// 删除一个没有任何订单的用户(创建临时用户)
|
// 删除一个没有任何订单的用户(创建临时用户)
|
||||||
|
|||||||
+13
-4
@@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
import { parse } from '../src/sql/parser';
|
import { parse } from '../src/sql/parser';
|
||||||
|
import type { SelectStatement } from '../src/query/ast';
|
||||||
|
|
||||||
|
/** v0.8.0: parse() 返回联合类型 Statement,需显式收窄后才能访问 SELECT 字段 */
|
||||||
|
function asSelect(stmt: ReturnType<typeof parse>): SelectStatement {
|
||||||
|
expect(stmt.type).toBe('SELECT');
|
||||||
|
if (stmt.type !== 'SELECT') throw new Error(`expected SELECT, got ${stmt.type}`);
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
describe('GROUP BY (v0.1.2)', () => {
|
describe('GROUP BY (v0.1.2)', () => {
|
||||||
let db: MetonaSqlark;
|
let db: MetonaSqlark;
|
||||||
@@ -101,18 +109,19 @@ describe('GROUP BY (v0.1.2)', () => {
|
|||||||
|
|
||||||
describe('Parser', () => {
|
describe('Parser', () => {
|
||||||
it('解析 COUNT(*)', () => {
|
it('解析 COUNT(*)', () => {
|
||||||
const ast = parse('SELECT COUNT(*) FROM employees');
|
const ast = asSelect(parse('SELECT COUNT(*) FROM employees'));
|
||||||
expect(ast.columns[0]).toBe('COUNT(*)');
|
expect(ast.columns[0]).toBe('COUNT(*)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('解析 GROUP BY', () => {
|
it('解析 GROUP BY', () => {
|
||||||
const ast = parse('SELECT dept, COUNT(*) FROM employees GROUP BY dept');
|
const ast = asSelect(parse('SELECT dept, COUNT(*) FROM employees GROUP BY dept'));
|
||||||
expect(ast.groupBy).toEqual(['dept']);
|
expect(ast.groupBy).toEqual(['dept']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('解析 HAVING', () => {
|
it('解析 HAVING', () => {
|
||||||
const ast = parse('SELECT dept, COUNT(*) FROM employees GROUP BY dept HAVING COUNT(*) > 1');
|
const ast = asSelect(parse('SELECT dept, COUNT(*) FROM employees GROUP BY dept HAVING COUNT(*) > 1'));
|
||||||
expect(ast.having).toBeDefined();
|
// v0.8.0: 断言真实结构而非 toBeDefined()
|
||||||
|
expect(ast.having).toEqual({ 'COUNT(*)': { $gt: 1 } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 测试共享 — 类型化断言辅助(v0.8.0)
|
||||||
|
*
|
||||||
|
* 背景:v0.8.0 起 tests/ 纳入类型检查(tsconfig.test.json)。测试里最常见的两类
|
||||||
|
* 类型错误是:
|
||||||
|
* 1. `backend.read(key)` 返回 `ArrayBuffer | null` → 直接喂给 TextDecoder 报错;
|
||||||
|
* 2. `db.query()` 返回 `unknown` → 取 `.length` / `[0].field` 报错。
|
||||||
|
* 旧做法是 `as any`,那等于把类型检查关掉;这里提供**断言 + 收窄**的辅助函数:
|
||||||
|
* 断言失败会明确报出"期望非空",而不是让后续断言落空。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* expect(decode(await backend.read('k'))).toBe('v');
|
||||||
|
* const rows = rows<{ id: string }>(await db.query('SELECT * FROM t'));
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 断言非空并收窄(失败信息指向调用点,便于定位) */
|
||||||
|
export function nonNull<T>(value: T | null | undefined, what = 'value'): T {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
throw new Error(`expected ${what} to be non-null, got ${String(value)}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解码字节为字符串(自动处理 null → 明确失败) */
|
||||||
|
export function decode(value: ArrayBuffer | Uint8Array | null | undefined, what = 'buffer'): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
throw new Error(`expected ${what} to be non-null for decoding`);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(value instanceof Uint8Array ? value : new Uint8Array(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 query/executor 的 unknown 结果收窄为行数组 */
|
||||||
|
export function rows<T = Record<string, unknown>>(value: unknown): T[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error(`expected query result to be an array, got ${typeof value}`);
|
||||||
|
}
|
||||||
|
return value as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 query 结果收窄为单行(断言至少一行并返回首行) */
|
||||||
|
export function firstRow<T = Record<string, unknown>>(value: unknown): T {
|
||||||
|
const list = rows<T>(value);
|
||||||
|
if (list.length === 0) throw new Error('expected at least one row, got empty result');
|
||||||
|
return list[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 query 结果收窄为对象(EXPLAIN 等返回单对象的场景) */
|
||||||
|
export function object<T = Record<string, unknown>>(value: unknown): T {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
throw new Error(`expected an object result, got ${Array.isArray(value) ? 'array' : typeof value}`);
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断言并收窄错误码(替代 `catch { threw = true }` 这种无法区分原因的写法) */
|
||||||
|
export async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
const actual = (error as { code?: string }).code;
|
||||||
|
if (actual !== code) {
|
||||||
|
throw new Error(`expected error code "${code}", got "${actual ?? '(none)'}": ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`expected rejection with code "${code}", but the promise resolved`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取用引擎上的**可选**方法(如 clearAll / backup / repair / createIndex)。
|
||||||
|
*
|
||||||
|
* `IStorageEngine` 把这些方法声明为可选,直接 `engine.clearAll()` 在类型上不成立。
|
||||||
|
* 测试里需要它们时用本函数:缺失即明确失败,而不是运行时 TypeError。
|
||||||
|
*/
|
||||||
|
export function engineMethod<M>(engine: unknown, name: string): M {
|
||||||
|
const fn = (engine as Record<string, unknown> | null)?.[name];
|
||||||
|
if (typeof fn !== 'function') {
|
||||||
|
throw new Error(`engine is missing required test method "${name}"`);
|
||||||
|
}
|
||||||
|
return (fn as (...a: unknown[]) => unknown).bind(engine) as M;
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 测试共享 — 故障注入后端包装器(v0.8.0 工作流 C-1)
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 为什么需要它(PLAN-v0.7.5.md 根因 5)
|
||||||
|
* ============================================================================
|
||||||
|
* 项目所有"崩溃恢复"测试用的都是 `await engine.backend.close()`,而 OPFSBackend.close()
|
||||||
|
* 会 `await this.writeQueue` 把在途写**全部刷完** —— 那是优雅停机,不是崩溃。
|
||||||
|
* 加上 mock 永远原子、绝不撕裂,崩溃相关的声称在结构上无法被验证。
|
||||||
|
*
|
||||||
|
* 本包装器让任意 IStorageBackend 具备可编程故障:
|
||||||
|
* - failNextWrite(n) / failNextAppend(n) / failNextDelete(n) 抛错注入
|
||||||
|
* - truncateNextAppendTo(n) 撕裂写(只落前 n 字节)
|
||||||
|
* - dropNextAppend(n) / dropNextWrite(n) 静默丢弃(模拟掉电丢失)
|
||||||
|
* - corruptNextWrite(mutator) 写入落盘后篡改字节
|
||||||
|
* - crash() 丢弃未提交写(委托介质)
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const faulty = new FaultyBackend(new OPFSBackend());
|
||||||
|
* await faulty.open('db');
|
||||||
|
* faulty.failNextAppend(); // 下一次追加失败
|
||||||
|
* ... 触发写入 ...
|
||||||
|
* await expect(...).rejects.toThrow();
|
||||||
|
* faulty.clearFaults();
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IStorageBackend } from '../../src/engine/aria/store/backend';
|
||||||
|
|
||||||
|
type Mutator = (bytes: Uint8Array) => void;
|
||||||
|
|
||||||
|
interface Faults {
|
||||||
|
failWrite: number;
|
||||||
|
failAppend: number;
|
||||||
|
failDelete: number;
|
||||||
|
dropWrite: number;
|
||||||
|
dropAppend: number;
|
||||||
|
truncateAppendTo: number;
|
||||||
|
corruptWrite: Mutator | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支持崩溃模拟的介质(OPFS mock / TransactionalFileStore 等) */
|
||||||
|
export interface CrashableMedium {
|
||||||
|
crashPending(): void;
|
||||||
|
hasPending(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支持崩溃模拟的后端(OPFSBackend 等在 v0.8.0 提供了 simulateCrash) */
|
||||||
|
export interface CrashableBackend {
|
||||||
|
simulateCrash(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCrashableMedium(m: unknown): m is CrashableMedium {
|
||||||
|
return typeof (m as CrashableMedium)?.crashPending === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCrashableBackend(m: unknown): m is CrashableBackend {
|
||||||
|
return typeof (m as CrashableBackend)?.simulateCrash === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FaultyBackend implements IStorageBackend {
|
||||||
|
private readonly inner: IStorageBackend;
|
||||||
|
private faults: Faults = {
|
||||||
|
failWrite: 0,
|
||||||
|
failAppend: 0,
|
||||||
|
failDelete: 0,
|
||||||
|
dropWrite: 0,
|
||||||
|
dropAppend: 0,
|
||||||
|
truncateAppendTo: -1,
|
||||||
|
corruptWrite: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 注入统计(测试可断言注入真的生效了,避免"注入了但没走到"的假绿) */
|
||||||
|
readonly injected = { write: 0, append: 0, delete: 0, dropped: 0, truncated: 0, corrupted: 0 };
|
||||||
|
|
||||||
|
constructor(inner: IStorageBackend) {
|
||||||
|
this.inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 暴露内层(需要访问具体 backing store 时使用,例如 crash()) */
|
||||||
|
unwrap<T extends IStorageBackend>(): T {
|
||||||
|
return this.inner as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 故障注入 API ----
|
||||||
|
|
||||||
|
failNextWrite(n = 1): void { this.faults.failWrite = n; }
|
||||||
|
failNextAppend(n = 1): void { this.faults.failAppend = n; }
|
||||||
|
failNextDelete(n = 1): void { this.faults.failDelete = n; }
|
||||||
|
/** 静默丢弃接下来 n 次 write(不抛错、"看起来成功",模拟掉电丢失) */
|
||||||
|
dropNextWrite(n = 1): void { this.faults.dropWrite = n; }
|
||||||
|
/** 静默丢弃接下来 n 次 append */
|
||||||
|
dropNextAppend(n = 1): void { this.faults.dropAppend = n; }
|
||||||
|
/** 下一次 append 只落前 n 字节(撕裂写) */
|
||||||
|
truncateNextAppendTo(n: number): void { this.faults.truncateAppendTo = n; }
|
||||||
|
/** 下一次写入的字节落盘后被就地篡改(模拟 bit flip) */
|
||||||
|
corruptNextWrite(mutator: Mutator): void { this.faults.corruptWrite = mutator; }
|
||||||
|
|
||||||
|
clearFaults(): void {
|
||||||
|
this.faults = {
|
||||||
|
failWrite: 0, failAppend: 0, failDelete: 0,
|
||||||
|
dropWrite: 0, dropAppend: 0, truncateAppendTo: -1, corruptWrite: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟崩溃:优先让内层后端自己处理(OPFSBackend.simulateCrash 会丢弃未提交的 swap 写入),
|
||||||
|
* 否则若内层介质本身可崩溃(TransactionalFileStore 等)则直接委托。
|
||||||
|
*
|
||||||
|
* 注意:**不要**用 inner.close() 代替崩溃 —— 那会把写队列刷完(优雅停机)。
|
||||||
|
* 返回 false 表示"该后端无法模拟崩溃",调用方应据此改用丢写注入表达崩溃。
|
||||||
|
*/
|
||||||
|
crash(): boolean {
|
||||||
|
if (isCrashableBackend(this.inner)) {
|
||||||
|
this.inner.simulateCrash();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isCrashableMedium(this.inner)) {
|
||||||
|
this.inner.crashPending();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IStorageBackend 委托 ----
|
||||||
|
|
||||||
|
open(name: string): Promise<void> {
|
||||||
|
return this.inner.open(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.inner.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen(): boolean {
|
||||||
|
return this.inner.isOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
read(key: string): Promise<ArrayBuffer | null> {
|
||||||
|
return this.inner.read(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
if (this.faults.dropWrite > 0) {
|
||||||
|
this.faults.dropWrite--;
|
||||||
|
this.injected.dropped++;
|
||||||
|
return; // 静默成功但没落盘
|
||||||
|
}
|
||||||
|
if (this.faults.failWrite > 0) {
|
||||||
|
this.faults.failWrite--;
|
||||||
|
this.injected.write++;
|
||||||
|
throw new Error(`[FaultyBackend] injected write failure: ${key}`);
|
||||||
|
}
|
||||||
|
await this.inner.write(key, data);
|
||||||
|
if (this.faults.corruptWrite) {
|
||||||
|
const mutator = this.faults.corruptWrite;
|
||||||
|
this.faults.corruptWrite = null;
|
||||||
|
const stored = await this.inner.read(key);
|
||||||
|
if (stored) {
|
||||||
|
mutator(new Uint8Array(stored));
|
||||||
|
await this.inner.write(key, stored);
|
||||||
|
this.injected.corrupted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async append(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
if (this.faults.dropAppend > 0) {
|
||||||
|
this.faults.dropAppend--;
|
||||||
|
this.injected.dropped++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.faults.failAppend > 0) {
|
||||||
|
this.faults.failAppend--;
|
||||||
|
this.injected.append++;
|
||||||
|
throw new Error(`[FaultyBackend] injected append failure: ${key}`);
|
||||||
|
}
|
||||||
|
if (this.faults.truncateAppendTo >= 0) {
|
||||||
|
const n = this.faults.truncateAppendTo;
|
||||||
|
this.faults.truncateAppendTo = -1;
|
||||||
|
this.injected.truncated++;
|
||||||
|
// 撕裂写:只把前 n 字节交给介质
|
||||||
|
// (不用 ArrayBuffer.slice —— jsdom 下可能被 Blob.slice 语义遮蔽,见 storage-harness 注释)
|
||||||
|
const len = Math.max(0, Math.min(n, data.byteLength));
|
||||||
|
const torn = new Uint8Array(len);
|
||||||
|
torn.set(new Uint8Array(data, 0, len));
|
||||||
|
const tornBuf = torn.buffer as ArrayBuffer;
|
||||||
|
if (typeof this.inner.append === 'function') {
|
||||||
|
await this.inner.append(key, tornBuf);
|
||||||
|
} else {
|
||||||
|
const existing = await this.inner.read(key);
|
||||||
|
const merged = new Uint8Array((existing?.byteLength ?? 0) + tornBuf.byteLength);
|
||||||
|
if (existing) merged.set(new Uint8Array(existing), 0);
|
||||||
|
merged.set(new Uint8Array(tornBuf), existing?.byteLength ?? 0);
|
||||||
|
await this.inner.write(key, merged.buffer as ArrayBuffer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof this.inner.append === 'function') {
|
||||||
|
await this.inner.append(key, data);
|
||||||
|
} else {
|
||||||
|
const existing = await this.inner.read(key);
|
||||||
|
const merged = new Uint8Array((existing?.byteLength ?? 0) + data.byteLength);
|
||||||
|
if (existing) merged.set(new Uint8Array(existing), 0);
|
||||||
|
merged.set(new Uint8Array(data), existing?.byteLength ?? 0);
|
||||||
|
await this.inner.write(key, merged.buffer as ArrayBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
|
||||||
|
// 逐 key 走出本包装的 write,使注入对批量写同样生效
|
||||||
|
for (const [key, data] of Object.entries(entries)) {
|
||||||
|
await this.write(key, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
if (this.faults.failDelete > 0) {
|
||||||
|
this.faults.failDelete--;
|
||||||
|
this.injected.delete++;
|
||||||
|
throw new Error(`[FaultyBackend] injected delete failure: ${key}`);
|
||||||
|
}
|
||||||
|
await this.inner.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMany(keys: string[]): Promise<void> {
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listKeys(): Promise<string[]> {
|
||||||
|
return this.inner.listKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
exists(key: string): Promise<boolean> {
|
||||||
|
return this.inner.exists(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): Promise<void> {
|
||||||
|
return this.inner.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* 测试共享 — 真实语义 OPFS mock
|
|
||||||
*
|
|
||||||
* 模拟 OPFS 的完整语义:
|
|
||||||
* - createWritable({ keepExistingData }) + position 追加写
|
|
||||||
* - 文件内容以 ArrayBuffer 存储(跨 backend 实例共享于同一 Map)
|
|
||||||
* - entries() 迭代、removeEntry 删除
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* const files = installOPFSMock(new Map());
|
|
||||||
* const backend = new OPFSBackend();
|
|
||||||
* await backend.open('any');
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface MockOpfsFile {
|
|
||||||
content: ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installOPFSMock(files: Map<string, MockOpfsFile>): {
|
|
||||||
files: Map<string, MockOpfsFile>;
|
|
||||||
dir: {
|
|
||||||
getFileHandle: (name: string, opts?: { create?: boolean }) => Promise<{
|
|
||||||
getFile: () => Promise<{ size: number; arrayBuffer: () => Promise<ArrayBuffer> }>;
|
|
||||||
createWritable: (wOpts?: { keepExistingData?: boolean }) => Promise<{
|
|
||||||
write: (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => Promise<void>;
|
|
||||||
close: () => Promise<void>;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
entries: () => AsyncGenerator<[string]>;
|
|
||||||
removeEntry: (name: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
} {
|
|
||||||
const getFileHandle = async (name: string, opts?: { create?: boolean }) => {
|
|
||||||
if (!files.has(name)) {
|
|
||||||
if (!opts?.create) throw new Error(`NotFoundError: ${name}`);
|
|
||||||
files.set(name, { content: new ArrayBuffer(0) });
|
|
||||||
}
|
|
||||||
const entry = files.get(name)!;
|
|
||||||
return {
|
|
||||||
getFile: async () => ({ size: entry.content.byteLength, arrayBuffer: async () => entry.content }),
|
|
||||||
createWritable: async (wOpts?: { keepExistingData?: boolean }) => {
|
|
||||||
const w: {
|
|
||||||
write: (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => Promise<void>;
|
|
||||||
close: () => Promise<void>;
|
|
||||||
} = {
|
|
||||||
write: async (arg) => {
|
|
||||||
const keepExisting = wOpts?.keepExistingData ?? false;
|
|
||||||
const chunk = (arg as { type?: string }).type === 'write'
|
|
||||||
? { data: (arg as { data: ArrayBuffer }).data, position: (arg as { position: number }).position }
|
|
||||||
: { data: arg as ArrayBuffer, position: keepExisting ? entry.content.byteLength : 0 };
|
|
||||||
const merged = new Uint8Array(chunk.position + chunk.data.byteLength);
|
|
||||||
if (keepExisting || chunk.position > 0) {
|
|
||||||
merged.set(new Uint8Array(entry.content), 0);
|
|
||||||
}
|
|
||||||
merged.set(new Uint8Array(chunk.data), chunk.position);
|
|
||||||
entry.content = merged.buffer;
|
|
||||||
},
|
|
||||||
close: async () => { /* no-op */ },
|
|
||||||
};
|
|
||||||
return w;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const dir = {
|
|
||||||
getFileHandle,
|
|
||||||
entries: async function* () {
|
|
||||||
for (const [name] of files) yield [name];
|
|
||||||
},
|
|
||||||
removeEntry: async (name: string) => {
|
|
||||||
files.delete(name);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
|
||||||
value: {
|
|
||||||
storage: {
|
|
||||||
getDirectory: async () => ({
|
|
||||||
getDirectoryHandle: async (_name: string, _opts?: unknown) => dir,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { files, dir };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/**
|
||||||
|
* 故障注入基座自测(v0.8.0 工作流 C-1)
|
||||||
|
*
|
||||||
|
* 这些测试的作用不是测产品代码,而是**证明测试基座本身是有效的**:
|
||||||
|
* 如果故障注入不能稳定复现"崩溃丢数据",那么后面所有崩溃相关测试都是空头支票。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TransactionalFileStore, installOPFSMock, clearRegistry, CrashableStoreBackend } from './storage-harness';
|
||||||
|
import { FaultyBackend } from './faulty-backend';
|
||||||
|
import { OPFSBackend } from '../../src/engine/aria/store/opfs_backend';
|
||||||
|
import { decode as decodeBytes } from './assertions';
|
||||||
|
|
||||||
|
describe('[v0.8.0] 测试基座:TransactionalFileStore', () => {
|
||||||
|
let store: TransactionalFileStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clearRegistry();
|
||||||
|
store = new TransactionalFileStore({ dbName: 'unit' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('提交语义:未提交的写入对读不可见(真实 OPFS 是 close 才原子替换)', async () => {
|
||||||
|
await store.write('k', new TextEncoder().encode('v1').buffer as ArrayBuffer);
|
||||||
|
expect(await store.read('k')).toBeNull(); // 未 commit → 读不到
|
||||||
|
store.commitAll();
|
||||||
|
expect(decodeBytes(await store.read('k'))).toBe('v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('崩溃语义:丢弃未提交写入,已提交内容保持', async () => {
|
||||||
|
await store.write('k', new TextEncoder().encode('committed').buffer as ArrayBuffer);
|
||||||
|
store.commitAll();
|
||||||
|
|
||||||
|
await store.write('k', new TextEncoder().encode('pending').buffer as ArrayBuffer);
|
||||||
|
expect(store.hasPending()).toBe(true);
|
||||||
|
store.crashPending(); // 崩溃:丢掉 pending
|
||||||
|
|
||||||
|
expect(decodeBytes(await store.read('k'))).toBe('committed');
|
||||||
|
expect(store.hasPending()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('读返回副本:调用方原地修改不会污染"磁盘"(旧 mock 的缺陷 1)', async () => {
|
||||||
|
await store.write('k', new TextEncoder().encode('original').buffer as ArrayBuffer);
|
||||||
|
store.commitAll();
|
||||||
|
|
||||||
|
const first = await store.read('k');
|
||||||
|
new Uint8Array(first!)[0] = 0x58; // 'X'
|
||||||
|
|
||||||
|
const second = await store.read('k');
|
||||||
|
expect(decodeBytes(second)).toBe('original');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('故障注入:failNextWrite / failNextAppend / failNextDelete 精确计数', async () => {
|
||||||
|
store.failNextWrite();
|
||||||
|
await expect(store.write('k', new ArrayBuffer(4))).rejects.toThrow(/injected write failure/);
|
||||||
|
await expect(store.write('k', new ArrayBuffer(4))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
store.failNextAppend(2);
|
||||||
|
await expect(store.append('k', new ArrayBuffer(2))).rejects.toThrow(/injected append failure/);
|
||||||
|
await expect(store.append('k', new ArrayBuffer(2))).rejects.toThrow(/injected append failure/);
|
||||||
|
await expect(store.append('k', new ArrayBuffer(2))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
store.failNextDelete();
|
||||||
|
await expect(store.delete('k')).rejects.toThrow(/injected delete failure/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('撕裂写:truncateNextAppendTo 只落前 n 字节', async () => {
|
||||||
|
const text = new TextEncoder();
|
||||||
|
await store.write('log', text.encode('AAAA').buffer as ArrayBuffer);
|
||||||
|
store.commitAll();
|
||||||
|
|
||||||
|
store.truncateNextAppendTo(2);
|
||||||
|
await store.append('log', text.encode('BBBB').buffer as ArrayBuffer);
|
||||||
|
store.commitAll();
|
||||||
|
|
||||||
|
// 已提交的 'AAAA' 保留 + 撕裂后只追加了 'BB' → 'AAAABB'
|
||||||
|
expect(decodeBytes(await store.read('log'))).toBe('AAAABB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('篡改已提交字节:corruptCommitted 用于 bit-flip 场景', async () => {
|
||||||
|
await store.write('k', new TextEncoder().encode('hello').buffer as ArrayBuffer);
|
||||||
|
store.commitAll();
|
||||||
|
|
||||||
|
const ok = store.corruptCommitted('k', (bytes) => { bytes[0] ^= 0xff; });
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
const after = new Uint8Array((await store.read('k'))!);
|
||||||
|
expect(after[0]).not.toBe('h'.charCodeAt(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('提交原子性:commitAll 一次性生效全部 pending', async () => {
|
||||||
|
await store.write('a', new ArrayBuffer(1));
|
||||||
|
await store.write('b', new ArrayBuffer(1));
|
||||||
|
expect(await store.listKeys()).toEqual(['a', 'b']);
|
||||||
|
store.commitAll();
|
||||||
|
expect((await store.listKeys()).sort()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.8.0] 测试基座:installOPFSMock 真实语义', () => {
|
||||||
|
beforeEach(() => clearRegistry());
|
||||||
|
|
||||||
|
test('keepExistingData:false 会截断(旧 mock 的缺陷 2)', async () => {
|
||||||
|
installOPFSMock('trunc');
|
||||||
|
const backend = new OPFSBackend();
|
||||||
|
await backend.open('trunc');
|
||||||
|
await backend.write('f', new TextEncoder().encode('LONGCONTENT').buffer as ArrayBuffer);
|
||||||
|
|
||||||
|
// 覆盖写更短内容 → 真实 OPFS 应截断为 5 字节
|
||||||
|
await backend.write('f', new TextEncoder().encode('short').buffer as ArrayBuffer);
|
||||||
|
const read = await backend.read('f');
|
||||||
|
expect(decodeBytes(read)).toBe('short');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('append 是真追加且不破坏已有内容', async () => {
|
||||||
|
installOPFSMock('app');
|
||||||
|
const backend = new OPFSBackend();
|
||||||
|
await backend.open('app');
|
||||||
|
await backend.write('log', new TextEncoder().encode('AAA').buffer as ArrayBuffer);
|
||||||
|
await backend.append('log', new TextEncoder().encode('BBB').buffer as ArrayBuffer);
|
||||||
|
expect(decodeBytes(await backend.read('log'))).toBe('AAABBB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('读返回副本,跨 backend 实例隔离', async () => {
|
||||||
|
installOPFSMock('iso');
|
||||||
|
const b1 = new OPFSBackend();
|
||||||
|
await b1.open('iso');
|
||||||
|
await b1.write('k', new TextEncoder().encode('v').buffer as ArrayBuffer);
|
||||||
|
|
||||||
|
const b2 = new OPFSBackend();
|
||||||
|
await b2.open('iso');
|
||||||
|
const buf = await b2.read('k');
|
||||||
|
new Uint8Array(buf!)[0] = 0x5a;
|
||||||
|
|
||||||
|
expect(decodeBytes(await b1.read('k'))).toBe('v');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('崩溃后重开:已提交数据在,未提交的不在', async () => {
|
||||||
|
const installed = installOPFSMock('crash');
|
||||||
|
const store = installed.store;
|
||||||
|
store.restoreCommitted(new Map()); // 清空
|
||||||
|
|
||||||
|
await store.write('committed', new TextEncoder().encode('yes').buffer as ArrayBuffer);
|
||||||
|
store.commitAll();
|
||||||
|
await store.write('pending', new TextEncoder().encode('no').buffer as ArrayBuffer);
|
||||||
|
store.crashPending();
|
||||||
|
|
||||||
|
expect(decodeBytes(await store.read('committed'))).toBe('yes');
|
||||||
|
expect(await store.read('pending')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.8.0] 测试基座:FaultyBackend', () => {
|
||||||
|
beforeEach(() => clearRegistry());
|
||||||
|
|
||||||
|
test('注入的写失败必须真的让调用方拿到 rejection', async () => {
|
||||||
|
installOPFSMock('faulty');
|
||||||
|
const faulty = new FaultyBackend(new OPFSBackend());
|
||||||
|
await faulty.open('faulty');
|
||||||
|
|
||||||
|
faulty.failNextWrite();
|
||||||
|
await expect(faulty.write('k', new ArrayBuffer(4))).rejects.toThrow(/injected write failure/);
|
||||||
|
expect(faulty.injected.write).toBe(1);
|
||||||
|
|
||||||
|
faulty.clearFaults();
|
||||||
|
await expect(faulty.write('k', new ArrayBuffer(4))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('静默丢弃写:调用方看到成功,但介质上没有(掉电场景)', async () => {
|
||||||
|
installOPFSMock('drop');
|
||||||
|
const faulty = new FaultyBackend(new OPFSBackend());
|
||||||
|
await faulty.open('drop');
|
||||||
|
|
||||||
|
faulty.dropNextWrite();
|
||||||
|
await expect(faulty.write('k', new ArrayBuffer(4))).resolves.toBeUndefined();
|
||||||
|
expect(await faulty.read('k')).toBeNull(); // 静默丢失
|
||||||
|
expect(faulty.injected.dropped).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('撕裂追加:写到一半的 WAL 记录可被构造', async () => {
|
||||||
|
installOPFSMock('torn');
|
||||||
|
const faulty = new FaultyBackend(new OPFSBackend());
|
||||||
|
await faulty.open('torn');
|
||||||
|
|
||||||
|
const record = new Uint8Array(100).fill(7);
|
||||||
|
faulty.truncateNextAppendTo(37);
|
||||||
|
await faulty.append('__wal_000000.bin', record.buffer as ArrayBuffer);
|
||||||
|
|
||||||
|
const stored = await faulty.read('__wal_000000.bin');
|
||||||
|
expect(stored!.byteLength).toBe(37);
|
||||||
|
expect(faulty.injected.truncated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crash() 丢弃未提交写(这才是崩溃,不是 close 优雅停机)', async () => {
|
||||||
|
const store = new TransactionalFileStore({ dbName: 'crash2' });
|
||||||
|
const backend = new CrashableStoreBackend(store);
|
||||||
|
const faulty = new FaultyBackend(backend);
|
||||||
|
|
||||||
|
const text = new TextEncoder();
|
||||||
|
await faulty.write('saved', text.encode('KEEP').buffer as ArrayBuffer);
|
||||||
|
backend.commit(); // 模拟 createWritable.close() 完成
|
||||||
|
|
||||||
|
await faulty.write('unsaved', text.encode('LOST').buffer as ArrayBuffer);
|
||||||
|
expect(store.hasPending()).toBe(true); // 存在真实的"提交前崩溃窗口"
|
||||||
|
|
||||||
|
expect(faulty.crash()).toBe(true);
|
||||||
|
|
||||||
|
expect(store.hasPending()).toBe(false);
|
||||||
|
expect(await store.read('unsaved')).toBeNull();
|
||||||
|
expect(decodeBytes(await store.read('saved'))).toBe('KEEP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对照组:close() 是优雅停机,会把未提交写刷完 —— 因此不能用来模拟崩溃', async () => {
|
||||||
|
const store = new TransactionalFileStore({ dbName: 'graceful' });
|
||||||
|
const backend = new CrashableStoreBackend(store);
|
||||||
|
|
||||||
|
await backend.write('k', new TextEncoder().encode('PENDING').buffer as ArrayBuffer);
|
||||||
|
expect(store.hasPending()).toBe(true);
|
||||||
|
|
||||||
|
// close() 不丢弃 pending(真实 OPFSBackend.close 还会等写队列排空)
|
||||||
|
await backend.close();
|
||||||
|
expect(store.hasPending()).toBe(true);
|
||||||
|
|
||||||
|
// 提交后才可见 —— 证明"用 close 当崩溃"会掩盖崩溃窗口
|
||||||
|
backend.commit();
|
||||||
|
expect(decodeBytes(await store.read('k'))).toBe('PENDING');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
/**
|
||||||
|
* 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版)
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1)
|
||||||
|
* ============================================================================
|
||||||
|
* 旧 mock(tests/helpers/opfs-mock.ts)有三处与真实 OPFS 语义不符,会掩盖真实缺陷:
|
||||||
|
* 1. `read()` 返回内部 ArrayBuffer **引用**(真实 OPFS 返回快照副本)
|
||||||
|
* → 调用方原地修改会"污染磁盘",而真实环境不会;
|
||||||
|
* 2. `write()` 中 `if (keepExisting || position > 0)` 会在 keepExistingData:false
|
||||||
|
* 时也保留旧字节(真实 OPFS 该场景应截断);
|
||||||
|
* 3. `close()` 是空函数、写入**立即对读可见**(真实 OPFS 是写 swap 文件、
|
||||||
|
* close 时才原子替换)。因此"提交前可见"这类缺陷在旧 mock 下永远测不出来。
|
||||||
|
*
|
||||||
|
* 更关键的是:**没有任何 mock 能表达"半写/撕裂/丢失一次写/部分删除失败"**,
|
||||||
|
* 而项目所有"崩溃恢复"测试用的都是 `backend.close()`(优雅停机,会把写队列刷完),
|
||||||
|
* 于是崩溃相关的声称在结构上无法被验证。
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 本实现的两层设计
|
||||||
|
* ============================================================================
|
||||||
|
* 第 1 层 `TransactionalFileStore`:
|
||||||
|
* - 纯数据 + 字节级故障注入 + 崩溃模拟(commit/discard),**不依赖任何浏览器 API**。
|
||||||
|
* 可直接用于单元测试:`store.write('k', buf); store.crashPending(); store.commitAll();`
|
||||||
|
* - 忠实实现 OPFS 的写语义:createWritable 后写入进入 pending 区,close 时原子提交;
|
||||||
|
* crash 丢弃全部 pending,已提交内容保持崩溃前状态。
|
||||||
|
*
|
||||||
|
* 第 2 层 `installOPFSMock()`:
|
||||||
|
* - 把 store 包装成 `navigator.storage.getDirectory()` 的 OPFS 门面,供 OPFSBackend 使用。
|
||||||
|
* - 读返回**副本**(不再泄漏内部引用)、keepExistingData:false 时截断、
|
||||||
|
* close 前不可见(与真实 OPFS 一致)。
|
||||||
|
*
|
||||||
|
* 故障注入 API(供故障矩阵测试使用):
|
||||||
|
* - `failNextWrite(n)` / `failNextAppend(n)` / `failNextDelete(n)`
|
||||||
|
* - `truncateNextAppendTo(n)` —— 追加只写入前 n 字节(撕裂写)
|
||||||
|
* - `crashPending()` —— 丢弃全部未提交写入(模拟进程崩溃)
|
||||||
|
* - `commitAll()` —— 提交全部 pending(模拟 createWritable.close 全部完成)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IStorageBackend } from '../../src/engine/aria/store/backend';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 第 1 层:事务性文件存储(无浏览器依赖,可直接单测)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TransactionalFileStoreOptions {
|
||||||
|
/** 库名(仅用于日志/调试) */
|
||||||
|
dbName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次写入的故障注入配置 */
|
||||||
|
interface FaultConfig {
|
||||||
|
failWrite: number;
|
||||||
|
failAppend: number;
|
||||||
|
failDelete: number;
|
||||||
|
/** 追加写只落盘前 N 字节(撕裂写);-1 表示不启用 */
|
||||||
|
truncateAppendTo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransactionalFileStore {
|
||||||
|
/** 已提交内容(= 真实"磁盘"状态) */
|
||||||
|
private committed = new Map<string, ArrayBuffer>();
|
||||||
|
/** 未提交写入(= OPFS createWritable 的 swap 区) */
|
||||||
|
private pending = new Map<string, ArrayBuffer | null>();
|
||||||
|
|
||||||
|
private readonly dbName: string;
|
||||||
|
private faults: FaultConfig = { failWrite: 0, failAppend: 0, failDelete: 0, truncateAppendTo: -1 };
|
||||||
|
|
||||||
|
/** 统计(供断言"到底发生了几次 IO") */
|
||||||
|
readonly stats = { writes: 0, appends: 0, deletes: 0, reads: 0, commits: 0, crashes: 0 };
|
||||||
|
|
||||||
|
constructor(options: TransactionalFileStoreOptions = {}) {
|
||||||
|
this.dbName = options.dbName ?? 'mock';
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this.dbName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 故障注入 ----
|
||||||
|
|
||||||
|
/** 接下来 n 次 write() 抛错 */
|
||||||
|
failNextWrite(n = 1): void { this.faults.failWrite = n; }
|
||||||
|
/** 接下来 n 次 append() 抛错 */
|
||||||
|
failNextAppend(n = 1): void { this.faults.failAppend = n; }
|
||||||
|
/** 接下来 n 次 delete() 抛错(模拟 OPFS removeEntry 失败) */
|
||||||
|
failNextDelete(n = 1): void { this.faults.failDelete = n; }
|
||||||
|
/** 下一次 append() 只落盘前 n 字节(撕裂写:模拟写入中途断电) */
|
||||||
|
truncateNextAppendTo(n: number): void { this.faults.truncateAppendTo = n; }
|
||||||
|
/** 清空全部故障注入 */
|
||||||
|
clearFaults(): void {
|
||||||
|
this.faults = { failWrite: 0, failAppend: 0, failDelete: 0, truncateAppendTo: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 崩溃 / 提交 ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟进程崩溃:丢弃**全部**未提交写入。
|
||||||
|
* 已提交内容保持崩溃前状态(这正是 OPFS copy-on-write 的保证)。
|
||||||
|
*/
|
||||||
|
crashPending(): void {
|
||||||
|
this.pending.clear();
|
||||||
|
this.stats.crashes++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟所有未完成的 createWritable 正常 close:把 pending 原子提交 */
|
||||||
|
commitAll(): void {
|
||||||
|
for (const [key, value] of this.pending) {
|
||||||
|
if (value === null) this.committed.delete(key);
|
||||||
|
else this.committed.set(key, value);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
this.stats.commits++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否存在未提交写入(测试可断言"崩溃窗口"是否真的存在) */
|
||||||
|
hasPending(): boolean {
|
||||||
|
return this.pending.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 数据操作(与 IStorageBackend 语义对齐,供 FaultyBackend 复用) ----
|
||||||
|
|
||||||
|
async read(key: string): Promise<ArrayBuffer | null> {
|
||||||
|
this.stats.reads++;
|
||||||
|
// 读取只看已提交内容 —— 未 close 的写入对读不可见(真实 OPFS 语义)
|
||||||
|
const buf = this.committed.get(key);
|
||||||
|
if (!buf) return null;
|
||||||
|
return copyBuffer(buf); // 返回副本,杜绝调用方原地修改污染"磁盘"
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
if (this.faults.failWrite > 0) {
|
||||||
|
this.faults.failWrite--;
|
||||||
|
throw new Error(`[OPFS-mock] injected write failure on "${key}"`);
|
||||||
|
}
|
||||||
|
this.stats.writes++;
|
||||||
|
this.pending.set(key, copyBuffer(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async append(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
if (this.faults.failAppend > 0) {
|
||||||
|
this.faults.failAppend--;
|
||||||
|
throw new Error(`[OPFS-mock] injected append failure on "${key}"`);
|
||||||
|
}
|
||||||
|
this.stats.appends++;
|
||||||
|
|
||||||
|
let chunk = copyBuffer(data);
|
||||||
|
if (this.faults.truncateAppendTo >= 0) {
|
||||||
|
// 撕裂写:只落盘前 n 字节
|
||||||
|
chunk = copyBufferHead(chunk, this.faults.truncateAppendTo);
|
||||||
|
this.faults.truncateAppendTo = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加目标是"已提交内容 + pending 中同 key 的写入"(同一 swap 文件内的续写)
|
||||||
|
const base = this.pending.has(key)
|
||||||
|
? (this.pending.get(key) as ArrayBuffer | null)
|
||||||
|
: this.committed.get(key) ?? null;
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
this.pending.set(key, chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = new Uint8Array(base.byteLength + chunk.byteLength);
|
||||||
|
merged.set(new Uint8Array(base), 0);
|
||||||
|
merged.set(new Uint8Array(chunk), base.byteLength);
|
||||||
|
this.pending.set(key, merged.buffer as ArrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 覆盖写(keepExistingData: false,真实 OPFS 会截断) */
|
||||||
|
async overwrite(key: string, data: ArrayBuffer): Promise<void> {
|
||||||
|
await this.write(key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<void> {
|
||||||
|
if (this.faults.failDelete > 0) {
|
||||||
|
this.faults.failDelete--;
|
||||||
|
throw new Error(`[OPFS-mock] injected delete failure on "${key}"`);
|
||||||
|
}
|
||||||
|
this.stats.deletes++;
|
||||||
|
this.pending.set(key, null); // 删除也在提交时才生效
|
||||||
|
}
|
||||||
|
|
||||||
|
async listKeys(): Promise<string[]> {
|
||||||
|
const keys = new Set<string>(this.committed.keys());
|
||||||
|
for (const [key, value] of this.pending) {
|
||||||
|
if (value === null) keys.delete(key);
|
||||||
|
else keys.add(key);
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<boolean> {
|
||||||
|
if (this.pending.has(key)) return this.pending.get(key) !== null;
|
||||||
|
return this.committed.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 直接查看已提交字节数(不经过 read 的副本语义,供结构断言用) */
|
||||||
|
committedSize(key: string): number {
|
||||||
|
return this.committed.get(key)?.byteLength ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已提交内容的只读快照(供"篡改磁盘"类测试:写坏字节后重开观察恢复) */
|
||||||
|
snapshotCommitted(): Map<string, ArrayBuffer> {
|
||||||
|
const out = new Map<string, ArrayBuffer>();
|
||||||
|
for (const [k, v] of this.committed) out.set(k, copyBuffer(v));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用快照替换已提交内容(模拟外部损坏/离线篡改) */
|
||||||
|
restoreCommitted(snapshot: Map<string, ArrayBuffer>): void {
|
||||||
|
this.committed = new Map();
|
||||||
|
for (const [k, v] of snapshot) this.committed.set(k, copyBuffer(v));
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 就地篡改已提交字节(模拟 bit flip / 部分覆盖),不做副本 */
|
||||||
|
corruptCommitted(key: string, mutate: (bytes: Uint8Array) => void): boolean {
|
||||||
|
const buf = this.committed.get(key);
|
||||||
|
if (!buf) return false;
|
||||||
|
mutate(new Uint8Array(buf));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.committed.clear();
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制 ArrayBuffer(杜绝引用泄漏 —— 旧 mock 的缺陷 1)
|
||||||
|
*
|
||||||
|
* 注意:不能用 `buf.slice(0)` —— 在 jsdom 环境下 `ArrayBuffer.prototype.slice` 可能被
|
||||||
|
* Blob/File 的 slice 语义遮蔽,导致 `slice(0, n)` 的参数被忽略、返回整段内容。
|
||||||
|
* 这里用 Uint8Array 显式复制,行为在任何环境都确定。
|
||||||
|
*/
|
||||||
|
function copyBuffer(buf: ArrayBuffer): ArrayBuffer {
|
||||||
|
const out = new Uint8Array(buf.byteLength);
|
||||||
|
out.set(new Uint8Array(buf));
|
||||||
|
return out.buffer as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制前 n 字节(撕裂写用;同上不使用 ArrayBuffer.slice) */
|
||||||
|
function copyBufferHead(buf: ArrayBuffer, n: number): ArrayBuffer {
|
||||||
|
const len = Math.max(0, Math.min(n, buf.byteLength));
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
out.set(new Uint8Array(buf, 0, len));
|
||||||
|
return out.buffer as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结构判别:是否为 `createWritable().write({type,position,data})` 形式。
|
||||||
|
* 不用 `instanceof ArrayBuffer` —— 跨 realm 时会误判(见 write 内注释)。
|
||||||
|
*/
|
||||||
|
function isPositionedWrite(
|
||||||
|
arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer },
|
||||||
|
): arg is { type: string; position: number; data: ArrayBuffer } {
|
||||||
|
const anyArg = arg as { type?: unknown; position?: unknown; data?: unknown };
|
||||||
|
return typeof anyArg?.type === 'string'
|
||||||
|
&& typeof anyArg?.position === 'number'
|
||||||
|
&& anyArg?.data != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 第 2 层:OPFS 门面(供 OPFSBackend 使用)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 全局注册表:库名 → store(跨 backend 实例共享 = 持久化语义) */
|
||||||
|
const registry = new Map<string, TransactionalFileStore>();
|
||||||
|
|
||||||
|
/** 取(或创建)某库的共享 store */
|
||||||
|
export function getStore(dbName: string): TransactionalFileStore {
|
||||||
|
let store = registry.get(dbName);
|
||||||
|
if (!store) {
|
||||||
|
store = new TransactionalFileStore({ dbName });
|
||||||
|
registry.set(dbName, store);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空全局注册表(测试隔离) */
|
||||||
|
export function clearRegistry(): void {
|
||||||
|
registry.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每次调用都重置的 OPFS 安装(推荐在 `beforeEach` 中使用)。
|
||||||
|
*
|
||||||
|
* 等价于 `clearRegistry(); installOPFSMock(dbName);`。
|
||||||
|
* 命名里强调 reset 是为了避免旧写法的误导:此前测试写 `installOPFSMock(new Map())`,
|
||||||
|
* 传入的 Map 会被 mock **静默忽略**(各测试文件各自传 Map 并不能隔离共享状态)。
|
||||||
|
*/
|
||||||
|
export function resetOPFSMock(dbName = 'mock'): InstalledOPFSMock {
|
||||||
|
clearRegistry();
|
||||||
|
return installOPFSMock(dbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstalledOPFSMock {
|
||||||
|
store: TransactionalFileStore;
|
||||||
|
/** 当前 mock 看到的所有文件名 */
|
||||||
|
listKeys(): Promise<string[]>;
|
||||||
|
/**
|
||||||
|
* 直接创建(或覆盖)一个文件 —— 用于制造"崩溃残留临时文件"等场景,
|
||||||
|
* 替代旧 API 暴露内部 dir/files 的做法。
|
||||||
|
*/
|
||||||
|
createFile(name: string, content?: ArrayBuffer): Promise<void>;
|
||||||
|
/** 文件是否存在 */
|
||||||
|
hasFile(name: string): Promise<boolean>;
|
||||||
|
/** 读取文件内容(返回副本) */
|
||||||
|
readFile(name: string): Promise<ArrayBuffer | null>;
|
||||||
|
/** 删除文件 */
|
||||||
|
removeFile(name: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装 OPFS mock 到 globalThis.navigator.storage.getDirectory()。
|
||||||
|
*
|
||||||
|
* @param dbName 库名(决定共享 store;同名多次安装拿到同一个 store)
|
||||||
|
*/
|
||||||
|
export function installOPFSMock(dbName = 'mock'): InstalledOPFSMock {
|
||||||
|
const store = getStore(dbName);
|
||||||
|
|
||||||
|
const makeFileHandle = async (name: string, opts?: { create?: boolean }) => {
|
||||||
|
if (!(await store.has(name)) && !opts?.create) {
|
||||||
|
throw new Error(`NotFoundError: ${name}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* 真实 OPFS 的 getFile() 返回 File,读的是**已提交**内容(size 与 arrayBuffer 一致)。
|
||||||
|
* append 路径会读 `existing.size` 来定位追加位置,因此这里保持二者同源。
|
||||||
|
*/
|
||||||
|
getFile: async () => {
|
||||||
|
const content = (await store.read(name)) ?? new ArrayBuffer(0);
|
||||||
|
return {
|
||||||
|
size: content.byteLength,
|
||||||
|
arrayBuffer: async () => content,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
createWritable: async (wOpts?: { keepExistingData?: boolean }) => {
|
||||||
|
const keepExisting = wOpts?.keepExistingData ?? false;
|
||||||
|
// OPFS 写语义:keepExistingData:false 时从空缓冲开始(旧 mock 会保留旧字节)
|
||||||
|
let buffer = keepExisting ? (await store.read(name)) ?? new ArrayBuffer(0) : new ArrayBuffer(0);
|
||||||
|
return {
|
||||||
|
write: async (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => {
|
||||||
|
// 注意:不能用 `arg instanceof ArrayBuffer` 判别 —— 跨 realm / 跨 Buffer 实现时
|
||||||
|
// 会失效(jsdom 与 Node 的 ArrayBuffer 可能不是同一个构造函数),
|
||||||
|
// 从而把 ArrayBuffer 误当成 {position,data} 分支。改用结构判别。
|
||||||
|
if (!isPositionedWrite(arg)) {
|
||||||
|
buffer = copyBuffer(arg as ArrayBuffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunk = arg;
|
||||||
|
const end = chunk.position + chunk.data.byteLength;
|
||||||
|
const merged = new Uint8Array(Math.max(end, buffer.byteLength));
|
||||||
|
merged.set(new Uint8Array(buffer), 0);
|
||||||
|
merged.set(new Uint8Array(chunk.data), chunk.position);
|
||||||
|
buffer = merged.buffer as ArrayBuffer;
|
||||||
|
},
|
||||||
|
close: async () => {
|
||||||
|
// 仅在 close 时提交 —— 未 close 的写入对读不可见(旧 mock 会立即可见)
|
||||||
|
await store.write(name, buffer);
|
||||||
|
store.commitAll();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dir = {
|
||||||
|
getFileHandle: makeFileHandle,
|
||||||
|
entries: async function* () {
|
||||||
|
for (const key of await store.listKeys()) yield [key];
|
||||||
|
},
|
||||||
|
removeEntry: async (name: string) => {
|
||||||
|
await store.delete(name);
|
||||||
|
store.commitAll();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
value: {
|
||||||
|
storage: {
|
||||||
|
getDirectory: async () => ({
|
||||||
|
getDirectoryHandle: async (_name: string, _opts?: unknown) => dir,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
listKeys: () => store.listKeys(),
|
||||||
|
createFile: async (name, content) => {
|
||||||
|
await store.write(name, content ?? new ArrayBuffer(0));
|
||||||
|
store.commitAll();
|
||||||
|
},
|
||||||
|
hasFile: (name) => store.has(name),
|
||||||
|
readFile: (name) => store.read(name),
|
||||||
|
removeFile: async (name) => {
|
||||||
|
await store.delete(name);
|
||||||
|
store.commitAll();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接由 TransactionalFileStore 驱动的可崩溃后端。
|
||||||
|
*
|
||||||
|
* 用途:验证"真崩溃"(丢弃未提交写入)而非"优雅停机"(close 刷完队列)。
|
||||||
|
* OPFSBackend 之上无法表达 pending 语义(它每次 write 都会 close 提交),
|
||||||
|
* 因此需要这一层直连介质的后端来构造"写入进行中崩溃"的窗口。
|
||||||
|
*/
|
||||||
|
export class CrashableStoreBackend implements IStorageBackend {
|
||||||
|
private readonly store: TransactionalFileStore;
|
||||||
|
|
||||||
|
constructor(store: TransactionalFileStore) {
|
||||||
|
this.store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(_name: string): Promise<void> { return Promise.resolve(); }
|
||||||
|
close(): Promise<void> { return Promise.resolve(); }
|
||||||
|
isOpen(): boolean { return true; }
|
||||||
|
read(key: string) { return this.store.read(key); }
|
||||||
|
write(key: string, data: ArrayBuffer) { return this.store.write(key, data); }
|
||||||
|
append(key: string, data: ArrayBuffer) { return this.store.append(key, data); }
|
||||||
|
writeMany(entries: Record<string, ArrayBuffer>) {
|
||||||
|
return (async () => {
|
||||||
|
for (const [k, v] of Object.entries(entries)) await this.store.write(k, v);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
delete(key: string) { return this.store.delete(key); }
|
||||||
|
deleteMany(keys: string[]) {
|
||||||
|
return (async () => {
|
||||||
|
for (const k of keys) await this.store.delete(k);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
listKeys() { return this.store.listKeys(); }
|
||||||
|
exists(key: string) { return this.store.has(key); }
|
||||||
|
clear(): Promise<void> { this.store.clear(); return Promise.resolve(); }
|
||||||
|
|
||||||
|
/** 崩溃:丢弃全部未提交写入 */
|
||||||
|
simulateCrash(): void {
|
||||||
|
this.store.crashPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正常提交(模拟所有 createWritable 完成 close) */
|
||||||
|
commit(): void {
|
||||||
|
this.store.commitAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,12 +65,12 @@ describe('v0.5.1 — CRUD hooks 真实接线', () => {
|
|||||||
const db = await createDb('sql-crud');
|
const db = await createDb('sql-crud');
|
||||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
db.on('beforeInsert', () => events.push('beforeInsert'));
|
db.on('beforeInsert', () => { events.push('beforeInsert'); });
|
||||||
db.on('afterInsert', () => events.push('afterInsert'));
|
db.on('afterInsert', () => { events.push('afterInsert'); });
|
||||||
db.on('beforeUpdate', () => events.push('beforeUpdate'));
|
db.on('beforeUpdate', () => { events.push('beforeUpdate'); });
|
||||||
db.on('afterUpdate', () => events.push('afterUpdate'));
|
db.on('afterUpdate', () => { events.push('afterUpdate'); });
|
||||||
db.on('beforeDelete', () => events.push('beforeDelete'));
|
db.on('beforeDelete', () => { events.push('beforeDelete'); });
|
||||||
db.on('afterDelete', () => events.push('afterDelete'));
|
db.on('afterDelete', () => { events.push('afterDelete'); });
|
||||||
|
|
||||||
await db.query("INSERT INTO users VALUES ('2', 'Bob', 25)");
|
await db.query("INSERT INTO users VALUES ('2', 'Bob', 25)");
|
||||||
await db.query("UPDATE users SET age = 26 WHERE id = '2'");
|
await db.query("UPDATE users SET age = 26 WHERE id = '2'");
|
||||||
@@ -87,7 +87,7 @@ describe('v0.5.1 — CRUD hooks 真实接线', () => {
|
|||||||
it('SQL INSERT 多行语句按列名映射触发 afterInsert', async () => {
|
it('SQL INSERT 多行语句按列名映射触发 afterInsert', async () => {
|
||||||
const db = await createDb('sql-insert-cols');
|
const db = await createDb('sql-insert-cols');
|
||||||
const seen: unknown[] = [];
|
const seen: unknown[] = [];
|
||||||
db.on('afterInsert', (_rows, pks) => seen.push(pks));
|
db.on('afterInsert', (_rows, pks) => { seen.push(pks); });
|
||||||
|
|
||||||
await db.query("INSERT INTO users (id, name) VALUES ('9', 'Zed')");
|
await db.query("INSERT INTO users (id, name) VALUES ('9', 'Zed')");
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ describe('v0.5.1 — CRUD hooks 真实接线', () => {
|
|||||||
'beforeQuery', 'afterQuery',
|
'beforeQuery', 'afterQuery',
|
||||||
'beforeTransaction', 'afterTransaction',
|
'beforeTransaction', 'afterTransaction',
|
||||||
] as const;
|
] as const;
|
||||||
for (const h of hooks) db.on(h, () => fired.add(h));
|
for (const h of hooks) db.on(h, () => { fired.add(h); });
|
||||||
|
|
||||||
await db.defineTable('t2', { id: { type: 'string', primaryKey: true } }); // createTable hooks
|
await db.defineTable('t2', { id: { type: 'string', primaryKey: true } }); // createTable hooks
|
||||||
await db.table('t2').insert({ id: '1' }); // insert hooks
|
await db.table('t2').insert({ id: '1' }); // insert hooks
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('HybridEngine', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
dbName = `test-hybrid-${++hybridCounter}`;
|
dbName = `test-hybrid-${++hybridCounter}`;
|
||||||
engine = new HybridEngine('indexeddb');
|
engine = new HybridEngine('opfs');
|
||||||
await engine.open(dbName, 1);
|
await engine.open(dbName, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
*/
|
*/
|
||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
import { parse } from '../src/sql/parser';
|
import { parse } from '../src/sql/parser';
|
||||||
import { createSchema } from '../src/table/schema';
|
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let dbCounter = 0;
|
let dbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { MemoryEngine } from '../../src/engine/memory';
|
import { MemoryEngine } from '../../src/engine/memory';
|
||||||
import { createSchema } from '../../src/table/schema';
|
|
||||||
import { SelectQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from '../../src/query/builder';
|
import { SelectQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from '../../src/query/builder';
|
||||||
import { compileStatement } from '../../src/query/compiler';
|
import { compileStatement } from '../../src/query/compiler';
|
||||||
import { QueryExecutor } from '../../src/query/executor';
|
import { QueryExecutor } from '../../src/query/executor';
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { MetonaSqlark } from '../src/core';
|
|||||||
import '../src/connection-manager';
|
import '../src/connection-manager';
|
||||||
import { parse, parseAll } from '../src/sql/parser';
|
import { parse, parseAll } from '../src/sql/parser';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
async function createDb(mode: 'memory' | 'disk' | 'aria' = 'memory') {
|
async function createDb(mode: 'memory' | 'disk' | 'aria' = 'memory') {
|
||||||
const db = new MetonaSqlark({ name: `sql-ext-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'opfs' });
|
const db = new MetonaSqlark({ name: `sql-ext-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'opfs' });
|
||||||
@@ -288,9 +288,9 @@ describe('[v0.3.0] CREATE INDEX / DROP INDEX', () => {
|
|||||||
|
|
||||||
test('CREATE INDEX 不存在列报错', async () => {
|
test('CREATE INDEX 不存在列报错', async () => {
|
||||||
const db = await createDb();
|
const db = await createDb();
|
||||||
const err = await db.query('CREATE INDEX idx_bad ON users (nonexistent)').catch((e: any) => e);
|
const err = await db.query('CREATE INDEX idx_bad ON users (nonexistent)').catch((e: unknown) => e);
|
||||||
expect(err).toBeDefined();
|
expect(err).toBeInstanceOf(Error);
|
||||||
expect(err.code).toBe('COLUMN_NOT_FOUND');
|
expect((err as { code?: string }).code).toBe('COLUMN_NOT_FOUND');
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import { parse } from '../src/sql/parser';
|
|||||||
import { WAL, type WALStore } from '../src/engine/aria/wal/log';
|
import { WAL, type WALStore } from '../src/engine/aria/wal/log';
|
||||||
import { WALRecordType } from '../src/engine/aria/types';
|
import { WALRecordType } from '../src/engine/aria/types';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
async function createDb(mode: 'memory' | 'aria' = 'memory') {
|
async function createDb(mode: 'memory' | 'aria' = 'memory') {
|
||||||
const db = new MetonaSqlark({ name: `sql-ext2-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'opfs' });
|
const db = new MetonaSqlark({ name: `sql-ext2-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'opfs' });
|
||||||
|
|||||||
+122
-38
@@ -1,69 +1,152 @@
|
|||||||
/**
|
/**
|
||||||
* Parser 边缘场景测试
|
* Parser 边缘场景测试
|
||||||
|
*
|
||||||
|
* v0.8.0 强化说明:
|
||||||
|
* 1. 此前 12 处断言写着 `expect(ast.where).toBeDefined()` —— 既没有验证结构也没有验证
|
||||||
|
* 值,属于"空断言"。审计(AUDIT-query-layer / SQL 层)指出:`parser.test.ts` 无一处
|
||||||
|
* 断言混合 AND/OR 的**结构**,这正是 AND/OR 无优先级缺陷(总账第 11 项)能长期存活的原因。
|
||||||
|
* 2. 本文件此前 `parse()` 的返回类型是联合类型 `Statement`,直接访问 `.where` / `.columns`
|
||||||
|
* 在类型上不成立(tests 从不做类型检查,所以没人发现)。现在用类型收窄辅助函数显式
|
||||||
|
* 区分语句种类 —— 收窄失败即测试失败,而不是静默读到一个 undefined。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parse } from '../../src/sql/parser';
|
import { parse } from '../../src/sql/parser';
|
||||||
|
import type {
|
||||||
|
SelectStatement,
|
||||||
|
InsertStatement,
|
||||||
|
UpdateStatement,
|
||||||
|
DeleteStatement,
|
||||||
|
CreateTableStatement,
|
||||||
|
} from '../../src/query/ast';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 类型收窄辅助:断言语句种类并返回收窄后的类型
|
||||||
|
// (替代 `as any` —— 收窄失败会立刻让测试失败,而不是让断言落空)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function asSelect(stmt: ReturnType<typeof parse>): SelectStatement {
|
||||||
|
expect(stmt.type).toBe('SELECT');
|
||||||
|
if (stmt.type !== 'SELECT') throw new Error(`expected SELECT, got ${stmt.type}`);
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asInsert(stmt: ReturnType<typeof parse>): InsertStatement & { values: unknown[][] } {
|
||||||
|
expect(stmt.type).toBe('INSERT');
|
||||||
|
if (stmt.type !== 'INSERT') throw new Error(`expected INSERT, got ${stmt.type}`);
|
||||||
|
if (!stmt.values) throw new Error('INSERT statement is missing values');
|
||||||
|
return stmt as InsertStatement & { values: unknown[][] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function asUpdate(stmt: ReturnType<typeof parse>): UpdateStatement {
|
||||||
|
expect(stmt.type).toBe('UPDATE');
|
||||||
|
if (stmt.type !== 'UPDATE') throw new Error(`expected UPDATE, got ${stmt.type}`);
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asDelete(stmt: ReturnType<typeof parse>): DeleteStatement {
|
||||||
|
expect(stmt.type).toBe('DELETE');
|
||||||
|
if (stmt.type !== 'DELETE') throw new Error(`expected DELETE, got ${stmt.type}`);
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asCreateTable(stmt: ReturnType<typeof parse>): CreateTableStatement {
|
||||||
|
expect(stmt.type).toBe('CREATE_TABLE');
|
||||||
|
if (stmt.type !== 'CREATE_TABLE') throw new Error(`expected CREATE_TABLE, got ${stmt.type}`);
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Parser 边缘场景', () => {
|
describe('Parser 边缘场景', () => {
|
||||||
// ---- SELECT 扩展 ----
|
// ---- SELECT 扩展 ----
|
||||||
|
|
||||||
describe('SELECT 扩展', () => {
|
describe('SELECT 扩展', () => {
|
||||||
it('WHERE 多条件 AND', () => {
|
it('WHERE 多条件 AND', () => {
|
||||||
const ast = parse("SELECT * FROM users WHERE age > 18 AND name LIKE 'A%'");
|
const ast = asSelect(parse("SELECT * FROM users WHERE age > 18 AND name LIKE 'A%'"));
|
||||||
expect(ast.type).toBe('SELECT');
|
// v0.8.0: 断言真实结构而非 toBeDefined()
|
||||||
expect(ast.where).toBeDefined();
|
expect(ast.where).toEqual({
|
||||||
|
$and: [{ age: { $gt: 18 } }, { name: { $like: 'A%' } }],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('WHERE IN 列表', () => {
|
it('WHERE IN 列表', () => {
|
||||||
const ast = parse('SELECT * FROM users WHERE id IN (1, 2, 3)');
|
const ast = asSelect(parse('SELECT * FROM users WHERE id IN (1, 2, 3)'));
|
||||||
expect(ast.type).toBe('SELECT');
|
expect(ast.where).toEqual({ id: { $in: [1, 2, 3] } });
|
||||||
expect(ast.where).toBeDefined();
|
|
||||||
const cond = (ast.where as any).id;
|
|
||||||
expect(cond.$in).toEqual([1, 2, 3]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('WHERE IS NULL', () => {
|
it('WHERE IS NULL', () => {
|
||||||
const ast = parse('SELECT * FROM users WHERE bio IS NULL');
|
const ast = asSelect(parse('SELECT * FROM users WHERE bio IS NULL'));
|
||||||
expect(ast.type).toBe('SELECT');
|
expect(ast.where).toEqual({ bio: { $eq: null } });
|
||||||
expect(ast.where).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('WHERE IS NOT NULL', () => {
|
it('WHERE IS NOT NULL', () => {
|
||||||
const ast = parse('SELECT * FROM users WHERE bio IS NOT NULL');
|
const ast = asSelect(parse('SELECT * FROM users WHERE bio IS NOT NULL'));
|
||||||
expect(ast.type).toBe('SELECT');
|
expect(ast.where).toEqual({ bio: { $ne: null } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('WHERE NOT', () => {
|
it('WHERE NOT', () => {
|
||||||
const ast = parse("SELECT * FROM users WHERE NOT age = 18");
|
const ast = asSelect(parse('SELECT * FROM users WHERE NOT age = 18'));
|
||||||
expect(ast.type).toBe('SELECT');
|
expect(ast.where).toEqual({ $not: { age: { $eq: 18 } } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('WHERE 括号分组', () => {
|
it('WHERE 括号分组', () => {
|
||||||
const ast = parse("SELECT * FROM users WHERE (age > 18 OR age < 10) AND active = TRUE");
|
const ast = asSelect(parse('SELECT * FROM users WHERE (age > 18 OR age < 10) AND active = TRUE'));
|
||||||
expect(ast.type).toBe('SELECT');
|
expect(ast.where).toEqual({
|
||||||
|
$and: [{ $or: [{ age: { $gt: 18 } }, { age: { $lt: 10 } }] }, { active: { $eq: true } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// v0.8.0 新增:AND 优先级必须高于 OR(SQL 标准),此前是纯左折叠
|
||||||
|
it('AND 优先级高于 OR(SQL 标准)', () => {
|
||||||
|
const ast = asSelect(parse('SELECT * FROM t WHERE a = 1 OR a = 2 AND b = 3'));
|
||||||
|
expect(ast.where).toEqual({
|
||||||
|
$or: [{ a: { $eq: 1 } }, { $and: [{ a: { $eq: 2 } }, { b: { $eq: 3 } }] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('显式括号可覆盖默认优先级', () => {
|
||||||
|
const ast = asSelect(parse('SELECT * FROM t WHERE (a = 1 OR a = 2) AND b = 3'));
|
||||||
|
expect(ast.where).toEqual({
|
||||||
|
$and: [{ $or: [{ a: { $eq: 1 } }, { a: { $eq: 2 } }] }, { b: { $eq: 3 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NOT 优先级高于 AND/OR', () => {
|
||||||
|
const ast = asSelect(parse('SELECT * FROM t WHERE NOT a = 1 OR b = 2'));
|
||||||
|
expect(ast.where).toEqual({
|
||||||
|
$or: [{ $not: { a: { $eq: 1 } } }, { b: { $eq: 2 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('多层 AND/OR 交替的结合性', () => {
|
||||||
|
const ast = asSelect(parse('SELECT * FROM t WHERE a = 1 AND b = 2 OR c = 3 AND d = 4'));
|
||||||
|
expect(ast.where).toEqual({
|
||||||
|
$or: [
|
||||||
|
{ $and: [{ a: { $eq: 1 } }, { b: { $eq: 2 } }] },
|
||||||
|
{ $and: [{ c: { $eq: 3 } }, { d: { $eq: 4 } }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ORDER BY 多列', () => {
|
it('ORDER BY 多列', () => {
|
||||||
const ast = parse('SELECT * FROM users ORDER BY age DESC, name ASC');
|
const ast = asSelect(parse('SELECT * FROM users ORDER BY age DESC, name ASC'));
|
||||||
expect(ast.type).toBe('SELECT');
|
|
||||||
expect(ast.orderBy).toHaveLength(2);
|
expect(ast.orderBy).toHaveLength(2);
|
||||||
expect(ast.orderBy![0]).toEqual({ column: 'age', direction: 'desc' });
|
expect(ast.orderBy![0]).toEqual({ column: 'age', direction: 'desc' });
|
||||||
expect(ast.orderBy![1]).toEqual({ column: 'name', direction: 'asc' });
|
expect(ast.orderBy![1]).toEqual({ column: 'name', direction: 'asc' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('只有 LIMIT 没有 OFFSET', () => {
|
it('只有 LIMIT 没有 OFFSET', () => {
|
||||||
const ast = parse('SELECT * FROM users LIMIT 5');
|
const ast = asSelect(parse('SELECT * FROM users LIMIT 5'));
|
||||||
expect(ast.limit).toBe(5);
|
expect(ast.limit).toBe(5);
|
||||||
expect(ast.offset).toBeUndefined();
|
expect(ast.offset).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('只有 OFFSET 没有 LIMIT', () => {
|
it('只有 OFFSET 没有 LIMIT', () => {
|
||||||
const ast = parse('SELECT * FROM users OFFSET 10');
|
const ast = asSelect(parse('SELECT * FROM users OFFSET 10'));
|
||||||
expect(ast).toMatchObject({ offset: 10 });
|
expect(ast.offset).toBe(10);
|
||||||
|
expect(ast.limit).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('不带 WHERE 的 SELECT', () => {
|
it('不带 WHERE 的 SELECT', () => {
|
||||||
const ast = parse('SELECT id, name FROM users ORDER BY id LIMIT 5');
|
const ast = asSelect(parse('SELECT id, name FROM users ORDER BY id LIMIT 5'));
|
||||||
expect(ast.where).toEqual({});
|
expect(ast.where).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -72,18 +155,18 @@ describe('Parser 边缘场景', () => {
|
|||||||
|
|
||||||
describe('INSERT 扩展', () => {
|
describe('INSERT 扩展', () => {
|
||||||
it('不带列名的 INSERT', () => {
|
it('不带列名的 INSERT', () => {
|
||||||
const ast = parse("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
const ast = asInsert(parse("INSERT INTO users VALUES ('1', 'Alice', 30)"));
|
||||||
expect(ast.columns).toBeUndefined();
|
expect(ast.columns).toBeUndefined();
|
||||||
expect(ast.values).toEqual([['1', 'Alice', 30]]);
|
expect(ast.values).toEqual([['1', 'Alice', 30]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('INSERT 布尔值和 NULL', () => {
|
it('INSERT 布尔值和 NULL', () => {
|
||||||
const ast = parse('INSERT INTO users VALUES (TRUE, FALSE, NULL)');
|
const ast = asInsert(parse('INSERT INTO users VALUES (TRUE, FALSE, NULL)'));
|
||||||
expect(ast.values[0]).toEqual([true, false, null]);
|
expect(ast.values[0]).toEqual([true, false, null]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('INSERT 负数和浮点数', () => {
|
it('INSERT 负数和浮点数', () => {
|
||||||
const ast = parse('INSERT INTO scores VALUES (-1, 3.14)');
|
const ast = asInsert(parse('INSERT INTO scores VALUES (-1, 3.14)'));
|
||||||
expect(ast.values[0]).toEqual([-1, 3.14]);
|
expect(ast.values[0]).toEqual([-1, 3.14]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -92,13 +175,13 @@ describe('Parser 边缘场景', () => {
|
|||||||
|
|
||||||
describe('UPDATE 扩展', () => {
|
describe('UPDATE 扩展', () => {
|
||||||
it('UPDATE 多列', () => {
|
it('UPDATE 多列', () => {
|
||||||
const ast = parse("UPDATE users SET name = 'Bob', age = 26 WHERE id = '1'");
|
const ast = asUpdate(parse("UPDATE users SET name = 'Bob', age = 26 WHERE id = '1'"));
|
||||||
expect(ast.type).toBe('UPDATE');
|
|
||||||
expect(ast.sets).toEqual({ name: 'Bob', age: 26 });
|
expect(ast.sets).toEqual({ name: 'Bob', age: 26 });
|
||||||
|
expect(ast.where).toEqual({ id: { $eq: '1' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('UPDATE 不带 WHERE', () => {
|
it('UPDATE 不带 WHERE', () => {
|
||||||
const ast = parse('UPDATE users SET active = FALSE');
|
const ast = asUpdate(parse('UPDATE users SET active = FALSE'));
|
||||||
expect(ast.where).toEqual({});
|
expect(ast.where).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -107,13 +190,15 @@ describe('Parser 边缘场景', () => {
|
|||||||
|
|
||||||
describe('DELETE 扩展', () => {
|
describe('DELETE 扩展', () => {
|
||||||
it('DELETE 不带 WHERE', () => {
|
it('DELETE 不带 WHERE', () => {
|
||||||
const ast = parse('DELETE FROM users');
|
const ast = asDelete(parse('DELETE FROM users'));
|
||||||
expect(ast.where).toEqual({});
|
expect(ast.where).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('DELETE 带复杂 WHERE', () => {
|
it('DELETE 带复杂 WHERE', () => {
|
||||||
const ast = parse("DELETE FROM users WHERE age < 18 OR status = 'inactive'");
|
const ast = asDelete(parse("DELETE FROM users WHERE age < 18 OR status = 'inactive'"));
|
||||||
expect(ast.type).toBe('DELETE');
|
expect(ast.where).toEqual({
|
||||||
|
$or: [{ age: { $lt: 18 } }, { status: { $eq: 'inactive' } }],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,8 +206,7 @@ describe('Parser 边缘场景', () => {
|
|||||||
|
|
||||||
describe('DDL 扩展', () => {
|
describe('DDL 扩展', () => {
|
||||||
it('CREATE TABLE 完整修饰符', () => {
|
it('CREATE TABLE 完整修饰符', () => {
|
||||||
const ast = parse("CREATE TABLE products (id STRING PRIMARY KEY, name STRING NOT NULL UNIQUE, price NUMBER DEFAULT 0, active BOOLEAN DEFAULT TRUE)");
|
const ast = asCreateTable(parse("CREATE TABLE products (id STRING PRIMARY KEY, name STRING NOT NULL UNIQUE, price NUMBER DEFAULT 0, active BOOLEAN DEFAULT TRUE)"));
|
||||||
expect(ast.type).toBe('CREATE_TABLE');
|
|
||||||
expect(ast.columns).toHaveLength(4);
|
expect(ast.columns).toHaveLength(4);
|
||||||
expect(ast.columns[0]).toMatchObject({ name: 'id', primaryKey: true });
|
expect(ast.columns[0]).toMatchObject({ name: 'id', primaryKey: true });
|
||||||
expect(ast.columns[1]).toMatchObject({ name: 'name', required: true, unique: true });
|
expect(ast.columns[1]).toMatchObject({ name: 'name', required: true, unique: true });
|
||||||
@@ -145,13 +229,13 @@ describe('Parser 边缘场景', () => {
|
|||||||
|
|
||||||
describe('常量值解析', () => {
|
describe('常量值解析', () => {
|
||||||
it('布尔值 TRUE/FALSE', () => {
|
it('布尔值 TRUE/FALSE', () => {
|
||||||
const ast = parse("SELECT * FROM users WHERE active = TRUE");
|
const ast = asSelect(parse('SELECT * FROM users WHERE active = TRUE'));
|
||||||
expect(ast.where).toBeDefined();
|
expect(ast.where).toEqual({ active: { $eq: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('NULL 值', () => {
|
it('NULL 值', () => {
|
||||||
const ast = parse('SELECT * FROM users WHERE bio IS NULL');
|
const ast = asSelect(parse('SELECT * FROM users WHERE bio IS NULL'));
|
||||||
expect(ast.where).toBeDefined();
|
expect(ast.where).toEqual({ bio: { $eq: null } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe('Schema 边缘场景', () => {
|
|||||||
|
|
||||||
describe('getPrimaryKey', () => {
|
describe('getPrimaryKey', () => {
|
||||||
it('没有标记主键时返回第一列', () => {
|
it('没有标记主键时返回第一列', () => {
|
||||||
const schema = createSchema('test', {
|
const _schema = createSchema('test', {
|
||||||
uuid: { type: 'string', primaryKey: true },
|
uuid: { type: 'string', primaryKey: true },
|
||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,30 +5,31 @@
|
|||||||
|
|
||||||
import { VERSION } from '../src/constants';
|
import { VERSION } from '../src/constants';
|
||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
import { AriaEngine } from '../src/engine/aria/index';
|
|
||||||
import { MemoryEngine } from '../src/engine/memory';
|
import { MemoryEngine } from '../src/engine/memory';
|
||||||
import { parse } from '../src/sql/parser';
|
import { parse } from '../src/sql/parser';
|
||||||
import { QueryExecutor } from '../src/query/executor';
|
import { QueryExecutor } from '../src/query/executor';
|
||||||
import { WAL } from '../src/engine/aria/wal/log';
|
import { WAL } from '../src/engine/aria/wal/log';
|
||||||
import { SSTableReader } from '../src/engine/aria/index/sstable';
|
import { SSTableReader } from '../src/engine/aria/index/sstable';
|
||||||
import { BloomFilter } from '../src/engine/aria/index/bloom';
|
|
||||||
import { CryptoManager } from '../src/engine/aria/crypto';
|
import { CryptoManager } from '../src/engine/aria/crypto';
|
||||||
import { PluginManager } from '../src/plugin/index';
|
import { PluginManager } from '../src/plugin/index';
|
||||||
import type { SSTableMeta } from '../src/engine/aria/types';
|
import type { SSTableMeta } from '../src/engine/aria/types';
|
||||||
import type { MetonaPlugin } from '../src/constants';
|
import type { MetonaPlugin } from '../src/constants';
|
||||||
import { createSchema } from '../src/table/schema';
|
import { createSchema } from '../src/table/schema';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// P0-1: 版本号统一
|
// P0-1: 版本号统一
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
||||||
test('VERSION 常量为当前版本(0.6.0)', () => {
|
// v0.8.0: 版本号一致性收敛到单一契约测试(tests/version-contract.test.ts),
|
||||||
expect(VERSION).toBe('0.7.4');
|
// 此处不再硬编码版本字面量 —— 此前每次发版必须手改本文件,且只校验源码常量、
|
||||||
|
// 与 package.json / dist 之间没有任何一致性检查。
|
||||||
|
test('VERSION 常量是合法的语义化版本', () => {
|
||||||
|
expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ describe('[v0.2.5] P0-6: PluginManager.install 传 db 实例', () => {
|
|||||||
let receivedDb: unknown = null;
|
let receivedDb: unknown = null;
|
||||||
const plugin: MetonaPlugin = {
|
const plugin: MetonaPlugin = {
|
||||||
name: 'test-plugin',
|
name: 'test-plugin',
|
||||||
|
version: '1.0.0',
|
||||||
install: (db) => { receivedDb = db; },
|
install: (db) => { receivedDb = db; },
|
||||||
destroy: () => {},
|
destroy: () => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { MemoryEngine } from '../src/engine/memory';
|
|||||||
import { WAL } from '../src/engine/aria/wal/log';
|
import { WAL } from '../src/engine/aria/wal/log';
|
||||||
import { tokenize } from '../src/sql/lexer';
|
import { tokenize } from '../src/sql/lexer';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// P0-1: Aria WAL DROP_TABLE 崩溃恢复
|
// P0-1: Aria WAL DROP_TABLE 崩溃恢复
|
||||||
@@ -403,7 +403,8 @@ describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
|
|||||||
|
|
||||||
describe('[v0.3.3] 端到端', () => {
|
describe('[v0.3.3] 端到端', () => {
|
||||||
test('全部修复点可共存于 MetonaSqlark API', async () => {
|
test('全部修复点可共存于 MetonaSqlark API', async () => {
|
||||||
expect(VERSION).toBe('0.7.4');
|
// v0.8.0: 版本一致性由 tests/version-contract.test.ts 统一校验
|
||||||
|
expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||||
await db.init();
|
await db.init();
|
||||||
await db.defineTable('users', {
|
await db.defineTable('users', {
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
import { AriaEngine } from '../src/engine/aria/index';
|
import { AriaEngine } from '../src/engine/aria/index';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
import { engineMethod } from './helpers/assertions';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
const uniqueName = (prefix: string): string =>
|
const uniqueName = (prefix: string): string =>
|
||||||
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@@ -326,12 +327,15 @@ describe('[v0.4.0] B-4: COUNT(DISTINCT) + NULLS FIRST/LAST', () => {
|
|||||||
await db.query('CREATE INDEX idx_orders_user ON orders (user_id)');
|
await db.query('CREATE INDEX idx_orders_user ON orders (user_id)');
|
||||||
|
|
||||||
// WHERE 主表前缀条件下推:引擎收到的 orders 查询 where 为 { user_id: { $eq: '1' } }
|
// WHERE 主表前缀条件下推:引擎收到的 orders 查询 where 为 { user_id: { $eq: '1' } }
|
||||||
const eng = db.getEngine() as { find: (...a: unknown[]) => Promise<unknown> };
|
const eng = db.getEngine() as { find?: (...a: unknown[]) => Promise<unknown> };
|
||||||
const origFind = eng.find.bind(eng);
|
if (!eng.find) throw new Error('engine.find is required for this probe');
|
||||||
|
const engineFind = eng.find;
|
||||||
|
const origFind = engineFind.bind(eng) as (...a: unknown[]) => Promise<unknown>;
|
||||||
let pushed: unknown = null;
|
let pushed: unknown = null;
|
||||||
eng.find = async (t: string, q: { where?: unknown }) => {
|
eng.find = async (...args: unknown[]): Promise<unknown> => {
|
||||||
|
const [t, q] = args as [string, { where?: unknown } | undefined];
|
||||||
if (t === 'orders' && q?.where) pushed = q.where;
|
if (t === 'orders' && q?.where) pushed = q.where;
|
||||||
return origFind(t, q);
|
return origFind(...args);
|
||||||
};
|
};
|
||||||
const rows = await db.query(
|
const rows = await db.query(
|
||||||
"SELECT u.name, o.product FROM orders o JOIN users u ON u.id = o.user_id WHERE o.user_id = '1'",
|
"SELECT u.name, o.product FROM orders o JOIN users u ON u.id = o.user_id WHERE o.user_id = '1'",
|
||||||
@@ -371,7 +375,7 @@ describe('[v0.4.0] B-4: COUNT(DISTINCT) + NULLS FIRST/LAST', () => {
|
|||||||
test('Aria $in 查询去重(IN 子查询含重复值不返回重复行)', async () => {
|
test('Aria $in 查询去重(IN 子查询含重复值不返回重复行)', async () => {
|
||||||
const db = new MetonaSqlark({ name: uniqueName('indup'), mode: 'aria', diskEngine: 'opfs' });
|
const db = new MetonaSqlark({ name: uniqueName('indup'), mode: 'aria', diskEngine: 'opfs' });
|
||||||
await db.init();
|
await db.init();
|
||||||
await db.getEngine().clearAll();
|
await engineMethod<() => Promise<void>>(db.getEngine(), 'clearAll')();
|
||||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
||||||
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' } });
|
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' } });
|
||||||
await db.query("INSERT INTO users VALUES ('1', 'Alice'), ('2', 'Bob')");
|
await db.query("INSERT INTO users VALUES ('1', 'Alice'), ('2', 'Bob')");
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ import { SSTableReader } from '../src/engine/aria/index/sstable';
|
|||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
import { OPFSBackend } from '../src/engine/aria/store/opfs_backend';
|
import { OPFSBackend } from '../src/engine/aria/store/opfs_backend';
|
||||||
import { KVStoreEngine } from '../src/engine/kvstore_engine';
|
import { KVStoreEngine } from '../src/engine/kvstore_engine';
|
||||||
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
|
|
||||||
import type { SSTableMeta } from '../src/engine/aria/types';
|
import type { SSTableMeta } from '../src/engine/aria/types';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -58,12 +57,14 @@ describe('P0-1a — SSTableReader 残缺数据防御', () => {
|
|||||||
truncated.set(sstableData.slice(0, indexOffset + 4), 0);
|
truncated.set(sstableData.slice(0, indexOffset + 4), 0);
|
||||||
truncated.set(sstableData.slice(sstableData.byteLength - 32), indexOffset + 4);
|
truncated.set(sstableData.slice(sstableData.byteLength - 32), indexOffset + 4);
|
||||||
// 构造必须不抛 RangeError
|
// 构造必须不抛 RangeError
|
||||||
let reader: SSTableReader;
|
let reader: SSTableReader | undefined;
|
||||||
expect(() => { reader = new SSTableReader(truncated, makeMeta(truncated)); }).not.toThrow();
|
expect(() => { reader = new SSTableReader(truncated, makeMeta(truncated)); }).not.toThrow();
|
||||||
expect(() => (reader as any).get('k-001')).not.toThrow();
|
if (!reader) throw new Error('SSTableReader 构造未成功');
|
||||||
expect(() => (reader as any).rangeScan('a', 'z', () => {})).not.toThrow();
|
const r = reader;
|
||||||
expect(() => (reader as any).scanAll(() => {})).not.toThrow();
|
expect(() => (r as any).get('k-001')).not.toThrow();
|
||||||
expect((reader as any).get('k-001')).toBeNull();
|
expect(() => (r as any).rangeScan('a', 'z', () => {})).not.toThrow();
|
||||||
|
expect(() => (r as any).scanAll(() => {})).not.toThrow();
|
||||||
|
expect((r as any).get('k-001')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('索引条目 blockSize 越界 → 该块跳过,其余块仍可读', () => {
|
it('索引条目 blockSize 越界 → 该块跳过,其余块仍可读', () => {
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import { MemoryEngine } from '../src/engine/memory';
|
|||||||
import { HybridEngine } from '../src/hybrid/index';
|
import { HybridEngine } from '../src/hybrid/index';
|
||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -290,12 +290,12 @@ describe('全模式抽查 — 生命周期与元数据', () => {
|
|||||||
|
|
||||||
it('HybridEngine getMeta/setMeta 委托磁盘', async () => {
|
it('HybridEngine getMeta/setMeta 委托磁盘', async () => {
|
||||||
const dbName = uniqueDB();
|
const dbName = uniqueDB();
|
||||||
const e = new HybridEngine('indexeddb');
|
const e = new HybridEngine('opfs');
|
||||||
await e.open(dbName, 1);
|
await e.open(dbName, 1);
|
||||||
await e.setMeta('__metona_version', '7');
|
await e.setMeta('__metona_version', '7');
|
||||||
expect(await e.getMeta('__metona_version')).toBe('7');
|
expect(await e.getMeta('__metona_version')).toBe('7');
|
||||||
await e.close();
|
await e.close();
|
||||||
const e2 = new HybridEngine('indexeddb');
|
const e2 = new HybridEngine('opfs');
|
||||||
await e2.open(dbName, 1);
|
await e2.open(dbName, 1);
|
||||||
expect(await e2.getMeta('__metona_version')).toBe('7');
|
expect(await e2.getMeta('__metona_version')).toBe('7');
|
||||||
await e2.close();
|
await e2.close();
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { createSchema } from '../src/table/schema';
|
|||||||
import { MemTable } from '../src/engine/aria/index/memtable';
|
import { MemTable } from '../src/engine/aria/index/memtable';
|
||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -25,47 +25,6 @@ function uniqueDB(): string {
|
|||||||
// OPFS mock(模拟真实 I/O:getFileHandle 延迟 + 写入按内容差异化耗时)
|
// OPFS mock(模拟真实 I/O:getFileHandle 延迟 + 写入按内容差异化耗时)
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
function mockOPFS(
|
|
||||||
files?: Map<string, string>,
|
|
||||||
opts: { ioDelay?: number; writeDelayFor?: (data: string) => number } = {},
|
|
||||||
): Map<string, string> {
|
|
||||||
const store = files ?? new Map<string, string>();
|
|
||||||
|
|
||||||
const dirMock = {
|
|
||||||
getDirectoryHandle: async (_name: string, _opts?: any) => dirMock as any,
|
|
||||||
getFileHandle: async (name: string, fileOpts?: any) => {
|
|
||||||
if (fileOpts?.create) {
|
|
||||||
if (opts.ioDelay) {
|
|
||||||
await new Promise((r) => setTimeout(r, opts.ioDelay));
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
createWritable: async () => ({
|
|
||||||
write: async (d: string) => {
|
|
||||||
const delay = opts.writeDelayFor ? opts.writeDelayFor(d) : 0;
|
|
||||||
if (delay > 0) {
|
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
|
||||||
}
|
|
||||||
store.set(name, d);
|
|
||||||
},
|
|
||||||
close: async () => {},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!store.has(name)) throw new Error('Not found');
|
|
||||||
return { getFile: async () => ({ text: async () => store.get(name)!, arrayBuffer: async () => new ArrayBuffer(0) }) };
|
|
||||||
},
|
|
||||||
removeEntry: async (name: string) => { store.delete(name); },
|
|
||||||
};
|
|
||||||
(dirMock as any).entries = () => ({
|
|
||||||
[Symbol.asyncIterator]: async function* () {
|
|
||||||
for (const [k] of store) yield [k];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const nav = (globalThis as any).navigator || {};
|
|
||||||
nav.storage = { getDirectory: async () => dirMock };
|
|
||||||
(globalThis as any).navigator = nav;
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// P0: 事务与 checkpoint 冲突
|
// P0: 事务与 checkpoint 冲突
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { AriaEngine } from '../src/engine/aria/index';
|
|||||||
import { createSchema } from '../src/table/schema';
|
import { createSchema } from '../src/table/schema';
|
||||||
import { LSM } from '../src/engine/aria/index/lsm';
|
import { LSM } from '../src/engine/aria/index/lsm';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -92,7 +92,7 @@ describe('P0 — flush 报告后台失败', () => {
|
|||||||
saved = 0;
|
saved = 0;
|
||||||
deleted = 0;
|
deleted = 0;
|
||||||
metas: { id: number; level: number }[] = [];
|
metas: { id: number; level: number }[] = [];
|
||||||
async save(id: number, _data: Uint8Array): Promise<void> {
|
async save(_id: number, _data: Uint8Array): Promise<void> {
|
||||||
if (this.failNext) {
|
if (this.failNext) {
|
||||||
this.failNext = false;
|
this.failNext = false;
|
||||||
throw new Error('disk full (simulated)');
|
throw new Error('disk full (simulated)');
|
||||||
@@ -159,7 +159,7 @@ describe('P1 — 提交顺序与 close 等待', () => {
|
|||||||
await engine.commitTransaction();
|
await engine.commitTransaction();
|
||||||
// 崩溃恢复路径:WAL 完整则恢复数据
|
// 崩溃恢复路径:WAL 完整则恢复数据
|
||||||
const backend = (engine as any).backend;
|
const backend = (engine as any).backend;
|
||||||
const walKeys = (await backend.listKeys()).filter((k) => k.startsWith('__wal_'));
|
const walKeys = (await backend.listKeys()).filter((k: string) => k.startsWith('__wal_'));
|
||||||
expect(walKeys.length).toBeGreaterThan(0);
|
expect(walKeys.length).toBeGreaterThan(0);
|
||||||
await engine.close();
|
await engine.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import { SSTableBuilder } from '../src/engine/aria/index/sstable_builder';
|
|||||||
import { SSTableReader } from '../src/engine/aria/index/sstable';
|
import { SSTableReader } from '../src/engine/aria/index/sstable';
|
||||||
import type { SSTableMeta } from '../src/engine/aria/types';
|
import type { SSTableMeta } from '../src/engine/aria/types';
|
||||||
|
|
||||||
import { installOPFSMock } from './helpers/opfs-mock';
|
import { resetOPFSMock } from './helpers/storage-harness';
|
||||||
|
|
||||||
beforeEach(() => { installOPFSMock(new Map()); });
|
beforeEach(() => { resetOPFSMock(); });
|
||||||
|
|
||||||
let idbCounter = 0;
|
let idbCounter = 0;
|
||||||
function uniqueDB(): string {
|
function uniqueDB(): string {
|
||||||
@@ -115,7 +115,7 @@ describe('P1 — v1 旧格式兼容', () => {
|
|||||||
let blockSize = 4;
|
let blockSize = 4;
|
||||||
for (const e of encoded) blockSize += 2 + e.keyBytes.length + 2 + e.valueBytes.length;
|
for (const e of encoded) blockSize += 2 + e.keyBytes.length + 2 + e.valueBytes.length;
|
||||||
const indexSize = 4 + 2 + encoded[encoded.length - 1].keyBytes.length + 8;
|
const indexSize = 4 + 2 + encoded[encoded.length - 1].keyBytes.length + 8;
|
||||||
const bloomSize = 0;
|
const _bloomSize = 0;
|
||||||
const total = blockSize + indexSize + 32;
|
const total = blockSize + indexSize + 32;
|
||||||
const buf = new ArrayBuffer(total);
|
const buf = new ArrayBuffer(total);
|
||||||
const view = new DataView(buf);
|
const view = new DataView(buf);
|
||||||
|
|||||||
+22
-21
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
import { MemoryEngine } from '../src/engine/memory';
|
import { MemoryEngine } from '../src/engine/memory';
|
||||||
|
import { rows as rowsOf } from './helpers/assertions';
|
||||||
|
|
||||||
describe('v0.7.3: 索引列 IS NULL(三引擎对齐)', () => {
|
describe('v0.7.3: 索引列 IS NULL(三引擎对齐)', () => {
|
||||||
test.each([
|
test.each([
|
||||||
@@ -28,9 +29,9 @@ describe('v0.7.3: 索引列 IS NULL(三引擎对齐)', () => {
|
|||||||
email: { type: 'string', index: true },
|
email: { type: 'string', index: true },
|
||||||
});
|
});
|
||||||
await db.query("INSERT INTO users VALUES ('1', NULL), ('2', 'a@b.c')");
|
await db.query("INSERT INTO users VALUES ('1', NULL), ('2', 'a@b.c')");
|
||||||
const rows = await db.query('SELECT * FROM users WHERE email IS NULL');
|
const rows = rowsOf<{ id: string }>(await db.query('SELECT * FROM users WHERE email IS NULL'));
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect((rows[0] as Record<string, unknown>).id).toBe('1');
|
expect(rows[0].id).toBe('1');
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,9 +177,9 @@ describe('v0.7.3: insert 语句级原子性', () => {
|
|||||||
let threw = false;
|
let threw = false;
|
||||||
try { await db.query("INSERT INTO users VALUES ('b'), ('a')"); } catch { threw = true; }
|
try { await db.query("INSERT INTO users VALUES ('b'), ('a')"); } catch { threw = true; }
|
||||||
expect(threw).toBe(true);
|
expect(threw).toBe(true);
|
||||||
const rows = await db.query('SELECT * FROM users');
|
const rows = rowsOf<{ id: string }>(await db.query('SELECT * FROM users'));
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect((rows[0] as Record<string, unknown>).id).toBe('a');
|
expect(rows[0].id).toBe('a');
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,9 +358,9 @@ describe('v0.7.3: SELECT * 混别名列投影', () => {
|
|||||||
const db = await MetonaSqlark.create({ name: 'v073-star-alias', mode: 'memory' });
|
const db = await MetonaSqlark.create({ name: 'v073-star-alias', mode: 'memory' });
|
||||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' }, age: { type: 'number' } });
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' }, age: { type: 'number' } });
|
||||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
||||||
const rows = await db.query('SELECT *, name AS nick FROM users');
|
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT *, name AS nick FROM users'));
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
const row = rows[0] as Record<string, unknown>;
|
const row = rows[0];
|
||||||
expect(row.id).toBe('1');
|
expect(row.id).toBe('1');
|
||||||
expect(row.name).toBe('Alice');
|
expect(row.name).toBe('Alice');
|
||||||
expect(row.age).toBe(30);
|
expect(row.age).toBe(30);
|
||||||
@@ -371,8 +372,8 @@ describe('v0.7.3: SELECT * 混别名列投影', () => {
|
|||||||
const db = await MetonaSqlark.create({ name: 'v073-star-only', mode: 'memory' });
|
const db = await MetonaSqlark.create({ name: 'v073-star-only', mode: 'memory' });
|
||||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
||||||
await db.query("INSERT INTO users VALUES ('1', 'Alice')");
|
await db.query("INSERT INTO users VALUES ('1', 'Alice')");
|
||||||
const rows = await db.query('SELECT * FROM users');
|
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT * FROM users'));
|
||||||
expect(Object.keys(rows[0] as Record<string, unknown>).sort()).toEqual(['id', 'name']);
|
expect(Object.keys(rows[0]).sort()).toEqual(['id', 'name']);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -380,8 +381,8 @@ describe('v0.7.3: SELECT * 混别名列投影', () => {
|
|||||||
const db = await MetonaSqlark.create({ name: 'v073-star-const', mode: 'memory' });
|
const db = await MetonaSqlark.create({ name: 'v073-star-const', mode: 'memory' });
|
||||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
||||||
await db.query("INSERT INTO users VALUES ('1')");
|
await db.query("INSERT INTO users VALUES ('1')");
|
||||||
const rows = await db.query("SELECT *, 'lit' AS c FROM users");
|
const rows = rowsOf<Record<string, unknown>>(await db.query("SELECT *, 'lit' AS c FROM users"));
|
||||||
const row = rows[0] as Record<string, unknown>;
|
const row = rows[0];
|
||||||
expect(row.id).toBe('1');
|
expect(row.id).toBe('1');
|
||||||
expect(row.c).toBe('lit');
|
expect(row.c).toBe('lit');
|
||||||
await db.close();
|
await db.close();
|
||||||
@@ -422,7 +423,7 @@ describe('v0.7.3: KVStore insert 持久化 validated 行', () => {
|
|||||||
await db.query("INSERT INTO users VALUES ('1')");
|
await db.query("INSERT INTO users VALUES ('1')");
|
||||||
await db.close();
|
await db.close();
|
||||||
const db2 = await MetonaSqlark.create({ name: 'v073-kv-validated', mode: 'disk', diskEngine: 'memory' });
|
const db2 = await MetonaSqlark.create({ name: 'v073-kv-validated', mode: 'disk', diskEngine: 'memory' });
|
||||||
const rows = await db2.query('SELECT * FROM users');
|
const rows = rowsOf<Record<string, unknown>>(await db2.query('SELECT * FROM users'));
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect(rows[0]).toEqual({ id: '1', age: 18 });
|
expect(rows[0]).toEqual({ id: '1', age: 18 });
|
||||||
await db2.close();
|
await db2.close();
|
||||||
@@ -434,8 +435,8 @@ describe('v0.7.3: KVStore insert 持久化 validated 行', () => {
|
|||||||
await db.table('users').insert({ id: '1', junk: 'x' } as never);
|
await db.table('users').insert({ id: '1', junk: 'x' } as never);
|
||||||
await db.close();
|
await db.close();
|
||||||
const db2 = await MetonaSqlark.create({ name: 'v073-kv-extra-col', mode: 'disk', diskEngine: 'memory' });
|
const db2 = await MetonaSqlark.create({ name: 'v073-kv-extra-col', mode: 'disk', diskEngine: 'memory' });
|
||||||
const rows = await db2.query('SELECT * FROM users');
|
const rows = rowsOf<Record<string, unknown>>(await db2.query('SELECT * FROM users'));
|
||||||
expect(Object.keys(rows[0] as Record<string, unknown>).sort()).toEqual(['id']);
|
expect(Object.keys(rows[0]).sort()).toEqual(['id']);
|
||||||
await db2.close();
|
await db2.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -537,9 +538,9 @@ describe('v0.7.3: $and 等值条件下推', () => {
|
|||||||
age: { type: 'number' },
|
age: { type: 'number' },
|
||||||
});
|
});
|
||||||
await db.query("INSERT INTO users VALUES ('1', 'a', 10), ('2', 'a', 20), ('3', 'b', 10)");
|
await db.query("INSERT INTO users VALUES ('1', 'a', 10), ('2', 'a', 20), ('3', 'b', 10)");
|
||||||
const rows = await db.query("SELECT * FROM users WHERE tag = 'a' AND age = 10");
|
const rows = rowsOf<{ id: string }>(await db.query("SELECT * FROM users WHERE tag = 'a' AND age = 10"));
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect((rows[0] as Record<string, unknown>).id).toBe('1');
|
expect(rows[0].id).toBe('1');
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -569,15 +570,15 @@ describe('v0.7.3: SELECT 常量列 SQL 标准转义', () => {
|
|||||||
const db = await MetonaSqlark.create({ name: 'v073-escape', mode: 'memory' });
|
const db = await MetonaSqlark.create({ name: 'v073-escape', mode: 'memory' });
|
||||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
||||||
await db.query("INSERT INTO users VALUES ('1')");
|
await db.query("INSERT INTO users VALUES ('1')");
|
||||||
const rows = await db.query("SELECT 'O''Brien' AS name FROM users");
|
const rows = rowsOf<{ name: string }>(await db.query("SELECT 'O''Brien' AS name FROM users"));
|
||||||
expect((rows[0] as Record<string, unknown>).name).toBe("O'Brien");
|
expect(rows[0].name).toBe("O'Brien");
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("无表查询 SELECT 'a''b' AS x 转义还原", async () => {
|
test("无表查询 SELECT 'a''b' AS x 转义还原", async () => {
|
||||||
const db = await MetonaSqlark.create({ name: 'v073-escape-notable', mode: 'memory' });
|
const db = await MetonaSqlark.create({ name: 'v073-escape-notable', mode: 'memory' });
|
||||||
const rows = await db.query("SELECT 'a''b' AS x");
|
const rows = rowsOf<{ x: string }>(await db.query("SELECT 'a''b' AS x"));
|
||||||
expect((rows[0] as Record<string, unknown>).x).toBe("a'b");
|
expect(rows[0].x).toBe("a'b");
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -585,8 +586,8 @@ describe('v0.7.3: SELECT 常量列 SQL 标准转义', () => {
|
|||||||
const db = await MetonaSqlark.create({ name: 'v073-escape-star', mode: 'memory' });
|
const db = await MetonaSqlark.create({ name: 'v073-escape-star', mode: 'memory' });
|
||||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
|
||||||
await db.query("INSERT INTO users VALUES ('1')");
|
await db.query("INSERT INTO users VALUES ('1')");
|
||||||
const rows = await db.query("SELECT *, 'x''y' AS c FROM users");
|
const rows = rowsOf<Record<string, unknown>>(await db.query("SELECT *, 'x''y' AS c FROM users"));
|
||||||
const row = rows[0] as Record<string, unknown>;
|
const row = rows[0];
|
||||||
expect(row.id).toBe('1');
|
expect(row.id).toBe('1');
|
||||||
expect(row.c).toBe("x'y");
|
expect(row.c).toBe("x'y");
|
||||||
await db.close();
|
await db.close();
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { MetonaSqlark } from '../src/core';
|
import { MetonaSqlark } from '../src/core';
|
||||||
import { MemoryEngine } from '../src/engine/memory';
|
|
||||||
import { KVStore } from '../src/engine/kvstore/index';
|
import { KVStore } from '../src/engine/kvstore/index';
|
||||||
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
|
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
|
||||||
|
|
||||||
@@ -449,7 +448,6 @@ describe('v0.7.4: Hybrid beginTransaction 部分成功补偿', () => {
|
|||||||
await db.defineTable('t', {
|
await db.defineTable('t', {
|
||||||
id: { type: 'string', primaryKey: true },
|
id: { type: 'string', primaryKey: true },
|
||||||
});
|
});
|
||||||
const disk = (db.getEngine() as { getDiskEngineType?: () => string } & Record<string, unknown>);
|
|
||||||
// 让磁盘引擎 begin 抛错:先把磁盘引擎置于活跃事务
|
// 让磁盘引擎 begin 抛错:先把磁盘引擎置于活跃事务
|
||||||
const diskEngine = (db.getEngine() as unknown as { getDiskEngine?: () => { beginTransaction(): Promise<void> } }).getDiskEngine
|
const diskEngine = (db.getEngine() as unknown as { getDiskEngine?: () => { beginTransaction(): Promise<void> } }).getDiskEngine
|
||||||
? undefined
|
? undefined
|
||||||
@@ -458,6 +456,7 @@ describe('v0.7.4: Hybrid beginTransaction 部分成功补偿', () => {
|
|||||||
// 内存引擎先 begin 成功、磁盘引擎第二个 begin 报 TX_ACTIVE
|
// 内存引擎先 begin 成功、磁盘引擎第二个 begin 报 TX_ACTIVE
|
||||||
const hybridEngine = db.getEngine() as unknown as {
|
const hybridEngine = db.getEngine() as unknown as {
|
||||||
beginTransaction: () => Promise<void>;
|
beginTransaction: () => Promise<void>;
|
||||||
|
rollbackTransaction: () => Promise<void>;
|
||||||
};
|
};
|
||||||
await hybridEngine.beginTransaction();
|
await hybridEngine.beginTransaction();
|
||||||
// 磁盘引擎现在活跃;再次 begin → 内存 begin 成功、磁盘抛 TX_ACTIVE → 补偿回滚
|
// 磁盘引擎现在活跃;再次 begin → 内存 begin 成功、磁盘抛 TX_ACTIVE → 补偿回滚
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 版本一致性契约测试(v0.8.0)
|
||||||
|
*
|
||||||
|
* 背景:此前版本号被硬编码在多个测试文件里(tests/v025-fixes.test.ts:31、
|
||||||
|
* tests/v033-fixes.test.ts:406),每次发版必须手改测试;而且只校验 src 常量,
|
||||||
|
* 与 package.json / dist 产物之间没有任何一致性检查 —— 三者可以静默漂移。
|
||||||
|
*
|
||||||
|
* 本测试把"版本号"变成一个单点校验的契约:
|
||||||
|
* 1. src/constants.ts 的 VERSION 是合法 semver;
|
||||||
|
* 2. package.json 的 version 与 VERSION 完全一致;
|
||||||
|
* 3. (可选)dist 产物存在时,其内嵌 VERSION 必须与上述一致。
|
||||||
|
*
|
||||||
|
* 第 3 条让"dist 与 src 漂移"(CI 无同步校验)变成红灯。
|
||||||
|
*/
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { VERSION } from '../src/constants';
|
||||||
|
|
||||||
|
const ROOT = join(__dirname, '..');
|
||||||
|
|
||||||
|
describe('[v0.8.0] 版本一致性契约', () => {
|
||||||
|
test('src/constants.ts VERSION 是合法语义化版本', () => {
|
||||||
|
expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('package.json version 与 VERSION 常量一致', () => {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { version: string };
|
||||||
|
expect(pkg.version).toBe(VERSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dist 产物内嵌 VERSION 与源码一致(dist 存在时)', () => {
|
||||||
|
const distFile = join(ROOT, 'dist', 'metona-sqlark.esm.js');
|
||||||
|
if (!existsSync(distFile)) {
|
||||||
|
// dist 未构建时不失败(CI 会先 build),但要显式跳过而不是静默通过
|
||||||
|
expect(existsSync(distFile)).toBe(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = readFileSync(distFile, 'utf8');
|
||||||
|
// 构建产物把源码常量内联为 `const VERSION = 'x.y.z';`
|
||||||
|
// (见 rollup 输出,不要去扫描文件里全部字符串字面量 —— 那会命中无关数字)
|
||||||
|
const match = content.match(/\bVERSION\s*=\s*['"](\d+\.\d+\.\d+)['"]/);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
expect(match![1]).toBe(VERSION);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
/*
|
||||||
|
* v0.8.0 新增:测试代码的类型检查配置。
|
||||||
|
*
|
||||||
|
* 背景:jest 用 babel-jest 转译,@babel/preset-typescript **只剥离类型、不做类型检查**,
|
||||||
|
* 而根 tsconfig.json 的 include/exclude 把 tests/ 排除在外 —— 于是测试代码里的
|
||||||
|
* 类型错误、拼写错误、未 await 的断言全部不可见。审计中发现的实例:
|
||||||
|
* tests/v025-fixes.test.ts 里 `diskEngine: 'opfs'` 出现在标题为 indexeddb 的用例中,
|
||||||
|
* 长期无人发现。
|
||||||
|
*
|
||||||
|
* 用法:npm run typecheck:tests
|
||||||
|
*/
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
/* 根 tsconfig 的 rootDir 是 src;测试要一起检查,必须放宽到仓库根 */
|
||||||
|
"rootDir": ".",
|
||||||
|
"types": ["node", "jest"],
|
||||||
|
/* 测试里大量使用 `as any` 构造边界输入,保留宽松度但不放弃检查 */
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "coverage"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user