Ctrl K
ComunidadGuías…

DATABASE_MIGRATION.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# External Database Support — Scoping Document

Status: **PostgreSQL — COMPLETE. All 80 files converted and verified against a real Postgres
container**, covering
auth, users, quality/library config, indexers, download clients, calendar events, saved library
views, remote instances, friend libraries, library groups (including its `WITH RECURSIVE`
nested-count rollup), person credits, custom formats (including TRaSH-Guides sync), collections
(including its smart-filter query builder and item reordering transaction), album tracks, blocklist,
import exclusions, artwork selection, global library search, share links, activity
(queue/history/timeline), the public calendar feed + token, the dashboard widgets, subtitle providers,
the wanted/missing + calendar views, household requests (including auto-approval and per-user storage
stats), web push subscriptions, the media-server watch webhook, library/media-server validation, the
full recycle-bin/corrupt-media/auto-archival cluster, instance settings (including the TOTP 2FA and
config-template export/import flows), the import-review queue, library media-compatibility analysis,
duplicate-detection, release-group reputation tracking, custom-format release scoring, storage
forecasting/disk-usage sampling, repeated-import detection, upgrade-candidate detection,
unmonitored/duplicate-file cleanup suggestions, Trakt list sync, TMDB/Last.fm recommendations, manual
media import, watchlist CSV import, media-server library import (Plex/Jellyfin/Emby movies + series),
import-list syncing (Trakt/IMDb/Last.fm), library scan-and-import (the filesystem scan that
matches/creates movies, episodic shows, and collection/artist items, plus its has_file rollups),
import-list CRUD, Prometheus metrics, Radarr/Sonarr/Lidarr/Readarr library migration, the
post-download file placement/manual-import pipeline (single/episodic/collection-shape file moves,
season-pack imports, and multi-file album imports), system status/network-stats/health/
orphaned-file-scan, the full media library CRUD surface (item CRUD, tags, bulk monitor/tag, CSV
export/import, metadata export (.nfo/.json/.plexmatch/Calibre .opf), corrupt-file check,
metadata-provider fetch, rematch, watch-state, episodes, sub-items/tracks, and the yt-dlp direct
sub-item download), the scheduler (auto-search/grab, queue polling, stalled-download cleanup, retry
logic, video-channel checks, and every registered background job), manual/bulk search and grab, and —
closing out the one deferred structural question from Round 92 onward — real per-dialect database
backup/restore (SQLite via better-sqlite3's own backup API, Postgres via `pg_dump`/`pg_restore`).
`AONARR_DATABASE_DRIVER=postgres` now runs the complete app, with the same routes, jobs, and behavior
as SQLite mode — this document's job is done; it's kept for the historical record and in case MariaDB
support is picked up later. MariaDB — scoped, not started, deliberately deferred until PostgreSQL was
fully done (see "The ask" below; narrowed from "MariaDB or PostgreSQL" to "PostgreSQL first" by
explicit user decision) — now unblocked if the user wants to pursue it.

## Migration complete (Round 102)

Every file that talks to the database now goes through the async `AsyncDb` interface
(`db/asyncDb.ts`), dispatching to either `SqliteAsyncDb` (wraps better-sqlite3) or `PostgresAsyncDb`
(a real `pg` Pool) based on `AONARR_DATABASE_DRIVER`. The last 3 files — `services/scheduler.ts`,
`routes/search.ts`, and `services/scheduledBackup.ts` — are covered in Round 102's notes below. See
the "Progress (Round N)" sections below for the full history of what was converted each round, the bug
classes found along the way (unquoted camelCase aliases, `COUNT()`/`SUM()` string-vs-number, SQLite
`LIKE` case-sensitivity, `IS ?` vs `IS NOT DISTINCT FROM`, `INSERT OR IGNORE` vs `ON CONFLICT DO
NOTHING`, the `INTEGER`-vs-`BIGINT` overflow bug), and the restructuring patterns used (async
transactions, `Promise.all`-precomputed `Map` lookups ahead of a `.sort()`/`.filter()`, per-dialect
`nowExpr()`/`nowOffsetExpr()`/`nowOffsetHoursExpr()` for in-query "now").

Execution plan: work tier by tier, batching several small Tier 1 files per round (matching this
session's established round size) and giving each large file (`starrImport.ts`, `importer.ts`,
`system.ts`, `media.ts`, `scheduler.ts`) its own dedicated round with full live Postgres+SQLite
verification, following the same ritual every round since 79 has used (typecheck → camelCase-alias
grep → build → live-verify both backends → update this doc + CHANGELOG.md → commit/push →
rebuild/push Docker Hub tags). `services/scheduledBackup.ts`'s per-dialect backup logic is deferred
to its own round after everything else, since it's a design question, not a conversion mop-up.

## Progress (Round 102) — final round, migration complete

Converted the last 3 files:

- **`services/scheduler.ts`** (864 lines) — `rowsToIndexers()`, `rowsToDownloadClients()`,
  `getQualityProfile()`, `isAlreadyQueued()` made `async`; `grab()`'s queue/history inserts;
  `runAutoSearch()`'s monitored-items query and its 3 shape branches (single/episodic/collection);
  `searchAndGrabTargets()` (backs the Library/Missing pages' bulk search); `runAutoUpgrade()` (no
  direct db calls, already called into now-async functions); `checkVideoChannels()`'s new-video
  insert + queue/history inserts; `retryFailedGrab()`; `pollQueue()`'s progress/status updates
  (`datetime('now')` → `nowExpr(db)`) and failed-import handling; `cleanupStalledDownloads()`'s
  stalled-threshold query (`datetime('now', ?)` → a new `nowOffsetHoursExpr(db, hours)` helper added
  next to the existing day-granularity `nowOffsetExpr()`, since this threshold is hours not days).
  `startScheduler()`'s job registrations needed no changes — they already just reference the
  now-async functions. No restructuring beyond straightforward `await`-insertion was needed anywhere;
  `chooseBestResult()` (Round 93) remains the only function in this file that needed the
  `Promise.all`-precomputed-`Map` pattern.
- **`routes/search.ts`** — `GET /:mediaItemId` (manual search, including its slskd-merge branch and
  the per-result `Promise.all`-wrapped format-scoring annotation), `POST /:mediaItemId/grab` (manual
  grab), `POST /bulk` (delegates to `scheduler.ts`'s already-async `searchAndGrabTargets()`).
- **`services/scheduledBackup.ts`** — the deferred structural question since Round 92/97: designed
  and implemented real per-dialect backup/restore instead of converting around it.
  - SQLite: unchanged, still `better-sqlite3`'s own online `Database.backup()` API via a
    `sqliteDb`-aliased import from `db/client.ts` (same pattern `routes/system.ts` established in
    Round 100).
  - Postgres: `pg_dump --format=custom` for backup, `pg_restore --clean --if-exists` for restore,
    shelled out via `child_process.execFile`. New exported `writeBackup()`/`restorePostgres()`/
    `backupFileExtension()` (`.dump` for Postgres, `.db` for SQLite — lets rotation and the manual
    restore-upload's magic-byte check tell the two apart) are shared between the scheduled-backup job
    and `routes/system.ts`'s `/backup`/`/backup/restore` routes, which were updated to branch on
    `db.dialect` instead of always assuming SQLite.
  - **Docker image change**: added `postgresql-client` (both `server/Dockerfile` and
    `Dockerfile.combined`) so `pg_dump`/`pg_restore` exist in the container. Debian bookworm's own
    `postgresql-client` package is v15, which refuses outright to `pg_dump` a newer server (a hard
    version check, not a warning) — common in practice since Postgres 16/17 are now typical. Switched
    to installing `postgresql-client-17` from the official PGDG apt repo instead, since a newer
    client can dump/restore against any older-or-equal server.
  - **Live-discovered compatibility issue**: even with a newer client, `pg_restore --single-transaction`
    against an older server failed outright — `pg_dump`/`pg_restore` 17 emit a `SET
    transaction_timeout = 0` preamble statement for a GUC that only exists on server 17+, and
    `--single-transaction` implies `--exit-on-error`, aborting the *entire* restore over that one
    cosmetic statement (confirmed live against a real `postgres:16-alpine` target). Fixed two ways:
    dropped `--single-transaction` (restoring `pg_restore`'s own default "continue past errors, report
    a count" behavior), and added error-inspection in `restorePostgres()` that treats "N errors
    ignored, none of them anything other than the known `transaction_timeout` line" as success rather
    than logging a false failure — verified the actual data was correctly restored despite the
    non-zero exit code before writing that logic, not assumed.

Live-verified against a real Postgres container (`postgres:16-alpine`, deliberately one major version
behind the new client, to actually exercise the compatibility fix above rather than test against a
same-version server that wouldn't have caught it): manual search and bulk search/grab through
`routes/search.ts` and `scheduler.ts`'s shared code path; a full backup → mutate → restore roundtrip
via `/system/backup` and `/system/backup/restore` — downloaded a real `PGDMP`-format dump, added a row,
restored, and confirmed via `psql` the added row was gone and the original data intact. Regression-
tested the identical search/bulk-search sequence plus a full SQLite backup → mutate → restore roundtrip
(including the container actually exiting and needing a manual restart in the test harness, exactly as
the SQLite restore route's own design expects `unless-stopped` to handle in production) against the
same image in SQLite mode — matched.

## Progress (Round 101)

Converted `routes/media.ts` (948 lines, ~40 routes) — the single largest remaining file. Covers: item
CRUD (`GET /`, `GET /:id`, `POST /`, `PATCH /:id`, `DELETE /:id`), tags (attach/detach, plus
`getTagsForMediaItem()` made `async`), bulk operations (`bulk/monitor`, `bulk/tag` — both converted
from better-sqlite3's synchronous `db.transaction((ids) => {...})` closure-call pattern to `await
db.transaction(async () => { for (...) await ... })`, the same async-transaction pattern established
since Round 90), CSV export/bulk-import, metadata export (.nfo/.json/.plexmatch for the general case,
Calibre .opf for book-shaped libraries), corrupt-file check, per-item metadata-provider fetch, rematch
(interactive re-search), watch-state (get/set, with a `datetime('now')` insert replaced by
`nowExpr(db)`), episodes (create/get/patch, season-level bulk monitor), and sub-items/tracks
(create/get/patch, plus the yt-dlp direct-download route for Online Videos sub-items).

**Two `INSERT OR IGNORE` call sites** (`bulk/tag` and `/:id/tags`, both inserting into
`media_item_tags`) converted to the same `db.dialect === "postgres" ? "... ON CONFLICT DO NOTHING" :
"INSERT OR IGNORE ..."` conditional established in `routes/collections.ts`. No new restructuring
pattern was needed anywhere else in the file — every remaining db call was a direct call inside an
already-`async` handler, consistent with every Tier 1/2/3 round since 93.

Live-verified against a fresh Postgres container: created a movie/series/artist item across all three
shapes, attached a tag via both the single-item and bulk-tag routes (confirmed via `psql` that all 3
`media_item_tags` rows landed correctly through the `ON CONFLICT DO NOTHING` path), added an episode
and a sub-item, set watch-state, bulk-monitored 3 items in one transaction, exercised season-level
episode monitoring and rematch, exported the CSV, and deleted an item. Regression-tested the identical
sequence against the same image in SQLite mode via the app's own API — matched.

## Progress (Round 100)

Converted `routes/system.ts`: `/network-stats`, `/status`, `/health`, and `/orphaned-scan` — all now
on the async `db`. Fixed a real camelCase-alias risk (`AS totalBytes` on `/network-stats`'s
queue-by-status query — quoted to `AS "totalBytes"`) and wrapped every raw `pg`-driver `COUNT(*)`/
`SUM(*)` result in `Number(...)` (`/network-stats`'s `count`/`totalBytes`, `/status`'s 4 counters,
`/health`'s `pendingRequests`). Restructured `/health`'s disk-warnings `.map()` (a synchronous
predicate calling a DB read per root folder) into `Promise.all(...).then(.filter(...))` — same pattern
used repeatedly since Round 93, not new restructuring. Added a new `nowOffsetHoursExpr(db, hours)`
helper next to the existing `nowOffsetExpr` (day-granularity) in `db/asyncDb.ts`, since `/health`'s
stuck-queue threshold is `-6 hours`, too fine-grained for the existing days-only helper — used it to
replace `/health`'s `datetime('now', '-6 hours')` query-level call.

**Deliberately left unconverted**: `/backup` and `/backup/restore`, which call better-sqlite3's
`Database.backup()`/`.close()` and do a raw SQLite file swap directly — no Postgres equivalent exists
yet, the same structural exception already tracked for `services/scheduledBackup.ts`. Rather than block
the rest of the file on that design question, `system.ts` now imports the async `db` from
`db/index.js` for every other route and separately imports the raw sync `db` from `db/client.js`
(aliased `sqliteDb`) just for these two routes — the first time this session a single file has needed
both imports side by side. `services/scheduledBackup.ts` remains queued for its own dedicated round to
design real per-dialect backup/restore logic; when it lands, `system.ts`'s two routes should switch to
call whatever that round produces instead of touching `sqliteDb` directly.

Live-verified against a fresh Postgres container: seeded a stuck queue item (10 hours old, past the
6-hour threshold) and a low-disk-space sample via `psql`, then confirmed `/system/network-stats`
correctly summed queue bytes as a real number, `/system/health` correctly flagged the stuck item and
the disk warning, and cross-checked the queue count against `/metrics`'s already-converted Prometheus
counter. Regression-tested the identical sequence (plus `/system/backup`, downloading a real SQLite
snapshot) against the same image in SQLite mode via the app's own API — matched.

## Progress (Round 99)

Converted all of Tier 2: `routes/importLists.ts` (import-list CRUD + the sync-trigger route),
`routes/metrics.ts` (Prometheus text-exposition metrics — wrapped the raw `pg`-driver `COUNT(*)`
string results in `Number(...)` both where they're read directly and where they feed a metric
sample's `value`), `services/starrImport.ts` (Radarr/Sonarr/Lidarr/Readarr library migration — turned
out `importCollectionData()`'s Lidarr/Readarr parent/child matching, including its `resolveParent()`
closure made `async`, was the file's only remaining query cluster; Radarr/Sonarr's own import paths
route entirely through the already-converted `mediaServerImport.ts`), and `services/importer.ts`
(post-download file placement — `placeFile()`, `placeAlbumFiles()`, `placeSeasonPackFiles()`, and
`importQueueItem()`, covering single/episodic/collection-shape moves, multi-file album imports, and
season-pack imports; the queue-completion `datetime('now')` UPDATE replaced with `nowExpr(db)`).

**Note**: no restructuring was needed anywhere in this round — every db call in all 4 files was a
direct call inside an already-`async` function or a plain sequential step in a `for` loop, consistent
with the Round 93 lesson that most call sites don't need the `.filter()`/`.sort()`-precompute pattern
`chooseBestResult()` needed.

Live-verified against a fresh Postgres container: created/listed/patched/deleted an import list via
`routes/importLists.ts`'s CRUD routes, scraped `/api/metrics` and confirmed the Prometheus counters
render as real numbers (not `"3"` string-literal output from an unconverted `COUNT(*)`), and exercised
the manual-import endpoint (`services/importer.ts`'s `placeFile()`) end-to-end — seeded a movie
`media_items` row and a fake download file, POSTed to `/api/import/manual`, and confirmed via `psql`
that the file was moved to its templated destination and `has_file`/`path`/`quality` and a `history`
row were all correctly set. Regression-tested the identical sequence against the same image in SQLite
mode via the app's own API — results matched exactly.

## Progress (Round 98)

Converted the rest of Tier 1: `services/mediaServerImport.ts` (Plex/Jellyfin/Emby movie + series
library import — `defaultQualityProfileId()`, `importMovieItems()`, `importSeriesData()`, including
its inner `resolveShow()` closure made `async`), `services/importLists.ts` (the service — Trakt/IMDb/
Last.fm list syncing; `insertSeriesEpisodes()`/`insertArtistAlbums()`'s internal sync inserts converted
to `for...of` + `await` loops), and `services/libraryScan.ts` (the filesystem scan-and-import — movie/
episodic/collection matching and creation, plus the has_file rollup queries and concurrency lock added
in earlier rounds, all now on the async path; `refreshLibraryMetadata()` and
`backfillEpisodicAndCollectionHasFile()` also converted, the latter from a sync `void`-returning
function to `async`). Fixed one call site in `services/starrImport.ts` (awaiting
`defaultQualityProfileId()`) without converting the rest of that file — it stays Tier 2. Updated
`server/src/index.ts` to `await backfillEpisodicAndCollectionHasFile()` since it's now async.

**TypeScript fix**: `insertSeriesEpisodes()`/`insertArtistAlbums()` in `importLists.ts` took
`mediaItemId: number | bigint`, but the new `AsyncDb` interface's `RunResult.lastInsertRowid` is
`number | bigint | null` (unlike better-sqlite3's non-nullable original) — widened both parameter types
to accept `null`.

Live-verified against a fresh Postgres container: seeded root folders + a quality profile via `psql`,
then exercised all three scan-import shapes through the app's real HTTP API — a TV episode file (series
shape), a music album track (collection/artist shape, multiFilePerChild), and a movie file (single
shape) — confirming via direct `psql` queries that each correctly created its `media_items` row with
`has_file=1` and the right child rows (`episodes`/`sub_items`) and `file_path`. Regression-tested the
identical sequence against the same image in SQLite mode via the app's own API (no shadow-DB split to
work around there) — all three shapes matched.

## Progress (Round 97)

Converted the rest of Tier 1's small leaves: `services/traktSync.ts`, `routes/metadata.ts`,
`services/recommendations.ts` (finishing what Round 93 started — its `isExcluded`/`filterAsync`
restructuring was already done, only `recentLibraryItems()`/`existingExternalIds()`'s own queries
were still on the old path), and `routes/watchlistImport.ts`. `routes/metadata.ts`'s `/import` route
carried 3 better-sqlite3-style synchronous `db.transaction(fn)(rows)` calls (episode/album/child
batch inserts) — converted to the same `await db.transaction(async () => { for (...) { await
db.prepare(...).run(...) } })` pattern already established for this shape of code.

Verified live against a real Postgres container: manual media import (including the transaction-based
child-episode insert path), Trakt sync's no-op-when-unconfigured path, recommendations' DB-backed
`recentLibraryItems`/`existingExternalIds` queries running cleanly ahead of the (key-gated) external
API calls, and watchlist import's full duplicate-skip → not-found → import-review-queue flow — the
review-queue insert (`queueForReview`, converted in Round 91) is now correctly `await`-ed inline
in the per-row loop instead of fire-and-forget, closing a small ordering gap. Same sequence
regression-checked against SQLite on the same build with identical results.

## Progress (Round 96)

Converted the first Tier 1 batch from the plan above: `services/storageForecast.ts`,
`services/duplicates.ts`, `services/upgradeCandidates.ts`, and `services/cleanupSuggestions.ts` — 4
true leaves with no edges into the remaining unconverted set. Fixed every downstream call site across
3 still-unconverted files that call into them (`routes/system.ts`'s `/status` and `/health` routes,
`routes/metrics.ts`, `services/scheduler.ts`'s `runAutoUpgrade`), including one genuine `.map()`
callback in `system.ts`'s `/status` route needing the same `Promise.all` restructuring pattern from
Round 93/95 — without converting the rest of those 3 files, the same surgical pattern used
repeatedly this session. Quoted several more unquoted camelCase SQL aliases found along the way
(`mediaItemId`, `subTitle`, `addedAt`, `parentTitle`, `childTitle` across `upgradeCandidates.ts` and
`cleanupSuggestions.ts`).

**Found and fixed a serious, previously-latent Postgres schema bug, unrelated to the async
conversion itself**: `schema.postgres.sql`'s translation of `schema.sql` mapped SQLite's `INTEGER`
to Postgres's `INTEGER` literally for 4 byte-count columns (`queue.size`, `recycle_bin.size_bytes`,
`disk_usage_samples.free_bytes`/`total_bytes`) — but SQLite's own `INTEGER` affinity is already
64-bit with no separate `BIGINT` type at all, while Postgres's plain `INTEGER` is 32-bit (max
~2.1GB). Live testing caught this by accident: `recordDiskUsageSamples()` silently failed on every
call against a real ~1TB test filesystem (`free_bytes`/`total_bytes` values around 10^12 bytes, an
order of magnitude past `INTEGER`'s range), caught by the function's own try/catch and never
surfacing an error anywhere — the same silent-overflow risk applies to `queue.size` and
`recycle_bin.size_bytes` for any real download/file over ~2GB (a routine 4K remux), meaning this bug
has likely been silently corrupting or dropping size data on every Postgres deployment doing real
downloads, entirely independent of and predating this migration's own async conversion work.
Fixed both the fresh-install schema (`schema.postgres.sql`, `INTEGER` → `BIGINT` on all 4 columns)
and existing installs via a new `TYPE_MIGRATIONS` list in `postgresSchema.ts` (`ALTER TABLE ... ALTER
COLUMN ... TYPE BIGINT`, run unconditionally on every startup — safe and idempotent, Postgres doesn't
error re-widening a column to the type it's already at).

Verified live against a real Postgres container: confirmed a fresh install creates all 4 columns as
`BIGINT` directly; separately downgraded them to `INTEGER` on a running instance to simulate an
existing pre-fix install, confirmed `recordDiskUsageSamples()` failed exactly as predicted (`integer
out of range`) against real ~1TB disk stats, then confirmed a single app restart's startup migration
correctly widened all 4 columns back to `BIGINT` with no data loss and is safely idempotent across
repeated restarts; then exercised every route end to end with the fix in place — disk usage sampling
against a real filesystem (storing a real ~1TB byte value that would have overflowed before the fix),
repeated-import detection, upgrade-candidate detection (with correct camelCase aliasing), and both
cleanup-suggestion routes (unmonitored items, and byte-identical duplicate file detection against two
real files written to disk) — same sequence regression-checked against SQLite on the same build,
where the bug never existed in the first place (SQLite has no 32-bit `INTEGER` distinction), so
everything ran end to end against a single consistent database from the start.

## Progress (Round 95)

Converted `services/customFormatScoring.ts` — the release-scoring function deferred all the way
back in Round 85 as "called synchronously from deep within the search/grab pipeline." As with
Round 93's services, re-checking its actual 2 call sites (not assumed from its role in the pipeline)
found they were tractable: `routes/search.ts`'s manual-search annotation and
`services/scheduler.ts`'s `chooseBestResult()` (itself made async in Round 93) both called it inside
a plain synchronous `.map()` callback — fixed with the same `Promise.all(items.map(async ...))`
restructuring already used twice in Round 93 (`metadata.ts`, `recommendations.ts`), not a deeper
rewrite.

Verified live end to end against SQLite (a real search, not a mocked one): created a movie, a custom
format matching `REMUX` in the title, a quality profile with a score for that format, and a local
mock RSS indexer; ran a real manual search and confirmed the returned result correctly carried
`formatScore: 75` and `formatMatches: ["Remux Format"]` — the exact score configured, proving both
the async conversion and the `Promise.all` restructuring work correctly together. Postgres
verification for this round was necessarily lighter than usual: `routes/search.ts` and
`services/scheduler.ts` (both still unconverted) read the media item/indexers they need for a search
through the old synchronous shadow-SQLite path (the Round 80 caveat), so a live end-to-end search
can't be driven from outside the app in Postgres mode the way it can under SQLite's single
consistent database — `scoreRelease()`'s own two queries are unchanged from already Postgres-verified
patterns (same tables/columns as `customFormats.ts`'s already-converted scores routes from Round 85),
and the typecheck + camelCase-alias grep both passed clean.

## Progress (Round 93)

Rather than converting one of the large remaining files, converted the 5 small, genuinely
self-contained services identified across Rounds 89–92 as blocked only by their *callers* not
awaiting them: `services/blocklist.ts`, `services/rootFolderSelect.ts`, `services/releaseGroupStats.ts`,
`services/duplicateCheck.ts`, and `services/importExclusions.ts`. Then updated every one of their ~25
call sites across 9 caller files (`routes/search.ts`, `routes/media.ts`, `routes/metadata.ts`,
`routes/watchlistImport.ts`, `services/importLists.ts`, `services/traktSync.ts`,
`services/recommendations.ts`, `services/importer.ts`, `services/scheduler.ts`, `routes/system.ts`) —
without converting any of those 9 files' own other database calls, the same surgical pattern Round 90
used for `recycleFile()`'s 3 call sites in `media.ts`.

Re-examining every call site (rather than assuming from the earlier survey) found only 2 that
genuinely needed restructuring, not the "most of them" Round 92 assumed:

1. `scheduler.ts`'s `chooseBestResult()` used `getGroupReputation()` directly inside a `.sort()`
   comparator (comparators can't `await`). Fixed by precomputing every distinct release group's
   reputation into a `Map` before the sort, then having the comparator do a synchronous `Map.get()`
   lookup — `chooseBestResult()` itself became `async`, and all 5 of its call sites (all already
   inside `async` functions) just needed `await` added.
2. `recommendations.ts` used `isExcluded()` as an `Array.filter()` predicate across 3 lists
   (`.filter(notExcluded)`) — `filter()` can't await either. Fixed with a small local `filterAsync()`
   helper (`Promise.all` the predicate over every item, then filter against the resulting boolean
   array) rather than restructuring the whole function.

Every other call site — `isBlocklisted`/`getBlocklistedTitles` in `search.ts` and `scheduler.ts`,
`findPossibleDuplicates` in `media.ts`/`metadata.ts`/`watchlistImport.ts`/`importLists.ts`,
`isExcluded` in `importLists.ts`/`traktSync.ts` (all inside plain `for` loops), `autoSelectRootFolderId`
in `media.ts` (pulled out of an inline object-literal field into a `const` above it),
`isRootFolderOverQuota` in `scheduler.ts`, `recordGroupSuccess`/`recordGroupFailure` in
`importer.ts`/`scheduler.ts`, and `listReleaseGroupStats` in `system.ts` — needed nothing more than
adding `await`.

Verified live against a real Postgres container (no admin-bootstrap env vars): confirmed
`isBlocklisted` correctly 400s a manual grab of a blocklisted release title and passes through a
non-blocklisted one via `routes/search.ts` (itself still unconverted, seeded through its own
shadow-SQLite create path — the same Round 80 "orphaned shadow database" caveat as ever, since
`media.ts`/`search.ts` haven't been converted yet), `findPossibleDuplicates` correctly 409s a real
duplicate title add and skips a duplicate watchlist-import row, `autoSelectRootFolderId` correctly
returns `null` with no root folders configured, `listReleaseGroupStats` returns real per-group success
rates, and the `recommendations.ts` `filterAsync` restructuring runs without error against empty
result sets (no TMDB/Last.fm keys configured in the test environment — the exclusion-filtering logic
itself already verified via `isExcluded`'s other call sites).

**Also surfaced, and ruled out as unrelated to this round**: creating a media item with an explicit or
auto-selected root folder 500s with a SQLite foreign-key error under Postgres — confirmed this
reproduces identically with a hardcoded `rootFolderId` (bypassing `autoSelectRootFolderId` entirely),
so it's the same pre-existing "`media.ts` still writes to the orphaned shadow SQLite while
`routes/rootFolders.ts` (converted in Round 82) writes real root folders to Postgres" split documented
since Round 80 — not a new bug, and not fixable without converting `media.ts` itself. Confirmed the
same `autoSelectRootFolderId` code path (multiple root folders, real selection) works correctly on
SQLite, where there's no database split to cause this.

Same full sequence regression-checked against SQLite on the same build, where — with no shadow-database
split to work around — the entire flow (blocklist create → grab-blocklisted 400 → duplicate-detect 409
→ root-folder auto-select across two real folders) ran end to end against one consistent database.

## Progress (Round 92)

Converted `services/mediaAnalysis.ts` + `routes/mediaAnalysis.ts` (the Library Analysis page: instant
compatibility summary from already-stored `media_info`, and the fire-and-forget full-library re-probe
job) — fully self-contained, no entanglement with the pipeline described above (only depends on
`ffprobe.ts` and `logger.ts`).

**Found and fixed a real, pre-existing bug unrelated to Postgres/SQLite portability**: the episodes
query inside `getLibraryAnalysis()` selected `e.id, e.title AS ep_title, e.file_path, e.media_info,
m.title AS parent_title, m.type` — no `e.media_item_id` — while the row-mapping function right below
it read `r.media_item_id` anyway. Every episode analysis item's `mediaItemId` field was silently
`undefined` in the API response, on both backends, since this table's creation; caught by reading the
query and its consumer side by side while converting, not by any Postgres-specific behavior. Fixed by
adding `e.media_item_id` to the `SELECT`.

Verified live against a real Postgres container (no admin-bootstrap env vars): seeded a movie and a
series+episode with real `media_info` JSON, confirmed the instant analysis route's summary counts and
per-item compatibility notes, and confirmed the episode item's `mediaItemId` now correctly resolves
(the bug fix) instead of being `undefined`; separately generated a real playable file with `ffmpeg`
inside the test container, ran the full-library re-probe job against it with real `ffprobe` (not
mocked), and confirmed the resulting `media_info` was correctly written back via the new async
`UPDATE` — same sequence regression-checked against SQLite on the same build (empty-library state,
since there was no non-destructive way to reuse the same seeded data across both backend runs).

## Progress (Round 91)

Converted `routes/settings.ts` (instance settings CRUD, API key regeneration, the TOTP 2FA setup/
verify/disable/check-login flow, and the config-template export/import round-trip) plus
`services/importReview.ts` + `routes/importReview.ts` (the review queue for titles a metadata search
or import-list sync couldn't confidently match). `settings.ts` had no entanglement with the
still-unconverted media-add/search pipeline, unlike most of what's left.

**Found and fixed a real, pre-existing bug — not specific to Postgres, already present on SQLite
too**: `settingsStore.ts`'s Round 80 in-memory cache design (`getSetting`/`setSetting`, documented as
the deliberate exception to "convert every table's access to the async interface" since `getSetting`
is called synchronously from `requireAuth` on every single request) is only correct if every write to
the `settings` table goes through `setSetting()`. `routes/settings.ts` never did — `PUT /:key`, `POST
/api-key/regenerate`, the TOTP secret cleanup, and the naming-template import loop all wrote via raw
`db.prepare(...).run(...)` instead, silently bypassing the cache. In practice this meant a freshly
regenerated API key didn't actually work until the next server restart (`requireAuth` kept checking
the stale cached key), and disabling TOTP didn't take effect until restart either — both live,
reproducible bugs caught by this round's own Postgres verification, that were equally broken under
SQLite the whole time (the bug is in `settings.ts`'s bypass, not in anything backend-specific). Fixed
by routing every one of those writes through `setSetting()` (and a new `deleteSetting()` added to
`settingsStore.ts` for the TOTP secret cleanup, following the same cache+fire-and-forget-persist
pattern), and by adding `getAllSettings()` so `GET /` and the template export's naming-template lookup
read from the authoritative cache instead of a raw `SELECT` that could momentarily lag behind an
in-flight write.

**Also found and fixed a genuine SQL portability bug**: `queueForReview()`'s dedup check used
`WHERE ... (import_list_id IS ?) AND ... (year IS ?)` — SQLite's `IS` operator does null-safe equality
against a bound parameter (SQLite-specific), but Postgres's `IS` only accepts the literal keywords
`NULL`/`TRUE`/`FALSE`/`UNKNOWN`, not a parameter; `col IS $1` is a straight syntax error there. Fixed
with the standard-SQL `IS NOT DISTINCT FROM`, which both SQLite and Postgres support and has the exact
same null-safe-equality semantics as SQLite's `IS ? ` — confirmed directly against Postgres via `psql`
before trusting it in the app.

Verified live against a real Postgres container (no admin-bootstrap env vars): confirmed the API-key
bug existed pre-fix (a regenerated key failed on its very next request) and is gone post-fix (works
immediately), same for TOTP disable (`check-login` reflected the disabled state on the very next
request instead of needing a restart) — using real generated TOTP codes (HMAC-SHA1 computed by hand
against the returned secret, not a mocked verifier) for setup/verify/check-login/disable, exercised a
full config-template export → import → export round-trip confirming a naming template written inside
the import's transaction was immediately visible both via `GET /settings` and the next export, and
exercised the import-review list/counts/resolve/dismiss routes — same sequence regression-checked
against SQLite on the same build, where the pre-fix bugs reproduced identically (confirming they were
never Postgres-specific) and the fix resolved them there too.

## Progress (Round 90)

Converted the recycle-bin/corrupt-media cluster deliberately deferred back in Round 86 and Round 88:
`services/recycleBin.ts` + `routes/recycleBin.ts`, `services/corruptMediaCheck.ts` +
`routes/corruptMediaReview.ts`, and `services/archival.ts` (auto-archival's retention-override lookup,
history logging, and the actual move/recycle/delete). This was possible now because the blocking
caller was only ever `recycleFile()` — every one of its unconverted call sites (`archival.ts` itself,
and 3 in the still-unconverted `routes/media.ts`) already sits inside an `async` handler, so making
`recycleFile()` genuinely async just meant adding `await` in front of 3 existing call sites in
`media.ts` — no need to convert `media.ts`'s own (much larger) set of database calls to unblock this
cluster. `services/archival.ts`'s `scheduler.ts` and `routes/system.ts` call sites, and
`corruptMediaCheck.ts`'s `scheduler.ts` call site, were already correctly `await`-ing these
(previously-synchronous-in-practice) functions as if they returned promises, so no changes were needed
there either.

`recycleBin.ts`'s `purgeExpiredRecycleBinEntries()` was one of the 5 files flagged in the original
Round 79 SQL-portability audit as using SQLite's `datetime('now', ?)` modifier syntax — converted to
the `nowOffsetExpr()` helper added in Round 86, the second of the 5 flagged files to actually use it
(after `collections.ts`).

`startRestoreFromRecycleBin()` had a doc comment promising it "throws synchronously... before any
async work starts" for its cheap validation checks (missing entry, already restoring) — with the DB
read now async, that's no longer literally synchronous, but the *behavioral* guarantee (validation
rejects before the actual file-move work begins) still holds; updated the comment's wording rather
than its meaning.

Verified live against a real Postgres container (no admin-bootstrap env vars): created real on-disk
test files, created media items pointing at them (via the still-shadow-SQLite-backed
`metadata.ts`/`media.ts` create path — a deliberate reminder that an unconverted file's own reads/
writes still land in the orphaned local SQLite database documented since Round 80, even though the
callee functions it invokes, like `recycleFile()`, now correctly reach the real configured backend),
exercised the full delete-with-recycle → list → restore → re-delete → purge lifecycle through
`media.ts`'s converted call sites and confirmed the file actually moved and moved back on disk each
time, and exercised the corrupt-media-review confirm/dismiss routes against directly-seeded Postgres
rows (necessary since review entries have a real FK to `media_items`, unlike the shadow-SQLite-backed
create path) — same sequence regression-checked against SQLite on the same build, where the single
consistent database made the whole flow simpler to verify end to end (and additionally surfaced that
`recycle_bin.media_item_id`'s `ON DELETE SET NULL` correctly nulls out once the parent row is actually
gone — the Postgres run didn't show this because the shadow-SQLite delete never touched the real
Postgres row, not because of any behavior difference between backends).

## Progress (Round 89)

Converted 6 files across three self-contained pairs: `services/push.ts` + `routes/push.ts` (VAPID
keys, web push subscribe/unsubscribe, send), `services/mediaServerWebhook.ts` +
`routes/mediaServerWebhook.ts` (the Plex/Jellyfin/Emby watch-event webhook receiver and its admin
token), and `services/libraryValidation.ts` (cross-references AoNarr's library against a configured
media server's own reported files, called from the still-unconverted `system.ts` — safe since that
call site already used `await`). Also converted `routes/requests.ts` (household media requests: list,
submit with duplicate-detection and per-user pending-request caps, admin approve/reject, per-user
storage-attribution stats, delete).

Before converting `push.ts`, confirmed both its call sites (`services/notifications.ts` and
`routes/requests.ts`) already treat `sendPush()` as a Promise (`.catch()` fire-and-forget or collected
into a `jobs` array) even though it was synchronous-under-the-hood until now — so making its DB calls
genuinely async carried none of the "unconverted caller doesn't await" risk that's blocked converting
several other small services in recent rounds.

`requests.ts`'s `approveRequestRow()` and the reject route both used `datetime('now')` inline in an
UPDATE's SET clause — replaced with the `nowExpr(db)` helper from Round 79/80 rather than
`nowOffsetExpr` (Round 86), since these need "right now," not a relative offset. Also applied
`Number(...)` to two more `COUNT(*)` results (`requests.ts`'s per-status counts and pending-count
check) proactively, per the established Round 84/86 bug class, before verification could catch them
live.

Verified live against a real Postgres container (no admin-bootstrap env vars): fetched the VAPID
public key and round-tripped a push subscription, confirmed the webhook token endpoint and a
Jellyfin-style watch payload correctly resolved to a seeded media item and immediately showed up in
the already-converted dashboard's recently-watched (a genuine cross-file check, not just an isolated
route test), exercised the full request lifecycle (submit via direct seed, per-user stats with correct
numeric counts, approve with its named-parameter media-item insert, reject with its `nowExpr`
resolution timestamp, delete), and confirmed `library-validation`'s "no media server configured" error
path (its query path itself validated by code review + typecheck + the camelCase-alias grep, since
exercising the success path needs a live media server, consistent with how other conditionally-gated
services were verified in this migration) — same sequence regression-checked against SQLite on the
same build with identical results.

## Progress (Round 88)

Converted 5 more self-contained route files: `routes/activity.ts` (queue, history, and the merged
cross-source timeline), `routes/calendarFeed.ts` (both the admin token router and the public `.ics`
feed router), `routes/dashboard.ts` (recently-added, per-type library counts, on-disk library sizes
with its 10-minute cache, and the media-server-aware recently-watched cross-reference), `routes/subtitles.ts`,
and `routes/wanted.ts` (missing items and the calendar view).

Quoted a large number of unquoted camelCase SQL aliases across this batch — `wanted.ts` and
`calendarFeed.ts` especially, since both build several near-identical multi-column SELECTs by hand
(`mediaItemId`, `mediaTitle`, `episodeId`, `subItemId`, `sortKey`, `hasFile`, `epTitle`, `subTitle`) —
same bug class as every prior round. Also proactively wrapped `dashboard.ts`'s `COUNT(*) AS count`
result in `Number(...)` before this round's own Postgres verification could catch it as a live bug,
since the Round 86 (`collections.ts`) and Round 84 (`libraryGroups.ts`) fixes already established that
Postgres returns `COUNT`/`SUM` aggregates as strings, not numbers.

**Deliberately NOT converted this round**: surveyed `routes/metrics.ts`, `routes/metadata.ts`, and
`routes/watchlistImport.ts` and left all three alone — each calls at least one of
`services/duplicateCheck.ts`, `services/importExclusions.ts`, or `services/upgradeCandidates.ts`
synchronously, all of which are themselves called from the still-unconverted media-add/search
pipeline (`media.ts`, `search.ts`, `importer.ts`, `recommendations.ts`, `traktSync.ts`,
`importReview.ts`). These three routes, together with `metadata.ts`'s own "add media" flow, form a
tightly coupled cluster (creating a media item, checking for duplicates/exclusions, and populating its
children) that's better tackled as one dedicated round alongside `media.ts` than converted piecemeal —
the same reasoning as the recycle-bin cluster deferred in Round 86.

Verified live against a real Postgres container (no admin-bootstrap env vars): seeded two movies and
a queue item directly via `psql`, confirmed the dashboard's library-counts came back as real numbers
(not Postgres's string-typed `COUNT` result), fetched recently-added/recently-watched/library-sizes,
listed wanted/missing and the wanted calendar with all their aliased columns intact, exercised the
full activity queue lifecycle (list, the "no download client" 400 path, delete) plus history and
timeline, created and listed a subtitle provider, and fetched/regenerated the calendar token before
confirming the public `.ics` feed both serves a real event and 401s on a wrong token — same sequence
regression-checked against SQLite on the same build with identical (empty-state) results.

## Progress (Round 87)

Converted 5 small, self-contained route files: `routes/blocklist.ts`, `routes/importExclusions.ts`
(the CRUD route only — its backing `services/importExclusions.ts` stays on the old sync path, see
below), `routes/artwork.ts`, `routes/librarySearch.ts` (the app's global cross-library search), and
`routes/shareLinks.ts` (both the admin-only and fully-public routers it exports).

**New portability issue found**: `librarySearch.ts`'s query used plain `LIKE` for its search-term
matching. SQLite's `LIKE` is case-insensitive for ASCII by default; Postgres's is case-sensitive — so
the exact same query would silently return fewer results (or none) on Postgres for any query that
didn't match the stored title's case. Fixed with a dialect-conditional operator (`ILIKE` on Postgres,
`LIKE` on SQLite) rather than a wrapper-level translation, since not every `LIKE` in the codebase
needs this (a handful of other converted/unconverted files use `LIKE` for exact substring flags where
case sensitivity doesn't matter, e.g. matching a JSON key). Also quoted several more unquoted
camelCase SQL aliases across these files (`AS mediaItemId`, `AS releaseTitle`, `AS createdAt`, etc.),
the same Round 80 bug class — `librarySearch.ts`'s query was a `UNION ALL`, where only the first
branch's aliases needed quoting since Postgres (like SQLite) takes the union's output column names
from the first `SELECT`.

**Deliberately NOT converted this round**: the surveyed remainder of the ~42 files fell into two
buckets, both left alone. First, several small services (`services/blocklist.ts`,
`services/importExclusions.ts`, `services/releaseGroupStats.ts`, `services/rootFolderSelect.ts`,
`services/duplicateCheck.ts`) export synchronous helper functions called directly, with no `await`,
from deep inside the still-unconverted search/import pipeline (`search.ts`, `importer.ts`,
`media.ts`, `recommendations.ts`, `traktSync.ts`, etc.) — converting their DB calls to async now would
leave those pipeline call sites firing unawaited promises against Postgres, the same risk class
documented for `customFormatScoring.ts` in Round 85 and the recycle-bin cluster in Round 86. Second,
`services/storageForecast.ts` and `services/duplicates.ts` are only called from `routes/system.ts` and
`services/scheduler.ts`, both large, central, not-yet-converted files better tackled as their own
dedicated round rather than piecemeal.

Verified live against a real Postgres container (no admin-bootstrap env vars): seeded two movies
directly via `psql`, confirmed `library-search` matches case-insensitively in both directions
(lowercase query against a mixed-case title, and vice versa) via the new `ILIKE` path, selected
artwork, created/listed/deleted a blocklist entry (confirming every quoted alias round-trips
correctly, including the `JOIN`-derived `mediaTitle`), created/listed/deleted an import exclusion,
and created/listed/deleted both an admin share link and its public token-based fetch — same sequence
regression-checked against SQLite on the same build (lighter on data-dependent assertions there, since
the image has no `sqlite3` CLI for direct seeding, but every route's success and 404 paths confirmed
identical).

## Progress (Round 86)

Converted `routes/collections.ts` (all 9 routes, including the smart-filter query builder and the
item-position reorder transaction) and `routes/tracks.ts` (both routes, including its track-list
upsert transaction).

**New pattern handled**: `collections.ts` used SQLite's `datetime('now', ?)` modifier syntax at the
query level for its `addedAfterDays` smart-filter condition — flagged back in Round 79's SQL-
portability audit as needing a per-call-site rewrite, not a generic wrapper translation. Added
`nowOffsetExpr(db, days)` to `asyncDb.ts` (alongside the existing `nowExpr`), which returns
`datetime('now', 'N days')` on SQLite or `to_char((now() AT TIME ZONE 'UTC') + interval 'N days', ...)`
on Postgres — the same 4 other files flagged in that audit (`system.ts`, `recycleBin.ts`,
`scheduler.ts`, `storageForecast.ts`) can reuse this helper when they're converted.

Also found and fixed two more instances of known bug classes from earlier rounds while converting
this batch:
- `collections.ts`'s list route aliased `COUNT(ci.media_item_id) AS itemCount` unquoted — Postgres
  folds it to lowercase, so `r.itemCount` would read as `undefined`. Fixed by quoting the alias
  (`AS "itemCount"`), the same class of bug caught in Round 80.
- The same route also read that `COUNT(...)` result as `r.itemCount` directly — Postgres's driver
  returns `bigint` aggregates as strings, so this rendered as `"itemCount":"0"` (a string) instead of
  a number. Fixed by wrapping in `Number(...)`, the same fix `libraryGroups.ts`'s `groupCounts()`
  needed in Round 84 for its own `COUNT`/`SUM` results.
- `collections.ts`'s "add item" route used SQLite's `INSERT OR IGNORE` syntax, which Postgres doesn't
  support at all (not just a folding/type issue — a hard syntax error). Switched to a
  dialect-conditional statement: `INSERT ... ON CONFLICT DO NOTHING` on Postgres, the original `INSERT
  OR IGNORE` on SQLite. `media.ts` (not yet converted) has two more call sites using this same
  SQLite-only syntax to handle when it's converted.

**Deliberately NOT converted this round**: `routes/recycleBin.ts`, `services/recycleBin.ts`,
`routes/corruptMediaReview.ts`, and `services/corruptMediaCheck.ts`. All four are entangled with each
other (`corruptMediaCheck.ts` calls `recycleFile()` from the recycle-bin service; the recycle-bin
route calls its own service directly) and, more importantly, `recycleFile()` is also called
synchronously — with no `await`, since it isn't async today — from three call sites inside
`routes/media.ts` (947 lines, not yet converted) and one inside `services/archival.ts`. Converting
`recycleFile()`'s DB write to async now would leave those still-synchronous callers firing an
unawaited promise into Postgres, the exact "converted callee reached from an unconverted caller"
failure class first hit with `audit.ts` in Round 80 — except here the caller can't simply be converted
alongside it the way `audit.ts` could, since the callers are still-unconverted, much larger files.
This cluster is deferred to a future round bundled with `media.ts` and `archival.ts`.

Verified live against a real Postgres container (no admin-bootstrap env vars): created a collection,
added movie rows directly via `psql` to exercise real item membership, added/deduped items via the
`ON CONFLICT DO NOTHING` path, reordered them and confirmed the new order round-tripped, exported both
`?format=json` and `?format=m3u` (confirming the m3u path correctly skips a fileless item and reports
the skip count), deleted an item and a collection, created a smart collection with an
`addedAfterDays` filter and confirmed `nowOffsetExpr`'s Postgres interval arithmetic matched the
expected items, and exercised both track routes — same regression sequence repeated against SQLite on
the same build (media-item seeding done differently there, no `psql` equivalent inside the image, but
every route and the `nowOffsetExpr` SQLite branch confirmed working identically).

## Progress (Round 85)

Converted `routes/customFormats.ts` (all 8 routes: list, create, patch, TRaSH-Guides JSON import,
trash-sync trigger, delete, and the two quality-profile-format-score routes) and its backing
`services/trashSync.ts`. The scores PUT route relies on `ON CONFLICT(quality_profile_id,
custom_format_id) DO UPDATE SET score = excluded.score` — ported unchanged, since Postgres supports
the same `ON CONFLICT ... DO UPDATE` upsert syntax as SQLite.

**Deliberately NOT converted**: `services/customFormatScoring.ts`, which scores releases during the
actual search/grab pipeline (called from `search.ts`, `importer.ts`, `upgradeCandidates.ts`,
`scheduler.ts`, and others). Converting it would cascade into that much larger and riskier surface
rather than staying scoped to "custom formats CRUD." Confirmed neither file converted this round
calls into it, so leaving it on the old synchronous `db/client.ts` path creates no
converted-calls-unconverted breakage this round.

Verified live against a real Postgres container (no admin-bootstrap env vars): created, listed,
renamed, and deleted a custom format; set and re-read a quality-profile format score via the upsert
route; ran a real `trash-sync` against the live TRaSH-Guides GitHub repo (234 Radarr formats synced
in, 8 unsupported and reported as such, matching the SQLite-path count exactly) — same regression
sequence repeated against SQLite on the same build, identical results end to end.

## Progress (Round 84)

Converted a batch of 6 smaller, mostly-independent route files plus one service:
`routes/people.ts`, `routes/calendarEvents.ts`, `routes/libraryViews.ts`,
`routes/remoteInstances.ts`, `routes/friendLibraries.ts` (+ its `services/friendLibraries.ts`
backing service), and `routes/libraryGroups.ts` — the last of these carrying the app's `WITH
RECURSIVE` query (the nested-group item-count rollup), the second-most structurally complex query
in the codebase after the transaction-based quality reorder from Round 82. No new translation gaps
found this round — the recursive CTE, the breadcrumb-walking loop (a `while` loop re-querying one
row at a time), and every INSERT/UPDATE/DELETE all ported with just the standard `await` treatment.

Verified live against a real Postgres container: created/listed custom calendar events, a saved
library view, a remote instance, a friend library, and a two-level library group hierarchy (System →
Maker), confirmed the `WITH RECURSIVE` count rollup correctly reports 0 items for a freshly-created
empty group, confirmed the breadcrumb and `isDeepestLevel`/`nextKind` fields on a single-group fetch,
patched and deleted a group, and confirmed `people.ts` fails gracefully (not a crash) when no TMDB
key is configured — same regression sequence repeated against SQLite on the same build.

## Progress (Round 83)

Converted `routes/indexers.ts`, `routes/downloadClients.ts`, and `services/prowlarrSync.ts` (called
by the indexers route, converted alongside it for the same reason `audit.ts` had to be converted
alongside `authRoutes.ts` in Round 80 — an unconverted callee undoes a converted caller).

**New pattern handled**: these two route files use better-sqlite3's named-parameter binding
(`.run({ name: "x", ... })` against SQL with `@name` tokens) for their longer INSERTs — Postgres's
driver has no named-parameter concept at all, only positional `$1, $2, ...`. Extended `asyncDb.ts`
with `translateNamedParams()`, which rewrites `@word` tokens to sequential `$N`s and builds the
matching positional values array, so the 6 files using this style (2 converted so far) don't need
their queries rewritten — just the same `await`/`db/index.js` treatment as everywhere else. Verified
standalone with a small test script before trusting it in the app (repeated-token reuse, and an `@`
inside a string literal being left alone, both confirmed).

**Found and fixed a real, pre-existing bug** (not caused by this migration, just surfaced by testing
more thoroughly than the existing test suite had): `indexers.ts`'s PATCH route bound whatever value
a boolean field (`enabled`, `useFlareSolverr`) arrived as directly, with no `true/false → 1/0`
coercion — unlike `downloadClients.ts`'s own PATCH route, which already had this coercion with a
comment explaining why. Both better-sqlite3 and Postgres reject binding a raw JS boolean to an
INTEGER column, so `PATCH /indexers/:id` with `{"enabled": true}` would have thrown on **either**
backend, not just Postgres — it just never came up before because nothing had tested that exact
input. Fixed in both `indexers.ts` (POST and PATCH) and `downloadClients.ts` (POST, which used
`b.enabled ?? 1` — silently wrong specifically for `enabled: false`, since `??` doesn't treat a real
`false` as nullish).

Verified live against a real Postgres container: created/listed/patched/deleted indexers and
download clients (including the named-parameter INSERTs and the boolean fields on both create and
patch), confirmed `prowlarr-sync` fails gracefully when unconfigured rather than crashing. Regression-
checked the identical sequence — including the exact boolean-edge-case payloads that had been
broken — against SQLite on the same build, confirming the fix (not just the Postgres path) is
correct on both backends.

## Progress (Round 82)

Converted the quality/library config slice: `routes/tags.ts`, `routes/rootFolders.ts`,
`routes/qualities.ts`, `routes/qualityProfiles.ts`, and `services/quality.ts` (the
`qualityRank`/`sizeWithinQualityBounds`/`preferredSizeDistance`/`pickBestAllowedQuality` functions
called synchronously throughout release-parsing/scoring code — converted to the same in-memory-cache
pattern as `settingsStore.ts`, since these are called from deep, hot, synchronous call chains that
would otherwise cascade `await` broadly for no real benefit on a small, read-heavy table).

**Found and fixed a genuinely critical gap**: `db/client.ts`'s SQLite-only first-boot seeding
(default qualities, the "Any" quality profile, and — the important one — generating the instance API
key) had no Postgres equivalent at all. A fresh Postgres-backed install would have booted with zero
qualities, no quality profile, and **no API key ever generated — meaning nobody could authenticate
into a brand-new Postgres install at all**, not even to click through initial setup. Added
`db/postgresSeed.ts` (an async port of that seeding logic, called once from `db/index.ts`'s
`initDb()` right after the schema itself is applied) to close this. Also silenced a confusing side
effect this surfaced: the old SQLite-only seeding still runs even in postgres mode (an unavoidable
consequence of every not-yet-converted file still importing `db/client.ts` directly — see the
"Not done yet" note below), which means it was *also* printing its own "generated AoNarr API key"
banner for a key that lives in the orphaned shadow SQLite database, not the one actually in use —
actively misleading rather than just redundant, since an admin reading it would try the wrong key.
That banner is now suppressed specifically when `AONARR_DATABASE_DRIVER=postgres`.

Verified live end-to-end against a real Postgres container: booted a **completely fresh** database
with no admin-bootstrap env vars set (the harder, more realistic case — most Postgres users
wouldn't set those), confirmed exactly one API key was generated and logged, authenticated with it,
confirmed 15 qualities and the default "Any" profile were seeded, then exercised every converted
route (create/list tags, create/list root folders, the quality-reorder transaction with its
negative-rank staging trick to dodge the UNIQUE constraint) — all correct. Regression-checked the
same sequence against SQLite on the same build.

## Progress (Round 81)

Converted `routes/users.ts` (household account CRUD, per-user library permissions, session listing/
revocation) — the natural next slice after auth itself, since it's the other half of "who can log in
and what can they see." No new patterns or bugs this round; straightforward `await` + `Promise.all`
for the one spot that mapped an array of rows through an async per-row lookup
(`getAllowedTypes`). Verified live against a real Postgres container: create a household user with
library permissions, list users, patch permissions (including the delete-then-reinsert allowed-types
pattern), log in as that user, list active sessions, force-revoke one, delete the user, and confirm
every one of those actions shows up correctly in the audit log — all passed, plus the same sequence
regression-checked against SQLite on the same build.

## Progress (Round 80) — first working vertical slice

Converted the full auth/login/session critical path — the single most connected piece of the app,
since `requireAuth` runs as global middleware on every request — to the async db interface, and
verified the whole thing end to end against a real `postgres:16` container: setup-status, creating
the bootstrap admin account from env vars, login, session-validated requests (`/me`, `/settings`),
logout, session revocation, a failed-login rejection, and the audit log correctly recording it all.
Confirmed zero regression on the same build against SQLite.

**Files converted**: `db/index.ts` (new — the driver dispatcher `initDb()`/`db` everything else
converted now imports instead of `db/client.ts` directly), `services/settingsStore.ts` (see its own
comment for the in-memory-cache design — deliberately kept `getSetting`/`setSetting` synchronous so
their ~101 existing call sites across 24 files don't need to change), `services/auth.ts`,
`middleware/auth.ts`, `services/bootstrapAdmin.ts`, `routes/authRoutes.ts`, `services/audit.ts` (kept
`logAuditEvent` synchronous/fire-and-forget for the same no-cascade reason as settings — see below),
`routes/auditLog.ts`, and one bugfix in `routes/users.ts` (a pre-existing `res.json(listActiveSessions())`
that TypeScript never caught, because `res.json(x: any)` doesn't care that `x` used to be a value and
is now a Promise — this class of bug won't show up as a compile error anywhere in the remaining
conversion, only as wrong runtime output, so each converted file needs someone to actually grep for
its exported functions' other call sites, not just trust `tsc -b` came back clean).

**Two real, previously-unknown bugs the Postgres verification pass caught** (both would have shipped
silently broken if this had gone straight from "compiles" to "looks done"):

1. **A converted file calling into an unconverted one through the *old* SQLite path corrupts data
   silently.** `authRoutes.ts`'s login handler calls `logAuditEvent(...)`, which — before this
   round's fix — still imported the old synchronous `db/client.ts`. In Postgres mode, that import
   still creates and opens a local SQLite file as an orphaned side effect (nothing stops it; every
   unconverted file does this), and audit_log's `user_id` foreign key pointed at that empty
   shadow database's `users` table, which the real user (created in Postgres) doesn't exist in —
   every login crashed with a foreign-key violation. Fixed by converting `audit.ts` too. **This means
   a file can't really be considered "converted and done" in isolation — everything it calls into
   that also touches the DB needs to be checked, or verification needs to exercise the actual
   call chain, not just the one file in question.**
2. **Postgres folds unquoted SQL identifiers to lowercase; SQLite doesn't.** `auditLog.ts`'s query
   aliased columns with plain camelCase (`user_id AS userId`) — on SQLite this returns a `userId`
   key exactly as written; on Postgres, an unquoted `userId` alias comes back as `userid`, silently
   breaking any code (or frontend) expecting camelCase keys. Fixed by quoting every such alias
   (`AS "userId"`). **This is a systemic risk for the rest of the conversion**: this codebase's raw
   SQL uses camelCase aliases extensively (`AS mediaTitle`, `AS hasFile`, `AS mediaItemId`, etc. —
   seen throughout `wanted.ts`, `dashboard.ts`, and others). Every file's conversion needs to audit
   its own queries for this, not just add `await`. A query that aliases to `snake_case` (or doesn't
   alias at all, matching the column's real name) and lets a `db/mappers.ts`-style function do the
   snake→camel conversion in JS — the pattern already used for full table rows — sidesteps this
   entirely and is the safer default for any *new* ad-hoc query, versus hand-quoting each alias.

## Progress (Round 79)

Phase 1 from the plan below ("introduce the abstraction, verify it standalone against a real
engine, don't touch the app yet") is done for PostgreSQL specifically:

- `server/src/db/asyncDb.ts` — the dual-dialect async interface (see its own module comment for
  the full design rationale). **Note: this shipped as a hand-written thin wrapper around raw SQL,
  not Kysely** as originally recommended below — once the SQL-portability audit was actually
  complete (see "What ported for free" below), the gap between "raw SQL + a thin async/placeholder-
  translation shim" and "adopt a query-builder DSL and rewrite 444 call sites into it" turned out to
  be enormous, with the query-builder buying comparatively little given how much of the existing SQL
  already ports unchanged. The Kysely option is still on the table if a future round finds the thin
  wrapper insufficient, but it's no longer the default recommendation.
- `server/src/db/schema.postgres.sql` — mechanical translation of `schema.sql`, committed as a
  static file (not generated at runtime).
- `server/src/db/postgresSchema.ts` — applies that schema plus a Postgres port of every
  `ensureColumn(...)` retrofit currently in `client.ts`, run via `migratePostgresSchema(db)`.
- **Verified against a real `postgres:16` container** (not mocked): schema migration (including
  idempotent re-run), insert with `lastInsertRowid` via `RETURNING id`, select, update, delete, the
  `ON CONFLICT ... DO UPDATE` upsert pattern used throughout the codebase (ported verbatim, ran
  correctly, no duplication on a second upsert), transaction commit, transaction rollback, and the
  one `WITH RECURSIVE` query in the codebase (`library_groups`' nested-count rollup) — all passed.

**What ported for free**, now confirmed rather than assumed: every table's DDL except two textual
substitutions (`AUTOINCREMENT` → `SERIAL`, `datetime('now')` → an explicit UTC-text-formatted
equivalent so `created_at`-style TEXT columns hold identical string shapes on both backends); the
`ON CONFLICT(...) DO UPDATE SET x = excluded.x` pattern used in 14+ places; `WITH RECURSIVE`; plain
positional `?` parameters (translated to `$1, $2, ...` internally by the wrapper, transparent to
callers). None of the 47 `ensureColumn(...)` DDL strings needed any translation either.

**One real bug the Postgres verification pass caught that the earlier static analysis missed**: not
every table has a plain `id` column — `settings` (keyed by `key`), and several join/junction tables
(`media_item_tags`, `quality_profile_format_scores`, `collection_items`, `user_library_access`,
`sessions`, `release_group_stats`) use a composite or different primary key. Blindly appending
`RETURNING id` to every INSERT breaks on those with Postgres error 42703 — and, worse, poisons the
rest of an in-progress transaction if it happens inside one (Postgres aborts the whole transaction
on a statement error; SQLite has no equivalent behavior, so this class of bug is invisible from the
SQLite side entirely). Fixed with a static exception list in `asyncDb.ts`, not a try/catch, because
retrying inside an already-poisoned transaction would just fail again.

**Also confirmed, not yet acted on**: 5 files (`collections.ts`, `system.ts`, `recycleBin.ts`,
`scheduler.ts`, `storageForecast.ts`) use SQLite's `datetime('now', ?)` modifier syntax
(`datetime('now', '-30 days')`) at the query level, not just in schema defaults — this has no direct
Postgres equivalent (`now() - interval '30 days'` is the right shape) and needs a per-call-site
rewrite when those files are converted, not a generic translation the wrapper can do for them.

**Not done yet, as of Round 79**: none of the ~70 application files (routes/services) had been
converted. **As of Round 80** (see above): 9 files converted — the full auth/login/session path,
proven end to end against real Postgres. `AONARR_DATABASE_DRIVER=postgres` now runs a real,
correctly-behaving app for that slice; everything else still imports the old synchronous
`db/client.ts` directly and would still be broken or silently wrong under Postgres (per Round 80's
bug #1 above — an unconverted file a converted one calls into can corrupt data through the orphaned
shadow SQLite database, not just throw a type error). The remaining ~61 files, converted one group
at a time and verified against both backends as each group lands, is the rest of this project.

## The ask

Let users choose PostgreSQL (MariaDB later, as a separate follow-up) as an alternative to AoNarr's
built-in SQLite, the way some self-hosted apps (Nextcloud, Immich, Jellyfin's newer versions) let
you point at an external DB server instead of a local file.

## Why this isn't a bounded feature

Every other item shipped in this project has been additive: a new route, a new table, a new page.
This one is different in kind — it's a foundation swap. The numbers from the current codebase:

- **70 files** call `db.prepare(...)` directly — 37 in `routes/`, 32 in `services/`, 1 in `db/`
- **444 individual `db.prepare()` call sites**
- **40 tables**, 34 foreign keys, 48 ad-hoc `ALTER TABLE ... ADD COLUMN` migrations already
  accumulated in `db/client.ts` (see `ensureColumn()` calls) — one per feature round that added a
  column to an existing table
- **13 files** use `db.transaction(...)` for multi-statement atomicity
- better-sqlite3 is **fully synchronous** — every `.prepare().run()/.get()/.all()` call blocks
  the calling function and returns immediately with a value, no `await` anywhere in the data layer.
  This is why so many routes in this codebase read as straight-line procedural code with no promise
  chains around DB access.

There is no existing abstraction layer. `db` (the raw better-sqlite3 `Database` instance) is
imported directly from `db/client.ts` into every route and service file that touches data. Adding a
second backend isn't "add a config option" — it's introducing an abstraction boundary retroactively
into 70 files that currently have none.

## What specifically breaks going from SQLite to Postgres/MariaDB

1. **Sync → async.** `mysql2`, `mariadb`, and `pg` (the standard Node drivers for the other two) are
   all async. Every one of the 444 call sites becomes `await db.prepare(...)`, which cascades: the
   *calling* function must become `async`, which cascades to *its* caller, and so on up through
   route handlers (already async, fine) but also plain service functions that are currently sync
   and called from other sync functions (not fine — this is the actual size of the blast radius,
   not the 444 call sites themselves).

2. **SQLite-specific SQL used throughout:**
   - `datetime('now')` as a column default (52 uses) — Postgres wants `now()`, MySQL/MariaDB wants
     `NOW()` or `CURRENT_TIMESTAMP`.
   - `INSERT ... ON CONFLICT(col) DO UPDATE SET x = excluded.x` (14 uses) — this exact syntax is
     valid Postgres too (lucky), but MariaDB/MySQL requires `ON DUPLICATE KEY UPDATE x = VALUES(x)`,
     a different clause entirely. This alone makes MariaDB strictly more work than Postgres.
   - `INTEGER PRIMARY KEY AUTOINCREMENT` (33 uses, i.e. nearly every table) — Postgres wants
     `SERIAL`/`GENERATED ALWAYS AS IDENTITY`, MariaDB wants `AUTO_INCREMENT`.
   - `PRAGMA table_info(table)` (used by the migration helpers in `db/client.ts` to introspect
     columns) has no equivalent; both alternatives use `information_schema.columns` instead.
   - `WITH RECURSIVE` (1 use, `library_groups`' nested-group rollup) — supported by both Postgres
     and MariaDB 10.2+/MySQL 8+, so this one's actually low-risk.
   - SQLite's dynamic typing means TEXT columns routinely hold JSON blobs (`external_ids`,
     `media_info`, `extra_metadata`, `config`, dozens more) parsed with `JSON.parse()` in JS.
     Postgres has a real `jsonb` type that would be the correct target; MariaDB has `JSON` (stored
     as `LONGTEXT` under the hood, so closer to what SQLite already does). Neither is a blocker,
     but it's a real design decision, not a mechanical port.

3. **The migration system itself is SQLite-specific.** `db/client.ts`'s `ensureColumn()` helper
   (`ALTER TABLE ... ADD COLUMN`, called 48 times) happens to be valid syntax on all three engines
   for simple additive columns, so that part actually ports cleanly. The harder part is the
   handful of *structural* migrations already in that file — `dropCheckConstraint()` and
   `repairDanglingReference()` rebuild a table from scratch (`RENAME TO ..._pre_migration`,
   recreate, copy rows, drop) because SQLite can't drop/alter a CHECK constraint or a foreign key
   in place. Postgres and MariaDB *can* (`ALTER TABLE ... DROP CONSTRAINT`, `ALTER TABLE ...
   MODIFY`), so these would need engine-specific rewrites rather than being reused as-is — but
   they're only 3 call sites, not 48.

4. **Every route currently trusts SQLite's implicit single-writer serialization.** Because
   better-sqlite3 runs in-process and synchronously, two "check then insert" statements back to
   back (common pattern: `SELECT ... WHERE title = ?` to check for a duplicate, then `INSERT`) are
   safe from races *by accident* — nothing else can interleave between them because nothing else is
   running. A real client-server DB with concurrent connections doesn't have that guarantee; those
   patterns need either an explicit transaction or a unique constraint + conflict handling to stay
   correct. This is the least visible risk in the whole migration — it won't show up as a compile
   error or even a test failure, only as an occasional duplicate row under real concurrent load.

## Options considered

**A. Query builder with multi-dialect support (Kysely, Knex).** Write SQL-ish TypeScript, the
library translates to each engine's dialect and abstracts the driver's async API. Still requires
touching every one of the 444 call sites and adding `await` up the call chain, but removes the need
to hand-write three versions of every query. Kysely in particular is fully typed and has clean
Postgres/MySQL/SQLite dialects. **This is the realistic option** if the goal is genuinely supporting
three backends long-term.

**B. Full ORM (Prisma, Drizzle).** Bigger rewrite than a query builder — schema gets redefined in
the ORM's own schema language, which then generates migrations, rather than hand-written SQL. More
upfront work than A, but better long-term ergonomics and built-in migration tooling (which this
project currently does by hand in `client.ts`, accumulating `ensureColumn()` calls). Drizzle is the
closer fit of the two (schema-as-TypeScript, keeps hand-written SQL as an escape hatch, decent
SQLite/Postgres/MySQL support); Prisma's query engine has historically been the heavier runtime cost
for a self-hosted single-binary-style app like this one.

**C. Thin custom repository layer, hand-write three dialects.** Wrap the ~40 tables' worth of
queries behind functions, implement each function three times (or with light templating) per
engine. More total code than A or B, all of it hand-maintained, but no new dependency and full
control. Given 444 call sites already exist as ad-hoc SQL, this is really "option A without the
library" — more work for no compensating benefit.

**D. Don't migrate the query layer at all — offer PostgreSQL-compatible export/import instead of
live dual-backend support.** Keep SQLite as the only *runtime* engine, but let a user export the
whole DB in a Postgres-loadable form (this is roughly what `pg_dump`-style tools do, or what a
one-way ETL job would do) for people who want their data queryable from an external BI tool, backup
system, etc. — without AoNarr itself ever running against anything but SQLite. Far less work, but
doesn't satisfy "let users choose MariaDB/Postgres as AoNarr's own database," which is what was
actually asked for.

## Recommendation

**Thin hand-written async wrapper (not Kysely — see "Progress" above for why that changed),
PostgreSQL only for now, MariaDB deferred as a separate later phase.**

- Postgres over MariaDB as the first (and for now, only) alternative target because the
  `ON CONFLICT ... DO UPDATE` syntax already used throughout the codebase is valid Postgres syntax
  verbatim — one whole category of the port (14+ call sites, likely more as the schema grows) is
  close to free. MariaDB's `ON DUPLICATE KEY UPDATE` is a genuinely different clause shape and would
  need every one of those sites rewritten with different logic, not just swapped syntax.
- SQLite stays the default and the only backend the Unraid Community Applications template assumes
  — this is an opt-in advanced setting for people who already run a Postgres server, not a
  requirement for anyone else. Nothing about the existing single-file-DB deployment story changes
  for the overwhelming majority of users.

## If pursued, suggested phasing

1. **Introduce the abstraction with zero behavior change.** ~~Swap `db/client.ts`'s raw
   better-sqlite3 export for a Kysely instance~~ — superseded, see "Progress" above:
   `server/src/db/asyncDb.ts` now provides the same async-shaped interface over hand-written SQL
   instead. Verified standalone against a real Postgres instance (done, Round 79). Converting the
   ~70 application files to actually use it — each still running against SQLite the whole time via
   this same interface — is the remaining, much larger part of this phase; not started. This is
   where the "add `await` up the call chain" cascade gets found and fixed, one file at a time,
   without also debugging a new database engine at the same time.
2. **Wire the Postgres path into the running app** once phase 1's file-by-file conversion is
   complete — an `AONARR_DATABASE_DRIVER=postgres` config value already exists (see config.ts) but
   doesn't yet produce a working app, since most routes still bypass the new abstraction entirely.
   Audit the "implicit serialization" risk (point 4 further up) as part of this — every
   check-then-insert pattern needs a real look, since Postgres will actually surface races SQLite
   never could.
3. **Live-verify a full round-trip on Postgres**: every route this project has, exercised against a
   real `postgres:16` container the same way every other feature in this project has been verified
   against a real running instance — not just "the query compiles."
4. **MariaDB as a follow-up phase**, once Postgres is solid and shipped — reusing the abstraction
   from phase 1, but redoing every `ON CONFLICT` site with `ON DUPLICATE KEY UPDATE` semantics, and
   auditing the two Postgres-specific choices already made (`RETURNING id` for lastInsertRowid,
   which MySQL/MariaDB gets natively via `insertId` instead; the UTC-text `datetime('now')`
   replacement, which needs MariaDB's own equivalent).

## Rough effort shape

Not a time estimate (this project's rounds don't map to calendar time), but a shape: phase 1 alone
is comparable in size to every round shipped in this project *combined* — it touches 70 files, not
one feature's worth. Phases 2–4 are each smaller than phase 1 individually, but assume phase 1 is
completely done and stable first; none of them are safe to start concurrently with it.

## Explicit non-goals

- **No live migration tool** (moving an existing SQLite install's data into Postgres/MariaDB) is
  assumed as part of this scope unless separately requested — this document only covers *supporting*
  an external DB as a fresh choice, not converting an existing installation's data.
- **Not** attempting to support all three engines simultaneously in one release — SQLite stays
  default throughout every phase above; Postgres is additive; MariaDB is a later, separate addition.
- **Not** in scope: connection pooling tuning, HA/replication setup, or anything about how the user
  runs their own Postgres/MariaDB server — that's entirely the user's infrastructure, same as how
  AoNarr doesn't manage the user's download client or media server today.

## Decision (resolved)

Commit to this; PostgreSQL first, MariaDB deferred as a separate later phase — decided by the user
after this document's original scoping pass. Foundation work started the same round (see
"Progress" above). Remaining rounds convert the ~70 application files one at a time.