In programming, getting and setting values are some of the most basic instructions you can write. Keeping track of how values have changed over time, however, requires a little more thought. For example, relational databases can use triggers to keep track of changes, but that only applies when an update script is run. What about code that performs update logic before ultimately writing back to the database? There’s always the option to rely on bespoke operations to test every possible change the programmer can think of, but that’s error prone and leads to disgusting code. Last night, I was wondering if it would be possible to build a generic way to track changes to an underlying object. Today, I released the obj-ledger library that does just that. This article is a deep dive into that library and the programming techniques that made it possible.
github: https://github.com/crispwaters/obj-ledger
npm: https://www.npmjs.com/package/obj-ledger
The first question to answer: what does this library even do? It accepts an existing JavaScript object, creates a copy of the object, and keeps track of changes applied to the object through the wrapper itself. That last note is important, changes should be made on the returned Ledger object directly and not on the original object. The Ledger object retains information about the original state of the object, the changes that have been made, and the final state of the object.
The second question to answer: why use JavaScript classes? This may be up to more personal preference, but I find that classes in JavaScript are very pleasant to work with, especially when dealing with backend code like Node. The main reason I choose to use classes instead of objects is classes handle setting up the prototype chain for me. The functions defined on the class only exist in one spot in memory: the class itself. Since JavaScript is a prototype language, if we defined the functions on the instantiated objects themselves, it would require a lot more resources to keep track of every ledger object. Instead, we can just keep track of the information at a class level and let the prototype chain allow the objects to find the functions needed at runtime. Another reason to use classes was to allow the use of private fields.
The third question to answer: why use private fields? The answer is to ensure data integrity. Since the goal is to be able to reliably keep track of changes to the underlying object, I needed to keep the values that stored the data separate from the interfaces that allow the user to interact with it. If the data were public, then it could be changed by the user without making the proper changes to the history information, thus rendering the entire library useless.
With the main design decisions addressed above, it’s time to dig into the implementation details. Namely, how do the public interfaces interact with the private data? There are two main ways of handling that interaction. The first way deals with constant data in which the value is a frozen object and the property cannot be overwritten. This prevents the user from being able to make any updates to the data which shouldn’t be changed in the first place. The second way is to use getters and setters on the object to allow the user to access the data without sacrificing the integrity of the data. This can be as simple as returning a copy of the data where the user cannot alter the data or, as in the case with properties on the object, also include setter methods that allow updating the underlying value in a way that also keeps track of history changes so they can be retrieved later.
set (key, value) {
if (this.obj[key] === undefined) {
const ledger = this
Object.defineProperty(ledger.obj, key, {
get () { return ledger.#values[key] },
set (value) {
if (value !== ledger.#values[key]) {
if (ledger.#logUpdates) ledger.#log.push({ [key]: { from: ledger.#values[key], to: value } })
if (ledger.#initialized) {
if (ledger.#audit[key] === undefined) ledger.#audit[key] = []
ledger.#audit[key].push(value)
}
}
ledger.#values[key] = value
}
})
}
this.obj[key] = value
}
The set method above shows how the obj property on the ledger object is accessible from outside the class but it is just an interface for interacting with the data held in the private #values object. When retrieving the value, it just returns the corresponding value from the private field. But when the value is changed through the setter, it performs additional checks to see if history information needs to be tracked.
Some final thoughts about this module. The first consideration when deciding if this module is right for your project is that it was designed for objects consisting of only primitive types. There is some future potential for allowing properties to have custom getters and setters based upon their underlying type, but supporting that was not the primary goal of this exercise. The other main consideration is that the JavaScript runtime must support private fields in classes. If running Node 12 or later, this shouldn’t be an issue, but existing Node 10 applications wouldn’t support this. However, since Node 10 will exist the maintenance phase later this spring, supporting it was not a concern of mine.