Skip to main content

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:
FileResponsibility
schema.tsDrizzle table definitions and column types
repo.tsRaw database queries against those tables
service.tsBusiness logic that orchestrates repo calls
projection.tsData transformation from DB rows to API response shapes
cache.tsRedis 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

TableDescription
postBlog posts with slug, title, body (JSONB PortableText), categoryId FK, published flag, publishDate, cover image reference, and publishedRevisionId pointing to the active content revision
pageStatic pages with slug, title, body (JSONB PortableText); slugs carry a DB UNIQUE constraint
contentContent 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

TableDescription
categoryPost categories; each post has exactly one category; deletion is blocked while any post still references the row
tagPost tags; deletion is likewise blocked while referenced
post_tagMany-to-many join between post and tag

Media

TableDescription
imageImage library: slug, S3 storage path, public URL, and thumbhash for blur-up placeholders; bytes live in S3
musicMusic 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

TableDescription
commentPost comments with name, hashed email (used for Gravatar lookup), body, and a postId FK

Users and sessions

Table / StoreDescription
userAdmin 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

TableDescription
setting14 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

TableDescription
access_logAnalytics visit records with geo fields (country, city, region) optionally enriched via MaxMind GeoLite2
audit_logAdmin 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:
  1. A DB UNIQUE(slug) constraint on the page table catches page-to-page collisions at the database level.
  2. 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.

Build docs developers (and LLMs) love