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

monolite-crud

v0.9.1

Published

Generic CRUD stack: one decorator gives an entity list/getOne/create/update/softDelete over HTTP.

Readme

monolite-crud

The CRUD you stop writing.

monolite-data removed the SQL: you describe a table and get a repository. This package does the same thing one layer up. You supply a repository and a mapper, and you get the BLL; you put one decorator on the controller, and you get the five routes with their validation and their OpenAPI. What disappears is the pass-through — the try/catch that defers to the global error handler, the id parsing that turns /users/abc into a 400, the 404 when there is no row, the status code of each verb — which every module used to write identically.

Nothing here is all-or-nothing: any verb can be dropped and declared by hand, buildWhere is the seam for a module's own filtering, and a BLL with real business rules overrides the verb that has them and keeps the other four.

Install

npm install monolite-crud

It expects express@^5 in the host application, and it builds on monolite-core, monolite-data and monolite-http.

Usage

A whole module, end to end.

1. The mapper declares once which DTO property comes from which entity property, and both directions follow from it:

import { createMapper } from "monolite-crud";

const userMapper = createMapper<IUser, UserDTO>({
  id: "pkUser",
  name: "name",
  email: "email",
  // Computed fields and read-only ones never travel back to the entity.
  displayName: { computed: (user) => `${user.name} <${user.email}>` },
  createdAt: { field: "createdAt", readOnly: true },
});

2. The BLL is the repository plus the mapper. Override buildWhere when the listing has to filter:

import { CrudBLL } from "monolite-crud";
import type { IGenericRepository, QueryOptions } from "monolite-data";

export class UsersBLL extends CrudBLL<IUser, UserDTO> {
  constructor(repository: IGenericRepository<IUser>) {
    super(repository, userMapper, { field: "name", direction: "asc" });
  }

  protected override buildWhere(query: unknown): QueryOptions<IUser>["where"] {
    const { search } = (query ?? {}) as { search?: string };
    return search ? { name: { contains: search } } : undefined;
  }
}

3. The controller is the declaration. @Crud() mounts the routes on the concrete class — it cannot live on the base, or the routes would register under the base's name:

import { Crud, CrudController } from "monolite-crud";
import { ApiController } from "monolite-http";

@ApiController("/users", { tag: "Users" })
@Crud({
  resource: "user",
  dto: "User",
  schemas: { create: createUserSchema, update: updateUserSchema, query: listUsersSchema },
})
export class UsersController extends CrudController {
  constructor(bll: UsersBLL, context: IRequestContext) {
    super(bll, context, "user");
  }
}

GET /users, GET /users/:id, POST /users, PUT /users/:id and DELETE /users/:id are now live, validated by those Zod schemas and documented from them — the schema exists once, so the documentation cannot drift from what the endpoint actually accepts.

Keeping a verb of your own

Drop it from verbs and declare it with its own route decorator:

@Crud({ resource: "user", dto: "User", verbs: ["list", "getOne", "update"] })
export class UsersController extends CrudController {
  constructor(
    private readonly users: UsersBLL,
    context: IRequestContext
  ) {
    super(users, context, "user");
  }

  @Post("/", { summary: "Register a user", body: registerSchema })
  public override create: CrudHandler = async (req, res, next) => {
    /* ... */
  };
}

Declaring it without dropping it is a mistake, and it fails at startup: the duplicate-route detector reports two routes for the same path rather than letting the second one be silently unreachable.

Transactions

@Transactional() replaces the unitOfWork.execute(...) that used to wrap a method body. If a transaction is already open it joins it instead of nesting another, so two transactional BLLs calling each other share one commit.

import { lockRow, Transactional, TransactionalBLL } from "monolite-crud";

export class AppointmentsBLL extends TransactionalBLL {
  @Transactional()
  async book(branchId: number, slot: Date): Promise<void> {
    // Must be the first statement: MySQL fixes the snapshot on the first
    // consistent read, so a plain SELECT before the lock would keep reading
    // stale state even after the lock is granted.
    await this.lockRow("branch", branchId);
    /* ... */
  }
}

A BLL that already extends CrudBLL cannot also extend TransactionalBLL — TypeScript has no multiple inheritance — so lockRow is exported as a standalone function too, taking the transaction context explicitly.

Loading relations

loadRelated is EF Core's Include() for this architecture: relations between aggregates are resolved in the application layer, because each repository knows a single table. The load is batched — one WHERE key IN (...) per relation, not one query per row.

const branches = await loadRelated(appointments, {
  foreignKey: "fkBranch",
  relatedKey: "pkBranch",
  repository: branchRepository,
});

API

| Export | What it is | | --- | --- | | CrudBLL, ICrudBLL, CrudBLLOptions, ListOptions, PaginatedDTO, EntityMapper | The business layer and its contracts | | include, Include, IncludeDefinition | A relation declared once, where the BLL is built, instead of hydrated by hand in each of the four verbs | | hydrated, HydratedField, hydratedFields, isHydratedField | The DTO field an include fills. A field declared hydrated() that no include fills stops the BLL from being built, rather than answering null for ever | | CrudController, ICrudController | The five HTTP handlers | | Crud, CrudOptions, CrudVerb | The decorator that mounts the routes | | createMapper, Mapper, MappingProfile, FieldMapping, MappedField, ComputedField | Declarative entity <-> DTO mapping | | Transactional, TransactionalBLL, lockRow | Ambient transactions | | loadRelated, IncludeSpec | Batched loading of an N:1 relation |

License

MIT