# Architecture ## Shape One Bun process serves the static React build, exposes a read-only JSON API, and runs the ingestion scheduler on a timer. SQLite is the only datastore. There is no auth layer because there are no users. ``` ┌──────────────────────────────────────────────┐ game wikis ───► │ Bun process │ news pages │ │ │ scheduler (every 6h) │ │ │ │ │ ▼ │ │ ingest pipeline │ │ fetch → clean → parse|extract → validate │ │ → reconcile → gate → publish │ │ │ │ │ │ │ └──► quarantine │ │ ▼ │ │ │ ┌─────────────┐ │ │ │ │ SQLite │◄────────────────┘ │ │ └─────────────┘ /review (127.0.0.1) │ │ │ │ │ ▼ │ │ GET /api/events static React build │ └────────┬─────────────────────────┬───────────┘ │ │ ▼ ▼ browser fetch browser render │ ▼ localStorage: completions, filters, region ← never leaves the device ``` Anthropic API calls happen only inside the ingest pipeline. **No request path — not `/api/events`, not a page load — ever calls the model.** If a feature seems to need live inference, it needs a precomputed field instead. ## Layout ``` src/ server/ index.ts Bun.serve entry: static + API + scheduler bootstrap routes/ events.ts GET /api/events, /api/events.json games.ts GET /api/games review.ts /review UI + approve/reject (localhost-bound) health.ts GET /api/health db/ client.ts bun:sqlite handle, WAL, pragmas migrations/ NNN-name.sql, applied in order at boot queries.ts all SQL lives here — no SQL in route handlers ingest/ scheduler.ts timer + jitter + per-source lock pipeline.ts the 7 stages, orchestration only clean.ts HTML → text reduction before extraction extract.ts Anthropic client, prompts, batch submission validate.ts zod parse + calendar sanity rules reconcile.ts diff vs published, confidence, conflict detection adapters/ index.ts registry: GameId → Adapter genshin.ts hsr.ts zzz.ts wuwa.ts arknights.ts endfield.ts shared/ schema.ts zod schemas — the contract, imported by both sides types.ts z.infer types only time.ts region reset math, duration formatting client/ main.tsx App.tsx views/ Timeline.tsx F1 EndingSoon.tsx F2 EventDetail.tsx state/ completions.ts localStorage read/write + export/import prefs.ts region, filters api.ts typed fetch of /api/events fixtures// checked-in raw HTML + expected parse output docs/ ``` ## Request paths | Route | Purpose | Notes | |---|---|---| | `GET /` + assets | React SPA | Served from the `bun build` output | | `GET /api/events?from&to&game` | Filtered feed | `ETag` + `Cache-Control: public, max-age=300` | | `GET /api/events.json` | Whole published feed | Cheap; the client mostly uses this and filters locally | | `GET /api/games` | Game metadata: id, name, color, lastUpdatedAt | Drives the freshness badges (F7) | | `GET /api/health` | Per-source last-success, quarantine depth | For an operator, not the UI | | `GET /review` | Quarantine review UI | **Bound to `127.0.0.1` only** | | `POST /api/review/:id/approve` \| `/reject` | Promote or discard a quarantined event | Same binding | ### Why `/review` needs no auth `Bun.serve` runs two listeners: the public one on `0.0.0.0:PORT` with the SPA and `/api/*`, and a second on `127.0.0.1:ADMIN_PORT` with `/review` and `/api/review/*`. The review routes are not registered on the public listener at all — they are unreachable from off-box, so there is nothing to authenticate. This is the mechanism that satisfies "no logins" without leaving an open admin endpoint on the internet. **This is load-bearing.** If someone later puts a reverse proxy in front of the admin port, or merges the two listeners "to simplify", the review UI becomes a public write endpoint. Any change in that area needs an explicit auth story first. ## Data flow, concretely 1. **Scheduler** wakes every 6h (± jitter). For each source not fetched within its `minIntervalMs`, it acquires a per-source lock row and enqueues a run. 2. **Pipeline** executes the seven stages in `docs/INGESTION.md`. Every stage writes to `ingest_runs` so a failure is diagnosable after the fact without re-running. 3. **Publish** upserts into `events` by stable ID, bumping `version` and `updatedAt` when any field changed. Events that vanish from a source are *not* deleted — they are marked `status = 'delisted'` so a source outage cannot silently empty the calendar. 4. **Client** fetches the feed, merges the completion set from `localStorage` by event ID, and renders. Merge is a client-side join; the server never learns what the user completed. ## Concurrency and failure - One in-flight run per source, enforced by a lock row with a stale-lock timeout of 15 minutes. - A source that fails keeps its previously published events. A failed run never deletes or blanks data — worst case, the game's lane goes stale and gets a warning badge (F7). - Three consecutive failures for one source raises its `health` to `failing` in `/api/health`. It does not stop the schedule; a wiki being down for a day is normal. - Extraction results are written to `extraction_log` with the input hash, so a prompt change can be evaluated against previously-seen inputs without re-fetching or re-paying. ## Deployment Single process, single SQLite file, no external services beyond the Anthropic API. ``` PORT=3000 ADMIN_PORT=3001 # bound to 127.0.0.1 DATABASE_PATH=./data/events.sqlite ANTHROPIC_API_KEY=sk-ant-... INGEST_INTERVAL_MS=21600000 INGEST_ENABLED=true # false for local UI work — never hits the network or the API EXTRACTION_MODE=batch # batch | sync CONFIDENCE_THRESHOLD=0.8 ``` `INGEST_ENABLED=false` is the default for local development. Frontend work should run against a seeded SQLite file and cost nothing. ## Deliberate non-choices - **No ORM.** `bun:sqlite` plus hand-written SQL in `queries.ts`. The schema is six tables. - **No Redis / job queue.** The scheduler is a timer and a lock row. Restarting the process resumes cleanly because state is in SQLite. - **No server-side rendering.** The feed is small and cacheable; a static SPA is enough. - **No websockets.** Events change on a scale of hours; a 5-minute cache is more than adequate.