@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
Maintainers
Readme
@alis-kit/mongoose
Decorator-based MongoDB schema & repository library with a fluent query builder, soft delete, TTL, custom indexes, and auto-populate relations.
Table of Contents
- Features
- Requirements
- Installation
- Quick Start
- Auto-Populate Relations
- CRUD Operations
- Paginated Search
- Custom Indexes
- TTL (Time-To-Live)
- Query Operations Reference
- BaseEntity Fields
- Real-World Usage
- Complete Example
- Testing
- Architecture
- License
Features
- Native TC39 Decorators — no
reflect-metadata, noexperimentalDecorators - Core Architecture — pure logic in
src/core/, decorators are thin wrappers - Zero
any— fully typed codebase - MongoDB Connection —
MongoConnectionutility with lifecycle callbacks - Decorator-based Schema —
@Schema,@VirtualField,@Repository - Auto-Populate Relations —
@Relationdecorator for automatic$lookup - Fluent Query Builder — incremental condition building with
$lookupsupport - Pageable — built-in pagination with
PageableandPageResult - Soft Delete —
softDelete(),restore(), auto-filtering - Hard Delete —
delete()/hardDelete()for permanent removal - TTL (Time-To-Live) —
@TTLdecorator + per-documentexpireAt - Custom Indexes —
@Indexdecorator for unique, compound, text, and geospatial indexes - Zod Integration —
BaseEntitySchemafor runtime validation - Audit Fields —
createdBy,updatedBy,deletedBywith actor tracking - JSDoc + Examples — every exported function is documented
Requirements
- Node.js >= 20.19.0
- TypeScript >= 5.6
Installation
npm install @alis-kit/mongooseQuick 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-metadataimport 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, extendBaseEntityyourself 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
AppBaseEntityinstead ofBaseEntityin domain entities:@Schema('users', { timestamps: true }) class User extends AppBaseEntity { // ← your custom base @Index({ unique: true }) email!: string; }Now every
Userdocument automatically includestenantId,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
$lookupnatively handleslocalFieldas an array — it matches each array element againstforeignField(_idby default). No$unwindis 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$lookupfor 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 lintArchitecture
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.tsRules:
- All logic lives in
core/— pure functions, no decorators - Decorators only call
core/— no logic inside decorators - Runtime reflection via
Mapregistries — noreflect-metadata
License
MIT
