mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
feat(core): new worker workspace engine (#9257)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
// ORDER MATTERS
|
||||
import './env';
|
||||
import './public-path';
|
||||
import './shared-worker';
|
||||
import './polyfill/browser';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// ORDER MATTERS
|
||||
import './env';
|
||||
import './public-path';
|
||||
import './shared-worker';
|
||||
import './polyfill/electron';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ResizeObserver } from '@juggle/resize-observer';
|
||||
|
||||
export function polyfillResizeObserver() {
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
}
|
||||
}
|
||||
|
||||
25
packages/frontend/core/src/bootstrap/shared-worker.ts
Normal file
25
packages/frontend/core/src/bootstrap/shared-worker.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ export const LocalQuotaModal = () => {
|
||||
}, [setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const disposable = currentWorkspace.engine.blob.onAbortLargeBlob(() => {
|
||||
const disposable = currentWorkspace.engine.blob.onReachedMaxBlobSize(() => {
|
||||
setOpen(true);
|
||||
});
|
||||
return () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
(
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -49,7 +49,6 @@ export class PDF extends Entity<AttachmentBlockModel> {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.renderer.listen();
|
||||
this.disposables.push(() => this.pages.clear());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { createIdentifier, type DocStorage } from '@toeverything/infra';
|
||||
|
||||
export interface UserspaceStorageProvider {
|
||||
getDocStorage(userId: string): DocStorage;
|
||||
}
|
||||
|
||||
export const UserspaceStorageProvider =
|
||||
createIdentifier<UserspaceStorageProvider>('UserspaceStorageProvider');
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,3 @@ consumer.register('renderWorkspaceProfile', data => {
|
||||
avatar: typeof avatar === 'string' ? avatar : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
consumer.listen();
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,5 @@ export function getWorkspaceProfileWorker() {
|
||||
);
|
||||
|
||||
worker = new OpClient<WorkerOps>(rawWorker);
|
||||
worker.listen();
|
||||
return worker;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}> {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user