Files
timelog/src/client/sync.ts
T

58 lines
2.1 KiB
TypeScript
Raw Normal View History

import type { TimeRecord } from "../shared/record.ts";
import { allRecords, applyRemote, cursor, pendingOperations, removeOperations } from "./store.ts";
export type SyncStatus = "offline" | "syncing" | "synced" | "pending" | "signed-out";
let current: Promise<void> | null = null;
let rerunRequested = false;
class SignedOut extends Error {}
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
if (response.status === 401) throw new SignedOut();
if (!response.ok) throw new Error(`Sync failed (${response.status})`);
return response.json() as Promise<T>;
}
export function syncNow(onChange: (status: SyncStatus, records?: TimeRecord[]) => void): Promise<void> {
if (current) {
rerunRequested = true;
return current;
}
current = (async () => {
if (!navigator.onLine) { onChange("offline"); return; }
onChange("syncing");
try {
let pending = await pendingOperations();
while (pending.length) {
const batch = pending.slice(0, 200);
const body = await api<{ accepted: string[] }>("/api/operations", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ operations: batch.map(({ id, record }) => ({ id, record })) }),
});
await removeOperations(body.accepted);
pending = await pendingOperations();
}
let since = await cursor();
for (;;) {
const body = await api<{ records: TimeRecord[]; cursor: number; hasMore: boolean }>(
`/api/state?since=${since}`, { cache: "no-store" });
await applyRemote(body.records, body.cursor);
since = body.cursor;
if (!body.hasMore) break;
}
onChange("synced", await allRecords());
} catch (error) {
if (error instanceof SignedOut) { onChange("signed-out"); return; }
console.error(error);
onChange("pending");
}
})().finally(() => {
current = null;
if (rerunRequested) {
rerunRequested = false;
void syncNow(onChange);
}
});
return current;
}