mercury-web/src/api/hooks/sync.ts

129 lines
4.8 KiB
TypeScript

"use client";
import { useCallback, useRef, useState } from "react";
import { z } from "zod";
import { apiGet, apiSend, ApiError } from "../client";
import { queryClient } from "../queryClient";
import { keys } from "../keys";
// --- wire schemas (kept local to this hook, per scope) ---
const JobRefSchema = z.object({ bank: z.string(), jobId: z.number() });
const LockedRefSchema = z.object({ bank: z.string(), retryAfterSeconds: z.number() });
// Note: fields below are `.optional()` rather than `.default(...)` — apiGet's
// `z.ZodType<T>` param type unifies output/input into one T, and zod's
// ZodDefault input type (optional) leaks into T when it's inferred that way.
// Optional + a `?? fallback` at each call site keeps the inferred type honest.
const SyncResponseSchema = z.object({
jobs: z.array(JobRefSchema).optional(),
locked: z.array(LockedRefSchema).optional(),
});
const SyncJobSchema = z.object({
id: z.number(),
bank: z.string(),
status: z.enum(["pending", "done", "error"]),
newCount: z.number().optional(),
error: z.string().optional(),
});
type SyncJob = z.infer<typeof SyncJobSchema>;
export interface SyncError {
bank: string;
message: string;
}
export interface SyncResult {
/** True when POST /sync came back 400 "no banks connected" — nothing was enqueued. */
noBanksConnected: boolean;
/** Sum of new_count across every completed job. */
newCount: number;
/** Jobs that finished with status "error" (or timed out still pending). */
errors: SyncError[];
/** Banks the server declined to enqueue this round (already syncing). */
locked: { bank: string; retryAfterSeconds: number }[];
}
const POLL_INTERVAL_MS = 1500;
const MAX_POLLS = 40; // ~60s ceiling
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Polls one sync job until it leaves "pending", capped at MAX_POLLS attempts.
* A job still pending at the cap is surfaced to the caller as an error (rather
* than hanging the UI indefinitely) so a stuck bank sync can't spin forever. */
export async function pollJobUntilDone(jobId: number): Promise<SyncJob> {
let last: SyncJob | null = null;
for (let i = 0; i < MAX_POLLS; i++) {
last = await apiGet(`/sync/jobs/${jobId}`, SyncJobSchema);
if (last.status !== "pending") return last;
if (i < MAX_POLLS - 1) await sleep(POLL_INTERVAL_MS);
}
return last ?? { id: jobId, bank: "", status: "error" as const, newCount: 0, error: "timeout" };
}
/** Invalidates every read query the dashboard/pages depend on so a completed
* sync's fresh data (new transactions, updated balances/budget/etc.) shows up
* without a manual reload. */
function invalidateAllReads() {
queryClient.invalidateQueries({ queryKey: keys.networth });
queryClient.invalidateQueries({ queryKey: ["transactions"] });
queryClient.invalidateQueries({ queryKey: ["analyze"] }); // covers both analyzeMonth + analyzeToday
queryClient.invalidateQueries({ queryKey: keys.budget });
queryClient.invalidateQueries({ queryKey: keys.connections });
queryClient.invalidateQueries({ queryKey: keys.manualAssets });
queryClient.invalidateQueries({ queryKey: keys.subscriptions });
queryClient.invalidateQueries({ queryKey: keys.categories });
queryClient.invalidateQueries({ queryKey: keys.me });
}
/** Triggers a server-side bank sync (POST /sync), polls every returned job to
* completion, then refreshes all read queries. See internal/transport/api/sync_handlers.go
* for the backend contract this mirrors. */
export function useSync() {
const [isSyncing, setIsSyncing] = useState(false);
const [result, setResult] = useState<SyncResult | null>(null);
const runningRef = useRef(false);
const sync = useCallback(async () => {
if (runningRef.current) return;
runningRef.current = true;
setIsSyncing(true);
setResult(null);
try {
let res: z.infer<typeof SyncResponseSchema>;
try {
res = (await apiSend("POST", "/sync", undefined, SyncResponseSchema)) as z.infer<
typeof SyncResponseSchema
>;
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
setResult({ noBanksConnected: true, newCount: 0, errors: [], locked: [] });
return;
}
throw err;
}
const jobs = res.jobs ?? [];
const jobResults = await Promise.all(jobs.map((j) => pollJobUntilDone(j.jobId)));
let newCount = 0;
const errors: SyncError[] = [];
for (const j of jobResults) {
if (j.status === "error") errors.push({ bank: j.bank, message: j.error || "sync_failed" });
else newCount += j.newCount ?? 0;
}
invalidateAllReads();
setResult({ noBanksConnected: false, newCount, errors, locked: res.locked ?? [] });
} finally {
runningRef.current = false;
setIsSyncing(false);
}
}, []);
return { sync, isSyncing, result };
}