import { createClient, Client } from '@libsql/client'; import { drizzle } from 'drizzle-orm/libsql'; import * as schema from './schema'; import { app } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; export interface DatabaseConfig { localPath: string; tursoUrl?: string; tursoAuthToken?: string; } type DrizzleDB = ReturnType; export class DatabaseConnection { private localDb: DrizzleDB | null = null; private remoteDb: DrizzleDB | null = null; private localClient: Client | null = null; private remoteClient: Client | null = null; private config: DatabaseConfig; constructor(config?: Partial) { const userDataPath = app.getPath('userData'); this.config = { localPath: config?.localPath || path.join(userDataPath, 'bds.db'), tursoUrl: config?.tursoUrl, tursoAuthToken: config?.tursoAuthToken, }; // Ensure user data directory exists const dataDir = path.dirname(this.config.localPath); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } // Ensure posts and media directories exist const postsDir = path.join(userDataPath, 'posts'); const mediaDir = path.join(userDataPath, 'media'); if (!fs.existsSync(postsDir)) { fs.mkdirSync(postsDir, { recursive: true }); } if (!fs.existsSync(mediaDir)) { fs.mkdirSync(mediaDir, { recursive: true }); } } async initializeLocal(): Promise { if (this.localDb) { return this.localDb; } // Use file: URL for local SQLite database via libsql this.localClient = createClient({ url: `file:${this.config.localPath}`, }); this.localDb = drizzle(this.localClient, { schema }); // Run migrations await this.runMigrations(); return this.localDb; } async initializeRemote(): Promise { if (!this.config.tursoUrl || !this.config.tursoAuthToken) { return null; } if (this.remoteDb) { return this.remoteDb; } this.remoteClient = createClient({ url: this.config.tursoUrl, authToken: this.config.tursoAuthToken, }); this.remoteDb = drizzle(this.remoteClient, { schema }); return this.remoteDb; } getLocal(): DrizzleDB { if (!this.localDb) { throw new Error('Local database not initialized. Call initializeLocal() first.'); } return this.localDb; } getRemote(): DrizzleDB | null { return this.remoteDb; } getLocalClient(): Client | null { return this.localClient; } async getActiveProject(): Promise<{ id: string; name: string; slug: string } | null> { if (!this.localClient) return null; const result = await this.localClient.execute('SELECT id, name, slug FROM projects WHERE is_active = 1 LIMIT 1'); if (result.rows.length === 0) return null; const row = result.rows[0]; return { id: row.id as string, name: row.name as string, slug: row.slug as string, }; } async setActiveProject(projectId: string): Promise { if (!this.localClient) return; await this.localClient.execute('UPDATE projects SET is_active = 0'); await this.localClient.execute({ sql: 'UPDATE projects SET is_active = 1 WHERE id = ?', args: [projectId], }); } private async runMigrations(): Promise { if (!this.localClient) return; // Create tables if they don't exist using batch execution await this.localClient.executeMultiple(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, description TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, is_active INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS posts ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL DEFAULT 'default', title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, excerpt TEXT, status TEXT NOT NULL DEFAULT 'draft', author TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, published_at INTEGER, file_path TEXT NOT NULL, sync_status TEXT NOT NULL DEFAULT 'pending', synced_at INTEGER, checksum TEXT, tags TEXT, categories TEXT, published_title TEXT, published_content TEXT, published_tags TEXT, published_categories TEXT, published_excerpt TEXT ); CREATE TABLE IF NOT EXISTS media ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL DEFAULT 'default', filename TEXT NOT NULL, original_name TEXT NOT NULL, mime_type TEXT NOT NULL, size INTEGER NOT NULL, width INTEGER, height INTEGER, alt TEXT, caption TEXT, file_path TEXT NOT NULL, sidecar_path TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, sync_status TEXT NOT NULL DEFAULT 'pending', synced_at INTEGER, checksum TEXT, tags TEXT ); CREATE TABLE IF NOT EXISTS sync_log ( id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, operation TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', timestamp INTEGER NOT NULL, error_message TEXT, retry_count INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS post_links ( id TEXT PRIMARY KEY, source_post_id TEXT NOT NULL, target_post_id TEXT NOT NULL, link_text TEXT, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug); CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status); CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status); CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at); CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status); CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status); CREATE INDEX IF NOT EXISTS idx_post_links_source ON post_links(source_post_id); CREATE INDEX IF NOT EXISTS idx_post_links_target ON post_links(target_post_id); `); // Check if project_id column exists in posts table, add if missing (migration) const postsColumns = await this.localClient.execute( "SELECT name FROM pragma_table_info('posts') WHERE name = 'project_id'" ); if (postsColumns.rows.length === 0) { await this.localClient.execute( "ALTER TABLE posts ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'" ); await this.localClient.execute( "CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)" ); } else { await this.localClient.execute( "CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id)" ); } // Check if project_id column exists in media table, add if missing (migration) const mediaColumns = await this.localClient.execute( "SELECT name FROM pragma_table_info('media') WHERE name = 'project_id'" ); if (mediaColumns.rows.length === 0) { await this.localClient.execute( "ALTER TABLE media ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'" ); await this.localClient.execute( "CREATE INDEX IF NOT EXISTS idx_media_project_id ON media(project_id)" ); } else { await this.localClient.execute( "CREATE INDEX IF NOT EXISTS idx_media_project_id ON media(project_id)" ); } // Migration: Add published snapshot columns for discard functionality const publishedContentCol = await this.localClient.execute( "SELECT name FROM pragma_table_info('posts') WHERE name = 'published_content'" ); if (publishedContentCol.rows.length === 0) { await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_title TEXT"); await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_content TEXT"); await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_tags TEXT"); await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_categories TEXT"); await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_excerpt TEXT"); } // Migration: Add content column for draft body text stored in DB const contentCol = await this.localClient.execute( "SELECT name FROM pragma_table_info('posts') WHERE name = 'content'" ); if (contentCol.rows.length === 0) { await this.localClient.execute("ALTER TABLE posts ADD COLUMN content TEXT"); } // Create FTS5 virtual table for full-text search await this.localClient.execute(` CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( id UNINDEXED, title, content, excerpt, tags, categories, content_rowid=rowid ); `); // Create default project if none exists const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects'); if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) { const now = Date.now(); await this.localClient.execute({ sql: 'INSERT INTO projects (id, name, slug, description, created_at, updated_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)', args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1], }); } } async close(): Promise { if (this.localClient) { this.localClient.close(); this.localClient = null; this.localDb = null; } if (this.remoteClient) { this.remoteClient.close(); this.remoteClient = null; this.remoteDb = null; } } getDataPaths() { const userDataPath = app.getPath('userData'); return { database: this.config.localPath, posts: path.join(userDataPath, 'posts'), media: path.join(userDataPath, 'media'), }; } } // Singleton instance let dbConnection: DatabaseConnection | null = null; export function getDatabase(): DatabaseConnection { if (!dbConnection) { dbConnection = new DatabaseConnection(); } return dbConnection; } export function initDatabase(config?: Partial): DatabaseConnection { dbConnection = new DatabaseConnection(config); return dbConnection; }