271 lines
15 KiB
TypeScript
271 lines
15 KiB
TypeScript
// `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<string, RelSnap>; play?: Record<string, RelSnap> };
|
|
/** 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<string, AppSnap> }
|
|
|
|
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<string, string | undefined> = process.env): string => env.STOREBOUND_HOME ?? join(homedir(), '.storebound');
|
|
export const stateFile = (env: Record<string, string | undefined> = process.env): string => join(watchHome(env), 'watch-state.json');
|
|
export const eventsFile = (env: Record<string, string | undefined> = process.env): string => join(watchHome(env), 'events.jsonl');
|
|
export const watchConfigFile = (env: Record<string, string | undefined> = 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 <file>.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<string, RelSnap> {
|
|
const out: Record<string, RelSnap & { rank: number }> = {};
|
|
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<AppSnap['health']> {
|
|
const out: NonNullable<AppSnap['health']> = {};
|
|
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<string, ReviewSnap>();
|
|
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<AppRead>; 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<string, AppSnap> = { ...o.prev.apps };
|
|
let next = 0;
|
|
const worker = async (): Promise<void> => {
|
|
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' });
|