Skip to content
approval@dev

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 arithmetic

ISO 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

RuleApplies toNotes
requiredallSkipped when a default is present
defaultallA value, or a function called per document
enumstring, numberMembership check
min / maxnumberInclusive bounds
minLength / maxLengthstring, arrayInclusive bounds
matchstringRegExp test
validateallCustom function returning true or an error message
nullableallPermits an explicit null; inferred when default is null
uniqueallCreates a unique index on the server on first write
indexallCreates 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-backed

The 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.
  • $set on a field that is not in the schema throws rather than silently writing.
  • $inc is rejected on non-number fields; $push and $addToSet check elements against the array's of type, including values inside $each.
  • timestamps: true manages createdAt and updatedAt as 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.