refactor: workspace provider (#2218)

This commit is contained in:
Himself65 2023-05-03 18:16:22 -05:00 committed by GitHub
parent ec39c23fb7
commit 9096ac2960
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 377 additions and 255 deletions

View File

@ -0,0 +1,71 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { initPage } from '@affine/env/blocksuite';
import {
rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom';
import { createIndexedDBDownloadProvider } from '@affine/workspace/providers';
import { WorkspaceFlavour } from '@affine/workspace/type';
import {
_cleanupBlockSuiteWorkspaceCache,
createEmptyBlockSuiteWorkspace,
} from '@affine/workspace/utils';
import type { ParagraphBlockModel } from '@blocksuite/blocks/models';
import type { Page } from '@blocksuite/store';
import { createStore } from 'jotai';
import { describe, expect, test } from 'vitest';
import { WorkspacePlugins } from '../../plugins';
import { rootCurrentWorkspaceAtom } from '../root';
describe('currentWorkspace atom', () => {
test('should be defined', async () => {
const store = createStore();
let id: string;
{
const workspace = createEmptyBlockSuiteWorkspace(
'test',
WorkspaceFlavour.LOCAL
);
const page = workspace.createPage('page0');
initPage(page);
const frameId = page.getBlockByFlavour('affine:frame').at(0)
?.id as string;
id = page.addBlock(
'affine:paragraph',
{
text: new page.Text('test 1'),
},
frameId
);
const provider = createIndexedDBDownloadProvider(workspace);
provider.sync();
await provider.whenReady;
const workspaceId = await WorkspacePlugins[
WorkspaceFlavour.LOCAL
].CRUD.create(workspace);
store.set(rootWorkspacesMetadataAtom, [
{
id: workspaceId,
flavour: WorkspaceFlavour.LOCAL,
},
]);
_cleanupBlockSuiteWorkspaceCache();
}
store.set(
rootCurrentWorkspaceIdAtom,
store.get(rootWorkspacesMetadataAtom)[0].id
);
const workspace = await store.get(rootCurrentWorkspaceAtom);
expect(workspace).toBeDefined();
const page = workspace.blockSuiteWorkspace.getPage('page0') as Page;
expect(page).not.toBeNull();
const paragraphBlock = page.getBlockById(id) as ParagraphBlockModel;
expect(paragraphBlock).not.toBeNull();
expect(paragraphBlock.text.toString()).toBe('test 1');
});
});

View File

@ -5,6 +5,10 @@ import {
rootCurrentWorkspaceIdAtom, rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom, rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom'; } from '@affine/workspace/atom';
import type {
NecessaryProvider,
WorkspaceRegistry,
} from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type'; import { WorkspaceFlavour } from '@affine/workspace/type';
import { assertExists } from '@blocksuite/store'; import { assertExists } from '@blocksuite/store';
import { atom } from 'jotai'; import { atom } from 'jotai';
@ -37,18 +41,38 @@ export const workspacesAtom = atom<Promise<AllWorkspace[]>>(async get => {
WorkspacePlugins[workspace.flavour as keyof typeof WorkspacePlugins]; WorkspacePlugins[workspace.flavour as keyof typeof WorkspacePlugins];
assertExists(plugin); assertExists(plugin);
const { CRUD } = plugin; const { CRUD } = plugin;
return CRUD.get(workspace.id); return CRUD.get(workspace.id).then(workspace => {
if (workspace === null) {
console.warn(
'workspace is null. this should not happen. If you see this error, please report it to the developer.'
);
}
return workspace;
});
}) })
).then(workspaces =>
workspaces.filter(
(workspace): workspace is WorkspaceRegistry['affine' | 'local'] =>
workspace !== null
)
); );
logger.info('workspaces', workspaces); const workspaceProviders = workspaces.map(workspace =>
workspaces.forEach(workspace => { workspace.providers.filter(
if (workspace === null) { (provider): provider is NecessaryProvider =>
console.warn( 'necessary' in provider && provider.necessary
'workspace is null. this should not happen. If you see this error, please report it to the developer.' )
); );
const promises: Promise<void>[] = [];
for (const providers of workspaceProviders) {
for (const provider of providers) {
provider.sync();
promises.push(provider.whenReady);
} }
}); }
return workspaces.filter(workspace => workspace !== null) as AllWorkspace[]; // we will wait for all the necessary providers to be ready
await Promise.all(promises);
logger.info('workspaces', workspaces);
return workspaces;
}); });
/** /**
@ -77,6 +101,15 @@ export const rootCurrentWorkspaceAtom = atom<Promise<AllWorkspace>>(
`cannot find the workspace with id ${targetId} in the plugin ${targetWorkspace.flavour}.` `cannot find the workspace with id ${targetId} in the plugin ${targetWorkspace.flavour}.`
); );
} }
const providers = workspace.providers.filter(
(provider): provider is NecessaryProvider =>
'necessary' in provider && provider.necessary === true
);
for (const provider of providers) {
provider.sync();
// we will wait for the necessary providers to be ready
await provider.whenReady;
}
return workspace; return workspace;
} }
); );

View File

@ -1,30 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, test } from 'vitest';
import { BlockSuiteWorkspace } from '../../shared';
import { createAffineProviders, createLocalProviders } from '..';
let blockSuiteWorkspace: BlockSuiteWorkspace;
beforeEach(() => {
blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test' });
});
describe('blocksuite providers', () => {
test('should be valid provider', () => {
[createLocalProviders, createAffineProviders].forEach(createProviders => {
createProviders(blockSuiteWorkspace).forEach(provider => {
expect(provider).toBeTypeOf('object');
expect(provider).toHaveProperty('flavour');
expect(provider).toHaveProperty('connect');
expect(provider.connect).toBeTypeOf('function');
expect(provider).toHaveProperty('disconnect');
expect(provider.disconnect).toBeTypeOf('function');
});
});
});
});

View File

@ -1,13 +1,15 @@
import { config } from '@affine/env'; import { config } from '@affine/env';
import { import {
createIndexedDBProvider, createIndexedDBDownloadProvider,
createLocalProviders, createLocalProviders,
} from '@affine/workspace/providers'; } from '@affine/workspace/providers';
import { createBroadCastChannelProvider } from '@affine/workspace/providers'; import {
createAffineWebSocketProvider,
createBroadCastChannelProvider,
} from '@affine/workspace/providers';
import type { Provider } from '@affine/workspace/type'; import type { Provider } from '@affine/workspace/type';
import type { BlockSuiteWorkspace } from '../shared'; import type { BlockSuiteWorkspace } from '../shared';
import { createAffineWebSocketProvider } from './providers';
import { createAffineDownloadProvider } from './providers/affine'; import { createAffineDownloadProvider } from './providers/affine';
export const createAffineProviders = ( export const createAffineProviders = (
@ -19,7 +21,7 @@ export const createAffineProviders = (
createAffineWebSocketProvider(blockSuiteWorkspace), createAffineWebSocketProvider(blockSuiteWorkspace),
config.enableBroadCastChannelProvider && config.enableBroadCastChannelProvider &&
createBroadCastChannelProvider(blockSuiteWorkspace), createBroadCastChannelProvider(blockSuiteWorkspace),
createIndexedDBProvider(blockSuiteWorkspace), createIndexedDBDownloadProvider(blockSuiteWorkspace),
] as any[] ] as any[]
).filter(v => Boolean(v)); ).filter(v => Boolean(v));
}; };

View File

@ -12,9 +12,15 @@ export const createAffineDownloadProvider = (
): AffineDownloadProvider => { ): AffineDownloadProvider => {
assertExists(blockSuiteWorkspace.id); assertExists(blockSuiteWorkspace.id);
const id = blockSuiteWorkspace.id; const id = blockSuiteWorkspace.id;
let connected = false;
const callbacks = new Set<() => void>();
return { return {
flavour: 'affine-download', flavour: 'affine-download',
background: true, background: true,
get connected() {
return connected;
},
callbacks,
connect: () => { connect: () => {
providerLogger.info('connect download provider', id); providerLogger.info('connect download provider', id);
if (hashMap.has(id)) { if (hashMap.has(id)) {
@ -23,6 +29,7 @@ export const createAffineDownloadProvider = (
blockSuiteWorkspace.doc, blockSuiteWorkspace.doc,
new Uint8Array(hashMap.get(id) as ArrayBuffer) new Uint8Array(hashMap.get(id) as ArrayBuffer)
); );
connected = true;
return; return;
} }
affineApis affineApis
@ -41,6 +48,7 @@ export const createAffineDownloadProvider = (
}, },
disconnect: () => { disconnect: () => {
providerLogger.info('disconnect download provider', id); providerLogger.info('disconnect download provider', id);
connected = false;
}, },
cleanup: () => { cleanup: () => {
hashMap.delete(id); hashMap.delete(id);

View File

@ -1,48 +0,0 @@
import { websocketPrefixUrl } from '@affine/env';
import { KeckProvider } from '@affine/workspace/affine/keck';
import { getLoginStorage } from '@affine/workspace/affine/login';
import type { AffineWebSocketProvider } from '@affine/workspace/type';
import { assertExists } from '@blocksuite/store';
import type { BlockSuiteWorkspace } from '../../shared';
import { providerLogger } from '../logger';
const createAffineWebSocketProvider = (
blockSuiteWorkspace: BlockSuiteWorkspace
): AffineWebSocketProvider => {
let webSocketProvider: KeckProvider | null = null;
return {
flavour: 'affine-websocket',
background: false,
cleanup: () => {
assertExists(webSocketProvider);
webSocketProvider.destroy();
webSocketProvider = null;
},
connect: () => {
webSocketProvider = new KeckProvider(
websocketPrefixUrl + '/api/sync/',
blockSuiteWorkspace.id,
blockSuiteWorkspace.doc,
{
params: { token: getLoginStorage()?.token ?? '' },
awareness: blockSuiteWorkspace.awarenessStore.awareness,
// we maintain broadcast channel by ourselves
// @ts-expect-error
disableBc: true,
connect: false,
}
);
providerLogger.info('connect', webSocketProvider.url);
webSocketProvider.connect();
},
disconnect: () => {
assertExists(webSocketProvider);
providerLogger.info('disconnect', webSocketProvider.url);
webSocketProvider.destroy();
webSocketProvider = null;
},
};
};
export { createAffineWebSocketProvider };

View File

@ -22,6 +22,7 @@ export function useRouterWithWorkspaceIdDefense(router: NextRouter) {
} }
const exist = metadata.find(m => m.id === currentWorkspaceId); const exist = metadata.find(m => m.id === currentWorkspaceId);
if (!exist) { if (!exist) {
console.warn('workspace not exist, redirect to first one');
// clean up // clean up
setCurrentWorkspaceId(null); setCurrentWorkspaceId(null);
setCurrentPageId(null); setCurrentPageId(null);

View File

@ -45,8 +45,9 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
window.apis?.onWorkspaceChange(targetWorkspace.id); window.apis?.onWorkspaceChange(targetWorkspace.id);
} }
void router.push({ void router.push({
pathname: '/workspace/[workspaceId]/all', pathname: router.pathname,
query: { query: {
...router.query,
workspaceId: targetWorkspace.id, workspaceId: targetWorkspace.id,
}, },
}); });
@ -56,8 +57,9 @@ export function useSyncRouterWithCurrentWorkspaceId(router: NextRouter) {
console.log('set workspace id', workspaceId); console.log('set workspace id', workspaceId);
setCurrentWorkspaceId(targetWorkspace.id); setCurrentWorkspaceId(targetWorkspace.id);
void router.push({ void router.push({
pathname: '/workspace/[workspaceId]/all', pathname: router.pathname,
query: { query: {
...router.query,
workspaceId: targetWorkspace.id, workspaceId: targetWorkspace.id,
}, },
}); });

View File

@ -1,6 +1,6 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { DEFAULT_HELLO_WORLD_PAGE_ID } from '@affine/env'; import { DEFAULT_HELLO_WORLD_PAGE_ID } from '@affine/env';
import { ensureRootPinboard, initPage } from '@affine/env/blocksuite'; import { initPage } from '@affine/env/blocksuite';
import { setUpLanguage, useTranslation } from '@affine/i18n'; import { setUpLanguage, useTranslation } from '@affine/i18n';
import { createAffineGlobalChannel } from '@affine/workspace/affine/sync'; import { createAffineGlobalChannel } from '@affine/workspace/affine/sync';
import { import {
@ -9,7 +9,7 @@ import {
rootStore, rootStore,
rootWorkspacesMetadataAtom, rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom'; } from '@affine/workspace/atom';
import type { LocalIndexedDBProvider } from '@affine/workspace/type'; import type { BackgroundProvider } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type'; import { WorkspaceFlavour } from '@affine/workspace/type';
import { assertEquals, assertExists, nanoid } from '@blocksuite/store'; import { assertEquals, assertExists, nanoid } from '@blocksuite/store';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper'; import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
@ -17,14 +17,7 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import type { FC, PropsWithChildren, ReactElement } from 'react'; import type { FC, PropsWithChildren, ReactElement } from 'react';
import { import { lazy, Suspense, useCallback, useEffect, useMemo } from 'react';
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { openQuickSearchModalAtom, openWorkspacesModalAtom } from '../atoms'; import { openQuickSearchModalAtom, openWorkspacesModalAtom } from '../atoms';
import { import {
@ -127,7 +120,10 @@ export const AllWorkspaceContext = ({
// ignore current workspace // ignore current workspace
.filter(workspace => workspace.id !== currentWorkspaceId) .filter(workspace => workspace.id !== currentWorkspaceId)
.flatMap(workspace => .flatMap(workspace =>
workspace.providers.filter(provider => provider.background) workspace.providers.filter(
(provider): provider is BackgroundProvider =>
'background' in provider && provider.background
)
); );
providers.forEach(provider => { providers.forEach(provider => {
provider.connect(); provider.connect();
@ -260,69 +256,48 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
const currentPageId = useAtomValue(rootCurrentPageIdAtom); const currentPageId = useAtomValue(rootCurrentPageIdAtom);
const router = useRouter(); const router = useRouter();
const { jumpToPage } = useRouterHelper(router); const { jumpToPage } = useRouterHelper(router);
const [isLoading, setIsLoading] = useState(true);
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
logger.info('currentWorkspace: ', currentWorkspace); logger.info('currentWorkspace: ', currentWorkspace);
globalThis.currentWorkspace = currentWorkspace;
}, [currentWorkspace]); }, [currentWorkspace]);
useEffect(() => { //#region init workspace
if (currentWorkspace) { if (currentWorkspace.blockSuiteWorkspace.isEmpty) {
globalThis.currentWorkspace = currentWorkspace; // this is a new workspace, so we should redirect to the new page
const pageId = nanoid();
const page = currentWorkspace.blockSuiteWorkspace.createPage(pageId);
assertEquals(page.id, pageId);
currentWorkspace.blockSuiteWorkspace.setPageMeta(page.id, {
init: true,
});
initPage(page);
if (!router.query.pageId) {
setCurrentPageId(pageId);
void jumpToPage(currentWorkspace.id, pageId);
} }
}, [currentWorkspace]); }
// fixme: pinboard has been removed,
// the related code should be removed in the future.
// no matter the workspace is empty, ensure the root pinboard exists
// ensureRootPinboard(currentWorkspace.blockSuiteWorkspace);
//#endregion
useEffect(() => { useEffect(() => {
if (currentWorkspace) { const backgroundProviders = currentWorkspace.providers.filter(
currentWorkspace.providers.forEach(provider => { (provider): provider is BackgroundProvider => 'background' in provider
provider.connect();
});
return () => {
currentWorkspace.providers.forEach(provider => {
provider.disconnect();
});
};
}
}, [currentWorkspace]);
useEffect(() => {
if (!router.isReady) {
return;
}
if (!currentWorkspace) {
return;
}
const localProvider = currentWorkspace.providers.find(
provider => provider.flavour === 'local-indexeddb'
); );
if (localProvider && localProvider.flavour === 'local-indexeddb') { backgroundProviders.forEach(provider => {
const provider = localProvider as LocalIndexedDBProvider; provider.connect();
const callback = () => { });
setIsLoading(false); return () => {
if (currentWorkspace.blockSuiteWorkspace.isEmpty) { backgroundProviders.forEach(provider => {
// this is a new workspace, so we should redirect to the new page provider.disconnect();
const pageId = nanoid(); });
const page = currentWorkspace.blockSuiteWorkspace.createPage(pageId); };
assertEquals(page.id, pageId); }, [currentWorkspace]);
currentWorkspace.blockSuiteWorkspace.setPageMeta(page.id, {
init: true,
});
initPage(page);
if (!router.query.pageId) {
setCurrentPageId(pageId);
void jumpToPage(currentWorkspace.id, pageId);
}
}
// no matter the workspace is empty, ensure the root pinboard exists
ensureRootPinboard(currentWorkspace.blockSuiteWorkspace);
};
provider.callbacks.add(callback);
return () => {
provider.callbacks.delete(callback);
};
}
}, [currentWorkspace, jumpToPage, router, setCurrentPageId]);
useEffect(() => { useEffect(() => {
if (!currentWorkspace) { if (!currentWorkspace) {
@ -395,11 +370,7 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
<MainContainerWrapper> <MainContainerWrapper>
<MainContainer className="main-container"> <MainContainer className="main-container">
<Suspense fallback={<PageLoading text={t('Page is Loading')} />}> <Suspense fallback={<PageLoading text={t('Page is Loading')} />}>
{isLoading ? ( {children}
<PageLoading text={t('Page is Loading')} />
) : (
children
)}
</Suspense> </Suspense>
<StyledToolWrapper> <StyledToolWrapper>
{/* fixme(himself65): remove this */} {/* fixme(himself65): remove this */}

View File

@ -3,12 +3,12 @@ import {
DEFAULT_HELLO_WORLD_PAGE_ID, DEFAULT_HELLO_WORLD_PAGE_ID,
DEFAULT_WORKSPACE_NAME, DEFAULT_WORKSPACE_NAME,
} from '@affine/env'; } from '@affine/env';
import { ensureRootPinboard, initPage } from '@affine/env/blocksuite'; import { initPage } from '@affine/env/blocksuite';
import { import {
CRUD, CRUD,
saveWorkspaceToLocalStorage, saveWorkspaceToLocalStorage,
} from '@affine/workspace/local/crud'; } from '@affine/workspace/local/crud';
import { createIndexedDBProvider } from '@affine/workspace/providers'; import { createIndexedDBBackgroundProvider } from '@affine/workspace/providers';
import { LoadPriority, WorkspaceFlavour } from '@affine/workspace/type'; import { LoadPriority, WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils'; import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import { nanoid } from '@blocksuite/store'; import { nanoid } from '@blocksuite/store';
@ -40,12 +40,11 @@ export const LocalPlugin: WorkspacePlugin<WorkspaceFlavour.LOCAL> = {
blockSuiteWorkspace.setPageMeta(page.id, { blockSuiteWorkspace.setPageMeta(page.id, {
jumpOnce: true, jumpOnce: true,
}); });
const provider = createIndexedDBProvider(blockSuiteWorkspace); const provider = createIndexedDBBackgroundProvider(blockSuiteWorkspace);
provider.connect(); provider.connect();
provider.callbacks.add(() => { provider.callbacks.add(() => {
provider.disconnect(); provider.disconnect();
}); });
ensureRootPinboard(blockSuiteWorkspace);
saveWorkspaceToLocalStorage(blockSuiteWorkspace.id); saveWorkspaceToLocalStorage(blockSuiteWorkspace.id);
logger.debug('create first workspace'); logger.debug('create first workspace');
return [blockSuiteWorkspace.id]; return [blockSuiteWorkspace.id];

View File

@ -17,7 +17,7 @@ export type RootWorkspaceMetadata = {
/** /**
* root workspaces atom * root workspaces atom
* this atom stores the metadata of all workspaces, * this atom stores the metadata of all workspaces,
* which is `id` and `flavour`, that is enough to load the real workspace data * which is `id` and `flavor`, that is enough to load the real workspace data
*/ */
export const rootWorkspacesMetadataAtom = atomWithSyncStorage< export const rootWorkspacesMetadataAtom = atomWithSyncStorage<
RootWorkspaceMetadata[] RootWorkspaceMetadata[]

View File

@ -1,3 +1,4 @@
import { CallbackSet } from '@affine/workspace/utils';
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store'; import { assertExists } from '@blocksuite/store';
import type { Awareness } from 'y-protocols/awareness'; import type { Awareness } from 'y-protocols/awareness';
@ -23,6 +24,7 @@ export const createBroadCastChannelProvider = (
const awareness = blockSuiteWorkspace.awarenessStore const awareness = blockSuiteWorkspace.awarenessStore
.awareness as unknown as Awareness; .awareness as unknown as Awareness;
let broadcastChannel: TypedBroadcastChannel | null = null; let broadcastChannel: TypedBroadcastChannel | null = null;
const callbacks = new CallbackSet();
const handleBroadcastChannelMessage = ( const handleBroadcastChannelMessage = (
event: BroadcastChannelMessageEvent event: BroadcastChannelMessageEvent
) => { ) => {
@ -56,6 +58,9 @@ export const createBroadCastChannelProvider = (
break; break;
} }
} }
if (callbacks.ready) {
callbacks.forEach(cb => cb());
}
}; };
const handleDocUpdate = (updateV1: Uint8Array, origin: any) => { const handleDocUpdate = (updateV1: Uint8Array, origin: any) => {
if (origin === broadcastChannel) { if (origin === broadcastChannel) {
@ -77,7 +82,11 @@ export const createBroadCastChannelProvider = (
}; };
return { return {
flavour: 'broadcast-channel', flavour: 'broadcast-channel',
background: false, background: true,
get connected() {
return callbacks.ready;
},
callbacks,
connect: () => { connect: () => {
assertExists(blockSuiteWorkspace.id); assertExists(blockSuiteWorkspace.id);
broadcastChannel = Object.assign( broadcastChannel = Object.assign(
@ -101,6 +110,7 @@ export const createBroadCastChannelProvider = (
broadcastChannel.postMessage(['awareness:update', awarenessUpdate]); broadcastChannel.postMessage(['awareness:update', awarenessUpdate]);
doc.on('update', handleDocUpdate); doc.on('update', handleDocUpdate);
awareness.on('update', handleAwarenessUpdate); awareness.on('update', handleAwarenessUpdate);
callbacks.ready = true;
}, },
disconnect: () => { disconnect: () => {
assertExists(broadcastChannel); assertExists(broadcastChannel);
@ -111,6 +121,7 @@ export const createBroadCastChannelProvider = (
doc.off('update', handleDocUpdate); doc.off('update', handleDocUpdate);
awareness.off('update', handleAwarenessUpdate); awareness.off('update', handleAwarenessUpdate);
broadcastChannel.close(); broadcastChannel.close();
callbacks.ready = false;
}, },
cleanup: () => { cleanup: () => {
assertExists(broadcastChannel); assertExists(broadcastChannel);

View File

@ -4,16 +4,22 @@ import {
getLoginStorage, getLoginStorage,
storageChangeSlot, storageChangeSlot,
} from '@affine/workspace/affine/login'; } from '@affine/workspace/affine/login';
import type { Provider, SQLiteProvider } from '@affine/workspace/type';
import type { import type {
AffineWebSocketProvider, AffineWebSocketProvider,
LocalIndexedDBProvider, LocalIndexedDBBackgroundProvider,
LocalIndexedDBDownloadProvider,
Provider,
SQLiteProvider,
} from '@affine/workspace/type'; } from '@affine/workspace/type';
import { CallbackSet } from '@affine/workspace/utils';
import type { BlobManager, Disposable } from '@blocksuite/store'; import type { BlobManager, Disposable } from '@blocksuite/store';
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; import {
import { assertExists } from '@blocksuite/store'; assertExists,
Workspace as BlockSuiteWorkspace,
} from '@blocksuite/store';
import { import {
createIndexedDBProvider as create, createIndexedDBProvider as create,
downloadBinary,
EarlyDisconnectError, EarlyDisconnectError,
} from '@toeverything/y-indexeddb'; } from '@toeverything/y-indexeddb';
@ -27,9 +33,15 @@ const createAffineWebSocketProvider = (
): AffineWebSocketProvider => { ): AffineWebSocketProvider => {
let webSocketProvider: KeckProvider | null = null; let webSocketProvider: KeckProvider | null = null;
let dispose: Disposable | undefined = undefined; let dispose: Disposable | undefined = undefined;
const callbacks = new CallbackSet();
const cb = () => callbacks.forEach(cb => cb());
const apis: AffineWebSocketProvider = { const apis: AffineWebSocketProvider = {
flavour: 'affine-websocket', flavour: 'affine-websocket',
background: false, background: true,
get connected() {
return callbacks.ready;
},
callbacks,
cleanup: () => { cleanup: () => {
assertExists(webSocketProvider); assertExists(webSocketProvider);
webSocketProvider.destroy(); webSocketProvider.destroy();
@ -48,20 +60,19 @@ const createAffineWebSocketProvider = (
{ {
params: { token: getLoginStorage()?.token ?? '' }, params: { token: getLoginStorage()?.token ?? '' },
awareness: blockSuiteWorkspace.awarenessStore.awareness, awareness: blockSuiteWorkspace.awarenessStore.awareness,
// we maintain broadcast channel by ourselves // we maintain a broadcast channel by ourselves
// @ts-expect-error
disableBc: true,
connect: false, connect: false,
} }
); );
logger.info('connect', webSocketProvider.url); logger.info('connect', webSocketProvider.url);
webSocketProvider.on('synced', cb);
webSocketProvider.connect(); webSocketProvider.connect();
}, },
disconnect: () => { disconnect: () => {
assertExists(webSocketProvider); assertExists(webSocketProvider);
logger.info('disconnect', webSocketProvider.url); logger.info('disconnect', webSocketProvider.url);
webSocketProvider.destroy(); webSocketProvider.disconnect();
webSocketProvider = null; webSocketProvider.off('synced', cb);
dispose?.dispose(); dispose?.dispose();
}, },
}; };
@ -69,52 +80,21 @@ const createAffineWebSocketProvider = (
return apis; return apis;
}; };
class CallbackSet extends Set<() => void> { const createIndexedDBBackgroundProvider = (
#ready = false;
get ready(): boolean {
return this.#ready;
}
set ready(v: boolean) {
this.#ready = v;
}
add(cb: () => void) {
if (this.ready) {
cb();
return this;
}
if (this.has(cb)) {
return this;
}
return super.add(cb);
}
delete(cb: () => void) {
if (this.has(cb)) {
return super.delete(cb);
}
return false;
}
}
const createIndexedDBProvider = (
blockSuiteWorkspace: BlockSuiteWorkspace blockSuiteWorkspace: BlockSuiteWorkspace
): LocalIndexedDBProvider => { ): LocalIndexedDBBackgroundProvider => {
const indexeddbProvider = create( const indexeddbProvider = create(
blockSuiteWorkspace.id, blockSuiteWorkspace.id,
blockSuiteWorkspace.doc blockSuiteWorkspace.doc
); );
const callbacks = new CallbackSet(); const callbacks = new CallbackSet();
return { return {
flavour: 'local-indexeddb', flavour: 'local-indexeddb-background',
// fixme: remove callbacks
callbacks,
// fixme: remove whenSynced
whenSynced: indexeddbProvider.whenSynced,
// fixme: remove background long polling
background: true, background: true,
get connected() {
return callbacks.ready;
},
callbacks,
cleanup: () => { cleanup: () => {
// todo: cleanup data // todo: cleanup data
}, },
@ -127,6 +107,7 @@ const createIndexedDBProvider = (
callbacks.forEach(cb => cb()); callbacks.forEach(cb => cb());
}) })
.catch(error => { .catch(error => {
callbacks.ready = false;
if (error instanceof EarlyDisconnectError) { if (error instanceof EarlyDisconnectError) {
return; return;
} else { } else {
@ -143,6 +124,40 @@ const createIndexedDBProvider = (
}; };
}; };
const createIndexedDBDownloadProvider = (
blockSuiteWorkspace: BlockSuiteWorkspace
): LocalIndexedDBDownloadProvider => {
let _resolve: () => void;
let _reject: (error: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
_resolve = resolve;
_reject = reject;
});
return {
flavour: 'local-indexeddb',
necessary: true,
get whenReady() {
return promise;
},
cleanup: () => {
// todo: cleanup data
},
sync: () => {
logger.info('connect indexeddb provider', blockSuiteWorkspace.id);
downloadBinary(blockSuiteWorkspace.id)
.then(binary => {
if (binary !== false) {
Y.applyUpdate(blockSuiteWorkspace.doc, binary);
}
_resolve();
})
.catch(error => {
_reject(error);
});
},
};
};
const createSQLiteProvider = ( const createSQLiteProvider = (
blockSuiteWorkspace: BlockSuiteWorkspace blockSuiteWorkspace: BlockSuiteWorkspace
): SQLiteProvider => { ): SQLiteProvider => {
@ -166,18 +181,20 @@ const createSQLiteProvider = (
const keysToPersist = allKeys.filter(k => !persistedKeys.includes(k)); const keysToPersist = allKeys.filter(k => !persistedKeys.includes(k));
logger.info('persisting blobs', keysToPersist, 'to sqlite'); logger.info('persisting blobs', keysToPersist, 'to sqlite');
keysToPersist.forEach(async k => { return Promise.all(
const blob = await bs.get(k); keysToPersist.map(async k => {
if (!blob) { const blob = await bs.get(k);
logger.warn('blob not found for', k); if (!blob) {
return; logger.warn('blob not found for', k);
} return;
window.apis.db.addBlob( }
blockSuiteWorkspace.id, return window.apis.db.addBlob(
k, blockSuiteWorkspace.id,
new Uint8Array(await blob.arrayBuffer()) k,
); new Uint8Array(await blob.arrayBuffer())
}); );
})
);
} }
async function syncUpdates() { async function syncUpdates() {
@ -202,16 +219,23 @@ const createSQLiteProvider = (
} }
let unsubscribe = () => {}; let unsubscribe = () => {};
let connected = false;
const callbacks = new CallbackSet();
const provider = { return {
flavour: 'sqlite', flavour: 'sqlite',
background: true, background: true,
callbacks,
get connected(): boolean {
return connected;
},
cleanup: () => { cleanup: () => {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
}, },
connect: async () => { connect: async () => {
logger.info('connecting sqlite provider', blockSuiteWorkspace.id); logger.info('connecting sqlite provider', blockSuiteWorkspace.id);
await syncUpdates(); await syncUpdates();
connected = true;
blockSuiteWorkspace.doc.on('update', handleUpdate); blockSuiteWorkspace.doc.on('update', handleUpdate);
@ -223,6 +247,7 @@ const createSQLiteProvider = (
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
} }
// @ts-expect-error ignore the type // @ts-expect-error ignore the type
timer = setTimeout(() => { timer = setTimeout(() => {
syncUpdates(); syncUpdates();
@ -237,16 +262,16 @@ const createSQLiteProvider = (
disconnect: () => { disconnect: () => {
unsubscribe(); unsubscribe();
blockSuiteWorkspace.doc.off('update', handleUpdate); blockSuiteWorkspace.doc.off('update', handleUpdate);
connected = false;
}, },
} satisfies SQLiteProvider; };
return provider;
}; };
export { export {
createAffineWebSocketProvider, createAffineWebSocketProvider,
createBroadCastChannelProvider, createBroadCastChannelProvider,
createIndexedDBProvider, createIndexedDBBackgroundProvider,
createIndexedDBDownloadProvider,
createSQLiteProvider, createSQLiteProvider,
}; };
@ -257,7 +282,8 @@ export const createLocalProviders = (
[ [
config.enableBroadCastChannelProvider && config.enableBroadCastChannelProvider &&
createBroadCastChannelProvider(blockSuiteWorkspace), createBroadCastChannelProvider(blockSuiteWorkspace),
createIndexedDBProvider(blockSuiteWorkspace), createIndexedDBBackgroundProvider(blockSuiteWorkspace),
createIndexedDBDownloadProvider(blockSuiteWorkspace),
environment.isDesktop && createSQLiteProvider(blockSuiteWorkspace), environment.isDesktop && createSQLiteProvider(blockSuiteWorkspace),
] as any[] ] as any[]
).filter(v => Boolean(v)); ).filter(v => Boolean(v));

View File

@ -9,44 +9,76 @@ export type JotaiStore = ReturnType<typeof createStore>;
export type BaseProvider = { export type BaseProvider = {
flavour: string; flavour: string;
// if this is true, we will connect the provider on the background
background: boolean;
connect: () => void;
disconnect: () => void;
// cleanup data when workspace is removed // cleanup data when workspace is removed
cleanup: () => void; cleanup: () => void;
}; };
/**
* @description
* If a provider is marked as a background provider,
* we will connect it in the `useEffect` in React.js.
*
* This means that the data might be stale when you use it.
*/
export interface BackgroundProvider extends BaseProvider { export interface BackgroundProvider extends BaseProvider {
// if this is true,
// we will connect the provider on the background
background: true; background: true;
get connected(): boolean;
connect(): void;
disconnect(): void;
callbacks: Set<() => void>; callbacks: Set<() => void>;
} }
export interface AffineDownloadProvider extends BaseProvider { /**
* @description
* If a provider is marked as a necessary provider,
* we will connect it once you read the workspace.
*
* This means that the data will be fresh when you use it.
*
* Currently, there is only on necessary provider: `local-indexeddb`.
*/
export interface NecessaryProvider extends Omit<BaseProvider, 'disconnect'> {
// if this is true,
// we will ensure that the provider is connected before you can use it
necessary: true;
sync(): void;
get whenReady(): Promise<void>;
}
export interface AffineDownloadProvider extends BackgroundProvider {
flavour: 'affine-download'; flavour: 'affine-download';
} }
export interface BroadCastChannelProvider extends BaseProvider { /**
* Download the first binary from local indexeddb
*/
export interface BroadCastChannelProvider extends BackgroundProvider {
flavour: 'broadcast-channel'; flavour: 'broadcast-channel';
} }
export interface LocalIndexedDBProvider extends BackgroundProvider { /**
flavour: 'local-indexeddb'; * Long polling provider with local indexeddb
whenSynced: Promise<void>; */
export interface LocalIndexedDBBackgroundProvider extends BackgroundProvider {
flavour: 'local-indexeddb-background';
} }
export interface SQLiteProvider extends BaseProvider { export interface SQLiteProvider extends BackgroundProvider {
flavour: 'sqlite'; flavour: 'sqlite';
} }
export interface AffineWebSocketProvider extends BaseProvider { export interface LocalIndexedDBDownloadProvider extends NecessaryProvider {
flavour: 'local-indexeddb';
}
export interface AffineWebSocketProvider extends BackgroundProvider {
flavour: 'affine-websocket'; flavour: 'affine-websocket';
} }
export type Provider = export type Provider = BackgroundProvider | NecessaryProvider;
| LocalIndexedDBProvider
| AffineWebSocketProvider
| BroadCastChannelProvider;
export interface AffineWorkspace extends RemoteWorkspace { export interface AffineWorkspace extends RemoteWorkspace {
flavour: WorkspaceFlavour.AFFINE; flavour: WorkspaceFlavour.AFFINE;

View File

@ -16,6 +16,11 @@ export function cleanupWorkspace(flavour: WorkspaceFlavour) {
const hashMap = new Map<string, Workspace>(); const hashMap = new Map<string, Workspace>();
/**
* @internal test only
*/
export const _cleanupBlockSuiteWorkspaceCache = () => hashMap.clear();
export function createEmptyBlockSuiteWorkspace( export function createEmptyBlockSuiteWorkspace(
id: string, id: string,
flavour: WorkspaceFlavour.AFFINE, flavour: WorkspaceFlavour.AFFINE,
@ -83,3 +88,33 @@ export function createEmptyBlockSuiteWorkspace(
hashMap.set(cacheKey, workspace); hashMap.set(cacheKey, workspace);
return workspace; return workspace;
} }
export class CallbackSet extends Set<() => void> {
#ready = false;
get ready(): boolean {
return this.#ready;
}
set ready(v: boolean) {
this.#ready = v;
}
add(cb: () => void) {
if (this.ready) {
cb();
return this;
}
if (this.has(cb)) {
return this;
}
return super.add(cb);
}
delete(cb: () => void) {
if (this.has(cb)) {
return super.delete(cb);
}
return false;
}
}

View File

@ -1,18 +1,24 @@
# @toeverything/y-indexeddb # @toeverything/y-indexeddb
> This package haven't been published yet.
## Usage ## Usage
```ts ```ts
import { createIndexedDBProvider } from '@toeverything/y-indexeddb'; import { createIndexedDBProvider, downloadBinary } from '@toeverything/y-indexeddb';
import * as Y from 'yjs'; import * as Y from 'yjs';
const yDoc = new Y.Doc(); const yDoc = new Y.Doc();
// sync yDoc with indexedDB
const provider = createIndexedDBProvider('docName', yDoc); const provider = createIndexedDBProvider('docName', yDoc);
provider.connect(); provider.connect();
await provider.whenSynced.then(() => { await provider.whenSynced.then(() => {
console.log('synced'); console.log('synced');
provider.disconnect(); provider.disconnect();
}); });
// dowload binary data from indexedDB for once
downloadBinary('docName').then(blob => {
if (blob !== false) {
Y.applyUpdate(yDoc, blob);
}
});
``` ```

View File

@ -340,7 +340,10 @@ describe('utils', () => {
provider.connect(); provider.connect();
await provider.whenSynced; await provider.whenSynced;
provider.disconnect(); provider.disconnect();
const update = await downloadBinary(workspace.id, rootDBName); const update = (await downloadBinary(
workspace.id,
rootDBName
)) as Uint8Array;
expect(update).toBeInstanceOf(Uint8Array); expect(update).toBeInstanceOf(Uint8Array);
const newWorkspace = new Workspace({ const newWorkspace = new Workspace({
id, id,

View File

@ -129,7 +129,7 @@ export async function tryMigrate(
export async function downloadBinary( export async function downloadBinary(
id: string, id: string,
dbName = DEFAULT_DB_NAME dbName = DEFAULT_DB_NAME
): Promise<UpdateMessage['update']> { ): Promise<UpdateMessage['update'] | false> {
const dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, { const dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, {
upgrade: upgradeDB, upgrade: upgradeDB,
}); });
@ -137,7 +137,7 @@ export async function downloadBinary(
const t = db.transaction('workspace', 'readonly').objectStore('workspace'); const t = db.transaction('workspace', 'readonly').objectStore('workspace');
const doc = await t.get(id); const doc = await t.get(id);
if (!doc) { if (!doc) {
return new Uint8Array(0); return false;
} else { } else {
return mergeUpdates(doc.updates.map(({ update }) => update)); return mergeUpdates(doc.updates.map(({ update }) => update));
} }