feat(database): support turso fts in tauri apps (#3484)

This commit is contained in:
Huang Xin
2026-03-06 04:00:15 +08:00
committed by GitHub
parent 13588b4a65
commit 97cab2d70b
19 changed files with 1482 additions and 1435 deletions
+3 -3
View File
@@ -10,6 +10,6 @@
[submodule "packages/simplecc-wasm"]
path = packages/simplecc-wasm
url = https://github.com/readest/simplecc-wasm.git
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-libsql"]
path = apps/readest-app/src-tauri/plugins/tauri-plugin-libsql
url = https://github.com/readest/tauri-plugin-libsql.git
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"]
path = apps/readest-app/src-tauri/plugins/tauri-plugin-turso
url = https://github.com/readest/tauri-plugin-turso.git
+1 -1
View File
@@ -25,7 +25,7 @@ packages
# Vendored Assets (Generated/External Code)
apps/readest-app/public/*.js
apps/readest-app/public/vendor
apps/readest-app/src-tauri/plugins/tauri-plugin-libsql
apps/readest-app/src-tauri/plugins/tauri-plugin-turso
# Environment & Editor
.env
Generated
+1171 -727
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -63,7 +63,7 @@ Vitest connects directly to the embedded WebDriver server (port 4445) in the run
- **Config:** `vitest.tauri.config.mts`
- **Pattern:** `src/**/*.tauri.test.ts`
- **Browser provider:** `@vitest/browser-webdriverio` (connects to port 4445)
- **Use for:** Tauri plugin commands (libsql, native-tts, etc.), native filesystem, Tauri IPC.
- **Use for:** Tauri plugin commands (turso, native-tts, etc.), native filesystem, Tauri IPC.
### Writing Tauri Tests
@@ -73,7 +73,7 @@ Tests access Tauri IPC via a shared helper:
import { invoke } from '../tauri/tauri-invoke';
it('calls a plugin command', async () => {
const result = await invoke('plugin:libsql|load', { options: { path: 'sqlite::memory:' } });
const result = await invoke('plugin:turso|load', { options: { path: 'sqlite::memory:' } });
expect(result).toBeDefined();
});
```
+1 -1
View File
@@ -58,7 +58,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" }
tauri-plugin-turso = { path = "./plugins/tauri-plugin-turso" }
tauri-plugin-webdriver = { version = "0.2", optional = true }
[target."cfg(target_os = \"macos\")".dependencies]
@@ -16,7 +16,7 @@
"log:default",
"shell:default",
"process:default",
"libsql:default",
"turso:default",
"native-tts:default",
"native-bridge:default"
]
@@ -179,7 +179,7 @@
"deep-link:default",
"sharekit:default",
"device-info:default",
"libsql:default",
"turso:default",
"native-tts:default",
"native-bridge:default"
]
+1 -1
View File
@@ -185,7 +185,7 @@ 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_turso::init())
.plugin(tauri_plugin_native_bridge::init())
.plugin(tauri_plugin_native_tts::init());
@@ -1,366 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { invoke } from '../tauri/tauri-invoke';
/**
* Integration tests for tauri-plugin-libsql running inside the Tauri WebView.
* These call plugin IPC commands directly via __TAURI_INTERNALS__.invoke().
*/
describe('libsql plugin (native Tauri)', () => {
const DB_PATH = 'sqlite::memory:';
let dbPath: string;
beforeEach(async () => {
dbPath = (await invoke('plugin:libsql|load', {
options: { path: DB_PATH },
})) as string;
});
afterEach(async () => {
await invoke('plugin:libsql|close', { db: dbPath });
});
// ---------------------------------------------------------------------------
// Connection lifecycle
// ---------------------------------------------------------------------------
it('loads an in-memory database and returns a path', () => {
expect(typeof dbPath).toBe('string');
expect(dbPath.length).toBeGreaterThan(0);
});
// ---------------------------------------------------------------------------
// Schema & basic operations
// ---------------------------------------------------------------------------
it('creates a table and inserts a row', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
const result = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['apple'],
})) as { rowsAffected: number; lastInsertId: number };
expect(result.rowsAffected).toBe(1);
expect(result.lastInsertId).toBe(1);
});
it('inserts multiple rows with auto-incrementing ids', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
const r1 = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['a'],
})) as { lastInsertId: number };
const r2 = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['b'],
})) as { lastInsertId: number };
const r3 = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['c'],
})) as { lastInsertId: number };
expect(r1.lastInsertId).toBe(1);
expect(r2.lastInsertId).toBe(2);
expect(r3.lastInsertId).toBe(3);
});
// ---------------------------------------------------------------------------
// SELECT queries
// ---------------------------------------------------------------------------
it('select returns typed rows', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name, qty) VALUES (?, ?)',
values: ['apple', 10],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name, qty) VALUES (?, ?)',
values: ['banana', 20],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM items ORDER BY id',
values: [],
})) as Array<{ id: number; name: string; qty: number }>;
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 invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['apple'],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['banana'],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM items WHERE name = ?',
values: ['banana'],
})) as Array<{ id: number; name: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]!.name).toBe('banana');
});
it('select returns empty array for no matching rows', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM items',
values: [],
})) as unknown[];
expect(rows).toEqual([]);
});
// ---------------------------------------------------------------------------
// UPDATE & DELETE
// ---------------------------------------------------------------------------
it('UPDATE returns rowsAffected', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['old'],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['old'],
});
const result = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'UPDATE items SET name = ? WHERE name = ?',
values: ['new', 'old'],
})) as { rowsAffected: number };
expect(result.rowsAffected).toBe(2);
});
it('DELETE returns rowsAffected', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['a'],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['b'],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['c'],
});
const result = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'DELETE FROM items WHERE name IN (?, ?)',
values: ['a', 'c'],
})) as { rowsAffected: number };
expect(result.rowsAffected).toBe(2);
const remaining = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT name FROM items',
values: [],
})) as Array<{ name: string }>;
expect(remaining).toHaveLength(1);
expect(remaining[0]!.name).toBe('b');
});
// ---------------------------------------------------------------------------
// Batch execution
// ---------------------------------------------------------------------------
it('batch executes multiple statements atomically', async () => {
await invoke('plugin:libsql|batch', {
db: dbPath,
queries: [
'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 invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT val FROM t1 ORDER BY id',
values: [],
})) as Array<{ val: string }>;
expect(rows).toHaveLength(3);
expect(rows.map((r) => r.val)).toEqual(['one', 'two', 'three']);
});
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
it('handles NULL values correctly', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO t (val) VALUES (?)',
values: [null],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM t',
values: [],
})) as Array<{ id: number; val: string | null }>;
expect(rows).toHaveLength(1);
expect(rows[0]!.val).toBeNull();
});
it('handles integer and real types', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE nums (i INTEGER, r REAL)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO nums (i, r) VALUES (?, ?)',
values: [42, 3.14],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM nums',
values: [],
})) as Array<{ i: number; r: number }>;
expect(rows[0]!.i).toBe(42);
expect(rows[0]!.r).toBeCloseTo(3.14);
});
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
it('throws on invalid SQL', async () => {
await expect(
invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INVALID SQL STATEMENT',
values: [],
}),
).rejects.toThrow();
});
it('throws on constraint violation', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT UNIQUE)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO t (val) VALUES (?)',
values: ['unique_val'],
});
await expect(
invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO t (val) VALUES (?)',
values: ['unique_val'],
}),
).rejects.toThrow();
});
// ---------------------------------------------------------------------------
// Result contract
// ---------------------------------------------------------------------------
it('execute result always has rowsAffected and lastInsertId as numbers', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE t (id INTEGER PRIMARY KEY)',
values: [],
});
const result = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO t DEFAULT VALUES',
values: [],
})) as { rowsAffected: number; lastInsertId: number };
expect(typeof result.rowsAffected).toBe('number');
expect(typeof result.lastInsertId).toBe('number');
});
// ---------------------------------------------------------------------------
// Plugin config
// ---------------------------------------------------------------------------
it('get_config returns encryption status', async () => {
const config = (await invoke('plugin:libsql|get_config')) as { encrypted: boolean };
expect(typeof config.encrypted).toBe('boolean');
});
});
@@ -5,7 +5,7 @@ import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/databa
// Mock: NativeDatabaseService
// ---------------------------------------------------------------------------
vi.mock('tauri-plugin-libsql', () => {
vi.mock('tauri-plugin-turso', () => {
const rows = new Map<string, DatabaseRow[]>();
const mockDb = {
@@ -99,7 +99,7 @@ describe('NativeDatabaseService', () => {
beforeEach(async () => {
vi.clearAllMocks();
const mod = await import('tauri-plugin-libsql');
const mod = await import('tauri-plugin-turso');
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
@@ -139,7 +139,7 @@ describe('NativeDatabaseService', () => {
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 mod = await import('tauri-plugin-turso');
const mockDb = (mod as unknown as { __mockDb: { batch: ReturnType<typeof vi.fn> } }).__mockDb;
expect(mockDb.batch).toHaveBeenCalledWith([
'CREATE TABLE t (id INTEGER)',
@@ -149,7 +149,7 @@ describe('NativeDatabaseService', () => {
it('close() delegates to underlying db.close()', async () => {
await db.close();
const mod = await import('tauri-plugin-libsql');
const mod = await import('tauri-plugin-turso');
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
expect(mockDb.close).toHaveBeenCalled();
});
@@ -0,0 +1,175 @@
import { it, expect } from 'vitest';
import { DatabaseService } from '@/types/database';
/**
* Shared base database operation tests exercised against any DatabaseService.
* Covers CRUD, batch, data types, error handling, and result contract.
*
* Call inside a describe() block:
* describe('base ops', () => { baseTests(() => db); });
*/
export function baseTests(getDb: () => DatabaseService) {
// ---------------------------------------------------------------------------
// Schema & basic operations
// ---------------------------------------------------------------------------
it('creates a table and inserts a row', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
const result = 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 () => {
const db = getDb();
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 () => {
const db = getDb();
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 () => {
const db = getDb();
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 () => {
const db = getDb();
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('UPDATE returns rowsAffected', async () => {
const db = getDb();
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('DELETE returns rowsAffected', async () => {
const db = getDb();
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 () => {
const db = getDb();
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 () => {
const db = getDb();
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 () => {
const db = getDb();
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 () => {
const db = getDb();
await expect(db.execute('INVALID SQL STATEMENT')).rejects.toThrow();
});
it('throws on constraint violation', async () => {
const db = getDb();
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();
});
// ---------------------------------------------------------------------------
// Result contract
// ---------------------------------------------------------------------------
it('execute result always has rowsAffected and lastInsertId as numbers', async () => {
const db = getDb();
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');
});
}
@@ -1,14 +1,15 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, beforeEach, afterEach } from 'vitest';
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
import { DatabaseService, DatabaseExecResult } from '@/types/database';
import { DatabaseService } from '@/types/database';
import { baseTests } from './suites/base-tests';
import { ftsTests } from './suites/fts-tests';
import { vectorTests } from './suites/vector-tests';
/**
* Integration tests using a real in-memory SQLite database via @tursodatabase/database.
* These complement the mock-based tests in databaseService.test.ts by exercising
* These complement the mock-based tests in mock.test.ts by exercising
* actual SQL execution through the DatabaseService interface using the same
* libsql engine that powers the browser-based @tursodatabase/database-wasm.
* turso engine that powers the browser-based @tursodatabase/database-wasm.
*/
describe('NodeDatabaseService (real in-memory SQLite)', () => {
let db: DatabaseService;
@@ -21,171 +22,14 @@ describe('NodeDatabaseService (real in-memory SQLite)', () => {
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);
describe('Base Operations', () => {
baseTests(() => db);
});
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');
});
// -------------------------------------------------------------------------
// Full-text search (Turso native FTS, Tantivy-based)
// -------------------------------------------------------------------------
describe('Full-Text Search', () => {
ftsTests(() => db);
});
// -------------------------------------------------------------------------
// Vector search
// -------------------------------------------------------------------------
describe('Vector Search', () => {
vectorTests(() => db);
});
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { invoke } from '../tauri/tauri-invoke';
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
import { baseTests } from './suites/base-tests';
import { ftsTests } from './suites/fts-tests';
import { vectorTests } from './suites/vector-tests';
/**
* Thin DatabaseService adapter over raw Tauri IPC invoke() calls.
* Enables reuse of the shared test suites.
*/
class TauriDatabaseAdapter implements DatabaseService {
constructor(private dbPath: string) {}
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
const result = (await invoke('plugin:turso|execute', {
db: this.dbPath,
query: sql,
values: params,
})) as { rowsAffected: number; lastInsertId: number };
return {
rowsAffected: result.rowsAffected,
lastInsertId: result.lastInsertId,
};
}
async select<T extends DatabaseRow = DatabaseRow>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
return (await invoke('plugin:turso|select', {
db: this.dbPath,
query: sql,
values: params,
})) as T[];
}
async batch(statements: string[]): Promise<void> {
await invoke('plugin:turso|batch', {
db: this.dbPath,
queries: statements,
});
}
async close(): Promise<void> {
await invoke('plugin:turso|close', { db: this.dbPath });
}
}
/**
* Integration tests for the turso-backed tauri-plugin-turso running inside
* the Tauri WebView. Calls plugin IPC commands via __TAURI_INTERNALS__.invoke().
*
* The database is opened with experimental index_method enabled so that
* FTS (Tantivy-based) CREATE INDEX ... USING fts works.
*/
describe('turso plugin (native Tauri)', () => {
const DB_PATH = 'sqlite::memory:';
let dbPath: string;
let db: DatabaseService;
beforeEach(async () => {
dbPath = (await invoke('plugin:turso|load', {
options: { path: DB_PATH, experimental: ['index_method'] },
})) as string;
db = new TauriDatabaseAdapter(dbPath);
});
afterEach(async () => {
await db.close();
});
// ---------------------------------------------------------------------------
// Connection lifecycle
// ---------------------------------------------------------------------------
it('loads an in-memory database and returns a path', () => {
expect(typeof dbPath).toBe('string');
expect(dbPath.length).toBeGreaterThan(0);
});
// ---------------------------------------------------------------------------
// Plugin config
// ---------------------------------------------------------------------------
it('get_config returns encryption status', async () => {
const config = (await invoke('plugin:turso|get_config')) as { encrypted: boolean };
expect(typeof config.encrypted).toBe('boolean');
});
// ---------------------------------------------------------------------------
// Shared test suites
// ---------------------------------------------------------------------------
describe('Base Operations', () => {
baseTests(() => db);
});
describe('Full-Text Search', () => {
ftsTests(() => db);
});
describe('Vector Search', () => {
vectorTests(() => db);
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, beforeEach, afterEach } from 'vitest';
import { WebDatabaseService } from '@/services/database/webDatabaseService';
import { DatabaseService, DatabaseExecResult } from '@/types/database';
import { DatabaseService } from '@/types/database';
import { baseTests } from './suites/base-tests';
import { ftsTests } from './suites/fts-tests';
import { vectorTests } from './suites/vector-tests';
@@ -20,171 +21,14 @@ describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
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);
describe('Base Operations', () => {
baseTests(() => db);
});
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');
});
// -------------------------------------------------------------------------
// Full-text search (Turso native FTS, Tantivy-based)
// -------------------------------------------------------------------------
describe('Full-Text Search', () => {
ftsTests(() => db);
});
// -------------------------------------------------------------------------
// Vector search
// -------------------------------------------------------------------------
describe('Vector Search', () => {
vectorTests(() => db);
});
@@ -1,4 +1,4 @@
import { Database, QueryResult } from 'tauri-plugin-libsql';
import { Database, QueryResult } from 'tauri-plugin-turso';
import { DatabaseService, DatabaseExecResult, DatabaseRow, DatabaseOpts } from '@/types/database';
export class NativeDatabaseService implements DatabaseService {
@@ -18,7 +18,7 @@ interface TursoDatabase {
/**
* DatabaseService implementation backed by @tursodatabase/database (Node.js native).
* Uses the same libsql engine and API surface as the browser-based
* Uses the same turso engine and API surface as the browser-based
* @tursodatabase/database-wasm used by WebDatabaseService.
*/
export class NodeDatabaseService implements DatabaseService {
+1 -1
View File
@@ -25,7 +25,7 @@
"@/components/ui/*": ["./src/components/primitives/*"],
"@pdfjs/*": ["./public/vendor/pdfjs/*"],
"@simplecc/*": ["./public/vendor/simplecc/*"],
"tauri-plugin-libsql": ["./src-tauri/plugins/tauri-plugin-libsql/guest-js"]
"tauri-plugin-turso": ["./src-tauri/plugins/tauri-plugin-turso/guest-js"]
}
},
"include": [