feat(core): new worker workspace engine (#9257)

This commit is contained in:
EYHN
2025-01-17 00:22:18 +08:00
committed by GitHub
parent 7dc470e7ea
commit a2ffdb4047
219 changed files with 4267 additions and 7194 deletions

View File

@@ -1,4 +1,5 @@
// ORDER MATTERS
import './env';
import './public-path';
import './shared-worker';
import './polyfill/browser';

View File

@@ -1,4 +1,5 @@
// ORDER MATTERS
import './env';
import './public-path';
import './shared-worker';
import './polyfill/electron';

View File

@@ -1,11 +1,9 @@
import { polyfillDispose } from './dispose';
import { polyfillIteratorHelpers } from './iterator-helpers';
import { polyfillPromise } from './promise-with-resolvers';
import './dispose';
import './iterator-helpers';
import './promise-with-resolvers';
import { polyfillEventLoop } from './request-idle-callback';
import { polyfillResizeObserver } from './resize-observer';
polyfillResizeObserver();
polyfillEventLoop();
await polyfillPromise();
await polyfillDispose();
await polyfillIteratorHelpers();

View File

@@ -1,8 +1,2 @@
export async function polyfillDispose() {
if (typeof Symbol.dispose !== 'symbol') {
// @ts-expect-error ignore
await import('core-js/modules/esnext.symbol.async-dispose');
// @ts-expect-error ignore
await import('core-js/modules/esnext.symbol.dispose');
}
}
import 'core-js/modules/esnext.symbol.async-dispose';
import 'core-js/modules/esnext.symbol.dispose';

View File

@@ -1,7 +1 @@
export async function polyfillIteratorHelpers() {
if (typeof globalThis['Iterator'] !== 'function') {
// @ts-expect-error ignore
// https://github.com/zloirock/core-js/blob/master/packages/core-js/proposals/iterator-helpers-stage-3.js
await import('core-js/proposals/iterator-helpers-stage-3');
}
}
import 'core-js/proposals/iterator-helpers-stage-3';

View File

@@ -1,6 +1 @@
export async function polyfillPromise() {
if (typeof Promise.withResolvers !== 'function') {
// @ts-expect-error ignore
await import('core-js/features/promise/with-resolvers');
}
}
import 'core-js/features/promise/with-resolvers';

View File

@@ -1,6 +1,6 @@
export function polyfillEventLoop() {
window.requestIdleCallback =
window.requestIdleCallback ||
globalThis.requestIdleCallback =
globalThis.requestIdleCallback ||
function (cb) {
const start = Date.now();
return setTimeout(function () {
@@ -13,8 +13,8 @@ export function polyfillEventLoop() {
}, 1);
};
window.cancelIdleCallback =
window.cancelIdleCallback ||
globalThis.cancelIdleCallback =
globalThis.cancelIdleCallback ||
function (id) {
clearTimeout(id);
};

View File

@@ -1,5 +1,7 @@
import { ResizeObserver } from '@juggle/resize-observer';
export function polyfillResizeObserver() {
window.ResizeObserver = ResizeObserver;
if (typeof window !== 'undefined') {
window.ResizeObserver = ResizeObserver;
}
}

View File

@@ -0,0 +1,25 @@
/**
* This is a wrapper for SharedWorker,
* added the `name` parameter to the `SharedWorker` URL so that
* multiple `SharedWorkers` can share one script file.
*/
const rawSharedWorker = globalThis.SharedWorker;
// TODO(@eyhn): remove this when we can use single shared worker for all workspaces
function PatchedSharedWorker(
urlParam: URL | string,
options?: string | { name: string }
) {
const url = typeof urlParam === 'string' ? new URL(urlParam) : urlParam;
if (options) {
url.searchParams.append(
typeof options === 'string' ? options : options.name,
''
);
}
return new rawSharedWorker(url, options);
}
// if SharedWorker is not supported, do nothing
if (rawSharedWorker) {
globalThis.SharedWorker = PatchedSharedWorker as any;
}

View File

@@ -1,7 +1,12 @@
import { useDocMetaHelper } from '@affine/core/components/hooks/use-block-suite-page-meta';
import { useDocCollectionPage } from '@affine/core/components/hooks/use-block-suite-workspace-page';
import { FetchService, GraphQLService } from '@affine/core/modules/cloud';
import { getAFFiNEWorkspaceSchema } from '@affine/core/modules/workspace';
import {
getAFFiNEWorkspaceSchema,
type WorkspaceFlavourProvider,
WorkspaceService,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { WorkspaceImpl } from '@affine/core/modules/workspace/impls/workspace';
import { DebugLogger } from '@affine/debug';
import type { ListHistoryQuery } from '@affine/graphql';
@@ -25,7 +30,6 @@ import {
useMutation,
} from '../../../components/hooks/use-mutation';
import { useQueryInfinite } from '../../../components/hooks/use-query';
import { CloudBlobStorage } from '../../../modules/workspace-engine/impls/engine/blob-cloud';
const logger = new DebugLogger('page-history');
@@ -105,19 +109,28 @@ const docCollectionMap = new Map<string, Workspace>();
// assume the workspace is a cloud workspace since the history feature is only enabled for cloud workspace
const getOrCreateShellWorkspace = (
workspaceId: string,
fetchService: FetchService,
graphQLService: GraphQLService
flavourProvider?: WorkspaceFlavourProvider
) => {
let docCollection = docCollectionMap.get(workspaceId);
if (!docCollection) {
const blobStorage = new CloudBlobStorage(
workspaceId,
fetchService,
graphQLService
);
docCollection = new WorkspaceImpl({
id: workspaceId,
blobSource: blobStorage,
blobSource: {
name: 'cloud',
readonly: true,
async get(key) {
return flavourProvider?.getWorkspaceBlob(workspaceId, key) ?? null;
},
set() {
return Promise.resolve('');
},
delete() {
return Promise.resolve();
},
list() {
return Promise.resolve([]);
},
},
schema: getAFFiNEWorkspaceSchema(),
});
docCollectionMap.set(workspaceId, docCollection);
@@ -150,6 +163,8 @@ export const useSnapshotPage = (
pageDocId: string,
ts?: string
) => {
const affineWorkspace = useService(WorkspaceService).workspace;
const workspacesService = useService(WorkspacesService);
const fetchService = useService(FetchService);
const graphQLService = useService(GraphQLService);
const snapshot = usePageHistory(docCollection.id, pageDocId, ts);
@@ -160,8 +175,7 @@ export const useSnapshotPage = (
const pageId = pageDocId + '-' + ts;
const historyShellWorkspace = getOrCreateShellWorkspace(
docCollection.id,
fetchService,
graphQLService
workspacesService.getWorkspaceFlavourProvider(affineWorkspace.meta)
);
let page = historyShellWorkspace.getDoc(pageId);
if (!page && snapshot) {
@@ -175,19 +189,31 @@ export const useSnapshotPage = (
}); // must load before applyUpdate
}
return page ?? undefined;
}, [ts, pageDocId, docCollection.id, fetchService, graphQLService, snapshot]);
}, [
ts,
pageDocId,
docCollection.id,
workspacesService,
affineWorkspace.meta,
snapshot,
]);
useEffect(() => {
const historyShellWorkspace = getOrCreateShellWorkspace(
docCollection.id,
fetchService,
graphQLService
workspacesService.getWorkspaceFlavourProvider(affineWorkspace.meta)
);
// apply the rootdoc's update to the current workspace
// this makes sure the page reference links are not deleted ones in the preview
const update = encodeStateAsUpdate(docCollection.doc);
applyUpdate(historyShellWorkspace.doc, update);
}, [docCollection, fetchService, graphQLService]);
}, [
affineWorkspace.meta,
docCollection,
fetchService,
graphQLService,
workspacesService,
]);
return page;
};

View File

@@ -68,11 +68,11 @@ export const CloudQuotaModal = () => {
}, [userQuota, isOwner, workspaceQuota, t]);
const onAbortLargeBlob = useAsyncCallback(
async (blob: Blob) => {
async (byteSize: number) => {
// wait for quota revalidation
await workspaceQuotaService.quota.waitForRevalidation();
if (
blob.size > (workspaceQuotaService.quota.quota$.value?.blobLimit ?? 0)
byteSize > (workspaceQuotaService.quota.quota$.value?.blobLimit ?? 0)
) {
setOpen(true);
}
@@ -85,10 +85,10 @@ export const CloudQuotaModal = () => {
return;
}
currentWorkspace.engine.blob.singleBlobSizeLimit = workspaceQuota.blobLimit;
currentWorkspace.engine.blob.setMaxBlobSize(workspaceQuota.blobLimit);
const disposable =
currentWorkspace.engine.blob.onAbortLargeBlob(onAbortLargeBlob);
currentWorkspace.engine.blob.onReachedMaxBlobSize(onAbortLargeBlob);
return () => {
disposable();
};

View File

@@ -16,7 +16,7 @@ export const LocalQuotaModal = () => {
}, [setOpen]);
useEffect(() => {
const disposable = currentWorkspace.engine.blob.onAbortLargeBlob(() => {
const disposable = currentWorkspace.engine.blob.onReachedMaxBlobSize(() => {
setOpen(true);
});
return () => {

View File

@@ -57,7 +57,6 @@ import {
patchForClipboardInElectron,
patchForEdgelessNoteConfig,
patchForMobile,
patchForSharedPage,
patchGenerateDocUrlExtension,
patchNotificationService,
patchOpenDocExtension,
@@ -93,7 +92,7 @@ interface BlocksuiteEditorProps {
defaultOpenProperty?: DefaultOpenProperty;
}
const usePatchSpecs = (shared: boolean, mode: DocMode) => {
const usePatchSpecs = (mode: DocMode) => {
const [reactToLit, portals] = useLitPortalFactory();
const {
peekViewService,
@@ -168,9 +167,6 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
patched = patched.concat(patchParseDocUrlExtension(framework));
patched = patched.concat(patchGenerateDocUrlExtension(framework));
patched = patched.concat(patchQuickSearchService(framework));
if (shared) {
patched = patched.concat(patchForSharedPage());
}
if (BUILD_CONFIG.isMobileEdition) {
patched = patched.concat(patchForMobile());
}
@@ -190,7 +186,6 @@ const usePatchSpecs = (shared: boolean, mode: DocMode) => {
peekViewService,
reactToLit,
referenceRenderer,
shared,
specs,
featureFlagService,
]);
@@ -261,7 +256,7 @@ export const BlocksuiteDocEditor = forwardRef<
[externalTitleRef]
);
const [specs, portals] = usePatchSpecs(!!shared, 'page');
const [specs, portals] = usePatchSpecs('page');
const displayBiDirectionalLink = useLiveData(
editorSettingService.editorSetting.settings$.selector(
@@ -349,8 +344,8 @@ export const BlocksuiteDocEditor = forwardRef<
export const BlocksuiteEdgelessEditor = forwardRef<
EdgelessEditor,
BlocksuiteEditorProps
>(function BlocksuiteEdgelessEditor({ page, shared }, ref) {
const [specs, portals] = usePatchSpecs(!!shared, 'edgeless');
>(function BlocksuiteEdgelessEditor({ page }, ref) {
const [specs, portals] = usePatchSpecs('edgeless');
const editorRef = useRef<EdgelessEditor | null>(null);
const onDocRef = useCallback(

View File

@@ -3,6 +3,7 @@ import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import type { BlobSyncState } from '@affine/nbstore';
import { useLiveData, useService } from '@toeverything/infra';
import { debounce } from 'lodash-es';
import { useCallback, useEffect } from 'react';
@@ -31,8 +32,8 @@ export const OverCapacityNotification = () => {
// debounce sync engine status
useEffect(() => {
const disposableOverCapacity =
currentWorkspace.engine.blob.isStorageOverCapacity$.subscribe(
debounce((isStorageOverCapacity: boolean) => {
currentWorkspace.engine.blob.state$.subscribe(
debounce(({ isStorageOverCapacity }: BlobSyncState) => {
const isOver = isStorageOverCapacity;
if (!isOver) {
return;

View File

@@ -20,11 +20,11 @@ import {
TeamWorkspaceIcon,
UnsyncIcon,
} from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import { LiveData, useLiveData } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useEffect, useState } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
import { WorkspaceAvatar } from '../../workspace-avatar';
@@ -85,7 +85,11 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
const workspace = useWorkspace(meta);
const engineState = useLiveData(
workspace?.engine.docEngineState$.throttleTime(100)
useMemo(() => {
return workspace
? LiveData.from(workspace.engine.doc.state$, null).throttleTime(100)
: null;
}, [workspace])
);
if (!engineState || !workspace) {
@@ -94,7 +98,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
const progress =
(engineState.total - engineState.syncing) / engineState.total;
const syncing = engineState.syncing > 0 || engineState.retrying;
const syncing = engineState.syncing > 0 || engineState.syncRetrying;
let content;
// TODO(@eyhn): add i18n
@@ -106,9 +110,9 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
}
} else if (!isOnline) {
content = 'Disconnected, please check your network connection';
} else if (engineState.retrying && engineState.errorMessage) {
content = `${engineState.errorMessage}, reconnecting.`;
} else if (engineState.retrying) {
} else if (engineState.syncRetrying && engineState.syncErrorMessage) {
content = `${engineState.syncErrorMessage}, reconnecting.`;
} else if (engineState.syncRetrying) {
content = 'Sync disconnected due to unexpected issues, reconnecting.';
} else if (syncing) {
content =
@@ -123,7 +127,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
return SyncingWorkspaceStatus({
progress: progress ? Math.max(progress, 0.2) : undefined,
});
} else if (engineState.retrying) {
} else if (engineState.syncRetrying) {
return UnSyncWorkspaceStatus();
} else {
return CloudWorkspaceStatus();
@@ -145,7 +149,7 @@ const useSyncEngineSyncProgress = (meta: WorkspaceMetadata) => {
progress,
active:
workspace.flavour !== 'local' &&
((syncing && progress !== undefined) || engineState.retrying), // active if syncing or retrying,
((syncing && progress !== undefined) || engineState.syncRetrying), // active if syncing or retrying,
};
};

View File

@@ -1,8 +1,7 @@
import { PropertyName, PropertyRoot, PropertyValue } from '@affine/component';
import { DocsService } from '@affine/core/modules/doc';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon, HistoryIcon } from '@blocksuite/icons/rc';
import { DateTimeIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import type { ConfigType } from 'dayjs';
@@ -19,11 +18,7 @@ export const TimeRow = ({
className?: string;
}) => {
const t = useI18n();
const workspaceService = useService(WorkspaceService);
const docsService = useService(DocsService);
const { syncing, retrying, serverClock } = useLiveData(
workspaceService.workspace.engine.doc.docState$(docId)
);
const docRecord = useLiveData(docsService.list.doc$(docId));
const docMeta = useLiveData(docRecord?.meta$);
@@ -43,38 +38,14 @@ export const TimeRow = ({
: null;
return (
<>
<PropertyRoot>
<PropertyName name={t['Created']()} icon={<DateTimeIcon />} />
<PropertyValue>
{docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime}
</PropertyValue>
</PropertyRoot>
{serverClock ? (
<PropertyRoot>
<PropertyName
name={t[
!syncing && !retrying ? 'Updated' : 'com.affine.syncing'
]()}
icon={<HistoryIcon />}
/>
<PropertyValue>
{!syncing && !retrying
? formatI18nTime(serverClock)
: docMeta?.updatedDate
? formatI18nTime(docMeta.updatedDate)
: null}
</PropertyValue>
</PropertyRoot>
) : docMeta?.updatedDate ? (
<PropertyRoot>
<PropertyName name={t['Updated']()} icon={<HistoryIcon />} />
<PropertyValue>{formatI18nTime(docMeta.updatedDate)}</PropertyValue>
</PropertyRoot>
) : null}
</>
<PropertyRoot>
<PropertyName name={t['Created']()} icon={<DateTimeIcon />} />
<PropertyValue>
{docMeta ? formatI18nTime(docMeta.createDate) : localizedCreateTime}
</PropertyValue>
</PropertyRoot>
);
}, [docMeta, retrying, serverClock, syncing, t]);
}, [docMeta, t]);
const dTimestampElement = useDebouncedValue(timestampElement, 500);

View File

@@ -63,7 +63,6 @@ const SettingModalInner = ({
const currentServerId = useLiveData(
globalContextService.globalContext.serverId.$
);
console.log(currentServerId);
const serversService = useService(ServersService);
const defaultServerService = useService(DefaultServerService);
const currentServer =

View File

@@ -39,8 +39,8 @@ export const DesktopExportPanel = ({ workspace }: ExportPanelProps) => {
type: 'workspace',
});
if (isOnline) {
await workspace.engine.waitForDocSynced();
await workspace.engine.blob.sync();
await workspace.engine.doc.waitForSynced();
await workspace.engine.blob.fullSync();
}
const result = await desktopApi.handler?.dialog.saveDBFileAs(workspaceId);

View File

@@ -8,9 +8,7 @@ import { WorkspaceServerService } from '@affine/core/modules/cloud';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { FrameworkScope, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { DeleteLeaveWorkspace } from './delete-leave-workspace';
import { EnableCloudPanel } from './enable-cloud';
@@ -34,17 +32,6 @@ export const WorkspaceSettingDetail = ({
const workspaceInfo = useWorkspaceInfo(workspace);
const handleResetSyncStatus = useCallback(() => {
workspace?.engine.doc
.resetSyncStatus()
.then(() => {
window.location.reload();
})
.catch(err => {
console.error(err);
});
}, [workspace]);
if (!workspace) {
return null;
}
@@ -71,8 +58,10 @@ export const WorkspaceSettingDetail = ({
<TemplateDocSetting />
<SettingWrapper title={t['com.affine.brand.affineCloud']()}>
<EnableCloudPanel onCloseSetting={onCloseSetting} />
<WorkspaceQuotaPanel />
<MembersPanel onChangeSettingState={onChangeSettingState} />
{workspace.flavour !== 'local' && <WorkspaceQuotaPanel />}
{workspace.flavour !== 'local' && (
<MembersPanel onChangeSettingState={onChangeSettingState} />
)}
</SettingWrapper>
<SharingPanel />
{BUILD_CONFIG.isElectron && (
@@ -82,19 +71,6 @@ export const WorkspaceSettingDetail = ({
)}
<SettingWrapper>
<DeleteLeaveWorkspace onCloseSetting={onCloseSetting} />
<SettingRow
name={
<span style={{ color: 'var(--affine-text-secondary-color)' }}>
{t['com.affine.resetSyncStatus.button']()}
</span>
}
desc={t['com.affine.resetSyncStatus.description']()}
style={{ cursor: 'pointer' }}
onClick={handleResetSyncStatus}
data-testid="reset-sync-status"
>
<ArrowRightSmallIcon />
</SettingRow>
</SettingWrapper>
</FrameworkScope>
</FrameworkScope>

View File

@@ -9,9 +9,10 @@ import { validateAndReduceImage } from '@affine/core/utils/reduce-image';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { useI18n } from '@affine/i18n';
import { CameraIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import type { KeyboardEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { map } from 'rxjs';
import * as style from './style.css';
@@ -24,8 +25,18 @@ export const ProfilePanel = () => {
useEffect(() => {
permissionService.permission.revalidate();
}, [permissionService]);
const workspaceIsReady = useLiveData(workspace?.engine.rootDocState$)?.ready;
const workspaceIsReady = useLiveData(
useMemo(() => {
return workspace
? LiveData.from(
workspace.engine.doc
.docState$(workspace.id)
.pipe(map(v => v.ready)),
false
)
: null;
}, [workspace])
);
const [name, setName] = useState('');
useEffect(() => {

View File

@@ -33,7 +33,7 @@ const useLoadAttachment = (pageId: string, attachmentId: string) => {
if (!doc.blockSuiteDoc.ready) {
doc.blockSuiteDoc.load();
}
doc.setPriorityLoad(10);
const dispose = doc.addPriorityLoad(10);
doc
.waitForSyncReady()
@@ -47,6 +47,7 @@ const useLoadAttachment = (pageId: string, attachmentId: string) => {
return () => {
release();
dispose();
};
}, [docRecord, docsService, pageId, attachmentId]);

View File

@@ -41,10 +41,7 @@ const useLoadDoc = (pageId: string) => {
// set sync engine priority target
useEffect(() => {
currentWorkspace.engine.doc.setPriority(pageId, 10);
return () => {
currentWorkspace.engine.doc.setPriority(pageId, 5);
};
return currentWorkspace.engine.doc.addPriority(pageId, 10);
}, [currentWorkspace, pageId]);
const isInTrash = useLiveData(doc?.meta$.map(meta => meta.trash));

View File

@@ -16,6 +16,7 @@ import {
import { ZipTransformer } from '@blocksuite/affine/blocks';
import {
FrameworkScope,
LiveData,
useLiveData,
useService,
useServices,
@@ -28,6 +29,7 @@ import {
useParams,
useSearchParams,
} from 'react-router-dom';
import { map } from 'rxjs';
import * as _Y from 'yjs';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
@@ -247,7 +249,20 @@ const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => {
}, [meta, workspacesService]);
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
useLiveData(
useMemo(
() =>
workspace
? LiveData.from(
workspace.engine.doc
.docState$(workspace.id)
.pipe(map(v => v.ready)),
false
)
: null,
[workspace]
)
) ?? false;
useEffect(() => {
if (workspace) {

View File

@@ -25,13 +25,13 @@ export const WorkspaceLayout = function WorkspaceLayout({
<WorkspaceDialogs />
{/* ---- some side-effect components ---- */}
{currentWorkspace?.flavour === 'local' ? (
<LocalQuotaModal />
) : (
{currentWorkspace?.flavour !== 'local' ? (
<>
<CloudQuotaModal />
<QuotaCheck workspaceMeta={currentWorkspace.meta} />
</>
) : (
<LocalQuotaModal />
)}
<AiLoginRequiredModal />
<WorkspaceSideEffects />

View File

@@ -5,12 +5,7 @@ import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-s
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { AppContainer } from '@affine/core/desktop/components/app-container';
import {
AuthService,
FetchService,
GraphQLService,
ServerService,
} from '@affine/core/modules/cloud';
import { AuthService, ServerService } from '@affine/core/modules/cloud';
import { type Doc, DocsService } from '@affine/core/modules/doc';
import {
type Editor,
@@ -19,13 +14,11 @@ import {
EditorsService,
} from '@affine/core/modules/editor';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import { ShareReaderService } from '@affine/core/modules/share-doc';
import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench';
import {
type Workspace,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { CloudBlobStorage } from '@affine/core/modules/workspace-engine';
import { useI18n } from '@affine/i18n';
import {
type DocMode,
@@ -35,22 +28,9 @@ import {
import type { AffineEditorContainer } from '@blocksuite/affine/presets';
import { DisposableGroup } from '@blocksuite/global/utils';
import { Logo1Icon } from '@blocksuite/icons/rc';
import {
EmptyBlobStorage,
FrameworkScope,
ReadonlyDocStorage,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { FrameworkScope, useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { PageNotFound } from '../../404';
@@ -65,15 +45,6 @@ export const SharePage = ({
workspaceId: string;
docId: string;
}) => {
const { shareReaderService, serverService } = useServices({
ShareReaderService,
ServerService,
});
const isLoading = useLiveData(shareReaderService.reader.isLoading$);
const error = useLiveData(shareReaderService.reader.error$);
const data = useLiveData(shareReaderService.reader.data$);
const location = useLocation();
const { mode, selector, isTemplate, templateName, templateSnapshotUrl } =
@@ -105,47 +76,26 @@ export const SharePage = ({
};
}, [location.search]);
useEffect(() => {
shareReaderService.reader.loadShare({
serverId: serverService.server.id,
workspaceId,
docId,
});
}, [shareReaderService, docId, workspaceId, serverService.server.id]);
let element: ReactNode = null;
if (isLoading) {
element = null;
} else if (data) {
element = (
return (
<AppContainer>
<SharePageInner
workspaceId={data.workspaceId}
docId={data.docId}
workspaceBinary={data.workspaceBinary}
docBinary={data.docBinary}
publishMode={mode || data.publishMode}
workspaceId={workspaceId}
docId={docId}
key={workspaceId + ':' + docId}
publishMode={mode ?? undefined}
selector={selector}
isTemplate={isTemplate}
templateName={templateName}
templateSnapshotUrl={templateSnapshotUrl}
/>
);
} else if (error) {
// TODO(@JimmFly): handle error
element = <PageNotFound />;
} else {
element = <PageNotFound noPermission />;
}
return <AppContainer fallback={!element}>{element}</AppContainer>;
</AppContainer>
);
};
const SharePageInner = ({
workspaceId,
docId,
workspaceBinary,
docBinary,
publishMode = 'page' as DocMode,
publishMode = 'page',
selector,
isTemplate,
templateName,
@@ -153,20 +103,18 @@ const SharePageInner = ({
}: {
workspaceId: string;
docId: string;
workspaceBinary: Uint8Array;
docBinary: Uint8Array;
publishMode?: DocMode;
selector?: EditorSelector;
isTemplate?: boolean;
templateName?: string;
templateSnapshotUrl?: string;
}) => {
const serverService = useService(ServerService);
const workspacesService = useService(WorkspacesService);
const fetchService = useService(FetchService);
const graphQLService = useService(GraphQLService);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [page, setPage] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const [noPermission, setNoPermission] = useState(false);
const [editorContainer, setActiveBlocksuiteEditor] =
useActiveBlocksuiteEditor();
@@ -181,38 +129,41 @@ const SharePageInner = ({
isSharedMode: true,
},
{
getDocStorage() {
return new ReadonlyDocStorage({
[workspaceId]: workspaceBinary,
[docId]: docBinary,
});
},
getAwarenessConnections() {
return [];
},
getDocServer() {
return null;
},
getLocalBlobStorage() {
return EmptyBlobStorage;
},
getRemoteBlobStorages() {
return [
new CloudBlobStorage(workspaceId, fetchService, graphQLService),
];
local: {
doc: {
name: 'StaticCloudDocStorage',
opts: {
id: workspaceId,
serverBaseUrl: serverService.server.baseUrl,
},
},
blob: {
name: 'CloudBlobStorage',
opts: {
id: workspaceId,
serverBaseUrl: serverService.server.baseUrl,
},
},
},
remotes: {},
}
);
setWorkspace(workspace);
workspace.engine
.waitForRootDocReady()
.then(() => {
workspace.engine.doc
.waitForDocLoaded(workspace.id)
.then(async () => {
const { doc } = workspace.scope.get(DocsService).open(docId);
doc.blockSuiteDoc.load();
doc.blockSuiteDoc.readonly = true;
await workspace.engine.doc.waitForDocLoaded(docId);
if (!doc.blockSuiteDoc.root) {
throw new Error('Doc is empty');
}
setPage(doc);
const editor = doc.scope.get(EditorsService).createEditor();
@@ -226,6 +177,7 @@ const SharePageInner = ({
})
.catch(err => {
console.error(err);
setNoPermission(true);
});
}, [
docId,
@@ -233,10 +185,7 @@ const SharePageInner = ({
workspacesService,
publishMode,
selector,
workspaceBinary,
docBinary,
fetchService,
graphQLService,
serverService.server.baseUrl,
]);
const t = useI18n();
@@ -281,6 +230,10 @@ const SharePageInner = ({
[editor, setActiveBlocksuiteEditor, jumpToPageBlock, openPage, workspaceId]
);
if (noPermission) {
return <PageNotFound noPermission />;
}
if (!workspace || !page || !editor) {
return;
}

View File

@@ -17,13 +17,20 @@ import type {
WorkspaceMetadata,
} from '@affine/core/modules/workspace';
import { WorkspacesService } from '@affine/core/modules/workspace';
import { FrameworkScope, useLiveData, useServices } from '@toeverything/infra';
import {
FrameworkScope,
LiveData,
useLiveData,
useServices,
} from '@toeverything/infra';
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { map } from 'rxjs';
import { AppFallback } from '../../components/app-fallback';
import { WorkspaceDialogs } from '../../dialogs';
@@ -33,11 +40,11 @@ declare global {
/**
* @internal debug only
*/
// eslint-disable-next-line no-var
// oxlint-disable-next-line no-var
var currentWorkspace: Workspace | undefined;
// eslint-disable-next-line no-var
// oxlint-disable-next-line no-var
var exportWorkspaceSnapshot: (docs?: string[]) => Promise<void>;
// eslint-disable-next-line no-var
// oxlint-disable-next-line no-var
var importWorkspaceSnapshot: () => Promise<void>;
interface WindowEventMap {
'affine:workspace:change': CustomEvent<{ id: string }>;
@@ -106,7 +113,20 @@ export const WorkspaceLayout = ({
]);
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
useLiveData(
useMemo(
() =>
workspace
? LiveData.from(
workspace.engine.doc
.docState$(workspace.id)
.pipe(map(v => v.ready)),
false
)
: null,
[workspace]
)
) ?? false;
if (!workspace) {
return null; // skip this, workspace will be set in layout effect
@@ -125,10 +145,10 @@ export const WorkspaceLayout = ({
{/* ---- some side-effect components ---- */}
<PeekViewManagerModal />
{workspace?.flavour === 'local' ? (
<LocalQuotaModal />
) : (
{workspace?.flavour !== 'local' ? (
<CloudQuotaModal />
) : (
<LocalQuotaModal />
)}
<AiLoginRequiredModal />
<WorkspaceSideEffects />

View File

@@ -11,9 +11,7 @@ export { AccountChanged } from './events/account-changed';
export { AccountLoggedIn } from './events/account-logged-in';
export { AccountLoggedOut } from './events/account-logged-out';
export { ServerInitialized } from './events/server-initialized';
export { RawFetchProvider } from './provider/fetch';
export { ValidatorProvider } from './provider/validator';
export { WebSocketAuthProvider } from './provider/websocket-auth';
export { AuthService } from './services/auth';
export { CaptchaService } from './services/captcha';
export { DefaultServerService } from './services/default-server';
@@ -27,7 +25,6 @@ export { SubscriptionService } from './services/subscription';
export { UserCopilotQuotaService } from './services/user-copilot-quota';
export { UserFeatureService } from './services/user-feature';
export { UserQuotaService } from './services/user-quota';
export { WebSocketService } from './services/websocket';
export { WorkspaceInvoicesService } from './services/workspace-invoices';
export { WorkspaceServerService } from './services/workspace-server';
export { WorkspaceSubscriptionService } from './services/workspace-subscription';
@@ -51,9 +48,7 @@ import { UserFeature } from './entities/user-feature';
import { UserQuota } from './entities/user-quota';
import { WorkspaceInvoices } from './entities/workspace-invoices';
import { WorkspaceSubscription } from './entities/workspace-subscription';
import { DefaultRawFetchProvider, RawFetchProvider } from './provider/fetch';
import { ValidatorProvider } from './provider/validator';
import { WebSocketAuthProvider } from './provider/websocket-auth';
import { ServerScope } from './scopes/server';
import { AuthService } from './services/auth';
import { CaptchaService } from './services/captcha';
@@ -69,7 +64,6 @@ import { SubscriptionService } from './services/subscription';
import { UserCopilotQuotaService } from './services/user-copilot-quota';
import { UserFeatureService } from './services/user-feature';
import { UserQuotaService } from './services/user-quota';
import { WebSocketService } from './services/websocket';
import { WorkspaceInvoicesService } from './services/workspace-invoices';
import { WorkspaceServerService } from './services/workspace-server';
import { WorkspaceSubscriptionService } from './services/workspace-subscription';
@@ -85,26 +79,16 @@ import { UserQuotaStore } from './stores/user-quota';
export function configureCloudModule(framework: Framework) {
framework
.impl(RawFetchProvider, DefaultRawFetchProvider)
.service(ServersService, [ServerListStore, ServerConfigStore])
.service(DefaultServerService, [ServersService])
.store(ServerListStore, [GlobalStateService])
.store(ServerConfigStore, [RawFetchProvider])
.store(ServerConfigStore)
.entity(Server, [ServerListStore])
.scope(ServerScope)
.service(ServerService, [ServerScope])
.service(FetchService, [RawFetchProvider, ServerService])
.service(FetchService, [ServerService])
.service(EventSourceService, [ServerService])
.service(GraphQLService, [FetchService])
.service(
WebSocketService,
f =>
new WebSocketService(
f.get(ServerService),
f.get(AuthService),
f.getOptional(WebSocketAuthProvider)
)
)
.service(CaptchaService, f => {
return new CaptchaService(
f.get(ServerService),

View File

@@ -1,17 +0,0 @@
import { createIdentifier } from '@toeverything/infra';
import type { FetchInit } from '../services/fetch';
export interface RawFetchProvider {
/**
* standard fetch, in ios&android, we can use native fetch to implement this
*/
fetch: (input: string | URL, init?: FetchInit) => Promise<Response>;
}
export const RawFetchProvider =
createIdentifier<RawFetchProvider>('FetchProvider');
export const DefaultRawFetchProvider = {
fetch: globalThis.fetch.bind(globalThis),
};

View File

@@ -1,22 +0,0 @@
import { createIdentifier } from '@toeverything/infra';
export interface WebSocketAuthProvider {
/**
* Returns the token and userId for WebSocket authentication
*
* Useful when cookies are not available for WebSocket connections
*
* @param url - The URL of the WebSocket endpoint
*/
getAuthToken: (url: string) => Promise<
| {
token?: string;
userId?: string;
}
| undefined
>;
}
export const WebSocketAuthProvider = createIdentifier<WebSocketAuthProvider>(
'WebSocketAuthProvider'
);

View File

@@ -3,7 +3,6 @@ import { UserFriendlyError } from '@affine/graphql';
import { fromPromise, Service } from '@toeverything/infra';
import { BackendError, NetworkError } from '../error';
import type { RawFetchProvider } from '../provider/fetch';
import type { ServerService } from './server';
const logger = new DebugLogger('affine:fetch');
@@ -11,10 +10,7 @@ const logger = new DebugLogger('affine:fetch');
export type FetchInit = RequestInit & { timeout?: number };
export class FetchService extends Service {
constructor(
private readonly fetchProvider: RawFetchProvider,
private readonly serverService: ServerService
) {
constructor(private readonly serverService: ServerService) {
super();
}
rxFetch = (
@@ -50,7 +46,7 @@ export class FetchService extends Service {
abortController.abort('timeout');
}, timeout);
const res = await this.fetchProvider
const res = await globalThis
.fetch(new URL(input, this.serverService.server.serverMetadata.baseUrl), {
...init,
signal: abortController.signal,

View File

@@ -1,66 +0,0 @@
import { OnEvent, Service } from '@toeverything/infra';
import { Manager } from 'socket.io-client';
import { ApplicationStarted } from '../../lifecycle';
import { AccountChanged } from '../events/account-changed';
import type { WebSocketAuthProvider } from '../provider/websocket-auth';
import type { AuthService } from './auth';
import type { ServerService } from './server';
@OnEvent(AccountChanged, e => e.update)
@OnEvent(ApplicationStarted, e => e.update)
export class WebSocketService extends Service {
ioManager: Manager = new Manager(`${this.serverService.server.baseUrl}/`, {
autoConnect: false,
transports: ['websocket'],
secure: location.protocol === 'https:',
});
socket = this.ioManager.socket('/', {
auth: this.webSocketAuthProvider
? cb => {
this.webSocketAuthProvider
?.getAuthToken(`${this.serverService.server.baseUrl}/`)
.then(v => {
cb(v ?? {});
})
.catch(e => {
console.error('Failed to get auth token for websocket', e);
});
}
: undefined,
});
refCount = 0;
constructor(
private readonly serverService: ServerService,
private readonly authService: AuthService,
private readonly webSocketAuthProvider?: WebSocketAuthProvider
) {
super();
}
/**
* Connect socket, with automatic connect and reconnect logic.
* External code should not call `socket.connect()` or `socket.disconnect()` manually.
* When socket is no longer needed, call `dispose()` to clean up resources.
*/
connect() {
this.refCount++;
this.update();
return {
socket: this.socket,
dispose: () => {
this.refCount--;
this.update();
},
};
}
update(): void {
if (this.authService.session.account$.value && this.refCount > 0) {
this.socket.connect();
} else {
this.socket.disconnect();
}
}
}

View File

@@ -8,13 +8,11 @@ import {
} from '@affine/graphql';
import { Store } from '@toeverything/infra';
import type { RawFetchProvider } from '../provider/fetch';
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
OauthProvidersQuery['serverConfig'];
export class ServerConfigStore extends Store {
constructor(private readonly fetcher: RawFetchProvider) {
constructor() {
super();
}
@@ -22,10 +20,7 @@ export class ServerConfigStore extends Store {
serverBaseUrl: string,
abortSignal?: AbortSignal
): Promise<ServerConfigType> {
const gql = gqlFetcherFactory(
`${serverBaseUrl}/graphql`,
this.fetcher.fetch
);
const gql = gqlFetcherFactory(`${serverBaseUrl}/graphql`, globalThis.fetch);
const serverConfigData = await gql({
query: serverConfigQuery,
context: {

View File

@@ -2,7 +2,8 @@ import type {
Table as OrmTable,
TableSchemaBuilder,
} from '@toeverything/infra';
import { Entity } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import { map } from 'rxjs';
import type { WorkspaceService } from '../../workspace';
@@ -18,13 +19,19 @@ export class WorkspaceDBTable<
super();
}
isSyncing$ = this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.map(docState => docState.syncing);
isSyncing$ = LiveData.from(
this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.pipe(map(docState => docState.syncing)),
false
);
isLoading$ = this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.map(docState => docState.loading);
isLoading$ = LiveData.from(
this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.pipe(map(docState => !docState.loaded)),
false
);
create = this.table.create.bind(this.table) as typeof this.table.create;
update = this.table.update.bind(this.table) as typeof this.table.update;

View File

@@ -46,11 +46,11 @@ export class WorkspaceDBService extends Service {
new YjsDBAdapter(AFFiNE_WORKSPACE_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: db${workspaceId}${guid}
guid: `db$${this.workspaceService.workspace.id}$${guid}`,
// guid format: db${guid}
guid: `db$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(
this.workspaceService.workspace.engine.doc.connectDoc(ydoc);
this.workspaceService.workspace.engine.doc.addPriority(
ydoc.guid,
50
);
@@ -59,8 +59,7 @@ export class WorkspaceDBService extends Service {
})
),
schema: AFFiNE_WORKSPACE_DB_SCHEMA,
storageDocId: tableName =>
`db$${this.workspaceService.workspace.id}$${tableName}`,
storageDocId: tableName => `db$${tableName}`,
}
) as WorkspaceDBWithTables<AFFiNEWorkspaceDbSchema>;
}
@@ -79,11 +78,11 @@ export class WorkspaceDBService extends Service {
new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: userdata${userId}${workspaceId}${guid}
guid: `userdata$${userId}$${this.workspaceService.workspace.id}$${guid}`,
// guid format: userdata${userId}${guid}
guid: `userdata$${userId}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(
this.workspaceService.workspace.engine.doc.connectDoc(ydoc);
this.workspaceService.workspace.engine.doc.addPriority(
ydoc.guid,
50
);
@@ -92,8 +91,7 @@ export class WorkspaceDBService extends Service {
})
),
schema: AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
storageDocId: tableName =>
`userdata$${userId}$${this.workspaceService.workspace.id}$${tableName}`,
storageDocId: tableName => `userdata$${userId}$${tableName}`,
}
);

View File

@@ -1,4 +1,4 @@
import type { DocStorage } from '@toeverything/infra';
import type { DocStorage } from '@affine/nbstore';
import {
AFFiNE_WORKSPACE_DB_SCHEMA,
@@ -6,27 +6,33 @@ import {
} from './schema';
export async function transformWorkspaceDBLocalToCloud(
localWorkspaceId: string,
cloudWorkspaceId: string,
_localWorkspaceId: string,
_cloudWorkspaceId: string,
localDocStorage: DocStorage,
cloudDocStorage: DocStorage,
accountId: string
) {
for (const tableName of Object.keys(AFFiNE_WORKSPACE_DB_SCHEMA)) {
const localDocName = `db$${localWorkspaceId}$${tableName}`;
const localDoc = await localDocStorage.doc.get(localDocName);
const localDocName = `db$${tableName}`;
const localDoc = await localDocStorage.getDoc(localDocName);
if (localDoc) {
const cloudDocName = `db$${cloudWorkspaceId}$${tableName}`;
await cloudDocStorage.doc.set(cloudDocName, localDoc);
const cloudDocName = `db$${tableName}`;
await cloudDocStorage.pushDocUpdate({
docId: cloudDocName,
bin: localDoc.bin,
});
}
}
for (const tableName of Object.keys(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA)) {
const localDocName = `userdata$__local__$${localWorkspaceId}$${tableName}`;
const localDoc = await localDocStorage.doc.get(localDocName);
const localDocName = `userdata$__local__$${tableName}`;
const localDoc = await localDocStorage.getDoc(localDocName);
if (localDoc) {
const cloudDocName = `userdata$${accountId}$${cloudWorkspaceId}$${tableName}`;
await cloudDocStorage.doc.set(cloudDocName, localDoc);
const cloudDocName = `userdata$${accountId}$${tableName}`;
await cloudDocStorage.pushDocUpdate({
docId: cloudDocName,
bin: localDoc.bin,
});
}
}
}

View File

@@ -28,8 +28,9 @@ export class DocDatabaseBacklinksService extends Service {
if (!docRef.doc.blockSuiteDoc.ready) {
docRef.doc.blockSuiteDoc.load();
}
docRef.doc.setPriorityLoad(10);
const disposePriorityLoad = docRef.doc.addPriorityLoad(10);
await docRef.doc.waitForSyncReady();
disposePriorityLoad();
return docRef;
}

View File

@@ -81,8 +81,8 @@ export class Doc extends Entity {
return this.store.waitForDocLoadReady(this.id);
}
setPriorityLoad(priority: number) {
return this.store.setPriorityLoad(this.id, priority);
addPriorityLoad(priority: number) {
return this.store.addPriorityLoad(this.id, priority);
}
changeDocTitle(newTitle: string) {

View File

@@ -107,7 +107,6 @@ export class DocsService extends Service {
) {
const doc = this.store.createBlockSuiteDoc();
initDocFromProps(doc, options.docProps);
this.store.markDocSyncStateAsReady(doc.id);
const docRecord = this.list.doc$(doc.id).value;
if (!docRecord) {
throw new Unreachable();
@@ -124,8 +123,9 @@ export class DocsService extends Service {
async addLinkedDoc(targetDocId: string, linkedDocId: string) {
const { doc, release } = this.open(targetDocId);
doc.setPriorityLoad(10);
const disposePriorityLoad = doc.addPriorityLoad(10);
await doc.waitForSyncReady();
disposePriorityLoad();
const text = new Text([
{
insert: ' ',
@@ -149,8 +149,9 @@ export class DocsService extends Service {
async changeDocTitle(docId: string, newTitle: string) {
const { doc, release } = this.open(docId);
doc.setPriorityLoad(10);
const disposePriorityLoad = doc.addPriorityLoad(10);
await doc.waitForSyncReady();
disposePriorityLoad();
doc.changeDocTitle(newTitle);
release();
}

View File

@@ -126,9 +126,9 @@ export class DocsStore extends Store {
}
watchDocListReady() {
return this.workspaceService.workspace.engine.rootDocState$
.map(state => !state.syncing)
.asObservable();
return this.workspaceService.workspace.engine.doc
.docState$(this.workspaceService.workspace.id)
.pipe(map(state => state.synced));
}
setDocMeta(id: string, meta: Partial<DocMeta>) {
@@ -153,14 +153,10 @@ export class DocsStore extends Store {
}
waitForDocLoadReady(id: string) {
return this.workspaceService.workspace.engine.doc.waitForReady(id);
return this.workspaceService.workspace.engine.doc.waitForDocLoaded(id);
}
setPriorityLoad(id: string, priority: number) {
return this.workspaceService.workspace.engine.doc.setPriority(id, priority);
}
markDocSyncStateAsReady(id: string) {
this.workspaceService.workspace.engine.doc.markAsReady(id);
addPriorityLoad(id: string, priority: number) {
return this.workspaceService.workspace.engine.doc.addPriority(id, priority);
}
}

View File

@@ -25,7 +25,7 @@ const logger = new DebugLogger('crawler');
const WORKSPACE_DOCS_INDEXER_VERSION_KEY = 'docs-indexer-version';
interface IndexerJobPayload {
storageDocId: string;
docId: string;
}
export class DocsIndexer extends Entity {
@@ -81,26 +81,31 @@ export class DocsIndexer extends Entity {
}
setupListener() {
this.disposables.push(
this.workspaceEngine.doc.storage.eventBus.on(event => {
if (WorkspaceDBService.isDBDocId(event.docId)) {
// skip db doc
return;
}
if (event.clientId === this.workspaceEngine.doc.clientId) {
this.jobQueue
.enqueue([
{
batchKey: event.docId,
payload: { storageDocId: event.docId },
},
])
.catch(err => {
console.error('Error enqueueing job', err);
});
}
this.workspaceEngine.doc.storage.connection
.waitForConnected()
.then(() => {
this.disposables.push(
this.workspaceEngine.doc.storage.subscribeDocUpdate(updated => {
if (WorkspaceDBService.isDBDocId(updated.docId)) {
// skip db doc
return;
}
this.jobQueue
.enqueue([
{
batchKey: updated.docId,
payload: { docId: updated.docId },
},
])
.catch(err => {
console.error('Error enqueueing job', err);
});
})
);
})
);
.catch(err => {
console.error('Error waiting for doc storage connection', err);
});
}
async execJob(jobs: Job<IndexerJobPayload>[], signal: AbortSignal) {
@@ -119,20 +124,19 @@ export class DocsIndexer extends Entity {
const isUpgrade = dbVersion < DocsIndexer.INDEXER_VERSION;
// jobs should have the same storage docId, so we just pick the first one
const storageDocId = jobs[0].payload.storageDocId;
const docId = jobs[0].payload.docId;
const worker = await this.ensureWorker(signal);
const startTime = performance.now();
logger.debug('Start crawling job for storageDocId:', storageDocId);
logger.debug('Start crawling job for docId:', docId);
let workerOutput;
if (storageDocId === this.workspaceId) {
const rootDocBuffer =
await this.workspaceEngine.doc.storage.loadDocFromLocal(
this.workspaceId
);
if (docId === this.workspaceId) {
const rootDocBuffer = (
await this.workspaceEngine.doc.storage.getDoc(this.workspaceId)
)?.bin;
if (!rootDocBuffer) {
return;
}
@@ -147,15 +151,13 @@ export class DocsIndexer extends Entity {
rootDocId: this.workspaceId,
});
} else {
const rootDocBuffer =
await this.workspaceEngine.doc.storage.loadDocFromLocal(
this.workspaceId
);
const rootDocBuffer = (
await this.workspaceEngine.doc.storage.getDoc(this.workspaceId)
)?.bin;
const docBuffer =
(await this.workspaceEngine.doc.storage.loadDocFromLocal(
storageDocId
)) ?? new Uint8Array(0);
(await this.workspaceEngine.doc.storage.getDoc(docId))?.bin ??
new Uint8Array(0);
if (!rootDocBuffer) {
return;
@@ -164,7 +166,7 @@ export class DocsIndexer extends Entity {
workerOutput = await worker.run({
type: 'doc',
docBuffer,
storageDocId,
docId,
rootDocBuffer,
rootDocId: this.workspaceId,
});
@@ -231,9 +233,9 @@ export class DocsIndexer extends Entity {
if (workerOutput.reindexDoc) {
await this.jobQueue.enqueue(
workerOutput.reindexDoc.map(({ storageDocId }) => ({
batchKey: storageDocId,
payload: { storageDocId },
workerOutput.reindexDoc.map(({ docId }) => ({
batchKey: docId,
payload: { docId },
}))
);
}
@@ -244,11 +246,7 @@ export class DocsIndexer extends Entity {
const duration = performance.now() - startTime;
logger.debug(
'Finish crawling job for storageDocId:' +
storageDocId +
' in ' +
duration +
'ms '
'Finish crawling job for docId:' + docId + ' in ' + duration + 'ms '
);
}
@@ -259,7 +257,7 @@ export class DocsIndexer extends Entity {
.enqueue([
{
batchKey: this.workspaceId,
payload: { storageDocId: this.workspaceId },
payload: { docId: this.workspaceId },
},
])
.catch(err => {

View File

@@ -1,4 +1,3 @@
import { getElectronAPIs } from '@affine/electron-api/web-worker';
import type {
AttachmentBlockModel,
BookmarkBlockModel,
@@ -50,11 +49,6 @@ const LRU_CACHE_SIZE = 5;
// lru cache for ydoc instances, last used at the end of the array
const lruCache = [] as { doc: YDoc; hash: string }[];
const electronAPIs = BUILD_CONFIG.isElectron ? getElectronAPIs() : null;
// @ts-expect-error test
globalThis.__electronAPIs = electronAPIs;
async function digest(data: Uint8Array) {
if (
globalThis.crypto &&
@@ -478,7 +472,7 @@ function unindentMarkdown(markdown: string) {
async function crawlingDocData({
docBuffer,
storageDocId,
docId,
rootDocBuffer,
rootDocId,
}: WorkerInput & { type: 'doc' }): Promise<WorkerOutput> {
@@ -489,18 +483,6 @@ async function crawlingDocData({
const yRootDoc = await getOrCreateCachedYDoc(rootDocBuffer);
let docId = null;
for (const [id, subdoc] of yRootDoc.getMap('spaces')) {
if (subdoc instanceof YDoc && storageDocId === subdoc.guid) {
docId = id;
break;
}
}
if (docId === null) {
return {};
}
let docExists: boolean | null = null;
(

View File

@@ -1,5 +1,4 @@
import { DebugLogger } from '@affine/debug';
import { connectWebWorker } from '@affine/electron-api/web-worker';
import { MANUALLY_STOP, throwIfAborted } from '@toeverything/infra';
import type {
@@ -13,7 +12,6 @@ const logger = new DebugLogger('affine:indexer-worker');
export async function createWorker(abort: AbortSignal) {
let worker: Worker | null = null;
let electronApiCleanup: (() => void) | null = null;
while (throwIfAborted(abort)) {
try {
worker = await new Promise<Worker>((resolve, reject) => {
@@ -32,10 +30,6 @@ export async function createWorker(abort: AbortSignal) {
});
worker.postMessage({ type: 'init', msgId: 0 } as WorkerIngoingMessage);
if (BUILD_CONFIG.isElectron) {
electronApiCleanup = connectWebWorker(worker);
}
setTimeout(() => {
reject('timeout');
}, 1000 * 30 /* 30 sec */);
@@ -104,7 +98,6 @@ export async function createWorker(abort: AbortSignal) {
dispose: () => {
terminateAbort.abort(MANUALLY_STOP);
worker.terminate();
electronApiCleanup?.();
},
};
}

View File

@@ -36,14 +36,14 @@ export type WorkerInput =
}
| {
type: 'doc';
storageDocId: string;
docId: string;
rootDocId: string;
rootDocBuffer: Uint8Array;
docBuffer: Uint8Array;
};
export interface WorkerOutput {
reindexDoc?: { docId: string; storageDocId: string }[];
reindexDoc?: { docId: string }[];
addedDoc?: {
id: string;
blocks: Document<BlockIndexSchema>[];

View File

@@ -1,6 +1,5 @@
import { type Framework } from '@toeverything/infra';
import { RawFetchProvider } from '../cloud';
import { WorkspacesService } from '../workspace';
import { ImportTemplateDialog } from './entities/dialog';
import { TemplateDownloader } from './entities/downloader';
@@ -16,6 +15,6 @@ export function configureImportTemplateModule(framework: Framework) {
.entity(ImportTemplateDialog)
.service(TemplateDownloaderService)
.entity(TemplateDownloader, [TemplateDownloaderStore])
.store(TemplateDownloaderStore, [RawFetchProvider])
.store(TemplateDownloaderStore)
.service(ImportTemplateService, [WorkspacesService]);
}

View File

@@ -18,7 +18,7 @@ export class ImportTemplateService extends Service {
this.workspacesService.open({
metadata: workspaceMetadata,
});
await workspace.engine.waitForRootDocReady();
await workspace.engine.doc.waitForDocReady(workspace.id); // wait for root doc ready
const [importedDoc] = await ZipTransformer.importDocs(
workspace.docCollection,
new Blob([docBinary], {
@@ -42,7 +42,7 @@ export class ImportTemplateService extends Service {
docBinary: Uint8Array
// todo: support doc mode on init
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
let docId: string = null!;
const { id: workspaceId } = await this.workspacesService.create(
flavour,
@@ -51,7 +51,10 @@ export class ImportTemplateService extends Service {
docCollection.meta.setName(workspaceName);
const doc = docCollection.createDoc();
docId = doc.id;
await docStorage.doc.set(doc.spaceDoc.guid, docBinary);
await docStorage.pushDocUpdate({
docId: doc.spaceDoc.guid,
bin: docBinary,
});
}
);
return { workspaceId, docId };

View File

@@ -1,14 +1,12 @@
import { Store } from '@toeverything/infra';
import type { RawFetchProvider } from '../../cloud';
export class TemplateDownloaderStore extends Store {
constructor(private readonly fetchProvider: RawFetchProvider) {
constructor() {
super();
}
async download(snapshotUrl: string) {
const response = await this.fetchProvider.fetch(snapshotUrl, {
const response = await globalThis.fetch(snapshotUrl, {
priority: 'high',
} as any);
const arrayBuffer = await response.arrayBuffer();

View File

@@ -38,7 +38,7 @@ import { configureShareDocsModule } from './share-doc';
import { configureShareSettingModule } from './share-setting';
import {
configureCommonGlobalStorageImpls,
configureGlobalStorageModule,
configureStorageModule,
} from './storage';
import { configureSystemFontFamilyModule } from './system-font-family';
import { configureTagModule } from './tag';
@@ -55,7 +55,7 @@ export function configureCommonModules(framework: Framework) {
configureWorkspaceModule(framework);
configureDocModule(framework);
configureWorkspaceDBModule(framework);
configureGlobalStorageModule(framework);
configureStorageModule(framework);
configureGlobalContextModule(framework);
configureLifecycleModule(framework);
configureFeatureFlagModule(framework);

View File

@@ -49,7 +49,6 @@ export class PDF extends Entity<AttachmentBlockModel> {
constructor() {
super();
this.renderer.listen();
this.disposables.push(() => this.pages.clear());
}

View File

@@ -1,4 +1,8 @@
import { OpConsumer, transfer } from '@toeverything/infra/op';
import {
type MessageCommunicapable,
OpConsumer,
transfer,
} from '@toeverything/infra/op';
import type { Document } from '@toeverything/pdf-viewer';
import {
createPDFium,
@@ -23,6 +27,11 @@ import type { ClientOps } from './ops';
import type { PDFMeta, RenderPageOpts } from './types';
class PDFRendererBackend extends OpConsumer<ClientOps> {
constructor(port: MessageCommunicapable) {
super(port);
this.register('open', this.open.bind(this));
this.register('render', this.render.bind(this));
}
private readonly viewer$: Observable<Viewer> = from(
createPDFium().then(pdfium => {
return new Viewer(new Runtime(pdfium));
@@ -147,13 +156,6 @@ class PDFRendererBackend extends OpConsumer<ClientOps> {
return imageBitmap;
}
override listen(): void {
this.register('open', this.open.bind(this));
this.register('render', this.render.bind(this));
super.listen();
}
}
// @ts-expect-error how could we get correct postMessage signature for worker, exclude `window.postMessage`
new PDFRendererBackend(self).listen();
new PDFRendererBackend(self as MessageCommunicapable);

View File

@@ -52,10 +52,7 @@ export const useEditor = (
// set sync engine priority target
useEffect(() => {
currentWorkspace.engine.doc.setPriority(pageId, 10);
return () => {
currentWorkspace.engine.doc.setPriority(pageId, 5);
};
return currentWorkspace.engine.doc.addPriority(pageId, 10);
}, [currentWorkspace, pageId]);
return { doc, editor, workspace: currentWorkspace, loading: !docListReady };

View File

@@ -5,7 +5,7 @@ import {
catchErrorInto,
effect,
Entity,
exhaustMapSwitchUntilChanged,
exhaustMapWithTrailing,
fromPromise,
LiveData,
onComplete,
@@ -13,7 +13,7 @@ import {
} from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import bytes from 'bytes';
import { EMPTY, map, mergeMap } from 'rxjs';
import { EMPTY, mergeMap } from 'rxjs';
import { isBackendError, isNetworkError } from '../../cloud';
import type { WorkspaceService } from '../../workspace';
@@ -68,49 +68,40 @@ export class WorkspaceQuota extends Entity {
}
revalidate = effect(
map(() => ({
workspaceId: this.workspaceService.workspace.id,
})),
exhaustMapSwitchUntilChanged(
(a, b) => a.workspaceId === b.workspaceId,
({ workspaceId }) => {
return fromPromise(async signal => {
if (!workspaceId) {
return; // no quota if no workspace
}
const data = await this.store.fetchWorkspaceQuota(
this.workspaceService.workspace.id,
signal
);
return { quota: data, used: data.usedSize };
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
count: 3,
}),
mergeMap(data => {
if (data) {
const { quota, used } = data;
this.quota$.next(quota);
this.used$.next(used);
} else {
this.quota$.next(null);
this.used$.next(null);
}
return EMPTY;
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch workspace quota', error);
}),
onStart(() => this.isRevalidating$.setValue(true)),
onComplete(() => this.isRevalidating$.setValue(false))
exhaustMapWithTrailing(() => {
return fromPromise(async signal => {
const data = await this.store.fetchWorkspaceQuota(
this.workspaceService.workspace.id,
signal
);
}
)
return { quota: data, used: data.usedSize };
}).pipe(
backoffRetry({
when: isNetworkError,
count: Infinity,
}),
backoffRetry({
when: isBackendError,
count: 3,
}),
mergeMap(data => {
if (data) {
const { quota, used } = data;
this.quota$.next(quota);
this.used$.next(used);
} else {
this.quota$.next(null);
this.used$.next(null);
}
return EMPTY;
}),
catchErrorInto(this.error$, error => {
logger.error('Failed to fetch workspace quota', error);
}),
onStart(() => this.isRevalidating$.setValue(true)),
onComplete(() => this.isRevalidating$.setValue(false))
);
})
);
waitForRevalidation(signal?: AbortSignal) {

View File

@@ -1,76 +0,0 @@
import { UserFriendlyError } from '@affine/graphql';
import type { DocMode } from '@blocksuite/affine/blocks';
import {
effect,
Entity,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { catchError, EMPTY, mergeMap, switchMap } from 'rxjs';
import type { ShareReaderStore } from '../stores/share-reader';
export class ShareReader extends Entity {
isLoading$ = new LiveData<boolean>(false);
error$ = new LiveData<UserFriendlyError | null>(null);
data$ = new LiveData<{
workspaceId: string;
docId: string;
workspaceBinary: Uint8Array;
docBinary: Uint8Array;
// Used for old share server-side mode control
publishMode?: DocMode;
} | null>(null);
constructor(private readonly store: ShareReaderStore) {
super();
}
loadShare = effect(
switchMap(
({
serverId,
workspaceId,
docId,
}: {
serverId: string;
workspaceId: string;
docId: string;
}) => {
return fromPromise(
this.store.loadShare(serverId, workspaceId, docId)
).pipe(
mergeMap(data => {
if (!data) {
this.data$.next(null);
} else {
this.data$.next({
workspaceId,
docId,
workspaceBinary: data.workspace,
docBinary: data.doc,
publishMode: data.publishMode ?? undefined,
});
}
return EMPTY;
}),
catchError((error: any) => {
this.error$.next(UserFriendlyError.fromAnyError(error));
return EMPTY;
}),
onStart(() => {
this.isLoading$.next(true);
this.data$.next(null);
this.error$.next(null);
}),
onComplete(() => {
this.isLoading$.next(false);
})
);
}
)
);
}

View File

@@ -1,11 +1,9 @@
export type { ShareReader } from './entities/share-reader';
export { ShareDocsListService } from './services/share-docs-list';
export { ShareInfoService } from './services/share-info';
export { ShareReaderService } from './services/share-reader';
import { type Framework } from '@toeverything/infra';
import { ServersService, WorkspaceServerService } from '../cloud';
import { WorkspaceServerService } from '../cloud';
import { DocScope, DocService } from '../doc';
import {
WorkspaceLocalCache,
@@ -14,19 +12,13 @@ import {
} from '../workspace';
import { ShareDocsList } from './entities/share-docs-list';
import { ShareInfo } from './entities/share-info';
import { ShareReader } from './entities/share-reader';
import { ShareDocsListService } from './services/share-docs-list';
import { ShareInfoService } from './services/share-info';
import { ShareReaderService } from './services/share-reader';
import { ShareStore } from './stores/share';
import { ShareDocsStore } from './stores/share-docs';
import { ShareReaderStore } from './stores/share-reader';
export function configureShareDocsModule(framework: Framework) {
framework
.service(ShareReaderService)
.entity(ShareReader, [ShareReaderStore])
.store(ShareReaderStore, [ServersService])
.scope(WorkspaceScope)
.service(ShareDocsListService, [WorkspaceService])
.store(ShareDocsStore, [WorkspaceServerService])

View File

@@ -1,7 +0,0 @@
import { Service } from '@toeverything/infra';
import { ShareReader } from '../entities/share-reader';
export class ShareReaderService extends Service {
reader = this.framework.createEntity(ShareReader);
}

View File

@@ -1,48 +0,0 @@
import { ErrorNames, UserFriendlyError } from '@affine/graphql';
import type { DocMode } from '@blocksuite/affine/blocks';
import { Store } from '@toeverything/infra';
import type { ServersService } from '../../cloud';
import { isBackendError } from '../../cloud';
export class ShareReaderStore extends Store {
constructor(private readonly serversService: ServersService) {
super();
}
async loadShare(serverId: string, workspaceId: string, docId: string) {
const server = this.serversService.server$(serverId).value;
if (!server) {
throw new Error(`Server ${serverId} not found`);
}
try {
const docResponse = await server.fetch(
`/api/workspaces/${workspaceId}/docs/${docId}`
);
const publishMode = docResponse.headers.get(
'publish-mode'
) as DocMode | null;
const docBinary = await docResponse.arrayBuffer();
const workspaceResponse = await server.fetch(
`/api/workspaces/${workspaceId}/docs/${workspaceId}`
);
const workspaceBinary = await workspaceResponse.arrayBuffer();
return {
doc: new Uint8Array(docBinary),
workspace: new Uint8Array(workspaceBinary),
publishMode,
};
} catch (error) {
if (
error instanceof Error &&
isBackendError(error) &&
UserFriendlyError.fromAnyError(error).name === ErrorNames.ACCESS_DENIED
) {
return null;
}
throw error;
}
}
}

View File

@@ -3,11 +3,13 @@ export {
GlobalSessionState,
GlobalState,
} from './providers/global';
export { NbstoreProvider } from './providers/nbstore';
export {
GlobalCacheService,
GlobalSessionStateService,
GlobalStateService,
} from './services/global';
export { NbstoreService } from './services/nbstore';
import { type Framework } from '@toeverything/infra';
@@ -23,16 +25,19 @@ import {
GlobalSessionState,
GlobalState,
} from './providers/global';
import { NbstoreProvider } from './providers/nbstore';
import {
GlobalCacheService,
GlobalSessionStateService,
GlobalStateService,
} from './services/global';
import { NbstoreService } from './services/nbstore';
export const configureGlobalStorageModule = (framework: Framework) => {
export const configureStorageModule = (framework: Framework) => {
framework.service(GlobalStateService, [GlobalState]);
framework.service(GlobalCacheService, [GlobalCache]);
framework.service(GlobalSessionStateService, [GlobalSessionState]);
framework.service(NbstoreService, [NbstoreProvider]);
};
export function configureLocalStorageStateStorageImpls(framework: Framework) {

View File

@@ -0,0 +1,26 @@
import type {
WorkerClient,
WorkerInitOptions,
} from '@affine/nbstore/worker/client';
import { createIdentifier } from '@toeverything/infra';
export interface NbstoreProvider {
/**
* Open a nbstore with the given options, if the store with the given key already exists, it will be returned.
*
* in environment with SharedWorker support, the store also can be shared with other tabs/windows.
*
* @param key - the key of the store, can used to share the store with other tabs/windows.
* @param options - the options to open the store.
*/
openStore(
key: string,
options: WorkerInitOptions
): {
store: WorkerClient;
dispose: () => void;
};
}
export const NbstoreProvider =
createIdentifier<NbstoreProvider>('NbstoreProvider');

View File

@@ -0,0 +1,14 @@
import type { WorkerInitOptions } from '@affine/nbstore/worker/client';
import { Service } from '@toeverything/infra';
import type { NbstoreProvider } from '../providers/nbstore';
export class NbstoreService extends Service {
constructor(private readonly nbstoreProvider: NbstoreProvider) {
super();
}
openStore(key: string, options: WorkerInitOptions) {
return this.nbstoreProvider.openStore(key, options);
}
}

View File

@@ -1,17 +1,21 @@
import { DocEngine, Entity } from '@toeverything/infra';
import { IndexedDBDocStorage } from '@affine/nbstore/idb';
import { SqliteDocStorage } from '@affine/nbstore/sqlite';
import type { WorkerClient } from '@affine/nbstore/worker/client';
import { Entity } from '@toeverything/infra';
import type { WebSocketService } from '../../cloud';
import { UserDBDocServer } from '../impls/user-db-doc-server';
import type { UserspaceStorageProvider } from '../provider/storage';
import type { ServerService } from '../../cloud';
import type { NbstoreService } from '../../storage';
export class UserDBEngine extends Entity<{
userId: string;
}> {
private readonly userId = this.props.userId;
readonly docEngine = new DocEngine(
this.userspaceStorageProvider.getDocStorage('affine-cloud:' + this.userId),
new UserDBDocServer(this.userId, this.websocketService)
);
readonly client: WorkerClient;
DocStorageType =
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
? SqliteDocStorage
: IndexedDBDocStorage;
canGracefulStop() {
// TODO(@eyhn): Implement this
@@ -19,14 +23,40 @@ export class UserDBEngine extends Entity<{
}
constructor(
private readonly userspaceStorageProvider: UserspaceStorageProvider,
private readonly websocketService: WebSocketService
private readonly nbstoreService: NbstoreService,
serverService: ServerService
) {
super();
this.docEngine.start();
}
override dispose() {
this.docEngine.stop();
const { store, dispose } = this.nbstoreService.openStore(
`userspace:${serverService.server.id},${this.userId}`,
{
local: {
doc: {
name: this.DocStorageType.identifier,
opts: {
id: `${serverService.server.id}:` + this.userId,
flavour: serverService.server.id,
type: 'userspace',
},
},
},
remotes: {
cloud: {
doc: {
name: 'CloudDocStorage',
opts: {
id: this.userId,
serverBaseUrl: serverService.server.baseUrl,
type: 'userspace',
},
},
},
},
}
);
this.client = store;
this.client.docFrontend.start();
this.disposables.push(() => dispose());
}
}

View File

@@ -1,8 +1,9 @@
import type { DocFrontendDocState } from '@affine/nbstore';
import type {
Table as OrmTable,
TableSchemaBuilder,
} from '@toeverything/infra';
import { Entity } from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import type { UserDBEngine } from './user-db-engine';
@@ -12,15 +13,16 @@ export class UserDBTable<Schema extends TableSchemaBuilder> extends Entity<{
engine: UserDBEngine;
}> {
readonly table = this.props.table;
readonly docEngine = this.props.engine.docEngine;
readonly docFrontend = this.props.engine.client.docFrontend;
isSyncing$ = this.docEngine
.docState$(this.props.storageDocId)
.map(docState => docState.syncing);
docSyncState$ = LiveData.from<DocFrontendDocState>(
this.docFrontend.docState$(this.props.storageDocId),
null as any
);
isLoading$ = this.docEngine
.docState$(this.props.storageDocId)
.map(docState => docState.loading);
isSyncing$ = this.docSyncState$.map(docState => docState.syncing);
isLoaded$ = this.docSyncState$.map(docState => docState.loaded);
create: typeof this.table.create = this.table.create.bind(this.table);
update: typeof this.table.update = this.table.update.bind(this.table);

View File

@@ -19,8 +19,8 @@ export class UserDB extends Entity<{
const ydoc = new YDoc({
guid,
});
this.engine.docEngine.addDoc(ydoc, false);
this.engine.docEngine.setPriority(ydoc.guid, 50);
this.engine.client.docFrontend.connectDoc(ydoc);
this.engine.client.docFrontend.addPriority(ydoc.guid, 50);
return ydoc;
},
})

View File

@@ -1,266 +0,0 @@
import type {
ByteKV,
ByteKVBehavior,
DocEvent,
DocEventBus,
DocStorage,
} from '@toeverything/infra';
import type { DBSchema, IDBPDatabase, IDBPObjectStore } from 'idb';
import { openDB } from 'idb';
import { mergeUpdates } from 'yjs';
class BroadcastChannelDocEventBus implements DocEventBus {
senderChannel = new BroadcastChannel('user-db:' + this.userId);
constructor(private readonly userId: string) {}
emit(event: DocEvent): void {
this.senderChannel.postMessage(event);
}
on(cb: (event: DocEvent) => void): () => void {
const listener = (event: MessageEvent<DocEvent>) => {
cb(event.data);
};
const channel = new BroadcastChannel('user-db:' + this.userId);
channel.addEventListener('message', listener);
return () => {
channel.removeEventListener('message', listener);
channel.close();
};
}
}
function isEmptyUpdate(binary: Uint8Array) {
return (
binary.byteLength === 0 ||
(binary.byteLength === 2 && binary[0] === 0 && binary[1] === 0)
);
}
export class IndexedDBUserspaceDocStorage implements DocStorage {
constructor(private readonly userId: string) {}
eventBus = new BroadcastChannelDocEventBus(this.userId);
readonly doc = new Doc(this.userId);
readonly syncMetadata = new KV(`affine-cloud:${this.userId}:sync-metadata`);
readonly serverClock = new KV(`affine-cloud:${this.userId}:server-clock`);
}
interface DocDBSchema extends DBSchema {
userspace: {
key: string;
value: {
id: string;
updates: {
timestamp: number;
update: Uint8Array;
}[];
};
};
}
type DocType = DocStorage['doc'];
class Doc implements DocType {
dbName = 'affine-cloud:' + this.userId + ':doc';
dbPromise: Promise<IDBPDatabase<DocDBSchema>> | null = null;
dbVersion = 1;
constructor(private readonly userId: string) {}
upgradeDB(db: IDBPDatabase<DocDBSchema>) {
db.createObjectStore('userspace', { keyPath: 'id' });
}
getDb() {
if (this.dbPromise === null) {
this.dbPromise = openDB<DocDBSchema>(this.dbName, this.dbVersion, {
upgrade: db => this.upgradeDB(db),
});
}
return this.dbPromise;
}
async get(docId: string): Promise<Uint8Array | null> {
const db = await this.getDb();
const store = db
.transaction('userspace', 'readonly')
.objectStore('userspace');
const data = await store.get(docId);
if (!data) {
return null;
}
const updates = data.updates
.map(({ update }) => update)
.filter(update => !isEmptyUpdate(update));
const update = updates.length > 0 ? mergeUpdates(updates) : null;
return update;
}
async set(docId: string, data: Uint8Array) {
const db = await this.getDb();
const store = db
.transaction('userspace', 'readwrite')
.objectStore('userspace');
const rows = [{ timestamp: Date.now(), update: data }];
await store.put({
id: docId,
updates: rows,
});
}
async keys() {
const db = await this.getDb();
const store = db
.transaction('userspace', 'readonly')
.objectStore('userspace');
return store.getAllKeys();
}
clear(): void | Promise<void> {
return;
}
del(_key: string): void | Promise<void> {
return;
}
async transaction<T>(
cb: (transaction: ByteKVBehavior) => Promise<T>
): Promise<T> {
const db = await this.getDb();
const store = db
.transaction('userspace', 'readwrite')
.objectStore('userspace');
return await cb({
async get(docId) {
const data = await store.get(docId);
if (!data) {
return null;
}
const { updates } = data;
const update = mergeUpdates(updates.map(({ update }) => update));
return update;
},
keys() {
return store.getAllKeys();
},
async set(docId, data) {
const rows = [{ timestamp: Date.now(), update: data }];
await store.put({
id: docId,
updates: rows,
});
},
async clear() {
return await store.clear();
},
async del(key) {
return store.delete(key);
},
});
}
}
interface KvDBSchema extends DBSchema {
kv: {
key: string;
value: { key: string; val: Uint8Array };
};
}
class KV implements ByteKV {
constructor(private readonly dbName: string) {}
dbPromise: Promise<IDBPDatabase<KvDBSchema>> | null = null;
dbVersion = 1;
upgradeDB(db: IDBPDatabase<KvDBSchema>) {
db.createObjectStore('kv', { keyPath: 'key' });
}
getDb() {
if (this.dbPromise === null) {
this.dbPromise = openDB<KvDBSchema>(this.dbName, this.dbVersion, {
upgrade: db => this.upgradeDB(db),
});
}
return this.dbPromise;
}
async transaction<T>(
cb: (transaction: ByteKVBehavior) => Promise<T>
): Promise<T> {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
const behavior = new KVBehavior(store);
return await cb(behavior);
}
async get(key: string): Promise<Uint8Array | null> {
const db = await this.getDb();
const store = db.transaction('kv', 'readonly').objectStore('kv');
return new KVBehavior(store).get(key);
}
async set(key: string, value: Uint8Array): Promise<void> {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
return new KVBehavior(store).set(key, value);
}
async keys(): Promise<string[]> {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
return new KVBehavior(store).keys();
}
async clear() {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
return new KVBehavior(store).clear();
}
async del(key: string) {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
return new KVBehavior(store).del(key);
}
}
class KVBehavior implements ByteKVBehavior {
constructor(
private readonly store: IDBPObjectStore<KvDBSchema, ['kv'], 'kv', any>
) {}
async get(key: string): Promise<Uint8Array | null> {
const value = await this.store.get(key);
return value?.val ?? null;
}
async set(key: string, value: Uint8Array): Promise<void> {
if (this.store.put === undefined) {
throw new Error('Cannot set in a readonly transaction');
}
await this.store.put({
key: key,
val: value,
});
}
async keys(): Promise<string[]> {
return await this.store.getAllKeys();
}
async del(key: string) {
if (this.store.delete === undefined) {
throw new Error('Cannot set in a readonly transaction');
}
return await this.store.delete(key);
}
async clear() {
if (this.store.clear === undefined) {
throw new Error('Cannot set in a readonly transaction');
}
return await this.store.clear();
}
}

View File

@@ -1,159 +0,0 @@
import type {
ByteKV,
ByteKVBehavior,
DocEvent,
DocEventBus,
DocStorage,
} from '@toeverything/infra';
import { AsyncLock } from '@toeverything/infra';
import type { DesktopApiService } from '../../desktop-api';
class BroadcastChannelDocEventBus implements DocEventBus {
senderChannel = new BroadcastChannel('user-db:' + this.userId);
constructor(private readonly userId: string) {}
emit(event: DocEvent): void {
this.senderChannel.postMessage(event);
}
on(cb: (event: DocEvent) => void): () => void {
const listener = (event: MessageEvent<DocEvent>) => {
cb(event.data);
};
const channel = new BroadcastChannel('user-db:' + this.userId);
channel.addEventListener('message', listener);
return () => {
channel.removeEventListener('message', listener);
channel.close();
};
}
}
export class SqliteUserspaceDocStorage implements DocStorage {
constructor(
private readonly userId: string,
private readonly electronApi: DesktopApiService
) {}
eventBus = new BroadcastChannelDocEventBus(this.userId);
readonly doc = new Doc(this.userId, this.electronApi);
readonly syncMetadata = new SyncMetadataKV(this.userId, this.electronApi);
readonly serverClock = new ServerClockKV(this.userId, this.electronApi);
}
type DocType = DocStorage['doc'];
class Doc implements DocType {
lock = new AsyncLock();
apis = this.electronApi.api.handler;
constructor(
private readonly userId: string,
private readonly electronApi: DesktopApiService
) {}
async transaction<T>(
cb: (transaction: ByteKVBehavior) => Promise<T>
): Promise<T> {
using _lock = await this.lock.acquire();
return await cb(this);
}
keys(): string[] | Promise<string[]> {
return [];
}
async get(docId: string) {
const update = await this.apis.db.getDocAsUpdates(
'userspace',
this.userId,
docId
);
if (update) {
if (
update.byteLength === 0 ||
(update.byteLength === 2 && update[0] === 0 && update[1] === 0)
) {
return null;
}
return update;
}
return null;
}
async set(docId: string, data: Uint8Array) {
await this.apis.db.applyDocUpdate('userspace', this.userId, data, docId);
}
clear(): void | Promise<void> {
return;
}
async del(docId: string) {
await this.apis.db.deleteDoc('userspace', this.userId, docId);
}
}
class SyncMetadataKV implements ByteKV {
apis = this.electronApi.api.handler;
constructor(
private readonly userId: string,
private readonly electronApi: DesktopApiService
) {}
transaction<T>(cb: (behavior: ByteKVBehavior) => Promise<T>): Promise<T> {
return cb(this);
}
get(key: string): Uint8Array | null | Promise<Uint8Array | null> {
return this.apis.db.getSyncMetadata('userspace', this.userId, key);
}
set(key: string, data: Uint8Array): void | Promise<void> {
return this.apis.db.setSyncMetadata('userspace', this.userId, key, data);
}
keys(): string[] | Promise<string[]> {
return this.apis.db.getSyncMetadataKeys('userspace', this.userId);
}
del(key: string): void | Promise<void> {
return this.apis.db.delSyncMetadata('userspace', this.userId, key);
}
clear(): void | Promise<void> {
return this.apis.db.clearSyncMetadata('userspace', this.userId);
}
}
class ServerClockKV implements ByteKV {
apis = this.electronApi.api.handler;
constructor(
private readonly userId: string,
private readonly electronApi: DesktopApiService
) {}
transaction<T>(cb: (behavior: ByteKVBehavior) => Promise<T>): Promise<T> {
return cb(this);
}
get(key: string): Uint8Array | null | Promise<Uint8Array | null> {
return this.apis.db.getServerClock('userspace', this.userId, key);
}
set(key: string, data: Uint8Array): void | Promise<void> {
return this.apis.db.setServerClock('userspace', this.userId, key, data);
}
keys(): string[] | Promise<string[]> {
return this.apis.db.getServerClockKeys('userspace', this.userId);
}
del(key: string): void | Promise<void> {
return this.apis.db.delServerClock('userspace', this.userId, key);
}
clear(): void | Promise<void> {
return this.apis.db.clearServerClock('userspace', this.userId);
}
}

View File

@@ -1,202 +0,0 @@
import { DebugLogger } from '@affine/debug';
import {
ErrorNames,
UserFriendlyError,
type UserFriendlyErrorResponse,
} from '@affine/graphql';
import { type DocServer, throwIfAborted } from '@toeverything/infra';
import type { Socket } from 'socket.io-client';
import type { WebSocketService } from '../../cloud';
import {
base64ToUint8Array,
uint8ArrayToBase64,
} from '../../workspace-engine/utils/base64';
type WebsocketResponse<T> = { error: UserFriendlyErrorResponse } | { data: T };
const logger = new DebugLogger('affine-cloud-doc-engine-server');
export class UserDBDocServer implements DocServer {
interruptCb: ((reason: string) => void) | null = null;
SEND_TIMEOUT = 30000;
socket: Socket;
disposeSocket: () => void;
constructor(
private readonly userId: string,
webSocketService: WebSocketService
) {
const { socket, dispose } = webSocketService.connect();
this.socket = socket;
this.disposeSocket = dispose;
}
private async clientHandShake() {
await this.socket.emitWithAck('space:join', {
spaceType: 'userspace',
spaceId: this.userId,
clientVersion: BUILD_CONFIG.appVersion,
});
}
async pullDoc(docId: string, state: Uint8Array) {
// for testing
await (window as any)._TEST_SIMULATE_SYNC_LAG;
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
const response: WebsocketResponse<{
missing: string;
state: string;
timestamp: number;
}> = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('space:load-doc', {
spaceType: 'userspace',
spaceId: this.userId,
docId: docId,
stateVector,
});
if ('error' in response) {
const error = new UserFriendlyError(response.error);
if (error.name === ErrorNames.DOC_NOT_FOUND) {
return null;
} else {
throw error;
}
} else {
return {
data: base64ToUint8Array(response.data.missing),
stateVector: response.data.state
? base64ToUint8Array(response.data.state)
: undefined,
serverClock: response.data.timestamp,
};
}
}
async pushDoc(docId: string, data: Uint8Array) {
const payload = await uint8ArrayToBase64(data);
const response: WebsocketResponse<{ timestamp: number }> = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('space:push-doc-updates', {
spaceType: 'userspace',
spaceId: this.userId,
docId: docId,
updates: [payload],
});
if ('error' in response) {
logger.error('client-update-v2 error', {
userId: this.userId,
guid: docId,
response,
});
throw new UserFriendlyError(response.error);
}
return { serverClock: response.data.timestamp };
}
async loadServerClock(after: number): Promise<Map<string, number>> {
const response: WebsocketResponse<Record<string, number>> =
await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('space:load-doc-timestamps', {
spaceType: 'userspace',
spaceId: this.userId,
timestamp: after,
});
if ('error' in response) {
logger.error('client-pre-sync error', {
workspaceId: this.userId,
response,
});
throw new UserFriendlyError(response.error);
}
return new Map(Object.entries(response.data));
}
async subscribeAllDocs(
cb: (updates: {
docId: string;
data: Uint8Array;
serverClock: number;
}) => void
): Promise<() => void> {
const handleUpdate = async (message: {
spaceType: string;
spaceId: string;
docId: string;
updates: string[];
timestamp: number;
}) => {
if (
message.spaceType === 'userspace' &&
message.spaceId === this.userId
) {
message.updates.forEach(update => {
cb({
docId: message.docId,
data: base64ToUint8Array(update),
serverClock: message.timestamp,
});
});
}
};
this.socket.on('space:broadcast-doc-updates', handleUpdate);
return () => {
this.socket.off('space:broadcast-doc-updates', handleUpdate);
};
}
async waitForConnectingServer(signal: AbortSignal): Promise<void> {
this.socket.on('server-version-rejected', this.handleVersionRejected);
this.socket.on('disconnect', this.handleDisconnect);
throwIfAborted(signal);
if (this.socket.connected) {
await this.clientHandShake();
} else {
await new Promise<void>((resolve, reject) => {
this.socket.on('connect', () => {
resolve();
});
signal.addEventListener('abort', () => {
reject('aborted');
});
});
throwIfAborted(signal);
await this.clientHandShake();
}
}
disconnectServer(): void {
this.socket.emit('space:leave', {
spaceType: 'userspace',
spaceId: this.userId,
});
this.socket.off('server-version-rejected', this.handleVersionRejected);
this.socket.off('disconnect', this.handleDisconnect);
}
onInterrupted = (cb: (reason: string) => void) => {
this.interruptCb = cb;
};
handleInterrupted = (reason: string) => {
this.interruptCb?.(reason);
};
handleDisconnect = (reason: Socket.DisconnectReason) => {
this.interruptCb?.(reason);
};
handleVersionRejected = () => {
this.interruptCb?.('Client version rejected');
};
dispose(): void {
this.disconnectServer();
this.disposeSocket();
}
}

View File

@@ -2,16 +2,13 @@ export { UserspaceService as UserDBService } from './services/userspace';
import type { Framework } from '@toeverything/infra';
import { AuthService, WebSocketService } from '../cloud';
import { AuthService, ServerService } from '../cloud';
import { ServerScope } from '../cloud/scopes/server';
import { DesktopApiService } from '../desktop-api/service/desktop-api';
import { NbstoreService } from '../storage';
import { CurrentUserDB } from './entities/current-user-db';
import { UserDB } from './entities/user-db';
import { UserDBEngine } from './entities/user-db-engine';
import { UserDBTable } from './entities/user-db-table';
import { IndexedDBUserspaceDocStorage } from './impls/indexeddb-storage';
import { SqliteUserspaceDocStorage } from './impls/sqlite-storage';
import { UserspaceStorageProvider } from './provider/storage';
import { UserspaceService } from './services/userspace';
export function configureUserspaceModule(framework: Framework) {
@@ -21,23 +18,5 @@ export function configureUserspaceModule(framework: Framework) {
.entity(CurrentUserDB, [UserspaceService, AuthService])
.entity(UserDB)
.entity(UserDBTable)
.entity(UserDBEngine, [UserspaceStorageProvider, WebSocketService]);
}
export function configureIndexedDBUserspaceStorageProvider(
framework: Framework
) {
framework.impl(UserspaceStorageProvider, {
getDocStorage(userId: string) {
return new IndexedDBUserspaceDocStorage(userId);
},
});
}
export function configureSqliteUserspaceStorageProvider(framework: Framework) {
framework.impl(UserspaceStorageProvider, p => ({
getDocStorage(userId: string) {
return new SqliteUserspaceDocStorage(userId, p.get(DesktopApiService));
},
}));
.entity(UserDBEngine, [NbstoreService, ServerService]);
}

View File

@@ -1,8 +0,0 @@
import { createIdentifier, type DocStorage } from '@toeverything/infra';
export interface UserspaceStorageProvider {
getDocStorage(userId: string): DocStorage;
}
export const UserspaceStorageProvider =
createIdentifier<UserspaceStorageProvider>('UserspaceStorageProvider');

View File

@@ -5,10 +5,29 @@ import {
getWorkspaceInfoQuery,
getWorkspacesQuery,
} from '@affine/graphql';
import type { BlobStorage, DocStorage } from '@affine/nbstore';
import { CloudBlobStorage, StaticCloudDocStorage } from '@affine/nbstore/cloud';
import {
IndexedDBBlobStorage,
IndexedDBDocStorage,
IndexedDBSyncStorage,
} from '@affine/nbstore/idb';
import {
IndexedDBV1BlobStorage,
IndexedDBV1DocStorage,
} from '@affine/nbstore/idb/v1';
import {
SqliteBlobStorage,
SqliteDocStorage,
SqliteSyncStorage,
} from '@affine/nbstore/sqlite';
import {
SqliteV1BlobStorage,
SqliteV1DocStorage,
} from '@affine/nbstore/sqlite/v1';
import type { WorkerInitOptions } from '@affine/nbstore/worker/client';
import {
type BlobStorage,
catchErrorInto,
type DocStorage,
effect,
exhaustMapSwitchUntilChanged,
fromPromise,
@@ -20,35 +39,25 @@ import {
} from '@toeverything/infra';
import { isEqual } from 'lodash-es';
import { EMPTY, map, mergeMap, Observable, switchMap } from 'rxjs';
import { encodeStateAsUpdate } from 'yjs';
import { type Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import type { Server, ServersService } from '../../cloud';
import {
AccountChanged,
AuthService,
FetchService,
GraphQLService,
WebSocketService,
WorkspaceServerService,
} from '../../cloud';
import type { GlobalState } from '../../storage';
import {
getAFFiNEWorkspaceSchema,
type Workspace,
type WorkspaceEngineProvider,
type WorkspaceFlavourProvider,
type WorkspaceFlavoursProvider,
type WorkspaceMetadata,
type WorkspaceProfileInfo,
} from '../../workspace';
import { WorkspaceImpl } from '../../workspace/impls/workspace';
import type { WorkspaceEngineStorageProvider } from '../providers/engine';
import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel';
import { CloudAwarenessConnection } from './engine/awareness-cloud';
import { CloudBlobStorage } from './engine/blob-cloud';
import { StaticBlobStorage } from './engine/blob-static';
import { CloudDocEngineServer } from './engine/doc-cloud';
import { CloudStaticDocStorage } from './engine/doc-cloud-static';
import { getWorkspaceProfileWorker } from './out-worker';
const getCloudWorkspaceCacheKey = (serverId: string) => {
@@ -62,20 +71,14 @@ const logger = new DebugLogger('affine:cloud-workspace-flavour-provider');
class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
private readonly authService: AuthService;
private readonly webSocketService: WebSocketService;
private readonly fetchService: FetchService;
private readonly graphqlService: GraphQLService;
private readonly unsubscribeAccountChanged: () => void;
constructor(
private readonly globalState: GlobalState,
private readonly storageProvider: WorkspaceEngineStorageProvider,
private readonly server: Server
) {
this.authService = server.scope.get(AuthService);
this.webSocketService = server.scope.get(WebSocketService);
this.fetchService = server.scope.get(FetchService);
this.graphqlService = server.scope.get(GraphQLService);
this.unsubscribeAccountChanged = this.server.scope.eventBus.on(
AccountChanged,
@@ -85,7 +88,30 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
);
}
flavour = this.server.id;
readonly flavour = this.server.id;
DocStorageType =
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
? SqliteDocStorage
: IndexedDBDocStorage;
DocStorageV1Type = BUILD_CONFIG.isElectron
? SqliteV1DocStorage
: BUILD_CONFIG.isWeb || BUILD_CONFIG.isMobileWeb
? IndexedDBV1DocStorage
: undefined;
BlobStorageType =
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
? SqliteBlobStorage
: IndexedDBBlobStorage;
BlobStorageV1Type = BUILD_CONFIG.isElectron
? SqliteV1BlobStorage
: BUILD_CONFIG.isWeb || BUILD_CONFIG.isMobileWeb
? IndexedDBV1BlobStorage
: undefined;
SyncStorageType =
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
? SqliteSyncStorage
: IndexedDBSyncStorage;
async deleteWorkspace(id: string): Promise<void> {
await this.graphqlService.gql({
@@ -113,13 +139,51 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
});
// save the initial state to local storage, then sync to cloud
const blobStorage = this.storageProvider.getBlobStorage(workspaceId);
const docStorage = this.storageProvider.getDocStorage(workspaceId);
const blobStorage = new this.BlobStorageType({
id: workspaceId,
flavour: this.flavour,
type: 'workspace',
});
blobStorage.connection.connect();
await blobStorage.connection.waitForConnected();
const docStorage = new this.DocStorageType({
id: workspaceId,
flavour: this.flavour,
type: 'workspace',
});
docStorage.connection.connect();
await docStorage.connection.waitForConnected();
const docList = new Set<YDoc>();
const docCollection = new WorkspaceImpl({
id: workspaceId,
schema: getAFFiNEWorkspaceSchema(),
blobSource: blobStorage,
blobSource: {
get: async key => {
const record = await blobStorage.get(key);
return record ? new Blob([record.data], { type: record.mime }) : null;
},
delete: async () => {
return;
},
list: async () => {
return [];
},
set: async (id, blob) => {
await blobStorage.set({
key: id,
data: new Uint8Array(await blob.arrayBuffer()),
mime: blob.type,
});
return id;
},
name: 'blob',
readonly: false,
},
onLoadDoc: doc => {
docList.add(doc);
},
});
try {
@@ -127,14 +191,16 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
await initial(docCollection, blobStorage, docStorage);
// save workspace to local storage, should be vary fast
await docStorage.doc.set(
workspaceId,
encodeStateAsUpdate(docCollection.doc)
);
for (const subdocs of docCollection.doc.getSubdocs()) {
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
for (const subdocs of docList) {
await docStorage.pushDocUpdate({
docId: subdocs.guid,
bin: encodeStateAsUpdate(subdocs),
});
}
docStorage.connection.disconnect();
blobStorage.connection.disconnect();
this.revalidate();
await this.waitForLoaded();
} finally {
@@ -228,11 +294,23 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
// get information from both cloud and local storage
// we use affine 'static' storage here, which use http protocol, no need to websocket.
const cloudStorage = new CloudStaticDocStorage(id, this.fetchService);
const docStorage = this.storageProvider.getDocStorage(id);
const cloudStorage = new StaticCloudDocStorage({
id: id,
serverBaseUrl: this.server.serverMetadata.baseUrl,
});
const docStorage = new this.DocStorageType({
id: id,
flavour: this.flavour,
type: 'workspace',
readonlyMode: true,
});
docStorage.connection.connect();
await docStorage.connection.waitForConnected();
// download root doc
const localData = await docStorage.doc.get(id);
const cloudData = (await cloudStorage.pull(id))?.data;
const localData = (await docStorage.getDoc(id))?.bin;
const cloudData = (await cloudStorage.getDoc(id))?.bin;
docStorage.connection.disconnect();
const info = await this.getWorkspaceInfo(id, signal);
@@ -260,48 +338,27 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
};
}
async getWorkspaceBlob(id: string, blob: string): Promise<Blob | null> {
const localBlob = await this.storageProvider.getBlobStorage(id).get(blob);
const storage = new this.BlobStorageType({
id: id,
flavour: this.flavour,
type: 'workspace',
});
storage.connection.connect();
await storage.connection.waitForConnected();
const localBlob = await storage.get(blob);
if (localBlob) {
return localBlob;
return new Blob([localBlob.data], { type: localBlob.mime });
}
const cloudBlob = new CloudBlobStorage(
const cloudBlob = await new CloudBlobStorage({
id,
this.fetchService,
this.graphqlService
);
return await cloudBlob.get(blob);
}
getEngineProvider(workspaceId: string): WorkspaceEngineProvider {
return {
getAwarenessConnections: () => {
return [
new BroadcastChannelAwarenessConnection(workspaceId),
new CloudAwarenessConnection(workspaceId, this.webSocketService),
];
},
getDocServer: () => {
return new CloudDocEngineServer(workspaceId, this.webSocketService);
},
getDocStorage: () => {
return this.storageProvider.getDocStorage(workspaceId);
},
getLocalBlobStorage: () => {
return this.storageProvider.getBlobStorage(workspaceId);
},
getRemoteBlobStorages: () => {
return [
new CloudBlobStorage(
workspaceId,
this.fetchService,
this.graphqlService
),
new StaticBlobStorage(),
];
},
};
serverBaseUrl: this.server.serverMetadata.baseUrl,
}).get(blob);
if (!cloudBlob) {
return null;
}
return new Blob([cloudBlob.data], { type: cloudBlob.mime });
}
onWorkspaceInitialized(workspace: Workspace): void {
@@ -319,6 +376,90 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
});
}
getEngineWorkerInitOptions(workspaceId: string): WorkerInitOptions {
return {
local: {
doc: {
name: this.DocStorageType.identifier,
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
blob: {
name: this.BlobStorageType.identifier,
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
sync: {
name: this.SyncStorageType.identifier,
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
awareness: {
name: 'BroadcastChannelAwarenessStorage',
opts: {
id: `${this.flavour}:${workspaceId}`,
},
},
},
remotes: {
[`cloud:${this.flavour}`]: {
doc: {
name: 'CloudDocStorage',
opts: {
type: 'workspace',
id: workspaceId,
serverBaseUrl: this.server.serverMetadata.baseUrl,
},
},
blob: {
name: 'CloudBlobStorage',
opts: {
id: workspaceId,
serverBaseUrl: this.server.serverMetadata.baseUrl,
},
},
awareness: {
name: 'CloudAwarenessStorage',
opts: {
type: 'workspace',
id: workspaceId,
serverBaseUrl: this.server.serverMetadata.baseUrl,
},
},
},
v1: {
doc: this.DocStorageV1Type
? {
name: this.DocStorageV1Type.identifier,
opts: {
id: workspaceId,
type: 'workspace',
},
}
: undefined,
blob: this.BlobStorageV1Type
? {
name: this.BlobStorageV1Type.identifier,
opts: {
id: workspaceId,
type: 'workspace',
},
}
: undefined,
},
},
};
}
private waitForLoaded() {
return this.isRevalidating$.waitFor(loading => !loading);
}
@@ -335,7 +476,6 @@ export class CloudWorkspaceFlavoursProvider
{
constructor(
private readonly globalState: GlobalState,
private readonly storageProvider: WorkspaceEngineStorageProvider,
private readonly serversService: ServersService
) {
super();
@@ -351,7 +491,6 @@ export class CloudWorkspaceFlavoursProvider
}
const provider = new CloudWorkspaceFlavourProvider(
this.globalState,
this.storageProvider,
server
);
provider.revalidate();

View File

@@ -1,77 +0,0 @@
import type { AwarenessConnection } from '@toeverything/infra';
import type { Awareness } from 'y-protocols/awareness.js';
import {
applyAwarenessUpdate,
encodeAwarenessUpdate,
} from 'y-protocols/awareness.js';
type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>;
type ChannelMessage =
| { type: 'connect' }
| { type: 'update'; update: Uint8Array };
export class BroadcastChannelAwarenessConnection
implements AwarenessConnection
{
channel: BroadcastChannel | null = null;
awareness: Awareness | null = null;
constructor(private readonly workspaceId: string) {}
connect(awareness: Awareness): void {
this.awareness = awareness;
this.channel = new BroadcastChannel('awareness:' + this.workspaceId);
this.channel.postMessage({
type: 'connect',
} satisfies ChannelMessage);
this.awareness.on('update', this.handleAwarenessUpdate);
this.channel.addEventListener('message', this.handleChannelMessage);
}
disconnect(): void {
this.channel?.close();
this.channel = null;
this.awareness?.off('update', this.handleAwarenessUpdate);
this.awareness = null;
}
handleAwarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
if (this.awareness === null) {
return;
}
if (origin === 'remote') {
return;
}
const changedClients = Object.values(changes).reduce((res, cur) =>
res.concat(cur)
);
const update = encodeAwarenessUpdate(this.awareness, changedClients);
this.channel?.postMessage({
type: 'update',
update: update,
} satisfies ChannelMessage);
};
handleChannelMessage = (event: MessageEvent<ChannelMessage>) => {
if (this.awareness === null) {
return;
}
if (event.data.type === 'update') {
const update = event.data.update;
applyAwarenessUpdate(this.awareness, update, 'remote');
}
if (event.data.type === 'connect') {
this.channel?.postMessage({
type: 'update',
update: encodeAwarenessUpdate(this.awareness, [
this.awareness.clientID,
]),
} satisfies ChannelMessage);
}
};
}

View File

@@ -1,196 +0,0 @@
import type { WebSocketService } from '@affine/core/modules/cloud';
import { DebugLogger } from '@affine/debug';
import type { AwarenessConnection } from '@toeverything/infra';
import type { Socket } from 'socket.io-client';
import type { Awareness } from 'y-protocols/awareness';
import {
applyAwarenessUpdate,
encodeAwarenessUpdate,
removeAwarenessStates,
} from 'y-protocols/awareness';
import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
const logger = new DebugLogger('affine:awareness:socketio');
type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>;
export class CloudAwarenessConnection implements AwarenessConnection {
awareness: Awareness | null = null;
socket: Socket;
disposeSocket: () => void;
constructor(
private readonly workspaceId: string,
webSocketService: WebSocketService
) {
const { socket, dispose } = webSocketService.connect();
this.socket = socket;
this.disposeSocket = dispose;
}
connect(awareness: Awareness): void {
this.socket.on('space:broadcast-awareness-update', this.awarenessBroadcast);
this.socket.on(
'space:collect-awareness',
this.newClientAwarenessInitHandler
);
this.awareness = awareness;
this.awareness.on('update', this.awarenessUpdate);
window.addEventListener('beforeunload', this.windowBeforeUnloadHandler);
this.socket.on('connect', this.handleConnect);
this.socket.on('server-version-rejected', this.handleReject);
if (this.socket.connected) {
this.handleConnect();
}
}
disconnect(): void {
if (this.awareness) {
removeAwarenessStates(
this.awareness,
[this.awareness.clientID],
'disconnect'
);
this.awareness.off('update', this.awarenessUpdate);
}
this.awareness = null;
this.socket.emit('space:leave-awareness', {
spaceType: 'workspace',
spaceId: this.workspaceId,
docId: this.workspaceId,
});
this.socket.off(
'space:broadcast-awareness-update',
this.awarenessBroadcast
);
this.socket.off(
'space:collect-awareness',
this.newClientAwarenessInitHandler
);
this.socket.off('connect', this.handleConnect);
this.socket.off('server-version-rejected', this.handleReject);
window.removeEventListener('unload', this.windowBeforeUnloadHandler);
}
awarenessBroadcast = ({
spaceId: wsId,
spaceType,
awarenessUpdate,
}: {
spaceType: string;
spaceId: string;
docId: string;
awarenessUpdate: string;
}) => {
if (!this.awareness) {
return;
}
if (wsId !== this.workspaceId || spaceType !== 'workspace') {
return;
}
applyAwarenessUpdate(
this.awareness,
base64ToUint8Array(awarenessUpdate),
'remote'
);
};
awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
if (!this.awareness) {
return;
}
if (origin === 'remote') {
return;
}
const changedClients = Object.values(changes).reduce((res, cur) =>
res.concat(cur)
);
const update = encodeAwarenessUpdate(this.awareness, changedClients);
uint8ArrayToBase64(update)
.then(encodedUpdate => {
this.socket.emit('space:update-awareness', {
spaceType: 'workspace',
spaceId: this.workspaceId,
docId: this.workspaceId,
awarenessUpdate: encodedUpdate,
});
})
.catch(err => logger.error(err));
};
newClientAwarenessInitHandler = () => {
if (!this.awareness) {
return;
}
const awarenessUpdate = encodeAwarenessUpdate(this.awareness, [
this.awareness.clientID,
]);
uint8ArrayToBase64(awarenessUpdate)
.then(encodedAwarenessUpdate => {
this.socket.emit('space:update-awareness', {
spaceType: 'workspace',
spaceId: this.workspaceId,
docId: this.workspaceId,
awarenessUpdate: encodedAwarenessUpdate,
});
})
.catch(err => logger.error(err));
};
windowBeforeUnloadHandler = () => {
if (!this.awareness) {
return;
}
removeAwarenessStates(
this.awareness,
[this.awareness.clientID],
'window unload'
);
};
handleConnect = () => {
this.socket.emit(
'space:join-awareness',
{
spaceType: 'workspace',
spaceId: this.workspaceId,
docId: this.workspaceId,
clientVersion: BUILD_CONFIG.appVersion,
},
(res: any) => {
logger.debug('awareness handshake finished', res);
this.socket.emit(
'space:load-awarenesses',
{
spaceType: 'workspace',
spaceId: this.workspaceId,
docId: this.workspaceId,
},
(res: any) => {
logger.debug('awareness-init finished', res);
}
);
}
);
};
handleReject = () => {
this.socket.off('server-version-rejected', this.handleReject);
};
dispose() {
this.disconnect();
this.disposeSocket();
}
}

View File

@@ -1,87 +0,0 @@
import type { FetchService, GraphQLService } from '@affine/core/modules/cloud';
import {
deleteBlobMutation,
listBlobsQuery,
setBlobMutation,
UserFriendlyError,
} from '@affine/graphql';
import type { BlobStorage } from '@toeverything/infra';
import { BlobStorageOverCapacity } from '@toeverything/infra';
import { bufferToBlob } from '../../utils/buffer-to-blob';
export class CloudBlobStorage implements BlobStorage {
constructor(
private readonly workspaceId: string,
private readonly fetchService: FetchService,
private readonly gqlService: GraphQLService
) {}
name = 'cloud';
readonly = false;
async get(key: string) {
const suffix = key.startsWith('/')
? key
: `/api/workspaces/${this.workspaceId}/blobs/${key}`;
return this.fetchService
.fetch(suffix, {
cache: 'default',
headers: {
Accept: 'application/octet-stream', // this is necessary for ios native fetch to return arraybuffer
},
})
.then(async res => {
if (!res.ok) {
// status not in the range 200-299
return null;
}
return bufferToBlob(await res.arrayBuffer());
})
.catch(() => {
return null;
});
}
async set(key: string, value: Blob) {
// set blob will check blob size & quota
return await this.gqlService
.gql({
query: setBlobMutation,
variables: {
workspaceId: this.workspaceId,
blob: new File([value], key),
},
})
.then(res => res.setBlob)
.catch(err => {
const error = UserFriendlyError.fromAnyError(err);
if (error.status === 413) {
throw new BlobStorageOverCapacity(error);
}
throw err;
});
}
async delete(key: string) {
await this.gqlService.gql({
query: deleteBlobMutation,
variables: {
workspaceId: key,
key,
},
});
}
async list() {
const result = await this.gqlService.gql({
query: listBlobsQuery,
variables: {
workspaceId: this.workspaceId,
},
});
return result.workspace.blobs.map(blob => blob.key);
}
}

View File

@@ -1,33 +0,0 @@
import type { BlobStorage } from '@toeverything/infra';
import { createStore, del, get, keys, set } from 'idb-keyval';
import { bufferToBlob } from '../../utils/buffer-to-blob';
export class IndexedDBBlobStorage implements BlobStorage {
constructor(private readonly workspaceId: string) {}
name = 'indexeddb';
readonly = false;
db = createStore(`${this.workspaceId}_blob`, 'blob');
mimeTypeDb = createStore(`${this.workspaceId}_blob_mime`, 'blob_mime');
async get(key: string) {
const res = await get<ArrayBuffer>(key, this.db);
if (res) {
return bufferToBlob(res);
}
return null;
}
async set(key: string, value: Blob) {
await set(key, await value.arrayBuffer(), this.db);
await set(key, value.type, this.mimeTypeDb);
return key;
}
async delete(key: string) {
await del(key, this.db);
await del(key, this.mimeTypeDb);
}
async list() {
return keys<string>(this.db);
}
}

View File

@@ -1,46 +0,0 @@
import type { DesktopApiService } from '@affine/core/modules/desktop-api';
import type { BlobStorage } from '@toeverything/infra';
import { bufferToBlob } from '../../utils/buffer-to-blob';
export class SqliteBlobStorage implements BlobStorage {
constructor(
private readonly workspaceId: string,
private readonly electronApi: DesktopApiService
) {}
name = 'sqlite';
readonly = false;
async get(key: string) {
const buffer = await this.electronApi.handler.db.getBlob(
'workspace',
this.workspaceId,
key
);
if (buffer) {
return bufferToBlob(buffer);
}
return null;
}
async set(key: string, value: Blob) {
await this.electronApi.handler.db.addBlob(
'workspace',
this.workspaceId,
key,
new Uint8Array(await value.arrayBuffer())
);
return key;
}
delete(key: string) {
return this.electronApi.handler.db.deleteBlob(
'workspace',
this.workspaceId,
key
);
}
list() {
return this.electronApi.handler.db.getBlobKeys(
'workspace',
this.workspaceId
);
}
}

View File

@@ -1,71 +0,0 @@
import type { BlobStorage } from '@toeverything/infra';
export const predefinedStaticFiles = [
'029uztLz2CzJezK7UUhrbGiWUdZ0J7NVs_qR6RDsvb8=',
'047ebf2c9a5c7c9d8521c2ea5e6140ff7732ef9e28a9f944e9bf3ca4',
'0hjYqQd8SvwHT2gPds7qFw8W6qIEGVbZvG45uzoYjUU=',
'1326bc48553a572c6756d9ee1b30a0dfdda26222fc2d2c872b14e609',
'27f983d0765289c19d10ee0b51c00c3c7665236a1a82406370d46e0a',
'28516717d63e469cd98729ff46be6595711898bab3dc43302319a987',
'4HXJrnBZGaGPFpowNawNog0aMg3dgoVaAnNqEMeUxq0=',
'5Cfem_137WmzR35ZeIC76oTkq5SQt-eHlZwJiLy0hgU=',
'6aa785ee927547ce9dd9d7b43e01eac948337fe57571443e87bc3a60',
'8oj6ym4HlTcshT40Zn6D5DeOgaVCSOOXJvT_EyiqUw8=',
'9288be57321c8772d04e05dbb69a22742372b3534442607a2d6a9998',
'9vXwWGEX5W9v5pzwpu0eK4pf22DZ_sCloO0zCH1aVQ4=',
'Bd5F0WRI0fLh8RK1al9PawPVT3jv7VwBrqiiBEtdV-g=',
'CBWoKrhSDndjBJzscQKENRqiXOOZnzIA5qyiCoy4-A0=',
'D7g-4LMqOsVWBNOD-_kGgCOvJEoc8rcpYbkfDlF2u5U=',
'Vqc8rxFbGyc5L1QeE_Zr10XEcIai_0Xw4Qv6d3ldRPE=',
'VuXYyM9JUv1Fv_qjg1v5Go4Zksz0r4NXFeh3Na7JkIc=',
'bfXllFddegV9vvxPcSWnOtm-_tuzXm-0OQ59z9Su1zA=',
'c820edeeba50006b531883903f5bb0b96bf523c9a6b3ce5868f03db5',
'cw9XjQ-pCeSW7LKMzVREGHeCPTXWYbtE-QbZLEY3RrI=',
'e93536e1be97e3b5206d43bf0793fdef24e60044d174f0abdefebe08',
'f9yKnlNMgKhF-CxOgHBsXkxfViCCkC6KwTv6Uj2Fcjw=',
'fb0SNPtMpQlzBQ90_PB7vCu34WpiSUJbNKocFkL2vIo=',
'gZLmSgmwumNdgf0eIfOSW44emctrLyFUaZapbk8eZ6s=',
'i39ZQ24NlUfWI0MhkbtvHTzGnWMVdr-aC2aOjvHPVg4=',
'k07JiWnb-S7qgd9gDQNgqo-LYMe03RX8fR0TXQ-SpG4=',
'nSEEkYxrThpZfLoPNOzMp6HWekvutAIYmADElDe1J6I=',
'pIqdA3pM1la1gKzxOmAcpLmTh3yXBrL9mGTz_hGj5xE=',
'qezoK6du9n3PF4dl4aq5r7LeXz_sV3xOVpFzVVgjNsE=',
'rY96Bunn-69CnNe5X_e5CJLwgCJnN6rcbUisecs8kkQ=',
'sNVNYDBzUDN2J9OFVJdLJlryBLzRZBLl-4MTNoPF1tA=',
'uvpOG9DrldeqIGNaqfwjFdMw_CcfXKfiEjYf7RXdeL0=',
'v2yF7lY2L5rtorTtTmYFsoMb9dBPKs5M1y9cUKxcI1M=',
];
export class StaticBlobStorage implements BlobStorage {
name = 'static';
readonly = true;
async get(key: string) {
const isStaticResource =
predefinedStaticFiles.includes(key) || key.startsWith('/static/');
if (!isStaticResource) {
return null;
}
const path = key.startsWith('/static/') ? key : `/static/${key}`;
const response = await fetch(path);
if (response.ok) {
return await response.blob();
}
return null;
}
async set(key: string) {
// ignore
return key;
}
async delete() {
// ignore
}
async list() {
// ignore
return [];
}
}

View File

@@ -1,21 +0,0 @@
import type { DocEvent, DocEventBus } from '@toeverything/infra';
export class BroadcastChannelDocEventBus implements DocEventBus {
senderChannel = new BroadcastChannel('doc:' + this.workspaceId);
constructor(private readonly workspaceId: string) {}
emit(event: DocEvent): void {
this.senderChannel.postMessage(event);
}
on(cb: (event: DocEvent) => void): () => void {
const listener = (event: MessageEvent<DocEvent>) => {
cb(event.data);
};
const channel = new BroadcastChannel('doc:' + this.workspaceId);
channel.addEventListener('message', listener);
return () => {
channel.removeEventListener('message', listener);
channel.close();
};
}
}

View File

@@ -1,30 +0,0 @@
import type { FetchService } from '@affine/core/modules/cloud';
export class CloudStaticDocStorage {
name = 'cloud-static';
constructor(
private readonly workspaceId: string,
private readonly fetchService: FetchService
) {}
async pull(
docId: string
): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> {
const response = await this.fetchService.fetch(
`/api/workspaces/${this.workspaceId}/docs/${docId}`,
{
priority: 'high',
headers: {
Accept: 'application/octet-stream', // this is necessary for ios native fetch to return arraybuffer
},
}
);
if (response.ok) {
const arrayBuffer = await response.arrayBuffer();
return { data: new Uint8Array(arrayBuffer) };
}
return null;
}
}

View File

@@ -1,204 +0,0 @@
import type { WebSocketService } from '@affine/core/modules/cloud';
import { DebugLogger } from '@affine/debug';
import {
ErrorNames,
UserFriendlyError,
type UserFriendlyErrorResponse,
} from '@affine/graphql';
import type { DocServer } from '@toeverything/infra';
import { throwIfAborted } from '@toeverything/infra';
import type { Socket } from 'socket.io-client';
import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
(window as any)._TEST_SIMULATE_SYNC_LAG = Promise.resolve();
const logger = new DebugLogger('affine-cloud-doc-engine-server');
type WebsocketResponse<T> = { error: UserFriendlyErrorResponse } | { data: T };
export class CloudDocEngineServer implements DocServer {
interruptCb: ((reason: string) => void) | null = null;
SEND_TIMEOUT = 30000;
socket: Socket;
disposeSocket: () => void;
constructor(
private readonly workspaceId: string,
webSocketService: WebSocketService
) {
const { socket, dispose } = webSocketService.connect();
this.socket = socket;
this.disposeSocket = dispose;
}
private async clientHandShake() {
await this.socket.emitWithAck('space:join', {
spaceType: 'workspace',
spaceId: this.workspaceId,
clientVersion: BUILD_CONFIG.appVersion,
});
}
async pullDoc(docId: string, state: Uint8Array) {
// for testing
await (window as any)._TEST_SIMULATE_SYNC_LAG;
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
const response: WebsocketResponse<{
missing: string;
state: string;
timestamp: number;
}> = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('space:load-doc', {
spaceType: 'workspace',
spaceId: this.workspaceId,
docId: docId,
stateVector,
});
if ('error' in response) {
const error = new UserFriendlyError(response.error);
if (error.name === ErrorNames.DOC_NOT_FOUND) {
return null;
} else {
throw error;
}
} else {
return {
data: base64ToUint8Array(response.data.missing),
stateVector: response.data.state
? base64ToUint8Array(response.data.state)
: undefined,
serverClock: response.data.timestamp,
};
}
}
async pushDoc(docId: string, data: Uint8Array) {
const payload = await uint8ArrayToBase64(data);
const response: WebsocketResponse<{ timestamp: number }> = await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('space:push-doc-updates', {
spaceType: 'workspace',
spaceId: this.workspaceId,
docId: docId,
updates: [payload],
});
if ('error' in response) {
logger.error('client-update-v2 error', {
workspaceId: this.workspaceId,
guid: docId,
response,
});
throw new UserFriendlyError(response.error);
}
return { serverClock: response.data.timestamp };
}
async loadServerClock(after: number): Promise<Map<string, number>> {
const response: WebsocketResponse<Record<string, number>> =
await this.socket
.timeout(this.SEND_TIMEOUT)
.emitWithAck('space:load-doc-timestamps', {
spaceType: 'workspace',
spaceId: this.workspaceId,
timestamp: after,
});
if ('error' in response) {
logger.error('client-pre-sync error', {
workspaceId: this.workspaceId,
response,
});
throw new UserFriendlyError(response.error);
}
return new Map(Object.entries(response.data));
}
async subscribeAllDocs(
cb: (updates: {
docId: string;
data: Uint8Array;
serverClock: number;
}) => void
): Promise<() => void> {
const handleUpdate = async (message: {
spaceType: string;
spaceId: string;
docId: string;
updates: string[];
timestamp: number;
}) => {
if (
message.spaceType === 'workspace' &&
message.spaceId === this.workspaceId
) {
message.updates.forEach(update => {
cb({
docId: message.docId,
data: base64ToUint8Array(update),
serverClock: message.timestamp,
});
});
}
};
this.socket.on('space:broadcast-doc-updates', handleUpdate);
return () => {
this.socket.off('space:broadcast-doc-updates', handleUpdate);
};
}
async waitForConnectingServer(signal: AbortSignal): Promise<void> {
this.socket.on('server-version-rejected', this.handleVersionRejected);
this.socket.on('disconnect', this.handleDisconnect);
throwIfAborted(signal);
if (this.socket.connected) {
await this.clientHandShake();
} else {
this.socket.connect();
await new Promise<void>((resolve, reject) => {
this.socket.on('connect', () => {
resolve();
});
signal.addEventListener('abort', () => {
reject('aborted');
});
});
throwIfAborted(signal);
await this.clientHandShake();
}
}
disconnectServer(): void {
this.socket.emit('space:leave', {
spaceType: 'workspace',
spaceId: this.workspaceId,
});
this.socket.off('server-version-rejected', this.handleVersionRejected);
this.socket.off('disconnect', this.handleDisconnect);
}
onInterrupted = (cb: (reason: string) => void) => {
this.interruptCb = cb;
};
handleInterrupted = (reason: string) => {
this.interruptCb?.(reason);
};
handleDisconnect = (reason: Socket.DisconnectReason) => {
this.interruptCb?.(reason);
};
handleVersionRejected = () => {
this.interruptCb?.('Client version rejected');
};
dispose(): void {
this.disconnectServer();
this.disposeSocket();
}
}

View File

@@ -1,242 +0,0 @@
import type { ByteKV, ByteKVBehavior, DocStorage } from '@toeverything/infra';
import type { DBSchema, IDBPDatabase, IDBPObjectStore } from 'idb';
import { openDB } from 'idb';
import { mergeUpdates } from 'yjs';
import { BroadcastChannelDocEventBus } from './doc-broadcast-channel';
function isEmptyUpdate(binary: Uint8Array) {
return (
binary.byteLength === 0 ||
(binary.byteLength === 2 && binary[0] === 0 && binary[1] === 0)
);
}
export class IndexedDBDocStorage implements DocStorage {
constructor(private readonly workspaceId: string) {}
eventBus = new BroadcastChannelDocEventBus(this.workspaceId);
readonly doc = new Doc();
readonly syncMetadata = new KV(`${this.workspaceId}:sync-metadata`);
readonly serverClock = new KV(`${this.workspaceId}:server-clock`);
}
interface DocDBSchema extends DBSchema {
workspace: {
key: string;
value: {
id: string;
updates: {
timestamp: number;
update: Uint8Array;
}[];
};
};
}
type DocType = DocStorage['doc'];
class Doc implements DocType {
dbName = 'affine-local';
dbPromise: Promise<IDBPDatabase<DocDBSchema>> | null = null;
dbVersion = 1;
constructor() {}
upgradeDB(db: IDBPDatabase<DocDBSchema>) {
db.createObjectStore('workspace', { keyPath: 'id' });
}
getDb() {
if (this.dbPromise === null) {
this.dbPromise = openDB<DocDBSchema>(this.dbName, this.dbVersion, {
upgrade: db => this.upgradeDB(db),
});
}
return this.dbPromise;
}
async get(docId: string): Promise<Uint8Array | null> {
const db = await this.getDb();
const store = db
.transaction('workspace', 'readonly')
.objectStore('workspace');
const data = await store.get(docId);
if (!data) {
return null;
}
const updates = data.updates
.map(({ update }) => update)
.filter(update => !isEmptyUpdate(update));
const update = updates.length > 0 ? mergeUpdates(updates) : null;
return update;
}
async set(docId: string, data: Uint8Array) {
const db = await this.getDb();
const store = db
.transaction('workspace', 'readwrite')
.objectStore('workspace');
const rows = [{ timestamp: Date.now(), update: data }];
await store.put({
id: docId,
updates: rows,
});
}
async keys() {
const db = await this.getDb();
const store = db
.transaction('workspace', 'readonly')
.objectStore('workspace');
return store.getAllKeys();
}
clear(): void | Promise<void> {
return;
}
del(_key: string): void | Promise<void> {
return;
}
async transaction<T>(
cb: (transaction: ByteKVBehavior) => Promise<T>
): Promise<T> {
const db = await this.getDb();
const store = db
.transaction('workspace', 'readwrite')
.objectStore('workspace');
return await cb({
async get(docId) {
const data = await store.get(docId);
if (!data) {
return null;
}
const { updates } = data;
const update = mergeUpdates(updates.map(({ update }) => update));
return update;
},
keys() {
return store.getAllKeys();
},
async set(docId, data) {
const rows = [{ timestamp: Date.now(), update: data }];
await store.put({
id: docId,
updates: rows,
});
},
async clear() {
return await store.clear();
},
async del(key) {
return store.delete(key);
},
});
}
}
interface KvDBSchema extends DBSchema {
kv: {
key: string;
value: { key: string; val: Uint8Array };
};
}
class KV implements ByteKV {
constructor(private readonly dbName: string) {}
dbPromise: Promise<IDBPDatabase<KvDBSchema>> | null = null;
dbVersion = 1;
upgradeDB(db: IDBPDatabase<KvDBSchema>) {
db.createObjectStore('kv', { keyPath: 'key' });
}
getDb() {
if (this.dbPromise === null) {
this.dbPromise = openDB<KvDBSchema>(this.dbName, this.dbVersion, {
upgrade: db => this.upgradeDB(db),
});
}
return this.dbPromise;
}
async transaction<T>(
cb: (transaction: ByteKVBehavior) => Promise<T>
): Promise<T> {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
const behavior = new KVBehavior(store);
return await cb(behavior);
}
async get(key: string): Promise<Uint8Array | null> {
const db = await this.getDb();
const store = db.transaction('kv', 'readonly').objectStore('kv');
return new KVBehavior(store).get(key);
}
async set(key: string, value: Uint8Array): Promise<void> {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
return new KVBehavior(store).set(key, value);
}
async keys(): Promise<string[]> {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
return new KVBehavior(store).keys();
}
async clear() {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
return new KVBehavior(store).clear();
}
async del(key: string) {
const db = await this.getDb();
const store = db.transaction('kv', 'readwrite').objectStore('kv');
return new KVBehavior(store).del(key);
}
}
class KVBehavior implements ByteKVBehavior {
constructor(
private readonly store: IDBPObjectStore<KvDBSchema, ['kv'], 'kv', any>
) {}
async get(key: string): Promise<Uint8Array | null> {
const value = await this.store.get(key);
return value?.val ?? null;
}
async set(key: string, value: Uint8Array): Promise<void> {
if (this.store.put === undefined) {
throw new Error('Cannot set in a readonly transaction');
}
await this.store.put({
key: key,
val: value,
});
}
async keys(): Promise<string[]> {
return await this.store.getAllKeys();
}
async del(key: string) {
if (this.store.delete === undefined) {
throw new Error('Cannot set in a readonly transaction');
}
return await this.store.delete(key);
}
async clear() {
if (this.store.clear === undefined) {
throw new Error('Cannot set in a readonly transaction');
}
return await this.store.clear();
}
}

View File

@@ -1,151 +0,0 @@
import type { DesktopApiService } from '@affine/core/modules/desktop-api';
import type { ByteKV, ByteKVBehavior, DocStorage } from '@toeverything/infra';
import { AsyncLock } from '@toeverything/infra';
import { BroadcastChannelDocEventBus } from './doc-broadcast-channel';
export class SqliteDocStorage implements DocStorage {
constructor(
private readonly workspaceId: string,
private readonly electronApi: DesktopApiService
) {}
eventBus = new BroadcastChannelDocEventBus(this.workspaceId);
readonly doc = new Doc(this.workspaceId, this.electronApi);
readonly syncMetadata = new SyncMetadataKV(
this.workspaceId,
this.electronApi
);
readonly serverClock = new ServerClockKV(this.workspaceId, this.electronApi);
}
type DocType = DocStorage['doc'];
class Doc implements DocType {
lock = new AsyncLock();
apis = this.electronApi.handler;
constructor(
private readonly workspaceId: string,
private readonly electronApi: DesktopApiService
) {}
async transaction<T>(
cb: (transaction: ByteKVBehavior) => Promise<T>
): Promise<T> {
using _lock = await this.lock.acquire();
return await cb(this);
}
keys(): string[] | Promise<string[]> {
return [];
}
async get(docId: string) {
const update = await this.apis.db.getDocAsUpdates(
'workspace',
this.workspaceId,
docId
);
if (update) {
if (
update.byteLength === 0 ||
(update.byteLength === 2 && update[0] === 0 && update[1] === 0)
) {
return null;
}
return update;
}
return null;
}
async set(docId: string, data: Uint8Array) {
await this.apis.db.applyDocUpdate(
'workspace',
this.workspaceId,
data,
docId
);
}
clear(): void | Promise<void> {
return;
}
async del(docId: string) {
await this.apis.db.deleteDoc('workspace', this.workspaceId, docId);
}
}
class SyncMetadataKV implements ByteKV {
apis = this.electronApi.handler;
constructor(
private readonly workspaceId: string,
private readonly electronApi: DesktopApiService
) {}
transaction<T>(cb: (behavior: ByteKVBehavior) => Promise<T>): Promise<T> {
return cb(this);
}
get(key: string): Uint8Array | null | Promise<Uint8Array | null> {
return this.apis.db.getSyncMetadata('workspace', this.workspaceId, key);
}
set(key: string, data: Uint8Array): void | Promise<void> {
return this.apis.db.setSyncMetadata(
'workspace',
this.workspaceId,
key,
data
);
}
keys(): string[] | Promise<string[]> {
return this.apis.db.getSyncMetadataKeys('workspace', this.workspaceId);
}
del(key: string): void | Promise<void> {
return this.apis.db.delSyncMetadata('workspace', this.workspaceId, key);
}
clear(): void | Promise<void> {
return this.apis.db.clearSyncMetadata('workspace', this.workspaceId);
}
}
class ServerClockKV implements ByteKV {
apis = this.electronApi.handler;
constructor(
private readonly workspaceId: string,
private readonly electronApi: DesktopApiService
) {}
transaction<T>(cb: (behavior: ByteKVBehavior) => Promise<T>): Promise<T> {
return cb(this);
}
get(key: string): Uint8Array | null | Promise<Uint8Array | null> {
return this.apis.db.getServerClock('workspace', this.workspaceId, key);
}
set(key: string, data: Uint8Array): void | Promise<void> {
return this.apis.db.setServerClock(
'workspace',
this.workspaceId,
key,
data
);
}
keys(): string[] | Promise<string[]> {
return this.apis.db.getServerClockKeys('workspace', this.workspaceId);
}
del(key: string): void | Promise<void> {
return this.apis.db.delServerClock('workspace', this.workspaceId, key);
}
clear(): void | Promise<void> {
return this.apis.db.clearServerClock('workspace', this.workspaceId);
}
}

View File

@@ -21,5 +21,3 @@ consumer.register('renderWorkspaceProfile', data => {
avatar: typeof avatar === 'string' ? avatar : undefined,
};
});
consumer.listen();

View File

@@ -1,28 +1,40 @@
import { DebugLogger } from '@affine/debug';
import type {
BlobStorage,
DocStorage,
FrameworkProvider,
} from '@toeverything/infra';
import type { BlobStorage, DocStorage } from '@affine/nbstore';
import {
IndexedDBBlobStorage,
IndexedDBDocStorage,
IndexedDBSyncStorage,
} from '@affine/nbstore/idb';
import {
IndexedDBV1BlobStorage,
IndexedDBV1DocStorage,
} from '@affine/nbstore/idb/v1';
import {
SqliteBlobStorage,
SqliteDocStorage,
SqliteSyncStorage,
} from '@affine/nbstore/sqlite';
import {
SqliteV1BlobStorage,
SqliteV1DocStorage,
} from '@affine/nbstore/sqlite/v1';
import type { WorkerInitOptions } from '@affine/nbstore/worker/client';
import type { FrameworkProvider } from '@toeverything/infra';
import { LiveData, Service } from '@toeverything/infra';
import { isEqual } from 'lodash-es';
import { nanoid } from 'nanoid';
import { Observable } from 'rxjs';
import { encodeStateAsUpdate } from 'yjs';
import { type Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { DesktopApiService } from '../../desktop-api';
import {
getAFFiNEWorkspaceSchema,
type WorkspaceEngineProvider,
type WorkspaceFlavourProvider,
type WorkspaceFlavoursProvider,
type WorkspaceMetadata,
type WorkspaceProfileInfo,
} from '../../workspace';
import { WorkspaceImpl } from '../../workspace/impls/workspace';
import type { WorkspaceEngineStorageProvider } from '../providers/engine';
import { BroadcastChannelAwarenessConnection } from './engine/awareness-broadcast-channel';
import { StaticBlobStorage } from './engine/blob-static';
import { getWorkspaceProfileWorker } from './out-worker';
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
@@ -56,16 +68,36 @@ export function setLocalWorkspaceIds(
}
class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
constructor(
private readonly storageProvider: WorkspaceEngineStorageProvider,
private readonly framework: FrameworkProvider
) {}
constructor(private readonly framework: FrameworkProvider) {}
flavour = 'local';
notifyChannel = new BroadcastChannel(
readonly flavour = 'local';
readonly notifyChannel = new BroadcastChannel(
LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY
);
DocStorageType =
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
? SqliteDocStorage
: IndexedDBDocStorage;
DocStorageV1Type = BUILD_CONFIG.isElectron
? SqliteV1DocStorage
: BUILD_CONFIG.isWeb || BUILD_CONFIG.isMobileWeb
? IndexedDBV1DocStorage
: undefined;
BlobStorageType =
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
? SqliteBlobStorage
: IndexedDBBlobStorage;
BlobStorageV1Type = BUILD_CONFIG.isElectron
? SqliteV1BlobStorage
: BUILD_CONFIG.isWeb || BUILD_CONFIG.isMobileWeb
? IndexedDBV1BlobStorage
: undefined;
SyncStorageType =
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
? SqliteSyncStorage
: IndexedDBSyncStorage;
async deleteWorkspace(id: string): Promise<void> {
setLocalWorkspaceIds(ids => ids.filter(x => x !== id));
@@ -87,25 +119,67 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
const id = nanoid();
// save the initial state to local storage, then sync to cloud
const blobStorage = this.storageProvider.getBlobStorage(id);
const docStorage = this.storageProvider.getDocStorage(id);
const docStorage = new this.DocStorageType({
id: id,
flavour: this.flavour,
type: 'workspace',
});
docStorage.connection.connect();
await docStorage.connection.waitForConnected();
const blobStorage = new this.BlobStorageType({
id: id,
flavour: this.flavour,
type: 'workspace',
});
blobStorage.connection.connect();
await blobStorage.connection.waitForConnected();
const docList = new Set<YDoc>();
const docCollection = new WorkspaceImpl({
id: id,
schema: getAFFiNEWorkspaceSchema(),
blobSource: blobStorage,
blobSource: {
get: async key => {
const record = await blobStorage.get(key);
return record ? new Blob([record.data], { type: record.mime }) : null;
},
delete: async () => {
return;
},
list: async () => {
return [];
},
set: async (id, blob) => {
await blobStorage.set({
key: id,
data: new Uint8Array(await blob.arrayBuffer()),
mime: blob.type,
});
return id;
},
name: 'blob',
readonly: false,
},
onLoadDoc(doc) {
docList.add(doc);
},
});
try {
// apply initial state
await initial(docCollection, blobStorage, docStorage);
// save workspace to local storage, should be vary fast
await docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc));
for (const subdocs of docCollection.doc.getSubdocs()) {
await docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
for (const subdocs of docList) {
await docStorage.pushDocUpdate({
docId: subdocs.guid,
bin: encodeStateAsUpdate(subdocs),
});
}
docStorage.connection.disconnect();
blobStorage.connection.disconnect();
// save workspace id to local storage
setLocalWorkspaceIds(ids => [...ids, id]);
@@ -152,8 +226,17 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
async getWorkspaceProfile(
id: string
): Promise<WorkspaceProfileInfo | undefined> {
const docStorage = this.storageProvider.getDocStorage(id);
const localData = await docStorage.doc.get(id);
const docStorage = new this.DocStorageType({
id: id,
flavour: this.flavour,
type: 'workspace',
readonlyMode: true,
});
docStorage.connection.connect();
await docStorage.connection.waitForConnected();
const localData = await docStorage.getDoc(id);
docStorage.connection.disconnect();
if (!localData) {
return {
@@ -165,7 +248,7 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
const result = await client.call(
'renderWorkspaceProfile',
[localData].filter(Boolean) as Uint8Array[]
[localData.bin].filter(Boolean) as Uint8Array[]
);
return {
@@ -174,26 +257,74 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
isOwner: true,
};
}
getWorkspaceBlob(id: string, blob: string): Promise<Blob | null> {
return this.storageProvider.getBlobStorage(id).get(blob);
async getWorkspaceBlob(id: string, blobKey: string): Promise<Blob | null> {
const storage = new this.BlobStorageType({
id: id,
flavour: this.flavour,
type: 'workspace',
});
storage.connection.connect();
await storage.connection.waitForConnected();
const blob = await storage.get(blobKey);
return blob ? new Blob([blob.data], { type: blob.mime }) : null;
}
getEngineProvider(workspaceId: string): WorkspaceEngineProvider {
getEngineWorkerInitOptions(workspaceId: string): WorkerInitOptions {
return {
getAwarenessConnections() {
return [new BroadcastChannelAwarenessConnection(workspaceId)];
local: {
doc: {
name: this.DocStorageType.identifier,
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
blob: {
name: this.BlobStorageType.identifier,
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
sync: {
name: this.SyncStorageType.identifier,
opts: {
flavour: this.flavour,
type: 'workspace',
id: workspaceId,
},
},
awareness: {
name: 'BroadcastChannelAwarenessStorage',
opts: {
id: workspaceId,
},
},
},
getDocServer() {
return null;
},
getDocStorage: () => {
return this.storageProvider.getDocStorage(workspaceId);
},
getLocalBlobStorage: () => {
return this.storageProvider.getBlobStorage(workspaceId);
},
getRemoteBlobStorages() {
return [new StaticBlobStorage()];
remotes: {
v1: {
doc: this.DocStorageV1Type
? {
name: this.DocStorageV1Type.identifier,
opts: {
id: workspaceId,
type: 'workspace',
},
}
: undefined,
blob: this.BlobStorageV1Type
? {
name: this.BlobStorageV1Type.identifier,
opts: {
id: workspaceId,
type: 'workspace',
},
}
: undefined,
},
},
};
}
@@ -203,13 +334,11 @@ export class LocalWorkspaceFlavoursProvider
extends Service
implements WorkspaceFlavoursProvider
{
constructor(
private readonly storageProvider: WorkspaceEngineStorageProvider
) {
constructor() {
super();
}
workspaceFlavours$ = new LiveData<WorkspaceFlavourProvider[]>([
new LocalWorkspaceFlavourProvider(this.storageProvider, this.framework),
new LocalWorkspaceFlavourProvider(this.framework),
]);
}

View File

@@ -17,6 +17,5 @@ export function getWorkspaceProfileWorker() {
);
worker = new OpClient<WorkerOps>(rawWorker);
worker.listen();
return worker;
}

View File

@@ -1,64 +1,25 @@
import { type Framework } from '@toeverything/infra';
import { ServersService } from '../cloud/services/servers';
import { DesktopApiService } from '../desktop-api';
import { GlobalState } from '../storage';
import { WorkspaceFlavoursProvider } from '../workspace';
import { CloudWorkspaceFlavoursProvider } from './impls/cloud';
import { IndexedDBBlobStorage } from './impls/engine/blob-indexeddb';
import { SqliteBlobStorage } from './impls/engine/blob-sqlite';
import { IndexedDBDocStorage } from './impls/engine/doc-indexeddb';
import { SqliteDocStorage } from './impls/engine/doc-sqlite';
import {
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
LocalWorkspaceFlavoursProvider,
} from './impls/local';
import { WorkspaceEngineStorageProvider } from './providers/engine';
export { CloudBlobStorage } from './impls/engine/blob-cloud';
export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/base64';
export function configureBrowserWorkspaceFlavours(framework: Framework) {
framework
.impl(WorkspaceFlavoursProvider('LOCAL'), LocalWorkspaceFlavoursProvider, [
WorkspaceEngineStorageProvider,
])
.impl(WorkspaceFlavoursProvider('LOCAL'), LocalWorkspaceFlavoursProvider)
.impl(WorkspaceFlavoursProvider('CLOUD'), CloudWorkspaceFlavoursProvider, [
GlobalState,
WorkspaceEngineStorageProvider,
ServersService,
]);
}
export function configureIndexedDBWorkspaceEngineStorageProvider(
framework: Framework
) {
framework.impl(WorkspaceEngineStorageProvider, {
getDocStorage(workspaceId: string) {
return new IndexedDBDocStorage(workspaceId);
},
getBlobStorage(workspaceId: string) {
return new IndexedDBBlobStorage(workspaceId);
},
});
}
export function configureSqliteWorkspaceEngineStorageProvider(
framework: Framework
) {
framework.impl(WorkspaceEngineStorageProvider, p => {
const electronApi = p.get(DesktopApiService);
return {
getDocStorage(workspaceId: string) {
return new SqliteDocStorage(workspaceId, electronApi);
},
getBlobStorage(workspaceId: string) {
return new SqliteBlobStorage(workspaceId, electronApi);
},
};
});
}
/**
* a hack for directly add local workspace to workspace list
* Used after copying sqlite database file to appdata folder

View File

@@ -1,15 +0,0 @@
import {
type BlobStorage,
createIdentifier,
type DocStorage,
} from '@toeverything/infra';
export interface WorkspaceEngineStorageProvider {
getDocStorage(workspaceId: string): DocStorage;
getBlobStorage(workspaceId: string): BlobStorage;
}
export const WorkspaceEngineStorageProvider =
createIdentifier<WorkspaceEngineStorageProvider>(
'WorkspaceEngineStorageProvider'
);

View File

@@ -1,83 +1,67 @@
import {
AwarenessEngine,
BlobEngine,
DocEngine,
Entity,
throwIfAborted,
} from '@toeverything/infra';
import type { Doc as YDoc } from 'yjs';
import type {
WorkerClient,
WorkerInitOptions,
} from '@affine/nbstore/worker/client';
import { Entity } from '@toeverything/infra';
import type { NbstoreService } from '../../storage';
import { WorkspaceEngineBeforeStart } from '../events';
import type { WorkspaceEngineProvider } from '../providers/flavour';
import type { WorkspaceService } from '../services/workspace';
export class WorkspaceEngine extends Entity<{
engineProvider: WorkspaceEngineProvider;
isSharedMode?: boolean;
engineWorkerInitOptions: WorkerInitOptions;
}> {
doc = new DocEngine(
this.props.engineProvider.getDocStorage(),
this.props.engineProvider.getDocServer()
);
worker?: WorkerClient;
started = false;
blob = new BlobEngine(
this.props.engineProvider.getLocalBlobStorage(),
this.props.engineProvider.getRemoteBlobStorages()
);
awareness = new AwarenessEngine(
this.props.engineProvider.getAwarenessConnections()
);
constructor(private readonly workspaceService: WorkspaceService) {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly nbstoreService: NbstoreService
) {
super();
}
setRootDoc(yDoc: YDoc) {
this.doc.setPriority(yDoc.guid, 100);
this.doc.addDoc(yDoc);
get doc() {
if (!this.worker) {
throw new Error('Engine is not initialized');
}
return this.worker.docFrontend;
}
get blob() {
if (!this.worker) {
throw new Error('Engine is not initialized');
}
return this.worker.blobFrontend;
}
get awareness() {
if (!this.worker) {
throw new Error('Engine is not initialized');
}
return this.worker.awarenessFrontend;
}
start() {
this.eventBus.emit(WorkspaceEngineBeforeStart, this);
this.doc.start();
this.awareness.connect(this.workspaceService.workspace.awareness);
if (!BUILD_CONFIG.isMobileEdition) {
// currently, blob synchronization consumes a lot of memory and is temporarily disabled on mobile devices.
this.blob.start();
if (this.started) {
throw new Error('Engine is already started');
}
}
this.started = true;
canGracefulStop() {
return this.doc.engineState$.value.saving === 0;
}
const { store, dispose } = this.nbstoreService.openStore(
(this.props.isSharedMode ? 'shared:' : '') +
`workspace:${this.workspaceService.workspace.flavour}:${this.workspaceService.workspace.id}`,
this.props.engineWorkerInitOptions
);
this.worker = store;
this.disposables.push(dispose);
this.eventBus.emit(WorkspaceEngineBeforeStart, this);
async waitForGracefulStop(abort?: AbortSignal) {
await this.doc.waitForSaved();
throwIfAborted(abort);
this.forceStop();
}
forceStop() {
this.doc.stop();
this.awareness.disconnect();
this.blob.stop();
}
docEngineState$ = this.doc.engineState$;
rootDocState$ = this.doc.docState$(this.workspaceService.workspace.id);
waitForDocSynced() {
return this.doc.waitForSynced();
}
waitForRootDocReady() {
return this.doc.waitForReady(this.workspaceService.workspace.id);
}
override dispose(): void {
this.forceStop();
this.doc.dispose();
this.awareness.dispose();
const rootDoc = this.workspaceService.workspace.docCollection.doc;
// priority load root doc
this.doc.addPriority(rootDoc.guid, 100);
this.doc.start();
this.disposables.push(() => this.doc.stop());
}
}

View File

@@ -3,7 +3,6 @@ import { Entity, LiveData } from '@toeverything/infra';
import { Observable } from 'rxjs';
import type { Awareness } from 'y-protocols/awareness.js';
import { WorkspaceDBService } from '../../db';
import { getAFFiNEWorkspaceSchema } from '../global-schema';
import { WorkspaceImpl } from '../impls/workspace';
import type { WorkspaceScope } from '../scopes/workspace';
@@ -28,20 +27,39 @@ export class Workspace extends Entity {
if (!this._docCollection) {
this._docCollection = new WorkspaceImpl({
id: this.openOptions.metadata.id,
blobSource: this.engine.blob,
blobSource: {
get: async key => {
const record = await this.engine.blob.get(key);
return record
? new Blob([record.data], { type: record.mime })
: null;
},
delete: async () => {
return;
},
list: async () => {
return [];
},
set: async (id, blob) => {
await this.engine.blob.set({
key: id,
data: new Uint8Array(await blob.arrayBuffer()),
mime: blob.type,
});
return id;
},
name: 'blob',
readonly: false,
},
schema: getAFFiNEWorkspaceSchema(),
});
this._docCollection.slots.docCreated.on(id => {
this.engine.doc.markAsReady(id);
onLoadDoc: doc => this.engine.doc.connectDoc(doc),
onLoadAwareness: awareness =>
this.engine.awareness.connectAwareness(awareness),
});
}
return this._docCollection;
}
get db() {
return this.framework.get(WorkspaceDBService).db;
}
get awareness() {
return this.docCollection.awarenessStore.awareness as Awareness;
}

View File

@@ -45,42 +45,32 @@ export class DocImpl implements Doc {
};
private readonly _initSubDoc = () => {
let subDoc = this.rootDoc.getMap('spaces').get(this.id);
if (!subDoc) {
subDoc = new Y.Doc({
guid: this.id,
});
this.rootDoc.getMap('spaces').set(this.id, subDoc);
this._loaded = true;
this._onLoadSlot.emit();
} else {
this._loaded = false;
this.rootDoc.on('subdocs', this._onSubdocEvent);
{
// This is a piece of old version compatible code. The old version relies on the subdoc instance on `spaces`.
// So if there is no subdoc on spaces, we will create it.
// new version no longer needs subdoc on `spaces`.
let subDoc = this.rootDoc.getMap('spaces').get(this.id);
if (!subDoc) {
subDoc = new Y.Doc({
guid: this.id,
});
this.rootDoc.getMap('spaces').set(this.id, subDoc);
}
}
return subDoc;
const spaceDoc = new Y.Doc({
guid: this.id,
});
spaceDoc.clientID = this.rootDoc.clientID;
this._loaded = false;
return spaceDoc;
};
private _loaded!: boolean;
private readonly _onLoadSlot = new Slot();
private readonly _onSubdocEvent = ({
loaded,
}: {
loaded: Set<Y.Doc>;
}): void => {
const result = Array.from(loaded).find(
doc => doc.guid === this._ySpaceDoc.guid
);
if (!result) {
return;
}
this.rootDoc.off('subdocs', this._onSubdocEvent);
this._loaded = true;
this._onLoadSlot.emit();
};
/** Indicate whether the block tree is ready */
private _ready = false;
@@ -301,7 +291,8 @@ export class DocImpl implements Doc {
return this;
}
this._ySpaceDoc.load();
this.spaceDoc.load();
this.workspace.onLoadDoc?.(this.spaceDoc);
if ((this.workspace.meta.docs?.length ?? 0) <= 1) {
this._handleVersion();
@@ -315,6 +306,7 @@ export class DocImpl implements Doc {
initFn?.();
this._loaded = true;
this._ready = true;
return this;

View File

@@ -30,6 +30,8 @@ type WorkspaceOptions = {
id?: string;
schema: Schema;
blobSource?: BlobSource;
onLoadDoc?: (doc: Y.Doc) => void;
onLoadAwareness?: (awareness: Awareness) => void;
};
export class WorkspaceImpl implements Workspace {
@@ -63,12 +65,25 @@ export class WorkspaceImpl implements Workspace {
return this._schema;
}
constructor({ id, schema, blobSource }: WorkspaceOptions) {
readonly onLoadDoc?: (doc: Y.Doc) => void;
readonly onLoadAwareness?: (awareness: Awareness) => void;
constructor({
id,
schema,
blobSource,
onLoadDoc,
onLoadAwareness,
}: WorkspaceOptions) {
this._schema = schema;
this.id = id || '';
this.doc = new Y.Doc({ guid: id });
this.awarenessStore = new AwarenessStore(new Awareness(this.doc));
this.onLoadDoc = onLoadDoc;
this.onLoadAwareness = onLoadAwareness;
this.onLoadDoc?.(this.doc);
this.onLoadAwareness?.(this.awarenessStore.awareness);
blobSource = blobSource ?? new MemoryBlobSource();
const logger = new NoopLogger();

View File

@@ -4,7 +4,6 @@ export { WorkspaceEngineBeforeStart, WorkspaceInitialized } from './events';
export { getAFFiNEWorkspaceSchema } from './global-schema';
export type { WorkspaceMetadata } from './metadata';
export type { WorkspaceOpenOptions } from './open-options';
export type { WorkspaceEngineProvider } from './providers/flavour';
export type { WorkspaceFlavourProvider } from './providers/flavour';
export { WorkspaceFlavoursProvider } from './providers/flavour';
export { WorkspaceLocalCache, WorkspaceLocalState } from './providers/storage';
@@ -14,7 +13,7 @@ export { WorkspacesService } from './services/workspaces';
import type { Framework } from '@toeverything/infra';
import { GlobalCache, GlobalState } from '../storage';
import { GlobalCache, GlobalState, NbstoreService } from '../storage';
import { WorkspaceEngine } from './entities/engine';
import { WorkspaceList } from './entities/list';
import { WorkspaceProfile } from './entities/profile';
@@ -73,7 +72,7 @@ export function configureWorkspaceModule(framework: Framework) {
.service(WorkspaceService)
.entity(Workspace, [WorkspaceScope])
.service(WorkspaceEngineService, [WorkspaceScope])
.entity(WorkspaceEngine, [WorkspaceService])
.entity(WorkspaceEngine, [WorkspaceService, NbstoreService])
.impl(WorkspaceLocalState, WorkspaceLocalStateImpl, [
WorkspaceService,
GlobalState,

View File

@@ -1,25 +1,12 @@
import type { BlobStorage, DocStorage } from '@affine/nbstore';
import type { WorkerInitOptions } from '@affine/nbstore/worker/client';
import type { Workspace as BSWorkspace } from '@blocksuite/affine/store';
import {
type AwarenessConnection,
type BlobStorage,
createIdentifier,
type DocServer,
type DocStorage,
type LiveData,
} from '@toeverything/infra';
import { createIdentifier, type LiveData } from '@toeverything/infra';
import type { WorkspaceProfileInfo } from '../entities/profile';
import type { Workspace } from '../entities/workspace';
import type { WorkspaceMetadata } from '../metadata';
export interface WorkspaceEngineProvider {
getDocServer(): DocServer | null;
getDocStorage(): DocStorage;
getLocalBlobStorage(): BlobStorage;
getRemoteBlobStorages(): BlobStorage[];
getAwarenessConnections(): AwarenessConnection[];
}
export interface WorkspaceFlavourProvider {
flavour: string;
@@ -54,7 +41,7 @@ export interface WorkspaceFlavourProvider {
getWorkspaceBlob(id: string, blob: string): Promise<Blob | null>;
getEngineProvider(workspaceId: string): WorkspaceEngineProvider;
getEngineWorkerInitOptions(workspaceId: string): WorkerInitOptions;
onWorkspaceInitialized?(workspace: Workspace): void;
}

View File

@@ -1,9 +1,9 @@
import type { WorkerInitOptions } from '@affine/nbstore/worker/client';
import { Scope } from '@toeverything/infra';
import type { WorkspaceOpenOptions } from '../open-options';
import type { WorkspaceEngineProvider } from '../providers/flavour';
export class WorkspaceScope extends Scope<{
openOptions: WorkspaceOpenOptions;
engineProvider: WorkspaceEngineProvider;
engineWorkerInitOptions: WorkerInitOptions;
}> {}

View File

@@ -8,7 +8,9 @@ export class WorkspaceEngineService extends Service {
get engine() {
if (!this._engine) {
this._engine = this.framework.createEntity(WorkspaceEngine, {
engineProvider: this.workspaceScope.props.engineProvider,
isSharedMode: this.workspaceScope.props.openOptions.isSharedMode,
engineWorkerInitOptions:
this.workspaceScope.props.engineWorkerInitOptions,
});
}
return this._engine;

View File

@@ -1,9 +1,6 @@
import type { BlobStorage, DocStorage } from '@affine/nbstore';
import type { Workspace } from '@blocksuite/affine/store';
import {
type BlobStorage,
type DocStorage,
Service,
} from '@toeverything/infra';
import { Service } from '@toeverything/infra';
import type { WorkspaceFlavoursService } from './flavours';
@@ -22,8 +19,8 @@ export class WorkspaceFactoryService extends Service {
flavour: string,
initial: (
docCollection: Workspace,
blobStorage: BlobStorage,
docStorage: DocStorage
blobFrontend: BlobStorage,
docFrontend: DocStorage
) => Promise<void> = () => Promise.resolve()
) => {
const provider = this.flavoursService.flavours$.value.find(

View File

@@ -1,10 +1,10 @@
import { DebugLogger } from '@affine/debug';
import type { WorkerInitOptions } from '@affine/nbstore/worker/client';
import { ObjectPool, Service } from '@toeverything/infra';
import type { Workspace } from '../entities/workspace';
import { WorkspaceInitialized } from '../events';
import type { WorkspaceOpenOptions } from '../open-options';
import type { WorkspaceEngineProvider } from '../providers/flavour';
import { WorkspaceScope } from '../scopes/workspace';
import type { WorkspaceFlavoursService } from './flavours';
import type { WorkspaceListService } from './list';
@@ -40,13 +40,16 @@ export class WorkspaceRepositoryService extends Service {
*/
open = (
options: WorkspaceOpenOptions,
customProvider?: WorkspaceEngineProvider
customEngineWorkerInitOptions?: WorkerInitOptions
): {
workspace: Workspace;
dispose: () => void;
} => {
if (options.isSharedMode) {
const workspace = this.instantiate(options, customProvider);
const workspace = this.instantiate(
options,
customEngineWorkerInitOptions
);
return {
workspace,
dispose: () => {
@@ -63,9 +66,7 @@ export class WorkspaceRepositoryService extends Service {
};
}
const workspace = this.instantiate(options, customProvider);
// sync information with workspace list, when workspace's avatar and name changed, information will be updated
// this.list.getInformation(metadata).syncWithWorkspace(workspace);
const workspace = this.instantiate(options, customEngineWorkerInitOptions);
const ref = this.pool.put(workspace.meta.id, workspace);
@@ -83,7 +84,7 @@ export class WorkspaceRepositoryService extends Service {
instantiate(
openOptions: WorkspaceOpenOptions,
customProvider?: WorkspaceEngineProvider
customEngineWorkerInitOptions?: WorkerInitOptions
) {
logger.info(
`open workspace [${openOptions.metadata.flavour}] ${openOptions.metadata.id} `
@@ -91,10 +92,10 @@ export class WorkspaceRepositoryService extends Service {
const flavourProvider = this.flavoursService.flavours$.value.find(
p => p.flavour === openOptions.metadata.flavour
);
const provider =
customProvider ??
flavourProvider?.getEngineProvider(openOptions.metadata.id);
if (!provider) {
const engineWorkerInitOptions =
customEngineWorkerInitOptions ??
flavourProvider?.getEngineWorkerInitOptions(openOptions.metadata.id);
if (!engineWorkerInitOptions) {
throw new Error(
`Unknown workspace flavour: ${openOptions.metadata.flavour}`
);
@@ -102,12 +103,11 @@ export class WorkspaceRepositoryService extends Service {
const workspaceScope = this.framework.createScope(WorkspaceScope, {
openOptions,
engineProvider: provider,
engineWorkerInitOptions,
});
const workspace = workspaceScope.get(WorkspaceService).workspace;
workspace.engine.setRootDoc(workspace.docCollection.doc);
workspace.engine.start();
this.framework.emitEvent(WorkspaceInitialized, workspace);

View File

@@ -28,23 +28,29 @@ export class WorkspaceTransformService extends Service {
): Promise<WorkspaceMetadata> => {
assertEquals(local.flavour, 'local');
const localDocStorage = local.engine.doc.storage.behavior;
const localDocStorage = local.engine.doc.storage;
const localDocList = Array.from(local.docCollection.docs.keys());
const newMetadata = await this.factory.create(
flavour,
async (docCollection, blobStorage, docStorage) => {
const rootDocBinary = await localDocStorage.doc.get(
local.docCollection.doc.guid
);
const rootDocBinary = (
await localDocStorage.getDoc(local.docCollection.doc.guid)
)?.bin;
if (rootDocBinary) {
applyUpdate(docCollection.doc, rootDocBinary);
}
for (const subdoc of docCollection.doc.getSubdocs()) {
const subdocBinary = await localDocStorage.doc.get(subdoc.guid);
for (const subdocId of localDocList) {
const subdocBinary = (await localDocStorage.getDoc(subdocId))?.bin;
if (subdocBinary) {
applyUpdate(subdoc, subdocBinary);
const doc = docCollection.getDoc(subdocId);
if (doc) {
const spaceDoc = doc.spaceDoc;
doc.load();
applyUpdate(spaceDoc, subdocBinary);
}
}
}
@@ -57,12 +63,12 @@ export class WorkspaceTransformService extends Service {
accountId
);
const blobList = await local.engine.blob.list();
const blobList = await local.engine.blob.storage.list();
for (const blobKey of blobList) {
const blob = await local.engine.blob.get(blobKey);
for (const { key } of blobList) {
const blob = await local.engine.blob.storage.get(key);
if (blob) {
await blobStorage.set(blobKey, blob);
await blobStorage.set(blob);
}
}
}

View File

@@ -55,4 +55,10 @@ export class WorkspacesService extends Service {
.find(x => x.flavour === meta.flavour)
?.getWorkspaceBlob(meta.id, blob);
}
getWorkspaceFlavourProvider(meta: WorkspaceMetadata) {
return this.flavoursService.flavours$.value.find(
x => x.flavour === meta.flavour
);
}
}

View File

@@ -21,7 +21,7 @@ export async function buildShowcaseWorkspace(
const { workspace, dispose } = workspacesService.open({ metadata: meta });
await workspace.engine.waitForRootDocReady();
await workspace.engine.doc.waitForDocReady(workspace.id);
const docsService = workspace.scope.get(DocsService);