Skip to content

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

interface Book extends BookIdentity, BookLocal {}

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:

  1. Book appears in library with hasEpubData: false
  2. UI shows "Download to read" prompt
  3. User can still view annotation list
  4. 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_at field
  • 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.