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.
|
|
|
|
|
- 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` |
|
2026-08-25 15:43:21 -04:00
|
|
|
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"lc":[…]}` — solutions committed under `work/`; logs each *first* solve (`source='commit'`), skips anything past stage `new`, takes `?dry=1&date=` |
|
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-25 15:43:21 -04:00
|
|
|
Two Actions workflows push into D1 with the `SRS_ADMIN_KEY` repo secret:
|
|
|
|
|
`sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile`
|
|
|
|
|
(the morning cron is the backstop), and `close-solved.yml` sends the whole
|
|
|
|
|
implemented `work/` set to `POST /admin/solved` on every push to `main`.
|
|
|
|
|
That second call is what keeps a committed solution from being invisible
|
|
|
|
|
here: closing its issue is not a state change the Worker can see — the
|
|
|
|
|
catalog reconcile ignores issue state, and only `logAttempt()` moves the
|
|
|
|
|
ladder. `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.
|
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
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
```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`.
|