APP-Backend / src /services /sync-service.js
Luca448's picture
Fix syntax error in sync-service.js
a563c57
Raw History Blame Contribute Delete
14.4 kB
import { supabase } from './supabase-client.js';
import { authService } from './auth-service.js';
import { cryptoService } from './crypto-service.js';
import { db } from '../db/database.js';
class SyncManager {
constructor() {
this.isSyncing = false;
this.lastSyncTime = 0;
this.lastUploadedSettings = {}; // Cache to prevent redundant uploads
// Listen for auth state changes
authService.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
// If we log in and the crypto is unlocked, we can trigger sync
if (authService.isCryptoUnlocked()) {
this.sync();
}
}
});
}
async initialize() {
// Called once on app startup to trigger initial sync if already logged in
if (authService.user && authService.isCryptoUnlocked()) {
this.sync();
// Auto-Sync every 15 seconds
setInterval(() => {
if (authService.user && authService.isCryptoUnlocked()) {
this.sync();
}
}, 15000);
}
}
async sync() {
if (!authService.user || !authService.isCryptoUnlocked() || this.isSyncing) return;
this.isSyncing = true;
const userId = authService.user.id;
console.log('[Sync] Starting Sync...');
try {
let hasUpdates = false;
if (await this.syncSettings(userId)) hasUpdates = true;
if (await this.syncConversations(userId)) hasUpdates = true;
if (await this.syncMessages(userId)) hasUpdates = true;
if (await this.syncFlashcardDecks(userId)) hasUpdates = true;
if (await this.syncFlashcards(userId)) hasUpdates = true;
console.log('[Sync] Sync completed successfully.');
if (hasUpdates) {
console.log('[Sync] New remote changes found, dispatching event.');
window.dispatchEvent(new CustomEvent('cloud_sync_updated'));
}
this.lastSyncTime = Date.now();
} catch (e) {
console.error('[Sync] Error during sync:', e);
} finally {
this.isSyncing = false;
}
}
async syncSettings(userId) {
// Local to Remote
const localSettings = db.exec("SELECT key, value FROM settings");
// Remote to Local
const { data: remoteSettings, error } = await supabase
.from('settings')
.select('*')
.eq('user_id', userId);
if (error) throw error;
const remoteSettingsMap = {};
if (remoteSettings) {
for (const row of remoteSettings) {
remoteSettingsMap[row.key] = await cryptoService.decrypt(row.value);
}
}
// Check if local settings is effectively empty (only defaults, no api keys)
const hasLocalApiKey = localSettings.some(r => r.key.includes('api_key') && r.value && r.value.trim().length > 0);
const hasRemoteApiKey = Object.keys(remoteSettingsMap).some(k => k.includes('api_key') && remoteSettingsMap[k] && remoteSettingsMap[k].trim().length > 0);
// If local has no API keys, but remote has some, we assume this is a NEW login on a new device.
// In this case, we DO NOT push our local defaults to remote. We just pull from remote.
if (!hasLocalApiKey && hasRemoteApiKey) {
for (const key of Object.keys(remoteSettingsMap)) {
if (remoteSettingsMap[key]) {
db.setSetting(key, remoteSettingsMap[key]);
}
}
return; // Skip pushing to remote
}
// Otherwise, push local to remote
for (const row of localSettings) {
if (this.lastUploadedSettings[row.key] === row.value) continue; // Skip redundant uploads
const encryptedValue = await cryptoService.encrypt(row.value);
await supabase.from('settings').upsert({
user_id: userId,
key: row.key,
value: encryptedValue,
updated_at: new Date().toISOString()
}, { onConflict: 'user_id, key' });
this.lastUploadedSettings[row.key] = row.value; // Cache it
}
let updated = false;
// Pull any setting from remote that isn't locally set (e.g. from another device)
if (remoteSettings) {
for (const row of remoteSettings) {
const localVal = db.getSetting(row.key);
if (!localVal) {
const decrypted = await cryptoService.decrypt(row.value);
if (decrypted) {
db.setSetting(row.key, decrypted);
updated = true;
}
}
}
}
return updated;
}
async syncConversations(userId) {
const localConvs = db.getConversations();
const { data: remoteConvs, error } = await supabase
.from('conversations')
.select('*')
.eq('user_id', userId);
if (error) throw error;
// Create map for fast lookup
const remoteMap = {};
if (remoteConvs) {
for (const c of remoteConvs) remoteMap[c.id] = c;
}
for (const local of localConvs) {
const remote = remoteMap[local.id];
const localTime = new Date(local.updated_at).getTime();
const remoteTime = remote ? new Date(remote.updated_at).getTime() : 0;
// Push to remote if local is newer or doesn't exist on remote
if (!remote || localTime > remoteTime) {
await supabase.from('conversations').upsert({
id: local.id,
user_id: userId,
title: await cryptoService.encrypt(local.title),
emoji: await cryptoService.encrypt(local.emoji),
preview: await cryptoService.encrypt(local.preview),
model_used: local.model_used,
created_at: local.created_at,
updated_at: local.updated_at
}, { onConflict: 'id, user_id' });
}
}
let updated = false;
// Pull from remote if remote is newer or doesn't exist locally
if (remoteConvs) {
for (const remote of remoteConvs) {
const local = localConvs.find(c => c.id === remote.id);
const remoteTime = new Date(remote.updated_at).getTime();
const localTime = local ? new Date(local.updated_at).getTime() : 0;
if (!local || remoteTime > localTime) {
const title = await cryptoService.decrypt(remote.title);
const emoji = await cryptoService.decrypt(remote.emoji);
const preview = await cryptoService.decrypt(remote.preview);
if (!local) {
db.exec(
"INSERT INTO conversations (id, title, emoji, preview, model_used, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
[remote.id, title, emoji, preview, remote.model_used, remote.created_at, remote.updated_at]
);
} else {
db.exec(
"UPDATE conversations SET title = ?, emoji = ?, preview = ?, model_used = ?, updated_at = ? WHERE id = ?",
[title, emoji, preview, remote.model_used, remote.updated_at, remote.id]
);
}
updated = true;
}
}
}
return updated;
}
async syncMessages(userId) {
const localMsgs = db.exec("SELECT * FROM messages");
// Because messages can be large, we might only want to fetch IDs to see what's missing
const { data: remoteMsgs, error } = await supabase
.from('messages')
.select('id, created_at')
.eq('user_id', userId);
if (error) throw error;
const remoteIds = new Set((remoteMsgs || []).map(m => m.id));
const localIds = new Set(localMsgs.map(m => m.id));
// Push local to remote
for (const local of localMsgs) {
if (!remoteIds.has(local.id)) {
await supabase.from('messages').upsert({
id: local.id,
conversation_id: local.conversation_id,
user_id: userId,
role: local.role,
content: await cryptoService.encrypt(local.content),
model_used: local.model_used,
style_used: local.style_used,
created_at: local.created_at
}, { onConflict: 'id, user_id' });
}
}
let updated = false;
// Pull remote to local
for (const remote of (remoteMsgs || [])) {
if (!localIds.has(remote.id)) {
const { data: remoteFull } = await supabase
.from('messages')
.select('*')
.eq('id', remote.id)
.single();
if (remoteFull) {
const content = await cryptoService.decrypt(remoteFull.content);
db.exec(
"INSERT INTO messages (id, conversation_id, role, content, model_used, style_used, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
[remoteFull.id, remoteFull.conversation_id, remoteFull.role, content, remoteFull.model_used, remoteFull.style_used, remoteFull.created_at]
);
updated = true;
}
}
}
return updated;
}
async syncFlashcardDecks(userId) {
const localDecks = db.getDecks();
const { data: remoteDecks, error } = await supabase
.from('flashcard_decks')
.select('*')
.eq('user_id', userId);
if (error) {
console.error("[Sync] Error fetching decks", error);
throw error;
}
const remoteMap = {};
if (remoteDecks) {
for (const c of remoteDecks) remoteMap[c.id] = c;
}
for (const local of localDecks) {
const remote = remoteMap[local.id];
const localTime = new Date(local.updated_at).getTime();
const remoteTime = remote ? new Date(remote.updated_at).getTime() : 0;
if (!remote || localTime > remoteTime) {
await supabase.from('flashcard_decks').upsert({
id: local.id,
user_id: userId,
title: await cryptoService.encrypt(local.title),
description: await cryptoService.encrypt(local.description || ''),
color: local.color,
source_lang: local.source_lang,
target_lang: local.target_lang,
created_at: local.created_at,
updated_at: local.updated_at
}, { onConflict: 'id, user_id' });
}
}
let updated = false;
if (remoteDecks) {
for (const remote of remoteDecks) {
const local = localDecks.find(c => c.id === remote.id);
const remoteTime = new Date(remote.updated_at).getTime();
const localTime = local ? new Date(local.updated_at).getTime() : 0;
if (!local || remoteTime > localTime) {
const title = await cryptoService.decrypt(remote.title);
const description = await cryptoService.decrypt(remote.description);
if (!local) {
db.exec(
"INSERT INTO flashcard_decks (id, title, description, color, source_lang, target_lang, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[remote.id, title, description, remote.color, remote.source_lang, remote.target_lang, remote.created_at, remote.updated_at]
);
} else {
db.exec(
"UPDATE flashcard_decks SET title = ?, description = ?, color = ?, source_lang = ?, target_lang = ?, updated_at = ? WHERE id = ?",
[title, description, remote.color, remote.source_lang, remote.target_lang, remote.updated_at, remote.id]
);
}
updated = true;
}
}
}
return updated;
}
async syncFlashcards(userId) {
const localCards = db.exec("SELECT * FROM flashcards");
const { data: remoteCardsRaw, error } = await supabase
.from('flashcards')
.select('id, updated_at')
.eq('user_id', userId);
if (error) {
console.error("[Sync] Error fetching flashcards", error);
throw error;
}
const remoteMapRaw = {};
for (const r of (remoteCardsRaw || [])) {
remoteMapRaw[r.id] = r;
}
for (const local of localCards) {
const remoteRaw = remoteMapRaw[local.id];
const localTime = new Date(local.updated_at).getTime();
const remoteTime = remoteRaw ? new Date(remoteRaw.updated_at).getTime() : 0;
if (!remoteRaw || localTime > remoteTime) {
await supabase.from('flashcards').upsert({
id: local.id,
user_id: userId,
deck_id: local.deck_id,
front: await cryptoService.encrypt(local.front),
back: await cryptoService.encrypt(local.back),
image_url: local.image_url ? await cryptoService.encrypt(local.image_url) : null,
next_review: local.next_review,
ease_factor: local.ease_factor,
interval: local.interval,
repetitions: local.repetitions,
created_at: local.created_at,
updated_at: local.updated_at
}, { onConflict: 'id, user_id' });
}
}
let updated = false;
for (const remoteRaw of (remoteCardsRaw || [])) {
const local = localCards.find(c => c.id === remoteRaw.id);
const remoteTime = new Date(remoteRaw.updated_at).getTime();
const localTime = local ? new Date(local.updated_at).getTime() : 0;
if (!local || remoteTime > localTime) {
const { data: remoteFull } = await supabase
.from('flashcards')
.select('*')
.eq('id', remoteRaw.id)
.single();
if (remoteFull) {
const front = await cryptoService.decrypt(remoteFull.front);
const back = await cryptoService.decrypt(remoteFull.back);
const image_url = remoteFull.image_url ? await cryptoService.decrypt(remoteFull.image_url) : null;
if (!local) {
db.exec(
"INSERT INTO flashcards (id, deck_id, front, back, image_url, next_review, ease_factor, interval, repetitions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[remoteFull.id, remoteFull.deck_id, front, back, image_url, remoteFull.next_review, remoteFull.ease_factor, remoteFull.interval, remoteFull.repetitions, remoteFull.created_at, remoteFull.updated_at]
);
} else {
db.exec(
"UPDATE flashcards SET front = ?, back = ?, image_url = ?, next_review = ?, ease_factor = ?, interval = ?, repetitions = ?, updated_at = ? WHERE id = ?",
[front, back, image_url, remoteFull.next_review, remoteFull.ease_factor, remoteFull.interval, remoteFull.repetitions, remoteFull.updated_at, remoteFull.id]
);
}
updated = true;
}
}
}
return updated;
}
}
export const syncManager = new SyncManager();