2026-08-25 11:19:19 -04:00
# 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.ts` reconciles 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.
2026-08-27 15:46:12 -04:00
- The GitHub Project mirror writes `Target Date` and 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/stats` read them from there, so mirroring them
into Project single-selects bought only drift. Do not re-add them.
`Set` /`Difficulty` are a projection of the issue labels, reconciled by
`bun run sync-project-fields` at the repo root, not by this Worker.
2026-08-25 11:19:19 -04:00
## The ladder
2026-08-31 10:41:45 -04:00
`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.
2026-08-25 11:19:19 -04:00
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` |
2026-08-31 10:49:55 -04:00
| `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 |
2026-08-25 11:19:19 -04:00
| `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: create `Review — 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.
2026-08-31 10:49:55 -04:00
Three Actions workflows talk to this Worker with the `SRS_ADMIN_KEY` repo
secret:
- `sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile` (the
morning cron is the backstop).
- `close-solved.yml` sends the whole implemented `work/` set — each entry
tagged with its bucket — to `POST /admin/solved` on every push to `main` .
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 only `logAttempt()` moves the ladder), and
the bucket is what lets a pushed re-solve advance `+3 → +7` instead of
being read as a stale first solve. `close-solved` still owns the issue close
itself (it knows the files and the commit), so this path only writes D1 and
mirrors Project fields.
- `spaced-repetition.yml` reads `POST /admin/due` at 06:00 ET and renders
today's `Spaced Repetition` issue. Read-only, and the only schedule involved
is this Worker's ladder.
2026-08-25 11:19:19 -04:00
## Local dev
```sh
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
2026-08-31 10:41:45 -04:00
bun test src # DST guards, date math, the rest-day sweep
2026-08-25 11:19:19 -04:00
```
2026-08-31 10:41:45 -04:00
`?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.
2026-08-25 11:19:19 -04:00
## Deploy
```sh
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` .