Imported from Divkix/pickmyclass (
AGENTS.md). Install upstream withnpx skills add Divkix/pickmyclass. Copyright stays with the author.
Repository Guidelines
Audience: AI coding agents and contributors. This file is the onboarding map for working in this repo. Read it before touching code — it explains how the system works, how it's built and tested, and the invariants you must not break.
Keeping this file current
When you discover something non-obvious — an invariant, gotcha, decision and its why — record it here and consolidate: merge into closest existing point, delete what your change made false, keep entries terse. One deduplicated map, not an append-only log. Don't record transient state or secrets. When doc and code disagree, code wins.
AGENTS.mdis canonical. FormerCLAUDE.mdsymlink removed —AGENTS.mdis the source (addCLAUDE.md -> AGENTS.mdsymlink if you need Claude Code compatibility).
Agent skills
- Issue tracker: GitHub Issues on
Divkix/pickmyclassviaghCLI. Seedocs/agents/issue-tracker.md. - Triage labels:
needs-triage,needs-info,ready-for-agent,ready-for-human,wontfix. Seedocs/agents/triage-labels.md. - Domain docs:
CONTEXT.md+docs/adr/at root. Seedocs/agents/domain.md.
What this is
PickMyClass notifies ASU students by email when a seat opens or instructor is assigned in a watched section. Next.js 16 App Router (React 19, TS strict) on Cloudflare Workers via vinext (Vite-based, not next-on-pages), PlanetScale Postgres via Hyperdrive (request-scoped Drizzle/postgres-js, --caching-disabled) + Clerk (jwtKey, ext_id) + polling (docs/adr/0014), Cloudflare Email + Queues + Durable Object for notifications. pnpm@11.10.0, Vite+ (vp).
Two systems to understand first: seat-check notification pipeline and auth/account lifecycle — details in ADRs, invariants below.
Architecture at a glance
Browser -> vinext Worker (worker.ts) -> PlanetScale via Hyperdrive (polling)
| Cron 0,30 * * * * + 5 4 * * * -> worker.ts scheduled() -> /api/cron -> Queue -> worker.ts queue() -> processSection() -> ASU API + Email
| Clerk FAPI (jwtKey verify) -> polling GET /api/class-watches/states
Queue consumer worker.ts queue() calls processSection() directly (not HTTP); the former app/api/queue/process-section/route.ts mirror was deleted (#380 Phase 1) — tests exercise processSection() directly. See docs/adr/0006.
Core systems (pointers, not copies)
- Seat-check pipeline:
worker.ts+app/api/cron/route.ts+lib/queue/process-section.ts+lib/asu/api.ts+lib/db/queries.ts+lib/email/. Flow: Cron -> CronLockDO (25min lease) ->getSectionsToCheck(even/odd stagger) -> Queue (batch 100, one retry) ->processSection()(read baseline -> fetch ASU -> detectChanges -> first-observation guard -> upsert class_states before send -> notify). Full ordering and dedup indocs/adr/0004,0006,0011. - Auth: Clerk hosted
<SignIn>/<SignUp>at/sign-in//sign-up(lib/clerk/config.tsliteral key). Sessions vialib/auth/clerk-session.ts(ext_idclaim),lib/auth/clerk-cookies.ts,proxy.tsgate +lib/auth/decide-gate.ts. WebhookPOST /api/webhooks/clerk->lib/db/users.tsmirror. Seedocs/adr/0012,0001. - Data: Single seam
lib/db/index.ts(getDb(hyperdrive)request-scoped). RPC-first (SECURITY DEFINERfunctions).class_statesunique on(class_nbr, term).user_profiles1:1 mirror. Seedocs/adr/0013.
Project structure
app/ # Routes, pages, API endpoints (App Router)
lib/ # Core logic (asu/, api/, auth/, clerk/, class-watches/, db/, queue/, worker/, email/, cache/, hooks/, contexts/, types/, blog/)
components/ # React (ui/, admin/, landing/, blog/)
tests/ # unit/, integration/, mocks/
worker.ts # CF Worker (fetch, scheduled, queue, CronLockDO)
proxy.ts # vinext middleware — THE auth gate + CSP nonce
db/migrations/ # timestamped SQL history (plain PG)
public/ # static + llms.txt, llms-full.txt
lib/utils.ts = shadcn cn() only; lib/utils/ = custom utils — by design, don't deduplicate.
Cloudflare Workers runtime
worker.tswraps vinext +scheduled/queue/CronLockDO(lib/worker/cron-lock.ts). Keep DO exports andwrangler.jsoncmigrationv2aligned.- Bindings
wrangler.jsonc+lib/types/env.ts:HYPERDRIVE,PICKMYCLASS_QUEUE->pickmyclass-queue+ DLQpickmyclass-dlq,PICKMYCLASS_CRON_LOCK_DO,EMAIL,ASSETS,CF_VERSION_METADATA. Vars:MAX_WATCHES_PER_USER(10). - Secrets via
wrangler secret put(never in jsonc):CLERK_*,ASU_API_*,CRON_SECRET,UNSUBSCRIBE_SIGNING_SECRET. - Access bindings via
import { env } from 'cloudflare:workers'+as unknown as Env. - Config:
main ./worker.ts,compatibility_date 2026-05-07,placement: smart, stateless, 128MB, 30s HTTP / 15min cron. Alwayspnpm run previewbefore deploy.
Build, test & dev
Through vinext + vp — don't use next/vitest/eslint directly. pnpm@11.10.0.
pnpm run dev # vinext dev :3000
pnpm run build
pnpm run preview # real Worker locally
pnpm run deploy # build + wrangler deploy + triggers deploy
pnpm run check # format+lint+app type-check (excludes worker.ts/scripts — see below)
pnpm run check:fix
pnpm run test / test:run / test:coverage # vitest, 80% threshold
pnpm run type-check # AUTHORITATIVE: tsc --noEmit && tsc -p tsconfig.worker.json --noEmit
Two tsconfigs: tsconfig.json (app, excludes worker.ts) + tsconfig.worker.json (Workers, add new worker files to include or they're un-typechecked). Tests import from vite-plus/test, mock cloudflare:workers/vinext via tests/mocks.
CI (.github/workflows/ci.yml)
validate-lockfile -> quality/test/check in parallel -> ci-success (required). Dependabot ignores the vite-plus toolchain (vite-plus, vite, vitest, @vitest/*, @voidzero-dev/vite-plus-core) — bump via vp migrate only, never solo (solo bumps desync core/vitest and break types/coverage).
Conventions
- API responses:
ok()/fail()fromlib/api/response.ts(exceptmonitoring/health,queue/process-section). - Validation: zod
safeParse+mapValidationIssues->fail(400). Schemas inlib/api/schemas.ts. - Auth in routes:
requireUser(request)/getSessionIdentity->UnauthorizedError(401); cron/queue ->verifyCronSecret. - Style: Oxfmt/Oxlint, 2-space, width 100, single quotes, semicolons, camelCase/PascalCase, imports auto-organized.
pnpm run check:fix. - Tests: under
tests/,*.test.ts(x)/*.spec.ts(x). - Config: constants in
lib/config.ts; logging vialog('Scope').info|warn|errornotconsole.*. - Email: all template data through
escapeHtml; unsubscribe tokens are stateless HMAC (90d, not single-use).
Critical invariants & gotchas
processSectionorder reset -> upsertclass_statesbefore send — moving send earlier double-sends on retry.- Email only the IDs returned by
tryRecordNotificationsBatch(claimed set) and rollback failed sends viadeleteNotificationRecordsByIds(row-id scoped throughgetNotificationRecordIds), or users suppressed 24h. expire_stale_notifications()on every 30-min cron tail + 04:05 maintenance sweep + past-term watch delete is load-bearing — without it re-notifications stop.processSectionownsack/retry(SectionCheckOutcome); callers only translate to transport. HTTP route returns200forackon purpose.class_stateskey is(class_nbr, term)— always include term.proxy.tsis THE auth gate (ClerkjwtKey,hasClerkSessionCookies,ext_idclaim,readAuthorizationState30s cache). Invalidate viainvalidateAuthorizationStateafter consent/admin changes.- First-observation guard (
!oldState) suppresses false seat emails — keep it. non_reserved_seatspopulated since #198 (Math.max(0, enrlCap-enrlTot-waitTot)), fallbacknon_reserved_seats ?? seats_availableindetectChanges.lib/asu/terms.tsneeds yearly August update or new watch creation silently blocks.- Never add dynamic API (
headers()/cookies()) toapp/layout.tsx— static pages 500.useSearchParamsneeds<Suspense>.
Known doc drift
README.md/CONTEXT.md drift-pruned 2026-08-22 for Hyperdrive+Clerk+polling. Remaining risk is hard numbers — verify against wrangler.jsonc/code.
Using Vite+, the Unified Toolchain for the Web
This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called vp. Vite+ is distinct from Vite, and it invokes Vite through vp dev and vp build. Run vp help to print a list of commands and vp <command> --help for information about a specific command.
Docs are local at node_modules/vite-plus/docs or online at https://viteplus.dev/guide/.
Built-in Commands vs Scripts
vp <name> runs a built-in command. vp run <name> runs a package.json script or a vite.config.ts task. Scripts cannot overwrite built-ins, so vp dev and vp run dev may do different things. Check package.json and vite.config.ts first, and run vp run <name> when the project defines a script or task with that name.
Tool Versions
Run vp toolchain to show versions and relationships in the active Vite+
release. Add a tool name to select part of the graph. For example, run
vp toolchain vite. Use --global to ignore the local vite-plus package. Use
vp why <package> to show the package-manager dependency graph.
Review Checklist
- Run
vp installafter pulling remote changes and before getting started. - Run
vp checkandvp testto format, lint, type check and test changes. - Check if there are
vite.config.tstasks orpackage.jsonscripts necessary for validation, run viavp run <script>. - If setup, runtime, or package-manager behavior looks wrong, run
vp env doctorand include its output when asking for help.