app-ci-deploy / test-build-deploy (push) Has been cancelled
- Pre-load: if session.currentPageId not in snapshot pages, reset to first available page - Post-load: if store instance.currentPageId still invalid, clear store for fresh start - On loadSnapshot error: clear store instead of silently failing with corrupt state - Root cause: teacher1 had a saved snapshot with currentPageId pointing to a deleted page
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
// 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<unknown | null> {
|
||
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<void> {
|
||
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<typeof setTimeout> | 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<void> {
|
||
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<string, { typeName: string; id: string }> })?.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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
if (this.currentNodePath) {
|
||
await this.saveCurrentSnapshot(this.currentNodePath);
|
||
}
|
||
}
|
||
|
||
clearCurrentNode(): void {
|
||
this.currentNodePath = null;
|
||
this.store.clear();
|
||
}
|
||
}
|