Public: anyone can read this; the crew can change it.
src/core/usage_count.ts counts in memory and flushes every pending key in one transaction #2137
Check · Review · Land · Live done — Landed and live in production.
This adds `src/core/usage_count.ts` and `test/usage-count.test.ts`. Nothing calls the module yet (#1918 does that). `bun x tsc --noEmit` is clean, and the new test and `test/check-list.test.ts` pass (17 tests, about 250 ms).
shipped production is serving it deploy f8d173e
The counting, the upsert and the migration match, and the tests are good for the happy path. One real bug to fix first, in flush (the catch that puts counts back then throw err).
- FIRST: a failed flush stalls counting and can crash the process. (a) When the timer calls
flushand the write throws (disk full, locked db), the error escapes a setTimeout callback and is an uncaught exception that takes the whole server down. (b) After the failure the counts are restored but no timer is rearmed, andcountonly schedules whenpending.sizewas 0 (first), so with pending non-empty it never schedules again; counts sit in memory until 500 distinct keys, orstop(). Fix: in the catch, restore the counts andschedule(flushMs)to retry; make the timer path swallow the error (call flush from a wrapper that catches) and let only the explicitflush()/stop()callers see the throw. Alternatively schedule whenevertimer === nullinstead of usingfirst. - Add a test that would fail without that: a db whose upsert throws (e.g. drop the table, then recreate it), assert the counts are not lost and a later flush writes them, and that the timer path does not throw.
Smaller: count throws RangeError on a bad event. A counter called from the request path should never be able to fail a request; the caller will have to wrap it, so say so in the header comment or drop the event instead of throwing. Nothing calls the module yet; fine if wiring is a separate issue, say so in the pull.
No UI surface in this change, so phone, keyboard and preview checks are not applicable; no security issues: no user text reaches SQL (all bound parameters), inputs are allow-listed and pattern length is capped.
src/core/usage_count.ts now keeps its counts when a write fails and retries by itself. A failing timer flush no longer takes the server down. The usage-count tests and the type check pass; I didn't run the rest of the suite.
- Timer path: the timer calls
flushthrough a wrapper that swallows the error. Only an explicitflush()orstop()sees the throw. - Retry: the catch in
flushputs the counts back and callsscheduleagain, unless the counter is stopped.countnow also schedules whenevertimer === null, not only whenpendingwas empty. - Second bug, found by the new test:
db.query(...)ran before thetry. A missing table made it throw afterpending.clear(), so the counts were lost. I moved it inside thetry. - New test: it drops the table, counts twice and lets the timer fail twice without an uncaught exception. It then checks that an explicit
flush()throws, recreates the table, and checks that the kept counts (n=2) are written by the retry. A later count andstop()bring it to n=3. - Bad events:
countstill throwsRangeErroron a malformed event, because the existing test pins that. I documented it in the header comment: a caller on the request path must wrapcountin try/catch. If you'd rather it drop the event silently, say so; that's a small change plus a test edit. - Wiring: nothing calls the module yet. Wiring it in is a separate issue.
To try it, run bun test test/usage-count.test.ts.
Does what the issue asks: count is one map increment, flush upserts every pending key in one transaction, the timer is unref d, the 500-key flush is deferred, and a failed write keeps the counts and retries. Tests cover each behaviour including the failure path. The module is internal and nothing calls it yet, so there is no page, terminal or MCP surface to check; the preview is unaffected. No dependency, no secrets, SQL is parameterised. Next: (1) wire count into the request path inside try/catch, with role taken from the verified identity and the pattern from the route, never the raw URL, so the key space stays bounded. (2) call stop() on shutdown and decide where a persistent flush failure is reported, since the timer swallows it silently.
This adds
src/core/usage_count.tsandtest/usage-count.test.ts. Nothing calls the module yet (#1918 does that).bun x tsc --noEmitis clean, and the new test andtest/check-list.test.tspass (17 tests, about 250 ms).createUsageCounter(db)returns{count, flush, stop}.-
countthrows aRangeErrorand writes nothing for a role outsideanon|user|admin|agent, a surface outsideapi|mcp|web, an unknown method, a status outside 200–599, or an empty or over-200-character pattern. - A validcountis one map increment under an hour (YYYY-MM-DDTHH, UTC) and the status class (2xx…5xx). It runs no query. -flushupserts every pending key in one transaction, adding ton. If the write fails, the counts go back into the pending set. - It flushes by itself about a second after the first pending count, in a deferred timer once 500 keys are pending, and onstop(). The timer isunref'd.readUsage(db, sinceHour)returns the rows from that hour on, oldest first.- three counts → one row of 3, with nothing before the flush; - two hours → two rows; - every refusal writes nothing; - the timer flush and the 500-key flush.
createUsageCountertakes two optional extra arguments, a clock and a flush delay. They let the tests stay far under 1 s.What I left out: I did not edit
scripts/gate.ts. It no longer has aGATElist:gateFilesderives the gate from everytest/*.test.tsminusEXCLUDED. The new test is gated as soon as it exists, so there was nothing to name.I ran only
bun x tsc --noEmit, the new test andtest/check-list.test.ts, not the whole suite, so I haven't seen any failure elsewhere.The tests create the table by running the existing
20261016130000-usage-count.tsmigration.To try it:
bun test test/usage-count.test.ts.🤖 Generated with Claude Code
Closes #2078