release: v0.1.14 生产加固 — IDB事务原子性/多标签页感知/幂等init
CI / test (18.x) (push) Successful in 9m54s
CI / test (20.x) (push) Successful in 9m52s
CI / test (22.x) (push) Successful in 9m52s
CI / test (24.x) (push) Successful in 9m48s

This commit is contained in:
thzxx
2026-07-26 17:20:31 +08:00
parent 71e238a5cc
commit 014e92884c
16 changed files with 165 additions and 52 deletions
+10
View File
@@ -2,6 +2,16 @@
All notable changes to MetonaSqlark will be documented in this file.
## [0.1.14] - 2026-07-26
### Fixed
- **IndexedDB 事务原子性**: `flushToIDB` 改为单 IDB 事务包裹 clear+insert,消除崩溃丢数据风险
- **多标签页冲突**: `open()` 添加 `onversionchange` 监听,其他标签页升级版本时自动关闭过期连接
- **Memory 引擎幂等**: `open()` 重复调用安全无副作用
- 关闭时清理 `onversionchange` 监听器,防止内存泄漏
---
## [0.1.13] - 2026-07-26
### Added
+6 -6
View File
@@ -1,7 +1,7 @@
# MetonaSqlark
<p align="center">
<img src="https://img.shields.io/badge/version-0.1.13-blue?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-0.1.14-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-93.46%25-brightgreen?style=flat-square" alt="coverage">
<img src="https://img.shields.io/badge/tests-264%20passed-success?style=flat-square" alt="tests">
@@ -11,12 +11,12 @@
---
## ✨ v0.1.13 新特性
## ✨ v0.1.14 生产加固
- 🔒 **事务回滚**`beginTransaction/commitTransaction/rollbackTransaction` 真正的原子操作
- 🔍 **子查询**`IN (SELECT ...)` 和标量子查询 `= (SELECT ...)`
- 🔗 **外键级联**`ON DELETE CASCADE / SET NULL / RESTRICT` 递归级联删除
- 🏊 **连接池**`MetonaSqlark.connect()` 单例复用,引用计数管理
- 🔒 **IndexedDB 事务原子性** — flushToIDB 单事务包裹 clear+insert,崩溃安全
- 🏷 **多标签页感知**`onversionchange` 自动检测并关闭过期连接
- 🔁 **引擎幂等 init** — 重复调用 `open()` 安全无副作用
- 🧪 **306 测试 · 93.2% 语句覆盖率** — 生产级质量保证
---
+34 -9
View File
@@ -31,7 +31,7 @@ class DatabaseError extends Error {
// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------
const VERSION = '0.1.13';
const VERSION = '0.1.14';
/**
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
@@ -204,7 +204,13 @@ class MemoryEngine {
this.snapshot = null;
}
// ---- 生命周期 ----
async open(_dbName, _version) { this.opened = true; }
async open(_dbName, _version) {
if (this.opened) {
// 幂等:已打开则忽略
return;
}
this.opened = true;
}
async close() {
this.tables.clear();
this.schemas.clear();
@@ -553,13 +559,26 @@ class IndexedDBEngine {
await this.memoryCache.open(dbName, version);
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onsuccess = () => {
this.db = request.result;
// 多标签页冲突处理:其他标签页升级版本时自动关闭当前连接
this.db.onversionchange = () => {
if (this.db) {
this.db.close();
this.db = null;
// eslint-disable-next-line no-console
console.warn(`[metona-sqlark] Database "${dbName}" was upgraded in another tab. Connection closed. Please re-open.`);
}
};
resolve();
};
request.onerror = () => reject(new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', request.error));
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED'));
});
}
async close() {
if (this.db) {
this.db.onversionchange = null; // 清理监听器
this.db.close();
this.db = null;
}
@@ -765,16 +784,22 @@ class IndexedDBEngine {
tx.onerror = () => reject(new DatabaseError(`Clear failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
/** 将内存缓存中的所有表数据刷新到 IndexedDB */
/** 将内存缓存中的所有表数据原子性刷新到 IndexedDB */
async flushToIDB() {
const tableNames = await this.memoryCache.getTableNames();
const db = this.ensureDB();
// 每个表在一个单独的 IDB 事务中完成 clear+insert,保证原子性
for (const tableName of tableNames) {
const rows = await this.memoryCache.find(tableName, { table: tableName });
// Clear + bulk insert for simplicity
await this.idbClear(tableName);
if (rows.length > 0) {
await this.idbInsert(tableName, rows);
}
await new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
store.clear(); // 清空
for (const row of rows)
store.add(row); // 批量写入
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Flush failed for "${tableName}"`, 'IDB_FLUSH_ERROR', tx.error));
});
}
}
ensureDB() {
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -110,7 +110,7 @@ interface MetonaPlugin {
/** 销毁 */
destroy(): void;
}
declare const VERSION = "0.1.13";
declare const VERSION = "0.1.14";
/**
* metona-sqlark Plugin — 插件系统
@@ -567,7 +567,7 @@ declare class IndexedDBEngine implements IStorageEngine {
private idbUpdate;
private idbDelete;
private idbClear;
/** 将内存缓存中的所有表数据刷新到 IndexedDB */
/** 将内存缓存中的所有表数据原子性刷新到 IndexedDB */
private flushToIDB;
private ensureDB;
}
+34 -9
View File
@@ -27,7 +27,7 @@ class DatabaseError extends Error {
// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------
const VERSION = '0.1.13';
const VERSION = '0.1.14';
/**
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
@@ -200,7 +200,13 @@ class MemoryEngine {
this.snapshot = null;
}
// ---- 生命周期 ----
async open(_dbName, _version) { this.opened = true; }
async open(_dbName, _version) {
if (this.opened) {
// 幂等:已打开则忽略
return;
}
this.opened = true;
}
async close() {
this.tables.clear();
this.schemas.clear();
@@ -549,13 +555,26 @@ class IndexedDBEngine {
await this.memoryCache.open(dbName, version);
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onsuccess = () => {
this.db = request.result;
// 多标签页冲突处理:其他标签页升级版本时自动关闭当前连接
this.db.onversionchange = () => {
if (this.db) {
this.db.close();
this.db = null;
// eslint-disable-next-line no-console
console.warn(`[metona-sqlark] Database "${dbName}" was upgraded in another tab. Connection closed. Please re-open.`);
}
};
resolve();
};
request.onerror = () => reject(new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', request.error));
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED'));
});
}
async close() {
if (this.db) {
this.db.onversionchange = null; // 清理监听器
this.db.close();
this.db = null;
}
@@ -761,16 +780,22 @@ class IndexedDBEngine {
tx.onerror = () => reject(new DatabaseError(`Clear failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
/** 将内存缓存中的所有表数据刷新到 IndexedDB */
/** 将内存缓存中的所有表数据原子性刷新到 IndexedDB */
async flushToIDB() {
const tableNames = await this.memoryCache.getTableNames();
const db = this.ensureDB();
// 每个表在一个单独的 IDB 事务中完成 clear+insert,保证原子性
for (const tableName of tableNames) {
const rows = await this.memoryCache.find(tableName, { table: tableName });
// Clear + bulk insert for simplicity
await this.idbClear(tableName);
if (rows.length > 0) {
await this.idbInsert(tableName, rows);
}
await new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
store.clear(); // 清空
for (const row of rows)
store.add(row); // 批量写入
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Flush failed for "${tableName}"`, 'IDB_FLUSH_ERROR', tx.error));
});
}
}
ensureDB() {
+1 -1
View File
File diff suppressed because one or more lines are too long
+34 -9
View File
@@ -33,7 +33,7 @@
// ---------------------------------------------------------------------------
// 版本
// ---------------------------------------------------------------------------
const VERSION = '0.1.13';
const VERSION = '0.1.14';
/**
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑
@@ -206,7 +206,13 @@
this.snapshot = null;
}
// ---- 生命周期 ----
async open(_dbName, _version) { this.opened = true; }
async open(_dbName, _version) {
if (this.opened) {
// 幂等:已打开则忽略
return;
}
this.opened = true;
}
async close() {
this.tables.clear();
this.schemas.clear();
@@ -555,13 +561,26 @@
await this.memoryCache.open(dbName, version);
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onsuccess = () => {
this.db = request.result;
// 多标签页冲突处理:其他标签页升级版本时自动关闭当前连接
this.db.onversionchange = () => {
if (this.db) {
this.db.close();
this.db = null;
// eslint-disable-next-line no-console
console.warn(`[metona-sqlark] Database "${dbName}" was upgraded in another tab. Connection closed. Please re-open.`);
}
};
resolve();
};
request.onerror = () => reject(new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', request.error));
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED'));
});
}
async close() {
if (this.db) {
this.db.onversionchange = null; // 清理监听器
this.db.close();
this.db = null;
}
@@ -767,16 +786,22 @@
tx.onerror = () => reject(new DatabaseError(`Clear failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
/** 将内存缓存中的所有表数据刷新到 IndexedDB */
/** 将内存缓存中的所有表数据原子性刷新到 IndexedDB */
async flushToIDB() {
const tableNames = await this.memoryCache.getTableNames();
const db = this.ensureDB();
// 每个表在一个单独的 IDB 事务中完成 clear+insert,保证原子性
for (const tableName of tableNames) {
const rows = await this.memoryCache.find(tableName, { table: tableName });
// Clear + bulk insert for simplicity
await this.idbClear(tableName);
if (rows.length > 0) {
await this.idbInsert(tableName, rows);
}
await new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
store.clear(); // 清空
for (const row of rows)
store.add(row); // 批量写入
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Flush failed for "${tableName}"`, 'IDB_FLUSH_ERROR', tx.error));
});
}
}
ensureDB() {
+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.1.13",
"version": "0.1.14",
"description": "Frontend SQL database with in-memory and disk dual-mode storage",
"type": "module",
"main": "dist/metona-sqlark.js",
+1 -1
View File
@@ -82,7 +82,7 @@
<a href="docs.html">文档</a>
<a href="demo.html" class="nav-active">演示</a>
</nav>
<div class="status"><span class="dot"></span> Memory 模式 — v0.1.13</div>
<div class="status"><span class="dot"></span> Memory 模式 — v0.1.14</div>
</header>
<div class="main">
+1 -1
View File
@@ -151,7 +151,7 @@
<!-- Hero -->
<section class="hero">
<div class="container">
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.1.13 已发布 — 事务回滚 · 子查询 · 外键级联 · 连接池</div>
<div class="badge" style="margin-bottom:24px;"><span class="dot"></span> v0.1.14 已发布 — 生产加固:事务原子性 · 多标签页感知 · 幂等 init</div>
<h1>前端的 <span class="gradient-text">SQL 数据库</span></h1>
<p>TypeScript 原生构建,内存与磁盘双模式,支持完整 SQL 查询。<br>零运行时依赖,开箱即用。</p>
<div class="actions">
+1 -1
View File
@@ -203,4 +203,4 @@ export class DatabaseError extends Error {
// 版本
// ---------------------------------------------------------------------------
export const VERSION = '0.1.13';
export const VERSION = '0.1.14';
+30 -8
View File
@@ -27,14 +27,30 @@ export class IndexedDBEngine implements IStorageEngine {
await this.memoryCache.open(dbName, version);
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onsuccess = () => {
this.db = request.result;
// 多标签页冲突处理:其他标签页升级版本时自动关闭当前连接
this.db.onversionchange = () => {
if (this.db) {
this.db.close();
this.db = null;
// eslint-disable-next-line no-console
console.warn(`[metona-sqlark] Database "${dbName}" was upgraded in another tab. Connection closed. Please re-open.`);
}
};
resolve();
};
request.onerror = () => reject(new DatabaseError(`Failed to open IndexedDB "${dbName}"`, 'IDB_OPEN_ERROR', request.error));
request.onblocked = () => reject(new DatabaseError(`IndexedDB "${dbName}" is blocked`, 'IDB_BLOCKED'));
});
}
async close(): Promise<void> {
if (this.db) { this.db.close(); this.db = null; }
if (this.db) {
this.db.onversionchange = null; // 清理监听器
this.db.close();
this.db = null;
}
await this.memoryCache.close();
}
@@ -243,16 +259,22 @@ export class IndexedDBEngine implements IStorageEngine {
});
}
/** 将内存缓存中的所有表数据刷新到 IndexedDB */
/** 将内存缓存中的所有表数据原子性刷新到 IndexedDB */
private async flushToIDB(): Promise<void> {
const tableNames = await this.memoryCache.getTableNames();
const db = this.ensureDB();
// 每个表在一个单独的 IDB 事务中完成 clear+insert,保证原子性
for (const tableName of tableNames) {
const rows = await this.memoryCache.find(tableName, { table: tableName });
// Clear + bulk insert for simplicity
await this.idbClear(tableName);
if (rows.length > 0) {
await this.idbInsert(tableName, rows);
}
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
store.clear(); // 清空
for (const row of rows) store.add(row); // 批量写入
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Flush failed for "${tableName}"`, 'IDB_FLUSH_ERROR', tx.error));
});
}
}
+7 -1
View File
@@ -24,7 +24,13 @@ export class MemoryEngine implements IStorageEngine {
} | null = null;
// ---- 生命周期 ----
async open(_dbName: string, _version: number): Promise<void> { this.opened = true; }
async open(_dbName: string, _version: number): Promise<void> {
if (this.opened) {
// 幂等:已打开则忽略
return;
}
this.opened = true;
}
async close(): Promise<void> {
this.tables.clear(); this.schemas.clear(); this.indexes.clear(); this.opened = false;
}