// Package store is the durable state of a book project: schema migrations, // the spend ledger (reserve/settle), chunk checkpoints and the request_log — // one SQLite file per project (modernc.org/sqlite, CGO-free, no native // extensions — Р3/Р6). // // Семантика денег и миграций портирована из vojo store.go; сам код написан // заново под SQLite (advisory-локов здесь нет — сериализацию даёт write-пул // из одного соединения + BEGIN IMMEDIATE; 03-implementation-notes §2 п.5). // // Рецепт конкурентности (§3.6): на файл БД два пула — write (MaxOpenConns=1, // транзакции immediate) и read (N соединений); прагмы на каждое соединение // через DSN: WAL, busy_timeout, foreign_keys, synchronous(NORMAL — переживает // kill -9; отключение питания вне модели угроз MVP). package store import ( "context" "database/sql" "fmt" "net/url" "time" _ "modernc.org/sqlite" ) // opTimeout bounds every store operation: SQLite is a local file and // effectively never blocks; the cap keeps a wedged filesystem from hanging a // pipeline goroutine forever. const opTimeout = 10 * time.Second // Store is one project database. type Store struct { w *sql.DB // single-connection write pool; all mutations go through it r *sql.DB // read pool } // Open opens (or creates) the project database at path, applies pending // migrations and recovers stale reservations from a crashed run. func Open(path string) (*Store, error) { dsn := func(txlock string) string { v := url.Values{} v.Add("_pragma", "journal_mode(WAL)") v.Add("_pragma", "busy_timeout(5000)") v.Add("_pragma", "foreign_keys(1)") v.Add("_pragma", "synchronous(NORMAL)") if txlock != "" { v.Set("_txlock", txlock) } return "file:" + path + "?" + v.Encode() } w, err := sql.Open("sqlite", dsn("immediate")) if err != nil { return nil, fmt.Errorf("store: open write pool: %w", err) } // One writer connection: SQLite has a single writer per file anyway; // serializing in Go turns SQLITE_BUSY storms into a plain queue. w.SetMaxOpenConns(1) r, err := sql.Open("sqlite", dsn("")) if err != nil { w.Close() return nil, fmt.Errorf("store: open read pool: %w", err) } r.SetMaxOpenConns(4) s := &Store{w: w, r: r} ctx, cancel := context.WithTimeout(context.Background(), opTimeout) defer cancel() if err := s.migrate(ctx); err != nil { s.Close() return nil, err } if err := s.recoverReservations(ctx); err != nil { s.Close() return nil, err } return s, nil } func (s *Store) Close() error { err1 := s.w.Close() err2 := s.r.Close() if err1 != nil { return err1 } return err2 } func opContext() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), opTimeout) } // recoverReservations zeroes reserved_usd left over by a crashed process. The // project file is owned by ONE process at a time (CLI), so any reservation // present at open belongs to a run that never settled — the recovery pass из // implementation-notes §3.3. func (s *Store) recoverReservations(ctx context.Context) error { res, err := s.w.ExecContext(ctx, `UPDATE spend SET reserved_usd = 0 WHERE reserved_usd > 0`) if err != nil { return fmt.Errorf("store: recover reservations: %w", err) } if n, _ := res.RowsAffected(); n > 0 { // The caller's logger isn't wired here; surface via the request_log-less // path is overkill — a plain stderr note keeps it visible. fmt.Printf("store: recovered %d stale reservation row(s) from a previous run\n", n) } return nil }