textmachine/platform/internal/pgstore/books.go

369 lines
14 KiB
Go

package pgstore
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// ErrNoBook is a book that does not exist, or does not belong to the caller. ONE error for both, on
// purpose: telling them apart would let anyone enumerate other people's libraries.
var ErrNoBook = errors.New("pgstore: no such book")
// ErrBadCursor is a pagination cursor this collection cannot use. Rejecting it is the SERVER's duty
// (contract, NextCursor): the client holds an opaque string and cannot judge it.
var ErrBadCursor = errors.New("pgstore: cursor does not apply to this collection")
// Book is the library row, in contract terms.
type Book struct {
ID string
Title string
SourceLang string
TargetLang string
Genre string
Status string
ChapterCount int
CharacterCount int64
NoteCount int
AddedAt time.Time
Progress Progress
// Revision is the book's own scope. Read on the card, where it orders the client's reads against
// the stream's frames; the LIBRARY has a revision of its own and the two are never compared.
Revision int64
}
// NewBook is one book being registered in the read model.
type NewBook struct {
OwnerID string
Title string
SourceLang string
TargetLang string
Genre string
ChapterCount int
CharacterCount int64
// Workdir is the engine's project directory. The platform reads the book's journal there and
// spawns the engine with it as the working directory; it never writes into it (D39.110).
Workdir string
Now time.Time
}
// AddBook registers a book. `not_started` and not `uploading`: the file is already on disk, and a
// status describing an upload this route never performed would be a lie the library then shows.
func (s *Store) AddBook(ctx context.Context, in NewBook) (string, error) {
id := newID("bk")
const q = `
insert into books (id, owner_id, title, source_lang, target_lang, genre, status,
chapter_count, character_count, added_at, workdir, engine_book_id)
values ($1, $2, $3, $4, $5, $6, 'not_started', $7, $8, $9, $10, $11)`
// engine_book_id is the platform's id until the engine's handshake reports its own. It is not
// left empty: the column is the join back to whatever the engine calls this book, and an empty
// one would read as "the engine has no name for it" rather than "we have not been told yet".
_, err := s.pool.Exec(ctx, q, id, in.OwnerID, in.Title, in.SourceLang, in.TargetLang, in.Genre,
in.ChapterCount, in.CharacterCount, in.Now, in.Workdir, id)
if err != nil {
var pg *pgconn.PgError
if errors.As(err, &pg) && pg.ConstraintName == "books_owner_id_fkey" {
return "", ErrNoAccount
}
return "", fmt.Errorf("pgstore: add book: %w", err)
}
return id, nil
}
// Progress is per phase, in units (contract §Progress).
type Progress struct {
DraftDone, DraftTotal int
EditDone, EditTotal int
// ETASeconds is absent when there is nothing to estimate from.
ETASeconds *int
}
// Run is the run row, in contract terms.
type Run struct {
ID string
BookID string
Revision int64
Status string
VerifyBank bool
CeilingChapters int
PausedReason string
StartedAt time.Time
FinishedAt *time.Time
}
// Library is one page of a user's books plus the revision of the library scope itself.
type Library struct {
Revision int64
Books []Book
NextCursor string
}
// ListBooks returns one page of the library, newest first.
//
// Keyset, not offset: the library is written to while it is read (a run bumps its book's revision on
// every materialized transaction), and an offset page silently skips or repeats rows under that.
func (s *Store) ListBooks(ctx context.Context, userID string, limit int, cursor string) (Library, error) {
if limit <= 0 || limit > maxPage {
limit = defaultPage
}
after, err := decodeCursor(userID, cursor)
if err != nil {
return Library{}, err
}
const q = `
select b.id, b.title, b.source_lang, b.target_lang, b.genre, b.status, b.chapter_count,
b.character_count, b.note_count, b.added_at,
coalesce(r.draft_done, 0), coalesce(r.draft_total, 0),
coalesce(r.edit_done, 0), coalesce(r.edit_total, 0), r.eta_seconds
from books b
left join lateral (
select draft_done, draft_total, edit_done, edit_total, eta_seconds
from runs where book_id = b.id order by started_at desc, id desc limit 1) r on true
where b.owner_id = $1
and ($2::timestamptz is null or (b.added_at, b.id) < ($2::timestamptz, $3::text))
order by b.added_at desc, b.id desc
limit $4`
rows, err := s.pool.Query(ctx, q, userID, after.time, after.id, limit+1)
if err != nil {
return Library{}, fmt.Errorf("pgstore: list books: %w", err)
}
defer rows.Close()
var out Library
for rows.Next() {
var b Book
if err := rows.Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Genre, &b.Status,
&b.ChapterCount, &b.CharacterCount, &b.NoteCount, &b.AddedAt,
&b.Progress.DraftDone, &b.Progress.DraftTotal, &b.Progress.EditDone, &b.Progress.EditTotal,
&b.Progress.ETASeconds); err != nil {
return Library{}, fmt.Errorf("pgstore: scan book: %w", err)
}
out.Books = append(out.Books, b)
}
if err := rows.Err(); err != nil {
return Library{}, fmt.Errorf("pgstore: list books: %w", err)
}
// One row was asked for beyond the page so that "is there a next page" is answered by data
// rather than by "the page came back full", which is wrong exactly when the last page is full.
if len(out.Books) > limit {
last := out.Books[limit-1]
out.Books = out.Books[:limit]
out.NextCursor = encodeCursor(userID, last.AddedAt, last.ID)
}
// The revision of the LIBRARY scope — membership and statuses — and never compared with the
// revision of a book (contract §Revision).
if err := s.pool.QueryRow(ctx,
`select coalesce(max(revision), 0) from books where owner_id = $1`, userID).Scan(&out.Revision); err != nil {
return Library{}, fmt.Errorf("pgstore: library revision: %w", err)
}
return out, nil
}
const (
defaultPage = 100
maxPage = 1000
)
// GetBook returns the book card: the book and its current or last run.
func (s *Store) GetBook(ctx context.Context, userID, bookID string) (Book, *Run, error) {
var b Book
const q = `
select id, title, source_lang, target_lang, genre, status, chapter_count, character_count,
note_count, added_at, revision
from books where id = $1 and owner_id = $2`
err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang,
&b.Genre, &b.Status, &b.ChapterCount, &b.CharacterCount, &b.NoteCount, &b.AddedAt, &b.Revision)
if errors.Is(err, pgx.ErrNoRows) {
return Book{}, nil, ErrNoBook
}
if err != nil {
return Book{}, nil, fmt.Errorf("pgstore: read book: %w", err)
}
run, err := s.lastRun(ctx, bookID)
if err != nil {
return Book{}, nil, err
}
if run != nil {
b.Progress = run.progress
return b, &run.Run, nil
}
return b, nil, nil
}
type runWithProgress struct {
Run
progress Progress
}
func (s *Store) lastRun(ctx context.Context, bookID string) (*runWithProgress, error) {
const q = `
select id, book_id, revision, status, verify_bank, ceiling_chapters,
coalesce(paused_reason, ''), started_at, finished_at,
draft_done, draft_total, edit_done, edit_total, eta_seconds
from runs where book_id = $1 order by started_at desc, id desc limit 1`
var r runWithProgress
err := s.pool.QueryRow(ctx, q, bookID).Scan(&r.ID, &r.BookID, &r.Revision, &r.Status, &r.VerifyBank,
&r.CeilingChapters, &r.PausedReason, &r.StartedAt, &r.FinishedAt,
&r.progress.DraftDone, &r.progress.DraftTotal, &r.progress.EditDone, &r.progress.EditTotal,
&r.progress.ETASeconds)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("pgstore: read run: %w", err)
}
return &r, nil
}
// BookRunContext is what starting a run needs to know about a book, read under the caller's
// ownership so a stranger's book is indistinguishable from a missing one.
type BookRunContext struct {
Workdir string
// ChaptersLeft is what is still untranslated, and it clamps the ceiling scale. Until the engine
// persists a chapter manifest (unified backlog row 100) the platform knows the chapter COUNT
// from intake and nothing per chapter, so a book with no materialized chapters answers with its
// whole length — which is right for a book that has never run.
ChaptersLeft int
HasLiveRun bool
}
// ReadBookForRun gathers the facts a run start is judged on.
func (s *Store) ReadBookForRun(ctx context.Context, userID, bookID string) (BookRunContext, error) {
const q = `
select b.workdir,
b.chapter_count - (select count(*) from chapters c
where c.book_id = b.id and c.units_total > 0
and c.units_done >= c.units_total),
exists (select 1 from runs r where r.book_id = b.id and r.finished_at is null)
from books b where b.id = $1 and b.owner_id = $2`
var out BookRunContext
err := s.pool.QueryRow(ctx, q, bookID, userID).Scan(&out.Workdir, &out.ChaptersLeft, &out.HasLiveRun)
if errors.Is(err, pgx.ErrNoRows) {
return BookRunContext{}, ErrNoBook
}
if err != nil {
return BookRunContext{}, fmt.Errorf("pgstore: read book for run: %w", err)
}
if out.ChaptersLeft < 0 {
out.ChaptersLeft = 0
}
return out, nil
}
// Usage is the credit state, in the only form that crosses the boundary: a share, never a sum
// (D39.84, contract §Usage).
type Usage struct {
// RemainingPercent is the balance as a share of everything ever granted to the account. Grants,
// not "a limit of a period": there are no periods (contract §/usage).
RemainingPercent int
// Spendable is whether there is anything left AT ALL. Separate from the percentage because the
// percentage is floored: $9 of a $1000 grant rounds to 0%, and "0%" is not the same fact as
// "nothing can be started".
Spendable bool
// PausedReason is set when the ACCOUNT is in a halted state.
PausedReason string
}
// ReadUsage computes the credit state.
func (s *Store) ReadUsage(ctx context.Context, userID string) (Usage, error) {
// The halted state is read off the LATEST run of each book, and NOT off "a run that has not
// finished". The platform's own pause path ends the run in the same statement that pauses it
// (PauseRun), so a condition on finished_at answered null exactly when the reconciler — rather
// than a stream event — was what paused the account.
const q = `
select coalesce((select balance_micro_usd from account_balances where user_id = $1), 0),
coalesce((select sum(amount_micro_usd) from credit_ledger
where user_id = $1 and kind = 'grant'), 0),
exists (
select 1 from books b
join lateral (select status, paused_reason from runs
where book_id = b.id order by started_at desc, id desc limit 1) r on true
where b.owner_id = $1 and r.status = 'paused' and r.paused_reason = 'credit_exhausted')
from users where id = $1`
var balance, granted int64
var halted bool
err := s.pool.QueryRow(ctx, q, userID).Scan(&balance, &granted, &halted)
if errors.Is(err, pgx.ErrNoRows) {
return Usage{}, ErrNoAccount
}
if err != nil {
return Usage{}, fmt.Errorf("pgstore: read usage: %w", err)
}
u := Usage{Spendable: balance > 0}
switch {
case granted <= 0:
// Nothing was ever granted. Zero out of zero is not 100% available; an account that cannot
// spend anything is exhausted, and saying so is what makes the screen actionable.
u.RemainingPercent = 0
case balance <= 0:
u.RemainingPercent = 0
case balance >= granted:
// A correction can push the balance above the sum of grants. The share is still capped: the
// contract's field is 0..100 and a client that got 137 would have to invent a meaning.
u.RemainingPercent = 100
default:
// Rounded DOWN so "1%" never means "already nothing": the figure is what is LEFT.
u.RemainingPercent = int(balance * 100 / granted)
}
if halted {
u.PausedReason = PausedCreditExhausted
}
return u, nil
}
// PausedCreditExhausted is the contract's only PausedReason value today.
const PausedCreditExhausted = "credit_exhausted"
// cursorPos is the keyset position a cursor encodes.
type cursorPos struct {
time *time.Time
id *string
}
// encodeCursor renders an opaque cursor. Opaque means the client MUST NOT parse it, not that it is
// secret: it carries nothing the same client cannot already see in the row it came from.
//
// It is BOUND to the collection it came from — the owner whose library produced it. Rejecting a
// cursor that does not apply is the SERVER's duty (contract §NextCursor) and the client cannot
// perform it, because the token is opaque to it by construction; without the binding, a page token
// handed to the wrong library was silently accepted and answered with someone else's window.
func encodeCursor(owner string, t time.Time, id string) string {
return base64.RawURLEncoding.EncodeToString(
[]byte(scopeTag(owner) + "\x00" + t.UTC().Format(time.RFC3339Nano) + "\x00" + id))
}
// scopeTag identifies the collection without carrying its identifier: the cursor travels to the
// client, and an account id in it would be one more place the id exists.
func scopeTag(owner string) string {
sum := sha256.Sum256([]byte("library\x00" + owner))
return base64.RawURLEncoding.EncodeToString(sum[:8])
}
func decodeCursor(owner, s string) (cursorPos, error) {
if s == "" {
return cursorPos{}, nil
}
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return cursorPos{}, ErrBadCursor
}
scope, rest, ok := strings.Cut(string(raw), "\x00")
if !ok || scope != scopeTag(owner) {
return cursorPos{}, ErrBadCursor
}
stamp, id, ok := strings.Cut(rest, "\x00")
if !ok || id == "" {
return cursorPos{}, ErrBadCursor
}
t, err := time.Parse(time.RFC3339Nano, stamp)
if err != nil {
return cursorPos{}, ErrBadCursor
}
return cursorPos{time: &t, id: &id}, nil
}