This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
MX Space is a personal blog server application (AI-powered headless CMS) built with NestJS, PostgreSQL, and Redis. This is a monorepo containing the core server application and related packages. The main application is located in apps/core/; the admin dashboard SPA lives at apps/admin/ and is built into the server release (see apps/admin/CLAUDE.md).
- Dashboard: now an in-repo app at
apps/admin(React 19 SPA,@mx-admin/admin). Built locally during the core build/release, served under/proxy/qaqdmin, and published to Cloudflare R2. Seeapps/admin/CLAUDE.mdanddocs/admin-monorepo-migration.md. (The former standalonemx-space/mx-adminrepo is archived.) - Frontend (Yohaku):
../Yohaku— 主站前端 (Next.js) - haklex:
../haklex(standalone) — Rich editor packages (@haklex/*)
mx-core uses @haklex/rich-headless (zero-React, server-side only) for Lexical JSON processing:
helper.lexical.service.ts—createHeadlessEditor()+allHeadlessNodes+$toMarkdown()for JSON → Markdown conversionai-translation/lexical-translation-parser.ts— AI translation content parsing- After haklex releases, update the pinned version in
apps/core/package.json
- Node.js: >= 22 (see
.nvmrcin root) - pnpm: Use Corepack (
corepack enable)
All commands should be run from the repository root unless specified otherwise.
pnpm dev- Start development serverpnpm build- Build the projectpnpm bundle- Create production bundlepnpm test- Run all testspnpm lint- Run ESLintpnpm format- Format code with Prettier
# Run a single test file
pnpm test -- test/src/modules/user/user.service.spec.ts
# Run tests matching a pattern
pnpm test -- --testNamePattern="should create user"
# Run tests in watch mode
pnpm -C apps/core run test:watchnpm run start:debug- Start with debug modenpm run start:cluster- Start in cluster modenpm run repl- Start REPL mode
apps/core/src/modules/- Business logic modules (auth, posts, comments, etc.)apps/core/src/processors/- Infrastructure services (database, redis, gateway, helpers)apps/core/src/common/- Shared utilities (guards, interceptors, decorators)apps/core/src/migration/- Database migration scriptsapps/core/test/- Test files and mockspackages/- Shared packages (api-client, webhook)
API Route Prefix: The @ApiController() decorator adds /api/v{version} prefix in production but no prefix in development. This allows direct access during development.
Processors: Infrastructure services organized in processors/:
database/- PostgreSQL connection (Drizzle ORM), repository registry, base repository classredis/- Redis caching and pub/subgateway/- WebSocket gateways for real-time featureshelper/- Utility services (email, image, JWT, Lexical, etc.)
Database: Uses PostgreSQL 16+ with Drizzle ORM. Schema definitions in src/database/schema/. Drizzle SQL migrations in src/database/migrations/. IDs are Snowflake bigint (serialized as strings at API boundaries). Repositories extend BaseRepository and are registered via repository.tokens.ts.
Authentication: Better Auth-based session management with decorators @Auth() for route protection and @CurrentUser() for accessing the authenticated user. Supports password, OAuth, Passkey, and API key (x-api-key header).
Every successful JSON response has the shape { data, meta? }. Every error has the shape { error: { code, message, details? } }.
Success envelope — ResponseInterceptor (global APP_INTERCEPTOR) wraps controller return values:
- A bare value
T→{ data: T } - A value produced via
withMeta(data, meta)(from~/common/response/envelope.types) → emitted as{ data, meta }. Detection is by an internalSymbol, not by the presence of adatakey — returning a literal{ data, ... }will be double-wrapped. CI enforces this viascripts/check-controller-response-envelope.ts. undefined→204 No Content
Error envelope — AppExceptionFilter (global APP_FILTER) maps every thrown error:
AppException(and subclasses) →{ error: { code, message, details? } }at the exception's HTTP statusZodError→ 400VALIDATION_FAILEDwithdetails.issues- Other
HttpException→{ error: { code: 'HTTP_ERROR', message } } - Unknown errors → 500
INTERNAL_ERROR
Exceptions — extend AppException with a stable SCREAMING_SNAKE code:
throw new BizException(ErrorCodeEnum.PostNotFound) // code: 'PostNotFound', 404
throw new CannotFindException() // code: 'NOT_FOUND', 404
throw new BanInDemoExcpetion() // code: 'DEMO_FORBIDDEN', 403
throw new NoContentCanBeModifiedException() // code: 'NO_CONTENT_MODIFIABLE', 400Meta — use MetaObjectBuilder for cross-cutting per-request data (pagination, translation, enrichment, interaction). Located in src/common/response/meta-builder.ts.
Named views — field selection uses *.views.ts Zod schemas (e.g. PostViews.card, PostViews.detail) instead of a ?select= parameter. Views are parsed at the controller layer.
Case conversion — code is camelCase end to end (Drizzle column TS props, Zod DTOs, services). The RequestCaseNormalizationPipe (global, runs before the Zod validation pipe) folds incoming request keys to camelCase: query and path params are camelized recursively; request bodies are camelized only at the top level so freeform JSON values (meta, socialIds, AI agent messages, snippet payloads) survive verbatim. Both ?sort_by= and ?sortBy= reach the controller as sortBy. ResponseInterceptor converts the response data/meta back to snake_case at the wire boundary (transformResponseCase in src/common/response/case-transform.ts); the wire format stays snake_case. DB column names are unchanged — each Drizzle column keeps its explicit snake_case name string. Never call a manual snakeCaseKeys-style helper in a controller.
@BypassCaseTransform([paths]) — opt a field subtree out of snake_case conversion (free-form JSON columns, snippet payloads). Paths root at data, dotted segments, [] marks an array level (e.g. 'items[].rawPayload'). Located in src/common/decorators/bypass-case-transform.decorator.ts.
@HTTPDecorators.RawResponse — opt out of the whole envelope + casing pipeline for non-JSON responses (streams, HTML, RSS, redirects). Located in src/common/decorators/http.decorator.ts.
Writing a new endpoint:
- Return
<value>for a bare envelope, orwithMeta(<value>, new MetaObjectBuilder()...build())for{ data, meta }. Never return an object literal whose top-level keys includedata. - Throw
AppExceptionsubclasses (orBizExceptionwith anErrorCodeEnumcode) for errors. - Use
@HTTPDecorators.RawResponseonly if the response is not JSON. - Define or reuse a view in
<resource>.views.tsand parse through it before returning.
Uses Vitest with PostgreSQL testcontainers (@testcontainers/postgresql) and Redis memory server.
Use createE2EApp helper from test/helper/create-e2e-app.ts. Tests requiring PostgreSQL use startPgTestContainer() from test/helper/pg-testcontainer.ts.
import { createE2EApp } from 'test/helper/create-e2e-app'
const proxy = createE2EApp({
imports: [...],
controllers: [MyController],
providers: [...],
})
it('should work', async () => {
const res = await proxy.app.inject({ method: 'GET', url: '/...' })
expect(res.statusCode).toBe(200)
})test/helper/pg-testcontainer.ts- Ephemeral PostgreSQL 17 container per test runtest/helper/pg-repository-mock.ts- Repository mock utilitiestest/helper/redis-mock.helper.ts- Redis mocktest/helper/create-mock-global-module.ts- Global module mockingtest/mock/modules/- Module-level mocks (auth, redis, gateway)test/mock/processors/- Processor mocks (email, event)
Database migrations use Drizzle Kit. SQL migration files live in src/database/migrations/ (e.g. 0000_initial.sql). Historical data migrations from the MongoDB era are in src/migration/postgres-data-migration/.
Release-phase migration: schema migrations run as a one-shot pre-deploy step, NOT on app startup. The app boot guard (assertSchemaCurrent in processors/database/postgres.provider.ts) refuses to start if the schema is behind. Run via pnpm -C apps/core run migrate locally or via the mx-migrate service in compose.
When authoring or reviewing a migration, use the mx-migration-author skill — it enforces expand-contract for rolling deploys (Dokploy 2 replicas). CI runs pnpm -C apps/core run lint:migrations to flag dangerous patterns. Full design: docs/superpowers/specs/2026-05-05-database-migration-release-phase-design.md.
Configuration via src/app.config.ts supports:
- Environment variables
- Command line arguments
- YAML configuration files