Skip to content

Configuration

Nest + TypeORM, Nest + Prisma, Nest + Mongoose, and Nest + MikroORM cover the zero-config path. This page is the field-by-field reference for everything you can configure once zero-config isn't enough: every @Kavo(Entity, config) parameter, and every global setting your KavoModule can set.

How config layers

Settings resolve through one precedence chain, each scope overriding the one before it:

built-in defaults → global (KavoModule) → entity (@Kavo config) → operation (operations.<id>) → per-call

A field you don't set at a given scope just falls through to the next one down. The full merge semantics (deep-merge rules, what "unset" means per field) are in Configuration — this page only documents what each field means and where you can set it.

Global config (KavoModule.forRoot / forRootAsync)

ts
KavoModule.forRootAsync({
  useFactory: () => ({
    infrastructure: createInfrastructure(dataSource),
    defaults: {/* KavoSettings, see below */},
    paginationStrategies: [],
  }),
  provideServices: true,
  graphql: true,
});
FieldTypeWhat it does
infrastructureKavoInfrastructureWhere entity metadata and the repository adapter come from — createInfrastructure(dataSource) or createInfrastructure(client, opts). Required for any @Kavo route to actually run.
defaultsDeepPartial<KavoSettings>App-wide settings, one level below the built-in defaults and above every entity's own config. See Settings fields below for what's in KavoSettings.
paginationStrategiesreadonly PaginationStrategy[]Registers custom pagination strategies beyond the built-in "offset", so pagination.strategy can name one of these instead.
useFactory(...args) => KavoModuleOptions(forRootAsync only) Builds the options object, e.g. after awaiting dataSource.initialize().
injectreadonly (string | symbol | Type)[](forRootAsync only) DI tokens injected as useFactory's arguments.
provideServicesbooleanAlso provides getKavoServiceToken(Entity) as a real DI provider for every @Kavo-decorated class the process has seen — needed only if some other class constructor-injects a @Kavo entity's service directly.
graphqlboolean | { path?: string }Mounts a default GraphQL controller merging every entity that called registerKavoGraphQLTypes onto one schema. true mounts at POST /graphql; { path } mounts elsewhere. Implies provideServices.
mcpboolean | { path?: string }Mounts a default MCP controller (Streamable HTTP, stateless) exposing every @Kavo entity's full standard toolset — no per-entity opt-in. true mounts at POST /mcp; { path } mounts elsewhere. Implies provideServices. Requires @modelcontextprotocol/sdk installed. Carries no auth guard of its own — a guard on an entity's REST controller does not extend to this route; write your own controller extending BaseKavoMcpController instead if the MCP surface needs auth.

Settings fields (KavoSettings)

This is the shape of defaults above, and also of every entity-scope, operation-scope, and per-call override — the same schema at every scope, just merged in order.

pagination

FieldTypeDefaultWhat it does
defaultLimitnumber20Page size when a request supplies no limit.
maxLimitnumber100Hard ceiling on limit — a request asking for more is clamped, not rejected.
strategy"offset" | "page" | "cursor" | "since" | a registered name"offset"Which pagination strategy computes the page. offset is flat limit/offset; page is page[number]/page[size]; cursor is keyset paging over an opaque ?cursor= token, which requires the effective sort to end in the entity's id field — with every sort key on allowlists.filterable and allowlists.selectable as well as sortable — and reports the next token as meta.nextCursor; since is polling by a plain, compound ?since=<value>|<id> token against since.field, with the sort forced to [since.field, id], exactly-once delivery (the id half breaks ties on since.field), and the next token reported as meta.nextSince. Pair either keyset strategy with count: false, and index the sort tuple; both are refused by the GraphQL and MCP bindings, which cannot page a keyset (see Cursor pagination, Since pagination, ADR-0021, and ADR-0022). See paginationStrategies above for adding your own.
since.fieldstring"updatedAt"Only consulted under strategy: "since": the column ?since= seeks against. Must be a date- or string-kind column on allowlists.filterable and allowlists.selectable, checked at startup — a missing or wrong-kind column fails immediately rather than on the first request.
countbooleantrueWhether list responses compute total (an extra COUNT query per list call). Set it to false alongside strategy: "cursor"/"since": the COUNT is O(n) over the whole match set and dominates the O(limit) keyset page it accompanies.

query

FieldTypeDefaultWhat it does
maxFilterDepthnumber3Max nesting depth of the filter AST (and/or groups nested inside each other).
maxInValuesnumber100Max array length for in, notIn, and between filter operators.
defaultSortreadonly Sort[][]Sort order applied when a request supplies no sort of its own. A client-supplied sort always wins outright — it never merges with this. Validated against the sortable allowlist, same as client-supplied sort.

errors

FieldTypeDefaultWhat it does
exposeInternalsbooleanfalseWhether driver-level error details (raw SQL error messages, stack info) leak into problem-details responses. Keep false in production.

relations

FieldTypeDefaultWhat it does
maxIncludeDepthnumber2Max nesting depth for include= chains (include=owner.tags is depth 2).
maxIncludedNodesnumber10Max total number of included relation nodes per request, across every branch of the include tree.
edgesReadonly<Record<string, RelationEdgeSettings>>{}Per-relation permissions, keyed by relation property name — see relations.edges below. Inclusion is opt-in: a relation absent here cannot be included at all.

relations.edges.<name> (RelationEdgeSettings):

FieldTypeDefaultWhat it does
includablebooleanfalseWhether clients may include= this relation at all.
defaultIncludebooleanfalseInclude this relation even when the client doesn't ask for it.
maxDepthnumber(inherits relations.maxIncludeDepth)Overrides the include-depth limit for the subtree below this relation only.
strategy"join" | "batch" | "auto""auto"How the relation loads: join (single query, correct for to-one), batch (per-level WHERE parentId IN (...), correct for to-many), or auto (picks per cardinality).

caching

FieldTypeDefaultWhat it does
etagbooleantrueWhether single-item responses carry an ETag, and whether If-None-Match (→ 304) and If-Match (→ 412) are honored. One key, both halves.

false at any scope turns both halves off together — no tag is computed and If-None-Match is ignored. If-Match is the exception: it is refused with 412 KAVO_PRECONDITION_UNSUPPORTED, not ignored. Answering 2xx would tell a client its write was guarded when nothing checked it, and the per-operation scope makes that easy to arrive at by accident (operations: { findOne: { caching: { etag: true } }, updateOne: { caching: { etag: false } } } would serve tags on GET and drop the header on PUT).

See ETags and conditional requests for the wire behavior, including the explicit limits: the If-Match check is check-then-write rather than an atomic compare-and-swap, and a token has to come from an unnarrowed read.

Redaction belongs in the DTO, not in an interceptor. Kavo's KavoResponseInterceptor is method-scoped and therefore innermost: it sets the ETag before any controller- or app-level interceptor runs. An outer interceptor that strips fields per role would ship a hash of the unredacted representation next to a redacted body — and a client's If-Match built from it would never match. Shape the response with a per-operation item DTO, which the engine serializes through before hashing.

An @Override'd method enforces If-Match only if it forwards it. The check lives in the engine, so a method you wrote in place of a generated one bypasses it. @Kavo still hands the tokens to the method as its last parameter; pass them on with { preconditions } on the typed service, or return service.engine.execute({ …, preconditions }) to also get the ETag header back.

softDelete

FieldTypeDefaultWhat it does
fieldstring"deletedAt"Name of the delete-marker column.
strategy"auto" | "soft" | "hard""auto"auto resolves per entity (soft if the marker field exists, hard otherwise); soft/hard state it outright. false for the whole softDelete key (instead of an object) disables soft delete entirely, even if a marker field exists.

See Getting started's soft delete section for the practical walkthrough, and Soft delete, restore & purge for the full behavior.

operations (global scope only)

At global scope, operations is a flat map of booleans, keyed by standard operation id — coarser than the richer per-entity form below:

ts
defaults: {
  operations: { restoreOne: false },
}
Operation idEnabled by default
createOneYes
findOneYes
findManyYes
updateOneYes
patchOneYes
deleteOneYes
restoreOneNo, unless soft delete is declared on the entity (ADR-0013)
purgeOneNo, until named explicitly

An entity's own operations.<id> (below) always wins over this global map.

@Kavo(Entity, config) — entity-scope config

Every field above (pagination, query, errors, relations, softDelete) can also be set here, one level above global. In addition, @Kavo's config carries four fields that only make sense per entity:

dto

Registers DTO classes per slot — every slot is independently optional and falls back to an entity-derived default when omitted:

ts
@Kavo(Book, {
  dto: {
    create: CreateBookDto,
    update: UpdateBookDto,
    item: BookItemDto,
    list: BookListDto,
  },
})
SlotDefault when omitted
createEntity's own shape, minus generated/relation fields
updateSame default as create
patchPartial<update> if set, else Partial<Entity>
queryGeneric QueryContext<Entity>
itemEntity, subject to field selection
listSame as item's resolved type

There's no patch DTO class to write on its own — it derives from update. See DTO system for full derivation rules.

allowlists

What a request may filter, sort, and select on — including relation paths. Anything outside an allowlist is rejected with a 400, never silently dropped:

ts
@Kavo(Book, {
  allowlists: {
    filterable: ["id", "title", "author"],
    sortable: ["id", "title"],
    selectable: ["id", "title", "author"],
  },
})
FieldTypeWhat it does
filterablereadonly FieldPath[] | { exclude: readonly FieldPath[] }Fields usable in filter[...].
sortablesame shapeFields usable in sort=.
selectablesame shapeFields usable in fields=.

{ exclude: [...] } means "every own column (plus, for selectable, every selectable computed field) except these", resolved at bootstrap against exactly the base set that key's plain default uses. Omit a key entirely and it derives from the query DTO or entity metadata instead.

computed

Response fields with no backing column, derived from an entity that has already been fetched:

ts
@Kavo(Book, {
  computed: {
    displayTitle: { resolve: (book) => (book.title === null ? null : `${book.title} (${book.year})`) },
    canEdit: { resolve: (book, context) => book.ownerId === (context.principal as User)?.id },
  },
})
FieldTypeWhat it does
resolve(entity, context: KavoContext) => unknownDerives the value. Called once per served item, synchronously — see the caveats below.
selectableboolean (default true)Whether fields= may name the field. false makes naming it a 400 — read the note below carefully.

A declared computed field is in the default item/list projection with no DTO registration, and in the selectable allowlist by default. It is never filterable, sortable, or writable — naming one in allowlists.filterable/sortable is both a type error and a bootstrap ConfigurationException, and so is naming one in a registered create/update/patch DTO. (A raw body key is still just dropped, like any other unknown key; the DTO case is a declaration, and every other computed misdeclaration fails at bootstrap too. It also has a wire consequence a silent drop cannot reach: @ApiBody is built from the DTO's runtime shape, so OpenAPI would advertise a property the engine unconditionally discards.)

resolve returning undefined omits the key; null emits it — the same distinction a column draws.

resolve must be total, not merely pure. It runs once per served item and nothing catches it, so one row whose resolver throws turns the whole collection endpoint into a 500 — not for that row, for every caller, until the row is fixed. Write it against everything the column can actually hold, including null: resolve: (todo) => todo.title?.toLowerCase() ?? null, never todo.title.toLowerCase() on a nullable column. A POST that sets title: null succeeds (computed fields are stripped from the payload; title is an ordinary column), and GET /todos is dead from then on. A throwing resolver surfaces as a 500 KAVO_PERSISTENCE_FAILED with the cause attached and the message not leaked.

Keep it a pure function of the entity as well (plus context.principal where a field has to vary by caller). It runs per row, so a resolver that queries the database or calls out over the network reintroduces exactly the N+1 that batched includes exist to avoid. Declaring it async is a bootstrap error rather than a slow success: the serializer never awaits, so the promise would be emitted as-is and serialize to {}.

resolve receives the full fetched row, not the projected object — selection is "kept internally, stripped late", so every column is present regardless of fields= or the registered item DTO. A computed field can therefore surface a value a narrowed DTO or selectable list deliberately hides. That is deliberate (resolve is server-authored code, the same trust level as exposeInternals), but it makes the resolver part of the exposure decision: narrowing the DTO does not narrow what the resolver can see.

What selectable: false does and does not mean. It removes the name from the allowlist, so ?fields=auditNote is a 400. It does not pin the field into every response: selection narrows the projection uniformly, so any request that sends fields= at all still drops it, and the client has no way to ask for it back. Read it as "not individually selectable", not "always present". An explicit allowlists.selectable list naming the field overrides the flag — an explicit list is always the deliberate answer.

On an included relation target, resolve receives the root request's KavoContext — serving GET /posts/1?include=author hands an Author computed field a context whose entityName, operation, config and query describe Post. Only principal, correlationId, transaction and state mean what they say from a relation target.

The generated OpenAPI response schema does not mention a computed field when no item/list DTO is registered: the schema falls back to the entity class, whose columns are all the reflection can see, while the runtime response carries the computed key. Registering an item/list DTO naming the field fixes the document and the static response type in one move — the same escape hatch, for both consequences.

Let the computed-key type parameter be inferred at the call site: pass the config inline to @Kavo(...) (or use satisfies), and pin neither an EntityConfig<Book> annotation on the config nor explicit type arguments on the call (@Kavo<Todo, CreateTodoDto>(...), createCrud<Book, CreateBookDto>(...) — the likelier spelling once an entity has custom DTOs). Either fixes Computed to never, which erases computed's value types and leaves resolve's parameter implicitly any.

See ADR-0019 for the reasoning and DTO system §7 for how it interacts with DTO narrowing and field selection.

operations

Per-operation overrides, keyed by standard operation id. Each entry is either a boolean shorthand or a full OperationConfig object:

ts
@Kavo(Book, {
  operations: {
    patchOne: false, // shorthand: disable outright
    restoreOne: { enabled: true, meta: { routes: { path: ":id/undelete" } } },
  },
})
FieldTypeWhat it does
enabledbooleanTurns the operation on or off explicitly — the long form of the true/false shorthand, for when the entry also carries settings or meta.
handlerOperationHandler<Entity>Replacement handler function, keeping the default DTO/serialization scaffolding around it.
metaOperationMetadataOpaque bag consumed by the framework layer — in @kavo/nest, this is { routes: KavoRouteOptions }.
dto{ input?, output?, query? }Overrides the entity's root dto slot for this operation only — see below.
(any settings key)same shape as global KavoSettingsOverrides that apply to this operation only, one level above the entity's own settings.

operations.<id>.dto narrows one operation's request body, response, or query contract independently of the entity's root dto slots (§dto above). Only the fields a given operation actually has are accepted — input/output on a write, output/query on a read, neither on deleteOne/purgeOne (void results):

ts
@Kavo(Book, {
  dto: { item: BookItemDto }, // entity-wide default for every read
  operations: {
    findOne: { dto: { output: BookDetailDto } }, // findOne only
    createOne: { dto: { input: CreateBookRequestDto, output: BookCreatedDto } },
  },
})

Fallback order per field: operations.<id>.dto.<field> → the entity's root dto.<slot> → the entity-derived default. Setting a field an operation doesn't have (dto.query on createOne, say) is both a type error and a bootstrap ConfigurationException. See DTO system §8 for the full applicability table and the fallback chain in the engine.

operations.<id>.meta.routes (@kavo/nest's KavoRouteOptions):

FieldTypeDefaultWhat it does
method"GET" | "POST" | "PUT" | "PATCH" | "DELETE"the operation's standard verbOverrides which HTTP verb the generated route uses.
pathstringthe operation's standard pathRoute path relative to the controller (e.g. ":id/activate").
enabledbooleantruefalse makes the operation service-only: still callable through service.engine.execute(...), but no route is generated.
successStatusnumber201 create, 204 delete, 200 otherwiseOverrides the response status code on success.

See NestJS integration for how route generation reads this, and Registry-driven operations for why routes always come from the same registry the engine uses.

Custom list metadata

The list envelope's meta bag (ListResultDto.meta) is the place for anything about the list that isn't a row — facet counts, a freshness stamp, a cursor — and it does not need a DTO or a config key: whatever the findMany handler returns as meta is what the client receives.

It is the envelope's one optional field. Until a handler fills it the key is absent from the response, not {}, so the common zero-config list doesn't carry an empty bag on every request; a contributor that returns {} leaves it absent too. Type it and read it accordingly — body.meta?.inStock.

ListResultDto.meta on the response and operations.<id>.meta above are unrelated. The first is an open bag on the list envelope; the second is OperationMetadata — route options the framework layer reads, which never reach a response body.

withListMeta wraps an existing handler so a contributor function's keys land on the bag, which saves rewriting the built-in findMany just to add one number:

ts
// data-source.ts — the same infrastructure app.module.ts hands KavoModule
export const infrastructure = createInfrastructure(dataSource);
ts
// book.controller.ts
import { builtInHandlers, withListMeta } from "@kavo/core";
import { infrastructure } from "./data-source.js";

const findMany = builtInHandlers(infrastructure.adapterFor(Book))("findMany");

@Kavo(Book, {
  operations: {
    findMany: {
      handler: withListMeta(findMany, (result) => ({
        inStock: result.entities.filter((book) => book.stock > 0).length,
        countedAt: new Date().toISOString(),
      })),
    },
  },
})
@Controller("books")
export class BookController {}
json
{ "items": [...], "limit": 20, "offset": 0, "total": 2, "meta": { "inStock": 1, "countedAt": "2026-01-01T00:00:00.000Z" } }

The adapter has to exist when the class is declared, because @Kavo's config object is evaluated at decoration time (ADR-0012) — the module-scope DataSource the wiring guide already builds is exactly that. If yours is created by a DI factory (KavoModule.forRootAsync) instead, it isn't available yet: contribute from a handler that doesn't need the adapter, or configure the entity through createCrud where the infrastructure is already resolved.

PointBehavior
Contributor inputThe wrapped handler's whole result (entities, total, and any meta it already set) plus the request KavoContext. It may be async.
Merge precedenceThe contributor's keys win. The inner handler's meta is the base and the contributor merges over it, so the outermost wrap owns any key it names; keys it doesn't name pass through.
Overriding thatThe inner bag is in hand — return { ...mine, ...result.meta } to let the inner handler win instead.
SerializationNone. meta is your data, not entity data: no DTO projection, no fields= selection, no renaming. It must be JSON-serializable.
Nothing contributedThe key is left off the response entirely. Judged on the merged bag, so {} from a contributor is the same as no contributor at all.
Wrong-shaped handlerWrapping a handler that doesn't return { entities, total } raises ConfigurationException (KAVO_CONFIG_INVALID) naming the operation, rather than serving a malformed envelope.

The wrapper is a convenience, not a requirement — the engine reads meta off whatever the findMany handler returns, so a hand-written one works the same way:

ts
import type { FindManyResult, KavoContext } from "@kavo/core";

const handler = {
  async execute(_input: null, context: KavoContext<Book>) {
    // `builtInHandlers(...)` hands back `OperationHandler<Book>`, whose
    // output type is `unknown` — the same erasure `withListMeta` works
    // around with its runtime shape check. Hand-rolling the wrap means
    // narrowing it yourself.
    const inner = (await findMany.execute(null, context)) as FindManyResult<Book>;
    return { ...inner, meta: { inStock: 1 } };
  },
};

Transport support. meta rides the same envelope everywhere, so it reaches REST responses and MCP tool results unchanged. It is not exposed by the GraphQL binding — that binding's generated list type declares items/total/limit/offset only, so a GraphQL client cannot select meta today.