Files
ersatztv/docs/decisions/records/scan/getoraddfolder-db-lookup.md
T
timothy fba5233caf
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 23s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m17s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 1m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 16m5s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(610): split the decision corpus into one YAML-frontmatter file per record
168 records -> docs/decisions/records/<area>/<topic>.md (163 active, 23 dirs) and
docs/decisions/archive/<area>/<topic>.md (5 archived). The filename IS the key,
so one-active-record-per-key becomes a filesystem property rather than a
validator check, and supersession becomes a `git mv`.

WHY: the monolith was a concurrency problem before an aesthetic one. A
3,900-line append target made parallel sessions collide -- PR #605 and PR #614
both hit append-vs-append conflicts during routine rebases, and hand-resolving
those inside the corpus is exactly the operation the rationale-rewrite guard
exists to police.

HOW IT IS VERIFIED: a ~170-file diff cannot be meaningfully read, so correctness
does not rest on reading it. The parser was taught BOTH formats first, so the
body-diff guard parses the old form at the merge-base and the new form at head --
the migration validates itself, no bypass. The proof is a field-level equivalence
harness: 168 records before and after, zero lost, zero gained, zero field
mismatches, zero rationale bodies differing. Reviewers should scrutinise the
harness; it is the actual evidence.

What measuring caught that reading would not have:

- ~500 lines sit OUTSIDE any record -- decisions.md's lifecycle schema and each
  topic file's preamble, mostly the only copy. Source files are kept and
  stripped, never deleted. They also cannot be filed per-area: topic files hold
  several areas and 4 of 23 areas span several files.
- Archive discovery was a non-recursive glob; after the split it found ZERO
  archived records, surfacing as four bogus "supersedes points to unknown key"
  errors rather than an obvious failure.
- ~32 live docs point into the corpus BY DATE, which the split dangles. Each
  stripped file now ends with a generated "Records formerly in this file" index,
  which also rescues the identical breadcrumbs in old issue comments.
- decisions.md's "In this file:" list was 97 same-file anchor bullets that the
  split makes WRONG, not merely stale. Dropped; the generated index replaces
  them with links that resolve.

The equivalence harness now runs against a checked-in FIXTURE, not the live
corpus. The earlier version migrated the real tree, which made it a one-shot:
the moment the migration landed there was nothing left to move and the tests
failed for reasons unrelated to the code. A fixture keeps them testing the
SCRIPT rather than the repo's current state.

Keys preserved verbatim, warts included: `sched` (12) and `scheduling` (1) remain
two directories for one concept. Renaming a key is not a move -- it changes
identity, breaks the equivalence proof, and invalidates MemPalace's per-key
drawers. Taxonomy normalisation is separate work.

refs #610
2026-07-25 19:45:09 +02:00

3.7 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
scan.getoraddfolder-db-lookup 2026-07-20 — `ILibraryRepository.GetOrAddFolder` resolves the folder from the DB, not the caller's `LibraryPath.LibraryFolders` navigation (#488) active 2026-07-20 none none `ILibraryRepository.GetOrAddFolder` resolves the existing folder via a DB query on `(LibraryPathId, Path)`, not the caller's `LibraryPath.LibraryFolders` in-memory navigation, since that navigation is only eager-loaded on the local scan path and is null on remote (Jellyfin) callers. GetOrAddFolder, LibraryFolders navigation, ArgumentNullException, folder lookup contract, remote scanner crash, eager-load assumption · paths: `ILibraryRepository.GetOrAddFolder`, `LibraryRepositoryTests`, `JellyfinMusicVideoLibraryScanner` · issues: #488 `LibraryRepositoryTests` (`LibraryPath.LibraryFolders == null` case); test coverage in `LibraryRepositoryTests`

GetOrAddFolder looked the existing folder up by reading libraryPath.LibraryFolders in memory. That navigation collection is only eager-loaded on the local scan path — LibraryRepository.GetLibrary .Include(l => l.Paths).ThenInclude(p => p.LibraryFolders) — which every *FolderScanner goes through. The remote (Jellyfin) sync path takes its LibraryPath straight off the JellyfinLibrary entity, whose Paths[].LibraryFolders is null, so .Filter(...) on it hit Enumerable.Where(null, …) and threw ArgumentNullException('source') on the very first item of every Jellyfin music-video scan. JellyfinMusicVideoLibraryScanner is the only remote caller of GetOrAddFolder (it does not derive from the MediaServer*LibraryScanner base that the movie/TV/other-video remote scanners share), which is why no other remote scanner tripped it, and the feature had never run in prod, CI, or locally.

  • Fix the repository, not the caller. The lookup now queries dbContext.LibraryFolders by (LibraryPathId, Path) (the method already opened a dbContext for the insert). This removes the implicit, undocumented loading contract entirely — correct for all nine callers — rather than the caller-side option (eager-load LibraryFolders on the Jellyfin path), which would have left the contract intact for the next remote scanner to trip. The contract change is now stated on ILibraryRepository.GetOrAddFolder.
  • null ≠ empty — do not coerce. A null navigation meant unknown, not known-absent; treating it as an empty collection would fall through to CreateNewFolder and insert a duplicate LibraryFolder for a path that already exists (there is no unique constraint behind it). Querying the DB distinguishes the two.
  • No new hot-path cost. Every local scanner already calls GetParentFolderId (a DB query with the same (LibraryPathId, …) shape) once per folder immediately before GetOrAddFolder, so the folder granularity was never served purely from memory; this adds one indexed lookup per folder, not a per-file query. As a bonus the DB lookup also sees folders created earlier in the same scan, which the load-time in-memory snapshot could not.
  • Tests. LibraryRepositoryTests (real in-memory-SQLite TvContext) drives GetOrAddFolder with LibraryPath.LibraryFolders == null — the exact remote-path shape — and asserts it creates the folder and is idempotent on re-scan (no duplicate row). Proven non-vacuous by restoring the navigation-collection lookup and watching both tests fail with the issue's ArgumentNullException('source'). Note: CleanEtagsForLibraryPath still reads libraryPath.LibraryFolders directly, but it is only reached on the local (eager-loaded) path, so it is not affected; left as-is (out of #488 scope).