mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
refactor: workspace manager (#5060)
This commit is contained in:
@@ -1,79 +0,0 @@
|
||||
import type { ActiveDocProvider, Workspace } from '@blocksuite/store';
|
||||
import type { PassiveDocProvider } from '@blocksuite/store';
|
||||
import type { Atom } from 'jotai/vanilla';
|
||||
import { atom } from 'jotai/vanilla';
|
||||
import { atomEffect } from 'jotai-effect';
|
||||
|
||||
/**
|
||||
* Map: guid -> Workspace
|
||||
*/
|
||||
export const INTERNAL_BLOCKSUITE_HASH_MAP = new Map<string, Workspace>([]);
|
||||
|
||||
const workspaceActiveAtomWeakMap = new WeakMap<
|
||||
Workspace,
|
||||
Atom<Promise<Workspace>>
|
||||
>();
|
||||
|
||||
const workspaceActiveWeakMap = new WeakMap<Workspace, boolean>();
|
||||
const workspaceEffectAtomWeakMap = new WeakMap<Workspace, Atom<void>>();
|
||||
|
||||
export async function waitForWorkspace(workspace: Workspace) {
|
||||
if (workspaceActiveWeakMap.get(workspace) !== true) {
|
||||
const providers = workspace.providers.filter(
|
||||
(provider): provider is ActiveDocProvider =>
|
||||
'active' in provider && provider.active === true
|
||||
);
|
||||
for (const provider of providers) {
|
||||
provider.sync();
|
||||
// we will wait for the necessary providers to be ready
|
||||
await provider.whenReady;
|
||||
}
|
||||
// timeout is INFINITE
|
||||
workspaceActiveWeakMap.set(workspace, true);
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspace(id: string) {
|
||||
if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
return INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace;
|
||||
}
|
||||
|
||||
export function getBlockSuiteWorkspaceAtom(
|
||||
id: string
|
||||
): [workspaceAtom: Atom<Promise<Workspace>>, workspaceEffectAtom: Atom<void>] {
|
||||
if (!INTERNAL_BLOCKSUITE_HASH_MAP.has(id)) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
const workspace = INTERNAL_BLOCKSUITE_HASH_MAP.get(id) as Workspace;
|
||||
if (!workspaceActiveAtomWeakMap.has(workspace)) {
|
||||
const baseAtom = atom(async () => {
|
||||
await waitForWorkspace(workspace);
|
||||
return workspace;
|
||||
});
|
||||
workspaceActiveAtomWeakMap.set(workspace, baseAtom);
|
||||
}
|
||||
if (!workspaceEffectAtomWeakMap.has(workspace)) {
|
||||
const effectAtom = atomEffect(() => {
|
||||
const providers = workspace.providers.filter(
|
||||
(provider): provider is PassiveDocProvider =>
|
||||
'passive' in provider && provider.passive === true
|
||||
);
|
||||
providers.forEach(provider => {
|
||||
provider.connect();
|
||||
});
|
||||
return () => {
|
||||
providers.forEach(provider => {
|
||||
provider.disconnect();
|
||||
});
|
||||
};
|
||||
});
|
||||
workspaceEffectAtomWeakMap.set(workspace, effectAtom);
|
||||
}
|
||||
|
||||
return [
|
||||
workspaceActiveAtomWeakMap.get(workspace) as Atom<Promise<Workspace>>,
|
||||
workspaceEffectAtomWeakMap.get(workspace) as Atom<void>,
|
||||
];
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { Schema, Workspace } from '@blocksuite/store';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { getDefaultStore } from 'jotai/vanilla';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getBlockSuiteWorkspaceAtom,
|
||||
INTERNAL_BLOCKSUITE_HASH_MAP,
|
||||
} from '../__internal__/workspace.js';
|
||||
|
||||
test('blocksuite atom', async () => {
|
||||
const sync = vi.fn();
|
||||
let connected = false;
|
||||
const connect = vi.fn(() => (connected = true));
|
||||
const workspace = new Workspace({
|
||||
schema: new Schema(),
|
||||
id: '1',
|
||||
providerCreators: [
|
||||
() => ({
|
||||
flavour: 'fake',
|
||||
active: true,
|
||||
sync,
|
||||
get whenReady(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
() => ({
|
||||
flavour: 'fake-2',
|
||||
passive: true,
|
||||
get connected() {
|
||||
return connected;
|
||||
},
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
INTERNAL_BLOCKSUITE_HASH_MAP.set('1', workspace);
|
||||
|
||||
{
|
||||
const [atom, effectAtom] = getBlockSuiteWorkspaceAtom('1');
|
||||
const store = getDefaultStore();
|
||||
const result = await store.get(atom);
|
||||
expect(result).toBe(workspace);
|
||||
expect(sync).toBeCalledTimes(1);
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
|
||||
store.sub(effectAtom, vi.fn());
|
||||
await waitFor(() => expect(connect).toBeCalledTimes(1));
|
||||
expect(connected).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -1,14 +1,3 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
import { getBlockSuiteWorkspaceAtom } from '../__internal__/workspace';
|
||||
|
||||
export const currentWorkspaceIdAtom = atom<string | null>(null);
|
||||
export const currentPageIdAtom = atom<string | null>(null);
|
||||
export const currentWorkspaceAtom = atom<Promise<Workspace>>(async get => {
|
||||
const workspaceId = get(currentWorkspaceIdAtom);
|
||||
assertExists(workspaceId);
|
||||
const [currentWorkspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId);
|
||||
return get(currentWorkspaceAtom);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export * from './initialization';
|
||||
export * from './migration/blob';
|
||||
export { migratePages as forceUpgradePages } from './migration/blocksuite'; // campatible with electron
|
||||
export {
|
||||
migratePages as forceUpgradePages,
|
||||
migrateGuidCompatibility,
|
||||
} from './migration/blocksuite'; // campatible with electron
|
||||
export * from './migration/fixing';
|
||||
export { migrateToSubdoc } from './migration/subdoc';
|
||||
export { migrateToSubdoc, upgradeV1ToV2 } from './migration/subdoc';
|
||||
export * from './migration/workspace';
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,12 +2,9 @@ import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Page, PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { createStore, WritableAtom } from 'jotai/vanilla';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Map as YMap } from 'yjs';
|
||||
|
||||
import { migratePages } from '../migration/blocksuite';
|
||||
import {
|
||||
checkWorkspaceCompatibility,
|
||||
MigrationPoint,
|
||||
} from '../migration/workspace';
|
||||
import { getLatestVersions } from '../migration/blocksuite';
|
||||
|
||||
export async function initEmptyPage(page: Page, title?: string) {
|
||||
await page.load(() => {
|
||||
@@ -261,10 +258,11 @@ export async function buildShowcaseWorkspace(
|
||||
|
||||
// The showcase building will create multiple pages once, and may skip the version writing.
|
||||
// https://github.com/toeverything/blocksuite/blob/master/packages/store/src/workspace/page.ts#L662
|
||||
const compatibilityResult = checkWorkspaceCompatibility(workspace);
|
||||
if (compatibilityResult === MigrationPoint.BlockVersion) {
|
||||
await migratePages(workspace.doc, workspace.schema);
|
||||
}
|
||||
workspace.doc.getMap('meta').set('pageVersion', 2);
|
||||
const newVersions = getLatestVersions(workspace.schema);
|
||||
workspace.doc
|
||||
.getMap('meta')
|
||||
.set('blockVersions', new YMap(Object.entries(newVersions)));
|
||||
|
||||
Object.entries(pageMetas).forEach(([oldId, meta]) => {
|
||||
const newId = idMap[oldId];
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createIndexeddbStorage } from '@blocksuite/store';
|
||||
|
||||
export async function migrateLocalBlobStorage(from: string, to: string) {
|
||||
const fromStorage = createIndexeddbStorage(from);
|
||||
const toStorage = createIndexeddbStorage(to);
|
||||
const keys = await fromStorage.crud.list();
|
||||
for (const key of keys) {
|
||||
const value = await fromStorage.crud.get(key);
|
||||
if (!value) {
|
||||
console.warn('cannot find blob:', key);
|
||||
continue;
|
||||
}
|
||||
await toStorage.crud.set(key, value);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Schema } from '@blocksuite/store';
|
||||
import type { Doc as YDoc } from 'yjs';
|
||||
import { Map as YMap } from 'yjs';
|
||||
import type { Array as YArray } from 'yjs';
|
||||
import {
|
||||
applyUpdate,
|
||||
Doc as YDoc,
|
||||
encodeStateAsUpdate,
|
||||
Map as YMap,
|
||||
transact,
|
||||
} from 'yjs';
|
||||
|
||||
const getLatestVersions = (schema: Schema): Record<string, number> => {
|
||||
export const getLatestVersions = (schema: Schema): Record<string, number> => {
|
||||
return [...schema.flavourSchemaMap.entries()].reduce(
|
||||
(record, [flavour, schema]) => {
|
||||
record[flavour] = schema.version;
|
||||
@@ -28,14 +34,62 @@ export async function migratePages(
|
||||
|
||||
// Hard code to upgrade page version to 2.
|
||||
// Let e2e to ensure the data version is correct.
|
||||
const pageVersion = meta.get('pageVersion');
|
||||
if (typeof pageVersion !== 'number' || pageVersion < 2) {
|
||||
meta.set('pageVersion', 2);
|
||||
}
|
||||
return transact(
|
||||
rootDoc,
|
||||
() => {
|
||||
const pageVersion = meta.get('pageVersion');
|
||||
if (typeof pageVersion !== 'number' || pageVersion < 2) {
|
||||
meta.set('pageVersion', 2);
|
||||
}
|
||||
|
||||
const newVersions = getLatestVersions(schema);
|
||||
meta.set('blockVersions', new YMap(Object.entries(newVersions)));
|
||||
return Object.entries(oldVersions).some(
|
||||
([flavour, version]) => newVersions[flavour] !== version
|
||||
const newVersions = getLatestVersions(schema);
|
||||
meta.set('blockVersions', new YMap(Object.entries(newVersions)));
|
||||
return Object.entries(oldVersions).some(
|
||||
([flavour, version]) => newVersions[flavour] !== version
|
||||
);
|
||||
},
|
||||
'migratePages',
|
||||
/**
|
||||
* transact as remote update, because blocksuite will skip local changes.
|
||||
* https://github.com/toeverything/blocksuite/blob/9c2df3f7aa5617c050e0dccdd73e99bb67e0c0f7/packages/store/src/reactive/utils.ts#L143
|
||||
*/
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// patch root doc's space guid compatibility issue
|
||||
//
|
||||
// in version 0.10, page id in spaces no longer has prefix "space:"
|
||||
// The data flow for fetching a doc's updates is:
|
||||
// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid`
|
||||
// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId
|
||||
// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces`
|
||||
// - when fetching the rows of this doc using the doc id === page id,
|
||||
// it will return empty since there is no updates associated with the page id
|
||||
export function migrateGuidCompatibility(rootDoc: YDoc) {
|
||||
const meta = rootDoc.getMap('meta') as YMap<unknown>;
|
||||
const pages = meta.get('pages') as YArray<YMap<unknown>>;
|
||||
pages?.forEach(page => {
|
||||
const pageId = page.get('id') as string | undefined;
|
||||
if (pageId?.includes(':')) {
|
||||
// remove the prefix "space:" from page id
|
||||
page.set('id', pageId.split(':').at(-1));
|
||||
}
|
||||
});
|
||||
const spaces = rootDoc.getMap('spaces') as YMap<YDoc>;
|
||||
spaces?.forEach((doc: YDoc, pageId: string) => {
|
||||
if (pageId.includes(':')) {
|
||||
const newPageId = pageId.split(':').at(-1) ?? pageId;
|
||||
const newDoc = new YDoc();
|
||||
// clone the original doc. yjs is not happy to use the same doc instance
|
||||
applyUpdate(newDoc, encodeStateAsUpdate(doc));
|
||||
newDoc.guid = doc.guid;
|
||||
spaces.set(newPageId, newDoc);
|
||||
// should remove the old doc, otherwise we will do it again in the next run
|
||||
spaces.delete(pageId);
|
||||
console.debug(
|
||||
`fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,48 +1,5 @@
|
||||
import type { Array as YArray, Map as YMap } from 'yjs';
|
||||
import { Doc as YDoc, transact } from 'yjs';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
// patch root doc's space guid compatibility issue
|
||||
//
|
||||
// in version 0.10, page id in spaces no longer has prefix "space:"
|
||||
// The data flow for fetching a doc's updates is:
|
||||
// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid`
|
||||
// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId
|
||||
// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces`
|
||||
// - when fetching the rows of this doc using the doc id === page id,
|
||||
// it will return empty since there is no updates associated with the page id
|
||||
export function guidCompatibilityFix(rootDoc: YDoc) {
|
||||
let changed = false;
|
||||
transact(rootDoc, () => {
|
||||
const meta = rootDoc.getMap('meta') as YMap<unknown>;
|
||||
const pages = meta.get('pages') as YArray<YMap<unknown>>;
|
||||
pages?.forEach(page => {
|
||||
const pageId = page.get('id') as string | undefined;
|
||||
if (pageId?.includes(':')) {
|
||||
// remove the prefix "space:" from page id
|
||||
page.set('id', pageId.split(':').at(-1));
|
||||
}
|
||||
});
|
||||
const spaces = rootDoc.getMap('spaces') as YMap<YDoc>;
|
||||
spaces?.forEach((doc: YDoc, pageId: string) => {
|
||||
if (pageId.includes(':')) {
|
||||
const newPageId = pageId.split(':').at(-1) ?? pageId;
|
||||
const newDoc = new YDoc();
|
||||
// clone the original doc. yjs is not happy to use the same doc instance
|
||||
applyUpdate(newDoc, encodeStateAsUpdate(doc));
|
||||
newDoc.guid = doc.guid;
|
||||
spaces.set(newPageId, newDoc);
|
||||
// should remove the old doc, otherwise we will do it again in the next run
|
||||
spaces.delete(pageId);
|
||||
changed = true;
|
||||
console.debug(
|
||||
`fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
import type { Doc as YDoc, Map as YMap } from 'yjs';
|
||||
import { transact } from 'yjs';
|
||||
|
||||
/**
|
||||
* Hard code to fix workspace version to be compatible with legacy data.
|
||||
@@ -55,13 +12,35 @@ export function fixWorkspaceVersion(rootDoc: YDoc) {
|
||||
* It doesn't matter to upgrade workspace version from 1 or undefined to 2.
|
||||
* Blocksuite just set the value, do nothing else.
|
||||
*/
|
||||
const workspaceVersion = meta.get('workspaceVersion');
|
||||
if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) {
|
||||
meta.set('workspaceVersion', 2);
|
||||
|
||||
function doFix() {
|
||||
const workspaceVersion = meta.get('workspaceVersion');
|
||||
if (typeof workspaceVersion !== 'number' || workspaceVersion < 2) {
|
||||
transact(
|
||||
rootDoc,
|
||||
() => {
|
||||
meta.set('workspaceVersion', 2);
|
||||
},
|
||||
'fixWorkspaceVersion',
|
||||
// transact as remote update, because blocksuite will skip local changes.
|
||||
false
|
||||
);
|
||||
}
|
||||
const pageVersion = meta.get('pageVersion');
|
||||
if (typeof pageVersion !== 'number') {
|
||||
meta.set('pageVersion', 1);
|
||||
if (typeof pageVersion !== 'number' || pageVersion < 2) {
|
||||
transact(
|
||||
rootDoc,
|
||||
() => {
|
||||
meta.set('pageVersion', 2);
|
||||
},
|
||||
'fixPageVersion',
|
||||
// transact as remote update, because blocksuite will skip local changes.
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
doFix();
|
||||
|
||||
// do fix every time when meta changed
|
||||
meta.observe(() => doFix());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
@@ -264,19 +263,17 @@ export function migrateToSubdoc(oldDoc: YDoc): YDoc {
|
||||
return newDoc;
|
||||
}
|
||||
|
||||
export const upgradeV1ToV2 = async (
|
||||
oldDoc: YDoc,
|
||||
createWorkspace: () => Promise<Workspace>
|
||||
) => {
|
||||
/**
|
||||
* upgrade oldDoc to v2, write to targetDoc
|
||||
*/
|
||||
export const upgradeV1ToV2 = async (oldDoc: YDoc, targetDoc: YDoc) => {
|
||||
const newDoc = migrateToSubdoc(oldDoc);
|
||||
const newWorkspace = await createWorkspace();
|
||||
applyUpdate(newWorkspace.doc, encodeStateAsUpdate(newDoc), migrationOrigin);
|
||||
applyUpdate(targetDoc, encodeStateAsUpdate(newDoc), migrationOrigin);
|
||||
newDoc.getSubdocs().forEach(subdoc => {
|
||||
newWorkspace.doc.getSubdocs().forEach(newDoc => {
|
||||
targetDoc.getSubdocs().forEach(newDoc => {
|
||||
if (subdoc.guid === newDoc.guid) {
|
||||
applyUpdate(newDoc, encodeStateAsUpdate(subdoc), migrationOrigin);
|
||||
}
|
||||
});
|
||||
});
|
||||
return newWorkspace;
|
||||
};
|
||||
|
||||
@@ -1,63 +1,50 @@
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import type { Schema } from '@blocksuite/store';
|
||||
import type { Doc as YDoc } from 'yjs';
|
||||
|
||||
import { migratePages } from './blocksuite';
|
||||
import { upgradeV1ToV2 } from './subdoc';
|
||||
|
||||
interface MigrationOptions {
|
||||
doc: YDoc;
|
||||
schema: Schema;
|
||||
createWorkspace: () => Promise<Workspace>;
|
||||
}
|
||||
|
||||
function createMigrationQueue(options: MigrationOptions) {
|
||||
return [
|
||||
async (doc: YDoc) => {
|
||||
const newWorkspace = await upgradeV1ToV2(doc, options.createWorkspace);
|
||||
return newWorkspace.doc;
|
||||
},
|
||||
async (doc: YDoc) => {
|
||||
await migratePages(doc, options.schema);
|
||||
return doc;
|
||||
},
|
||||
];
|
||||
}
|
||||
import type { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
|
||||
|
||||
/**
|
||||
* For split migrate function from MigrationQueue.
|
||||
*/
|
||||
export enum MigrationPoint {
|
||||
SubDoc = 1,
|
||||
BlockVersion = 2,
|
||||
}
|
||||
|
||||
export async function migrateWorkspace(
|
||||
point: MigrationPoint,
|
||||
options: MigrationOptions
|
||||
) {
|
||||
const migrationQueue = createMigrationQueue(options);
|
||||
const migrationFns = migrationQueue.slice(point - 1);
|
||||
|
||||
let doc = options.doc;
|
||||
for (const migrate of migrationFns) {
|
||||
doc = await migrate(doc);
|
||||
}
|
||||
return doc;
|
||||
GuidFix = 2,
|
||||
BlockVersion = 3,
|
||||
}
|
||||
|
||||
export function checkWorkspaceCompatibility(
|
||||
workspace: Workspace
|
||||
): MigrationPoint | null {
|
||||
const workspaceDocJSON = workspace.doc.toJSON();
|
||||
const spaceMetaObj = workspaceDocJSON['space:meta'];
|
||||
const docKeys = Object.keys(workspaceDocJSON);
|
||||
const haveSpaceMeta = !!spaceMetaObj && Object.keys(spaceMetaObj).length > 0;
|
||||
// check if there is any key starts with 'space:' on root doc
|
||||
const spaceMetaObj = workspace.doc.share.get('space:meta') as
|
||||
| YMap<any>
|
||||
| undefined;
|
||||
const docKeys = Array.from(workspace.doc.share.keys());
|
||||
const haveSpaceMeta = !!spaceMetaObj && spaceMetaObj.size > 0;
|
||||
const haveLegacySpace = docKeys.some(key => key.startsWith('space:'));
|
||||
if (haveSpaceMeta || haveLegacySpace) {
|
||||
return MigrationPoint.SubDoc;
|
||||
}
|
||||
|
||||
// exit if no pages
|
||||
if (!workspace.meta.pages?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// check guid compatibility
|
||||
const meta = workspace.doc.getMap('meta') as YMap<unknown>;
|
||||
const pages = meta.get('pages') as YArray<YMap<unknown>>;
|
||||
for (const page of pages) {
|
||||
const pageId = page.get('id') as string | undefined;
|
||||
if (pageId?.includes(':')) {
|
||||
return MigrationPoint.GuidFix;
|
||||
}
|
||||
}
|
||||
const spaces = workspace.doc.getMap('spaces') as YMap<YDoc>;
|
||||
for (const [pageId, _] of spaces) {
|
||||
if (pageId.includes(':')) {
|
||||
return MigrationPoint.GuidFix;
|
||||
}
|
||||
}
|
||||
|
||||
const hasVersion = workspace.meta.hasVersion;
|
||||
if (!hasVersion) {
|
||||
return MigrationPoint.BlockVersion;
|
||||
|
||||
Reference in New Issue
Block a user