srs-api
Cloudflare Worker running the spaced-repetition system: daily digest email,
one-tap logging, /done webhook, Saturday review issues, live SVG charts,
and the /api/stats feed for the docs progress page.
Live at https://srs-api.prdlk.workers.dev.
Direction of truth
- D1 owns SRS state — stages, review dates, attempt history, gate scores, boost flags. Nothing else is authoritative.
- GitHub issues own the catalog — topics, problems,
set:*/diff:*labels, milestones.catalog.tsreconciles them into D1 nightly (and via/admin/reconcile); it recomputes from scratch, never invents rows, and never overwrites SRS-owned columns (stage,next_review). - The repo owns the schedule —
data/schedule.json, human-edited, bundled at deploy. Git history is its audit log. - The GitHub Project mirror (
Target Date,SRS Stage,First Attempt) is best-effort: failures log warnings, never block a D1 write. Topic rows are never written.
The ladder
new → +2 → +5 → +10 → retired; pass advances, fail resets to +2.
A problem's first-ever log enters at +2 regardless of result. Stage names
the NEXT review's interval. All dates are ET calendar strings anchored at
noon UTC (src/srs.ts) — never raw Date math.
Blind drills draw only from topics already learned: scheduled in an earlier week (the current week's optional pool is reserved for Saturday's gate) and with at least one core problem on the ladder — a skipped learning day never feeds drills just because its calendar week lapsed.
Routes
| Route | Auth | Purpose |
|---|---|---|
GET /log?p&r&d&sig |
HMAC (LINK_KEY) |
one-tap pass/fail; idempotent per (problem, date, kind); links expire after 3 days |
POST /webhook/github |
HMAC (WEBHOOK_SECRET) |
/done <n> pass|fail comments (owner only, any issue); review-issue close → gate scoring |
GET /chart/{progress,ladder,heatmap}.svg, GET /badge/gate.svg |
public | hand-rolled SVGs, max-age=300 (GitHub Camo's freshness floor) |
GET /api/stats |
public, CORS-pinned to the docs origin | one JSON document for /progress |
POST /admin/{digest,review,reconcile} |
Authorization: Bearer <LINK_KEY> |
manual triggers; digest takes ?dry=1&force=1&date= |
Crons (DST-proof)
Each event has two UTC crons; code fires only when the computed ET hour
matches (etHour in src/srs.ts, unit-checked in src/srs.test.ts):
0 12,13 * * *→ 8 AM ET: catalog reconcile, then the digest.0 4,5 * * 6→ midnight ET Saturday: createReview — Week N(so the 8 AM digest can link to it).
Secrets & bindings
wrangler secret put — GH_PAT (Issues + Projects RW), WEBHOOK_SECRET
(matches the repo webhook), LINK_KEY (signs one-tap links, gates admin
routes). Bindings in wrangler.jsonc: DB (D1 srs), EMAIL
(send_email, restricted to the verified destination). Sender domain
prdlk.com is onboarded to Email Sending.
Local dev
cp .dev.vars.example .dev.vars # or fill GH_PAT/WEBHOOK_SECRET/LINK_KEY
bun install
bunx wrangler d1 migrations apply srs --local
bun run dev # wrangler dev on :8787, local D1
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
bun test src # DST guard + date math
?date= on admin routes is the SRS_TODAY equivalent. The one-shot
migration from the retired .github/srs/srs.json lives at
scripts/import-srs.ts (--remote for production D1); it is re-runnable —
import-sourced attempts are wiped and re-inserted.
Deploy
bun run deploy # from api/, or `bun run api:deploy` at the repo root
Manual by design — never coupled to the docs deploy. After changing
bindings, rerun bunx wrangler types.