diff --git a/backend/internal/ports/errors.go b/backend/internal/ports/errors.go new file mode 100644 index 0000000..1cc6dfa --- /dev/null +++ b/backend/internal/ports/errors.go @@ -0,0 +1,24 @@ +package ports + +import ( + "errors" + + "booklib/internal/store" +) + +// Re-export sentinel errors so handlers can use errors.Is via ports. +var ( + ErrLastAdmin = store.ErrLastAdmin + ErrUniqueViolation = store.ErrUniqueViolation +) + +// IsUniqueViolation consolidates the pg 23505 check into a single predicate. +func IsUniqueViolation(err error) bool { return store.IsUniqueViolation(err) } + +// Upload sentinel errors. +var ( + ErrTooLarge = errors.New("file too large") + ErrIncomplete = errors.New("upload incomplete") + ErrSizeMismatch = errors.New("size mismatch") + ErrNotFound = errors.New("not found") +) diff --git a/backend/internal/ports/ports.go b/backend/internal/ports/ports.go new file mode 100644 index 0000000..ec4f698 --- /dev/null +++ b/backend/internal/ports/ports.go @@ -0,0 +1,99 @@ +// Package ports defines consumer-side interfaces for the backend services. +// Handlers depend on these interfaces, not on concrete implementations. +// Main.go wires concrete types (*store.Store, *redispkg.R, etc.) that satisfy them. +// +// Value types (User, Book, etc.) live in internal/store and are referenced here. +package ports + +import ( + "context" + "io" + "time" + + "booklib/internal/store" +) + +// ---------- Store interfaces ---------- + +type UserStore interface { + CountUsers(ctx context.Context) (int, error) + CreateUser(ctx context.Context, username, hash, role string) (int64, error) + GetUserByName(ctx context.Context, username string) (store.User, error) + GetUserByID(ctx context.Context, id int64) (store.User, error) + ListUsers(ctx context.Context) ([]store.User, error) + DeleteUser(ctx context.Context, id int64) error +} + +type LibraryStore interface { + CreateLibrary(ctx context.Context, name, root string) (int64, error) + ListLibraries(ctx context.Context) ([]store.Library, error) + GetLibrary(ctx context.Context, id int64) (store.Library, error) +} + +type BookStore interface { + InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) + GetBook(ctx context.Context, id int64) (store.Book, error) + ListBookMeta(ctx context.Context, libID int64) (map[string]store.BookMeta, error) + UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error + DeleteBookByPath(ctx context.Context, libID int64, path string) error + DeleteBook(ctx context.Context, id int64) error + SetBookState(ctx context.Context, id int64, state, msg string) error + ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]store.BookView, error) + BookHashes(ctx context.Context) (map[int64][2]int64, error) +} + +type ProgressStore interface { + UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error + GetProgress(ctx context.Context, userID, libID int64, bookPath string) (store.Progress, error) + ListProgress(ctx context.Context, userID int64) ([]store.Progress, error) +} + +type BookmarkStore interface { + InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) + ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]store.Bookmark, error) + UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) + DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) +} + +// ---------- Redis interfaces ---------- + +type PageCache interface { + Get(ctx context.Context, key string) (string, bool) + Set(ctx context.Context, key, val string, ttl time.Duration) +} + +type RateLimiter interface { + IncrWindow(ctx context.Context, key string, ttl time.Duration) int +} + +type ScanLocker interface { + ScanLock(ctx context.Context, key string, ttl time.Duration) (func(), bool) +} + +// ---------- Service interfaces ---------- + +type Scanner interface { + ScanLibraryByID(ctx context.Context, id int64) +} + +type UploadSessions interface { + Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error) + Status(ctx context.Context, uploadID string) ([]int64, error) + PutPart(ctx context.Context, uploadID string, index int64, body io.Reader, maxSize int64) error + Complete(ctx context.Context, uploadID string, root string) (string, error) + Sweep(ctx context.Context) error + UniquePath(root, name string) (string, error) +} + +type Media interface { + EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error + ChaptersOf(idx []string) []Chapter + PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error) + CacheBuster(size, modTS int64) string +} + +// Chapter represents a CBZ chapter derived from the archive's folder structure. +type Chapter struct { + Title string `json:"title"` + Start int `json:"start"` +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 70e5121..ed2ff85 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -13,6 +13,9 @@ import ( // ErrLastAdmin is returned by DeleteUser when attempting to delete the last admin. var ErrLastAdmin = errors.New("cannot delete the last admin") +// ErrUniqueViolation is a sentinel for PostgreSQL unique constraint violations. +var ErrUniqueViolation = errors.New("unique violation") + // IsUniqueViolation reports whether err is a PostgreSQL unique constraint violation (23505). func IsUniqueViolation(err error) bool { var pgErr *pgconn.PgError