We reach the end of the module and the lesson that settles the course's oldest debt. Since Module 4, when we set up the first node:http server and sold the first ticket, we have been dragging along a problem that validation did not solve, that well-typed errors did not solve and that not even $inc fully solved: two people buying the last available ticket at the same time. Before attacking it, two pieces every project with a database needs and that almost nobody teaches in time: migrations, so the schema has a history the way the code does, and seeds, to populate the database with one command. With them we will finally retire data/events.json as the source of truth.
Contents
- Migrations: the schema is code too
sequelize-cliand Escena Viva's initial migration- Migrations over existing data, golden rules and the MongoDB case
- Seeds:
npm run seed - Transactions, ACID and the overselling scenario
- Transactions in Sequelize and isolation levels
- Pessimistic versus optimistic locking
- Transactional
buyTicketsand the MongoDB solution - Deadlocks, retries and what not to put inside
Migrations: the schema is code too
Your code has a history: every change is a commit, with an author, a date, a reason and the option to revert it. Your database schema, if you created it with sync() or by hand with psql, has none of that. Nobody knows when that column was added, or why, or what it looked like before. And when a colleague clones the repository, their database looks nothing like yours. A migration is a versioned file, stored next to the code, describing a schema change in two directions: up applies it, down undoes it. They run in order and the database records which ones it has already applied. Three benefits make it non-negotiable: reproducibility (new machine, empty database, one command), review (a schema change goes through a pull request like any other) and automated deployment (the CI/CD of Module 11 runs them before starting the new version).
sequelize-cli and Escena Viva's initial migration
You install it with npm install --save-dev sequelize-cli and npx sequelize-cli init creates the standard structure: config/ (connections), migrations/, seeders/ and models/. The generated config/config.json is no use to us, because configuration lives in src/config/index.js: we replace it with a config/config.js exporting an object with one key per environment (development, test, production), each with { url: configuration.postgresUrl, dialect: 'postgres' }. A migration is a module with two async functions receiving queryInterface — the API for manipulating the schema — and Sequelize for the types. Sequelize creates a SequelizeMeta table with one row per applied migration: when you run db:migrate it compares the files against that table and applies only the missing ones, in alphabetical order — which is why the names begin with a timestamp.
queryInterface offers createTable/dropTable, addColumn/removeColumn/changeColumn/renameColumn, addIndex and addConstraint (for CHECK, UNIQUE and foreign keys), bulkInsert/bulkUpdate/bulkDelete to move data inside the migration itself, and sequelize.query when none of the above is enough.
// migrations/20260814090000-create-initial-schema.js
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Everything inside a transaction: if anything fails, the database is untouched.
await queryInterface.sequelize.transaction(async (t) => {
await queryInterface.createTable('events', {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
event_id: { type: Sequelize.STRING(20), allowNull: false, unique: true },
title: { type: Sequelize.STRING(160), allowNull: false },
venue: { type: Sequelize.STRING(120), allowNull: false },
organizer_id: { type: Sequelize.STRING(40), allowNull: false },
category: { type: Sequelize.STRING(60), allowNull: false },
duration_minutes: { type: Sequelize.INTEGER, allowNull: false },
status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'draft' },
}, { transaction: t });
await queryInterface.createTable('sessions', {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
session_id: { type: Sequelize.STRING(20), allowNull: false, unique: true },
event_id_ref: { type: Sequelize.INTEGER, allowNull: false,
references: { model: 'events', key: 'id' }, onDelete: 'CASCADE' },
date_time: { type: Sequelize.DATE, allowNull: false },
capacity: { type: Sequelize.INTEGER, allowNull: false },
price_cents: { type: Sequelize.INTEGER, allowNull: false },
sold: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
}, { transaction: t });
// The key constraint of the course, declared explicitly.
await queryInterface.addConstraint('sessions', {
fields: ['sold', 'capacity'], type: 'check', name: 'capacity_not_exceeded',
where: { sold: { [Sequelize.Op.lte]: Sequelize.col('capacity') } }, transaction: t });
await queryInterface.addIndex('events', ['status', 'venue'], { transaction: t });
// ...and in the same way users, orders and tickets.
});
},
// REVERSE order in down: first the tables that depend on others.
async down(queryInterface) {
await queryInterface.dropTable('sessions');
await queryInterface.dropTable('events');
},
};Migrations over existing data and golden rules
The commands are npx sequelize-cli db:migrate (applies the pending ones), db:migrate:status (which ones are applied) and db:migrate:undo (reverts the last one). Weeks later, the business asks to classify events by language. The table already has data: you cannot simply add a NOT NULL column, because the existing rows would have no value and the engine would reject the change. The correct pattern has three steps.
// migrations/20260901120000-add-language-to-events.js
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async (t) => {
// 1. Add it ALLOWING nulls: existing rows are left at null.
await queryInterface.addColumn('events', 'language', { type: Sequelize.STRING(5) }, { transaction: t });
// 2. Fill the existing rows with a sensible value.
await queryInterface.sequelize.query(
`UPDATE events SET language = 'es' WHERE language IS NULL`, { transaction: t });
// 3. Now that no row is null, tighten the constraint.
await queryInterface.changeColumn('events', 'language',
{ type: Sequelize.STRING(5), allowNull: false, defaultValue: 'es' }, { transaction: t });
});
},
down: (queryInterface) => queryInterface.removeColumn('events', 'language'),
};This sequence — add permissively, backfill, tighten — is the universal recipe for mandatory columns on populated tables. Memorize it, and with it the golden rules:
- Never edit an already applied migration. On your machine you would edit it and re-run it, but in production it is already in
SequelizeMetaand will not be re-run, so your change will never get there. Fixes are made with a new migration. - Every migration must be reversible. Write the
downand test it: adownthat does not work is a deployment you cannot back out of at three in the morning. - Backwards-compatible changes. In a zero-downtime deployment (Module 11) the old version and the new one coexist for minutes against the same database. If your migration renames a column, the old version breaks instantly. The technique is called expand and contract: add the new nullable column while the new code writes to both, copy the data, deploy code that uses only the new one and, finally, drop the old one.
- Data and schema, separated when they are large, and test against a copy of production. An
UPDATEover ten million rows inside a migration locks the table and leaves the deployment hanging; for that, a separate batched script. And the problems only show up at volume, never on your empty three-row database.
Migrations in MongoDB
"MongoDB has no schema, therefore it needs no migrations." That is false, and it is one of the most expensive confusions in the industry. The absence of a schema in the engine does not remove the schema: it moves it into your code. If you add language with required: true to the Mongoose schema, the five million existing documents do not have it and any save() on them will fail. If you rename price to priceCents, the old documents keep the old name and your queries will return nothing without raising any error.
// migrations/20260901120000-add-language.js (with migrate-mongo)
module.exports = {
async up(db) {
await db.collection('events').updateMany({ language: { $exists: false } }, { $set: { language: 'es' } });
// Production indexes are created here, not with autoIndex at startup.
await db.collection('events').createIndex({ status: 1, venue: 1, title: 1 });
},
down: (db) => db.collection('events').updateMany({}, { $unset: { language: '' } }),
};A real difference: in SQL the migration changes the structure and the data adapts; in MongoDB the migration is a data transformation. But the discipline — a versioned file, up/down, a record of what has been applied, review in a pull request — is identical.
Seeds: npm run seed
A seed loads the initial data. Our case is a fond one: the 3 events and 7 sessions of data/events.json, the file that has been with us since Module 3, become the database's initial load. It stops being the store and becomes the seed. It is invoked with npm run seed and, with --reset, it wipes before loading (seed:clean).
// scripts/seed.js (requires omitted for brevity)
'use strict';
const SEED_PATH = path.join(__dirname, '..', 'data', 'events.json');
const DEMO_USERS = [
{ email: '[email protected]', name: 'Lucia Serrano', role: 'attendee' },
{ email: '[email protected]', name: 'Marc Oliveras', role: 'organizer' },
{ email: '[email protected]', name: 'Administration', role: 'administrator' },
];
/**
* Loads the catalog. It is IDEMPOTENT: running it twice leaves the same state
* as running it once. That is achieved with an upsert on the business key.
*/
async function seed({ reset = false } = {}) {
await connect();
if (reset) await Promise.all([Event.deleteMany({}), User.deleteMany({})]);
const { events } = JSON.parse(await readFile(SEED_PATH, 'utf8'));
let inserted = 0;
for (const { id, sessions, ...data } of events) {
const result = await Event.updateOne(
{ eventId: id }, // business key: evt-001, evt-002, evt-003
{ $set: { ...data, status: data.status ?? 'published',
sessions: sessions.map((session) => ({
sessionId: session.id, dateTime: new Date(session.dateTime), capacity: session.capacity,
sold: session.sold, priceCents: session.priceCents,
})) } },
{ upsert: true, runValidators: true }, // creates if absent, updates if present
);
if (result.upsertedCount > 0) inserted += 1;
}
for (const user of DEMO_USERS) {
await User.updateOne({ email: user.email }, { $set: user }, { upsert: true });
}
// Load check: it must report 7 sessions, capacity 3000, sold 1811.
const [total] = await Event.aggregate([{ $unwind: '$sessions' },
{ $group: { _id: null, sessions: { $sum: 1 },
capacity: { $sum: '$sessions.capacity' }, sold: { $sum: '$sessions.sold' } } }]);
console.log(`[seed] new: ${inserted}; sessions: ${total.sessions}, ` +
`capacity: ${total.capacity}, sold: ${total.sold}`);
await disconnect();
}
// Runnable as a script (npm run seed) and importable from the tests.
if (require.main === module) {
seed({ reset: process.argv.includes('--reset') })
.catch((error) => { console.error('[seed]', error.message); process.exit(1); });
}
module.exports = { seed };Idempotence is what separates a professional seed from a throwaway script: it is achieved with an upsert on the business key, never with an insert. You can run it on every development startup, after every migration and before every demo without fear of duplicating anything. And its value goes beyond development: in Module 9, when we write tests, a deterministic seed is what lets you assert "after selling 2 tickets for ses-001-1, 86 are left", because the starting state is known and reproducible. Combined with an in-memory or per-suite ephemeral database, it makes integration tests fast and isolated. We will develop that there; here it is enough to leave the tool ready.
Transactions, ACID and the overselling scenario
A transaction is a set of operations the engine treats as a single one. Applied to somebody buying two tickets: atomicity means that reserving capacity, creating the order and issuing two tickets happens all or nothing, with no visible intermediate states — if the second ticket fails, the capacity comes back on its own and the order does not exist; consistency, that on commit every constraint still holds (the CHECK (sold <= capacity), the foreign keys, the NOT NULLs) and that if the result would violate any of them the engine rolls everything back; isolation, that while your transaction is in progress another one does not see your changes half-done; and durability, that when the engine says "committed" it is on disk and a power cut a millisecond later does not undo it. Without transactions, each operation is atomic on its own, but the set is not: that is exactly the problem written in capitals inside createOrder in lesson 07-03. Let's look at it: ses-001-1 has a capacity of 400 and 399 sold, one ticket is left, and Lucía and Marc both hit "buy" in the same second.
sequenceDiagram
participant L as Lucia's request
participant DB as Database<br/>ses-001-1
participant M as Marc's request
Note over DB: capacity 400 / sold 399<br/>1 ticket left
L->>DB: 1. read session
DB-->>L: sold = 399, available = 1
M->>DB: 2. read session
DB-->>M: sold = 399, available = 1
Note over L,M: BOTH checks pass:<br/>1 available >= 1 requested
L->>DB: 3. write sold = 400
DB-->>L: committed
M->>DB: 4. write sold = 400
DB-->>M: committed
L->>DB: 5. create order + ticket EV-2026-000431
M->>DB: 6. create order + ticket EV-2026-000432
Note over DB: OVERSELLING:<br/>401 tickets for 400 seats
Let's analyze why the defenses we already have fail. The prior check is not enough: between step 2 and step 4 there is a window, and the state Marc read is no longer the real one when he writes. Any logic of the form "read, decide, write" has that window, and in Node it is especially easy to open, because every await hands control back to the event loop, which serves Marc precisely in that gap. Making the window smaller does not remove it: it only makes the failure rarer and harder to reproduce, which is worse. $inc is not enough either: it solves the lost update — if both add 1, the result is 401 and not 400, because each addition is applied to the real value — but it adds unconditionally, with no idea that 401 exceeds the capacity; we have swapped an incorrect number for a correct number that reports an overselling that has already happened. And schema validation is not enough either: the Mongoose validator runs when saving a complete document, and in an updateOne with $inc it has access neither to the result nor to capacity; and even if it did, it runs in your process, so two Node processes would validate independently and both would approve.
The solution has to satisfy one condition: the check and the write must be a single indivisible operation for the engine, and there are two ways to get there.
Transactions in Sequelize and isolation levels
// MANAGED (recommended): commits when it finishes without errors, rolls back if it throws.
const order = await sequelize.transaction(async (t) => {
const session = await Session.findOne({ where: { sessionId }, transaction: t });
await session.increment('sold', { by: quantity, transaction: t });
return Order.create({ /* ... */ }, { transaction: t });
});
// UNMANAGED: manual control. If you forget the rollback, the transaction stays
// open and holds a connection from the pool until it expires.
const t = await sequelize.transaction();
try {
await Session.increment('sold', { by: quantity, where: { sessionId }, transaction: t });
await t.commit();
} catch (error) {
await t.rollback(); throw error;
}Use the managed one by default; the unmanaged one only if you need savepoints or complex conditional logic. The detail everyone forgets: you must propagate { transaction: t } to every single query. A query without t runs outside the transaction, on another connection, and it will neither see the pending changes nor be rolled back with them. It is the most common and the quietest bug: everything seems to work until something fails and you discover half-written data.
Perfect isolation would mean running transactions one at a time, but that would destroy performance. The levels let you choose how much correctness you pay for in concurrency.
| Level | Dirty read | Non-repeatable read | Phantom read | Cost |
|---|---|---|---|---|
| Read uncommitted | Possible | Possible | Possible | Minimal |
| Read committed | Prevented | Possible | Possible | Low — the default in PostgreSQL |
| Repeatable read | Prevented | Prevented | Prevented in Postgres | Medium |
| Serializable | Prevented | Prevented | Prevented | High: it can abort transactions |
The three anomalies with our example. Dirty read: you read sold = 400 from a transaction that has not committed yet and that will end up rolling back; you decide on data that never existed. Non-repeatable read: you read 399, another transaction commits, you read again and now it is 400 — two reads of the same value in the same transaction give different results, and it is the anomaly that causes our overselling. Phantom read: you count 5 tickets, another transaction inserts one and on recounting there are 6. With { isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE } PostgreSQL detects the conflict and aborts one of the two transactions with a serialization error: that is correct, but it moves the work into your code, which has to retry. For ticket sales there is a simpler and cheaper solution.
Pessimistic versus optimistic locking
Pessimistic: I assume there will be a conflict and I lock the row before touching it. SELECT ... FOR UPDATE reserves it, and any other transaction that wants to lock it waits until I commit or roll back.
BEGIN;
-- Locks this row: Marc will wait here until Lucia is done.
SELECT sold, capacity FROM sessions WHERE session_id = 'ses-001-1' FOR UPDATE;
UPDATE sessions SET sold = sold + 1 WHERE session_id = 'ses-001-1';
COMMIT;In Sequelize you ask for it with lock: t.LOCK.UPDATE. When Marc wakes up, he reads the updated value, 400, sees that nothing is available and fails cleanly with a 409 INSUFFICIENT_CAPACITY. The window has vanished. Optimistic: I assume conflicts are rare, I lock nothing, and I detect on writing whether somebody got there first, by means of a version column incremented on every change; the UPDATE carries the version I read in its where, and if it affects no rows then somebody got ahead of me and I have to retry from scratch.
// The UPDATE only affects rows whose version is still the one I read.
const [affected] = await Session.update(
{ sold: newSold, version: version + 1 },
{ where: { sessionId, version }, transaction: t });
if (affected === 0) {
throw new StateConflict('The session changed while you were buying', { appCode: 'INVALID_STATE' });
}Pessimistic (FOR UPDATE) |
Optimistic (version) |
|
|---|---|---|
| Cost with no conflict | A lock, a little waiting | None |
| Cost with a conflict | Waiting, resolution guaranteed | A full retry from scratch |
| Risk | Deadlocks, contention | Cascading retries under high contention |
| Ideal for | Frequent conflicts over few rows | Rare conflicts |
Which one fits ticket sales. The pessimistic one, without a doubt. Selling has a very characteristic pattern: when tickets go on sale for a long-awaited concert, thousands of people compete for the same row for a few minutes. With optimistic locking most attempts would fail and retry, generating more load at exactly the worst moment and a terrible experience. With pessimistic locking the requests serialize over that row and each one gets a definitive answer first time: either you have a ticket, or there is none left. On top of that the transaction is extremely short, so contention lasts milliseconds. The optimistic one would be the right choice for editing an event's details, where two organizers collide once a month.
Transactional buyTickets and the MongoDB solution
The definitive version. Compare it with the one from lesson 07-03, all-caps comment included.
// src/repositories/purchases-sql.js (requires omitted for brevity)
'use strict';
const generateCode = (year, n) => `EV-${year}-${String(n).padStart(6, '0')}`;
/**
* Atomic purchase: reserves the capacity, creates the order and issues the tickets.
* Either all three, or none. No intermediate state is possible.
*/
async function buyTickets({ userId, sessionId, quantity, channel }) {
return sequelize.transaction(async (t) => {
// 1. Pessimistic lock on the row. Any other purchase for this same
// session waits here until we commit or roll back.
const session = await Session.findOne({
where: { sessionId }, transaction: t, lock: t.LOCK.UPDATE });
if (!session) {
throw new ResourceNotFound(`${sessionId} does not exist`, { appCode: 'SESSION_NOT_FOUND' });
}
// 2. Capacity check. NOW it is reliable: nobody else touches the row.
// Throwing here rolls the whole transaction back automatically.
const available = session.capacity - session.sold;
if (available < quantity) {
throw new StateConflict('Not enough tickets left', {
appCode: 'INSUFFICIENT_CAPACITY', details: { available, requested: quantity } });
}
// 3. Capacity reservation. The engine's CHECK is the last safety net.
session.sold += quantity;
await session.save({ transaction: t });
// 4. Order.
const order = await Order.create({
userId, sessionIdRef: session.id, quantity, channel,
totalCents: session.priceCents * quantity, status: 'paid',
}, { transaction: t });
// 5. Tickets. The running number comes from a DATABASE sequence, not from an
// in-memory counter: two Node processes never generate the same code.
const [{ next }] = await sequelize.query("SELECT nextval('tickets_code_seq') AS next",
{ type: sequelize.QueryTypes.SELECT, transaction: t });
const year = new Date().getUTCFullYear();
const tickets = await Ticket.bulkCreate(
Array.from({ length: quantity }, (unused, i) => ({
code: generateCode(year, Number(next) + i),
orderId: order.id, sessionIdRef: session.id, status: 'valid',
})), { transaction: t, validate: true });
// 6. Final state. By returning without throwing, Sequelize commits; if anything
// had failed at any point, the database would have been left untouched.
order.status = 'issued';
await order.save({ transaction: t });
return { order, tickets, remainingAvailable: session.capacity - session.sold };
});
}
module.exports = { buyTickets };Go back to Lucía and Marc's scenario with this code. Lucía comes in, locks the row, sees 1 available, sells and commits. Marc was waiting at step 1; he wakes up, reads sold = 400, sees 0 available and receives a clean 409 INSUFFICIENT_CAPACITY, with its error code and its message. There is no overselling. There is no half-written state. There is no ticket issued without an order and no order without tickets. The problem we opened in Module 4 is closed. In MongoDB it is solved without a transaction, taking advantage of the fact that an update to a single document is atomic: the key is to put the condition inside the filter.
/**
* Atomic reservation: the capacity check is part of the FILTER, so the
* condition and the write are a single indivisible operation for the engine.
*/
async function reserveCapacity(sessionId, quantity) {
const document = await Event.findOneAndUpdate(
{ 'sessions.sessionId': sessionId,
sessions: { $elemMatch: { sessionId,
$expr: { $lte: ['$sold', { $subtract: ['$capacity', quantity] }] } } } },
{ $inc: { 'sessions.$.sold': quantity } },
{ new: true },
);
// If nothing matches: either the session does not exist, or there was no capacity.
// NOTHING has been written, so there is nothing to roll back.
if (!document) {
throw new StateConflict('Not enough tickets left',
{ appCode: 'INSUFFICIENT_CAPACITY', details: { sessionId, requested: quantity } });
}
return document;
}Why it works: the filter sold <= capacity - quantity and the $inc are evaluated and applied under the engine's own document lock. If Marc arrives after Lucía, his filter does not match and findOneAndUpdate returns null without writing. It is a compare-and-swap operation, the same idea as a compare-and-swap in concurrent programming: fast, transaction-free and with no infrastructure requirements. Its limit: it only covers one document, and the order and the tickets live in other collections. That is what multi-document sessions are for; they are opened with mongoose.startSession() and used with mongoSession.withTransaction(async () => { ... }) — which commits when it finishes, rolls back if it throws and additionally retries transient errors — propagating { session } to every operation, just like { transaction: t } in Sequelize. And an operational requirement that surprises people: MongoDB transactions require a replica set; a standalone mongod on your laptop does not support them and it has to be started with --replSet, even as a single node (on Atlas they come as standard). They also have a cost: they hold snapshots, expire after 60 seconds by default and can abort on a write conflict. The criterion: in MongoDB use the conditional atomic update whenever the problem fits inside one document — our case for capacity, thanks to having embedded the sessions — and save multi-document transactions for when the change unavoidably spans several collections.
Deadlocks, retries and what not to put inside
A deadlock happens when two transactions wait for each other: Lucía locks session A and wants B; Marc locks B and wants A. PostgreSQL detects it and aborts one with error 40P01. It is avoided by always locking in the same order (for example, sessions sorted by ascending identifier, which makes the cycle impossible), with short transactions and by locking as little as possible. And when it happens anyway, you retry with an increasing wait:
/** Retries on TRANSIENT errors (deadlock, serialization failure). */
async function withRetries(operation, { attempts = 3, baseDelay = 50 } = {}) {
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try { return await operation(); } catch (error) {
// Only these codes are transient; any other propagates as is.
if (!['40001', '40P01'].includes(error.parent?.code) || attempt === attempts) throw error;
// Exponential wait with jitter, so they do not all retry at once.
const delay = baseDelay * 2 ** (attempt - 1) + Math.random() * 25;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}Note the nuance: only transient errors are retried. An INSUFFICIENT_CAPACITY is never retried; it is not a temporary failure, it is an answer. And there are things that must never go inside a transaction. HTTP calls to external services, because a slow service keeps the locks open for seconds, the pool runs dry and the whole application stops. Sending emails, for the same reason and because they are also not reversible: if the transaction rolls back, the email has already gone out. Charges to the payment gateway, which are not undone by a ROLLBACK: you charge before, or you compensate afterwards. Writing files and emitting SalesManager events, which do not take part in the transaction and would make subscribers act on data that might be rolled back. And long loops or heavy computation, which stretch the transaction and the contention. The rule: inside, only database operations, and as few as possible. Everything else goes before (if it must gate the purchase) or after (if it must react to it). In Escena Viva the charge is authorized first, the transaction records the sale, and the confirmation email and the sale-recorded event are emitted after the commit. When that "after" has to be reliable — retries, ordering, guaranteed delivery — it becomes a job queue, which is what we will see with Redis in Module 10.
Common Mistakes and Tips
- Editing an already applied migration. It will not reach the environments that already ran it. Fix it with a new migration.
- Forgetting
{ transaction: t }on a query, which then runs outside and is not rolled back, or leaving an unmanaged transaction with norollbackin thecatch, which holds a pool connection until it expires; with enough of them, the application hangs. - Trusting the prior check alone. Between reading and writing there is always a window. Lock, or atomic filter.
- Putting an HTTP call inside the transaction, or retrying business errors: the first drains the pool at the first traffic spike and the second fixes nothing, because
INSUFFICIENT_CAPACITYdoes not improve on retry. - Believing MongoDB needs no migrations. The schema exists all the same; it just lives in your code and in documents that no longer match it.
- Tip: test concurrency for real — 50 simultaneous purchases against a session with 10 tickets — and measure how long your transactions take: any that exceeds 100 ms deserves a review. Without that test you do not know your solution works: you believe it works.
Exercises
Exercise 1: transactional refund
Implement cancelPurchase(orderId) with Sequelize, transactionally: lock the order and its session, check that the status allows cancellation (paid or issued), mark the tickets as cancelled, return the capacity and mark the order as cancelled. Justify the order in which you lock.
Exercise 2: testing concurrency
Write a script that sets a test session to capacity 10 and sold: 0, fires 50 simultaneous calls to buyTickets for 1 ticket with Promise.allSettled, and verifies that exactly 10 resolve, 40 are rejected with INSUFFICIENT_CAPACITY and sold ends up at 10.
Solutions
Exercise 1. You lock the order first and the session second; always keeping that same order in every transaction touching both tables is precisely what makes a deadlock impossible.
async function cancelPurchase(orderId, reason = 'customer request') {
return sequelize.transaction(async (t) => {
const order = await Order.findByPk(orderId, { transaction: t, lock: t.LOCK.UPDATE });
if (!order) {
throw new ResourceNotFound(`Order ${orderId} does not exist`, { appCode: 'ORDER_NOT_FOUND' });
}
if (!['paid', 'issued'].includes(order.status)) {
throw new StateConflict(`A ${order.status} order is not cancelled`, { appCode: 'INVALID_STATE' });
}
const session = await Session.findByPk(order.sessionIdRef, { transaction: t, lock: t.LOCK.UPDATE });
await Ticket.update({ status: 'cancelled' },
{ where: { orderId: order.id, status: 'valid' }, transaction: t });
session.sold -= order.quantity;
await session.save({ transaction: t });
Object.assign(order, { status: 'cancelled', cancelledAt: new Date(), cancellationReason: reason });
await order.save({ transaction: t });
return order;
});
}Exercise 2.
// scripts/concurrency-test.js
async function runTest() {
await Session.update({ sold: 0, capacity: 10 }, { where: { sessionId: 'ses-999-1' } });
const attempts = Array.from({ length: 50 }, () =>
buyTickets({ userId: 1, sessionId: 'ses-999-1', quantity: 1, channel: 'web' }));
const results = await Promise.allSettled(attempts);
const successes = results.filter((one) => one.status === 'fulfilled').length;
const soldOut = results.filter(
(one) => one.status === 'rejected' && one.reason.appCode === 'INSUFFICIENT_CAPACITY').length;
const session = await Session.findOne({ where: { sessionId: 'ses-999-1' } });
console.log(`successes: ${successes} (10), sold out: ${soldOut} (40), sold: ${session.sold} (10)`);
await sequelize.close();
}If once every hundred runs it reports 11 successes, you have a race condition; run it several times, because concurrency failures are intermittent by nature and that is exactly why you have to go looking for them on purpose.
Conclusion
Module 7 closes, and with it the JSON file era. We began by understanding why data/events.json had stopped being enough and what a DBMS gives you; we modeled Escena Viva with MongoDB and Mongoose, with its embedded sessions, its validators, its virtuals and its indexes; we wrote the complete CRUD and retired src/catalog-data.js, replacing it with src/repositories/events.js without the controllers noticing; we explored relationships, populate, the N+1 problem and the aggregation framework, which swept away the reports we used to build by reading CSV files; we visited the relational world with PostgreSQL and Sequelize, where a CHECK (sold <= capacity) turns an invariant into a law of the engine and where include performs a real JOIN; and today we have versioned the schema with migrations, loaded the catalog with an idempotent seed — the 3 events and 7 sessions, capacity 3000, 1811 sold — and finally solved the problem we had been dragging along since Module 4.
Because that is what matters: Escena Viva no longer oversells. Neither with SELECT ... FOR UPDATE inside a transaction in PostgreSQL, nor with findOneAndUpdate and its conditional filter in MongoDB. Two engines, two techniques, one and the same guarantee: either the purchase happens whole, or nothing happens. And yet, there is something that should be nagging at you. Look at buyTickets again. It receives a userId… from where? Right now, from whatever the client feels like sending. Anybody can call the API. Anybody can buy on Lucía's behalf, look up Marc's orders, publish an event at Teatro Almendra without being its organizer or cancel a stranger's tickets. There are no real users, no passwords, no sessions, no permissions. The User model has been waiting since lesson 07-02 with its role field — attendee, organizer, administrator — without anybody using it for anything.
In Module 8 we fill that gap: authentication and authorization. User registration and password hashing done properly, sessions and cookies with Passport, JSON Web Tokens, access control based on the roles we have already modeled, and the security best practices that turn an API that works into an API you can trust. The data is already safe from concurrency; now it is time to make it safe from strangers.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
