initial commit

This commit is contained in:
2026-02-10 11:04:44 +01:00
commit 5979fa3374
57 changed files with 19344 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
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<typeof drizzle>;
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<DatabaseConfig>) {
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<DrizzleDB> {
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<DrizzleDB | null> {
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;
}
private async runMigrations(): Promise<void> {
if (!this.localClient) return;
// Create tables if they don't exist using batch execution
await this.localClient.executeMultiple(`
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
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
);
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
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 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_media_sync_status ON media(sync_status);
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
`);
}
async close(): Promise<void> {
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<DatabaseConfig>): DatabaseConnection {
dbConnection = new DatabaseConnection(config);
return dbConnection;
}

View File

@@ -0,0 +1,2 @@
export * from './schema';
export * from './connection';

View File

@@ -0,0 +1,70 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// Posts table - stores metadata for blog posts
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
excerpt: text('excerpt'),
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
author: text('author'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
publishedAt: integer('published_at', { mode: 'timestamp' }),
filePath: text('file_path').notNull(),
syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'),
syncedAt: integer('synced_at', { mode: 'timestamp' }),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
categories: text('categories'), // JSON array stored as text
});
// Media table - stores metadata for images and other media
export const media = sqliteTable('media', {
id: text('id').primaryKey(),
filename: text('filename').notNull(),
originalName: text('original_name').notNull(),
mimeType: text('mime_type').notNull(),
size: integer('size').notNull(),
width: integer('width'),
height: integer('height'),
alt: text('alt'),
caption: text('caption'),
filePath: text('file_path').notNull(),
sidecarPath: text('sidecar_path').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
syncStatus: text('sync_status', { enum: ['pending', 'synced', 'conflict'] }).notNull().default('pending'),
syncedAt: integer('synced_at', { mode: 'timestamp' }),
checksum: text('checksum'),
tags: text('tags'), // JSON array stored as text
});
// Sync log - tracks sync operations
export const syncLog = sqliteTable('sync_log', {
id: text('id').primaryKey(),
entityType: text('entity_type', { enum: ['post', 'media'] }).notNull(),
entityId: text('entity_id').notNull(),
operation: text('operation', { enum: ['create', 'update', 'delete'] }).notNull(),
status: text('status', { enum: ['pending', 'completed', 'failed'] }).notNull().default('pending'),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
errorMessage: text('error_message'),
retryCount: integer('retry_count').notNull().default(0),
});
// App settings - stores application configuration
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Types for TypeScript
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type Media = typeof media.$inferSelect;
export type NewMedia = typeof media.$inferInsert;
export type SyncLogEntry = typeof syncLog.$inferSelect;
export type NewSyncLogEntry = typeof syncLog.$inferInsert;
export type Setting = typeof settings.$inferSelect;
export type NewSetting = typeof settings.$inferInsert;