fix(uploads): ship the image backfill script in the container image (#231)
Linting / lint (pull_request) Successful in 3m48s
SonarQube Analysis / sonarqube (pull_request) Failing after 21m6s

`npm run backfill:images` could not run in QA or production. Three reasons, each sufficient alone: tsconfig includes only `src`, so `scripts/` was never compiled; the Dockerfile copies `dist`, `migrate.js` and `migrations` and never `scripts/`; and `tsx`, which the npm script invoked, is a devDependency that `npm install --omit=dev` strips from the final stage. The half of #226 that closes the exposure on already-stored photos had no way to run where the photos are.

Moved to `src/backfillImageReencode.ts` so it compiles into `dist` and ships. Both of its runtime dependencies, sharp and pg, were already production dependencies, so the image needs nothing else. `scripts/bench-hash-latency.ts` was the pattern followed originally, and it is a development tool that never needs to run deployed; this one is an operational task that can only be useful where the images are, which makes `migrate.js` the right precedent instead.

The npm script now runs the compiled output rather than tsx, so one command behaves identically on a laptop and inside a container. The entry point is guarded with `require.main === module`: putting a catalogue-wide irreversible rewrite in the same directory the server imports at boot means an accidental import would otherwise run it, and nothing should depend on people continuing not to write that import.

Proven in the built production image rather than argued. `node_modules/.bin/tsx` and `scripts/` are both absent from it, and `npm run backfill:images` still runs: report mode found the planted file, `--apply` rewrote it 35760 to 16019 bytes, a second `--apply` reported skipped 1 processed 0, and on the mounted volume the EXIF was gone with the image bounded to 2000x1333 and still JPEG. That is the exact scenario the previous version would have failed.

Backend: 285 unit, 260 integration, tsc clean, lint unchanged at six warnings, all six pre-existing.

Closes #231
This commit is contained in:
2026-08-29 15:03:44 -05:00
parent 846646f7ec
commit 167c8ad97c
2 changed files with 31 additions and 9 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
"test:integration:json": "jest -c jest.integration.config.js --runInBand --json --outputFile=integration-results.json",
"test:integration:cov": "jest -c jest.integration.config.js --runInBand --coverage --forceExit",
"bench:hashing": "tsx scripts/bench-hash-latency.ts",
"backfill:images": "tsx scripts/backfill-image-reencode.ts",
"backfill:images": "node dist/backfillImageReencode.js",
"db:test:up": "docker compose -f docker-compose.test.yml up -d",
"db:test:down": "docker compose -f docker-compose.test.yml down -v",
"migrate:up": "node migrate.js up",
@@ -15,11 +15,25 @@
* - It never renames the stored file, so item_images.image_path stays correct
* and no database write is needed at all.
*
* It lives in src/ rather than scripts/ so that it compiles into dist and ships
* in the container image. `scripts/` is excluded by tsconfig, is never copied by
* the Dockerfile, and would need `tsx` a devDependency that `npm install
* --omit=dev` removes. An operational task that can only be useful where the
* images are has to be somewhere the image actually carries it, which is the
* same reason `migrate.js` sits where it does. See #231.
*
* Usage
* -----
* Locally, after `npm run build`:
*
* npm run backfill:images # report only
* npm run backfill:images -- --apply # rewrite the files
*
* In a deployed container, identically package.json ships in the image:
*
* docker exec <container> npm run backfill:images
* docker exec <container> npm run backfill:images -- --apply
*
* Point it at QA first. Compare a handful of images by eye before production,
* and take a backup that you have confirmed restores.
*/
@@ -27,8 +41,8 @@
import sharp from 'sharp';
import { promises as fs } from 'fs';
import path from 'path';
import { pool } from '../src/db';
import { needsProcessing, reencodeInPlace } from '../src/imageProcessing';
import { pool } from './db';
import { needsProcessing, reencodeInPlace } from './imageProcessing';
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/app/uploads';
const APPLY = process.argv.includes('--apply');
@@ -152,9 +166,17 @@ async function run(): Promise<void> {
}
}
run()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => pool.end());
// Guarded rather than run on import. This file lives in src/ so that it
// compiles into dist and therefore ships in the image (#231) — but that puts a
// catalogue-wide, irreversible rewrite in the same directory as the modules the
// server imports at boot. Without this guard, importing it by mistake would run
// it. Nothing imports it today; the guard is here so that staying true does not
// depend on anyone noticing.
if (require.main === module) {
run()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => pool.end());
}