refactor(core): move infra modules to core (#9207)

This commit is contained in:
EYHN
2024-12-23 04:53:59 +00:00
parent 2ea79d25ad
commit 129f94ee78
337 changed files with 908 additions and 1367 deletions

View File

@@ -4,44 +4,7 @@ export * from './blocksuite';
export * from './framework';
export * from './initialization';
export * from './livedata';
export * from './modules/db';
export * from './modules/doc';
export * from './modules/feature-flag';
export * from './modules/global-context';
export * from './modules/lifecycle';
export * from './modules/storage';
export * from './modules/workspace';
export * from './orm';
export * from './storage';
export * from './sync';
export * from './utils';
import type { Framework } from './framework';
import { configureWorkspaceDBModule } from './modules/db';
import { configureDocModule } from './modules/doc';
import { configureFeatureFlagModule } from './modules/feature-flag';
import { configureGlobalContextModule } from './modules/global-context';
import { configureLifecycleModule } from './modules/lifecycle';
import {
configureGlobalStorageModule,
configureTestingGlobalStorage,
} from './modules/storage';
import {
configureTestingWorkspaceProvider,
configureWorkspaceModule,
} from './modules/workspace';
export function configureInfraModules(framework: Framework) {
configureWorkspaceModule(framework);
configureDocModule(framework);
configureWorkspaceDBModule(framework);
configureGlobalStorageModule(framework);
configureGlobalContextModule(framework);
configureLifecycleModule(framework);
configureFeatureFlagModule(framework);
}
export function configureTestingInfraModules(framework: Framework) {
configureTestingGlobalStorage(framework);
configureTestingWorkspaceProvider(framework);
}

View File

@@ -1,29 +0,0 @@
import { Entity } from '../../../framework';
import type { DBSchemaBuilder, TableMap } from '../../../orm';
import { WorkspaceDBTable } from './table';
export class WorkspaceDB<Schema extends DBSchemaBuilder> extends Entity<{
db: TableMap<Schema>;
schema: Schema;
storageDocId: (tableName: string) => string;
}> {
readonly db = this.props.db;
constructor() {
super();
Object.entries(this.props.schema).forEach(([tableName]) => {
const table = this.framework.createEntity(WorkspaceDBTable, {
table: this.db[tableName],
storageDocId: this.props.storageDocId(tableName),
});
Object.defineProperty(this, tableName, {
get: () => table,
});
});
}
}
export type WorkspaceDBWithTables<Schema extends DBSchemaBuilder> =
WorkspaceDB<Schema> & {
[K in keyof Schema]: WorkspaceDBTable<Schema[K]>;
};

View File

@@ -1,35 +0,0 @@
import { Entity } from '../../../framework';
import type { Table as OrmTable, TableSchemaBuilder } from '../../../orm';
import type { WorkspaceService } from '../../workspace';
export class WorkspaceDBTable<
Schema extends TableSchemaBuilder,
> extends Entity<{
table: OrmTable<Schema>;
storageDocId: string;
}> {
readonly table = this.props.table;
constructor(private readonly workspaceService: WorkspaceService) {
super();
}
isSyncing$ = this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.map(docState => docState.syncing);
isLoading$ = this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.map(docState => docState.loading);
create = this.table.create.bind(this.table);
update = this.table.update.bind(this.table);
get = this.table.get.bind(this.table);
// eslint-disable-next-line rxjs/finnish
get$ = this.table.get$.bind(this.table);
find = this.table.find.bind(this.table);
// eslint-disable-next-line rxjs/finnish
find$ = this.table.find$.bind(this.table);
keys = this.table.keys.bind(this.table);
delete = this.table.delete.bind(this.table);
}

View File

@@ -1,17 +0,0 @@
import type { Framework } from '../../framework';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { WorkspaceDB } from './entities/db';
import { WorkspaceDBTable } from './entities/table';
import { WorkspaceDBService } from './services/db';
export type { DocCustomPropertyInfo, DocProperties } from './schema';
export { WorkspaceDBService } from './services/db';
export { transformWorkspaceDBLocalToCloud } from './services/db';
export function configureWorkspaceDBModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(WorkspaceDBService, [WorkspaceService])
.entity(WorkspaceDB)
.entity(WorkspaceDBTable, [WorkspaceService]);
}

View File

@@ -1,7 +0,0 @@
export type { DocCustomPropertyInfo, DocProperties } from './schema';
export {
AFFiNE_WORKSPACE_DB_SCHEMA,
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
type AFFiNEWorkspaceDbSchema,
type AFFiNEWorkspaceUserdataDbSchema,
} from './schema';

View File

@@ -1,48 +0,0 @@
import { nanoid } from 'nanoid';
import { type DBSchemaBuilder, f, type ORMEntity, t } from '../../../orm';
export const AFFiNE_WORKSPACE_DB_SCHEMA = {
folders: {
id: f.string().primaryKey().optional().default(nanoid),
parentId: f.string().optional(),
data: f.string(),
type: f.string(),
index: f.string(),
},
docProperties: t.document({
// { [`custom:{customPropertyId}`]: any }
id: f.string().primaryKey(),
primaryMode: f.string().optional(),
edgelessColorTheme: f.string().optional(),
journal: f.string().optional(),
pageWidth: f.string().optional(),
}),
docCustomPropertyInfo: {
id: f.string().primaryKey().optional().default(nanoid),
name: f.string().optional(),
type: f.string(),
show: f.string().optional(),
index: f.string().optional(),
icon: f.string().optional(),
additionalData: f.json().optional(),
isDeleted: f.boolean().optional(),
// we will keep deleted properties in the database, for override legacy data
},
} as const satisfies DBSchemaBuilder;
export type AFFiNEWorkspaceDbSchema = typeof AFFiNE_WORKSPACE_DB_SCHEMA;
export type DocProperties = ORMEntity<AFFiNEWorkspaceDbSchema['docProperties']>;
export type DocCustomPropertyInfo = ORMEntity<
AFFiNEWorkspaceDbSchema['docCustomPropertyInfo']
>;
export const AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA = {
favorite: {
key: f.string().primaryKey(),
index: f.string(),
},
} as const satisfies DBSchemaBuilder;
export type AFFiNEWorkspaceUserdataDbSchema =
typeof AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA;

View File

@@ -1,125 +0,0 @@
import { Doc as YDoc } from 'yjs';
import { Service } from '../../../framework';
import { createORMClient, YjsDBAdapter } from '../../../orm';
import type { DocStorage } from '../../../sync';
import { ObjectPool } from '../../../utils';
import type { WorkspaceService } from '../../workspace';
import { WorkspaceDB, type WorkspaceDBWithTables } from '../entities/db';
import {
AFFiNE_WORKSPACE_DB_SCHEMA,
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
type AFFiNEWorkspaceDbSchema,
type AFFiNEWorkspaceUserdataDbSchema,
} from '../schema';
const WorkspaceDBClient = createORMClient(AFFiNE_WORKSPACE_DB_SCHEMA);
const WorkspaceUserdataDBClient = createORMClient(
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA
);
export class WorkspaceDBService extends Service {
db: WorkspaceDBWithTables<AFFiNEWorkspaceDbSchema>;
userdataDBPool = new ObjectPool<
string,
WorkspaceDB<AFFiNEWorkspaceUserdataDbSchema>
>({
onDangling() {
return false; // never release
},
});
constructor(private readonly workspaceService: WorkspaceService) {
super();
this.db = this.framework.createEntity(
WorkspaceDB<AFFiNEWorkspaceDbSchema>,
{
db: new WorkspaceDBClient(
new YjsDBAdapter(AFFiNE_WORKSPACE_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: db${workspaceId}${guid}
guid: `db$${this.workspaceService.workspace.id}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(
ydoc.guid,
50
);
return ydoc;
},
})
),
schema: AFFiNE_WORKSPACE_DB_SCHEMA,
storageDocId: tableName =>
`db$${this.workspaceService.workspace.id}$${tableName}`,
}
) as WorkspaceDBWithTables<AFFiNEWorkspaceDbSchema>;
}
userdataDB(userId: (string & {}) | '__local__') {
// __local__ for local workspace
const userdataDb = this.userdataDBPool.get(userId);
if (userdataDb) {
return userdataDb.obj as WorkspaceDBWithTables<AFFiNEWorkspaceUserdataDbSchema>;
}
const newDB = this.framework.createEntity(
WorkspaceDB<AFFiNEWorkspaceUserdataDbSchema>,
{
db: new WorkspaceUserdataDBClient(
new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: userdata${userId}${workspaceId}${guid}
guid: `userdata$${userId}$${this.workspaceService.workspace.id}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(
ydoc.guid,
50
);
return ydoc;
},
})
),
schema: AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
storageDocId: tableName =>
`userdata$${userId}$${this.workspaceService.workspace.id}$${tableName}`,
}
);
this.userdataDBPool.put(userId, newDB);
return newDB as WorkspaceDBWithTables<AFFiNEWorkspaceUserdataDbSchema>;
}
static isDBDocId(docId: string) {
return docId.startsWith('db$') || docId.startsWith('userdata$');
}
}
export async function transformWorkspaceDBLocalToCloud(
localWorkspaceId: string,
cloudWorkspaceId: string,
localDocStorage: DocStorage,
cloudDocStorage: DocStorage,
accountId: string
) {
for (const tableName of Object.keys(AFFiNE_WORKSPACE_DB_SCHEMA)) {
const localDocName = `db$${localWorkspaceId}$${tableName}`;
const localDoc = await localDocStorage.doc.get(localDocName);
if (localDoc) {
const cloudDocName = `db$${cloudWorkspaceId}$${tableName}`;
await cloudDocStorage.doc.set(cloudDocName, localDoc);
}
}
for (const tableName of Object.keys(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA)) {
const localDocName = `userdata$__local__$${localWorkspaceId}$${tableName}`;
const localDoc = await localDocStorage.doc.get(localDocName);
if (localDoc) {
const cloudDocName = `userdata$${accountId}$${cloudWorkspaceId}$${tableName}`;
await cloudDocStorage.doc.set(cloudDocName, localDoc);
}
}
}

View File

@@ -1,54 +0,0 @@
import type { DocCustomPropertyInfo } from '../db';
/**
* default built-in custom property, user can update and delete them
*
* 'id' and 'type' is request, 'index' is a manually maintained incremental key.
*/
export const BUILT_IN_CUSTOM_PROPERTY_TYPE = [
{
id: 'tags',
type: 'tags',
index: 'a0000001',
},
{
id: 'docPrimaryMode',
type: 'docPrimaryMode',
show: 'always-hide',
index: 'a0000002',
},
{
id: 'journal',
type: 'journal',
show: 'always-hide',
index: 'a0000003',
},
{
id: 'createdAt',
type: 'createdAt',
index: 'a0000004',
},
{
id: 'updatedAt',
type: 'updatedAt',
index: 'a0000005',
},
{
id: 'createdBy',
type: 'createdBy',
show: 'always-hide',
index: 'a0000006',
},
{
id: 'edgelessTheme',
type: 'edgelessTheme',
show: 'always-hide',
index: 'a0000007',
},
{
id: 'pageWidth',
type: 'pageWidth',
show: 'always-hide',
index: 'a0000008',
},
] as DocCustomPropertyInfo[];

View File

@@ -1,86 +0,0 @@
import type { DocMode, RootBlockModel } from '@blocksuite/affine/blocks';
import { Entity } from '../../../framework';
import type { WorkspaceService } from '../../workspace';
import type { DocScope } from '../scopes/doc';
import type { DocsStore } from '../stores/docs';
export class Doc extends Entity {
constructor(
public readonly scope: DocScope,
private readonly store: DocsStore,
private readonly workspaceService: WorkspaceService
) {
super();
}
/**
* for convenience
*/
get workspace() {
return this.workspaceService.workspace;
}
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 properties$ = this.record.properties$;
readonly primaryMode$ = this.record.primaryMode$;
readonly title$ = this.record.title$;
readonly trash$ = this.record.trash$;
customProperty$(propertyId: string) {
return this.record.customProperty$(propertyId);
}
setCustomProperty(propertyId: string, value: string) {
return this.record.setCustomProperty(propertyId, value);
}
setPrimaryMode(mode: DocMode) {
return this.record.setPrimaryMode(mode);
}
getPrimaryMode() {
return this.record.getPrimaryMode();
}
togglePrimaryMode() {
this.setPrimaryMode(
(this.getPrimaryMode() === 'edgeless' ? 'page' : 'edgeless') as DocMode
);
}
moveToTrash() {
return this.record.moveToTrash();
}
restoreFromTrash() {
return this.record.restoreFromTrash();
}
waitForSyncReady() {
return this.store.waitForDocLoadReady(this.id);
}
setPriorityLoad(priority: number) {
return this.store.setPriorityLoad(this.id, priority);
}
changeDocTitle(newTitle: string) {
const pageBlock = this.blockSuiteDoc.getBlocksByFlavour('affine:page').at(0)
?.model as RootBlockModel | undefined;
if (pageBlock) {
this.blockSuiteDoc.transact(() => {
pageBlock.title.delete(0, pageBlock.title.length);
pageBlock.title.insert(newTitle, 0);
});
this.record.setMeta({ title: newTitle });
}
}
}

View File

@@ -1,74 +0,0 @@
import { Entity } from '../../../framework';
import { LiveData } from '../../../livedata';
import { generateFractionalIndexingKeyBetween } from '../../../utils';
import type { DocCustomPropertyInfo } from '../../db/schema/schema';
import type { DocPropertiesStore } from '../stores/doc-properties';
export class DocPropertyList extends Entity {
constructor(private readonly docPropertiesStore: DocPropertiesStore) {
super();
}
properties$ = LiveData.from(
this.docPropertiesStore.watchDocPropertyInfoList(),
[]
);
sortedProperties$ = this.properties$.map(list =>
// default index key is '', so always before any others
list.toSorted((a, b) => ((a.index ?? '') > (b.index ?? '') ? 1 : -1))
);
propertyInfo$(id: string) {
return this.properties$.map(list => list.find(info => info.id === id));
}
updatePropertyInfo(id: string, properties: Partial<DocCustomPropertyInfo>) {
this.docPropertiesStore.updateDocPropertyInfo(id, properties);
}
createProperty(
properties: Omit<DocCustomPropertyInfo, 'id'> & { id?: string }
) {
return this.docPropertiesStore.createDocPropertyInfo(properties);
}
removeProperty(id: string) {
this.docPropertiesStore.removeDocPropertyInfo(id);
}
indexAt(at: 'before' | 'after', targetId?: string) {
const sortedChildren = this.sortedProperties$.value.filter(
node => node.index
) as (DocCustomPropertyInfo & { index: string })[];
const targetIndex = targetId
? sortedChildren.findIndex(node => node.id === targetId)
: -1;
if (targetIndex === -1) {
if (at === 'before') {
const first = sortedChildren.at(0);
return generateFractionalIndexingKeyBetween(null, first?.index ?? null);
} else {
const last = sortedChildren.at(-1);
return generateFractionalIndexingKeyBetween(last?.index ?? null, null);
}
} else {
const target = sortedChildren[targetIndex];
const before: DocCustomPropertyInfo | null =
sortedChildren[targetIndex - 1] || null;
const after: DocCustomPropertyInfo | null =
sortedChildren[targetIndex + 1] || null;
if (at === 'before') {
return generateFractionalIndexingKeyBetween(
before?.index ?? null,
target.index
);
} else {
return generateFractionalIndexingKeyBetween(
target.index,
after?.index ?? null
);
}
}
}
}

View File

@@ -1,86 +0,0 @@
import type { DocMode } from '@blocksuite/affine/blocks';
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 docsMap$ = LiveData.from<Map<string, DocRecord>>(
this.store.watchDocIds().pipe(
map(
ids =>
new Map(
ids.map(id => {
const exists = this.pool.get(id);
if (exists) {
return [id, exists];
}
const record = this.framework.createEntity(DocRecord, { id });
this.pool.set(id, record);
return [id, record];
})
)
)
),
new Map()
);
public readonly docs$ = this.docsMap$.selector(d => Array.from(d.values()));
public readonly trashDocs$ = LiveData.from<DocRecord[]>(
this.store.watchTrashDocIds().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.docsMap$.selector(map => map.get(id));
}
public setPrimaryMode(id: string, mode: DocMode) {
return this.store.setDocPrimaryModeSetting(id, mode);
}
public getPrimaryMode(id: string) {
return this.store.getDocPrimaryModeSetting(id);
}
public togglePrimaryMode(id: string) {
const mode = (
this.getPrimaryMode(id) === 'edgeless' ? 'page' : 'edgeless'
) as DocMode;
this.setPrimaryMode(id, mode);
return this.getPrimaryMode(id);
}
public primaryMode$(id: string) {
return LiveData.from(
this.store.watchDocPrimaryModeSetting(id),
this.getPrimaryMode(id)
);
}
}

View File

@@ -1,80 +0,0 @@
import type { DocMode } from '@blocksuite/affine/blocks';
import type { DocMeta } from '@blocksuite/affine/store';
import { Entity } from '../../../framework';
import { LiveData } from '../../../livedata';
import type { DocProperties } from '../../db';
import type { DocPropertiesStore } from '../stores/doc-properties';
import type { DocsStore } from '../stores/docs';
/**
* # DocRecord
*
* Some data you can use without open a doc.
*/
export class DocRecord extends Entity<{ id: string }> {
id: string = this.props.id;
constructor(
private readonly docsStore: DocsStore,
private readonly docPropertiesStore: DocPropertiesStore
) {
super();
}
meta$ = LiveData.from<Partial<DocMeta>>(
this.docsStore.watchDocMeta(this.id),
{}
);
properties$ = LiveData.from<DocProperties>(
this.docPropertiesStore.watchDocProperties(this.id),
{ id: this.id }
);
customProperty$(propertyId: string) {
return this.properties$.selector(
p => p['custom:' + propertyId]
) as LiveData<string | undefined | null>;
}
setCustomProperty(propertyId: string, value: string) {
this.docPropertiesStore.updateDocProperties(this.id, {
['custom:' + propertyId]: value,
});
}
setProperty(propertyId: string, value: string) {
this.docPropertiesStore.updateDocProperties(this.id, {
[propertyId]: value,
});
}
setMeta(meta: Partial<DocMeta>): void {
this.docsStore.setDocMeta(this.id, meta);
}
primaryMode$: LiveData<DocMode> = LiveData.from(
this.docsStore.watchDocPrimaryModeSetting(this.id),
'page' as DocMode
).map(mode => (mode === 'edgeless' ? 'edgeless' : 'page') as DocMode);
setPrimaryMode(mode: DocMode) {
return this.docsStore.setDocPrimaryModeSetting(this.id, mode);
}
getPrimaryMode() {
return this.docsStore.getDocPrimaryModeSetting(this.id);
}
moveToTrash() {
return this.setMeta({ trash: true, trashDate: Date.now() });
}
restoreFromTrash() {
return this.setMeta({ trash: false, trashDate: undefined });
}
title$ = this.meta$.map(meta => meta.title ?? '');
trash$ = this.meta$.map(meta => meta.trash ?? false);
}

View File

@@ -1,4 +0,0 @@
import { createEvent } from '../../../framework';
import type { DocRecord } from '../entities/record';
export const DocCreated = createEvent<DocRecord>('DocCreated');

View File

@@ -1,34 +0,0 @@
export { Doc } from './entities/doc';
export { DocRecord } from './entities/record';
export { DocRecordList } from './entities/record-list';
export { DocCreated } from './events';
export { DocScope } from './scopes/doc';
export { DocService } from './services/doc';
export { DocsService } from './services/docs';
import type { Framework } from '../../framework';
import { WorkspaceDBService } from '../db';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { Doc } from './entities/doc';
import { DocPropertyList } from './entities/property-list';
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 { DocPropertiesStore } from './stores/doc-properties';
import { DocsStore } from './stores/docs';
export function configureDocModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(DocsService, [DocsStore])
.store(DocPropertiesStore, [WorkspaceService, WorkspaceDBService])
.store(DocsStore, [WorkspaceService, DocPropertiesStore])
.entity(DocRecord, [DocsStore, DocPropertiesStore])
.entity(DocRecordList, [DocsStore])
.entity(DocPropertyList, [DocPropertiesStore])
.scope(DocScope)
.entity(Doc, [DocScope, DocsStore, WorkspaceService])
.service(DocService);
}

View File

@@ -1,10 +0,0 @@
import type { Doc as BlockSuiteDoc } from '@blocksuite/affine/store';
import { Scope } from '../../../framework';
import type { DocRecord } from '../entities/record';
export class DocScope extends Scope<{
docId: string;
record: DocRecord;
blockSuiteDoc: BlockSuiteDoc;
}> {}

View File

@@ -1,6 +0,0 @@
import { Service } from '../../../framework';
import { Doc } from '../entities/doc';
export class DocService extends Service {
public readonly doc = this.framework.createEntity(Doc);
}

View File

@@ -1,124 +0,0 @@
import { DebugLogger } from '@affine/debug';
import { Unreachable } from '@affine/env/constant';
import type { DocMode } from '@blocksuite/affine/blocks';
import type { DeltaInsert } from '@blocksuite/affine/inline';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { Service } from '../../../framework';
import { type DocProps, initDocFromProps } from '../../../initialization';
import { ObjectPool } from '../../../utils';
import type { Doc } from '../entities/doc';
import { DocPropertyList } from '../entities/property-list';
import { DocRecordList } from '../entities/record-list';
import { DocCreated } from '../events';
import { DocScope } from '../scopes/doc';
import type { DocsStore } from '../stores/docs';
import { DocService } from './doc';
const logger = new DebugLogger('DocsService');
export class DocsService extends Service {
list = this.framework.createEntity(DocRecordList);
pool = new ObjectPool<string, Doc>({
onDelete(obj) {
obj.scope.dispose();
},
});
propertyList = this.framework.createEntity(DocPropertyList);
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,
});
try {
blockSuiteDoc.load();
} catch (e) {
logger.error('Failed to load doc', {
docId,
error: e,
});
}
const doc = docScope.get(DocService).doc;
const { obj, release } = this.pool.put(docId, doc);
return { doc: obj, release };
}
createDoc(
options: {
primaryMode?: DocMode;
docProps?: DocProps;
} = {}
) {
const doc = this.store.createBlockSuiteDoc();
initDocFromProps(doc, options.docProps);
this.store.markDocSyncStateAsReady(doc.id);
const docRecord = this.list.doc$(doc.id).value;
if (!docRecord) {
throw new Unreachable();
}
if (options.primaryMode) {
docRecord.setPrimaryMode(options.primaryMode);
}
this.eventBus.emit(DocCreated, docRecord);
return docRecord;
}
async addLinkedDoc(targetDocId: string, linkedDocId: string) {
const { doc, release } = this.open(targetDocId);
doc.setPriorityLoad(10);
await doc.waitForSyncReady();
const text = new doc.blockSuiteDoc.Text([
{
insert: ' ',
attributes: {
reference: {
type: 'LinkedPage',
pageId: linkedDocId,
},
},
},
] as DeltaInsert<AffineTextAttributes>[]);
const [frame] = doc.blockSuiteDoc.getBlocksByFlavour('affine:note');
frame &&
doc.blockSuiteDoc.addBlock(
'affine:paragraph' as never, // TODO(eyhn): fix type
{ text },
frame.id
);
release();
}
async changeDocTitle(docId: string, newTitle: string) {
const { doc, release } = this.open(docId);
doc.setPriorityLoad(10);
await doc.waitForSyncReady();
doc.changeDocTitle(newTitle);
release();
}
}

View File

@@ -1,262 +0,0 @@
import { differenceBy, isNil, omitBy } from 'lodash-es';
import { combineLatest, map, switchMap } from 'rxjs';
import { AbstractType as YAbstractType } from 'yjs';
import { Store } from '../../../framework';
import {
yjsObserveByPath,
yjsObserveDeep,
} from '../../../utils/yjs-observable';
import type { WorkspaceDBService } from '../../db';
import type {
DocCustomPropertyInfo,
DocProperties,
} from '../../db/schema/schema';
import type { WorkspaceService } from '../../workspace';
import { BUILT_IN_CUSTOM_PROPERTY_TYPE } from '../constants';
interface LegacyDocProperties {
custom?: Record<string, { value: unknown } | undefined>;
system?: Record<string, { value: unknown } | undefined>;
}
type LegacyDocPropertyInfo = {
id?: string;
name?: string;
type?: string;
icon?: string;
};
type LegacyDocPropertyInfoList = Record<
string,
LegacyDocPropertyInfo | undefined
>;
export class DocPropertiesStore extends Store {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly dbService: WorkspaceDBService
) {
super();
}
updateDocProperties(id: string, config: Partial<DocProperties>) {
return this.dbService.db.docProperties.create({
id,
...config,
});
}
getDocPropertyInfoList() {
const db = this.dbService.db.docCustomPropertyInfo.find();
const legacy = this.upgradeLegacyDocPropertyInfoList(
this.getLegacyDocPropertyInfoList()
);
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)];
const all = [
...withLegacy,
...differenceBy(builtIn, withLegacy, i => i.id),
];
return all.filter(i => !i.isDeleted);
}
createDocPropertyInfo(
config: Omit<DocCustomPropertyInfo, 'id'> & { id?: string }
) {
return this.dbService.db.docCustomPropertyInfo.create(config);
}
removeDocPropertyInfo(id: string) {
this.updateDocPropertyInfo(id, {
additionalData: {}, // also remove additional data to reduce size
isDeleted: true,
});
}
updateDocPropertyInfo(id: string, config: Partial<DocCustomPropertyInfo>) {
const needMigration = !this.dbService.db.docCustomPropertyInfo.get(id);
const isBuiltIn =
needMigration && BUILT_IN_CUSTOM_PROPERTY_TYPE.some(i => i.id === id);
if (isBuiltIn) {
this.createPropertyFromBuiltIn(id, config);
} else if (needMigration) {
// if this property is not in db, we need to migration it from legacy to db, only type and name is needed
this.migrateLegacyDocPropertyInfo(id, config);
} else {
this.dbService.db.docCustomPropertyInfo.update(id, config);
}
}
migrateLegacyDocPropertyInfo(
id: string,
override: Partial<DocCustomPropertyInfo>
) {
const legacy = this.getLegacyDocPropertyInfo(id);
this.dbService.db.docCustomPropertyInfo.create({
id,
type:
legacy?.type ??
'unknown' /* should never reach here, just for safety, we need handle unknown property type */,
name: legacy?.name,
...override,
});
}
createPropertyFromBuiltIn(
id: string,
override: Partial<DocCustomPropertyInfo>
) {
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE.find(i => i.id === id);
if (!builtIn) {
return;
}
this.createDocPropertyInfo({ ...builtIn, ...override });
}
watchDocPropertyInfoList() {
return combineLatest([
this.watchLegacyDocPropertyInfoList().pipe(
map(this.upgradeLegacyDocPropertyInfoList)
),
this.dbService.db.docCustomPropertyInfo.find$(),
]).pipe(
map(([legacy, db]) => {
const builtIn = BUILT_IN_CUSTOM_PROPERTY_TYPE;
const withLegacy = [...db, ...differenceBy(legacy, db, i => i.id)];
const all = [
...withLegacy,
...differenceBy(builtIn, withLegacy, i => i.id),
];
return all.filter(i => !i.isDeleted);
})
);
}
getDocProperties(id: string) {
return {
...this.upgradeLegacyDocProperties(this.getLegacyDocProperties(id)),
...omitBy(this.dbService.db.docProperties.get(id), isNil),
// db always override legacy, but nil value should not override
};
}
watchDocProperties(id: string) {
return combineLatest([
this.watchLegacyDocProperties(id).pipe(
map(this.upgradeLegacyDocProperties)
),
this.dbService.db.docProperties.get$(id),
]).pipe(
map(
([legacy, db]) =>
({
...legacy,
...omitBy(db, isNil), // db always override legacy, but nil value should not override
}) as DocProperties
)
);
}
private upgradeLegacyDocProperties(properties?: LegacyDocProperties) {
if (!properties) {
return {};
}
const newProperties: Record<string, string> = {};
for (const [key, info] of Object.entries(properties.system ?? {})) {
if (info?.value !== undefined && info.value !== null) {
newProperties[key] = info.value.toString();
}
}
for (const [key, info] of Object.entries(properties.custom ?? {})) {
if (info?.value !== undefined && info.value !== null) {
newProperties['custom:' + key] = info.value.toString();
}
}
return newProperties;
}
private upgradeLegacyDocPropertyInfoList(
infoList?: LegacyDocPropertyInfoList
) {
if (!infoList) {
return [];
}
const newInfoList: DocCustomPropertyInfo[] = [];
for (const [id, info] of Object.entries(infoList ?? {})) {
if (info?.type) {
newInfoList.push({
id,
name: info.name,
type: info.type,
icon: info.icon,
});
}
}
return newInfoList;
}
private getLegacyDocProperties(id: string) {
return this.workspaceService.workspace.rootYDoc
.getMap<any>('affine:workspace-properties')
.get('pageProperties')
?.get(id)
?.toJSON() as LegacyDocProperties | undefined;
}
private watchLegacyDocProperties(id: string) {
return yjsObserveByPath(
this.workspaceService.workspace.rootYDoc.getMap<any>(
'affine:workspace-properties'
),
`pageProperties.${id}`
).pipe(
switchMap(yjsObserveDeep),
map(
p =>
(p instanceof YAbstractType ? p.toJSON() : p) as
| LegacyDocProperties
| undefined
)
);
}
private getLegacyDocPropertyInfoList() {
return this.workspaceService.workspace.rootYDoc
.getMap<any>('affine:workspace-properties')
.get('schema')
?.get('pageProperties')
?.get('custom')
?.toJSON() as LegacyDocPropertyInfoList | undefined;
}
private watchLegacyDocPropertyInfoList() {
return yjsObserveByPath(
this.workspaceService.workspace.rootYDoc.getMap<any>(
'affine:workspace-properties'
),
'schema.pageProperties.custom'
).pipe(
switchMap(yjsObserveDeep),
map(
p =>
(p instanceof YAbstractType ? p.toJSON() : p) as
| LegacyDocPropertyInfoList
| undefined
)
);
}
private getLegacyDocPropertyInfo(id: string) {
return this.workspaceService.workspace.rootYDoc
.getMap<any>('affine:workspace-properties')
.get('schema')
?.get('pageProperties')
?.get('custom')
?.get(id)
?.toJSON() as LegacyDocPropertyInfo | undefined;
}
}

View File

@@ -1,140 +0,0 @@
import type { DocMode } from '@blocksuite/affine/blocks';
import type { DocMeta } from '@blocksuite/affine/store';
import { distinctUntilChanged, map, switchMap } from 'rxjs';
import { Array as YArray, Map as YMap } from 'yjs';
import { Store } from '../../../framework';
import { yjsObserve, yjsObserveByPath, yjsObserveDeep } from '../../../utils';
import type { WorkspaceService } from '../../workspace';
import type { DocPropertiesStore } from './doc-properties';
export class DocsStore extends Store {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly docPropertiesStore: DocPropertiesStore
) {
super();
}
getBlockSuiteDoc(id: string) {
return this.workspaceService.workspace.docCollection.getDoc(id);
}
createBlockSuiteDoc() {
return this.workspaceService.workspace.docCollection.createDoc();
}
watchDocIds() {
return yjsObserveByPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
switchMap(yjsObserve),
map(meta => {
if (meta instanceof YArray) {
return meta.map(v => v.get('id') as string);
} else {
return [];
}
})
);
}
watchTrashDocIds() {
return yjsObserveByPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
switchMap(yjsObserveDeep),
map(meta => {
if (meta instanceof YArray) {
return meta
.map(v => (v.get('trash') ? v.get('id') : null))
.filter(Boolean) as string[];
} else {
return [];
}
})
);
}
watchDocMeta(id: string) {
let docMetaIndexCache = -1;
return yjsObserveByPath(
this.workspaceService.workspace.rootYDoc.getMap('meta'),
'pages'
).pipe(
switchMap(yjsObserve),
map(meta => {
if (meta instanceof YArray) {
if (docMetaIndexCache >= 0) {
const doc = meta.get(docMetaIndexCache);
if (doc && doc.get('id') === id) {
return doc as YMap<any>;
}
}
// meta is YArray, `for-of` is faster then `for`
let i = 0;
for (const doc of meta) {
if (doc && doc.get('id') === id) {
docMetaIndexCache = i;
return doc as YMap<any>;
}
i++;
}
return null;
} else {
return null;
}
}),
switchMap(yjsObserveDeep),
map(meta => {
if (meta instanceof YMap) {
return meta.toJSON() as Partial<DocMeta>;
} else {
return {};
}
})
);
}
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);
}
setDocPrimaryModeSetting(id: string, mode: DocMode) {
return this.docPropertiesStore.updateDocProperties(id, {
primaryMode: mode,
});
}
getDocPrimaryModeSetting(id: string) {
return this.docPropertiesStore.getDocProperties(id)?.primaryMode;
}
watchDocPrimaryModeSetting(id: string) {
return this.docPropertiesStore.watchDocProperties(id).pipe(
map(config => config?.primaryMode),
distinctUntilChanged((p, c) => p === c)
);
}
waitForDocLoadReady(id: string) {
return this.workspaceService.workspace.engine.doc.waitForReady(id);
}
setPriorityLoad(id: string, priority: number) {
return this.workspaceService.workspace.engine.doc.setPriority(id, priority);
}
markDocSyncStateAsReady(id: string) {
this.workspaceService.workspace.engine.doc.markAsReady(id);
}
}

View File

@@ -1,240 +0,0 @@
import type { FlagInfo } from './types';
const isNotStableBuild = BUILD_CONFIG.appBuildType !== 'stable';
const isDesktopEnvironment = BUILD_CONFIG.isElectron;
const isCanaryBuild = BUILD_CONFIG.appBuildType === 'canary';
const isMobile = BUILD_CONFIG.isMobileEdition;
export const AFFINE_FLAGS = {
enable_ai: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-ai.name',
description:
'com.affine.settings.workspace.experimental-features.enable-ai.description',
hide: true,
configurable: true,
defaultState: true,
},
enable_new_dnd: {
category: 'blocksuite',
bsFlag: 'enable_new_dnd',
displayName:
'com.affine.settings.workspace.experimental-features.enable-new-dnd.name',
description:
'com.affine.settings.workspace.experimental-features.enable-new-dnd.description',
configurable: false,
defaultState: true,
},
enable_database_full_width: {
category: 'blocksuite',
bsFlag: 'enable_database_full_width',
displayName:
'com.affine.settings.workspace.experimental-features.enable-database-full-width.name',
description:
'com.affine.settings.workspace.experimental-features.enable-database-full-width.description',
configurable: isCanaryBuild,
},
enable_database_attachment_note: {
category: 'blocksuite',
bsFlag: 'enable_database_attachment_note',
displayName:
'com.affine.settings.workspace.experimental-features.enable-database-attachment-note.name',
description:
'com.affine.settings.workspace.experimental-features.enable-database-attachment-note.description',
configurable: isNotStableBuild,
},
enable_block_query: {
category: 'blocksuite',
bsFlag: 'enable_block_query',
displayName:
'com.affine.settings.workspace.experimental-features.enable-block-query.name',
description:
'com.affine.settings.workspace.experimental-features.enable-block-query.description',
configurable: isCanaryBuild,
},
enable_synced_doc_block: {
category: 'blocksuite',
bsFlag: 'enable_synced_doc_block',
displayName:
'com.affine.settings.workspace.experimental-features.enable-synced-doc-block.name',
description:
'com.affine.settings.workspace.experimental-features.enable-synced-doc-block.description',
configurable: false,
defaultState: true,
},
enable_edgeless_text: {
category: 'blocksuite',
bsFlag: 'enable_edgeless_text',
displayName:
'com.affine.settings.workspace.experimental-features.enable-edgeless-text.name',
description:
'com.affine.settings.workspace.experimental-features.enable-edgeless-text.description',
configurable: false,
defaultState: true,
},
enable_color_picker: {
category: 'blocksuite',
bsFlag: 'enable_color_picker',
displayName:
'com.affine.settings.workspace.experimental-features.enable-color-picker.name',
description:
'com.affine.settings.workspace.experimental-features.enable-color-picker.description',
configurable: false,
defaultState: true,
},
enable_ai_chat_block: {
category: 'blocksuite',
bsFlag: 'enable_ai_chat_block',
displayName:
'com.affine.settings.workspace.experimental-features.enable-ai-chat-block.name',
description:
'com.affine.settings.workspace.experimental-features.enable-ai-chat-block.description',
configurable: false,
defaultState: true,
},
enable_ai_onboarding: {
category: 'blocksuite',
bsFlag: 'enable_ai_onboarding',
displayName:
'com.affine.settings.workspace.experimental-features.enable-ai-onboarding.name',
description:
'com.affine.settings.workspace.experimental-features.enable-ai-onboarding.description',
configurable: false,
defaultState: true,
},
enable_mind_map_import: {
category: 'blocksuite',
bsFlag: 'enable_mind_map_import',
displayName:
'com.affine.settings.workspace.experimental-features.enable-mind-map-import.name',
description:
'com.affine.settings.workspace.experimental-features.enable-mind-map-import.description',
configurable: false,
defaultState: true,
},
enable_multi_view: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-multi-view.name',
description:
'com.affine.settings.workspace.experimental-features.enable-multi-view.description',
feedbackType: 'discord',
feedbackLink:
'https://discord.com/channels/959027316334407691/1280009690004324405',
configurable: isDesktopEnvironment,
defaultState: isCanaryBuild,
},
enable_emoji_folder_icon: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name',
description:
'com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.description',
feedbackType: 'discord',
feedbackLink:
'https://discord.com/channels/959027316334407691/1280014319865696351/1280014319865696351',
configurable: true,
defaultState: true,
},
enable_emoji_doc_icon: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-emoji-doc-icon.name',
description:
'com.affine.settings.workspace.experimental-features.enable-emoji-doc-icon.description',
feedbackType: 'discord',
feedbackLink:
'https://discord.com/channels/959027316334407691/1280014319865696351',
configurable: true,
defaultState: true,
},
enable_editor_settings: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-editor-settings.name',
description:
'com.affine.settings.workspace.experimental-features.enable-editor-settings.description',
configurable: false,
defaultState: true,
},
enable_offline_mode: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-offline-mode.name',
description:
'com.affine.settings.workspace.experimental-features.enable-offline-mode.description',
configurable: isDesktopEnvironment,
defaultState: false,
},
enable_theme_editor: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-theme-editor.name',
description:
'com.affine.settings.workspace.experimental-features.enable-theme-editor.description',
configurable: isCanaryBuild && !isMobile,
defaultState: isCanaryBuild,
},
enable_local_workspace: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-local-workspace.name',
description:
'com.affine.settings.workspace.experimental-features.enable-local-workspace.description',
configurable: isCanaryBuild,
defaultState: isDesktopEnvironment || isCanaryBuild,
},
enable_advanced_block_visibility: {
category: 'blocksuite',
bsFlag: 'enable_advanced_block_visibility',
displayName:
'com.affine.settings.workspace.experimental-features.enable-advanced-block-visibility.name',
description:
'com.affine.settings.workspace.experimental-features.enable-advanced-block-visibility.description',
configurable: true,
defaultState: false,
},
enable_mobile_keyboard_toolbar: {
category: 'blocksuite',
bsFlag: 'enable_mobile_keyboard_toolbar',
displayName:
'com.affine.settings.workspace.experimental-features.enable-mobile-keyboard-toolbar.name',
description:
'com.affine.settings.workspace.experimental-features.enable-mobile-keyboard-toolbar.description',
configurable: false,
defaultState: isMobile,
},
enable_mobile_linked_doc_menu: {
category: 'blocksuite',
bsFlag: 'enable_mobile_linked_doc_menu',
displayName:
'com.affine.settings.workspace.experimental-features.enable-mobile-linked-doc-menu.name',
description:
'com.affine.settings.workspace.experimental-features.enable-mobile-linked-doc-menu.description',
configurable: false,
defaultState: isMobile,
},
enable_multiple_cloud_servers: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-multiple-cloud-servers.name',
description:
'com.affine.settings.workspace.experimental-features.enable-multiple-cloud-servers.description',
configurable: isDesktopEnvironment,
defaultState: false,
},
enable_mobile_edgeless_editing: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-mobile-edgeless-editing.name',
description:
'com.affine.settings.workspace.experimental-features.enable-mobile-edgeless-editing.description',
configurable: isMobile,
defaultState: false,
},
} satisfies { [key in string]: FlagInfo };
// eslint-disable-next-line no-redeclare
export type AFFINE_FLAGS = typeof AFFINE_FLAGS;

View File

@@ -1,67 +0,0 @@
import { NEVER } from 'rxjs';
import { Entity } from '../../../framework';
import { LiveData } from '../../../livedata';
import type { GlobalStateService } from '../../storage';
import { AFFINE_FLAGS } from '../constant';
import type { FlagInfo } from '../types';
const FLAG_PREFIX = 'affine-flag:';
export type Flag<F extends FlagInfo = FlagInfo> = {
readonly value: F['defaultState'] extends boolean
? boolean
: boolean | undefined;
set: (value: boolean) => void;
// eslint-disable-next-line rxjs/finnish
$: F['defaultState'] extends boolean
? LiveData<boolean>
: LiveData<boolean> | LiveData<boolean | undefined>;
} & F;
export class Flags extends Entity {
private readonly globalState = this.globalStateService.globalState;
constructor(private readonly globalStateService: GlobalStateService) {
super();
Object.entries(AFFINE_FLAGS).forEach(([flagKey, flag]) => {
const configurable = flag.configurable ?? true;
const defaultState =
'defaultState' in flag ? flag.defaultState : undefined;
const getValue = () => {
return configurable
? (this.globalState.get<boolean>(FLAG_PREFIX + flagKey) ??
defaultState)
: defaultState;
};
const item = {
...flag,
get value() {
return getValue();
},
set: (value: boolean) => {
if (!configurable) {
return;
}
this.globalState.set(FLAG_PREFIX + flagKey, value);
},
$: configurable
? LiveData.from<boolean | undefined>(
this.globalState.watch<boolean>(FLAG_PREFIX + flagKey),
undefined
).map(value => value ?? defaultState)
: LiveData.from(NEVER, defaultState),
} as Flag<typeof flag>;
Object.defineProperty(this, flagKey, {
get: () => {
return item;
},
});
});
}
}
export type FlagsExt = Flags & {
[K in keyof AFFINE_FLAGS]: Flag<AFFINE_FLAGS[K]>;
};

View File

@@ -1,13 +0,0 @@
import type { Framework } from '../../framework';
import { GlobalStateService } from '../storage';
import { Flags } from './entities/flags';
import { FeatureFlagService } from './services/feature-flag';
export { AFFINE_FLAGS } from './constant';
export type { Flag } from './entities/flags';
export { FeatureFlagService } from './services/feature-flag';
export type { FlagInfo } from './types';
export function configureFeatureFlagModule(framework: Framework) {
framework.service(FeatureFlagService).entity(Flags, [GlobalStateService]);
}

View File

@@ -1,34 +0,0 @@
import { distinctUntilChanged, skip } from 'rxjs';
import { OnEvent, Service } from '../../../framework';
import { ApplicationStarted } from '../../lifecycle';
import type { Workspace } from '../../workspace';
import { WorkspaceInitialized } from '../../workspace/events';
import { AFFINE_FLAGS } from '../constant';
import { Flags, type FlagsExt } from '../entities/flags';
@OnEvent(WorkspaceInitialized, e => e.setupBlocksuiteEditorFlags)
@OnEvent(ApplicationStarted, e => e.setupRestartListener)
export class FeatureFlagService extends Service {
flags = this.framework.createEntity(Flags) as FlagsExt;
setupBlocksuiteEditorFlags(workspace: Workspace) {
for (const [key, flag] of Object.entries(AFFINE_FLAGS)) {
if (flag.category === 'blocksuite') {
const value = this.flags[key as keyof AFFINE_FLAGS].value;
if (value !== undefined) {
workspace.docCollection.awarenessStore.setFlag(flag.bsFlag, value);
}
}
}
}
setupRestartListener() {
this.flags.enable_ai.$.pipe(distinctUntilChanged(), skip(1)).subscribe(
() => {
// when enable_ai flag changes, reload the page.
window.location.reload();
}
);
}
}

View File

@@ -1,24 +0,0 @@
import type { BlockSuiteFlags } from '@blocksuite/affine/global/types';
type FeedbackType = 'discord' | 'email' | 'github';
export type FlagInfo = {
displayName: string;
description?: string;
configurable?: boolean;
defaultState?: boolean; // default to open and not controlled by user
/**
* hide in the feature flag settings, but still can be controlled by the code
*/
hide?: boolean;
feedbackType?: FeedbackType;
feedbackLink?: string;
} & (
| {
category: 'affine';
}
| {
category: 'blocksuite';
bsFlag: keyof BlockSuiteFlags;
}
);

View File

@@ -1,54 +0,0 @@
import type { DocMode } from '@blocksuite/affine/blocks';
import { Entity } from '../../../framework';
import { LiveData } from '../../../livedata';
import { MemoryMemento } from '../../../storage';
export class GlobalContext extends Entity {
memento = new MemoryMemento();
workspaceId = this.define<string>('workspaceId');
workspaceFlavour = this.define<string>('workspaceFlavour');
serverId = this.define<string>('serverId');
/**
* is in doc page
*/
isDoc = this.define<boolean>('isDoc');
isTrashDoc = this.define<boolean>('isTrashDoc');
docId = this.define<string>('docId');
docMode = this.define<DocMode>('docMode');
/**
* is in collection page
*/
isCollection = this.define<boolean>('isCollection');
collectionId = this.define<string>('collectionId');
/**
* is in trash page
*/
isTrash = this.define<boolean>('isTrash');
/**
* is in tag page
*/
isTag = this.define<boolean>('isTag');
tagId = this.define<string>('tagId');
/**
* is in all docs page
*/
isAllDocs = this.define<boolean>('isAllDocs');
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

@@ -1,9 +0,0 @@
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

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

View File

@@ -1,12 +0,0 @@
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

@@ -1,26 +0,0 @@
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

@@ -1,35 +0,0 @@
export {
GlobalCache,
GlobalSessionState,
GlobalState,
} from './providers/global';
export {
GlobalCacheService,
GlobalSessionStateService,
GlobalStateService,
} from './services/global';
import type { Framework } from '../../framework';
import { MemoryMemento } from '../../storage';
import {
GlobalCache,
GlobalSessionState,
GlobalState,
} from './providers/global';
import {
GlobalCacheService,
GlobalSessionStateService,
GlobalStateService,
} from './services/global';
export const configureGlobalStorageModule = (framework: Framework) => {
framework.service(GlobalStateService, [GlobalState]);
framework.service(GlobalCacheService, [GlobalCache]);
framework.service(GlobalSessionStateService, [GlobalSessionState]);
};
export const configureTestingGlobalStorage = (framework: Framework) => {
framework.impl(GlobalCache, MemoryMemento);
framework.impl(GlobalState, MemoryMemento);
framework.impl(GlobalSessionState, MemoryMemento);
};

View File

@@ -1,28 +0,0 @@
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');
/**
* A memento object that stores session state.
*
* Session state is not persisted, it will be cleared when the application is closed. (thinking about sessionStorage)
*/
export interface GlobalSessionState extends Memento {}
export const GlobalSessionState =
createIdentifier<GlobalSessionState>('GlobalSessionState');

View File

@@ -1,24 +0,0 @@
import { Service } from '../../../framework';
import type {
GlobalCache,
GlobalSessionState,
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();
}
}
export class GlobalSessionStateService extends Service {
constructor(public readonly globalSessionState: GlobalSessionState) {
super();
}
}

View File

@@ -1,31 +0,0 @@
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('local'),
});
expect(workspace.workspace).toBeInstanceOf(Workspace);
expect(workspaceService.list.workspaces$.value.length).toBe(1);
});
});

View File

@@ -1,79 +0,0 @@
import type { Doc as YDoc } from 'yjs';
import { Entity } from '../../../framework';
import { AwarenessEngine, BlobEngine, DocEngine } from '../../../sync';
import { throwIfAborted } from '../../../utils';
import { WorkspaceEngineBeforeStart } from '../events';
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.eventBus.emit(WorkspaceEngineBeforeStart, this);
this.doc.start();
this.awareness.connect(this.workspaceService.workspace.awareness);
if (!BUILD_CONFIG.isMobileEdition) {
// currently, blob synchronization consumes a lot of memory and is temporarily disabled on mobile devices.
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();
this.doc.dispose();
this.awareness.dispose();
}
}

View File

@@ -1,51 +0,0 @@
import { combineLatest, map, of, switchMap } from 'rxjs';
import { Entity } from '../../../framework';
import { LiveData } from '../../../livedata';
import type { WorkspaceMetadata } from '../metadata';
import type { WorkspaceFlavoursService } from '../services/flavours';
export class WorkspaceList extends Entity {
workspaces$ = LiveData.from<WorkspaceMetadata[]>(
this.flavoursService.flavours$.pipe(
switchMap(flavours =>
combineLatest(flavours.map(flavour => flavour.workspaces$)).pipe(
map(workspaces => workspaces.flat())
)
)
),
[]
);
isRevalidating$ = LiveData.from<boolean>(
this.flavoursService.flavours$.pipe(
switchMap(flavours =>
combineLatest(
flavours.map(flavour => flavour.isRevalidating$ ?? of(false))
).pipe(map(isLoadings => isLoadings.some(isLoading => isLoading)))
)
),
false
);
workspace$(id: string) {
return this.workspaces$.map(workspaces =>
workspaces.find(workspace => workspace.id === id)
);
}
constructor(private readonly flavoursService: WorkspaceFlavoursService) {
super();
}
revalidate() {
this.flavoursService.flavours$.value.forEach(provider => {
provider.revalidate?.();
});
}
waitForRevalidation(signal?: AbortSignal) {
this.revalidate();
return this.isRevalidating$.waitFor(isLoading => !isLoading, signal);
}
}

View File

@@ -1,105 +0,0 @@
import { DebugLogger } from '@affine/debug';
import { isEqual } from 'lodash-es';
import { catchError, EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { Entity } from '../../../framework';
import {
effect,
fromPromise,
LiveData,
onComplete,
onStart,
} from '../../../livedata';
import type { WorkspaceMetadata } from '../metadata';
import type { WorkspaceFlavourProvider } from '../providers/flavour';
import type { WorkspaceFlavoursService } from '../services/flavours';
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;
isAdmin?: boolean;
isTeam?: 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,
flavoursService: WorkspaceFlavoursService
) {
super();
this.provider =
flavoursService.flavours$.value.find(
p => p.flavour === this.props.metadata.flavour
) ?? null;
}
private setProfile(info: WorkspaceProfileInfo) {
if (isEqual(this.profile$.value, info)) {
return;
}
this.cache.setProfileCache(this.props.metadata.id, info);
}
revalidate = effect(
exhaustMap(() => {
const provider = this.provider;
if (!provider) {
return EMPTY;
}
return fromPromise(signal =>
provider.getWorkspaceProfile(this.props.metadata.id, signal)
).pipe(
mergeMap(info => {
if (info) {
this.setProfile({ ...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.setProfile({ ...old, name: name ?? old?.name });
});
workspace.avatar$.subscribe(avatar => {
const old = this.profile$.value;
this.setProfile({ ...old, avatar: avatar ?? old?.avatar });
});
}
}

View File

@@ -1,89 +0,0 @@
import { DocCollection } from '@blocksuite/affine/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 { WorkspaceDBService } from '../../db';
import { getAFFiNEWorkspaceSchema } from '../global-schema';
import type { WorkspaceScope } from '../scopes/workspace';
import { WorkspaceEngineService } from '../services/engine';
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,
blobSources: {
main: this.engine.blob,
},
idGenerator: () => nanoid(),
schema: getAFFiNEWorkspaceSchema(),
});
this._docCollection.slots.docCreated.on(id => {
this.engine.doc.markAsReady(id);
});
}
return this._docCollection;
}
get db() {
return this.framework.get(WorkspaceDBService).db;
}
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;
}
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
);
override dispose(): void {
this.docCollection.dispose();
}
}

View File

@@ -1,11 +0,0 @@
import { createEvent } from '../../../framework';
import type { WorkspaceEngine } from '../entities/engine';
import type { Workspace } from '../entities/workspace';
export const WorkspaceEngineBeforeStart = createEvent<WorkspaceEngine>(
'WorkspaceEngineBeforeStart'
);
export const WorkspaceInitialized = createEvent<Workspace>(
'WorkspaceInitialized'
);

View File

@@ -1,15 +0,0 @@
import { AffineSchemas } from '@blocksuite/affine/blocks/schemas';
import { Schema } from '@blocksuite/affine/store';
import { AIChatBlockSchema } from '../../blocksuite/blocks/ai-chat-block/ai-chat-model';
let _schema: Schema | null = null;
export function getAFFiNEWorkspaceSchema() {
if (!_schema) {
_schema = new Schema();
_schema.register([...AffineSchemas, AIChatBlockSchema]);
}
return _schema;
}

View File

@@ -1,75 +0,0 @@
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 | undefined {
return this.wrapped.get<T>(key);
}
watch<T>(key: string) {
return this.wrapped.watch<T>(key);
}
set<T>(key: string, value: T): 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 | undefined {
return this.wrapped.get<T>(key);
}
watch<T>(key: string) {
return this.wrapped.watch<T>(key);
}
set<T>(key: string, value: T): 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

@@ -1,92 +0,0 @@
export type { WorkspaceProfileInfo } from './entities/profile';
export { Workspace } from './entities/workspace';
export { WorkspaceEngineBeforeStart, WorkspaceInitialized } from './events';
export { getAFFiNEWorkspaceSchema } from './global-schema';
export type { WorkspaceMetadata } from './metadata';
export type { WorkspaceOpenOptions } from './open-options';
export type { WorkspaceEngineProvider } from './providers/flavour';
export type { WorkspaceFlavourProvider } from './providers/flavour';
export { WorkspaceFlavoursProvider } 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 { Workspace } from './entities/workspace';
import {
WorkspaceLocalCacheImpl,
WorkspaceLocalStateImpl,
} from './impls/storage';
import { WorkspaceFlavoursProvider } 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 { WorkspaceFlavoursService } from './services/flavours';
import { WorkspaceListService } from './services/list';
import { WorkspaceProfileService } from './services/profile';
import { WorkspaceRepositoryService } from './services/repo';
import { WorkspaceTransformService } from './services/transform';
import { WorkspaceService } from './services/workspace';
import { WorkspacesService } from './services/workspaces';
import { WorkspaceProfileCacheStore } from './stores/profile-cache';
import { TestingWorkspaceFlavoursProvider } from './testing/testing-provider';
export function configureWorkspaceModule(framework: Framework) {
framework
.service(WorkspacesService, [
WorkspaceFlavoursService,
WorkspaceListService,
WorkspaceProfileService,
WorkspaceTransformService,
WorkspaceRepositoryService,
WorkspaceFactoryService,
WorkspaceDestroyService,
])
.service(WorkspaceFlavoursService, [[WorkspaceFlavoursProvider]])
.service(WorkspaceDestroyService, [WorkspaceFlavoursService])
.service(WorkspaceListService)
.entity(WorkspaceList, [WorkspaceFlavoursService])
.service(WorkspaceProfileService)
.store(WorkspaceProfileCacheStore, [GlobalCache])
.entity(WorkspaceProfile, [
WorkspaceProfileCacheStore,
WorkspaceFlavoursService,
])
.service(WorkspaceFactoryService, [WorkspaceFlavoursService])
.service(WorkspaceTransformService, [
WorkspaceFactoryService,
WorkspaceDestroyService,
])
.service(WorkspaceRepositoryService, [
WorkspaceFlavoursService,
WorkspaceProfileService,
])
.scope(WorkspaceScope)
.service(WorkspaceService)
.entity(Workspace, [WorkspaceScope])
.service(WorkspaceEngineService, [WorkspaceScope])
.entity(WorkspaceEngine, [WorkspaceService])
.impl(WorkspaceLocalState, WorkspaceLocalStateImpl, [
WorkspaceService,
GlobalState,
])
.impl(WorkspaceLocalCache, WorkspaceLocalCacheImpl, [
WorkspaceService,
GlobalCache,
]);
}
export function configureTestingWorkspaceProvider(framework: Framework) {
framework.impl(
WorkspaceFlavoursProvider('LOCAL'),
TestingWorkspaceFlavoursProvider,
[GlobalState]
);
}

View File

@@ -1,5 +0,0 @@
export type WorkspaceMetadata = {
id: string;
flavour: string;
initialized?: boolean;
};

View File

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

View File

@@ -1,67 +0,0 @@
import type { DocCollection } from '@blocksuite/affine/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: string;
deleteWorkspace(id: string): Promise<void>;
createWorkspace(
initial: (
docCollection: DocCollection,
blobStorage: BlobStorage,
docStorage: DocStorage
) => Promise<void>
): Promise<WorkspaceMetadata>;
workspaces$: LiveData<WorkspaceMetadata[]>;
/**
* means the workspace list is loading. if it's true, the workspace page will show loading spinner.
*/
isRevalidating$?: LiveData<boolean>;
/**
* revalidate the workspace list.
*
* will be called when user open workspace list, or workspace not found.
*/
revalidate?: () => void;
getWorkspaceProfile(
id: string,
signal?: AbortSignal
): Promise<WorkspaceProfileInfo | undefined>;
getWorkspaceBlob(id: string, blob: string): Promise<Blob | null>;
getEngineProvider(workspaceId: string): WorkspaceEngineProvider;
onWorkspaceInitialized?(workspace: Workspace): void;
}
export interface WorkspaceFlavoursProvider {
workspaceFlavours$: LiveData<WorkspaceFlavourProvider[]>;
}
export const WorkspaceFlavoursProvider =
createIdentifier<WorkspaceFlavoursProvider>('WorkspaceFlavoursProvider');

View File

@@ -1,13 +0,0 @@
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

@@ -1,10 +0,0 @@
import { Scope } from '../../../framework';
import type { WorkspaceOpenOptions } from '../open-options';
import type { WorkspaceEngineProvider } from '../providers/flavour';
export type { DocCollection } from '@blocksuite/affine/store';
export class WorkspaceScope extends Scope<{
openOptions: WorkspaceOpenOptions;
engineProvider: WorkspaceEngineProvider;
}> {}

View File

@@ -1,19 +0,0 @@
import { Service } from '../../../framework';
import type { WorkspaceMetadata } from '../metadata';
import type { WorkspaceFlavoursService } from './flavours';
export class WorkspaceDestroyService extends Service {
constructor(private readonly flavoursService: WorkspaceFlavoursService) {
super();
}
deleteWorkspace = async (metadata: WorkspaceMetadata) => {
const provider = this.flavoursService.flavours$.value.find(
p => p.flavour === metadata.flavour
);
if (!provider) {
throw new Error(`Unknown workspace flavour: ${metadata.flavour}`);
}
return provider.deleteWorkspace(metadata.id);
};
}

View File

@@ -1,25 +0,0 @@
import { Service } from '../../../framework';
import { WorkspaceEngine } from '../entities/engine';
import type { WorkspaceScope } from '../scopes/workspace';
export class WorkspaceEngineService extends Service {
private _engine: WorkspaceEngine | null = null;
get engine() {
if (!this._engine) {
this._engine = this.framework.createEntity(WorkspaceEngine, {
engineProvider: this.workspaceScope.props.engineProvider,
});
}
return this._engine;
}
constructor(private readonly workspaceScope: WorkspaceScope) {
super();
}
override dispose(): void {
this._engine?.dispose();
this._engine = null;
super.dispose();
}
}

View File

@@ -1,35 +0,0 @@
import type { DocCollection } from '@blocksuite/affine/store';
import { Service } from '../../../framework';
import type { BlobStorage, DocStorage } from '../../../sync';
import type { WorkspaceFlavoursService } from './flavours';
export class WorkspaceFactoryService extends Service {
constructor(private readonly flavoursService: WorkspaceFlavoursService) {
super();
}
/**
* create workspace
* @param flavour workspace flavour
* @param initial callback to put initial data to workspace
* @returns workspace id
*/
create = async (
flavour: string,
initial: (
docCollection: DocCollection,
blobStorage: BlobStorage,
docStorage: DocStorage
) => Promise<void> = () => Promise.resolve()
) => {
const provider = this.flavoursService.flavours$.value.find(
x => x.flavour === flavour
);
if (!provider) {
throw new Error(`Unknown workspace flavour: ${flavour}`);
}
const metadata = await provider.createWorkspace(initial);
return metadata;
};
}

View File

@@ -1,18 +0,0 @@
import { combineLatest, map } from 'rxjs';
import { Service } from '../../../framework';
import { LiveData } from '../../../livedata';
import type { WorkspaceFlavoursProvider } from '../providers/flavour';
export class WorkspaceFlavoursService extends Service {
constructor(private readonly providers: WorkspaceFlavoursProvider[]) {
super();
}
flavours$ = LiveData.from(
combineLatest(this.providers.map(p => p.workspaceFlavours$)).pipe(
map(flavours => flavours.flat())
),
[]
);
}

View File

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

View File

@@ -1,21 +0,0 @@
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

@@ -1,116 +0,0 @@
import { DebugLogger } from '@affine/debug';
import { Service } from '../../../framework';
import { ObjectPool } from '../../../utils';
import type { Workspace } from '../entities/workspace';
import { WorkspaceInitialized } from '../events';
import type { WorkspaceOpenOptions } from '../open-options';
import type { WorkspaceEngineProvider } from '../providers/flavour';
import { WorkspaceScope } from '../scopes/workspace';
import type { WorkspaceFlavoursService } from './flavours';
import type { WorkspaceProfileService } from './profile';
import { WorkspaceService } from './workspace';
const logger = new DebugLogger('affine:workspace-repository');
export class WorkspaceRepositoryService extends Service {
constructor(
private readonly flavoursService: WorkspaceFlavoursService,
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?: WorkspaceEngineProvider
): {
workspace: Workspace;
dispose: () => void;
} => {
if (options.isSharedMode) {
const workspace = this.instantiate(options, customProvider);
return {
workspace,
dispose: () => {
workspace.scope.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?: WorkspaceEngineProvider
) {
logger.info(
`open workspace [${openOptions.metadata.flavour}] ${openOptions.metadata.id} `
);
const flavourProvider = this.flavoursService.flavours$.value.find(
p => p.flavour === openOptions.metadata.flavour
);
const provider =
customProvider ??
flavourProvider?.getEngineProvider(openOptions.metadata.id);
if (!provider) {
throw new Error(
`Unknown workspace flavour: ${openOptions.metadata.flavour}`
);
}
const workspaceScope = this.framework.createScope(WorkspaceScope, {
openOptions,
engineProvider: provider,
});
const workspace = workspaceScope.get(WorkspaceService).workspace;
workspace.engine.setRootDoc(workspace.docCollection.doc);
workspace.engine.start();
this.framework.emitEvent(WorkspaceInitialized, workspace);
flavourProvider?.onWorkspaceInitialized?.(workspace);
this.profileRepo
.getProfile(openOptions.metadata)
.syncWithWorkspace(workspace);
return workspace;
}
}

View File

@@ -1,75 +0,0 @@
import { assertEquals } from '@blocksuite/affine/global/utils';
import { applyUpdate } from 'yjs';
import { Service } from '../../../framework';
import { transformWorkspaceDBLocalToCloud } from '../../db';
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
*
* @param accountId - all local user data will be transformed to this account
*/
transformLocalToCloud = async (
local: Workspace,
accountId: string,
flavour: string
): Promise<WorkspaceMetadata> => {
assertEquals(local.flavour, 'local');
const localDocStorage = local.engine.doc.storage.behavior;
const newMetadata = await this.factory.create(
flavour,
async (docCollection, blobStorage, docStorage) => {
const rootDocBinary = await localDocStorage.doc.get(
local.docCollection.doc.guid
);
if (rootDocBinary) {
applyUpdate(docCollection.doc, rootDocBinary);
}
for (const subdoc of docCollection.doc.getSubdocs()) {
const subdocBinary = await localDocStorage.doc.get(subdoc.guid);
if (subdocBinary) {
applyUpdate(subdoc, subdocBinary);
}
}
// transform db
await transformWorkspaceDBLocalToCloud(
local.id,
docCollection.id,
localDocStorage,
docStorage,
accountId
);
const blobList = await local.engine.blob.list();
for (const blobKey of blobList) {
const blob = await local.engine.blob.get(blobKey);
if (blob) {
await blobStorage.set(blobKey, blob);
}
}
}
);
await this.destroy.deleteWorkspace(local.meta);
return newMetadata;
};
}

View File

@@ -1,17 +0,0 @@
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;
}
override dispose(): void {
this._workspace?.dispose();
}
}

View File

@@ -1,53 +0,0 @@
import { Service } from '../../../framework';
import type { WorkspaceMetadata } from '..';
import type { WorkspaceDestroyService } from './destroy';
import type { WorkspaceFactoryService } from './factory';
import type { WorkspaceFlavoursService } from './flavours';
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 flavoursService: WorkspaceFlavoursService,
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.flavoursService.flavours$.value
.find(x => x.flavour === meta.flavour)
?.getWorkspaceBlob(meta.id, blob);
}
}

View File

@@ -1,36 +0,0 @@
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,
isAdmin: info.isAdmin,
isTeam: info.isTeam,
};
})
);
}
setProfileCache(workspaceId: string, info: WorkspaceProfileInfo) {
this.cache.set(WORKSPACE_PROFILE_CACHE_KEY + workspaceId, info);
}
}

View File

@@ -1,145 +0,0 @@
import { DocCollection, nanoid } from '@blocksuite/affine/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,
type DocStorage,
MemoryDocStorage,
} from '../../../sync';
import { MemoryBlobStorage } from '../../../sync/blob/blob';
import type { GlobalState } from '../../storage';
import type { WorkspaceProfileInfo } from '../entities/profile';
import { getAFFiNEWorkspaceSchema } from '../global-schema';
import type { WorkspaceMetadata } from '../metadata';
import type {
WorkspaceEngineProvider,
WorkspaceFlavourProvider,
WorkspaceFlavoursProvider,
} from '../providers/flavour';
class TestingWorkspaceLocalProvider implements WorkspaceFlavourProvider {
flavour = 'local';
store = wrapMemento(this.globalStore, 'testing/');
workspaceListStore = wrapMemento(this.store, 'workspaces/');
docStorage = new MemoryDocStorage(wrapMemento(this.store, 'docs/'));
constructor(private readonly globalStore: GlobalState) {}
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,
docStorage: DocStorage
) => Promise<void>
): Promise<WorkspaceMetadata> {
const id = nanoid();
const meta = { id, flavour: 'local' };
const blobStorage = new MemoryBlobStorage(
wrapMemento(this.store, id + '/blobs/')
);
const docCollection = new DocCollection({
id: id,
idGenerator: () => nanoid(),
schema: getAFFiNEWorkspaceSchema(),
blobSources: {
main: blobStorage,
},
});
// apply initial state
await initial(docCollection, blobStorage, this.docStorage);
// 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]);
docCollection.dispose();
return { id, flavour: '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: getAFFiNEWorkspaceSchema(),
});
applyUpdate(bs.doc, data);
bs.dispose();
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(workspaceId: string): WorkspaceEngineProvider {
return {
getDocStorage: () => {
return this.docStorage;
},
getAwarenessConnections() {
return [];
},
getDocServer() {
return null;
},
getLocalBlobStorage: () => {
return new MemoryBlobStorage(
wrapMemento(this.store, workspaceId + '/blobs/')
);
},
getRemoteBlobStorages() {
return [];
},
};
}
}
export class TestingWorkspaceFlavoursProvider
extends Service
implements WorkspaceFlavoursProvider
{
constructor(private readonly globalStore: GlobalState) {
super();
}
workspaceFlavours$ = new LiveData<WorkspaceFlavourProvider[]>([
new TestingWorkspaceLocalProvider(this.globalStore),
]);
}