fix: v0.7.2 语句级原子性 + 事务 DDL 拒绝 + 约束/绑定硬化 — 6 项修复 + 43 回归 + CI 重型套件串行
CI / test (22.x) (push) Successful in 24m26s
CI / e2e (push) Successful in 10m0s
CI / test (18.x) (push) Successful in 27m14s
CI / test (20.x) (push) Failing after 1h19m9s
CI / test (24.x) (push) Successful in 37m49s

- UPDATE 语句级部分提交(P1,四引擎):两阶段全量预检后执行,批内唯一互查,
  任何一行失败整句不执行(aria 场景 WAL 与内存不再错位)
- 事务内 ALTER/CREATE INDEX/DROP INDEX 残留(P1):Memory/KVStore 显式拒绝
  (对齐 Aria),createTable/dropTable 保持可回滚
- SET NULL 级联绕过 required 约束(P1):预检阶段整体拒绝 FOREIGN_KEY_VIOLATION
- bindParameters 注释误判(P2):行注释/块注释中的 ? 与引号不再参与绑定
- 未闭合字符串静默接受 → lexer 抛 PARSE_ERROR;未知 where 操作符抛 QUERY_ERROR
- UPDATE undefined 覆盖列值 → 语义化为不更新(null 仍置空)
- Hybrid 写穿透非原子(P1):磁盘失败自动重载内存对齐磁盘再抛原错误
- CI:Run tests 拆常规并行 + 重型串行(runInBand),重型测试超时余量提升,
  性能护栏 kv 120→240s / opfs 150→300s(仍拦截悬崖回归)
- 测试 1155 → 1198(74 套件),覆盖率 89.82% 保持
This commit is contained in:
thzxx
2026-08-13 15:31:16 +08:00
parent cbe407eb49
commit 05e6823bf1
31 changed files with 2635 additions and 762 deletions
+10 -2
View File
@@ -33,8 +33,16 @@ jobs:
run: npm run lint
continue-on-error: true
- name: Run tests
run: npx jest --forceExit --maxWorkers=2 --no-cache
# v0.7.2: Run tests 拆两步 —— 常规套件并行(快),重型套件串行(runInBand)。
# 此前重型测试(10 万行 kv/opfs、生产矩阵)与常规套件在慢 runner 上并行
# 争抢 CPU 与 4GB 堆 → 单测超时(120~180s)与 OOM 类假失败。
- name: Run tests (regular suites)
run: npx jest --forceExit --maxWorkers=2 --no-cache --testPathIgnorePatterns='/node_modules/|/tests/e2e/|/tests/helpers/|aria-prod-load|kvstore-stress|aria-matrix-audit|aria-idx-flush-race'
env:
NODE_OPTIONS: --max-old-space-size=4096
- name: Run tests (heavy suites, serial)
run: npx jest --forceExit --runInBand --no-cache tests/engine/aria-prod-load.test.ts tests/engine/kvstore-stress.test.ts tests/engine/aria-matrix-audit.test.ts tests/engine/aria-idx-flush-race.test.ts
env:
NODE_OPTIONS: --max-old-space-size=4096
+48
View File
@@ -2,6 +2,54 @@
All notable changes to MetonaSqlark will be documented in this file.
## [0.7.2] - 2026-08-13
### 语句级原子性 / 事务 DDL 语义统一 / 约束与绑定硬化
> 深度审计第五阶段:修复 UPDATE 语句级部分提交、事务内 DDL 回滚残留、
> SET NULL 级联绕过 required 约束、参数绑定注释误判等 6 项问题,
> Hybrid 写穿透失败补偿,四引擎语义对齐。
### Fixed
- **UPDATE 语句级部分提交(P1Memory/Aria/KVStore/Hybrid** — 单条 UPDATE 匹配
多行时,第 N 行唯一约束/校验失败抛错,前 N-1 行已写入(aria 场景其 WAL 记录
随 appendBatch 一起丢失,内存与 WAL 进一步不一致)。改为两阶段:先全量预检
validateRow + 批内唯一互查 + 索引唯一查 + 主键冲突 + 外键 RESTRICT/SET NULL
预检),任何一行失败整句不执行,后统一执行
- **批内唯一互查缺失** — 两行在同一语句中更新到同一新唯一值:索引尚未反映本语句
变更,逐行检查相互看不见 → 绕过唯一约束。两阶段预检增加批内 Set 互查
- **事务内 DDL 回滚残留(P1)** — `BEGIN; ALTER ADD COLUMN; ROLLBACK` 后新列残留
Memory/KVStore 事务快照对 schema 是浅拷贝,alterTable 直改共享 columns 对象)。
Memory/KVStore 的 ALTER TABLE / CREATE INDEX / DROP INDEX 在事务内显式拒绝
`NOT_SUPPORTED`,与 AriaEngine 对齐);createTable/dropTable 保持可回滚
- **SET NULL 级联绕过 required 约束(P1)** — 外键列 `required: true` 时 delete 级联
与 update 主键变更级联静默写入 null(不经过 validateRow)。预检阶段整体拒绝
`FOREIGN_KEY_VIOLATION`),Memory/Aria 双引擎对齐
- **bindParameters 注释误判(P2** — `-- comment ?` 中的 `?` 计入占位符(参数错位
PARAM_ERROR);注释中的单引号触发 "Unterminated string literal"。词法扫描感知
行注释与块注释(其中 `?`/引号不参与绑定与字符串状态机)
- **未闭合字符串静默接受** — `SELECT 'abc` 此前解析成功(错误结果),lexer 现抛
`PARSE_ERROR`
- **未知 where 操作符静默全匹配(P3)** — `$betwen` 等未实现/拼错操作符此前
`default: return true`(所有行匹配、过滤形同虚设)。现抛 `QUERY_ERROR`
- **UPDATE undefined 覆盖列值** — `update({ col: undefined })` 此前把 undefined 写入
行(列键丢失)。undefined 现语义化为"不更新该列"(保留旧值),null 仍显式置空
(四引擎统一;KVStore 主键 undefined 不再误触主键变更路径)
- **Hybrid write-through 非原子(P1)** — 磁盘写失败时内存已写入(重启丢数据且错误
已抛)。insert/update/delete/createTable/dropTable/alterTable/clear/createIndex/
dropIndex 磁盘失败后自动从磁盘重载内存(内存=磁盘对齐),再抛原始错误;
事务路径由双引擎快照回滚保证不受影响
### Changed
- 测试 1155 → **1198**74 套件,+43 个 v0.7.2 回归);新增 `tests/v072-fixes.test.ts`
(语句原子性 ×6 / 批内互查 ×3 / 事务 DDL ×6 / SET NULL 约束 ×5 / 注释感知 ×6 /
未闭合字符串 ×2 / 未知操作符 ×2 / undefined 语义 ×9 / Hybrid 补偿 ×4
- `CONTRIBUTING.md` 项目结构同步(移除已删除的 indexeddb/opfs 引擎与 utils.ts
---
## [0.7.1] - 2026-08-13
### API 修复 / 防御统一 / 工程质量
+8 -6
View File
@@ -42,22 +42,23 @@ src/
├── index.ts # Entry point, global API (MetonaSqlark + MeSqlark)
├── core.ts # MetonaSqlark main class
├── constants.ts # Types, defaults, enums, errors
├── utils.ts # Utility functions
├── connection-manager.ts # Connection pool (connect/disconnect)
├── engine/ # Storage engines
│ ├── interface.ts # IStorageEngine interface
│ ├── memory.ts # MemoryEngine (Map-based)
│ ├── indexeddb.ts # IndexedDBEngine (browser persistence)
│ ├── opfs.ts # OPFSEngine (Origin Private File System)
│ ├── kvstore_engine.ts # KVStoreEngine (disk mode, self-built KV store)
│ ├── kvstore/ # KVStore (log + snapshot + atomic multi-key write)
│ └── aria/ # AriaEngine (LSM-Tree page storage engine)
│ ├── index/ # LSM / MemTable / SSTable / Bloom / MergeIterator
│ ├── page/ # 4KB slotted page format
│ ├── buffer/ # Buffer Pool (LRU eviction)
│ ├── wal/ # Write-Ahead Log + Checkpoint
│ ├── transaction/ # MVCC manager
│ ├── store/ # Backends (IndexedDB / OPFS / Memory)
│ ├── store/ # Backends (OPFS / KVStore / Memory / Encrypted)
│ ├── locks.ts # Web Locks multi-tab exclusive lock
│ └── compression/ # LZ4
├── hybrid/ # HybridEngine (write-through)
├── migration/ # Legacy IndexedDB migration tool (one-shot)
├── table/ # Table management & Schema validation
├── query/ # Query system
│ ├── ast.ts # SQL AST type definitions
@@ -68,12 +69,13 @@ src/
├── sql/ # SQL parser
│ ├── tokens.ts # Token types & keywords
│ ├── lexer.ts # Tokenizer
── parser.ts # Recursive descent parser
── parser.ts # Recursive descent parser
│ └── params.ts # Parameter binding (? placeholders)
├── transaction/ # Transaction manager
├── plugin/ # Plugin system (14 lifecycle hooks)
└── integrations/ # React & Vue hooks
tests/ # Test suite (1022 test cases, 62 suites + 7 e2e)
tests/ # Test suite (1198 test cases, 74 suites + 12 e2e)
tests/helpers/ # 共享测试工具(OPFS mock 等)
tests/e2e/ # Playwright e2e(真实 Chromium + OPFS
site/ # Documentation site (index / docs / demo)
+5 -5
View File
@@ -1,10 +1,10 @@
# MetonaSqlark
<p align="center">
<img src="https://img.shields.io/badge/version-0.7.1-blue?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-0.7.2-blue?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
<img src="https://img.shields.io/badge/coverage-89.8%25-brightgreen?style=flat-square" alt="coverage">
<img src="https://img.shields.io/badge/tests-1155%20passed-success?style=flat-square" alt="tests">
<img src="https://img.shields.io/badge/tests-1198%20passed-success?style=flat-square" alt="tests">
</p>
> 基于 TypeScript 的**前端关系型数据库**:完整 SQL + Query Builder 双 API
@@ -401,7 +401,7 @@ const { data, loading, error, refresh } = useSqlarkQuery(db, 'SELECT * FROM user
npm install # 安装依赖
npm run dev # 开发模式(localhost:3001
npm run build # 生产构建(生成 dist/
npm test # 运行测试(1155 用例 · 73 套件)
npm test # 运行测试(1198 用例 · 74 套件)
npm run test:e2e # Playwright e2e(真实 Chromium + OPFS + 崩溃注入,需先 build
npm run lint # 代码检查
npm run typecheck # 类型检查
@@ -413,8 +413,8 @@ npm run typecheck # 类型检查
| 指标 | 数值 |
|------|------|
| 测试用例 | 1155+12 Playwright e2e |
| 测试套件 | 70 |
| 测试用例 | 1198+12 Playwright e2e |
| 测试套件 | 74 |
| 行覆盖率 | 89.8% |
| SQL 关键字 | 72 |
| 存储引擎 | 5Memory / KVStore / OPFS / Hybrid / Aria |
+501 -201
View File
@@ -34,7 +34,7 @@ class DatabaseError extends Error {
// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------
const VERSION = '0.7.1';
const VERSION = '0.7.2';
/**
* metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑
@@ -160,7 +160,10 @@ function matchOperator(value, op, operand) {
case '$in': return Array.isArray(operand) && operand.includes(value);
case '$nin': return Array.isArray(operand) && !operand.includes(value);
case '$like': return compileLikeRegex(String(operand)).test(String(value));
default: return true;
// v0.7.2: 未知操作符显式报错 —— 此前静默返回 true(所有行匹配),
// 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示
default:
throw new DatabaseError(`Unknown where operator "${op}"`, 'QUERY_ERROR');
}
}
// ---------------------------------------------------------------------------
@@ -219,6 +222,123 @@ function projectColumns(row, columns) {
return projected;
}
/**
* metona-sqlark Schema 表结构定义与校验
* @module table/schema
*/
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
function createSchema(name, columns) {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
function validateColumns(columns) {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR');
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR');
}
}
/**
* v0.7.2: 更新载荷清洗 undefined 值视为"不更新该列"保留旧值
* 此前 `update({ col: undefined })` 会把 undefined 写入行覆盖旧值列键丢失
* null 保留显式置空语义
*/
function stripUndefinedUpdates(updates) {
const clean = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined)
clean[key] = value;
}
return clean;
}
/** 检查字段类型(含约束校验) */
function checkFieldType(tableName, colName, type, value, colDef) {
const jsType = typeof value;
switch (type) {
case 'string':
if (jsType !== 'string') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR');
}
break;
case 'number':
if (jsType !== 'number') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.min !== undefined && value < colDef.min) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR');
}
if (colDef?.max !== undefined && value > colDef.max) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR');
}
break;
case 'boolean':
if (jsType !== 'boolean') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR');
}
break;
case 'date':
if (jsType !== 'string' || isNaN(Date.parse(value))) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR');
}
break;
case 'json':
if (jsType !== 'object') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR');
}
break;
}
}
/** 将 AST 列定义转换为 ColumnDef */
function astColumnToColumnDef(astCol) {
return {
type: astCol.type,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}
/**
* metona-sqlark Memory Engine 基于 Map 的内存存储引擎
* @module engine/memory
@@ -301,6 +421,12 @@ class MemoryEngine {
* 此前走 executor 通用路径行为相同统一到引擎层保证 Hybrid/IndexedDB 委托一致性
*/
async alterTable(tableName, action, column) {
// v0.7.2: 事务内 DDL 显式拒绝(与 AriaEngine 对齐)。此前事务快照对 schema
// 是浅拷贝,alterTable 直接修改共享 columns 对象 → ROLLBACK 后结构变更残留
// (三引擎行为不一致:Aria 拒绝 / Memory、KVStore 静默残留)
if (this.snapshot) {
throw new DatabaseError(`ALTER TABLE is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
if (action === 'ADD') {
@@ -389,32 +515,116 @@ class MemoryEngine {
const schema = this.schemas.get(tableName);
const table = this.tables.get(tableName);
const pkCol = this.getPrimaryKey(schema);
let count = 0;
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
for (const [pk, row] of [...table]) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
const planned = [];
const batchUnique = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const [pk, row] of table) {
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
planned.push({ pk, row, updated, newPk });
}
// 阶段 1b:主键变更 RESTRICT 预检(引用表依赖行检查,任何修改前)
for (const p of planned) {
if (p.newPk !== p.pk)
this.checkUpdateRestrict(tableName, p.pk);
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
let count = 0;
for (const { pk, row, updated, newPk } of planned) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
}
return count;
}
/**
* v0.7.2: 更新唯一性预检 批内互查多条行更新到同一唯一值+ 索引查
* 排除自身旧条目阶段 1 中索引尚未更新批内互查避免"两行同时改到
* 同一新值"绕过唯一约束
*/
checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique) {
const tableIndexes = this.indexes.get(tableName);
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique)
continue;
const value = updated[colName];
if (value === undefined || value === null)
continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
}
seen.add(value);
if (!tableIndexes)
continue;
const colIndex = tableIndexes.get(colName);
if (colIndex && colIndex.has(value)) {
const pks = colIndex.get(value);
// 值未变(新值 = 旧值)且索引中只有自身 → 允许
if (!(pks.size === 1 && pks.has(pk))) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
}
}
}
}
/**
* v0.7.2: ON UPDATE RESTRICT 预检 applyUpdateCascade 提取
* 两阶段 update 在任何修改前调用整体拒绝语义
*/
checkUpdateRestrict(tableName, oldPk) {
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName)
continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate)
continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName)
continue;
const refTableData = this.tables.get(refTableName);
if (!refTableData)
continue;
let hasDependents = false;
for (const [, refRow] of refTableData) {
if (String(refRow[colName]) !== oldPk)
continue;
hasDependents = true;
if (colDef.onUpdate === 'RESTRICT') {
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 与 RESTRICT 同样整体拒绝
// (此前级联直写 null 绕过 validateRowrequired 列被静默置空)
if (hasDependents && colDef.onUpdate === 'SET NULL' && colDef.required) {
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 被引用表主键变更时处理引用表
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空
@@ -526,6 +736,10 @@ class MemoryEngine {
if (colDef.onDelete === 'RESTRICT' && refPks.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && refPks.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
if (colDef.onDelete === 'CASCADE') {
for (const refPk of refPks) {
this.checkCascadeRestrict(refTableName, refPk, visited);
@@ -556,6 +770,11 @@ class MemoryEngine {
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName, column, unique) {
// v0.7.2: 事务内修改列级标志(colDef.index/unique)会写入共享列对象,
// 事务快照无法回滚 → 与 alterTable 同样显式拒绝
if (this.snapshot) {
throw new DatabaseError(`CREATE INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
const colDef = schema.columns[column];
@@ -581,6 +800,10 @@ class MemoryEngine {
}
}
async dropIndex(tableName, column, _indexName) {
// v0.7.2: 同 createIndex —— 列级标志修改无法通过事务快照回滚,显式拒绝
if (this.snapshot) {
throw new DatabaseError(`DROP INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
const colDef = schema.columns[column];
@@ -2029,6 +2252,12 @@ class KVStoreEngine {
}
async alterTable(tableName, action, column) {
this.ensureOpen();
// v0.7.2: 事务内 ALTER 显式拒绝(与 AriaEngine/MemoryEngine 对齐)——
// memory.alterTable 直接修改共享 columns 对象,事务快照无法回滚
// (此前 ROLLBACK 后新增列残留)
if (this.txActive) {
throw new DatabaseError(`ALTER TABLE is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.alterTable(tableName, action, column);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2090,10 +2319,12 @@ class KVStoreEngine {
if (!schema)
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
const pkCol = this.getPK(schema);
const pkChanged = pkCol in updates;
// v0.7.2: undefined 值视为"不更新该列"(与 memory.update 语义对齐)
const cleanUpdates = stripUndefinedUpdates(updates);
const pkChanged = pkCol in cleanUpdates;
// 收集受影响旧主键(内存匹配)
const affected = pkChanged ? [] : await this.collectMatchingPks(tableName, query);
const count = await this.memory.update(tableName, query, updates);
const count = await this.memory.update(tableName, query, cleanUpdates);
if (this.txActive) {
this.txDirtyTables.add(tableName);
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
@@ -2218,6 +2449,9 @@ class KVStoreEngine {
// ---- 动态索引 ----
async createIndex(tableName, column, unique) {
this.ensureOpen();
if (this.txActive) {
throw new DatabaseError(`CREATE INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.createIndex(tableName, column, unique);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2228,6 +2462,9 @@ class KVStoreEngine {
}
async dropIndex(tableName, column, indexName) {
this.ensureOpen();
if (this.txActive) {
throw new DatabaseError(`DROP INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.dropIndex(tableName, column, indexName);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2436,110 +2673,6 @@ class KVStoreEngine {
}
}
/**
* metona-sqlark Schema 表结构定义与校验
* @module table/schema
*/
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
function createSchema(name, columns) {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
function validateColumns(columns) {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR');
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR');
}
}
/** 检查字段类型(含约束校验) */
function checkFieldType(tableName, colName, type, value, colDef) {
const jsType = typeof value;
switch (type) {
case 'string':
if (jsType !== 'string') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR');
}
break;
case 'number':
if (jsType !== 'number') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.min !== undefined && value < colDef.min) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR');
}
if (colDef?.max !== undefined && value > colDef.max) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR');
}
break;
case 'boolean':
if (jsType !== 'boolean') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR');
}
break;
case 'date':
if (jsType !== 'string' || isNaN(Date.parse(value))) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR');
}
break;
case 'json':
if (jsType !== 'object') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR');
}
break;
}
}
/** 将 AST 列定义转换为 ColumnDef */
function astColumnToColumnDef(astCol) {
return {
type: astCol.type,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}
/**
* AriaEngine Types 内部类型定义
* @module engine/aria/types
@@ -6854,16 +6987,18 @@ class AriaEngine {
const walRecords = [];
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set();
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
const ranges = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
const p = `${String(cleanUpdates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
}
else if (!(colName in updates)) {
else if (!(colName in cleanUpdates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null)
@@ -6874,66 +7009,85 @@ class AriaEngine {
}
await idxLsm.prefetchPrefixRanges(ranges);
}
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
const planned = [];
const batchUnique = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName);
const key = `${tableName}:${row[pkCol]}`;
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
planned.push({ row, pk: String(row[pkCol]), key, updated, newPk, pkChanged });
}
// 阶段 1b:主键变更 RESTRICT / SET NULL+required 预检(任何修改前)
for (const p of planned) {
if (p.pkChanged) {
await this.checkForeignKeyUpdateRestrict(tableName, p.pk, p.newPk);
}
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
for (const { row, pk, key, updated, newPk, pkChanged } of planned) {
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
this.txnSnapshot.set(key, { __txn_deleted: true });
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
}
else {
if (pkChanged)
this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
this.txnSnapshot.set(key, { __txn_deleted: true });
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
}
else {
if (pkChanged)
this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.UPDATE,
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
key: pk,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
await this.wal.appendBatch(walRecords);
this.opCounter += count;
@@ -6941,6 +7095,54 @@ class AriaEngine {
this.trimAllCaches();
return count;
}
/**
* v0.7.2: 批内唯一互查 两条行在同一语句中更新到同一唯一值时的兜底检查
* 阶段 1 中索引尚未反映本语句的变更
*/
checkBatchUnique(tableName, uniqueCols, updated, batchUnique) {
for (const colName of uniqueCols) {
const value = updated[colName];
if (value === undefined || value === null)
continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
seen.add(value);
}
}
/**
* v0.7.2: ON UPDATE 外键预检 applyForeignKeyUpdateRules 提取两阶段 update
* RESTRICT 存在依赖行抛错SET NULL required 列同样整体拒绝
*/
async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) {
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName)
continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate)
continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName)
continue;
if (colDef.onUpdate === 'RESTRICT' || (colDef.onUpdate === 'SET NULL' && colDef.required)) {
const refRows = await this.getAllRows(refTableName);
for (const refRow of refRows) {
if (String(refRow[colName]) === oldPk) {
const reason = colDef.onUpdate === 'RESTRICT'
? `foreign key "${colName}" in "${refTableName}" has dependent rows`
: `foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`;
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": ${reason}`, 'FOREIGN_KEY_VIOLATION');
}
}
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 主键 oldPk newPk 时处理引用表
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空含索引与 WAL 记录
@@ -7083,6 +7285,10 @@ class AriaEngine {
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && matched.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
if (colDef.onDelete === 'CASCADE') {
const refPkCol = this.tablePKs.get(refTableName);
for (const refRow of matched) {
@@ -8255,11 +8461,21 @@ class HybridEngine {
// ---- 表管理 ----
async createTable(schema) {
await this.memoryEngine.createTable(schema);
await this.diskEngine.createTable(schema);
try {
await this.diskEngine.createTable(schema);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropTable(tableName) {
await this.memoryEngine.dropTable(tableName);
await this.diskEngine.dropTable(tableName);
try {
await this.diskEngine.dropTable(tableName);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async hasTable(tableName) {
return this.memoryEngine.hasTable(tableName);
@@ -8273,21 +8489,49 @@ class HybridEngine {
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
async alterTable(tableName, action, column) {
await this.memoryEngine.alterTable(tableName, action, column);
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
try {
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
}
else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP')
delete schema.columns[column.name];
}
}
else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP')
delete schema.columns[column.name];
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- CRUDwrite-through 策略) ----
/**
* v0.7.2: 磁盘写失败补偿 内存已先行写入磁盘失败 内存与磁盘不一致
* 重启后数据丢失且调用方已收到错误从磁盘重载内存对齐真实状态
* 内存=磁盘再重新抛出原始错误事务路径由双引擎快照回滚保证
* 无需此补偿
*/
async recoverMemoryAfterDiskError(error) {
try {
await this.reloadMemoryFromDisk();
}
catch {
// 磁盘本身不可用(错误根源)时重载可能失败:错误已抛给调用方,
// 内存保持失败前状态,repair()/重试可恢复
// eslint-disable-next-line no-console
console.warn('[metona-sqlark] Hybrid: failed to reload memory after disk write error');
}
throw error;
}
async insert(tableName, rows) {
const pks = await this.memoryEngine.insert(tableName, rows);
// write-through: 同步写入磁盘
await this.diskEngine.insert(tableName, rows);
try {
await this.diskEngine.insert(tableName, rows);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return pks;
}
async find(tableName, query) {
@@ -8301,13 +8545,23 @@ class HybridEngine {
async update(tableName, query, updates) {
const count = await this.memoryEngine.update(tableName, query, updates);
// write-through: 同步更新磁盘
await this.diskEngine.update(tableName, query, updates);
try {
await this.diskEngine.update(tableName, query, updates);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async delete(tableName, query) {
const count = await this.memoryEngine.delete(tableName, query);
// write-through: 同步删除磁盘
await this.diskEngine.delete(tableName, query);
try {
await this.diskEngine.delete(tableName, query);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async count(tableName, query) {
@@ -8315,19 +8569,34 @@ class HybridEngine {
}
async clear(tableName) {
await this.memoryEngine.clear(tableName);
await this.diskEngine.clear(tableName);
try {
await this.diskEngine.clear(tableName);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName, column, unique) {
await this.memoryEngine.createIndex(tableName, column, unique);
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
try {
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
}
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropIndex(tableName, column, indexName) {
await this.memoryEngine.dropIndex(tableName, column, indexName);
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
try {
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
}
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 事务 ----
@@ -9057,6 +9326,11 @@ class Lexer {
value += this.ch;
this.readChar();
}
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
if (this.ch === '') {
throw new DatabaseError(`Unterminated string literal at position ${start}`, 'PARSE_ERROR');
}
return {
type: TokenType.STRING,
value,
@@ -11623,6 +11897,10 @@ class QueryExecutor {
* 绑定在词法层面完成仅替换字符串字面量之外的 `?`
* 值按 SQL 字面量编码字符串 `''` 转义数字/布尔/JSON 直出
* 从根上规避 SQL 注入不经过字符串拼接由用户自行转义
*
* v0.7.2: 词法扫描感知注释 行注释`--`与块注释slash-star 包裹中的 `?`
* 与引号不再参与占位符识别与字符串状态机此前注释中的 `?` 计入占位符导致
* PARAM_ERROR 错位注释中的单引号触发 "Unterminated string literal"
*/
/** 将单个参数值编码为 SQL 字面量 */
function encodeParam(value) {
@@ -11641,7 +11919,7 @@ function encodeParam(value) {
throw new DatabaseError('Object/array query parameters are not supported by SQL binding (pass JSON strings explicitly)', 'PARAM_ERROR');
}
/**
* SQL 中的位置参数 `?`字符串字面量之外替换为编码后的字面量
* SQL 中的位置参数 `?`字符串字面量与注释之外替换为编码后的字面量
* @param sql `?` 占位符的 SQL
* @param params 位置参数数组
* @throws PARAM_ERROR 参数数量不匹配
@@ -11676,6 +11954,28 @@ function bindParameters(sql, params) {
i++;
continue;
}
// v0.7.2: 行注释 `-- ...`(含其中的 ? 与引号)原样保留、不参与绑定
if (ch === '-' && sql[i + 1] === '-') {
while (i < sql.length && sql[i] !== '\n' && sql[i] !== '\r') {
out += sql[i];
i++;
}
continue;
}
// v0.7.2: 块注释(slash-star 包裹)同样跳过
if (ch === '/' && sql[i + 1] === '*') {
out += sql[i] + sql[i + 1];
i += 2;
while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) {
out += sql[i];
i++;
}
if (i < sql.length) {
out += sql[i] + sql[i + 1];
i += 2;
}
continue;
}
if (ch === '?') {
if (pIdx >= params.length) {
throw new DatabaseError(`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`, 'PARAM_ERROR');
+1 -1
View File
File diff suppressed because one or more lines are too long
+29 -1
View File
@@ -164,7 +164,7 @@ interface MetonaPlugin {
/** 销毁 */
destroy(): void;
}
declare const VERSION = "0.7.1";
declare const VERSION = "0.7.2";
/**
* metona-sqlark Plugin — 插件系统
@@ -807,6 +807,17 @@ declare class MemoryEngine implements IStorageEngine {
/** v0.4.0: 流式查询 — 逐行回调(单次迭代,不物化结果数组) */
findStream(tableName: string, query: QueryPlan, onRow: (row: Record<string, unknown>) => void): Promise<number>;
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
/**
* v0.7.2: 更新唯一性预检 — 批内互查(多条行更新到同一唯一值)+ 索引查
* (排除自身旧条目)。阶段 1 中索引尚未更新,批内互查避免"两行同时改到
* 同一新值"绕过唯一约束。
*/
private checkUpdateUniqueness;
/**
* v0.7.2: ON UPDATE RESTRICT 预检 — 从 applyUpdateCascade 提取,
* 两阶段 update 在任何修改前调用(整体拒绝语义)。
*/
private checkUpdateRestrict;
/**
* v0.4.2-fix: ON UPDATE 外键级联 — 被引用表主键变更时处理引用表:
* RESTRICT 抛错 / CASCADE 更新 FK 值 / SET NULL 置空。
@@ -1026,6 +1037,16 @@ declare class AriaEngine implements IStorageEngine {
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number>;
/**
* v0.7.2: 批内唯一互查 — 两条行在同一语句中更新到同一唯一值时的兜底检查
* (阶段 1 中索引尚未反映本语句的变更)。
*/
private checkBatchUnique;
/**
* v0.7.2: ON UPDATE 外键预检 — 从 applyForeignKeyUpdateRules 提取(两阶段 update 用):
* RESTRICT 存在依赖行抛错;SET NULL 撞 required 列同样整体拒绝。
*/
private checkForeignKeyUpdateRestrict;
/**
* v0.4.2-fix: ON UPDATE 外键级联 — 主键 oldPk → newPk 时处理引用表。
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空(含索引与 WAL 记录)。
@@ -1181,6 +1202,13 @@ declare class HybridEngine implements IStorageEngine {
alterTable(tableName: string, action: 'ADD' | 'DROP', column: ColumnDef & {
name: string;
}): Promise<void>;
/**
* v0.7.2: 磁盘写失败补偿 — 内存已先行写入、磁盘失败 → 内存与磁盘不一致
* (重启后数据丢失且调用方已收到错误)。从磁盘重载内存对齐真实状态
* (内存=磁盘),再重新抛出原始错误。事务路径由双引擎快照回滚保证,
* 无需此补偿。
*/
private recoverMemoryAfterDiskError;
insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]>;
find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]>;
/** v0.4.0: 流式查询(内存引擎逐行回调) */
+501 -201
View File
@@ -30,7 +30,7 @@ class DatabaseError extends Error {
// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------
const VERSION = '0.7.1';
const VERSION = '0.7.2';
/**
* metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑
@@ -156,7 +156,10 @@ function matchOperator(value, op, operand) {
case '$in': return Array.isArray(operand) && operand.includes(value);
case '$nin': return Array.isArray(operand) && !operand.includes(value);
case '$like': return compileLikeRegex(String(operand)).test(String(value));
default: return true;
// v0.7.2: 未知操作符显式报错 —— 此前静默返回 true(所有行匹配),
// 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示
default:
throw new DatabaseError(`Unknown where operator "${op}"`, 'QUERY_ERROR');
}
}
// ---------------------------------------------------------------------------
@@ -215,6 +218,123 @@ function projectColumns(row, columns) {
return projected;
}
/**
* metona-sqlark Schema 表结构定义与校验
* @module table/schema
*/
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
function createSchema(name, columns) {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
function validateColumns(columns) {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR');
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR');
}
}
/**
* v0.7.2: 更新载荷清洗 undefined 值视为"不更新该列"保留旧值
* 此前 `update({ col: undefined })` 会把 undefined 写入行覆盖旧值列键丢失
* null 保留显式置空语义
*/
function stripUndefinedUpdates(updates) {
const clean = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined)
clean[key] = value;
}
return clean;
}
/** 检查字段类型(含约束校验) */
function checkFieldType(tableName, colName, type, value, colDef) {
const jsType = typeof value;
switch (type) {
case 'string':
if (jsType !== 'string') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR');
}
break;
case 'number':
if (jsType !== 'number') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.min !== undefined && value < colDef.min) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR');
}
if (colDef?.max !== undefined && value > colDef.max) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR');
}
break;
case 'boolean':
if (jsType !== 'boolean') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR');
}
break;
case 'date':
if (jsType !== 'string' || isNaN(Date.parse(value))) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR');
}
break;
case 'json':
if (jsType !== 'object') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR');
}
break;
}
}
/** 将 AST 列定义转换为 ColumnDef */
function astColumnToColumnDef(astCol) {
return {
type: astCol.type,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}
/**
* metona-sqlark Memory Engine 基于 Map 的内存存储引擎
* @module engine/memory
@@ -297,6 +417,12 @@ class MemoryEngine {
* 此前走 executor 通用路径行为相同统一到引擎层保证 Hybrid/IndexedDB 委托一致性
*/
async alterTable(tableName, action, column) {
// v0.7.2: 事务内 DDL 显式拒绝(与 AriaEngine 对齐)。此前事务快照对 schema
// 是浅拷贝,alterTable 直接修改共享 columns 对象 → ROLLBACK 后结构变更残留
// (三引擎行为不一致:Aria 拒绝 / Memory、KVStore 静默残留)
if (this.snapshot) {
throw new DatabaseError(`ALTER TABLE is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
if (action === 'ADD') {
@@ -385,32 +511,116 @@ class MemoryEngine {
const schema = this.schemas.get(tableName);
const table = this.tables.get(tableName);
const pkCol = this.getPrimaryKey(schema);
let count = 0;
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
for (const [pk, row] of [...table]) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
const planned = [];
const batchUnique = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const [pk, row] of table) {
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
planned.push({ pk, row, updated, newPk });
}
// 阶段 1b:主键变更 RESTRICT 预检(引用表依赖行检查,任何修改前)
for (const p of planned) {
if (p.newPk !== p.pk)
this.checkUpdateRestrict(tableName, p.pk);
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
let count = 0;
for (const { pk, row, updated, newPk } of planned) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
}
return count;
}
/**
* v0.7.2: 更新唯一性预检 批内互查多条行更新到同一唯一值+ 索引查
* 排除自身旧条目阶段 1 中索引尚未更新批内互查避免"两行同时改到
* 同一新值"绕过唯一约束
*/
checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique) {
const tableIndexes = this.indexes.get(tableName);
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique)
continue;
const value = updated[colName];
if (value === undefined || value === null)
continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
}
seen.add(value);
if (!tableIndexes)
continue;
const colIndex = tableIndexes.get(colName);
if (colIndex && colIndex.has(value)) {
const pks = colIndex.get(value);
// 值未变(新值 = 旧值)且索引中只有自身 → 允许
if (!(pks.size === 1 && pks.has(pk))) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
}
}
}
}
/**
* v0.7.2: ON UPDATE RESTRICT 预检 applyUpdateCascade 提取
* 两阶段 update 在任何修改前调用整体拒绝语义
*/
checkUpdateRestrict(tableName, oldPk) {
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName)
continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate)
continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName)
continue;
const refTableData = this.tables.get(refTableName);
if (!refTableData)
continue;
let hasDependents = false;
for (const [, refRow] of refTableData) {
if (String(refRow[colName]) !== oldPk)
continue;
hasDependents = true;
if (colDef.onUpdate === 'RESTRICT') {
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 与 RESTRICT 同样整体拒绝
// (此前级联直写 null 绕过 validateRowrequired 列被静默置空)
if (hasDependents && colDef.onUpdate === 'SET NULL' && colDef.required) {
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 被引用表主键变更时处理引用表
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空
@@ -522,6 +732,10 @@ class MemoryEngine {
if (colDef.onDelete === 'RESTRICT' && refPks.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && refPks.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
if (colDef.onDelete === 'CASCADE') {
for (const refPk of refPks) {
this.checkCascadeRestrict(refTableName, refPk, visited);
@@ -552,6 +766,11 @@ class MemoryEngine {
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName, column, unique) {
// v0.7.2: 事务内修改列级标志(colDef.index/unique)会写入共享列对象,
// 事务快照无法回滚 → 与 alterTable 同样显式拒绝
if (this.snapshot) {
throw new DatabaseError(`CREATE INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
const colDef = schema.columns[column];
@@ -577,6 +796,10 @@ class MemoryEngine {
}
}
async dropIndex(tableName, column, _indexName) {
// v0.7.2: 同 createIndex —— 列级标志修改无法通过事务快照回滚,显式拒绝
if (this.snapshot) {
throw new DatabaseError(`DROP INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
const colDef = schema.columns[column];
@@ -2025,6 +2248,12 @@ class KVStoreEngine {
}
async alterTable(tableName, action, column) {
this.ensureOpen();
// v0.7.2: 事务内 ALTER 显式拒绝(与 AriaEngine/MemoryEngine 对齐)——
// memory.alterTable 直接修改共享 columns 对象,事务快照无法回滚
// (此前 ROLLBACK 后新增列残留)
if (this.txActive) {
throw new DatabaseError(`ALTER TABLE is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.alterTable(tableName, action, column);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2086,10 +2315,12 @@ class KVStoreEngine {
if (!schema)
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
const pkCol = this.getPK(schema);
const pkChanged = pkCol in updates;
// v0.7.2: undefined 值视为"不更新该列"(与 memory.update 语义对齐)
const cleanUpdates = stripUndefinedUpdates(updates);
const pkChanged = pkCol in cleanUpdates;
// 收集受影响旧主键(内存匹配)
const affected = pkChanged ? [] : await this.collectMatchingPks(tableName, query);
const count = await this.memory.update(tableName, query, updates);
const count = await this.memory.update(tableName, query, cleanUpdates);
if (this.txActive) {
this.txDirtyTables.add(tableName);
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
@@ -2214,6 +2445,9 @@ class KVStoreEngine {
// ---- 动态索引 ----
async createIndex(tableName, column, unique) {
this.ensureOpen();
if (this.txActive) {
throw new DatabaseError(`CREATE INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.createIndex(tableName, column, unique);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2224,6 +2458,9 @@ class KVStoreEngine {
}
async dropIndex(tableName, column, indexName) {
this.ensureOpen();
if (this.txActive) {
throw new DatabaseError(`DROP INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.dropIndex(tableName, column, indexName);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2432,110 +2669,6 @@ class KVStoreEngine {
}
}
/**
* metona-sqlark Schema 表结构定义与校验
* @module table/schema
*/
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
function createSchema(name, columns) {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
function validateColumns(columns) {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR');
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR');
}
}
/** 检查字段类型(含约束校验) */
function checkFieldType(tableName, colName, type, value, colDef) {
const jsType = typeof value;
switch (type) {
case 'string':
if (jsType !== 'string') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR');
}
break;
case 'number':
if (jsType !== 'number') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.min !== undefined && value < colDef.min) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR');
}
if (colDef?.max !== undefined && value > colDef.max) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR');
}
break;
case 'boolean':
if (jsType !== 'boolean') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR');
}
break;
case 'date':
if (jsType !== 'string' || isNaN(Date.parse(value))) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR');
}
break;
case 'json':
if (jsType !== 'object') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR');
}
break;
}
}
/** 将 AST 列定义转换为 ColumnDef */
function astColumnToColumnDef(astCol) {
return {
type: astCol.type,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}
/**
* AriaEngine Types 内部类型定义
* @module engine/aria/types
@@ -6850,16 +6983,18 @@ class AriaEngine {
const walRecords = [];
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set();
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
const ranges = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
const p = `${String(cleanUpdates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
}
else if (!(colName in updates)) {
else if (!(colName in cleanUpdates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null)
@@ -6870,66 +7005,85 @@ class AriaEngine {
}
await idxLsm.prefetchPrefixRanges(ranges);
}
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
const planned = [];
const batchUnique = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName);
const key = `${tableName}:${row[pkCol]}`;
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
planned.push({ row, pk: String(row[pkCol]), key, updated, newPk, pkChanged });
}
// 阶段 1b:主键变更 RESTRICT / SET NULL+required 预检(任何修改前)
for (const p of planned) {
if (p.pkChanged) {
await this.checkForeignKeyUpdateRestrict(tableName, p.pk, p.newPk);
}
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
for (const { row, pk, key, updated, newPk, pkChanged } of planned) {
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
this.txnSnapshot.set(key, { __txn_deleted: true });
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
}
else {
if (pkChanged)
this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
this.txnSnapshot.set(key, { __txn_deleted: true });
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
}
else {
if (pkChanged)
this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.UPDATE,
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
key: pk,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
await this.wal.appendBatch(walRecords);
this.opCounter += count;
@@ -6937,6 +7091,54 @@ class AriaEngine {
this.trimAllCaches();
return count;
}
/**
* v0.7.2: 批内唯一互查 两条行在同一语句中更新到同一唯一值时的兜底检查
* 阶段 1 中索引尚未反映本语句的变更
*/
checkBatchUnique(tableName, uniqueCols, updated, batchUnique) {
for (const colName of uniqueCols) {
const value = updated[colName];
if (value === undefined || value === null)
continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
seen.add(value);
}
}
/**
* v0.7.2: ON UPDATE 外键预检 applyForeignKeyUpdateRules 提取两阶段 update
* RESTRICT 存在依赖行抛错SET NULL required 列同样整体拒绝
*/
async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) {
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName)
continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate)
continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName)
continue;
if (colDef.onUpdate === 'RESTRICT' || (colDef.onUpdate === 'SET NULL' && colDef.required)) {
const refRows = await this.getAllRows(refTableName);
for (const refRow of refRows) {
if (String(refRow[colName]) === oldPk) {
const reason = colDef.onUpdate === 'RESTRICT'
? `foreign key "${colName}" in "${refTableName}" has dependent rows`
: `foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`;
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": ${reason}`, 'FOREIGN_KEY_VIOLATION');
}
}
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 主键 oldPk newPk 时处理引用表
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空含索引与 WAL 记录
@@ -7079,6 +7281,10 @@ class AriaEngine {
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && matched.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
if (colDef.onDelete === 'CASCADE') {
const refPkCol = this.tablePKs.get(refTableName);
for (const refRow of matched) {
@@ -8251,11 +8457,21 @@ class HybridEngine {
// ---- 表管理 ----
async createTable(schema) {
await this.memoryEngine.createTable(schema);
await this.diskEngine.createTable(schema);
try {
await this.diskEngine.createTable(schema);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropTable(tableName) {
await this.memoryEngine.dropTable(tableName);
await this.diskEngine.dropTable(tableName);
try {
await this.diskEngine.dropTable(tableName);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async hasTable(tableName) {
return this.memoryEngine.hasTable(tableName);
@@ -8269,21 +8485,49 @@ class HybridEngine {
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
async alterTable(tableName, action, column) {
await this.memoryEngine.alterTable(tableName, action, column);
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
try {
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
}
else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP')
delete schema.columns[column.name];
}
}
else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP')
delete schema.columns[column.name];
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- CRUDwrite-through 策略) ----
/**
* v0.7.2: 磁盘写失败补偿 内存已先行写入磁盘失败 内存与磁盘不一致
* 重启后数据丢失且调用方已收到错误从磁盘重载内存对齐真实状态
* 内存=磁盘再重新抛出原始错误事务路径由双引擎快照回滚保证
* 无需此补偿
*/
async recoverMemoryAfterDiskError(error) {
try {
await this.reloadMemoryFromDisk();
}
catch {
// 磁盘本身不可用(错误根源)时重载可能失败:错误已抛给调用方,
// 内存保持失败前状态,repair()/重试可恢复
// eslint-disable-next-line no-console
console.warn('[metona-sqlark] Hybrid: failed to reload memory after disk write error');
}
throw error;
}
async insert(tableName, rows) {
const pks = await this.memoryEngine.insert(tableName, rows);
// write-through: 同步写入磁盘
await this.diskEngine.insert(tableName, rows);
try {
await this.diskEngine.insert(tableName, rows);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return pks;
}
async find(tableName, query) {
@@ -8297,13 +8541,23 @@ class HybridEngine {
async update(tableName, query, updates) {
const count = await this.memoryEngine.update(tableName, query, updates);
// write-through: 同步更新磁盘
await this.diskEngine.update(tableName, query, updates);
try {
await this.diskEngine.update(tableName, query, updates);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async delete(tableName, query) {
const count = await this.memoryEngine.delete(tableName, query);
// write-through: 同步删除磁盘
await this.diskEngine.delete(tableName, query);
try {
await this.diskEngine.delete(tableName, query);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async count(tableName, query) {
@@ -8311,19 +8565,34 @@ class HybridEngine {
}
async clear(tableName) {
await this.memoryEngine.clear(tableName);
await this.diskEngine.clear(tableName);
try {
await this.diskEngine.clear(tableName);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName, column, unique) {
await this.memoryEngine.createIndex(tableName, column, unique);
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
try {
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
}
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropIndex(tableName, column, indexName) {
await this.memoryEngine.dropIndex(tableName, column, indexName);
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
try {
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
}
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 事务 ----
@@ -9053,6 +9322,11 @@ class Lexer {
value += this.ch;
this.readChar();
}
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
if (this.ch === '') {
throw new DatabaseError(`Unterminated string literal at position ${start}`, 'PARSE_ERROR');
}
return {
type: TokenType.STRING,
value,
@@ -11619,6 +11893,10 @@ class QueryExecutor {
* 绑定在词法层面完成仅替换字符串字面量之外的 `?`
* 值按 SQL 字面量编码字符串 `''` 转义数字/布尔/JSON 直出
* 从根上规避 SQL 注入不经过字符串拼接由用户自行转义
*
* v0.7.2: 词法扫描感知注释 行注释`--`与块注释slash-star 包裹中的 `?`
* 与引号不再参与占位符识别与字符串状态机此前注释中的 `?` 计入占位符导致
* PARAM_ERROR 错位注释中的单引号触发 "Unterminated string literal"
*/
/** 将单个参数值编码为 SQL 字面量 */
function encodeParam(value) {
@@ -11637,7 +11915,7 @@ function encodeParam(value) {
throw new DatabaseError('Object/array query parameters are not supported by SQL binding (pass JSON strings explicitly)', 'PARAM_ERROR');
}
/**
* SQL 中的位置参数 `?`字符串字面量之外替换为编码后的字面量
* SQL 中的位置参数 `?`字符串字面量与注释之外替换为编码后的字面量
* @param sql `?` 占位符的 SQL
* @param params 位置参数数组
* @throws PARAM_ERROR 参数数量不匹配
@@ -11672,6 +11950,28 @@ function bindParameters(sql, params) {
i++;
continue;
}
// v0.7.2: 行注释 `-- ...`(含其中的 ? 与引号)原样保留、不参与绑定
if (ch === '-' && sql[i + 1] === '-') {
while (i < sql.length && sql[i] !== '\n' && sql[i] !== '\r') {
out += sql[i];
i++;
}
continue;
}
// v0.7.2: 块注释(slash-star 包裹)同样跳过
if (ch === '/' && sql[i + 1] === '*') {
out += sql[i] + sql[i + 1];
i += 2;
while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) {
out += sql[i];
i++;
}
if (i < sql.length) {
out += sql[i] + sql[i + 1];
i += 2;
}
continue;
}
if (ch === '?') {
if (pIdx >= params.length) {
throw new DatabaseError(`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`, 'PARAM_ERROR');
+1 -1
View File
File diff suppressed because one or more lines are too long
+501 -201
View File
@@ -36,7 +36,7 @@
// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------
const VERSION = '0.7.1';
const VERSION = '0.7.2';
/**
* metona-sqlark Shared WHERE Matcher 统一的条件匹配逻辑
@@ -162,7 +162,10 @@
case '$in': return Array.isArray(operand) && operand.includes(value);
case '$nin': return Array.isArray(operand) && !operand.includes(value);
case '$like': return compileLikeRegex(String(operand)).test(String(value));
default: return true;
// v0.7.2: 未知操作符显式报错 —— 此前静默返回 true(所有行匹配),
// 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示
default:
throw new DatabaseError(`Unknown where operator "${op}"`, 'QUERY_ERROR');
}
}
// ---------------------------------------------------------------------------
@@ -221,6 +224,123 @@
return projected;
}
/**
* metona-sqlark Schema 表结构定义与校验
* @module table/schema
*/
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
function createSchema(name, columns) {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
function validateColumns(columns) {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR');
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR');
}
}
/**
* v0.7.2: 更新载荷清洗 undefined 值视为"不更新该列"保留旧值
* 此前 `update({ col: undefined })` 会把 undefined 写入行覆盖旧值列键丢失
* null 保留显式置空语义
*/
function stripUndefinedUpdates(updates) {
const clean = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined)
clean[key] = value;
}
return clean;
}
/** 检查字段类型(含约束校验) */
function checkFieldType(tableName, colName, type, value, colDef) {
const jsType = typeof value;
switch (type) {
case 'string':
if (jsType !== 'string') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR');
}
break;
case 'number':
if (jsType !== 'number') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.min !== undefined && value < colDef.min) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR');
}
if (colDef?.max !== undefined && value > colDef.max) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR');
}
break;
case 'boolean':
if (jsType !== 'boolean') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR');
}
break;
case 'date':
if (jsType !== 'string' || isNaN(Date.parse(value))) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR');
}
break;
case 'json':
if (jsType !== 'object') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR');
}
break;
}
}
/** 将 AST 列定义转换为 ColumnDef */
function astColumnToColumnDef(astCol) {
return {
type: astCol.type,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}
/**
* metona-sqlark Memory Engine 基于 Map 的内存存储引擎
* @module engine/memory
@@ -303,6 +423,12 @@
* 此前走 executor 通用路径行为相同统一到引擎层保证 Hybrid/IndexedDB 委托一致性
*/
async alterTable(tableName, action, column) {
// v0.7.2: 事务内 DDL 显式拒绝(与 AriaEngine 对齐)。此前事务快照对 schema
// 是浅拷贝,alterTable 直接修改共享 columns 对象 → ROLLBACK 后结构变更残留
// (三引擎行为不一致:Aria 拒绝 / Memory、KVStore 静默残留)
if (this.snapshot) {
throw new DatabaseError(`ALTER TABLE is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
if (action === 'ADD') {
@@ -391,32 +517,116 @@
const schema = this.schemas.get(tableName);
const table = this.tables.get(tableName);
const pkCol = this.getPrimaryKey(schema);
let count = 0;
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
for (const [pk, row] of [...table]) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
const planned = [];
const batchUnique = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const [pk, row] of table) {
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
planned.push({ pk, row, updated, newPk });
}
// 阶段 1b:主键变更 RESTRICT 预检(引用表依赖行检查,任何修改前)
for (const p of planned) {
if (p.newPk !== p.pk)
this.checkUpdateRestrict(tableName, p.pk);
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
let count = 0;
for (const { pk, row, updated, newPk } of planned) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
}
return count;
}
/**
* v0.7.2: 更新唯一性预检 批内互查多条行更新到同一唯一值+ 索引查
* 排除自身旧条目阶段 1 中索引尚未更新批内互查避免"两行同时改到
* 同一新值"绕过唯一约束
*/
checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique) {
const tableIndexes = this.indexes.get(tableName);
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique)
continue;
const value = updated[colName];
if (value === undefined || value === null)
continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
}
seen.add(value);
if (!tableIndexes)
continue;
const colIndex = tableIndexes.get(colName);
if (colIndex && colIndex.has(value)) {
const pks = colIndex.get(value);
// 值未变(新值 = 旧值)且索引中只有自身 → 允许
if (!(pks.size === 1 && pks.has(pk))) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
}
}
}
}
/**
* v0.7.2: ON UPDATE RESTRICT 预检 applyUpdateCascade 提取
* 两阶段 update 在任何修改前调用整体拒绝语义
*/
checkUpdateRestrict(tableName, oldPk) {
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName)
continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate)
continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName)
continue;
const refTableData = this.tables.get(refTableName);
if (!refTableData)
continue;
let hasDependents = false;
for (const [, refRow] of refTableData) {
if (String(refRow[colName]) !== oldPk)
continue;
hasDependents = true;
if (colDef.onUpdate === 'RESTRICT') {
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 与 RESTRICT 同样整体拒绝
// (此前级联直写 null 绕过 validateRowrequired 列被静默置空)
if (hasDependents && colDef.onUpdate === 'SET NULL' && colDef.required) {
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 被引用表主键变更时处理引用表
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空
@@ -528,6 +738,10 @@
if (colDef.onDelete === 'RESTRICT' && refPks.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && refPks.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
if (colDef.onDelete === 'CASCADE') {
for (const refPk of refPks) {
this.checkCascadeRestrict(refTableName, refPk, visited);
@@ -558,6 +772,11 @@
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName, column, unique) {
// v0.7.2: 事务内修改列级标志(colDef.index/unique)会写入共享列对象,
// 事务快照无法回滚 → 与 alterTable 同样显式拒绝
if (this.snapshot) {
throw new DatabaseError(`CREATE INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
const colDef = schema.columns[column];
@@ -583,6 +802,10 @@
}
}
async dropIndex(tableName, column, _indexName) {
// v0.7.2: 同 createIndex —— 列级标志修改无法通过事务快照回滚,显式拒绝
if (this.snapshot) {
throw new DatabaseError(`DROP INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName);
const colDef = schema.columns[column];
@@ -2031,6 +2254,12 @@
}
async alterTable(tableName, action, column) {
this.ensureOpen();
// v0.7.2: 事务内 ALTER 显式拒绝(与 AriaEngine/MemoryEngine 对齐)——
// memory.alterTable 直接修改共享 columns 对象,事务快照无法回滚
// (此前 ROLLBACK 后新增列残留)
if (this.txActive) {
throw new DatabaseError(`ALTER TABLE is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.alterTable(tableName, action, column);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2092,10 +2321,12 @@
if (!schema)
throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
const pkCol = this.getPK(schema);
const pkChanged = pkCol in updates;
// v0.7.2: undefined 值视为"不更新该列"(与 memory.update 语义对齐)
const cleanUpdates = stripUndefinedUpdates(updates);
const pkChanged = pkCol in cleanUpdates;
// 收集受影响旧主键(内存匹配)
const affected = pkChanged ? [] : await this.collectMatchingPks(tableName, query);
const count = await this.memory.update(tableName, query, updates);
const count = await this.memory.update(tableName, query, cleanUpdates);
if (this.txActive) {
this.txDirtyTables.add(tableName);
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
@@ -2220,6 +2451,9 @@
// ---- 动态索引 ----
async createIndex(tableName, column, unique) {
this.ensureOpen();
if (this.txActive) {
throw new DatabaseError(`CREATE INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.createIndex(tableName, column, unique);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2230,6 +2464,9 @@
}
async dropIndex(tableName, column, indexName) {
this.ensureOpen();
if (this.txActive) {
throw new DatabaseError(`DROP INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`, 'NOT_SUPPORTED');
}
await this.memory.dropIndex(tableName, column, indexName);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -2438,110 +2675,6 @@
}
}
/**
* metona-sqlark Schema 表结构定义与校验
* @module table/schema
*/
// ---------------------------------------------------------------------------
// Schema 工具
// ---------------------------------------------------------------------------
/** 从列定义创建 TableSchema */
function createSchema(name, columns) {
validateColumns(columns);
return { name, columns };
}
/** 校验列定义 */
function validateColumns(columns) {
const colNames = Object.keys(columns);
if (colNames.length === 0) {
throw new DatabaseError('Table must have at least one column', 'SCHEMA_ERROR');
}
let primaryKeyCount = 0;
for (const [colName, colDef] of Object.entries(columns)) {
// v0.7.1: '__proto__' 作为列名会触发对象原型 setter(列静默丢失);
// 显式拒绝避免原型污染类攻击面
if (colName === '__proto__') {
throw new DatabaseError('Column name "__proto__" is not allowed', 'SCHEMA_ERROR');
}
// 类型校验
if (!FIELD_TYPES.includes(colDef.type)) {
throw new DatabaseError(`Invalid type "${colDef.type}" for column "${colName}". Valid types: ${FIELD_TYPES.join(', ')}`, 'SCHEMA_ERROR');
}
// 主键计数
if (colDef.primaryKey) {
primaryKeyCount++;
}
}
// 至少需要一个主键
if (primaryKeyCount === 0) {
throw new DatabaseError('Table must have at least one primary key column', 'SCHEMA_ERROR');
}
// v0.7.0: 复合主键(多列 primaryKey)当前不支持 —— 所有引擎的存储布局与
// 外键引用均为单主键假设(此前静默取第一个主键,其余标记被忽略 → 语义陷阱)。
// 显式拒绝避免用户误用;复合主键列入 v0.8 路线图。
if (primaryKeyCount > 1) {
throw new DatabaseError(`Composite primary keys are not supported yet: table has ${primaryKeyCount} primary key columns. ` +
'Use a single primary key column (or a unique column combination) instead.', 'SCHEMA_ERROR');
}
}
/** 检查字段类型(含约束校验) */
function checkFieldType(tableName, colName, type, value, colDef) {
const jsType = typeof value;
switch (type) {
case 'string':
if (jsType !== 'string') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects string, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.maxLength !== undefined && value.length > colDef.maxLength) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" exceeds max length ${colDef.maxLength}`, 'VALIDATION_ERROR');
}
break;
case 'number':
if (jsType !== 'number') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects number, got ${jsType}`, 'TYPE_ERROR');
}
if (colDef?.min !== undefined && value < colDef.min) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} below minimum ${colDef.min}`, 'VALIDATION_ERROR');
}
if (colDef?.max !== undefined && value > colDef.max) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" value ${value} above maximum ${colDef.max}`, 'VALIDATION_ERROR');
}
break;
case 'boolean':
if (jsType !== 'boolean') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects boolean, got ${jsType}`, 'TYPE_ERROR');
}
break;
case 'date':
if (jsType !== 'string' || isNaN(Date.parse(value))) {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects valid date string, got ${typeof value}`, 'TYPE_ERROR');
}
break;
case 'json':
if (jsType !== 'object') {
throw new DatabaseError(`Column "${colName}" in table "${tableName}" expects object/array, got ${jsType}`, 'TYPE_ERROR');
}
break;
}
}
/** 将 AST 列定义转换为 ColumnDef */
function astColumnToColumnDef(astCol) {
return {
type: astCol.type,
primaryKey: astCol.primaryKey,
unique: astCol.unique,
required: astCol.required,
default: astCol.default,
index: astCol.index,
maxLength: astCol.maxLength,
min: astCol.min,
max: astCol.max,
references: astCol.references,
onDelete: astCol.onDelete,
onUpdate: astCol.onUpdate,
};
}
/**
* AriaEngine Types 内部类型定义
* @module engine/aria/types
@@ -6856,16 +6989,18 @@
const walRecords = [];
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set();
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`);
const ranges = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
const p = `${String(cleanUpdates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
}
else if (!(colName in updates)) {
else if (!(colName in cleanUpdates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null)
@@ -6876,66 +7011,85 @@
}
await idxLsm.prefetchPrefixRanges(ranges);
}
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
const planned = [];
const batchUnique = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName);
const key = `${tableName}:${row[pkCol]}`;
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where))
continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底)
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
planned.push({ row, pk: String(row[pkCol]), key, updated, newPk, pkChanged });
}
// 阶段 1b:主键变更 RESTRICT / SET NULL+required 预检(任何修改前)
for (const p of planned) {
if (p.pkChanged) {
await this.checkForeignKeyUpdateRestrict(tableName, p.pk, p.newPk);
}
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
for (const { row, pk, key, updated, newPk, pkChanged } of planned) {
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !existing.__txn_deleted) {
throw new DatabaseError(`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`, 'DUPLICATE_KEY');
}
}
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(tableName, String(row[pkCol]), newPk, walRecords, visited);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
this.txnSnapshot.set(key, { __txn_deleted: true });
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
}
else {
if (pkChanged)
this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
this.txnSnapshot.set(key, { __txn_deleted: true });
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
}
else {
if (pkChanged)
this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.UPDATE,
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
key: pk,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
await this.wal.appendBatch(walRecords);
this.opCounter += count;
@@ -6943,6 +7097,54 @@
this.trimAllCaches();
return count;
}
/**
* v0.7.2: 批内唯一互查 两条行在同一语句中更新到同一唯一值时的兜底检查
* 阶段 1 中索引尚未反映本语句的变更
*/
checkBatchUnique(tableName, uniqueCols, updated, batchUnique) {
for (const colName of uniqueCols) {
const value = updated[colName];
if (value === undefined || value === null)
continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${tableName}"`, 'UNIQUE_VIOLATION');
}
seen.add(value);
}
}
/**
* v0.7.2: ON UPDATE 外键预检 applyForeignKeyUpdateRules 提取两阶段 update
* RESTRICT 存在依赖行抛错SET NULL required 列同样整体拒绝
*/
async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) {
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName)
continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate)
continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName)
continue;
if (colDef.onUpdate === 'RESTRICT' || (colDef.onUpdate === 'SET NULL' && colDef.required)) {
const refRows = await this.getAllRows(refTableName);
for (const refRow of refRows) {
if (String(refRow[colName]) === oldPk) {
const reason = colDef.onUpdate === 'RESTRICT'
? `foreign key "${colName}" in "${refTableName}" has dependent rows`
: `foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`;
throw new DatabaseError(`Cannot update "${tableName}" key "${oldPk}": ${reason}`, 'FOREIGN_KEY_VIOLATION');
}
}
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE 外键级联 主键 oldPk newPk 时处理引用表
* RESTRICT 抛错 / CASCADE 更新 FK / SET NULL 置空含索引与 WAL 记录
@@ -7085,6 +7287,10 @@
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && matched.length > 0) {
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`, 'FOREIGN_KEY_VIOLATION');
}
if (colDef.onDelete === 'CASCADE') {
const refPkCol = this.tablePKs.get(refTableName);
for (const refRow of matched) {
@@ -8257,11 +8463,21 @@
// ---- 表管理 ----
async createTable(schema) {
await this.memoryEngine.createTable(schema);
await this.diskEngine.createTable(schema);
try {
await this.diskEngine.createTable(schema);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropTable(tableName) {
await this.memoryEngine.dropTable(tableName);
await this.diskEngine.dropTable(tableName);
try {
await this.diskEngine.dropTable(tableName);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async hasTable(tableName) {
return this.memoryEngine.hasTable(tableName);
@@ -8275,21 +8491,49 @@
/** v0.4.2-fix: 引擎级 ALTER TABLE — 双引擎同步(磁盘持久化 + 内存引用) */
async alterTable(tableName, action, column) {
await this.memoryEngine.alterTable(tableName, action, column);
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
try {
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
}
else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP')
delete schema.columns[column.name];
}
}
else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP')
delete schema.columns[column.name];
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- CRUDwrite-through 策略) ----
/**
* v0.7.2: 磁盘写失败补偿 内存已先行写入磁盘失败 内存与磁盘不一致
* 重启后数据丢失且调用方已收到错误从磁盘重载内存对齐真实状态
* 内存=磁盘再重新抛出原始错误事务路径由双引擎快照回滚保证
* 无需此补偿
*/
async recoverMemoryAfterDiskError(error) {
try {
await this.reloadMemoryFromDisk();
}
catch {
// 磁盘本身不可用(错误根源)时重载可能失败:错误已抛给调用方,
// 内存保持失败前状态,repair()/重试可恢复
// eslint-disable-next-line no-console
console.warn('[metona-sqlark] Hybrid: failed to reload memory after disk write error');
}
throw error;
}
async insert(tableName, rows) {
const pks = await this.memoryEngine.insert(tableName, rows);
// write-through: 同步写入磁盘
await this.diskEngine.insert(tableName, rows);
try {
await this.diskEngine.insert(tableName, rows);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return pks;
}
async find(tableName, query) {
@@ -8303,13 +8547,23 @@
async update(tableName, query, updates) {
const count = await this.memoryEngine.update(tableName, query, updates);
// write-through: 同步更新磁盘
await this.diskEngine.update(tableName, query, updates);
try {
await this.diskEngine.update(tableName, query, updates);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async delete(tableName, query) {
const count = await this.memoryEngine.delete(tableName, query);
// write-through: 同步删除磁盘
await this.diskEngine.delete(tableName, query);
try {
await this.diskEngine.delete(tableName, query);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async count(tableName, query) {
@@ -8317,19 +8571,34 @@
}
async clear(tableName) {
await this.memoryEngine.clear(tableName);
await this.diskEngine.clear(tableName);
try {
await this.diskEngine.clear(tableName);
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName, column, unique) {
await this.memoryEngine.createIndex(tableName, column, unique);
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
try {
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
}
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropIndex(tableName, column, indexName) {
await this.memoryEngine.dropIndex(tableName, column, indexName);
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
try {
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
}
}
catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 事务 ----
@@ -9059,6 +9328,11 @@
value += this.ch;
this.readChar();
}
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
if (this.ch === '') {
throw new DatabaseError(`Unterminated string literal at position ${start}`, 'PARSE_ERROR');
}
return {
type: TokenType.STRING,
value,
@@ -11625,6 +11899,10 @@
* 绑定在词法层面完成仅替换字符串字面量之外的 `?`
* 值按 SQL 字面量编码字符串 `''` 转义数字/布尔/JSON 直出
* 从根上规避 SQL 注入不经过字符串拼接由用户自行转义
*
* v0.7.2: 词法扫描感知注释 行注释`--`与块注释slash-star 包裹中的 `?`
* 与引号不再参与占位符识别与字符串状态机此前注释中的 `?` 计入占位符导致
* PARAM_ERROR 错位注释中的单引号触发 "Unterminated string literal"
*/
/** 将单个参数值编码为 SQL 字面量 */
function encodeParam(value) {
@@ -11643,7 +11921,7 @@
throw new DatabaseError('Object/array query parameters are not supported by SQL binding (pass JSON strings explicitly)', 'PARAM_ERROR');
}
/**
* SQL 中的位置参数 `?`字符串字面量之外替换为编码后的字面量
* SQL 中的位置参数 `?`字符串字面量与注释之外替换为编码后的字面量
* @param sql `?` 占位符的 SQL
* @param params 位置参数数组
* @throws PARAM_ERROR 参数数量不匹配
@@ -11678,6 +11956,28 @@
i++;
continue;
}
// v0.7.2: 行注释 `-- ...`(含其中的 ? 与引号)原样保留、不参与绑定
if (ch === '-' && sql[i + 1] === '-') {
while (i < sql.length && sql[i] !== '\n' && sql[i] !== '\r') {
out += sql[i];
i++;
}
continue;
}
// v0.7.2: 块注释(slash-star 包裹)同样跳过
if (ch === '/' && sql[i + 1] === '*') {
out += sql[i] + sql[i + 1];
i += 2;
while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) {
out += sql[i];
i++;
}
if (i < sql.length) {
out += sql[i] + sql[i + 1];
i += 2;
}
continue;
}
if (ch === '?') {
if (pIdx >= params.length) {
throw new DatabaseError(`Too few query parameters: placeholder #${pIdx + 1} has no value (got ${params.length} total)`, 'PARAM_ERROR');
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@metona-team/metona-sqlark",
"version": "0.7.1",
"version": "0.7.2",
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
"type": "module",
"main": "dist/metona-sqlark.cjs",
+1 -1
View File
@@ -214,4 +214,4 @@ export class DatabaseError extends Error {
// 版本
// ---------------------------------------------------------------------------
export const VERSION = '0.7.1';
export const VERSION = '0.7.2';
+141 -57
View File
@@ -9,7 +9,7 @@ import type { IStorageEngine } from '../interface';
import type { QueryPlan, TableSchema, ColumnDef } from '../../constants';
import { DatabaseError } from '../../constants';
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher';
import { checkFieldType } from '../../table/schema';
import { checkFieldType, stripUndefinedUpdates } from '../../table/schema';
import type { AriaEngineConfig, SSTableMeta } from './types';
import { DEFAULT_ARIA_CONFIG } from './types';
@@ -695,16 +695,18 @@ export class AriaEngine implements IStorageEngine {
const walRecords: Omit<import('./types').WALRecord, 'lsn' | 'checksum'>[] = [];
// v0.4.2-fix: ON UPDATE 级联环路保护
const visited = new Set<string>();
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.6.2: 唯一约束 — 批量预加载本批更新涉及的唯一列索引范围(一次 drainChain
const uniqueCols = this.uniqueColumns(tableName, schema);
for (const colName of uniqueCols) {
const idxLsm = this.secondaryIndexes.get(`${tableName}:idx:${colName}`)!;
const ranges: [string, string][] = [];
if (updates[colName] !== undefined && updates[colName] !== null) {
const p = `${String(updates[colName])}:`;
if (cleanUpdates[colName] !== undefined && cleanUpdates[colName] !== null) {
const p = `${String(cleanUpdates[colName])}:`;
ranges.push([p, `${p}\uffff`]);
} else if (!(colName in updates)) {
} else if (!(colName in cleanUpdates)) {
for (const row of rows) {
const val = row[colName];
if (val === undefined || val === null) continue;
@@ -715,77 +717,95 @@ export class AriaEngine implements IStorageEngine {
await idxLsm.prefetchPrefixRanges(ranges);
}
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// 且其 WAL 记录随 appendBatch 一起丢失 → 内存已改、WAL 无记录、调用方已收到错误
// (无事务下语句级部分提交 + 崩溃后进一步不一致)。
const planned: { row: Record<string, unknown>; pk: string; key: string; updated: Record<string, unknown>; newPk: string; pkChanged: boolean }[] = [];
const batchUnique: Map<string, Set<unknown>> = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const row of rows) {
const pkCol = this.tablePKs.get(tableName)!;
const key = `${tableName}:${row[pkCol]}`;
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where)) continue;
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// 批内唯一互查(索引尚未更新,两行同时改到同一新值需要互查兜底
this.checkBatchUnique(tableName, uniqueCols, updated, batchUnique);
// v0.6.2: 唯一约束检查(排除自身旧索引条目:主键变更时旧条目仍以旧键存在)
this.checkUniqueSync(tableName, uniqueCols, updated, String(row[pkCol]));
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.4.2-fix: 支持更新主键 — 删除旧键 + 落新键 + WAL 两条记录
const newPk = String(updated[pkCol]);
const pkChanged = newPk !== String(row[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
'DUPLICATE_KEY',
);
}
}
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(
tableName, String(row[pkCol]), newPk, walRecords, visited,
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY
// (此前静默覆盖另一行丢数据;与 MemoryEngine 对齐)
if (pkChanged) {
const newKey = `${tableName}:${newPk}`;
const existing = this.currentTxnId
? (this.txnSnapshot?.get(newKey) ?? this.lsm.get(newKey))
: this.lsm.get(newKey);
if (existing && !(existing as unknown as Record<string, unknown>).__txn_deleted) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
'DUPLICATE_KEY',
);
}
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(tableName, String(row[pkCol]), this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
} else {
if (pkChanged) this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
planned.push({ row, pk: String(row[pkCol]), key, updated, newPk, pkChanged });
}
// 阶段 1b:主键变更 RESTRICT / SET NULL+required 预检(任何修改前)
for (const p of planned) {
if (p.pkChanged) {
await this.checkForeignKeyUpdateRestrict(tableName, p.pk, p.newPk);
}
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
for (const { row, pk, key, updated, newPk, pkChanged } of planned) {
if (pkChanged) {
// ON UPDATE 外键级联(RESTRICT 抛错 / CASCADE / SET NULL
await this.applyForeignKeyUpdateRules(tableName, pk, newPk, walRecords, visited);
}
if (this.currentTxnId && this.txnSnapshot) {
if (pkChanged) {
walRecords.push({
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: String(row[pkCol]),
});
this.txnSnapshot.set(key, { __txn_deleted: true } as unknown as Record<string, unknown>);
this.mvcc.deleteVersion(tableName, pk, this.currentTxnId);
}
this.txnSnapshot.set(`${tableName}:${newPk}`, updated);
this.mvcc.writeVersion(tableName, newPk, updated, this.currentTxnId);
} else {
if (pkChanged) this.lsm.delete(key);
this.lsm.put(`${tableName}:${newPk}`, updated);
}
count++;
if (pkChanged) {
walRecords.push({
type: WALRecordType.UPDATE,
type: WALRecordType.DELETE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
key: pk,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
walRecords.push({
type: WALRecordType.UPDATE,
txnId: this.currentTxnId ?? 0,
tableName,
key: newPk,
data: updated,
});
// 更新二级索引(主键变更时旧索引条目一并清理)
// v0.6.2-fix: 此前非主键更新不传旧行 → 旧索引条目残留
// (唯一性检查误报 / 索引存储膨胀);现在统一传旧行清理旧值
this.updateSecondaryIndexes(tableName, newPk, updated, row);
}
await this.wal.appendBatch(walRecords);
@@ -796,6 +816,63 @@ export class AriaEngine implements IStorageEngine {
return count;
}
/**
* v0.7.2: 批内唯一互查
* 1
*/
private checkBatchUnique(
tableName: string,
uniqueCols: string[],
updated: Record<string, unknown>,
batchUnique: Map<string, Set<unknown>>,
): void {
for (const colName of uniqueCols) {
const value = updated[colName];
if (value === undefined || value === null) continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set<unknown>();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${tableName}"`,
'UNIQUE_VIOLATION',
);
}
seen.add(value);
}
}
/**
* v0.7.2: ON UPDATE applyForeignKeyUpdateRules update
* RESTRICT SET NULL required
*/
private async checkForeignKeyUpdateRestrict(tableName: string, oldPk: string, _newPk: string): Promise<void> {
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
if (colDef.onUpdate === 'RESTRICT' || (colDef.onUpdate === 'SET NULL' && colDef.required)) {
const refRows = await this.getAllRows(refTableName);
for (const refRow of refRows) {
if (String(refRow[colName]) === oldPk) {
const reason = colDef.onUpdate === 'RESTRICT'
? `foreign key "${colName}" in "${refTableName}" has dependent rows`
: `foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`;
throw new DatabaseError(
`Cannot update "${tableName}" key "${oldPk}": ${reason}`,
'FOREIGN_KEY_VIOLATION',
);
}
}
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE oldPk newPk
* RESTRICT / CASCADE FK / SET NULL WAL
@@ -947,6 +1024,13 @@ export class AriaEngine implements IStorageEngine {
'FOREIGN_KEY_VIOLATION',
);
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && matched.length > 0) {
throw new DatabaseError(
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`,
'FOREIGN_KEY_VIOLATION',
);
}
if (colDef.onDelete === 'CASCADE') {
const refPkCol = this.tablePKs.get(refTableName)!;
for (const refRow of matched) {
+26 -2
View File
@@ -23,6 +23,7 @@ import { DatabaseError } from '../constants';
import { MemoryEngine } from './memory';
import { KVStore } from './kvstore/index';
import type { IStorageBackend } from './aria/store/backend';
import { stripUndefinedUpdates } from '../table/schema';
const SCHEMA_KEY = '__schema';
const ROW_PREFIX = 't:';
@@ -229,6 +230,15 @@ export class KVStoreEngine implements IStorageEngine {
column: import('../constants').ColumnDef & { name: string },
): Promise<void> {
this.ensureOpen();
// v0.7.2: 事务内 ALTER 显式拒绝(与 AriaEngine/MemoryEngine 对齐)——
// memory.alterTable 直接修改共享 columns 对象,事务快照无法回滚
// (此前 ROLLBACK 后新增列残留)
if (this.txActive) {
throw new DatabaseError(
`ALTER TABLE is not supported inside a transaction (KVStoreEngine DDL is not transactional)`,
'NOT_SUPPORTED',
);
}
await this.memory.alterTable(tableName, action, column);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -296,11 +306,13 @@ export class KVStoreEngine implements IStorageEngine {
const schema = await this.memory.getTableSchema(tableName);
if (!schema) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
const pkCol = this.getPK(schema);
const pkChanged = pkCol in updates;
// v0.7.2: undefined 值视为"不更新该列"(与 memory.update 语义对齐)
const cleanUpdates = stripUndefinedUpdates(updates);
const pkChanged = pkCol in cleanUpdates;
// 收集受影响旧主键(内存匹配)
const affected = pkChanged ? [] : await this.collectMatchingPks(tableName, query);
const count = await this.memory.update(tableName, query, updates);
const count = await this.memory.update(tableName, query, cleanUpdates);
if (this.txActive) {
this.txDirtyTables.add(tableName);
// v0.7.0: 行级变更记录 —— 主键变更无法行级追踪(旧键删除+新键落盘+级联),
@@ -425,6 +437,12 @@ export class KVStoreEngine implements IStorageEngine {
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
this.ensureOpen();
if (this.txActive) {
throw new DatabaseError(
`CREATE INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`,
'NOT_SUPPORTED',
);
}
await this.memory.createIndex(tableName, column, unique);
if (this.txActive) {
this.txDirtyTables.add(tableName);
@@ -436,6 +454,12 @@ export class KVStoreEngine implements IStorageEngine {
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
this.ensureOpen();
if (this.txActive) {
throw new DatabaseError(
`DROP INDEX is not supported inside a transaction (KVStoreEngine DDL is not transactional)`,
'NOT_SUPPORTED',
);
}
await this.memory.dropIndex(tableName, column, indexName);
if (this.txActive) {
this.txDirtyTables.add(tableName);
+154 -25
View File
@@ -7,6 +7,7 @@ import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema } from '../constants';
import { DatabaseError } from '../constants';
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
import { stripUndefinedUpdates } from '../table/schema';
export class MemoryEngine implements IStorageEngine {
readonly name = 'memory';
@@ -96,6 +97,15 @@ export class MemoryEngine implements IStorageEngine {
action: 'ADD' | 'DROP',
column: import('../constants').ColumnDef & { name: string },
): Promise<void> {
// v0.7.2: 事务内 DDL 显式拒绝(与 AriaEngine 对齐)。此前事务快照对 schema
// 是浅拷贝,alterTable 直接修改共享 columns 对象 → ROLLBACK 后结构变更残留
// (三引擎行为不一致:Aria 拒绝 / Memory、KVStore 静默残留)
if (this.snapshot) {
throw new DatabaseError(
`ALTER TABLE is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
'NOT_SUPPORTED',
);
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
if (action === 'ADD') {
@@ -184,36 +194,133 @@ export class MemoryEngine implements IStorageEngine {
const schema = this.schemas.get(tableName)!;
const table = this.tables.get(tableName)!;
const pkCol = this.getPrimaryKey(schema);
let count = 0;
// v0.4.2-fix: 迭代期间会 delete/set 同一 Map(主键变更)→ 拷贝快照避免跳过/重复
for (const [pk, row] of [...table]) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
this.checkUniqueness(schema, updated);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
'DUPLICATE_KEY',
);
}
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
// v0.7.2: undefined 值视为"不更新该列"(保留旧值),null 显式置空
const cleanUpdates = stripUndefinedUpdates(updates);
// v0.7.2: 语句级原子性 — 两阶段(先全量预检,后执行)。
// 此前逐行"校验+写入":第 N 行唯一冲突/校验失败抛错时,前 N-1 行已写入
// → 无事务下语句级部分提交(数据半更新且调用方已收到错误)。
const planned: { pk: string; row: Record<string, unknown>; updated: Record<string, unknown>; newPk: string }[] = [];
const batchUnique: Map<string, Set<unknown>> = new Map();
// 阶段 1:全量预检(任何一行失败 → 整条语句不执行)
for (const [pk, row] of table) {
if (query.where && Object.keys(query.where).length > 0 && !matchWhere(row, query.where)) continue;
const updated = { ...row, ...cleanUpdates };
this.validateRow(schema, updated);
this.checkUpdateUniqueness(schema, tableName, pk, updated, batchUnique);
const newPk = String(updated[pkCol]);
// v0.6.2-fix(P0): 主键变更撞已有主键 → 抛 DUPLICATE_KEY(此前静默覆盖丢数据)
if (newPk !== pk && table.has(newPk)) {
throw new DatabaseError(
`Duplicate primary key "${newPk}" in table "${tableName}" (cannot update key to existing value)`,
'DUPLICATE_KEY',
);
}
planned.push({ pk, row, updated, newPk });
}
// 阶段 1b:主键变更 RESTRICT 预检(引用表依赖行检查,任何修改前)
for (const p of planned) {
if (p.newPk !== p.pk) this.checkUpdateRestrict(tableName, p.pk);
}
// 阶段 2:执行(预检已通过,此阶段不再抛校验类错误)
let count = 0;
for (const { pk, row, updated, newPk } of planned) {
// v0.3.3: 先移除旧值索引条目(修复 update 后唯一约束被绕过、按新值查索引丢行)
this.removeIndexEntries(tableName, row, pk);
// v0.4.2-fix: 主键变更 — 删除旧键 + 级联更新引用表 + 新键落表
if (newPk !== pk) {
await this.applyUpdateCascade(tableName, pk, newPk);
}
table.delete(pk);
table.set(newPk, updated);
this.updateIndexes(tableName, updated, newPk);
count++;
}
return count;
}
/**
* v0.7.2: 更新唯一性预检 +
* 1 "
* "
*/
private checkUpdateUniqueness(
schema: TableSchema,
tableName: string,
pk: string,
updated: Record<string, unknown>,
batchUnique: Map<string, Set<unknown>>,
): void {
const tableIndexes = this.indexes.get(tableName);
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique) continue;
const value = updated[colName];
if (value === undefined || value === null) continue;
let seen = batchUnique.get(colName);
if (!seen) {
seen = new Set<unknown>();
batchUnique.set(colName, seen);
}
if (seen.has(value)) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${schema.name}"`,
'UNIQUE_VIOLATION',
);
}
seen.add(value);
if (!tableIndexes) continue;
const colIndex = tableIndexes.get(colName);
if (colIndex && colIndex.has(value)) {
const pks = colIndex.get(value)!;
// 值未变(新值 = 旧值)且索引中只有自身 → 允许
if (!(pks.size === 1 && pks.has(pk))) {
throw new DatabaseError(
`Unique constraint violation on column "${colName}" in table "${schema.name}"`,
'UNIQUE_VIOLATION',
);
}
}
}
}
/**
* v0.7.2: ON UPDATE RESTRICT applyUpdateCascade
* update
*/
private checkUpdateRestrict(tableName: string, oldPk: string): void {
for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue;
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue;
const refTableData = this.tables.get(refTableName);
if (!refTableData) continue;
let hasDependents = false;
for (const [, refRow] of refTableData) {
if (String(refRow[colName]) !== oldPk) continue;
hasDependents = true;
if (colDef.onUpdate === 'RESTRICT') {
throw new DatabaseError(
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION',
);
}
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 与 RESTRICT 同样整体拒绝
// (此前级联直写 null 绕过 validateRowrequired 列被静默置空)
if (hasDependents && colDef.onUpdate === 'SET NULL' && colDef.required) {
throw new DatabaseError(
`Cannot update "${tableName}" key "${oldPk}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`,
'FOREIGN_KEY_VIOLATION',
);
}
}
}
}
/**
* v0.4.2-fix: ON UPDATE
* RESTRICT / CASCADE FK / SET NULL
@@ -315,6 +422,13 @@ export class MemoryEngine implements IStorageEngine {
'FOREIGN_KEY_VIOLATION',
);
}
// v0.7.2: SET NULL 到 required 列违反约束 —— 预检阶段整体拒绝
if (colDef.onDelete === 'SET NULL' && colDef.required && refPks.length > 0) {
throw new DatabaseError(
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" is required (SET NULL violates constraint)`,
'FOREIGN_KEY_VIOLATION',
);
}
if (colDef.onDelete === 'CASCADE') {
for (const refPk of refPks) {
this.checkCascadeRestrict(refTableName, refPk, visited);
@@ -343,6 +457,14 @@ export class MemoryEngine implements IStorageEngine {
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
// v0.7.2: 事务内修改列级标志(colDef.index/unique)会写入共享列对象,
// 事务快照无法回滚 → 与 alterTable 同样显式拒绝
if (this.snapshot) {
throw new DatabaseError(
`CREATE INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
'NOT_SUPPORTED',
);
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
@@ -365,6 +487,13 @@ export class MemoryEngine implements IStorageEngine {
}
async dropIndex(tableName: string, column: string, _indexName?: string): Promise<void> {
// v0.7.2: 同 createIndex —— 列级标志修改无法通过事务快照回滚,显式拒绝
if (this.snapshot) {
throw new DatabaseError(
`DROP INDEX is not supported inside a transaction (MemoryEngine DDL is not transactional)`,
'NOT_SUPPORTED',
);
}
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const colDef = schema.columns[column];
+70 -16
View File
@@ -140,12 +140,20 @@ export class HybridEngine implements IStorageEngine {
async createTable(schema: TableSchema): Promise<void> {
await this.memoryEngine.createTable(schema);
await this.diskEngine.createTable(schema);
try {
await this.diskEngine.createTable(schema);
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropTable(tableName: string): Promise<void> {
await this.memoryEngine.dropTable(tableName);
await this.diskEngine.dropTable(tableName);
try {
await this.diskEngine.dropTable(tableName);
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async hasTable(tableName: string): Promise<boolean> {
@@ -167,21 +175,47 @@ export class HybridEngine implements IStorageEngine {
column: import('../constants').ColumnDef & { name: string },
): Promise<void> {
await this.memoryEngine.alterTable(tableName, action, column);
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
} else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP') delete schema.columns[column.name];
try {
if (typeof this.diskEngine.alterTable === 'function') {
await this.diskEngine.alterTable(tableName, action, column);
} else {
// 磁盘引擎无引擎级实现 → 从磁盘重建内存 schema(disk 引擎 schema 以自身为准)
const schema = await this.diskEngine.getTableSchema(tableName);
if (schema && action === 'DROP') delete schema.columns[column.name];
}
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- CRUDwrite-through 策略) ----
/**
* v0.7.2: 磁盘写失败补偿
*
* =
*
*/
private async recoverMemoryAfterDiskError(error: unknown): Promise<never> {
try {
await this.reloadMemoryFromDisk();
} catch {
// 磁盘本身不可用(错误根源)时重载可能失败:错误已抛给调用方,
// 内存保持失败前状态,repair()/重试可恢复
// eslint-disable-next-line no-console
console.warn('[metona-sqlark] Hybrid: failed to reload memory after disk write error');
}
throw error;
}
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
const pks = await this.memoryEngine.insert(tableName, rows);
// write-through: 同步写入磁盘
await this.diskEngine.insert(tableName, rows);
try {
await this.diskEngine.insert(tableName, rows);
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return pks;
}
@@ -198,14 +232,22 @@ export class HybridEngine implements IStorageEngine {
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
const count = await this.memoryEngine.update(tableName, query, updates);
// write-through: 同步更新磁盘
await this.diskEngine.update(tableName, query, updates);
try {
await this.diskEngine.update(tableName, query, updates);
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
const count = await this.memoryEngine.delete(tableName, query);
// write-through: 同步删除磁盘
await this.diskEngine.delete(tableName, query);
try {
await this.diskEngine.delete(tableName, query);
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
return count;
}
@@ -215,22 +257,34 @@ export class HybridEngine implements IStorageEngine {
async clear(tableName: string): Promise<void> {
await this.memoryEngine.clear(tableName);
await this.diskEngine.clear(tableName);
try {
await this.diskEngine.clear(tableName);
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
// ---- 动态索引(v0.3.0 ----
async createIndex(tableName: string, column: string, unique?: boolean): Promise<void> {
await this.memoryEngine.createIndex(tableName, column, unique);
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
try {
if (typeof this.diskEngine.createIndex === 'function') {
await this.diskEngine.createIndex(tableName, column, unique);
}
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
async dropIndex(tableName: string, column: string, indexName?: string): Promise<void> {
await this.memoryEngine.dropIndex(tableName, column, indexName);
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
try {
if (typeof this.diskEngine.dropIndex === 'function') {
await this.diskEngine.dropIndex(tableName, column, indexName);
}
} catch (error) {
await this.recoverMemoryAfterDiskError(error);
}
}
+5 -1
View File
@@ -7,6 +7,7 @@
*/
import type { WhereCondition, OrderBy } from '../constants';
import { DatabaseError } from '../constants';
// ---------------------------------------------------------------------------
// LIKE 正则缓存
@@ -139,7 +140,10 @@ function matchOperator(value: unknown, op: string, operand: unknown): boolean {
case '$in': return Array.isArray(operand) && operand.includes(value);
case '$nin': return Array.isArray(operand) && !operand.includes(value);
case '$like': return compileLikeRegex(String(operand)).test(String(value));
default: return true;
// v0.7.2: 未知操作符显式报错 —— 此前静默返回 true(所有行匹配),
// 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示
default:
throw new DatabaseError(`Unknown where operator "${op}"`, 'QUERY_ERROR');
}
}
+10
View File
@@ -6,6 +6,7 @@
*/
import { TokenType, type Token, KEYWORDS } from './tokens';
import { DatabaseError } from '../constants';
// ---------------------------------------------------------------------------
// Lexer
@@ -217,6 +218,15 @@ export class Lexer {
this.readChar();
}
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
if (this.ch === '') {
throw new DatabaseError(
`Unterminated string literal at position ${start}`,
'PARSE_ERROR',
);
}
return {
type: TokenType.STRING,
value,
+29 -1
View File
@@ -6,6 +6,10 @@
* `?`
* SQL `''` //JSON
* SQL
*
* v0.7.2: 词法扫描感知注释 `--`slash-star `?`
* `?`
* PARAM_ERROR "Unterminated string literal"
*/
import { DatabaseError } from '../constants';
@@ -27,7 +31,7 @@ function encodeParam(value: unknown): string {
}
/**
* SQL `?`
* SQL `?`
* @param sql `?` SQL
* @param params
* @throws PARAM_ERROR
@@ -66,6 +70,30 @@ export function bindParameters(sql: string, params?: unknown[]): string {
continue;
}
// v0.7.2: 行注释 `-- ...`(含其中的 ? 与引号)原样保留、不参与绑定
if (ch === '-' && sql[i + 1] === '-') {
while (i < sql.length && sql[i] !== '\n' && sql[i] !== '\r') {
out += sql[i];
i++;
}
continue;
}
// v0.7.2: 块注释(slash-star 包裹)同样跳过
if (ch === '/' && sql[i + 1] === '*') {
out += sql[i] + sql[i + 1];
i += 2;
while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) {
out += sql[i];
i++;
}
if (i < sql.length) {
out += sql[i] + sql[i + 1];
i += 2;
}
continue;
}
if (ch === '?') {
if (pIdx >= params.length) {
throw new DatabaseError(
+13
View File
@@ -70,6 +70,19 @@ export function getPrimaryKey(schema: TableSchema): string {
return Object.keys(schema.columns)[0];
}
/**
* v0.7.2: 更新载荷清洗 undefined "不更新该列"
* `update({ col: undefined })` undefined
* null
*/
export function stripUndefinedUpdates(updates: Record<string, unknown>): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) clean[key] = value;
}
return clean;
}
/** 校验行数据 */
export function validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
const validated: Record<string, unknown> = {};
+3 -3
View File
@@ -66,7 +66,7 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
expect(viaIdx.length).toBe(5000);
}
await engine2.close();
}, 180000);
}, 600000);
it('小批量高频写入(每批 50 行)触发极端 freeze 竞态', async () => {
const engine = new AriaEngine({
@@ -89,7 +89,7 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
expect(await engine.find('items', { table: 'items', where: { tag: 'a' } })).toHaveLength(Math.ceil(TOTAL / 3));
expect(await engine.find('items', { table: 'items', where: { tag: 'b' } })).toHaveLength(TOTAL - Math.ceil(TOTAL / 3));
await engine.close();
}, 120000);
}, 600000);
it('多索引列同时写入:每列索引都完整', async () => {
const engine = new AriaEngine({
@@ -119,5 +119,5 @@ describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
expect(await engine.find('multi', { table: 'multi', where: { grp: 0 } })).toHaveLength(TOTAL / 5);
expect(await engine.find('multi', { table: 'multi', where: { grp: 4 } })).toHaveLength(TOTAL / 5);
await engine.close();
}, 120000);
}, 600000);
});
+1 -1
View File
@@ -218,7 +218,7 @@ describe('AriaEngine + kv 后端(storageBackend: kv', () => {
await e2.open(dbName, 1);
expect(await e2.count('big')).toBe(1000);
await e2.close();
}, 60000);
}, 300000);
});
// ===================================================================
+13 -13
View File
@@ -96,7 +96,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
await assertIndexes(engine2, 'big', 30000);
expect((await engine2.find('big', { table: 'big', where: { id: 'k50' } }))[0].name).toBe('Tx50');
await engine2.close();
}, 180000);
}, 600000);
it('opfs × 3 万行 + 双索引 + 崩溃恢复', async () => {
const dbName = uniqueDB();
@@ -110,7 +110,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
const engine2 = await reopen(dbName, coreConfig('opfs'));
await assertIndexes(engine2, 'big', 30000);
await engine2.close();
}, 180000);
}, 600000);
it('memory × 2 万行 + 双索引 + 事务回滚', async () => {
const engine = new AriaEngine(coreConfig('memory') as never);
@@ -126,7 +126,7 @@ describe('生产矩阵审计 — 后端 × 核心功能', () => {
expect(await engine.find('big', { table: 'big', where: { tag: 't9' } })).toHaveLength(2001);
expect(await engine.find('big', { table: 'big', where: { tag: 't7' } })).toHaveLength(1999);
await engine.close();
}, 120000);
}, 600000);
});
describe('生产矩阵审计 — 特性组合', () => {
@@ -149,7 +149,7 @@ describe('生产矩阵审计 — 特性组合', () => {
// 错误密码必须拒绝打开
const bad = new AriaEngine({ ...cfg, encryption: { password: 'wrong' } } as never);
await expect(bad.open(dbName, 1)).rejects.toThrow();
}, 180000);
}, 600000);
it('kv × pageStorage:false(整 value)→ 2 万行 + 崩溃恢复', async () => {
const dbName = uniqueDB();
@@ -167,7 +167,7 @@ describe('生产矩阵审计 — 特性组合', () => {
const engine2 = await reopen(dbName, cfg);
await assertIndexes(engine2, 'big', 20000);
await engine2.close();
}, 180000);
}, 600000);
it('opfs × 加密 × 压缩 × 页面化全开 → 2 万行 + 崩溃恢复', async () => {
const dbName = uniqueDB();
@@ -186,7 +186,7 @@ describe('生产矩阵审计 — 特性组合', () => {
const engine2 = await reopen(dbName, cfg);
await assertIndexes(engine2, 'big', 20000);
await engine2.close();
}, 180000);
}, 600000);
it('opfs × walEnabled:false → 写入 + 优雅关闭后重开完整', async () => {
const dbName = uniqueDB();
@@ -204,7 +204,7 @@ describe('生产矩阵审计 — 特性组合', () => {
const engine2 = await reopen(dbName, cfg);
await assertIndexes(engine2, 'big', 20000);
await engine2.close();
}, 180000);
}, 600000);
it('kv × walEnabled:false → 写入 + 优雅关闭后重开完整', async () => {
const dbName = uniqueDB();
@@ -221,7 +221,7 @@ describe('生产矩阵审计 — 特性组合', () => {
const engine2 = await reopen(dbName, cfg);
await assertIndexes(engine2, 'big', 20000);
await engine2.close();
}, 180000);
}, 600000);
it('kv × walSyncMode:batch → 优雅关闭后重开完整(崩溃保底已 checkpoint 数据)', async () => {
const dbName = uniqueDB();
@@ -238,7 +238,7 @@ describe('生产矩阵审计 — 特性组合', () => {
const engine2 = await reopen(dbName, cfg);
await assertIndexes(engine2, 'big', 20000);
await engine2.close();
}, 180000);
}, 600000);
it('opfs × walSyncMode:none → 优雅关闭后重开完整(checkpoint 兜底)', async () => {
const dbName = uniqueDB();
@@ -255,7 +255,7 @@ describe('生产矩阵审计 — 特性组合', () => {
const engine2 = await reopen(dbName, cfg);
await assertIndexes(engine2, 'big', 20000);
await engine2.close();
}, 180000);
}, 600000);
});
describe('生产矩阵审计 — 主键变更索引一致性(三后端)', () => {
@@ -270,7 +270,7 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
expect(await engine.find('big', { table: 'big', where: { tag: 'x' } })).toHaveLength(1);
expect(await engine.find('big', { table: 'big', where: { id: 'a1' } })).toHaveLength(0);
await engine.close();
}, 60000);
}, 300000);
it('opfs × 主键变更 + 崩溃恢复:索引一致', async () => {
const dbName = uniqueDB();
@@ -292,7 +292,7 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
expect(await engine2.find('big', { table: 'big', where: { tag: `t${t}` } })).toHaveLength(500);
}
await engine2.close();
}, 120000);
}, 600000);
it('memory × 级联删除 + 索引清理', async () => {
const engine = new AriaEngine({ storageBackend: 'memory', memtableSizeThreshold: 256 * 1024 } as never);
@@ -313,5 +313,5 @@ describe('生产矩阵审计 — 主键变更索引一致性(三后端)', ()
expect(await engine.count('child')).toBe(1999);
expect(await engine.find('child', { table: 'child', where: { pid: 'p5' } })).toHaveLength(0);
await engine.close();
}, 60000);
}, 300000);
});
+14 -13
View File
@@ -84,7 +84,7 @@ describe('AriaEngine — 生产负载验证', () => {
// 索引(重启重建)
expect(await engine2.find('big', { table: 'big', where: { tag: 't5' } })).toHaveLength(5000);
await engine2.close();
}, 180000);
}, 600000);
it('高频更新/删除(Compaction 回收墓碑)→ 重启后一致', async () => {
const dbName = uniqueDB();
@@ -127,7 +127,7 @@ describe('AriaEngine — 生产负载验证', () => {
await engine2.open(dbName, 1);
expect(await engine2.count('big')).toBe(count);
await engine2.close();
}, 120000);
}, 600000);
it('大 value100KB × 50)压缩写入/恢复完整', async () => {
const dbName = uniqueDB();
@@ -161,7 +161,7 @@ describe('AriaEngine — 生产负载验证', () => {
const one = await engine2.find('docs', { table: 'docs', where: { id: 'd25' } });
expect((one[0].body as string).length).toBe(chunk.length);
await engine2.close();
}, 120000);
}, 600000);
it('混合操作 + 崩溃:已确认写入零丢失(20000 操作)', async () => {
const dbName = uniqueDB();
@@ -217,7 +217,7 @@ describe('AriaEngine — 生产负载验证', () => {
expect(rows[0].val).toBe(expected.val);
}
await engine2.close();
}, 120000);
}, 600000);
it('大量删除(90% 行)+ Compaction → 重启无残留(墓碑清理)', async () => {
const dbName = uniqueDB();
@@ -253,7 +253,7 @@ describe('AriaEngine — 生产负载验证', () => {
const all = await engine2.find('big', { table: 'big' });
expect(all.every((r) => Number(String(r.id).slice(1)) < 1000)).toBe(true);
await engine2.close();
}, 120000);
}, 600000);
it('kv 后端 5 万行(页面化路径):写入 → 崩溃 → 恢复完整', async () => {
const dbName = uniqueDB();
@@ -289,7 +289,7 @@ describe('AriaEngine — 生产负载验证', () => {
expect(await engine2.count('big')).toBe(TOTAL);
expect(await engine2.find('big', { table: 'big', where: { tag: 't3' } })).toHaveLength(5000);
await engine2.close();
}, 180000);
}, 600000);
it('10 万行 kv 后端(含索引):完整查询 + 崩溃恢复(v0.6.1-perf 回归)', async () => {
const dbName = uniqueDB();
@@ -314,10 +314,11 @@ describe('AriaEngine — 生产负载验证', () => {
}
const insertMs = Date.now() - t0;
// 性能护栏:修复前 353sbatch 32 起每批 8~11s 性能悬崖),
// 修复后本机 ~30s。CIdebian runner + maxWorkers=2 并行重型测试)慢 2~3 倍,
// 护栏放宽到 120s —— 仍能拦截性能悬崖回归(353s >> 120s),不误报健康慢环境。
// 修复后本机 ~12.5s。CIdebian runner 慢 2~3 倍、重型套件串行)下
// 健康耗时约 30~80s护栏放宽到 240s —— 仍能拦截性能悬崖回归(353s >> 240s),
// 不误报健康慢环境。
console.log(`10万行 kv 插入耗时: ${insertMs}ms`);
expect(insertMs).toBeLessThan(120000);
expect(insertMs).toBeLessThan(240000);
expect(await engine.count('big')).toBe(TOTAL);
// 全部 10 个 tag 索引查询完整
@@ -339,7 +340,7 @@ describe('AriaEngine — 生产负载验证', () => {
expect(await engine2.count('big')).toBe(TOTAL);
expect(await engine2.find('big', { table: 'big', where: { tag: 't7' } })).toHaveLength(10000);
await engine2.close();
}, 180000);
}, 600000);
it('10 万行 opfs 后端(含索引):完整查询(v0.6.1-perf 回归)', async () => {
const engine = new AriaEngine({
@@ -362,14 +363,14 @@ describe('AriaEngine — 生产负载验证', () => {
await engine.insert('big', rows);
}
const insertMs = Date.now() - t0;
// 同上:CI 慢环境护栏放宽(本机 ~25s)
// 同上:CI 慢环境护栏放宽(本机 ~25s;悬崖回归仍会被拦截
console.log(`10万行 opfs 插入耗时: ${insertMs}ms`);
expect(insertMs).toBeLessThan(150000);
expect(insertMs).toBeLessThan(300000);
expect(await engine.count('big')).toBe(TOTAL);
for (let t = 0; t < 10; t++) {
const viaIdx = await engine.find('big', { table: 'big', where: { tag: `t${t}` } });
expect(viaIdx.length).toBe(10000);
}
await engine.close();
}, 180000);
}, 600000);
});
+3 -3
View File
@@ -58,7 +58,7 @@ describe('KVStoreEngine — 10 万级压力', () => {
expect(Number(rows[0].val)).toBe(Number(id.slice(1)));
}
await engine2.close();
}, 120000);
}, 600000);
it('5 万混合操作 + 崩溃模拟(不 checkpoint)→ 重开全部已确认写入可见', async () => {
const dbName = uniqueDB();
@@ -112,7 +112,7 @@ describe('KVStoreEngine — 10 万级压力', () => {
expect(rows[0].val).toBe(val);
}
await engine2.close();
}, 120000);
}, 600000);
it('多次 checkpoint 循环(500 次)数据不丢', async () => {
const dbName = uniqueDB();
@@ -135,5 +135,5 @@ describe('KVStoreEngine — 10 万级压力', () => {
const last = await engine2.find('t', { table: 't', where: { id: 'k499' } });
expect(last[0].v).toBe(499);
await engine2.close();
}, 60000);
}, 300000);
});
+2 -2
View File
@@ -129,7 +129,7 @@ describe('KVStore — 边界与故障', () => {
expect(kv2.size()).toBe(10000);
expect(dec(await kv2.get('k9999'))).toBe('v9999');
await kv2.close();
}, 60000);
}, 300000);
it('写入失败后 KVStore 继续可用(错误不污染后续操作)', async () => {
const dbName = uniqueDB();
@@ -322,7 +322,7 @@ describe('KVStoreEngine — 异常与一致性', () => {
const remaining = await e2.find('bulk', { table: 'bulk', where: { id: { $gte: 3000 } } });
expect(remaining).toHaveLength(1000);
await e2.close();
}, 60000);
}, 300000);
});
// ===================================================================
+1 -1
View File
@@ -28,7 +28,7 @@ beforeEach(() => { installOPFSMock(new Map()); });
describe('[v0.2.5] P0-1: 版本号统一', () => {
test('VERSION 常量为当前版本(0.6.0', () => {
expect(VERSION).toBe('0.7.1');
expect(VERSION).toBe('0.7.2');
});
});
+1 -1
View File
@@ -403,7 +403,7 @@ describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
describe('[v0.3.3] 端到端', () => {
test('全部修复点可共存于 MetonaSqlark API', async () => {
expect(VERSION).toBe('0.7.1');
expect(VERSION).toBe('0.7.2');
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
await db.init();
await db.defineTable('users', {
+540
View File
@@ -0,0 +1,540 @@
/**
* v0.7.2
*
*
* - UPDATE
* -
* - DDL ALTER TABLE / CREATE INDEX / DROP INDEXMemory/KVStore Aria
* - SET NULL required FOREIGN_KEY_VIOLATIONdelete update Memory/Aria
* - bindParameters / ?
* - PARSE_ERROR
* - where QUERY_ERROR
* - UPDATE undefined
* - Hybrid
*/
import { MetonaSqlark } from '../src/core';
import { parse } from '../src/sql/parser';
import { bindParameters } from '../src/sql/params';
import { matchWhere } from '../src/query/where-matcher';
import { MemoryEngine } from '../src/engine/memory';
import { KVStoreEngine } from '../src/engine/kvstore_engine';
import { HybridEngine } from '../src/hybrid/index';
import { createSchema } from '../src/table/schema';
function uniqueDB(): string {
return `v072-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
// ===================================================================
// UPDATE 语句级原子性
// ===================================================================
describe('v0.7.2 — UPDATE 语句级原子性', () => {
const modes = ['memory', 'disk', 'hybrid', 'aria'] as const;
for (const mode of modes) {
it(`${mode}: 多行更新撞唯一约束 → 整句拒绝、数据不变`, async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
});
await db.table('t').insertMany([
{ id: 'a', email: 'a@x.com' },
{ id: 'b', email: 'b@x.com' },
{ id: 'c', email: 'c@x.com' },
]);
await db.table('t').update({ email: 'z@x.com' }).where({ id: 'a' }).execute();
// 一条语句同时把 b、c 改成 a 已占用的 z@x.com:b 成功改到一半,c 撞唯一
await expect(
db.table('t').update({ email: 'z@x.com' }).where({ id: 'c' }).execute(),
).rejects.toThrow(); // 单行冲突
// 关键场景:无 where 全表更新到同一值 → 第二行即冲突
await expect(
db.table('t').update({ email: 'y@x.com' }).execute(),
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
// 整句拒绝:任何行都不该被改成 y@x.com
const rows = await db.query('SELECT id, email FROM t ORDER BY id') as Record<string, unknown>[];
expect(rows).toEqual([
{ id: 'a', email: 'z@x.com' },
{ id: 'b', email: 'b@x.com' },
{ id: 'c', email: 'c@x.com' },
]);
await db.close();
});
}
it('memory: 校验失败(required 置 null)也整句拒绝', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', required: true },
});
await db.table('t').insertMany([
{ id: '1', name: 'A' },
{ id: '2', name: 'B' },
]);
await expect(
db.table('t').update({ name: null }).execute(),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
const rows = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
expect(rows).toEqual([{ id: '1', name: 'A' }, { id: '2', name: 'B' }]);
await db.close();
});
it('aria: 主键变更撞已有主键整句拒绝(含 WAL 一致性)', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
n: { type: 'number' },
});
await db.table('t').insertMany([
{ id: 'a', n: 1 }, { id: 'b', n: 2 }, { id: 'c', n: 3 },
]);
// a → c 撞已有 c;且 d 行的更新(同语句)也不得生效
await expect(
db.table('t').update({ id: 'c' }).where({ id: 'a' }).execute(),
).rejects.toMatchObject({ code: 'DUPLICATE_KEY' });
const rows = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
expect(rows.map((r) => r.id)).toEqual(['a', 'b', 'c']);
await db.close();
});
});
// ===================================================================
// 批内唯一互查
// ===================================================================
describe('v0.7.2 — 批内唯一互查', () => {
it('两行同时更新到同一新唯一值 → 整句拒绝(memory)', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
});
await db.table('t').insertMany([
{ id: 'a', email: 'a@x.com' },
{ id: 'b', email: 'b@x.com' },
]);
await expect(
db.table('t').update({ email: 'z@x.com' }).execute(),
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
const rows = await db.query('SELECT email FROM t ORDER BY id') as Record<string, unknown>[];
expect(rows).toEqual([{ email: 'a@x.com' }, { email: 'b@x.com' }]);
await db.close();
});
it('两行同时更新到同一新唯一值 → 整句拒绝(aria)', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
});
await db.table('t').insertMany([
{ id: 'a', email: 'a@x.com' },
{ id: 'b', email: 'b@x.com' },
]);
await expect(
db.table('t').update({ email: 'z@x.com' }).execute(),
).rejects.toMatchObject({ code: 'UNIQUE_VIOLATION' });
const rows = await db.query('SELECT email FROM t ORDER BY id') as Record<string, unknown>[];
expect(rows).toEqual([{ email: 'a@x.com' }, { email: 'b@x.com' }]);
await db.close();
});
it('唯一值不变时更新自身不误报(回归)', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
name: { type: 'string' },
});
await db.table('t').insertMany([
{ id: 'a', email: 'a@x.com', name: 'A' },
]);
const n = await db.table('t').update({ name: 'A2', email: 'a@x.com' }).execute();
expect(n).toBe(1);
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows[0].name).toBe('A2');
await db.close();
});
});
// ===================================================================
// 事务内 DDL 拒绝
// ===================================================================
describe('v0.7.2 — 事务内 DDL 拒绝(三引擎一致)', () => {
it('memory: 事务内 ALTER TABLE 抛 NOT_SUPPORTED,事务可继续提交', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, a: { type: 'number' } });
await db.query('BEGIN');
await db.query("INSERT INTO t VALUES ('1', 10)");
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.query('COMMIT');
const schema = await db.getEngine().getTableSchema('t');
expect('b' in (schema!.columns)).toBe(false);
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toEqual([{ id: '1', a: 10 }]);
await db.close();
});
it('memory: ROLLBACK 后无 DDL 残留', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query('BEGIN');
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.query('ROLLBACK');
const schema = await db.getEngine().getTableSchema('t');
expect(Object.keys(schema!.columns)).toEqual(['id']);
await db.close();
});
it('kv: 事务内 ALTER TABLE 抛 NOT_SUPPORTED', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query('BEGIN');
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.query('ROLLBACK');
const schema = await db.getEngine().getTableSchema('t');
expect(Object.keys(schema!.columns)).toEqual(['id']);
await db.close();
});
it('kv: 事务内 CREATE INDEX / DROP INDEX 抛 NOT_SUPPORTED', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await db.query('BEGIN');
await expect(db.query('CREATE INDEX idx_v ON t (v)')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.query('ROLLBACK');
const schema = await db.getEngine().getTableSchema('t');
expect(schema!.columns.v.index).toBeFalsy();
await db.close();
});
it('memory: 事务内 createTable/dropTable 仍可回滚(既有行为保持)', async () => {
const eng = new MemoryEngine();
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await eng.beginTransaction();
await eng.createTable(createSchema('tmp', { id: { type: 'string', primaryKey: true } }));
await eng.rollbackTransaction();
expect(await eng.hasTable('tmp')).toBe(false);
expect(await eng.hasTable('t')).toBe(true);
await eng.close();
});
it('hybrid: 事务内 ALTER TABLE 抛 NOT_SUPPORTED(内存层拒绝)', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query('BEGIN');
await expect(db.query('ALTER TABLE t ADD COLUMN b STRING')).rejects.toMatchObject({ code: 'NOT_SUPPORTED' });
await db.query('ROLLBACK');
const schema = await db.getEngine().getTableSchema('t');
expect(Object.keys(schema!.columns)).toEqual(['id']);
await db.close();
});
});
// ===================================================================
// SET NULL 级联撞 required 列
// ===================================================================
describe('v0.7.2 — SET NULL 级联撞 required 列', () => {
it('memory: delete 级联 SET NULL 到 required 列 → FOREIGN_KEY_VIOLATION', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
await db.defineTable('child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', required: true, references: 'parent.id', onDelete: 'SET NULL' },
});
await db.query("INSERT INTO parent VALUES ('p1')");
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
await expect(db.query("DELETE FROM parent WHERE id = 'p1'")).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
// 整体拒绝:父行仍在
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
expect(rows).toHaveLength(1);
await db.close();
});
it('aria: delete 级联 SET NULL 到 required 列 → FOREIGN_KEY_VIOLATION', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
await db.defineTable('child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', required: true, references: 'parent.id', onDelete: 'SET NULL' },
});
await db.query("INSERT INTO parent VALUES ('p1')");
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
await expect(db.query("DELETE FROM parent WHERE id = 'p1'")).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
expect(rows).toHaveLength(1);
await db.close();
});
it('memory: update 主键变更 SET NULL 到 required 列 → 整体拒绝', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
await db.defineTable('child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', required: true, references: 'parent.id', onUpdate: 'SET NULL' },
});
await db.query("INSERT INTO parent VALUES ('p1')");
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
await expect(
db.table('parent').update({ id: 'p2' }).where({ id: 'p1' }).execute(),
).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
expect(rows).toEqual([{ id: 'p1' }]);
await db.close();
});
it('aria: update 主键变更 SET NULL 到 required 列 → 整体拒绝', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
await db.defineTable('child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', required: true, references: 'parent.id', onUpdate: 'SET NULL' },
});
await db.query("INSERT INTO parent VALUES ('p1')");
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
await expect(
db.table('parent').update({ id: 'p2' }).where({ id: 'p1' }).execute(),
).rejects.toMatchObject({ code: 'FOREIGN_KEY_VIOLATION' });
const rows = await db.query('SELECT * FROM parent') as Record<string, unknown>[];
expect(rows).toEqual([{ id: 'p1' }]);
await db.close();
});
it('SET NULL 到非 required 列仍正常(回归)', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('parent', { id: { type: 'string', primaryKey: true } });
await db.defineTable('child', {
id: { type: 'string', primaryKey: true },
pid: { type: 'string', references: 'parent.id', onDelete: 'SET NULL' },
});
await db.query("INSERT INTO parent VALUES ('p1')");
await db.query("INSERT INTO child VALUES ('c1', 'p1')");
await db.query("DELETE FROM parent WHERE id = 'p1'");
const rows = await db.query('SELECT * FROM child') as Record<string, unknown>[];
expect(rows).toEqual([{ id: 'c1', pid: null }]);
await db.close();
});
});
// ===================================================================
// bindParameters 注释感知
// ===================================================================
describe('v0.7.2 — bindParameters 注释感知', () => {
it('行注释中的 ? 不参与占位符', () => {
const sql = "SELECT * FROM t -- comment with ? inside\n WHERE id = ?";
const bound = bindParameters(sql, ['1']);
expect(bound).toContain('comment with ? inside');
expect(bound).toContain("WHERE id = '1'");
});
it('块注释中的 ? 不参与占位符', () => {
const sql = 'SELECT * FROM t /* c ? c */ WHERE id = ?';
const bound = bindParameters(sql, ['1']);
expect(bound).toContain('/* c ? c */');
expect(bound).toContain("WHERE id = '1'");
});
it("行注释中的单引号不触发未闭合字符串(-- don't", () => {
const sql = "SELECT * FROM t WHERE id = ? -- don't touch";
const bound = bindParameters(sql, ['1']);
expect(bound).toContain("-- don't touch");
});
it('块注释中的单引号不触发未闭合字符串', () => {
const sql = "SELECT * FROM t /* it's fine */ WHERE id = ?";
const bound = bindParameters(sql, ['1']);
expect(bound).toContain("/* it's fine */");
});
it('占位符数量按注释外计算(注释里两个 ? 不报参数不足)', () => {
const sql = "SELECT * FROM t WHERE id = ? /* ? ? */ AND x = ? -- ?\n";
const bound = bindParameters(sql, ['1', '2']);
expect(bound).toContain("'1'");
expect(bound).toContain("'2'");
});
it('SQL 注入载荷仍被安全编码(回归)', () => {
const sql = 'SELECT * FROM t WHERE name = ?';
const bound = bindParameters(sql, ["O'Brien'; DROP TABLE t; --"]);
expect(bound).toContain("'O''Brien''; DROP TABLE t; --'");
});
});
// ===================================================================
// 未闭合字符串字面量
// ===================================================================
describe('v0.7.2 — 未闭合字符串字面量', () => {
it("SELECT 'abc → PARSE_ERROR", () => {
expect(() => parse("SELECT 'abc")).toThrowError(/Unterminated string/);
});
it('query API 同样抛 PARSE_ERROR', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await expect(db.query("SELECT 'abc")).rejects.toMatchObject({ code: 'PARSE_ERROR' });
await db.close();
});
});
// ===================================================================
// 未知 where 操作符
// ===================================================================
describe('v0.7.2 — 未知 where 操作符抛错', () => {
it('$between 未实现 → QUERY_ERROR(不再静默全匹配)', () => {
expect(() => matchWhere({ age: 3 }, { age: { $between: [1, 2] } } as never)).toThrowError(/Unknown where operator/);
});
it('query API 路径同样报错', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1')");
await expect(
db.getEngine().find('t', { table: 't', where: { id: { $betwen: '1' } } as never }),
).rejects.toThrowError(/Unknown where operator/);
await db.close();
});
});
// ===================================================================
// UPDATE undefined 语义化
// ===================================================================
describe('v0.7.2 — UPDATE undefined 语义化', () => {
const modes = ['memory', 'disk', 'hybrid', 'aria'] as const;
for (const mode of modes) {
it(`${mode}: update({ col: undefined }) 保留旧值`, async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
tag: { type: 'string', default: 'def' },
});
await db.query("INSERT INTO t VALUES ('1', 'x')");
await db.table('t').update({ tag: undefined }).where({ id: '1' }).execute();
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows[0]).toEqual({ id: '1', tag: 'x' });
await db.close();
});
it(`${mode}: update({ col: null }) 仍显式置空`, async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode, diskEngine: 'memory' });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
tag: { type: 'string' },
});
await db.query("INSERT INTO t VALUES ('1', 'x')");
await db.table('t').update({ tag: null }).where({ id: '1' }).execute();
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows[0]).toEqual({ id: '1', tag: null });
await db.close();
});
}
it('主键 undefined 不触发主键变更路径(kv 引擎)', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'disk', diskEngine: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
await db.query("INSERT INTO t VALUES ('1', 1)");
const n = await db.table('t').update({ id: undefined, n: 2 }).where({ id: '1' }).execute();
expect(n).toBe(1);
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows[0]).toEqual({ id: '1', n: 2 });
await db.close();
});
});
// ===================================================================
// Hybrid 磁盘写失败补偿
// ===================================================================
describe('v0.7.2 — Hybrid 磁盘写失败补偿', () => {
it('insert 磁盘失败 → 错误抛出 + 内存与磁盘一致', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1')");
const eng = db.getEngine() as HybridEngine;
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
const origInsert = disk.insert.bind(disk);
let fail = true;
disk.insert = async (table: string, rows: Record<string, unknown>[]) => {
if (fail) throw new Error('disk down');
return origInsert(table, rows);
};
await expect(db.query("INSERT INTO t VALUES ('2')")).rejects.toThrow('disk down');
// 内存已对齐磁盘:'2' 不在(补偿后重载)
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toEqual([{ id: '1' }]);
// 恢复后正常写入
fail = false;
await db.query("INSERT INTO t VALUES ('3')");
const rows2 = await db.query('SELECT * FROM t ORDER BY id') as Record<string, unknown>[];
expect(rows2).toEqual([{ id: '1' }, { id: '3' }]);
await db.close();
});
it('update 磁盘失败 → 错误抛出 + 内存对齐磁盘', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, n: { type: 'number' } });
await db.query("INSERT INTO t VALUES ('1', 1)");
const eng = db.getEngine() as HybridEngine;
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
disk.update = async () => { throw new Error('disk down'); };
await expect(db.table('t').update({ n: 99 }).where({ id: '1' }).execute()).rejects.toThrow('disk down');
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toEqual([{ id: '1', n: 1 }]);
await db.close();
});
it('delete 磁盘失败 → 错误抛出 + 内存对齐磁盘', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1')");
const eng = db.getEngine() as HybridEngine;
const disk = (eng as unknown as { diskEngine: IStorageEngineMock }).diskEngine;
disk.delete = async () => { throw new Error('disk down'); };
await expect(db.table('t').delete().where({ id: '1' }).execute()).rejects.toThrow('disk down');
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toEqual([{ id: '1' }]);
await db.close();
});
it('事务内写失败仍由快照回滚(补偿不影响事务路径)', async () => {
const db = await MetonaSqlark.create({ name: uniqueDB(), mode: 'hybrid', diskEngine: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1')");
await expect(db.transaction(async (trx) => {
await trx.table('t').insert({ id: '2' });
throw new Error('tx fail');
})).rejects.toThrow('tx fail');
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toEqual([{ id: '1' }]);
await db.close();
});
});
interface IStorageEngineMock {
insert: (table: string, rows: Record<string, unknown>[]) => Promise<string[]>;
update: (table: string, query: never, updates: Record<string, unknown>) => Promise<number>;
delete: (table: string, query: never) => Promise<number>;
}
// 确保 KVStoreEngine 也导出(引用用)
void KVStoreEngine;