// How long a saved read stays fresh, which store lanes a read uses, and the reads the background reader does for // every app. Pure (tests/scheduler.test.ts). import type { Lane } from './scheduler'; const MIN = 60_000; /** Time to live of a saved read per method: status and submission state 5 min, reviews 15 min, catalog / prices / * listings 30 min, health and analytics 6 h (the stores update them daily). */ export const TTL: Record = { 'portfolio.status': 5 * MIN, 'apple.status': 5 * MIN, 'play.status': 5 * MIN, 'apple.testflight_status': 5 * MIN, 'reviews.list': 15 * MIN, 'catalog.diff': 30 * MIN, 'prices.current': 30 * MIN, 'project.listings': 30 * MIN, 'project.editable': 30 * MIN, 'listing.live': 30 * MIN, 'art.live': 6 * 60 * MIN, 'apple.ppo_status': 30 * MIN, validate: 30 * MIN, scan: 30 * MIN, health: 6 * 60 * MIN, 'health.issue': 6 * 60 * MIN, 'analytics.compare': 6 * 60 * MIN, 'aso.audit': 6 * 60 * MIN, }; export const DEFAULT_TTL = 30 * MIN; export const ttlOf = (method: string): number => TTL[method] ?? DEFAULT_TTL; /** True when a read saved at `at` (ISO) is older than its TTL, or there is none. */ export function isStale(method: string, at: string | null | undefined, now = Date.now()): boolean { if (!at) return true; const t = Date.parse(at); return !Number.isFinite(t) || now - t >= ttlOf(method); } /** The store lanes a read takes a slot in. */ export function lanesOf(method: string, o: { ios: boolean; android: boolean }): Lane[] { if (method.startsWith('apple.')) return ['apple']; if (method.startsWith('play.')) return ['play']; if (['project.listings', 'project.editable', 'scan', 'analytics.compare', 'aso.audit', 'reviews.drafts', 'project.declarations', 'project.renders', 'project.icon'].includes(method)) return ['local']; const l: Lane[] = []; if (o.ios) l.push('apple'); if (o.android) l.push('play'); return l.length ? l : ['local']; } export interface PlannedRead { method: string; args: Record } /** The reads the background reader does for one app (the ones its tabs show), by what the app has. */ export function readsFor(o: { folder: boolean; ios: boolean; android: boolean }): PlannedRead[] { const r: PlannedRead[] = []; const add = (method: string, args: Record = {}) => r.push({ method, args }); if (o.ios) { add('apple.status'); add('apple.testflight_status'); } if (o.android) add('play.status'); if (o.ios || o.android) { add('reviews.list', { days: 30 }); add('health'); } if (o.folder) { add('scan'); add('project.listings'); add('project.editable'); if (o.ios || o.android) { add('catalog.diff'); add('prices.current'); add('validate'); } add('analytics.compare'); add('aso.audit'); } if (o.ios) add('apple.ppo_status'); // store-only apps: the listing editor's live texts if (!o.folder && (o.ios || o.android)) add('listing.live'); return r; } /** Key of one read in the scheduler and the read bus. */ export const readKey = (target: string, method: string, args: Record = {}): string => `${target}\u001f${method}\u001f${JSON.stringify(args)}`; /** "429", "RESOURCE_EXHAUSTED", "rate limit": the store refused for its quota (not the read itself). */ export const looksThrottled = (text: string | undefined): boolean => !!text && /\b429\b|RESOURCE_EXHAUSTED|rate.?limit|quota exceeded/i.test(text); /** The one-line reason of a failed read, for "couldn't refresh: ". */ export function oneLine(text: string, max = 140): string { const l = text.trim().split('\n').map(s => s.trim()).filter(Boolean).at(-1) ?? ''; const s = l.replace(/^\S+ \[[^\]]+\] /, ''); return s.length > max ? `${s.slice(0, max - 1)}…` : s; }