CLAUDE.md claimed re-uploading a chapter unconditionally drops saved-page references. In fact the crawler re-fetch (content::persist_pages) upserts by (chapter_id, page_number) with ON CONFLICT DO UPDATE, preserving pages.id, so collections/page-tags survive a re-fetch. Saves drop only when a pages row is actually deleted (chapter deletion, or a user delete-then-recreate). Migration 0023's text is left untouched (sqlx checksums applied migrations). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project
Mangalord is a manga / comics reader. Users browse, search, read, bookmark, and upload manga. The same HTTP API is consumed by both the SvelteKit frontend and external bots/scripts — there is no separate "bot API" or "internal API." Deployment is a single server via Docker Compose.
Stack
- Backend: Rust, axum 0.7, sqlx 0.8 (Postgres), tokio
- Database: Postgres 16
- Frontend: SvelteKit 2 with Svelte 5 runes, TypeScript, Vite
- Storage:
Storagetrait +LocalStorageimpl. S3 and friends are planned implementations, not refactors. - Tests:
cargo test(unit + integration via#[sqlx::test]), Vitest (frontend unit), Playwright (E2E)
Development workflow rules
These rules apply to every change. They are not negotiable per-task — if a change can't be made under them, raise the conflict before writing code.
TDD
Tests describing the expected behaviour are written before the implementation, in the same commit or one immediately preceding. A commit that adds production code without a corresponding test (or that updates a test only after the fact to match what was built) does not meet this bar — split it.
See TDD workflow below for picking the right test level.
Git
- One branch per logical change, prefixed by intent:
feat/<slug>,bugfix/<slug>,chore/<slug>,docs/<slug>,refactor/<slug>,test/<slug>. Never commit directly tomain. - Commit messages follow Conventional Commits (
feat:,fix:,chore:,docs:,refactor:,test:). One concern per commit. - Each branch lands via a reviewed PR (self-review counts when working solo; the diff must still be read end-to-end before merge). Squash-merge into
mainto keep history linear. - Do not push
maindirectly, do not force-push shared branches, and do not bypass hooks.
Semantic versioning
The project version is tracked in backend/Cargo.toml and frontend/package.json and bumped on every merge to main:
feat:→ bump minorfix:/bugfix:→ bump patch- breaking change (only valid before 1.0, otherwise needs a major bump after 1.0) → bump minor while under 1.0
chore:/docs:/test:/refactor:with no behaviour change → no bump
While the project is pre-release the version stays below 1.0.0. The 1.0.0 release happens only at the user's explicit instruction — do not bump to or past it autonomously.
Both manifests must stay in lockstep (same version string).
TDD workflow
The project is developed test-first. For each change:
- Write a failing test at the right level.
- Implement the minimum to make it pass.
- Refactor with tests green.
Pick the level deliberately:
- Pure logic / mappers / validation → unit test next to the code. In Rust:
#[cfg(test)] mod testsin the same file. In the frontend: a sibling*.test.ts. - Anything that touches the DB → integration test in backend/tests/, using
#[sqlx::test(migrations = "./migrations")]. Each test gets a fresh, migrated database. - Cross-component user journeys → Playwright in frontend/e2e/. Mock the network at the Playwright route level when the journey doesn't require a real backend.
Run these before claiming a change works:
(cd backend && cargo test)
(cd frontend && npm test)
(cd frontend && npm run test:e2e) # needs dev server, see playwright.config.ts
Backend integration tests require DATABASE_URL to point at a Postgres where the test user can CREATEDB (the #[sqlx::test] macro provisions a fresh database per test).
Backend layout
Hexagonal-ish layering. Handlers depend on repo and storage, never the other way around. This is the seam new extensions plug into.
- backend/src/domain/ — pure data types, no I/O. Add new types here first.
- backend/src/repo/ — DB access functions taking
&PgPool. Plain async fns rather than a repository struct; tests target them directly. - backend/src/storage/ —
Storagetrait +LocalStorage. AddS3Storagehere when needed, do not branch on backend type in handlers. - backend/src/api/ — axum handlers and route wiring. One module per resource.
- backend/src/app.rs —
AppStateand router assembly. Integration tests use the samerouter(state)function with a testAppState. - backend/src/error.rs —
AppErroris the single error type returned by handlers. New variants map to status codes inIntoResponse.
When adding a new resource:
- Add a migration
backend/migrations/NNNN_<name>.sql. - Add the domain type in backend/src/domain/.
- Add repo functions in backend/src/repo/.
- Add handlers in
backend/src/api/<resource>.rsand merge in backend/src/api/mod.rs. - Add integration tests in backend/tests/, reusing backend/tests/common/mod.rs.
Database access
Queries use sqlx::query_as::<_, T>(...) (runtime-checked) with #[derive(FromRow)] on the domain types, so the project builds without a live DB. If you want compile-time SQL checking later, switch to sqlx::query!/query_as! macros and commit a .sqlx directory via cargo sqlx prepare.
Migrations live in backend/migrations/ and run automatically at startup via sqlx::migrate!.
Storage trait
Do not call tokio::fs or any filesystem API from handlers. Go through state.storage (the Storage trait). Keys are /-separated paths; the local backend rejects .. and empty segments. Suggested key layout:
mangas/{manga_id}/cover.{ext}mangas/{manga_id}/chapters/{chapter_id}/pages/{nnnn}.{ext}
Frontend layout
- frontend/src/lib/api/ — typed API client. All backend calls go through here, not raw
fetchin components. The base URL isimport.meta.env.VITE_API_BASE, defaulting to/api. Two layers route/api/*to axum depending on environment:npm run devuses the Vite proxy in vite.config.ts to forward/apitohttp://localhost:8080.- Production / adapter-node uses frontend/src/hooks.server.ts to reverse-proxy
/api/*toBACKEND_URL(compose wireshttp://backend:8080). The browser only ever talks to the SvelteKit container on:3000, so cookies stay same-origin andCORS_ALLOWED_ORIGINScan stay empty.
- frontend/src/routes/ — SvelteKit routes.
- Use Svelte 5 runes (
$state,$derived,$effect,$props). Do not use the legacylet-reactive syntax oron:event=directive form — preferonevent={...}. - The Node adapter is configured for production;
npm run build && node buildis what the Dockerfile runs.
Common commands
# Full stack (compose), production-like
docker compose up --build
# Local dev: Postgres in compose, backend + frontend native
docker compose -f docker-compose.dev.yml up -d
(cd backend && cargo run)
(cd frontend && npm run dev)
# Backend tests
(cd backend && cargo test)
(cd backend && cargo test --test api_mangas) # single integration test file
(cd backend && cargo test list_is_empty_initially) # single test by name
# Frontend tests
(cd frontend && npm test) # vitest, all
(cd frontend && npx vitest run src/lib/api/mangas.test.ts) # single file
(cd frontend && npm run test:e2e) # playwright (auto-starts vite)
Extension points
These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure.
- Tags / lists: new tables joined to
mangas. Newdomain,repo, andapimodules; the existing manga endpoints do not need to change. - Per-page collections / tags:
collectionsis heterogeneous —collection_mangasholds whole mangas,collection_pagesholds individual pages (FK topages.id). Per-user page tags live inpage_tags, which references the sharedtagstable bytag_id(migration 0024) — the same lookup tablemanga_tagsuses, so manga tags and page tags share one global vocabulary. The HTTP contract still speaks tag names;repo::page_tagresolves name↔id viarepo::tag::upsert_by_nameand applies the stricter page-tagnormalize_tag(lowercase, collapse whitespace, reject wildcards/control/invisible chars) at the API layer. Bothcollection_pagesandpage_tagscascade-delete withpagesandchapters, so a saved-page reference is dropped only when itspagesrow is genuinely deleted — i.e. when the chapter is deleted (cascade), or when a user deletes and re-creates a chapter (the user upload path inapi::chapters::finalize_chapterinserts a new chapter row with fresh page ids). A crawler re-fetch does not drop saves:content::persist_pagesupserts pages by(chapter_id, page_number)(ON CONFLICT (…) DO UPDATE … RETURNING id), preserving eachpages.id, so collections and page tags keyed on that id survive the re-fetch. (Migration 0023's header comment predates this and describes the cascade as an unconditional "re-upload drops saves"; the checked-in migration text is intentionally left as-is because sqlx checksums applied migrations.) - Tag-based content search (
/search): the user-facing search surface lives at frontend/src/routes/search/+page.svelte. Three result views (Pages / Chapters / Mangas) consume the matching/v1/me/page-tags,/v1/me/page-tags/chapters, and/v1/me/page-tags/mangasendpoints. Note the two distinct query-param spaces:?q=on/v1/me/page-tagsis a tag-name prefix (for autocomplete in the "Add tag" sheet);?text=on the aggregation endpoints performs OCR full-text search — the active OCR backend writespage_ocr_textrows and a weightedsearch_doctsvector, and the aggregation queries JOIN on aplainto_tsqueryfilter ranked byts_rank(see backend/src/repo/page_analysis.rs). (text=was previously reserved and returned 501text_search_not_yet_supported; that placeholder is gone now that the OCR backend is active. The genericAppError::NotImplemented501 mechanism remains for future feature reservations.) - Full-text / fuzzy search: enable
pg_trgmin a migration and add a GIN index onmangas.title; swap theWHEREinrepo::manga::listto use%operator ortsvector. The API shape (?search=...) does not change. - OCR / autotagging: a background worker (a separate binary or a tokio task spawned in
app::build) that reads pages fromstorage::Storageand writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously. - S3 storage: add
storage::S3StorageimplementingStorage. Branch inapp::buildbased on a config field (e.g.,STORAGE_BACKEND=s3). Handlers do not change. - Auth: an axum middleware producing a
CurrentUserextractor. Bots use an API token header, browser users a session cookie — both should populate the same extractor so handlers stay backend-agnostic. Until this lands, treat all endpoints as unauthenticated (this is acknowledged technical debt, not a final design).