← All write-ups

Migration · Node.js · PostgreSQL

Upgrading a million-line API from Sequelize 4 to 6

The ORM under our core API was two major versions behind. Here's how I moved it to v6 in one cutover, the silent behavior changes I found along the way, and the production regression that showed up anyway.

Fortress OS · Jul–Sep 2026 · Owner and author of the upgrade

Context

Our core API is a Node.js and PostgreSQL monolith of roughly a million lines of JavaScript and TypeScript, with more than 500 Sequelize models. It was still on Sequelize 4, which was out of support and blocking other upgrades.

The original plan was two hops, 4 → 5 → 6. I went straight to 6 instead. I checked each concern from the v5 changelog one by one rather than paying for an extra cutover.

Hold the line first

A long-running upgrade branch loses to a busy trunk: every day, new code gets written in the old style. Before changing anything, I added a custom ESLint plugin for v4-only patterns and a diff-aware CI gate that fails a PR only for the violations it introduces. Existing code could stay as it was, but nobody could add to the pile while the upgrade was in flight.

The mechanical part

A mechanical diff verifier then checked every hunk. Each one had to be either a provable codemod transform or one of a short, listed set of hand edits. That kept a 408-file diff reviewable.

The part that matters: silent changes

The risky changes are the ones that don't throw. Most of the work went into finding these:

What production found anyway

On release day the API crashed three times with heap exhaustion. Datadog's out-of-memory heap profile pointed at one place: 9.5 GiB, 81% of the live heap, was in lodash's cloneDeep, reached from Model#toJSON during response serialization. v4's toJSON made a shallow copy and v6's makes a deep one, which is costly on large result sets.

I restored the shallow copy on the model prototype, after auditing all 116 toJSON call sites to confirm none mutated nested data. The same investigation exposed a latent bug v4 had tolerated: model hooks that started database writes without returning the promise. On v6 those writes raced their own transaction's commit and were rejected. Those hooks now return their promise and run inside the transaction. The fix merged the same day, as one PR covering all the upgrade's fallout, with data backfill tracked separately.

Outcome

What I'd keep

This write-up describes work at Fortress OS. Details are generalized, and no customer data or internal identifiers are included.