1096 lines
44 KiB
Go
1096 lines
44 KiB
Go
package pgstore
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"textmachine/platform/internal/ingest"
|
||
)
|
||
|
||
// readmodel.go: the reading surface's store half — the chapter tree with its pairs, the notes
|
||
// derived from what the stream resolved, and the bank as the client signs it.
|
||
//
|
||
// Two properties run through all of it and neither is decoration:
|
||
//
|
||
// - a page and the revision it is stamped with are read in ONE transaction. The revision travels
|
||
// as "what this page is a picture of" and a client MUST drop a read that is not above what it
|
||
// applied, so a number read separately is newer than the rows it labels and everything
|
||
// materialized in between is lost for good (PD-163).
|
||
// - a walk across several pages is stamped with the LOWEST revision it saw, and that number is
|
||
// also the watermark of the next delta read. Taking the newest would skip every row that changed
|
||
// between the first page and the last (canon §Revision).
|
||
//
|
||
// The second is the CLIENT's arithmetic — the server answers one revision per page — but it decides
|
||
// the shape here: every collection answers a per-page revision rather than a per-collection one.
|
||
|
||
// ErrVersionTooOld is a delta watermark from before a collection was replaced wholesale. A delta
|
||
// cannot express a deletion, so the answer is a refusal that names the remedy (re-read in full)
|
||
// rather than a short list that looks complete.
|
||
var ErrVersionTooOld = errors.New("pgstore: that revision predates a wholesale replacement of this collection")
|
||
|
||
// ErrNoChapter is a chapter this book no longer has. Told apart from "no such book" because the
|
||
// remedies differ: the client re-reads the tree rather than checking the address (canon: 410).
|
||
var ErrNoChapter = errors.New("pgstore: this book has no such chapter")
|
||
|
||
// ErrTextUnknownForANewCut is a tree written from a manifest whose companion export could not be
|
||
// read, for a book that was cut AGAIN. Refused rather than written: see SaveStructure.
|
||
var ErrTextUnknownForANewCut = errors.New("pgstore: a book cut again may not be rewritten without its text")
|
||
|
||
// derivedID mints an opaque identifier that is a FUNCTION of what it names.
|
||
//
|
||
// Chapter and pair identities have to survive being re-materialized: the same chapter re-read from
|
||
// the same manifest must come back with the id a client already holds, or every refresh would
|
||
// invalidate every anchor. A random id would need a lookup table keyed by the engine's identity,
|
||
// which is the same mapping with a table to keep in step.
|
||
//
|
||
// The BOOK is hashed in, and that is not namespacing for its own sake: the engine derives a
|
||
// chapter's identity from its TEXT, so two accounts uploading the same book would otherwise collide
|
||
// on a primary key and one would read the other's rows.
|
||
func derivedID(prefix, bookID, engineID string) string {
|
||
sum := sha256.Sum256([]byte(prefix + "\x00" + bookID + "\x00" + engineID))
|
||
return prefix + "_" + hex.EncodeToString(sum[:])[:24]
|
||
}
|
||
|
||
// Structure is a book's tree as the engine last cut it, with the text of every pair.
|
||
type Structure struct {
|
||
// ManifestKey is the engine's validity key. When it differs from the stored one the book was cut
|
||
// again: the structure version moves, cursors and pair anchors die, and the client is told to
|
||
// re-read.
|
||
ManifestKey string
|
||
// TextRead says whether the PAIRS channel ANSWERED — a different question from any pair's
|
||
// TextKnown. An export carrying nothing is a book with no text yet; an export that failed is not
|
||
// knowing, and only one of those may be written over existing text. See SaveStructure.
|
||
TextRead bool
|
||
Chapters []StructureChapter
|
||
}
|
||
|
||
// StructureChapter is one chapter and its pairs, in reading order.
|
||
type StructureChapter struct {
|
||
// EngineID is the engine's content-derived chapter identity; Number its dense display ordinal.
|
||
EngineID string
|
||
Number int
|
||
Units []StructureUnit
|
||
}
|
||
|
||
// StructureUnit is one pair.
|
||
type StructureUnit struct {
|
||
// EngineID carries the cut, so it changes when the book is cut differently — which is exactly
|
||
// what the contract promises about a pair id.
|
||
EngineID string
|
||
// Ordinal is the leader chunk index: reading order inside the chapter and the join key of every
|
||
// resolution and every export record.
|
||
Ordinal int
|
||
Source string
|
||
Target string
|
||
State string
|
||
// TextKnown says whether the PAIRS channel answered about THIS pair. False leaves the stored text
|
||
// alone: the tree and the text are two engine calls, and one that failed — or that answered about
|
||
// a different cut — has nothing to say about a pair it did not carry.
|
||
TextKnown bool
|
||
}
|
||
|
||
// SaveStructure replaces a book's tree with the one just read from the engine.
|
||
//
|
||
// A REPLACEMENT and not a merge, because that is what the engine's artifact is: a manifest is the
|
||
// whole cut, and a chapter absent from it does not exist any more. The structure version moves only
|
||
// when the engine's own validity key moved, so an ordinary refresh — same cut, new text — leaves
|
||
// every cursor and every anchor a client holds valid.
|
||
//
|
||
// ⚠ IT REFUSES A RE-CUT WHOSE TEXT COULD NOT BE READ. `TextKnown` protects a pair only where its row
|
||
// survives: a re-cut changes every identity, so the old rows are deleted and the new ones inserted
|
||
// with what the caller carried — nothing, whenever the export failed and the manifest did not. The
|
||
// debt is then written off and an empty surface freezes over text the account paid for. Refusing
|
||
// keeps the previous tree and leaves the debt for the next drain.
|
||
func (s *Store) SaveStructure(ctx context.Context, bookID string, in Structure) error {
|
||
return s.inTx(ctx, func(tx pgx.Tx) error {
|
||
var storedKey string
|
||
var revision int64
|
||
var structure int
|
||
err := tx.QueryRow(ctx,
|
||
`select manifest_key, revision, structure_version from books where id = $1 for update`,
|
||
bookID).Scan(&storedKey, &revision, &structure)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNoBook
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("pgstore: read book structure: %w", err)
|
||
}
|
||
recut := storedKey != in.ManifestKey
|
||
// ⚠ A book whose cut has never been seen is not a book that was cut AGAIN. The distinction
|
||
// only matters below, where a re-cut drops the resolutions of the previous cut: treating the
|
||
// first materialization as one threw away the work of a run that finished before the tree
|
||
// landed — which is exactly what happens when the intake's own refresh failed (PD-276).
|
||
changed := storedKey != "" && recut
|
||
if changed && !in.TextRead {
|
||
return ErrTextUnknownForANewCut
|
||
}
|
||
revision++
|
||
if recut {
|
||
structure++
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
update books set manifest_key = $2, structure_version = $3, revision = $4,
|
||
chapter_count = $5,
|
||
structure_reset_revision = case when $6 then $4 else structure_reset_revision end
|
||
where id = $1`,
|
||
bookID, in.ManifestKey, structure, revision, len(in.Chapters), recut); err != nil {
|
||
return fmt.Errorf("pgstore: record structure: %w", err)
|
||
}
|
||
if err := writeChapters(ctx, tx, bookID, in, revision, changed); err != nil {
|
||
return err
|
||
}
|
||
// One `chapter` frame per chapter would be a frame per chapter of a 2283-chapter book for a
|
||
// refresh that changed nothing a client is looking at. What a re-cut needs is the
|
||
// structure_version the next frame carries — the stream turns that into `resync_required` by
|
||
// itself — and what an ordinary refresh needs is one poke saying the text moved.
|
||
return emitStatus(ctx, tx, bookID)
|
||
})
|
||
}
|
||
|
||
func writeChapters(ctx context.Context, tx pgx.Tx, bookID string, in Structure, revision int64, recut bool) error {
|
||
if recut {
|
||
// ⚠ Resolutions are addressed by the engine's (chapter number, unit ordinal), which a re-cut
|
||
// re-points: kept, a chapter reported the finished units of whoever held its number before.
|
||
// Dropped rather than translated because no mapping between the two cuts survives; the
|
||
// counters restart for the new cut, which is the truth about a book just cut differently.
|
||
if _, err := tx.Exec(ctx,
|
||
`delete from unit_resolutions where book_id = $1`, bookID); err != nil {
|
||
return fmt.Errorf("pgstore: drop resolutions of the previous cut: %w", err)
|
||
}
|
||
}
|
||
keep := make([]string, 0, len(in.Chapters))
|
||
for _, c := range in.Chapters {
|
||
id := derivedID("ch", bookID, c.EngineID)
|
||
keep = append(keep, id)
|
||
if _, err := tx.Exec(ctx, `
|
||
insert into chapters (id, book_id, number, units_total, revision)
|
||
values ($1, $2, $3, $4, $5)
|
||
on conflict (id) do update set number = excluded.number,
|
||
units_total = excluded.units_total,
|
||
revision = excluded.revision`,
|
||
id, bookID, c.Number, len(c.Units), revision); err != nil {
|
||
return fmt.Errorf("pgstore: write chapter: %w", err)
|
||
}
|
||
if err := writeUnits(ctx, tx, bookID, id, c.Units, revision); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// A chapter the current cut does not have is GONE, and so are its pairs (the cascade). Deleting
|
||
// is what makes `410` answerable at all: a chapter left behind would keep answering 200 with the
|
||
// text of a book that no longer exists.
|
||
//
|
||
// ⚠ Runs after the upserts, which needs `chapters_book_id_number_key` DEFERRED (00018): a chapter
|
||
// id is its text's hash and survives a re-cut while its number shifts, so mid-loop two rows hold
|
||
// one number. Delete-first does not help — the colliding chapters are the surviving ones.
|
||
if _, err := tx.Exec(ctx,
|
||
`delete from chapters where book_id = $1 and not (id = any($2))`, bookID, keep); err != nil {
|
||
return fmt.Errorf("pgstore: drop chapters outside the cut: %w", err)
|
||
}
|
||
// The chapters' own counters are derived from what the stream resolved, and a re-cut re-numbers
|
||
// chapters — so they are recomputed here rather than carried across, or a chapter would inherit
|
||
// the progress of whatever chapter used to hold its number.
|
||
if _, err := tx.Exec(ctx, `
|
||
update chapters c set
|
||
units_draft_done = (select count(*) from unit_resolutions
|
||
where book_id = c.book_id and chapter = c.number and wave = 'draft'),
|
||
units_edit_done = (select count(*) from unit_resolutions
|
||
where book_id = c.book_id and chapter = c.number and wave = 'edit'),
|
||
note_count = (select count(*) from unit_resolutions
|
||
where book_id = c.book_id and chapter = c.number and flagged)
|
||
where c.book_id = $1`, bookID); err != nil {
|
||
return fmt.Errorf("pgstore: recompute chapter counters: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func writeUnits(ctx context.Context, tx pgx.Tx, bookID, chapterID string, units []StructureUnit, revision int64) error {
|
||
keep := make([]string, 0, len(units))
|
||
for _, u := range units {
|
||
keep = append(keep, derivedID("un", bookID, u.EngineID))
|
||
}
|
||
// ⚠ The removed rows go FIRST here, and that is the opposite of the bank's order for a reason
|
||
// that is not symmetry: a pair id carries the cut, so re-cutting a chapter mints a new id for
|
||
// every pair in it while the ORDINALS stay 0, 1, 2 — and (chapter, ordinal) is unique. Inserting
|
||
// first, every re-cut collided on the row it was about to replace. Nothing references a pair, so
|
||
// deleting first costs nothing; a bank decision references its term, which is why that one is the
|
||
// other way round.
|
||
if _, err := tx.Exec(ctx,
|
||
`delete from units where chapter_id = $1 and not (id = any($2))`, chapterID, keep); err != nil {
|
||
return fmt.Errorf("pgstore: drop units outside the cut: %w", err)
|
||
}
|
||
for i, u := range units {
|
||
// ⚠ Text is written only where this materialization HAS text: a failed export arrives empty and
|
||
// an unconditional upsert erased a translated book with empty strings. Emptiness cannot be the
|
||
// signal — an empty target is legitimate (`pending`, `withheld`) — so the caller says, per pair.
|
||
if _, err := tx.Exec(ctx, `
|
||
insert into units (id, chapter_id, ordinal, source, target, state, revision)
|
||
values ($1, $2, $3, $4, $5, $6, $7)
|
||
on conflict (id) do update set
|
||
ordinal = excluded.ordinal,
|
||
source = case when $8 then excluded.source else units.source end,
|
||
target = case when $8 then excluded.target else units.target end,
|
||
state = case when $8 then excluded.state else units.state end,
|
||
revision = excluded.revision`,
|
||
keep[i], chapterID, u.Ordinal, u.Source, u.Target, u.State, revision, u.TextKnown); err != nil {
|
||
return fmt.Errorf("pgstore: write unit: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SaveBank replaces a book's bank with the engine's read-out of it.
|
||
//
|
||
// A replacement, and the pipeline's own rule is why: it rebuilds a book's whole glossary from its
|
||
// deterministic inputs on every run, so a merge here would keep rows the engine has decided no
|
||
// longer exist.
|
||
//
|
||
// `bank_decisions` is not touched. Nothing writes that table today — the per-term verb it served was
|
||
// abolished (D39.144) and removed — but a rebuild must still leave it alone: what replaces it, an
|
||
// EDIT of a term, has to survive the rebuild for the same reason, and the note left where the write
|
||
// path stood promises that the storage is ready for it.
|
||
func (s *Store) SaveBank(ctx context.Context, bookID string, terms []ingest.BankTerm) error {
|
||
return s.inTx(ctx, func(tx pgx.Tx) error {
|
||
if err := lockBook(ctx, tx, bookID); err != nil {
|
||
return err
|
||
}
|
||
var revision int64
|
||
if err := tx.QueryRow(ctx,
|
||
`update books set revision = revision + 1 where id = $1 returning revision`, bookID).
|
||
Scan(&revision); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNoBook
|
||
}
|
||
return fmt.Errorf("pgstore: bump revision for the bank: %w", err)
|
||
}
|
||
// ⚠ Rows are written BEFORE the removed ones are deleted, and the order is load-bearing:
|
||
// `bank_decisions` cascades on `bank_terms`, so clearing the table first would take that table
|
||
// with it — today emptying nothing, and tomorrow the user's own corrections.
|
||
keep := make([]string, 0, len(terms))
|
||
for _, t := range terms {
|
||
id := derivedID("tm", bookID, t.ID)
|
||
keep = append(keep, id)
|
||
if _, err := tx.Exec(ctx, `
|
||
insert into bank_terms (id, book_id, src, dst, kind, status, origin, sense,
|
||
since_chapter, until_chapter, revision)
|
||
values ($1, $2, $3, $4, nullif($5, ''), $6, $7, $8, $9, $10, $11)
|
||
on conflict (id) do update set src = excluded.src, dst = excluded.dst,
|
||
kind = excluded.kind, status = excluded.status,
|
||
origin = excluded.origin, sense = excluded.sense,
|
||
since_chapter = excluded.since_chapter,
|
||
until_chapter = excluded.until_chapter,
|
||
revision = excluded.revision`,
|
||
id, bookID, t.Src, t.Dst, t.Kind, t.Status, t.Origin, t.Sense,
|
||
t.SinceChapter, t.UntilChapter, revision); err != nil {
|
||
return fmt.Errorf("pgstore: write bank term: %w", err)
|
||
}
|
||
}
|
||
tag, err := tx.Exec(ctx,
|
||
`delete from bank_terms where book_id = $1 and not (id = any($2))`, bookID, keep)
|
||
if err != nil {
|
||
return fmt.Errorf("pgstore: drop bank terms outside the read-out: %w", err)
|
||
}
|
||
if tag.RowsAffected() > 0 {
|
||
// A row that disappeared cannot be expressed as a delta, so every watermark from before
|
||
// this moment is refused rather than answered with an incomplete list.
|
||
if _, err := tx.Exec(ctx,
|
||
`update books set bank_reset_revision = $2 where id = $1`, bookID, revision); err != nil {
|
||
return fmt.Errorf("pgstore: record the bank reset: %w", err)
|
||
}
|
||
}
|
||
counts, err := bankCountsTx(ctx, tx, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return emitFrame(ctx, tx, bookID, FrameBank, counts.payload())
|
||
})
|
||
}
|
||
|
||
// BankCounts are the whole-bank aggregates: the header of a signing screen, and INFORMATIONAL —
|
||
// never a gate. Signing is one act over the whole bank and `resume` lifts the stop with the
|
||
// decisions as they stand (D39.144), so nothing here decides whether continuing may be offered.
|
||
type BankCounts struct {
|
||
Total int
|
||
Signed int
|
||
PendingDecisions int
|
||
Complete bool
|
||
}
|
||
|
||
func (c BankCounts) payload() map[string]any {
|
||
return map[string]any{
|
||
"total": c.Total, "signed": c.Signed,
|
||
"pending_decisions": c.PendingDecisions, "complete": c.Complete,
|
||
}
|
||
}
|
||
|
||
func bankCountsTx(ctx context.Context, tx pgx.Tx, bookID string) (BankCounts, error) {
|
||
// ⚠ `pending_decisions` reads "proposed rows nobody has touched", and since the write path was
|
||
// removed (D39.144) nothing ever touches one — so it is the count of `proposed` rows, and
|
||
// `complete` says the engine's read-out carries none. The subquery is kept rather than folded
|
||
// away: it is the shape the EDIT handle will need, and the wire fields are the canon's.
|
||
const q = `
|
||
select count(*),
|
||
count(*) filter (where t.status = 'approved'),
|
||
count(*) filter (where t.status = 'proposed'
|
||
and not exists (select 1 from bank_decisions d
|
||
where d.book_id = t.book_id and d.term_id = t.id))
|
||
from bank_terms t where t.book_id = $1`
|
||
var c BankCounts
|
||
if err := tx.QueryRow(ctx, q, bookID).Scan(&c.Total, &c.Signed, &c.PendingDecisions); err != nil {
|
||
return BankCounts{}, fmt.Errorf("pgstore: count the bank: %w", err)
|
||
}
|
||
c.Complete = c.PendingDecisions == 0
|
||
return c, nil
|
||
}
|
||
|
||
// bookStatus is the product status of a book with the three machine reasons that accompany it.
|
||
type bookStatus struct {
|
||
Status string
|
||
PausedReason string
|
||
RejectReason string
|
||
FailureReason string
|
||
}
|
||
|
||
// derivedStatus is the contract's rule of precedence, written once as SQL: the book's status is
|
||
// that of its current or last run, EXCEPT the states a run cannot be in — which belong to the book
|
||
// before any run exists or instead of one (canon §BookStatus).
|
||
//
|
||
// Without it the two disagreed observably: a signing stop is written on the RUN row and the book's
|
||
// own column stayed `translating`, so the card said the work was going while the run was waiting for
|
||
// a signature.
|
||
const derivedStatus = `case when b.status in ('uploading', 'parsing', 'not_started', 'rejected')
|
||
then b.status else coalesce(r.status, b.status) end`
|
||
|
||
// bookColumns is the library row, in the ONE shape every path that hands out a book uses. Written
|
||
// once because a second copy is how a projection drifts: the intake's 201, the library page and the
|
||
// card all answer the same fields or the client sees a book change shape by the route it came from.
|
||
const bookColumns = `b.id, b.title, b.source_lang, b.target_lang, ` + derivedStatus + `, b.reject_reason,
|
||
b.structure_version, b.chapter_count, ` + chaptersDone + `, b.character_count, ` + noteCount + `,
|
||
b.added_at, b.revision`
|
||
|
||
// editWave is whether the pipeline this book's work goes through has an EDITOR, over a `books b`.
|
||
// Unknown reads as "it has one": a chapter only half-done must not count as finished.
|
||
const editWave = `coalesce(b.edit_wave, true)`
|
||
|
||
// finishedUnits is how many of a chapter's units are FINISHED: resolved by the LAST pass the book
|
||
// actually gets on this deployment.
|
||
//
|
||
// ⚠ Not always the edit wave, and naming that one outright was money. A pipeline with no editor gives
|
||
// the edit counter a denominator of zero, so counted as edit no chapter was ever finished: the book's
|
||
// bar stayed at zero and the purchase scale was never clamped, so the service went on offering
|
||
// chapters it had already translated.
|
||
//
|
||
// ⚠ The answer is the BOOK's (migration 00024) and not the latest run's. Read off the latest run it
|
||
// moved BACKWARDS: the latest run is the newest, and a run just admitted has announced nothing, so
|
||
// every book on a deployment with no editor fell back to zero chapters done until that run's first
|
||
// progress event.
|
||
const finishedUnits = `(case when ` + editWave + ` then c.units_edit_done else c.units_draft_done end)`
|
||
|
||
// segmentUnits is the same count for the current SEGMENT of a run, which is the work between two
|
||
// stops: in the first segment of a run that stops for signing a chapter counts when the DRAFT is
|
||
// done with it, and otherwise when the last pass is.
|
||
const segmentUnits = `(case when r.verify_bank and not r.bank_released
|
||
then c.units_draft_done else ` + finishedUnits + ` end)`
|
||
|
||
// chaptersDone is the BOOK's own progress: chapters fully translated, against chapter_count. It is a
|
||
// different question from the bar of a run — which measures what that run bought.
|
||
const chaptersDone = `(select count(*) from chapters c
|
||
where c.book_id = b.id and c.units_total > 0 and ` + finishedUnits + ` >= c.units_total)`
|
||
|
||
// noteCount sums the per-chapter counters, which is what makes it describe the same set as the
|
||
// notes LIST: a note carries a chapter_id, so a resolution whose chapter has no row has no address
|
||
// on the wire — and no counter to be counted in either.
|
||
//
|
||
// Summed rather than counted from `unit_resolutions` with a join to `chapters`: that form answered
|
||
// the same number and cost an index scan of every note the book has, per book, on every library page
|
||
// (migration 00022 carries the measurement).
|
||
const noteCount = `(select coalesce(sum(c.note_count), 0) from chapters c where c.book_id = b.id)`
|
||
|
||
func scanBook(row pgx.Row) (Book, error) {
|
||
var b Book
|
||
err := row.Scan(&b.ID, &b.Title, &b.SourceLang, &b.TargetLang, &b.Status, &b.RejectReason,
|
||
&b.StructureVersion, &b.ChapterCount, &b.ChaptersDone, &b.CharacterCount, &b.NoteCount,
|
||
&b.AddedAt, &b.Revision)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return Book{}, ErrNoBook
|
||
}
|
||
if err != nil {
|
||
return Book{}, fmt.Errorf("pgstore: read book: %w", err)
|
||
}
|
||
return b, nil
|
||
}
|
||
|
||
// readBookTx answers one book in the shape above.
|
||
func readBookTx(ctx context.Context, tx pgx.Tx, bookID string) (Book, error) {
|
||
return scanBook(tx.QueryRow(ctx,
|
||
`select `+bookColumns+` from books b `+lastRun+` where b.id = $1`, bookID))
|
||
}
|
||
|
||
// runProgress is the run's bar: chapters finished in the CURRENT segment, against what the run
|
||
// bought. The segment is chosen by the same rule the chapter counters are read by (scope.wave).
|
||
//
|
||
// ⚠ Measured from the run's own BASELINE and clamped into its bought range. Resolutions persist
|
||
// across runs — a resumed run re-walks finished chapters at $0 and must not move them — so counting
|
||
// the book's finished chapters would open a continuation run's bar at everything the previous run
|
||
// did, against a denominator of what THIS one bought: a fraction starting above zero and able to
|
||
// exceed one. `least(…, ceiling_chapters)` closes the other end, where a run that bought 5 chapters
|
||
// watches a neighbouring run finish more of the same book.
|
||
const runProgress = `least(greatest((select count(*) from chapters c
|
||
where c.book_id = b.id and c.units_total > 0
|
||
and ` + segmentUnits + ` >= c.units_total) - r.chapters_before, 0), r.ceiling_chapters)`
|
||
|
||
// lastRun is the join every book-scoped read uses: the current or last run of the book.
|
||
const lastRun = `left join lateral (
|
||
select id, status, paused_reason, failure_reason, verify_bank, bank_released, ceiling_chapters,
|
||
chapters_before, eta_seconds, started_at, finished_at, stop_requested_at
|
||
from runs where book_id = b.id order by started_at desc, id desc limit 1) r on true`
|
||
|
||
func readBookStatusTx(ctx context.Context, tx pgx.Tx, bookID string) (bookStatus, error) {
|
||
var st bookStatus
|
||
err := tx.QueryRow(ctx, `
|
||
select `+derivedStatus+`, coalesce(r.paused_reason, ''), b.reject_reason,
|
||
coalesce(r.failure_reason, '')
|
||
from books b `+lastRun+` where b.id = $1`, bookID).
|
||
Scan(&st.Status, &st.PausedReason, &st.RejectReason, &st.FailureReason)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return bookStatus{}, ErrNoBook
|
||
}
|
||
if err != nil {
|
||
return bookStatus{}, fmt.Errorf("pgstore: read book status: %w", err)
|
||
}
|
||
return st, nil
|
||
}
|
||
|
||
// emitStatus announces the book's product status, with the three machine reasons that travel with
|
||
// it — each translated into the CONTRACT's vocabulary here and not by whoever reads the frame.
|
||
//
|
||
// ⚠ The translation is the point. The columns hold this platform's own words (`daily_ceiling`,
|
||
// `parser_unavailable`), the wire has no name for some of them, and the frame used to carry them
|
||
// raw while the JSON path next door mapped them properly: the same fact then read `null` on the card
|
||
// and `daily_ceiling` on the stream.
|
||
//
|
||
// A read failure emits NOTHING rather than a frame with a null status: `EventStatus` requires the
|
||
// field, and a frame outside its own schema is worse than a frame that did not arrive — the client
|
||
// re-reads on the next one either way.
|
||
func emitStatus(ctx context.Context, tx pgx.Tx, bookID string) error {
|
||
st, err := readBookStatusTx(ctx, tx, bookID)
|
||
if err != nil {
|
||
if errors.Is(err, ErrNoBook) {
|
||
return nil
|
||
}
|
||
return err
|
||
}
|
||
return emitFrame(ctx, tx, bookID, FrameStatus, map[string]any{
|
||
"status": st.Status,
|
||
"paused_reason": nullable(ingest.ContractPausedReason(st.PausedReason)),
|
||
"reject_reason": nullable(ingest.ContractRejectReason(st.RejectReason)),
|
||
"failure_reason": nullable(st.FailureReason),
|
||
})
|
||
}
|
||
|
||
// Page is the envelope every collection answers in.
|
||
type Page struct {
|
||
Revision int64
|
||
StructureVersion int
|
||
NextCursor string
|
||
}
|
||
|
||
// Chapter is one row of the chapter tree.
|
||
type Chapter struct {
|
||
ID string
|
||
// Number is the display ordinal, or nil for a book with no numbering.
|
||
Number *int
|
||
UnitsTotal int
|
||
// UnitsDone is the pairs finished IN THE CURRENT PASS — the same accounting as the run's own bar,
|
||
// one level down. It legally returns to zero when a new pass re-walks a chapter.
|
||
UnitsDone int
|
||
NoteCount int
|
||
}
|
||
|
||
// ChapterPage is one page of the tree.
|
||
type ChapterPage struct {
|
||
Page
|
||
Chapters []Chapter
|
||
}
|
||
|
||
// ListChapters returns one page of the chapter tree, in reading order.
|
||
func (s *Store) ListChapters(ctx context.Context, userID, bookID string, limit int, cursor string) (ChapterPage, error) {
|
||
limit = clampPage(limit)
|
||
var out ChapterPage
|
||
err := s.inReadTx(ctx, func(tx pgx.Tx) error {
|
||
scope, err := bookScope(ctx, tx, userID, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
after, err := decodeNumberCursor(scope.tag("chapters"), cursor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out.Page = Page{Revision: scope.revision, StructureVersion: scope.structure}
|
||
rows, err := tx.Query(ctx, `
|
||
select c.id, c.number, c.units_total,
|
||
case when $4 = 'draft' then c.units_draft_done else c.units_edit_done end,
|
||
c.note_count
|
||
from chapters c
|
||
where c.book_id = $1 and ($2::integer is null or c.number > $2::integer)
|
||
order by c.number limit $3`, bookID, after, limit+1, scope.wave)
|
||
if err != nil {
|
||
return fmt.Errorf("pgstore: list chapters: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var c Chapter
|
||
var number int
|
||
if err := rows.Scan(&c.ID, &number, &c.UnitsTotal, &c.UnitsDone, &c.NoteCount); err != nil {
|
||
return fmt.Errorf("pgstore: scan chapter: %w", err)
|
||
}
|
||
c.Number = &number
|
||
out.Chapters = append(out.Chapters, c)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return fmt.Errorf("pgstore: list chapters: %w", err)
|
||
}
|
||
if len(out.Chapters) > limit {
|
||
last := out.Chapters[limit-1]
|
||
out.Chapters = out.Chapters[:limit]
|
||
out.NextCursor = encodeNumberCursor(scope.tag("chapters"), *last.Number)
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return ChapterPage{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Unit is one source/translation pair with the notes attached to it.
|
||
type Unit struct {
|
||
ID string
|
||
Source string
|
||
Target string
|
||
State string
|
||
Notes []Note
|
||
}
|
||
|
||
// UnitPage is one page of a chapter's pairs.
|
||
type UnitPage struct {
|
||
Page
|
||
Units []Unit
|
||
}
|
||
|
||
// ListUnits returns one page of a chapter's pairs.
|
||
//
|
||
// PER CHAPTER and only per chapter: a book-wide pairs endpoint is never introduced and the client's
|
||
// whole memory model stands on that — a corpus book's pairs are tens of megabytes (canon §listUnits;
|
||
// the figures are in PLATFORM_DIRECTION §5б).
|
||
func (s *Store) ListUnits(ctx context.Context, userID, bookID, chapterID string, limit int, cursor string) (UnitPage, error) {
|
||
limit = clampPage(limit)
|
||
var out UnitPage
|
||
err := s.inReadTx(ctx, func(tx pgx.Tx) error {
|
||
scope, err := bookScope(ctx, tx, userID, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var number int
|
||
err = tx.QueryRow(ctx,
|
||
`select number from chapters where id = $1 and book_id = $2`, chapterID, bookID).Scan(&number)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNoChapter
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("pgstore: read chapter: %w", err)
|
||
}
|
||
after, err := decodeNumberCursor(scope.tag("units:"+chapterID), cursor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out.Page = Page{Revision: scope.revision, StructureVersion: scope.structure}
|
||
rows, err := tx.Query(ctx, `
|
||
select id, ordinal, source, target, state from units
|
||
where chapter_id = $1 and ($2::integer is null or ordinal > $2::integer)
|
||
order by ordinal limit $3`, chapterID, after, limit+1)
|
||
if err != nil {
|
||
return fmt.Errorf("pgstore: list units: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var ordinals []int // parallel to out.Units: the leader index each pair is addressed by
|
||
for rows.Next() {
|
||
var u Unit
|
||
var ordinal int
|
||
if err := rows.Scan(&u.ID, &ordinal, &u.Source, &u.Target, &u.State); err != nil {
|
||
return fmt.Errorf("pgstore: scan unit: %w", err)
|
||
}
|
||
ordinals = append(ordinals, ordinal)
|
||
out.Units = append(out.Units, u)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return fmt.Errorf("pgstore: list units: %w", err)
|
||
}
|
||
if len(out.Units) > limit {
|
||
out.Units, ordinals = out.Units[:limit], ordinals[:limit]
|
||
out.NextCursor = encodeNumberCursor(scope.tag("units:"+chapterID), ordinals[limit-1])
|
||
}
|
||
byOrdinal := make(map[int]int, len(ordinals))
|
||
for i, ordinal := range ordinals {
|
||
byOrdinal[ordinal] = i
|
||
}
|
||
// The notes of these pairs travel WITH them, so a reader screen need not join two collections
|
||
// (canon §Unit.notes). Read for the chapter and attached by the leader index the resolution
|
||
// and the pair share.
|
||
notes, err := chapterNotes(ctx, tx, bookID, chapterID, number)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, n := range notes {
|
||
if i, ok := byOrdinal[n.unit]; ok {
|
||
out.Units[i].Notes = append(out.Units[i].Notes, n.Note)
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return UnitPage{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Note is one remark about a piece of the translation.
|
||
//
|
||
// It is PROJECTED from `unit_resolutions` rather than stored: the resolution is the durable record
|
||
// the stream writes, and a note is a view of the flagged ones. Its identity is derived from the
|
||
// resolution's own key, so a re-delivered line re-derives the same note instead of minting a second.
|
||
type Note struct {
|
||
ID string
|
||
CreatedAt time.Time
|
||
// Reason is the ENGINE's flag reason and does NOT go on the wire: the contract's code and the
|
||
// step are mapped from it at projection time, so a reason this build has never heard of gets a
|
||
// neutral phrase instead of a hole.
|
||
Reason string
|
||
ChapterID string
|
||
UnitID string
|
||
Revision int64
|
||
}
|
||
|
||
type chapterNote struct {
|
||
Note
|
||
unit int
|
||
}
|
||
|
||
func chapterNotes(ctx context.Context, tx pgx.Tx, bookID, chapterID string, number int) ([]chapterNote, error) {
|
||
rows, err := tx.Query(ctx, `
|
||
select ur.unit, ur.wave, ur.reason, ur.at, ur.revision, u.id
|
||
from unit_resolutions ur
|
||
left join units u on u.chapter_id = $2 and u.ordinal = ur.unit
|
||
where ur.book_id = $1 and ur.chapter = $3 and ur.flagged
|
||
order by ur.at, ur.unit, ur.wave`, bookID, chapterID, number)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("pgstore: read chapter notes: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []chapterNote
|
||
for rows.Next() {
|
||
var n chapterNote
|
||
var wave string
|
||
var unitID *string
|
||
if err := rows.Scan(&n.unit, &wave, &n.Reason, &n.CreatedAt, &n.Revision, &unitID); err != nil {
|
||
return nil, fmt.Errorf("pgstore: scan note: %w", err)
|
||
}
|
||
n.ID = noteID(bookID, number, n.unit, wave)
|
||
n.ChapterID = chapterID
|
||
if unitID != nil {
|
||
n.UnitID = *unitID
|
||
}
|
||
out = append(out, n)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// noteID is a note's identity: derived from the resolution it is a view of, so it survives the same
|
||
// line being delivered twice and the projection being rebuilt.
|
||
func noteID(bookID string, chapter, unit int, wave string) string {
|
||
return derivedID("nt", bookID, fmt.Sprintf("%d:%d:%s", chapter, unit, wave))
|
||
}
|
||
|
||
// NotePage is one page of a book's notes.
|
||
type NotePage struct {
|
||
Page
|
||
Notes []Note
|
||
}
|
||
|
||
// ListNotes returns one page of a book's notes, oldest first.
|
||
//
|
||
// Ordered by time rather than by position in the book so that a note arriving on the stream can be
|
||
// placed into a list the client already holds. Ties are broken by the server in a way the client
|
||
// does NOT reproduce — here by the chapter, pair and pass the resolution belongs to, which is a
|
||
// total order over rows that share a timestamp.
|
||
func (s *Store) ListNotes(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (NotePage, error) {
|
||
limit = clampPage(limit)
|
||
var out NotePage
|
||
err := s.inReadTx(ctx, func(tx pgx.Tx) error {
|
||
scope, err := bookScope(ctx, tx, userID, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if after != nil && *after < scope.structureReset {
|
||
// The tree was re-cut, which re-numbers chapters and re-points every note's address.
|
||
return ErrVersionTooOld
|
||
}
|
||
pos, err := decodeNoteCursor(scope.tag("notes"), cursor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out.Page = Page{Revision: scope.revision, StructureVersion: scope.structure}
|
||
rows, err := tx.Query(ctx, `
|
||
select ur.chapter, ur.unit, ur.wave, ur.reason, ur.at, ur.revision, c.id, u.id
|
||
from unit_resolutions ur
|
||
join chapters c on c.book_id = ur.book_id and c.number = ur.chapter
|
||
left join units u on u.chapter_id = c.id and u.ordinal = ur.unit
|
||
where ur.book_id = $1 and ur.flagged
|
||
and ($2::bigint is null or ur.revision >= $2::bigint)
|
||
and ($3::timestamptz is null
|
||
or (ur.at, ur.chapter, ur.unit, ur.wave) > ($3::timestamptz, $4::integer, $5::integer, $6::text))
|
||
order by ur.at, ur.chapter, ur.unit, ur.wave limit $7`,
|
||
bookID, after, pos.at, pos.chapter, pos.unit, pos.wave, limit+1)
|
||
if err != nil {
|
||
return fmt.Errorf("pgstore: list notes: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var tail notePos
|
||
for rows.Next() {
|
||
var n Note
|
||
var chapter, unit int
|
||
var wave string
|
||
var unitID *string
|
||
if err := rows.Scan(&chapter, &unit, &wave, &n.Reason, &n.CreatedAt, &n.Revision,
|
||
&n.ChapterID, &unitID); err != nil {
|
||
return fmt.Errorf("pgstore: scan note: %w", err)
|
||
}
|
||
n.ID = noteID(bookID, chapter, unit, wave)
|
||
if unitID != nil {
|
||
n.UnitID = *unitID
|
||
}
|
||
if len(out.Notes) < limit {
|
||
tail = notePos{at: &n.CreatedAt, chapter: &chapter, unit: &unit, wave: &wave}
|
||
}
|
||
out.Notes = append(out.Notes, n)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return fmt.Errorf("pgstore: list notes: %w", err)
|
||
}
|
||
if len(out.Notes) > limit {
|
||
out.Notes = out.Notes[:limit]
|
||
out.NextCursor = encodeNoteCursor(scope.tag("notes"), tail)
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return NotePage{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// BankTerm is one row of the bank as the client reads it.
|
||
type BankTerm struct {
|
||
ID string
|
||
Src string
|
||
Dst string
|
||
Kind string // "" means the kind could not be decided: a legal state
|
||
Status string
|
||
Origin string
|
||
Sense string
|
||
Since *int
|
||
Until *int
|
||
}
|
||
|
||
// BankPage is one page of the bank. The aggregates ride on the FIRST page only — any response to a
|
||
// request with no cursor — which puts them at the same moment as the oldest rows of the walk.
|
||
type BankPage struct {
|
||
Page
|
||
Terms []BankTerm
|
||
First bool
|
||
Counts BankCounts
|
||
}
|
||
|
||
// ListBank returns one page of the bank, ordered by source surface then by the term's window so
|
||
// that the several rows of one surface stand together.
|
||
func (s *Store) ListBank(ctx context.Context, userID, bookID string, limit int, cursor string, after *int64) (BankPage, error) {
|
||
limit = clampPage(limit)
|
||
var out BankPage
|
||
err := s.inReadTx(ctx, func(tx pgx.Tx) error {
|
||
scope, err := bookScope(ctx, tx, userID, bookID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if after != nil && *after < scope.bankReset {
|
||
return ErrVersionTooOld
|
||
}
|
||
pos, err := decodeBankCursor(scope.tag("bank"), cursor)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out.Page = Page{Revision: scope.revision, StructureVersion: scope.structure}
|
||
out.First = cursor == ""
|
||
// Ordered by the SOURCE SURFACE and then by the term's WINDOW, which is what the contract
|
||
// declares and what puts the several rows of one surface together in the order a person reads
|
||
// them. The keyset follows the same tuple — ordering by the derived id instead put one
|
||
// surface's rows in hash order while the doc comment claimed otherwise. `coalesce(…, 0)` is
|
||
// the boundary's own sentinel: "from the beginning" sorts first.
|
||
rows, err := tx.Query(ctx, `
|
||
select id, src, dst, coalesce(kind, ''), status, origin, sense, since_chapter, until_chapter
|
||
from bank_terms
|
||
where book_id = $1
|
||
and ($2::bigint is null or revision >= $2::bigint)
|
||
and ($3::text is null or (src, coalesce(since_chapter, 0), coalesce(until_chapter, 0), id)
|
||
> ($3::text, $4::integer, $5::integer, $6::text))
|
||
order by src, coalesce(since_chapter, 0), coalesce(until_chapter, 0), id
|
||
limit $7`, bookID, after, pos.src, pos.since, pos.until, pos.id, limit+1)
|
||
if err != nil {
|
||
return fmt.Errorf("pgstore: list bank: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var t BankTerm
|
||
if err := rows.Scan(&t.ID, &t.Src, &t.Dst, &t.Kind, &t.Status, &t.Origin, &t.Sense,
|
||
&t.Since, &t.Until); err != nil {
|
||
return fmt.Errorf("pgstore: scan bank term: %w", err)
|
||
}
|
||
out.Terms = append(out.Terms, t)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return fmt.Errorf("pgstore: list bank: %w", err)
|
||
}
|
||
if len(out.Terms) > limit {
|
||
last := out.Terms[limit-1]
|
||
out.Terms = out.Terms[:limit]
|
||
out.NextCursor = encodeBankCursor(scope.tag("bank"), last)
|
||
}
|
||
if out.First {
|
||
if out.Counts, err = bankCountsTx(ctx, tx, bookID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return BankPage{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// scope is what every book-scoped read needs before it reads anything: that the caller may see the
|
||
// book at all, and the two numbers the page is stamped with.
|
||
type scope struct {
|
||
bookID string
|
||
revision int64
|
||
structure int
|
||
structureReset int64
|
||
bankReset int64
|
||
// wave is which pass the chapter counters are read from — the platform's own split, which the
|
||
// wire never carries. The FIRST segment of a run that stops for signing is the draft pass; so is
|
||
// every pass on a deployment whose pipeline has no editor (finishedUnits). Everything else counts
|
||
// a chapter when its work is finished end to end.
|
||
wave string
|
||
}
|
||
|
||
// The BOOK is in the tag: without it a cursor minted for one book was accepted on another at the
|
||
// same structure version, and answered that book's rows from this one's offset.
|
||
func (s scope) tag(collection string) string {
|
||
return s.bookID + "\x00" + collection + "\x00" + strconv.Itoa(s.structure)
|
||
}
|
||
|
||
func bookScope(ctx context.Context, tx pgx.Tx, userID, bookID string) (scope, error) {
|
||
out := scope{bookID: bookID}
|
||
var stopForSigning, released *bool
|
||
var edits bool
|
||
err := tx.QueryRow(ctx, `
|
||
select b.revision, b.structure_version, b.structure_reset_revision, b.bank_reset_revision,
|
||
r.verify_bank, r.bank_released, `+editWave+`
|
||
from books b `+lastRun+`
|
||
where b.id = $1 and b.owner_id = $2`, bookID, userID).
|
||
Scan(&out.revision, &out.structure, &out.structureReset, &out.bankReset,
|
||
&stopForSigning, &released, &edits)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return scope{}, ErrNoBook
|
||
}
|
||
if err != nil {
|
||
return scope{}, fmt.Errorf("pgstore: read book scope: %w", err)
|
||
}
|
||
out.wave = "edit"
|
||
signing := stopForSigning != nil && *stopForSigning && released != nil && !*released
|
||
if signing || !edits {
|
||
out.wave = "draft"
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// clampPage bounds a page size to what the contract lets a client ASK for, and CLAMPS rather than
|
||
// refuses: a server that answered the default instead made "ask for more, get fewer rows than a
|
||
// smaller request" discoverable only by experiment (canon §Limit).
|
||
func clampPage(limit int) int {
|
||
switch {
|
||
case limit <= 0:
|
||
return DefaultPage
|
||
case limit > maxPage:
|
||
return maxPage
|
||
}
|
||
return limit
|
||
}
|
||
|
||
// ── Cursors ─────────────────────────────────────────────────────────────────────────────────────
|
||
//
|
||
// Opaque, and BOUND to the collection they came from and to the structure version they were minted
|
||
// under. Rejecting a cursor that no longer applies is the SERVER's duty and the client cannot
|
||
// perform it, because the token is opaque to it by construction (canon §NextCursor).
|
||
|
||
func cursorScope(tag string) string {
|
||
sum := sha256.Sum256([]byte(tag))
|
||
return base64.RawURLEncoding.EncodeToString(sum[:8])
|
||
}
|
||
|
||
func encodeCursorParts(tag string, parts ...string) string {
|
||
return base64.RawURLEncoding.EncodeToString(
|
||
[]byte(cursorScope(tag) + "\x00" + strings.Join(parts, "\x00")))
|
||
}
|
||
|
||
func decodeCursorParts(tag, s string, want int) ([]string, error) {
|
||
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||
if err != nil {
|
||
return nil, ErrBadCursor
|
||
}
|
||
fields := strings.Split(string(raw), "\x00")
|
||
if len(fields) != want+1 || fields[0] != cursorScope(tag) {
|
||
return nil, ErrBadCursor
|
||
}
|
||
return fields[1:], nil
|
||
}
|
||
|
||
func encodeNumberCursor(tag string, n int) string {
|
||
return encodeCursorParts(tag, strconv.Itoa(n))
|
||
}
|
||
|
||
func decodeNumberCursor(tag, s string) (*int, error) {
|
||
if s == "" {
|
||
return nil, nil
|
||
}
|
||
fields, err := decodeCursorParts(tag, s, 1)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
n, err := strconv.Atoi(fields[0])
|
||
if err != nil {
|
||
return nil, ErrBadCursor
|
||
}
|
||
return &n, nil
|
||
}
|
||
|
||
// notePos is the keyset position of the note list: the timestamp the contract orders by, plus the
|
||
// server's own tiebreak.
|
||
type notePos struct {
|
||
at *time.Time
|
||
chapter *int
|
||
unit *int
|
||
wave *string
|
||
}
|
||
|
||
// encodeNoteCursor packs the server's own tiebreak. ⚠ The pass is carried as a NUMBER and not as its
|
||
// name: a cursor is opaque by contract but trivially decodable in practice, and `draft`/`edit` are
|
||
// exactly the words this seam exists to keep off the wire.
|
||
func encodeNoteCursor(tag string, p notePos) string {
|
||
if p.at == nil {
|
||
return ""
|
||
}
|
||
return encodeCursorParts(tag, p.at.UTC().Format(time.RFC3339Nano),
|
||
strconv.Itoa(*p.chapter), strconv.Itoa(*p.unit), strconv.Itoa(waveOrdinal(*p.wave)))
|
||
}
|
||
|
||
// waveOrdinal and waveOf are the two halves of that packing: the ordinal is the platform's own and
|
||
// the order it imposes is the order the query sorts by.
|
||
func waveOrdinal(wave string) int {
|
||
if wave == ingest.WaveDraft {
|
||
return 0
|
||
}
|
||
return 1
|
||
}
|
||
|
||
func waveOf(ordinal int) string {
|
||
if ordinal == 0 {
|
||
return ingest.WaveDraft
|
||
}
|
||
return ingest.WaveEdit
|
||
}
|
||
|
||
func decodeNoteCursor(tag, s string) (notePos, error) {
|
||
if s == "" {
|
||
return notePos{}, nil
|
||
}
|
||
fields, err := decodeCursorParts(tag, s, 4)
|
||
if err != nil {
|
||
return notePos{}, err
|
||
}
|
||
at, err := time.Parse(time.RFC3339Nano, fields[0])
|
||
if err != nil {
|
||
return notePos{}, ErrBadCursor
|
||
}
|
||
chapter, err := strconv.Atoi(fields[1])
|
||
if err != nil {
|
||
return notePos{}, ErrBadCursor
|
||
}
|
||
unit, err := strconv.Atoi(fields[2])
|
||
if err != nil {
|
||
return notePos{}, ErrBadCursor
|
||
}
|
||
ordinal, err := strconv.Atoi(fields[3])
|
||
if err != nil {
|
||
return notePos{}, ErrBadCursor
|
||
}
|
||
wave := waveOf(ordinal)
|
||
return notePos{at: &at, chapter: &chapter, unit: &unit, wave: &wave}, nil
|
||
}
|
||
|
||
// bankPos is the keyset position of the bank: the declared order, tuple for tuple.
|
||
type bankPos struct {
|
||
src *string
|
||
since *int
|
||
until *int
|
||
id *string
|
||
}
|
||
|
||
func encodeBankCursor(tag string, t BankTerm) string {
|
||
return encodeCursorParts(tag, t.Src, strconv.Itoa(bound(t.Since)), strconv.Itoa(bound(t.Until)), t.ID)
|
||
}
|
||
|
||
// bound is the window's own sentinel: an open boundary sorts as 0, which chapter numbering cannot
|
||
// produce (it starts at 1).
|
||
func bound(n *int) int {
|
||
if n == nil {
|
||
return 0
|
||
}
|
||
return *n
|
||
}
|
||
|
||
func decodeBankCursor(tag, s string) (bankPos, error) {
|
||
if s == "" {
|
||
return bankPos{}, nil
|
||
}
|
||
fields, err := decodeCursorParts(tag, s, 4)
|
||
if err != nil {
|
||
return bankPos{}, err
|
||
}
|
||
since, err := strconv.Atoi(fields[1])
|
||
if err != nil {
|
||
return bankPos{}, ErrBadCursor
|
||
}
|
||
until, err := strconv.Atoi(fields[2])
|
||
if err != nil {
|
||
return bankPos{}, ErrBadCursor
|
||
}
|
||
return bankPos{src: &fields[0], since: &since, until: &until, id: &fields[3]}, nil
|
||
}
|