Annotation Sync Design¶
Design document for annotation-first sync using Nostr protocol.
Overview¶
SvelteReader takes an annotation-first approach to sync: - Annotations are the primary synced data - Books are identified by SHA-256 hash (content-addressable) - Users can selectively publish annotations to Nostr relays - Annotations can exist without the book downloaded ("ghost books")
Data Structures¶
Book Identity (Publishable)¶
Fields that can be broadcast to Nostr for book discovery:
interface BookIdentity {
sha256: string; // SHA-256 of EPUB file (primary identifier)
title: string;
author: string;
isbn?: string; // ISBN-10 or ISBN-13
year?: number; // Publication year
coverBase64?: string; // Base64-encoded cover image
}
Book Local (Not Published)¶
Local reading state, never broadcast:
interface BookLocal {
id: string; // Local UUID (IndexedDB key)
sha256: string; // Links to BookIdentity + Annotations
progress: number; // 0-100
currentPage: number;
totalPages: number;
currentCfi?: string;
hasEpubData: boolean; // false = "ghost book"
defaultPublishAnnotations?: boolean;
}
Combined Book Type¶
Annotation (Publishable Core)¶
type AnnotationColor = 'yellow' | 'green' | 'blue' | 'pink';
interface Annotation {
bookSha256: string; // Links to book by content hash
cfiRange: string; // EPUB CFI location
// Composite key: bookSha256 + ":" + cfiRange (no separate UUID needed)
text: string; // Selected text
highlightColor?: AnnotationColor | null;
note?: string;
createdAt: number; // Unix timestamp (ms)
// Nostr sync state
nostrEventId?: string; // Set after publish
relays?: string[]; // Relay URLs where published
isPublic?: boolean; // User opted to broadcast
}
Annotation Local (Extended)¶
Local-only fields, never broadcast:
interface AnnotationLocal extends Annotation {
chatThreadIds?: string[]; // AI chat thread references
}
Annotation Display (Runtime Only)¶
Computed at runtime, never stored:
interface AnnotationDisplay extends AnnotationLocal {
page: number; // Derived from CFI + locations
chapter?: string; // Derived from CFI + TOC
}
Storage Architecture¶
IndexedDB Schema¶
sveltereader (v2)
├── epubs key: Book.id → ArrayBuffer
├── locations key: Book.id → JSON string
├── books key: Book.id → Book
│ └── index: by-sha256
└── annotations key: Annotation.id → AnnotationLocal
└── index: by-book (bookSha256)
SHA-256 Computation¶
Computed once on EPUB import:
async function computeSha256(arrayBuffer: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
Ghost Books¶
When annotations exist for a book that isn't downloaded locally:
- Book appears in library with
hasEpubData: false - UI shows "Download to read" prompt
- User can still view annotation list
- Opening the book prompts for EPUB file
This enables: - Syncing annotations from another device before downloading books - Importing someone else's annotations for a book you'll get later
Delete Operations¶
| Action | Effect |
|---|---|
| Delete Book | Removes EPUB data, locations, book metadata. If annotations exist, sets hasEpubData: false (becomes ghost book). |
| Delete Annotations | Removes all annotations for bookSha256. |
| Delete All Data | Removes book + annotations completely. |
Conflict Resolution¶
Last Write Wins (LWW) using createdAt timestamp.
- Simple and predictable
- Nostr events have native
created_atfield - No complex CRDT overhead
Nostr Integration¶
Nostr serves two purposes: 1. Social discoverability — Find annotations from other readers 2. Data storage / sync — Persist and sync user's own annotations across devices
Addressable Events (kind 30000-40000)¶
Annotations use Addressable Events rather than regular notes:
| Event Type | Kind Range | Behavior |
|---|---|---|
| Regular | 1-9999 | Stored permanently, immutable |
| Replaceable | 10000-19999 | Latest event per pubkey+kind wins |
| Ephemeral | 20000-29999 | Not stored |
| Addressable | 30000-39999 | Latest event per pubkey+kind+d-tag wins |
Why Addressable?
- User can update an annotation (edit note, change color)
- User can delete by publishing empty/tombstone event
- Unique identifier: pubkey + kind + d-tag
- Only latest version stored by relays
Annotation Event Structure¶
{
"kind": 30078, // Custom addressable kind for annotations
"pubkey": "<user-pubkey>",
"created_at": 1703100000,
"tags": [
["d", "<book-sha256>:<cfi-range>"], // Composite unique identifier
["color", "yellow"], // Optional: highlight color
["r", "wss://relay1.example"], // Relay hints
["r", "wss://relay2.example"]
],
"content": "{\"text\":\"selected text\",\"note\":\"user note\"}",
"sig": "<signature>"
}
Event Content (JSON)¶
interface AnnotationEventContent {
text: string; // Selected text from book
note?: string; // User's note
deleted?: boolean; // Tombstone for deletion
}
Sync Flow¶
Publishing:
1. User creates/edits annotation locally
2. If isPublic or sync enabled, sign and publish addressable event
3. Store nostrEventId locally for reference
Fetching (fresh install):
1. User logs in with Nostr identity
2. Query relays: {"kinds": [30078], "authors": ["<pubkey>"]}
3. For each event, parse d tag to extract bookSha256 and cfiRange
4. Create/update local annotations
5. Create ghost books for unknown bookSha256 values
Deletion:
1. Publish event with same d tag, content: {"deleted": true}
2. Relays replace old event with tombstone
3. Other clients see deletion on sync
Local Annotation Fields for Sync¶
interface Annotation {
// Core fields (composite key: bookSha256 + cfiRange)
bookSha256: string;
cfiRange: string;
text: string;
highlightColor?: AnnotationColor | null;
note?: string;
createdAt: number;
// Nostr sync state
nostrEventId?: string; // Event ID after publish
nostrCreatedAt?: number; // Event created_at for LWW
relays?: string[]; // Relay URLs where published
isPublic?: boolean; // User opted to broadcast
syncPending?: boolean; // Local changes not yet published
}
Migration¶
This is a clean-slate redesign. No migration from previous localStorage-based annotation storage.