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 while keeping native MongoDB/Mongoose escape hatches nearby.
const db = require("kilic.db");
await db.create("User", { id: "u_1", email: "[email protected]" });
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.dbNode.js 20.19.0 or newer is required.
mongoose, archiver, and mongodb-memory-server-core are optional dependencies installed with kilic.db by default. If your package manager skips optional dependencies, install them explicitly or reinstall with optional dependencies enabled.
const db = require("kilic.db");
const path = require("path");
db.config({
type: "server",
url: "mongodb://localhost:27017/myapp",
collections: path.join(__dirname, "collections"),
backupDir: path.join(__dirname, "backups"),
debug: true,
});config() starts the connection in the background. Mongoose buffers commands while the connection is opening.
No MongoDB server? Use local mode, or omit config entirely. kilic.db starts a local one-node mongodb-memory-server-core replica set, loads the .kd data file into MongoDB, and writes changes back to the same file after kilic.db write commands:
db.config({
type: "local",
file: path.join(__dirname, "data", "myapp.kd"),
database: "myapp",
collections: path.join(__dirname, "collections"),
});If file is omitted, kilic.db creates <cwd>/datas.kd. If db.config() is never called, the first command uses local mode with that default file. The .kd extension is enforced automatically — any other extension is replaced. The file uses MongoDB EJSON internally, so ObjectIds, dates, and other BSON values can round-trip safely.
The memory mode uses mongodb-memory-server-core, so it does not download a MongoDB binary during package install. The first URL-free run may download the mongod binary into the user's MongoDB Memory Server cache unless MONGOMS_SYSTEM_BINARY or other MongoDB Memory Server config points to an existing binary.
Need an explicit boot barrier?
await db.ready();When an immediately invoked function expression follows db.config() on the next line, prefer a semicolon. config() is ASI-safe, but explicit semicolons keep plain JavaScript easier to read:
db.config({ file: "datas", collections: "./collections" });
(async () => {
await db.create("User", { id: "u_1" });
})();Config options:
| Option | Type | Purpose |
|---|---|---|
type |
"local" | "server" |
Database mode. Defaults to server when url exists, otherwise local |
url |
string |
MongoDB connection string |
options |
ConnectOptions |
Options passed to mongoose.connect() |
collections |
string |
Directory for auto-loading collection/model files |
path |
string |
Deprecated alias for collections |
file |
string |
JSON file for built-in memory-server persistence when url is omitted |
database |
string |
Database name for built-in memory-server mode |
memoryServerOptions |
object |
Options passed to MongoMemoryReplSet.create() |
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 | type, url, file, collections, Mongoose connect options |
ready() |
wait for connection | startup checks |
flush() |
write file-backed data now | memory-server mode |
disconnect() |
flush and close | scripts, tests, shutdown |
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, schema?) |
define or retrieve a model | kilic.db schemas, 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: "[email protected]",
name: "Ada",
});Without id, provide the identity filter:
await db.create(
"User",
{ email: "[email protected]", name: "Ada" },
{ filter: { email: "[email protected]" } }
);Create many by passing an array:
await db.create("User", [
{ id: "u_1", email: "[email protected]" },
{ id: "u_2", email: "[email protected]" },
]);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: "[email protected]" },
});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: "[email protected]" });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");
});In file-backed memory-server mode, kilic.db flushes after its own write commands. If you write through raw Mongoose models directly, call await db.flush() before shutdown so those changes are written to the JSON file.
The examples/ directory contains runnable examples for both modes:
npm run build
node examples/file-backed-memory.js
node examples/transactions.jsUse examples/mongodb-url.js with a real MongoDB URL:
MONGODB_URI="mongodb://127.0.0.1:27017/kilic_example" node examples/mongodb-url.jsRun the full local check:
npm testnpm test builds the package and runs the Node.js test suite in tests/. CI runs type checks, tests, and package dry-run checks on supported Node.js versions.
Releases are automated from main: when tests pass and the package.json version is not already published on npm, GitHub Actions publishes the package and creates the matching GitHub release tag. Configure npm Trusted Publishing for this repository, or set an NPM_TOKEN repository secret.
Define collections with kilic.db:
const db = require("kilic.db");
module.exports = new db.model("User", {
id: { type: String, unique: true },
email: String,
name: String,
age: Number,
});Then point collections at the directory:
collections/
User.js
Post.js
Order.js
Mongoose model exports are still supported for advanced projects:
const mongoose = require("mongoose");
module.exports = mongoose.model("User", userSchema);If a collection file imports mongoose directly, install mongoose in the application or switch the file to new db.model("User", schema).
Default exports are supported.
Model names are resolved only inside config.collections; path traversal strings such as "../User" are ignored. The old config.path name still works as an alias, but collections is the preferred option.
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: "[email protected]",
});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