(null);
+ const content = useMemo(() => {
+ if (currentWorkspace.flavour === WorkspaceFlavour.LOCAL) {
+ return 'Saved locally';
+ }
+ if (!isOnline) {
+ return 'Disconnected, please check your network connection';
+ }
+ switch (forceSyncStatus.type) {
+ case 'syncing':
+ return 'Syncing with AFFiNE Cloud';
+ case 'error':
+ return 'Sync failed due to server issues, please try again later.';
+ default:
+ return 'Sync with AFFiNE Cloud';
+ }
+ }, [currentWorkspace.flavour, forceSyncStatus.type, isOnline]);
+ const CloudWorkspaceSyncStatus = useCallback(() => {
+ if (forceSyncStatus.type === 'syncing') {
+ return SyncingWorkspaceStatus();
+ } else if (forceSyncStatus.type === 'error') {
+ return UnSyncWorkspaceStatus();
+ } else {
+ return CloudWorkspaceStatus();
+ }
+ }, [forceSyncStatus.type]);
+ return (
+
+
+ {
+ setIsHovered(true);
+ }}
+ ref={setContainer}
+ onMouseLeave={() => setIsHovered(false)}
+ onClick={useCallback(
+ (e: MouseEvent) => {
+ e.stopPropagation();
+ startForceSync();
+ },
+ [startForceSync]
+ )}
+ >
+ {currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
+ !isOnline ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
/**
* @todo-Doma Co-locate WorkspaceListModal with {@link WorkspaceSelector},
* because it's never used elsewhere.
@@ -36,13 +164,11 @@ export const WorkspaceSelector = ({
const [name] = useBlockSuiteWorkspaceName(
currentWorkspace.blockSuiteWorkspace
);
- const [isHovered, setIsHovered] = useState(false);
- const [container, setContainer] = useState(null);
// Open dialog when `Enter` or `Space` pressed
// TODO-Doma Refactor with `@radix-ui/react-dialog` or other libraries that handle these out of the box and be accessible by default
// TODO: Delete this?
const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
+ (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// TODO-Doma Rename this callback to `onOpenDialog` or something to reduce ambiguity.
@@ -51,41 +177,8 @@ export const WorkspaceSelector = ({
},
[onClick]
);
- const loginStatus = useCurrentLoginStatus();
- const isOnline = useSystemOnline();
- const content = useMemo(() => {
- if (!isOnline) {
- return 'Disconnected, please check your network connection';
- }
- if (
- loginStatus === 'authenticated' &&
- currentWorkspace.flavour !== 'local'
- ) {
- return 'Sync with AFFiNE Cloud';
- }
- return 'Saved locally';
- }, [currentWorkspace.flavour, isOnline, loginStatus]);
+ const isHovered = useAtomValue(hoverAtom);
- const WorkspaceStatus = () => {
- if (!isOnline) {
- return (
- <>
-
- Offline
- >
- );
- }
- return (
- <>
- {currentWorkspace.flavour === 'local' ? (
-
- ) : (
-
- )}
- {currentWorkspace.flavour === 'local' ? 'Local' : 'AFFiNE Cloud'}
- >
- );
- };
return (
{name}
-
-
- {
- setIsHovered(true);
- }}
- ref={setContainer}
- onMouseLeave={() => setIsHovered(false)}
- onClick={e => e.stopPropagation()}
- >
-
-
-
-
+
);
diff --git a/apps/core/src/hooks/use-datasource-sync.ts b/apps/core/src/hooks/use-datasource-sync.ts
new file mode 100644
index 0000000000..9b4df60a29
--- /dev/null
+++ b/apps/core/src/hooks/use-datasource-sync.ts
@@ -0,0 +1,88 @@
+import { pushNotificationAtom } from '@affine/component/notification-center';
+import type {
+ AffineSocketIOProvider,
+ LocalIndexedDBBackgroundProvider,
+ SQLiteProvider,
+} from '@affine/env/workspace';
+import { type Status, syncDataSource } from '@affine/y-provider';
+import { assertExists } from '@blocksuite/global/utils';
+import type { Workspace } from '@blocksuite/store';
+import { useSetAtom } from 'jotai';
+import { startTransition, useCallback, useMemo, useState } from 'react';
+
+export function useDatasourceSync(workspace: Workspace) {
+ const [status, setStatus] = useState({
+ type: 'idle',
+ });
+ const pushNotification = useSetAtom(pushNotificationAtom);
+ const providers = workspace.providers;
+ const remoteProvider: AffineSocketIOProvider | undefined = useMemo(() => {
+ return providers.find(
+ (provider): provider is AffineSocketIOProvider =>
+ provider.flavour === 'affine-socket-io'
+ );
+ }, [providers]);
+ const localProvider = useMemo(() => {
+ const sqliteProvider = providers.find(
+ (provider): provider is SQLiteProvider => provider.flavour === 'sqlite'
+ );
+ const indexedDbProvider = providers.find(
+ (provider): provider is LocalIndexedDBBackgroundProvider =>
+ provider.flavour === 'local-indexeddb-background'
+ );
+ const provider = sqliteProvider || indexedDbProvider;
+ assertExists(provider, 'no local provider');
+ return provider;
+ }, [providers]);
+ return [
+ status,
+ useCallback(() => {
+ if (!remoteProvider) {
+ return;
+ }
+ startTransition(() => {
+ setStatus({
+ type: 'syncing',
+ });
+ });
+ syncDataSource(
+ () => [
+ workspace.doc.guid,
+ ...[...workspace.doc.subdocs].map(doc => doc.guid),
+ ],
+ remoteProvider.datasource,
+ localProvider.datasource
+ )
+ .then(() => {
+ startTransition(() => {
+ setStatus({
+ type: 'synced',
+ });
+ pushNotification({
+ title: 'Synced successfully',
+ type: 'success',
+ });
+ });
+ })
+ .catch(error => {
+ startTransition(() => {
+ setStatus({
+ type: 'error',
+ error,
+ });
+ pushNotification({
+ title: 'Unable to Sync',
+ message: 'Server error, please try again later.',
+ type: 'error',
+ });
+ });
+ });
+ }, [
+ remoteProvider,
+ localProvider.datasource,
+ workspace.doc.guid,
+ workspace.doc.subdocs,
+ pushNotification,
+ ]),
+ ] as const;
+}
diff --git a/packages/env/src/workspace.ts b/packages/env/src/workspace.ts
index bbe79aa81a..e3f779349a 100644
--- a/packages/env/src/workspace.ts
+++ b/packages/env/src/workspace.ts
@@ -1,4 +1,4 @@
-import type { StatusAdapter } from '@affine/y-provider';
+import type { DataSourceAdapter } from '@affine/y-provider';
import type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
import type {
@@ -32,7 +32,7 @@ export interface BroadCastChannelProvider extends PassiveDocProvider {
* Long polling provider with local IndexedDB
*/
export interface LocalIndexedDBBackgroundProvider
- extends StatusAdapter,
+ extends DataSourceAdapter,
PassiveDocProvider {
flavour: 'local-indexeddb-background';
}
@@ -41,7 +41,7 @@ export interface LocalIndexedDBDownloadProvider extends ActiveDocProvider {
flavour: 'local-indexeddb';
}
-export interface SQLiteProvider extends PassiveDocProvider, StatusAdapter {
+export interface SQLiteProvider extends PassiveDocProvider, DataSourceAdapter {
flavour: 'sqlite';
}
@@ -49,7 +49,9 @@ export interface SQLiteDBDownloadProvider extends ActiveDocProvider {
flavour: 'sqlite-download';
}
-export interface AffineSocketIOProvider extends PassiveDocProvider {
+export interface AffineSocketIOProvider
+ extends PassiveDocProvider,
+ DataSourceAdapter {
flavour: 'affine-socket-io';
}
diff --git a/packages/hooks/src/use-data-source-status.ts b/packages/hooks/src/use-data-source-status.ts
index 0d80098798..bf0e6c86ba 100644
--- a/packages/hooks/src/use-data-source-status.ts
+++ b/packages/hooks/src/use-data-source-status.ts
@@ -1,4 +1,4 @@
-import type { Status, StatusAdapter } from '@affine/y-provider';
+import type { DataSourceAdapter, Status } from '@affine/y-provider';
import { useCallback, useSyncExternalStore } from 'react';
type UIStatus =
@@ -7,9 +7,9 @@ type UIStatus =
type: 'unknown';
};
-export function useDataSourceStatus(datasource: StatusAdapter): UIStatus {
+export function useDataSourceStatus(provider: DataSourceAdapter): UIStatus {
return useSyncExternalStore(
- datasource.subscribeStatusChange,
- useCallback(() => datasource.status, [datasource])
+ provider.subscribeStatusChange,
+ useCallback(() => provider.status, [provider])
);
}
diff --git a/packages/workspace/src/providers/index.ts b/packages/workspace/src/providers/index.ts
index bad631530d..0e425e68be 100644
--- a/packages/workspace/src/providers/index.ts
+++ b/packages/workspace/src/providers/index.ts
@@ -35,9 +35,15 @@ const createAffineSocketIOProvider: DocProviderCreator = (
{ awareness }
): AffineSocketIOProvider => {
const dataSource = createAffineDataSource(id, doc, awareness);
+ const lazyProvider = createLazyProvider(doc, dataSource, {
+ origin: 'affine-socket-io',
+ });
return {
flavour: 'affine-socket-io',
- ...createLazyProvider(doc, dataSource),
+ ...lazyProvider,
+ get status() {
+ return lazyProvider.status;
+ },
};
};
@@ -50,6 +56,7 @@ const createIndexedDBBackgroundProvider: DocProviderCreator = (
let connected = false;
return {
flavour: 'local-indexeddb-background',
+ datasource: indexeddbProvider.datasource,
passive: true,
get status() {
return indexeddbProvider.status;
diff --git a/packages/workspace/src/providers/sqlite-providers.ts b/packages/workspace/src/providers/sqlite-providers.ts
index 9b4df6caa9..53b48a5c87 100644
--- a/packages/workspace/src/providers/sqlite-providers.ts
+++ b/packages/workspace/src/providers/sqlite-providers.ts
@@ -54,11 +54,12 @@ export const createSQLiteProvider: DocProviderCreator = (
id,
rootDoc
): SQLiteProvider => {
- let datasource: ReturnType | null = null;
+ const datasource = createDatasource(id);
let provider: ReturnType | null = null;
let connected = false;
return {
flavour: 'sqlite',
+ datasource,
passive: true,
get status() {
assertExists(provider);
@@ -69,14 +70,12 @@ export const createSQLiteProvider: DocProviderCreator = (
return provider.subscribeStatusChange(onStatusChange);
},
connect: () => {
- datasource = createDatasource(id);
provider = createLazyProvider(rootDoc, datasource, { origin: 'sqlite' });
provider.connect();
connected = true;
},
disconnect: () => {
provider?.disconnect();
- datasource = null;
provider = null;
connected = false;
},
diff --git a/packages/y-indexeddb/src/provider.ts b/packages/y-indexeddb/src/provider.ts
index 1c7fd9e624..5389de2497 100644
--- a/packages/y-indexeddb/src/provider.ts
+++ b/packages/y-indexeddb/src/provider.ts
@@ -4,6 +4,7 @@ import {
writeOperation,
} from '@affine/y-provider';
import { assertExists } from '@blocksuite/global/utils';
+import type { IDBPDatabase } from 'idb';
import { openDB } from 'idb';
import type { Doc } from 'yjs';
import { diffUpdate, encodeStateVectorFromUpdate } from 'yjs';
@@ -31,13 +32,20 @@ export const createIndexedDBDatasource = ({
dbName: string;
mergeCount?: number;
}) => {
- const dbPromise = openDB(dbName, dbVersion, {
- upgrade: upgradeDB,
- });
+ let dbPromise: Promise> | null = null;
+ const getDb = async () => {
+ if (dbPromise === null) {
+ dbPromise = openDB(dbName, dbVersion, {
+ upgrade: upgradeDB,
+ });
+ }
+ return dbPromise;
+ };
+
const adapter = {
queryDocState: async (guid, options) => {
try {
- const db = await dbPromise;
+ const db = await getDb();
const store = db
.transaction('workspace', 'readonly')
.objectStore('workspace');
@@ -64,7 +72,7 @@ export const createIndexedDBDatasource = ({
},
sendDocUpdate: async (guid, update) => {
try {
- const db = await dbPromise;
+ const db = await getDb();
const store = db
.transaction('workspace', 'readwrite')
.objectStore('workspace');
@@ -96,10 +104,15 @@ export const createIndexedDBDatasource = ({
return {
...adapter,
disconnect: () => {
- dbPromise.then(db => db.close()).catch(console.error);
+ getDb()
+ .then(db => db.close())
+ .then(() => {
+ dbPromise = null;
+ })
+ .catch(console.error);
},
cleanup: async () => {
- const db = await dbPromise;
+ const db = await getDb();
await db.clear('workspace');
},
};
@@ -112,7 +125,7 @@ export const createIndexedDBProvider = (
doc: Doc,
dbName: string = DEFAULT_DB_NAME
): IndexedDBProvider => {
- let datasource: ReturnType | null = null;
+ const datasource = createIndexedDBDatasource({ dbName, mergeCount });
let provider: ReturnType | null = null;
const apis = {
@@ -128,14 +141,12 @@ export const createIndexedDBProvider = (
if (apis.connected) {
apis.disconnect();
}
- datasource = createIndexedDBDatasource({ dbName, mergeCount });
provider = createLazyProvider(doc, datasource, { origin: 'idb' });
provider.connect();
},
disconnect: () => {
datasource?.disconnect();
provider?.disconnect();
- datasource = null;
provider = null;
},
cleanup: async () => {
@@ -144,6 +155,7 @@ export const createIndexedDBProvider = (
get connected() {
return provider?.connected || false;
},
+ datasource,
} satisfies IndexedDBProvider;
return apis;
diff --git a/packages/y-indexeddb/src/shared.ts b/packages/y-indexeddb/src/shared.ts
index 3e874e05c4..ed85604fa6 100644
--- a/packages/y-indexeddb/src/shared.ts
+++ b/packages/y-indexeddb/src/shared.ts
@@ -1,4 +1,4 @@
-import type { StatusAdapter } from '@affine/y-provider';
+import type { DataSourceAdapter } from '@affine/y-provider';
import type { DBSchema, IDBPDatabase } from 'idb';
export const dbVersion = 1;
@@ -9,7 +9,7 @@ export function upgradeDB(db: IDBPDatabase) {
db.createObjectStore('milestone', { keyPath: 'id' });
}
-export interface IndexedDBProvider extends StatusAdapter {
+export interface IndexedDBProvider extends DataSourceAdapter {
connect: () => void;
disconnect: () => void;
cleanup: () => Promise;
diff --git a/packages/y-provider/src/data-source.ts b/packages/y-provider/src/data-source.ts
index 94874ffb1f..f5cbecacda 100644
--- a/packages/y-provider/src/data-source.ts
+++ b/packages/y-provider/src/data-source.ts
@@ -1,6 +1,6 @@
-import type { DocState, StatusAdapter } from './types';
+import type { DocState } from './types';
-export interface DatasourceDocAdapter extends Partial {
+export interface DatasourceDocAdapter {
/**
* request diff update from other clients
*/
diff --git a/packages/y-provider/src/lazy-provider.ts b/packages/y-provider/src/lazy-provider.ts
index 115626e9f4..65db5b0b57 100644
--- a/packages/y-provider/src/lazy-provider.ts
+++ b/packages/y-provider/src/lazy-provider.ts
@@ -7,7 +7,7 @@ import {
} from 'yjs';
import type { DatasourceDocAdapter } from './data-source';
-import type { StatusAdapter } from './types';
+import type { DataSourceAdapter } from './types';
import type { Status } from './types';
function getDoc(doc: Doc, guid: string): Doc | undefined {
@@ -45,7 +45,7 @@ export const createLazyProvider = (
rootDoc: Doc,
datasource: DatasourceDocAdapter,
options: LazyProviderOptions = {}
-): DocProvider & StatusAdapter => {
+): DocProvider & DataSourceAdapter => {
let connected = false;
const pendingMap = new Map(); // guid -> pending-updates
const disposableMap = new Map void>>();
@@ -62,21 +62,17 @@ export const createLazyProvider = (
const callbackSet = new Set<() => void>();
const changeStatus = (newStatus: Status) => {
// simulate a stack, each syncing and synced should be paired
- if (newStatus.type === 'idle') {
- if (connected && syncingStack !== 0) {
- console.error('syncingStatus !== 0, this should not happen');
- }
- syncingStack = 0;
- }
if (newStatus.type === 'syncing') {
syncingStack++;
- }
- if (newStatus.type === 'synced' || newStatus.type === 'error') {
+ } else if (newStatus.type === 'synced' || newStatus.type === 'error') {
syncingStack--;
}
if (syncingStack < 0) {
- console.error('syncingStatus < 0, this should not happen');
+ console.error(
+ 'syncingStatus < 0, this should not happen',
+ options.origin
+ );
}
if (syncingStack === 0) {
@@ -85,6 +81,17 @@ export const createLazyProvider = (
if (newStatus.type !== 'synced') {
currentStatus = newStatus;
}
+ if (syncingStack === 0) {
+ if (!connected) {
+ currentStatus = {
+ type: 'idle',
+ };
+ } else {
+ currentStatus = {
+ type: 'synced',
+ };
+ }
+ }
callbackSet.forEach(cb => cb());
};
@@ -102,12 +109,6 @@ export const createLazyProvider = (
stateVector: encodeStateVector(doc),
})
.then(remoteUpdate => {
- if (!connected) {
- changeStatus({
- type: 'idle',
- });
- return;
- }
changeStatus({
type: 'synced',
});
@@ -201,9 +202,6 @@ export const createLazyProvider = (
function setupDatasourceListeners() {
assertExists(abortController, 'abortController should be defined');
const unsubscribe = datasource.onDocUpdate?.((guid, update) => {
- if (!connected) {
- return;
- }
changeStatus({
type: 'syncing',
});
@@ -283,12 +281,6 @@ export const createLazyProvider = (
// but we want to populate the cache for later update events
connectDoc(rootDoc)
.then(() => {
- if (!connected) {
- changeStatus({
- type: 'idle',
- });
- return;
- }
changeStatus({
type: 'synced',
});
@@ -305,9 +297,6 @@ export const createLazyProvider = (
async function disconnect() {
connected = false;
- changeStatus({
- type: 'idle',
- });
disposeAll();
assertExists(abortController, 'abortController should be defined');
abortController.abort();
@@ -349,5 +338,7 @@ export const createLazyProvider = (
passive: true,
connect,
disconnect,
+
+ datasource,
};
};
diff --git a/packages/y-provider/src/types.ts b/packages/y-provider/src/types.ts
index bf44210900..6ad651abfb 100644
--- a/packages/y-provider/src/types.ts
+++ b/packages/y-provider/src/types.ts
@@ -1,3 +1,5 @@
+import type { DatasourceDocAdapter } from './data-source';
+
export type Status =
| {
type: 'idle';
@@ -10,11 +12,13 @@ export type Status =
}
| {
type: 'error';
- error: Error;
+ error: unknown;
};
-export interface StatusAdapter {
+export interface DataSourceAdapter {
+ datasource: DatasourceDocAdapter;
readonly status: Status;
+
subscribeStatusChange(onStatusChange: () => void): () => void;
}