Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions electron/src/__mocks__/better-sqlite3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { vi } from 'vitest';

/**
* @description Mock for better-sqlite3 database module.
*/
export const mockStatement = {
get: vi.fn(),
run: vi.fn(),
all: vi.fn().mockReturnValue([]),
};

export const mockDatabase = {
prepare: vi.fn().mockReturnValue(mockStatement),
exec: vi.fn(),
transaction: vi.fn((fn: () => void) => fn),
close: vi.fn(),
};

function MockDatabase() {
return mockDatabase;
}

export default MockDatabase;
19 changes: 19 additions & 0 deletions electron/src/services/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getDataPath,
getTemplateDatabasePath,
} from '../../utils/paths.js';
import { MigrationRunner } from './migrations/migration-runner.js';

let prismaInstance: PrismaClient | null = null;

Expand All @@ -24,6 +25,7 @@ export class DatabaseManager {
if (!prismaInstance) {
this.initDirectory();
this.initDatabaseFromTemplate();
this.runMigrations();

const dbPath = getDatabasePath();
const adapter = new PrismaBetterSqlite3(
Expand Down Expand Up @@ -94,6 +96,23 @@ export class DatabaseManager {
}
}

/**
* Runs pending database migrations automatically.
* Creates a backup before applying any schema changes.
*/
private runMigrations(): void {
try {
const runner = new MigrationRunner();
const result = runner.runMigrations();
if (result.applied > 0) {
console.log(`Applied ${result.applied} database migration(s)`);
}
runner.close();
} catch (error) {
console.error('Error running database migrations:', error);
}
}

/**
* Seeds all default data during initialization.
* @internal NEVER call ensureInitialized() from this method - causes deadlock.
Expand Down
31 changes: 31 additions & 0 deletions electron/src/services/database/migrations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Database migrations registry.
* Each migration has a version number and SQL statements to execute.
*/
export interface Migration {
version: number;
name: string;
up: string[];
}

/**
* All database migrations in order.
* Add new migrations at the end with incrementing version numbers.
*/
export const migrations: Migration[] = [
// Version 1 is the initial schema from template.db
// Add future migrations here:
// {
// version: 2,
// name: 'add_new_column',
// up: [
// 'ALTER TABLE projects ADD COLUMN new_field TEXT;',
// ],
// },
];

/**
* Current schema version (matches the latest migration version).
* Start at 1 for the initial template.db schema.
*/
export const CURRENT_SCHEMA_VERSION = 1;
188 changes: 188 additions & 0 deletions electron/src/services/database/migrations/migration-runner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { migrations, CURRENT_SCHEMA_VERSION } from './index.js';

/**
* @description Tests for migrations registry and MigrationRunner.
*/
describe('migrations registry', () => {
it('should export migrations array', () => {
expect(Array.isArray(migrations)).toBe(true);
});

it('should have CURRENT_SCHEMA_VERSION defined', () => {
expect(CURRENT_SCHEMA_VERSION).toBe(1);
});

it('should have migrations with correct structure', () => {
for (const migration of migrations) {
expect(migration).toHaveProperty('version');
expect(migration).toHaveProperty('name');
expect(migration).toHaveProperty('up');
expect(typeof migration.version).toBe('number');
expect(typeof migration.name).toBe('string');
expect(Array.isArray(migration.up)).toBe(true);
}
});

it('should have migrations in ascending version order', () => {
for (let i = 1; i < migrations.length; i++) {
expect(migrations[i].version).toBeGreaterThan(migrations[i - 1].version);
}
});
});

describe('MigrationRunner', () => {
const mockStatement = {
get: vi.fn(),
run: vi.fn(),
all: vi.fn().mockReturnValue([]),
};

const mockDb = {
prepare: vi.fn().mockReturnValue(mockStatement),
exec: vi.fn(),
transaction: vi.fn((fn: () => void) => fn),
close: vi.fn(),
};

beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();

mockStatement.get.mockReset();
mockStatement.run.mockReset();
mockStatement.all.mockReset().mockReturnValue([]);
mockDb.prepare.mockReset().mockReturnValue(mockStatement);
mockDb.exec.mockReset();
mockDb.transaction.mockReset().mockImplementation((fn: () => void) => fn);
mockDb.close.mockReset();

vi.doMock('better-sqlite3', () => ({
default: function MockDatabase() {
return mockDb;
},
}));

vi.doMock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
copyFileSync: vi.fn(),
mkdirSync: vi.fn(),
}));

vi.doMock('../../../utils/paths.js', () => ({
getDatabasePath: vi.fn(() => '/mock/path/database.db'),
getBackupPath: vi.fn(() => '/mock/path/backups'),
}));
});

it('should create instance with default path', async () => {
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
expect(runner).toBeDefined();
});

it('should create instance with custom path', async () => {
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner('/custom/path.db');
expect(runner).toBeDefined();
});

describe('getSchemaVersion', () => {
it('should return version from database', async () => {
mockStatement.get.mockReturnValue({ value: '5' });
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
const version = runner.getSchemaVersion();
expect(version).toBe(5);
});

it('should return 0 if no version exists', async () => {
mockStatement.get.mockReturnValue(undefined);
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
const version = runner.getSchemaVersion();
expect(version).toBe(0);
});

it('should return 0 on database error', async () => {
mockDb.prepare.mockImplementation(() => {
throw new Error('DB error');
});
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
const version = runner.getSchemaVersion();
expect(version).toBe(0);
});
});

describe('runMigrations', () => {
it('should return applied 0 when schema is up to date', async () => {
mockStatement.get
.mockReturnValueOnce({ '1': 1 })
.mockReturnValueOnce({ value: String(CURRENT_SCHEMA_VERSION) });
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
const result = runner.runMigrations();
expect(result.applied).toBe(0);
expect(result.backupPath).toBeUndefined();
});

it('should handle case when backup directory needs creation', async () => {
const fs = await import('fs');
expect(fs.existsSync).toBeDefined();
expect(fs.mkdirSync).toBeDefined();
});

it('should have backup functionality available', async () => {
const fs = await import('fs');
expect(fs.copyFileSync).toBeDefined();
});

it('should run pending migrations', async () => {
mockStatement.get.mockReturnValue(undefined);
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
const result = runner.runMigrations();
expect(result.applied).toBeGreaterThanOrEqual(0);
});

it('should update schema version after migration', async () => {
mockStatement.get.mockReturnValue(undefined);
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
runner.runMigrations();
expect(mockStatement.run).toHaveBeenCalled();
});

it('should have transaction support for migrations', async () => {
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
expect(runner.runMigrations).toBeDefined();
});

it('should ensure version table exists', async () => {
mockStatement.get.mockReturnValue(undefined);
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
runner.runMigrations();
expect(mockDb.exec).toHaveBeenCalled();
});

it('should insert initial version when table is empty', async () => {
mockStatement.get.mockReturnValue(undefined);
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
runner.runMigrations();
expect(mockStatement.run).toHaveBeenCalled();
});
});

describe('close', () => {
it('should close the database connection', async () => {
const { MigrationRunner } = await import('./migration-runner.js');
const runner = new MigrationRunner();
runner.close();
expect(mockDb.close).toHaveBeenCalled();
});
});
});
Loading