package store import "database/sql" // 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 } // ReplaceRubyReadings replaces a book's ENTIRE ruby set with rows in one // transaction (DELETE-by-book, then INSERT each). The caller (persistRuby) // recomputes the whole-book aggregate on every ingest, so a full replace is the // correct idempotency model: re-ingesting the identical source rewrites identical // rows; a source edit converges every column to the truth — including a pair the // edit REMOVED, which a prior upsert-only path left as a permanent phantom (a // name-lock on a name no longer in the book — external-review #6). Scoped to // book_id, so other books are untouched. Each row's BookID must equal bookID. func (s *Store) ReplaceRubyReadings(bookID string, rows []RubyReading) error { ctx, cancel := opContext() defer cancel() tx, err := s.w.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `DELETE FROM ruby_readings WHERE book_id = ?`, bookID); err != nil { return err } for _, rr := range rows { if _, err := tx.ExecContext(ctx, ` INSERT INTO ruby_readings (book_id, base, reading, first_chapter, occurrences) VALUES (?, ?, ?, ?, ?)`, rr.BookID, rr.Base, rr.Reading, rr.FirstChapter, rr.Occurrences); err != nil { return err } } return tx.Commit() } // 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) { return queryAll(s.r, ` SELECT base, reading, first_chapter, occurrences FROM ruby_readings WHERE book_id = ? ORDER BY first_chapter, base, reading`, func(rows *sql.Rows) (RubyReading, error) { rr := RubyReading{BookID: bookID} err := rows.Scan(&rr.Base, &rr.Reading, &rr.FirstChapter, &rr.Occurrences) return rr, err }, bookID) }