feat(phase-b): Supabase navigation store, snapshot service, auth wiring

navigationStore: rewritten off Neo4j db names — Supabase whiteboard_rooms table,
  setAuthInfo(token, userId) pattern, auto-creates default room per context on first use
snapshotService: rewritten to Supabase Storage REST (/storage/v1/object/authenticated/cc.users/…),
  setAccessToken() instance method, static methods take accessToken not dbName
AuthContext/NeoUserContext: auth injected into nav store, no Neo4j db names required
singlePlayerPage: loadNodeData no longer calls Neo4j; snapshot wired via accessToken
navigation types: NeoGraphNode updated for Supabase-backed tree structure
transcriptionStore/Service: getSession() removed, accessToken via AuthContext
LLMConfigModal: auth context wiring fixes
GraphNavigator/GraphSidebar: updated nav components

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 01:25:15 +01:00
co-authored by Claude Sonnet 4.6
parent 3a65cf436b
commit b0c7758135
11 changed files with 1641 additions and 1892 deletions
+140 -335
View File
@@ -1,27 +1,52 @@
// External imports
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
import axios from '../../axiosConfig';
import logger from '../../debugConfig';
import { SharedStoreService } from './sharedStoreService';
import { StorageKeys, storageService } from '../auth/localStorageService';
import { NavigationNode } from '../../types/navigation';
export interface LoadingState {
status: 'loading' | 'ready' | 'error';
error: string;
}
const EMPTY_NODE: NavigationNode = {
id: '',
node_storage_path: '',
type: '',
label: ''
};
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;
@@ -33,24 +58,21 @@ export class NavigationSnapshotService {
this.editor = editor || null;
logger.debug('snapshot-service', '🔄 Initialized NavigationSnapshotService', {
storeId: store.id,
hasEditor: !!editor
hasEditor: !!editor,
});
}
setEditor(editor: Editor): void {
this.editor = editor;
logger.debug('snapshot-service', '🔄 Editor reference updated', {
editorId: editor.store.id
});
}
private static replaceBackslashes(input: string | undefined): string {
return input ? input.replace(/\\/g, '/') : '';
setAccessToken(token: string): void {
this._accessToken = token;
}
static async loadNodeSnapshotFromDatabase(
nodePath: string,
dbName: string,
accessToken: string,
store: TLStore,
setLoadingState: (state: LoadingState) => void,
sharedStore?: SharedStoreService,
@@ -58,252 +80,102 @@ export class NavigationSnapshotService {
): Promise<void> {
try {
setLoadingState({ status: 'loading', error: '' });
logger.info('snapshot-service', '📂 Loading snapshot from Storage', { path: nodePath });
logger.info('snapshot-service', '📂 Loading file from path', {
path: nodePath,
db_name: dbName
});
const snapshot = await storageGet(nodePath, accessToken);
const response = await axios.get(
'/database/tldraw_supabase/get_tldraw_node_file', {
params: {
path: this.replaceBackslashes(nodePath),
db_name: dbName
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);
}
}
);
const snapshot = response.data;
logger.debug('snapshot-service', '🔍 Snapshot data received', {
hasSnapshot: !!snapshot,
hasDocument: !!snapshot?.document,
hasSession: !!snapshot?.session,
hasSchemaVersion: !!snapshot?.schemaVersion,
schemaVersion: snapshot?.schemaVersion,
snapshotKeys: snapshot ? Object.keys(snapshot) : []
});
if (snapshot && snapshot.document && snapshot.session) {
logger.debug('snapshot-service', '📥 Snapshot loaded successfully');
if (sharedStore) {
await sharedStore.loadSnapshot(snapshot, setLoadingState);
} else {
logger.debug('snapshot-service', '🔄 Calling TLDraw loadSnapshot', {
hasStore: !!store,
snapshotType: typeof snapshot,
snapshotKeys: Object.keys(snapshot),
snapshotSchemaVersion: snapshot?.schemaVersion,
snapshotDocument: !!snapshot?.document,
snapshotSession: !!snapshot?.session
});
// Create a defensive copy to ensure the snapshot doesn't get modified
const snapshotCopy = {
schemaVersion: snapshot.schemaVersion || snapshot.document?.schema?.schemaVersion,
document: snapshot.document,
session: snapshot.session
};
logger.debug('snapshot-service', '🔄 Calling loadSnapshot with defensive copy', {
copySchemaVersion: snapshotCopy.schemaVersion,
copyDocument: !!snapshotCopy.document,
copySession: !!snapshotCopy.session,
storeType: typeof store,
storeIsNull: store === null,
storeIsUndefined: store === undefined,
storeKeys: store ? Object.keys(store) : 'N/A'
});
// Debug: Log the snapshot schema sequences
if (snapshotCopy.document?.schema?.sequences) {
logger.debug('snapshot-service', '🔍 Snapshot schema sequences:', snapshotCopy.document.schema.sequences);
const customSequences = Object.keys(snapshotCopy.document.schema.sequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences in snapshot:', customSequences);
}
// Debug: Log the store schema sequences
if (store?.schema) {
const storeSequences = store.schema.serialize().sequences;
logger.debug('snapshot-service', '🔍 Store schema sequences:', storeSequences);
const storeCustomSequences = Object.keys(storeSequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences in store:', storeCustomSequences);
}
// Add try-catch around the loadSnapshot call to get more specific error info
try {
// Ensure store is properly initialized before loading snapshot
if (!store) {
throw new Error('Store is null or undefined');
}
// Validate snapshot structure before loading
if (!snapshotCopy || !snapshotCopy.document || !snapshotCopy.session) {
throw new Error('Invalid snapshot structure');
}
// Check for schema migrations and handle them properly
logger.debug('snapshot-service', '🔄 Checking for schema migrations', {
storeId: store.id,
storeType: typeof store,
storeConstructor: store.constructor.name,
snapshotSchemaVersion: snapshotCopy.schemaVersion,
snapshotDocumentKeys: Object.keys(snapshotCopy.document || {}),
snapshotSessionKeys: Object.keys(snapshotCopy.session || {})
});
try {
// Try to load the snapshot directly first
logger.debug('snapshot-service', '🔄 Attempting to load snapshot directly');
if (editor) {
loadSnapshot(editor.store, snapshotCopy);
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
} else {
// Fallback: use global loadSnapshot if no editor available
logger.debug('snapshot-service', '🔄 No editor available, using global loadSnapshot');
loadSnapshot(store, snapshotCopy);
logger.debug('snapshot-service', '✅ Snapshot loaded successfully via global loadSnapshot');
}
} catch (migrationError) {
// Check if this is a schema migration error that we can safely ignore
const errorMessage = migrationError instanceof Error ? migrationError.message : String(migrationError);
const isSchemaMigrationError = errorMessage.includes('migration') ||
errorMessage.includes('schema') ||
errorMessage.includes('Incompatible');
if (isSchemaMigrationError) {
logger.debug('snapshot-service', '️ Schema migration warning (non-critical)', {
error: errorMessage
});
// Continue with empty store - this is expected for some snapshots
} else {
logger.warn('snapshot-service', '⚠️ Unexpected load error', {
error: errorMessage
});
}
}
logger.debug('snapshot-service', '✅ loadSnapshot call succeeded');
setLoadingState({ status: 'ready', error: '' });
} catch (loadError) {
logger.error('snapshot-service', '❌ loadSnapshot call failed', {
error: loadError instanceof Error ? loadError.message : String(loadError),
storeType: typeof store,
storeHasLoadSnapshot: store && typeof store.loadSnapshot === 'function',
snapshotType: typeof snapshotCopy,
snapshotKeys: Object.keys(snapshotCopy)
});
throw loadError;
}
storageService.set(StorageKeys.NODE_FILE_PATH, nodePath);
}
} else {
logger.error('snapshot-service', '❌ Invalid snapshot format');
setLoadingState({ status: 'error', error: 'Invalid snapshot format' });
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;
}
const snapshotCopy = {
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
document: snap.document,
session: snap.session,
};
try {
if (editor) {
loadSnapshot(editor.store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
} else {
loadSnapshot(store, snapshotCopy as Parameters<typeof loadSnapshot>[1]);
}
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', { error: msg });
}
}
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('snapshot-service', '❌ Failed to fetch snapshot', {
error: error instanceof Error ? error.message : 'Unknown 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 file'
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load snapshot',
});
}
}
static async saveNodeSnapshotToDatabase(
nodePath: string,
dbName: string,
accessToken: string,
store: TLStore
): Promise<void> {
try {
logger.info('snapshot-service', '💾 Saving snapshot to database', {
path: nodePath,
db_name: dbName
});
logger.info('snapshot-service', '💾 Saving snapshot to Storage', { path: nodePath });
const snapshot = getSnapshot(store);
// Debug: Log what we're saving
logger.debug('snapshot-service', '🔍 Snapshot being saved:', {
hasSnapshot: !!snapshot,
snapshotKeys: Object.keys(snapshot || {}),
schemaVersion: snapshot?.schemaVersion,
hasDocument: !!snapshot?.document,
hasSession: !!snapshot?.session
});
// Debug: Log the schema sequences in the snapshot being saved
if (snapshot?.document?.schema?.sequences) {
logger.debug('snapshot-service', '🔍 Schema sequences being saved:', snapshot.document.schema.sequences);
const customSequences = Object.keys(snapshot.document.schema.sequences).filter(key => key.includes('cc-'));
logger.debug('snapshot-service', '🔍 Custom shape sequences being saved:', customSequences);
}
const response = await axios.post(
'/database/tldraw_supabase/set_tldraw_node_file',
snapshot,
{
params: {
path: this.replaceBackslashes(nodePath),
db_name: dbName
}
}
);
if (response.data.status === 'success') {
logger.debug('snapshot-service', '✅ Snapshot saved successfully');
} else {
throw new Error('Failed to save snapshot');
}
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'
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) {
logger.debug('snapshot-service', '⚠️ Skipping save - path mismatch', {
currentPath: this.currentNodePath,
savePath: nodePath
});
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;
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? '';
if (!dbName) {
logger.debug('snapshot-service', '⚠️ No db name - snapshot save skipped (Phase B will migrate to Supabase Storage)');
return;
}
logger.debug('snapshot-service', '💾 Saving snapshot', {
nodePath,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.saveNodeSnapshotToDatabase(nodePath, dbName, this.store);
logger.debug('snapshot-service', '✅ Saved navigation snapshot', {
nodePath,
storeId: this.store.id
});
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
nodePath,
});
throw error;
} finally {
@@ -311,141 +183,77 @@ export class NavigationSnapshotService {
}
}
private async loadSnapshotForNode(node: NavigationNode): Promise<void> {
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;
const user = storageService.get(StorageKeys.USER);
if (!user) {
throw new Error('No user found');
}
const dbName = (user as (typeof user & { user_db_name?: string })).user_db_name ?? '';
if (!dbName) {
logger.debug('snapshot-service', '⚠️ No db name - snapshot load skipped (Phase B will migrate to Supabase Storage)');
return;
}
logger.debug('snapshot-service', '📥 Loading snapshot', {
nodePath: node.node_storage_path,
dbName,
userType: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
node.node_storage_path,
dbName,
this._accessToken,
this.store,
(state: LoadingState) => {
if (state.status === 'ready') {
this.currentNodePath = node.node_storage_path;
logger.debug('snapshot-service', '✅ Snapshot loaded and path updated', {
nodePath: node.node_storage_path,
currentNodePath: this.currentNodePath
});
} else if (state.status === 'error') {
logger.error('snapshot-service', '❌ Error in load callback', {
error: state.error,
nodePath: node.node_storage_path
});
}
},
undefined, // sharedStore
this.editor || undefined // editor - use stored editor or fallback to store.loadSnapshot
undefined,
this.editor || undefined
);
} catch (error) {
logger.error('snapshot-service', '❌ Failed to load navigation snapshot', {
error: error instanceof Error ? error.message : 'Unknown error',
nodePath: node.node_storage_path
});
throw error;
} finally {
this.isLoading = false;
}
}
async handleNavigationStart(fromNode: NavigationNode | null, toNode: NavigationNode | null): Promise<void> {
if (!toNode) {
logger.warn('snapshot-service', '⚠️ Cannot navigate to null node');
return;
}
// Clear any pending debounce
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
// Debounce the navigation operation
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 || EMPTY_NODE, toNode);
await this.executeNavigation(fromNode, toNode);
resolve();
} catch (error) {
logger.error('snapshot-service', '❌ Navigation failed', error);
throw error;
}
}, 100); // 100ms debounce
}, 100);
});
}
private async executeNavigation(fromNode: NavigationNode, toNode: NavigationNode): Promise<void> {
try {
logger.debug('snapshot-service', '🔄 Starting navigation snapshot handling', {
from: fromNode.node_storage_path,
to: toNode.node_storage_path,
currentPath: this.currentNodePath
});
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;
}
// If we're already in a navigation operation, queue this one
if (this.isSaving || this.isLoading) {
this.pendingOperation = {
save: fromNode.node_storage_path || undefined,
load: toNode.node_storage_path
};
logger.debug('snapshot-service', '⏳ Queued navigation operation', this.pendingOperation);
return;
}
this.currentNodePath = null;
// Clear the store before loading new snapshot
logger.debug('snapshot-service', '🔄 Clearing store');
this.currentNodePath = null;
logger.debug('snapshot-service', '🧹 Cleared current node path');
if (toNode.node_storage_path) {
await this.loadSnapshotForNode(toNode);
}
// Load the new node's snapshot
if (toNode.node_storage_path) {
await this.loadSnapshotForNode(toNode);
logger.debug('snapshot-service', '✅ Loaded new node snapshot', {
nodePath: toNode.node_storage_path
});
}
// Process any pending operations
if (this.pendingOperation) {
logger.debug('snapshot-service', '🔄 Processing pending operation', this.pendingOperation);
const operation = this.pendingOperation;
this.pendingOperation = null;
await this.handleNavigationStart(
operation.save ? { ...EMPTY_NODE, node_storage_path: operation.save } : null,
operation.load ? { ...EMPTY_NODE, node_storage_path: operation.load } : null
);
logger.debug('snapshot-service', '✅ Completed pending operation');
}
} catch (error) {
logger.error('snapshot-service', '❌ Error during navigation snapshot handling', {
error: error instanceof Error ? error.message : 'Unknown error',
fromPath: fromNode.node_storage_path,
toPath: toNode.node_storage_path
});
throw error;
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;
logger.debug('snapshot-service', '🔄 Auto-save setting changed', {
enabled
});
}
setCurrentNodePath(path: string): void {
this.currentNodePath = path;
}
getCurrentNodePath(): string | null {
@@ -455,14 +263,11 @@ export class NavigationSnapshotService {
async forceSaveCurrentNode(): Promise<void> {
if (this.currentNodePath) {
await this.saveCurrentSnapshot(this.currentNodePath);
} else {
logger.warn('snapshot-service', '⚠️ Cannot save - no current node path set');
}
}
clearCurrentNode(): void {
this.currentNodePath = null;
this.store.clear();
logger.debug('snapshot-service', '🧹 Cleared current node and store');
}
}
}