fix: old workspace migration (#14403)

fix #14395 

#### PR Dependency Tree


* **PR #14403** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-09 00:22:37 +08:00
committed by GitHub
parent bb01bb1aef
commit 8b68574820
5 changed files with 189 additions and 20 deletions
@@ -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<string[]> {
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;
@@ -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(),
};
@@ -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');
@@ -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<void>;
get<T>(key: string): T | undefined;
set<T>(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<void> | 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);
}
@@ -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]));
}