feat(infra): framework

This commit is contained in:
EYHN
2024-04-17 14:12:29 +08:00
parent ab17a05df3
commit 06fda3b62c
467 changed files with 9996 additions and 8697 deletions

View 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();
}
}

View File

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

View 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 ?? '');
}

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

View 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;
}> {}

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

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

View 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`);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { Service } from '../../../framework';
import { GlobalContext } from '../entities/global-context';
export class GlobalContextService extends Service {
globalContext = this.framework.createEntity(GlobalContext);
}

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

View File

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

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

View File

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

View 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();
}
}

View File

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

View File

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

View 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?.());
}
}

View File

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

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

View File

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

View File

@@ -0,0 +1,6 @@
import { AffineSchemas } from '@blocksuite/blocks/schemas';
import { Schema } from '@blocksuite/store';
export const globalBlockSuiteSchema = new Schema();
globalBlockSuiteSchema.register(AffineSchemas);

View 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();
}
}

View 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]
);
}

View File

@@ -0,0 +1,3 @@
import type { WorkspaceFlavour } from '@affine/env/workspace';
export type WorkspaceMetadata = { id: string; flavour: WorkspaceFlavour };

View File

@@ -0,0 +1,6 @@
import type { WorkspaceMetadata } from './metadata';
export interface WorkspaceOpenOptions {
metadata: WorkspaceMetadata;
isSharedMode?: boolean;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { Service } from '../../../framework';
import { WorkspaceList } from '../entities/list';
export class WorkspaceListService extends Service {
list = this.framework.createEntity(WorkspaceList);
}

View File

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

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

View File

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

View File

@@ -0,0 +1,6 @@
import { Service } from '../../../framework';
import { WorkspaceUpgrade } from '../entities/upgrade';
export class WorkspaceUpgradeService extends Service {
upgrade = this.framework.createEntity(WorkspaceUpgrade);
}

View File

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

View File

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

View File

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

View File

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