refactor(infra): migrate to new infra (#5565)

This commit is contained in:
EYHN
2024-01-30 07:16:39 +00:00
parent 1e3499c323
commit 329fc19852
170 changed files with 2007 additions and 4354 deletions

View File

@@ -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",

View File

@@ -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);
};
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -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.');
}
}

View File

@@ -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);
},
};
}
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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();

View File

@@ -1,4 +1,4 @@
import type { SyncStorage } from '@affine/workspace';
import type { SyncStorage } from '@toeverything/infra';
export function createTestStorage(origin: SyncStorage) {
const controler = {

View File

@@ -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);
},
};
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 [];
}
}

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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,
};
}
}

View File

@@ -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);
};
}
}

View File

@@ -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 () => {};
}
}

View File

@@ -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);

View File

@@ -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);
},
};
}
}

View File

@@ -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);
}