forked from akai/readest
feat(database): add platform-agnostic schema migration system (#3485)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user