mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
refactor(infra): migrate to new infra (#5565)
This commit is contained in:
@@ -15,7 +15,6 @@
|
||||
"@affine/electron-api": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/workspace": "workspace:*",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"idb": "^8.0.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { AwarenessProvider } from '@affine/workspace';
|
||||
import type { AwarenessProvider } from '@toeverything/infra';
|
||||
import {
|
||||
applyAwarenessUpdate,
|
||||
type Awareness,
|
||||
@@ -14,30 +14,66 @@ const logger = new DebugLogger('affine:awareness:socketio');
|
||||
|
||||
type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>;
|
||||
|
||||
export function createCloudAwarenessProvider(
|
||||
workspaceId: string,
|
||||
awareness: Awareness
|
||||
): AwarenessProvider {
|
||||
const socket = getIoManager().socket('/');
|
||||
export class AffineCloudAwarenessProvider implements AwarenessProvider {
|
||||
socket = getIoManager().socket('/');
|
||||
|
||||
const awarenessBroadcast = ({
|
||||
constructor(
|
||||
private readonly workspaceId: string,
|
||||
private readonly awareness: Awareness
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
this.socket.on('server-awareness-broadcast', this.awarenessBroadcast);
|
||||
this.socket.on(
|
||||
'new-client-awareness-init',
|
||||
this.newClientAwarenessInitHandler
|
||||
);
|
||||
this.awareness.on('update', this.awarenessUpdate);
|
||||
|
||||
window.addEventListener('beforeunload', this.windowBeforeUnloadHandler);
|
||||
|
||||
this.socket.connect();
|
||||
|
||||
this.socket.on('connect', () => this.handleConnect());
|
||||
|
||||
this.socket.emit('client-handshake-awareness', this.workspaceId);
|
||||
this.socket.emit('awareness-init', this.workspaceId);
|
||||
}
|
||||
disconnect(): void {
|
||||
removeAwarenessStates(
|
||||
this.awareness,
|
||||
[this.awareness.clientID],
|
||||
'disconnect'
|
||||
);
|
||||
this.awareness.off('update', this.awarenessUpdate);
|
||||
this.socket.emit('client-leave-awareness', this.workspaceId);
|
||||
this.socket.off('server-awareness-broadcast', this.awarenessBroadcast);
|
||||
this.socket.off(
|
||||
'new-client-awareness-init',
|
||||
this.newClientAwarenessInitHandler
|
||||
);
|
||||
this.socket.off('connect', this.handleConnect);
|
||||
window.removeEventListener('unload', this.windowBeforeUnloadHandler);
|
||||
}
|
||||
|
||||
awarenessBroadcast = ({
|
||||
workspaceId: wsId,
|
||||
awarenessUpdate,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
awarenessUpdate: string;
|
||||
}) => {
|
||||
if (wsId !== workspaceId) {
|
||||
if (wsId !== this.workspaceId) {
|
||||
return;
|
||||
}
|
||||
applyAwarenessUpdate(
|
||||
awareness,
|
||||
this.awareness,
|
||||
base64ToUint8Array(awarenessUpdate),
|
||||
'remote'
|
||||
);
|
||||
};
|
||||
|
||||
const awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
|
||||
awarenessUpdate = (changes: AwarenessChanges, origin: unknown) => {
|
||||
if (origin === 'remote') {
|
||||
return;
|
||||
}
|
||||
@@ -46,63 +82,41 @@ export function createCloudAwarenessProvider(
|
||||
res.concat(cur)
|
||||
);
|
||||
|
||||
const update = encodeAwarenessUpdate(awareness, changedClients);
|
||||
const update = encodeAwarenessUpdate(this.awareness, changedClients);
|
||||
uint8ArrayToBase64(update)
|
||||
.then(encodedUpdate => {
|
||||
socket.emit('awareness-update', {
|
||||
workspaceId: workspaceId,
|
||||
this.socket.emit('awareness-update', {
|
||||
workspaceId: this.workspaceId,
|
||||
awarenessUpdate: encodedUpdate,
|
||||
});
|
||||
})
|
||||
.catch(err => logger.error(err));
|
||||
};
|
||||
|
||||
const newClientAwarenessInitHandler = () => {
|
||||
const awarenessUpdate = encodeAwarenessUpdate(awareness, [
|
||||
awareness.clientID,
|
||||
newClientAwarenessInitHandler = () => {
|
||||
const awarenessUpdate = encodeAwarenessUpdate(this.awareness, [
|
||||
this.awareness.clientID,
|
||||
]);
|
||||
uint8ArrayToBase64(awarenessUpdate)
|
||||
.then(encodedAwarenessUpdate => {
|
||||
socket.emit('awareness-update', {
|
||||
guid: workspaceId,
|
||||
this.socket.emit('awareness-update', {
|
||||
guid: this.workspaceId,
|
||||
awarenessUpdate: encodedAwarenessUpdate,
|
||||
});
|
||||
})
|
||||
.catch(err => logger.error(err));
|
||||
};
|
||||
|
||||
const windowBeforeUnloadHandler = () => {
|
||||
removeAwarenessStates(awareness, [awareness.clientID], 'window unload');
|
||||
windowBeforeUnloadHandler = () => {
|
||||
removeAwarenessStates(
|
||||
this.awareness,
|
||||
[this.awareness.clientID],
|
||||
'window unload'
|
||||
);
|
||||
};
|
||||
|
||||
function handleConnect() {
|
||||
socket.emit('client-handshake-awareness', workspaceId);
|
||||
socket.emit('awareness-init', workspaceId);
|
||||
}
|
||||
|
||||
return {
|
||||
connect: () => {
|
||||
socket.on('server-awareness-broadcast', awarenessBroadcast);
|
||||
socket.on('new-client-awareness-init', newClientAwarenessInitHandler);
|
||||
awareness.on('update', awarenessUpdate);
|
||||
|
||||
window.addEventListener('beforeunload', windowBeforeUnloadHandler);
|
||||
|
||||
socket.connect();
|
||||
|
||||
socket.on('connect', handleConnect);
|
||||
|
||||
socket.emit('client-handshake-awareness', workspaceId);
|
||||
socket.emit('awareness-init', workspaceId);
|
||||
},
|
||||
disconnect: () => {
|
||||
removeAwarenessStates(awareness, [awareness.clientID], 'disconnect');
|
||||
awareness.off('update', awarenessUpdate);
|
||||
socket.emit('client-leave-awareness', workspaceId);
|
||||
socket.off('server-awareness-broadcast', awarenessBroadcast);
|
||||
socket.off('new-client-awareness-init', newClientAwarenessInitHandler);
|
||||
socket.off('connect', handleConnect);
|
||||
window.removeEventListener('unload', windowBeforeUnloadHandler);
|
||||
},
|
||||
handleConnect = () => {
|
||||
this.socket.emit('client-handshake-awareness', this.workspaceId);
|
||||
this.socket.emit('awareness-init', this.workspaceId);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,69 +7,70 @@ import {
|
||||
setBlobMutation,
|
||||
} from '@affine/graphql';
|
||||
import { fetcher } from '@affine/graphql';
|
||||
import type { BlobStorage } from '@affine/workspace';
|
||||
import { BlobStorageOverCapacity } from '@affine/workspace';
|
||||
import { type BlobStorage, BlobStorageOverCapacity } from '@toeverything/infra';
|
||||
import { isArray } from 'lodash-es';
|
||||
|
||||
import { bufferToBlob } from '../utils/buffer-to-blob';
|
||||
|
||||
export const createAffineCloudBlobStorage = (
|
||||
workspaceId: string
|
||||
): BlobStorage => {
|
||||
return {
|
||||
name: 'affine-cloud',
|
||||
readonly: false,
|
||||
get: async key => {
|
||||
const suffix = key.startsWith('/')
|
||||
? key
|
||||
: `/api/workspaces/${workspaceId}/blobs/${key}`;
|
||||
export class AffineCloudBlobStorage implements BlobStorage {
|
||||
constructor(private readonly workspaceId: string) {}
|
||||
|
||||
return fetchWithTraceReport(getBaseUrl() + suffix).then(async res => {
|
||||
if (!res.ok) {
|
||||
// status not in the range 200-299
|
||||
return null;
|
||||
name = 'affine-cloud';
|
||||
readonly = false;
|
||||
|
||||
async get(key: string) {
|
||||
const suffix = key.startsWith('/')
|
||||
? key
|
||||
: `/api/workspaces/${this.workspaceId}/blobs/${key}`;
|
||||
|
||||
return fetchWithTraceReport(getBaseUrl() + suffix).then(async res => {
|
||||
if (!res.ok) {
|
||||
// status not in the range 200-299
|
||||
return null;
|
||||
}
|
||||
return bufferToBlob(await res.arrayBuffer());
|
||||
});
|
||||
}
|
||||
|
||||
async set(key: string, value: Blob) {
|
||||
// set blob will check blob size & quota
|
||||
return await fetcher({
|
||||
query: setBlobMutation,
|
||||
variables: {
|
||||
workspaceId: this.workspaceId,
|
||||
blob: new File([value], key),
|
||||
},
|
||||
})
|
||||
.then(res => res.setBlob)
|
||||
.catch(err => {
|
||||
if (isArray(err)) {
|
||||
err.map(e => {
|
||||
if (e instanceof GraphQLError && e.extensions.code === 413) {
|
||||
throw new BlobStorageOverCapacity(e);
|
||||
} else throw e;
|
||||
});
|
||||
}
|
||||
return bufferToBlob(await res.arrayBuffer());
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
set: async (key, value) => {
|
||||
// set blob will check blob size & quota
|
||||
return await fetcher({
|
||||
query: setBlobMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
blob: new File([value], key),
|
||||
},
|
||||
})
|
||||
.then(res => res.setBlob)
|
||||
.catch(err => {
|
||||
if (isArray(err)) {
|
||||
err.map(e => {
|
||||
if (e instanceof GraphQLError && e.extensions.code === 413) {
|
||||
throw new BlobStorageOverCapacity(e);
|
||||
} else throw e;
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
list: async () => {
|
||||
const result = await fetcher({
|
||||
query: listBlobsQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
return result.listBlobs;
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
await fetcher({
|
||||
query: deleteBlobMutation,
|
||||
variables: {
|
||||
workspaceId,
|
||||
hash: key,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
await fetcher({
|
||||
query: deleteBlobMutation,
|
||||
variables: {
|
||||
workspaceId: key,
|
||||
hash: key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list() {
|
||||
const result = await fetcher({
|
||||
query: listBlobsQuery,
|
||||
variables: {
|
||||
workspaceId: this.workspaceId,
|
||||
},
|
||||
});
|
||||
return result.listBlobs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,26 @@ import {
|
||||
getWorkspacesQuery,
|
||||
} from '@affine/graphql';
|
||||
import { fetcher } from '@affine/graphql';
|
||||
import type { WorkspaceListProvider } from '@affine/workspace';
|
||||
import { globalBlockSuiteSchema } from '@affine/workspace';
|
||||
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||
import type { WorkspaceListProvider } from '@toeverything/infra';
|
||||
import {
|
||||
type BlobStorage,
|
||||
type SyncStorage,
|
||||
type WorkspaceInfo,
|
||||
type WorkspaceMetadata,
|
||||
} from '@toeverything/infra';
|
||||
import { globalBlockSuiteSchema } from '@toeverything/infra';
|
||||
import { difference } from 'lodash-es';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getSession } from 'next-auth/react';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { createLocalBlobStorage } from '../local/blob';
|
||||
import { createLocalStorage } from '../local/sync';
|
||||
import { IndexedDBBlobStorage } from '../local/blob-indexeddb';
|
||||
import { SQLiteBlobStorage } from '../local/blob-sqlite';
|
||||
import { IndexedDBSyncStorage } from '../local/sync-indexeddb';
|
||||
import { SQLiteSyncStorage } from '../local/sync-sqlite';
|
||||
import { CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY } from './consts';
|
||||
import { createAffineStaticStorage } from './sync';
|
||||
import { AffineStaticSyncStorage } from './sync';
|
||||
|
||||
async function getCloudWorkspaceList() {
|
||||
const session = await getSession();
|
||||
@@ -41,120 +49,134 @@ async function getCloudWorkspaceList() {
|
||||
}
|
||||
}
|
||||
|
||||
export function createCloudWorkspaceListProvider(): WorkspaceListProvider {
|
||||
const notifyChannel = new BroadcastChannel(
|
||||
export class CloudWorkspaceListProvider implements WorkspaceListProvider {
|
||||
name = WorkspaceFlavour.AFFINE_CLOUD;
|
||||
notifyChannel = new BroadcastChannel(
|
||||
CLOUD_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY
|
||||
);
|
||||
|
||||
return {
|
||||
name: WorkspaceFlavour.AFFINE_CLOUD,
|
||||
async getList() {
|
||||
return getCloudWorkspaceList();
|
||||
},
|
||||
async create(initial) {
|
||||
const tempId = nanoid();
|
||||
getList(): Promise<WorkspaceMetadata[]> {
|
||||
return getCloudWorkspaceList();
|
||||
}
|
||||
async delete(workspaceId: string): Promise<void> {
|
||||
await fetcher({
|
||||
query: deleteWorkspaceMutation,
|
||||
variables: {
|
||||
id: workspaceId,
|
||||
},
|
||||
});
|
||||
// notify all browser tabs, so they can update their workspace list
|
||||
this.notifyChannel.postMessage(null);
|
||||
}
|
||||
async create(
|
||||
initial: (
|
||||
workspace: BlockSuiteWorkspace,
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void>
|
||||
): Promise<WorkspaceMetadata> {
|
||||
const tempId = nanoid();
|
||||
|
||||
const workspace = new BlockSuiteWorkspace({
|
||||
id: tempId,
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
const workspace = new BlockSuiteWorkspace({
|
||||
id: tempId,
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
|
||||
// create workspace on cloud, get workspace id
|
||||
const {
|
||||
createWorkspace: { id: workspaceId },
|
||||
} = await fetcher({
|
||||
query: createWorkspaceMutation,
|
||||
});
|
||||
// create workspace on cloud, get workspace id
|
||||
const {
|
||||
createWorkspace: { id: workspaceId },
|
||||
} = await fetcher({
|
||||
query: createWorkspaceMutation,
|
||||
});
|
||||
|
||||
// save the initial state to local storage, then sync to cloud
|
||||
const blobStorage = createLocalBlobStorage(workspaceId);
|
||||
const syncStorage = createLocalStorage(workspaceId);
|
||||
// save the initial state to local storage, then sync to cloud
|
||||
const blobStorage = environment.isDesktop
|
||||
? new SQLiteBlobStorage(workspaceId)
|
||||
: new IndexedDBBlobStorage(workspaceId);
|
||||
const syncStorage = environment.isDesktop
|
||||
? new SQLiteSyncStorage(workspaceId)
|
||||
: new IndexedDBSyncStorage(workspaceId);
|
||||
|
||||
// apply initial state
|
||||
await initial(workspace, blobStorage);
|
||||
// apply initial state
|
||||
await initial(workspace, blobStorage);
|
||||
|
||||
// save workspace to local storage, should be vary fast
|
||||
await syncStorage.push(workspaceId, encodeStateAsUpdate(workspace.doc));
|
||||
for (const subdocs of workspace.doc.getSubdocs()) {
|
||||
await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||
}
|
||||
// save workspace to local storage, should be vary fast
|
||||
await syncStorage.push(workspaceId, encodeStateAsUpdate(workspace.doc));
|
||||
for (const subdocs of workspace.doc.getSubdocs()) {
|
||||
await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||
}
|
||||
|
||||
// notify all browser tabs, so they can update their workspace list
|
||||
notifyChannel.postMessage(null);
|
||||
// notify all browser tabs, so they can update their workspace list
|
||||
this.notifyChannel.postMessage(null);
|
||||
|
||||
return workspaceId;
|
||||
},
|
||||
async delete(id) {
|
||||
await fetcher({
|
||||
query: deleteWorkspaceMutation,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
// notify all browser tabs, so they can update their workspace list
|
||||
notifyChannel.postMessage(null);
|
||||
},
|
||||
subscribe(callback) {
|
||||
let lastWorkspaceIDs: string[] = [];
|
||||
return { id: workspaceId, flavour: WorkspaceFlavour.AFFINE_CLOUD };
|
||||
}
|
||||
subscribe(
|
||||
callback: (changed: {
|
||||
added?: WorkspaceMetadata[] | undefined;
|
||||
deleted?: WorkspaceMetadata[] | undefined;
|
||||
}) => void
|
||||
): () => void {
|
||||
let lastWorkspaceIDs: string[] = [];
|
||||
|
||||
function scan() {
|
||||
(async () => {
|
||||
const allWorkspaceIDs = (await getCloudWorkspaceList()).map(
|
||||
workspace => workspace.id
|
||||
);
|
||||
const added = difference(allWorkspaceIDs, lastWorkspaceIDs);
|
||||
const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs);
|
||||
lastWorkspaceIDs = allWorkspaceIDs;
|
||||
callback({
|
||||
added: added.map(id => ({
|
||||
id,
|
||||
flavour: WorkspaceFlavour.AFFINE_CLOUD,
|
||||
})),
|
||||
deleted: deleted.map(id => ({
|
||||
id,
|
||||
flavour: WorkspaceFlavour.AFFINE_CLOUD,
|
||||
})),
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
function scan() {
|
||||
(async () => {
|
||||
const allWorkspaceIDs = (await getCloudWorkspaceList()).map(
|
||||
workspace => workspace.id
|
||||
);
|
||||
const added = difference(allWorkspaceIDs, lastWorkspaceIDs);
|
||||
const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs);
|
||||
lastWorkspaceIDs = allWorkspaceIDs;
|
||||
callback({
|
||||
added: added.map(id => ({
|
||||
id,
|
||||
flavour: WorkspaceFlavour.AFFINE_CLOUD,
|
||||
})),
|
||||
deleted: deleted.map(id => ({
|
||||
id,
|
||||
flavour: WorkspaceFlavour.AFFINE_CLOUD,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
scan();
|
||||
|
||||
// rescan if other tabs notify us
|
||||
notifyChannel.addEventListener('message', scan);
|
||||
return () => {
|
||||
notifyChannel.removeEventListener('message', scan);
|
||||
};
|
||||
},
|
||||
async getInformation(id) {
|
||||
// get information from both cloud and local storage
|
||||
|
||||
// we use affine 'static' storage here, which use http protocol, no need to websocket.
|
||||
const cloudStorage = createAffineStaticStorage(id);
|
||||
const localStorage = createLocalStorage(id);
|
||||
// download root doc
|
||||
const localData = await localStorage.pull(id, new Uint8Array([]));
|
||||
const cloudData = await cloudStorage.pull(id, new Uint8Array([]));
|
||||
|
||||
if (!cloudData && !localData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bs = new BlockSuiteWorkspace({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
if (localData) applyUpdate(bs.doc, localData.data);
|
||||
if (cloudData) applyUpdate(bs.doc, cloudData.data);
|
||||
scan();
|
||||
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
};
|
||||
},
|
||||
};
|
||||
// rescan if other tabs notify us
|
||||
this.notifyChannel.addEventListener('message', scan);
|
||||
return () => {
|
||||
this.notifyChannel.removeEventListener('message', scan);
|
||||
};
|
||||
}
|
||||
async getInformation(id: string): Promise<WorkspaceInfo | undefined> {
|
||||
// get information from both cloud and local storage
|
||||
|
||||
// we use affine 'static' storage here, which use http protocol, no need to websocket.
|
||||
const cloudStorage: SyncStorage = new AffineStaticSyncStorage(id);
|
||||
const localStorage = environment.isDesktop
|
||||
? new SQLiteSyncStorage(id)
|
||||
: new IndexedDBSyncStorage(id);
|
||||
// download root doc
|
||||
const localData = await localStorage.pull(id, new Uint8Array([]));
|
||||
const cloudData = await cloudStorage.pull(id, new Uint8Array([]));
|
||||
|
||||
if (!cloudData && !localData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bs = new BlockSuiteWorkspace({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
|
||||
if (localData) applyUpdate(bs.doc, localData.data);
|
||||
if (cloudData) applyUpdate(bs.doc, cloudData.data);
|
||||
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { fetchWithTraceReport } from '@affine/graphql';
|
||||
import type { SyncStorage } from '@affine/workspace';
|
||||
import { type SyncStorage } from '@toeverything/infra';
|
||||
import type { CleanupService } from '@toeverything/infra/lifecycle';
|
||||
|
||||
import { getIoManager } from '../../utils/affine-io';
|
||||
import { base64ToUint8Array, uint8ArrayToBase64 } from '../../utils/base64';
|
||||
@@ -8,22 +9,21 @@ import { MultipleBatchSyncSender } from './batch-sync-sender';
|
||||
|
||||
const logger = new DebugLogger('affine:storage:socketio');
|
||||
|
||||
export function createAffineStorage(
|
||||
workspaceId: string
|
||||
): SyncStorage & { disconnect: () => void } {
|
||||
logger.debug('createAffineStorage', workspaceId);
|
||||
const socket = getIoManager().socket('/');
|
||||
export class AffineSyncStorage implements SyncStorage {
|
||||
name = 'affine-cloud';
|
||||
|
||||
const syncSender = new MultipleBatchSyncSender(async (guid, updates) => {
|
||||
socket = getIoManager().socket('/');
|
||||
|
||||
syncSender = new MultipleBatchSyncSender(async (guid, updates) => {
|
||||
const payload = await Promise.all(
|
||||
updates.map(update => uint8ArrayToBase64(update))
|
||||
);
|
||||
|
||||
return new Promise(resolve => {
|
||||
socket.emit(
|
||||
this.socket.emit(
|
||||
'client-update-v2',
|
||||
{
|
||||
workspaceId,
|
||||
workspaceId: this.workspaceId,
|
||||
guid,
|
||||
updates: payload,
|
||||
},
|
||||
@@ -35,7 +35,7 @@ export function createAffineStorage(
|
||||
// TODO: raise error with different code to users
|
||||
if (response.error) {
|
||||
logger.error('client-update-v2 error', {
|
||||
workspaceId,
|
||||
workspaceId: this.workspaceId,
|
||||
guid,
|
||||
response,
|
||||
});
|
||||
@@ -51,145 +51,160 @@ export function createAffineStorage(
|
||||
});
|
||||
});
|
||||
|
||||
function handleConnect() {
|
||||
socket.emit(
|
||||
constructor(
|
||||
private readonly workspaceId: string,
|
||||
cleanupService: CleanupService
|
||||
) {
|
||||
this.socket.on('connect', this.handleConnect);
|
||||
|
||||
this.socket.connect();
|
||||
|
||||
this.socket.emit(
|
||||
'client-handshake-sync',
|
||||
workspaceId,
|
||||
this.workspaceId,
|
||||
(response: { error?: any }) => {
|
||||
if (!response.error) {
|
||||
syncSender.start();
|
||||
this.syncSender.start();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
cleanupService.add(() => {
|
||||
this.cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('connect', handleConnect);
|
||||
|
||||
socket.connect();
|
||||
|
||||
socket.emit(
|
||||
'client-handshake-sync',
|
||||
workspaceId,
|
||||
(response: { error?: any }) => {
|
||||
if (!response.error) {
|
||||
syncSender.start();
|
||||
handleConnect = () => {
|
||||
this.socket.emit(
|
||||
'client-handshake-sync',
|
||||
this.workspaceId,
|
||||
(response: { error?: any }) => {
|
||||
if (!response.error) {
|
||||
this.syncSender.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'affine-cloud',
|
||||
async pull(docId, state) {
|
||||
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
|
||||
async pull(
|
||||
docId: string,
|
||||
state: Uint8Array
|
||||
): Promise<{ data: Uint8Array; state?: Uint8Array } | null> {
|
||||
const stateVector = state ? await uint8ArrayToBase64(state) : undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.debug('doc-load-v2', {
|
||||
workspaceId: workspaceId,
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.debug('doc-load-v2', {
|
||||
workspaceId: this.workspaceId,
|
||||
guid: docId,
|
||||
stateVector,
|
||||
});
|
||||
this.socket.emit(
|
||||
'doc-load-v2',
|
||||
{
|
||||
workspaceId: this.workspaceId,
|
||||
guid: docId,
|
||||
stateVector,
|
||||
});
|
||||
socket.emit(
|
||||
'doc-load-v2',
|
||||
{
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
(
|
||||
response: // TODO: reuse `EventError` with server
|
||||
{ error: any } | { data: { missing: string; state: string } }
|
||||
) => {
|
||||
logger.debug('doc-load callback', {
|
||||
workspaceId: this.workspaceId,
|
||||
guid: docId,
|
||||
stateVector,
|
||||
},
|
||||
(
|
||||
response: // TODO: reuse `EventError` with server
|
||||
{ error: any } | { data: { missing: string; state: string } }
|
||||
) => {
|
||||
logger.debug('doc-load callback', {
|
||||
workspaceId: workspaceId,
|
||||
guid: docId,
|
||||
stateVector,
|
||||
response,
|
||||
});
|
||||
|
||||
if ('error' in response) {
|
||||
// TODO: result `EventError` with server
|
||||
if (response.error.code === 'DOC_NOT_FOUND') {
|
||||
resolve(null);
|
||||
} else {
|
||||
reject(new Error(response.error.message));
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
data: base64ToUint8Array(response.data.missing),
|
||||
state: response.data.state
|
||||
? base64ToUint8Array(response.data.state)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
async push(docId, update) {
|
||||
logger.debug('client-update-v2', {
|
||||
workspaceId,
|
||||
guid: docId,
|
||||
update,
|
||||
});
|
||||
|
||||
await syncSender.send(docId, update);
|
||||
},
|
||||
async subscribe(cb, disconnect) {
|
||||
const handleUpdate = async (message: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
updates: string[];
|
||||
}) => {
|
||||
if (message.workspaceId === workspaceId) {
|
||||
message.updates.forEach(update => {
|
||||
cb(message.guid, base64ToUint8Array(update));
|
||||
response,
|
||||
});
|
||||
}
|
||||
};
|
||||
socket.on('server-updates', handleUpdate);
|
||||
|
||||
socket.on('disconnect', reason => {
|
||||
socket.off('server-updates', handleUpdate);
|
||||
disconnect(reason);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('server-updates', handleUpdate);
|
||||
};
|
||||
},
|
||||
disconnect() {
|
||||
syncSender.stop();
|
||||
socket.emit('client-leave-sync', workspaceId);
|
||||
socket.off('connect', handleConnect);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createAffineStaticStorage(workspaceId: string): SyncStorage {
|
||||
logger.debug('createAffineStaticStorage', workspaceId);
|
||||
|
||||
return {
|
||||
name: 'affine-cloud-static',
|
||||
async pull(docId) {
|
||||
const response = await fetchWithTraceReport(
|
||||
`/api/workspaces/${workspaceId}/docs/${docId}`,
|
||||
{
|
||||
priority: 'high',
|
||||
if ('error' in response) {
|
||||
// TODO: result `EventError` with server
|
||||
if (response.error.code === 'DOC_NOT_FOUND') {
|
||||
resolve(null);
|
||||
} else {
|
||||
reject(new Error(response.error.message));
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
data: base64ToUint8Array(response.data.missing),
|
||||
state: response.data.state
|
||||
? base64ToUint8Array(response.data.state)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
});
|
||||
}
|
||||
|
||||
return { data: new Uint8Array(arrayBuffer) };
|
||||
async push(docId: string, update: Uint8Array) {
|
||||
logger.debug('client-update-v2', {
|
||||
workspaceId: this.workspaceId,
|
||||
guid: docId,
|
||||
update,
|
||||
});
|
||||
|
||||
await this.syncSender.send(docId, update);
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
cb: (docId: string, data: Uint8Array) => void,
|
||||
disconnect: (reason: string) => void
|
||||
) {
|
||||
const handleUpdate = async (message: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
updates: string[];
|
||||
}) => {
|
||||
if (message.workspaceId === this.workspaceId) {
|
||||
message.updates.forEach(update => {
|
||||
cb(message.guid, base64ToUint8Array(update));
|
||||
});
|
||||
}
|
||||
};
|
||||
this.socket.on('server-updates', handleUpdate);
|
||||
|
||||
return null;
|
||||
},
|
||||
async push() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
async subscribe() {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
};
|
||||
this.socket.on('disconnect', reason => {
|
||||
this.socket.off('server-updates', handleUpdate);
|
||||
disconnect(reason);
|
||||
});
|
||||
|
||||
return () => {
|
||||
this.socket.off('server-updates', handleUpdate);
|
||||
};
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.syncSender.stop();
|
||||
this.socket.emit('client-leave-sync', this.workspaceId);
|
||||
this.socket.off('connect', this.handleConnect);
|
||||
}
|
||||
}
|
||||
|
||||
export class AffineStaticSyncStorage implements SyncStorage {
|
||||
name = 'affine-cloud-static';
|
||||
constructor(private readonly workspaceId: string) {}
|
||||
|
||||
async pull(
|
||||
docId: string
|
||||
): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> {
|
||||
const response = await fetchWithTraceReport(
|
||||
`/api/workspaces/${this.workspaceId}/docs/${docId}`,
|
||||
{
|
||||
priority: 'high',
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
return { data: new Uint8Array(arrayBuffer) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
push(): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
subscribe(): Promise<() => void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,55 @@
|
||||
import { setupEditorFlags } from '@affine/env/global';
|
||||
import type { WorkspaceFactory } from '@affine/workspace';
|
||||
import { BlobEngine, SyncEngine, WorkspaceEngine } from '@affine/workspace';
|
||||
import { globalBlockSuiteSchema } from '@affine/workspace';
|
||||
import { Workspace } from '@affine/workspace';
|
||||
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { WorkspaceFactory } from '@toeverything/infra';
|
||||
import {
|
||||
AwarenessContext,
|
||||
AwarenessProvider,
|
||||
RemoteBlobStorage,
|
||||
RemoteSyncStorage,
|
||||
WorkspaceIdContext,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
import type { ServiceCollection } from '@toeverything/infra/di';
|
||||
import { CleanupService } from '@toeverything/infra/lifecycle';
|
||||
|
||||
import { createBroadcastChannelAwarenessProvider } from '../local/awareness';
|
||||
import { createLocalBlobStorage } from '../local/blob';
|
||||
import { createStaticBlobStorage } from '../local/blob-static';
|
||||
import { createLocalStorage } from '../local/sync';
|
||||
import { createCloudAwarenessProvider } from './awareness';
|
||||
import { createAffineCloudBlobStorage } from './blob';
|
||||
import { createAffineStorage } from './sync';
|
||||
import { LocalWorkspaceFactory } from '../local';
|
||||
import { IndexedDBBlobStorage, SQLiteBlobStorage } from '../local';
|
||||
import { AffineCloudAwarenessProvider } from './awareness';
|
||||
import { AffineCloudBlobStorage } from './blob';
|
||||
import { AffineSyncStorage } from './sync';
|
||||
|
||||
export const cloudWorkspaceFactory: WorkspaceFactory = {
|
||||
name: 'affine-cloud',
|
||||
openWorkspace(metadata) {
|
||||
const blobEngine = new BlobEngine(createLocalBlobStorage(metadata.id), [
|
||||
createAffineCloudBlobStorage(metadata.id),
|
||||
createStaticBlobStorage(),
|
||||
]);
|
||||
export class CloudWorkspaceFactory implements WorkspaceFactory {
|
||||
name = WorkspaceFlavour.AFFINE_CLOUD;
|
||||
configureWorkspace(services: ServiceCollection): void {
|
||||
// configure local-first providers
|
||||
new LocalWorkspaceFactory().configureWorkspace(services);
|
||||
|
||||
// create blocksuite workspace
|
||||
const bs = new BlockSuiteWorkspace({
|
||||
id: metadata.id,
|
||||
blobStorages: [
|
||||
() => ({
|
||||
crud: blobEngine,
|
||||
}),
|
||||
],
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
|
||||
const affineStorage = createAffineStorage(metadata.id);
|
||||
const syncEngine = new SyncEngine(bs.doc, createLocalStorage(metadata.id), [
|
||||
affineStorage,
|
||||
]);
|
||||
|
||||
const awarenessProviders = [
|
||||
createBroadcastChannelAwarenessProvider(
|
||||
metadata.id,
|
||||
bs.awarenessStore.awareness
|
||||
),
|
||||
createCloudAwarenessProvider(metadata.id, bs.awarenessStore.awareness),
|
||||
];
|
||||
const engine = new WorkspaceEngine(
|
||||
blobEngine,
|
||||
syncEngine,
|
||||
awarenessProviders
|
||||
);
|
||||
|
||||
setupEditorFlags(bs);
|
||||
|
||||
const workspace = new Workspace(metadata, engine, bs);
|
||||
|
||||
workspace.onStop.once(() => {
|
||||
// affine sync storage need manually disconnect
|
||||
affineStorage.disconnect();
|
||||
});
|
||||
|
||||
return workspace;
|
||||
},
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.addImpl(RemoteBlobStorage('affine-cloud'), AffineCloudBlobStorage, [
|
||||
WorkspaceIdContext,
|
||||
])
|
||||
.addImpl(RemoteSyncStorage('affine-cloud'), AffineSyncStorage, [
|
||||
WorkspaceIdContext,
|
||||
CleanupService,
|
||||
])
|
||||
.addImpl(
|
||||
AwarenessProvider('affine-cloud'),
|
||||
AffineCloudAwarenessProvider,
|
||||
[WorkspaceIdContext, AwarenessContext]
|
||||
);
|
||||
}
|
||||
async getWorkspaceBlob(id: string, blobKey: string): Promise<Blob | null> {
|
||||
// try to get blob from local storage first
|
||||
const localBlobStorage = createLocalBlobStorage(id);
|
||||
const localBlobStorage = environment.isDesktop
|
||||
? new SQLiteBlobStorage(id)
|
||||
: new IndexedDBBlobStorage(id);
|
||||
|
||||
const localBlob = await localBlobStorage.get(blobKey);
|
||||
if (localBlob) {
|
||||
return localBlob;
|
||||
}
|
||||
|
||||
const blobStorage = createAffineCloudBlobStorage(id);
|
||||
const blobStorage = new AffineCloudBlobStorage(id);
|
||||
return await blobStorage.get(blobKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import { WorkspaceList, WorkspaceManager } from '@affine/workspace';
|
||||
import { WorkspaceFactory, WorkspaceListProvider } from '@toeverything/infra';
|
||||
import type { ServiceCollection } from '@toeverything/infra/di';
|
||||
|
||||
import { CloudWorkspaceFactory, CloudWorkspaceListProvider } from './cloud';
|
||||
import {
|
||||
cloudWorkspaceFactory,
|
||||
createCloudWorkspaceListProvider,
|
||||
} from './cloud';
|
||||
import {
|
||||
createLocalWorkspaceListProvider,
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
localWorkspaceFactory,
|
||||
LocalWorkspaceFactory,
|
||||
LocalWorkspaceListProvider,
|
||||
} from './local';
|
||||
|
||||
const list = new WorkspaceList([
|
||||
createLocalWorkspaceListProvider(),
|
||||
createCloudWorkspaceListProvider(),
|
||||
]);
|
||||
|
||||
export const workspaceManager = new WorkspaceManager(list, [
|
||||
localWorkspaceFactory,
|
||||
cloudWorkspaceFactory,
|
||||
]);
|
||||
|
||||
(window as any).workspaceManager = workspaceManager;
|
||||
|
||||
export * from './cloud';
|
||||
export * from './local';
|
||||
|
||||
export function configureWorkspaceImplServices(services: ServiceCollection) {
|
||||
services
|
||||
.addImpl(WorkspaceListProvider('affine-cloud'), CloudWorkspaceListProvider)
|
||||
.addImpl(WorkspaceFactory('affine-cloud'), CloudWorkspaceFactory)
|
||||
.addImpl(WorkspaceListProvider('local'), LocalWorkspaceListProvider)
|
||||
.addImpl(WorkspaceFactory('local'), LocalWorkspaceFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* a hack for directly add local workspace to workspace list
|
||||
* Used after copying sqlite database file to appdata folder
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { SyncEngine, SyncEngineStep, SyncPeerStep } from '@affine/workspace';
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { Schema, Workspace } from '@blocksuite/store';
|
||||
import { SyncEngine, SyncEngineStep, SyncPeerStep } from '@toeverything/infra';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { Doc } from 'yjs';
|
||||
|
||||
import { createIndexedDBStorage } from '..';
|
||||
import { IndexedDBSyncStorage } from '..';
|
||||
import { createTestStorage } from './test-storage';
|
||||
|
||||
const schema = new Schema();
|
||||
@@ -29,10 +29,10 @@ describe('SyncEngine', () => {
|
||||
|
||||
const syncEngine = new SyncEngine(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid),
|
||||
new IndexedDBSyncStorage(workspace.doc.guid),
|
||||
[
|
||||
createIndexedDBStorage(workspace.doc.guid + '1'),
|
||||
createIndexedDBStorage(workspace.doc.guid + '2'),
|
||||
new IndexedDBSyncStorage(workspace.doc.guid + '1'),
|
||||
new IndexedDBSyncStorage(workspace.doc.guid + '2'),
|
||||
]
|
||||
);
|
||||
syncEngine.start();
|
||||
@@ -60,7 +60,7 @@ describe('SyncEngine', () => {
|
||||
});
|
||||
const syncEngine = new SyncEngine(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid),
|
||||
new IndexedDBSyncStorage(workspace.doc.guid),
|
||||
[]
|
||||
);
|
||||
syncEngine.start();
|
||||
@@ -79,7 +79,7 @@ describe('SyncEngine', () => {
|
||||
});
|
||||
const syncEngine = new SyncEngine(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid + '1'),
|
||||
new IndexedDBSyncStorage(workspace.doc.guid + '1'),
|
||||
[]
|
||||
);
|
||||
syncEngine.start();
|
||||
@@ -98,7 +98,7 @@ describe('SyncEngine', () => {
|
||||
});
|
||||
const syncEngine = new SyncEngine(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid + '2'),
|
||||
new IndexedDBSyncStorage(workspace.doc.guid + '2'),
|
||||
[]
|
||||
);
|
||||
syncEngine.start();
|
||||
@@ -113,9 +113,9 @@ describe('SyncEngine', () => {
|
||||
test('status', async () => {
|
||||
const ydoc = new Doc({ guid: 'test - syncengine - status' });
|
||||
|
||||
const localStorage = createTestStorage(createIndexedDBStorage(ydoc.guid));
|
||||
const localStorage = createTestStorage(new IndexedDBSyncStorage(ydoc.guid));
|
||||
const remoteStorage = createTestStorage(
|
||||
createIndexedDBStorage(ydoc.guid + '1')
|
||||
new IndexedDBSyncStorage(ydoc.guid + '1')
|
||||
);
|
||||
|
||||
localStorage.pausePull();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { SyncPeer, SyncPeerStep } from '@affine/workspace';
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { Schema, Workspace } from '@blocksuite/store';
|
||||
import { SyncPeer, SyncPeerStep } from '@toeverything/infra';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createIndexedDBStorage } from '..';
|
||||
import { IndexedDBSyncStorage } from '..';
|
||||
|
||||
const schema = new Schema();
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('SyncPeer', () => {
|
||||
|
||||
const syncPeer = new SyncPeer(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid)
|
||||
new IndexedDBSyncStorage(workspace.doc.guid)
|
||||
);
|
||||
await syncPeer.waitForLoaded();
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('SyncPeer', () => {
|
||||
});
|
||||
const syncPeer = new SyncPeer(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid)
|
||||
new IndexedDBSyncStorage(workspace.doc.guid)
|
||||
);
|
||||
await syncPeer.waitForSynced();
|
||||
expect(workspace.doc.toJSON()).toEqual({
|
||||
@@ -73,7 +73,7 @@ describe('SyncPeer', () => {
|
||||
|
||||
const syncPeer = new SyncPeer(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid)
|
||||
new IndexedDBSyncStorage(workspace.doc.guid)
|
||||
);
|
||||
expect(syncPeer.status.step).toBe(SyncPeerStep.LoadingRootDoc);
|
||||
await syncPeer.waitForSynced();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SyncStorage } from '@affine/workspace';
|
||||
import type { SyncStorage } from '@toeverything/infra';
|
||||
|
||||
export function createTestStorage(origin: SyncStorage) {
|
||||
const controler = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AwarenessProvider } from '@affine/workspace';
|
||||
import type { AwarenessProvider } from '@toeverything/infra';
|
||||
import type { Awareness } from 'y-protocols/awareness.js';
|
||||
import {
|
||||
applyAwarenessUpdate,
|
||||
@@ -11,13 +11,35 @@ type ChannelMessage =
|
||||
| { type: 'connect' }
|
||||
| { type: 'update'; update: Uint8Array };
|
||||
|
||||
export function createBroadcastChannelAwarenessProvider(
|
||||
workspaceId: string,
|
||||
awareness: Awareness
|
||||
): AwarenessProvider {
|
||||
const channel = new BroadcastChannel('awareness:' + workspaceId);
|
||||
export class BroadcastChannelAwarenessProvider implements AwarenessProvider {
|
||||
channel: BroadcastChannel | null = null;
|
||||
|
||||
function handleAwarenessUpdate(changes: AwarenessChanges, origin: unknown) {
|
||||
constructor(
|
||||
private readonly workspaceId: string,
|
||||
private readonly awareness: Awareness
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
this.channel = new BroadcastChannel('awareness:' + this.workspaceId);
|
||||
this.channel.postMessage({
|
||||
type: 'connect',
|
||||
} satisfies ChannelMessage);
|
||||
this.awareness.on('update', (changes: AwarenessChanges, origin: unknown) =>
|
||||
this.handleAwarenessUpdate(changes, origin)
|
||||
);
|
||||
this.channel.addEventListener(
|
||||
'message',
|
||||
(event: MessageEvent<ChannelMessage>) => {
|
||||
this.handleChannelMessage(event);
|
||||
}
|
||||
);
|
||||
}
|
||||
disconnect(): void {
|
||||
this.channel?.close();
|
||||
this.channel = null;
|
||||
}
|
||||
|
||||
handleAwarenessUpdate(changes: AwarenessChanges, origin: unknown) {
|
||||
if (origin === 'remote') {
|
||||
return;
|
||||
}
|
||||
@@ -26,37 +48,25 @@ export function createBroadcastChannelAwarenessProvider(
|
||||
res.concat(cur)
|
||||
);
|
||||
|
||||
const update = encodeAwarenessUpdate(awareness, changedClients);
|
||||
channel.postMessage({
|
||||
const update = encodeAwarenessUpdate(this.awareness, changedClients);
|
||||
this.channel?.postMessage({
|
||||
type: 'update',
|
||||
update: update,
|
||||
} satisfies ChannelMessage);
|
||||
}
|
||||
|
||||
function handleChannelMessage(event: MessageEvent<ChannelMessage>) {
|
||||
handleChannelMessage(event: MessageEvent<ChannelMessage>) {
|
||||
if (event.data.type === 'update') {
|
||||
const update = event.data.update;
|
||||
applyAwarenessUpdate(awareness, update, 'remote');
|
||||
applyAwarenessUpdate(this.awareness, update, 'remote');
|
||||
}
|
||||
if (event.data.type === 'connect') {
|
||||
channel.postMessage({
|
||||
this.channel?.postMessage({
|
||||
type: 'update',
|
||||
update: encodeAwarenessUpdate(awareness, [awareness.clientID]),
|
||||
update: encodeAwarenessUpdate(this.awareness, [
|
||||
this.awareness.clientID,
|
||||
]),
|
||||
} satisfies ChannelMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connect() {
|
||||
channel.postMessage({
|
||||
type: 'connect',
|
||||
} satisfies ChannelMessage);
|
||||
awareness.on('update', handleAwarenessUpdate);
|
||||
channel.addEventListener('message', handleChannelMessage);
|
||||
},
|
||||
disconnect() {
|
||||
awareness.off('update', handleAwarenessUpdate);
|
||||
channel.removeEventListener('message', handleChannelMessage);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import type { BlobStorage } from '@affine/workspace';
|
||||
import { type BlobStorage } from '@toeverything/infra';
|
||||
import { createStore, del, get, keys, set } from 'idb-keyval';
|
||||
|
||||
import { bufferToBlob } from '../utils/buffer-to-blob';
|
||||
|
||||
export const createIndexeddbBlobStorage = (
|
||||
workspaceId: string
|
||||
): BlobStorage => {
|
||||
const db = createStore(`${workspaceId}_blob`, 'blob');
|
||||
const mimeTypeDb = createStore(`${workspaceId}_blob_mime`, 'blob_mime');
|
||||
return {
|
||||
name: 'indexeddb',
|
||||
readonly: false,
|
||||
get: async (key: string) => {
|
||||
const res = await get<ArrayBuffer>(key, db);
|
||||
if (res) {
|
||||
return bufferToBlob(res);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
set: async (key: string, value: Blob) => {
|
||||
await set(key, await value.arrayBuffer(), db);
|
||||
await set(key, value.type, mimeTypeDb);
|
||||
return key;
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
await del(key, db);
|
||||
await del(key, mimeTypeDb);
|
||||
},
|
||||
list: async () => {
|
||||
return keys<string>(db);
|
||||
},
|
||||
};
|
||||
};
|
||||
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,38 +1,36 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import type { BlobStorage } from '@affine/workspace';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { type BlobStorage } from '@toeverything/infra';
|
||||
|
||||
import { bufferToBlob } from '../utils/buffer-to-blob';
|
||||
|
||||
export const createSQLiteBlobStorage = (workspaceId: string): BlobStorage => {
|
||||
assertExists(apis);
|
||||
return {
|
||||
name: 'sqlite',
|
||||
readonly: false,
|
||||
get: async (key: string) => {
|
||||
assertExists(apis);
|
||||
const buffer = await apis.db.getBlob(workspaceId, key);
|
||||
if (buffer) {
|
||||
return bufferToBlob(buffer);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
set: async (key: string, value: Blob) => {
|
||||
assertExists(apis);
|
||||
await apis.db.addBlob(
|
||||
workspaceId,
|
||||
key,
|
||||
new Uint8Array(await value.arrayBuffer())
|
||||
);
|
||||
return key;
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
assertExists(apis);
|
||||
return apis.db.deleteBlob(workspaceId, key);
|
||||
},
|
||||
list: async () => {
|
||||
assertExists(apis);
|
||||
return apis.db.getBlobKeys(workspaceId);
|
||||
},
|
||||
};
|
||||
};
|
||||
export class SQLiteBlobStorage implements BlobStorage {
|
||||
constructor(private readonly workspaceId: string) {}
|
||||
name = 'sqlite';
|
||||
readonly = false;
|
||||
async get(key: string) {
|
||||
assertExists(apis);
|
||||
const buffer = await apis.db.getBlob(this.workspaceId, key);
|
||||
if (buffer) {
|
||||
return bufferToBlob(buffer);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async set(key: string, value: Blob) {
|
||||
assertExists(apis);
|
||||
await apis.db.addBlob(
|
||||
this.workspaceId,
|
||||
key,
|
||||
new Uint8Array(await value.arrayBuffer())
|
||||
);
|
||||
return key;
|
||||
}
|
||||
delete(key: string) {
|
||||
assertExists(apis);
|
||||
return apis.db.deleteBlob(this.workspaceId, key);
|
||||
}
|
||||
list() {
|
||||
assertExists(apis);
|
||||
return apis.db.getBlobKeys(this.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BlobStorage } from '@affine/workspace';
|
||||
import { type BlobStorage } from '@toeverything/infra';
|
||||
|
||||
export const predefinedStaticFiles = [
|
||||
'029uztLz2CzJezK7UUhrbGiWUdZ0J7NVs_qR6RDsvb8=',
|
||||
@@ -36,37 +36,36 @@ export const predefinedStaticFiles = [
|
||||
'v2yF7lY2L5rtorTtTmYFsoMb9dBPKs5M1y9cUKxcI1M=',
|
||||
];
|
||||
|
||||
export const createStaticBlobStorage = (): BlobStorage => {
|
||||
return {
|
||||
name: 'static',
|
||||
readonly: true,
|
||||
get: async (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();
|
||||
}
|
||||
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;
|
||||
},
|
||||
set: async key => {
|
||||
// ignore
|
||||
return key;
|
||||
},
|
||||
delete: async () => {
|
||||
// ignore
|
||||
},
|
||||
list: async () => {
|
||||
// ignore
|
||||
return [];
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
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,10 +0,0 @@
|
||||
import { createIndexeddbBlobStorage } from './blob-indexeddb';
|
||||
import { createSQLiteBlobStorage } from './blob-sqlite';
|
||||
|
||||
export function createLocalBlobStorage(workspaceId: string) {
|
||||
if (environment.isDesktop) {
|
||||
return createSQLiteBlobStorage(workspaceId);
|
||||
} else {
|
||||
return createIndexeddbBlobStorage(workspaceId);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
export * from './awareness';
|
||||
export * from './blob';
|
||||
export * from './blob-indexeddb';
|
||||
export * from './blob-sqlite';
|
||||
export * from './blob-static';
|
||||
export * from './consts';
|
||||
export * from './list';
|
||||
export * from './sync';
|
||||
export * from './sync-indexeddb';
|
||||
export * from './sync-sqlite';
|
||||
export * from './workspace-factory';
|
||||
|
||||
@@ -1,130 +1,151 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { WorkspaceListProvider } from '@affine/workspace';
|
||||
import { globalBlockSuiteSchema } from '@affine/workspace';
|
||||
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||
import type { WorkspaceListProvider } from '@toeverything/infra';
|
||||
import {
|
||||
type BlobStorage,
|
||||
type WorkspaceInfo,
|
||||
type WorkspaceMetadata,
|
||||
} from '@toeverything/infra';
|
||||
import { globalBlockSuiteSchema } from '@toeverything/infra';
|
||||
import { difference } from 'lodash-es';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { createLocalBlobStorage } from './blob';
|
||||
import { IndexedDBBlobStorage } from './blob-indexeddb';
|
||||
import { SQLiteBlobStorage } from './blob-sqlite';
|
||||
import {
|
||||
LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY,
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
} from './consts';
|
||||
import { createLocalStorage } from './sync';
|
||||
import { IndexedDBSyncStorage } from './sync-indexeddb';
|
||||
import { SQLiteSyncStorage } from './sync-sqlite';
|
||||
|
||||
export function createLocalWorkspaceListProvider(): WorkspaceListProvider {
|
||||
const notifyChannel = new BroadcastChannel(
|
||||
export class LocalWorkspaceListProvider implements WorkspaceListProvider {
|
||||
name = WorkspaceFlavour.LOCAL;
|
||||
|
||||
notifyChannel = new BroadcastChannel(
|
||||
LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY
|
||||
);
|
||||
|
||||
return {
|
||||
name: WorkspaceFlavour.LOCAL,
|
||||
getList() {
|
||||
return Promise.resolve(
|
||||
JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL }))
|
||||
);
|
||||
},
|
||||
subscribe(callback) {
|
||||
let lastWorkspaceIDs: string[] = [];
|
||||
async getList() {
|
||||
return JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL }));
|
||||
}
|
||||
|
||||
function scan() {
|
||||
const allWorkspaceIDs: string[] = JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
);
|
||||
const added = difference(allWorkspaceIDs, lastWorkspaceIDs);
|
||||
const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs);
|
||||
lastWorkspaceIDs = allWorkspaceIDs;
|
||||
callback({
|
||||
added: added.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })),
|
||||
deleted: deleted.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })),
|
||||
});
|
||||
}
|
||||
async delete(workspaceId: string) {
|
||||
const allWorkspaceIDs: string[] = JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
);
|
||||
localStorage.setItem(
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(allWorkspaceIDs.filter(x => x !== workspaceId))
|
||||
);
|
||||
|
||||
scan();
|
||||
if (apis && environment.isDesktop) {
|
||||
await apis.workspace.delete(workspaceId);
|
||||
}
|
||||
|
||||
// rescan if other tabs notify us
|
||||
notifyChannel.addEventListener('message', scan);
|
||||
return () => {
|
||||
notifyChannel.removeEventListener('message', scan);
|
||||
};
|
||||
},
|
||||
async create(initial) {
|
||||
const id = nanoid();
|
||||
// notify all browser tabs, so they can update their workspace list
|
||||
this.notifyChannel.postMessage(workspaceId);
|
||||
}
|
||||
|
||||
const blobStorage = createLocalBlobStorage(id);
|
||||
const syncStorage = createLocalStorage(id);
|
||||
async create(
|
||||
initial: (
|
||||
workspace: BlockSuiteWorkspace,
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void>
|
||||
): Promise<WorkspaceMetadata> {
|
||||
const id = nanoid();
|
||||
|
||||
const workspace = new BlockSuiteWorkspace({
|
||||
id: id,
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
const blobStorage = environment.isDesktop
|
||||
? new SQLiteBlobStorage(id)
|
||||
: new IndexedDBBlobStorage(id);
|
||||
const syncStorage = environment.isDesktop
|
||||
? new SQLiteSyncStorage(id)
|
||||
: new IndexedDBSyncStorage(id);
|
||||
|
||||
// apply initial state
|
||||
await initial(workspace, blobStorage);
|
||||
const workspace = new BlockSuiteWorkspace({
|
||||
id: id,
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
|
||||
// save workspace to local storage
|
||||
await syncStorage.push(id, encodeStateAsUpdate(workspace.doc));
|
||||
for (const subdocs of workspace.doc.getSubdocs()) {
|
||||
await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||
}
|
||||
// apply initial state
|
||||
await initial(workspace, blobStorage);
|
||||
|
||||
// save workspace id to local storage
|
||||
// save workspace to local storage
|
||||
await syncStorage.push(id, encodeStateAsUpdate(workspace.doc));
|
||||
for (const subdocs of workspace.doc.getSubdocs()) {
|
||||
await syncStorage.push(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||
}
|
||||
|
||||
// save workspace id to local storage
|
||||
const allWorkspaceIDs: string[] = JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
);
|
||||
allWorkspaceIDs.push(id);
|
||||
localStorage.setItem(
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(allWorkspaceIDs)
|
||||
);
|
||||
|
||||
// notify all browser tabs, so they can update their workspace list
|
||||
this.notifyChannel.postMessage(id);
|
||||
|
||||
return { id, flavour: WorkspaceFlavour.LOCAL };
|
||||
}
|
||||
subscribe(
|
||||
callback: (changed: {
|
||||
added?: WorkspaceMetadata[] | undefined;
|
||||
deleted?: WorkspaceMetadata[] | undefined;
|
||||
}) => void
|
||||
): () => void {
|
||||
let lastWorkspaceIDs: string[] = [];
|
||||
|
||||
function scan() {
|
||||
const allWorkspaceIDs: string[] = JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
);
|
||||
allWorkspaceIDs.push(id);
|
||||
localStorage.setItem(
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(allWorkspaceIDs)
|
||||
);
|
||||
|
||||
// notify all browser tabs, so they can update their workspace list
|
||||
notifyChannel.postMessage(id);
|
||||
|
||||
return id;
|
||||
},
|
||||
async delete(workspaceId) {
|
||||
const allWorkspaceIDs: string[] = JSON.parse(
|
||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||
);
|
||||
localStorage.setItem(
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(allWorkspaceIDs.filter(x => x !== workspaceId))
|
||||
);
|
||||
|
||||
if (apis && environment.isDesktop) {
|
||||
await apis.workspace.delete(workspaceId);
|
||||
}
|
||||
|
||||
// notify all browser tabs, so they can update their workspace list
|
||||
notifyChannel.postMessage(workspaceId);
|
||||
},
|
||||
async getInformation(id) {
|
||||
// get information from root doc
|
||||
|
||||
const storage = createLocalStorage(id);
|
||||
const data = await storage.pull(id, new Uint8Array([]));
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bs = new BlockSuiteWorkspace({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
const added = difference(allWorkspaceIDs, lastWorkspaceIDs);
|
||||
const deleted = difference(lastWorkspaceIDs, allWorkspaceIDs);
|
||||
lastWorkspaceIDs = allWorkspaceIDs;
|
||||
callback({
|
||||
added: added.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })),
|
||||
deleted: deleted.map(id => ({ id, flavour: WorkspaceFlavour.LOCAL })),
|
||||
});
|
||||
}
|
||||
|
||||
applyUpdate(bs.doc, data.data);
|
||||
scan();
|
||||
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
};
|
||||
},
|
||||
};
|
||||
// rescan if other tabs notify us
|
||||
this.notifyChannel.addEventListener('message', scan);
|
||||
return () => {
|
||||
this.notifyChannel.removeEventListener('message', scan);
|
||||
};
|
||||
}
|
||||
async getInformation(id: string): Promise<WorkspaceInfo | undefined> {
|
||||
// get information from root doc
|
||||
const storage = environment.isDesktop
|
||||
? new SQLiteSyncStorage(id)
|
||||
: new IndexedDBSyncStorage(id);
|
||||
const data = await storage.pull(id, new Uint8Array([]));
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bs = new BlockSuiteWorkspace({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
|
||||
applyUpdate(bs.doc, data.data);
|
||||
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { SyncStorage } from '@affine/workspace';
|
||||
import { mergeUpdates, type SyncStorage } from '@toeverything/infra';
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from 'idb';
|
||||
import { diffUpdate, encodeStateVectorFromUpdate } from 'yjs';
|
||||
|
||||
import { mergeUpdates } from '../utils/merge-updates';
|
||||
|
||||
export const dbVersion = 1;
|
||||
export const DEFAULT_DB_NAME = 'affine-local';
|
||||
|
||||
@@ -38,81 +36,83 @@ type ChannelMessage = {
|
||||
payload: { docId: string; update: Uint8Array };
|
||||
};
|
||||
|
||||
export function createIndexedDBStorage(
|
||||
workspaceId: string,
|
||||
dbName = DEFAULT_DB_NAME,
|
||||
mergeCount = 1
|
||||
): SyncStorage {
|
||||
let dbPromise: Promise<IDBPDatabase<BlockSuiteBinaryDB>> | null = null;
|
||||
const getDb = async () => {
|
||||
if (dbPromise === null) {
|
||||
dbPromise = openDB<BlockSuiteBinaryDB>(dbName, dbVersion, {
|
||||
export class IndexedDBSyncStorage implements SyncStorage {
|
||||
name = 'indexeddb';
|
||||
dbName = DEFAULT_DB_NAME;
|
||||
mergeCount = 1;
|
||||
dbPromise: Promise<IDBPDatabase<BlockSuiteBinaryDB>> | null = null;
|
||||
// indexeddb could be shared between tabs, so we use broadcast channel to notify other tabs
|
||||
channel = new BroadcastChannel('indexeddb:' + this.workspaceId);
|
||||
|
||||
constructor(private readonly workspaceId: string) {}
|
||||
|
||||
getDb() {
|
||||
if (this.dbPromise === null) {
|
||||
this.dbPromise = openDB<BlockSuiteBinaryDB>(this.dbName, dbVersion, {
|
||||
upgrade: upgradeDB,
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
};
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
// indexeddb could be shared between tabs, so we use broadcast channel to notify other tabs
|
||||
const channel = new BroadcastChannel('indexeddb:' + workspaceId);
|
||||
async pull(
|
||||
docId: string,
|
||||
state: Uint8Array
|
||||
): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> {
|
||||
const db = await this.getDb();
|
||||
const store = db
|
||||
.transaction('workspace', 'readonly')
|
||||
.objectStore('workspace');
|
||||
const data = await store.get(docId);
|
||||
|
||||
return {
|
||||
name: 'indexeddb',
|
||||
async pull(docId, state) {
|
||||
const db = await getDb();
|
||||
const store = db
|
||||
.transaction('workspace', 'readonly')
|
||||
.objectStore('workspace');
|
||||
const data = await store.get(docId);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
const { updates } = data;
|
||||
const update = mergeUpdates(updates.map(({ update }) => update));
|
||||
|
||||
const diff = state.length ? diffUpdate(update, state) : update;
|
||||
|
||||
return { data: diff, state: encodeStateVectorFromUpdate(update) };
|
||||
}
|
||||
|
||||
async push(docId: string, data: Uint8Array): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
const store = db
|
||||
.transaction('workspace', 'readwrite')
|
||||
.objectStore('workspace');
|
||||
|
||||
// TODO: maybe we do not need to get data every time
|
||||
const { updates } = (await store.get(docId)) ?? { updates: [] };
|
||||
let rows: UpdateMessage[] = [
|
||||
...updates,
|
||||
{ timestamp: Date.now(), update: data },
|
||||
];
|
||||
if (this.mergeCount && rows.length >= this.mergeCount) {
|
||||
const merged = mergeUpdates(rows.map(({ update }) => update));
|
||||
rows = [{ timestamp: Date.now(), update: merged }];
|
||||
}
|
||||
await store.put({
|
||||
id: docId,
|
||||
updates: rows,
|
||||
});
|
||||
this.channel.postMessage({
|
||||
type: 'db-updated',
|
||||
payload: { docId, update: data },
|
||||
} satisfies ChannelMessage);
|
||||
}
|
||||
async subscribe(cb: (docId: string, data: Uint8Array) => void) {
|
||||
function onMessage(event: MessageEvent<ChannelMessage>) {
|
||||
const { type, payload } = event.data;
|
||||
if (type === 'db-updated') {
|
||||
const { docId, update } = payload;
|
||||
cb(docId, update);
|
||||
}
|
||||
|
||||
const { updates } = data;
|
||||
const update = mergeUpdates(updates.map(({ update }) => update));
|
||||
|
||||
const diff = state.length ? diffUpdate(update, state) : update;
|
||||
|
||||
return { data: diff, state: encodeStateVectorFromUpdate(update) };
|
||||
},
|
||||
async push(docId, update) {
|
||||
const db = await getDb();
|
||||
const store = db
|
||||
.transaction('workspace', 'readwrite')
|
||||
.objectStore('workspace');
|
||||
|
||||
// TODO: maybe we do not need to get data every time
|
||||
const { updates } = (await store.get(docId)) ?? { updates: [] };
|
||||
let rows: UpdateMessage[] = [
|
||||
...updates,
|
||||
{ timestamp: Date.now(), update },
|
||||
];
|
||||
if (mergeCount && rows.length >= mergeCount) {
|
||||
const merged = mergeUpdates(rows.map(({ update }) => update));
|
||||
rows = [{ timestamp: Date.now(), update: merged }];
|
||||
}
|
||||
await store.put({
|
||||
id: docId,
|
||||
updates: rows,
|
||||
});
|
||||
channel.postMessage({
|
||||
type: 'db-updated',
|
||||
payload: { docId, update },
|
||||
} satisfies ChannelMessage);
|
||||
},
|
||||
async subscribe(cb, _disconnect) {
|
||||
function onMessage(event: MessageEvent<ChannelMessage>) {
|
||||
const { type, payload } = event.data;
|
||||
if (type === 'db-updated') {
|
||||
const { docId, update } = payload;
|
||||
cb(docId, update);
|
||||
}
|
||||
}
|
||||
channel.addEventListener('message', onMessage);
|
||||
return () => {
|
||||
channel.removeEventListener('message', onMessage);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
this.channel.addEventListener('message', onMessage);
|
||||
return () => {
|
||||
this.channel.removeEventListener('message', onMessage);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import type { SyncStorage } from '@affine/workspace';
|
||||
import { type SyncStorage } from '@toeverything/infra';
|
||||
import { encodeStateVectorFromUpdate } from 'yjs';
|
||||
|
||||
export function createSQLiteStorage(workspaceId: string): SyncStorage {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
export class SQLiteSyncStorage implements SyncStorage {
|
||||
name = 'sqlite';
|
||||
constructor(private readonly workspaceId: string) {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'sqlite',
|
||||
async pull(docId, _state) {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
const update = await apis.db.getDocAsUpdates(
|
||||
workspaceId,
|
||||
workspaceId === docId ? undefined : docId
|
||||
);
|
||||
async pull(docId: string, _state: Uint8Array) {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
const update = await apis.db.getDocAsUpdates(
|
||||
this.workspaceId,
|
||||
this.workspaceId === docId ? undefined : docId
|
||||
);
|
||||
|
||||
if (update) {
|
||||
return {
|
||||
data: update,
|
||||
state: encodeStateVectorFromUpdate(update),
|
||||
};
|
||||
}
|
||||
if (update) {
|
||||
return {
|
||||
data: update,
|
||||
state: encodeStateVectorFromUpdate(update),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
async push(docId, data) {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.applyDocUpdate(
|
||||
workspaceId,
|
||||
data,
|
||||
workspaceId === docId ? undefined : docId
|
||||
);
|
||||
},
|
||||
async subscribe(_cb, _disconnect) {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
async push(docId: string, data: Uint8Array) {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.applyDocUpdate(
|
||||
this.workspaceId,
|
||||
data,
|
||||
this.workspaceId === docId ? undefined : docId
|
||||
);
|
||||
}
|
||||
|
||||
async subscribe() {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createIndexedDBStorage } from './sync-indexeddb';
|
||||
import { createSQLiteStorage } from './sync-sqlite';
|
||||
|
||||
export const createLocalStorage = (workspaceId: string) =>
|
||||
environment.isDesktop
|
||||
? createSQLiteStorage(workspaceId)
|
||||
: createIndexedDBStorage(workspaceId);
|
||||
@@ -1,54 +1,50 @@
|
||||
import { setupEditorFlags } from '@affine/env/global';
|
||||
import type { WorkspaceFactory } from '@affine/workspace';
|
||||
import { WorkspaceEngine } from '@affine/workspace';
|
||||
import { BlobEngine } from '@affine/workspace';
|
||||
import { SyncEngine } from '@affine/workspace';
|
||||
import { globalBlockSuiteSchema } from '@affine/workspace';
|
||||
import { Workspace } from '@affine/workspace';
|
||||
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { ServiceCollection, WorkspaceFactory } from '@toeverything/infra';
|
||||
import {
|
||||
AwarenessContext,
|
||||
AwarenessProvider,
|
||||
LocalBlobStorage,
|
||||
LocalSyncStorage,
|
||||
RemoteBlobStorage,
|
||||
WorkspaceIdContext,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { createBroadcastChannelAwarenessProvider } from './awareness';
|
||||
import { createLocalBlobStorage } from './blob';
|
||||
import { createStaticBlobStorage } from './blob-static';
|
||||
import { createLocalStorage } from './sync';
|
||||
import { BroadcastChannelAwarenessProvider } from './awareness';
|
||||
import { IndexedDBBlobStorage } from './blob-indexeddb';
|
||||
import { SQLiteBlobStorage } from './blob-sqlite';
|
||||
import { StaticBlobStorage } from './blob-static';
|
||||
import { IndexedDBSyncStorage } from './sync-indexeddb';
|
||||
import { SQLiteSyncStorage } from './sync-sqlite';
|
||||
|
||||
export const localWorkspaceFactory: WorkspaceFactory = {
|
||||
name: 'local',
|
||||
openWorkspace(metadata) {
|
||||
const blobEngine = new BlobEngine(createLocalBlobStorage(metadata.id), [
|
||||
createStaticBlobStorage(),
|
||||
]);
|
||||
const bs = new BlockSuiteWorkspace({
|
||||
id: metadata.id,
|
||||
blobStorages: [
|
||||
() => ({
|
||||
crud: blobEngine,
|
||||
}),
|
||||
],
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
const syncEngine = new SyncEngine(
|
||||
bs.doc,
|
||||
createLocalStorage(metadata.id),
|
||||
[]
|
||||
);
|
||||
const awarenessProvider = createBroadcastChannelAwarenessProvider(
|
||||
metadata.id,
|
||||
bs.awarenessStore.awareness
|
||||
);
|
||||
const engine = new WorkspaceEngine(blobEngine, syncEngine, [
|
||||
awarenessProvider,
|
||||
]);
|
||||
export class LocalWorkspaceFactory implements WorkspaceFactory {
|
||||
name = 'local';
|
||||
configureWorkspace(services: ServiceCollection): void {
|
||||
if (environment.isDesktop) {
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.addImpl(LocalBlobStorage, SQLiteBlobStorage, [WorkspaceIdContext])
|
||||
.addImpl(LocalSyncStorage, SQLiteSyncStorage, [WorkspaceIdContext]);
|
||||
} else {
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.addImpl(LocalBlobStorage, IndexedDBBlobStorage, [WorkspaceIdContext])
|
||||
.addImpl(LocalSyncStorage, IndexedDBSyncStorage, [WorkspaceIdContext]);
|
||||
}
|
||||
|
||||
setupEditorFlags(bs);
|
||||
|
||||
return new Workspace(metadata, engine, bs);
|
||||
},
|
||||
async getWorkspaceBlob(id, blobKey) {
|
||||
const blobStorage = createLocalBlobStorage(id);
|
||||
services
|
||||
.scope(WorkspaceScope)
|
||||
.addImpl(RemoteBlobStorage('static'), StaticBlobStorage)
|
||||
.addImpl(
|
||||
AwarenessProvider('broadcast-channel'),
|
||||
BroadcastChannelAwarenessProvider,
|
||||
[WorkspaceIdContext, AwarenessContext]
|
||||
);
|
||||
}
|
||||
async getWorkspaceBlob(id: string, blobKey: string): Promise<Blob | null> {
|
||||
const blobStorage = environment.isDesktop
|
||||
? new SQLiteBlobStorage(id)
|
||||
: new IndexedDBBlobStorage(id);
|
||||
|
||||
return await blobStorage.get(blobKey);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
export function mergeUpdates(updates: Uint8Array[]) {
|
||||
if (updates.length === 0) {
|
||||
return new Uint8Array();
|
||||
}
|
||||
if (updates.length === 1) {
|
||||
return updates[0];
|
||||
}
|
||||
const doc = new Doc();
|
||||
doc.transact(() => {
|
||||
updates.forEach(update => {
|
||||
applyUpdate(doc, update);
|
||||
});
|
||||
});
|
||||
return encodeStateAsUpdate(doc);
|
||||
}
|
||||
Reference in New Issue
Block a user