desktop: one read registry for every read button: single-flight per canonical key, broad reads cover narrow keys, overlapping buttons all busy and disabled (useReadState), 20 s 'read just now' memo, quota pause on the buttons, every read through the scheduler (Check again, Import apps, account Test, Read the stores, Dependencies), writes invalidate only the reads they change; tests/read-registry.test.ts; fixtures ?blocked and window.__fakeCalls

This commit is contained in:
CRTkafa 2026-10-23 11:09:51 +08:00
parent 7345de2f7c
commit c03938f9dc
14 changed files with 861 additions and 164 deletions

View file

@ -0,0 +1,260 @@
// 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<string, number>();
const pending = new Map<string, { done: (v: unknown) => void; fail: (e: unknown) => void }[]>();
const spec = (key: string, x: Partial<ReadSpec<unknown>> = {}): ReadSpec<unknown> => ({
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<void>(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<unknown> 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(`<button data-id="${id}" disabled="" aria-busy="true">spin</button>`);
expect(html).toContain('<button data-id="other-app" aria-busy="false">idle</button>');
// 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('<button data-id="card" disabled="" aria-busy="true">spin</button>');
expect(html2).toContain('<button data-id="notice" disabled="" aria-busy="true">spin</button>');
});
});