diff --git a/package-lock.json b/package-lock.json index 21ecdfc..f7189de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@automerge/automerge": "^3.1.1", "@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0", + "@automerge/automerge-repo-storage-indexeddb": "^2.5.0", "@chengsokdara/use-whisper": "^0.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", @@ -211,6 +212,31 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@automerge/automerge-repo-storage-indexeddb": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo-storage-indexeddb/-/automerge-repo-storage-indexeddb-2.5.0.tgz", + "integrity": "sha512-7MJYJ5S6K7dHlbvs5/u/v9iexqOeprU/qQonup28r2IoVqwzjuN5ezaoVk6JRBMDI/ZxWfU4rNrqVrVlB49yXA==", + "license": "MIT", + "dependencies": { + "@automerge/automerge-repo": "2.5.0" + } + }, + "node_modules/@automerge/automerge-repo-storage-indexeddb/node_modules/@automerge/automerge-repo": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.5.0.tgz", + "integrity": "sha512-bdxuMuKmxw0ZjwQXecrIX1VrHXf445bYCftNJJ5vqgGWVvINB5ZKFYAbtgPIyu1Y0TXQKvc6eqESaDeL+g8MmA==", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "2.2.8 - 3", + "bs58check": "^3.0.1", + "cbor-x": "^1.3.0", + "debug": "^4.3.4", + "eventemitter3": "^5.0.1", + "fast-sha256": "^1.3.0", + "uuid": "^9.0.0", + "xstate": "^5.9.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", diff --git a/package.json b/package.json index b1f9f3d..39f1a04 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@automerge/automerge": "^3.1.1", "@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0", + "@automerge/automerge-repo-storage-indexeddb": "^2.5.0", "@chengsokdara/use-whisper": "^0.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", diff --git a/src/automerge/documentIdMapping.ts b/src/automerge/documentIdMapping.ts new file mode 100644 index 0000000..110202d --- /dev/null +++ b/src/automerge/documentIdMapping.ts @@ -0,0 +1,250 @@ +/** + * Document ID Mapping Utility + * + * Manages the mapping between room IDs (human-readable slugs) and + * Automerge document IDs (automerge:xxxx format). + * + * This is necessary because: + * - Automerge requires specific document ID formats + * - We want to persist documents in IndexedDB with consistent IDs + * - Room IDs are user-friendly slugs that may not match Automerge's format + */ + +const DB_NAME = 'canvas-document-mappings' +const STORE_NAME = 'mappings' +const DB_VERSION = 1 + +interface DocumentMapping { + roomId: string + documentId: string + createdAt: number + lastAccessedAt: number +} + +let dbInstance: IDBDatabase | null = null + +/** + * Open the IndexedDB database for document ID mappings + */ +async function openDatabase(): Promise { + if (dbInstance) return dbInstance + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => { + console.error('Failed to open document mapping database:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + dbInstance = request.result + resolve(request.result) + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'roomId' }) + store.createIndex('documentId', 'documentId', { unique: true }) + store.createIndex('lastAccessedAt', 'lastAccessedAt', { unique: false }) + } + } + }) +} + +/** + * Get the Automerge document ID for a given room ID + * Returns null if no mapping exists + */ +export async function getDocumentId(roomId: string): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.get(roomId) + + request.onerror = () => { + console.error('Failed to get document mapping:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + const mapping = request.result as DocumentMapping | undefined + if (mapping) { + // Update last accessed time in background + updateLastAccessed(roomId).catch(console.error) + resolve(mapping.documentId) + } else { + resolve(null) + } + } + }) + } catch (error) { + console.error('Error getting document ID:', error) + return null + } +} + +/** + * Save a mapping between room ID and Automerge document ID + */ +export async function saveDocumentId(roomId: string, documentId: string): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + + const mapping: DocumentMapping = { + roomId, + documentId, + createdAt: Date.now(), + lastAccessedAt: Date.now() + } + + const request = store.put(mapping) + + request.onerror = () => { + console.error('Failed to save document mapping:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + console.log(`๐Ÿ“ Saved document mapping: ${roomId} โ†’ ${documentId}`) + resolve() + } + }) + } catch (error) { + console.error('Error saving document ID:', error) + throw error + } +} + +/** + * Update the last accessed timestamp for a room + */ +async function updateLastAccessed(roomId: string): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const getRequest = store.get(roomId) + + getRequest.onerror = () => reject(getRequest.error) + + getRequest.onsuccess = () => { + const mapping = getRequest.result as DocumentMapping | undefined + if (mapping) { + mapping.lastAccessedAt = Date.now() + store.put(mapping) + } + resolve() + } + }) + } catch (error) { + // Silent fail for background update + } +} + +/** + * Delete a document mapping (useful for cleanup) + */ +export async function deleteDocumentMapping(roomId: string): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const request = store.delete(roomId) + + request.onerror = () => { + console.error('Failed to delete document mapping:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + console.log(`๐Ÿ—‘๏ธ Deleted document mapping for: ${roomId}`) + resolve() + } + }) + } catch (error) { + console.error('Error deleting document mapping:', error) + throw error + } +} + +/** + * Get all document mappings (useful for debugging/management) + */ +export async function getAllMappings(): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.getAll() + + request.onerror = () => { + console.error('Failed to get all document mappings:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + resolve(request.result as DocumentMapping[]) + } + }) + } catch (error) { + console.error('Error getting all mappings:', error) + return [] + } +} + +/** + * Clean up old document mappings (documents not accessed in X days) + * This helps manage storage quota + */ +export async function cleanupOldMappings(maxAgeDays: number = 30): Promise { + try { + const db = await openDatabase() + const cutoffTime = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000) + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const index = store.index('lastAccessedAt') + const range = IDBKeyRange.upperBound(cutoffTime) + const request = index.openCursor(range) + + let deletedCount = 0 + + request.onerror = () => { + console.error('Failed to cleanup old mappings:', request.error) + reject(request.error) + } + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + cursor.delete() + deletedCount++ + cursor.continue() + } else { + console.log(`๐Ÿงน Cleaned up ${deletedCount} old document mappings`) + resolve(deletedCount) + } + } + }) + } catch (error) { + console.error('Error cleaning up old mappings:', error) + return 0 + } +} diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index e04c1a1..7cebbd4 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -3,8 +3,9 @@ import { TLStoreSnapshot } from "@tldraw/tldraw" import { CloudflareNetworkAdapter } from "./CloudflareAdapter" import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2" import { TLStoreWithStatus } from "@tldraw/tldraw" -import { Repo } from "@automerge/automerge-repo" -import { DocHandle } from "@automerge/automerge-repo" +import { Repo, DocHandle, DocumentId } from "@automerge/automerge-repo" +import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb" +import { getDocumentId, saveDocumentId } from "./documentIdMapping" interface AutomergeSyncConfig { uri: string @@ -17,9 +18,23 @@ interface AutomergeSyncConfig { } } -export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: DocHandle | null; presence: ReturnType } { +// Track online/offline status +export type ConnectionStatus = 'online' | 'offline' | 'syncing' + +// Return type for useAutomergeSync - extends TLStoreWithStatus with offline capabilities +export interface AutomergeSyncResult { + store?: TLStoreWithStatus['store'] + status: TLStoreWithStatus['status'] + error?: TLStoreWithStatus['error'] + handle: DocHandle | null + presence: ReturnType + connectionStatus: ConnectionStatus + isOfflineReady: boolean +} + +export function useAutomergeSync(config: AutomergeSyncConfig): AutomergeSyncResult { const { uri, user } = config - + // Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123") const roomId = useMemo(() => { const match = uri.match(/\/connect\/([^\/]+)$/) @@ -33,14 +48,18 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus const [handle, setHandle] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [connectionStatus, setConnectionStatus] = useState( + typeof navigator !== 'undefined' && navigator.onLine ? 'online' : 'offline' + ) + const [isOfflineReady, setIsOfflineReady] = useState(false) const handleRef = useRef(null) const storeRef = useRef(null) - + // Update refs when handle/store changes useEffect(() => { handleRef.current = handle }, [handle]) - + // JSON sync is deprecated - all data now flows through Automerge sync protocol // Old format content is converted server-side and saved to R2 in Automerge format // This callback is kept for backwards compatibility but should not be used @@ -49,92 +68,199 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus // Don't apply JSON sync - let Automerge sync handle everything return }, []) - + + // Create Repo with both network AND storage adapters for offline support const [repo] = useState(() => { - const adapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData) + const networkAdapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData) + const storageAdapter = new IndexedDBStorageAdapter() + + console.log('๐Ÿ—„๏ธ Creating Automerge Repo with IndexedDB storage adapter for offline support') + return new Repo({ - network: [adapter] + network: [networkAdapter], + storage: storageAdapter }) }) - // Initialize Automerge document handle + // Listen for online/offline events + useEffect(() => { + const handleOnline = () => { + console.log('๐ŸŒ Network: Back online') + setConnectionStatus('syncing') + // The network adapter will automatically reconnect and sync + // After a short delay, assume we're synced if no errors + setTimeout(() => { + setConnectionStatus('online') + }, 2000) + } + + const handleOffline = () => { + console.log('๐Ÿ“ด Network: Gone offline') + setConnectionStatus('offline') + } + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + return () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + }, []) + + // Initialize Automerge document handle with offline-first approach useEffect(() => { let mounted = true const initializeHandle = async () => { try { - console.log("๐Ÿ”Œ Initializing Automerge Repo with NetworkAdapter for room:", roomId) - - if (mounted) { - // CRITICAL: Create a new Automerge document (repo.create() generates a proper document ID) - // We can't use repo.find() with a custom ID because Automerge requires specific document ID formats - // Instead, we'll create a new document and load initial data from the server - const handle = repo.create() - - console.log("Created Automerge handle via Repo:", { - handleId: handle.documentId, - isReady: handle.isReady() - }) - - // Wait for the handle to be ready + console.log("๐Ÿ”Œ Initializing Automerge Repo with offline support for room:", roomId) + + if (!mounted) return + + let handle: DocHandle + let existingDocId: string | null = null + let loadedFromLocal = false + + // Step 1: Check if we have a stored document ID for this room + try { + existingDocId = await getDocumentId(roomId) + if (existingDocId) { + console.log(`๐Ÿ“ฆ Found existing document ID in IndexedDB: ${existingDocId}`) + } + } catch (error) { + console.warn('โš ๏ธ Could not check IndexedDB for existing document:', error) + } + + // Step 2: Try to load from local storage first (offline-first approach) + if (existingDocId) { + try { + console.log(`๐Ÿ” Attempting to load document from IndexedDB: ${existingDocId}`) + // Use repo.find() which will check IndexedDB storage adapter first + // In automerge-repo v2.x, find() can return a Promise + const foundHandle = await Promise.resolve(repo.find(existingDocId as DocumentId)) + handle = foundHandle as DocHandle + + // Wait for the handle to be ready (will load from IndexedDB if available) + await handle.whenReady() + + const localDoc = handle.doc() as any + const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0 + + if (localRecordCount > 0) { + console.log(`โœ… Loaded ${localRecordCount} records from IndexedDB (offline-first)`) + loadedFromLocal = true + setIsOfflineReady(true) + } else { + console.log('๐Ÿ“ฆ Document exists in IndexedDB but is empty') + } + } catch (error) { + console.warn('โš ๏ธ Could not load from IndexedDB, will create new document:', error) + existingDocId = null + } + } + + // Step 3: If no local document, create a new one + if (!existingDocId || !handle!) { + console.log('๐Ÿ“ Creating new Automerge document') + handle = repo.create() + + // Save the mapping for future offline access + await saveDocumentId(roomId, handle.documentId) + console.log(`๐Ÿ“ Saved new document mapping: ${roomId} โ†’ ${handle.documentId}`) + await handle.whenReady() - - // CRITICAL: Always load initial data from the server - // The server stores documents in R2 as JSON, so we need to load and initialize the Automerge document - console.log("๐Ÿ“ฅ Loading initial data from server...") + } + + // Step 4: Sync with server if online (background sync) + if (navigator.onLine) { + setConnectionStatus('syncing') + console.log("๐Ÿ“ฅ Syncing with server...") + try { const response = await fetch(`${workerUrl}/room/${roomId}`) if (response.ok) { const serverDoc = await response.json() as TLStoreSnapshot - const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 const serverRecordCount = Object.keys(serverDoc.store || {}).length - - console.log(`๐Ÿ“ฅ Loaded document from server: ${serverRecordCount} records, ${serverShapeCount} shapes`) - - // Initialize the Automerge document with server data + const serverShapeCount = serverDoc.store + ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length + : 0 + + console.log(`๐Ÿ“ฅ Server has: ${serverRecordCount} records, ${serverShapeCount} shapes`) + + // Merge server data into local document if (serverDoc.store && serverRecordCount > 0) { - handle.change((doc: any) => { - // Initialize store if it doesn't exist - if (!doc.store) { - doc.store = {} - } - // Copy all records from server document - Object.entries(serverDoc.store).forEach(([id, record]) => { - doc.store[id] = record + const localDoc = handle.doc() as any + const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0 + + // If server has more data or local is empty, merge server data + if (serverRecordCount > 0) { + handle.change((doc: any) => { + if (!doc.store) { + doc.store = {} + } + // Merge server records (Automerge will handle conflicts) + Object.entries(serverDoc.store).forEach(([id, record]) => { + // Only add if not already present locally, or if this is first load + if (!doc.store[id] || !loadedFromLocal) { + doc.store[id] = record + } + }) }) - }) - - console.log(`โœ… Initialized Automerge document with ${serverRecordCount} records from server`) - } else { - console.log("๐Ÿ“ฅ Server document is empty - starting with empty Automerge document") + + const mergedDoc = handle.doc() as any + const mergedCount = mergedDoc?.store ? Object.keys(mergedDoc.store).length : 0 + console.log(`โœ… Merged server data. Total records: ${mergedCount}`) + } + } else if (response.status !== 404) { + console.log("๐Ÿ“ฅ Server document is empty") } + + setConnectionStatus('online') } else if (response.status === 404) { - console.log("๐Ÿ“ฅ No document found on server (404) - starting with empty document") + console.log("๐Ÿ“ฅ No document on server yet - local document will be synced when saved") + setConnectionStatus('online') } else { - console.warn(`โš ๏ธ Failed to load document from server: ${response.status} ${response.statusText}`) + console.warn(`โš ๏ธ Server sync failed: ${response.status}`) + setConnectionStatus(loadedFromLocal ? 'offline' : 'online') } } catch (error) { - console.error("โŒ Error loading initial document from server:", error) - // Continue anyway - user can still create new content + console.error("โŒ Error syncing with server:", error) + // If we loaded from local, we're still functional in offline mode + setConnectionStatus(loadedFromLocal ? 'offline' : 'online') } - - const finalDoc = handle.doc() as any - const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0 - const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 - - console.log("Automerge handle initialized:", { - hasDoc: !!finalDoc, - storeKeys: finalStoreKeys, - shapeCount: finalShapeCount - }) - + } else { + console.log("๐Ÿ“ด Offline - using local data only") + setConnectionStatus('offline') + } + + // Mark as offline-ready once we have any document loaded + setIsOfflineReady(true) + + const finalDoc = handle.doc() as any + const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0 + const finalShapeCount = finalDoc?.store + ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length + : 0 + + console.log("โœ… Automerge handle initialized:", { + documentId: handle.documentId, + hasDoc: !!finalDoc, + storeKeys: finalStoreKeys, + shapeCount: finalShapeCount, + loadedFromLocal, + isOnline: navigator.onLine + }) + + if (mounted) { setHandle(handle) setIsLoading(false) } } catch (error) { - console.error("Error initializing Automerge handle:", error) + console.error("โŒ Error initializing Automerge handle:", error) if (mounted) { setIsLoading(false) + setConnectionStatus('offline') } } } @@ -144,7 +270,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus return () => { mounted = false } - }, [repo, roomId]) + }, [repo, roomId, workerUrl]) // Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls) // CRITICAL: This ensures new shapes are persisted to R2 @@ -279,6 +405,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus return { ...storeWithStatus, handle, - presence + presence, + connectionStatus, + isOfflineReady } } diff --git a/src/components/OfflineIndicator.tsx b/src/components/OfflineIndicator.tsx new file mode 100644 index 0000000..c956c47 --- /dev/null +++ b/src/components/OfflineIndicator.tsx @@ -0,0 +1,66 @@ +import { ConnectionStatus } from '@/automerge/useAutomergeSyncRepo' + +interface OfflineIndicatorProps { + connectionStatus: ConnectionStatus + isOfflineReady: boolean +} + +export function OfflineIndicator({ connectionStatus, isOfflineReady }: OfflineIndicatorProps) { + // Don't show indicator when online and everything is working normally + if (connectionStatus === 'online') { + return null + } + + const getStatusConfig = () => { + switch (connectionStatus) { + case 'offline': + return { + icon: '๐Ÿ“ด', + text: isOfflineReady ? 'Offline (changes saved locally)' : 'Offline', + bgColor: '#fef3c7', // warm yellow + textColor: '#92400e', + borderColor: '#f59e0b' + } + case 'syncing': + return { + icon: '๐Ÿ”„', + text: 'Syncing...', + bgColor: '#dbeafe', // light blue + textColor: '#1e40af', + borderColor: '#3b82f6' + } + default: + return null + } + } + + const config = getStatusConfig() + if (!config) return null + + return ( +
+ {config.icon} + {config.text} +
+ ) +} diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 9e8b62d..3d3f955 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -56,6 +56,7 @@ import { Collection, initializeGlobalCollections } from "@/collections" import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection" import { GestureTool } from "@/GestureTool" import { CmdK } from "@/CmdK" +import { OfflineIndicator } from "@/components/OfflineIndicator" import "react-cmdk/dist/cmdk.css" @@ -203,13 +204,14 @@ export function Board() { // Use Automerge sync for all environments const storeWithHandle = useAutomergeSync(storeConfig) - const store = { - store: storeWithHandle.store, + const store = { + store: storeWithHandle.store, status: storeWithHandle.status, - ...('connectionStatus' in storeWithHandle ? { connectionStatus: storeWithHandle.connectionStatus } : {}), error: storeWithHandle.error } - const automergeHandle = (storeWithHandle as any).handle + const automergeHandle = storeWithHandle.handle + const connectionStatus = storeWithHandle.connectionStatus + const isOfflineReady = storeWithHandle.isOfflineReady const [editor, setEditor] = useState(null) useEffect(() => { @@ -658,6 +660,7 @@ export function Board() {
Loading canvas...
+ ) } @@ -740,6 +743,7 @@ export function Board() { > + )