diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 491d73e3..793947ca 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -80,10 +80,27 @@ jobs: working-directory: apps/readest-app run: npx playwright install --with-deps chromium - - name: run browser tests + - name: run web tests if: matrix.config.platform == 'web' working-directory: apps/readest-app - run: pnpm test:browser + run: pnpm test:pr:web + + - name: install Rust toolchain + if: matrix.config.platform == 'tauri' + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - name: install system dependencies (tauri) + if: matrix.config.platform == 'tauri' + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev xvfb + + - name: run tauri tests + if: matrix.config.platform == 'tauri' + working-directory: apps/readest-app + run: xvfb-run pnpm test:pr:tauri - name: run lint working-directory: apps/readest-app diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 116d77dc..4fa73ccf 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -15,6 +15,9 @@ "test": "dotenv -e .env -e .env.test.local vitest", "test:browser": "vitest --config vitest.browser.config.mts --watch=false", "test:tauri": "vitest --config vitest.tauri.config.mts --watch=false", + "test:pr:web": "pnpm test -- --watch=false && pnpm test:browser", + "test:pr:tauri": "pnpm test -- --watch=false && bash scripts/test-tauri.sh", + "test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh", "test:e2e": "wdio run wdio.conf.ts", "tauri:dev:test": "dotenv -e .env.tauri -- tauri dev --features webdriver", "tauri:build:test": "dotenv -e .env.tauri -- tauri build --debug --features webdriver", diff --git a/apps/readest-app/scripts/test-tauri.sh b/apps/readest-app/scripts/test-tauri.sh new file mode 100755 index 00000000..d680014d --- /dev/null +++ b/apps/readest-app/scripts/test-tauri.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# Starts a Next.js dev server, launches the Tauri app with webdriver +# (no file watcher, no built-in dev server), waits for the WebDriver +# server on port 4445, runs tests, then tears down everything cleanly. +# +set -euo pipefail + +DEV_PORT=3000 +WEBDRIVER_PORT=4445 +POLL_INTERVAL=3 +TIMEOUT=300 + +cleanup() { + if [[ -n "${TAURI_PID:-}" ]]; then + pkill -P "$TAURI_PID" 2>/dev/null || true + kill "$TAURI_PID" 2>/dev/null || true + wait "$TAURI_PID" 2>/dev/null || true + fi + if [[ -n "${DEV_PID:-}" ]]; then + pkill -P "$DEV_PID" 2>/dev/null || true + kill "$DEV_PID" 2>/dev/null || true + wait "$DEV_PID" 2>/dev/null || true + fi + lsof -ti :"$WEBDRIVER_PORT" 2>/dev/null | xargs kill 2>/dev/null || true + lsof -ti :"$DEV_PORT" 2>/dev/null | xargs kill 2>/dev/null || true +} + +trap cleanup EXIT INT TERM + +echo "Starting Next.js dev server..." +dotenv -e .env.tauri -- next dev & +DEV_PID=$! + +echo "Waiting for dev server on port $DEV_PORT..." +elapsed=0 +while ! curl -sf "http://127.0.0.1:${DEV_PORT}" >/dev/null 2>&1; do + if ! kill -0 "$DEV_PID" 2>/dev/null; then + echo "ERROR: Dev server exited unexpectedly." + exit 1 + fi + if (( elapsed >= TIMEOUT )); then + echo "ERROR: Timed out waiting for dev server on port $DEV_PORT." + exit 1 + fi + sleep "$POLL_INTERVAL" + (( elapsed += POLL_INTERVAL )) +done + +echo "Starting Tauri app with webdriver (no-watch, skip beforeDevCommand)..." +dotenv -e .env.tauri -- tauri dev --features webdriver --no-watch \ + --config '{"build":{"beforeDevCommand":""}}' & +TAURI_PID=$! + +echo "Waiting for WebDriver server on port $WEBDRIVER_PORT (timeout ${TIMEOUT}s)..." +elapsed=0 +while ! curl -sf "http://127.0.0.1:${WEBDRIVER_PORT}/status" >/dev/null 2>&1; do + if ! kill -0 "$TAURI_PID" 2>/dev/null; then + echo "ERROR: Tauri app exited before WebDriver became ready." + exit 1 + fi + if (( elapsed >= TIMEOUT )); then + echo "ERROR: Timed out waiting for WebDriver on port $WEBDRIVER_PORT." + exit 1 + fi + sleep "$POLL_INTERVAL" + (( elapsed += POLL_INTERVAL )) +done + +echo "WebDriver is ready. Running Tauri tests..." +pnpm test:tauri diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 81f8ce76..86a168ae 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -153,6 +153,8 @@ pub fn run() { .plugin( tauri_plugin_log::Builder::new() .level(log::LevelFilter::Info) + .level_for("tracing", log::LevelFilter::Warn) + .level_for("tantivy", log::LevelFilter::Warn) .build(), ) .plugin(tauri_plugin_websocket::init()) diff --git a/apps/readest-app/src/__tests__/database/migrate.test.ts b/apps/readest-app/src/__tests__/database/migrate.test.ts new file mode 100644 index 00000000..5405b79e --- /dev/null +++ b/apps/readest-app/src/__tests__/database/migrate.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { migrate, MigrationEntry } from '@/services/database/migrate'; +import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database'; + +/** + * In-memory DatabaseService for testing the migration runner. + * Tracks tables, rows, and PRAGMA user_version. + */ +function createMockDb(): DatabaseService & { + userVersion: number; + tables: Map; +} { + const tables = new Map(); + let userVersion = 0; + + const db: DatabaseService & { userVersion: number; tables: Map } = { + userVersion: 0, + tables, + + async execute(sql: string, params: unknown[] = []): Promise { + const trimmed = sql.trim(); + + if (/^CREATE TABLE/i.test(trimmed)) { + const tableName = trimmed.match(/CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)/i)?.[1]; + if (tableName && !tables.has(tableName)) { + tables.set(tableName, []); + } + return { rowsAffected: 0, lastInsertId: 0 }; + } + + if (/^INSERT INTO/i.test(trimmed)) { + const tableName = trimmed.match(/INTO\s+(\w+)/i)?.[1] ?? '_default'; + const existing = tables.get(tableName) ?? []; + const id = existing.length + 1; + + // Parse VALUES for the migration tracking table + const valuesMatch = trimmed.match(/VALUES\s*\(([^)]+)\)/i); + if (valuesMatch) { + const rawVal = + params.length > 0 ? params[0] : valuesMatch[1]!.trim().replace(/^'|'$/g, ''); + existing.push({ id, name: rawVal }); + } else { + existing.push({ id }); + } + tables.set(tableName, existing); + return { rowsAffected: 1, lastInsertId: id }; + } + + return { rowsAffected: 0, lastInsertId: 0 }; + }, + + async select(sql: string): Promise { + const trimmed = sql.trim(); + + if (/^PRAGMA user_version/i.test(trimmed)) { + return [{ user_version: userVersion } as unknown as T]; + } + + const tableName = trimmed.match(/FROM\s+(\w+)/i)?.[1] ?? '_default'; + return (tables.get(tableName) ?? []) as T[]; + }, + + async batch(statements: string[]): Promise { + for (const stmt of statements) { + const trimmed = stmt.trim(); + if (/^PRAGMA user_version\s*=/i.test(trimmed)) { + const val = parseInt(trimmed.match(/=\s*(\d+)/)?.[1] ?? '0', 10); + userVersion = val; + db.userVersion = val; + } else { + await db.execute(trimmed); + } + } + }, + + async close(): Promise { + tables.clear(); + }, + }; + + return db; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('migrate()', () => { + let db: ReturnType; + + const migrations: MigrationEntry[] = [ + { + name: '2026030601_create_books', + sql: ` + CREATE TABLE IF NOT EXISTS books ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL + ); + `, + }, + { + name: '2026030602_create_annotations', + sql: ` + CREATE TABLE IF NOT EXISTS annotations ( + id INTEGER PRIMARY KEY, + book_id INTEGER NOT NULL, + text TEXT + ); + `, + }, + ]; + + beforeEach(() => { + db = createMockDb(); + }); + + it('applies all migrations on a fresh database', async () => { + await migrate(db, migrations); + + expect(db.tables.has('books')).toBe(true); + expect(db.tables.has('annotations')).toBe(true); + expect(db.tables.has('__migrations')).toBe(true); + expect(db.userVersion).toBe(2); + }); + + it('records migration names in the tracking table', async () => { + await migrate(db, migrations); + + const tracked = db.tables.get('__migrations') ?? []; + const names = tracked.map((r) => r['name']); + expect(names).toContain('2026030601_create_books'); + expect(names).toContain('2026030602_create_annotations'); + }); + + it('skips already-applied migrations (idempotent)', async () => { + await migrate(db, migrations); + const firstRunTracked = (db.tables.get('__migrations') ?? []).length; + + // Run again — should be a no-op via PRAGMA user_version fast-path + await migrate(db, migrations); + const secondRunTracked = (db.tables.get('__migrations') ?? []).length; + + expect(secondRunTracked).toBe(firstRunTracked); + expect(db.userVersion).toBe(2); + }); + + it('applies only new migrations when schema grows', async () => { + // Apply first migration only + await migrate(db, [migrations[0]!]); + expect(db.userVersion).toBe(1); + expect(db.tables.has('books')).toBe(true); + expect(db.tables.has('annotations')).toBe(false); + + // Now apply both — only second should run + await migrate(db, migrations); + expect(db.userVersion).toBe(2); + expect(db.tables.has('annotations')).toBe(true); + + const tracked = db.tables.get('__migrations') ?? []; + expect(tracked).toHaveLength(2); + }); + + it('does nothing when migrations array is empty', async () => { + await migrate(db, []); + expect(db.tables.size).toBe(0); + expect(db.userVersion).toBe(0); + }); + + it('uses custom tracking table name', async () => { + await migrate(db, migrations, { table: '__custom_migrations' }); + + expect(db.tables.has('__custom_migrations')).toBe(true); + expect(db.tables.has('__migrations')).toBe(false); + }); + + it('handles migration names with single quotes', async () => { + const trickyMigrations: MigrationEntry[] = [ + { + name: "2026030601_it's_tricky", + sql: 'CREATE TABLE IF NOT EXISTS tricky (id INTEGER PRIMARY KEY);', + }, + ]; + + await migrate(db, trickyMigrations); + expect(db.tables.has('tricky')).toBe(true); + expect(db.userVersion).toBe(1); + }); +}); + +describe('getMigrations()', () => { + it('returns an array for defined schema types', async () => { + const { getMigrations } = await import('@/services/database/migrations'); + const result = getMigrations('nonexistent_schema'); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); +}); diff --git a/apps/readest-app/src/__tests__/database/suites/migration-tests.ts b/apps/readest-app/src/__tests__/database/suites/migration-tests.ts new file mode 100644 index 00000000..41f15fbe --- /dev/null +++ b/apps/readest-app/src/__tests__/database/suites/migration-tests.ts @@ -0,0 +1,265 @@ +import { it, expect } from 'vitest'; +import { DatabaseService } from '@/types/database'; +import { migrate, MigrationEntry } from '@/services/database/migrate'; + +/** + * Shared migration tests exercised against any real DatabaseService. + * Verifies the migration runner works correctly with actual SQLite + * across all three turso backends (native, node, wasm). + * + * Call inside a describe() block: + * describe('Migrations', () => { migrationTests(() => db); }); + */ +export function migrationTests(getDb: () => DatabaseService) { + const migrationV1: MigrationEntry[] = [ + { + name: '2026030601_create_books', + sql: ` + CREATE TABLE books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + author TEXT + ); + `, + }, + ]; + + const migrationV2: MigrationEntry[] = [ + ...migrationV1, + { + name: '2026030602_create_annotations', + sql: ` + CREATE TABLE annotations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id INTEGER NOT NULL REFERENCES books(id), + cfi TEXT NOT NULL, + text TEXT + ); + `, + }, + ]; + + const migrationV3: MigrationEntry[] = [ + ...migrationV2, + { + name: '2026031501_add_bookmarks', + sql: ` + CREATE TABLE bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id INTEGER NOT NULL REFERENCES books(id), + cfi TEXT NOT NULL, + label TEXT + ); + CREATE INDEX idx_bookmarks_book ON bookmarks(book_id); + `, + }, + ]; + + // --------------------------------------------------------------------------- + // Basic migration + // --------------------------------------------------------------------------- + + it('applies migrations and creates tables', async () => { + const db = getDb(); + await migrate(db, migrationV1); + + // Table should exist and be usable + const result = await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', [ + 'Test Book', + 'Author', + ]); + expect(result.rowsAffected).toBe(1); + expect(result.lastInsertId).toBe(1); + + const rows = await db.select<{ id: number; title: string; author: string }>( + 'SELECT * FROM books', + ); + expect(rows).toHaveLength(1); + expect(rows[0]!.title).toBe('Test Book'); + }); + + it('creates the __migrations tracking table', async () => { + const db = getDb(); + await migrate(db, migrationV1); + + const tracked = await db.select<{ id: number; name: string; applied_at: string }>( + 'SELECT * FROM __migrations ORDER BY id', + ); + expect(tracked).toHaveLength(1); + expect(tracked[0]!.name).toBe('2026030601_create_books'); + expect(tracked[0]!.applied_at).toBeTruthy(); + }); + + it('sets PRAGMA user_version to migration count', async () => { + const db = getDb(); + await migrate(db, migrationV2); + + const rows = await db.select<{ user_version: number }>('PRAGMA user_version'); + expect(rows[0]!.user_version).toBe(2); + }); + + // --------------------------------------------------------------------------- + // Idempotency + // --------------------------------------------------------------------------- + + it('is idempotent — running twice has no effect', async () => { + const db = getDb(); + await migrate(db, migrationV2); + + // Insert data + await db.execute('INSERT INTO books (title) VALUES (?)', ['Book 1']); + + // Run again — should not duplicate tables or data + await migrate(db, migrationV2); + + const books = await db.select<{ title: string }>('SELECT * FROM books'); + expect(books).toHaveLength(1); + + const tracked = await db.select('SELECT * FROM __migrations'); + expect(tracked).toHaveLength(2); + + const rows = await db.select<{ user_version: number }>('PRAGMA user_version'); + expect(rows[0]!.user_version).toBe(2); + }); + + // --------------------------------------------------------------------------- + // Incremental migration + // --------------------------------------------------------------------------- + + it('applies only new migrations incrementally', async () => { + const db = getDb(); + + // First: apply V1 + await migrate(db, migrationV1); + let version = await db.select<{ user_version: number }>('PRAGMA user_version'); + expect(version[0]!.user_version).toBe(1); + + // Second: apply V2 (adds annotations) + await migrate(db, migrationV2); + version = await db.select<{ user_version: number }>('PRAGMA user_version'); + expect(version[0]!.user_version).toBe(2); + + // Verify annotations table works + await db.execute('INSERT INTO books (title) VALUES (?)', ['B1']); + await db.execute('INSERT INTO annotations (book_id, cfi, text) VALUES (?, ?, ?)', [ + 1, + '/2/4', + 'A note', + ]); + const annotations = await db.select<{ text: string }>('SELECT text FROM annotations'); + expect(annotations).toHaveLength(1); + expect(annotations[0]!.text).toBe('A note'); + + // Third: apply V3 (adds bookmarks + index) + await migrate(db, migrationV3); + version = await db.select<{ user_version: number }>('PRAGMA user_version'); + expect(version[0]!.user_version).toBe(3); + + // Verify bookmarks table works + await db.execute('INSERT INTO bookmarks (book_id, cfi, label) VALUES (?, ?, ?)', [ + 1, + '/2/8', + 'Chapter 3', + ]); + const bookmarks = await db.select<{ label: string }>('SELECT label FROM bookmarks'); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0]!.label).toBe('Chapter 3'); + }); + + // --------------------------------------------------------------------------- + // Multi-statement migration + // --------------------------------------------------------------------------- + + it('handles migrations with multiple SQL statements', async () => { + const db = getDb(); + await migrate(db, migrationV3); + + // V3 migration creates both a table and an index + // Verify the index exists via sqlite_master + const indexes = await db.select<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_bookmarks_book'", + ); + expect(indexes).toHaveLength(1); + expect(indexes[0]!.name).toBe('idx_bookmarks_book'); + }); + + // --------------------------------------------------------------------------- + // Empty migrations + // --------------------------------------------------------------------------- + + it('does nothing for empty migration list', async () => { + const db = getDb(); + await migrate(db, []); + + // No tracking table should be created + const tables = await db.select<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__migrations'", + ); + expect(tables).toHaveLength(0); + }); + + // --------------------------------------------------------------------------- + // Custom tracking table + // --------------------------------------------------------------------------- + + it('supports custom tracking table name', async () => { + const db = getDb(); + await migrate(db, migrationV1, { table: '__schema_history' }); + + const tracked = await db.select<{ name: string }>('SELECT name FROM __schema_history'); + expect(tracked).toHaveLength(1); + expect(tracked[0]!.name).toBe('2026030601_create_books'); + + // Default table should not exist + const defaultTable = await db.select( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__migrations'", + ); + expect(defaultTable).toHaveLength(0); + }); + + // --------------------------------------------------------------------------- + // Fast-path verification + // --------------------------------------------------------------------------- + + it('fast-path skips when user_version is current', async () => { + const db = getDb(); + await migrate(db, migrationV2); + + // Manually verify user_version is set + const before = await db.select<{ user_version: number }>('PRAGMA user_version'); + expect(before[0]!.user_version).toBe(2); + + // Running same migrations again should hit the fast-path + // and not touch the tracking table + await migrate(db, migrationV2); + + const after = await db.select<{ user_version: number }>('PRAGMA user_version'); + expect(after[0]!.user_version).toBe(2); + + const tracked = await db.select('SELECT * FROM __migrations'); + expect(tracked).toHaveLength(2); + }); + + // --------------------------------------------------------------------------- + // Data survives migration + // --------------------------------------------------------------------------- + + it('existing data survives new migrations', async () => { + const db = getDb(); + + // Apply V1 and insert data + await migrate(db, migrationV1); + await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', ['Book A', 'Author A']); + await db.execute('INSERT INTO books (title, author) VALUES (?, ?)', ['Book B', 'Author B']); + + // Apply V2 — books data should survive + await migrate(db, migrationV2); + + const books = await db.select<{ title: string; author: string }>( + 'SELECT title, author FROM books ORDER BY id', + ); + expect(books).toHaveLength(2); + expect(books[0]!.title).toBe('Book A'); + expect(books[1]!.title).toBe('Book B'); + }); +} diff --git a/apps/readest-app/src/__tests__/database/turso-node.test.ts b/apps/readest-app/src/__tests__/database/turso-node.test.ts index 4dfe1514..2a06792b 100644 --- a/apps/readest-app/src/__tests__/database/turso-node.test.ts +++ b/apps/readest-app/src/__tests__/database/turso-node.test.ts @@ -4,6 +4,7 @@ import { DatabaseService } from '@/types/database'; import { baseTests } from './suites/base-tests'; import { ftsTests } from './suites/fts-tests'; import { vectorTests } from './suites/vector-tests'; +import { migrationTests } from './suites/migration-tests'; /** * Integration tests using a real in-memory SQLite database via @tursodatabase/database. @@ -33,4 +34,8 @@ describe('NodeDatabaseService (real in-memory SQLite)', () => { describe('Vector Search', () => { vectorTests(() => db); }); + + describe('Migrations', () => { + migrationTests(() => db); + }); }); diff --git a/apps/readest-app/src/__tests__/database/turso-rust.tauri.test.ts b/apps/readest-app/src/__tests__/database/turso-rust.tauri.test.ts index b0b729c3..01d47fbb 100644 --- a/apps/readest-app/src/__tests__/database/turso-rust.tauri.test.ts +++ b/apps/readest-app/src/__tests__/database/turso-rust.tauri.test.ts @@ -4,6 +4,7 @@ import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/databa import { baseTests } from './suites/base-tests'; import { ftsTests } from './suites/fts-tests'; import { vectorTests } from './suites/vector-tests'; +import { migrationTests } from './suites/migration-tests'; /** * Thin DatabaseService adapter over raw Tauri IPC invoke() calls. @@ -103,4 +104,8 @@ describe('turso plugin (native Tauri)', () => { describe('Vector Search', () => { vectorTests(() => db); }); + + describe('Migrations', () => { + migrationTests(() => db); + }); }); diff --git a/apps/readest-app/src/__tests__/database/turso-wasm.browser.test.ts b/apps/readest-app/src/__tests__/database/turso-wasm.browser.test.ts index 092d2b5f..b3ede734 100644 --- a/apps/readest-app/src/__tests__/database/turso-wasm.browser.test.ts +++ b/apps/readest-app/src/__tests__/database/turso-wasm.browser.test.ts @@ -4,6 +4,7 @@ import { DatabaseService } from '@/types/database'; import { baseTests } from './suites/base-tests'; import { ftsTests } from './suites/fts-tests'; import { vectorTests } from './suites/vector-tests'; +import { migrationTests } from './suites/migration-tests'; /** * Browser-based integration tests for WebDatabaseService using @tursodatabase/database-wasm. @@ -32,4 +33,8 @@ describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => { describe('Vector Search', () => { vectorTests(() => db); }); + + describe('Migrations', () => { + migrationTests(() => db); + }); }); diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index e85376bf..cc031e49 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -11,6 +11,7 @@ import { } from '@/types/system'; import { FileSystem, BaseDir, DeleteAction } from '@/types/system'; import { DatabaseOpts, DatabaseService } from '@/types/database'; +import { SchemaType } from '@/services/database/migrate'; import { Book, BookConfig, @@ -133,7 +134,12 @@ export abstract class BaseAppService implements AppService { mimeType?: string, ): Promise; abstract ask(message: string): Promise; - abstract openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise; + abstract openDatabase( + schema: SchemaType, + path: string, + base: BaseDir, + opts?: DatabaseOpts, + ): Promise; protected async runMigrations(lastMigrationVersion: number): Promise { if (lastMigrationVersion < 20251124) { diff --git a/apps/readest-app/src/services/database/migrate.ts b/apps/readest-app/src/services/database/migrate.ts new file mode 100644 index 00000000..7af21ba7 --- /dev/null +++ b/apps/readest-app/src/services/database/migrate.ts @@ -0,0 +1,77 @@ +import { DatabaseService } from '@/types/database'; + +export interface MigrationEntry { + /** Migration name, e.g. "2026030601_initial_schema" */ + name: string; + /** SQL statements separated by semicolons */ + sql: string; +} + +/** + * Discriminator for databases with different schemas. + * Add new schema types here as needed. + */ +export type SchemaType = string; + +export interface MigrateOptions { + /** Name of the tracking table. @default '__migrations' */ + table?: string; +} + +/** + * Run pending migrations against a DatabaseService instance. + * + * Uses PRAGMA user_version as an O(1) fast-path to skip migration checks + * when the database is already at the latest version. This makes it cheap + * to call on every openDatabase(), even with hundreds of database files. + * + * Each migration runs atomically via batch() — the schema changes and + * the tracking record are committed together. + */ +export async function migrate( + db: DatabaseService, + migrations: MigrationEntry[], + options: MigrateOptions = {}, +): Promise { + const targetVersion = migrations.length; + if (targetVersion === 0) return; + + // Fast path: PRAGMA user_version is stored in the file header. + // Reading it requires no table scan — essentially free. + const rows = await db.select<{ user_version: number }>('PRAGMA user_version'); + const currentVersion = rows[0]?.user_version ?? 0; + if (currentVersion >= targetVersion) return; + + const table = options.table ?? '__migrations'; + + // Ensure tracking table exists (only reached when migrations are needed) + await db.execute( + `CREATE TABLE IF NOT EXISTS ${table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + ); + + // Get already-applied migration names + const applied = await db.select<{ name: string }>(`SELECT name FROM ${table}`); + const appliedSet = new Set(applied.map((r) => r.name)); + + // Apply pending migrations in order + for (let i = 0; i < migrations.length; i++) { + const migration = migrations[i]!; + if (appliedSet.has(migration.name)) continue; + + const statements = migration.sql + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + // Record the migration and bump user_version atomically + const safeName = migration.name.replace(/'/g, "''"); + statements.push(`INSERT INTO ${table} (name) VALUES ('${safeName}')`); + statements.push(`PRAGMA user_version = ${i + 1}`); + + await db.batch(statements); + } +} diff --git a/apps/readest-app/src/services/database/migrations/index.ts b/apps/readest-app/src/services/database/migrations/index.ts new file mode 100644 index 00000000..9aeab335 --- /dev/null +++ b/apps/readest-app/src/services/database/migrations/index.ts @@ -0,0 +1,36 @@ +import { MigrationEntry, SchemaType } from '../migrate'; + +/** + * Migration definitions for each schema type. + * + * To add a new migration: + * 1. Append a new entry to the appropriate schema array below. + * 2. Use a date-based name: YYYYMMDDNN (NN = sequence within the day). + * 3. Never reorder or remove existing entries. + * + * To add a new schema type: + * 1. Add the type to SchemaType in migrate.ts. + * 2. Add a new key here with its migration array. + */ +const migrations: Record = { + // Example schema — replace with real migrations when ready: + // + // library: [ + // { + // name: '2026030601_initial_schema', + // sql: ` + // CREATE TABLE IF NOT EXISTS books ( + // id INTEGER PRIMARY KEY AUTOINCREMENT, + // title TEXT NOT NULL, + // author TEXT, + // path TEXT NOT NULL UNIQUE, + // created_at DATETIME DEFAULT CURRENT_TIMESTAMP + // ); + // `, + // }, + // ], +}; + +export function getMigrations(schema: SchemaType): MigrationEntry[] { + return migrations[schema] ?? []; +} diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index e04db7c2..7a55a1c1 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -43,6 +43,7 @@ import { copyFiles } from '@/utils/files'; import { BaseAppService } from './appService'; import { DatabaseOpts, DatabaseService } from '@/types/database'; +import { SchemaType } from '@/services/database/migrate'; import { DATA_SUBDIR, LOCAL_BOOKS_SUBDIR, @@ -565,10 +566,19 @@ export class NativeAppService extends BaseAppService { return await ask(message); } - async openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise { + async openDatabase( + schema: SchemaType, + path: string, + base: BaseDir, + opts?: DatabaseOpts, + ): Promise { const fullPath = await this.resolveFilePath(path, base); const { NativeDatabaseService } = await import('./database/nativeDatabaseService'); - return NativeDatabaseService.open(`sqlite:${fullPath}`, opts); + const db = await NativeDatabaseService.open(`sqlite:${fullPath}`, opts); + const { migrate } = await import('./database/migrate'); + const { getMigrations } = await import('./database/migrations'); + await migrate(db, getMigrations(schema)); + return db; } async migrate20251029() { diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index ca02c993..5d453b3a 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -1,5 +1,6 @@ import { FileSystem, BaseDir, AppPlatform, ResolvedPath, FileItem } from '@/types/system'; import { DatabaseOpts, DatabaseService } from '@/types/database'; +import { SchemaType } from '@/services/database/migrate'; import { getOSPlatform, isValidURL } from '@/utils/misc'; import { RemoteFile } from '@/utils/file'; import { isPWA } from './environment'; @@ -349,9 +350,18 @@ export class WebAppService extends BaseAppService { return window.confirm(message); } - async openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise { + async openDatabase( + schema: SchemaType, + path: string, + base: BaseDir, + opts?: DatabaseOpts, + ): Promise { const fullPath = await this.resolveFilePath(path, base); const { WebDatabaseService } = await import('./database/webDatabaseService'); - return WebDatabaseService.open(fullPath, opts); + const db = await WebDatabaseService.open(fullPath, opts); + const { migrate } = await import('./database/migrate'); + const { getMigrations } = await import('./database/migrations'); + await migrate(db, getMigrations(schema)); + return db; } } diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 7062a502..48514767 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -5,6 +5,7 @@ import { ProgressHandler } from '@/utils/transfer'; import { CustomFont, CustomFontInfo } from '@/styles/fonts'; import { CustomTextureInfo } from '@/styles/textures'; import { DatabaseOpts, DatabaseService } from './database'; +import { SchemaType } from '@/services/database/migrate'; export type AppPlatform = 'web' | 'tauri'; export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown'; @@ -159,5 +160,10 @@ export interface AppService { generateCoverImageUrl(book: Book): Promise; updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise; ask(message: string): Promise; - openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise; + openDatabase( + schema: SchemaType, + path: string, + base: BaseDir, + opts?: DatabaseOpts, + ): Promise; }