diff --git a/src/application/database-yjs/dispatch/row.ts b/src/application/database-yjs/dispatch/row.ts index 372173ae..429b55bb 100644 --- a/src/application/database-yjs/dispatch/row.ts +++ b/src/application/database-yjs/dispatch/row.ts @@ -594,7 +594,7 @@ export function useDuplicateRowDispatch() { cachedDoc = getCachedProviderDoc(sourceDocId) ?? cachedDoc; } } else { - cachedDoc = getCachedProviderDoc(sourceDocId) ?? null; + cachedDoc = getCachedProviderDoc(sourceDocId) ?? undefined; } if (cachedDoc) { diff --git a/src/application/services/domains/view.ts b/src/application/services/domains/view.ts index 70d05ecc..ef0ab73b 100644 --- a/src/application/services/domains/view.ts +++ b/src/application/services/domains/view.ts @@ -6,9 +6,9 @@ export { getAppTrash as getTrash, createOrphanedView as createOrphaned, checkIfCollabExists as checkCollabExists, + getDatabaseViews, } from '../js-services/http/view-api'; export { getAppViewCached as get, invalidateViewCache as invalidateCache, - getAppDatabaseViewRelationsFromCollab as getDatabaseRelations, } from '../js-services/cached-api'; diff --git a/src/application/services/js-services/cached-api.ts b/src/application/services/js-services/cached-api.ts index 14494e65..908bfd20 100644 --- a/src/application/services/js-services/cached-api.ts +++ b/src/application/services/js-services/cached-api.ts @@ -4,8 +4,6 @@ * Module-level state replaces the singleton class instance state. */ import * as random from 'lib0/random'; -import * as Y from 'yjs'; - import { openCollabDB } from '@/application/db'; import { Log } from '@/utils/log'; import { @@ -39,7 +37,6 @@ import { unpublishView as unpublishViewAPI, updatePublishConfig as updatePublishConfigAPI, updatePublishNamespace as updatePublishNamespaceAPI, - getCollab, getCurrentUser as getCurrentUserAPI, getUserWorkspaceInfo as getUserWorkspaceInfoAPI, duplicatePublishView as duplicatePublishViewAPI, @@ -48,17 +45,13 @@ import { emit, EventType } from '@/application/session'; import { afterAuth, AUTH_CALLBACK_URL, saveRedirectTo } from '@/application/session/sign_in'; import { getTokenParsed } from '@/application/session/token'; import { - DatabaseRelations, DuplicatePublishView, DuplicatePublishViewResponse, PublishViewPayload, - Types, UpdatePublishConfigPayload, UploadPublishNamespacePayload, UserWorkspaceInfo, - YjsEditorKey, } from '@/application/types'; -import { applyYDoc } from '@/application/ydoc/apply'; import { registerUpload, unregisterUpload } from '@/utils/upload-tracker'; // ============================================================================ @@ -341,21 +334,6 @@ export async function duplicatePublishViewTransformed(params: DuplicatePublishVi }; } -export async function getAppDatabaseViewRelationsFromCollab(workspaceId: string, databaseStorageId: string) { - const res = await getCollab(workspaceId, databaseStorageId, Types.WorkspaceDatabase); - const doc = new Y.Doc(); - - applyYDoc(doc, res.data); - - const { databases } = doc.getMap(YjsEditorKey.data_section).toJSON(); - const result: DatabaseRelations = {}; - - databases.forEach((database: { database_id: string; views: string[] }) => { - result[database.database_id] = database.views[0]; - }); - return result; -} - export async function uploadFileWithTracking(workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) { const uploadId = registerUpload(); diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index c8b379bb..3c42e16c 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -66,6 +66,7 @@ export { getAppTrash, createOrphanedView, checkIfCollabExists, + getDatabaseViews, } from './view-api'; // Page diff --git a/src/application/services/js-services/http/view-api.ts b/src/application/services/js-services/http/view-api.ts index 2905f402..6a6622b9 100644 --- a/src/application/services/js-services/http/view-api.ts +++ b/src/application/services/js-services/http/view-api.ts @@ -1,5 +1,5 @@ import { AppOutlineResponse } from '@/application/services/services.type'; -import { View } from '@/application/types'; +import { AFDatabaseListPage, View } from '@/application/types'; import { APIResponse, executeAPIRequest, getAxios } from './core'; @@ -76,6 +76,42 @@ export async function createOrphanedView(workspaceId: string, payload: { documen return new Uint8Array(docStateArray); } +export async function getDatabaseViews(workspaceId: string): Promise { + // Server caps `limit` at 200 (LIST_DATABASE_MAX_LIMIT). Walk offsets until + // has_more is false so workspaces with >200 databases don't silently drop + // entries from the cache consumed by useWorkspaceData. + const PAGE_SIZE = 200; + const aggregated: AFDatabaseListPage['databases'] = []; + let offset = 0; + + // Hard cap to guarantee termination if the server ever fails to unset + // `has_more` (defensive against a buggy or misbehaving backend). + const MAX_PAGES = 100; + + for (let page = 0; page < MAX_PAGES; page++) { + const url = `/api/workspace/${workspaceId}/database?offset=${offset}&limit=${PAGE_SIZE}`; + const resp = await executeAPIRequest(() => + getAxios()?.get>(url) + ); + + if (resp?.databases?.length) { + aggregated.push(...resp.databases); + } + + if (!resp?.has_more) { + return { databases: aggregated, has_more: false }; + } + + offset += PAGE_SIZE; + } + + console.warn( + `[getDatabaseViews] Reached pagination hard cap (${MAX_PAGES} pages, ${aggregated.length} databases). ` + + 'Some databases may be missing from the cache.' + ); + return { databases: aggregated, has_more: false }; +} + export async function checkIfCollabExists(workspaceId: string, objectId: string) { const url = `/api/workspace/${workspaceId}/collab/${objectId}/collab-exists`; diff --git a/src/application/types.ts b/src/application/types.ts index 0f5f5b2e..8ffb6628 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -962,6 +962,28 @@ export type LoadViewMeta = (viewId: string, onChange?: (meta: View | null) => vo export type DatabaseRelations = Record; +/** A single view that belongs to a database (from server endpoint). */ +export interface AFDatabaseViewItem { + view_id: string; + layout: number; + is_container: boolean; + embedded: boolean; + name: string; + parent_view_id: string | null; +} + +/** A database and all the views that belong to it (from server endpoint). */ +export interface AFDatabaseWithViews { + database_id: string; + views: AFDatabaseViewItem[]; +} + +/** Response shape of GET /api/workspace/{id}/database?offset=&limit= */ +export interface AFDatabaseListPage { + databases: AFDatabaseWithViews[]; + has_more: boolean; +} + export interface Workspace { icon: string; id: string; diff --git a/src/components/app/contexts/AppOutlineContext.ts b/src/components/app/contexts/AppOutlineContext.ts index 31823a64..6c5085b7 100644 --- a/src/components/app/contexts/AppOutlineContext.ts +++ b/src/components/app/contexts/AppOutlineContext.ts @@ -59,6 +59,8 @@ export interface AppOutlineContextType { refreshOutline?: () => Promise; /** Load cross-database relation metadata for the workspace. */ loadDatabaseRelations?: () => Promise; + /** Synchronous reverse lookup: viewId → databaseId. */ + getDatabaseIdForViewId?: (viewId: string) => string | undefined; /** Resolve a user UUID to their mentionable-person profile. */ getMentionUser?: (uuid: string) => Promise; /** Load all mentionable users (workspace members) for @-mention autocomplete. */ diff --git a/src/components/app/hooks/useDatabaseIdentity.ts b/src/components/app/hooks/useDatabaseIdentity.ts index 115dd8fd..63ea1ce0 100644 --- a/src/components/app/hooks/useDatabaseIdentity.ts +++ b/src/components/app/hooks/useDatabaseIdentity.ts @@ -1,15 +1,23 @@ import { useCallback, useRef } from 'react'; -import { openCollabDB } from '@/application/db'; -import { DatabaseId, Types, ViewId, YDoc, YjsEditorKey } from '@/application/types'; +import { DatabaseId, DatabaseRelations, Types, ViewId, YDoc } from '@/application/types'; import { getDatabaseIdFromDoc } from '@/application/view-loader'; -import type { SyncContextType } from '@/components/ws/useSync'; import { Log } from '@/utils/log'; type UseDatabaseIdentityParams = { currentWorkspaceId?: string; - databaseStorageId?: string; - registerSyncContext: SyncContextType['registerSyncContext']; + /** Synchronous lookup: viewId → databaseId (from the cached reverse map). */ + getDatabaseIdForViewId?: (viewId: string) => string | undefined; + /** Synchronous lookup: returns the cached DatabaseRelations map. */ + getCachedDatabaseRelations?: () => DatabaseRelations | undefined; + /** + * Async loader: ensures the database relations are fetched, returns the map. + * Pass `forceRefresh=true` to bypass the workspace-level cache, which is + * required when retrying a lookup after a cache miss — the cached snapshot + * could have been warmed before a newly-created database existed, and the + * HTTP endpoint has no push-update channel to invalidate it. + */ + loadDatabaseRelations?: (forceRefresh?: boolean) => Promise; }; /** @@ -22,54 +30,35 @@ type UseDatabaseIdentityParams = { * For database layouts those two differ: * - `viewId` = database-view id (grid/board/calendar layout) * - `objectId` = shared database id + * + * The mapping data comes from the server `GET /database-views` endpoint, + * cached in `useWorkspaceData`. */ export function useDatabaseIdentity({ currentWorkspaceId, - databaseStorageId, - registerSyncContext, + getDatabaseIdForViewId, + getCachedDatabaseRelations, + loadDatabaseRelations, }: UseDatabaseIdentityParams) { - const workspaceDatabaseDocMapRef = useRef>(new Map()); const databaseIdViewIdMapRef = useRef>(new Map()); - const registerWorkspaceDatabaseDoc = useCallback( - async (workspaceId: string, workspaceDatabaseStorageId: string) => { - const doc = await openCollabDB(workspaceDatabaseStorageId); - - // Workspace-database sync is keyed by `databaseStorageId` (not workspaceId). - // Keep guid aligned with the collab object id used by providers and sync routing. - doc.guid = workspaceDatabaseStorageId; - const { doc: workspaceDatabaseDoc } = registerSyncContext({ - doc, - collabType: Types.WorkspaceDatabase, - }); - - workspaceDatabaseDocMapRef.current.clear(); - workspaceDatabaseDocMapRef.current.set(workspaceId, workspaceDatabaseDoc); - }, - [registerSyncContext] - ); - - const getDatabaseIdForViewId = useCallback( - async (viewId: string) => { - if (!currentWorkspaceId) return; + const resolveDatabaseIdForView = useCallback( + async (viewId: string): Promise => { + if (!currentWorkspaceId) return null; - // First check URL params for database mappings (passed from template duplication) - // This allows immediate lookup without waiting for workspace database sync + // 1. Check URL params for database mappings (passed from template duplication) try { const urlParams = new URLSearchParams(window.location.search); const dbMappingsParam = urlParams.get('db_mappings'); if (dbMappingsParam) { const dbMappings: Record = JSON.parse(decodeURIComponent(dbMappingsParam)); - // Store in localStorage for persistence across page refreshes const storageKey = `db_mappings_${currentWorkspaceId}`; const existingMappings = JSON.parse(localStorage.getItem(storageKey) || '{}'); const mergedMappings = { ...existingMappings, ...dbMappings }; localStorage.setItem(storageKey, JSON.stringify(mergedMappings)); - Log.debug('[useDatabaseIdentity] stored db_mappings to localStorage', mergedMappings); - // Find the database ID that contains this view for (const [databaseId, viewIds] of Object.entries(dbMappings)) { if (viewIds.includes(viewId)) { Log.debug('[useDatabaseIdentity] found databaseId from URL params', { viewId, databaseId }); @@ -81,7 +70,7 @@ export function useDatabaseIdentity({ console.warn('[useDatabaseIdentity] failed to parse db_mappings from URL', e); } - // Check localStorage for cached database mappings (persists across page refreshes) + // 2. Check localStorage for cached database mappings try { const storageKey = `db_mappings_${currentWorkspaceId}`; const cachedMappings = localStorage.getItem(storageKey); @@ -100,174 +89,84 @@ export function useDatabaseIdentity({ console.warn('[useDatabaseIdentity] failed to read db_mappings from localStorage', e); } - if (databaseStorageId && !workspaceDatabaseDocMapRef.current.has(currentWorkspaceId)) { - await registerWorkspaceDatabaseDoc(currentWorkspaceId, databaseStorageId); - } + // 3. Primary: use cached reverse map from the /database-views endpoint + const cachedId = getDatabaseIdForViewId?.(viewId); - return new Promise((resolve) => { - const sharedRoot = workspaceDatabaseDocMapRef.current.get(currentWorkspaceId)?.getMap(YjsEditorKey.data_section); - let resolved = false; - let warningLogged = false; - let observerRegistered = false; - let timeoutId: ReturnType | null = null; - - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - - if (observerRegistered && sharedRoot) { - try { - sharedRoot.unobserveDeep(observeEvent); - } catch { - // Ignore if already unobserved - } + if (cachedId) { + Log.debug('[useDatabaseIdentity] found databaseId from cached map', { viewId, databaseId: cachedId }); + return cachedId; + } - observerRegistered = false; + // 4. Cache miss: force a fresh fetch with retry. The server populates + // `af_folder_view.extra.database_id` via an event-driven backfill, so a + // just-created database may not appear in the HTTP response immediately. + // Retry up to 3 times with backoff (matching the old Yjs observer's + // tolerance for propagation delay). + if (loadDatabaseRelations) { + const RETRY_DELAYS = [0, 2000, 3000, 5000]; // ~10s total, matching old Yjs observer timeout + + for (const delay of RETRY_DELAYS) { + if (delay > 0) { + await new Promise((r) => setTimeout(r, delay)); } - }; - - const observeEvent = () => { - if (resolved) return; - - const databases = sharedRoot?.toJSON()?.databases; - const databaseId = databases?.find((database: { database_id: string; views: string[] }) => - database.views.find((view) => view === viewId) - )?.database_id; - - if (databaseId) { - resolved = true; - Log.debug('[useDatabaseIdentity] mapped view to database', { viewId, databaseId }); - cleanup(); - resolve(databaseId); - return; - } + await loadDatabaseRelations(true); + const freshId = getDatabaseIdForViewId?.(viewId); - // Only log warning once, not on every observe event - if (!warningLogged) { - warningLogged = true; - Log.debug('[useDatabaseIdentity] databaseId not found for view yet, waiting for sync', { viewId }); + if (freshId) { + Log.debug('[useDatabaseIdentity] found databaseId after loading relations', { viewId, databaseId: freshId }); + return freshId; } - }; - - observeEvent(); - if (sharedRoot && !resolved) { - sharedRoot.observeDeep(observeEvent); - observerRegistered = true; } + } - // Add timeout to prevent hanging forever - timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); - console.warn('[useDatabaseIdentity] databaseId lookup timed out for view', { viewId }); - resolve(null); - } - }, 10000); // 10 second timeout - }); + console.warn('[useDatabaseIdentity] databaseId not found for view', { viewId }); + return null; }, - [currentWorkspaceId, databaseStorageId, registerWorkspaceDatabaseDoc] + [currentWorkspaceId, getDatabaseIdForViewId, loadDatabaseRelations] ); const getViewIdFromDatabaseId = useCallback( - async (databaseId: string) => { + async (databaseId: string): Promise => { if (!currentWorkspaceId) { return null; } + // Check local cache first if (databaseIdViewIdMapRef.current.has(databaseId)) { return databaseIdViewIdMapRef.current.get(databaseId) || null; } - // Lazy-load workspace database doc if not yet registered (e.g. after page refresh). - // This mirrors the logic in getDatabaseIdForViewId. - if (databaseStorageId && !workspaceDatabaseDocMapRef.current.has(currentWorkspaceId)) { - await registerWorkspaceDatabaseDoc(currentWorkspaceId, databaseStorageId); - } + // Try the cached relations map (database_id → primary view_id) + const cached = getCachedDatabaseRelations?.(); - const workspaceDatabaseDoc = workspaceDatabaseDocMapRef.current.get(currentWorkspaceId); - - if (!workspaceDatabaseDoc) { - return null; - } - - const sharedRoot = workspaceDatabaseDoc.getMap(YjsEditorKey.data_section); - - const tryResolve = (): string | null => { - const databases = sharedRoot?.toJSON()?.databases; - const database = databases?.find((db: { database_id: string; views: string[] }) => db.database_id === databaseId); - - if (database) { - databaseIdViewIdMapRef.current.set(databaseId, database.views[0]); - return database.views[0]; - } - - return null; - }; - - // Try synchronous lookup first - const immediate = tryResolve(); - - if (immediate) { - return immediate; + if (cached?.[databaseId]) { + databaseIdViewIdMapRef.current.set(databaseId, cached[databaseId]); + return cached[databaseId]; } - // Wait for the workspace database doc to sync, with timeout - return new Promise((resolve) => { - let resolved = false; - let observerRegistered = false; - let timeoutId: ReturnType | null = null; + // Cache miss: force-refresh with retry to tolerate the event-driven + // backfill propagation delay on the server. + if (loadDatabaseRelations) { + const RETRY_DELAYS = [0, 1500, 3000]; - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; + for (const delay of RETRY_DELAYS) { + if (delay > 0) { + await new Promise((r) => setTimeout(r, delay)); } - if (observerRegistered && sharedRoot) { - try { - sharedRoot.unobserveDeep(observeEvent); - } catch { - // Ignore if already unobserved - } + const fresh = await loadDatabaseRelations(true); - observerRegistered = false; + if (fresh?.[databaseId]) { + databaseIdViewIdMapRef.current.set(databaseId, fresh[databaseId]); + return fresh[databaseId]; } - }; - - const observeEvent = () => { - if (resolved) return; - - const result = tryResolve(); - - if (result) { - resolved = true; - cleanup(); - resolve(result); - } - }; - - if (sharedRoot) { - sharedRoot.observeDeep(observeEvent); - observerRegistered = true; - } else { - resolve(null); - return; } + } - timeoutId = setTimeout(() => { - if (!resolved) { - resolved = true; - cleanup(); - resolve(null); - } - }, 10000); - }); + return null; }, - [currentWorkspaceId, databaseStorageId, registerWorkspaceDatabaseDoc] + [currentWorkspaceId, getCachedDatabaseRelations, loadDatabaseRelations] ); const resolveCollabObjectId = useCallback( @@ -277,7 +176,6 @@ export function useDatabaseIdentity({ } // First try getting databaseId directly from the doc (fast, synchronous). - // This works for newly created embedded databases where the doc already has the ID. let databaseId = getDatabaseIdFromDoc(doc); if (databaseId) { @@ -286,8 +184,8 @@ export function useDatabaseIdentity({ databaseId, }); } else { - // Fallback to workspace database mapping lookup (async, may timeout). - databaseId = (await getDatabaseIdForViewId(viewId)) ?? null; + // Fallback to server-side mapping lookup. + databaseId = await resolveDatabaseIdForView(viewId); } if (!databaseId) { @@ -301,7 +199,7 @@ export function useDatabaseIdentity({ doc.guid = databaseId; return databaseId; }, - [getDatabaseIdForViewId] + [resolveDatabaseIdForView] ); return { diff --git a/src/components/app/hooks/useViewOperations.ts b/src/components/app/hooks/useViewOperations.ts index 1ef35b07..4d1c9fe4 100644 --- a/src/components/app/hooks/useViewOperations.ts +++ b/src/components/app/hooks/useViewOperations.ts @@ -44,12 +44,24 @@ export function getViewReadOnlyStatus(viewId: string, outline?: View[]) { return false; } +interface UseViewOperationsParams { + /** Synchronous lookup: viewId → databaseId (from cached reverse map). */ + getDatabaseIdForViewId?: (viewId: string) => string | undefined; + /** Synchronous lookup: returns the cached DatabaseRelations map. */ + getCachedDatabaseRelations?: () => import('@/application/types').DatabaseRelations | undefined; + /** Async loader: ensures database relations are fetched, returns the map. */ + loadDatabaseRelations?: (forceRefresh?: boolean) => Promise; +} + // Hook for managing view-related operations -export function useViewOperations() { - const { currentWorkspaceId, userWorkspaceInfo } = useAuthInternal(); +export function useViewOperations({ + getDatabaseIdForViewId, + getCachedDatabaseRelations, + loadDatabaseRelations, +}: UseViewOperationsParams = {}) { + const { currentWorkspaceId } = useAuthInternal(); const { registerSyncContext, eventEmitter } = useSyncInternal(); const navigate = useNavigate(); - const databaseStorageId = userWorkspaceInfo?.selectedWorkspace?.databaseStorageId; const [awarenessMap, setAwarenessMap] = useState>({}); // Ref for stable access to awarenessMap in callbacks (prevents bindViewSync recreation) @@ -83,8 +95,9 @@ export function useViewOperations() { const { resolveCollabObjectId, getViewIdFromDatabaseId } = useDatabaseIdentity({ currentWorkspaceId, - databaseStorageId, - registerSyncContext, + getDatabaseIdForViewId, + getCachedDatabaseRelations, + loadDatabaseRelations, }); // Check if view should be readonly based on access permissions diff --git a/src/components/app/hooks/useWorkspaceData.ts b/src/components/app/hooks/useWorkspaceData.ts index 0a4293e5..86254623 100644 --- a/src/components/app/hooks/useWorkspaceData.ts +++ b/src/components/app/hooks/useWorkspaceData.ts @@ -18,7 +18,7 @@ import { } from '@/components/_shared/outline/mergeOutline'; import { findView, findViewByLayout } from '@/components/_shared/outline/utils'; import { notification } from '@/proto/messages'; -import { createDeduplicatedNoArgsRequest } from '@/utils/deduplicateRequest'; +import { createDeduplicatedNoArgsRequest, createDeduplicatedRequest } from '@/utils/deduplicateRequest'; import { Log } from '@/utils/log'; import { useAuthInternal } from '../contexts/AuthInternalContext'; @@ -168,6 +168,8 @@ export function useWorkspaceData() { const [trashList, setTrashList] = useState(); const [workspaceDatabases, setWorkspaceDatabases] = useState(undefined); const workspaceDatabasesRef = useRef(undefined); + // Reverse map: viewId → databaseId (built from the same endpoint response) + const viewToDatabaseRef = useRef | undefined>(undefined); const [requestAccessError, setRequestAccessError] = useState(null); const mentionableUsersRef = useRef([]); @@ -894,36 +896,61 @@ export function useWorkspaceData() { return workspaceDatabasesRef.current; }, []); + // Get database_id for a given view_id (synchronous, from cached reverse map) + const getDatabaseIdForViewId = useCallback((viewId: string): string | undefined => { + return viewToDatabaseRef.current?.[viewId]; + }, []); + // Internal helper to fetch and update database relations const fetchAndUpdateDatabaseRelations = useCallback(async (silent = false) => { if (!currentWorkspaceId) { return; } - const selectedWorkspace = userWorkspaceInfo?.selectedWorkspace; - - if (!selectedWorkspace) return; - try { - const res = await ViewService.getDatabaseRelations(currentWorkspaceId, selectedWorkspace.databaseStorageId); + const resp = await ViewService.getDatabaseViews(currentWorkspaceId); + + if (resp) { + // Flatten into DatabaseRelations: { database_id → primary view_id } + const relations: DatabaseRelations = {}; + // Build reverse map: { view_id → database_id } + const viewToDb: Record = {}; + + for (const db of resp.databases) { + // Pick the best "primary" view: prefer container + non-embedded, then non-embedded, then first. + const primary = + db.views.find((v) => v.is_container && !v.embedded) ?? + db.views.find((v) => !v.embedded) ?? + db.views[0]; + + if (primary) { + relations[db.database_id] = primary.view_id; + } - if (res) { - workspaceDatabasesRef.current = res; - setWorkspaceDatabases(res); - } + // Map every view in this database to the database_id + for (const v of db.views) { + viewToDb[v.view_id] = db.database_id; + } + } - return res; + workspaceDatabasesRef.current = relations; + viewToDatabaseRef.current = viewToDb; + setWorkspaceDatabases(relations); + return relations; + } } catch (e) { if (!silent) { console.error(e); } } - }, [currentWorkspaceId, userWorkspaceInfo?.selectedWorkspace]); + }, [currentWorkspaceId]); - // Load database relations (returns cached if available, fetches otherwise) - const loadDatabaseRelations = useCallback(async () => { - // Return cached data if already loaded to avoid unnecessary re-renders - if (workspaceDatabasesRef.current) { + // Load database relations (returns cached if available, fetches otherwise). + // Pass `forceRefresh=true` to bypass the cache — required after a new database + // is created mid-session, since the HTTP snapshot has no push-notification path + // and callers looking up a just-created id would otherwise see stale data. + const loadDatabaseRelations = useCallback(async (forceRefresh = false) => { + if (!forceRefresh && workspaceDatabasesRef.current) { return workspaceDatabasesRef.current; } @@ -937,7 +964,11 @@ export function useWorkspaceData() { }, [fetchAndUpdateDatabaseRelations]); const enhancedLoadDatabaseRelations = useMemo(() => { - return createDeduplicatedNoArgsRequest(loadDatabaseRelations); + // Dedupe concurrent calls, keying on `forceRefresh` so a pending cached-read + // doesn't absorb a later force-refresh request. + return createDeduplicatedRequest(loadDatabaseRelations, (forceRefresh) => + forceRefresh ? 'force' : 'cached' + ); }, [loadDatabaseRelations]); // Load views based on variant @@ -1023,6 +1054,7 @@ export function useWorkspaceData() { // Clear database relations cache when switching workspaces to prevent // cross-workspace data contamination workspaceDatabasesRef.current = undefined; + viewToDatabaseRef.current = undefined; setWorkspaceDatabases(undefined); void loadOutline(currentWorkspaceId, true); void (async () => { @@ -1054,6 +1086,7 @@ export function useWorkspaceData() { loadTrash, loadDatabaseRelations: enhancedLoadDatabaseRelations, getCachedDatabaseRelations, + getDatabaseIdForViewId, refreshDatabaseRelationsInBackground, loadViews, getMentionUser, diff --git a/src/components/app/layers/AppBusinessLayer.tsx b/src/components/app/layers/AppBusinessLayer.tsx index cc20087d..7816357b 100644 --- a/src/components/app/layers/AppBusinessLayer.tsx +++ b/src/components/app/layers/AppBusinessLayer.tsx @@ -98,6 +98,8 @@ export const AppBusinessLayer: FC = ({ children }) => { loadRecentViews, loadTrash, loadDatabaseRelations, + getCachedDatabaseRelations, + getDatabaseIdForViewId, loadViews, getMentionUser, loadMentionableUsers, @@ -117,7 +119,11 @@ export const AppBusinessLayer: FC = ({ children }) => { }, [outline, tabViewId, viewId]); // Initialize view operations - const { loadView, toView, awarenessMap, getViewIdFromDatabaseId, bindViewSync, getCollabHistory, previewCollabVersion } = useViewOperations(); + const { loadView, toView, awarenessMap, getViewIdFromDatabaseId, bindViewSync, getCollabHistory, previewCollabVersion } = useViewOperations({ + getDatabaseIdForViewId, + getCachedDatabaseRelations, + loadDatabaseRelations, + }); // Initialize row operations const { createRow } = useRowOperations(); @@ -492,6 +498,7 @@ export const AppBusinessLayer: FC = ({ children }) => { loadViews, refreshOutline, loadDatabaseRelations, + getDatabaseIdForViewId, getMentionUser, loadMentionableUsers, }), @@ -499,7 +506,8 @@ export const AppBusinessLayer: FC = ({ children }) => { outline, favoriteViews, recentViews, trashList, loadedViewIds, loadViewChildren, loadViewChildrenBatch, markViewChildrenStale, loadFavoriteViews, loadRecentViews, loadTrash, loadViews, - refreshOutline, loadDatabaseRelations, getMentionUser, loadMentionableUsers, + refreshOutline, loadDatabaseRelations, getDatabaseIdForViewId, + getMentionUser, loadMentionableUsers, ] ); diff --git a/src/components/ws/useSync.ts b/src/components/ws/useSync.ts index 2f38cc49..f22dff2c 100644 --- a/src/components/ws/useSync.ts +++ b/src/components/ws/useSync.ts @@ -58,7 +58,6 @@ export type { RegisterSyncContext, UpdateCollabInfo, SyncContextType } from './s * **Called by:** * - `useViewOperations.bindViewSync()` — after a document/database view loads * - `useViewOperations.createRow()` — immediately after creating a new database row - * - `useDatabaseIdentity.registerWorkspaceDatabaseDoc()` — lazily on first database view * - `useBindViewSync` — simplified binding used by the Database component * - `rebuildCollabDoc()` — internally during version-reset or revert to re-register * the rebuilt doc