704 lines
30 KiB
Go
704 lines
30 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 := NewBookID()
|
|
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, revision)
|
|
values ($1, $2, $3, $4, $5, $6, 'not_started', $7, $8, $9, $10, $11, ` + nextLibraryRevision + `)`
|
|
// 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
|
|
}
|
|
|
|
// NewBookID mints a book identifier. Exported because the intake needs one BEFORE the row exists:
|
|
// the book's directory is named after it, and the row records that directory.
|
|
func NewBookID() string { return newID("bk") }
|
|
|
|
// nextLibraryRevision is the revision a book JOINS the library with, as a SQL fragment over $2 (the
|
|
// owner).
|
|
//
|
|
// The library's revision is derived as the maximum over the account's books, and the contract asks
|
|
// a client to DROP a read whose revision is not above what it already applied. A book inserted at
|
|
// revision 0 therefore left the library's number unchanged, and the screen that had just uploaded
|
|
// it dropped the very read that carried it (register row PD-122). Starting above the account's
|
|
// current maximum is what makes an addition visible.
|
|
//
|
|
// It is the greatest of BOTH halves the library's own revision is read from — the maximum over the
|
|
// books and the account's floor — because taking only the maximum reproduced the very defect the
|
|
// floor exists to prevent, one step later: after a cancelled upload raised the floor above the
|
|
// maximum, the NEXT book joined below it, and the library then answered the same number for three
|
|
// different states of itself. Found by acceptance; the first version of this constant read only the
|
|
// maximum.
|
|
//
|
|
// Two concurrent additions can compute the same number, and that is harmless: the value still rises,
|
|
// so the client still refetches and still sees both. What stays open in PD-122 is the account-scope
|
|
// counter itself — one writer for membership and statuses — which is what would let two SIMULTANEOUS
|
|
// writers get distinct numbers instead of merely rising ones.
|
|
const nextLibraryRevision = `(select greatest(
|
|
coalesce((select max(revision) from books where owner_id = $2), 0),
|
|
coalesce((select library_revision from users where id = $2), 0)) + 1)`
|
|
|
|
// nextRevisionOfThisBooksLibrary is the same number for a statement that has the book but not its
|
|
// owner: the owner is read from the row being updated.
|
|
//
|
|
// ⚠ A status change may NOT simply do `revision + 1`, and that is the same defect as the one above
|
|
// wearing different clothes. A book whose own counter sits below the account's floor — it was
|
|
// uploaded before another upload was cancelled — would walk `uploading → parsing → not_started`
|
|
// three increments deep and still be under the floor, so `greatest(max, floor)` would not move at
|
|
// all. A client obeying the contract drops every read that is not ABOVE what it applied, and the
|
|
// screen that uploaded the book would keep showing it as arriving until some unrelated event pushed
|
|
// the number. Taking the library's own next number instead makes every status visible exactly once.
|
|
const nextRevisionOfThisBooksLibrary = `(select greatest(
|
|
coalesce((select max(b.revision) from books b where b.owner_id = books.owner_id), 0),
|
|
coalesce((select u.library_revision from users u where u.id = books.owner_id), 0)) + 1)`
|
|
|
|
// NewUpload is one book arriving through the contract's intake, before its file has been received.
|
|
type NewUpload struct {
|
|
OwnerID string
|
|
Title string
|
|
SourceLang string
|
|
TargetLang string
|
|
Genre string
|
|
// Workdir is the engine project directory this book will own. It is named after the book id and
|
|
// created before the row, so a row always points at a directory that exists.
|
|
Workdir string
|
|
Now time.Time
|
|
}
|
|
|
|
// CreateUpload registers a book whose file is still arriving.
|
|
//
|
|
// `uploading` is a real, observable state and not a formality: the row is written BEFORE the body is
|
|
// read, so a 60 MB upload is visible in the library — with its languages, without its size — while
|
|
// it is still on the wire. The alternative, inserting once the bytes have landed, gives the status
|
|
// no writer at all and makes a half-finished upload invisible to everything, including the sweep
|
|
// that has to clean it up.
|
|
func (s *Store) CreateUpload(ctx context.Context, id string, in NewUpload) (Book, error) {
|
|
const q = `
|
|
insert into books (id, owner_id, title, source_lang, target_lang, genre, status,
|
|
added_at, workdir, engine_book_id, revision)
|
|
values ($1, $2, $3, $4, $5, $6, 'uploading', $7, $8, $1, ` + nextLibraryRevision + `)
|
|
returning id, title, source_lang, target_lang, genre, status, chapter_count, character_count,
|
|
note_count, added_at, revision`
|
|
var b Book
|
|
err := s.pool.QueryRow(ctx, q, id, in.OwnerID, in.Title, in.SourceLang, in.TargetLang, in.Genre,
|
|
in.Now, in.Workdir).
|
|
Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Genre, &b.Status, &b.ChapterCount,
|
|
&b.CharacterCount, &b.NoteCount, &b.AddedAt, &b.Revision)
|
|
if err != nil {
|
|
var pg *pgconn.PgError
|
|
if errors.As(err, &pg) && pg.ConstraintName == "books_owner_id_fkey" {
|
|
return Book{}, ErrNoAccount
|
|
}
|
|
return Book{}, fmt.Errorf("pgstore: create upload: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// StartParsing hands a received file to the parser: `uploading → parsing`, with the size the intake
|
|
// counted while the bytes went past.
|
|
//
|
|
// Conditional on the status, so an upload the sweep has already given up on cannot be resurrected by
|
|
// a request that finally finished.
|
|
//
|
|
// enqueue joins the caller's transaction for the same reason the run admission's does: a status that
|
|
// says "being parsed" with no job to do it waits for the backstop sweep's grace, and a job for a
|
|
// book that never became `parsing` is a worker with nothing to claim.
|
|
func (s *Store) StartParsing(ctx context.Context, id string, characters int64,
|
|
enqueue func(context.Context, Tx, string) error) (Book, error) {
|
|
var b Book
|
|
err := s.inTx(ctx, func(tx pgx.Tx) error {
|
|
const q = `
|
|
update books set status = 'parsing', character_count = $2,
|
|
revision = ` + nextRevisionOfThisBooksLibrary + `
|
|
where id = $1 and status = 'uploading'
|
|
returning id, title, source_lang, target_lang, genre, status, chapter_count,
|
|
character_count, note_count, added_at, revision`
|
|
err := tx.QueryRow(ctx, q, id, characters).
|
|
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 ErrNoBook
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: start parsing: %w", err)
|
|
}
|
|
if enqueue != nil {
|
|
return enqueue(ctx, tx, id)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Book{}, err
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// ParseClaim is one book this process may parse.
|
|
type ParseClaim struct {
|
|
BookID string
|
|
Workdir string
|
|
Attempts int
|
|
// At is the stamp this claim was taken with. Every write that ENDS the parse carries it back, so
|
|
// a pass whose claim was taken over by another cannot record a verdict about work it is no longer
|
|
// doing — the same re-check-under-the-write the money paths use.
|
|
At time.Time
|
|
}
|
|
|
|
// ErrParseClaimed is a parse someone else is already doing, or a book that is no longer waiting for
|
|
// one. The ordinary case, not a failure: the queue job and the backstop sweep both aim at the same
|
|
// book on purpose.
|
|
var ErrParseClaimed = errors.New("pgstore: the parse of this book is already claimed")
|
|
|
|
// ClaimParse takes the right to parse a book, once.
|
|
//
|
|
// A compare-and-set rather than a plain read, for the same reason the spawn claim is one: the queue
|
|
// worker and the backstop sweep legitimately arrive together, and two `tmctl manifest` processes on
|
|
// one project directory would be two writers of one SQLite file — which is exactly what the engine's
|
|
// exclusive lock exists to refuse, noisily and after the work.
|
|
//
|
|
// staleAfter is what makes the claim recoverable: a process that died holding one leaves a stamp
|
|
// nothing clears, and without a grace the book would wait for parsing forever.
|
|
func (s *Store) ClaimParse(ctx context.Context, id string, now, staleBefore time.Time) (ParseClaim, error) {
|
|
const q = `
|
|
update books set parse_started_at = $2, parse_attempts = parse_attempts + 1
|
|
where id = $1 and status = 'parsing'
|
|
and (parse_started_at is null or parse_started_at < $3)
|
|
returning id, workdir, parse_attempts`
|
|
var c ParseClaim
|
|
err := s.pool.QueryRow(ctx, q, id, now, staleBefore).Scan(&c.BookID, &c.Workdir, &c.Attempts)
|
|
c.At = now
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ParseClaim{}, ErrParseClaimed
|
|
}
|
|
if err != nil {
|
|
return ParseClaim{}, fmt.Errorf("pgstore: claim parse: %w", err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// RefundParseAttempt gives back an attempt that was claimed but never spent on the engine.
|
|
//
|
|
// The budget exists to bound how many times a broken HOST is asked to parse a book, and a book that
|
|
// is only waiting for its configuration asks nobody: counting those claims meant the budget ran out
|
|
// while nothing had been tried, and the first real answer from the engine was terminal the moment it
|
|
// arrived. Conditional on the claim, like every other write that ends a pass.
|
|
func (s *Store) RefundParseAttempt(ctx context.Context, id string, claimedAt time.Time) error {
|
|
_, err := s.pool.Exec(ctx, `
|
|
update books set parse_attempts = greatest(parse_attempts - 1, 0)
|
|
where id = $1 and status = 'parsing' and parse_started_at = $2`, id, claimedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: refund parse attempt: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParsedBook is what the engine's manifest told the platform about a book it has now cut.
|
|
type ParsedBook struct {
|
|
Chapters int
|
|
SourceSHA256 []byte
|
|
ChunkerVersion string
|
|
}
|
|
|
|
// FinishParse records a parsed book: `parsing → not_started`.
|
|
//
|
|
// Conditional on the CLAIM still being this caller's: a pass whose claim was taken over is not the
|
|
// pass whose answer counts, and two answers about one book are one answer too many.
|
|
func (s *Store) FinishParse(ctx context.Context, id string, claimedAt time.Time, in ParsedBook) error {
|
|
const q = `
|
|
update books set status = 'not_started', chapter_count = $2, source_sha256 = $3,
|
|
chunker_version = $4, parse_started_at = null,
|
|
revision = ` + nextRevisionOfThisBooksLibrary + `
|
|
where id = $1 and status = 'parsing' and parse_started_at = $5`
|
|
tag, err := s.pool.Exec(ctx, q, id, in.Chapters, in.SourceSHA256, in.ChunkerVersion, claimedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: finish parse: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNoBook
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RejectBook is the terminal end of an intake that failed: `parsing → rejected`.
|
|
//
|
|
// The reason is the platform's own word for the class of failure and is not projected — contract v0
|
|
// has no field for it (migration 00013).
|
|
//
|
|
// Conditional on the CLAIM, like FinishParse and for a sharper reason: rejecting is destructive —
|
|
// the book's source goes with it — and a pass whose claim was stolen may be holding an exit code
|
|
// that says nothing about the book. The engine maps every failure onto exit 1, including a lock held
|
|
// by the parse that took the claim over.
|
|
func (s *Store) RejectBook(ctx context.Context, id string, claimedAt time.Time, reason string) error {
|
|
const q = `
|
|
update books set status = 'rejected', reject_reason = $2, parse_started_at = null,
|
|
revision = ` + nextRevisionOfThisBooksLibrary + `
|
|
where id = $1 and status = 'parsing' and parse_started_at = $3`
|
|
tag, err := s.pool.Exec(ctx, q, id, reason, claimedAt)
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: reject book: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNoBook
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteUpload removes a book whose file never arrived.
|
|
//
|
|
// Not `rejected`: that status means "the file could not be parsed" (contract §BookStatus), and a
|
|
// request the client abandoned produced no file to judge. The user's library must not accumulate a
|
|
// permanent row for every cancelled upload — there is no delete handle in the contract to clear one
|
|
// with.
|
|
//
|
|
// Narrow on purpose: only a book still in `uploading`, and only one no run was ever started for, so
|
|
// this can never become a way to delete a book that has cost money.
|
|
//
|
|
// ⚠ It also carries the library's revision ACROSS the deletion, and that is not bookkeeping: the
|
|
// library's number is derived as the maximum over the account's books, so removing the newest one
|
|
// would lower it — and the contract asks a client to DROP a read whose revision is below what it
|
|
// applied (§Revision). The screen would then keep rendering a book that no longer exists until some
|
|
// other book's counter grew past it. The floor lives in `users.library_revision`, the account-scope
|
|
// counter that has existed unused since 00001 (register row PD-122).
|
|
func (s *Store) DeleteUpload(ctx context.Context, id string) error {
|
|
return s.inTx(ctx, func(tx pgx.Tx) error {
|
|
// Book first, as in every transaction that touches two of these tables (lockBook). `users` is
|
|
// taken after it, and no path in this package takes them the other way round.
|
|
if err := lockBook(ctx, tx, id); err != nil {
|
|
return err
|
|
}
|
|
var owner string
|
|
var revision int64
|
|
err := tx.QueryRow(ctx, `
|
|
delete from books where id = $1 and status = 'uploading'
|
|
and not exists (select 1 from runs where book_id = $1)
|
|
returning owner_id, revision`, id).Scan(&owner, &revision)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNoBook
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("pgstore: delete upload: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
update users set library_revision = greatest(library_revision, $2) where id = $1`,
|
|
owner, revision+1); err != nil {
|
|
return fmt.Errorf("pgstore: carry the library revision: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// IntakeBook is a book the backstop sweep has to make a decision about.
|
|
type IntakeBook struct {
|
|
ID string
|
|
Status string
|
|
Workdir string
|
|
Attempts int
|
|
}
|
|
|
|
// StuckIntake lists books that stopped moving through intake.
|
|
//
|
|
// Both halves have the same cause and different cures: an `uploading` row whose request is gone is
|
|
// litter to remove, a `parsing` row whose claim went stale is work to retry. Neither is reachable
|
|
// from the request that created it — the process holding it is the one that died — so the sweep is
|
|
// the only thing that can finish the walk.
|
|
func (s *Store) StuckIntake(ctx context.Context, uploadingBefore, claimedBefore time.Time) ([]IntakeBook, error) {
|
|
// coalesce(claimed, added): an unclaimed parse is stuck when the BOOK has been waiting, a claimed
|
|
// one when the CLAIM has. Written as one expression because they are the same question — how long
|
|
// has this book had nobody working on it — and a NULL claim is simply the first answer to it.
|
|
const q = `
|
|
select id, status, workdir, parse_attempts
|
|
from books
|
|
where (status = 'uploading' and added_at < $1)
|
|
or (status = 'parsing' and coalesce(parse_started_at, added_at) < $2)
|
|
order by added_at`
|
|
rows, err := s.pool.Query(ctx, q, uploadingBefore, claimedBefore)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pgstore: list stuck intake: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []IntakeBook
|
|
for rows.Next() {
|
|
var b IntakeBook
|
|
if err := rows.Scan(&b.ID, &b.Status, &b.Workdir, &b.Attempts); err != nil {
|
|
return nil, fmt.Errorf("pgstore: scan stuck intake: %w", err)
|
|
}
|
|
out = append(out, b)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// 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).
|
|
//
|
|
// The maximum over the account's books, FLOORED by the account's own counter. The floor is what
|
|
// makes the number monotonic across a deletion: the maximum alone falls when the newest book goes
|
|
// (DeleteUpload raises the floor as it deletes), and a library revision that goes backwards is a
|
|
// library the client stops rendering.
|
|
if err := s.pool.QueryRow(ctx, `
|
|
select greatest(coalesce((select max(revision) from books where owner_id = $1), 0),
|
|
coalesce((select library_revision from users where id = $1), 0))`,
|
|
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
|
|
// ONE counter per book, and this is the single place that says so for every run this store
|
|
// hands out (§Revision: "every book-scoped read … carry the same number"). Read off the run's
|
|
// own column the card lagged — a unit_done bumps the book and the chapter and not the run, so
|
|
// a client that had applied frame id=2 got revision 0 back and, obeying the contract, dropped
|
|
// the read. The stop and resume handles answer from ReadRun/RequestStop, which select the
|
|
// book's revision for the same reason.
|
|
run.Revision = b.Revision
|
|
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
|
|
// Status is the book's own state. A run may only be started on a book whose intake finished:
|
|
// the directory of a book still `uploading` holds half a file and the directory of a `rejected`
|
|
// one may hold nothing at all.
|
|
Status 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.status,
|
|
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.Status, &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
|
|
}
|