Document the Node.js upgrade procedure and this build's reinstall requirement

Upgrading the runtime does not go through scripts/upgrade.sh, so nothing
reinstalls dependencies — which is precisely when the one remaining native
module goes stale. The runbook now covers the version floor imposed by
--env-file-if-exists, why the better-sqlite3 pin is exact, and why a version
without a matching prebuild can turn Restart=always into a boot loop.

Also records that this build changes dependencies in both directions: rolling
back past it needs the reinstall too, because earlier builds import sharp at
runtime and this one drops it from production dependencies.

Kept deployment-neutral — no hostnames, addresses, or environment specifics.
This commit is contained in:
ScreenTinker 2026-08-13 12:25:04 -05:00
parent c436a44c89
commit ba33b26a96
2 changed files with 113 additions and 1 deletions

View file

@ -1,5 +1,60 @@
# Changelog
## Unreleased
<!-- Rename this heading to the version when cutting the release — scripts/bump-version.sh
warns if CHANGELOG.md has no entry matching the new version. -->
### ⚠️ Upgrading to this build requires reinstalling dependencies
Both changes below alter `server/package.json`, so **`npm ci --omit=dev` is required, not
optional** — in both directions. The runbook's rollback step marks that command "only if
dependencies changed"; for this release they did.
- **Upgrading**: `scripts/upgrade.sh` already runs it. A hand-rolled deploy that skips it leaves a
`better-sqlite3` that no longer matches `package.json`.
- **Rolling back past this build**: the reinstall is **mandatory**. Earlier builds import `sharp` at
runtime to thumbnail images, and this build removes it from the production dependencies — so
rolling back the code without reinstalling leaves a server whose image ingest cannot load its
decoder. `lib/preflight-deps.js` catches this at boot and repairs it, but do not rely on that as
the plan.
Docker deployments need no action either way: dependencies are installed inside the image.
No migrations, no configuration changes, no player-side changes.
### Changed — image processing no longer uses a native module
`sharp` is gone. Thumbnailing and image measurement are pure JavaScript (Jimp) with WebAssembly
codecs for webp and avif, running on a worker thread. `sharp` remains as a development dependency
for test fixtures, so `--omit=dev` excludes it from a deployed install entirely.
The motivation is that a native module needs a prebuilt binary matching both the platform and the
Node ABI; when there isn't one, the failure arrives at load time and reads like database corruption
rather than a missing image library. `better-sqlite3` is now the only native module left.
Format support is unchanged in practice. jpeg, png, gif, tiff and bmp decode natively; webp and avif
via WebAssembly. `.heic` still produces no thumbnail — it never did, because the `sharp` builds in
use decode AV1 but refuse HEVC.
Images are decoded on a worker thread rather than in-process. Pure-JavaScript decoding costs about a
second for a 12-megapixel photo, which in-process would block the event loop — and the thumbnail
backfill walks an entire library at boot, which is exactly how a maintenance task turns into missed
heartbeats and players marked offline. Thumbnailing is slower in wall-clock terms than the native
library was, and no longer competes with serving requests.
### Changed — `better-sqlite3` pinned to 12.9.0
Preparation for a future Node 22 upgrade, landed separately so the runtime move and the database
driver move stay independently reversible.
The pin is **exact on purpose**. 12.9.0 is the last release publishing prebuilt binaries for both
the current and the next Node major; later 12.x releases dropped the older one while still
advertising support for it in `engines`. A caret range would resolve to one of those and silently
turn installation into a from-source compile. `lib/preflight-deps.js` explains this at the point
anyone debugging the resulting failure would be reading.
Nothing in the query API changed — every major since 9.x was bumped only to drop end-of-life Node
and Electron versions.
## 1.9.34-alpha6
### Added — a setup guide for single sign-on

View file

@ -20,6 +20,7 @@ did not behave.
- [Verifying a deploy](#verifying-a-deploy)
- [Rolling back](#rolling-back)
- [Releases and version numbers](#releases-and-version-numbers)
- [Upgrading Node.js](#upgrading-nodejs)
- [Traps worth knowing before they bite](#traps-worth-knowing-before-they-bite)
---
@ -253,12 +254,68 @@ or an image, delete-and-repush is not a fix; cut the next version instead.
---
## Upgrading Node.js
Upgrading the runtime is not like deploying a release: nothing in the app's own upgrade path is
involved, so the usual `scripts/upgrade.sh` never runs and nothing reinstalls dependencies. Read
this before changing the Node major.
**Do it as two separate deploys, never one.** Move the app to a release whose dependencies support
both the old and new Node major first, confirm it on the runtime you already have, and only then
change Node. Each half is then independently reversible. Doing both at once means a failure gives
you nothing to bisect and no single step to undo.
**Check the version floor.** `npm start` uses `node --env-file-if-exists=.env`. That flag reached
the Node 22 line only in **22.9.0** — it works on Node 20 because it was separately backported
there. On Node 22.022.8 the server refuses to start with `node: bad option`. Target 22.9.0 or
newer.
**One native module has to survive the move.** `better-sqlite3` is compiled against a single Node
ABI, so changing Node invalidates it. Two things make this survivable:
- `lib/preflight-deps.js` runs before anything else at boot, detects the mismatch by *opening a
database* (a bare `require` succeeds even on a wrong ABI, so it is not a valid check), and repairs
it with `npm rebuild better-sqlite3`.
- The pinned version ships **prebuilt binaries for both the current and the next Node major**, so
that repair downloads a binary instead of compiling one.
⚠️ **That second point is why the version is pinned exactly rather than with a caret**, and why
widening it is risky in a way `package.json` does not show. A version with no prebuild for your Node
falls back to a from-source `node-gyp` build — and because preflight rebuilds *synchronously before
the server listens*, a compile that outlives `TimeoutStartSec` turns `Restart=always` into a boot
loop that never finishes. Before changing that pin, check the project's release assets and confirm a
prebuild exists for every Node ABI you intend to run. A build toolchain (`python3`, `make`, `g++`)
should still be present as a fallback.
**Native (git + systemd)**
1. Back up first — a Node upgrade cannot corrupt the database, but you want the rollback anyway.
2. Change the Node major. If Node came from a distribution repository pinned to a major, the repo
definition itself must be repointed — upgrading the package alone can never cross majors, and
this pin lives in system configuration rather than in this repository.
3. `node --version` to confirm.
4. Rebuild the native module explicitly (`npm rebuild better-sqlite3` as the service user, in
`server/`), or let preflight do it on the next restart. Doing it by hand keeps the logs readable.
5. Restart, then verify as in [Verifying a deploy](#verifying-a-deploy). In the logs, confirm
preflight reports a successful rebuild rather than exiting.
**Docker** — nothing to rebuild. Change the base image, build, and deploy the new tag: dependencies
are installed inside the image against its own Node, so the ABI can never be stale. Rollback is
repinning the previous tag.
**Afterwards, move CI too.** CI pins its own Node version, and it will happily keep validating a
version nobody runs — which is worse than no signal, because it looks like coverage. The Docker base
image is a separate pin from the CI one; both need changing or what CI tests and what ships diverge.
---
## Traps worth knowing before they bite
**Native modules are built for one Node ABI.** `better-sqlite3` is compiled against the Node that
installed it. Run the app — or its tests — under a different major version and it fails with
`NODE_MODULE_VERSION` mismatch, which presents as hundreds of unrelated test failures rather than
one clear error. Use the same Node the service runs.
one clear error. Use the same Node the service runs. See [Upgrading Node.js](#upgrading-nodejs)
before changing it deliberately.
**SQLite foreign keys are off unless enabled per connection.** A declared `ON DELETE CASCADE` does
not fire on its own, so deleting a parent row can leave orphaned children. Check with