import { writable, get } from 'svelte/store'; import type { PushResult, ServerNote, SyncState } from '../types'; import { notes, settings, cursor, setCursor, applyServerNotes, markPushed, replaceWithServer } from '../db/store'; export const syncState = writable('local'); export const lastSyncAt = writable(null); export const syncError = writable(''); let editTimer: ReturnType | null = null; let interval: ReturnType | null = null; let running = false; let queued = false; function configured(): boolean { const s = get(settings); return !!s.serverUrl; } function api(path: string, init: RequestInit = {}): Promise { const s = get(settings); const base = s.serverUrl.replace(/\/+$/, ''); return fetch(base + path, { ...init, headers: { ...(init.headers || {}), Authorization: 'Bearer ' + s.token, 'Content-Type': 'application/json', }, }); } async function pull(): Promise { for (;;) { const since = get(cursor); const res = await api(`/api/v1/changes?since=${since}&limit=500`); if (!res.ok) throw new Error(`pull: HTTP ${res.status}`); const body: { notes: ServerNote[]; cursor: number; more: boolean } = await res.json(); await applyServerNotes(body.notes); if (body.cursor > since) await setCursor(body.cursor); if (!body.more) break; } } async function push(): Promise { const dirty = [...get(notes).values()].filter((n) => n.dirty); if (!dirty.length) return; const payload = dirty.map((n) => ({ id: n.id, content: n.content, tags: n.tags, created_at: n.created_at, modified_at: n.modified_at, deleted: n.deleted, baseVersion: n.baseVersion, })); const res = await api('/api/v1/notes/batch', { method: 'POST', body: JSON.stringify({ notes: payload }) }); if (!res.ok) throw new Error(`push: HTTP ${res.status}`); const body: { results: PushResult[] } = await res.json(); const sentAt = new Map(dirty.map((n) => [n.id, n.modified_at])); for (const r of body.results) { if (r.status === 'accepted' && r.version) { await markPushed(r.id, sentAt.get(r.id) ?? 0, r.version); } else if (r.status === 'conflict' && r.serverNote) { await replaceWithServer(r.serverNote); // conflict copy arrives on next pull } } } /** Pull-then-push; safe to interrupt at any point (SPEC §4). */ export async function syncNow(): Promise { if (!configured()) { syncState.set('local'); return; } if (running) { queued = true; return; } running = true; syncState.set('syncing'); try { await pull(); await push(); await pull(); // pick up conflict copies + versions created by our own push syncState.set(navigator.onLine ? 'synced' : 'offline'); lastSyncAt.set(Date.now()); syncError.set(''); } catch (e) { syncState.set(navigator.onLine ? 'error' : 'offline'); syncError.set(e instanceof Error ? e.message : String(e)); } finally { running = false; if (queued) { queued = false; void syncNow(); } } } /** Debounced trigger: sync 3 s after the last edit (SPEC §4 client loop). */ export function notifyEdit(): void { if (!configured()) return; if (editTimer) clearTimeout(editTimer); editTimer = setTimeout(() => void syncNow(), 3000); } export function startSync(): void { window.addEventListener('online', () => void syncNow()); window.addEventListener('offline', () => syncState.set('offline')); if (interval) clearInterval(interval); interval = setInterval(() => void syncNow(), 60_000); void syncNow(); }