@rivyn/db
v0.3.0
Published
Client for the Rivyn self-hosted NoSQL database
Maintainers
Readme
@rivyn/db
TypeScript client for the Rivyn self-hosted NoSQL database. MongoDB-like API, zero runtime dependencies.
Install
npm i @rivyn/dbRequires Node 20.19+ (or 22.12+). The package is ESM, but Node's require(esm) support means it works from CommonJS projects too:
import { RivynClient } from "@rivyn/db"; // ESM
const { RivynClient } = require("@rivyn/db"); // CommonJSYou also need a running @rivyn/db-server instance (local machine or your own VPS).
Quick start
import { RivynClient } from "@rivyn/db";
const client = new RivynClient({
host: "127.0.0.1",
port: 7223,
key: "your-auth-key",
});
interface User {
name: string;
age: number;
[key: string]: unknown;
}
const users = client.collection<User>("users");
await users.insert({ name: "arel", age: 21 });
await users.insertMany([{ name: "mert", age: 17 }, { name: "zeynep", age: 25 }]);
const adults = await users.find({ age: { $gt: 18 } }, { sort: { age: -1 }, limit: 10 });
const arel = await users.findOne({ name: "arel" });
await users.updateOne({ name: "arel" }, { $inc: { age: 1 }, $set: { active: true } });
await users.deleteMany({ age: { $lt: 18 } });
console.log(await users.count());
client.close();The client connects lazily on the first request (or call client.connect() explicitly) and automatically reconnects with exponential backoff if the connection drops. Requests in flight when a connection drops are rejected rather than buffered, so a failed write always surfaces as an error instead of disappearing silently.
Schemas (Mongoose-style)
Collections are schemaless by default. For validated, structured data use Schema + client.model():
import { RivynClient, Schema, RivynValidationError } from "@rivyn/db";
const userSchema = new Schema(
{
name: { type: "string", required: true, minLength: 2 },
email: { type: "string", required: true, unique: true, match: /^\S+@\S+$/ },
age: { type: "number", min: 0, max: 150, default: 18 },
role: { type: "string", enum: ["user", "admin"], default: "user" },
joinedAt: { type: "date", default: null },
xp: { type: "number", default: 0, index: true },
tags: { type: "array", of: "string", default: [] },
profile: { type: "object", fields: { city: { type: "string", required: true } } },
settings: { type: "object", default: {} }, // free-form map
nickname: "string",
},
{ timestamps: true }
);
const User = client.model<UserType>("users", userSchema);
const arel = await User.create({ name: "arel", email: "[email protected]" });
// → defaults applied, createdAt/updatedAt set, _id returned
await User.updateOne({ email: "[email protected]" }, { $set: { role: "admin" } });
// → $set values validated, updatedAt bumped automatically
await User.create({ name: "x" });
// → throws RivynValidationError locally (never reaches the server)Field types
string, number, boolean, date, object (with nested fields), array (with of), any. A bare string is shorthand: nickname: "string".
date accepts a Date, an ISO string or epoch milliseconds and always stores an ISO string, so values survive JSON unchanged. ISO strings sort chronologically, so $gt/$lt range filters work as expected:
await User.find({ joinedAt: { $gte: new Date("2026-01-01") } });Read values back as strings — wrap them when you need date arithmetic: new Date(user.joinedAt).
An object with no fields is a free-form map. Any path beneath it is allowed, which is how you model what would have been a Mongoose Map:
await User.updateOne({ id }, { $set: { "settings.theme": "dark", "settings.locale.tz": "UTC" } });Rules
required, default (value or function), nullable, enum, min/max, minLength/maxLength, match (RegExp), validate (custom function returning true or an error message), unique, index.
nullable: truepermits an explicitnull. It is inferred automatically whendefaultisnull, so{ type: "string", default: null }needs no extra flag.unique: truecreates a unique index on the server the first time the model writes.index: truecreates a secondary index, so equality and range filters on that field skip the full scan. Declaring it inside an array's element definition builds a multikey index —punishments.activebelow indexes the flag of every element:new Schema({ punishments: { type: "array", of: { type: "object", fields: { active: { type: "boolean", index: true } } }, default: [], }, }); await User.find({ "punishments.active": true }); // index-backedtimestamps: truemanagescreatedAt/updatedAtISO strings for you.
Behaviour
- Unknown fields are stripped on create;
$seton a field that is not in the schema throws. - Updates are validated too:
$setvalues against field rules,$inconly on number fields,$push/$addToSetelements against the array'softype (including inside$each). - Validation failures throw
RivynValidationErrorwith anissuesarray ({ path, message }), before anything is sent to the server. - Like Mongoose, validation runs client-side only — the server stays schemaless, so evolving your schema never conflicts with old documents. Updates validate just the paths they touch.
- Optional server-side enforcement: to make the server reject invalid writes from any client (MongoDB's
$jsonSchemaequivalent), push the schema manually:await collection.setSchema(mySchema.toServerSpec()). Clear it withsetSchema(null), inspect it withgetSchema(). The server then validates the whole resulting document on every write, so documents predating the schema may need migrating first.
Model exposes the same query API as Collection (find, findOne, count, deleteOne, deleteMany, findOneAndUpdate, findOneAndDelete, stats), plus create/createMany instead of raw inserts. The raw collection stays available as User.collection.
create()applies schema defaults; anupsertdoes not — it seeds the new document from the filter and then applies the update. For "fetch or create with full defaults", read first and fall back tocreate().
Options
new RivynClient({
host: "1.2.3.4", // required
port: 7223, // default 7223
key: "...", // required — the server's auth key
tls: false, // true, or { rejectUnauthorized: false } for self-signed certs
requestTimeoutMs: 10000, // per-request timeout
reconnect: true, // auto-reconnect on connection loss
reconnectDelayMs: 500, // initial backoff delay
maxReconnectDelayMs: 10000,
});API
Client
| Method | Description |
| --- | --- |
| connect() | Connect + authenticate; returns { server, version } |
| ping() | Round-trip health check |
| collection<T>(name) | Get a typed collection handle |
| model<T>(name, schema) | Get a schema-backed model |
| collections() | List collections with document counts |
| stats() | Server-wide stats (uptime, docs, memory) |
| dropCollection(name) | Delete a collection and its data |
| on("connect" \| "disconnect", fn) | Connection lifecycle events |
| close() | Close the connection (disables reconnect) |
Collection
| Method | Returns |
| --- | --- |
| insert(doc) | { insertedId } |
| insertMany(docs) | { insertedIds } |
| find(filter?, { sort, limit, skip }?) | WithId<T>[] |
| findOne(filter?) | WithId<T> \| null |
| updateOne(filter, update, opts?) / updateMany | { modifiedCount, upsertedId? } |
| deleteOne(filter) / deleteMany | { deletedCount } |
| count(filter?) | number |
| aggregate(pipeline) | Row[] — $match, $group ($sum/$avg/$min/$max), $sort, $limit, $skip |
| findOneAndUpdate(filter, update, opts?) | WithId<T> \| null |
| findOneAndDelete(filter) | WithId<T> \| null |
| stats() | CollectionStats |
| createIndex(field, { unique }?) | { field, unique } |
| dropIndex(field) | boolean |
| indexes() | IndexDef[] |
Update options (opts): { upsert?: boolean, arrayFilters?: Record<string, unknown>[], returnNew?: boolean }.
Queries
Filters support $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex (with $options), $elemMatch, $or, $and.
Dotted paths reach through arrays. "punishments.id" matches if any element has that id:
await users.find({ "punishments.id": 3 });
await users.find({ "punishments.oldRoles.id": "r1" }); // nested arrays too$elemMatch requires all conditions to hold on the same element:
await users.find({ punishments: { $elemMatch: { active: true, type: "ban" } } });$regex takes flags through $options:
await users.find({ name: { $regex: "^ar", $options: "i" } });Updates
$set, $unset, $inc, $push, $pull, $addToSet, or a plain object for full replacement.
$each spreads several values into $push or $addToSet; $addToSet compares by value, so objects are deduplicated properly:
await users.updateOne({ id }, { $push: { reasons: { $each: ["spam", "raid"] } } });
await users.updateOne({ id }, { $addToSet: { roles: { $each: [{ id: "r1" }, { id: "r2" }] } } });Positional operators
$ updates the element the filter matched, $[] updates every element, and $[name] updates the elements picked by arrayFilters:
// The punishment with id 2 — the one the filter selected.
await users.updateOne(
{ id: "550", "punishments.id": 2 },
{ $set: { "punishments.$.active": false } },
);
// Every punishment.
await users.updateMany({}, { $set: { "punishments.$[].reviewed": true } });
// Rewrite one role id wherever it appears, at any depth.
await users.updateMany(
{ "punishments.oldRoles.id": oldId },
{ $set: { "punishments.$[].oldRoles.$[role].id": newId } },
{ arrayFilters: [{ "role.id": oldId }] },
);Upsert
await users.updateOne(
{ id: "550" },
{ $set: { username: "arel" } },
{ upsert: true },
);When nothing matches, the document is seeded from the filter's plain equality fields ({ id: "550" }, and dotted keys like "a.b": 1 become { a: { b: 1 } }), then the update is applied. Operator conditions carry no single value, so they are skipped.
Server-side errors (unique index violations, invalid operations) are thrown as RivynError.
Development
npm install
npm test # runs against a real server spawned from ../server
npm run buildLicense
MIT
