feat(database): add platform-agnostic schema migration system (#3485)

This commit is contained in:
Huang Xin
2026-03-06 20:07:26 +08:00
committed by GitHub
parent 97cab2d70b
commit 54bc1514df
15 changed files with 723 additions and 8 deletions
+19 -2
View File
@@ -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
+3
View File
@@ -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",
+71
View File
@@ -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
+2
View File
@@ -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);
});
});
+7 -1
View File
@@ -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() {
+12 -2
View File
@@ -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;
}
}
+7 -1
View File
@@ -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>;
}