Type-safe, generated CRUD controllers for NestJS 12, with a stable adapter SPI for memory, TypeORM, Drizzle ORM, and Prisma.
Caution
This project remains prerelease software. The package API may change between alpha releases. The ORM packages certify PostgreSQL only in the first alpha.
| Package | Purpose |
|---|---|
@nestm/crud |
Resources, generated controllers, orchestration, query AST, scopes, hooks, Swagger, and adapter SPI |
@nestm/crud-memory |
Copy-on-write transactional adapter for tests and ephemeral services |
@nestm/crud-typeorm |
PostgreSQL adapter for TypeORM 1.1 |
@nestm/crud-drizzle |
PostgreSQL adapter for Drizzle ORM 0.45 |
@nestm/crud-prisma |
PostgreSQL adapter for Prisma 7.10 |
By default, the package generates ordinary Nest controllers during synchronous
CrudModule.forFeature() construction. Set generateControllers: false for a
headless feature that registers and exports its CRUD services without generated
routes. The package uses public dynamic-module and custom-provider APIs; it does
not install controllers after bootstrap and never registers guards, pipes,
interceptors, or filters globally.
pnpm add @nestm/crud@alpha @nestm/crud-memory@alpha
pnpm add @nestm/standard-schema@alpha @standard-schema/spec @nestjs/swaggerInstall and pin the stable NestJS 12 packages tested by your application. This
workspace currently tests 12.0.1. @nestm/standard-schema and @nestjs/swagger
are required peers, not optional integrations.
import { Module } from "@nestjs/common";
import { CrudModule, crudOperations, defineCrudResource } from "@nestm/crud";
import { bindMemoryCrud } from "@nestm/crud-memory";
import { StandardSchemaModule } from "@nestm/standard-schema";
import { z } from "zod";
const UserId = z.object({ id: z.coerce.number().int().positive() });
const CreateUser = z.object({
id: z.number().int().positive(),
name: z.string().trim().min(1),
});
const UpdateUser = z.object({ name: z.string().trim().min(1).optional() });
const UserResponse = z.object({
id: z.number().int().positive(),
name: z.string(),
});
export const users = defineCrudResource({
fields: ["id", "name"],
name: "users",
path: "users",
itemPath: ":id",
idFields: { id: "id" },
contracts: {
id: UserId,
create: CreateUser,
update: UpdateUser,
response: UserResponse,
},
operations: crudOperations.all(),
query: {
filters: {
id: { schema: z.coerce.number(), operators: ["eq", "in"] },
name: { schema: z.string(), operators: ["eq", "icontains"] },
},
sort: {
fields: ["id", "name"],
default: ["id"],
cursor: ["id", "name"],
},
search: { fields: ["name"] },
pagination: { offset: true, cursor: true },
},
});
const usersBinding = bindMemoryCrud({
resource: users,
unique: [["id"]],
mappings: {
create: (input) => input,
update: (input) => input,
response: (record) => record,
},
});
@Module({
imports: [
StandardSchemaModule.forRoot(),
CrudModule.forRootAsync({
useFactory: () => ({
// At least 32 bytes. Read this from validated application config.
cursor: { secret: process.env.CRUD_CURSOR_SECRET! },
}),
}),
CrudModule.forFeature({ resources: [usersBinding] }),
],
})
export class AppModule {}Applications must import StandardSchemaModule.forRoot() once. It installs the
Nest-native Standard Schema request validation and response serialization used
by generated controllers; bootstrap fails when CRUD resources are registered
without both integrations enabled. Do not register a second copy of Nest's
global Standard Schema pipe or serializer.
Set up SwaggerModule normally. Generated handlers carry tags, operation IDs,
response statuses, request/response schemas, path parameters, query parameters,
and Nest error responses. If the schema vendor needs a custom Nest Swagger
converter, compose it so the generated list wrapper is also converted:
import { SwaggerModule } from "@nestjs/swagger";
import { withCrudStandardSchemaConverter } from "@nestm/crud";
const document = SwaggerModule.createDocument(app, swaggerConfig, {
standardSchemaConverter: withCrudStandardSchemaConverter(vendorSchemaConverter),
});Schemas with native Standard JSON Schema output do not need a vendor converter.
Generated list operations with configured filters include versioned
x-nestm-crud-query metadata in addition to their normal OpenAPI query
parameters. Install the optional Swagger UI plugin to replace the flat filter
inputs with field/operator/value rows and + / − controls:
import { NestMCrudQuerySwaggerUiPlugin, NESTM_CRUD_SWAGGER_UI_CSS } from "@nestm/crud/swagger-ui";
SwaggerModule.setup("docs", app, document, {
swaggerOptions: {
plugins: [NestMCrudQuerySwaggerUiPlugin],
},
customCss: NESTM_CRUD_SWAGGER_UI_CSS,
});The builder supports only the filter operations already declared by the
resource. Rows are combined with AND; list and range values remain
comma-separated. It writes into the existing
filter[field][operator] parameters, so enabling the plugin does not change
the HTTP contract, parser, predicate model, or persistence adapters. Without the
plugin, Swagger UI continues to show the ordinary generated parameters.
| Operation | Route | Response |
|---|---|---|
| Create | POST /users |
201, response DTO |
| List | GET /users |
200, { data, meta } |
| Read | GET /users/:id |
200, response DTO |
| Update | PATCH /users/:id |
200, response DTO |
| Upsert | PUT /users/:id |
200, response DTO |
| Delete | DELETE /users/:id |
204, no body |
| Restore | POST /users/:id/restore |
200, response DTO |
Operations are always explicit: use crudOperations.all(),
crudOperations.readOnly(), or crudOperations.only(...). Omitting operations
is a resource-definition error.
List queries use these forms:
filter[name][icontains]=ada
filter[id][in]=1,2,3
sort=-createdAt,name
search=engineer
include=posts,profile
deleted=include
page=2&limit=20
after=<signed-cursor>&limit=20
Filters, sort fields, search fields, includes, and deleted-row access are all
allowlisted. Cursor tokens are versioned and HMAC-SHA-256 signed, bind the
resource and exact ordering, and include every ID field as a stable tie-breaker.
Cursor sort fields must be non-nullable. A cursor-enabled resource fails at
bootstrap unless the root module has a secret of at least 32 bytes; load it from
validated application configuration through forRootAsync(). The deliberately
insecure codec is exported only from @nestm/crud/testing.
Pagination mode defaults are deliberate:
- no pagination configuration means offset mode;
{ cursor: true }means cursor-only mode;- set
{ offset: true, cursor: true }to accept both; and - set
{ offset: true, cursor: false }for explicit offset-only mode.
Offset responses include total counts and page metadata. Cursor responses use
keyset pagination and contain only limit, nextCursor, and hasNextPage.
The ID contract must output one required property for every itemPath parameter
and idFields key. Composite IDs work throughout reads, mutations, relations,
and cursor tie-breaking:
const tenantUsers = defineCrudResource({
fields: ["tenantId", "id"],
name: "tenant-users",
path: "tenant-users",
itemPath: ":tenantId/:id",
idFields: { tenantId: "tenantId", id: "id" },
contracts: {
id: z.object({ tenantId: z.string().uuid(), id: z.coerce.number().int().positive() }),
create: CreateTenantUser,
update: UpdateTenantUser,
response: TenantUserResponse,
},
operations: crudOperations.all(),
});The resource's fields tuple is the authoritative logical vocabulary. ORM
adapter column maps can translate those logical names to physical database columns.
Collection paths may own part of a resource's identity. Declare those route parameters separately for collection validation, while the ordinary ID contract continues to describe the complete item route:
const versions = defineCrudResource({
fields: ["artifactId", "versionId"],
name: "artifact-versions",
path: "artifacts/:artifactId/versions",
pathParams: {
contract: z.object({ artifactId: z.string().uuid() }),
fields: { artifactId: "artifactId" },
},
itemPath: ":versionId",
idFields: { artifactId: "artifactId", versionId: "versionId" },
contracts: {
id: z.object({ artifactId: z.string().uuid(), versionId: z.string().uuid() }),
create: CreateVersion,
update: UpdateVersion,
response: VersionResponse,
},
operations: crudOperations.all(),
});Nested list/count predicates always include the mapped parent values. Nested
creates treat those values as framework-owned insert values and therefore
require mappings.scopeCreate plus scopeCreateFields; request-body mappings
cannot override them. Scopes, hooks, adapter transaction runners, row
predicates, and after-commit events receive context.pathParams. Direct
headless calls use crud.list(query, pathParams, context?) and
crud.create(input, pathParams, context?); item calls still receive the full
ID object.
Cursor tokens for a nested collection are bound to its parent values, so a valid cursor issued under one parent is rejected under another. A nested resource may be a relation source, but cannot be a relation target in this release because a batched relation query has no single parent-path context.
For a fully custom controller, make the feature headless and inject the same orchestrator into the application controller:
import { Controller, Get, Module, Query, type ExecutionContext } from "@nestjs/common";
import {
createCrudPageSchema,
CrudContext,
CrudModule,
type CrudRawQuery,
CrudService,
InjectCrud,
resolveCrudPaginationModes,
} from "@nestm/crud";
import { StandardSchemaResponse } from "@nestm/standard-schema";
const UsersPage = createCrudPageSchema(
users.contracts.response,
resolveCrudPaginationModes(users.query?.pagination),
);
@Controller("admin/users")
export class AdminUsersController {
constructor(@InjectCrud(users) private readonly crud: CrudService<typeof users>) {}
@Get()
@StandardSchemaResponse(UsersPage)
list(@Query() query: CrudRawQuery, @CrudContext() context: ExecutionContext) {
return this.crud.list(query, context);
}
}
@Module({
imports: [
CrudModule.forFeature({
resources: [usersBinding],
generateControllers: false,
}),
],
controllers: [AdminUsersController],
})
export class AdminUsersModule {}generateControllers defaults to true. Setting it to false suppresses
controller generation for every resource in that forFeature() call, while
keeping binding and adapter providers, registry entries, service exports, and
resource imports available. This is intended for fully custom compatibility
controllers. To replace only selected operations, leave generation enabled,
omit those operations from the resource, and add custom routes alongside the
generated controller. operations controls generated route exposure; it is not
a runtime authorization boundary on direct CrudService calls. A custom route
must still declare an operation when its adapter capability or binding
configuration (for example atomic upsert) is validated from that declaration.
CrudService is where scopes, hooks, transactions, soft deletion, error
sanitization, and response mapping run, so custom controllers reuse the same
security-sensitive path. Generated-controller serialization metadata is not
inherited by a custom controller: apply StandardSchemaResponse (or Nest's
equivalent @SerializeOptions({ schema })) to every custom response. Likewise,
custom request parameters need explicit Standard Schema metadata when they are
not plain @Query() objects. Custom controllers also own their route, status,
authorization, validation, serialization, and OpenAPI decorators.
Configure a logical deletion field and explicitly add restore; it is not part
of crudOperations.all() unless supplied as an override:
operations: crudOperations.all({
restore: { guards: [RestoreGuard] },
}),
softDelete: {
field: "deletedAt",
allowQueryDeleted: true,
deleteValue: () => new Date(),
restoreValue: () => null,
queryDeletedEnhancers: { guards: [DeletedRowsGuard] },
},Normal reads exclude deleted records. deleted=include|only is accepted only
when allowQueryDeleted is true. queryDeletedEnhancers decorate the generated
list route, so guards must inspect the request and require elevated access only
when deleted is include or only; ordinary list requests must still pass.
Soft-delete and explicit scope updateValues flow through
mappings.persistence, allowing logical fields to map safely to different
persistence keys.
Resource scopes are ordered injectable providers. Their predicates apply to
list/count/read/update/upsert/delete/restore and relation queries. Scope createValues
overwrite client values only while inserting, which supports tenant and owner
isolation without making immutable ownership updateable. A scope that
intentionally owns an update field must return it through distinct
updateValues.
Mutation hooks run in this order:
- Resolve scopes and transactionally capture prior state where applicable.
- Run
before*hooks. - Perform the mutation.
- Run
after*hooks in the same transaction. - Commit.
- Run
afterCommit; report delivery failures to the configured sink without representing the committed mutation as rolled back.
Register scope and hook provider tokens on the resource and make those providers
available through the feature module's imports. A hook failure before commit
rolls back the mutation; an afterCommit failure is sent to
afterCommitErrorHandler after the committed response has been determined.
An adapter transaction must resolve only after the real commit it owns; a
savepoint or joined ambient transaction cannot satisfy this mutation contract.
Upsert is an explicit opt-in operation; crudOperations.all() does not enable
it. Add an upsert request contract and select the operation to generate
PUT itemPath, or keep the resource headless and call CrudService.upsert()
from a compatibility controller:
const viewerBindings = defineCrudResource({
// ...path, complete ID, and ordinary contracts
fields: ["artifactId", "viewerUserId", "mcpServerId", "toolPrefix", "allowedTools"],
contracts: { id, create, update, upsert: UpsertViewerBinding, response },
operations: crudOperations.only("upsert", "delete"),
});
const binding = bindTypeOrmCrud({
resource: viewerBindings,
adapter,
scopeCreateFields: ["viewerUserId"],
upsert: {
conflictFields: ["artifactId", "viewerUserId", "mcpServerId"],
overwriteFields: ["toolPrefix", "allowedTools"],
},
mappings: {
upsert: (id, input) => ({ ...id, ...input }),
scopeCreate: (values) => ({ viewerUserId: values.viewerUserId }),
// ...ordinary mappings
},
});The mapper produces one proposed final insert row; scope-owned values are
merged last. conflictFields and overwriteFields are adapter persistence
paths, not public field names. The conflict target may include scope-owned
identity columns that are absent from the URL, while overwrite fields must be
disjoint from both the conflict identity and scopeCreateFields.
An adapter advertising the optional atomic-upsert capability must perform one
race-free statement, apply the normal resource and scope predicate inside the
conflict-update arm, return null for a hidden conflict without changing it,
and return the resulting record without a reload. Upsert has dedicated
beforeUpsert/afterUpsert/afterCommit lifecycle events and deliberately has
no pre-read, prior record, or created-versus-updated branch signal. The
TypeORM PostgreSQL adapter is certified for this contract; the other bundled
adapters currently reject resources that enable upsert.
Some response fields are not columns. An adapter selects from one table with no
join and no groupBy, so an aggregate — artifactCount on a project,
memberCount on an organization — is invisible to it by design.
A projection resolves those fields for a whole page at once:
@Injectable()
export class ProjectArtifactCounts implements CrudProjection {
constructor(private readonly projects: ProjectsRepository) {}
async project(records: readonly ProjectRow[]) {
const counts = await this.projects.countArtifactsByProject(records.map((r) => r.id));
return records.map((record) => ({ artifactCount: counts.get(record.id) ?? 0 }));
}
}
defineCrudResource({
// …
fields: ["id"],
projections: [ProjectArtifactCounts],
});project receives the entire page and returns one entry per record, aligned by
index; returning a different number of entries is an error rather than a silent
truncation. Several projections merge in declaration order, so a later one wins
a key collision. The merged result reaches the binding as the optional third
argument of mappings.response(record, relations, projected?) — undefined
when the resource declares none.
Batching is the point. A per-record resolver would issue one aggregate query per
row, so projections run once per page on list, once per relation target set
when an include is expanded, and once for the single record on read and on the
create/update/restore responses. Mutation responses are projected too, so one
response schema does not yield two shapes depending on the verb.
Register projection tokens on the resource and make the providers available through the feature module's imports, exactly as for scopes and hooks.
Generated routes can also carry integration-specific Nest metadata without a CRUD dependency on that integration:
enhancers: { decorators: [ApiTenant()] },
operations: crudOperations.all({
list: { decorators: [RequirePermission("document:list")] },
read: { decorators: [RequirePermission("document:read")] },
}),Resource decorators are applied to the generated controller. Operation and
queryDeletedEnhancers decorators are applied to their generated handlers.
Decorator arrays are copied and frozen when the resource is defined.
belongsTo, hasOne, and hasMany relations are one-hop, opt-in includes:
relations: {
posts: defineCrudRelation({
type: "hasMany",
target: () => posts,
local: ["id"],
foreign: ["authorId"],
maxItems: 50,
}),
},Register both resource bindings. include=posts batches the target query,
supports composite join tuples, and always applies the target resource's scopes
and soft-delete policy. To-many includes fetch one row beyond maxItems (or the
root maxRelatedRows) and return 422 if the bound is exceeded; data is never
silently truncated. Nested relation traversal is deferred.
Generated routes preserve Nest's normal exception body shape. Invalid request,
query, cursor, or database-constraint input returns 400; missing and
scope-hidden records return 404; unique conflicts return 409; over-bound
relations return 422. Unexpected adapter and transactional-hook failures
return a sanitized 500; raw ORM errors are never sent to the client.
Mapped conflict and constraint exceptions retain the originating
CrudAdapterError as their internal cause, allowing custom facades and logs
to distinguish retryable conflicts without exposing persistence details on the
wire.
Import the stable SPI from @nestm/crud/adapter. An adapter compiles the neutral
predicate/order AST into parameterized database APIs and implements transaction,
create, find-one, find-page/count, update, and delete. Bindings must map API
create/update values to persistence fields and persistence records to response
DTO inputs; ORM entities never escape automatically.
CrudAdapterError.retryable distinguishes conflicts for which the complete
operation may be retried in a fresh transaction. CRUD never retries operations
itself because doing so could repeat lifecycle-hook side effects. Use
isCrudAdapterError() instead of instanceof at package boundaries so errors
remain recognizable when a package manager installs more than one CRUD copy.
Application binders are bindMemoryCrud, bindTypeOrmCrud,
bindDrizzleCrud, and bindPrismaCrud. Each accepts standard Nest useValue,
useClass, useExisting, or useFactory adapter providers. The SQL binders
never create, initialize, connect, disconnect, or destroy consumer-owned
repositories and clients. In the first alpha, TypeORM 1.1.x, Drizzle 0.45.x,
and Prisma 7.10.x are certified against PostgreSQL only.
The TypeORM adapter also accepts a native select object for resources backed
by entities with sensitive or operational columns. Selected mode narrows both
the database hydration and the binding's record type, and its direct DML path
uses only an explicit RETURNING allowlist so TypeORM cannot perform a hidden
full-row reload during mutations. All primary columns must be selected. Entity
constructors and load listeners can still add runtime properties; see the
package README for the query-builder lifecycle tradeoffs.
Every binding supplies create and update mappings for API values and a
response mapping from a record plus loaded relations into the response-schema
input. Optional Standard Schema properties may be returned as explicit
undefined; CRUD removes those properties before passing mapped values to an
adapter. Define persistence when scopes or soft deletion generate logical
update values. It may be omitted when those values are always empty; a non-empty
unmapped value fails closed instead of being silently discarded. Soft-delete
bindings require the mapping during service construction. Scoped bindings
should additionally define scopeCreate to map their logical create values to
adapter insert fields.
When a scope owns required insert columns such as tenantId or ownerId, set
scopeCreateFields: ["tenantId", "ownerId"]. Only those declared fields become
optional in the contextual return type of mappings.create; an unscoped binding
still has to return its complete adapter create model. On create, the declared
fields must be materialized by mappings.scopeCreate, and those scope-derived
values overwrite any values returned by the API mapper.
For compatibility, a scoped binding without scopeCreate falls back to
persistence. That legacy path cannot model immutable insert-only fields and is
deprecated for removal in the next major version.
Reusable, runner-neutral conformance cases are available from
@nestm/crud/testing as createCrudAdapterConformanceCases and
runCrudAdapterConformance. That test-only subpath also exports
InsecureCrudCursorCodec; never use it in an application.
The first alpha includes base CRUD, structured filters, search, offset and cursor pagination, composite IDs, soft delete/restore, one-hop bounded relation includes, scopes, transactional lifecycle hooks, Standard Schema validation, Swagger metadata, and four adapters. The broader hono-crud feature surface is a staged roadmap, not an alpha release gate.
Batch operations, bulk patch, optimistic concurrency, aggregates, full-text search, sparse fieldsets, import/export, computed fields, audit history, record versioning, GraphQL, microservices, and schematics are deferred. Cache, rate limiting, idempotency, logging/events/webhooks, observability, encryption, approvals, health checks, and MCP remain separate Nest-native integrations or future add-ons.
pnpm install
pnpm run check
pnpm run test
pnpm run verify:packThe repository is BSD-3-Clause licensed.