import { writable } from 'svelte/store'; import { db } from '$lib/db/schema'; import { getAccessToken } from '$lib/auth/authStore'; import type { SyncPushRequest, SyncPullResponse } from '@ka-note/shared'; export type SyncStatus = 'idle' | 'syncing' | 'error'; export const syncStatus = writable('idle'); export const lastSyncAt = writable( (() => { const stored = localStorage.getItem('lastSyncAt'); return stored ? new Date(stored) : null; })() ); const API_BASE = import.meta.env.VITE_API_URL ?? ''; async function apiFetch(path: string, init: RequestInit): Promise { const token = await getAccessToken(); try { const payload = JSON.parse(atob(token.split('.')[1])); console.log('[sync] token aud:', payload.aud, '| scp:', payload.scp, '| exp:', new Date(payload.exp * 1000).toISOString()); } catch { /* ignore */ } return fetch(`${API_BASE}${path}`, { ...init, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(init.headers ?? {}), }, }); } async function pullAndMerge(since: Date | null): Promise { const params = since ? `?since=${encodeURIComponent(since.toISOString())}` : ''; const res = await apiFetch(`/api/sync/pull${params}`, { method: 'GET' }); if (!res.ok) throw new Error(`Pull failed: ${res.status}`); const data: SyncPullResponse = await res.json(); const { contexts, topics, historyEntries, ratings } = data.changes; await db.transaction('rw', [db.contexts, db.topics, db.historyEntries, db.ratings], async () => { for (const serverCtx of contexts) { const local = await db.contexts.get(serverCtx.id); if (!local || serverCtx.version > local.version) { await db.contexts.put(serverCtx); } } for (const serverTopic of topics) { const local = await db.topics.get(serverTopic.id); if (!local || serverTopic.version > local.version) { await db.topics.put(serverTopic); } } for (const serverHe of historyEntries) { const local = await db.historyEntries.get(serverHe.id); if (!local || serverHe.version > local.version) { await db.historyEntries.put(serverHe); } } for (const serverRating of ratings) { const local = await db.ratings.get(serverRating.id); if (!local || serverRating.version > local.version) { await db.ratings.put(serverRating); } } }); return data.serverTimestamp; } async function pushAll(): Promise { const [contexts, topics, historyEntries, ratings] = await Promise.all([ db.contexts.toArray(), db.topics.toArray(), db.historyEntries.toArray(), db.ratings.toArray(), ]); const body: SyncPushRequest = { changes: { contexts, topics, historyEntries, ratings } }; const res = await apiFetch('/api/sync/push', { method: 'POST', body: JSON.stringify(body) }); if (!res.ok) throw new Error(`Push failed: ${res.status}`); } export async function fullSync(): Promise { await doSync(null); } export async function sync(): Promise { let since: Date | null = null; lastSyncAt.subscribe((v) => (since = v))(); await doSync(since); } async function doSync(since: Date | null): Promise { syncStatus.set('syncing'); try { await pushAll(); const serverTimestamp = await pullAndMerge(since); const syncTime = new Date(serverTimestamp); lastSyncAt.set(syncTime); localStorage.setItem('lastSyncAt', syncTime.toISOString()); syncStatus.set('idle'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (err instanceof TypeError && (msg === 'Failed to fetch' || msg.includes('fetch'))) { // Offline or server unreachable — stay idle silently syncStatus.set('idle'); } else if (msg === 'Redirecting for token' || msg.includes('no_account_in_silent_request')) { // Token expired, silent refresh not possible — skip, don't redirect from background syncStatus.set('idle'); } else { console.error('[sync]', err); syncStatus.set('error'); } } }