Sync
Create the indexes a model declares and apply a MongoDB $jsonSchema validator generated from its Zod schema, with sync().
sync() prepares a model's collection on the server: it creates the indexes the model declares
and, when the model asks for it, applies a $jsonSchema validator generated from its Zod schema. It
never runs on its own: call it before the app takes requests (see
When to run it).
import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string()
.min(1),
slug: z.string(),
body: z.string()
.default(''),
status: z.enum(['draft', 'published'])
.default('draft'),
tags: mz.set(z.string())
.default([]),
authorId: mz.objectId(),
createdAt: mz.date(),
updatedAt: mz.date()
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema,
timestamps: true,
indexes: (index) => [
index({ status: 1, createdAt: -1 }),
index({ slug: 1 }, { unique: true, partialFilterExpression: { status: 'published' } }),
index({ title: 'text', body: 'text' }, { weights: { title: 10 } })
],
jsonSchema: true
})
const { indexes, extraIndexes } = await PostsModel.sync()
// indexes: ['status_1_createdAt_-1', 'slug_1', 'title_text_body_text']
// extraIndexes: []Running it again changes nothing: the indexes that exist stay as they are.
Indexes
indexes is a callback that declares each index with index(key, options), like the driver's
createIndex(key, options). It is a callback so that the editor suggests the paths of the
document while you type them.
- Keys are paths of the document, dotted and through arrays as in a filter, or the wildcards
$**andpath.$**. They take the driver's values:1,-1,'text','2dsphere'and the rest. - Options are the driver's
IndexDescription:unique,name,partialFilterExpression,expireAfterSeconds,collation,weightsand the rest. partialFilterExpressionis checked like a filter of the model, and the keys ofweightsare paths of the document, or$**. In the example above, the slug of a published post is unique, and drafts may share one.
const sessionSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
userId: mz.objectId(),
expiresAt: mz.date()
})
const SessionsModel = mongozod({
collection: 'sessions',
db,
schema: sessionSchema,
indexes: (index) => [
// MongoDB deletes each session after its expiresAt
index({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
index({ userId: 1 })
]
})A key or a weight that is not a path doesn't compile, and throws an Error when the model is
defined:
mongozod({
collection: 'posts',
db,
schema: postSchema,
// @ts-expect-error: "titel" is not a path of the document
indexes: (index) => [index({ titel: 1 })]
})
// Error: index key "titel" is not a path of the documentSome mistakes reach the server or pass unnoticed:
- An index without keys,
index({}), compiles, and throws anErrorwhen the model is defined. - An operator that a partial index doesn't take, such as
$ne, compiles: the server rejects it whensync()runs, with the code 67. expireAfterSecondson a field that is not a date compiles, and MongoDB takes it, but deletes nothing.
What sync() returns
sync() returns { indexes, extraIndexes }:
indexes: the names of the declared indexes, created now or already there.extraIndexes: the indexes of the collection that the model doesn't declare, as the driver describes them, with theirnameandkey. The index of_idis not among them, nor the one MongoDB creates on themetaFieldandtimeFieldof a time-series collection.
MongoZod doesn't write to the console. Your script decides what to do with the extra indexes:
for (const index of extraIndexes) {
console.warn(`posts has an index the model doesn't declare: ${index.name}`)
}It never drops an index
sync() never drops an index, unlike Mongoose's
syncIndexes(), which drops the indexes its schema
doesn't define. When an index of the collection differs from a declared one, sync() fails with
the server's error, which names the index: the code 85 when the same keys have another name, and 86
when the same name has other options. MongoDB creates the indexes of one call all or none, so none
is created.
To change an index, drop it with the driver and run sync() again:
await PostsModel.collection.dropIndex('slug_1')
await PostsModel.sync()An index with another name and other options is created next to the old one, which stays among
extraIndexes.
$jsonSchema
Writes through the model are validated by Zod. The jsonSchema option makes the server check the
structure of each document too, so what the model can't see is checked as well: writes through the
driver or other apps, and a dotted $set that creates a parent without its required fields.
jsonSchema: trueapplies the validator with MongoDB's defaults,validationLevel: 'strict'andvalidationAction: 'error'.- An object sets them:
{ validationLevel: 'moderate' }doesn't validate the updates of documents that already fail, for a collection with old documents, and{ validationAction: 'warn' }logs instead of rejecting. See validation levels. - Applied with
collMod, or whensync()creates the collection. It replaces the validator the collection had, one set by hand included. Without the option,sync()leaves the validator alone. - Needs
dbAdmin:collModis an action of that role, not ofreadWrite, which covers the indexes. Runsync()from a deploy step with a user that has it, and keep the application's user atreadWrite(see The user).
A write that fails the validator fails with the server's error, code 121:
await db.collection('posts').insertOne({ title: 'Hello', status: 'archived' })
// MongoServerError: Document failed validation (code 121)What it checks
The validator follows the output of the schema, which is what the database stores: the end of a pipe or a codec.
- Types: the BSON type of each field, nested objects and arrays, enums and literals,
nulland unions, as ananyOf. - Required fields: those the output always has, including the ones with a default.
- Field types:
mz.objectId()is anobjectId,mz.date()adate, andmz.set()an array withuniqueItems: true, so the server also rejects a repeated value. A field that Zod doesn't describe declares its type with.meta({ bsonType }). - Extra fields: a
z.objectleavesadditionalPropertiesopen. It drops the keys it doesn't declare on each write, but the stored documents may have them. Az.strictObjectcloses it, and a.catchall()checks them with its schema. Either way, when the schema doesn't declare_id, the validator does, as anobjectId, so the_idthe driver generates passes.
Part of the validator of the postSchema above:
{
"bsonType": "object",
"properties": {
"_id": { "bsonType": "objectId" },
"title": { "bsonType": "string" },
"status": { "enum": ["draft", "published"] },
"tags": { "bsonType": "array", "items": { "bsonType": "string" }, "uniqueItems": true },
"createdAt": { "bsonType": "date" }
},
"required": [
"_id",
"title",
"slug",
"body",
"status",
"tags",
"authorId",
"createdAt",
"updatedAt"
]
}Some things stay in the app:
- Refinements, such as
.min(1)or.email(): the validator checks types and structure. - Transforms,
z.custom()andz.unknown(): their fields get no rule, unless they declare absonType. - Recursive schemas: the field through which the schema refers to itself gets no rule, because
$jsonSchemahas no$ref.
MongoZod writes the validator itself instead of using z.toJSONSchema(): MongoDB's $jsonSchema
is draft 4 without $ref, default, format or the integer type,
rejects unknown keywords, and uses bsonType for dates and the other BSON types.
When to run it
sync() is idempotent: indexes that exist are left as they are, and it sends a few commands per
model. Run it for every model, before the code that relies on its indexes takes requests, in one of
these ways:
- A deploy step: a command that runs before the new version takes traffic, such as the release
or pre-deploy command, the job or the CI step of your platform, with access to the database. A
failure stops the deploy, and the version that runs keeps running. A user with
dbAdminapplies the$jsonSchemathere too (see Connection). - When the process starts, before it listens: for a long-running server without a deploy step. MongoDB joins the identical builds of several instances. A build on a large collection delays the start, and one that fails, such as a unique index over repeated values, fails each start.
- On the first operation, with the
autoIndexplugin: for development, tests and serverless platforms, which have no start before requests. It creates the indexes only. - In the setup of the tests, as
await Model.init()in Mongoose, when the tests rely on a unique index and do not useautoIndex.
Keep the models in one list, such as the module that exports them, so that a new one is not left out of the step that syncs them.
Where it runs
- Time-series and capped collections, and a default collation, are created with the driver,
with
db.createCollection(), beforesync(). - A model whose
dbis a function syncs the database the function returns at that moment, so a project with a database per tenant runssync()for each one. See Multi-tenant.
Common mistakes
- Never calling
sync(): the model works, but auniqueindex doesn't exist, so duplicates get in, and queries scan the collection. Run it on each deploy, or useautoIndex. - Running
sync()withjsonSchemaas the application's user:collModfails with the code 13,Unauthorized, unless the user hasdbAdmin. Give it to the user of the deploy step only. - Changing the options of an existing index:
sync()fails with the code 85 or 86. Drop the index withmodel.collection.dropIndex()first. - Expecting
sync()to drop the indexes you removed: they stay, listed inextraIndexes. - Expecting the server to check refinements:
.min(),.email()and.refine()run only in the app. - Turning on
jsonSchema: trueover old documents: withstrict, an update of a document that doesn't pass fails. Use{ validationLevel: 'moderate' }while you migrate them. See Schema migrations. - Using an operator that partial indexes don't take, such as
$ne, in apartialFilterExpression: it compiles, and the server rejects the index with the code 67.
Last updated on
MongoZod is an independent open-source project. It is not affiliated with, endorsed by or sponsored by MongoDB, Inc. MongoDB® is a registered trademark of MongoDB, Inc.