Build a JSON Database in Node.js (Part 6): Update Records & Prevent No-Ops
69 min read·Dec 5, 2025
In this part, we'll implement a method for updating the attributes of existing records, including a no-op guard to prevent unnecessary disk writes.
Ready? Let's build!
Update the attributes of records
Within the Model class, let's define a new method named update() responsible for updating the attributes of existing records in the associated model's table:
// ...
export default class Model {
// ...
update(filters = {}, values = {}) {
//
}
}
This method takes as parameters:
filters <Object>: The list of filters identical to thefilters.whereobject used in thefind()method.values <Object>: The list of attributes to update.
Example
Let's consider the following query:
await Users.update({
email: {
$in: [
'bob.fischer@gmail.com',
'alice.green@outlook.com',
'jack.dawson@hotmail.com',
]
},
{
status: 'inactive'
}
});
Which in plain English translates to:
"In the
userstable, setstatusto'inactive'where'bob.fischer@gmail.com','alice.green@outlook.com','jack.dawson@hotmail.com'.
And in SQL to:
UPDATE users SET status = 'inactive' WHERE email IN ('bob.fischer@gmail.com', 'alice.green@outlook.com', 'jack.dawson@hotmail.com');
Check the method's parameters
Within this method, let's throw an error if the filters or the values parameters are not objects.
// ...
export default class Model {
// ...
update(filters = {}, values = {}) {
if (!isPlainObject(filters)) {
throw new Error('Model: update(): "filters" must be an object');
} else if (!isPlainObject(values)) {
throw new Error('Model: update(): "values" must be an object');
}
}
}
Retrieve and filter records
Let's iterate on the records of the model's table and use the #evalFilters() method to check if the current record matches the list of filters.
// ...
export default class Model {
// ...
update(filters = {}, values = {}) {
// ...
for (let record of this.#table) {
if (this.#evalFilters(record, filters)) {
//
}
}
}
}
💡 Unlike
find(),update()doesn't clone records. It iterates on the actual records of the model's table and uses their reference in order to update their original value directly.
Validate and sanitize attributes
Let's
- Merge the record's current attributes with the list of specified values into a temporary object to avoid overwriting the record's original values.
- Validate and sanitize the properties of the temporary object using the model's validation schema.
// ...
export default class Model {
// ...
update(filters = {}, values = {}) {
// ...
let updatedCount = 0;
for (let record of this.#table) {
if (this.#evalFilters(record, filters)) {
const updatedRecord = { ...record, ...values };
const { value, error } = this.#schema.validate(updatedRecord, { abortEarly: false });
}
}
}
}
Merge attributes and update records
Let's:
- Declare an
updatedCountvariable to keep track of the total number of records updated. - Throw an error if the properties of the temporary object have been rejected by the validation schema.
- Otherwise, merge the properties returned by the validation schema with the properties of the record.
- Increment the updates counter by 1.
// ...
export default class Model {
// ...
update(filters = {}, values = {}) {
// ...
let updatedCount = 0;
for (let record of this.#table) {
if (this.#evalFilters(record, filters)) {
// ...
if (error) {
throw new Error(`Model: update(): ${error.message}`);
}
Object.assign(record, value);
updatedCount++;
}
}
}
}
Persist changes on disk
Finally, let's:
- Declare the
update()method asynchronous. - Execute the
#saveToDisk()method to persist the table's changes on disk. - Return the updates counter.
// ...
export default class Model {
// ...
async update(filters = {}, values = {}) {
// ...
await this.#saveToDisk();
return updatedCount;
}
}
Prevent no-op updates
A no-op update is an attempted write that sets a record to the exact same values it already has.
Create a shallow comparison function
To prevent these unnecessary updates and write operations, let's first create and export a new function named isShallowEqual() in the utils.js module that returns true if the top-level properties of two objects are equal, and false otherwise.
// ...
const isShallowEqual = (a, b) => {
if (a === b) {
return true;
} else if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return false;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) {
return false;
}
for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) {
return false;
}
}
return true;
};
export {
isNonEmptyString,
isPlainObject,
isShallowEqual,
};
💡 In programming, comparing the top-level properties of two objects is referred to as "shallow equality".
Include a no-op update guard
Let's import the isShallowEqual() function into the model.js module, and use it to ensure that the properties of the record and the schema-validated object are actually different before merging them and incrementing the updates counter.
import { isPlainObject, isShallowEqual } from './utils.js';
export default class Model {
// ...
async update(filters = {}, values = {}) {
// ...
for (let record of this.#table) {
if (this.#evalFilters(record, filters)) {
// ...
if (!isShallowEqual(record, value)) {
Object.assign(record, value);
updatedCount++;
}
}
}
//...
}
}
Finally, let's check that at least one record has been updated before persisting the database changes, thus avoiding an unnecessary write operation.
// ...
export default class Model {
// ...
async update(filters = {}, values = {}) {
// ...
if (updatedCount > 0) {
await this.#saveToDisk();
}
return updatedCount;
}
}
Delete records
Within the Model class, let's define a new method named delete() responsible for deleting records from the associated model's table:
// ...
export default class Model {
// ...
delete(filters = {}) {
//
}
}
This method takes as a single parameter:
filters <Object>: The list of filters identical to thefilters.whereobject used in thefind()method.
Example
Let's consider the following query:
await Emails.delete({
folder: { $eq: 'bin' },
read: { $eq: false }
});
Which in plain English translates to:
"Delete all the records from the
emailstable wherefolderequals'bin'andreadtofalse."
And in SQL to:
DELETE FROM emails WHERE folder = 'bin' AND read = FALSE;
Check the method's parameters
Within this method, let's throw an error if the filters parameter isn't an object.
// ...
export default class Model {
// ...
delete(filters = {}) {
if (!isPlainObject(filters)) {
throw new Error('Model: delete(): "filters" must be an object');
}
}
}
Filter records
Let's iterate on the records of the model's table in reverse order to keep the indices of the not-yet-visited records unchanged and use the #evalFilters() method to check if the current record matches the list of filters.
// ...
export default class Model {
// ...
delete(filters = {}) {
// ...
for (let i = this.#table.length - 1 ; i >= 0 ; i--) {
let record = this.#table[i];
if (this.#evalFilters(record, filters)) {
//
}
}
}
}
Let's:
- Declare a
deletedCountvariable to keep track of the number of records deleted. - Remove the current record from the table if the list of filters are a match.
- Increment the deletions counter by 1.
// ...
export default class Model {
// ...
delete(filters = {}) {
// ...
let deletedCount = 0;
for (let i = this.#table.length - 1 ; i >= 0 ; i--) {
let record = this.#table[i];
if (this.#evalFilters(record, filters)) {
this.#table.splice(i, 1);
deletedCount++;
}
}
}
}
Finally, let's:
- Declare the
delete()method as asynchronous. - Use the
#saveToDisk()method to persist the changes on disk if at least one record has been deleted. - Return the deletions counter.
// ...
export default class Model {
// ...
async delete(filters = {}) {
// ...
if (deletedCount > 0) {
await this.#saveToDisk();
}
return deletedCount;
}
}
Enable soft deletions
Soft deletion is the practice of marking records as deleted instead of physically and irreversibly removing them, which in turn allows developers to undo deletions whenever needed and keep data for audits/history.
Update the define() method
To implement this mechanism, let's first update the define() method of the RowStackDB class by adding an is_deleted property to the validation schema object, with a default value set to false.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
this.#schemas[name] = Joi.object({
...schema,
is_deleted: Joi.boolean().default(false).required()
});
}
// ...
}
Update the create() method
Let's update the create() method of the Model class by adding an is_deleted property (set to false by default) to the validated object to account for the same addition in the validation schema object.
// ...
export default class Model {
// ...
async create(record = {}) {
// ...
const { value, error } = this.#schema.validate({
...record,
is_deleted: false
}, {
abortEarly: false
});
// ...
}
}
Update the find() method
Let's update the find() method of the Model class by filtering out the records whose is_deleted property is set to true, and remove this property from the objects in the results set to keep it "hidden".
// ...
export default class Model {
// ...
find(filters = {}) {
let results = this.#table
.map(record => ({ ...record }))
.filter(result => !result.is_deleted && this.#evalFilters(result, filters.where))
.map(({ is_deleted, ...result }) => result);
// ...
}
}
Update the update() method
Let's update the update() method of the Model class by filtering out the records whose is_deleted property is set to true.
// ...
export default class Model {
// ...
async update(filters = {}, values = {}) {
// ...
for (let record of this.#table) {
if (!record.is_deleted && this.#evalFilters(record, filters)) {
// ...
}
}
// ...
}
}
Update the delete() method
Let's:
- Update the signature of the
delete()method by adding a new parameter namedoptionswith a default property namedsoftset totrueused to mark records as "softly deleted" by default. - Throw an error if the
optionsparameter is not an object.
// ...
export default class Model {
// ...
async delete(filters = {}, options = { soft: true }) {
if (!isPlainObject(filters)) {
throw new Error('Model: delete(): "filters" must be an object');
} else if (!isPlainObject(options)) {
throw new Error('Model: delete(): "options" must be an object');
}
// ...
}
}
Finally, let's:
- Filter out the records whose
is_deletedproperty is set totrue. - Physically remove the matching records from the table if the
options.softproperty is set tofalse. - Otherwise, set their
is_deletedproperty totrue.
// ...
export default class Model {
// ...
async delete(filters = {}, options = { soft: true }) {
// ...
for (let i = this.#table.length - 1 ; i >= 0 ; i--) {
let record = this.#table[i];
if (!record.is_deleted && this.#evalFilters(record, filters)) {
if (options.soft === false) {
this.#table.splice(i, 1);
} else {
record.is_deleted = true;
}
deletedCount++;
}
}
// ...
}
}
Conclusion
Congratulations!
You now have a method to efficiently update records.
In the next part, you'll learn how to implement a method for safely deleting records from a table.
Read next: Build a JSON Database in Node.js (Part 7): Delete Records Softly & Hardly