A customer’s entire league got deleted. Hundreds of teams, gone. The recovery tooling I reached for was a pile of per-sport Classic ASP scripts: one for hockey leagues, one for soccer leagues, one for baseball leagues, each restoring a tenant’s site row by row from a dated backup database. Three files that did almost the same thing.

Every one of them carried the same bug, copy-pasted across all three and then quietly mutated by years of independent edits.

The bug was an un-scoped remap. When you copy a tenant’s rows into a multi-tenant database with IDENTITY primary keys, you can’t know the new IDs until after the rows are inserted, so you insert with placeholders and then rewrite the foreign keys in a second pass. Those rewrite UPDATEs need to be scoped to the tenant you’re recovering. The legacy scripts ran UPDATE ... SET gameID = newID WHERE gameID = oldID without filtering by which account owned the row. A single-team test can look fine when no overlapping old values happen to be present. In a league where dozens of teams were created in the same era and share overlapping old ID values, it becomes quiet cross-account corruption: you recover one team and repoint another team’s games at the wrong rows.

That’s the thing about N copy-pasted variants. The danger people warn you about is duplication: more lines, more files, more to maintain. That’s the boring half. The real danger is that the copies drift, each gets edited in isolation by whoever was on call that week, and underneath the drift they still share the original sin from the day someone hit Ctrl-C. You don’t have one bug in three places that you can fix three times. You have one bug that’s been forgotten in N-1 places, because the day you find it in the hockey script, nobody’s looking at the soccer one.

The consolidation

The fix wasn’t to patch each script. It was to delete the premise that there should be more than one. I consolidated the per-sport variants into a single 1,477-line recovery engine driven by data instead of forking. Sport-specific behavior moved into config: detect the sport from the backup, build the per-sport stat-table and player-table definitions from a lookup, drive the score-column lists off the sport. One file, one code path, the sport is an argument.

The header comment on the new file says it out loud, both the goal and the bug it was built to kill:

Consolidate behavior from the legacy per-sport recovery variants. The biggest bug in several legacy league scripts is that remap UPDATEs do NOT filter by username, which can corrupt multi-team leagues where different teams share the same old ID values.

In the consolidated engine, every remap UPDATE carries the tenant: UPDATE ... SET gameID = newID WHERE username = ? AND gameID = oldID. The placeholder-and-remap pattern itself is worth a sentence because it’s why the bug existed in the first place. Child rows like scores and stats reference parents like games by integer ID. Copy them verbatim and the foreign keys point at some other tenant’s real rows. So on insert, every FK gets bumped into a reserved numeric range that can’t collide with live IDs, and after a tenant’s rows are all in, a remap pass swaps each parked value for the real new ID. That remap is exactly the UPDATE that has to be tenant-scoped. Get the scoping wrong and the offset trick that was supposed to prevent collisions becomes the mechanism that causes them.

The win here is not fewer lines. Consolidating three forked files into one made that one file large. If you measure refactors by line count you’d call this a loss. The win is that the bug now has exactly one place to live. Fix the un-scoped remap once and it’s fixed for hockey, soccer, baseball, and every sport added later, because there is no “later soccer copy” to forget.

Why drift makes copies worse than they look

Copy-paste feels safe at the moment you do it because the copy is correct. It’s a snapshot of working code. The problem is everything that happens after.

Variant A gets a fix for a date-handling edge case. Variant B doesn’t, because the person fixing A didn’t know B existed or didn’t have a reason to open it. Variant C gets a new column added to its copy list for a feature that shipped on that sport first. Now the three files are genuinely different. They’ve drifted. And drift is camouflage: it makes the files look like they were intentionally specialized, like they have real reasons to differ, when most of the difference is just the random walk of independent maintenance.

So when you finally find the shared defect, you can’t trust that it’s shared in the obvious way. You have to read all N files carefully to confirm each one has it, because each one might have the bug in a slightly mutated form, or might have accidentally fixed it, or might have a second bug the others don’t. The cost of the fix scales with the number of copies and the degree of drift, and both of those went up every week you left the copies in place. The un-scoped remap was in the league scripts. Whether it was in the same exact shape in every variant, or subtly different in each, was its own investigation before a single line got changed.

Consolidation collapses that. After the merge there is one file to read, one remap to verify, one copy-list to audit.

Consolidation makes the rest of the engineering possible

There’s a second-order payoff that’s easy to miss. Once the recovery logic lives in one file, you can build tooling around that one file. With N drifting copies, any tool you write to check correctness has to handle N targets and N versions of the truth.

The recovery engine copies columns by hand-maintained lists, one declaration per table naming its string and numeric columns. Those lists silently fall behind every time a feature adds a column, and a recovery that skips a column drops customer data. So I wrote a schema-audit tool that parses the recovery code as text, pulls out the declared column lists, diffs them against the live database schema exported to CSV, and reports what’s missing. The database has grown to thousands of columns across hundreds of tables and keeps growing, so that diff is the only thing standing between “the copy lists are complete” and “we’ll find out which columns we dropped when a customer notices.”

That audit tool targets a single file. It parses one set of column declarations and checks them against the schema. If the recovery logic were still three drifting forks, the audit would have to parse three files, reconcile three different column-list dialects, and account for the fact that each fork might be behind the schema by a different amount. The schema-sync tool is feasible because consolidation gave it one source of truth to check. The refactor didn’t just fix the bug. It bought every safety tool after it the right to assume there was only one place to look.