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)
|
||||
- **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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
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 {
|
||||
tursoUrl: string;
|
||||
tursoAuthToken: string;
|
||||
autoSync: boolean;
|
||||
syncInterval: number;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user