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

@zola_do/crud

v0.2.9

Published

Generic CRUD controllers and services for NestJS

Readme

@zola_do/crud

npm version npm downloads License: ISC

Generic CRUD controllers, services, and repositories for NestJS entities with built-in authorization, hooks, and pagination.

Overview

@zola_do/crud provides reusable CRUD factories that generate type-safe controllers and services for any TypeORM entity. It includes:

  • Controller FactoriesEntityCrudController, ExtraCrudController, RelationCrudController
  • Service Classes — Generic CRUD operations with hooks
  • Repository Pattern — EntityCrudRepository for data access
  • Authorization Guards — Permission-based access control
  • Operation Hooks — before/after callbacks for each operation
  • Endpoint Control — Hide or block specific operations
  • Request Context — Automatic tenant/user context mapping
  • Global Exception Filter — Safe JSON error responses

Installation

# Install individually
npm install @zola_do/crud

# Or via meta package
npm install @zola_do/nestjs-shared

Dependencies

npm install @zola_do/collection-query @zola_do/authorization

Optional Dependencies

# For RPC exception filter
npm install @nestjs/microservices

Quick Start

1. Create an Entity

import { CommonEntity } from '@zola_do/nestjs-shared';
import {
  Entity,
  Column,
  PrimaryGeneratedColumn,
  DeleteDateColumn,
} from 'typeorm';

@Entity('products')
export class Product extends CommonEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  price: number;

  @Column({ default: 'active' })
  status: string;

  @DeleteDateColumn()
  deletedAt?: Date;
}

2. Create Controller

import { Controller } from '@nestjs/common';
import { EntityCrudController } from '@zola_do/crud';
import { EntityCrudService } from '@zola_do/crud/service';
import { Product } from './product.entity';

@Controller('products')
export class ProductsController extends EntityCrudController<Product>({
  createPermission: 'product:create',
  viewPermission: 'product:view',
  updatePermission: 'product:update',
  deletePermission: 'product:delete',
}) {
  constructor(service: EntityCrudService<Product>) {
    super(service);
  }
}

3. Create Module

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EntityCrudService } from '@zola_do/crud/service';
import { Product } from './product.entity';
import { ProductsController } from './products.controller';

@Module({
  imports: [TypeOrmModule.forFeature([Product])],
  controllers: [ProductsController],
  providers: [EntityCrudService],
})
export class ProductsModule {}

Available Endpoints

| Method | Endpoint | Description | | -------- | -------------- | -------------------------------------------- | | POST | / | Create a new entity | | GET | / | List all entities (with ?q= query support) | | GET | /:id | Get single entity by ID | | PUT | /:id | Update entire entity | | PATCH | /:id | Partial update | | DELETE | /:id | Soft delete | | PATCH | /restore/:id | Restore soft-deleted entity | | GET | /archived | List archived entities |

EntityCrudOptions

Full configuration options for the CRUD controller:

import { EntityCrudOptions } from '@zola_do/crud';

const options: EntityCrudOptions = {
  // DTOs for validation
  createDto: CreateProductDto,
  updateDto: UpdateProductDto,

  // Permissions for guards
  createPermission: 'product:create',
  viewPermission: 'product:view',
  updatePermission: 'product:update',
  deletePermission: 'product:delete',
  restorePermission: 'product:restore',
  viewArchivedPermission: 'product:view_archived',

  // Operation hooks (create-time context mapping)
  beforeCreate: async ({ req, itemData }) => {
    itemData.organizationId = req?.user?.organizationId;
    itemData.createdBy = req?.user?.id;
  },
  afterCreate: async ({ service, req, result }) => {
    await service.sendNotification(result.id);
  },

  // Write field restrictions
  allowedWriteFields: ['name', 'price'],
  blockedWriteFields: ['id', 'createdAt'],

  beforeFindAll: async ({ service, req, where }) => {
    where.organizationId = req.user.organizationId;
  },
  afterFindAll: async ({ service, req, result }) => {
    result.items = result.items.filter(i => i.isActive);
  },

  beforeFindOne: async ({ service, req, where, params }) => {
    where.id = params.id;
  },

  beforeUpdate: async ({ service, req, itemData, params }) => {
    itemData.updatedBy = req.user.id;
  },
  afterUpdate: async ({ service, req, result }) => {
    await service.invalidateCache(result.id);
  },

  beforeDelete: async ({ service, req, params }) => {
    await service.assertCanDelete(params.id);
  },

  beforeRestore: async ({ service, req, params }) => {
    await service.assertCanRestore(params.id);
  },

  // Authorization callbacks
  authorize: async ({ req, operation }) => {
    return req.user.isAdmin;
  },
  canCreate: async ({ service, req }) => /* boolean */,
  canFindAll: async ({ service, req }) => /* boolean */,
  canFindOne: async ({ service, req, params }) => /* boolean */,
  canUpdate: async ({ service, req, params }) => /* boolean */,
  canDelete: async ({ service, req, params }) => /* boolean */,
  canRestore: async ({ service, req, params }) => /* boolean */,

  // Delete behavior
  deleteMode: 'soft', // 'auto' | 'soft' | 'hard'

  // Endpoint control
  operations: {
    create: 'hide', // Hide from routes (404)
    findAll: { mode: 'block', reason: 'maintenance' }, // Block with 403
    findOne: false, // Shorthand for 'hide'
    update: 'block',
    delete: 'hide',
    restore: 'hide',
    findAllArchived: 'hide',
  },
};

Operation Hooks

Hooks are callbacks that run before or after CRUD operations. They receive a context object and can modify the request data or perform side effects.

Hook Types

interface CrudHookContext {
  req?: any; // Express request object
  params?: any; // Route parameters
  query?: any; // Query parameters
  id?: string; // Entity ID (for single-entity operations)
  itemData?: any; // Create/Update payload (mutable)
  where?: any; // Query filter (mutable for reads)
  entity?: any; // Existing entity (for updates)
  result?: any; // Operation result
  operation: string; // 'create' | 'findAll' | 'findOne' | 'update' | 'delete' | 'restore'
  service?: any; // CRUD service instance
  repository?: any; // TypeORM repository
  setMeta?: (key: string, value: any) => void; // Cross-hook metadata
  getMeta?: <T = any>(key: string) => T | undefined;
}

Hook Examples

Before Create - Add Context Data

beforeCreate: async ({ service, req, itemData }) => {
  const userId = req?.user?.sub ?? req?.user?.id;
  const orgId = req?.user?.organizationId;

  if (!userId || !orgId) {
    throw new UnauthorizedException('User context required');
  }

  itemData.createdBy = userId;
  itemData.organizationId = orgId;
  itemData.status ??= 'pending';
},

Before FindAll - Filter by Tenant

beforeFindAll: async ({ service, req, where }) => {
  where.organizationId = req.user.organizationId;
  where.deletedAt = undefined; // Exclude soft-deleted
},

Before Update - Validate and Modify

beforeUpdate: async ({ service, req, itemData, params }) => {
  const existing = await service.findOne(params.id);

  if (existing.status === 'completed') {
    throw new BadRequestException('Cannot update completed items');
  }

  itemData.updatedBy = req.user.id;
  itemData.updatedAt = new Date();
},

After Create - Side Effects

afterCreate: async ({ service, req, result }) => {
  await service.sendWebhook('item.created', result);
  await service.updateAnalytics(result);
},

Using Metadata for Cross-Hook Communication

beforeFindAll: async ({ req, setMeta }) => {
  setMeta('userId', req.user.id);
  setMeta('orgId', req.user.organizationId);
},

afterFindAll: async ({ service, req, result, getMeta }) => {
  const userId = getMeta('userId');
  await service.trackSearch(userId, result.items.length);
},

Authorization Callbacks

Operation-specific authorization allows fine-grained access control:

const options: EntityCrudOptions = {
  // Global authorize (runs for all operations)
  authorize: async ({ req, operation }) => {
    if (!req.user) return false;
    return true;
  },

  // Per-operation authorization
  canCreate: async ({ service, req }) => {
    return req.user.permissions.includes('product:create');
  },

  canFindAll: async ({ service, req }) => {
    return true; // Public read
  },

  canUpdate: async ({ service, req, params }) => {
    const entity = await service.findOne(params.id);
    return entity.ownerId === req.user.id || req.user.isAdmin;
  },

  canDelete: async ({ service, req, params }) => {
    const entity = await service.findOne(params.id);
    return entity.ownerId === req.user.id;
  },

  canRestore: async ({ service, req, params }) => {
    return req.user.isAdmin;
  },
};

Endpoint Disabling

Control which endpoints are available using the operations option:

operations: {
  // 'hide' - Route not registered (404)
  create: 'hide',

  // 'block' - Route registered but denied (403)
  findAll: { mode: 'block', reason: 'Under maintenance' },

  // false - Same as 'hide'
  findOne: false,

  // 'block' with string shorthand
  update: 'block',

  // All available keys
  delete: 'hide',
  restore: 'hide',
  findAllArchived: 'hide',
}

Operation Keys

| Key | Endpoint | Description | | ----------------- | -------------------- | ----------------- | | create | POST / | Create new entity | | findAll | GET / | List entities | | findOne | GET /:id | Get single entity | | update | PUT /:id | Full update | | delete | DELETE /:id | Soft delete | | restore | PATCH /restore/:id | Restore entity | | findAllArchived | GET /archived | List deleted |

Request Transactions

Enable atomic mutating requests (create/update/delete hooks + DB writes commit or roll back together):

import { InjectDataSource } from '@nestjs/typeorm';
import {
  bindCrudTransactionDataSource,
  CrudTransactionModule,
} from '@zola_do/crud';
import { DataSource } from 'typeorm';

@Module({
  imports: [TypeOrmModule.forRoot(dataSourceOptions), CrudTransactionModule],
})
export class AppModule {
  constructor(@InjectDataSource() dataSource: DataSource) {
    bindCrudTransactionDataSource(dataSource);
  }
}
EntityCrudController<Product>({
  useTransaction: true,
  beforeCreate: async ({ itemData }) => {
    itemData.status = 'draft';
  },
  afterCreate: async ({ repository, result }) => {
    // DB work here uses the same transaction; throws roll back the insert too.
    await repository.insert({ ...relatedRow, productId: result.id });
  },
});

How it works:

  1. useTransaction: true attaches TransactionInterceptor to mutating routes (POST, PUT, DELETE, restore, bulk).
  2. The interceptor starts a TypeORM transaction and stores the entity manager on req.
  3. CRUD services resolve repositories from that manager, so beforeCreate, inserts/updates/deletes, and afterCreate DB calls share one transaction.
  4. If any step throws, the interceptor rolls back and the client receives the error.

Notes:

  • Non-DB side effects in hooks (HTTP, email, queues) are not rolled back automatically.
  • Use context.repository (or pass context.req into other service methods) so related DB work stays in the same transaction.
  • Read endpoints (GET) are unchanged and do not start a transaction unless you add the interceptor manually.

Postgres RLS / session context (consuming app)

Implement {@link CrudTransactionContextHook} in your app and bind it in AppModule (the interceptor singleton is created before optional token injection resolves):

import {
  bindCrudTransactionContextHook,
  bindCrudTransactionDataSource,
  CrudTransactionContextHook,
  CrudTransactionModule,
} from '@zola_do/crud';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Injectable } from '@nestjs/common';

@Injectable()
class PostgresRlsTransactionContextHook implements CrudTransactionContextHook {
  async apply({ req, queryRunner }) {
    const user = req?.user;
    await queryRunner.query(
      `SELECT set_config('app.user_id', $1, true), set_config('app.tenant_id', $2, true)`,
      [user?.id ?? '', user?.tenantId ?? ''],
    );
  }
}

@Module({
  imports: [TypeOrmModule.forRoot(dataSourceOptions), CrudTransactionModule],
  providers: [PostgresRlsTransactionContextHook],
})
export class AppModule {
  constructor(
    @InjectDataSource() dataSource: DataSource,
    rlsHook: PostgresRlsTransactionContextHook,
  ) {
    bindCrudTransactionDataSource(dataSource);
    bindCrudTransactionContextHook(rlsHook);
  }
}

The hook runs after START TRANSACTION and before CRUD handlers. Use transaction-local set_config(..., true) so pooled connections do not leak context. For read routes (GET), add a similar global interceptor if RLS must apply there too.

Request Context Mapping

The CRUD package supports automatic context injection for create operations:

Method 1: Per-CRUD beforeCreate Hook

EntityCrudController<Product>({
  beforeCreate: async ({ req, itemData }) => {
    itemData.organizationId = req?.user?.organizationId;
    itemData.organizationName = req?.user?.organizationName;
    itemData.createdBy = req?.user?.id;
  },
});

When beforeCreate is configured, the legacy req.user.organization fallback does not overwrite client-supplied organization fields.

Method 2: Module-Level Resolver

import {
  CRUD_REQUEST_CONTEXT_RESOLVER,
  CrudRequestContextResolver,
} from '@zola_do/crud/request-context';

const resolver: CrudRequestContextResolver = {
  resolveCreateContext: ({ req }) => ({
    organizationId: req?.auth?.orgId,
    organizationName: req?.auth?.orgName,
    createdBy: req?.auth?.userId,
  }),
};

@Module({
  providers: [
    {
      provide: CRUD_REQUEST_CONTEXT_RESOLVER,
      useValue: resolver,
    },
  ],
})
export class ProductsModule {}

Method 3: Legacy Fallback

If no resolver is configured, the system falls back to req.user.organization:

// Automatically uses req.user.organization.organizationId
// and req.user.organization.organizationName

Relation CRUD

For managing many-to-many relationships:

import { RelationCrudController, RelationCrudService } from '@zola_do/crud';

@Controller('products/:productId/categories')
export class ProductCategoriesController extends RelationCrudController(
  {
    firstEntityIdName: 'productId',
    firstInclude: 'product',
    secondEntityIdName: 'categoryId',
    secondInclude: 'category',
    viewPermission: 'product:view',
    operations: {
      bulkSaveFirst: 'hide',
      findAllSecond: 'hide',
    },
  },
  ProductCategory,
) {
  constructor(service: RelationCrudService) {
    super(service);
  }
}

Relation CRUD Endpoints

| Method | Endpoint | Description | | ------ | ------------------- | ------------------------------- | | POST | /bulk-save-first | Assign multiple first entities | | POST | /bulk-save-second | Assign multiple second entities | | GET | /first-entities | List all first entities | | GET | /second-entities | List all second entities |

Extra CRUD

Extended CRUD with parent entity scoping:

import { ExtraCrudController, ExtraCrudService } from '@zola_do/crud';

@Controller('events/:eventId/attendees')
export class EventAttendeesController extends ExtraCrudController(
  {
    entityIdName: 'eventId',
    createPermission: 'attendee:create',
    viewPermission: 'attendee:view',
  },
  Attendee,
) {
  constructor(service: ExtraCrudService<Attendee>) {
    super(service);
  }
}

Global Exception Filter

The package includes a safe global exception filter:

import { GlobalExceptionFilter } from '@zola_do/crud';

// In your main.ts
app.useGlobalFilters(new GlobalExceptionFilter());

Response Format

// Success response
{
  "statusCode": 200,
  "data": { ... }
}

// Error response (production)
{
  "statusCode": 400,
  "message": "Validation failed",
  "error": "Bad Request",
  "path": "/api/products",
  "timestamp": "2024-01-01T00:00:00.000Z"
}

Note: Raw exceptions and stack traces are never exposed to clients in production.

Delete Modes

| Mode | Behavior | Use Case | | ------ | ------------------------------ | ------------------------------------------ | | auto | Uses entity metadata (default) | Standard entities with @DeleteDateColumn | | soft | Sets deletedAt timestamp | Audit trail required | | hard | Physical delete | Data that can be safely removed |

// Force hard delete
EntityCrudController<Product>({
  deleteMode: 'hard',
  beforeDelete: async ({ service, params }) => {
    await service.cleanupRelatedData(params.id);
  },
});

Query String Support

List endpoints support collection query parameters via ?q=:

GET /products?q=w=status$eq$active&t=20&o=createdAt$desc&i=category

See @zola_do/collection-query for full query syntax.

API Reference

Controllers

EntityCrudController<T>(options: EntityCrudOptions)

Generic CRUD controller factory.

class EntityCrudController<T> {
  constructor(
    protected readonly service: EntityCrudService<T>,
  ) {}

  // POST /
  create(@Body() dto: any, @Request() req: any): Promise<T>

  // GET /
  findAll(
    @Query('q') query: string,
    @Request() req: any,
  ): Promise<CollectionResult<T>>

  // GET /:id
  findOne(@Param('id') id: string, @Request() req: any): Promise<T>

  // PUT /:id
  update(
    @Param('id') id: string,
    @Body() dto: any,
    @Request() req: any,
  ): Promise<T>

  // PATCH /:id
  patch(
    @Param('id') id: string,
    @Body() dto: any,
    @Request() req: any,
  ): Promise<T>

  // DELETE /:id
  delete(@Param('id') id: string, @Request() req: any): Promise<void>

  // PATCH /restore/:id
  restore(@Param('id') id: string, @Request() req: any): Promise<T>

  // GET /archived
  findAllArchived(@Request() req: any): Promise<T[]>
}

ExtraCrudController<T>(options: ExtraCrudOptions, entity: new () => T)

Extended CRUD with parent entity scope.

RelationCrudController(options: RelationCrudOptions, entity: new () => any)

Many-to-many relationship management.

Services

EntityCrudService<T>

Generic CRUD service with query builder.

class EntityCrudService<T> {
  constructor(protected readonly repository: Repository<T>) {}

  findAll(query: CollectionQuery): Promise<CollectionResult<T>>;
  findOne(id: string): Promise<T>;
  create(data: any): Promise<T>;
  update(id: string, data: any): Promise<T>;
  patch(id: string, data: any): Promise<T>;
  delete(id: string): Promise<void>;
  restore(id: string): Promise<T>;
}

Repositories

EntityCrudRepository<T>

Repository with built-in query construction.

Recommended Imports

Subpath imports are recommended for tree-shaking:

import { EntityCrudController } from '@zola_do/crud/controller';
import { EntityCrudService } from '@zola_do/crud/service';
import { EntityCrudRepository } from '@zola_do/crud/repository';
import {
  CRUD_REQUEST_CONTEXT_RESOLVER,
  CrudRequestContextResolver,
} from '@zola_do/crud/request-context';
import { EntityCrudOptions, GlobalExceptionFilter } from '@zola_do/crud';

Root import is supported for backward compatibility:

import {
  EntityCrudController,
  EntityCrudService,
  EntityCrudOptions,
} from '@zola_do/crud';

Optional Features

RPC Exception Filter

For NestJS microservices:

import { ExceptionFilter } from '@zola_do/crud/optional/rpc';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: ExceptionFilter,
    },
  ],
})
export class OrdersModule {}

Requires @nestjs/microservices peer dependency.

Troubleshooting

Q: How do I add custom methods to the CRUD service?

Extend the generic service:

@Injectable()
export class ProductService extends EntityCrudService<Product> {
  constructor(
    @InjectRepository(Product)
    repository: Repository<Product>,
  ) {
    super(repository);
  }

  async findActive(): Promise<Product[]> {
    return this.repository.find({ where: { status: 'active' } });
  }
}

Q: How do I validate the DTO?

Use class-validator decorators:

import { IsString, IsNumber, IsEnum, IsOptional } from 'class-validator';

export class CreateProductDto {
  @IsString()
  name: string;

  @IsNumber()
  price: number;

  @IsEnum(['draft', 'active', 'archived'])
  @IsOptional()
  status?: string;
}

Q: How do I handle file uploads?

Combine with @nestjs/platform-express:

// Add to your DTO
import { UploadedFile } from '@nestjs/common';

async create(
  @Body() dto: CreateProductDto,
  @UploadedFile() file: Express.Multer.File,
) {
  if (file) {
    dto.imageUrl = await this.uploadService.upload(file);
  }
  return this.productService.create(dto);
}

Related Packages

License

ISC

Community