feat(database): add database service abstraction with libsql/turso backend (#3472)
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"i18n:extract": "i18next-scanner",
|
||||
"lint": "eslint .",
|
||||
"test": "dotenv -e .env -e .env.test.local vitest",
|
||||
"test:browser": "vitest --config vitest.browser.config.mts --watch=false",
|
||||
"tauri": "tauri",
|
||||
"clippy": "cargo clippy -p Readest --no-deps -- -D warnings",
|
||||
"format": "pnpm -w format",
|
||||
@@ -74,6 +75,10 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.1",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"@serwist/next": "^9.5.6",
|
||||
"@serwist/webpack-plugin": "^9.5.6",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
@@ -81,20 +86,21 @@
|
||||
"@supabase/auth-ui-react": "^0.4.7",
|
||||
"@supabase/auth-ui-shared": "^0.1.8",
|
||||
"@supabase/supabase-js": "^2.76.1",
|
||||
"@tauri-apps/api": "2.9.1",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.6",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-haptics": "^2.3.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "~2.3.4",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-shell": "~2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-websocket": "~2.4.2",
|
||||
"@tursodatabase/database-wasm": "^0.4.4",
|
||||
"@zip.js/zip.js": "^2.8.16",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"ai": "^6.0.47",
|
||||
@@ -156,9 +162,10 @@
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^15.4.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tauri-apps/cli": "2.9.6",
|
||||
"@tauri-apps/cli": "2.10.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@tursodatabase/database": "^0.4.4",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cssbeautify": "^0.3.5",
|
||||
"@types/lunr": "^2.3.7",
|
||||
@@ -175,6 +182,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"cpx2": "^8.0.0",
|
||||
@@ -187,6 +195,7 @@
|
||||
"jsdom": "^26.1.0",
|
||||
"mkdirp": "^3.0.1",
|
||||
"node-env-run": "^4.0.2",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
|
||||
@@ -56,6 +56,7 @@ tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-sharekit = "0.3"
|
||||
tauri-plugin-device-info = "1.0.1"
|
||||
tauri-plugin-libsql = { path = "./plugins/tauri-plugin-libsql" }
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
rand = "0.8"
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
"deep-link:default",
|
||||
"sharekit:default",
|
||||
"device-info:default",
|
||||
"libsql:default",
|
||||
"native-tts:default",
|
||||
"native-bridge:default"
|
||||
]
|
||||
|
||||
Submodule apps/readest-app/src-tauri/plugins/tauri-plugin-libsql added at b1b1d671c5
@@ -185,22 +185,28 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_sharekit::init())
|
||||
.plugin(tauri_plugin_device_info::init())
|
||||
.plugin(tauri_plugin_libsql::init())
|
||||
.plugin(tauri_plugin_native_bridge::init())
|
||||
.plugin(tauri_plugin_native_tts::init());
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
|
||||
let _ = app
|
||||
.get_webview_window("main")
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
let files = get_files_from_argv(argv.clone());
|
||||
if !files.is_empty() {
|
||||
allow_file_in_scopes(app, files.clone());
|
||||
}
|
||||
app.emit("single-instance", SingleInstancePayload { args: argv, cwd })
|
||||
.unwrap();
|
||||
}));
|
||||
let builder = builder.plugin(
|
||||
tauri_plugin_single_instance::Builder::new()
|
||||
.callback(move |app, argv, cwd| {
|
||||
let _ = app
|
||||
.get_webview_window("main")
|
||||
.expect("no main window")
|
||||
.set_focus();
|
||||
let files = get_files_from_argv(argv.clone());
|
||||
if !files.is_empty() {
|
||||
allow_file_in_scopes(app, files.clone());
|
||||
}
|
||||
app.emit("single-instance", SingleInstancePayload { args: argv, cwd })
|
||||
.unwrap();
|
||||
})
|
||||
.dbus_id("com.bilingify.readest".to_owned())
|
||||
.build(),
|
||||
);
|
||||
|
||||
let builder = builder.plugin(tauri_plugin_deep_link::init());
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock: NativeDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('tauri-plugin-libsql', () => {
|
||||
const rows = new Map<string, DatabaseRow[]>();
|
||||
|
||||
const mockDb = {
|
||||
execute: vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||
const insertTable = sql.match(/INTO\s+(\w+)/i)?.[1];
|
||||
const fromTable = sql.match(/FROM\s+(\w+)/i)?.[1];
|
||||
const table = insertTable ?? fromTable ?? '_default';
|
||||
if (/^INSERT/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
const id = existing.length + 1;
|
||||
existing.push({ id, value: params[0] ?? null });
|
||||
rows.set(table, existing);
|
||||
return { rowsAffected: 1, lastInsertId: id };
|
||||
}
|
||||
if (/^DELETE/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
rows.set(table, []);
|
||||
return { rowsAffected: existing.length, lastInsertId: 0 };
|
||||
}
|
||||
return { rowsAffected: 0, lastInsertId: 0 };
|
||||
}),
|
||||
select: vi.fn(async (sql: string) => {
|
||||
const table = sql.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
|
||||
return rows.get(table) ?? [];
|
||||
}),
|
||||
batch: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {
|
||||
rows.clear();
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
Database: {
|
||||
load: vi.fn(async () => mockDb),
|
||||
},
|
||||
__mockDb: mockDb,
|
||||
__rows: rows,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock: @tursodatabase/database-wasm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('@tursodatabase/database-wasm', () => {
|
||||
const rows = new Map<string, DatabaseRow[]>();
|
||||
|
||||
const mockDb = {
|
||||
prepare: vi.fn((sql: string) => ({
|
||||
run: vi.fn((...params: unknown[]) => {
|
||||
const table = sql.match(/INTO\s+(\w+)/i)?.[1] ?? '_default';
|
||||
if (/^INSERT/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
const id = existing.length + 1;
|
||||
existing.push({ id, value: params[0] ?? null });
|
||||
rows.set(table, existing);
|
||||
return { changes: 1, lastInsertRowid: id };
|
||||
}
|
||||
if (/^DELETE/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
rows.set(table, []);
|
||||
return { changes: existing.length, lastInsertRowid: 0 };
|
||||
}
|
||||
return { changes: 0, lastInsertRowid: 0 };
|
||||
}),
|
||||
all: vi.fn(() => {
|
||||
const table = sql.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
|
||||
return rows.get(table) ?? [];
|
||||
}),
|
||||
})),
|
||||
exec: vi.fn(),
|
||||
close: vi.fn(() => {
|
||||
rows.clear();
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
connect: vi.fn(() => mockDb),
|
||||
__mockDb: mockDb,
|
||||
__rows: rows,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: NativeDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('NativeDatabaseService', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mod = await import('tauri-plugin-libsql');
|
||||
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
|
||||
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
db = await NativeDatabaseService.open('sqlite:test.db');
|
||||
});
|
||||
|
||||
it('execute() returns DatabaseExecResult for INSERT', async () => {
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (value) VALUES (?)', [
|
||||
'hello',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('execute() returns DatabaseExecResult for DELETE', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['a']);
|
||||
const result = await db.execute('DELETE FROM items');
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
});
|
||||
|
||||
it('select() returns typed row arrays', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['alpha']);
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['beta']);
|
||||
|
||||
const rows = await db.select<{ id: number; value: string }>('SELECT * FROM items');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.id).toBe(1);
|
||||
expect(rows[0]!.value).toBe('alpha');
|
||||
expect(rows[1]!.id).toBe(2);
|
||||
expect(rows[1]!.value).toBe('beta');
|
||||
});
|
||||
|
||||
it('select() returns empty array when no rows', async () => {
|
||||
const rows = await db.select('SELECT * FROM empty_table');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('batch() delegates to underlying db.batch()', async () => {
|
||||
await db.batch(['CREATE TABLE t (id INTEGER)', 'INSERT INTO t VALUES (1)']);
|
||||
const mod = await import('tauri-plugin-libsql');
|
||||
const mockDb = (mod as unknown as { __mockDb: { batch: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.batch).toHaveBeenCalledWith([
|
||||
'CREATE TABLE t (id INTEGER)',
|
||||
'INSERT INTO t VALUES (1)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('close() delegates to underlying db.close()', async () => {
|
||||
await db.close();
|
||||
const mod = await import('tauri-plugin-libsql');
|
||||
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: WebDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('WebDatabaseService', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
db = await WebDatabaseService.open('test.db');
|
||||
});
|
||||
|
||||
it('execute() maps changes/lastInsertRowid to DatabaseExecResult', async () => {
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (value) VALUES (?)', [
|
||||
'hello',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('select() returns row objects from prepare().all()', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['alpha']);
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['beta']);
|
||||
|
||||
const rows = await db.select<{ id: number; value: string }>('SELECT * FROM items');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.id).toBe(1);
|
||||
expect(rows[0]!.value).toBe('alpha');
|
||||
});
|
||||
|
||||
it('select() returns empty array when no rows', async () => {
|
||||
const rows = await db.select('SELECT * FROM empty_table');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('batch() wraps in BEGIN/COMMIT transaction', async () => {
|
||||
await db.batch(['CREATE TABLE t (id INTEGER)', 'INSERT INTO t VALUES (1)']);
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { exec: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('BEGIN');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('CREATE TABLE t (id INTEGER)');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('INSERT INTO t VALUES (1)');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('COMMIT');
|
||||
});
|
||||
|
||||
it('batch() rolls back on error', async () => {
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { exec: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
let callCount = 0;
|
||||
mockDb.exec.mockImplementation((_sql: string) => {
|
||||
callCount++;
|
||||
// Fail on the second exec (first real statement after BEGIN)
|
||||
if (callCount === 2) throw new Error('SQL error');
|
||||
});
|
||||
|
||||
await expect(db.batch(['BAD SQL', 'GOOD SQL'])).rejects.toThrow('SQL error');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('ROLLBACK');
|
||||
});
|
||||
|
||||
it('close() delegates to underlying db.close()', async () => {
|
||||
await db.close();
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: DatabaseExecResult shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DatabaseExecResult type contract', () => {
|
||||
it('has rowsAffected and lastInsertId properties', () => {
|
||||
const result: DatabaseExecResult = {
|
||||
rowsAffected: 5,
|
||||
lastInsertId: 42,
|
||||
};
|
||||
expect(result.rowsAffected).toBe(5);
|
||||
expect(result.lastInsertId).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Unified API consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('API consistency between Native and Web implementations', () => {
|
||||
it('both implementations satisfy the DatabaseService interface', async () => {
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
const nativeDb: DatabaseService = await NativeDatabaseService.open('sqlite:test.db');
|
||||
expect(nativeDb.execute).toBeDefined();
|
||||
expect(nativeDb.select).toBeDefined();
|
||||
expect(nativeDb.batch).toBeDefined();
|
||||
expect(nativeDb.close).toBeDefined();
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
const webDb: DatabaseService = await WebDatabaseService.open('test.db');
|
||||
expect(webDb.execute).toBeDefined();
|
||||
expect(webDb.select).toBeDefined();
|
||||
expect(webDb.batch).toBeDefined();
|
||||
expect(webDb.close).toBeDefined();
|
||||
});
|
||||
|
||||
it('both return same shape from execute()', async () => {
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
const nativeDb = await NativeDatabaseService.open('sqlite:test.db');
|
||||
const nativeResult = await nativeDb.execute('INSERT INTO t (value) VALUES (?)', ['x']);
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
const webDb = await WebDatabaseService.open('test.db');
|
||||
const webResult = await webDb.execute('INSERT INTO t (value) VALUES (?)', ['x']);
|
||||
|
||||
expect(Object.keys(nativeResult).sort()).toEqual(['lastInsertId', 'rowsAffected']);
|
||||
expect(Object.keys(webResult).sort()).toEqual(['lastInsertId', 'rowsAffected']);
|
||||
expect(typeof nativeResult.rowsAffected).toBe('number');
|
||||
expect(typeof webResult.rowsAffected).toBe('number');
|
||||
expect(typeof nativeResult.lastInsertId).toBe('number');
|
||||
expect(typeof webResult.lastInsertId).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
|
||||
import { DatabaseService, DatabaseExecResult } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Integration tests using a real in-memory SQLite database via @tursodatabase/database.
|
||||
* These complement the mock-based tests in databaseService.test.ts by exercising
|
||||
* actual SQL execution through the DatabaseService interface using the same
|
||||
* libsql engine that powers the browser-based @tursodatabase/database-wasm.
|
||||
*/
|
||||
describe('NodeDatabaseService (real in-memory SQLite)', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await NodeDatabaseService.open(':memory:');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Schema & basic operations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('creates a table and inserts a row', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (name) VALUES (?)', [
|
||||
'apple',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBe(1);
|
||||
});
|
||||
|
||||
it('inserts multiple rows with auto-incrementing ids', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const r1 = await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
const r2 = await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
const r3 = await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
expect(r1.lastInsertId).toBe(1);
|
||||
expect(r2.lastInsertId).toBe(2);
|
||||
expect(r3.lastInsertId).toBe(3);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SELECT queries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('select() returns typed rows', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)');
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['apple', 10]);
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['banana', 20]);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string; qty: number }>(
|
||||
'SELECT * FROM items ORDER BY id',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.name).toBe('apple');
|
||||
expect(rows[0]!.qty).toBe(10);
|
||||
expect(rows[1]!.name).toBe('banana');
|
||||
expect(rows[1]!.qty).toBe(20);
|
||||
});
|
||||
|
||||
it('select() with WHERE and params', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['apple']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['banana']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['cherry']);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string }>(
|
||||
'SELECT * FROM items WHERE name = ?',
|
||||
['banana'],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.name).toBe('banana');
|
||||
});
|
||||
|
||||
it('select() returns empty array for no matching rows', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const rows = await db.select('SELECT * FROM items');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// UPDATE & DELETE
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('execute() UPDATE returns rowsAffected', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
|
||||
const result = await db.execute('UPDATE items SET name = ? WHERE name = ?', ['new', 'old']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
});
|
||||
|
||||
it('execute() DELETE returns rowsAffected', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
|
||||
const result = await db.execute('DELETE FROM items WHERE name IN (?, ?)', ['a', 'c']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
|
||||
const remaining = await db.select<{ name: string }>('SELECT name FROM items');
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.name).toBe('b');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Batch execution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('batch() executes multiple statements atomically', async () => {
|
||||
await db.batch([
|
||||
'CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)',
|
||||
"INSERT INTO t1 (val) VALUES ('one')",
|
||||
"INSERT INTO t1 (val) VALUES ('two')",
|
||||
"INSERT INTO t1 (val) VALUES ('three')",
|
||||
]);
|
||||
|
||||
const rows = await db.select<{ val: string }>('SELECT val FROM t1 ORDER BY id');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.val)).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('handles NULL values correctly', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', [null]);
|
||||
|
||||
const rows = await db.select<{ id: number; val: string | null }>('SELECT * FROM t');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.val).toBeNull();
|
||||
});
|
||||
|
||||
it('handles integer and real types', async () => {
|
||||
await db.execute('CREATE TABLE nums (i INTEGER, r REAL)');
|
||||
await db.execute('INSERT INTO nums (i, r) VALUES (?, ?)', [42, 3.14]);
|
||||
|
||||
const rows = await db.select<{ i: number; r: number }>('SELECT * FROM nums');
|
||||
expect(rows[0]!.i).toBe(42);
|
||||
expect(rows[0]!.r).toBeCloseTo(3.14);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws on invalid SQL', async () => {
|
||||
await expect(db.execute('INVALID SQL STATEMENT')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on constraint violation', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT UNIQUE)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val']);
|
||||
await expect(db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val'])).rejects.toThrow();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DatabaseExecResult contract
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('execute() result always has rowsAffected and lastInsertId as numbers', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY)');
|
||||
const result = await db.execute('INSERT INTO t DEFAULT VALUES');
|
||||
expect(typeof result.rowsAffected).toBe('number');
|
||||
expect(typeof result.lastInsertId).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { WebDatabaseService } from '@/services/database/webDatabaseService';
|
||||
import { DatabaseService, DatabaseExecResult } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Browser-based integration tests for WebDatabaseService using @tursodatabase/database-wasm.
|
||||
* These run in real headless Chromium via @vitest/browser + Playwright, providing
|
||||
* Web Workers, SharedArrayBuffer, and OPFS support required by the WASM module.
|
||||
*/
|
||||
describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await WebDatabaseService.open(':memory:');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Schema & basic operations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('creates a table and inserts a row', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (name) VALUES (?)', [
|
||||
'apple',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBe(1);
|
||||
});
|
||||
|
||||
it('inserts multiple rows with auto-incrementing ids', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const r1 = await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
const r2 = await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
const r3 = await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
expect(r1.lastInsertId).toBe(1);
|
||||
expect(r2.lastInsertId).toBe(2);
|
||||
expect(r3.lastInsertId).toBe(3);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SELECT queries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('select() returns typed rows', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)');
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['apple', 10]);
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['banana', 20]);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string; qty: number }>(
|
||||
'SELECT * FROM items ORDER BY id',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.name).toBe('apple');
|
||||
expect(rows[0]!.qty).toBe(10);
|
||||
expect(rows[1]!.name).toBe('banana');
|
||||
expect(rows[1]!.qty).toBe(20);
|
||||
});
|
||||
|
||||
it('select() with WHERE and params', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['apple']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['banana']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['cherry']);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string }>(
|
||||
'SELECT * FROM items WHERE name = ?',
|
||||
['banana'],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.name).toBe('banana');
|
||||
});
|
||||
|
||||
it('select() returns empty array for no matching rows', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const rows = await db.select('SELECT * FROM items');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// UPDATE & DELETE
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('execute() UPDATE returns rowsAffected', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
|
||||
const result = await db.execute('UPDATE items SET name = ? WHERE name = ?', ['new', 'old']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
});
|
||||
|
||||
it('execute() DELETE returns rowsAffected', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
|
||||
const result = await db.execute('DELETE FROM items WHERE name IN (?, ?)', ['a', 'c']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
|
||||
const remaining = await db.select<{ name: string }>('SELECT name FROM items');
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.name).toBe('b');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Batch execution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('batch() executes multiple statements atomically', async () => {
|
||||
await db.batch([
|
||||
'CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)',
|
||||
"INSERT INTO t1 (val) VALUES ('one')",
|
||||
"INSERT INTO t1 (val) VALUES ('two')",
|
||||
"INSERT INTO t1 (val) VALUES ('three')",
|
||||
]);
|
||||
|
||||
const rows = await db.select<{ val: string }>('SELECT val FROM t1 ORDER BY id');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.val)).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('handles NULL values correctly', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', [null]);
|
||||
|
||||
const rows = await db.select<{ id: number; val: string | null }>('SELECT * FROM t');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.val).toBeNull();
|
||||
});
|
||||
|
||||
it('handles integer and real types', async () => {
|
||||
await db.execute('CREATE TABLE nums (i INTEGER, r REAL)');
|
||||
await db.execute('INSERT INTO nums (i, r) VALUES (?, ?)', [42, 3.14]);
|
||||
|
||||
const rows = await db.select<{ i: number; r: number }>('SELECT * FROM nums');
|
||||
expect(rows[0]!.i).toBe(42);
|
||||
expect(rows[0]!.r).toBeCloseTo(3.14);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws on invalid SQL', async () => {
|
||||
await expect(db.execute('INVALID SQL STATEMENT')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on constraint violation', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT UNIQUE)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val']);
|
||||
await expect(db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val'])).rejects.toThrow();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DatabaseExecResult contract
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('execute() result always has rowsAffected and lastInsertId as numbers', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY)');
|
||||
const result = await db.execute('INSERT INTO t DEFAULT VALUES');
|
||||
expect(typeof result.rowsAffected).toBe('number');
|
||||
expect(typeof result.lastInsertId).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SelectDirectoryMode,
|
||||
} from '@/types/system';
|
||||
import { FileSystem, BaseDir, DeleteAction } from '@/types/system';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import {
|
||||
Book,
|
||||
BookConfig,
|
||||
@@ -132,6 +133,7 @@ export abstract class BaseAppService implements AppService {
|
||||
mimeType?: string,
|
||||
): Promise<boolean>;
|
||||
abstract ask(message: string): Promise<boolean>;
|
||||
abstract openDatabase(path: string, base: BaseDir): Promise<DatabaseService>;
|
||||
|
||||
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
|
||||
if (lastMigrationVersion < 20251124) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Database, QueryResult } from 'tauri-plugin-libsql';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
|
||||
export class NativeDatabaseService implements DatabaseService {
|
||||
private db: Database;
|
||||
|
||||
private constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
static async open(path: string): Promise<NativeDatabaseService> {
|
||||
const db = await Database.load(path);
|
||||
return new NativeDatabaseService(db);
|
||||
}
|
||||
|
||||
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
|
||||
const result: QueryResult = await this.db.execute(sql, params);
|
||||
return {
|
||||
rowsAffected: result.rowsAffected,
|
||||
lastInsertId: result.lastInsertId,
|
||||
};
|
||||
}
|
||||
|
||||
async select<T extends DatabaseRow = DatabaseRow>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
return await this.db.select<T[]>(sql, params);
|
||||
}
|
||||
|
||||
async batch(statements: string[]): Promise<void> {
|
||||
await this.db.batch(statements);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
|
||||
interface TursoRunResult {
|
||||
changes: number;
|
||||
lastInsertRowid: number;
|
||||
}
|
||||
|
||||
interface TursoStatement {
|
||||
run(...params: unknown[]): Promise<TursoRunResult>;
|
||||
all(...params: unknown[]): Promise<Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
interface TursoDatabase {
|
||||
prepare(sql: string): TursoStatement;
|
||||
exec(sql: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DatabaseService implementation backed by @tursodatabase/database (Node.js native).
|
||||
* Uses the same libsql engine and API surface as the browser-based
|
||||
* @tursodatabase/database-wasm used by WebDatabaseService.
|
||||
*/
|
||||
export class NodeDatabaseService implements DatabaseService {
|
||||
private db: TursoDatabase;
|
||||
|
||||
private constructor(db: TursoDatabase) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
static async open(path: string): Promise<NodeDatabaseService> {
|
||||
const mod = await import('@tursodatabase/database');
|
||||
const db = (await mod.connect(path)) as unknown as TursoDatabase;
|
||||
return new NodeDatabaseService(db);
|
||||
}
|
||||
|
||||
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
|
||||
const stmt = this.db.prepare(sql);
|
||||
const result = await stmt.run(...params);
|
||||
return {
|
||||
rowsAffected: result.changes,
|
||||
lastInsertId: Number(result.lastInsertRowid),
|
||||
};
|
||||
}
|
||||
|
||||
async select<T extends DatabaseRow = DatabaseRow>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const stmt = this.db.prepare(sql);
|
||||
const rows = await stmt.all(...params);
|
||||
return rows as T[];
|
||||
}
|
||||
|
||||
async batch(statements: string[]): Promise<void> {
|
||||
await this.db.exec('BEGIN');
|
||||
try {
|
||||
for (const sql of statements) {
|
||||
await this.db.exec(sql);
|
||||
}
|
||||
await this.db.exec('COMMIT');
|
||||
} catch (error: unknown) {
|
||||
await this.db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
|
||||
interface WasmRunResult {
|
||||
changes: number;
|
||||
lastInsertRowid: number;
|
||||
}
|
||||
|
||||
interface WasmStatement {
|
||||
run(...params: unknown[]): Promise<WasmRunResult>;
|
||||
all(...params: unknown[]): Promise<Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
interface WasmDatabase {
|
||||
prepare(sql: string): WasmStatement;
|
||||
exec(sql: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class WebDatabaseService implements DatabaseService {
|
||||
private db: WasmDatabase;
|
||||
|
||||
private constructor(db: WasmDatabase) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
static async open(path: string): Promise<WebDatabaseService> {
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const db = (await mod.connect(path)) as unknown as WasmDatabase;
|
||||
return new WebDatabaseService(db);
|
||||
}
|
||||
|
||||
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
|
||||
const stmt = this.db.prepare(sql);
|
||||
const result = await stmt.run(...params);
|
||||
return {
|
||||
rowsAffected: result.changes,
|
||||
lastInsertId: Number(result.lastInsertRowid),
|
||||
};
|
||||
}
|
||||
|
||||
async select<T extends DatabaseRow = DatabaseRow>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const stmt = this.db.prepare(sql);
|
||||
const rows = await stmt.all(...params);
|
||||
return rows as T[];
|
||||
}
|
||||
|
||||
async batch(statements: string[]): Promise<void> {
|
||||
await this.db.exec('BEGIN');
|
||||
try {
|
||||
for (const sql of statements) {
|
||||
await this.db.exec(sql);
|
||||
}
|
||||
await this.db.exec('COMMIT');
|
||||
} catch (error: unknown) {
|
||||
await this.db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { copyURIToPath, getStorefrontRegionCode } from '@/utils/bridge';
|
||||
import { copyFiles } from '@/utils/files';
|
||||
|
||||
import { BaseAppService } from './appService';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import {
|
||||
DATA_SUBDIR,
|
||||
LOCAL_BOOKS_SUBDIR,
|
||||
@@ -564,6 +565,12 @@ export class NativeAppService extends BaseAppService {
|
||||
return await ask(message);
|
||||
}
|
||||
|
||||
async openDatabase(path: string, base: BaseDir): Promise<DatabaseService> {
|
||||
const fullPath = await this.resolveFilePath(path, base);
|
||||
const { NativeDatabaseService } = await import('./database/nativeDatabaseService');
|
||||
return NativeDatabaseService.open(`sqlite:${fullPath}`);
|
||||
}
|
||||
|
||||
async migrate20251029() {
|
||||
console.log('Running migration 20251029 to update paths in Images dir...');
|
||||
const rootPath = await this.resolveFilePath('..', 'Data');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FileSystem, BaseDir, AppPlatform, ResolvedPath, FileItem } from '@/types/system';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import { getOSPlatform, isValidURL } from '@/utils/misc';
|
||||
import { RemoteFile } from '@/utils/file';
|
||||
import { isPWA } from './environment';
|
||||
@@ -347,4 +348,10 @@ export class WebAppService extends BaseAppService {
|
||||
async ask(message: string): Promise<boolean> {
|
||||
return window.confirm(message);
|
||||
}
|
||||
|
||||
async openDatabase(path: string, base: BaseDir): Promise<DatabaseService> {
|
||||
const fullPath = await this.resolveFilePath(path, base);
|
||||
const { WebDatabaseService } = await import('./database/webDatabaseService');
|
||||
return WebDatabaseService.open(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface DatabaseExecResult {
|
||||
rowsAffected: number;
|
||||
lastInsertId: number;
|
||||
}
|
||||
|
||||
export type DatabaseRow = Record<string, unknown>;
|
||||
|
||||
export interface DatabaseService {
|
||||
execute(sql: string, params?: unknown[]): Promise<DatabaseExecResult>;
|
||||
select<T extends DatabaseRow = DatabaseRow>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||
batch(statements: string[]): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { BookMetadata } from '@/libs/document';
|
||||
import { ProgressHandler } from '@/utils/transfer';
|
||||
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
|
||||
import { CustomTextureInfo } from '@/styles/textures';
|
||||
import { DatabaseService } from './database';
|
||||
|
||||
export type AppPlatform = 'web' | 'tauri';
|
||||
export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
|
||||
@@ -158,4 +159,5 @@ export interface AppService {
|
||||
generateCoverImageUrl(book: Book): Promise<string>;
|
||||
updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise<void>;
|
||||
ask(message: string): Promise<boolean>;
|
||||
openDatabase(path: string, base: BaseDir): Promise<DatabaseService>;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/ui/*": ["./src/components/primitives/*"],
|
||||
"@pdfjs/*": ["./public/vendor/pdfjs/*"],
|
||||
"@simplecc/*": ["./public/vendor/simplecc/*"]
|
||||
"@simplecc/*": ["./public/vendor/simplecc/*"],
|
||||
"tauri-plugin-libsql": ["./src-tauri/plugins/tauri-plugin-libsql/guest-js"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
resolve: {
|
||||
conditions: ['development'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@tursodatabase/database-wasm',
|
||||
'@tursodatabase/database-wasm-common',
|
||||
'@tursodatabase/database-common',
|
||||
],
|
||||
},
|
||||
server: {
|
||||
headers: {
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.browser.test.ts'],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'chromium' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -7,5 +7,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**', '**/*.browser.test.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user