@ubyjerome/meta-manager
v3.0.1
Published
MongoDB entity manager with REST, events, interceptors, and relations out of the box.
Maintainers
Readme
@ubyjerome/meta-manager
MongoDB entity manager for Express. Define an entity once — get a full REST API, typed service layer, lifecycle events, interceptors, and deep parent/child relations, with no repetitive boilerplate.
Install
npm install @ubyjerome/meta-manager mongoose expressQuick Start
import mongoose from "mongoose";
import express from "express";
import { MetaEntity } from "@ubyjerome/meta-manager";
await mongoose.connect("mongodb://localhost:27017/mydb");
const app = express();
app.use(express.json());
const booksEntity = new MetaEntity("books", {
searchableFields: ["title_name", "description"],
defaultSort: "created_at",
defaultOrder: "desc",
defaultLimit: 20,
softDelete: true,
});
app.use("/books", booksEntity.controller);
app.listen(3000);That single mount produces the following endpoints:
| Method | Path | Description |
|--------|------|-------------|
| GET | /books/all | Paginated list with sort, filter, fields |
| GET | /books/search?q=term | Full-text or regex search |
| GET | /books/count | Document count with optional filter |
| GET | /books/by/:field/:value | Find by any field |
| GET | /books/exists/:field/:value | Existence check |
| GET | /books/:id | Single record by UUID or _id |
| GET | /books/:id/children | Record with nested children |
| POST | /books/create | Create one |
| POST | /books/create/many | Bulk create |
| PUT/PATCH | /books/:id | Full or partial update |
| PATCH | /books/:id/field/:field | Update a single nested field |
| DELETE | /books/:id | Soft delete (add ?hard=true for hard delete) |
| POST | /books/:id/restore | Restore a soft-deleted record |
Default Document Shape
Every entity document inherits these fields automatically:
{
uuid: string; // auto-generated UUID v4
request_id?: string;
meta_key?: string;
meta_value?: string;
data_type?: string;
title_name?: string;
description?: string;
entity_featured_url?: string;
extra_data?: unknown[]; // schema-less, accepts anything
meta_data?: unknown[]; // schema-less, accepts anything
status: "active" | "inactive" | "archived";
parent_entity_type?: string;
parent_entity?: string;
owned_by?: string;
added_by?: string;
created_by?: string;
updated_by?: string;
slug?: string; // auto-generated from title_name
deleted_at?: Date;
created_at: Date;
updated_at: Date;
}Entity Options
new MetaEntity("books", {
// Extra schema fields using Mongoose field definition syntax
additionalFields: {
isbn: { type: String, required: true, unique: true },
pageCount: { type: Number, default: 0 },
rating: { type: Number, min: 0, max: 5 },
},
// Fields to run regex/text search against
searchableFields: ["title_name", "description", "isbn"],
// Soft delete behaviour (default: true)
softDelete: true,
// Default query settings
defaultSort: "created_at",
defaultOrder: "desc",
defaultLimit: 20,
// Override the MongoDB collection name
collectionName: "library_books",
// Parent relations (adds required foreignKey to the schema)
parents: [
{
entity: () => libraryEntity,
type: "parent",
foreignKey: "libraryId",
},
],
// Sister relations (adds required foreignKey to the schema)
sisters: [
{
entity: () => authorEntity,
type: "sister",
foreignKey: "authorId",
},
],
// Declares which entities are children of this one
children: [
{
entity: () => chaptersEntity,
foreignKey: "bookId", // field on the child pointing to this entity's UUID
alias: "chapters", // key under which paginated results appear
},
],
});Query Parameters
All GET endpoints accept these query parameters:
Pagination & Sorting
?page=2&limit=10&sort=title_name&order=ascField Projection
?fields=uuid,title_name,status,created_atArbitrary Filter
?filter[status]=active&filter[owned_by]=some-uuidSearch
GET /books/search?q=tolkien&searchFields=title_name,descriptionChildren
# Include all registered children
GET /books/:id/children?includeChildren=true
# Include specific children only
GET /books/:id/children?includeChildren=chapters,reviews
# Control depth (fetch children of children)
GET /books/:id/children?includeChildren=true&childDepth=2
# Per-child pagination
GET /books/:id/children?includeChildren=chapters&childPage[chapters]=2&childLimit[chapters]=5&childSort[chapters]=created_at&childOrder[chapters]=ascService Layer
The service exposes all the same logic for use in your own business code:
const bookService = booksEntity.service;
// Paginated list
const { data, pagination } = await bookService.all({ page: 1, limit: 10, sort: "title_name" });
// By ID (UUID or _id)
const book = await bookService.findById("some-uuid");
// Find by any field
const results = await bookService.findBy("status", "active", { page: 1, limit: 5 });
// Search
const found = await bookService.search("tolkien", { searchFields: ["title_name"] });
// Create
const book = await bookService.create({ title_name: "The Hobbit", libraryId: "lib-uuid" });
// Bulk create
const books = await bookService.createMany([{ title_name: "..." }, { title_name: "..." }]);
// Update
const updated = await bookService.update("book-uuid", { status: "inactive" });
// Update a single field (dot-notation safe)
await bookService.updateField("book-uuid", "extra_data.0.tokenName", "NewName");
// Soft delete
await bookService.delete("book-uuid");
// Hard delete
await bookService.delete("book-uuid", { soft: false });
// Restore
await bookService.restore("book-uuid");
// Count
const total = await bookService.count({ status: "active" });
// Existence
const exists = await bookService.exists({ isbn: "978-3-16-148410-0" });
// Record with children
const library = await libraryEntity.service.withChildren("lib-uuid", {
includeChildren: ["books"],
childDepth: 2,
childPagination: { books: { page: 1, limit: 10 } },
});Lifecycle Events
Subscribe to entity events using trigger(). Events fire after the relevant database operation completes.
Available Event Types
| Pattern | Fires when |
|---------|-----------|
| create | A new document is created |
| update | Any field on a document changes |
| update.fieldName | The specific field fieldName changes |
| update.extra_data[*] | Any element in the extra_data array changes |
| update.extra_data[tokenName].Zugacoin | The array element with tokenName === "Zugacoin" changes |
| delete | A document is deleted |
| restore | A soft-deleted document is restored |
// New document
booksEntity.trigger(["create"], (whatWas, whatIs, book) => {
sendEmail(book.owned_by, `New book added: ${book.title_name}`);
});
// Any update
booksEntity.trigger(["update"], (whatWas, whatIs, book) => {
const changedFields = Object.keys(whatIs);
console.log(`Fields changed: ${changedFields.join(", ")}`);
});
// Specific field change
booksEntity.trigger(["update.status"], (whatWas, whatIs, book) => {
console.log(`Status changed from ${whatWas.status} to ${whatIs.status}`);
});
// Array element change (any element)
booksEntity.trigger(["update.extra_data[*]"], (whatWas, whatIs, book) => {
console.log("A token in extra_data was modified");
});
// Specific named array element
booksEntity.trigger(["update.extra_data[tokenName].Zugacoin"], (whatWas, whatIs, book) => {
console.log("Zugacoin token data changed", whatWas, whatIs);
});
// Multiple events in one subscription
booksEntity.trigger(["create", "update"], (whatWas, whatIs, book) => {
rebuildSearchIndex(book.uuid);
});
// Deletion
booksEntity.trigger(["delete"], (whatWas, _whatIs, book) => {
console.log("Deleted:", book.uuid);
});Callbacks may be async. Errors inside callbacks are caught and logged — they do not interrupt the HTTP response.
Interceptors (Middleware / RBAC)
Mount middleware that runs before specific controller actions:
// Auth guard on delete
booksEntity.intercept("delete", (req, res, next) => {
if (!req.user?.isAdmin) {
return res.status(403).json({ message: "Forbidden" });
}
next();
});
// Scope reads to the current user
booksEntity.intercept("read", (req, res, next) => {
req.query["filter[owned_by]"] = req.user.uuid;
next();
});
// Multiple actions at once
booksEntity.intercept(["create", "update"], async (req, res, next) => {
req.body.updated_by = req.user?.uuid;
next();
});
// All actions
booksEntity.intercept("all", (req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
next();
});Available action values: "create", "read", "update", "delete", "all".
Multiple interceptors for the same action run in registration order.
Relations
Parent (owned-by)
// chapters requires bookId on create
const chaptersEntity = new MetaEntity("chapters", {
parents: [
{ entity: () => booksEntity, type: "parent", foreignKey: "bookId" }
],
});
// Creating a chapter requires bookId in the body
await chaptersEntity.service.create({ title_name: "Chapter 1", bookId: "book-uuid" });Children (populated on parent fetch)
const booksEntity = new MetaEntity("books", {
children: [
{ entity: () => chaptersEntity, foreignKey: "bookId", alias: "chapters" },
{ entity: () => reviewsEntity, foreignKey: "bookId", alias: "reviews" },
],
});
// Via service
const book = await booksEntity.service.withChildren("book-uuid", {
includeChildren: ["chapters"],
childDepth: 1,
childPagination: { chapters: { page: 1, limit: 10, sort: "created_at" } },
});
// Via HTTP
// GET /books/:id/children?includeChildren=chapters&childPage[chapters]=2&childLimit[chapters]=5Sister (peer relation)
const profileEntity = new MetaEntity("profiles", {
sisters: [
{ entity: () => userEntity, type: "sister", foreignKey: "userId" }
],
});Updating Nested Fields
The PATCH /:id/field/:field endpoint and updateField() method accept dot-notation paths:
# Update a top-level field
PATCH /books/:id/field/status
Body: { "value": "inactive" }
# Update a nested object field
PATCH /books/:id/field/extra_data.0.tokenName
Body: { "value": "NewTokenName" }await bookService.updateField(id, "extra_data.0.minProcessingAmount", 50);TypeScript
The package ships full .d.ts declarations. When building with TypeScript you can type your entities:
import { MetaEntity, BaseEntityDocument } from "@ubyjerome/meta-manager";
interface BookDocument extends BaseEntityDocument {
isbn: string;
pageCount: number;
libraryId: string;
}
const booksEntity = new MetaEntity<BookDocument>("books", { ... });
// service is fully typed as IMetaService<BookDocument>
const book = await booksEntity.service.findById("uuid");
// book is BookDocument | nullMongoDB Connection Requirement
MetaEntity checks for an active (connected or connecting) Mongoose connection at instantiation time and throws immediately if none exists. Always call mongoose.connect() before creating entity instances.
await mongoose.connect(process.env.MONGO_URI!);
// Safe to create entities now
const booksEntity = new MetaEntity("books", { ... });