All checks were successful
Demo images / Build & push demo images (push) Successful in 3m28s
Part B1+B2 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md. Foundation for pages
that render instantly from cache on revisit and revalidate in the
background, instead of unmount-refetch-spinner on every navigation.
- stores/resources.ts: keyed {data, loadState, fetchedAt, error} entries
with sticky-ready (never regress ready→loading), keep-last-value on
error, per-key in-flight dedup, sessionStorage snapshot hydrate,
debounced invalidate() fan-out, optimistic-update-with-rollback
- composables/useCachedResource.ts: SWR hook over the store — synchronous
hydrate, TTL-gated background revalidate, revalidate-on-focus,
abort-on-unmount fetcher signal
- rpc-client: AbortSignal support (aborts pending retries too), opt-in
in-flight dedup keyed method+params, per-call maxRetries override
- 10 tests covering the SWR semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
// Shared cache for RPC-backed page data (the "stale-while-revalidate" layer).
|
|
//
|
|
// Pages used to fetch-on-mount with a spinner on every navigation — Dashboard
|
|
// keys its router-view by route.path, so each visit unmounted and refetched
|
|
// everything. This store is the single place resource state lives instead:
|
|
// keyed entries survive navigation (Pinia) and reloads (sessionStorage
|
|
// snapshot), and `useCachedResource` renders them instantly while
|
|
// revalidating in the background.
|
|
//
|
|
// Semantics (generalized from homeStatus.ts / useFleetData.ts, the proven
|
|
// hand-rolled versions):
|
|
// - sticky-ready: once a key is 'ready' it never regresses to 'loading';
|
|
// refreshes show as 'refreshing' so the UI keeps the data visible.
|
|
// - keep-last-known-value on error: a failed revalidate leaves data in place
|
|
// (with `error` set and `fetchedAt` untouched → age badge shows staleness).
|
|
// - in-flight dedup per key: concurrent refreshes collapse into one fetch.
|
|
|
|
import { defineStore } from 'pinia'
|
|
import { reactive } from 'vue'
|
|
|
|
export type ResourceLoadState = 'idle' | 'loading' | 'ready' | 'refreshing' | 'error'
|
|
|
|
export interface ResourceEntry<T = unknown> {
|
|
data: T | null
|
|
loadState: ResourceLoadState
|
|
/** Epoch ms of the last SUCCESSFUL fetch (drives TTL + stale badges). */
|
|
fetchedAt: number | null
|
|
error: string | null
|
|
}
|
|
|
|
const SNAPSHOT_PREFIX = 'resource:'
|
|
|
|
function readSnapshot<T>(key: string): { data: T; fetchedAt: number } | null {
|
|
try {
|
|
const raw = sessionStorage.getItem(SNAPSHOT_PREFIX + key)
|
|
if (!raw) return null
|
|
const parsed = JSON.parse(raw)
|
|
if (parsed && typeof parsed.fetchedAt === 'number' && 'data' in parsed) return parsed
|
|
} catch {
|
|
/* corrupt/absent snapshot — fall through to a fresh fetch */
|
|
}
|
|
return null
|
|
}
|
|
|
|
function writeSnapshot(key: string, data: unknown, fetchedAt: number): void {
|
|
try {
|
|
sessionStorage.setItem(SNAPSHOT_PREFIX + key, JSON.stringify({ data, fetchedAt }))
|
|
} catch {
|
|
/* quota exceeded or unserializable — memory cache still works */
|
|
}
|
|
}
|
|
|
|
export const useResourcesStore = defineStore('resources', () => {
|
|
const entries = reactive(new Map<string, ResourceEntry>())
|
|
// Non-reactive bookkeeping: in-flight fetches + active revalidators.
|
|
const inflight = new Map<string, Promise<void>>()
|
|
const revalidators = new Map<string, Set<() => void>>()
|
|
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
|
|
/** Get (or create) the reactive entry for a key, hydrating from the
|
|
* sessionStorage snapshot on first sight so revisits after a reload paint
|
|
* before any RPC completes. Pass `persist: false` to skip snapshots. */
|
|
function entry<T>(key: string, persist = true): ResourceEntry<T> {
|
|
let e = entries.get(key)
|
|
if (!e) {
|
|
const snap = persist ? readSnapshot<T>(key) : null
|
|
e = reactive<ResourceEntry>({
|
|
data: snap ? snap.data : null,
|
|
loadState: snap ? 'ready' : 'idle',
|
|
fetchedAt: snap ? snap.fetchedAt : null,
|
|
error: null,
|
|
})
|
|
entries.set(key, e)
|
|
}
|
|
return e as ResourceEntry<T>
|
|
}
|
|
|
|
/** Run `fetcher` for `key` with sticky-ready + keep-last-value semantics.
|
|
* Concurrent calls for the same key share one in-flight fetch. */
|
|
function refresh<T>(
|
|
key: string,
|
|
fetcher: () => Promise<T>,
|
|
opts: { persist?: boolean } = {},
|
|
): Promise<void> {
|
|
const existing = inflight.get(key)
|
|
if (existing) return existing
|
|
const e = entry<T>(key, opts.persist ?? true)
|
|
e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading'
|
|
const p = (async () => {
|
|
try {
|
|
const data = await fetcher()
|
|
e.data = data
|
|
e.error = null
|
|
e.fetchedAt = Date.now()
|
|
e.loadState = 'ready'
|
|
if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt)
|
|
} catch (err) {
|
|
e.error = err instanceof Error ? err.message : String(err)
|
|
// Keep last-known data visible; only 'error' when we have nothing.
|
|
e.loadState = e.data !== null ? 'ready' : 'error'
|
|
} finally {
|
|
inflight.delete(key)
|
|
}
|
|
})()
|
|
inflight.set(key, p)
|
|
return p
|
|
}
|
|
|
|
/** Mark a key stale and (debounced) re-run every mounted subscriber's
|
|
* fetcher. Call after a mutation or on a relevant WS push. */
|
|
function invalidate(key: string, opts: { debounceMs?: number } = {}): void {
|
|
const e = entries.get(key)
|
|
if (e) e.fetchedAt = null
|
|
const subs = revalidators.get(key)
|
|
if (!subs || subs.size === 0) return
|
|
const t = invalidateTimers.get(key)
|
|
if (t) clearTimeout(t)
|
|
invalidateTimers.set(
|
|
key,
|
|
setTimeout(() => {
|
|
invalidateTimers.delete(key)
|
|
for (const fn of subs) fn()
|
|
}, opts.debounceMs ?? 800),
|
|
)
|
|
}
|
|
|
|
/** Register a live revalidator for a key (used by useCachedResource);
|
|
* returns an unsubscribe fn. */
|
|
function subscribe(key: string, revalidate: () => void): () => void {
|
|
let subs = revalidators.get(key)
|
|
if (!subs) {
|
|
subs = new Set()
|
|
revalidators.set(key, subs)
|
|
}
|
|
subs.add(revalidate)
|
|
return () => {
|
|
subs.delete(revalidate)
|
|
}
|
|
}
|
|
|
|
/** Optimistically apply `update` to the cached value; returns a rollback.
|
|
* Pattern: rollback on RPC failure (generalized TransportPrefsCard). */
|
|
function optimistic<T>(key: string, update: (current: T | null) => T): () => void {
|
|
const e = entry<T>(key)
|
|
const before = e.data
|
|
const beforeState = e.loadState
|
|
e.data = update(before)
|
|
if (e.loadState === 'idle' || e.loadState === 'error') e.loadState = 'ready'
|
|
return () => {
|
|
e.data = before
|
|
e.loadState = beforeState
|
|
}
|
|
}
|
|
|
|
/** Drop a key entirely (memory + snapshot). */
|
|
function evict(key: string): void {
|
|
entries.delete(key)
|
|
try {
|
|
sessionStorage.removeItem(SNAPSHOT_PREFIX + key)
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
|
|
return { entries, entry, refresh, invalidate, subscribe, optimistic, evict }
|
|
})
|