Files
manage/openspec/changes/service-storage-harness/tasks.md
T
Developer 9a251db23c spec(service-storage-harness): add tasks (4 slices, each <=400 lines)
S1 harness+store+client+integration; S2 widget adapter+3 FE widgets+
LineSeriesChart extract; S3 MediaIndex +service_id migration (fixes latent
global-clear bug); S4 cascade-delete wiring. Each slice leaves pytest/npm
build/npm lint green. Risk flags on LineSeriesChart extract + MediaIndex migration.
2026-07-09 08:02:31 +00:00

294 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SDD Tasks: Service Storage Harness (qBittorrent widgets + MediaIndex migration)
**Change:** `service-storage-harness`
**Phase:** tasks
**Date:** 2026-07-09
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~1,1801,380 (sum of four implementation slices) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: Harness + QbittorrentSampleStore + client + integration → PR 2: Widget adapter + LineSeriesChart extract + 3 FE widgets → PR 3: MediaIndex migration → PR 4: Cascade-delete wiring |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |
```text
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
```
> **Slice ordering rationale:** Slices 12 prove the harness and deliver all qBittorrent functionality independently of the MediaIndex migration. Slice 3 migrates the load-bearing MediaIndex feature. Slice 4 wires cascade-delete end-to-end. This ordering ensures the two biggest risks (new abstraction × load-bearing refactor) never fire in the same slice — the harness shape is settled in code before MediaIndex touches it.
---
## Slice 1: ServiceDataHarness + QbittorrentSampleStore + QbittorrentClient + integration registration
**Goal:** Build the lifecycle-only storage harness, prove it with the qBittorrent speed-sample store, add the qBittorrent HTTP client and integration registration. No widgets or frontend yet — this slice is backend-only and proves the harness contract with a real consumer.
- [ ] **1.1 Create `ServiceDataHarness` lifecycle module**
- Files: `backend/src/media_library_viewer_api/services/service_data.py` (NEW)
- Lines: ~100
- Dependencies: none
- Details: `StorageConcern` dataclass (`concern_key`, `db_filename`, `migrations: list[str]`, `tables: list[str]`, `service_id_column="service_id"`). `ServiceDataHarness` class with `register(concern)`, `db_path(concern_key)`, `connect(concern_key)` (WAL + busy_timeout 30s), `run_migrations()` (per-concern, per-statement try/except for "duplicate column name"), `cascade_delete(service_id)` (iterate concerns, PRAGMA-check column exists, DELETE WHERE col = ?). Module singleton `get_service_data_harness()` that lazy-inits with `base_dir = BACKEND_CACHE_DIR or ".cache/media_library_viewer"` and calls `run_migrations()`. NO generic value table, NO generic CRUD.
- [ ] **1.2 Create `QbittorrentSampleStore` with concern registration**
- Files: `backend/src/media_library_viewer_api/services/qbittorrent_store.py` (NEW)
- Lines: ~80
- Dependencies: 1.1
- Details: Define `QBITTORRENT_CONCERN` (`db_filename="qbittorrent.db"`, migration creates `qbittorrent_speed_samples(service_id, ts, dl_speed, up_speed)` + index on `(service_id, ts)`, `tables=["qbittorrent_speed_samples"]`). `MAX_SAMPLES = 120`. `QbittorrentSampleStore` class: `append(service_id, ts, dl_speed, up_speed)` (INSERT + prune keeping most recent MAX_SAMPLES for this service_id), `window(service_id, since_ts=None)` (SELECT ordered ASC). Register `QBITTORRENT_CONCERN` with harness on import.
- [ ] **1.3 Create `QbittorrentClient` HTTP client**
- Files: `backend/src/media_library_viewer_api/clients/qbittorrent.py` (NEW)
- Lines: ~90
- Dependencies: none
- Details: `QbittorrentClient(base_url, username, password, timeout=10)` modeled on `JellyfinClient`'s session pattern. `_login()` POSTs to `/auth/login` with `Referer` header, expects `"Ok."` response, stores SID cookie. `_get(path, **params)` auto-logins on first call, re-logins on 403. `maindata()` calls `/sync/maindata` returning `{server_state: {...}, torrents: {hash: {...}}}`. Base URL normalization: append `/api/v2` if not present. Timeout + `requests.RequestException` handling consistent with existing clients.
- [ ] **1.4 Create qBittorrent integration definition**
- Files: `backend/src/media_library_viewer_api/integrations/qbittorrent.py` (NEW)
- Lines: ~55
- Dependencies: 1.1 (for `ServiceConfigBase`, `WidgetConfigBase`, `SecretField`, `ServiceDefinition`, `widget_kind` from `base.py`)
- Details: `QbittorrentConfig(ServiceConfigBase)` with `base_url: ServiceBaseUrl`, `timeout_seconds: int = 10`. `QbittorrentWidgetConfig(WidgetConfigBase)` empty (all three widget kinds derive from service connection). `DEFINITION = ServiceDefinition(service_type="qbittorrent", ...)` with `secret_fields=[username (required), password (required)]` and three `widget_kinds`: `totals` (30s refresh), `active` (15s), `speed` (5s). Model on `integrations/prometheus.py`.
- [ ] **1.5 Register qBittorrent in backend integration registry**
- Files: `backend/src/media_library_viewer_api/integrations/registry.py` (MODIFY)
- Lines: ~5
- Dependencies: 1.4
- Details: Import `DEFINITION as QBITTORRENT` from `integrations/qbittorrent.py`; add `SERVICE_DEFINITIONS["qbittorrent"] = QBITTORRENT`. Verify `list_service_types()` now includes `qbittorrent`.
- [ ] **1.6 Initialize harness in application lifespan**
- Files: `backend/src/media_library_viewer_api/main.py` (MODIFY, lifespan function ~line 41)
- Lines: ~5
- Dependencies: 1.1, 1.2
- Details: In `lifespan()` after `get_settings_store().ensure_defaults()`, add a try/except block calling `get_service_data_harness()` (triggers lazy init + migration run). Log on failure (same pattern as the settings seed block at lines 5154). Import is local inside lifespan to avoid circular import risk.
- [ ] **1.7 Create backend tests for harness + store**
- Files: `backend/tests/test_service_data.py` (NEW)
- Lines: ~110
- Dependencies: 1.1, 1.2
- Details: Test `ServiceDataHarness`: register concern → `run_migrations()` creates table; `cascade_delete(service_id)` removes rows by service_id but leaves other services' rows; migration idempotency (re-run doesn't crash). Test `QbittorrentSampleStore`: `append` + prune (append 130 samples, assert only 120 remain), `window` returns ordered samples, two services don't cross-contaminate. Use `tmp_path` for base_dir isolation.
- [ ] **1.8 Create backend tests for qBittorrent client**
- Files: `backend/tests/test_qbittorrent_client.py` (NEW)
- Lines: ~85
- Dependencies: 1.3
- Details: Test login flow (mock `requests.Session.post``"Ok."` + cookie), cookie reuse on subsequent calls, 403 → re-login flow (first GET returns 403, second returns 200 after re-login), `maindata()` parsing, base URL normalization (appends `/api/v2`), login failure (response ≠ "Ok." raises RuntimeError), timeout handling. Use `unittest.mock.patch` on the session.
- [ ] **1.9 Verify Slice 1 (backend only)**
- Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_service_data.py tests/test_qbittorrent_client.py -v`
- Verify: all new tests pass; existing tests unaffected.
**Slice 1 total:** ~310390 changed lines. Backend-only; no frontend changes.
---
## Slice 2: Widget source adapter + LineSeriesChart extraction + frontend widgets + registry binding
**Goal:** Wire qBittorrent data into the widget system, extract a shared chart renderer, and build the three frontend widgets. This slice touches `PrometheusChartWidget` (extraction) — its existing tests MUST stay green.
- [ ] **2.1 Add `QbittorrentWidgetSource` adapter to `widgets/sources.py`**
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (MODIFY)
- Lines: ~75
- Dependencies: Slice 1 (1.3, 1.2)
- Details: Add `QbittorrentWidgetSource` implementing `WidgetSource.fetch(service, widget_kind, config)`. Resolves client inline from `ServiceRecord` config+secrets (like `PrometheusWidgetSource`). Three branches:
- `totals`: `len(torrents)` + `by_state` dict breakdown. Returns `{total, by_state}`.
- `active`: filter `state in {"downloading","uploading"}`. Returns `{torrents: [{name, state, size, progress, dl_speed, up_speed}]}`.
- `speed`: append sample to `QbittorrentSampleStore(service.id, ts, dl, up)`, read `window(service.id)`, return `{series: [{label:"download", points:[{t: ts*1000, v: dl}]}, {label:"upload", ...}]}` (timestamps ×1000 for JS epoch).
All branches wrap in try/except → `{"error": ...}`. Add `"qbittorrent": QbittorrentWidgetSource()` to `SERVICE_ADAPTERS`.
- [ ] **2.2 Add backend tests for qBittorrent widget adapter**
- Files: `backend/tests/test_widgets.py` (EXTEND)
- Lines: ~90
- Dependencies: 2.1
- Details: Test `QbittorrentWidgetSource` with mocked `QbittorrentClient.maindata()`: totals counts all torrents + breaks down by state; active filters to DL/UL only (assert queued/stalled excluded); speed appends sample and returns `{series}` shape with two labeled series; missing service → `{"error": ...}`; timeout → `{"error": ...}`. Use `tmp_path` harness override for the store.
- [ ] **2.3 Extract shared `LineSeriesChart` component from `PrometheusChartWidget`**
- Files: `frontend/src/components/LineSeriesChart.tsx` (NEW), `frontend/src/widgets/PrometheusChartWidget.tsx` (MODIFY)
- Lines: ~75 (new file) + ~20 (PrometheusChartWidget thin wrapper)
- Dependencies: none (refactor of existing code)
- Details: **RISK ITEM (a).** Extract `mergeSeries`, `formatTime`, `CHART_COLORS`, `ChartSeries`/`SeriesPoint` types, and the `<ResponsiveContainer>+<LineChart>+<XAxis>+<YAxis>+<CartesianGrid>+<Tooltip>+<Line>` JSX from `PrometheusChartWidget.tsx` into `components/LineSeriesChart.tsx`. Props: `{ series: ChartSeries[], height?: number }`. `PrometheusChartWidget` becomes: `useWidgetData``<LineSeriesChart series={data.data.series} />`. Export `ChartSeries` type from `LineSeriesChart` for reuse. **PrometheusChartWidget's existing tests MUST stay green — the extraction is mechanical (move + import), not a rewrite.**
- [ ] **2.4 Create `QbittorrentTotalsWidget` component**
- Files: `frontend/src/widgets/QbittorrentTotalsWidget.tsx` (NEW)
- Lines: ~60
- Dependencies: 2.3
- Details: Renders `{total, by_state}` from `useWidgetData`. Uses `SectionCard` + prominent total count + state breakdown badges (e.g. `downloading: 3`, `uploading: 1`, `paused: 5`). Loading skeleton + error alert + empty state. Model on `BackupsWidget`/`MetricCard` patterns.
- [ ] **2.5 Create `QbittorrentActiveTorrentsWidget` component**
- Files: `frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx` (NEW)
- Lines: ~80
- Dependencies: 2.3
- Details: Renders `{torrents: [...]}` from `useWidgetData`. Uses `SectionCard` + compact table/list (manual `Table` rows, NOT DataTable — keep it simple for ≤10 active torrents). Columns: name, state badge, progress bar, dl/up speed. Scrollable container for overflow. Loading + error + empty ("No active torrents") states. Reuses `humanSize`-style formatting for speeds (bytes/s → MB/s).
- [ ] **2.6 Create `QbittorrentSpeedWidget` component**
- Files: `frontend/src/widgets/QbittorrentSpeedWidget.tsx` (NEW)
- Lines: ~40
- Dependencies: 2.3
- Details: Renders `{series}` from `useWidgetData` — IDENTICAL pattern to `PrometheusChartWidget`. Thin wrapper: `useWidgetData``<LineSeriesChart series={data.data.series} height={220} />`. Loading skeleton + error alert + empty state ("No speed data yet"). Refresh interval from registry binding (5s).
- [ ] **2.7 Add qBittorrent binding to frontend registry**
- Files: `frontend/src/integrations/registry.ts` (MODIFY)
- Lines: ~40
- Dependencies: 2.4, 2.5, 2.6
- Details: Import the three new widget components. Add `qbittorrent` entry to `SERVICE_REGISTRY` with three widget kinds: `totals` (30s, `QbittorrentTotalsWidget`), `active` (15s, `QbittorrentActiveTorrentsWidget`), `speed` (5s, `QbittorrentSpeedWidget`). All with empty config schemas (`{type:"object", properties:{}, required:[]}`). Update barrel `widgets/index.ts` with the three new exports.
- [ ] **2.8 Create frontend tests for new widgets + LineSeriesChart**
- Files: `frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx` (NEW), `frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx` (NEW), `frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx` (NEW), `frontend/src/components/__tests__/LineSeriesChart.test.tsx` (NEW)
- Lines: ~110 (4 files × ~25-30 lines)
- Dependencies: 2.3, 2.4, 2.5, 2.6
- Details: Each widget test: loading skeleton (`data-slot="skeleton"`), error alert (destructive variant), rendered data case. `LineSeriesChart.test.tsx`: renders lines from series data, empty state (no series). Model on existing `PrometheusChartWidget.test.tsx` mock pattern (`vi.mocked(useWidgetData).mockReturnValue(...)`). **Verify `PrometheusChartWidget.test.tsx` still passes after extraction.**
- [ ] **2.9 Verify Slice 2 (full stack)**
- Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_widgets.py -v`
- Run: `cd frontend && npm run lint && npm run build && npx vitest run src/widgets/__tests__/Qbittorrent src/widgets/__tests__/PrometheusChart src/components/__tests__/LineSeriesChart`
- Verify: PrometheusChartWidget tests GREEN (extraction not a regression); qBit widget tests GREEN; build + lint GREEN.
**Slice 2 total:** ~350400 changed lines.
---
## Slice 3: MediaIndex migration onto harness (load-bearing refactor)
**Goal:** Register MediaIndex as a harness concern, add `service_id` scoping to the schema, fix the latent global-clear bug in `replace_items`, and thread `service_id` through query/build/worker. All existing MediaIndex tests MUST pass unchanged.
- [ ] **3.1 Define `MEDIA_INDEX_CONCERN` and register with harness**
- Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY — add concern constant near top), `backend/src/media_library_viewer_api/services/service_data.py` (MODIFY — register concern in `get_service_data_harness`)
- Lines: ~25
- Dependencies: Slice 1 (1.1)
- Details: `MEDIA_INDEX_CONCERN = StorageConcern(concern_key="media_index", db_filename="media_index.sqlite", migrations=["ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''"], tables=["media_items"])`. Register in harness singleton alongside `QBITTORRENT_CONCERN`. The `db_filename` MUST match the existing `DEFAULT_INDEX_PATH` leaf name (`media_index.sqlite`). The harness `base_dir` (`.cache/media_library_viewer`) is the same parent — no file move.
- [ ] **3.2 Add `service_id` column to `init_schema` CREATE TABLE**
- Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `init_schema` ~line 107)
- Lines: ~3
- Dependencies: 3.1
- Details: Add `service_id TEXT NOT NULL DEFAULT ''` as the last column in the `CREATE TABLE IF NOT EXISTS media_items` statement. New installs get the column from `init_schema`; existing DBs get it from the harness ALTER migration. Both paths converge on the same schema.
- [ ] **3.3 Scope `replace_items` by `service_id` (FIXES latent global-clear bug)**
- Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `replace_items` ~line 166)
- Lines: ~20
- Dependencies: 3.2
- Details: **RISK ITEM (b).** Add `service_id: str = ""` parameter. Add `"service_id"` to the columns list. Change `DELETE FROM media_items``DELETE FROM media_items WHERE service_id = ?` with `(service_id,)`. Change the `executemany` rows to prepend `service_id` to each row's values. This FIXES the latent bug where building for one Jellyfin wipes another's rows. Existing tests call `replace_items(rows)` with no `service_id` → defaults to `""` → deletes `WHERE service_id = ''` → backward-compatible (existing test data has `service_id=''` from backfill).
- [ ] **3.4 Scope `query` by `service_id`**
- Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `query` ~line 279)
- Lines: ~10
- Dependencies: 3.2
- Details: Add `service_id: str = ""` parameter. After existing WHERE clause building, add: `if service_id: where.append("service_id = ?"); params.append(service_id)`. When `service_id=""` (empty), skip the filter → shows all rows (backward-compatible). This means existing tests (which pass no `service_id`) see all rows unchanged.
- [ ] **3.5 Thread `service_id` through `build_media_index` → `replace_items`**
- Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `build_media_index` ~line 331)
- Lines: ~5
- Dependencies: 3.3
- Details: Add `service_id: str = ""` parameter to `build_media_index(...)`. Change the `count = index.replace_items(normalized_rows)` call (~line 458) to `count = index.replace_items(normalized_rows, service_id=service_id)`.
- [ ] **3.6 Pass `service_id` from worker to `build_media_index`**
- Files: `backend/src/media_library_viewer_api/workers/media_index_worker.py` (MODIFY, `run_build` ~line 144, `build_media_index` call ~line 168)
- Lines: ~3
- Dependencies: 3.5
- Details: The worker's `run_build(final_index_path, staging_index_path, service_id="")` ALREADY receives `service_id` from argparse (`--service-id`). Change the `build_media_index(...)` call (~line 168) to pass `service_id=service_id`.
- [ ] **3.7 Add `jellyfin_service_id` to `query_media` and pass to `query`**
- Files: `backend/src/media_library_viewer_api/routers/media.py` (MODIFY, `query_media` ~line 276)
- Lines: ~5
- Dependencies: 3.4
- Details: `query_media` does NOT currently have a `jellyfin_service_id` param (unlike `post_build_index` which does). Add `jellyfin_service_id: str | None = None` to the signature (after `offset`). Pass `service_id=jellyfin_service_id or ""` to the `index.query(...)` call (~line 310). Existing callers that don't send the param → `None``""` → all rows (backward-compatible).
- [ ] **3.8 Add backend tests for scoped MediaIndex operations**
- Files: `backend/tests/test_media_index.py` (EXTEND)
- Lines: ~45
- Dependencies: 3.3, 3.4
- Details: Add tests for the NEW scoped behavior: `replace_items(rows, service_id="svc-a")` then `replace_items(other_rows, service_id="svc-b")` — assert svc-a rows survive svc-b's replace (proves the bug fix). `query(service_id="svc-a")` returns only svc-a rows. `query(service_id="")` returns all rows. `query()` (no arg) returns all rows. **Existing MediaIndex tests MUST pass unchanged** — verify they don't break by running the full file.
- [ ] **3.9 Verify Slice 3 (backend, load-bearing)**
- Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_media_index.py -v`
- Run: `cd backend && PYTHONPATH=src python -m pytest -q` (full suite — no regressions)
- Verify: ALL existing MediaIndex tests pass unchanged; new scoped tests pass; full suite green.
**Slice 3 total:** ~200260 changed lines. Backend-only; Media page UX unchanged.
---
## Slice 4: Cascade-delete wiring end-to-end + integration test
**Goal:** Hook `ServiceDataHarness.cascade_delete` into `SettingsStore.delete_service` so removing a service wipes its owned data across all harness-managed tables. End-to-end integration test.
- [ ] **4.1 Wire harness cascade-delete into `delete_service`**
- Files: `backend/src/media_library_viewer_api/services/settings_store.py` (MODIFY, `delete_service` ~line 1742)
- Lines: ~10
- Dependencies: Slice 1 (1.1), Slice 3 (3.1)
- Details: After the existing `DELETE FROM services WHERE id = ?` (line 1754), add a try/except block: `from media_library_viewer_api.services.service_data import get_service_data_harness; get_service_data_harness().cascade_delete(service_id)`. Log on failure (`logger.exception(...)`) — cascade is best-effort (service row is already deleted; data cleanup must not block deletion). Local import to avoid circular dependency.
- [ ] **4.2 Add integration test for cascade-delete**
- Files: `backend/tests/test_services.py` (EXTEND)
- Lines: ~45
- Dependencies: 4.1
- Details: Test end-to-end cascade: (1) create a qBittorrent service, append speed samples for it, delete the service → assert samples gone from `qbittorrent.db`. (2) Create two Jellyfin services, build media for both with distinct `service_id`s, delete one → assert only that service's media rows removed, other survives. Use `tmp_path` for both settings DB and harness base_dir. Verify `delete_service` doesn't raise even if harness fails (best-effort guard).
- [ ] **4.3 Verify Slice 4 (full suite)**
- Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest -q`
- Run: `cd frontend && npm run lint && npm run build`
- Verify: full backend suite green (293+ tests + new); frontend unaffected (no FE changes in this slice).
**Slice 4 total:** ~80110 changed lines. Backend-only.
---
## Integration and acceptance verification
- [ ] **5.1 Full backend test run**
- Run: `cd backend && PYTHONPATH=src python -m pytest -q`
- Verify: all existing tests pass + all new tests (harness, store, client, widget adapter, scoped MediaIndex, cascade-delete) pass.
- [ ] **5.2 Full frontend build + lint**
- Run: `cd frontend && npm run lint && npm run build`
- Verify: no TypeScript errors; no new lint failures; PrometheusChartWidget tests still green after LineSeriesChart extraction.
- [ ] **5.3 Verify qBittorrent end-to-end (manual or integration)**
- Verify: a qBittorrent service can be registered; three widget kinds are bindable; totals shows item count; active shows DL/UL torrents; speed shows a live recharts line chart.
- [ ] **5.4 Verify MediaIndex backward-compat**
- Verify: existing Media page works identically (no `service_id` → sees all rows); building for a specific Jellyfin service scopes correctly.
- [ ] **5.5 Verify cascade-delete**
- Verify: deleting a service removes its qBit samples + media items; deleting one of multiple Jellyfin services removes only its rows.
---
## Total estimate
| Slice | Changed lines | Risk |
|-------|---------------|------|
| Slice 1: Harness + store + client + integration | ~310390 | Low (new code, no existing behavior touched) |
| Slice 2: Widget adapter + LineSeriesChart + FE widgets | ~350400 | Medium (touches PrometheusChartWidget) |
| Slice 3: MediaIndex migration | ~200260 | **High** (load-bearing; must fix replace_items bug + keep tests green) |
| Slice 4: Cascade-delete wiring | ~80110 | Low (small, well-isolated) |
| Integration tests | ~45 | Low |
| **Total** | **~9851,205** | |
---
## Guard lines
```text
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
```
---
## Risk callouts (for reviewer + apply agents)
| # | Risk | Slice | Mitigation |
|---|------|-------|------------|
| **R1** | `LineSeriesChart` extraction breaks `PrometheusChartWidget` tests | S2 (2.3, 2.8) | Extraction is mechanical (move JSX + helpers, pass series as prop). S2 exit gate explicitly verifies `PrometheusChartWidget.test.tsx` still passes. |
| **R2** | MediaIndex migration breaks existing tests or introduces a regression | S3 (3.3, 3.8) | `service_id` defaults to `""`; empty-string queries skip the filter → all rows visible. `replace_items` bug fix changes `DELETE FROM media_items``DELETE WHERE service_id = ?` with `""` default → existing tests (which use `""`) are unaffected. S3 exit gate runs full backend suite. |
| **R3** | ALTER TABLE fails on fresh installs where `init_schema` already created the column | S3 (3.1) | `run_migrations` catches "duplicate column name" per-statement (designed in Slice 1). |
| **R4** | Harness lazy-init hides migration failures on startup | S1 (1.6) | `run_migrations` runs on first access in lifespan; failures raise (only "duplicate column name" is swallowed). |
| **R5** | Circular import between `settings_store` and `service_data` | S4 (4.1) | Cascade-delete uses local import inside `delete_service` method, not module-level. |