package store // ruby.go: the ruby/furigana readings captured on ingest (шаг 3a, v4 schema). // This is CAPTURE-ONLY storage — the reading layer that epub-v1 text extraction // would otherwise silently drop (04-unhappy §4 / D9). Memory v2 (шаг 4) reads it // to seed a glossary name-lock; nothing here injects into a prompt, so no field // is snapshot- or wire-load-bearing (it never touches request_hash). // // The store is dumb storage (mirrors chunkstatus.go): the aggregation "one row per // (base,reading) with first_chapter=MIN and occurrences=full-book count" happens in // the pipeline (ingest.go); this layer only upserts and reads back. // RubyReading is one (book, base, reading) row. type RubyReading struct { BookID string Base string // the ruby body (kanji/base surface form) Reading string // the reading (furigana) FirstChapter int // MIN 1-based chapter the pair appears in Occurrences int // full-book count of the pair } // UpsertRubyReading writes (or converges) one ruby row. Idempotent by design: the // caller (persistRuby) recomputes the WHOLE-book aggregate on every ingest, so it // passes the authoritative first_chapter (the full-book MIN) and occurrences (the // full-book count) already. Both columns are therefore REPLACED on conflict — // re-ingesting the identical source rewrites identical values (idempotent), and a // source edit that moves the pair's first appearance re-converges to the truth. A // MIN-merge here would instead pin first_chapter to a stale-early chapter the pair // no longer occupies after such an edit (self-review finding), asymmetric with // occurrences. Callers pass one row per (base,reading); the ON CONFLICT only fires // across separate ingests of the same book. func (s *Store) UpsertRubyReading(rr RubyReading) error { ctx, cancel := opContext() defer cancel() _, err := s.w.ExecContext(ctx, ` INSERT INTO ruby_readings (book_id, base, reading, first_chapter, occurrences) VALUES (?, ?, ?, ?, ?) ON CONFLICT (book_id, base, reading) DO UPDATE SET first_chapter = excluded.first_chapter, occurrences = excluded.occurrences`, rr.BookID, rr.Base, rr.Reading, rr.FirstChapter, rr.Occurrences) return err } // RubyReadingsForBook returns every captured reading for a book, ordered // deterministically (first_chapter, base, reading) for a stable report/consumer. func (s *Store) RubyReadingsForBook(bookID string) ([]RubyReading, error) { ctx, cancel := opContext() defer cancel() rows, err := s.r.QueryContext(ctx, ` SELECT base, reading, first_chapter, occurrences FROM ruby_readings WHERE book_id = ? ORDER BY first_chapter, base, reading`, bookID) if err != nil { return nil, err } defer rows.Close() var out []RubyReading for rows.Next() { rr := RubyReading{BookID: bookID} if err := rows.Scan(&rr.Base, &rr.Reading, &rr.FirstChapter, &rr.Occurrences); err != nil { return nil, err } out = append(out, rr) } return out, rows.Err() }