From 7c6f667ff3de674457c6ce4b266b2a0dbec8301b Mon Sep 17 00:00:00 2001 From: vavallee Date: Sun, 27 Sep 2026 19:58:29 -0300 Subject: [PATCH] feat(db): record Calibre deliveries in a durable ledger (#2832) Adds migration 095, the calibre_deliveries table: one row per ebook book_files row with state, attempts, last error and the id in the push target. The migration backfills a delivered row for every book whose calibre_id came from a push, so the first push all after upgrade does not send them again. Books from a Calibre library import are left out, recognised by metadata_provider, a calibre:book: foreign_id, or a book row in calibre_provenance. CalibreDeliveryRepo covers what the delivery job and the push all rework need. Storage only: imports and pushes are unchanged, and the repo is not wired yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee --- changelog.d/2832-calibre-deliveries-table.md | 2 + internal/db/calibre_deliveries.go | 306 +++++++++++++ internal/db/calibre_deliveries_test.go | 419 ++++++++++++++++++ internal/db/migration_095_test.go | 202 +++++++++ .../db/migrations/095_calibre_deliveries.sql | 101 +++++ internal/models/calibre_delivery.go | 56 +++ 6 files changed, 1086 insertions(+) create mode 100644 changelog.d/2832-calibre-deliveries-table.md create mode 100644 internal/db/calibre_deliveries.go create mode 100644 internal/db/calibre_deliveries_test.go create mode 100644 internal/db/migration_095_test.go create mode 100644 internal/db/migrations/095_calibre_deliveries.sql create mode 100644 internal/models/calibre_delivery.go diff --git a/changelog.d/2832-calibre-deliveries-table.md b/changelog.d/2832-calibre-deliveries-table.md new file mode 100644 index 000000000..db399bd3a --- /dev/null +++ b/changelog.d/2832-calibre-deliveries-table.md @@ -0,0 +1,2 @@ +### Changed +- **Bindery now records which books it has sent to Calibre** (#2832), in preparation for retrying deliveries that fail while Calibre is unreachable, and books already pushed are recorded when you upgrade. diff --git a/internal/db/calibre_deliveries.go b/internal/db/calibre_deliveries.go new file mode 100644 index 000000000..eaf5bf321 --- /dev/null +++ b/internal/db/calibre_deliveries.go @@ -0,0 +1,306 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/vavallee/bindery/internal/models" +) + +// calibreDeliveryTimeLayout is the one shape every calibre_deliveries time +// column is written in. It is RFC3339 with a fixed nine digit fraction, so it +// parses with the RFC3339Nano layout while also comparing and sorting as +// text, which the due query needs. See migration 095 for why neither +// timeValueArg (variable width fraction) nor CURRENT_TIMESTAMP will do. +const calibreDeliveryTimeLayout = "2006-01-02T15:04:05.000000000Z" + +func calibreDeliveryTime(t time.Time) string { + return t.UTC().Format(calibreDeliveryTimeLayout) +} + +// CalibreDeliveryRepo is the calibre_deliveries ledger (#2832): one row per +// ebook file, recording whether it reached the Calibre push target. Every +// method is a single statement, so none of them holds a transaction across +// a Calibre call. +type CalibreDeliveryRepo struct { + db *sql.DB + now func() time.Time +} + +func NewCalibreDeliveryRepo(db *sql.DB) *CalibreDeliveryRepo { + return &CalibreDeliveryRepo{db: db, now: time.Now} +} + +const calibreDeliveryColumns = `id, book_id, book_file_id, edition_id, file_path, format, state, + outcome, attempts, last_error, last_error_code, calibre_id, target_library, + next_attempt_at, created_at, updated_at, delivered_at` + +// normalizeCalibreFormat stores a format as the ledger expects it: the file +// extension, lower case, without the dot. +func normalizeCalibreFormat(format string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(format), ".")) +} + +// Enqueue queues bookFileID for delivery, due now. It reports whether this +// call created the row. +// +// An existing row for the file is left exactly as it is, whatever its state: +// a pending row is already queued, a delivered one must not be sent twice, +// and a failed or skipped one stays put until someone asks for it through +// Retry. Re-importing a file therefore never re-arms a delivery that gave up. +func (r *CalibreDeliveryRepo) Enqueue(ctx context.Context, bookID, bookFileID int64, editionID *int64, filePath, format string) (bool, error) { + now := calibreDeliveryTime(r.now()) + res, err := r.db.ExecContext(ctx, ` + INSERT INTO calibre_deliveries + (book_id, book_file_id, edition_id, file_path, format, state, + next_attempt_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?) + ON CONFLICT(book_file_id) DO NOTHING`, + bookID, bookFileID, editionID, filepath.Clean(filePath), normalizeCalibreFormat(format), now, now, now) + if err != nil { + return false, fmt.Errorf("calibre deliveries enqueue: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("calibre deliveries enqueue rows: %w", err) + } + return n > 0, nil +} + +// Get returns one row by id, or nil when there is none. +func (r *CalibreDeliveryRepo) Get(ctx context.Context, id int64) (*models.CalibreDelivery, error) { + return r.getOne(ctx, `SELECT `+calibreDeliveryColumns+` FROM calibre_deliveries WHERE id = ?`, id) +} + +// GetByBookFile returns the row for a book_files id, or nil when there is none. +func (r *CalibreDeliveryRepo) GetByBookFile(ctx context.Context, bookFileID int64) (*models.CalibreDelivery, error) { + return r.getOne(ctx, `SELECT `+calibreDeliveryColumns+` FROM calibre_deliveries WHERE book_file_id = ?`, bookFileID) +} + +func (r *CalibreDeliveryRepo) getOne(ctx context.Context, query string, arg any) (*models.CalibreDelivery, error) { + rows, err := r.db.QueryContext(ctx, query, arg) + if err != nil { + return nil, fmt.Errorf("calibre deliveries get: %w", err) + } + out, err := scanCalibreDeliveries(rows) + if err != nil || len(out) == 0 { + return nil, err + } + return &out[0], nil +} + +// DueBatch returns up to limit pending rows whose next_attempt_at is at or +// before now, oldest due first. +func (r *CalibreDeliveryRepo) DueBatch(ctx context.Context, now time.Time, limit int) ([]models.CalibreDelivery, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT `+calibreDeliveryColumns+` FROM calibre_deliveries + WHERE state = 'pending' AND next_attempt_at <= ? + ORDER BY next_attempt_at, id + LIMIT ?`, calibreDeliveryTime(now), limit) + if err != nil { + return nil, fmt.Errorf("calibre deliveries due: %w", err) + } + return scanCalibreDeliveries(rows) +} + +// MarkDelivered records a successful delivery. outcome says how it landed +// (for example "added" or "linked"); targetLibrary identifies the library +// calibreID belongs to. The last error is cleared. +func (r *CalibreDeliveryRepo) MarkDelivered(ctx context.Context, id, calibreID int64, outcome, targetLibrary string) error { + now := calibreDeliveryTime(r.now()) + return r.execOne(ctx, "mark delivered", ` + UPDATE calibre_deliveries + SET state = 'delivered', calibre_id = ?, outcome = ?, target_library = ?, + last_error = '', last_error_code = '', delivered_at = ?, updated_at = ? + WHERE id = ?`, + calibreID, outcome, targetLibrary, now, now, id) +} + +// MarkFailed records one failed attempt and increments attempts. A terminal +// failure moves the row to failed, out of the queue until Retry; otherwise +// it stays pending and comes due again at nextAttemptAt. +// +// Only call this for an attempt that actually reached Calibre. An unreachable +// Calibre is not an attempt and should leave the row alone. +func (r *CalibreDeliveryRepo) MarkFailed(ctx context.Context, id int64, code, msg string, nextAttemptAt time.Time, terminal bool) error { + state := models.CalibreDeliveryPending + if terminal { + state = models.CalibreDeliveryFailed + } + return r.execOne(ctx, "mark failed", ` + UPDATE calibre_deliveries + SET state = ?, attempts = attempts + 1, last_error_code = ?, last_error = ?, + next_attempt_at = ?, updated_at = ? + WHERE id = ?`, + string(state), code, msg, calibreDeliveryTime(nextAttemptAt), calibreDeliveryTime(r.now()), id) +} + +// MarkSkipped takes the row out of the queue on purpose. reason lands in +// outcome. +func (r *CalibreDeliveryRepo) MarkSkipped(ctx context.Context, id int64, reason string) error { + return r.execOne(ctx, "mark skipped", ` + UPDATE calibre_deliveries + SET state = 'skipped', outcome = ?, updated_at = ? + WHERE id = ?`, + reason, calibreDeliveryTime(r.now()), id) +} + +// Retry puts failed or skipped rows back in the queue, due now, with attempts +// and the last error reset. state picks which: failed, skipped, or "" for +// both. Pending and delivered rows are never touched. It returns the number +// of rows re-queued. +func (r *CalibreDeliveryRepo) Retry(ctx context.Context, state models.CalibreDeliveryState) (int64, error) { + // Two placeholders either way: a single state is bound twice. + var a, b models.CalibreDeliveryState + switch state { + case models.CalibreDeliveryFailed, models.CalibreDeliverySkipped: + a, b = state, state + case "": + a, b = models.CalibreDeliveryFailed, models.CalibreDeliverySkipped + default: + return 0, fmt.Errorf("calibre deliveries retry: cannot retry %q rows", state) + } + now := calibreDeliveryTime(r.now()) + res, err := r.db.ExecContext(ctx, ` + UPDATE calibre_deliveries + SET state = 'pending', attempts = 0, last_error = '', last_error_code = '', + outcome = '', next_attempt_at = ?, updated_at = ? + WHERE state IN (?, ?)`, now, now, string(a), string(b)) + if err != nil { + return 0, fmt.Errorf("calibre deliveries retry: %w", err) + } + return res.RowsAffected() +} + +// ClearPending drops every pending row, emptying the queue without touching +// the record of what was delivered, failed or skipped. +func (r *CalibreDeliveryRepo) ClearPending(ctx context.Context) (int64, error) { + res, err := r.db.ExecContext(ctx, `DELETE FROM calibre_deliveries WHERE state = 'pending'`) + if err != nil { + return 0, fmt.Errorf("calibre deliveries clear pending: %w", err) + } + return res.RowsAffected() +} + +// ResetAll forgets the whole ledger. It is for a change of push target: every +// row describes the old target, so a delivered row would wrongly claim the +// file is in the new library and its calibre_id would point into the wrong +// one. Deleting rather than re-queueing means nothing is sent to the new +// target until something enqueues it again. +func (r *CalibreDeliveryRepo) ResetAll(ctx context.Context) (int64, error) { + res, err := r.db.ExecContext(ctx, `DELETE FROM calibre_deliveries`) + if err != nil { + return 0, fmt.Errorf("calibre deliveries reset: %w", err) + } + return res.RowsAffected() +} + +// Summary counts rows per state and reports the most recent delivery. +func (r *CalibreDeliveryRepo) Summary(ctx context.Context) (models.CalibreDeliverySummary, error) { + var s models.CalibreDeliverySummary + var last sql.NullString + err := r.db.QueryRowContext(ctx, ` + SELECT + COALESCE(SUM(state = 'pending'), 0), + COALESCE(SUM(state = 'delivered'), 0), + COALESCE(SUM(state = 'failed'), 0), + COALESCE(SUM(state = 'skipped'), 0), + MAX(CASE WHEN state = 'delivered' THEN delivered_at END) + FROM calibre_deliveries`).Scan(&s.Pending, &s.Delivered, &s.Failed, &s.Skipped, &last) + if err != nil { + return s, fmt.Errorf("calibre deliveries summary: %w", err) + } + s.LastDeliveredAt, err = parseFlexibleTime(last) + if err != nil { + return s, fmt.Errorf("calibre deliveries summary: %w", err) + } + return s, nil +} + +// List pages through the ledger, most recently updated first. An empty state +// lists every row. +func (r *CalibreDeliveryRepo) List(ctx context.Context, state models.CalibreDeliveryState, limit, offset int) ([]models.CalibreDelivery, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT `+calibreDeliveryColumns+` FROM calibre_deliveries + WHERE (? = '' OR state = ?) + ORDER BY updated_at DESC, id DESC + LIMIT ? OFFSET ?`, string(state), string(state), limit, offset) + if err != nil { + return nil, fmt.Errorf("calibre deliveries list: %w", err) + } + return scanCalibreDeliveries(rows) +} + +// DeliveredByCalibreID returns the delivered rows that point at calibreID in +// the push target, oldest first. Bindery owns a Calibre record exactly when +// one of these exists for the current target library. Callers compare +// TargetLibrary themselves: a backfilled row has it empty, because the +// library a pre-ledger push went to was never recorded. +func (r *CalibreDeliveryRepo) DeliveredByCalibreID(ctx context.Context, calibreID int64) ([]models.CalibreDelivery, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT `+calibreDeliveryColumns+` FROM calibre_deliveries + WHERE state = 'delivered' AND calibre_id = ? + ORDER BY id`, calibreID) + if err != nil { + return nil, fmt.Errorf("calibre deliveries by calibre id: %w", err) + } + return scanCalibreDeliveries(rows) +} + +// ErrCalibreDeliveryNotFound is returned by the Mark methods when the row is +// gone, for example cleared by ClearPending or cascaded away with its +// book_files row while a delivery was in flight. +var ErrCalibreDeliveryNotFound = errors.New("calibre delivery not found") + +func (r *CalibreDeliveryRepo) execOne(ctx context.Context, what, query string, args ...any) error { + res, err := r.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("calibre deliveries %s: %w", what, err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("calibre deliveries %s rows: %w", what, err) + } + if n == 0 { + return fmt.Errorf("calibre deliveries %s: %w", what, ErrCalibreDeliveryNotFound) + } + return nil +} + +func scanCalibreDeliveries(rows *sql.Rows) ([]models.CalibreDelivery, error) { + defer rows.Close() + var out []models.CalibreDelivery + for rows.Next() { + var d models.CalibreDelivery + var state string + var edition, calibreID sql.NullInt64 + var next, created, updated, delivered sql.NullString + if err := rows.Scan(&d.ID, &d.BookID, &d.BookFileID, &edition, &d.FilePath, &d.Format, &state, + &d.Outcome, &d.Attempts, &d.LastError, &d.LastErrorCode, &calibreID, &d.TargetLibrary, + &next, &created, &updated, &delivered); err != nil { + return nil, fmt.Errorf("scan calibre delivery: %w", err) + } + d.State = models.CalibreDeliveryState(state) + if edition.Valid { + v := edition.Int64 + d.EditionID = &v + } + if calibreID.Valid { + v := calibreID.Int64 + d.CalibreID = &v + } + d.NextAttemptAt = parseFlexibleTimeValue(next, "calibre_deliveries.next_attempt_at") + d.CreatedAt = parseFlexibleTimeValue(created, "calibre_deliveries.created_at") + d.UpdatedAt = parseFlexibleTimeValue(updated, "calibre_deliveries.updated_at") + if t, err := parseFlexibleTime(delivered); err == nil { + d.DeliveredAt = t + } + out = append(out, d) + } + return out, rows.Err() +} diff --git a/internal/db/calibre_deliveries_test.go b/internal/db/calibre_deliveries_test.go new file mode 100644 index 000000000..ffe5bcde4 --- /dev/null +++ b/internal/db/calibre_deliveries_test.go @@ -0,0 +1,419 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "reflect" + "testing" + "time" + + "github.com/vavallee/bindery/internal/models" +) + +type deliveryFixture struct { + db *sql.DB + repo *CalibreDeliveryRepo + clock time.Time + books *BookRepo + book int64 + files []int64 +} + +// newDeliveryFixture opens a database with one book and n ebook files, and a +// repo whose clock the test moves by hand. +func newDeliveryFixture(t *testing.T, n int) *deliveryFixture { + t.Helper() + database, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + f := &deliveryFixture{ + db: database, + books: NewBookRepo(database), + clock: time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC), + } + f.repo = NewCalibreDeliveryRepo(database) + f.repo.now = func() time.Time { return f.clock } + author := seedAuthor(t, NewAuthorRepo(database), "OL1A", "Author") + f.book = seedBook(t, f.books, author.ID, "OL1W", "Book").ID + for i := 0; i < n; i++ { + f.files = append(f.files, f.addFile(t, f.book, "/lib/"+string(rune('a'+i))+".epub")) + } + return f +} + +func (f *deliveryFixture) addFile(t *testing.T, bookID int64, path string) int64 { + t.Helper() + res, err := f.db.Exec(`INSERT INTO book_files (book_id, format, path) VALUES (?, 'ebook', ?)`, bookID, path) + if err != nil { + t.Fatal(err) + } + id, _ := res.LastInsertId() + return id +} + +func (f *deliveryFixture) enqueue(t *testing.T, i int) *models.CalibreDelivery { + t.Helper() + created, err := f.repo.Enqueue(context.Background(), f.book, f.files[i], nil, "/lib//x.epub", ".EPUB") + if err != nil || !created { + t.Fatalf("Enqueue file %d = %v, %v; want created", i, created, err) + } + d, err := f.repo.GetByBookFile(context.Background(), f.files[i]) + if err != nil || d == nil { + t.Fatalf("GetByBookFile %d = %v, %v", i, d, err) + } + return d +} + +func TestCalibreDeliveryEnqueue(t *testing.T) { + f := newDeliveryFixture(t, 1) + ctx := context.Background() + edition := int64(42) + + created, err := f.repo.Enqueue(ctx, f.book, f.files[0], &edition, "/lib//a.epub", ".EPUB") + if err != nil || !created { + t.Fatalf("Enqueue = %v, %v; want true, nil", created, err) + } + d, err := f.repo.GetByBookFile(ctx, f.files[0]) + if err != nil || d == nil { + t.Fatalf("GetByBookFile = %v, %v", d, err) + } + if d.State != models.CalibreDeliveryPending || d.Attempts != 0 || d.BookID != f.book || + d.FilePath != "/lib/a.epub" || d.Format != "epub" || d.EditionID == nil || *d.EditionID != 42 || + d.CalibreID != nil || d.DeliveredAt != nil || !d.NextAttemptAt.Equal(f.clock) || !d.CreatedAt.Equal(f.clock) { + t.Fatalf("enqueued row = %+v", d) + } + if byID, err := f.repo.Get(ctx, d.ID); err != nil || byID == nil || byID.BookFileID != f.files[0] { + t.Fatalf("Get = %+v, %v", byID, err) + } + if missing, err := f.repo.Get(ctx, 9999); err != nil || missing != nil { + t.Fatalf("Get on a missing id = %+v, %v; want nil, nil", missing, err) + } +} + +// TestCalibreDeliveryEnqueueLeavesExistingRows pins the conflict rule: a +// second Enqueue for the same file changes nothing, in any state. Only Retry +// re-arms a failed or skipped row. +func TestCalibreDeliveryEnqueueLeavesExistingRows(t *testing.T) { + ctx := context.Background() + for _, tc := range []struct { + name string + move func(f *deliveryFixture, id int64) error + }{ + {"pending", func(*deliveryFixture, int64) error { return nil }}, + {"delivered", func(f *deliveryFixture, id int64) error { + return f.repo.MarkDelivered(ctx, id, 7, "added", "/target") + }}, + {"failed", func(f *deliveryFixture, id int64) error { + return f.repo.MarkFailed(ctx, id, "rejected", "bad file", f.clock.Add(time.Hour), true) + }}, + {"skipped", func(f *deliveryFixture, id int64) error { return f.repo.MarkSkipped(ctx, id, "unsupported") }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newDeliveryFixture(t, 1) + d := f.enqueue(t, 0) + if err := tc.move(f, d.ID); err != nil { + t.Fatal(err) + } + before, _ := f.repo.Get(ctx, d.ID) + f.clock = f.clock.Add(24 * time.Hour) + created, err := f.repo.Enqueue(ctx, f.book, f.files[0], nil, "/elsewhere/b.mobi", "mobi") + if err != nil || created { + t.Fatalf("second Enqueue = %v, %v; want false, nil", created, err) + } + after, _ := f.repo.Get(ctx, d.ID) + if !reflect.DeepEqual(after, before) { + t.Fatalf("second Enqueue changed the row:\n before %+v\n after %+v", before, after) + } + }) + } +} + +func TestCalibreDeliveryDueBatch(t *testing.T) { + f := newDeliveryFixture(t, 4) + ctx := context.Background() + a := f.enqueue(t, 0) + f.clock = f.clock.Add(time.Second) + b := f.enqueue(t, 1) + // Sub-second precision with trailing zeros is where RFC3339Nano text + // stops sorting: .1 would sort after .123 as text. + f.clock = f.clock.Add(100 * time.Millisecond) + c := f.enqueue(t, 2) + d := f.enqueue(t, 3) + if err := f.repo.MarkDelivered(ctx, d.ID, 1, "added", ""); err != nil { + t.Fatal(err) + } + // a fails transiently and comes due 23ms after c: as RFC3339Nano text + // "…01.123Z" would sort before "…01.1Z" and already be due. + if err := f.repo.MarkFailed(ctx, a.ID, "timeout", "slow", f.clock.Add(23*time.Millisecond), false); err != nil { + t.Fatal(err) + } + + ids := func(ds []models.CalibreDelivery) []int64 { + out := make([]int64, len(ds)) + for i, d := range ds { + out[i] = d.ID + } + return out + } + due, err := f.repo.DueBatch(ctx, f.clock, 10) + if err != nil { + t.Fatal(err) + } + if got := ids(due); len(got) != 2 || got[0] != b.ID || got[1] != c.ID { + t.Fatalf("DueBatch at enqueue time = %v, want [%d %d]", got, b.ID, c.ID) + } + due, err = f.repo.DueBatch(ctx, f.clock.Add(time.Hour), 10) + if err != nil { + t.Fatal(err) + } + if got := ids(due); len(got) != 3 || got[0] != b.ID || got[1] != c.ID || got[2] != a.ID { + t.Fatalf("DueBatch later = %v, want [%d %d %d]", got, b.ID, c.ID, a.ID) + } + due, err = f.repo.DueBatch(ctx, f.clock.Add(time.Hour), 1) + if err != nil || len(due) != 1 || due[0].ID != b.ID { + t.Fatalf("DueBatch limit 1 = %v, %v", ids(due), err) + } +} + +func TestCalibreDeliveryMarkMethods(t *testing.T) { + f := newDeliveryFixture(t, 3) + ctx := context.Background() + a, b, c := f.enqueue(t, 0), f.enqueue(t, 1), f.enqueue(t, 2) + + next := f.clock.Add(5 * time.Minute) + if err := f.repo.MarkFailed(ctx, a.ID, "timeout", "first", next, false); err != nil { + t.Fatal(err) + } + got, _ := f.repo.Get(ctx, a.ID) + if got.State != models.CalibreDeliveryPending || got.Attempts != 1 || got.LastErrorCode != "timeout" || + got.LastError != "first" || !got.NextAttemptAt.Equal(next) { + t.Fatalf("after a transient failure = %+v", got) + } + if err := f.repo.MarkFailed(ctx, a.ID, "rejected", "second", next, true); err != nil { + t.Fatal(err) + } + got, _ = f.repo.Get(ctx, a.ID) + if got.State != models.CalibreDeliveryFailed || got.Attempts != 2 || got.LastErrorCode != "rejected" { + t.Fatalf("after a terminal failure = %+v", got) + } + + // A delivery after a transient failure clears the error. + if err := f.repo.MarkFailed(ctx, b.ID, "timeout", "slow", next, false); err != nil { + t.Fatal(err) + } + f.clock = f.clock.Add(time.Minute) + if err := f.repo.MarkDelivered(ctx, b.ID, 77, "added", "/target/lib"); err != nil { + t.Fatal(err) + } + got, _ = f.repo.Get(ctx, b.ID) + if got.State != models.CalibreDeliveryDelivered || got.CalibreID == nil || *got.CalibreID != 77 || + got.Outcome != "added" || got.TargetLibrary != "/target/lib" || got.LastError != "" || + got.LastErrorCode != "" || got.DeliveredAt == nil || !got.DeliveredAt.Equal(f.clock) || got.Attempts != 1 { + t.Fatalf("after delivery = %+v", got) + } + + if err := f.repo.MarkSkipped(ctx, c.ID, "audiobook"); err != nil { + t.Fatal(err) + } + got, _ = f.repo.Get(ctx, c.ID) + if got.State != models.CalibreDeliverySkipped || got.Outcome != "audiobook" { + t.Fatalf("after skip = %+v", got) + } + + for name, err := range map[string]error{ + "MarkDelivered": f.repo.MarkDelivered(ctx, 9999, 1, "added", ""), + "MarkFailed": f.repo.MarkFailed(ctx, 9999, "x", "y", next, false), + "MarkSkipped": f.repo.MarkSkipped(ctx, 9999, "z"), + } { + if !errors.Is(err, ErrCalibreDeliveryNotFound) { + t.Errorf("%s on a missing row = %v, want ErrCalibreDeliveryNotFound", name, err) + } + } +} + +func TestCalibreDeliveryRetry(t *testing.T) { + f := newDeliveryFixture(t, 4) + ctx := context.Background() + failed, skipped, delivered, pending := f.enqueue(t, 0), f.enqueue(t, 1), f.enqueue(t, 2), f.enqueue(t, 3) + later := f.clock.Add(time.Hour) + if err := f.repo.MarkFailed(ctx, failed.ID, "rejected", "bad", later, true); err != nil { + t.Fatal(err) + } + if err := f.repo.MarkSkipped(ctx, skipped.ID, "why"); err != nil { + t.Fatal(err) + } + if err := f.repo.MarkDelivered(ctx, delivered.ID, 5, "added", ""); err != nil { + t.Fatal(err) + } + if err := f.repo.MarkFailed(ctx, pending.ID, "timeout", "slow", later, false); err != nil { + t.Fatal(err) + } + + if _, err := f.repo.Retry(ctx, models.CalibreDeliveryDelivered); err == nil { + t.Fatal("Retry of delivered rows must be refused") + } + + f.clock = f.clock.Add(time.Minute) + n, err := f.repo.Retry(ctx, models.CalibreDeliveryFailed) + if err != nil || n != 1 { + t.Fatalf("Retry(failed) = %d, %v; want 1", n, err) + } + got, _ := f.repo.Get(ctx, failed.ID) + if got.State != models.CalibreDeliveryPending || got.Attempts != 0 || got.LastError != "" || + got.LastErrorCode != "" || !got.NextAttemptAt.Equal(f.clock) { + t.Fatalf("retried row = %+v", got) + } + if got, _ := f.repo.Get(ctx, skipped.ID); got.State != models.CalibreDeliverySkipped { + t.Fatalf("Retry(failed) touched a skipped row: %+v", got) + } + // A pending row with a transient failure keeps its attempts and backoff. + if got, _ := f.repo.Get(ctx, pending.ID); got.Attempts != 1 || !got.NextAttemptAt.Equal(later) { + t.Fatalf("Retry touched a pending row: %+v", got) + } + + if err := f.repo.MarkFailed(ctx, failed.ID, "rejected", "again", later, true); err != nil { + t.Fatal(err) + } + n, err = f.repo.Retry(ctx, "") + if err != nil || n != 2 { + t.Fatalf(`Retry("") = %d, %v; want 2 (the failed and the skipped row)`, n, err) + } + if got, _ := f.repo.Get(ctx, delivered.ID); got.State != models.CalibreDeliveryDelivered { + t.Fatalf("Retry touched a delivered row: %+v", got) + } +} + +func TestCalibreDeliverySummaryListClearReset(t *testing.T) { + f := newDeliveryFixture(t, 5) + ctx := context.Background() + + s, err := f.repo.Summary(ctx) + if err != nil || s != (models.CalibreDeliverySummary{}) { + t.Fatalf("Summary on an empty ledger = %+v, %v", s, err) + } + + var ds []*models.CalibreDelivery + for i := range f.files { + ds = append(ds, f.enqueue(t, i)) + } + f.clock = f.clock.Add(time.Minute) + if err := f.repo.MarkDelivered(ctx, ds[0].ID, 1, "added", ""); err != nil { + t.Fatal(err) + } + f.clock = f.clock.Add(time.Minute) + lastDelivered := f.clock + if err := f.repo.MarkDelivered(ctx, ds[1].ID, 2, "linked", ""); err != nil { + t.Fatal(err) + } + if err := f.repo.MarkFailed(ctx, ds[2].ID, "rejected", "bad", f.clock, true); err != nil { + t.Fatal(err) + } + if err := f.repo.MarkSkipped(ctx, ds[3].ID, "why"); err != nil { + t.Fatal(err) + } + + s, err = f.repo.Summary(ctx) + if err != nil || s.Pending != 1 || s.Delivered != 2 || s.Failed != 1 || s.Skipped != 1 || + s.LastDeliveredAt == nil || !s.LastDeliveredAt.Equal(lastDelivered) { + t.Fatalf("Summary = %+v, %v", s, err) + } + + all, err := f.repo.List(ctx, "", 10, 0) + if err != nil || len(all) != 5 { + t.Fatalf("List(all) = %d rows, %v", len(all), err) + } + // Most recently updated first; ties broken by the newer id. + if all[0].UpdatedAt.Before(all[len(all)-1].UpdatedAt) { + t.Fatalf("List is not newest first: %v then %v", all[0].UpdatedAt, all[len(all)-1].UpdatedAt) + } + page, err := f.repo.List(ctx, "", 2, 1) + if err != nil || len(page) != 2 || page[0].ID != all[1].ID { + t.Fatalf("List page = %+v, %v", page, err) + } + delivered, err := f.repo.List(ctx, models.CalibreDeliveryDelivered, 10, 0) + if err != nil || len(delivered) != 2 { + t.Fatalf("List(delivered) = %d rows, %v", len(delivered), err) + } + for _, d := range delivered { + if d.State != models.CalibreDeliveryDelivered { + t.Fatalf("List(delivered) returned %+v", d) + } + } + + n, err := f.repo.ClearPending(ctx) + if err != nil || n != 1 { + t.Fatalf("ClearPending = %d, %v; want 1", n, err) + } + if got, _ := f.repo.Get(ctx, ds[4].ID); got != nil { + t.Fatalf("ClearPending left the pending row: %+v", got) + } + s, _ = f.repo.Summary(ctx) + if s.Pending != 0 || s.Delivered != 2 || s.Failed != 1 || s.Skipped != 1 { + t.Fatalf("ClearPending touched a settled row: %+v", s) + } + + n, err = f.repo.ResetAll(ctx) + if err != nil || n != 4 { + t.Fatalf("ResetAll = %d, %v; want 4", n, err) + } + if s, _ := f.repo.Summary(ctx); s != (models.CalibreDeliverySummary{}) { + t.Fatalf("ResetAll left %+v", s) + } +} + +func TestCalibreDeliveryDeliveredByCalibreID(t *testing.T) { + f := newDeliveryFixture(t, 3) + ctx := context.Background() + a, b, c := f.enqueue(t, 0), f.enqueue(t, 1), f.enqueue(t, 2) + if err := f.repo.MarkDelivered(ctx, a.ID, 50, "added", "/t"); err != nil { + t.Fatal(err) + } + if err := f.repo.MarkDelivered(ctx, b.ID, 51, "added", "/t"); err != nil { + t.Fatal(err) + } + // c carries calibre id 50 but is not delivered, so it does not prove + // ownership. + if _, err := f.db.Exec(`UPDATE calibre_deliveries SET calibre_id = 50 WHERE id = ?`, c.ID); err != nil { + t.Fatal(err) + } + got, err := f.repo.DeliveredByCalibreID(ctx, 50) + if err != nil || len(got) != 1 || got[0].ID != a.ID || got[0].TargetLibrary != "/t" { + t.Fatalf("DeliveredByCalibreID(50) = %+v, %v", got, err) + } + if got, err := f.repo.DeliveredByCalibreID(ctx, 999); err != nil || len(got) != 0 { + t.Fatalf("DeliveredByCalibreID(999) = %+v, %v", got, err) + } +} + +// TestCalibreDeliveryCascades checks the ledger follows the rows it points +// at: deleting a book_files row or the whole book removes its deliveries. +func TestCalibreDeliveryCascades(t *testing.T) { + f := newDeliveryFixture(t, 2) + ctx := context.Background() + a, b := f.enqueue(t, 0), f.enqueue(t, 1) + + if _, err := f.db.Exec(`DELETE FROM book_files WHERE id = ?`, f.files[0]); err != nil { + t.Fatal(err) + } + if got, err := f.repo.Get(ctx, a.ID); err != nil || got != nil { + t.Fatalf("delivery for a deleted book file survived: %+v, %v", got, err) + } + if got, _ := f.repo.Get(ctx, b.ID); got == nil { + t.Fatal("deleting one book file removed another file's delivery") + } + if _, err := f.db.Exec(`DELETE FROM books WHERE id = ?`, f.book); err != nil { + t.Fatal(err) + } + if got, err := f.repo.Get(ctx, b.ID); err != nil || got != nil { + t.Fatalf("delivery for a deleted book survived: %+v, %v", got, err) + } + + // And the foreign keys hold on insert: a file that does not exist cannot + // be queued. + if _, err := f.repo.Enqueue(ctx, f.book, 9999, nil, "/x.epub", "epub"); err == nil { + t.Fatal("Enqueue for a missing book file must fail the foreign key") + } +} diff --git a/internal/db/migration_095_test.go b/internal/db/migration_095_test.go new file mode 100644 index 000000000..71445517e --- /dev/null +++ b/internal/db/migration_095_test.go @@ -0,0 +1,202 @@ +package db + +import ( + "context" + "database/sql" + "testing" +) + +// TestMigrate095BackfillsPushedBooks covers the calibre_deliveries backfill +// (#2832). A book whose calibre_id came from a push gets one delivered row for +// the ebook file it reports; a book that came from a Calibre library import +// gets nothing, however that origin is recorded; a book with no ebook file +// gets nothing; and running the migration again adds and changes nothing. +func TestMigrate095BackfillsPushedBooks(t *testing.T) { + database, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer database.Close() + ctx := context.Background() + + // Put the database back at version 94: no ledger table, no marker. + v095 := migrationVersionForTest(t, "095_calibre_deliveries.sql") + mustExec(t, database, `DROP TABLE calibre_deliveries`) + mustExec(t, database, `DELETE FROM schema_migrations WHERE version = ?`, v095) + + authors := NewAuthorRepo(database) + books := NewBookRepo(database) + author := seedAuthor(t, authors, "OL1A", "Author") + + type seeded struct { + id int64 + files map[string]int64 + wantFile string // path of the file that must be backfilled, "" for none + } + seed := func(foreignID, provider string, calibreID any, ebookPath string, files [][2]string) seeded { + t.Helper() + b := seedBook(t, books, author.ID, foreignID, foreignID) + mustExec(t, database, `UPDATE books SET metadata_provider = ?, calibre_id = ?, ebook_file_path = ? WHERE id = ?`, + provider, calibreID, ebookPath, b.ID) + s := seeded{id: b.ID, files: map[string]int64{}} + for _, f := range files { + res, err := database.ExecContext(ctx, `INSERT INTO book_files (book_id, format, path) VALUES (?, ?, ?)`, b.ID, f[0], f[1]) + if err != nil { + t.Fatalf("seed file %s: %v", f[1], err) + } + id, _ := res.LastInsertId() + s.files[f[1]] = id + } + return s + } + + // Pushed, reporting its second ebook file as the ebook path: that one wins + // over the lower id. + pushedPath := seed("OL1W", "openlibrary", 10, "/lib/a/A.MOBI", [][2]string{ + {"ebook", "/lib/a/A.epub"}, + {"ebook", "/lib/a/A.MOBI"}, + }) + pushedPath.wantFile = "/lib/a/A.MOBI" + + // Pushed, no ebook path stored: the lowest id ebook row, not the lower id + // audiobook row. The directory has a dot and the file has an extension. + pushedFirst := seed("OL2W", "hardcover", 11, "", [][2]string{ + {"audiobook", "/lib/b/B (m4b)"}, + {"ebook", "/lib/v1.2/B.azw3"}, + {"ebook", "/lib/v1.2/B.pdf"}, + }) + pushedFirst.wantFile = "/lib/v1.2/B.azw3" + + // Pushed, file with no extension under a dotted directory: format stays empty. + pushedNoExt := seed("OL3W", "openlibrary", 12, "", [][2]string{{"ebook", "/lib/c.d/book"}}) + pushedNoExt.wantFile = "/lib/c.d/book" + + // Calibre origin, each marker on its own. + byForeignID := seed("calibre:book:5", "openlibrary", 5, "", [][2]string{{"ebook", "/cal/5.epub"}}) + byProvider := seed("OL4W", " Calibre ", 6, "", [][2]string{{"ebook", "/cal/6.epub"}}) + byProvenance := seed("OL5W", "openlibrary", 7, "", [][2]string{{"ebook", "/cal/7.epub"}}) + mustExec(t, database, `INSERT INTO calibre_provenance (entity_type, external_id, local_id) VALUES ('book', 'calibre:book:7', ?)`, byProvenance.id) + // A provenance row for another entity type with the same local id is not + // a book import and must not exclude anything. + pushedOtherProv := seed("OL6W", "openlibrary", 13, "", [][2]string{{"ebook", "/lib/e/E.epub"}}) + pushedOtherProv.wantFile = "/lib/e/E.epub" + mustExec(t, database, `INSERT INTO calibre_provenance (entity_type, external_id, local_id) VALUES ('author', 'calibre:author:1', ?)`, pushedOtherProv.id) + + // Pushed but only an audiobook file, and never pushed at all. + audioOnly := seed("OL7W", "openlibrary", 14, "", [][2]string{{"audiobook", "/lib/f/F.m4b"}}) + notPushed := seed("OL8W", "openlibrary", nil, "", [][2]string{{"ebook", "/lib/g/G.epub"}}) + + if err := migrate(database); err != nil { + t.Fatalf("migrate to 095: %v", err) + } + + type row struct { + bookID, fileID, calibreID int64 + path, format, state, outcome, next string + attempts int + delivered sql.NullString + } + readRows := func() map[int64]row { + t.Helper() + rs, err := database.QueryContext(ctx, `SELECT book_id, book_file_id, calibre_id, file_path, format, state, outcome, + CAST(next_attempt_at AS TEXT), attempts, CAST(delivered_at AS TEXT) FROM calibre_deliveries`) + if err != nil { + t.Fatal(err) + } + defer rs.Close() + out := map[int64]row{} + for rs.Next() { + var r row + if err := rs.Scan(&r.bookID, &r.fileID, &r.calibreID, &r.path, &r.format, &r.state, &r.outcome, &r.next, &r.attempts, &r.delivered); err != nil { + t.Fatal(err) + } + if _, dup := out[r.bookID]; dup { + t.Errorf("book %d has more than one backfilled row", r.bookID) + } + out[r.bookID] = r + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return out + } + + got := readRows() + want := map[int64]struct { + s seeded + calibreID int64 + format string + }{ + pushedPath.id: {pushedPath, 10, "mobi"}, + pushedFirst.id: {pushedFirst, 11, "azw3"}, + pushedNoExt.id: {pushedNoExt, 12, ""}, + pushedOtherProv.id: {pushedOtherProv, 13, "epub"}, + } + for bookID, w := range want { + r, ok := got[bookID] + if !ok { + t.Errorf("pushed book %d got no delivered row", bookID) + continue + } + if r.fileID != w.s.files[w.s.wantFile] || r.path != w.s.wantFile { + t.Errorf("book %d backfilled file %d %q, want %d %q", bookID, r.fileID, r.path, w.s.files[w.s.wantFile], w.s.wantFile) + } + if r.state != "delivered" || r.outcome != "backfilled" || r.attempts != 0 { + t.Errorf("book %d row = %s/%s/%d, want delivered/backfilled/0", bookID, r.state, r.outcome, r.attempts) + } + if r.calibreID != w.calibreID { + t.Errorf("book %d calibre_id = %d, want %d", bookID, r.calibreID, w.calibreID) + } + if r.format != w.format { + t.Errorf("book %d format = %q, want %q", bookID, r.format, w.format) + } + if !r.delivered.Valid || len(r.delivered.String) != len(calibreDeliveryTimeLayout) || len(r.next) != len(calibreDeliveryTimeLayout) { + t.Errorf("book %d times = %q / %q, want the fixed width ledger shape", bookID, r.next, r.delivered.String) + } + } + for name, s := range map[string]seeded{ + "calibre foreign_id": byForeignID, + "calibre provider": byProvider, + "calibre provenance": byProvenance, + "audiobook only": audioOnly, + "never pushed": notPushed, + } { + if _, ok := got[s.id]; ok { + t.Errorf("%s book %d must not be backfilled", name, s.id) + } + } + if len(got) != len(want) { + t.Errorf("backfill wrote %d rows, want %d", len(got), len(want)) + } + + // The repo must read a backfilled row back with its times intact. + d, err := NewCalibreDeliveryRepo(database).GetByBookFile(ctx, pushedPath.files["/lib/a/A.MOBI"]) + if err != nil || d == nil || d.DeliveredAt == nil || d.NextAttemptAt.IsZero() { + t.Fatalf("GetByBookFile on a backfilled row = %+v, %v", d, err) + } + + // Running the migration again adds nothing and changes nothing, even for a + // row whose state has moved on since. + mustExec(t, database, `UPDATE calibre_deliveries SET state = 'failed', calibre_id = 99 WHERE book_id = ?`, pushedFirst.id) + before := readRows() + mustExec(t, database, `DELETE FROM schema_migrations WHERE version = ?`, v095) + if err := migrate(database); err != nil { + t.Fatalf("rerun migration 095: %v", err) + } + after := readRows() + if len(after) != len(before) { + t.Fatalf("rerun changed the row count from %d to %d", len(before), len(after)) + } + for id, r := range before { + if after[id] != r { + t.Errorf("rerun changed book %d from %+v to %+v", id, r, after[id]) + } + } +} + +func mustExec(t *testing.T, database *sql.DB, query string, args ...any) { + t.Helper() + if _, err := database.ExecContext(context.Background(), query, args...); err != nil { + t.Fatalf("%s: %v", query, err) + } +} diff --git a/internal/db/migrations/095_calibre_deliveries.sql b/internal/db/migrations/095_calibre_deliveries.sql new file mode 100644 index 000000000..17b803781 --- /dev/null +++ b/internal/db/migrations/095_calibre_deliveries.sql @@ -0,0 +1,101 @@ +-- +migrate Up +-- calibre_deliveries is the durable ledger of ebook files sent to Calibre +-- (#2832). Before it, a failed live push was one WARN line and the book was +-- missed for good. One row per ebook book_files row, keyed on book_file_id. +-- +-- calibre_id here is the record id in the push TARGET library. It is not +-- books.calibre_id, which keeps meaning the id in the library at +-- calibre.library_path (the import source). +-- +-- format is the file extension, lower case and without the dot ('epub'). +-- +-- Time columns are written by Go in one fixed width UTC shape, +-- 2006-01-02T15:04:05.000000000Z (see calibreDeliveryTime), so that +-- next_attempt_at compares and sorts as text and the (state, next_attempt_at) +-- index serves the due query. RFC3339Nano trims trailing zeros, which breaks +-- text ordering below the second, and CURRENT_TIMESTAMP uses a space +-- separator that sorts before every 'T' on the same day. The DEFAULTs exist +-- only so a hand written row is valid. Every writer passes the value. +CREATE TABLE IF NOT EXISTS calibre_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + book_file_id INTEGER NOT NULL REFERENCES book_files(id) ON DELETE CASCADE, + edition_id INTEGER, + file_path TEXT NOT NULL, + format TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'pending' + CHECK(state IN ('pending','delivered','failed','skipped')), + outcome TEXT NOT NULL DEFAULT '', + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + last_error_code TEXT NOT NULL DEFAULT '', + calibre_id INTEGER, + target_library TEXT NOT NULL DEFAULT '', + next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + delivered_at DATETIME, + UNIQUE (book_file_id) +); +CREATE INDEX IF NOT EXISTS idx_calibre_deliveries_due ON calibre_deliveries(state, next_attempt_at); +CREATE INDEX IF NOT EXISTS idx_calibre_deliveries_book ON calibre_deliveries(book_id); + +-- Backfill: a book that already carries a calibre_id and did NOT come from a +-- Calibre library import got that id from a push, so its ebook file is +-- recorded as delivered. Without this, the first "push all" after upgrade +-- would send every one of those books again. +-- +-- Calibre origin is excluded three ways. The first two are the test +-- isCalibreOrigin in internal/calibre/syncer.go already applies before it +-- refuses to overwrite a calibre_id (metadata_provider 'calibre', or a +-- foreign_id of calibre:book:N). The third is a book row in +-- calibre_provenance, which the library import writes for EVERY book it +-- matches, including an existing Bindery book it adopted by title and left +-- with its own foreign_id and provider. Such a book's calibre_id is an id in +-- the import library, not proof of a push. Leaving a pushed book out costs a +-- duplicate check on the next push, while recording an unpushed one as +-- delivered would hide it from delivery for good, so doubt excludes. +-- +-- The file is the one the book reports as its ebook path +-- (books.ebook_file_path, the order bookColumns reads) and otherwise the +-- lowest id ebook row, the bookCTE fallback. A book with no ebook row gets +-- nothing. ON CONFLICT keeps a re-run from adding or changing anything. +WITH picked AS ( + SELECT b.id AS book_id, + b.calibre_id AS calibre_id, + COALESCE( + (SELECT MIN(x.id) FROM book_files x + WHERE x.book_id = b.id AND x.format = 'ebook' AND x.path = b.ebook_file_path), + (SELECT MIN(x.id) FROM book_files x + WHERE x.book_id = b.id AND x.format = 'ebook') + ) AS book_file_id + FROM books b + WHERE b.calibre_id IS NOT NULL AND b.calibre_id > 0 + AND LOWER(TRIM(COALESCE(b.metadata_provider, ''))) <> 'calibre' + AND LOWER(TRIM(COALESCE(b.foreign_id, ''))) NOT LIKE 'calibre:book:%' + AND NOT EXISTS (SELECT 1 FROM calibre_provenance p + WHERE p.entity_type = 'book' AND p.local_id = b.id) +), +files AS ( + SELECT p.book_id, p.calibre_id, bf.id AS book_file_id, bf.path, + substr(bf.path, length(rtrim(bf.path, replace(bf.path, '.', ''))) + 1) AS tail + FROM picked p + JOIN book_files bf ON bf.id = p.book_file_id +), +stamp AS ( + SELECT strftime('%Y-%m-%dT%H:%M:%f', 'now') || '000000Z' AS ts +) +INSERT INTO calibre_deliveries + (book_id, book_file_id, file_path, format, state, outcome, attempts, + calibre_id, next_attempt_at, created_at, updated_at, delivered_at) +SELECT f.book_id, f.book_file_id, f.path, + CASE WHEN instr(f.path, '.') > 0 AND instr(f.tail, '/') = 0 AND instr(f.tail, char(92)) = 0 + THEN LOWER(f.tail) ELSE '' END, + 'delivered', 'backfilled', 0, + f.calibre_id, s.ts, s.ts, s.ts, s.ts +FROM files f, stamp s +WHERE 1 +ON CONFLICT(book_file_id) DO NOTHING; + +-- +migrate Down +DROP TABLE IF EXISTS calibre_deliveries; diff --git a/internal/models/calibre_delivery.go b/internal/models/calibre_delivery.go new file mode 100644 index 000000000..46eecd824 --- /dev/null +++ b/internal/models/calibre_delivery.go @@ -0,0 +1,56 @@ +package models + +import "time" + +// CalibreDeliveryState is where one ebook file stands in the Calibre delivery +// ledger (#2832). The values are the CHECK constraint on +// calibre_deliveries.state, so keep the two in step. +type CalibreDeliveryState string + +const ( + // CalibreDeliveryPending is waiting for its next_attempt_at. + CalibreDeliveryPending CalibreDeliveryState = "pending" + // CalibreDeliveryDelivered reached Calibre; calibre_id holds the target's id. + CalibreDeliveryDelivered CalibreDeliveryState = "delivered" + // CalibreDeliveryFailed gave up after a terminal error. Only an explicit + // retry puts it back in the queue. + CalibreDeliveryFailed CalibreDeliveryState = "failed" + // CalibreDeliverySkipped was deliberately not sent (the outcome says why). + CalibreDeliverySkipped CalibreDeliveryState = "skipped" +) + +// CalibreDelivery is one row of the calibre_deliveries ledger: one ebook +// book_files row and whether it has been sent to the Calibre push target. +// +// CalibreID is the record id in the push target library. It is deliberately +// not books.calibre_id, which keeps meaning the id in the library at +// calibre.library_path. +type CalibreDelivery struct { + ID int64 `json:"id"` + BookID int64 `json:"bookId"` + BookFileID int64 `json:"bookFileId"` + EditionID *int64 `json:"editionId,omitempty"` + FilePath string `json:"filePath"` + Format string `json:"format"` + State CalibreDeliveryState `json:"state"` + Outcome string `json:"outcome"` + Attempts int `json:"attempts"` + LastError string `json:"lastError"` + LastErrorCode string `json:"lastErrorCode"` + CalibreID *int64 `json:"calibreId,omitempty"` + TargetLibrary string `json:"targetLibrary"` + NextAttemptAt time.Time `json:"nextAttemptAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeliveredAt *time.Time `json:"deliveredAt,omitempty"` +} + +// CalibreDeliverySummary is the ledger at a glance: row counts per state and +// the most recent delivery. LastDeliveredAt is nil when nothing was delivered. +type CalibreDeliverySummary struct { + Pending int `json:"pending"` + Delivered int `json:"delivered"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + LastDeliveredAt *time.Time `json:"lastDeliveredAt,omitempty"` +}