diff --git a/platform/internal/pgstore/books.go b/platform/internal/pgstore/books.go index 6eacbcf0..6a05a34f 100644 --- a/platform/internal/pgstore/books.go +++ b/platform/internal/pgstore/books.go @@ -816,7 +816,8 @@ type Run struct { OrderedChapters int // DeliveredChapters is NIL for a run whose order does not close whole chapters: such a run has // delivered no CHAPTER, and reporting `0` would read as «nothing happened» rather than as «this - // is not the unit this run is measured in». DeliveredUnits carries its progress instead. + // is not the unit this run is measured in». Progress carries what it HAS done instead, and for + // such a run its `done`/`total` are counted in units rather than chapters. DeliveredChapters *int // BondFunded is whether this run's hold has room for the book-wide consistency pass on top of // what it bought. It is the order form's promise, kept where it can still be read after the diff --git a/platform/internal/pgstore/price_test.go b/platform/internal/pgstore/price_test.go index 2970701a..42516d29 100644 --- a/platform/internal/pgstore/price_test.go +++ b/platform/internal/pgstore/price_test.go @@ -194,3 +194,83 @@ func TestTheEnginesCharacterCountReachesTheLibraryRowBesideItsAccuracy(t *testin t.Errorf("the cut's provenance did not land: %q", after.Structure) } } + +// ⛔ THE HONEST CHARACTER COUNT SURVIVES A PRICE THIS BUILD COULD NOT READ, and it did not until an +// audit asked. `source_chars` was written only when the whole projection passed its witness — so a +// manifest that named the text perfectly and failed on a MONEY key cost the screen its character +// count too, and «Знаков» fell back to the intake's approximation, which for an EPUB counts the runes +// of a ZIP archive. The two facts are independent: one is about the text, the other about money. +// +// Mutation caught: writing `source_chars` off `in.Price` again. +func TestTheCharacterCountSurvivesAPriceThisBuildCouldNotRead(t *testing.T) { + s, ctx := testDB(t) + book := readingBook(t, s, ctx, "u1") + in := priced() + in.Price = nil // the money did not arrive… + in.SourceChars = 3000 // …and the engine's count of the text did + if err := s.SaveStructure(ctx, book, in); err != nil { + t.Fatal(err) + } + got, _, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if got.SourceChars == nil || *got.SourceChars != 3000 { + t.Fatalf("the engine's rune count was discarded with the price: %v", got.SourceChars) + } + // …and the book is still refused for SALE, which is the other half: the two facts are independent + // in both directions. + order, err := s.ReadBookForOrder(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if order.Priced { + t.Error("a book whose price did not arrive reads as priced") + } +} + +// ⛔ WORK DELIVERED BEYOND THE ORDER'S BOUNDARY MUST NOT CANCEL WORK STILL OWED INSIDE IT. The +// allowance handed to the engine is «ordered minus delivered», and counting the delivered book-WIDE +// while counting the ordered only up to the boundary makes the two describe different sets: a run +// ordered through chapter 2 whose chapters 1 and 3 came back delivered fell to its floor of one unit +// and half-delivered what was bought. It costs no money — settlement is by fact — and it breaks «you +// bought N», which is the promise this pack exists to make true. +// +// Mutation caught: `deliveredWithinOrder` counting the book instead of the order. +func TestDeliveryBeyondTheOrderDoesNotCancelWorkOwedInsideIt(t *testing.T) { + s, ctx := testDB(t) + book := readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, priced()); err != nil { + t.Fatal(err) + } + // The order runs through chapter ONE (two units). Chapter TWO — beyond it — comes back delivered. + var c1 string + if err := s.pool.QueryRow(ctx, + `select id from chapters where book_id = $1 and number = 1`, book).Scan(&c1); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(ctx, ` + update books set ordered_at = now(), ordered_through_chapter_id = $2, + ordered_through_chapter_number = 1 where id = $1`, book, c1); err != nil { + t.Fatal(err) + } + if _, err := s.pool.Exec(ctx, ` + insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) + values ($1, 2, 0, 'edit', true, false, now())`, book); err != nil { + t.Fatal(err) + } + var ordered, delivered int + var resolved bool + if err := s.pool.QueryRow(ctx, ` + select `+orderedUnits+`, `+orderResolved+`, `+deliveredWithinOrder+` + from books b where b.id = $1`, book).Scan(&ordered, &resolved, &delivered); err != nil { + t.Fatal(err) + } + if !resolved || ordered != 2 { + t.Fatalf("the order through chapter one resolves to %d units (resolved %v), want 2", ordered, resolved) + } + if delivered != 0 { + t.Errorf("a chapter delivered BEYOND the order counted as %d units of it: the allowance would "+ + "fall to its floor and half-deliver what was bought", delivered) + } +} diff --git a/platform/internal/pgstore/readmodel.go b/platform/internal/pgstore/readmodel.go index 1fbba7d7..a54c323a 100644 --- a/platform/internal/pgstore/readmodel.go +++ b/platform/internal/pgstore/readmodel.go @@ -81,6 +81,15 @@ type Structure struct { // engine did not say — a manifest older than the landing — and stored as such: the decision it // feeds (ingest.ChapterOrdersOffered) treats what it does not know as untrusted. Structure string + // SourceChars is the engine's count of the book's ingested text in runes. + // + // ⛔ CARRIED APART FROM Price, AND THAT IS THE POINT. It is a property of the CUT, not of the + // money: a manifest can name the text honestly and still fail the price witness (a renamed money + // key, a chapter that lost its bill). Read off `Price`, such a book lost its honest character + // count too and fell back to the intake's approximation — a number that for an EPUB counts the + // runes of a ZIP archive — for a reason that has nothing to do with counting. Zero means the + // engine did not say. + SourceChars int64 } // StructureChapter is one chapter and its pairs, in reading order. @@ -161,11 +170,17 @@ func (s *Store) SaveStructure(ctx context.Context, bookID string, in Structure) // Nulls are written through when the book is not priced. A stale projection kept beside a fresh // tree would sell yesterday's book; an absent one refuses the sale, which is the honest half. var expected, bookOnce, stepMax *int64 - var sourceChars *int64 if in.Price != nil { e, b, sm := int64(in.Price.ExpectedUSD), int64(in.Price.BookOnceUSD), int64(in.Price.StepMaxUSD) - sc := in.Price.SourceChars - expected, bookOnce, stepMax, sourceChars = &e, &b, &sm, &sc + expected, bookOnce, stepMax = &e, &b, &sm + } + // ⚠ INDEPENDENT OF THE PRICE — see Structure.SourceChars. A book the engine could not price is + // still a book whose text the engine counted, and the screen's «Знаков» has no business + // falling back to the intake's approximation because a MONEY key was renamed. + var sourceChars *int64 + if in.SourceChars > 0 { + sc := in.SourceChars + sourceChars = &sc } var structureWord *string if in.Structure != "" { @@ -632,9 +647,12 @@ const ( // top for the same reason the bar is: free and carried units ride outside the grant (volume.go), // so a run can finish more than it was sold, and a figure above what was bought would read as an // overdelivery rather than as the rounding it is. Zero for a re-pass, which buys no chapters. - // ⚠ AND WHAT SUCH A RUN HAS DELIVERED IS COUNTED IN UNITS TOO — reported to the client as - // `delivered_units` beside a null `delivered_chapters`, because a run that bought a prefix of a - // chapter has delivered no CHAPTER and saying «0» would read as «nothing happened». + // ⚠ NULL — NOT ZERO — FOR A UNIT-SHAPED ORDER, and there is no second figure beside it. A run + // that bought a prefix of a chapter has delivered no CHAPTER, and «0» would read as «nothing + // happened» when work was done and paid for. What such a run has delivered is carried by the + // BAR instead, whose `done`/`total` are counted in units for exactly this run (see runDone) — + // so the two figures a client gets are a null chapter count and a unit-shaped fraction, and + // nothing on the wire is called `delivered_units`. runDelivered = `(case when r.ordered_units is not null then null when r.ceiling_chapters = 0 then 0 else least(greatest((case when ` + editWave + ` then ` + editChapters + ` - r.chapters_before diff --git a/platform/internal/readmodel/readmodel.go b/platform/internal/readmodel/readmodel.go index fef6b887..448495f6 100644 --- a/platform/internal/readmodel/readmodel.go +++ b/platform/internal/readmodel/readmodel.go @@ -316,7 +316,10 @@ func (s *Service) refreshStructure(ctx context.Context, bookID, workdir string, // the old rows are deleted rather than left alone and the text is gone for good. in := pgstore.Structure{ManifestKey: manifest.Key, TextRead: textErr == nil, Structure: manifest.Structure, - Chapters: make([]pgstore.StructureChapter, 0, len(manifest.Chapters))} + // The engine's own rune count, carried whether or not the book could be PRICED: it is a fact + // about the text, and the price witness is about money (pgstore.Structure.SourceChars). + SourceChars: manifestSourceChars(manifest), + Chapters: make([]pgstore.StructureChapter, 0, len(manifest.Chapters))} // ⚠ ALL OR NOTHING, and the decision is Priced's rather than this loop's. A projection is written // only when the whole of it was read — the book figure, a price on every chapter, and the two // agreeing — because the half-read shape is the dangerous one: a `step_max_usd` that decoded as @@ -380,3 +383,15 @@ func (s *Service) refreshBank(ctx context.Context, bookID, bankExport string) er } return s.Store.SaveBank(ctx, bookID, bank.Terms) } + +// manifestSourceChars is the book's ingested text in runes as the engine counted it, or 0. +// +// It reads the figure off the price block because that is where the engine publishes it — but it +// deliberately does NOT ask whether the projection as a whole was readable. A renamed money key must +// not cost the screen its honest character count. +func manifestSourceChars(m ingest.Manifest) int64 { + if m.Price == nil { + return 0 + } + return m.Price.SourceChars +} diff --git a/platform/internal/runs/order_test.go b/platform/internal/runs/order_test.go index 12301ec7..7b281dc4 100644 --- a/platform/internal/runs/order_test.go +++ b/platform/internal/runs/order_test.go @@ -92,7 +92,7 @@ func TestAWholeBookOrderCarriesNoVolumeBound(t *testing.T) { // respawned after a restart would be handed its whole order a second time and the promise «you // bought N units» would stop being about N. // -// Mutation caught: maxUnitsFor ignoring DeliveredUnits. +// Mutation caught: maxUnitsFor ignoring SpawnOrder.Delivered. func TestTheVolumeAllowanceIsWhatIsLeftOfTheOrderAtEverySpawn(t *testing.T) { f := newFixture(t, "10", 500) run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(10)})