Implement offline storage with IndexedDB for canvas documents
- Add @automerge/automerge-repo-storage-indexeddb for local persistence - Create documentIdMapping utility to track roomId → documentId in IndexedDB - Update useAutomergeSyncRepo with offline-first loading strategy: - Load from IndexedDB first for instant access - Sync with server in background when online - Track connection status (online/offline/syncing) - Add OfflineIndicator component to show connection state - Integrate offline indicator into Board component Documents are now cached locally and available offline. Automerge CRDT handles conflict resolution when syncing back online. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e4743c6ff6
commit
b502a08c62
|
|
@ -13,6 +13,7 @@
|
||||||
"@automerge/automerge": "^3.1.1",
|
"@automerge/automerge": "^3.1.1",
|
||||||
"@automerge/automerge-repo": "^2.2.0",
|
"@automerge/automerge-repo": "^2.2.0",
|
||||||
"@automerge/automerge-repo-react-hooks": "^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",
|
"@chengsokdara/use-whisper": "^0.2.0",
|
||||||
"@daily-co/daily-js": "^0.60.0",
|
"@daily-co/daily-js": "^0.60.0",
|
||||||
"@daily-co/daily-react": "^0.20.0",
|
"@daily-co/daily-react": "^0.20.0",
|
||||||
|
|
@ -211,6 +212,31 @@
|
||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"@automerge/automerge": "^3.1.1",
|
"@automerge/automerge": "^3.1.1",
|
||||||
"@automerge/automerge-repo": "^2.2.0",
|
"@automerge/automerge-repo": "^2.2.0",
|
||||||
"@automerge/automerge-repo-react-hooks": "^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",
|
"@chengsokdara/use-whisper": "^0.2.0",
|
||||||
"@daily-co/daily-js": "^0.60.0",
|
"@daily-co/daily-js": "^0.60.0",
|
||||||
"@daily-co/daily-react": "^0.20.0",
|
"@daily-co/daily-react": "^0.20.0",
|
||||||
|
|
|
||||||
|
|
@ -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<IDBDatabase> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<DocumentMapping[]> {
|
||||||
|
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<number> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,9 @@ import { TLStoreSnapshot } from "@tldraw/tldraw"
|
||||||
import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
|
import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
|
||||||
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
|
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
|
||||||
import { TLStoreWithStatus } from "@tldraw/tldraw"
|
import { TLStoreWithStatus } from "@tldraw/tldraw"
|
||||||
import { Repo } from "@automerge/automerge-repo"
|
import { Repo, DocHandle, DocumentId } from "@automerge/automerge-repo"
|
||||||
import { DocHandle } from "@automerge/automerge-repo"
|
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"
|
||||||
|
import { getDocumentId, saveDocumentId } from "./documentIdMapping"
|
||||||
|
|
||||||
interface AutomergeSyncConfig {
|
interface AutomergeSyncConfig {
|
||||||
uri: string
|
uri: string
|
||||||
|
|
@ -17,9 +18,23 @@ interface AutomergeSyncConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: DocHandle<any> | null; presence: ReturnType<typeof useAutomergePresence> } {
|
// 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<any> | null
|
||||||
|
presence: ReturnType<typeof useAutomergePresence>
|
||||||
|
connectionStatus: ConnectionStatus
|
||||||
|
isOfflineReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutomergeSync(config: AutomergeSyncConfig): AutomergeSyncResult {
|
||||||
const { uri, user } = config
|
const { uri, user } = config
|
||||||
|
|
||||||
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
|
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
|
||||||
const roomId = useMemo(() => {
|
const roomId = useMemo(() => {
|
||||||
const match = uri.match(/\/connect\/([^\/]+)$/)
|
const match = uri.match(/\/connect\/([^\/]+)$/)
|
||||||
|
|
@ -33,14 +48,18 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
|
|
||||||
const [handle, setHandle] = useState<any>(null)
|
const [handle, setHandle] = useState<any>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(
|
||||||
|
typeof navigator !== 'undefined' && navigator.onLine ? 'online' : 'offline'
|
||||||
|
)
|
||||||
|
const [isOfflineReady, setIsOfflineReady] = useState(false)
|
||||||
const handleRef = useRef<any>(null)
|
const handleRef = useRef<any>(null)
|
||||||
const storeRef = useRef<any>(null)
|
const storeRef = useRef<any>(null)
|
||||||
|
|
||||||
// Update refs when handle/store changes
|
// Update refs when handle/store changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleRef.current = handle
|
handleRef.current = handle
|
||||||
}, [handle])
|
}, [handle])
|
||||||
|
|
||||||
// JSON sync is deprecated - all data now flows through Automerge sync protocol
|
// 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
|
// 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
|
// 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
|
// Don't apply JSON sync - let Automerge sync handle everything
|
||||||
return
|
return
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Create Repo with both network AND storage adapters for offline support
|
||||||
const [repo] = useState(() => {
|
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({
|
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(() => {
|
useEffect(() => {
|
||||||
let mounted = true
|
let mounted = true
|
||||||
|
|
||||||
const initializeHandle = async () => {
|
const initializeHandle = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("🔌 Initializing Automerge Repo with NetworkAdapter for room:", roomId)
|
console.log("🔌 Initializing Automerge Repo with offline support for room:", roomId)
|
||||||
|
|
||||||
if (mounted) {
|
if (!mounted) return
|
||||||
// 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
|
let handle: DocHandle<any>
|
||||||
// Instead, we'll create a new document and load initial data from the server
|
let existingDocId: string | null = null
|
||||||
const handle = repo.create()
|
let loadedFromLocal = false
|
||||||
|
|
||||||
console.log("Created Automerge handle via Repo:", {
|
// Step 1: Check if we have a stored document ID for this room
|
||||||
handleId: handle.documentId,
|
try {
|
||||||
isReady: handle.isReady()
|
existingDocId = await getDocumentId(roomId)
|
||||||
})
|
if (existingDocId) {
|
||||||
|
console.log(`📦 Found existing document ID in IndexedDB: ${existingDocId}`)
|
||||||
// Wait for the handle to be ready
|
}
|
||||||
|
} 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<any>
|
||||||
|
|
||||||
|
// 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()
|
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
|
// Step 4: Sync with server if online (background sync)
|
||||||
console.log("📥 Loading initial data from server...")
|
if (navigator.onLine) {
|
||||||
|
setConnectionStatus('syncing')
|
||||||
|
console.log("📥 Syncing with server...")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const serverDoc = await response.json() as TLStoreSnapshot
|
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
|
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||||
|
const serverShapeCount = serverDoc.store
|
||||||
console.log(`📥 Loaded document from server: ${serverRecordCount} records, ${serverShapeCount} shapes`)
|
? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||||
|
: 0
|
||||||
// Initialize the Automerge document with server data
|
|
||||||
|
console.log(`📥 Server has: ${serverRecordCount} records, ${serverShapeCount} shapes`)
|
||||||
|
|
||||||
|
// Merge server data into local document
|
||||||
if (serverDoc.store && serverRecordCount > 0) {
|
if (serverDoc.store && serverRecordCount > 0) {
|
||||||
handle.change((doc: any) => {
|
const localDoc = handle.doc() as any
|
||||||
// Initialize store if it doesn't exist
|
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||||
if (!doc.store) {
|
|
||||||
doc.store = {}
|
// If server has more data or local is empty, merge server data
|
||||||
}
|
if (serverRecordCount > 0) {
|
||||||
// Copy all records from server document
|
handle.change((doc: any) => {
|
||||||
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
if (!doc.store) {
|
||||||
doc.store[id] = record
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
const mergedDoc = handle.doc() as any
|
||||||
console.log(`✅ Initialized Automerge document with ${serverRecordCount} records from server`)
|
const mergedCount = mergedDoc?.store ? Object.keys(mergedDoc.store).length : 0
|
||||||
} else {
|
console.log(`✅ Merged server data. Total records: ${mergedCount}`)
|
||||||
console.log("📥 Server document is empty - starting with empty Automerge document")
|
}
|
||||||
|
} else if (response.status !== 404) {
|
||||||
|
console.log("📥 Server document is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setConnectionStatus('online')
|
||||||
} else if (response.status === 404) {
|
} 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 {
|
} 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) {
|
} catch (error) {
|
||||||
console.error("❌ Error loading initial document from server:", error)
|
console.error("❌ Error syncing with server:", error)
|
||||||
// Continue anyway - user can still create new content
|
// If we loaded from local, we're still functional in offline mode
|
||||||
|
setConnectionStatus(loadedFromLocal ? 'offline' : 'online')
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
const finalDoc = handle.doc() as any
|
console.log("📴 Offline - using local data only")
|
||||||
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
setConnectionStatus('offline')
|
||||||
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
}
|
||||||
|
|
||||||
console.log("Automerge handle initialized:", {
|
// Mark as offline-ready once we have any document loaded
|
||||||
hasDoc: !!finalDoc,
|
setIsOfflineReady(true)
|
||||||
storeKeys: finalStoreKeys,
|
|
||||||
shapeCount: finalShapeCount
|
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)
|
setHandle(handle)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing Automerge handle:", error)
|
console.error("❌ Error initializing Automerge handle:", error)
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setConnectionStatus('offline')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +270,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false
|
mounted = false
|
||||||
}
|
}
|
||||||
}, [repo, roomId])
|
}, [repo, roomId, workerUrl])
|
||||||
|
|
||||||
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
|
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
|
||||||
// CRITICAL: This ensures new shapes are persisted to R2
|
// CRITICAL: This ensures new shapes are persisted to R2
|
||||||
|
|
@ -279,6 +405,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
return {
|
return {
|
||||||
...storeWithStatus,
|
...storeWithStatus,
|
||||||
handle,
|
handle,
|
||||||
presence
|
presence,
|
||||||
|
connectionStatus,
|
||||||
|
isOfflineReady
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '16px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
backgroundColor: config.bgColor,
|
||||||
|
color: config.textColor,
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${config.borderColor}`,
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
zIndex: 9999,
|
||||||
|
fontSize: '14px',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px' }}>{config.icon}</span>
|
||||||
|
<span>{config.text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ import { Collection, initializeGlobalCollections } from "@/collections"
|
||||||
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
|
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
|
||||||
import { GestureTool } from "@/GestureTool"
|
import { GestureTool } from "@/GestureTool"
|
||||||
import { CmdK } from "@/CmdK"
|
import { CmdK } from "@/CmdK"
|
||||||
|
import { OfflineIndicator } from "@/components/OfflineIndicator"
|
||||||
|
|
||||||
|
|
||||||
import "react-cmdk/dist/cmdk.css"
|
import "react-cmdk/dist/cmdk.css"
|
||||||
|
|
@ -203,13 +204,14 @@ export function Board() {
|
||||||
|
|
||||||
// Use Automerge sync for all environments
|
// Use Automerge sync for all environments
|
||||||
const storeWithHandle = useAutomergeSync(storeConfig)
|
const storeWithHandle = useAutomergeSync(storeConfig)
|
||||||
const store = {
|
const store = {
|
||||||
store: storeWithHandle.store,
|
store: storeWithHandle.store,
|
||||||
status: storeWithHandle.status,
|
status: storeWithHandle.status,
|
||||||
...('connectionStatus' in storeWithHandle ? { connectionStatus: storeWithHandle.connectionStatus } : {}),
|
|
||||||
error: storeWithHandle.error
|
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<Editor | null>(null)
|
const [editor, setEditor] = useState<Editor | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -658,6 +660,7 @@ export function Board() {
|
||||||
<div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
<div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
<div>Loading canvas...</div>
|
<div>Loading canvas...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<OfflineIndicator connectionStatus={connectionStatus} isOfflineReady={isOfflineReady} />
|
||||||
</AutomergeHandleProvider>
|
</AutomergeHandleProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -740,6 +743,7 @@ export function Board() {
|
||||||
>
|
>
|
||||||
<CmdK />
|
<CmdK />
|
||||||
</Tldraw>
|
</Tldraw>
|
||||||
|
<OfflineIndicator connectionStatus={connectionStatus} isOfflineReady={isOfflineReady} />
|
||||||
</div>
|
</div>
|
||||||
</AutomergeHandleProvider>
|
</AutomergeHandleProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue