Documentation Index
Fetch the complete documentation index at: https://mintlify.com/syhily/yufan.me/llms.txt
Use this file to discover all available pages before exploring further.
yufan.me uses Drizzle ORM with Postgres. The schema is organized around 15 business domains, each living in its own folder under src/server/domains/. Every domain follows a locked file vocabulary that separates table definitions, queries, business logic, API projections, and caching strategy into distinct files — making it straightforward to navigate any domain without reading the whole codebase.
Domain module vocabulary
Each folder under src/server/domains/ contains a fixed set of files:
| File | Responsibility |
|---|
schema.ts | Drizzle table definitions and column types |
repo.ts | Raw database queries against those tables |
service.ts | Business logic that orchestrates repo calls |
projection.ts | Data transformation from DB rows to API response shapes |
cache.ts | Redis caching strategy for the domain |
Additional feature-named files (preview.ts, loader.ts, fence.ts, etc.) extend a domain when the locked vocabulary is not sufficient, but never replace it. Domains may import from shared/, infra/, and other domains/.
Key tables and relationships
Content
| Table | Description |
|---|
post | Blog posts with slug, title, body (JSONB PortableText), categoryId FK, published flag, publishDate, cover image reference, and publishedRevisionId pointing to the active content revision |
page | Static pages with slug, title, body (JSONB PortableText); slugs carry a DB UNIQUE constraint |
content | Content revisions shared by both posts and pages; post.publishedRevisionId points to the active revision — a post with published=true but no published revision is still treated as a draft |
A post is considered draft (invisible to the public) when published=false or publishedRevisionId=null. All public queries check both conditions.
Taxonomies
| Table | Description |
|---|
category | Post categories; each post has exactly one category; deletion is blocked while any post still references the row |
tag | Post tags; deletion is likewise blocked while referenced |
post_tag | Many-to-many join between post and tag |
| Table | Description |
|---|
image | Image library: slug, S3 storage path, public URL, and thumbhash for blur-up placeholders; bytes live in S3 |
music | Music library: 16-char nanoid playerId, title, artist, source, sourceId, audio path, cover path, and lyric text so the player avoids a second round trip |
Social
| Table | Description |
|---|
comment | Post comments with name, hashed email (used for Gravatar lookup), body, and a postId FK |
Users and sessions
| Table / Store | Description |
|---|
user | Admin and author accounts with role, email, and passwordHash |
| Redis (sessions) | Sessions are Redis-backed via react-router’s createSessionStorage — there is no session DB table |
Settings
| Table | Description |
|---|
setting | 14 JSONB rows keyed by scope (e.g. blog.general, blog.assets). All runtime configuration lives here — there is no checked-in config file |
Analytics and auditing
| Table | Description |
|---|
access_log | Analytics visit records with geo fields (country, city, region) optionally enriched via MaxMind GeoLite2 |
audit_log | Admin event log with actor identity, action (kebab-case verb), resource type, and details JSONB. L3-sensitive fields are automatically masked to *** in API responses |
Shared slug namespace
Posts and pages share one global slug namespace. A page slug and a post slug cannot collide — the catalog, OG generator, comment threading, and sitemap all key on slug alone.
Enforcement is split across two layers:
- A DB
UNIQUE(slug) constraint on the page table catches page-to-page collisions at the database level.
- The cross-table fence in
@/server/domains/pages/fence::validateSlugFence enforces the post-to-page boundary in application code. All new slug-emitting surfaces must fold into validateSlugFence.
Slug derivation runs pinyin-pro → whitespace-collapse → github-slugger in @/server/infra/slug::deriveSlug. The pipeline is server-only — pinyin-pro ships ~150 KB of CJK lookup tables that must never reach the client bundle.
PortableText storage
Post and page bodies are stored as JSONB PortableText arrays. The canonical Zod schema in @/shared/pt/schema is the wire format for both the Tiptap editor (authoring) and @portabletext/react (SSR rendering). Custom block components (image, code, mathBlock, mermaid, musicPlayer, solution, footnoteDefinition, table) live in @/ui/pt/blocks/.
The PortableText ↔ ProseMirror bridge in @/shared/pt/bridge handles lossless round-trips between the editor state and the stored format. Round-trip fidelity is contract-tested.
Settings JSONB rows
All runtime configuration lives in the setting table as 14 independent JSONB rows. Each row is saved independently so concurrent admin tabs cannot race on an aggregate settings object. The 14 scopes are:
blog.general · blog.assets · blog.navigation · blog.socials · blog.content · blog.sidebar · blog.comments · blog.seo · blog.mail · blog.cache · blog.rateLimit · blog.search · blog.fonts · blog.backup · blog.limits
The Zod schema for each row lives in src/server/domains/settings/schema.ts. The mapping from section key to DB scope, Zod schema, and bundle key is defined in SECTION_REGISTRY inside src/server/domains/settings/sections.ts.
The audit_log table is excluded from pg_dump database backups. Instead, rows older than auditLogDbRetentionDays (default 30, max 90) are archived daily at 04:00 to S3 as audit-log/archive/YYYY-MM-DD.jsonl.gz and then deleted from the database. S3 archives are retained for auditLogArchiveRetentionDays (default 180 days).The access_log table optionally uses TimescaleDB for hypertable time-series optimization when analytics query volume warrants it.