docs: spec for cleanup batch (Tier A loose ends + Tier B design fixes)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
|||||||
|
# Cleanup batch — Tier A (loose ends) + Tier B (design tradeoffs)
|
||||||
|
|
||||||
|
**Date:** 2026-07-13
|
||||||
|
**Status:** design approved, pending spec review
|
||||||
|
**Branch:** `cleanup/tier-a-b`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Close the deferred backlog down to zero *fixes* so the codebase has no known loose
|
||||||
|
ends before we brainstorm net-new features (Tier C). This batch covers the clear
|
||||||
|
hardening/polish items (Tier A) and the two benign-but-real design tradeoffs the
|
||||||
|
user chose to fix properly (Tier B). Tier C (Last.fm source, crash-resume
|
||||||
|
mid-pipeline, per-track duration matching, UI error states) is explicitly **out of
|
||||||
|
scope** here — it goes to the feature brainstorm next.
|
||||||
|
|
||||||
|
Non-goals: no behavior change to the acquisition pipeline, no new user-facing
|
||||||
|
features, no schema change beyond the two additive tables below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier A — loose ends (web, mechanical)
|
||||||
|
|
||||||
|
### A1. PATCH allowlist hardening
|
||||||
|
Both `web/src/app/api/monitor/config/route.ts` and
|
||||||
|
`web/src/app/api/discover/config/route.ts` PATCH handlers currently gate writes on
|
||||||
|
`k in DEFAULTS`, which walks the prototype chain. The DELETE `/api/config` handler
|
||||||
|
was already hardened to `Object.prototype.hasOwnProperty.call(DEFAULTS, k)`; apply
|
||||||
|
the same own-property check to both PATCH routes. This only ever prevented writing
|
||||||
|
harmless junk `Config` rows (neither PATCH deletes), so this is consistency
|
||||||
|
hardening, not a live vulnerability — but it removes the last prototype-chain gap.
|
||||||
|
|
||||||
|
### A2. a11y + copy
|
||||||
|
- Add an `aria-label` to the Monitor tab `enabled` toggle (it currently has none;
|
||||||
|
every other control on the page is labelled and web tests key off labels).
|
||||||
|
- Update the Settings page eyebrow at `web/src/app/settings/settings-form.tsx:159`
|
||||||
|
from `"Credentials · library · discovery"` to cover all five tabs, e.g.
|
||||||
|
`"Credentials · library · monitor · discovery"`.
|
||||||
|
|
||||||
|
### A3. DELETE allowlist test
|
||||||
|
Add a `?field=constructor` case to the DELETE `/api/config` allowlist test
|
||||||
|
(`web/src/app/api/config/route.test.ts`) alongside the existing `__proto__` case, so
|
||||||
|
the own-property guard is covered against both prototype-chain keys. Structural fix
|
||||||
|
already in place; this is belt-and-suspenders coverage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier B1 — discovery scoring: per-seed contributions table (worker + migration)
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
`_upsert_artist` / `_upsert_album` upsert with `score = EXCLUDED.score` (**replace**),
|
||||||
|
but the intended semantic is *score = sum of every seed's contribution*. Since
|
||||||
|
`discover.maxSeeds` was retired and chunking is now the default, a single sweep
|
||||||
|
partitions its seeds across many worker iterations. A candidate surfaced by seeds in
|
||||||
|
different chunks has its score **overwritten** by the last chunk instead of summed
|
||||||
|
(item 4), and the same replace-not-accumulate breaks across throttled sweeps
|
||||||
|
(item 5). The score a suggestion shows reflects only its most-recent chunk, not the
|
||||||
|
whole library's affinity.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
Introduce a normalized contributions table so a suggestion's score is a derived
|
||||||
|
aggregate, independent of chunk and sweep boundaries.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model DiscoverySeedContribution {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
candidateMbid String
|
||||||
|
candidateName String
|
||||||
|
seedMbid String
|
||||||
|
score Float
|
||||||
|
sources String[]
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([candidateMbid, seedMbid])
|
||||||
|
@@index([candidateMbid])
|
||||||
|
@@index([seedMbid])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`run_discovery`, per seed **S** processed in the chunk:
|
||||||
|
|
||||||
|
1. `affected := SELECT DISTINCT "candidateMbid" WHERE "seedMbid" = S` — the
|
||||||
|
candidates S *used* to contribute to (so drop-offs are recomputed too).
|
||||||
|
2. `DELETE FROM "DiscoverySeedContribution" WHERE "seedMbid" = S`.
|
||||||
|
3. Build a per-seed aggregate over the live sources: for each similar candidate
|
||||||
|
(skipping already-followed mbids), sum score across sources and union source
|
||||||
|
names. `INSERT` one contribution row per (candidate, S).
|
||||||
|
4. `affected |= {candidate mbids newly inserted for S}`.
|
||||||
|
|
||||||
|
After the chunk's seeds are processed, recompute every candidate in the union of all
|
||||||
|
`affected` sets:
|
||||||
|
|
||||||
|
- `SELECT SUM(score), COUNT(*), <sources>, any candidateName
|
||||||
|
FROM "DiscoverySeedContribution" WHERE "candidateMbid" = ANY(affected)
|
||||||
|
GROUP BY "candidateMbid"`.
|
||||||
|
`COUNT(*)` is the seedCount (unique on `(candidate, seed)` → one row per seed).
|
||||||
|
- For each with `score >= min_score`, upsert `DiscoverySuggestion` exactly as today,
|
||||||
|
keeping the `WHERE status = 'pending'` guard (dismissed/wanted rows untouched) and
|
||||||
|
the rowcount-drives-album-derivation logic. Album suggestions inherit the
|
||||||
|
recomputed artist aggregate.
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
- Score is now **exactly** the sum of all seeds' latest contributions — no chunk
|
||||||
|
artifact, no partial-sweep reset. A sweep triggered by one newly-followed artist
|
||||||
|
updates only that seed's rows and leaves every other seed's contribution intact.
|
||||||
|
- Deleting-then-reinserting a seed's rows self-heals stale entries: if S stops
|
||||||
|
finding a candidate, S's row for it vanishes and the candidate is recomputed
|
||||||
|
(seedCount drops) because it was in `affected`.
|
||||||
|
- A candidate whose contributions fall to zero: its pending suggestion is **left as
|
||||||
|
is** (not auto-retracted), consistent with today's semantics — suggestions persist
|
||||||
|
until dismissed/actioned. Documented, not a bug.
|
||||||
|
|
||||||
|
### Migration & cleanup
|
||||||
|
- Additive table only; no data backfill (existing suggestions keep their current
|
||||||
|
score and are corrected on the next sweep that touches their seeds).
|
||||||
|
- Consider a one-time note: existing `DiscoverySuggestion` scores stay stale until
|
||||||
|
re-swept — acceptable for an off-by-default feed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tier B2 — scan worklist table (worker + migration)
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
`scan_chunk` re-walks the whole `/music` tree every chunk and skips entries sorting
|
||||||
|
`<= cursor` — `O(N²/C)` directory stat ops over a full scan. The scan is
|
||||||
|
MB-rate-limited (~1 req/s), so this is ~2% overhead, but the only true `O(N)` fix is
|
||||||
|
a persisted worklist (a JSON-blob-in-Config would just move the cost to re-parsing
|
||||||
|
the blob each chunk).
|
||||||
|
|
||||||
|
### Design
|
||||||
|
Persist the album worklist once at scan start and pop it by indexed cursor.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ScanWorkItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
scanId String
|
||||||
|
artist String
|
||||||
|
album String
|
||||||
|
path String
|
||||||
|
done Boolean @default(false)
|
||||||
|
|
||||||
|
@@unique([scanId, path])
|
||||||
|
@@index([scanId, done, artist, album])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`scan.py`:
|
||||||
|
- `scan_build_worklist(conn, scan_id, dest_root)` — walk once via the existing
|
||||||
|
`_iter_album_entries`, `INSERT` a row per album folder (`ON CONFLICT (scanId, path)
|
||||||
|
DO NOTHING`, so a rebuild is idempotent). Store `artist`, `album` (= album folder),
|
||||||
|
`path` so the chunk needs no re-derivation.
|
||||||
|
- `scan_chunk(conn, resolver, probe, browser, scan_id, limit)` — `SELECT` the next
|
||||||
|
`limit` rows `WHERE scanId = %s AND NOT done ORDER BY artist, album`, process each
|
||||||
|
via the existing `_process_album`, mark `done = true`. Returns
|
||||||
|
`(imported, skipped, done)` where `done` is True when no undone rows remain.
|
||||||
|
- `scan_library(conn, resolver, probe, browser, dest_root)` (test-only unbounded
|
||||||
|
path) — reimplemented on the worklist: build with a fixed scan id, drain fully,
|
||||||
|
delete that scan's rows, return `ScanResult`. Keeps existing tests meaningful.
|
||||||
|
|
||||||
|
`main.maybe_run_scan`:
|
||||||
|
- On start (`scan.requested` and not in progress): set `scan.inProgress=true`,
|
||||||
|
`scan.requested=false`, `scan.progress="0/0"`, generate a `scan_id`
|
||||||
|
(uuid), store it in `scan.id`, and `scan_build_worklist`.
|
||||||
|
- Each iteration: read `scan.id`, pop one chunk, accumulate imported/skipped in
|
||||||
|
`scan.progress`.
|
||||||
|
- On drain: write `scan.result` (unchanged `"imported X, skipped Y"` format), clear
|
||||||
|
`scan.progress`/`scan.id`, set `scan.inProgress=false`, and
|
||||||
|
`DELETE FROM "ScanWorkItem" WHERE scanId = %s`. Preserve the existing
|
||||||
|
"rescan requested mid-scan triggers one more full pass" guarantee.
|
||||||
|
- `scan.cursor` is retired (worklist replaces the string cursor).
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
- `O(log N + C)` per chunk via the `(scanId, done, artist, album)` index; one tree
|
||||||
|
walk per scan total.
|
||||||
|
- Robust to folders appearing/disappearing mid-scan: the worklist is frozen at scan
|
||||||
|
start, so the set processed is deterministic.
|
||||||
|
- Resumable/idempotent: `done` flag + `ON CONFLICT` inserts + `ON CONFLICT` record
|
||||||
|
writes mean an interrupted worker resumes cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
One additive Prisma migration adds both tables (`DiscoverySeedContribution`,
|
||||||
|
`ScanWorkItem`). No column changes to existing tables, no backfill. Reaches live
|
||||||
|
`lyra` via the web entrypoint's `migrate deploy` on rebuild.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Worker (`pytest`, against `lyra_test`):**
|
||||||
|
- Contribution accumulation: a candidate surfaced by seeds split across two
|
||||||
|
simulated chunks sums to the same score as a single-chunk sweep; a partial sweep
|
||||||
|
(one seed) preserves other seeds' contributions; a seed dropping a candidate
|
||||||
|
lowers its seedCount and recomputes the suggestion.
|
||||||
|
- Scan worklist: build → pop chunks → drain marks all done; resume after a partial
|
||||||
|
chunk; idempotent rebuild; `scan_library` unbounded path still returns the same
|
||||||
|
`ScanResult`.
|
||||||
|
- **Web (`vitest`):**
|
||||||
|
- PATCH `/api/monitor/config` and `/api/discover/config` reject a
|
||||||
|
prototype-chain key (`__proto__`, `constructor`) — no `Config` write.
|
||||||
|
- DELETE `/api/config` `?field=constructor` rejected (added to existing test).
|
||||||
|
- Full suites + `tsc` + `next build` green before merge.
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
Branch `cleanup/tier-a-b` → writing-plans → subagent-driven build (per-task review +
|
||||||
|
Opus whole-branch review) → ff-merge to `main` → `git push` →
|
||||||
|
`docker compose up -d --build web worker` (runs `migrate deploy`) → verify live
|
||||||
|
(web 200, worker healthy, migration applied). Then move to the Tier C feature
|
||||||
|
brainstorm.
|
||||||
|
|
||||||
|
## Out of scope (→ Tier C brainstorm)
|
||||||
|
Last.fm second `SimilaritySource`, crash-resume mid-pipeline, per-track duration
|
||||||
|
matching, UI error states on mutations.
|
||||||
Reference in New Issue
Block a user