A document in ReadMachine has two lives. One is the file on disk. The other is everything the app knows about it: its place on a shelf, my current reading position, the pages I’ve visited, and my notes.
When I open the library again, I need both. But I don’t need them stored in the same place.
ReadMachine currently reads PDF documents, with more formats planned. It keeps source documents as ordinary files inside a user-selected library. SQLite stores the app’s own records and reading state. I use GRDB to work with that database.
The interesting decision isn’t simply choosing SQLite. It’s deciding what SQLite should own, what the filesystem should own, and what happens when one operation needs to change both.
Two kinds of storage, two different jobs
The current macOS library has a straightforward layout:
ReadMachine/
├── library/ source documents
├── library.sqlite app state
├── cache/covers/ generated thumbnails
└── Trash/ files moved out of the library
The folder paths come from DirectLibraryAccess. LibraryFilesystem.prepare creates the required directories before the database opens.
The source document is not stored as a database blob. SQLite stores a Book ID, paths and metadata, shelf membership, reading position, visited-page ranges, bookmarks, annotations, and local-link aliases. A generated cover goes into the cache because ReadMachine can build it again from the document.
That split also affects how the app handles missing files. A Book row can still exist after a file is moved outside ReadMachine or becomes unavailable through a file provider. During reconciliation, the scanner checks the filesystem and the store updates the record’s availability.
The database describes the library; it does not guarantee that every document is currently readable.
Keeping this distinction clear should help when more formats arrive. EPUB may need a different parser and reader, but it shouldn’t need a separate way to save shelves or reading progress.
One store, without putting SQL in SwiftUI
At the center of persistence is LibraryStore, a Swift actor that owns the GRDB DatabasePool, the library access object, and the file scanner:
actor LibraryStore {
let access: any LibraryAccess
let database: DatabasePool
let scanner: LibraryFileScanner
}
The implementation is divided into extensions for scanning, reading, shelves, content identity, presentation, and snapshots. These are source-code divisions, not separate databases or separate actors.
WorkspaceViewModel asks the store for data or sends it an operation. The SwiftUI views don’t run SQL. This doesn’t require a large set of repository protocols: one focused actor gives the rest of the app a place to work with persisted data.
The actor also makes ownership easier to follow. But it doesn’t replace GRDB’s concurrency rules.
DatabasePool already manages its connections. It supports concurrent reads and serializes writes. The actor is an application-level choice; GRDB does not need it to make database access safe.
And an actor method isn’t automatically a database transaction. If a method reads something, reaches an await, and later writes something else, another actor call may run while the first method is suspended.
The code still needs explicit transaction boundaries.
A shelf change is more than one SQL statement
Consider adding a book to a shelf. ReadMachine supports several shelves per book and a separate book order for each shelf.
The book_shelves table holds those relationships. When toggleMembership adds a membership, it checks whether the pair already exists, finds an insertion position, shifts existing positions if needed, and inserts the new row.
All of that happens inside one database.write call.
This matters because the membership and its order are one logical change. If an insert fails, I don’t want half of the ordering changes left behind.
GRDB’s write runs the closure in a transaction. The statements commit together, or an error rolls the transaction back. That’s a database guarantee, not something the actor provides by itself.
The same idea appears when resetting reading progress. ReadMachine removes the visited-page ranges and writes the new reading position in one transaction. The reset shouldn’t leave the database showing a fresh position with old coverage data.
The database can reject an old reader event
Reading state is written during interaction with the Reader. Page changes, scrolling, zoom, and display-mode changes can reach the store through asynchronous tasks.
One delayed task shouldn’t overwrite a more recent reading position only because it finished later.
In LibraryStore+Reading.swift, saveReadingState has a small but important rule:
ON CONFLICT(bookID) DO UPDATE SET
pageIndex=excluded.pageIndex,
scrollPosition=excluded.scrollPosition,
zoomScale=excluded.zoomScale,
displayMode=excluded.displayMode,
updatedAt=excluded.updatedAt
WHERE excluded.updatedAt >= reading_states.updatedAt
This is the update portion of the real UPSERT. The excluded values come from the incoming row. If the stored row has a later updatedAt, SQLite skips the update.
That makes the timestamp check part of the write itself. It doesn’t rely only on tasks reaching the store in the right order.
There are limits. Equal timestamps are accepted, and a timestamp isn’t a perfect version number. Also, resetReadingProgress uses a separate, unconditional UPSERT to apply an explicit reset. So this isn’t a claim that every reading-state write uses the same conflict rule.
I like this detail because it shows why keeping SQL visible can be useful. A meaningful part of concurrency correctness lives in one line of the query.
A snapshot should describe one moment
The reader isn’t the only part of the app that needs consistent data. Home needs books, shelves, memberships, progress, bookmarks, and annotations together.
LibraryStore.snapshot fetches those records inside one database.read closure and assembles a LibrarySnapshot.
GRDB gives that read a transactionally consistent view of SQLite. The UI won’t receive a set of books from one database moment and their shelf memberships from another moment inside the same snapshot.
This is different from saying the UI automatically watches SQLite.
GRDB offers ValueObservation, but ReadMachine currently uses explicit snapshots and refreshes from its workspace model. I don’t want to describe an observation-based UI that the code hasn’t implemented.
The schema protects relationships too
Some rules are worth enforcing below the UI.
book_shelves uses a composite primary key so the same book can’t be added twice to one shelf. Bookmark records have a unique book-and-page pair. Shelf names have a case-insensitive uniqueness rule.
Several records also reference their parent Book with ON DELETE CASCADE. Deleting a Book row can therefore remove its dependent memberships, bookmarks, and annotations from SQLite.
But it doesn’t delete the document file. A foreign key only governs database records.
These constraints are useful even when the UI normally avoids invalid actions. They keep basic relationships valid if another code path changes the data.
ReadMachine has grown its schema through DatabaseMigrator: from books and shelves to reader annotations, coverage ranges, local links, and duplicate candidates. Each migration is registered with a stable identifier. Two historical names happen to start with 006, but their full identifiers differ; renaming an old migration would be the dangerous change.
A pre-migration copy is not a complete backup strategy
Before DatabasePool opens an existing non-empty database, LibraryFilesystem.prepare copies library.sqlite to library.sqlite.before-migration.
The name makes it sound like the copy happens only when there is a migration to run. It doesn’t. The current code makes that copy on library preparation whenever the database file already exists and is non-empty.
That is useful context for debugging an upgrade, but I wouldn’t promise it is a reliable recovery backup.
The implementation copies the main SQLite file with FileManager.copyItem. It doesn’t use SQLite’s Online Backup API, and it doesn’t coordinate that copy with a live writer or a possible WAL file. Copying only the main file is not a general solution for backing up an active WAL-mode database.
A proper backup feature would need a stronger design. Describing the existing file copy honestly is more helpful than calling it a safety guarantee it doesn’t provide.
SQLite cannot undo a file move
The clearest limit appears when ReadMachine resolves a possible duplicate.
It first lets the user choose which document and which metadata to keep. Then resolveDuplicate moves the rejected source file into the library’s Trash folder.
After that, it runs a GRDB write transaction: update the kept Book’s chosen metadata, mark the other Book as trashed, and remove the duplicate candidate.
The order matters:
move file to Trash
↓
write SQLite changes
↓
if the write fails, try moving the file back
The database changes are transactional. The file move is not part of that transaction.
If the database update fails, the code attempts to move the file back. If that recovery move also fails, it is not reported as a separate success: the original database error is thrown, and the filesystem may still need repair.
This is compensation, not an atomic operation across two systems. A crash between the move and the database update is another case the SQLite transaction cannot solve on its own. ReadMachine has a scan-time repair path for some interrupted moves, but that is recovery logic, not a shared transaction.
This is where the storage split becomes a real engineering choice. GRDB can keep related rows consistent. ReadMachine still has to plan for failures that involve both files and rows.
Why I kept GRDB
I use GRDB because it gives me SQLite’s useful parts without hiding them: DatabasePool, transactions, migrations, and direct SQL when a rule matters.
It doesn’t make the filesystem transactional. It doesn’t make asynchronous tasks run in timestamp order. And it doesn’t automatically turn the current SwiftUI views into database observations.
Those aren’t problems with GRDB. They are responsibilities of the application.
For ReadMachine, that’s the useful separation: source documents stay in the filesystem; durable application state lives in SQLite; GRDB manages database access; and the store decides how an app operation crosses those parts.
What matters isn’t that all persistence goes through one actor. It’s that each operation has an explicit owner—and that I can tell which changes a transaction can really protect.
