feat(database): add database service abstraction with libsql/turso backend (#3472)

This commit is contained in:
Huang Xin
2026-03-05 02:01:35 +08:00
parent bd957a4eb8
commit 5273ef75dc
26 changed files with 2738 additions and 668 deletions
+15 -6
View File
@@ -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",
+1
View File
@@ -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"
]
+18 -12
View File
@@ -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);
}
}
+13
View File
@@ -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>;
}
+2
View File
@@ -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>;
}
+2 -1
View File
@@ -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' }],
},
},
});
+1
View File
@@ -7,5 +7,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/*.browser.test.ts'],
},
});