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)
- **React** for the renderer UI
- **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
---
@@ -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
### 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
@@ -719,5 +732,5 @@ export type NewTableInsert = typeof newTable.$inferInsert;
- Never log sensitive data (auth tokens, passwords)
- Validate all IPC inputs before processing
- 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)

View File

@@ -7,17 +7,13 @@ 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>) {
@@ -25,8 +21,6 @@ export class DatabaseConnection {
this.config = {
localPath: config?.localPath || path.join(userDataPath, 'bds.db'),
tursoUrl: config?.tursoUrl,
tursoAuthToken: config?.tursoAuthToken,
};
// Ensure user data directory exists
@@ -64,38 +58,6 @@ export class DatabaseConnection {
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 {
if (!this.localDb) {
throw new Error('Local database not initialized. Call initializeLocal() first.');
@@ -103,10 +65,6 @@ export class DatabaseConnection {
return this.localDb;
}
getRemote(): DrizzleDB | null {
return this.remoteDb;
}
getLocalClient(): Client | null {
return this.localClient;
}
@@ -389,11 +347,6 @@ export class DatabaseConnection {
this.localClient = null;
this.localDb = null;
}
if (this.remoteClient) {
this.remoteClient.close();
this.remoteClient = null;
this.remoteDb = null;
}
}
getDataPaths() {

View File

@@ -1,19 +1,15 @@
import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import { eq, and } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { getDatabase } from '../database';
import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema';
import { taskManager, Task } from './TaskManager';
import { getPostEngine } from './PostEngine';
import { getMediaEngine } from './MediaEngine';
import { getDropboxSyncEngine } from './DropboxSyncEngine';
export type SyncDirection = 'push' | 'pull' | 'bidirectional';
export type SyncStatus = 'idle' | 'syncing' | 'error';
export interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
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 {
private syncStatus: SyncStatus = 'idle';
private syncConfig: SyncConfig | null = null;
private syncIntervalId: NodeJS.Timeout | null = null;
private syncTimeout: number = DEFAULT_SYNC_TIMEOUT;
constructor() {
super();
}
getSyncTimeout(): number {
return this.syncTimeout;
}
setSyncTimeout(timeoutMs: number): void {
this.syncTimeout = timeoutMs;
}
getSyncStatus(): SyncStatus {
return this.syncStatus;
}
/**
* Check if sync is configured. Uses Dropbox configuration status.
*/
isConfigured(): boolean {
return this.syncConfig !== null &&
!!this.syncConfig.tursoUrl &&
!!this.syncConfig.tursoAuthToken;
const dropboxEngine = getDropboxSyncEngine();
return dropboxEngine.isConfigured();
}
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);
}
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(
entityId: string,
entityType: 'post' | 'media',
@@ -324,38 +164,110 @@ export class SyncEngine extends EventEmitter {
}
/**
* Full sync: metadata via Turso + files via Dropbox.
* Coordinates both sync engines for a complete bidirectional sync.
* Full sync: Files via Dropbox.
* Synchronizes posts and media files to Dropbox for backup and cross-device access.
*/
async fullSync(direction: SyncDirection = 'bidirectional'): Promise<SyncResult> {
// Run metadata sync (Turso)
const metadataResult = await this.sync(direction);
// Run file sync (Dropbox) if configured
const dropboxEngine = getDropboxSyncEngine();
if (dropboxEngine.isConfigured()) {
try {
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}`);
}
if (this.syncStatus === 'syncing') {
return {
success: false,
pushed: 0,
pulled: 0,
conflicts: 0,
errors: ['Sync already in progress'],
};
}
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[]);
}
// Re-configure sync backends from saved credentials
// Re-configure Dropbox sync from saved credentials
const savedCreds = localStorage.getItem('bds-credentials');
if (savedCreds) {
try {
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) {
await window.electronAPI?.dropbox?.configure({
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();
setSyncConfigured(syncConfigured || false);

View File

@@ -3,8 +3,6 @@ import { showToast } from '../Toast';
import './CredentialsPanel.css';
interface Credentials {
tursoUrl: string;
tursoToken: string;
ftpHost?: string;
ftpUser?: string;
ftpPassword?: string;
@@ -15,8 +13,6 @@ interface Credentials {
export const CredentialsPanel: React.FC = () => {
const [credentials, setCredentials] = useState<Credentials>({
tursoUrl: '',
tursoToken: '',
ftpHost: '',
ftpUser: '',
ftpPassword: '',
@@ -24,7 +20,7 @@ export const CredentialsPanel: React.FC = () => {
sshUser: '',
sshKeyPath: '',
});
const [activeTab, setActiveTab] = useState<'sync' | 'ftp' | 'ssh'>('sync');
const [activeTab, setActiveTab] = useState<'ftp' | 'ssh'>('ftp');
const [showTokens, setShowTokens] = useState(false);
// 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)
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');
} catch (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 };
switch (type) {
case 'sync':
newCreds.tursoUrl = '';
newCreds.tursoToken = '';
break;
case 'ftp':
newCreds.ftpHost = '';
newCreds.ftpUser = '';
@@ -85,31 +67,20 @@ export const CredentialsPanel: React.FC = () => {
setCredentials(newCreds);
};
const handleTestConnection = async (type: 'sync' | 'ftp' | 'ssh') => {
const handleTestConnection = async (type: 'ftp' | 'ssh') => {
showToast.loading(`Testing ${type.toUpperCase()} connection...`);
// Simulate connection test
await new Promise(resolve => setTimeout(resolve, 1500));
// In a real implementation, this would test the actual connection
if (type === 'sync' && credentials.tursoUrl && credentials.tursoToken) {
showToast.dismiss();
showToast.success('Sync connection successful');
} else {
showToast.dismiss();
showToast.error('Connection failed - check credentials');
}
showToast.dismiss();
showToast.error('Connection failed - check credentials');
};
return (
<div className="credentials-panel">
<div className="credentials-tabs">
<button
className={activeTab === 'sync' ? 'active' : ''}
onClick={() => setActiveTab('sync')}
>
Cloud Sync
</button>
<button
className={activeTab === 'ftp' ? 'active' : ''}
onClick={() => setActiveTab('ftp')}
@@ -125,55 +96,6 @@ export const CredentialsPanel: React.FC = () => {
</div>
<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' && (
<div className="credentials-form">
<div className="credentials-header">

View File

@@ -17,9 +17,6 @@ export const scrollToSettingsSection = (category: SettingsCategory) => {
// Settings categories
interface Credentials {
// Turso Cloud Sync
tursoUrl: string;
tursoToken: string;
// Dropbox File Sync
dropboxAccessToken: string;
dropboxAppKey: string;
@@ -35,8 +32,6 @@ interface Credentials {
}
const defaultCredentials: Credentials = {
tursoUrl: '',
tursoToken: '',
dropboxAccessToken: '',
dropboxAppKey: '',
dropboxRemotePath: '/blog',
@@ -173,29 +168,6 @@ export const SettingsView: React.FC = () => {
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 () => {
try {
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 };
switch (type) {
case 'turso':
newCreds.tursoUrl = '';
newCreds.tursoToken = '';
useAppStore.getState().setSyncConfigured(false);
break;
case 'dropbox':
newCreds.dropboxAccessToken = '';
newCreds.dropboxAppKey = '';
@@ -270,31 +237,19 @@ export const SettingsView: React.FC = () => {
}
};
const handleTestConnection = async (type: 'turso' | 'dropbox') => {
showToast.loading(`Testing ${type} connection...`);
const handleTestDropboxConnection = async () => {
showToast.loading('Testing Dropbox connection...');
try {
if (type === 'turso') {
// Simulate connection test
await new Promise(resolve => setTimeout(resolve, 1500));
if (credentials.tursoUrl && credentials.tursoToken) {
showToast.dismiss();
showToast.success('Cloud sync connection successful');
} else {
showToast.dismiss();
showToast.error('Missing credentials');
}
const status = await window.electronAPI?.dropbox?.getStatus();
showToast.dismiss();
if (status) {
showToast.success('Dropbox connection active');
} else {
const status = await window.electronAPI?.dropbox?.getStatus();
showToast.dismiss();
if (status) {
showToast.success('Dropbox connection active');
} else {
showToast.error('Dropbox connection failed');
}
showToast.error('Dropbox connection failed');
}
} catch {
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 editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
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 dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'links', 'folder', 'filesystem'];
@@ -485,68 +440,6 @@ export const SettingsView: React.FC = () => {
<>
<SettingSection
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"
description="Synchronize your blog files (posts and media) to Dropbox for backup and cross-device access."
hidden={!sectionHasMatches(syncKeywords)}
@@ -606,7 +499,7 @@ export const SettingsView: React.FC = () => {
<button className="primary" onClick={handleSaveDropbox}>
{dropboxConfigured ? 'Update Configuration' : 'Enable Dropbox Sync'}
</button>
<button className="secondary" onClick={() => handleTestConnection('dropbox')}>
<button className="secondary" onClick={handleTestDropboxConnection}>
Test Connection
</button>
{dropboxConfigured && (

View File

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

View File

@@ -66,6 +66,7 @@ vi.mock('../../src/main/database', () => ({
})),
initializeLocal: vi.fn(),
initializeRemote: vi.fn(async () => {}),
runRemoteMigrations: vi.fn(async () => {}),
close: vi.fn(),
})),
}));
@@ -450,4 +451,408 @@ describe('SyncEngine', () => {
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) => {
onProgress(25, 'Step 1');
await new Promise(r => setTimeout(r, 260));
onProgress(50, 'Step 2');
await new Promise(r => setTimeout(r, 260));
onProgress(75, 'Step 3');
await new Promise(r => setTimeout(r, 260));
onProgress(100, 'Complete');
});