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:
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user