Skip to content

kilicdev/kilic.db

Repository files navigation

kilic.db

Tiny MongoDB commands for Node.js projects.

npm version npm downloads TypeScript Docs License: MIT

Configure once. Query everywhere. Keep the API small. Keep MongoDB powerful.


Overview

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" } } },
]);

Why

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

Install

npm install kilic.db

Node.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.

Configure

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 Map

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

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

Get

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,
});

Update

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

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 }.

Find

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();

Count

const activeUsers = await db.count("User", { active: true });

Need a metadata-based estimate?

const totalUsers = await db.model("User").estimatedDocumentCount();

Aggregate

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.

Backup

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.

Raw Mongoose

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.

Examples

The examples/ directory contains runnable examples for both modes:

npm run build
node examples/file-backed-memory.js
node examples/transactions.js

Use examples/mongodb-url.js with a real MongoDB URL:

MONGODB_URI="mongodb://127.0.0.1:27017/kilic_example" node examples/mongodb-url.js

Development

Run the full local check:

npm test

npm 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.


Models

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.

TypeScript

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);
});

Errors

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.

Safety

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.

Recipes

Idempotent Registration

await db.create("User", {
  id: externalUser.id,
  email: externalUser.email,
  provider: "github",
});

Batch Sync

await db.create("Customer", customers, {
  filter: (customer) => ({ externalId: customer.externalId }),
});

await db.update("Customer", customers, (customer) => ({
  externalId: customer.externalId,
}));

Huge Export

const cursor = await db.find("Event", { type: "purchase" }, {
  cursor: true,
  sort: { createdAt: 1 },
});

for await (const event of cursor) {
  await writeToExport(event);
}

Archive Old Data

await db.update(
  "User",
  { archived: true },
  { lastLoginAt: { $lt: new Date("2025-01-01") } },
  { multi: true }
);

Dashboard Stats

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 });

Philosophy

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.

License

MIT © kilicdev

About

Zero-boilerplate MongoDB wrapper. Configure once, use everywhere. Singleton-based, race condition proof, and fully typed.

Topics

Resources

License

Stars

21 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors