npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@void-snippets/nestjs

v0.3.2

Published

Typed NestJS REST resources from one config object — defineResource() gives you CRUD, filters, sort, search, offset+cursor pagination, soft delete, tenant scoping, route toggles and per-route guards over TypeORM or Mongoose

Readme

@void-snippets/nestjs

Describe a REST resource once, in one typed config object, and get a full NestJS controller + service + repository — CRUD, pagination, filtering, sorting, search, soft delete, tenant scoping — without writing the boilerplate by hand.

Works over TypeORM or Mongoose. You never import either one into this package; you point it at the repository/model you already have.

pnpm add @void-snippets/nestjs @void-snippets/core
# plus whichever ORM you use:
pnpm add @nestjs/typeorm typeorm            # SQL
# or
pnpm add @nestjs/mongoose mongoose          # MongoDB
# only if you validate request bodies with DTOs:
pnpm add class-validator class-transformer

Table of contents

  1. The 60-second version
  2. What you get
  3. Mental model — three levels of control
  4. Level 1 — zero classes with forFeature
  5. Level 2 — configure the resource
  6. Level 3 — add your own logic
  7. Validating request bodies (DTOs)
  8. Filtering, sorting, search, pagination — the query API
  9. Turning routes on and off
  10. Per-route guards
  11. App-wide defaults with forRoot
  12. The response envelope
  13. Entity / document types
  14. Mongoose setup
  15. Full API reference
  16. Upgrading from 0.1

The 60-second version

You have an entity. You want REST endpoints for it. Here's the whole thing:

// contact.entity.ts
import { Entity, Column } from "typeorm";
import { VSBaseEntity } from "@void-snippets/nestjs/typeorm"; // gives id + timestamps

@Entity()
export class Contact extends VSBaseEntity {
  @Column() name!: string;
  @Column() email!: string;
}
// contacts.module.ts
import { Module } from "@nestjs/common";
import { forTypeOrmResource } from "@void-snippets/nestjs/typeorm";
import { Contact } from "./contact.entity";

@Module({
  imports: [forTypeOrmResource(Contact)], // name "contacts" and id "id" are derived
})
export class ContactsModule {}

That's it. You now have these live endpoints:

| Method & path | What it does | | ------------------------- | ------------------------------------------ | | GET /contacts | Paginated list | | GET /contacts/count | Count matching rows | | GET /contacts/:id | One item | | POST /contacts | Create | | POST /contacts/bulk | Create many | | PATCH /contacts/:id | Update | | PATCH /contacts/:id/restore | Restore a soft-deleted row (if enabled) | | DELETE /contacts/:id | Delete | | DELETE /contacts/bulk | Delete many (body: { "ids": [...] }) |

No ContactsController, no ContactsService, no ContactsRepository classes to write.

Every defineResource option (filters, hooks, scoping, route toggles…) can be passed inline as the second argument:

forTypeOrmResource(Contact, {
  softDelete: "deletedAt",
  query: { search: ["name", "email"] },
  routes: { remove: false },
});

What you get

  • One config object (defineResource) is the single source of truth.
  • Type safety end to end, with zero manual generics — pass entity and DTO classes and every type is inferred: the id's value type, hook parameters, filter/sort/search field names. A typo is a compile error, not a silently-ignored param.
  • Offset and cursor pagination, switchable per resource.
  • Filtering DSL with a per-field operator allowlist (safe by default).
  • Soft delete + restore, optimistic locking, sparse fieldsets (?fields=).
  • First-class tenant scopingscope: { field: "orgId", value: (ctx) => ctx.orgId } fences every operation, failing closed by default.
  • Twelve lifecycle hooks — from beforeCreate to beforeList/afterList, afterGet, and a serialize response shaper — inline or as subclass methods.
  • Route toggles + per-route customization — read-only resources, custom paths, HTTP status codes, pipes, per-route DTOs, and arbitrary method decorators (Swagger, throttling…) without overriding anything.
  • Guards at both levels — on the whole controller or per route, typed.
  • A pluggable response envelope (default { data: ... }), set once.

Mental model — three levels of control

Everything is progressive. Start at Level 1; drop down only when you need to.

| Level | You write | Use it when | | ----- | -------------------------------------------- | ---------------------------------------- | | 1 | defineResource + forFeature | Plain CRUD. No custom behaviour. | | 2 | ...plus config (filters, routes, guards…) | You need to shape the endpoints. | | 3 | ...plus extends ResourceService(def) | You need real logic (side effects, deps). |

You never lose access to the lower levels — Level 3 is just Level 1 with a class.


Level 1 — zero classes with forFeature

This is the 60-second version above. forTypeOrmResource (and forMongooseResource) build the repository, service, and controller for you and wire them together:

import { forTypeOrmResource } from "@void-snippets/nestjs/typeorm";
// inline:     imports: [forTypeOrmResource(Contact)]
//             imports: [forTypeOrmResource(Contact, { ...any defineResource option })]
// definition: imports: [forTypeOrmResource(contactsResource, Contact)]
// options:    forTypeOrmResource(Contact, { dataSource: "reporting", imports: [...] })

import { forMongooseResource } from "@void-snippets/nestjs/mongoose";
// inline:     imports: [forMongooseResource({ name: "Contact", schema: ContactSchema })]
// definition: imports: [forMongooseResource(contactsResource, { name: "Contact", schema: ContactSchema })]

In the inline form name defaults to the pluralised class/model name ("Contact" → /contacts, "Category" → /categories) and id to "id" for TypeORM / "_id" for Mongoose. Reach for a separate defineResource when the definition is shared — e.g. a controller subclass also references it.

Under the hood these call VoidResourceModule.forFeature, which is also usable directly when you need full control over the wiring:

import { VoidResourceModule } from "@void-snippets/nestjs";
import { TypeOrmModule, getRepositoryToken } from "@nestjs/typeorm";

VoidResourceModule.forFeature({
  definition: contactsResource,
  imports: [TypeOrmModule.forFeature([Contact])], // provides the handle in this scope
  repository: getRepositoryToken(Contact), // the DI token to wrap
});

Why the token indirection? The core module never depends on @nestjs/typeorm/@nestjs/mongoose. The /typeorm and /mongoose subpath helpers do that wiring for you; forFeature stays ORM-agnostic underneath.


Level 2 — configure the resource

Everything is a field on the defineResource config. Here's a fuller example with comments:

export const contactsResource = defineResource({
  entity: Contact, // ← gives the config (and everything built from it) its types
  name: "contacts",
  id: "id", // its VALUE type (number here) becomes the id type everywhere

  // Pagination
  pagination: {
    mode: "both", // "offset" | "cursor" | "both"
    defaultLimit: 20, // page size when ?limit= is omitted
    maxLimit: 100, // hard ceiling on ?limit=
  },

  // What clients may filter / sort / search / select on
  query: {
    filters: defineFilters<Contact>({
      email: filter.like(), // ?email[like]=acme
      createdAt: filter.dateRange(), // ?createdAt[gte]=2026-01-01
    }),
    sort: { allowedFields: ["name", "createdAt"], default: "-createdAt" },
    search: ["name", "email"], // ?q=john
    select: ["id", "name", "email"], // ?fields=id,name
  },

  softDelete: "deletedAt", // enables soft delete + /restore
  optimisticLock: "version", // 409 on stale updates

  // Multi-tenancy in one line — ANDed into EVERY operation. A request without
  // an orgId gets a 403 (fail closed; set onMissing: "skip" to allow).
  scope: { field: "orgId", value: (ctx) => ctx.orgId },

  guards: [JwtAuthGuard], // applied to every route (class-level)
  routes: { remove: false }, // no DELETE for this resource
});

Every key is optional. sort, search, and select accept either a plain array of field names or a full spec object if you need more control — and every field name (including the sort default) is checked against the entity at compile time.


Level 3 — add your own logic

When you need side effects, extra injected services, or custom endpoints, stop using forFeature and write thin classes that extend the generated bases. Everything stays typed to your entity.

// contacts.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ResourceRepository } from "@void-snippets/nestjs/advanced";
import { Contact } from "./contact.entity";

@Injectable()
export class ContactsRepository extends ResourceRepository<Contact, number> {
  constructor(@InjectRepository(Contact) repo: Repository<Contact>) {
    super(repo);
  }
}
// contacts.service.ts
import { Injectable } from "@nestjs/common";
import { ResourceService, type RequestContext } from "@void-snippets/nestjs";
import { contactsResource } from "./contacts.resource";
import { ContactsRepository } from "./contacts.repository";
import { MailerService } from "../mailer/mailer.service";

@Injectable()
export class ContactsService extends ResourceService(contactsResource) {
  constructor(repo: ContactsRepository, private mailer: MailerService) {
    super(repo);
  }

  // Hooks run around the built-in CRUD. All are optional. `dto` and `entity`
  // are fully typed — no casting needed.
  protected override async beforeCreate(dto: Contact, ctx: RequestContext) {
    return { ...dto, orgId: ctx.orgId }; // enrich the payload before insert
  }
  protected override async afterCreate(entity: Contact) {
    await this.mailer.sendWelcome(entity.email);
  }
}
// contacts.controller.ts
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ResourceController } from "@void-snippets/nestjs";
import { contactsResource } from "./contacts.resource";
import { ContactsService } from "./contacts.service";

// Put @Controller(name) on your subclass. It sets the route path AND — because
// a class needs at least one decorator for TypeScript to emit constructor
// metadata (emitDecoratorMetadata) — it's what lets Nest inject your service.
@Controller("contacts")
export class ContactsController extends ResourceController(contactsResource) {
  constructor(service: ContactsService) {
    super(service);
  }

  // All the generated routes are inherited. Add your own freely — `this.service`
  // is the generated service, fully typed:
  @Get("recent")
  @UseGuards(AdminGuard)
  recent() {
    return this.service.list({ sort: "-createdAt", limit: 5 });
  }
}

Why @Controller is required here. In a hand-wired module, Nest injects your constructor from design:paramtypes, which TypeScript only emits for a class that carries a decorator. @Injectable() on the service and @Controller(name) on the controller provide it. (The forTypeOrmResource / forFeature path doesn't need this — it injects by explicit token internally.)

// contacts.module.ts — classic NestJS wiring
@Module({
  imports: [TypeOrmModule.forFeature([Contact])],
  controllers: [ContactsController],
  providers: [ContactsRepository, ContactsService],
})
export class ContactsModule {}

Prefer hooks without a subclass? You can also pass hooks: { beforeCreate, afterCreate, scope, ... } directly in defineResource and keep using forFeature. A subclass method always wins over an inline hook of the same name.

The full hook set

All hooks exist both as inline hooks: { ... } entries and as protected override methods on a service subclass. All are optional; every parameter is typed from your entity/DTOs.

| Hook | Runs on | Return to transform | | --------------------------- | -------------------- | ------------------------------ | | beforeCreate(dto, ctx) | create, createMany | replacement dto | | afterCreate(entity, ctx) | create, createMany | — | | beforeUpdate(id, dto, ctx)| update | replacement dto | | afterUpdate(entity, ctx) | update | — | | beforeDelete(entity, ctx) | remove, removeMany | — | | afterDelete(entity, ctx) | remove, removeMany | — | | afterRestore(entity, ctx) | restore | — | | beforeList(params, ctx) | list and count | replacement query params | | afterList(payload, ctx) | list | replacement payload | | beforeGet(id, ctx) | get | — | | afterGet(entity, ctx) | get | replacement entity | | serialize(entity, ctx) | every response entity| the wire representation | | scope(ctx) | every operation | extra FilterCondition[] |

serialize is the response shaper: it runs last, on every entity leaving the API (list items, get, create, update, restore, createMany), and never on internal reads or the entities other hooks receive:

hooks: {
  serialize: (contact) => ({ id: contact.id, name: contact.name }), // drop internals
}

Validating request bodies (DTOs)

By default the create/update body is accepted as-is. To validate it, give the resource DTO classes. They're validated with class-validator and become the real request types.

import { IsEmail, IsString, IsOptional } from "class-validator";
import { VSCreateInput } from "@void-snippets/nestjs";

// `implements VSCreateInput<Contact>` keeps the DTO in sync with the entity:
// it must cover exactly the fields the server doesn't manage.
export class CreateContactDto implements VSCreateInput<Contact> {
  @IsString() name!: string;
  @IsEmail() email!: string;
}

export class UpdateContactDto {
  @IsOptional() @IsString() name?: string;
  @IsOptional() @IsEmail() email?: string;
}
defineResource({
  entity: Contact,
  name: "contacts",
  id: "id",
  dto: { create: CreateContactDto, update: UpdateContactDto },
});

Now POST /contacts with an invalid email returns 400 automatically, and unknown fields are stripped. No @Body() or ValidationPipe wiring on your side.

The DTO classes also become the request types everywhere: beforeCreate(dto) receives a CreateContactDto, service.update() expects an UpdateContactDto — inferred, no generics. A single route can override the resource-level DTO with routes: { update: { dto: PatchContactDto } }.


Filtering, sorting, search, pagination — the query API

These are the query-string parameters your list endpoints understand. What's allowed is whatever you declared in query.

Filtering — ?field[op]=value

GET /contacts?email[like]=acme
GET /contacts?age[gte]=18&age[lte]=65
GET /contacts?status[in]=active,trial
GET /contacts?deletedAt[isNull]=true
GET /contacts?status=active            ← shorthand for status[eq]=active

You declare which fields and operators are allowed with defineFilters + the filter builders:

import { defineFilters, filter } from "@void-snippets/nestjs";

filters: defineFilters<Contact>({
  name:      filter.like(),      // like, ilike, contains, eq
  age:       filter.range(),     // gt, gte, lt, lte, between, eq  (coerced to number)
  createdAt: filter.dateRange(), // same ops, coerced to Date
  isActive:  filter.bool(),      // eq, ne (coerced to boolean)
  status:    filter.exact(),     // eq, ne, in, nin
  deletedAt: filter.nullable(),  // isNull, eq, ne
});

A filter on a field you didn't declare is ignored. An operator you didn't allow returns 400. And because defineFilters<Contact> is typed, writing a field name that isn't on Contact won't even compile:

defineFilters<Contact>({ emial: filter.like() });
//                        ^^^^^ compile error — did you mean "email"?

Sorting — ?sort=

GET /contacts?sort=name          → name ASC
GET /contacts?sort=-createdAt    → createdAt DESC   (leading "-" = descending)
GET /contacts?sort=-priority,name
sort: ["name", "createdAt", "priority"]; // allowlist; or { allowedFields, default }

Search — ?q=

GET /contacts?q=john   → case-insensitive match across the configured fields
search: ["name", "email"]; // or { fields, mode: "contains" | "prefix" }

Sparse fieldsets — ?fields=

GET /contacts?fields=id,name   → only these fields are fetched and returned
select: ["id", "name", "email"]; // allowlist for ?fields=

Pagination

Offset (classic pages):

GET /contacts?page=2&limit=20
{ "data": { "items": [...], "page": 2, "limit": 20, "totalPages": 5, "totalDocuments": 92 } }

Cursor (infinite scroll / big tables — stays fast at any depth):

GET /contacts?limit=20                     ← first page
GET /contacts?limit=20&cursor=<nextCursor> ← next page
{ "data": { "items": [...], "limit": 20, "nextCursor": "…", "prevCursor": null, "hasNextPage": true } }

Pick per resource with pagination.mode: "offset", "cursor", or "both" (with "both", sending ?page= gives offset, otherwise you get cursor).

Tip: For production cursors, set a cursorSecret (per resource or globally via forRoot). It signs each cursor so clients can't tamper with it.


Turning routes on and off

Every route is on by default. Turn things off in routes:

routes: {
  readOnly: true,     // shorthand: only list, get, count survive
}
routes: {
  remove: false,      // no DELETE /:id
  bulk: false,        // no POST /bulk and no DELETE /bulk
  restore: false,     // no /:id/restore
}

A disabled route is never registered — it 404s, it isn't a hidden method that throws.


Guards & per-route customization

Guards (and interceptors) attach at two levels — the whole controller, or an individual route — without overriding any method:

import { JwtAuthGuard } from "../auth/jwt.guard";

defineResource({
  entity: Contact,
  name: "contacts",
  guards: [JwtAuthGuard], // ← every route
  routes: {
    remove: { guards: [AdminGuard] }, // ← stacks on top for DELETE only
  },
});

Each route entry accepts the full option set:

routes: {
  create: {
    guards: [JwtAuthGuard],        // @UseGuards on this route
    interceptors: [AuditInterceptor], // @UseInterceptors
    pipes: [TrimPipe],             // @UsePipes
    httpCode: 202,                 // @HttpCode — e.g. 202 Accepted
    path: "submit",                // POST /contacts/submit instead of POST /contacts
    dto: SubmitContactDto,         // per-route body DTO override
    decorators: [ApiOperation({ summary: "Create a contact" })], // any method decorator
  },
}

decorators is the escape hatch: Swagger annotations, @Throttle, @CacheTTL — anything that decorates a method lands on the generated handler.


App-wide defaults with forRoot

Set things once at the root instead of repeating them in every resource. Import forRoot a single time in your AppModule:

@Module({
  imports: [
    VoidResourceModule.forRoot({
      cursorSecret: process.env.CURSOR_SECRET, // signs all cursors
      maxLimit: 200, // global ceiling on ?limit=
      defaultLimit: 25,
      paginationMode: "both",
      envelope: (data) => ({ success: true, data }), // house response shape
    }),
    ContactsModule,
    // ...other feature modules
  ],
})
export class AppModule {}

Any value a resource sets in its own defineResource config wins over the global default.


The response envelope

Every successful response is wrapped by an envelope function. The default is:

(data) => ({ data });

So a single item comes back as { "data": { ...entity } } and a list as { "data": { "items": [...], "page": 1, ... } }. This is the exact shape the @void-snippets/react and @void-snippets/angular clients consume with zero configuration.

To change it, set envelope — globally in forRoot, or per resource in defineResource:

envelope: (data) => ({ ok: true, result: data, ts: Date.now() });

Entity / document types

Helpers that describe what your ORM actually returns, so your types are honest.

TypeORM — base entity classes (import from @void-snippets/nestjs/typeorm):

import { VSBaseEntity, VSBaseUuidEntity, VSBaseVersionedEntity } from "@void-snippets/nestjs/typeorm";

class Contact extends VSBaseEntity {} // id: number + createdAt + updatedAt
class User extends VSBaseUuidEntity {} // id: string (uuid) + timestamps
class Order extends VSBaseVersionedEntity {} // + version column for optimistic lock

Mongoose — see the Mongoose section for createBaseSchema / VSDocOf.

Derived input types — write the entity once, keep DTO types in lockstep:

type ContactCreate = VSCreateInput<Contact>; // entity minus id/timestamps/version/deletedAt
type ContactUpdate = VSUpdateInput<Contact>; // Partial of the above

RelationsRef<T> is "id when lean, document when populated"; VSPopulated flips the populated paths to their real type:

class Post {
  author!: Ref<User>; // string | User
  tags!: Ref<Tag>[];
}
type PostWithAuthor = VSPopulated<Post, "author">; // author is now User

Mongoose setup

Same config — only the wiring differs. Build a schema, register the model, and point forFeature at the model token.

// contact.schema.ts
import { createBaseSchema, type VSDocOf } from "@void-snippets/nestjs/mongoose";

export const ContactSchema = createBaseSchema(
  {
    name: { type: String, required: true },
    email: { type: String, required: true, unique: true },
  },
  { softDelete: true }, // adds an indexed deletedAt field
);

// The honest lean type: your fields + _id + createdAt + updatedAt + deletedAt
export type Contact = VSDocOf<typeof ContactSchema, { softDelete: true }>;
// contacts.module.ts
import { Module } from "@nestjs/common";
import { forMongooseResource } from "@void-snippets/nestjs/mongoose";

@Module({
  imports: [
    forMongooseResource(
      { name: "Contact", schema: ContactSchema }, // schema is typed → T = Contact
      { softDelete: "deletedAt", query: { search: ["name", "email"] } },
    ),
  ],
})
export class ContactsModule {}

name derives to /contacts and id defaults to "_id". A shared defineResource<Contact>({ name: "contacts", ... }) definition plus forMongooseResource(definition, { name, schema }) works too.

populate is available for Mongoose resources:

defineResource<Post>({ name: "posts", populate: ["author", "tags"] });

Full API reference

defineResource(config)

The one config object. Returns a frozen definition consumed by the factories and the module. All generics are inferred from the config — T from entity, the id's value type from id, TCreate/TUpdate from dto. Key config fields:

| Field | Type | Notes | | ---------------- | --------------------------------------------------- | ------------------------------------------------- | | name | string | REST base path + default error name. | | entity | Type<T> | Entity class — the source of all typing. | | id | keyof T | Primary key. Defaults to _id, else id. | | resourceName | string | Singular name for 404/409 messages. | | dto | { create?, update? } | class-validator DTO classes → become TCreate/TUpdate. | | pagination | { mode?, defaultLimit?, maxLimit?, cursorField? } | mode: offset | cursor | both. | | query | { filters?, sort?, search?, select? } | Allowlists. sort/search/select accept arrays. | | softDelete | keyof T | Field to mark deleted; enables /restore. | | optimisticLock | keyof T | Numeric version field → 409 on stale writes. | | maxBulk | number | Max items per POST /bulk / DELETE /bulk. Default 500. | | populate | string \| string[] \| PopulateOptions | Mongoose only. | | cursorSecret | string | Signs cursors (overrides global). | | envelope | (data) => unknown | Response shape (overrides global). | | guards | VSGuard[] | Controller-level guards (every route). | | interceptors | VSInterceptor[] | Controller-level interceptors. | | scope | { field, value(ctx), onMissing? } | First-class tenant scoping; fails closed. | | routes | VSRoutesConfig | Enable/disable + per-route options. | | hooks | VSResourceHooks | Inline lifecycle hooks + raw scope. |

ResourceService(definition) → class

A service base class configured from the definition. extends it to add logic. Overridable hooks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete, afterRestore, beforeList, afterList, beforeGet, afterGet, serialize, and scope(ctx) for tenant isolation. See the full hook set.

ResourceController(definition) → class

A controller base class exposing the enabled routes. extends it to add custom routes. @Resource(definition) is the decorator equivalent for annotating your own class.

@Resource(definition) decorator

Decorator form of ResourceController. Apply it to your own controller class; the class must expose the injected service on a property named service (the generated route handlers call this.service):

@Resource(contactsResource)
export class ContactsController {
  constructor(readonly service: ContactsService) {}
}

VoidResourceModule and the ORM helpers

  • VoidResourceModule.forRoot(options) — app-wide defaults. Import once.
  • VoidResourceModule.forFeature({ definition, imports, repository }) — the ORM-agnostic registration primitive.
  • forTypeOrmResource(Entity, config?) — from @void-snippets/nestjs/typeorm. The one-liner most TypeORM apps want; config takes every defineResource option inline plus { dataSource?, imports? }. Also accepts the definition-first form forTypeOrmResource(definition, Entity, options?).
  • forMongooseResource({ name, schema }, config?) — from @void-snippets/nestjs/mongoose; same inline config plus { connectionName?, imports? }. Also accepts forMongooseResource(definition, { name, schema }, options?).

Query DSL (re-exported from @void-snippets/nestjs)

defineFilters, filter (.like, .range, .dateRange, .bool, .exact, .nullable), defineSort, defineSearch, defineSelect — all also available from @void-snippets/core.

Unsigned cursors. If a resource uses cursor pagination without a cursorSecret (per resource or via forRoot), it logs a one-time dev warning — unsigned cursors are tamperable. Set a secret in production.

The @Ctx() decorator & RequestContext

Every hook receives a RequestContext{ user?, orgId?, traceId, request? }. user comes from your auth guard (req.user); orgId from the x-org-id header; traceId from x-request-id/x-trace-id or auto-generated. Use it in scope() for multi-tenancy:

protected override scope(ctx: RequestContext): FilterCondition<Contact>[] {
  return ctx.orgId ? [{ field: "orgId", op: "eq", value: ctx.orgId }] : [];
}

field is constrained to keyof Contact, so a mistyped column is a compile error. A row outside the scope behaves exactly like one that doesn't exist (404), so cross-tenant ids can't be probed.


The /advanced subpath

The low-level engine the high-level API is built on lives behind @void-snippets/nestjs/advanced: ResourceNestService, ResourceNestController, ResourceNestRepository, ResourceRepository, the cursor codec, and the repository query types. Reach for it only when you're hand-writing a service against the engine or building a custom ORM adapter.


Upgrading

0.2 → 0.3

  • defineResource generics changed — stop passing them. Where you wrote defineResource<Contact, number>({ name, id: "id" }), write defineResource({ entity: Contact, name, id: "id" }); the id's value type is read off the entity. (If you must pass generics, the second parameter is now the id keydefineResource<Contact, "id"> — not the id type.)
  • Low-level engine moved — import ResourceNestService, ResourceNestController, ResourceRepository, and the cursor codec from @void-snippets/nestjs/advanced instead of the main entry.
  • ORM one-liners flipped — the primary form is now forTypeOrmResource(Entity, config?) / forMongooseResource(model, config?). The definition-first form still works unchanged.
  • New (non-breaking): first-class scope, controller-level guards/ interceptors, beforeList/afterList/beforeGet/afterGet/serialize hooks, and per-route path/httpCode/pipes/dto/decorators.

0.1 → 0.2

0.2 added the defineResource + factory + module API and changed service return values: list(), count(), and removeMany() return the raw payload (e.g. { items, page, ... }), not a pre-wrapped { data: ... }. The HTTP responses are unchanged — the controller applies the (now configurable) envelope. If you called those service methods directly, drop the .data hop.