// `storebound watch` core, pure and offline: the snapshot an app is reduced to, the diff of two snapshots into events, // the state file (~/.storebound/watch-state.json) and the event log (~/.storebound/events.jsonl, rotated at 5 MB). // STOREBOUND_HOME moves both (the same folder as settings.json, cli/lib/bypass.ts). Network and process work lives in // cli/watch.ts and cli/lib/watch-sinks.ts. import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { type ConsoleIds, consoleUrl } from './console-links'; import { lastValue, PLAY_THRESHOLDS, type HealthSnapshot, weightedAverage } from './health'; import type { StoreRelease } from './desktop-protocol'; import { PHASE_RANK } from './portfolio'; export const EVENT_KINDS = ['rejected', 'approved', 'live', 'rollout_halted', 'crash_threshold', 'review_low', 'review_needs_reply'] as const; export type EventKind = (typeof EVENT_KINDS)[number]; export type WatchStore = 'apple' | 'play'; export interface WatchEvent { at: string; app: string; store: WatchStore; kind: EventKind; version: string | null; from: string | null; to: string | null; detail: string; /** The absolute console deep link for the app (App Store Connect / Play Console). */ url: string; /** Review events only: the store's review id (dedupe key of the ingest sink). */ ref?: string; } /** One version on one store, reduced to what the diff needs. */ export interface RelSnap { phase: string; state: string; fraction: number | null } /** A review the watch has seen (text trimmed; the author is never kept). */ export interface ReviewSnap { id: string; rating: number; replied: boolean; date: string; text: string } /** What one app looked like at the last read. A part that is absent was not read (never guessed). */ export interface AppSnap { name: string; ids: ConsoleIds; releases?: { apple?: Record; play?: Record }; /** Play: 28-day user-perceived rates (null = no data); Apple: the metrics / diagnostics rated poor. */ health?: { play?: { crash: number | null; anr: number | null }; apple?: { poor: string[] } }; healthAt?: string; reviews?: { apple?: ReviewSnap[]; play?: ReviewSnap[] }; } export interface WatchState { version: 1; at: string; apps: Record } export const STATE_VERSION = 1 as const; export const emptyState = (): WatchState => ({ version: STATE_VERSION, at: '', apps: {} }); export const MAX_SEEN_REVIEWS = 300; export const LOW_STARS = 2; export const ROTATE_BYTES = 5 * 1024 * 1024; // ---- files ---- export const watchHome = (env: Record = process.env): string => env.STOREBOUND_HOME ?? join(homedir(), '.storebound'); export const stateFile = (env: Record = process.env): string => join(watchHome(env), 'watch-state.json'); export const eventsFile = (env: Record = process.env): string => join(watchHome(env), 'events.jsonl'); export const watchConfigFile = (env: Record = process.env): string => join(watchHome(env), 'watch.json'); export function readState(file = stateFile()): WatchState { try { const s = JSON.parse(readFileSync(file, 'utf8')) as WatchState; if (s && s.version === STATE_VERSION && s.apps && typeof s.apps === 'object') return s; } catch { /* none yet, or unreadable: a new baseline */ } return emptyState(); } /** Atomic write (temp file, rename). */ export function writeState(s: WatchState, file = stateFile()): void { mkdirSync(dirname(file), { recursive: true }); const tmp = `${file}.tmp-${process.pid}`; writeFileSync(tmp, JSON.stringify(s) + '\n'); renameSync(tmp, file); } /** Appends events as JSON lines; a log of `maxBytes` or more is moved to .1 first (one old file is kept). */ export function appendEvents(events: WatchEvent[], file = eventsFile(), maxBytes = ROTATE_BYTES): void { if (!events.length) return; mkdirSync(dirname(file), { recursive: true }); try { if (existsSync(file) && statSync(file).size >= maxBytes) { rmSync(`${file}.1`, { force: true }); renameSync(file, `${file}.1`); } } catch { /* rotation is best effort; appending still works */ } appendFileSync(file, events.map(e => JSON.stringify(e)).join('\n') + '\n'); } /** Every event of the log (the old file first), oldest first. Unreadable lines are skipped. */ export function readEvents(file = eventsFile()): WatchEvent[] { const out: WatchEvent[] = []; for (const f of [`${file}.1`, file]) { let text = ''; try { text = readFileSync(f, 'utf8'); } catch { continue; } for (const line of text.split('\n')) { if (!line.trim()) continue; try { const e = JSON.parse(line) as WatchEvent; if (e && typeof e.at === 'string' && typeof e.kind === 'string') out.push(e); } catch { /* a torn line */ } } } return out; } /** `events` filters: since (ms back from now), app (case-insensitive name match), kind. Newest last. */ export function filterEvents(evs: WatchEvent[], o: { sinceMs?: number; now?: number; app?: string; kind?: string[] } = {}): WatchEvent[] { const cut = o.sinceMs !== undefined ? (o.now ?? Date.now()) - o.sinceMs : null; const app = o.app?.toLowerCase(); return evs.filter(e => (cut === null || Date.parse(e.at) >= cut) && (!app || e.app.toLowerCase() === app || e.app.toLowerCase().includes(app)) && (!o.kind?.length || o.kind.includes(e.kind))); } /** "15m", "24h", "2d"; a bare number is `bare` ("m" for --every, "s" otherwise). */ export function durationMs(s: string, bare: 's' | 'm' = 's'): number { const m = /^(\d+(?:\.\d+)?)(s|m|h|d)?$/.exec(s.trim()); if (!m) throw new Error(`bad duration "${s}" (use 15m, 24h, 2d)`); return Math.round(Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[(m[2] ?? bare) as 's' | 'm' | 'h' | 'd']); } // ---- reducing store reads to a snapshot ---- /** Per version of one store, the furthest-along row (live before rollout before review ...). */ export function releaseMap(rows: StoreRelease[], store: 'ios' | 'android'): Record { const out: Record = {}; for (const r of rows) { if (r.store !== store || !r.version) continue; const rank = PHASE_RANK[r.phase]; if (!out[r.version] || rank < out[r.version]!.rank) out[r.version] = { phase: r.phase, state: r.state, fraction: r.fraction, rank }; } return Object.fromEntries(Object.entries(out).map(([v, { rank: _r, ...s }]) => [v, s])); } /** The Play rates the health findings judge (28-day user-weighted, else the window average), and Apple's poor items. */ export function healthSnap(h: HealthSnapshot): NonNullable { const out: NonNullable = {}; if (h.play && !('error' in h.play)) { out.play = { crash: lastValue(h.play.crash, 'userPerceivedCrashRate28dUserWeighted') ?? weightedAverage(h.play.crash, 'userPerceivedCrashRate'), anr: lastValue(h.play.anr, 'userPerceivedAnrRate28dUserWeighted') ?? weightedAverage(h.play.anr, 'userPerceivedAnrRate'), }; } if (h.apple && !('error' in h.apple)) { out.apple = { poor: [ ...h.apple.metrics.filter(m => m.goal === 'poor').map(m => `${m.metric} ${m.value} ${m.unit} on ${m.version}`), ...h.apple.signatures.filter(s => s.weight >= 0.25).map(s => `${s.type} build ${s.build}: ${s.signature.slice(0, 80)}`), ] }; } return out; } /** Keeps what the new read has and what the old one had for a part this read could not get. Pure. */ export function mergeSnap(prev: AppSnap | undefined, next: AppSnap): AppSnap { const seen = (a?: ReviewSnap[], b?: ReviewSnap[]): ReviewSnap[] | undefined => { if (!a && !b) return undefined; const byId = new Map(); for (const r of [...(b ?? []), ...(a ?? [])]) byId.set(r.id, r); return [...byId.values()].sort((x, y) => y.date.localeCompare(x.date)).slice(0, MAX_SEEN_REVIEWS); }; const releases = { ...(prev?.releases ?? {}), ...(next.releases ?? {}) }; const health = { ...(prev?.health ?? {}), ...(next.health ?? {}) }; const reviews = { apple: seen(next.reviews?.apple, prev?.reviews?.apple), play: seen(next.reviews?.play, prev?.reviews?.play) }; const hasR = reviews.apple || reviews.play; return { name: next.name, ids: { ...prev?.ids, ...Object.fromEntries(Object.entries(next.ids).filter(([, v]) => v)) }, ...(Object.keys(releases).length ? { releases } : {}), ...(Object.keys(health).length ? { health } : {}), ...(next.healthAt ?? prev?.healthAt ? { healthAt: next.healthAt ?? prev?.healthAt } : {}), ...(hasR ? { reviews: { ...(reviews.apple ? { apple: reviews.apple } : {}), ...(reviews.play ? { play: reviews.play } : {}) } } : {}) }; } // ---- the diff ---- const REACHING = new Set(['live', 'rollout', 'halted', 'released']); const pct = (x: number | null): string => (x === null ? 'n/a' : `${(x * 100).toFixed(2)} %`); const storeName = (s: WatchStore): string => (s === 'apple' ? 'App Store' : 'Google Play'); /** The console page that explains each kind, per store. Apple has no deep link for reviews or crashes in the table: * the version page / TestFlight stand in (the app's own pages, never a made-up path). */ export function eventUrl(kind: EventKind, store: WatchStore, ids: ConsoleIds): string { if (store === 'apple') return consoleUrl(kind === 'rejected' ? 'asc.app-review' : kind === 'crash_threshold' ? 'asc.testflight' : 'asc.version', ids); return consoleUrl(kind === 'crash_threshold' ? 'play.vitals' : kind === 'review_low' || kind === 'review_needs_reply' ? 'play.dashboard' : 'play.production', ids); } const snippet = (s: string, n = 140): string => s.replace(/\s+/g, ' ').trim().slice(0, n); /** * Events between two snapshots of one app. `prev` undefined = the app's first read: nothing (baseline). A part that * either side does not have is skipped, so a store that could not be read never produces an event, and a part read * for the first time is a baseline too. Pure. */ export function diffApp(prev: AppSnap | undefined, next: AppSnap, at: string): WatchEvent[] { if (!prev) return []; const out: WatchEvent[] = []; const ids = { ...prev.ids, ...next.ids }; const ev = (store: WatchStore, kind: EventKind, version: string | null, from: string | null, to: string | null, detail: string, ref?: string): void => { out.push({ at, app: next.name, store, kind, version, from, to, detail, url: eventUrl(kind, store, ids), ...(ref ? { ref } : {}) }); }; for (const store of ['apple', 'play'] as const) { const p = prev.releases?.[store], n = next.releases?.[store]; if (p && n) for (const [version, now] of Object.entries(n)) { const was = p[version]; if (was?.phase === now.phase) continue; const from = was?.state ?? null, to = now.state, v = version; if (now.phase === 'rejected') ev(store, 'rejected', v, from, to, `${storeName(store)} ${v}: review ended in ${to}${store === 'apple' ? ' (the reason is in the Resolution Center)' : ''}`); else if (now.phase === 'approved') ev(store, 'approved', v, from, to, `${storeName(store)} ${v}: passed review, waiting for release (${to})`); else if ((now.phase === 'live' || now.phase === 'rollout') && !REACHING.has(was?.phase ?? '')) { ev(store, 'live', v, from, to, `${storeName(store)} ${v} is ${now.phase === 'live' ? 'live' : `rolling out${now.fraction !== null ? ` at ${Math.round(now.fraction * 1000) / 10} %` : ''}`}`); } else if (now.phase === 'halted') ev(store, 'rollout_halted', v, from, to, `${storeName(store)} ${v}: rollout halted${now.fraction !== null ? ` at ${Math.round(now.fraction * 1000) / 10} %` : ''}`); } } const pp = prev.health?.play, np = next.health?.play; if (pp && np) { for (const [key, label, limit] of [['crash', 'user-perceived crash rate', PLAY_THRESHOLDS.crash], ['anr', 'user-perceived ANR rate', PLAY_THRESHOLDS.anr]] as const) { const was = pp[key], now = np[key]; if (now !== null && now >= limit && (was === null || was < limit)) { ev('play', 'crash_threshold', null, pct(was), pct(now), `${label} (28 d) ${pct(now)} crossed the Play bad behavior threshold ${pct(limit)}`); } } } const pa = prev.health?.apple, na = next.health?.apple; if (pa && na) for (const item of na.poor) if (!pa.poor.includes(item)) ev('apple', 'crash_threshold', null, null, 'poor', `App Store diagnostics: ${item}`); for (const store of ['apple', 'play'] as const) { const p = prev.reviews?.[store], n = next.reviews?.[store]; if (!p || !n) continue; const seen = new Set(p.map(r => r.id)); for (const r of n) { if (seen.has(r.id)) continue; if (r.rating >= 1 && r.rating <= LOW_STARS) ev(store, 'review_low', null, null, `${r.rating} stars`, `${r.rating}-star review${r.replied ? '' : ', no reply yet'}: ${snippet(r.text)}`, r.id); else if (!r.replied) ev(store, 'review_needs_reply', null, null, `${r.rating} stars`, `${r.rating}-star review without a reply: ${snippet(r.text)}`, r.id); } } return out; } export interface PassTarget { key: string; name: string } export interface AppRead { snap: AppSnap | null; errors: string[] } /** * One pass over the targets: `read` gives each app's new snapshot; the result is the new state, the events and the * errors. An app whose read failed entirely keeps its old snapshot. Apps in the state that are no longer targets are * kept (a folder removed for a while does not lose its baseline). Pure given `read`. */ export async function runPass(o: { targets: PassTarget[]; prev: WatchState; at: string; read: (t: PassTarget, prev: AppSnap | undefined) => Promise; concurrency?: number }): Promise<{ state: WatchState; events: WatchEvent[]; errors: { app: string; error: string }[]; baseline: string[] }> { const events: WatchEvent[] = []; const errors: { app: string; error: string }[] = []; const baseline: string[] = []; const apps: Record = { ...o.prev.apps }; let next = 0; const worker = async (): Promise => { while (next < o.targets.length) { const t = o.targets[next++]!; const before = o.prev.apps[t.key]; let r: AppRead; try { r = await o.read(t, before); } catch (e) { r = { snap: null, errors: [(e as Error).message] }; } for (const e of r.errors) errors.push({ app: t.name, error: e }); if (!r.snap) continue; events.push(...diffApp(before, r.snap, o.at)); if (!before) baseline.push(t.name); apps[t.key] = mergeSnap(before, r.snap); } }; await Promise.all(Array.from({ length: Math.max(1, Math.min(o.concurrency ?? 2, o.targets.length)) }, worker)); return { state: { version: STATE_VERSION, at: o.at, apps }, events, errors, baseline }; } /** A made-up event for `watch test`. */ export const testEvent = (at = new Date().toISOString()): WatchEvent => ({ at, app: 'storebound test', store: 'apple', kind: 'live', version: '1.0.0', from: 'READY_FOR_SALE', to: 'READY_FOR_SALE', detail: 'This is a test event from storebound watch test', url: 'https://appstoreconnect.apple.com/apps' });