npm.io
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, no experimentalDecorators
  • Core Architecture — pure logic in src/core/, decorators as 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 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);
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 Map registries — no reflect-metadata

License

MIT

} __INLINE_CODE_46__ In array __INLINE_CODE_47__ __INLINE_CODE_48__ Not in array __INLINE_CODE_49__ __INLINE_CODE_50__ Is null __INLINE_CODE_51__ __INLINE_CODE_52__ Is not null __INLINE_CODE_53__ __INLINE_CODE_54__ Field exists __INLINE_CODE_55__ __INLINE_CODE_56__ Field doesn't exist __INLINE_CODE_57__

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

Keywords