diff --git a/apps/desktop/src/lib/scheduler.ts b/apps/desktop/src/lib/scheduler.ts index 14586de..b2e79e3 100644 --- a/apps/desktop/src/lib/scheduler.ts +++ b/apps/desktop/src/lib/scheduler.ts @@ -42,8 +42,8 @@ export interface Outcome { export interface ReadTask { key: string; lanes: Lane[]; - /** Keys of the store credentials it uses ("apple:", "play:"). */ - credentials: string[]; + /** Keys of the store accounts it uses ("apple:", "play:"). */ + accounts: string[]; priority: Priority; run: () => Promise; } @@ -175,7 +175,7 @@ export class ReadScheduler { private ready(i: Item, now: number): number { // 0 = can start now; otherwise the time it can (Infinity: waits on a lane slot) let at = i.notBefore; - for (const c of i.task.credentials) at = Math.max(at, this.nextAt.get(c) ?? 0); + for (const c of i.task.accounts) at = Math.max(at, this.nextAt.get(c) ?? 0); if (i.task.lanes.some(l => this.busy[l] >= Math.max(1, this.o.limits[l]))) return Infinity; return at <= now ? 0 : at; } @@ -204,7 +204,7 @@ export class ReadScheduler { this.queue = this.queue.filter(x => x !== i); this.running.set(i.task.key, i); for (const l of i.task.lanes) this.busy[l]++; - for (const c of i.task.credentials) this.nextAt.set(c, now + this.gap(c, now)); + for (const c of i.task.accounts) this.nextAt.set(c, now + this.gap(c, now)); i.attempt++; this.emit(); i.task.run().then(r => this.finish(i, r, undefined, true), e => this.finish(i, undefined, e, false)); @@ -227,7 +227,7 @@ export class ReadScheduler { const exp = Math.min(this.o.backoffMaxMs, this.o.backoffBaseMs * 2 ** (i.attempt - 1)); const delay = Math.round(exp / 2 + this.clock.random() * exp / 2); // "equal jitter" i.notBefore = now + delay; - for (const c of i.task.credentials) this.nextAt.set(c, Math.max(this.nextAt.get(c) ?? 0, now + delay)); + for (const c of i.task.accounts) this.nextAt.set(c, Math.max(this.nextAt.get(c) ?? 0, now + delay)); this.queue.push(i); } else { this.done++; diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index c51239f..5055326 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -46,7 +46,7 @@ export function classifyRead(result: unknown, error: unknown, task: ReadTask c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining }; + out.quota = { credential: task.accounts.find(c => c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining }; if (r && !r.ok && (q?.throttled || looksThrottled(r.output))) out.throttled = true; if (error !== undefined && looksThrottled(errorText(error))) out.throttled = true; return out; @@ -241,7 +241,7 @@ export function DataProvider({ children }: { children: ReactNode }) { const focusRef = useRef(null); const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []); - const credentialsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => { + const accountsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => { const c: string[] = []; if (lanes.includes('apple')) c.push(`apple:${a?.project?.appleAccount ?? a?.ios?.account ?? 'default'}`); if (lanes.includes('play')) c.push(`play:${a?.project?.playAccount ?? a?.android?.account ?? 'default'}`); @@ -254,7 +254,7 @@ export function DataProvider({ children }: { children: ReactNode }) { const app = appsRef.current.find(a => a.target === target); const pf = platformsOf(app, app ? statusRef.current(app) : undefined); const lanes = lanesOf(method, pf); - const p = sched.enqueue({ key, lanes, credentials: credentialsOf(app, lanes), priority, run: () => api.call(method, target, { args }, newJob(method)) }); + const p = sched.enqueue({ key, lanes, accounts: accountsOf(app, lanes), priority, run: () => api.call(method, target, { args }, newJob(method)) }); emitRead(key, { kind: 'start' }); p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) })); return p; @@ -263,7 +263,7 @@ export function DataProvider({ children }: { children: ReactNode }) { const readAll = useCallback(async (force = false) => { if (!force && !settingsRef.current.refresh.autoRead) return; if (force || isStale('portfolio.status', liveRef.current?.at)) { - sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], credentials: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {}); + sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {}); } const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing) .sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current)); diff --git a/apps/desktop/tests/scheduler.test.ts b/apps/desktop/tests/scheduler.test.ts index b2ba8d1..8124e94 100644 --- a/apps/desktop/tests/scheduler.test.ts +++ b/apps/desktop/tests/scheduler.test.ts @@ -36,7 +36,7 @@ const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve() function controlled() { const runs: { key: string; done: (v: unknown) => void; fail: (e: unknown) => void }[] = []; const task = (key: string, o: Partial> = {}): ReadTask => ({ - key, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, ...o, + key, lanes: ['apple'], accounts: ['apple:k1'], priority: 2, ...o, run: () => new Promise((done, fail) => { runs.push({ key, done, fail }); }), }); return { runs, task }; @@ -48,8 +48,8 @@ describe('ReadScheduler', () => { const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 2, play: 1 } }); const { runs, task } = controlled(); for (const k of ['a', 'b', 'c', 'd']) s.enqueue(task(k)).catch(() => {}); - s.enqueue(task('p1', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {}); - s.enqueue(task('p2', { lanes: ['play'], credentials: ['play:k'] })).catch(() => {}); + s.enqueue(task('p1', { lanes: ['play'], accounts: ['play:k'] })).catch(() => {}); + s.enqueue(task('p2', { lanes: ['play'], accounts: ['play:k'] })).catch(() => {}); await clock.advance(0); expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1']); expect(s.stats()).toMatchObject({ running: 3, queued: 3, total: 6 }); @@ -60,8 +60,8 @@ describe('ReadScheduler', () => { const both = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1, play: 1 } }); const c2 = controlled(); both.enqueue(c2.task('apple-only')).catch(() => {}); - both.enqueue(c2.task('both', { lanes: ['apple', 'play'], credentials: [] })).catch(() => {}); - both.enqueue(c2.task('play-only', { lanes: ['play'], credentials: [] })).catch(() => {}); + both.enqueue(c2.task('both', { lanes: ['apple', 'play'], accounts: [] })).catch(() => {}); + both.enqueue(c2.task('play-only', { lanes: ['play'], accounts: [] })).catch(() => {}); await clock.advance(0); expect(c2.runs.map(r => r.key)).toEqual(['apple-only', 'play-only']); }); @@ -100,12 +100,12 @@ describe('ReadScheduler', () => { expect(runs).toHaveLength(2); }); - test('starts on one credential are spaced; other credentials are not held up', async () => { + test('starts on one credential are spaced; other accounts are not held up', async () => { const clock = new FakeClock(); const s = new ReadScheduler({ clock, spacingMs: 400, limits: { apple: 8 } }); const { runs, task } = controlled(); for (const k of ['a', 'b', 'c']) s.enqueue(task(k)).catch(() => {}); - s.enqueue(task('other', { credentials: ['apple:k2'] })).catch(() => {}); + s.enqueue(task('other', { accounts: ['apple:k2'] })).catch(() => {}); await clock.advance(0); expect(runs.map(r => r.key)).toEqual(['a', 'other']); await clock.advance(399); @@ -122,10 +122,10 @@ describe('ReadScheduler', () => { const classify = (r: unknown): Outcome => ({ throttled: (r as { status: number }).status === 429 }); const s = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 1000, backoffMaxMs: 60_000, maxAttempts: 4, classify }); const starts: number[] = []; - const p = s.enqueue({ key: 'x', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { starts.push(clock.now()); attempts++; return { status: attempts < 3 ? 429 : 200 }; } }); + const p = s.enqueue({ key: 'x', lanes: ['play'], accounts: ['play:k'], priority: 0, run: async () => { starts.push(clock.now()); attempts++; return { status: attempts < 3 ? 429 : 200 }; } }); let other = false; await clock.advance(0); - s.enqueue({ key: 'y', lanes: ['play'], credentials: ['play:k'], priority: 2, run: async () => { other = true; return { status: 200 }; } }).catch(() => {}); + s.enqueue({ key: 'y', lanes: ['play'], accounts: ['play:k'], priority: 2, run: async () => { other = true; return { status: 200 }; } }).catch(() => {}); await clock.advance(0); // first try at 0; jitter (rand 0.5) -> 0.75 x the exponential step: 750 ms, then 1500 ms expect(starts).toEqual([0]); @@ -143,7 +143,7 @@ describe('ReadScheduler', () => { // always 429: resolved with the last answer after maxAttempts, counted as failed const s2 = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 10, maxAttempts: 3, classify }); let n = 0; - const q = s2.enqueue({ key: 'z', lanes: ['play'], credentials: ['play:k'], priority: 0, run: async () => { n++; return { status: 429 }; } }); + const q = s2.enqueue({ key: 'z', lanes: ['play'], accounts: ['play:k'], priority: 0, run: async () => { n++; return { status: 429 }; } }); await clock.advance(1000); expect(await q).toEqual({ status: 429 }); expect(n).toBe(3); @@ -152,11 +152,11 @@ describe('ReadScheduler', () => { test('App Store Connect quota: spreads the rest of the hour when it runs low, pauses at the reserve', async () => { const clock = new FakeClock(); let remaining = 3412; - const classify = (_r: unknown, _e: unknown, t: ReadTask): Outcome => ({ quota: { credential: t.credentials[0]!, limit: 3600, remaining } }); + const classify = (_r: unknown, _e: unknown, t: ReadTask): Outcome => ({ quota: { credential: t.accounts[0]!, limit: 3600, remaining } }); const s = new ReadScheduler({ clock, spacingMs: 100, reserve: 100, slowBelow: 0.2, pauseMs: 600_000, limits: { apple: 4 }, classify }); const starts: number[] = []; const run = async () => { starts.push(clock.now()); return {}; }; - const add = (k: string) => s.enqueue({ key: k, lanes: ['apple'], credentials: ['apple:k1'], priority: 2, run }).catch(() => {}); + const add = (k: string) => s.enqueue({ key: k, lanes: ['apple'], accounts: ['apple:k1'], priority: 2, run }).catch(() => {}); add('a'); add('b'); await clock.advance(0); await clock.advance(100); diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 2f2131f..195a688 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -124,6 +124,7 @@ export const WRAPPED: { apple: Record; play: Record; play: Record { const r = await s.call('reviews.list', { virtual: V, args: { days: 30 }, credentials: APPLE }); expect(r.error).toBeUndefined(); expect(existsSync(join(s.calls[0]!.o.cwd, 'storebound.config.json'))).toBe(true); - expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'health', 'play_status', 'reviews_list']); + expect([...VIRTUAL_READS].sort()).toEqual(['apple_ppo_status', 'apple_status', 'apple_testflight_status', 'health', 'health_issue', 'play_status', 'reviews_list']); for (const m of ['play.listing', 'catalog.diff', 'project.listings', 'check', 'reviews.approve']) expect((await s.call(m, { virtual: V, args: m === 'reviews.approve' ? { file: 'apple-1.md' } : {} })).error).toMatchObject({ code: ERR.invalidParams }); expect((await s.call('health', { virtual: V, project: '/tmp' })).error).toMatchObject({ code: ERR.invalidParams }); diff --git a/tests/store-real-shapes.test.ts b/tests/store-real-shapes.test.ts index 66a143d..f608550 100644 --- a/tests/store-real-shapes.test.ts +++ b/tests/store-real-shapes.test.ts @@ -187,7 +187,7 @@ describe('health with no field data yet', () => { }) as typeof fetch; const h = await collectAppleHealth(mkdtempSync(join(tmpdir(), 'sb-real-')), 'com.example.cutter', '6400000401', 7); expect(h.metrics).toEqual([]); - expect(h.signatures).toEqual([{ build: '10', type: 'HANGS', signature: 'main thread', weight: 0.4 }]); + expect(h.signatures).toEqual([{ build: '10', type: 'HANGS', signature: 'main thread', weight: 0.4, id: 's1' }]); }); test('other errors still make the store unreadable', async () => { ascKey();