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:
parent
7345de2f7c
commit
c03938f9dc
14 changed files with 861 additions and 164 deletions
|
|
@ -18,7 +18,7 @@ import { useShowIgnored } from './lib/show-ignored';
|
|||
import { Onboarding } from './screens/Onboarding';
|
||||
import { Portfolio } from './screens/Portfolio';
|
||||
import { Settings } from './screens/Settings';
|
||||
import { useData } from './store';
|
||||
import { useData, useReadState } from './store';
|
||||
import { Icon, type IconName, Mark } from './ui/icons';
|
||||
import { AppIcon, Kbd, MOD, StudioMark } from './ui/kit';
|
||||
import { type Command, Palette } from './ui/Palette';
|
||||
|
|
@ -52,6 +52,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
|
||||
const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []);
|
||||
const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
||||
// Refresh all is busy while any read it covers is in flight (its own pass, a card's refresh, Check again, auto-read)
|
||||
const allReads = useReadState({ tags: ['all'] });
|
||||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
||||
const listed = showIgnored ? [...visible, ...ignoredApps] : visible;
|
||||
|
|
@ -262,8 +264,8 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
<span className="spacer" />
|
||||
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
|
||||
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length} title={t('action.refreshAllHint')} data-testid="refresh-all">
|
||||
<Icon name="refresh" className={d.reader.running ? 'spin' : undefined} />{t('action.refreshAll')}
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length || allReads.busy} aria-busy={allReads.busy} title={allReads.busy ? t('read.busyShared') : t('action.refreshAllHint')} data-testid="refresh-all">
|
||||
<Icon name="refresh" className={allReads.busy ? 'spin' : undefined} />{t('action.refreshAll')}
|
||||
</button>
|
||||
<ExportMenu />
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { buildReport } from '../../../../cli/lib/report';
|
|||
import { buildApps, type Overrides } from '../lib/identity';
|
||||
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model';
|
||||
import { editableFixture, liveFixture } from './editor-fixtures';
|
||||
import { serviceDisabledMessage } from './error-fixtures';
|
||||
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, consolePlan, consoleVerify, ownerDry, PLAY_STATUS } from './submit-fixtures';
|
||||
import { ANALYTICS, ASO, DECLARATIONS, LISTING_TEXT, PPO, PPO_STORE, PRICE_DIFF, PRICES, renderArt, RENDERS } from './fixtures-more';
|
||||
import { TOOLBOX } from '../../../../cli/lib/desktop-coverage';
|
||||
|
|
@ -454,7 +455,17 @@ export function fixtureRead(method: string, id: string, args: Record<string, unk
|
|||
return method.startsWith('apple.') && r.ok ? { ...r, quota: { store: 'apple', limit: 3600, remaining: 3412 } } : r;
|
||||
}
|
||||
|
||||
/** ?blocked: every Play health read fails with Google's SERVICE_DISABLED (the portfolio's "Check again" notice). */
|
||||
const blockedSwitch = (): boolean => typeof location !== 'undefined' && new URLSearchParams(location.search).has('blocked');
|
||||
|
||||
function read(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
||||
const r = read0(method, id, args);
|
||||
const h = r.data as HealthSnapshot | undefined;
|
||||
if (method !== 'health' || !blockedSwitch() || !h?.play) return r;
|
||||
return { ...r, data: { ...h, play: { error: serviceDisabledMessage('com.example.app') } } };
|
||||
}
|
||||
|
||||
function read0(method: string, id: string, args: Record<string, unknown> = {}): ToolResult {
|
||||
if (method === 'health.issue') return ok(method, ISSUE_SAMPLE(args));
|
||||
if (id.startsWith('store:')) {
|
||||
const play = /android:/.test(id);
|
||||
|
|
@ -534,7 +545,12 @@ async function progressLines(job: string | null, lines: string[]) {
|
|||
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
|
||||
}
|
||||
|
||||
/** Every fake-host call, for checks that count them (window.__fakeCalls: "cmd method target args"). */
|
||||
const fakeCalls: string[] = [];
|
||||
if (typeof window !== 'undefined') (window as unknown as { __fakeCalls: string[] }).__fakeCalls = fakeCalls;
|
||||
|
||||
export async function mockInvoke<T>(cmd: string, a: Record<string, unknown>): Promise<T> {
|
||||
fakeCalls.push([cmd, a.method ?? '', a.projectId ?? a.id ?? '', JSON.stringify((a.params as { args?: unknown } | null)?.args ?? a.live ?? '')].join(' '));
|
||||
const r = await answer(cmd, a);
|
||||
return r as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -917,6 +917,9 @@ export const en = {
|
|||
'read.couldNotRefresh': "couldn't refresh: {reason}",
|
||||
'read.refresh': "Refresh",
|
||||
'read.waiting': "Reading…",
|
||||
'read.justNow': "read just now",
|
||||
'read.paused': "store limit: waits until {time}",
|
||||
'read.busyShared': "Already reading: this joins the read in progress",
|
||||
'reader.progress': "Reading {done} of {total}",
|
||||
'reader.upToDate': "Everything read",
|
||||
'reader.off': "Auto-read is off",
|
||||
|
|
|
|||
|
|
@ -919,6 +919,9 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'read.couldNotRefresh': "yenilenemedi: {reason}",
|
||||
'read.refresh': "Yenile",
|
||||
'read.waiting': "Okunuyor…",
|
||||
'read.justNow': "az önce okundu",
|
||||
'read.paused': "mağaza sınırı: {time} saatine kadar bekliyor",
|
||||
'read.busyShared': "Zaten okunuyor: bu, süren okumaya katılır",
|
||||
'reader.progress': "{total} okumadan {done} tamam",
|
||||
'reader.upToDate': "Her şey okundu",
|
||||
'reader.off': "Otomatik okuma kapalı",
|
||||
|
|
|
|||
283
apps/desktop/src/lib/read-registry.ts
Normal file
283
apps/desktop/src/lib/read-registry.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
// The one read registry of the window: every read button, notice, card, the background reader, the tray and Ctrl+K
|
||||
// ask it, never the host directly. Pure logic over lib/scheduler.ts (tests/read-registry.test.ts).
|
||||
//
|
||||
// - Canonical key: target + method + args (lib/freshness.ts readKey), or a named key for reads without a target
|
||||
// (the portfolio, an account's app list, an account check).
|
||||
// - Single-flight: a read whose key is in flight (queued or running) is joined: the caller gets the same promise, its
|
||||
// result or its error. No second host call, ever.
|
||||
// - Scope: a read carries tags ('all', 'app:<target>', 'import', 'account:<id>' ...). A query asks by keys and / or
|
||||
// tags; it is busy when any in-flight read has one of its keys, one of its tags, or COVERS one of its keys.
|
||||
// - Covering: a broad read (Refresh all, Import all) is a group: it lists the narrow keys it stands for. While it runs
|
||||
// those keys show busy everywhere and a click on one of them waits for the group, then reads only if the group did
|
||||
// not (it usually did, so the click costs nothing). A narrow read in flight makes every broad button whose tag it
|
||||
// carries busy too (decided with the owner: overlapping buttons are all busy and disabled; one click never starts a
|
||||
// duplicate, it joins).
|
||||
// - Just now: a read that finished less than `justNowMs` ago is not run again by a second click: the caller gets the
|
||||
// last result ("read just now"). A write's invalidation drops that memo for the keys it touched.
|
||||
// - Rate limits stay the scheduler's (lanes per store, spacing and the App Store Connect quota per account, shared
|
||||
// 429 backoff); the registry reports a paused account on every query that uses it.
|
||||
import { type Clock, type Lane, type Priority, type ReadScheduler, realClock } from './scheduler';
|
||||
|
||||
export interface ReadSpec<R> {
|
||||
key: string;
|
||||
/** Scopes the read belongs to: a query by any of these tags sees it. */
|
||||
tags?: string[];
|
||||
lanes: Lane[];
|
||||
/** Credentials it uses ("apple:<account>", "play:<account>"): their quota pause shows on its buttons. */
|
||||
accounts: string[];
|
||||
priority: Priority;
|
||||
run: () => Promise<R>;
|
||||
/** Stops a running read (the host job). */
|
||||
cancel?: () => void;
|
||||
}
|
||||
|
||||
export interface GroupSpec<R> {
|
||||
key: string;
|
||||
tags?: string[];
|
||||
/** Narrow keys this read stands for until it releases them (release()) or ends. */
|
||||
covers: string[];
|
||||
run: (g: GroupHandle) => Promise<R>;
|
||||
}
|
||||
|
||||
export interface GroupHandle {
|
||||
/** This key is no longer the group's (read by its own entry now, or skipped as fresh). */
|
||||
release(key: string): void;
|
||||
}
|
||||
|
||||
export type ReadQuery = string | { keys?: string[]; tags?: string[] };
|
||||
|
||||
export interface ReadStatus {
|
||||
/** Something this query overlaps is queued or running. */
|
||||
busy: boolean;
|
||||
/** How many in-flight reads overlap it. */
|
||||
inFlight: number;
|
||||
/** Every key of the query finished successfully less than justNowMs ago. */
|
||||
justNow: boolean;
|
||||
/** Oldest last-finished time over the query's keys (epoch ms), null when one never finished here. */
|
||||
lastAt: number | null;
|
||||
/** The last error of a single-key query (cleared by its next success). */
|
||||
error: string | null;
|
||||
/** An account the query uses waits on a quota pause or a 429 backoff until this time (epoch ms). */
|
||||
pausedUntil: number | null;
|
||||
}
|
||||
|
||||
export type ReadEvent = { kind: 'start' } | { kind: 'done'; result: unknown } | { kind: 'error'; error: unknown };
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
tags: Set<string>;
|
||||
covers: Set<string>;
|
||||
accounts: string[];
|
||||
promise: Promise<unknown>;
|
||||
cancel?: () => void;
|
||||
group: boolean;
|
||||
}
|
||||
|
||||
interface Done { at: number; ok: boolean; result?: unknown; error?: string }
|
||||
|
||||
export interface RegistryOptions {
|
||||
justNowMs?: number;
|
||||
clock?: Clock;
|
||||
/** Per-key start / done / error, for the cards' read bus (only real reads: never a join or a just-now answer). */
|
||||
onEvent?: (key: string, e: ReadEvent) => void;
|
||||
}
|
||||
|
||||
const errText = (e: unknown): string => (e instanceof Error ? e.message : typeof e === 'string' ? e : (e as { message?: string })?.message ?? String(e));
|
||||
|
||||
export class ReadRegistry {
|
||||
private entries = new Map<string, Entry>();
|
||||
private done = new Map<string, Done>();
|
||||
private accountsOf = new Map<string, string[]>();
|
||||
private listeners = new Set<() => void>();
|
||||
private ver = 0;
|
||||
private justNowMs: number;
|
||||
private clock: Clock;
|
||||
private onEvent?: RegistryOptions['onEvent'];
|
||||
private expiry: unknown = null;
|
||||
|
||||
constructor(private sched: ReadScheduler, o: RegistryOptions = {}) {
|
||||
this.justNowMs = o.justNowMs ?? 20_000;
|
||||
this.clock = o.clock ?? realClock;
|
||||
this.onEvent = o.onEvent;
|
||||
// a quota pause or backoff of the scheduler changes what the buttons say
|
||||
sched.subscribe(() => this.emit());
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void): (() => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
|
||||
/** Changes on every start, finish, cover release, invalidation and scheduler change (useSyncExternalStore). */
|
||||
version = (): number => this.ver;
|
||||
|
||||
/** True when this exact key is in flight (its own read, or a group that covers it). */
|
||||
active(key: string): boolean {
|
||||
if (this.entries.has(key)) return true;
|
||||
for (const e of this.entries.values()) if (e.covers.has(key)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The last result of a key that finished less than justNowMs ago (undefined when there is none). */
|
||||
recent(key: string): unknown {
|
||||
const d = this.done.get(key);
|
||||
return d?.ok && this.clock.now() - d.at < this.justNowMs ? d.result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one key: joins it when it is in flight, waits for a group that covers it, answers a just-finished read
|
||||
* from its memo (unless `force`), else runs it through the scheduler.
|
||||
*/
|
||||
read<R>(spec: ReadSpec<R>, o: { force?: boolean } = {}): Promise<R> {
|
||||
this.accountsOf.set(spec.key, spec.accounts);
|
||||
const own = this.entries.get(spec.key);
|
||||
if (own) {
|
||||
// a higher priority asker raises the queued read
|
||||
if (!own.group) this.sched.enqueue({ key: spec.key, lanes: spec.lanes, accounts: spec.accounts, priority: spec.priority, run: spec.run as () => Promise<unknown> }).catch(() => {});
|
||||
return own.promise as Promise<R>;
|
||||
}
|
||||
for (const e of this.entries.values()) {
|
||||
if (e.covers.has(spec.key)) {
|
||||
// the broad read stands for this key: wait for it, then read only what it did not
|
||||
return e.promise.then(() => undefined, () => undefined).then(() => this.read(spec, { force: false }));
|
||||
}
|
||||
}
|
||||
const d = this.done.get(spec.key);
|
||||
if (!o.force && d?.ok && this.clock.now() - d.at < this.justNowMs) return Promise.resolve(d.result as R);
|
||||
const p = this.sched.enqueue<R>({ key: spec.key, lanes: spec.lanes, accounts: spec.accounts, priority: spec.priority, run: spec.run });
|
||||
const entry: Entry = { key: spec.key, tags: new Set(spec.tags ?? []), covers: new Set(), accounts: spec.accounts, promise: p, cancel: spec.cancel, group: false };
|
||||
this.entries.set(spec.key, entry);
|
||||
this.onEvent?.(spec.key, { kind: 'start' });
|
||||
this.emit();
|
||||
p.then(r => this.settle(entry, true, r), e => this.settle(entry, false, undefined, e));
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A broad read made of narrow ones (Refresh all, Import all): single-flight on its own key too. */
|
||||
group<R>(spec: GroupSpec<R>): Promise<R> {
|
||||
const own = this.entries.get(spec.key);
|
||||
if (own) return own.promise as Promise<R>;
|
||||
const covers = new Set(spec.covers);
|
||||
let resolve!: (r: R) => void, reject!: (e: unknown) => void;
|
||||
const promise = new Promise<R>((a, b) => { resolve = a; reject = b; });
|
||||
const entry: Entry = { key: spec.key, tags: new Set(spec.tags ?? []), covers, accounts: [], promise, group: true };
|
||||
this.entries.set(spec.key, entry);
|
||||
this.emit();
|
||||
const handle: GroupHandle = { release: k => { if (covers.delete(k)) this.emit(); } };
|
||||
// run after the entry exists, so a read started inside joins nothing by mistake (release first, then read)
|
||||
Promise.resolve().then(() => spec.run(handle)).then(r => { this.settle(entry, true, r); resolve(r); }, e => { this.settle(entry, false, undefined, e); reject(e); });
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Stops what overlaps the query: queued reads are dropped (their waiters get "cancelled"), running ones cancel their job. */
|
||||
cancel(q: ReadQuery): void {
|
||||
const { keys, tags } = norm(q);
|
||||
for (const e of [...this.entries.values()]) {
|
||||
if (!this.overlaps(e, keys, tags) || e.group) continue;
|
||||
if (this.sched.isRunning(e.key)) e.cancel?.();
|
||||
else this.sched.clear(k => k === e.key);
|
||||
}
|
||||
}
|
||||
|
||||
/** A write changed what these keys read: their "just now" memo goes, so the next read really reads. */
|
||||
invalidate(match: (key: string) => boolean): string[] {
|
||||
const hit: string[] = [];
|
||||
for (const k of [...this.done.keys()]) if (match(k)) { this.done.delete(k); hit.push(k); }
|
||||
if (hit.length) this.emit();
|
||||
return hit;
|
||||
}
|
||||
|
||||
state(q: ReadQuery): ReadStatus {
|
||||
const { keys, tags } = norm(q);
|
||||
const now = this.clock.now();
|
||||
let inFlight = 0;
|
||||
const accts = new Set<string>();
|
||||
for (const e of this.entries.values()) {
|
||||
if (!this.overlaps(e, keys, tags)) continue;
|
||||
inFlight++;
|
||||
for (const a of e.accounts) accts.add(a);
|
||||
}
|
||||
for (const k of keys) for (const a of this.accountsOf.get(k) ?? []) accts.add(a);
|
||||
let lastAt: number | null = keys.length ? Infinity : null;
|
||||
let justNow = keys.length > 0;
|
||||
for (const k of keys) {
|
||||
const d = this.done.get(k);
|
||||
if (!d) { lastAt = null; justNow = false; break; }
|
||||
lastAt = Math.min(lastAt as number, d.at);
|
||||
if (!d.ok || now - d.at >= this.justNowMs) justNow = false;
|
||||
}
|
||||
const paused = this.sched.stats().paused;
|
||||
let pausedUntil: number | null = null;
|
||||
for (const a of accts) if (paused[a] && paused[a] > now) pausedUntil = Math.max(pausedUntil ?? 0, paused[a]);
|
||||
const one = keys.length === 1 ? this.done.get(keys[0]!) : undefined;
|
||||
return { busy: inFlight > 0, inFlight, justNow: justNow && inFlight === 0, lastAt: lastAt === Infinity ? null : lastAt, error: one && !one.ok ? one.error ?? null : null, pausedUntil };
|
||||
}
|
||||
|
||||
private overlaps(e: Entry, keys: string[], tags: string[]): boolean {
|
||||
for (const k of keys) if (e.key === k || e.covers.has(k)) return true;
|
||||
for (const t of tags) if (e.tags.has(t) || e.key === t) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private settle(e: Entry, ok: boolean, result: unknown, error?: unknown): void {
|
||||
if (this.entries.get(e.key) === e) this.entries.delete(e.key);
|
||||
const at = this.clock.now();
|
||||
if (ok) this.done.set(e.key, { at, ok, result });
|
||||
else if (!/cancel/i.test(errText(error))) this.done.set(e.key, { at, ok, error: errText(error) });
|
||||
if (!e.group) this.onEvent?.(e.key, ok ? { kind: 'done', result } : { kind: 'error', error });
|
||||
this.armExpiry();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** "Read just now" ends by itself: one timer re-renders the buttons when the oldest memo runs out. */
|
||||
private armExpiry(): void {
|
||||
if (this.expiry !== null) return;
|
||||
this.expiry = this.clock.setTimeout(() => { this.expiry = null; this.emit(); }, this.justNowMs + 50);
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
this.ver++;
|
||||
for (const f of this.listeners) { try { f(); } catch { /* a listener never breaks the registry */ } }
|
||||
}
|
||||
}
|
||||
|
||||
function norm(q: ReadQuery): { keys: string[]; tags: string[] } {
|
||||
return typeof q === 'string' ? { keys: [q], tags: [] } : { keys: q.keys ?? [], tags: q.tags ?? [] };
|
||||
}
|
||||
|
||||
// ---- canonical keys of reads without an app target ----
|
||||
export const PORTFOLIO_KEY = '*\u001fportfolio.status\u001f{"live":true}';
|
||||
export const PORTFOLIO_CHECK_KEY = '*\u001fportfolio.status\u001f{}';
|
||||
export const READ_ALL_KEY = '*\u001freadAll';
|
||||
export const IMPORT_ALL_KEY = '*\u001fimport';
|
||||
export const importKey = (account: string): string => `account:${account}\u001fstore_apps.list`;
|
||||
export const accountCheckKey = (account: string): string => `account:${account}\u001faccount.check`;
|
||||
|
||||
/** The scope tags of a read of one app (Refresh all sees every app read; an app's own buttons see `app:<target>`). */
|
||||
export const appTags = (target: string): string[] => ['all', `app:${target}`];
|
||||
|
||||
// ---- which reads a write changes ----
|
||||
const ALL = null;
|
||||
const LISTING = ['project.listings', 'project.editable', 'listing.live', 'validate', 'scan', 'aso.audit'];
|
||||
const CATALOG = ['prices.current', 'prices.diff', 'catalog.diff', 'validate', 'scan'];
|
||||
const ART = ['art.live', 'project.renders', 'project.icon', 'validate', 'scan'];
|
||||
|
||||
/**
|
||||
* The read methods of the same app a write makes outdated (null: all of them). `portfolio` true when the write
|
||||
* changes release state the portfolio read shows. Unknown writes invalidate everything of the app (safe default).
|
||||
*/
|
||||
export function affectedReads(writeMethod: string): { methods: string[] | null; portfolio: boolean } {
|
||||
const m = writeMethod;
|
||||
const r = (methods: string[] | null, portfolio = false) => ({ methods, portfolio });
|
||||
if (m === 'owner.play_rollout' || m === 'owner.ios_phased') return r(['apple.status', 'play.status'], true);
|
||||
if (m.startsWith('reviews.') || m === 'owner.reply_send') return r(['reviews.list', 'reviews.drafts']);
|
||||
if (m.startsWith('prices.') || m === 'owner.prices_import' || m === 'apple.iap' || m === 'apple.subscriptions' || m === 'play.products' || m === 'play.subscriptions') return r(CATALOG);
|
||||
if (m === 'apple.listing_pull' || m === 'play.listing_pull' || m === 'play.listing' || m === 'apple.appinfo' || m === 'apple.review_details') return r(LISTING);
|
||||
if (m.startsWith('art.') || m.startsWith('shots.') || m === 'apple.screenshots' || m === 'apple.previews' || m === 'play.images' || m === 'icons') return r(ART);
|
||||
if (m.startsWith('apple.testflight_') || m === 'owner.testflight_testers') return r(['apple.testflight_status']);
|
||||
if (m.startsWith('owner.ppo_') || m.startsWith('apple.ppo_')) return r(['apple.ppo_status']);
|
||||
if (m === 'apple.game_center' || m === 'play.games') return r(['games.status']);
|
||||
if (m === 'apple.encryption' || m === 'play.data_safety') return r(['project.declarations', 'validate', 'scan']);
|
||||
if (m.startsWith('analytics.')) return r(['analytics.compare']);
|
||||
return r(ALL, true);
|
||||
}
|
||||
|
||||
/** The method part of a canonical key (readKey: target \u001f method \u001f args). */
|
||||
export const methodOfKey = (key: string): string => key.split('\u001f')[1] ?? '';
|
||||
export const targetOfKey = (key: string): string => key.split('\u001f')[0] ?? '';
|
||||
|
|
@ -127,6 +127,9 @@ export class ReadScheduler {
|
|||
|
||||
subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
|
||||
|
||||
/** True while the read of this key is running (not just queued). */
|
||||
isRunning(key: string): boolean { return this.running.has(key); }
|
||||
|
||||
has(key: string): boolean { return this.running.has(key) || this.queue.some(i => i.task.key === key); }
|
||||
|
||||
enqueue<R>(task: ReadTask<R>): Promise<R> {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type St
|
|||
import { type Key, t } from '../i18n';
|
||||
import { ago } from '../lib/format';
|
||||
import { withIgnored } from '../lib/identity';
|
||||
import { type AccountHealth, useData } from '../store';
|
||||
import { type AccountHealth, useData, useReadState } from '../store';
|
||||
import { accountCheckKey, importKey } from '../lib/read-registry';
|
||||
import { ImportAppsButton, TestAccountButton } from '../ui/ReadButtons';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { Chip, Notice, StudioMark } from '../ui/kit';
|
||||
import { EmptyState, PageHeader, Section, StatTile } from '../ui/ds';
|
||||
|
|
@ -58,11 +60,14 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
|
|||
};
|
||||
const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); if (a.kind === 'play' && a.profile && dev.trim() !== (a.developerId ?? '')) await api.setPlayDeveloper(a.id, dev.trim() || null); await d.reload(); setEdit(false); }); };
|
||||
const check = h?.check;
|
||||
// Test and Import of this account: busy from the read registry (Test all / Import apps make them busy too)
|
||||
const ck = useReadState(accountCheckKey(a.id));
|
||||
const im = useReadState(importKey(a.id));
|
||||
const cm = useContextMenu();
|
||||
const more = useMenuButton();
|
||||
const menu = (): MenuInput => [
|
||||
{ label: t('acct.test'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => run(`ck-${a.id}`, () => d.checkAccount(a.id)) },
|
||||
{ label: t('acct.import'), icon: 'download', disabled: busy || d.importBusy ? t('cm.busy') : false, onSelect: () => run(`im-${a.id}`, () => d.importApps(a.id)) },
|
||||
{ label: t('acct.test'), icon: 'shield', disabled: ck.busy ? t('cm.busy') : false, onSelect: () => { d.checkAccount(a.id).catch(() => {}); } },
|
||||
{ label: t('acct.import'), icon: 'download', disabled: im.busy ? t('cm.busy') : false, onSelect: () => { d.importApps(a.id).catch(() => {}); } },
|
||||
{ label: t('acct.edit'), icon: 'edit', onSelect: () => setEdit(true) },
|
||||
'sep',
|
||||
{ label: t('cm.copy'), icon: 'copy', submenu: [
|
||||
|
|
@ -90,8 +95,8 @@ function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string |
|
|||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={() => run(`ck-${a.id}`, () => d.checkAccount(a.id))} disabled={!!busy}><Icon name="shield" className={busy === `ck-${a.id}` ? 'spin' : undefined} />{t('acct.test')}</button>
|
||||
<button className="btn btn-sm" onClick={() => run(`im-${a.id}`, () => d.importApps(a.id))} disabled={!!busy || d.importBusy}><Icon name="download" />{t('acct.import')}</button>
|
||||
<TestAccountButton account={a.id} label={t('acct.test')} />
|
||||
<ImportAppsButton account={a.id} label={t('acct.import')} />
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => setEdit(x => !x)} aria-expanded={edit} aria-label={t('acct.editAria', { label: a.label })} title={t('acct.edit')}><Icon name="edit" /></button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" {...more(menu)} aria-label={t('cm.more')} title={t('cm.more')}><Icon name="more" /></button>
|
||||
</div>
|
||||
|
|
@ -196,7 +201,6 @@ export function Accounts({ focus }: { focus?: string }) {
|
|||
const addStudio = () => act('studio', async () => { const s = await api.saveStudio(null, studioName, studioColor); setStudioName(''); return s; }, r => t('studio.added', { name: (r as Studio).name }));
|
||||
const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel, playStudio || null); if (a) setPlayLabel(''); return a; }, r => t('accounts.saved', { label: (r as Account).label }));
|
||||
const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId, appleStudio || null); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; }, r => t('accounts.saved', { label: (r as Account).label }));
|
||||
const testAll = async () => { setBusy('all'); await Promise.all(d.accounts.map(a => d.checkAccount(a.id))); setBusy(null); };
|
||||
const unassigned = d.accounts.filter(a => !a.studio || !d.studios.some(s => s.id === a.studio));
|
||||
const studioSelect = (id: string, value: string, set: (v: string) => void) => (
|
||||
<div className="field"><label htmlFor={id}>{t('studio.studio')}</label>
|
||||
|
|
@ -209,8 +213,8 @@ export function Accounts({ focus }: { focus?: string }) {
|
|||
return (
|
||||
<div className="page scr-accounts">
|
||||
<PageHeader icon="key" eyebrow={t('v2cfg.workspace')} title={t('nav.accounts')} subtitle={t('accounts.leadStudios')} actions={<>
|
||||
<button className="btn" onClick={testAll} disabled={!!busy || !d.accounts.length}><Icon name="shield" className={busy === 'all' ? 'spin' : undefined} />{t('acct.testAll')}</button>
|
||||
<button className="btn btn-primary" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
|
||||
<TestAccountButton className="btn" label={t('acct.testAll')} />
|
||||
<ImportAppsButton className="btn btn-primary" />
|
||||
</>} />
|
||||
{d.accounts.length > 0 && <div className="ds-grid acct-kpis">
|
||||
<StatTile icon="building" label={t('v2cfg.studios')} value={d.studios.length} />
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import { type AndroidSdkLookup, api, type DownloadInfo, errorText, type LocalToo
|
|||
import { t } from '../i18n';
|
||||
import { bytes } from '../lib/format';
|
||||
import { shortPath } from '../paths';
|
||||
import { useData } from '../store';
|
||||
import { useData, useReadState } from '../store';
|
||||
import { Icon, type IconName } from '../ui/icons';
|
||||
import { Chip, Notice, Skeleton } from '../ui/kit';
|
||||
import { PageHeader, Section, StatTile, StatusPill } from '../ui/ds';
|
||||
import '../styles/deps.css';
|
||||
|
||||
/** The local tools check (system.tools): one registry key. */
|
||||
const TOOLS_KEY = '*\u001fsystem.tools';
|
||||
|
||||
const TOOL_ICON: Record<string, IconName> = { ffmpeg: 'image', ffprobe: 'search', chrome: 'globe', xcode: 'terminal', simctl: 'phone', adb: 'phone', git: 'layers' };
|
||||
|
||||
/** Status chip of one tool. */
|
||||
|
|
@ -29,16 +32,18 @@ export function Dependencies() {
|
|||
const [sdk, setSdk] = useState<AndroidSdkLookup | null>(d.sidecar?.tools?.androidSdk ?? null);
|
||||
const [info, setInfo] = useState<DownloadInfo[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// local tools check: one registry key, so a second click (or a remount) joins the check in flight
|
||||
const busy = useReadState(TOOLS_KEY).busy;
|
||||
const [dl, setDl] = useState<Record<string, Dl>>({});
|
||||
|
||||
const reg = d.registry;
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true); setError(null);
|
||||
setError(null);
|
||||
try {
|
||||
const [s, i] = await Promise.all([api.call<LocalTools>('system.tools'), api.toolsInfo()]);
|
||||
const [s, i] = await reg.read({ key: TOOLS_KEY, lanes: ['local'], accounts: [], priority: 0, run: () => Promise.all([api.call<LocalTools>('system.tools'), api.toolsInfo()]) }, { force: true });
|
||||
setTools(s.tools); setInfo(i); setSdk(s.androidSdk ?? null);
|
||||
} catch (e) { setError(errorText(e)); } finally { setBusy(false); }
|
||||
}, []);
|
||||
} catch (e) { setError(errorText(e)); }
|
||||
}, [reg]);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
useEffect(() => {
|
||||
const un = onDownload(e => setDl(m => {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
// version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first.
|
||||
import { plainError } from '../../../../cli/lib/google-errors';
|
||||
import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, type AppStatus, type StoreRelease, type ToolResult } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { ago, pct, share } from '../lib/format';
|
||||
import { useShowIgnored } from '../lib/show-ignored';
|
||||
|
|
@ -15,7 +15,10 @@ import { type AppTab, useNav } from '../nav';
|
|||
import { useContextMenu } from '../ui/ContextMenu';
|
||||
import { appMenu } from '../ui/app-menu';
|
||||
import type { IconName } from '../ui/icons';
|
||||
import { useData } from '../store';
|
||||
import { useData, useReadState } from '../store';
|
||||
import { ImportAppsButton } from '../ui/ReadButtons';
|
||||
import { readKey } from '../lib/freshness';
|
||||
import { PORTFOLIO_KEY } from '../lib/read-registry';
|
||||
import { exportName, portfolioCsv } from '../lib/exports';
|
||||
import { useExports } from '../ui/Exports';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
|
|
@ -302,20 +305,25 @@ export function Portfolio() {
|
|||
const [showIgnored, setShowIgnored] = useShowIgnored();
|
||||
const menu = useContextMenu();
|
||||
const [round, setRound] = useState(0);
|
||||
const [rechecking, setRechecking] = useState(false);
|
||||
const health = useCachedHealth(visible.map(a => a.target), round);
|
||||
const blocked = useMemo(() => blockedProjects(visible.map(a => {
|
||||
const h = health[a.target]?.h, l = d.liveFor(a);
|
||||
return { app: a.name, errors: [h?.play && isError(h.play) ? h.play : null, l?.storeErrors.android] };
|
||||
})), [visible, health, d]);
|
||||
// "Check again": read health of the blocked apps again (one at a time: the same Cloud project's quota), then the cache
|
||||
const recheck = async () => {
|
||||
setRechecking(true);
|
||||
const names = new Set(blocked.flatMap(b => b.apps));
|
||||
for (const a of visible.filter(x => names.has(x.name))) await api.call<ToolResult>('health', a.target, { args: {} }, newJob('health')).catch(() => null);
|
||||
setRechecking(false);
|
||||
setRound(n => n + 1);
|
||||
// "Check again": the health reads of the blocked apps (the same keys as their Health tab's refresh and Refresh all),
|
||||
// plus the portfolio read when a blocked store came from it. Busy (and disabled) whenever any of these keys is in
|
||||
// flight, whoever started it; a click joins what runs and the scheduler spaces the rest (one Cloud project's quota).
|
||||
const blockedApps = useMemo(() => { const names = new Set(blocked.flatMap(b => b.apps)); return visible.filter(x => names.has(x.name)); }, [blocked, visible]);
|
||||
const fromLive = blockedApps.some(a => !!d.liveFor(a)?.storeErrors.android);
|
||||
const checkKeys = useMemo(() => [...blockedApps.map(a => readKey(a.target, 'health', {})), ...(fromLive ? [PORTFOLIO_KEY] : [])], [blockedApps, fromLive]);
|
||||
const checkReads = useReadState({ keys: checkKeys });
|
||||
const recheck = () => {
|
||||
Promise.allSettled([...blockedApps.map(a => d.schedule(a.target, 'health', {}, 0)), ...(fromLive ? [d.refreshPortfolio(true)] : [])]).catch(() => {});
|
||||
};
|
||||
// whoever read them (this button, Refresh all, a Health tab), the notice looks at the saved health again after
|
||||
const wasBusy = useRef(false);
|
||||
useEffect(() => { if (wasBusy.current && !checkReads.busy) setRound(n => n + 1); wasBusy.current = checkReads.busy; }, [checkReads.busy]);
|
||||
const allReads = useReadState({ tags: ['all'] });
|
||||
const open = (a: UnifiedApp) => nav.go({ screen: 'app', appId: a.key, tab: 'releases' });
|
||||
|
||||
const all = useMemo(() => visible.map(app => {
|
||||
|
|
@ -411,14 +419,14 @@ export function Portfolio() {
|
|||
<p>{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{neverImported === 0 && <button className="btn" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length} title={t('action.importAppsHint')}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>}
|
||||
{neverImported === 0 && <ImportAppsButton className="btn" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
|
||||
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
|
||||
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
|
||||
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm btn-primary" onClick={() => { d.readAll(true).catch(() => {}); }}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
|
||||
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={checkReads.busy} />
|
||||
{neverImported > 0 && <Notice tone="info" icon="download" action={<ImportAppsButton />}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
|
||||
{!known && d.ready && d.portfolioBusy !== 'live' && <Notice tone="info" action={<button className="btn btn-sm btn-primary" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={allReads.busy} aria-busy={allReads.busy}><Icon name="refresh" className={allReads.busy ? 'spin' : undefined} />{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
|
||||
|
||||
<div className="kpis">
|
||||
<Kpi icon="layers" label={t('kpi.apps')} value={rows.length} sub={t('kpi.appsSubStore', { ios: iosApps, android: androidApps, store: storeOnly })} />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { pairCandidates, type UnifiedApp, withPair, withUnpair } from '../lib/id
|
|||
import { useNav } from '../nav';
|
||||
import { usePrefs } from '../prefs-ctx';
|
||||
import { useData } from '../store';
|
||||
import { ImportAppsButton, RefreshAllButton } from '../ui/ReadButtons';
|
||||
import { Icon, type IconName, StoreGlyph } from '../ui/icons';
|
||||
import { AppIcon, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit';
|
||||
|
||||
|
|
@ -153,8 +154,8 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
{[1, 2, 3, 4, 6, 8].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select><span className="hint">{t('set.parallelHint')}</span></div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" />{t('action.importApps')}</button>
|
||||
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }}><Icon name="refresh" />{t('action.refreshAll')}</button>
|
||||
<ImportAppsButton />
|
||||
<RefreshAllButton />
|
||||
<span className="small muted">{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
40
apps/desktop/src/ui/ReadButtons.tsx
Normal file
40
apps/desktop/src/ui/ReadButtons.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Read buttons used on several screens. Their busy state comes from the read registry (useReadState), so every copy
|
||||
// of a button, and every other button whose read overlaps it, spins and is disabled together.
|
||||
import { t } from '../i18n';
|
||||
import { accountCheckKey, importKey } from '../lib/read-registry';
|
||||
import { useData, useReadState } from '../store';
|
||||
import { Icon } from './icons';
|
||||
|
||||
/** Refresh all: busy while any read it covers runs (its own pass, a card refresh, Check again, the background reader). */
|
||||
export function RefreshAllButton({ className = 'btn btn-sm' }: { className?: string }) {
|
||||
const d = useData();
|
||||
const s = useReadState({ tags: ['all'] });
|
||||
return (
|
||||
<button className={className} onClick={() => { d.readAll(true).catch(() => {}); }} disabled={s.busy} aria-busy={s.busy} title={s.busy ? t('read.busyShared') : t('action.refreshAllHint')}>
|
||||
<Icon name="refresh" className={s.busy ? 'spin' : undefined} />{t('action.refreshAll')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Import apps (every account, or one): busy while that account's (or any account's) app list is being read. */
|
||||
export function ImportAppsButton({ account, className = 'btn btn-sm', label }: { account?: string; className?: string; label?: string }) {
|
||||
const d = useData();
|
||||
const s = useReadState(account ? { keys: [importKey(account)] } : { tags: ['import'] });
|
||||
return (
|
||||
<button className={className} onClick={() => { d.importApps(account).catch(() => {}); }} disabled={s.busy || !d.accounts.length} aria-busy={s.busy} title={s.busy ? t('read.busyShared') : t('action.importAppsHint')}>
|
||||
<Icon name="download" className={s.busy ? 'spin' : undefined} />{label ?? t('action.importApps')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Test one account (or all): single-flight per account; Test all and a row's Test are busy together. */
|
||||
export function TestAccountButton({ account, className = 'btn btn-sm', label }: { account?: string; className?: string; label: string }) {
|
||||
const d = useData();
|
||||
const s = useReadState(account ? { keys: [accountCheckKey(account)] } : { tags: ['check'] });
|
||||
const run = () => { (account ? d.checkAccount(account) : Promise.all(d.accounts.map(a => d.checkAccount(a.id)))).catch(() => {}); };
|
||||
return (
|
||||
<button className={className} onClick={run} disabled={s.busy || !d.accounts.length} aria-busy={s.busy}>
|
||||
<Icon name="shield" className={s.busy ? 'spin' : undefined} />{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -180,12 +180,13 @@ export function StoreOnlyTag() {
|
|||
|
||||
/** The state of one read beside its card's title: "read 3 min ago" with a small spinner while it refreshes, or
|
||||
* "couldn't refresh: <reason>" over the last good value, and the per-card refresh icon (no "Read" button first). */
|
||||
export function ReadControl({ r, label }: { r: { data: unknown; at: string | null; loading: boolean; error: string | null; refresh: () => Promise<void> }; label?: string }) {
|
||||
const text = r.error ? t('read.couldNotRefresh', { reason: r.error }) : r.at ? t('read.ago', { when: ago(r.at) }) : r.loading ? t('read.waiting') : '';
|
||||
export function ReadControl({ r, label }: { r: { data: unknown; at: string | null; loading: boolean; error: string | null; justNow?: boolean; pausedUntil?: number | null; refresh: () => Promise<void> }; label?: string }) {
|
||||
const paused = r.pausedUntil ? t('read.paused', { time: new Date(r.pausedUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : '';
|
||||
const text = r.error ? t('read.couldNotRefresh', { reason: r.error }) : paused && r.loading ? paused : r.justNow ? t('read.justNow') : r.at ? t('read.ago', { when: ago(r.at) }) : r.loading ? t('read.waiting') : '';
|
||||
return (
|
||||
<span className="read-ctl" data-read-state={r.loading ? 'loading' : r.error ? 'error' : r.data !== null ? 'ok' : 'none'}>
|
||||
{text && <span className={`read-stamp ${r.error ? 'read-err' : ''}`} title={r.error ?? undefined}>{text}</span>}
|
||||
<button className="btn btn-sm btn-icon" onClick={() => { r.refresh(); }} disabled={r.loading} aria-label={label ?? t('read.refresh')} title={label ?? t('read.refresh')}>
|
||||
<span className="read-ctl" data-read-state={r.loading ? 'loading' : r.error ? 'error' : r.data !== null ? 'ok' : 'none'} data-read-paused={paused ? 'true' : undefined}>
|
||||
{text && <span className={`read-stamp ${r.error ? 'read-err' : ''}`} title={r.error ?? (paused || undefined)}>{text}</span>}
|
||||
<button className="btn btn-sm btn-icon" onClick={() => { r.refresh(); }} disabled={r.loading} aria-busy={r.loading} aria-label={label ?? t('read.refresh')} title={label ?? t('read.refresh')}>
|
||||
<Icon name="refresh" className={r.loading ? 'spin' : undefined} />
|
||||
</button>
|
||||
</span>
|
||||
|
|
|
|||
260
apps/desktop/tests/read-registry.test.ts
Normal file
260
apps/desktop/tests/read-registry.test.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
// The read registry (src/lib/read-registry.ts) over the scheduler, with a fake clock: single-flight per key, scope
|
||||
// overlap (tags, broad reads covering narrow keys), "read just now", invalidation, one error to every waiter,
|
||||
// cancellation, the quota pause on the query, which reads a write invalidates; and the component level: two read
|
||||
// buttons whose reads overlap both render busy and disabled from the registry (react-dom/server, no DOM needed).
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { readKey } from '../src/lib/freshness';
|
||||
import { affectedReads, appTags, PORTFOLIO_KEY, READ_ALL_KEY, type ReadQuery, ReadRegistry, type ReadSpec } from '../src/lib/read-registry';
|
||||
import { RegistryCtx, useReadState } from '../src/store';
|
||||
import { type Clock, ReadScheduler } from '../src/lib/scheduler';
|
||||
|
||||
class FakeClock implements Clock {
|
||||
t = 1_000_000;
|
||||
private timers: { at: number; fn: () => void; id: number }[] = [];
|
||||
private n = 0;
|
||||
now() { return this.t; }
|
||||
setTimeout(fn: () => void, ms: number) { const id = ++this.n; this.timers.push({ at: this.t + ms, fn, id }); return id; }
|
||||
clearTimeout(h: unknown) { this.timers = this.timers.filter(x => x.id !== h); }
|
||||
random() { return 0.5; }
|
||||
async advance(ms: number) {
|
||||
const end = this.t + ms;
|
||||
for (;;) {
|
||||
await flush();
|
||||
const next = this.timers.filter(x => x.at <= end).sort((a, b) => a.at - b.at)[0];
|
||||
if (!next) break;
|
||||
this.t = next.at;
|
||||
this.timers = this.timers.filter(x => x !== next);
|
||||
next.fn();
|
||||
}
|
||||
this.t = end;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
|
||||
|
||||
/** A registry whose reads resolve when the test says so; `calls` counts host calls per key. */
|
||||
function setup(o: { justNowMs?: number } = {}) {
|
||||
const clock = new FakeClock();
|
||||
const sched = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 4, play: 4, local: 2 } });
|
||||
const events: string[] = [];
|
||||
const reg = new ReadRegistry(sched, { clock, justNowMs: o.justNowMs ?? 20_000, onEvent: (k, e) => events.push(`${e.kind}:${k}`) });
|
||||
const calls = new Map<string, number>();
|
||||
const pending = new Map<string, { done: (v: unknown) => void; fail: (e: unknown) => void }[]>();
|
||||
const spec = (key: string, x: Partial<ReadSpec<unknown>> = {}): ReadSpec<unknown> => ({
|
||||
key, lanes: ['apple'], accounts: ['apple:k1'], priority: 0, tags: x.tags ?? appTags('app1'), ...x,
|
||||
run: () => new Promise((done, fail) => {
|
||||
calls.set(key, (calls.get(key) ?? 0) + 1);
|
||||
pending.set(key, [...(pending.get(key) ?? []), { done, fail }]);
|
||||
}),
|
||||
});
|
||||
const finish = (key: string, v: unknown = { ok: true }) => { for (const p of pending.get(key) ?? []) p.done(v); pending.delete(key); };
|
||||
const fail = (key: string, e: unknown) => { for (const p of pending.get(key) ?? []) p.fail(e); pending.delete(key); };
|
||||
return { clock, sched, reg, calls, spec, finish, fail, events };
|
||||
}
|
||||
|
||||
const K1 = readKey('app1', 'health', {});
|
||||
const K2 = readKey('app1', 'reviews.list', { days: 30 });
|
||||
const K3 = readKey('app2', 'health', {});
|
||||
|
||||
describe('ReadRegistry', () => {
|
||||
test('single-flight: the same key twice is one host call and one promise; both get the result', async () => {
|
||||
const { clock, reg, calls, spec, finish, events } = setup();
|
||||
const a = reg.read(spec(K1));
|
||||
const b = reg.read(spec(K1));
|
||||
await clock.advance(0);
|
||||
expect(calls.get(K1)).toBe(1);
|
||||
expect(reg.state(K1).busy).toBe(true);
|
||||
finish(K1, { ok: true, n: 7 });
|
||||
expect(await a).toEqual({ ok: true, n: 7 });
|
||||
expect(await b).toEqual({ ok: true, n: 7 });
|
||||
expect(reg.state(K1).busy).toBe(false);
|
||||
// the bus hears one start and one done, never a join
|
||||
expect(events).toEqual([`start:${K1}`, `done:${K1}`]);
|
||||
});
|
||||
|
||||
test('just now: a second click right after a finished read answers from it; after the window it reads again', async () => {
|
||||
const { clock, reg, calls, spec, finish } = setup({ justNowMs: 10_000 });
|
||||
const a = reg.read(spec(K1));
|
||||
await clock.advance(0); finish(K1, 'first'); await a;
|
||||
expect(reg.state(K1).justNow).toBe(true);
|
||||
expect(await reg.read(spec(K1))).toBe('first');
|
||||
expect(calls.get(K1)).toBe(1);
|
||||
await clock.advance(10_001);
|
||||
expect(reg.state(K1).justNow).toBe(false);
|
||||
const c = reg.read(spec(K1));
|
||||
await clock.advance(0); finish(K1, 'second');
|
||||
expect(await c).toBe('second');
|
||||
expect(calls.get(K1)).toBe(2);
|
||||
// force skips the memo
|
||||
const f = reg.read(spec(K1), { force: true });
|
||||
await clock.advance(0); finish(K1, 'third');
|
||||
expect(await f).toBe('third');
|
||||
expect(calls.get(K1)).toBe(3);
|
||||
});
|
||||
|
||||
test('invalidation drops the memo of the keys a write touched, and only those', async () => {
|
||||
const { clock, reg, calls, spec, finish } = setup();
|
||||
for (const k of [K1, K2]) { const p = reg.read(spec(k)); await clock.advance(0); finish(k); await p; }
|
||||
expect(reg.invalidate(k => k === K2)).toEqual([K2]);
|
||||
reg.read(spec(K1)).catch(() => {}); reg.read(spec(K2)).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(calls.get(K1)).toBe(1);
|
||||
expect(calls.get(K2)).toBe(2);
|
||||
});
|
||||
|
||||
test('an error reaches every waiter, is the key\'s error, and is not memoized', async () => {
|
||||
const { clock, reg, calls, spec, fail, finish } = setup();
|
||||
const a = reg.read(spec(K1)), b = reg.read(spec(K1));
|
||||
await clock.advance(0);
|
||||
fail(K1, new Error('403 SERVICE_DISABLED'));
|
||||
await expect(a).rejects.toThrow('SERVICE_DISABLED');
|
||||
await expect(b).rejects.toThrow('SERVICE_DISABLED');
|
||||
expect(reg.state(K1)).toMatchObject({ busy: false, error: '403 SERVICE_DISABLED', justNow: false });
|
||||
const c = reg.read(spec(K1));
|
||||
await clock.advance(0); finish(K1, 'ok');
|
||||
expect(await c).toBe('ok');
|
||||
expect(calls.get(K1)).toBe(2);
|
||||
expect(reg.state(K1).error).toBeNull();
|
||||
});
|
||||
|
||||
test('scope: a narrow read makes the broad tag busy; another app\'s query stays free', async () => {
|
||||
const { clock, reg, spec, finish } = setup();
|
||||
reg.read(spec(K1)).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect(reg.state({ tags: ['all'] }).busy).toBe(true);
|
||||
expect(reg.state({ tags: ['app:app1'] }).busy).toBe(true);
|
||||
expect(reg.state(K3).busy).toBe(false);
|
||||
expect(reg.state({ keys: [K3, K1] }).busy).toBe(true);
|
||||
finish(K1);
|
||||
await flush();
|
||||
expect(reg.state({ tags: ['all'] }).busy).toBe(false);
|
||||
});
|
||||
|
||||
test('a broad read covers its narrow keys: they are busy at once, a click on one waits and does not read again', async () => {
|
||||
const { clock, reg, calls, spec, finish } = setup();
|
||||
let release!: (k: string) => void;
|
||||
let go!: () => void;
|
||||
const gate = new Promise<void>(r => { go = r; });
|
||||
const all = reg.group({ key: READ_ALL_KEY, tags: ['all'], covers: [PORTFOLIO_KEY, K1, K2], run: async g => {
|
||||
release = g.release;
|
||||
await gate; // the TTL checks
|
||||
const ps = [K1, K2].map(k => { g.release(k); return reg.read(spec(k)); });
|
||||
return Promise.all(ps);
|
||||
} });
|
||||
await flush();
|
||||
// nothing started yet, but every covered key already shows busy
|
||||
expect(calls.size).toBe(0);
|
||||
for (const k of [K1, K2, PORTFOLIO_KEY]) expect(reg.state(k).busy).toBe(true);
|
||||
expect(reg.state({ tags: ['all'] }).busy).toBe(true);
|
||||
// the card's refresh click while covered: joins the group, no read of its own
|
||||
const click = reg.read(spec(K1));
|
||||
// a second Refresh all joins the first
|
||||
const again = reg.group({ key: READ_ALL_KEY, covers: [], run: async () => { throw new Error('must not run'); } });
|
||||
expect(again).toBe(all as Promise<unknown> as typeof again);
|
||||
release(PORTFOLIO_KEY);
|
||||
expect(reg.state(PORTFOLIO_KEY).busy).toBe(false);
|
||||
go();
|
||||
await clock.advance(0);
|
||||
expect(calls.get(K1)).toBe(1);
|
||||
finish(K1, 'one'); finish(K2, 'two');
|
||||
await all;
|
||||
expect(await click).toBe('one');
|
||||
expect(calls.get(K1)).toBe(1);
|
||||
expect(reg.state({ tags: ['all'] }).busy).toBe(false);
|
||||
});
|
||||
|
||||
test('cancel: a queued read is dropped and every waiter hears "cancelled"; a running one cancels its job', async () => {
|
||||
const { clock, sched, reg, spec } = setup();
|
||||
sched.setLimits({ apple: 1 });
|
||||
let cancelled = 0;
|
||||
reg.read(spec(K1, { cancel: () => { cancelled++; } })).catch(() => {});
|
||||
const q1 = reg.read(spec(K3)), q2 = reg.read(spec(K3));
|
||||
await clock.advance(0);
|
||||
reg.cancel(K3);
|
||||
await expect(q1).rejects.toThrow('cancelled');
|
||||
await expect(q2).rejects.toThrow('cancelled');
|
||||
await flush();
|
||||
expect(reg.state(K3).busy).toBe(false);
|
||||
// a cancel is not a failure the card keeps showing
|
||||
expect(reg.state(K3).error).toBeNull();
|
||||
reg.cancel(K1);
|
||||
expect(cancelled).toBe(1);
|
||||
});
|
||||
|
||||
test('a 429 backoff of the account shows as paused on every query that uses it', async () => {
|
||||
const clock = new FakeClock();
|
||||
let n = 0;
|
||||
const sched = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 60_000, classify: () => ({ throttled: n++ === 0 }) });
|
||||
const reg = new ReadRegistry(sched, { clock });
|
||||
const p = reg.read({ key: K1, tags: appTags('app1'), lanes: ['play'], accounts: ['play:acc'], priority: 0, run: async () => 'x' });
|
||||
await clock.advance(0);
|
||||
const st = reg.state(K1);
|
||||
expect(st.busy).toBe(true);
|
||||
expect(st.pausedUntil).toBeGreaterThan(clock.now());
|
||||
expect(reg.state({ tags: ['all'] }).pausedUntil).toBe(st.pausedUntil);
|
||||
await clock.advance(120_000);
|
||||
expect(await p).toBe('x');
|
||||
expect(reg.state(K1).pausedUntil).toBeNull();
|
||||
});
|
||||
|
||||
test('a joining click with a higher priority raises the queued read', async () => {
|
||||
const { clock, sched, reg, calls, spec, finish } = setup();
|
||||
sched.setLimits({ apple: 1 });
|
||||
reg.read(spec('blocker')).catch(() => {});
|
||||
reg.read(spec(K2, { priority: 2 })).catch(() => {});
|
||||
reg.read(spec(K3, { priority: 2 })).catch(() => {});
|
||||
reg.read(spec(K3, { priority: 0 })).catch(() => {});
|
||||
await clock.advance(0);
|
||||
expect([...calls.keys()]).toEqual(['blocker']);
|
||||
expect(calls.get(K3)).toBeUndefined();
|
||||
// still one queued read per key; the joined K3 now goes before K2
|
||||
expect(sched.stats().queued).toBe(2);
|
||||
finish('blocker');
|
||||
await clock.advance(0);
|
||||
expect(calls.get(K3)).toBe(1);
|
||||
expect(calls.get(K2)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('affectedReads', () => {
|
||||
test('writes invalidate their own reads only; unknown writes everything', () => {
|
||||
expect(affectedReads('reviews.reply').methods).toEqual(['reviews.list', 'reviews.drafts']);
|
||||
expect(affectedReads('owner.play_rollout')).toEqual({ methods: ['apple.status', 'play.status'], portfolio: true });
|
||||
expect(affectedReads('apple.testflight_distribute').methods).toEqual(['apple.testflight_status']);
|
||||
expect(affectedReads('prices.import').methods).toContain('prices.current');
|
||||
expect(affectedReads('prices.import').methods).not.toContain('health');
|
||||
expect(affectedReads('art.pull').methods).toContain('art.live');
|
||||
expect(affectedReads('project.edit')).toEqual({ methods: null, portfolio: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('two overlapping read buttons render busy together', () => {
|
||||
test('a card refresh in flight: Refresh all, the card, Check again are busy and disabled; another app is not', async () => {
|
||||
const { clock, reg, spec } = setup();
|
||||
// the window's rule (src/ui/ReadButtons.tsx, ReadControl): useReadState(query).busy -> spinner + disabled
|
||||
const Btn = ({ q, id }: { q: ReadQuery; id: string }) => {
|
||||
const s = useReadState(q);
|
||||
return createElement('button', { 'data-id': id, disabled: s.busy, 'aria-busy': s.busy }, s.busy ? 'spin' : 'idle');
|
||||
};
|
||||
const view = () => renderToStaticMarkup(createElement(RegistryCtx.Provider, { value: reg },
|
||||
createElement(Btn, { q: { tags: ['all'] }, id: 'refresh-all' }),
|
||||
createElement(Btn, { q: K1, id: 'health-card' }),
|
||||
createElement(Btn, { q: { keys: [K1, K3] }, id: 'check-again' }),
|
||||
createElement(Btn, { q: readKey('app9', 'scan', {}), id: 'other-app' })));
|
||||
expect(view()).not.toContain('disabled');
|
||||
reg.read(spec(K1)).catch(() => {});
|
||||
await clock.advance(0);
|
||||
const html = view();
|
||||
for (const id of ['refresh-all', 'health-card', 'check-again']) expect(html).toContain(`<button data-id="${id}" disabled="" aria-busy="true">spin</button>`);
|
||||
expect(html).toContain('<button data-id="other-app" aria-busy="false">idle</button>');
|
||||
// and the other way round: a broad read in flight makes the narrow buttons busy before any of them started
|
||||
const g = setup();
|
||||
g.reg.group({ key: READ_ALL_KEY, tags: ['all'], covers: [K3], run: () => new Promise(() => {}) }).catch(() => {});
|
||||
await flush();
|
||||
const html2 = renderToStaticMarkup(createElement(RegistryCtx.Provider, { value: g.reg }, createElement(Btn, { q: K3, id: 'card' }), createElement(Btn, { q: { keys: [K3] }, id: 'notice' })));
|
||||
expect(html2).toContain('<button data-id="card" disabled="" aria-busy="true">spin</button>');
|
||||
expect(html2).toContain('<button data-id="notice" disabled="" aria-busy="true">spin</button>');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue