nestjs-crud
Wires up database CRUD operations in NestJS apps with permission controls and relation loading.
Installation
Paste this into Claude Code, Cursor, or any agent that can run commands.
SKILL.mdShow the author's original SKILL.md
---
name: nestjs-crud
description: >-
Use when integrating `@nestjs-crud/*` (v2.2+) — wiring TypeORM/Drizzle/MikroORM/Prisma adapters,
configuring `@Crud()`/`@CrudAuth()`/`@Override()`/`@Feature()`/`@Action()`, opt-in cursor
pagination, ACL/RBAC guards via `getFeature`/`getAction` + `nest-access-control`/CASL, DTOs
with `CrudValidationGroups`, split-query relation loading, nested `?join=` 400 `Invalid join`,
npm `ERESOLVE` on `ioredis`, debugging `RequestQueryException`,
`CrudCacheNotConfiguredError`, `EBADENGINE` (Node <22.12), validation-fails-on-update,
MikroORM stale-em, savepoints on overridden writes, TS2559 "has no properties in common"
on `implements CrudController` with `serviceProperty` (use `CrudControllerFor`).
---
# @nestjs-crud
Auto-generates RESTful CRUD endpoints from `@Crud()`. Four adapters: TypeORM, Drizzle, MikroORM, Prisma. Node 22.12+ (NestJS 12 is ESM-only → Node's `require(esm)`, which arrives at 22.12). NestJS 10–12, TypeORM 0.3.30+ or 1.x (NestJS 12 + TypeORM 1 since v2.3). [CHANGELOG](https://github.com/kodjunkie/nestjs-crud/blob/master/CHANGELOG.md).
## Install
```bash
npm install @nestjs-crud/core @nestjs-crud/<adapter> # typeorm | drizzle | mikro-orm | prisma
npm install @nestjs-crud/request # optional frontend QB
# All four adapters declare the DB driver as an optional peer — install whichever your backend uses:
npm install pg # Postgres (typeorm/drizzle/mikro-orm)
npm install mysql2 # MySQL (typeorm/drizzle/mikro-orm)
# Prisma 7 needs a driver adapter:
npm install @prisma/adapter-pg pg # Postgres
npm install @prisma/adapter-mariadb mariadb # MySQL
npm install -D prisma # CLI
```
Swagger optional — install `@nestjs/swagger` to enable metadata; library null-guards every Swagger path when absent.
## Quickstart
`@Crud()` block + `@Controller()` are identical across adapters. Only service ctor differs.
```typescript
@Crud({
model: { type: User },
query: {
limit: 25, maxLimit: 100,
join: { profile: { eager: true }, posts: { allow: ['id', 'title'] } },
},
})
@Controller('users')
export class UsersController implements CrudController<User> {
constructor(public service: UsersService) {}
}
```
### TypeORM service
```typescript
@Injectable()
export class UsersService extends TypeOrmCrudService<User> {
constructor(@InjectRepository(User) repo: Repository<User>) {
super(repo);
}
}
```
### Drizzle service
```typescript
@Injectable()
export class UsersService extends DrizzleCrudService<typeof users.$inferSelect> {
constructor(@Inject('DB') drizzleDb: typeof db) {
super(drizzleDb, users); // (db instance, table reference)
}
}
```
### MikroORM service
```typescript
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityRepository } from '@mikro-orm/core';
@Injectable()
export class UsersService extends MikroOrmCrudService<User> {
constructor(@InjectRepository(User) usersRepo: EntityRepository<User>) {
super(usersRepo, User);
}
}
```
Constructor accepts `EntityManager | EntityRepository<T>` (v2.2+). `super(usersRepo, User)` unwraps via `repo.getEntityManager()` internally. The DI-injected em proxy resolves to the per-request forked em via `RequestContext` — request-scope identity-map isolation preserved without effort. To pass `EntityManager` directly: `constructor(em: EntityManager) { super(em, User); }` — same behavior.
### Prisma service (v2.1+ with Prisma 7)
```typescript
import { PrismaCrudService, PrismaJoinResolver } from '@nestjs-crud/prisma';
const prisma = new PrismaClient({
adapter: new PrismaPg(new Pool({ connectionString: process.env.DATABASE_URL })),
});
@Injectable()
export class UsersService extends PrismaCrudService<User> {
constructor(@Inject('PRISMA') prisma: PrismaClient) {
super(prisma, 'user', {
entityColumns: ['id', 'email', 'isActive', 'companyId', 'deletedAt'],
entityPrimaryColumns: ['id'],
entityHasDeleteColumn: true,
softDeleteColumn: 'deletedAt',
onBadRequest: (msg) => { throw new BadRequestException(msg); },
joinResolver: new PrismaJoinResolver({
relationFields: ['company'],
allowedColumnsByRelation: { company: ['id', 'name'] },
}),
});
}
}
```
Prisma 7 removed the env-URL path — driver adapter required. Schema rewrite covered in [v2.1 Migration wiki](https://github.com/kodjunkie/nestjs-crud/wiki/v2.1-Migration). Swap `@prisma/adapter-pg` for `@prisma/adapter-mariadb` on MySQL. 3rd ctor arg = `PrismaCrudServiceConfig`; all fields above are required, `logger` and `cacheStrategy` are optional fields of it.
## Generated Endpoints
| Method | Path | Handler |
|--------|------|---------|
| GET | `/users` | `getManyBase` |
| GET | `/users/:id` | `getOneBase` |
| POST | `/users` | `createOneBase` |
| POST | `/users/bulk` | `createManyBase` |
| PATCH | `/users/:id` | `updateOneBase` |
| PUT | `/users/:id` | `replaceOneBase` |
| DELETE | `/users/:id` | `deleteOneBase` |
| PATCH | `/users/:id/recover` | `recoverOneBase` (requires `query.softDelete: true`) |
## `@Crud()` Key Options
```typescript
@Crud({
model: { type: Entity }, // required
dto: { create, update, replace }, // optional — see DTOs
serialize: { getMany, get }, // per-route response DTOs
query: {
limit: 25, maxLimit: 100,
pagination: 'offset' | 'cursor', // v2.2+ — default 'offset'
cache: 2000, // ms
alwaysPaginate: false,
softDelete: false, // enables recoverOneBase
relationLoadStrategy: 'join' | 'query', // TypeORM only
join: { rel: { eager, allow, exclude, required } },
filter: [{ field, operator, value }],
sort: [{ field, order }],
exclude: ['password'],
},
routes: { exclude: ['createManyBase'], updateOneBase: { allowParamsOverride: false } },
params: { id: { field: 'id', type: 'uuid', primary: true } },
serviceProperty: 'usersService', // v2.2+ — defaults 'service'; see §IntelliSense for typing
})
```
Full reference: [Controllers wiki](https://github.com/kodjunkie/nestjs-crud/wiki/Controllers).
**Nested joins (v2.3+).** `?join=profile.licenses` needs every ancestor requested in `?join=` or eager, in any order and at any depth. Otherwise all 4 adapters return 400 `Invalid join: 'profile.licenses'`. An eager nested key whose ancestor is not eager makes `@Crud()` throw at startup. Only TypeORM and Drizzle load nested relations; MikroORM and Prisma join or include the top-level relation only. A `?join=` relation missing from `query.join` is ignored, not rejected.
## Cursor Pagination (v2.2+, opt-in)
```typescript
@Crud({ query: { pagination: 'cursor', limit: 25 } })
```
Switches `getManyBase` to keyset cursor mode. Response: `{ data, count, cursor: { next, prev } }`. Default `'offset'` preserved (non-breaking).
- Cursor = opaque base64url JSON `{sortField, sortValue, id, dir}`. **NOT signed** — authorization stays in `@CrudAuth`. Cursor opacity ≠ access boundary.
- `@CrudAuth` filter applies BEFORE cursor (auth merges into `parsed.search` at the interceptor; cursor `WHERE` ANDs on top at the service). Cursor pages are subsets of the `@CrudAuth`-filtered result set — same isolation guarantees as offset mode.
- Sort: `?sort=` else route `query.sort` default (default honored v2.2.6+); single field + auto-PK tail. Multi-sort or no sort at all → 400. Requires `limit` or `maxLimit` (missing → 400). Tampered/wrong-sort cursor → 400.
- Cursor mode bypasses cache wrap (per-cursor-key cardinality unbounded). On hot endpoints, pair with `@nestjs/throttler`.
- Honored across all 4 adapters via per-adapter `QueryComposer.applyCursor`. Prisma's built-in `cursor:` arg intentionally bypassed (single-column unique-key only).
[Cursor Pagination wiki](https://github.com/kodjunkie/nestjs-crud/wiki/CursorPagination).
## Query Params (Frontend → Backend)
```typescript
RequestQueryBuilder.create()
.setFilter({ field: 'isActive', operator: CondOperator.EQUALS, value: true })
.setOr({ field: 'role', operator: CondOperator.IN, value: ['admin'] })
.setJoin({ field: 'profile' })
.sortBy({ field: 'createdAt', order: 'DESC' })
.setLimit(20).setPage(2)
.query();
```
**23 operators (`CondOperator`):** `$eq $ne $gt $gte $lt $lte $between $isnull $notnull $in $notin $cont $excl $starts $ends` + 8 case-insensitive `*L` variants.
Raw form: `?filter=name||$cont||john&sort=createdAt,DESC&join=profile&limit=25&page=2&cursor=<opaque>`
## Global Defaults
Set project-wide defaults in `main.ts` BEFORE `NestFactory.create`. Every `@Crud()` deep-merges; controller scalars win, arrays replace.
```typescript
CrudConfigService.load({
query: { limit: 25, maxLimit: 100, alwaysPaginate: true, cache: 2000, softDelete: false },
routes: { updateOneBase: { allowParamsOverride: false, returnShallow: false }, deleteOneBase: { returnDeleted: false } },
params: { id: { field: 'id', type: 'number', primary: true } },
serialize: { getMany: false }, // disable serialize globally for list responses
queryParser: { delimiter: ',' }, // passed to RequestQueryBuilder.setOptions
auth: { property: 'user' }, // default user property for @CrudAuth
});
```
## `@CrudAuth` — Per-User Scoping
```typescript
@Crud({ model: { type: Post } })
@CrudAuth({
property: 'user',
filter: (u: User) => ({ authorId: { $eq: u.id } }), // appended to every read
persist: (u: User) => ({ authorId: u.id }), // set on every write
// or: (u: User) => ({ isPublic: { $eq: true } }), // alternative to filter: rows match `or` OR the request search; filter skipped when set
})
@UseGuards(JwtAuthGuard)
@Controller('posts')
export class PostsController { constructor(public service: PostsService) {} }
```
`persist` keys validated against entity columns at runtime — typos → 400 `@CrudAuth persist: invalid key(s) "X" — not columns on the target entity`.
**Guard ordering:** `@UseGuards()` MUST be on the controller class (runs before `CrudRequestInterceptor` reads `req.user`).
## ACL / RBAC integration
Generated route handlers carry an auto-applied `@Action(CrudActions.<X>)` — values: `Read-All`, `Read-One`, `Create-One`, `Create-Many`, `Update-One`, `Replace-One`, `Delete-One`, `Recover-One`. Tag the controller with `@Feature(name)` and reflect both from a guard via `getFeature(ctx.getClass())` + `getAction(ctx.getHandler())`, then dispatch to your ACL backend (`nest-access-control`, CASL, etc). Full `ACLGuard` example: [Controllers wiki §Additional decorators](https://github.com/kodjunkie/nestjs-crud/wiki/Controllers#additional-decorators).
## `@Override()` — Custom Handlers
```typescript
@Override('getOneBase')
getUser(@ParsedRequest() req: CrudRequest, @Param('id') id: string) {
return this.service.getOne(req);
}
@Override('createOneBase')
createUser(@ParsedRequest() req: CrudRequest, @ParsedBody() dto: User) {
return this.service.createOne(req, dto);
}
```
`@Override` accepts: `'getManyBase'`, `'getOneBase'`, `'createOneBase'`, `'createManyBase'`, `'updateOneBase'`, `'replaceOneBase'`, `'deleteOneBase'`, `'recoverOneBase'`.
Use `@ParsedBody()` not `@Body()` in write overrides — `@Body()` bypasses `class-validator` group selection (CREATE and UPDATE validation become identical).
## DTOs — Two Patterns
**Pattern A (default): Entity-as-DTO** with `class-validator` groups:
```typescript
const { CREATE, UPDATE } = CrudValidationGroups;
@IsOptional({ groups: [UPDATE] })
@IsNotEmpty({ groups: [CREATE] })
@IsString({ always: true })
name: string;
```
`CREATE` → POST + PUT; `UPDATE` → PATCH; `{ always: true }` → both.
**Pattern B: Dedicated DTOs** via `@Crud({ dto: { create, update, replace } })`. With `dto`, `CrudValidationGroups` not used — controller validates against the DTO class directly. Combine entity-in (A) + DTO-out (`@Crud({ serialize })`) freely.
### Validation groups: factory vs hand-rolled controllers
`CrudValidationGroups.CREATE` / `UPDATE` only fire when the validator runs with matching `groups`. The `@Crud()` factory binds `new ValidationPipe({ groups: [CREATE | UPDATE], ... })` per generated route automatically. Hand-rolled controllers built without `@Crud()` get the **default** pipe (`groups: undefined`); group-scoped decorators silently skip — required-field validators don't fire.
```typescript
// ✗ Hand-rolled — group-scoped decorators silently skip
@Controller('special')
export class SpecialController {
@Post('/users')
createSpecial(@Body() user: User) { ... } // CREATE-group validators DO NOT run
}
// ✓ Hand-rolled — wire the group pipe explicitly
@Post('/users')
createSpecial(
@Body(new ValidationPipe({ groups: [CrudValidationGroups.CREATE], whitelist: true })) user: User,
) { ... }
```
Many such routes? Factor a helper. Alternative: switch to dedicated DTOs (Pattern B) without group decorators — they validate identically everywhere.
## Strict Field Allowlist (v2 BREAKING)
Every field in `?sort/?filter/?search/?fields` MUST be entity column OR allow-listed relation. Otherwise 400; wording varies by adapter and branch: `Field "X" is not allowed`, `Invalid field: 'X'`, `Invalid sort field: 'X'`, `Unknown column: X`. No opt-out. Common breakages: TypeORM `@VirtualColumn`/`@Formula` (not in `metadata.columns` — override `protected entityColumnsHash` in subclass to allow), client-side aliases, dotted paths against unjoined relations.
## Caching
`@Crud({ query: { cache: <ttl-ms> } })` honored by all 4 adapters when a `CacheStrategy` is wired (TypeORM-only pre-v2.2). TTL is milliseconds.
```ts
import { createClient } from 'redis';
import { CrudConfigService } from '@nestjs-crud/core';
import { TypeOrmCacheStrategy } from '@nestjs-crud/typeorm';
const redis = createClient({ url: 'redis://localhost:6379' });
CrudConfigService.load({
query: { cache: 5000, cacheStrategy: new TypeOrmCacheStrategy(redis) },
});
```
Per-CrudService override available via constructor. Per-route override via `@Crud({ query: { cache } })`. Per-request bypass via `?cache=0`. Cursor routes never cache (§Cursor Pagination).
Strategies: `TypeOrm | MikroOrm | Drizzle | PrismaRedis | PrismaAccelerate`. Accept `redis` (node-redis v5, or v6 since v2.3) or `ioredis` clients with lazy-once auto-connect — no explicit `connect()` required. Custom backends: implement `RedisLike` (`set / get / del / scanPrefix`) from `@nestjs-crud/core/cache`. Writes auto-invalidate by entity prefix.
`@Crud cache` set without strategy (and no TypeORM `DataSource.cache` fallback) → `CrudCacheNotConfiguredError` on next cached read. Setup, security, tuning: [Caching wiki](https://github.com/kodjunkie/nestjs-crud/wiki/Caching).
## Transactions on Write Overrides
`updateOne` / `replaceOne` / `deleteOne` wrap read-modify-write in tx at READ COMMITTED — closes lost-update race. `recoverOne` excluded (no prior read). If you wrap an `@Override()` in your own outer tx, adapter's inner tx becomes a savepoint inside yours; higher outer isolation NOT downgraded; rollbacks cascade up.
## TypeORM Split-Query Relation Loading
`@Crud({ query: { relationLoadStrategy: 'query' } })` — separate query per relation via `setFindOptions`. **Footgun:** `JoinOption.allow` is ignored; relations load all columns. Don't opt in if you use `allow` to hide sensitive columns. A nested join loads only when its full dotted path is a `query.join` key (v2.3+). Drizzle, MikroORM and Prisma ignore the option.
| Strategy | Behavior | When to pick |
|---|---|---|
| `'join'` | Single SQL via `leftJoin`/`innerJoin` | Shallow joins; or you depend on `JoinOption.allow` |
| `'query'` | Separate query per relation via `setFindOptions` | Deep multi-relation reads where JOIN cross-products inflate payload |
## Swagger Customization
`@Crud({ swagger: {...} })` controls operation text, tags, examples, error responses, version-aware grouping. Defaults: imperative summaries, multi-line descriptions, 400/401-if-auth/404 emission, auto `@ApiTags`, auto request-body examples from `@ApiProperty`.
**Constraints:** `operationId` not overridable; default 401 emission keys off `@CrudAuth` (set `swagger.errorResponses.unauthorized: true` for `APP_GUARD` setups); `synthExample` consumer fn wins over metadata; `@Override()` + manual `@ApiOperation()` wins over `swagger.operations[...]`. Full reference: [Swagger wiki](https://github.com/kodjunkie/nestjs-crud/wiki/Swagger).
## Logger
All 4 adapters default to `new Logger(<ServiceName>)`. TypeORM/Drizzle/MikroORM take `logger?: LoggerService` as the positional ctor arg before `cacheStrategy?` — `(repo, logger?, cacheStrategy?)`, `(db, table, relationsConfig?, logger?, cacheStrategy?)`, `(emOrRepo, entityClass, logger?, cacheStrategy?)`. Prisma's logger lives inside `serviceConfig` as `{ error, warn?, debug? }`.
**Emission convention:** `debug` for query traces; `warn` for SQLi rejections + tx rollbacks; `error` for uncaught DB errors. **NEVER interpolate `err.message`** — DB drivers leak SQL parameter values into messages; emit `err.name` as the message and pass `err.stack` as second arg.
## IntelliSense
`@Crud()` wires handlers at runtime; TS can't see them. `implements CrudController<T>` restores `service` typing. From inside `@Override()`, `this.service.getMany(req)` is fully typed and idiomatic. Composing two generated handlers in one override: `get base(): CrudController<User> { return this; }`.
**With `serviceProperty` (v2.2.5+):** `implements CrudController<T>` fails — TS2559 `has no properties in common`. `CrudController<T>` is all-optional (weak type); a class declaring only a renamed field shares zero properties with it. Use `CrudControllerFor<T, P>` instead:
```typescript
@Crud({ model: { type: Contact }, serviceProperty: 'contactService' })
@Controller('contacts')
export class ContactsController implements CrudControllerFor<Contact, 'contactService'> {
constructor(public contactService: ContactsCrudService) {}
}
```
Default `CrudControllerFor<T>` ≡ `CrudController<T>`. Alternative: drop `implements` entirely — routing doesn't need it.
## Best Practices
- Use `CondOperator` enum, not raw strings (`'$cont'` silently misspells)
- Always `exclude` sensitive fields in `query` config (`?fields=password` returns it without)
- Always `allow`-list join fields on sensitive relations
- `search` for AND/OR composition; `filter` is AND-only
- Set `maxLimit` server-side — never trust client `limit`
## Common Issues
| Symptom | Fix |
|---------|-----|
| Relations not loading | Add to `query.join`. Without it, client join requests silently ignored. |
| `maxLimit` exceeded → 400 | Raise `maxLimit` or set `alwaysPaginate: false`. |
| Validation always fails on update | Fields need `@IsOptional({ groups: [UPDATE] })`. |
| `Cannot find module 'typeorm'` / `Cannot find module 'pg'` / `Cannot find module 'mysql2'` / `repo.createQueryBuilder is not a function` | Adapter ORM or DB driver peer not installed. `npm install typeorm pg` (Postgres) or `npm install typeorm mysql2` (MySQL). All four adapters declare DB drivers as optional peers — pick the one your backend uses. |
| Swagger metadata empty | `@nestjs/swagger` not installed. Library skips Swagger setup; install + restart. |
| `@CrudAuth` filter not applying | `@UseGuards()` must be on controller class (runs before interceptor). |
| Flat array instead of `{ data, count, total, page }` | `alwaysPaginate: true` inside `query:` (NOT top-level). |
| 400 `Field "X" is not allowed` / `Invalid field: 'X'` / `Invalid sort field: 'X'` / `Unknown column: X` | Add to entity columns or `query.join` allow-list. |
| 400 `@CrudAuth persist: invalid key(s) "X"` | Typo in `@CrudAuth({ persist })` against entity column. |
| TS2559 `has no properties in common with type 'CrudController<T>'` | `serviceProperty` renamed the field; weak-type check fails. `implements CrudControllerFor<Entity, 'fieldName'>` (v2.2.5+) or drop `implements`. |
| `EBADENGINE` on `npm install` | Node <22.12. Upgrade or pin to `^1.0.2`. |
| `CrudCacheNotConfiguredError` | `@Crud cache` set but no `CacheStrategy` wired (and no TypeORM `DataSource.cache` fallback). |
| Cursor: `Cursor pagination supports a single sort field` → 400 | Multi-sort unsupported — use one field. No sort at all: pass `?sort=` or set `query.sort` (default honored v2.2.6+; earlier needs explicit `?sort=`). |
| Cursor: `Cursor pagination requires a limit` → 400 | Cursor mode needs `query.limit` or `maxLimit`. |
| Cursor: `Invalid cursor` / `Cursor token exceeds maximum length` → 400 | Tampered or malformed cursor, or longer than 1024 characters. Re-fetch the first page. |
| Cursor: `Cursor sort field mismatch` → 400 | Cursor was issued under another `?sort=` field. Send the same sort field. |
| 400 `Invalid join: 'a.b'` | Parent `a` not joined. Add `a` to `?join=` or mark it eager (v2.3+). |
| Startup: `@Crud: eager join 'a.b' on X needs 'a' to be eager too` | Mark `a` eager, or remove `eager` from `a.b`. |
| npm `ERESOLVE` on `ioredis` installing `@nestjs-crud/typeorm` | TypeORM 1.x peers `ioredis@^5`; NestJS 12 resolves 6. `npm install ioredis@^5` or add `"overrides": { "ioredis": "^5.0.4" }`. |
| MikroORM: stale entity / `em.flush()` doesn't persist | Subclass cached `em` in ctor. Use `getEm()` thunk; never store `em` as field. |
| Jest: `Cannot use 'import.meta' outside a module` | MikroORM 7 and NestJS 12 are pure ESM. Run Jest with `NODE_OPTIONS=--experimental-vm-modules` and a ts-jest ESM preset. |
| Prisma: `Unknown argument 'where'` on `include` | Prisma rejects `where` on to-one `include`. Filter at parent `where`. Adapter handles SCondition dotted paths automatically. |
| Outer tx + `@Override()` write: SERIALIZABLE persists, rollback cascades | Adapter inner tx is savepoint inside yours. Decide: remove the wrap or accept savepoint nesting. |
| Split-query: relation loads all columns despite `allow` | `setFindOptions` doesn't expose alias-level select. Stay on `'join'` for sensitive relations. |
Mirrored from the author's public source. Install counts from the open skills registry.