1.1.0 • Published yesterday
@alis-kit/mongoose
Licence
MIT
Version
1.1.0
Deps
2
Size
213 kB
Vulns
0
Weekly
0
@alis-kit/mongoose
Decorator-based MongoDB schema & repository library with fluent query builder, soft delete, TTL, custom indexes, and auto-populate relations.
Features
- Native TC39 Decorators — no
reflect-metadata, noexperimentalDecorators - Core Architecture — pure logic in
src/core/, decorators as 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 has documentation
Requirements
- Node.js >= 18
- TypeScript >= 5.2
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'
// Disconnect gracefully
await MongoConnection.disconnect();
2. Define Entities
@Schema({ collection: "profiles", timestamps: true })
class Profile extends BaseEntity {
nama: string;
city: string;
}
@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 {
token: string;
userId: 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(),
profile: IProfileSchema.nullable(), // null if not joined
});
type IUser = z.infer<typeof IUserSchema>;
4. Define 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, "createdAt", "desc"));
}
}
5. Usage
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
@Relation({
collection: "orders",
localField: "_id",
foreignField: "userId",
type: "hasMany"
})
orders: IOrder[];
Save
// Save with actor
const newUser = await userRepo.save(
{ firstName: "Dudi", lastName: "Setiawan", email: "dudi@email.com", 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: "system@bot.com", age: 0 }
);
// Save with TTL — document expires in 1 hour
const tempUser = await userRepo.save(
{ firstName: "Temp", lastName: "User", email: "temp@email.com", age: 0 },
{ ttl: 3600 }
);
// Save with exact expiry date
const scheduledUser = await userRepo.save(
{ firstName: "Scheduled", lastName: "User", email: "sched@email.com", 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, size: 10, total: 24, totalPages: 3,
// hasPrev: false, hasNext: true
// }
6. 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 })
@Schema({ collection: "products" })
class Product extends BaseEntity { ... }
7. 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 via expireAt
Every entity has an expireAt field. Set it during save():
// 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.
8. Query Operations
| 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.
9. 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 |
10. Connection — 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);
});
Direct Mongoose Access
// If you need the raw mongoose instance
const mongooseInstance = MongoConnection.getMongoose();
// Use for advanced operations like transactions, etc.
11. Relation — Detailed Usage
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 }]
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));
// Results have profile auto-populated + filtered by city
}
}
Without @Relation (Manual Join via Builder)
// 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 builder with join:
const builder = new CustomBuilder<IUser>()
.with(SearchCustom.of("user.profile.city", CustomOperation.OPERATION_JOIN_EQUAL, "Jakarta"));
const users = await userRepo.find(builder.build());
// Now profile data is in the pipeline via $lookup
12. Complete E-Commerce Example
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);
// Disconnect
await MongoConnection.disconnect();
13. Testing
# Run all tests
pnpm test
# Watch mode
pnpm test:watch
# Typecheck
pnpm lint
# Build
pnpm build
14. 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 in
core/— pure functions, no decorators - Decorators only call
core/— no logic inside - Runtime reflection via
Mapregistries — noreflect-metadata
License
MIT
}All operations also have __INLINE_CODE_58__ variants that trigger __INLINE_CODE_59__ for cross-collection queries.
9. BaseEntity Fields
| Field | Type | Description |
|---|---|---|
| __INLINE_CODE_60__ | __INLINE_CODE_61__ | MongoDB document ID |
| __INLINE_CODE_62__ | __INLINE_CODE_63__ | Auto-managed by Mongoose |
| __INLINE_CODE_64__ | __INLINE_CODE_65__ | Auto-managed by Mongoose |
| __INLINE_CODE_66__ | __INLINE_CODE_67__ | Set via __INLINE_CODE_68__ on save |
| __INLINE_CODE_69__ | __INLINE_CODE_70__ | Set via __INLINE_CODE_71__ on save/update |
| __INLINE_CODE_72__ | __INLINE_CODE_73__ | Set on soft delete, null = active |
| __INLINE_CODE_74__ | __INLINE_CODE_75__ | Set on soft delete |
| __INLINE_CODE_76__ | __INLINE_CODE_77__ | TTL expiry date |
10. Connection — 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);
});
Direct Mongoose Access
// If you need the raw mongoose instance
const mongooseInstance = MongoConnection.getMongoose();
// Use for advanced operations like transactions, etc.
11. Relation — Detailed Usage
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 }]
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));
// Results have profile auto-populated + filtered by city
}
}
Without @Relation (Manual Join via Builder)
// 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 builder with join:
const builder = new CustomBuilder<IUser>()
.with(SearchCustom.of("user.profile.city", CustomOperation.OPERATION_JOIN_EQUAL, "Jakarta"));
const users = await userRepo.find(builder.build());
// Now profile data is in the pipeline via $lookup
12. Complete E-Commerce Example
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);
// Disconnect
await MongoConnection.disconnect();
13. Testing
# Run all tests
pnpm test
# Watch mode
pnpm test:watch
# Typecheck
pnpm lint
# Build
pnpm build
14. 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 in __INLINE_CODE_78__ — pure functions, no decorators
- Decorators only call __INLINE_CODE_79__ — no logic inside
- Runtime reflection via __INLINE_CODE_80__ registries — no __INLINE_CODE_81__
License
MIT