Escena Viva runs on MongoDB and it runs well. This lesson has not come to dismantle that: it has come to show you the other world, the relational one, by modeling exactly the same domain in PostgreSQL so you can see with your own eyes what changes. A Node developer who knows only one of the two models has half the tools. And there is one very concrete extra reason: in the relational world two pieces appear that solve problems we have been dragging along at the root. One is the CHECK constraint, which turns sold <= capacity into a law of the engine rather than a good intention of ours. The other is transactions, which arrive in the next lesson. By the end you will have a second implementation of src/repositories/events.js on top of Sequelize, interchangeable with the Mongoose one without touching controllers or domain.

Contents

  1. Relational databases in ten minutes, and why sessions really are a table here
  2. Escena Viva's SQL schema
  3. Installing Sequelize, connecting, and why sync() is forbidden
  4. Defining models and data types
  5. Model validations versus database constraints
  6. Associations and queries with Sequelize
  7. Parameterized SQL and injection
  8. The alternative repository on Sequelize
  9. Mongoose versus Sequelize: the criteria

Relational databases in ten minutes

A relational database stores tables: sets of rows with the same columns, each of a declared type. Everything is built on top of that very simple structure:

  • Primary key (PK). The column that uniquely identifies each row; usually an auto-incrementing id or a UUID.
  • Foreign key (FK). A column pointing at another table's PK. The engine guarantees the target exists: you cannot insert an order with a non-existent user_id, nor delete a user who has orders unless you define what should happen. That is referential integrity, and MongoDB does not have it.
  • NOT NULL, UNIQUE, DEFAULT. The column must have a value, must not repeat, or takes a value by omission.
  • CHECK. An arbitrary condition every row must satisfy. It is the most underrated one and the one we care about most.

The deep cultural difference: here the schema is mandatory and the engine enforces it. You cannot insert a row with an extra field or with the wrong type. That is uncomfortable at first and it saves projects three years later.

Normalization: why sessions really are a table here

Normalizing means organizing the data so that each fact is stored exactly once. The normal forms are a broad body of theory, but in practice they boil down to three rules: no multiple values in one cell (if there are several sessions, you create a table); each table describes a single thing; and nothing derived from something that is not the key. In MongoDB we embedded sessions inside the event because the document model allows it and the access pattern favored it. In SQL, sessions are a table of their own, and that is not a whim: it is the only natural way to have one row per session with its own primary key, its own constraints — CHECK (sold <= capacity) applies per row, that is, per session — and its own foreign keys from tickets and orders. We lose the single-shot read (an event with its sessions is now two tables and a JOIN) and we gain integrity guaranteed by the engine. It is exactly the trade-off described in the table of lesson 07-01, now with proper names.

Escena Viva's SQL schema

-- Users. No password: authentication is module 8.
CREATE TABLE users (
  id     SERIAL PRIMARY KEY,
  email  VARCHAR(160) NOT NULL UNIQUE,
  name   VARCHAR(120) NOT NULL,
  role   VARCHAR(20)  NOT NULL DEFAULT 'attendee'
         CHECK (role IN ('attendee', 'organizer', 'administrator')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Events. event_id is the business identifier (evt-001), stable and public.
CREATE TABLE events (
  id               SERIAL PRIMARY KEY,
  event_id         VARCHAR(20)  NOT NULL UNIQUE,
  title            VARCHAR(160) NOT NULL,
  venue            VARCHAR(120) NOT NULL,
  organizer_id     VARCHAR(40)  NOT NULL,
  category         VARCHAR(60)  NOT NULL,
  duration_minutes INTEGER      NOT NULL CHECK (duration_minutes BETWEEN 1 AND 600),
  status           VARCHAR(20)  NOT NULL DEFAULT 'draft'
                   CHECK (status IN ('draft', 'published', 'finished'))
);

-- Sessions: here they ARE a table, with their own per-row integrity.
CREATE TABLE sessions (
  id            SERIAL PRIMARY KEY,
  session_id    VARCHAR(20) NOT NULL UNIQUE,
  -- If the event is deleted, its sessions go with it. The engine guarantees it.
  event_id_ref  INTEGER     NOT NULL REFERENCES events(id) ON DELETE CASCADE,
  date_time     TIMESTAMPTZ NOT NULL,
  capacity      INTEGER     NOT NULL CHECK (capacity > 0),
  sold          INTEGER     NOT NULL DEFAULT 0 CHECK (sold >= 0),
  price_cents   INTEGER     NOT NULL CHECK (price_cents >= 0),
  -- THE constraint of the course: the engine physically rejects overselling.
  CONSTRAINT capacity_not_exceeded CHECK (sold <= capacity)
);

CREATE TABLE orders (
  id             SERIAL PRIMARY KEY,
  user_id        INTEGER     NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  session_id_ref INTEGER     NOT NULL REFERENCES sessions(id) ON DELETE RESTRICT,
  quantity       INTEGER     NOT NULL CHECK (quantity BETWEEN 1 AND 6),
  total_cents    INTEGER     NOT NULL CHECK (total_cents >= 0),
  channel        VARCHAR(20) NOT NULL CHECK (channel IN ('web', 'box-office', 'phone')),
  status         VARCHAR(20) NOT NULL DEFAULT 'pending'
                 CHECK (status IN ('pending', 'paid', 'issued', 'cancelled')),
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE tickets (
  id             SERIAL PRIMARY KEY,
  code           VARCHAR(20) NOT NULL UNIQUE CHECK (code ~ '^EV-\d{4}-\d{6}$'),
  order_id       INTEGER     NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  session_id_ref INTEGER     NOT NULL REFERENCES sessions(id) ON DELETE RESTRICT,
  status         VARCHAR(20) NOT NULL DEFAULT 'valid'
                 CHECK (status IN ('valid', 'used', 'cancelled')),
  used_at        TIMESTAMPTZ,
  -- Internal coherence: if it is used it must have a usage date, and if not, not.
  CONSTRAINT usage_consistent CHECK ((status = 'used') = (used_at IS NOT NULL))
);

-- Indexes for the frequent queries: in Postgres FKs are NOT indexed automatically.
CREATE INDEX idx_events_status_venue ON events (status, venue);
CREATE INDEX idx_sessions_event      ON sessions (event_id_ref);
CREATE INDEX idx_orders_user         ON orders (user_id, created_at DESC);

Stop at CONSTRAINT capacity_not_exceeded CHECK (sold <= capacity). That line is the relational answer to the course's problem. In MongoDB we have a validator that Mongoose runs sometimes — remember: not on updateOne without runValidators, and with no access to other fields on partial updates. Here it is the engine that checks it on every INSERT and every UPDATE, without exception, whether it comes from your application, from a script, from somebody with psql or from a badly written migration. It is integrity that MongoDB simply does not give you. And notice ON DELETE RESTRICT versus ON DELETE CASCADE: the first stops you deleting a user who has orders; the second deletes the tickets when their order is deleted. No orphan tickets: the engine does not allow it.

Installing Sequelize, connecting, and why sync() is forbidden

# ORM + PostgreSQL driver, and the CLI for migrations (lesson 07-06).
npm install sequelize pg pg-hstore && npm install --save-dev sequelize-cli
# Postgres locally with Docker; in module 11 we formalize it with compose.
docker run -d --name postgres-escena-viva -e POSTGRES_PASSWORD=development \
  -e POSTGRES_DB=escena_viva -p 5432:5432 postgres:16
// src/db/sequelize.js — the URL comes in, as always, through src/config/index.js.
'use strict';

const { Sequelize } = require('sequelize');
const { configuration } = require('../config/index.js');

const sequelize = new Sequelize(configuration.postgresUrl, {
  dialect: 'postgres',
  // SQL logging: extremely useful in development, noisy in production.
  logging: configuration.nodeEnv === 'development' ? console.log : false,
  pool: { max: 10, min: 1, idle: 10_000, acquire: 30_000 },
  define: { underscored: true, timestamps: true, createdAt: 'created_at', updatedAt: 'updated_at' },
});

// authenticate() fires a SELECT 1: it checks credentials and network at startup.
const connect = () => sequelize.authenticate().then(() => sequelize);

module.exports = { sequelize, connect, disconnect: () => sequelize.close() };

Sequelize can create the tables from your models with sequelize.sync(), sync({ alter: true }) or sync({ force: true }). It is convenient in a project's first hour and in production it is forbidden, for four reasons worth understanding rather than merely obeying. There is no history: nobody knows what changed, when or why, and it cannot be reviewed in a pull request or reverted. alter: true is unpredictable: to change a column's type it may drop and recreate it, losing the data, and it does not detect renames. It does not preserve data: adding a NOT NULL column to a table with a million rows requires deciding what value those rows have, and sync does not ask. And force: true in the wrong environment is the fastest known way to wipe a production database. The alternative is migrations, the first half of the next lesson.

Defining models and data types

// src/models-sql/session.js
'use strict';

const { Model, DataTypes } = require('sequelize');
const { sequelize } = require('../db/sequelize.js');

class Session extends Model {
  // The domain getters are reproduced as class properties.
  get available() { return this.capacity - this.sold; }
  get soldOut() { return this.sold >= this.capacity; }
}
Session.init(
  {
    id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
    // 'field' declares the real column name when it differs from the attribute.
    sessionId: { type: DataTypes.STRING(20), allowNull: false, unique: true,
      field: 'session_id', validate: { is: /^ses-\d{3}-\d+$/ } },
    dateTime: { type: DataTypes.DATE, allowNull: false, field: 'date_time' },
    capacity: { type: DataTypes.INTEGER, allowNull: false, validate: { min: 1 } },
    sold: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, validate: { min: 0 } },
    priceCents: { type: DataTypes.INTEGER, allowNull: false, field: 'price_cents' },
  },
  {
    sequelize, modelName: 'Session', tableName: 'sessions', timestamps: false,
    validate: {
      // Model validation: unlike field validation, it sees the WHOLE row.
      consistentCapacity() {
        if (this.sold > this.capacity) throw new Error('Sold cannot exceed the capacity');
      },
    },
  },
);

module.exports = { Session };

Event, Order, Ticket and User are defined the same way, with ENUM for status, channel and role.

Sequelize type SQL type (Postgres) Use in Escena Viva
INTEGER / BIGINT integer / bigint capacity, sold, priceCents
STRING(n) / TEXT varchar(n) / text title, code / long descriptions
DECIMAL(p,s) numeric Exact decimals — we do not use it
FLOAT / DOUBLE real / double Never for money
DATE / DATEONLY timestamptz / date dateTime, timestamps
BOOLEAN / ENUM(...) boolean / enum type Flags / status, channel, role
JSONB / UUID jsonb / uuid Flexible metadata / opaque keys

On DECIMAL versus integer cents: DECIMAL is exact and correct for money, but in Node it arrives as a string — a number cannot represent every 128-bit decimal — and it forces decimal arithmetic in every operation. Our convention avoids the problem at the root: JavaScript integers are exact up to 2⁵³, more than enough for any revenue figure. FLOAT and DOUBLE are ruled out without discussion: 0.1 + 0.2 !== 0.3. And JSONB deserves a note: PostgreSQL stores binary JSON, indexable and queryable with its own operators, so you can have document-shaped fields inside a relational database; the border between the two worlds is blurrier than the debates suggest.

Model validations versus database constraints

It is the same question as in 07-02 with zod and Mongoose, with the same answer: you need both, because they protect you from different things.

Sequelize validation Engine constraint
Where it runs In your process, before the INSERT In PostgreSQL, always
Who it protects Your application Everyone: scripts, psql, other applications
Message Good, human, field by field Technical: violates check constraint "capacity_not_exceeded"
Cost Zero round trips to the database One round trip that ends in an error
Bypassed by validate: false, bulkCreate, raw SQL Nothing. It is impassable

The professional strategy: validation in the model to give good messages and save round trips; constraints in the database as the definitive safety net. If you could only pick one, pick the constraint: it is the only one that cannot be dodged. A CHECK outlives your code, your ORM and your team.

Associations

// src/models-sql/associations.js
// An event has many sessions; the FK lives in the 'sessions' table.
Event.hasMany(Session, { as: 'sessions', foreignKey: 'event_id_ref', onDelete: 'CASCADE' });
Session.belongsTo(Event, { as: 'event', foreignKey: 'event_id_ref' });
User.hasMany(Order, { as: 'orders', foreignKey: 'user_id' });
Order.belongsTo(User, { as: 'user', foreignKey: 'user_id' });
Session.hasMany(Order, { as: 'orders', foreignKey: 'session_id_ref' });
Order.hasMany(Ticket, { as: 'tickets', foreignKey: 'order_id', onDelete: 'CASCADE' });
Ticket.belongsTo(Order, { as: 'order', foreignKey: 'order_id' });

// Many to many: Sequelize uses (or creates) the join table given in through.
Event.belongsToMany(Tag, { through: 'events_tags', foreignKey: 'event_id' });
Association Foreign key Methods it adds
A.hasOne(B) / A.belongsTo(B) In B / in A a.getB(), a.setB(), a.createB()
A.hasMany(B) In B a.getBs(), a.addB(), a.removeB(), a.countBs()
A.belongsToMany(B) In the join table a.getBs(), a.addB(), a.setBs(), a.hasB()
const event = await Event.findOne({ where: { eventId: 'evt-001' } });
// An ADDITIONAL query: equivalent to Session.findAll({ where: { event_id_ref: event.id } }).
const sessions = await event.getSessions();
// createSession inserts with the FK already filled in. Very convenient.
await event.createSession({ sessionId: 'ses-001-3', capacity: 400, sold: 0, priceCents: 2500 });

getSessions() is another query: if you call it inside a loop over events, you have just reinvented the N+1 of the previous lesson. The solution in Sequelize is include.

Queries with Sequelize

const { Op } = require('sequelize');

const published = await Event.findAll({
  where: { status: 'published', venue: 'Teatro Almendra' },
  attributes: ['eventId', 'title', 'venue'],   // the equivalent of a projection
  order: [['title', 'ASC']], limit: 20,
});
const upcoming = await Session.findAll({
  where: {
    dateTime: { [Op.between]: [new Date('2026-03-01'), new Date('2026-03-16')] },
    // Comparing two COLUMNS with each other: trivial here, in Mongo it needed $expr.
    sold: { [Op.lt]: sequelize.col('capacity') },
  },
});

// findAndCountAll returns rows and total in one call: ideal for pagination.
const { rows, count } = await Order.findAndCountAll({
  where: { userId: 7, status: { [Op.ne]: 'cancelled' } }, limit: 20, offset: 0,
});

The operators live in Op and map almost one to one onto MongoDB's: Op.eq/Op.ne and the comparisons (gt, gte, lt, lte) are $eq, $ne, $gt…; Op.between, Op.in and Op.notIn are $gte+$lte, $in and $nin; Op.like/Op.iLike (which ignores case) play the role of $regex; and Op.and, Op.or, Op.not are the logical ones.

And now the key difference from populate:

// include generates a real JOIN: ONE SINGLE query to the engine.
const catalog = await Event.findAll({
  where: { status: 'published' },
  include: [{
    association: 'sessions',
    // You can filter the PARENT by the child's columns: required turns the
    // LEFT JOIN into an INNER JOIN, dropping events with no available sessions.
    where: { sold: { [Op.lt]: sequelize.col('sessions.capacity') } },
    required: true,
    attributes: ['sessionId', 'dateTime', 'capacity', 'sold', 'priceCents'],
  }],
  order: [['title', 'ASC']],
});

// Aggregations with fn, col and literal: the revenue by venue from 07-04.
// raw: true returns plain objects without instantiating models: Sequelize's lean().
const byVenue = await Session.findAll({
  attributes: [[col('event.venue'), 'venue'],
    [fn('SUM', literal('sold * price_cents')), 'revenueCents']],
  include: [{ association: 'event', attributes: [], where: { status: 'published' } }],
  group: [col('event.venue')], raw: true,
});

This is what populate cannot do. With include: one query, the engine joins, filters and sorts with its indexes and its optimizer, and you can filter the event by a condition on its sessions; with populate it was two queries stitched together in Node and filtering the parent was impossible. In exchange, a badly designed include over three relations can generate an enormous cartesian product; that is what separate: true is for.

Parameterized SQL and injection

In Module 6 we promised to come back to injection. It is understood in two blocks.

// NEVER. Concatenating user input into SQL.
const [rows] = await sequelize.query(`SELECT * FROM events WHERE venue = '${req.query.venue}'`);

If the client sends venue=x' OR '1'='1, the statement executed is SELECT * FROM events WHERE venue = 'x' OR '1'='1' and it returns the entire table. With venue=x'; DROP TABLE tickets; -- the engine receives two statements and the second one destroys data. The conceptual failure is that the user's data has become SQL code: the engine receives a string and cannot tell which part you wrote and which part the attacker did.

// RIGHT. Parameterized: the data travels SEPARATELY from the statement.
const rows = await sequelize.query(
  'SELECT * FROM events WHERE venue = :venue AND status = :status',
  { replacements: { venue: req.query.venue, status: 'published' }, type: sequelize.QueryTypes.SELECT },
);

Why it is immune, and it is not "because it escapes quotes": with parameters, the driver sends the statement template on one side and the values on the other. PostgreSQL parses and plans the statement before knowing the values; by the time they arrive, the syntax tree is already fixed and a value can only fill a literal's slot. x' OR '1'='1 is searched for as the literal text of a venue with that name, not as logic. It is a structural separation, not a character filter, which is why it works even with inputs nobody anticipated. The whole ORM parameterizes by default: every where, every create, every update generates SQL with placeholders, so as long as you use its API you are protected without thinking about it. The risk appears in three places: sequelize.query without replacements, sequelize.literal with user data inside, and any SQL built with string templates. One important nuance: parameters substitute values, not identifiers, so a dynamic ORDER BY requires an allowlist.

// Safe dynamic ordering: we validate against a closed list.
const COLUMNS = new Set(['title', 'venue', 'created_at']);
const column = COLUMNS.has(req.query.sort) ? req.query.sort : 'title';
const events = await Event.findAll({ order: [[column, req.query.dir === 'desc' ? 'DESC' : 'ASC']] });

The ORM covers almost everything, but there are queries — recursive CTEs, window functions, finely tuned INSERT ... ON CONFLICT — that are better written by hand. Dropping down to raw SQL with sequelize.query and replacements is not a failure: it is using the right tool. That said, encapsulate it inside the repository: a controller should never see a SQL string.

The alternative repository on Sequelize

Here the architecture of lesson 07-01 collects its prize: same public signature, same output — domain instances — a different engine underneath.

// src/repositories/events-sql.js
'use strict';

const { Op } = require('sequelize');
const { Event: EventModel } = require('../models-sql/event.js');
const { Event } = require('../domain/event.js');

/** Translates a row (with its sessions) into the usual domain instance. */
function toDomain({ eventId, sessions = [], ...rest }) {
  return Event.fromJSON({ ...rest, id: eventId,
    sessions: sessions.map(({ sessionId, dateTime, ...data }) => ({
      ...data, id: sessionId, dateTime: new Date(dateTime).toISOString(),
    })),
  });
}

async function getCatalog() {
  const rows = await EventModel.findAll({
    where: { status: { [Op.ne]: 'draft' } },
    include: [{ association: 'sessions' }],   // a JOIN, a single query
    order: [['title', 'ASC']],
  });
  return rows.map((row) => toDomain(row.get({ plain: true })));
}

async function getEventById(eventId) {
  const row = await EventModel.findOne({ where: { eventId }, include: ['sessions'] });
  return row ? toDomain(row.get({ plain: true })) : null;
}

module.exports = { getCatalog, getEventById };
// src/repositories/index.js — a single switch to change engines.
const eventRepository = configuration.dataEngine === 'postgres'
  ? require('./events-sql.js')
  : require('./events.js');
module.exports = { eventRepository };

That switch is all the proof we needed: the four event controllers, the routes, the validation middleware, the error hierarchy and the entire domain work identically with MongoDB or with PostgreSQL. The abstraction that looked like bureaucracy in lesson 07-01 has just saved a complete rewrite.

Mongoose versus Sequelize: the criteria

Aspect Mongoose (MongoDB) Sequelize (SQL)
Schema From the ODM; the engine does not demand it From the engine; mandatory and verified
Nested data Natural (subdocuments) A separate table, or JSONB
Relationships populate (extra queries) or $lookup include = a real JOIN, one query
Referential integrity Yours The engine's, with foreign keys
CHECK-style constraints They do not exist Yes, impassable
Migrations External (migrate-mongo) sequelize-cli, built in
Transactions Multi-document, with replica sets Native, standard, mature
Aggregation The aggregate pipeline SQL: GROUP BY, windows, CTEs
Scaling / learning Built-in sharding; a gentle curve from JS Replicas and manual partitioning; you must learn SQL

The criteria, without dogma. Choose document when your data consists of self-contained aggregates, the schema evolves fast, read volume is high and the shape varies between items. Choose relational when there is money, hard invariants, many cross relationships and complex reports, and when correctness matters more than flexibility. Escena Viva, honestly, is a textbook case for the relational model: capacity, payments, named tickets, accounting. We have done it in MongoDB because it works and because you have to know how, and in the next lesson we will see both solutions to overselling, one per engine. Other options worth knowing: Prisma, the most popular modern ORM in Node, with its own schema language, excellent typing and very carefully designed migrations, although it moves away from SQL and struggles with complex queries; and Knex, which is not an ORM but a query builder with a chainable API, no models and no magic. Many teams combine an ORM for CRUD with Knex or raw SQL for the hard parts.

Common Mistakes and Tips

  • Calling sync({ alter: true }) at startup. One day it will alter something you did not want, in production, with no log. Migrations.
  • Not indexing foreign keys. PostgreSQL creates the PK's index, but not one for FK columns: a JOIN without it scans the whole table.
  • Using FLOAT for money, or concatenating user input into sequelize.query: integer cents and replacements, always.
  • Trusting model validations alone. They are bypassed by bulkCreate or raw SQL. Engine constraints are not.
  • Loading relations inside a loop with getSessions(): it is the N+1 under another name. Use include.
  • Tip: turn on logging: console.log in development and read the SQL Sequelize generates — it is the best way to learn SQL and to spot absurd queries — and learn PostgreSQL's EXPLAIN ANALYZE: it is the previous lesson's explain() with more information.

Exercises

Exercise 1: modeling the ticket

Write src/models-sql/ticket.js with Model.init: code (unique, with validate.is for the EV-<year>-<6 digits> format), orderId, sessionIdRef, status (ENUM, valid by default) and usedAt (DATE, nullable). Add the associations and say which generated method would give you an order's tickets.

Exercise 2: fixing an injection

This endpoint is vulnerable. Explain the concrete attack and rewrite it safely, knowing that sort can be title or venue.

const { term, sort } = req.query;
const rows = await sequelize.query(
  `SELECT * FROM events WHERE title LIKE '%${term}%' ORDER BY ${sort}`,
);

Solutions

Exercise 1.

Ticket.init({
  id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
  code: { type: DataTypes.STRING(20), allowNull: false, unique: true,
    validate: { is: /^EV-\d{4}-\d{6}$/ } },
  orderId: { type: DataTypes.INTEGER, allowNull: false, field: 'order_id' },
  sessionIdRef: { type: DataTypes.INTEGER, allowNull: false, field: 'session_id_ref' },
  status: { type: DataTypes.ENUM('valid', 'used', 'cancelled'), allowNull: false,
    defaultValue: 'valid' },
  usedAt: { type: DataTypes.DATE, field: 'used_at' },
}, { sequelize, modelName: 'Ticket', tableName: 'tickets', updatedAt: false });

With Order.hasMany(Ticket, { as: 'tickets', foreignKey: 'order_id' }), the method is order.getTickets(). For several orders at once, use include instead of the method, so as not to fall into N+1.

Exercise 2. There are two vulnerabilities. In term, a value such as x%' UNION SELECT id, email, name, role, null, null FROM users -- leaks the entire users table. In sort, title; DROP TABLE tickets; -- injects a second statement. And sort cannot be parameterized because it is an identifier, not a value: it has to be validated against an allowlist.

const COLUMNS = new Set(['title', 'venue']);
const column = COLUMNS.has(req.query.sort) ? req.query.sort : 'title';

const rows = await sequelize.query(
  `SELECT event_id, title, venue FROM events WHERE title ILIKE :pattern ORDER BY ${column} ASC`,
  { replacements: { pattern: `%${req.query.term ?? ''}%` }, type: sequelize.QueryTypes.SELECT },
);

The % goes inside the parameter's value, not inside the template: that way it is still data. And column is interpolated only after being validated against a closed set.

Conclusion

You have seen the same domain twice, and that is the lesson. You can read and write a relational schema with primary and foreign keys, NOT NULL, UNIQUE and that CHECK (sold <= capacity) which turns a business invariant into a law of the engine. You know how to connect Sequelize to PostgreSQL from configuration, why sync() must never touch production, how to define models with suitable types — integer cents, never floating point — why model validations and database constraints coexist, how to declare associations and what methods they generate, and how to query with where, include — a real single-query JOIN, unlike populate — aggregations and raw SQL when needed. You have closed the promise from Module 6: injection is avoided by separating the statement from the data, not by escaping quotes. And above all, the repository pattern has done what it promised: src/repositories/events-sql.js replaces src/repositories/events.js with a configuration switch, and not one controller and not one domain class has noticed.

One lesson of the module remains, and it is the one you have been waiting for since Module 4. Migrations to version the schema the way code is versioned, seeds to finally load the 3 events and 7 sessions of data/events.json and retire the file for good, and transactions: what ACID means in practice, how two concurrent buyers exhaust the last ticket, why neither $inc nor a prior check is enough, isolation levels, pessimistic versus optimistic locking, and the definitive implementation of buyTickets that reserves capacity, creates the order and issues the tickets or does absolutely nothing.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved