desktop: one read registry for every read button: single-flight per canonical key, broad reads cover narrow keys, overlapping buttons all busy and disabled (useReadState), 20 s 'read just now' memo, quota pause on the buttons, every read through the scheduler (Check again, Import apps, account Test, Read the stores, Dependencies), writes invalidate only the reads they change; tests/read-registry.test.ts; fixtures ?blocked and window.__fakeCalls

This commit is contained in:
CRTkafa 2026-10-23 11:09:51 +08:00
parent 7345de2f7c
commit c03938f9dc
14 changed files with 861 additions and 164 deletions

View file

@ -2,7 +2,7 @@
// overrides, settings, the sidecar, the portfolio (offline check + the last live read from the cache), app icons, and
// background jobs shown as toasts with progress and cancel.
import { askPreview } from './ui/DryRunPreview';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import {
type Account, type AccountCheck, api, type AppInfo, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, errorText, newJob, onDownload,
onProgress, onTrayAction, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult,
@ -17,6 +17,7 @@ import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/
import { keepLines, progressOf } from './lib/progress';
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
import { accountCheckKey, affectedReads, appTags, IMPORT_ALL_KEY, importKey, methodOfKey, PORTFOLIO_CHECK_KEY, PORTFOLIO_KEY, READ_ALL_KEY, type ReadQuery, ReadRegistry, type ReadStatus } from './lib/read-registry';
import { fixHint } from './lib/views';
import type { Route } from './nav';
import { withLastGood } from '../../../cli/lib/train';
@ -90,8 +91,11 @@ export interface Data {
icons: Record<string, string | null>;
/** Background reader: progress and the App Store Connect quota seen this hour. */
reader: ReaderState;
/** Runs one read through the scheduler (deduped, rate limited); the result also goes to the read bus. */
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority) => Promise<ToolResult>;
/** Runs one read through the read registry and the scheduler (single-flight, rate limited, "read just now" memo
* unless `force`); the result also goes to the read bus. */
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority, force?: boolean) => Promise<ToolResult>;
/** The one read registry (lib/read-registry.ts): every read button asks it through useReadState. */
registry: ReadRegistry;
/** Listens to one read's bus key. */
onRead: (key: string, fn: (e: ReadEvent) => void) => () => void;
isReading: (key: string) => boolean;
@ -101,8 +105,8 @@ export interface Data {
muteUntil: number;
/** Mutes notifications for `minutes` (0 = unmute). */
setMute: (minutes: number) => void;
/** After a write: the app's reads are read again. */
invalidate: (target: string) => void;
/** After a write: the reads of the app that `writeMethod` changed are read again (all of them without one). */
invalidate: (target: string, writeMethod?: string) => void;
/** The app on screen (its reads go first). */
setFocus: (key: string | null) => void;
/** True when the read succeeded. */
@ -127,6 +131,8 @@ export interface Data {
}
const Ctx = createContext<Data | null>(null);
/** The read registry on its own context (DataProvider fills it), so read buttons and tests need only this. */
export const RegistryCtx = createContext<ReadRegistry | null>(null);
export const useData = (): Data => {
const d = useContext(Ctx);
if (!d) throw new Error('useData outside DataProvider');
@ -137,6 +143,9 @@ const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at(
/** A failure line with how to fix it, when the error is a known one. */
const withHint = (msg: string | undefined): string | undefined => { const h = fixHint(msg); return h && msg ? `${msg} · ${t(h)}` : msg; };
/** The scheduler credential of a store account. */
const credOf = (a: Account): string => `${a.kind === 'play' ? 'play' : 'apple'}:${a.id}`;
const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play');
/** Title and body of the notification of one release change (by kind; a rejection adds the store's reason when it has one). */
export function releaseText(c: ReleaseChange): [string, string] {
@ -164,9 +173,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
const [sidecarError, setSidecarError] = useState<string | null>(null);
const [portfolio, setPortfolio] = useState<PortfolioResult | null>(null);
const [live, setLive] = useState<CacheEntry<PortfolioResult> | null>(null);
const [portfolioBusy, setBusy] = useState<Data['portfolioBusy']>(null);
const [portfolioError, setPortfolioError] = useState<string | null>(null);
const [importBusy, setImportBusy] = useState(false);
const [icons, setIcons] = useState<Record<string, string | null>>({});
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
@ -240,42 +247,64 @@ export function DataProvider({ children }: { children: ReactNode }) {
const appsRef = useRef(apps);
appsRef.current = apps;
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false): Promise<boolean> => {
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
try {
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
let r = await api.portfolio(liveRead, job, stores);
// a store that failed this time keeps its last good releases ("couldn't refresh"), never turns empty
const prevLive = liveRef.current;
if (liveRead && prevLive) r = { ...r, apps: r.apps.map(a => withLastGood(prevLive.value.apps.find(x => x.project === a.project), a, prevLive.at)) };
setPortfolio(r);
if (liveRead) {
const changes = releaseChanges(liveRef.current?.value, r);
setLive({ value: r, at: r.at });
for (const c of changes.slice(0, 5)) {
const [title, body] = releaseText(c);
const app = appsRef.current.find(a => a.target === c.project);
notice(c.kind, title, body, app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined);
}
const failed = r.apps.filter(a => a.storeErrors.ios || a.storeErrors.android).length;
if (tid) updateToast(tid, failed ? { kind: 'info', title: t('toast.readDonePartial'), detail: t('toast.readDonePartialDetail', { count: failed }), progress: undefined, job: undefined }
: { kind: 'ok', title: t('toast.readDone'), detail: t('toast.readDoneDetail', { count: r.apps.length }), progress: undefined, job: undefined });
}
} catch (e) {
const msg = errorText(e);
setPortfolioError(msg);
if (tid) updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailed'), detail: msg, progress: undefined, job: undefined });
return false;
} finally { setBusy(null); }
return true;
}, [toast, updateToast, notice]);
// ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ----
// ---- reads: one scheduler (lib/scheduler.ts) under one read registry (lib/read-registry.ts) for every read of the
// window: buttons, notices, cards, the background reader, the tray and Ctrl+K. Same key = one host call (joined);
// a broad read covers its narrow keys; every button renders busy from the registry (useReadState), never its own flag.
const schedRef = useRef<ReadScheduler | null>(null);
if (!schedRef.current) schedRef.current = new ReadScheduler({ classify: classifyRead, limits: { apple: settings.refresh.parallel, play: settings.refresh.parallel } });
const sched = schedRef.current;
const bus = useRef(new Map<string, Set<(e: ReadEvent) => void>>());
const emitRead = useCallback((key: string, e: ReadEvent) => { for (const f of bus.current.get(key) ?? []) { try { f(e); } catch { /* a card's listener never breaks the bus */ } } }, []);
const regRef = useRef<ReadRegistry | null>(null);
if (!regRef.current) regRef.current = new ReadRegistry(sched, {
onEvent: (key, e) => emitRead(key, e.kind === 'start' ? e : e.kind === 'done' ? { kind: 'done', result: e.result as ToolResult } : { kind: 'error', error: errorText(e.error) }),
});
const reg = regRef.current;
// the provider's derived busy flags (portfolioBusy, importBusy) follow the registry
const regVer = useSyncExternalStore(reg.subscribe, reg.version, reg.version);
const portfolioBusy: Data['portfolioBusy'] = useMemo(() => (reg.state(PORTFOLIO_KEY).busy ? 'live' : reg.state(PORTFOLIO_CHECK_KEY).busy ? 'offline' : null), [reg, regVer]); // eslint-disable-line react-hooks/exhaustive-deps
const importBusy = useMemo(() => reg.state({ tags: ['import'] }).busy, [reg, regVer]); // eslint-disable-line react-hooks/exhaustive-deps
const accountsRef = useRef(accounts);
accountsRef.current = accounts;
const refreshPortfolio = useCallback((liveRead = false, quiet = false): Promise<boolean> => {
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
const run = async (): Promise<boolean> => {
setPortfolioError(null);
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
try {
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
let r = await api.portfolio(liveRead, job, stores);
// a store that failed this time keeps its last good releases ("couldn't refresh"), never turns empty
const prevLive = liveRef.current;
if (liveRead && prevLive) r = { ...r, apps: r.apps.map(a => withLastGood(prevLive.value.apps.find(x => x.project === a.project), a, prevLive.at)) };
setPortfolio(r);
if (liveRead) {
const changes = releaseChanges(liveRef.current?.value, r);
setLive({ value: r, at: r.at });
for (const c of changes.slice(0, 5)) {
const [title, body] = releaseText(c);
const app = appsRef.current.find(a => a.target === c.project);
notice(c.kind, title, body, app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined);
}
const failed = r.apps.filter(a => a.storeErrors.ios || a.storeErrors.android).length;
if (tid) updateToast(tid, failed ? { kind: 'info', title: t('toast.readDonePartial'), detail: t('toast.readDonePartialDetail', { count: failed }), progress: undefined, job: undefined }
: { kind: 'ok', title: t('toast.readDone'), detail: t('toast.readDoneDetail', { count: r.apps.length }), progress: undefined, job: undefined });
}
} catch (e) {
const msg = errorText(e);
setPortfolioError(msg);
if (tid) updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailed'), detail: msg, progress: undefined, job: undefined });
return false;
}
return true;
};
// the live read uses every store account (their quota pauses show on its buttons); the offline check is local
return reg.read<boolean>(liveRead
? { key: PORTFOLIO_KEY, tags: ['all', 'portfolio'], lanes: ['apple', 'play'], accounts: accountsRef.current.map(credOf), priority: quiet ? 1 : 0, run, cancel: () => { api.cancel(job).catch(() => {}); } }
: { key: PORTFOLIO_CHECK_KEY, tags: ['portfolio-check'], lanes: ['local'], accounts: [], priority: 0, run }).catch(() => false);
}, [reg, toast, updateToast, notice]);
const [reader, setReader] = useState<SchedulerStats>(() => sched.stats());
useEffect(() => {
let tm: ReturnType<typeof setTimeout> | null = null;
@ -283,14 +312,12 @@ export function DataProvider({ children }: { children: ReactNode }) {
return () => { un(); if (tm) clearTimeout(tm); };
}, [sched]);
useEffect(() => { sched.setLimits({ apple: settings.refresh.parallel, play: settings.refresh.parallel }); }, [sched, settings.refresh.parallel]);
const bus = useRef(new Map<string, Set<(e: ReadEvent) => void>>());
const emitRead = useCallback((key: string, e: ReadEvent) => { for (const f of bus.current.get(key) ?? []) { try { f(e); } catch { /* a card's listener never breaks the bus */ } } }, []);
const onRead = useCallback((key: string, fn: (e: ReadEvent) => void) => {
const set = bus.current.get(key) ?? new Set();
set.add(fn); bus.current.set(key, set);
return () => { set.delete(fn); if (!set.size) bus.current.delete(key); };
}, []);
const isReading = useCallback((key: string) => sched.has(key), [sched]);
const isReading = useCallback((key: string) => reg.active(key), [reg]);
const focusRef = useRef<string | null>(null);
const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []);
@ -303,101 +330,124 @@ export function DataProvider({ children }: { children: ReactNode }) {
const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined);
const lastData = useRef(new Map<string, unknown>());
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority): Promise<ToolResult> => {
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority, force = false): Promise<ToolResult> => {
const key = readKey(target, method, args);
const app = appsRef.current.find(a => a.target === target);
const pf = platformsOf(app, app ? statusRef.current(app) : undefined);
const lanes = lanesOf(method, pf);
// reviews and health: compared with the previous read here (not in a card), so the tray app notices them with no window open
const watch = method === 'reviews.list' || method === 'health';
const job = newJob(method);
const run = async (): Promise<ToolResult> => {
const before = watch ? (lastData.current.has(key) ? lastData.current.get(key) : (await api.cached<ToolResult>(method, target, args).catch(() => null))?.value.data ?? null) : null;
const result = await api.call<ToolResult>(method, target, { args }, newJob(method));
const result = await api.call<ToolResult>(method, target, { args }, job);
if (watch && usable(method, result)) {
notifyAfterRead(method, before, result.data, notice, app?.name, app?.key);
lastData.current.set(key, result.data);
}
return result;
};
const p = sched.enqueue<ToolResult>({ key, lanes, accounts: accountsOf(app, lanes), priority, run });
emitRead(key, { kind: 'start' });
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
return p;
}, [sched, emitRead, notice]);
return reg.read<ToolResult>({ key, tags: appTags(target), lanes, accounts: accountsOf(app, lanes), priority, run, cancel: () => { api.cancel(job).catch(() => {}); } }, { force });
}, [reg, notice]);
/**
* Reads everything past its TTL. The portfolio read is a LIVE one (refreshPortfolio(true)): it is what releaseChanges
* compares, so a rejection is seen even when no screen asked. Resolves when that read is done: false when it failed.
* Refresh all (and the background pass): one group read that covers the portfolio and every app read it plans, so
* every button and card of those keys shows busy at once and a click on any of them joins. Single-flight: a second
* Refresh all, the tray's "Read now" or a pass while one runs joins it. Without `force` only reads past their TTL run.
* Resolves when everything it started is done: false when the portfolio read failed.
*/
const readAll = useCallback(async (force = false): Promise<boolean> => {
if (!force && !settingsRef.current.refresh.autoRead) return true;
let ok = true;
let portfolioRead: Promise<void> | null = null;
const pkey = '*\u001fportfolio.status';
if (!sched.has(pkey) && (force || isStale('portfolio.status', liveRef.current?.at))) {
portfolioRead = sched.enqueue<boolean>({ key: pkey, lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) })
.then(r => { ok = r !== false; }, () => { ok = false; });
}
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
for (const a of list) {
const pf = platformsOf(a, statusRef.current(a));
for (const r of readsFor({ folder: !!a.project, ...pf })) {
const key = readKey(a.target, r.method, r.args);
if (sched.has(key)) continue;
if (!force) {
const c = await api.cached<ToolResult>(r.method, a.target, r.args).catch(() => null);
if (c && !isStale(r.method, c.at)) continue;
}
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
}
}
await portfolioRead;
return ok;
}, [sched, schedule, refreshPortfolio]);
const planned = list.flatMap(a => readsFor({ folder: !!a.project, ...platformsOf(a, statusRef.current(a)) }).map(r => ({ a, r, key: readKey(a.target, r.method, r.args) })));
return reg.group<boolean>({
key: READ_ALL_KEY, tags: ['all'], covers: [PORTFOLIO_KEY, ...planned.map(p => p.key)],
run: async g => {
const waits: Promise<unknown>[] = [];
let portfolioRead: Promise<boolean> | null = null;
g.release(PORTFOLIO_KEY);
if (force || isStale('portfolio.status', liveRef.current?.at)) portfolioRead = refreshPortfolio(true, true);
// the TTL checks of every key at once (cache reads, local), then the reads that are due, in priority order
const due = await Promise.all(planned.map(async p => {
if (force || reg.active(p.key)) return true;
const c = await api.cached<ToolResult>(p.r.method, p.a.target, p.r.args).catch(() => null);
return !(c && !isStale(p.r.method, c.at));
}));
planned.forEach((p, i) => {
g.release(p.key);
if (due[i]) waits.push(schedule(p.a.target, p.r.method, p.r.args, p.a.key === focusRef.current ? 0 : 2).catch(() => {}));
});
const ok = portfolioRead ? await portfolioRead : true;
await Promise.allSettled(waits);
return ok;
},
}).catch(() => false);
}, [reg, schedule, refreshPortfolio]);
const invalidate = useCallback((target: string) => {
/** After a write: only the reads it changed (lib/read-registry.ts affectedReads) lose their memo and read again. */
const invalidate = useCallback((target: string, writeMethod?: string) => {
const aff = writeMethod ? affectedReads(writeMethod) : { methods: null, portfolio: true };
const pre = `${target}\u001f`;
for (const k of [...bus.current.keys()]) if (k.startsWith(pre)) emitRead(k, { kind: 'invalidate' });
const hits = (k: string) => k.startsWith(pre) && (!aff.methods || aff.methods.includes(methodOfKey(k)));
reg.invalidate(k => hits(k) || (aff.portfolio && (k === PORTFOLIO_KEY || k === PORTFOLIO_CHECK_KEY)));
for (const k of [...bus.current.keys()]) if (hits(k)) emitRead(k, { kind: 'invalidate' });
const a = appsRef.current.find(x => x.target === target);
if (a) for (const r of readsFor({ folder: !!a.project, ...platformsOf(a, statusRef.current(a)) })) {
const key = readKey(target, r.method, r.args);
if (!bus.current.has(key)) schedule(target, r.method, r.args, 1).catch(() => {});
if (hits(key) && !bus.current.has(key)) schedule(target, r.method, r.args, 1, true).catch(() => {});
}
}, [emitRead, schedule]);
}, [reg, emitRead, schedule]);
const importApps = useCallback(async (account?: string) => {
/** Import apps: one read per account (key importKey); "all" is a group that covers them, so an account's own
* Import button and the page's Import apps are busy together and never read an account twice. */
const importApps = useCallback(async (account?: string): Promise<void> => {
const ids = (account ? [account] : accounts.map(a => a.id)).filter(id => account || !overrides.ignored.includes(`account:${id}`));
if (!ids.length) return;
const job = newJob('import');
setImportBusy(true);
const tid = toast({ id: job, kind: 'job', title: t('toast.importing'), detail: t('toast.importingDetail'), progress: { done: 0, total: ids.length }, job });
let ok = 0, apps = 0;
const errors: string[] = [];
for (const [n, id] of ids.entries()) {
try {
const e = await api.refreshStoreApps(id, job);
setStoreApps(m => ({ ...m, [id]: e }));
ok++; apps += e.value.apps.length;
} catch (e) {
const msg = errorText(e);
if (/cancel/i.test(msg)) { updateToast(tid, { kind: 'info', title: t('toast.cancelled'), detail: undefined, progress: undefined, job: undefined }); setImportBusy(false); return; }
errors.push(`${accounts.find(a => a.id === id)?.label ?? id}: ${msg}`);
}
updateToast(tid, { progress: { done: n + 1, total: ids.length } });
}
setImportBusy(false);
updateToast(tid, errors.length ? { kind: ok ? 'info' : 'bad', title: t('toast.importPartial', { count: errors.length }), detail: errors[0], progress: undefined, job: undefined }
: { kind: 'ok', title: t('toast.importDone', { count: apps }), detail: t('toast.importDoneDetail', { count: ok }), progress: undefined, job: undefined });
}, [accounts, overrides.ignored, toast, updateToast]);
const readOne = (id: string) => {
const acc = accountsRef.current.find(a => a.id === id);
const cred = acc ? credOf(acc) : `apple:${id}`;
return reg.read<CacheEntry<AppsListResult>>({ key: importKey(id), tags: ['import', `account:${id}`], lanes: [cred.startsWith('play:') ? 'play' : 'apple'], accounts: [cred], priority: 0,
run: () => api.refreshStoreApps(id, job).then(e => { setStoreApps(m => ({ ...m, [id]: e })); return e; }), cancel: () => { api.cancel(job).catch(() => {}); } });
};
// one account whose import is already running (alone or inside Import all): join it, no second toast
if (account && reg.active(importKey(account))) { await readOne(account).catch(() => {}); return; }
const body = async (g?: { release: (k: string) => void }) => {
const tid = toast({ id: job, kind: 'job', title: t('toast.importing'), detail: t('toast.importingDetail'), progress: { done: 0, total: ids.length }, job });
let ok = 0, apps = 0, n = 0, cancelled = false;
const errors: string[] = [];
await Promise.all(ids.map(async id => {
g?.release(importKey(id));
try { const e = await readOne(id); ok++; apps += e.value.apps.length; } catch (e) {
const msg = errorText(e);
if (/cancel/i.test(msg)) cancelled = true;
else errors.push(`${accountsRef.current.find(a => a.id === id)?.label ?? id}: ${msg}`);
}
updateToast(tid, { progress: { done: ++n, total: ids.length } });
}));
if (cancelled) { updateToast(tid, { kind: 'info', title: t('toast.cancelled'), detail: undefined, progress: undefined, job: undefined }); return; }
updateToast(tid, errors.length ? { kind: ok ? 'info' : 'bad', title: t('toast.importPartial', { count: errors.length }), detail: errors[0], progress: undefined, job: undefined }
: { kind: 'ok', title: t('toast.importDone', { count: apps }), detail: t('toast.importDoneDetail', { count: ok }), progress: undefined, job: undefined });
};
if (account) { await body(); return; }
await reg.group({ key: IMPORT_ALL_KEY, tags: ['import'], covers: ids.map(importKey), run: g => body(g) }).catch(() => {});
}, [reg, accounts, overrides.ignored, toast, updateToast]);
/** "Test" of one account: single-flight per account (Test all and the row's Test join). */
const checkAccount = useCallback(async (id: string) => {
try {
const check = await api.checkAccount(id, newJob('check'));
setAccountHealth(m => ({ ...m, [id]: { check, at: new Date().toISOString() } }));
} catch (e) { setAccountHealth(m => ({ ...m, [id]: { error: errorText(e), at: new Date().toISOString() } })); }
}, []);
const acc = accountsRef.current.find(a => a.id === id);
const cred = acc ? credOf(acc) : `apple:${id}`;
const job = newJob('check');
await reg.read({ key: accountCheckKey(id), tags: ['check', `account:${id}`], lanes: [cred.startsWith('play:') ? 'play' : 'apple'], accounts: [cred], priority: 0,
run: async () => {
try {
const check = await api.checkAccount(id, job);
setAccountHealth(m => ({ ...m, [id]: { check, at: new Date().toISOString() } }));
} catch (e) { setAccountHealth(m => ({ ...m, [id]: { error: errorText(e), at: new Date().toISOString() } })); }
return null;
} }, { force: true }).catch(() => {});
}, [reg]);
const updateOverrides = useCallback(async (fn: (o: Overrides) => Overrides) => {
const next = fn(overrides);
setOverrides(next);
@ -534,13 +584,13 @@ export function DataProvider({ children }: { children: ReactNode }) {
const value = useMemo<Data>(() => ({
ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
importBusy, icons, reader: readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus,
importBusy, icons, reader: readerState, muteUntil, setMute, schedule, registry: reg, onRead, isReading, readAll, invalidate, setFocus,
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
toasts, toast, updateToast, dismiss, cancelJob,
}), [readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
toasts, toast, updateToast, dismiss, cancelJob]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
toasts, toast, updateToast, dismiss, cancelJob, reg]);
return <Ctx.Provider value={value}><RegistryCtx.Provider value={reg}>{children}</RegistryCtx.Provider></Ctx.Provider>;
}
export interface ReadState<T> {
@ -554,6 +604,10 @@ export interface ReadState<T> {
error: string | null;
/** The command line it ran (shown with errors). */
command: string | null;
/** Finished less than a few seconds ago: a click answers from it ("read just now"), no second store call. */
justNow?: boolean;
/** Its store account waits on a quota pause or 429 backoff until then (epoch ms). */
pausedUntil?: number | null;
refresh: () => Promise<void>;
}
@ -584,7 +638,7 @@ export function useWrite(projectId: string | null) {
const o = await api.writeWithConfirm(w.method, projectId, w.args, job);
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined });
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); d.invalidate(projectId); }
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); d.invalidate(projectId, w.method); }
// exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the
// Catalog tab's owner action applies it); everything else it could do was done
else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
@ -615,19 +669,34 @@ function notifyAfterRead(method: string, prev: unknown, next: unknown, notice: N
}
}
/**
* The live state of one read query (a key, several keys, tags such as 'all' or 'import') from the read registry:
* busy while anything overlapping is queued or running (its own read, a broad read covering it, a narrow read of its
* scope), "read just now", the quota pause of its accounts. Every read button, notice and card renders loading and
* disabled from this, never from a local flag. Re-renders on every registry change (useSyncExternalStore).
*/
export function useReadState(q: ReadQuery): ReadStatus {
const reg = useContext(RegistryCtx);
if (!reg) throw new Error('useReadState outside DataProvider');
const ver = useSyncExternalStore(reg.subscribe, reg.version, reg.version);
const qk = typeof q === 'string' ? q : JSON.stringify(q);
return useMemo(() => reg.state(q), [reg, ver, qk]); // eslint-disable-line react-hooks/exhaustive-deps
}
/**
* One read method of one app (a project id, or a store-only app's store target). The cached value shows at once with
* its time; then, past its TTL (lib/freshness.ts), it is read again through the background scheduler (offline reads
* always; store reads while Settings > Reading > auto-read is on). A read the background reader runs for this key
* lands here too (the read bus). A failed read keeps the last good value and says so: never an empty "all fine".
* its time; then, past its TTL (lib/freshness.ts), it is read again through the read registry (offline reads always;
* store reads while Settings > Reading > auto-read is on). A read anyone else runs for this key (the background reader,
* Refresh all, a write's invalidation) lands here too (the read bus), and `loading` follows the registry: busy whenever
* the key, or a broad read covering it, is in flight. A failed read keeps the last good value and says so.
*/
export function useRead<T>(target: string, method: string, o: { network: boolean; args?: Record<string, unknown>; title?: string; appName?: string; skip?: boolean }): ReadState<T> {
const d = useData();
const argsKey = JSON.stringify(o.args ?? {});
const key = readKey(target, method, JSON.parse(argsKey) as Record<string, unknown>);
// `loading` here is only the first look into the cache; the read itself is the registry's
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
const dataRef = useRef<T | null>(null);
dataRef.current = s.data;
const st = useReadState(key);
const dRef = useRef(d);
dRef.current = d;
const skipRef = useRef(o.skip);
@ -635,7 +704,8 @@ export function useRead<T>(target: string, method: string, o: { network: boolean
const refresh = useCallback(async () => {
if (skipRef.current) return;
set(x => ({ ...x, loading: true }));
set(x => ({ ...x, loading: false }));
// a click: highest priority, joins a read in flight, answered from the memo when it finished just now
await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record<string, unknown>, 0).catch(() => {});
}, [target, method, argsKey]);
@ -643,14 +713,13 @@ export function useRead<T>(target: string, method: string, o: { network: boolean
useEffect(() => {
if (o.skip) return;
return d.onRead(key, e => {
if (e.kind === 'start') set(x => ({ ...x, loading: true }));
else if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
else if (e.kind === 'error') { if (/cancel/i.test(e.error)) set(x => ({ ...x, loading: false })); else set(x => ({ ...x, loading: false, error: oneLine(e.error) })); }
if (e.kind === 'start') return;
if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
else if (e.kind === 'error') { if (!/cancel/i.test(e.error)) set(x => ({ ...x, error: oneLine(e.error) })); }
else {
const r = e.result;
if (usable(method, r)) {
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command });
} else set(x => ({ ...x, loading: false, error: oneLine(failureText(r)), command: r.command }));
if (usable(method, r)) set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command });
else set(x => ({ ...x, error: oneLine(failureText(r)), command: r.command }));
}
});
}, [key, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
@ -659,13 +728,12 @@ export function useRead<T>(target: string, method: string, o: { network: boolean
let alive = true;
if (o.skip) { set({ data: null, at: null, fromCache: false, loading: false, error: null, command: null }); return; }
const auto = !o.network || dRef.current.settings.refresh.autoRead;
const running = dRef.current.isReading(key);
set({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
const go = (at: string | null) => {
if (!alive) return;
const due = !o.network || isStale(method, at);
if (running) set(x => ({ ...x, loading: true }));
else if (auto && due) refresh();
// in flight already (Refresh all, the background reader): the registry shows it busy; nothing to start
if (!dRef.current.isReading(key) && auto && due) refresh();
else set(x => ({ ...x, loading: false }));
};
api.cached<ToolResult>(method, target, JSON.parse(argsKey) as Record<string, unknown>).then(c => {
@ -676,5 +744,5 @@ export function useRead<T>(target: string, method: string, o: { network: boolean
return () => { alive = false; };
}, [target, method, argsKey, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
return { ...s, refresh };
return { ...s, loading: !o.skip && (s.loading || st.busy), justNow: st.justNow, pausedUntil: st.pausedUntil, refresh };
}