// External imports import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw'; import logger from '../../debugConfig'; import { SharedStoreService } from './sharedStoreService'; export interface LoadingState { status: 'loading' | 'ready' | 'error'; error: string; } const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string; const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string; const BUCKET = 'cc.users'; async function storageGet(path: string, accessToken: string): Promise { const url = `${SUPABASE_URL}/storage/v1/object/authenticated/${BUCKET}/${path}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, apikey: SUPABASE_ANON_KEY, }, }); if (res.status === 404 || res.status === 400) return null; if (!res.ok) throw new Error(`Storage GET ${res.status}: ${await res.text()}`); return res.json(); } async function storagePut(path: string, accessToken: string, data: unknown): Promise { const url = `${SUPABASE_URL}/storage/v1/object/${BUCKET}/${path}`; const headers = { Authorization: `Bearer ${accessToken}`, apikey: SUPABASE_ANON_KEY, 'Content-Type': 'application/json', }; const body = JSON.stringify(data); // PUT replaces an existing object; POST creates a new one. // Avoids x-upsert custom header which self-hosted Supabase CORS may block. let res = await fetch(url, { method: 'PUT', headers, body }); if (!res.ok && (res.status === 404 || res.status === 400)) { res = await fetch(url, { method: 'POST', headers, body }); } if (!res.ok) throw new Error(`Storage ${res.status}: ${await res.text()}`); } export class NavigationSnapshotService { private store: TLStore; private editor: Editor | null = null; private currentNodePath: string | null = null; private _accessToken: string | null = null; private isAutoSaveEnabled = true; private isSaving = false; private isLoading = false; private pendingOperation: { save?: string; load?: string } | null = null; private debounceTimeout: ReturnType | null = null; constructor(store: TLStore, editor?: Editor) { this.store = store; this.editor = editor || null; logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', { storeId: store.id, hasEditor: !!editor, }); } setEditor(editor: Editor): void { this.editor = editor; } setAccessToken(token: string): void { this._accessToken = token; } static async loadNodeSnapshotFromDatabase( nodePath: string, accessToken: string, store: TLStore, setLoadingState: (state: LoadingState) => void, sharedStore?: SharedStoreService, editor?: Editor ): Promise { try { setLoadingState({ status: 'loading', error: '' }); logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath }); const snapshot = await storageGet(nodePath, accessToken); if (!snapshot) { logger.debug('snapshot-service', 'â„šī¸ No snapshot found at path — clearing canvas', { nodePath }); // Clear all shapes so the canvas is blank for this new node if (editor) { const shapeIds = [...editor.getCurrentPageShapeIds()]; if (shapeIds.length > 0) { editor.deleteShapes(shapeIds); } } setLoadingState({ status: 'ready', error: '' }); return; } const snap = snapshot as { document?: unknown; session?: unknown; schemaVersion?: unknown }; if (!snap.document || !snap.session) { logger.warn('snapshot-service', 'âš ī¸ Invalid snapshot format at path', { nodePath }); setLoadingState({ status: 'ready', error: '' }); return; } if (sharedStore) { await sharedStore.loadSnapshot(snapshot, setLoadingState); return; } // Sanitize session.currentPageId against pages actually in the document const docStore = (snap.document as { store?: Record })?.store ?? {}; const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id); const session = snap.session as { currentPageId?: string } | undefined; if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) { logger.warn('snapshot-service', 'âš ī¸ session.currentPageId not in snapshot pages — resetting to first page', { currentPageId: session.currentPageId, availablePages: pageIds }); session.currentPageId = pageIds[0]; } const snapshotCopy = { schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion, document: snap.document, session: snap.session, }; const targetStore = editor ? editor.store : store; try { loadSnapshot(targetStore, snapshotCopy as any); // Post-load validation: ensure instance.currentPageId is still valid const instance = targetStore.get(TLINSTANCE_ID); if (instance) { const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page'); const pageValid = pages.some((p) => p.id === instance.currentPageId); if (!pageValid) { logger.warn('snapshot-service', 'âš ī¸ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId }); targetStore.clear(); } } logger.debug('snapshot-service', '✅ Snapshot loaded successfully'); } catch (err) { const msg = err instanceof Error ? err.message : String(err); const isSchemaMigration = /migration|schema|Incompatible/i.test(msg); if (isSchemaMigration) { logger.debug('snapshot-service', 'â„šī¸ Schema migration warning (non-critical)', { error: msg }); } else { logger.warn('snapshot-service', 'âš ī¸ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg }); targetStore.clear(); } } setLoadingState({ status: 'ready', error: '' }); } catch (error) { logger.error('snapshot-service', '❌ Failed to load snapshot', { error: error instanceof Error ? error.message : 'Unknown error', }); setLoadingState({ status: 'error', error: error instanceof Error ? error.message : 'Failed to load snapshot', }); } } static async saveNodeSnapshotToDatabase( nodePath: string, accessToken: string, store: TLStore ): Promise { try { logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath }); const snapshot = getSnapshot(store); await storagePut(nodePath, accessToken, snapshot); logger.debug('snapshot-service', '✅ Snapshot saved successfully'); } catch (error) { logger.error('snapshot-service', '❌ Failed to save snapshot', { error: error instanceof Error ? error.message : 'Unknown error', }); throw error; } } private async saveCurrentSnapshot(nodePath: string): Promise { if (!this.currentNodePath || this.currentNodePath !== nodePath) return; if (!this._accessToken) { logger.debug('snapshot-service', 'âš ī¸ No access token — snapshot save skipped'); return; } try { this.isSaving = true; await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, this._accessToken, this.store); logger.debug('snapshot-service', '✅ Saved navigation snapshot', { nodePath }); } catch (error) { logger.error('snapshot-service', '❌ Failed to save navigation snapshot', { error: error instanceof Error ? error.message : 'Unknown error', nodePath, }); throw error; } finally { this.isSaving = false; } } private async loadSnapshotForNode(node: { node_storage_path: string }): Promise { if (!this._accessToken) { logger.debug('snapshot-service', 'âš ī¸ No access token — snapshot load skipped'); return; } try { this.isLoading = true; await NavigationSnapshotService.loadNodeSnapshotFromDatabase( node.node_storage_path, this._accessToken, this.store, (state: LoadingState) => { if (state.status === 'ready') { this.currentNodePath = node.node_storage_path; } }, undefined, this.editor || undefined ); } finally { this.isLoading = false; } } async handleNavigationStart(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string } | null): Promise { if (!toNode) return; if (this.debounceTimeout) clearTimeout(this.debounceTimeout); return new Promise((resolve) => { this.debounceTimeout = setTimeout(async () => { try { await this.executeNavigation(fromNode, toNode); resolve(); } catch (error) { logger.error('snapshot-service', '❌ Navigation failed', error); throw error; } }, 100); }); } private async executeNavigation(fromNode: { node_storage_path: string } | null, toNode: { node_storage_path: string }): Promise { if (this.isSaving || this.isLoading) { this.pendingOperation = { save: fromNode?.node_storage_path, load: toNode.node_storage_path, }; return; } this.currentNodePath = null; if (toNode.node_storage_path) { await this.loadSnapshotForNode(toNode); } if (this.pendingOperation) { const op = this.pendingOperation; this.pendingOperation = null; await this.handleNavigationStart( op.save ? { node_storage_path: op.save } : null, op.load ? { node_storage_path: op.load } : null ); } } setAutoSave(enabled: boolean): void { this.isAutoSaveEnabled = enabled; } setCurrentNodePath(path: string): void { this.currentNodePath = path; } getCurrentNodePath(): string | null { return this.currentNodePath; } async forceSaveCurrentNode(): Promise { if (this.currentNodePath) { await this.saveCurrentSnapshot(this.currentNodePath); } } clearCurrentNode(): void { this.currentNodePath = null; this.store.clear(); } }