Configure once. Query everywhere. Keep the API small. Keep MongoDB powerful.
kilic.db is a compact command layer over Mongoose. It gives everyday database work a clean shape without hiding the native MongoDB/Mongoose escape hatches.
const db = require("kilic.db");
await db.create("User", { id: "u_1", email: "ada@example.com" });
const user = await db.get("User", { id: "u_1" });
await db.update("User", { $inc: { loginCount: 1 } }, { id: "u_1" });
const revenue = await db.aggregate("Order", [
{ $match: { status: "paid" } },
{ $group: { _id: "$currency", total: { $sum: "$amount" } } },
]);| You want | kilic.db gives you |
|---|---|
| One database setup | db.config() once, then use it anywhere |
| Clear create/update semantics | create() inserts once, update() changes existing data |
| Small surface area | A focused command set instead of a giant wrapper |
| Safe defaults | Write methods reject empty filters and empty payloads |
| Real MongoDB power | First-class aggregate() plus raw db.model() |
| TypeScript without ceremony | Generic return types where they matter |
npm install kilic.db mongoosemongoose is a peer dependency, so your app owns the actual Mongoose version.
archiver is installed with kilic.db and is used internally by db.backup().
const db = require("kilic.db");
const path = require("path");
db.config({
url: "mongodb://localhost:27017/myapp",
path: path.join(__dirname, "models"),
backupDir: path.join(__dirname, "backups"),
debug: true,
});config() starts the connection in the background. Mongoose buffers commands while the connection is opening.
Need an explicit boot barrier?
await db.ready();Config options:
| Option | Type | Purpose |
|---|---|---|
url |
string |
MongoDB connection string |
options |
ConnectOptions |
Options passed to mongoose.connect() |
path |
string |
Directory for auto-loading model files |
backupDir |
string |
Default output directory for db.backup() |
debug |
boolean |
Print small kilic.db lifecycle logs |
| Command | Reads like | Supports |
|---|---|---|
config(options) |
connect and configure | url, path, Mongoose connect options |
ready() |
wait for connection | startup checks |
create(model, data, options?) |
create once | single data, array data, custom filters |
get(model, filter, options?) |
read one | projection, populate, session, lean control |
update(model, data, filter?, options?) |
update data | single update, array updates, multi |
delete(model, filter, options?) |
delete data | single filter, array filters, multi |
find(model, filter?, options?) |
read many | projection, sort, skip, limit, populate, cursor |
count(model, filter?, options?) |
count many | filtered counts |
aggregate(model, stages, options?) |
run pipeline | full MongoDB aggregation |
backup(options?) |
zip a database backup | EJSON collection dumps, metadata, dated zip files |
model(model) |
escape hatch | raw Mongoose model access |
create() means “create this logical document once.” It uses an atomic upsert with $setOnInsert, so existing documents are not overwritten.
await db.create("User", {
id: "u_1",
email: "ada@example.com",
name: "Ada",
});Without id, provide the identity filter:
await db.create(
"User",
{ email: "ada@example.com", name: "Ada" },
{ filter: { email: "ada@example.com" } }
);Create many by passing an array:
await db.create("User", [
{ id: "u_1", email: "ada@example.com" },
{ id: "u_2", email: "grace@example.com" },
]);Create many with a filter resolver:
await db.create("User", users, {
filter: (user) => ({ email: user.email }),
});Create many with a filter array:
await db.create("User", users, {
filter: users.map((user) => ({ email: user.email })),
});Array data cannot share one static filter object. This is blocked on purpose:
// Throws: every item would target the same document.
await db.create("User", users, {
filter: { email: "ada@example.com" },
});Use a resolver function or a filter array so every item has its own identity:
await db.create("User", users, {
filter: (user) => ({ email: user.email }),
});| Guardrail | Why it exists |
|---|---|
| Empty data is rejected | A create command should create meaningful data |
| Update operators are rejected | $inc, $push, $set belong in update() |
| Shared static filters are rejected for array data | Prevents many items from writing the same document |
| Duplicate key races return existing docs when possible | Startup and request flows stay idempotent |
const user = await db.get("User", { id: "u_1" });const publicUser = await db.get("User", { id: "u_1" }, {
projection: { password: 0, token: 0 },
});const post = await db.get("Post", { id: "p_1" }, {
populate: "author",
});Lean objects are returned by default. Ask for a Mongoose document when you need document methods:
const userDoc = await db.get("User", { id: "u_1" }, {
lean: false,
});Plain objects become $set updates:
await db.update("User", { name: "Grace" }, { id: "u_1" });MongoDB update operators pass through:
await db.update("User", { $inc: { loginCount: 1 } }, { id: "u_1" });Update many matching documents with one payload:
await db.update("User", { archived: true }, { active: false }, {
multi: true,
});Update many documents with different payloads:
await db.update("User", [
{ id: "u_1", name: "Ada" },
{ id: "u_2", name: "Grace" },
]);Use a filter resolver when your identity field is not id:
await db.update("User", users, (user) => ({ email: user.email }));Array updates also reject one shared filter object:
// Throws: every update would target the same user.
await db.update("User", users, { email: "ada@example.com" });When multi: true is used, update() returns counts instead of a document:
const result = await db.update("User", { archived: true }, { active: false }, {
multi: true,
});
console.log(result.matchedCount, result.modifiedCount);Delete one:
await db.delete("Session", { token: "session_token" });Delete many matching one filter:
await db.delete("Session", { expired: true }, { multi: true });Delete multiple independent filters:
await db.delete("Session", [
{ token: "token_1" },
{ token: "token_2" },
]);delete() returns { success, deletedCount }.
const users = await db.find("User", { active: true }, {
projection: { password: 0 },
sort: { createdAt: -1 },
skip: 20,
limit: 10,
populate: "team",
});By default, find() returns an array. That is perfect for normal lists and paginated screens.
For huge datasets, do not load everything into memory. Use cursor mode:
const cursor = await db.find("Log", { level: "error" }, {
cursor: true,
sort: { createdAt: 1 },
cursorOptions: { batchSize: 500 },
});
for await (const log of cursor) {
// process one document at a time
}Cursor mode returns a Mongoose async iterable instead of an array. It is the right path for exports, migrations, backfills, and large reporting jobs.
For even more control, raw Mongoose is still available:
const cursor = db.model("Log").find({ level: "error" }).cursor();const activeUsers = await db.count("User", { active: true });Need a metadata-based estimate?
const totalUsers = await db.model("User").estimatedDocumentCount();Aggregation is a core MongoDB feature, so it is first-class here.
const leaderboard = await db.aggregate("Score", [
{ $match: { season: "2026" } },
{ $group: { _id: "$userId", total: { $sum: "$points" } } },
{ $sort: { total: -1 } },
{ $limit: 10 },
], {
allowDiskUse: true,
});Sessions work too:
const session = await db.mongoose.startSession();
const rows = await db.aggregate("Order", [
{ $match: { status: "paid" } },
{ $group: { _id: "$userId", revenue: { $sum: "$amount" } } },
], { session });Use the real pipeline stages: $lookup, $unwind, $facet, $project, $bucket, $graphLookup, and everything else MongoDB supports through Mongoose.
Create a dated zip backup of every collection:
const backup = await db.backup();
console.log(backup.file);Set a default backup directory in config:
db.config({
url: "mongodb://localhost:27017/myapp",
backupDir: path.join(__dirname, "backups"),
});Or override it for one run:
await db.backup({
backupDir: "/var/backups/myapp",
batchSize: 500,
});Use a custom file id when you want a stable name:
await db.backup({
id: "before-migration",
});backup() returns:
{
success: true,
id: "kilic-db-2026-05-21T16-30-00-000Z",
file: "/app/backups/kilic-db-2026-05-21T16-30-00-000Z.zip",
directory: "/app/backups",
database: "myapp",
collections: [
{ collection: "users", count: 42, file: "users.json" },
],
size: 12480,
createdAt: "2026-05-21T16:30:00.000Z",
}The zip contains one EJSON .json dump per collection plus __meta__.json. Backups are logical JSON exports, not a replacement for MongoDB's native mongodump archive format. For very large databases, native MongoDB tooling is still the safer operational choice.
The wrapper stays small on purpose. When you need full Mongoose, take the model:
const User = db.model("User");
await User.bulkWrite([
{
updateOne: {
filter: { id: "u_1" },
update: { $set: { role: "admin" } },
},
},
]);Raw access is also available for sessions, plugins, transactions, and connection events:
const session = await db.mongoose.startSession();
db.connection.on("disconnected", () => {
console.warn("MongoDB disconnected");
});Register models yourself:
mongoose.model("User", userSchema);Or let kilic.db load model files from config.path:
models/
User.js
Post.js
Order.js
Each file should export a Mongoose model:
module.exports = mongoose.model("User", userSchema);Default exports are supported.
Model names are resolved only inside config.path; path traversal strings such as "../User" are ignored.
import db from "kilic.db";
interface User {
id: string;
email: string;
name: string;
}
const user = await db.get<User>("User", { id: "u_1" });
const users = await db.find<User>("User", { active: true });
const created = await db.create<User>("User", {
id: "u_2",
email: "grace@example.com",
});Typed aggregation results:
interface RevenueRow {
_id: string;
total: number;
}
const rows = await db.aggregate<RevenueRow>("Order", [
{ $group: { _id: "$currency", total: { $sum: "$amount" } } },
]);Typed backup results:
const backup = await db.backup({
backupDir: "./backups",
});
backup.collections.forEach((item) => {
console.log(item.collection, item.count);
});All wrapper errors are KilicError instances with a stable code field:
try {
await db.delete("User", {});
} catch (err) {
console.log(err.code);
console.log(err.message);
}Example message:
[kilic.db:MISSING_FILTER]
delete() requires a non-empty filter.
Mongoose duplicate key, validation, and cast errors are normalized with hints and details while preserving originalError.
| Operation | Guardrail |
|---|---|
create() |
Rejects empty data and update operators |
create() |
Uses $setOnInsert so existing documents are not overwritten |
create(array) |
Rejects one shared filter object; use a resolver or filter array |
update() |
Uses data.id or a non-empty filter |
update(array) |
Rejects one shared filter object; use a resolver or filter array |
update({ multi: true }) |
Requires one explicit filter object |
delete() |
Requires non-empty filters |
find({ cursor: true }) |
Streams results instead of building a huge array |
aggregate() |
Requires an array pipeline |
backup() |
Writes to a temporary folder first, then zips and cleans it up |
These guardrails are not a security product. They are boring defaults that prevent the common foot-guns.
await db.create("User", {
id: externalUser.id,
email: externalUser.email,
provider: "github",
});await db.create("Customer", customers, {
filter: (customer) => ({ externalId: customer.externalId }),
});
await db.update("Customer", customers, (customer) => ({
externalId: customer.externalId,
}));const cursor = await db.find("Event", { type: "purchase" }, {
cursor: true,
sort: { createdAt: 1 },
});
for await (const event of cursor) {
await writeToExport(event);
}await db.update(
"User",
{ archived: true },
{ lastLoginAt: { $lt: new Date("2025-01-01") } },
{ multi: true }
);const stats = await db.aggregate("Order", [
{ $match: { status: "paid" } },
{
$group: {
_id: {
day: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },
},
orders: { $sum: 1 },
revenue: { $sum: "$amount" },
},
},
{ $sort: { "_id.day": 1 } },
], { allowDiskUse: true });kilic.db is not trying to replace Mongoose. It is the small layer you write when you are tired of repeating the same database ceremony across routes, services, jobs, and scripts.
create create one or many logical documents once
get read one document
update update one, array data, or many with multi
delete delete one, array filters, or many with multi
find read many documents
count count matching documents
aggregate run a MongoDB pipeline
backup create a dated EJSON zip backup
model use raw Mongoose
If a feature is common and benefits from a clear command, it belongs here. If a feature is broad, rare, or deeply Mongo-specific, db.model() keeps it one line away.
MIT © kilicdev