Files
leetcode/AGENTS.md
T

29 KiB
Raw Blame History

Repository Guidelines

Project Overview

LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under work/, a Blume docs site under apps/docs/, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (apps/api/) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 topic issues (Topic NN — Name) own ~161 problem sub-issues titled LC <num> · <Name>, with difficulty and set carried only by labels (set:core|optional|deferred, diff:*) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.

Architecture & Data Flow

Three owners, one direction of truth:

flowchart LR
  work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
  work -->|close-solved.yml| PI[problem issues]
  work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
  PI -->|close-topics.yml| TI[topic issues]
  PI -->|close-solved.yml release job| Rel[timever release per finished topic]
  GH[GitHub issues = catalog] -->|nightly reconcile| D1
  D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
  Mail -->|GET /log| D1
  PI -->|/done webhook| D1
  D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
  GH -->|close-solved.yml sync-project-fields| Proj
  D1 -->|Sat midnight ET| Review[Review — Week N issue]
  PI -->|"spaced-repetition.yml (6 AM ET cron, closed +3d/+7d)"| SR[Spaced Repetition — Month D issue]
  D1 --> Charts[SVG charts + /api/stats]
  Charts -->|bun run stats| TUI[terminal dashboard]
  • Reconcile, don't react. close-solved.ts, close-topics.ts, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. close-solved posts its whole implemented set to /admin/solved, not just the issues it closed this run.
  • Solving has three doors, one write path. A digest one-tap, a /done comment, and a solution landing in work/ all end in logAttempt(). Closing an issue is not one of them: the catalog reconcile never reads issue state, so a work/ push that skipped the email would otherwise leave stage='new' — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence /admin/solved, which takes {lc, bucket} pairs (source='commit'): the bucket names the rung the file settles — work/1 settles new, work/3 settles +3, work/7 settles +7 — so a pushed re-solve advances the ladder instead of vanishing, and re-posting the whole implemented set writes nothing because the rung named has already been left behind. That stage check is the entire idempotency mechanism; no index, no dedupe table, no state file. close-topics needs no such call — D1 stores no topic completion, only problem rows.
  • The rest day is structural, not cosmetic. Sunday is never booked: topics run Mon–Fri (apps/api/data/schedule.json), the gate is Saturday, and the ladder's windows are 3 and 7 precisely because those return a solve to a working day. Every scheduled date — ladder review, levelled overflow, deferred-Hard release, review-issue window — comes out of workingDay() in apps/api/src/srs.ts, which slides the one exception (a Thursday solve's +3) forward to Monday. Forward, never back: a review may slip later than its interval, never shorten it. Never mint a scheduled date with bare addDays(), and never "fix" a Sunday landing downstream — the digest's Sunday rest branch is a courtesy, not the mechanism. apps/api/src/srs.test.ts sweeps the campaign calendar to keep this honest.
  • One schedule, one answer to "what is open today". D1's ladder IS the schedule; dueToday() (POST /admin/due) is its read side and DUE_WHERE is the single SQL definition of "due", shared by the digest, the review issue and the docs queue chart. spaced-repetition.yml renders that answer into today's Spaced Repetition — <Month D, YYYY> issue — grouped by rung, WINDOW_CAP (3) required per rung with the overflow listed as optional, keyed by the ET date in the title so a re-run rewrites one body. It schedules nothing, stores nothing, and writes nothing on Sunday. Without SRS_ADMIN_KEY it FAILS rather than inventing a second schedule from issue close dates — that reconstruction was the drift, and it is gone. NEVER add a JSON state file (.github/problems.json and friends) or a second "when is this due" rule: D1 answers it, with one writer (logAttempt) and git-free history in attempts.
  • The picker owns the local review loop; the buckets are its state. work/ holds three buckets (1 = first solve, 3/7 = blind re-solves, named after the ladder rungs) and bun run pick has three sections — Tab cycles them. Pools are reconciled from the filesystem plus one git log (apps/cli/work.ts): a first solve dates from the ET date of the commit that added its work/1 file — the same push that closes the problem issue and starts the +3/+7 windows — and a window stops being owed the moment its bucket holds a file. Uncapped, unlike the review issue's WINDOW_CAP: that issue is one day's assignment, the picker is everything still owed. An empty scaffold under work/1 owes nothing (it is unsolved), which is why isImplemented() lives in apps/cli/source.ts and is shared with close-solved.ts. leetcode-cli reads its output directory from ~/.leetcode/workspaces/<active>/config.json and nowhere else — no flag, no env var — so pick.ts MERGES workDir into that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed at work/1, then treats the scaffold's existence as the acceptance test, since leetcode-cli exits 0 even when an expired session made it write nothing. The picker itself writes no state: pushing the re-solve is what moves the ladder, via close-solved → /admin/solved with that file's bucket.
  • The README charts are live, never committed. They are Worker endpoints reading D1 per request (Cache-Control: max-age=300, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
  • D1 owns SRS state; GitHub issues own the catalog; apps/api/data/schedule.json owns the calendar. The catalog reconcile never invents rows and never overwrites SRS columns (stage, next_review). Target Date is the only Project field the Worker mirrors (apps/api/src/mirror.ts): failures are warnings, never lost D1 writes; topic rows' Target Date is never written. SRS stage and first-attempt result live only in D1 — do not re-add them as Project fields. The Project's Set/Difficulty single-selects are a projection of the issue labels, reconciled by bun run sync-project-fields — a best-effort final step of close-solved.yml, never the Worker.
  • The plan names days; the samplers fill the gaps. apps/api/data/schedule.json maps a date to a ScheduledDay (apps/api/src/srs.ts): topics (one or more topic issues — a doubled day lists two, and the digest renders each with its core list then the optional "if time" list), problems (a set day: fixed numbers, rendered like a topic), gate (Saturday's blind set, verbatim — createReviewIssue refuses to create the issue if any number is not a catalog row, so gate problems must exist as set:optional sub-issues of their topic first), or sweep (every problem whose last attempt failed, taps to re-log, no drills). Every number a gate or problems entry names is in RESERVED, and the drill/gate samplers never draw it — that is what keeps a planned gate problem unseen until its Saturday. Dates absent from the plan still get the ladder's reviews and drills; a Saturday without a gate entry falls back to sampling this week's optional pools.
  • Determinism = idempotency. Drill/gate sampling uses a seeded PRNG (rng() in apps/api/src/srs.ts, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in email_log; one-tap links are unique on (problem, date, kind).
  • DST-proof crons: each event has two UTC crons; code fires only on the computed ET hour (etHour), unit-checked in apps/api/src/srs.test.ts.
  • Chained workflows: GITHUB_TOKEN-driven issue closes fire no issues events, so close-topics.yml chains off workflow_run of Close Solved instead. The Worker's webhook uses its own GH_PAT, so its events flow normally.
  • One "finished topic" rule, two consumers. apps/cli/topics.ts (coreSet(): every set:core sub-issue closed, zero core = never) is read by close-topics.ts (closes the topic issue) and release-topics.ts (the release job of close-solved.yml). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = timever (YYYYMMDD.HHMMSS.NNNNNNNNN, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first — Blume's changelog makes it the page's meta description — then closed on the finish day / earlier / open at release, with solution links at the tag). A release is found by its <!-- release-topics: #N --> body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs /changelog is these releases (githubReleases in apps/docs/blume.config.ts, fetched at build with GITHUB_TOKEN), dated by the timever tag, not published_at (which the API cannot backdate): releasesDatedByTag rewrites each timever release's published_at before Blume maps it, via custom(githubReleasesSource(…)) because the config-level githubReleases() takes no fetchImpl. Changing the tag format breaks the changelog dates.

Key Directories

Path Purpose
work/<1|3|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py} Solutions, e.g. work/1/Easy/Array/1.two-sum.py. The top directory is the spaced-repetition bucket: 1 = first solve, 3/7 = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / Difficulty: / URL / ─ rule / statement) — preserve its exact shape
apps/cli/ Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root bun run scripts delegate here
apps/docs/ Blume site (blume.config.ts, content/, components/ + components.ts, islands/, public/). content/(0N-<phase>)/<topic>/<num>-<slug>.mdx plus one meta.ts per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a (group) (sidebar section, no route segment), the topic folder is the README campaign topic slug (tree-dfs/, heap-priority-queue/) and a real route segment (/tree-dfs/invert-binary-tree), the meta.ts files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX wrapper (components/ProblemBadges.astro, registered as mdx.wrapper in components.ts — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off Astro.url) reads the page's topics: (LeetCode's topic tags, from sync) and video: (NeetCode YouTube id, from bun run videos) frontmatter — both declared via frontmatter.extend in blume.config.ts, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (accent), any other tag to its guide page /guides/<slug> (neutral; LeetCode's problem list if the guide is gone), and the video as a red Explanation badge. The reverse direction is the PageFooter slot (components/BacklinksList.astro): an Obsidian-style <num> <name> list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, hybrid-structures, still lists its problems), in a native <details> open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their <num>. <name> title and render no list. Which tags the campaign teaches is ONE map, apps/docs/topic-landings.ts (tag slug → landing routes), and where a badge points is ONE function beside it, tagTarget() — read by both components (so badges and backlinks cannot disagree) and the map by sync. content/guides/ (the Guides header tab) is one page per tag outside that map, filed by hand into (group) categories — (1-data-structures)/, (2-algorithms)/, (3-techniques)/ (hand-written meta.ts each; no route segment, so every guide stays /guides/<slug>). Sync finds a guide by slug anywhere under guides/ and creates a placeholder only when none exists or it is empty — at the guides/ root, for you to file into a category and give a Lucide icon: that no topic uses (not a TOPIC_ICONS value — sync warns on a collision — and, by convention, not one any other page uses either) — and after that owns exactly one key of it: sidebar.badge, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the BacklinksList rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter list: — the tag's LeetCode problem list, rendered by the same wrapper as an orange LeetCode list badge; sync also writes guides/meta.ts. A hand-written (0N-<phase>)/<topic>/index.mdx (hidden: true) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (sliding-window/index.mdx → /sliding-window) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: islands/<Name>.tsx is hydrated React (auto-discovered; ProgressDashboard, DependencySpine — the README topic map as data, zoom/pan, nodes link to whatever route Blume's /api/docs/navigation.json reports for the topic folder, inert until a topic has pages), components/<Name>.astro registered in components.ts is static markup plus a custom element for behaviour, the way Blume's own Frame is built (Carousel/CarouselImage: src takes a URL, /file.png, or the page-relative ../../public/file.png)
apps/api/ Cloudflare Worker workspace: src/ modules, data/schedule.json (the plan: `date → { topics
.github/workflows/ deploy (docs → Pages with GITHUB_TOKEN for the releases-backed changelog, Worker → Cloudflare, on every main push), close-solved (issues + /admin/solved + Project Set/Difficulty, needs SRS_ADMIN_KEY and PROJECT_PAT; then the release job: timever tag + release per finished topic, contents: write), close-topics, sync-d1 (issue edits → /admin/reconcile), spaced-repetition (daily 06:00 ET cron → today's review issue; GITHUB_TOKEN only, no secrets)
.githooks/ Tracked git hooks; bun install points core.hooksPath here (root prepare script, no husky). pre-commit runs bun run sync and stages only what sync changed (pages, apps/docs/redirects.ts), so a solution, its page and its /<num> short link land in one commit; it refuses to run while work/1 has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under apps/docs/content (in-progress ## Approach prose) unstaged. git commit --no-verify for a deliberate partial commit

Development Commands

All from the repo root (a Bun workspace over apps/*):

bun run pick            # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
                        # sections: new problems → work/1, then the owed 3-day and 7-day
                        # re-solves → work/3, work/7. Each section hides what its own bucket
                        # already holds; index+descriptions cached in
                        # ~/.local/share/leetcode/data.db — see apps/cli/db.ts)
bun run stats           # terminal dashboard over GET /api/stats — four panes (Overview,
                        # Ladder, Concepts, Activity); Tab/1-4 switches, ↑↓ scrolls,
                        # r refetches, q quits. Outside a TTY it prints every pane once.
                        # SRS_API points it at a local Worker like close-solved does.
bun run test            # run ONE solution against LeetCode's judge (not a test suite;
                        # most recently tested first, stamped in the db on exit 0)
bun run submit          # submit ONE solution to LeetCode (newest scaffold first, nothing
                        # hidden; last_submitted stamped in the db on exit 0)
bun run sync            # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
                        # links; re-solves are never published)
bun run videos          # docs pages with no `video:` frontmatter → NeetCode video per page
                        # (channel search scraped, no API key; --write adds the
                        # `video:` key; "no NeetCode match" pages stay untouched)
bun run dev|build       # Blume docs site (runs in apps/docs/)
bun run close-solved -- --dry-run    # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run
bun run release-topics -- --dry-run # prints every would-be topic release body
# close-solved also pushes the solved set to the Worker; point it at a local
# one and nothing production is touched:
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
# runs as the last step of close-solved.yml in CI):
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
# today's review issue from the +3d/+7d closes (--date backfills a missed day;
# a Sunday date writes nothing — the rest day has no assignment):
bun run spaced-repetition -- --dry-run --date=2026-08-31
bun run api:dev         # wrangler dev on :8787 (local D1)
bun run api:test        # DST guards + the rest-day calendar sweep
bun run api:deploy      # deploy the Worker by hand (CI also deploys on main pushes)
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
  "localhost:8787/admin/digest?dry=1&date=2026-08-30"   # preview a digest, send nothing

Code Conventions & Common Patterns

  • Scripts are either entries or libraries. Entries (apps/cli/close-*.ts) use shebang + top-level await. Libraries (apps/cli/github.ts, apps/cli/report.ts, everything in apps/api/src/) are side-effect-free on import — nothing touches network/credentials until a factory (github(), projectMirror()) is called.
  • Shared plumbing: github() gives repo, api() (throws METHOD path -> status body), list() (paginating async generator), closeIssue() (comment first, then close — a failed PATCH still leaves a trace). report.ts gives printTable/markdownTable/writeStepSummary.
  • Narrow API payloads with guards, not inline casts: if (!("number" in value) || typeof value.number !== "number") return; (see readProblemIssue in close-solved.ts). Named-const casts only with a reason.
  • Dry-run everything: reconcilers take --dry-run/DRY_RUN=1; Worker admin routes take ?dry=1&force=1&date= (the SRS_TODAY equivalent) and wrangler dev runs against local D1 — exercise logic there before touching production state.
  • Dates: always ET calendar strings (YYYY-MM-DD), arithmetic anchored at noon UTC (atNoon in apps/api/src/srs.ts) to dodge DST. Never new Date() math directly.
  • Style: section-divider comments (// ── name ───), file-top doc comments explaining the why and invariants, 2-space JSON with trailing newline, Map for dynamic keys / Record for static tables, no tiny one-expression wrapper functions.
  • Known intentional duplication: sync.ts keeps its own header split instead of sharing apps/cli/source.ts, because sync.ts runs its whole pipeline on import and so cannot be imported from. Don't "deduplicate" it. close-solved.ts and work.ts do share it — the stub test decides both "close the issue" and "owes a review", and the two must never disagree.

Important Files

  • apps/api/src/srs.ts — SRS domain: the ladder (new → +3 → +7 → retired; fail resets to +3; first-ever log enters at +3), WINDOWS/STAGES/TEMPERATURES/DIFFICULTIES/SETS (the one vocabulary shared by the charts, the work/<n> buckets, the review issues and the stats dashboard), ET date math, workingDay() (no scheduled date is ever a Sunday), temperatureOf() (days since a concept was last exercised → hot/fresh/fading/cold, bands derived from WINDOWS so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, logAttempt() (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most REVIEW_CAP (3) reviews surface per day — levelReviews() (run by sendDigest on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
  • apps/api/src/index.ts — router + cron dispatch; apps/api/wrangler.jsonc — bindings (DB, EMAIL), crons, vars; secrets GH_PAT/WEBHOOK_SECRET/LINK_KEY via wrangler secret put.
  • apps/api/src/digest.ts / apps/api/src/email.tsx — the daily digest, split data/presentation. digest.ts reads D1 into a DigestData; email.tsx owns every colour and every sentence, and renders both the HTML and (via its own plainDigest, not React Email's plainText mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: DigestRow has no title and no issue field, so a review or drill line cannot leak the topic or a solution link. Subject is (Day N/56) LeetCode Daily Digest.
  • apps/api/src/png.ts — hand-rolled PNG encoder (RGB8, one IDAT, zlib via CompressionStream("deflate"), CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so /chart/heatmap.png rasters the heatmap for the digest while /chart/heatmap.svg keeps serving the README byte-for-byte. Both come from one heatmapCells() so the two pictures cannot drift.
  • apps/cli/sync.ts — work→docs contract: on existing pages only the frontmatter icon: and the ## Solution section are regenerated, and topics: (LeetCode's topicTags as { name, slug }, fetched by GraphQL) is filled once — on create, or on any run that finds it missing — never refreshed (delete the key to refetch; offline, the page is written without it and the next sync retries). ## Solution is the latter bounded by solutionEnd() at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside ## Solution. Two classifications, two homes: the sidebar group is the README topic (readmeTopics(), first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at order: 0, ahead of topic 1) and the work/1/<Difficulty>/<Category>/ category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (## Phase N — Name headings, shortened via PHASE_ALIASES): content/(0N-<phase>)/<topic>/. The topic meta.ts is script-owned and rewritten every run (label, curriculum order, TOPIC_ICONS icon, display: "group"); the phase meta.ts is written once when missing (Phase N: Name, order: N) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume (group) — no route segment, so /tree-dfs is unchanged and a landing page there is one level deep, never at / — and the topic folder is the real segment; never put a topic index.mdx directly inside a (group). A page is renamed when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written index.mdx (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from slugify(), which glues a leading number to the next token (1d-dynamic-programming) because Blume strips <digits>- prefixes from routes. The hand-owned parts have their own owners: ## Approach (a Mermaid flowchart tracing the page's code, placed before ## Solution so a re-sync keeps it) is written per the problem-approach skill (/problem-approach fills every page missing one); the frontmatter video: (the NeetCode walkthrough, rendered as the red Explanation badge) is added by bun run videos --write (apps/cli/videos.ts).
  • apps/cli/work.ts — the work/ layout: bucket paths, isSolution()/solvedInBucket() (the stub test, shared with close-solved.ts), firstSolved() (one git log, ET dates, keyed by LC number so past layout moves don't matter), and reviewQueues() (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
  • apps/api/src/stats.ts / apps/cli/stats.ts / apps/cli/tui.ts / apps/cli/api.ts — the read side. buildStats() is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, and heat (every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island and bun run stats, so its shape is both their contract: change all three together. tui.ts is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or NO_COLOR dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the work/ solution count, which is inventory, not schedule.
  • README.md live charts are Worker endpoints (/chart/*.svg, /badge/gate.svg, 5-min Camo cache); the docs /progress page fetches /api/stats client-side (apps/docs/islands/ProgressDashboard.tsx).
  • apps/docs/blume.config.ts — site base /leetcode; imports apps/docs/redirects.ts, sync-generated (/<num> → /<topic>/<slug>, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus blume-redirects.json); README.md campaign table doubles as topic-map input to sync.ts (readmeTopics(), TOPIC_ALIASES, PHASE_ALIASES).
  • Old .github/srs/ JSON state, its srs-* Actions, and the one-shot import-srs.ts importer are RETIRED and deleted — do not resurrect; git history is the record.

Runtime/Tooling Preferences

  • Bun only: run scripts with bun apps/cli/<name>.ts (or the root bun run aliases), install with bun install --frozen-lockfile. One workspace (apps/*), one root bun.lock. Node 22 appears only in deploy.yml because Blume requires it.
  • Near-zero runtime npm dependencies — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + fetch only. The one exception is the digest email: apps/api/src/email.tsx renders React Email (react, react-dom, @react-email/components, @react-email/render) inside the Worker, because hand-rolling table-based email HTML that survives Gmail and Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no nodejs_compat (it resolves to renderToReadableStream), and requires "jsx": "react-jsx" in apps/api/tsconfig.json. Do not extend this exception to any other module.
  • Local GitHub auth falls back to gh auth token; scripts run fine outside Actions.

Testing & QA

  • Repo-wide: no test framework — bun run test submits one solution to LeetCode's judge. Exception: apps/api/src/srs.test.ts (bun:test) proves the DST cron guards with fixed dates; run via bun run api:test.
  • QA is dry-runs + local state: Worker logic against wrangler dev + local D1 with ?dry=1&date=; reconcilers with --dry-run; idempotency check = run twice, cmp output.
  • Docs changes: bunx blume build --isolated (from apps/docs/) must pass with no new warnings (then rm -rf apps/docs/.blume-verify); after sync, review only created pages (prose transforms are heuristic).