textmachine/platform/internal/pgstore/books_test.go

540 lines
22 KiB
Go

package pgstore
import (
"context"
"errors"
"testing"
"time"
"textmachine/platform/internal/money"
)
// The library is what the read handles serve, and a book put in the read model by ANY route is a
// book the API lists: the development intake and the contract's upload are two writers of one row,
// not two worlds.
func TestTheLibraryListsWhateverWasRegisteredAndNobodyElsesBooks(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "10")
fundedAccount(t, s, ctx, "u2", "10")
mine, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "蛊真人", SourceLang: "zh", TargetLang: "ru",
ChapterCount: 500, CharacterCount: 23_000_000, Workdir: "/srv/books/a", Now: now})
if err != nil {
t.Fatal(err)
}
if _, err := s.AddBook(ctx, NewBook{OwnerID: "u2", Title: "another", SourceLang: "ja", TargetLang: "ru",
ChapterCount: 10, Workdir: "/srv/books/b", Now: now}); err != nil {
t.Fatal(err)
}
lib, err := s.ListBooks(ctx, "u1", 0, "")
if err != nil {
t.Fatal(err)
}
if len(lib.Books) != 1 || lib.Books[0].ID != mine {
t.Fatalf("library: %+v", lib.Books)
}
b := lib.Books[0]
if b.Status != "not_started" || b.ChapterCount != 500 || b.CharacterCount != 23_000_000 {
t.Errorf("book row: %+v", b)
}
// The library row carries no bar at all since 0.3.0: progress belongs to the RUN, and the book's
// own figure is chapters_done against chapter_count.
if b.ChaptersDone != 0 || b.NoteCount != 0 || b.StructureVersion != 0 {
t.Errorf("a book that never ran carries progress: %+v", b)
}
if _, err := s.AddBook(ctx, NewBook{OwnerID: "nobody", Title: "x", SourceLang: "zh", TargetLang: "ru",
ChapterCount: 1, Workdir: "/srv/x", Now: now}); !errors.Is(err, ErrNoAccount) {
t.Errorf("adding a book to a missing account gave %v, want ErrNoAccount", err)
}
}
// Keyset pagination, and the assertion is the one that catches the classic off-by-one: the last page
// must not carry a cursor, or the client follows it forever.
func TestTheLibraryPagesForwardAndStopsCleanly(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "10")
for i := range 5 {
if _, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru",
ChapterCount: 1, Workdir: "/srv/books/x", Now: now.Add(time.Duration(i) * time.Second)}); err != nil {
t.Fatal(err)
}
}
seen := map[string]bool{}
cursor := ""
for page := 0; ; page++ {
if page > 5 {
t.Fatal("pagination did not terminate")
}
lib, err := s.ListBooks(ctx, "u1", 2, cursor)
if err != nil {
t.Fatal(err)
}
for _, b := range lib.Books {
if seen[b.ID] {
t.Fatalf("book %s came back on two pages", b.ID)
}
seen[b.ID] = true
}
if lib.NextCursor == "" {
break
}
cursor = lib.NextCursor
}
if len(seen) != 5 {
t.Fatalf("paged over %d books, want 5", len(seen))
}
// Rejecting a cursor this collection cannot use is the SERVER's duty: the client holds an opaque
// string and cannot judge it.
if _, err := s.ListBooks(ctx, "u1", 2, "not-a-cursor"); !errors.Is(err, ErrBadCursor) {
t.Errorf("a malformed cursor gave %v, want ErrBadCursor", err)
}
}
// The card carries the book's OWN revision when it has never run, and the run's when it has: the
// field orders the client's reads, and a card without one gives it nothing to compare.
func TestTheBookCardCarriesARevisionEitherWay(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "10")
id, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "b", SourceLang: "zh", TargetLang: "ru",
ChapterCount: 50, Workdir: "/srv/books/a", Now: now})
if err != nil {
t.Fatal(err)
}
book, run, err := s.GetBook(ctx, "u1", id)
if err != nil {
t.Fatal(err)
}
if run != nil {
t.Fatalf("a book that never ran has a run: %+v", run)
}
if book.ID != id {
t.Fatalf("card: %+v", book)
}
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: id, OrderedChapters: 10,
Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil)
if err != nil {
t.Fatal(err)
}
_, run, err = s.GetBook(ctx, "u1", id)
if err != nil {
t.Fatal(err)
}
if run == nil || run.ID != started.ID || run.OrderedChapters != 10 || run.VerifyBank {
t.Fatalf("card run: %+v", run)
}
if run.PausedReason != "" || run.FinishedAt != nil {
t.Errorf("a live run reports a pause or an end: %+v", run)
}
if _, _, err := s.GetBook(ctx, "u2", id); !errors.Is(err, ErrNoBook) {
t.Errorf("another account read the card: %v", err)
}
}
// What is LEFT of a book is what the read model knows, and since the order form it is read PER
// CHAPTER, with a price, rather than as one count.
//
// ⚠ THE CARRIER MOVED AND THE GUARANTEE DID NOT. `BookRunContext.ChaptersLeft` was a single number
// that clamped a ceiling SCALE; there is no scale, and a count without prices cannot quote an order
// over chapters that differ. What replaced it is `ReadBookForOrder().Remaining` — the same fact, per
// chapter, with what each is expected to cost. This test asks the same three questions of the new
// carrier: a book nothing has touched offers all of itself, a delivered chapter leaves the offer,
// and a stranger is answered as if the book did not exist.
func TestWhatIsLeftOfABookIsWhatTheReadModelKnows(t *testing.T) {
s, ctx := testDB(t)
fundedAccount(t, s, ctx, "u1", "10")
seedBook(t, s, ctx, "bk1", "u1", 3)
priceBookRow(t, s, ctx, "bk1")
// Three chapters, one unit each, nothing delivered.
exec(t, s, ctx, `insert into chapters (id, book_id, number, units_total)
values ('c1','bk1',1,1), ('c2','bk1',2,1), ('c3','bk1',3,1)`)
exec(t, s, ctx, `insert into units (id, chapter_id, ordinal, source, target, state, expected_micro_usd, source_chars)
values ('u1','c1',0,'s','','pending',50000,1000),
('u2','c2',0,'s','','pending',50000,1000),
('u3','c3',0,'s','','pending',50000,1000)`)
got, err := s.ReadBookForOrder(ctx, "u1", "bk1")
if err != nil {
t.Fatal(err)
}
if !got.Priced || len(got.Remaining) != 3 {
t.Fatalf("a book nothing has touched offers %d chapters (priced %v)", len(got.Remaining), got.Priced)
}
// One chapter delivered leaves the offer entirely.
exec(t, s, ctx, `insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at)
values ('bk1',1,0,'edit',true,false,now())`)
got, err = s.ReadBookForOrder(ctx, "u1", "bk1")
if err != nil {
t.Fatal(err)
}
if len(got.Remaining) != 2 || got.Remaining[0].Number != 2 {
t.Errorf("after one chapter was delivered the offer is %+v", got.Remaining)
}
if _, err := s.ReadBookForOrder(ctx, "u2", "bk1"); !errors.Is(err, ErrNoBook) {
t.Errorf("another account read a book's price: %v", err)
}
// The run's own context still answers what a START is judged on, and ownership with it.
rc, err := s.ReadBookForRun(ctx, "u1", "bk1")
if err != nil || rc.HasLiveRun || rc.Workdir == "" || rc.ChapterCount != 3 {
t.Fatalf("book for run: %+v (%v)", rc, err)
}
if _, err := s.ReadBookForRun(ctx, "u2", "bk1"); !errors.Is(err, ErrNoBook) {
t.Errorf("another account read a book for a run: %v", err)
}
}
// priceBookRow gives a book the projection an order is judged against. Written here rather than in
// each test because an UNPRICED book is refused outright now, and every test of what is left of a
// book would otherwise be testing that refusal.
func priceBookRow(t *testing.T, s *Store, ctx context.Context, book string) {
t.Helper()
exec(t, s, ctx, `update books set expected_micro_usd = 150000, book_once_micro_usd = 0,
step_max_micro_usd = 69828, source_chars = 3000,
structure = 'detected' where id = '`+book+`'`)
}
// …and "finished" is the LAST pass the book actually gets HERE. A deployment whose pipeline has no
// editor announces an edit denominator of zero, and counting the edit wave there left every chapter
// unfinished forever: the scale was never clamped, so the service went on offering — and taking a
// hold for — chapters it had already translated.
//
// Mutation caught: naming units_edit_done outright in finishedUnits.
func TestADraftOnlyDeploymentStillClampsTheScale(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "10")
seedBook(t, s, ctx, "bk1", "u1", 3)
priceBookRow(t, s, ctx, "bk1")
// Two chapters the draft wave finished, and an edit wave that never touched them.
exec(t, s, ctx, `insert into chapters (id, book_id, number, units_total)
values ('c1','bk1',1,1), ('c2','bk1',2,1), ('c3','bk1',3,1)`)
exec(t, s, ctx, `insert into units (id, chapter_id, ordinal, source, target, state, expected_micro_usd, source_chars)
values ('u1','c1',0,'s','','pending',50000,1000),
('u2','c2',0,'s','','pending',50000,1000),
('u3','c3',0,'s','','pending',50000,1000)`)
exec(t, s, ctx, `insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at)
values ('bk1',1,0,'draft',true,false,now()), ('bk1',2,0,'draft',true,false,now())`)
got, err := s.ReadBookForOrder(ctx, "u1", "bk1")
if err != nil {
t.Fatal(err)
}
// No run has reported yet, so the pipeline's shape is unknown — and unknown must not read as done.
if len(got.Remaining) != 3 {
t.Fatalf("the offer is %d chapters before any run reported, want all three", len(got.Remaining))
}
if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", OrderedChapters: 3,
Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil); err != nil {
t.Fatal(err)
}
// What the engine's first progress event settles on a pipeline with no editor (recordWaveShape).
exec(t, s, ctx, `update books set edit_wave = false where id = 'bk1'`)
got, err = s.ReadBookForOrder(ctx, "u1", "bk1")
if err != nil {
t.Fatal(err)
}
if len(got.Remaining) != 1 || got.Remaining[0].Number != 3 {
t.Errorf("a draft-only deployment offers %+v, want only the chapter the draft has not finished",
got.Remaining)
}
}
// What `/usage` carries is a SHARE, and since 05.09 that is a statement about THIS question rather
// than a prohibition: the blanket ban on money crossing the boundary was the owner's own and the
// owner revoked it (D39.196 §2) — the balance goes out in dollars, on the ORDER FORM, where a sum has
// an order beside it to be a price OF. The account screen asks «am I running low», and a percentage
// is honest exactly as an alarm.
//
// The edge that matters is an account with no grants at all — zero out of zero is not "everything
// available", and telling a user they have 100% of nothing is the one answer they cannot act on.
func TestUsageIsAShareAndAnAccountWithNoGrantsIsExhausted(t *testing.T) {
s, ctx := testDB(t)
seedUser(t, s, ctx, "empty")
u, err := s.ReadUsage(ctx, "empty")
if err != nil {
t.Fatal(err)
}
// …and an account that cannot spend anything IS halted, whether it ran out or never had anything:
// the answer a client acts on is the same either way.
if u.RemainingPercent != 0 || u.Spendable || u.PausedReason != PausedCreditExhausted {
t.Errorf("an account with no grants: %+v", u)
}
now := fundedAccount(t, s, ctx, "u1", "10")
seedBook(t, s, ctx, "bk1", "u1", 500)
if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.RemainingPercent != 100 {
t.Fatalf("a fresh account: %+v (%v)", u, err)
}
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", OrderedChapters: 100,
Ceiling: money.MicroUSD(3_000_000), Now: now}, 0, nil)
if err != nil {
t.Fatal(err)
}
// A hold is a debit, so the share drops when it is TAKEN and not when it is spent.
if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.RemainingPercent != 70 {
t.Fatalf("after a $3 hold on $10: %+v (%v)", u, err)
}
// ⚠ A RUN that stopped at the ceiling IT bought does NOT halt the account. The canon says so at
// §AccountHaltReason, and the wire said the opposite: `remaining_percent: 97` beside
// `halt_reason: credit_exhausted` — a user with money told they have none.
exec(t, s, ctx, `update runs set paused_reason='credit_exhausted', status='paused' where id=$1`, run.ID)
if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.PausedReason != "" || u.RemainingPercent != 70 {
t.Fatalf("a run at its own limit halted the account: %+v (%v)", u, err)
}
// The account is halted when the ACCOUNT has nothing left, and by nothing else.
if _, err := s.Adjust(ctx, "u1", money.MicroUSD(-7_000_000), "test", "spend", "spent", now); err != nil {
t.Fatal(err)
}
if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.PausedReason != PausedCreditExhausted {
t.Fatalf("an account with nothing left: %+v (%v)", u, err)
}
if _, err := s.ReadUsage(ctx, "nobody"); !errors.Is(err, ErrNoAccount) {
t.Errorf("usage of a missing account gave %v, want ErrNoAccount", err)
}
}
// Rounded DOWN, because the figure is what is LEFT: rounding 0.4% up to 1% tells a user they can
// still start something.
func TestTheRemainingShareRoundsDown(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "10")
if _, err := s.Adjust(ctx, "u1", money.MicroUSD(-9_960_000), "test", "spend", "spent", now); err != nil {
t.Fatal(err)
}
u, err := s.ReadUsage(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if u.RemainingPercent != 0 {
t.Errorf("0.4%% of the grant left reported as %d%%", u.RemainingPercent)
}
}
// Rejecting a cursor that does not apply to this collection is the SERVER's duty (contract
// §NextCursor): the token is opaque to the client, so the client cannot perform the check. Measured
// before the fix: a token built from another account's library was accepted and answered with a
// window of the caller's own books.
func TestACursorFromAnotherLibraryIsRefused(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "10")
fundedAccount(t, s, ctx, "u2", "10")
for i := range 3 {
at := now.Add(time.Duration(i) * time.Second)
if _, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "mine", SourceLang: "zh",
TargetLang: "ru", ChapterCount: 1, Workdir: "/srv/a", Now: at}); err != nil {
t.Fatal(err)
}
if _, err := s.AddBook(ctx, NewBook{OwnerID: "u2", Title: "theirs", SourceLang: "zh",
TargetLang: "ru", ChapterCount: 1, Workdir: "/srv/b", Now: at}); err != nil {
t.Fatal(err)
}
}
theirs, err := s.ListBooks(ctx, "u2", 1, "")
if err != nil {
t.Fatal(err)
}
if theirs.NextCursor == "" {
t.Fatal("no cursor to carry across")
}
if _, err := s.ListBooks(ctx, "u1", 1, theirs.NextCursor); !errors.Is(err, ErrBadCursor) {
t.Fatalf("another library's cursor gave %v, want ErrBadCursor", err)
}
// The account's own cursor still works, so the binding did not simply break pagination.
mine, err := s.ListBooks(ctx, "u1", 1, "")
if err != nil {
t.Fatal(err)
}
if _, err := s.ListBooks(ctx, "u1", 1, mine.NextCursor); err != nil {
t.Fatalf("the account's own cursor was refused: %v", err)
}
}
// A pause the platform's OWN path caused is visible where a pause belongs — on the RUN, and through
// it on the book's card. It ends the run in the same statement that pauses it, so a projection keyed
// on "the run has not finished" answered null exactly when the reconciler, rather than a stream
// event, was what stopped the work.
//
// ⚠ On the run and NOT on the account: this used to be asserted through `/usage`, which is how the
// account came to be lit from a fact about one run (canon §AccountHaltReason).
func TestAPauseTheReconcilerCausedIsVisibleOnTheRun(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "10")
seedBook(t, s, ctx, "bk1", "u1", 500)
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: "bk1", OrderedChapters: 10,
Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil)
if err != nil {
t.Fatal(err)
}
if paused, err := s.PauseRun(ctx, run.ID, run.AttemptID, PausedCreditExhausted, now); err != nil || !paused {
t.Fatalf("PauseRun = %v, %v", paused, err)
}
book, card, err := s.GetBook(ctx, "u1", "bk1")
if err != nil {
t.Fatal(err)
}
if card == nil || card.PausedReason != PausedCreditExhausted || book.Status != "paused" {
t.Errorf("the card does not report the pause the reconciler caused: %+v %+v", book, card)
}
// …and the ACCOUNT is untouched by it: the money is still there.
u, err := s.ReadUsage(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if u.PausedReason != "" || !u.Spendable {
t.Errorf("one run's pause halted the whole account: %+v", u)
}
}
// "Exhausted" is a fact about the balance, not a consequence of rounding the share down: a small
// remainder of a large grant floors to 0% while runs can still be started.
func TestASmallRemainderOfALargeGrantIsStillSpendable(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "1000")
if _, err := s.Adjust(ctx, "u1", money.MicroUSD(-991_000_000), "test", "spend", "spent", now); err != nil {
t.Fatal(err)
}
u, err := s.ReadUsage(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if u.RemainingPercent != 0 {
t.Fatalf("share %d%%, want 0 (floored)", u.RemainingPercent)
}
if !u.Spendable {
t.Error("an account with $9 left reported nothing spendable")
}
if _, err := s.Adjust(ctx, "u1", money.MicroUSD(-9_000_000), "test", "rest", "rest", now); err != nil {
t.Fatal(err)
}
if u, err = s.ReadUsage(ctx, "u1"); err != nil || u.Spendable {
t.Errorf("an empty account: %+v (%v)", u, err)
}
}
// The gate an engine upgrade reads (orchestrator's addendum, 14.08). `tmctl migrate` moves a book's
// project file to the new binary's schema, and the OLD binary cannot open it afterwards — so a book
// is only safe to migrate when nothing is still pinned to that older build. Three facts say it is
// not, and each of them alone is enough:
//
// - a LIVE run, whose engine is holding the file open right now;
// - a RESUMABLE one, which waits to be continued by the build its attempt is pinned to (row 139)
// — migrating under it would force the continuation onto a new binary, and re-paying for calls
// the account has already bought is exactly what the pinning exists to prevent;
// - an UNSETTLED hold, whose money is read with that same pinned build. Migrate first and the
// figure can never be read again, and the hold stays reserved with no sweep able to close it.
//
// Pinned as a table because the query answers three independent facts in one pass, and the command
// that reads it (`tmplatformctl books --migratable`) is a for-loop in a deploy note: a blocker that
// silently stops blocking does not fail anything until an operator's upgrade has already run.
//
// Mutation caught: dropping any one of the three predicates from BooksForMigration.
func TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList(t *testing.T) {
s, ctx := testDB(t)
now := fundedAccount(t, s, ctx, "u1", "100")
start := func(t *testing.T, book string) StartedRun {
t.Helper()
seedBook(t, s, ctx, book, "u1", 500)
run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book,
OrderedChapters: 10, Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil)
if err != nil {
t.Fatal(err)
}
return run
}
end := func(t *testing.T, run StartedRun, status, reason string) {
t.Helper()
closed, err := s.FinishRun(ctx, RunEnding{RunID: run.ID, AttemptID: run.AttemptID,
Status: status, PausedReason: reason, ExitResult: "exit-code", Now: now})
if err != nil || !closed {
t.Fatalf("FinishRun(%s) = %v, %v", status, closed, err)
}
}
settle := func(t *testing.T, run StartedRun) {
t.Helper()
if err := s.Settle(ctx, ReservationKey(run.ID, 1), money.MicroUSD(100_000), now); err != nil {
t.Fatal(err)
}
}
// One book per fact, and one with none of them.
settled := start(t, "bk-free")
end(t, settled, "ready", "")
settle(t, settled)
live := start(t, "bk-live") // admitted and never finished
// Its hold is closed by hand, which no live run does: a live run always holds money, so the two
// facts co-occur naturally and one predicate would then be hiding behind the other. Each has to
// carry its own weight — the query is consumed by a for-loop in a deploy note, where a blocker
// that has quietly stopped blocking is invisible until an upgrade has already run.
settle(t, live)
// ⚠ A `paused` book is MIGRATABLE since P7, and the reason is the contract rather than a
// relaxation: a run stopped at a limit cannot be continued by `resume` at all any more (409
// `ceiling_reached`), so nothing is pinned to an old build — the remedy is a new run on the
// current one. A book stuck on the engine's daily ceiling used to block an upgrade for ever
// (PD-217).
paused := start(t, "bk-paused")
end(t, paused, "paused", PausedCreditExhausted)
settle(t, paused)
holding := start(t, "bk-hold")
end(t, holding, "ready", "") // finished, but its hold was never closed
books, err := s.BooksForMigration(ctx)
if err != nil {
t.Fatal(err)
}
got := map[string]BookForMigration{}
for _, b := range books {
got[b.ID] = b
}
for _, tc := range []struct {
book string
migratable bool
live bool
resumable bool
unsettled bool
}{
{book: "bk-free", migratable: true},
{book: "bk-live", live: true},
{book: "bk-paused", migratable: true},
{book: "bk-hold", unsettled: true},
} {
b, ok := got[tc.book]
if !ok {
t.Errorf("%s is not in the migration list at all", tc.book)
continue
}
if b.Migratable() != tc.migratable {
t.Errorf("%s: migratable = %v, want %v (live=%v resumable=%v unsettled=%v)",
tc.book, b.Migratable(), tc.migratable, b.Live, b.Resumable, b.Unsettled)
}
if b.Live != tc.live || b.Resumable != tc.resumable || b.Unsettled != tc.unsettled {
t.Errorf("%s: live=%v resumable=%v unsettled=%v, want %v/%v/%v",
tc.book, b.Live, b.Resumable, b.Unsettled, tc.live, tc.resumable, tc.unsettled)
}
}
// The other half of the same fact: the list the deploy note's loop actually consumes carries the
// free book and nothing else.
var safe []string
for _, b := range books {
if b.Migratable() {
safe = append(safe, b.ID)
}
}
if len(safe) != 2 || safe[0] != "bk-free" || safe[1] != "bk-paused" {
t.Errorf("the migratable list is %v, want the free book and the paused one", safe)
}
// Every status a resume can continue from blocks, not just the one above: `stopped` and
// `awaiting_bank` wait for the same pinned build.
for _, status := range []string{"stopped", "awaiting_bank"} {
book := "bk-" + status
run := start(t, book)
end(t, run, status, "")
settle(t, run)
books, err := s.BooksForMigration(ctx)
if err != nil {
t.Fatal(err)
}
for _, b := range books {
if b.ID == book && b.Migratable() {
t.Errorf("a %s run left its book migratable", status)
}
}
}
}