storebound/apps/desktop/src/lib/freshness.ts

82 lines
3.7 KiB
TypeScript

// 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<string, number> = {
'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<string, unknown> }
/** 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<string, unknown> = {}) => 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, unknown> = {}): 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: <reason>". */
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;
}