Skip to content

Commit 8c7e3e3

Browse files
author
Lasim
committed
feat(database): enhance database initialization and migration handling with improved logging and test coverage
1 parent 11d77b3 commit 8c7e3e3

File tree

3 files changed

+493
-119
lines changed

3 files changed

+493
-119
lines changed

services/backend/src/db/index.ts

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -151,18 +151,21 @@ async function createDatabaseInstance(config: DatabaseConfig, schema: AnySchema,
151151
* Apply migrations for any database type
152152
*/
153153
async function applyMigrations(db: AnyDatabase, config: DatabaseConfig, logger: FastifyBaseLogger) {
154+
// Skip migrations in test mode
155+
if (isTestMode()) {
156+
return;
157+
}
158+
154159
const projectRootMigrationsDir = path.join(process.cwd(), 'drizzle');
155160
const migrationsPath = path.join(projectRootMigrationsDir, 'migrations_sqlite');
156161

157162
try {
158163
await fs.access(migrationsPath);
159164
} catch {
160-
if (!isTestMode()) {
161-
logger.info({
162-
operation: 'apply_migrations',
163-
migrationsPath
164-
}, `Migrations directory not found at: ${migrationsPath}, skipping migrations.`);
165-
}
165+
logger.info({
166+
operation: 'apply_migrations',
167+
migrationsPath
168+
}, `Migrations directory not found at: ${migrationsPath}, skipping migrations.`);
166169
return;
167170
}
168171

@@ -369,11 +372,9 @@ async function applyMigrations(db: AnyDatabase, config: DatabaseConfig, logger:
369372
*/
370373
export async function initializeDatabase(logger: FastifyBaseLogger): Promise<boolean> {
371374
if (isDbInitialized) {
372-
if (!isTestMode()) {
373-
logger.info({
374-
operation: 'initialize_database'
375-
}, 'Database already initialized.');
376-
}
375+
logger.info({
376+
operation: 'initialize_database'
377+
}, 'Database already initialized.');
377378
return true;
378379
}
379380

@@ -419,11 +420,9 @@ export async function initializeDatabase(logger: FastifyBaseLogger): Promise<boo
419420
} catch (error) {
420421
const typedError = error as Error;
421422
if (typedError.message.includes('No database selection found')) {
422-
if (!isTestMode()) {
423-
logger.info({
424-
operation: 'initialize_database'
425-
}, 'No database configured yet. Please use the /api/db/setup endpoint to configure your database.');
426-
}
423+
logger.info({
424+
operation: 'initialize_database'
425+
}, 'No database configured yet. Please use the /api/db/setup endpoint to configure your database.');
427426
} else {
428427
logger.error({
429428
operation: 'initialize_database',
@@ -459,21 +458,53 @@ export function getSchema(): AnySchema {
459458
* Get database status
460459
*/
461460
export function getDbStatus() {
462-
if (!dbConfig) {
461+
try {
462+
if (!dbConfig) {
463+
// Try to get config to see if one exists
464+
const config = getDatabaseConfig();
465+
if (config) {
466+
return {
467+
configured: validateDatabaseConfig(config),
468+
initialized: isDbInitialized,
469+
dialect: config.type,
470+
type: config.type
471+
};
472+
}
473+
}
474+
475+
if (!dbConfig) {
476+
return {
477+
configured: false,
478+
initialized: false,
479+
dialect: null,
480+
type: null
481+
};
482+
}
483+
484+
return {
485+
configured: validateDatabaseConfig(dbConfig),
486+
initialized: isDbInitialized,
487+
dialect: dbConfig.type,
488+
type: dbConfig.type
489+
};
490+
} catch {
463491
return {
464492
configured: false,
465493
initialized: false,
466494
dialect: null,
467495
type: null
468496
};
469497
}
470-
471-
return {
472-
configured: validateDatabaseConfig(dbConfig),
473-
initialized: isDbInitialized,
474-
dialect: dbConfig.type,
475-
type: dbConfig.type
476-
};
498+
}
499+
500+
/**
501+
* Reset database state (for testing)
502+
*/
503+
export function resetDatabaseState() {
504+
dbInstance = null;
505+
dbSchema = null;
506+
dbConfig = null;
507+
isDbInitialized = false;
477508
}
478509

479510
/**
@@ -492,7 +523,7 @@ export function executeDbOperation<T>(
492523
interface DatabaseExtensionWithTables extends DatabaseExtension {
493524

494525
tableDefinitions?: Record<string, Record<string, (columnBuilder: any) => any>>;
495-
onDatabaseInit?: (db: AnyDatabase, logger: FastifyBaseLogger) => Promise<void>;
526+
onDatabaseInit?: (db: AnyDatabase, schema: AnySchema) => Promise<void>;
496527
}
497528

498529
export function registerPluginTables(plugins: Plugin[], logger?: FastifyBaseLogger) {
@@ -515,11 +546,9 @@ export function registerPluginTables(plugins: Plugin[], logger?: FastifyBaseLogg
515546
}
516547

517548
export async function createPluginTables(plugins: Plugin[], logger: FastifyBaseLogger) {
518-
if (!isTestMode()) {
519-
logger.info({
520-
operation: 'create_plugin_tables'
521-
}, 'Plugin tables are handled by migrations.');
522-
}
549+
logger.info({
550+
operation: 'create_plugin_tables'
551+
}, 'Plugin tables are handled by migrations.');
523552
}
524553

525554
export async function initializePluginDatabases(db: AnyDatabase, plugins: Plugin[], logger: FastifyBaseLogger) {
@@ -535,7 +564,15 @@ export async function initializePluginDatabases(db: AnyDatabase, plugins: Plugin
535564
try {
536565
// Create a child logger for this plugin
537566
const pluginLogger = logger.child({ pluginId: plugin.meta.id });
538-
await ext.onDatabaseInit(db, pluginLogger);
567+
// Get the current schema - use dbSchema directly if available, otherwise generate one
568+
let schema: AnySchema;
569+
try {
570+
schema = getSchema();
571+
} catch {
572+
// If getSchema fails, generate a basic schema for the plugin
573+
schema = generateSchema();
574+
}
575+
await ext.onDatabaseInit(db, schema);
539576
if (!isTestMode()) {
540577
logger.info({
541578
operation: 'initialize_plugin_databases',

services/backend/tests/unit/db/config.test.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { validateDatabaseConfig, getDatabaseStatus } from '../../../src/db/config';
2+
import { validateDatabaseConfig, getDatabaseStatus, getDatabaseConfig } from '../../../src/db/config';
33
import type { DatabaseConfig } from '../../../src/db/config';
4+
import * as fs from 'fs';
5+
import * as path from 'path';
6+
7+
// Mock the fs module
8+
vi.mock('fs', () => ({
9+
existsSync: vi.fn(),
10+
readFileSync: vi.fn(),
11+
}));
12+
13+
// Mock the path module
14+
vi.mock('path', () => ({
15+
join: vi.fn((...paths) => paths.join('/')),
16+
}));
17+
18+
// Mock process.cwd
19+
const originalCwd = process.cwd;
20+
21+
const mockedFs = vi.mocked(fs);
22+
const mockedPath = vi.mocked(path);
423

524
describe('Database Configuration', () => {
625
let originalEnv: string | undefined;
726
let originalDbType: string | undefined;
827
let originalSqliteDbPath: string | undefined;
928
let originalTursoUrl: string | undefined;
1029
let originalTursoToken: string | undefined;
30+
let mockLogger: any;
1131

1232
beforeEach(() => {
1333
// Store original environment variables
@@ -22,6 +42,26 @@ describe('Database Configuration', () => {
2242
delete process.env.SQLITE_DB_PATH;
2343
delete process.env.TURSO_DATABASE_URL;
2444
delete process.env.TURSO_AUTH_TOKEN;
45+
46+
// Create mock logger
47+
mockLogger = {
48+
info: vi.fn(),
49+
warn: vi.fn(),
50+
error: vi.fn(),
51+
};
52+
53+
// Reset mocks
54+
vi.clearAllMocks();
55+
56+
// Default mock implementations
57+
mockedFs.existsSync.mockReturnValue(false);
58+
mockedFs.readFileSync.mockReturnValue('');
59+
60+
// Mock path.join
61+
mockedPath.join.mockImplementation((...paths) => paths.join('/'));
62+
63+
// Mock process.cwd
64+
process.cwd = vi.fn(() => '/mock/cwd');
2565
});
2666

2767
afterEach(() => {
@@ -49,6 +89,9 @@ describe('Database Configuration', () => {
4989
} else {
5090
delete process.env.TURSO_AUTH_TOKEN;
5191
}
92+
93+
// Restore process.cwd
94+
process.cwd = originalCwd;
5295
});
5396

5497
describe('validateDatabaseConfig', () => {
@@ -371,4 +414,79 @@ describe('Database Configuration', () => {
371414
expect(result).toBe(false);
372415
});
373416
});
417+
418+
describe('Integration scenarios', () => {
419+
beforeEach(() => {
420+
vi.useFakeTimers();
421+
});
422+
423+
afterEach(() => {
424+
vi.useRealTimers();
425+
});
426+
427+
it('should handle complete SQLite workflow', () => {
428+
// Simulate no selection file, use environment
429+
mockedFs.existsSync.mockReturnValue(false);
430+
process.env.NODE_ENV = 'production'; // Override test mode
431+
process.env.DB_TYPE = 'sqlite';
432+
process.env.SQLITE_DB_PATH = '/production/path/database.db';
433+
434+
const config = getDatabaseConfig(mockLogger);
435+
const isValid = validateDatabaseConfig(config);
436+
const status = getDatabaseStatus(config);
437+
438+
expect(config).toEqual({
439+
type: 'sqlite',
440+
dbPath: '/production/path/database.db'
441+
});
442+
expect(isValid).toBe(true);
443+
expect(status).toEqual({
444+
configured: true,
445+
dialect: 'sqlite',
446+
type: 'sqlite'
447+
});
448+
});
449+
450+
it('should handle fallback scenario with partial configuration', () => {
451+
// Simulate corrupted selection file, fallback to environment
452+
mockedFs.existsSync.mockReturnValue(true);
453+
mockedFs.readFileSync.mockImplementation(() => {
454+
throw new Error('File corrupted');
455+
});
456+
457+
process.env.NODE_ENV = 'production'; // Override test mode
458+
process.env.DB_TYPE = 'turso';
459+
process.env.TURSO_DATABASE_URL = 'libsql://fallback.turso.io';
460+
// Missing auth token - should throw error
461+
462+
expect(() => getDatabaseConfig(mockLogger)).toThrow(
463+
'Turso configuration incomplete. Required: TURSO_DATABASE_URL, TURSO_AUTH_TOKEN'
464+
);
465+
});
466+
});
467+
468+
describe('Performance and reliability', () => {
469+
it('should handle file system race conditions', () => {
470+
process.env.NODE_ENV = 'production'; // Override test mode
471+
let callCount = 0;
472+
mockedFs.existsSync.mockImplementation(() => {
473+
callCount++;
474+
if (callCount === 1) {
475+
return true; // First call says file exists
476+
}
477+
return false; // Subsequent calls say it doesn't (race condition)
478+
});
479+
480+
mockedFs.readFileSync.mockImplementation(() => {
481+
throw new Error('File disappeared');
482+
});
483+
484+
process.env.DB_TYPE = 'sqlite';
485+
486+
const config = getDatabaseConfig(mockLogger);
487+
488+
expect(config.type).toBe('sqlite');
489+
expect(config.dbPath).toBe('persistent_data/database/deploystack.db');
490+
});
491+
});
374492
});

0 commit comments

Comments
 (0)