Skip to content

Latest commit

 

History

History
413 lines (296 loc) · 23.7 KB

File metadata and controls

413 lines (296 loc) · 23.7 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Quick Reference

Self-Improvement Instructions

Claude Code must actively maintain and improve this file and .claude/rules/.

After significant development sessions, update with new learnings (patterns, pitfalls, best practices). Keep this file concise (~150 lines) and move detailed topics to .claude/rules/.

Living Documentation

The following documents must be kept up to date when making changes that affect them:

Document Update when...
docs/REQUEST-LIFECYCLE.md Adding/changing decorators, interceptors, guards, pipes, middleware, plugins, services, modules, or the request/response flow
migration-guides/ Releasing MINOR or MAJOR versions (see .claude/rules/migration-guides.md)
.claude/rules/configurable-features.md Adding new configurable features
FRAMEWORK-API.md Auto-regenerated by pnpm run build — verify after adding interfaces, CrudService methods, or core modules

Rule: When a code change adds, removes, or modifies a feature listed in docs/REQUEST-LIFECYCLE.md (Features Overview, diagrams, decorator reference, configuration, etc.), update the document in the same commit or PR.

Project Overview

@lenne.tech/nest-server - An extension layer on top of NestJS for building server applications with GraphQL and MongoDB.

Consumption in downstream projects — npm vs vendor

Consumer projects (created via lt fullstack init / lt server create) can consume this framework in one of two modes. As a framework author working in THIS repo, you should understand both so your changes are compatible with either downstream layout:

  • npm mode (classic) — consumers install @lenne.tech/nest-server as an npm dependency. They read framework code from node_modules/@lenne.tech/nest-server/ and import via the bare specifier from '@lenne.tech/nest-server'. This repo's dist/ is what they actually see at runtime.

  • vendor mode (pilot) — consumers copy this repo's src/core/ (plus src/index.ts, src/core.module.ts, src/test/, src/templates/, src/types/, LICENSE, bin/migrate.js) directly into their project at <api>/src/core/ as first-class project code. No npm dependency. Imports use relative paths. Consumers apply a flatten-fix that moves src/index.ts and src/core.module.ts under src/core/ and rewrites ./core/…./…. Update flow is via the lt-dev:nest-server-core-updater Claude Code agent, which clones this repo at a target ref, computes a structured delta against the consumer's baseline, and presents a curated review.

Implications for framework authors:

  • Keep src/core/ self-contained. Any new import in src/core/** that points OUTSIDE src/core/ (e.g. ../server/..., ../main) breaks vendor consumers, whose src/server/ is unrelated to this repo's. The src/server/ here is a framework-testing example, not a shipping component.

  • Keep top-level src/index.ts and src/core.module.ts consistent with the core directory structure. The flatten-fix strips ./core/ from specifiers in these two files, so if you add re-exports there, use export * from './core/common/...' (not absolute paths).

  • Top-level meta files like FRAMEWORK-API.md, SHOWCASE.md, CHANGELOG.md, and load-tests/ are stripped from vendor consumer projects during convertCloneToVendored. They're framework-author- only content. migration-guides/ is kept because the updater agent reads it at sync time — keep the migration guides readable outside of an npm-install context (no node_modules/… path references).

  • package.json devDeps used at runtime by framework code (e.g. find-file-up in the config loader) must be listed in the CLI's src/config/vendor-runtime-deps.json as a runtime helper, otherwise vendor consumers miss them at install time. Prefer putting runtime-used packages in dependencies directly, not devDependencies.

  • Vendor-mode projects never run pnpm update @lenne.tech/nest-server. They use /lt-dev:backend:update-nest-server-core instead, which does NOT bump a package.json entry but rewrites src/core/** in place. Do not assume version-bump telemetry from npm for vendored installs.

See the CLI's convertCloneToVendored() in lenneTech/cli/src/extensions/server.ts for the full vendor transformation, and the lt-dev:nest-server-core-updater agent for the update-agent contract.

Common Development Commands

# Building & Running
pnpm run build          # Build (outputs to dist/)
pnpm start              # Start in local mode
pnpm run start:dev      # Development mode with watch

# Testing (ALWAYS run before completing changes)
pnpm test               # Run E2E tests (Vitest)
pnpm run test:cov       # With coverage
npx vitest run --config vitest-e2e.config.ts --reporter=hanging-process   # Debug open handles
pnpm run test:cleanup   # Remove leftover test artifacts (.txt, .bin)

# Linting & Formatting
pnpm run lint           # ESLint check
pnpm run lint:fix       # Auto-fix
pnpm run format         # Prettier format

# Package Development
pnpm run build:dev      # Build for local development (use with pnpm link)
pnpm run reinit         # Clean reinstall + tests + build

Code Architecture

Two-Layer Structure:

  • src/core/ - Reusable framework components (exported)
  • src/server/ - Internal test implementation (not exported)
  • src/index.ts - Public API exports

Key Components:

  • CoreModule - Dynamic module with GraphQL (optional, disable via graphQl: false), MongoDB, security
  • src/core/common/ - Decorators, helpers, interceptors, services
  • src/core/modules/ - Auth, BetterAuth, ErrorCode, File, HealthCheck, Migrate, SystemSetup, Tus, User

See .claude/rules/architecture.md for detailed documentation. See docs/REQUEST-LIFECYCLE.md for the complete request lifecycle, security architecture, and interceptor/decorator reference.

Development Guidelines

Core Principles

  1. Module Inheritance Pattern - Extend through inheritance, not hooks/events

    • See .claude/rules/module-inheritance.md
  2. Dynamic Integration - Components opt-in, configurable, no package modification required

  3. Backward Compatibility - Changes affect all consuming projects

  4. Test Coverage - All changes must pass pnpm test

  5. Export Management - New public components → src/index.ts

AI Module: Tools Are Opt-In (Critical for AI agents integrating the module)

The CoreAiModule ships with AiToolRegistry empty by default — it has NO automatic access to any domain model. When integrating the AI module into a consumer project, you MUST register tools for every domain operation that should be reachable from chat (find_users, get_orders, etc.), otherwise the assistant correctly answers "I don't have a tool to do that" for every domain question.

The pattern: extend AiTool, route through a CrudService with context.serviceOptions (NEVER direct Model.find() — bypasses @Restricted and securityCheck), mark mutating tools mutating: true and destructive ones destructive: true. Group in an AiToolsModule and add it to ServerModule.imports.

Reference tools live at src/server/modules/ai/tools/ (find-users, get-user, delete-user, update-user-job-title) — these are framework test fixtures, NOT auto-registered in consumer projects. See src/core/modules/ai/INTEGRATION-CHECKLIST.md §3 + §4 for the full step-by-step.

Role System (Critical)

System roles (S_ prefix) are runtime checks only - NEVER store in user.roles:

@Roles(RoleEnum.S_USER)      // Correct: runtime check
roles: [RoleEnum.S_USER]      // WRONG: never store S_ roles!
roles: [RoleEnum.ADMIN]       // Correct: real role

See .claude/rules/role-system.md for complete documentation.

Versioning

MAJOR.MINOR.PATCH where MAJOR = NestJS version, MINOR = breaking changes, PATCH = non-breaking.

See .claude/rules/versioning.md for release process.

Docker Production Build

The starter includes a multi-stage Dockerfile that works standalone and in monorepos:

docker build -t api .                                    # Standalone
docker build --build-arg API_DIR=projects/api -t api .   # Monorepo

Key files: Dockerfile, docker-entrypoint.sh (migrations + server start), .dockerignore

The migration store uses NSC__MONGOOSE__URI env var (not config.env.ts) for Docker compatibility.

Environment Configuration

// config.env.ts supports:
- Direct environment variables
- NEST_SERVER_CONFIG JSON variable
- NSC__* prefixed variables

Key areas: JWT, MongoDB, GraphQL, email, security, betterAuth

Debugging & Troubleshooting

Issue Solution
Tests timeout Ensure MongoDB running on localhost:27017
GraphQL introspection fails Check config.env.ts introspection setting
Module not found after adding Verify export in src/index.ts, run pnpm run build
Open handles in tests Run vitest with --reporter=hanging-process

Best Practices

  1. All code, comments, documentation in English
  2. Run tests before completing changes - pnpm test
  3. Follow existing patterns for consistency
  4. Never store S_ roles in user.roles array
  5. Use Module Inheritance Pattern for core modules
  6. Document breaking changes in commits
  7. Integration Checklists for Core Modules - Every core module requiring project integration needs INTEGRATION-CHECKLIST.md (see .claude/rules/core-modules.md)
  8. Don't add redundant @UseGuards - @Roles() already handles JWT auth (see .claude/rules/role-system.md)
  9. Fixed package versions only - Never use ^, ~, or ranges in package.json (see .claude/rules/package-management.md)

Migration Guides

When releasing MINOR or MAJOR versions, create migration guides in migration-guides/:

  • Use migration-guides/TEMPLATE.md as starting point
  • Always analyze src/server/ and nest-server-starter
  • Ask developer for additional projects to analyze
  • See .claude/rules/migration-guides.md for complete process

Modular Rules

Detailed documentation in .claude/rules/:

File Content
module-inheritance.md Core architectural pattern for extending modules
role-system.md Role system, S_ prefix rules, @Roles vs @UseGuards
architecture.md Detailed code architecture
testing.md Test configuration and best practices
versioning.md Version strategy and release process
core-modules.md Path-scoped rules for src/core/modules/ incl. Integration Checklist requirements
better-auth.md Security-critical: Better-Auth module rules (standard compliance, security, testing)
module-deprecation.md Legacy Auth → BetterAuth migration roadmap
migration-guides.md Process for creating version migration guides
configurable-features.md Configuration patterns: "Presence implies enabled" and "Boolean shorthand" (true / {})
package-management.md Fixed package versions only - no ^, ~, or ranges
framework-compatibility.md Maintenance obligations for FRAMEWORK-API.md, shipped docs, cross-repo dependencies

Native MongoDB Driver — Security Rules

model.collection / model.db — BLOCKED

Access to this.mainDbModel.collection and this.mainDbModel.db is blocked via TypeScript type (SafeModel = Omit<Model, 'collection' | 'db'>). For additionally injected models (@InjectModel): NEVER use .collection.* or .db.*. All Mongoose plugins (Tenant, Audit, RoleGuard, Password) are bypassed.

Use Mongoose Model methods instead:

Forbidden Allowed
collection.insertOne(doc) Model.insertMany([doc])
collection.bulkWrite(ops) Model.bulkWrite(ops)
collection.updateOne(f, u) Model.updateOne(f, u)
collection.updateMany(f, u) Model.updateMany(f, u)
collection.deleteOne(f) Model.deleteOne(f)
collection.deleteMany(f) Model.deleteMany(f)
collection.find(f) Model.find(f) or Model.find(f).lean()
collection.aggregate(p) Model.aggregate(p)

If native access is unavoidable: use this.getNativeCollection(reason) or this.getNativeConnection(reason) from CrudService.

connection.db.collection() — Use With Caution

Allowed for:

  • Collections without Mongoose schema (OAuth, BetterAuth, MCP)
  • Read-only aggregations/counts on other collections
  • Admin operations (indexes, drops, backups)

NOT allowed for:

  • Write operations on tenant-scoped collections → use Mongoose Model instead
  • CRUD on collections that have a Mongoose schema → use CrudService instead

Details: docs/native-driver-security.md

CrudService process() Pipeline — Memory Considerations

The process() pipeline (prepareInput → checkRights → serviceFunc → processFieldSelection → prepareOutput → checkRights) adds memory overhead per call through object cloning, Mongoose hydration, and populate operations. For typical API usage this is negligible, but it can become significant in:

  • High-frequency operations (e.g. monitor checks running every 10-60 seconds per monitor)
  • Service cascades (Service A → Service B → Service C, each going through process())
  • Populate chains (3-5 levels of nested population)

When to Use CrudService vs Direct Mongoose

CrudService.create/update/get() wraps every operation in process() which provides:

  • Input validation (prepareInput: type mapping, ObjectId conversion)
  • Authorization (checkRights: @Restricted, S_CREATOR, S_SELF, field-level permissions)
  • Output filtering (prepareOutput: recursive secret removal, recursive ObjectId→string conversion, strip restricted fields)

Use CrudService when the caller is a user-facing API (Controller → Service → Response to User) — authorization and field filtering are required.

Use direct Mongoose when the caller is system-internal (Processor, Cron, Service-to-Service) — no user context exists, no response is sent to a user, and input is internally controlled.

Context Use Why
Controller handling user request CrudService.update() User needs authorization + field filtering
Cron job updating internal state Model.findByIdAndUpdate() No user context, no response to user
Queue processor writing results Model.create() System-internal, plugins provide tenant/audit
WebSocket emit loading data Model.findById().lean() Read-only, clients receive JSON
Appending to subdoc array CrudService.pushToArray() Always — even in controllers (see rule 6 below)
Removing from subdoc array CrudService.pullFromArray() Atomic removal, bypasses process()

Performance Alternatives (System-Internal Only)

These alternatives bypass process() but keep Mongoose plugins (Tenant, Audit, RoleGuard) active. Only use in system-internal contexts where no user authorization or output filtering is needed.

Instead of Use Relative Speed Relative Memory
CrudService.create(input) Model.create(input) ~3x faster than save() ~9x less than save()
CrudService.create(input) (batch N) Model.insertMany(docs) ~2.5x faster than N× save() ~3x less than N× save()
CrudService.update(id, input) Model.findByIdAndUpdate(id, input).lean() Fastest update pattern Lowest memory
CrudService.get(id) / getForce(id) Model.findById(id).lean() ~2x slower per call ~5x less memory

Note: Model.create() is significantly faster and lighter than new Model().save() for single documents. findById().lean() trades per-call speed for much lower memory — prefer lean in high-frequency paths where memory stability matters more than per-call latency. For low-frequency user-facing paths, hydrated reads via CrudService are preferred. Model.create() internally calls save() but is optimized by Mongoose — it triggers all pre('save') hooks (Tenant, Audit, RoleGuard, Password).

NEVER bypass Mongoose entirely via collection.* — see section above. The CheckSecurityInterceptor acts as a safety net on HTTP responses regardless of how data was written.

Secret removal happens in two layers:

  • prepareOutput (service-level, in process()): Recursively removes password, verificationToken, passwordResetToken — fields that are NEVER needed internally after hashing/use.
  • CheckSecurityInterceptor (HTTP-level, Safety Net): Removes the full configurable security.secretFields list (additionally refreshTokens, tempTokens) — these are needed internally for token validation and process flows but must not reach HTTP clients.

High-Frequency Path Design Rules

These rules apply when building features that execute many times per minute (monitoring, metrics, queue processors). These paths are always system-internal — no user context, no authorization needed.

  1. Model.create(doc) over new Model().save() for single documents — create() is ~3x faster and uses ~9x less memory. For batch inserts (N>1), use Model.insertMany(docs) — a single call with N documents is ~2.5x faster than N parallel save() calls.

  2. Model.findById().lean() over getForce() for read-only lookups — lean uses ~5x less memory but is ~2x slower per call. In hot paths, the memory savings outweigh the latency cost because reduced GC pressure improves overall throughput. For low-frequency user-facing paths, hydrated reads via CrudService are preferred (faster + authorization).

  3. Defer complex logic to cron/queue — high-frequency paths should only write data (checks, metrics). Processing that data (incident creation, notifications, escalation) belongs in low-frequency cron jobs or separate queue processors that run in bounded batches.

  4. Avoid service cascades in hot paths — if Service A calls Service B which calls Service C, each with process(), the objects from all three levels live in the heap simultaneously. In hot paths, call Mongoose directly instead of going through other services.

  5. WebSocket emits should use lean dataemitUpdated() with getForce() triggers a full process() pipeline per emit. Use Model.findById().lean() instead — WebSocket clients receive JSON, not Mongoose Documents.

  6. NEVER pass Mongoose SubDocument Arrays through CrudService.update() — this applies in ALL contexts (user-facing AND system-internal). Mongoose subdocument arrays (e.g. entity.logs, entity.comments, entity.history) are Proxy-wrapped objects. When passed through process(), clone() (rfdc) and processDeep() trigger Proxy getters/setters on every property of every subdocument, and mergePlain() clones the entire array again. On long-lived documents with hundreds of entries this causes OOM (3GB+ heap). Use CrudService.pushToArray() / pullFromArray() or direct $push / $set:

    // WRONG — even in a controller, this causes OOM on large arrays
    entity.logs.push(newLog);
    await this.entityService.update(id, { logs: entity.logs });
    
    // CORRECT — use CrudService helper (preferred)
    await this.entityService.pushToArray(id, 'logs', newLog);
    await this.entityService.pushToArray(id, 'logs', newLog, { $slice: -500 }); // cap at 500
    
    // CORRECT — direct Mongoose for combined operations
    await entityModel.findByIdAndUpdate(id, {
      $push: { logs: newLog },
      $set: { status: 'RESOLVED', resolvedAt: new Date() },
    }).lean().exec();
    
    // CORRECT — remove items by condition
    await this.entityService.pullFromArray(id, 'tags', 'obsolete');
  7. Clear timers in Promise.race patterns — if using Promise.race([operation, timeout]), always clear the timeout after the race resolves. Leaked timers accumulate memory and cause unhandled rejections in high-frequency paths.

  8. In-memory buffers need eviction — any Map or array used as a buffer (error tracking, dedup, cache) must have a size cap and periodic cleanup. Without eviction, buffers grow unbounded and are never GC'd.

  9. Queue-based crons must prevent re-enqueue of in-progress items — if a cron adds jobs to a BullMQ queue faster than the processor consumes them, the queue grows unbounded. Guard with getWaitingCount() check before enqueuing.

Details: docs/process-performance-optimization.md Details: docs/subdocument-array-optimization-plan.md

Mongoose Index Placement

Rule: Single-field indexes live on the property. Schema.index() is reserved for compound (multi-field) indexes only.

  1. Single-field indexes → declare directly on the property via @Prop({ index: true }) or @UnifiedField({ mongoose: { index: true } }). Keeps all property info in one place and visible during code review.

  2. Framework-managed indexes → do NOT set manually. Example: tenantId is automatically indexed by the mongooseTenantPlugin (src/core/common/plugins/mongoose-tenant.plugin.tsschema.index({ tenantId: 1 })). Adding index: true on tenantId in a consumer project triggers Mongoose "Duplicate schema index" warnings. Any future plugin that auto-indexes a field must be documented here.

  3. Compound (multi-field) indexes → only these belong in Schema.index({ field1: 1, field2: 1 }) after SchemaFactory.createForClass(...). This is the only acceptable use of Schema.index().

  4. TTL indexes → declare inline: @Prop({ required: true, index: { expireAfterSeconds: 3600 } }). Do NOT duplicate with a separate Schema.index() call — the single-field TTL belongs on the property.

  5. unique: true implies an index → Mongoose auto-creates a unique index for @Prop({ unique: true }). Do not additionally declare Schema.index({ field: 1 }, { unique: true }).

Why: Property-local indexes are immediately visible when reviewing a field. Hidden Schema.index() calls at the bottom of the file are easy to miss, leading to duplicate definitions and Mongoose warnings when multiple developers touch the same model. When building plugins that auto-index fields, always emit a warning in the plugin's docstring so consumers know not to set the index manually.

In-Depth Documentation

File Content
docs/REQUEST-LIFECYCLE.md Complete request lifecycle, security architecture, interceptor chain, decorator reference, CrudService pipeline, Safety Net, diagrams
docs/native-driver-security.md Native MongoDB Driver restrictions, secure alternatives, review checklist
docs/process-performance-optimization.md process() pipeline performance optimizations
docs/subdocument-array-optimization-plan.md SubDocument array handling (implemented): pushToArray/pullFromArray, checkRestricted optimization, bug fixes