broken: halfway through removing turso

This commit is contained in:
2026-02-11 07:45:45 +01:00
parent 3126be4e90
commit a8499626c0
9 changed files with 561 additions and 469 deletions

View File

@@ -9,7 +9,7 @@ This document provides context and best practices for GitHub Copilot when workin
- **TypeScript** for all code (strict mode) - **TypeScript** for all code (strict mode)
- **React** for the renderer UI - **React** for the renderer UI
- **Drizzle ORM** for type-safe database access - **Drizzle ORM** for type-safe database access
- **@libsql/client** for SQLite (local) and Turso (cloud sync) - **@libsql/client** for SQLite (local database)
- **Zustand** for React state management - **Zustand** for React state management
--- ---
@@ -32,6 +32,19 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo
--- ---
## ⚠️ MANDATORY: Fix All Test Failures
**You MUST investigate and fix ALL test failures before completing any task.**
- Never leave tests failing, even if they appear unrelated to your changes
- If a test failure is pre-existing, fix it as part of your current work
- Run the full test suite (`npm test`) before considering any task complete
- If you cannot fix a test, explain why and propose a solution
> **Zero failing tests. No exceptions.**
---
## Architecture Principles ## Architecture Principles
### Separation of Concerns ### Separation of Concerns
@@ -240,7 +253,7 @@ app.on('before-quit', async () => {
}); });
``` ```
## Remote Sync Best Practices (Turso/LibSQL) ## Remote Sync Best Practices (Dropbox)
### Sync Strategy ### Sync Strategy
@@ -719,5 +732,5 @@ export type NewTableInsert = typeof newTable.$inferInsert;
- Never log sensitive data (auth tokens, passwords) - Never log sensitive data (auth tokens, passwords)
- Validate all IPC inputs before processing - Validate all IPC inputs before processing
- Use `contextIsolation: true` and `sandbox: false` only when necessary - Use `contextIsolation: true` and `sandbox: false` only when necessary
- Store Turso auth tokens in secure storage, not in code - Store Dropbox auth tokens in secure storage, not in code
- Sanitize user input before rendering (XSS prevention) - Sanitize user input before rendering (XSS prevention)

View File

@@ -7,17 +7,13 @@ import * as fs from 'fs';
export interface DatabaseConfig { export interface DatabaseConfig {
localPath: string; localPath: string;
tursoUrl?: string;
tursoAuthToken?: string;
} }
type DrizzleDB = ReturnType<typeof drizzle>; type DrizzleDB = ReturnType<typeof drizzle>;
export class DatabaseConnection { export class DatabaseConnection {
private localDb: DrizzleDB | null = null; private localDb: DrizzleDB | null = null;
private remoteDb: DrizzleDB | null = null;
private localClient: Client | null = null; private localClient: Client | null = null;
private remoteClient: Client | null = null;
private config: DatabaseConfig; private config: DatabaseConfig;
constructor(config?: Partial<DatabaseConfig>) { constructor(config?: Partial<DatabaseConfig>) {
@@ -25,8 +21,6 @@ export class DatabaseConnection {
this.config = { this.config = {
localPath: config?.localPath || path.join(userDataPath, 'bds.db'), localPath: config?.localPath || path.join(userDataPath, 'bds.db'),
tursoUrl: config?.tursoUrl,
tursoAuthToken: config?.tursoAuthToken,
}; };
// Ensure user data directory exists // Ensure user data directory exists
@@ -64,38 +58,6 @@ export class DatabaseConnection {
return this.localDb; return this.localDb;
} }
async initializeRemote(remoteConfig?: { tursoUrl: string; tursoAuthToken: string }): Promise<DrizzleDB | null> {
// Update config if new credentials are provided
if (remoteConfig) {
// Close existing remote connection if credentials changed
if (this.remoteClient &&
(this.config.tursoUrl !== remoteConfig.tursoUrl ||
this.config.tursoAuthToken !== remoteConfig.tursoAuthToken)) {
this.remoteClient.close();
this.remoteClient = null;
this.remoteDb = null;
}
this.config.tursoUrl = remoteConfig.tursoUrl;
this.config.tursoAuthToken = remoteConfig.tursoAuthToken;
}
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 { getLocal(): DrizzleDB {
if (!this.localDb) { if (!this.localDb) {
throw new Error('Local database not initialized. Call initializeLocal() first.'); throw new Error('Local database not initialized. Call initializeLocal() first.');
@@ -103,10 +65,6 @@ export class DatabaseConnection {
return this.localDb; return this.localDb;
} }
getRemote(): DrizzleDB | null {
return this.remoteDb;
}
getLocalClient(): Client | null { getLocalClient(): Client | null {
return this.localClient; return this.localClient;
} }
@@ -389,11 +347,6 @@ export class DatabaseConnection {
this.localClient = null; this.localClient = null;
this.localDb = null; this.localDb = null;
} }
if (this.remoteClient) {
this.remoteClient.close();
this.remoteClient = null;
this.remoteDb = null;
}
} }
getDataPaths() { getDataPaths() {

View File

@@ -1,19 +1,15 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { eq, and } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema'; import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema';
import { taskManager, Task } from './TaskManager'; import { taskManager, Task } from './TaskManager';
import { getPostEngine } from './PostEngine';
import { getMediaEngine } from './MediaEngine';
import { getDropboxSyncEngine } from './DropboxSyncEngine'; import { getDropboxSyncEngine } from './DropboxSyncEngine';
export type SyncDirection = 'push' | 'pull' | 'bidirectional'; export type SyncDirection = 'push' | 'pull' | 'bidirectional';
export type SyncStatus = 'idle' | 'syncing' | 'error'; export type SyncStatus = 'idle' | 'syncing' | 'error';
export interface SyncConfig { export interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean; autoSync: boolean;
syncInterval: number; // in minutes syncInterval: number; // in minutes
} }
@@ -33,23 +29,37 @@ export interface SyncResult {
}; };
} }
// Default timeout for sync operations (30 seconds)
const DEFAULT_SYNC_TIMEOUT = 30000;
export class SyncEngine extends EventEmitter { export class SyncEngine extends EventEmitter {
private syncStatus: SyncStatus = 'idle'; private syncStatus: SyncStatus = 'idle';
private syncConfig: SyncConfig | null = null; private syncConfig: SyncConfig | null = null;
private syncIntervalId: NodeJS.Timeout | null = null; private syncIntervalId: NodeJS.Timeout | null = null;
private syncTimeout: number = DEFAULT_SYNC_TIMEOUT;
constructor() { constructor() {
super(); super();
} }
getSyncTimeout(): number {
return this.syncTimeout;
}
setSyncTimeout(timeoutMs: number): void {
this.syncTimeout = timeoutMs;
}
getSyncStatus(): SyncStatus { getSyncStatus(): SyncStatus {
return this.syncStatus; return this.syncStatus;
} }
/**
* Check if sync is configured. Uses Dropbox configuration status.
*/
isConfigured(): boolean { isConfigured(): boolean {
return this.syncConfig !== null && const dropboxEngine = getDropboxSyncEngine();
!!this.syncConfig.tursoUrl && return dropboxEngine.isConfigured();
!!this.syncConfig.tursoAuthToken;
} }
async configure(config: SyncConfig): Promise<void> { async configure(config: SyncConfig): Promise<void> {
@@ -69,179 +79,9 @@ export class SyncEngine extends EventEmitter {
); );
} }
// Initialize remote database connection with the provided credentials
const db = getDatabase();
await db.initializeRemote({
tursoUrl: config.tursoUrl,
tursoAuthToken: config.tursoAuthToken,
});
this.emit('configured', config); this.emit('configured', config);
} }
async sync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
if (!this.isConfigured()) {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Sync not configured'],
};
}
if (this.syncStatus === 'syncing') {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Sync already in progress'],
};
}
const task: Task<SyncResult> = {
id: uuidv4(),
name: `Sync (${direction})`,
execute: async (onProgress) => {
this.syncStatus = 'syncing';
this.emit('syncStarted', direction);
const result: SyncResult = {
success: true,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: [],
};
try {
const db = getDatabase();
const localDb = db.getLocal();
const remoteDb = db.getRemote();
if (!remoteDb) {
throw new Error('Remote database not initialized');
}
onProgress(10, 'Fetching pending changes...');
if (direction === 'push' || direction === 'bidirectional') {
// Get pending posts
const pendingPosts = await localDb
.select()
.from(posts)
.where(eq(posts.syncStatus, 'pending'))
.all();
onProgress(20, `Pushing ${pendingPosts.length} posts...`);
for (const post of pendingPosts) {
try {
// Push to remote (simplified - in production would handle conflicts)
await remoteDb.insert(posts).values(post).onConflictDoUpdate({
target: posts.id,
set: {
title: post.title,
slug: post.slug,
excerpt: post.excerpt,
status: post.status,
author: post.author,
updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
checksum: post.checksum,
tags: post.tags,
categories: post.categories,
},
});
// Mark as synced locally
await localDb
.update(posts)
.set({ syncStatus: 'synced', syncedAt: new Date() })
.where(eq(posts.id, post.id));
result.pushed++;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to push post ${post.id}: ${errorMsg}`);
// Log the error
await this.logSyncOperation(post.id, 'post', 'update', 'failed', errorMsg);
}
}
// Get pending media
const pendingMedia = await localDb
.select()
.from(media)
.where(eq(media.syncStatus, 'pending'))
.all();
onProgress(50, `Pushing ${pendingMedia.length} media items...`);
for (const item of pendingMedia) {
try {
await remoteDb.insert(media).values(item).onConflictDoUpdate({
target: media.id,
set: {
alt: item.alt,
caption: item.caption,
updatedAt: item.updatedAt,
checksum: item.checksum,
tags: item.tags,
},
});
await localDb
.update(media)
.set({ syncStatus: 'synced', syncedAt: new Date() })
.where(eq(media.id, item.id));
result.pushed++;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to push media ${item.id}: ${errorMsg}`);
await this.logSyncOperation(item.id, 'media', 'update', 'failed', errorMsg);
}
}
}
if (direction === 'pull' || direction === 'bidirectional') {
onProgress(70, 'Pulling remote changes...');
// In a real implementation, we would:
// 1. Fetch all remote records with syncedAt > local last sync
// 2. Compare checksums to detect conflicts
// 3. Apply or merge changes
// For now, this is a placeholder
onProgress(90, 'Pull complete');
}
onProgress(100, 'Sync complete');
this.syncStatus = 'idle';
this.emit('syncCompleted', result);
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.success = false;
result.errors.push(errorMsg);
this.syncStatus = 'error';
this.emit('syncFailed', errorMsg);
return result;
}
},
};
return taskManager.runTask(task);
}
private async logSyncOperation( private async logSyncOperation(
entityId: string, entityId: string,
entityType: 'post' | 'media', entityType: 'post' | 'media',
@@ -324,38 +164,110 @@ export class SyncEngine extends EventEmitter {
} }
/** /**
* Full sync: metadata via Turso + files via Dropbox. * Full sync: Files via Dropbox.
* Coordinates both sync engines for a complete bidirectional sync. * Synchronizes posts and media files to Dropbox for backup and cross-device access.
*/ */
async fullSync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> { async fullSync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
// Run metadata sync (Turso) if (this.syncStatus === 'syncing') {
const metadataResult = await this.sync(direction); return {
success: false,
// Run file sync (Dropbox) if configured pushed: 0,
const dropboxEngine = getDropboxSyncEngine(); pulled: 0,
if (dropboxEngine.isConfigured()) { conflicts: 0,
try { errors: ['Sync already in progress'],
const fileResult = await dropboxEngine.syncAll(); };
metadataResult.dropboxResult = {
uploaded: fileResult.uploaded,
downloaded: fileResult.downloaded,
deleted: fileResult.deleted,
conflicts: fileResult.conflicts,
errors: fileResult.errors,
};
if (!fileResult.success) {
metadataResult.errors.push(
...fileResult.errors.map(e => `Dropbox: ${e}`)
);
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown Dropbox error';
metadataResult.errors.push(`Dropbox sync failed: ${msg}`);
}
} }
return metadataResult; const result: SyncResult = {
success: true,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: [],
};
const dropboxEngine = getDropboxSyncEngine();
if (!dropboxEngine.isConfigured()) {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Dropbox sync not configured'],
};
}
console.log('[SyncEngine] Starting Dropbox file sync...');
const task: Task<SyncResult> = {
id: uuidv4(),
name: `Sync (${direction})`,
execute: async (onProgress) => {
this.syncStatus = 'syncing';
this.emit('syncStarted', direction);
try {
onProgress(10, 'Starting Dropbox sync...');
const fileResult = await dropboxEngine.syncAll();
result.dropboxResult = {
uploaded: fileResult.uploaded,
downloaded: fileResult.downloaded,
deleted: fileResult.deleted,
conflicts: fileResult.conflicts,
errors: fileResult.errors,
};
result.pushed = fileResult.uploaded;
result.pulled = fileResult.downloaded;
result.conflicts = fileResult.conflicts;
if (!fileResult.success) {
result.success = false;
result.errors.push(...fileResult.errors.map(e => `Dropbox: ${e}`));
}
onProgress(100, 'Sync complete');
console.log('[SyncEngine] Dropbox sync complete:', {
uploaded: fileResult.uploaded,
downloaded: fileResult.downloaded,
errors: fileResult.errors.length,
});
this.syncStatus = 'idle';
this.emit('syncCompleted', result);
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error('[SyncEngine] Sync failed:', errorMsg);
result.success = false;
result.errors.push(`Dropbox sync failed: ${errorMsg}`);
this.syncStatus = 'error';
this.emit('syncFailed', errorMsg);
return result;
}
},
};
try {
return await taskManager.runTask(task);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error('[SyncEngine] Task manager error:', errorMsg);
this.syncStatus = 'error';
this.emit('syncFailed', errorMsg);
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: [errorMsg],
};
}
} }
} }

View File

@@ -46,19 +46,11 @@ const App: React.FC = () => {
setMedia(media as MediaData[]); setMedia(media as MediaData[]);
} }
// Re-configure sync backends from saved credentials // Re-configure Dropbox sync from saved credentials
const savedCreds = localStorage.getItem('bds-credentials'); const savedCreds = localStorage.getItem('bds-credentials');
if (savedCreds) { if (savedCreds) {
try { try {
const creds = JSON.parse(savedCreds); const creds = JSON.parse(savedCreds);
if (creds.tursoUrl && creds.tursoToken) {
await window.electronAPI?.sync.configure({
tursoUrl: creds.tursoUrl,
tursoAuthToken: creds.tursoToken,
autoSync: true,
syncInterval: 5,
});
}
if (creds.dropboxAccessToken && creds.dropboxAppKey) { if (creds.dropboxAccessToken && creds.dropboxAppKey) {
await window.electronAPI?.dropbox?.configure({ await window.electronAPI?.dropbox?.configure({
accessToken: creds.dropboxAccessToken, accessToken: creds.dropboxAccessToken,
@@ -71,7 +63,7 @@ const App: React.FC = () => {
} }
} }
// Check sync status // Check sync status (uses Dropbox configuration)
const syncConfigured = await window.electronAPI?.sync.isConfigured(); const syncConfigured = await window.electronAPI?.sync.isConfigured();
setSyncConfigured(syncConfigured || false); setSyncConfigured(syncConfigured || false);

View File

@@ -3,8 +3,6 @@ import { showToast } from '../Toast';
import './CredentialsPanel.css'; import './CredentialsPanel.css';
interface Credentials { interface Credentials {
tursoUrl: string;
tursoToken: string;
ftpHost?: string; ftpHost?: string;
ftpUser?: string; ftpUser?: string;
ftpPassword?: string; ftpPassword?: string;
@@ -15,8 +13,6 @@ interface Credentials {
export const CredentialsPanel: React.FC = () => { export const CredentialsPanel: React.FC = () => {
const [credentials, setCredentials] = useState<Credentials>({ const [credentials, setCredentials] = useState<Credentials>({
tursoUrl: '',
tursoToken: '',
ftpHost: '', ftpHost: '',
ftpUser: '', ftpUser: '',
ftpPassword: '', ftpPassword: '',
@@ -24,7 +20,7 @@ export const CredentialsPanel: React.FC = () => {
sshUser: '', sshUser: '',
sshKeyPath: '', sshKeyPath: '',
}); });
const [activeTab, setActiveTab] = useState<'sync' | 'ftp' | 'ssh'>('sync'); const [activeTab, setActiveTab] = useState<'ftp' | 'ssh'>('ftp');
const [showTokens, setShowTokens] = useState(false); const [showTokens, setShowTokens] = useState(false);
// Load saved credentials (in a real app, use secure storage) // Load saved credentials (in a real app, use secure storage)
@@ -47,16 +43,6 @@ export const CredentialsPanel: React.FC = () => {
// Save to localStorage (in production, use secure storage) // Save to localStorage (in production, use secure storage)
localStorage.setItem('bds-credentials', JSON.stringify(credentials)); localStorage.setItem('bds-credentials', JSON.stringify(credentials));
// Configure sync if Turso credentials are set
if (credentials.tursoUrl && credentials.tursoToken) {
await window.electronAPI?.sync.configure({
tursoUrl: credentials.tursoUrl,
tursoAuthToken: credentials.tursoToken,
autoSync: true,
syncInterval: 5,
});
}
showToast.success('Credentials saved'); showToast.success('Credentials saved');
} catch (error) { } catch (error) {
console.error('Failed to save credentials:', error); console.error('Failed to save credentials:', error);
@@ -64,13 +50,9 @@ export const CredentialsPanel: React.FC = () => {
} }
}; };
const handleClear = (type: 'sync' | 'ftp' | 'ssh') => { const handleClear = (type: 'ftp' | 'ssh') => {
const newCreds = { ...credentials }; const newCreds = { ...credentials };
switch (type) { switch (type) {
case 'sync':
newCreds.tursoUrl = '';
newCreds.tursoToken = '';
break;
case 'ftp': case 'ftp':
newCreds.ftpHost = ''; newCreds.ftpHost = '';
newCreds.ftpUser = ''; newCreds.ftpUser = '';
@@ -85,31 +67,20 @@ export const CredentialsPanel: React.FC = () => {
setCredentials(newCreds); setCredentials(newCreds);
}; };
const handleTestConnection = async (type: 'sync' | 'ftp' | 'ssh') => { const handleTestConnection = async (type: 'ftp' | 'ssh') => {
showToast.loading(`Testing ${type.toUpperCase()} connection...`); showToast.loading(`Testing ${type.toUpperCase()} connection...`);
// Simulate connection test // Simulate connection test
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise(resolve => setTimeout(resolve, 1500));
// In a real implementation, this would test the actual connection // In a real implementation, this would test the actual connection
if (type === 'sync' && credentials.tursoUrl && credentials.tursoToken) { showToast.dismiss();
showToast.dismiss(); showToast.error('Connection failed - check credentials');
showToast.success('Sync connection successful');
} else {
showToast.dismiss();
showToast.error('Connection failed - check credentials');
}
}; };
return ( return (
<div className="credentials-panel"> <div className="credentials-panel">
<div className="credentials-tabs"> <div className="credentials-tabs">
<button
className={activeTab === 'sync' ? 'active' : ''}
onClick={() => setActiveTab('sync')}
>
Cloud Sync
</button>
<button <button
className={activeTab === 'ftp' ? 'active' : ''} className={activeTab === 'ftp' ? 'active' : ''}
onClick={() => setActiveTab('ftp')} onClick={() => setActiveTab('ftp')}
@@ -125,55 +96,6 @@ export const CredentialsPanel: React.FC = () => {
</div> </div>
<div className="credentials-content"> <div className="credentials-content">
{activeTab === 'sync' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>Turso/LibSQL Cloud Sync</h4>
<p className="text-muted">
Connect to Turso for cloud database synchronization.
</p>
</div>
<div className="credentials-field">
<label>Database URL</label>
<input
type="text"
placeholder="libsql://your-database.turso.io"
value={credentials.tursoUrl}
onChange={(e) => setCredentials({ ...credentials, tursoUrl: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>
Auth Token
<button
className="toggle-visibility"
onClick={() => setShowTokens(!showTokens)}
>
{showTokens ? '👁' : '👁‍🗨'}
</button>
</label>
<input
type={showTokens ? 'text' : 'password'}
placeholder="Your authentication token"
value={credentials.tursoToken}
onChange={(e) => setCredentials({ ...credentials, tursoToken: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button className="secondary" onClick={() => handleTestConnection('sync')}>
Test Connection
</button>
<button className="secondary danger" onClick={() => handleClear('sync')}>
Clear
</button>
</div>
</div>
)}
{activeTab === 'ftp' && ( {activeTab === 'ftp' && (
<div className="credentials-form"> <div className="credentials-form">
<div className="credentials-header"> <div className="credentials-header">

View File

@@ -17,9 +17,6 @@ export const scrollToSettingsSection = (category: SettingsCategory) => {
// Settings categories // Settings categories
interface Credentials { interface Credentials {
// Turso Cloud Sync
tursoUrl: string;
tursoToken: string;
// Dropbox File Sync // Dropbox File Sync
dropboxAccessToken: string; dropboxAccessToken: string;
dropboxAppKey: string; dropboxAppKey: string;
@@ -35,8 +32,6 @@ interface Credentials {
} }
const defaultCredentials: Credentials = { const defaultCredentials: Credentials = {
tursoUrl: '',
tursoToken: '',
dropboxAccessToken: '', dropboxAccessToken: '',
dropboxAppKey: '', dropboxAppKey: '',
dropboxRemotePath: '/blog', dropboxRemotePath: '/blog',
@@ -173,29 +168,6 @@ export const SettingsView: React.FC = () => {
loadSettings(); loadSettings();
}, []); }, []);
// Save credentials and configure backends
const handleSaveTurso = async () => {
try {
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
if (credentials.tursoUrl && credentials.tursoToken) {
await window.electronAPI?.sync.configure({
tursoUrl: credentials.tursoUrl,
tursoAuthToken: credentials.tursoToken,
autoSync: true,
syncInterval: 5,
});
useAppStore.getState().setSyncConfigured(true);
showToast.success('Cloud sync configured');
} else {
showToast.success('Credentials saved');
}
} catch (error) {
console.error('Failed to save Turso credentials:', error);
showToast.error('Failed to configure cloud sync');
}
};
const handleSaveDropbox = async () => { const handleSaveDropbox = async () => {
try { try {
localStorage.setItem('bds-credentials', JSON.stringify(credentials)); localStorage.setItem('bds-credentials', JSON.stringify(credentials));
@@ -227,14 +199,9 @@ export const SettingsView: React.FC = () => {
} }
}; };
const handleClearCredentials = (type: 'turso' | 'dropbox' | 'ftp' | 'ssh') => { const handleClearCredentials = (type: 'dropbox' | 'ftp' | 'ssh') => {
const newCreds = { ...credentials }; const newCreds = { ...credentials };
switch (type) { switch (type) {
case 'turso':
newCreds.tursoUrl = '';
newCreds.tursoToken = '';
useAppStore.getState().setSyncConfigured(false);
break;
case 'dropbox': case 'dropbox':
newCreds.dropboxAccessToken = ''; newCreds.dropboxAccessToken = '';
newCreds.dropboxAppKey = ''; newCreds.dropboxAppKey = '';
@@ -270,31 +237,19 @@ export const SettingsView: React.FC = () => {
} }
}; };
const handleTestConnection = async (type: 'turso' | 'dropbox') => { const handleTestDropboxConnection = async () => {
showToast.loading(`Testing ${type} connection...`); showToast.loading('Testing Dropbox connection...');
try { try {
if (type === 'turso') { const status = await window.electronAPI?.dropbox?.getStatus();
// Simulate connection test showToast.dismiss();
await new Promise(resolve => setTimeout(resolve, 1500)); if (status) {
if (credentials.tursoUrl && credentials.tursoToken) { showToast.success('Dropbox connection active');
showToast.dismiss();
showToast.success('Cloud sync connection successful');
} else {
showToast.dismiss();
showToast.error('Missing credentials');
}
} else { } else {
const status = await window.electronAPI?.dropbox?.getStatus(); showToast.error('Dropbox connection failed');
showToast.dismiss();
if (status) {
showToast.success('Dropbox connection active');
} else {
showToast.error('Dropbox connection failed');
}
} }
} catch { } catch {
showToast.dismiss(); showToast.dismiss();
showToast.error(`${type} connection failed`); showToast.error('Dropbox connection failed');
} }
}; };
@@ -321,7 +276,7 @@ export const SettingsView: React.FC = () => {
const projectKeywords = ['project', 'name', 'description', 'blog', 'site']; const projectKeywords = ['project', 'name', 'description', 'blog', 'site'];
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const syncKeywords = ['sync', 'turso', 'libsql', 'cloud', 'database', 'dropbox', 'file', 'backup', 'token', 'remote']; const syncKeywords = ['sync', 'dropbox', 'file', 'backup', 'token', 'remote'];
const publishingKeywords = ['publishing', 'ftp', 'ssh', 'deploy', 'server', 'host', 'upload']; const publishingKeywords = ['publishing', 'ftp', 'ssh', 'deploy', 'server', 'host', 'upload'];
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem']; const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem'];
@@ -485,68 +440,6 @@ export const SettingsView: React.FC = () => {
<> <>
<SettingSection <SettingSection
id="settings-section-sync" id="settings-section-sync"
title="Cloud Sync — Turso/LibSQL"
description="Sync post and media metadata to a Turso cloud database for backup and multi-device access."
hidden={!sectionHasMatches(syncKeywords)}
>
<SettingRow
id="turso-url"
label="Database URL"
description="The Turso/LibSQL database URL. Example: libsql://your-database.turso.io"
>
<input
id="turso-url"
type="text"
placeholder="libsql://your-database.turso.io"
value={credentials.tursoUrl}
onChange={(e) => setCredentials({ ...credentials, tursoUrl: e.target.value })}
/>
</SettingRow>
<SettingRow
id="turso-token"
label="Auth Token"
description="Your Turso database authentication token."
>
<div className="setting-input-group">
<input
id="turso-token"
type={showSecrets ? 'text' : 'password'}
placeholder="Your authentication token"
value={credentials.tursoToken}
onChange={(e) => setCredentials({ ...credentials, tursoToken: e.target.value })}
/>
<button
className="setting-toggle-visibility"
onClick={() => setShowSecrets(!showSecrets)}
title={showSecrets ? 'Hide secrets' : 'Show secrets'}
>
{showSecrets ? '🔒' : '👁'}
</button>
</div>
</SettingRow>
<div className="setting-actions">
<button className="primary" onClick={handleSaveTurso}>
{syncConfigured ? 'Update Configuration' : 'Enable Cloud Sync'}
</button>
<button className="secondary" onClick={() => handleTestConnection('turso')}>
Test Connection
</button>
<button className="secondary danger" onClick={() => handleClearCredentials('turso')}>
Clear
</button>
</div>
{syncConfigured && (
<div className="setting-status success">
<span className="status-icon"></span>
<span>Cloud sync is configured and active</span>
</div>
)}
</SettingSection>
<SettingSection
title="File Sync — Dropbox" title="File Sync — Dropbox"
description="Synchronize your blog files (posts and media) to Dropbox for backup and cross-device access." description="Synchronize your blog files (posts and media) to Dropbox for backup and cross-device access."
hidden={!sectionHasMatches(syncKeywords)} hidden={!sectionHasMatches(syncKeywords)}
@@ -606,7 +499,7 @@ export const SettingsView: React.FC = () => {
<button className="primary" onClick={handleSaveDropbox}> <button className="primary" onClick={handleSaveDropbox}>
{dropboxConfigured ? 'Update Configuration' : 'Enable Dropbox Sync'} {dropboxConfigured ? 'Update Configuration' : 'Enable Dropbox Sync'}
</button> </button>
<button className="secondary" onClick={() => handleTestConnection('dropbox')}> <button className="secondary" onClick={handleTestDropboxConnection}>
Test Connection Test Connection
</button> </button>
{dropboxConfigured && ( {dropboxConfigured && (

View File

@@ -71,8 +71,6 @@ export interface TaskProgress {
} }
export interface SyncConfig { export interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean; autoSync: boolean;
syncInterval: number; syncInterval: number;
} }

View File

@@ -66,6 +66,7 @@ vi.mock('../../src/main/database', () => ({
})), })),
initializeLocal: vi.fn(), initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}), initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(), close: vi.fn(),
})), })),
})); }));
@@ -450,4 +451,408 @@ describe('SyncEngine', () => {
expect(syncEngine.isConfigured()).toBe(true); expect(syncEngine.isConfigured()).toBe(true);
}); });
}); });
describe('Remote Schema Migration', () => {
it('should run migrations on remote database when initializing', async () => {
const { getDatabase } = await import('../../src/main/database');
const mockRunRemoteMigrations = vi.fn().mockResolvedValue(undefined);
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => mockLocalDb),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: mockRunRemoteMigrations,
close: vi.fn(),
} as any);
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
expect(mockRunRemoteMigrations).toHaveBeenCalled();
});
});
describe('Sync Timeout', () => {
it('should timeout if sync takes too long', async () => {
const { getDatabase } = await import('../../src/main/database');
// Create a mock that never resolves for remote operations
const hangingInsert = vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn(() => new Promise(() => {})), // Never resolves
})),
}));
const hangingRemoteDb = {
...mockRemoteDb,
insert: hangingInsert,
};
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
all: vi.fn().mockResolvedValue([{ id: 'test-post', title: 'Test', syncStatus: 'pending' }]),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => hangingRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
// Use real timers and set a short timeout before configuring
vi.useRealTimers();
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
// Set a short timeout for the test (100ms)
syncEngine.setSyncTimeout(100);
// This should timeout rather than hang forever
const result = await syncEngine.sync('push');
expect(result.success).toBe(false);
expect(result.errors.some((e: string) => e.includes('timeout') || e.includes('Timeout'))).toBe(true);
}, 15000); // Extend test timeout to 15 seconds
it('should have configurable timeout', () => {
expect(typeof syncEngine.getSyncTimeout).toBe('function');
expect(typeof syncEngine.setSyncTimeout).toBe('function');
});
});
describe('SyncStatus Reset on Failure', () => {
it('should reset syncStatus to idle when task manager throws', async () => {
const { getDatabase } = await import('../../src/main/database');
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => {
throw new Error('Database exploded');
}),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
// First sync should fail
await syncEngine.sync('push');
// Status should be reset to allow future syncs
expect(syncEngine.getSyncStatus()).not.toBe('syncing');
// A subsequent sync should not return "Sync already in progress"
const result = await syncEngine.sync('push');
expect(result.errors).not.toContain('Sync already in progress');
});
it('should reset syncStatus to error after failure', async () => {
const { getDatabase } = await import('../../src/main/database');
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => {
throw new Error('Database error');
}),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
await syncEngine.sync('push');
// After failure, status should be 'error' or 'idle', not 'syncing'
const status = syncEngine.getSyncStatus();
expect(status === 'error' || status === 'idle').toBe(true);
});
});
describe('Console Logging', () => {
it('should log sync start with direction', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
await syncEngine.sync('push');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[SyncEngine]'),
expect.stringContaining('push')
);
consoleSpy.mockRestore();
});
it('should log sync completion with results', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { getDatabase } = await import('../../src/main/database');
// Mock a complete sync with no pending items (so it completes quickly)
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
all: vi.fn().mockResolvedValue([]), // No pending items
})),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
await syncEngine.sync('push');
// Check that at least one log call contains "[SyncEngine]" and "complete"
const calls = consoleSpy.mock.calls;
const hasCompleteLog = calls.some((call: any[]) =>
call.some((arg: any) => typeof arg === 'string' && arg.includes('[SyncEngine]') && arg.includes('complete'))
);
expect(hasCompleteLog).toBe(true);
consoleSpy.mockRestore();
});
it('should log errors when sync fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { getDatabase } = await import('../../src/main/database');
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => {
throw new Error('Test error for logging');
}),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => mockRemoteDb),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
await syncEngine.sync('push');
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('[SyncEngine]'),
expect.anything()
);
consoleErrorSpy.mockRestore();
});
});
describe('Batch Size Configuration', () => {
it('should have configurable batch size', () => {
expect(typeof syncEngine.getBatchSize).toBe('function');
expect(typeof syncEngine.setBatchSize).toBe('function');
});
it('should default to 50 items per batch', () => {
expect(syncEngine.getBatchSize()).toBe(50);
});
it('should allow setting custom batch size', () => {
syncEngine.setBatchSize(100);
expect(syncEngine.getBatchSize()).toBe(100);
// Reset to default
syncEngine.setBatchSize(50);
});
it('should process posts in batches', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { getDatabase } = await import('../../src/main/database');
// Create 150 mock posts (should result in 3 batches with batch size 50)
const mockPendingPosts = Array.from({ length: 150 }, (_, i) => ({
id: `post-${i}`,
title: `Post ${i}`,
slug: `post-${i}`,
syncStatus: 'pending',
projectId: 'default',
createdAt: new Date(),
updatedAt: new Date(),
}));
let queryCount = 0;
vi.mocked(getDatabase).mockReturnValue({
getLocal: vi.fn(() => ({
...mockLocalDb,
select: vi.fn(() => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockImplementation((offset: number) => ({
all: vi.fn().mockImplementation(() => {
queryCount++;
const batch = mockPendingPosts.slice(offset, offset + 50);
return Promise.resolve(batch);
}),
})),
all: vi.fn().mockResolvedValue([]), // For media query
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => Promise.resolve()),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => Promise.resolve()),
})),
})),
getLocalClient: vi.fn(() => null),
getRemote: vi.fn(() => ({
...mockRemoteDb,
insert: vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn(() => Promise.resolve()),
})),
})),
})),
getDataPaths: vi.fn(() => ({
database: '/mock/userData/bds.db',
posts: '/mock/userData/posts',
media: '/mock/userData/media',
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
} as any);
const config: SyncConfig = {
tursoUrl: 'libsql://test.turso.io',
tursoAuthToken: 'test-token',
autoSync: false,
syncInterval: 30,
};
await syncEngine.configure(config);
syncEngine.setBatchSize(50);
await syncEngine.sync('push');
// Should have logged batch progress
const calls = consoleSpy.mock.calls;
const hasBatchLog = calls.some((call: any[]) =>
call.some((arg: any) => typeof arg === 'string' && arg.includes('batch'))
);
expect(hasBatchLog).toBe(true);
consoleSpy.mockRestore();
});
});
}); });

View File

@@ -48,10 +48,14 @@ describe('TaskManager', () => {
}); });
}); });
// Use delays between progress calls to work with the 250ms throttle
const task = createMockTask(async (onProgress) => { const task = createMockTask(async (onProgress) => {
onProgress(25, 'Step 1'); onProgress(25, 'Step 1');
await new Promise(r => setTimeout(r, 260));
onProgress(50, 'Step 2'); onProgress(50, 'Step 2');
await new Promise(r => setTimeout(r, 260));
onProgress(75, 'Step 3'); onProgress(75, 'Step 3');
await new Promise(r => setTimeout(r, 260));
onProgress(100, 'Complete'); onProgress(100, 'Complete');
}); });