From 8b6857482036b251edd2f36c3e74152f939dec2f Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:22:37 +0800 Subject: [PATCH] fix: old workspace migration (#14403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #14395 #### PR Dependency Tree * **PR #14403** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Added ability to enumerate and list local workspaces. * Improved workspace ID persistence with Electron global-state storage, automatic fallback to legacy storage, and one-time migration to consolidate IDs. * **Tests** * Added unit test validating listing behavior (includes/excludes workspaces based on presence of workspace DB file). --- .../electron/src/helper/workspace/handlers.ts | 28 ++++ .../electron/src/helper/workspace/index.ts | 2 + .../electron/test/workspace/handlers.spec.ts | 37 +++++ .../modules/workspace-engine/impls/local.ts | 131 ++++++++++++++++-- .../src/modules/workspace-engine/index.ts | 11 +- 5 files changed, 189 insertions(+), 20 deletions(-) diff --git a/packages/frontend/apps/electron/src/helper/workspace/handlers.ts b/packages/frontend/apps/electron/src/helper/workspace/handlers.ts index 111f2a63a..9790f393c 100644 --- a/packages/frontend/apps/electron/src/helper/workspace/handlers.ts +++ b/packages/frontend/apps/electron/src/helper/workspace/handlers.ts @@ -15,6 +15,7 @@ import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter'; import type { WorkspaceMeta } from '../type'; import { getDeletedWorkspacesBasePath, + getSpaceBasePath, getSpaceDBPath, getWorkspaceBasePathV1, getWorkspaceMeta, @@ -96,6 +97,33 @@ export async function storeWorkspaceMeta( } } +export async function listLocalWorkspaceIds(): Promise { + const localWorkspaceBasePath = path.join( + await getSpaceBasePath('workspace'), + 'local' + ); + if (!(await fs.pathExists(localWorkspaceBasePath))) { + return []; + } + + const entries = await fs.readdir(localWorkspaceBasePath); + const ids = await Promise.all( + entries.map(async entry => { + const workspacePath = path.join(localWorkspaceBasePath, entry); + const stat = await fs.stat(workspacePath).catch(() => null); + if (!stat?.isDirectory()) { + return null; + } + if (!(await fs.pathExists(path.join(workspacePath, 'storage.db')))) { + return null; + } + return entry; + }) + ); + + return ids.filter((id): id is string => typeof id === 'string'); +} + type WorkspaceDocMeta = { id: string; name: string; diff --git a/packages/frontend/apps/electron/src/helper/workspace/index.ts b/packages/frontend/apps/electron/src/helper/workspace/index.ts index b44295033..7187b4287 100644 --- a/packages/frontend/apps/electron/src/helper/workspace/index.ts +++ b/packages/frontend/apps/electron/src/helper/workspace/index.ts @@ -3,6 +3,7 @@ import { deleteBackupWorkspace, deleteWorkspace, getDeletedWorkspaces, + listLocalWorkspaceIds, trashWorkspace, } from './handlers'; @@ -18,4 +19,5 @@ export const workspaceHandlers = { return getDeletedWorkspaces(); }, deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id), + listLocalWorkspaceIds: async () => listLocalWorkspaceIds(), }; diff --git a/packages/frontend/apps/electron/test/workspace/handlers.spec.ts b/packages/frontend/apps/electron/test/workspace/handlers.spec.ts index 1cfebed45..5c3cd1434 100644 --- a/packages/frontend/apps/electron/test/workspace/handlers.spec.ts +++ b/packages/frontend/apps/electron/test/workspace/handlers.spec.ts @@ -33,6 +33,43 @@ afterAll(() => { }); describe('workspace db management', () => { + test('list local workspace ids', async () => { + const { listLocalWorkspaceIds } = + await import('@affine/electron/helper/workspace/handlers'); + const validWorkspaceId = v4(); + const noDbWorkspaceId = v4(); + const fileEntry = 'README.txt'; + + const validWorkspacePath = path.join( + appDataPath, + 'workspaces', + 'local', + validWorkspaceId + ); + const noDbWorkspacePath = path.join( + appDataPath, + 'workspaces', + 'local', + noDbWorkspaceId + ); + const nonDirectoryPath = path.join( + appDataPath, + 'workspaces', + 'local', + fileEntry + ); + + await fs.ensureDir(validWorkspacePath); + await fs.ensureFile(path.join(validWorkspacePath, 'storage.db')); + await fs.ensureDir(noDbWorkspacePath); + await fs.outputFile(nonDirectoryPath, 'not-a-workspace'); + + const ids = await listLocalWorkspaceIds(); + expect(ids).toContain(validWorkspaceId); + expect(ids).not.toContain(noDbWorkspaceId); + expect(ids).not.toContain(fileEntry); + }); + test('trash workspace', async () => { const { trashWorkspace } = await import('@affine/electron/helper/workspace/handlers'); diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/local.ts b/packages/frontend/core/src/modules/workspace-engine/impls/local.ts index d19fe89f5..37a4cc3a6 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/local.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/local.ts @@ -48,15 +48,44 @@ import { WorkspaceImpl } from '../../workspace/impls/workspace'; import { getWorkspaceProfileWorker } from './out-worker'; export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace'; +export const LOCAL_WORKSPACE_GLOBAL_STATE_KEY = + 'workspace-engine:local-workspace-ids:v1'; const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY = 'affine-local-workspace-changed'; const logger = new DebugLogger('local-workspace'); -export function getLocalWorkspaceIds(): string[] { +type GlobalStateStorageLike = { + ready: Promise; + get(key: string): T | undefined; + set(key: string, value: T): void; +}; + +function normalizeWorkspaceIds(ids: unknown): string[] { + if (!Array.isArray(ids)) { + return []; + } + return ids.filter((id): id is string => typeof id === 'string'); +} + +function getElectronGlobalStateStorage(): GlobalStateStorageLike | null { + if (!BUILD_CONFIG.isElectron) { + return null; + } + const sharedStorage = ( + globalThis as { + __sharedStorage?: { globalState?: GlobalStateStorageLike }; + } + ).__sharedStorage; + return sharedStorage?.globalState ?? null; +} + +function getLegacyLocalWorkspaceIds(): string[] { try { - return JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + return normalizeWorkspaceIds( + JSON.parse( + localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' + ) ); } catch (e) { logger.error('Failed to get local workspace ids', e); @@ -64,21 +93,98 @@ export function getLocalWorkspaceIds(): string[] { } } +export function getLocalWorkspaceIds(): string[] { + const globalState = getElectronGlobalStateStorage(); + if (globalState) { + const value = globalState.get(LOCAL_WORKSPACE_GLOBAL_STATE_KEY); + if (value !== undefined) { + return normalizeWorkspaceIds(value); + } + } + + return getLegacyLocalWorkspaceIds(); +} + export function setLocalWorkspaceIds( idsOrUpdater: string[] | ((ids: string[]) => string[]) ) { - localStorage.setItem( - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, - JSON.stringify( - typeof idsOrUpdater === 'function' - ? idsOrUpdater(getLocalWorkspaceIds()) - : idsOrUpdater - ) + const next = normalizeWorkspaceIds( + typeof idsOrUpdater === 'function' + ? idsOrUpdater(getLocalWorkspaceIds()) + : idsOrUpdater ); + const deduplicated = [...new Set(next)]; + + const globalState = getElectronGlobalStateStorage(); + if (globalState) { + globalState.set(LOCAL_WORKSPACE_GLOBAL_STATE_KEY, deduplicated); + return; + } + + try { + localStorage.setItem( + LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, + JSON.stringify(deduplicated) + ); + } catch (e) { + logger.error('Failed to set local workspace ids', e); + } } class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider { - constructor(private readonly framework: FrameworkProvider) {} + constructor(private readonly framework: FrameworkProvider) { + if (BUILD_CONFIG.isElectron) { + void this.ensureWorkspaceIdsMigrated(); + } + } + + private migration: Promise | null = null; + + private ensureWorkspaceIdsMigrated() { + if (!BUILD_CONFIG.isElectron) { + return; + } + if (this.migration) { + return; + } + + this.migration = (async () => { + const electronApi = this.framework.get(DesktopApiService); + await electronApi.sharedStorage.globalState.ready; + + const persistedIds = normalizeWorkspaceIds( + electronApi.sharedStorage.globalState.get( + LOCAL_WORKSPACE_GLOBAL_STATE_KEY + ) + ); + const legacyIds = getLegacyLocalWorkspaceIds(); + + let scannedIds: string[] = []; + try { + scannedIds = + await electronApi.handler.workspace.listLocalWorkspaceIds(); + } catch (e) { + logger.error('Failed to scan local workspace ids', e); + } + + setLocalWorkspaceIds(currentIds => { + return [ + ...new Set([ + ...currentIds, + ...persistedIds, + ...legacyIds, + ...scannedIds, + ]), + ]; + }); + })() + .catch(e => { + logger.error('Failed to migrate local workspace ids', e); + }) + .finally(() => { + this.notifyChannel.postMessage(null); + }); + } readonly flavour = 'local'; readonly notifyChannel = new BroadcastChannel( @@ -242,6 +348,9 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ); isRevalidating$ = new LiveData(false); revalidate(): void { + if (BUILD_CONFIG.isElectron) { + void this.ensureWorkspaceIdsMigrated(); + } // notify livedata to re-scan workspaces this.notifyChannel.postMessage(null); } diff --git a/packages/frontend/core/src/modules/workspace-engine/index.ts b/packages/frontend/core/src/modules/workspace-engine/index.ts index be09cedd6..8e5188b9c 100644 --- a/packages/frontend/core/src/modules/workspace-engine/index.ts +++ b/packages/frontend/core/src/modules/workspace-engine/index.ts @@ -5,8 +5,8 @@ import { GlobalState } from '../storage'; import { WorkspaceFlavoursProvider } from '../workspace'; import { CloudWorkspaceFlavoursProvider } from './impls/cloud'; import { - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, LocalWorkspaceFlavoursProvider, + setLocalWorkspaceIds, } from './impls/local'; export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/base64'; @@ -25,12 +25,5 @@ export function configureBrowserWorkspaceFlavours(framework: Framework) { * Used after copying sqlite database file to appdata folder */ export function _addLocalWorkspace(id: string) { - const allWorkspaceIDs: string[] = JSON.parse( - localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' - ); - allWorkspaceIDs.push(id); - localStorage.setItem( - LOCAL_WORKSPACE_LOCAL_STORAGE_KEY, - JSON.stringify(allWorkspaceIDs) - ); + setLocalWorkspaceIds(ids => (ids.includes(id) ? ids : [...ids, id])); }