diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 94ee3c7..b1b01fe 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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 }) { {FIXTURES && {t('dev.fixtures')}} {lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')} - diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 003694b..4ba99d4 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -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 typeof location !== 'undefined' && new URLSearchParams(location.search).has('blocked'); + function read(method: string, id: string, args: Record = {}): 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 = {}): 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(cmd: string, a: Record): Promise { + 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; } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 127bf41..c504156 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index f88fe73..15c2593 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -919,6 +919,9 @@ export const tr: Record = { '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ı", diff --git a/apps/desktop/src/lib/read-registry.ts b/apps/desktop/src/lib/read-registry.ts new file mode 100644 index 0000000..bd28135 --- /dev/null +++ b/apps/desktop/src/lib/read-registry.ts @@ -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:', 'import', 'account:' ...). 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 { + key: string; + /** Scopes the read belongs to: a query by any of these tags sees it. */ + tags?: string[]; + lanes: Lane[]; + /** Credentials it uses ("apple:", "play:"): their quota pause shows on its buttons. */ + accounts: string[]; + priority: Priority; + run: () => Promise; + /** Stops a running read (the host job). */ + cancel?: () => void; +} + +export interface GroupSpec { + key: string; + tags?: string[]; + /** Narrow keys this read stands for until it releases them (release()) or ends. */ + covers: string[]; + run: (g: GroupHandle) => Promise; +} + +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; + covers: Set; + accounts: string[]; + promise: Promise; + 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(); + private done = new Map(); + private accountsOf = new Map(); + 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(spec: ReadSpec, o: { force?: boolean } = {}): Promise { + 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 }).catch(() => {}); + return own.promise as Promise; + } + 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({ 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(spec: GroupSpec): Promise { + const own = this.entries.get(spec.key); + if (own) return own.promise as Promise; + const covers = new Set(spec.covers); + let resolve!: (r: R) => void, reject!: (e: unknown) => void; + const promise = new Promise((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(); + 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:`). */ +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] ?? ''; diff --git a/apps/desktop/src/lib/scheduler.ts b/apps/desktop/src/lib/scheduler.ts index b2e79e3..a2b94c0 100644 --- a/apps/desktop/src/lib/scheduler.ts +++ b/apps/desktop/src/lib/scheduler.ts @@ -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(task: ReadTask): Promise { diff --git a/apps/desktop/src/screens/Accounts.tsx b/apps/desktop/src/screens/Accounts.tsx index 64aa20b..7dc2f89 100644 --- a/apps/desktop/src/screens/Accounts.tsx +++ b/apps/desktop/src/screens/Accounts.tsx @@ -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 |
- - + +
@@ -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) => (
@@ -209,8 +213,8 @@ export function Accounts({ focus }: { focus?: string }) { return (
- - + + } /> {d.accounts.length > 0 &&
diff --git a/apps/desktop/src/screens/Dependencies.tsx b/apps/desktop/src/screens/Dependencies.tsx index e7a04c8..9320e90 100644 --- a/apps/desktop/src/screens/Dependencies.tsx +++ b/apps/desktop/src/screens/Dependencies.tsx @@ -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 = { 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(d.sidecar?.tools?.androidSdk ?? null); const [info, setInfo] = useState([]); const [error, setError] = useState(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>({}); + const reg = d.registry; const refresh = useCallback(async () => { - setBusy(true); setError(null); + setError(null); try { - const [s, i] = await Promise.all([api.call('system.tools'), api.toolsInfo()]); + const [s, i] = await reg.read({ key: TOOLS_KEY, lanes: ['local'], accounts: [], priority: 0, run: () => Promise.all([api.call('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 => { diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index 12bc6bc..c49926d 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -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('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() {

{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}

- {neverImported === 0 && } + {neverImported === 0 && }
{d.portfolioError && {d.portfolioError}} - - {neverImported > 0 && d.importApps()} disabled={d.importBusy}>{t('action.importApps')}}>{t('portfolio.notImported', { count: neverImported })}} - {!known && d.ready && d.portfolioBusy !== 'live' && { d.readAll(true).catch(() => {}); }}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}} + + {neverImported > 0 && }>{t('portfolio.notImported', { count: neverImported })}} + {!known && d.ready && d.portfolioBusy !== 'live' && { d.readAll(true).catch(() => {}); }} disabled={allReads.busy} aria-busy={allReads.busy}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}}
diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index cde32f6..599c9af 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -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 => )} {t('set.parallelHint')}
- - + + {d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}
diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index d203870..ada04ce 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -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; /** 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, priority: Priority) => Promise; + /** 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, priority: Priority, force?: boolean) => Promise; + /** 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(null); +/** The read registry on its own context (DataProvider fills it), so read buttons and tests need only this. */ +export const RegistryCtx = createContext(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(null); const [portfolio, setPortfolio] = useState(null); const [live, setLive] = useState | null>(null); - const [portfolioBusy, setBusy] = useState(null); const [portfolioError, setPortfolioError] = useState(null); - const [importBusy, setImportBusy] = useState(false); const [icons, setIcons] = useState>({}); const [toasts, setToasts] = useState([]); const timers = useRef(new Map>()); @@ -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 => { - 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(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 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(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 => { + const job = newJob(liveRead ? 'portfolio-live' : 'portfolio'); + const run = async (): Promise => { + 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(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(() => sched.stats()); useEffect(() => { let tm: ReturnType | 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 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(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()); - const schedule = useCallback((target: string, method: string, args: Record, priority: Priority): Promise => { + const schedule = useCallback((target: string, method: string, args: Record, priority: Priority, force = false): Promise => { 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 => { const before = watch ? (lastData.current.has(key) ? lastData.current.get(key) : (await api.cached(method, target, args).catch(() => null))?.value.data ?? null) : null; - const result = await api.call(method, target, { args }, newJob(method)); + const result = await api.call(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({ 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({ 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 => { if (!force && !settingsRef.current.refresh.autoRead) return true; - let ok = true; - let portfolioRead: Promise | null = null; - const pkey = '*\u001fportfolio.status'; - if (!sched.has(pkey) && (force || isStale('portfolio.status', liveRef.current?.at))) { - portfolioRead = sched.enqueue({ 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(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({ + key: READ_ALL_KEY, tags: ['all'], covers: [PORTFOLIO_KEY, ...planned.map(p => p.key)], + run: async g => { + const waits: Promise[] = []; + let portfolioRead: Promise | 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(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 => { 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>({ 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(() => ({ 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 {children}; + toasts, toast, updateToast, dismiss, cancelJob, reg]); + return {children}; } export interface ReadState { @@ -554,6 +604,10 @@ export interface ReadState { 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; } @@ -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(target: string, method: string, o: { network: boolean; args?: Record; title?: string; appName?: string; skip?: boolean }): ReadState { const d = useData(); const argsKey = JSON.stringify(o.args ?? {}); const key = readKey(target, method, JSON.parse(argsKey) as Record); + // `loading` here is only the first look into the cache; the read itself is the registry's const [s, set] = useState, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null }); - const dataRef = useRef(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(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, 0).catch(() => {}); }, [target, method, argsKey]); @@ -643,14 +713,13 @@ export function useRead(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(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(method, target, JSON.parse(argsKey) as Record).then(c => { @@ -676,5 +744,5 @@ export function useRead(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 }; } diff --git a/apps/desktop/src/ui/ReadButtons.tsx b/apps/desktop/src/ui/ReadButtons.tsx new file mode 100644 index 0000000..ca1a011 --- /dev/null +++ b/apps/desktop/src/ui/ReadButtons.tsx @@ -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 ( + + ); +} + +/** 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 ( + + ); +} + +/** 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 ( + + ); +} diff --git a/apps/desktop/src/ui/kit.tsx b/apps/desktop/src/ui/kit.tsx index 62c5106..d118760 100644 --- a/apps/desktop/src/ui/kit.tsx +++ b/apps/desktop/src/ui/kit.tsx @@ -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: " 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 }; 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 }; 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 ( - - {text && {text}} - diff --git a/apps/desktop/tests/read-registry.test.ts b/apps/desktop/tests/read-registry.test.ts new file mode 100644 index 0000000..dc6bf09 --- /dev/null +++ b/apps/desktop/tests/read-registry.test.ts @@ -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(); + const pending = new Map void; fail: (e: unknown) => void }[]>(); + const spec = (key: string, x: Partial> = {}): ReadSpec => ({ + 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(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 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(``); + expect(html).toContain(''); + // 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(''); + expect(html2).toContain(''); + }); +});