Files
tefter/frontend/src/sync/engine.ts
Senad c389bab46e
All checks were successful
ci / test (push) Successful in 3m8s
ci / release (push) Successful in 11m21s
Add version history with rollback and per-note created/synced info
Server (schema v2, auto-migrates): every accepted overwrite snapshots the
superseded revision into note_history (capped at 50 per note); new
GET /api/v1/notes/{id}/history endpoint; compact purges orphaned history.

Client: notes get a synced_at stamp on every confirmed server exchange;
an info footer under the editor and a Ctrl/Cmd+I panel show created/
modified/last-synced plus the revision list. Restoring a revision applies
it as a normal edit through the sync path, so rollback is non-destructive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 22:20:19 +02:00

123 lines
4.0 KiB
TypeScript

import { writable, get } from 'svelte/store';
import type { PushResult, Revision, ServerNote, SyncState } from '../types';
import { notes, settings, cursor, setCursor, applyServerNotes, markPushed, replaceWithServer } from '../db/store';
export const syncState = writable<SyncState>('local');
export const lastSyncAt = writable<number | null>(null);
export const syncError = writable<string>('');
let editTimer: ReturnType<typeof setTimeout> | null = null;
let interval: ReturnType<typeof setInterval> | 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<Response> {
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<void> {
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<void> {
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
}
}
}
/** Fetch a note's server-side revision history (newest first). */
export async function fetchHistory(id: string): Promise<Revision[]> {
if (!configured()) throw new Error('no server configured');
const res = await api(`/api/v1/notes/${encodeURIComponent(id)}/history`);
if (!res.ok) throw new Error(`history: HTTP ${res.status}`);
const body: { revisions: Revision[] } = await res.json();
return body.revisions;
}
/** Pull-then-push; safe to interrupt at any point (SPEC §4). */
export async function syncNow(): Promise<void> {
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();
}