chore(database): unit testing and feature detect for fts and vector search (#3476)

This commit is contained in:
Huang Xin
2026-03-05 14:41:16 +08:00
committed by GitHub
parent 02cc0a9ebb
commit 1e9bd1d821
15 changed files with 628 additions and 21 deletions
+1
View File
@@ -99,6 +99,7 @@
"@tauri-apps/plugin-shell": "~2.3.5",
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-websocket": "~2.4.2",
"@tursodatabase/database-common": "^0.5.0",
"@tursodatabase/database-wasm": "^0.5.0",
"@tybys/wasm-util": "^0.10.1",
"@zip.js/zip.js": "^2.8.16",
@@ -0,0 +1,329 @@
import { it, expect } from 'vitest';
import { DatabaseService } from '@/types/database';
/**
* Shared full-text search test cases for Turso's native FTS (Tantivy-based).
* Call this inside a describe() block after setting up a DatabaseService instance
* opened with { experimental: ['index_method'] }.
*
* API reference (turso v0.5):
* - CREATE INDEX ... USING fts (columns) [WITH (tokenizer=..., weights=...)]
* - fts_match(col1, col2, ..., query) — boolean filter
* - fts_score(col1, col2, ..., query) — BM25 relevance score
* - fts_highlight(col, open, close, query) — wrap matched terms
* - OPTIMIZE INDEX idx_name
*
* Reference: https://turso.tech/blog/beyond-fts5
*/
export function ftsTests(getDb: () => DatabaseService) {
let ftsProbed = false;
let ftsSupported = false;
/**
* Wrapper around it() that probes FTS support on first run (lazily,
* after beforeEach has created the db) and skips when unavailable.
*/
function ftsIt(name: string, fn: () => Promise<void>) {
it(name, async ({ skip }) => {
if (!ftsProbed) {
ftsProbed = true;
const db = getDb();
try {
await db.execute('CREATE TABLE _fts_probe (id INTEGER PRIMARY KEY, t TEXT)');
await db.execute('CREATE INDEX _fts_probe_idx ON _fts_probe USING fts (t)');
await db.execute('DROP INDEX _fts_probe_idx');
await db.execute('DROP TABLE _fts_probe');
ftsSupported = true;
} catch {
try {
await db.execute('DROP TABLE IF EXISTS _fts_probe');
} catch {
/* ignore cleanup errors */
}
}
}
if (!ftsSupported) {
skip();
return;
}
await fn();
});
}
async function seedArticles(db: DatabaseService) {
await db.execute('CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, body TEXT)');
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
'Introduction to SQLite',
'SQLite is a lightweight relational database engine widely used in embedded systems.',
]);
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
'Understanding WAL Mode',
'Write-Ahead Logging improves concurrency in SQLite database operations.',
]);
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
'Full-Text Search Basics',
'Full-text search allows efficient querying of large text datasets.',
]);
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
'Rust and WebAssembly',
'Rust compiles to WebAssembly for high-performance browser applications.',
]);
await db.execute('CREATE INDEX idx_articles_fts ON articles USING fts (title, body)');
}
// ---------------------------------------------------------------------------
// FTS index creation
// ---------------------------------------------------------------------------
ftsIt('creates an FTS index on a table', async () => {
const db = getDb();
await db.execute('CREATE TABLE docs (id INTEGER PRIMARY KEY, content TEXT)');
await db.execute('CREATE INDEX idx_docs_fts ON docs USING fts (content)');
await db.execute('INSERT INTO docs (content) VALUES (?)', ['hello world']);
const rows = await db.select<{ id: number; content: string }>(
"SELECT * FROM docs WHERE fts_match(content, 'hello')",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.content).toBe('hello world');
});
// ---------------------------------------------------------------------------
// fts_match() — full-text filtering
// ---------------------------------------------------------------------------
ftsIt('fts_match() returns relevant rows', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ id: number; title: string }>(
"SELECT id, title FROM articles WHERE fts_match(title, body, 'SQLite')",
);
expect(rows.length).toBeGreaterThanOrEqual(1);
const titles = rows.map((r) => r.title);
expect(titles).toContain('Introduction to SQLite');
});
ftsIt('fts_match() returns empty result for non-matching query', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ id: number }>(
"SELECT id FROM articles WHERE fts_match(title, body, 'blockchain')",
);
expect(rows).toHaveLength(0);
});
ftsIt('fts_match() works with parameterized queries', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ id: number; title: string }>(
'SELECT id, title FROM articles WHERE fts_match(title, body, ?)',
['WebAssembly'],
);
expect(rows).toHaveLength(1);
expect(rows[0]!.title).toBe('Rust and WebAssembly');
});
ftsIt('fts_match() on single indexed column', async () => {
const db = getDb();
await db.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT)');
await db.execute('INSERT INTO tags (label) VALUES (?)', ['typescript']);
await db.execute('INSERT INTO tags (label) VALUES (?)', ['javascript']);
await db.execute('INSERT INTO tags (label) VALUES (?)', ['python']);
await db.execute('CREATE INDEX idx_tags_fts ON tags USING fts (label)');
const rows = await db.select<{ id: number; label: string }>(
"SELECT id, label FROM tags WHERE fts_match(label, 'typescript')",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.label).toBe('typescript');
});
// ---------------------------------------------------------------------------
// fts_score() — BM25 ranking
// ---------------------------------------------------------------------------
ftsIt('fts_score() returns relevance scores ordered by rank', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ score: number; title: string }>(
"SELECT fts_score(title, body, 'database') AS score, title FROM articles WHERE fts_match(title, body, 'database') ORDER BY score DESC",
);
expect(rows.length).toBeGreaterThanOrEqual(1);
for (const row of rows) {
expect(row.score).toBeGreaterThan(0);
}
for (let i = 1; i < rows.length; i++) {
expect(rows[i - 1]!.score).toBeGreaterThanOrEqual(rows[i]!.score);
}
});
ftsIt('fts_score() with multi-term query', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ score: number; title: string }>(
"SELECT fts_score(title, body, 'SQLite database') AS score, title FROM articles WHERE fts_match(title, body, 'SQLite database') ORDER BY score DESC",
);
expect(rows.length).toBeGreaterThanOrEqual(1);
for (const row of rows) {
expect(row.score).toBeGreaterThan(0);
}
});
// ---------------------------------------------------------------------------
// fts_highlight() — result highlighting
// ---------------------------------------------------------------------------
ftsIt('fts_highlight() wraps matched terms with markers', async () => {
const db = getDb();
await seedArticles(db);
const rows = await db.select<{ highlighted: string }>(
"SELECT fts_highlight(title, '<b>', '</b>', 'SQLite') AS highlighted FROM articles WHERE fts_match(title, body, 'SQLite') LIMIT 1",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.highlighted).toContain('<b>');
expect(rows[0]!.highlighted).toContain('</b>');
});
// ---------------------------------------------------------------------------
// Column weights
// ---------------------------------------------------------------------------
ftsIt('FTS index with column weights affects ranking', async () => {
const db = getDb();
await db.execute('CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT)');
// "database" appears only in title of post 1, only in body of post 2
await db.execute('INSERT INTO posts (title, body) VALUES (?, ?)', [
'Database Internals',
'This book covers storage engines and distributed systems.',
]);
await db.execute('INSERT INTO posts (title, body) VALUES (?, ?)', [
'Systems Programming',
'Learn about database drivers, networking, and concurrency.',
]);
await db.execute(
"CREATE INDEX idx_posts_fts ON posts USING fts (title, body) WITH (weights = 'title=5.0,body=1.0')",
);
const rows = await db.select<{ score: number; title: string }>(
"SELECT fts_score(title, body, 'database') AS score, title FROM posts WHERE fts_match(title, body, 'database') ORDER BY score DESC",
);
expect(rows.length).toBe(2);
// Post with "database" in the heavily-weighted title should rank higher
expect(rows[0]!.title).toBe('Database Internals');
});
// ---------------------------------------------------------------------------
// Tokenizers
// ---------------------------------------------------------------------------
ftsIt('ngram tokenizer enables substring matching', async () => {
const db = getDb();
await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT)');
await db.execute('INSERT INTO products (name) VALUES (?)', ['JavaScript']);
await db.execute('INSERT INTO products (name) VALUES (?)', ['TypeScript']);
await db.execute('INSERT INTO products (name) VALUES (?)', ['Python']);
await db.execute(
"CREATE INDEX idx_products_fts ON products USING fts (name) WITH (tokenizer = 'ngram')",
);
const rows = await db.select<{ name: string }>(
"SELECT name FROM products WHERE fts_match(name, 'Script') ORDER BY name",
);
expect(rows.length).toBe(2);
const names = rows.map((r) => r.name);
expect(names).toContain('JavaScript');
expect(names).toContain('TypeScript');
});
ftsIt('raw tokenizer performs exact matching', async () => {
const db = getDb();
await db.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, tag TEXT)');
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v1.0.0']);
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v1.0.1']);
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v2.0.0']);
await db.execute("CREATE INDEX idx_tags_raw ON tags USING fts (tag) WITH (tokenizer = 'raw')");
const rows = await db.select<{ tag: string }>(
"SELECT tag FROM tags WHERE fts_match(tag, 'v1.0.0')",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.tag).toBe('v1.0.0');
});
// ---------------------------------------------------------------------------
// FTS with data mutations
// ---------------------------------------------------------------------------
ftsIt('FTS index reflects newly inserted rows', async () => {
const db = getDb();
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
await db.execute('INSERT INTO notes (text) VALUES (?)', ['first note about testing']);
let rows = await db.select<{ id: number }>(
"SELECT id FROM notes WHERE fts_match(text, 'testing')",
);
expect(rows).toHaveLength(1);
await db.execute('INSERT INTO notes (text) VALUES (?)', [
'second note about testing strategies',
]);
rows = await db.select<{ id: number }>("SELECT id FROM notes WHERE fts_match(text, 'testing')");
expect(rows).toHaveLength(2);
});
ftsIt('FTS index reflects deleted rows', async () => {
const db = getDb();
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
await db.execute('INSERT INTO notes (text) VALUES (?)', ['important meeting notes']);
await db.execute('INSERT INTO notes (text) VALUES (?)', ['grocery list items']);
await db.execute('DELETE FROM notes WHERE id = 2');
const rows = await db.select<{ text: string }>(
"SELECT text FROM notes WHERE fts_match(text, 'meeting')",
);
expect(rows).toHaveLength(1);
expect(rows[0]!.text).toContain('meeting');
const all = await db.select('SELECT * FROM notes');
expect(all).toHaveLength(1);
});
ftsIt('FTS index reflects updated rows', async () => {
const db = getDb();
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
await db.execute('INSERT INTO notes (text) VALUES (?)', ['old content about cats']);
await db.execute('UPDATE notes SET text = ? WHERE id = ?', ['new content about dogs', 1]);
const catRows = await db.select<{ id: number }>(
"SELECT id FROM notes WHERE fts_match(text, 'cats')",
);
expect(catRows).toHaveLength(0);
const dogRows = await db.select<{ id: number }>(
"SELECT id FROM notes WHERE fts_match(text, 'dogs')",
);
expect(dogRows).toHaveLength(1);
});
// ---------------------------------------------------------------------------
// OPTIMIZE INDEX
// ---------------------------------------------------------------------------
ftsIt('OPTIMIZE INDEX runs without error', async () => {
const db = getDb();
await seedArticles(db);
await db.execute('OPTIMIZE INDEX idx_articles_fts');
});
}
@@ -0,0 +1,236 @@
import { it, expect } from 'vitest';
import { DatabaseService } from '@/types/database';
/**
* Shared vector search test cases for Turso's built-in vector functions.
* Call this inside a describe() block after setting up a DatabaseService instance.
*
* Vector support is available in both the native (Node.js) and WASM backends.
*
* API reference:
* - vector32 / vector / vector64 / vector8 / vector1bit — constructors
* - vector_distance_cos(a, b) — cosine distance
* - vector_distance_l2(a, b) — Euclidean distance
* - vector_distance_dot(a, b) — negative dot product
* - vector_extract(blob) — BLOB → JSON text
* - vector_concat(a, b) — merge vectors
* - vector_slice(blob, s, e) — extract sub-vector
*
* Reference: https://docs.turso.tech/sql-reference/functions/vector
*/
export function vectorTests(getDb: () => DatabaseService) {
// ---------------------------------------------------------------------------
// Vector creation & storage
// ---------------------------------------------------------------------------
it('stores and retrieves vector32 embeddings', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, embedding BLOB)');
await db.execute('INSERT INTO items (embedding) VALUES (vector32(?))', [
'[1.0, 2.0, 3.0, 4.0]',
]);
const rows = await db.select<{ id: number; json: string }>(
'SELECT id, vector_extract(embedding) AS json FROM items',
);
expect(rows).toHaveLength(1);
const parsed: number[] = JSON.parse(rows[0]!.json);
expect(parsed).toHaveLength(4);
expect(parsed[0]).toBeCloseTo(1.0);
expect(parsed[3]).toBeCloseTo(4.0);
});
it('vector() is an alias for vector32()', async () => {
const db = getDb();
const rows = await db.select<{ eq: number }>(
"SELECT vector_extract(vector('[1,2,3]')) = vector_extract(vector32('[1,2,3]')) AS eq",
);
expect(rows[0]!.eq).toBe(1);
});
it('stores and retrieves vector64 embeddings', async () => {
const db = getDb();
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, embedding BLOB)');
await db.execute('INSERT INTO items (embedding) VALUES (vector64(?))', ['[1.0, 2.0, 3.0]']);
const rows = await db.select<{ json: string }>(
'SELECT vector_extract(embedding) AS json FROM items',
);
const parsed: number[] = JSON.parse(rows[0]!.json);
expect(parsed).toHaveLength(3);
expect(parsed[0]).toBeCloseTo(1.0);
});
// ---------------------------------------------------------------------------
// Distance functions
// ---------------------------------------------------------------------------
it('vector_distance_cos() returns 0 for identical vectors', async () => {
const db = getDb();
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_cos(vector32('[1,2,3]'), vector32('[1,2,3]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(0, 5);
});
it('vector_distance_cos() returns higher distance for dissimilar vectors', async () => {
const db = getDb();
const rows = await db.select<{ d_similar: number; d_different: number }>(
`SELECT
vector_distance_cos(vector32('[1,0,0]'), vector32('[0.9,0.1,0]')) AS d_similar,
vector_distance_cos(vector32('[1,0,0]'), vector32('[0,0,1]')) AS d_different`,
);
expect(rows[0]!.d_similar).toBeLessThan(rows[0]!.d_different);
});
it('vector_distance_l2() returns 0 for identical vectors', async () => {
const db = getDb();
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_l2(vector32('[3,4]'), vector32('[3,4]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(0, 5);
});
it('vector_distance_l2() computes correct Euclidean distance', async () => {
const db = getDb();
// distance between [0,0] and [3,4] should be 5
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_l2(vector32('[0,0]'), vector32('[3,4]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(5.0, 4);
});
it('vector_distance_dot() returns more negative for similar vectors', async () => {
const db = getDb();
const rows = await db.select<{ d_similar: number; d_different: number }>(
`SELECT
vector_distance_dot(vector32('[1,1,1]'), vector32('[1,1,1]')) AS d_similar,
vector_distance_dot(vector32('[1,1,1]'), vector32('[-1,-1,-1]')) AS d_different`,
);
// dot returns negative dot product; more negative = more similar
expect(rows[0]!.d_similar).toBeLessThan(rows[0]!.d_different);
});
// ---------------------------------------------------------------------------
// Similarity search pattern
// ---------------------------------------------------------------------------
it('finds nearest neighbors by cosine distance', async () => {
const db = getDb();
await db.execute('CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT, embedding BLOB)');
await db.execute("INSERT INTO docs (title, embedding) VALUES (?, vector32('[1,0,0,0]'))", [
'Doc A',
]);
await db.execute("INSERT INTO docs (title, embedding) VALUES (?, vector32('[0,1,0,0]'))", [
'Doc B',
]);
await db.execute(
"INSERT INTO docs (title, embedding) VALUES (?, vector32('[0.95,0.05,0,0]'))",
['Doc C'],
);
// Query vector is close to [1,0,0,0]
const rows = await db.select<{ title: string; distance: number }>(
`SELECT title,
vector_distance_cos(embedding, vector32('[1,0,0,0]')) AS distance
FROM docs ORDER BY distance ASC LIMIT 2`,
);
expect(rows).toHaveLength(2);
// Doc A (exact match) and Doc C (very close) should be the nearest
expect(rows[0]!.title).toBe('Doc A');
expect(rows[1]!.title).toBe('Doc C');
expect(rows[0]!.distance).toBeCloseTo(0, 4);
expect(rows[0]!.distance).toBeLessThan(rows[1]!.distance);
});
it('finds nearest neighbors by L2 distance', async () => {
const db = getDb();
await db.execute('CREATE TABLE points (id INTEGER PRIMARY KEY, pos BLOB)');
await db.execute("INSERT INTO points (pos) VALUES (vector32('[0,0]'))");
await db.execute("INSERT INTO points (pos) VALUES (vector32('[3,4]'))");
await db.execute("INSERT INTO points (pos) VALUES (vector32('[1,1]'))");
const rows = await db.select<{ id: number; dist: number }>(
`SELECT id, vector_distance_l2(pos, vector32('[0,0]')) AS dist
FROM points ORDER BY dist ASC`,
);
expect(rows).toHaveLength(3);
// Origin first, then [1,1] (dist ~1.41), then [3,4] (dist 5)
expect(rows[0]!.dist).toBeCloseTo(0, 4);
expect(rows[1]!.dist).toBeCloseTo(Math.sqrt(2), 4);
expect(rows[2]!.dist).toBeCloseTo(5, 4);
});
// ---------------------------------------------------------------------------
// vector_extract()
// ---------------------------------------------------------------------------
it('vector_extract() converts BLOB back to JSON', async () => {
const db = getDb();
const rows = await db.select<{ json: string }>(
"SELECT vector_extract(vector32('[10.5, 20.5, 30.5]')) AS json",
);
const parsed: number[] = JSON.parse(rows[0]!.json);
expect(parsed).toHaveLength(3);
expect(parsed[0]).toBeCloseTo(10.5);
expect(parsed[2]).toBeCloseTo(30.5);
});
// ---------------------------------------------------------------------------
// vector_slice()
// ---------------------------------------------------------------------------
it('vector_slice() extracts a sub-vector', async () => {
const db = getDb();
const rows = await db.select<{ json: string }>(
"SELECT vector_extract(vector_slice(vector32('[10,20,30,40,50]'), 1, 4)) AS json",
);
const parsed: number[] = JSON.parse(rows[0]!.json);
// zero-based: elements at index 1,2,3
expect(parsed).toHaveLength(3);
expect(parsed[0]).toBeCloseTo(20);
expect(parsed[1]).toBeCloseTo(30);
expect(parsed[2]).toBeCloseTo(40);
});
// ---------------------------------------------------------------------------
// vector_concat()
// ---------------------------------------------------------------------------
it('vector_concat() merges two vectors', async () => {
const db = getDb();
const rows = await db.select<{ json: string }>(
"SELECT vector_extract(vector_concat(vector32('[1,2]'), vector32('[3,4]'))) AS json",
);
const parsed: number[] = JSON.parse(rows[0]!.json);
expect(parsed).toHaveLength(4);
expect(parsed).toEqual(
expect.arrayContaining([
expect.closeTo(1),
expect.closeTo(2),
expect.closeTo(3),
expect.closeTo(4),
]),
);
});
// ---------------------------------------------------------------------------
// Mixed precision
// ---------------------------------------------------------------------------
it('vector_distance_cos() works with vector64', async () => {
const db = getDb();
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_cos(vector64('[1,0,0]'), vector64('[1,0,0]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(0, 5);
});
it('vector_distance_l2() works with vector64', async () => {
const db = getDb();
const rows = await db.select<{ d: number }>(
"SELECT vector_distance_l2(vector64('[0,0]'), vector64('[3,4]')) AS d",
);
expect(rows[0]!.d).toBeCloseTo(5.0, 4);
});
}
@@ -1,6 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
import { DatabaseService, DatabaseExecResult } from '@/types/database';
import { ftsTests } from './suites/fts-tests';
import { vectorTests } from './suites/vector-tests';
/**
* Integration tests using a real in-memory SQLite database via @tursodatabase/database.
@@ -12,7 +14,7 @@ describe('NodeDatabaseService (real in-memory SQLite)', () => {
let db: DatabaseService;
beforeEach(async () => {
db = await NodeDatabaseService.open(':memory:');
db = await NodeDatabaseService.open(':memory:', { experimental: ['index_method'] });
});
afterEach(async () => {
@@ -171,4 +173,20 @@ describe('NodeDatabaseService (real in-memory SQLite)', () => {
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,6 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { WebDatabaseService } from '@/services/database/webDatabaseService';
import { DatabaseService, DatabaseExecResult } from '@/types/database';
import { ftsTests } from './suites/fts-tests';
import { vectorTests } from './suites/vector-tests';
/**
* Browser-based integration tests for WebDatabaseService using @tursodatabase/database-wasm.
@@ -11,7 +13,7 @@ describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
let db: DatabaseService;
beforeEach(async () => {
db = await WebDatabaseService.open(':memory:');
db = await WebDatabaseService.open(':memory:', { experimental: ['index_method'] });
});
afterEach(async () => {
@@ -170,4 +172,20 @@ describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
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);
});
});
+2 -2
View File
@@ -10,7 +10,7 @@ import {
SelectDirectoryMode,
} from '@/types/system';
import { FileSystem, BaseDir, DeleteAction } from '@/types/system';
import { DatabaseService } from '@/types/database';
import { DatabaseOpts, DatabaseService } from '@/types/database';
import {
Book,
BookConfig,
@@ -133,7 +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>;
abstract openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService>;
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
if (lastMigrationVersion < 20251124) {
@@ -1,5 +1,5 @@
import { Database, QueryResult } from 'tauri-plugin-libsql';
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
import { DatabaseService, DatabaseExecResult, DatabaseRow, DatabaseOpts } from '@/types/database';
export class NativeDatabaseService implements DatabaseService {
private db: Database;
@@ -8,7 +8,7 @@ export class NativeDatabaseService implements DatabaseService {
this.db = db;
}
static async open(path: string): Promise<NativeDatabaseService> {
static async open(path: string, _opts?: DatabaseOpts): Promise<NativeDatabaseService> {
const db = await Database.load(path);
return new NativeDatabaseService(db);
}
@@ -1,4 +1,4 @@
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
import { DatabaseService, DatabaseExecResult, DatabaseRow, DatabaseOpts } from '@/types/database';
interface TursoRunResult {
changes: number;
@@ -28,9 +28,9 @@ export class NodeDatabaseService implements DatabaseService {
this.db = db;
}
static async open(path: string): Promise<NodeDatabaseService> {
static async open(path: string, opts?: DatabaseOpts): Promise<NodeDatabaseService> {
const mod = await import('@tursodatabase/database');
const db = (await mod.connect(path)) as unknown as TursoDatabase;
const db = (await mod.connect(path, opts)) as unknown as TursoDatabase;
return new NodeDatabaseService(db);
}
@@ -1,4 +1,4 @@
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
import { DatabaseService, DatabaseExecResult, DatabaseRow, DatabaseOpts } from '@/types/database';
interface WasmRunResult {
changes: number;
@@ -23,9 +23,9 @@ export class WebDatabaseService implements DatabaseService {
this.db = db;
}
static async open(path: string): Promise<WebDatabaseService> {
static async open(path: string, opts?: DatabaseOpts): Promise<WebDatabaseService> {
const mod = await import('@tursodatabase/database-wasm');
const db = (await mod.connect(path)) as unknown as WasmDatabase;
const db = (await mod.connect(path, opts)) as unknown as WasmDatabase;
return new WebDatabaseService(db);
}
@@ -42,7 +42,7 @@ import { copyURIToPath, getStorefrontRegionCode } from '@/utils/bridge';
import { copyFiles } from '@/utils/files';
import { BaseAppService } from './appService';
import { DatabaseService } from '@/types/database';
import { DatabaseOpts, DatabaseService } from '@/types/database';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
@@ -565,10 +565,10 @@ export class NativeAppService extends BaseAppService {
return await ask(message);
}
async openDatabase(path: string, base: BaseDir): Promise<DatabaseService> {
async openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService> {
const fullPath = await this.resolveFilePath(path, base);
const { NativeDatabaseService } = await import('./database/nativeDatabaseService');
return NativeDatabaseService.open(`sqlite:${fullPath}`);
return NativeDatabaseService.open(`sqlite:${fullPath}`, opts);
}
async migrate20251029() {
@@ -1,5 +1,5 @@
import { FileSystem, BaseDir, AppPlatform, ResolvedPath, FileItem } from '@/types/system';
import { DatabaseService } from '@/types/database';
import { DatabaseOpts, DatabaseService } from '@/types/database';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { RemoteFile } from '@/utils/file';
import { isPWA } from './environment';
@@ -349,9 +349,9 @@ export class WebAppService extends BaseAppService {
return window.confirm(message);
}
async openDatabase(path: string, base: BaseDir): Promise<DatabaseService> {
async openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService> {
const fullPath = await this.resolveFilePath(path, base);
const { WebDatabaseService } = await import('./database/webDatabaseService');
return WebDatabaseService.open(fullPath);
return WebDatabaseService.open(fullPath, opts);
}
}
+2
View File
@@ -1,3 +1,5 @@
export type { DatabaseOpts } from '@tursodatabase/database-common';
export interface DatabaseExecResult {
rowsAffected: number;
lastInsertId: number;
+2 -2
View File
@@ -4,7 +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';
import { DatabaseOpts, DatabaseService } from './database';
export type AppPlatform = 'web' | 'tauri';
export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
@@ -159,5 +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>;
openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService>;
}
+3
View File
@@ -191,6 +191,9 @@ importers:
'@tauri-apps/plugin-websocket':
specifier: ~2.4.2
version: 2.4.2
'@tursodatabase/database-common':
specifier: ^0.5.0
version: 0.5.0
'@tursodatabase/database-wasm':
specifier: ^0.5.0
version: 0.5.0