Skip to main content

findOneAndUpdate

Updating is the most challenging part of Lesan. Updating one document may require updating thousands โ€” or even millions โ€” of other documents. The good news: Lesan does it automatically. This tutorial walks through the two scenarios (updating a user, updating a country), then explains the three solutions Lesan proposes for the worst case.

Best case: update a userโ€‹

const updateUserValidator = () => {
return object({
set: object({
_id: objectIdValidation,
name: optional(string()),
age: optional(number()),
}),
get: coreApp.schemas.selectStruct("user", 1),
});
};

const updateUser: ActFn = async (body) => {
const { name, age, _id } = body.details.set;
const setObj: { name?: string; age?: number } = {};
name && (setObj.name = name);
age && (setObj.age = age);

return await users.findOneAndUpdate({
filter: { _id: new ObjectId(_id) },
projection: body.details.get,
update: { $set: setObj },
});
};

coreApp.acts.setAct({
schema: "user",
actName: "updateUser",
validator: updateUserValidator(),
fn: updateUser,
});

Remember the user model: each user lives in cities, and each city stores the last 50 users in its users list. So when a user is updated, Lesan must find every city that user lives in and update the embedded users list in each one.

If a related list is sorted by a field that the update changes, it gets more interesting: the user may drop out of the list entirely, and another qualifying user takes their place. Lesan handles that refill automatically. For the full mechanics see What Is the Relationship Really?.

findOneAndUpdate accepts:

  • filter โ€” the MongoDB filter selecting the document to update.
  • update โ€” the MongoDB update document (e.g. { $set: {...} }).
  • projection โ€” shapes the returned document.
  • options (optional) โ€” MongoDB FindOneAndUpdateOptions.

The full example is examples/document/08-1-findOneAndUpdate.ts.

Before updating the user:

User document before updateUser in Compass

While updating:

User document while updateUser runs

After updating โ€” the user's name and age changed, and the embedded lists were kept in sync:

User document after updateUser in Compass

Worse case: update a countryโ€‹

const updateCountryValidator = () => {
return object({
set: object({
_id: objectIdValidation,
name: optional(string()),
abb: optional(string()),
population: optional(number()),
}),
get: coreApp.schemas.selectStruct("country", 1),
});
};

const updateCountry: ActFn = async (body) => {
const { name, abb, population, _id } = body.details.set;
const setObj: { name?: string; abb?: string; population?: number } = {};
name && (setObj.name = name);
abb && (setObj.abb = abb);
population && (setObj.population = population);

return await countries.findOneAndUpdate({
filter: { _id: new ObjectId(_id) },
projection: body.details.get,
update: { $set: setObj },
});
};

coreApp.acts.setAct({
schema: "country",
actName: "updateCountry",
validator: updateCountryValidator(),
fn: updateCountry,
});

In a real scenario, updating a country means updating a very large number of documents: every user and every city that embeds this country. The full example is examples/document/08-2-findOneAndUpdate.ts.

Before updating the country:

Country document before updateCountry in Compass

While updating:

Country document while updateCountry runs

After updating โ€” every city and user that embeds the country was updated too:

Country document after updateCountry in Compass

Lesan's solutions to the update challengeโ€‹

As you've seen, updating one country can cascade into millions of documents. Lesan proposes three approaches:

QQ โ€” the query queueโ€‹

QQ stands for query queue: a queue of commands to send to the database. Use it to chunk millions of updates. Take the first few hundred thousand documents that should be updated, perform the update immediately, then store the id of the last updated document together with the remaining commands in QQ. Run the next chunk whenever hardware resources are free.

In-memory databaseโ€‹

Because Lesan keeps detailed relation information, it can know โ€” when sending data to the client โ€” that some of it changed in QQ. By saving those changes in an in-memory database, the response can be corrected in RAM and sent to the client without touching the data on disk at all.

Make a new relationโ€‹

Perhaps the most elegant solution: turn frequently-changing fields into a new relation.

Back to our example: a country's name and abb rarely change, but population might be updated every second โ€” which would force every city and user to update every second. Impossible at scale.

Instead, move population into its own schema and relate it to country (and city):

const populationPure = {
population: number(),
type: enums(["City", "Country"]),
};

const populationRelations = {
country: {
optional: false,
schemaName: "country",
type: "single" as RelationDataType,
relatedRelations: {
populations: {
type: "multiple" as RelationDataType,
limit: 50,
sort: {
field: "_id",
order: "desc" as RelationSortOrderType,
},
},
},
},
city: {
optional: false,
schemaName: "city",
type: "single" as RelationDataType,
relatedRelations: {
populations: {
type: "multiple" as RelationDataType,
limit: 50,
sort: {
field: "_id",
order: "desc" as RelationSortOrderType,
},
},
},
},
};

const populations = coreApp.odm.newModel(
"population",
populationPure,
populationRelations
);

Now remove population from the pure fields of city and country โ€” but you can still filter and sort country/city based on the population relation, since the related snapshots are embedded. Even better, each country keeps a list of its last 50 population records, so you can ask questions like "which countries added more than 100 people in the last minute" without a complex query.

And the crucial payoff: a population change now updates two documents instead of millions. Each population change creates one new population record and stores it only in its country's (or city's) populations list. No more cascading updates.

Next stepsโ€‹