feat(issues): the honest processing-issues ledger — row-less import failures, self-healing resolution, family lens (faceted plan Phase 11)

processing_issues only recorded cross-library move rejections; files
that failed to import vanished into an error counter and stdout. The
ledger now records every failure class and heals itself:

Schema (in place, pre-release boundary):
- media_item_id NULLABLE + file_path TEXT (deliberately unbounded —
  an over-long path is itself a failure mode; a VARCHAR(500) ledger
  column died on the exact 22001 it existed to report — caught by the
  new test). Row-less failures key on (library, file_path); item-backed
  on (item, type). Widened dedup = COALESCE-expression unique index;
  the old table UNIQUE stays (NULLs pairwise-distinct for row-less).
- Conflict-target upsert: repeat failures UPDATE the same row and
  REOPEN resolved ones — never duplicates.

Wirings:
- CreateMediaItem failure (single-file + multifile createAudioBookItem)
  → row-less import_failed (error).
- Extraction hard-fail with fallback import → item-backed
  metadata_extraction_failed (warning).
- Change-path re-extraction failure (the ev/sidecar guard) → item-backed
  metadata_extraction_failed; LogProcessingIssue keeps its signature, a
  row-less helper joins it.

Auto-resolution:
- Successful import → ResolveOpenProcessingIssuesForPath (library,
  file_path).
- Successful (re)extraction → ResolveOpenProcessingIssuesForItem
  (single-file change path, multifile editorial apply).
- Unchanged-skip scans do NOT resolve: extraction never re-ran, so the
  degraded metadata is still degraded — the page heals exactly when the
  book does.

Family lens on ALL issues surfaces (list, stats, resolve, delete, SSR
page): explicit out-of-set library id → 403, never silent. This
tightens one pre-lens behavior — a nonexistent library id returned
200-empty, now 403 (pinned, changelog entry in docs/contract_pins.md).
List is LEFT-JOINed so row-less rows appear (media_item_id "",
title falls back to the path).

Tests: full lifecycle state machine through real scans (fail → appears,
repeat → no dups, success-at-path → resolved, LEFT-JOIN list shape);
family lens 403s with a fresh non-shared admin; all 7 pre-existing
issue suites green (two expectations widened to the lens, changelogged);
adjacent suites (CrossLibraryMove, Audiobook, Calibre,
ExtractorVersion, SchemaIdempotency) green; unit suites green.
This commit is contained in:
John O'Keefe committed 2026-10-20 19:38:02 -04:00
1 parent 19b9c7c9b8
commit 9b5defcb31
50 files changed
+1244 -681

No files matched your search

+90 -10
View File
@@ -1581,14 +1581,14 @@ func (q *Queries) CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateO
const CreateProcessingIssue = `-- name: CreateProcessingIssue :one
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (media_item_id, issue_type)
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (COALESCE(media_item_id, '00000000-0000-0000-0000-000000000000'::uuid), library_id, issue_type, COALESCE(file_path, ''))
DO UPDATE SET issue_description = EXCLUDED.issue_description,
severity = EXCLUDED.severity,
resolved = false,
resolved_at = NULL
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
`
type CreateProcessingIssueParams struct {
@@ -1597,11 +1597,17 @@ type CreateProcessingIssueParams struct {
IssueType string `db:"issue_type" json:"issue_type"`
IssueDescription string `db:"issue_description" json:"issue_description"`
Severity string `db:"severity" json:"severity"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
}
// ============================================================================
// PROCESSING ISSUES QUERIES
// ============================================================================
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
// are nullable — item-backed rows key on (item, library, type, ”),
// row-less import failures on (zero-uuid, library, type, path). The
// conflict target matches idx_processing_issues_dedup's expressions.
// A repeat failure REOPENS a resolved row (resolved=false).
func (q *Queries) CreateProcessingIssue(ctx context.Context, arg CreateProcessingIssueParams) (ProcessingIssues, error) {
row := q.db.QueryRow(ctx, CreateProcessingIssue,
arg.MediaItemID,
@@ -1609,6 +1615,7 @@ func (q *Queries) CreateProcessingIssue(ctx context.Context, arg CreateProcessin
arg.IssueType,
arg.IssueDescription,
arg.Severity,
arg.FilePath,
)
var i ProcessingIssues
err := row.Scan(
@@ -1621,6 +1628,7 @@ func (q *Queries) CreateProcessingIssue(ctx context.Context, arg CreateProcessin
&i.Resolved,
&i.ResolvedAt,
&i.CreatedAt,
&i.FilePath,
)
return i, err
}
@@ -2201,7 +2209,7 @@ func (q *Queries) DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewPa
const DeleteProcessingIssue = `-- name: DeleteProcessingIssue :one
DELETE FROM processing_issues
WHERE id = $1
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
`
func (q *Queries) DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error) {
@@ -2217,6 +2225,7 @@ func (q *Queries) DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (Pr
&i.Resolved,
&i.ResolvedAt,
&i.CreatedAt,
&i.FilePath,
)
return i, err
}
@@ -8506,6 +8515,29 @@ func (q *Queries) GetPopularBooks(ctx context.Context, arg GetPopularBooksParams
return items, nil
}
const GetProcessingIssue = `-- name: GetProcessingIssue :one
SELECT id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path FROM processing_issues WHERE id = $1
`
// Single-issue read for the resolve/delete endpoints' family-lens gate.
func (q *Queries) GetProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error) {
row := q.db.QueryRow(ctx, GetProcessingIssue, id)
var i ProcessingIssues
err := row.Scan(
&i.ID,
&i.MediaItemID,
&i.LibraryID,
&i.IssueType,
&i.IssueDescription,
&i.Severity,
&i.Resolved,
&i.ResolvedAt,
&i.CreatedAt,
&i.FilePath,
)
return i, err
}
const GetProcessingIssueStats = `-- name: GetProcessingIssueStats :one
SELECT
COUNT(*) FILTER (WHERE severity = 'error' AND resolved = false) as error_count,
@@ -12813,12 +12845,12 @@ SELECT
pi.resolved,
pi.resolved_at,
pi.created_at,
mi.title,
mi.file_path,
mi.format_group,
COALESCE(mi.title, '') AS title,
COALESCE(mi.file_path, pi.file_path, '') AS file_path,
COALESCE(mi.format_group, '') AS format_group,
lt.name as library_type_name
FROM processing_issues pi
JOIN media_items mi ON pi.media_item_id = mi.id
LEFT JOIN media_items mi ON pi.media_item_id = mi.id
JOIN libraries l ON pi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
WHERE pi.library_id = $1
@@ -12847,6 +12879,10 @@ type ListProcessingIssuesByLibraryRow struct {
LibraryTypeName string `db:"library_type_name" json:"library_type_name"`
}
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
// row — an inner join would silently drop the ledger's newest kind.
// Item-side projections COALESCE to keep them non-nullable strings;
// the display file path falls back to the issue's own for row-less rows.
func (q *Queries) ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error) {
rows, err := q.db.Query(ctx, ListProcessingIssuesByLibrary, libraryID)
if err != nil {
@@ -13629,13 +13665,56 @@ func (q *Queries) ResolveHashConflict(ctx context.Context, arg ResolveHashConfli
return err
}
const ResolveOpenProcessingIssuesForItem = `-- name: ResolveOpenProcessingIssuesForItem :execrows
UPDATE processing_issues
SET resolved = true,
resolved_at = NOW()
WHERE media_item_id = $1
AND resolved = false
`
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
// item successfully closes its open issues (extraction failures heal).
func (q *Queries) ResolveOpenProcessingIssuesForItem(ctx context.Context, mediaItemID pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, ResolveOpenProcessingIssuesForItem, mediaItemID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const ResolveOpenProcessingIssuesForPath = `-- name: ResolveOpenProcessingIssuesForPath :execrows
UPDATE processing_issues
SET resolved = true,
resolved_at = NOW()
WHERE media_item_id IS NULL
AND library_id = $1
AND file_path = $2
AND resolved = false
`
type ResolveOpenProcessingIssuesForPathParams struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
}
// Row-less issues resolve by (library, file_path) when the file finally
// imports — the page heals itself without admin action.
func (q *Queries) ResolveOpenProcessingIssuesForPath(ctx context.Context, arg ResolveOpenProcessingIssuesForPathParams) (int64, error) {
result, err := q.db.Exec(ctx, ResolveOpenProcessingIssuesForPath, arg.LibraryID, arg.FilePath)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const ResolveProcessingIssue = `-- name: ResolveProcessingIssue :one
UPDATE processing_issues
SET resolved = true,
resolved_at = NOW()
WHERE id = $1
AND media_item_id = $2
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at
RETURNING id, media_item_id, library_id, issue_type, issue_description, severity, resolved, resolved_at, created_at, file_path
`
type ResolveProcessingIssueParams struct {
@@ -13656,6 +13735,7 @@ func (q *Queries) ResolveProcessingIssue(ctx context.Context, arg ResolveProcess
&i.Resolved,
&i.ResolvedAt,
&i.CreatedAt,
&i.FilePath,
)
return i, err
}