Guides
Schemas
Mongoose-style validation that runs on the client, never on the server.
Collections are schemaless by default. When you want structure, define a Schema and get
a typed Model back.
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: {} },
nickname: "string",
},
{ timestamps: true },
);
const User = client.model<UserType>("users", userSchema);Field types
string, number, boolean, date, object, array, any. A bare string is
shorthand, as with nickname above.
Dates
There is no Date on the wire, so a date field accepts a Date, an ISO string or epoch
milliseconds and always stores an ISO string:
await User.updateOne({ email }, { $set: { joinedAt: new Date() } });
const user = await User.findOne({ email });
user.joinedAt; // "2026-07-20T12:00:00.000Z"
new Date(user.joinedAt); // wrap it for date arithmeticISO strings are fixed-width and UTC, so they sort chronologically. Range filters work without any special handling:
await User.find({ joinedAt: { $gte: new Date("2026-01-01") } });Free-form objects
An object with no fields is an open map — any path beneath it is allowed. This is how
you model what would have been a Mongoose Map:
await User.updateOne({ email }, {
$set: { "settings.theme": "dark", "settings.locale.tz": "UTC" },
});An object that does declare fields stays strict: $set on an undeclared sub-path
throws.
Why validation is client-side
Like Mongoose, validation runs entirely in your application — the server stays schemaless. That is a deliberate choice: if the server enforced a schema, adding a new required field would instantly invalidate every document written before it, and every existing row would need migrating before the next write succeeded.
Keeping it client-side means your schema can evolve freely. Updates validate only the paths they actually touch.
Rules
| Rule | Applies to | Notes |
|---|---|---|
required | all | Skipped when a default is present |
default | all | A value, or a function called per document |
enum | string, number | Membership check |
min / max | number | Inclusive bounds |
minLength / maxLength | string, array | Inclusive bounds |
match | string | RegExp test |
validate | all | Custom function returning true or an error message |
nullable | all | Permits an explicit null; inferred when default is null |
unique | all | Creates a unique index on the server on first write |
index | all | Creates a secondary index on first write |
nullable rarely needs writing out. A field declared { type: "string", default: null }
is nullable already, which keeps the common "empty until set" shape terse.
Indexes
index: true builds a secondary index so equality and range filters on that field skip the
full scan. Declare it inside an array's element definition and you get a multikey index
— every element contributes an entry:
const userSchema = new Schema({
xp: { type: "number", default: 0, index: true },
punishments: {
type: "array",
of: { type: "object", fields: { active: { type: "boolean", index: true } } },
default: [],
},
});
await User.find({ "punishments.active": true }); // index-backed
await User.find({ xp: { $gt: 0 } }, { sort: { xp: -1 } }); // index-backedThe model creates the indexes on its first write. The planner only uses an index when the filter names exactly that field — anything else is a full scan.
Behaviour
- Unknown fields are stripped when creating a document.
$seton a field that is not in the schema throws rather than silently writing.$incis rejected on non-number fields;$pushand$addToSetcheck elements against the array'softype, including values inside$each.timestamps: truemanagescreatedAtandupdatedAtas ISO strings.create()applies defaults; an upsert does not.
Failures throw RivynValidationError before anything reaches the network:
try {
await User.create({ name: "x" });
} catch (error) {
if (error instanceof RivynValidationError) {
console.error(error.issues); // [{ path: "name", message: "must be at least 2 characters" }]
}
}Enforcing on the server anyway
If you want the server to reject invalid writes from any client — the equivalent of
MongoDB's $jsonSchema — push the schema across manually:
await User.collection.setSchema(userSchema.toServerSpec());Clear it with setSchema(null). Note that the server then validates the whole resulting
document on every write, so documents that predate the schema may need migrating first.