// The read registry (src/lib/read-registry.ts) over the scheduler, with a fake clock: single-flight per key, scope // overlap (tags, broad reads covering narrow keys), "read just now", invalidation, one error to every waiter, // cancellation, the quota pause on the query, which reads a write invalidates; and the component level: two read // buttons whose reads overlap both render busy and disabled from the registry (react-dom/server, no DOM needed). import { describe, expect, test } from 'bun:test'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { readKey } from '../src/lib/freshness'; import { affectedReads, appTags, PORTFOLIO_KEY, READ_ALL_KEY, type ReadQuery, ReadRegistry, type ReadSpec } from '../src/lib/read-registry'; import { RegistryCtx, useReadState } from '../src/store'; import { type Clock, ReadScheduler } from '../src/lib/scheduler'; class FakeClock implements Clock { t = 1_000_000; private timers: { at: number; fn: () => void; id: number }[] = []; private n = 0; now() { return this.t; } setTimeout(fn: () => void, ms: number) { const id = ++this.n; this.timers.push({ at: this.t + ms, fn, id }); return id; } clearTimeout(h: unknown) { this.timers = this.timers.filter(x => x.id !== h); } random() { return 0.5; } async advance(ms: number) { const end = this.t + ms; for (;;) { await flush(); const next = this.timers.filter(x => x.at <= end).sort((a, b) => a.at - b.at)[0]; if (!next) break; this.t = next.at; this.timers = this.timers.filter(x => x !== next); next.fn(); } this.t = end; await flush(); } } const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; /** A registry whose reads resolve when the test says so; `calls` counts host calls per key. */ function setup(o: { justNowMs?: number } = {}) { const clock = new FakeClock(); const sched = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 4, play: 4, local: 2 } }); const events: string[] = []; const reg = new ReadRegistry(sched, { clock, justNowMs: o.justNowMs ?? 20_000, onEvent: (k, e) => events.push(`${e.kind}:${k}`) }); const calls = new Map(); const pending = new Map void; fail: (e: unknown) => void }[]>(); const spec = (key: string, x: Partial> = {}): ReadSpec => ({ key, lanes: ['apple'], accounts: ['apple:k1'], priority: 0, tags: x.tags ?? appTags('app1'), ...x, run: () => new Promise((done, fail) => { calls.set(key, (calls.get(key) ?? 0) + 1); pending.set(key, [...(pending.get(key) ?? []), { done, fail }]); }), }); const finish = (key: string, v: unknown = { ok: true }) => { for (const p of pending.get(key) ?? []) p.done(v); pending.delete(key); }; const fail = (key: string, e: unknown) => { for (const p of pending.get(key) ?? []) p.fail(e); pending.delete(key); }; return { clock, sched, reg, calls, spec, finish, fail, events }; } const K1 = readKey('app1', 'health', {}); const K2 = readKey('app1', 'reviews.list', { days: 30 }); const K3 = readKey('app2', 'health', {}); describe('ReadRegistry', () => { test('single-flight: the same key twice is one host call and one promise; both get the result', async () => { const { clock, reg, calls, spec, finish, events } = setup(); const a = reg.read(spec(K1)); const b = reg.read(spec(K1)); await clock.advance(0); expect(calls.get(K1)).toBe(1); expect(reg.state(K1).busy).toBe(true); finish(K1, { ok: true, n: 7 }); expect(await a).toEqual({ ok: true, n: 7 }); expect(await b).toEqual({ ok: true, n: 7 }); expect(reg.state(K1).busy).toBe(false); // the bus hears one start and one done, never a join expect(events).toEqual([`start:${K1}`, `done:${K1}`]); }); test('just now: a second click right after a finished read answers from it; after the window it reads again', async () => { const { clock, reg, calls, spec, finish } = setup({ justNowMs: 10_000 }); const a = reg.read(spec(K1)); await clock.advance(0); finish(K1, 'first'); await a; expect(reg.state(K1).justNow).toBe(true); expect(await reg.read(spec(K1))).toBe('first'); expect(calls.get(K1)).toBe(1); await clock.advance(10_001); expect(reg.state(K1).justNow).toBe(false); const c = reg.read(spec(K1)); await clock.advance(0); finish(K1, 'second'); expect(await c).toBe('second'); expect(calls.get(K1)).toBe(2); // force skips the memo const f = reg.read(spec(K1), { force: true }); await clock.advance(0); finish(K1, 'third'); expect(await f).toBe('third'); expect(calls.get(K1)).toBe(3); }); test('invalidation drops the memo of the keys a write touched, and only those', async () => { const { clock, reg, calls, spec, finish } = setup(); for (const k of [K1, K2]) { const p = reg.read(spec(k)); await clock.advance(0); finish(k); await p; } expect(reg.invalidate(k => k === K2)).toEqual([K2]); reg.read(spec(K1)).catch(() => {}); reg.read(spec(K2)).catch(() => {}); await clock.advance(0); expect(calls.get(K1)).toBe(1); expect(calls.get(K2)).toBe(2); }); test('an error reaches every waiter, is the key\'s error, and is not memoized', async () => { const { clock, reg, calls, spec, fail, finish } = setup(); const a = reg.read(spec(K1)), b = reg.read(spec(K1)); await clock.advance(0); fail(K1, new Error('403 SERVICE_DISABLED')); await expect(a).rejects.toThrow('SERVICE_DISABLED'); await expect(b).rejects.toThrow('SERVICE_DISABLED'); expect(reg.state(K1)).toMatchObject({ busy: false, error: '403 SERVICE_DISABLED', justNow: false }); const c = reg.read(spec(K1)); await clock.advance(0); finish(K1, 'ok'); expect(await c).toBe('ok'); expect(calls.get(K1)).toBe(2); expect(reg.state(K1).error).toBeNull(); }); test('scope: a narrow read makes the broad tag busy; another app\'s query stays free', async () => { const { clock, reg, spec, finish } = setup(); reg.read(spec(K1)).catch(() => {}); await clock.advance(0); expect(reg.state({ tags: ['all'] }).busy).toBe(true); expect(reg.state({ tags: ['app:app1'] }).busy).toBe(true); expect(reg.state(K3).busy).toBe(false); expect(reg.state({ keys: [K3, K1] }).busy).toBe(true); finish(K1); await flush(); expect(reg.state({ tags: ['all'] }).busy).toBe(false); }); test('a broad read covers its narrow keys: they are busy at once, a click on one waits and does not read again', async () => { const { clock, reg, calls, spec, finish } = setup(); let release!: (k: string) => void; let go!: () => void; const gate = new Promise(r => { go = r; }); const all = reg.group({ key: READ_ALL_KEY, tags: ['all'], covers: [PORTFOLIO_KEY, K1, K2], run: async g => { release = g.release; await gate; // the TTL checks const ps = [K1, K2].map(k => { g.release(k); return reg.read(spec(k)); }); return Promise.all(ps); } }); await flush(); // nothing started yet, but every covered key already shows busy expect(calls.size).toBe(0); for (const k of [K1, K2, PORTFOLIO_KEY]) expect(reg.state(k).busy).toBe(true); expect(reg.state({ tags: ['all'] }).busy).toBe(true); // the card's refresh click while covered: joins the group, no read of its own const click = reg.read(spec(K1)); // a second Refresh all joins the first const again = reg.group({ key: READ_ALL_KEY, covers: [], run: async () => { throw new Error('must not run'); } }); expect(again).toBe(all as Promise as typeof again); release(PORTFOLIO_KEY); expect(reg.state(PORTFOLIO_KEY).busy).toBe(false); go(); await clock.advance(0); expect(calls.get(K1)).toBe(1); finish(K1, 'one'); finish(K2, 'two'); await all; expect(await click).toBe('one'); expect(calls.get(K1)).toBe(1); expect(reg.state({ tags: ['all'] }).busy).toBe(false); }); test('cancel: a queued read is dropped and every waiter hears "cancelled"; a running one cancels its job', async () => { const { clock, sched, reg, spec } = setup(); sched.setLimits({ apple: 1 }); let cancelled = 0; reg.read(spec(K1, { cancel: () => { cancelled++; } })).catch(() => {}); const q1 = reg.read(spec(K3)), q2 = reg.read(spec(K3)); await clock.advance(0); reg.cancel(K3); await expect(q1).rejects.toThrow('cancelled'); await expect(q2).rejects.toThrow('cancelled'); await flush(); expect(reg.state(K3).busy).toBe(false); // a cancel is not a failure the card keeps showing expect(reg.state(K3).error).toBeNull(); reg.cancel(K1); expect(cancelled).toBe(1); }); test('a 429 backoff of the account shows as paused on every query that uses it', async () => { const clock = new FakeClock(); let n = 0; const sched = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 60_000, classify: () => ({ throttled: n++ === 0 }) }); const reg = new ReadRegistry(sched, { clock }); const p = reg.read({ key: K1, tags: appTags('app1'), lanes: ['play'], accounts: ['play:acc'], priority: 0, run: async () => 'x' }); await clock.advance(0); const st = reg.state(K1); expect(st.busy).toBe(true); expect(st.pausedUntil).toBeGreaterThan(clock.now()); expect(reg.state({ tags: ['all'] }).pausedUntil).toBe(st.pausedUntil); await clock.advance(120_000); expect(await p).toBe('x'); expect(reg.state(K1).pausedUntil).toBeNull(); }); test('a joining click with a higher priority raises the queued read', async () => { const { clock, sched, reg, calls, spec, finish } = setup(); sched.setLimits({ apple: 1 }); reg.read(spec('blocker')).catch(() => {}); reg.read(spec(K2, { priority: 2 })).catch(() => {}); reg.read(spec(K3, { priority: 2 })).catch(() => {}); reg.read(spec(K3, { priority: 0 })).catch(() => {}); await clock.advance(0); expect([...calls.keys()]).toEqual(['blocker']); expect(calls.get(K3)).toBeUndefined(); // still one queued read per key; the joined K3 now goes before K2 expect(sched.stats().queued).toBe(2); finish('blocker'); await clock.advance(0); expect(calls.get(K3)).toBe(1); expect(calls.get(K2)).toBeUndefined(); }); }); describe('affectedReads', () => { test('writes invalidate their own reads only; unknown writes everything', () => { expect(affectedReads('reviews.reply').methods).toEqual(['reviews.list', 'reviews.drafts']); expect(affectedReads('owner.play_rollout')).toEqual({ methods: ['apple.status', 'play.status'], portfolio: true }); expect(affectedReads('apple.testflight_distribute').methods).toEqual(['apple.testflight_status']); expect(affectedReads('prices.import').methods).toContain('prices.current'); expect(affectedReads('prices.import').methods).not.toContain('health'); expect(affectedReads('art.pull').methods).toContain('art.live'); expect(affectedReads('project.edit')).toEqual({ methods: null, portfolio: true }); }); }); describe('two overlapping read buttons render busy together', () => { test('a card refresh in flight: Refresh all, the card, Check again are busy and disabled; another app is not', async () => { const { clock, reg, spec } = setup(); // the window's rule (src/ui/ReadButtons.tsx, ReadControl): useReadState(query).busy -> spinner + disabled const Btn = ({ q, id }: { q: ReadQuery; id: string }) => { const s = useReadState(q); return createElement('button', { 'data-id': id, disabled: s.busy, 'aria-busy': s.busy }, s.busy ? 'spin' : 'idle'); }; const view = () => renderToStaticMarkup(createElement(RegistryCtx.Provider, { value: reg }, createElement(Btn, { q: { tags: ['all'] }, id: 'refresh-all' }), createElement(Btn, { q: K1, id: 'health-card' }), createElement(Btn, { q: { keys: [K1, K3] }, id: 'check-again' }), createElement(Btn, { q: readKey('app9', 'scan', {}), id: 'other-app' }))); expect(view()).not.toContain('disabled'); reg.read(spec(K1)).catch(() => {}); await clock.advance(0); const html = view(); for (const id of ['refresh-all', 'health-card', 'check-again']) expect(html).toContain(``); expect(html).toContain(''); // and the other way round: a broad read in flight makes the narrow buttons busy before any of them started const g = setup(); g.reg.group({ key: READ_ALL_KEY, tags: ['all'], covers: [K3], run: () => new Promise(() => {}) }).catch(() => {}); await flush(); const html2 = renderToStaticMarkup(createElement(RegistryCtx.Provider, { value: g.reg }, createElement(Btn, { q: K3, id: 'card' }), createElement(Btn, { q: { keys: [K3] }, id: 'notice' }))); expect(html2).toContain(''); expect(html2).toContain(''); }); });