Build a JSON Database in Node.js (Part 2): Table Definition & Data Integrity
38 min read·Dec 5, 2025
In database design, the domain integrity constraint states that all of the attributes of a relation must be declared upon a defined domain.
In other words, this means that whenever creating a new attribute in a relation, a certain number of properties must be defined in order to control the kind of data that can be stored in it, such as its:
- data type,
- length,
- acceptance or not of null values,
- set of allowable values,
- default value.
In this second part, we'll implement a method for defining database tables whose data structure will be enforced by validation schemas responsible for validating and sanitizing the data of objects before they are inserted or updated as records.
Then, we'll implement an ORM-like layer that will later on provide methods for performing CRUD operations on database tables.
Ready? Let's build!
Create a table definition method
Within the RowStackDB class, let's declare a define() method responsible for:
- Creating new database tables.
- Defining the structure of these tables.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
//
}
}
This method takes as parameters:
name <string>: The table's name (e.g.,'users').attributes <Object>: The table's attributes (e.g.,{ email, username }).
💡 This method is in a way equivalent to the
CREATE TABLEstatement in SQL.
Create a new table
Within the define() method, let's:
- Throw an error if the
nameparameter is not a non-empty string. - Create a new database table by adding a new property to the
#tablesprivate class field using thenameparameter as key, and assign it an empty array ([]) that will be used to store the table's record objects.
// ...
export default class RowStackDB {
// ...
#tables;
// ...
define(name, attributes) {
if (!isNonEmptyString(name)) {
throw new Error(`RowStackDB: define(): "name" must be a string`);
}
if (!this.#tables[name]) {
this.#tables[name] = [];
}
}
}
Enforce domain integrity
To enforce domain integrity on the records of a table, each attribute in the attributes object parameter will be defined according to the following properties:
{
attribute: {
type,
allowNull?,
values?,
defaultValue?,
}
}
Where:
attribute <string>: The attribute's name (e.g.,'account_type').type <Function>: The attribute's Joi data type (e.g.,Joi.boolean(),Joi.string()).allowNull? <boolean>: Whether the attribute acceptsnullas a value (e.g.,true). Optional. Default:false.values? <Array[<any>]>: A list of accepted values (e.g.,['freemium', 'premium']). Optional.defaultValue? <any>: A default value if the attribute isundefinedduring the creation or update of a record (e.g.,'freemium'). Optional. IfdefaultValueisundefined, the attribute must be specified.
Upon execution of the define() method, the attributes object will be translated into a schema that will later on be used by another layer of the application to validate and sanitize data objects before they are inserted or updated in the table.
To do so, we'll use the joi package — a popular data validation library that helps developers define the shape and constraints of data, and check that any given object matches that shape before the application uses it.
For example, the following call to the define() method:
define('users', {
username: {
type: Joi.string()
},
fullName: {
type: Joi.string(),
allowNull: true
},
account: {
type: Joi.any(),
values: ['freemium', 'premium'],
defaultValue: 'freemium'
}
});
Will result into the creation of the following validation schema:
Joi.object({
username: Joi.string().max(255).invalid(null).required(),
fullName: Joi.string().max(255).allow(null).required(),
account: Joi.any().valid('freemium', 'premium').invalid(null).default('freemium')
});
Import the Joi package
Let's start by importing the joi package into the engine.js module.
import * as path from 'node:path';
import * as fs from 'node:fs';
import Joi from 'joi';
import { isNonEmptyString, isPlainObject } from './utils.js';
// ...
Iterate on attributes
Within the define() method, let's throw an error if the attributes parameter is not a valid object with at least one property.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
if (!isPlainObject(attributes) || !Object.keys(attributes).length) {
throw new Error(`RowStackDB: define(): "attributes" must be a non-empty object`);
}
}
}
Let's declare a variable named schema that will be used to aggregate the translated properties of the attributes parameter.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
let schema = {};
}
}
For each attribute, let's:
- Store the attribute's name into the
attributevariable and its properties into theconfigvariable. - Throw an error if
configis not an object with at least one property. - Otherwise, declare a new
rulevariable that will be used to build its validation rule.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
let schema = {};
for (const [attribute, config] of Object.entries(attributes)) {
if (!isPlainObject(config) || !Object.keys(config).length) {
throw new Error(`RowStackDB: define(): config for "${attribute}" must be a non-empty object`);
}
let rule;
}
}
}
Parse the attribute's type
Let's:
- Throw an error if
config.typeis not a valid Joi data type. - Otherwise, store its value into the
rulevariable to which we'll chain other Joi validation rules.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
for (const [attribute, config] of Object.entries(attributes)) {
// ...
if (!Joi.isSchema(config.type)) {
throw new Error(`RowStackDB: define(): "type" for "${attribute}" must be a Joi data type`);
} else {
rule = config.type;
}
}
}
}
Parse the attribute's accepted values
Let's check if config.values is defined and:
- Throw an error if it is not an array.
- Otherwise, chain the Joi
valid()method to therulevariable to only allow for the values it contains.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
for (const [attribute, config] of Object.entries(attributes)) {
// ...
if (config.values !== undefined) {
if (!Array.isArray(config.values)) {
throw new Error(`RowStackDB: define(): "values" of "${attribute}" must be an array`);
}
rule = rule.valid(...config.values);
}
}
}
}
Parse the attribute's default value
Let's check if config.defaultValue is defined and:
- Chain the Joi
default()method to therulevariable to specify the attribute's default value upon record creation/update. - Otherwise, chain the Joi
required()method to therulevariable to make the attribute's definition mandatory upon record creation/update.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
for (const [attribute, config] of Object.entries(attributes)) {
// ...
if (config.defaultValue !== undefined) {
rule = rule.default(config.defaultValue);
} else {
rule = rule.required();
}
}
}
}
👉 Since the value of
config.defaultValuecan be set to0ornull, we have to explicitly check that it is notundefinedinstead of simply truthy.
Parse the attribute's nullable value
Let's check if config.allowNull is defined and:
- Throw an error if it's not a boolean.
- Chain the Joi
allow()method to therulevariable to allow fornullas a value if the property is set totrue, and the Joiinvalid()method otherwise. - Otherwise, chain the Joi
invalid()method.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
for (const [attribute, config] of Object.entries(attributes)) {
// ...
if (config.allowNull !== undefined && typeof config.allowNull !== 'boolean') {
throw new Error(`RowStackDB: define(): "allowNull" of "${attribute}" must be a boolean`);
}
if (config.allowNull) {
rule = rule.allow(null);
} else {
rule = rule.invalid(null);
}
}
}
}
Store the attribute's rule
Now that the validation chain is complete, let's store the rule variable into the schema variable under the attribute's key.
// ...
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
let schema = {};
for (const [attribute, config] of Object.entries(attributes)) {
// ...
schema[attribute] = rule;
}
}
}
Convert and store the validation schema
Within the constructor() method, let's initialize a new private class field named #schemas with an empty object literal ({}) that will be used to store the validation schemas of the database tables.
// ...
export default class RowStackDB {
// ...
#schemas;
constructor(dbName, dirPath = 'data') {
// ...
this.#schemas = {};
}
// ...
}
Within the define() method, let's use the Joi.object() method to convert the schema object into a usable Joi validation schema and store it into the #schemas object using the table's name as key.
// ...
export default class RowStackDB {
// ...
#schemas;
// ...
define(name, attributes) {
// ...
this.#schemas[name] = Joi.object(schema);
}
}
Create reusable data types
In order to improve the consistency and configurability of the validation schemas, let's create a new module named datatypes.js responsible for exporting a list of factory functions that wrap around the Joi API.
Within this file, let's:
- Import the
joipackage. - Export an object whose properties are named after MySQL's most commonly used data types.
import Joi from 'joi';
export default {
BOOLEAN: () => Joi.boolean(),
INTEGER: () => Joi.number().integer(),
FLOAT: (precision = 2) => {
let rule = Joi.number();
if (Number.isInteger(precision)) {
rule = rule.precision(precision);
}
return rule;
},
VARCHAR: (size = 255, pattern = null) => {
let rule = Joi.string();
if (Number.isInteger(size)) {
rule = rule.max(size);
}
if (pattern instanceof RegExp) {
rule = rule.pattern(pattern);
}
return rule;
},
DATETIME: () => Joi.date().iso().custom(date => (date instanceof Date ? date.toISOString() : date)),
ENUM: () => Joi.any()
};
Where:
-
BOOLEAN <Function>: Returns a Joi boolean type. -
INTEGER <Function>: Returns a Joi integer number type. -
FLOAT <Function>: Returns a Joi floating point number type that takes an optional parameter where:precision <integer>: The maximum number of decimal places.
-
VARCHAR <Function>: Returns a Joi string type that takes optional parameters where:size <integer>: The maximum number of characters.pattern <RegExp>: A pattern rule.
-
DATETIME <Function>: Returns a Joi date type and time expressed in the ISO format. -
ENUM <Function>: Returns a generic Joi type meant to be combined with values.
For example:
define('users', {
username: {
type: DataTypes.VARCHAR(8, new RegExp('^[a-zA-Z0-9-_]{1,8}$'))
},
fullName: {
type: DataTypes.VARCHAR(100),
allowNull: true
},
account: {
type: DataTypes.ENUM(),
values: ['freemium', 'premium'],
defaultValue: 'freemium'
}
});
Export the data types module
Within the "barrel" module, let's export the datatypes.js module as DataTypes.
export { default as RowStackDB } from './engine.js';
export { default as DataTypes } from './datatypes.js';
Create a model class
In ORM design, a model is an abstraction that maps to a single table in the database.
This layer is responsible for providing create, read, update, and delete (CRUD) methods to manipulate the table's data and enforcing domain integrity using its associated validation schema.
Separating the model from the database engine allows us to:
- Keep the database logic (file management, table creation) isolated from the data logic (validation, queries).
- Reuse the same model class for every table instead of re-implementing CRUD operations for each one.
- Write cleaner, more expressive code, for example,
Products.find()instead ofdb.find('products'). - Improve maintainability and scalability, since each model operates independently of the others.
Create the model module
Let's create a new module named model.js, and within it, declare and export a class named Model.
export default class Model {
//
}
Initialize the table and schema
Within the Model class, let's declare a constructor() method.
export default class Model {
constructor(table, schema) {
//
}
}
This method takes as parameters:
table <Array>: An array of records from the#tablesprivate class field of theRowStackDBclass.schema <Object>: A Joi validation schema from the#schemasprivate class field of theRowStackDBclass.
Within this method, let's:
- Import the
Joipackage. - Throw an error if the
tableparameter is not an array. - Throw an error if the
schemaparameter is not a Joi validation schema. - Otherwise, store these parameters into private class fields named
#tableand#schema.
import Joi from 'joi';
export default class Model {
#table;
#schema;
constructor(table, schema) {
if (!Array.isArray(table)) {
throw new Error(`Model: constructor(): "table" must be an array`);
} else if (!Joi.isSchema(schema)) {
throw new Error(`Model: constructor(): "schema" must be a validation schema`);
}
this.#table = table;
this.#schema = schema;
}
}
Instantiate the model
The most practical way to expose the CRUD methods provided by the model to the end-user is to instantiate the Model class within the define() method of the RowStackDB class, and return its instance to the calling code.
This way, we'll have direct access to the underlying table's API when defining a table, for example:
const Users = database.define('users', { /* ... */ });
await Users.create(/* ... */);
const results = Users.read(/* ... */);
const updated = await Users.update(/* ... */);
const deleted = await Users.delete(/* ... */);
So, let's:
- Import the
model.jsmodule into theengine.jsmodule. - Within the
define()method of theRowStackDBclass, return a new instance of theModelclass using as parameter the table and schema we've previously defined.
// ...
import Model from './model.js';
export default class RowStackDB {
// ...
define(name, attributes) {
// ...
return new Model(this.#tables[name], this.#schemas[name]);
}
}
Conclusion
Congratulations!
You now have a database engine that can define database tables, structure them, and return a CRUD operation layer.
In the next part, you'll learn how to implement a method for inserting records into the database and persisting them to the disk.
Read next: Build a JSON Database in Node.js (Part 3): Insert & Persist Records
Unlock the program 🚀
Pay once, own it forever.
€79
30-day money-back guarantee
- 13 modules
- 113 lessons with full-code examples
- 29 projects with commented solutions
- All future lesson and project updates
- Lifetime access
By submitting this form, you agree to the Terms & Conditions and Privacy Policy.