mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
feat(infra): framework
This commit is contained in:
28
packages/common/infra/src/modules/doc/entities/doc.ts
Normal file
28
packages/common/infra/src/modules/doc/entities/doc.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Entity } from '../../../framework';
|
||||
import type { DocScope } from '../scopes/doc';
|
||||
import type { DocMode } from './record';
|
||||
|
||||
export class Doc extends Entity {
|
||||
constructor(public readonly scope: DocScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.scope.props.docId;
|
||||
}
|
||||
|
||||
public readonly blockSuiteDoc = this.scope.props.blockSuiteDoc;
|
||||
public readonly record = this.scope.props.record;
|
||||
|
||||
readonly meta$ = this.record.meta$;
|
||||
readonly mode$ = this.record.mode$;
|
||||
readonly title$ = this.record.title$;
|
||||
|
||||
setMode(mode: DocMode) {
|
||||
this.record.setMode(mode);
|
||||
}
|
||||
|
||||
toggleMode() {
|
||||
this.record.toggleMode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { map } from 'rxjs';
|
||||
|
||||
import { Entity } from '../../../framework';
|
||||
import { LiveData } from '../../../livedata';
|
||||
import type { DocsStore } from '../stores/docs';
|
||||
import { DocRecord } from './record';
|
||||
|
||||
export class DocRecordList extends Entity {
|
||||
constructor(private readonly store: DocsStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly pool = new Map<string, DocRecord>();
|
||||
|
||||
public readonly docs$ = LiveData.from<DocRecord[]>(
|
||||
this.store.watchDocIds().pipe(
|
||||
map(ids =>
|
||||
ids.map(id => {
|
||||
const exists = this.pool.get(id);
|
||||
if (exists) {
|
||||
return exists;
|
||||
}
|
||||
const record = this.framework.createEntity(DocRecord, { id });
|
||||
this.pool.set(id, record);
|
||||
return record;
|
||||
})
|
||||
)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
public readonly isReady$ = LiveData.from(
|
||||
this.store.watchDocListReady(),
|
||||
false
|
||||
);
|
||||
|
||||
public doc$(id: string) {
|
||||
return this.docs$.map(record => record.find(record => record.id === id));
|
||||
}
|
||||
}
|
||||
45
packages/common/infra/src/modules/doc/entities/record.ts
Normal file
45
packages/common/infra/src/modules/doc/entities/record.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
|
||||
import { Entity } from '../../../framework';
|
||||
import { LiveData } from '../../../livedata';
|
||||
import type { DocsStore } from '../stores/docs';
|
||||
|
||||
export type DocMode = 'edgeless' | 'page';
|
||||
|
||||
/**
|
||||
* # DocRecord
|
||||
*
|
||||
* Some data you can use without open a doc.
|
||||
*/
|
||||
export class DocRecord extends Entity<{ id: string }> {
|
||||
id: string = this.props.id;
|
||||
meta: Partial<DocMeta> | null = null;
|
||||
constructor(private readonly docsStore: DocsStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
meta$ = LiveData.from<Partial<DocMeta>>(
|
||||
this.docsStore.watchDocMeta(this.id),
|
||||
{}
|
||||
);
|
||||
|
||||
setMeta(meta: Partial<DocMeta>): void {
|
||||
this.docsStore.setDocMeta(this.id, meta);
|
||||
}
|
||||
|
||||
mode$: LiveData<DocMode> = LiveData.from(
|
||||
this.docsStore.watchDocModeSetting(this.id),
|
||||
'page'
|
||||
).map(mode => (mode === 'edgeless' ? 'edgeless' : 'page'));
|
||||
|
||||
setMode(mode: DocMode) {
|
||||
this.docsStore.setDocModeSetting(this.id, mode);
|
||||
}
|
||||
|
||||
toggleMode() {
|
||||
this.setMode(this.mode$.value === 'edgeless' ? 'page' : 'edgeless');
|
||||
return this.mode$.value;
|
||||
}
|
||||
|
||||
title$ = this.meta$.map(meta => meta.title ?? '');
|
||||
}
|
||||
33
packages/common/infra/src/modules/doc/index.ts
Normal file
33
packages/common/infra/src/modules/doc/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export { Doc } from './entities/doc';
|
||||
export type { DocMode } from './entities/record';
|
||||
export { DocRecord } from './entities/record';
|
||||
export { DocRecordList } from './entities/record-list';
|
||||
export { DocScope } from './scopes/doc';
|
||||
export { DocService } from './services/doc';
|
||||
export { DocsService } from './services/docs';
|
||||
|
||||
import type { Framework } from '../../framework';
|
||||
import {
|
||||
WorkspaceLocalState,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '../workspace';
|
||||
import { Doc } from './entities/doc';
|
||||
import { DocRecord } from './entities/record';
|
||||
import { DocRecordList } from './entities/record-list';
|
||||
import { DocScope } from './scopes/doc';
|
||||
import { DocService } from './services/doc';
|
||||
import { DocsService } from './services/docs';
|
||||
import { DocsStore } from './stores/docs';
|
||||
|
||||
export function configureDocModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(DocsService, [DocsStore])
|
||||
.store(DocsStore, [WorkspaceService, WorkspaceLocalState])
|
||||
.entity(DocRecord, [DocsStore])
|
||||
.entity(DocRecordList, [DocsStore])
|
||||
.scope(DocScope)
|
||||
.entity(Doc, [DocScope])
|
||||
.service(DocService);
|
||||
}
|
||||
10
packages/common/infra/src/modules/doc/scopes/doc.ts
Normal file
10
packages/common/infra/src/modules/doc/scopes/doc.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
||||
|
||||
import { Scope } from '../../../framework';
|
||||
import type { DocRecord } from '../entities/record';
|
||||
|
||||
export class DocScope extends Scope<{
|
||||
docId: string;
|
||||
record: DocRecord;
|
||||
blockSuiteDoc: BlockSuiteDoc;
|
||||
}> {}
|
||||
6
packages/common/infra/src/modules/doc/services/doc.ts
Normal file
6
packages/common/infra/src/modules/doc/services/doc.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Service } from '../../../framework';
|
||||
import { Doc } from '../entities/doc';
|
||||
|
||||
export class DocService extends Service {
|
||||
public readonly doc = this.framework.createEntity(Doc);
|
||||
}
|
||||
49
packages/common/infra/src/modules/doc/services/docs.ts
Normal file
49
packages/common/infra/src/modules/doc/services/docs.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Service } from '../../../framework';
|
||||
import { ObjectPool } from '../../../utils';
|
||||
import type { Doc } from '../entities/doc';
|
||||
import { DocRecordList } from '../entities/record-list';
|
||||
import { DocScope } from '../scopes/doc';
|
||||
import type { DocsStore } from '../stores/docs';
|
||||
import { DocService } from './doc';
|
||||
|
||||
export class DocsService extends Service {
|
||||
list = this.framework.createEntity(DocRecordList);
|
||||
|
||||
pool = new ObjectPool<string, Doc>({
|
||||
onDelete(obj) {
|
||||
obj.scope.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
constructor(private readonly store: DocsStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
open(docId: string) {
|
||||
const docRecord = this.list.doc$(docId).value;
|
||||
if (!docRecord) {
|
||||
throw new Error('Doc record not found');
|
||||
}
|
||||
const blockSuiteDoc = this.store.getBlockSuiteDoc(docId);
|
||||
if (!blockSuiteDoc) {
|
||||
throw new Error('Doc not found');
|
||||
}
|
||||
|
||||
const exists = this.pool.get(docId);
|
||||
if (exists) {
|
||||
return { doc: exists.obj, release: exists.release };
|
||||
}
|
||||
|
||||
const docScope = this.framework.createScope(DocScope, {
|
||||
docId,
|
||||
blockSuiteDoc,
|
||||
record: docRecord,
|
||||
});
|
||||
|
||||
const doc = docScope.get(DocService).doc;
|
||||
|
||||
const { obj, release } = this.pool.put(docId, doc);
|
||||
|
||||
return { doc: obj, release };
|
||||
}
|
||||
}
|
||||
85
packages/common/infra/src/modules/doc/stores/docs.ts
Normal file
85
packages/common/infra/src/modules/doc/stores/docs.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { type DocMeta } from '@blocksuite/store';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { distinctUntilChanged, Observable } from 'rxjs';
|
||||
|
||||
import { Store } from '../../../framework';
|
||||
import type { WorkspaceLocalState, WorkspaceService } from '../../workspace';
|
||||
import type { DocMode } from '../entities/record';
|
||||
|
||||
export class DocsStore extends Store {
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly localState: WorkspaceLocalState
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getBlockSuiteDoc(id: string) {
|
||||
return this.workspaceService.workspace.docCollection.getDoc(id);
|
||||
}
|
||||
|
||||
watchDocIds() {
|
||||
return new Observable<string[]>(subscriber => {
|
||||
const emit = () => {
|
||||
subscriber.next(
|
||||
this.workspaceService.workspace.docCollection.meta.docMetas.map(
|
||||
v => v.id
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
emit();
|
||||
|
||||
const dispose =
|
||||
this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on(
|
||||
emit
|
||||
).dispose;
|
||||
return () => {
|
||||
dispose();
|
||||
};
|
||||
}).pipe(distinctUntilChanged((p, c) => isEqual(p, c)));
|
||||
}
|
||||
|
||||
watchDocMeta(id: string) {
|
||||
let meta: DocMeta | null = null;
|
||||
return new Observable<Partial<DocMeta>>(subscriber => {
|
||||
const emit = () => {
|
||||
if (meta === null) {
|
||||
// getDocMeta is heavy, so we cache the doc meta reference
|
||||
meta =
|
||||
this.workspaceService.workspace.docCollection.meta.getDocMeta(id) ||
|
||||
null;
|
||||
}
|
||||
subscriber.next({ ...meta });
|
||||
};
|
||||
|
||||
emit();
|
||||
|
||||
const dispose =
|
||||
this.workspaceService.workspace.docCollection.meta.docMetaUpdated.on(
|
||||
emit
|
||||
).dispose;
|
||||
return () => {
|
||||
dispose();
|
||||
};
|
||||
}).pipe(distinctUntilChanged((p, c) => isEqual(p, c)));
|
||||
}
|
||||
|
||||
watchDocListReady() {
|
||||
return this.workspaceService.workspace.engine.rootDocState$
|
||||
.map(state => !state.syncing)
|
||||
.asObservable();
|
||||
}
|
||||
|
||||
setDocMeta(id: string, meta: Partial<DocMeta>) {
|
||||
this.workspaceService.workspace.docCollection.setDocMeta(id, meta);
|
||||
}
|
||||
|
||||
setDocModeSetting(id: string, mode: DocMode) {
|
||||
this.localState.set(`page:${id}:mode`, mode);
|
||||
}
|
||||
|
||||
watchDocModeSetting(id: string) {
|
||||
return this.localState.watch<DocMode>(`page:${id}:mode`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Entity } from '../../../framework';
|
||||
import { LiveData } from '../../../livedata';
|
||||
import { MemoryMemento } from '../../../storage';
|
||||
import type { DocMode } from '../../doc';
|
||||
|
||||
export class GlobalContext extends Entity {
|
||||
memento = new MemoryMemento();
|
||||
|
||||
workspaceId = this.define<string>('workspaceId');
|
||||
|
||||
docId = this.define<string>('docId');
|
||||
|
||||
docMode = this.define<DocMode>('docMode');
|
||||
|
||||
define<T>(key: string) {
|
||||
this.memento.set(key, null);
|
||||
const livedata$ = LiveData.from(this.memento.watch<T>(key), null);
|
||||
return {
|
||||
get: () => this.memento.get(key) as T | null,
|
||||
set: (value: T | null) => this.memento.set(key, value),
|
||||
$: livedata$,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { GlobalContextService } from './services/global-context';
|
||||
|
||||
import type { Framework } from '../../framework';
|
||||
import { GlobalContext } from './entities/global-context';
|
||||
import { GlobalContextService } from './services/global-context';
|
||||
|
||||
export function configureGlobalContextModule(framework: Framework) {
|
||||
framework.service(GlobalContextService).entity(GlobalContext);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Service } from '../../../framework';
|
||||
import { GlobalContext } from '../entities/global-context';
|
||||
|
||||
export class GlobalContextService extends Service {
|
||||
globalContext = this.framework.createEntity(GlobalContext);
|
||||
}
|
||||
12
packages/common/infra/src/modules/lifecycle/index.ts
Normal file
12
packages/common/infra/src/modules/lifecycle/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Framework } from '../../framework';
|
||||
import { LifecycleService } from './service/lifecycle';
|
||||
|
||||
export {
|
||||
ApplicationFocused,
|
||||
ApplicationStarted,
|
||||
LifecycleService,
|
||||
} from './service/lifecycle';
|
||||
|
||||
export function configureLifecycleModule(framework: Framework) {
|
||||
framework.service(LifecycleService);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createEvent, Service } from '../../../framework';
|
||||
|
||||
/**
|
||||
* Event that is emitted when application is started.
|
||||
*/
|
||||
export const ApplicationStarted = createEvent<boolean>('ApplicationStartup');
|
||||
|
||||
/**
|
||||
* Event that is emitted when browser tab or windows is focused again, after being blurred.
|
||||
* Can be used to actively refresh some data.
|
||||
*/
|
||||
export const ApplicationFocused = createEvent<boolean>('ApplicationFocused');
|
||||
|
||||
export class LifecycleService extends Service {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
applicationStart() {
|
||||
this.eventBus.emit(ApplicationStarted, true);
|
||||
}
|
||||
|
||||
applicationFocus() {
|
||||
this.eventBus.emit(ApplicationFocused, true);
|
||||
}
|
||||
}
|
||||
17
packages/common/infra/src/modules/storage/index.ts
Normal file
17
packages/common/infra/src/modules/storage/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { GlobalCache, GlobalState } from './providers/global';
|
||||
export { GlobalCacheService, GlobalStateService } from './services/global';
|
||||
|
||||
import type { Framework } from '../../framework';
|
||||
import { MemoryMemento } from '../../storage';
|
||||
import { GlobalCache, GlobalState } from './providers/global';
|
||||
import { GlobalCacheService, GlobalStateService } from './services/global';
|
||||
|
||||
export const configureGlobalStorageModule = (framework: Framework) => {
|
||||
framework.service(GlobalStateService, [GlobalState]);
|
||||
framework.service(GlobalCacheService, [GlobalCache]);
|
||||
};
|
||||
|
||||
export const configureTestingGlobalStorage = (framework: Framework) => {
|
||||
framework.impl(GlobalCache, MemoryMemento);
|
||||
framework.impl(GlobalState, MemoryMemento);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createIdentifier } from '../../../framework';
|
||||
import type { Memento } from '../../../storage';
|
||||
|
||||
/**
|
||||
* A memento object that stores the entire application state.
|
||||
*
|
||||
* State is persisted, even the application is closed.
|
||||
*/
|
||||
export interface GlobalState extends Memento {}
|
||||
|
||||
export const GlobalState = createIdentifier<GlobalState>('GlobalState');
|
||||
|
||||
/**
|
||||
* A memento object that stores the entire application cache.
|
||||
*
|
||||
* Cache may be deleted from time to time, business logic should not rely on cache.
|
||||
*/
|
||||
export interface GlobalCache extends Memento {}
|
||||
|
||||
export const GlobalCache = createIdentifier<GlobalCache>('GlobalCache');
|
||||
14
packages/common/infra/src/modules/storage/services/global.ts
Normal file
14
packages/common/infra/src/modules/storage/services/global.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Service } from '../../../framework';
|
||||
import type { GlobalCache, GlobalState } from '../providers/global';
|
||||
|
||||
export class GlobalStateService extends Service {
|
||||
constructor(public readonly globalState: GlobalState) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GlobalCacheService extends Service {
|
||||
constructor(public readonly globalCache: GlobalCache) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { Framework } from '../../../framework';
|
||||
import { configureTestingGlobalStorage } from '../../storage';
|
||||
import {
|
||||
configureTestingWorkspaceProvider,
|
||||
configureWorkspaceModule,
|
||||
Workspace,
|
||||
WorkspacesService,
|
||||
} from '..';
|
||||
|
||||
describe('Workspace System', () => {
|
||||
test('create workspace', async () => {
|
||||
const framework = new Framework();
|
||||
configureTestingGlobalStorage(framework);
|
||||
configureWorkspaceModule(framework);
|
||||
configureTestingWorkspaceProvider(framework);
|
||||
|
||||
const provider = framework.provider();
|
||||
const workspaceService = provider.get(WorkspacesService);
|
||||
expect(workspaceService.list.workspaces$.value.length).toBe(0);
|
||||
|
||||
const workspace = workspaceService.open({
|
||||
metadata: await workspaceService.create(WorkspaceFlavour.LOCAL),
|
||||
});
|
||||
|
||||
expect(workspace.workspace).toBeInstanceOf(Workspace);
|
||||
|
||||
expect(workspaceService.list.workspaces$.value.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Doc as YDoc } from 'yjs';
|
||||
|
||||
import { Entity } from '../../../framework';
|
||||
import { AwarenessEngine, BlobEngine, DocEngine } from '../../../sync';
|
||||
import { throwIfAborted } from '../../../utils';
|
||||
import type { WorkspaceEngineProvider } from '../providers/flavour';
|
||||
import type { WorkspaceService } from '../services/workspace';
|
||||
|
||||
export class WorkspaceEngine extends Entity<{
|
||||
engineProvider: WorkspaceEngineProvider;
|
||||
}> {
|
||||
doc = new DocEngine(
|
||||
this.props.engineProvider.getDocStorage(),
|
||||
this.props.engineProvider.getDocServer()
|
||||
);
|
||||
|
||||
blob = new BlobEngine(
|
||||
this.props.engineProvider.getLocalBlobStorage(),
|
||||
this.props.engineProvider.getRemoteBlobStorages()
|
||||
);
|
||||
|
||||
awareness = new AwarenessEngine(
|
||||
this.props.engineProvider.getAwarenessConnections()
|
||||
);
|
||||
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
}
|
||||
|
||||
setRootDoc(yDoc: YDoc) {
|
||||
this.doc.setPriority(yDoc.guid, 100);
|
||||
this.doc.addDoc(yDoc);
|
||||
}
|
||||
|
||||
start() {
|
||||
this.doc.start();
|
||||
this.awareness.connect();
|
||||
this.blob.start();
|
||||
}
|
||||
|
||||
canGracefulStop() {
|
||||
return this.doc.engineState$.value.saving === 0;
|
||||
}
|
||||
|
||||
async waitForGracefulStop(abort?: AbortSignal) {
|
||||
await this.doc.waitForSaved();
|
||||
throwIfAborted(abort);
|
||||
this.forceStop();
|
||||
}
|
||||
|
||||
forceStop() {
|
||||
this.doc.stop();
|
||||
this.awareness.disconnect();
|
||||
this.blob.stop();
|
||||
}
|
||||
|
||||
docEngineState$ = this.doc.engineState$;
|
||||
|
||||
rootDocState$ = this.doc.docState$(this.workspaceService.workspace.id);
|
||||
|
||||
waitForDocSynced() {
|
||||
return this.doc.waitForSynced();
|
||||
}
|
||||
|
||||
waitForRootDocReady() {
|
||||
return this.doc.waitForReady(this.workspaceService.workspace.id);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.forceStop();
|
||||
}
|
||||
}
|
||||
27
packages/common/infra/src/modules/workspace/entities/list.ts
Normal file
27
packages/common/infra/src/modules/workspace/entities/list.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Entity } from '../../../framework';
|
||||
import { LiveData } from '../../../livedata';
|
||||
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||
|
||||
export class WorkspaceList extends Entity {
|
||||
workspaces$ = new LiveData(this.providers.map(p => p.workspaces$))
|
||||
.map(v => {
|
||||
return v;
|
||||
})
|
||||
.flat()
|
||||
.map(workspaces => {
|
||||
return workspaces.flat();
|
||||
});
|
||||
isLoading$ = new LiveData(
|
||||
this.providers.map(p => p.isLoading$ ?? new LiveData(false))
|
||||
)
|
||||
.flat()
|
||||
.map(isLoadings => isLoadings.some(isLoading => isLoading));
|
||||
|
||||
constructor(private readonly providers: WorkspaceFlavourProvider[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
revalidate() {
|
||||
this.providers.forEach(provider => provider.revalidate?.());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { catchError, EMPTY, from, mergeMap, switchMap } from 'rxjs';
|
||||
|
||||
import { Entity } from '../../../framework';
|
||||
import { effect, LiveData, onComplete, onStart } from '../../../livedata';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||
import type { WorkspaceProfileCacheStore } from '../stores/profile-cache';
|
||||
import type { Workspace } from './workspace';
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-profile');
|
||||
|
||||
export interface WorkspaceProfileInfo {
|
||||
avatar?: string;
|
||||
name?: string;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* # WorkspaceProfile
|
||||
*
|
||||
* This class take care of workspace avatar and name
|
||||
*/
|
||||
export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
|
||||
private readonly provider: WorkspaceFlavourProvider | null;
|
||||
|
||||
get id() {
|
||||
return this.props.metadata.id;
|
||||
}
|
||||
|
||||
profile$ = LiveData.from<WorkspaceProfileInfo | null>(
|
||||
this.cache.watchProfileCache(this.props.metadata.id),
|
||||
null
|
||||
);
|
||||
|
||||
avatar$ = this.profile$.map(v => v?.avatar);
|
||||
name$ = this.profile$.map(v => v?.name);
|
||||
|
||||
isLoading$ = new LiveData(false);
|
||||
|
||||
constructor(
|
||||
private readonly cache: WorkspaceProfileCacheStore,
|
||||
providers: WorkspaceFlavourProvider[]
|
||||
) {
|
||||
super();
|
||||
|
||||
this.provider =
|
||||
providers.find(p => p.flavour === this.props.metadata.flavour) ?? null;
|
||||
}
|
||||
|
||||
private setCache(info: WorkspaceProfileInfo) {
|
||||
this.cache.setProfileCache(this.props.metadata.id, info);
|
||||
}
|
||||
|
||||
revalidate = effect(
|
||||
switchMap(() => {
|
||||
if (!this.provider) {
|
||||
return EMPTY;
|
||||
}
|
||||
return from(
|
||||
this.provider.getWorkspaceProfile(this.props.metadata.id)
|
||||
).pipe(
|
||||
mergeMap(info => {
|
||||
if (info) {
|
||||
this.setCache({ ...this.profile$.value, ...info });
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
catchError(err => {
|
||||
logger.error(err);
|
||||
return EMPTY;
|
||||
}),
|
||||
onStart(() => this.isLoading$.next(true)),
|
||||
onComplete(() => this.isLoading$.next(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
syncWithWorkspace(workspace: Workspace) {
|
||||
workspace.name$.subscribe(name => {
|
||||
const old = this.profile$.value;
|
||||
this.setCache({ ...old, name: name ?? old?.name });
|
||||
});
|
||||
workspace.avatar$.subscribe(avatar => {
|
||||
const old = this.profile$.value;
|
||||
this.setCache({ ...old, avatar: avatar ?? old?.avatar });
|
||||
});
|
||||
}
|
||||
}
|
||||
135
packages/common/infra/src/modules/workspace/entities/upgrade.ts
Normal file
135
packages/common/infra/src/modules/workspace/entities/upgrade.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import {
|
||||
checkWorkspaceCompatibility,
|
||||
forceUpgradePages,
|
||||
migrateGuidCompatibility,
|
||||
MigrationPoint,
|
||||
upgradeV1ToV2,
|
||||
} from '../../../blocksuite';
|
||||
import { Entity } from '../../../framework';
|
||||
import { LiveData } from '../../../livedata';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
import type { WorkspaceDestroyService } from '../services/destroy';
|
||||
import type { WorkspaceFactoryService } from '../services/factory';
|
||||
import type { WorkspaceService } from '../services/workspace';
|
||||
|
||||
export class WorkspaceUpgrade extends Entity {
|
||||
needUpgrade$ = new LiveData(false);
|
||||
upgrading$ = new LiveData(false);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly workspaceFactory: WorkspaceFactoryService,
|
||||
private readonly workspaceDestroy: WorkspaceDestroyService
|
||||
) {
|
||||
super();
|
||||
this.checkIfNeedUpgrade();
|
||||
workspaceService.workspace.docCollection.doc.on('update', () => {
|
||||
this.checkIfNeedUpgrade();
|
||||
});
|
||||
}
|
||||
|
||||
checkIfNeedUpgrade() {
|
||||
const needUpgrade = !!checkWorkspaceCompatibility(
|
||||
this.workspaceService.workspace.docCollection,
|
||||
this.workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD
|
||||
);
|
||||
this.needUpgrade$.next(needUpgrade);
|
||||
return needUpgrade;
|
||||
}
|
||||
|
||||
async upgrade(): Promise<WorkspaceMetadata | null> {
|
||||
if (this.upgrading$.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.upgrading$.next(true);
|
||||
|
||||
try {
|
||||
await this.workspaceService.workspace.engine.waitForDocSynced();
|
||||
|
||||
const step = checkWorkspaceCompatibility(
|
||||
this.workspaceService.workspace.docCollection,
|
||||
this.workspaceService.workspace.flavour ===
|
||||
WorkspaceFlavour.AFFINE_CLOUD
|
||||
);
|
||||
|
||||
if (!step) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clone a new doc to prevent change events.
|
||||
const clonedDoc = new YDoc({
|
||||
guid: this.workspaceService.workspace.docCollection.doc.guid,
|
||||
});
|
||||
applyDoc(clonedDoc, this.workspaceService.workspace.docCollection.doc);
|
||||
|
||||
if (step === MigrationPoint.SubDoc) {
|
||||
const newWorkspace = await this.workspaceFactory.create(
|
||||
WorkspaceFlavour.LOCAL,
|
||||
async (workspace, blobStorage) => {
|
||||
await upgradeV1ToV2(clonedDoc, workspace.doc);
|
||||
migrateGuidCompatibility(clonedDoc);
|
||||
await forceUpgradePages(
|
||||
workspace.doc,
|
||||
this.workspaceService.workspace.docCollection.schema
|
||||
);
|
||||
const blobList =
|
||||
await this.workspaceService.workspace.docCollection.blob.list();
|
||||
|
||||
for (const blobKey of blobList) {
|
||||
const blob =
|
||||
await this.workspaceService.workspace.docCollection.blob.get(
|
||||
blobKey
|
||||
);
|
||||
if (blob) {
|
||||
await blobStorage.set(blobKey, blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
await this.workspaceDestroy.deleteWorkspace(
|
||||
this.workspaceService.workspace.meta
|
||||
);
|
||||
return newWorkspace;
|
||||
} else if (step === MigrationPoint.GuidFix) {
|
||||
migrateGuidCompatibility(clonedDoc);
|
||||
await forceUpgradePages(
|
||||
clonedDoc,
|
||||
this.workspaceService.workspace.docCollection.schema
|
||||
);
|
||||
applyDoc(this.workspaceService.workspace.docCollection.doc, clonedDoc);
|
||||
await this.workspaceService.workspace.engine.waitForDocSynced();
|
||||
return null;
|
||||
} else if (step === MigrationPoint.BlockVersion) {
|
||||
await forceUpgradePages(
|
||||
clonedDoc,
|
||||
this.workspaceService.workspace.docCollection.schema
|
||||
);
|
||||
applyDoc(this.workspaceService.workspace.docCollection.doc, clonedDoc);
|
||||
await this.workspaceService.workspace.engine.waitForDocSynced();
|
||||
return null;
|
||||
} else {
|
||||
throw new Unreachable();
|
||||
}
|
||||
} finally {
|
||||
this.upgrading$.next(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyDoc(target: YDoc, result: YDoc) {
|
||||
applyUpdate(target, encodeStateAsUpdate(result));
|
||||
for (const targetSubDoc of target.subdocs.values()) {
|
||||
const resultSubDocs = Array.from(result.subdocs.values());
|
||||
const resultSubDoc = resultSubDocs.find(
|
||||
item => item.guid === targetSubDoc.guid
|
||||
);
|
||||
if (resultSubDoc) {
|
||||
applyDoc(targetSubDoc, resultSubDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { Awareness } from 'y-protocols/awareness.js';
|
||||
|
||||
import { Entity } from '../../../framework';
|
||||
import { LiveData } from '../../../livedata';
|
||||
import { globalBlockSuiteSchema } from '../global-schema';
|
||||
import type { WorkspaceScope } from '../scopes/workspace';
|
||||
import { WorkspaceEngineService } from '../services/engine';
|
||||
import { WorkspaceUpgradeService } from '../services/upgrade';
|
||||
|
||||
export class Workspace extends Entity {
|
||||
constructor(public readonly scope: WorkspaceScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly id = this.scope.props.openOptions.metadata.id;
|
||||
|
||||
readonly openOptions = this.scope.props.openOptions;
|
||||
|
||||
readonly meta = this.scope.props.openOptions.metadata;
|
||||
|
||||
readonly flavour = this.meta.flavour;
|
||||
|
||||
_docCollection: DocCollection | null = null;
|
||||
|
||||
get docCollection() {
|
||||
if (!this._docCollection) {
|
||||
this._docCollection = new DocCollection({
|
||||
id: this.openOptions.metadata.id,
|
||||
blobStorages: [
|
||||
() => ({
|
||||
crud: {
|
||||
get: key => {
|
||||
return this.engine.blob.get(key);
|
||||
},
|
||||
set: (key, value) => {
|
||||
return this.engine.blob.set(key, value);
|
||||
},
|
||||
list: () => {
|
||||
return this.engine.blob.list();
|
||||
},
|
||||
delete: key => {
|
||||
return this.engine.blob.delete(key);
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
}
|
||||
return this._docCollection;
|
||||
}
|
||||
|
||||
get awareness() {
|
||||
return this.docCollection.awarenessStore.awareness as Awareness;
|
||||
}
|
||||
|
||||
get rootYDoc() {
|
||||
return this.docCollection.doc;
|
||||
}
|
||||
|
||||
get canGracefulStop() {
|
||||
// TODO
|
||||
return true;
|
||||
}
|
||||
|
||||
get engine() {
|
||||
return this.framework.get(WorkspaceEngineService).engine;
|
||||
}
|
||||
|
||||
get upgrade() {
|
||||
return this.framework.get(WorkspaceUpgradeService).upgrade;
|
||||
}
|
||||
|
||||
get flavourProvider() {
|
||||
return this.scope.props.flavourProvider;
|
||||
}
|
||||
|
||||
name$ = LiveData.from<string | undefined>(
|
||||
new Observable(subscriber => {
|
||||
subscriber.next(this.docCollection.meta.name);
|
||||
return this.docCollection.meta.commonFieldsUpdated.on(() => {
|
||||
subscriber.next(this.docCollection.meta.name);
|
||||
}).dispose;
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
avatar$ = LiveData.from<string | undefined>(
|
||||
new Observable(subscriber => {
|
||||
subscriber.next(this.docCollection.meta.avatar);
|
||||
return this.docCollection.meta.commonFieldsUpdated.on(() => {
|
||||
subscriber.next(this.docCollection.meta.avatar);
|
||||
}).dispose;
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { AffineSchemas } from '@blocksuite/blocks/schemas';
|
||||
import { Schema } from '@blocksuite/store';
|
||||
|
||||
export const globalBlockSuiteSchema = new Schema();
|
||||
|
||||
globalBlockSuiteSchema.register(AffineSchemas);
|
||||
75
packages/common/infra/src/modules/workspace/impls/storage.ts
Normal file
75
packages/common/infra/src/modules/workspace/impls/storage.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { type Memento, wrapMemento } from '../../../storage';
|
||||
import type { GlobalCache, GlobalState } from '../../storage';
|
||||
import type {
|
||||
WorkspaceLocalCache,
|
||||
WorkspaceLocalState,
|
||||
} from '../providers/storage';
|
||||
import type { WorkspaceService } from '../services/workspace';
|
||||
|
||||
export class WorkspaceLocalStateImpl implements WorkspaceLocalState {
|
||||
wrapped: Memento;
|
||||
constructor(workspaceService: WorkspaceService, globalState: GlobalState) {
|
||||
this.wrapped = wrapMemento(
|
||||
globalState,
|
||||
`workspace-state:${workspaceService.workspace.id}:`
|
||||
);
|
||||
}
|
||||
|
||||
keys(): string[] {
|
||||
return this.wrapped.keys();
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
return this.wrapped.get<T>(key);
|
||||
}
|
||||
|
||||
watch<T>(key: string) {
|
||||
return this.wrapped.watch<T>(key);
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T | null): void {
|
||||
return this.wrapped.set<T>(key, value);
|
||||
}
|
||||
|
||||
del(key: string): void {
|
||||
return this.wrapped.del(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
return this.wrapped.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceLocalCacheImpl implements WorkspaceLocalCache {
|
||||
wrapped: Memento;
|
||||
constructor(workspaceService: WorkspaceService, globalCache: GlobalCache) {
|
||||
this.wrapped = wrapMemento(
|
||||
globalCache,
|
||||
`workspace-cache:${workspaceService.workspace.id}:`
|
||||
);
|
||||
}
|
||||
|
||||
keys(): string[] {
|
||||
return this.wrapped.keys();
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
return this.wrapped.get<T>(key);
|
||||
}
|
||||
|
||||
watch<T>(key: string) {
|
||||
return this.wrapped.watch<T>(key);
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T | null): void {
|
||||
return this.wrapped.set<T>(key, value);
|
||||
}
|
||||
|
||||
del(key: string): void {
|
||||
return this.wrapped.del(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
return this.wrapped.clear();
|
||||
}
|
||||
}
|
||||
96
packages/common/infra/src/modules/workspace/index.ts
Normal file
96
packages/common/infra/src/modules/workspace/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type { WorkspaceProfileInfo } from './entities/profile';
|
||||
export { Workspace } from './entities/workspace';
|
||||
export { globalBlockSuiteSchema } from './global-schema';
|
||||
export type { WorkspaceMetadata } from './metadata';
|
||||
export type { WorkspaceOpenOptions } from './open-options';
|
||||
export type { WorkspaceEngineProvider } from './providers/flavour';
|
||||
export { WorkspaceFlavourProvider } from './providers/flavour';
|
||||
export { WorkspaceLocalCache, WorkspaceLocalState } from './providers/storage';
|
||||
export { WorkspaceScope } from './scopes/workspace';
|
||||
export { WorkspaceService } from './services/workspace';
|
||||
export { WorkspacesService } from './services/workspaces';
|
||||
|
||||
import type { Framework } from '../../framework';
|
||||
import { GlobalCache, GlobalState } from '../storage';
|
||||
import { WorkspaceEngine } from './entities/engine';
|
||||
import { WorkspaceList } from './entities/list';
|
||||
import { WorkspaceProfile } from './entities/profile';
|
||||
import { WorkspaceUpgrade } from './entities/upgrade';
|
||||
import { Workspace } from './entities/workspace';
|
||||
import {
|
||||
WorkspaceLocalCacheImpl,
|
||||
WorkspaceLocalStateImpl,
|
||||
} from './impls/storage';
|
||||
import { WorkspaceFlavourProvider } from './providers/flavour';
|
||||
import { WorkspaceLocalCache, WorkspaceLocalState } from './providers/storage';
|
||||
import { WorkspaceScope } from './scopes/workspace';
|
||||
import { WorkspaceDestroyService } from './services/destroy';
|
||||
import { WorkspaceEngineService } from './services/engine';
|
||||
import { WorkspaceFactoryService } from './services/factory';
|
||||
import { WorkspaceListService } from './services/list';
|
||||
import { WorkspaceProfileService } from './services/profile';
|
||||
import { WorkspaceRepositoryService } from './services/repo';
|
||||
import { WorkspaceTransformService } from './services/transform';
|
||||
import { WorkspaceUpgradeService } from './services/upgrade';
|
||||
import { WorkspaceService } from './services/workspace';
|
||||
import { WorkspacesService } from './services/workspaces';
|
||||
import { WorkspaceProfileCacheStore } from './stores/profile-cache';
|
||||
import { TestingWorkspaceLocalProvider } from './testing/testing-provider';
|
||||
|
||||
export function configureWorkspaceModule(framework: Framework) {
|
||||
framework
|
||||
.service(WorkspacesService, [
|
||||
[WorkspaceFlavourProvider],
|
||||
WorkspaceListService,
|
||||
WorkspaceProfileService,
|
||||
WorkspaceTransformService,
|
||||
WorkspaceRepositoryService,
|
||||
WorkspaceFactoryService,
|
||||
WorkspaceDestroyService,
|
||||
])
|
||||
.service(WorkspaceDestroyService, [[WorkspaceFlavourProvider]])
|
||||
.service(WorkspaceListService)
|
||||
.entity(WorkspaceList, [[WorkspaceFlavourProvider]])
|
||||
.service(WorkspaceProfileService)
|
||||
.store(WorkspaceProfileCacheStore, [GlobalCache])
|
||||
.entity(WorkspaceProfile, [
|
||||
WorkspaceProfileCacheStore,
|
||||
[WorkspaceFlavourProvider],
|
||||
])
|
||||
.service(WorkspaceFactoryService, [[WorkspaceFlavourProvider]])
|
||||
.service(WorkspaceTransformService, [
|
||||
WorkspaceFactoryService,
|
||||
WorkspaceDestroyService,
|
||||
])
|
||||
.service(WorkspaceRepositoryService, [
|
||||
[WorkspaceFlavourProvider],
|
||||
WorkspaceProfileService,
|
||||
])
|
||||
.scope(WorkspaceScope)
|
||||
.service(WorkspaceService)
|
||||
.entity(Workspace, [WorkspaceScope])
|
||||
.service(WorkspaceEngineService, [WorkspaceService])
|
||||
.entity(WorkspaceEngine, [WorkspaceService])
|
||||
.service(WorkspaceUpgradeService)
|
||||
.entity(WorkspaceUpgrade, [
|
||||
WorkspaceService,
|
||||
WorkspaceFactoryService,
|
||||
WorkspaceDestroyService,
|
||||
])
|
||||
.impl(WorkspaceLocalState, WorkspaceLocalStateImpl, [
|
||||
WorkspaceService,
|
||||
GlobalState,
|
||||
])
|
||||
.impl(WorkspaceLocalCache, WorkspaceLocalCacheImpl, [
|
||||
WorkspaceService,
|
||||
GlobalCache,
|
||||
]);
|
||||
}
|
||||
|
||||
export function configureTestingWorkspaceProvider(framework: Framework) {
|
||||
framework.impl(
|
||||
WorkspaceFlavourProvider('LOCAL'),
|
||||
TestingWorkspaceLocalProvider,
|
||||
[GlobalState]
|
||||
);
|
||||
}
|
||||
3
packages/common/infra/src/modules/workspace/metadata.ts
Normal file
3
packages/common/infra/src/modules/workspace/metadata.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
|
||||
export type WorkspaceMetadata = { id: string; flavour: WorkspaceFlavour };
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { WorkspaceMetadata } from './metadata';
|
||||
|
||||
export interface WorkspaceOpenOptions {
|
||||
metadata: WorkspaceMetadata;
|
||||
isSharedMode?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
|
||||
import { createIdentifier } from '../../../framework';
|
||||
import type { LiveData } from '../../../livedata';
|
||||
import type {
|
||||
AwarenessConnection,
|
||||
BlobStorage,
|
||||
DocServer,
|
||||
DocStorage,
|
||||
} from '../../../sync';
|
||||
import type { WorkspaceProfileInfo } from '../entities/profile';
|
||||
import type { Workspace } from '../entities/workspace';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
|
||||
export interface WorkspaceEngineProvider {
|
||||
getDocServer(): DocServer | null;
|
||||
getDocStorage(): DocStorage;
|
||||
getLocalBlobStorage(): BlobStorage;
|
||||
getRemoteBlobStorages(): BlobStorage[];
|
||||
getAwarenessConnections(): AwarenessConnection[];
|
||||
}
|
||||
|
||||
export interface WorkspaceFlavourProvider {
|
||||
flavour: WorkspaceFlavour;
|
||||
|
||||
deleteWorkspace(id: string): Promise<void>;
|
||||
|
||||
createWorkspace(
|
||||
initial: (
|
||||
docCollection: DocCollection,
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void>
|
||||
): Promise<WorkspaceMetadata>;
|
||||
|
||||
workspaces$: LiveData<WorkspaceMetadata[]>;
|
||||
|
||||
/**
|
||||
* means the workspace list is loading. if it's true, the workspace page will show loading spinner.
|
||||
*/
|
||||
isLoading$?: LiveData<boolean>;
|
||||
|
||||
/**
|
||||
* revalidate the workspace list.
|
||||
*
|
||||
* will be called when user open workspace list, or workspace not found.
|
||||
*/
|
||||
revalidate?: () => void;
|
||||
|
||||
getWorkspaceProfile(id: string): Promise<WorkspaceProfileInfo | undefined>;
|
||||
|
||||
getWorkspaceBlob(id: string, blob: string): Promise<Blob | null>;
|
||||
|
||||
getEngineProvider(workspace: Workspace): WorkspaceEngineProvider;
|
||||
}
|
||||
|
||||
export const WorkspaceFlavourProvider =
|
||||
createIdentifier<WorkspaceFlavourProvider>('WorkspaceFlavourProvider');
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createIdentifier } from '../../../framework';
|
||||
import type { Memento } from '../../../storage';
|
||||
|
||||
export interface WorkspaceLocalState extends Memento {}
|
||||
export interface WorkspaceLocalCache extends Memento {}
|
||||
|
||||
export const WorkspaceLocalState = createIdentifier<WorkspaceLocalState>(
|
||||
'WorkspaceLocalState'
|
||||
);
|
||||
|
||||
export const WorkspaceLocalCache = createIdentifier<WorkspaceLocalCache>(
|
||||
'WorkspaceLocalCache'
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Scope } from '../../../framework';
|
||||
import type { WorkspaceOpenOptions } from '../open-options';
|
||||
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||
|
||||
export type { DocCollection } from '@blocksuite/store';
|
||||
|
||||
export class WorkspaceScope extends Scope<{
|
||||
openOptions: WorkspaceOpenOptions;
|
||||
flavourProvider: WorkspaceFlavourProvider;
|
||||
}> {}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Service } from '../../../framework';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||
|
||||
export class WorkspaceDestroyService extends Service {
|
||||
constructor(private readonly providers: WorkspaceFlavourProvider[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
deleteWorkspace = async (metadata: WorkspaceMetadata) => {
|
||||
const provider = this.providers.find(p => p.flavour === metadata.flavour);
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown workspace flavour: ${metadata.flavour}`);
|
||||
}
|
||||
return provider.deleteWorkspace(metadata.id);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Service } from '../../../framework';
|
||||
import { WorkspaceEngine } from '../entities/engine';
|
||||
import type { WorkspaceService } from './workspace';
|
||||
|
||||
export class WorkspaceEngineService extends Service {
|
||||
private _engine: WorkspaceEngine | null = null;
|
||||
get engine() {
|
||||
if (!this._engine) {
|
||||
this._engine = this.framework.createEntity(WorkspaceEngine, {
|
||||
engineProvider:
|
||||
this.workspaceService.workspace.flavourProvider.getEngineProvider(
|
||||
this.workspaceService.workspace
|
||||
),
|
||||
});
|
||||
}
|
||||
return this._engine;
|
||||
}
|
||||
|
||||
constructor(private readonly workspaceService: WorkspaceService) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import type { DocCollection } from '@blocksuite/store';
|
||||
|
||||
import { Service } from '../../../framework';
|
||||
import type { BlobStorage } from '../../../sync';
|
||||
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||
|
||||
export class WorkspaceFactoryService extends Service {
|
||||
constructor(private readonly providers: WorkspaceFlavourProvider[]) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* create workspace
|
||||
* @param flavour workspace flavour
|
||||
* @param initial callback to put initial data to workspace
|
||||
* @returns workspace id
|
||||
*/
|
||||
create = async (
|
||||
flavour: WorkspaceFlavour,
|
||||
initial: (
|
||||
docCollection: DocCollection,
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void> = () => Promise.resolve()
|
||||
) => {
|
||||
const provider = this.providers.find(x => x.flavour === flavour);
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown workspace flavour: ${flavour}`);
|
||||
}
|
||||
const metadata = await provider.createWorkspace(initial);
|
||||
return metadata;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Service } from '../../../framework';
|
||||
import { WorkspaceList } from '../entities/list';
|
||||
|
||||
export class WorkspaceListService extends Service {
|
||||
list = this.framework.createEntity(WorkspaceList);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Service } from '../../../framework';
|
||||
import { ObjectPool } from '../../../utils';
|
||||
import { WorkspaceProfile } from '../entities/profile';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
|
||||
export class WorkspaceProfileService extends Service {
|
||||
pool = new ObjectPool<string, WorkspaceProfile>();
|
||||
|
||||
getProfile = (metadata: WorkspaceMetadata): WorkspaceProfile => {
|
||||
const exists = this.pool.get(metadata.id);
|
||||
if (exists) {
|
||||
return exists.obj;
|
||||
}
|
||||
|
||||
const profile = this.framework.createEntity(WorkspaceProfile, {
|
||||
metadata,
|
||||
});
|
||||
|
||||
return this.pool.put(metadata.id, profile).obj;
|
||||
};
|
||||
}
|
||||
114
packages/common/infra/src/modules/workspace/services/repo.ts
Normal file
114
packages/common/infra/src/modules/workspace/services/repo.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
|
||||
import { setupEditorFlags } from '../../../atom';
|
||||
import { fixWorkspaceVersion } from '../../../blocksuite';
|
||||
import { Service } from '../../../framework';
|
||||
import { ObjectPool } from '../../../utils';
|
||||
import type { Workspace } from '../entities/workspace';
|
||||
import type { WorkspaceOpenOptions } from '../open-options';
|
||||
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||
import { WorkspaceScope } from '../scopes/workspace';
|
||||
import type { WorkspaceProfileService } from './profile';
|
||||
import { WorkspaceService } from './workspace';
|
||||
|
||||
const logger = new DebugLogger('affine:workspace-repository');
|
||||
|
||||
export class WorkspaceRepositoryService extends Service {
|
||||
constructor(
|
||||
private readonly providers: WorkspaceFlavourProvider[],
|
||||
private readonly profileRepo: WorkspaceProfileService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
pool = new ObjectPool<string, Workspace>({
|
||||
onDelete(workspace) {
|
||||
workspace.scope.dispose();
|
||||
},
|
||||
onDangling(workspace) {
|
||||
return workspace.canGracefulStop;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* open workspace reference by metadata.
|
||||
*
|
||||
* You basically don't need to call this function directly, use the react hook `useWorkspace(metadata)` instead.
|
||||
*
|
||||
* @returns the workspace reference and a release function, don't forget to call release function when you don't
|
||||
* need the workspace anymore.
|
||||
*/
|
||||
open = (
|
||||
options: WorkspaceOpenOptions,
|
||||
customProvider?: WorkspaceFlavourProvider
|
||||
): {
|
||||
workspace: Workspace;
|
||||
dispose: () => void;
|
||||
} => {
|
||||
if (options.isSharedMode) {
|
||||
const workspace = this.instantiate(options, customProvider);
|
||||
return {
|
||||
workspace,
|
||||
dispose: () => {
|
||||
workspace.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const exist = this.pool.get(options.metadata.id);
|
||||
if (exist) {
|
||||
return {
|
||||
workspace: exist.obj,
|
||||
dispose: exist.release,
|
||||
};
|
||||
}
|
||||
|
||||
const workspace = this.instantiate(options, customProvider);
|
||||
// sync information with workspace list, when workspace's avatar and name changed, information will be updated
|
||||
// this.list.getInformation(metadata).syncWithWorkspace(workspace);
|
||||
|
||||
const ref = this.pool.put(workspace.meta.id, workspace);
|
||||
|
||||
return {
|
||||
workspace: ref.obj,
|
||||
dispose: ref.release,
|
||||
};
|
||||
};
|
||||
|
||||
instantiate(
|
||||
openOptions: WorkspaceOpenOptions,
|
||||
customProvider?: WorkspaceFlavourProvider
|
||||
) {
|
||||
logger.info(
|
||||
`open workspace [${openOptions.metadata.flavour}] ${openOptions.metadata.id} `
|
||||
);
|
||||
const provider =
|
||||
customProvider ??
|
||||
this.providers.find(p => p.flavour === openOptions.metadata.flavour);
|
||||
if (!provider) {
|
||||
throw new Error(
|
||||
`Unknown workspace flavour: ${openOptions.metadata.flavour}`
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceScope = this.framework.createScope(WorkspaceScope, {
|
||||
openOptions,
|
||||
flavourProvider: provider,
|
||||
});
|
||||
|
||||
const workspace = workspaceScope.get(WorkspaceService).workspace;
|
||||
|
||||
workspace.engine.setRootDoc(workspace.docCollection.doc);
|
||||
workspace.engine.start();
|
||||
|
||||
// apply compatibility fix
|
||||
fixWorkspaceVersion(workspace.docCollection.doc);
|
||||
|
||||
setupEditorFlags(workspace.docCollection);
|
||||
|
||||
this.profileRepo
|
||||
.getProfile(openOptions.metadata)
|
||||
.syncWithWorkspace(workspace);
|
||||
|
||||
return workspace;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { assertEquals } from '@blocksuite/global/utils';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { Service } from '../../../framework';
|
||||
import type { Workspace } from '../entities/workspace';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
import type { WorkspaceDestroyService } from './destroy';
|
||||
import type { WorkspaceFactoryService } from './factory';
|
||||
|
||||
export class WorkspaceTransformService extends Service {
|
||||
constructor(
|
||||
private readonly factory: WorkspaceFactoryService,
|
||||
private readonly destroy: WorkspaceDestroyService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* helper function to transform local workspace to cloud workspace
|
||||
*/
|
||||
transformLocalToCloud = async (
|
||||
local: Workspace
|
||||
): Promise<WorkspaceMetadata> => {
|
||||
assertEquals(local.flavour, WorkspaceFlavour.LOCAL);
|
||||
|
||||
await local.engine.waitForDocSynced();
|
||||
|
||||
const newMetadata = await this.factory.create(
|
||||
WorkspaceFlavour.AFFINE_CLOUD,
|
||||
async (ws, bs) => {
|
||||
applyUpdate(ws.doc, encodeStateAsUpdate(local.docCollection.doc));
|
||||
|
||||
for (const subdoc of local.docCollection.doc.getSubdocs()) {
|
||||
for (const newSubdoc of ws.doc.getSubdocs()) {
|
||||
if (newSubdoc.guid === subdoc.guid) {
|
||||
applyUpdate(newSubdoc, encodeStateAsUpdate(subdoc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blobList = await local.engine.blob.list();
|
||||
|
||||
for (const blobKey of blobList) {
|
||||
const blob = await local.engine.blob.get(blobKey);
|
||||
if (blob) {
|
||||
await bs.set(blobKey, blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await this.destroy.deleteWorkspace(local.meta);
|
||||
|
||||
return newMetadata;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Service } from '../../../framework';
|
||||
import { WorkspaceUpgrade } from '../entities/upgrade';
|
||||
|
||||
export class WorkspaceUpgradeService extends Service {
|
||||
upgrade = this.framework.createEntity(WorkspaceUpgrade);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Service } from '../../../framework';
|
||||
import { Workspace } from '../entities/workspace';
|
||||
|
||||
export class WorkspaceService extends Service {
|
||||
_workspace: Workspace | null = null;
|
||||
|
||||
get workspace() {
|
||||
if (!this._workspace) {
|
||||
this._workspace = this.framework.createEntity(Workspace);
|
||||
}
|
||||
return this._workspace;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Service } from '../../../framework';
|
||||
import type { WorkspaceMetadata } from '..';
|
||||
import type { WorkspaceFlavourProvider } from '../providers/flavour';
|
||||
import type { WorkspaceDestroyService } from './destroy';
|
||||
import type { WorkspaceFactoryService } from './factory';
|
||||
import type { WorkspaceListService } from './list';
|
||||
import type { WorkspaceProfileService } from './profile';
|
||||
import type { WorkspaceRepositoryService } from './repo';
|
||||
import type { WorkspaceTransformService } from './transform';
|
||||
|
||||
export class WorkspacesService extends Service {
|
||||
get list() {
|
||||
return this.listService.list;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly providers: WorkspaceFlavourProvider[],
|
||||
private readonly listService: WorkspaceListService,
|
||||
private readonly profileRepo: WorkspaceProfileService,
|
||||
private readonly transform: WorkspaceTransformService,
|
||||
private readonly workspaceRepo: WorkspaceRepositoryService,
|
||||
private readonly workspaceFactory: WorkspaceFactoryService,
|
||||
private readonly destroy: WorkspaceDestroyService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get deleteWorkspace() {
|
||||
return this.destroy.deleteWorkspace;
|
||||
}
|
||||
|
||||
get getProfile() {
|
||||
return this.profileRepo.getProfile;
|
||||
}
|
||||
|
||||
get transformLocalToCloud() {
|
||||
return this.transform.transformLocalToCloud;
|
||||
}
|
||||
|
||||
get open() {
|
||||
return this.workspaceRepo.open;
|
||||
}
|
||||
|
||||
get create() {
|
||||
return this.workspaceFactory.create;
|
||||
}
|
||||
|
||||
async getWorkspaceBlob(meta: WorkspaceMetadata, blob: string) {
|
||||
return await this.providers
|
||||
.find(x => x.flavour === meta.flavour)
|
||||
?.getWorkspaceBlob(meta.id, blob);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { map } from 'rxjs';
|
||||
|
||||
import { Store } from '../../../framework';
|
||||
import type { GlobalCache } from '../../storage';
|
||||
import type { WorkspaceProfileInfo } from '../entities/profile';
|
||||
|
||||
const WORKSPACE_PROFILE_CACHE_KEY = 'workspace-information:';
|
||||
|
||||
export class WorkspaceProfileCacheStore extends Store {
|
||||
constructor(private readonly cache: GlobalCache) {
|
||||
super();
|
||||
}
|
||||
|
||||
watchProfileCache(workspaceId: string) {
|
||||
return this.cache.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceId).pipe(
|
||||
map(data => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const info = data as WorkspaceProfileInfo;
|
||||
|
||||
return {
|
||||
avatar: info.avatar,
|
||||
name: info.name,
|
||||
isOwner: info.isOwner,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setProfileCache(workspaceId: string, info: WorkspaceProfileInfo) {
|
||||
this.cache.set(WORKSPACE_PROFILE_CACHE_KEY + workspaceId, info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { DocCollection, nanoid } from '@blocksuite/store';
|
||||
import { map } from 'rxjs';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { Service } from '../../../framework';
|
||||
import { LiveData } from '../../../livedata';
|
||||
import { wrapMemento } from '../../../storage';
|
||||
import { type BlobStorage, MemoryDocStorage } from '../../../sync';
|
||||
import { MemoryBlobStorage } from '../../../sync/blob/blob';
|
||||
import type { GlobalState } from '../../storage';
|
||||
import type { WorkspaceProfileInfo } from '../entities/profile';
|
||||
import type { Workspace } from '../entities/workspace';
|
||||
import { globalBlockSuiteSchema } from '../global-schema';
|
||||
import type { WorkspaceMetadata } from '../metadata';
|
||||
import type {
|
||||
WorkspaceEngineProvider,
|
||||
WorkspaceFlavourProvider,
|
||||
} from '../providers/flavour';
|
||||
|
||||
export class TestingWorkspaceLocalProvider
|
||||
extends Service
|
||||
implements WorkspaceFlavourProvider
|
||||
{
|
||||
flavour: WorkspaceFlavour = WorkspaceFlavour.LOCAL;
|
||||
|
||||
store = wrapMemento(this.globalStore, 'testing/');
|
||||
workspaceListStore = wrapMemento(this.store, 'workspaces/');
|
||||
docStorage = new MemoryDocStorage(wrapMemento(this.store, 'docs/'));
|
||||
|
||||
constructor(private readonly globalStore: GlobalState) {
|
||||
super();
|
||||
}
|
||||
|
||||
async deleteWorkspace(id: string): Promise<void> {
|
||||
const list = this.workspaceListStore.get<WorkspaceMetadata[]>('list') ?? [];
|
||||
const newList = list.filter(meta => meta.id !== id);
|
||||
this.workspaceListStore.set('list', newList);
|
||||
}
|
||||
async createWorkspace(
|
||||
initial: (
|
||||
docCollection: DocCollection,
|
||||
blobStorage: BlobStorage
|
||||
) => Promise<void>
|
||||
): Promise<WorkspaceMetadata> {
|
||||
const id = nanoid();
|
||||
const meta = { id, flavour: WorkspaceFlavour.LOCAL };
|
||||
|
||||
const blobStorage = new MemoryBlobStorage(
|
||||
wrapMemento(this.store, id + '/blobs/')
|
||||
);
|
||||
|
||||
const docCollection = new DocCollection({
|
||||
id: id,
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
blobStorages: [
|
||||
() => {
|
||||
return {
|
||||
crud: blobStorage,
|
||||
};
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// apply initial state
|
||||
await initial(docCollection, blobStorage);
|
||||
|
||||
// save workspace to storage
|
||||
await this.docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc));
|
||||
for (const subdocs of docCollection.doc.getSubdocs()) {
|
||||
await this.docStorage.doc.set(subdocs.guid, encodeStateAsUpdate(subdocs));
|
||||
}
|
||||
|
||||
const list = this.workspaceListStore.get<WorkspaceMetadata[]>('list') ?? [];
|
||||
this.workspaceListStore.set('list', [...list, meta]);
|
||||
|
||||
return { id, flavour: WorkspaceFlavour.LOCAL };
|
||||
}
|
||||
workspaces$ = LiveData.from<WorkspaceMetadata[]>(
|
||||
this.workspaceListStore
|
||||
.watch<WorkspaceMetadata[]>('list')
|
||||
.pipe(map(m => m ?? [])),
|
||||
[]
|
||||
);
|
||||
async getWorkspaceProfile(
|
||||
id: string
|
||||
): Promise<WorkspaceProfileInfo | undefined> {
|
||||
const data = await this.docStorage.doc.get(id);
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bs = new DocCollection({
|
||||
id,
|
||||
schema: globalBlockSuiteSchema,
|
||||
});
|
||||
|
||||
applyUpdate(bs.doc, data);
|
||||
|
||||
return {
|
||||
name: bs.meta.name,
|
||||
avatar: bs.meta.avatar,
|
||||
isOwner: true,
|
||||
};
|
||||
}
|
||||
getWorkspaceBlob(id: string, blob: string): Promise<Blob | null> {
|
||||
return new MemoryBlobStorage(wrapMemento(this.store, id + '/blobs/')).get(
|
||||
blob
|
||||
);
|
||||
}
|
||||
getEngineProvider(workspace: Workspace): WorkspaceEngineProvider {
|
||||
return {
|
||||
getDocStorage: () => {
|
||||
return this.docStorage;
|
||||
},
|
||||
getAwarenessConnections() {
|
||||
return [];
|
||||
},
|
||||
getDocServer() {
|
||||
return null;
|
||||
},
|
||||
getLocalBlobStorage: () => {
|
||||
return new MemoryBlobStorage(
|
||||
wrapMemento(this.store, workspace.id + '/blobs/')
|
||||
);
|
||||
},
|
||||
getRemoteBlobStorages() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user