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:
@@ -15,6 +15,7 @@ import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
|
|||||||
import type { WorkspaceMeta } from '../type';
|
import type { WorkspaceMeta } from '../type';
|
||||||
import {
|
import {
|
||||||
getDeletedWorkspacesBasePath,
|
getDeletedWorkspacesBasePath,
|
||||||
|
getSpaceBasePath,
|
||||||
getSpaceDBPath,
|
getSpaceDBPath,
|
||||||
getWorkspaceBasePathV1,
|
getWorkspaceBasePathV1,
|
||||||
getWorkspaceMeta,
|
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 = {
|
type WorkspaceDocMeta = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
deleteBackupWorkspace,
|
deleteBackupWorkspace,
|
||||||
deleteWorkspace,
|
deleteWorkspace,
|
||||||
getDeletedWorkspaces,
|
getDeletedWorkspaces,
|
||||||
|
listLocalWorkspaceIds,
|
||||||
trashWorkspace,
|
trashWorkspace,
|
||||||
} from './handlers';
|
} from './handlers';
|
||||||
|
|
||||||
@@ -18,4 +19,5 @@ export const workspaceHandlers = {
|
|||||||
return getDeletedWorkspaces();
|
return getDeletedWorkspaces();
|
||||||
},
|
},
|
||||||
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
|
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
|
||||||
|
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,43 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('workspace db management', () => {
|
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 () => {
|
test('trash workspace', async () => {
|
||||||
const { trashWorkspace } =
|
const { trashWorkspace } =
|
||||||
await import('@affine/electron/helper/workspace/handlers');
|
await import('@affine/electron/helper/workspace/handlers');
|
||||||
|
|||||||
@@ -48,15 +48,44 @@ import { WorkspaceImpl } from '../../workspace/impls/workspace';
|
|||||||
import { getWorkspaceProfileWorker } from './out-worker';
|
import { getWorkspaceProfileWorker } from './out-worker';
|
||||||
|
|
||||||
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
|
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 =
|
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
|
||||||
'affine-local-workspace-changed';
|
'affine-local-workspace-changed';
|
||||||
|
|
||||||
const logger = new DebugLogger('local-workspace');
|
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 {
|
try {
|
||||||
return JSON.parse(
|
return normalizeWorkspaceIds(
|
||||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
JSON.parse(
|
||||||
|
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to get local workspace ids', 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(
|
export function setLocalWorkspaceIds(
|
||||||
idsOrUpdater: string[] | ((ids: string[]) => string[])
|
idsOrUpdater: string[] | ((ids: string[]) => string[])
|
||||||
) {
|
) {
|
||||||
localStorage.setItem(
|
const next = normalizeWorkspaceIds(
|
||||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
typeof idsOrUpdater === 'function'
|
||||||
JSON.stringify(
|
? idsOrUpdater(getLocalWorkspaceIds())
|
||||||
typeof idsOrUpdater === 'function'
|
: idsOrUpdater
|
||||||
? 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 {
|
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 flavour = 'local';
|
||||||
readonly notifyChannel = new BroadcastChannel(
|
readonly notifyChannel = new BroadcastChannel(
|
||||||
@@ -242,6 +348,9 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
|||||||
);
|
);
|
||||||
isRevalidating$ = new LiveData(false);
|
isRevalidating$ = new LiveData(false);
|
||||||
revalidate(): void {
|
revalidate(): void {
|
||||||
|
if (BUILD_CONFIG.isElectron) {
|
||||||
|
void this.ensureWorkspaceIdsMigrated();
|
||||||
|
}
|
||||||
// notify livedata to re-scan workspaces
|
// notify livedata to re-scan workspaces
|
||||||
this.notifyChannel.postMessage(null);
|
this.notifyChannel.postMessage(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { GlobalState } from '../storage';
|
|||||||
import { WorkspaceFlavoursProvider } from '../workspace';
|
import { WorkspaceFlavoursProvider } from '../workspace';
|
||||||
import { CloudWorkspaceFlavoursProvider } from './impls/cloud';
|
import { CloudWorkspaceFlavoursProvider } from './impls/cloud';
|
||||||
import {
|
import {
|
||||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
|
||||||
LocalWorkspaceFlavoursProvider,
|
LocalWorkspaceFlavoursProvider,
|
||||||
|
setLocalWorkspaceIds,
|
||||||
} from './impls/local';
|
} from './impls/local';
|
||||||
|
|
||||||
export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/base64';
|
export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/base64';
|
||||||
@@ -25,12 +25,5 @@ export function configureBrowserWorkspaceFlavours(framework: Framework) {
|
|||||||
* Used after copying sqlite database file to appdata folder
|
* Used after copying sqlite database file to appdata folder
|
||||||
*/
|
*/
|
||||||
export function _addLocalWorkspace(id: string) {
|
export function _addLocalWorkspace(id: string) {
|
||||||
const allWorkspaceIDs: string[] = JSON.parse(
|
setLocalWorkspaceIds(ids => (ids.includes(id) ? ids : [...ids, id]));
|
||||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
|
||||||
);
|
|
||||||
allWorkspaceIDs.push(id);
|
|
||||||
localStorage.setItem(
|
|
||||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
|
||||||
JSON.stringify(allWorkspaceIDs)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user