feat: metona-sqlark v0.1.12 — 前端TypeScript关系型数据库

- 4种存储引擎:Memory / IndexedDB / OPFS / Hybrid
- 完整SQL支持:SELECT/INSERT/UPDATE/DELETE/JOIN/GROUP BY/HAVING/DISTINCT
- Query Builder链式API + TypeScript泛型支持
- 聚合函数:COUNT/SUM/AVG/MIN/MAX
- 事务、插件系统(14 hooks)、发布订阅、数据迁移、导入导出
- React/Vue框架集成
- 264个测试用例,93.46%覆盖率
- 零运行时依赖
This commit is contained in:
thzxx
2026-07-26 15:00:01 +08:00
commit e2a590c5b1
60 changed files with 16359 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
/**
* metona-sqlark Engine — 存储引擎层
* @module engine
*/
export type { IStorageEngine } from './interface';
export { MemoryEngine } from './memory';
export { IndexedDBEngine } from './indexeddb';
export { OPFSEngine } from './opfs';
+185
View File
@@ -0,0 +1,185 @@
/**
* metona-sqlark IndexedDB Engine — 基于 IndexedDB 的持久化存储引擎
* @module engine/indexeddb
*/
import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema } from '../constants';
import { DatabaseError } from '../constants';
import { MemoryEngine } from './memory';
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
export class IndexedDBEngine implements IStorageEngine {
readonly name = 'indexeddb';
private db: IDBDatabase | null = null;
private dbName = '';
private version = 1;
private memoryCache: MemoryEngine = new MemoryEngine();
async open(dbName: string, version: number): Promise<void> {
this.dbName = dbName; this.version = version;
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.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; }
await this.memoryCache.close();
}
isOpen(): boolean { return this.db !== null; }
// ---- 表管理 ----
async createTable(schema: TableSchema): Promise<void> {
await this.memoryCache.createTable(schema);
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const newVersion = db.version + 1; db.close();
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
const pkColumn = Object.entries(schema.columns).find(([, c]) => c.primaryKey)?.[0] ?? Object.keys(schema.columns)[0];
const store = db.createObjectStore(schema.name, { keyPath: pkColumn });
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (colDef.index && colName !== pkColumn) {
store.createIndex(`idx_${colName}`, colName, { unique: colDef.unique ?? false });
}
}
};
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onerror = () => reject(new DatabaseError(`Failed to create table "${schema.name}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
async dropTable(tableName: string): Promise<void> {
await this.memoryCache.dropTable(tableName);
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const newVersion = db.version + 1; db.close();
const request = indexedDB.open(this.dbName, newVersion);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (db.objectStoreNames.contains(tableName)) db.deleteObjectStore(tableName);
};
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onerror = () => reject(new DatabaseError(`Failed to drop table "${tableName}"`, 'IDB_UPGRADE_ERROR', request.error));
});
}
async hasTable(tableName: string): Promise<boolean> { return this.ensureDB().objectStoreNames.contains(tableName); }
async getTableNames(): Promise<string[]> { return Array.from(this.ensureDB().objectStoreNames); }
async getTableSchema(tableName: string): Promise<TableSchema | null> { return this.memoryCache.getTableSchema(tableName); }
// ---- CRUD ----
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
await this.memoryCache.insert(tableName, rows);
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
const pks: string[] = [];
for (const row of rows) {
const req = store.add(row);
req.onsuccess = () => pks.push(String(req.result));
req.onerror = () => {}; // 内存层已校验,忽略 IDB 重复键
}
tx.oncomplete = () => resolve(pks);
tx.onerror = () => reject(new DatabaseError(`Insert failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readonly');
const req = tx.objectStore(tableName).getAll();
req.onsuccess = () => {
let results: Record<string, unknown>[] = req.result ?? [];
if (query.where && Object.keys(query.where).length > 0) {
results = results.filter((row) => matchWhere(row, query.where!));
}
if (query.orderBy && query.orderBy.length > 0) {
results = applyOrderBy(results, query.orderBy);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? results.length;
results = results.slice(offset, offset + limit);
if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
results = results.map((r) => projectColumns(r, query.columns!));
}
resolve(results);
};
req.onerror = () => reject(new DatabaseError(`Find failed for "${tableName}"`, 'IDB_READ_ERROR', req.error));
});
}
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
await this.memoryCache.update(tableName, query, updates);
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
const getAllReq = store.getAll();
let count = 0;
getAllReq.onsuccess = () => {
for (const row of getAllReq.result ?? []) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
Object.assign(row, updates); store.put(row); count++;
}
}
};
tx.oncomplete = () => resolve(count);
tx.onerror = () => reject(new DatabaseError(`Update failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
await this.memoryCache.delete(tableName, query);
const db = this.ensureDB();
const schema = await this.memoryCache.getTableSchema(tableName);
if (!schema) throw new DatabaseError(`Table "${tableName}" not found`, 'TABLE_NOT_FOUND');
const pkColumn = Object.entries(schema.columns).find(([, c]) => c.primaryKey)?.[0] ?? Object.keys(schema.columns)[0];
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
const store = tx.objectStore(tableName);
const getAllReq = store.getAll();
let count = 0;
getAllReq.onsuccess = () => {
for (const row of getAllReq.result ?? []) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
store.delete(row[pkColumn] as IDBValidKey); count++;
}
}
};
tx.oncomplete = () => resolve(count);
tx.onerror = () => reject(new DatabaseError(`Delete failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
async count(tableName: string, query?: QueryPlan): Promise<number> {
const results = await this.find(tableName, { table: tableName, where: query?.where ?? {} });
return results.length;
}
async clear(tableName: string): Promise<void> {
await this.memoryCache.clear(tableName);
const db = this.ensureDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(tableName, 'readwrite');
tx.objectStore(tableName).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new DatabaseError(`Clear failed for "${tableName}"`, 'IDB_TX_ERROR', tx.error));
});
}
private ensureDB(): IDBDatabase {
if (!this.db) throw new DatabaseError('Database not opened', 'DB_NOT_OPEN');
return this.db;
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* metona-sqlark Engine Interface — 存储引擎抽象接口
* @module engine/interface
*/
import type { QueryPlan, TableSchema } from '../constants';
// ---------------------------------------------------------------------------
// IStorageEngine — 所有存储引擎必须实现的接口
// ---------------------------------------------------------------------------
export interface IStorageEngine {
/** 引擎名称 */
readonly name: string;
/** 打开数据库 */
open(dbName: string, version: number): Promise<void>;
/** 关闭数据库 */
close(): Promise<void>;
/** 检查数据库是否已打开 */
isOpen(): boolean;
/** 创建表 */
createTable(schema: TableSchema): Promise<void>;
/** 删除表 */
dropTable(tableName: string): Promise<void>;
/** 检查表是否存在 */
hasTable(tableName: string): Promise<boolean>;
/** 获取所有表名 */
getTableNames(): Promise<string[]>;
/** 获取表结构 */
getTableSchema(tableName: string): Promise<TableSchema | null>;
/** 插入行,返回主键值列表 */
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>;
/** 删除行,返回影响行数 */
delete(tableName: string, query: QueryPlan): Promise<number>;
/** 计数 */
count(tableName: string, query?: QueryPlan): Promise<number>;
/** 清空表数据(保留结构) */
clear(tableName: string): Promise<void>;
}
+215
View File
@@ -0,0 +1,215 @@
/**
* metona-sqlark Memory Engine — 基于 Map 的内存存储引擎
* @module engine/memory
*/
import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema } from '../constants';
import { DatabaseError } from '../constants';
import { matchWhere, applyOrderBy, projectColumns } from '../query/where-matcher';
export class MemoryEngine implements IStorageEngine {
readonly name = 'memory';
private tables: Map<string, Map<string, Record<string, unknown>>> = new Map();
private schemas: Map<string, TableSchema> = new Map();
private indexes: Map<string, Map<string, Map<unknown, Set<string>>>> = new Map();
private opened = false;
// ---- 生命周期 ----
async open(_dbName: string, _version: number): Promise<void> { this.opened = true; }
async close(): Promise<void> {
this.tables.clear(); this.schemas.clear(); this.indexes.clear(); this.opened = false;
}
isOpen(): boolean { return this.opened; }
// ---- 表管理 ----
async createTable(schema: TableSchema): Promise<void> {
if (this.schemas.has(schema.name)) throw new DatabaseError(`Table "${schema.name}" already exists`, 'TABLE_EXISTS');
this.schemas.set(schema.name, schema);
this.tables.set(schema.name, new Map());
const tableIndexes = new Map<string, Map<unknown, Set<string>>>();
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (colDef.index || colDef.unique) tableIndexes.set(colName, new Map());
}
this.indexes.set(schema.name, tableIndexes);
}
async dropTable(tableName: string): Promise<void> {
this.ensureTable(tableName);
this.schemas.delete(tableName); this.tables.delete(tableName); this.indexes.delete(tableName);
}
async hasTable(tableName: string): Promise<boolean> { return this.schemas.has(tableName); }
async getTableNames(): Promise<string[]> { return Array.from(this.schemas.keys()); }
async getTableSchema(tableName: string): Promise<TableSchema | null> { return this.schemas.get(tableName) ?? null; }
// ---- CRUD ----
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const table = this.tables.get(tableName)!;
const pkColumn = this.getPrimaryKey(schema);
const pks: string[] = [];
for (const row of rows) {
const validatedRow = this.validateRow(schema, row);
const pkValue = String(validatedRow[pkColumn]);
if (table.has(pkValue)) throw new DatabaseError(`Duplicate primary key "${pkValue}" in table "${tableName}"`, 'DUPLICATE_KEY');
this.checkUniqueness(schema, validatedRow);
table.set(pkValue, validatedRow);
this.updateIndexes(tableName, validatedRow, pkValue);
pks.push(pkValue);
}
return pks;
}
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
this.ensureTable(tableName);
const table = this.tables.get(tableName)!;
let results = this.tryIndexLookup(tableName, table, query);
if (query.where && Object.keys(query.where).length > 0) {
results = results.filter((row) => matchWhere(row, query.where!));
}
if (query.orderBy && query.orderBy.length > 0) {
results = applyOrderBy(results, query.orderBy);
}
const offset = query.offset ?? 0;
const limit = query.limit ?? results.length;
results = results.slice(offset, offset + limit);
if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
results = results.map((row) => projectColumns(row, query.columns!));
}
return results;
}
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
this.ensureTable(tableName);
const schema = this.schemas.get(tableName)!;
const table = this.tables.get(tableName)!;
let count = 0;
for (const [pk, row] of table) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
const updated = { ...row, ...updates };
this.validateRow(schema, updated);
table.set(pk, updated);
count++;
}
}
return count;
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
this.ensureTable(tableName);
const table = this.tables.get(tableName)!;
const toDelete: string[] = [];
for (const [pk, row] of table) {
if (!query.where || Object.keys(query.where).length === 0 || matchWhere(row, query.where)) {
toDelete.push(pk);
}
}
for (const pk of toDelete) table.delete(pk);
return toDelete.length;
}
async count(tableName: string, query?: QueryPlan): Promise<number> {
this.ensureTable(tableName);
const table = this.tables.get(tableName)!;
if (!query?.where || Object.keys(query.where).length === 0) return table.size;
let result = 0;
for (const row of table.values()) { if (matchWhere(row, query.where)) result++; }
return result;
}
async clear(tableName: string): Promise<void> {
this.ensureTable(tableName);
this.tables.get(tableName)!.clear();
const tableIndexes = this.indexes.get(tableName);
if (tableIndexes) for (const colIndex of tableIndexes.values()) colIndex.clear();
}
// ---- 内部辅助 ----
private ensureTable(tableName: string): void {
if (!this.tables.has(tableName)) throw new DatabaseError(`Table "${tableName}" does not exist`, 'TABLE_NOT_FOUND');
}
private getPrimaryKey(schema: TableSchema): string {
for (const [name, col] of Object.entries(schema.columns)) { if (col.primaryKey) return name; }
return Object.keys(schema.columns)[0];
}
private validateRow(schema: TableSchema, row: Record<string, unknown>): Record<string, unknown> {
const validated: Record<string, unknown> = {};
for (const [colName, colDef] of Object.entries(schema.columns)) {
let value = row[colName];
if (value === undefined && colDef.default !== undefined) value = colDef.default;
if (colDef.required && (value === undefined || value === null)) {
throw new DatabaseError(`Column "${colName}" is required in table "${schema.name}"`, 'VALIDATION_ERROR');
}
if (value !== undefined && value !== null) this.checkType(colName, colDef.type, value);
if (value !== undefined) validated[colName] = value;
}
return validated;
}
private checkType(colName: string, type: string, value: unknown): void {
const jsType = typeof value;
switch (type) {
case 'string': if (jsType !== 'string') throw new DatabaseError(`Column "${colName}" expects string, got ${jsType}`, 'TYPE_ERROR'); break;
case 'number': if (jsType !== 'number') throw new DatabaseError(`Column "${colName}" expects number, got ${jsType}`, 'TYPE_ERROR'); break;
case 'boolean': if (jsType !== 'boolean') throw new DatabaseError(`Column "${colName}" expects boolean, got ${jsType}`, 'TYPE_ERROR'); break;
case 'date': if (jsType !== 'string' || isNaN(Date.parse(value as string))) throw new DatabaseError(`Column "${colName}" expects valid date`, 'TYPE_ERROR'); break;
case 'json': if (jsType !== 'object') throw new DatabaseError(`Column "${colName}" expects object/array, got ${jsType}`, 'TYPE_ERROR'); break;
}
}
/** O(1) 唯一性检查:利用哈希索引 */
private checkUniqueness(schema: TableSchema, row: Record<string, unknown>): void {
const tableIndexes = this.indexes.get(schema.name);
if (!tableIndexes) return;
for (const [colName, colDef] of Object.entries(schema.columns)) {
if (!colDef.unique || row[colName] === undefined || row[colName] === null) continue;
const colIndex = tableIndexes.get(colName);
if (colIndex && colIndex.has(row[colName])) {
throw new DatabaseError(`Unique constraint violation on column "${colName}" in table "${schema.name}"`, 'UNIQUE_VIOLATION');
}
}
}
/** 索引查找 */
private tryIndexLookup(
tableName: string, table: Map<string, Record<string, unknown>>, query: QueryPlan,
): Record<string, unknown>[] {
const tableIndexes = this.indexes.get(tableName);
if (!tableIndexes || !query.where) return Array.from(table.values());
for (const [col, condition] of Object.entries(query.where)) {
if (typeof condition !== 'object' || condition === null) {
const colIndex = tableIndexes.get(col);
if (colIndex) {
const pks = colIndex.get(condition);
if (pks) {
const result: Record<string, unknown>[] = [];
for (const pk of pks) { const r = table.get(pk); if (r) result.push(r); }
return result;
}
return [];
}
}
}
return Array.from(table.values());
}
/** 更新索引 */
private updateIndexes(tableName: string, row: Record<string, unknown>, pk: string): void {
const tableIndexes = this.indexes.get(tableName);
if (!tableIndexes) return;
for (const [colName, colIndex] of tableIndexes) {
const value = row[colName];
if (value !== undefined && value !== null) {
if (!colIndex.has(value)) colIndex.set(value, new Set());
colIndex.get(value)!.add(pk);
}
}
}
}
+174
View File
@@ -0,0 +1,174 @@
/**
* metona-sqlark OPFS Engine — 基于 Origin Private File System 的持久化存储引擎
* @module engine/opfs
*
* 使用 JSON-per-table 文件存储方案。
* 目录结构:{dbName}/tables/{tableName}.json
*/
import type { IStorageEngine } from './interface';
import type { QueryPlan, TableSchema } from '../constants';
import { DatabaseError } from '../constants';
import { MemoryEngine } from './memory';
// ---------------------------------------------------------------------------
// OPFSEngine
// ---------------------------------------------------------------------------
export class OPFSEngine implements IStorageEngine {
readonly name = 'opfs';
private root: FileSystemDirectoryHandle | null = null;
private tablesDir: FileSystemDirectoryHandle | null = null;
private dbName = '';
// 运行时内存缓存(OPFS 文件读写有延迟)
private memoryCache: MemoryEngine = new MemoryEngine();
// ---- 生命周期 ----
async open(dbName: string, version: number): Promise<void> {
this.dbName = dbName;
await this.memoryCache.open(dbName, version);
// 获取 OPFS 根目录
this.root = await navigator.storage.getDirectory();
// 创建数据库目录
this.tablesDir = await this.root.getDirectoryHandle(dbName, { create: true });
}
async close(): Promise<void> {
this.root = null;
this.tablesDir = null;
await this.memoryCache.close();
}
isOpen(): boolean {
return this.tablesDir !== null;
}
// ---- 表管理 ----
async createTable(schema: TableSchema): Promise<void> {
await this.memoryCache.createTable(schema);
// OPFS 中表以空 JSON 数组文件形式存在
await this.writeTableData(schema.name, []);
}
async dropTable(tableName: string): Promise<void> {
await this.memoryCache.dropTable(tableName);
if (this.tablesDir) {
try {
await this.tablesDir.removeEntry(`${tableName}.json`);
} catch {
// 文件不存在则忽略
}
}
}
async hasTable(tableName: string): Promise<boolean> {
if (!this.tablesDir) return false;
try {
await this.tablesDir.getFileHandle(`${tableName}.json`);
return true;
} catch {
return false;
}
}
async getTableNames(): Promise<string[]> {
if (!this.tablesDir) return [];
const names: string[] = [];
for await (const [name] of (this.tablesDir as any).entries()) {
if (name.endsWith('.json')) {
names.push(name.replace('.json', ''));
}
}
return names;
}
async getTableSchema(tableName: string): Promise<TableSchema | null> {
return this.memoryCache.getTableSchema(tableName);
}
// ---- CRUD ----
async insert(tableName: string, rows: Record<string, unknown>[]): Promise<string[]> {
const pks = await this.memoryCache.insert(tableName, rows);
// 持久化到 OPFS
const allRows = await this.memoryCache.find(tableName, { table: tableName });
await this.writeTableData(tableName, allRows);
return pks;
}
async find(tableName: string, query: QueryPlan): Promise<Record<string, unknown>[]> {
return this.memoryCache.find(tableName, query);
}
async update(tableName: string, query: QueryPlan, updates: Record<string, unknown>): Promise<number> {
const count = await this.memoryCache.update(tableName, query, updates);
const allRows = await this.memoryCache.find(tableName, { table: tableName });
await this.writeTableData(tableName, allRows);
return count;
}
async delete(tableName: string, query: QueryPlan): Promise<number> {
const count = await this.memoryCache.delete(tableName, query);
const allRows = await this.memoryCache.find(tableName, { table: tableName });
await this.writeTableData(tableName, allRows);
return count;
}
async count(tableName: string, query?: QueryPlan): Promise<number> {
return this.memoryCache.count(tableName, query);
}
async clear(tableName: string): Promise<void> {
await this.memoryCache.clear(tableName);
await this.writeTableData(tableName, []);
}
// ---- 内部辅助 ----
private ensureDir(): FileSystemDirectoryHandle {
if (!this.tablesDir) {
throw new DatabaseError('Database not opened', 'DB_NOT_OPEN');
}
return this.tablesDir;
}
private async writeTableData(tableName: string, data: Record<string, unknown>[]): Promise<void> {
const dir = this.ensureDir();
const fileName = `${tableName}.json`;
const fileHandle = await dir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data));
await writable.close();
}
private async readTableData(tableName: string): Promise<Record<string, unknown>[]> {
const dir = this.ensureDir();
const fileName = `${tableName}.json`;
try {
const fileHandle = await dir.getFileHandle(fileName);
const file = await fileHandle.getFile();
const text = await file.text();
return JSON.parse(text);
} catch {
return [];
}
}
/** 从 OPFS 加载表数据到内存缓存 */
async loadTableIntoMemory(tableName: string, schema: TableSchema): Promise<void> {
await this.memoryCache.createTable(schema);
const rows = await this.readTableData(tableName);
if (rows.length > 0) {
// 直接用 Map 设置绕过 insert 校验
for (const row of rows) {
await this.memoryCache.insert(tableName, [row]);
}
}
}
}