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
@@ -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);
});
});