feat: improve Obsidian vault storage with IndexedDB content store

- Add noteContentStore.ts for storing full note content in IndexedDB
- Avoids Automerge WASM capacity limits and localStorage quota (~5MB)
- Only metadata (id, title, tags, links) syncs via Automerge
- Full content stays local and loads on-demand
- Handle ephemeral messages in AutomergeDurableObject for cursor sync
- Improvements to ObsidianVaultBrowser component
- Enhanced obsidianImporter functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-24 15:15:48 +01:00
parent 2030ae447d
commit b2941333f3
4 changed files with 601 additions and 32 deletions

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect, useMemo, useContext, useRef } from 'react'
import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord } from '@/lib/obsidianImporter'
import React, { useState, useEffect, useMemo, useContext, useRef, useCallback } from 'react'
import { ObsidianImporter, ObsidianObsNote, ObsidianVault, FolderNode, ObsidianVaultRecord, ObsidianVaultRecordLight, ObsidianObsNoteMeta, FolderNodeMeta } from '@/lib/obsidianImporter'
import { AuthContext } from '@/context/AuthContext'
import { useEditor } from '@tldraw/tldraw'
import { useAutomergeHandle } from '@/context/AutomergeHandleContext'
import { saveVaultNoteContents, getVaultNoteContents, getNoteContent, deleteVaultNoteContents } from '@/lib/noteContentStore'
interface ObsidianVaultBrowserProps {
onObsNoteSelect: (obs_note: ObsidianObsNote) => void
@ -79,45 +80,57 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
}
}, [vault])
// Save vault to Automerge store
const saveVaultToAutomerge = (vault: ObsidianVault) => {
// Save vault to Automerge store (metadata only) and IndexedDB (content)
const saveVaultToAutomerge = async (vault: ObsidianVault) => {
// First, save full note content to IndexedDB (local-only, never synced)
try {
const noteContents = importer.extractNoteContents(vault)
await saveVaultNoteContents(vault.name, noteContents)
console.log(`Saved ${noteContents.length} note contents to IndexedDB for vault: ${vault.name}`)
} catch (idbError) {
console.error('Error saving note contents to IndexedDB:', idbError)
}
// Create light record (no content) for Automerge sync
const lightRecord = importer.vaultToLightRecord(vault)
if (!automergeHandle) {
// No Automerge, just save light metadata to localStorage
try {
const vaultRecord = importer.vaultToRecord(vault)
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
...vaultRecord,
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
...lightRecord,
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
}))
} catch (localStorageError) {
console.warn('Could not save vault to localStorage:', localStorageError)
console.warn('Could not save vault metadata to localStorage:', localStorageError)
}
return
}
try {
const vaultRecord = importer.vaultToRecord(vault)
// Save directly to Automerge, bypassing TLDraw store validation
// Save LIGHT record (no content) to Automerge - this is safe and won't overflow
automergeHandle.change((doc: any) => {
if (!doc.store) {
doc.store = {}
}
const recordToSave = {
...vaultRecord,
lastImported: vaultRecord.lastImported instanceof Date
? vaultRecord.lastImported.toISOString()
: vaultRecord.lastImported
...lightRecord,
lastImported: lightRecord.lastImported instanceof Date
? lightRecord.lastImported.toISOString()
: lightRecord.lastImported
}
doc.store[vaultRecord.id] = recordToSave
doc.store[lightRecord.id] = recordToSave
})
// Also save to localStorage as a backup
console.log(`Saved light vault record to Automerge: ${lightRecord.id} (${lightRecord.totalObsNotes} notes, content stored locally)`)
// Also save light metadata to localStorage as backup
try {
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
...vaultRecord,
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
...lightRecord,
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
}))
} catch (localStorageError) {
// Silent fail for backup
@ -126,10 +139,9 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
console.error('Error saving vault to Automerge:', error)
// Try localStorage as fallback
try {
const vaultRecord = importer.vaultToRecord(vault)
localStorage.setItem(`obsidian_vault_cache:${vault.name}`, JSON.stringify({
...vaultRecord,
lastImported: vaultRecord.lastImported instanceof Date ? vaultRecord.lastImported.toISOString() : vaultRecord.lastImported
...lightRecord,
lastImported: lightRecord.lastImported instanceof Date ? lightRecord.lastImported.toISOString() : lightRecord.lastImported
}))
} catch (localStorageError) {
console.warn('Could not save vault to localStorage:', localStorageError)
@ -137,7 +149,87 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
}
}
// Load vault from Automerge store
// Clear vault data from Automerge (for privacy - removes any synced vault data)
const clearVaultFromAutomerge = useCallback((vaultName: string) => {
const vaultId = `obsidian_vault:${vaultName}`
if (automergeHandle) {
try {
automergeHandle.change((doc: any) => {
if (doc.store && doc.store[vaultId]) {
delete doc.store[vaultId]
console.log(`Cleared vault from Automerge: ${vaultId}`)
}
})
} catch (error) {
console.error('Error clearing vault from Automerge:', error)
}
}
// Also clear from localStorage
try {
localStorage.removeItem(`obsidian_vault_cache:${vaultName}`)
console.log(`Cleared vault from localStorage: ${vaultName}`)
} catch (e) {
// Silent fail
}
// Clear from IndexedDB
deleteVaultNoteContents(vaultName).then(() => {
console.log(`Cleared vault content from IndexedDB: ${vaultName}`)
}).catch(e => {
console.error('Error clearing vault from IndexedDB:', e)
})
}, [automergeHandle])
// Helper to check if a note has content (distinguishes light vs full records)
const noteHasContent = (note: ObsidianObsNote | ObsidianObsNoteMeta): note is ObsidianObsNote => {
return 'content' in note && typeof note.content === 'string' && note.content.length > 0
}
// Convert light note to full note with placeholder content
const lightNoteToFullNote = (note: ObsidianObsNoteMeta, vaultName: string): ObsidianObsNote => {
return {
...note,
content: '', // Will be loaded on-demand from IndexedDB
vaultPath: note.vaultPath || vaultName
}
}
// Convert light folder tree to full folder tree (with empty content placeholders)
const lightFolderTreeToFull = (node: FolderNodeMeta, vaultName: string): FolderNode => {
return {
name: node.name,
path: node.path,
children: node.children.map(child => lightFolderTreeToFull(child, vaultName)),
notes: node.notes.map(note => lightNoteToFullNote(note, vaultName)),
isExpanded: node.isExpanded,
level: node.level
}
}
// Load content for a note from IndexedDB
const loadNoteContentFromIDB = async (noteId: string): Promise<string> => {
try {
const content = await getNoteContent(noteId)
return content || ''
} catch (e) {
console.error('Failed to load note content from IndexedDB:', e)
return ''
}
}
// Load content for multiple notes from IndexedDB
const loadVaultContentFromIDB = async (vaultName: string): Promise<Map<string, string>> => {
try {
return await getVaultNoteContents(vaultName)
} catch (e) {
console.error('Failed to load vault content from IndexedDB:', e)
return new Map()
}
}
// Load vault from Automerge store (handles both light and full records)
const loadVaultFromAutomerge = (vaultName: string): ObsidianVault | null => {
// Try loading from Automerge first
if (automergeHandle) {
@ -145,17 +237,34 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
const doc = automergeHandle.doc()
if (doc && doc.store) {
const vaultId = `obsidian_vault:${vaultName}`
const vaultRecord = doc.store[vaultId] as ObsidianVaultRecord | undefined
const vaultRecord = doc.store[vaultId] as (ObsidianVaultRecord | ObsidianVaultRecordLight) | undefined
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
const recordCopy = JSON.parse(JSON.stringify(vaultRecord))
if (typeof recordCopy.lastImported === 'string') {
recordCopy.lastImported = new Date(recordCopy.lastImported)
}
// Check if this is a light record (notes don't have content)
const isLightRecord = recordCopy.obs_notes.length > 0 && !noteHasContent(recordCopy.obs_notes[0])
if (isLightRecord) {
// Convert light record to full vault with empty content placeholders
return {
name: recordCopy.name,
path: recordCopy.path,
obs_notes: recordCopy.obs_notes.map((n: ObsidianObsNoteMeta) => lightNoteToFullNote(n, vaultName)),
totalObsNotes: recordCopy.totalObsNotes,
lastImported: recordCopy.lastImported,
folderTree: lightFolderTreeToFull(recordCopy.folderTree, vaultName)
}
}
return importer.recordToVault(recordCopy)
}
}
} catch (error) {
console.error('Error loading from Automerge:', error)
// Fall through to localStorage
}
}
@ -164,12 +273,27 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
try {
const cached = localStorage.getItem(`obsidian_vault_cache:${vaultName}`)
if (cached) {
const vaultRecord = JSON.parse(cached) as ObsidianVaultRecord
const vaultRecord = JSON.parse(cached) as (ObsidianVaultRecord | ObsidianVaultRecordLight)
if (vaultRecord && vaultRecord.typeName === 'obsidian_vault') {
if (typeof vaultRecord.lastImported === 'string') {
vaultRecord.lastImported = new Date(vaultRecord.lastImported)
}
return importer.recordToVault(vaultRecord)
// Check if this is a light record
const isLightRecord = vaultRecord.obs_notes.length > 0 && !noteHasContent(vaultRecord.obs_notes[0])
if (isLightRecord) {
return {
name: vaultRecord.name,
path: vaultRecord.path,
obs_notes: vaultRecord.obs_notes.map((n: any) => lightNoteToFullNote(n, vaultName)),
totalObsNotes: vaultRecord.totalObsNotes,
lastImported: vaultRecord.lastImported,
folderTree: lightFolderTreeToFull(vaultRecord.folderTree as FolderNodeMeta, vaultName)
}
}
return importer.recordToVault(vaultRecord as ObsidianVaultRecord)
}
}
} catch (e) {
@ -564,8 +688,14 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
}
}
const handleObsNoteClick = (obs_note: ObsidianObsNote) => {
onObsNoteSelect(obs_note)
const handleObsNoteClick = async (obs_note: ObsidianObsNote) => {
// Load content from IndexedDB if not already loaded
if (!obs_note.content && vault) {
const content = await loadNoteContentFromIDB(obs_note.id)
onObsNoteSelect({ ...obs_note, content })
} else {
onObsNoteSelect(obs_note)
}
}
const handleObsNoteToggle = (obs_note: ObsidianObsNote) => {
@ -578,9 +708,21 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
setSelectedNotes(newSelected)
}
const handleBulkImport = () => {
const handleBulkImport = async () => {
const selectedObsNotes = filteredObsNotes.filter(obs_note => selectedNotes.has(obs_note.id))
onObsNotesSelect(selectedObsNotes)
// Load content from IndexedDB for all selected notes
if (vault) {
const contentMap = await loadVaultContentFromIDB(vault.name)
const notesWithContent = selectedObsNotes.map(note => ({
...note,
content: note.content || contentMap.get(note.id) || ''
}))
onObsNotesSelect(notesWithContent)
} else {
onObsNotesSelect(selectedObsNotes)
}
setSelectedNotes(new Set())
}
@ -630,6 +772,11 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
const handleDisconnectVault = () => {
// Clear vault from Automerge/sync when disconnecting
if (vault) {
clearVaultFromAutomerge(vault.name)
}
updateSession({
obsidianVaultPath: undefined,
obsidianVaultName: undefined
@ -646,6 +793,14 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
setIsLoadingVault(false)
}
// Clear vault data from Automerge without disconnecting (for privacy)
const handleClearVaultFromSync = () => {
if (vault) {
clearVaultFromAutomerge(vault.name)
alert(`Cleared "${vault.name}" from sync. Your vault data will no longer be visible to others.`)
}
}
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Only close if clicking on the backdrop, not on the modal content
if (e.target === e.currentTarget) {
@ -1119,8 +1274,16 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
>
🔌 Disconnect Vault
</button>
<button
onClick={handleClearVaultFromSync}
className="clear-sync-button"
title="Clear vault data from sync (keeps local data)"
style={{ marginLeft: '8px', fontSize: '0.85em', opacity: 0.8 }}
>
🗑 Clear from Sync
</button>
</div>
<div className="selection-controls">
<button
onClick={handleSelectAll}
@ -1374,8 +1537,16 @@ export const ObsidianVaultBrowser: React.FC<ObsidianVaultBrowserProps> = ({
>
🔌 Disconnect Vault
</button>
<button
onClick={handleClearVaultFromSync}
className="clear-sync-button"
title="Clear vault data from sync (keeps local data)"
style={{ marginLeft: '8px', fontSize: '0.85em', opacity: 0.8 }}
>
🗑 Clear from Sync
</button>
</div>
<div className="selection-controls">
<button
onClick={handleSelectAll}

283
src/lib/noteContentStore.ts Normal file
View File

@ -0,0 +1,283 @@
/**
* IndexedDB store for Obsidian note content
*
* Stores full note content locally in IndexedDB to avoid:
* 1. Automerge capacity overflow (WASM limits)
* 2. localStorage quota exceeded (~5MB limit)
* 3. Syncing private note content to other users
*
* Only metadata (id, title, tags, links) is synced via Automerge.
* Full content stays local and is loaded on-demand.
*/
const DB_NAME = 'canvas-obsidian-content';
const DB_VERSION = 1;
const STORE_NAME = 'note_content';
const VAULT_META_STORE = 'vault_metadata';
let dbInstance: IDBDatabase | null = null;
export interface StoredNoteContent {
id: string; // Note ID (matches ObsidianObsNote.id)
vaultName: string; // Vault this note belongs to
content: string; // Full markdown content
storedAt: Date; // When this was stored
}
export interface StoredVaultMeta {
vaultName: string;
noteCount: number;
totalSize: number; // Approximate size in bytes
storedAt: Date;
}
/**
* Open or create the IndexedDB database
*/
export async function openNoteContentDB(): 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 note content database:', request.error);
reject(request.error);
};
request.onsuccess = () => {
dbInstance = request.result;
// Handle connection closing unexpectedly
dbInstance.onclose = () => {
dbInstance = null;
};
resolve(dbInstance);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Note content store - keyed by note ID
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('vaultName', 'vaultName', { unique: false });
store.createIndex('storedAt', 'storedAt', { unique: false });
}
// Vault metadata store
if (!db.objectStoreNames.contains(VAULT_META_STORE)) {
db.createObjectStore(VAULT_META_STORE, { keyPath: 'vaultName' });
}
};
});
}
/**
* Close the database connection
*/
export function closeNoteContentDB(): void {
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
}
/**
* Save a single note's content to IndexedDB
*/
export async function saveNoteContent(
noteId: string,
vaultName: string,
content: string
): Promise<void> {
const db = await openNoteContentDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const record: StoredNoteContent = {
id: noteId,
vaultName,
content,
storedAt: new Date()
};
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Save all note contents for a vault in a batch operation
*/
export async function saveVaultNoteContents(
vaultName: string,
notes: Array<{ id: string; content: string }>
): Promise<void> {
const db = await openNoteContentDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([STORE_NAME, VAULT_META_STORE], 'readwrite');
const store = tx.objectStore(STORE_NAME);
const metaStore = tx.objectStore(VAULT_META_STORE);
let totalSize = 0;
const now = new Date();
// Save each note's content
for (const note of notes) {
const record: StoredNoteContent = {
id: note.id,
vaultName,
content: note.content,
storedAt: now
};
store.put(record);
totalSize += note.content.length;
}
// Save vault metadata
const meta: StoredVaultMeta = {
vaultName,
noteCount: notes.length,
totalSize,
storedAt: now
};
metaStore.put(meta);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Get a single note's content from IndexedDB
*/
export async function getNoteContent(noteId: string): Promise<string | null> {
const db = await openNoteContentDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(noteId);
request.onsuccess = () => {
const record = request.result as StoredNoteContent | undefined;
resolve(record?.content ?? null);
};
request.onerror = () => reject(request.error);
});
}
/**
* Get all note contents for a vault
*/
export async function getVaultNoteContents(
vaultName: string
): Promise<Map<string, string>> {
const db = await openNoteContentDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const index = store.index('vaultName');
const request = index.getAll(vaultName);
request.onsuccess = () => {
const records = request.result as StoredNoteContent[];
const contentMap = new Map<string, string>();
for (const record of records) {
contentMap.set(record.id, record.content);
}
resolve(contentMap);
};
request.onerror = () => reject(request.error);
});
}
/**
* Delete all note contents for a vault
*/
export async function deleteVaultNoteContents(vaultName: string): Promise<void> {
const db = await openNoteContentDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([STORE_NAME, VAULT_META_STORE], 'readwrite');
const store = tx.objectStore(STORE_NAME);
const metaStore = tx.objectStore(VAULT_META_STORE);
const index = store.index('vaultName');
// Get all note IDs for this vault
const request = index.getAllKeys(vaultName);
request.onsuccess = () => {
const keys = request.result;
for (const key of keys) {
store.delete(key);
}
metaStore.delete(vaultName);
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Get vault metadata (note count, size)
*/
export async function getVaultMeta(vaultName: string): Promise<StoredVaultMeta | null> {
const db = await openNoteContentDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(VAULT_META_STORE, 'readonly');
const store = tx.objectStore(VAULT_META_STORE);
const request = store.get(vaultName);
request.onsuccess = () => {
resolve(request.result as StoredVaultMeta | null);
};
request.onerror = () => reject(request.error);
});
}
/**
* List all vaults stored in IndexedDB
*/
export async function listStoredVaults(): Promise<StoredVaultMeta[]> {
const db = await openNoteContentDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(VAULT_META_STORE, 'readonly');
const store = tx.objectStore(VAULT_META_STORE);
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result as StoredVaultMeta[]);
};
request.onerror = () => reject(request.error);
});
}
/**
* Clear all stored content (for debugging/reset)
*/
export async function clearAllNoteContent(): Promise<void> {
const db = await openNoteContentDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([STORE_NAME, VAULT_META_STORE], 'readwrite');
tx.objectStore(STORE_NAME).clear();
tx.objectStore(VAULT_META_STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

View File

@ -50,6 +50,52 @@ export interface ObsidianVaultRecord {
meta: Record<string, any>
}
/**
* Light version of note without content - for Automerge sync
* Full content is stored in IndexedDB to avoid capacity overflow
*/
export interface ObsidianObsNoteMeta {
id: string
title: string
filePath: string
tags: string[]
created: Date | string
modified: Date | string
links: string[]
backlinks: string[]
frontmatter: Record<string, any>
vaultPath?: string
// content is omitted - stored in IndexedDB
}
/**
* Light version of FolderNode without full note content
*/
export interface FolderNodeMeta {
name: string
path: string
children: FolderNodeMeta[]
notes: ObsidianObsNoteMeta[] // Notes without content
isExpanded: boolean
level: number
}
/**
* Light vault record for Automerge sync - no note content
* This prevents capacity overflow and keeps private content local
*/
export interface ObsidianVaultRecordLight {
id: string
typeName: 'obsidian_vault'
name: string
path: string
obs_notes: ObsidianObsNoteMeta[] // Notes without content
totalObsNotes: number
lastImported: Date
folderTree: FolderNodeMeta // Folder tree with light notes
meta: Record<string, any>
}
export class ObsidianImporter {
private vault: ObsidianVault | null = null
@ -627,6 +673,67 @@ A collection of creative project ideas and concepts.
}
}
/**
* Strip content from a note, keeping only metadata
*/
noteToMeta(note: ObsidianObsNote): ObsidianObsNoteMeta {
return {
id: note.id,
title: note.title,
filePath: note.filePath,
tags: note.tags,
created: note.created,
modified: note.modified,
links: note.links,
backlinks: note.backlinks,
frontmatter: note.frontmatter,
vaultPath: note.vaultPath
}
}
/**
* Convert folder tree to light version (notes without content)
*/
folderTreeToMeta(node: FolderNode): FolderNodeMeta {
return {
name: node.name,
path: node.path,
children: node.children.map(child => this.folderTreeToMeta(child)),
notes: node.notes.map(note => this.noteToMeta(note)),
isExpanded: node.isExpanded,
level: node.level
}
}
/**
* Convert vault to light Automerge record format (no note content)
* Use this for syncing to Automerge to avoid capacity overflow
*/
vaultToLightRecord(vault: ObsidianVault): ObsidianVaultRecordLight {
return {
id: `obsidian_vault:${vault.name}`,
typeName: 'obsidian_vault',
name: vault.name,
path: vault.path,
obs_notes: vault.obs_notes.map(note => this.noteToMeta(note)),
totalObsNotes: vault.totalObsNotes,
lastImported: vault.lastImported,
folderTree: this.folderTreeToMeta(vault.folderTree),
meta: {}
}
}
/**
* Extract note contents for IndexedDB storage
* Returns array of {id, content} for batch saving
*/
extractNoteContents(vault: ObsidianVault): Array<{ id: string; content: string }> {
return vault.obs_notes.map(note => ({
id: note.id,
content: note.content
}))
}
/**
* Search notes in the current vault
*/

View File

@ -639,6 +639,14 @@ export class AutomergeDurableObject {
sessionId: message.sessionId || sessionId
})
break
case "ephemeral":
// Handle Automerge's ephemeral messages (temporary data like cursor positions)
// These are part of automerge-repo's sync protocol and should be relayed to other peers
// but NOT persisted. They're used for real-time presence/cursor sharing.
console.log(`✨ Received ephemeral message from ${sessionId}`)
// Relay the ephemeral message to all other connected clients
this.broadcastToOthers(sessionId, message)
break
default:
console.log("Unknown message type:", message.type)
}