118 lines
3.9 KiB
TypeScript
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');
|
|
}
|
|
}
|
|
}
|