14 SEPTEMBER 2026 · what shipped

The dry run

One environment variable, FILE_MIGRATION_DRY_RUN. The whole backfill runs for real — seed, batches, lanes, chaining — except the two steps that leave a mark outside the migration: no object is copied and no record is rewritten.

Files changed
5backendApi only
Lines
56no new job, route or button
Front end
0no swagger regen
Gates
3layering, tsc, eslint
why it is trustworthy

The flag sits at two leaves — the copy call and the thumbnail pass. Everything above them is untouched, so the run you watch is the run you would ship rather than a second implementation of it. Both existence checks still happen, which is the whole difference from FILE_MIGRATION_SKIP_COPY: that one short-circuits before looking and marks every row copied without checking anything.

What runs and what does not2 skips

seed, cutBatch, lanes, chaining, closeBatchruns

Untouched. The SKIP LOCKED claim, the parallel lanes and the batch close all behave exactly as in a real run.

exists(destination) then exists(source)runs

Both checks, in the same order, before anything else.

copyPreservingAcl(...)skipped

The single line removed. Everything either side of it is the real code path.

the thumbnail passskipped

A thumbnail never decides its row’s outcome, so probing for one would cost two reads per picture and tell the ledger nothing.

markCopied / markSkippedruns

The ledger records the outcome a real run would have reached — which is where the result is read, and why no new UI was needed.

write-backrefused

Hard refusal in writeBackPass, not a warning. Rollback is still allowed: it only restores values this migration itself wrote.

Why the missing count can be believedthe probe switch

checkFileExistsswallows every error

Returns false on a permission error, an auth expiry or a network blip — indistinguishable from a file that is genuinely absent. For the copier that is tolerable, because a rerun re-checks. For a preview it would corrupt the exact figure being sought.

objectExistsOrThrowdry run only

Errors reach withRetry instead of being swallowed, so a blip is retried and then fails rather than being counted as a missing source. The live copy path keeps its original behaviour byte for byte.

Two things to knowbefore using it

the Joi default is trueopen

Written as .default(false); the file currently reads .default(true), so an environment with no explicit value runs in dry mode and copies nothing. @nestjs/config writes validated defaults back into process.env, so it takes effect. A test-box value belongs in .env, not in the schema default.

reset the ledger between runsoperational

A dry run leaves rows marked copied. A real run afterwards sees the work as done and does nothing.