mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 03:56:23 +08:00
refactor(workspace): split workspace interface and implementation (#5463)
@affine/workspace -> (@affine/workspace, @affine/workspace-impl)
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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 { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { Doc } from 'yjs';
|
||||
|
||||
import { createIndexedDBStorage } from '..';
|
||||
import { createTestStorage } from './test-storage';
|
||||
|
||||
const schema = new Schema();
|
||||
|
||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
|
||||
});
|
||||
|
||||
describe('SyncEngine', () => {
|
||||
test('basic - indexeddb', async () => {
|
||||
let prev: any;
|
||||
{
|
||||
const workspace = new Workspace({
|
||||
id: 'test',
|
||||
|
||||
schema,
|
||||
});
|
||||
|
||||
const syncEngine = new SyncEngine(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid),
|
||||
[
|
||||
createIndexedDBStorage(workspace.doc.guid + '1'),
|
||||
createIndexedDBStorage(workspace.doc.guid + '2'),
|
||||
]
|
||||
);
|
||||
syncEngine.start();
|
||||
|
||||
const page = workspace.createPage({
|
||||
id: 'page0',
|
||||
});
|
||||
await page.load();
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
title: new page.Text(''),
|
||||
});
|
||||
page.addBlock('affine:surface', {}, pageBlockId);
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
await syncEngine.waitForSynced();
|
||||
syncEngine.forceStop();
|
||||
prev = workspace.doc.toJSON();
|
||||
}
|
||||
|
||||
{
|
||||
const workspace = new Workspace({
|
||||
id: 'test',
|
||||
|
||||
schema,
|
||||
});
|
||||
const syncEngine = new SyncEngine(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid),
|
||||
[]
|
||||
);
|
||||
syncEngine.start();
|
||||
await syncEngine.waitForSynced();
|
||||
expect(workspace.doc.toJSON()).toEqual({
|
||||
...prev,
|
||||
});
|
||||
syncEngine.forceStop();
|
||||
}
|
||||
|
||||
{
|
||||
const workspace = new Workspace({
|
||||
id: 'test',
|
||||
|
||||
schema,
|
||||
});
|
||||
const syncEngine = new SyncEngine(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid + '1'),
|
||||
[]
|
||||
);
|
||||
syncEngine.start();
|
||||
await syncEngine.waitForSynced();
|
||||
expect(workspace.doc.toJSON()).toEqual({
|
||||
...prev,
|
||||
});
|
||||
syncEngine.forceStop();
|
||||
}
|
||||
|
||||
{
|
||||
const workspace = new Workspace({
|
||||
id: 'test',
|
||||
|
||||
schema,
|
||||
});
|
||||
const syncEngine = new SyncEngine(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid + '2'),
|
||||
[]
|
||||
);
|
||||
syncEngine.start();
|
||||
await syncEngine.waitForSynced();
|
||||
expect(workspace.doc.toJSON()).toEqual({
|
||||
...prev,
|
||||
});
|
||||
syncEngine.forceStop();
|
||||
}
|
||||
});
|
||||
|
||||
test('status', async () => {
|
||||
const ydoc = new Doc({ guid: 'test - status' });
|
||||
|
||||
const localStorage = createTestStorage(createIndexedDBStorage(ydoc.guid));
|
||||
const remoteStorage = createTestStorage(createIndexedDBStorage(ydoc.guid));
|
||||
|
||||
localStorage.pausePull();
|
||||
localStorage.pausePush();
|
||||
remoteStorage.pausePull();
|
||||
remoteStorage.pausePush();
|
||||
|
||||
const syncEngine = new SyncEngine(ydoc, localStorage, [remoteStorage]);
|
||||
expect(syncEngine.status.step).toEqual(SyncEngineStep.Stopped);
|
||||
|
||||
syncEngine.start();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(syncEngine.status.step).toEqual(SyncEngineStep.Syncing);
|
||||
expect(syncEngine.status.local?.step).toEqual(
|
||||
SyncPeerStep.LoadingRootDoc
|
||||
);
|
||||
});
|
||||
|
||||
localStorage.resumePull();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(syncEngine.status.step).toEqual(SyncEngineStep.Syncing);
|
||||
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Synced);
|
||||
expect(syncEngine.status.remotes[0]?.step).toEqual(
|
||||
SyncPeerStep.LoadingRootDoc
|
||||
);
|
||||
});
|
||||
|
||||
remoteStorage.resumePull();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(syncEngine.status.step).toEqual(SyncEngineStep.Synced);
|
||||
expect(syncEngine.status.remotes[0]?.step).toEqual(SyncPeerStep.Synced);
|
||||
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Synced);
|
||||
});
|
||||
|
||||
ydoc.getArray('test').insert(0, [1, 2, 3]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(syncEngine.status.step).toEqual(SyncEngineStep.Syncing);
|
||||
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Syncing);
|
||||
expect(syncEngine.status.remotes[0]?.step).toEqual(SyncPeerStep.Syncing);
|
||||
});
|
||||
|
||||
localStorage.resumePush();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(syncEngine.status.step).toEqual(SyncEngineStep.Syncing);
|
||||
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Synced);
|
||||
expect(syncEngine.status.remotes[0]?.step).toEqual(SyncPeerStep.Syncing);
|
||||
});
|
||||
|
||||
remoteStorage.resumePush();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(syncEngine.status.step).toEqual(SyncEngineStep.Synced);
|
||||
expect(syncEngine.status.local?.step).toEqual(SyncPeerStep.Synced);
|
||||
expect(syncEngine.status.remotes[0]?.step).toEqual(SyncPeerStep.Synced);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { SyncPeer, SyncPeerStep } from '@affine/workspace';
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { Schema, Workspace } from '@blocksuite/store';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { createIndexedDBStorage } from '..';
|
||||
|
||||
const schema = new Schema();
|
||||
|
||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
|
||||
});
|
||||
|
||||
describe('SyncPeer', () => {
|
||||
test('basic - indexeddb', async () => {
|
||||
let prev: any;
|
||||
{
|
||||
const workspace = new Workspace({
|
||||
id: 'test',
|
||||
|
||||
schema,
|
||||
});
|
||||
|
||||
const syncPeer = new SyncPeer(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid)
|
||||
);
|
||||
await syncPeer.waitForLoaded();
|
||||
|
||||
const page = workspace.createPage({
|
||||
id: 'page0',
|
||||
});
|
||||
await page.load();
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
title: new page.Text(''),
|
||||
});
|
||||
page.addBlock('affine:surface', {}, pageBlockId);
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
await syncPeer.waitForSynced();
|
||||
syncPeer.stop();
|
||||
prev = workspace.doc.toJSON();
|
||||
}
|
||||
|
||||
{
|
||||
const workspace = new Workspace({
|
||||
id: 'test',
|
||||
|
||||
schema,
|
||||
});
|
||||
const syncPeer = new SyncPeer(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid)
|
||||
);
|
||||
await syncPeer.waitForSynced();
|
||||
expect(workspace.doc.toJSON()).toEqual({
|
||||
...prev,
|
||||
});
|
||||
syncPeer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('status', async () => {
|
||||
const workspace = new Workspace({
|
||||
id: 'test - status',
|
||||
|
||||
schema,
|
||||
});
|
||||
|
||||
const syncPeer = new SyncPeer(
|
||||
workspace.doc,
|
||||
createIndexedDBStorage(workspace.doc.guid)
|
||||
);
|
||||
expect(syncPeer.status.step).toBe(SyncPeerStep.LoadingRootDoc);
|
||||
await syncPeer.waitForSynced();
|
||||
expect(syncPeer.status.step).toBe(SyncPeerStep.Synced);
|
||||
|
||||
const page = workspace.createPage({
|
||||
id: 'page0',
|
||||
});
|
||||
expect(syncPeer.status.step).toBe(SyncPeerStep.LoadingSubDoc);
|
||||
await page.load();
|
||||
await syncPeer.waitForSynced();
|
||||
page.addBlock('affine:page', {
|
||||
title: new page.Text(''),
|
||||
});
|
||||
expect(syncPeer.status.step).toBe(SyncPeerStep.Syncing);
|
||||
syncPeer.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SyncStorage } from '@affine/workspace';
|
||||
|
||||
export function createTestStorage(origin: SyncStorage) {
|
||||
const controler = {
|
||||
pausedPull: Promise.resolve(),
|
||||
resumePull: () => {},
|
||||
pausedPush: Promise.resolve(),
|
||||
resumePush: () => {},
|
||||
};
|
||||
|
||||
return {
|
||||
name: `${origin.name}(testing)`,
|
||||
pull(docId: string, state: Uint8Array) {
|
||||
return controler.pausedPull.then(() => origin.pull(docId, state));
|
||||
},
|
||||
push(docId: string, data: Uint8Array) {
|
||||
return controler.pausedPush.then(() => origin.push(docId, data));
|
||||
},
|
||||
subscribe(
|
||||
cb: (docId: string, data: Uint8Array) => void,
|
||||
disconnect: (reason: string) => void
|
||||
) {
|
||||
return origin.subscribe(cb, disconnect);
|
||||
},
|
||||
pausePull() {
|
||||
controler.pausedPull = new Promise(resolve => {
|
||||
controler.resumePull = resolve;
|
||||
});
|
||||
},
|
||||
resumePull() {
|
||||
controler.resumePull?.();
|
||||
},
|
||||
pausePush() {
|
||||
controler.pausedPush = new Promise(resolve => {
|
||||
controler.resumePush = resolve;
|
||||
});
|
||||
},
|
||||
resumePush() {
|
||||
controler.resumePush?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { AwarenessProvider } from '@affine/workspace';
|
||||
import type { Awareness } from 'y-protocols/awareness.js';
|
||||
import {
|
||||
applyAwarenessUpdate,
|
||||
encodeAwarenessUpdate,
|
||||
} from 'y-protocols/awareness.js';
|
||||
|
||||
type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>;
|
||||
|
||||
type ChannelMessage =
|
||||
| { type: 'connect' }
|
||||
| { type: 'update'; update: Uint8Array };
|
||||
|
||||
export function createBroadcastChannelAwarenessProvider(
|
||||
workspaceId: string,
|
||||
awareness: Awareness
|
||||
): AwarenessProvider {
|
||||
const channel = new BroadcastChannel('awareness:' + workspaceId);
|
||||
|
||||
function handleAwarenessUpdate(changes: AwarenessChanges, origin: unknown) {
|
||||
if (origin === 'remote') {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedClients = Object.values(changes).reduce((res, cur) =>
|
||||
res.concat(cur)
|
||||
);
|
||||
|
||||
const update = encodeAwarenessUpdate(awareness, changedClients);
|
||||
channel.postMessage({
|
||||
type: 'update',
|
||||
update: update,
|
||||
} satisfies ChannelMessage);
|
||||
}
|
||||
|
||||
function handleChannelMessage(event: MessageEvent<ChannelMessage>) {
|
||||
if (event.data.type === 'update') {
|
||||
const update = event.data.update;
|
||||
applyAwarenessUpdate(awareness, update, 'remote');
|
||||
}
|
||||
if (event.data.type === 'connect') {
|
||||
channel.postMessage({
|
||||
type: 'update',
|
||||
update: encodeAwarenessUpdate(awareness, [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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BlobStorage } from '@affine/workspace';
|
||||
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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import type { BlobStorage } from '@affine/workspace';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { BlobStorage } from '@affine/workspace';
|
||||
|
||||
export const predefinedStaticFiles = [
|
||||
'029uztLz2CzJezK7UUhrbGiWUdZ0J7NVs_qR6RDsvb8=',
|
||||
'047ebf2c9a5c7c9d8521c2ea5e6140ff7732ef9e28a9f944e9bf3ca4',
|
||||
'0hjYqQd8SvwHT2gPds7qFw8W6qIEGVbZvG45uzoYjUU=',
|
||||
'1326bc48553a572c6756d9ee1b30a0dfdda26222fc2d2c872b14e609',
|
||||
'27f983d0765289c19d10ee0b51c00c3c7665236a1a82406370d46e0a',
|
||||
'28516717d63e469cd98729ff46be6595711898bab3dc43302319a987',
|
||||
'4HXJrnBZGaGPFpowNawNog0aMg3dgoVaAnNqEMeUxq0=',
|
||||
'5Cfem_137WmzR35ZeIC76oTkq5SQt-eHlZwJiLy0hgU=',
|
||||
'6aa785ee927547ce9dd9d7b43e01eac948337fe57571443e87bc3a60',
|
||||
'8oj6ym4HlTcshT40Zn6D5DeOgaVCSOOXJvT_EyiqUw8=',
|
||||
'9288be57321c8772d04e05dbb69a22742372b3534442607a2d6a9998',
|
||||
'9vXwWGEX5W9v5pzwpu0eK4pf22DZ_sCloO0zCH1aVQ4=',
|
||||
'Bd5F0WRI0fLh8RK1al9PawPVT3jv7VwBrqiiBEtdV-g=',
|
||||
'CBWoKrhSDndjBJzscQKENRqiXOOZnzIA5qyiCoy4-A0=',
|
||||
'D7g-4LMqOsVWBNOD-_kGgCOvJEoc8rcpYbkfDlF2u5U=',
|
||||
'Vqc8rxFbGyc5L1QeE_Zr10XEcIai_0Xw4Qv6d3ldRPE=',
|
||||
'VuXYyM9JUv1Fv_qjg1v5Go4Zksz0r4NXFeh3Na7JkIc=',
|
||||
'bfXllFddegV9vvxPcSWnOtm-_tuzXm-0OQ59z9Su1zA=',
|
||||
'c820edeeba50006b531883903f5bb0b96bf523c9a6b3ce5868f03db5',
|
||||
'cw9XjQ-pCeSW7LKMzVREGHeCPTXWYbtE-QbZLEY3RrI=',
|
||||
'e93536e1be97e3b5206d43bf0793fdef24e60044d174f0abdefebe08',
|
||||
'f9yKnlNMgKhF-CxOgHBsXkxfViCCkC6KwTv6Uj2Fcjw=',
|
||||
'fb0SNPtMpQlzBQ90_PB7vCu34WpiSUJbNKocFkL2vIo=',
|
||||
'gZLmSgmwumNdgf0eIfOSW44emctrLyFUaZapbk8eZ6s=',
|
||||
'i39ZQ24NlUfWI0MhkbtvHTzGnWMVdr-aC2aOjvHPVg4=',
|
||||
'k07JiWnb-S7qgd9gDQNgqo-LYMe03RX8fR0TXQ-SpG4=',
|
||||
'nSEEkYxrThpZfLoPNOzMp6HWekvutAIYmADElDe1J6I=',
|
||||
'pIqdA3pM1la1gKzxOmAcpLmTh3yXBrL9mGTz_hGj5xE=',
|
||||
'qezoK6du9n3PF4dl4aq5r7LeXz_sV3xOVpFzVVgjNsE=',
|
||||
'rY96Bunn-69CnNe5X_e5CJLwgCJnN6rcbUisecs8kkQ=',
|
||||
'sNVNYDBzUDN2J9OFVJdLJlryBLzRZBLl-4MTNoPF1tA=',
|
||||
'uvpOG9DrldeqIGNaqfwjFdMw_CcfXKfiEjYf7RXdeL0=',
|
||||
'v2yF7lY2L5rtorTtTmYFsoMb9dBPKs5M1y9cUKxcI1M=',
|
||||
];
|
||||
|
||||
export 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();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
set: async key => {
|
||||
// ignore
|
||||
return key;
|
||||
},
|
||||
delete: async () => {
|
||||
// ignore
|
||||
},
|
||||
list: async () => {
|
||||
// ignore
|
||||
return [];
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
|
||||
export const LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY =
|
||||
'affine-local-workspace-created';
|
||||
@@ -0,0 +1,11 @@
|
||||
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';
|
||||
@@ -0,0 +1,130 @@
|
||||
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 { difference } from 'lodash-es';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { createLocalBlobStorage } from './blob';
|
||||
import {
|
||||
LOCAL_WORKSPACE_CREATED_BROADCAST_CHANNEL_KEY,
|
||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||
} from './consts';
|
||||
import { createLocalStorage } from './sync';
|
||||
|
||||
export function createLocalWorkspaceListProvider(): WorkspaceListProvider {
|
||||
const 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[] = [];
|
||||
|
||||
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 })),
|
||||
});
|
||||
}
|
||||
|
||||
scan();
|
||||
|
||||
// rescan if other tabs notify us
|
||||
notifyChannel.addEventListener('message', scan);
|
||||
return () => {
|
||||
notifyChannel.removeEventListener('message', scan);
|
||||
};
|
||||
},
|
||||
async create(initial) {
|
||||
const id = nanoid();
|
||||
|
||||
const blobStorage = createLocalBlobStorage(id);
|
||||
const syncStorage = createLocalStorage(id);
|
||||
|
||||
const workspace = new BlockSuiteWorkspace({
|
||||
id: id,
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
|
||||
// apply initial state
|
||||
await initial(workspace, blobStorage);
|
||||
|
||||
// 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
|
||||
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,
|
||||
});
|
||||
|
||||
applyUpdate(bs.doc, data.data);
|
||||
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { SyncStorage } from '@affine/workspace';
|
||||
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';
|
||||
|
||||
type UpdateMessage = {
|
||||
timestamp: number;
|
||||
update: Uint8Array;
|
||||
};
|
||||
|
||||
type WorkspacePersist = {
|
||||
id: string;
|
||||
updates: UpdateMessage[];
|
||||
};
|
||||
|
||||
interface BlockSuiteBinaryDB extends DBSchema {
|
||||
workspace: {
|
||||
key: string;
|
||||
value: WorkspacePersist;
|
||||
};
|
||||
milestone: {
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function upgradeDB(db: IDBPDatabase<BlockSuiteBinaryDB>) {
|
||||
db.createObjectStore('workspace', { keyPath: 'id' });
|
||||
db.createObjectStore('milestone', { keyPath: 'id' });
|
||||
}
|
||||
|
||||
type ChannelMessage = {
|
||||
type: 'db-updated';
|
||||
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, {
|
||||
upgrade: upgradeDB,
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
};
|
||||
|
||||
// indexeddb could be shared between tabs, so we use broadcast channel to notify other tabs
|
||||
const channel = new BroadcastChannel('indexeddb:' + workspaceId);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apis } from '@affine/electron-api';
|
||||
import type { SyncStorage } from '@affine/workspace';
|
||||
import { encodeStateVectorFromUpdate } from 'yjs';
|
||||
|
||||
export function createSQLiteStorage(workspaceId: string): SyncStorage {
|
||||
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
|
||||
);
|
||||
|
||||
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 () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createIndexedDBStorage } from './sync-indexeddb';
|
||||
import { createSQLiteStorage } from './sync-sqlite';
|
||||
|
||||
export const createLocalStorage = (workspaceId: string) =>
|
||||
environment.isDesktop
|
||||
? createSQLiteStorage(workspaceId)
|
||||
: createIndexedDBStorage(workspaceId);
|
||||
@@ -0,0 +1,54 @@
|
||||
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 { createBroadcastChannelAwarenessProvider } from './awareness';
|
||||
import { createLocalBlobStorage } from './blob';
|
||||
import { createStaticBlobStorage } from './blob-static';
|
||||
import { createLocalStorage } from './sync';
|
||||
|
||||
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,
|
||||
]);
|
||||
|
||||
setupEditorFlags(bs);
|
||||
|
||||
return new Workspace(metadata, engine, bs);
|
||||
},
|
||||
async getWorkspaceBlob(id, blobKey) {
|
||||
const blobStorage = createLocalBlobStorage(id);
|
||||
|
||||
return await blobStorage.get(blobKey);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user