feat: show persisted investment data and sync state

Consume the provided resource-envelope API snapshot. Preserve financial snapshots during
refresh, observe synchronization without importing, and fence late responses across
account and connection changes.
This commit is contained in:
2026-09-26 21:22:49 +03:00
parent b987fd8e78
commit 4e5f564fa1
81 changed files with 4714 additions and 1420 deletions
+47 -1
View File
@@ -14,6 +14,13 @@ type AuthHandlers = {
clear: () => void;
};
const inFlight = new Set<{ pathname: string; invalidated: boolean }>();
const latestReads = new Map<string, { sequence: number; pending: number }>();
function invalidateApiRequests(matches: (pathname: string) => boolean) {
for (const request of inFlight)
if (matches(request.pathname)) request.invalidated = true;
}
let auth: AuthHandlers | null = null;
let hasAuthenticatedSession = false;
const authSessionListeners = new Set<() => void>();
@@ -25,6 +32,7 @@ function setAuthenticatedSession(next: boolean) {
}
function configureAuthClient(handlers: AuthHandlers | null) {
if (!handlers) invalidateApiRequests(() => true);
auth = handlers;
setAuthenticatedSession(Boolean(auth?.accessToken()));
}
@@ -39,12 +47,47 @@ function subscribeAuthenticatedSession(listener: () => void) {
}
async function authenticatedFetch(request: Request): Promise<Response> {
const url = new URL(request.url);
url.searchParams.delete("sync");
url.searchParams.sort();
const key = `${request.method} ${url}`;
const ticket = { pathname: url.pathname, invalidated: false };
const read =
request.method === "GET"
? (latestReads.get(key) ?? { sequence: 0, pending: 0 })
: null;
if (read) {
read.sequence += 1;
read.pending += 1;
latestReads.set(key, read);
}
const sequence = read?.sequence;
inFlight.add(ticket);
try {
return await fetchCurrent(
request,
() => !ticket.invalidated && (!read || sequence === read.sequence),
);
} finally {
inFlight.delete(ticket);
if (read && --read.pending === 0) latestReads.delete(key);
}
}
async function fetchCurrent(
request: Request,
isLatest: () => boolean,
): Promise<Response> {
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
const session = auth;
const generation = session?.generation?.();
const isCurrent = () => generation === auth?.generation?.();
const isCurrent = () =>
(session?.generation
? generation === session.generation() &&
generation === auth?.generation?.()
: session === auth) && isLatest();
const token = session?.accessToken();
const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers);
@@ -55,6 +98,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetch(authorized);
await response.clone().arrayBuffer();
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (
@@ -78,6 +122,7 @@ async function authenticatedFetch(request: Request): Promise<Response> {
const refreshedToken = auth.accessToken();
if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken));
await retried.clone().arrayBuffer();
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried;
}
@@ -95,5 +140,6 @@ export {
apiQueryClient,
configureAuthClient,
getAuthenticatedSessionSnapshot,
invalidateApiRequests,
subscribeAuthenticatedSession,
};