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

@relayerjs/nestjs-crud

v0.1.1

Published

NestJS integration for Relayer — DI-native CRUD services and controllers

Readme

@relayerjs/nestjs-crud

Full-featured CRUD for NestJS on top of Relayer, an ORM-agnostic database layer. Define a Drizzle schema, add an entity class, and get a production-ready REST API with first-class TypeScript types across every layer.


Features

🧱 Built on Relayer's core — ORM-agnostic by design. Currently supports Drizzle ORM only, as we are in early development.

Full-featured CRUD — Turns your database schema into a first-class REST API with zero boilerplate.

🔥 Complex filters and aggregations — AND, OR, relations, JSON fields, any SQL-derived fields, and configurable search. Thanks to Relayer's nature, all fields are treated equally.

🎛️ Full lifecycle control — Hooks, data mappers, field-level access control.

🏗️ First-class TypeScript — Type-safe across all your entities, services, controllers, hooks, and responses.

Table of Contents

Installation

npm install @relayerjs/nestjs-crud @relayerjs/drizzle drizzle-orm

Peer dependencies: @nestjs/common, @nestjs/core, reflect-metadata, rxjs.

Quick Start

1. Define entities

// entities/post.entity.ts
import { createRelayerEntity } from '@relayerjs/drizzle';

import * as schema from '../schema';

export class PostEntity extends createRelayerEntity(schema, 'posts') {}
// entities/user.entity.ts
const UserBase = createRelayerEntity(schema, 'users');

export class UserEntity extends UserBase {
  @UserBase.computed({
    resolve: ({ table, sql }) => sql`${table.firstName} || ' ' || ${table.lastName}`,
  })
  fullName!: string;

  @UserBase.derived({
    query: ({ db, schema: s, sql, field }) =>
      db
        .select({ [field()]: sql<number>`count(*)::int`, userId: s.posts.authorId })
        .from(s.posts)
        .groupBy(s.posts.authorId),
    on: ({ parent, derived: d, eq }) => eq(parent.id, d.userId),
  })
  postsCount!: number;
}

2. Create an entity map

The entity map ties your entity classes together and enables cross-entity type inference:

// entities/entity-map.ts
export const entities = { users: UserEntity, posts: PostEntity };
export type EM = typeof entities;

3. Register the module

@Module({
  imports: [
    RelayerModule.forRoot({
      db,
      schema,
      entities: [UserEntity, PostEntity],
      baseUrl: () => `http://localhost:3000`,
    }),
    PostsModule,
  ],
})
export class AppModule {}

4. Create a service

@Injectable()
export class PostsService extends RelayerService<PostEntity, EM> {
  constructor(@InjectRelayer() r: RelayerInstance<EM>) {
    super(r, PostEntity);
  }

  async findPublished() {
    return this.findMany({
      where: { published: true },
      select: { id: true, title: true },
    });
  }
}

5. Create a controller

@CrudController<PostEntity, EM>({
  model: PostEntity,
  routes: {
    list: {
      defaults: { orderBy: { field: 'createdAt', order: 'desc' } },
      maxLimit: 50,
      defaultLimit: 20,
    },
    create: { schema: createPostSchema },
    update: { schema: updatePostSchema },
  },
})
export class PostsController extends RelayerController<PostEntity, EM> {
  constructor(postsService: PostsService) {
    super(postsService);
  }
}

That's it. Seven routes are ready:

| Method | Path | Description | | ---------------------- | ------------------------------------------------ | ----------- | | GET /posts | List with pagination, filtering, sorting, search | | GET /posts/:id | Find by ID | | POST /posts | Create | | PATCH /posts/:id | Update | | DELETE /posts/:id | Delete | | GET /posts/count | Count matching records | | GET /posts/aggregate | Aggregation with groupBy |

Full working example with entities, services, controllers, hooks, and DTO mapping is available in examples/nestjs-crud.

Read the full documentation

Architecture

Every request flows through a layered pipeline. Each layer is optional and independently overridable:

flowchart TD
    A["HTTP Request"] --> B["@CrudController\nRoute generation, query parsing, validation"]
    B --> C["DtoMapper\ntoCreateInput / toUpdateInput"]
    B --> D["Hooks\nbeforeCreate / beforeFind / ..."]
    C --> E["RelayerService\ngetDefaultWhere merging, CRUD execution"]
    D --> E
    E --> F["RelayerDrizzle\nQuery building, SQL execution"]
    F --> G["Hooks\nafterCreate / afterFind / ..."]
    F --> H["DtoMapper\ntoListItem / toSingleItem"]
    G --> I["HTTP Response\n{ data: T, meta?: {...} }"]
    H --> I

What each piece does

RelayerService is the data layer. It wraps a Relayer repository with typed CRUD methods and applies business-level defaults (tenant isolation, default ordering, field restrictions). Services are usable anywhere: controllers, cron jobs, other services, tests.

RelayerController is the HTTP layer. It parses query strings into typed options, applies route-level defaults and field whitelists, handles pagination, and wraps responses in a standard envelope. The @CrudController decorator generates route handlers automatically.

DtoMapper transforms data between the internal entity shape and the API shape. It runs after reads (entity -> response) and before writes (request -> entity). Two separate methods for list items vs. single item detail allow different response shapes per context.

RelayerHooks are lifecycle callbacks that fire around each operation. They receive fully typed arguments and can modify data in-flight (e.g. slugify a title before create, filter archived records after find).

Request lifecycle

A GET /posts request goes through these steps:

  1. @CrudController matches the route, @ListQuery parses the query string
  2. Controller merges route-level defaults (select, where, orderBy) with the parsed query
  3. Controller applies allow rules (field whitelist, operator restrictions, select limits)
  4. Controller calls hooks.beforeFind(options, ctx) if defined
  5. service.findMany(options) applies getDefaultWhere (AND-merged) and getDefaultOrderBy
  6. Relayer builds and executes the SQL query
  7. Controller calls hooks.afterFind(entities, ctx) if defined
  8. Controller calls dtoMapper.toListItem(entity, ctx) for each result if defined
  9. Controller wraps the result in { data: [...], meta: { total, limit, offset } }

Mutations follow the same pattern: parse -> validate -> hooks.before -> dtoMapper.toCreateInput -> service -> hooks.after -> dtoMapper.toSingleItem -> respond.

Service

RelayerService<TEntity, TEntities> provides fully typed CRUD methods:

service.findMany({ where, select, orderBy, limit, offset })
service.findFirst({ where, select, orderBy })
service.count({ where })
service.create({ data })
service.createMany({ data: [...] })
service.update({ where, data })
service.updateMany({ where, data })
service.delete({ where })
service.deleteMany({ where })
service.aggregate({ groupBy, _count, _sum, _avg, _min, _max, where, having })

Service Defaults

Override protected methods to enforce business-level defaults. These are applied automatically to every service method, whether called from a controller, a cron job, or another service:

@Injectable()
export class PostsService extends RelayerService<PostEntity, EM> {
  constructor(@InjectRelayer() r: RelayerInstance<EM>) {
    super(r, PostEntity);
  }

  // Enforced on every query: findMany, findFirst, count, update, delete
  protected getDefaultWhere(): Where<PostEntity, EM> | undefined {
    return { tenantId: this.currentTenantId };
  }

  // Applied when caller doesn't specify orderBy
  protected getDefaultOrderBy() {
    return { field: 'createdAt' as const, order: 'desc' as const };
  }

  // Applied when caller doesn't specify select
  protected getDefaultSelect() {
    return { id: true, title: true, published: true };
  }
}

getDefaultWhere is combined with caller-provided where via AND (both conditions must match). getDefaultOrderBy and getDefaultSelect are fallbacks: used only when the caller doesn't provide their own.

Cross-entity Access

The r property gives typed access to all registered entities:

async getPostWithAuthor(id: number) {
  const post = await this.findFirst({ where: { id } });
  const author = await this.r.users.findFirst({ where: { id: post?.authorId } });
  return { post, author };
}

Controller

Route Configuration

@CrudController<PostEntity, EM>({
  model: PostEntity,
  path: 'blog-posts',               // default: entity key
  id: { field: 'id', type: 'uuid' }, // default: 'id', 'number'

  routes: {
    list: {
      pagination: 'offset',          // 'offset' (default) | 'cursor_UNSTABLE'
      defaults: {
        select: { id: true, title: true, author: { fullName: true } },
        where: { published: true },
        orderBy: { field: 'createdAt', order: 'desc' },
      },
      allow: {
        select: { title: true, comments: { $limit: 5 } },
        where: {
          title: { operators: ['contains', 'startsWith'] },
          published: true,
        },
        orderBy: ['title', 'createdAt'],
      },
      maxLimit: 100,
      defaultLimit: 20,
      search: (q) => ({
        OR: [{ title: { ilike: `%${q}%` } }, { content: { ilike: `%${q}%` } }],
      }),
    },
    findById: {
      defaults: { select: { id: true, title: true, content: true } },
    },
    create: { schema: createPostSchema },
    update: { schema: updatePostSchema },
    delete: true,
    count: true,
    aggregate: true,
  },
})

Decorator Targeting

Apply NestJS decorators to specific routes:

@CrudController({
  model: PostEntity,
  decorators: [
    UseGuards(AuthGuard),                                           // all routes
    { apply: [Roles('admin')], for: ['create', 'update', 'delete'] },
    { apply: [CacheInterceptor], for: ['list', 'findById'] },
  ],
})

Overriding Handlers

Override any handler method in the controller class:

export class PostsController extends RelayerController<PostEntity, EM> {
  constructor(private readonly postsService: PostsService) {
    super(postsService);
  }

  protected async handleFindById(id: string, request: unknown) {
    const post = await this.postsService.findFirst({
      where: { id: parseInt(id, 10) },
      select: { id: true, title: true, author: { fullName: true } },
    });
    return { data: post };
  }

  // Custom non-CRUD routes work as usual
  @Get('published')
  async published() {
    return { data: await this.postsService.findPublished() };
  }
}

DtoMapper

Transform between the internal entity shape and the API response shape. Two separate methods let you return different amounts of data for lists vs. detail views:

interface PostListItem {
  id: number;
  title: string;
  published: boolean;
}

interface PostDetail extends PostListItem {
  content: string | null;
  tags: string[];
  createdAt: Date;
}

@Injectable()
export class PostDtoMapper extends DtoMapper<PostEntity, PostListItem, PostDetail> {
  toListItem(entity: PostEntity): PostListItem {
    return { id: entity.id, title: entity.title, published: entity.published };
  }

  toSingleItem(entity: PostEntity): PostDetail {
    return {
      ...this.toListItem(entity),
      content: entity.content,
      tags: entity.tags,
      createdAt: entity.createdAt,
    };
  }

  // Enrich input before it reaches the service
  toCreateInput(input: Partial<PostEntity>, ctx: RequestContext) {
    return { ...input, authorId: (ctx.user as { id: number }).id };
  }
}

Register in the controller config:

@CrudController({ model: PostEntity, dtoMapper: PostDtoMapper })

| Generic | Default | Description | | ------------- | ------------------ | ---------------------------------------------------- | | TEntity | | Entity type | | TListItem | TEntity | Return type of toListItem() | | TSingleItem | TListItem | Return type of toSingleItem() | | TInput | Partial<TEntity> | Input type for toCreateInput() / toUpdateInput() |

Lifecycle Hooks

Hooks fire around each CRUD operation. They are injectable, receive fully typed arguments, and can modify data in-flight:

@Injectable()
export class PostHooks extends RelayerHooks<PostEntity, EM> {
  async beforeCreate(data: Partial<PostEntity>, ctx: RequestContext) {
    data.slug = slugify(data.title!);
    return data;
  }

  async afterCreate(entity: PostEntity) {
    await this.notificationService.send(`New post: ${entity.title}`);
  }

  async afterFind(entities: PostEntity[]) {
    return entities.filter((e) => !e.isArchived);
  }
}

Register in the controller config:

@CrudController({ model: PostEntity, hooks: PostHooks })

| Hook | Arguments | Can modify result? | | ----------------- | -------------------- | ---------------------- | | beforeCreate | (data, ctx) | Return modified data | | afterCreate | (entity, ctx) | | | beforeUpdate | (data, where, ctx) | Return modified data | | afterUpdate | (entity, ctx) | | | beforeDelete | (where, ctx) | | | afterDelete | (entity, ctx) | | | beforeFind | (options, ctx) | | | afterFind | (entities, ctx) | Return modified list | | beforeFindOne | (options, ctx) | | | afterFindOne | (entity, ctx) | Return modified entity | | beforeCount | (options, ctx) | | | beforeAggregate | (options, ctx) | | | afterAggregate | (result, ctx) | Return modified result |

Query DSL

All query parameters are passed via URL query string as JSON:

GET /posts?where={"published":true}&select={"id":true,"title":true}&orderBy={"field":"createdAt","order":"desc"}&limit=10

Alternative sort syntax:

GET /posts?sort=-createdAt,title&limit=10

Search (when search callback is configured in list route config):

GET /posts?search=hello

Cursor pagination:

GET /posts?cursor=eyJ2YWx1Z...&limit=20

Aggregation

Via HTTP:

GET /posts/aggregate?groupBy=["author.fullName"]&_count=true&_avg={"author.postsCount":true}

Programmatic usage with full type inference:

const result = await service.aggregate({
  groupBy: ['author.fullName'],
  _count: true,
  _sum: { rating: true },
});

result[0].author.fullName; // typed as string
result[0]._count; // typed as number
result[0]._sum.author.postsCount; // typed as number | null

Response Types

The package exports typed response envelopes for use in custom controllers or client-side code:

import type {
  CountResponse,
  CursorListResponse,
  DetailResponse,
  ListResponse,
} from '@relayerjs/nestjs-crud';
ListResponse<T>; // { data: T[], meta: { total, limit, offset, nextPageUrl? } }
CursorListResponse<T>; // { data: T[], meta: { limit, hasMore, nextCursor?, nextPageUrl? } }
DetailResponse<T>; // { data: T }
CountResponse; // { data: { count: number } }

Validation

Supports both Zod and class-validator out of the box:

// Zod
const createPostSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
  published: z.boolean().optional().default(false),
});

// class-validator
class CreatePostDto {
  @IsString() @MinLength(1) title!: string;
  @IsString() content!: string;
  @IsBoolean() @IsOptional() published?: boolean;
}

Both are passed to the route config the same way:

routes: {
  create: { schema: createPostSchema },
}

Error Handling

RelayerExceptionFilter standardizes error responses across your API:

app.useGlobalFilters(new RelayerExceptionFilter());
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Entity not found",
    "status": 404
  }
}

Validation errors (422) include field-level details:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "status": 422,
    "errors": [
      {
        "code": "too_small",
        "path": ["title"],
        "message": "String must contain at least 1 character(s)"
      }
    ]
  }
}

Dependency Injection

Three injection decorators for different levels of access:

// Full Relayer client with all entities
constructor(@InjectRelayer() r: RelayerInstance<EM>) {}

// Single entity repository
constructor(@InjectEntity(PostEntity) repo: EntityRepo<PostEntity, EM>) {}

// Auto-registered service for an entity
constructor(@InjectQueryService(PostEntity) service: RelayerService<PostEntity, EM>) {}

Roadmap

  • Stable cursor pagination (requires @relayerjs/drizzle patch)
  • Swagger for API documentation
  • API endpoints for linking m2m, one2m relations
  • Better integration with Relayer context object

License

MIT