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 }