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
+10
View File
@@ -75,6 +75,16 @@ jobs:
run: |
pnpm test -- --watch=false
- name: install playwright browsers
if: matrix.config.platform == 'web'
working-directory: apps/readest-app
run: npx playwright install --with-deps chromium
- name: run browser tests
if: matrix.config.platform == 'web'
working-directory: apps/readest-app
run: pnpm test:browser
- name: run lint
working-directory: apps/readest-app
run: |
+3
View File
@@ -10,3 +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
+1
View File
@@ -25,6 +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
# Environment & Editor
.env
Generated
+1543 -551
View File
File diff suppressed because it is too large Load Diff
+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'],
},
});
+275 -96
View File
@@ -83,9 +83,18 @@ importers:
'@choochmeque/tauri-plugin-sharekit-api':
specifier: ^0.3.0
version: 0.3.0
'@emnapi/core':
specifier: ^1.7.1
version: 1.8.1
'@emnapi/runtime':
specifier: ^1.7.1
version: 1.8.1
'@fabianlars/tauri-plugin-oauth':
specifier: '2'
version: 2.0.0
'@napi-rs/wasm-runtime':
specifier: ^1.1.1
version: 1.1.1
'@opennextjs/cloudflare':
specifier: ^1.15.1
version: 1.15.1(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.60.0)
@@ -141,14 +150,14 @@ importers:
specifier: ^2.76.1
version: 2.91.1
'@tauri-apps/api':
specifier: 2.9.1
version: 2.9.1
specifier: 2.10.1
version: 2.10.1
'@tauri-apps/plugin-cli':
specifier: ^2.4.1
version: 2.4.1
'@tauri-apps/plugin-deep-link':
specifier: ^2.4.6
version: 2.4.6
specifier: ^2.4.7
version: 2.4.7
'@tauri-apps/plugin-dialog':
specifier: ^2.6.0
version: 2.6.0
@@ -159,8 +168,8 @@ importers:
specifier: ^2.3.2
version: 2.3.2
'@tauri-apps/plugin-http':
specifier: ^2.5.6
version: 2.5.6
specifier: ^2.5.7
version: 2.5.7
'@tauri-apps/plugin-log':
specifier: ^2.8.0
version: 2.8.0
@@ -174,14 +183,20 @@ importers:
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-shell':
specifier: ~2.3.4
version: 2.3.4
specifier: ~2.3.5
version: 2.3.5
'@tauri-apps/plugin-updater':
specifier: ^2.9.0
version: 2.9.0
specifier: ^2.10.0
version: 2.10.0
'@tauri-apps/plugin-websocket':
specifier: ~2.4.2
version: 2.4.2
'@tursodatabase/database-wasm':
specifier: ^0.4.4
version: 0.4.4
'@tybys/wasm-util':
specifier: ^0.10.1
version: 0.10.1
'@zip.js/zip.js':
specifier: ^2.8.16
version: 2.8.16
@@ -361,14 +376,17 @@ importers:
specifier: ^0.5.16
version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.2))
'@tauri-apps/cli':
specifier: 2.9.6
version: 2.9.6
specifier: 2.10.1
version: 2.10.1
'@testing-library/dom':
specifier: ^10.4.0
version: 10.4.1
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tursodatabase/database':
specifier: ^0.4.4
version: 0.4.4
'@types/cors':
specifier: ^2.8.17
version: 2.8.19
@@ -417,6 +435,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^5.1.1
version: 5.1.2(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))
'@vitest/browser-playwright':
specifier: ^4.0.18
version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
autoprefixer:
specifier: ^10.4.20
version: 10.4.23(postcss@8.5.6)
@@ -453,6 +474,9 @@ importers:
node-env-run:
specifier: ^4.0.2
version: 4.0.2
playwright:
specifier: ^1.58.2
version: 1.58.2
postcss:
specifier: ^8.4.49
version: 8.5.6
@@ -479,7 +503,7 @@ importers:
version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(yaml@2.8.2)
wrangler:
specifier: ^4.60.0
version: 4.60.0
@@ -2091,6 +2115,9 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
'@next/bundle-analyzer@15.5.9':
resolution: {integrity: sha512-lT1EBpFyGVN9u8M43f2jE78DsCu0A5KPA5OkF5PdIHrKDo4oTJ4lUQKciA9T2u9gccSXIPQcZb5TYkHF4f8iiw==}
@@ -3537,90 +3564,90 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tauri-apps/api@2.9.1':
resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
'@tauri-apps/cli-darwin-arm64@2.9.6':
resolution: {integrity: sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==}
'@tauri-apps/cli-darwin-arm64@2.10.1':
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.9.6':
resolution: {integrity: sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==}
'@tauri-apps/cli-darwin-x64@2.10.1':
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.9.6':
resolution: {integrity: sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==}
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.9.6':
resolution: {integrity: sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==}
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==}
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==}
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==}
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.9.6':
resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==}
'@tauri-apps/cli-linux-x64-musl@2.10.1':
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==}
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.9.6':
resolution: {integrity: sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==}
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.9.6':
resolution: {integrity: sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==}
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.9.6':
resolution: {integrity: sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==}
'@tauri-apps/cli@2.10.1':
resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==}
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-cli@2.4.1':
resolution: {integrity: sha512-8JXofQFI5cmiGolh1PlU4hzE2YJgrgB1lyaztyBYiiMCy13luVxBXaXChYPeqMkUo46J1UadxvYdjRjj0E8zaw==}
'@tauri-apps/plugin-deep-link@2.4.6':
resolution: {integrity: sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA==}
'@tauri-apps/plugin-deep-link@2.4.7':
resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==}
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
@@ -3631,8 +3658,8 @@ packages:
'@tauri-apps/plugin-haptics@2.3.2':
resolution: {integrity: sha512-TqvBR5JoPodDQWPXsNymmyqp37dOpunZOdVVbx5xOAxki7rVwiCMvoEPdtJQNIrU+dQDfkQYZ2oDBVOu4hf+ig==}
'@tauri-apps/plugin-http@2.5.6':
resolution: {integrity: sha512-KhCK3TDNDF4vdz75/j+KNQipYKf+295Visa8r32QcXScg0+D3JwShcCM6D+FN8WuDF24X3KSiAB8QtRxW6jKRA==}
'@tauri-apps/plugin-http@2.5.7':
resolution: {integrity: sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==}
'@tauri-apps/plugin-log@2.8.0':
resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==}
@@ -3646,11 +3673,11 @@ packages:
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-shell@2.3.4':
resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==}
'@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@tauri-apps/plugin-updater@2.9.0':
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
'@tauri-apps/plugin-updater@2.10.0':
resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==}
'@tauri-apps/plugin-websocket@2.4.2':
resolution: {integrity: sha512-B1/csKPwJH9kLduNoqWFUdCsTgCvLM84T5ZZYK35jeBc9m2cm2m2G/hbSr28ZpVOZrl0x24fCWFe/9rpp7WETw==}
@@ -3677,6 +3704,40 @@ packages:
'@tsconfig/node18@1.0.3':
resolution: {integrity: sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ==}
'@tursodatabase/database-common@0.4.4':
resolution: {integrity: sha512-iuSYWgBQEETjwfWOmi3E4Q++jQTb+VOPXeaKozQqW8fXtgU6ZaYUMmWbqQVMGSS9sAWZlZi9qyPVz0rkT8zoqQ==}
'@tursodatabase/database-darwin-arm64@0.4.4':
resolution: {integrity: sha512-nQMYrJY1X0sNgNWdbuPdbNCL9S/Ei9KLMwx3hFdyUDYJWU0h7o+kZv50QoNXB7vb7LmEBMeVWZaVEQbHd/Ionw==}
cpu: [arm64]
os: [darwin]
'@tursodatabase/database-linux-arm64-gnu@0.4.4':
resolution: {integrity: sha512-6vnnKBgAgfKhspr+nScPevOSrrnZwNJKuUbiHMSagOqNDZCzOAGDcfeC83sKd2QvZquR17l6V3tFxcFN5FnByQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tursodatabase/database-linux-x64-gnu@0.4.4':
resolution: {integrity: sha512-N63wK6ow6wPmD1/BjBhbrR8/Ifo6+A6zavpzgsT5YABVC4wGZBvJ1TvSz935R9n5E8eqQNY8jKiQPJLwpgOjdw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tursodatabase/database-wasm-common@0.4.4':
resolution: {integrity: sha512-iLy4piISaNNIbE9viRDNZqRx/Z16JjTUh8Sa3D3icirSyHvXWlBggH+EJUjuCo+dtZgDYYGhmOcTGCIH4Q3h/g==}
'@tursodatabase/database-wasm@0.4.4':
resolution: {integrity: sha512-U8tukY8BXjsnyF9vQz1p7tweJbnBzG66OJZ/BRyx3jpqa2j1Df/pewxok7SWWIGobnyQTMukvZ1q6GguS3Zskw==}
'@tursodatabase/database-win32-x64-msvc@0.4.4':
resolution: {integrity: sha512-TYEl5FaUAImgZew05FvxvcFWLb1AQ+Zeyw/ApEbBDqS7mQmbz8VGs4iq04ECzJG2esfBR+zrLdDEG3X1BnaDTg==}
cpu: [x64]
os: [win32]
'@tursodatabase/database@0.4.4':
resolution: {integrity: sha512-QkQ0qLp0AY8dC9BOrIMyeyUXoFCvhlRaH+wcg013OvzYufSmDnnzg44U0tQkmnXYfUZEIp+IpADO/UeGNL0M2Q==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -4086,6 +4147,17 @@ packages:
peerDependencies:
vite: '>=7.3.1'
'@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
peerDependencies:
playwright: '*'
vitest: 4.0.18
'@vitest/browser@4.0.18':
resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
peerDependencies:
vitest: 4.0.18
'@vitest/expect@4.0.18':
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
@@ -6858,6 +6930,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pixelmatch@7.1.0:
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
hasBin: true
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@@ -6870,11 +6946,25 @@ packages:
engines: {node: '>=18'}
hasBin: true
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.1:
resolution: {integrity: sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -7542,6 +7632,10 @@ packages:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
slash@5.1.0:
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
engines: {node: '>=14.16'}
@@ -10064,7 +10158,7 @@ snapshots:
'@choochmeque/tauri-plugin-sharekit-api@0.3.0':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@cloudflare/kv-asset-handler@0.4.2': {}
@@ -10135,17 +10229,14 @@ snapshots:
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.8.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.1.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.25.4':
optional: true
@@ -10426,7 +10517,7 @@ snapshots:
'@fabianlars/tauri-plugin-oauth@2.0.0':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@floating-ui/core@1.7.3':
dependencies:
@@ -10656,6 +10747,12 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.8.1
'@emnapi/runtime': 1.8.1
'@tybys/wasm-util': 0.10.1
'@next/bundle-analyzer@15.5.9':
dependencies:
webpack-bundle-analyzer: 4.10.1
@@ -12335,106 +12432,106 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.19(yaml@2.8.2)
'@tauri-apps/api@2.9.1': {}
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/cli-darwin-arm64@2.9.6':
'@tauri-apps/cli-darwin-arm64@2.10.1':
optional: true
'@tauri-apps/cli-darwin-x64@2.9.6':
'@tauri-apps/cli-darwin-x64@2.10.1':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.9.6':
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.9.6':
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.9.6':
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.9.6':
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.9.6':
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.9.6':
'@tauri-apps/cli-linux-x64-musl@2.10.1':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.9.6':
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.9.6':
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.9.6':
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
optional: true
'@tauri-apps/cli@2.9.6':
'@tauri-apps/cli@2.10.1':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.9.6
'@tauri-apps/cli-darwin-x64': 2.9.6
'@tauri-apps/cli-linux-arm-gnueabihf': 2.9.6
'@tauri-apps/cli-linux-arm64-gnu': 2.9.6
'@tauri-apps/cli-linux-arm64-musl': 2.9.6
'@tauri-apps/cli-linux-riscv64-gnu': 2.9.6
'@tauri-apps/cli-linux-x64-gnu': 2.9.6
'@tauri-apps/cli-linux-x64-musl': 2.9.6
'@tauri-apps/cli-win32-arm64-msvc': 2.9.6
'@tauri-apps/cli-win32-ia32-msvc': 2.9.6
'@tauri-apps/cli-win32-x64-msvc': 2.9.6
'@tauri-apps/cli-darwin-arm64': 2.10.1
'@tauri-apps/cli-darwin-x64': 2.10.1
'@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1
'@tauri-apps/cli-linux-arm64-gnu': 2.10.1
'@tauri-apps/cli-linux-arm64-musl': 2.10.1
'@tauri-apps/cli-linux-riscv64-gnu': 2.10.1
'@tauri-apps/cli-linux-x64-gnu': 2.10.1
'@tauri-apps/cli-linux-x64-musl': 2.10.1
'@tauri-apps/cli-win32-arm64-msvc': 2.10.1
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/plugin-cli@2.4.1':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-deep-link@2.4.6':
'@tauri-apps/plugin-deep-link@2.4.7':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-fs@2.4.5':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-haptics@2.3.2':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-http@2.5.6':
'@tauri-apps/plugin-http@2.5.7':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-log@2.8.0':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-opener@2.5.3':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-os@2.3.2':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-shell@2.3.4':
'@tauri-apps/plugin-shell@2.3.5':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-updater@2.9.0':
'@tauri-apps/plugin-updater@2.10.0':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-websocket@2.4.2':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
'@testing-library/dom@10.4.1':
dependencies:
@@ -12459,10 +12556,41 @@ snapshots:
'@tsconfig/node18@1.0.3': {}
'@tursodatabase/database-common@0.4.4': {}
'@tursodatabase/database-darwin-arm64@0.4.4':
optional: true
'@tursodatabase/database-linux-arm64-gnu@0.4.4':
optional: true
'@tursodatabase/database-linux-x64-gnu@0.4.4':
optional: true
'@tursodatabase/database-wasm-common@0.4.4':
dependencies:
'@napi-rs/wasm-runtime': 1.1.1
'@tursodatabase/database-wasm@0.4.4':
dependencies:
'@tursodatabase/database-common': 0.4.4
'@tursodatabase/database-wasm-common': 0.4.4
'@tursodatabase/database-win32-x64-msvc@0.4.4':
optional: true
'@tursodatabase/database@0.4.4':
dependencies:
'@tursodatabase/database-common': 0.4.4
optionalDependencies:
'@tursodatabase/database-darwin-arm64': 0.4.4
'@tursodatabase/database-linux-arm64-gnu': 0.4.4
'@tursodatabase/database-linux-x64-gnu': 0.4.4
'@tursodatabase/database-win32-x64-msvc': 0.4.4
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
optional: true
'@types/aria-query@5.0.4': {}
@@ -12889,6 +13017,36 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))
playwright: 1.58.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(yaml@2.8.2)
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)':
dependencies:
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))
'@vitest/utils': 4.0.18
magic-string: 0.30.21
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(yaml@2.8.2)
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/expect@4.0.18':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -16278,6 +16436,10 @@ snapshots:
pirates@4.0.7: {}
pixelmatch@7.1.0:
dependencies:
pngjs: 7.0.0
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -16289,6 +16451,8 @@ snapshots:
playwright-core@1.58.1:
optional: true
playwright-core@1.58.2: {}
playwright@1.58.1:
dependencies:
playwright-core: 1.58.1
@@ -16296,6 +16460,14 @@ snapshots:
fsevents: 2.3.2
optional: true
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
pngjs@7.0.0: {}
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@@ -17078,6 +17250,12 @@ snapshots:
mrmime: 2.0.1
totalist: 3.0.1
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
slash@5.1.0: {}
slice-ansi@7.1.2:
@@ -17361,7 +17539,7 @@ snapshots:
tauri-plugin-device-info-api@1.0.1:
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/api': 2.10.1
teex@1.0.1:
dependencies:
@@ -17803,7 +17981,7 @@ snapshots:
terser: 5.46.0
yaml: 2.8.2
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(yaml@2.8.2):
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))
@@ -17828,6 +18006,7 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 22.19.7
'@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)
jsdom: 26.1.0
transitivePeerDependencies:
- jiti