Chapter 8: Node.js: Attending to the Runtime
The Node.js PostgreSQL ecosystem presents a distinctive challenge: three competing ORMs, each with its own philosophy, its own migration system, and its own level of materialized view support — which ranges, I'm afraid, from "workaround required" to "explicit but incomplete" to "genuinely good, actually."
The good news is that materialized views require no ORM support. They are tables. Every ORM can read from tables. The workarounds for each are straightforward once you know what they are, and the underlying SQL — CREATE MATERIALIZED VIEW, REFRESH MATERIALIZED VIEW CONCURRENTLY, SELECT FROM — is identical regardless of which ORM delivers it.
This chapter covers the raw driver first (the foundation everything else builds on), then Prisma (the most popular), Drizzle (the fastest-growing), and TypeORM (the most explicit MV support), followed by scheduling and event-driven refresh patterns.
The Foundation: node-postgres
The pg library by Brian Carlson is the foundation of nearly every Node.js PostgreSQL application. Prisma, Drizzle, and TypeORM all sit on top of it or its equivalents. When your ORM can't express something — and every Node.js ORM has limits — you fall back to pool.query(). Understanding the raw driver ensures you are never blocked by ORM limitations.
import { Pool } from 'pg';
const pool = new Pool({
connectionString: 'postgresql://postgres:secret@localhost:5432/myapp',
max: 20,
idleTimeoutMillis: 30000,
});
// Create (in a migration script — run once)
await pool.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_dashboard AS
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*)::integer AS events,
COUNT(DISTINCT user_id)::integer AS unique_users,
SUM(CASE WHEN event_type = 'purchase'
THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
WITH DATA
`);
await pool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_dashboard_day
ON mv_dashboard (day)
`);
// Query — identical to querying any table
const { rows } = await pool.query(
'SELECT * FROM mv_dashboard ORDER BY day DESC LIMIT 30'
);
// Refresh
await pool.query(
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard'
); This is the pattern that every ORM section in this chapter builds on. The SQL is the same everywhere. The ORM provides type safety, query building, and migration management on top of it. The materialized view does not care which layer sends the query.
Prisma
Prisma is the most popular Node.js ORM, and it has no native materialized view support. GitHub issue #18758 requests it. The Prisma team has acknowledged the request and noted that materialized views "would need some Client API and would also need to be migrated differently." As of early 2026, no implementation timeline has been announced.
The workaround, however, is clean and production-viable. Prisma's view keyword — currently a preview feature, though it may graduate to general availability in a future release — maps to database views and materialized views alike. You declare the materialized view as a view in your Prisma schema, and Prisma generates fully typed read operations. Refresh uses $executeRaw. Your application code never knows the difference.
Schema declaration
// schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["views"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
view DashboardMetrics {
day DateTime @unique @db.Date
events Int
uniqueUsers Int @map("unique_users")
revenue Decimal @db.Decimal(13, 2)
@@map("mv_dashboard")
} The view keyword tells Prisma three things: this object is read-only (no create, update, or delete methods will be generated), it is not managed by Prisma Migrate (Prisma will not attempt to CREATE or ALTER it), and it maps to an existing database object. The @@map directive connects the Prisma model name to the materialized view's actual name in PostgreSQL.
Migration
Prisma migrations use SQL files. Create the materialized view in a manual migration:
npx prisma migrate dev --create-only --name create_mv_dashboard This generates an empty migration file. Edit it to contain your materialized view definition:
-- prisma/migrations/XXXX_create_mv_dashboard/migration.sql
CREATE MATERIALIZED VIEW mv_dashboard AS
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*)::integer AS events,
COUNT(DISTINCT user_id)::integer AS unique_users,
SUM(CASE WHEN event_type = 'purchase'
THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
WITH DATA;
CREATE UNIQUE INDEX idx_mv_dashboard_day ON mv_dashboard (day); Then apply: npx prisma migrate dev. The materialized view is created, and Prisma Client is regenerated with the typed DashboardMetrics model.
Query — fully typed
const metrics = await prisma.dashboardMetrics.findMany({
orderBy: { day: 'desc' },
take: 30,
});
// metrics is DashboardMetrics[] with full TypeScript autocomplete
// metrics[0].day — DateTime
// metrics[0].uniqueUsers — number
// metrics[0].revenue — Decimal The type safety is genuine and valuable. Every field has its correct TypeScript type, derived from the Prisma schema. If you access a field that doesn't exist, TypeScript tells you at compile time. This is the experience that makes Prisma compelling, and it works identically for materialized views declared as view blocks.
Refresh
await prisma.$executeRaw`
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard
`; $executeRaw bypasses Prisma's query engine for direct SQL execution. It is the mechanism for any SQL that Prisma's API cannot express — and REFRESH MATERIALIZED VIEW is one such operation.
Prisma may add native materialized view support in the future — a materialized modifier on the view keyword, a prisma.dashboardMetrics.refresh() method. Until then, this workaround is stable, the types are correct, and your application doesn't know the difference between a materialized view declared as a view and a table declared as a model. That is precisely the point.
Drizzle
Drizzle ORM has risen rapidly through 2025 and 2026, positioning itself as a lightweight, TypeScript-first alternative to Prisma. For materialized views specifically, Drizzle offers something no other Node.js ORM provides: a dedicated pgMaterializedView() function for schema declaration, with drizzle-kit migration generation support added in the 0.26.x series (check the latest release notes for bug fixes in early versions).
Declaration with the query builder
When your materialized view's query can be expressed through Drizzle's query builder:
import { pgMaterializedView, count, countDistinct, sum, sql } from 'drizzle-orm/pg-core';
import { events } from './schema';
export const mvDashboard = pgMaterializedView('mv_dashboard')
.as((qb) => qb
.select({
day: sql`date_trunc('day', ${events.createdAt})::date`.as('day'),
events: count().as('events'),
uniqueUsers: countDistinct(events.userId).as('unique_users'),
})
.from(events)
.groupBy(sql`1`)
); Drizzle infers the column types automatically from the query builder — no manual type declaration needed. Notice that the revenue column is absent here — the SUM(CASE WHEN ...) expression exceeds the query builder's expressiveness. For the complete view including revenue, use raw SQL.
Declaration with raw SQL
For queries that exceed the query builder's expressiveness — conditional aggregations, window functions, CTEs:
import { pgMaterializedView, date, integer, numeric } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const mvDashboard = pgMaterializedView('mv_dashboard', {
day: date('day').primaryKey(),
events: integer('events').notNull(),
uniqueUsers: integer('unique_users').notNull(),
revenue: numeric('revenue', { precision: 13, scale: 2 }).notNull(),
}).as(sql`
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*)::integer AS events,
COUNT(DISTINCT user_id)::integer AS unique_users,
SUM(CASE WHEN event_type = 'purchase'
THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
`); When using raw SQL, you declare the column types explicitly — Drizzle cannot infer them from a SQL string.
The .existing() pattern
For materialized views that were created outside Drizzle — perhaps through a raw SQL migration, or by a DBA, or by a previous ORM:
export const mvDashboard = pgMaterializedView('mv_dashboard', {
day: date('day').primaryKey(),
events: integer('events').notNull(),
uniqueUsers: integer('unique_users').notNull(),
revenue: numeric('revenue', { precision: 13, scale: 2 }).notNull(),
}).existing(); The .existing() modifier tells drizzle-kit to ignore this view entirely — no migration generated, no CREATE statement, no management. Drizzle simply provides typed query access to an object that already exists in the database. This is analogous to Django's managed = False: the ORM reads from it but does not claim ownership.
Migration
drizzle-kit version 0.26.0 and later generates migrations for materialized views declared in the schema. Running npx drizzle-kit generate produces the CREATE MATERIALIZED VIEW SQL in the migration file. For .existing() views, use npx drizzle-kit generate --custom and write the migration SQL manually.
Query
import { desc } from 'drizzle-orm';
const metrics = await db
.select()
.from(mvDashboard)
.orderBy(desc(mvDashboard.day))
.limit(30);
// metrics is typed based on the view declaration Refresh
await db.execute(
sql`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard`
); Drizzle does not provide a dedicated refresh method — the raw sql template literal is the mechanism. This is a minor inconvenience, not a limitation. The SQL is one line.
Current limitations
Drizzle's materialized view support is the most complete in the Node.js ecosystem, but gaps remain. drizzle-kit introspect does not pick up existing materialized views from the database — you must declare them manually. The Drizzle relations API (for nested queries with db.query) does not support materialized views. And refresh requires raw SQL rather than a typed method. These are active areas of development; the trajectory since v0.26.0 suggests they will be addressed.
TypeORM
TypeORM has the most explicit materialized view declaration of any Node.js ORM — a @ViewEntity decorator with a materialized: true option:
import { ViewEntity, ViewColumn, DataSource } from 'typeorm';
@ViewEntity({
materialized: true,
expression: `
SELECT
date_trunc('day', created_at)::date AS day,
COUNT(*)::integer AS events,
COUNT(DISTINCT user_id)::integer AS unique_users,
SUM(CASE WHEN event_type = 'purchase'
THEN amount ELSE 0 END)::numeric(13,2) AS revenue
FROM events
WHERE created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY 1
`
})
export class DashboardMetrics {
@ViewColumn() day: Date;
@ViewColumn() events: number;
@ViewColumn({ name: 'unique_users' }) uniqueUsers: number;
@ViewColumn() revenue: number;
} Query
const metrics = await dataSource
.getRepository(DashboardMetrics)
.find({
order: { day: 'DESC' },
take: 30,
}); Refresh
await dataSource.query(
'REFRESH MATERIALIZED VIEW CONCURRENTLY dashboard_metrics'
); TypeORM will create the materialized view during synchronize — a convenience for development. For production, manage materialized views through TypeORM's migration system instead. synchronize compares the current database state to the entity metadata and applies changes directly; this is useful for development iteration but dangerous in production where data loss from an unexpected DROP-and-recreate would be rather more consequential than a failed migration.
Scheduling the Refresh
Three approaches, in order of simplicity.
node-cron: in-process, no dependencies
import cron from 'node-cron';
cron.schedule('*/15 * * * *', async () => {
try {
await pool.query(
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard'
);
console.log('Dashboard MV refreshed');
} catch (err) {
console.error('Refresh failed:', err);
// Route to Sentry, PagerDuty, Slack, etc.
}
}); node-cron runs inside your Node.js process. No external service. No additional npm package beyond node-cron itself. For a single-instance deployment, this is the path of least resistance.
BullMQ: for multi-instance deployments
When multiple application instances are running and you need to ensure only one refreshes at a time, BullMQ provides distributed job scheduling:
import { Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';
const connection = new Redis();
const refreshQueue = new Queue('mv-refresh', { connection });
await refreshQueue.add('dashboard', {}, {
repeat: { every: 15 * 60 * 1000 }
});
new Worker('mv-refresh', async () => {
await pool.query(
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard'
);
}, { connection }); I note with some irony that BullMQ requires Redis for its job queue. If your only use of Redis is powering the queue that refreshes materialized views designed to eliminate Redis, you have constructed a dependency loop that node-cron or pg_cron would resolve more gracefully.
pg_cron: the simplest option
If your refresh doesn't require application logic — no conditional checks, no error routing to Sentry, no integration with deployment pipelines — then pg_cron eliminates the Node.js code entirely:
SELECT cron.schedule('refresh-dashboard', '*/15 * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard'); Three lines of SQL. No npm package. No Node.js process management. The refresh runs inside PostgreSQL regardless of whether your application is running, deployed, or in the middle of a blue-green deployment.
Multi-instance without Redis
For teams that need multi-instance coordination without adding Redis, the advisory lock pattern from Chapter 5 provides a clean solution. Each instance runs node-cron with the same schedule, but the refresh function acquires a PostgreSQL advisory lock before executing:
cron.schedule('*/15 * * * *', async () => {
const client = await pool.connect();
try {
const { rows } = await client.query(
"SELECT pg_try_advisory_lock(hashtext('mv_dashboard')) AS acquired"
);
if (!rows[0].acquired) return;
await client.query(
'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard'
);
} finally {
await client.query(
"SELECT pg_advisory_unlock(hashtext('mv_dashboard'))"
);
client.release();
}
}); The pool.connect() is deliberate — session-level advisory locks are bound to the connection, so the lock, the refresh, and the unlock must execute on the same one. No Redis. No external coordinator. PostgreSQL itself ensures that exactly one instance refreshes at a time.
LISTEN/NOTIFY in Node.js
For applications that need near-real-time freshness without polling, the pg library provides direct access to PostgreSQL's LISTEN/NOTIFY:
import { Client, Pool } from 'pg';
// Dedicated client for listening — NOT from the pool
const listener = new Client('postgresql://postgres:secret@localhost:5432/myapp');
await listener.connect();
await listener.query('LISTEN mv_refresh');
let debounceTimer = null;
const ALLOWED_VIEWS = new Set(['mv_dashboard', 'mv_daily_revenue']);
listener.on('notification', (msg) => {
const viewName = msg.payload;
if (!ALLOWED_VIEWS.has(viewName)) return; // whitelist guards against injection
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
try {
// View name is safe — validated against ALLOWED_VIEWS above.
// PostgreSQL does not support parameterized identifiers,
// so the whitelist is the protection.
await pool.query(
`REFRESH MATERIALIZED VIEW CONCURRENTLY ${viewName}`
);
} catch (err) {
console.error('Refresh failed:', err);
}
}, 5000); // 5-second debounce
}); The dedicated Client is important — it must stay in LISTEN mode permanently. Pool connections are recycled between queries; a LISTEN connection cannot be. Use a separate Client for listening and the Pool for everything else.
The debounce is essential for the same reason it was essential in Chapter 5's Python implementation: without it, rapid writes trigger rapid notifications that trigger rapid refresh attempts. The 5-second debounce waits for write activity to settle, then refreshes once. The trigger that sends the notification is identical to Chapter 5's — the same SQL works regardless of which language's listener receives it.
The Node.js ORM landscape is, I confess, more fragmented than Python's or Ruby's. Three ORMs, three migration systems, three philosophies about how much SQL the developer should see. But the underlying reality is simpler than the ecosystem suggests. A materialized view is a table. Every ORM reads from tables. The refresh is a single SQL command sent by whatever scheduling mechanism you prefer.
The ORM provides the types and the developer experience. The materialized view provides the performance. Neither needs to do the other's job.
In the next chapter, we attend to Ruby and Rails — where the Scenic gem makes this entire process rather more elegant than what we've just navigated.