Skip the migration writes that change nothing, without skipping the ones that matter
Mihai Perdum
Author
10 min readSeptember 3, 2026
Key takeaways
END STATE: a re-run that writes only what differs, with a false-SAME test suite you run BEFORE trusting it with a write.
Our canonicaliser nulled any object holding an empty `text` key. A mention's attrs carries one, so attrs.id vanished and two DIFFERENT users hashed the same. Fixed by guarding on node type.
Hash the PLANNED value against the destination. Source-against-destination never matches for any entity your pipeline rewrites.
Both hashers collapse whitespace inside code — a mangled Python indent will be skipped as a no-op. Exclude code bodies.
Pick the hasher by BODY FORMAT, not by product: Confluence v2 defaults to atlas_doc_format, which is ADF.
Re-running a migration script should be cheap. Usually it is not: the second run rewrites every entity, bumps every version number, and spends the same rate-limit budget to produce bytes that are already there.
The fix is small — hash what you are about to write, hash what is there, skip when they match. I wrote that up, then went looking for cases where the hash is wrong, and found one that would have skipped writes it should have made. So this is the pattern and the test suite, because the second is what makes the first safe.
Note
Prerequisites
Node 18 or later. Verified on v24.15.0.
Two hashers from the migration toolkit: semanticHash in adf-builders.js for ADF, and BackupManager.storageHash in backup-manager.js for Confluence storage XHTML. Note the second is a static method, not a bare export.
No tenant, no credentials, no deploy. Everything runs locally against literals, which is the point: you want this proven before it decides whether to skip a production write.
About thirty minutes.
1
Write the harness so every claim below is a command you can run.
2
Prove it ignores cosmetic difference, and still catches a real edit.
3
Hunt for a false SAME, which is the failure that loses data.
4
Compare the planned value against the destination, never the source.
5
Check what a Cloud-to-Cloud move preserves and what it does not.
6
Pick the hasher by body format, not by product name.
7
Record the skip so an audit can tell it from an entity you never reached.
Step 1 — Write the harness
Every output block below comes from this file. Save it as check.js beside the two templates.
js
1const adf =require("./adf-builders.js");2const{BackupManager}=require("./backup-manager.js");34constp=(t)=>({type:"doc",version:1,5content:[{type:"paragraph",content:[{type:"text",text: t }]}]});6consth=(d)=> adf.semanticHash(d);7constrow=(label, a, b, want)=>{8const same =h(a)===h(b);9console.log(`${same === want ?"ok ":"!! "}${label.padEnd(44)}`+10`${same ?"SAME":"DIFF"} want ${want ?"SAME":"DIFF"}`);11};12module.exports={ adf,BackupManager, p, h, row };
How you know it worked:node -e "require('./check.js'); console.log('harness ok')" should print harness ok and nothing else. If it throws on BackupManager, you destructured a bare export that does not exist — storageHash hangs off the class.
Step 2 — Prove both directions
A hash that returns SAME for everything is worse than no hash, because it silently stops migrating. So run the negative control in the same breath as the positive one.
bash
1node check-cosmetic.js
text
1=== should be SAME — cosmetic difference only ===
2 ok identical documents SAME want SAME
3 ok collapsed whitespace SAME want SAME
4 ok leading/trailing space SAME want SAME
5 ok empty marks array present SAME want SAME
6 ok key order differs SAME want SAME
78=== should be DIFFERENT — a real edit ===
9 ok one word changed DIFF want DIFF
10 ok case changed DIFF want DIFF
How you know it worked: every line should start ok. Case matters — the canonicaliser collapses whitespace but does not lower-case, which is right for prose and wrong for URLs, where scheme and host are case-insensitive. If you hash links, know that HTTPS://… and https://… will differ.
Step 3 — Hunt for a false SAME
This is the step that matters, and it is the one I nearly skipped. A false DIFFERENT costs you rate-limit points. A false SAME skips a write that should have happened, and nothing anywhere reports it.
Ours had one, in the code that had been in production for months:
text
1 raw mention A : {"type":"mention","attrs":{"id":"accountid-AAAA","text":"","accessLevel":""}}
2 raw mention B : {"type":"mention","attrs":{"id":"accountid-BBBB","text":"","accessLevel":""}}
3 canonical A : {"attrs":null,"type":"mention"}
4 canonical B : {"attrs":null,"type":"mention"}
5 semanticHash equal: true
Two mentions of different people, hashing identically. The canonicaliser had this:
js
1if(k ==="text"&&typeof v ==="string"){2 v = v.replace(/\s+/g," ").trim();3if(!v)returnnull;// <- nulls the ENCLOSING OBJECT4}
The intent was to drop empty text nodes. What it actually did was null any object holding an empty text key — and a mention's attrs holds one. Atlassian's ADF spec makes attrs.text optional while attrs.id is required, and our own mention(accountId) helper emits text: "" when no display name is passed. So attrs was nulled, taking attrs.id with it, before anything was compared. Emoji and status nodes have the same shape and were annihilated the same way.
Play that forward in a migration. Run one writes a wrong accountId. You spot it, fix the mapping, re-run — and the hash reports the destination already matches. The write is skipped, the plan records success, the audit is clean, and the wrong user stays there forever.
The fix is to guard on the node type rather than the key name:
js
1const isTextNode = node.type==="text";2// …3if(!v && isTextNode)returnnull;// an empty TEXT NODE is nothing
How you know it worked: after the fix, two mentions with different ids should print DIFF, and all seven rows from step 2 should still print ok.
text
1 two users, empty attrs.text: DIFFERENT fixed
2 built by adf.mention(): DIFFERENT fixed
3 emoji :A: vs :B: DIFFERENT fixed
4 status Done vs Todo DIFFERENT fixed
And do not read that as the only one. It is the one I found. Two others I can characterise: JavaScript's \s matches a non-breaking space, so text differing only by is skipped; and both hashers collapse whitespace inside code, so a migration meant to repair mangled Python or YAML indentation will treat it as a no-op. Exclude code bodies from the hash. I have not enumerated the rest, and you should not assume the list is closed.
Step 4 — Compare the planned value, not the source
The tempting comparison is source against destination. For any entity your pipeline rewrites — which is the point of a migration — those never match, so nothing is ever skipped and you have added a read per entity for no saving. (For an entity the pipeline leaves alone, they do match; that is the exception, not the rule.)
The correct comparison is planned against destination. It is the same ordering discipline as stamping a plan with the tenant it was built for — a check earns its read only when it compares the two things that can actually differ:
js
1const planned =rewrite(sourceDoc);2const current =await jira.getIssue(key,"description");3if(adf.semanticHash(planned)=== adf.semanticHash(current.fields.description)){4// skip — see step 7 for how to record it5}
How you know it worked — the two orderings should give opposite answers on the same pair:
text
1 planned-for-B vs live-on-B : SAME -> correctly skips
2 source-on-A vs live-on-B : DIFFERENT -> would rewrite forever
One guard before you ship it: semanticHash(null) === semanticHash(undefined) is true, so a rewrite() that returns undefined against an empty destination field skips and reports success. Assert your planned value is a document before you hash it.
Step 5 — Check what a Cloud-to-Cloud move preserves
On Cloud-to-Cloud, a mention with a stable accountId and the same cached display name hashes the same on both tenants. The accountId belongs to the Atlassian account rather than the site, so it survives the move:
text
1 same accountId + same text SAME (skip)
2 same accountId, display name differs DIFFERENT (write)
That second row is worth knowing: attrs.text is hashed too, so a cached display name that changed between tenants writes even when the identity did not.
Data Center is the opposite case. The user identifier is remapped as part of the move, so a source-versus-destination hash can never match for any mention-bearing document. Note the source representation differs by product — Confluence stores an opaque per-instance key, <ri:user ri:userkey="2c9680f7405147ee0140514c26120003"/>, which the storage format reference calls "the unique identifier of the user". It is not a username, and it means nothing on the destination.
How you know it worked: on a Cloud-to-Cloud plan you should see a meaningful skip rate on mention-bearing content and near zero on anything embedding the old tenant hostname. On a Data Center plan you should see near zero either way until you switch to planned-versus-destination — which is step 4, and on DC it is not an optimisation but the only way the check works at all.
Step 6 — Pick the hasher by body format
The rule is not "Confluence uses storageHash". It is: hash the format you actually fetched.
Confluence Cloud's v2 API defaults to atlas_doc_format, which is ADF — our own client signature is getPageByIdV2(pageId, bodyFormat = "atlas_doc_format"), and the toolkit's notes call it "ADF JSON. Same as Jira's. The new default." Fetch a v2 page with defaults and hand it to storageHash and you get a string hash of serialised JSON, which sorts no keys and skips nothing.
text
1Two semantically IDENTICAL Confluence ADF bodies, different JSON key order:
2 storageHash : DIFFERENT <- never skips
3 semanticHash : SAME <- correct
Use storageHash only when you asked for body-format=storage. It normalises the three things that change XHTML bytes without changing the page:
js
1.replace(/>\s+</g,"><")// inter-tag whitespace2.replace(/\s+\/>/g,"/>")// self-closing form3.replace(/\s+/g," ")// runs of whitespace — including inside <pre>
Read that first rule closely, because it is narrower than it looks: it collapses whitespace between tags, where a > is followed by a <. Indentation wrapped around text is a different case. \s+ becomes a single space rather than nothing, and nothing trims, so <p>hello</p> and <p>\n hello\n</p> canonicalise to <p>hello</p> and <p> hello </p> — and hash differently.
How you know it worked: three SAME and two DIFFERENT.
text
1=== Confluence storage XHTML ===
2 identical SAME (skip)
3 reflowed whitespace SAME (skip)
4 indentation BETWEEN tags SAME (skip)
5 indentation around TEXT in a tag DIFFERENT (write)
6 real edit DIFFERENT (write)
That fourth row is the harmless direction — a false DIFFERENT costs you a write you did not need, not data. Worth knowing anyway if your source was ever pretty-printed, because it will quietly cost you the whole saving on exactly the content you hoped to skip. And note the third rule is the code-indentation trap from step 3 — it does not stop at markup.
One more thing to expect: localId on panels and task lists, and ac:macro-id on Confluence macros, are stamped per instance and are hashed. On macro-heavy content the skip rate collapses and that is the reason — worth putting on the gap list before you start rather than discovering it mid-run.
Step 7 — Record the skip
The signature is updateEntryStatus(entryId, status, error = null) — that third argument is the error field, so passing a reason there marks every successful skip as an error. Put the reason somewhere it belongs:
How you know it worked: after a second run over unchanged data, every entry should read status: "skipped" with error: null and a populated skipReason. A silently-returned skip leaves {"status":"pending","error":null} — byte-identical to an entity the script never reached, and it will be handed back to you again on every future run.
[[takeaways]] A semantic hash makes a re-run cheap: canonicalise, hash, skip the writes that would produce bytes already present. Compare the planned value against the destination, never the source, because rewriting is the job.
The part I would carry to any hash you write yourself: the false SAME is the one that costs you. Ours nulled any object holding an empty text key, which erased a mention's accountId — so two different users hashed alike and a corrected user mapping would have been skipped as "already matches", with a clean audit. It is fixed by guarding on the node type, and it existed because the canonicaliser was tested for what it should ignore and never for what it must not.
Test both directions before a hash is allowed to skip a write. Exclude code bodies, since both hashers collapse whitespace inside them. Choose the hasher by the body format you fetched rather than by the product. And record the skip in the plan, because an audit cannot tell a silent skip from an entity you never reached.
Build your migration's gap list before you start, and make the differ refuse to guess