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 => {
})
);
logger.info('workspaces', workspaces);
workspaces.forEach(workspace => {
if (workspace === null) { if (workspace === null) {
console.warn( console.warn(
'workspace is null. this should not happen. If you see this error, please report it to the developer.' 'workspace is null. this should not happen. If you see this error, please report it to the developer.'
); );
} }
return workspace;
}); });
return workspaces.filter(workspace => workspace !== null) as AllWorkspace[]; })
).then(workspaces =>
workspaces.filter(
(workspace): workspace is WorkspaceRegistry['affine' | 'local'] =>
workspace !== null
)
);
const workspaceProviders = workspaces.map(workspace =>
workspace.providers.filter(
(provider): provider is NecessaryProvider =>
'necessary' in provider && provider.necessary
)
);
const promises: Promise<void>[] = [];
for (const providers of workspaceProviders) {
for (const provider of providers) {
provider.sync();
promises.push(provider.whenReady);
}
}
// 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,46 +256,14 @@ 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);
}, [currentWorkspace]);
useEffect(() => {
if (currentWorkspace) {
globalThis.currentWorkspace = currentWorkspace; globalThis.currentWorkspace = currentWorkspace;
}
}, [currentWorkspace]); }, [currentWorkspace]);
useEffect(() => { //#region init workspace
if (currentWorkspace) {
currentWorkspace.providers.forEach(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') {
const provider = localProvider as LocalIndexedDBProvider;
const callback = () => {
setIsLoading(false);
if (currentWorkspace.blockSuiteWorkspace.isEmpty) { if (currentWorkspace.blockSuiteWorkspace.isEmpty) {
// this is a new workspace, so we should redirect to the new page // this is a new workspace, so we should redirect to the new page
const pageId = nanoid(); const pageId = nanoid();
@ -314,15 +278,26 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
void jumpToPage(currentWorkspace.id, pageId); void jumpToPage(currentWorkspace.id, pageId);
} }
} }
// 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 // no matter the workspace is empty, ensure the root pinboard exists
ensureRootPinboard(currentWorkspace.blockSuiteWorkspace); // ensureRootPinboard(currentWorkspace.blockSuiteWorkspace);
}; //#endregion
provider.callbacks.add(callback);
useEffect(() => {
const backgroundProviders = currentWorkspace.providers.filter(
(provider): provider is BackgroundProvider => 'background' in provider
);
backgroundProviders.forEach(provider => {
provider.connect();
});
return () => { return () => {
provider.callbacks.delete(callback); backgroundProviders.forEach(provider => {
provider.disconnect();
});
}; };
} }, [currentWorkspace]);
}, [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(
keysToPersist.map(async k => {
const blob = await bs.get(k); const blob = await bs.get(k);
if (!blob) { if (!blob) {
logger.warn('blob not found for', k); logger.warn('blob not found for', k);
return; return;
} }
window.apis.db.addBlob( return window.apis.db.addBlob(
blockSuiteWorkspace.id, blockSuiteWorkspace.id,
k, k,
new Uint8Array(await blob.arrayBuffer()) 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));
} }