Files

127 lines
6.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- 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.
## 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 MonFri, 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: 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.
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.
## 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
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
```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`.