broken: halfway through removing turso
This commit is contained in:
19
.github/copilot-instructions.md
vendored
19
.github/copilot-instructions.md
vendored
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
2
src/renderer/types/electron.d.ts
vendored
2
src/renderer/types/electron.d.ts
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user