forked from akai/readest
feat(database): add platform-agnostic schema migration system (#3485)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+71
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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<string, DatabaseRow[]>;
|
||||
} {
|
||||
const tables = new Map<string, DatabaseRow[]>();
|
||||
let userVersion = 0;
|
||||
|
||||
const db: DatabaseService & { userVersion: number; tables: Map<string, DatabaseRow[]> } = {
|
||||
userVersion: 0,
|
||||
tables,
|
||||
|
||||
async execute(sql: string, params: unknown[] = []): Promise<DatabaseExecResult> {
|
||||
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<T extends DatabaseRow = DatabaseRow>(sql: string): Promise<T[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
tables.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migrate()', () => {
|
||||
let db: ReturnType<typeof createMockDb>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean>;
|
||||
abstract ask(message: string): Promise<boolean>;
|
||||
abstract openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService>;
|
||||
abstract openDatabase(
|
||||
schema: SchemaType,
|
||||
path: string,
|
||||
base: BaseDir,
|
||||
opts?: DatabaseOpts,
|
||||
): Promise<DatabaseService>;
|
||||
|
||||
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
|
||||
if (lastMigrationVersion < 20251124) {
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<SchemaType, MigrationEntry[]> = {
|
||||
// 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] ?? [];
|
||||
}
|
||||
@@ -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<DatabaseService> {
|
||||
async openDatabase(
|
||||
schema: SchemaType,
|
||||
path: string,
|
||||
base: BaseDir,
|
||||
opts?: DatabaseOpts,
|
||||
): Promise<DatabaseService> {
|
||||
const fullPath = await this.resolveFilePath(path, base);
|
||||
const { NativeDatabaseService } = await import('./database/nativeDatabaseService');
|
||||
return NativeDatabaseService.open(`sqlite:${fullPath}`, 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() {
|
||||
|
||||
@@ -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<DatabaseService> {
|
||||
async openDatabase(
|
||||
schema: SchemaType,
|
||||
path: string,
|
||||
base: BaseDir,
|
||||
opts?: DatabaseOpts,
|
||||
): Promise<DatabaseService> {
|
||||
const fullPath = await this.resolveFilePath(path, base);
|
||||
const { WebDatabaseService } = await import('./database/webDatabaseService');
|
||||
return WebDatabaseService.open(fullPath, 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise<void>;
|
||||
ask(message: string): Promise<boolean>;
|
||||
openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService>;
|
||||
openDatabase(
|
||||
schema: SchemaType,
|
||||
path: string,
|
||||
base: BaseDir,
|
||||
opts?: DatabaseOpts,
|
||||
): Promise<DatabaseService>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user