The Repository Pattern is the primary data access pattern in the Tome project. It provides a clean abstraction layer between business logic and database operations, making code more maintainable, testable, and consistent.
Why Repository Pattern?
- Centralized data access: All database queries in one place
- Type safety: Full TypeScript support with Drizzle ORM
- Testability: Easy to mock repositories in tests
- Consistency: Standard CRUD operations across all tables
- Maintainability: Changes to data access logic stay in repositories
API Route / Service
↓
Repository (business logic, filtering)
↓
Drizzle ORM (query building)
↓
SQLite Database
Never bypass repositories! Always use repositories for database access.
All repositories extend BaseRepository<T, InsertT, TableT>, which provides standard CRUD operations.
Location: lib/repositories/base.repository.ts
- T: The select type (returned from queries)
- InsertT: The insert type (for creating/updating records)
- TableT: The Drizzle table schema type
Find a single record by its ID.
const book = await bookRepository.findById(123);
if (book) {
console.log(book.title);
}Find all records in the table.
const allBooks = await bookRepository.findAll();Find records matching a WHERE clause.
import { eq } from "drizzle-orm";
import { books } from "@/lib/db/schema/books";
const orphanedBooks = await bookRepository.find(
eq(books.orphaned, true)
);Find a single record matching a WHERE clause.
import { eq } from "drizzle-orm";
import { books } from "@/lib/db/schema/books";
const book = await bookRepository.findOne(
eq(books.calibreId, 456)
);Create a new record.
const newBook = await bookRepository.create({
calibreId: 789,
title: "The Great Gatsby",
authors: ["F. Scott Fitzgerald"],
path: "/path/to/book",
});Update a record by ID.
const updated = await bookRepository.update(123, {
totalPages: 350,
rating: 5,
});Delete a record by ID. Returns true if deleted, false if not found.
const deleted = await bookRepository.delete(123);
if (deleted) {
console.log("Book deleted");
}Delete records matching a WHERE clause. Returns count of deleted records.
import { eq } from "drizzle-orm";
import { books } from "@/lib/db/schema/books";
const deletedCount = await bookRepository.deleteWhere(
eq(books.orphaned, true)
);
console.log(`Deleted ${deletedCount} orphaned books`);Count all records in the table.
const totalBooks = await bookRepository.count();Count records matching a WHERE clause.
import { eq } from "drizzle-orm";
import { books } from "@/lib/db/schema/books";
const orphanedCount = await bookRepository.countWhere(
eq(books.orphaned, true)
);Check if a record exists by ID.
if (await bookRepository.exists(123)) {
console.log("Book exists");
}Each table has its own specialized repository extending BaseRepository with custom methods.
Location: lib/repositories/book.repository.ts
Import: import { bookRepository } from "@/lib/repositories/book.repository";
Find a book by its Calibre ID.
const book = await bookRepository.findByCalibreId(456);findWithFilters(filters: BookFilter, limit: number, skip: number, sortBy?: string): Promise<{ books: Book[], total: number }>
Find books with complex filtering, pagination, and sorting.
const { books, total } = await bookRepository.findWithFilters(
{
status: "reading",
search: "Harry Potter",
tags: ["Fantasy"],
rating: "4+",
showOrphaned: false,
},
50, // limit
0, // skip
"title" // sortBy
);
console.log(`Found ${total} books, showing ${books.length}`);Supported Filters:
status: Filter by reading status (joins with sessions)search: Search title and authors (case-insensitive)tags: Filter by tags (JSON array contains)rating: Filter by rating ("all" | "5" | "4+" | "3+" | "2+" | "1+" | "unrated")showOrphaned: Include orphaned booksorphanedOnly: Show only orphaned books
Supported Sort Options:
title/title_descauthor/author_desccreated/created_descrating/rating_asc
Update a book by Calibre ID.
const updated = await bookRepository.updateByCalibreId(456, {
rating: 5,
lastSynced: new Date(),
});Find books not in the provided list of Calibre IDs (for orphaning).
const currentCalibreIds = [1, 2, 3, 4, 5];
const orphanedBooks = await bookRepository.findNotInCalibreIds(currentCalibreIds);Mark a book as orphaned (removed from Calibre library).
await bookRepository.markAsOrphaned(123);Get all unique tags from all books, sorted alphabetically.
const tags = await bookRepository.getAllTags();
// ["Fantasy", "Fiction", "Mystery", "Science Fiction"]Location: lib/repositories/session.repository.ts
Import: import { sessionRepository } from "@/lib/repositories/session.repository";
Find all sessions for a book (all read-throughs).
const sessions = await sessionRepository.findByBookId(123);Find the active session for a book.
const activeSession = await sessionRepository.findActiveByBookId(123);
if (activeSession) {
console.log(`Currently ${activeSession.status}`);
}Find all active sessions with a specific status.
const readingSessions = await sessionRepository.findByStatus("reading");Get the next session number for a book (for re-reading).
const nextNum = await sessionRepository.getNextSessionNumber(123);
// If book has sessions 1 and 2, returns 3Deactivate all sessions except the specified one.
await sessionRepository.deactivateOtherSessions(123, 456);Location: lib/repositories/progress.repository.ts
Import: import { progressRepository } from "@/lib/repositories/progress.repository";
Find progress logs for a book, ordered by date DESC.
const logs = await progressRepository.findByBookId(123, 10);Find progress logs for a specific session.
const logs = await progressRepository.findBySessionId(456);Find the most recent progress log for a book.
const latest = await progressRepository.findLatestByBookId(123);
if (latest) {
console.log(`Currently on page ${latest.currentPage}`);
}Find progress logs within a date range.
const startOfMonth = new Date("2024-01-01");
const endOfMonth = new Date("2024-01-31");
const logs = await progressRepository.findByDateRange(startOfMonth, endOfMonth);Get unique dates that have progress (for streak calculations).
const activeDates = await progressRepository.getUniqueDatesWithProgress();
// ["2024-01-15", "2024-01-16", "2024-01-17"]Location: lib/repositories/streak.repository.ts
Import: import { streakRepository } from "@/lib/repositories/streak.repository";
Get the active streak (singleton for single-user mode).
const streak = await streakRepository.getActiveStreak();
if (streak) {
console.log(`Current streak: ${streak.currentStreak} days`);
}Create or update the active streak.
const streak = await streakRepository.upsertStreak({
currentStreak: 5,
longestStreak: 10,
lastActivityDate: new Date(),
});First, define your Drizzle schema in lib/db/schema/your-table.ts:
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const yourTable = sqliteTable("your_table", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export type YourModel = typeof yourTable.$inferSelect;
export type NewYourModel = typeof yourTable.$inferInsert;Create lib/repositories/your.repository.ts:
import { BaseRepository } from "./base.repository";
import { yourTable, YourModel, NewYourModel } from "@/lib/db/schema/your-table";
import { eq } from "drizzle-orm";
export class YourRepository extends BaseRepository<
YourModel,
NewYourModel,
typeof yourTable
> {
constructor() {
super(yourTable);
}
/**
* Custom method: Find by name
*/
async findByName(name: string): Promise<YourModel | undefined> {
return this.findOne(eq(yourTable.name, name));
}
/**
* Custom method: Search by partial name
*/
async searchByName(query: string): Promise<YourModel[]> {
return this.getDatabase()
.select()
.from(yourTable)
.where(like(yourTable.name, `%${query}%`))
.all();
}
}
// Singleton instance
export const yourRepository = new YourRepository();// app/api/your-resource/route.ts
import { NextRequest, NextResponse } from "next/server";
import { yourRepository } from "@/lib/repositories/your.repository";
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const name = searchParams.get("name");
if (name) {
const item = await yourRepository.findByName(name);
if (!item) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(item);
}
const all = await yourRepository.findAll();
return NextResponse.json(all);
} catch (error) {
console.error("Error fetching items:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}Use setDatabase() and resetDatabase() from lib/db/context.ts:
import { test, expect, beforeEach } from "bun:test";
import { setDatabase, resetDatabase } from "@/lib/db/context";
import { db as testDb } from "@/lib/db/sqlite";
import { bookRepository } from "@/lib/repositories/book.repository";
beforeEach(() => {
// Switch to test database
setDatabase(testDb);
// Clear all tables
resetDatabase();
});
test("should create a book", async () => {
const book = await bookRepository.create({
calibreId: 1,
title: "Test Book",
authors: ["Test Author"],
path: "/test/path",
});
expect(book.id).toBeDefined();
expect(book.title).toBe("Test Book");
});
test("should find book by calibreId", async () => {
// Arrange
await bookRepository.create({
calibreId: 123,
title: "Test Book",
authors: ["Author"],
path: "/path",
});
// Act
const found = await bookRepository.findByCalibreId(123);
// Assert
expect(found).toBeDefined();
expect(found?.title).toBe("Test Book");
});For unit tests, mock the repository:
import { test, expect, mock } from "bun:test";
import { bookRepository } from "@/lib/repositories/book.repository";
test("should handle book not found", async () => {
// Mock the repository method
const mockFindById = mock(() => Promise.resolve(undefined));
bookRepository.findById = mockFindById;
// Test your service/API that uses the repository
const result = await yourService.getBook(999);
expect(result).toBeNull();
expect(mockFindById).toHaveBeenCalledWith(999);
});Use repositories for all database access:
// ✅ CORRECT
import { bookRepository } from "@/lib/repositories/book.repository";
const books = await bookRepository.findAll();Add custom methods for complex queries:
// ✅ CORRECT - Complex query in repository
class BookRepository extends BaseRepository<...> {
async findReadingBooksWithProgress(): Promise<BookWithProgress[]> {
// Complex join logic here
}
}Handle errors properly:
// ✅ CORRECT
try {
const book = await bookRepository.create(data);
return NextResponse.json(book);
} catch (error) {
console.error("Error creating book:", error);
return NextResponse.json(
{ error: "Failed to create book" },
{ status: 500 }
);
}Use type inference:
// ✅ CORRECT - TypeScript knows the types
const book = await bookRepository.findById(123);
if (book) {
console.log(book.title); // TypeScript knows this exists
}Never bypass repositories:
// ❌ WRONG - Direct db access
import { db } from "@/lib/db/sqlite";
import { books } from "@/lib/db/schema/books";
const allBooks = db.select().from(books).all();Don't put business logic in API routes:
// ❌ WRONG - Complex logic in API route
export async function GET(request: NextRequest) {
const db = getDatabase();
const books = db.select().from(books)
.where(eq(books.status, "reading"))
.join(sessions, ...)
.all(); // Complex query here!
}
// ✅ CORRECT - Logic in repository
export async function GET(request: NextRequest) {
const books = await bookRepository.findReadingBooks();
}Don't skip error handling:
// ❌ WRONG - No error handling
export async function GET(request: NextRequest) {
const book = await bookRepository.findById(123);
return NextResponse.json(book); // What if it throws?
}Don't use 'any' types:
// ❌ WRONG
async findByName(name: string): Promise<any> {
return this.find(eq(this.table.name, name));
}
// ✅ CORRECT
async findByName(name: string): Promise<YourModel | undefined> {
return this.findOne(eq(this.table.name, name));
}// Build WHERE conditions dynamically
const conditions: SQL[] = [];
if (filters.status) {
conditions.push(eq(table.status, filters.status));
}
if (filters.search) {
conditions.push(
or(
like(table.title, `%${filters.search}%`),
like(table.author, `%${filters.search}%`)
)!
);
}
const whereClause = conditions.length > 0
? and(...conditions)
: undefined;
return this.getDatabase()
.select()
.from(table)
.where(whereClause)
.all();async findWithPagination(
limit: number = 50,
offset: number = 0
): Promise<{ items: T[], total: number }> {
// Get total count
const total = await this.count();
// Get paginated results
const items = this.getDatabase()
.select()
.from(this.table)
.limit(limit)
.offset(offset)
.all();
return { items, total };
}async upsert(uniqueField: string, data: InsertT): Promise<T> {
const existing = await this.findOne(
eq(this.table[uniqueField], data[uniqueField])
);
if (existing) {
return this.update(existing.id, data);
} else {
return this.create(data);
}
}async findBooksWithSessions(): Promise<BookWithSession[]> {
return this.getDatabase()
.select({
// Book fields
id: books.id,
title: books.title,
authors: books.authors,
// Session fields
sessionStatus: readingSessions.status,
startedDate: readingSessions.startedDate,
})
.from(books)
.leftJoin(
readingSessions,
and(
eq(readingSessions.bookId, books.id),
eq(readingSessions.isActive, true)
)
)
.all();
}- AI Coding Patterns:
docs/AI_CODING_PATTERNS.md- Repository pattern usage in context - Architecture:
docs/ARCHITECTURE.md- Overall system design - Base Repository:
lib/repositories/base.repository.ts- Source code - Example Repositories:
lib/repositories/book.repository.tslib/repositories/session.repository.tslib/repositories/progress.repository.tslib/repositories/streak.repository.ts
Last Updated: 2025-11-20 For: All developers working on Tome Status: Production pattern - use for all database access