Illustration Image

How to Build a Node.js App with Drizzle ORM and YugabyteDB

Introduction

Choosing an ORM (Object-Relational Mapping) for your application is often a balance between performance, flexibility, and developer experience.

Drizzle ORMhas become a popular option for developers using relational databases to back JavaScript and TypeScript applications. The lightweight nature of this TypeScript-first framework optimizes performance and productivity without sacrificing type safety. Drizzle provides best-in-class feature completeness through dialect-specific packages. This allows developers to work with PostgreSQL and PostgreSQL-compatible databases like YugabyteDB.

In this blog, I’ll demonstrate how you can use Drizzle ORM to develop an IoT application with YugabyteDB. Structured and semi-structured (JSONB) data can be stored in the same database, making YugabyteDB the perfect choice for supporting heterogenous or evolving datasets.

Getting Started

Before setting up a TypeScript project with Drizzle, I’ll create a 3-node YugabyteDB cluster in Docker.

docker run -d --name yugabytedb-node1 --net test_net \
  -p 15433:15433 -p 5433:5433 \
  -v ~/yugabyte-volume/node1:/home/yugabyte/yb_data --restart unless-stopped \
  yugabytedb/yugabyte:latest \
  bin/yugabyted start
  --base_dir=/home/yugabyte/yb_data --background=false \
  --cloud_location=gcp.us-east1.us-east1-a \
  --fault_tolerance=region

while ! docker exec -it yugabytedb-node1 postgres/bin/pg_isready -U yugabyte -h yugabytedb-node1; do sleep 1; done

docker run -d --name yugabytedb-node2 --net test_net \
  -p 15434:15433 -p 5434:5433 \
  -v ~/yugabyte-volume/node2:/home/yugabyte/yb_data --restart unless-stopped \
  yugabytedb/yugabyte:latest \
  bin/yugabyted start --join=yugabytedb-node1 --base_dir=/home/yugabyte/yb_data --background=false \
      --cloud_location=gcp.us-central1.us-central1-a \
      --fault_tolerance=region

docker run -d --name yugabytedb-node3 --net test_net \
  -p 15435:15433 -p 5435:5433 \
  -v ~/yugabyte-volume/node3:/home/yugabyte/yb_data --restart unless-stopped \
  yugabytedb/yugabyte:latest \
  bin/yugabyted start --join=yugabytedb-node1 --base_dir=/home/yugabyte/yb_data --background=false \
  --cloud_location=gcp.us-west2.us-west2-a \
  --fault_tolerance=region

This cluster can be configured to be fault-tolerant across regions. This means the data is replicated across regions allowing the cluster to withstand a full regional outage.

Here’s how to use the yugabyted command-line utility to configure the data placement for the cluster.

docker exec -it yugabytedb-node1 \
    bin/yugabyted configure data_placement --fault_tolerance=region --base_dir=/home/yugabyte/yb_data


+-----------------------------------------------------------------------------------------------+
|                                           yugabyted                                           |
+-----------------------------------------------------------------------------------------------+
| Status                 : Configuration successful. Primary data placement is geo-redundant.   |
| Fault Tolerance        : Primary Cluster can survive at most any 1 region failure.            |
+-----------------------------------------------------------------------------------------------+

Now, connect to the cluster and create a database for use in the application.

docker exec -it yugabytedb-node1 ysqlsh -U yugabyte -p 5433 -h yugabytedb-node1 -c "CREATE DATABASE iot_data;"

With a database up and running, you can begin to develop a sample application using Drizzle.

Drizzle and Drizzle Kit

The Drizzle ecosystem has already rapidly expanded to meet developer demands. Components include Drizzle, the core ORM for querying relational databases, and Drizzle Kit, a command-line tool for managing database migrations.

Here’s how I initialized a new project and installed the dependencies.

mkdir drizzle_iot && cd drizzle_iot
npm init -y
npm install drizzle-orm pg dotenv
npm install --save-dev drizzle-kit tsx @types/pg

This will install the Drizzle ORM and the associated node-postgres driver. If you want to include load balancing and topology awareness in the application, you can use the YugabyteDB node-postgres smart driver as a drop-in replacement. The dotenv package is installed for loading environment variables using a .env file.

Drizzle Kit is installed as a development dependency along with packages for running TypeScript files in Node.js.

Now, set up a connection to YugabyteDB to generate a database schema with Drizzle. The database connection string is defined in a .env file.

// .env
DATABASE_URL="postgresql://yugabyte:yugabyte@localhost:5433/iot_data"

This string can be used to instantiate a connection pool, which is passed to Drizzle.

// connection.ts
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({
 connectionString: process.env.DATABASE_URL,
});

export const db = drizzle(pool);

A Drizzle configuration file is used to define the locations of schema files and generated schema migration files, SQL dialects, database credentials, database extensions, and more.

// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
 schema: "./src/db/schema.ts",
 out: "./drizzle",
 dialect: "postgresql",
 dbCredentials: {
   url: process.env.DATABASE_URL!,
 },
});

Let’s define the schema.ts file referenced in this configuration file.

// schema.ts
import {
 bigint,
 pgTable,
 bigserial,
 text,
 timestamp,
 jsonb,
} from "drizzle-orm/pg-core";

// Table for storing sensor metadata
export const sensors = pgTable("sensors", {
 id: bigserial({mode: "number"}).primaryKey(),
 name: text("name").notNull(),
 location: text("location"),
 created_at: timestamp("created_at").defaultNow(),
});

// Table for storing sensor readings
export const sensorData = pgTable("sensor_data", {
 id: bigserial({mode: "number"}).primaryKey(),
 sensor_id: bigint({mode: "number"})
   .notNull()
   .references(() => sensors.id),
 data: jsonb("data").notNull(), // JSONB column to store sensor readings
 recorded_at: timestamp("recorded_at").defaultNow(),
});

Drizzle’s type definitions come in the form of parameterized functions. I have imported all of the types required to generate a schema with 2 tables, sensors and sensor_data.

The sensor_data table stores a foreign key to a particular sensor, with data stored as JSONB. This data type is particularly useful for storing sensor data, as each sensor will report different metrics. This eliminates the need to create a new structured table with the fields for each sensor.

Now, I generate migration files for this schema and run it against the database to create tables using Drizzle Kit.

npx drizzle-kit generate

No config path provided, using default 'drizzle.config.ts'
Reading config file '/Users/bhoyer/Projects/drizzle_iot/drizzle.config.ts'
2 tables
sensor_data 4 columns 0 indexes 1 fks
sensors 4 columns 0 indexes 0 fks

[✓] Your SQL migration file ➜ drizzle/0000_melted_mercury.sql 🚀

This generates a file with raw DDL statements to execute.

// drizzle/0000_melted_mercury.sql
CREATE TABLE IF NOT EXISTS "sensor_data" (
   "id" bigserial PRIMARY KEY NOT NULL,
   "sensor_id" bigint NOT NULL,
   "data" jsonb NOT NULL,
   "recorded_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "sensors" (
   "id" bigserial PRIMARY KEY NOT NULL,
   "name" text NOT NULL,
   "location" text,
   "created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "sensor_data" ADD CONSTRAINT "sensor_data_sensor_id_sensors_id_fk" FOREIGN KEY ("sensor_id") REFERENCES "public"."sensors"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

npx drizzle-kit migrate

Reading config file '/Users/bhoyer/Projects/drizzle_iot/drizzle.config.ts'
Using 'pg' driver for database querying
[✓] migrations applied successfully

By connecting to YugabyteDB using the ysqlsh CLI, I can verify that the migration succeeded as reported.

docker exec -it yugabytedb-node1 ysqlsh -U yugabyte -p 5433 -h yugabytedb-node1 -d iot_data

iot_data=# \d
                 List of relations
 Schema |        Name        |   Type   |  Owner   
--------+--------------------+----------+----------
 public | sensor_data        | table    | yugabyte
 public | sensor_data_id_seq | sequence | yugabyte
 public | sensors            | table    | yugabyte
 public | sensors_id_seq     | sequence | yugabyte
(4 rows)

Simulating Sensor Data

Let’s use our database to simulate sensor readings from devices. The beauty of Drizzle is that the very same functions used to define the schema can be used in our application code. This simultaneously ensures type safety and enhances the developer experience.

I start by creating three sensors.

// seedSensors.js
import { db } from "./connection";
import { sensors } from "./schema";

async function seedSensors() {
 const sensorEntries = [
   { name: "Temperature Sensor", location: "Living Room" },
   { name: "Humidity Sensor", location: "Office" },
   { name: "Pressure Sensor", location: "Basement" },
 ];

 for (const sensor of sensorEntries) {
   await db.insert(sensors).values(sensor);
 }

 console.log("Sensors have been seeded!");
 process.exit(0);
}

seedSensors().catch((err) => {
 console.error("Error seeding sensors:", err);
 process.exit(1);
});

Here, I can use the Drizzle db object to write to the database using the sensors object. Each write to the database takes an object with the name and location columns, which are the only columns requiring user-supplied data in the sensors schema.

This script can be executed using tsx.

npx tsx seedSensors.ts

With three sensors seeded in the database, let’s create a script for writing simulation data to the sensor_data table.

// simulateSensorData.ts
import { db } from "./connection";
import { sensorData, sensors } from "./schema";

// Generate random data based on sensor type
const generateSensorData = (sensorName: string) => {
 switch (sensorName) {
   case "Temperature Sensor":
     return { temperature: (20 + Math.random() * 15).toFixed(1) }; // 20-35°C
   case "Humidity Sensor":
     return { humidity: (30 + Math.random() * 40).toFixed(1) }; // 30-70%
   case "Pressure Sensor":
     return { pressure: (950 + Math.random() * 50).toFixed(1) }; // 950-1000 hPa
   default:
     return { genericReading: Math.random().toFixed(2) }; // Default generic data
 }
};

// Fetch all sensors
async function getSensors() {
 return await db.select().from(sensors);
}

// Insert sensor readings into the database
async function insertSensorData(sensorId: number, sensorName: string) {
 const data = generateSensorData(sensorName);
 await db.insert(sensorData).values({
   sensor_id: sensorId,
   data,
 });
 console.log(`Inserted data for ${sensorName} (${sensorId}):`, data);
}

// Continuously generate and insert data
async function simulateSensorData() {
 const allSensors = await getSensors();
 if (!allSensors.length) {
   console.error("No sensors found. Seed the database first.");
   process.exit(1);
 }

 setInterval(async () => {
   for (const sensor of allSensors) {
     await insertSensorData(sensor.id, sensor.name);
   }
 }, 1000); // Insert data every second
}

simulateSensorData().catch((err) => {
 console.error("Error during simulation:", err);
 process.exit(1);
});

I first fetch all sensors from the database, before inserting representative sensor data every second.

By setting the foreign key sensor_id and JSONB data, I can associate data to a particular sensor. This simple example shows how Drizzle shares TypeScript objects across the project, aiding schema creation and application building.

We can run this script to see the records being inserted into the sensor_data table.

npx tsx src/db/simulateSensorData.ts

Inserted data for Temperature Sensor (1): { temperature: '32.2' }
Inserted data for Humidity Sensor (2): { humidity: '62.1' }
Inserted data for Pressure Sensor (3): { pressure: '976.4' }
Inserted data for Temperature Sensor (1): { temperature: '33.5' }
Inserted data for Humidity Sensor (2): { humidity: '45.6' }
Inserted data for Pressure Sensor (3): { pressure: '967.1' }
Inserted data for Temperature Sensor (1): { temperature: '34.1' }
Inserted data for Humidity Sensor (2): { humidity: '54.9' }
Inserted data for Pressure Sensor (3): { pressure: '969.7' }
Inserted data for Temperature Sensor (1): { temperature: '30.4' }

Why Drizzle?

The Node.js ecosystem has seen countless ORMs over the years, with major players like Sequelize, TypeORM, and Prisma being the most popular options.

However, all of these frameworks have tradeoffs. Some developers prefer lightweight query builders like Kysely and Knex, due to their performance and simplicity compared to the previously mentioned frameworks.

This is where Drizzle shines, bringing a lightweight and flexible design to the ORM landscape, without sacrificing the features enjoyed by users of higher-level systems with more abstractions.

Drizzle provides robust support for writing raw queries, relational queries, and select statements. Multiple ways to interface with the database allow developers with varied knowledge of SQL to work with the database.

Conclusion

Drizzle ORM is a great option if you want a next-gen solution to pair with your next-gen YugabyteDB database. The addition of Drizzle Kit for managing migrations and Drizzle Studio for visualizing data, allows the team to continue to meet the evolving needs of developers.

Want to learn more about developing apps with YugabyteDB? Check out one of these recent blogs:

Become part of our
growing community!
Welcome to Planet Cassandra, a community for Apache Cassandra®! We're a passionate and dedicated group of users, developers, and enthusiasts who are working together to make Cassandra the best it can be. Whether you're just getting started with Cassandra or you're an experienced user, there's a place for you in our community.
A dinosaur
Planet Cassandra is a service for the Apache Cassandra® user community to share with each other. From tutorials and guides, to discussions and updates, we're here to help you get the most out of Cassandra. Connect with us and become part of our growing community today.
© 2009-2023 The Apache Software Foundation under the terms of the Apache License 2.0. Apache, the Apache feather logo, Apache Cassandra, Cassandra, and the Cassandra logo, are either registered trademarks or trademarks of The Apache Software Foundation. Sponsored by Anant Corporation and Datastax, and Developed by Anant Corporation.

Get Involved with Planet Cassandra!

We believe that the power of the Planet Cassandra community lies in the contributions of its members. Do you have content, articles, videos, or use cases you want to share with the world?