---
title: "I Wrote a 1,477-Line Site-Recovery Engine in Classic ASP: Copy First, Remap IDs Second"
canonical: https://dxdev.com/blog/copy-first-remap-ids-second-site-recovery-engine/
datePublished: 2026-01-27
---
A customer's entire sports site got deleted. Not one team, a whole league with 327 teams under it. The fix wasn't a button and it wasn't `RESTORE DATABASE`. It was a 1,477-line JScript-on-ASP recovery engine that resurrects one account from a backup database, row by row, table by table, against live production, behind a hard 30-minute `Server.ScriptTimeout`.

It has to work that way because of the data model. That data model is the whole point of this post, and it's also where the bug that almost shipped lives.

My first master-file draft still carried the unscoped remap pattern from the legacy per-sport scripts. I caught that pattern while writing the consolidated engine's header comment, before it reached a tenant's rows. The mistake was treating an old ID as though it meant the same thing outside the username that owned it.

## "Recover this site" is a copy/remap problem, not a restore

The production app stores each org's site as rows keyed by `username` across hundreds of per-sport tables: `<app-db>.<sport>games`, `<sport>scores`, `<sport>layoutstruct`, and so on for every sport. There is no per-tenant database. There is one big database where a tenant is a value in a column. So when one tenant's rows get deleted, the rest of the database is fine, and a database-level restore would clobber every other customer's live data to bring one customer back.

That leaves exactly one option: re-insert that tenant's rows out of a dated backup database (`<app-db><date>`) into live prod. Application-level copy, scoped to one tenant, into a table full of everyone else's rows.

I consolidated this into one master recovery file. Before that there was a pile of legacy variants, one copy-pasted per sport, each drifted from the others, and each carrying the same latent bug. I'll get to the bug, because the bug is the lesson.

The file is Classic ASP with JScript as the language:

```asp
<%@LANGUAGE="JAVASCRIPT" CODEPAGE="65001"%>
```

```js
var HOURS_TO_RUN = 0.5;
var MINUTES_TO_RUN = HOURS_TO_RUN * 60;
var SECONDS_TO_RUN = MINUTES_TO_RUN * 60;
Server.ScriptTimeout = SECONDS_TO_RUN;
var RECOVERY_BACKUP_DB = "<app-db><date>";
```

Each table's copyable columns are declared through a tiny DSL, two comma-separated lists per table, one for string columns and one for numeric columns. The same generic copy loop then handles `about`, `news`, `layoutstruct`, `games`, `scores`, and every stats table, because they all reduce to "these string cols and these numeric cols."

## The ordering invariant: copy first, then remap IDs

Here is the constraint that shapes everything. Child rows reference parent rows by integer ID. Scores point at games by `gameID`. Stats point at players. Photo details point at albums. Those parent tables have `IDENTITY` primary keys, so when you insert a game into prod, SQL Server assigns it a brand new `gameID`. You do not and cannot know that new ID until the row is in.

So you can't copy a score row and keep its old `gameID`, because that ID now belongs to some other tenant's real game. Copy it verbatim and you have silently pointed this customer's scores at another customer's games. That is cross-account corruption that no error will tell you about.

The invariant, stated in the file's own header comment, is "keep 'copy first, then remap IDs' as a clear, auditable ordering." You insert all the rows first with placeholder foreign keys, then make a second pass that rewrites the foreign keys to the real new IDs.

## The OFFSET_ID placeholder

The placeholder is one magic number:

```js
var OFFSET_ID = 100000000;

function AddOffset(numVal) {
  numVal = Number(numVal);
  if (!numVal) return 0;
  return numVal + OFFSET_ID;
}
```

On insert, every foreign key gets `OFFSET_ID` added to it. That parks it at 100,000,000-plus, a range no real ID in these tables has ever reached, so the offset FKs can't collide with a live row. The child rows go in with offset FKs that are deliberately wrong but unambiguously recognizable as "needs remapping."

Then the second pass walks the maps it built during insertion, `(oldID -> newID)` for game, archived-game, location, player, and album, and runs `UPDATE`s against the children: score rows, registration rows, the opponent-game-id column, every per-sport stat table, plus the archive variants of each. The offset value is swapped for the real new ID.

The remap reaches further than FK columns, too. Layout IDs get embedded inside serialized layout blobs, so a second remap function builds a per-ID regex and string-replaces old layout IDs inside the serialized text columns (content body, parent reference, and slideshow settings):

```js
reOldID = new RegExp("\\\\b" + Clean(oldID) + "\\\\b", "g");
```

Run against each of those serialized text columns in the layout table. If you only remapped FK columns you would leave dangling IDs baked into the serialized layout, and the recovered site would render with broken structure even though every foreign key checked out.

## The bug that eats multi-tenant migrations

Now the lesson, and it is the single most important line in the file, because I wrote the bug myself before I wrote the fix. Those legacy per-sport scripts were mine, copy-pasted one sport at a time, and the same unscoped remap pattern was still sitting in my first draft of the master file until I caught it while writing the header comment on the consolidated engine:

> 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.

Read that again with the data model in mind. Everything is a row keyed by tenant. Old IDs are not globally unique across tenants. Two different teams in the same league can have a game with the same old `gameID`, because those IDs were only ever unique within a tenant.

So if your remap is "find every row where `gameID = oldVal` and set it to `newVal`," you will happily rewrite a different team's game that happened to share that old ID. The remap that was supposed to fix one tenant reaches across tenants and corrupts the ones it never touched. With 327 teams under one league, the odds of a shared old ID are not theoretical, they are near certain.

Every remap UPDATE in this engine is scoped to the tenant:

```sql
WHERE username = ? AND field = oldVal
```

Username first, then the value. The tenant scope is not an optimization, it is correctness. Drop it and the engine becomes a corruption machine that looks like it's working, and the only signal you'll get is a customer asking why another team's scores are in their site.

## Related

- [The OFFSET_ID trick: copying rows whose foreign keys you don't know yet](offset-id-trick-remap-foreign-keys-restore): the placeholder step that makes this remap safe to perform later
- [From 3.5 million round-trips to hundreds: batching a remap with temp tables](batch-remap-temp-tables-3-5-million-round-trips): the set-based optimization for the same recovery engine when a league makes the per-row path too slow
