Ka-Note/ka-note/client/src/lib/sync/syncService.ts

118 lines
3.9 KiB
TypeScript

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<SyncStatus>('idle');
export const lastSyncAt = writable<Date | null>(
(() => {
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<Response> {
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<string> {
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<void> {
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<void> {
await doSync(null);
}
export async function sync(): Promise<void> {
let since: Date | null = null;
lastSyncAt.subscribe((v) => (since = v))();
await doSync(since);
}
async function doSync(since: Date | null): Promise<void> {
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');
}
}
}