6.8 KiB
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 writes
Target Dateand nothing else: it is best-effort, so failures log warnings and never block a D1 write, and topic rows are never written. SRS stage and first-attempt result stay in D1 only — the charts, digest and/api/statsread them from there, so mirroring them into Project single-selects bought only drift. Do not re-add them.Set/Difficultyare a projection of the issue labels, reconciled bybun run sync-project-fieldsat the repo root, not by this Worker.
The ladder
new → +3 → +7 → retired; pass advances, fail resets to +3. A problem's
first-ever log enters at +3 regardless of result. Stage names the NEXT
review's interval, and the two rungs are the same numbers as the work/3 and
work/7 buckets bun run pick scaffolds re-solves into.
Sunday is never booked. Topics run Mon–Fri, the gate is Saturday, and 3/7
are chosen so a solve returns on a working day: only a Thursday solve's +3
would land on the rest day, and workingDay() in src/srs.ts slides it to
Monday. Every scheduled date in the Worker — ladder reviews, levelled
overflow, deferred-Hard release dates — is minted by that one function, so the
rest day cannot be booked and then swallowed by the digest's Sunday branch.
Reviews may slip later, never earlier: pulling one back to Saturday would
shorten the interval it exists to test. src/srs.test.ts sweeps the whole
campaign calendar to prove it.
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/solved |
Authorization: Bearer <LINK_KEY> |
{"solved":[{"lc":1,"bucket":1},…]} — solutions committed under work/; the bucket names the rung it settles (1 → new, 3 → +3, 7 → +7), so a first solve enters the ladder and a pushed re-solve advances it. Writes only when the problem stands on that rung, which is what makes re-posting the whole set a no-op. Takes ?dry=1&date= |
POST /admin/due |
Authorization: Bearer <LINK_KEY> |
read-only: what the ladder has due on ?date= (default today), oldest first, with the rung/bucket, how late it is, and when it was last seen. The one answer behind the daily Spaced Repetition issue |
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.
Three Actions workflows talk to this Worker with the SRS_ADMIN_KEY repo
secret:
sync-d1.ymlsends curriculum issue edits toPOST /admin/reconcile(the morning cron is the backstop).close-solved.ymlsends the whole implementedwork/set — each entry tagged with its bucket — toPOST /admin/solvedon every push tomain. That call is what keeps a committed solution from being invisible here: closing an issue is not a state change the Worker can see (the catalog reconcile ignores issue state, and onlylogAttempt()moves the ladder), and the bucket is what lets a pushed re-solve advance+3 → +7instead of being read as a stale first solve.close-solvedstill owns the issue close itself (it knows the files and the commit), so this path only writes D1 and mirrors Project fields.spaced-repetition.ymlreadsPOST /admin/dueat 06:00 ET and renders today'sSpaced Repetitionissue. Read-only, and the only schedule involved is this Worker's ladder.
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 guards, date math, the rest-day sweep
?date= on admin routes is the SRS_TODAY equivalent. Migrations are
append-only and applied in order; 0002_ladder_3_7.sql is the +2/+5/+10 →
+3/+7 rebuild, which also lifts every date that was sitting on a Sunday.
The one-shot importer for the retired .github/srs/srs.json is gone — that
state no longer exists, and git history is its record.
Deploy
bun run deploy # from apps/api/, or `bun run api:deploy` at the repo root
CI deploys on every push to main (deploy.yml, gated on bun run api:test,
using the CLOUDFLARE_API_TOKEN/CLOUDFLARE_ACCOUNT_ID repo secrets); the
commands above are for out-of-band deploys. After changing bindings, rerun
bunx wrangler types.