Villa Aurora, Florence
Zero lost characters
The old database stored text in several character sets — sometimes two within the same table. Libretto couldn’t go live until every name, title and grade came across exactly as it was meant to be.
The problem
Each column declared its own character set — latin1, latin2, UTF-16 — and a single users table mixed two of them: latin2 for first and last names, latin1 for everything else. Read naively through a UTF-8 connection, a name like Nicolás comes out as Nicolás — or, worse, characters vanish without a trace.
People were spread across three sources — personal records, logins and teaching assignments — with no shared key, and student numbers were unique only within a degree programme.
Constraints
- The old system stayed in use until cut-over, so the migration had to run again and again without duplicating anything.
- Production already held data loaded by earlier scripts; the sync had to adopt it, not wipe it.
- A non-technical operator had to be able to run it with one command.
- No schema changes to the application just to support the migration.
What I built
A sync engine in Node.js that reads the legacy database, plans every write as a pure transformation, checks the whole plan against a quality gate and only then writes — in foreign-key order, one transaction per logical unit, so a crashed run can simply be run again.
It is insert-only: it never updates or deletes. A mapping table (entity, legacy key → new id) remembers what has already moved. For each item: if its key is in the map, skip it; if a row with the same natural key already exists — an email, a user and course, a catalog code — adopt it into the map; otherwise insert it. That middle rule let the engine take over a production database that earlier scripts had already filled.
Three modes for three moments: a preview that writes nothing, a bootstrap that only builds the map, and real runs that require an explicit --allow-write. A wrapper for the operator opens the connections, runs it and prints the outcome in plain language.
The hard parts
Bytes in the old database
6C926573616D65
Decoded as UTF-8
l�esame
1 replacement character. The gate refuses to write.
Decoded as latin1
lInvisible control character U+0092esame
No replacement characters, but a character is gone
Decoded as Windows-1252
l’esame
Correct
Read bytes, not text
Every text column is read as hexadecimal — pure ASCII, so neither the driver nor the connection charset can corrupt it. The bytes are then decoded in Node with the codec that matches the column’s declared charset, looked up in information_schema and cached per table: UTF-16 as UTF-16BE, latin2 as Windows-1250, latin1 as Windows-1252.
Why Windows-1252, not latin1
In Windows-1252, bytes 0x92 to 0x96 are curly quotes and dashes. In strict latin1 they are invisible control characters — so decoding as latin1 loses them silently, without producing a single replacement character. A check that only looked for broken characters would have passed. Try it in the figure above.
A gate that refuses to write
Every decode counts U+FFFD, the replacement character. If the total is above zero, the engine throws before its first write and exits non-zero. In the prototype, decoding two sample tables as UTF-8 produced 54 and 284 of them; with the right codecs, zero. Later, when the live host converted one exam table to real UTF-8 mid-project, decoding moved from per table to per column.
Who is who
Three sources merged into 436 canonical users, including 51 placeholders for people who existed only in exam tables — without them, 280 exam records would have been dropped. Student numbers collided across people; resolving them by canonical identity and most recent enrollment assigned a number to 357 of 364 people and left 7 for a human to decide, instead of guessing.
Result
- Zero replacement characters across every migrated table.
- 436 canonical users from three sources, and 280 exam records kept that a naive merge would have dropped.
- 357 of 364 student numbers resolved automatically; 7 flagged for review instead of guessed.
- Idempotent by design: running it again after a load finds nothing to do. The map holds 14,227 records.
- On 13 October 2026 the old system was frozen and Libretto took over.
The code belongs to the client and stays private. Figures are illustrations with sample data; no real records appear on this page.