Merge branch 'migrate-dexie-to-sqlite'

This commit is contained in:
2025-06-20 11:49:51 -06:00
27 changed files with 4782 additions and 965 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,150 @@
/**
* Manage database migrations as people upgrade their app over time
*/
import { logger } from "../utils/logger";
/**
* Migration interface for database schema migrations
*/
interface Migration {
name: string;
sql: string;
}
export class MigrationService {
private static instance: MigrationService;
/**
* Migration registry to store and manage database migrations
*/
class MigrationRegistry {
private migrations: Migration[] = [];
private constructor() {}
static getInstance(): MigrationService {
if (!MigrationService.instance) {
MigrationService.instance = new MigrationService();
}
return MigrationService.instance;
}
registerMigration(migration: Migration) {
/**
* Register a migration with the registry
*
* @param migration - The migration to register
*/
registerMigration(migration: Migration): void {
this.migrations.push(migration);
logger.info(`[MigrationService] Registered migration: ${migration.name}`);
}
/**
* @param sqlExec - A function that executes a SQL statement and returns some update result
* @param sqlQuery - A function that executes a SQL query and returns the result in some format
* @param extractMigrationNames - A function that extracts the names (string array) from a "select name from migrations" query
* Get all registered migrations
*
* @returns Array of registered migrations
*/
async runMigrations<T>(
// note that this does not take parameters because the Capacitor SQLite 'execute' is different
sqlExec: (sql: string) => Promise<unknown>,
sqlQuery: (sql: string) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
// Create migrations table if it doesn't exist
await sqlExec(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
getMigrations(): Migration[] {
return this.migrations;
}
// Get list of executed migrations
const result1: T = await sqlQuery("SELECT name FROM migrations;");
const executedMigrations = extractMigrationNames(result1);
// Run pending migrations in order
for (const migration of this.migrations) {
if (!executedMigrations.has(migration.name)) {
await sqlExec(migration.sql);
await sqlExec(
`INSERT INTO migrations (name) VALUES ('${migration.name}')`,
);
}
}
/**
* Clear all registered migrations
*/
clearMigrations(): void {
this.migrations = [];
logger.info("[MigrationService] Cleared all registered migrations");
}
}
export default MigrationService.getInstance();
// Create a singleton instance of the migration registry
const migrationRegistry = new MigrationRegistry();
/**
* Register a migration with the migration service
*
* This function is used by the migration system to register database
* schema migrations that need to be applied to the database.
*
* @param migration - The migration to register
*/
export function registerMigration(migration: Migration): void {
migrationRegistry.registerMigration(migration);
}
/**
* Run all registered migrations against the database
*
* This function executes all registered migrations in order, checking
* which ones have already been applied to avoid duplicate execution.
* It creates a migrations table if it doesn't exist to track applied
* migrations.
*
* @param sqlExec - Function to execute SQL statements
* @param sqlQuery - Function to query SQL data
* @param extractMigrationNames - Function to extract migration names from query results
* @returns Promise that resolves when all migrations are complete
*/
export async function runMigrations<T>(
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
try {
// Create migrations table if it doesn't exist
await sqlExec(`
CREATE TABLE IF NOT EXISTS migrations (
name TEXT PRIMARY KEY,
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
);
`);
// Get list of already applied migrations
const appliedMigrationsResult = await sqlQuery(
"SELECT name FROM migrations",
);
const appliedMigrations = extractMigrationNames(appliedMigrationsResult);
logger.info(
`[MigrationService] Found ${appliedMigrations.size} applied migrations`,
);
// Get all registered migrations
const migrations = migrationRegistry.getMigrations();
if (migrations.length === 0) {
logger.warn("[MigrationService] No migrations registered");
return;
}
logger.info(
`[MigrationService] Running ${migrations.length} registered migrations`,
);
// Run each migration that hasn't been applied yet
for (const migration of migrations) {
if (appliedMigrations.has(migration.name)) {
logger.info(
`[MigrationService] Skipping already applied migration: ${migration.name}`,
);
continue;
}
logger.info(`[MigrationService] Applying migration: ${migration.name}`);
try {
// Execute the migration SQL
await sqlExec(migration.sql);
// Record that the migration was applied
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.info(
`[MigrationService] Successfully applied migration: ${migration.name}`,
);
} catch (error) {
logger.error(
`[MigrationService] Failed to apply migration ${migration.name}:`,
error,
);
throw new Error(`Migration ${migration.name} failed: ${error}`);
}
}
logger.info("[MigrationService] All migrations completed successfully");
} catch (error) {
logger.error("[MigrationService] Migration process failed:", error);
throw error;
}
}