Files
mcma-webui/src/store/rtkqPersist.ts
T
Senko-san 231887c3b7 feat(discover): wire A4 search + A5 downloads to backend
Adds DownloadJob/ExternalSearchResult/SourceInfo contract types + mappers, the downloads + search RTKQ endpoints, and the SearchDownloadPage (search external sources, per-result download states) and DownloadsManagerPage (active/history, progress, retry/cancel, poll-while-active). en/ru i18n. Snapshot also bundles in-progress queue/metadata-editor/storage UI work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:04:43 +03:00

91 lines
3.0 KiB
TypeScript

/*
* Tier 2 offline support: persist the RTK Query cache (last-seen server data —
* library/albums/artists/…) to the active backend's localStorage namespace and
* replay it into the cache on startup. With the backend down, components keep
* rendering the last-known data read-only instead of an error state.
*
* Per the architecture invariant, server data is NOT copied into a slice: this
* snapshots the RTKQ cache itself and feeds it back through RTKQ's own
* `extractRehydrationInfo` mechanism (see `api/index.ts`).
*/
import { instanceStorage } from '../config/instances';
import { rehydrateApi, type RehydrateApiPayload } from '../api/rehydrate';
import type { RootState } from './index';
const CACHE_KEY = 'rtkq';
type ApiState = RootState['api'];
type QueryEntry = ApiState['queries'][string];
/**
* Keep only successfully-fulfilled query results — pending/rejected entries
* carry no usable data and subscriptions are rebuilt by components on mount.
* Mutation results are never restored.
*/
const EMPTY_PROVIDED = { tags: {}, keys: {} };
function snapshot(apiState: ApiState): RehydrateApiPayload {
const queries: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(apiState.queries)) {
const q = entry as QueryEntry | undefined;
if (q && q.status === 'fulfilled') queries[key] = q;
}
// Carry `provided` along so RTKQ can re-register invalidation tags for the
// restored entries; it is also required structurally (see RehydrateApiPayload).
return {
queries,
mutations: {},
provided: apiState.provided ?? EMPTY_PROVIDED,
};
}
function load(): RehydrateApiPayload | null {
try {
const raw = instanceStorage.get(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
if (!parsed.queries) return null;
// `provided` may be absent in snapshots written before this field existed —
// default it so the invalidation slice doesn't crash on `provided.tags`.
return {
queries: parsed.queries,
mutations: {},
provided: parsed.provided ?? EMPTY_PROVIDED,
};
} catch {
return null;
}
}
/** Replay the persisted cache into RTKQ. Call once after the store is created. */
export function rehydrateApiCache(dispatch: (action: unknown) => void): void {
const cached = load();
if (cached) dispatch(rehydrateApi(cached));
}
/**
* Subscribe a store so the RTKQ cache is flushed to localStorage. Throttled,
* since cache state churns on every in-flight query transition.
*/
export function startApiPersistence(store: {
getState: () => RootState;
subscribe: (listener: () => void) => () => void;
}): () => void {
let last = '';
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const snap = JSON.stringify(snapshot(store.getState().api));
if (snap !== last) {
instanceStorage.set(CACHE_KEY, snap);
last = snap;
}
};
return store.subscribe(() => {
if (timer) return;
timer = setTimeout(flush, 2000);
});
}