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.
- 408files changed
- ~1,700operator keys converted
- 33,073unit tests passing at merge
- 81%of the heap traced to one deep clone after release
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
- About 1,700 string operator keys (
$or,$gte, …) converted toOpsymbols across source and tests. - About 280
findByIdcall sites moved tofindByPk. sequelize.import(), removed in v6, replaced with direct model factory requires.
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:
- Symbol keys are invisible to
Object.keys. Two guards that checked "is this where-clause empty?" were quietly dropping filter fragments once operators became symbols. - The dirty-field set changed from an object to a
Set. Code that read the private field would have silently stopped writing changelogs. Switched to the publicchanged()API. - The CLS namespace moved. After-commit hooks lost track of implicit transactions until the helper read the namespace directly.
undefinedin a where clause now throws instead of being stripped. 12 real call sites fixed. About 60 more verified safe by their input validation.RETURNING *became an explicit column list. Any column a migration added but the model doesn't declare no longer comes back after a write. The audit found 56 tables with undeclared columns. These were tracked as follow-up rather than silently "fixed".- Connection pinning. v6 issues session
SETs that pin RDS Proxy connections. One dialect option suppressed them.
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
- The API now runs on a supported major version. Heap usage returned to pre-upgrade levels after the fix.
- Latency stayed the same. Performance was never the goal: getting off an unsupported ORM without regressions was.
- The diff-aware lint gate is still running, now as a general guard against Sequelize anti-patterns.
What I'd keep
- Gate new violations, not old ones. It lets a long migration coexist with daily feature work.
- Mocked unit tests can't see ORM behavior. The bugs that mattered showed up only against a real database or under production data volumes.
- Read the library's source, not just its changelog. Both of the worst regressions came from internals the changelog never mentioned.
This write-up describes work at Fortress OS. Details are generalized, and no customer data or internal identifiers are included.