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