
Research
/Security News
77 Firefox Extensions Linked to Crypto Wallet and Credential Theft
Socket uncovered 77 linked Firefox extensions, including 40 that steal wallet secrets or credentials and 37 deceptive sports-score shells.
@monlite/core
Advanced tools
An embedded document database for TypeScript apps. MongoDB-like API, Prisma-like DX, SQLite under the hood. Zero config, zero migrations, zero server.
Your app's local database. A MongoDB-like API and Prisma-style DX in a single file. Zero config, zero migrations, zero server.
import { createDb } from "@monlite/core";
const db = createDb("./app.db");
const users = db.collection("users");
await users.create({ data: { name: "Ali", age: 28 } });
await users.findMany({ where: { age: { gte: 18 } } });
That's the whole setup. Your data is in app.db.
monlite is one database with one query API. You never have to choose "SQL or NoSQL." You only choose, per collection, where each field is stored:
db.collection(name, { schema })) — the fields you
declare become real SQL columns (typed, indexed, joinable). Anything else
overflows into JSON automatically.A schema changes the storage, never the syntax.
create,find,where,orderBy,groupByare identical in both modes and return identical results — structured mode is just faster and SQL-native underneath.
Raw SQL is the one optional place SQL becomes visible: the $queryRaw escape
hatch, for joins/CTEs/window functions the document API doesn't cover.
| You decide… | Document (default) | Structured ({ schema }) |
|---|---|---|
| How you query | find / where / orderBy / groupBy | identical |
| Where a field lives | JSON data blob | a native column (declared) — the rest overflow to JSON |
| Pick it when | the shape is unknown or varies per record | the shape is stable and you want joins, FKs, reporting, or fast native indexes |
You can mix both in the same .db, and move a collection from document to
structured later without changing a single query.
npm install @monlite/core
monlite has zero required dependencies. On Node 22.5+ it uses the
built-in node:sqlite engine out of the
box. To run on Node 18/20 — or to avoid node:sqlite's experimental warning —
also install the (optional) native driver:
npm install @monlite/core better-sqlite3
See Drivers & zero dependencies below.
| Situation | Use |
|---|---|
| Desktop / Electron / Tauri app needing local data | ✅ monlite |
| CLI tool with persistent state | ✅ monlite |
| Local-first app syncing with a cloud MongoDB | ✅ monlite |
| Prototype / MVP needing fast iteration, flexible schema | ✅ monlite |
| Server app with Postgres/MySQL | ❌ use your DB directly |
| Strictly relational, known, stable schema | ❌ use SQLite directly |
| Production cloud database | ❌ use MongoDB / a managed DB |
If your data is structured and you already know your schema, plain SQLite adds nothing on top of monlite — use it directly. monlite earns its keep when your documents are dynamic, schema-free, or mirror a cloud NoSQL store.
| monlite | MongoDB | better-sqlite3 | Prisma + SQLite | |
|---|---|---|---|---|
| Schema-free documents | ✅ | ✅ | ⚠️ manual JSON | ❌ |
| Native typed columns | ✅ (opt-in) | ❌ | ✅ | ✅ |
| Same API for both | ✅ | — | — | — |
| Raw SQL escape hatch | ✅ | ❌ | ✅ | ✅ ($queryRaw) |
| No server / single file | ✅ | ❌ | ✅ | ✅ |
| No migrations / codegen | ✅ | ✅ | ✅ | ❌ |
| Aggregation API | ✅ | ✅ | ⚠️ manual | ⚠️ limited |
| Local-first sync | ✅ (@monlite/sync) | ⚠️ Atlas/Realm | ❌ | ❌ |
| Runtime dependencies | 0 (Node 22.5+) | server | 1 (native) | several |
import { createDb } from "@monlite/core";
const db = createDb("./app.db"); // creates the file if missing
const mem = createDb(":memory:"); // in-memory database
const db = createDb("./app.db", {
driver: "auto", // "auto" | "better-sqlite3" | "node:sqlite" (default: "auto")
autoIndex: true, // auto-create indexes on hot JSON paths (default: true)
autoIndexAfter: 10, // create an index after a path is queried N times (default: 10)
readonly: false, // open read-only (default: false)
wal: true, // use WAL journal mode (default: true)
verbose: (sql) => console.log(sql), // log every executed SQL statement
});
Collections are created automatically on first access — no schema, no migration, no definition needed. Pass a type for full inference.
interface User {
name: string;
age?: number;
address?: { city: string };
tags?: string[];
}
const users = db.collection<User>("users");
Every stored document gains three system fields:
| Field | Type | Notes |
|---|---|---|
_id | string | Auto-generated, ObjectId-compatible (24 hex chars), time-sortable. Provide your own to override. |
created_at | number | Unix epoch milliseconds, set on insert. |
updated_at | number | Unix epoch milliseconds, bumped on every update. |
// create
const user = await users.create({
data: { name: "Ali", age: 28, address: { city: "Riyadh" } },
});
// user._id, user.created_at, user.updated_at are populated
// createMany (single transaction)
await users.createMany({ data: [{ name: "Sara" }, { name: "Omar" }] });
// read
await users.findById("…"); // doc | null
await users.findFirst({ where: { name: "Ali" } }); // doc | null
await users.findUnique({ where: { email: "a@x.com" } }); // alias of findFirst
await users.findFirstOrThrow({ where: { name: "Ali" } }); // throws if missing
await users.exists({ role: "admin" }); // boolean
await users.findMany({
where: { age: { gte: 18 } },
orderBy: { age: "desc" },
select: { name: true, age: true },
skip: 0,
take: 10,
});
// update (first match) — returns the updated doc or null
await users.update({ where: { _id: "…" }, data: { age: 29 } });
await users.updateMany({ where: { role: "admin" }, data: { active: true } }); // { count }
// upsert
await users.upsert({
where: { name: "Ali" },
create: { name: "Ali", age: 1 },
update: { age: 2 },
});
// delete — returns the deleted doc or null
await users.delete({ where: { _id: "…" } });
await users.deleteMany({ where: { active: false } }); // { count }
await users.deleteMany(); // delete all → { count }
// count
await users.count({ where: { role: "admin" } });
Prisma-style, no $ prefix. A bare value is shorthand for equals.
// Comparison
where: { age: 28 } // shorthand equals
where: { age: { equals: 28 } }
where: { age: { not: 28 } } // also matches docs missing the field
where: { age: { gt: 18 } } // gt, gte, lt, lte
where: { role: { in: ["admin", "editor"] } }
where: { role: { notIn: ["guest"] } }
// String (case-sensitive by default; wildcards are matched literally)
where: { name: { contains: "li" } }
where: { name: { startsWith: "A" } }
where: { name: { endsWith: "i" } }
where: { name: { contains: "ALI", mode: "insensitive" } } // case-insensitive (ASCII)
// Arrays
where: { tags: { contains: "admin" } } // element membership
where: { tags: { has: "admin" } } // explicit element membership
// Existence
where: { phone: { exists: true } } // field present (even if null)
where: { phone: { exists: false } }
// Nested paths (dot notation)
where: { "address.city": { equals: "Riyadh" } }
where: { "meta.score": { gte: 9 } }
// Logical
where: { AND: [{ age: { gte: 18 } }, { active: true }] }
where: { OR: [{ role: "admin" }, { role: "editor" }] }
where: { NOT: { role: "guest" } }
where: { role: "admin", age: { gt: 30 } } // multiple fields => implicit AND
contains/startsWith/endsWithare case-sensitive (implemented with SQLite'sinstr/substr, so%and_are literal). On an array field,containschecks element membership.
The data payload is either a plain object (shallow-merged) or update operators.
The two forms cannot be mixed.
// Default — shallow merge
await c.update({ where: { _id }, data: { age: 29, name: "Ali Updated" } });
// $set — set fields, including nested dot paths
await c.update({ where: { _id }, data: { $set: { "address.city": "Jeddah" } } });
// $inc — increment (missing field starts at 0)
await c.update({ where: { _id }, data: { $inc: { score: 1 } } });
// $push — append to an array ($each pushes many)
await c.update({ where: { _id }, data: { $push: { tags: "moderator" } } });
await c.update({ where: { _id }, data: { $push: { tags: { $each: ["a", "b"] } } } });
// $pull — remove matching elements from an array
await c.update({ where: { _id }, data: { $pull: { tags: "guest" } } });
// $unset — remove a field
await c.update({ where: { _id }, data: { $unset: { temporaryField: true } } });
_id is immutable — attempts to set it via update data are ignored.
// aggregate
const stats = await users.aggregate({
where: { active: true },
_count: true,
_sum: { age: true },
_avg: { age: true },
_min: { age: true },
_max: { age: true },
});
// { _count: 42, _sum: { age: 1200 }, _avg: { age: 28.5 }, _min: { age: 18 }, _max: { age: 64 } }
// groupBy
const grouped = await users.groupBy({
by: ["role"],
where: { active: true },
_count: true,
_sum: { age: true },
orderBy: { _count: "desc" },
});
// [ { role: "admin", _count: 5, _sum: { age: 140 } }, … ]
// groupBy + having (filter groups by an aggregate, like SQL HAVING)
await users.groupBy({
by: ["role"],
_count: true,
_sum: { age: true },
having: {
_count: { gte: 2 }, // keep groups with COUNT(*) >= 2
_sum: { age: { gt: 50 } }, // and SUM(age) > 50
},
});
// having comparisons: equals, not, gt, gte, lt, lte — on _count and on
// _sum/_avg/_min/_max of any field.
await users.distinct("role"); // ["admin", "editor"]
await users.distinct("age", { role: "admin" }); // [28, 31]
// Array fields are unwound — each element is a value (like MongoDB):
await users.distinct("tags"); // ["a", "b", "c"]
collection.watch() keeps a query result live. The callback fires once
immediately (type: "init") and again whenever a change affects this query —
matching is row-level, so unrelated writes don't trigger a recompute. It also
fires for changes applied by @monlite/sync, so the UI updates when cloud data
arrives.
const handle = users.watch({ where: { role: "admin" } }, (event) => {
event.results; // full current result set
event.added; // docs that just entered the set
event.removed; // docs that just left
event.changed; // docs still in the set whose contents changed
});
handle.results; // current results, kept up to date
handle.stop(); // unsubscribe
Perfect for Electron/Tauri UIs: bind handle.results to your view and it stays
in sync with every write (local or synced).
By default a collection is document mode — schema-free, every field stored
as JSON. Pass a schema to make it a structured collection: the declared
fields become real, typed SQL columns (fast, indexable, joinable, constrainable)
and any other fields overflow into a JSON column. The CRUD/query API is
identical — find, where, orderBy, groupBy, distinct, updates. As the
mental model says: a schema changes the storage,
not the syntax.
const orders = db.collection("orders", {
schema: {
user_id: { type: "TEXT", index: true, references: "users(_id)" },
amount: "REAL",
status: { type: "TEXT", notNull: true, default: "pending" },
meta: "JSON", // objects/arrays, transparently (de)serialized
},
});
// Same API as document collections — but `amount`/`status` are real columns:
await orders.create({ data: { user_id: "u1", amount: 100, status: "paid", note: "rush" } });
await orders.findMany({ where: { amount: { gte: 50 }, status: "paid" } });
await orders.groupBy({ by: ["status"], _sum: { amount: true } });
// Undeclared fields (like `note`) still work — they overflow into JSON.
await orders.findMany({ where: { note: { contains: "rush" } } });
Because the columns are native, they join, constrain, and index like any SQL
table — including from the raw SQL hatch with no json_extract:
await db.$queryRaw`
SELECT u.name, SUM(o.amount) AS revenue
FROM users u JOIN orders o ON o.user_id = u._id
GROUP BY u._id
`;
Column types: "TEXT" | "INTEGER" | "REAL" | "BLOB" | "JSON". A full column
definition supports index, unique, notNull, default, and references.
Migrations are automatic for additive changes. Re-opening a collection with a
new declared column adds it (ALTER TABLE ADD COLUMN) on declaration — give
NOT NULL columns a default so existing rows can be backfilled. Destructive
changes (rename/drop/type-change) still need a manual migration.
monlite never hides which is which:
orders.mode; // "structured" | "document"
await db.$schema("orders"); // physical columns: [{ name, type, notNull, primaryKey }, …]
createDb("./app.db", { verbose: (sql) => console.log(sql) }); // see json_extract vs bare columns
Rule of thumb: unknown/flexible shape → document (JSON); known/stable shape with heavy joins, reporting, or external SQL tooling → structured (native columns).
Both document and structured collections are syncable via
@monlite/sync. To sync a structured collection, open it with itsschemaon every node before syncing (so each side knows the native columns).
The companion package @monlite/sync
replicates a local monlite database with a remote source of truth — MongoDB
first — so apps can work offline and converge when reconnected.
Opt in with { sync: true } (adds a change feed + tombstones + versioning; zero
overhead when off), then drive an engine:
import { createDb } from "@monlite/core";
import { sync, MongoAdapter } from "@monlite/sync";
import { MongoClient } from "mongodb";
const db = createDb("./app.db", { sync: true });
const mongo = new MongoClient(uri);
await mongo.connect();
const engine = sync(db, {
adapter: new MongoAdapter({ client: mongo, db: "app" }),
collections: "*",
mode: "two-way", // "pull" | "push" | "two-way"
conflict: "lww", // or a custom resolver
interval: 5000,
});
await engine.start();
Pull / push / two-way replication, last-write-wins (or custom) conflict
resolution, and pluggable adapters (MongoAdapter, MonliteAdapter for
monlite-to-monlite, MemoryAdapter for tests). monlite's ObjectId-compatible
_ids map 1:1 to Mongo _ids. See the
@monlite/sync README for details.
When you need full SQL power — complex joins, analytics, cross-collection
queries — drop to raw SQL. Documents live in a data JSON column, queryable
with SQLite's json_extract.
// Tagged template — values are safely parameterized
const report = await db.$queryRaw`
SELECT json_extract(u.data, '$.name') AS customer,
SUM(json_extract(o.data, '$.amount')) AS revenue
FROM users u
JOIN orders o ON json_extract(o.data, '$.userId') = u._id
WHERE json_extract(u.data, '$.role') = 'admin'
GROUP BY u._id
`;
// Execute (returns affected row count)
await db.$executeRaw`UPDATE users SET updated_at = ${Date.now()} WHERE _id = ${id}`;
// String form with positional params
await db.$queryRawUnsafe(`SELECT * FROM users WHERE _id = ?`, id);
await db.$executeRawUnsafe(`DELETE FROM users WHERE _id = ?`, id);
// Synchronous transaction (the callback must not be async)
await db.$transaction((tx) => {
// ...use tx.collection(...) or tx.sqlite...
});
Need the raw driver? db.sqlite is the underlying native handle (a
better-sqlite3 Database or a node:sqlite DatabaseSync, depending on the
active backend), and db.driverName tells you which one is in use.
monlite tracks which JSON paths your where/orderBy/aggregation clauses touch.
Once a path crosses the threshold (default 10 queries), an expression index is
created silently:
CREATE INDEX IF NOT EXISTS idx_users_address_city
ON users(json_extract(data, '$.address.city'));
You never think about indexes. Disable with createDb("./app.db", { autoIndex: false }).
Want to see whether a query uses an index? Ask:
await users.explain({ where: { "address.city": "Riyadh" } });
// { sql, usesIndex: true, plan: [{ id, parent, detail }, …] }
await db.$collections(); // string[] of collection names
await db.$drop("users"); // drop a collection and its data
await db.$dropAll(); // drop everything
await db.backup("./snapshot.db"); // consistent on-disk snapshot
await db.$disconnect(); // close the connection
db.sqlite; // the underlying native driver handle
db.driverName; // "better-sqlite3" | "node:sqlite"
@monlite/core stays lean; heavier or optional capabilities are opt-in plugins
passed to createDb:
import { createDb } from "@monlite/core";
import { fts } from "@monlite/fts";
const db = createDb("./app.db", {
plugins: [fts({ posts: ["title", "body"] })],
});
await db.collection("posts").search("hello world"); // full-text search
| Plugin | Adds |
|---|---|
@monlite/fts | Full-text search (SQLite FTS5) via collection.search() |
Write your own against the MonlitePlugin interface (init / afterWrite /
collectionMethods hooks).
monlite talks to SQLite through a tiny driver adapter, so it runs on two interchangeable backends:
| Backend | When it's used | Notes |
|---|---|---|
node:sqlite | Built into Node 22.5+ | Zero dependencies. Still flagged experimental by Node, so it prints a one-time ExperimentalWarning. |
better-sqlite3 | When the package is installed | Battle-tested native driver. Works on Node 18/20/22, no warning. Install it yourself: npm i better-sqlite3. |
By default (driver: "auto") monlite uses better-sqlite3 if it's installed,
otherwise falls back to the built-in node:sqlite. Force one explicitly:
createDb("./app.db", { driver: "node:sqlite" }); // zero-dep (Node 22.5+)
createDb("./app.db", { driver: "better-sqlite3" }); // native, no warning
Both backends pass the exact same test suite, so behavior is identical — pick based on your Node version and whether you want the extra dependency.
Want truly zero dependencies on Node 22.5+? Just
npm install @monlite/coreand don't installbetter-sqlite3. To silence the experimental warning, either installbetter-sqlite3or run Node with--no-warnings.
Every collection is a single SQLite table:
CREATE TABLE IF NOT EXISTS "users" (
_id TEXT PRIMARY KEY,
data TEXT NOT NULL, -- your document as JSON
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
Your entire document lives in the data column as JSON; _id, created_at
and updated_at are real columns. SQLite's built-in json_extract /
json_each power all document queries. No columns are added per field, so
there is no schema and no migration — ever.
All operations are synchronous under the hood (both SQLite backends are sync)
but are exposed as async (they return Promises) for API consistency and
future-proofing.
_id, created_at, updated_at are reserved; document fields with those
names are managed by monlite and won't round-trip as ordinary data.contains/startsWith/endsWith are case-sensitive (see above).$transaction callbacks run synchronously and must not be async.[A-Za-z_][A-Za-z0-9_]*).MIT 🌙
FAQs
An embedded document database for TypeScript apps. MongoDB-like API, Prisma-like DX, SQLite under the hood. Zero config, zero migrations, zero server.
The npm package @monlite/core receives a total of 66 weekly downloads. As such, @monlite/core popularity was classified as not popular.
We found that @monlite/core demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Research
/Security News
Socket uncovered 77 linked Firefox extensions, including 40 that steal wallet secrets or credentials and 37 deceptive sports-score shells.

Security News
NIST disclosed an unreleased AI tool called V-etalon and opened a broad inquiry into NVD modernization after years of automation plans produced no public enrichment system.

Security News
In his AI Council 2026 talk, Feross Aboukhadijeh covers recent package compromises, vulnerability discovery, and a more automated security model.