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>
123 lines
4.0 KiB
TypeScript
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();
|
|
}
|