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

@alis-kit/mongoose

v1.3.1

Published

Decorator-based MongoDB schema & repository library with fluent query builder, soft delete, TTL, and auto-populate relations

Downloads

797

Readme

@alis-kit/mongoose

Decorator-based MongoDB schema & repository library with a fluent query builder, soft delete, TTL, custom indexes, and auto-populate relations.

npm version license


Table of Contents


Features

  • Native TC39 Decorators — no reflect-metadata, no experimentalDecorators
  • Core Architecture — pure logic in src/core/, decorators are thin wrappers
  • Zero any — fully typed codebase
  • MongoDB ConnectionMongoConnection utility with lifecycle callbacks
  • Decorator-based Schema@Schema, @VirtualField, @Repository
  • Auto-Populate Relations@Relation decorator for automatic $lookup
  • Fluent Query Builder — incremental condition building with $lookup support
  • Pageable — built-in pagination with Pageable and PageResult
  • Soft DeletesoftDelete(), restore(), auto-filtering
  • Hard Deletedelete() / hardDelete() for permanent removal
  • TTL (Time-To-Live)@TTL decorator + per-document expireAt
  • Custom Indexes@Index decorator for unique, compound, text, and geospatial indexes
  • Zod IntegrationBaseEntitySchema for runtime validation
  • Audit FieldscreatedBy, updatedBy, deletedBy with actor tracking
  • JSDoc + Examples — every exported function is documented

Requirements

  • Node.js >= 20.19.0
  • TypeScript >= 5.6

Installation

npm install @alis-kit/mongoose

Quick Start

import {
  MongoConnection,
  Schema, VirtualField, Repository, Relation, Index, TTL,
  BaseRepository, BaseEntity, BaseEntitySchema,
  CustomBuilder, SearchCustom, MultipleSearch, CustomOperation,
  Pageable
} from "@alis-kit/mongoose";
import { z } from "zod";

No reflect-metadata import needed.

1. Connect to MongoDB

// Simple connection
await MongoConnection.connect({
  uri: "mongodb://localhost:27017/mydb",
});

// With full options
await MongoConnection.connect({
  uri: process.env.MONGODB_URI!,
  debug: process.env.NODE_ENV === "development",
  options: {
    maxPoolSize: 10,
    serverSelectionTimeoutMS: 5000,
  },
  onConnected: () => console.log("MongoDB connected"),
  onError: (err) => console.error("MongoDB error:", err),
  onDisconnected: () => console.log("MongoDB disconnected"),
});

// Check connection state
console.log(MongoConnection.isConnected()); // true
console.log(MongoConnection.getState());    // 'connected'

2. Define an Entity

@Schema({ collection: "products", timestamps: true })
class Product extends BaseEntity {
  name!: string;
  price!: number;
}

💡 Tip — Create your own base entity with shared fields If you want every entity to automatically include fields like tenantId, createdBy, or soft-delete markers, extend BaseEntity yourself first:

import { BaseEntity } from '@alis-kit/mongoose';

/** Your app-wide base entity — common fields for all entities */
export abstract class AppBaseEntity extends BaseEntity {
  tenantId!: string;
  createdBy!: string;
  updatedBy?: string;
  deletedAt?: Date;
  deletedBy?: string;
}

Then use your AppBaseEntity instead of BaseEntity in domain entities:

@Schema('users', { timestamps: true })
class User extends AppBaseEntity {    // ← your custom base
  @Index({ unique: true })
  email!: string;
}

Now every User document automatically includes tenantId, createdBy, updatedBy, and soft-delete fields — no repetition needed. The audit hooks (pre-save / pre-remove) read these fields automatically; just set them before saving.

You can also combine multiple decorators — indexes, virtual fields, and relations — on a single entity:

@Index({ email: 1 }, { unique: true })
@Index({ firstName: "text", lastName: "text" })
@Schema({ collection: "users", timestamps: true })
class User extends BaseEntity {
  @VirtualField((doc) => `${doc.firstName} ${doc.lastName}`)
  fullName: string;

  firstName: string;
  lastName: string;
  email: string;
  age: number;

  // Auto-populate: profile will be automatically joined on every query
  @Relation({ collection: "profiles", localField: "profileId" })
  profile: IProfile | null;
}

// TTL example — sessions expire 30 days after creation
@TTL("createdAt", 2592000)
@Schema({ collection: "sessions", timestamps: true })
class Session extends BaseEntity {
  name!: string;
}

3. Define Zod Schemas & Types

const IProfileSchema = BaseEntitySchema.extend({
  nama: z.string(),
  city: z.string(),
});
type IProfile = z.infer<typeof IProfileSchema>;

const IUserSchema = BaseEntitySchema.extend({
  firstName: z.string(),
  lastName: z.string(),
  fullName: z.string(),
  email: z.string(),
  age: z.number(),
});
type IUser = z.infer<typeof IUserSchema>;

4. Create a Repository

@Repository(User)
class UserRepository extends BaseRepository<IUser> {

  async findByName(name: string): Promise<IUser[]> {
    const builder = new CustomBuilder<IUser>()
      .with(SearchCustom.of("firstName", CustomOperation.LIKE, name));
    return this.find(builder.build());
  }

  async search(filter: {
    name?: string;
    city?: string;
    minAge?: number;
    maxAge?: number;
  }, page: number, size: number) {
    const builder = new CustomBuilder<IUser>();

    if (filter.name) {
      builder.with(SearchCustom.of("firstName", CustomOperation.LIKE, filter.name));
    }

    if (filter.city) {
      builder.with(
        SearchCustom.of("user.profile.city", CustomOperation.OPERATION_JOIN_EQUAL, filter.city)
      );
    }

    if (filter.minAge !== undefined && filter.maxAge !== undefined) {
      builder.with(
        MultipleSearch.of(
          SearchCustom.OPERATION_AND,
          SearchCustom.of("age", CustomOperation.GTE, filter.minAge),
          SearchCustom.of("age", CustomOperation.LTE, filter.maxAge)
        )
      );
    }

    return this.findAll(builder.build(), Pageable.of(page, size));
  }
}

Auto-Populate Relations

const userRepo = new UserRepository();

// findById — profile auto-populated!
const user = await userRepo.findById("some-id");
// {
//   _id: "some-id",
//   firstName: "Dudi",
//   profile: { _id: "...", nama: "Dudi S", city: "Jakarta" },
//   ...
// }

// find() — all users with profiles
const users = await userRepo.find();
// [{ _id: "...", firstName: "Dudi", profile: { ... } }, ...]

// findAll() with pagination — profiles also included
const paged = await userRepo.findAll(undefined, Pageable.of(1, 10));
// { content: [{ profile: { ... }, ... }], page: 1, total: 42, ... }

@Relation Options

// belongsTo (default) — result: single object or null
@Relation({ collection: "profiles", localField: "profileId" })
profile: IProfile | null;

// hasMany — result: array (inverse lookup)
@Relation({
  collection: "orders",
  localField: "_id",
  foreignField: "userId",
  type: "hasMany"
})
orders: IOrder[];

// hasManyRefs — result: array (field stores array of _id references)
@Relation({
  collection: "tags",
  localField: "tagIds",
  type: "hasManyRefs"   // foreignField defaults to "_id"
})
tags: ITag[];

belongsTo (Many-to-One)

// User has ONE profile → profileId stores the Profile._id
@Schema({ collection: "users" })
class User extends BaseEntity {
  firstName: string;

  @Relation({ collection: "profiles", localField: "profileId" })
  profile: IProfile | null;
}

// Usage — profile auto-populated on ALL queries:
const user = await userRepo.findById("user123");
// user.profile = { _id: "...", nama: "Dudi", city: "Jakarta" }  ← auto!

const users = await userRepo.find();
// users[0].profile = { ... }  ← auto!

const paged = await userRepo.findAll(undefined, Pageable.of(1, 10));
// paged.content[0].profile = { ... }  ← auto!

hasMany (One-to-Many)

// User has MANY orders → Order.userId references User._id
@Schema({ collection: "users" })
class User extends BaseEntity {
  firstName: string;

  @Relation({
    collection: "orders",
    localField: "_id",           // match User._id
    foreignField: "userId",      // against Order.userId
    type: "hasMany",
  })
  orders: IOrder[];              // ← array of orders
}

// Usage:
const user = await userRepo.findById("user123");
// user.orders = [{ _id: "...", total: 150000 }, { _id: "...", total: 80000 }]

hasManyRefs (Many-to-Many via Embedded IDs)

// Post has MANY tags → post.tagIds stores an array of Tag._id values
@Schema({ collection: "posts" })
class Post extends BaseEntity {
  title: string;
  content: string;

  @Relation({
    collection: "tags",
    localField: "tagIds",       // Post.tagIds is an array of _id strings
    type: "hasManyRefs",        // no foreignField needed — defaults to '_id'
  })
  tags: ITag[];                 // ← populated into an array of full tag objects
}

// Usage:
const post = await postRepo.findById("post123");
// post.tags = [{ _id: "...", name: "news" }, { _id: "...", name: "tech" }]

💡 MongoDB's $lookup natively handles localField as an array — it matches each array element against foreignField (_id by default). No $unwind is applied, so the result is always an array.

Multiple Relations on One Entity

@Schema({ collection: "users" })
class User extends BaseEntity {
  firstName: string;
  lastName: string;

  // belongsTo profile
  @Relation({ collection: "profiles", localField: "profileId" })
  profile: IProfile | null;

  // belongsTo department
  @Relation({ collection: "departments", localField: "departmentId" })
  department: IDepartment | null;

  // hasMany orders
  @Relation({ collection: "orders", localField: "_id", foreignField: "userId", type: "hasMany" })
  orders: IOrder[];
}

// ALL three relations auto-populated on every query:
const user = await userRepo.findById("user123");
// user.profile     = { nama: "Dudi", city: "Jakarta" }
// user.department  = { name: "Engineering" }
// user.orders      = [{ total: 150000 }, { total: 80000 }]

Relation + Query Builder (Combined)

@Repository(User)
class UserRepository extends BaseRepository<IUser> {

  // @Relation auto-populates profile on the result
  // CustomBuilder adds filtering logic
  async searchByCity(city: string, page: number, size: number) {
    const builder = new CustomBuilder<IUser>()
      .with(SearchCustom.of(
        "user.profile.city",
        CustomOperation.OPERATION_JOIN_EQUAL,
        city
      ));

    return this.findAll(builder.build(), Pageable.of(page, size));
  }
}

Without @Relation

// If you DON'T use @Relation, relations are NOT auto-populated.
// You must explicitly use OPERATION_JOIN_* in CustomBuilder:

@Schema({ collection: "users" })
class User extends BaseEntity {
  firstName: string;
  // No @Relation here — profile NOT auto-populated
}

const userRepo = new UserRepository();

// findById → NO profile data
const user = await userRepo.findById("user123");
// user = { _id: "...", firstName: "Dudi" }  ← no profile!

// To get profile, you must use the builder with a join instead:
const builder = new CustomBuilder<IUser>()
  .with(SearchCustom.of("profileId", CustomOperation.OPERATION_JOIN_EQUAL, "profile_id_value"));

CRUD Operations

Save

// Save with actor
const newUser = await userRepo.save(
  { firstName: "Dudi", lastName: "Setiawan", email: "[email protected]", age: 25, profileId: "profile_id" },
  { actorId: "admin_user_id" }
);

// Save from system — createdBy/updatedBy will be null
const systemUser = await userRepo.save(
  { firstName: "System", lastName: "Bot", email: "[email protected]", age: 0 }
);

// Save with TTL — document expires in 1 hour
const tempUser = await userRepo.save(
  { firstName: "Temp", lastName: "User", email: "[email protected]", age: 0 },
  { ttl: 3600 }
);

// Save with exact expiry date
const scheduledUser = await userRepo.save(
  { firstName: "Scheduled", lastName: "User", email: "[email protected]", age: 0 },
  { expireAt: new Date("2025-12-31T23:59:59Z") }
);

Update

await userRepo.update(newUser._id, { age: 26 }, { actorId: "admin_user_id" });

Soft Delete & Restore

// Soft delete — document hidden from standard queries
await userRepo.softDelete(newUser._id, { actorId: "admin_user_id" });

// Find only soft-deleted documents (with relations auto-populated)
const deleted = await userRepo.findOnlyDeleted();

// Include soft-deleted in queries (with relations auto-populated)
const all = await userRepo.findWithDeleted();

// Restore a soft-deleted document
await userRepo.restore(newUser._id, { actorId: "admin_user_id" });

Hard Delete

// Permanently remove from database (irreversible)
await userRepo.delete(newUser._id);
// or
await userRepo.hardDelete(newUser._id);

Paginated Search

const result = await userRepo.search(
  { city: "Jakarta", minAge: 18, maxAge: 35 },
  1,
  10
);
// {
//   content: [{ _id: "...", firstName: "Dudi", profile: { ... }, ... }],
//   page: 1,
//   total: 12,
//   ...
// }

Custom Indexes

// Unique index
@Index({ email: 1 }, { unique: true })

// Compound index
@Index({ category: 1, price: -1 })

// Text search index
@Index({ firstName: "text", lastName: "text" })

// Geospatial index
@Index({ location: "2dsphere" })

// Sparse index
@Index({ optionalField: 1 }, { sparse: true })

TTL (Time-To-Live)

Entity-level TTL

// Documents expire 24 hours after creation
@TTL("createdAt", 86400)
@Schema({ collection: "otps" })
class OTP extends BaseEntity {
  code!: string;
  userId!: string;
}

Per-document TTL

// Expires in 5 minutes
await otpRepo.save({ code: "123456", userId: "user1" }, { ttl: 300 });

// Expires at a specific date
await otpRepo.save({ code: "789012", userId: "user2" }, {
  expireAt: new Date("2025-06-01T00:00:00Z")
});

MongoDB's background thread checks TTL indexes every ~60 seconds and removes expired documents automatically.


Query Operations Reference

| Operation | Description | MongoDB Equivalent | |-----------|-------------|---------------------| | EQUAL | Exact match | { field: value } | | NOT_EQUAL | Not equal | { $ne: value } | | GT | Greater than | { $gt: value } | | GTE | Greater than or equal | { $gte: value } | | LT | Less than | { $lt: value } | | LTE | Less than or equal | { $lte: value } | | LIKE | Case-insensitive contains | { $regex: value, $options: 'i' } | | STARTS_WITH | Starts with | { $regex: '^value' } | | ENDS_WITH | Ends with | { $regex: 'value$' } | | IN | In array | { $in: [values] } | | NOT_IN | Not in array | { $nin: [values] } | | IS_NULL | Is null | { field: null } | | IS_NOT_NULL | Is not null | { $ne: null } | | EXISTS | Field exists | { $exists: true } | | NOT_EXISTS | Field doesn't exist | { $exists: false } |

All operations also have OPERATION_JOIN_* variants that trigger $lookup for cross-collection queries.


BaseEntity Fields

| Field | Type | Description | |-------|------|--------------| | _id | string | MongoDB document ID | | createdAt | Date | Auto-managed by Mongoose | | updatedAt | Date | Auto-managed by Mongoose | | createdBy | string \| null | Set via actorId on save | | updatedBy | string \| null | Set via actorId on save/update | | deletedAt | Date \| null | Set on soft delete, null = active | | deletedBy | string \| null | Set on soft delete | | expireAt | Date \| null | TTL expiry date |


Real-World Usage

Express / NestJS App Bootstrap

// src/database.ts
import { MongoConnection } from "@alis-kit/mongoose";

export async function connectDatabase() {
  await MongoConnection.connect({
    uri: process.env.MONGODB_URI || "mongodb://localhost:27017/myapp",
    debug: process.env.NODE_ENV === "development",
    options: {
      maxPoolSize: 10,
      minPoolSize: 2,
      serverSelectionTimeoutMS: 5000,
      socketTimeoutMS: 45000,
    },
    onConnected: () => console.log("MongoDB connected"),
    onError: (err) => console.error("MongoDB error:", err.message),
    onDisconnected: () => console.log("MongoDB disconnected"),
  });
}

// src/app.ts
import express from "express";
import { connectDatabase } from "./database";
import { MongoConnection } from "@alis-kit/mongoose";

const app = express();

// Connect before starting server
connectDatabase().then(() => {
  app.listen(3000, () => console.log("Server running on :3000"));
});

// Health check endpoint
app.get("/health", (req, res) => {
  res.json({
    db: MongoConnection.isConnected(),
    dbState: MongoConnection.getState(),
  });
});

// Graceful shutdown
process.on("SIGTERM", async () => {
  await MongoConnection.disconnect();
  process.exit(0);
});

Complete Example

An end-to-end e-commerce example combining entities, relations, TTL, indexes, and repositories.

import {
  MongoConnection, Schema, VirtualField, Repository, Relation,
  Index, TTL, BaseRepository, BaseEntity, BaseEntitySchema,
  CustomBuilder, SearchCustom, CustomOperation, Pageable
} from "@alis-kit/mongoose";
import { z } from "zod";

// ── Connect ───────────────────────────────────────────────────────
await MongoConnection.connect({ uri: "mongodb://localhost:27017/shop" });

// ── Entities ──────────────────────────────────────────────────────
@Schema({ collection: "categories" })
class Category extends BaseEntity { name: string; }

@Index({ sku: 1 }, { unique: true })
@Index({ name: "text", description: "text" })
@Schema({ collection: "products" })
class Product extends BaseEntity {
  name: string;
  sku: string;
  price: number;
  description: string;

  @Relation({ collection: "categories", localField: "categoryId" })
  category: ICategory | null;
}

@TTL("createdAt", 900)  // OTP expires in 15 minutes
@Schema({ collection: "otps" })
class OTP extends BaseEntity { code: string; userId: string; }

// ── Types ─────────────────────────────────────────────────────────
const ICategorySchema = BaseEntitySchema.extend({
  name: z.string(),
});
type ICategory = z.infer<typeof ICategorySchema>;

const IProductSchema = BaseEntitySchema.extend({
  name: z.string(),
  sku: z.string(),
  price: z.number(),
  description: z.string(),
  category: ICategorySchema.nullable(),
});
type IProduct = z.infer<typeof IProductSchema>;

const IOTPSchema = BaseEntitySchema.extend({
  code: z.string(),
  userId: z.string(),
});
type IOTP = z.infer<typeof IOTPSchema>;

// ── Repositories ──────────────────────────────────────────────────
@Repository(Category)
class CategoryRepository extends BaseRepository<ICategory> {}

@Repository(Product)
class ProductRepository extends BaseRepository<IProduct> {
  async searchProducts(keyword: string, minPrice?: number, maxPrice?: number) {
    const builder = new CustomBuilder<IProduct>();
    builder.with(SearchCustom.of("name", CustomOperation.LIKE, keyword));
    if (minPrice) builder.with(SearchCustom.of("price", CustomOperation.GTE, minPrice));
    if (maxPrice) builder.with(SearchCustom.of("price", CustomOperation.LTE, maxPrice));
    return this.findAll(builder.build(), Pageable.of(1, 20, "price", "asc"));
  }
}

@Repository(OTP)
class OTPRepository extends BaseRepository<IOTP> {}

// ── Usage ─────────────────────────────────────────────────────────
const categoryRepo = new CategoryRepository();
const productRepo = new ProductRepository();
const otpRepo = new OTPRepository();

// Create category
const category = await categoryRepo.save(
  { name: "Electronics" },
  { actorId: "admin_id" }
);

// Create product with TTL
const product = await productRepo.save(
  { name: "Laptop", sku: "LPT-001", price: 15000000, description: "Gaming laptop", categoryId: category._id },
  { actorId: "admin_id" }
);

// findById — category auto-populated!
const found = await productRepo.findById(product._id);
// found.category = { _id: "...", name: "Electronics" }

// Search products
const results = await productRepo.searchProducts("laptop", 1000000, 20000000);

// Create OTP (expires in 15 minutes)
const otp = await otpRepo.save(
  { code: "123456", userId: "user1" },
  { ttl: 900 }
);

// Soft delete
await productRepo.softDelete(product._id, { actorId: "admin_id" });

// Restore
await productRepo.restore(product._id, { actorId: "admin_id" });

// Hard delete (permanent)
await productRepo.hardDelete(product._id);

Testing

# Run all tests
pnpm test

# Watch mode
pnpm test:watch

# Typecheck
pnpm lint

Architecture

This library follows the Functional Core + Decorator Sugar architecture:

src/
├── core/          # Pure functions (logic lives here)
│   ├── registry.ts
│   ├── schema-builder.ts
│   ├── match-expression.ts
│   ├── query-pipeline.ts
│   ├── relation-resolver.ts
│   ├── connection-manager.ts
│   ├── repository-factory.ts
│   ├── audit.ts
│   └── utils.ts
├── decorators/    # Thin wrappers → call core/
│   ├── Schema.ts
│   ├── Repository.ts
│   ├── Index.ts
│   ├── TTL.ts
│   ├── Relation.ts
│   └── VirtualField.ts
├── base/          # Base classes
│   ├── BaseEntity.ts
│   └── BaseRepository.ts
├── builder/       # Query builder (already decoupled)
│   ├── CustomBuilder.ts
│   ├── CustomOperation.ts
│   ├── MultipleSearch.ts
│   └── SearchCustom.ts
└── pageable/      # Pagination
    ├── Pageable.ts
    └── PageResult.ts

Rules:

  • All logic lives in core/ — pure functions, no decorators
  • Decorators only call core/ — no logic inside decorators
  • Runtime reflection via Map registries — no reflect-metadata

License

MIT